Skip to content

Commit f81de44

Browse files
fix(cli): reject update downgrades and explain installation errors
1 parent 38ea19c commit f81de44

5 files changed

Lines changed: 353 additions & 49 deletions

File tree

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,9 @@ copies must be updated through their package manager.
138138
Manual updates preserve the stable, staging, or dev release channel. Installation
139139
failures stop with an error; concurrent update attempts are refused. Installer
140140
output goes to stderr and does not mix with JSON output on stdout.
141+
The updater resolves the channel through the selected package manager before
142+
installing. Older registry or mirror releases are refused; a newer release is
143+
installed by its exact version so a moving tag cannot change the target.
141144

142145
Checks are skipped in CI, when stderr is redirected, under `npm exec` or `npx`,
143146
from a repository checkout, and for prerelease versions. Set

‎packages/sim-cli/README.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,8 @@ its global installation before making changes. Supported managers are npm, pnpm,
4242
Bun, and Yarn Classic. Use `sim update --package-manager bun` if detection does
4343
not match a custom installation. Manual updates preserve staging and dev channels.
4444
Installation failures exit with an error; concurrent update attempts are refused.
45+
The updater resolves the channel through that package manager, refuses older
46+
releases, and installs the exact version it checked.
4547

4648
Set `SIM_NO_UPDATE_CHECK=1` to disable update notices. Project-local installs and
4749
temporary package-runner copies must be updated through their package manager.

‎packages/sim-cli/src/update/install.process.test.ts‎

Lines changed: 52 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -40,17 +40,24 @@ afterAll(() => {
4040
rmSync(directory, { recursive: true, force: true })
4141
})
4242

