Skip to content

feat(cli): auto-update mechanism via npm registry + contextify update#1

Merged
furkankoykiran merged 6 commits into
mainfrom
feat/auto-update-mechanism
May 17, 2026
Merged

feat(cli): auto-update mechanism via npm registry + contextify update#1
furkankoykiran merged 6 commits into
mainfrom
feat/auto-update-mechanism

Conversation

@furkankoykiran
Copy link
Copy Markdown
Owner

Summary

Adds the classic update-notifier pattern (npm, yarn, vercel) plus an active contextify update subcommand. Zero new runtime dependencies — uses node:https for the registry probe and a hand-rolled SemVer comparator.

How it works

  1. Background probe — once per 24h, a detached subprocess hits https://registry.npmjs.org/@furkankoykiran/contextify-cli/latest and writes the result to ~/.contextify/update-check.json (chmod 600). The user's command never waits on the network.
  2. Notifier — after every foreground command, the cache is read; if a newer version is published, a one-line banner is printed on stderr.
  3. contextify update — actively shells out to the package manager that owns the running binary (npm/pnpm/yarn — detected from argv[1]) and installs <pkg>@latest. The running executable is never touched directly; the host package manager handles the swap.
  4. contextify update --check — prints the upgrade command without executing it.

Design decisions

  • Source of truth = npm registry, not GitHub Releases. The registry is what npm i -g resolves against, so the notifier can never surface a version the user is unable to install. GitHub tags can drift.
  • Notifier is gated by: stderr-is-TTY, CI=true, NODE_ENV=test, CONTEXTIFY_NO_UPDATE_CHECK=1, NO_UPDATE_NOTIFIER=1, and --no-update-check. Logs and pipelines stay clean.
  • Hooks and ship are exempted from the notifier — both are machine-driven (Claude Code hook subprocess, batched flushes) and their stderr can land in IDE / cron logs.
  • No semver dep. Inline parser covers vX.Y.Z and X.Y.Z-prerelease with spec-correct ordering, including numeric-vs-alphanumeric prerelease ordering.

Files

  • src/updater.ts — registry fetch, cache I/O, semver compare, notifier, runUpdate, detached probe entry point.
  • src/commands/update.ts — thin subcommand wrapper.
  • src/index.ts — dispatch + post-command notifier + help text + env doc.
  • src/updater.test.ts — 37 unit tests (semver edge cases, cache round-trip, probe scheduling, notifier gating, package-manager detection, runUpdate happy / failure paths).

Test plan

  • pnpm lint passes
  • pnpm typecheck passes
  • pnpm test passes (135/135, 37 new updater tests)
  • pnpm build produces a working dist/index.js
  • Manual sanity check: node dist/index.js --version0.4.4; node dist/index.js update --check → correctly reports 0.4.4 is already the latest version (live registry probe).

Related

Implements the auto-update workflow tracked alongside the standalone CLI repo split.

Adds the classic update-notifier pattern (npm, yarn, vercel) plus an active
`contextify update` subcommand, with zero new runtime dependencies.

How it works
------------
- Once per 24h, a detached background subprocess hits
  https://registry.npmjs.org/<pkg>/latest and caches the result to
  ~/.contextify/update-check.json (chmod 600). The user's command never
  waits on the network.
- On every foreground command, the notifier reads the cache and prints a
  one-line banner on stderr when a newer version is available. The banner
  is gated by stderr-is-TTY plus CI / NODE_ENV=test / CONTEXTIFY_NO_UPDATE_CHECK
  / NO_UPDATE_NOTIFIER opt-outs so the notifier never pollutes logs or
  machine-driven pipelines.
- `contextify update` shells out to the package manager that owns the
  running binary (npm/pnpm/yarn — detected from argv[1] path), refreshes
  the cache on success, and never touches the running executable directly.
- `contextify update --check` reports the upgrade command without running it.

Why npm registry (not GitHub Releases): the registry is what `npm i -g`
resolves against, so the notifier never surfaces a version the user cannot
actually install. SemVer comparison is implemented inline to keep the CLI
zero-deps.

Notifier is suppressed for `hooks` and `ship` (machine-driven, output may
end up in IDE / cron logs) and for `update` itself.
Independent /codex review of the auto-update mechanism surfaced 7 actionable
findings, all addressed here. The diff also adds 13 new tests covering each
regression path so they cannot silently come back.

