skillrepo 2.0.0 → 3.1.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 +276 -145
- package/bin/skillrepo.mjs +224 -36
- package/package.json +6 -3
- package/src/commands/add.mjs +176 -0
- package/src/commands/get.mjs +116 -0
- package/src/commands/init.mjs +589 -143
- package/src/commands/list.mjs +176 -0
- package/src/commands/remove.mjs +162 -0
- package/src/commands/search.mjs +188 -0
- package/src/commands/session-sync.mjs +152 -0
- package/src/commands/uninstall.mjs +484 -0
- package/src/commands/update.mjs +184 -0
- package/src/lib/artifact-registry.mjs +265 -0
- package/src/lib/cli-config.mjs +230 -0
- package/src/lib/config.mjs +238 -0
- package/src/lib/detect-ides.mjs +0 -19
- package/src/lib/errors.mjs +264 -0
- package/src/lib/file-write.mjs +705 -0
- package/src/lib/fs-utils.mjs +83 -1
- package/src/lib/http.mjs +817 -37
- package/src/lib/identifier.mjs +153 -0
- package/src/lib/mcp-merge.mjs +275 -0
- package/src/lib/mergers/gitignore.mjs +73 -18
- package/src/lib/mergers/session-hook.mjs +298 -0
- package/src/lib/paths.mjs +67 -17
- package/src/lib/prompt.mjs +11 -44
- package/src/lib/removers/claude-mcp.mjs +67 -0
- package/src/lib/removers/cursor-mcp.mjs +60 -0
- package/src/lib/removers/env-local.mjs +55 -0
- package/src/lib/removers/gitignore.mjs +108 -0
- package/src/lib/removers/settings.mjs +183 -0
- package/src/lib/removers/vscode-mcp.mjs +87 -0
- package/src/lib/removers/windsurf-mcp.mjs +65 -0
- package/src/lib/sync.mjs +305 -0
- package/src/test/commands/add.test.mjs +285 -0
- package/src/test/commands/get.test.mjs +176 -0
- package/src/test/commands/init.test.mjs +697 -0
- package/src/test/commands/list.test.mjs +172 -0
- package/src/test/commands/remove.test.mjs +234 -0
- package/src/test/commands/search.test.mjs +204 -0
- package/src/test/commands/session-sync.test.mjs +350 -0
- package/src/test/commands/uninstall.test.mjs +768 -0
- package/src/test/commands/update.test.mjs +322 -0
- package/src/test/detect-ides.test.mjs +9 -14
- package/src/test/dispatcher.test.mjs +224 -0
- package/src/test/e2e/cli-commands.test.mjs +576 -0
- package/src/test/e2e/mock-server.mjs +364 -22
- package/src/test/helpers/capture-stream.mjs +48 -0
- package/src/test/integration/file-write.integration.test.mjs +279 -0
- package/src/test/lib/artifact-registry.test.mjs +268 -0
- package/src/test/lib/cli-config.test.mjs +407 -0
- package/src/test/lib/config.test.mjs +257 -0
- package/src/test/lib/errors.test.mjs +359 -0
- package/src/test/lib/file-write.test.mjs +784 -0
- package/src/test/lib/http.test.mjs +1198 -0
- package/src/test/lib/identifier.test.mjs +157 -0
- package/src/test/lib/mcp-merge.test.mjs +345 -0
- package/src/test/lib/paths.test.mjs +83 -0
- package/src/test/lib/sync.test.mjs +514 -0
- package/src/test/mergers/gitignore.test.mjs +145 -20
- package/src/test/mergers/session-hook.test.mjs +745 -0
- package/src/test/mergers/uninstall-claude-mcp.test.mjs +145 -0
- package/src/test/mergers/uninstall-cursor-mcp.test.mjs +108 -0
- package/src/test/mergers/uninstall-env-local.test.mjs +144 -0
- package/src/test/mergers/uninstall-gitignore.test.mjs +209 -0
- package/src/test/mergers/uninstall-settings.test.mjs +285 -0
- package/src/test/mergers/uninstall-vscode-mcp.test.mjs +215 -0
- package/src/test/mergers/uninstall-windsurf-mcp.test.mjs +122 -0
- package/src/lib/write-configs.mjs +0 -202
- package/src/test/e2e/HANDOFF.md +0 -223
- package/src/test/e2e/cli-init.test.mjs +0 -213
- package/src/test/e2e/payload-factory.mjs +0 -22
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Integration tests for src/lib/file-write.mjs (PR1 of #646).
|
|
3
|
+
*
|
|
4
|
+
* Differs from the unit tests in that we compose the public API against
|
|
5
|
+
* a real temp filesystem and assert on multi-step behaviors:
|
|
6
|
+
* - End-to-end skill round-trip (write → read back → verify)
|
|
7
|
+
* - Update path leaves NO orphan state on success
|
|
8
|
+
* - Sequential rewrites are atomic from the reader's perspective
|
|
9
|
+
* - Recovery from injected partial state (simulated mid-write crash)
|
|
10
|
+
* - Multi-target writes produce identical content
|
|
11
|
+
*
|
|
12
|
+
* These tests touch the real filesystem in a temp directory. They do
|
|
13
|
+
* not require network or a running server.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { describe, it, beforeEach, afterEach } from "node:test";
|
|
17
|
+
import assert from "node:assert/strict";
|
|
18
|
+
import {
|
|
19
|
+
mkdtempSync,
|
|
20
|
+
rmSync,
|
|
21
|
+
mkdirSync,
|
|
22
|
+
writeFileSync,
|
|
23
|
+
existsSync,
|
|
24
|
+
readFileSync,
|
|
25
|
+
readdirSync,
|
|
26
|
+
} from "node:fs";
|
|
27
|
+
import { join } from "node:path";
|
|
28
|
+
import { tmpdir } from "node:os";
|
|
29
|
+
|
|
30
|
+
import {
|
|
31
|
+
writeSkillDir,
|
|
32
|
+
removeSkillDir,
|
|
33
|
+
cleanupOrphans,
|
|
34
|
+
resolvePlacementDir,
|
|
35
|
+
} from "../../lib/file-write.mjs";
|
|
36
|
+
|
|
37
|
+
let sandbox;
|
|
38
|
+
let originalCwd;
|
|
39
|
+
let originalHome;
|
|
40
|
+
|
|
41
|
+
function setupSandbox() {
|
|
42
|
+
sandbox = mkdtempSync(join(tmpdir(), "cli-fw-int-"));
|
|
43
|
+
mkdirSync(join(sandbox, "project"), { recursive: true });
|
|
44
|
+
mkdirSync(join(sandbox, "home"), { recursive: true });
|
|
45
|
+
originalCwd = process.cwd();
|
|
46
|
+
originalHome = process.env.HOME;
|
|
47
|
+
process.chdir(join(sandbox, "project"));
|
|
48
|
+
process.env.HOME = join(sandbox, "home");
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function teardownSandbox() {
|
|
52
|
+
process.chdir(originalCwd);
|
|
53
|
+
process.env.HOME = originalHome;
|
|
54
|
+
if (sandbox) rmSync(sandbox, { recursive: true, force: true });
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function multiFileSkill(name = "pdf-helper") {
|
|
58
|
+
return {
|
|
59
|
+
owner: "alice",
|
|
60
|
+
name,
|
|
61
|
+
files: [
|
|
62
|
+
{
|
|
63
|
+
path: "SKILL.md",
|
|
64
|
+
content: `---\nname: ${name}\ndescription: Multi-file integration test skill.\n---\n\nMain body.\n`,
|
|
65
|
+
},
|
|
66
|
+
{ path: "scripts/extract.py", content: "import sys\nprint(sys.argv)\n" },
|
|
67
|
+
{ path: "scripts/format.sh", content: "#!/bin/bash\necho ok\n" },
|
|
68
|
+
{ path: "references/REFERENCE.md", content: "# Reference\n\nDetails.\n" },
|
|
69
|
+
{ path: "assets/template.json", content: '{"version": 1}\n' },
|
|
70
|
+
// Path-preserving non-spec dir — proves Defect A fix end-to-end
|
|
71
|
+
{ path: "lib/helper.py", content: "def helper():\n return 42\n" },
|
|
72
|
+
],
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
describe("file-write.mjs integration — round-trip", () => {
|
|
77
|
+
beforeEach(setupSandbox);
|
|
78
|
+
afterEach(teardownSandbox);
|
|
79
|
+
|
|
80
|
+
it("writes a 6-file skill and reads back identical bytes", () => {
|
|
81
|
+
const skill = multiFileSkill();
|
|
82
|
+
const result = writeSkillDir(skill, { vendors: ["claudeCode"] });
|
|
83
|
+
assert.equal(result.written.length, 1);
|
|
84
|
+
const dir = result.written[0];
|
|
85
|
+
|
|
86
|
+
for (const file of skill.files) {
|
|
87
|
+
const onDisk = readFileSync(join(dir, file.path), "utf-8");
|
|
88
|
+
assert.equal(onDisk, file.content, `Mismatch for ${file.path}`);
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("writes the same skill identically to multiple targets", () => {
|
|
93
|
+
const skill = multiFileSkill();
|
|
94
|
+
const result = writeSkillDir(skill, { vendors: ["claudeCode", "cursor"] });
|
|
95
|
+
assert.equal(result.written.length, 2);
|
|
96
|
+
|
|
97
|
+
const [firstDir, secondDir] = result.written;
|
|
98
|
+
for (const file of skill.files) {
|
|
99
|
+
const a = readFileSync(join(firstDir, file.path), "utf-8");
|
|
100
|
+
const b = readFileSync(join(secondDir, file.path), "utf-8");
|
|
101
|
+
assert.equal(a, b, `Targets diverged for ${file.path}`);
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("writes to global dir under HOME with --global", () => {
|
|
106
|
+
const skill = multiFileSkill();
|
|
107
|
+
const result = writeSkillDir(skill, { global: true });
|
|
108
|
+
assert.equal(result.written.length, 1);
|
|
109
|
+
const dir = result.written[0];
|
|
110
|
+
assert.ok(dir.startsWith(process.env.HOME), "should write under HOME");
|
|
111
|
+
for (const file of skill.files) {
|
|
112
|
+
assert.ok(existsSync(join(dir, file.path)));
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
describe("file-write.mjs integration — update path", () => {
|
|
118
|
+
beforeEach(setupSandbox);
|
|
119
|
+
afterEach(teardownSandbox);
|
|
120
|
+
|
|
121
|
+
it("leaves no .tmp/ or .old/ directories after a successful write", () => {
|
|
122
|
+
writeSkillDir(multiFileSkill(), { vendors: ["claudeCode"] });
|
|
123
|
+
const root = join(process.cwd(), ".claude", "skills");
|
|
124
|
+
const entries = readdirSync(root);
|
|
125
|
+
for (const entry of entries) {
|
|
126
|
+
assert.ok(!entry.endsWith(".tmp"), `Unexpected .tmp leftover: ${entry}`);
|
|
127
|
+
assert.ok(!entry.endsWith(".old"), `Unexpected .old leftover: ${entry}`);
|
|
128
|
+
}
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it("removes deleted files when overwriting", () => {
|
|
132
|
+
// First write
|
|
133
|
+
writeSkillDir(multiFileSkill(), { vendors: ["claudeCode"] });
|
|
134
|
+
|
|
135
|
+
// Second write removes the lib/ and references/ dirs
|
|
136
|
+
const trimmed = {
|
|
137
|
+
owner: "alice",
|
|
138
|
+
name: "pdf-helper",
|
|
139
|
+
files: [
|
|
140
|
+
{ path: "SKILL.md", content: "---\nname: pdf-helper\n---\nTrimmed.\n" },
|
|
141
|
+
{ path: "scripts/extract.py", content: "print('only this remains')\n" },
|
|
142
|
+
],
|
|
143
|
+
};
|
|
144
|
+
const result = writeSkillDir(trimmed, { vendors: ["claudeCode"] });
|
|
145
|
+
const dir = result.written[0];
|
|
146
|
+
|
|
147
|
+
assert.ok(existsSync(join(dir, "SKILL.md")));
|
|
148
|
+
assert.ok(existsSync(join(dir, "scripts/extract.py")));
|
|
149
|
+
assert.ok(!existsSync(join(dir, "lib/helper.py")), "lib/ should be gone");
|
|
150
|
+
assert.ok(!existsSync(join(dir, "references/REFERENCE.md")), "references/ should be gone");
|
|
151
|
+
assert.ok(!existsSync(join(dir, "assets/template.json")), "assets/ should be gone");
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it("multiple sequential updates each end in a clean state", () => {
|
|
155
|
+
for (let i = 0; i < 5; i++) {
|
|
156
|
+
const skill = {
|
|
157
|
+
owner: "alice",
|
|
158
|
+
name: "pdf-helper",
|
|
159
|
+
files: [
|
|
160
|
+
{ path: "SKILL.md", content: `---\nname: pdf-helper\n---\nIteration ${i}.\n` },
|
|
161
|
+
{ path: "scripts/iter.py", content: `# iteration ${i}\n` },
|
|
162
|
+
],
|
|
163
|
+
};
|
|
164
|
+
writeSkillDir(skill, { vendors: ["claudeCode"] });
|
|
165
|
+
|
|
166
|
+
const dir = resolvePlacementDir("claudeProject", "pdf-helper");
|
|
167
|
+
const skillMd = readFileSync(join(dir, "SKILL.md"), "utf-8");
|
|
168
|
+
assert.match(skillMd, new RegExp(`Iteration ${i}`));
|
|
169
|
+
|
|
170
|
+
// No leftovers
|
|
171
|
+
const root = join(process.cwd(), ".claude", "skills");
|
|
172
|
+
const entries = readdirSync(root);
|
|
173
|
+
for (const entry of entries) {
|
|
174
|
+
assert.ok(!entry.endsWith(".tmp"));
|
|
175
|
+
assert.ok(!entry.endsWith(".old"));
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
});
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
describe("file-write.mjs integration — orphan recovery", () => {
|
|
182
|
+
beforeEach(setupSandbox);
|
|
183
|
+
afterEach(teardownSandbox);
|
|
184
|
+
|
|
185
|
+
it("cleanupOrphans removes injected .tmp/ (with live siblings) and .old/ across all roots", () => {
|
|
186
|
+
// Inject orphans in claudeProject root, claudeGlobal root, and
|
|
187
|
+
// projectFallback root. .tmp/ entries need a live sibling so the
|
|
188
|
+
// safety invariant doesn't preserve them as recoverable.
|
|
189
|
+
const claudeProjectRoot = join(process.cwd(), ".claude", "skills");
|
|
190
|
+
const fallbackRoot = join(process.cwd(), "skills");
|
|
191
|
+
const globalRoot = join(process.env.HOME, ".claude", "skills");
|
|
192
|
+
mkdirSync(claudeProjectRoot, { recursive: true });
|
|
193
|
+
mkdirSync(fallbackRoot, { recursive: true });
|
|
194
|
+
mkdirSync(globalRoot, { recursive: true });
|
|
195
|
+
|
|
196
|
+
// ghost-1: live sibling + .tmp + .old (.tmp gets cleaned because live exists)
|
|
197
|
+
mkdirSync(join(claudeProjectRoot, "ghost-1"));
|
|
198
|
+
mkdirSync(join(claudeProjectRoot, "ghost-1.tmp"));
|
|
199
|
+
mkdirSync(join(claudeProjectRoot, "ghost-1.old"));
|
|
200
|
+
writeFileSync(join(claudeProjectRoot, "ghost-1.tmp", "garbage.txt"), "leftover");
|
|
201
|
+
|
|
202
|
+
// ghost-2: just a .old/ in the fallback root (.old has no invariant)
|
|
203
|
+
mkdirSync(join(fallbackRoot, "ghost-2.old"));
|
|
204
|
+
|
|
205
|
+
// ghost-3: live sibling + .tmp in the global root
|
|
206
|
+
mkdirSync(join(globalRoot, "ghost-3"));
|
|
207
|
+
mkdirSync(join(globalRoot, "ghost-3.tmp"));
|
|
208
|
+
|
|
209
|
+
const result = cleanupOrphans({});
|
|
210
|
+
assert.equal(result.cleaned.length, 4);
|
|
211
|
+
|
|
212
|
+
assert.ok(!existsSync(join(claudeProjectRoot, "ghost-1.tmp")));
|
|
213
|
+
assert.ok(!existsSync(join(claudeProjectRoot, "ghost-1.old")));
|
|
214
|
+
assert.ok(!existsSync(join(fallbackRoot, "ghost-2.old")));
|
|
215
|
+
assert.ok(!existsSync(join(globalRoot, "ghost-3.tmp")));
|
|
216
|
+
// Live siblings preserved
|
|
217
|
+
assert.ok(existsSync(join(claudeProjectRoot, "ghost-1")));
|
|
218
|
+
assert.ok(existsSync(join(globalRoot, "ghost-3")));
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
it("cleanupOrphans preserves a .tmp/ whose live target is missing (Windows recovery)", () => {
|
|
222
|
+
// This is the safety invariant proof — a .tmp/ with no live
|
|
223
|
+
// sibling is the user's only copy of a skill that crashed
|
|
224
|
+
// mid-rename on Windows, and must not be deleted.
|
|
225
|
+
const root = join(process.cwd(), ".claude", "skills");
|
|
226
|
+
mkdirSync(join(root, "recoverable.tmp"), { recursive: true });
|
|
227
|
+
writeFileSync(join(root, "recoverable.tmp", "SKILL.md"), "---\nname: recoverable\n---\nUser's only copy.\n");
|
|
228
|
+
|
|
229
|
+
const result = cleanupOrphans({});
|
|
230
|
+
assert.equal(result.cleaned.length, 0, "no orphans should be cleaned");
|
|
231
|
+
assert.ok(existsSync(join(root, "recoverable.tmp", "SKILL.md")), "recoverable .tmp must survive");
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it("write recovers when a stale .tmp/ from a crashed run is present", () => {
|
|
235
|
+
const skill = multiFileSkill();
|
|
236
|
+
// Pre-populate stale .tmp/ with garbage that would conflict
|
|
237
|
+
const targetDir = resolvePlacementDir("claudeProject", "pdf-helper");
|
|
238
|
+
mkdirSync(`${targetDir}.tmp`, { recursive: true });
|
|
239
|
+
writeFileSync(`${targetDir}.tmp/old-garbage.txt`, "x");
|
|
240
|
+
|
|
241
|
+
const result = writeSkillDir(skill, { vendors: ["claudeCode"] });
|
|
242
|
+
const dir = result.written[0];
|
|
243
|
+
|
|
244
|
+
// Old garbage should be gone; new files should be present
|
|
245
|
+
assert.ok(!existsSync(join(dir, "old-garbage.txt")));
|
|
246
|
+
for (const file of skill.files) {
|
|
247
|
+
assert.ok(existsSync(join(dir, file.path)));
|
|
248
|
+
}
|
|
249
|
+
// No .tmp/ leftover
|
|
250
|
+
assert.ok(!existsSync(`${targetDir}.tmp`));
|
|
251
|
+
});
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
describe("file-write.mjs integration — remove + .gitignore", () => {
|
|
255
|
+
beforeEach(setupSandbox);
|
|
256
|
+
afterEach(teardownSandbox);
|
|
257
|
+
|
|
258
|
+
it("write then remove leaves the skills root empty", () => {
|
|
259
|
+
writeSkillDir(multiFileSkill(), { vendors: ["claudeCode"] });
|
|
260
|
+
const result = removeSkillDir("pdf-helper", { vendors: ["claudeCode"] });
|
|
261
|
+
assert.equal(result.removed.length, 1);
|
|
262
|
+
const root = join(process.cwd(), ".claude", "skills");
|
|
263
|
+
if (existsSync(root)) {
|
|
264
|
+
const entries = readdirSync(root);
|
|
265
|
+
assert.equal(entries.length, 0, "skills root should be empty after remove");
|
|
266
|
+
}
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
it("write to fallback creates .gitignore entry; remove does not delete it", () => {
|
|
270
|
+
writeSkillDir(multiFileSkill(), { vendors: ["cursor"] });
|
|
271
|
+
const giBefore = readFileSync(join(process.cwd(), ".gitignore"), "utf-8");
|
|
272
|
+
assert.match(giBefore, /\/skills\//);
|
|
273
|
+
|
|
274
|
+
removeSkillDir("pdf-helper", { vendors: ["cursor"] });
|
|
275
|
+
const giAfter = readFileSync(join(process.cwd(), ".gitignore"), "utf-8");
|
|
276
|
+
// remove should not touch .gitignore
|
|
277
|
+
assert.equal(giAfter, giBefore);
|
|
278
|
+
});
|
|
279
|
+
});
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CI enforcement test for the artifact registry (#885).
|
|
3
|
+
*
|
|
4
|
+
* Architect tightening #1: the artifact-registry test must be
|
|
5
|
+
* BIDIRECTIONAL. It's not enough to iterate registry entries and
|
|
6
|
+
* assert each has a matching remover — that catches "descriptor
|
|
7
|
+
* without implementation" but misses the more dangerous direction,
|
|
8
|
+
* "new merger writes a file we never catalogued."
|
|
9
|
+
*
|
|
10
|
+
* The checks below:
|
|
11
|
+
*
|
|
12
|
+
* 1. Filesystem → registry:
|
|
13
|
+
* - Every file in `src/lib/mergers/*.mjs` (installer modules)
|
|
14
|
+
* must be declared in MERGER_EXPECTED, which maps installer
|
|
15
|
+
* names to the registry ids they produce. A new merger file
|
|
16
|
+
* without an expected-set entry FAILS this test.
|
|
17
|
+
* - Every file in `src/lib/removers/*.mjs` (uninstaller
|
|
18
|
+
* modules) must be declared in REMOVER_EXPECTED, which
|
|
19
|
+
* maps remover names to the registry ids they delete. A new
|
|
20
|
+
* remover file without an expected-set entry FAILS.
|
|
21
|
+
*
|
|
22
|
+
* 2. Registry → filesystem:
|
|
23
|
+
* - Every descriptor id in ARTIFACT_REGISTRY must appear in
|
|
24
|
+
* REMOVER_EXPECTED or DIRECTORY_ARTIFACT_IDS (the inline-
|
|
25
|
+
* handled directory removals in uninstall.mjs). A new
|
|
26
|
+
* descriptor without a remover mapping FAILS.
|
|
27
|
+
*
|
|
28
|
+
* 3. Mutual consistency:
|
|
29
|
+
* - Every registry id in MERGER_EXPECTED also appears in
|
|
30
|
+
* REMOVER_EXPECTED or DIRECTORY_ARTIFACT_IDS. A merger that
|
|
31
|
+
* writes an artifact with no remover path FAILS.
|
|
32
|
+
*
|
|
33
|
+
* When this test fails, the fix is always either (a) update the
|
|
34
|
+
* expected-set in this test because the change was intentional
|
|
35
|
+
* (adding a new merger/remover pair), or (b) update the registry
|
|
36
|
+
* because the implementation drifted. NEVER silence the test — that
|
|
37
|
+
* defeats the entire drift-protection mechanism.
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
import { describe, it } from "node:test";
|
|
41
|
+
import assert from "node:assert/strict";
|
|
42
|
+
import { readdirSync } from "node:fs";
|
|
43
|
+
import { join, dirname } from "node:path";
|
|
44
|
+
import { fileURLToPath } from "node:url";
|
|
45
|
+
|
|
46
|
+
import { ARTIFACT_REGISTRY } from "../../lib/artifact-registry.mjs";
|
|
47
|
+
|
|
48
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
49
|
+
const LIB_DIR = join(__dirname, "..", "..", "lib");
|
|
50
|
+
const MERGERS_DIR = join(LIB_DIR, "mergers");
|
|
51
|
+
const REMOVERS_DIR = join(LIB_DIR, "removers");
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Maps each installer merger file (by basename without `.mjs`) to
|
|
55
|
+
* the registry descriptor ids it produces. The init flow writes
|
|
56
|
+
* through these mergers; every installed artifact must correspond
|
|
57
|
+
* to a catalogued descriptor so the uninstaller can find it.
|
|
58
|
+
*
|
|
59
|
+
* UPDATE this table in the same PR that adds a new merger. The
|
|
60
|
+
* "filesystem → registry" assertion below reads the directory and
|
|
61
|
+
* requires every file to appear here. A missing entry fails with:
|
|
62
|
+
*
|
|
63
|
+
* "merger X.mjs has no entry in MERGER_EXPECTED"
|
|
64
|
+
*
|
|
65
|
+
* which points directly at this file.
|
|
66
|
+
*/
|
|
67
|
+
const MERGER_EXPECTED = Object.freeze({
|
|
68
|
+
"claude-mcp": ["claude-mcp-entry"],
|
|
69
|
+
"cursor-mcp": ["cursor-mcp-entry"],
|
|
70
|
+
"vscode-mcp": ["vscode-mcp-entry", "vscode-mcp-input"],
|
|
71
|
+
"windsurf-mcp": ["windsurf-mcp-entry"],
|
|
72
|
+
"env-local": ["env-local-key"],
|
|
73
|
+
gitignore: ["gitignore-entries"],
|
|
74
|
+
// Session-sync installer added by #884. Writes the hook entry that
|
|
75
|
+
// the `settings` remover (src/lib/removers/settings.mjs) identifies
|
|
76
|
+
// via SESSION_HOOK_FINGERPRINT. One installer module, two target
|
|
77
|
+
// paths: project-local (`.claude/settings.local.json`) when called
|
|
78
|
+
// without --global, user-wide (`~/.claude/settings.local.json`)
|
|
79
|
+
// with --global. Each path has its own registry descriptor so
|
|
80
|
+
// `skillrepo uninstall` and `skillrepo uninstall --global` both
|
|
81
|
+
// know to look in the right place. Round-trip contracts are
|
|
82
|
+
// verified in src/test/mergers/session-hook.test.mjs.
|
|
83
|
+
"session-hook": ["settings-session-hook", "settings-session-hook-global"],
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Maps each remover file (by basename without `.mjs`) to the
|
|
88
|
+
* registry descriptor ids it tears down. Symmetric to
|
|
89
|
+
* MERGER_EXPECTED — every file in src/lib/removers/ must appear
|
|
90
|
+
* here.
|
|
91
|
+
*/
|
|
92
|
+
const REMOVER_EXPECTED = Object.freeze({
|
|
93
|
+
"claude-mcp": ["claude-mcp-entry"],
|
|
94
|
+
"cursor-mcp": ["cursor-mcp-entry"],
|
|
95
|
+
"vscode-mcp": ["vscode-mcp-entry", "vscode-mcp-input"],
|
|
96
|
+
"windsurf-mcp": ["windsurf-mcp-entry"],
|
|
97
|
+
"env-local": ["env-local-key"],
|
|
98
|
+
gitignore: ["gitignore-entries"],
|
|
99
|
+
// One remover file (settings.mjs), two descriptor ids — project-
|
|
100
|
+
// local and global variants go through the same walk with the
|
|
101
|
+
// settings remover's `{ global }` option.
|
|
102
|
+
settings: ["settings-session-hook", "settings-session-hook-global"],
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Registry ids handled inline by uninstall.mjs's
|
|
107
|
+
* `removeDirectoryArtifact` (not by a dedicated remover module).
|
|
108
|
+
* These are the `kind: "directory"` descriptors in the registry.
|
|
109
|
+
*/
|
|
110
|
+
const DIRECTORY_ARTIFACT_IDS = Object.freeze([
|
|
111
|
+
"skills-dir-project",
|
|
112
|
+
"skills-dir-global",
|
|
113
|
+
"global-config-dir",
|
|
114
|
+
]);
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Read an mjs directory and return an array of basenames without
|
|
118
|
+
* extension, excluding any hidden files or subdirectories.
|
|
119
|
+
*/
|
|
120
|
+
function listMjsBasenames(dir) {
|
|
121
|
+
return readdirSync(dir, { withFileTypes: true })
|
|
122
|
+
.filter((d) => d.isFile() && d.name.endsWith(".mjs"))
|
|
123
|
+
.map((d) => d.name.replace(/\.mjs$/, ""));
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
describe("artifact-registry: drift enforcement", () => {
|
|
127
|
+
it("every file in src/lib/mergers/ is declared in MERGER_EXPECTED", () => {
|
|
128
|
+
// Filesystem-first direction: a new merger with no expected-set
|
|
129
|
+
// entry fails here. This is the load-bearing check — it catches
|
|
130
|
+
// "engineer added a new write path without updating the catalog."
|
|
131
|
+
const found = listMjsBasenames(MERGERS_DIR);
|
|
132
|
+
for (const name of found) {
|
|
133
|
+
assert.ok(
|
|
134
|
+
MERGER_EXPECTED[name],
|
|
135
|
+
`merger ${name}.mjs has no entry in MERGER_EXPECTED. ` +
|
|
136
|
+
`Add it to src/test/lib/artifact-registry.test.mjs AND add ` +
|
|
137
|
+
`a matching remover + registry descriptor.`,
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it("every entry in MERGER_EXPECTED has a corresponding mjs file", () => {
|
|
143
|
+
// Inverse: catches a typo or stale entry in MERGER_EXPECTED
|
|
144
|
+
// (e.g., someone removed a merger file but forgot to trim this
|
|
145
|
+
// table). Low-severity drift but still worth catching.
|
|
146
|
+
const found = new Set(listMjsBasenames(MERGERS_DIR));
|
|
147
|
+
for (const name of Object.keys(MERGER_EXPECTED)) {
|
|
148
|
+
assert.ok(
|
|
149
|
+
found.has(name),
|
|
150
|
+
`MERGER_EXPECTED names ${name}.mjs but the file does not exist at ${MERGERS_DIR}.`,
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it("every file in src/lib/removers/ is declared in REMOVER_EXPECTED", () => {
|
|
156
|
+
const found = listMjsBasenames(REMOVERS_DIR);
|
|
157
|
+
for (const name of found) {
|
|
158
|
+
assert.ok(
|
|
159
|
+
REMOVER_EXPECTED[name],
|
|
160
|
+
`remover ${name}.mjs has no entry in REMOVER_EXPECTED. ` +
|
|
161
|
+
`Add it to src/test/lib/artifact-registry.test.mjs AND confirm ` +
|
|
162
|
+
`its target ids are in ARTIFACT_REGISTRY.`,
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it("every entry in REMOVER_EXPECTED has a corresponding mjs file", () => {
|
|
168
|
+
const found = new Set(listMjsBasenames(REMOVERS_DIR));
|
|
169
|
+
for (const name of Object.keys(REMOVER_EXPECTED)) {
|
|
170
|
+
assert.ok(
|
|
171
|
+
found.has(name),
|
|
172
|
+
`REMOVER_EXPECTED names ${name}.mjs but the file does not exist at ${REMOVERS_DIR}.`,
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it("every registry descriptor id has a remover or is an inline directory removal", () => {
|
|
178
|
+
// Registry-first direction: catches "descriptor added but no
|
|
179
|
+
// one implemented the removal."
|
|
180
|
+
const allImplementedIds = new Set([
|
|
181
|
+
...Object.values(REMOVER_EXPECTED).flat(),
|
|
182
|
+
...DIRECTORY_ARTIFACT_IDS,
|
|
183
|
+
]);
|
|
184
|
+
for (const d of ARTIFACT_REGISTRY) {
|
|
185
|
+
assert.ok(
|
|
186
|
+
allImplementedIds.has(d.id),
|
|
187
|
+
`ARTIFACT_REGISTRY id "${d.id}" has no remover. Either add ` +
|
|
188
|
+
`it to REMOVER_EXPECTED (with a matching src/lib/removers/ ` +
|
|
189
|
+
`file) or to DIRECTORY_ARTIFACT_IDS if it's a whole-directory ` +
|
|
190
|
+
`removal handled inline by uninstall.mjs.`,
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it("every REMOVER_EXPECTED id exists in ARTIFACT_REGISTRY", () => {
|
|
196
|
+
// Catches a typo in the expected-set that doesn't match a real
|
|
197
|
+
// descriptor.
|
|
198
|
+
const registryIds = new Set(ARTIFACT_REGISTRY.map((d) => d.id));
|
|
199
|
+
for (const [removerName, ids] of Object.entries(REMOVER_EXPECTED)) {
|
|
200
|
+
for (const id of ids) {
|
|
201
|
+
assert.ok(
|
|
202
|
+
registryIds.has(id),
|
|
203
|
+
`REMOVER_EXPECTED["${removerName}"] references id "${id}" ` +
|
|
204
|
+
`but ARTIFACT_REGISTRY has no descriptor with that id.`,
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it("every MERGER_EXPECTED id has a matching descriptor AND a remover path", () => {
|
|
211
|
+
// Mutual consistency: a merger that installs an artifact must
|
|
212
|
+
// have both a registry descriptor AND a teardown path (direct
|
|
213
|
+
// remover or inline directory removal). Otherwise we're
|
|
214
|
+
// writing state the user can never clean up.
|
|
215
|
+
const registryIds = new Set(ARTIFACT_REGISTRY.map((d) => d.id));
|
|
216
|
+
const allImplementedIds = new Set([
|
|
217
|
+
...Object.values(REMOVER_EXPECTED).flat(),
|
|
218
|
+
...DIRECTORY_ARTIFACT_IDS,
|
|
219
|
+
]);
|
|
220
|
+
for (const [mergerName, ids] of Object.entries(MERGER_EXPECTED)) {
|
|
221
|
+
for (const id of ids) {
|
|
222
|
+
assert.ok(
|
|
223
|
+
registryIds.has(id),
|
|
224
|
+
`MERGER_EXPECTED["${mergerName}"] references id "${id}" ` +
|
|
225
|
+
`but ARTIFACT_REGISTRY has no descriptor with that id.`,
|
|
226
|
+
);
|
|
227
|
+
assert.ok(
|
|
228
|
+
allImplementedIds.has(id),
|
|
229
|
+
`MERGER_EXPECTED["${mergerName}"] installs id "${id}" but ` +
|
|
230
|
+
`no remover is bound to it. A write with no teardown is ` +
|
|
231
|
+
`exactly the drift #885 exists to prevent.`,
|
|
232
|
+
);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it("every DIRECTORY_ARTIFACT_ID is a directory-kind descriptor in the registry", () => {
|
|
238
|
+
for (const id of DIRECTORY_ARTIFACT_IDS) {
|
|
239
|
+
const d = ARTIFACT_REGISTRY.find((x) => x.id === id);
|
|
240
|
+
assert.ok(d, `DIRECTORY_ARTIFACT_IDS references unknown id ${id}`);
|
|
241
|
+
assert.equal(
|
|
242
|
+
d.kind,
|
|
243
|
+
"directory",
|
|
244
|
+
`DIRECTORY_ARTIFACT_IDS contains non-directory descriptor ${id} (kind=${d.kind})`,
|
|
245
|
+
);
|
|
246
|
+
}
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
it("descriptor kind enum is limited to the known set", () => {
|
|
250
|
+
// Catches a descriptor that uses a `kind` the dispatch table
|
|
251
|
+
// doesn't understand — would produce a runtime "no remover
|
|
252
|
+
// bound" error at uninstall time rather than a clean test failure.
|
|
253
|
+
const VALID_KINDS = new Set([
|
|
254
|
+
"json-key",
|
|
255
|
+
"json-input",
|
|
256
|
+
"line",
|
|
257
|
+
"section",
|
|
258
|
+
"directory",
|
|
259
|
+
]);
|
|
260
|
+
for (const d of ARTIFACT_REGISTRY) {
|
|
261
|
+
assert.ok(
|
|
262
|
+
VALID_KINDS.has(d.kind),
|
|
263
|
+
`descriptor ${d.id} has unknown kind "${d.kind}". Allowed: ` +
|
|
264
|
+
`${[...VALID_KINDS].join(", ")}`,
|
|
265
|
+
);
|
|
266
|
+
}
|
|
267
|
+
});
|
|
268
|
+
});
|