43-
function fakePackageManager(exitCode = 0): void {
43+
function fakePackageManager(
44+
exitCode = 0,
45+
version = '2.1.5',
46+
manifestBody?: string,
47+
globalDirectory = modules
48+
): void {
4449
const script = `
4550
if (process.env.SIM_API_KEY) throw new Error('Sim API key leaked to package manager')
4651
const args = process.argv.slice(2)
4752
if (args.join(' ') === 'root -g') {
48-
process.stdout.write(${JSON.stringify(modules)})
49-
} else if (args.join(' ') === 'install -g sim@latest') {
53+
process.stdout.write(${JSON.stringify(globalDirectory)})
54+
} else if (args.join(' ') === 'view sim@latest version --json') {
55+
process.stdout.write(JSON.stringify(${JSON.stringify(version)}))
56+
} else if (args.join(' ') === 'install -g sim@' + ${JSON.stringify(version)}) {
5057
process.stdout.write('package manager stdout\\n')
5158
process.stderr.write('package manager stderr\\n')
5259
if (${exitCode} !== 0) process.exit(${exitCode})
53-
require('node:fs').writeFileSync(${JSON.stringify(manifest)}, JSON.stringify({ name: 'sim', type: 'module', version: '2.1.5' }))
60+
require('node:fs').writeFileSync(${JSON.stringify(manifest)}, ${JSON.stringify(manifestBody ?? JSON.stringify({ name: 'sim', type: 'module', version }))})
5461
} else {
5562
throw new Error('Unexpected arguments: ' + args.join(' '))
5663
}
@@ -106,4 +113,45 @@ describe.skipIf(process.platform === 'win32')('the bundled sim update command',
106113
expect(result.stdout).toContain('--package-manager')
107114
expect(result.stderr).toBe('')
108115
})
116+
117+
it('refuses an older registry release without running the installer', () => {
118+
fakePackageManager(0, '2.1.1')
119+
const result = run(['update'])
120+
expect(result.status).toBe(1)
121+
expect(result.stderr).toContain('Refusing to downgrade')
122+
expect(result.stderr).not.toContain('package manager stdout')
123+
expect(result.stderr).not.toMatch(/\n\s+at /)
124+
expect(run(['--version']).stdout.trim()).toBe('2.1.2')
125+
})
126+
127+
it('prints a clear failure for a missing global installation entry', () => {
128+
fakePackageManager(0, '2.1.5', undefined, join(directory, 'missing'))
129+
const result = run(['update'])
130+
expect(result.status).toBe(1)
131+
expect(result.stderr).toContain('Cannot access the Sim installation')
132+
expect(result.stderr).not.toMatch(/\n\s+at /)
133+
})
134+
135+
it('prints a clear failure when the installed manifest is malformed', () => {
136+
fakePackageManager(0, '2.1.5', '{')
137+
const result = run(['update'])
138+
expect(result.status).toBe(1)
139+
expect(result.stderr).toContain('Cannot read the installed Sim manifest')
140+
expect(result.stderr).not.toContain('Updated Sim')
141+
expect(result.stderr).not.toMatch(/\n\s+at /)
142+
})
143+
144+
it('prints a clear failure for a concurrent update', () => {
145+
fakePackageManager()
146+
const lockDirectory = join(modules, 'sim.lock')
147+
mkdirSync(lockDirectory)
148+
try {
149+
const result = run(['update'])
150+
expect(result.status).toBe(1)
151+
expect(result.stderr).toContain('Cannot lock Sim for update')
152+
expect(result.stderr).not.toMatch(/\n\s+at /)
153+
} finally {
154+
rmSync(lockDirectory, { recursive: true })
155+
}
156+
})
109157
})

‎packages/sim-cli/src/update/install.test.ts‎

Lines changed: 149 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
existsSync,
66
mkdirSync,
77
mkdtempSync,
8+
readFileSync,
89
rmSync,
910
symlinkSync,
1011
unlinkSync,
@@ -13,7 +14,7 @@ import {
1314
import { tmpdir } from 'node:os'
1415
import { dirname, join } from 'node:path'
1516
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
16-
import { installUpdate, type PackageManager } from '#sim-cli/update/install'
17+
import { CliUpdateError, installUpdate, type PackageManager } from '#sim-cli/update/install'
1718

1819
let directory: string
1920
let packageRoot: string
@@ -63,6 +64,13 @@ describe('installing a CLI update', () => {
6364
const run = vi
6465
.fn()
6566
.mockResolvedValueOnce(globalDirectory)
67+
.mockResolvedValueOnce(
68+
packageManager === 'yarn'
69+
? JSON.stringify({ type: 'inspect', data: '2.1.5' }) +
70+
'\n' +
71+
JSON.stringify({ type: 'finished', data: 1 })
72+
: JSON.stringify('2.1.5')
73+
)
6674
.mockImplementationOnce(() => {
6775
writeVersion('2.1.5')
6876
return Promise.resolve('')
@@ -72,10 +80,10 @@ describe('installing a CLI update', () => {
7280

7381
const expectedArgs =
7482
packageManager === 'npm'
75-
? ['install', '-g', 'sim@latest']
83+
? ['install', '-g', 'sim@2.1.5']
7684
: packageManager === 'yarn'
77-
? ['global', 'add', 'sim@latest']
78-
: ['add', '-g', 'sim@latest']
85+
? ['global', 'add', 'sim@2.1.5']
86+
: ['add', '-g', 'sim@2.1.5']
7987
expect(run).toHaveBeenLastCalledWith(packageManager, expectedArgs, {
8088
env: { SIM_NO_UPDATE_CHECK: '1' },
8189
capture: false,
@@ -91,14 +99,21 @@ describe('installing a CLI update', () => {
9199
['2.1.2-dev.123.1', 'dev'],
92100
])('preserves the release channel for %s', async (currentVersion, tag) => {
93101
writeVersion(currentVersion)
94-
const run = vi.fn().mockResolvedValue(join(directory, 'node_modules'))
102+
const run = vi
103+
.fn()
104+
.mockResolvedValueOnce(join(directory, 'node_modules'))
105+
.mockResolvedValueOnce(JSON.stringify(currentVersion))
95106
await installUpdate({ ...options(), currentVersion, run })
96-
expect(run.mock.calls[1][1]).toEqual(['install', '-g', `sim@${tag}`])
107+
expect(run.mock.calls[1][1]).toEqual(['view', `sim@${tag}`, 'version', '--json'])
108+
expect(run).toHaveBeenCalledTimes(2)
97109
})
98110

99111
it('does not pass the Sim API key to the package manager or change the parent environment', async () => {
100112
const env = { SIM_API_KEY: 'private', npm_config_registry: 'https://registry.example' }
101-
const run = vi.fn().mockResolvedValue(join(directory, 'node_modules'))
113+
const run = vi
114+
.fn()
115+
.mockResolvedValueOnce(join(directory, 'node_modules'))
116+
.mockResolvedValueOnce(JSON.stringify('2.1.2'))
102117
await installUpdate({ ...options(), env, run })
103118
expect(run.mock.calls[1][2].env).toEqual({
104119
npm_config_registry: 'https://registry.example',
@@ -110,11 +125,129 @@ describe('installing a CLI update', () => {
110125
it('reports an already current installation', async () => {
111126
await installUpdate({
112127
...options(),
113-
run: vi.fn().mockResolvedValue(join(directory, 'node_modules')),
128+
run: vi
129+
.fn()
130+
.mockResolvedValueOnce(join(directory, 'node_modules'))
131+
.mockResolvedValueOnce(JSON.stringify('2.1.2')),
114132
})
115133
expect(output.join('')).toContain('already up to date')
116134
})
117135

136+
it.each([
137+
['2.1.5', '2.1.2'],
138+
['2.1.10', '2.1.9'],
139+
['3.0.0', '2.99.99'],
140+
['2.1.5-preview.10.1', '2.1.5-preview.9.9'],
141+
['2.1.5-dev.10.2', '2.1.5-dev.10.1'],
142+
])('refuses to downgrade %s to %s before installation', async (currentVersion, candidate) => {
143+
writeVersion(currentVersion)
144+
const run = vi
145+
.fn()
146+
.mockResolvedValueOnce(join(directory, 'node_modules'))
147+
.mockResolvedValueOnce(JSON.stringify(candidate))
148+
await expect(installUpdate({ ...options(), currentVersion, run })).rejects.toThrow(
149+
'Refusing to downgrade'
150+
)
151+
expect(run).toHaveBeenCalledTimes(2)
152+
expect(readFileSync(join(packageRoot, 'package.json'), 'utf8')).toContain(currentVersion)
153+
expect(existsSync(`${packageRoot}.lock`)).toBe(false)
154+
})
155+
156+
it.each([
157+
['2.1.9', '2.1.10'],
158+
['2.1.5-preview.9.9', '2.1.5-preview.10.1'],
159+
['2.1.5-dev.10.9', '2.1.5-dev.10.10'],
160+
])(
161+
'compares numeric release components when updating %s to %s',
162+
async (currentVersion, candidate) => {
163+
writeVersion(currentVersion)
164+
const run = vi
165+
.fn()
166+
.mockResolvedValueOnce(join(directory, 'node_modules'))
167+
.mockResolvedValueOnce(JSON.stringify(candidate))
168+
.mockImplementationOnce(async () => {
169+
writeVersion(candidate)
170+
return ''
171+
})
172+
await installUpdate({ ...options(), currentVersion, run })
173+
expect(run.mock.calls[2][1]).toEqual(['install', '-g', `sim@${candidate}`])
174+
}
175+
)
176+
177+
it('does not reinstall versions that differ only in build metadata', async () => {
178+
writeVersion('2.1.2+local')
179+
const run = vi
180+
.fn()
181+
.mockResolvedValueOnce(join(directory, 'node_modules'))
182+
.mockResolvedValueOnce(JSON.stringify('2.1.2+registry'))
183+
await installUpdate({ ...options(), currentVersion: '2.1.2+local', run })
184+
expect(run).toHaveBeenCalledTimes(2)
185+
expect(output.join('')).toContain('already up to date')
186+
})
187+
188+
it.each([
189+
'"2.1.5-dev.1.1"',
190+
'"invalid"',
191+
'"2.1.5; echo unsafe"',
192+
'"2.1.05"',
193+
'{',
194+
'["2.1.5"]',
195+
'null',
196+
])(
197+
'rejects invalid or wrong-channel registry metadata without installing: %s',
198+
async (metadata) => {
199+
const run = vi
200+
.fn()
201+
.mockResolvedValueOnce(join(directory, 'node_modules'))
202+
.mockResolvedValueOnce(metadata)
203+
await expect(installUpdate({ ...options(), run })).rejects.toBeInstanceOf(CliUpdateError)
204+
expect(run).toHaveBeenCalledTimes(2)
205+
expect(output).toEqual([])
206+
}
207+
)
208+
209+
it('rejects ambiguous Yarn version events', async () => {
210+
const event = JSON.stringify({ type: 'inspect', data: '2.1.5' })
211+
const run = vi.fn().mockResolvedValueOnce(directory).mockResolvedValueOnce(`${event}\n${event}`)
212+
await expect(installUpdate({ ...options(), packageManager: 'yarn', run })).rejects.toThrow(
213+
'single Sim release'
214+
)
215+
expect(run).toHaveBeenCalledTimes(2)
216+
})
217+
218+
it('refuses to install if the current installation changed while locating it', async () => {
219+
const run = vi.fn().mockImplementationOnce(async () => {
220+
writeVersion('2.1.6')
221+
return join(directory, 'node_modules')
222+
})
223+
await expect(installUpdate({ ...options(), run })).rejects.toThrow('installation changed')
224+
expect(run).toHaveBeenCalledTimes(1)
225+
})
226+
227+
it('normalizes a missing installation entry', async () => {
228+
const run = vi.fn().mockResolvedValue(join(directory, 'missing'))
229+
await expect(installUpdate({ ...options(), run })).rejects.toMatchObject({
230+
constructor: CliUpdateError,
231+
message: expect.stringContaining('Cannot access the Sim installation'),
232+
})
233+
})
234+
235+
it.each(['missing', 'malformed', 'directory'])(
236+
'normalizes a %s installed manifest',
237+
async (failure) => {
238+
const manifestPath = join(packageRoot, 'package.json')
239+
rmSync(manifestPath)
240+
if (failure === 'malformed') writeFileSync(manifestPath, '{')
241+
if (failure === 'directory') mkdirSync(manifestPath)
242+
const run = vi.fn().mockResolvedValue(join(directory, 'node_modules'))
243+
await expect(installUpdate({ ...options(), run })).rejects.toMatchObject({
244+
constructor: CliUpdateError,
245+
message: expect.stringContaining('Cannot read the installed Sim manifest'),
246+
})
247+
expect(existsSync(`${packageRoot}.lock`)).toBe(false)
248+
}
249+
)
250+
118251
it('verifies the new pnpm symlink instead of reading the old version directory', async () => {
119252
const globalRoot = join(directory, 'global/node_modules')
120253
mkdirSync(globalRoot, { recursive: true })
@@ -127,6 +260,7 @@ describe('installing a CLI update', () => {
127260
const run = vi
128261
.fn()
129262
.mockResolvedValueOnce(globalRoot)
263+
.mockResolvedValueOnce(JSON.stringify('2.1.5'))
130264
.mockImplementationOnce(() => {
131265
unlinkSync(link)
132266
symlinkSync(nextRoot, link)
@@ -199,6 +333,7 @@ describe('installing a CLI update', () => {
199333
const run = vi
200334
.fn()
201335
.mockResolvedValueOnce(join(directory, 'node_modules'))
336+
.mockResolvedValueOnce(JSON.stringify('2.1.5'))
202337
.mockRejectedValueOnce(new Error('permission denied'))
203338
await expect(installUpdate({ ...options(), run })).rejects.toThrow('permission denied')
204339
expect(existsSync(`${packageRoot}.lock`)).toBe(false)
@@ -211,19 +346,23 @@ describe('installing a CLI update', () => {
211346
const run = vi
212347
.fn()
213348
.mockResolvedValueOnce(join(directory, 'node_modules'))
349+
.mockResolvedValueOnce(JSON.stringify('2.1.5'))
214350
.mockImplementationOnce(() => {
215351
writeVersion(version)
216352
return Promise.resolve('')
217353
})
218-
await expect(installUpdate({ ...options(), run })).rejects.toThrow('release channel')
354+
await expect(installUpdate({ ...options(), run })).rejects.toThrow('expected Sim version')
219355
expect(output.join('')).not.toContain('Updated Sim')
220356
}
221357
)
222358

223359
it('refuses concurrent updates before a second installer starts', async () => {
224360
mkdirSync(`${packageRoot}.lock`)
225361
const run = vi.fn().mockResolvedValue(join(directory, 'node_modules'))
226-
await expect(installUpdate({ ...options(), run })).rejects.toThrow('already being held')
362+
await expect(installUpdate({ ...options(), run })).rejects.toMatchObject({
363+
constructor: CliUpdateError,
364+
message: expect.stringContaining('already being held'),
365+
})
227366
expect(run).toHaveBeenCalledTimes(1)
228367
})
229368
})

0 commit comments

Comments
 (0)