Skip to content

Commit fd3f3db

Browse files
committed
fix(cli): correct the update-notifier privacy claim and prerelease parsing
Review round 1: five findings, all valid. The privacy statement was too absolute. The request carries no Sim API key, but `npm_config_registry` can point at a private mirror, and a token embedded in that URL is sent with the request - it has to be, or the mirror rejects it. Both docs now say which credentials are involved and where they go: your registry's, to the host you configured, never Sim's. `parseVersion` accepted zero-padded prerelease identifiers. Semver forbids them, and accepting `2.1.3-preview.09` was worse than cosmetic: `09` failed the numeric test and fell through to being an alphanumeric identifier, and alphanumerics outrank every number, so `preview.010` sorted ABOVE `preview.2`. The file's own doc comment already claimed leading zeroes were rejected "the way the specification rejects them" - true of the release triple, not of the prerelease. Now true of both. The `--version`/`--help` test did not hold the guarantee it advertised. It watched for a request and a cache file, but neither ever appears from inside a checkout no matter what runs, because the check suppresses itself there - so it would have passed even if the hook fired, which is the exact regression it claims to prevent. It now swaps a sentinel into commander's registered preAction hooks and asserts the sentinel does not fire while parsing those two, then asserts it DOES fire for a real action command, so the negative assertion means something. No module mocking, which this package bans. The troubleshooting page hardcoded `npm install -g`, which installs a second copy under a different package manager rather than replacing the executable on PATH. It now shows all three, and says the notice already prints the one matching your install - which the notifier has always done. Tests: 861 -> 863. Both new guards mutation-checked: dropping the leading-zero rejection and deleting the hook each fail the suite.
1 parent 206d325 commit fd3f3db

6 files changed

Lines changed: 109 additions & 41 deletions

