skillrepo 4.5.0 → 4.5.2
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/package.json +1 -1
- package/src/commands/add.mjs +17 -2
- package/src/commands/get.mjs +17 -1
- package/src/commands/list.mjs +28 -9
- package/src/commands/remove.mjs +13 -0
- package/src/lib/agent-registry.mjs +15 -1
- package/src/lib/cli-config.mjs +32 -5
- package/src/lib/detect-agents.mjs +42 -5
- package/src/lib/sync.mjs +253 -3
- package/src/test/e2e/mock-server.mjs +92 -2
- package/src/test/integration/update-list-contract.integration.test.mjs +1565 -0
- package/src/test/lib/cli-config.test.mjs +57 -6
- package/src/test/lib/detect-agents.test.mjs +37 -4
- package/src/test/lib/sync.test.mjs +220 -0
|
@@ -0,0 +1,1565 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cross-command integration: `update` → `list` contract.
|
|
3
|
+
*
|
|
4
|
+
* Why this file exists
|
|
5
|
+
* --------------------
|
|
6
|
+
* This is the test layer the v4.5.0 production-readiness review missed.
|
|
7
|
+
*
|
|
8
|
+
* The 4.5.0 staleness epic shipped two commands that share a contract:
|
|
9
|
+
*
|
|
10
|
+
* `update` WRITES skills to disk under every vendor it targets.
|
|
11
|
+
* `list` READS those placements to compute per-skill drift state.
|
|
12
|
+
*
|
|
13
|
+
* The shared contract is the set of "detected vendors." If `update`
|
|
14
|
+
* writes to a smaller set than `list` walks, the list rolls up as
|
|
15
|
+
* MISSING for every multi-vendor user — exactly the production bug
|
|
16
|
+
* that triggered the 4.5.1 hotfix.
|
|
17
|
+
*
|
|
18
|
+
* Per-command unit tests (update.test.mjs + list.test.mjs) cannot
|
|
19
|
+
* surface this. They each fix one half of the contract and mock the
|
|
20
|
+
* other. Only an end-to-end test that runs `update` THEN reads the
|
|
21
|
+
* resulting `list` output catches a contract divergence. That's what
|
|
22
|
+
* this file is — the test that would have caught 4.5.1 pre-shipping.
|
|
23
|
+
*
|
|
24
|
+
* Test isolation
|
|
25
|
+
* --------------
|
|
26
|
+
* Every test runs inside a sandbox HOME so detection env vars are
|
|
27
|
+
* controlled, on-disk placements live in temp dirs, and the
|
|
28
|
+
* `.last-sync` state file is sandbox-local. The mock server stands in
|
|
29
|
+
* for the real registry. Detection signals are toggled via env vars
|
|
30
|
+
* documented in `agent-registry.mjs`:
|
|
31
|
+
*
|
|
32
|
+
* - claudeCode: CLAUDECODE=1
|
|
33
|
+
* - cursor: CURSOR_AGENT=1
|
|
34
|
+
*
|
|
35
|
+
* Detection via `home`/`project` filesystem signals is also exercised
|
|
36
|
+
* implicitly when a test seeds `.claude/` or `.agents/` directories
|
|
37
|
+
* before invoking `update`.
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
import { describe, it, beforeEach, afterEach } from "node:test";
|
|
41
|
+
import assert from "node:assert/strict";
|
|
42
|
+
import {
|
|
43
|
+
mkdtempSync,
|
|
44
|
+
mkdirSync,
|
|
45
|
+
rmSync,
|
|
46
|
+
existsSync,
|
|
47
|
+
readFileSync,
|
|
48
|
+
writeFileSync,
|
|
49
|
+
} from "node:fs";
|
|
50
|
+
import { join } from "node:path";
|
|
51
|
+
import { tmpdir } from "node:os";
|
|
52
|
+
|
|
53
|
+
import { runUpdate } from "../../commands/update.mjs";
|
|
54
|
+
import { runList } from "../../commands/list.mjs";
|
|
55
|
+
import { runGet } from "../../commands/get.mjs";
|
|
56
|
+
import { runAdd } from "../../commands/add.mjs";
|
|
57
|
+
import { runRemove } from "../../commands/remove.mjs";
|
|
58
|
+
import { resolvePlacementDir } from "../../lib/file-write.mjs";
|
|
59
|
+
import { globalLastSyncPath } from "../../lib/paths.mjs";
|
|
60
|
+
import { readLastSync } from "../../lib/sync.mjs";
|
|
61
|
+
import { createMockServer } from "../e2e/mock-server.mjs";
|
|
62
|
+
import { createCaptureStream } from "../helpers/capture-stream.mjs";
|
|
63
|
+
import {
|
|
64
|
+
captureHome,
|
|
65
|
+
setSandboxHome,
|
|
66
|
+
restoreHome,
|
|
67
|
+
} from "../helpers/sandbox-home.mjs";
|
|
68
|
+
|
|
69
|
+
let sandbox;
|
|
70
|
+
let server;
|
|
71
|
+
let serverUrl;
|
|
72
|
+
let originalCwd;
|
|
73
|
+
/** @type {import("../helpers/sandbox-home.mjs").HomeEnvSnapshot} */
|
|
74
|
+
let originalHomeEnv;
|
|
75
|
+
let stdout;
|
|
76
|
+
const VALID_KEY = "sk_live_test";
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Build a registry skill payload with a single SKILL.md file. Same
|
|
80
|
+
* helper the unit tests use; replicated here so changes in either
|
|
81
|
+
* place don't silently break the other.
|
|
82
|
+
*/
|
|
83
|
+
function makeSkill(owner, name, version = "1.0.0", updatedAt) {
|
|
84
|
+
const content = `---\nname: ${name}\ndescription: ${name} description\n---\n\nbody\n`;
|
|
85
|
+
return {
|
|
86
|
+
owner,
|
|
87
|
+
name,
|
|
88
|
+
version,
|
|
89
|
+
description: `${name} description`,
|
|
90
|
+
files: [
|
|
91
|
+
{
|
|
92
|
+
path: "SKILL.md",
|
|
93
|
+
content,
|
|
94
|
+
// Server-side SHAs are not used by the CLI for drift detection —
|
|
95
|
+
// the CLI re-computes from content on disk. Any non-empty
|
|
96
|
+
// placeholder works.
|
|
97
|
+
sha256: "x",
|
|
98
|
+
size: content.length,
|
|
99
|
+
contentType: "text/markdown",
|
|
100
|
+
},
|
|
101
|
+
],
|
|
102
|
+
// Default to "now" so the mock server's `since` filter (mirrors
|
|
103
|
+
// production `gt(skills.updatedAt, since)`) treats fresh fixtures
|
|
104
|
+
// as in-window. Tests that need to simulate "skill unchanged
|
|
105
|
+
// since last sync" pass an older string explicitly. Pre-PR #1575
|
|
106
|
+
// mock-server-tightening hardcoded "2025-01-01" which made every
|
|
107
|
+
// skill look stale once `since` was applied — that masked the
|
|
108
|
+
// real production behavior.
|
|
109
|
+
updatedAt: updatedAt ?? new Date().toISOString(),
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async function setup() {
|
|
114
|
+
sandbox = mkdtempSync(join(tmpdir(), "cli-int-update-list-"));
|
|
115
|
+
mkdirSync(join(sandbox, "project"), { recursive: true });
|
|
116
|
+
mkdirSync(join(sandbox, "home"), { recursive: true });
|
|
117
|
+
originalCwd = process.cwd();
|
|
118
|
+
originalHomeEnv = captureHome();
|
|
119
|
+
process.chdir(join(sandbox, "project"));
|
|
120
|
+
setSandboxHome(join(sandbox, "home"));
|
|
121
|
+
|
|
122
|
+
// Strip any inherited credentials/detection that would otherwise
|
|
123
|
+
// pollute the test's controlled environment.
|
|
124
|
+
delete process.env.SKILLREPO_ACCESS_KEY;
|
|
125
|
+
delete process.env.SKILLREPO_URL;
|
|
126
|
+
delete process.env.CLAUDECODE;
|
|
127
|
+
delete process.env.CURSOR_AGENT;
|
|
128
|
+
delete process.env.CURSOR_CLI;
|
|
129
|
+
|
|
130
|
+
server = createMockServer({});
|
|
131
|
+
const port = await server.start();
|
|
132
|
+
serverUrl = `http://127.0.0.1:${port}`;
|
|
133
|
+
|
|
134
|
+
stdout = createCaptureStream();
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
async function teardown() {
|
|
138
|
+
if (server) await server.stop();
|
|
139
|
+
process.chdir(originalCwd);
|
|
140
|
+
restoreHome(originalHomeEnv);
|
|
141
|
+
delete process.env.CLAUDECODE;
|
|
142
|
+
delete process.env.CURSOR_AGENT;
|
|
143
|
+
delete process.env.CURSOR_CLI;
|
|
144
|
+
if (sandbox) rmSync(sandbox, { recursive: true, force: true });
|
|
145
|
+
server = null;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// ───────────────────────────────────────────────────────────────────────
|
|
149
|
+
// The 4.5.1 regression suite
|
|
150
|
+
// ───────────────────────────────────────────────────────────────────────
|
|
151
|
+
//
|
|
152
|
+
// The bug: `effectiveVendors` defaulted to `["claudeCode"]` when no
|
|
153
|
+
// `--agent` was passed, so `skillrepo update` (with no flags) wrote
|
|
154
|
+
// ONLY to claudeCode's placement. Meanwhile, `skillrepo list` walked
|
|
155
|
+
// EVERY detected vendor's placement. Result for multi-agent users:
|
|
156
|
+
// every skill rolled up as MISSING for every vendor that wasn't
|
|
157
|
+
// Claude Code.
|
|
158
|
+
//
|
|
159
|
+
// The fix: `effectiveVendors` now defaults to every detected vendor
|
|
160
|
+
// (DI-injectable for testability). These tests prove the new contract
|
|
161
|
+
// end-to-end.
|
|
162
|
+
|
|
163
|
+
describe("update → list cross-command contract (#1574)", () => {
|
|
164
|
+
beforeEach(setup);
|
|
165
|
+
afterEach(teardown);
|
|
166
|
+
|
|
167
|
+
it("multi-vendor: update with no --agent writes to every detected vendor (no MISS in list)", async () => {
|
|
168
|
+
// ── Forcing detection of TWO vendors ─────────────────────────────
|
|
169
|
+
// This is the exact production scenario behind 4.5.1: a user with
|
|
170
|
+
// both Claude Code AND Cursor installed runs `skillrepo update`
|
|
171
|
+
// with no `--agent`. Pre-fix, only claudeCode got the files. We
|
|
172
|
+
// assert both placements receive the skill AND that `list` agrees.
|
|
173
|
+
process.env.CLAUDECODE = "1";
|
|
174
|
+
process.env.CURSOR_AGENT = "1";
|
|
175
|
+
|
|
176
|
+
server.setLibraryResponse({
|
|
177
|
+
skills: [makeSkill("alice", "shared-skill", "1.0.0")],
|
|
178
|
+
removals: [],
|
|
179
|
+
syncedAt: "2026-01-01T00:00:00Z",
|
|
180
|
+
});
|
|
181
|
+
server.setEtag('"v1"');
|
|
182
|
+
|
|
183
|
+
// ── Step 1: update with no --agent ───────────────────────────────
|
|
184
|
+
await runUpdate(["--key", VALID_KEY, "--url", serverUrl], { stdout });
|
|
185
|
+
|
|
186
|
+
// Both placements must exist on disk — the contract claim.
|
|
187
|
+
const claudeDir = resolvePlacementDir("claudeProject", "shared-skill");
|
|
188
|
+
const agentsDir = resolvePlacementDir("agentsProject", "shared-skill");
|
|
189
|
+
assert.ok(existsSync(claudeDir), "claudeProject placement must be written");
|
|
190
|
+
assert.ok(existsSync(agentsDir), "agentsProject (cursor) placement must be written");
|
|
191
|
+
|
|
192
|
+
// Same bytes in both placements — sync writes identical content,
|
|
193
|
+
// not a stub. A future regression that wrote to multiple targets
|
|
194
|
+
// but produced divergent content would break list's drift check
|
|
195
|
+
// and surface here.
|
|
196
|
+
const claudeBody = readFileSync(join(claudeDir, "SKILL.md"), "utf-8");
|
|
197
|
+
const agentsBody = readFileSync(join(agentsDir, "SKILL.md"), "utf-8");
|
|
198
|
+
assert.equal(claudeBody, agentsBody, "writes to multiple targets must produce identical content");
|
|
199
|
+
|
|
200
|
+
// ── Step 2: list reads the same placements ───────────────────────
|
|
201
|
+
// Use --json so we can assert the per-placement state directly.
|
|
202
|
+
stdout = createCaptureStream();
|
|
203
|
+
await runList(
|
|
204
|
+
["--key", VALID_KEY, "--url", serverUrl, "--json"],
|
|
205
|
+
{ stdout },
|
|
206
|
+
);
|
|
207
|
+
const [item] = JSON.parse(stdout.text());
|
|
208
|
+
assert.equal(item.name, "shared-skill");
|
|
209
|
+
// The rollup: both placements are current → rollup is current.
|
|
210
|
+
// Pre-fix this was "missing" because cursor's placement was empty.
|
|
211
|
+
assert.equal(item.state, "current", "rollup must be `current` when every detected vendor has the skill");
|
|
212
|
+
// Lock in the EXACT vendor count. Without this assertion a
|
|
213
|
+
// regression that over-detects (e.g., a future change that
|
|
214
|
+
// defaults to ALL seven registry entries instead of detected-only)
|
|
215
|
+
// would still pass the per-vendor `state === "current"` checks
|
|
216
|
+
// below because both claudeCode and cursor would land in the
|
|
217
|
+
// list. The `.length === 2` check catches the over-detection
|
|
218
|
+
// direction; the test above already catches under-detection.
|
|
219
|
+
assert.equal(
|
|
220
|
+
item.placements.length,
|
|
221
|
+
2,
|
|
222
|
+
"list must walk exactly the two detected vendors, not every registered one",
|
|
223
|
+
);
|
|
224
|
+
// Each vendor's placement entry reports current independently.
|
|
225
|
+
const byVendor = Object.fromEntries(item.placements.map((p) => [p.vendor, p]));
|
|
226
|
+
assert.ok(byVendor.claudeCode, "claudeCode placement must appear");
|
|
227
|
+
assert.ok(byVendor.cursor, "cursor placement must appear");
|
|
228
|
+
assert.equal(byVendor.claudeCode.state, "current");
|
|
229
|
+
assert.equal(byVendor.cursor.state, "current");
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
it("single-vendor: update with only cursor detected → list shows current for cursor only", async () => {
|
|
233
|
+
// The narrow contract: only the one detected vendor gets the
|
|
234
|
+
// placement, AND `list` afterwards reports `current` for that
|
|
235
|
+
// vendor — no phantom MISS for claudeCode.
|
|
236
|
+
//
|
|
237
|
+
// This test used to opt out of the `list` assertion because of a
|
|
238
|
+
// companion bug: writing `~/.claude/skillrepo/.last-sync`
|
|
239
|
+
// created `~/.claude/`, which then matched claudeCode's home
|
|
240
|
+
// detection signal. PR #1574's production-readiness audit
|
|
241
|
+
// narrowed that signal to `~/.claude/settings.json` (a file
|
|
242
|
+
// Claude Code itself creates, that SkillRepo never writes) so
|
|
243
|
+
// the false positive no longer fires. This test is now the
|
|
244
|
+
// regression guard: a Cursor-only user must see `list` reporting
|
|
245
|
+
// their skills as `current`, NOT all-MISS for a vendor they
|
|
246
|
+
// don't use.
|
|
247
|
+
process.env.CURSOR_AGENT = "1";
|
|
248
|
+
|
|
249
|
+
server.setLibraryResponse({
|
|
250
|
+
skills: [makeSkill("alice", "cursor-only", "1.0.0")],
|
|
251
|
+
removals: [],
|
|
252
|
+
syncedAt: "2026-01-01T00:00:00Z",
|
|
253
|
+
});
|
|
254
|
+
server.setEtag('"v1"');
|
|
255
|
+
|
|
256
|
+
await runUpdate(["--key", VALID_KEY, "--url", serverUrl], { stdout });
|
|
257
|
+
|
|
258
|
+
// Only cursor's placement exists.
|
|
259
|
+
assert.ok(
|
|
260
|
+
existsSync(resolvePlacementDir("agentsProject", "cursor-only")),
|
|
261
|
+
"agentsProject placement should exist for cursor",
|
|
262
|
+
);
|
|
263
|
+
assert.equal(
|
|
264
|
+
existsSync(resolvePlacementDir("claudeProject", "cursor-only")),
|
|
265
|
+
false,
|
|
266
|
+
"claudeProject placement should NOT exist when claudeCode is not detected at update-time",
|
|
267
|
+
);
|
|
268
|
+
|
|
269
|
+
// `list` now correctly sees ONE detected vendor and reports
|
|
270
|
+
// `current`. The false-positive claudeCode detection that
|
|
271
|
+
// shipped in 4.5.0/4.5.1 produced all-MISS output here.
|
|
272
|
+
stdout = createCaptureStream();
|
|
273
|
+
await runList(["--key", VALID_KEY, "--url", serverUrl, "--json"], { stdout });
|
|
274
|
+
const [item] = JSON.parse(stdout.text());
|
|
275
|
+
assert.equal(item.state, "current");
|
|
276
|
+
assert.equal(
|
|
277
|
+
item.placements.length,
|
|
278
|
+
1,
|
|
279
|
+
"Cursor-only user must not see a phantom claudeCode placement entry",
|
|
280
|
+
);
|
|
281
|
+
assert.equal(item.placements[0].vendor, "cursor");
|
|
282
|
+
assert.equal(item.placements[0].state, "current");
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
it("no-detection fallback: update with no --agent uses the historical claudeCode default", async () => {
|
|
286
|
+
// The other edge of the fix: if NOTHING is detected (no env vars,
|
|
287
|
+
// no `.claude/` or `.agents/` dirs anywhere), the CLI falls back
|
|
288
|
+
// to writing claudeCode-only — preserving the pre-4.5.1 behavior
|
|
289
|
+
// for first-run users. Without this fallback, a brand-new
|
|
290
|
+
// checkout in a clean shell would silently no-op `update` because
|
|
291
|
+
// detection returned an empty list.
|
|
292
|
+
//
|
|
293
|
+
// We don't set ANY env vars. The sandbox HOME is brand-new
|
|
294
|
+
// (no .claude / no .cursor / no .agents).
|
|
295
|
+
|
|
296
|
+
server.setLibraryResponse({
|
|
297
|
+
skills: [makeSkill("alice", "fallback", "1.0.0")],
|
|
298
|
+
removals: [],
|
|
299
|
+
syncedAt: "2026-01-01T00:00:00Z",
|
|
300
|
+
});
|
|
301
|
+
server.setEtag('"v1"');
|
|
302
|
+
|
|
303
|
+
await runUpdate(["--key", VALID_KEY, "--url", serverUrl], { stdout });
|
|
304
|
+
|
|
305
|
+
// claudeProject is the documented fallback target.
|
|
306
|
+
assert.ok(
|
|
307
|
+
existsSync(resolvePlacementDir("claudeProject", "fallback")),
|
|
308
|
+
"fallback to claudeProject must hold when nothing is detected",
|
|
309
|
+
);
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
it("explicit --agent narrows the write set even when more vendors are detected", async () => {
|
|
313
|
+
// The explicit-flag escape hatch: even if both vendors are
|
|
314
|
+
// detected, `--agent claude` (or `--agent cursor`) writes only
|
|
315
|
+
// to the named vendor. `effectiveVendors` returns the explicit
|
|
316
|
+
// list verbatim — detection is ONLY consulted when no --agent
|
|
317
|
+
// is passed.
|
|
318
|
+
process.env.CLAUDECODE = "1";
|
|
319
|
+
process.env.CURSOR_AGENT = "1";
|
|
320
|
+
|
|
321
|
+
server.setLibraryResponse({
|
|
322
|
+
skills: [makeSkill("alice", "narrowed", "1.0.0")],
|
|
323
|
+
removals: [],
|
|
324
|
+
syncedAt: "2026-01-01T00:00:00Z",
|
|
325
|
+
});
|
|
326
|
+
server.setEtag('"v1"');
|
|
327
|
+
|
|
328
|
+
await runUpdate(
|
|
329
|
+
["--key", VALID_KEY, "--url", serverUrl, "--agent", "claude"],
|
|
330
|
+
{ stdout },
|
|
331
|
+
);
|
|
332
|
+
|
|
333
|
+
assert.ok(existsSync(resolvePlacementDir("claudeProject", "narrowed")));
|
|
334
|
+
// cursor's placement must NOT be written, even though cursor
|
|
335
|
+
// is detected. Explicit > implicit.
|
|
336
|
+
assert.equal(
|
|
337
|
+
existsSync(resolvePlacementDir("agentsProject", "narrowed")),
|
|
338
|
+
false,
|
|
339
|
+
"--agent claude must NOT incidentally write to cursor's placement",
|
|
340
|
+
);
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
it("explicit --agent ALSO has the listed vendor appear in list when detected", async () => {
|
|
344
|
+
// Defense-in-depth check: a user can run `update --agent claude`
|
|
345
|
+
// on a multi-vendor machine and `list` will still walk EVERY
|
|
346
|
+
// detected vendor (including cursor) and surface cursor's
|
|
347
|
+
// placement as MISS — that's the correct user-visible signal that
|
|
348
|
+
// their explicit-narrow update left a vendor un-updated.
|
|
349
|
+
process.env.CLAUDECODE = "1";
|
|
350
|
+
process.env.CURSOR_AGENT = "1";
|
|
351
|
+
|
|
352
|
+
server.setLibraryResponse({
|
|
353
|
+
skills: [makeSkill("alice", "explicit", "1.0.0")],
|
|
354
|
+
removals: [],
|
|
355
|
+
syncedAt: "2026-01-01T00:00:00Z",
|
|
356
|
+
});
|
|
357
|
+
server.setEtag('"v1"');
|
|
358
|
+
|
|
359
|
+
await runUpdate(
|
|
360
|
+
["--key", VALID_KEY, "--url", serverUrl, "--agent", "claude"],
|
|
361
|
+
{ stdout },
|
|
362
|
+
);
|
|
363
|
+
|
|
364
|
+
stdout = createCaptureStream();
|
|
365
|
+
await runList(["--key", VALID_KEY, "--url", serverUrl, "--json"], { stdout });
|
|
366
|
+
const [item] = JSON.parse(stdout.text());
|
|
367
|
+
// Cursor's placement is empty → its per-vendor state is missing
|
|
368
|
+
// → rollup is missing (worst state wins). User sees a clear
|
|
369
|
+
// signal that one of their vendors didn't get the update.
|
|
370
|
+
assert.equal(item.state, "missing");
|
|
371
|
+
const cursor = item.placements.find((p) => p.vendor === "cursor");
|
|
372
|
+
assert.ok(cursor, "list must still include cursor's placement entry");
|
|
373
|
+
assert.equal(cursor.state, "missing");
|
|
374
|
+
});
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
// ───────────────────────────────────────────────────────────────────────
|
|
378
|
+
// .last-sync v1 → v2 migration round-trip
|
|
379
|
+
// ───────────────────────────────────────────────────────────────────────
|
|
380
|
+
//
|
|
381
|
+
// A user upgrading from 4.4.x to 4.5.x has a v1 `.last-sync` on disk.
|
|
382
|
+
// The new CLI must:
|
|
383
|
+
// 1. Read it without crashing.
|
|
384
|
+
// 2. Preserve the etag + syncedAt so the next sync still benefits
|
|
385
|
+
// from a 304 short-circuit.
|
|
386
|
+
// 3. After the next successful sync, write a v2 file with the
|
|
387
|
+
// per-skill SHA map populated.
|
|
388
|
+
//
|
|
389
|
+
// Sync.test.mjs has unit-level coverage. This integration test
|
|
390
|
+
// exercises the path through runSync against the mock server, which
|
|
391
|
+
// is the only way to verify the v2 file that lands on disk has the
|
|
392
|
+
// expected shape AND the ETag carry-forward took effect.
|
|
393
|
+
|
|
394
|
+
describe("v1 → v2 .last-sync migration round-trip", () => {
|
|
395
|
+
beforeEach(setup);
|
|
396
|
+
afterEach(teardown);
|
|
397
|
+
|
|
398
|
+
it("reads v1 .last-sync, performs sync, writes v2 with SHA map populated", async () => {
|
|
399
|
+
// Seed a v1 state file at the documented path. Both fields are
|
|
400
|
+
// strings — that's the v1 shape `readLastSync` migrates from.
|
|
401
|
+
const v1Path = globalLastSyncPath();
|
|
402
|
+
mkdirSync(join(v1Path, ".."), { recursive: true });
|
|
403
|
+
writeFileSync(
|
|
404
|
+
v1Path,
|
|
405
|
+
JSON.stringify({
|
|
406
|
+
schemaVersion: 1,
|
|
407
|
+
etag: '"v1-etag"',
|
|
408
|
+
syncedAt: "2025-12-01T00:00:00Z",
|
|
409
|
+
}),
|
|
410
|
+
);
|
|
411
|
+
|
|
412
|
+
process.env.CLAUDECODE = "1";
|
|
413
|
+
server.setEtag('"v2-etag"');
|
|
414
|
+
server.setLibraryResponse({
|
|
415
|
+
skills: [makeSkill("alice", "migrated", "1.0.0")],
|
|
416
|
+
removals: [],
|
|
417
|
+
syncedAt: "2026-05-19T00:00:00Z",
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
// ── Run update — this triggers the v1→v2 in-memory migration
|
|
421
|
+
// in readLastSync and the v2 write at the end of runSync ──
|
|
422
|
+
await runUpdate(["--key", VALID_KEY, "--url", serverUrl], { stdout });
|
|
423
|
+
|
|
424
|
+
// The on-disk file must now be v2 and carry the per-skill SHA
|
|
425
|
+
// map. The pre-existing v1 etag/syncedAt are NOT preserved
|
|
426
|
+
// because the sync actually completed and got a fresh ETag from
|
|
427
|
+
// the server. Carry-forward applies to the per-skill SHA map
|
|
428
|
+
// (which was empty in v1), not to the library ETag itself.
|
|
429
|
+
const afterRaw = readFileSync(v1Path, "utf-8");
|
|
430
|
+
const after = JSON.parse(afterRaw);
|
|
431
|
+
assert.equal(after.schemaVersion, 2, "must persist as v2");
|
|
432
|
+
assert.equal(after.etag, '"v2-etag"', "etag must reflect server's response");
|
|
433
|
+
assert.ok(
|
|
434
|
+
after.skills && typeof after.skills === "object",
|
|
435
|
+
"skills map must be populated",
|
|
436
|
+
);
|
|
437
|
+
assert.ok(after.skills["alice/migrated"], "synced skill must appear in the SHA map");
|
|
438
|
+
const entry = after.skills["alice/migrated"];
|
|
439
|
+
assert.equal(entry.version, "1.0.0");
|
|
440
|
+
assert.match(entry.skillMdSha256, /^[a-f0-9]{64}$/, "skillMdSha256 must be a hex SHA-256");
|
|
441
|
+
assert.match(entry.filesSha256, /^[a-f0-9]{64}$/, "filesSha256 must be a hex SHA-256");
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
it("v1 state with NO content changes → first sync after upgrade does a full re-fetch (recovery)", async () => {
|
|
445
|
+
// The recovery path for users upgrading from v1 `.last-sync`
|
|
446
|
+
// (4.4.x and earlier) or any state where the per-skill SHA map
|
|
447
|
+
// is empty. `placementsAreComplete` returns false when the
|
|
448
|
+
// baseline map is empty — that's the "we have an etag but no
|
|
449
|
+
// per-skill cache, so we can't trust the 304" signal. runSync
|
|
450
|
+
// drops `If-None-Match`, fetches the full library, and writes
|
|
451
|
+
// both the skill bytes and the per-skill SHA cache. The "library
|
|
452
|
+
// synced" message replaces "up to date" exactly once per
|
|
453
|
+
// upgrade; subsequent syncs (now with a populated skills map
|
|
454
|
+
// AND placement directories on disk) return to the 304 fast
|
|
455
|
+
// path.
|
|
456
|
+
const v1Path = globalLastSyncPath();
|
|
457
|
+
mkdirSync(join(v1Path, ".."), { recursive: true });
|
|
458
|
+
writeFileSync(
|
|
459
|
+
v1Path,
|
|
460
|
+
JSON.stringify({
|
|
461
|
+
schemaVersion: 1,
|
|
462
|
+
etag: '"unchanged"',
|
|
463
|
+
syncedAt: "2025-12-01T00:00:00Z",
|
|
464
|
+
}),
|
|
465
|
+
);
|
|
466
|
+
|
|
467
|
+
process.env.CLAUDECODE = "1";
|
|
468
|
+
server.setEtag('"unchanged"');
|
|
469
|
+
server.setLibraryResponse({
|
|
470
|
+
skills: [makeSkill("alice", "unmigrated", "1.0.0")],
|
|
471
|
+
removals: [],
|
|
472
|
+
syncedAt: "2026-05-19T00:00:00Z",
|
|
473
|
+
});
|
|
474
|
+
|
|
475
|
+
// First sync after upgrade: empty skills map → placementsAreComplete
|
|
476
|
+
// returns false → full re-fetch. User sees the "synced" message,
|
|
477
|
+
// not "up to date" — recovery is visible.
|
|
478
|
+
server.resetLibraryInspection();
|
|
479
|
+
await runUpdate(["--key", VALID_KEY, "--url", serverUrl], { stdout });
|
|
480
|
+
const firstOut = stdout.text();
|
|
481
|
+
// Negative assertion: the misleading "up to date" message must
|
|
482
|
+
// not appear during the recovery sync.
|
|
483
|
+
assert.ok(
|
|
484
|
+
!/up to date/.test(firstOut),
|
|
485
|
+
"first sync after upgrade must NOT 304 — empty skills map forces full re-fetch",
|
|
486
|
+
);
|
|
487
|
+
// Positive assertion: the recovery sync must report at least
|
|
488
|
+
// one write. Without this, a future regression where the command
|
|
489
|
+
// crashed before printing anything would also pass the negative
|
|
490
|
+
// assertion above. See QA Gap 4.
|
|
491
|
+
assert.match(
|
|
492
|
+
firstOut,
|
|
493
|
+
/Library sync complete|added|updated/,
|
|
494
|
+
"first sync after upgrade must positively report a sync, not silently no-op",
|
|
495
|
+
);
|
|
496
|
+
// Wire-level: ETag was dropped — placementsAreComplete returned
|
|
497
|
+
// false for the empty baseline. This is the load-bearing
|
|
498
|
+
// mechanism, not just a side-effect of output.
|
|
499
|
+
assert.equal(
|
|
500
|
+
server.getLastLibraryIfNoneMatch(),
|
|
501
|
+
null,
|
|
502
|
+
"v1 migration must drop If-None-Match (placementsAreComplete returns false for empty map)",
|
|
503
|
+
);
|
|
504
|
+
|
|
505
|
+
// After the recovery sync, the on-disk file is v2 with the
|
|
506
|
+
// skills map populated. The skill is on disk in the claudeProject
|
|
507
|
+
// placement — the user-visible recovery.
|
|
508
|
+
const after = readLastSync();
|
|
509
|
+
assert.equal(after.schemaVersion, 2);
|
|
510
|
+
assert.ok(after.skills["alice/unmigrated"]);
|
|
511
|
+
assert.ok(existsSync(resolvePlacementDir("claudeProject", "unmigrated")));
|
|
512
|
+
|
|
513
|
+
// Second sync with the SAME vendor set: now the ETag short-circuit
|
|
514
|
+
// is safe and 304 fires (this proves we didn't accidentally make
|
|
515
|
+
// every sync do a full re-fetch — the fix is targeted).
|
|
516
|
+
stdout = createCaptureStream();
|
|
517
|
+
await runUpdate(["--key", VALID_KEY, "--url", serverUrl], { stdout });
|
|
518
|
+
assert.match(
|
|
519
|
+
stdout.text(),
|
|
520
|
+
/up to date/,
|
|
521
|
+
"second sync (vendor set now in .last-sync) should 304 normally",
|
|
522
|
+
);
|
|
523
|
+
});
|
|
524
|
+
});
|
|
525
|
+
|
|
526
|
+
// ───────────────────────────────────────────────────────────────────────
|
|
527
|
+
// End-to-end drift state through the real update path
|
|
528
|
+
// ───────────────────────────────────────────────────────────────────────
|
|
529
|
+
//
|
|
530
|
+
// list.test.mjs synthesizes `.last-sync` and disk state by hand to
|
|
531
|
+
// exercise each drift state. Those tests prove computeSkillState is
|
|
532
|
+
// correct given a state. They do NOT prove that the state runSync
|
|
533
|
+
// actually persists, when fed back into list, yields the expected
|
|
534
|
+
// classification.
|
|
535
|
+
//
|
|
536
|
+
// These tests run a real update first, then mutate something, then
|
|
537
|
+
// run list. They lock in the round-trip from update → on-disk state
|
|
538
|
+
// → list → drift verdict — which is the only layer where a mismatch
|
|
539
|
+
// between sync's persisted shape and list's expected shape would
|
|
540
|
+
// surface as a user-visible bug.
|
|
541
|
+
|
|
542
|
+
describe("update → mutate → list: drift verdicts on real persisted state", () => {
|
|
543
|
+
beforeEach(setup);
|
|
544
|
+
afterEach(teardown);
|
|
545
|
+
|
|
546
|
+
it("OK after update: a freshly synced skill is `current` in list with no mutation", async () => {
|
|
547
|
+
process.env.CLAUDECODE = "1";
|
|
548
|
+
server.setEtag('"v1"');
|
|
549
|
+
server.setLibraryResponse({
|
|
550
|
+
skills: [makeSkill("alice", "fresh", "1.0.0")],
|
|
551
|
+
removals: [],
|
|
552
|
+
syncedAt: "2026-05-19T00:00:00Z",
|
|
553
|
+
});
|
|
554
|
+
await runUpdate(["--key", VALID_KEY, "--url", serverUrl], { stdout });
|
|
555
|
+
|
|
556
|
+
stdout = createCaptureStream();
|
|
557
|
+
await runList(["--key", VALID_KEY, "--url", serverUrl, "--json"], { stdout });
|
|
558
|
+
const [item] = JSON.parse(stdout.text());
|
|
559
|
+
assert.equal(item.state, "current");
|
|
560
|
+
});
|
|
561
|
+
|
|
562
|
+
it("EDIT after update: hand-edit a SKILL.md and list shows `edited`", async () => {
|
|
563
|
+
process.env.CLAUDECODE = "1";
|
|
564
|
+
server.setEtag('"v1"');
|
|
565
|
+
server.setLibraryResponse({
|
|
566
|
+
skills: [makeSkill("alice", "tampered", "1.0.0")],
|
|
567
|
+
removals: [],
|
|
568
|
+
syncedAt: "2026-05-19T00:00:00Z",
|
|
569
|
+
});
|
|
570
|
+
await runUpdate(["--key", VALID_KEY, "--url", serverUrl], { stdout });
|
|
571
|
+
|
|
572
|
+
// Append a line to SKILL.md — the persisted SHA in .last-sync
|
|
573
|
+
// no longer matches the on-disk SHA. drift.mjs's contract is
|
|
574
|
+
// "version match + SHA mismatch → edited."
|
|
575
|
+
const skillPath = join(
|
|
576
|
+
resolvePlacementDir("claudeProject", "tampered"),
|
|
577
|
+
"SKILL.md",
|
|
578
|
+
);
|
|
579
|
+
const original = readFileSync(skillPath, "utf-8");
|
|
580
|
+
writeFileSync(skillPath, original + "\n<!-- user edit -->\n");
|
|
581
|
+
|
|
582
|
+
stdout = createCaptureStream();
|
|
583
|
+
await runList(["--key", VALID_KEY, "--url", serverUrl, "--json"], { stdout });
|
|
584
|
+
const [item] = JSON.parse(stdout.text());
|
|
585
|
+
assert.equal(item.state, "edited", "post-update hand-edit must surface as `edited`");
|
|
586
|
+
});
|
|
587
|
+
|
|
588
|
+
it("STALE after update: server bumps version and list shows `stale`", async () => {
|
|
589
|
+
process.env.CLAUDECODE = "1";
|
|
590
|
+
server.setEtag('"v1"');
|
|
591
|
+
server.setLibraryResponse({
|
|
592
|
+
skills: [makeSkill("alice", "aging", "1.0.0")],
|
|
593
|
+
removals: [],
|
|
594
|
+
syncedAt: "2026-05-19T00:00:00Z",
|
|
595
|
+
});
|
|
596
|
+
await runUpdate(["--key", VALID_KEY, "--url", serverUrl], { stdout });
|
|
597
|
+
|
|
598
|
+
// Server's library moves forward: same skill, new version. No
|
|
599
|
+
// second `update` happens — `.last-sync` still shows 1.0.0 as
|
|
600
|
+
// synced.
|
|
601
|
+
server.setEtag('"v2"');
|
|
602
|
+
server.setLibraryResponse({
|
|
603
|
+
skills: [makeSkill("alice", "aging", "1.1.0")],
|
|
604
|
+
removals: [],
|
|
605
|
+
syncedAt: "2026-05-19T01:00:00Z",
|
|
606
|
+
});
|
|
607
|
+
|
|
608
|
+
stdout = createCaptureStream();
|
|
609
|
+
await runList(["--key", VALID_KEY, "--url", serverUrl, "--json"], { stdout });
|
|
610
|
+
const [item] = JSON.parse(stdout.text());
|
|
611
|
+
assert.equal(item.state, "stale", "library moved ahead → `stale`");
|
|
612
|
+
});
|
|
613
|
+
|
|
614
|
+
it("MISSING after update + manual delete: removing a placement makes list show `missing`", async () => {
|
|
615
|
+
process.env.CLAUDECODE = "1";
|
|
616
|
+
server.setEtag('"v1"');
|
|
617
|
+
server.setLibraryResponse({
|
|
618
|
+
skills: [makeSkill("alice", "deletable", "1.0.0")],
|
|
619
|
+
removals: [],
|
|
620
|
+
syncedAt: "2026-05-19T00:00:00Z",
|
|
621
|
+
});
|
|
622
|
+
await runUpdate(["--key", VALID_KEY, "--url", serverUrl], { stdout });
|
|
623
|
+
|
|
624
|
+
// Delete the placement directory the user just synced. .last-sync
|
|
625
|
+
// still records this skill as present (the SHA map remembers it)
|
|
626
|
+
// but the disk no longer has it — drift.mjs's contract is
|
|
627
|
+
// "baseline exists + on-disk gone → missing."
|
|
628
|
+
rmSync(resolvePlacementDir("claudeProject", "deletable"), {
|
|
629
|
+
recursive: true,
|
|
630
|
+
force: true,
|
|
631
|
+
});
|
|
632
|
+
|
|
633
|
+
stdout = createCaptureStream();
|
|
634
|
+
await runList(["--key", VALID_KEY, "--url", serverUrl, "--json"], { stdout });
|
|
635
|
+
const [item] = JSON.parse(stdout.text());
|
|
636
|
+
assert.equal(item.state, "missing");
|
|
637
|
+
});
|
|
638
|
+
|
|
639
|
+
it("multi-vendor mixed drift: claudeCode edited + cursor current rolls up to `edited` (worst-state-wins)", async () => {
|
|
640
|
+
// The rollup contract is "worst state wins" — defined in
|
|
641
|
+
// drift.mjs's `rollupState`. End-to-end coverage proves the
|
|
642
|
+
// contract holds when both placements go through the real
|
|
643
|
+
// update + list path, not just the synthesized state path.
|
|
644
|
+
process.env.CLAUDECODE = "1";
|
|
645
|
+
process.env.CURSOR_AGENT = "1";
|
|
646
|
+
|
|
647
|
+
server.setEtag('"v1"');
|
|
648
|
+
server.setLibraryResponse({
|
|
649
|
+
skills: [makeSkill("alice", "split-drift", "1.0.0")],
|
|
650
|
+
removals: [],
|
|
651
|
+
syncedAt: "2026-05-19T00:00:00Z",
|
|
652
|
+
});
|
|
653
|
+
await runUpdate(["--key", VALID_KEY, "--url", serverUrl], { stdout });
|
|
654
|
+
|
|
655
|
+
// Edit ONLY the claudeCode placement.
|
|
656
|
+
const claudeFile = join(
|
|
657
|
+
resolvePlacementDir("claudeProject", "split-drift"),
|
|
658
|
+
"SKILL.md",
|
|
659
|
+
);
|
|
660
|
+
writeFileSync(claudeFile, readFileSync(claudeFile, "utf-8") + "// drift\n");
|
|
661
|
+
|
|
662
|
+
stdout = createCaptureStream();
|
|
663
|
+
await runList(["--key", VALID_KEY, "--url", serverUrl, "--json"], { stdout });
|
|
664
|
+
const [item] = JSON.parse(stdout.text());
|
|
665
|
+
|
|
666
|
+
// Rollup: edited vs current → edited (worst wins). The user-
|
|
667
|
+
// visible signal is "something needs attention" even though one
|
|
668
|
+
// vendor is fine.
|
|
669
|
+
assert.equal(item.state, "edited");
|
|
670
|
+
const byVendor = Object.fromEntries(item.placements.map((p) => [p.vendor, p]));
|
|
671
|
+
assert.equal(byVendor.claudeCode.state, "edited");
|
|
672
|
+
assert.equal(byVendor.cursor.state, "current");
|
|
673
|
+
});
|
|
674
|
+
});
|
|
675
|
+
|
|
676
|
+
// ───────────────────────────────────────────────────────────────────────
|
|
677
|
+
// `get` / `add` / `remove` write-back to `.last-sync`
|
|
678
|
+
// ───────────────────────────────────────────────────────────────────────
|
|
679
|
+
//
|
|
680
|
+
// The 4.5.1 effective-vendors fix was a write-side contract gap. PR
|
|
681
|
+
// #1574's production-readiness audit surfaced THREE more of the same
|
|
682
|
+
// shape: `get`, `add`, and `remove` write to disk via `writeSkillDir`
|
|
683
|
+
// (or `removeSkillDir`) but never updated `.last-sync` — so the very
|
|
684
|
+
// next `list` reported every freshly-fetched / freshly-added skill as
|
|
685
|
+
// `MISSING` even though it was sitting right there on disk. These
|
|
686
|
+
// tests lock in the closed contract.
|
|
687
|
+
|
|
688
|
+
describe("get → list cross-command contract", () => {
|
|
689
|
+
beforeEach(setup);
|
|
690
|
+
afterEach(teardown);
|
|
691
|
+
|
|
692
|
+
it("after `get`, list reports the skill as current (not MISSING)", async () => {
|
|
693
|
+
process.env.CLAUDECODE = "1";
|
|
694
|
+
|
|
695
|
+
// Prime `.last-sync` with one prior-synced skill so we can also
|
|
696
|
+
// verify that `get` doesn't clobber existing entries.
|
|
697
|
+
server.setEtag('"baseline"');
|
|
698
|
+
server.setLibraryResponse({
|
|
699
|
+
skills: [makeSkill("alice", "prior-sync", "1.0.0")],
|
|
700
|
+
removals: [],
|
|
701
|
+
syncedAt: "2026-01-01T00:00:00Z",
|
|
702
|
+
});
|
|
703
|
+
await runUpdate(["--key", VALID_KEY, "--url", serverUrl], { stdout });
|
|
704
|
+
|
|
705
|
+
// Now `get` a different skill that's NOT in the library
|
|
706
|
+
// response yet. The mock server serves it from setSkillResponse.
|
|
707
|
+
server.setSkillResponse(
|
|
708
|
+
"bob",
|
|
709
|
+
"fetched",
|
|
710
|
+
makeSkill("bob", "fetched", "1.0.0"),
|
|
711
|
+
);
|
|
712
|
+
stdout = createCaptureStream();
|
|
713
|
+
await runGet(
|
|
714
|
+
["@bob/fetched", "--key", VALID_KEY, "--url", serverUrl],
|
|
715
|
+
{ stdout },
|
|
716
|
+
);
|
|
717
|
+
|
|
718
|
+
// Server's library now reports both skills (the maintainer
|
|
719
|
+
// published `bob/fetched` to the catalog). list must see both
|
|
720
|
+
// as `current` — the freshly-fetched one IS on disk and IS in
|
|
721
|
+
// the baseline.
|
|
722
|
+
server.setEtag('"baseline+1"');
|
|
723
|
+
server.setLibraryResponse({
|
|
724
|
+
skills: [
|
|
725
|
+
makeSkill("alice", "prior-sync", "1.0.0"),
|
|
726
|
+
makeSkill("bob", "fetched", "1.0.0"),
|
|
727
|
+
],
|
|
728
|
+
removals: [],
|
|
729
|
+
syncedAt: "2026-01-02T00:00:00Z",
|
|
730
|
+
});
|
|
731
|
+
|
|
732
|
+
stdout = createCaptureStream();
|
|
733
|
+
await runList(
|
|
734
|
+
["--key", VALID_KEY, "--url", serverUrl, "--json"],
|
|
735
|
+
{ stdout },
|
|
736
|
+
);
|
|
737
|
+
const parsed = JSON.parse(stdout.text());
|
|
738
|
+
const prior = parsed.find((s) => s.name === "prior-sync");
|
|
739
|
+
const fetched = parsed.find((s) => s.name === "fetched");
|
|
740
|
+
assert.equal(prior.state, "current", "prior-synced skill must stay current");
|
|
741
|
+
assert.equal(
|
|
742
|
+
fetched.state,
|
|
743
|
+
"current",
|
|
744
|
+
"skill fetched via `get` must show as current — get must update .last-sync",
|
|
745
|
+
);
|
|
746
|
+
});
|
|
747
|
+
|
|
748
|
+
it("get preserves the library-level etag (next update can still 304)", async () => {
|
|
749
|
+
// Critical invariant: `get` updates the per-skill SHA entry but
|
|
750
|
+
// leaves the library-level `etag` and `syncedAt` alone. If `get`
|
|
751
|
+
// clobbered the etag, the next `update` would do a wasted full
|
|
752
|
+
// sync. This test guards that behavior.
|
|
753
|
+
process.env.CLAUDECODE = "1";
|
|
754
|
+
|
|
755
|
+
server.setEtag('"original-etag"');
|
|
756
|
+
server.setLibraryResponse({
|
|
757
|
+
skills: [makeSkill("alice", "prior", "1.0.0")],
|
|
758
|
+
removals: [],
|
|
759
|
+
syncedAt: "2026-01-01T00:00:00Z",
|
|
760
|
+
});
|
|
761
|
+
await runUpdate(["--key", VALID_KEY, "--url", serverUrl], { stdout });
|
|
762
|
+
|
|
763
|
+
server.setSkillResponse("bob", "extra", makeSkill("bob", "extra", "1.0.0"));
|
|
764
|
+
await runGet(
|
|
765
|
+
["@bob/extra", "--key", VALID_KEY, "--url", serverUrl],
|
|
766
|
+
{ stdout },
|
|
767
|
+
);
|
|
768
|
+
|
|
769
|
+
// Read the persisted state directly — the etag/syncedAt must
|
|
770
|
+
// match what the prior update wrote, not what `get` happened to
|
|
771
|
+
// synthesize.
|
|
772
|
+
const after = readLastSync();
|
|
773
|
+
assert.equal(after.etag, '"original-etag"', "get must not clobber the library etag");
|
|
774
|
+
assert.equal(
|
|
775
|
+
after.syncedAt,
|
|
776
|
+
"2026-01-01T00:00:00Z",
|
|
777
|
+
"get must not clobber the library syncedAt",
|
|
778
|
+
);
|
|
779
|
+
// The new skill entry IS in the map alongside the prior entry.
|
|
780
|
+
assert.ok(after.skills["alice/prior"], "prior entry preserved");
|
|
781
|
+
assert.ok(after.skills["bob/extra"], "newly-fetched entry recorded");
|
|
782
|
+
});
|
|
783
|
+
});
|
|
784
|
+
|
|
785
|
+
describe("add → list cross-command contract", () => {
|
|
786
|
+
beforeEach(setup);
|
|
787
|
+
afterEach(teardown);
|
|
788
|
+
|
|
789
|
+
it("after `add`, list reports the skill as current (not MISSING)", async () => {
|
|
790
|
+
process.env.CLAUDECODE = "1";
|
|
791
|
+
|
|
792
|
+
// `add` does POST /library/refs THEN GET /skills/<owner>/<name>.
|
|
793
|
+
// Both need responses on the mock server.
|
|
794
|
+
server.setAddResponseForAny({
|
|
795
|
+
status: 201,
|
|
796
|
+
body: {
|
|
797
|
+
added: {
|
|
798
|
+
owner: "carol",
|
|
799
|
+
name: "added-skill",
|
|
800
|
+
version: "2.0.0",
|
|
801
|
+
addedAt: "2026-01-01T00:00:00Z",
|
|
802
|
+
},
|
|
803
|
+
},
|
|
804
|
+
});
|
|
805
|
+
server.setSkillResponse(
|
|
806
|
+
"carol",
|
|
807
|
+
"added-skill",
|
|
808
|
+
makeSkill("carol", "added-skill", "2.0.0"),
|
|
809
|
+
);
|
|
810
|
+
|
|
811
|
+
await runAdd(
|
|
812
|
+
["@carol/added-skill", "--key", VALID_KEY, "--url", serverUrl],
|
|
813
|
+
{ stdout },
|
|
814
|
+
);
|
|
815
|
+
|
|
816
|
+
// Subsequent list with the catalog now showing the same skill —
|
|
817
|
+
// it should report as `current`.
|
|
818
|
+
server.setEtag('"v1"');
|
|
819
|
+
server.setLibraryResponse({
|
|
820
|
+
skills: [makeSkill("carol", "added-skill", "2.0.0")],
|
|
821
|
+
removals: [],
|
|
822
|
+
syncedAt: "2026-01-01T00:00:00Z",
|
|
823
|
+
});
|
|
824
|
+
|
|
825
|
+
stdout = createCaptureStream();
|
|
826
|
+
await runList(
|
|
827
|
+
["--key", VALID_KEY, "--url", serverUrl, "--json"],
|
|
828
|
+
{ stdout },
|
|
829
|
+
);
|
|
830
|
+
const [item] = JSON.parse(stdout.text());
|
|
831
|
+
assert.equal(item.name, "added-skill");
|
|
832
|
+
assert.equal(
|
|
833
|
+
item.state,
|
|
834
|
+
"current",
|
|
835
|
+
"skill added via `add` must show as current — add must update .last-sync",
|
|
836
|
+
);
|
|
837
|
+
});
|
|
838
|
+
});
|
|
839
|
+
|
|
840
|
+
describe("remove → list cross-command contract", () => {
|
|
841
|
+
beforeEach(setup);
|
|
842
|
+
afterEach(teardown);
|
|
843
|
+
|
|
844
|
+
it("after `remove`, the skill's entry is purged from .last-sync", async () => {
|
|
845
|
+
process.env.CLAUDECODE = "1";
|
|
846
|
+
|
|
847
|
+
// Sync two skills, then remove one.
|
|
848
|
+
server.setEtag('"v1"');
|
|
849
|
+
server.setLibraryResponse({
|
|
850
|
+
skills: [
|
|
851
|
+
makeSkill("alice", "keep", "1.0.0"),
|
|
852
|
+
makeSkill("alice", "drop", "1.0.0"),
|
|
853
|
+
],
|
|
854
|
+
removals: [],
|
|
855
|
+
syncedAt: "2026-01-01T00:00:00Z",
|
|
856
|
+
});
|
|
857
|
+
await runUpdate(["--key", VALID_KEY, "--url", serverUrl], { stdout });
|
|
858
|
+
|
|
859
|
+
// Confirm both entries are present in .last-sync.
|
|
860
|
+
let state = readLastSync();
|
|
861
|
+
assert.ok(state.skills["alice/keep"]);
|
|
862
|
+
assert.ok(state.skills["alice/drop"]);
|
|
863
|
+
|
|
864
|
+
server.setRemoveResponseForAny({
|
|
865
|
+
status: 200,
|
|
866
|
+
body: { removed: { owner: "alice", name: "drop" } },
|
|
867
|
+
});
|
|
868
|
+
await runRemove(
|
|
869
|
+
["@alice/drop", "--key", VALID_KEY, "--url", serverUrl],
|
|
870
|
+
{ stdout },
|
|
871
|
+
);
|
|
872
|
+
|
|
873
|
+
// `alice/drop` is now absent from .last-sync; the kept skill is
|
|
874
|
+
// unaffected; the library-level etag is preserved (remove does
|
|
875
|
+
// NOT touch it).
|
|
876
|
+
state = readLastSync();
|
|
877
|
+
assert.ok(state.skills["alice/keep"], "kept skill's entry remains");
|
|
878
|
+
assert.equal(
|
|
879
|
+
state.skills["alice/drop"],
|
|
880
|
+
undefined,
|
|
881
|
+
"removed skill's entry must be purged",
|
|
882
|
+
);
|
|
883
|
+
assert.equal(state.etag, '"v1"', "remove must not clobber the library etag");
|
|
884
|
+
});
|
|
885
|
+
|
|
886
|
+
it("removing a skill that was never in .last-sync is idempotent (no throw, no spurious write)", async () => {
|
|
887
|
+
// Edge case: user runs `skillrepo remove @alice/never-synced`
|
|
888
|
+
// for a skill that was never on disk and was never tracked. The
|
|
889
|
+
// server returns 404 (not-in-library) but `remove` still tries
|
|
890
|
+
// to clean local files (which don't exist) and update
|
|
891
|
+
// `.last-sync` (which has no entry to remove). This must not
|
|
892
|
+
// throw and must not change the etag/syncedAt.
|
|
893
|
+
process.env.CLAUDECODE = "1";
|
|
894
|
+
server.setEtag('"unchanged"');
|
|
895
|
+
server.setLibraryResponse({
|
|
896
|
+
skills: [],
|
|
897
|
+
removals: [],
|
|
898
|
+
syncedAt: "2026-01-01T00:00:00Z",
|
|
899
|
+
});
|
|
900
|
+
await runUpdate(["--key", VALID_KEY, "--url", serverUrl], { stdout });
|
|
901
|
+
|
|
902
|
+
server.setRemoveResponseForAny({
|
|
903
|
+
status: 404,
|
|
904
|
+
body: { error: "Skill not found", code: "not_found" },
|
|
905
|
+
});
|
|
906
|
+
await assert.doesNotReject(() =>
|
|
907
|
+
runRemove(
|
|
908
|
+
["@alice/never-synced", "--key", VALID_KEY, "--url", serverUrl],
|
|
909
|
+
{ stdout },
|
|
910
|
+
),
|
|
911
|
+
);
|
|
912
|
+
|
|
913
|
+
const state = readLastSync();
|
|
914
|
+
assert.equal(state.etag, '"unchanged"');
|
|
915
|
+
});
|
|
916
|
+
});
|
|
917
|
+
|
|
918
|
+
// ───────────────────────────────────────────────────────────────────────
|
|
919
|
+
// QA-flagged coverage gaps (from PR #1574 production-readiness audit)
|
|
920
|
+
// ───────────────────────────────────────────────────────────────────────
|
|
921
|
+
|
|
922
|
+
describe("additional coverage from production-readiness audit", () => {
|
|
923
|
+
beforeEach(setup);
|
|
924
|
+
afterEach(teardown);
|
|
925
|
+
|
|
926
|
+
it("corrupt .last-sync survives — runUpdate writes a fresh v2 file and list works", async () => {
|
|
927
|
+
// Real-world failure mode: a power loss mid-write leaves a
|
|
928
|
+
// partially-written `.last-sync` that won't parse as JSON.
|
|
929
|
+
// `readLastSync` returns null on parse failure (documented
|
|
930
|
+
// forward-compat behavior) and runSync performs a full re-sync.
|
|
931
|
+
// This test holds that contract — a future refactor that removes
|
|
932
|
+
// the try/catch and throws on parse error would break every user
|
|
933
|
+
// with a corrupt cache.
|
|
934
|
+
const path = globalLastSyncPath();
|
|
935
|
+
mkdirSync(join(path, ".."), { recursive: true });
|
|
936
|
+
writeFileSync(path, "CORRUPTED-NOT-JSON{{{", "utf-8");
|
|
937
|
+
|
|
938
|
+
process.env.CLAUDECODE = "1";
|
|
939
|
+
server.setEtag('"fresh"');
|
|
940
|
+
server.setLibraryResponse({
|
|
941
|
+
skills: [makeSkill("alice", "recovered", "1.0.0")],
|
|
942
|
+
removals: [],
|
|
943
|
+
syncedAt: "2026-05-19T00:00:00Z",
|
|
944
|
+
});
|
|
945
|
+
|
|
946
|
+
await assert.doesNotReject(
|
|
947
|
+
() => runUpdate(["--key", VALID_KEY, "--url", serverUrl], { stdout }),
|
|
948
|
+
"runUpdate must survive a corrupt .last-sync and complete the sync",
|
|
949
|
+
);
|
|
950
|
+
const after = readLastSync();
|
|
951
|
+
assert.equal(after.schemaVersion, 2);
|
|
952
|
+
assert.equal(after.etag, '"fresh"');
|
|
953
|
+
assert.ok(after.skills["alice/recovered"]);
|
|
954
|
+
});
|
|
955
|
+
|
|
956
|
+
it("delta sync preserves entries the server didn't return in this round", async () => {
|
|
957
|
+
// The carry-forward contract. Server returns skill A on sync 1,
|
|
958
|
+
// then returns only skill B on sync 2 (delta — A didn't change).
|
|
959
|
+
// After sync 2, `.last-sync` must STILL contain A's SHA entry.
|
|
960
|
+
// Without this carry-forward, every delta sync shrinks the map
|
|
961
|
+
// until only the most-recently-touched skills remain — defeating
|
|
962
|
+
// list's drift detection for stable skills.
|
|
963
|
+
process.env.CLAUDECODE = "1";
|
|
964
|
+
|
|
965
|
+
// Sync 1: both A and B in the library.
|
|
966
|
+
server.setEtag('"sync1"');
|
|
967
|
+
server.setLibraryResponse({
|
|
968
|
+
skills: [
|
|
969
|
+
makeSkill("alice", "stable", "1.0.0"),
|
|
970
|
+
makeSkill("alice", "churning", "1.0.0"),
|
|
971
|
+
],
|
|
972
|
+
removals: [],
|
|
973
|
+
syncedAt: "2026-01-01T00:00:00Z",
|
|
974
|
+
});
|
|
975
|
+
await runUpdate(["--key", VALID_KEY, "--url", serverUrl], { stdout });
|
|
976
|
+
let state = readLastSync();
|
|
977
|
+
assert.ok(state.skills["alice/stable"]);
|
|
978
|
+
assert.ok(state.skills["alice/churning"]);
|
|
979
|
+
|
|
980
|
+
// Sync 2: server returns ONLY the changed skill (delta). The
|
|
981
|
+
// stable skill is untouched. Note: production runSync sends
|
|
982
|
+
// `since` based on the persisted syncedAt; the mock server
|
|
983
|
+
// doesn't filter by `since`, so the test mimics the delta-only
|
|
984
|
+
// server response shape directly.
|
|
985
|
+
server.setEtag('"sync2"');
|
|
986
|
+
server.setLibraryResponse({
|
|
987
|
+
skills: [makeSkill("alice", "churning", "1.1.0")],
|
|
988
|
+
removals: [],
|
|
989
|
+
syncedAt: "2026-01-02T00:00:00Z",
|
|
990
|
+
});
|
|
991
|
+
await runUpdate(["--key", VALID_KEY, "--url", serverUrl], { stdout });
|
|
992
|
+
|
|
993
|
+
state = readLastSync();
|
|
994
|
+
assert.ok(
|
|
995
|
+
state.skills["alice/stable"],
|
|
996
|
+
"stable skill's entry must survive a delta sync that didn't return it",
|
|
997
|
+
);
|
|
998
|
+
assert.equal(state.skills["alice/churning"].version, "1.1.0");
|
|
999
|
+
});
|
|
1000
|
+
|
|
1001
|
+
it("three detected vendors all receive the skill (proves verbatim passthrough)", async () => {
|
|
1002
|
+
// Earlier integration tests only cover two vendors. A hardcoded
|
|
1003
|
+
// `["claudeCode", "cursor"]` impl would pass those — it would
|
|
1004
|
+
// even produce both placements. This test forces three vendors
|
|
1005
|
+
// (claudeCode + cursor + windsurf via CURSOR_AGENT + CLAUDECODE
|
|
1006
|
+
// + the windsurf home signal). Detection returns three entries
|
|
1007
|
+
// and `update` must write to all three.
|
|
1008
|
+
process.env.CLAUDECODE = "1";
|
|
1009
|
+
// Cursor needs its env signal AND its settings.json equivalent
|
|
1010
|
+
// (cursor's home signal is `.cursor` which is a dir — cursor
|
|
1011
|
+
// doesn't have the same `.claude` poisoning issue because we
|
|
1012
|
+
// never write into `~/.cursor`).
|
|
1013
|
+
process.env.CURSOR_AGENT = "1";
|
|
1014
|
+
// Trigger windsurf detection via its home signal
|
|
1015
|
+
// (`~/.codeium/windsurf/`). The dir-existing signal is the
|
|
1016
|
+
// documented detection mechanism per the registry.
|
|
1017
|
+
mkdirSync(join(process.env.HOME, ".codeium", "windsurf"), {
|
|
1018
|
+
recursive: true,
|
|
1019
|
+
});
|
|
1020
|
+
|
|
1021
|
+
server.setEtag('"v1"');
|
|
1022
|
+
server.setLibraryResponse({
|
|
1023
|
+
skills: [makeSkill("alice", "everywhere", "1.0.0")],
|
|
1024
|
+
removals: [],
|
|
1025
|
+
syncedAt: "2026-01-01T00:00:00Z",
|
|
1026
|
+
});
|
|
1027
|
+
|
|
1028
|
+
await runUpdate(["--key", VALID_KEY, "--url", serverUrl], { stdout });
|
|
1029
|
+
|
|
1030
|
+
// All three vendors' placements must exist on disk. claudeCode
|
|
1031
|
+
// writes to `.claude/skills/`. cursor + windsurf both share the
|
|
1032
|
+
// `.agents/skills/` cohort placement target — one physical
|
|
1033
|
+
// write satisfies both (placementTargetsFor dedupes by target).
|
|
1034
|
+
assert.ok(existsSync(resolvePlacementDir("claudeProject", "everywhere")));
|
|
1035
|
+
assert.ok(existsSync(resolvePlacementDir("agentsProject", "everywhere")));
|
|
1036
|
+
|
|
1037
|
+
// List walks every detected vendor independently. claudeCode,
|
|
1038
|
+
// cursor, AND windsurf all have project targets (windsurf's
|
|
1039
|
+
// project target is `agentsProject` per the registry — windsurf
|
|
1040
|
+
// joins the cohort for project-scope skills, even though its
|
|
1041
|
+
// personal-scope placement is at `~/.codeium/windsurf/skills/`).
|
|
1042
|
+
// All three must appear in the JSON output, all `current`.
|
|
1043
|
+
stdout = createCaptureStream();
|
|
1044
|
+
await runList(["--key", VALID_KEY, "--url", serverUrl, "--json"], { stdout });
|
|
1045
|
+
const [item] = JSON.parse(stdout.text());
|
|
1046
|
+
assert.equal(item.state, "current");
|
|
1047
|
+
const vendors = item.placements.map((p) => p.vendor).sort();
|
|
1048
|
+
assert.deepEqual(
|
|
1049
|
+
vendors,
|
|
1050
|
+
["claudeCode", "cursor", "windsurf"],
|
|
1051
|
+
"list must walk every detected vendor (3 in this case — proves no hardcoded pair)",
|
|
1052
|
+
);
|
|
1053
|
+
for (const p of item.placements) {
|
|
1054
|
+
assert.equal(p.state, "current");
|
|
1055
|
+
}
|
|
1056
|
+
});
|
|
1057
|
+
});
|
|
1058
|
+
|
|
1059
|
+
// ───────────────────────────────────────────────────────────────────────
|
|
1060
|
+
// Upgrade-path scenarios — the bugs the fresh-sandbox tests cannot see
|
|
1061
|
+
// ───────────────────────────────────────────────────────────────────────
|
|
1062
|
+
//
|
|
1063
|
+
// The 4.5.0 → 4.5.1 production-readiness audit missed a real user-facing
|
|
1064
|
+
// bug because every test in this file (and every reviewer-spawned
|
|
1065
|
+
// verification) ran in a freshly-created sandbox. The actual user
|
|
1066
|
+
// upgrade path is different: pre-existing `.last-sync` written by the
|
|
1067
|
+
// old CLI, then run the new CLI, expect new behavior to take effect.
|
|
1068
|
+
//
|
|
1069
|
+
// The specific bug: `runSync`'s ETag short-circuit fires when the
|
|
1070
|
+
// server hasn't changed library content since the last sync — but if
|
|
1071
|
+
// the local detected-vendor set has EXPANDED since that sync (the
|
|
1072
|
+
// classic 4.5.0 → 4.5.1 transition: claudeCode-only → all-detected),
|
|
1073
|
+
// the 304 means the new vendors NEVER receive their skills. User sees
|
|
1074
|
+
// "Library is up to date" followed by `list` reporting every skill as
|
|
1075
|
+
// MISS for the newly-detected vendors.
|
|
1076
|
+
//
|
|
1077
|
+
// These tests reproduce that specific scenario by:
|
|
1078
|
+
// 1. Seeding `.last-sync` with a state that mimics the old CLI's
|
|
1079
|
+
// output (single-vendor sync, etag X).
|
|
1080
|
+
// 2. Setting the detection signals for additional vendors.
|
|
1081
|
+
// 3. Running `update` against a server that returns 304 for etag X.
|
|
1082
|
+
// 4. Asserting the new vendors' placements were populated anyway.
|
|
1083
|
+
|
|
1084
|
+
describe("upgrade path: vendor set expands between syncs (regression for the bug 4.5.1 missed)", () => {
|
|
1085
|
+
beforeEach(setup);
|
|
1086
|
+
afterEach(teardown);
|
|
1087
|
+
|
|
1088
|
+
it("new vendor detected after prior sync → ETag is dropped and writes fire for the new vendor", async () => {
|
|
1089
|
+
// STEP 1: simulate the 4.5.0 user's state — only claudeCode
|
|
1090
|
+
// detected, sync completed, `.last-sync` has skill SHAs and an
|
|
1091
|
+
// etag.
|
|
1092
|
+
process.env.CLAUDECODE = "1";
|
|
1093
|
+
server.setEtag('"library-v1"');
|
|
1094
|
+
const skill = makeSkill("alice", "shared", "1.0.0");
|
|
1095
|
+
server.setLibraryResponse({
|
|
1096
|
+
skills: [skill],
|
|
1097
|
+
removals: [],
|
|
1098
|
+
syncedAt: "2026-05-01T00:00:00Z",
|
|
1099
|
+
});
|
|
1100
|
+
await runUpdate(["--key", VALID_KEY, "--url", serverUrl], { stdout });
|
|
1101
|
+
|
|
1102
|
+
// Sanity: only claudeProject got the write at this point.
|
|
1103
|
+
assert.ok(
|
|
1104
|
+
existsSync(resolvePlacementDir("claudeProject", "shared")),
|
|
1105
|
+
"first sync wrote to claudeProject",
|
|
1106
|
+
);
|
|
1107
|
+
assert.equal(
|
|
1108
|
+
existsSync(resolvePlacementDir("agentsProject", "shared")),
|
|
1109
|
+
false,
|
|
1110
|
+
"first sync did NOT write to agentsProject (cursor not detected yet)",
|
|
1111
|
+
);
|
|
1112
|
+
|
|
1113
|
+
// STEP 2: simulate the user installing Cursor — env signal fires
|
|
1114
|
+
// on the NEXT `update` invocation. The library content has not
|
|
1115
|
+
// changed on the server, so the server will return 304.
|
|
1116
|
+
process.env.CURSOR_AGENT = "1";
|
|
1117
|
+
|
|
1118
|
+
// Mock server keeps the same etag → If-None-Match match → 304.
|
|
1119
|
+
// No need to change the library response: the 304 branch in
|
|
1120
|
+
// runSync skips reading the body anyway.
|
|
1121
|
+
|
|
1122
|
+
// STEP 3: run update. Pre-fix: short-circuits on 304, no writes
|
|
1123
|
+
// to cursor, user sees "Library is up to date" but cursor's
|
|
1124
|
+
// placement remains empty.
|
|
1125
|
+
server.resetLibraryInspection();
|
|
1126
|
+
stdout = createCaptureStream();
|
|
1127
|
+
await runUpdate(["--key", VALID_KEY, "--url", serverUrl], { stdout });
|
|
1128
|
+
|
|
1129
|
+
// STEP 4: assert. THIS IS THE LOAD-BEARING CHECK.
|
|
1130
|
+
// The newly-detected cursor vendor's placement MUST exist after
|
|
1131
|
+
// the second `update`, even though the server responded 304.
|
|
1132
|
+
// Pre-fix this assertion fails.
|
|
1133
|
+
assert.ok(
|
|
1134
|
+
existsSync(resolvePlacementDir("agentsProject", "shared")),
|
|
1135
|
+
"agentsProject placement MUST exist after a 304 sync when cursor was newly detected — " +
|
|
1136
|
+
"the ETag short-circuit must not prevent writes to vendors absent from the prior sync",
|
|
1137
|
+
);
|
|
1138
|
+
// Wire-level proof: the client did NOT send If-None-Match — the
|
|
1139
|
+
// placement-presence check correctly forced a full re-fetch.
|
|
1140
|
+
// Without this assertion, a regression that still sent the ETag
|
|
1141
|
+
// but happened to re-write to cursor via some unrelated code
|
|
1142
|
+
// path would pass the disk check above silently. See QA Gap 2.
|
|
1143
|
+
assert.equal(
|
|
1144
|
+
server.getLastLibraryIfNoneMatch(),
|
|
1145
|
+
null,
|
|
1146
|
+
"client must NOT have sent If-None-Match — placement-presence check should have dropped it",
|
|
1147
|
+
);
|
|
1148
|
+
|
|
1149
|
+
// Confirm `list` agrees: every detected vendor reports current.
|
|
1150
|
+
stdout = createCaptureStream();
|
|
1151
|
+
await runList(
|
|
1152
|
+
["--key", VALID_KEY, "--url", serverUrl, "--json"],
|
|
1153
|
+
{ stdout },
|
|
1154
|
+
);
|
|
1155
|
+
const [item] = JSON.parse(stdout.text());
|
|
1156
|
+
assert.equal(
|
|
1157
|
+
item.state,
|
|
1158
|
+
"current",
|
|
1159
|
+
"list must show current after the upgrade-path sync — not MISS",
|
|
1160
|
+
);
|
|
1161
|
+
assert.equal(item.placements.length, 2);
|
|
1162
|
+
for (const p of item.placements) {
|
|
1163
|
+
assert.equal(p.state, "current");
|
|
1164
|
+
}
|
|
1165
|
+
});
|
|
1166
|
+
|
|
1167
|
+
it("vendor set unchanged across syncs → 304 short-circuit still fires (no wasted full re-fetch)", async () => {
|
|
1168
|
+
// Defense-in-depth: the fix must NOT invalidate the 304 unless
|
|
1169
|
+
// the placement set actually changed. A user who runs `update`
|
|
1170
|
+
// twice in a row with the same vendors should still benefit from
|
|
1171
|
+
// the 304 fast path. The assertion is BOTH the user-visible "up
|
|
1172
|
+
// to date" output AND the wire-level `If-None-Match` header —
|
|
1173
|
+
// otherwise a future regression that broke the ETag header but
|
|
1174
|
+
// happened to produce the same output (e.g., full re-fetch with
|
|
1175
|
+
// zero deltas) would pass this test silently. See QA reviewer
|
|
1176
|
+
// Gap 2.
|
|
1177
|
+
process.env.CLAUDECODE = "1";
|
|
1178
|
+
process.env.CURSOR_AGENT = "1";
|
|
1179
|
+
server.setEtag('"library-v1"');
|
|
1180
|
+
server.setLibraryResponse({
|
|
1181
|
+
skills: [makeSkill("alice", "unchanged", "1.0.0")],
|
|
1182
|
+
removals: [],
|
|
1183
|
+
syncedAt: "2026-05-01T00:00:00Z",
|
|
1184
|
+
});
|
|
1185
|
+
|
|
1186
|
+
await runUpdate(["--key", VALID_KEY, "--url", serverUrl], { stdout });
|
|
1187
|
+
|
|
1188
|
+
// Second run with the SAME vendor set — should 304 and produce
|
|
1189
|
+
// the "up to date" message (no writes needed).
|
|
1190
|
+
server.resetLibraryInspection();
|
|
1191
|
+
stdout = createCaptureStream();
|
|
1192
|
+
await runUpdate(["--key", VALID_KEY, "--url", serverUrl], { stdout });
|
|
1193
|
+
assert.match(
|
|
1194
|
+
stdout.text(),
|
|
1195
|
+
/up to date/,
|
|
1196
|
+
"same vendor set + same library = 304 fast path still fires",
|
|
1197
|
+
);
|
|
1198
|
+
// Wire-level proof: the client actually sent If-None-Match.
|
|
1199
|
+
// Without this, a regression that broke the conditional request
|
|
1200
|
+
// but produced the same output would slip past output-only
|
|
1201
|
+
// assertions.
|
|
1202
|
+
assert.equal(
|
|
1203
|
+
server.getLastLibraryIfNoneMatch(),
|
|
1204
|
+
'"library-v1"',
|
|
1205
|
+
"second sync MUST have sent If-None-Match: <prior-etag> — the ETag fast path is load-bearing",
|
|
1206
|
+
);
|
|
1207
|
+
});
|
|
1208
|
+
|
|
1209
|
+
it("--global sync followed by project sync → project placement gets populated", async () => {
|
|
1210
|
+
// Edge case the vendor-set tracking alone misses: same vendors,
|
|
1211
|
+
// different scope. The user runs `update --global` (writes to
|
|
1212
|
+
// ~/.claude/skills/), then runs `update` with no flag (writes to
|
|
1213
|
+
// <cwd>/.claude/skills/). Same vendor key (claudeCode) but
|
|
1214
|
+
// entirely different placement directory. A naive syncedVendors
|
|
1215
|
+
// check would say "set unchanged" → 304 → no writes → project
|
|
1216
|
+
// placement empty → list shows MISS.
|
|
1217
|
+
//
|
|
1218
|
+
// The proper contract: invalidate the ETag whenever the resolved
|
|
1219
|
+
// placement directories for the current sync don't ALL contain
|
|
1220
|
+
// the skills they should. That covers vendor-set expansion AND
|
|
1221
|
+
// scope changes AND cwd switches.
|
|
1222
|
+
process.env.CLAUDECODE = "1";
|
|
1223
|
+
server.setEtag('"library-v1"');
|
|
1224
|
+
server.setLibraryResponse({
|
|
1225
|
+
skills: [makeSkill("alice", "scope-test", "1.0.0")],
|
|
1226
|
+
removals: [],
|
|
1227
|
+
syncedAt: "2026-05-01T00:00:00Z",
|
|
1228
|
+
});
|
|
1229
|
+
|
|
1230
|
+
// First sync: --global → ~/.claude/skills/ (claudeGlobal target).
|
|
1231
|
+
await runUpdate(
|
|
1232
|
+
["--key", VALID_KEY, "--url", serverUrl, "--global"],
|
|
1233
|
+
{ stdout },
|
|
1234
|
+
);
|
|
1235
|
+
assert.ok(
|
|
1236
|
+
existsSync(resolvePlacementDir("claudeGlobal", "scope-test")),
|
|
1237
|
+
"first sync wrote to claudeGlobal",
|
|
1238
|
+
);
|
|
1239
|
+
assert.equal(
|
|
1240
|
+
existsSync(resolvePlacementDir("claudeProject", "scope-test")),
|
|
1241
|
+
false,
|
|
1242
|
+
"first sync did NOT write to claudeProject (no --global flag inverted)",
|
|
1243
|
+
);
|
|
1244
|
+
|
|
1245
|
+
// Second sync: no --global → <cwd>/.claude/skills/ (claudeProject).
|
|
1246
|
+
// Same vendor (claudeCode), same library content (304 from server),
|
|
1247
|
+
// but the destination directory is completely different. Without
|
|
1248
|
+
// the fix, the 304 short-circuit fires and claudeProject stays
|
|
1249
|
+
// empty.
|
|
1250
|
+
stdout = createCaptureStream();
|
|
1251
|
+
await runUpdate(["--key", VALID_KEY, "--url", serverUrl], { stdout });
|
|
1252
|
+
|
|
1253
|
+
// THE LOAD-BEARING CHECK: claudeProject must exist after the
|
|
1254
|
+
// project-scope sync, even though the server 304'd.
|
|
1255
|
+
assert.ok(
|
|
1256
|
+
existsSync(resolvePlacementDir("claudeProject", "scope-test")),
|
|
1257
|
+
"claudeProject placement MUST exist after a 304 sync when the prior sync " +
|
|
1258
|
+
"was --global — the ETag short-circuit must not skip writes when the " +
|
|
1259
|
+
"resolved placement directory is empty",
|
|
1260
|
+
);
|
|
1261
|
+
});
|
|
1262
|
+
|
|
1263
|
+
it("cwd switch between syncs → second project's placement gets populated", async () => {
|
|
1264
|
+
// Same bug class as the --global swap. User runs `update` in
|
|
1265
|
+
// project A, then runs `update` in project B. Both projects use
|
|
1266
|
+
// the same `.last-sync` (lives in HOME, not project) but the
|
|
1267
|
+
// placement directories are cwd-dependent. Without the fix, the
|
|
1268
|
+
// second project sees a 304 and never gets its placements.
|
|
1269
|
+
process.env.CLAUDECODE = "1";
|
|
1270
|
+
server.setEtag('"library-v1"');
|
|
1271
|
+
server.setLibraryResponse({
|
|
1272
|
+
skills: [makeSkill("alice", "cwd-test", "1.0.0")],
|
|
1273
|
+
removals: [],
|
|
1274
|
+
syncedAt: "2026-05-01T00:00:00Z",
|
|
1275
|
+
});
|
|
1276
|
+
|
|
1277
|
+
// First sync in project A.
|
|
1278
|
+
const projectA = process.cwd();
|
|
1279
|
+
await runUpdate(["--key", VALID_KEY, "--url", serverUrl], { stdout });
|
|
1280
|
+
assert.ok(
|
|
1281
|
+
existsSync(resolvePlacementDir("claudeProject", "cwd-test")),
|
|
1282
|
+
"first sync populated project A's placement",
|
|
1283
|
+
);
|
|
1284
|
+
|
|
1285
|
+
// Switch to project B (a separate temp directory inside the same
|
|
1286
|
+
// sandbox so the home-scoped `.last-sync` is shared).
|
|
1287
|
+
const projectB = join(sandbox, "project-b");
|
|
1288
|
+
mkdirSync(projectB, { recursive: true });
|
|
1289
|
+
process.chdir(projectB);
|
|
1290
|
+
|
|
1291
|
+
try {
|
|
1292
|
+
// Same library content, same vendor — server still 304s. Without
|
|
1293
|
+
// the fix, no writes to project B.
|
|
1294
|
+
stdout = createCaptureStream();
|
|
1295
|
+
await runUpdate(["--key", VALID_KEY, "--url", serverUrl], { stdout });
|
|
1296
|
+
|
|
1297
|
+
assert.ok(
|
|
1298
|
+
existsSync(resolvePlacementDir("claudeProject", "cwd-test")),
|
|
1299
|
+
"project B's claudeProject placement MUST exist after sync — " +
|
|
1300
|
+
"ETag short-circuit must not skip writes when the cwd changed",
|
|
1301
|
+
);
|
|
1302
|
+
} finally {
|
|
1303
|
+
process.chdir(projectA);
|
|
1304
|
+
}
|
|
1305
|
+
});
|
|
1306
|
+
|
|
1307
|
+
it("partial baseline recovery: delete 1 of 5 skills' placements → ETag dropped, all re-fetched (QA Gap 1)", async () => {
|
|
1308
|
+
// Most probable failure mode in normal use: user has many
|
|
1309
|
+
// skills, manually deletes one (or a cleanup script trims a
|
|
1310
|
+
// skill they removed), then runs `update`. The library is
|
|
1311
|
+
// unchanged on the server (304), but the placement-presence
|
|
1312
|
+
// check sees the missing dir and forces a full re-fetch. This
|
|
1313
|
+
// restores every skill in one round. The wire-level check
|
|
1314
|
+
// proves the ETag was DROPPED, not silently sent and ignored.
|
|
1315
|
+
process.env.CLAUDECODE = "1";
|
|
1316
|
+
server.setEtag('"library-v1"');
|
|
1317
|
+
server.setLibraryResponse({
|
|
1318
|
+
skills: [
|
|
1319
|
+
makeSkill("alice", "skill-1", "1.0.0"),
|
|
1320
|
+
makeSkill("alice", "skill-2", "1.0.0"),
|
|
1321
|
+
makeSkill("alice", "skill-3", "1.0.0"),
|
|
1322
|
+
makeSkill("alice", "skill-4", "1.0.0"),
|
|
1323
|
+
makeSkill("alice", "skill-5", "1.0.0"),
|
|
1324
|
+
],
|
|
1325
|
+
removals: [],
|
|
1326
|
+
syncedAt: "2026-05-01T00:00:00Z",
|
|
1327
|
+
});
|
|
1328
|
+
|
|
1329
|
+
// Sync — all 5 land.
|
|
1330
|
+
await runUpdate(["--key", VALID_KEY, "--url", serverUrl], { stdout });
|
|
1331
|
+
for (const n of ["skill-1", "skill-2", "skill-3", "skill-4", "skill-5"]) {
|
|
1332
|
+
assert.ok(existsSync(resolvePlacementDir("claudeProject", n)));
|
|
1333
|
+
}
|
|
1334
|
+
|
|
1335
|
+
// User deletes ONE skill — `skill-3`.
|
|
1336
|
+
rmSync(resolvePlacementDir("claudeProject", "skill-3"), {
|
|
1337
|
+
recursive: true,
|
|
1338
|
+
force: true,
|
|
1339
|
+
});
|
|
1340
|
+
|
|
1341
|
+
server.resetLibraryInspection();
|
|
1342
|
+
stdout = createCaptureStream();
|
|
1343
|
+
await runUpdate(["--key", VALID_KEY, "--url", serverUrl], { stdout });
|
|
1344
|
+
|
|
1345
|
+
// `skill-3` is restored. The other 4 are untouched (idempotent
|
|
1346
|
+
// write — same content, same SHA).
|
|
1347
|
+
assert.ok(
|
|
1348
|
+
existsSync(resolvePlacementDir("claudeProject", "skill-3")),
|
|
1349
|
+
"deleted skill must be restored after self-healing sync",
|
|
1350
|
+
);
|
|
1351
|
+
for (const n of ["skill-1", "skill-2", "skill-4", "skill-5"]) {
|
|
1352
|
+
assert.ok(
|
|
1353
|
+
existsSync(resolvePlacementDir("claudeProject", n)),
|
|
1354
|
+
`${n} must remain present (no collateral damage)`,
|
|
1355
|
+
);
|
|
1356
|
+
}
|
|
1357
|
+
// Wire-level: ETag was dropped — placement-presence check fired.
|
|
1358
|
+
assert.equal(
|
|
1359
|
+
server.getLastLibraryIfNoneMatch(),
|
|
1360
|
+
null,
|
|
1361
|
+
"ETag MUST have been dropped — placement-presence check found the missing skill-3 dir",
|
|
1362
|
+
);
|
|
1363
|
+
});
|
|
1364
|
+
|
|
1365
|
+
it("multi-vendor + manual delete of ONE vendor's placement → next update self-heals", async () => {
|
|
1366
|
+
// Real-world scenario flagged by reviewer: a user with two
|
|
1367
|
+
// vendors syncs successfully, then manually deletes one
|
|
1368
|
+
// vendor's placement directory (rm -rf the cohort dir, or a
|
|
1369
|
+
// hostile cleanup script touched it). The `.last-sync` baseline
|
|
1370
|
+
// and the OTHER vendor's placement are intact, so the server
|
|
1371
|
+
// would 304. Without the placement-presence check, the deleted
|
|
1372
|
+
// vendor's placement would stay empty forever — list would show
|
|
1373
|
+
// MISS for every skill at that vendor.
|
|
1374
|
+
//
|
|
1375
|
+
// The placement-presence check sees the missing claudeProject
|
|
1376
|
+
// SKILL.md and forces a re-fetch, restoring both vendors'
|
|
1377
|
+
// placements in one round. This is the "self-healing" property
|
|
1378
|
+
// of the fix.
|
|
1379
|
+
process.env.CLAUDECODE = "1";
|
|
1380
|
+
process.env.CURSOR_AGENT = "1";
|
|
1381
|
+
server.setEtag('"library-v1"');
|
|
1382
|
+
server.setLibraryResponse({
|
|
1383
|
+
skills: [
|
|
1384
|
+
makeSkill("alice", "a", "1.0.0"),
|
|
1385
|
+
makeSkill("alice", "b", "1.0.0"),
|
|
1386
|
+
],
|
|
1387
|
+
removals: [],
|
|
1388
|
+
syncedAt: "2026-05-01T00:00:00Z",
|
|
1389
|
+
});
|
|
1390
|
+
|
|
1391
|
+
// Initial sync — both vendors get both skills.
|
|
1392
|
+
await runUpdate(["--key", VALID_KEY, "--url", serverUrl], { stdout });
|
|
1393
|
+
assert.ok(existsSync(resolvePlacementDir("claudeProject", "a")));
|
|
1394
|
+
assert.ok(existsSync(resolvePlacementDir("agentsProject", "a")));
|
|
1395
|
+
assert.ok(existsSync(resolvePlacementDir("claudeProject", "b")));
|
|
1396
|
+
assert.ok(existsSync(resolvePlacementDir("agentsProject", "b")));
|
|
1397
|
+
|
|
1398
|
+
// User manually nukes the entire claudeProject cohort. cursor's
|
|
1399
|
+
// placement at .agents/skills/ remains.
|
|
1400
|
+
rmSync(resolvePlacementDir("claudeProject", "a"), {
|
|
1401
|
+
recursive: true,
|
|
1402
|
+
force: true,
|
|
1403
|
+
});
|
|
1404
|
+
rmSync(resolvePlacementDir("claudeProject", "b"), {
|
|
1405
|
+
recursive: true,
|
|
1406
|
+
force: true,
|
|
1407
|
+
});
|
|
1408
|
+
|
|
1409
|
+
// Next update — server still 304s (same etag) — but the
|
|
1410
|
+
// placement-presence check fires on missing claudeProject
|
|
1411
|
+
// SKILL.md and forces full re-fetch.
|
|
1412
|
+
stdout = createCaptureStream();
|
|
1413
|
+
await runUpdate(["--key", VALID_KEY, "--url", serverUrl], { stdout });
|
|
1414
|
+
|
|
1415
|
+
assert.ok(
|
|
1416
|
+
existsSync(resolvePlacementDir("claudeProject", "a")),
|
|
1417
|
+
"claudeProject `a` must be restored after self-healing sync",
|
|
1418
|
+
);
|
|
1419
|
+
assert.ok(
|
|
1420
|
+
existsSync(resolvePlacementDir("claudeProject", "b")),
|
|
1421
|
+
"claudeProject `b` must be restored after self-healing sync",
|
|
1422
|
+
);
|
|
1423
|
+
// cursor's placements were never touched.
|
|
1424
|
+
assert.ok(existsSync(resolvePlacementDir("agentsProject", "a")));
|
|
1425
|
+
assert.ok(existsSync(resolvePlacementDir("agentsProject", "b")));
|
|
1426
|
+
});
|
|
1427
|
+
|
|
1428
|
+
it("forced re-fetch drops BOTH If-None-Match AND ?since= (BLOCKER from Round 2 code-review)", async () => {
|
|
1429
|
+
// The bug that almost shipped: PR #1575's first iteration dropped
|
|
1430
|
+
// `If-None-Match` when placements were incomplete but kept `since`
|
|
1431
|
+
// unconditionally. Production server filters by
|
|
1432
|
+
// `gt(skills.updatedAt, since)`, so a forced re-fetch would
|
|
1433
|
+
// return an empty delta for any skill not modified within the
|
|
1434
|
+
// window — leaving the newly-discovered missing placement empty
|
|
1435
|
+
// forever. Permanent ETag-miss loop.
|
|
1436
|
+
//
|
|
1437
|
+
// This test makes both wire-level conditions explicit. The
|
|
1438
|
+
// mock server now mirrors production's `since` filter (see
|
|
1439
|
+
// mock-server.mjs change in this commit), so a regression that
|
|
1440
|
+
// re-introduces the bug would fail the FULL-LIBRARY assertion
|
|
1441
|
+
// even before the wire-level check.
|
|
1442
|
+
process.env.CLAUDECODE = "1";
|
|
1443
|
+
|
|
1444
|
+
// Use an OLD updatedAt so the skill would be filtered out by
|
|
1445
|
+
// `since` if the bug regressed. The default "now" timestamp
|
|
1446
|
+
// would mask the issue.
|
|
1447
|
+
server.setEtag('"library-v1"');
|
|
1448
|
+
server.setLibraryResponse({
|
|
1449
|
+
skills: [makeSkill("alice", "stale-fixture", "1.0.0", "2025-01-01T00:00:00Z")],
|
|
1450
|
+
removals: [],
|
|
1451
|
+
syncedAt: "2026-05-01T00:00:00Z",
|
|
1452
|
+
});
|
|
1453
|
+
|
|
1454
|
+
// First sync: writes the skill to claudeProject.
|
|
1455
|
+
await runUpdate(["--key", VALID_KEY, "--url", serverUrl], { stdout });
|
|
1456
|
+
const skillDir = resolvePlacementDir("claudeProject", "stale-fixture");
|
|
1457
|
+
assert.ok(existsSync(join(skillDir, "SKILL.md")));
|
|
1458
|
+
|
|
1459
|
+
// Force the placement-incomplete path: delete the SKILL.md
|
|
1460
|
+
// (placementsAreComplete will return false on next sync).
|
|
1461
|
+
rmSync(skillDir, { recursive: true, force: true });
|
|
1462
|
+
|
|
1463
|
+
// Run update. Pre-fix: ETag dropped, since=2026-05-01 sent,
|
|
1464
|
+
// server filters by `gt(updatedAt='2025-01-01', since='2026-05-01')`,
|
|
1465
|
+
// returns empty skills array, runSync writes nothing, placement
|
|
1466
|
+
// stays empty. Post-fix: BOTH headers dropped, server returns
|
|
1467
|
+
// the full library, runSync re-writes everything.
|
|
1468
|
+
server.resetLibraryInspection();
|
|
1469
|
+
stdout = createCaptureStream();
|
|
1470
|
+
await runUpdate(["--key", VALID_KEY, "--url", serverUrl], { stdout });
|
|
1471
|
+
|
|
1472
|
+
// Outcome assertion: the skill is back on disk.
|
|
1473
|
+
assert.ok(
|
|
1474
|
+
existsSync(join(skillDir, "SKILL.md")),
|
|
1475
|
+
"stale-fixture must be restored — full re-fetch must return it even though updatedAt < syncedAt",
|
|
1476
|
+
);
|
|
1477
|
+
|
|
1478
|
+
// Wire-level assertions: BOTH headers dropped on the forced
|
|
1479
|
+
// re-fetch path. If either is sent, the bug has regressed.
|
|
1480
|
+
assert.equal(
|
|
1481
|
+
server.getLastLibraryIfNoneMatch(),
|
|
1482
|
+
null,
|
|
1483
|
+
"If-None-Match MUST be dropped when placements are incomplete",
|
|
1484
|
+
);
|
|
1485
|
+
assert.equal(
|
|
1486
|
+
server.getLastLibrarySince(),
|
|
1487
|
+
null,
|
|
1488
|
+
"?since= MUST also be dropped — otherwise server filters out unchanged skills " +
|
|
1489
|
+
"and the missing placement stays empty (the BLOCKER from Round 2 code-review)",
|
|
1490
|
+
);
|
|
1491
|
+
});
|
|
1492
|
+
|
|
1493
|
+
it("placement dir exists but SKILL.md is missing (partial placement) → next update self-heals", async () => {
|
|
1494
|
+
// Reviewer-flagged HIGH: `existsSync(dir)` alone would satisfy
|
|
1495
|
+
// the placement-presence check even for an empty/partial
|
|
1496
|
+
// directory. The fix probes for SKILL.md specifically. This
|
|
1497
|
+
// test locks in that behavior: a placement directory that
|
|
1498
|
+
// exists but lacks its SKILL.md is treated as MISSING and
|
|
1499
|
+
// forces a re-fetch.
|
|
1500
|
+
process.env.CLAUDECODE = "1";
|
|
1501
|
+
server.setEtag('"library-v1"');
|
|
1502
|
+
server.setLibraryResponse({
|
|
1503
|
+
skills: [makeSkill("alice", "partial", "1.0.0")],
|
|
1504
|
+
removals: [],
|
|
1505
|
+
syncedAt: "2026-05-01T00:00:00Z",
|
|
1506
|
+
});
|
|
1507
|
+
|
|
1508
|
+
// Initial sync writes SKILL.md.
|
|
1509
|
+
await runUpdate(["--key", VALID_KEY, "--url", serverUrl], { stdout });
|
|
1510
|
+
const dir = resolvePlacementDir("claudeProject", "partial");
|
|
1511
|
+
const skillMd = join(dir, "SKILL.md");
|
|
1512
|
+
assert.ok(existsSync(skillMd));
|
|
1513
|
+
|
|
1514
|
+
// User (or hostile tool) deletes just the SKILL.md, leaving the
|
|
1515
|
+
// directory.
|
|
1516
|
+
rmSync(skillMd);
|
|
1517
|
+
assert.ok(existsSync(dir), "dir still exists");
|
|
1518
|
+
assert.equal(existsSync(skillMd), false, "but SKILL.md is gone");
|
|
1519
|
+
|
|
1520
|
+
// Next update — placement-presence check sees no SKILL.md and
|
|
1521
|
+
// forces re-fetch.
|
|
1522
|
+
stdout = createCaptureStream();
|
|
1523
|
+
await runUpdate(["--key", VALID_KEY, "--url", serverUrl], { stdout });
|
|
1524
|
+
assert.ok(
|
|
1525
|
+
existsSync(skillMd),
|
|
1526
|
+
"SKILL.md must be restored — empty dirs must not satisfy the placement check",
|
|
1527
|
+
);
|
|
1528
|
+
});
|
|
1529
|
+
|
|
1530
|
+
it("vendor REMOVED from detected set → next update still 304s (no over-eager full re-fetch)", async () => {
|
|
1531
|
+
// Edge case: user uninstalls a tool between syncs. The detected
|
|
1532
|
+
// vendor set shrinks. The library content hasn't changed.
|
|
1533
|
+
// Question: should the next update do a full re-fetch?
|
|
1534
|
+
//
|
|
1535
|
+
// Answer (per the fix's design): no. We only invalidate the ETag
|
|
1536
|
+
// when the vendor set EXPANDED (added vendors need writes). A
|
|
1537
|
+
// shrunk set has no new writes to do — the remaining vendors
|
|
1538
|
+
// already have what they need. The orphaned placements (from
|
|
1539
|
+
// the removed vendor) are left in place; cleaning them up is a
|
|
1540
|
+
// separate concern.
|
|
1541
|
+
//
|
|
1542
|
+
// Pre-fix: same behavior (304 fires regardless of vendor change).
|
|
1543
|
+
// Post-fix: explicit policy — only invalidate on expansion.
|
|
1544
|
+
process.env.CLAUDECODE = "1";
|
|
1545
|
+
process.env.CURSOR_AGENT = "1";
|
|
1546
|
+
server.setEtag('"library-v1"');
|
|
1547
|
+
server.setLibraryResponse({
|
|
1548
|
+
skills: [makeSkill("alice", "shrinkable", "1.0.0")],
|
|
1549
|
+
removals: [],
|
|
1550
|
+
syncedAt: "2026-05-01T00:00:00Z",
|
|
1551
|
+
});
|
|
1552
|
+
await runUpdate(["--key", VALID_KEY, "--url", serverUrl], { stdout });
|
|
1553
|
+
|
|
1554
|
+
// "Uninstall" cursor — drop the env signal.
|
|
1555
|
+
delete process.env.CURSOR_AGENT;
|
|
1556
|
+
|
|
1557
|
+
stdout = createCaptureStream();
|
|
1558
|
+
await runUpdate(["--key", VALID_KEY, "--url", serverUrl], { stdout });
|
|
1559
|
+
assert.match(
|
|
1560
|
+
stdout.text(),
|
|
1561
|
+
/up to date/,
|
|
1562
|
+
"shrinking the vendor set should not force a wasteful re-fetch",
|
|
1563
|
+
);
|
|
1564
|
+
});
|
|
1565
|
+
});
|