skillrepo 4.7.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 +124 -8
- package/src/test/e2e/mock-server.mjs +19 -11
- package/src/test/lib/sync.test.mjs +300 -0
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
|
* ------------------------------
|
|
@@ -88,13 +93,20 @@
|
|
|
88
93
|
* @property {number} schemaVersion
|
|
89
94
|
* @property {string|null} etag
|
|
90
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.
|
|
91
100
|
* @property {Record<string, SyncedSkillEntry>} skills - v2+. Keyed by `"<owner>/<name>"`.
|
|
92
101
|
*/
|
|
93
102
|
|
|
94
103
|
import { existsSync, readFileSync } from "node:fs";
|
|
95
104
|
import { join } from "node:path";
|
|
96
105
|
|
|
106
|
+
import semver from "semver";
|
|
107
|
+
|
|
97
108
|
import { getLibrary, postSyncReceipt } from "./http.mjs";
|
|
109
|
+
import { getCliVersion } from "./cli-version.mjs";
|
|
98
110
|
import {
|
|
99
111
|
writeSkillDir,
|
|
100
112
|
removeSkillDir,
|
|
@@ -118,6 +130,66 @@ import { computeSkillShas } from "./crypto-shas.mjs";
|
|
|
118
130
|
*/
|
|
119
131
|
export const LAST_SYNC_SCHEMA_VERSION = 2;
|
|
120
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
|
+
|
|
121
193
|
/**
|
|
122
194
|
* Check whether every skill in the `.last-sync` baseline already exists
|
|
123
195
|
* under every placement target the current sync would write to. Returns
|
|
@@ -297,17 +369,26 @@ export function readLastSync() {
|
|
|
297
369
|
* sync"; consumers should treat absent entries as "unknown" rather
|
|
298
370
|
* than as "missing from library."
|
|
299
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
|
+
*
|
|
300
378
|
* @param {object} state
|
|
301
379
|
* @param {string | null} [state.etag]
|
|
302
380
|
* @param {string} [state.syncedAt]
|
|
303
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.
|
|
304
384
|
*/
|
|
305
|
-
export function writeLastSync({ etag, syncedAt, skills } = {}) {
|
|
385
|
+
export function writeLastSync({ etag, syncedAt, skills, cliVersion } = {}) {
|
|
306
386
|
const path = globalLastSyncPath();
|
|
307
387
|
const body = {
|
|
308
388
|
schemaVersion: LAST_SYNC_SCHEMA_VERSION,
|
|
309
389
|
etag: etag ?? null,
|
|
310
390
|
syncedAt: syncedAt ?? new Date().toISOString(),
|
|
391
|
+
cliVersion: cliVersion !== undefined ? cliVersion : safeCliVersion(),
|
|
311
392
|
skills: skills && typeof skills === "object" && !Array.isArray(skills) ? skills : {},
|
|
312
393
|
};
|
|
313
394
|
try {
|
|
@@ -390,6 +471,15 @@ export function upsertLastSyncEntry(skill) {
|
|
|
390
471
|
etag: prior?.etag ?? null,
|
|
391
472
|
syncedAt: prior?.syncedAt,
|
|
392
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,
|
|
393
483
|
});
|
|
394
484
|
}
|
|
395
485
|
|
|
@@ -413,6 +503,10 @@ export function deleteLastSyncEntry(owner, name) {
|
|
|
413
503
|
etag: prior.etag ?? null,
|
|
414
504
|
syncedAt: prior.syncedAt,
|
|
415
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,
|
|
416
510
|
});
|
|
417
511
|
}
|
|
418
512
|
|
|
@@ -527,19 +621,41 @@ export async function runSync(options) {
|
|
|
527
621
|
const placementsComplete =
|
|
528
622
|
lastSync?.etag &&
|
|
529
623
|
placementsAreComplete(lastSync.skills, vendors, global);
|
|
530
|
-
|
|
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) {
|
|
531
645
|
opts.ifNoneMatch = lastSync.etag;
|
|
532
646
|
}
|
|
533
|
-
if (lastSync?.syncedAt && placementsComplete) {
|
|
647
|
+
if (lastSync?.syncedAt && placementsComplete && !needsFullResync) {
|
|
534
648
|
opts.since = lastSync.syncedAt;
|
|
535
649
|
}
|
|
536
650
|
|
|
537
651
|
// Track whether this is a full or delta sync BEFORE the network
|
|
538
|
-
// call, for the returned summary's `fullSync` field.
|
|
539
|
-
// is
|
|
540
|
-
//
|
|
541
|
-
//
|
|
542
|
-
//
|
|
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.
|
|
543
659
|
const fullSync = !lastSync?.syncedAt;
|
|
544
660
|
|
|
545
661
|
const result = await getLibrary(serverUrl, apiKey, opts);
|
|
@@ -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 ────────────────────────────────────────────
|
|
@@ -1126,6 +1146,286 @@ describe("runSync — ETag round-trip", () => {
|
|
|
1126
1146
|
|
|
1127
1147
|
// ── runSync — argument validation ──────────────────────────────────────
|
|
1128
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
|
+
|
|
1129
1429
|
describe("runSync — input validation", () => {
|
|
1130
1430
|
beforeEach(setupServer);
|
|
1131
1431
|
afterEach(teardownServer);
|