Skip to content

fix(update): make the auto-updater reachable from cron and loud when it fails (v1.55.4) - #191

Merged
paulkr merged 2 commits into
mainfrom
fix/auto-update-npm-resolution
Aug 27, 2026
Merged

fix(update): make the auto-updater reachable from cron and loud when it fails (v1.55.4)#191
paulkr merged 2 commits into
mainfrom
fix/auto-update-npm-resolution

Conversation

@paulkr

@paulkr paulkr commented Aug 27, 2026

Copy link
Copy Markdown
Member

The bug

spawn('npm', …) resolves through PATH — and the runs that need updating most are the ones with the emptiest PATH.

one sync schedule installs a crontab line. cron hands that job PATH=/usr/bin:/bin, where an nvm / Volta / fnm / Homebrew npm does not exist. So every auto-update attempt from a scheduled run died with npm: command not found.

It died silently. Because the spawn used shell: true, the shell started fine and merely exited 127 — which fires no 'error' event, the only thing the old code listened for:

const child = spawn('npm', ['install', '-g', `@withone/cli@${targetVersion}`], {
  detached: true, stdio: 'ignore', shell: true,
});
child.on('error', () => { /* never fires on exit 127 */ });

Nothing was logged, counted, retried differently, or shown. The installs that run most often were the ones that could never update themselves.

What it cost

A user's scheduled install has been pinned to v1.48.0 since Aug 13, re-running the same doomed update every 5 minutes, while their interactive installs on the same account tracked latest normally.

Because it was frozen below v1.55.3, it never received the telemetry uuid dedupe fix either — so it re-sent its entire rollup queue on every run:

events real rollups amplification
That one install, 14d 1,547,633 ~1,240 ~1,250x
All CLI rollups, 14d 2,725,178 41,315 66x

That single frozen install is 57% of all CLI Usage Rollup events we ingested in 14 days. Peak day: 244,007 events from 94 real rollups. Client-side fixes cannot reach a client that cannot update — this is the bug that makes every other fix undeliverable.

The fix

  • Resolve npm next to process.execPath instead of trusting PATH. npm sits beside node in every standard layout (nvm, Volta, fnm, Homebrew, the official installer, the Docker node images), so execPath is a reliable anchor. Bare-npm PATH lookup stays as the fallback.
  • Prepend node's own directory to the child's PATH. An absolute npm is not sufficient alone — the npm shim's shebang is #!/usr/bin/env node, so a cron PATH without node fails one layer further down, just as quietly.
  • Drop shell: true on POSIX, so a missing binary raises a real ENOENT 'error' event rather than a silent 127. Windows keeps the shell (Node refuses to run a .cmd without one) with the path quoted.
  • Check the exit code, not just 'error'. A non-zero exit now settles the lock and counts as a failure.
  • Account for abandoned installs across runs. The detached child usually outlives the parent, so its exit code is often unobservable — but the lock it left behind is not. A stale lock plus a version that did not move means the install never landed. The version we are running, not the lock, is the source of truth, so a successful install that also abandoned its lock isn't miscounted as a failure.
  • Stop failing silently. npm's own output is appended to ~/.one/auto-update.log, and after 3 consecutive failed attempts a warning goes to stderr — never stdout, so it can't corrupt --agent JSON or a piped command — repeated at most once a day.

Same npm resolution applied to the interactive one update, which had the identical bug in any minimal-PATH environment.

Tests

23 new tests in src/commands/update.test.ts, including an end-to-end pair that runs under a real PATH=/usr/bin:/bin:

✔ reproduces the original failure: bare `npm` is invisible to cron
    → exit ≠ 0, and sawErrorEvent === false   (the heart of the silence)
✔ succeeds with the resolved binary and patched PATH
    → exit 0

Plus npm resolution, PATH construction (including Windows key casing — emitting both Path and PATH leaves the winner undefined), failure accounting, and notice throttling.

