skillrepo 4.5.2 → 4.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/commands/list.mjs +8 -1
- package/src/lib/http.mjs +72 -4
- package/src/lib/sync.mjs +112 -4
- package/src/test/commands/list.test.mjs +14 -0
- package/src/test/e2e/mock-server.mjs +93 -0
- package/src/test/lib/http.test.mjs +103 -0
- package/src/test/lib/mcp-merge.test.mjs +2 -2
- package/src/test/lib/sync.test.mjs +299 -0
- package/src/test/mergers/claude-mcp.test.mjs +4 -4
- package/src/test/mergers/uninstall-claude-mcp.test.mjs +1 -1
- package/src/test/mergers/uninstall-vscode-mcp.test.mjs +1 -1
package/package.json
CHANGED
package/src/commands/list.mjs
CHANGED
|
@@ -56,7 +56,14 @@ export async function runList(argv, io = {}) {
|
|
|
56
56
|
const stdout = io.stdout ?? process.stdout;
|
|
57
57
|
const flags = resolveFlags(argv);
|
|
58
58
|
|
|
59
|
-
|
|
59
|
+
// `list` is a read-only drift check — it must NEVER set sync state.
|
|
60
|
+
// Use the manifest read (#1832): metadata only, no file bodies, and the
|
|
61
|
+
// server records no delivery for it. Per-skill drift is computed from
|
|
62
|
+
// on-disk SHAs + `.last-sync` below, never from the response body, so
|
|
63
|
+
// the empty `files` array a manifest returns is irrelevant here.
|
|
64
|
+
const libraryResponse = await getLibrary(flags.serverUrl, flags.apiKey, {
|
|
65
|
+
manifest: true,
|
|
66
|
+
});
|
|
60
67
|
|
|
61
68
|
// Defensive guard: `list` calls getLibrary without an If-None-Match
|
|
62
69
|
// header so it can't legitimately receive a 304 today. But getLibrary's
|
package/src/lib/http.mjs
CHANGED
|
@@ -305,10 +305,20 @@ async function mapErrorResponse(res, url) {
|
|
|
305
305
|
if (res.status === 401) {
|
|
306
306
|
// Derive the auth URL from the request URL's origin so users
|
|
307
307
|
// running against staging see a staging hint, not a prod hint.
|
|
308
|
-
// `url` here is the full request URL
|
|
309
|
-
// https://staging.skillrepo.dev/api/v1/library
|
|
310
|
-
//
|
|
311
|
-
// `
|
|
308
|
+
// `url` here is the full request URL — typically
|
|
309
|
+
// `https://staging.skillrepo.dev/api/v1/library` (canonical) or
|
|
310
|
+
// `https://api.staging.skillrepo.dev/v1/library` (subdomain alias
|
|
311
|
+
// per #1588). `new URL(...).origin` strips back to the host —
|
|
312
|
+
// `https://staging.skillrepo.dev` or `https://api.staging.skillrepo.dev`
|
|
313
|
+
// — and `cliAuthUrl` builds the `/cli/auth` URL from there.
|
|
314
|
+
//
|
|
315
|
+
// Known limitation: if the request URL uses the api.* / mcp.*
|
|
316
|
+
// subdomain, the resulting auth hint URL points at that subdomain
|
|
317
|
+
// (`https://api.staging.skillrepo.dev/cli/auth`), which 404s
|
|
318
|
+
// because /cli/auth only lives on the canonical host. Today the
|
|
319
|
+
// CLI defaults to the canonical URL so this is latent; surfaces
|
|
320
|
+
// only when a user passes `--url https://api.<env>.skillrepo.dev`
|
|
321
|
+
// explicitly. Tracked separately — not blocking this PR.
|
|
312
322
|
let authUrl = DEFAULT_CLI_AUTH_URL;
|
|
313
323
|
try {
|
|
314
324
|
authUrl = cliAuthUrl(new URL(url).origin);
|
|
@@ -526,12 +536,20 @@ export async function validateAccessKey(serverUrl, apiKey, source = "validate")
|
|
|
526
536
|
* @param {object} [opts]
|
|
527
537
|
* @param {string} [opts.ifNoneMatch] - ETag to send as If-None-Match
|
|
528
538
|
* @param {string} [opts.since] - ISO timestamp for delta filter
|
|
539
|
+
* @param {boolean} [opts.manifest] - Request a metadata-only manifest:
|
|
540
|
+
* the server returns empty `files` for every skill and records NO
|
|
541
|
+
* delivery. Used by `skillrepo list` for a read-only drift check that
|
|
542
|
+
* must not set sync state (#1832).
|
|
529
543
|
* @returns {Promise<LibrarySyncResult>}
|
|
530
544
|
*/
|
|
531
545
|
export async function getLibrary(serverUrl, apiKey, opts = {}) {
|
|
532
546
|
const base = normalizeUrl(serverUrl);
|
|
533
547
|
const params = new URLSearchParams();
|
|
534
548
|
if (opts.since) params.set("since", opts.since);
|
|
549
|
+
// Manifest mode (#1832): metadata-only, non-recording read used by
|
|
550
|
+
// `skillrepo list`. A peek must not set sync state, so the server
|
|
551
|
+
// returns empty `files` and records no delivery for this request.
|
|
552
|
+
if (opts.manifest) params.set("manifest", "1");
|
|
535
553
|
const url = params.toString()
|
|
536
554
|
? `${base}/api/v1/library?${params.toString()}`
|
|
537
555
|
: `${base}/api/v1/library`;
|
|
@@ -573,6 +591,56 @@ export async function getLibrary(serverUrl, apiKey, opts = {}) {
|
|
|
573
591
|
};
|
|
574
592
|
}
|
|
575
593
|
|
|
594
|
+
/**
|
|
595
|
+
* POST /api/v1/library/sync-receipts — report confirmed local writes (#1832).
|
|
596
|
+
*
|
|
597
|
+
* Called by `runSync` AFTER it successfully writes skill files to disk.
|
|
598
|
+
* Carries the (owner, name, version) pairs actually written so the server
|
|
599
|
+
* records sync state from a confirmed write, never from a fetch response.
|
|
600
|
+
*
|
|
601
|
+
* Best-effort + retryable: the receipt is telemetry and the files are
|
|
602
|
+
* already on disk by the time we call this, so callers treat a failure as
|
|
603
|
+
* a non-fatal warning. Transient 5xx / network failures retry — the server
|
|
604
|
+
* dedups by `(user, skill, version, UTC day)` and stamps idempotently, so
|
|
605
|
+
* a replay is safe. A non-2xx after retries throws a typed error.
|
|
606
|
+
*
|
|
607
|
+
* @param {string} serverUrl
|
|
608
|
+
* @param {string} apiKey
|
|
609
|
+
* @param {object} receipt
|
|
610
|
+
* @param {{owner: string, name: string, version: string}[]} receipt.skills
|
|
611
|
+
* The skills written to disk this sync. Empty array is a valid no-op.
|
|
612
|
+
* @param {string} receipt.syncedAt - ISO timestamp of the sync.
|
|
613
|
+
* @returns {Promise<{recorded: number}>}
|
|
614
|
+
*/
|
|
615
|
+
export async function postSyncReceipt(serverUrl, apiKey, receipt) {
|
|
616
|
+
const url = `${normalizeUrl(serverUrl)}/api/v1/library/sync-receipts`;
|
|
617
|
+
const res = await safeFetch(url, {
|
|
618
|
+
method: "POST",
|
|
619
|
+
headers: {
|
|
620
|
+
...(await authHeaders(apiKey)),
|
|
621
|
+
"Content-Type": "application/json",
|
|
622
|
+
},
|
|
623
|
+
body: JSON.stringify({
|
|
624
|
+
syncedAt: receipt.syncedAt,
|
|
625
|
+
skills: receipt.skills,
|
|
626
|
+
}),
|
|
627
|
+
// Idempotent server-side (dedup bucket + idempotent UPDATE), so a
|
|
628
|
+
// transient 5xx / network blip is safe to retry.
|
|
629
|
+
retry: true,
|
|
630
|
+
});
|
|
631
|
+
if (!res.ok) {
|
|
632
|
+
const err = await mapErrorResponse(res, url);
|
|
633
|
+
if (err === null) {
|
|
634
|
+
throw validationError(
|
|
635
|
+
`The server at ${normalizeUrl(serverUrl)} does not expose ` +
|
|
636
|
+
`/api/v1/library/sync-receipts. Is --url correct?`,
|
|
637
|
+
);
|
|
638
|
+
}
|
|
639
|
+
throw err;
|
|
640
|
+
}
|
|
641
|
+
return parseJsonOrThrow(res, url);
|
|
642
|
+
}
|
|
643
|
+
|
|
576
644
|
// ── /api/v1/skills/[owner]/[name] ───────────────────────────────────────
|
|
577
645
|
|
|
578
646
|
/**
|
package/src/lib/sync.mjs
CHANGED
|
@@ -69,7 +69,14 @@
|
|
|
69
69
|
* render accurate user messages (see
|
|
70
70
|
* init.mjs step 7).
|
|
71
71
|
* @property {string} [syncedAt]
|
|
72
|
-
* @property {string} syncedAt - ISO timestamp
|
|
72
|
+
* @property {string} syncedAt - ISO timestamp of the sync: the server
|
|
73
|
+
* response `syncedAt` on a 200, or the
|
|
74
|
+
* previously-cached sync timestamp on a
|
|
75
|
+
* 304 (the server sends no body). NOTE:
|
|
76
|
+
* this is distinct from the re-assertion
|
|
77
|
+
* receipt's own timestamp on a 304, which
|
|
78
|
+
* is the CLI's "now" (a liveness signal),
|
|
79
|
+
* not this cached value.
|
|
73
80
|
*
|
|
74
81
|
* @typedef {Object} SyncedSkillEntry
|
|
75
82
|
* @property {string} version - Version of the skill as synced (e.g. "1.4.0").
|
|
@@ -87,7 +94,7 @@
|
|
|
87
94
|
import { existsSync, readFileSync } from "node:fs";
|
|
88
95
|
import { join } from "node:path";
|
|
89
96
|
|
|
90
|
-
import { getLibrary } from "./http.mjs";
|
|
97
|
+
import { getLibrary, postSyncReceipt } from "./http.mjs";
|
|
91
98
|
import {
|
|
92
99
|
writeSkillDir,
|
|
93
100
|
removeSkillDir,
|
|
@@ -417,7 +424,8 @@ export function deleteLastSyncEntry(owner, name) {
|
|
|
417
424
|
* 2. Read the last-sync state file
|
|
418
425
|
* 3. Call GET /api/v1/library with `If-None-Match` (if we have a
|
|
419
426
|
* cached ETag) AND `since` (always, for delta semantics)
|
|
420
|
-
* 4.
|
|
427
|
+
* 4. On 304 — re-assert the on-disk state via a receipt (liveness +
|
|
428
|
+
* heal) and short-circuit with `notModified: true`
|
|
421
429
|
* 5. For each skill in the response:
|
|
422
430
|
* a. Write it via `writeSkillDir` — overwrites if changed
|
|
423
431
|
* b. Skip writing skills with `filesIncomplete: true` (see comment)
|
|
@@ -425,7 +433,9 @@ export function deleteLastSyncEntry(owner, name) {
|
|
|
425
433
|
* a. Call `removeSkillDir` — deletes from all configured targets
|
|
426
434
|
* 7. Persist the new ETag (if the response was complete) for the
|
|
427
435
|
* next sync
|
|
428
|
-
* 8.
|
|
436
|
+
* 8. Re-assert the full on-disk state via a sync receipt (#1832) —
|
|
437
|
+
* the confirmed-write signal that sets team sync state
|
|
438
|
+
* 9. Return the summary
|
|
429
439
|
*
|
|
430
440
|
* Error handling: any thrown error from the network or filesystem
|
|
431
441
|
* layer propagates unchanged. The caller (a command) decides how to
|
|
@@ -536,6 +546,23 @@ export async function runSync(options) {
|
|
|
536
546
|
|
|
537
547
|
// Step 4: 304 short-circuit
|
|
538
548
|
if (result.notModified) {
|
|
549
|
+
// Re-assert the on-disk state even though nothing changed (#1832).
|
|
550
|
+
// The library is unchanged, so what's on disk is exactly the prior
|
|
551
|
+
// `.last-sync` skills map. This receipt:
|
|
552
|
+
// • is the liveness signal that REPLACES the GET-side 304 heartbeat
|
|
553
|
+
// for receipt-era servers (>= 4.7.0 CLIs no longer trigger it), so
|
|
554
|
+
// a deliberately-stable library doesn't drift to "stale"; and
|
|
555
|
+
// • heals any delivery the server missed (a receipt POST that failed
|
|
556
|
+
// on a prior sync), correcting drift in the safe direction.
|
|
557
|
+
// Timestamp is the CLI's "now" so freshness advances; the server's
|
|
558
|
+
// 5-minute future tolerance absorbs ordinary clock skew. Best-effort.
|
|
559
|
+
await reportReceipt(
|
|
560
|
+
serverUrl,
|
|
561
|
+
apiKey,
|
|
562
|
+
receiptEntriesFromSkillsMap(lastSync?.skills),
|
|
563
|
+
new Date().toISOString(),
|
|
564
|
+
stderr,
|
|
565
|
+
);
|
|
539
566
|
return {
|
|
540
567
|
added: 0,
|
|
541
568
|
updated: 0,
|
|
@@ -696,11 +723,92 @@ export async function runSync(options) {
|
|
|
696
723
|
}
|
|
697
724
|
}
|
|
698
725
|
|
|
726
|
+
// Re-assert the full on-disk state via a sync receipt (#1832). This
|
|
727
|
+
// confirmed-write signal — not the fetch itself — is what sets team
|
|
728
|
+
// sync state. We post the COMPLETE post-sync on-disk set (`skillsMap`:
|
|
729
|
+
// carry-forward skills + the skills written this round), not just what
|
|
730
|
+
// changed, so the receipt heals any delivery the server missed (a prior
|
|
731
|
+
// receipt POST that failed) and re-stamps freshness, replacing the
|
|
732
|
+
// GET-side heartbeat. `skillsMap` already reflects exactly what is on
|
|
733
|
+
// disk: skills skipped for `filesIncomplete` keep their prior carry-
|
|
734
|
+
// forward version (still on disk) and tombstoned skills were deleted
|
|
735
|
+
// from the map, so a partial or delta sync still re-asserts precisely
|
|
736
|
+
// what landed on disk. The server dedups (user, skill, version, UTC
|
|
737
|
+
// day), so re-asserting unchanged versions doesn't bloat the event log.
|
|
738
|
+
// Best-effort: files are already written, so a failed receipt is a
|
|
739
|
+
// non-fatal warning, never a sync failure.
|
|
740
|
+
await reportReceipt(
|
|
741
|
+
serverUrl,
|
|
742
|
+
apiKey,
|
|
743
|
+
receiptEntriesFromSkillsMap(skillsMap),
|
|
744
|
+
result.syncedAt,
|
|
745
|
+
stderr,
|
|
746
|
+
);
|
|
747
|
+
|
|
699
748
|
return summary;
|
|
700
749
|
}
|
|
701
750
|
|
|
702
751
|
// ── Internals ──────────────────────────────────────────────────────────
|
|
703
752
|
|
|
753
|
+
/**
|
|
754
|
+
* Build sync-receipt entries from a `.last-sync` skills map (#1832).
|
|
755
|
+
*
|
|
756
|
+
* Each key is `"<owner>/<name>"`; the value carries the version label of
|
|
757
|
+
* the skill as written to disk. The returned `[{ owner, name, version }]`
|
|
758
|
+
* array is the CLI's assertion of what it actually has on disk — the
|
|
759
|
+
* evidence the server treats as the single source of truth for sync
|
|
760
|
+
* state. Entries with no version label are skipped: a skill with no
|
|
761
|
+
* published version can't resolve to a delivery server-side. Malformed
|
|
762
|
+
* keys (no `/`, empty owner/name) are skipped defensively rather than
|
|
763
|
+
* producing a bogus receipt entry.
|
|
764
|
+
*
|
|
765
|
+
* @param {Record<string, {version?: string}> | null | undefined} skillsMap
|
|
766
|
+
* @returns {{owner: string, name: string, version: string}[]}
|
|
767
|
+
*/
|
|
768
|
+
function receiptEntriesFromSkillsMap(skillsMap) {
|
|
769
|
+
/** @type {{owner: string, name: string, version: string}[]} */
|
|
770
|
+
const entries = [];
|
|
771
|
+
if (!skillsMap || typeof skillsMap !== "object") return entries;
|
|
772
|
+
for (const [key, entry] of Object.entries(skillsMap)) {
|
|
773
|
+
if (!entry || typeof entry !== "object") continue;
|
|
774
|
+
const version = typeof entry.version === "string" ? entry.version : "";
|
|
775
|
+
if (version === "") continue;
|
|
776
|
+
const slashAt = key.indexOf("/");
|
|
777
|
+
if (slashAt < 0) continue;
|
|
778
|
+
const owner = key.slice(0, slashAt);
|
|
779
|
+
const name = key.slice(slashAt + 1);
|
|
780
|
+
if (!owner || !name) continue;
|
|
781
|
+
entries.push({ owner, name, version });
|
|
782
|
+
}
|
|
783
|
+
return entries;
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
/**
|
|
787
|
+
* Post a sync receipt, best-effort (#1832). A no-op when `skills` is
|
|
788
|
+
* empty (nothing on disk to assert — e.g. an empty library). The skill
|
|
789
|
+
* files are already on disk and the server dedups replays, so a failed
|
|
790
|
+
* receipt is a non-fatal warning surfaced via the injected stderr
|
|
791
|
+
* stream — never a sync failure.
|
|
792
|
+
*
|
|
793
|
+
* @param {string} serverUrl
|
|
794
|
+
* @param {string} apiKey
|
|
795
|
+
* @param {{owner: string, name: string, version: string}[]} skills
|
|
796
|
+
* @param {string} syncedAt - ISO timestamp for the delivery + dedup key.
|
|
797
|
+
* @param {NodeJS.WritableStream} stderr
|
|
798
|
+
*/
|
|
799
|
+
async function reportReceipt(serverUrl, apiKey, skills, syncedAt, stderr) {
|
|
800
|
+
if (skills.length === 0) return;
|
|
801
|
+
try {
|
|
802
|
+
await postSyncReceipt(serverUrl, apiKey, { skills, syncedAt });
|
|
803
|
+
} catch (err) {
|
|
804
|
+
stderr.write(
|
|
805
|
+
` warning: failed to report sync receipt (${err.message}). ` +
|
|
806
|
+
`Your skills are synced locally; team sync status may lag until ` +
|
|
807
|
+
`your next sync.\n`,
|
|
808
|
+
);
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
|
|
704
812
|
/**
|
|
705
813
|
* Check whether ANY of the configured placement targets already
|
|
706
814
|
* contains a directory for the given skill name. Used to distinguish
|
|
@@ -92,6 +92,20 @@ describe("runList — happy path", () => {
|
|
|
92
92
|
assert.match(out, /2 skills in your library/);
|
|
93
93
|
});
|
|
94
94
|
|
|
95
|
+
it("makes a non-recording manifest read (#1832)", async () => {
|
|
96
|
+
server.setLibraryResponse({
|
|
97
|
+
skills: [makeSkill("alice", "pdf-helper")],
|
|
98
|
+
removals: [],
|
|
99
|
+
syncedAt: "x",
|
|
100
|
+
});
|
|
101
|
+
await runList(["--key", VALID_KEY, "--url", serverUrl], { stdout });
|
|
102
|
+
// `list` is a peek — it must hit the metadata-only manifest path so
|
|
103
|
+
// the server records no delivery for it...
|
|
104
|
+
assert.equal(server.getLastLibraryManifest(), "1");
|
|
105
|
+
// ...and it never posts a sync receipt (that's a write-confirm signal).
|
|
106
|
+
assert.equal(server.getReceiptRequestCount(), 0);
|
|
107
|
+
});
|
|
108
|
+
|
|
95
109
|
it("sorts alphabetically by owner then name", async () => {
|
|
96
110
|
server.setLibraryResponse({
|
|
97
111
|
skills: [
|
|
@@ -91,6 +91,19 @@ export function createMockServer(initialPayload, options = {}) {
|
|
|
91
91
|
/** @type {string | null} */
|
|
92
92
|
let lastLibrarySince = null;
|
|
93
93
|
let libraryRequestCount = 0;
|
|
94
|
+
// #1832 — `?manifest=1` capture for the GET /api/v1/library route so
|
|
95
|
+
// tests can assert `skillrepo list` made the non-recording manifest read.
|
|
96
|
+
/** @type {string | null} */
|
|
97
|
+
let lastLibraryManifest = null;
|
|
98
|
+
|
|
99
|
+
// #1832 — POST /api/v1/library/sync-receipts inspection + response slot.
|
|
100
|
+
// `lastReceipt` holds the most recent JSON-parsed receipt body;
|
|
101
|
+
// `receiptResponse` overrides the default 200 so tests can simulate
|
|
102
|
+
// failures (the CLI treats a failed receipt as a non-fatal warning).
|
|
103
|
+
let lastReceipt = null;
|
|
104
|
+
let receiptRequestCount = 0;
|
|
105
|
+
/** @type {{ status: number, body: any } | null} */
|
|
106
|
+
let receiptResponse = null;
|
|
94
107
|
|
|
95
108
|
// PR3a mutable slots for POST/DELETE library routes
|
|
96
109
|
//
|
|
@@ -274,6 +287,41 @@ export function createMockServer(initialPayload, options = {}) {
|
|
|
274
287
|
return;
|
|
275
288
|
}
|
|
276
289
|
|
|
290
|
+
// ── #1832: POST /api/v1/library/sync-receipts (confirmed-write) ─
|
|
291
|
+
//
|
|
292
|
+
// The CLI posts this after `runSync` writes skills to disk. Default
|
|
293
|
+
// 200 echoes a `recorded` count; tests can override via
|
|
294
|
+
// setReceiptResponse to exercise the non-fatal-warning path.
|
|
295
|
+
if (
|
|
296
|
+
url.pathname === "/api/v1/library/sync-receipts" &&
|
|
297
|
+
req.method === "POST"
|
|
298
|
+
) {
|
|
299
|
+
if (!checkAuth(req, res)) return;
|
|
300
|
+
let body;
|
|
301
|
+
try {
|
|
302
|
+
body = rawBody.length > 0 ? JSON.parse(rawBody) : {};
|
|
303
|
+
} catch {
|
|
304
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
305
|
+
res.end(
|
|
306
|
+
JSON.stringify({ error: "Body must be JSON", code: "bad_request" }),
|
|
307
|
+
);
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
lastReceipt = body;
|
|
311
|
+
receiptRequestCount++;
|
|
312
|
+
if (receiptResponse) {
|
|
313
|
+
res.writeHead(receiptResponse.status, {
|
|
314
|
+
"Content-Type": "application/json",
|
|
315
|
+
});
|
|
316
|
+
res.end(JSON.stringify(receiptResponse.body ?? {}));
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
const recorded = Array.isArray(body?.skills) ? body.skills.length : 0;
|
|
320
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
321
|
+
res.end(JSON.stringify({ recorded }));
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
|
|
277
325
|
// ── #1452: POST /api/v1/library (multipart file-push) ──────────
|
|
278
326
|
//
|
|
279
327
|
// Accepts multipart/form-data and returns a synthesized
|
|
@@ -507,6 +555,7 @@ export function createMockServer(initialPayload, options = {}) {
|
|
|
507
555
|
const sinceParam = url.searchParams.get("since");
|
|
508
556
|
lastLibraryIfNoneMatch = ifNoneMatch ?? null;
|
|
509
557
|
lastLibrarySince = sinceParam ?? null;
|
|
558
|
+
lastLibraryManifest = url.searchParams.get("manifest");
|
|
510
559
|
libraryRequestCount++;
|
|
511
560
|
if (libraryEtag && ifNoneMatch === libraryEtag) {
|
|
512
561
|
res.writeHead(304, { ETag: libraryEtag });
|
|
@@ -673,9 +722,53 @@ export function createMockServer(initialPayload, options = {}) {
|
|
|
673
722
|
resetLibraryInspection() {
|
|
674
723
|
lastLibraryIfNoneMatch = null;
|
|
675
724
|
lastLibrarySince = null;
|
|
725
|
+
lastLibraryManifest = null;
|
|
676
726
|
libraryRequestCount = 0;
|
|
677
727
|
},
|
|
678
728
|
|
|
729
|
+
/**
|
|
730
|
+
* Inspect the inbound `?manifest=` query param from the LAST request
|
|
731
|
+
* to GET /api/v1/library. `"1"` means the client made the
|
|
732
|
+
* non-recording manifest read (#1832, `skillrepo list`); `null`
|
|
733
|
+
* means a normal recording sync.
|
|
734
|
+
*/
|
|
735
|
+
getLastLibraryManifest() {
|
|
736
|
+
return lastLibraryManifest;
|
|
737
|
+
},
|
|
738
|
+
|
|
739
|
+
// ── #1832: sync-receipt slots ─────────────────────────────────
|
|
740
|
+
|
|
741
|
+
/**
|
|
742
|
+
* Return the most recent POST /api/v1/library/sync-receipts body
|
|
743
|
+
* (JSON-parsed), or null if none received. Tests assert on
|
|
744
|
+
* `.skills` (the confirmed-write list) and `.syncedAt`.
|
|
745
|
+
*/
|
|
746
|
+
getLastReceipt() {
|
|
747
|
+
return lastReceipt;
|
|
748
|
+
},
|
|
749
|
+
|
|
750
|
+
/** Count of sync-receipt POSTs since server start or reset. */
|
|
751
|
+
getReceiptRequestCount() {
|
|
752
|
+
return receiptRequestCount;
|
|
753
|
+
},
|
|
754
|
+
|
|
755
|
+
/**
|
|
756
|
+
* Override the sync-receipt response to simulate a failure (e.g.
|
|
757
|
+
* `{ status: 500, body: { error: "boom" } }`). The CLI treats a
|
|
758
|
+
* failed receipt as a non-fatal warning, so tests use this to
|
|
759
|
+
* assert the sync still succeeds. Pass null to restore the 200.
|
|
760
|
+
*/
|
|
761
|
+
setReceiptResponse(override) {
|
|
762
|
+
receiptResponse = override;
|
|
763
|
+
},
|
|
764
|
+
|
|
765
|
+
/** Reset sync-receipt inspection slots. */
|
|
766
|
+
resetReceipts() {
|
|
767
|
+
lastReceipt = null;
|
|
768
|
+
receiptRequestCount = 0;
|
|
769
|
+
receiptResponse = null;
|
|
770
|
+
},
|
|
771
|
+
|
|
679
772
|
/** Register a single-skill response keyed by `owner/name`. */
|
|
680
773
|
setSkillResponse(owner, name, skill) {
|
|
681
774
|
skillResponses.set(`${owner}/${name}`, skill);
|
|
@@ -35,6 +35,7 @@ import { once } from "node:events";
|
|
|
35
35
|
import {
|
|
36
36
|
validateAccessKey,
|
|
37
37
|
getLibrary,
|
|
38
|
+
postSyncReceipt,
|
|
38
39
|
getSkill,
|
|
39
40
|
searchSkills,
|
|
40
41
|
addSkillToLibrary,
|
|
@@ -520,6 +521,20 @@ describe("getLibrary", () => {
|
|
|
520
521
|
}
|
|
521
522
|
});
|
|
522
523
|
|
|
524
|
+
it("sends manifest=1 when the manifest option is set (#1832)", async () => {
|
|
525
|
+
let capturedUrl;
|
|
526
|
+
const srv = await startServer((req, res) => {
|
|
527
|
+
capturedUrl = req.url;
|
|
528
|
+
jsonRes(res, 200, { skills: [], removals: [], syncedAt: "x" });
|
|
529
|
+
});
|
|
530
|
+
try {
|
|
531
|
+
await getLibrary(srv.url, VALID_KEY, { manifest: true });
|
|
532
|
+
assert.match(capturedUrl, /[?&]manifest=1/);
|
|
533
|
+
} finally {
|
|
534
|
+
await srv.close();
|
|
535
|
+
}
|
|
536
|
+
});
|
|
537
|
+
|
|
523
538
|
it("tolerates missing fields in the response (defaults applied)", async () => {
|
|
524
539
|
const srv = await startServer((req, res) => {
|
|
525
540
|
jsonRes(res, 200, {});
|
|
@@ -563,6 +578,94 @@ describe("getLibrary", () => {
|
|
|
563
578
|
});
|
|
564
579
|
});
|
|
565
580
|
|
|
581
|
+
// ── postSyncReceipt ─────────────────────────────────────────────────────
|
|
582
|
+
|
|
583
|
+
describe("postSyncReceipt", () => {
|
|
584
|
+
it("POSTs the receipt body and returns the parsed result", async () => {
|
|
585
|
+
const srv = await startServer((req, res) => {
|
|
586
|
+
jsonRes(res, 200, { recorded: 2 });
|
|
587
|
+
});
|
|
588
|
+
try {
|
|
589
|
+
const result = await postSyncReceipt(srv.url, VALID_KEY, {
|
|
590
|
+
syncedAt: "2025-06-01T00:00:00Z",
|
|
591
|
+
skills: [
|
|
592
|
+
{ owner: "alice", name: "pdf-helper", version: "1.0" },
|
|
593
|
+
{ owner: "bob", name: "code-review", version: "2.1" },
|
|
594
|
+
],
|
|
595
|
+
});
|
|
596
|
+
assert.equal(result.recorded, 2);
|
|
597
|
+
assert.equal(srv.lastRequest.method, "POST");
|
|
598
|
+
assert.match(srv.lastRequest.url, /\/api\/v1\/library\/sync-receipts$/);
|
|
599
|
+
assert.equal(
|
|
600
|
+
srv.lastRequest.headers.authorization,
|
|
601
|
+
`Bearer ${VALID_KEY}`,
|
|
602
|
+
);
|
|
603
|
+
assert.match(
|
|
604
|
+
srv.lastRequest.headers["content-type"],
|
|
605
|
+
/application\/json/,
|
|
606
|
+
);
|
|
607
|
+
const sent = JSON.parse(srv.lastRequest.body);
|
|
608
|
+
assert.equal(sent.syncedAt, "2025-06-01T00:00:00Z");
|
|
609
|
+
assert.equal(sent.skills.length, 2);
|
|
610
|
+
assert.deepEqual(sent.skills[0], {
|
|
611
|
+
owner: "alice",
|
|
612
|
+
name: "pdf-helper",
|
|
613
|
+
version: "1.0",
|
|
614
|
+
});
|
|
615
|
+
} finally {
|
|
616
|
+
await srv.close();
|
|
617
|
+
}
|
|
618
|
+
});
|
|
619
|
+
|
|
620
|
+
it("throws authError on 401", async () => {
|
|
621
|
+
const srv = await startServer((req, res) =>
|
|
622
|
+
jsonRes(res, 401, { error: "nope" }),
|
|
623
|
+
);
|
|
624
|
+
try {
|
|
625
|
+
await assert.rejects(
|
|
626
|
+
() =>
|
|
627
|
+
postSyncReceipt(srv.url, VALID_KEY, { syncedAt: "x", skills: [] }),
|
|
628
|
+
(err) => err instanceof CliError && err.exitCode === EXIT_AUTH,
|
|
629
|
+
);
|
|
630
|
+
} finally {
|
|
631
|
+
await srv.close();
|
|
632
|
+
}
|
|
633
|
+
});
|
|
634
|
+
|
|
635
|
+
it("throws validationError on 404 (endpoint missing — wrong URL)", async () => {
|
|
636
|
+
const srv = await startServer((req, res) =>
|
|
637
|
+
jsonRes(res, 404, { error: "nope" }),
|
|
638
|
+
);
|
|
639
|
+
try {
|
|
640
|
+
await assert.rejects(
|
|
641
|
+
() =>
|
|
642
|
+
postSyncReceipt(srv.url, VALID_KEY, { syncedAt: "x", skills: [] }),
|
|
643
|
+
(err) => err instanceof CliError && err.exitCode === EXIT_VALIDATION,
|
|
644
|
+
);
|
|
645
|
+
} finally {
|
|
646
|
+
await srv.close();
|
|
647
|
+
}
|
|
648
|
+
});
|
|
649
|
+
|
|
650
|
+
it("throws networkError on a persistent 5xx (runSync treats it as non-fatal)", async () => {
|
|
651
|
+
// Pins the http-layer contract: a 5xx must THROW so runSync's
|
|
652
|
+
// try/catch surfaces the non-fatal warning. If this ever started
|
|
653
|
+
// returning null instead, the warning path would silently break.
|
|
654
|
+
const srv = await startServer((req, res) =>
|
|
655
|
+
jsonRes(res, 500, { error: "boom" }),
|
|
656
|
+
);
|
|
657
|
+
try {
|
|
658
|
+
await assert.rejects(
|
|
659
|
+
() =>
|
|
660
|
+
postSyncReceipt(srv.url, VALID_KEY, { syncedAt: "x", skills: [] }),
|
|
661
|
+
(err) => err instanceof CliError && err.exitCode === EXIT_NETWORK,
|
|
662
|
+
);
|
|
663
|
+
} finally {
|
|
664
|
+
await srv.close();
|
|
665
|
+
}
|
|
666
|
+
});
|
|
667
|
+
});
|
|
668
|
+
|
|
566
669
|
// ── getSkill ────────────────────────────────────────────────────────────
|
|
567
670
|
|
|
568
671
|
describe("getSkill", () => {
|
|
@@ -76,7 +76,7 @@ describe("mergeMcpForVendors — happy path", () => {
|
|
|
76
76
|
it("creates .mcp.json when it does not exist (claudeCode)", async () => {
|
|
77
77
|
const results = await mergeMcpForVendors({
|
|
78
78
|
vendors: ["claudeCode"],
|
|
79
|
-
mcpUrl: "https://skillrepo.dev
|
|
79
|
+
mcpUrl: "https://mcp.skillrepo.dev",
|
|
80
80
|
yes: true,
|
|
81
81
|
io: { stdout, stderr },
|
|
82
82
|
});
|
|
@@ -88,7 +88,7 @@ describe("mergeMcpForVendors — happy path", () => {
|
|
|
88
88
|
const mcp = JSON.parse(readFileSync(join(process.cwd(), ".mcp.json"), "utf-8"));
|
|
89
89
|
assert.ok(mcp.mcpServers?.skillrepo);
|
|
90
90
|
assert.equal(mcp.mcpServers.skillrepo.type, "http");
|
|
91
|
-
assert.equal(mcp.mcpServers.skillrepo.url, "https://skillrepo.dev
|
|
91
|
+
assert.equal(mcp.mcpServers.skillrepo.url, "https://mcp.skillrepo.dev");
|
|
92
92
|
});
|
|
93
93
|
|
|
94
94
|
it("merges into existing .mcp.json preserving other servers", async () => {
|
|
@@ -390,6 +390,269 @@ describe("runSync — write skills", () => {
|
|
|
390
390
|
});
|
|
391
391
|
});
|
|
392
392
|
|
|
393
|
+
// ── runSync — sync receipts (#1832) ────────────────────────────────────
|
|
394
|
+
|
|
395
|
+
describe("runSync — sync receipts (#1832)", () => {
|
|
396
|
+
beforeEach(setupServer);
|
|
397
|
+
afterEach(teardownServer);
|
|
398
|
+
|
|
399
|
+
it("posts a receipt with the skills it wrote", async () => {
|
|
400
|
+
server.setLibraryResponse({
|
|
401
|
+
skills: [makeSkill("pdf-helper"), makeSkill("code-review")],
|
|
402
|
+
removals: [],
|
|
403
|
+
syncedAt: "2025-06-01T00:00:00Z",
|
|
404
|
+
});
|
|
405
|
+
await runSync({ serverUrl, apiKey: VALID_KEY, vendors: ["claudeCode"] });
|
|
406
|
+
|
|
407
|
+
assert.equal(server.getReceiptRequestCount(), 1);
|
|
408
|
+
const receipt = server.getLastReceipt();
|
|
409
|
+
assert.equal(receipt.syncedAt, "2025-06-01T00:00:00Z");
|
|
410
|
+
const names = receipt.skills.map((s) => s.name).sort();
|
|
411
|
+
assert.deepEqual(names, ["code-review", "pdf-helper"]);
|
|
412
|
+
for (const s of receipt.skills) {
|
|
413
|
+
assert.equal(s.owner, "alice");
|
|
414
|
+
assert.equal(s.version, "1.0.0");
|
|
415
|
+
}
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
it("re-asserts the on-disk state on a 304 (liveness + heal), not nothing", async () => {
|
|
419
|
+
server.setEtag('"v1"');
|
|
420
|
+
server.setLibraryResponse({
|
|
421
|
+
skills: [makeSkill("pdf-helper")],
|
|
422
|
+
removals: [],
|
|
423
|
+
syncedAt: "2025-06-01T00:00:00Z",
|
|
424
|
+
});
|
|
425
|
+
// First sync writes + posts a receipt.
|
|
426
|
+
await runSync({ serverUrl, apiKey: VALID_KEY, vendors: ["claudeCode"] });
|
|
427
|
+
assert.equal(server.getReceiptRequestCount(), 1);
|
|
428
|
+
server.resetReceipts();
|
|
429
|
+
|
|
430
|
+
// Second sync 304-short-circuits (etag matches, placement present) but
|
|
431
|
+
// STILL posts a receipt re-asserting the on-disk set. This is the
|
|
432
|
+
// liveness signal that replaces the GET-side 304 heartbeat for
|
|
433
|
+
// receipt-era servers, and it heals any delivery a prior failed
|
|
434
|
+
// receipt POST lost (#1832 PR B).
|
|
435
|
+
const result = await runSync({
|
|
436
|
+
serverUrl,
|
|
437
|
+
apiKey: VALID_KEY,
|
|
438
|
+
vendors: ["claudeCode"],
|
|
439
|
+
});
|
|
440
|
+
assert.equal(result.notModified, true);
|
|
441
|
+
assert.equal(server.getReceiptRequestCount(), 1);
|
|
442
|
+
const receipt = server.getLastReceipt();
|
|
443
|
+
assert.deepEqual(
|
|
444
|
+
receipt.skills.map((s) => s.name),
|
|
445
|
+
["pdf-helper"],
|
|
446
|
+
);
|
|
447
|
+
assert.equal(receipt.skills[0].version, "1.0.0");
|
|
448
|
+
// syncedAt on a 304 is the CLI's "now" (the server sends no body),
|
|
449
|
+
// so freshness advances — not the stale first-sync timestamp.
|
|
450
|
+
assert.ok(
|
|
451
|
+
Number.isFinite(new Date(receipt.syncedAt).getTime()),
|
|
452
|
+
"304 receipt carries a valid ISO syncedAt",
|
|
453
|
+
);
|
|
454
|
+
assert.notEqual(
|
|
455
|
+
receipt.syncedAt,
|
|
456
|
+
"2025-06-01T00:00:00Z",
|
|
457
|
+
"304 receipt uses the CLI's now, not the prior sync timestamp",
|
|
458
|
+
);
|
|
459
|
+
// Stronger than "not the fixture": prove the timestamp is wall-clock
|
|
460
|
+
// FRESH. A regression that stamped the receipt with any stale cached
|
|
461
|
+
// value (e.g. lastSync.syncedAt) could differ from the fixture and
|
|
462
|
+
// still pass the notEqual above — but it would fail this. The liveness
|
|
463
|
+
// signal that replaces the 304 heartbeat must advance every sync.
|
|
464
|
+
assert.ok(
|
|
465
|
+
Date.now() - new Date(receipt.syncedAt).getTime() < 60_000,
|
|
466
|
+
"304 receipt syncedAt must be wall-clock fresh (within the last minute)",
|
|
467
|
+
);
|
|
468
|
+
});
|
|
469
|
+
|
|
470
|
+
it("re-asserts the full on-disk set (carry-forward + newly written) on a delta sync", async () => {
|
|
471
|
+
server.setEtag('"v1"');
|
|
472
|
+
server.setLibraryResponse({
|
|
473
|
+
skills: [makeSkill("alpha"), makeSkill("beta")],
|
|
474
|
+
removals: [],
|
|
475
|
+
syncedAt: "2025-01-01T00:00:00Z",
|
|
476
|
+
});
|
|
477
|
+
await runSync({ serverUrl, apiKey: VALID_KEY, vendors: ["claudeCode"] });
|
|
478
|
+
server.resetReceipts();
|
|
479
|
+
|
|
480
|
+
// Library changes: only `gamma` is new this round; alpha+beta are
|
|
481
|
+
// unchanged and arrive only via the carry-forward `.last-sync` map.
|
|
482
|
+
server.setEtag('"v2"');
|
|
483
|
+
server.setLibraryResponse({
|
|
484
|
+
skills: [makeSkill("gamma")],
|
|
485
|
+
removals: [],
|
|
486
|
+
syncedAt: "2025-01-02T00:00:00Z",
|
|
487
|
+
});
|
|
488
|
+
const result = await runSync({
|
|
489
|
+
serverUrl,
|
|
490
|
+
apiKey: VALID_KEY,
|
|
491
|
+
vendors: ["claudeCode"],
|
|
492
|
+
});
|
|
493
|
+
|
|
494
|
+
assert.equal(result.notModified, false);
|
|
495
|
+
assert.equal(server.getReceiptRequestCount(), 1);
|
|
496
|
+
const receipt = server.getLastReceipt();
|
|
497
|
+
// The receipt re-asserts EVERYTHING on disk, not just the changed
|
|
498
|
+
// skill — that is what heals server drift and keeps freshness honest.
|
|
499
|
+
assert.deepEqual(
|
|
500
|
+
receipt.skills.map((s) => s.name).sort(),
|
|
501
|
+
["alpha", "beta", "gamma"],
|
|
502
|
+
);
|
|
503
|
+
// A 200 uses the server's response syncedAt for the delivery time.
|
|
504
|
+
assert.equal(receipt.syncedAt, "2025-01-02T00:00:00Z");
|
|
505
|
+
});
|
|
506
|
+
|
|
507
|
+
it("re-asserts carry-forward on a 200 delta that writes nothing new", async () => {
|
|
508
|
+
// Distinct from the 304 short-circuit: here the ETag changes (a real
|
|
509
|
+
// 200) but the response carries no skills to write, so nothing new
|
|
510
|
+
// lands on disk. The receipt must still re-assert the full carry-
|
|
511
|
+
// forward set — the 200 path's re-assertion is driven by the on-disk
|
|
512
|
+
// `.last-sync` map, not by what was written this round.
|
|
513
|
+
server.setEtag('"v1"');
|
|
514
|
+
server.setLibraryResponse({
|
|
515
|
+
skills: [makeSkill("alpha"), makeSkill("beta")],
|
|
516
|
+
removals: [],
|
|
517
|
+
syncedAt: "2025-01-01T00:00:00Z",
|
|
518
|
+
});
|
|
519
|
+
await runSync({ serverUrl, apiKey: VALID_KEY, vendors: ["claudeCode"] });
|
|
520
|
+
server.resetReceipts();
|
|
521
|
+
|
|
522
|
+
server.setEtag('"v2"');
|
|
523
|
+
server.setLibraryResponse({
|
|
524
|
+
skills: [],
|
|
525
|
+
removals: [],
|
|
526
|
+
syncedAt: "2025-01-02T00:00:00Z",
|
|
527
|
+
});
|
|
528
|
+
const result = await runSync({
|
|
529
|
+
serverUrl,
|
|
530
|
+
apiKey: VALID_KEY,
|
|
531
|
+
vendors: ["claudeCode"],
|
|
532
|
+
});
|
|
533
|
+
|
|
534
|
+
assert.equal(result.notModified, false);
|
|
535
|
+
assert.equal(result.added, 0);
|
|
536
|
+
assert.equal(result.updated, 0);
|
|
537
|
+
assert.equal(server.getReceiptRequestCount(), 1);
|
|
538
|
+
const receipt = server.getLastReceipt();
|
|
539
|
+
assert.deepEqual(
|
|
540
|
+
receipt.skills.map((s) => s.name).sort(),
|
|
541
|
+
["alpha", "beta"],
|
|
542
|
+
);
|
|
543
|
+
assert.equal(receipt.syncedAt, "2025-01-02T00:00:00Z");
|
|
544
|
+
});
|
|
545
|
+
|
|
546
|
+
it("excludes a filesIncomplete skill from the receipt", async () => {
|
|
547
|
+
const incomplete = makeSkill("incomplete");
|
|
548
|
+
incomplete.filesIncomplete = true;
|
|
549
|
+
server.setLibraryResponse({
|
|
550
|
+
skills: [incomplete, makeSkill("complete")],
|
|
551
|
+
removals: [],
|
|
552
|
+
syncedAt: "x",
|
|
553
|
+
});
|
|
554
|
+
await runSync({ serverUrl, apiKey: VALID_KEY, vendors: ["claudeCode"] });
|
|
555
|
+
|
|
556
|
+
const receipt = server.getLastReceipt();
|
|
557
|
+
assert.deepEqual(
|
|
558
|
+
receipt.skills.map((s) => s.name),
|
|
559
|
+
["complete"],
|
|
560
|
+
);
|
|
561
|
+
});
|
|
562
|
+
|
|
563
|
+
it("re-asserts the OLD on-disk version when a newer version fails to inline (filesIncomplete carry-forward)", async () => {
|
|
564
|
+
// First sync writes pdf-helper@1.0.0.
|
|
565
|
+
server.setEtag('"v1"');
|
|
566
|
+
server.setLibraryResponse({
|
|
567
|
+
skills: [makeSkill("pdf-helper")],
|
|
568
|
+
removals: [],
|
|
569
|
+
syncedAt: "2025-01-01T00:00:00Z",
|
|
570
|
+
});
|
|
571
|
+
await runSync({ serverUrl, apiKey: VALID_KEY, vendors: ["claudeCode"] });
|
|
572
|
+
server.resetReceipts();
|
|
573
|
+
|
|
574
|
+
// The server tries to deliver pdf-helper@1.1.0 but its files fail to
|
|
575
|
+
// inline. The CLI skips the write (1.0.0 stays on disk) and must
|
|
576
|
+
// re-assert the OLD version it still has — not the version it failed
|
|
577
|
+
// to write, and not nothing.
|
|
578
|
+
const newer = makeSkill("pdf-helper");
|
|
579
|
+
newer.version = "1.1.0";
|
|
580
|
+
newer.filesIncomplete = true;
|
|
581
|
+
server.setEtag('"v2"');
|
|
582
|
+
server.setLibraryResponse({
|
|
583
|
+
skills: [newer],
|
|
584
|
+
removals: [],
|
|
585
|
+
syncedAt: "2025-01-02T00:00:00Z",
|
|
586
|
+
});
|
|
587
|
+
await runSync({ serverUrl, apiKey: VALID_KEY, vendors: ["claudeCode"] });
|
|
588
|
+
|
|
589
|
+
assert.equal(server.getReceiptRequestCount(), 1);
|
|
590
|
+
const receipt = server.getLastReceipt();
|
|
591
|
+
assert.deepEqual(receipt.skills, [
|
|
592
|
+
{ owner: "alice", name: "pdf-helper", version: "1.0.0" },
|
|
593
|
+
]);
|
|
594
|
+
// Freshness still advances on a partial sync: the receipt carries the
|
|
595
|
+
// SERVER's 200 response syncedAt (the fresh sync time), not the stale
|
|
596
|
+
// carry-forward entry's own recorded syncedAt.
|
|
597
|
+
assert.equal(receipt.syncedAt, "2025-01-02T00:00:00Z");
|
|
598
|
+
});
|
|
599
|
+
|
|
600
|
+
it("does NOT post a receipt when ALL skills are filesIncomplete (nothing on disk to assert)", async () => {
|
|
601
|
+
// A first sync where every skill is filesIncomplete writes nothing
|
|
602
|
+
// and has no carry-forward `.last-sync` baseline, so the post-sync
|
|
603
|
+
// on-disk set is empty and `reportReceipt`'s empty-skills guard skips
|
|
604
|
+
// the POST.
|
|
605
|
+
const a = makeSkill("inc-a");
|
|
606
|
+
a.filesIncomplete = true;
|
|
607
|
+
const b = makeSkill("inc-b");
|
|
608
|
+
b.filesIncomplete = true;
|
|
609
|
+
server.setLibraryResponse({ skills: [a, b], removals: [], syncedAt: "x" });
|
|
610
|
+
await runSync({ serverUrl, apiKey: VALID_KEY, vendors: ["claudeCode"] });
|
|
611
|
+
assert.equal(server.getReceiptRequestCount(), 0);
|
|
612
|
+
});
|
|
613
|
+
|
|
614
|
+
it("omits skills with no version label from the receipt", async () => {
|
|
615
|
+
const draft = makeSkill("draft");
|
|
616
|
+
draft.version = null;
|
|
617
|
+
server.setLibraryResponse({
|
|
618
|
+
skills: [draft, makeSkill("published")],
|
|
619
|
+
removals: [],
|
|
620
|
+
syncedAt: "x",
|
|
621
|
+
});
|
|
622
|
+
await runSync({ serverUrl, apiKey: VALID_KEY, vendors: ["claudeCode"] });
|
|
623
|
+
|
|
624
|
+
const receipt = server.getLastReceipt();
|
|
625
|
+
assert.deepEqual(
|
|
626
|
+
receipt.skills.map((s) => s.name),
|
|
627
|
+
["published"],
|
|
628
|
+
);
|
|
629
|
+
});
|
|
630
|
+
|
|
631
|
+
it("a failed receipt POST is non-fatal — the sync still succeeds", async () => {
|
|
632
|
+
const { createCaptureStream } = await import(
|
|
633
|
+
"../helpers/capture-stream.mjs"
|
|
634
|
+
);
|
|
635
|
+
const stderr = createCaptureStream();
|
|
636
|
+
server.setReceiptResponse({ status: 500, body: { error: "boom" } });
|
|
637
|
+
server.setLibraryResponse({
|
|
638
|
+
skills: [makeSkill("pdf-helper")],
|
|
639
|
+
removals: [],
|
|
640
|
+
syncedAt: "x",
|
|
641
|
+
});
|
|
642
|
+
const result = await runSync({
|
|
643
|
+
serverUrl,
|
|
644
|
+
apiKey: VALID_KEY,
|
|
645
|
+
vendors: ["claudeCode"],
|
|
646
|
+
io: { stderr },
|
|
647
|
+
});
|
|
648
|
+
// Files still written, summary intact despite the receipt failure.
|
|
649
|
+
assert.equal(result.added, 1);
|
|
650
|
+
const dir = resolvePlacementDir("claudeProject", "pdf-helper");
|
|
651
|
+
assert.ok(existsSync(join(dir, "SKILL.md")));
|
|
652
|
+
assert.match(stderr.text(), /failed to report sync receipt/);
|
|
653
|
+
});
|
|
654
|
+
});
|
|
655
|
+
|
|
393
656
|
// ── runSync — skills map population (v2) ───────────────────────────────
|
|
394
657
|
//
|
|
395
658
|
// #1553 — every successful sync populates `.last-sync.skills` with the
|
|
@@ -684,6 +947,9 @@ describe("runSync — tombstones", () => {
|
|
|
684
947
|
});
|
|
685
948
|
await runSync({ serverUrl, apiKey: VALID_KEY, vendors: ["claudeCode"] });
|
|
686
949
|
assert.ok(existsSync(resolvePlacementDir("claudeProject", "doomed")));
|
|
950
|
+
// Reset receipt tracking so the tombstone-only sync below is measured
|
|
951
|
+
// from zero (the first sync legitimately posted one).
|
|
952
|
+
server.resetReceipts();
|
|
687
953
|
|
|
688
954
|
// Now the server says it's removed (the response has the tombstone
|
|
689
955
|
// AND the skill is no longer in skills[])
|
|
@@ -700,6 +966,39 @@ describe("runSync — tombstones", () => {
|
|
|
700
966
|
});
|
|
701
967
|
assert.equal(result.removed, 1);
|
|
702
968
|
assert.ok(!existsSync(resolvePlacementDir("claudeProject", "doomed")));
|
|
969
|
+
// The `.last-sync` baseline was reset (rm above) and the only skill
|
|
970
|
+
// was tombstoned, so the post-sync on-disk set is empty → the
|
|
971
|
+
// re-assertion receipt has nothing to send and is skipped (#1832).
|
|
972
|
+
assert.equal(server.getReceiptRequestCount(), 0);
|
|
973
|
+
});
|
|
974
|
+
|
|
975
|
+
it("re-asserts surviving skills but excludes a tombstoned one", async () => {
|
|
976
|
+
server.setEtag('"v1"');
|
|
977
|
+
server.setLibraryResponse({
|
|
978
|
+
skills: [makeSkill("keep"), makeSkill("drop")],
|
|
979
|
+
removals: [],
|
|
980
|
+
syncedAt: "2025-01-01T00:00:00Z",
|
|
981
|
+
});
|
|
982
|
+
await runSync({ serverUrl, apiKey: VALID_KEY, vendors: ["claudeCode"] });
|
|
983
|
+
server.resetReceipts();
|
|
984
|
+
|
|
985
|
+
// `drop` is tombstoned; `keep` stays on disk and must be re-asserted.
|
|
986
|
+
server.setEtag('"v2"');
|
|
987
|
+
server.setLibraryResponse({
|
|
988
|
+
skills: [],
|
|
989
|
+
removals: [
|
|
990
|
+
{ owner: "alice", name: "drop", removedAt: "2025-01-02T00:00:00Z" },
|
|
991
|
+
],
|
|
992
|
+
syncedAt: "2025-01-02T00:00:00Z",
|
|
993
|
+
});
|
|
994
|
+
await runSync({ serverUrl, apiKey: VALID_KEY, vendors: ["claudeCode"] });
|
|
995
|
+
|
|
996
|
+
assert.equal(server.getReceiptRequestCount(), 1);
|
|
997
|
+
const receipt = server.getLastReceipt();
|
|
998
|
+
assert.deepEqual(
|
|
999
|
+
receipt.skills.map((s) => s.name),
|
|
1000
|
+
["keep"],
|
|
1001
|
+
);
|
|
703
1002
|
});
|
|
704
1003
|
|
|
705
1004
|
it("applies removals BEFORE writes (re-add scenario)", async () => {
|
|
@@ -22,14 +22,14 @@ afterEach(() => {
|
|
|
22
22
|
describe("Claude Code MCP config merger", () => {
|
|
23
23
|
it("creates .mcp.json when file does not exist", async () => {
|
|
24
24
|
const { mergeClaudeMcpConfig } = await import("../../lib/mergers/claude-mcp.mjs");
|
|
25
|
-
const result = mergeClaudeMcpConfig("https://skillrepo.dev
|
|
25
|
+
const result = mergeClaudeMcpConfig("https://mcp.skillrepo.dev");
|
|
26
26
|
|
|
27
27
|
assert.equal(result.action, "created");
|
|
28
28
|
assert.equal(result.path, ".mcp.json");
|
|
29
29
|
|
|
30
30
|
const content = JSON.parse(readFileSync(join(tempDir, ".mcp.json"), "utf-8"));
|
|
31
31
|
assert.equal(content.mcpServers.skillrepo.type, "http");
|
|
32
|
-
assert.equal(content.mcpServers.skillrepo.url, "https://skillrepo.dev
|
|
32
|
+
assert.equal(content.mcpServers.skillrepo.url, "https://mcp.skillrepo.dev");
|
|
33
33
|
assert.equal(content.mcpServers.skillrepo.headers.Authorization, "Bearer ${SKILLREPO_ACCESS_KEY}");
|
|
34
34
|
});
|
|
35
35
|
|
|
@@ -44,7 +44,7 @@ describe("Claude Code MCP config merger", () => {
|
|
|
44
44
|
};
|
|
45
45
|
writeFileSync(join(tempDir, ".mcp.json"), JSON.stringify(existing));
|
|
46
46
|
|
|
47
|
-
const result = mergeClaudeMcpConfig("https://skillrepo.dev
|
|
47
|
+
const result = mergeClaudeMcpConfig("https://mcp.skillrepo.dev");
|
|
48
48
|
|
|
49
49
|
assert.equal(result.action, "merged"); // adding to existing file
|
|
50
50
|
const content = JSON.parse(readFileSync(join(tempDir, ".mcp.json"), "utf-8"));
|
|
@@ -73,6 +73,6 @@ describe("Claude Code MCP config merger", () => {
|
|
|
73
73
|
const { mergeClaudeMcpConfig } = await import("../../lib/mergers/claude-mcp.mjs");
|
|
74
74
|
writeFileSync(join(tempDir, ".mcp.json"), "not json");
|
|
75
75
|
|
|
76
|
-
assert.throws(() => mergeClaudeMcpConfig("https://skillrepo.dev
|
|
76
|
+
assert.throws(() => mergeClaudeMcpConfig("https://mcp.skillrepo.dev"), /invalid JSON/);
|
|
77
77
|
});
|
|
78
78
|
});
|