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 {} +}