feat(cli): auto-update mechanism via npm registry + contextify update#1
Merged
Conversation
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
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.
4 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Adds the classic
update-notifierpattern (npm, yarn, vercel) plus an activecontextify updatesubcommand. Zero new runtime dependencies — usesnode:httpsfor the registry probe and a hand-rolled SemVer comparator.How it works
https://registry.npmjs.org/@furkankoykiran/contextify-cli/latestand writes the result to~/.contextify/update-check.json(chmod 600). The user's command never waits on the network.contextify update— actively shells out to the package manager that owns the running binary (npm/pnpm/yarn— detected fromargv[1]) and installs<pkg>@latest. The running executable is never touched directly; the host package manager handles the swap.contextify update --check— prints the upgrade command without executing it.Design decisions
npm i -gresolves against, so the notifier can never surface a version the user is unable to install. GitHub tags can drift.CI=true,NODE_ENV=test,CONTEXTIFY_NO_UPDATE_CHECK=1,NO_UPDATE_NOTIFIER=1, and--no-update-check. Logs and pipelines stay clean.shipare exempted from the notifier — both are machine-driven (Claude Code hook subprocess, batched flushes) and their stderr can land in IDE / cron logs.semverdep. Inline parser coversvX.Y.ZandX.Y.Z-prereleasewith 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,runUpdatehappy / failure paths).Test plan
pnpm lintpassespnpm typecheckpassespnpm testpasses (135/135, 37 new updater tests)pnpm buildproduces a workingdist/index.jsnode dist/index.js --version→0.4.4;node dist/index.js update --check→ correctly reports0.4.4 is already the latest version(live registry probe).Related
Implements the auto-update workflow tracked alongside the standalone CLI repo split.