feat(cli): tell the user when their sim is out of date - #7420
Conversation
`sim tools execute` shipped in 2.1.5. Someone on 2.1.2 looking for it saw a help listing without it and concluded the CLI could not do it - a missing subcommand is indistinguishable from a feature that was never built, and nothing in the CLI could tell them otherwise. It had no update check, no version negotiation, and no way to learn what "current" is. Once a day, at an interactive terminal, the root `preAction` hook asks `registry.npmjs.org` for the dist-tags of the channel it was installed from and prints one line on stderr when a newer version exists. The request carries the CLI version and nothing else - no key, no workspace, no command - and `SIM_NO_UPDATE_CHECK=1` turns it off. Everything about it fails silently, and it says nothing when stderr is not a terminal, in CI, under `npx`, from a checkout, or to a prerelease install. The last two are not politeness: the repo manifest trails npm permanently by design because the publish workflow bumps the version in-job under `permissions: contents: read` and never commits it back, so without the checkout guard every engineer here would be told daily to upgrade to a version their own tree already contains; and `staging` publishes on every push, so advising a prerelease user would be stale within the hour. Comparison is scoped to one channel, which is what makes "upgrade" to an older stable version structurally impossible rather than merely guarded against. The comparator implements semver precedence including the numeric prerelease rule - `preview.9` precedes `preview.44`, which a string comparison gets backwards. The `preAction` hook is deliberate over a teardown in the entrypoint: commander answers `--help` and `--version` during parsing, so the two latency-sensitive invocations are excluded by construction, and some commands call `process.exit` directly where a `finally` would never run. Timeout is a hard 1s rather than `SIM_TIMEOUT_SECONDS`, which defaults to an hour and governs work the user actually asked for. The check is stamped whether or not it succeeds, so a blackholed registry costs one second a day instead of one per command.
Mutation testing found three tests that could not fail: deleting the `preAction` hook, switching the default writer to stdout, and flipping `comparePrerelease`'s empty-list arm all left the suite green. The stdout one was vacuous because the test helper always injected a writer, so the single safety property this feature claims - never touch stdout - was unprotected. The hook now has a positive test. It asserts registration rather than a resulting request, because the check suppresses itself when running from a checkout, and inside the suite `import.meta.url` IS a checkout: the behavioural path is unreachable there by construction. It is covered directly in check.test.ts and walked against the real registry from a staged global install. Security review: the response body is now read under a 64KB budget instead of buffering whatever a mirror sends, the request refuses to follow redirects, and the registry's answer is parsed before it is persisted, so nothing unvalidated reaches the disk. The reduced User-Agent was a comment; it is now an assertion, so a future "DRY up the user agent" refactor cannot silently start handing npm the user's node version, platform and arch. A configured mirror's own path and query are preserved. `new URL(relative, base)` discards both, so a token-authenticated Artifactory or Nexus base was being rewritten into a request the mirror answers with a 404. Also: one normalisation for every module-path decision (separators AND case, so a Windows or case-insensitive checkout is not read as a global install by one guard and a checkout by the other), the package name is named once rather than spelled in two unrelated places, and `delete process.env.SIM_CONFIG_DIR` in teardown - assigning `undefined` stores the literal string and leaves later tests pointed at a relative `./undefined` directory. Tests: 843 -> 861. Ten mutations applied to verify the new assertions actually fail when the thing they guard is broken; all ten killed. Declined, with reasons: the ~10s lingering-socket exit delay could not be reproduced through the CLI (measured 1.11-1.38s across three runs on node v23.11.0, including a command that only sets exitCode), so no node:https rewrite. `announced` plus `resetUpdateCheck` stays - it is the same shape as the existing resetEnvironmentNotices and resetRenameWarnings seams. The channel type stays rather than collapsing to a boolean, because it is what a decision to notify prerelease users would extend; its docs now say what the code does instead of describing a comparison it never performs.
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
Greptile SummaryThe PR adds a daily, opt-out CLI update notification for interactive stable installations and documents its behavior.
Confidence Score: 5/5The PR appears safe to merge. No blocking failure remains.
|
| Filename | Overview |
|---|---|
| packages/sim-cli/src/update/check.ts | Implements the guarded, cached registry check and accurately limits configured mirror credentials to the selected origin by refusing redirects. |
| packages/sim-cli/src/update/semver.ts | Adds strict semantic-version parsing and precedence comparison for release-channel checks. |
| packages/sim-cli/src/program.ts | Registers the update notifier as a root pre-action hook while leaving parser-handled help and version invocations untouched. |
| apps/docs/content/docs/cli/configuration.mdx | Documents update notices, suppression conditions, caching, private mirrors, and the credential behavior identified in the prior review. |
| packages/sim-cli/README.md | Adds a concise update-check overview and accurately caveats credentials embedded in private registry URLs. |
| packages/sim-cli/src/update/check.test.ts | Covers notification, suppression, caching, request privacy, response limits, mirror URL preservation, and upgrade-command selection. |
Sequence Diagram
sequenceDiagram
participant User
participant CLI
participant Cache as Update cache
participant Registry as npm registry or mirror
User->>CLI: Run an actionable command
CLI->>CLI: Apply TTY, CI, install, and channel guards
CLI->>Cache: Read last-check timestamp
alt Cache is fresh
Cache-->>CLI: Skip update request
else Check is due
CLI->>Registry: Request sim dist-tags
Registry-->>CLI: Return bounded JSON response
CLI->>Cache: Record check result
alt Newer stable version exists
CLI-->>User: Print update notice to stderr
end
end
Reviews (4): Last reviewed commit: "docs(cli): name the update cache path fo..." | Re-trigger Greptile
There was a problem hiding this comment.
All reported issues were addressed across 10 files
Reply with feedback, questions, or to request a fix.
Fix all with cubic | Re-trigger cubic
…rsing Review round 1: five findings, all valid. The privacy statement was too absolute. The request carries no Sim API key, but `npm_config_registry` can point at a private mirror, and a token embedded in that URL is sent with the request - it has to be, or the mirror rejects it. Both docs now say which credentials are involved and where they go: your registry's, to the host you configured, never Sim's. `parseVersion` accepted zero-padded prerelease identifiers. Semver forbids them, and accepting `2.1.3-preview.09` was worse than cosmetic: `09` failed the numeric test and fell through to being an alphanumeric identifier, and alphanumerics outrank every number, so `preview.010` sorted ABOVE `preview.2`. The file's own doc comment already claimed leading zeroes were rejected "the way the specification rejects them" - true of the release triple, not of the prerelease. Now true of both. The `--version`/`--help` test did not hold the guarantee it advertised. It watched for a request and a cache file, but neither ever appears from inside a checkout no matter what runs, because the check suppresses itself there - so it would have passed even if the hook fired, which is the exact regression it claims to prevent. It now swaps a sentinel into commander's registered preAction hooks and asserts the sentinel does not fire while parsing those two, then asserts it DOES fire for a real action command, so the negative assertion means something. No module mocking, which this package bans. The troubleshooting page hardcoded `npm install -g`, which installs a second copy under a different package manager rather than replacing the executable on PATH. It now shows all three, and says the notice already prints the one matching your install - which the notifier has always done. Tests: 861 -> 863. Both new guards mutation-checked: dropping the leading-zero rejection and deleting the hook each fail the suite.
|
@cubic review |
@mzxchandra I have started the AI code review. It will take a few minutes to complete. |
There was a problem hiding this comment.
1 issue found across 10 files
Confidence score: 5/5
- In
packages/sim-cli/src/program.test.ts, assigningundefinedleavesSIM_CONFIG_DIRas the truthy string'undefined', so the test may exercise the wrong configuration path; usedelete process.env.SIM_CONFIG_DIRto reliably unset it.
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="packages/sim-cli/src/program.test.ts">
<violation number="1" location="packages/sim-cli/src/program.test.ts:209">
P3: `process.env.SIM_CONFIG_DIR = undefined` does not unset the variable — Node coerces the assignment to the string 'undefined', which stays truthy for `paths.ts`'s `process.env.SIM_CONFIG_DIR || ...` lookup. Use `delete process.env.SIM_CONFIG_DIR` in the finally block so the environment is restored rather than polluted with a bogus config-dir value.</violation>
</file>
Reply with feedback, questions, or to request a fix.
Fix all with cubic | Re-trigger cubic
…does Review round 2. Three findings, all valid. The previous commit's message claimed it had replaced `process.env.SIM_CONFIG_DIR = undefined` with `delete` in the test teardowns. It had not: it added a comment explaining why the assignment is wrong and left the assignment in place, so the teardown still stored the literal string "undefined". Both files now actually delete it. The same pattern exists in profile.test.ts and configure.test.ts, which predate this branch and are left alone. Two documentation claims were stronger than the implementation. "At most once a day" is only true with a writable `~/.sim`. The pace lives in a timestamp file, so a read-only home in a container - or a `~/.sim` left root-owned by an earlier sudo install - means the pace cannot be remembered and the check runs per command. That was already noted in a code comment; it is now in the docs where users read it, along with the fact that it stays bounded by the same one-second timeout. "The tag it was installed from" described behaviour that does not exist. The check only ever queries `latest`, because prerelease installs return before any request. Both docs now say that plainly instead of implying the CLI can ask about the staging or dev channel.
|
@cubic review |
@mzxchandra I have started the AI code review. It will take a few minutes to complete. |
There was a problem hiding this comment.
1 issue found across 10 files
Confidence score: 5/5
- In
apps/docs/content/docs/cli/configuration.mdx, the cache-file instruction points users withSIM_CONFIG_DIRset to the wrong directory, which can lead them to inspect or update the wrong timestamp file—document theSIM_CONFIG_DIR-aware location.
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="apps/docs/content/docs/cli/configuration.mdx">
<violation number="1" location="apps/docs/content/docs/cli/configuration.mdx:158">
P3: When `SIM_CONFIG_DIR` is set, the CLI stores the update timestamp under that directory, not under `~/.sim`; this instruction sends users with relocated configuration to the wrong cache file. Document the `SIM_CONFIG_DIR` location as an alternative.</violation>
</file>
Reply with feedback, questions, or to request a fix.
Fix all with cubic | Re-trigger cubic
Review round 3. The cache is derived from `configDir()`, so it moves with `SIM_CONFIG_DIR` like the config and credentials files do - but the docs named only the `~/.sim` default, sending anyone with a relocated config dir to a file that is not there.
|
@cubic review |
@mzxchandra I have started the AI code review. It will take a few minutes to complete. |
There was a problem hiding this comment.
1 existing issue remains and no new issues found across 10 files
Confidence score: 5/5
apps/docs/content/docs/cli/configuration.mdxdocumentsupdateCachePath()as placing the cache beside a relocatedSIM_CONFIG_FILEorSIM_CREDENTIALS_FILE, but it currently remains under~/.sim, which could mislead users configuring isolated paths — clarify the documentation to state that the cache followsSIM_CONFIG_FILEonly when applicable, or update the implementation to match the documented behavior.
Requires human review: Auto-approval blocked because this review re-detected 1 unresolved issue already reported by Cubic.
Re-trigger cubic
Summary
sim tools executeshipped in 2.1.5. Someone on 2.1.2 looking for it saw a help listing without it and concluded the CLI could not do it. A missing subcommand is indistinguishable from a feature that was never built, and nothing in the CLI could tell them otherwise: there was no update check, no client/server version negotiation, and no way to learn what "current" is.The notifier (
feat(cli): tell the user when their sim is out of date)Once a day, at an interactive terminal, a root
preActionhook asksregistry.npmjs.orgfor the dist-tags of the channel it was installed from and prints one line on stderr:preAction, not a teardown. Commander answers--helpand--versionduring parsing, so the two latency-sensitive invocations are excluded by construction rather than by a check; and some commands callprocess.exitdirectly, where afinallywould never run.npx, from a checkout, or on a prerelease install.SIM_NO_UPDATE_CHECK=1turns it off.permissions: contents: readand never commits it back — so without the checkout guard every engineer here would be told daily to upgrade to a version their own tree already contains. Andstagingpublishes on every push, so advising a prerelease user would be stale within the hour.preview.9precedespreview.44, which a string comparison gets backwards). Nosemverdependency: the package bundles to a single file, and this is +7.3KB (+1.2%) with no new dep.SIM_TIMEOUT_SECONDS(which defaults to an hour and governs work the user actually asked for). The check is stamped whether or not it succeeds, so a blackholed registry costs one second a day rather than one per command.Review fixes (
fix(cli): close the update-notifier findings from pre-landing review)redirect: 'error'; the registry's answer is parsed before it is persisted, so nothing unvalidated reaches disk.new URL(relative, base)discards both, so a token-authenticated Artifactory/Nexus base was being rewritten into a request the mirror answers with a 404.Privacy
This is the first non-Sim host the CLI has ever contacted, in a tool with no telemetry. Three mitigations, stated up front:
sim-cli/<version>— deliberately not the exportedUSER_AGENT, which also carries node version, platform and arch. This is now an assertion, not a comment, so a future "DRY up the user agent" refactor cannot silently start sending it.Test Coverage
Tests: 843 → 861 (+18). An independent coverage audit measured 65% weighted branch coverage on the first pass; the gaps it and the testing specialist named are what the +18 close.
The more useful number: 10 mutations applied, 10 killed. Each new assertion was verified to actually fail when the thing it guards is broken.
preActionhookcomparePrereleaseempty-list armredirect: 'error'AbortSignal.timeoutUSER_AGENTCIfromCI_VARIABLESBefore these, three tests could not fail: deleting the hook, switching the default writer to stdout, and flipping the empty-prerelease arm all left the suite green. The stdout one was vacuous because the test helper always injected a writer — so the single safety property this feature claims (never touch stdout) was unprotected.
One honest limitation: the hook test asserts registration rather than a resulting request, because the check suppresses itself when running from a checkout — and inside the suite
import.meta.urlis a checkout, so the behavioural path is unreachable there by construction. It is covered directly incheck.test.tsand walked against the real registry from a staged global install (below).Pre-Landing Review
22 findings from 4 specialists + a coverage audit + a plan audit. 14 fixed, 3 declined with reasons, rest were confirmations.
Declined:
exitCode. Nonode:httpsrewrite.announced/resetUpdateCheck(maintainability). Same shape as the existingresetEnvironmentNoticesandresetRenameWarningsseams in this package.Security review found no credential reachable from the request,
registryBaseprotocol-allowlisted and failing closed on embedded userinfo, and terminal-escape injection closed by the anchored version regex (a dist-tag carrying ANSI/OSC bytes fails to parse, so the notice is suppressed rather than printed).Design Review
No frontend files changed — design review skipped.
Eval Results
No prompt-related files changed — evals skipped.
Scope Drift
Scope Check: CLEAN. Intent: tell users when their CLI is stale. Delivered: exactly that, plus its docs. No generated artifact is touched —
check:cli-docsandcheck:cli-apiboth pass unchanged, which is the proof the command tree did not move. That is why this uses an env var rather than a--no-update-checkflag: a root flag would force a docs regen and permanently consume a global-flag slot, for a decision nobody makes per-invocation.Plan Completion
31 done / 4 changed / 0 skipped, independently audited. The four "changed" are deliberate: the Unicode arrow (matching the existing
SIM_DEBUGtrace output inhttp/client.ts), a broader checkout guard than specified,CLI_VERSIONimported rather than injected for the UA header, and the test cases now closed by the +18.The companion
minCliVersionfloor onGET /api/v2/metawas explicitly deferred — it answers a different question ("this deployment needs a newer CLI", for self-hosted installs), and it would have been silent through the incident that prompted this work.Verification Results
No dev server applies to a CLI. Verified by hand against the real npm registry, from a staged global install driven under a pty (a checkout is deliberately suppressed, so this is the only way to exercise the real path):
2.1.5; cache written, dir 0700192.0.2.1)latestVersion: nullstampedCI=1/SIM_NO_UPDATE_CHECK=1/--version/--help/ bare group command2.1.3-preview.44.1)dist/index.jsGates:
bun run test(861 passed, 1 skipped),type-check,lint:check,build(a real gate —--reject-unresolved),check:audits(45 audits),check:cli-docsunchanged.Documentation
packages/sim-cli/README.md—SIM_NO_UPDATE_CHECKrow, what the check sends, and a pointer to the canonical list.apps/docs/content/docs/cli/configuration.mdx— the canonical "Update notices" section: env row, exactly what is sent, and every case where the notice stays quiet.apps/docs/content/docs/cli/troubleshooting.mdx— "a documented command is missing" (the incident's own symptom) and "an update notice appears in output I am parsing".Both
.mdxfiles are hand-writtenGUIDE_PAGES, not generated output.Test plan
bun run testinpackages/sim-cli— 861 passed, 1 skippedbun run type-check,bun run lint:check,bun run buildbun run check:audits— 45 auditsbun run check:cli-docs/check:cli-api— pass unchanged🤖 Generated with Claude Code