skillrepo 4.5.1 → 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 +2 -2
- package/src/commands/get.mjs +1 -1
- package/src/commands/list.mjs +28 -9
- package/src/lib/sync.mjs +160 -3
- package/src/test/e2e/mock-server.mjs +92 -2
- package/src/test/integration/update-list-contract.integration.test.mjs +575 -28
- package/src/test/lib/sync.test.mjs +220 -0
package/package.json
CHANGED
package/src/commands/add.mjs
CHANGED
|
@@ -196,11 +196,11 @@ export async function runAdd(argv, io = {}) {
|
|
|
196
196
|
.join(", ")})`;
|
|
197
197
|
if (wasNewlyAdded) {
|
|
198
198
|
stdout.write(
|
|
199
|
-
`\n ✓ Added ${formatIdentifier({ owner, name })} to your library (${skill.files.length} files) → ${where}\n\n`,
|
|
199
|
+
`\n ✓ Added ${formatIdentifier({ owner, name })} to your library (${skill.files.length} file${skill.files.length === 1 ? "" : "s"}) → ${where}\n\n`,
|
|
200
200
|
);
|
|
201
201
|
} else {
|
|
202
202
|
stdout.write(
|
|
203
|
-
`\n ✓ ${formatIdentifier({ owner, name })} was already in your library — refreshed (${skill.files.length} files) → ${where}\n\n`,
|
|
203
|
+
`\n ✓ ${formatIdentifier({ owner, name })} was already in your library — refreshed (${skill.files.length} file${skill.files.length === 1 ? "" : "s"}) → ${where}\n\n`,
|
|
204
204
|
);
|
|
205
205
|
}
|
|
206
206
|
}
|
package/src/commands/get.mjs
CHANGED
|
@@ -143,6 +143,6 @@ export async function runGet(argv, io = {}) {
|
|
|
143
143
|
.map(describePlacementTarget)
|
|
144
144
|
.join(", ")})`;
|
|
145
145
|
stdout.write(
|
|
146
|
-
`\n ✓ Fetched ${formatIdentifier({ owner, name })} (${skill.files.length} files) → ${where}\n\n`,
|
|
146
|
+
`\n ✓ Fetched ${formatIdentifier({ owner, name })} (${skill.files.length} file${skill.files.length === 1 ? "" : "s"}) → ${where}\n\n`,
|
|
147
147
|
);
|
|
148
148
|
}
|
package/src/commands/list.mjs
CHANGED
|
@@ -243,9 +243,13 @@ function printTable(augmented, detected, out) {
|
|
|
243
243
|
.join(", ");
|
|
244
244
|
out.write(`\n Detected: ${detectedLabel}\n`);
|
|
245
245
|
} else {
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
246
|
+
// Earlier copy said "drift cannot be reported" but the table
|
|
247
|
+
// below DOES report drift — every skill rolls up as MISSING
|
|
248
|
+
// because no placement exists to compare against. The simpler
|
|
249
|
+
// statement of fact is what users need; the footer ("No sync
|
|
250
|
+
// history…" / "library has changed…") tells them what to do
|
|
251
|
+
// next.
|
|
252
|
+
out.write("\n No agents detected in this project.\n");
|
|
249
253
|
}
|
|
250
254
|
|
|
251
255
|
const sorted = augmented.slice().sort(sortByOwnerAndName);
|
|
@@ -337,12 +341,27 @@ function printFooter(augmented, libraryEtag, lastSync, out, useGlyphs) {
|
|
|
337
341
|
}
|
|
338
342
|
|
|
339
343
|
if (etagMatches && driftCount > 0) {
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
344
|
+
// Distinguish EDITED (running update loses user changes) from
|
|
345
|
+
// MISS/STALE (running update is non-destructive — fills gaps or
|
|
346
|
+
// pulls a newer version). The earlier copy said "Run `skillrepo
|
|
347
|
+
// update` to refresh" for every drift case, which silently
|
|
348
|
+
// destroyed user edits when EDITED rows were present. Now we
|
|
349
|
+
// warn explicitly when edits are at risk.
|
|
350
|
+
const editedCount = augmented.filter(
|
|
351
|
+
(s) => s.state === SKILL_STATE.EDITED,
|
|
352
|
+
).length;
|
|
353
|
+
const driftPhrase = `${driftCount} skill${driftCount === 1 ? "" : "s"} show${driftCount === 1 ? "s" : ""}`;
|
|
354
|
+
if (editedCount > 0) {
|
|
355
|
+
out.write(
|
|
356
|
+
`\n ${warn} library in sync — but ${driftPhrase} local drift, including ${editedCount} with local edit${editedCount === 1 ? "" : "s"}.\n` +
|
|
357
|
+
" Run `skillrepo update` to refresh — this will OVERWRITE local edits.\n\n",
|
|
358
|
+
);
|
|
359
|
+
} else {
|
|
360
|
+
out.write(
|
|
361
|
+
`\n ${warn} library in sync — but ${driftPhrase} local drift.\n` +
|
|
362
|
+
" Run `skillrepo update` to refresh.\n\n",
|
|
363
|
+
);
|
|
364
|
+
}
|
|
346
365
|
return;
|
|
347
366
|
}
|
|
348
367
|
|
package/src/lib/sync.mjs
CHANGED
|
@@ -85,6 +85,7 @@
|
|
|
85
85
|
*/
|
|
86
86
|
|
|
87
87
|
import { existsSync, readFileSync } from "node:fs";
|
|
88
|
+
import { join } from "node:path";
|
|
88
89
|
|
|
89
90
|
import { getLibrary } from "./http.mjs";
|
|
90
91
|
import {
|
|
@@ -110,6 +111,103 @@ import { computeSkillShas } from "./crypto-shas.mjs";
|
|
|
110
111
|
*/
|
|
111
112
|
export const LAST_SYNC_SCHEMA_VERSION = 2;
|
|
112
113
|
|
|
114
|
+
/**
|
|
115
|
+
* Check whether every skill in the `.last-sync` baseline already exists
|
|
116
|
+
* under every placement target the current sync would write to. Returns
|
|
117
|
+
* true when the 304 short-circuit is safe (skipping writes won't leave
|
|
118
|
+
* any placement empty), false when at least one (skill, target) pair
|
|
119
|
+
* is missing on disk.
|
|
120
|
+
*
|
|
121
|
+
* This is the load-bearing guard for the ETag fast path. The contract:
|
|
122
|
+
* "library content unchanged AND every expected placement already has
|
|
123
|
+
* its content" — the second clause is what this function enforces.
|
|
124
|
+
*
|
|
125
|
+
* See the call site in runSync for the full motivation (upgrade-path,
|
|
126
|
+
* --global ↔ project swap, and cwd-switch scenarios all funnel through
|
|
127
|
+
* this gate).
|
|
128
|
+
*
|
|
129
|
+
* @param {Record<string, {skillMdSha256: string, filesSha256: string, version: string}> | null | undefined} skillsMap
|
|
130
|
+
* The `.skills` map from `.last-sync` — keyed by `"<owner>/<name>"`.
|
|
131
|
+
* @param {string[] | undefined} vendors
|
|
132
|
+
* The current sync's vendor list (output of `effectiveVendors`).
|
|
133
|
+
* @param {boolean | undefined} global
|
|
134
|
+
* Whether the current sync is `--global` mode.
|
|
135
|
+
* @returns {boolean}
|
|
136
|
+
*/
|
|
137
|
+
export function placementsAreComplete(skillsMap, vendors, global) {
|
|
138
|
+
// Empty baseline: we cannot trust the ETag short-circuit. Two
|
|
139
|
+
// legitimate code paths land here:
|
|
140
|
+
// 1. Genuine fresh install — no `.last-sync` file at all; this
|
|
141
|
+
// function isn't called because runSync's caller checks
|
|
142
|
+
// `lastSync?.etag` before invoking. So we never see this
|
|
143
|
+
// branch in the fresh-install case.
|
|
144
|
+
// 2. v1 → v2 migration — `readLastSync` synthesizes an empty
|
|
145
|
+
// `skills` map for a v1 file (which had no per-skill SHA
|
|
146
|
+
// cache). The etag IS present but the baseline is empty;
|
|
147
|
+
// a 304 would tell us "library unchanged" but we'd still
|
|
148
|
+
// have no per-skill cache, so `list` would render every
|
|
149
|
+
// skill as MISS. Forcing a re-fetch here populates the
|
|
150
|
+
// cache and surfaces the actual library state.
|
|
151
|
+
// The conservative direction is "force re-fetch" — wire cost
|
|
152
|
+
// happens once per upgrade, not every time.
|
|
153
|
+
if (
|
|
154
|
+
!skillsMap ||
|
|
155
|
+
typeof skillsMap !== "object" ||
|
|
156
|
+
Object.keys(skillsMap).length === 0
|
|
157
|
+
) {
|
|
158
|
+
return false;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// No vendors: this is the `--agent none` case or a degenerate config.
|
|
162
|
+
// requireVendorTargets will catch this downstream; from here, treat
|
|
163
|
+
// as "no placements to verify" → trivially complete.
|
|
164
|
+
if (!Array.isArray(vendors) || vendors.length === 0) {
|
|
165
|
+
return true;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
let targets;
|
|
169
|
+
try {
|
|
170
|
+
targets = placementTargetsFor({ vendors, global: global === true });
|
|
171
|
+
} catch {
|
|
172
|
+
// placementTargetsFor throws on an invalid vendor + scope combo
|
|
173
|
+
// (e.g., `--global` with a vendor that has no `globalTarget`).
|
|
174
|
+
// The downstream runSync write loop will surface the same error
|
|
175
|
+
// with the correct typed exception; conservatively treat as
|
|
176
|
+
// "placement incomplete" so we drop the ETag and force the
|
|
177
|
+
// full-fetch path (which is what we'd want under any error).
|
|
178
|
+
return false;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
for (const key of Object.keys(skillsMap)) {
|
|
182
|
+
// Skill keys are `"<owner>/<name>"`. We only need the name half
|
|
183
|
+
// because placement directories are keyed by skill name alone.
|
|
184
|
+
const slashAt = key.indexOf("/");
|
|
185
|
+
if (slashAt < 0) continue; // malformed entry — skip rather than crash
|
|
186
|
+
const skillName = key.slice(slashAt + 1);
|
|
187
|
+
if (!skillName) continue;
|
|
188
|
+
|
|
189
|
+
for (const target of targets) {
|
|
190
|
+
const dir = resolvePlacementDir(target, skillName);
|
|
191
|
+
// Probe `SKILL.md` rather than the bare directory. An empty or
|
|
192
|
+
// partial directory left behind by a hostile filesystem
|
|
193
|
+
// condition (manual mkdir, third-party tool, interrupted write
|
|
194
|
+
// before the atomic rename — rare but possible) would otherwise
|
|
195
|
+
// satisfy `existsSync(dir)` and let the 304 fire, leaving the
|
|
196
|
+
// user with a placement that contains no usable skill content.
|
|
197
|
+
// SKILL.md is the spec-required entry point; its presence is
|
|
198
|
+
// the load-bearing invariant the placement-presence check is
|
|
199
|
+
// really asserting.
|
|
200
|
+
if (!existsSync(join(dir, "SKILL.md"))) {
|
|
201
|
+
// ONE missing placement is enough — full re-fetch will re-write
|
|
202
|
+
// EVERY skill to EVERY target, restoring the invariant in one
|
|
203
|
+
// round.
|
|
204
|
+
return false;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
return true;
|
|
209
|
+
}
|
|
210
|
+
|
|
113
211
|
/**
|
|
114
212
|
* Read the persisted last-sync state from ~/.claude/skillrepo/.last-sync.
|
|
115
213
|
* Returns null if the file doesn't exist, is malformed, or has a
|
|
@@ -370,8 +468,61 @@ export async function runSync(options) {
|
|
|
370
468
|
|
|
371
469
|
// Step 3: fetch with conditional headers
|
|
372
470
|
const opts = {};
|
|
373
|
-
|
|
374
|
-
|
|
471
|
+
// The ETag short-circuit is only safe when EVERY skill in
|
|
472
|
+
// `.last-sync`'s baseline is present in EVERY placement directory
|
|
473
|
+
// the current sync would write to. The 304 means "library content
|
|
474
|
+
// unchanged" — but the local placement set can change between
|
|
475
|
+
// syncs even when content does not:
|
|
476
|
+
//
|
|
477
|
+
// - User upgrades from 4.5.0 (single-vendor default) to 4.5.1+
|
|
478
|
+
// (all-detected default) — new vendors detected, their
|
|
479
|
+
// placement dirs are empty.
|
|
480
|
+
// - User runs `update --global` once, then `update` (no flag)
|
|
481
|
+
// later — same vendor, but the resolved placement dir is
|
|
482
|
+
// project-scoped now instead of global.
|
|
483
|
+
// - User runs `update` in project A, then in project B — same
|
|
484
|
+
// vendor and scope, but `<cwd>/.claude/skills/` resolves to a
|
|
485
|
+
// different directory in B than in A.
|
|
486
|
+
//
|
|
487
|
+
// Without this check, any of those cases produces 304 → no writes
|
|
488
|
+
// → all-MISS output in `list`. The fix is a placement-presence
|
|
489
|
+
// check: for every (skill, target) pair the current sync would
|
|
490
|
+
// touch, the skill's directory MUST already exist under the
|
|
491
|
+
// target's root. If ANY is missing, drop `If-None-Match` so the
|
|
492
|
+
// server returns the full library and we populate the empty
|
|
493
|
+
// placement.
|
|
494
|
+
//
|
|
495
|
+
// Performance: N targets × M skills `existsSync` calls. For a 14-
|
|
496
|
+
// skill library with 2 targets, that's 28 stat calls — well under
|
|
497
|
+
// a millisecond. The conditional-fetch wire savings on a clean
|
|
498
|
+
// repeat sync still applies; this is the safety guard above it.
|
|
499
|
+
// BOTH the ETag short-circuit AND the `since` delta-query optimization
|
|
500
|
+
// depend on the same trust invariant: "the local placement set has
|
|
501
|
+
// every skill we expect." If a placement is missing, dropping ONLY
|
|
502
|
+
// `If-None-Match` is insufficient — the server filters `result.skills`
|
|
503
|
+
// by `updatedAt > since`, so a server response that we trigger by
|
|
504
|
+
// dropping the ETag will STILL omit any skill whose content hasn't
|
|
505
|
+
// changed on the server since the last sync. The missing placement
|
|
506
|
+
// would never be repopulated, producing a permanent ETag-miss loop
|
|
507
|
+
// that the user can only escape by `rm ~/.claude/skillrepo/.last-sync`.
|
|
508
|
+
//
|
|
509
|
+
// Verified against the production server at
|
|
510
|
+
// src/lib/queries/library.ts (`gt(skills.updatedAt, since)`): `since`
|
|
511
|
+
// alone is enough to filter unchanged skills out of the response.
|
|
512
|
+
//
|
|
513
|
+
// Fix: gate BOTH headers on the same `placementsAreComplete` check.
|
|
514
|
+
// When placements are incomplete, drop both — server returns the
|
|
515
|
+
// full library, runSync writes every skill to every detected
|
|
516
|
+
// vendor's placement, recovery is complete in one round.
|
|
517
|
+
const placementsComplete =
|
|
518
|
+
lastSync?.etag &&
|
|
519
|
+
placementsAreComplete(lastSync.skills, vendors, global);
|
|
520
|
+
if (placementsComplete) {
|
|
521
|
+
opts.ifNoneMatch = lastSync.etag;
|
|
522
|
+
}
|
|
523
|
+
if (lastSync?.syncedAt && placementsComplete) {
|
|
524
|
+
opts.since = lastSync.syncedAt;
|
|
525
|
+
}
|
|
375
526
|
|
|
376
527
|
// Track whether this is a full or delta sync BEFORE the network
|
|
377
528
|
// call, for the returned summary's `fullSync` field. A "full" sync
|
|
@@ -520,7 +671,13 @@ export async function runSync(options) {
|
|
|
520
671
|
}
|
|
521
672
|
}
|
|
522
673
|
|
|
523
|
-
// Step 7: persist new ETag + skills map (only if the response was
|
|
674
|
+
// Step 7: persist new ETag + skills map (only if the response was
|
|
675
|
+
// complete). The placement-presence check in `placementsAreComplete`
|
|
676
|
+
// (called above before the conditional request) handles the safety
|
|
677
|
+
// gate for the ETag short-circuit — no need to persist a separate
|
|
678
|
+
// vendor-set field; the on-disk placements themselves are the
|
|
679
|
+
// source of truth for "did the local placement set change in a way
|
|
680
|
+
// that would invalidate 304?"
|
|
524
681
|
if (!anyIncomplete && result.etag) {
|
|
525
682
|
try {
|
|
526
683
|
writeLastSync({
|
|
@@ -80,6 +80,18 @@ export function createMockServer(initialPayload, options = {}) {
|
|
|
80
80
|
/** @type {{ status: number, body: any } | null} */
|
|
81
81
|
let forcedError = null;
|
|
82
82
|
|
|
83
|
+
// Library-request inspection. Tests use these to distinguish a true
|
|
84
|
+
// 304 wire exchange (`If-None-Match` sent, server replied 304) from
|
|
85
|
+
// a request that produced "up to date" output via some other path.
|
|
86
|
+
// PR #1575's placement-presence fix made this distinction
|
|
87
|
+
// load-bearing — see the assertion at the call sites in
|
|
88
|
+
// update-list-contract.integration.test.mjs.
|
|
89
|
+
/** @type {string | null} */
|
|
90
|
+
let lastLibraryIfNoneMatch = null;
|
|
91
|
+
/** @type {string | null} */
|
|
92
|
+
let lastLibrarySince = null;
|
|
93
|
+
let libraryRequestCount = 0;
|
|
94
|
+
|
|
83
95
|
// PR3a mutable slots for POST/DELETE library routes
|
|
84
96
|
//
|
|
85
97
|
// Each entry is `{ status: number, body: any }` describing the
|
|
@@ -486,18 +498,57 @@ export function createMockServer(initialPayload, options = {}) {
|
|
|
486
498
|
return;
|
|
487
499
|
}
|
|
488
500
|
|
|
489
|
-
// ETag short-circuit
|
|
501
|
+
// ETag short-circuit. Capture the inbound If-None-Match and
|
|
502
|
+
// `since` into inspectable slots BEFORE deciding 304-vs-200 so
|
|
503
|
+
// tests can assert on what the client actually sent — not just
|
|
504
|
+
// whether the response was 304. PR #1575's placement-presence
|
|
505
|
+
// fix makes the wire-level distinction load-bearing.
|
|
490
506
|
const ifNoneMatch = req.headers["if-none-match"];
|
|
507
|
+
const sinceParam = url.searchParams.get("since");
|
|
508
|
+
lastLibraryIfNoneMatch = ifNoneMatch ?? null;
|
|
509
|
+
lastLibrarySince = sinceParam ?? null;
|
|
510
|
+
libraryRequestCount++;
|
|
491
511
|
if (libraryEtag && ifNoneMatch === libraryEtag) {
|
|
492
512
|
res.writeHead(304, { ETag: libraryEtag });
|
|
493
513
|
res.end();
|
|
494
514
|
return;
|
|
495
515
|
}
|
|
496
516
|
|
|
517
|
+
// Mirror the production server's `since` filter — only return
|
|
518
|
+
// skills updated AFTER the `since` timestamp. Pre-PR #1575 the
|
|
519
|
+
// mock returned every skill regardless of `since`, which hid a
|
|
520
|
+
// real production bug: dropping the ETag without ALSO dropping
|
|
521
|
+
// `since` made the server respond with an empty delta, leaving
|
|
522
|
+
// newly-detected vendor placements empty forever. Mirroring the
|
|
523
|
+
// filter here means any future regression of that class fails
|
|
524
|
+
// at test time. See src/lib/queries/library.ts in the server
|
|
525
|
+
// for the production behavior (`gt(skills.updatedAt, since)`).
|
|
526
|
+
let respondedSkills = libraryResponse.skills ?? [];
|
|
527
|
+
if (sinceParam && Array.isArray(respondedSkills)) {
|
|
528
|
+
const sinceDate = new Date(sinceParam);
|
|
529
|
+
if (!Number.isNaN(sinceDate.getTime())) {
|
|
530
|
+
respondedSkills = respondedSkills.filter((s) => {
|
|
531
|
+
// updatedAt is a string ISO timestamp in the fixture skills.
|
|
532
|
+
// Missing/invalid → treat as "older than since" → exclude
|
|
533
|
+
// (matches the production semantic of "not modified in
|
|
534
|
+
// the window").
|
|
535
|
+
if (typeof s.updatedAt !== "string") return false;
|
|
536
|
+
const updated = new Date(s.updatedAt);
|
|
537
|
+
if (Number.isNaN(updated.getTime())) return false;
|
|
538
|
+
return updated.getTime() > sinceDate.getTime();
|
|
539
|
+
});
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
const filteredResponse = {
|
|
544
|
+
...libraryResponse,
|
|
545
|
+
skills: respondedSkills,
|
|
546
|
+
};
|
|
547
|
+
|
|
497
548
|
const headers = { "Content-Type": "application/json" };
|
|
498
549
|
if (libraryEtag) headers.ETag = libraryEtag;
|
|
499
550
|
res.writeHead(200, headers);
|
|
500
|
-
res.end(JSON.stringify(
|
|
551
|
+
res.end(JSON.stringify(filteredResponse));
|
|
501
552
|
return;
|
|
502
553
|
}
|
|
503
554
|
|
|
@@ -586,6 +637,45 @@ export function createMockServer(initialPayload, options = {}) {
|
|
|
586
637
|
libraryEtag = etag;
|
|
587
638
|
},
|
|
588
639
|
|
|
640
|
+
/**
|
|
641
|
+
* Inspect the inbound `If-None-Match` header from the LAST request
|
|
642
|
+
* to GET /api/v1/library. Returns the literal header string, or
|
|
643
|
+
* `null` if the request didn't send one (full-fetch path). Used
|
|
644
|
+
* by tests asserting that the placement-presence check correctly
|
|
645
|
+
* dropped or sent the ETag header. See PR #1575's coverage gap
|
|
646
|
+
* 2 — without this accessor, the "304 fires" tests could pass on
|
|
647
|
+
* an implementation that broke the wire-level optimization while
|
|
648
|
+
* preserving the user-facing "up to date" output.
|
|
649
|
+
*/
|
|
650
|
+
getLastLibraryIfNoneMatch() {
|
|
651
|
+
return lastLibraryIfNoneMatch;
|
|
652
|
+
},
|
|
653
|
+
|
|
654
|
+
/** Count of GET /api/v1/library requests since server start or reset. */
|
|
655
|
+
getLibraryRequestCount() {
|
|
656
|
+
return libraryRequestCount;
|
|
657
|
+
},
|
|
658
|
+
|
|
659
|
+
/**
|
|
660
|
+
* Inspect the inbound `?since=` query param from the LAST request
|
|
661
|
+
* to GET /api/v1/library. Returns the literal string or `null` if
|
|
662
|
+
* not present. The placement-presence fix must drop BOTH the
|
|
663
|
+
* If-None-Match header AND the `since` query param when placements
|
|
664
|
+
* are incomplete — otherwise the server returns an empty delta
|
|
665
|
+
* and missing placements stay empty. Tests use this to assert the
|
|
666
|
+
* fix at the wire level.
|
|
667
|
+
*/
|
|
668
|
+
getLastLibrarySince() {
|
|
669
|
+
return lastLibrarySince;
|
|
670
|
+
},
|
|
671
|
+
|
|
672
|
+
/** Reset the inspection slots — useful between phases of a multi-step test. */
|
|
673
|
+
resetLibraryInspection() {
|
|
674
|
+
lastLibraryIfNoneMatch = null;
|
|
675
|
+
lastLibrarySince = null;
|
|
676
|
+
libraryRequestCount = 0;
|
|
677
|
+
},
|
|
678
|
+
|
|
589
679
|
/** Register a single-skill response keyed by `owner/name`. */
|
|
590
680
|
setSkillResponse(owner, name, skill) {
|
|
591
681
|
skillResponses.set(`${owner}/${name}`, skill);
|
|
@@ -80,7 +80,7 @@ const VALID_KEY = "sk_live_test";
|
|
|
80
80
|
* helper the unit tests use; replicated here so changes in either
|
|
81
81
|
* place don't silently break the other.
|
|
82
82
|
*/
|
|
83
|
-
function makeSkill(owner, name, version = "1.0.0") {
|
|
83
|
+
function makeSkill(owner, name, version = "1.0.0", updatedAt) {
|
|
84
84
|
const content = `---\nname: ${name}\ndescription: ${name} description\n---\n\nbody\n`;
|
|
85
85
|
return {
|
|
86
86
|
owner,
|
|
@@ -99,7 +99,14 @@ function makeSkill(owner, name, version = "1.0.0") {
|
|
|
99
99
|
contentType: "text/markdown",
|
|
100
100
|
},
|
|
101
101
|
],
|
|
102
|
-
|
|
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(),
|
|
103
110
|
};
|
|
104
111
|
}
|
|
105
112
|
|
|
@@ -434,13 +441,18 @@ describe("v1 → v2 .last-sync migration round-trip", () => {
|
|
|
434
441
|
assert.match(entry.filesSha256, /^[a-f0-9]{64}$/, "filesSha256 must be a hex SHA-256");
|
|
435
442
|
});
|
|
436
443
|
|
|
437
|
-
it("v1 state with NO content changes
|
|
438
|
-
//
|
|
439
|
-
//
|
|
440
|
-
//
|
|
441
|
-
//
|
|
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.
|
|
444
456
|
const v1Path = globalLastSyncPath();
|
|
445
457
|
mkdirSync(join(v1Path, ".."), { recursive: true });
|
|
446
458
|
writeFileSync(
|
|
@@ -460,27 +472,54 @@ describe("v1 → v2 .last-sync migration round-trip", () => {
|
|
|
460
472
|
syncedAt: "2026-05-19T00:00:00Z",
|
|
461
473
|
});
|
|
462
474
|
|
|
463
|
-
//
|
|
464
|
-
//
|
|
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();
|
|
465
479
|
await runUpdate(["--key", VALID_KEY, "--url", serverUrl], { stdout });
|
|
466
|
-
|
|
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
|
+
);
|
|
467
504
|
|
|
468
|
-
//
|
|
469
|
-
//
|
|
470
|
-
//
|
|
471
|
-
const
|
|
472
|
-
|
|
473
|
-
assert.
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
//
|
|
477
|
-
//
|
|
478
|
-
//
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
assert.
|
|
482
|
-
|
|
483
|
-
|
|
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
|
+
);
|
|
484
523
|
});
|
|
485
524
|
});
|
|
486
525
|
|
|
@@ -1016,3 +1055,511 @@ describe("additional coverage from production-readiness audit", () => {
|
|
|
1016
1055
|
}
|
|
1017
1056
|
});
|
|
1018
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
|
+
});
|
|
@@ -38,6 +38,7 @@ import {
|
|
|
38
38
|
runSync,
|
|
39
39
|
readLastSync,
|
|
40
40
|
writeLastSync,
|
|
41
|
+
placementsAreComplete,
|
|
41
42
|
LAST_SYNC_SCHEMA_VERSION,
|
|
42
43
|
} from "../../lib/sync.mjs";
|
|
43
44
|
import { resolvePlacementDir } from "../../lib/file-write.mjs";
|
|
@@ -780,6 +781,48 @@ describe("runSync — ETag round-trip", () => {
|
|
|
780
781
|
const after = readFileSync(join(dir, "SKILL.md"), "utf-8");
|
|
781
782
|
assert.equal(before, after);
|
|
782
783
|
});
|
|
784
|
+
|
|
785
|
+
it("forced re-fetch (placementsAreComplete=false) sets fullSync=false (prior state existed)", async () => {
|
|
786
|
+
// Locks in the semantic contract from QA Round 2 Gap 5: a forced
|
|
787
|
+
// full re-fetch (placementsAreComplete returns false because a
|
|
788
|
+
// placement was missing) does NOT bump fullSync to true. fullSync
|
|
789
|
+
// is determined by whether prior `syncedAt` existed, not by
|
|
790
|
+
// whether the server returned a delta or full payload. The
|
|
791
|
+
// distinction matters to init.mjs which interprets the
|
|
792
|
+
// `fullSync` × `counters-all-zero` combination — for an `init`
|
|
793
|
+
// that hits a placement-incomplete state, counters won't be all
|
|
794
|
+
// zero (the missing skill produces added>=1), so the consumer
|
|
795
|
+
// distinction is preserved. This test pins that behavior.
|
|
796
|
+
server.setEtag('"v1"');
|
|
797
|
+
server.setLibraryResponse({
|
|
798
|
+
skills: [makeSkill("forced")],
|
|
799
|
+
removals: [],
|
|
800
|
+
syncedAt: "2025-01-01T00:00:00Z",
|
|
801
|
+
});
|
|
802
|
+
await runSync({ serverUrl, apiKey: VALID_KEY, vendors: ["claudeCode"] });
|
|
803
|
+
|
|
804
|
+
// Delete the placement on disk — forces re-fetch on next runSync.
|
|
805
|
+
rmSync(resolvePlacementDir("claudeProject", "forced"), {
|
|
806
|
+
recursive: true,
|
|
807
|
+
force: true,
|
|
808
|
+
});
|
|
809
|
+
|
|
810
|
+
const result = await runSync({
|
|
811
|
+
serverUrl,
|
|
812
|
+
apiKey: VALID_KEY,
|
|
813
|
+
vendors: ["claudeCode"],
|
|
814
|
+
});
|
|
815
|
+
// Prior syncedAt was set → fullSync stays false even though a
|
|
816
|
+
// full re-fetch happened. This preserves the init.mjs consumer
|
|
817
|
+
// semantic: fullSync=true means "no prior sync state existed",
|
|
818
|
+
// not "this sync returned a full library payload."
|
|
819
|
+
assert.equal(result.fullSync, false);
|
|
820
|
+
// The forced re-fetch DOES produce a non-zero write count —
|
|
821
|
+
// which is why the behavioral consequence of the semantic
|
|
822
|
+
// mismatch is unreachable. Documents that fact.
|
|
823
|
+
assert.equal(result.added, 1, "forced re-fetch must restore the missing skill");
|
|
824
|
+
assert.equal(result.notModified, false);
|
|
825
|
+
});
|
|
783
826
|
});
|
|
784
827
|
|
|
785
828
|
// ── runSync — argument validation ──────────────────────────────────────
|
|
@@ -1093,3 +1136,180 @@ describe("runSync — cohort placement classification", () => {
|
|
|
1093
1136
|
);
|
|
1094
1137
|
});
|
|
1095
1138
|
});
|
|
1139
|
+
|
|
1140
|
+
// ───────────────────────────────────────────────────────────────────
|
|
1141
|
+
// placementsAreComplete unit tests (PR #1575 hotfix)
|
|
1142
|
+
// ───────────────────────────────────────────────────────────────────
|
|
1143
|
+
//
|
|
1144
|
+
// Direct unit coverage for the function that decides whether the ETag
|
|
1145
|
+
// short-circuit is safe. The integration tests in
|
|
1146
|
+
// update-list-contract.integration.test.mjs exercise the function
|
|
1147
|
+
// end-to-end through runSync; this suite covers the branches:
|
|
1148
|
+
//
|
|
1149
|
+
// - empty/missing skills map (returns false — force re-fetch)
|
|
1150
|
+
// - empty/missing vendors (returns true — nothing to verify)
|
|
1151
|
+
// - placementTargetsFor throws (returns false — safe direction)
|
|
1152
|
+
// - malformed skill keys (skip without crash)
|
|
1153
|
+
// - skill present in EVERY target (returns true)
|
|
1154
|
+
// - skill missing in ONE target (returns false)
|
|
1155
|
+
// - directory exists but SKILL.md missing (returns false)
|
|
1156
|
+
|
|
1157
|
+
describe("placementsAreComplete — direct unit coverage", () => {
|
|
1158
|
+
beforeEach(() => {
|
|
1159
|
+
sandbox = mkdtempSync(join(tmpdir(), "cli-pac-"));
|
|
1160
|
+
mkdirSync(join(sandbox, "project"), { recursive: true });
|
|
1161
|
+
mkdirSync(join(sandbox, "home"), { recursive: true });
|
|
1162
|
+
originalCwd = process.cwd();
|
|
1163
|
+
originalHomeEnv = captureHome();
|
|
1164
|
+
process.chdir(join(sandbox, "project"));
|
|
1165
|
+
setSandboxHome(join(sandbox, "home"));
|
|
1166
|
+
});
|
|
1167
|
+
afterEach(() => {
|
|
1168
|
+
process.chdir(originalCwd);
|
|
1169
|
+
restoreHome(originalHomeEnv);
|
|
1170
|
+
if (sandbox) rmSync(sandbox, { recursive: true, force: true });
|
|
1171
|
+
});
|
|
1172
|
+
|
|
1173
|
+
function seedSkillMd(target, skillName) {
|
|
1174
|
+
const dir = resolvePlacementDir(target, skillName);
|
|
1175
|
+
mkdirSync(dir, { recursive: true });
|
|
1176
|
+
writeFileSync(
|
|
1177
|
+
join(dir, "SKILL.md"),
|
|
1178
|
+
`---\nname: ${skillName}\ndescription: x\n---\n`,
|
|
1179
|
+
"utf-8",
|
|
1180
|
+
);
|
|
1181
|
+
}
|
|
1182
|
+
|
|
1183
|
+
it("returns false on null skillsMap (can't trust empty baseline)", () => {
|
|
1184
|
+
assert.equal(placementsAreComplete(null, ["claudeCode"], false), false);
|
|
1185
|
+
});
|
|
1186
|
+
|
|
1187
|
+
it("returns false on undefined skillsMap", () => {
|
|
1188
|
+
assert.equal(placementsAreComplete(undefined, ["claudeCode"], false), false);
|
|
1189
|
+
});
|
|
1190
|
+
|
|
1191
|
+
it("returns false on empty skillsMap object (v1 → v2 recovery path)", () => {
|
|
1192
|
+
assert.equal(placementsAreComplete({}, ["claudeCode"], false), false);
|
|
1193
|
+
});
|
|
1194
|
+
|
|
1195
|
+
it("returns true on empty vendors array (no placements to verify)", () => {
|
|
1196
|
+
// Defensive guard — `requireVendorTargets` in the command layer
|
|
1197
|
+
// catches this case before runSync, but if it ever reached here
|
|
1198
|
+
// we'd return true (no targets means trivially complete).
|
|
1199
|
+
const skills = {
|
|
1200
|
+
"alice/skill": {
|
|
1201
|
+
version: "1.0.0",
|
|
1202
|
+
skillMdSha256: "a".repeat(64),
|
|
1203
|
+
filesSha256: "b".repeat(64),
|
|
1204
|
+
syncedAt: "x",
|
|
1205
|
+
},
|
|
1206
|
+
};
|
|
1207
|
+
assert.equal(placementsAreComplete(skills, [], false), true);
|
|
1208
|
+
});
|
|
1209
|
+
|
|
1210
|
+
it("returns true on undefined vendors (same defensive path)", () => {
|
|
1211
|
+
const skills = {
|
|
1212
|
+
"alice/skill": {
|
|
1213
|
+
version: "1.0.0",
|
|
1214
|
+
skillMdSha256: "a".repeat(64),
|
|
1215
|
+
filesSha256: "b".repeat(64),
|
|
1216
|
+
syncedAt: "x",
|
|
1217
|
+
},
|
|
1218
|
+
};
|
|
1219
|
+
assert.equal(placementsAreComplete(skills, undefined, false), true);
|
|
1220
|
+
});
|
|
1221
|
+
|
|
1222
|
+
it("returns true when every (skill, target) pair has its SKILL.md on disk", () => {
|
|
1223
|
+
seedSkillMd("claudeProject", "skill-a");
|
|
1224
|
+
const skills = {
|
|
1225
|
+
"alice/skill-a": {
|
|
1226
|
+
version: "1.0.0",
|
|
1227
|
+
skillMdSha256: "a".repeat(64),
|
|
1228
|
+
filesSha256: "b".repeat(64),
|
|
1229
|
+
syncedAt: "x",
|
|
1230
|
+
},
|
|
1231
|
+
};
|
|
1232
|
+
assert.equal(placementsAreComplete(skills, ["claudeCode"], false), true);
|
|
1233
|
+
});
|
|
1234
|
+
|
|
1235
|
+
it("returns false when ONE skill's SKILL.md is missing in ONE target", () => {
|
|
1236
|
+
// Two skills, claudeProject + agentsProject expected. Only one
|
|
1237
|
+
// skill landed in claudeProject; agentsProject has neither.
|
|
1238
|
+
seedSkillMd("claudeProject", "skill-a");
|
|
1239
|
+
const skills = {
|
|
1240
|
+
"alice/skill-a": {
|
|
1241
|
+
version: "1.0.0",
|
|
1242
|
+
skillMdSha256: "a".repeat(64),
|
|
1243
|
+
filesSha256: "b".repeat(64),
|
|
1244
|
+
syncedAt: "x",
|
|
1245
|
+
},
|
|
1246
|
+
};
|
|
1247
|
+
// Both claudeCode and cursor → both targets must have skill-a.
|
|
1248
|
+
// Only claudeProject does → returns false → forces re-fetch.
|
|
1249
|
+
assert.equal(
|
|
1250
|
+
placementsAreComplete(skills, ["claudeCode", "cursor"], false),
|
|
1251
|
+
false,
|
|
1252
|
+
);
|
|
1253
|
+
});
|
|
1254
|
+
|
|
1255
|
+
it("returns false when directory exists but SKILL.md is missing (partial dir guard)", () => {
|
|
1256
|
+
// The HIGH finding from the code-reviewer audit: an empty
|
|
1257
|
+
// directory satisfies existsSync but lacks the load-bearing
|
|
1258
|
+
// SKILL.md. The check probes SKILL.md specifically.
|
|
1259
|
+
const dir = resolvePlacementDir("claudeProject", "partial");
|
|
1260
|
+
mkdirSync(dir, { recursive: true });
|
|
1261
|
+
// NOTE: no writeFileSync — dir exists but is empty.
|
|
1262
|
+
const skills = {
|
|
1263
|
+
"alice/partial": {
|
|
1264
|
+
version: "1.0.0",
|
|
1265
|
+
skillMdSha256: "a".repeat(64),
|
|
1266
|
+
filesSha256: "b".repeat(64),
|
|
1267
|
+
syncedAt: "x",
|
|
1268
|
+
},
|
|
1269
|
+
};
|
|
1270
|
+
assert.equal(placementsAreComplete(skills, ["claudeCode"], false), false);
|
|
1271
|
+
});
|
|
1272
|
+
|
|
1273
|
+
it("returns false when placementTargetsFor throws (conservative direction)", () => {
|
|
1274
|
+
// copilot has globalTarget=null. `--global` with copilot throws
|
|
1275
|
+
// inside placementTargetsFor. placementsAreComplete catches the
|
|
1276
|
+
// throw and returns false so we drop the ETag and the
|
|
1277
|
+
// downstream write loop surfaces the same error with the
|
|
1278
|
+
// correct typed exception.
|
|
1279
|
+
const skills = {
|
|
1280
|
+
"alice/skill": {
|
|
1281
|
+
version: "1.0.0",
|
|
1282
|
+
skillMdSha256: "a".repeat(64),
|
|
1283
|
+
filesSha256: "b".repeat(64),
|
|
1284
|
+
syncedAt: "x",
|
|
1285
|
+
},
|
|
1286
|
+
};
|
|
1287
|
+
assert.equal(
|
|
1288
|
+
placementsAreComplete(skills, ["copilot"], true),
|
|
1289
|
+
false,
|
|
1290
|
+
);
|
|
1291
|
+
});
|
|
1292
|
+
|
|
1293
|
+
it("skips malformed skill keys (no slash) without crashing", () => {
|
|
1294
|
+
seedSkillMd("claudeProject", "good");
|
|
1295
|
+
const skills = {
|
|
1296
|
+
"alice/good": {
|
|
1297
|
+
version: "1.0.0",
|
|
1298
|
+
skillMdSha256: "a".repeat(64),
|
|
1299
|
+
filesSha256: "b".repeat(64),
|
|
1300
|
+
syncedAt: "x",
|
|
1301
|
+
},
|
|
1302
|
+
// Malformed key — no slash. Should be silently skipped so a
|
|
1303
|
+
// corrupt entry doesn't poison the whole check.
|
|
1304
|
+
"no-slash-key": {
|
|
1305
|
+
version: "1.0.0",
|
|
1306
|
+
skillMdSha256: "a".repeat(64),
|
|
1307
|
+
filesSha256: "b".repeat(64),
|
|
1308
|
+
syncedAt: "x",
|
|
1309
|
+
},
|
|
1310
|
+
};
|
|
1311
|
+
// Returns true because the valid skill is on disk and the
|
|
1312
|
+
// malformed entry is skipped (continue) — no false negative.
|
|
1313
|
+
assert.equal(placementsAreComplete(skills, ["claudeCode"], false), true);
|
|
1314
|
+
});
|
|
1315
|
+
});
|