Suite: 501 tests, 495 pass, 6 fail. Those 6 are pre-existing on main (verified by stashing: main alone is 478 tests / 6 fail) — a macOS-only /private/var vs /var symlink assertion in config.test.ts. Zero regressions, and CI runs ubuntu/windows where they don't occur.

Docs

README.md (auto-update section) and src/lib/guide-content.ts (scheduled syncs) both updated to describe cron-safe updating and where to look when an install fails.

Follow-ups (not in this PR)

  • The frozen 1.48.0 install needs a manual npm i -g @withone/cli@latest — this fix can't reach it, by definition.
  • Worth considering an ingestion-side guard in PostHog for CLI Usage Rollup from pre-1.55.3 clients; it would have saved ~1.55M events this fortnight regardless of who upgrades.

🤖 Generated with Claude Code

https://claude.ai/code/session_01KNxvXMWirX42XQKX2y9daC

paulkr and others added 2 commits August 27, 2026 16:18
…it fails (v1.55.4)

`spawn('npm', …)` resolves through PATH, and the runs that need updating most
are the ones with the emptiest PATH. `one sync schedule` writes a crontab line;
cron hands that job `PATH=/usr/bin:/bin`, where an nvm / Volta / fnm / Homebrew
npm does not appear. The spawn died as "npm: command not found" — and because it
ran under `shell: true`, the SHELL started fine and merely exited 127, so the
`'error'` handler never fired and nothing was logged, counted, or surfaced.

Net effect: the installs that run most often were the ones that could never
update themselves. One scheduled install stayed on v1.48.0 for two weeks,
re-running the same doomed update every 5 minutes, while its owner's interactive
installs tracked latest normally. Because it was frozen below v1.55.3 it also
never got the telemetry dedupe fix, and re-sent its rollup queue on every run —
1.55M duplicate PostHog events, 57% of all CLI rollup volume over 14 days.

- Resolve npm next to `process.execPath` (npm sits beside node in every standard
  layout) instead of trusting PATH.
- Prepend node's own directory to the child's PATH: an absolute npm is not
  enough on its own, since the npm shim's shebang is `#!/usr/bin/env node` and
  fails one layer further down otherwise.
- Drop `shell: true` on POSIX, so a missing binary raises a real ENOENT `'error'`
  event rather than a silent 127. Windows keeps it (Node won't run a `.cmd`
  without a shell) with the path quoted.
- Check the exit code, not just `'error'`: a non-zero exit now settles the lock
  and counts as a failure.
- Account for abandoned installs across runs. The detached child usually
  outlives us, so its exit code is unobservable — but the lock it left behind is
  not. A stale lock plus a version that did not move means the install never
  landed. The version we are running, not the lock, is the truth, so a
  successful install that also abandoned its lock is not miscounted.
- Stop failing silently: npm's output goes to `~/.one/auto-update.log`, and
  after 3 consecutive failures a warning goes to stderr (never stdout, so it
  cannot corrupt `--agent` JSON), repeated at most once a day.

Same npm resolution applied to the interactive `one update`, which had the
identical bug in any minimal-PATH environment.

Tests cover npm resolution, PATH construction (including Windows key casing,
where emitting both `Path` and `PATH` leaves the winner undefined), failure
accounting, notice throttling, and an end-to-end pair that reproduces the
original silent 127 under `PATH=/usr/bin:/bin` and proves the fix exits 0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KNxvXMWirX42XQKX2y9daC
The PATH-prepend test hardcoded a POSIX `:` separator, which is a single
opaque entry on Windows where the delimiter is `;` — so the assertion failed
on all three Windows matrix jobs while the implementation (which uses
path.delimiter) was correct.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KNxvXMWirX42XQKX2y9daC
@paulkr
paulkr merged commit 44e4f58 into main Aug 27, 2026
7 checks passed
@paulkr
paulkr deleted the fix/auto-update-npm-resolution branch August 27, 2026 20:39
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant