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,514 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for src/lib/sync.mjs (PR2 of #646).
|
|
3
|
+
*
|
|
4
|
+
* Strategy: spin up an in-process HTTP mock server with mutable
|
|
5
|
+
* library response, point sync.runSync at it, assert on the resulting
|
|
6
|
+
* filesystem state and the returned summary.
|
|
7
|
+
*
|
|
8
|
+
* Coverage:
|
|
9
|
+
* • Empty library → no writes, summary all zeros
|
|
10
|
+
* • Single skill → writeSkillDir called, added++
|
|
11
|
+
* • Existing skill → writeSkillDir overwrites, updated++
|
|
12
|
+
* • Tombstone → removeSkillDir called, removed++
|
|
13
|
+
* • Both writes and tombstones in one sync
|
|
14
|
+
* • ETag round-trip → second call short-circuits 304
|
|
15
|
+
* • Last-sync state file persisted with correct schema
|
|
16
|
+
* • Last-sync state file read on second call
|
|
17
|
+
* • Corrupt last-sync state file is tolerated
|
|
18
|
+
* • filesIncomplete skill is skipped AND ETag is NOT persisted
|
|
19
|
+
* • Removals applied BEFORE writes (ordering matters for re-add)
|
|
20
|
+
* • runSync rejects missing serverUrl/apiKey
|
|
21
|
+
* • cleanupOrphans runs as part of sync (orphans gone after)
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { describe, it, beforeEach, afterEach } from "node:test";
|
|
25
|
+
import assert from "node:assert/strict";
|
|
26
|
+
import {
|
|
27
|
+
mkdtempSync,
|
|
28
|
+
rmSync,
|
|
29
|
+
mkdirSync,
|
|
30
|
+
writeFileSync,
|
|
31
|
+
existsSync,
|
|
32
|
+
readFileSync,
|
|
33
|
+
} from "node:fs";
|
|
34
|
+
import { join } from "node:path";
|
|
35
|
+
import { tmpdir } from "node:os";
|
|
36
|
+
|
|
37
|
+
import { runSync, readLastSync, writeLastSync, LAST_SYNC_SCHEMA_VERSION } from "../../lib/sync.mjs";
|
|
38
|
+
import { resolvePlacementDir } from "../../lib/file-write.mjs";
|
|
39
|
+
import { globalLastSyncPath } from "../../lib/paths.mjs";
|
|
40
|
+
import { CliError, EXIT_VALIDATION } from "../../lib/errors.mjs";
|
|
41
|
+
import { createMockServer } from "../e2e/mock-server.mjs";
|
|
42
|
+
|
|
43
|
+
let sandbox;
|
|
44
|
+
let originalCwd;
|
|
45
|
+
let originalHome;
|
|
46
|
+
let server;
|
|
47
|
+
let serverUrl;
|
|
48
|
+
const VALID_KEY = "sk_live_test123";
|
|
49
|
+
|
|
50
|
+
function makeSkill(name, content = `# ${name}\n`) {
|
|
51
|
+
return {
|
|
52
|
+
owner: "alice",
|
|
53
|
+
name,
|
|
54
|
+
version: "1.0.0",
|
|
55
|
+
description: `${name} skill`,
|
|
56
|
+
files: [
|
|
57
|
+
{
|
|
58
|
+
path: "SKILL.md",
|
|
59
|
+
content: `---\nname: ${name}\ndescription: Test skill ${name}\n---\n\n${content}`,
|
|
60
|
+
sha256: "x",
|
|
61
|
+
size: content.length,
|
|
62
|
+
contentType: "text/markdown",
|
|
63
|
+
},
|
|
64
|
+
],
|
|
65
|
+
updatedAt: new Date().toISOString(),
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async function setupServer() {
|
|
70
|
+
sandbox = mkdtempSync(join(tmpdir(), "cli-sync-"));
|
|
71
|
+
mkdirSync(join(sandbox, "project"), { recursive: true });
|
|
72
|
+
mkdirSync(join(sandbox, "home"), { recursive: true });
|
|
73
|
+
originalCwd = process.cwd();
|
|
74
|
+
originalHome = process.env.HOME;
|
|
75
|
+
process.chdir(join(sandbox, "project"));
|
|
76
|
+
process.env.HOME = join(sandbox, "home");
|
|
77
|
+
|
|
78
|
+
server = createMockServer({});
|
|
79
|
+
const port = await server.start();
|
|
80
|
+
serverUrl = `http://127.0.0.1:${port}`;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async function teardownServer() {
|
|
84
|
+
if (server) await server.stop();
|
|
85
|
+
process.chdir(originalCwd);
|
|
86
|
+
process.env.HOME = originalHome;
|
|
87
|
+
if (sandbox) rmSync(sandbox, { recursive: true, force: true });
|
|
88
|
+
server = null;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// ── Last-sync state file ───────────────────────────────────────────────
|
|
92
|
+
|
|
93
|
+
describe("readLastSync / writeLastSync", () => {
|
|
94
|
+
beforeEach(setupServer);
|
|
95
|
+
afterEach(teardownServer);
|
|
96
|
+
|
|
97
|
+
it("returns null when state file does not exist", () => {
|
|
98
|
+
assert.equal(readLastSync(), null);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("round-trips a valid state file", () => {
|
|
102
|
+
writeLastSync({ etag: '"abc123"', syncedAt: "2025-01-01T00:00:00Z" });
|
|
103
|
+
const result = readLastSync();
|
|
104
|
+
assert.equal(result.etag, '"abc123"');
|
|
105
|
+
assert.equal(result.syncedAt, "2025-01-01T00:00:00Z");
|
|
106
|
+
assert.equal(result.schemaVersion, LAST_SYNC_SCHEMA_VERSION);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("returns null on corrupt JSON", () => {
|
|
110
|
+
const path = globalLastSyncPath();
|
|
111
|
+
mkdirSync(join(process.env.HOME, ".claude", "skillrepo"), { recursive: true });
|
|
112
|
+
writeFileSync(path, "not json {{{");
|
|
113
|
+
assert.equal(readLastSync(), null);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("returns null on unknown schema version", () => {
|
|
117
|
+
writeLastSync({ etag: "x", syncedAt: "x" });
|
|
118
|
+
const path = globalLastSyncPath();
|
|
119
|
+
const obj = JSON.parse(readFileSync(path, "utf-8"));
|
|
120
|
+
obj.schemaVersion = 999;
|
|
121
|
+
writeFileSync(path, JSON.stringify(obj));
|
|
122
|
+
assert.equal(readLastSync(), null);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it("creates the parent directory if missing", () => {
|
|
126
|
+
rmSync(join(process.env.HOME, ".claude"), { recursive: true, force: true });
|
|
127
|
+
writeLastSync({ etag: "x", syncedAt: "x" });
|
|
128
|
+
assert.ok(existsSync(globalLastSyncPath()));
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
// ── runSync — empty library ────────────────────────────────────────────
|
|
133
|
+
|
|
134
|
+
describe("runSync — empty library", () => {
|
|
135
|
+
beforeEach(setupServer);
|
|
136
|
+
afterEach(teardownServer);
|
|
137
|
+
|
|
138
|
+
it("returns all-zero summary for empty server response", async () => {
|
|
139
|
+
server.setLibraryResponse({ skills: [], removals: [], syncedAt: "x" });
|
|
140
|
+
const result = await runSync({
|
|
141
|
+
serverUrl,
|
|
142
|
+
apiKey: VALID_KEY,
|
|
143
|
+
vendors: ["claudeCode"],
|
|
144
|
+
});
|
|
145
|
+
assert.equal(result.added, 0);
|
|
146
|
+
assert.equal(result.updated, 0);
|
|
147
|
+
assert.equal(result.removed, 0);
|
|
148
|
+
assert.equal(result.notModified, false);
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
// ── runSync — write skills ─────────────────────────────────────────────
|
|
153
|
+
|
|
154
|
+
describe("runSync — write skills", () => {
|
|
155
|
+
beforeEach(setupServer);
|
|
156
|
+
afterEach(teardownServer);
|
|
157
|
+
|
|
158
|
+
it("writes a new skill and increments added", async () => {
|
|
159
|
+
server.setLibraryResponse({
|
|
160
|
+
skills: [makeSkill("pdf-helper")],
|
|
161
|
+
removals: [],
|
|
162
|
+
syncedAt: "2025-01-01T00:00:00Z",
|
|
163
|
+
});
|
|
164
|
+
const result = await runSync({
|
|
165
|
+
serverUrl,
|
|
166
|
+
apiKey: VALID_KEY,
|
|
167
|
+
vendors: ["claudeCode"],
|
|
168
|
+
});
|
|
169
|
+
assert.equal(result.added, 1);
|
|
170
|
+
assert.equal(result.updated, 0);
|
|
171
|
+
|
|
172
|
+
const dir = resolvePlacementDir("claudeProject", "pdf-helper");
|
|
173
|
+
assert.ok(existsSync(join(dir, "SKILL.md")));
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it("overwrites an existing skill and increments updated", async () => {
|
|
177
|
+
// First sync writes the skill
|
|
178
|
+
server.setLibraryResponse({
|
|
179
|
+
skills: [makeSkill("pdf-helper", "version 1")],
|
|
180
|
+
removals: [],
|
|
181
|
+
syncedAt: "2025-01-01T00:00:00Z",
|
|
182
|
+
});
|
|
183
|
+
await runSync({ serverUrl, apiKey: VALID_KEY, vendors: ["claudeCode"] });
|
|
184
|
+
|
|
185
|
+
// Clear state so the second sync doesn't 304-short-circuit
|
|
186
|
+
rmSync(globalLastSyncPath(), { force: true });
|
|
187
|
+
|
|
188
|
+
// Second sync overwrites with new content
|
|
189
|
+
server.setLibraryResponse({
|
|
190
|
+
skills: [makeSkill("pdf-helper", "version 2")],
|
|
191
|
+
removals: [],
|
|
192
|
+
syncedAt: "2025-01-02T00:00:00Z",
|
|
193
|
+
});
|
|
194
|
+
const result = await runSync({ serverUrl, apiKey: VALID_KEY, vendors: ["claudeCode"] });
|
|
195
|
+
assert.equal(result.added, 0);
|
|
196
|
+
assert.equal(result.updated, 1);
|
|
197
|
+
|
|
198
|
+
const dir = resolvePlacementDir("claudeProject", "pdf-helper");
|
|
199
|
+
const skillMd = readFileSync(join(dir, "SKILL.md"), "utf-8");
|
|
200
|
+
assert.match(skillMd, /version 2/);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it("writes multiple skills in one sync", async () => {
|
|
204
|
+
server.setLibraryResponse({
|
|
205
|
+
skills: [makeSkill("first"), makeSkill("second"), makeSkill("third")],
|
|
206
|
+
removals: [],
|
|
207
|
+
syncedAt: "x",
|
|
208
|
+
});
|
|
209
|
+
const result = await runSync({
|
|
210
|
+
serverUrl,
|
|
211
|
+
apiKey: VALID_KEY,
|
|
212
|
+
vendors: ["claudeCode"],
|
|
213
|
+
});
|
|
214
|
+
assert.equal(result.added, 3);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it("skips a filesIncomplete skill", async () => {
|
|
218
|
+
const incomplete = makeSkill("incomplete");
|
|
219
|
+
incomplete.filesIncomplete = true;
|
|
220
|
+
server.setLibraryResponse({
|
|
221
|
+
skills: [incomplete, makeSkill("complete")],
|
|
222
|
+
removals: [],
|
|
223
|
+
syncedAt: "x",
|
|
224
|
+
});
|
|
225
|
+
const result = await runSync({
|
|
226
|
+
serverUrl,
|
|
227
|
+
apiKey: VALID_KEY,
|
|
228
|
+
vendors: ["claudeCode"],
|
|
229
|
+
});
|
|
230
|
+
// Only `complete` was written
|
|
231
|
+
assert.equal(result.added, 1);
|
|
232
|
+
const incompleteDir = resolvePlacementDir("claudeProject", "incomplete");
|
|
233
|
+
assert.ok(!existsSync(incompleteDir));
|
|
234
|
+
const completeDir = resolvePlacementDir("claudeProject", "complete");
|
|
235
|
+
assert.ok(existsSync(completeDir));
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
it("does NOT persist ETag when any skill is filesIncomplete", async () => {
|
|
239
|
+
const incomplete = makeSkill("incomplete");
|
|
240
|
+
incomplete.filesIncomplete = true;
|
|
241
|
+
server.setEtag('"v1"');
|
|
242
|
+
server.setLibraryResponse({
|
|
243
|
+
skills: [incomplete],
|
|
244
|
+
removals: [],
|
|
245
|
+
syncedAt: "x",
|
|
246
|
+
});
|
|
247
|
+
await runSync({ serverUrl, apiKey: VALID_KEY, vendors: ["claudeCode"] });
|
|
248
|
+
// The next sync would re-fetch instead of short-circuiting
|
|
249
|
+
assert.equal(readLastSync(), null);
|
|
250
|
+
});
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
// ── runSync — tombstones ───────────────────────────────────────────────
|
|
254
|
+
|
|
255
|
+
describe("runSync — tombstones", () => {
|
|
256
|
+
beforeEach(setupServer);
|
|
257
|
+
afterEach(teardownServer);
|
|
258
|
+
|
|
259
|
+
it("applies a tombstone and increments removed", async () => {
|
|
260
|
+
// Pre-write a skill
|
|
261
|
+
server.setLibraryResponse({
|
|
262
|
+
skills: [makeSkill("doomed")],
|
|
263
|
+
removals: [],
|
|
264
|
+
syncedAt: "2025-01-01T00:00:00Z",
|
|
265
|
+
});
|
|
266
|
+
await runSync({ serverUrl, apiKey: VALID_KEY, vendors: ["claudeCode"] });
|
|
267
|
+
assert.ok(existsSync(resolvePlacementDir("claudeProject", "doomed")));
|
|
268
|
+
|
|
269
|
+
// Now the server says it's removed (the response has the tombstone
|
|
270
|
+
// AND the skill is no longer in skills[])
|
|
271
|
+
rmSync(globalLastSyncPath(), { force: true });
|
|
272
|
+
server.setLibraryResponse({
|
|
273
|
+
skills: [],
|
|
274
|
+
removals: [{ owner: "alice", name: "doomed", removedAt: "2025-01-02T00:00:00Z" }],
|
|
275
|
+
syncedAt: "2025-01-02T00:00:00Z",
|
|
276
|
+
});
|
|
277
|
+
const result = await runSync({
|
|
278
|
+
serverUrl,
|
|
279
|
+
apiKey: VALID_KEY,
|
|
280
|
+
vendors: ["claudeCode"],
|
|
281
|
+
});
|
|
282
|
+
assert.equal(result.removed, 1);
|
|
283
|
+
assert.ok(!existsSync(resolvePlacementDir("claudeProject", "doomed")));
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
it("applies removals BEFORE writes (re-add scenario)", async () => {
|
|
287
|
+
// The server returns a tombstone AND a skill with the same name
|
|
288
|
+
// (the user removed and re-added in the same window). The CLI
|
|
289
|
+
// must apply the tombstone first so the new skill survives.
|
|
290
|
+
server.setLibraryResponse({
|
|
291
|
+
skills: [makeSkill("phoenix", "freshly re-added")],
|
|
292
|
+
removals: [{ owner: "alice", name: "phoenix", removedAt: "x" }],
|
|
293
|
+
syncedAt: "x",
|
|
294
|
+
});
|
|
295
|
+
await runSync({
|
|
296
|
+
serverUrl,
|
|
297
|
+
apiKey: VALID_KEY,
|
|
298
|
+
vendors: ["claudeCode"],
|
|
299
|
+
});
|
|
300
|
+
// The phoenix skill should EXIST because the write came after
|
|
301
|
+
// the removal, not the other way around.
|
|
302
|
+
const dir = resolvePlacementDir("claudeProject", "phoenix");
|
|
303
|
+
assert.ok(existsSync(join(dir, "SKILL.md")));
|
|
304
|
+
const skillMd = readFileSync(join(dir, "SKILL.md"), "utf-8");
|
|
305
|
+
assert.match(skillMd, /freshly re-added/);
|
|
306
|
+
});
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
// ── runSync — ETag short-circuit ───────────────────────────────────────
|
|
310
|
+
|
|
311
|
+
describe("runSync — ETag round-trip", () => {
|
|
312
|
+
beforeEach(setupServer);
|
|
313
|
+
afterEach(teardownServer);
|
|
314
|
+
|
|
315
|
+
it("first sync persists ETag, second sync 304s", async () => {
|
|
316
|
+
server.setEtag('"v1"');
|
|
317
|
+
server.setLibraryResponse({
|
|
318
|
+
skills: [makeSkill("first")],
|
|
319
|
+
removals: [],
|
|
320
|
+
syncedAt: "2025-01-01T00:00:00Z",
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
const first = await runSync({
|
|
324
|
+
serverUrl,
|
|
325
|
+
apiKey: VALID_KEY,
|
|
326
|
+
vendors: ["claudeCode"],
|
|
327
|
+
});
|
|
328
|
+
assert.equal(first.added, 1);
|
|
329
|
+
assert.equal(first.notModified, false);
|
|
330
|
+
|
|
331
|
+
// Second sync with same ETag → 304
|
|
332
|
+
const second = await runSync({
|
|
333
|
+
serverUrl,
|
|
334
|
+
apiKey: VALID_KEY,
|
|
335
|
+
vendors: ["claudeCode"],
|
|
336
|
+
});
|
|
337
|
+
assert.equal(second.notModified, true);
|
|
338
|
+
assert.equal(second.added, 0);
|
|
339
|
+
assert.equal(second.updated, 0);
|
|
340
|
+
assert.equal(second.removed, 0);
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
it("304 short-circuit does not delete or modify on-disk skills", async () => {
|
|
344
|
+
server.setEtag('"v1"');
|
|
345
|
+
server.setLibraryResponse({
|
|
346
|
+
skills: [makeSkill("staying")],
|
|
347
|
+
removals: [],
|
|
348
|
+
syncedAt: "x",
|
|
349
|
+
});
|
|
350
|
+
await runSync({ serverUrl, apiKey: VALID_KEY, vendors: ["claudeCode"] });
|
|
351
|
+
|
|
352
|
+
const dir = resolvePlacementDir("claudeProject", "staying");
|
|
353
|
+
const before = readFileSync(join(dir, "SKILL.md"), "utf-8");
|
|
354
|
+
|
|
355
|
+
await runSync({ serverUrl, apiKey: VALID_KEY, vendors: ["claudeCode"] });
|
|
356
|
+
const after = readFileSync(join(dir, "SKILL.md"), "utf-8");
|
|
357
|
+
assert.equal(before, after);
|
|
358
|
+
});
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
// ── runSync — argument validation ──────────────────────────────────────
|
|
362
|
+
|
|
363
|
+
describe("runSync — input validation", () => {
|
|
364
|
+
beforeEach(setupServer);
|
|
365
|
+
afterEach(teardownServer);
|
|
366
|
+
|
|
367
|
+
it("throws validationError when serverUrl is missing", async () => {
|
|
368
|
+
await assert.rejects(
|
|
369
|
+
() => runSync({ serverUrl: "", apiKey: VALID_KEY, vendors: ["claudeCode"] }),
|
|
370
|
+
(err) => err instanceof CliError && err.exitCode === EXIT_VALIDATION,
|
|
371
|
+
);
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
it("throws validationError when apiKey is missing", async () => {
|
|
375
|
+
await assert.rejects(
|
|
376
|
+
() => runSync({ serverUrl, apiKey: "", vendors: ["claudeCode"] }),
|
|
377
|
+
(err) => err instanceof CliError && err.exitCode === EXIT_VALIDATION,
|
|
378
|
+
);
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
it("accepts io: null without crashing (round-2 review fix)", async () => {
|
|
382
|
+
// Regression for the round-2 finding both reviewers caught: the
|
|
383
|
+
// destructure default `io = {}` only handles `undefined`, so an
|
|
384
|
+
// explicit `io: null` would crash on the .stderr access. The
|
|
385
|
+
// sync.mjs fix coalesces null → {} via `io ?? {}`. This test
|
|
386
|
+
// confirms it.
|
|
387
|
+
server.setLibraryResponse({ skills: [], removals: [], syncedAt: "x" });
|
|
388
|
+
await runSync({
|
|
389
|
+
serverUrl,
|
|
390
|
+
apiKey: VALID_KEY,
|
|
391
|
+
vendors: ["claudeCode"],
|
|
392
|
+
io: null,
|
|
393
|
+
});
|
|
394
|
+
// No throw → pass
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
it("accepts io: undefined as if io was omitted", async () => {
|
|
398
|
+
server.setLibraryResponse({ skills: [], removals: [], syncedAt: "x" });
|
|
399
|
+
await runSync({
|
|
400
|
+
serverUrl,
|
|
401
|
+
apiKey: VALID_KEY,
|
|
402
|
+
vendors: ["claudeCode"],
|
|
403
|
+
io: undefined,
|
|
404
|
+
});
|
|
405
|
+
});
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
describe("runSync — io.stderr injection (round-3 coverage fix)", () => {
|
|
409
|
+
beforeEach(setupServer);
|
|
410
|
+
afterEach(teardownServer);
|
|
411
|
+
|
|
412
|
+
it("forwards the writeLastSync failure warning to io.stderr (not process.stderr)", { skip: process.platform === "win32" || process.getuid?.() === 0 }, async () => {
|
|
413
|
+
// Round-3 review found that the previous io: null test didn't
|
|
414
|
+
// actually exercise the writeLastSync failure path because the
|
|
415
|
+
// fixture had no ETag, so the `stderr.write(...)` branch was
|
|
416
|
+
// never reached. This test arranges for ALL of:
|
|
417
|
+
// 1. Server response has an ETag (so the persist branch runs)
|
|
418
|
+
// 2. writeLastSync is forced to fail (state dir is read-only)
|
|
419
|
+
// 3. io.stderr is an injected capture stream
|
|
420
|
+
// and asserts the warning lands on the injection target, NOT
|
|
421
|
+
// on process.stderr.
|
|
422
|
+
|
|
423
|
+
const { chmodSync } = await import("node:fs");
|
|
424
|
+
const { createCaptureStream } = await import("../helpers/capture-stream.mjs");
|
|
425
|
+
const { globalLastSyncPath } = await import("../../lib/paths.mjs");
|
|
426
|
+
const { dirname } = await import("node:path");
|
|
427
|
+
|
|
428
|
+
// Pre-create the state dir, then make it read-only so writeLastSync
|
|
429
|
+
// fails on the writeFileSync call.
|
|
430
|
+
const stateDir = dirname(globalLastSyncPath());
|
|
431
|
+
const { mkdirSync } = await import("node:fs");
|
|
432
|
+
mkdirSync(stateDir, { recursive: true });
|
|
433
|
+
chmodSync(stateDir, 0o555);
|
|
434
|
+
|
|
435
|
+
server.setEtag('"v1"');
|
|
436
|
+
server.setLibraryResponse({
|
|
437
|
+
skills: [makeSkill("with-etag")],
|
|
438
|
+
removals: [],
|
|
439
|
+
syncedAt: "2025-01-01T00:00:00Z",
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
const stderr = createCaptureStream();
|
|
443
|
+
try {
|
|
444
|
+
await runSync({
|
|
445
|
+
serverUrl,
|
|
446
|
+
apiKey: VALID_KEY,
|
|
447
|
+
vendors: ["claudeCode"],
|
|
448
|
+
io: { stderr },
|
|
449
|
+
});
|
|
450
|
+
} finally {
|
|
451
|
+
// Restore writable permissions so afterEach can clean up
|
|
452
|
+
chmodSync(stateDir, 0o755);
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
// The warning must have landed on the injected stream
|
|
456
|
+
assert.match(stderr.text(), /failed to persist last-sync state/);
|
|
457
|
+
assert.match(stderr.text(), /Next sync will be a full fetch/);
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
it("falls back to process.stderr when io.stderr is not provided", async () => {
|
|
461
|
+
// The flip side: when no io is passed, the warning still goes
|
|
462
|
+
// somewhere (process.stderr) rather than crashing. We don't
|
|
463
|
+
// assert on process.stderr's contents (that would require
|
|
464
|
+
// monkey-patching, which conflicts with node:test's IPC), but
|
|
465
|
+
// we do assert that runSync completes successfully.
|
|
466
|
+
server.setLibraryResponse({ skills: [], removals: [], syncedAt: "x" });
|
|
467
|
+
await runSync({
|
|
468
|
+
serverUrl,
|
|
469
|
+
apiKey: VALID_KEY,
|
|
470
|
+
vendors: ["claudeCode"],
|
|
471
|
+
});
|
|
472
|
+
// No throw → pass; the happy-path uses no warning so this is
|
|
473
|
+
// also covered by all the other tests, but explicit is good.
|
|
474
|
+
});
|
|
475
|
+
});
|
|
476
|
+
|
|
477
|
+
// ── runSync — orphan cleanup ───────────────────────────────────────────
|
|
478
|
+
|
|
479
|
+
describe("runSync — orphan cleanup", () => {
|
|
480
|
+
beforeEach(setupServer);
|
|
481
|
+
afterEach(teardownServer);
|
|
482
|
+
|
|
483
|
+
it("cleans orphan .tmp/ with a live sibling before writing", async () => {
|
|
484
|
+
// Pre-write a skill so a stale .tmp/ has a live sibling
|
|
485
|
+
server.setLibraryResponse({
|
|
486
|
+
skills: [makeSkill("clean-me")],
|
|
487
|
+
removals: [],
|
|
488
|
+
syncedAt: "x",
|
|
489
|
+
});
|
|
490
|
+
await runSync({ serverUrl, apiKey: VALID_KEY, vendors: ["claudeCode"] });
|
|
491
|
+
|
|
492
|
+
// Inject a stale .tmp/ from a "previous crash"
|
|
493
|
+
const root = join(process.cwd(), ".claude", "skills");
|
|
494
|
+
mkdirSync(join(root, "clean-me.tmp"));
|
|
495
|
+
writeFileSync(join(root, "clean-me.tmp", "garbage.txt"), "x");
|
|
496
|
+
|
|
497
|
+
// Run sync again — the orphan cleanup should fire first
|
|
498
|
+
rmSync(globalLastSyncPath(), { force: true });
|
|
499
|
+
await runSync({ serverUrl, apiKey: VALID_KEY, vendors: ["claudeCode"] });
|
|
500
|
+
assert.ok(!existsSync(join(root, "clean-me.tmp")));
|
|
501
|
+
});
|
|
502
|
+
|
|
503
|
+
it("preserves orphan .tmp/ with NO live sibling (Windows recovery)", async () => {
|
|
504
|
+
// No live sibling — this .tmp/ is the user's only copy of a
|
|
505
|
+
// crashed-mid-rename skill. cleanupOrphans must preserve it.
|
|
506
|
+
const root = join(process.cwd(), ".claude", "skills");
|
|
507
|
+
mkdirSync(join(root, "recoverable.tmp"), { recursive: true });
|
|
508
|
+
writeFileSync(join(root, "recoverable.tmp", "SKILL.md"), "x");
|
|
509
|
+
|
|
510
|
+
server.setLibraryResponse({ skills: [], removals: [], syncedAt: "x" });
|
|
511
|
+
await runSync({ serverUrl, apiKey: VALID_KEY, vendors: ["claudeCode"] });
|
|
512
|
+
assert.ok(existsSync(join(root, "recoverable.tmp", "SKILL.md")));
|
|
513
|
+
});
|
|
514
|
+
});
|