skillrepo 2.0.0 → 3.0.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 +215 -150
- package/bin/skillrepo.mjs +210 -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 +471 -143
- package/src/commands/list.mjs +176 -0
- package/src/commands/remove.mjs +167 -0
- package/src/commands/search.mjs +188 -0
- package/src/commands/update.mjs +67 -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/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/paths.mjs +46 -17
- package/src/lib/prompt.mjs +11 -44
- 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 +486 -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/update.test.mjs +164 -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/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/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,784 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for src/lib/file-write.mjs (PR1 of #646).
|
|
3
|
+
*
|
|
4
|
+
* Covers:
|
|
5
|
+
* - Path validation (safety only — no layout enforcement)
|
|
6
|
+
* - Skill name validation
|
|
7
|
+
* - Frontmatter name parsing
|
|
8
|
+
* - Placement target resolution
|
|
9
|
+
* - placementTargetsFor() decision logic
|
|
10
|
+
* - validateSkill rejection paths via writeSkillDir
|
|
11
|
+
* - writeSkillDir + readback (path-preserving via lib/helper.py)
|
|
12
|
+
* - Update path (atomic POSIX dance)
|
|
13
|
+
* - removeSkillDir
|
|
14
|
+
* - cleanupOrphans
|
|
15
|
+
* - .gitignore management for the project /skills/ fallback
|
|
16
|
+
*
|
|
17
|
+
* Each test uses a temp cwd and HOME so vendor placement targets resolve
|
|
18
|
+
* to throwaway directories.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { describe, it, beforeEach, afterEach } from "node:test";
|
|
22
|
+
import assert from "node:assert/strict";
|
|
23
|
+
import {
|
|
24
|
+
mkdtempSync,
|
|
25
|
+
rmSync,
|
|
26
|
+
mkdirSync,
|
|
27
|
+
writeFileSync,
|
|
28
|
+
existsSync,
|
|
29
|
+
readFileSync,
|
|
30
|
+
chmodSync,
|
|
31
|
+
} from "node:fs";
|
|
32
|
+
import { join } from "node:path";
|
|
33
|
+
import { tmpdir } from "node:os";
|
|
34
|
+
|
|
35
|
+
import {
|
|
36
|
+
validateFilePath,
|
|
37
|
+
isValidSkillName,
|
|
38
|
+
readFrontmatterName,
|
|
39
|
+
resolvePlacementDir,
|
|
40
|
+
placementTargetsFor,
|
|
41
|
+
writeSkillDir,
|
|
42
|
+
removeSkillDir,
|
|
43
|
+
cleanupOrphans,
|
|
44
|
+
MAX_PATH_DEPTH,
|
|
45
|
+
BLOCKED_EXTENSIONS,
|
|
46
|
+
} from "../../lib/file-write.mjs";
|
|
47
|
+
import { CliError, EXIT_VALIDATION } from "../../lib/errors.mjs";
|
|
48
|
+
|
|
49
|
+
// ── Test sandbox helpers ────────────────────────────────────────────────
|
|
50
|
+
|
|
51
|
+
let sandbox;
|
|
52
|
+
let originalCwd;
|
|
53
|
+
let originalHome;
|
|
54
|
+
|
|
55
|
+
function setupSandbox() {
|
|
56
|
+
sandbox = mkdtempSync(join(tmpdir(), "cli-fw-"));
|
|
57
|
+
mkdirSync(join(sandbox, "project"), { recursive: true });
|
|
58
|
+
mkdirSync(join(sandbox, "home"), { recursive: true });
|
|
59
|
+
originalCwd = process.cwd();
|
|
60
|
+
originalHome = process.env.HOME;
|
|
61
|
+
process.chdir(join(sandbox, "project"));
|
|
62
|
+
process.env.HOME = join(sandbox, "home");
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function teardownSandbox() {
|
|
66
|
+
process.chdir(originalCwd);
|
|
67
|
+
process.env.HOME = originalHome;
|
|
68
|
+
if (sandbox) rmSync(sandbox, { recursive: true, force: true });
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// A minimal valid skill payload for happy-path tests
|
|
72
|
+
function minimalSkill(overrides = {}) {
|
|
73
|
+
return {
|
|
74
|
+
owner: "alice",
|
|
75
|
+
name: "pdf-helper",
|
|
76
|
+
files: [
|
|
77
|
+
{
|
|
78
|
+
path: "SKILL.md",
|
|
79
|
+
content: "---\nname: pdf-helper\ndescription: Test skill.\n---\n\nBody.\n",
|
|
80
|
+
},
|
|
81
|
+
],
|
|
82
|
+
...overrides,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// ── validateFilePath ────────────────────────────────────────────────────
|
|
87
|
+
|
|
88
|
+
describe("validateFilePath", () => {
|
|
89
|
+
it("accepts a plain SKILL.md", () => {
|
|
90
|
+
assert.equal(validateFilePath("SKILL.md"), null);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("accepts a script under scripts/", () => {
|
|
94
|
+
assert.equal(validateFilePath("scripts/extract.py"), null);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("accepts a non-spec top-level dir (Option B path-preserving)", () => {
|
|
98
|
+
// This is the central proof of Defect A's fix — the CLI does NOT
|
|
99
|
+
// enforce the spec layout. lib/helper.py is path-preserving and OK.
|
|
100
|
+
assert.equal(validateFilePath("lib/helper.py"), null);
|
|
101
|
+
assert.equal(validateFilePath("utils/format.js"), null);
|
|
102
|
+
assert.equal(validateFilePath("data/lookup.json"), null);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("rejects path traversal", () => {
|
|
106
|
+
assert.match(validateFilePath("../etc/passwd"), /traversal/);
|
|
107
|
+
assert.match(validateFilePath("scripts/../../../etc/passwd"), /traversal/);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("rejects URL-encoded path traversal", () => {
|
|
111
|
+
assert.match(validateFilePath("..%2Fetc%2Fpasswd"), /traversal/);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("rejects absolute paths (POSIX)", () => {
|
|
115
|
+
assert.match(validateFilePath("/etc/passwd"), /absolute/);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it("rejects absolute paths (Windows drive letter)", () => {
|
|
119
|
+
assert.match(validateFilePath("C:/Windows/System32/cmd.exe"), /absolute/);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("rejects paths exceeding MAX_PATH_DEPTH", () => {
|
|
123
|
+
const tooDeep = Array(MAX_PATH_DEPTH + 1).fill("a").join("/") + ".py";
|
|
124
|
+
const err = validateFilePath(tooDeep);
|
|
125
|
+
assert.match(err, /nesting depth/);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it("accepts paths exactly at MAX_PATH_DEPTH", () => {
|
|
129
|
+
const atLimit = Array(MAX_PATH_DEPTH).fill("a").join("/") + ".py";
|
|
130
|
+
// depth = 5 segments separated by / — exactly the limit
|
|
131
|
+
assert.equal(validateFilePath(atLimit), null);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it("rejects blocked extensions", () => {
|
|
135
|
+
for (const ext of BLOCKED_EXTENSIONS) {
|
|
136
|
+
const err = validateFilePath(`scripts/payload${ext}`);
|
|
137
|
+
assert.match(err, /Blocked file type/, `Expected ${ext} to be blocked`);
|
|
138
|
+
}
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it("rejects malformed URL encoding", () => {
|
|
142
|
+
const err = validateFilePath("%E0%A4%A");
|
|
143
|
+
assert.match(err, /URL encoding/);
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
// ── isValidSkillName ────────────────────────────────────────────────────
|
|
148
|
+
|
|
149
|
+
describe("isValidSkillName", () => {
|
|
150
|
+
it("accepts canonical names", () => {
|
|
151
|
+
assert.equal(isValidSkillName("pdf-helper"), true);
|
|
152
|
+
assert.equal(isValidSkillName("a"), true);
|
|
153
|
+
assert.equal(isValidSkillName("skill123"), true);
|
|
154
|
+
assert.equal(isValidSkillName("a-very-long-but-valid-name"), true);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it("rejects empty and oversize names", () => {
|
|
158
|
+
assert.equal(isValidSkillName(""), false);
|
|
159
|
+
assert.equal(isValidSkillName("x".repeat(65)), false);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it("rejects uppercase letters", () => {
|
|
163
|
+
assert.equal(isValidSkillName("PDF-Helper"), false);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it("rejects leading or trailing hyphens", () => {
|
|
167
|
+
assert.equal(isValidSkillName("-pdf"), false);
|
|
168
|
+
assert.equal(isValidSkillName("pdf-"), false);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it("rejects consecutive hyphens", () => {
|
|
172
|
+
assert.equal(isValidSkillName("pdf--helper"), false);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it("rejects non-string input", () => {
|
|
176
|
+
assert.equal(isValidSkillName(undefined), false);
|
|
177
|
+
assert.equal(isValidSkillName(null), false);
|
|
178
|
+
assert.equal(isValidSkillName(42), false);
|
|
179
|
+
});
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
// ── readFrontmatterName ─────────────────────────────────────────────────
|
|
183
|
+
|
|
184
|
+
describe("readFrontmatterName", () => {
|
|
185
|
+
it("extracts the name field from a SKILL.md", () => {
|
|
186
|
+
const files = [{ path: "SKILL.md", content: "---\nname: pdf-helper\n---\nBody" }];
|
|
187
|
+
assert.equal(readFrontmatterName(files), "pdf-helper");
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it("strips quotes around the name value", () => {
|
|
191
|
+
const files = [{ path: "SKILL.md", content: '---\nname: "pdf-helper"\n---\nBody' }];
|
|
192
|
+
assert.equal(readFrontmatterName(files), "pdf-helper");
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it("returns null when frontmatter is missing", () => {
|
|
196
|
+
const files = [{ path: "SKILL.md", content: "Plain markdown" }];
|
|
197
|
+
assert.equal(readFrontmatterName(files), null);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it("returns null when SKILL.md is missing", () => {
|
|
201
|
+
const files = [{ path: "scripts/extract.py", content: "print()" }];
|
|
202
|
+
assert.equal(readFrontmatterName(files), null);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it("returns null when name field is missing inside frontmatter", () => {
|
|
206
|
+
const files = [{ path: "SKILL.md", content: "---\ndescription: foo\n---\nBody" }];
|
|
207
|
+
assert.equal(readFrontmatterName(files), null);
|
|
208
|
+
});
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
// ── resolvePlacementDir + placementTargetsFor ───────────────────────────
|
|
212
|
+
|
|
213
|
+
describe("placementTargetsFor", () => {
|
|
214
|
+
beforeEach(setupSandbox);
|
|
215
|
+
afterEach(teardownSandbox);
|
|
216
|
+
|
|
217
|
+
it("returns [claudeGlobal] for --global", () => {
|
|
218
|
+
assert.deepEqual(placementTargetsFor({ global: true }), ["claudeGlobal"]);
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
it("returns [claudeProject] for vendor=claudeCode only", () => {
|
|
222
|
+
assert.deepEqual(placementTargetsFor({ vendors: ["claudeCode"] }), ["claudeProject"]);
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it("returns [projectFallback] for vendor=cursor only", () => {
|
|
226
|
+
assert.deepEqual(placementTargetsFor({ vendors: ["cursor"] }), ["projectFallback"]);
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it("returns [claudeProject, projectFallback] for both", () => {
|
|
230
|
+
assert.deepEqual(
|
|
231
|
+
placementTargetsFor({ vendors: ["claudeCode", "cursor"] }),
|
|
232
|
+
["claudeProject", "projectFallback"],
|
|
233
|
+
);
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
it("dedupes the fallback when multiple non-claude vendors are present", () => {
|
|
237
|
+
const targets = placementTargetsFor({
|
|
238
|
+
vendors: ["cursor", "windsurf", "vscode"],
|
|
239
|
+
});
|
|
240
|
+
// Only one projectFallback — not three
|
|
241
|
+
assert.equal(targets.filter((t) => t === "projectFallback").length, 1);
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
it("throws on empty vendors without --global", () => {
|
|
245
|
+
assert.throws(
|
|
246
|
+
() => placementTargetsFor({ vendors: [] }),
|
|
247
|
+
(err) => err instanceof CliError && err.exitCode === EXIT_VALIDATION,
|
|
248
|
+
);
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
it("throws on unknown vendor", () => {
|
|
252
|
+
assert.throws(
|
|
253
|
+
() => placementTargetsFor({ vendors: ["jetbrains"] }),
|
|
254
|
+
(err) => err instanceof CliError && err.exitCode === EXIT_VALIDATION,
|
|
255
|
+
);
|
|
256
|
+
});
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
describe("resolvePlacementDir", () => {
|
|
260
|
+
beforeEach(setupSandbox);
|
|
261
|
+
afterEach(teardownSandbox);
|
|
262
|
+
|
|
263
|
+
it("resolves claudeProject under cwd", () => {
|
|
264
|
+
const dir = resolvePlacementDir("claudeProject", "pdf-helper");
|
|
265
|
+
assert.ok(dir.endsWith("/.claude/skills/pdf-helper"));
|
|
266
|
+
assert.ok(dir.startsWith(process.cwd()));
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
it("resolves claudeGlobal under HOME", () => {
|
|
270
|
+
const dir = resolvePlacementDir("claudeGlobal", "pdf-helper");
|
|
271
|
+
assert.ok(dir.endsWith("/.claude/skills/pdf-helper"));
|
|
272
|
+
assert.ok(dir.startsWith(process.env.HOME));
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
it("resolves projectFallback under cwd /skills/", () => {
|
|
276
|
+
const dir = resolvePlacementDir("projectFallback", "pdf-helper");
|
|
277
|
+
assert.equal(dir, join(process.cwd(), "skills", "pdf-helper"));
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
it("throws on unknown target", () => {
|
|
281
|
+
assert.throws(
|
|
282
|
+
() => resolvePlacementDir("invalid", "pdf-helper"),
|
|
283
|
+
(err) => err instanceof CliError && err.exitCode === EXIT_VALIDATION,
|
|
284
|
+
);
|
|
285
|
+
});
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
// ── writeSkillDir validation rejections ─────────────────────────────────
|
|
289
|
+
|
|
290
|
+
describe("writeSkillDir — validation", () => {
|
|
291
|
+
beforeEach(setupSandbox);
|
|
292
|
+
afterEach(teardownSandbox);
|
|
293
|
+
|
|
294
|
+
it("rejects skills with no SKILL.md", () => {
|
|
295
|
+
const skill = minimalSkill({ files: [{ path: "scripts/x.py", content: "print()" }] });
|
|
296
|
+
assert.throws(
|
|
297
|
+
() => writeSkillDir(skill, { vendors: ["claudeCode"] }),
|
|
298
|
+
(err) => err instanceof CliError && err.exitCode === EXIT_VALIDATION && /SKILL\.md/.test(err.message),
|
|
299
|
+
);
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
it("rejects skills with name/parent mismatch in frontmatter", () => {
|
|
303
|
+
const skill = minimalSkill({
|
|
304
|
+
name: "pdf-helper",
|
|
305
|
+
files: [
|
|
306
|
+
{ path: "SKILL.md", content: "---\nname: not-pdf-helper\n---\nBody" },
|
|
307
|
+
],
|
|
308
|
+
});
|
|
309
|
+
assert.throws(
|
|
310
|
+
() => writeSkillDir(skill, { vendors: ["claudeCode"] }),
|
|
311
|
+
(err) =>
|
|
312
|
+
err instanceof CliError &&
|
|
313
|
+
err.exitCode === EXIT_VALIDATION &&
|
|
314
|
+
/does not match/.test(err.message),
|
|
315
|
+
);
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
it("rejects skills with invalid skill name", () => {
|
|
319
|
+
const skill = minimalSkill({ name: "PDF-Helper" });
|
|
320
|
+
assert.throws(
|
|
321
|
+
() => writeSkillDir(skill, { vendors: ["claudeCode"] }),
|
|
322
|
+
(err) => err instanceof CliError && err.exitCode === EXIT_VALIDATION,
|
|
323
|
+
);
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
it("rejects skills with no owner", () => {
|
|
327
|
+
const skill = minimalSkill();
|
|
328
|
+
delete skill.owner;
|
|
329
|
+
assert.throws(
|
|
330
|
+
() => writeSkillDir(skill, { vendors: ["claudeCode"] }),
|
|
331
|
+
(err) => err instanceof CliError && err.exitCode === EXIT_VALIDATION,
|
|
332
|
+
);
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
it("rejects skills with empty files array", () => {
|
|
336
|
+
const skill = minimalSkill({ files: [] });
|
|
337
|
+
assert.throws(
|
|
338
|
+
() => writeSkillDir(skill, { vendors: ["claudeCode"] }),
|
|
339
|
+
(err) => err instanceof CliError && err.exitCode === EXIT_VALIDATION,
|
|
340
|
+
);
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
it("rejects skills with file containing path traversal", () => {
|
|
344
|
+
const skill = minimalSkill({
|
|
345
|
+
files: [
|
|
346
|
+
...minimalSkill().files,
|
|
347
|
+
{ path: "../escape.py", content: "x" },
|
|
348
|
+
],
|
|
349
|
+
});
|
|
350
|
+
assert.throws(
|
|
351
|
+
() => writeSkillDir(skill, { vendors: ["claudeCode"] }),
|
|
352
|
+
(err) => err instanceof CliError && err.exitCode === EXIT_VALIDATION,
|
|
353
|
+
);
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
it("rejects skills with duplicate file paths", () => {
|
|
357
|
+
const skill = minimalSkill({
|
|
358
|
+
files: [
|
|
359
|
+
{ path: "SKILL.md", content: "---\nname: pdf-helper\n---\n" },
|
|
360
|
+
{ path: "SKILL.md", content: "---\nname: pdf-helper\n---\n duplicate" },
|
|
361
|
+
],
|
|
362
|
+
});
|
|
363
|
+
assert.throws(
|
|
364
|
+
() => writeSkillDir(skill, { vendors: ["claudeCode"] }),
|
|
365
|
+
(err) => err instanceof CliError && err.exitCode === EXIT_VALIDATION,
|
|
366
|
+
);
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
it("rejects skills with non-string file content", () => {
|
|
370
|
+
const skill = minimalSkill({
|
|
371
|
+
files: [
|
|
372
|
+
...minimalSkill().files,
|
|
373
|
+
{ path: "scripts/binary.dat", content: Buffer.from([0, 1, 2]) },
|
|
374
|
+
],
|
|
375
|
+
});
|
|
376
|
+
assert.throws(
|
|
377
|
+
() => writeSkillDir(skill, { vendors: ["claudeCode"] }),
|
|
378
|
+
(err) => err instanceof CliError && err.exitCode === EXIT_VALIDATION,
|
|
379
|
+
);
|
|
380
|
+
});
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
// ── writeSkillDir happy path + path preservation (Defect A fix) ─────────
|
|
384
|
+
|
|
385
|
+
describe("writeSkillDir — happy path", () => {
|
|
386
|
+
beforeEach(setupSandbox);
|
|
387
|
+
afterEach(teardownSandbox);
|
|
388
|
+
|
|
389
|
+
it("writes a skill with SKILL.md only to claudeCode project dir", () => {
|
|
390
|
+
const skill = minimalSkill();
|
|
391
|
+
const result = writeSkillDir(skill, { vendors: ["claudeCode"] });
|
|
392
|
+
|
|
393
|
+
assert.equal(result.written.length, 1);
|
|
394
|
+
const dir = result.written[0];
|
|
395
|
+
const skillMd = readFileSync(join(dir, "SKILL.md"), "utf-8");
|
|
396
|
+
assert.match(skillMd, /name: pdf-helper/);
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
it("preserves non-spec top-level directories (Defect A fix)", () => {
|
|
400
|
+
// This is the central proof: the CLI is path-preserving. A file at
|
|
401
|
+
// lib/helper.py must end up at <skill>/lib/helper.py on disk.
|
|
402
|
+
const skill = minimalSkill({
|
|
403
|
+
files: [
|
|
404
|
+
...minimalSkill().files,
|
|
405
|
+
{ path: "lib/helper.py", content: "def help(): pass\n" },
|
|
406
|
+
{ path: "utils/format.js", content: "export const fmt = x => x;\n" },
|
|
407
|
+
],
|
|
408
|
+
});
|
|
409
|
+
const result = writeSkillDir(skill, { vendors: ["claudeCode"] });
|
|
410
|
+
const dir = result.written[0];
|
|
411
|
+
|
|
412
|
+
assert.equal(
|
|
413
|
+
readFileSync(join(dir, "lib/helper.py"), "utf-8"),
|
|
414
|
+
"def help(): pass\n",
|
|
415
|
+
);
|
|
416
|
+
assert.equal(
|
|
417
|
+
readFileSync(join(dir, "utils/format.js"), "utf-8"),
|
|
418
|
+
"export const fmt = x => x;\n",
|
|
419
|
+
);
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
it("writes spec-compliant subdirs (scripts/, references/, assets/)", () => {
|
|
423
|
+
const skill = minimalSkill({
|
|
424
|
+
files: [
|
|
425
|
+
...minimalSkill().files,
|
|
426
|
+
{ path: "scripts/extract.py", content: "print('hi')\n" },
|
|
427
|
+
{ path: "references/REFERENCE.md", content: "# Reference\n" },
|
|
428
|
+
{ path: "assets/template.json", content: "{}\n" },
|
|
429
|
+
],
|
|
430
|
+
});
|
|
431
|
+
const result = writeSkillDir(skill, { vendors: ["claudeCode"] });
|
|
432
|
+
const dir = result.written[0];
|
|
433
|
+
|
|
434
|
+
assert.equal(readFileSync(join(dir, "scripts/extract.py"), "utf-8"), "print('hi')\n");
|
|
435
|
+
assert.equal(readFileSync(join(dir, "references/REFERENCE.md"), "utf-8"), "# Reference\n");
|
|
436
|
+
assert.equal(readFileSync(join(dir, "assets/template.json"), "utf-8"), "{}\n");
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
it("writes to multiple targets when claudeCode + cursor detected", () => {
|
|
440
|
+
const skill = minimalSkill();
|
|
441
|
+
const result = writeSkillDir(skill, { vendors: ["claudeCode", "cursor"] });
|
|
442
|
+
assert.equal(result.written.length, 2);
|
|
443
|
+
for (const dir of result.written) {
|
|
444
|
+
assert.ok(existsSync(join(dir, "SKILL.md")));
|
|
445
|
+
}
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
it("writes to global home dir with --global", () => {
|
|
449
|
+
const skill = minimalSkill();
|
|
450
|
+
const result = writeSkillDir(skill, { global: true });
|
|
451
|
+
assert.equal(result.written.length, 1);
|
|
452
|
+
assert.ok(result.written[0].startsWith(process.env.HOME));
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
it("creates /skills/ fallback when only non-claude vendor is detected", () => {
|
|
456
|
+
const skill = minimalSkill();
|
|
457
|
+
const result = writeSkillDir(skill, { vendors: ["cursor"] });
|
|
458
|
+
assert.equal(result.written.length, 1);
|
|
459
|
+
assert.ok(result.written[0].includes("/skills/pdf-helper"));
|
|
460
|
+
// .gitignore should now contain /skills/
|
|
461
|
+
const gi = readFileSync(join(process.cwd(), ".gitignore"), "utf-8");
|
|
462
|
+
assert.match(gi, /\/skills\//);
|
|
463
|
+
assert.match(gi, /SkillRepo/);
|
|
464
|
+
});
|
|
465
|
+
|
|
466
|
+
it("idempotent .gitignore management — does not double-add /skills/", () => {
|
|
467
|
+
const skill = minimalSkill();
|
|
468
|
+
writeSkillDir(skill, { vendors: ["cursor"] });
|
|
469
|
+
writeSkillDir(skill, { vendors: ["cursor"] });
|
|
470
|
+
const gi = readFileSync(join(process.cwd(), ".gitignore"), "utf-8");
|
|
471
|
+
const matches = gi.match(/\/skills\//g) || [];
|
|
472
|
+
assert.equal(matches.length, 1, ".gitignore should have exactly one /skills/ entry");
|
|
473
|
+
});
|
|
474
|
+
|
|
475
|
+
it("appends to an existing .gitignore without clobbering", () => {
|
|
476
|
+
writeFileSync(join(process.cwd(), ".gitignore"), "node_modules\n.env\n");
|
|
477
|
+
writeSkillDir(minimalSkill(), { vendors: ["cursor"] });
|
|
478
|
+
const gi = readFileSync(join(process.cwd(), ".gitignore"), "utf-8");
|
|
479
|
+
assert.match(gi, /node_modules/);
|
|
480
|
+
assert.match(gi, /\.env/);
|
|
481
|
+
assert.match(gi, /\/skills\//);
|
|
482
|
+
});
|
|
483
|
+
});
|
|
484
|
+
|
|
485
|
+
// ── writeSkillDir update path (atomic) ──────────────────────────────────
|
|
486
|
+
|
|
487
|
+
describe("writeSkillDir — update path", () => {
|
|
488
|
+
beforeEach(setupSandbox);
|
|
489
|
+
afterEach(teardownSandbox);
|
|
490
|
+
|
|
491
|
+
it("overwrites an existing skill atomically", () => {
|
|
492
|
+
// First write
|
|
493
|
+
writeSkillDir(minimalSkill(), { vendors: ["claudeCode"] });
|
|
494
|
+
|
|
495
|
+
// Second write with changed content
|
|
496
|
+
const updated = minimalSkill({
|
|
497
|
+
files: [
|
|
498
|
+
{
|
|
499
|
+
path: "SKILL.md",
|
|
500
|
+
content: "---\nname: pdf-helper\ndescription: Updated.\n---\n\nUpdated body.\n",
|
|
501
|
+
},
|
|
502
|
+
{ path: "scripts/new.py", content: "print('new')\n" },
|
|
503
|
+
],
|
|
504
|
+
});
|
|
505
|
+
const result = writeSkillDir(updated, { vendors: ["claudeCode"] });
|
|
506
|
+
|
|
507
|
+
const dir = result.written[0];
|
|
508
|
+
const skillMd = readFileSync(join(dir, "SKILL.md"), "utf-8");
|
|
509
|
+
assert.match(skillMd, /Updated/);
|
|
510
|
+
assert.equal(readFileSync(join(dir, "scripts/new.py"), "utf-8"), "print('new')\n");
|
|
511
|
+
});
|
|
512
|
+
|
|
513
|
+
it("removes files no longer in the skill (full overwrite semantics)", () => {
|
|
514
|
+
// First write with two files
|
|
515
|
+
writeSkillDir(minimalSkill({
|
|
516
|
+
files: [
|
|
517
|
+
{ path: "SKILL.md", content: "---\nname: pdf-helper\n---\n" },
|
|
518
|
+
{ path: "scripts/old.py", content: "print('old')\n" },
|
|
519
|
+
],
|
|
520
|
+
}), { vendors: ["claudeCode"] });
|
|
521
|
+
|
|
522
|
+
// Second write without the old file
|
|
523
|
+
const result = writeSkillDir(minimalSkill(), { vendors: ["claudeCode"] });
|
|
524
|
+
const dir = result.written[0];
|
|
525
|
+
assert.ok(!existsSync(join(dir, "scripts/old.py")), "old file should be gone");
|
|
526
|
+
});
|
|
527
|
+
|
|
528
|
+
it("cleans up stale .tmp/ from a previous crash before populating a new one", () => {
|
|
529
|
+
const skill = minimalSkill();
|
|
530
|
+
// Pre-create a stale .tmp/ to simulate a crashed run
|
|
531
|
+
const targetDir = resolvePlacementDir("claudeProject", "pdf-helper");
|
|
532
|
+
mkdirSync(`${targetDir}.tmp`, { recursive: true });
|
|
533
|
+
writeFileSync(`${targetDir}.tmp/leftover.txt`, "garbage");
|
|
534
|
+
|
|
535
|
+
// Now run the write — should clean and succeed
|
|
536
|
+
const result = writeSkillDir(skill, { vendors: ["claudeCode"] });
|
|
537
|
+
assert.ok(existsSync(join(result.written[0], "SKILL.md")));
|
|
538
|
+
assert.ok(!existsSync(join(result.written[0], "leftover.txt")));
|
|
539
|
+
});
|
|
540
|
+
|
|
541
|
+
it("successful update overwrites content and leaves no .tmp/.old residue", { skip: process.platform === "win32" }, () => {
|
|
542
|
+
// KNOWN COVERAGE GAP: this test exercises the happy path of the
|
|
543
|
+
// atomic .tmp/.old rename dance — write twice, confirm the second
|
|
544
|
+
// write replaces the first and leaves no orphan state. It does NOT
|
|
545
|
+
// exercise the rollback branch in writeSkillToDir at the
|
|
546
|
+
// `renameSync(tmpDir, targetDir)` failure path. Triggering that
|
|
547
|
+
// branch deterministically requires either root permissions to
|
|
548
|
+
// chmod a parent directory, a symlink trick, or an injected fault
|
|
549
|
+
// — none of which are stable across CI runners.
|
|
550
|
+
//
|
|
551
|
+
// The rollback branch IS reachable in production (file system
|
|
552
|
+
// races, EINVAL on unusual filesystems, etc.) and IS implemented
|
|
553
|
+
// correctly per code review, but it is not covered by an automated
|
|
554
|
+
// test. The next time someone touches that branch, manual
|
|
555
|
+
// verification is required — see the comment at the rollback site
|
|
556
|
+
// in file-write.mjs for the exact branch.
|
|
557
|
+
writeSkillDir(minimalSkill(), { vendors: ["claudeCode"] });
|
|
558
|
+
|
|
559
|
+
const targetDir = resolvePlacementDir("claudeProject", "pdf-helper");
|
|
560
|
+
const oldDir = `${targetDir}.old`;
|
|
561
|
+
|
|
562
|
+
const updated = {
|
|
563
|
+
owner: "alice",
|
|
564
|
+
name: "pdf-helper",
|
|
565
|
+
files: [
|
|
566
|
+
{ path: "SKILL.md", content: "---\nname: pdf-helper\n---\nNew content.\n" },
|
|
567
|
+
],
|
|
568
|
+
};
|
|
569
|
+
writeSkillDir(updated, { vendors: ["claudeCode"] });
|
|
570
|
+
const skillMd = readFileSync(join(targetDir, "SKILL.md"), "utf-8");
|
|
571
|
+
assert.match(skillMd, /New content/);
|
|
572
|
+
assert.ok(!existsSync(`${targetDir}.tmp`));
|
|
573
|
+
assert.ok(!existsSync(oldDir));
|
|
574
|
+
});
|
|
575
|
+
|
|
576
|
+
it("throws diskError when the .tmp path is occupied by a regular file", { skip: process.platform === "win32" }, () => {
|
|
577
|
+
const skill = minimalSkill();
|
|
578
|
+
const targetDir = resolvePlacementDir("claudeProject", "pdf-helper");
|
|
579
|
+
// Pre-create the .tmp path as a FILE (not a directory) — the
|
|
580
|
+
// pre-flight cleanup will rmSync it and re-create as a dir, so
|
|
581
|
+
// this test verifies the rmSync path works on a file.
|
|
582
|
+
mkdirSync(join(process.cwd(), ".claude", "skills"), { recursive: true });
|
|
583
|
+
writeFileSync(`${targetDir}.tmp`, "I am a file, not a dir");
|
|
584
|
+
|
|
585
|
+
// Should successfully clean the file and proceed
|
|
586
|
+
const result = writeSkillDir(skill, { vendors: ["claudeCode"] });
|
|
587
|
+
assert.ok(existsSync(join(result.written[0], "SKILL.md")));
|
|
588
|
+
assert.ok(!existsSync(`${targetDir}.tmp`));
|
|
589
|
+
});
|
|
590
|
+
});
|
|
591
|
+
|
|
592
|
+
describe("writeSkillDir — return shape", () => {
|
|
593
|
+
beforeEach(setupSandbox);
|
|
594
|
+
afterEach(teardownSandbox);
|
|
595
|
+
|
|
596
|
+
it("returns only `written`, never `skipped` (architect B1 fix)", () => {
|
|
597
|
+
const skill = minimalSkill();
|
|
598
|
+
const result = writeSkillDir(skill, { vendors: ["claudeCode"] });
|
|
599
|
+
assert.deepEqual(Object.keys(result).sort(), ["written"]);
|
|
600
|
+
});
|
|
601
|
+
});
|
|
602
|
+
|
|
603
|
+
describe("ensureFallbackGitignore — error surfacing", () => {
|
|
604
|
+
beforeEach(setupSandbox);
|
|
605
|
+
afterEach(teardownSandbox);
|
|
606
|
+
|
|
607
|
+
it("throws diskError when .gitignore is read-only", { skip: process.platform === "win32" || process.getuid?.() === 0 }, () => {
|
|
608
|
+
// Pre-create a read-only .gitignore
|
|
609
|
+
const gi = join(process.cwd(), ".gitignore");
|
|
610
|
+
writeFileSync(gi, "node_modules\n");
|
|
611
|
+
chmodSync(gi, 0o444);
|
|
612
|
+
|
|
613
|
+
try {
|
|
614
|
+
assert.throws(
|
|
615
|
+
() => writeSkillDir(minimalSkill(), { vendors: ["cursor"] }),
|
|
616
|
+
(err) => err instanceof CliError && err.exitCode === 3, // EXIT_DISK
|
|
617
|
+
);
|
|
618
|
+
} finally {
|
|
619
|
+
// Restore writable so afterEach can clean up
|
|
620
|
+
chmodSync(gi, 0o644);
|
|
621
|
+
}
|
|
622
|
+
});
|
|
623
|
+
|
|
624
|
+
it("read-only .gitignore aborts ALL writes (no partial state) when both claudeCode + cursor requested", { skip: process.platform === "win32" || process.getuid?.() === 0 }, () => {
|
|
625
|
+
// Architect re-review found that ensureFallbackGitignore() throwing
|
|
626
|
+
// mid-loop would leave a successful claudeCode write + a thrown
|
|
627
|
+
// cursor failure = partial state. The fix moves the .gitignore
|
|
628
|
+
// pre-flight before the loop. This test locks that fix in.
|
|
629
|
+
const gi = join(process.cwd(), ".gitignore");
|
|
630
|
+
writeFileSync(gi, "node_modules\n");
|
|
631
|
+
chmodSync(gi, 0o444);
|
|
632
|
+
|
|
633
|
+
try {
|
|
634
|
+
assert.throws(
|
|
635
|
+
() => writeSkillDir(minimalSkill(), { vendors: ["claudeCode", "cursor"] }),
|
|
636
|
+
(err) => err instanceof CliError && err.exitCode === 3,
|
|
637
|
+
);
|
|
638
|
+
// Critical: the claudeCode write must NOT have executed because
|
|
639
|
+
// the pre-flight failed before any disk work began.
|
|
640
|
+
const claudeDir = resolvePlacementDir("claudeProject", "pdf-helper");
|
|
641
|
+
assert.ok(!existsSync(claudeDir), "claudeCode skill must not be partially written");
|
|
642
|
+
} finally {
|
|
643
|
+
chmodSync(gi, 0o644);
|
|
644
|
+
}
|
|
645
|
+
});
|
|
646
|
+
});
|
|
647
|
+
|
|
648
|
+
describe("cleanupOrphans — Windows recovery invariant", () => {
|
|
649
|
+
beforeEach(setupSandbox);
|
|
650
|
+
afterEach(teardownSandbox);
|
|
651
|
+
|
|
652
|
+
it("preserves .tmp/ whose live target is missing (recovery from crashed Windows rename)", () => {
|
|
653
|
+
// Inject a .tmp/ with NO sibling live target — the CLI's only copy
|
|
654
|
+
// of a recoverable skill from a crashed Windows update.
|
|
655
|
+
const root = join(process.cwd(), ".claude", "skills");
|
|
656
|
+
mkdirSync(join(root, "pdf-helper.tmp"), { recursive: true });
|
|
657
|
+
writeFileSync(join(root, "pdf-helper.tmp", "SKILL.md"), "---\nname: pdf-helper\n---\nRecoverable.\n");
|
|
658
|
+
|
|
659
|
+
const result = cleanupOrphans({ vendors: ["claudeCode"] });
|
|
660
|
+
assert.equal(result.cleaned.length, 0, ".tmp with no live sibling should be preserved");
|
|
661
|
+
assert.ok(existsSync(join(root, "pdf-helper.tmp", "SKILL.md")), "user's only copy must survive");
|
|
662
|
+
});
|
|
663
|
+
|
|
664
|
+
it("removes .tmp/ when a live sibling exists (post-successful-write cleanup)", () => {
|
|
665
|
+
// Both live target and .tmp present — .tmp is stale state from a
|
|
666
|
+
// future-crashed write that the user has since recovered from.
|
|
667
|
+
const root = join(process.cwd(), ".claude", "skills");
|
|
668
|
+
writeSkillDir(minimalSkill(), { vendors: ["claudeCode"] });
|
|
669
|
+
mkdirSync(join(root, "pdf-helper.tmp"));
|
|
670
|
+
writeFileSync(join(root, "pdf-helper.tmp", "stale.txt"), "x");
|
|
671
|
+
|
|
672
|
+
const result = cleanupOrphans({ vendors: ["claudeCode"] });
|
|
673
|
+
assert.equal(result.cleaned.length, 1, "stale .tmp with live sibling should be cleaned");
|
|
674
|
+
assert.ok(!existsSync(join(root, "pdf-helper.tmp")));
|
|
675
|
+
// Live skill untouched
|
|
676
|
+
assert.ok(existsSync(join(root, "pdf-helper", "SKILL.md")));
|
|
677
|
+
});
|
|
678
|
+
});
|
|
679
|
+
|
|
680
|
+
// ── removeSkillDir ──────────────────────────────────────────────────────
|
|
681
|
+
|
|
682
|
+
describe("removeSkillDir", () => {
|
|
683
|
+
beforeEach(setupSandbox);
|
|
684
|
+
afterEach(teardownSandbox);
|
|
685
|
+
|
|
686
|
+
it("removes an existing skill from claudeCode project dir", () => {
|
|
687
|
+
writeSkillDir(minimalSkill(), { vendors: ["claudeCode"] });
|
|
688
|
+
const dir = resolvePlacementDir("claudeProject", "pdf-helper");
|
|
689
|
+
assert.ok(existsSync(dir));
|
|
690
|
+
|
|
691
|
+
const result = removeSkillDir("pdf-helper", { vendors: ["claudeCode"] });
|
|
692
|
+
assert.equal(result.removed.length, 1);
|
|
693
|
+
assert.equal(result.notFound.length, 0);
|
|
694
|
+
assert.ok(!existsSync(dir));
|
|
695
|
+
});
|
|
696
|
+
|
|
697
|
+
it("returns notFound when the skill directory doesn't exist", () => {
|
|
698
|
+
const result = removeSkillDir("ghost", { vendors: ["claudeCode"] });
|
|
699
|
+
assert.equal(result.removed.length, 0);
|
|
700
|
+
assert.equal(result.notFound.length, 1);
|
|
701
|
+
});
|
|
702
|
+
|
|
703
|
+
it("removes from multiple targets", () => {
|
|
704
|
+
writeSkillDir(minimalSkill(), { vendors: ["claudeCode", "cursor"] });
|
|
705
|
+
const result = removeSkillDir("pdf-helper", { vendors: ["claudeCode", "cursor"] });
|
|
706
|
+
assert.equal(result.removed.length, 2);
|
|
707
|
+
});
|
|
708
|
+
|
|
709
|
+
it("rejects invalid skill names", () => {
|
|
710
|
+
assert.throws(
|
|
711
|
+
() => removeSkillDir("BAD", { vendors: ["claudeCode"] }),
|
|
712
|
+
(err) => err instanceof CliError && err.exitCode === EXIT_VALIDATION,
|
|
713
|
+
);
|
|
714
|
+
});
|
|
715
|
+
});
|
|
716
|
+
|
|
717
|
+
// ── cleanupOrphans ──────────────────────────────────────────────────────
|
|
718
|
+
|
|
719
|
+
describe("cleanupOrphans", () => {
|
|
720
|
+
beforeEach(setupSandbox);
|
|
721
|
+
afterEach(teardownSandbox);
|
|
722
|
+
|
|
723
|
+
it("removes .tmp/ orphans whose live sibling exists (post-successful-write residue)", () => {
|
|
724
|
+
// The cleanupOrphans safety invariant preserves a `.tmp/` whose live
|
|
725
|
+
// target is missing (Windows recovery path). To test that orphan
|
|
726
|
+
// cleanup actually fires, we need a live sibling so the .tmp is
|
|
727
|
+
// confirmed safe to delete.
|
|
728
|
+
const root = join(process.cwd(), ".claude", "skills");
|
|
729
|
+
mkdirSync(join(root, "ghost"), { recursive: true }); // live sibling
|
|
730
|
+
mkdirSync(join(root, "ghost.tmp"));
|
|
731
|
+
writeFileSync(join(root, "ghost.tmp", "x.txt"), "garbage");
|
|
732
|
+
|
|
733
|
+
const result = cleanupOrphans({ vendors: ["claudeCode"] });
|
|
734
|
+
assert.equal(result.cleaned.length, 1);
|
|
735
|
+
assert.ok(!existsSync(join(root, "ghost.tmp")));
|
|
736
|
+
});
|
|
737
|
+
|
|
738
|
+
it("removes .old/ orphans under claudeProject root", () => {
|
|
739
|
+
const root = join(process.cwd(), ".claude", "skills");
|
|
740
|
+
mkdirSync(root, { recursive: true });
|
|
741
|
+
mkdirSync(join(root, "ghost.old"));
|
|
742
|
+
|
|
743
|
+
const result = cleanupOrphans({ vendors: ["claudeCode"] });
|
|
744
|
+
assert.equal(result.cleaned.length, 1);
|
|
745
|
+
});
|
|
746
|
+
|
|
747
|
+
it("leaves regular skill directories alone", () => {
|
|
748
|
+
writeSkillDir(minimalSkill(), { vendors: ["claudeCode"] });
|
|
749
|
+
const result = cleanupOrphans({ vendors: ["claudeCode"] });
|
|
750
|
+
assert.equal(result.cleaned.length, 0);
|
|
751
|
+
const dir = resolvePlacementDir("claudeProject", "pdf-helper");
|
|
752
|
+
assert.ok(existsSync(dir));
|
|
753
|
+
});
|
|
754
|
+
|
|
755
|
+
it("scans all known roots when no vendors specified", () => {
|
|
756
|
+
// Drop one orphan in each root we know about. .tmp/ entries need
|
|
757
|
+
// a live sibling so the safety invariant doesn't preserve them.
|
|
758
|
+
const claudeRoot = join(process.cwd(), ".claude", "skills");
|
|
759
|
+
const fallbackRoot = join(process.cwd(), "skills");
|
|
760
|
+
const globalRoot = join(process.env.HOME, ".claude", "skills");
|
|
761
|
+
mkdirSync(claudeRoot, { recursive: true });
|
|
762
|
+
mkdirSync(fallbackRoot, { recursive: true });
|
|
763
|
+
mkdirSync(globalRoot, { recursive: true });
|
|
764
|
+
mkdirSync(join(claudeRoot, "ghost")); // live sibling for the .tmp below
|
|
765
|
+
mkdirSync(join(claudeRoot, "ghost.tmp"));
|
|
766
|
+
mkdirSync(join(fallbackRoot, "ghost.old")); // .old has no invariant — always cleaned
|
|
767
|
+
mkdirSync(join(globalRoot, "ghost")); // live sibling for the .tmp below
|
|
768
|
+
mkdirSync(join(globalRoot, "ghost.tmp"));
|
|
769
|
+
|
|
770
|
+
const result = cleanupOrphans({});
|
|
771
|
+
assert.equal(result.cleaned.length, 3);
|
|
772
|
+
});
|
|
773
|
+
|
|
774
|
+
it("is idempotent when there are no orphans", () => {
|
|
775
|
+
const result = cleanupOrphans({ vendors: ["claudeCode"] });
|
|
776
|
+
assert.deepEqual(result.cleaned, []);
|
|
777
|
+
});
|
|
778
|
+
|
|
779
|
+
it("handles missing roots gracefully", () => {
|
|
780
|
+
// No roots exist at all
|
|
781
|
+
const result = cleanupOrphans({ vendors: ["claudeCode"] });
|
|
782
|
+
assert.deepEqual(result.cleaned, []);
|
|
783
|
+
});
|
|
784
|
+
});
|