Skip to content

Commit f91073c

Browse files
committed
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.
1 parent d218534 commit f91073c

10 files changed

Lines changed: 912 additions & 1 deletion

File tree

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

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,24 @@ shared profile cannot also set its own endpoint or API key.
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 the once-a-day update notice |
123+
124+
## Update notices
125+
126+
At most once a day, and only when stderr is a terminal, the CLI asks
127+
`registry.npmjs.org` which version is published under the dist-tag it was
128+
installed from. When a newer one exists it prints a single line on stderr naming
129+
both versions and the command that upgrades:
130+
131+
```
132+
Update available: sim 2.1.2 → 2.1.5. Run: npm install -g sim@latest
133+
```
134+
135+
The request carries the CLI version and nothing else — no API key, no workspace,
136+
no command. It is skipped entirely when stderr is redirected, in CI, under
137+
`npx`, and for prerelease installs, so scripted output is never affected. Set
138+
`SIM_NO_UPDATE_CHECK=1` to turn it off, and `npm_config_registry` to ask a
139+
mirror instead.
122140

123141
Node ignores `HTTPS_PROXY` unless you also set `NODE_USE_ENV_PROXY=1`, and only
124142
from Node 22.21 and 24.5. The CLI warns when a proxy is configured but will not

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

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,29 @@ editing the file by hand:
8585
sim --output table configure --set-output json
8686
```
8787

88+
## A command is missing that the documentation describes
89+
90+
The docs track the current release, so a command that exists here and not in
91+
`sim --help` usually means the installed CLI is older than the feature. Compare
92+
`sim --version` against the published version and upgrade:
93+
94+
```bash
95+
sim --version
96+
npm install -g sim@latest
97+
```
98+
99+
The CLI normally tells you this itself, once a day, on stderr. It stays quiet
100+
when stderr is redirected, in CI, and under `npx`.
101+
102+
## An update notice appears in output I am parsing
103+
104+
It should not: the notice is written to stderr, never stdout, so `--output json`
105+
piped to `jq` is unaffected. If something merges the two streams, silence it:
106+
107+
```bash
108+
export SIM_NO_UPDATE_CHECK=1
109+
```
110+
88111
## Anything else
89112

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

packages/sim-cli/README.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -259,6 +259,13 @@ The main environment variables are:
259259
| `SIM_CONFIG_DIR` | Directory containing CLI config and credentials |
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+
Once a day, at an interactive terminal, `sim` asks `registry.npmjs.org` which
265+
version is published under the tag it was installed from, and prints one line on
266+
stderr when a newer one exists. It sends nothing but its own version, never a
267+
key, and stays quiet when stderr is not a terminal, in CI, and under `npx`. Set
268+
`SIM_NO_UPDATE_CHECK=1` to turn it off.
262269

263270
## Documentation
264271

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: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
11
/**
22
* @vitest-environment node
33
*/
4+
import { mkdtempSync, readdirSync, rmSync } from 'node:fs'
5+
import { tmpdir } from 'node:os'
6+
import { join } from 'node:path'
47
import type { Command } from 'commander'
5-
import { describe, expect, it } from 'vitest'
8+
import { describe, expect, it, vi } from 'vitest'
69
import { buildProgram } from './program'
710
import { CLI_VERSION } from './version'
811

@@ -159,3 +162,35 @@ describe('help typed after a command that does not exist', () => {
159162
expect(implicit.out).toContain('Usage: sim profiles add')
160163
})
161164
})
165+
166+
describe('the update check', () => {
167+
/**
168+
* The notice must cost `--version` and `--help` nothing. Commander answers
169+
* both during parsing, before any action hook runs, so the guarantee is
170+
* structural — this holds it in place if the check is ever moved.
171+
*/
172+
it('never runs for the two commands commander answers during parsing', async () => {
173+
const stderr = process.stderr
174+
const wasTty = stderr.isTTY
175+
const dir = mkdtempSync(join(tmpdir(), 'sim-cli-program-'))
176+
const requests: string[] = []
177+
Object.defineProperty(stderr, 'isTTY', { configurable: true, value: true })
178+
process.env.SIM_CONFIG_DIR = dir
179+
vi.stubGlobal('fetch', (input: URL) => {
180+
requests.push(String(input))
181+
return Promise.resolve(Response.json({ latest: '99.0.0' }))
182+
})
183+
184+
try {
185+
await parse(['--version'])
186+
await parse(['--help'])
187+
expect(requests).toEqual([])
188+
expect(readdirSync(dir)).toEqual([])
189+
} finally {
190+
vi.unstubAllGlobals()
191+
process.env.SIM_CONFIG_DIR = undefined
192+
Object.defineProperty(stderr, 'isTTY', { configurable: true, value: wasTty })
193+
rmSync(dir, { recursive: true, force: true })
194+
}
195+
})
196+
})

packages/sim-cli/src/program.ts

Lines changed: 6 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,11 @@ export function buildProgram(options: { version?: boolean } = {}): Command {
151152

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

155+
// Root hooks are inherited by the whole tree, and commander answers `--help`
156+
// and `--version` during parsing without ever reaching an action — so the two
157+
// invocations that must stay instant are excluded by construction.
158+
program.hook('preAction', () => announceUpdateIfAvailable())
159+
154160
refuseHelpAfterUnknownCommand(program)
155161
assertNoReservedProgramFlags(program)
156162

0 commit comments

Comments
 (0)