- [P2] spawn 'error' event handler — child_process.spawn surfaces OS-level
  failures (EMFILE, EAGAIN, Windows ENOENT) via the child's 'error' event,
  NOT the surrounding try/catch. Without a listener, the optional background
  probe could crash the foreground command — directly violating the feature's
  "never block / never crash" invariant. Attach a no-op handler and document.

- [P2] negative-cache failed probes — on persistent registry failure
  (corporate proxy, DNS, offline), runProbe used to leave checkedAt unchanged,
  so every subsequent invocation respawned a detached probe and re-hit the
  failing network. Now: write {latest: '', checkedAt: now}, and shouldSpawnProbe
  uses PROBE_BACKOFF_MS (1h) instead of the full 24h interval. Previously-known
  latest is preserved so the notifier doesn't disappear during transient outages.

- [P2] runUpdate defers to the package manager on registry-probe failure —
  before, a failed direct fetch to registry.npmjs.org made `contextify update`
  exit 1, blocking the install even when the user's npm/pnpm/yarn was
  perfectly configured for a corporate mirror or private registry. Now we let
  the package manager handle registry access; we just lose the version-diff
  messaging. `update --check` likewise prints the install command on probe
  failure instead of giving up.

- [P2] bounded registry response body — added MAX_RESPONSE_BYTES (256KB) cap
  so a misconfigured proxy or malicious response can't make the foreground
  process buffer unbounded data.

- [P2] case-insensitive package-manager path detection — Windows Yarn classic
  installs under %LOCALAPPDATA%\Yarn\..., which previously fell through to
  `npm install -g` because the heuristic was case-sensitive. Lowercase the
  normalized path before matching.

- [P2] stripGlobalFlag only consumes flags before the subcommand and before
  `--` — fixes the regression where `contextify wrap -- mycmd --no-update-check`
  would silently strip the flag intended for the wrapped child process.
  Renamed from stripFlag for clarity; exported for unit testing.

- [P3] SemVer parser rejects leading-zero numeric identifiers per SemVer
  2.0.0 §2 / §9 — e.g. `01.2.3` and `1.0.0-alpha.01` are now correctly
  rejected. Pure alphanumeric prerelease IDs like `0a` still parse.

Tests added (13):
  - parseVersion rejects leading-zero numeric ids; accepts build metadata
  - shouldSpawnProbe honors PROBE_BACKOFF_MS on negative cache, but still
    respects the 24h interval on success
  - runUpdate defers to the package manager when fetchLatest returns null
  - runUpdate --check prints the install command on probe failure
  - detectPackageManager handles Windows-cased "Yarn"/"PNPM" paths
  - stripGlobalFlag: strips before subcommand, returns false when absent,
    leaves post-subcommand and post-`--` flags alone, handles repeats
  - runProbe negative-cache shape + transient-outage preservation + live
    smoke (resolves without throwing under either online or offline CI)
Round-2 /codex review of the round-1 hardening surfaced two regressions in
the fixes themselves:

- [P3] Decouple failure tracking from checkedAt — round-1 wrote a fresh
  `checkedAt` on probe failure while preserving the previous `latest`, which
  meant `shouldSpawnProbe` (gated on `latest === ''`) treated the failure as a
  successful check and waited the full 24h interval before retrying. A
  transient outage right at the scheduled recheck could therefore suppress
  rechecks for a full day instead of backing off for PROBE_BACKOFF_MS (1h).

  Fix: add an explicit `failedAt?: number` field to UpdateCache. On probe
  failure we now preserve both `latest` AND `checkedAt`, and only update
  `failedAt`. shouldSpawnProbe honors the short PROBE_BACKOFF_MS whenever
  failedAt is present, regardless of how stale `latest` might be — so a
  failure mid-24h-window correctly retries in 1h.

- [P2] Require --force when registry probe fails — round-1's "defer to the
  package manager" fallback would blindly invoke `npm install -g pkg@latest`
  when our direct probe to registry.npmjs.org failed. For users on a local
  build, a prerelease, or behind a lagging corporate mirror, that silently
  DOWNGRADES the CLI (because the registry's `@latest` dist-tag is behind
  their current version). The non-fallback path gates on isUpdateAvailable
  precisely to prevent this.

  Fix: when fetchLatest === null, print the install command and exit 1.
  Pass --force to actually run it. --check still prints the command. Help
  text and parseUpdateArgs updated accordingly.

