skillrepo 4.3.0 → 4.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,670 @@
1
+ /**
2
+ * Unit tests for `packages/cli/src/lib/npm-update-check.mjs` (#1554).
3
+ *
4
+ * The module is fire-and-forget by design: every failure mode returns
5
+ * null and the caller never knows. These tests inject mock I/O so we
6
+ * can assert on:
7
+ *
8
+ * - Cache miss → fetch → nudge emitted when latest > current.
9
+ * - Cache miss → fetch returns same version → no nudge.
10
+ * - Cache miss → fetch returns older version → no nudge (downgrade
11
+ * scenarios from `next` tag accidents would otherwise mis-nudge).
12
+ * - Fetch network error / timeout / non-2xx / parse failure / non-
13
+ * semver body → no throw, no nudge, fetchOk: false cache entry.
14
+ * - Read-only FS (writeFile throws) → no throw, the nudge still
15
+ * emits (cache is best-effort, not a gate on rendering).
16
+ * - Positive cache within 24h → no fetch attempted, cached value
17
+ * drives the nudge decision.
18
+ * - Negative cache within 1h → no fetch, no nudge.
19
+ * - Negative cache past 1h → re-fetch.
20
+ * - Positive cache past 24h → re-fetch.
21
+ * - currentCliVersion mismatch → discard cache and re-fetch (so a
22
+ * just-upgraded user isn't nudged toward the version they just
23
+ * installed).
24
+ * - SKILLREPO_NO_UPDATE_CHECK=1 → no fetch, no cache read, no nudge.
25
+ * - CI=true → no fetch, no nudge.
26
+ * - Parent command argv with --json is suppressed by the dispatcher,
27
+ * not by this module — covered separately at the bin layer (see
28
+ * the smoke-test note at the bottom of this file).
29
+ * - Windows-shaped cache path resolution.
30
+ *
31
+ * Test strategy
32
+ * -------------
33
+ * We never touch the real npm registry or the real `~/.claude/`
34
+ * directory. Every call to `checkForCliUpdate` passes a custom `io`
35
+ * with mocked fetch/now/fs primitives. Cache state is observed by
36
+ * inspecting the captured `writeFile` calls; nudge output is observed
37
+ * by capturing `stderrWrite`. This is the same DI pattern used in
38
+ * `telemetry.test.mjs`.
39
+ */
40
+
41
+ import { describe, it, beforeEach, afterEach } from "node:test";
42
+ import assert from "node:assert/strict";
43
+ import { homedir } from "node:os";
44
+ import { join } from "node:path";
45
+
46
+ import {
47
+ checkForCliUpdate,
48
+ cachePath,
49
+ CACHE_SCHEMA_VERSION,
50
+ POSITIVE_TTL_MS,
51
+ NEGATIVE_TTL_MS,
52
+ FETCH_TIMEOUT_MS,
53
+ NPM_LATEST_URL,
54
+ updateCheckDisabledByEnv,
55
+ } from "../../lib/npm-update-check.mjs";
56
+
57
+ // ── Test fixture builder ────────────────────────────────────────────────
58
+
59
+ /**
60
+ * Build a fresh I/O mock. Each field defaults to a "no-op success"
61
+ * shape; individual tests override the ones they care about.
62
+ *
63
+ * `cacheStore` is a closure that mocks the on-disk cache file: reads
64
+ * return its current value (or throw ENOENT when null); writes update
65
+ * it. `fetchResult` is what the mocked fetch resolves to.
66
+ */
67
+ function makeIo({
68
+ fetchResult = null,
69
+ fetchThrows = null,
70
+ now = Date.now(),
71
+ writeThrows = null,
72
+ } = {}) {
73
+ const stderrWrites = [];
74
+ const writeCalls = [];
75
+ const fetchCalls = [];
76
+
77
+ const io = {
78
+ fetch: async (url, options) => {
79
+ fetchCalls.push({ url, options });
80
+ if (fetchThrows) throw fetchThrows;
81
+ return fetchResult;
82
+ },
83
+ now: () => now,
84
+ // The cache read path uses node:fs directly (off-injection — see
85
+ // the temp-HOME setup below). Only writes flow through this `io`.
86
+ exists: () => true,
87
+ mkdir: () => {},
88
+ writeFile: (path, contents) => {
89
+ writeCalls.push({ path, contents });
90
+ if (writeThrows) throw writeThrows;
91
+ },
92
+ stderrWrite: (s) => {
93
+ stderrWrites.push(s);
94
+ },
95
+ };
96
+
97
+ return {
98
+ io,
99
+ getStderrWrites: () => stderrWrites,
100
+ getWriteCalls: () => writeCalls,
101
+ getFetchCalls: () => fetchCalls,
102
+ };
103
+ }
104
+
105
+ // ── Cache-read seam ────────────────────────────────────────────────────
106
+ //
107
+ // The module reads its cache file via `readFileSync` from `node:fs`
108
+ // (the real one), keyed off `cachePath()`. We can't inject around that
109
+ // without changing the module's public surface, so the tests redirect
110
+ // the home directory via `sandbox-home.mjs`. That helper sets BOTH
111
+ // HOME and USERPROFILE so `os.homedir()` lands in the sandbox on
112
+ // POSIX AND Windows — a plain `process.env.HOME = ...` would be a
113
+ // silent no-op on Windows (where homedir() reads USERPROFILE), and
114
+ // every other test under packages/cli/ uses the helper for exactly
115
+ // this reason. See helpers/sandbox-home.mjs JSDoc for the full
116
+ // history (PR #892 — 99 Windows CI failures).
117
+
118
+ import { mkdtempSync, mkdirSync, writeFileSync, existsSync, rmSync } from "node:fs";
119
+ import { tmpdir } from "node:os";
120
+
121
+ import {
122
+ captureHome,
123
+ setSandboxHome,
124
+ restoreHome,
125
+ } from "../helpers/sandbox-home.mjs";
126
+
127
+ /** @type {import("../helpers/sandbox-home.mjs").HomeEnvSnapshot} */
128
+ let originalHomeEnv;
129
+ let testHome;
130
+
131
+ beforeEach(() => {
132
+ originalHomeEnv = captureHome();
133
+ testHome = mkdtempSync(join(tmpdir(), "npm-update-check-"));
134
+ setSandboxHome(testHome);
135
+ delete process.env.SKILLREPO_NO_UPDATE_CHECK;
136
+ delete process.env.CI;
137
+ });
138
+
139
+ afterEach(() => {
140
+ if (testHome && existsSync(testHome)) {
141
+ rmSync(testHome, { recursive: true, force: true });
142
+ }
143
+ restoreHome(originalHomeEnv);
144
+ });
145
+
146
+ // Helper: seed the cache file on disk so the module's real
147
+ // readFileSync can find it. Uses `cachePath()` (which now resolves
148
+ // via `homedir()` → sandboxed HOME/USERPROFILE) to compute the
149
+ // target — never hard-codes a path so this stays correct on Windows.
150
+ function writeCacheFile(entry) {
151
+ const target = cachePath();
152
+ mkdirSync(join(testHome, ".claude", "skillrepo"), { recursive: true });
153
+ writeFileSync(target, JSON.stringify(entry, null, 2) + "\n", "utf-8");
154
+ }
155
+
156
+ // ── cachePath ──────────────────────────────────────────────────────────
157
+
158
+ describe("cachePath", () => {
159
+ it("resolves to ~/.claude/skillrepo/.npm-version-check via path.join", () => {
160
+ const expected = join(homedir(), ".claude", "skillrepo", ".npm-version-check");
161
+ assert.equal(cachePath(), expected);
162
+ });
163
+
164
+ it("uses platform-native separators (Windows compat smoke)", () => {
165
+ // FACT: path.join on win32 produces backslashes; on POSIX, forward
166
+ // slashes. We assert against `path.join` from the same module so
167
+ // this test passes on both platforms and locks in that the module
168
+ // does not hand-roll a separator.
169
+ const result = cachePath();
170
+ assert.ok(result.includes(".claude"));
171
+ assert.ok(result.includes("skillrepo"));
172
+ assert.ok(result.endsWith(".npm-version-check"));
173
+ // No double separators, no mixed separators on either platform.
174
+ if (process.platform === "win32") {
175
+ assert.ok(!result.includes("/"), "should use \\ on Windows");
176
+ } else {
177
+ assert.ok(!result.includes("\\"), "should use / on POSIX");
178
+ }
179
+ });
180
+ });
181
+
182
+ // ── updateCheckDisabledByEnv ───────────────────────────────────────────
183
+
184
+ describe("updateCheckDisabledByEnv", () => {
185
+ it("is false when neither env var is set", () => {
186
+ assert.equal(updateCheckDisabledByEnv(), false);
187
+ });
188
+
189
+ it("is true when SKILLREPO_NO_UPDATE_CHECK=1", () => {
190
+ process.env.SKILLREPO_NO_UPDATE_CHECK = "1";
191
+ assert.equal(updateCheckDisabledByEnv(), true);
192
+ });
193
+
194
+ it("is true for any truthy SKILLREPO_NO_UPDATE_CHECK value", () => {
195
+ process.env.SKILLREPO_NO_UPDATE_CHECK = "yes";
196
+ assert.equal(updateCheckDisabledByEnv(), true);
197
+ });
198
+
199
+ it("is false when SKILLREPO_NO_UPDATE_CHECK=0", () => {
200
+ process.env.SKILLREPO_NO_UPDATE_CHECK = "0";
201
+ assert.equal(updateCheckDisabledByEnv(), false);
202
+ });
203
+
204
+ it("is false when SKILLREPO_NO_UPDATE_CHECK=false", () => {
205
+ process.env.SKILLREPO_NO_UPDATE_CHECK = "false";
206
+ assert.equal(updateCheckDisabledByEnv(), false);
207
+ });
208
+
209
+ it("is true when CI=true (auto-disable for GitHub Actions / GitLab / CircleCI)", () => {
210
+ process.env.CI = "true";
211
+ assert.equal(updateCheckDisabledByEnv(), true);
212
+ });
213
+
214
+ it("is false when CI=false (the literal string 'false')", () => {
215
+ process.env.CI = "false";
216
+ assert.equal(updateCheckDisabledByEnv(), false);
217
+ });
218
+
219
+ it("is false when CI=1 (we match only 'true', not generic truthy)", () => {
220
+ // FACT: GitHub Actions, GitLab CI, CircleCI, Travis all set
221
+ // CI=true literally. Some other tools set CI=1; the issue
222
+ // explicitly scopes auto-disable to `CI === "true"`, so we lock
223
+ // that contract here. Users who want broader detection can set
224
+ // SKILLREPO_NO_UPDATE_CHECK.
225
+ process.env.CI = "1";
226
+ assert.equal(updateCheckDisabledByEnv(), false);
227
+ });
228
+ });
229
+
230
+ // ── checkForCliUpdate: cache miss + happy paths ────────────────────────
231
+
232
+ describe("checkForCliUpdate (cache miss, fetch succeeds)", () => {
233
+ it("emits a nudge when the registry returns a newer version", async () => {
234
+ const fixture = makeIo({
235
+ fetchResult: {
236
+ ok: true,
237
+ json: async () => ({ version: "4.5.0" }),
238
+ },
239
+ });
240
+ const result = await checkForCliUpdate({
241
+ currentVersion: "4.3.0",
242
+ io: fixture.io,
243
+ });
244
+ assert.equal(result.nudged, true);
245
+ const stderr = fixture.getStderrWrites().join("");
246
+ assert.match(stderr, /A newer .*skillrepo.* is available/);
247
+ assert.match(stderr, /4\.3\.0/);
248
+ assert.match(stderr, /4\.5\.0/);
249
+ assert.match(stderr, /npm install -g skillrepo@latest/);
250
+ });
251
+
252
+ it("does not nudge when registry returns the same version", async () => {
253
+ const fixture = makeIo({
254
+ fetchResult: { ok: true, json: async () => ({ version: "4.3.0" }) },
255
+ });
256
+ const result = await checkForCliUpdate({
257
+ currentVersion: "4.3.0",
258
+ io: fixture.io,
259
+ });
260
+ assert.equal(result.nudged, false);
261
+ assert.equal(result.reason, "up-to-date");
262
+ assert.equal(fixture.getStderrWrites().length, 0);
263
+ });
264
+
265
+ it("does not nudge when registry returns an older version", async () => {
266
+ // Defensive: if npm `latest` ever points at an older release
267
+ // (mistaken `next`/`latest` swap, dist-tag rollback), don't tell
268
+ // the user to "upgrade" to a version they're already ahead of.
269
+ const fixture = makeIo({
270
+ fetchResult: { ok: true, json: async () => ({ version: "4.1.0" }) },
271
+ });
272
+ const result = await checkForCliUpdate({
273
+ currentVersion: "4.3.0",
274
+ io: fixture.io,
275
+ });
276
+ assert.equal(result.nudged, false);
277
+ assert.equal(result.reason, "up-to-date");
278
+ });
279
+
280
+ it("writes a positive cache entry on success", async () => {
281
+ const now = 1747680000000; // 2026-05-19 ish
282
+ const fixture = makeIo({
283
+ fetchResult: { ok: true, json: async () => ({ version: "4.5.0" }) },
284
+ now,
285
+ });
286
+ await checkForCliUpdate({ currentVersion: "4.3.0", io: fixture.io });
287
+ const writes = fixture.getWriteCalls();
288
+ assert.equal(writes.length, 1);
289
+ const written = JSON.parse(writes[0].contents);
290
+ assert.equal(written.schemaVersion, CACHE_SCHEMA_VERSION);
291
+ assert.equal(written.checkedAt, new Date(now).toISOString());
292
+ assert.equal(written.currentCliVersion, "4.3.0");
293
+ assert.equal(written.latestPublishedVersion, "4.5.0");
294
+ assert.equal(written.fetchOk, true);
295
+ });
296
+
297
+ it("sends application/json Accept + identifying User-Agent", async () => {
298
+ // FACT: the npm registry returns HTTP 406 for
299
+ // `application/vnd.npm.install-v1+json` on the `/skillrepo/latest`
300
+ // endpoint (verified via curl 2026-05-19). `application/json` is
301
+ // what `npm view` itself sends for `/latest` and is what works.
302
+ // The issue body's header is incorrect for this endpoint and is
303
+ // tracked for a doc fix in the PR.
304
+ const fixture = makeIo({
305
+ fetchResult: { ok: true, json: async () => ({ version: "4.5.0" }) },
306
+ });
307
+ await checkForCliUpdate({ currentVersion: "4.3.0", io: fixture.io });
308
+ const call = fixture.getFetchCalls()[0];
309
+ assert.equal(call.url, NPM_LATEST_URL);
310
+ assert.equal(call.options.headers.Accept, "application/json");
311
+ assert.match(
312
+ call.options.headers["User-Agent"],
313
+ /^skillrepo-cli\/4\.3\.0 \(\+https:\/\/skillrepo\.dev\)$/,
314
+ );
315
+ });
316
+ });
317
+
318
+ // ── checkForCliUpdate: failure modes ───────────────────────────────────
319
+
320
+ describe("checkForCliUpdate (fetch failures — all silent)", () => {
321
+ it("network error: no nudge, fetchOk:false cached", async () => {
322
+ const fixture = makeIo({ fetchThrows: new Error("ECONNREFUSED") });
323
+ const result = await checkForCliUpdate({
324
+ currentVersion: "4.3.0",
325
+ io: fixture.io,
326
+ });
327
+ assert.equal(result.nudged, false);
328
+ assert.equal(result.reason, "no-result");
329
+ const writes = fixture.getWriteCalls();
330
+ assert.equal(writes.length, 1);
331
+ const written = JSON.parse(writes[0].contents);
332
+ assert.equal(written.fetchOk, false);
333
+ assert.equal(written.latestPublishedVersion, null);
334
+ });
335
+
336
+ it("non-2xx response: no nudge, no throw", async () => {
337
+ const fixture = makeIo({
338
+ fetchResult: { ok: false, status: 503, json: async () => ({}) },
339
+ });
340
+ const result = await checkForCliUpdate({
341
+ currentVersion: "4.3.0",
342
+ io: fixture.io,
343
+ });
344
+ assert.equal(result.nudged, false);
345
+ const writes = fixture.getWriteCalls();
346
+ assert.equal(writes.length, 1);
347
+ assert.equal(JSON.parse(writes[0].contents).fetchOk, false);
348
+ });
349
+
350
+ it("JSON parse failure: no nudge, no throw", async () => {
351
+ const fixture = makeIo({
352
+ fetchResult: {
353
+ ok: true,
354
+ json: async () => {
355
+ throw new SyntaxError("Unexpected token <");
356
+ },
357
+ },
358
+ });
359
+ const result = await checkForCliUpdate({
360
+ currentVersion: "4.3.0",
361
+ io: fixture.io,
362
+ });
363
+ assert.equal(result.nudged, false);
364
+ assert.equal(JSON.parse(fixture.getWriteCalls()[0].contents).fetchOk, false);
365
+ });
366
+
367
+ it("missing version field: no nudge", async () => {
368
+ const fixture = makeIo({
369
+ fetchResult: { ok: true, json: async () => ({ name: "skillrepo" }) },
370
+ });
371
+ const result = await checkForCliUpdate({
372
+ currentVersion: "4.3.0",
373
+ io: fixture.io,
374
+ });
375
+ assert.equal(result.nudged, false);
376
+ });
377
+
378
+ it("malformed semver in response: no nudge (defense against compromised mirror)", async () => {
379
+ const fixture = makeIo({
380
+ fetchResult: { ok: true, json: async () => ({ version: "not-a-version" }) },
381
+ });
382
+ const result = await checkForCliUpdate({
383
+ currentVersion: "4.3.0",
384
+ io: fixture.io,
385
+ });
386
+ assert.equal(result.nudged, false);
387
+ // We still cache fetchOk:false so we don't hammer a misbehaving
388
+ // mirror every command.
389
+ assert.equal(
390
+ JSON.parse(fixture.getWriteCalls()[0].contents).fetchOk,
391
+ false,
392
+ );
393
+ });
394
+
395
+ it("read-only filesystem (writeFile throws): no throw, no crash", async () => {
396
+ const fixture = makeIo({
397
+ fetchResult: { ok: true, json: async () => ({ version: "4.5.0" }) },
398
+ writeThrows: Object.assign(new Error("EROFS"), { code: "EROFS" }),
399
+ });
400
+ // Should still produce the nudge — cache is best-effort.
401
+ const result = await checkForCliUpdate({
402
+ currentVersion: "4.3.0",
403
+ io: fixture.io,
404
+ });
405
+ assert.equal(result.nudged, true);
406
+ });
407
+
408
+ it("invalid currentVersion: no fetch, no nudge", async () => {
409
+ const fixture = makeIo({
410
+ fetchResult: { ok: true, json: async () => ({ version: "4.5.0" }) },
411
+ });
412
+ const result = await checkForCliUpdate({
413
+ currentVersion: "garbage",
414
+ io: fixture.io,
415
+ });
416
+ assert.equal(result.nudged, false);
417
+ assert.equal(result.reason, "invalid-current-version");
418
+ assert.equal(fixture.getFetchCalls().length, 0);
419
+ });
420
+ });
421
+
422
+ // ── checkForCliUpdate: caching behavior ────────────────────────────────
423
+
424
+ describe("checkForCliUpdate (cache hit paths)", () => {
425
+ it("positive cache within 24h: no fetch, nudge from cache", async () => {
426
+ const now = 1747680000000;
427
+ writeCacheFile({
428
+ schemaVersion: CACHE_SCHEMA_VERSION,
429
+ checkedAt: new Date(now - 1000).toISOString(),
430
+ currentCliVersion: "4.3.0",
431
+ latestPublishedVersion: "4.5.0",
432
+ fetchOk: true,
433
+ });
434
+ const fixture = makeIo({
435
+ // If a fetch is attempted, this will be returned — but the test
436
+ // asserts zero fetches.
437
+ fetchResult: { ok: true, json: async () => ({ version: "9.9.9" }) },
438
+ now,
439
+ });
440
+ const result = await checkForCliUpdate({
441
+ currentVersion: "4.3.0",
442
+ io: fixture.io,
443
+ });
444
+ assert.equal(fixture.getFetchCalls().length, 0);
445
+ assert.equal(result.nudged, true);
446
+ assert.match(fixture.getStderrWrites().join(""), /4\.5\.0/);
447
+ });
448
+
449
+ it("positive cache where cached version equals current: no nudge, no fetch", async () => {
450
+ // Scenario: user upgraded between runs. With NO version-mismatch
451
+ // gate this would still nudge — see the version-mismatch test
452
+ // below. Here cachedCurrent == liveCurrent, so the cache is
453
+ // reused; we just don't nudge because latest == current.
454
+ const now = 1747680000000;
455
+ writeCacheFile({
456
+ schemaVersion: CACHE_SCHEMA_VERSION,
457
+ checkedAt: new Date(now - 1000).toISOString(),
458
+ currentCliVersion: "4.3.0",
459
+ latestPublishedVersion: "4.3.0",
460
+ fetchOk: true,
461
+ });
462
+ const fixture = makeIo({ now });
463
+ const result = await checkForCliUpdate({
464
+ currentVersion: "4.3.0",
465
+ io: fixture.io,
466
+ });
467
+ assert.equal(fixture.getFetchCalls().length, 0);
468
+ assert.equal(result.nudged, false);
469
+ assert.equal(result.reason, "up-to-date");
470
+ });
471
+
472
+ it("positive cache past 24h: re-fetches", async () => {
473
+ const now = 1747680000000;
474
+ writeCacheFile({
475
+ schemaVersion: CACHE_SCHEMA_VERSION,
476
+ checkedAt: new Date(now - POSITIVE_TTL_MS - 1).toISOString(),
477
+ currentCliVersion: "4.3.0",
478
+ latestPublishedVersion: "4.5.0",
479
+ fetchOk: true,
480
+ });
481
+ const fixture = makeIo({
482
+ fetchResult: { ok: true, json: async () => ({ version: "4.6.0" }) },
483
+ now,
484
+ });
485
+ const result = await checkForCliUpdate({
486
+ currentVersion: "4.3.0",
487
+ io: fixture.io,
488
+ });
489
+ assert.equal(fixture.getFetchCalls().length, 1);
490
+ assert.equal(result.nudged, true);
491
+ assert.match(fixture.getStderrWrites().join(""), /4\.6\.0/);
492
+ });
493
+
494
+ it("negative cache within 1h: no re-fetch, no nudge", async () => {
495
+ const now = 1747680000000;
496
+ writeCacheFile({
497
+ schemaVersion: CACHE_SCHEMA_VERSION,
498
+ checkedAt: new Date(now - 1000).toISOString(),
499
+ currentCliVersion: "4.3.0",
500
+ latestPublishedVersion: null,
501
+ fetchOk: false,
502
+ });
503
+ const fixture = makeIo({
504
+ fetchResult: { ok: true, json: async () => ({ version: "9.9.9" }) },
505
+ now,
506
+ });
507
+ const result = await checkForCliUpdate({
508
+ currentVersion: "4.3.0",
509
+ io: fixture.io,
510
+ });
511
+ assert.equal(fixture.getFetchCalls().length, 0);
512
+ assert.equal(result.nudged, false);
513
+ assert.equal(result.reason, "no-result");
514
+ });
515
+
516
+ it("negative cache past 1h: re-fetches", async () => {
517
+ const now = 1747680000000;
518
+ writeCacheFile({
519
+ schemaVersion: CACHE_SCHEMA_VERSION,
520
+ checkedAt: new Date(now - NEGATIVE_TTL_MS - 1).toISOString(),
521
+ currentCliVersion: "4.3.0",
522
+ latestPublishedVersion: null,
523
+ fetchOk: false,
524
+ });
525
+ const fixture = makeIo({
526
+ fetchResult: { ok: true, json: async () => ({ version: "4.5.0" }) },
527
+ now,
528
+ });
529
+ const result = await checkForCliUpdate({
530
+ currentVersion: "4.3.0",
531
+ io: fixture.io,
532
+ });
533
+ assert.equal(fixture.getFetchCalls().length, 1);
534
+ assert.equal(result.nudged, true);
535
+ });
536
+
537
+ it("cache mismatch on currentCliVersion: re-fetches", async () => {
538
+ // Scenario: user was on 4.3.0 yesterday, cache says latest is
539
+ // 4.5.0. Today they're running 4.5.0 (they upgraded). Don't keep
540
+ // nudging them — the live version is the source of truth.
541
+ const now = 1747680000000;
542
+ writeCacheFile({
543
+ schemaVersion: CACHE_SCHEMA_VERSION,
544
+ checkedAt: new Date(now - 1000).toISOString(),
545
+ currentCliVersion: "4.3.0",
546
+ latestPublishedVersion: "4.5.0",
547
+ fetchOk: true,
548
+ });
549
+ const fixture = makeIo({
550
+ fetchResult: { ok: true, json: async () => ({ version: "4.5.0" }) },
551
+ now,
552
+ });
553
+ const result = await checkForCliUpdate({
554
+ currentVersion: "4.5.0",
555
+ io: fixture.io,
556
+ });
557
+ // Re-fetch fires; cache gets rewritten with current=4.5.0.
558
+ assert.equal(fixture.getFetchCalls().length, 1);
559
+ assert.equal(result.nudged, false);
560
+ assert.equal(result.reason, "up-to-date");
561
+ });
562
+
563
+ it("malformed cache JSON: treated as miss", async () => {
564
+ mkdirSync(join(testHome, ".claude", "skillrepo"), { recursive: true });
565
+ writeFileSync(cachePath(), "{not json", "utf-8");
566
+ const fixture = makeIo({
567
+ fetchResult: { ok: true, json: async () => ({ version: "4.5.0" }) },
568
+ });
569
+ const result = await checkForCliUpdate({
570
+ currentVersion: "4.3.0",
571
+ io: fixture.io,
572
+ });
573
+ assert.equal(fixture.getFetchCalls().length, 1);
574
+ assert.equal(result.nudged, true);
575
+ });
576
+
577
+ it("cache with wrong schemaVersion: treated as miss", async () => {
578
+ writeCacheFile({
579
+ schemaVersion: 99,
580
+ checkedAt: new Date().toISOString(),
581
+ currentCliVersion: "4.3.0",
582
+ latestPublishedVersion: "4.5.0",
583
+ fetchOk: true,
584
+ });
585
+ const fixture = makeIo({
586
+ fetchResult: { ok: true, json: async () => ({ version: "4.5.0" }) },
587
+ });
588
+ const result = await checkForCliUpdate({
589
+ currentVersion: "4.3.0",
590
+ io: fixture.io,
591
+ });
592
+ assert.equal(fixture.getFetchCalls().length, 1);
593
+ assert.equal(result.nudged, true);
594
+ });
595
+ });
596
+
597
+ // ── checkForCliUpdate: kill switches ───────────────────────────────────
598
+
599
+ describe("checkForCliUpdate (kill switches)", () => {
600
+ it("SKILLREPO_NO_UPDATE_CHECK=1 → no fetch, no cache read, no nudge", async () => {
601
+ process.env.SKILLREPO_NO_UPDATE_CHECK = "1";
602
+ const fixture = makeIo({
603
+ fetchResult: { ok: true, json: async () => ({ version: "4.5.0" }) },
604
+ });
605
+ const result = await checkForCliUpdate({
606
+ currentVersion: "4.3.0",
607
+ io: fixture.io,
608
+ });
609
+ assert.equal(result.nudged, false);
610
+ assert.equal(result.reason, "disabled");
611
+ assert.equal(fixture.getFetchCalls().length, 0);
612
+ assert.equal(fixture.getStderrWrites().length, 0);
613
+ });
614
+
615
+ it("CI=true → no fetch, no nudge", async () => {
616
+ process.env.CI = "true";
617
+ const fixture = makeIo({
618
+ fetchResult: { ok: true, json: async () => ({ version: "4.5.0" }) },
619
+ });
620
+ const result = await checkForCliUpdate({
621
+ currentVersion: "4.3.0",
622
+ io: fixture.io,
623
+ });
624
+ assert.equal(result.nudged, false);
625
+ assert.equal(result.reason, "disabled");
626
+ assert.equal(fixture.getFetchCalls().length, 0);
627
+ });
628
+
629
+ it("SKILLREPO_NO_UPDATE_CHECK=0 is NOT a disable", async () => {
630
+ process.env.SKILLREPO_NO_UPDATE_CHECK = "0";
631
+ const fixture = makeIo({
632
+ fetchResult: { ok: true, json: async () => ({ version: "4.5.0" }) },
633
+ });
634
+ const result = await checkForCliUpdate({
635
+ currentVersion: "4.3.0",
636
+ io: fixture.io,
637
+ });
638
+ assert.equal(result.nudged, true);
639
+ });
640
+ });
641
+
642
+ // ── FETCH_TIMEOUT_MS constant lock ─────────────────────────────────────
643
+
644
+ describe("constants", () => {
645
+ it("FETCH_TIMEOUT_MS is 2 seconds per the spec", () => {
646
+ assert.equal(FETCH_TIMEOUT_MS, 2000);
647
+ });
648
+
649
+ it("POSITIVE_TTL_MS is exactly 24 hours", () => {
650
+ assert.equal(POSITIVE_TTL_MS, 24 * 60 * 60 * 1000);
651
+ });
652
+
653
+ it("NEGATIVE_TTL_MS is exactly 1 hour", () => {
654
+ assert.equal(NEGATIVE_TTL_MS, 60 * 60 * 1000);
655
+ });
656
+ });
657
+
658
+ // ── Notes on coverage gaps ─────────────────────────────────────────────
659
+ //
660
+ // 1. The `--json` suppression lives in `bin/skillrepo.mjs`. Asserting
661
+ // on it would require spawning the binary as a subprocess, which
662
+ // the rest of the CLI test suite avoids — and the suppression is a
663
+ // one-line `if (!rest.includes("--json"))` guard that's verified
664
+ // by code-review eyeballs and the existing CLI E2E suite (which
665
+ // asserts stdout/stderr cleanliness on `--json` invocations).
666
+ //
667
+ // 2. The 2-second wall-clock fetch timeout is exercised structurally
668
+ // (the AbortController is wired up in `fetchLatestVersion`); we do
669
+ // not assert on real wall-clock timing in unit tests because a
670
+ // sleep-based assertion is inherently flaky on shared CI runners.