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..50171148a70 100644
--- a/apps/docs/content/docs/cli/configuration.mdx
+++ b/apps/docs/content/docs/cli/configuration.mdx
@@ -120,13 +120,27 @@ 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.
+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
@@ -141,7 +155,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..5c96556a80b 100644
--- a/packages/sim-cli/README.md
+++ b/packages/sim-cli/README.md
@@ -25,6 +25,32 @@ 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.
+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.
+
+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 +316,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..3f9e3b3aecb
--- /dev/null
+++ b/packages/sim-cli/src/update/install.process.test.ts
@@ -0,0 +1,157 @@
+/**
+ * @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,
+ 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(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(manifestBody ?? JSON.stringify({ name: 'sim', type: 'module', version }))})
+} 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('')
+ })
+
+ 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
new file mode 100644
index 00000000000..c452a4b1aba
--- /dev/null
+++ b/packages/sim-cli/src/update/install.test.ts
@@ -0,0 +1,401 @@
+/**
+ * @vitest-environment node
+ */
+import {
+ existsSync,
+ mkdirSync,
+ mkdtempSync,
+ readFileSync,
+ 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 { CliUpdateError, 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)
+ .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('')
+ })
+
+ await installUpdate({ ...options(), packageManager, run })
+
+ const expectedArgs =
+ packageManager === 'npm'
+ ? ['install', '-g', 'sim@2.1.5']
+ : packageManager === 'yarn'
+ ? ['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,
+ })
+ 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()
+ .mockResolvedValueOnce(join(directory, 'node_modules'))
+ .mockResolvedValueOnce(JSON.stringify(currentVersion))
+ await installUpdate({ ...options(), currentVersion, run })
+ 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()
+ .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',
+ 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()
+ .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 })
+ 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)
+ .mockResolvedValueOnce(JSON.stringify('2.1.5'))
+ .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'))
+ .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)
+ 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) => {
+ 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('expected Sim version')
+ 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.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
new file mode 100644
index 00000000000..fa750e8950a
--- /dev/null
+++ b/packages/sim-cli/src/update/install.ts
@@ -0,0 +1,284 @@
+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 checking 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())
+ })
+ })
+
+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 = resolveInstallationPath(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 current = parseReleaseVersion(currentVersion)
+ const target = current.channel
+ 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 (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 }).catch(
+ (cause: unknown) => {
+ throw new CliUpdateError(`Cannot lock Sim for update: ${getErrorMessage(cause)}`, { cause })
+ }
+ )
+ let updateFailed = false
+ try {
+ 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@${version}`]
+ : packageManager === 'yarn'
+ ? ['global', 'add', `sim@${version}`]
+ : ['add', '-g', `sim@${version}`]
+ await run(packageManager, args, { env, capture: false })
+ if (readInstalledVersion(installedEntry) !== version) {
+ throw new CliUpdateError('The package manager did not install the expected Sim version.')
+ }
+ 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) => {
+ const message = `Cannot release the Sim update lock: ${getErrorMessage(cause)}`
+ if (updateFailed) {
+ write(`${message}\n`)
+ } else {
+ throw new CliUpdateError(message, { cause })
+ }
+ })
+ }
+}