Skip to content

Commit 206d325

Browse files
committed
fix(cli): close the update-notifier findings from pre-landing review
Mutation testing found three tests that could not fail: deleting the `preAction` hook, switching the default writer to stdout, and flipping `comparePrerelease`'s empty-list arm all left the suite green. The stdout one was vacuous because the test helper always injected a writer, so the single safety property this feature claims - never touch stdout - was unprotected. The hook now has a positive test. It asserts registration rather than a resulting request, because the check suppresses itself when running from a checkout, and inside the suite `import.meta.url` IS a checkout: the behavioural path is unreachable there by construction. It is covered directly in check.test.ts and walked against the real registry from a staged global install. Security review: the response body is now read under a 64KB budget instead of buffering whatever a mirror sends, the request refuses to follow redirects, and the registry's answer is parsed before it is persisted, so nothing unvalidated reaches the disk. The reduced User-Agent was a comment; it is now an assertion, so a future "DRY up the user agent" refactor cannot silently start handing npm the user's node version, platform and arch. A configured mirror's own path and query are preserved. `new URL(relative, base)` discards both, so a token-authenticated Artifactory or Nexus base was being rewritten into a request the mirror answers with a 404. Also: one normalisation for every module-path decision (separators AND case, so a Windows or case-insensitive checkout is not read as a global install by one guard and a checkout by the other), the package name is named once rather than spelled in two unrelated places, and `delete process.env.SIM_CONFIG_DIR` in teardown - assigning `undefined` stores the literal string and leaves later tests pointed at a relative `./undefined` directory. Tests: 843 -> 861. Ten mutations applied to verify the new assertions actually fail when the thing they guard is broken; all ten killed. Declined, with reasons: the ~10s lingering-socket exit delay could not be reproduced through the CLI (measured 1.11-1.38s across three runs on node v23.11.0, including a command that only sets exitCode), so no node:https rewrite. `announced` plus `resetUpdateCheck` stays - it is the same shape as the existing resetEnvironmentNotices and resetRenameWarnings seams. The channel type stays rather than collapsing to a boolean, because it is what a decision to notify prerelease users would extend; its docs now say what the code does instead of describing a comparison it never performs.
1 parent afbec15 commit 206d325

7 files changed

Lines changed: 290 additions & 59 deletions