File tree

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

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -132,8 +132,16 @@ both versions and the command that upgrades:
132132
Update available: sim 2.1.2 → 2.1.5. Run: npm install -g sim@latest
133133
```
134134

135-
The request carries the CLI version and nothing else — no API key, no workspace,
136-
no command — and it never follows a redirect away from the registry you asked.
135+
The request carries the CLI version and nothing else — no Sim API key, no
136+
workspace, no command — and it never follows a redirect away from the registry
137+
it asked.
138+
139+
One caveat worth stating plainly: if you point `npm_config_registry` at a
140+
private mirror, the check goes to that mirror instead of npm, and any
141+
credentials embedded in that URL (an Artifactory or Nexus `?token=…`) are sent
142+
with it — they have to be, or the mirror would reject the request. Those are
143+
your registry's credentials, not Sim's, and they go only to the host you
144+
configured.
137145

138146
The notice is skipped entirely when:
139147

@@ -149,6 +157,11 @@ The notice is skipped entirely when:
149157
Set `npm_config_registry` to ask a mirror instead; its path and query are
150158
preserved, so a token-authenticated Artifactory or Nexus base works.
151159

160+
The command the notice prints matches how Sim was installed — `npm install -g`,
161+
`pnpm add -g`, `bun add -g`, or `yarn global add` — so running it updates the
162+
executable already on your `PATH` rather than installing a second copy under a
163+
different package manager.
164+
152165
Node ignores `HTTPS_PROXY` unless you also set `NODE_USE_ENV_PROXY=1`, and only
153166
from Node 22.21 and 24.5. The CLI warns when a proxy is configured but will not
154167
be used.

apps/docs/content/docs/cli/troubleshooting.mdx

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ title: Troubleshooting
33
description: The failures whose cause is not obvious from the error message
44
---
55

6+
import { Tab, Tabs } from 'fumadocs-ui/components/tabs'
7+
68
Errors print one line to stderr, prefixed `Error:`, and exit `1` — except
79
`sim whoami`, which exits `2` when it could not reach the API to check at all.
810
Most say what to do next; the cases below are the ones that do not.
@@ -93,11 +95,32 @@ The docs track the current release, so a command that exists here and not in
9395

9496
```bash
9597
sim --version
96-
npm install -g sim@latest
9798
```
9899

99-
The CLI normally tells you this itself, once a day, on stderr. It stays quiet
100-
when stderr is redirected, in CI, and under `npx`.
100+
Then upgrade with the package manager you installed it with — using a different
101+
one installs a second copy instead of replacing the executable on your `PATH`:
102+
103+
<Tabs items={['npm', 'pnpm', 'bun']}>
104+
<Tab value="npm">
105+
```bash
106+
npm install -g sim@latest
107+
```
108+
</Tab>
109+
<Tab value="pnpm">
110+
```bash
111+
pnpm add -g sim@latest
112+
```
113+
</Tab>
114+
<Tab value="bun">
115+
```bash
116+
bun add -g sim@latest
117+
```
118+
</Tab>
119+
</Tabs>
120+
121+
The CLI normally tells you this itself, once a day, on stderr, and the command
122+
it prints already matches your installation. It stays quiet when stderr is
123+
redirected, in CI, and under `npx`.
101124

102125
## An update notice appears in output I am parsing
103126

packages/sim-cli/README.md

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -263,9 +263,12 @@ The main environment variables are:
263263

264264
Once a day, at an interactive terminal, `sim` asks `registry.npmjs.org` which
265265
version is published under the tag it was installed from, and prints one line on
266-
stderr when a newer one exists. It sends nothing but its own version, never a
267-
key. Set `SIM_NO_UPDATE_CHECK=1` to turn it off; the full list of cases where it
268-
stays quiet is in the [configuration guide](https://docs.sim.ai/cli/configuration).
266+
stderr when a newer one exists. It sends nothing but its own version and never
267+
your Sim API key. If `npm_config_registry` points at a private mirror, the check
268+
goes there instead and carries whatever credentials that URL embeds, since the
269+
mirror would otherwise refuse it. Set `SIM_NO_UPDATE_CHECK=1` to turn it off;
270+
the full list of cases where it stays quiet is in the
271+
[configuration guide](https://docs.sim.ai/cli/configuration).
269272

270273
## Documentation
271274

packages/sim-cli/src/program.test.ts

Lines changed: 38 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,19 @@
11
/**
22
* @vitest-environment node
33
*/
4-
import { mkdtempSync, readdirSync, rmSync } from 'node:fs'
4+
import { mkdtempSync, rmSync } from 'node:fs'
55
import { tmpdir } from 'node:os'
66
import { join } from 'node:path'
77
import type { Command } from 'commander'
8-
import { describe, expect, it, vi } from 'vitest'
8+
import { describe, expect, it } from 'vitest'
99
import { buildProgram } from './program'
1010
import { CLI_VERSION } from './version'
1111

1212
/** Parses argv against a program whose output and exits are captured, not taken. */
13-
async function parse(argv: string[]): Promise<{ out: string; code: string | null }> {
14-
const program = buildProgram()
13+
async function parse(
14+
argv: string[],
15+
program: Command = buildProgram()
16+
): Promise<{ out: string; code: string | null }> {
1517
let out = ''
1618
const capture = (command: Command) => {
1719
command.exitOverride()
@@ -163,33 +165,48 @@ describe('help typed after a command that does not exist', () => {
163165
})
164166
})
165167

168+
/** Commander keeps lifecycle hooks on a private field and offers no getter. */
169+
function preActionHooks(program: Command): Array<(a: Command, b: Command) => unknown> {
170+
const { _lifeCycleHooks: hooks } = program as Command & {
171+
_lifeCycleHooks?: Record<string, Array<(a: Command, b: Command) => unknown>>
172+
}
173+
return hooks?.preAction ?? []
174+
}
175+
166176
describe('the update check', () => {
167177
/**
168178
* The notice must cost `--version` and `--help` nothing. Commander answers
169179
* both during parsing, before any action hook runs, so the guarantee is
170180
* structural — this holds it in place if the check is ever moved.
181+
*
182+
* It swaps in a sentinel hook rather than watching for a request or a cache
183+
* file. Those side effects never appear from inside a checkout no matter
184+
* what runs, because the check suppresses itself there — so asserting on
185+
* them would pass even if the hook fired, which is precisely the regression
186+
* this is meant to catch.
171187
*/
172-
it('never runs for the two commands commander answers during parsing', async () => {
173-
const stderr = process.stderr
174-
const wasTty = stderr.isTTY
175-
const dir = mkdtempSync(join(tmpdir(), 'sim-cli-program-'))
176-
const requests: string[] = []
177-
Object.defineProperty(stderr, 'isTTY', { configurable: true, value: true })
178-
process.env.SIM_CONFIG_DIR = dir
179-
vi.stubGlobal('fetch', (input: URL) => {
180-
requests.push(String(input))
181-
return Promise.resolve(Response.json({ latest: '99.0.0' }))
188+
it('fires no preAction hook for the two commands commander answers while parsing', async () => {
189+
let fired = 0
190+
const program = buildProgram()
191+
const hooks = preActionHooks(program)
192+
expect(hooks).toHaveLength(1)
193+
hooks.splice(0, hooks.length, () => {
194+
fired += 1
182195
})
183196

197+
await parse(['--version'], program)
198+
await parse(['--help'], program)
199+
expect(fired).toBe(0)
200+
201+
// And the sentinel is not inert: the same hook does fire for a real action,
202+
// which is what makes the assertion above mean something.
203+
const dir = mkdtempSync(join(tmpdir(), 'sim-cli-program-'))
204+
process.env.SIM_CONFIG_DIR = dir
184205
try {
185-
await parse(['--version'])
186-
await parse(['--help'])
187-
expect(requests).toEqual([])
188-
expect(readdirSync(dir)).toEqual([])
206+
await parse(['configure', '--set-output', 'json'], program)
207+
expect(fired).toBe(1)
189208
} finally {
190-
vi.unstubAllGlobals()
191209
process.env.SIM_CONFIG_DIR = undefined
192-
Object.defineProperty(stderr, 'isTTY', { configurable: true, value: wasTty })
193210
rmSync(dir, { recursive: true, force: true })
194211
}
195212
})
@@ -207,12 +224,7 @@ describe('the update check', () => {
207224
*/
208225
it('registers the update check as a root preAction hook', async () => {
209226
const program = buildProgram()
210-
// Commander keeps lifecycle hooks on a private field and offers no getter,
211-
// the same way `rawArgs` is read elsewhere in this file.
212-
const { _lifeCycleHooks: hooks } = program as Command & {
213-
_lifeCycleHooks?: Record<string, Array<(a: Command, b: Command) => unknown>>
214-
}
215-
const preAction = hooks?.preAction ?? []
227+
const preAction = preActionHooks(program)
216228

217229
expect(preAction).toHaveLength(1)
218230
// Invoking it must resolve, never throw: it runs in front of the user's

packages/sim-cli/src/update/semver.test.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,8 @@ describe('parsing a published version', () => {
3434
['01.2.3', 'a leading zero'],
3535
['2.1.2-', 'an empty prerelease'],
3636
['2.1.2-preview..1', 'an empty identifier'],
37+
['2.1.3-preview.09', 'a zero-padded numeric identifier'],
38+
['2.1.3-01', 'a zero-padded identifier on its own'],
3739
['', 'nothing at all'],
3840
['latest', 'a dist-tag mistaken for a version'],
3941
])('rejects %s (%s)', (version) => {
@@ -63,6 +65,13 @@ describe('precedence', () => {
6365
expect(order('2.1.3', '2.1.3-preview.44.1')).toBe(1)
6466
})
6567

68+
it('does not let a malformed identifier outrank every number', () => {
69+
// `09` is not a valid numeric identifier. Accepting it would reclassify it
70+
// as alphanumeric, and alphanumerics outrank numbers — so `preview.010`
71+
// would sort above `preview.2`.
72+
expect(parseVersion('2.1.3-preview.010')).toBeNull()
73+
})
74+
6675
it('orders two alphanumeric identifiers by ASCII', () => {
6776
expect(order('2.1.3-alpha', '2.1.3-beta')).toBe(-1)
6877
expect(order('2.1.3-beta', '2.1.3-alpha')).toBe(1)

packages/sim-cli/src/update/semver.ts

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,15 @@ const VERSION_PATTERN =
1919
/** A prerelease identifier that is all digits compares as a number. */
2020
const NUMERIC_IDENTIFIER = /^(0|[1-9]\d*)$/
2121

22+
/**
23+
* A numeric prerelease identifier carrying a leading zero, which the
24+
* specification forbids. It has to be spotted rather than simply failing
25+
* `NUMERIC_IDENTIFIER`: falling through would silently reclassify `09` as an
26+
* alphanumeric identifier, and alphanumerics outrank every number — so
27+
* `preview.010` would sort above `preview.2`.
28+
*/
29+
const LEADING_ZERO_IDENTIFIER = /^0\d+$/
30+
2231
export interface ParsedVersion {
2332
major: number
2433
minor: number
@@ -58,13 +67,12 @@ export function parseVersion(version: string): ParsedVersion | null {
5867
return null
5968
}
6069

61-
const prerelease = match[4]
62-
? match[4]
63-
.split('.')
64-
.map((identifier) =>
65-
NUMERIC_IDENTIFIER.test(identifier) ? Number(identifier) : identifier
66-
)
67-
: []
70+
const identifiers = match[4] ? match[4].split('.') : []
71+
if (identifiers.some((identifier) => LEADING_ZERO_IDENTIFIER.test(identifier))) return null
72+
73+
const prerelease = identifiers.map((identifier) =>
74+
NUMERIC_IDENTIFIER.test(identifier) ? Number(identifier) : identifier
75+
)
6876
if (
6977
prerelease.some(
7078
(identifier) => typeof identifier === 'number' && !Number.isSafeInteger(identifier)

0 commit comments

Comments
 (0)