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,697 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit/integration tests for src/commands/init.mjs (PR3b rewrite, #646).
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { describe, it, beforeEach, afterEach } from "node:test";
|
|
6
|
+
import assert from "node:assert/strict";
|
|
7
|
+
import {
|
|
8
|
+
mkdtempSync,
|
|
9
|
+
mkdirSync,
|
|
10
|
+
rmSync,
|
|
11
|
+
existsSync,
|
|
12
|
+
readFileSync,
|
|
13
|
+
writeFileSync,
|
|
14
|
+
} from "node:fs";
|
|
15
|
+
import { join } from "node:path";
|
|
16
|
+
import { tmpdir } from "node:os";
|
|
17
|
+
|
|
18
|
+
import { runInit } from "../../commands/init.mjs";
|
|
19
|
+
import { readConfig } from "../../lib/config.mjs";
|
|
20
|
+
import { CliError, EXIT_AUTH, EXIT_VALIDATION } from "../../lib/errors.mjs";
|
|
21
|
+
import { createMockServer } from "../e2e/mock-server.mjs";
|
|
22
|
+
import { createCaptureStream } from "../helpers/capture-stream.mjs";
|
|
23
|
+
|
|
24
|
+
let sandbox;
|
|
25
|
+
let server;
|
|
26
|
+
let serverUrl;
|
|
27
|
+
let originalCwd;
|
|
28
|
+
let originalHome;
|
|
29
|
+
let stdout;
|
|
30
|
+
let stderr;
|
|
31
|
+
const VALID_KEY = "sk_live_init_test";
|
|
32
|
+
|
|
33
|
+
async function setup() {
|
|
34
|
+
sandbox = mkdtempSync(join(tmpdir(), "cli-cmd-init-"));
|
|
35
|
+
// init defaults to detecting IDEs in cwd. Create a `.claude/`
|
|
36
|
+
// marker so detection finds claudeCode and the command doesn't
|
|
37
|
+
// refuse for "no IDEs detected".
|
|
38
|
+
mkdirSync(join(sandbox, "project", ".claude"), { recursive: true });
|
|
39
|
+
mkdirSync(join(sandbox, "home"), { recursive: true });
|
|
40
|
+
originalCwd = process.cwd();
|
|
41
|
+
originalHome = process.env.HOME;
|
|
42
|
+
process.chdir(join(sandbox, "project"));
|
|
43
|
+
process.env.HOME = join(sandbox, "home");
|
|
44
|
+
delete process.env.SKILLREPO_ACCESS_KEY;
|
|
45
|
+
delete process.env.SKILLREPO_URL;
|
|
46
|
+
|
|
47
|
+
server = createMockServer({});
|
|
48
|
+
const port = await server.start();
|
|
49
|
+
serverUrl = `http://127.0.0.1:${port}`;
|
|
50
|
+
|
|
51
|
+
stdout = createCaptureStream();
|
|
52
|
+
stderr = createCaptureStream();
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function teardown() {
|
|
56
|
+
if (server) await server.stop();
|
|
57
|
+
process.chdir(originalCwd);
|
|
58
|
+
process.env.HOME = originalHome;
|
|
59
|
+
if (sandbox) rmSync(sandbox, { recursive: true, force: true });
|
|
60
|
+
server = null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// ── Happy path ─────────────────────────────────────────────────────────
|
|
64
|
+
|
|
65
|
+
describe("runInit — happy path", () => {
|
|
66
|
+
beforeEach(setup);
|
|
67
|
+
afterEach(teardown);
|
|
68
|
+
|
|
69
|
+
it("writes config + MCP + runs first sync with --yes", async () => {
|
|
70
|
+
await runInit(
|
|
71
|
+
["--key", VALID_KEY, "--url", serverUrl, "--yes"],
|
|
72
|
+
{ stdout, stderr },
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
// Config file persisted
|
|
76
|
+
const cfg = readConfig();
|
|
77
|
+
assert.ok(cfg, "config should be readable after init");
|
|
78
|
+
assert.equal(cfg.apiKey, VALID_KEY);
|
|
79
|
+
assert.equal(cfg.serverUrl, serverUrl);
|
|
80
|
+
assert.equal(cfg.accountSlug, "mock");
|
|
81
|
+
|
|
82
|
+
// MCP config created in project
|
|
83
|
+
assert.ok(existsSync(join(process.cwd(), ".mcp.json")));
|
|
84
|
+
const mcp = JSON.parse(readFileSync(join(process.cwd(), ".mcp.json"), "utf-8"));
|
|
85
|
+
assert.ok(mcp.mcpServers?.skillrepo);
|
|
86
|
+
|
|
87
|
+
// .env.local written for agent env var consumers
|
|
88
|
+
assert.ok(existsSync(join(process.cwd(), ".env.local")));
|
|
89
|
+
const envContent = readFileSync(join(process.cwd(), ".env.local"), "utf-8");
|
|
90
|
+
assert.match(envContent, new RegExp(`SKILLREPO_ACCESS_KEY=${VALID_KEY}`));
|
|
91
|
+
|
|
92
|
+
// .gitignore has the three init-required entries.
|
|
93
|
+
// Round-3 architect + code-reviewer caught that this gitignore
|
|
94
|
+
// management was documented in the README but never actually
|
|
95
|
+
// implemented — this assertion locks the fix so a future
|
|
96
|
+
// regression that removes the mergeGitignore call from init
|
|
97
|
+
// fails loudly.
|
|
98
|
+
assert.ok(existsSync(join(process.cwd(), ".gitignore")));
|
|
99
|
+
const gi = readFileSync(join(process.cwd(), ".gitignore"), "utf-8");
|
|
100
|
+
assert.match(gi, /^\.env\.local$/m, ".env.local must be gitignored (contains access key)");
|
|
101
|
+
assert.match(gi, /^\.claude\/skills\/$/m, ".claude/skills/ must be gitignored");
|
|
102
|
+
assert.match(gi, /^\.claude\/settings\.local\.json$/m, ".claude/settings.local.json must be gitignored");
|
|
103
|
+
|
|
104
|
+
// Human summary
|
|
105
|
+
assert.match(stdout.text(), /SkillRepo is ready/);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("--json outputs structured summary", async () => {
|
|
109
|
+
await runInit(
|
|
110
|
+
["--key", VALID_KEY, "--url", serverUrl, "--yes", "--json"],
|
|
111
|
+
{ stdout, stderr },
|
|
112
|
+
);
|
|
113
|
+
const json = JSON.parse(stdout.text());
|
|
114
|
+
assert.equal(json.action, "initialized");
|
|
115
|
+
assert.equal(json.account.slug, "mock");
|
|
116
|
+
assert.ok(Array.isArray(json.vendors));
|
|
117
|
+
assert.ok(Array.isArray(json.mcp.merged));
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("respects --ide flag to override detection", async () => {
|
|
121
|
+
await runInit(
|
|
122
|
+
["--key", VALID_KEY, "--url", serverUrl, "--yes", "--ide", "claude"],
|
|
123
|
+
{ stdout, stderr },
|
|
124
|
+
);
|
|
125
|
+
const mcp = JSON.parse(readFileSync(join(process.cwd(), ".mcp.json"), "utf-8"));
|
|
126
|
+
assert.ok(mcp.mcpServers?.skillrepo);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it("detects multiple IDEs when both .claude/ and .cursor/ exist", async () => {
|
|
130
|
+
mkdirSync(join(process.cwd(), ".cursor"), { recursive: true });
|
|
131
|
+
await runInit(
|
|
132
|
+
["--key", VALID_KEY, "--url", serverUrl, "--yes"],
|
|
133
|
+
{ stdout, stderr },
|
|
134
|
+
);
|
|
135
|
+
assert.ok(existsSync(join(process.cwd(), ".mcp.json")));
|
|
136
|
+
assert.ok(existsSync(join(process.cwd(), ".cursor", "mcp.json")));
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
// ── Credential resolution ─────────────────────────────────────────────
|
|
141
|
+
|
|
142
|
+
describe("runInit — credential resolution", () => {
|
|
143
|
+
beforeEach(setup);
|
|
144
|
+
afterEach(teardown);
|
|
145
|
+
|
|
146
|
+
it("reads existing config when no --key provided and server validates OK", async () => {
|
|
147
|
+
// Pre-seed the config file
|
|
148
|
+
mkdirSync(join(process.env.HOME, ".claude", "skillrepo"), { recursive: true });
|
|
149
|
+
writeFileSync(
|
|
150
|
+
join(process.env.HOME, ".claude", "skillrepo", "config.json"),
|
|
151
|
+
JSON.stringify({
|
|
152
|
+
schemaVersion: 1,
|
|
153
|
+
apiKey: VALID_KEY,
|
|
154
|
+
serverUrl,
|
|
155
|
+
}),
|
|
156
|
+
);
|
|
157
|
+
// No --key passed; init should pick up the config and succeed
|
|
158
|
+
await runInit(["--yes"], { stdout, stderr });
|
|
159
|
+
assert.match(stdout.text(), /SkillRepo is ready/);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it("reads SKILLREPO_ACCESS_KEY env var when no flag or config", async () => {
|
|
163
|
+
process.env.SKILLREPO_ACCESS_KEY = VALID_KEY;
|
|
164
|
+
process.env.SKILLREPO_URL = serverUrl;
|
|
165
|
+
await runInit(["--yes"], { stdout, stderr });
|
|
166
|
+
assert.match(stdout.text(), /SkillRepo is ready/);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it("refuses to run under --yes when no key is configured anywhere", async () => {
|
|
170
|
+
// No --key, no config, no env var, non-interactive → hard fail
|
|
171
|
+
await assert.rejects(
|
|
172
|
+
() => runInit(["--yes"], { stdout, stderr }),
|
|
173
|
+
(err) => err instanceof CliError && err.exitCode === EXIT_AUTH,
|
|
174
|
+
);
|
|
175
|
+
});
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
// ── Error paths ────────────────────────────────────────────────────────
|
|
179
|
+
|
|
180
|
+
describe("runInit — error paths", () => {
|
|
181
|
+
beforeEach(setup);
|
|
182
|
+
afterEach(teardown);
|
|
183
|
+
|
|
184
|
+
it("rejects invalid key format (not sk_live_ prefix)", async () => {
|
|
185
|
+
await assert.rejects(
|
|
186
|
+
() => runInit(
|
|
187
|
+
["--key", "not_a_valid_key", "--url", serverUrl, "--yes"],
|
|
188
|
+
{ stdout, stderr },
|
|
189
|
+
),
|
|
190
|
+
(err) => err instanceof CliError && err.exitCode === EXIT_VALIDATION,
|
|
191
|
+
);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it("401 from validate surfaces as authError", async () => {
|
|
195
|
+
server.setForcedStatus(401, { error: "Invalid access key" });
|
|
196
|
+
await assert.rejects(
|
|
197
|
+
() => runInit(
|
|
198
|
+
["--key", VALID_KEY, "--url", serverUrl, "--yes"],
|
|
199
|
+
{ stdout, stderr },
|
|
200
|
+
),
|
|
201
|
+
(err) => err instanceof CliError && err.exitCode === EXIT_AUTH,
|
|
202
|
+
);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it("refuses with clear error when no IDE detected and no --ide flag", async () => {
|
|
206
|
+
// Remove the .claude marker that setup() created
|
|
207
|
+
rmSync(join(process.cwd(), ".claude"), { recursive: true, force: true });
|
|
208
|
+
await assert.rejects(
|
|
209
|
+
() => runInit(
|
|
210
|
+
["--key", VALID_KEY, "--url", serverUrl, "--yes"],
|
|
211
|
+
{ stdout, stderr },
|
|
212
|
+
),
|
|
213
|
+
(err) =>
|
|
214
|
+
err instanceof CliError &&
|
|
215
|
+
err.exitCode === EXIT_VALIDATION &&
|
|
216
|
+
/No IDEs detected/.test(err.message),
|
|
217
|
+
);
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it("headless CI scenario: explicit --ide claude works in empty project", async () => {
|
|
221
|
+
// Remove the .claude marker — empty dir
|
|
222
|
+
rmSync(join(process.cwd(), ".claude"), { recursive: true, force: true });
|
|
223
|
+
// With --ide claude, init should proceed even in an empty dir
|
|
224
|
+
await runInit(
|
|
225
|
+
["--key", VALID_KEY, "--url", serverUrl, "--yes", "--ide", "claude"],
|
|
226
|
+
{ stdout, stderr },
|
|
227
|
+
);
|
|
228
|
+
// And write the MCP config
|
|
229
|
+
assert.ok(existsSync(join(process.cwd(), ".mcp.json")));
|
|
230
|
+
});
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
// ── Idempotency ────────────────────────────────────────────────────────
|
|
234
|
+
|
|
235
|
+
describe("runInit — idempotency", () => {
|
|
236
|
+
beforeEach(setup);
|
|
237
|
+
afterEach(teardown);
|
|
238
|
+
|
|
239
|
+
it("running init twice with valid existing config is a no-op refresh", async () => {
|
|
240
|
+
// First init
|
|
241
|
+
await runInit(
|
|
242
|
+
["--key", VALID_KEY, "--url", serverUrl, "--yes"],
|
|
243
|
+
{ stdout, stderr },
|
|
244
|
+
);
|
|
245
|
+
const firstConfig = readConfig();
|
|
246
|
+
|
|
247
|
+
// Reset captures for the second run
|
|
248
|
+
stdout = createCaptureStream();
|
|
249
|
+
stderr = createCaptureStream();
|
|
250
|
+
|
|
251
|
+
// Second init WITHOUT --key — should pick up from config
|
|
252
|
+
await runInit(["--yes"], { stdout, stderr });
|
|
253
|
+
const secondConfig = readConfig();
|
|
254
|
+
assert.equal(secondConfig.apiKey, firstConfig.apiKey);
|
|
255
|
+
assert.match(stdout.text(), /SkillRepo is ready/);
|
|
256
|
+
});
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
// ── --force flag (round-1 review fix) ──────────────────────────────────
|
|
260
|
+
|
|
261
|
+
describe("runInit — --force flag", () => {
|
|
262
|
+
beforeEach(setup);
|
|
263
|
+
afterEach(teardown);
|
|
264
|
+
|
|
265
|
+
it("--force ignores existing config key (requires explicit new key)", async () => {
|
|
266
|
+
// Pre-seed a valid config
|
|
267
|
+
mkdirSync(join(process.env.HOME, ".claude", "skillrepo"), { recursive: true });
|
|
268
|
+
writeFileSync(
|
|
269
|
+
join(process.env.HOME, ".claude", "skillrepo", "config.json"),
|
|
270
|
+
JSON.stringify({
|
|
271
|
+
schemaVersion: 1,
|
|
272
|
+
apiKey: VALID_KEY,
|
|
273
|
+
serverUrl,
|
|
274
|
+
}),
|
|
275
|
+
);
|
|
276
|
+
|
|
277
|
+
// --force WITHOUT --key AND WITHOUT env var under --yes should
|
|
278
|
+
// hard-fail with EXIT_AUTH. This proves --force actually
|
|
279
|
+
// invalidates the cached credential rather than silently
|
|
280
|
+
// inheriting from the config. Before the round-1 fix, --force
|
|
281
|
+
// only cleared the key but still inherited serverUrl — this
|
|
282
|
+
// test also locks that the cached URL is ignored (the --url
|
|
283
|
+
// flag is required alongside --force for a full reset).
|
|
284
|
+
await assert.rejects(
|
|
285
|
+
() =>
|
|
286
|
+
runInit(["--force", "--yes"], { stdout, stderr }),
|
|
287
|
+
(err) => err instanceof CliError && err.exitCode === EXIT_AUTH,
|
|
288
|
+
);
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
it("--force + --key + --url re-runs validation against new credentials", async () => {
|
|
292
|
+
// Pre-seed with one server URL
|
|
293
|
+
mkdirSync(join(process.env.HOME, ".claude", "skillrepo"), { recursive: true });
|
|
294
|
+
writeFileSync(
|
|
295
|
+
join(process.env.HOME, ".claude", "skillrepo", "config.json"),
|
|
296
|
+
JSON.stringify({
|
|
297
|
+
schemaVersion: 1,
|
|
298
|
+
apiKey: "sk_live_old_key",
|
|
299
|
+
serverUrl: "https://old.example",
|
|
300
|
+
}),
|
|
301
|
+
);
|
|
302
|
+
|
|
303
|
+
// --force + explicit new credentials should succeed and OVERWRITE
|
|
304
|
+
// the config with the new ones (not merge).
|
|
305
|
+
await runInit(
|
|
306
|
+
["--force", "--key", VALID_KEY, "--url", serverUrl, "--yes"],
|
|
307
|
+
{ stdout, stderr },
|
|
308
|
+
);
|
|
309
|
+
const cfg = readConfig();
|
|
310
|
+
assert.equal(cfg.apiKey, VALID_KEY);
|
|
311
|
+
assert.equal(cfg.serverUrl, serverUrl);
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
it("--force + SKILLREPO_ACCESS_KEY env var (no --key flag) uses the env var", async () => {
|
|
315
|
+
// Cross-review coverage gap: the README explicitly documents
|
|
316
|
+
// this scenario ("init --force with SKILLREPO_ACCESS_KEY set:
|
|
317
|
+
// uses the env var (step 2). --force only clears the cached
|
|
318
|
+
// credentials, not the runtime env."), but no existing test
|
|
319
|
+
// locked it. Reviewers (correctly) pointed out that missing
|
|
320
|
+
// coverage on this path would let a regression silently change
|
|
321
|
+
// the priority order between env vars and the cached config.
|
|
322
|
+
//
|
|
323
|
+
// Pre-seed a cached config with a DIFFERENT key than the env
|
|
324
|
+
// var so we can tell which one init actually used.
|
|
325
|
+
mkdirSync(join(process.env.HOME, ".claude", "skillrepo"), { recursive: true });
|
|
326
|
+
writeFileSync(
|
|
327
|
+
join(process.env.HOME, ".claude", "skillrepo", "config.json"),
|
|
328
|
+
JSON.stringify({
|
|
329
|
+
schemaVersion: 1,
|
|
330
|
+
apiKey: "sk_live_CONFIG_KEY",
|
|
331
|
+
serverUrl: "https://old.example",
|
|
332
|
+
}),
|
|
333
|
+
);
|
|
334
|
+
|
|
335
|
+
process.env.SKILLREPO_ACCESS_KEY = VALID_KEY;
|
|
336
|
+
process.env.SKILLREPO_URL = serverUrl;
|
|
337
|
+
|
|
338
|
+
await runInit(["--force", "--yes"], { stdout, stderr });
|
|
339
|
+
|
|
340
|
+
// The config file should now contain the env var's key, NOT
|
|
341
|
+
// the seeded config key. --force cleared the cache; the env
|
|
342
|
+
// var won over the interactive-prompt fallback (which would
|
|
343
|
+
// also have fired since no --key was given).
|
|
344
|
+
const cfg = readConfig();
|
|
345
|
+
assert.equal(cfg.apiKey, VALID_KEY);
|
|
346
|
+
assert.notEqual(cfg.apiKey, "sk_live_CONFIG_KEY");
|
|
347
|
+
assert.equal(cfg.serverUrl, serverUrl);
|
|
348
|
+
});
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
// ── Non-fatal sync failure (round-2 review fix) ──────────────────────
|
|
352
|
+
|
|
353
|
+
describe("runInit — non-fatal sync failure", () => {
|
|
354
|
+
beforeEach(setup);
|
|
355
|
+
afterEach(teardown);
|
|
356
|
+
|
|
357
|
+
it("sync failure during init does NOT abort the command", async () => {
|
|
358
|
+
// Round-2 review caught the prior behavior: sync failure warned
|
|
359
|
+
// "run skillrepo update later to retry" but then rethrew the
|
|
360
|
+
// error, so the init command exited non-zero — contradicting
|
|
361
|
+
// its own message. The fix: swallow the sync failure, print
|
|
362
|
+
// the warning, and exit 0 with a synthesized zero-delta summary.
|
|
363
|
+
// The user's config and MCP setup are already persisted; only
|
|
364
|
+
// the skill fetch failed, and `skillrepo update` is the
|
|
365
|
+
// documented recovery path.
|
|
366
|
+
//
|
|
367
|
+
// We simulate the failure by forcing the mock server to return
|
|
368
|
+
// 500 on the library sync endpoint AFTER validate succeeds.
|
|
369
|
+
// setForcedStatus fires once then clears, so the POST /validate
|
|
370
|
+
// in step 2 succeeds with a 200, and the subsequent GET /library
|
|
371
|
+
// hits normal routing — which we break by setting the status
|
|
372
|
+
// mid-run. The simplest approach: set the forced status to a
|
|
373
|
+
// non-200 BEFORE calling init and let it fire on the first
|
|
374
|
+
// non-auth request (the library GET comes after validate).
|
|
375
|
+
//
|
|
376
|
+
// But that would hit validate first. Instead we use a
|
|
377
|
+
// purpose-built "always 500 on /library" by overriding the
|
|
378
|
+
// library-sync response via the mock's setLibraryResponse slot.
|
|
379
|
+
server.setLibraryStatus({ status: 500, body: { error: "Upstream exploded" } });
|
|
380
|
+
|
|
381
|
+
// Init should complete without throwing
|
|
382
|
+
await runInit(
|
|
383
|
+
["--key", VALID_KEY, "--url", serverUrl, "--yes"],
|
|
384
|
+
{ stdout, stderr },
|
|
385
|
+
);
|
|
386
|
+
|
|
387
|
+
// Config IS persisted despite the sync failure
|
|
388
|
+
const cfg = readConfig();
|
|
389
|
+
assert.ok(cfg, "config must be written even when sync fails");
|
|
390
|
+
assert.equal(cfg.apiKey, VALID_KEY);
|
|
391
|
+
|
|
392
|
+
// MCP IS configured despite the sync failure
|
|
393
|
+
assert.ok(existsSync(join(process.cwd(), ".mcp.json")));
|
|
394
|
+
|
|
395
|
+
// Warning is surfaced
|
|
396
|
+
assert.match(stdout.text(), /first sync failed/i);
|
|
397
|
+
assert.match(stdout.text(), /skillrepo update/);
|
|
398
|
+
|
|
399
|
+
// Final "ready" line STILL prints — the init completed
|
|
400
|
+
assert.match(stdout.text(), /SkillRepo is ready/);
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
it("--json sync failure includes failureReason in the JSON payload", async () => {
|
|
404
|
+
server.setLibraryStatus({ status: 500, body: { error: "Upstream exploded" } });
|
|
405
|
+
await runInit(
|
|
406
|
+
["--key", VALID_KEY, "--url", serverUrl, "--yes", "--json"],
|
|
407
|
+
{ stdout, stderr },
|
|
408
|
+
);
|
|
409
|
+
const json = JSON.parse(stdout.text());
|
|
410
|
+
assert.equal(json.action, "initialized");
|
|
411
|
+
// `failureReason` is the sentinel — downstream scripts detect a
|
|
412
|
+
// partial init by `sync.failureReason != null`. The synthesized
|
|
413
|
+
// SyncSummary no longer carries a `failed` field because that
|
|
414
|
+
// wasn't part of the documented SyncSummary typedef; the
|
|
415
|
+
// failure is signalled exclusively via `failureReason`.
|
|
416
|
+
assert.ok(json.sync.failureReason, "sync.failureReason should be present on failure");
|
|
417
|
+
assert.equal(json.sync.added, 0);
|
|
418
|
+
assert.equal(json.sync.updated, 0);
|
|
419
|
+
assert.equal(json.sync.removed, 0);
|
|
420
|
+
assert.equal(json.sync.notModified, false);
|
|
421
|
+
});
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
// ── Stale-key handling (round-1 review fix) ───────────────────────────
|
|
425
|
+
|
|
426
|
+
describe("runInit — stale-key handling", () => {
|
|
427
|
+
beforeEach(setup);
|
|
428
|
+
afterEach(teardown);
|
|
429
|
+
|
|
430
|
+
it("existing config + 401 from validate + --yes → hard failure (no re-prompt)", async () => {
|
|
431
|
+
// Pre-seed an existing config
|
|
432
|
+
mkdirSync(join(process.env.HOME, ".claude", "skillrepo"), { recursive: true });
|
|
433
|
+
writeFileSync(
|
|
434
|
+
join(process.env.HOME, ".claude", "skillrepo", "config.json"),
|
|
435
|
+
JSON.stringify({
|
|
436
|
+
schemaVersion: 1,
|
|
437
|
+
apiKey: VALID_KEY,
|
|
438
|
+
serverUrl,
|
|
439
|
+
}),
|
|
440
|
+
);
|
|
441
|
+
// Force the server to reject the stale key
|
|
442
|
+
server.setForcedStatus(401, { error: "Invalid access key" });
|
|
443
|
+
|
|
444
|
+
// Under --yes (non-interactive), init MUST NOT fall back to
|
|
445
|
+
// promptSecret because there's no interactive stdin. It must
|
|
446
|
+
// surface the auth error directly. This test locks the guard
|
|
447
|
+
// in init.mjs's catch block: the re-prompt path is gated on
|
|
448
|
+
// `!yes` specifically so non-interactive callers (CI, scripts)
|
|
449
|
+
// fail loudly instead of hanging on stdin.
|
|
450
|
+
await assert.rejects(
|
|
451
|
+
() =>
|
|
452
|
+
runInit(
|
|
453
|
+
["--url", serverUrl, "--yes"],
|
|
454
|
+
{ stdout, stderr },
|
|
455
|
+
),
|
|
456
|
+
(err) => err instanceof CliError && err.exitCode === EXIT_AUTH,
|
|
457
|
+
);
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
it("existing config + 401 + --force + --yes still hard-fails (force + yes both gate)", async () => {
|
|
461
|
+
// Pre-seed config
|
|
462
|
+
mkdirSync(join(process.env.HOME, ".claude", "skillrepo"), { recursive: true });
|
|
463
|
+
writeFileSync(
|
|
464
|
+
join(process.env.HOME, ".claude", "skillrepo", "config.json"),
|
|
465
|
+
JSON.stringify({
|
|
466
|
+
schemaVersion: 1,
|
|
467
|
+
apiKey: VALID_KEY,
|
|
468
|
+
serverUrl,
|
|
469
|
+
}),
|
|
470
|
+
);
|
|
471
|
+
server.setForcedStatus(401, { error: "Invalid access key" });
|
|
472
|
+
|
|
473
|
+
// With --force AND --yes, the re-prompt path is gated twice:
|
|
474
|
+
// once by `!force` (the intent of --force is "use exactly what I
|
|
475
|
+
// passed, no fallbacks") and once by `!yes` (non-interactive).
|
|
476
|
+
// Either alone would block re-prompt; this test locks both.
|
|
477
|
+
await assert.rejects(
|
|
478
|
+
() =>
|
|
479
|
+
runInit(
|
|
480
|
+
["--force", "--key", VALID_KEY, "--url", serverUrl, "--yes"],
|
|
481
|
+
{ stdout, stderr },
|
|
482
|
+
),
|
|
483
|
+
(err) => err instanceof CliError && err.exitCode === EXIT_AUTH,
|
|
484
|
+
);
|
|
485
|
+
});
|
|
486
|
+
});
|
|
487
|
+
|
|
488
|
+
// ── Session-sync step 6 (#884) ────────────────────────────────────────
|
|
489
|
+
//
|
|
490
|
+
// INTENT-based coverage of the new step 6 added in v3.1.0. Tests use
|
|
491
|
+
// the PATH-shim trick from session-sync.test.mjs to make
|
|
492
|
+
// `which skillrepo` resolve deterministically: a fake `skillrepo`
|
|
493
|
+
// executable is dropped into `$HOME/bin` and prepended to PATH.
|
|
494
|
+
// Without this, the behavior of these tests would depend on whether
|
|
495
|
+
// a global install exists on the developer's machine.
|
|
496
|
+
//
|
|
497
|
+
// Lower-level installer correctness (hook shape, idempotency,
|
|
498
|
+
// round-trip with remover) is covered in session-hook.test.mjs. These
|
|
499
|
+
// init tests verify the ORCHESTRATION: --yes path, --no-session-sync
|
|
500
|
+
// opt-out, --json output shape, and the non-fatal disk-error path.
|
|
501
|
+
|
|
502
|
+
import { chmodSync as _chmodSync } from "node:fs";
|
|
503
|
+
import { SESSION_HOOK_FINGERPRINT as _FINGERPRINT } from "../../lib/artifact-registry.mjs";
|
|
504
|
+
|
|
505
|
+
async function setupWithShim() {
|
|
506
|
+
await setup();
|
|
507
|
+
// Drop a deterministic `skillrepo` shim into HOME/bin so
|
|
508
|
+
// `which skillrepo` resolves to it rather than a possibly-missing
|
|
509
|
+
// global install.
|
|
510
|
+
const binDir = join(process.env.HOME, "bin");
|
|
511
|
+
mkdirSync(binDir, { recursive: true });
|
|
512
|
+
const shim = join(binDir, "skillrepo");
|
|
513
|
+
writeFileSync(shim, "#!/bin/sh\nexit 0\n");
|
|
514
|
+
_chmodSync(shim, 0o755);
|
|
515
|
+
process.env.PATH = `${binDir}:${process.env.PATH}`;
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
describe("runInit — session sync (#884)", () => {
|
|
519
|
+
beforeEach(setupWithShim);
|
|
520
|
+
afterEach(teardown);
|
|
521
|
+
|
|
522
|
+
it("--yes installs the hook at step 6 by default", async () => {
|
|
523
|
+
// INTENT: the architect-designed default for --yes mode is
|
|
524
|
+
// "install the hook." CI/onboarding scripts passing --yes should
|
|
525
|
+
// get a fully-configured project including session sync.
|
|
526
|
+
await runInit(
|
|
527
|
+
["--key", VALID_KEY, "--url", serverUrl, "--yes"],
|
|
528
|
+
{ stdout, stderr },
|
|
529
|
+
);
|
|
530
|
+
|
|
531
|
+
const settingsPath = join(process.cwd(), ".claude", "settings.local.json");
|
|
532
|
+
assert.ok(existsSync(settingsPath), "settings.local.json must exist");
|
|
533
|
+
|
|
534
|
+
const parsed = JSON.parse(readFileSync(settingsPath, "utf-8"));
|
|
535
|
+
const hasHook = parsed.hooks.SessionStart.flatMap((g) => g.hooks).some(
|
|
536
|
+
(h) => h.command.includes(_FINGERPRINT),
|
|
537
|
+
);
|
|
538
|
+
assert.ok(hasHook, "SkillRepo SessionStart hook must be installed");
|
|
539
|
+
assert.match(stdout.text(), /SessionStart hook installed/);
|
|
540
|
+
});
|
|
541
|
+
|
|
542
|
+
it("--no-session-sync skips the hook install even with --yes", async () => {
|
|
543
|
+
// INTENT: the only way CI scripts that bootstrap a project
|
|
544
|
+
// without starting Claude Code sessions can opt out. Must work
|
|
545
|
+
// alongside --yes (otherwise --yes would force hook install).
|
|
546
|
+
await runInit(
|
|
547
|
+
["--key", VALID_KEY, "--url", serverUrl, "--yes", "--no-session-sync"],
|
|
548
|
+
{ stdout, stderr },
|
|
549
|
+
);
|
|
550
|
+
|
|
551
|
+
const settingsPath = join(process.cwd(), ".claude", "settings.local.json");
|
|
552
|
+
assert.ok(
|
|
553
|
+
!existsSync(settingsPath),
|
|
554
|
+
"settings.local.json must NOT be written under --no-session-sync",
|
|
555
|
+
);
|
|
556
|
+
assert.match(stdout.text(), /Session sync skipped \(--no-session-sync\)/);
|
|
557
|
+
});
|
|
558
|
+
|
|
559
|
+
it("re-running init is idempotent — exactly one hook entry", async () => {
|
|
560
|
+
// INTENT: users re-run init for many reasons (switching keys,
|
|
561
|
+
// updating the config). A duplicate hook would fire sync twice
|
|
562
|
+
// per session — waste at best, race at worst.
|
|
563
|
+
await runInit(
|
|
564
|
+
["--key", VALID_KEY, "--url", serverUrl, "--yes"],
|
|
565
|
+
{ stdout, stderr },
|
|
566
|
+
);
|
|
567
|
+
stdout.clear();
|
|
568
|
+
await runInit(
|
|
569
|
+
["--key", VALID_KEY, "--url", serverUrl, "--yes"],
|
|
570
|
+
{ stdout, stderr },
|
|
571
|
+
);
|
|
572
|
+
|
|
573
|
+
const parsed = JSON.parse(
|
|
574
|
+
readFileSync(
|
|
575
|
+
join(process.cwd(), ".claude", "settings.local.json"),
|
|
576
|
+
"utf-8",
|
|
577
|
+
),
|
|
578
|
+
);
|
|
579
|
+
const skillrepoHooks = parsed.hooks.SessionStart.flatMap(
|
|
580
|
+
(g) => g.hooks,
|
|
581
|
+
).filter((h) => h.command.includes(_FINGERPRINT));
|
|
582
|
+
assert.equal(skillrepoHooks.length, 1, "exactly one SkillRepo hook");
|
|
583
|
+
});
|
|
584
|
+
|
|
585
|
+
it("--json includes a sessionSync block with action + path", async () => {
|
|
586
|
+
// INTENT: automation scripts need to know whether session sync
|
|
587
|
+
// was installed, opted out, or failed. The JSON contract is the
|
|
588
|
+
// machine-readable channel for that.
|
|
589
|
+
await runInit(
|
|
590
|
+
["--key", VALID_KEY, "--url", serverUrl, "--yes", "--json"],
|
|
591
|
+
{ stdout, stderr },
|
|
592
|
+
);
|
|
593
|
+
const json = JSON.parse(stdout.text());
|
|
594
|
+
assert.ok(json.sessionSync, "sessionSync must be in --json output");
|
|
595
|
+
assert.equal(json.sessionSync.action, "installed");
|
|
596
|
+
assert.equal(json.sessionSync.path, ".claude/settings.local.json");
|
|
597
|
+
});
|
|
598
|
+
|
|
599
|
+
it("--json with --no-session-sync reports action: 'opted-out'", async () => {
|
|
600
|
+
await runInit(
|
|
601
|
+
[
|
|
602
|
+
"--key",
|
|
603
|
+
VALID_KEY,
|
|
604
|
+
"--url",
|
|
605
|
+
serverUrl,
|
|
606
|
+
"--yes",
|
|
607
|
+
"--no-session-sync",
|
|
608
|
+
"--json",
|
|
609
|
+
],
|
|
610
|
+
{ stdout, stderr },
|
|
611
|
+
);
|
|
612
|
+
const json = JSON.parse(stdout.text());
|
|
613
|
+
assert.equal(json.sessionSync.action, "opted-out");
|
|
614
|
+
assert.equal(json.sessionSync.path, null);
|
|
615
|
+
});
|
|
616
|
+
|
|
617
|
+
it("skips session sync entirely when only non-Claude-Code IDEs are targeted (cross-PR review fix)", async () => {
|
|
618
|
+
// Cross-PR review flagged: before this guard, a user running
|
|
619
|
+
// `skillrepo init --ide cursor` would get a Claude Code-specific
|
|
620
|
+
// SessionStart hook written to `.claude/settings.local.json`.
|
|
621
|
+
// Cursor never reads that file, so the hook was silent useless
|
|
622
|
+
// state that `skillrepo uninstall` later had to clean up.
|
|
623
|
+
//
|
|
624
|
+
// The guard in init.mjs step 6 now skips the install when
|
|
625
|
+
// `claudeCode` is not in the resolved vendors list AND
|
|
626
|
+
// `--global` is not passed. This test proves the skip fires.
|
|
627
|
+
//
|
|
628
|
+
// Use --ide cursor to force vendors = ["cursor"]. Bypass the
|
|
629
|
+
// .claude/ auto-detection by creating .cursor/ instead.
|
|
630
|
+
mkdirSync(join(process.cwd(), ".cursor"), { recursive: true });
|
|
631
|
+
rmSync(join(process.cwd(), ".claude"), { recursive: true, force: true });
|
|
632
|
+
|
|
633
|
+
await runInit(
|
|
634
|
+
[
|
|
635
|
+
"--key",
|
|
636
|
+
VALID_KEY,
|
|
637
|
+
"--url",
|
|
638
|
+
serverUrl,
|
|
639
|
+
"--yes",
|
|
640
|
+
"--ide",
|
|
641
|
+
"cursor",
|
|
642
|
+
"--json",
|
|
643
|
+
],
|
|
644
|
+
{ stdout, stderr },
|
|
645
|
+
);
|
|
646
|
+
|
|
647
|
+
const json = JSON.parse(stdout.text());
|
|
648
|
+
assert.equal(
|
|
649
|
+
json.sessionSync.action,
|
|
650
|
+
"not-applicable",
|
|
651
|
+
"session sync must report 'not-applicable' for non-Claude-Code targets",
|
|
652
|
+
);
|
|
653
|
+
assert.equal(json.sessionSync.path, null);
|
|
654
|
+
// Critical: the settings.local.json file must NOT have been
|
|
655
|
+
// written. A Cursor user should never see this Claude-specific
|
|
656
|
+
// file materialize from `skillrepo init`.
|
|
657
|
+
assert.ok(
|
|
658
|
+
!existsSync(join(process.cwd(), ".claude", "settings.local.json")),
|
|
659
|
+
".claude/settings.local.json must NOT be written for Cursor-only init",
|
|
660
|
+
);
|
|
661
|
+
});
|
|
662
|
+
|
|
663
|
+
it("still installs session sync under --global even without claudeCode in vendors", async () => {
|
|
664
|
+
// INTENT: `--global` writes to `~/.claude/settings.local.json`,
|
|
665
|
+
// which IS Claude Code's user-wide settings path. A user who
|
|
666
|
+
// runs `skillrepo init --global` (even without `--ide claude`)
|
|
667
|
+
// is implicitly targeting Claude Code. The guard must allow
|
|
668
|
+
// this path so `--global` users still get auto-sync.
|
|
669
|
+
//
|
|
670
|
+
// Note: the setup() helper already creates `.claude/` in the
|
|
671
|
+
// project, which would normally push vendors to include
|
|
672
|
+
// claudeCode. Force vendors = ["cursor"] via --ide to exercise
|
|
673
|
+
// the "--global overrides vendors" branch.
|
|
674
|
+
await runInit(
|
|
675
|
+
[
|
|
676
|
+
"--key",
|
|
677
|
+
VALID_KEY,
|
|
678
|
+
"--url",
|
|
679
|
+
serverUrl,
|
|
680
|
+
"--yes",
|
|
681
|
+
"--global",
|
|
682
|
+
"--ide",
|
|
683
|
+
"cursor",
|
|
684
|
+
"--json",
|
|
685
|
+
],
|
|
686
|
+
{ stdout, stderr },
|
|
687
|
+
);
|
|
688
|
+
|
|
689
|
+
const json = JSON.parse(stdout.text());
|
|
690
|
+
assert.equal(
|
|
691
|
+
json.sessionSync.action,
|
|
692
|
+
"installed",
|
|
693
|
+
"--global must install the hook even when vendors doesn't include claudeCode",
|
|
694
|
+
);
|
|
695
|
+
assert.equal(json.sessionSync.path, "~/.claude/settings.local.json");
|
|
696
|
+
});
|
|
697
|
+
});
|