From 38ea19c5b8f224f054c9f788a833fc332f9a1deb Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Thu, 10 Sep 2026 17:45:36 -0700 Subject: [PATCH 1/3] feat(cli): add explicit self-update command and update notices --- apps/docs/content/docs/cli/commands.mdx | 16 ++ apps/docs/content/docs/cli/configuration.mdx | 25 +- apps/docs/content/docs/cli/index.mdx | 5 + apps/docs/content/docs/cli/reference.mdx | 18 ++ .../docs/content/docs/cli/troubleshooting.mdx | 10 +- packages/sim-cli/README.md | 32 ++- packages/sim-cli/package.json | 3 + packages/sim-cli/src/commands/update.test.ts | 52 ++++ packages/sim-cli/src/commands/update.ts | 15 ++ packages/sim-cli/src/index.ts | 3 +- packages/sim-cli/src/program.ts | 8 +- .../sim-cli/src/update/check.process.test.ts | 4 +- packages/sim-cli/src/update/check.test.ts | 4 +- packages/sim-cli/src/update/check.ts | 8 +- .../src/update/install.process.test.ts | 109 +++++++++ packages/sim-cli/src/update/install.test.ts | 229 ++++++++++++++++++ packages/sim-cli/src/update/install.ts | 165 +++++++++++++ 17 files changed, 679 insertions(+), 27 deletions(-) create mode 100644 packages/sim-cli/src/commands/update.test.ts create mode 100644 packages/sim-cli/src/commands/update.ts create mode 100644 packages/sim-cli/src/update/install.process.test.ts create mode 100644 packages/sim-cli/src/update/install.test.ts create mode 100644 packages/sim-cli/src/update/install.ts diff --git a/apps/docs/content/docs/cli/commands.mdx b/apps/docs/content/docs/cli/commands.mdx index d5454bfcd66..1c565753a0f 100644 --- a/apps/docs/content/docs/cli/commands.mdx +++ b/apps/docs/content/docs/cli/commands.mdx @@ -124,6 +124,22 @@ sim configure [options] +## Update this global CLI installation to the newest release on its channel + +```bash +sim update [options] +``` + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--package-manager ` | No | Package manager that installed this copy. Accepted values: `npm`, `pnpm`, `bun`, `yarn`. | + + + ## Ask Sim and print the reply ```bash diff --git a/apps/docs/content/docs/cli/configuration.mdx b/apps/docs/content/docs/cli/configuration.mdx index 9a6d09b5d8c..0db7963c611 100644 --- a/apps/docs/content/docs/cli/configuration.mdx +++ b/apps/docs/content/docs/cli/configuration.mdx @@ -120,13 +120,24 @@ endpoint or stored login. | `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 | +| `SIM_NO_UPDATE_CHECK` | Turn off update checks and notices | -## Update notices +## Updates -The CLI checks for a newer release at most once per day on eligible interactive -invocations. Notices go to stderr and show an upgrade command for the package -manager that installed Sim. +The CLI checks for a newer stable release at most once per day on eligible +interactive invocations. It prints an optional notice to stderr and continues +your command. Installation only happens when you run `sim update`. + +Run `sim update` to update immediately, including in CI and when automatic checks +are disabled. It requires a global installation and verifies that the package +manager targets the running copy before installing. Supported managers are npm, +pnpm, Bun, and Yarn Classic. For custom installations, select the manager with +`sim update --package-manager bun`. Project-local and temporary package-runner +copies must be updated through their package manager. + +Manual updates preserve the stable, staging, or dev release channel. Installation +failures stop with an error; concurrent update attempts are refused. Installer +output goes to stderr and does not mix with JSON output on stdout. Checks are skipped in CI, when stderr is redirected, under `npm exec` or `npx`, from a repository checkout, and for prerelease versions. Set @@ -141,7 +152,9 @@ malformed non-empty values disable the check. Redirects are not followed. The daily cache is `~/.sim/update-check.json`, or under `SIM_CONFIG_DIR`. `SIM_CONFIG_FILE` and `SIM_CREDENTIALS_FILE` do not relocate it. If the cache cannot be written, eligible invocations may check again. Concurrent commands -can also each check. Requests have a one-second deadline. +can also each check. Registry checks have a one-second deadline; package-manager +installation has a five-minute deadline. Registry-check failures suppress the +check, while installer failures are reported. 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+). diff --git a/apps/docs/content/docs/cli/index.mdx b/apps/docs/content/docs/cli/index.mdx index 3a0107f247a..cf2b958cdef 100644 --- a/apps/docs/content/docs/cli/index.mdx +++ b/apps/docs/content/docs/cli/index.mdx @@ -34,6 +34,11 @@ or local configuration. Requires Node.js 20 or newer. Verify with `sim --version`. +The CLI shows optional update notices on eligible interactive invocations. Run +`sim update` when you want to install the update. See +[Updates](/cli/configuration#updates) for installation requirements and how to +disable notices. + To run it without installing, use `npx sim `. Using Sim as a library instead? See the [TypeScript](/api-reference/typescript) diff --git a/apps/docs/content/docs/cli/reference.mdx b/apps/docs/content/docs/cli/reference.mdx index 054a8290273..b0e4f065045 100644 --- a/apps/docs/content/docs/cli/reference.mdx +++ b/apps/docs/content/docs/cli/reference.mdx @@ -103,6 +103,24 @@ sim configure [options] +## sim update + +Update this global CLI installation to the newest release on its channel + +```bash +sim update [options] +``` + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--package-manager ` | No | Package manager that installed this copy. Accepted values: `npm`, `pnpm`, `bun`, `yarn`. | + + + ## sim chat Ask Sim and print the reply diff --git a/apps/docs/content/docs/cli/troubleshooting.mdx b/apps/docs/content/docs/cli/troubleshooting.mdx index f577f21e1de..f5bd7da2281 100644 --- a/apps/docs/content/docs/cli/troubleshooting.mdx +++ b/apps/docs/content/docs/cli/troubleshooting.mdx @@ -99,7 +99,8 @@ The docs track the current release, so a command that exists here and not in sim --version ``` -Then upgrade with the package manager you installed it with — using a different +Run `sim update` to update the active global installation. If this older release +does not yet have the `update` command, 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`: @@ -125,9 +126,10 @@ one installs a second copy instead of replacing the executable on your `PATH`: -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`. +The CLI also shows an optional update notice through a cached daily check on +eligible interactive invocations. Your command continues, and you choose when to +run `sim update`. Checks stay quiet when stderr is redirected, in CI, and under +`npm exec` or `npx`. ## An update notice appears in output I am parsing diff --git a/packages/sim-cli/README.md b/packages/sim-cli/README.md index d12e65321b9..35484e84421 100644 --- a/packages/sim-cli/README.md +++ b/packages/sim-cli/README.md @@ -25,6 +25,30 @@ You can also run a command without installing the package globally: npx sim --help ``` +## Updates + +The CLI checks for a newer stable release on eligible interactive invocations, +at most once per day. It prints an optional update notice and continues your +command. Updates install only when you run `sim update`. + +Update immediately, including in CI or with automatic checks disabled: + +```bash +sim update +``` + +The updater uses the package manager that installed the running copy and verifies +its global installation before making changes. Supported managers are npm, pnpm, +Bun, and Yarn Classic. Use `sim update --package-manager bun` if detection does +not match a custom installation. Manual updates preserve staging and dev channels. +Installation failures exit with an error; concurrent update attempts are refused. + +Set `SIM_NO_UPDATE_CHECK=1` to disable update notices. Project-local installs and +temporary package-runner copies must be updated through their package manager. + +Older releases without `sim update` need one upgrade using the package manager +that installed them before this mechanism becomes available. + ## Get started Sign in to the default profile: @@ -290,12 +314,12 @@ The main environment variables are: | `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 | +| `SIM_NO_UPDATE_CHECK` | Turn off update notices | 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`; +`registry.npmjs.org` what is published under the `latest` tag and prints an +optional notice 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. diff --git a/packages/sim-cli/package.json b/packages/sim-cli/package.json index 5bd8b1ce27a..6fa86a4c51f 100644 --- a/packages/sim-cli/package.json +++ b/packages/sim-cli/package.json @@ -3,6 +3,9 @@ "version": "2.1.2", "description": "Sim CLI - talk to the Sim API from your terminal", "type": "module", + "imports": { + "#sim-cli/*": "./src/*.ts" + }, "bin": { "sim": "dist/index.js" }, diff --git a/packages/sim-cli/src/commands/update.test.ts b/packages/sim-cli/src/commands/update.test.ts new file mode 100644 index 00000000000..155b3ae9dff --- /dev/null +++ b/packages/sim-cli/src/commands/update.test.ts @@ -0,0 +1,52 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { installUpdate, announceUpdateIfAvailable } = vi.hoisted(() => ({ + installUpdate: vi.fn(), + announceUpdateIfAvailable: vi.fn(), +})) + +vi.mock('#sim-cli/update/install', () => ({ installUpdate })) +vi.mock('#sim-cli/update/check', () => ({ announceUpdateIfAvailable })) + +import { buildProgram } from '#sim-cli/program' + +beforeEach(() => { + vi.clearAllMocks() + installUpdate.mockResolvedValue(undefined) + announceUpdateIfAvailable.mockResolvedValue(undefined) +}) + +describe('update command wiring', () => { + it('runs a manual update without the daily check or authentication', async () => { + await buildProgram().parseAsync(['node', 'sim', 'update']) + expect(announceUpdateIfAvailable).not.toHaveBeenCalled() + expect(installUpdate).toHaveBeenCalledExactlyOnceWith({ packageManager: undefined }) + }) + + it('passes an explicit package manager to the updater', async () => { + await buildProgram().parseAsync(['node', 'sim', 'update', '--package-manager', 'bun']) + expect(installUpdate).toHaveBeenCalledExactlyOnceWith({ packageManager: 'bun' }) + }) + + it('checks for a notice and continues the requested action without installing', async () => { + const program = buildProgram() + const action = vi.fn() + program.commands.find((command) => command.name() === 'whoami')!.action(action) + await program.parseAsync(['node', 'sim', 'whoami']) + expect(announceUpdateIfAvailable).toHaveBeenCalledOnce() + expect(action).toHaveBeenCalledOnce() + expect(installUpdate).not.toHaveBeenCalled() + }) + + it('propagates an explicit update failure', async () => { + installUpdate.mockRejectedValueOnce(new Error('installation failed')) + const program = buildProgram() + await expect(program.parseAsync(['node', 'sim', 'update'])).rejects.toThrow( + 'installation failed' + ) + expect(announceUpdateIfAvailable).not.toHaveBeenCalled() + }) +}) diff --git a/packages/sim-cli/src/commands/update.ts b/packages/sim-cli/src/commands/update.ts new file mode 100644 index 00000000000..7dcd6f5e594 --- /dev/null +++ b/packages/sim-cli/src/commands/update.ts @@ -0,0 +1,15 @@ +import { Command, Option } from 'commander' +import { installUpdate, type PackageManager } from '#sim-cli/update/install' + +export function updateCommand(): Command { + return new Command('update') + .description('Update this global CLI installation to the newest release on its channel') + .addOption( + new Option('--package-manager ', 'Package manager that installed this copy').choices( + ['npm', 'pnpm', 'bun', 'yarn'] + ) + ) + .action(async (options: { packageManager?: PackageManager }) => { + await installUpdate({ packageManager: options.packageManager }) + }) +} diff --git a/packages/sim-cli/src/index.ts b/packages/sim-cli/src/index.ts index 36a983333f8..53c7b9a92ab 100644 --- a/packages/sim-cli/src/index.ts +++ b/packages/sim-cli/src/index.ts @@ -2,6 +2,7 @@ import chalk from 'chalk' import { dump } from 'js-yaml' +import { CliUpdateError } from '#sim-cli/update/install' import { ProfileConfigError } from './config/index' import { clientFrom } from './context' import { @@ -23,7 +24,7 @@ async function main() { try { await program.parseAsync(process.argv) } catch (error) { - if (error instanceof ProfileConfigError) { + if (error instanceof ProfileConfigError || error instanceof CliUpdateError) { console.error(chalk.red(`Error: ${sanitize(error.message)}`)) process.exit(1) } diff --git a/packages/sim-cli/src/program.ts b/packages/sim-cli/src/program.ts index 28eeb00a8be..f64eef62d93 100644 --- a/packages/sim-cli/src/program.ts +++ b/packages/sim-cli/src/program.ts @@ -1,4 +1,5 @@ import { Command, Option } from 'commander' +import { updateCommand } from '#sim-cli/commands/update' import { loginCommand, logoutCommand, profilesCommand, whoamiCommand } from './commands/auth' import { configureCommand } from './commands/configure' import { attachCredentialCommands } from './commands/credentials' @@ -142,6 +143,8 @@ export function buildProgram(options: { version?: boolean } = {}): Command { program.addCommand(whoamiCommand()) program.addCommand(profilesCommand()) program.addCommand(configureCommand()) + const update = updateCommand() + program.addCommand(update) for (const command of buildGeneratedCommands()) { program.addCommand(command) @@ -153,7 +156,10 @@ export function buildProgram(options: { version?: boolean } = {}): Command { program.addHelpText('after', HELP_EPILOGUE) - program.hook('preAction', () => announceUpdateIfAvailable()) + program.hook('preAction', async (_program, command) => { + if (command === update) return + await 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 index 03972c4c5d1..542e69b5543 100644 --- a/packages/sim-cli/src/update/check.process.test.ts +++ b/packages/sim-cli/src/update/check.process.test.ts @@ -304,9 +304,7 @@ it('preserves a mirror path, query, and reduced request headers', async () => { 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(output.notices).toEqual(['Update available: sim 2.1.2 → 2.1.5. Run: sim update\n']) expect(requestPath).toBe('/api/npm/repo/-/package/sim/dist-tags?token=abc') expect(requestHeaders).toMatchObject({ accept: 'application/json', diff --git a/packages/sim-cli/src/update/check.test.ts b/packages/sim-cli/src/update/check.test.ts index 9400d1f6a43..b899dc50d7e 100644 --- a/packages/sim-cli/src/update/check.test.ts +++ b/packages/sim-cli/src/update/check.test.ts @@ -79,9 +79,7 @@ afterEach(() => { 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' - ) + expect(notices.join('')).toBe('Update available: sim 2.1.2 → 2.1.5. Run: sim update\n') }) it('asks the registry for the dist-tags and nothing else', async () => { diff --git a/packages/sim-cli/src/update/check.ts b/packages/sim-cli/src/update/check.ts index 900ef4752e7..a6b105907bd 100644 --- a/packages/sim-cli/src/update/check.ts +++ b/packages/sim-cli/src/update/check.ts @@ -6,8 +6,8 @@ * 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. + * Registry and cache failures suppress the courtesy notice. Installation only + * happens when the user explicitly runs `sim update`. */ import { spawn } from 'node:child_process' @@ -468,8 +468,6 @@ export async function announceUpdateIfAvailable(options: UpdateCheckOptions = {} 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` - ) + write(`Update available: sim ${currentVersion} → ${latest}. Run: sim update\n`) } catch {} } diff --git a/packages/sim-cli/src/update/install.process.test.ts b/packages/sim-cli/src/update/install.process.test.ts new file mode 100644 index 00000000000..f154612a11b --- /dev/null +++ b/packages/sim-cli/src/update/install.process.test.ts @@ -0,0 +1,109 @@ +/** + * @vitest-environment node + */ +import { execFileSync, spawnSync } from 'node:child_process' +import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { delimiter, join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest' + +let directory: string +let entrypoint: string +let manifest: string +let bin: string +let modules: string + +beforeAll(() => { + directory = mkdtempSync(join(tmpdir(), 'sim-cli-update-process-')) + modules = join(directory, 'node_modules') + entrypoint = join(modules, 'sim/dist/index.js') + manifest = join(modules, 'sim/package.json') + bin = join(directory, 'bin') + mkdirSync(bin) + execFileSync('bun', [ + 'build', + fileURLToPath(new URL('../index.ts', import.meta.url)), + '--target=node', + '--format=esm', + '--packages=bundle', + '--outfile', + entrypoint, + ]) +}) + +beforeEach(() => { + writeFileSync(manifest, JSON.stringify({ name: 'sim', type: 'module', version: '2.1.2' })) +}) + +afterAll(() => { + rmSync(directory, { recursive: true, force: true }) +}) + +function fakePackageManager(exitCode = 0): void { + const script = ` +if (process.env.SIM_API_KEY) throw new Error('Sim API key leaked to package manager') +const args = process.argv.slice(2) +if (args.join(' ') === 'root -g') { + process.stdout.write(${JSON.stringify(modules)}) +} else if (args.join(' ') === 'install -g sim@latest') { + process.stdout.write('package manager stdout\\n') + process.stderr.write('package manager stderr\\n') + if (${exitCode} !== 0) process.exit(${exitCode}) + require('node:fs').writeFileSync(${JSON.stringify(manifest)}, JSON.stringify({ name: 'sim', type: 'module', version: '2.1.5' })) +} else { + throw new Error('Unexpected arguments: ' + args.join(' ')) +} +` + const executable = join(bin, 'npm') + writeFileSync(executable, `#!/usr/bin/env node\n${script}`) + chmodSync(executable, 0o755) +} + +function run(args: string[]) { + return spawnSync(process.execPath, [entrypoint, ...args], { + encoding: 'utf8', + timeout: 15_000, + env: { + ...process.env, + PATH: `${bin}${delimiter}${process.env.PATH}`, + SIM_CONFIG_DIR: join(directory, 'no-profile'), + SIM_API_KEY: 'must-not-reach-installer', + SIM_NO_UPDATE_CHECK: '1', + npm_command: '', + npm_config_user_agent: '', + }, + }) +} + +describe.skipIf(process.platform === 'win32')('the bundled sim update command', () => { + it('runs without login, updates the package, and keeps stdout clean', () => { + fakePackageManager() + const result = run(['update']) + expect(result.status).toBe(0) + expect(result.stdout).toBe('') + expect(result.stderr).toContain('package manager stdout') + expect(result.stderr).toContain('package manager stderr') + expect(result.stderr).toContain('Updated Sim 2.1.2 → 2.1.5') + expect(run(['--version']).stdout.trim()).toBe('2.1.5') + }) + + it('exits unsuccessfully on an installer failure without printing success', () => { + fakePackageManager(17) + const result = run(['update']) + expect(result.status).toBe(1) + expect(result.stdout).toBe('') + expect(result.stderr).toContain('exit 17') + expect(result.stderr).not.toContain('Updated Sim') + expect(run(['--version']).stdout.trim()).toBe('2.1.2') + }) + + it('answers update help without invoking the installer', () => { + fakePackageManager(17) + const result = run(['update', '--help']) + expect(result.status).toBe(0) + expect(result.stdout).toContain('sim update') + expect(result.stdout).toContain('--package-manager') + expect(result.stderr).toBe('') + }) +}) diff --git a/packages/sim-cli/src/update/install.test.ts b/packages/sim-cli/src/update/install.test.ts new file mode 100644 index 00000000000..d07c7ff66f2 --- /dev/null +++ b/packages/sim-cli/src/update/install.test.ts @@ -0,0 +1,229 @@ +/** + * @vitest-environment node + */ +import { + existsSync, + mkdirSync, + mkdtempSync, + rmSync, + symlinkSync, + unlinkSync, + writeFileSync, +} from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { installUpdate, type PackageManager } from '#sim-cli/update/install' + +let directory: string +let packageRoot: string +let modulePath: string +let output: string[] + +function writeVersion(version: string, root = packageRoot): void { + writeFileSync(join(root, 'package.json'), JSON.stringify({ name: 'sim', version })) +} + +beforeEach(() => { + directory = mkdtempSync(join(tmpdir(), 'sim-cli-install-update-')) + packageRoot = join(directory, 'node_modules/sim') + modulePath = join(packageRoot, 'dist/index.js') + mkdirSync(dirname(modulePath), { recursive: true }) + writeFileSync(modulePath, '') + writeVersion('2.1.2') + output = [] +}) + +afterEach(() => { + rmSync(directory, { recursive: true, force: true }) +}) + +function options() { + return { + modulePath, + env: {}, + currentVersion: '2.1.2', + write: (message: string) => output.push(message), + } +} + +describe('installing a CLI update', () => { + it.each(['npm', 'pnpm', 'bun', 'yarn'])( + 'updates the active global copy with %s', + async (packageManager) => { + const bin = join(directory, 'bin') + mkdirSync(bin) + symlinkSync(modulePath, join(bin, 'sim')) + const globalDirectory = + packageManager === 'bun' + ? bin + : packageManager === 'yarn' + ? directory + : join(directory, 'node_modules') + const run = vi + .fn() + .mockResolvedValueOnce(globalDirectory) + .mockImplementationOnce(() => { + writeVersion('2.1.5') + return Promise.resolve('') + }) + + await installUpdate({ ...options(), packageManager, run }) + + const expectedArgs = + packageManager === 'npm' + ? ['install', '-g', 'sim@latest'] + : packageManager === 'yarn' + ? ['global', 'add', 'sim@latest'] + : ['add', '-g', 'sim@latest'] + expect(run).toHaveBeenLastCalledWith(packageManager, expectedArgs, { + env: { SIM_NO_UPDATE_CHECK: '1' }, + capture: false, + }) + expect(output.join('')).toContain('Updated Sim 2.1.2 → 2.1.5') + expect(existsSync(`${packageRoot}.lock`)).toBe(false) + } + ) + + it.each([ + ['2.1.2', 'latest'], + ['2.1.2-preview.123.1', 'staging'], + ['2.1.2-dev.123.1', 'dev'], + ])('preserves the release channel for %s', async (currentVersion, tag) => { + writeVersion(currentVersion) + const run = vi.fn().mockResolvedValue(join(directory, 'node_modules')) + await installUpdate({ ...options(), currentVersion, run }) + expect(run.mock.calls[1][1]).toEqual(['install', '-g', `sim@${tag}`]) + }) + + it('does not pass the Sim API key to the package manager or change the parent environment', async () => { + const env = { SIM_API_KEY: 'private', npm_config_registry: 'https://registry.example' } + const run = vi.fn().mockResolvedValue(join(directory, 'node_modules')) + await installUpdate({ ...options(), env, run }) + expect(run.mock.calls[1][2].env).toEqual({ + npm_config_registry: 'https://registry.example', + SIM_NO_UPDATE_CHECK: '1', + }) + expect(env.SIM_API_KEY).toBe('private') + }) + + it('reports an already current installation', async () => { + await installUpdate({ + ...options(), + run: vi.fn().mockResolvedValue(join(directory, 'node_modules')), + }) + expect(output.join('')).toContain('already up to date') + }) + + it('verifies the new pnpm symlink instead of reading the old version directory', async () => { + const globalRoot = join(directory, 'global/node_modules') + mkdirSync(globalRoot, { recursive: true }) + const link = join(globalRoot, 'sim') + symlinkSync(packageRoot, link) + const nextRoot = join(directory, 'next/node_modules/sim') + mkdirSync(join(nextRoot, 'dist'), { recursive: true }) + writeFileSync(join(nextRoot, 'dist/index.js'), '') + writeVersion('2.1.5', nextRoot) + const run = vi + .fn() + .mockResolvedValueOnce(globalRoot) + .mockImplementationOnce(() => { + unlinkSync(link) + symlinkSync(nextRoot, link) + return Promise.resolve('') + }) + await installUpdate({ ...options(), packageManager: 'pnpm', run }) + expect(output.join('')).toContain('Updated Sim 2.1.2 → 2.1.5') + }) + + it('refuses to update another installation even when both versions match', async () => { + const other = join(directory, 'other/node_modules') + mkdirSync(join(other, 'sim/dist'), { recursive: true }) + writeFileSync(join(other, 'sim/dist/index.js'), '') + const run = vi.fn().mockResolvedValue(other) + await expect(installUpdate({ ...options(), run })).rejects.toThrow('different Sim installation') + expect(run).toHaveBeenCalledTimes(1) + }) + + it.each(['relative/path', '/path\nextra output', ''])( + 'refuses an invalid package-manager directory: %s', + async (path) => { + const run = vi.fn().mockResolvedValue(path) + await expect(installUpdate({ ...options(), run })).rejects.toThrow( + 'valid global installation path' + ) + expect(run).toHaveBeenCalledTimes(1) + } + ) + + it.each([ + 'checkout/src/index.ts', + '_npx/cache/node_modules/sim/dist/index.js', + 'bunx-123/node_modules/sim/dist/index.js', + ])('refuses a checkout or temporary installation: %s', async (path) => { + const entry = join(directory, path) + mkdirSync(dirname(entry), { recursive: true }) + writeFileSync(entry, '') + const run = vi.fn() + await expect(installUpdate({ ...options(), modulePath: entry, run })).rejects.toThrow( + 'global installation' + ) + expect(run).not.toHaveBeenCalled() + }) + + it('refuses npm exec without spawning a package manager', async () => { + const run = vi.fn() + await expect( + installUpdate({ ...options(), env: { npm_command: 'exec' }, run }) + ).rejects.toThrow('global installation') + expect(run).not.toHaveBeenCalled() + }) + + it('fails on an unknown prerelease channel', async () => { + const run = vi.fn() + await expect( + installUpdate({ ...options(), currentVersion: '2.1.2-beta.1', run }) + ).rejects.toThrow('release channel') + expect(run).not.toHaveBeenCalled() + }) + + it('rejects executable text in the version before running anything', async () => { + const run = vi.fn() + await expect( + installUpdate({ ...options(), currentVersion: '2.1.5; echo unsafe', run }) + ).rejects.toThrow('release channel') + expect(run).not.toHaveBeenCalled() + }) + + it('propagates installer failure, releases the lock, and never reports success', async () => { + const run = vi + .fn() + .mockResolvedValueOnce(join(directory, 'node_modules')) + .mockRejectedValueOnce(new Error('permission denied')) + await expect(installUpdate({ ...options(), run })).rejects.toThrow('permission denied') + expect(existsSync(`${packageRoot}.lock`)).toBe(false) + expect(output.join('')).not.toContain('Updated Sim') + }) + + it.each(['invalid', '2.1.5-dev.1.1'])( + 'rejects an invalid or wrong-channel installed version: %s', + async (version) => { + const run = vi + .fn() + .mockResolvedValueOnce(join(directory, 'node_modules')) + .mockImplementationOnce(() => { + writeVersion(version) + return Promise.resolve('') + }) + await expect(installUpdate({ ...options(), run })).rejects.toThrow('release channel') + expect(output.join('')).not.toContain('Updated Sim') + } + ) + + it('refuses concurrent updates before a second installer starts', async () => { + mkdirSync(`${packageRoot}.lock`) + const run = vi.fn().mockResolvedValue(join(directory, 'node_modules')) + await expect(installUpdate({ ...options(), run })).rejects.toThrow('already being held') + expect(run).toHaveBeenCalledTimes(1) + }) +}) diff --git a/packages/sim-cli/src/update/install.ts b/packages/sim-cli/src/update/install.ts new file mode 100644 index 00000000000..73831f14300 --- /dev/null +++ b/packages/sim-cli/src/update/install.ts @@ -0,0 +1,165 @@ +import { spawn } from 'node:child_process' +import { readFileSync, realpathSync } from 'node:fs' +import { homedir } from 'node:os' +import { dirname, isAbsolute, join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { getErrorMessage } from '@sim/utils/errors' +import { omit } from '@sim/utils/object' +import { lock } from 'proper-lockfile' +import { upgradeCommand } from '#sim-cli/update/check' +import { CLI_VERSION } from '#sim-cli/version' + +export class CliUpdateError extends Error {} + +const PACKAGE_MANAGERS = ['npm', 'pnpm', 'bun', 'yarn'] as const +export type PackageManager = (typeof PACKAGE_MANAGERS)[number] + +interface PackageManagerOptions { + env: NodeJS.ProcessEnv + capture: boolean +} + +type RunPackageManager = ( + manager: PackageManager, + args: string[], + options: PackageManagerOptions +) => Promise + +interface InstallUpdateOptions { + modulePath?: string + env?: NodeJS.ProcessEnv + currentVersion?: string + packageManager?: PackageManager + run?: RunPackageManager + write?: (message: string) => void +} + +/** Runs only fixed package-manager commands; installer output belongs on stderr. */ +const runPackageManager: RunPackageManager = (manager, args, { env, capture }) => + new Promise((resolve, reject) => { + const child = spawn(manager, args, { + cwd: homedir(), + env, + /** Windows package managers ship .cmd launchers. Arguments contain no user input. */ + shell: process.platform === 'win32', + stdio: ['ignore', capture ? 'pipe' : process.stderr, process.stderr], + timeout: capture ? 10_000 : 5 * 60_000, + killSignal: 'SIGKILL', + windowsHide: true, + }) + let output = '' + child.stdout?.setEncoding('utf8').on('data', (chunk: string) => { + output += chunk + if (Buffer.byteLength(output) > 64 * 1024) { + child.kill('SIGKILL') + reject(new CliUpdateError(`${manager} returned too much output while locating Sim.`)) + } + }) + child.once('error', (error) => { + reject(new CliUpdateError(`Could not run ${manager}: ${getErrorMessage(error)}`)) + }) + child.once('close', (code, signal) => { + if (code !== 0) { + reject( + new CliUpdateError( + `${manager} ${args.join(' ')} failed (${signal ?? `exit ${code}`}). Resolve the package-manager error and run sim update again.` + ) + ) + return + } + resolve(output.trim()) + }) + }) + +/** Preserves published preview/dev channels instead of silently switching them to stable. */ +function updateTarget(current: string): string { + if (/^\d+\.\d+\.\d+-preview\.\d+\.\d+$/.test(current)) return 'staging' + if (/^\d+\.\d+\.\d+-dev\.\d+\.\d+$/.test(current)) return 'dev' + if (/^\d+\.\d+\.\d+(?:\+[\w.-]+)?$/.test(current)) return 'latest' + throw new CliUpdateError(`Cannot determine the release channel for Sim ${current}.`) +} + +/** Updates the verified global installation and reports the version actually installed. */ +export async function installUpdate(options: InstallUpdateOptions = {}): Promise { + const modulePath = realpathSync(options.modulePath ?? fileURLToPath(import.meta.url)) + const env = omit(options.env ?? process.env, ['SIM_API_KEY']) + const normalized = modulePath.replaceAll('\\', '/').toLowerCase() + if ( + env.npm_command === 'exec' || + normalized.includes('/_npx/') || + normalized.includes('/bunx-') || + !normalized.endsWith('/node_modules/sim/dist/index.js') + ) { + throw new CliUpdateError( + 'sim update requires a global installation. Update project dependencies with their package manager, or use sim@latest with your package runner.' + ) + } + + const manager = options.packageManager ?? upgradeCommand(modulePath, env).split(' ')[0] + if (!PACKAGE_MANAGERS.includes(manager as PackageManager)) { + throw new CliUpdateError('Cannot determine which package manager installed Sim.') + } + const packageManager = manager as PackageManager + const run = options.run ?? runPackageManager + const currentVersion = options.currentVersion ?? CLI_VERSION + const target = updateTarget(currentVersion) + const write = options.write ?? ((message: string) => void process.stderr.write(message)) + env.SIM_NO_UPDATE_CHECK = '1' + + const locateArgs = + packageManager === 'bun' + ? ['pm', 'bin', '-g'] + : packageManager === 'yarn' + ? ['global', 'dir', '--silent'] + : ['root', '-g'] + const directory = await run(packageManager, locateArgs, { env, capture: true }) + if (!isAbsolute(directory) || /[\r\n]/.test(directory)) { + throw new CliUpdateError(`${packageManager} did not return a valid global installation path.`) + } + const installedEntry = + packageManager === 'bun' + ? join(directory, 'sim') + : join(directory, ...(packageManager === 'yarn' ? ['node_modules'] : []), 'sim/dist/index.js') + if (realpathSync(installedEntry) !== modulePath) { + throw new CliUpdateError( + `${packageManager} would update a different Sim installation. Use the package manager and global configuration that installed this copy, or select --package-manager.` + ) + } + + const release = await lock(dirname(dirname(modulePath)), { retries: 0, realpath: false }) + try { + write(`Updating Sim ${currentVersion} with ${packageManager} (sim@${target})…\n`) + const args = + packageManager === 'npm' + ? ['install', '-g', `sim@${target}`] + : packageManager === 'yarn' + ? ['global', 'add', `sim@${target}`] + : ['add', '-g', `sim@${target}`] + await run(packageManager, args, { env, capture: false }) + const manifest: unknown = JSON.parse( + readFileSync(join(dirname(dirname(realpathSync(installedEntry))), 'package.json'), 'utf8') + ) + if ( + typeof manifest !== 'object' || + manifest === null || + !('name' in manifest) || + manifest.name !== 'sim' || + !('version' in manifest) || + typeof manifest.version !== 'string' + ) { + throw new CliUpdateError('The package manager did not install the expected Sim version.') + } + if (updateTarget(manifest.version) !== target) { + throw new CliUpdateError( + 'The package manager installed Sim from a different release channel.' + ) + } + write( + manifest.version === currentVersion + ? `Sim ${currentVersion} is already up to date.\n` + : `Updated Sim ${currentVersion} → ${manifest.version}. The next invocation will use the new version.\n` + ) + } finally { + await release() + } +} From f81de4480be284a8320678beca9f02e604552e55 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Thu, 10 Sep 2026 18:32:56 -0700 Subject: [PATCH 2/3] fix(cli): reject update downgrades and explain installation errors --- apps/docs/content/docs/cli/configuration.mdx | 3 + packages/sim-cli/README.md | 2 + .../src/update/install.process.test.ts | 56 +++++- packages/sim-cli/src/update/install.test.ts | 159 ++++++++++++++- packages/sim-cli/src/update/install.ts | 182 ++++++++++++++---- 5 files changed, 353 insertions(+), 49 deletions(-) diff --git a/apps/docs/content/docs/cli/configuration.mdx b/apps/docs/content/docs/cli/configuration.mdx index 0db7963c611..50171148a70 100644 --- a/apps/docs/content/docs/cli/configuration.mdx +++ b/apps/docs/content/docs/cli/configuration.mdx @@ -138,6 +138,9 @@ copies must be updated through their package manager. Manual updates preserve the stable, staging, or dev release channel. Installation failures stop with an error; concurrent update attempts are refused. Installer output goes to stderr and does not mix with JSON output on stdout. +The updater resolves the channel through the selected package manager before +installing. Older registry or mirror releases are refused; a newer release is +installed by its exact version so a moving tag cannot change the target. Checks are skipped in CI, when stderr is redirected, under `npm exec` or `npx`, from a repository checkout, and for prerelease versions. Set diff --git a/packages/sim-cli/README.md b/packages/sim-cli/README.md index 35484e84421..5c96556a80b 100644 --- a/packages/sim-cli/README.md +++ b/packages/sim-cli/README.md @@ -42,6 +42,8 @@ its global installation before making changes. Supported managers are npm, pnpm, Bun, and Yarn Classic. Use `sim update --package-manager bun` if detection does not match a custom installation. Manual updates preserve staging and dev channels. Installation failures exit with an error; concurrent update attempts are refused. +The updater resolves the channel through that package manager, refuses older +releases, and installs the exact version it checked. Set `SIM_NO_UPDATE_CHECK=1` to disable update notices. Project-local installs and temporary package-runner copies must be updated through their package manager. diff --git a/packages/sim-cli/src/update/install.process.test.ts b/packages/sim-cli/src/update/install.process.test.ts index f154612a11b..3f9e3b3aecb 100644 --- a/packages/sim-cli/src/update/install.process.test.ts +++ b/packages/sim-cli/src/update/install.process.test.ts @@ -40,17 +40,24 @@ afterAll(() => { rmSync(directory, { recursive: true, force: true }) }) -function fakePackageManager(exitCode = 0): void { +function fakePackageManager( + exitCode = 0, + version = '2.1.5', + manifestBody?: string, + globalDirectory = modules +): void { const script = ` if (process.env.SIM_API_KEY) throw new Error('Sim API key leaked to package manager') const args = process.argv.slice(2) if (args.join(' ') === 'root -g') { - process.stdout.write(${JSON.stringify(modules)}) -} else if (args.join(' ') === 'install -g sim@latest') { + process.stdout.write(${JSON.stringify(globalDirectory)}) +} else if (args.join(' ') === 'view sim@latest version --json') { + process.stdout.write(JSON.stringify(${JSON.stringify(version)})) +} else if (args.join(' ') === 'install -g sim@' + ${JSON.stringify(version)}) { process.stdout.write('package manager stdout\\n') process.stderr.write('package manager stderr\\n') if (${exitCode} !== 0) process.exit(${exitCode}) - require('node:fs').writeFileSync(${JSON.stringify(manifest)}, JSON.stringify({ name: 'sim', type: 'module', version: '2.1.5' })) + require('node:fs').writeFileSync(${JSON.stringify(manifest)}, ${JSON.stringify(manifestBody ?? JSON.stringify({ name: 'sim', type: 'module', version }))}) } else { throw new Error('Unexpected arguments: ' + args.join(' ')) } @@ -106,4 +113,45 @@ describe.skipIf(process.platform === 'win32')('the bundled sim update command', expect(result.stdout).toContain('--package-manager') expect(result.stderr).toBe('') }) + + it('refuses an older registry release without running the installer', () => { + fakePackageManager(0, '2.1.1') + const result = run(['update']) + expect(result.status).toBe(1) + expect(result.stderr).toContain('Refusing to downgrade') + expect(result.stderr).not.toContain('package manager stdout') + expect(result.stderr).not.toMatch(/\n\s+at /) + expect(run(['--version']).stdout.trim()).toBe('2.1.2') + }) + + it('prints a clear failure for a missing global installation entry', () => { + fakePackageManager(0, '2.1.5', undefined, join(directory, 'missing')) + const result = run(['update']) + expect(result.status).toBe(1) + expect(result.stderr).toContain('Cannot access the Sim installation') + expect(result.stderr).not.toMatch(/\n\s+at /) + }) + + it('prints a clear failure when the installed manifest is malformed', () => { + fakePackageManager(0, '2.1.5', '{') + const result = run(['update']) + expect(result.status).toBe(1) + expect(result.stderr).toContain('Cannot read the installed Sim manifest') + expect(result.stderr).not.toContain('Updated Sim') + expect(result.stderr).not.toMatch(/\n\s+at /) + }) + + it('prints a clear failure for a concurrent update', () => { + fakePackageManager() + const lockDirectory = join(modules, 'sim.lock') + mkdirSync(lockDirectory) + try { + const result = run(['update']) + expect(result.status).toBe(1) + expect(result.stderr).toContain('Cannot lock Sim for update') + expect(result.stderr).not.toMatch(/\n\s+at /) + } finally { + rmSync(lockDirectory, { recursive: true }) + } + }) }) diff --git a/packages/sim-cli/src/update/install.test.ts b/packages/sim-cli/src/update/install.test.ts index d07c7ff66f2..f4f36f4246f 100644 --- a/packages/sim-cli/src/update/install.test.ts +++ b/packages/sim-cli/src/update/install.test.ts @@ -5,6 +5,7 @@ import { existsSync, mkdirSync, mkdtempSync, + readFileSync, rmSync, symlinkSync, unlinkSync, @@ -13,7 +14,7 @@ import { import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { installUpdate, type PackageManager } from '#sim-cli/update/install' +import { CliUpdateError, installUpdate, type PackageManager } from '#sim-cli/update/install' let directory: string let packageRoot: string @@ -63,6 +64,13 @@ describe('installing a CLI update', () => { const run = vi .fn() .mockResolvedValueOnce(globalDirectory) + .mockResolvedValueOnce( + packageManager === 'yarn' + ? JSON.stringify({ type: 'inspect', data: '2.1.5' }) + + '\n' + + JSON.stringify({ type: 'finished', data: 1 }) + : JSON.stringify('2.1.5') + ) .mockImplementationOnce(() => { writeVersion('2.1.5') return Promise.resolve('') @@ -72,10 +80,10 @@ describe('installing a CLI update', () => { const expectedArgs = packageManager === 'npm' - ? ['install', '-g', 'sim@latest'] + ? ['install', '-g', 'sim@2.1.5'] : packageManager === 'yarn' - ? ['global', 'add', 'sim@latest'] - : ['add', '-g', 'sim@latest'] + ? ['global', 'add', 'sim@2.1.5'] + : ['add', '-g', 'sim@2.1.5'] expect(run).toHaveBeenLastCalledWith(packageManager, expectedArgs, { env: { SIM_NO_UPDATE_CHECK: '1' }, capture: false, @@ -91,14 +99,21 @@ describe('installing a CLI update', () => { ['2.1.2-dev.123.1', 'dev'], ])('preserves the release channel for %s', async (currentVersion, tag) => { writeVersion(currentVersion) - const run = vi.fn().mockResolvedValue(join(directory, 'node_modules')) + const run = vi + .fn() + .mockResolvedValueOnce(join(directory, 'node_modules')) + .mockResolvedValueOnce(JSON.stringify(currentVersion)) await installUpdate({ ...options(), currentVersion, run }) - expect(run.mock.calls[1][1]).toEqual(['install', '-g', `sim@${tag}`]) + expect(run.mock.calls[1][1]).toEqual(['view', `sim@${tag}`, 'version', '--json']) + expect(run).toHaveBeenCalledTimes(2) }) it('does not pass the Sim API key to the package manager or change the parent environment', async () => { const env = { SIM_API_KEY: 'private', npm_config_registry: 'https://registry.example' } - const run = vi.fn().mockResolvedValue(join(directory, 'node_modules')) + const run = vi + .fn() + .mockResolvedValueOnce(join(directory, 'node_modules')) + .mockResolvedValueOnce(JSON.stringify('2.1.2')) await installUpdate({ ...options(), env, run }) expect(run.mock.calls[1][2].env).toEqual({ npm_config_registry: 'https://registry.example', @@ -110,11 +125,129 @@ describe('installing a CLI update', () => { it('reports an already current installation', async () => { await installUpdate({ ...options(), - run: vi.fn().mockResolvedValue(join(directory, 'node_modules')), + run: vi + .fn() + .mockResolvedValueOnce(join(directory, 'node_modules')) + .mockResolvedValueOnce(JSON.stringify('2.1.2')), }) expect(output.join('')).toContain('already up to date') }) + it.each([ + ['2.1.5', '2.1.2'], + ['2.1.10', '2.1.9'], + ['3.0.0', '2.99.99'], + ['2.1.5-preview.10.1', '2.1.5-preview.9.9'], + ['2.1.5-dev.10.2', '2.1.5-dev.10.1'], + ])('refuses to downgrade %s to %s before installation', async (currentVersion, candidate) => { + writeVersion(currentVersion) + const run = vi + .fn() + .mockResolvedValueOnce(join(directory, 'node_modules')) + .mockResolvedValueOnce(JSON.stringify(candidate)) + await expect(installUpdate({ ...options(), currentVersion, run })).rejects.toThrow( + 'Refusing to downgrade' + ) + expect(run).toHaveBeenCalledTimes(2) + expect(readFileSync(join(packageRoot, 'package.json'), 'utf8')).toContain(currentVersion) + expect(existsSync(`${packageRoot}.lock`)).toBe(false) + }) + + it.each([ + ['2.1.9', '2.1.10'], + ['2.1.5-preview.9.9', '2.1.5-preview.10.1'], + ['2.1.5-dev.10.9', '2.1.5-dev.10.10'], + ])( + 'compares numeric release components when updating %s to %s', + async (currentVersion, candidate) => { + writeVersion(currentVersion) + const run = vi + .fn() + .mockResolvedValueOnce(join(directory, 'node_modules')) + .mockResolvedValueOnce(JSON.stringify(candidate)) + .mockImplementationOnce(async () => { + writeVersion(candidate) + return '' + }) + await installUpdate({ ...options(), currentVersion, run }) + expect(run.mock.calls[2][1]).toEqual(['install', '-g', `sim@${candidate}`]) + } + ) + + it('does not reinstall versions that differ only in build metadata', async () => { + writeVersion('2.1.2+local') + const run = vi + .fn() + .mockResolvedValueOnce(join(directory, 'node_modules')) + .mockResolvedValueOnce(JSON.stringify('2.1.2+registry')) + await installUpdate({ ...options(), currentVersion: '2.1.2+local', run }) + expect(run).toHaveBeenCalledTimes(2) + expect(output.join('')).toContain('already up to date') + }) + + it.each([ + '"2.1.5-dev.1.1"', + '"invalid"', + '"2.1.5; echo unsafe"', + '"2.1.05"', + '{', + '["2.1.5"]', + 'null', + ])( + 'rejects invalid or wrong-channel registry metadata without installing: %s', + async (metadata) => { + const run = vi + .fn() + .mockResolvedValueOnce(join(directory, 'node_modules')) + .mockResolvedValueOnce(metadata) + await expect(installUpdate({ ...options(), run })).rejects.toBeInstanceOf(CliUpdateError) + expect(run).toHaveBeenCalledTimes(2) + expect(output).toEqual([]) + } + ) + + it('rejects ambiguous Yarn version events', async () => { + const event = JSON.stringify({ type: 'inspect', data: '2.1.5' }) + const run = vi.fn().mockResolvedValueOnce(directory).mockResolvedValueOnce(`${event}\n${event}`) + await expect(installUpdate({ ...options(), packageManager: 'yarn', run })).rejects.toThrow( + 'single Sim release' + ) + expect(run).toHaveBeenCalledTimes(2) + }) + + it('refuses to install if the current installation changed while locating it', async () => { + const run = vi.fn().mockImplementationOnce(async () => { + writeVersion('2.1.6') + return join(directory, 'node_modules') + }) + await expect(installUpdate({ ...options(), run })).rejects.toThrow('installation changed') + expect(run).toHaveBeenCalledTimes(1) + }) + + it('normalizes a missing installation entry', async () => { + const run = vi.fn().mockResolvedValue(join(directory, 'missing')) + await expect(installUpdate({ ...options(), run })).rejects.toMatchObject({ + constructor: CliUpdateError, + message: expect.stringContaining('Cannot access the Sim installation'), + }) + }) + + it.each(['missing', 'malformed', 'directory'])( + 'normalizes a %s installed manifest', + async (failure) => { + const manifestPath = join(packageRoot, 'package.json') + rmSync(manifestPath) + if (failure === 'malformed') writeFileSync(manifestPath, '{') + if (failure === 'directory') mkdirSync(manifestPath) + const run = vi.fn().mockResolvedValue(join(directory, 'node_modules')) + await expect(installUpdate({ ...options(), run })).rejects.toMatchObject({ + constructor: CliUpdateError, + message: expect.stringContaining('Cannot read the installed Sim manifest'), + }) + expect(existsSync(`${packageRoot}.lock`)).toBe(false) + } + ) + it('verifies the new pnpm symlink instead of reading the old version directory', async () => { const globalRoot = join(directory, 'global/node_modules') mkdirSync(globalRoot, { recursive: true }) @@ -127,6 +260,7 @@ describe('installing a CLI update', () => { const run = vi .fn() .mockResolvedValueOnce(globalRoot) + .mockResolvedValueOnce(JSON.stringify('2.1.5')) .mockImplementationOnce(() => { unlinkSync(link) symlinkSync(nextRoot, link) @@ -199,6 +333,7 @@ describe('installing a CLI update', () => { const run = vi .fn() .mockResolvedValueOnce(join(directory, 'node_modules')) + .mockResolvedValueOnce(JSON.stringify('2.1.5')) .mockRejectedValueOnce(new Error('permission denied')) await expect(installUpdate({ ...options(), run })).rejects.toThrow('permission denied') expect(existsSync(`${packageRoot}.lock`)).toBe(false) @@ -211,11 +346,12 @@ describe('installing a CLI update', () => { const run = vi .fn() .mockResolvedValueOnce(join(directory, 'node_modules')) + .mockResolvedValueOnce(JSON.stringify('2.1.5')) .mockImplementationOnce(() => { writeVersion(version) return Promise.resolve('') }) - await expect(installUpdate({ ...options(), run })).rejects.toThrow('release channel') + await expect(installUpdate({ ...options(), run })).rejects.toThrow('expected Sim version') expect(output.join('')).not.toContain('Updated Sim') } ) @@ -223,7 +359,10 @@ describe('installing a CLI update', () => { it('refuses concurrent updates before a second installer starts', async () => { mkdirSync(`${packageRoot}.lock`) const run = vi.fn().mockResolvedValue(join(directory, 'node_modules')) - await expect(installUpdate({ ...options(), run })).rejects.toThrow('already being held') + await expect(installUpdate({ ...options(), run })).rejects.toMatchObject({ + constructor: CliUpdateError, + message: expect.stringContaining('already being held'), + }) expect(run).toHaveBeenCalledTimes(1) }) }) diff --git a/packages/sim-cli/src/update/install.ts b/packages/sim-cli/src/update/install.ts index 73831f14300..c3395a209e7 100644 --- a/packages/sim-cli/src/update/install.ts +++ b/packages/sim-cli/src/update/install.ts @@ -52,7 +52,7 @@ const runPackageManager: RunPackageManager = (manager, args, { env, capture }) = output += chunk if (Buffer.byteLength(output) > 64 * 1024) { child.kill('SIGKILL') - reject(new CliUpdateError(`${manager} returned too much output while locating Sim.`)) + reject(new CliUpdateError(`${manager} returned too much output while checking Sim.`)) } }) child.once('error', (error) => { @@ -71,17 +71,105 @@ const runPackageManager: RunPackageManager = (manager, args, { env, capture }) = }) }) -/** Preserves published preview/dev channels instead of silently switching them to stable. */ -function updateTarget(current: string): string { - if (/^\d+\.\d+\.\d+-preview\.\d+\.\d+$/.test(current)) return 'staging' - if (/^\d+\.\d+\.\d+-dev\.\d+\.\d+$/.test(current)) return 'dev' - if (/^\d+\.\d+\.\d+(?:\+[\w.-]+)?$/.test(current)) return 'latest' - throw new CliUpdateError(`Cannot determine the release channel for Sim ${current}.`) +interface ReleaseVersion { + channel: 'latest' | 'staging' | 'dev' + precedence: bigint[] +} + +/** Parses the release formats published by CI; build metadata has no precedence. */ +function parseReleaseVersion(version: string): ReleaseVersion { + const match = + /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-(preview|dev)\.(0|[1-9]\d*)\.(0|[1-9]\d*))?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/.exec( + version + ) + if (version.length > 256 || !match) { + throw new CliUpdateError(`Cannot determine the release channel for Sim ${version}.`) + } + const precedence = [BigInt(match[1]), BigInt(match[2]), BigInt(match[3])] + if (match[4]) precedence.push(BigInt(match[5]), BigInt(match[6])) + return { + channel: match[4] === 'preview' ? 'staging' : match[4] === 'dev' ? 'dev' : 'latest', + precedence, + } +} + +/** Both versions have already been checked to belong to the same release channel. */ +function compareReleases(candidate: ReleaseVersion, current: ReleaseVersion): number { + for (const [index, component] of candidate.precedence.entries()) { + if (component !== current.precedence[index]) + return component > current.precedence[index] ? 1 : -1 + } + return 0 +} + +function resolveInstallationPath(path: string): string { + try { + return realpathSync(path) + } catch (cause) { + throw new CliUpdateError(`Cannot access the Sim installation: ${getErrorMessage(cause)}`, { + cause, + }) + } +} + +function readInstalledVersion(entrypoint: string): string { + const manifestPath = join(dirname(dirname(resolveInstallationPath(entrypoint))), 'package.json') + let manifest: unknown + try { + manifest = JSON.parse(readFileSync(manifestPath, 'utf8')) + } catch (cause) { + throw new CliUpdateError(`Cannot read the installed Sim manifest: ${getErrorMessage(cause)}`, { + cause, + }) + } + if ( + typeof manifest !== 'object' || + manifest === null || + !('name' in manifest) || + manifest.name !== 'sim' || + !('version' in manifest) || + typeof manifest.version !== 'string' + ) { + throw new CliUpdateError( + 'The installed Sim manifest must name the sim package and its version.' + ) + } + return manifest.version +} + +/** Yarn Classic wraps the selected field in an inspect event among JSON status lines. */ +function parseRegistryVersion(output: string, manager: PackageManager): string { + let version: unknown + try { + if (manager === 'yarn') { + const events: unknown[] = output + .split(/\r?\n/) + .filter((line) => line.trim()) + .map((line) => JSON.parse(line)) + const inspections = events.filter( + (event): event is { type: 'inspect'; data: unknown } => + typeof event === 'object' && + event !== null && + 'type' in event && + event.type === 'inspect' && + 'data' in event + ) + if (inspections.length === 1) version = inspections[0].data + } else { + version = JSON.parse(output) + } + } catch (cause) { + throw new CliUpdateError(`${manager} returned invalid registry JSON.`, { cause }) + } + if (typeof version !== 'string') { + throw new CliUpdateError(`${manager} did not resolve a single Sim release version.`) + } + return version } /** Updates the verified global installation and reports the version actually installed. */ export async function installUpdate(options: InstallUpdateOptions = {}): Promise { - const modulePath = realpathSync(options.modulePath ?? fileURLToPath(import.meta.url)) + const modulePath = resolveInstallationPath(options.modulePath ?? fileURLToPath(import.meta.url)) const env = omit(options.env ?? process.env, ['SIM_API_KEY']) const normalized = modulePath.replaceAll('\\', '/').toLowerCase() if ( @@ -102,7 +190,8 @@ export async function installUpdate(options: InstallUpdateOptions = {}): Promise const packageManager = manager as PackageManager const run = options.run ?? runPackageManager const currentVersion = options.currentVersion ?? CLI_VERSION - const target = updateTarget(currentVersion) + const current = parseReleaseVersion(currentVersion) + const target = current.channel const write = options.write ?? ((message: string) => void process.stderr.write(message)) env.SIM_NO_UPDATE_CHECK = '1' @@ -120,46 +209,69 @@ export async function installUpdate(options: InstallUpdateOptions = {}): Promise packageManager === 'bun' ? join(directory, 'sim') : join(directory, ...(packageManager === 'yarn' ? ['node_modules'] : []), 'sim/dist/index.js') - if (realpathSync(installedEntry) !== modulePath) { + if (resolveInstallationPath(installedEntry) !== modulePath) { throw new CliUpdateError( `${packageManager} would update a different Sim installation. Use the package manager and global configuration that installed this copy, or select --package-manager.` ) } - const release = await lock(dirname(dirname(modulePath)), { retries: 0, realpath: false }) + const release = await lock(dirname(dirname(modulePath)), { retries: 0, realpath: false }).catch( + (cause: unknown) => { + throw new CliUpdateError(`Cannot lock Sim for update: ${getErrorMessage(cause)}`, { cause }) + } + ) try { - write(`Updating Sim ${currentVersion} with ${packageManager} (sim@${target})…\n`) + if (readInstalledVersion(installedEntry) !== currentVersion) { + throw new CliUpdateError( + 'The Sim installation changed while starting the update. Run sim update again.' + ) + } + const version = parseRegistryVersion( + await run( + packageManager, + [ + packageManager === 'bun' || packageManager === 'yarn' ? 'info' : 'view', + `sim@${target}`, + 'version', + '--json', + ], + { env, capture: true } + ), + packageManager + ) + const candidate = parseReleaseVersion(version) + if (candidate.channel !== target) { + throw new CliUpdateError('The registry resolved Sim to a different release channel.') + } + const comparison = compareReleases(candidate, current) + if (comparison < 0) { + throw new CliUpdateError( + `Refusing to downgrade Sim ${currentVersion} to ${version}. Check your package-manager registry settings.` + ) + } + if (comparison === 0) { + write(`Sim ${currentVersion} is already up to date.\n`) + return + } + write(`Updating Sim ${currentVersion} with ${packageManager} (sim@${version})…\n`) const args = packageManager === 'npm' - ? ['install', '-g', `sim@${target}`] + ? ['install', '-g', `sim@${version}`] : packageManager === 'yarn' - ? ['global', 'add', `sim@${target}`] - : ['add', '-g', `sim@${target}`] + ? ['global', 'add', `sim@${version}`] + : ['add', '-g', `sim@${version}`] await run(packageManager, args, { env, capture: false }) - const manifest: unknown = JSON.parse( - readFileSync(join(dirname(dirname(realpathSync(installedEntry))), 'package.json'), 'utf8') - ) - if ( - typeof manifest !== 'object' || - manifest === null || - !('name' in manifest) || - manifest.name !== 'sim' || - !('version' in manifest) || - typeof manifest.version !== 'string' - ) { + if (readInstalledVersion(installedEntry) !== version) { throw new CliUpdateError('The package manager did not install the expected Sim version.') } - if (updateTarget(manifest.version) !== target) { - throw new CliUpdateError( - 'The package manager installed Sim from a different release channel.' - ) - } write( - manifest.version === currentVersion - ? `Sim ${currentVersion} is already up to date.\n` - : `Updated Sim ${currentVersion} → ${manifest.version}. The next invocation will use the new version.\n` + `Updated Sim ${currentVersion} → ${version}. The next invocation will use the new version.\n` ) } finally { - await release() + await release().catch((cause: unknown) => { + throw new CliUpdateError(`Cannot release the Sim update lock: ${getErrorMessage(cause)}`, { + cause, + }) + }) } } From b456ed95d0248be7fd42d61a2f01518a8978c173 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Thu, 10 Sep 2026 18:51:49 -0700 Subject: [PATCH 3/3] fix(cli): preserve update failures during lock cleanup --- packages/sim-cli/src/update/install.test.ts | 33 +++++++++++++++++++++ packages/sim-cli/src/update/install.ts | 13 ++++++-- 2 files changed, 43 insertions(+), 3 deletions(-) diff --git a/packages/sim-cli/src/update/install.test.ts b/packages/sim-cli/src/update/install.test.ts index f4f36f4246f..c452a4b1aba 100644 --- a/packages/sim-cli/src/update/install.test.ts +++ b/packages/sim-cli/src/update/install.test.ts @@ -340,6 +340,39 @@ describe('installing a CLI update', () => { expect(output.join('')).not.toContain('Updated Sim') }) + it.each(['registry', 'installer'])( + 'preserves the original %s failure when releasing the lock also fails', + async (phase) => { + const failure = new CliUpdateError(`${phase} permission denied`) + const run = vi.fn().mockResolvedValueOnce(join(directory, 'node_modules')) + if (phase === 'installer') run.mockResolvedValueOnce(JSON.stringify('2.1.5')) + run.mockImplementationOnce(async () => { + writeFileSync(join(`${packageRoot}.lock`, 'obstruction'), '') + throw failure + }) + + await expect(installUpdate({ ...options(), run })).rejects.toBe(failure) + expect(output.join('')).toContain('Cannot release the Sim update lock') + expect(output.join('')).not.toContain('Updated Sim') + } + ) + + it('fails with a CLI error when only releasing the lock fails', async () => { + const run = vi + .fn() + .mockResolvedValueOnce(join(directory, 'node_modules')) + .mockImplementationOnce(async () => { + writeFileSync(join(`${packageRoot}.lock`, 'obstruction'), '') + return JSON.stringify('2.1.2') + }) + + await expect(installUpdate({ ...options(), run })).rejects.toMatchObject({ + constructor: CliUpdateError, + message: expect.stringContaining('Cannot release the Sim update lock'), + cause: expect.objectContaining({ code: 'ENOTEMPTY' }), + }) + }) + it.each(['invalid', '2.1.5-dev.1.1'])( 'rejects an invalid or wrong-channel installed version: %s', async (version) => { diff --git a/packages/sim-cli/src/update/install.ts b/packages/sim-cli/src/update/install.ts index c3395a209e7..fa750e8950a 100644 --- a/packages/sim-cli/src/update/install.ts +++ b/packages/sim-cli/src/update/install.ts @@ -220,6 +220,7 @@ export async function installUpdate(options: InstallUpdateOptions = {}): Promise throw new CliUpdateError(`Cannot lock Sim for update: ${getErrorMessage(cause)}`, { cause }) } ) + let updateFailed = false try { if (readInstalledVersion(installedEntry) !== currentVersion) { throw new CliUpdateError( @@ -267,11 +268,17 @@ export async function installUpdate(options: InstallUpdateOptions = {}): Promise write( `Updated Sim ${currentVersion} → ${version}. The next invocation will use the new version.\n` ) + } catch (error) { + updateFailed = true + throw error } finally { await release().catch((cause: unknown) => { - throw new CliUpdateError(`Cannot release the Sim update lock: ${getErrorMessage(cause)}`, { - cause, - }) + const message = `Cannot release the Sim update lock: ${getErrorMessage(cause)}` + if (updateFailed) { + write(`${message}\n`) + } else { + throw new CliUpdateError(message, { cause }) + } }) } }