Skip to content

Commit c6f946e

Browse files
authored
feat(cli): non-blocking "new version available" notice (24h-cached npm check, opt-out, CI-safe) (#181)
1 parent 768da46 commit c6f946e

4 files changed

Lines changed: 540 additions & 1 deletion

File tree

DOCUMENTATION.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -455,8 +455,20 @@ These apply to every command:
455455
| `TESTSPRITE_API_URL` | API endpoint — overrides the credentials file |
456456
| `TESTSPRITE_PROFILE` | Active profile (below `--profile`, above `default`) |
457457
| `TESTSPRITE_REQUEST_TIMEOUT_MS` | Per-request timeout in **milliseconds** (default `120000`, range `1000``600000`) |
458+
| `TESTSPRITE_NO_UPDATE_NOTIFIER` | Any non-empty value disables the once-per-24h "new version available" notice |
458459
| `NO_COLOR` | Suppress ANSI escape sequences in ticker output ([no-color.org](https://no-color.org/)) |
459460

461+
### Update notice
462+
463+
Interactive runs print a one-line "new version available" notice on stderr when
464+
a newer release exists. To learn this, the CLI contacts the public npm registry
465+
(`registry.npmjs.org`) at most once per 24 hours; the request carries the
466+
package name only — never your API key, project data, or command line. The
467+
check is skipped in CI, when stderr is not a TTY, under `--output json` /
468+
`--dry-run`, and entirely when `TESTSPRITE_NO_UPDATE_NOTIFIER` is set. Any
469+
failure is silent: the notice can never break or delay a command. This is the
470+
only outbound call the CLI makes besides your configured API endpoint.
471+
460472
### Scopes
461473

462474
API-key scopes gate the write and run surfaces:

src/index.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import { Output, isOutputMode } from './lib/output.js';
1616
import { maybeInstallProxyAgent } from './lib/proxy.js';
1717
import { renderCommanderError, rephraseUnknownOption } from './lib/render-error.js';
1818
import { maybeEmitSkillNudge } from './lib/skill-nudge.js';
19+
import { maybeNotifyUpdate } from './lib/update-check.js';
1920
import { VERSION } from './version.js';
2021
import { shouldRejectNodeVersion } from './version-guard.js';
2122

@@ -140,14 +141,24 @@ program.hook('preAction', (_thisCommand, actionCommand) => {
140141
profile?: string;
141142
dryRun?: boolean;
142143
};
144+
const commandPath = commandPathOf(actionCommand);
143145
maybeEmitSkillNudge({
144-
commandPath: commandPathOf(actionCommand),
146+
commandPath,
145147
output: isOutputMode(globals.output) ? globals.output : 'text',
146148
dryRun: globals.dryRun ?? false,
147149
profile: globals.profile ?? 'default',
148150
cwd: process.cwd(),
149151
env: process.env,
150152
});
153+
154+
// Best-effort update notice (see lib/update-check.ts): self-gates on the
155+
// opt-out env, CI, TTY, and a 24h cache; the wiring adds the flag-level
156+
// gates the lib cannot see. Skipped for `completion` (its stdout is eval'd
157+
// by shells), under --output json, and under --dry-run. Deliberately not
158+
// awaited: an advisory must never delay the real command.
159+
if (globals.output !== 'json' && globals.dryRun !== true && commandPath !== 'completion') {
160+
void maybeNotifyUpdate();
161+
}
151162
});
152163

153164
// Corporate/CI proxies: honor HTTPS_PROXY/HTTP_PROXY/NO_PROXY (Node's fetch

src/lib/update-check.test.ts

Lines changed: 231 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,231 @@
1+
/**
2+
* Unit tests for the update notice (issue #122). Every effect is injected:
3+
* no real network, filesystem, clock, or TTY is touched.
4+
*/
5+
6+
import { describe, expect, it, vi } from 'vitest';
7+
import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
8+
import { tmpdir } from 'node:os';
9+
import { join } from 'node:path';
10+
import type { UpdateCheckDeps } from './update-check.js';
11+
import {
12+
UPDATE_CHECK_OPT_OUT_ENV,
13+
UPDATE_CHECK_TTL_MS,
14+
compareSemver,
15+
fetchLatestVersion,
16+
maybeNotifyUpdate,
17+
shouldCheckForUpdate,
18+
} from './update-check.js';
19+
20+
/** In-memory fs + deterministic clock harness for the cache round-trip. */
21+
function makeHarness(overrides: UpdateCheckDeps = {}) {
22+
const files = new Map<string, string>();
23+
const stderrLines: string[] = [];
24+
const deps: UpdateCheckDeps = {
25+
env: {},
26+
now: () => 1_000_000,
27+
cachePath: '/fake/.testsprite/update-check.json',
28+
readFile: path => {
29+
const content = files.get(path);
30+
if (content === undefined) throw new Error('ENOENT');
31+
return content;
32+
},
33+
writeFile: (path, content) => {
34+
files.set(path, content);
35+
},
36+
mkdir: () => undefined,
37+
isTTY: true,
38+
stderr: line => stderrLines.push(line),
39+
currentVersion: '0.2.0',
40+
fetchImpl: async () =>
41+
new Response(JSON.stringify({ version: '0.2.0' }), {
42+
status: 200,
43+
headers: { 'content-type': 'application/json' },
44+
}),
45+
...overrides,
46+
};
47+
return { deps, files, stderrLines };
48+
}
49+
50+
describe('shouldCheckForUpdate gates', () => {
51+
it('opt-out env set to any non-empty value disables (even "0")', () => {
52+
const { deps } = makeHarness({ env: { [UPDATE_CHECK_OPT_OUT_ENV]: '0' } });
53+
expect(shouldCheckForUpdate(deps)).toBe(false);
54+
});
55+
56+
it('CI set disables; CI="false" re-enables', () => {
57+
expect(shouldCheckForUpdate(makeHarness({ env: { CI: 'true' } }).deps)).toBe(false);
58+
expect(shouldCheckForUpdate(makeHarness({ env: { CI: '' } }).deps)).toBe(false);
59+
expect(shouldCheckForUpdate(makeHarness({ env: { CI: 'false' } }).deps)).toBe(true);
60+
});
61+
62+
it('non-TTY stderr disables', () => {
63+
expect(shouldCheckForUpdate(makeHarness({ isTTY: false }).deps)).toBe(false);
64+
});
65+
66+
it('a fresh cache suppresses; a stale cache does not', () => {
67+
const fresh = makeHarness();
68+
fresh.files.set(
69+
'/fake/.testsprite/update-check.json',
70+
JSON.stringify({ lastCheckMs: 1_000_000 - UPDATE_CHECK_TTL_MS + 5_000 }),
71+
);
72+
expect(shouldCheckForUpdate(fresh.deps)).toBe(false);
73+
74+
const stale = makeHarness();
75+
stale.files.set(
76+
'/fake/.testsprite/update-check.json',
77+
JSON.stringify({ lastCheckMs: 1_000_000 - UPDATE_CHECK_TTL_MS - 5_000 }),
78+
);
79+
expect(shouldCheckForUpdate(stale.deps)).toBe(true);
80+
});
81+
82+
it('missing, corrupt, wrong-shape, or future-stamped caches count as stale', () => {
83+
expect(shouldCheckForUpdate(makeHarness().deps)).toBe(true); // missing
84+
const corrupt = makeHarness();
85+
corrupt.files.set('/fake/.testsprite/update-check.json', '{not json');
86+
expect(shouldCheckForUpdate(corrupt.deps)).toBe(true);
87+
const wrongShape = makeHarness();
88+
wrongShape.files.set('/fake/.testsprite/update-check.json', JSON.stringify({ nope: true }));
89+
expect(shouldCheckForUpdate(wrongShape.deps)).toBe(true);
90+
const future = makeHarness();
91+
future.files.set(
92+
'/fake/.testsprite/update-check.json',
93+
JSON.stringify({ lastCheckMs: 9_999_999_999 }),
94+
);
95+
expect(shouldCheckForUpdate(future.deps)).toBe(true);
96+
});
97+
});
98+
99+
describe('fetchLatestVersion', () => {
100+
it('returns the version from a valid registry body', async () => {
101+
const { deps } = makeHarness({
102+
fetchImpl: async () => new Response(JSON.stringify({ version: '1.2.3' }), { status: 200 }),
103+
});
104+
await expect(fetchLatestVersion(deps)).resolves.toBe('1.2.3');
105+
});
106+
107+
it('returns undefined on non-2xx, thrown fetch, and wrong-shape body', async () => {
108+
const notOk = makeHarness({ fetchImpl: async () => new Response('nope', { status: 500 }) });
109+
await expect(fetchLatestVersion(notOk.deps)).resolves.toBeUndefined();
110+
111+
const throwing = makeHarness({
112+
fetchImpl: async () => {
113+
throw new TypeError('fetch failed');
114+
},
115+
});
116+
await expect(fetchLatestVersion(throwing.deps)).resolves.toBeUndefined();
117+
118+
const wrongShape = makeHarness({
119+
fetchImpl: async () => new Response(JSON.stringify({ notVersion: 1 }), { status: 200 }),
120+
});
121+
await expect(fetchLatestVersion(wrongShape.deps)).resolves.toBeUndefined();
122+
});
123+
});
124+
125+
describe('compareSemver', () => {
126+
it('orders numerically and treats prerelease as older than its release', () => {
127+
expect(compareSemver('0.2.0', '0.3.0')).toBe(-1);
128+
expect(compareSemver('1.0.0', '0.9.9')).toBe(1);
129+
expect(compareSemver('0.2.0', '0.2.0')).toBe(0);
130+
expect(compareSemver('0.10.0', '0.9.0')).toBe(1); // numeric, not lexicographic
131+
expect(compareSemver('1.0.0-rc.1', '1.0.0')).toBe(-1);
132+
expect(compareSemver('1.0.0', '1.0.0-rc.1')).toBe(1);
133+
});
134+
135+
it('unparseable input on either side compares as 0 (never a false notice)', () => {
136+
expect(compareSemver('garbage', '1.0.0')).toBe(0);
137+
expect(compareSemver('1.0.0', '')).toBe(0);
138+
});
139+
});
140+
141+
describe('maybeNotifyUpdate', () => {
142+
it('prints exactly one stderr line naming both versions when newer, and stamps the cache', async () => {
143+
const harness = makeHarness({
144+
fetchImpl: async () => new Response(JSON.stringify({ version: '0.3.1' }), { status: 200 }),
145+
});
146+
await maybeNotifyUpdate(harness.deps);
147+
expect(harness.stderrLines).toHaveLength(1);
148+
expect(harness.stderrLines[0]).toContain('0.2.0 -> 0.3.1');
149+
expect(harness.stderrLines[0]).toContain(UPDATE_CHECK_OPT_OUT_ENV);
150+
const cache = JSON.parse(harness.files.get('/fake/.testsprite/update-check.json')!) as {
151+
lastCheckMs: number;
152+
latestKnown?: string;
153+
};
154+
expect(cache.lastCheckMs).toBe(1_000_000);
155+
expect(cache.latestKnown).toBe('0.3.1');
156+
});
157+
158+
it('stays silent on an equal or older registry version', async () => {
159+
const equal = makeHarness();
160+
await maybeNotifyUpdate(equal.deps);
161+
expect(equal.stderrLines).toHaveLength(0);
162+
163+
const older = makeHarness({
164+
fetchImpl: async () => new Response(JSON.stringify({ version: '0.1.9' }), { status: 200 }),
165+
});
166+
await maybeNotifyUpdate(older.deps);
167+
expect(older.stderrLines).toHaveLength(0);
168+
});
169+
170+
it('a failed probe stays silent but still stamps the cache (retry once per TTL)', async () => {
171+
const harness = makeHarness({
172+
fetchImpl: async () => {
173+
throw new TypeError('fetch failed');
174+
},
175+
});
176+
await maybeNotifyUpdate(harness.deps);
177+
expect(harness.stderrLines).toHaveLength(0);
178+
const cache = JSON.parse(harness.files.get('/fake/.testsprite/update-check.json')!) as {
179+
lastCheckMs: number;
180+
latestKnown?: string;
181+
};
182+
expect(cache.lastCheckMs).toBe(1_000_000);
183+
expect(cache.latestKnown).toBeUndefined();
184+
});
185+
186+
it('does nothing when a gate blocks (no fetch fired)', async () => {
187+
const fetchImpl = vi.fn(async () => new Response('{}', { status: 200 }));
188+
const harness = makeHarness({ env: { CI: '1' }, fetchImpl });
189+
await maybeNotifyUpdate(harness.deps);
190+
expect(fetchImpl).not.toHaveBeenCalled();
191+
expect(harness.stderrLines).toHaveLength(0);
192+
});
193+
194+
it('never rejects, even when every injected dependency throws', async () => {
195+
const harness = makeHarness({
196+
fetchImpl: async () => new Response(JSON.stringify({ version: '9.9.9' }), { status: 200 }),
197+
writeFile: () => {
198+
throw new Error('EROFS');
199+
},
200+
stderr: () => {
201+
throw new Error('broken stderr sink');
202+
},
203+
});
204+
await expect(maybeNotifyUpdate(harness.deps)).resolves.toBeUndefined();
205+
});
206+
207+
it('uses the default fs readers/writers when none are injected (real cache round-trip)', async () => {
208+
const dir = mkdtempSync(join(tmpdir(), 'update-check-'));
209+
// Point cachePath at a not-yet-existing subdir so the default mkdir
210+
// (recursive) and writeFile arrows both run, and the first read (file
211+
// absent) exercises the default readFile arrow's ENOENT path.
212+
const cachePath = join(dir, 'nested', 'update-check.json');
213+
const stderrLines: string[] = [];
214+
try {
215+
await maybeNotifyUpdate({
216+
env: {},
217+
now: () => 1_000_000,
218+
isTTY: true,
219+
currentVersion: '0.0.1',
220+
cachePath,
221+
stderr: line => stderrLines.push(line),
222+
fetchImpl: async () => new Response(JSON.stringify({ version: '9.9.9' }), { status: 200 }),
223+
});
224+
// Cache was persisted by the default writeFile through the default mkdir.
225+
expect(JSON.parse(readFileSync(cachePath, 'utf8'))).toMatchObject({ latestKnown: '9.9.9' });
226+
expect(stderrLines.join('\n')).toContain('0.0.1 -> 9.9.9');
227+
} finally {
228+
rmSync(dir, { recursive: true, force: true });
229+
}
230+
});
231+
});

0 commit comments

Comments
 (0)