skillrepo 4.17.2 → 4.18.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/README.md +18 -8
- package/package.json +1 -1
- package/src/commands/update.mjs +13 -0
- package/src/lib/file-write.mjs +151 -8
- package/src/lib/http.mjs +23 -1
- package/src/lib/paths.mjs +12 -0
- package/src/lib/sync.mjs +320 -58
- package/src/test/commands/update.test.mjs +37 -3
- package/src/test/e2e/mock-server.mjs +8 -0
- package/src/test/lib/file-write.test.mjs +168 -0
- package/src/test/lib/http.test.mjs +55 -2
- package/src/test/lib/sync-skillset.test.mjs +176 -11
- package/src/test/lib/sync.test.mjs +492 -22
|
@@ -25,8 +25,10 @@ import {
|
|
|
25
25
|
rmSync,
|
|
26
26
|
mkdirSync,
|
|
27
27
|
writeFileSync,
|
|
28
|
+
appendFileSync,
|
|
28
29
|
existsSync,
|
|
29
30
|
readFileSync,
|
|
31
|
+
readdirSync,
|
|
30
32
|
chmodSync,
|
|
31
33
|
} from "node:fs";
|
|
32
34
|
import { join } from "node:path";
|
|
@@ -41,10 +43,14 @@ import {
|
|
|
41
43
|
placementTargetsFor,
|
|
42
44
|
writeSkillDir,
|
|
43
45
|
removeSkillDir,
|
|
46
|
+
removeManagedDir,
|
|
47
|
+
quarantineSkillDir,
|
|
48
|
+
withFsRetry,
|
|
44
49
|
cleanupOrphans,
|
|
45
50
|
MAX_PATH_DEPTH,
|
|
46
51
|
BLOCKED_EXTENSIONS,
|
|
47
52
|
} from "../../lib/file-write.mjs";
|
|
53
|
+
import { globalQuarantineDir } from "../../lib/paths.mjs";
|
|
48
54
|
import { CliError, EXIT_VALIDATION } from "../../lib/errors.mjs";
|
|
49
55
|
import {
|
|
50
56
|
captureHome,
|
|
@@ -1085,6 +1091,168 @@ describe("removeSkillDir", () => {
|
|
|
1085
1091
|
);
|
|
1086
1092
|
});
|
|
1087
1093
|
|
|
1094
|
+
// ── withFsRetry (Windows-lock retry) ─────────────────────────────────────
|
|
1095
|
+
|
|
1096
|
+
describe("withFsRetry", () => {
|
|
1097
|
+
it("returns the op result without retrying on success", () => {
|
|
1098
|
+
let calls = 0;
|
|
1099
|
+
const out = withFsRetry(() => {
|
|
1100
|
+
calls++;
|
|
1101
|
+
return "ok";
|
|
1102
|
+
});
|
|
1103
|
+
assert.equal(out, "ok");
|
|
1104
|
+
assert.equal(calls, 1);
|
|
1105
|
+
});
|
|
1106
|
+
|
|
1107
|
+
it("retries a transient EBUSY, then succeeds", () => {
|
|
1108
|
+
let calls = 0;
|
|
1109
|
+
const out = withFsRetry(
|
|
1110
|
+
() => {
|
|
1111
|
+
calls++;
|
|
1112
|
+
if (calls < 3) {
|
|
1113
|
+
const err = new Error("locked");
|
|
1114
|
+
err.code = "EBUSY";
|
|
1115
|
+
throw err;
|
|
1116
|
+
}
|
|
1117
|
+
return "recovered";
|
|
1118
|
+
},
|
|
1119
|
+
[1, 1, 1, 1], // tiny real backoff — exercises the Atomics.wait sleep
|
|
1120
|
+
);
|
|
1121
|
+
assert.equal(out, "recovered");
|
|
1122
|
+
assert.equal(calls, 3, "failed twice, succeeded on the third attempt");
|
|
1123
|
+
});
|
|
1124
|
+
|
|
1125
|
+
it("rethrows a NON-transient error immediately (no retry)", () => {
|
|
1126
|
+
let calls = 0;
|
|
1127
|
+
assert.throws(
|
|
1128
|
+
() =>
|
|
1129
|
+
withFsRetry(() => {
|
|
1130
|
+
calls++;
|
|
1131
|
+
const err = new Error("denied");
|
|
1132
|
+
err.code = "EACCES";
|
|
1133
|
+
throw err;
|
|
1134
|
+
}, [0, 0, 0]),
|
|
1135
|
+
/denied/,
|
|
1136
|
+
);
|
|
1137
|
+
assert.equal(calls, 1, "EACCES is a real error — fail fast, never retried");
|
|
1138
|
+
});
|
|
1139
|
+
|
|
1140
|
+
it("rethrows the transient error after exhausting the retry budget", () => {
|
|
1141
|
+
let calls = 0;
|
|
1142
|
+
assert.throws(
|
|
1143
|
+
() =>
|
|
1144
|
+
withFsRetry(() => {
|
|
1145
|
+
calls++;
|
|
1146
|
+
const err = new Error("still locked");
|
|
1147
|
+
err.code = "EPERM";
|
|
1148
|
+
throw err;
|
|
1149
|
+
}, [0, 0]),
|
|
1150
|
+
/still locked/,
|
|
1151
|
+
);
|
|
1152
|
+
assert.equal(calls, 3, "initial attempt + 2 retries, then rethrow");
|
|
1153
|
+
});
|
|
1154
|
+
});
|
|
1155
|
+
|
|
1156
|
+
// ── removeManagedDir ─────────────────────────────────────────────────────
|
|
1157
|
+
|
|
1158
|
+
describe("removeManagedDir", () => {
|
|
1159
|
+
beforeEach(setupSandbox);
|
|
1160
|
+
afterEach(teardownSandbox);
|
|
1161
|
+
|
|
1162
|
+
it("removes an existing dir and returns true", () => {
|
|
1163
|
+
writeSkillDir(minimalSkill(), { vendors: ["claudeCode"] });
|
|
1164
|
+
const dir = resolvePlacementDir("claudeProject", "pdf-helper");
|
|
1165
|
+
assert.ok(existsSync(dir));
|
|
1166
|
+
assert.equal(removeManagedDir(dir), true);
|
|
1167
|
+
assert.ok(!existsSync(dir));
|
|
1168
|
+
});
|
|
1169
|
+
|
|
1170
|
+
it("returns false when the dir does not exist (idempotent)", () => {
|
|
1171
|
+
const dir = resolvePlacementDir("claudeProject", "nope");
|
|
1172
|
+
assert.equal(removeManagedDir(dir), false);
|
|
1173
|
+
});
|
|
1174
|
+
|
|
1175
|
+
it(
|
|
1176
|
+
"throws a typed diskError when removal fails",
|
|
1177
|
+
{ skip: process.platform === "win32" || process.getuid?.() === 0 },
|
|
1178
|
+
() => {
|
|
1179
|
+
writeSkillDir(minimalSkill(), { vendors: ["claudeCode"] });
|
|
1180
|
+
const dir = resolvePlacementDir("claudeProject", "pdf-helper");
|
|
1181
|
+
const root = join(process.cwd(), ".claude", "skills");
|
|
1182
|
+
chmodSync(root, 0o555);
|
|
1183
|
+
try {
|
|
1184
|
+
assert.throws(
|
|
1185
|
+
() => removeManagedDir(dir),
|
|
1186
|
+
(err) => err instanceof CliError && err.exitCode === 3,
|
|
1187
|
+
);
|
|
1188
|
+
} finally {
|
|
1189
|
+
chmodSync(root, 0o755);
|
|
1190
|
+
}
|
|
1191
|
+
},
|
|
1192
|
+
);
|
|
1193
|
+
});
|
|
1194
|
+
|
|
1195
|
+
// ── quarantineSkillDir ───────────────────────────────────────────────────
|
|
1196
|
+
|
|
1197
|
+
describe("quarantineSkillDir", () => {
|
|
1198
|
+
beforeEach(setupSandbox);
|
|
1199
|
+
afterEach(teardownSandbox);
|
|
1200
|
+
|
|
1201
|
+
it("moves the dir out of the loadable placement into quarantine, preserving content", () => {
|
|
1202
|
+
writeSkillDir(minimalSkill(), { vendors: ["claudeCode"] });
|
|
1203
|
+
const dir = resolvePlacementDir("claudeProject", "pdf-helper");
|
|
1204
|
+
appendFileSync(join(dir, "SKILL.md"), "\nlocal edit\n");
|
|
1205
|
+
assert.ok(existsSync(dir));
|
|
1206
|
+
|
|
1207
|
+
const dest = quarantineSkillDir(dir, { skillName: "pdf-helper" });
|
|
1208
|
+
|
|
1209
|
+
// Loadable copy is gone; the content lives under
|
|
1210
|
+
// ~/.claude/skillrepo/quarantine/, which no vendor scans.
|
|
1211
|
+
assert.ok(!existsSync(dir), "loadable copy removed");
|
|
1212
|
+
assert.ok(
|
|
1213
|
+
dest.startsWith(globalQuarantineDir()),
|
|
1214
|
+
"dest is under the quarantine root",
|
|
1215
|
+
);
|
|
1216
|
+
assert.ok(existsSync(dest));
|
|
1217
|
+
assert.match(readFileSync(join(dest, "SKILL.md"), "utf-8"), /local edit/);
|
|
1218
|
+
});
|
|
1219
|
+
|
|
1220
|
+
it("gives each quarantined copy a unique dir (no collision across targets)", () => {
|
|
1221
|
+
writeSkillDir(minimalSkill(), { vendors: ["claudeCode"] });
|
|
1222
|
+
const destA = quarantineSkillDir(
|
|
1223
|
+
resolvePlacementDir("claudeProject", "pdf-helper"),
|
|
1224
|
+
{ skillName: "pdf-helper" },
|
|
1225
|
+
);
|
|
1226
|
+
// Recreate and quarantine the same name again.
|
|
1227
|
+
writeSkillDir(minimalSkill(), { vendors: ["claudeCode"] });
|
|
1228
|
+
const destB = quarantineSkillDir(
|
|
1229
|
+
resolvePlacementDir("claudeProject", "pdf-helper"),
|
|
1230
|
+
{ skillName: "pdf-helper" },
|
|
1231
|
+
);
|
|
1232
|
+
assert.notEqual(destA, destB, "two quarantines of the same name never collide");
|
|
1233
|
+
assert.equal(readdirSync(globalQuarantineDir()).length, 2);
|
|
1234
|
+
});
|
|
1235
|
+
|
|
1236
|
+
it("rejects an invalid skill name (never builds a path from untrusted input)", () => {
|
|
1237
|
+
assert.throws(
|
|
1238
|
+
() => quarantineSkillDir("/tmp/whatever", { skillName: "../evil" }),
|
|
1239
|
+
(err) => err instanceof CliError && err.exitCode === EXIT_VALIDATION,
|
|
1240
|
+
);
|
|
1241
|
+
});
|
|
1242
|
+
|
|
1243
|
+
it("throws a typed diskError when the copy fails (source is gone)", () => {
|
|
1244
|
+
// cpSync of a nonexistent source throws ENOENT (not a CliError) → the
|
|
1245
|
+
// catch maps it to a typed disk error, never a raw OS error.
|
|
1246
|
+
assert.throws(
|
|
1247
|
+
() =>
|
|
1248
|
+
quarantineSkillDir(join(process.cwd(), "does-not-exist"), {
|
|
1249
|
+
skillName: "ghost",
|
|
1250
|
+
}),
|
|
1251
|
+
(err) => err instanceof CliError && err.exitCode === 3,
|
|
1252
|
+
);
|
|
1253
|
+
});
|
|
1254
|
+
});
|
|
1255
|
+
|
|
1088
1256
|
// ── cleanupOrphans ──────────────────────────────────────────────────────
|
|
1089
1257
|
|
|
1090
1258
|
describe("cleanupOrphans", () => {
|
|
@@ -487,6 +487,54 @@ describe("getLibrary", () => {
|
|
|
487
487
|
}
|
|
488
488
|
});
|
|
489
489
|
|
|
490
|
+
it("throws networkError on a valid-JSON 200 whose skills is not an array (#2879 over-deletion guard)", async () => {
|
|
491
|
+
// A 200 with valid JSON but no top-level skills[] array — an upstream
|
|
492
|
+
// proxy/CDN body, an API-gateway health envelope, or a future
|
|
493
|
+
// server-envelope refactor (e.g. {data:{skills}}). Before the guard this
|
|
494
|
+
// collapsed to skills:[] and a full-sync reconcile (#2804) treated it as
|
|
495
|
+
// "everything was removed", deleting the user's whole local library. It
|
|
496
|
+
// must fail loud instead. Covers null/absent AND truthy-non-array shapes
|
|
497
|
+
// (the old `?? []` passed the latter straight into a later `.map` crash).
|
|
498
|
+
for (const malformed of [
|
|
499
|
+
{},
|
|
500
|
+
{ removals: [] },
|
|
501
|
+
{ data: { skills: [] } },
|
|
502
|
+
{ skills: null },
|
|
503
|
+
{ skills: "nope" },
|
|
504
|
+
{ skills: { 0: "x" } },
|
|
505
|
+
]) {
|
|
506
|
+
const srv = await startServer((req, res) => jsonRes(res, 200, malformed));
|
|
507
|
+
try {
|
|
508
|
+
await assert.rejects(
|
|
509
|
+
() => getLibrary(srv.url, VALID_KEY),
|
|
510
|
+
(err) =>
|
|
511
|
+
err instanceof CliError &&
|
|
512
|
+
err.exitCode === EXIT_NETWORK &&
|
|
513
|
+
/no skills\[\] array/.test(err.message),
|
|
514
|
+
`malformed body ${JSON.stringify(malformed)} must reject`,
|
|
515
|
+
);
|
|
516
|
+
} finally {
|
|
517
|
+
await srv.close();
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
});
|
|
521
|
+
|
|
522
|
+
it("still returns a genuine empty library (skills: []) without throwing (#2879)", async () => {
|
|
523
|
+
// The guard fires ONLY on a missing/non-array skills — a real empty
|
|
524
|
+
// library sends skills: [] (an array), and the #2804 reconcile that
|
|
525
|
+
// removes a truly-departed skill depends on this legitimate 200.
|
|
526
|
+
const srv = await startServer((req, res) =>
|
|
527
|
+
jsonRes(res, 200, { skills: [], removals: [], syncedAt: "2025-01-01T00:00:00Z" }),
|
|
528
|
+
);
|
|
529
|
+
try {
|
|
530
|
+
const result = await getLibrary(srv.url, VALID_KEY);
|
|
531
|
+
assert.deepEqual(result.skills, []);
|
|
532
|
+
assert.equal(result.notModified, false);
|
|
533
|
+
} finally {
|
|
534
|
+
await srv.close();
|
|
535
|
+
}
|
|
536
|
+
});
|
|
537
|
+
|
|
490
538
|
it("sends If-None-Match when ifNoneMatch is provided", async () => {
|
|
491
539
|
const srv = await startServer((req, res) => {
|
|
492
540
|
// Echo the conditional check
|
|
@@ -537,9 +585,14 @@ describe("getLibrary", () => {
|
|
|
537
585
|
}
|
|
538
586
|
});
|
|
539
587
|
|
|
540
|
-
it("tolerates missing
|
|
588
|
+
it("tolerates missing removals/syncedAt when skills[] is present (defaults applied)", async () => {
|
|
589
|
+
// Resilience for the NON-deletion fields survives the #2879 skills[]
|
|
590
|
+
// guard: a 200 that carries skills[] but omits removals/syncedAt still
|
|
591
|
+
// defaults them. (A missing/non-array skills[] now throws instead of
|
|
592
|
+
// defaulting to [] — see the over-deletion guard test above — because a
|
|
593
|
+
// full-sync reconcile would treat that empty set as authoritative.)
|
|
541
594
|
const srv = await startServer((req, res) => {
|
|
542
|
-
jsonRes(res, 200, {});
|
|
595
|
+
jsonRes(res, 200, { skills: [] });
|
|
543
596
|
});
|
|
544
597
|
try {
|
|
545
598
|
const result = await getLibrary(srv.url, VALID_KEY);
|
|
@@ -48,7 +48,7 @@ import {
|
|
|
48
48
|
repoStatePathFor,
|
|
49
49
|
REPO_SYNC_GRACE_WINDOW_MS,
|
|
50
50
|
} from "../../lib/repo-sync-state.mjs";
|
|
51
|
-
import { globalLastSyncPath } from "../../lib/paths.mjs";
|
|
51
|
+
import { globalLastSyncPath, globalQuarantineDir } from "../../lib/paths.mjs";
|
|
52
52
|
import { CliError, EXIT_NETWORK, EXIT_UNRESOLVABLE } from "../../lib/errors.mjs";
|
|
53
53
|
import { getLibrary, postSyncReceipt } from "../../lib/http.mjs";
|
|
54
54
|
import { createMockServer } from "../e2e/mock-server.mjs";
|
|
@@ -403,11 +403,12 @@ describe("skillset-scoped sync — 200 full-set reconcile (#2365)", () => {
|
|
|
403
403
|
assert.deepEqual(receipt.skills, []);
|
|
404
404
|
});
|
|
405
405
|
|
|
406
|
-
it("empty skillset
|
|
406
|
+
it("empty skillset reconcile quarantines a modified managed dir (#2804): loadable copy removed, edit preserved", async () => {
|
|
407
407
|
declareRepo();
|
|
408
408
|
server.setSkillsetEtag(SS_ETAG_A);
|
|
409
409
|
server.setSkillsetResponse(scopedResponse([makeSkill("alpha")]));
|
|
410
410
|
await scopedSync();
|
|
411
|
+
// The CLI wrote alpha (per-repo baseline); the user then edits it.
|
|
411
412
|
appendFileSync(join(repoRoot(), ".claude", "skills", "alpha", "SKILL.md"), "\nedited\n");
|
|
412
413
|
|
|
413
414
|
server.setSkillsetEtag(SS_ETAG_B);
|
|
@@ -416,12 +417,156 @@ describe("skillset-scoped sync — 200 full-set reconcile (#2365)", () => {
|
|
|
416
417
|
const stderr = createCaptureStream();
|
|
417
418
|
const result = await scopedSync({ io: { stderr } });
|
|
418
419
|
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
420
|
+
// Compliance (#2804 owner decision): a recalled/removed skill the CLI
|
|
421
|
+
// wrote must leave the loadable folder even when locally edited — the
|
|
422
|
+
// edit is preserved in quarantine, never destroyed. This REPLACES the
|
|
423
|
+
// old warn-and-keep, which left the recalled skill loadable by agents.
|
|
424
|
+
assert.equal(result.removed, 1, "the edited managed dir is reconciled (quarantined)");
|
|
425
|
+
assert.equal(
|
|
426
|
+
existsSync(join(repoRoot(), ".claude", "skills", "alpha")),
|
|
427
|
+
false,
|
|
428
|
+
"loadable copy is gone — agents no longer load the recalled skill",
|
|
429
|
+
);
|
|
430
|
+
assert.match(
|
|
431
|
+
stderr.text(),
|
|
432
|
+
/alpha\/ was removed from this repo's skillset but has local edits — moved to quarantine/,
|
|
433
|
+
);
|
|
434
|
+
|
|
435
|
+
// The edit survives in the non-loaded quarantine area.
|
|
436
|
+
const quarantined = readdirSync(globalQuarantineDir()).filter((d) =>
|
|
437
|
+
d.startsWith("alpha-"),
|
|
438
|
+
);
|
|
439
|
+
assert.equal(quarantined.length, 1, "exactly one quarantined copy of alpha");
|
|
440
|
+
assert.match(
|
|
441
|
+
readFileSync(join(globalQuarantineDir(), quarantined[0], "SKILL.md"), "utf-8"),
|
|
442
|
+
/edited/,
|
|
443
|
+
"the user's edit is preserved in quarantine",
|
|
444
|
+
);
|
|
445
|
+
|
|
422
446
|
assert.deepEqual(server.getLastReceipt().repo.resolved, []);
|
|
423
447
|
});
|
|
424
448
|
|
|
449
|
+
it("never resolves a traversal-shaped manifest entry into a path — no arbitrary-dir deletion (#2879)", async () => {
|
|
450
|
+
// Same guard on the skillset reconcile path (reconcileDeleteGuarded): the
|
|
451
|
+
// per-repo manifest is local data, not trusted input. A traversal-named
|
|
452
|
+
// entry must be rejected before resolvePlacementDir. Plant a byte-identical
|
|
453
|
+
// sentinel OUTSIDE the placement root so a guard-LESS delete branch would
|
|
454
|
+
// rm it, inject a traversal entry reusing victim's SHAs, then re-sync.
|
|
455
|
+
declareRepo();
|
|
456
|
+
server.setSkillsetEtag(SS_ETAG_A);
|
|
457
|
+
server.setSkillsetResponse(scopedResponse([makeSkill("victim")]));
|
|
458
|
+
await scopedSync();
|
|
459
|
+
const victimDir = join(repoRoot(), ".claude", "skills", "victim");
|
|
460
|
+
|
|
461
|
+
const sentinelDir = join(sandbox, "project", "PWNED-SENTINEL");
|
|
462
|
+
mkdirSync(sentinelDir, { recursive: true });
|
|
463
|
+
writeFileSync(
|
|
464
|
+
join(sentinelDir, "SKILL.md"),
|
|
465
|
+
readFileSync(join(victimDir, "SKILL.md")),
|
|
466
|
+
);
|
|
467
|
+
|
|
468
|
+
const state = readRepoSyncState(repoRoot());
|
|
469
|
+
const victimEntry = state.resolved.find((e) => e.name === "victim");
|
|
470
|
+
state.resolved.push({ ...victimEntry, name: "../../PWNED-SENTINEL" });
|
|
471
|
+
writeRepoSyncState(repoRoot(), state);
|
|
472
|
+
|
|
473
|
+
// victim stays served; the traversal entry is absent → it would reconcile.
|
|
474
|
+
server.setSkillsetEtag(SS_ETAG_B);
|
|
475
|
+
server.setSkillsetResponse(scopedResponse([makeSkill("victim")]));
|
|
476
|
+
const result = await scopedSync();
|
|
477
|
+
|
|
478
|
+
assert.ok(
|
|
479
|
+
existsSync(join(sentinelDir, "SKILL.md")),
|
|
480
|
+
"traversal target UNTOUCHED — the guard rejected the name before any fs op",
|
|
481
|
+
);
|
|
482
|
+
assert.equal(result.removed, 0, "a traversal entry reconciles nothing");
|
|
483
|
+
assert.equal(result.quarantined ?? 0, 0, "and quarantines nothing");
|
|
484
|
+
});
|
|
485
|
+
|
|
486
|
+
it("reconcile keeps (never quarantines) an absent member the CLI never wrote — no authorship proof (#2108 preserved)", async () => {
|
|
487
|
+
declareRepo();
|
|
488
|
+
// Hand-author a dir at a member's name BEFORE the CLI ever writes it, so
|
|
489
|
+
// the serve is refused as unmanaged and the resolved entry carries NO
|
|
490
|
+
// baseline SHAs — the CLI has no proof it wrote this dir.
|
|
491
|
+
const dir = join(repoRoot(), ".claude", "skills", "squatter");
|
|
492
|
+
mkdirSync(dir, { recursive: true });
|
|
493
|
+
writeFileSync(join(dir, "SKILL.md"), "---\nname: squatter\n---\nhand-authored\n");
|
|
494
|
+
|
|
495
|
+
server.setSkillsetEtag(SS_ETAG_A);
|
|
496
|
+
server.setSkillsetResponse(scopedResponse([makeSkill("squatter", "server body")]));
|
|
497
|
+
await scopedSync(); // serve-refused (unmanaged) → resolved entry, no SHAs
|
|
498
|
+
|
|
499
|
+
// squatter leaves the set. With no authorship proof the reconcile must
|
|
500
|
+
// NOT touch it — the owner's remove-and-quarantine covers CLI-authored
|
|
501
|
+
// edits ONLY, never hand-authored content. Keep it with a warning.
|
|
502
|
+
server.setSkillsetEtag(SS_ETAG_B);
|
|
503
|
+
server.setSkillsetResponse(scopedResponse([]));
|
|
504
|
+
const stderr = createCaptureStream();
|
|
505
|
+
const result = await scopedSync({ io: { stderr } });
|
|
506
|
+
|
|
507
|
+
assert.equal(result.removed, 0, "hand-authored dir is never removed by the reconcile");
|
|
508
|
+
assert.match(
|
|
509
|
+
readFileSync(join(dir, "SKILL.md"), "utf-8"),
|
|
510
|
+
/hand-authored/,
|
|
511
|
+
"the user's hand-authored content survives byte-for-byte",
|
|
512
|
+
);
|
|
513
|
+
assert.match(
|
|
514
|
+
stderr.text(),
|
|
515
|
+
/squatter\/ was removed from this repo's skillset but was not written by this CLI/,
|
|
516
|
+
);
|
|
517
|
+
// And it is NOT quarantined — quarantine is for CLI-authored edits only.
|
|
518
|
+
const quarantined = existsSync(globalQuarantineDir())
|
|
519
|
+
? readdirSync(globalQuarantineDir()).filter((d) => d.startsWith("squatter-"))
|
|
520
|
+
: [];
|
|
521
|
+
assert.equal(quarantined.length, 0, "hand-authored content is never moved to quarantine");
|
|
522
|
+
});
|
|
523
|
+
|
|
524
|
+
it("reconcile does not quarantine a re-attributed member (same name, new owner) (#2804 review)", async () => {
|
|
525
|
+
declareRepo();
|
|
526
|
+
server.setSkillsetEtag(SS_ETAG_A);
|
|
527
|
+
server.setSkillsetResponse(scopedResponse([{ ...makeSkill("foo"), owner: "skillrepo" }]));
|
|
528
|
+
await scopedSync();
|
|
529
|
+
const dir = join(repoRoot(), ".claude", "skills", "foo");
|
|
530
|
+
assert.ok(existsSync(dir));
|
|
531
|
+
appendFileSync(join(dir, "SKILL.md"), "\nlocal edit\n");
|
|
532
|
+
|
|
533
|
+
// The member is re-attributed skillrepo/foo → acme/foo (a claim). It is
|
|
534
|
+
// STILL in the skillset, just re-keyed — the reconcile must NOT quarantine
|
|
535
|
+
// the edited copy; it defers to the apply loop, which serve-refuses the
|
|
536
|
+
// edit and keeps it, exactly as for any edited member.
|
|
537
|
+
server.setSkillsetEtag(SS_ETAG_B);
|
|
538
|
+
server.setSkillsetResponse(scopedResponse([{ ...makeSkill("foo"), owner: "acme" }]));
|
|
539
|
+
const stderr = createCaptureStream();
|
|
540
|
+
const result = await scopedSync({ io: { stderr } });
|
|
541
|
+
|
|
542
|
+
assert.equal(result.quarantined ?? 0, 0, "a re-attributed member is never quarantined");
|
|
543
|
+
assert.doesNotMatch(stderr.text(), /moved to quarantine/);
|
|
544
|
+
assert.match(
|
|
545
|
+
readFileSync(join(dir, "SKILL.md"), "utf-8"),
|
|
546
|
+
/local edit/,
|
|
547
|
+
"the edit survives (served-but-refused, not quarantined)",
|
|
548
|
+
);
|
|
549
|
+
const quarantined = existsSync(globalQuarantineDir())
|
|
550
|
+
? readdirSync(globalQuarantineDir()).filter((d) => d.startsWith("foo-"))
|
|
551
|
+
: [];
|
|
552
|
+
assert.equal(quarantined.length, 0, "no quarantine copy is made");
|
|
553
|
+
|
|
554
|
+
// Classified as a local EDIT of CLI-authored content, NOT as
|
|
555
|
+
// hand-authored/unmanaged — the re-attribution must not lose the CLI's
|
|
556
|
+
// authorship record via the owner-keyed lookup (#2804 review). This is
|
|
557
|
+
// the durable signal that goes to state + the server receipt.
|
|
558
|
+
const resolved = readRepoSyncState(repoRoot()).resolved.find(
|
|
559
|
+
(e) => e.name === "foo",
|
|
560
|
+
);
|
|
561
|
+
assert.equal(resolved.owner, "acme", "manifest carries the new owner");
|
|
562
|
+
assert.equal(resolved.written, false, "served-but-refused (a local edit)");
|
|
563
|
+
assert.equal(
|
|
564
|
+
resolved.unwrittenReason,
|
|
565
|
+
"modified",
|
|
566
|
+
"labeled 'modified' (CLI wrote it, user edited), not 'unmanaged'",
|
|
567
|
+
);
|
|
568
|
+
});
|
|
569
|
+
|
|
425
570
|
it("prints each skippedExtras message VERBATIM and stores/echoes reason only", async () => {
|
|
426
571
|
declareRepo(["acme/db-migrations"]);
|
|
427
572
|
server.setSkillsetEtag(SS_ETAG_A);
|
|
@@ -1124,7 +1269,7 @@ describe("skillset-scoped sync — guards compose across multiple targets (#2365
|
|
|
1124
1269
|
]);
|
|
1125
1270
|
});
|
|
1126
1271
|
|
|
1127
|
-
it("
|
|
1272
|
+
it("reconcile removes the clean target and quarantines only the edited one (#2804, per-target)", async () => {
|
|
1128
1273
|
declareRepo();
|
|
1129
1274
|
server.setSkillsetEtag(SS_ETAG_A);
|
|
1130
1275
|
server.setSkillsetResponse(scopedResponse([makeSkill("beta")]));
|
|
@@ -1140,16 +1285,36 @@ describe("skillset-scoped sync — guards compose across multiple targets (#2365
|
|
|
1140
1285
|
const stderr = createCaptureStream();
|
|
1141
1286
|
const result = await scopedSync({ vendors: TWO_VENDORS, io: { stderr } });
|
|
1142
1287
|
|
|
1143
|
-
|
|
1144
|
-
|
|
1288
|
+
// #2804: per-target, not all-or-nothing. The baseline-clean copy is
|
|
1289
|
+
// exactly what the CLI wrote, so it is deleted; the edited copy leaves
|
|
1290
|
+
// the loadable folder too but is quarantined so the edit survives. Both
|
|
1291
|
+
// loadable copies of the recalled skill are gone (compliance) — the old
|
|
1292
|
+
// all-or-nothing keep left BOTH loadable.
|
|
1293
|
+
assert.equal(result.removed, 1, "beta reconciled (clean deleted + edited quarantined = one entry)");
|
|
1294
|
+
assert.equal(
|
|
1145
1295
|
existsSync(join(claudeDir("beta"), "SKILL.md")),
|
|
1146
|
-
|
|
1296
|
+
false,
|
|
1297
|
+
"baseline-clean copy is deleted — it is exactly what the CLI wrote",
|
|
1298
|
+
);
|
|
1299
|
+
assert.equal(
|
|
1300
|
+
existsSync(join(agentsDir("beta"), "SKILL.md")),
|
|
1301
|
+
false,
|
|
1302
|
+
"edited copy leaves the loadable folder (moved to quarantine)",
|
|
1303
|
+
);
|
|
1304
|
+
assert.match(
|
|
1305
|
+
stderr.text(),
|
|
1306
|
+
/beta\/ was removed from this repo's skillset but has local edits — moved to quarantine/,
|
|
1307
|
+
);
|
|
1308
|
+
|
|
1309
|
+
// Only the edited target is quarantined; its content is preserved.
|
|
1310
|
+
const quarantined = readdirSync(globalQuarantineDir()).filter((d) =>
|
|
1311
|
+
d.startsWith("beta-"),
|
|
1147
1312
|
);
|
|
1313
|
+
assert.equal(quarantined.length, 1, "exactly one quarantined copy — the edited target");
|
|
1148
1314
|
assert.match(
|
|
1149
|
-
readFileSync(join(
|
|
1315
|
+
readFileSync(join(globalQuarantineDir(), quarantined[0], "SKILL.md"), "utf-8"),
|
|
1150
1316
|
/local edit/,
|
|
1151
1317
|
);
|
|
1152
|
-
assert.match(stderr.text(), /beta\/ was removed from this repo's skillset but has local changes/);
|
|
1153
1318
|
assert.deepEqual(server.getLastReceipt().repo.resolved, [], "emptied set still attested");
|
|
1154
1319
|
});
|
|
1155
1320
|
});
|