skillrepo 4.6.0 → 4.8.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 +234 -48
- package/src/test/e2e/mock-server.mjs +19 -11
- package/src/test/lib/sync.test.mjs +483 -7
package/package.json
CHANGED
package/src/lib/sync.mjs
CHANGED
|
@@ -31,6 +31,11 @@
|
|
|
31
31
|
* version + content SHAs of every skill written in the last
|
|
32
32
|
* successful sync. Unblocks per-skill drift detection in `list`
|
|
33
33
|
* (#1555) and any future `status`-style command.
|
|
34
|
+
* Later (#1911) v2 also carries `cliVersion` — the version of the
|
|
35
|
+
* CLI that last wrote the state file. Additive, not a schema bump:
|
|
36
|
+
* absence is meaningful (a state file with no `cliVersion` was
|
|
37
|
+
* written by a pre-#1911 CLI and triggers the one-time membership
|
|
38
|
+
* heal). See `FULL_RESYNC_FLOOR` / `cliVersionBelowFloor`.
|
|
34
39
|
*
|
|
35
40
|
* Forward/backward compatibility
|
|
36
41
|
* ------------------------------
|
|
@@ -69,7 +74,14 @@
|
|
|
69
74
|
* render accurate user messages (see
|
|
70
75
|
* init.mjs step 7).
|
|
71
76
|
* @property {string} [syncedAt]
|
|
72
|
-
* @property {string} syncedAt - ISO timestamp
|
|
77
|
+
* @property {string} syncedAt - ISO timestamp of the sync: the server
|
|
78
|
+
* response `syncedAt` on a 200, or the
|
|
79
|
+
* previously-cached sync timestamp on a
|
|
80
|
+
* 304 (the server sends no body). NOTE:
|
|
81
|
+
* this is distinct from the re-assertion
|
|
82
|
+
* receipt's own timestamp on a 304, which
|
|
83
|
+
* is the CLI's "now" (a liveness signal),
|
|
84
|
+
* not this cached value.
|
|
73
85
|
*
|
|
74
86
|
* @typedef {Object} SyncedSkillEntry
|
|
75
87
|
* @property {string} version - Version of the skill as synced (e.g. "1.4.0").
|
|
@@ -81,13 +93,20 @@
|
|
|
81
93
|
* @property {number} schemaVersion
|
|
82
94
|
* @property {string|null} etag
|
|
83
95
|
* @property {string} syncedAt
|
|
96
|
+
* @property {string} [cliVersion] - v2+ (#1911). Version of the CLI that last
|
|
97
|
+
* wrote this state. Absent on files written
|
|
98
|
+
* by a pre-#1911 CLI; absence triggers the
|
|
99
|
+
* one-time membership heal.
|
|
84
100
|
* @property {Record<string, SyncedSkillEntry>} skills - v2+. Keyed by `"<owner>/<name>"`.
|
|
85
101
|
*/
|
|
86
102
|
|
|
87
103
|
import { existsSync, readFileSync } from "node:fs";
|
|
88
104
|
import { join } from "node:path";
|
|
89
105
|
|
|
106
|
+
import semver from "semver";
|
|
107
|
+
|
|
90
108
|
import { getLibrary, postSyncReceipt } from "./http.mjs";
|
|
109
|
+
import { getCliVersion } from "./cli-version.mjs";
|
|
91
110
|
import {
|
|
92
111
|
writeSkillDir,
|
|
93
112
|
removeSkillDir,
|
|
@@ -111,6 +130,66 @@ import { computeSkillShas } from "./crypto-shas.mjs";
|
|
|
111
130
|
*/
|
|
112
131
|
export const LAST_SYNC_SCHEMA_VERSION = 2;
|
|
113
132
|
|
|
133
|
+
/**
|
|
134
|
+
* One-time heal floor (#1911).
|
|
135
|
+
*
|
|
136
|
+
* A `.last-sync` written by a CLI BELOW this version predates the
|
|
137
|
+
* server-side delta-membership fix. Such a state file can be permanently
|
|
138
|
+
* missing skills that were added to the library but never delivered: the
|
|
139
|
+
* old delta filter keyed only on `skills.updatedAt`, so a skill whose
|
|
140
|
+
* content predated the user's `since` was skipped even though the library
|
|
141
|
+
* ETag advanced (count++). The client then cached the advanced ETag and
|
|
142
|
+
* every subsequent `update` short-circuited on a 304 — the skill could
|
|
143
|
+
* never arrive via delta.
|
|
144
|
+
*
|
|
145
|
+
* The server fix makes FUTURE adds correct, but it cannot rescue a client
|
|
146
|
+
* that already cached the advanced ETag (it still matches → 304). So the
|
|
147
|
+
* FIRST sync performed by a CLI at/above this floor forces ONE full fetch
|
|
148
|
+
* (drops `If-None-Match` AND `since`), re-fetching the entire library to
|
|
149
|
+
* fill any gap, then resumes normal delta. `writeLastSync` stamps the
|
|
150
|
+
* running CLI version on every write, so the heal fires at most once per
|
|
151
|
+
* upgrade.
|
|
152
|
+
*
|
|
153
|
+
* MUST equal the CLI version that ships this fix (see packages/cli/package.json).
|
|
154
|
+
*/
|
|
155
|
+
export const FULL_RESYNC_FLOOR = "4.8.0";
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* True when a `.last-sync`'s recorded writer version is below the heal
|
|
159
|
+
* floor — i.e. the state file was written before the #1911 fix and may be
|
|
160
|
+
* missing never-delivered skills. A missing or unparseable version is
|
|
161
|
+
* treated as below the floor (older CLIs never stamped `cliVersion`), so
|
|
162
|
+
* every pre-fix state file heals exactly once.
|
|
163
|
+
*
|
|
164
|
+
* @param {unknown} recordedVersion - `.last-sync`'s `cliVersion` field.
|
|
165
|
+
* @param {string} floor - The heal floor (a valid semver string).
|
|
166
|
+
* @returns {boolean}
|
|
167
|
+
*/
|
|
168
|
+
export function cliVersionBelowFloor(recordedVersion, floor) {
|
|
169
|
+
if (typeof recordedVersion !== "string" || !semver.valid(recordedVersion)) {
|
|
170
|
+
return true;
|
|
171
|
+
}
|
|
172
|
+
return semver.lt(recordedVersion, floor);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* The running CLI's version, or null if it cannot be read. `getCliVersion`
|
|
177
|
+
* throws on a malformed/absent package.json (a broken tarball); persisting
|
|
178
|
+
* last-sync state must never fail on that, so we soft-handle to null here.
|
|
179
|
+
* A null stamp simply means the next run treats this state as pre-floor and
|
|
180
|
+
* heals again — safe, just one extra full fetch in the (production-
|
|
181
|
+
* impossible) broken-tarball case.
|
|
182
|
+
*
|
|
183
|
+
* @returns {string | null}
|
|
184
|
+
*/
|
|
185
|
+
function safeCliVersion() {
|
|
186
|
+
try {
|
|
187
|
+
return getCliVersion();
|
|
188
|
+
} catch {
|
|
189
|
+
return null;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
114
193
|
/**
|
|
115
194
|
* Check whether every skill in the `.last-sync` baseline already exists
|
|
116
195
|
* under every placement target the current sync would write to. Returns
|
|
@@ -290,17 +369,26 @@ export function readLastSync() {
|
|
|
290
369
|
* sync"; consumers should treat absent entries as "unknown" rather
|
|
291
370
|
* than as "missing from library."
|
|
292
371
|
*
|
|
372
|
+
* Always stamps `cliVersion` with the running CLI's version (#1911). This
|
|
373
|
+
* is the writer-version record the heal gate (`cliVersionBelowFloor`) reads
|
|
374
|
+
* on the next run; a state file written by the current CLI is therefore
|
|
375
|
+
* never re-healed. A caller-supplied `cliVersion` overrides the stamp (used
|
|
376
|
+
* only by tests to simulate a pre-fix writer); production callers omit it.
|
|
377
|
+
*
|
|
293
378
|
* @param {object} state
|
|
294
379
|
* @param {string | null} [state.etag]
|
|
295
380
|
* @param {string} [state.syncedAt]
|
|
296
381
|
* @param {Record<string, SyncedSkillEntry>} [state.skills]
|
|
382
|
+
* @param {string | null} [state.cliVersion] - Override the writer-version
|
|
383
|
+
* stamp. Defaults to the running CLI's version.
|
|
297
384
|
*/
|
|
298
|
-
export function writeLastSync({ etag, syncedAt, skills } = {}) {
|
|
385
|
+
export function writeLastSync({ etag, syncedAt, skills, cliVersion } = {}) {
|
|
299
386
|
const path = globalLastSyncPath();
|
|
300
387
|
const body = {
|
|
301
388
|
schemaVersion: LAST_SYNC_SCHEMA_VERSION,
|
|
302
389
|
etag: etag ?? null,
|
|
303
390
|
syncedAt: syncedAt ?? new Date().toISOString(),
|
|
391
|
+
cliVersion: cliVersion !== undefined ? cliVersion : safeCliVersion(),
|
|
304
392
|
skills: skills && typeof skills === "object" && !Array.isArray(skills) ? skills : {},
|
|
305
393
|
};
|
|
306
394
|
try {
|
|
@@ -383,6 +471,15 @@ export function upsertLastSyncEntry(skill) {
|
|
|
383
471
|
etag: prior?.etag ?? null,
|
|
384
472
|
syncedAt: prior?.syncedAt,
|
|
385
473
|
skills: updated,
|
|
474
|
+
// Preserve the prior writer-version too (#1911). `add`/`get` fetch a
|
|
475
|
+
// SINGLE skill — they do NOT perform the full-library reconciliation
|
|
476
|
+
// the membership heal needs, so they must NOT advance the heal gate.
|
|
477
|
+
// Letting `writeLastSync` default-stamp the running version here would
|
|
478
|
+
// let a stuck user "spend" their one-time heal on a single-skill add,
|
|
479
|
+
// leaving every OTHER never-delivered skill stuck forever. `?? null`
|
|
480
|
+
// carries a pre-fix file's absent version forward as an explicit null
|
|
481
|
+
// so the next `update` still heals. Same rationale as etag/syncedAt.
|
|
482
|
+
cliVersion: prior?.cliVersion ?? null,
|
|
386
483
|
});
|
|
387
484
|
}
|
|
388
485
|
|
|
@@ -406,6 +503,10 @@ export function deleteLastSyncEntry(owner, name) {
|
|
|
406
503
|
etag: prior.etag ?? null,
|
|
407
504
|
syncedAt: prior.syncedAt,
|
|
408
505
|
skills: rest,
|
|
506
|
+
// Preserve the prior writer-version (#1911) — `remove` is not a full
|
|
507
|
+
// reconciliation, so it must not advance the one-time heal gate.
|
|
508
|
+
// See upsertLastSyncEntry for the full rationale.
|
|
509
|
+
cliVersion: prior.cliVersion ?? null,
|
|
409
510
|
});
|
|
410
511
|
}
|
|
411
512
|
|
|
@@ -417,7 +518,8 @@ export function deleteLastSyncEntry(owner, name) {
|
|
|
417
518
|
* 2. Read the last-sync state file
|
|
418
519
|
* 3. Call GET /api/v1/library with `If-None-Match` (if we have a
|
|
419
520
|
* cached ETag) AND `since` (always, for delta semantics)
|
|
420
|
-
* 4.
|
|
521
|
+
* 4. On 304 — re-assert the on-disk state via a receipt (liveness +
|
|
522
|
+
* heal) and short-circuit with `notModified: true`
|
|
421
523
|
* 5. For each skill in the response:
|
|
422
524
|
* a. Write it via `writeSkillDir` — overwrites if changed
|
|
423
525
|
* b. Skip writing skills with `filesIncomplete: true` (see comment)
|
|
@@ -425,7 +527,9 @@ export function deleteLastSyncEntry(owner, name) {
|
|
|
425
527
|
* a. Call `removeSkillDir` — deletes from all configured targets
|
|
426
528
|
* 7. Persist the new ETag (if the response was complete) for the
|
|
427
529
|
* next sync
|
|
428
|
-
* 8.
|
|
530
|
+
* 8. Re-assert the full on-disk state via a sync receipt (#1832) —
|
|
531
|
+
* the confirmed-write signal that sets team sync state
|
|
532
|
+
* 9. Return the summary
|
|
429
533
|
*
|
|
430
534
|
* Error handling: any thrown error from the network or filesystem
|
|
431
535
|
* layer propagates unchanged. The caller (a command) decides how to
|
|
@@ -517,25 +621,64 @@ export async function runSync(options) {
|
|
|
517
621
|
const placementsComplete =
|
|
518
622
|
lastSync?.etag &&
|
|
519
623
|
placementsAreComplete(lastSync.skills, vendors, global);
|
|
520
|
-
|
|
624
|
+
|
|
625
|
+
// One-time membership-heal (#1911). A `.last-sync` written by a CLI below
|
|
626
|
+
// the heal floor predates the server delta-membership fix and may be
|
|
627
|
+
// permanently missing skills that were added to the library but never
|
|
628
|
+
// delivered (their content predated `since`, so the old delta skipped
|
|
629
|
+
// them while the ETag still advanced — the client cached the advanced
|
|
630
|
+
// ETag and every later sync 304'd). The server fix can't rescue such a
|
|
631
|
+
// client: its cached ETag still matches, so it keeps getting 304s. Force
|
|
632
|
+
// ONE full fetch (drop BOTH conditional headers) so the entire library
|
|
633
|
+
// is re-fetched and the gap is filled. `writeLastSync` stamps the running
|
|
634
|
+
// version on the resulting write, so this fires at most once per upgrade.
|
|
635
|
+
//
|
|
636
|
+
// Mixed-version note (benign, do NOT "fix" with a different mechanism): if
|
|
637
|
+
// a pre-4.8.0 CLI shares this same global `.last-sync` and runs after a
|
|
638
|
+
// 4.8.0 heal, its `writeLastSync` re-emits a file with no `cliVersion`, so
|
|
639
|
+
// the next 4.8.0 run heals again. That loop is harmless — a full re-fetch
|
|
640
|
+
// is idempotent — and self-resolves once the older install is upgraded.
|
|
641
|
+
const needsFullResync =
|
|
642
|
+
!!lastSync && cliVersionBelowFloor(lastSync.cliVersion, FULL_RESYNC_FLOOR);
|
|
643
|
+
|
|
644
|
+
if (placementsComplete && !needsFullResync) {
|
|
521
645
|
opts.ifNoneMatch = lastSync.etag;
|
|
522
646
|
}
|
|
523
|
-
if (lastSync?.syncedAt && placementsComplete) {
|
|
647
|
+
if (lastSync?.syncedAt && placementsComplete && !needsFullResync) {
|
|
524
648
|
opts.since = lastSync.syncedAt;
|
|
525
649
|
}
|
|
526
650
|
|
|
527
651
|
// Track whether this is a full or delta sync BEFORE the network
|
|
528
|
-
// call, for the returned summary's `fullSync` field.
|
|
529
|
-
// is
|
|
530
|
-
//
|
|
531
|
-
//
|
|
532
|
-
//
|
|
652
|
+
// call, for the returned summary's `fullSync` field. The documented
|
|
653
|
+
// semantic is "no PRIOR last-sync state existed" — NOT "no `since` was
|
|
654
|
+
// sent". A forced full re-fetch (placement-incomplete, or the #1911
|
|
655
|
+
// membership heal) still reports `fullSync:false` because prior state
|
|
656
|
+
// existed; only a genuine first sync reports true. Consumers (init.mjs)
|
|
657
|
+
// rely on this to tell "empty library" from "nothing changed since last
|
|
658
|
+
// sync" in the zero-counters case. Locked by sync.test.mjs.
|
|
533
659
|
const fullSync = !lastSync?.syncedAt;
|
|
534
660
|
|
|
535
661
|
const result = await getLibrary(serverUrl, apiKey, opts);
|
|
536
662
|
|
|
537
663
|
// Step 4: 304 short-circuit
|
|
538
664
|
if (result.notModified) {
|
|
665
|
+
// Re-assert the on-disk state even though nothing changed (#1832).
|
|
666
|
+
// The library is unchanged, so what's on disk is exactly the prior
|
|
667
|
+
// `.last-sync` skills map. This receipt:
|
|
668
|
+
// • is the liveness signal that REPLACES the GET-side 304 heartbeat
|
|
669
|
+
// for receipt-era servers (>= 4.7.0 CLIs no longer trigger it), so
|
|
670
|
+
// a deliberately-stable library doesn't drift to "stale"; and
|
|
671
|
+
// • heals any delivery the server missed (a receipt POST that failed
|
|
672
|
+
// on a prior sync), correcting drift in the safe direction.
|
|
673
|
+
// Timestamp is the CLI's "now" so freshness advances; the server's
|
|
674
|
+
// 5-minute future tolerance absorbs ordinary clock skew. Best-effort.
|
|
675
|
+
await reportReceipt(
|
|
676
|
+
serverUrl,
|
|
677
|
+
apiKey,
|
|
678
|
+
receiptEntriesFromSkillsMap(lastSync?.skills),
|
|
679
|
+
new Date().toISOString(),
|
|
680
|
+
stderr,
|
|
681
|
+
);
|
|
539
682
|
return {
|
|
540
683
|
added: 0,
|
|
541
684
|
updated: 0,
|
|
@@ -606,11 +749,6 @@ export async function runSync(options) {
|
|
|
606
749
|
let anyIncomplete = false;
|
|
607
750
|
/** @type {Record<string, SyncedSkillEntry>} */
|
|
608
751
|
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
752
|
// Carry forward entries from the previous sync for skills we did
|
|
615
753
|
// NOT re-fetch this round. A delta sync that touched 2 of 50
|
|
616
754
|
// skills must keep the other 48's metadata — otherwise the
|
|
@@ -674,17 +812,6 @@ export async function runSync(options) {
|
|
|
674
812
|
syncedAt: result.syncedAt,
|
|
675
813
|
};
|
|
676
814
|
}
|
|
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
815
|
}
|
|
689
816
|
|
|
690
817
|
// Step 7: persist new ETag + skills map (only if the response was
|
|
@@ -712,33 +839,92 @@ export async function runSync(options) {
|
|
|
712
839
|
}
|
|
713
840
|
}
|
|
714
841
|
|
|
715
|
-
//
|
|
716
|
-
//
|
|
717
|
-
//
|
|
718
|
-
//
|
|
719
|
-
//
|
|
720
|
-
//
|
|
721
|
-
//
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
842
|
+
// Re-assert the full on-disk state via a sync receipt (#1832). This
|
|
843
|
+
// confirmed-write signal — not the fetch itself — is what sets team
|
|
844
|
+
// sync state. We post the COMPLETE post-sync on-disk set (`skillsMap`:
|
|
845
|
+
// carry-forward skills + the skills written this round), not just what
|
|
846
|
+
// changed, so the receipt heals any delivery the server missed (a prior
|
|
847
|
+
// receipt POST that failed) and re-stamps freshness, replacing the
|
|
848
|
+
// GET-side heartbeat. `skillsMap` already reflects exactly what is on
|
|
849
|
+
// disk: skills skipped for `filesIncomplete` keep their prior carry-
|
|
850
|
+
// forward version (still on disk) and tombstoned skills were deleted
|
|
851
|
+
// from the map, so a partial or delta sync still re-asserts precisely
|
|
852
|
+
// what landed on disk. The server dedups (user, skill, version, UTC
|
|
853
|
+
// day), so re-asserting unchanged versions doesn't bloat the event log.
|
|
854
|
+
// Best-effort: files are already written, so a failed receipt is a
|
|
855
|
+
// non-fatal warning, never a sync failure.
|
|
856
|
+
await reportReceipt(
|
|
857
|
+
serverUrl,
|
|
858
|
+
apiKey,
|
|
859
|
+
receiptEntriesFromSkillsMap(skillsMap),
|
|
860
|
+
result.syncedAt,
|
|
861
|
+
stderr,
|
|
862
|
+
);
|
|
736
863
|
|
|
737
864
|
return summary;
|
|
738
865
|
}
|
|
739
866
|
|
|
740
867
|
// ── Internals ──────────────────────────────────────────────────────────
|
|
741
868
|
|
|
869
|
+
/**
|
|
870
|
+
* Build sync-receipt entries from a `.last-sync` skills map (#1832).
|
|
871
|
+
*
|
|
872
|
+
* Each key is `"<owner>/<name>"`; the value carries the version label of
|
|
873
|
+
* the skill as written to disk. The returned `[{ owner, name, version }]`
|
|
874
|
+
* array is the CLI's assertion of what it actually has on disk — the
|
|
875
|
+
* evidence the server treats as the single source of truth for sync
|
|
876
|
+
* state. Entries with no version label are skipped: a skill with no
|
|
877
|
+
* published version can't resolve to a delivery server-side. Malformed
|
|
878
|
+
* keys (no `/`, empty owner/name) are skipped defensively rather than
|
|
879
|
+
* producing a bogus receipt entry.
|
|
880
|
+
*
|
|
881
|
+
* @param {Record<string, {version?: string}> | null | undefined} skillsMap
|
|
882
|
+
* @returns {{owner: string, name: string, version: string}[]}
|
|
883
|
+
*/
|
|
884
|
+
function receiptEntriesFromSkillsMap(skillsMap) {
|
|
885
|
+
/** @type {{owner: string, name: string, version: string}[]} */
|
|
886
|
+
const entries = [];
|
|
887
|
+
if (!skillsMap || typeof skillsMap !== "object") return entries;
|
|
888
|
+
for (const [key, entry] of Object.entries(skillsMap)) {
|
|
889
|
+
if (!entry || typeof entry !== "object") continue;
|
|
890
|
+
const version = typeof entry.version === "string" ? entry.version : "";
|
|
891
|
+
if (version === "") continue;
|
|
892
|
+
const slashAt = key.indexOf("/");
|
|
893
|
+
if (slashAt < 0) continue;
|
|
894
|
+
const owner = key.slice(0, slashAt);
|
|
895
|
+
const name = key.slice(slashAt + 1);
|
|
896
|
+
if (!owner || !name) continue;
|
|
897
|
+
entries.push({ owner, name, version });
|
|
898
|
+
}
|
|
899
|
+
return entries;
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
/**
|
|
903
|
+
* Post a sync receipt, best-effort (#1832). A no-op when `skills` is
|
|
904
|
+
* empty (nothing on disk to assert — e.g. an empty library). The skill
|
|
905
|
+
* files are already on disk and the server dedups replays, so a failed
|
|
906
|
+
* receipt is a non-fatal warning surfaced via the injected stderr
|
|
907
|
+
* stream — never a sync failure.
|
|
908
|
+
*
|
|
909
|
+
* @param {string} serverUrl
|
|
910
|
+
* @param {string} apiKey
|
|
911
|
+
* @param {{owner: string, name: string, version: string}[]} skills
|
|
912
|
+
* @param {string} syncedAt - ISO timestamp for the delivery + dedup key.
|
|
913
|
+
* @param {NodeJS.WritableStream} stderr
|
|
914
|
+
*/
|
|
915
|
+
async function reportReceipt(serverUrl, apiKey, skills, syncedAt, stderr) {
|
|
916
|
+
if (skills.length === 0) return;
|
|
917
|
+
try {
|
|
918
|
+
await postSyncReceipt(serverUrl, apiKey, { skills, syncedAt });
|
|
919
|
+
} catch (err) {
|
|
920
|
+
stderr.write(
|
|
921
|
+
` warning: failed to report sync receipt (${err.message}). ` +
|
|
922
|
+
`Your skills are synced locally; team sync status may lag until ` +
|
|
923
|
+
`your next sync.\n`,
|
|
924
|
+
);
|
|
925
|
+
}
|
|
926
|
+
}
|
|
927
|
+
|
|
742
928
|
/**
|
|
743
929
|
* Check whether ANY of the configured placement targets already
|
|
744
930
|
* contains a directory for the given skill name. Used to distinguish
|
|
@@ -571,21 +571,29 @@ export function createMockServer(initialPayload, options = {}) {
|
|
|
571
571
|
// newly-detected vendor placements empty forever. Mirroring the
|
|
572
572
|
// filter here means any future regression of that class fails
|
|
573
573
|
// at test time. See src/lib/queries/library.ts in the server
|
|
574
|
-
// for the production behavior
|
|
574
|
+
// for the production behavior. As of #1911 the production delta is
|
|
575
|
+
// `or(gt(skills.updatedAt, since), gt(skillLibraryItems.addedAt, since))`
|
|
576
|
+
// — a membership add delivers a skill whose CONTENT predates `since`.
|
|
577
|
+
// Mirror BOTH arms here, otherwise no CLI-layer test can exercise the
|
|
578
|
+
// addedAt branch and a server-side revert of it would pass every CLI
|
|
579
|
+
// test. Fixture skills may carry an optional `addedAt` ISO string to
|
|
580
|
+
// drive that arm; absent → only the updatedAt arm applies.
|
|
575
581
|
let respondedSkills = libraryResponse.skills ?? [];
|
|
576
582
|
if (sinceParam && Array.isArray(respondedSkills)) {
|
|
577
583
|
const sinceDate = new Date(sinceParam);
|
|
578
584
|
if (!Number.isNaN(sinceDate.getTime())) {
|
|
579
|
-
|
|
580
|
-
//
|
|
581
|
-
//
|
|
582
|
-
//
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
585
|
+
const afterSince = (iso) => {
|
|
586
|
+
// Missing/invalid → treat as "older than since" (excluded by
|
|
587
|
+
// this arm), matching the production semantic of "not modified
|
|
588
|
+
// / not added in the window".
|
|
589
|
+
if (typeof iso !== "string") return false;
|
|
590
|
+
const t = new Date(iso);
|
|
591
|
+
if (Number.isNaN(t.getTime())) return false;
|
|
592
|
+
return t.getTime() > sinceDate.getTime();
|
|
593
|
+
};
|
|
594
|
+
respondedSkills = respondedSkills.filter(
|
|
595
|
+
(s) => afterSince(s.updatedAt) || afterSince(s.addedAt),
|
|
596
|
+
);
|
|
589
597
|
}
|
|
590
598
|
}
|
|
591
599
|
|
|
@@ -38,7 +38,11 @@ import {
|
|
|
38
38
|
runSync,
|
|
39
39
|
readLastSync,
|
|
40
40
|
writeLastSync,
|
|
41
|
+
upsertLastSyncEntry,
|
|
42
|
+
deleteLastSyncEntry,
|
|
41
43
|
placementsAreComplete,
|
|
44
|
+
cliVersionBelowFloor,
|
|
45
|
+
FULL_RESYNC_FLOOR,
|
|
42
46
|
LAST_SYNC_SCHEMA_VERSION,
|
|
43
47
|
} from "../../lib/sync.mjs";
|
|
44
48
|
import { resolvePlacementDir } from "../../lib/file-write.mjs";
|
|
@@ -263,6 +267,22 @@ describe("readLastSync / writeLastSync", () => {
|
|
|
263
267
|
writeLastSync({ etag: "x", syncedAt: "x" });
|
|
264
268
|
assert.ok(existsSync(globalLastSyncPath()));
|
|
265
269
|
});
|
|
270
|
+
|
|
271
|
+
it("stamps cliVersion with the running CLI version by default (#1911)", () => {
|
|
272
|
+
// Every write records the writer's version so the heal gate
|
|
273
|
+
// (cliVersionBelowFloor) can tell on the next run whether the state
|
|
274
|
+
// predates the #1911 fix. getCliVersion() reads the real
|
|
275
|
+
// packages/cli/package.json, so this is a genuine semver.
|
|
276
|
+
writeLastSync({ etag: '"v"', syncedAt: "x" });
|
|
277
|
+
const onDisk = JSON.parse(readFileSync(globalLastSyncPath(), "utf-8"));
|
|
278
|
+
assert.match(onDisk.cliVersion, /^\d+\.\d+\.\d+/);
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
it("honors an explicit cliVersion override (test seam for pre-fix state) (#1911)", () => {
|
|
282
|
+
writeLastSync({ etag: '"v"', syncedAt: "x", cliVersion: "4.7.0" });
|
|
283
|
+
const onDisk = JSON.parse(readFileSync(globalLastSyncPath(), "utf-8"));
|
|
284
|
+
assert.equal(onDisk.cliVersion, "4.7.0");
|
|
285
|
+
});
|
|
266
286
|
});
|
|
267
287
|
|
|
268
288
|
// ── runSync — empty library ────────────────────────────────────────────
|
|
@@ -415,7 +435,7 @@ describe("runSync — sync receipts (#1832)", () => {
|
|
|
415
435
|
}
|
|
416
436
|
});
|
|
417
437
|
|
|
418
|
-
it("
|
|
438
|
+
it("re-asserts the on-disk state on a 304 (liveness + heal), not nothing", async () => {
|
|
419
439
|
server.setEtag('"v1"');
|
|
420
440
|
server.setLibraryResponse({
|
|
421
441
|
skills: [makeSkill("pdf-helper")],
|
|
@@ -427,14 +447,120 @@ describe("runSync — sync receipts (#1832)", () => {
|
|
|
427
447
|
assert.equal(server.getReceiptRequestCount(), 1);
|
|
428
448
|
server.resetReceipts();
|
|
429
449
|
|
|
430
|
-
// Second sync 304-short-circuits (etag matches, placement present)
|
|
450
|
+
// Second sync 304-short-circuits (etag matches, placement present) but
|
|
451
|
+
// STILL posts a receipt re-asserting the on-disk set. This is the
|
|
452
|
+
// liveness signal that replaces the GET-side 304 heartbeat for
|
|
453
|
+
// receipt-era servers, and it heals any delivery a prior failed
|
|
454
|
+
// receipt POST lost (#1832 PR B).
|
|
431
455
|
const result = await runSync({
|
|
432
456
|
serverUrl,
|
|
433
457
|
apiKey: VALID_KEY,
|
|
434
458
|
vendors: ["claudeCode"],
|
|
435
459
|
});
|
|
436
460
|
assert.equal(result.notModified, true);
|
|
437
|
-
assert.equal(server.getReceiptRequestCount(),
|
|
461
|
+
assert.equal(server.getReceiptRequestCount(), 1);
|
|
462
|
+
const receipt = server.getLastReceipt();
|
|
463
|
+
assert.deepEqual(
|
|
464
|
+
receipt.skills.map((s) => s.name),
|
|
465
|
+
["pdf-helper"],
|
|
466
|
+
);
|
|
467
|
+
assert.equal(receipt.skills[0].version, "1.0.0");
|
|
468
|
+
// syncedAt on a 304 is the CLI's "now" (the server sends no body),
|
|
469
|
+
// so freshness advances — not the stale first-sync timestamp.
|
|
470
|
+
assert.ok(
|
|
471
|
+
Number.isFinite(new Date(receipt.syncedAt).getTime()),
|
|
472
|
+
"304 receipt carries a valid ISO syncedAt",
|
|
473
|
+
);
|
|
474
|
+
assert.notEqual(
|
|
475
|
+
receipt.syncedAt,
|
|
476
|
+
"2025-06-01T00:00:00Z",
|
|
477
|
+
"304 receipt uses the CLI's now, not the prior sync timestamp",
|
|
478
|
+
);
|
|
479
|
+
// Stronger than "not the fixture": prove the timestamp is wall-clock
|
|
480
|
+
// FRESH. A regression that stamped the receipt with any stale cached
|
|
481
|
+
// value (e.g. lastSync.syncedAt) could differ from the fixture and
|
|
482
|
+
// still pass the notEqual above — but it would fail this. The liveness
|
|
483
|
+
// signal that replaces the 304 heartbeat must advance every sync.
|
|
484
|
+
assert.ok(
|
|
485
|
+
Date.now() - new Date(receipt.syncedAt).getTime() < 60_000,
|
|
486
|
+
"304 receipt syncedAt must be wall-clock fresh (within the last minute)",
|
|
487
|
+
);
|
|
488
|
+
});
|
|
489
|
+
|
|
490
|
+
it("re-asserts the full on-disk set (carry-forward + newly written) on a delta sync", async () => {
|
|
491
|
+
server.setEtag('"v1"');
|
|
492
|
+
server.setLibraryResponse({
|
|
493
|
+
skills: [makeSkill("alpha"), makeSkill("beta")],
|
|
494
|
+
removals: [],
|
|
495
|
+
syncedAt: "2025-01-01T00:00:00Z",
|
|
496
|
+
});
|
|
497
|
+
await runSync({ serverUrl, apiKey: VALID_KEY, vendors: ["claudeCode"] });
|
|
498
|
+
server.resetReceipts();
|
|
499
|
+
|
|
500
|
+
// Library changes: only `gamma` is new this round; alpha+beta are
|
|
501
|
+
// unchanged and arrive only via the carry-forward `.last-sync` map.
|
|
502
|
+
server.setEtag('"v2"');
|
|
503
|
+
server.setLibraryResponse({
|
|
504
|
+
skills: [makeSkill("gamma")],
|
|
505
|
+
removals: [],
|
|
506
|
+
syncedAt: "2025-01-02T00:00:00Z",
|
|
507
|
+
});
|
|
508
|
+
const result = await runSync({
|
|
509
|
+
serverUrl,
|
|
510
|
+
apiKey: VALID_KEY,
|
|
511
|
+
vendors: ["claudeCode"],
|
|
512
|
+
});
|
|
513
|
+
|
|
514
|
+
assert.equal(result.notModified, false);
|
|
515
|
+
assert.equal(server.getReceiptRequestCount(), 1);
|
|
516
|
+
const receipt = server.getLastReceipt();
|
|
517
|
+
// The receipt re-asserts EVERYTHING on disk, not just the changed
|
|
518
|
+
// skill — that is what heals server drift and keeps freshness honest.
|
|
519
|
+
assert.deepEqual(
|
|
520
|
+
receipt.skills.map((s) => s.name).sort(),
|
|
521
|
+
["alpha", "beta", "gamma"],
|
|
522
|
+
);
|
|
523
|
+
// A 200 uses the server's response syncedAt for the delivery time.
|
|
524
|
+
assert.equal(receipt.syncedAt, "2025-01-02T00:00:00Z");
|
|
525
|
+
});
|
|
526
|
+
|
|
527
|
+
it("re-asserts carry-forward on a 200 delta that writes nothing new", async () => {
|
|
528
|
+
// Distinct from the 304 short-circuit: here the ETag changes (a real
|
|
529
|
+
// 200) but the response carries no skills to write, so nothing new
|
|
530
|
+
// lands on disk. The receipt must still re-assert the full carry-
|
|
531
|
+
// forward set — the 200 path's re-assertion is driven by the on-disk
|
|
532
|
+
// `.last-sync` map, not by what was written this round.
|
|
533
|
+
server.setEtag('"v1"');
|
|
534
|
+
server.setLibraryResponse({
|
|
535
|
+
skills: [makeSkill("alpha"), makeSkill("beta")],
|
|
536
|
+
removals: [],
|
|
537
|
+
syncedAt: "2025-01-01T00:00:00Z",
|
|
538
|
+
});
|
|
539
|
+
await runSync({ serverUrl, apiKey: VALID_KEY, vendors: ["claudeCode"] });
|
|
540
|
+
server.resetReceipts();
|
|
541
|
+
|
|
542
|
+
server.setEtag('"v2"');
|
|
543
|
+
server.setLibraryResponse({
|
|
544
|
+
skills: [],
|
|
545
|
+
removals: [],
|
|
546
|
+
syncedAt: "2025-01-02T00:00:00Z",
|
|
547
|
+
});
|
|
548
|
+
const result = await runSync({
|
|
549
|
+
serverUrl,
|
|
550
|
+
apiKey: VALID_KEY,
|
|
551
|
+
vendors: ["claudeCode"],
|
|
552
|
+
});
|
|
553
|
+
|
|
554
|
+
assert.equal(result.notModified, false);
|
|
555
|
+
assert.equal(result.added, 0);
|
|
556
|
+
assert.equal(result.updated, 0);
|
|
557
|
+
assert.equal(server.getReceiptRequestCount(), 1);
|
|
558
|
+
const receipt = server.getLastReceipt();
|
|
559
|
+
assert.deepEqual(
|
|
560
|
+
receipt.skills.map((s) => s.name).sort(),
|
|
561
|
+
["alpha", "beta"],
|
|
562
|
+
);
|
|
563
|
+
assert.equal(receipt.syncedAt, "2025-01-02T00:00:00Z");
|
|
438
564
|
});
|
|
439
565
|
|
|
440
566
|
it("excludes a filesIncomplete skill from the receipt", async () => {
|
|
@@ -454,9 +580,48 @@ describe("runSync — sync receipts (#1832)", () => {
|
|
|
454
580
|
);
|
|
455
581
|
});
|
|
456
582
|
|
|
457
|
-
it("
|
|
458
|
-
//
|
|
459
|
-
|
|
583
|
+
it("re-asserts the OLD on-disk version when a newer version fails to inline (filesIncomplete carry-forward)", async () => {
|
|
584
|
+
// First sync writes pdf-helper@1.0.0.
|
|
585
|
+
server.setEtag('"v1"');
|
|
586
|
+
server.setLibraryResponse({
|
|
587
|
+
skills: [makeSkill("pdf-helper")],
|
|
588
|
+
removals: [],
|
|
589
|
+
syncedAt: "2025-01-01T00:00:00Z",
|
|
590
|
+
});
|
|
591
|
+
await runSync({ serverUrl, apiKey: VALID_KEY, vendors: ["claudeCode"] });
|
|
592
|
+
server.resetReceipts();
|
|
593
|
+
|
|
594
|
+
// The server tries to deliver pdf-helper@1.1.0 but its files fail to
|
|
595
|
+
// inline. The CLI skips the write (1.0.0 stays on disk) and must
|
|
596
|
+
// re-assert the OLD version it still has — not the version it failed
|
|
597
|
+
// to write, and not nothing.
|
|
598
|
+
const newer = makeSkill("pdf-helper");
|
|
599
|
+
newer.version = "1.1.0";
|
|
600
|
+
newer.filesIncomplete = true;
|
|
601
|
+
server.setEtag('"v2"');
|
|
602
|
+
server.setLibraryResponse({
|
|
603
|
+
skills: [newer],
|
|
604
|
+
removals: [],
|
|
605
|
+
syncedAt: "2025-01-02T00:00:00Z",
|
|
606
|
+
});
|
|
607
|
+
await runSync({ serverUrl, apiKey: VALID_KEY, vendors: ["claudeCode"] });
|
|
608
|
+
|
|
609
|
+
assert.equal(server.getReceiptRequestCount(), 1);
|
|
610
|
+
const receipt = server.getLastReceipt();
|
|
611
|
+
assert.deepEqual(receipt.skills, [
|
|
612
|
+
{ owner: "alice", name: "pdf-helper", version: "1.0.0" },
|
|
613
|
+
]);
|
|
614
|
+
// Freshness still advances on a partial sync: the receipt carries the
|
|
615
|
+
// SERVER's 200 response syncedAt (the fresh sync time), not the stale
|
|
616
|
+
// carry-forward entry's own recorded syncedAt.
|
|
617
|
+
assert.equal(receipt.syncedAt, "2025-01-02T00:00:00Z");
|
|
618
|
+
});
|
|
619
|
+
|
|
620
|
+
it("does NOT post a receipt when ALL skills are filesIncomplete (nothing on disk to assert)", async () => {
|
|
621
|
+
// A first sync where every skill is filesIncomplete writes nothing
|
|
622
|
+
// and has no carry-forward `.last-sync` baseline, so the post-sync
|
|
623
|
+
// on-disk set is empty and `reportReceipt`'s empty-skills guard skips
|
|
624
|
+
// the POST.
|
|
460
625
|
const a = makeSkill("inc-a");
|
|
461
626
|
a.filesIncomplete = true;
|
|
462
627
|
const b = makeSkill("inc-b");
|
|
@@ -821,10 +986,41 @@ describe("runSync — tombstones", () => {
|
|
|
821
986
|
});
|
|
822
987
|
assert.equal(result.removed, 1);
|
|
823
988
|
assert.ok(!existsSync(resolvePlacementDir("claudeProject", "doomed")));
|
|
824
|
-
//
|
|
989
|
+
// The `.last-sync` baseline was reset (rm above) and the only skill
|
|
990
|
+
// was tombstoned, so the post-sync on-disk set is empty → the
|
|
991
|
+
// re-assertion receipt has nothing to send and is skipped (#1832).
|
|
825
992
|
assert.equal(server.getReceiptRequestCount(), 0);
|
|
826
993
|
});
|
|
827
994
|
|
|
995
|
+
it("re-asserts surviving skills but excludes a tombstoned one", async () => {
|
|
996
|
+
server.setEtag('"v1"');
|
|
997
|
+
server.setLibraryResponse({
|
|
998
|
+
skills: [makeSkill("keep"), makeSkill("drop")],
|
|
999
|
+
removals: [],
|
|
1000
|
+
syncedAt: "2025-01-01T00:00:00Z",
|
|
1001
|
+
});
|
|
1002
|
+
await runSync({ serverUrl, apiKey: VALID_KEY, vendors: ["claudeCode"] });
|
|
1003
|
+
server.resetReceipts();
|
|
1004
|
+
|
|
1005
|
+
// `drop` is tombstoned; `keep` stays on disk and must be re-asserted.
|
|
1006
|
+
server.setEtag('"v2"');
|
|
1007
|
+
server.setLibraryResponse({
|
|
1008
|
+
skills: [],
|
|
1009
|
+
removals: [
|
|
1010
|
+
{ owner: "alice", name: "drop", removedAt: "2025-01-02T00:00:00Z" },
|
|
1011
|
+
],
|
|
1012
|
+
syncedAt: "2025-01-02T00:00:00Z",
|
|
1013
|
+
});
|
|
1014
|
+
await runSync({ serverUrl, apiKey: VALID_KEY, vendors: ["claudeCode"] });
|
|
1015
|
+
|
|
1016
|
+
assert.equal(server.getReceiptRequestCount(), 1);
|
|
1017
|
+
const receipt = server.getLastReceipt();
|
|
1018
|
+
assert.deepEqual(
|
|
1019
|
+
receipt.skills.map((s) => s.name),
|
|
1020
|
+
["keep"],
|
|
1021
|
+
);
|
|
1022
|
+
});
|
|
1023
|
+
|
|
828
1024
|
it("applies removals BEFORE writes (re-add scenario)", async () => {
|
|
829
1025
|
// The server returns a tombstone AND a skill with the same name
|
|
830
1026
|
// (the user removed and re-added in the same window). The CLI
|
|
@@ -950,6 +1146,286 @@ describe("runSync — ETag round-trip", () => {
|
|
|
950
1146
|
|
|
951
1147
|
// ── runSync — argument validation ──────────────────────────────────────
|
|
952
1148
|
|
|
1149
|
+
describe("runSync — one-time membership heal (#1911)", () => {
|
|
1150
|
+
beforeEach(setupServer);
|
|
1151
|
+
afterEach(teardownServer);
|
|
1152
|
+
|
|
1153
|
+
it("cliVersionBelowFloor: missing/invalid/older → true; equal/newer → false", () => {
|
|
1154
|
+
assert.equal(cliVersionBelowFloor(undefined, "4.8.0"), true);
|
|
1155
|
+
assert.equal(cliVersionBelowFloor(null, "4.8.0"), true);
|
|
1156
|
+
assert.equal(cliVersionBelowFloor("", "4.8.0"), true);
|
|
1157
|
+
assert.equal(cliVersionBelowFloor("not-semver", "4.8.0"), true);
|
|
1158
|
+
assert.equal(cliVersionBelowFloor("4.7.0", "4.8.0"), true);
|
|
1159
|
+
assert.equal(cliVersionBelowFloor("4.7.9", "4.8.0"), true);
|
|
1160
|
+
assert.equal(cliVersionBelowFloor("4.8.0", "4.8.0"), false);
|
|
1161
|
+
assert.equal(cliVersionBelowFloor("4.8.1", "4.8.0"), false);
|
|
1162
|
+
assert.equal(cliVersionBelowFloor("5.0.0", "4.8.0"), false);
|
|
1163
|
+
});
|
|
1164
|
+
|
|
1165
|
+
it("FULL_RESYNC_FLOOR equals the shipped CLI version", () => {
|
|
1166
|
+
// The floor MUST equal the version that ships this fix; otherwise the
|
|
1167
|
+
// running CLI's own writes would stamp a version below the floor and
|
|
1168
|
+
// it would re-heal on every run. Lock them together.
|
|
1169
|
+
const pkg = JSON.parse(
|
|
1170
|
+
readFileSync(new URL("../../../package.json", import.meta.url), "utf-8"),
|
|
1171
|
+
);
|
|
1172
|
+
assert.equal(FULL_RESYNC_FLOOR, pkg.version);
|
|
1173
|
+
});
|
|
1174
|
+
|
|
1175
|
+
it("a pre-fix .last-sync forces ONE full re-fetch, then resumes delta", async () => {
|
|
1176
|
+
server.setEtag('"v1"');
|
|
1177
|
+
server.setLibraryResponse({
|
|
1178
|
+
skills: [makeSkill("healme")],
|
|
1179
|
+
removals: [],
|
|
1180
|
+
syncedAt: "2025-01-01T00:00:00Z",
|
|
1181
|
+
});
|
|
1182
|
+
|
|
1183
|
+
// 1) Normal sync by the current CLI → disk + .last-sync stamped with
|
|
1184
|
+
// the current (>= floor) version.
|
|
1185
|
+
await runSync({ serverUrl, apiKey: VALID_KEY, vendors: ["claudeCode"] });
|
|
1186
|
+
|
|
1187
|
+
// 2) Rewrite .last-sync to mimic a PRE-#1911 writer: identical
|
|
1188
|
+
// etag/skills/syncedAt — so placements are complete and a 304 WOULD
|
|
1189
|
+
// normally fire — but stamped below the heal floor.
|
|
1190
|
+
const prior = readLastSync();
|
|
1191
|
+
writeLastSync({
|
|
1192
|
+
etag: prior.etag,
|
|
1193
|
+
syncedAt: prior.syncedAt,
|
|
1194
|
+
skills: prior.skills,
|
|
1195
|
+
cliVersion: "4.7.0",
|
|
1196
|
+
});
|
|
1197
|
+
server.resetLibraryInspection();
|
|
1198
|
+
|
|
1199
|
+
// 3) Heal: must force a FULL fetch — NEITHER conditional header sent —
|
|
1200
|
+
// despite complete placements and a matching etag.
|
|
1201
|
+
const heal = await runSync({
|
|
1202
|
+
serverUrl,
|
|
1203
|
+
apiKey: VALID_KEY,
|
|
1204
|
+
vendors: ["claudeCode"],
|
|
1205
|
+
});
|
|
1206
|
+
assert.equal(
|
|
1207
|
+
server.getLastLibraryIfNoneMatch(),
|
|
1208
|
+
null,
|
|
1209
|
+
"heal must NOT send If-None-Match",
|
|
1210
|
+
);
|
|
1211
|
+
assert.equal(server.getLastLibrarySince(), null, "heal must NOT send since");
|
|
1212
|
+
assert.equal(heal.notModified, false, "heal performs a real fetch, not a 304");
|
|
1213
|
+
|
|
1214
|
+
// 4) The heal write re-stamped the current version → the next sync
|
|
1215
|
+
// resumes normal delta (sends the conditional, gets a 304).
|
|
1216
|
+
const after = readLastSync();
|
|
1217
|
+
assert.equal(
|
|
1218
|
+
cliVersionBelowFloor(after.cliVersion, FULL_RESYNC_FLOOR),
|
|
1219
|
+
false,
|
|
1220
|
+
"heal must restamp cliVersion at/above the floor",
|
|
1221
|
+
);
|
|
1222
|
+
server.resetLibraryInspection();
|
|
1223
|
+
const resumed = await runSync({
|
|
1224
|
+
serverUrl,
|
|
1225
|
+
apiKey: VALID_KEY,
|
|
1226
|
+
vendors: ["claudeCode"],
|
|
1227
|
+
});
|
|
1228
|
+
assert.equal(
|
|
1229
|
+
server.getLastLibraryIfNoneMatch(),
|
|
1230
|
+
'"v1"',
|
|
1231
|
+
"post-heal sync sends If-None-Match",
|
|
1232
|
+
);
|
|
1233
|
+
assert.equal(
|
|
1234
|
+
resumed.notModified,
|
|
1235
|
+
true,
|
|
1236
|
+
"post-heal sync short-circuits on 304",
|
|
1237
|
+
);
|
|
1238
|
+
});
|
|
1239
|
+
|
|
1240
|
+
it("does NOT force a re-fetch when .last-sync was written at/above the floor", async () => {
|
|
1241
|
+
server.setEtag('"v1"');
|
|
1242
|
+
server.setLibraryResponse({
|
|
1243
|
+
skills: [makeSkill("steady")],
|
|
1244
|
+
removals: [],
|
|
1245
|
+
syncedAt: "2025-01-01T00:00:00Z",
|
|
1246
|
+
});
|
|
1247
|
+
// First sync stamps the current (>= floor) version.
|
|
1248
|
+
await runSync({ serverUrl, apiKey: VALID_KEY, vendors: ["claudeCode"] });
|
|
1249
|
+
server.resetLibraryInspection();
|
|
1250
|
+
|
|
1251
|
+
const second = await runSync({
|
|
1252
|
+
serverUrl,
|
|
1253
|
+
apiKey: VALID_KEY,
|
|
1254
|
+
vendors: ["claudeCode"],
|
|
1255
|
+
});
|
|
1256
|
+
assert.equal(
|
|
1257
|
+
server.getLastLibraryIfNoneMatch(),
|
|
1258
|
+
'"v1"',
|
|
1259
|
+
"steady-state sync still sends the conditional (no spurious heal)",
|
|
1260
|
+
);
|
|
1261
|
+
assert.equal(second.notModified, true, "no heal → normal 304");
|
|
1262
|
+
});
|
|
1263
|
+
|
|
1264
|
+
it("heals a REAL pre-fix v2 .last-sync (cliVersion key absent, not just old)", async () => {
|
|
1265
|
+
// The actual on-disk shape for users still on < 4.8.0 is a v2 file with
|
|
1266
|
+
// NO `cliVersion` key at all (writeLastSync didn't emit it pre-#1911) —
|
|
1267
|
+
// distinct from an explicit "4.7.0". Prove the heal fires for that shape
|
|
1268
|
+
// end-to-end through runSync (the unit test of cliVersionBelowFloor(undefined)
|
|
1269
|
+
// covers the gate in isolation; this covers the full path).
|
|
1270
|
+
server.setEtag('"v1"');
|
|
1271
|
+
server.setLibraryResponse({
|
|
1272
|
+
skills: [makeSkill("healme2")],
|
|
1273
|
+
removals: [],
|
|
1274
|
+
syncedAt: "2025-01-01T00:00:00Z",
|
|
1275
|
+
});
|
|
1276
|
+
await runSync({ serverUrl, apiKey: VALID_KEY, vendors: ["claudeCode"] });
|
|
1277
|
+
const prior = readLastSync();
|
|
1278
|
+
|
|
1279
|
+
// Rewrite as a genuine pre-#1911 v2 file: cliVersion key entirely absent.
|
|
1280
|
+
mkdirSync(join(process.env.HOME, ".claude", "skillrepo"), { recursive: true });
|
|
1281
|
+
writeFileSync(
|
|
1282
|
+
globalLastSyncPath(),
|
|
1283
|
+
JSON.stringify({
|
|
1284
|
+
schemaVersion: 2,
|
|
1285
|
+
etag: prior.etag,
|
|
1286
|
+
syncedAt: prior.syncedAt,
|
|
1287
|
+
skills: prior.skills,
|
|
1288
|
+
}),
|
|
1289
|
+
);
|
|
1290
|
+
const onDisk = JSON.parse(readFileSync(globalLastSyncPath(), "utf-8"));
|
|
1291
|
+
assert.equal(
|
|
1292
|
+
Object.prototype.hasOwnProperty.call(onDisk, "cliVersion"),
|
|
1293
|
+
false,
|
|
1294
|
+
"precondition: cliVersion must be ABSENT, not old",
|
|
1295
|
+
);
|
|
1296
|
+
server.resetLibraryInspection();
|
|
1297
|
+
|
|
1298
|
+
const heal = await runSync({
|
|
1299
|
+
serverUrl,
|
|
1300
|
+
apiKey: VALID_KEY,
|
|
1301
|
+
vendors: ["claudeCode"],
|
|
1302
|
+
});
|
|
1303
|
+
assert.equal(server.getLastLibraryIfNoneMatch(), null, "absent cliVersion must heal");
|
|
1304
|
+
assert.equal(server.getLastLibrarySince(), null, "absent cliVersion must heal");
|
|
1305
|
+
assert.equal(heal.notModified, false);
|
|
1306
|
+
assert.equal(
|
|
1307
|
+
cliVersionBelowFloor(readLastSync().cliVersion, FULL_RESYNC_FLOOR),
|
|
1308
|
+
false,
|
|
1309
|
+
"heal restamps a real version so it won't re-heal",
|
|
1310
|
+
);
|
|
1311
|
+
});
|
|
1312
|
+
|
|
1313
|
+
it("delta delivers a skill whose content predates `since` but was added after it", async () => {
|
|
1314
|
+
// The CLI-layer counterpart of the server fix: a steady-state DELTA sync
|
|
1315
|
+
// (since sent, NOT a heal) must still receive a skill whose updatedAt is
|
|
1316
|
+
// older than `since` when its addedAt is newer. The mock mirrors the
|
|
1317
|
+
// server's `or(updatedAt, addedAt)` filter so a server-side revert of the
|
|
1318
|
+
// addedAt branch would fail here, not silently pass.
|
|
1319
|
+
const S = "2025-06-01T00:00:00Z";
|
|
1320
|
+
server.setEtag('"e1"');
|
|
1321
|
+
server.setLibraryResponse({
|
|
1322
|
+
skills: [{ ...makeSkill("base"), updatedAt: "2025-06-02T00:00:00Z" }],
|
|
1323
|
+
removals: [],
|
|
1324
|
+
syncedAt: S,
|
|
1325
|
+
});
|
|
1326
|
+
// Baseline sync: puts `base` on disk, stamps current version (no heal).
|
|
1327
|
+
await runSync({ serverUrl, apiKey: VALID_KEY, vendors: ["claudeCode"] });
|
|
1328
|
+
|
|
1329
|
+
// A new skill enters the library: OLD content (updatedAt < S), just ADDED
|
|
1330
|
+
// (addedAt > S). Library etag changes (not a 304).
|
|
1331
|
+
server.setEtag('"e2"');
|
|
1332
|
+
server.setLibraryResponse({
|
|
1333
|
+
skills: [
|
|
1334
|
+
{ ...makeSkill("base"), updatedAt: "2025-06-02T00:00:00Z" },
|
|
1335
|
+
{
|
|
1336
|
+
...makeSkill("added-old"),
|
|
1337
|
+
updatedAt: "2025-01-01T00:00:00Z",
|
|
1338
|
+
addedAt: "2025-12-01T00:00:00Z",
|
|
1339
|
+
},
|
|
1340
|
+
],
|
|
1341
|
+
removals: [],
|
|
1342
|
+
syncedAt: "2025-12-02T00:00:00Z",
|
|
1343
|
+
});
|
|
1344
|
+
server.resetLibraryInspection();
|
|
1345
|
+
const result = await runSync({
|
|
1346
|
+
serverUrl,
|
|
1347
|
+
apiKey: VALID_KEY,
|
|
1348
|
+
vendors: ["claudeCode"],
|
|
1349
|
+
});
|
|
1350
|
+
|
|
1351
|
+
assert.equal(server.getLastLibrarySince(), S, "must be a DELTA (since sent), not a heal");
|
|
1352
|
+
assert.ok(
|
|
1353
|
+
existsSync(join(resolvePlacementDir("claudeProject", "added-old"), "SKILL.md")),
|
|
1354
|
+
"addedAt-only skill must be delivered via the membership branch",
|
|
1355
|
+
);
|
|
1356
|
+
assert.equal(result.added, 1, "the added-old skill counts as newly added");
|
|
1357
|
+
});
|
|
1358
|
+
|
|
1359
|
+
it("upsertLastSyncEntry preserves a pre-fix cliVersion so the heal isn't burned", () => {
|
|
1360
|
+
// A stuck pre-fix client whose FIRST 4.8.0 command is `add`/`get`
|
|
1361
|
+
// (→ upsertLastSyncEntry) must NOT have its heal-gate version advanced —
|
|
1362
|
+
// add fetches ONE skill, not the full library, so other stuck skills
|
|
1363
|
+
// remain missing and the next `update` must still heal.
|
|
1364
|
+
writeLastSync({
|
|
1365
|
+
etag: '"e"',
|
|
1366
|
+
syncedAt: "2025-01-01T00:00:00Z",
|
|
1367
|
+
skills: {},
|
|
1368
|
+
cliVersion: "4.7.0",
|
|
1369
|
+
});
|
|
1370
|
+
upsertLastSyncEntry({
|
|
1371
|
+
owner: "alice",
|
|
1372
|
+
name: "added",
|
|
1373
|
+
version: "1.0.0",
|
|
1374
|
+
files: [
|
|
1375
|
+
{ path: "SKILL.md", content: "---\nname: added\ndescription: d\n---\nbody" },
|
|
1376
|
+
],
|
|
1377
|
+
});
|
|
1378
|
+
assert.equal(
|
|
1379
|
+
readLastSync().cliVersion,
|
|
1380
|
+
"4.7.0",
|
|
1381
|
+
"add/get must not advance the heal-gate version",
|
|
1382
|
+
);
|
|
1383
|
+
});
|
|
1384
|
+
|
|
1385
|
+
it("upsertLastSyncEntry carries an ABSENT cliVersion forward (still below floor)", () => {
|
|
1386
|
+
mkdirSync(join(process.env.HOME, ".claude", "skillrepo"), { recursive: true });
|
|
1387
|
+
writeFileSync(
|
|
1388
|
+
globalLastSyncPath(),
|
|
1389
|
+
JSON.stringify({ schemaVersion: 2, etag: '"e"', syncedAt: "s", skills: {} }),
|
|
1390
|
+
);
|
|
1391
|
+
upsertLastSyncEntry({
|
|
1392
|
+
owner: "alice",
|
|
1393
|
+
name: "added",
|
|
1394
|
+
version: "1.0.0",
|
|
1395
|
+
files: [
|
|
1396
|
+
{ path: "SKILL.md", content: "---\nname: added\ndescription: d\n---\nbody" },
|
|
1397
|
+
],
|
|
1398
|
+
});
|
|
1399
|
+
assert.equal(
|
|
1400
|
+
cliVersionBelowFloor(readLastSync().cliVersion, FULL_RESYNC_FLOOR),
|
|
1401
|
+
true,
|
|
1402
|
+
"absent cliVersion stays below floor → next update still heals",
|
|
1403
|
+
);
|
|
1404
|
+
});
|
|
1405
|
+
|
|
1406
|
+
it("deleteLastSyncEntry preserves a pre-fix cliVersion", () => {
|
|
1407
|
+
writeLastSync({
|
|
1408
|
+
etag: '"e"',
|
|
1409
|
+
syncedAt: "s",
|
|
1410
|
+
skills: {
|
|
1411
|
+
"alice/x": {
|
|
1412
|
+
version: "1.0.0",
|
|
1413
|
+
skillMdSha256: "a".repeat(64),
|
|
1414
|
+
filesSha256: "b".repeat(64),
|
|
1415
|
+
syncedAt: "s",
|
|
1416
|
+
},
|
|
1417
|
+
},
|
|
1418
|
+
cliVersion: "4.7.0",
|
|
1419
|
+
});
|
|
1420
|
+
deleteLastSyncEntry("alice", "x");
|
|
1421
|
+
assert.equal(
|
|
1422
|
+
readLastSync().cliVersion,
|
|
1423
|
+
"4.7.0",
|
|
1424
|
+
"remove must not advance the heal-gate version",
|
|
1425
|
+
);
|
|
1426
|
+
});
|
|
1427
|
+
});
|
|
1428
|
+
|
|
953
1429
|
describe("runSync — input validation", () => {
|
|
954
1430
|
beforeEach(setupServer);
|
|
955
1431
|
afterEach(teardownServer);
|