skillrepo 4.4.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.
- package/README.md +57 -3
- package/bin/skillrepo.mjs +45 -0
- package/package.json +3 -2
- package/src/commands/list.mjs +328 -56
- package/src/lib/crypto-shas.mjs +131 -0
- package/src/lib/drift.mjs +175 -0
- package/src/lib/file-write.mjs +16 -1
- package/src/lib/npm-update-check.mjs +366 -0
- package/src/lib/paths.mjs +10 -0
- package/src/lib/placement-walk.mjs +285 -0
- package/src/lib/sync.mjs +163 -17
- package/src/test/commands/list.test.mjs +510 -2
- package/src/test/lib/crypto-shas.test.mjs +172 -0
- package/src/test/lib/drift.test.mjs +289 -0
- package/src/test/lib/npm-update-check.test.mjs +670 -0
- package/src/test/lib/placement-walk.test.mjs +453 -0
- package/src/test/lib/sync.test.mjs +409 -1
|
@@ -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.
|