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
|
@@ -29,10 +29,12 @@ import {
|
|
|
29
29
|
mkdirSync,
|
|
30
30
|
chmodSync,
|
|
31
31
|
writeFileSync,
|
|
32
|
+
appendFileSync,
|
|
32
33
|
existsSync,
|
|
33
34
|
readFileSync,
|
|
35
|
+
readdirSync,
|
|
34
36
|
} from "node:fs";
|
|
35
|
-
import { join
|
|
37
|
+
import { join } from "node:path";
|
|
36
38
|
import { tmpdir } from "node:os";
|
|
37
39
|
|
|
38
40
|
import {
|
|
@@ -48,7 +50,7 @@ import {
|
|
|
48
50
|
MIN_SYNC_INTERVAL_MS,
|
|
49
51
|
} from "../../lib/sync.mjs";
|
|
50
52
|
import { resolvePlacementDir } from "../../lib/file-write.mjs";
|
|
51
|
-
import { globalLastSyncPath } from "../../lib/paths.mjs";
|
|
53
|
+
import { globalLastSyncPath, globalQuarantineDir } from "../../lib/paths.mjs";
|
|
52
54
|
import { CliError, EXIT_VALIDATION, EXIT_DISK } from "../../lib/errors.mjs";
|
|
53
55
|
import { computeSkillShas } from "../../lib/crypto-shas.mjs";
|
|
54
56
|
import { walkDetectedPlacements } from "../../lib/placement-walk.mjs";
|
|
@@ -996,8 +998,10 @@ describe("runSync — tombstones", () => {
|
|
|
996
998
|
beforeEach(setupServer);
|
|
997
999
|
afterEach(teardownServer);
|
|
998
1000
|
|
|
999
|
-
it("applies a tombstone and increments removed", async () => {
|
|
1000
|
-
//
|
|
1001
|
+
it("applies a delta tombstone and increments removed", async () => {
|
|
1002
|
+
// First sync writes `doomed` and persists an etag baseline, so the NEXT
|
|
1003
|
+
// sync is a genuine DELTA (sends If-None-Match + `since`).
|
|
1004
|
+
server.setEtag('"v1"');
|
|
1001
1005
|
server.setLibraryResponse({
|
|
1002
1006
|
skills: [makeSkill("doomed")],
|
|
1003
1007
|
removals: [],
|
|
@@ -1005,13 +1009,15 @@ describe("runSync — tombstones", () => {
|
|
|
1005
1009
|
});
|
|
1006
1010
|
await runSync({ serverUrl, apiKey: VALID_KEY, vendors: ["claudeCode"] });
|
|
1007
1011
|
assert.ok(existsSync(resolvePlacementDir("claudeProject", "doomed")));
|
|
1008
|
-
// Reset receipt tracking so the tombstone-only sync below is measured
|
|
1009
|
-
// from zero (the first sync legitimately posted one).
|
|
1010
|
-
server.resetReceipts();
|
|
1011
1012
|
|
|
1012
|
-
//
|
|
1013
|
-
//
|
|
1014
|
-
|
|
1013
|
+
// DELTA sync (etag advances → 200 with `since` sent): the server carries
|
|
1014
|
+
// the removal as a tombstone and the client deletes it. This is the
|
|
1015
|
+
// CONTROL for #2804 — a tombstone on a delta still deletes. (A FULL sync
|
|
1016
|
+
// carries removals:[] and the orphan reconcile handles absence instead;
|
|
1017
|
+
// see the "#2804 full-sync orphan" test below. Before the mock was fixed
|
|
1018
|
+
// to force removals:[] on full syncs, this test wiped `.last-sync` and
|
|
1019
|
+
// still "passed" against a shape production never sends.)
|
|
1020
|
+
server.setEtag('"v2"');
|
|
1015
1021
|
server.setLibraryResponse({
|
|
1016
1022
|
skills: [],
|
|
1017
1023
|
removals: [{ owner: "alice", name: "doomed", removedAt: "2025-01-02T00:00:00Z" }],
|
|
@@ -1024,10 +1030,6 @@ describe("runSync — tombstones", () => {
|
|
|
1024
1030
|
});
|
|
1025
1031
|
assert.equal(result.removed, 1);
|
|
1026
1032
|
assert.ok(!existsSync(resolvePlacementDir("claudeProject", "doomed")));
|
|
1027
|
-
// The `.last-sync` baseline was reset (rm above) and the only skill
|
|
1028
|
-
// was tombstoned, so the post-sync on-disk set is empty → the
|
|
1029
|
-
// re-assertion receipt has nothing to send and is skipped (#1832).
|
|
1030
|
-
assert.equal(server.getReceiptRequestCount(), 0);
|
|
1031
1033
|
});
|
|
1032
1034
|
|
|
1033
1035
|
it("re-asserts surviving skills but excludes a tombstoned one", async () => {
|
|
@@ -1806,7 +1808,9 @@ describe("runSync — cohort placement classification", () => {
|
|
|
1806
1808
|
});
|
|
1807
1809
|
|
|
1808
1810
|
it("tombstone via cohort vendor removes from .agents/skills/ but leaves .claude/skills/", async () => {
|
|
1809
|
-
// Pre-write to BOTH targets via a multi-vendor first sync
|
|
1811
|
+
// Pre-write to BOTH targets via a multi-vendor first sync; persist an
|
|
1812
|
+
// etag baseline so the tombstone sync below is a genuine DELTA.
|
|
1813
|
+
server.setEtag('"v1"');
|
|
1810
1814
|
server.setLibraryResponse({
|
|
1811
1815
|
skills: [makeSkill("doomed")],
|
|
1812
1816
|
removals: [],
|
|
@@ -1823,13 +1827,15 @@ describe("runSync — cohort placement classification", () => {
|
|
|
1823
1827
|
assert.ok(existsSync(claudeDir));
|
|
1824
1828
|
assert.ok(existsSync(cohortDir));
|
|
1825
1829
|
|
|
1826
|
-
|
|
1827
|
-
|
|
1828
|
-
//
|
|
1829
|
-
// tombstones map to the cohort target (.agents/skills/) and must
|
|
1830
|
-
//
|
|
1831
|
-
//
|
|
1832
|
-
//
|
|
1830
|
+
// Apply a tombstone with vendors: ["cursor"] only, on a DELTA sync (etag
|
|
1831
|
+
// advances → `since` sent → the mock carries the tombstone, matching
|
|
1832
|
+
// production which only emits removals on deltas). Vendor-scoped
|
|
1833
|
+
// tombstones map to the cohort target (.agents/skills/) and must NOT
|
|
1834
|
+
// delete the Claude target — only the explicit vendor scope is touched.
|
|
1835
|
+
// This locks the "vendor-scoped removal isn't a footgun" behavior.
|
|
1836
|
+
// (Previously this wiped `.last-sync` and rode the mock's unfiltered
|
|
1837
|
+
// removals on a FULL sync — a shape production never sends; #2804.)
|
|
1838
|
+
server.setEtag('"v2"');
|
|
1833
1839
|
server.setLibraryResponse({
|
|
1834
1840
|
skills: [],
|
|
1835
1841
|
removals: [{ owner: "alice", name: "doomed", removedAt: "2025-01-02T00:00:00Z" }],
|
|
@@ -1850,6 +1856,470 @@ describe("runSync — cohort placement classification", () => {
|
|
|
1850
1856
|
});
|
|
1851
1857
|
});
|
|
1852
1858
|
|
|
1859
|
+
// ───────────────────────────────────────────────────────────────────
|
|
1860
|
+
// #2804 — full-sync orphan reconcile (guarded auto-delete)
|
|
1861
|
+
// ───────────────────────────────────────────────────────────────────
|
|
1862
|
+
//
|
|
1863
|
+
// On a FULL (non-delta) whole-library fetch the server sends the complete
|
|
1864
|
+
// served set with removals:[] (route.ts:624). A skill that has LEFT the
|
|
1865
|
+
// library — recalled to absence (#2367), owner-deleted (#2532),
|
|
1866
|
+
// membership-removed — is in NEITHER skills[] NOR removals[]. Before the
|
|
1867
|
+
// fix it was orphaned on disk forever and kept loading. These drive the
|
|
1868
|
+
// REAL production shape (full fetch + removals:[] + skill missing from
|
|
1869
|
+
// skills[]) through runSync; the delta-tombstone control lives in the
|
|
1870
|
+
// "runSync — tombstones" suite above.
|
|
1871
|
+
|
|
1872
|
+
describe("runSync — full-sync orphan reconcile (#2804)", () => {
|
|
1873
|
+
beforeEach(setupServer);
|
|
1874
|
+
afterEach(teardownServer);
|
|
1875
|
+
|
|
1876
|
+
// Force the NEXT sync to be a full fetch while KEEPING the baseline — the
|
|
1877
|
+
// real #2804 trigger (the #1911 upgrade heal / incomplete placements).
|
|
1878
|
+
// Rewriting `.last-sync` with a pre-floor cliVersion makes
|
|
1879
|
+
// runWholeLibrarySync drop BOTH conditional headers → `opts.since` absent
|
|
1880
|
+
// → the server returns the complete authoritative set.
|
|
1881
|
+
function forceFullFetchNextSync() {
|
|
1882
|
+
const prior = readLastSync();
|
|
1883
|
+
writeLastSync({
|
|
1884
|
+
etag: prior.etag,
|
|
1885
|
+
syncedAt: prior.syncedAt,
|
|
1886
|
+
skills: prior.skills,
|
|
1887
|
+
cliVersion: "4.7.0", // below FULL_RESYNC_FLOOR
|
|
1888
|
+
});
|
|
1889
|
+
}
|
|
1890
|
+
|
|
1891
|
+
it("deletes an unmodified skill absent from a full response (the #2804 shape)", async () => {
|
|
1892
|
+
server.setEtag('"v1"');
|
|
1893
|
+
server.setLibraryResponse({
|
|
1894
|
+
skills: [makeSkill("keep"), makeSkill("gone")],
|
|
1895
|
+
removals: [],
|
|
1896
|
+
syncedAt: "2025-01-01T00:00:00Z",
|
|
1897
|
+
});
|
|
1898
|
+
await runSync({ serverUrl, apiKey: VALID_KEY, vendors: ["claudeCode"] });
|
|
1899
|
+
assert.ok(existsSync(resolvePlacementDir("claudeProject", "gone")));
|
|
1900
|
+
|
|
1901
|
+
forceFullFetchNextSync();
|
|
1902
|
+
server.resetLibraryInspection();
|
|
1903
|
+
|
|
1904
|
+
// Full authoritative set with `gone` dropped and NO tombstone — exactly
|
|
1905
|
+
// what production sends on a full sync after a recall/removal.
|
|
1906
|
+
server.setEtag('"v2"');
|
|
1907
|
+
server.setLibraryResponse({
|
|
1908
|
+
skills: [makeSkill("keep")],
|
|
1909
|
+
removals: [],
|
|
1910
|
+
syncedAt: "2025-01-02T00:00:00Z",
|
|
1911
|
+
});
|
|
1912
|
+
const result = await runSync({
|
|
1913
|
+
serverUrl,
|
|
1914
|
+
apiKey: VALID_KEY,
|
|
1915
|
+
vendors: ["claudeCode"],
|
|
1916
|
+
});
|
|
1917
|
+
|
|
1918
|
+
// Precondition: it really was a full fetch (no `since`).
|
|
1919
|
+
assert.equal(server.getLastLibrarySince(), null, "must be a full fetch");
|
|
1920
|
+
|
|
1921
|
+
assert.equal(result.removed, 1, "the orphaned skill is reconciled off disk");
|
|
1922
|
+
assert.equal(
|
|
1923
|
+
existsSync(resolvePlacementDir("claudeProject", "gone")),
|
|
1924
|
+
false,
|
|
1925
|
+
"recalled skill no longer on disk — agents stop loading it",
|
|
1926
|
+
);
|
|
1927
|
+
assert.ok(
|
|
1928
|
+
existsSync(resolvePlacementDir("claudeProject", "keep")),
|
|
1929
|
+
"still-served skill is untouched",
|
|
1930
|
+
);
|
|
1931
|
+
|
|
1932
|
+
// State no longer lists the orphan (no phantom in `list`).
|
|
1933
|
+
const state = readLastSync();
|
|
1934
|
+
assert.ok(state.skills["alice/keep"], "keep stays in .last-sync");
|
|
1935
|
+
assert.equal(
|
|
1936
|
+
state.skills["alice/gone"],
|
|
1937
|
+
undefined,
|
|
1938
|
+
"gone dropped from .last-sync",
|
|
1939
|
+
);
|
|
1940
|
+
|
|
1941
|
+
// Deleted, NOT quarantined: an unmodified orphan must be removed outright,
|
|
1942
|
+
// never relocated. Without this, an always-quarantine regression would
|
|
1943
|
+
// still satisfy removed===1 above and go undetected (#2879 QA finding).
|
|
1944
|
+
assert.equal(
|
|
1945
|
+
result.quarantined ?? 0,
|
|
1946
|
+
0,
|
|
1947
|
+
"an unmodified orphan is deleted, not quarantined",
|
|
1948
|
+
);
|
|
1949
|
+
assert.ok(
|
|
1950
|
+
!existsSync(globalQuarantineDir()) ||
|
|
1951
|
+
readdirSync(globalQuarantineDir()).length === 0,
|
|
1952
|
+
"quarantine area stays empty for an unmodified delete",
|
|
1953
|
+
);
|
|
1954
|
+
});
|
|
1955
|
+
|
|
1956
|
+
it("quarantines an EDITED orphan instead of deleting it (compliance + no data loss)", async () => {
|
|
1957
|
+
server.setEtag('"v1"');
|
|
1958
|
+
server.setLibraryResponse({
|
|
1959
|
+
skills: [makeSkill("edited-gone")],
|
|
1960
|
+
removals: [],
|
|
1961
|
+
syncedAt: "2025-01-01T00:00:00Z",
|
|
1962
|
+
});
|
|
1963
|
+
await runSync({ serverUrl, apiKey: VALID_KEY, vendors: ["claudeCode"] });
|
|
1964
|
+
|
|
1965
|
+
// The CLI wrote it; the user edits it locally.
|
|
1966
|
+
const dir = resolvePlacementDir("claudeProject", "edited-gone");
|
|
1967
|
+
appendFileSync(join(dir, "SKILL.md"), "\nmy local edit\n");
|
|
1968
|
+
|
|
1969
|
+
forceFullFetchNextSync();
|
|
1970
|
+
server.setEtag('"v2"');
|
|
1971
|
+
server.setLibraryResponse({
|
|
1972
|
+
skills: [], // recalled to absence
|
|
1973
|
+
removals: [],
|
|
1974
|
+
syncedAt: "2025-01-02T00:00:00Z",
|
|
1975
|
+
});
|
|
1976
|
+
const { createCaptureStream } = await import(
|
|
1977
|
+
"../helpers/capture-stream.mjs"
|
|
1978
|
+
);
|
|
1979
|
+
const stderr = createCaptureStream();
|
|
1980
|
+
const result = await runSync({
|
|
1981
|
+
serverUrl,
|
|
1982
|
+
apiKey: VALID_KEY,
|
|
1983
|
+
vendors: ["claudeCode"],
|
|
1984
|
+
io: { stderr },
|
|
1985
|
+
});
|
|
1986
|
+
|
|
1987
|
+
// Removed from the loadable folder (counts as removed so the hook is
|
|
1988
|
+
// never silent) but NOT destroyed — and distinctly counted as quarantined
|
|
1989
|
+
// so the summary can tell the user their edits were preserved.
|
|
1990
|
+
assert.equal(result.removed, 1);
|
|
1991
|
+
assert.equal(result.quarantined, 1, "the move is counted as quarantined, distinct from a delete");
|
|
1992
|
+
assert.equal(existsSync(dir), false, "loadable copy is gone");
|
|
1993
|
+
assert.match(
|
|
1994
|
+
stderr.text(),
|
|
1995
|
+
/edited-gone\/ is no longer in your library but has local edits — moved to quarantine/,
|
|
1996
|
+
);
|
|
1997
|
+
|
|
1998
|
+
const quarantined = readdirSync(globalQuarantineDir()).filter((d) =>
|
|
1999
|
+
d.startsWith("edited-gone-"),
|
|
2000
|
+
);
|
|
2001
|
+
assert.equal(quarantined.length, 1, "exactly one quarantined copy");
|
|
2002
|
+
assert.match(
|
|
2003
|
+
readFileSync(
|
|
2004
|
+
join(globalQuarantineDir(), quarantined[0], "SKILL.md"),
|
|
2005
|
+
"utf-8",
|
|
2006
|
+
),
|
|
2007
|
+
/my local edit/,
|
|
2008
|
+
"the edit is preserved in quarantine",
|
|
2009
|
+
);
|
|
2010
|
+
});
|
|
2011
|
+
|
|
2012
|
+
it("does NOT reconcile on a DELTA — absence from a partial skills[] is not removal", async () => {
|
|
2013
|
+
server.setEtag('"v1"');
|
|
2014
|
+
server.setLibraryResponse({
|
|
2015
|
+
skills: [makeSkill("keep"), makeSkill("stable")],
|
|
2016
|
+
removals: [],
|
|
2017
|
+
syncedAt: "2025-01-01T00:00:00Z",
|
|
2018
|
+
});
|
|
2019
|
+
await runSync({ serverUrl, apiKey: VALID_KEY, vendors: ["claudeCode"] });
|
|
2020
|
+
assert.ok(existsSync(resolvePlacementDir("claudeProject", "stable")));
|
|
2021
|
+
|
|
2022
|
+
// A genuine DELTA (cliVersion stays current → `since` IS sent). The
|
|
2023
|
+
// response re-delivers only `keep`; `stable` is absent simply because it
|
|
2024
|
+
// did not change — NOT because it was removed. It must survive.
|
|
2025
|
+
server.resetLibraryInspection();
|
|
2026
|
+
server.setEtag('"v2"');
|
|
2027
|
+
server.setLibraryResponse({
|
|
2028
|
+
skills: [makeSkill("keep")],
|
|
2029
|
+
removals: [],
|
|
2030
|
+
syncedAt: "2025-01-02T00:00:00Z",
|
|
2031
|
+
});
|
|
2032
|
+
const result = await runSync({
|
|
2033
|
+
serverUrl,
|
|
2034
|
+
apiKey: VALID_KEY,
|
|
2035
|
+
vendors: ["claudeCode"],
|
|
2036
|
+
});
|
|
2037
|
+
|
|
2038
|
+
assert.notEqual(
|
|
2039
|
+
server.getLastLibrarySince(),
|
|
2040
|
+
null,
|
|
2041
|
+
"must be a delta (since sent)",
|
|
2042
|
+
);
|
|
2043
|
+
assert.equal(result.removed, 0, "a delta never reconciles by absence");
|
|
2044
|
+
assert.ok(
|
|
2045
|
+
existsSync(resolvePlacementDir("claudeProject", "stable")),
|
|
2046
|
+
"unchanged skill absent from the delta is NOT deleted",
|
|
2047
|
+
);
|
|
2048
|
+
assert.ok(
|
|
2049
|
+
readLastSync().skills["alice/stable"],
|
|
2050
|
+
"and it stays in state for a later full sync",
|
|
2051
|
+
);
|
|
2052
|
+
});
|
|
2053
|
+
|
|
2054
|
+
it("drops a departed skill from .last-sync even when its dir was already gone", async () => {
|
|
2055
|
+
server.setEtag('"v1"');
|
|
2056
|
+
server.setLibraryResponse({
|
|
2057
|
+
skills: [makeSkill("keep"), makeSkill("manually-deleted")],
|
|
2058
|
+
removals: [],
|
|
2059
|
+
syncedAt: "2025-01-01T00:00:00Z",
|
|
2060
|
+
});
|
|
2061
|
+
await runSync({ serverUrl, apiKey: VALID_KEY, vendors: ["claudeCode"] });
|
|
2062
|
+
|
|
2063
|
+
// User manually deletes the dir; it is still listed in `.last-sync`.
|
|
2064
|
+
rmSync(resolvePlacementDir("claudeProject", "manually-deleted"), {
|
|
2065
|
+
recursive: true,
|
|
2066
|
+
force: true,
|
|
2067
|
+
});
|
|
2068
|
+
|
|
2069
|
+
forceFullFetchNextSync();
|
|
2070
|
+
server.setEtag('"v2"');
|
|
2071
|
+
server.setLibraryResponse({
|
|
2072
|
+
skills: [makeSkill("keep")],
|
|
2073
|
+
removals: [],
|
|
2074
|
+
syncedAt: "2025-01-02T00:00:00Z",
|
|
2075
|
+
});
|
|
2076
|
+
const result = await runSync({
|
|
2077
|
+
serverUrl,
|
|
2078
|
+
apiKey: VALID_KEY,
|
|
2079
|
+
vendors: ["claudeCode"],
|
|
2080
|
+
});
|
|
2081
|
+
|
|
2082
|
+
// Nothing on disk to remove (removed stays 0), but state is cleaned so
|
|
2083
|
+
// `list` won't report a phantom managed skill.
|
|
2084
|
+
assert.equal(result.removed, 0);
|
|
2085
|
+
assert.equal(
|
|
2086
|
+
readLastSync().skills["alice/manually-deleted"],
|
|
2087
|
+
undefined,
|
|
2088
|
+
"phantom entry dropped from .last-sync",
|
|
2089
|
+
);
|
|
2090
|
+
assert.ok(readLastSync().skills["alice/keep"]);
|
|
2091
|
+
});
|
|
2092
|
+
|
|
2093
|
+
it("counts a removed skill ONCE even when placed at multiple vendor targets (review HIGH)", async () => {
|
|
2094
|
+
server.setEtag('"v1"');
|
|
2095
|
+
server.setLibraryResponse({
|
|
2096
|
+
skills: [makeSkill("keep"), makeSkill("gone")],
|
|
2097
|
+
removals: [],
|
|
2098
|
+
syncedAt: "2025-01-01T00:00:00Z",
|
|
2099
|
+
});
|
|
2100
|
+
await runSync({
|
|
2101
|
+
serverUrl,
|
|
2102
|
+
apiKey: VALID_KEY,
|
|
2103
|
+
vendors: ["claudeCode", "cursor"],
|
|
2104
|
+
});
|
|
2105
|
+
// `gone` is on disk at BOTH targets (claudeProject + agentsProject).
|
|
2106
|
+
assert.ok(existsSync(resolvePlacementDir("claudeProject", "gone")));
|
|
2107
|
+
assert.ok(existsSync(resolvePlacementDir("agentsProject", "gone")));
|
|
2108
|
+
|
|
2109
|
+
forceFullFetchNextSync();
|
|
2110
|
+
server.setEtag('"v2"');
|
|
2111
|
+
server.setLibraryResponse({
|
|
2112
|
+
skills: [makeSkill("keep")],
|
|
2113
|
+
removals: [],
|
|
2114
|
+
syncedAt: "2025-01-02T00:00:00Z",
|
|
2115
|
+
});
|
|
2116
|
+
const result = await runSync({
|
|
2117
|
+
serverUrl,
|
|
2118
|
+
apiKey: VALID_KEY,
|
|
2119
|
+
vendors: ["claudeCode", "cursor"],
|
|
2120
|
+
});
|
|
2121
|
+
|
|
2122
|
+
// One skill left the library → removed:1, NOT 2 (once per skill, not
|
|
2123
|
+
// once per placement target — mirrors the tombstone/skillset paths).
|
|
2124
|
+
assert.equal(result.removed, 1, "counted per skill, not per target");
|
|
2125
|
+
assert.equal(existsSync(resolvePlacementDir("claudeProject", "gone")), false);
|
|
2126
|
+
assert.equal(existsSync(resolvePlacementDir("agentsProject", "gone")), false);
|
|
2127
|
+
});
|
|
2128
|
+
|
|
2129
|
+
it("does NOT foreclose a --global recalled skill's cleanup during a project sync (review B-1)", async () => {
|
|
2130
|
+
// Write `gskill` GLOBALLY; it is recorded in the machine-global .last-sync.
|
|
2131
|
+
server.setEtag('"vg1"');
|
|
2132
|
+
server.setLibraryResponse({
|
|
2133
|
+
skills: [makeSkill("gskill")],
|
|
2134
|
+
removals: [],
|
|
2135
|
+
syncedAt: "2025-01-01T00:00:00Z",
|
|
2136
|
+
});
|
|
2137
|
+
await runSync({
|
|
2138
|
+
serverUrl,
|
|
2139
|
+
apiKey: VALID_KEY,
|
|
2140
|
+
vendors: ["claudeCode"],
|
|
2141
|
+
global: true,
|
|
2142
|
+
});
|
|
2143
|
+
const globalDir = resolvePlacementDir("claudeGlobal", "gskill");
|
|
2144
|
+
assert.ok(existsSync(globalDir), "global dir written");
|
|
2145
|
+
|
|
2146
|
+
// A PROJECT full fetch fires with `gskill` recalled. The project scope
|
|
2147
|
+
// has no `gskill` dir; the reconcile must NOT purge its state key (that
|
|
2148
|
+
// would strand the global dir loadable forever) and must NOT touch it.
|
|
2149
|
+
forceFullFetchNextSync();
|
|
2150
|
+
server.setEtag('"vp1"');
|
|
2151
|
+
server.setLibraryResponse({ skills: [], removals: [], syncedAt: "2025-01-02T00:00:00Z" });
|
|
2152
|
+
const project = await runSync({ serverUrl, apiKey: VALID_KEY, vendors: ["claudeCode"] });
|
|
2153
|
+
|
|
2154
|
+
assert.equal(project.removed, 0, "a project sync cleans nothing in the global scope");
|
|
2155
|
+
assert.ok(existsSync(globalDir), "global dir untouched by the project sync");
|
|
2156
|
+
assert.ok(
|
|
2157
|
+
readLastSync().skills["alice/gskill"],
|
|
2158
|
+
"state key retained so a later --global sync can still reconcile it",
|
|
2159
|
+
);
|
|
2160
|
+
|
|
2161
|
+
// A --global full fetch then reconciles it correctly (no foreclosure).
|
|
2162
|
+
forceFullFetchNextSync();
|
|
2163
|
+
server.setEtag('"vg2"');
|
|
2164
|
+
server.setLibraryResponse({ skills: [], removals: [], syncedAt: "2025-01-03T00:00:00Z" });
|
|
2165
|
+
const glob = await runSync({
|
|
2166
|
+
serverUrl,
|
|
2167
|
+
apiKey: VALID_KEY,
|
|
2168
|
+
vendors: ["claudeCode"],
|
|
2169
|
+
global: true,
|
|
2170
|
+
});
|
|
2171
|
+
assert.equal(glob.removed, 1, "the --global sync finally removes it");
|
|
2172
|
+
assert.equal(existsSync(globalDir), false);
|
|
2173
|
+
assert.equal(readLastSync().skills["alice/gskill"], undefined);
|
|
2174
|
+
});
|
|
2175
|
+
|
|
2176
|
+
it("does not reconcile a re-attributed skill (same name, new owner) (review B-2)", async () => {
|
|
2177
|
+
server.setEtag('"v1"');
|
|
2178
|
+
server.setLibraryResponse({
|
|
2179
|
+
skills: [{ ...makeSkill("foo"), owner: "skillrepo" }],
|
|
2180
|
+
removals: [],
|
|
2181
|
+
syncedAt: "2025-01-01T00:00:00Z",
|
|
2182
|
+
});
|
|
2183
|
+
await runSync({ serverUrl, apiKey: VALID_KEY, vendors: ["claudeCode"] });
|
|
2184
|
+
const dir = resolvePlacementDir("claudeProject", "foo");
|
|
2185
|
+
assert.ok(existsSync(dir));
|
|
2186
|
+
assert.ok(readLastSync().skills["skillrepo/foo"], "baseline keyed by original owner");
|
|
2187
|
+
|
|
2188
|
+
// A claim re-attributes `foo` skillrepo/foo → alice/foo. On the next full
|
|
2189
|
+
// fetch the OLD key is absent from servedKeys but the NAME is still served
|
|
2190
|
+
// (a library has one owner per name) — it must NOT be reconciled; the
|
|
2191
|
+
// apply loop rewrites the same dir under the new key, and the stale key
|
|
2192
|
+
// is dropped from state.
|
|
2193
|
+
forceFullFetchNextSync();
|
|
2194
|
+
server.setEtag('"v2"');
|
|
2195
|
+
server.setLibraryResponse({
|
|
2196
|
+
skills: [{ ...makeSkill("foo"), owner: "alice" }],
|
|
2197
|
+
removals: [],
|
|
2198
|
+
syncedAt: "2025-01-02T00:00:00Z",
|
|
2199
|
+
});
|
|
2200
|
+
const result = await runSync({ serverUrl, apiKey: VALID_KEY, vendors: ["claudeCode"] });
|
|
2201
|
+
|
|
2202
|
+
assert.equal(result.removed, 0, "re-attribution is not a removal");
|
|
2203
|
+
assert.equal(result.quarantined ?? 0, 0, "and never a spurious quarantine");
|
|
2204
|
+
assert.ok(existsSync(dir), "the dir survives (rewritten under the new owner)");
|
|
2205
|
+
assert.ok(readLastSync().skills["alice/foo"], "state carries the new key");
|
|
2206
|
+
assert.equal(
|
|
2207
|
+
readLastSync().skills["skillrepo/foo"],
|
|
2208
|
+
undefined,
|
|
2209
|
+
"stale old key dropped",
|
|
2210
|
+
);
|
|
2211
|
+
});
|
|
2212
|
+
|
|
2213
|
+
it("never resolves a traversal-shaped .last-sync key into a path — no arbitrary-dir deletion (#2879)", async () => {
|
|
2214
|
+
// `.last-sync` is local data, not trusted input (corruption or a local
|
|
2215
|
+
// tamper). The reconcile's isValidSkillName guard is the ONLY barrier
|
|
2216
|
+
// between "remove this orphan" and an rm -rf on an escaped path — nothing
|
|
2217
|
+
// downstream (resolvePlacementDir → removeManagedDir) re-validates. Prove
|
|
2218
|
+
// the guard: a traversal key whose target is byte-identical to a real
|
|
2219
|
+
// baseline (so a guard-LESS delete branch WOULD match SHAs and delete it)
|
|
2220
|
+
// must be a clean no-op. Delete the guard line and this goes red.
|
|
2221
|
+
server.setEtag('"v1"');
|
|
2222
|
+
server.setLibraryResponse({
|
|
2223
|
+
skills: [makeSkill("victim")],
|
|
2224
|
+
removals: [],
|
|
2225
|
+
syncedAt: "2025-01-01T00:00:00Z",
|
|
2226
|
+
});
|
|
2227
|
+
await runSync({ serverUrl, apiKey: VALID_KEY, vendors: ["claudeCode"] });
|
|
2228
|
+
const victimDir = resolvePlacementDir("claudeProject", "victim");
|
|
2229
|
+
|
|
2230
|
+
// Plant a sentinel OUTSIDE the placement root, byte-identical to victim so
|
|
2231
|
+
// its on-disk SHAs match victim's recorded baseline.
|
|
2232
|
+
const sentinelDir = join(sandbox, "project", "PWNED-SENTINEL");
|
|
2233
|
+
mkdirSync(sentinelDir, { recursive: true });
|
|
2234
|
+
writeFileSync(
|
|
2235
|
+
join(sentinelDir, "SKILL.md"),
|
|
2236
|
+
readFileSync(join(victimDir, "SKILL.md")),
|
|
2237
|
+
);
|
|
2238
|
+
|
|
2239
|
+
// Inject a traversal key that resolves into the sentinel (join normalizes
|
|
2240
|
+
// the `..`), reusing victim's SHAs so a guard-less reconcile would treat
|
|
2241
|
+
// it as an unmodified orphan and delete it.
|
|
2242
|
+
const prior = readLastSync();
|
|
2243
|
+
writeLastSync({
|
|
2244
|
+
...prior,
|
|
2245
|
+
cliVersion: "4.7.0", // force a full fetch
|
|
2246
|
+
skills: {
|
|
2247
|
+
...prior.skills,
|
|
2248
|
+
"alice/../../PWNED-SENTINEL": { ...prior.skills["alice/victim"] },
|
|
2249
|
+
},
|
|
2250
|
+
});
|
|
2251
|
+
|
|
2252
|
+
// Full authoritative set WITHOUT the traversal key → it would be reconciled.
|
|
2253
|
+
server.setEtag('"v2"');
|
|
2254
|
+
server.setLibraryResponse({
|
|
2255
|
+
skills: [makeSkill("victim")],
|
|
2256
|
+
removals: [],
|
|
2257
|
+
syncedAt: "2025-01-02T00:00:00Z",
|
|
2258
|
+
});
|
|
2259
|
+
const result = await runSync({
|
|
2260
|
+
serverUrl,
|
|
2261
|
+
apiKey: VALID_KEY,
|
|
2262
|
+
vendors: ["claudeCode"],
|
|
2263
|
+
});
|
|
2264
|
+
|
|
2265
|
+
assert.equal(server.getLastLibrarySince(), null, "must be a full fetch");
|
|
2266
|
+
assert.ok(
|
|
2267
|
+
existsSync(join(sentinelDir, "SKILL.md")),
|
|
2268
|
+
"traversal target is UNTOUCHED — the guard rejected the name before any fs op",
|
|
2269
|
+
);
|
|
2270
|
+
assert.equal(result.removed, 0, "a traversal key reconciles nothing");
|
|
2271
|
+
assert.equal(result.quarantined ?? 0, 0, "and quarantines nothing");
|
|
2272
|
+
});
|
|
2273
|
+
|
|
2274
|
+
it("preserves ALL files of a multi-file edited orphan in quarantine (#2879)", async () => {
|
|
2275
|
+
// Quarantine must copy the WHOLE dir tree, not just SKILL.md. A real skill
|
|
2276
|
+
// carries scripts/ and references/; prove every file survives byte-for-byte
|
|
2277
|
+
// (the existing quarantine tests only cover single-file skills).
|
|
2278
|
+
const multi = {
|
|
2279
|
+
owner: "alice",
|
|
2280
|
+
name: "multi",
|
|
2281
|
+
version: "1.0.0",
|
|
2282
|
+
description: "multi-file skill",
|
|
2283
|
+
files: [
|
|
2284
|
+
{ path: "SKILL.md", content: "---\nname: multi\ndescription: m\n---\n\nbody\n", sha256: "x", size: 1, contentType: "text/markdown" },
|
|
2285
|
+
{ path: "scripts/run.sh", content: "#!/bin/sh\necho hi\n", sha256: "x", size: 1, contentType: "text/x-shellscript" },
|
|
2286
|
+
{ path: "references/guide.md", content: "# Guide\nreference body\n", sha256: "x", size: 1, contentType: "text/markdown" },
|
|
2287
|
+
],
|
|
2288
|
+
};
|
|
2289
|
+
server.setEtag('"v1"');
|
|
2290
|
+
server.setLibraryResponse({ skills: [multi], removals: [], syncedAt: "2025-01-01T00:00:00Z" });
|
|
2291
|
+
await runSync({ serverUrl, apiKey: VALID_KEY, vendors: ["claudeCode"] });
|
|
2292
|
+
const dir = resolvePlacementDir("claudeProject", "multi");
|
|
2293
|
+
assert.ok(existsSync(join(dir, "scripts", "run.sh")), "multi-file skill written with scripts/");
|
|
2294
|
+
|
|
2295
|
+
// User edits one nested file → whole dir diverges from baseline → quarantine.
|
|
2296
|
+
appendFileSync(join(dir, "references", "guide.md"), "\nMY EDIT\n");
|
|
2297
|
+
|
|
2298
|
+
forceFullFetchNextSync();
|
|
2299
|
+
server.setEtag('"v2"');
|
|
2300
|
+
server.setLibraryResponse({ skills: [], removals: [], syncedAt: "2025-01-02T00:00:00Z" });
|
|
2301
|
+
const result = await runSync({ serverUrl, apiKey: VALID_KEY, vendors: ["claudeCode"] });
|
|
2302
|
+
|
|
2303
|
+
assert.equal(result.quarantined, 1, "the edited multi-file orphan is quarantined");
|
|
2304
|
+
assert.equal(existsSync(dir), false, "loadable dir removed");
|
|
2305
|
+
const q = readdirSync(globalQuarantineDir()).filter((d) => d.startsWith("multi-"));
|
|
2306
|
+
assert.equal(q.length, 1, "exactly one quarantined copy");
|
|
2307
|
+
const qdir = join(globalQuarantineDir(), q[0]);
|
|
2308
|
+
assert.ok(existsSync(join(qdir, "SKILL.md")), "SKILL.md preserved in quarantine");
|
|
2309
|
+
assert.ok(existsSync(join(qdir, "scripts", "run.sh")), "scripts/run.sh preserved in quarantine");
|
|
2310
|
+
assert.match(
|
|
2311
|
+
readFileSync(join(qdir, "scripts", "run.sh"), "utf-8"),
|
|
2312
|
+
/echo hi/,
|
|
2313
|
+
"nested script content intact",
|
|
2314
|
+
);
|
|
2315
|
+
assert.match(
|
|
2316
|
+
readFileSync(join(qdir, "references", "guide.md"), "utf-8"),
|
|
2317
|
+
/MY EDIT/,
|
|
2318
|
+
"the user's edit is preserved byte-for-byte",
|
|
2319
|
+
);
|
|
2320
|
+
});
|
|
2321
|
+
});
|
|
2322
|
+
|
|
1853
2323
|
// ───────────────────────────────────────────────────────────────────
|
|
1854
2324
|
// placementsAreComplete unit tests (PR #1575 hotfix)
|
|
1855
2325
|
// ───────────────────────────────────────────────────────────────────
|