File tree

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

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -133,10 +133,21 @@ Update available: sim 2.1.2 → 2.1.5. Run: npm install -g sim@latest
133133
```
134134

135135
The request carries the CLI version and nothing else — no API key, no workspace,
136-
no command. It is skipped entirely when stderr is redirected, in CI, under
137-
`npx`, and for prerelease installs, so scripted output is never affected. Set
138-
`SIM_NO_UPDATE_CHECK=1` to turn it off, and `npm_config_registry` to ask a
139-
mirror instead.
136+
no command — and it never follows a redirect away from the registry you asked.
137+
138+
The notice is skipped entirely when:
139+
140+
- `SIM_NO_UPDATE_CHECK` is set to anything but `0` or `false`
141+
- stderr is not a terminal, so redirected and piped output is never affected
142+
- a CI environment variable is present (`CI`, `GITHUB_ACTIONS`, `JENKINS_URL`,
143+
`TEAMCITY_VERSION`, `BUILDKITE`)
144+
- the CLI is running under `npx`, which resolves the newest version every time
145+
- the CLI is running from a checkout of the sim repository, whose version
146+
deliberately trails the published one
147+
- the installed version is a prerelease from the `staging` or `dev` channel
148+
149+
Set `npm_config_registry` to ask a mirror instead; its path and query are
150+
preserved, so a token-authenticated Artifactory or Nexus base works.
140151

141152
Node ignores `HTTPS_PROXY` unless you also set `NODE_USE_ENV_PROXY=1`, and only
142153
from Node 22.21 and 24.5. The CLI warns when a proxy is configured but will not

packages/sim-cli/README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -264,8 +264,8 @@ The main environment variables are:
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
266266
stderr when a newer one exists. It sends nothing but its own version, never a
267-
key, and stays quiet when stderr is not a terminal, in CI, and under `npx`. Set
268-
`SIM_NO_UPDATE_CHECK=1` to turn it off.
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).
269269

270270
## Documentation
271271

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

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,4 +193,30 @@ describe('the update check', () => {
193193
rmSync(dir, { recursive: true, force: true })
194194
}
195195
})
196+
197+
/**
198+
* The positive half, and the one that matters: without it the hook can be
199+
* deleted from `buildProgram` and every other test still passes.
200+
*
201+
* It asserts registration rather than a resulting request, because the check
202+
* suppresses itself when it is running from a checkout — and inside this
203+
* suite `import.meta.url` IS a checkout, so the behavioural path is
204+
* unreachable here by construction. That path is covered directly in
205+
* check.test.ts and walked against the real registry from a staged global
206+
* install before release.
207+
*/
208+
it('registers the update check as a root preAction hook', async () => {
209+
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 ?? []
216+
217+
expect(preAction).toHaveLength(1)
218+
// Invoking it must resolve, never throw: it runs in front of the user's
219+
// command, and a rejection here would fail the command itself.
220+
await expect(preAction[0](program, program)).resolves.toBeUndefined()
221+
})
196222
})

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

Lines changed: 113 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node
55
import { tmpdir } from 'node:os'
66
import { join } from 'node:path'
77
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
8+
import { CLI_VERSION } from '../version'
89
import { announceUpdateIfAvailable, resetUpdateCheck, upgradeCommand } from './check'
910

1011
/** A global install, which is the only shape that gets advised at all. */
@@ -13,11 +14,20 @@ const INSTALLED = '/usr/local/lib/node_modules/sim/dist/index.js'
1314
let configDir: string
1415
let notices: string[]
1516
let fetched: URL[]
17+
let inits: RequestInit[]
1618

1719
/** Answers the dist-tags request the way the registry does. */
18-
function stubRegistry(tags: Record<string, string> | 'reject' | 'not-found' | 'html'): void {
19-
vi.stubGlobal('fetch', (input: URL) => {
20+
function stubRegistry(
21+
tags: Record<string, unknown> | 'reject' | 'not-found' | 'html' | 'oversized'
22+
): void {
23+
vi.stubGlobal('fetch', (input: URL, init: RequestInit) => {
2024
fetched.push(input)
25+
inits.push(init)
26+
if (tags === 'oversized') {
27+
return Promise.resolve(
28+
Response.json({ latest: '2.1.5' }, { headers: { 'content-length': String(1024 * 1024) } })
29+
)
30+
}
2131
if (tags === 'reject') return Promise.reject(new Error('getaddrinfo ENOTFOUND'))
2232
if (tags === 'not-found') return Promise.resolve(new Response('', { status: 404 }))
2333
if (tags === 'html') return Promise.resolve(new Response('<html>nope</html>', { status: 200 }))
@@ -46,11 +56,14 @@ beforeEach(() => {
4656
process.env.SIM_CONFIG_DIR = configDir
4757
notices = []
4858
fetched = []
59+
inits = []
4960
stubRegistry({ latest: '2.1.5' })
5061
})
5162

5263
afterEach(() => {
5364
vi.unstubAllGlobals()
65+
// `= undefined` would store the literal string "undefined", leaving later
66+
// tests pointed at a relative `./undefined` config directory.
5467
process.env.SIM_CONFIG_DIR = undefined
5568
rmSync(configDir, { recursive: true, force: true })
5669
})
@@ -78,20 +91,49 @@ describe('announcing a newer release', () => {
7891
expect(notices).toEqual([])
7992
})
8093

81-
it('writes nothing to stdout, which may be a pipeline', async () => {
82-
const originalWrite = process.stdout.write
83-
const stdout: string[] = []
94+
it('writes through the real default: stderr yes, stdout never', async () => {
95+
// Deliberately without the `write` override, so the production default is
96+
// the thing under test. stdout may be a pipeline feeding jq.
97+
const realOut = process.stdout.write
98+
const realErr = process.stderr.write
99+
const seen = { out: [] as string[], err: [] as string[] }
84100
process.stdout.write = ((chunk: string) => {
85-
stdout.push(String(chunk))
101+
seen.out.push(String(chunk))
86102
return true
87103
}) as typeof process.stdout.write
104+
process.stderr.write = ((chunk: string) => {
105+
seen.err.push(String(chunk))
106+
return true
107+
}) as typeof process.stderr.write
88108
try {
89-
await run()
109+
await announceUpdateIfAvailable({
110+
currentVersion: '2.1.2',
111+
env: {},
112+
isTty: true,
113+
modulePath: INSTALLED,
114+
})
90115
} finally {
91-
process.stdout.write = originalWrite
116+
process.stdout.write = realOut
117+
process.stderr.write = realErr
92118
}
93-
expect(notices).toHaveLength(1)
94-
expect(stdout).toEqual([])
119+
expect(seen.out).toEqual([])
120+
expect(seen.err.join('')).toContain('Update available: sim 2.1.2 → 2.1.5')
121+
})
122+
123+
it('sends only its own version, and refuses to follow a redirect', async () => {
124+
await run()
125+
const headers = inits[0]?.headers as Record<string, string>
126+
// The reduced agent is the privacy property: the exported USER_AGENT in
127+
// version.ts also carries node version, platform and arch.
128+
expect(headers['user-agent']).toBe(`sim-cli/${CLI_VERSION}`)
129+
expect(headers.accept).toBe('application/json')
130+
expect(headers.authorization).toBeUndefined()
131+
expect(inits[0]?.redirect).toBe('error')
132+
})
133+
134+
it('bounds the request so a hung registry cannot stall the command', async () => {
135+
await run()
136+
expect(inits[0]?.signal).toBeInstanceOf(AbortSignal)
95137
})
96138
})
97139

@@ -113,20 +155,36 @@ describe('when the notice is suppressed', () => {
113155
expect(notices).toEqual([])
114156
})
115157

116-
it('says nothing in CI, even where CI allocates a terminal', async () => {
117-
await run({ env: { BUILDKITE: 'true' } })
118-
expect(notices).toEqual([])
119-
})
158+
it.each(['CI', 'GITHUB_ACTIONS', 'JENKINS_URL', 'TEAMCITY_VERSION', 'BUILDKITE'])(
159+
'says nothing when %s is set, even where CI allocates a terminal',
160+
async (variable) => {
161+
await run({ env: { [variable]: 'true' } })
162+
expect(fetched).toEqual([])
163+
expect(notices).toEqual([])
164+
}
165+
)
120166

121-
it('says nothing under npx, which resolves the tag on every run', async () => {
122-
await run({ modulePath: '/Users/x/.npm/_npx/a1b2/node_modules/sim/dist/index.js' })
167+
it.each([
168+
'/Users/x/.npm/_npx/a1b2/node_modules/sim/dist/index.js',
169+
'C:\\Users\\x\\AppData\\Local\\npm-cache\\_npx\\a1b2\\node_modules\\sim\\dist\\index.js',
170+
])('says nothing under npx, which resolves the tag on every run (%s)', async (modulePath) => {
171+
await run({ modulePath })
123172
expect(notices).toEqual([])
124173
})
125174

126-
it('says nothing from a checkout, whose manifest trails npm by design', async () => {
127-
await run({ modulePath: '/Users/x/sim/packages/sim-cli/dist/index.js' })
128-
expect(notices).toEqual([])
129-
})
175+
it.each([
176+
'/Users/x/sim/packages/sim-cli/dist/index.js',
177+
// Windows, and mixed case: a checkout is a checkout on a case-insensitive
178+
// volume too, and this is the guard that stops every Sim engineer being
179+
// nagged daily by their own build.
180+
'C:\\Users\\x\\Sim\\Packages\\Sim-CLI\\dist\\index.js',
181+
])(
182+
'says nothing from a checkout, whose manifest trails npm by design (%s)',
183+
async (modulePath) => {
184+
await run({ modulePath })
185+
expect(notices).toEqual([])
186+
}
187+
)
130188

131189
it('says nothing to a prerelease install', async () => {
132190
stubRegistry({ latest: '2.1.5', staging: '2.1.6-preview.812.1' })
@@ -157,8 +215,10 @@ describe('the once-a-day cache', () => {
157215
checkedAt: '2026-09-02T10:00:00.000Z',
158216
latestVersion: '2.1.5',
159217
})
160-
// Not secret, but not world-writable either.
161-
expect(statSync(cachePath()).mode & 0o777).toBe(0o644)
218+
// Assert the property, not the literal mode: writeFileSync's mode is
219+
// masked by the ambient umask, so an exact comparison fails under
220+
// `umask 077` for reasons that have nothing to do with this code.
221+
expect(statSync(cachePath()).mode & 0o022).toBe(0)
162222
})
163223

164224
it('does not contact the registry again within the day', async () => {
@@ -221,6 +281,22 @@ describe('when the registry does not answer', () => {
221281
expect(notices).toEqual([])
222282
})
223283

284+
it.each([
285+
['an empty object', {} as Record<string, unknown>],
286+
['a non-string tag value', { latest: 42 } as Record<string, unknown>],
287+
['a nested object where a version belongs', { latest: { version: '9.9.9' } }],
288+
])('stays silent when the payload carries %s', async (_label, payload) => {
289+
stubRegistry(payload)
290+
await run()
291+
expect(notices).toEqual([])
292+
})
293+
294+
it('refuses a body far larger than this endpoint could legitimately return', async () => {
295+
stubRegistry('oversized')
296+
await run()
297+
expect(notices).toEqual([])
298+
})
299+
224300
it('stays silent when the tag is missing or is not a version', async () => {
225301
stubRegistry({ staging: '2.1.6-preview.1.1' })
226302
await run()
@@ -244,10 +320,23 @@ describe('when the registry does not answer', () => {
244320
expect(fetched.map(String)).toEqual(['https://npm.internal/api/npm/-/package/sim/dist-tags'])
245321
})
246322

247-
it('ignores a mirror setting that is not an http url', async () => {
248-
await run({ env: { npm_config_registry: 'not a url' } })
323+
it.each([
324+
['a value that is not a url', 'not a url'],
325+
['a non-http protocol', 'file:///var/tmp/registry'],
326+
['whitespace', ' '],
327+
])('falls back to the default registry for %s', async (_label, configured) => {
328+
await run({ env: { npm_config_registry: configured } })
249329
expect(fetched.map(String)).toEqual(['https://registry.npmjs.org/-/package/sim/dist-tags'])
250330
})
331+
332+
it("keeps a token-authenticated mirror's own path and query", async () => {
333+
// Artifactory and Nexus bases carry both. Resolving the path as a relative
334+
// URL would drop them and ask the mirror a question it answers with a 404.
335+
await run({ env: { npm_config_registry: 'https://npm.internal/api/npm/repo?token=abc' } })
336+
expect(fetched.map(String)).toEqual([
337+
'https://npm.internal/api/npm/repo/-/package/sim/dist-tags?token=abc',
338+
])
339+
})
251340
})
252341

253342
describe('the upgrade command', () => {

0 commit comments

Comments
 (0)