diff --git a/apps/docs/content/docs/cli/configuration.mdx b/apps/docs/content/docs/cli/configuration.mdx
index b5ba49ac7d4..8758ab51859 100644
--- a/apps/docs/content/docs/cli/configuration.mdx
+++ b/apps/docs/content/docs/cli/configuration.mdx
@@ -114,15 +114,80 @@ shared profile cannot also set its own endpoint or API key.
| `SIM_API_KEY` | API key — skips `sim login` entirely |
| `SIM_WORKSPACE` | Workspace to target |
| `SIM_OUTPUT` | Output format |
-| `SIM_CONFIG_DIR` | Relocate both files away from `~/.sim` |
+| `SIM_CONFIG_DIR` | Relocate the config directory and update cache; file-specific overrides below still win |
| `SIM_CONFIG_FILE` | Relocate only the config file |
| `SIM_CREDENTIALS_FILE` | Relocate only the credentials file |
| `SIM_TIMEOUT_SECONDS` | Per-request timeout; `0` waits indefinitely. Defaults to `3600`, above every timeout the server itself applies |
| `SIM_DEBUG` | Trace each request's method, URL, status and duration to stderr |
+| `SIM_NO_UPDATE_CHECK` | Turn off update checks |
-Node ignores `HTTPS_PROXY` unless you also set `NODE_USE_ENV_PROXY=1`, and only
-from Node 22.21 and 24.5. The CLI warns when a proxy is configured but will not
-be used.
+## Update notices
+
+On eligible invocations, the CLI uses a daily cache before asking
+`registry.npmjs.org` what is published under the `latest` tag. Prerelease
+installs are skipped entirely, so a `-preview` or `-dev` build is never told to
+upgrade. When a newer one exists, it prints a single line on stderr naming both
+versions and the command that upgrades:
+
+```
+Update available: sim 2.1.2 → 2.1.5. Run: npm install -g sim@latest
+```
+
+Apart from the configured registry URL, the request identifies only the CLI
+version — no Sim API key, workspace, or command — and it never follows a
+redirect away from the registry it asked.
+
+One caveat worth stating plainly: if you point `npm_config_registry` at a
+private mirror, the check goes to that mirror instead of npm. Query-string
+credentials (an Artifactory or Nexus `?token=…`, for example) are preserved and
+sent as part of the configured registry request — they have to be, or the
+mirror would reject it. As with other registry traffic, configured proxies or
+TLS inspection can observe what that network setup permits. A registry URL
+containing username/password userinfo, such as
+`https://user:password@registry.example`, is rejected and no update check is
+made.
+
+An empty or whitespace-only `npm_config_registry` is treated as unset, so the
+public registry remains the default. Non-empty malformed and non-HTTP(S) values
+disable the update check rather than making an unexpected public request.
+
+The notice is skipped entirely when:
+
+- `SIM_NO_UPDATE_CHECK` is set to anything but `0` or `false`
+- stderr is not a terminal, so redirected and piped output is never affected
+- a CI environment variable is present (`CI`, `GITHUB_ACTIONS`, `JENKINS_URL`,
+ `TEAMCITY_VERSION`, `BUILDKITE`)
+- the CLI is running under `npm exec` or `npx`, which may use a project-local or
+ ephemeral package where global-install advice is inappropriate
+- the CLI is running from a checkout of the sim repository, whose version
+ deliberately trails the published one
+- the installed version is a prerelease
+
+The daily pace comes from a timestamp in the config directory's
+`update-check.json`: `~/.sim/update-check.json` by default, or under
+`SIM_CONFIG_DIR` when that is set. `SIM_CONFIG_FILE` and
+`SIM_CREDENTIALS_FILE` do not move the cache, so it may not sit beside a file
+relocated with either of those variables.
+
+This throttle is best-effort across processes. Two commands that start together
+can both see a stale cache and check. Cache replacement is atomic, so either
+complete write can win without leaving a partially interleaved file. If the
+cache cannot be written — for example, because the config directory is
+read-only — every eligible invocation attempts a check because there is no
+timestamp to reuse.
+
+The registry check has a one-second deadline. On expiry, the CLI terminates its
+short-lived request process so stalled DNS, connection, or response work cannot
+remain active and delay the command. `SIM_NO_UPDATE_CHECK=1` still turns the
+check off.
+
+The command the notice prints matches how Sim was installed — `npm install -g`,
+`pnpm add -g`, `bun add -g`, or `yarn global add` — so running it updates the
+executable already on your `PATH` rather than installing a second copy under a
+different package manager.
+
+Node's `fetch` uses `HTTP(S)_PROXY` when opted in with `NODE_USE_ENV_PROXY=1`
+(Node 22.21+ or 24.0+) or `--use-env-proxy` (Node 22.21+ or 24.5+).
For CI, set `SIM_API_KEY` and `SIM_WORKSPACE` and nothing needs to touch the
filesystem at all.
diff --git a/apps/docs/content/docs/cli/troubleshooting.mdx b/apps/docs/content/docs/cli/troubleshooting.mdx
index 8bf2dfd58c0..b429a455b4a 100644
--- a/apps/docs/content/docs/cli/troubleshooting.mdx
+++ b/apps/docs/content/docs/cli/troubleshooting.mdx
@@ -3,6 +3,8 @@ title: Troubleshooting
description: The failures whose cause is not obvious from the error message
---
+import { Tab, Tabs } from 'fumadocs-ui/components/tabs'
+
Errors print one line to stderr, prefixed `Error:`, and exit `1` — except
`sim whoami`, which exits `2` when it could not reach the API to check at all.
Most say what to do next; the cases below are the ones that do not.
@@ -85,6 +87,50 @@ editing the file by hand:
sim --output table configure --set-output json
```
+## A command is missing that the documentation describes
+
+The docs track the current release, so a command that exists here and not in
+`sim --help` usually means the installed CLI is older than the feature. Compare
+`sim --version` against the published version and upgrade:
+
+```bash
+sim --version
+```
+
+Then upgrade with the package manager you installed it with — using a different
+one installs a second copy instead of replacing the executable on your `PATH`:
+
+
+
+ ```bash
+ npm install -g sim@latest
+ ```
+
+
+ ```bash
+ pnpm add -g sim@latest
+ ```
+
+
+ ```bash
+ bun add -g sim@latest
+ ```
+
+
+
+The CLI can also tell you this through a cached daily check on eligible
+invocations, and the command it prints already matches your installation. It
+stays quiet when stderr is redirected, in CI, and under `npm exec` or `npx`.
+
+## An update notice appears in output I am parsing
+
+It should not: the notice is written to stderr, never stdout, so `--output json`
+piped to `jq` is unaffected. If something merges the two streams, silence it:
+
+```bash
+export SIM_NO_UPDATE_CHECK=1
+```
+
## Anything else
An unexpected error prints a stack trace. That is a bug in the CLI — please
diff --git a/packages/sim-cli/README.md b/packages/sim-cli/README.md
index fd86dc9ca79..ccb6381cc45 100644
--- a/packages/sim-cli/README.md
+++ b/packages/sim-cli/README.md
@@ -256,9 +256,26 @@ The main environment variables are:
| `SIM_API_KEY` | API key, usually for CI |
| `SIM_WORKSPACE` | Workspace to target |
| `SIM_OUTPUT` | `table`, `json`, `yaml`, or `text` |
-| `SIM_CONFIG_DIR` | Directory containing CLI config and credentials |
+| `SIM_CONFIG_DIR` | Base directory for CLI config, credentials, and the update cache |
| `SIM_TIMEOUT_SECONDS` | Per-request timeout; `0` waits indefinitely |
| `SIM_DEBUG` | Print request diagnostics to stderr |
+| `SIM_NO_UPDATE_CHECK` | Turn off the update notice |
+
+On eligible interactive invocations, `sim` uses a daily cache before asking
+`registry.npmjs.org` what is published under the `latest` tag and prints one
+line on stderr when a newer version exists. Prerelease installs are skipped
+entirely. The cache lives in `~/.sim` by default and follows `SIM_CONFIG_DIR`;
+without a writable cache, each eligible invocation checks again. Concurrent
+invocations can also perform duplicate checks. The registry request has a
+one-second deadline; the short-lived request process is terminated on expiry.
+Apart from the configured registry URL, it sends only its own version and never
+your Sim API key. If `npm_config_registry` points at a private mirror, its query
+string is preserved, including any query-string credentials. Registry URLs
+containing username/password userinfo are rejected. Set
+`SIM_NO_UPDATE_CHECK=1` to turn it off. Empty or whitespace-only registry values
+use the public default; non-empty malformed or non-HTTP(S) values fail closed.
+The full list of cases where it stays quiet is in the
+[configuration guide](https://docs.sim.ai/cli/configuration).
## Documentation
diff --git a/packages/sim-cli/src/config/paths.ts b/packages/sim-cli/src/config/paths.ts
index 158a356d57c..9618931e080 100644
--- a/packages/sim-cli/src/config/paths.ts
+++ b/packages/sim-cli/src/config/paths.ts
@@ -19,3 +19,17 @@ export function configPath(): string {
export function credentialsPath(): string {
return process.env.SIM_CREDENTIALS_FILE || join(configDir(), 'credentials')
}
+
+/**
+ * Where the once-a-day update check remembers that it ran.
+ *
+ * Cache, not configuration, so it is safe to delete at any time and gets no
+ * `SIM_*` override of its own: nobody relocates a cache deliberately, and
+ * `SIM_CONFIG_DIR` already moves it for the two callers that matter — the test
+ * harness and anyone keeping `~/.sim` somewhere else. It is kept out of the
+ * config file because that file is INI the user edits, and a timestamp inside a
+ * `[profile x]` section would surface in `sim configure` and `sim whoami`.
+ */
+export function updateCachePath(): string {
+ return join(configDir(), 'update-check.json')
+}
diff --git a/packages/sim-cli/src/program.test.ts b/packages/sim-cli/src/program.test.ts
index bb3b94ba5af..8124fc3fb83 100644
--- a/packages/sim-cli/src/program.test.ts
+++ b/packages/sim-cli/src/program.test.ts
@@ -1,14 +1,19 @@
/**
* @vitest-environment node
*/
+import { mkdtempSync, rmSync } from 'node:fs'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
import type { Command } from 'commander'
import { describe, expect, it } from 'vitest'
import { buildProgram } from './program'
import { CLI_VERSION } from './version'
/** Parses argv against a program whose output and exits are captured, not taken. */
-async function parse(argv: string[]): Promise<{ out: string; code: string | null }> {
- const program = buildProgram()
+async function parse(
+ argv: string[],
+ program: Command = buildProgram()
+): Promise<{ out: string; code: string | null }> {
let out = ''
const capture = (command: Command) => {
command.exitOverride()
@@ -159,3 +164,69 @@ describe('help typed after a command that does not exist', () => {
expect(implicit.out).toContain('Usage: sim profiles add')
})
})
+
+/** Commander keeps lifecycle hooks on a private field and offers no getter. */
+function preActionHooks(program: Command): Array<(a: Command, b: Command) => unknown> {
+ const { _lifeCycleHooks: hooks } = program as Command & {
+ _lifeCycleHooks?: Record unknown>>
+ }
+ return hooks?.preAction ?? []
+}
+
+describe('the update check', () => {
+ /**
+ * The notice must cost `--version` and `--help` nothing. Commander answers
+ * both during parsing, before any action hook runs, so the guarantee is
+ * structural — this holds it in place if the check is ever moved.
+ *
+ * It swaps in a sentinel hook rather than watching for a request or a cache
+ * file. Those side effects never appear from inside a checkout no matter
+ * what runs, because the check suppresses itself there — so asserting on
+ * them would pass even if the hook fired, which is precisely the regression
+ * this is meant to catch.
+ */
+ it('fires no preAction hook for the two commands commander answers while parsing', async () => {
+ let fired = 0
+ const program = buildProgram()
+ const hooks = preActionHooks(program)
+ expect(hooks).toHaveLength(1)
+ hooks.splice(0, hooks.length, () => {
+ fired += 1
+ })
+
+ await parse(['--version'], program)
+ await parse(['--help'], program)
+ expect(fired).toBe(0)
+
+ const dir = mkdtempSync(join(tmpdir(), 'sim-cli-program-'))
+ const previousConfigDir = process.env.SIM_CONFIG_DIR
+ process.env.SIM_CONFIG_DIR = dir
+ try {
+ await parse(['configure', '--set-output', 'json'], program)
+ expect(fired).toBe(1)
+ } finally {
+ if (previousConfigDir === undefined) Reflect.deleteProperty(process.env, 'SIM_CONFIG_DIR')
+ else process.env.SIM_CONFIG_DIR = previousConfigDir
+ rmSync(dir, { recursive: true, force: true })
+ }
+ })
+
+ /**
+ * The positive half, and the one that matters: without it the hook can be
+ * deleted from `buildProgram` and every other test still passes.
+ *
+ * It asserts registration rather than a resulting request, because the check
+ * suppresses itself when it is running from a checkout — and inside this
+ * suite `import.meta.url` IS a checkout, so the behavioural path is
+ * unreachable here by construction. That path is covered directly in
+ * check.test.ts and walked against the real registry from a staged global
+ * install before release.
+ */
+ it('registers the update check as a root preAction hook', async () => {
+ const program = buildProgram()
+ const preAction = preActionHooks(program)
+
+ expect(preAction).toHaveLength(1)
+ await expect(preAction[0](program, program)).resolves.toBeUndefined()
+ })
+})
diff --git a/packages/sim-cli/src/program.ts b/packages/sim-cli/src/program.ts
index e2e784672d4..d83cd6afe79 100644
--- a/packages/sim-cli/src/program.ts
+++ b/packages/sim-cli/src/program.ts
@@ -10,6 +10,7 @@ import {
buildGeneratedCommands,
refuseHelpAfterUnknownCommand,
} from './runtime/build'
+import { announceUpdateIfAvailable } from './update/check'
import { CLI_VERSION } from './version'
/** Root program description, shared by `--help` and the generated docs. */
@@ -151,6 +152,8 @@ export function buildProgram(options: { version?: boolean } = {}): Command {
program.addHelpText('after', HELP_EPILOGUE)
+ program.hook('preAction', () => announceUpdateIfAvailable())
+
refuseHelpAfterUnknownCommand(program)
assertNoReservedProgramFlags(program)
diff --git a/packages/sim-cli/src/update/check.process.test.ts b/packages/sim-cli/src/update/check.process.test.ts
new file mode 100644
index 00000000000..03972c4c5d1
--- /dev/null
+++ b/packages/sim-cli/src/update/check.process.test.ts
@@ -0,0 +1,447 @@
+/**
+ * @vitest-environment node
+ */
+import { execFileSync, spawn } from 'node:child_process'
+import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
+import { createServer, type RequestListener } from 'node:http'
+import type { AddressInfo, Socket } from 'node:net'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { fileURLToPath, pathToFileURL } from 'node:url'
+import { afterAll, beforeAll, expect, it } from 'vitest'
+
+interface ChildResult {
+ code: number | null
+ elapsedMs: number
+ signal: NodeJS.Signals | null
+ stderr: string
+ stdout: string
+}
+
+interface CheckOutput {
+ elapsedMs: number
+ notices: string[]
+}
+
+interface RunChildOptions {
+ env?: NodeJS.ProcessEnv
+ nodeArgs?: string[]
+ useProcessEnv?: boolean
+}
+
+let entrypoint: string
+let temporaryDir: string
+
+const [NODE_MAJOR, NODE_MINOR] = process.versions.node.split('.').map(Number)
+const SUPPORTS_ENV_PROXY = NODE_MAJOR >= 24 || (NODE_MAJOR === 22 && NODE_MINOR >= 21)
+const SUPPORTS_PROXY_FLAG =
+ NODE_MAJOR > 24 ||
+ (NODE_MAJOR === 24 && NODE_MINOR >= 5) ||
+ (NODE_MAJOR === 22 && NODE_MINOR >= 21)
+
+function buildUpdateCheck(temporaryDir: string): string {
+ const entrypoint = join(temporaryDir, 'dist', 'check.mjs')
+ const sourcePath = fileURLToPath(new URL('./check.ts', import.meta.url))
+ mkdirSync(join(temporaryDir, 'dist'))
+ writeFileSync(join(temporaryDir, 'package.json'), JSON.stringify({ version: '2.1.2' }))
+ execFileSync(
+ 'bun',
+ ['build', sourcePath, '--target=node', '--format=esm', '--outfile', entrypoint],
+ { stdio: 'pipe' }
+ )
+ return entrypoint
+}
+
+function runChild(
+ entrypoint: string,
+ registry: string,
+ configDir: string,
+ options: RunChildOptions = {}
+): Promise {
+ const requestEnvironment = options.useProcessEnv
+ ? 'process.env'
+ : `{ npm_config_registry: ${JSON.stringify(registry)} }`
+ const source = `
+ import { announceUpdateIfAvailable } from ${JSON.stringify(pathToFileURL(entrypoint).href)}
+ const started = Date.now()
+ const notices = []
+ await announceUpdateIfAvailable({
+ currentVersion: '2.1.2',
+ env: ${requestEnvironment},
+ isTty: true,
+ modulePath: '/usr/local/lib/node_modules/sim/dist/index.js',
+ write: (message) => notices.push(message),
+ })
+ process.stdout.write(JSON.stringify({ elapsedMs: Date.now() - started, notices }))
+ `
+ const started = performance.now()
+ const child = spawn(
+ process.execPath,
+ [...(options.nodeArgs ?? []), '--input-type=module', '--eval', source],
+ {
+ env: {
+ ...process.env,
+ BUILDKITE: '0',
+ CI: '0',
+ GITHUB_ACTIONS: '0',
+ JENKINS_URL: '0',
+ NODE_USE_ENV_PROXY: '0',
+ NO_PROXY: '127.0.0.1,localhost',
+ npm_command: '',
+ TEAMCITY_VERSION: '0',
+ ...options.env,
+ SIM_CONFIG_DIR: configDir,
+ ...(options.useProcessEnv ? { npm_config_registry: registry } : {}),
+ },
+ stdio: ['ignore', 'pipe', 'pipe'],
+ }
+ )
+ let stdout = ''
+ let stderr = ''
+ child.stdout.setEncoding('utf8').on('data', (chunk: string) => {
+ stdout += chunk
+ })
+ child.stderr.setEncoding('utf8').on('data', (chunk: string) => {
+ stderr += chunk
+ })
+
+ return new Promise((resolve, reject) => {
+ let timedOut = false
+ const deadline = setTimeout(() => {
+ timedOut = true
+ child.kill('SIGKILL')
+ }, 3500)
+ child.once('error', reject)
+ child.once('close', (code, signal) => {
+ clearTimeout(deadline)
+ if (timedOut) {
+ reject(new Error('Update-check child did not exit promptly'))
+ return
+ }
+ resolve({ code, elapsedMs: performance.now() - started, signal, stderr, stdout })
+ })
+ })
+}
+
+async function withServer(
+ listener: RequestListener,
+ run: (origin: string) => Promise
+): Promise {
+ const server = createServer(listener)
+ await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve))
+ const { port } = server.address() as AddressInfo
+
+ try {
+ await run(`http://127.0.0.1:${port}`)
+ } finally {
+ server.closeAllConnections()
+ await new Promise((resolve) => server.close(() => resolve()))
+ }
+}
+
+async function withProxyServer(
+ run: (origin: string) => Promise
+): Promise<{ requests: string[]; result: T }> {
+ const requests: string[] = []
+ const body = JSON.stringify({ latest: '2.1.5' })
+ const server = createServer((request, response) => {
+ requests.push(request.url ?? '')
+ response.setHeader('content-type', 'application/json')
+ response.end(body)
+ })
+ server.on('connect', (request, socket, head) => {
+ requests.push(`CONNECT ${request.url ?? ''}`)
+ socket.write('HTTP/1.1 200 Connection Established\r\n\r\n')
+ const respond = () => {
+ socket.end(
+ `HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: ${Buffer.byteLength(body)}\r\nConnection: close\r\n\r\n${body}`
+ )
+ }
+ if (head.length > 0) respond()
+ else socket.once('data', respond)
+ })
+
+ await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve))
+ const { port } = server.address() as AddressInfo
+ try {
+ const result = await run(`http://127.0.0.1:${port}`)
+ return { requests, result }
+ } finally {
+ server.closeAllConnections()
+ await new Promise((resolve) => server.close(() => resolve()))
+ }
+}
+
+beforeAll(() => {
+ temporaryDir = mkdtempSync(join(tmpdir(), 'sim-cli-update-process-'))
+ entrypoint = buildUpdateCheck(temporaryDir)
+})
+
+afterAll(() => {
+ rmSync(temporaryDir, { recursive: true, force: true })
+})
+
+it('destroys a timed-out request so its socket cannot hold the process open', async () => {
+ await withServer(
+ () => {},
+ async (origin) => {
+ const result = await runChild(entrypoint, origin, join(temporaryDir, 'config'))
+
+ expect(result).toMatchObject({ code: 0, signal: null, stderr: '' })
+ const output = JSON.parse(result.stdout) as CheckOutput
+ expect(output.elapsedMs).toBeGreaterThanOrEqual(750)
+ expect(result.elapsedMs).toBeLessThan(3500)
+ }
+ )
+}, 10_000)
+
+it('destroys a response whose body stalls after the headers arrive', async () => {
+ await withServer(
+ (_request, response) => {
+ response.writeHead(200, { 'content-type': 'application/json' })
+ response.write('{"latest":')
+ },
+ async (origin) => {
+ const result = await runChild(entrypoint, origin, join(temporaryDir, 'config-stalled-body'))
+
+ expect(result).toMatchObject({ code: 0, signal: null, stderr: '' })
+ const output = JSON.parse(result.stdout) as CheckOutput
+ expect(output.elapsedMs).toBeGreaterThanOrEqual(750)
+ expect(result.elapsedMs).toBeLessThan(3500)
+ }
+ )
+}, 10_000)
+
+it('gives the request process its own deadline if the CLI process disappears', async () => {
+ const server = createServer(() => {})
+ let outer: ReturnType | undefined
+
+ try {
+ await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve))
+ const { port } = server.address() as AddressInfo
+ const source = `
+ import { announceUpdateIfAvailable } from ${JSON.stringify(pathToFileURL(entrypoint).href)}
+ await announceUpdateIfAvailable({
+ currentVersion: '2.1.2',
+ env: { npm_config_registry: 'http://127.0.0.1:${port}' },
+ isTty: true,
+ modulePath: '/usr/local/lib/node_modules/sim/dist/index.js',
+ })
+ `
+ const connection = new Promise((resolve, reject) => {
+ const deadline = setTimeout(() => reject(new Error('Registry probe did not connect')), 2500)
+ server.once('connection', (socket) => {
+ clearTimeout(deadline)
+ resolve(socket)
+ })
+ })
+
+ outer = spawn(process.execPath, ['--input-type=module', '--eval', source], {
+ env: {
+ ...process.env,
+ NODE_USE_ENV_PROXY: '0',
+ NO_PROXY: '127.0.0.1,localhost',
+ SIM_CONFIG_DIR: join(temporaryDir, 'config-orphan'),
+ },
+ stdio: 'ignore',
+ })
+ const socket = await connection
+ const killedAt = performance.now()
+ outer.kill('SIGKILL')
+ await new Promise((resolve) => outer?.once('close', () => resolve()))
+ await new Promise((resolve, reject) => {
+ const deadline = setTimeout(
+ () => reject(new Error('Orphaned registry probe outlived its own deadline')),
+ 2500
+ )
+ socket.once('close', () => {
+ clearTimeout(deadline)
+ resolve()
+ })
+ })
+
+ expect(performance.now() - killedAt).toBeLessThan(2500)
+ } finally {
+ outer?.kill('SIGKILL')
+ server.closeAllConnections()
+ await new Promise((resolve) => server.close(() => resolve()))
+ }
+}, 10_000)
+
+it('caps a chunked response even when it omits Content-Length', async () => {
+ await withServer(
+ (_request, response) => {
+ response.writeHead(200, { 'content-type': 'application/json' })
+ response.write(JSON.stringify({ latest: '2.1.5' }))
+ response.end(' '.repeat(64 * 1024))
+ },
+ async (origin) => {
+ const result = await runChild(entrypoint, origin, join(temporaryDir, 'config-chunked'))
+
+ expect(result).toMatchObject({ code: 0, signal: null, stderr: '' })
+ const output = JSON.parse(result.stdout) as CheckOutput
+ expect(output.notices).toEqual([])
+ }
+ )
+}, 10_000)
+
+it('preserves a mirror path, query, and reduced request headers', async () => {
+ let requestHeaders: Record = {}
+ let requestPath: string | undefined
+ await withServer(
+ (request, response) => {
+ requestHeaders = request.headers
+ requestPath = request.url
+ response.setHeader('content-type', 'application/json')
+ response.end(JSON.stringify({ latest: '2.1.5' }))
+ },
+ async (origin) => {
+ const result = await runChild(
+ entrypoint,
+ `${origin}/api/npm/repo?token=abc`,
+ join(temporaryDir, 'config-mirror')
+ )
+
+ expect(result).toMatchObject({ code: 0, signal: null, stderr: '' })
+ const output = JSON.parse(result.stdout) as CheckOutput
+ expect(output.notices).toEqual([
+ 'Update available: sim 2.1.2 → 2.1.5. Run: npm install -g sim@latest\n',
+ ])
+ expect(requestPath).toBe('/api/npm/repo/-/package/sim/dist-tags?token=abc')
+ expect(requestHeaders).toMatchObject({
+ accept: 'application/json',
+ 'user-agent': 'sim-cli/2.1.2',
+ })
+ expect(requestHeaders.authorization).toBeUndefined()
+ }
+ )
+}, 10_000)
+
+it.skipIf(!SUPPORTS_ENV_PROXY)(
+ 'preserves built-in environment proxy support inside the request process',
+ async () => {
+ const { requests, result } = await withProxyServer((origin) =>
+ runChild(entrypoint, 'http://sim-update.invalid', join(temporaryDir, 'config-proxy'), {
+ env: {
+ HTTP_PROXY: origin,
+ NODE_USE_ENV_PROXY: '1',
+ NO_PROXY: '',
+ http_proxy: origin,
+ no_proxy: '',
+ },
+ })
+ )
+
+ expect(result).toMatchObject({ code: 0, signal: null, stderr: '' })
+ expect(requests).not.toEqual([])
+ const output = JSON.parse(result.stdout) as CheckOutput
+ expect(output.notices).toHaveLength(1)
+ },
+ 10_000
+)
+
+it.skipIf(!SUPPORTS_PROXY_FLAG)(
+ 'preserves a parent use-env-proxy flag and its command-line precedence',
+ async () => {
+ const { requests, result } = await withProxyServer((origin) =>
+ runChild(entrypoint, 'http://sim-update.invalid', join(temporaryDir, 'config-proxy-flag'), {
+ env: {
+ HTTP_PROXY: origin,
+ NODE_OPTIONS: '--no-use-env-proxy',
+ NODE_USE_ENV_PROXY: '0',
+ NO_PROXY: '',
+ http_proxy: origin,
+ no_proxy: '',
+ },
+ nodeArgs: ['--use-env-proxy'],
+ })
+ )
+
+ expect(result).toMatchObject({ code: 0, signal: null, stderr: '' })
+ expect(requests).not.toEqual([])
+ const output = JSON.parse(result.stdout) as CheckOutput
+ expect(output.notices).toHaveLength(1)
+ },
+ 10_000
+)
+
+it('keeps CLI credentials out of probe argv and environment', async () => {
+ const inspectionPath = join(temporaryDir, 'probe-inspection.json')
+ const preloadDir = join(temporaryDir, 'probe preload')
+ const preloadPath = join(preloadDir, 'inspect-probe.cjs')
+ const registrySentinel = 'registry-secret-sentinel'
+ const apiKeySentinel = 'api-key-secret-sentinel'
+ let requestPath: string | undefined
+ mkdirSync(preloadDir)
+ writeFileSync(
+ preloadPath,
+ `
+ const { writeFileSync } = require('node:fs')
+ if (process.execArgv.some((value) => value.includes('maxResponseBytes'))) {
+ writeFileSync(
+ process.env.PROBE_INSPECTION_PATH,
+ JSON.stringify({
+ argv: process.argv,
+ environmentValues: Object.values(process.env),
+ execArgv: process.execArgv,
+ })
+ )
+ }
+ `
+ )
+
+ await withServer(
+ (request, response) => {
+ requestPath = request.url
+ response.setHeader('content-type', 'application/json')
+ response.end(JSON.stringify({ latest: '2.1.5' }))
+ },
+ async (origin) => {
+ const registry = `${origin}?token=${registrySentinel}`
+ const result = await runChild(entrypoint, registry, join(temporaryDir, 'config-credential'), {
+ env: {
+ NODE_OPTIONS: `--require="${preloadPath}"`,
+ NPM_CONFIG_REGISTRY: registry,
+ PROBE_INSPECTION_PATH: inspectionPath,
+ SIM_API_KEY: apiKeySentinel,
+ },
+ useProcessEnv: true,
+ })
+
+ expect(result).toMatchObject({ code: 0, signal: null, stderr: '' })
+ expect(requestPath).toBe(`/-/package/sim/dist-tags?token=${registrySentinel}`)
+ const inspection = JSON.parse(readFileSync(inspectionPath, 'utf8')) as {
+ argv: string[]
+ environmentValues: string[]
+ execArgv: string[]
+ }
+ const serializedInspection = JSON.stringify(inspection)
+ expect(serializedInspection).not.toContain(registrySentinel)
+ expect(serializedInspection).not.toContain(apiKeySentinel)
+ }
+ )
+}, 10_000)
+
+it('refuses redirects without contacting their destination', async () => {
+ const paths: string[] = []
+ await withServer(
+ (request, response) => {
+ paths.push(request.url ?? '')
+ if (request.url === '/redirected') {
+ response.setHeader('content-type', 'application/json')
+ response.end(JSON.stringify({ latest: '2.1.5' }))
+ return
+ }
+ response.writeHead(302, { location: '/redirected' })
+ response.end()
+ },
+ async (origin) => {
+ const result = await runChild(entrypoint, origin, join(temporaryDir, 'config-redirect'))
+
+ expect(result).toMatchObject({ code: 0, signal: null, stderr: '' })
+ const output = JSON.parse(result.stdout) as CheckOutput
+ expect(output.notices).toEqual([])
+ expect(paths).toEqual(['/-/package/sim/dist-tags'])
+ }
+ )
+}, 10_000)
diff --git a/packages/sim-cli/src/update/check.test.ts b/packages/sim-cli/src/update/check.test.ts
new file mode 100644
index 00000000000..9400d1f6a43
--- /dev/null
+++ b/packages/sim-cli/src/update/check.test.ts
@@ -0,0 +1,441 @@
+/**
+ * @vitest-environment node
+ */
+import {
+ linkSync,
+ mkdtempSync,
+ readFileSync,
+ rmSync,
+ statSync,
+ symlinkSync,
+ writeFileSync,
+} from 'node:fs'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { afterEach, beforeEach, describe, expect, it } from 'vitest'
+import { CLI_VERSION } from '../version'
+import { announceUpdateIfAvailable, type UpdateCheckOptions, upgradeCommand } from './check'
+
+/** A global install, which is the only shape that gets advised at all. */
+const INSTALLED = '/usr/local/lib/node_modules/sim/dist/index.js'
+
+let configDir: string
+let previousConfigDir: string | undefined
+let notices: string[]
+let fetched: URL[]
+type RegistryRequest = NonNullable
+let inits: Parameters[1][]
+let registryRequest: RegistryRequest
+
+/** Answers the dist-tags request the way the registry does. */
+function stubRegistry(
+ tags: Record | 'reject' | 'not-found' | 'html' | 'oversized'
+): void {
+ registryRequest = async (input, init) => {
+ fetched.push(input)
+ inits.push(init)
+ if (tags === 'oversized') {
+ return `${JSON.stringify({ latest: '2.1.5' })}${' '.repeat(64 * 1024)}`
+ }
+ if (tags === 'reject') throw new Error('getaddrinfo ENOTFOUND')
+ if (tags === 'not-found') return null
+ if (tags === 'html') return 'nope'
+ return JSON.stringify(tags)
+ }
+}
+
+async function run(overrides: Parameters[0] = {}) {
+ await announceUpdateIfAvailable({
+ currentVersion: '2.1.2',
+ env: {},
+ isTty: true,
+ modulePath: INSTALLED,
+ registryRequest,
+ write: (message) => notices.push(message),
+ ...overrides,
+ })
+}
+
+function cachePath(): string {
+ return join(configDir, 'update-check.json')
+}
+
+beforeEach(() => {
+ previousConfigDir = process.env.SIM_CONFIG_DIR
+ configDir = mkdtempSync(join(tmpdir(), 'sim-cli-update-'))
+ process.env.SIM_CONFIG_DIR = configDir
+ notices = []
+ fetched = []
+ inits = []
+ stubRegistry({ latest: '2.1.5' })
+})
+
+afterEach(() => {
+ if (previousConfigDir === undefined) Reflect.deleteProperty(process.env, 'SIM_CONFIG_DIR')
+ else process.env.SIM_CONFIG_DIR = previousConfigDir
+ rmSync(configDir, { recursive: true, force: true })
+})
+
+describe('announcing a newer release', () => {
+ it('names both versions and the command that closes the gap', async () => {
+ await run()
+ expect(notices.join('')).toBe(
+ 'Update available: sim 2.1.2 → 2.1.5. Run: npm install -g sim@latest\n'
+ )
+ })
+
+ it('asks the registry for the dist-tags and nothing else', async () => {
+ await run()
+ expect(fetched.map(String)).toEqual(['https://registry.npmjs.org/-/package/sim/dist-tags'])
+ })
+
+ it('stays silent when the installed version is current', async () => {
+ await run({ currentVersion: '2.1.5' })
+ expect(notices).toEqual([])
+ })
+
+ it('stays silent when the installed version is ahead of the tag', async () => {
+ await run({ currentVersion: '2.2.0' })
+ expect(notices).toEqual([])
+ })
+
+ it.each([
+ ['minor', '2.2.0'],
+ ['major', '3.0.0'],
+ ])('announces a newer %s version', async (_difference, latest) => {
+ stubRegistry({ latest })
+ await run()
+ expect(notices.join('')).toContain(`2.1.2 → ${latest}`)
+ })
+
+ it('writes through the real default: stderr yes, stdout never', async () => {
+ const realOut = process.stdout.write
+ const realErr = process.stderr.write
+ const seen = { out: [] as string[], err: [] as string[] }
+ process.stdout.write = ((chunk: string) => {
+ seen.out.push(String(chunk))
+ return true
+ }) as typeof process.stdout.write
+ process.stderr.write = ((chunk: string) => {
+ seen.err.push(String(chunk))
+ return true
+ }) as typeof process.stderr.write
+ try {
+ await announceUpdateIfAvailable({
+ currentVersion: '2.1.2',
+ env: {},
+ isTty: true,
+ modulePath: INSTALLED,
+ registryRequest,
+ })
+ } finally {
+ process.stdout.write = realOut
+ process.stderr.write = realErr
+ }
+ expect(seen.out).toEqual([])
+ expect(seen.err.join('')).toContain('Update available: sim 2.1.2 → 2.1.5')
+ })
+
+ it('sends only its own version and gives the request a one-second deadline', async () => {
+ await run()
+ const headers = inits[0]?.headers
+ expect(headers['user-agent']).toBe(`sim-cli/${CLI_VERSION}`)
+ expect(headers.accept).toBe('application/json')
+ expect(headers.authorization).toBeUndefined()
+ expect(inits[0]?.maxResponseBytes).toBe(64 * 1024)
+ expect(inits[0]?.timeoutMs).toBe(1000)
+ })
+})
+
+describe('when the notice is suppressed', () => {
+ it('respects SIM_NO_UPDATE_CHECK', async () => {
+ await run({ env: { SIM_NO_UPDATE_CHECK: '1' } })
+ expect(fetched).toEqual([])
+ expect(notices).toEqual([])
+ })
+
+ it('treats an explicitly off value as not set', async () => {
+ await run({ env: { SIM_NO_UPDATE_CHECK: '0' } })
+ expect(notices).toHaveLength(1)
+ })
+
+ it('says nothing when stderr is not a terminal', async () => {
+ await run({ isTty: false })
+ expect(fetched).toEqual([])
+ expect(notices).toEqual([])
+ })
+
+ it.each(['CI', 'GITHUB_ACTIONS', 'JENKINS_URL', 'TEAMCITY_VERSION', 'BUILDKITE'])(
+ 'says nothing when %s is set, even where CI allocates a terminal',
+ async (variable) => {
+ await run({ env: { [variable]: 'true' } })
+ expect(fetched).toEqual([])
+ expect(notices).toEqual([])
+ }
+ )
+
+ it.each([
+ '/Users/x/.npm/_npx/a1b2/node_modules/sim/dist/index.js',
+ 'C:\\Users\\x\\AppData\\Local\\npm-cache\\_npx\\a1b2\\node_modules\\sim\\dist\\index.js',
+ ])('says nothing for an npx cache installation (%s)', async (modulePath) => {
+ await run({ modulePath })
+ expect(fetched).toEqual([])
+ expect(notices).toEqual([])
+ })
+
+ it.each([
+ '/Users/x/project/node_modules/sim/dist/index.js',
+ 'C:\\Users\\x\\project\\node_modules\\sim\\dist\\index.js',
+ ])('says nothing when npm exec resolves a project-local dependency (%s)', async (modulePath) => {
+ await run({ env: { npm_command: 'exec' }, modulePath })
+ expect(fetched).toEqual([])
+ expect(notices).toEqual([])
+ })
+
+ it.each([
+ {
+ cwd: '/Users/x/project/packages/app',
+ modulePath: '/Users/x/project/node_modules/sim/dist/index.js',
+ },
+ {
+ cwd: 'C:\\Users\\x\\project\\packages\\app',
+ modulePath:
+ 'C:\\Users\\x\\project\\node_modules\\.pnpm\\sim@2.1.2\\node_modules\\sim\\dist\\index.js',
+ },
+ ])('says nothing from a project-local install at $modulePath', async ({ cwd, modulePath }) => {
+ await run({ cwd, modulePath })
+ expect(fetched).toEqual([])
+ expect(notices).toEqual([])
+ })
+
+ it.each([
+ '/Users/x/sim/packages/sim-cli/dist/index.js',
+ 'C:\\Users\\x\\Sim\\Packages\\Sim-CLI\\dist\\index.js',
+ ])(
+ 'says nothing from a checkout, whose manifest trails npm by design (%s)',
+ async (modulePath) => {
+ await run({ modulePath })
+ expect(notices).toEqual([])
+ }
+ )
+
+ it('says nothing to a prerelease install', async () => {
+ stubRegistry({ latest: '2.1.5' })
+ await run({ currentVersion: '2.1.3-preview.44.1' })
+ expect(fetched).toEqual([])
+ expect(notices).toEqual([])
+ })
+
+ it('ignores stable build metadata when comparing versions', async () => {
+ await run({ currentVersion: '2.1.2+local.1' })
+ expect(notices).toHaveLength(1)
+ })
+
+ it('says nothing when the running version cannot be read', async () => {
+ await run({ currentVersion: 'not-a-version' })
+ expect(notices).toEqual([])
+ })
+})
+
+describe('the once-a-day cache', () => {
+ it('records when the check ran', async () => {
+ const now = new Date('2026-09-02T10:00:00.000Z')
+ await run({ now })
+ expect(JSON.parse(readFileSync(cachePath(), 'utf8'))).toEqual({
+ version: 1,
+ checkedAt: '2026-09-02T10:00:00.000Z',
+ })
+ expect(statSync(cachePath()).mode & 0o022).toBe(0)
+ })
+
+ it('does not contact the registry again within the day', async () => {
+ await run({ now: new Date('2026-09-02T10:00:00.000Z') })
+ fetched = []
+ notices = []
+ await run({ now: new Date('2026-09-02T22:00:00.000Z') })
+ expect(fetched).toEqual([])
+ expect(notices).toEqual([])
+ })
+
+ it('checks again once the day is up', async () => {
+ await run({ now: new Date('2026-09-02T10:00:00.000Z') })
+ notices = []
+ await run({ now: new Date('2026-09-03T11:00:00.000Z') })
+ expect(notices).toHaveLength(1)
+ })
+
+ it('checks again when the clock has moved backwards', async () => {
+ await run({ now: new Date('2026-09-02T10:00:00.000Z') })
+ notices = []
+ await run({ now: new Date('2026-09-01T10:00:00.000Z') })
+ expect(notices).toHaveLength(1)
+ })
+
+ it('re-checks rather than trusting a truncated file', async () => {
+ writeFileSync(cachePath(), '{"version": 1, "checked')
+ await run()
+ expect(notices).toHaveLength(1)
+ })
+
+ it('re-checks rather than trusting a cache a newer CLI wrote', async () => {
+ writeFileSync(cachePath(), JSON.stringify({ version: 99, checkedAt: new Date().toISOString() }))
+ await run()
+ expect(notices).toHaveLength(1)
+ })
+
+ it('re-checks rather than trusting an oversized valid fresh cache', async () => {
+ const now = new Date('2026-09-02T10:00:00.000Z')
+ writeFileSync(
+ cachePath(),
+ JSON.stringify({
+ version: 1,
+ checkedAt: now.toISOString(),
+ padding: 'x'.repeat(1024 * 1024),
+ })
+ )
+
+ await run({ now })
+
+ expect(fetched).toHaveLength(1)
+ expect(notices).toHaveLength(1)
+ })
+
+ it('replaces a hard-linked cache without modifying its other name', async () => {
+ const victimPath = join(configDir, 'victim')
+ writeFileSync(victimPath, 'do not overwrite')
+ linkSync(victimPath, cachePath())
+
+ await run()
+
+ expect(readFileSync(victimPath, 'utf8')).toBe('do not overwrite')
+ expect(JSON.parse(readFileSync(cachePath(), 'utf8'))).toMatchObject({
+ version: 1,
+ checkedAt: expect.any(String),
+ })
+ })
+
+ it('re-checks rather than following a cache symlink', async () => {
+ const victimPath = join(configDir, 'victim')
+ writeFileSync(victimPath, JSON.stringify({ version: 1, checkedAt: new Date().toISOString() }))
+ symlinkSync(victimPath, cachePath())
+
+ await run()
+
+ expect(fetched).toHaveLength(1)
+ expect(notices).toHaveLength(1)
+ expect(readFileSync(victimPath, 'utf8')).toContain('"version":1')
+ })
+
+ it('still runs the command when the cache cannot be written', async () => {
+ const wall = join(configDir, 'wall')
+ writeFileSync(wall, 'not a directory')
+ process.env.SIM_CONFIG_DIR = join(wall, 'sim')
+ await expect(run()).resolves.toBeUndefined()
+ expect(notices).toHaveLength(1)
+ })
+})
+
+describe('when the registry does not answer', () => {
+ it.each([
+ ['the request fails', 'reject' as const],
+ ['the response is an error', 'not-found' as const],
+ ['a proxy answers with an HTML page', 'html' as const],
+ ])('stays silent and does not throw when %s', async (_label, behaviour) => {
+ stubRegistry(behaviour)
+ await expect(run()).resolves.toBeUndefined()
+ expect(notices).toEqual([])
+ })
+
+ it.each([
+ ['an empty object', {} as Record],
+ ['a non-string tag value', { latest: 42 } as Record],
+ ['a nested object where a version belongs', { latest: { version: '9.9.9' } }],
+ ])('stays silent when the payload carries %s', async (_label, payload) => {
+ stubRegistry(payload)
+ await run()
+ expect(notices).toEqual([])
+ })
+
+ it('refuses a body far larger than this endpoint could legitimately return', async () => {
+ stubRegistry('oversized')
+ await run()
+ expect(notices).toEqual([])
+ })
+
+ it('stays silent when the tag is missing or is not a version', async () => {
+ stubRegistry({ staging: '2.1.6-preview.1.1' })
+ await run()
+ expect(notices).toEqual([])
+
+ rmSync(cachePath(), { force: true })
+ stubRegistry({ latest: 'nonsense' })
+ await run()
+ expect(notices).toEqual([])
+ })
+
+ it('still records the attempt, so a dead registry costs one request a day', async () => {
+ stubRegistry('reject')
+ await run({ now: new Date('2026-09-02T10:00:00.000Z') })
+ expect(JSON.parse(readFileSync(cachePath(), 'utf8'))).toEqual({
+ version: 1,
+ checkedAt: '2026-09-02T10:00:00.000Z',
+ })
+ })
+
+ it('asks a configured mirror instead of the default', async () => {
+ await run({ env: { npm_config_registry: 'https://npm.internal/api/npm' } })
+ expect(fetched.map(String)).toEqual(['https://npm.internal/api/npm/-/package/sim/dist-tags'])
+ })
+
+ it('refuses registry URLs with username/password userinfo', async () => {
+ await run({ env: { npm_config_registry: 'https://user:secret@npm.internal/api/npm' } })
+ expect(fetched).toEqual([])
+ expect(notices).toEqual([])
+ })
+
+ it.each([
+ ['a value that is not a URL', 'not a url'],
+ ['a non-HTTP protocol', 'file:///var/tmp/registry'],
+ ])('makes no request for %s', async (_label, configured) => {
+ await run({ env: { npm_config_registry: configured } })
+ expect(fetched).toEqual([])
+ expect(notices).toEqual([])
+ })
+
+ it('uses the default registry when the configured value is only whitespace', async () => {
+ await run({ env: { npm_config_registry: ' ' } })
+ expect(fetched.map(String)).toEqual(['https://registry.npmjs.org/-/package/sim/dist-tags'])
+ })
+
+ it("keeps a token-authenticated mirror's own path and query", async () => {
+ await run({ env: { npm_config_registry: 'https://npm.internal/api/npm/repo?token=abc' } })
+ expect(fetched.map(String)).toEqual([
+ 'https://npm.internal/api/npm/repo/-/package/sim/dist-tags?token=abc',
+ ])
+ })
+})
+
+describe('the upgrade command', () => {
+ it.each([
+ ['/usr/local/lib/node_modules/sim/dist/index.js', 'npm install -g sim@latest'],
+ ['/Users/x/.bun/install/global/node_modules/sim/dist/index.js', 'bun add -g sim@latest'],
+ ['/Users/x/Library/pnpm/global/5/node_modules/sim/dist/index.js', 'pnpm add -g sim@latest'],
+ ['/Users/x/.yarn/global/node_modules/sim/dist/index.js', 'yarn global add sim@latest'],
+ [
+ 'C:\\Users\\x\\AppData\\Roaming\\npm\\node_modules\\sim\\dist\\index.js',
+ 'npm install -g sim@latest',
+ ],
+ [
+ 'C:\\Users\\x\\AppData\\Local\\pnpm\\global\\5\\node_modules\\sim\\dist\\index.js',
+ 'pnpm add -g sim@latest',
+ ],
+ ])('reads %s as the installation it is', (modulePath, expected) => {
+ expect(upgradeCommand(modulePath, {})).toBe(expected)
+ })
+
+ it('falls back to the invoking package manager when the path says nothing', () => {
+ expect(upgradeCommand(INSTALLED, { npm_config_user_agent: 'pnpm/9.1.0 npm/? node/v22' })).toBe(
+ 'pnpm add -g sim@latest'
+ )
+ })
+})
diff --git a/packages/sim-cli/src/update/check.ts b/packages/sim-cli/src/update/check.ts
new file mode 100644
index 00000000000..900ef4752e7
--- /dev/null
+++ b/packages/sim-cli/src/update/check.ts
@@ -0,0 +1,475 @@
+/**
+ * The once-a-day "there is a newer sim" notice.
+ *
+ * It exists because a missing subcommand is indistinguishable from a feature
+ * that was never built: someone on 2.1.2 looking for `sim tools execute` — added
+ * in 2.1.5 — sees a help listing without it and concludes the CLI cannot do it.
+ * The version is the only thing that can tell them otherwise.
+ *
+ * Everything here fails silently. A courtesy notice that breaks a command, or
+ * that writes anything to stdout, is worse than no notice at all.
+ */
+
+import { spawn } from 'node:child_process'
+import {
+ closeSync,
+ constants,
+ fstatSync,
+ lstatSync,
+ mkdirSync,
+ openSync,
+ readSync,
+ renameSync,
+ unlinkSync,
+ writeFileSync,
+} from 'node:fs'
+import { dirname } from 'node:path'
+import { fileURLToPath } from 'node:url'
+import { updateCachePath } from '../config/paths'
+import { CLI_VERSION } from '../version'
+
+/** How long a cached check suppresses another request. */
+const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000
+
+/** Courtesy work gets a short deadline independent of command request timeouts. */
+const REGISTRY_TIMEOUT_MS = 1000
+
+const DEFAULT_REGISTRY = 'https://registry.npmjs.org'
+
+/** Published package name used in both the registry path and upgrade command. */
+const PACKAGE_NAME = 'sim'
+
+/** Relative to the registry root, and about a hundred bytes of response. */
+const DIST_TAGS_PATH = `-/package/${PACKAGE_NAME}/dist-tags`
+
+/** Bounds responses from the environment-configurable registry host. */
+const MAX_RESPONSE_BYTES = 64 * 1024
+
+/** Far above the small timestamp-only cache while still bounding hostile files. */
+const MAX_CACHE_BYTES = 4 * 1024
+
+/** Stable SemVer, with optional build metadata that does not affect precedence. */
+const STABLE_VERSION_PATTERN =
+ /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/
+
+type StableVersion = readonly [major: number, minor: number, patch: number]
+
+/** Parses only stable versions because prerelease installations are never notified. */
+function parseStableVersion(version: string): StableVersion | null {
+ const match = STABLE_VERSION_PATTERN.exec(version)
+ if (!match) return null
+ const parsed = [Number(match[1]), Number(match[2]), Number(match[3])] as const
+ return parsed.every(Number.isSafeInteger) ? parsed : null
+}
+
+function isNewerVersion(candidate: StableVersion, current: StableVersion): boolean {
+ if (candidate[0] !== current[0]) return candidate[0] > current[0]
+ if (candidate[1] !== current[1]) return candidate[1] > current[1]
+ return candidate[2] > current[2]
+}
+
+/** Covers CI jobs that allocate a terminal despite being non-interactive. */
+const CI_VARIABLES = [
+ 'CI',
+ 'GITHUB_ACTIONS',
+ 'JENKINS_URL',
+ 'TEAMCITY_VERSION',
+ 'BUILDKITE',
+] as const
+
+/** The shape written to the update cache. */
+interface UpdateCacheEntry {
+ /** Unknown cache versions are treated as absent. */
+ version: 1
+ checkedAt: string
+}
+
+const CACHE_VERSION = 1
+
+export interface UpdateCheckOptions {
+ /** Current working directory. Injected so project-local installation detection is testable. */
+ cwd?: string
+ currentVersion?: string
+ env?: NodeJS.ProcessEnv
+ /** Whether stderr is a terminal. Injected so the suppression rule is testable. */
+ isTty?: boolean
+ /** Location of the running module, used to recognise npx and local builds. */
+ modulePath?: string
+ now?: Date
+ /** Registry transport. Injectable so network behavior can be tested without global state. */
+ registryRequest?: RegistryRequest
+ write?: (message: string) => void
+}
+
+interface RegistryRequestOptions {
+ headers: Record
+ maxResponseBytes: number
+ timeoutMs: number
+}
+
+type RegistryRequest = (url: URL, options: RegistryRequestOptions) => Promise
+
+/** Makes adjacent temporary files unique across writes in this process. */
+let cacheWriteSequence = 0
+
+/** Anything but unset, empty, `0` or `false` turns a switch on. */
+function isEnabled(value: string | undefined): boolean {
+ if (value === undefined) return false
+ const normalized = value.trim().toLowerCase()
+ return normalized !== '' && normalized !== '0' && normalized !== 'false'
+}
+
+/** Whether the package is installed in a node_modules tree above the working directory. */
+function isProjectLocalInstall(modulePath: string, cwd: string): boolean {
+ const normalizedModulePath = normalizeModulePath(modulePath)
+ const nodeModulesIndex = normalizedModulePath.indexOf('/node_modules/')
+ if (nodeModulesIndex < 0) return false
+
+ const installRoot = normalizedModulePath.slice(0, nodeModulesIndex)
+ const workingDirectory = normalizeModulePath(cwd).replace(/\/+$/, '')
+ return workingDirectory === installRoot || workingDirectory.startsWith(`${installRoot}/`)
+}
+
+/** Skips ephemeral, project-local, and checkout installs that global advice cannot update. */
+function isUnadvisableInstall(modulePath: string, env: NodeJS.ProcessEnv, cwd: string): boolean {
+ const normalized = normalizeModulePath(modulePath)
+ return (
+ env.npm_command === 'exec' ||
+ normalized.includes('/_npx/') ||
+ normalized.includes('/packages/sim-cli/') ||
+ isProjectLocalInstall(modulePath, cwd)
+ )
+}
+
+/** Normalizes separators and case before installation-path comparisons. */
+function normalizeModulePath(modulePath: string): string {
+ return modulePath.replace(/\\/g, '/').toLowerCase()
+}
+
+/**
+ * The full dist-tags URL, honouring a configured mirror.
+ *
+ * A configured private registry keeps its path and query. `.npmrc` is not read;
+ * supporting its scoped configuration and auth is outside this courtesy check.
+ */
+function registryUrl(env: NodeJS.ProcessEnv): URL | null {
+ const fallback = new URL(DIST_TAGS_PATH, DEFAULT_REGISTRY)
+ const configured = env.npm_config_registry?.trim()
+ if (!configured) return fallback
+ try {
+ const base = new URL(configured)
+ if (base.protocol !== 'http:' && base.protocol !== 'https:') return null
+ if (base.username || base.password) return null
+ base.pathname = `${base.pathname.replace(/\/$/, '')}/${DIST_TAGS_PATH}`
+ return base
+ } catch {
+ return null
+ }
+}
+
+const REGISTRY_REQUEST_SCRIPT = `
+let input = ''
+process.stdin.setEncoding('utf8')
+for await (const chunk of process.stdin) input += chunk
+
+try {
+ const { url, headers, maxResponseBytes, timeoutMs } = JSON.parse(input)
+ const deadline = setTimeout(() => process.exit(1), timeoutMs)
+ const response = await fetch(url, { headers, redirect: 'error' })
+ const declared = Number(response.headers.get('content-length'))
+
+ if (!response.ok || !response.body || (Number.isFinite(declared) && declared > maxResponseBytes)) {
+ process.exit(1)
+ }
+
+ const reader = response.body.getReader()
+ const chunks = []
+ let seen = 0
+
+ while (true) {
+ const { done, value } = await reader.read()
+ if (done) break
+ seen += value.byteLength
+ if (seen > maxResponseBytes) {
+ process.exit(1)
+ }
+ chunks.push(Buffer.from(value))
+ }
+
+ clearTimeout(deadline)
+ process.stdout.write(Buffer.concat(chunks), () => process.exit(0))
+} catch {
+ process.exit(1)
+}
+`
+
+/** Preserves proxy/TLS settings without copying CLI credentials into the probe. */
+function registryProcessEnv(): NodeJS.ProcessEnv {
+ const env = { ...process.env }
+ for (const key of Object.keys(env)) {
+ const normalized = key.toLowerCase()
+ if (normalized === 'npm_config_registry' || normalized === 'sim_api_key') delete env[key]
+ }
+ return env
+}
+
+/**
+ * Makes one request in a process whose lifetime is owned entirely by this check.
+ *
+ * Neither a Fetch abort nor `ClientRequest.destroy()` can cancel every pending
+ * operation: Undici may retain a connection attempt, and the native client
+ * cannot cancel an OS `dns.lookup()`. Terminating this child at the deadline
+ * closes both escape hatches. Input travels over stdin rather than argv or the
+ * environment so a configured registry credential cannot appear in a process
+ * listing.
+ */
+function requestRegistry(
+ url: URL,
+ { headers, maxResponseBytes, timeoutMs }: RegistryRequestOptions
+): Promise {
+ return new Promise((resolve, reject) => {
+ const proxyArguments = process.execArgv.filter(
+ (argument) => argument === '--use-env-proxy' || argument === '--no-use-env-proxy'
+ )
+ const child = spawn(
+ process.execPath,
+ [...proxyArguments, '--input-type=module', '--eval', REGISTRY_REQUEST_SCRIPT],
+ {
+ env: registryProcessEnv(),
+ killSignal: 'SIGKILL',
+ stdio: ['pipe', 'pipe', 'ignore'],
+ timeout: timeoutMs,
+ windowsHide: true,
+ }
+ )
+ const chunks: Buffer[] = []
+ let failed = false
+ let seen = 0
+
+ child.stdout.on('data', (chunk: Buffer) => {
+ seen += chunk.byteLength
+ if (seen > maxResponseBytes) {
+ failed = true
+ child.kill('SIGKILL')
+ return
+ }
+ chunks.push(chunk)
+ })
+ child.stdout.on('error', () => {
+ failed = true
+ child.kill('SIGKILL')
+ })
+ child.stdin.on('error', () => {})
+ child.once('error', reject)
+ child.once('close', (code) => {
+ resolve(code === 0 && !failed ? Buffer.concat(chunks).toString('utf8') : null)
+ })
+ child.stdin.end(JSON.stringify({ headers, maxResponseBytes, timeoutMs, url: url.href }))
+ })
+}
+
+/**
+ * The published dist-tags, or null if anything at all goes wrong.
+ *
+ * `-/package/sim/dist-tags` is about a hundred bytes and answers exactly the
+ * question asked. The abbreviated packument would be tens of kilobytes and list
+ * every version ever published.
+ *
+ * The User-Agent is cut down to the bare version: the full one from
+ * `version.ts` carries the Node version, platform and architecture, which is
+ * useful in our own logs and gratuitous to hand a third party.
+ */
+async function fetchDistTags(
+ env: NodeJS.ProcessEnv,
+ request: RegistryRequest
+): Promise | null> {
+ try {
+ const url = registryUrl(env)
+ if (!url) return null
+ const text = await request(url, {
+ headers: { accept: 'application/json', 'user-agent': `${PACKAGE_NAME}-cli/${CLI_VERSION}` },
+ maxResponseBytes: MAX_RESPONSE_BYTES,
+ timeoutMs: REGISTRY_TIMEOUT_MS,
+ })
+ if (text === null || Buffer.byteLength(text) > MAX_RESPONSE_BYTES) return null
+ const body: unknown = JSON.parse(text)
+ if (typeof body !== 'object' || body === null || Array.isArray(body)) return null
+ const tags: Record = {}
+ for (const [tag, version] of Object.entries(body)) {
+ if (typeof version === 'string') tags[tag] = version
+ }
+ return tags
+ } catch {
+ return null
+ }
+}
+
+function readCache(path: string): UpdateCacheEntry | null {
+ let descriptor: number | null = null
+ try {
+ if (!lstatSync(path).isFile()) return null
+ descriptor = openSync(path, constants.O_RDONLY | constants.O_NONBLOCK | constants.O_NOFOLLOW)
+ const descriptorStats = fstatSync(descriptor)
+ if (!descriptorStats.isFile() || descriptorStats.size > MAX_CACHE_BYTES) {
+ return null
+ }
+
+ const buffer = Buffer.allocUnsafe(MAX_CACHE_BYTES + 1)
+ let bytesRead = 0
+ while (bytesRead < buffer.byteLength) {
+ const count = readSync(
+ descriptor,
+ buffer,
+ bytesRead,
+ buffer.byteLength - bytesRead,
+ bytesRead
+ )
+ if (count === 0) break
+ bytesRead += count
+ }
+ if (bytesRead > MAX_CACHE_BYTES) return null
+
+ const parsed: unknown = JSON.parse(buffer.subarray(0, bytesRead).toString('utf8'))
+ if (typeof parsed !== 'object' || parsed === null) return null
+ const entry = parsed as Partial
+ if (entry.version !== CACHE_VERSION) return null
+ if (typeof entry.checkedAt !== 'string' || Number.isNaN(Date.parse(entry.checkedAt)))
+ return null
+ return {
+ version: CACHE_VERSION,
+ checkedAt: entry.checkedAt,
+ }
+ } catch {
+ return null
+ } finally {
+ if (descriptor !== null) {
+ try {
+ closeSync(descriptor)
+ } catch {}
+ }
+ }
+}
+
+/**
+ * Records that a check happened, whether or not it produced an answer.
+ *
+ * Stamping on failure too is what keeps a blackholed registry costing one second
+ * a day instead of one second per command.
+ *
+ * Failures are ignored because the cache is best-effort. An exclusive adjacent
+ * temporary file makes replacement atomic without modifying a linked target.
+ */
+function writeCache(path: string, entry: UpdateCacheEntry): void {
+ let descriptor: number | null = null
+ let temporaryCreated = false
+ const temporaryPath = `${path}.${process.pid}.${Date.now()}.${cacheWriteSequence++}.tmp`
+ try {
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 })
+ descriptor = openSync(temporaryPath, 'wx', 0o644)
+ temporaryCreated = true
+ writeFileSync(descriptor, `${JSON.stringify(entry, null, 2)}\n`)
+ closeSync(descriptor)
+ descriptor = null
+ renameSync(temporaryPath, path)
+ temporaryCreated = false
+ } catch {
+ } finally {
+ if (descriptor !== null) {
+ try {
+ closeSync(descriptor)
+ } catch {}
+ }
+ if (temporaryCreated) {
+ try {
+ unlinkSync(temporaryPath)
+ } catch {}
+ }
+ }
+}
+
+/** Treats future timestamps as stale in case the clock moved backward. */
+function isFresh(entry: UpdateCacheEntry, now: Date): boolean {
+ const age = now.getTime() - Date.parse(entry.checkedAt)
+ return age >= 0 && age < CHECK_INTERVAL_MS
+}
+
+/**
+ * The command that upgrades *this* installation.
+ *
+ * The path is asked first because it describes the installation; the
+ * environment is a fallback because for a globally installed CLI it usually
+ * describes nothing but the shell that happened to invoke it.
+ */
+export function upgradeCommand(
+ modulePath: string = fileURLToPath(import.meta.url),
+ env: NodeJS.ProcessEnv = process.env
+): string {
+ const target = `${PACKAGE_NAME}@latest`
+ const normalized = normalizeModulePath(modulePath)
+
+ if (normalized.includes('.bun/install/global')) return `bun add -g ${target}`
+ if (normalized.includes('/pnpm/') || normalized.includes('/.pnpm/')) {
+ return `pnpm add -g ${target}`
+ }
+ if (normalized.includes('/.yarn/') || normalized.includes('/yarn/')) {
+ return `yarn global add ${target}`
+ }
+
+ const agent = env.npm_config_user_agent ?? ''
+ if (agent.startsWith('pnpm/')) return `pnpm add -g ${target}`
+ if (agent.startsWith('yarn/')) return `yarn global add ${target}`
+ if (agent.startsWith('bun/')) return `bun add -g ${target}`
+
+ return `npm install -g ${target}`
+}
+
+/**
+ * Uses a daily cache before telling the user their installation is out of date.
+ *
+ * Wired as a root `preAction` hook rather than a teardown in the entrypoint for
+ * two structural reasons: commander answers `--help` and `--version` during
+ * parsing, before any action hook runs, so the two most latency-sensitive
+ * invocations are excluded by construction rather than by a check; and some
+ * commands call `process.exit` directly, which a `finally` would never see.
+ *
+ * Never throws, writes only plain text to stderr, and stays silent when stderr
+ * is redirected. The caller runs this before the user's actual command.
+ */
+export async function announceUpdateIfAvailable(options: UpdateCheckOptions = {}): Promise {
+ try {
+ const env = options.env ?? process.env
+ const isTty = options.isTty ?? process.stderr.isTTY === true
+ const modulePath = options.modulePath ?? fileURLToPath(import.meta.url)
+ const cwd = options.cwd ?? process.cwd()
+ const now = options.now ?? new Date()
+
+ if (isEnabled(env.SIM_NO_UPDATE_CHECK)) return
+ if (!isTty) return
+ if (CI_VARIABLES.some((variable) => isEnabled(env[variable]))) return
+ if (isUnadvisableInstall(modulePath, env, cwd)) return
+
+ const currentVersion = options.currentVersion ?? CLI_VERSION
+ const current = parseStableVersion(currentVersion)
+ if (!current) return
+
+ const cachePath = updateCachePath()
+ const cached = readCache(cachePath)
+ if (cached && isFresh(cached, now)) return
+
+ const tags = await fetchDistTags(env, options.registryRequest ?? requestRegistry)
+ const latest = tags?.latest ?? null
+ const available = latest ? parseStableVersion(latest) : null
+ writeCache(cachePath, {
+ version: CACHE_VERSION,
+ checkedAt: now.toISOString(),
+ })
+ if (!latest || !available) return
+
+ if (!isNewerVersion(available, current)) return
+
+ const write = options.write ?? ((message: string) => void process.stderr.write(message))
+ write(
+ `Update available: sim ${currentVersion} → ${latest}. Run: ${upgradeCommand(modulePath, env)}\n`
+ )
+ } catch {}
+}