Tests updated/added:
  - failedAt round-trips through writeCache/readCache
  - shouldSpawnProbe: PROBE_BACKOFF_MS gated on failedAt, NOT latest === ''
  - shouldSpawnProbe: failedAt overrides a still-fresh checkedAt
    (transient-outage scenario)
  - runUpdate: without --force, probe failure exits 1 + prints --force hint
  - runUpdate: with --force, defers to the package manager as before
  - failed probe preserves checkedAt AND latest (not just latest)

  Removed the now-obsolete "treats latest='' as backoff signal" assertion.

Total: 150/150 tests pass (was 148). Build + lint + typecheck clean.
Round-3 review surfaced two more silent-downgrade / silent-banner-leak paths
in the round-1+2 hardening:

- [P2] Pin the install spec to the probed version, not @latest — even on the
  verified-success path, runUpdate was building `<pm> install -g pkg@latest`.
  In environments where npm/pnpm/yarn points at a lagging private mirror
  while registry.npmjs.org is reachable, we'd verify "npmjs has 0.5.0,
  upgrade is available" and then ask the mirror for `@latest`, which can
  return 0.4.x and silently downgrade the user — the exact regression the
  --force gate was supposed to prevent.

  Fix: updateCommandFor() now takes an optional version arg; the verified
  success path pins `${PACKAGE_NAME}@${latest}` (the exact version we just
  probed), the fallback path keeps `@latest` (we don't know the target).

- [P2] Treat stderr.isTTY !== true as non-interactive — Node only sets
  isTTY to `true` for real ttys. For redirected/piped streams it's
  `undefined`, NOT `false`. The previous check `typeof isTTY === 'boolean'
  && !isTTY` only suppressed the banner for explicit `false`, so any
  non-tty stream with undefined isTTY still got the banner — exactly the
  log/pipeline pollution the check was meant to prevent.

  Fix: `stderr.isTTY !== true` (anything not explicit-true is non-tty).

Tests:
  - Pin assertions for verified success now expect `@0.5.0`, fallback path
    still expects `@latest`.
  - New: updateCommandFor pins to an explicit version when given.
  - New: notifier suppresses banner when stderr.isTTY is undefined.
  Total 152/152 (was 150).
Round-4 /codex review flagged the runProbe smoke test as making a real
HTTP request to registry.npmjs.org, making the suite slow/flaky in offline
or firewalled CI.

Fix: add `fetchLatest?: () => Promise<string | null>` to UpdaterOptions
(matches the existing pattern in RunUpdateOptions). runProbe now uses the
injected fetcher when provided, falling back to the real fetchLatestVersion
in production. The detached-child probe entry point passes no fetcher, so
production behavior is unchanged.

Tests:
  - Replaced the live-network smoke test with three deterministic cases:
    1. successful probe writes latest + checkedAt, no failedAt
    2. failed probe with prior cache preserves latest/checkedAt, adds failedAt
    3. first-ever failed probe writes empty latest + checkedAt=0 + failedAt

Total 154/154 (was 152), no live network calls anywhere in the test suite.
Round-5 /codex review surfaced: child_process.spawn() without `shell: true`
cannot launch `.cmd` script files by their bare name. On Windows, npm,
pnpm, and yarn all ship as `.cmd` shim scripts under `%APPDATA%\npm\` (or
equivalent), so `contextify update` would otherwise crash with ENOENT
when trying to spawn the chosen package manager.

Fix: pass `shell: process.platform === 'win32'` to spawn(). This delegates
to cmd.exe on Windows, which knows how to resolve and execute .cmd shims.

`shell: true` is safe here because every argument we pass is either a
hardcoded constant (the package name, the manager subcommand, the global
flag) or a version string we already parsed through the strict SemVer
regex that rejects everything but X.Y.Z(-prerelease)(+build) — no shell
metacharacter injection surface. Documented inline.

Unix behavior unchanged.
@furkankoykiran furkankoykiran merged commit 9e7eeed into main May 17, 2026
2 checks passed
furkankoykiran added a commit that referenced this pull request May 17, 2026
Backwards-compatible new feature: auto-update mechanism (npm-registry-backed
notifier + contextify update subcommand). See #1 for the full hardening pass.
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