skillrepo 4.6.0 → 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/lib/sync.mjs +110 -40
- package/src/test/lib/sync.test.mjs +183 -7
package/package.json
CHANGED
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").
|
|
@@ -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,
|
|
@@ -606,11 +633,6 @@ export async function runSync(options) {
|
|
|
606
633
|
let anyIncomplete = false;
|
|
607
634
|
/** @type {Record<string, SyncedSkillEntry>} */
|
|
608
635
|
const skillsMap = {};
|
|
609
|
-
// Skills actually written to disk this sync, for the confirmed-write
|
|
610
|
-
// receipt (#1832). Only what we WRITE this round — carry-forward skills
|
|
611
|
-
// were reported on the sync that wrote them.
|
|
612
|
-
/** @type {{owner: string, name: string, version: string}[]} */
|
|
613
|
-
const writtenThisSync = [];
|
|
614
636
|
// Carry forward entries from the previous sync for skills we did
|
|
615
637
|
// NOT re-fetch this round. A delta sync that touched 2 of 50
|
|
616
638
|
// skills must keep the other 48's metadata — otherwise the
|
|
@@ -674,17 +696,6 @@ export async function runSync(options) {
|
|
|
674
696
|
syncedAt: result.syncedAt,
|
|
675
697
|
};
|
|
676
698
|
}
|
|
677
|
-
|
|
678
|
-
// Record this write for the sync receipt (#1832). Only entries with a
|
|
679
|
-
// real version label can resolve to a delivery server-side, so skip
|
|
680
|
-
// null/empty versions (a skill with no current published version).
|
|
681
|
-
if (typeof skill.version === "string" && skill.version !== "") {
|
|
682
|
-
writtenThisSync.push({
|
|
683
|
-
owner: skill.owner,
|
|
684
|
-
name: skill.name,
|
|
685
|
-
version: skill.version,
|
|
686
|
-
});
|
|
687
|
-
}
|
|
688
699
|
}
|
|
689
700
|
|
|
690
701
|
// Step 7: persist new ETag + skills map (only if the response was
|
|
@@ -712,33 +723,92 @@ export async function runSync(options) {
|
|
|
712
723
|
}
|
|
713
724
|
}
|
|
714
725
|
|
|
715
|
-
//
|
|
716
|
-
//
|
|
717
|
-
//
|
|
718
|
-
//
|
|
719
|
-
//
|
|
720
|
-
//
|
|
721
|
-
//
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
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
|
+
);
|
|
736
747
|
|
|
737
748
|
return summary;
|
|
738
749
|
}
|
|
739
750
|
|
|
740
751
|
// ── Internals ──────────────────────────────────────────────────────────
|
|
741
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
|
+
|
|
742
812
|
/**
|
|
743
813
|
* Check whether ANY of the configured placement targets already
|
|
744
814
|
* contains a directory for the given skill name. Used to distinguish
|
|
@@ -415,7 +415,7 @@ describe("runSync — sync receipts (#1832)", () => {
|
|
|
415
415
|
}
|
|
416
416
|
});
|
|
417
417
|
|
|
418
|
-
it("
|
|
418
|
+
it("re-asserts the on-disk state on a 304 (liveness + heal), not nothing", async () => {
|
|
419
419
|
server.setEtag('"v1"');
|
|
420
420
|
server.setLibraryResponse({
|
|
421
421
|
skills: [makeSkill("pdf-helper")],
|
|
@@ -427,14 +427,120 @@ describe("runSync — sync receipts (#1832)", () => {
|
|
|
427
427
|
assert.equal(server.getReceiptRequestCount(), 1);
|
|
428
428
|
server.resetReceipts();
|
|
429
429
|
|
|
430
|
-
// Second sync 304-short-circuits (etag matches, placement present)
|
|
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).
|
|
431
435
|
const result = await runSync({
|
|
432
436
|
serverUrl,
|
|
433
437
|
apiKey: VALID_KEY,
|
|
434
438
|
vendors: ["claudeCode"],
|
|
435
439
|
});
|
|
436
440
|
assert.equal(result.notModified, true);
|
|
437
|
-
assert.equal(server.getReceiptRequestCount(),
|
|
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");
|
|
438
544
|
});
|
|
439
545
|
|
|
440
546
|
it("excludes a filesIncomplete skill from the receipt", async () => {
|
|
@@ -454,9 +560,48 @@ describe("runSync — sync receipts (#1832)", () => {
|
|
|
454
560
|
);
|
|
455
561
|
});
|
|
456
562
|
|
|
457
|
-
it("
|
|
458
|
-
//
|
|
459
|
-
|
|
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.
|
|
460
605
|
const a = makeSkill("inc-a");
|
|
461
606
|
a.filesIncomplete = true;
|
|
462
607
|
const b = makeSkill("inc-b");
|
|
@@ -821,10 +966,41 @@ describe("runSync — tombstones", () => {
|
|
|
821
966
|
});
|
|
822
967
|
assert.equal(result.removed, 1);
|
|
823
968
|
assert.ok(!existsSync(resolvePlacementDir("claudeProject", "doomed")));
|
|
824
|
-
//
|
|
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).
|
|
825
972
|
assert.equal(server.getReceiptRequestCount(), 0);
|
|
826
973
|
});
|
|
827
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
|
+
);
|
|
1002
|
+
});
|
|
1003
|
+
|
|
828
1004
|
it("applies removals BEFORE writes (re-add scenario)", async () => {
|
|
829
1005
|
// The server returns a tombstone AND a skill with the same name
|
|
830
1006
|
// (the user removed and re-added in the same window). The CLI
|