Skip to content

Commit 34e522a

Browse files
feat(cli): tell the user when their sim is out of date (#7420)
* feat(cli): tell the user when their sim is out of date `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. * fix(cli): close the update-notifier findings from pre-landing review 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. * fix(cli): correct the update-notifier privacy claim and prerelease parsing 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. * fix(cli): make the update-notifier docs match what the code actually 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. * docs(cli): name the update cache path for relocated config dirs 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. * fix(cli): harden and simplify update checks * test(cli): isolate update checks from CI markers * fix(cli): tighten update check eligibility --------- Co-authored-by: Waleed Latif <walif6@gmail.com>
1 parent c261ac1 commit 34e522a

9 files changed

Lines changed: 1586 additions & 7 deletions

File tree

apps/docs/content/docs/cli/configuration.mdx

Lines changed: 69 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -114,15 +114,80 @@ shared profile cannot also set its own endpoint or API key.
114114
| `SIM_API_KEY` | API key — skips `sim login` entirely |
115115
| `SIM_WORKSPACE` | Workspace to target |
116116
| `SIM_OUTPUT` | Output format |
117-
| `SIM_CONFIG_DIR` | Relocate both files away from `~/.sim` |
117+
| `SIM_CONFIG_DIR` | Relocate the config directory and update cache; file-specific overrides below still win |
118118
| `SIM_CONFIG_FILE` | Relocate only the config file |
119119
| `SIM_CREDENTIALS_FILE` | Relocate only the credentials file |
120120
| `SIM_TIMEOUT_SECONDS` | Per-request timeout; `0` waits indefinitely. Defaults to `3600`, above every timeout the server itself applies |
121121
| `SIM_DEBUG` | Trace each request's method, URL, status and duration to stderr |
122+
| `SIM_NO_UPDATE_CHECK` | Turn off update checks |
122123

123-
Node ignores `HTTPS_PROXY` unless you also set `NODE_USE_ENV_PROXY=1`, and only
124-
from Node 22.21 and 24.5. The CLI warns when a proxy is configured but will not
125-
be used.
124+
## Update notices
125+
126+
On eligible invocations, the CLI uses a daily cache before asking
127+
`registry.npmjs.org` what is published under the `latest` tag. Prerelease
128+
installs are skipped entirely, so a `-preview` or `-dev` build is never told to
129+
upgrade. When a newer one exists, it prints a single line on stderr naming both
130+
versions and the command that upgrades:
131+
132+
```
133+
Update available: sim 2.1.2 → 2.1.5. Run: npm install -g sim@latest
134+
```
135+
136+
Apart from the configured registry URL, the request identifies only the CLI
137+
version — no Sim API key, workspace, or command — and it never follows a
138+
redirect away from the registry it asked.
139+
140+
One caveat worth stating plainly: if you point `npm_config_registry` at a
141+
private mirror, the check goes to that mirror instead of npm. Query-string
142+
credentials (an Artifactory or Nexus `?token=…`, for example) are preserved and
143+
sent as part of the configured registry request — they have to be, or the
144+
mirror would reject it. As with other registry traffic, configured proxies or
145+
TLS inspection can observe what that network setup permits. A registry URL
146+
containing username/password userinfo, such as
147+
`https://user:password@registry.example`, is rejected and no update check is
148+
made.
149+
150+
An empty or whitespace-only `npm_config_registry` is treated as unset, so the
151+
public registry remains the default. Non-empty malformed and non-HTTP(S) values
152+
disable the update check rather than making an unexpected public request.
153+
154+
The notice is skipped entirely when:
155+
156+
- `SIM_NO_UPDATE_CHECK` is set to anything but `0` or `false`
157+
- stderr is not a terminal, so redirected and piped output is never affected
158+
- a CI environment variable is present (`CI`, `GITHUB_ACTIONS`, `JENKINS_URL`,
159+
`TEAMCITY_VERSION`, `BUILDKITE`)
160+
- the CLI is running under `npm exec` or `npx`, which may use a project-local or
161+
ephemeral package where global-install advice is inappropriate
162+
- the CLI is running from a checkout of the sim repository, whose version
163+
deliberately trails the published one
164+
- the installed version is a prerelease
165+
166+
The daily pace comes from a timestamp in the config directory's
167+
`update-check.json`: `~/.sim/update-check.json` by default, or under
168+
`SIM_CONFIG_DIR` when that is set. `SIM_CONFIG_FILE` and
169+
`SIM_CREDENTIALS_FILE` do not move the cache, so it may not sit beside a file
170+
relocated with either of those variables.
171+
172+
This throttle is best-effort across processes. Two commands that start together
173+
can both see a stale cache and check. Cache replacement is atomic, so either
174+
complete write can win without leaving a partially interleaved file. If the
175+
cache cannot be written — for example, because the config directory is
176+
read-only — every eligible invocation attempts a check because there is no
177+
timestamp to reuse.
178+
179+
The registry check has a one-second deadline. On expiry, the CLI terminates its
180+
short-lived request process so stalled DNS, connection, or response work cannot
181+
remain active and delay the command. `SIM_NO_UPDATE_CHECK=1` still turns the
182+
check off.
183+
184+
The command the notice prints matches how Sim was installed — `npm install -g`,
185+
`pnpm add -g`, `bun add -g`, or `yarn global add` — so running it updates the
186+
executable already on your `PATH` rather than installing a second copy under a
187+
different package manager.
188+
189+
Node's `fetch` uses `HTTP(S)_PROXY` when opted in with `NODE_USE_ENV_PROXY=1`
190+
(Node 22.21+ or 24.0+) or `--use-env-proxy` (Node 22.21+ or 24.5+).
126191

127192
For CI, set `SIM_API_KEY` and `SIM_WORKSPACE` and nothing needs to touch the
128193
filesystem at all.

apps/docs/content/docs/cli/troubleshooting.mdx

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ title: Troubleshooting
33
description: The failures whose cause is not obvious from the error message
44
---
55

6+
import { Tab, Tabs } from 'fumadocs-ui/components/tabs'
7+
68
Errors print one line to stderr, prefixed `Error:`, and exit `1` — except
79
`sim whoami`, which exits `2` when it could not reach the API to check at all.
810
Most say what to do next; the cases below are the ones that do not.
@@ -85,6 +87,50 @@ editing the file by hand:
8587
sim --output table configure --set-output json
8688
```
8789

90+
## A command is missing that the documentation describes
91+
92+
The docs track the current release, so a command that exists here and not in
93+
`sim --help` usually means the installed CLI is older than the feature. Compare
94+
`sim --version` against the published version and upgrade:
95+
96+
```bash
97+
sim --version
98+
```
99+
100+
Then upgrade with the package manager you installed it with — using a different
101+
one installs a second copy instead of replacing the executable on your `PATH`:
102+
103+
<Tabs items={['npm', 'pnpm', 'bun']}>
104+
<Tab value="npm">
105+
```bash
106+
npm install -g sim@latest
107+
```
108+
</Tab>
109+
<Tab value="pnpm">
110+
```bash
111+
pnpm add -g sim@latest
112+
```
113+
</Tab>
114+
<Tab value="bun">
115+
```bash
116+
bun add -g sim@latest
117+
```
118+
</Tab>
119+
</Tabs>
120+
121+
The CLI can also tell you this through a cached daily check on eligible
122+
invocations, and the command it prints already matches your installation. It
123+
stays quiet when stderr is redirected, in CI, and under `npm exec` or `npx`.
124+
125+
## An update notice appears in output I am parsing
126+
127+
It should not: the notice is written to stderr, never stdout, so `--output json`
128+
piped to `jq` is unaffected. If something merges the two streams, silence it:
129+
130+
```bash
131+
export SIM_NO_UPDATE_CHECK=1
132+
```
133+
88134
## Anything else
89135

90136
An unexpected error prints a stack trace. That is a bug in the CLI — please

packages/sim-cli/README.md

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -256,9 +256,26 @@ The main environment variables are:
256256
| `SIM_API_KEY` | API key, usually for CI |
257257
| `SIM_WORKSPACE` | Workspace to target |
258258
| `SIM_OUTPUT` | `table`, `json`, `yaml`, or `text` |
259-
| `SIM_CONFIG_DIR` | Directory containing CLI config and credentials |
259+
| `SIM_CONFIG_DIR` | Base directory for CLI config, credentials, and the update cache |
260260
| `SIM_TIMEOUT_SECONDS` | Per-request timeout; `0` waits indefinitely |
261261
| `SIM_DEBUG` | Print request diagnostics to stderr |
262+
| `SIM_NO_UPDATE_CHECK` | Turn off the update notice |
263+
264+
On eligible interactive invocations, `sim` uses a daily cache before asking
265+
`registry.npmjs.org` what is published under the `latest` tag and prints one
266+
line on stderr when a newer version exists. Prerelease installs are skipped
267+
entirely. The cache lives in `~/.sim` by default and follows `SIM_CONFIG_DIR`;
268+
without a writable cache, each eligible invocation checks again. Concurrent
269+
invocations can also perform duplicate checks. The registry request has a
270+
one-second deadline; the short-lived request process is terminated on expiry.
271+
Apart from the configured registry URL, it sends only its own version and never
272+
your Sim API key. If `npm_config_registry` points at a private mirror, its query
273+
string is preserved, including any query-string credentials. Registry URLs
274+
containing username/password userinfo are rejected. Set
275+
`SIM_NO_UPDATE_CHECK=1` to turn it off. Empty or whitespace-only registry values
276+
use the public default; non-empty malformed or non-HTTP(S) values fail closed.
277+
The full list of cases where it stays quiet is in the
278+
[configuration guide](https://docs.sim.ai/cli/configuration).
262279

263280
## Documentation
264281

packages/sim-cli/src/config/paths.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,3 +19,17 @@ export function configPath(): string {
1919
export function credentialsPath(): string {
2020
return process.env.SIM_CREDENTIALS_FILE || join(configDir(), 'credentials')
2121
}
22+
23+
/**
24+
* Where the once-a-day update check remembers that it ran.
25+
*
26+
* Cache, not configuration, so it is safe to delete at any time and gets no
27+
* `SIM_*` override of its own: nobody relocates a cache deliberately, and
28+
* `SIM_CONFIG_DIR` already moves it for the two callers that matter — the test
29+
* harness and anyone keeping `~/.sim` somewhere else. It is kept out of the
30+
* config file because that file is INI the user edits, and a timestamp inside a
31+
* `[profile x]` section would surface in `sim configure` and `sim whoami`.
32+
*/
33+
export function updateCachePath(): string {
34+
return join(configDir(), 'update-check.json')
35+
}

packages/sim-cli/src/program.test.ts

Lines changed: 73 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,19 @@
11
/**
22
* @vitest-environment node
33
*/
4+
import { mkdtempSync, rmSync } from 'node:fs'
5+
import { tmpdir } from 'node:os'
6+
import { join } from 'node:path'
47
import type { Command } from 'commander'
58
import { describe, expect, it } from 'vitest'
69
import { buildProgram } from './program'
710
import { CLI_VERSION } from './version'
811

912
/** Parses argv against a program whose output and exits are captured, not taken. */
10-
async function parse(argv: string[]): Promise<{ out: string; code: string | null }> {
11-
const program = buildProgram()
13+
async function parse(
14+
argv: string[],
15+
program: Command = buildProgram()
16+
): Promise<{ out: string; code: string | null }> {
1217
let out = ''
1318
const capture = (command: Command) => {
1419
command.exitOverride()
@@ -159,3 +164,69 @@ describe('help typed after a command that does not exist', () => {
159164
expect(implicit.out).toContain('Usage: sim profiles add')
160165
})
161166
})
167+
168+
/** Commander keeps lifecycle hooks on a private field and offers no getter. */
169+
function preActionHooks(program: Command): Array<(a: Command, b: Command) => unknown> {
170+
const { _lifeCycleHooks: hooks } = program as Command & {
171+
_lifeCycleHooks?: Record<string, Array<(a: Command, b: Command) => unknown>>
172+
}
173+
return hooks?.preAction ?? []
174+
}
175+
176+
describe('the update check', () => {
177+
/**
178+
* The notice must cost `--version` and `--help` nothing. Commander answers
179+
* both during parsing, before any action hook runs, so the guarantee is
180+
* structural — this holds it in place if the check is ever moved.
181+
*
182+
* It swaps in a sentinel hook rather than watching for a request or a cache
183+
* file. Those side effects never appear from inside a checkout no matter
184+
* what runs, because the check suppresses itself there — so asserting on
185+
* them would pass even if the hook fired, which is precisely the regression
186+
* this is meant to catch.
187+
*/
188+
it('fires no preAction hook for the two commands commander answers while parsing', async () => {
189+
let fired = 0
190+
const program = buildProgram()
191+
const hooks = preActionHooks(program)
192+
expect(hooks).toHaveLength(1)
193+
hooks.splice(0, hooks.length, () => {
194+
fired += 1
195+
})
196+
197+
await parse(['--version'], program)
198+
await parse(['--help'], program)
199+
expect(fired).toBe(0)
200+
201+
const dir = mkdtempSync(join(tmpdir(), 'sim-cli-program-'))
202+
const previousConfigDir = process.env.SIM_CONFIG_DIR
203+
process.env.SIM_CONFIG_DIR = dir
204+
try {
205+
await parse(['configure', '--set-output', 'json'], program)
206+
expect(fired).toBe(1)
207+
} finally {
208+
if (previousConfigDir === undefined) Reflect.deleteProperty(process.env, 'SIM_CONFIG_DIR')
209+
else process.env.SIM_CONFIG_DIR = previousConfigDir
210+
rmSync(dir, { recursive: true, force: true })
211+
}
212+
})
213+
214+
/**
215+
* The positive half, and the one that matters: without it the hook can be
216+
* deleted from `buildProgram` and every other test still passes.
217+
*
218+
* It asserts registration rather than a resulting request, because the check
219+
* suppresses itself when it is running from a checkout — and inside this
220+
* suite `import.meta.url` IS a checkout, so the behavioural path is
221+
* unreachable here by construction. That path is covered directly in
222+
* check.test.ts and walked against the real registry from a staged global
223+
* install before release.
224+
*/
225+
it('registers the update check as a root preAction hook', async () => {
226+
const program = buildProgram()
227+
const preAction = preActionHooks(program)
228+
229+
expect(preAction).toHaveLength(1)
230+
await expect(preAction[0](program, program)).resolves.toBeUndefined()
231+
})
232+
})

packages/sim-cli/src/program.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
buildGeneratedCommands,
1111
refuseHelpAfterUnknownCommand,
1212
} from './runtime/build'
13+
import { announceUpdateIfAvailable } from './update/check'
1314
import { CLI_VERSION } from './version'
1415

1516
/** Root program description, shared by `--help` and the generated docs. */
@@ -151,6 +152,8 @@ export function buildProgram(options: { version?: boolean } = {}): Command {
151152

152153
program.addHelpText('after', HELP_EPILOGUE)
153154

155+
program.hook('preAction', () => announceUpdateIfAvailable())
156+
154157
refuseHelpAfterUnknownCommand(program)
155158
assertNoReservedProgramFlags(program)
156159

0 commit comments

Comments
 (0)