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,322 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit/integration tests for src/commands/update.mjs (PR2 of #646).
|
|
3
|
+
*
|
|
4
|
+
* Tests run the runUpdate function directly against an in-process mock
|
|
5
|
+
* server. Captures stdout via a stream override.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { describe, it, beforeEach, afterEach } from "node:test";
|
|
9
|
+
import assert from "node:assert/strict";
|
|
10
|
+
import { mkdtempSync, mkdirSync, rmSync, existsSync, writeFileSync } from "node:fs";
|
|
11
|
+
import { join } from "node:path";
|
|
12
|
+
import { tmpdir } from "node:os";
|
|
13
|
+
|
|
14
|
+
import { runUpdate } from "../../commands/update.mjs";
|
|
15
|
+
import { resolvePlacementDir } from "../../lib/file-write.mjs";
|
|
16
|
+
import { CliError, EXIT_AUTH, EXIT_VALIDATION } from "../../lib/errors.mjs";
|
|
17
|
+
import { createMockServer } from "../e2e/mock-server.mjs";
|
|
18
|
+
import { createCaptureStream } from "../helpers/capture-stream.mjs";
|
|
19
|
+
|
|
20
|
+
let sandbox;
|
|
21
|
+
let server;
|
|
22
|
+
let serverUrl;
|
|
23
|
+
let originalCwd;
|
|
24
|
+
let originalHome;
|
|
25
|
+
let stdout;
|
|
26
|
+
const VALID_KEY = "sk_live_test";
|
|
27
|
+
|
|
28
|
+
function makeSkill(name) {
|
|
29
|
+
return {
|
|
30
|
+
owner: "alice",
|
|
31
|
+
name,
|
|
32
|
+
version: "1.0.0",
|
|
33
|
+
description: `${name} description`,
|
|
34
|
+
files: [
|
|
35
|
+
{
|
|
36
|
+
path: "SKILL.md",
|
|
37
|
+
content: `---\nname: ${name}\ndescription: ${name} description\n---\n\nbody\n`,
|
|
38
|
+
sha256: "x",
|
|
39
|
+
size: 50,
|
|
40
|
+
contentType: "text/markdown",
|
|
41
|
+
},
|
|
42
|
+
],
|
|
43
|
+
updatedAt: new Date().toISOString(),
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function setup() {
|
|
48
|
+
sandbox = mkdtempSync(join(tmpdir(), "cli-cmd-update-"));
|
|
49
|
+
mkdirSync(join(sandbox, "project"), { recursive: true });
|
|
50
|
+
mkdirSync(join(sandbox, "home"), { recursive: true });
|
|
51
|
+
originalCwd = process.cwd();
|
|
52
|
+
originalHome = process.env.HOME;
|
|
53
|
+
process.chdir(join(sandbox, "project"));
|
|
54
|
+
process.env.HOME = join(sandbox, "home");
|
|
55
|
+
delete process.env.SKILLREPO_ACCESS_KEY;
|
|
56
|
+
delete process.env.SKILLREPO_URL;
|
|
57
|
+
|
|
58
|
+
server = createMockServer({});
|
|
59
|
+
const port = await server.start();
|
|
60
|
+
serverUrl = `http://127.0.0.1:${port}`;
|
|
61
|
+
|
|
62
|
+
stdout = createCaptureStream();
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function teardown() {
|
|
66
|
+
if (server) await server.stop();
|
|
67
|
+
process.chdir(originalCwd);
|
|
68
|
+
process.env.HOME = originalHome;
|
|
69
|
+
if (sandbox) rmSync(sandbox, { recursive: true, force: true });
|
|
70
|
+
server = null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
describe("runUpdate — happy path", () => {
|
|
74
|
+
beforeEach(setup);
|
|
75
|
+
afterEach(teardown);
|
|
76
|
+
|
|
77
|
+
it("syncs an empty library and prints up-to-date", async () => {
|
|
78
|
+
server.setLibraryResponse({ skills: [], removals: [], syncedAt: "x" });
|
|
79
|
+
await runUpdate(["--key", VALID_KEY, "--url", serverUrl], { stdout });
|
|
80
|
+
assert.match(stdout.text(), /up to date/);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("syncs a single skill and prints add count", async () => {
|
|
84
|
+
server.setLibraryResponse({
|
|
85
|
+
skills: [makeSkill("first")],
|
|
86
|
+
removals: [],
|
|
87
|
+
syncedAt: "x",
|
|
88
|
+
});
|
|
89
|
+
await runUpdate(["--key", VALID_KEY, "--url", serverUrl], { stdout });
|
|
90
|
+
assert.match(stdout.text(), /1 added/);
|
|
91
|
+
assert.ok(existsSync(resolvePlacementDir("claudeProject", "first")));
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("--json outputs structured summary", async () => {
|
|
95
|
+
server.setLibraryResponse({
|
|
96
|
+
skills: [makeSkill("first"), makeSkill("second")],
|
|
97
|
+
removals: [],
|
|
98
|
+
syncedAt: "x",
|
|
99
|
+
});
|
|
100
|
+
await runUpdate(["--key", VALID_KEY, "--url", serverUrl, "--json"], { stdout });
|
|
101
|
+
const json = JSON.parse(stdout.text());
|
|
102
|
+
assert.equal(json.added, 2);
|
|
103
|
+
assert.equal(json.removed, 0);
|
|
104
|
+
assert.equal(json.notModified, false);
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
describe("runUpdate — credential resolution", () => {
|
|
109
|
+
beforeEach(setup);
|
|
110
|
+
afterEach(teardown);
|
|
111
|
+
|
|
112
|
+
it("reads from the global config file when no flags", async () => {
|
|
113
|
+
mkdirSync(join(process.env.HOME, ".claude", "skillrepo"), { recursive: true });
|
|
114
|
+
writeFileSync(
|
|
115
|
+
join(process.env.HOME, ".claude", "skillrepo", "config.json"),
|
|
116
|
+
JSON.stringify({ apiKey: VALID_KEY, serverUrl }),
|
|
117
|
+
);
|
|
118
|
+
server.setLibraryResponse({ skills: [], removals: [], syncedAt: "x" });
|
|
119
|
+
await runUpdate([], { stdout });
|
|
120
|
+
assert.match(stdout.text(), /up to date/);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("throws authError when no key and no config", async () => {
|
|
124
|
+
await assert.rejects(
|
|
125
|
+
() => runUpdate([]),
|
|
126
|
+
(err) => err instanceof CliError && err.exitCode === EXIT_AUTH,
|
|
127
|
+
);
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
describe("runUpdate — flag handling", () => {
|
|
132
|
+
beforeEach(setup);
|
|
133
|
+
afterEach(teardown);
|
|
134
|
+
|
|
135
|
+
it("rejects unknown flag", async () => {
|
|
136
|
+
await assert.rejects(
|
|
137
|
+
() => runUpdate(["--key", VALID_KEY, "--url", serverUrl, "--bogus"]),
|
|
138
|
+
(err) => err instanceof CliError && err.exitCode === EXIT_VALIDATION,
|
|
139
|
+
);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it("--global writes to the global skills dir", async () => {
|
|
143
|
+
server.setLibraryResponse({
|
|
144
|
+
skills: [makeSkill("global-test")],
|
|
145
|
+
removals: [],
|
|
146
|
+
syncedAt: "x",
|
|
147
|
+
});
|
|
148
|
+
await runUpdate(["--key", VALID_KEY, "--url", serverUrl, "--global"]);
|
|
149
|
+
const dir = resolvePlacementDir("claudeGlobal", "global-test");
|
|
150
|
+
assert.ok(existsSync(dir));
|
|
151
|
+
assert.ok(dir.startsWith(process.env.HOME));
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it("--ide cursor writes to the project /skills/ fallback", async () => {
|
|
155
|
+
server.setLibraryResponse({
|
|
156
|
+
skills: [makeSkill("cursor-test")],
|
|
157
|
+
removals: [],
|
|
158
|
+
syncedAt: "x",
|
|
159
|
+
});
|
|
160
|
+
await runUpdate(["--key", VALID_KEY, "--url", serverUrl, "--ide", "cursor"]);
|
|
161
|
+
const dir = resolvePlacementDir("projectFallback", "cursor-test");
|
|
162
|
+
assert.ok(existsSync(dir));
|
|
163
|
+
});
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
// ── --session-hook mode (#884) ────────────────────────────────────────
|
|
167
|
+
//
|
|
168
|
+
// Session-hook mode is what the Claude Code SessionStart hook #884's
|
|
169
|
+
// installer writes invokes: `skillrepo update --session-hook`. The
|
|
170
|
+
// contract: exit 0 on ALL errors, silent on 304, one-line summary on
|
|
171
|
+
// changes, one-line failure message on error. A sync failure must
|
|
172
|
+
// NEVER block a session start.
|
|
173
|
+
//
|
|
174
|
+
// Architect pre-flight review flagged the flag-acceptance bug as
|
|
175
|
+
// "most likely silent-failure bug" — if `resolveFlags` rejects the
|
|
176
|
+
// flag before the exit-0 contract activates, every session-start
|
|
177
|
+
// hook silently fails with a symptom indistinguishable from 304.
|
|
178
|
+
// Tests in this suite specifically guard against that.
|
|
179
|
+
|
|
180
|
+
describe("runUpdate — --session-hook contract", () => {
|
|
181
|
+
beforeEach(setup);
|
|
182
|
+
afterEach(teardown);
|
|
183
|
+
|
|
184
|
+
it("accepts the --session-hook flag without throwing a validation error", async () => {
|
|
185
|
+
// THE architect-flagged regression guard. If a future refactor
|
|
186
|
+
// removes the acceptPositional callback from update.mjs,
|
|
187
|
+
// resolveFlags throws `Unknown argument: --session-hook` before
|
|
188
|
+
// the exit-0 contract has a chance to fire. The `|| true` shell
|
|
189
|
+
// backstop would catch it, but the user would lose the failure
|
|
190
|
+
// message AND every sync would silently no-op. This test makes
|
|
191
|
+
// that class of regression fail loudly at unit-test time.
|
|
192
|
+
server.setLibraryResponse({ skills: [], removals: [], syncedAt: "x" });
|
|
193
|
+
await assert.doesNotReject(
|
|
194
|
+
() =>
|
|
195
|
+
runUpdate(
|
|
196
|
+
["--key", VALID_KEY, "--url", serverUrl, "--session-hook"],
|
|
197
|
+
{ stdout },
|
|
198
|
+
),
|
|
199
|
+
"update --session-hook must NOT throw Unknown argument",
|
|
200
|
+
);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it("is silent on 304 / up-to-date (no 'Syncing' output every session)", async () => {
|
|
204
|
+
// INTENT: 304 means nothing changed. Printing "Syncing..." on
|
|
205
|
+
// every session start with no value to show would clutter the
|
|
206
|
+
// system-message surface. The contract is SILENCE on 304.
|
|
207
|
+
server.setLibraryResponse({ skills: [], removals: [], syncedAt: "x" });
|
|
208
|
+
await runUpdate(
|
|
209
|
+
["--key", VALID_KEY, "--url", serverUrl, "--session-hook"],
|
|
210
|
+
{ stdout },
|
|
211
|
+
);
|
|
212
|
+
assert.equal(
|
|
213
|
+
stdout.text(),
|
|
214
|
+
"",
|
|
215
|
+
"session-hook mode must emit zero output when there's nothing to sync",
|
|
216
|
+
);
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it("prints one line on successful sync with changes", async () => {
|
|
220
|
+
// INTENT: when content CHANGES, surface exactly one actionable
|
|
221
|
+
// line so the user knows their library updated. No banners, no
|
|
222
|
+
// multi-line output — a single line the hook runner can render
|
|
223
|
+
// as a system message.
|
|
224
|
+
server.setLibraryResponse({
|
|
225
|
+
skills: [makeSkill("sync-hook-test")],
|
|
226
|
+
removals: [],
|
|
227
|
+
syncedAt: "x",
|
|
228
|
+
});
|
|
229
|
+
await runUpdate(
|
|
230
|
+
["--key", VALID_KEY, "--url", serverUrl, "--session-hook"],
|
|
231
|
+
{ stdout },
|
|
232
|
+
);
|
|
233
|
+
const out = stdout.text();
|
|
234
|
+
assert.match(out, /^\[SkillRepo\] Library synced: \d+ added, \d+ updated, \d+ removed\.\n$/);
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it("exits 0 with a failure message on a server error instead of throwing", async () => {
|
|
238
|
+
// THE load-bearing contract. A sync failure must NEVER block a
|
|
239
|
+
// session start. runUpdate called with --session-hook against an
|
|
240
|
+
// unreachable server must return normally (no throw) and emit a
|
|
241
|
+
// one-line failure message.
|
|
242
|
+
//
|
|
243
|
+
// We simulate an unreachable server by pointing at an invalid
|
|
244
|
+
// port. Without the exit-0 contract, this would throw
|
|
245
|
+
// networkError from runSync; WITH the contract, the catch block
|
|
246
|
+
// swallows it and prints a failure message.
|
|
247
|
+
await assert.doesNotReject(
|
|
248
|
+
() =>
|
|
249
|
+
runUpdate(
|
|
250
|
+
["--key", VALID_KEY, "--url", "http://127.0.0.1:1", "--session-hook"],
|
|
251
|
+
{ stdout },
|
|
252
|
+
),
|
|
253
|
+
"session-hook mode must NEVER throw — session start must proceed",
|
|
254
|
+
);
|
|
255
|
+
const out = stdout.text();
|
|
256
|
+
assert.match(out, /^\[SkillRepo\] Sync failed: .+\n$/);
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
it("exits 0 with a failure message on auth error (invalid key)", async () => {
|
|
260
|
+
// Auth error is non-retryable AND non-blocking for session start.
|
|
261
|
+
// If the user's key was rotated, we want them to see "access key
|
|
262
|
+
// invalid" in their session system message, but the session must
|
|
263
|
+
// still open normally.
|
|
264
|
+
server.setForcedStatus(401, { error: "Invalid access key" });
|
|
265
|
+
await assert.doesNotReject(
|
|
266
|
+
() =>
|
|
267
|
+
runUpdate(
|
|
268
|
+
["--key", VALID_KEY, "--url", serverUrl, "--session-hook"],
|
|
269
|
+
{ stdout },
|
|
270
|
+
),
|
|
271
|
+
);
|
|
272
|
+
const out = stdout.text();
|
|
273
|
+
assert.match(out, /Sync failed/);
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
it("exits 0 with a failure message when no access key is configured", async () => {
|
|
277
|
+
// INTENT: the VERY first session after installing skillrepo, if
|
|
278
|
+
// init hasn't run yet, has no key. The hook must not block the
|
|
279
|
+
// session. This is a genuine first-run scenario, not a synthetic
|
|
280
|
+
// edge case — users who set up Claude Code before running
|
|
281
|
+
// `skillrepo init` will hit exactly this.
|
|
282
|
+
//
|
|
283
|
+
// No --key flag, no cached config, no env var. resolveFlags
|
|
284
|
+
// normally throws authError in this case. Session-hook mode
|
|
285
|
+
// must catch that and exit 0 anyway.
|
|
286
|
+
await assert.doesNotReject(
|
|
287
|
+
() =>
|
|
288
|
+
runUpdate(["--url", serverUrl, "--session-hook"], { stdout }),
|
|
289
|
+
"session-hook mode with no key must NEVER throw",
|
|
290
|
+
);
|
|
291
|
+
const out = stdout.text();
|
|
292
|
+
assert.match(out, /Sync failed/, "failure message must still surface");
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
it("exits 0 when the --session-hook flag appears after other flags", async () => {
|
|
296
|
+
// INTENT: flag ordering is not part of the contract. The installer
|
|
297
|
+
// writes a specific order today, but a user hand-editing their
|
|
298
|
+
// settings (or a future refactor moving flags around) must not
|
|
299
|
+
// break the exit-0 behavior.
|
|
300
|
+
server.setLibraryResponse({ skills: [], removals: [], syncedAt: "x" });
|
|
301
|
+
await assert.doesNotReject(
|
|
302
|
+
() =>
|
|
303
|
+
runUpdate(
|
|
304
|
+
["--session-hook", "--key", VALID_KEY, "--url", serverUrl],
|
|
305
|
+
{ stdout },
|
|
306
|
+
),
|
|
307
|
+
);
|
|
308
|
+
assert.equal(stdout.text(), "", "still silent on 304 regardless of flag order");
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
it("exits 0 silently when the server returns zero deltas (empty success, not 304)", async () => {
|
|
312
|
+
// INTENT: 304 isn't the only silent-success case. A 200 response
|
|
313
|
+
// with zero added/updated/removed (a fresh sync that happened to
|
|
314
|
+
// produce no work) is also silent — same UX reasoning as 304.
|
|
315
|
+
server.setLibraryResponse({ skills: [], removals: [], syncedAt: "x" });
|
|
316
|
+
await runUpdate(
|
|
317
|
+
["--key", VALID_KEY, "--url", serverUrl, "--session-hook"],
|
|
318
|
+
{ stdout },
|
|
319
|
+
);
|
|
320
|
+
assert.equal(stdout.text(), "", "zero-delta 200 is silent");
|
|
321
|
+
});
|
|
322
|
+
});
|
|
@@ -53,18 +53,13 @@ describe("IDE detection", () => {
|
|
|
53
53
|
// windsurf depends on home dir, skip assertion
|
|
54
54
|
});
|
|
55
55
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
const { getDetectedIdeKeys } = await import("../lib/detect-ides.mjs");
|
|
66
|
-
|
|
67
|
-
const keys = getDetectedIdeKeys({ claudeCode: true, cursor: false, windsurf: false, vscode: true });
|
|
68
|
-
assert.deepEqual(keys, ["claudeCode", "vscode"]);
|
|
69
|
-
});
|
|
56
|
+
// PR4 cross-review cleanup: the old v2.0.0 `getDetectedIdeKeys`
|
|
57
|
+
// helper with a "default to claudeCode + cursor when nothing
|
|
58
|
+
// detected" fallback was dead code — the plan explicitly
|
|
59
|
+
// removed the silent fallback in PR3b, and no command called
|
|
60
|
+
// this helper. It was exported and had tests asserting the
|
|
61
|
+
// v2.0.0 behavior, which contradicted the actual v3.0.0 code
|
|
62
|
+
// path. Deleted; init now refuses with a --ide hint when
|
|
63
|
+
// nothing is detected (see the "refuses with clear error when
|
|
64
|
+
// no IDE detected and no --ide flag" test in init.test.mjs).
|
|
70
65
|
});
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for bin/skillrepo.mjs (PR1 of #646).
|
|
3
|
+
*
|
|
4
|
+
* Spawns the real binary as a subprocess and asserts on stdout, stderr,
|
|
5
|
+
* and exit code for each documented routing path. Intentionally thin —
|
|
6
|
+
* the dispatcher should be a pure routing layer, so most logic lives in
|
|
7
|
+
* the command modules tested elsewhere.
|
|
8
|
+
*
|
|
9
|
+
* IMPORTANT: keep these tests fast — they run as part of `npm run check`
|
|
10
|
+
* and a slow dispatcher suite slows down every commit.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { describe, it } from "node:test";
|
|
14
|
+
import assert from "node:assert/strict";
|
|
15
|
+
import { execFile } from "node:child_process";
|
|
16
|
+
import { fileURLToPath } from "node:url";
|
|
17
|
+
import { dirname, resolve } from "node:path";
|
|
18
|
+
import { readFileSync } from "node:fs";
|
|
19
|
+
|
|
20
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
21
|
+
const CLI_BIN = resolve(__dirname, "../../bin/skillrepo.mjs");
|
|
22
|
+
const PKG_PATH = resolve(__dirname, "../../package.json");
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Run the CLI dispatcher and capture stdout, stderr, and exit code.
|
|
26
|
+
* Always resolves — never rejects — so tests can assert on failures.
|
|
27
|
+
*
|
|
28
|
+
* @param {string[]} args
|
|
29
|
+
* @param {object} [opts]
|
|
30
|
+
* @param {object} [opts.env] - Extra env vars (merged onto process.env).
|
|
31
|
+
* Useful for tests that need to point HOME at an empty dir to
|
|
32
|
+
* prove "no global config" code paths fire correctly. Pass
|
|
33
|
+
* SKILLREPO_ACCESS_KEY: "" explicitly to override an inherited
|
|
34
|
+
* env var from the developer's shell.
|
|
35
|
+
*/
|
|
36
|
+
function runCli(args = [], opts = {}) {
|
|
37
|
+
return new Promise((resolve) => {
|
|
38
|
+
const baseEnv = {
|
|
39
|
+
...process.env,
|
|
40
|
+
NO_COLOR: "1",
|
|
41
|
+
// Default tests to "no key in env" so the no-config code paths
|
|
42
|
+
// are exercised reliably across developer machines that may
|
|
43
|
+
// have SKILLREPO_ACCESS_KEY set in their shell.
|
|
44
|
+
SKILLREPO_ACCESS_KEY: "",
|
|
45
|
+
// Short timeout so the network-failure case fails fast rather
|
|
46
|
+
// than waiting 30s on the default safeFetch timeout.
|
|
47
|
+
SKILLREPO_TIMEOUT_MS: "2000",
|
|
48
|
+
};
|
|
49
|
+
execFile(
|
|
50
|
+
process.execPath,
|
|
51
|
+
[CLI_BIN, ...args],
|
|
52
|
+
{
|
|
53
|
+
encoding: "utf-8",
|
|
54
|
+
timeout: 10_000,
|
|
55
|
+
env: { ...baseEnv, ...(opts.env || {}) },
|
|
56
|
+
},
|
|
57
|
+
(err, stdout, stderr) => {
|
|
58
|
+
resolve({
|
|
59
|
+
stdout: stdout ?? "",
|
|
60
|
+
stderr: stderr ?? "",
|
|
61
|
+
status: err ? err.code ?? 1 : 0,
|
|
62
|
+
});
|
|
63
|
+
},
|
|
64
|
+
);
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
describe("dispatcher — top-level help", () => {
|
|
69
|
+
it("`skillrepo` with no args prints top-level help and exits 0", async () => {
|
|
70
|
+
const r = await runCli([]);
|
|
71
|
+
assert.equal(r.status, 0);
|
|
72
|
+
assert.match(r.stdout, /SkillRepo CLI/);
|
|
73
|
+
// All 7 commands listed
|
|
74
|
+
for (const cmd of ["init", "update", "get", "add", "remove", "list", "search"]) {
|
|
75
|
+
assert.match(r.stdout, new RegExp(`\\b${cmd}\\b`), `expected to see "${cmd}" in help`);
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("`skillrepo --help` is the same as no args", async () => {
|
|
80
|
+
const r = await runCli(["--help"]);
|
|
81
|
+
assert.equal(r.status, 0);
|
|
82
|
+
assert.match(r.stdout, /SkillRepo CLI/);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("`skillrepo -h` is the same as --help", async () => {
|
|
86
|
+
const r = await runCli(["-h"]);
|
|
87
|
+
assert.equal(r.status, 0);
|
|
88
|
+
assert.match(r.stdout, /SkillRepo CLI/);
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
describe("dispatcher — version", () => {
|
|
93
|
+
it("`skillrepo --version` prints the package.json version", async () => {
|
|
94
|
+
const r = await runCli(["--version"]);
|
|
95
|
+
assert.equal(r.status, 0);
|
|
96
|
+
const pkg = JSON.parse(readFileSync(PKG_PATH, "utf-8"));
|
|
97
|
+
assert.equal(r.stdout.trim(), pkg.version);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("`skillrepo -v` is the same as --version", async () => {
|
|
101
|
+
const r = await runCli(["-v"]);
|
|
102
|
+
assert.equal(r.status, 0);
|
|
103
|
+
const pkg = JSON.parse(readFileSync(PKG_PATH, "utf-8"));
|
|
104
|
+
assert.equal(r.stdout.trim(), pkg.version);
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
describe("dispatcher — unknown command", () => {
|
|
109
|
+
it("exits with EXIT_VALIDATION (5) and prints a hint", async () => {
|
|
110
|
+
const r = await runCli(["nonsense"]);
|
|
111
|
+
assert.equal(r.status, 5);
|
|
112
|
+
assert.match(r.stderr, /Unknown command/);
|
|
113
|
+
assert.match(r.stderr, /skillrepo --help/);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("rejects unknown flags as commands too", async () => {
|
|
117
|
+
const r = await runCli(["--bogus"]);
|
|
118
|
+
assert.equal(r.status, 5);
|
|
119
|
+
assert.match(r.stderr, /Unknown command/);
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
describe("dispatcher — per-command help", () => {
|
|
124
|
+
// PR1 only ships init for real; the other 6 are stubs but still
|
|
125
|
+
// route --help correctly.
|
|
126
|
+
for (const cmd of ["init", "update", "get", "add", "remove", "list", "search"]) {
|
|
127
|
+
it(`\`skillrepo ${cmd} --help\` prints command-specific help`, async () => {
|
|
128
|
+
const r = await runCli([cmd, "--help"]);
|
|
129
|
+
assert.equal(r.status, 0);
|
|
130
|
+
assert.match(r.stdout, new RegExp(`skillrepo ${cmd}`));
|
|
131
|
+
assert.match(r.stdout, /Usage:/);
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
// All 7 commands are implemented as of PR3a. No stubs remain.
|
|
137
|
+
// (PR1 shipped init; PR2 shipped update/get/list/search; PR3a ships add/remove.)
|
|
138
|
+
|
|
139
|
+
describe("dispatcher — implemented commands route to their modules", () => {
|
|
140
|
+
// These commands have real implementations as of PR2. The dispatcher
|
|
141
|
+
// routing test verifies the binary doesn't fall through to the stub
|
|
142
|
+
// factory — we expect a "real" failure (validation/auth/network)
|
|
143
|
+
// rather than the "Not yet implemented" stub message.
|
|
144
|
+
//
|
|
145
|
+
// Each command is invoked WITHOUT credentials in a directory where
|
|
146
|
+
// there is no global config, so the expected outcome is an authError
|
|
147
|
+
// (exit 2, "No access key configured") OR a validationError (exit 5)
|
|
148
|
+
// for commands that require a positional argument.
|
|
149
|
+
it("`skillrepo update` is wired to the real module (not a stub)", async () => {
|
|
150
|
+
const r = await runCli(["update"], { env: { HOME: "/tmp/no-skillrepo-config" } });
|
|
151
|
+
assert.doesNotMatch(r.stderr, /Not yet implemented/);
|
|
152
|
+
// Either auth (no key) or network (no server) — both prove routing worked
|
|
153
|
+
assert.ok([1, 2, 5].includes(r.status), `expected 1/2/5, got ${r.status}`);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it("`skillrepo get` is wired to the real module (not a stub)", async () => {
|
|
157
|
+
const r = await runCli(["get", "@alice/pdf-helper"], { env: { HOME: "/tmp/no-skillrepo-config" } });
|
|
158
|
+
assert.doesNotMatch(r.stderr, /Not yet implemented/);
|
|
159
|
+
assert.ok([1, 2, 5].includes(r.status));
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it("`skillrepo get` without an identifier exits 5 with a usage hint", async () => {
|
|
163
|
+
// Missing positional should fail validation BEFORE attempting
|
|
164
|
+
// credential resolution — proves the dispatcher passes argv
|
|
165
|
+
// through and the command's positional check fires.
|
|
166
|
+
const r = await runCli(["get"], { env: { HOME: "/tmp/no-skillrepo-config" } });
|
|
167
|
+
assert.doesNotMatch(r.stderr, /Not yet implemented/);
|
|
168
|
+
// Either validation (missing positional) or auth (no key, depending
|
|
169
|
+
// on which check fires first) — both prove the stub is gone.
|
|
170
|
+
assert.ok([2, 5].includes(r.status));
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it("`skillrepo list` is wired to the real module (not a stub)", async () => {
|
|
174
|
+
const r = await runCli(["list"], { env: { HOME: "/tmp/no-skillrepo-config" } });
|
|
175
|
+
assert.doesNotMatch(r.stderr, /Not yet implemented/);
|
|
176
|
+
assert.ok([1, 2, 5].includes(r.status));
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it("`skillrepo search` is wired to the real module (not a stub)", async () => {
|
|
180
|
+
const r = await runCli(["search", "test"], { env: { HOME: "/tmp/no-skillrepo-config" } });
|
|
181
|
+
assert.doesNotMatch(r.stderr, /Not yet implemented/);
|
|
182
|
+
assert.ok([1, 2, 5].includes(r.status));
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it("`skillrepo search` without a query exits with a usage hint", async () => {
|
|
186
|
+
const r = await runCli(["search"], { env: { HOME: "/tmp/no-skillrepo-config" } });
|
|
187
|
+
assert.doesNotMatch(r.stderr, /Not yet implemented/);
|
|
188
|
+
assert.ok([2, 5].includes(r.status));
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it("`skillrepo add` is wired to the real module (not a stub)", async () => {
|
|
192
|
+
const r = await runCli(["add", "@alice/pdf-helper"], { env: { HOME: "/tmp/no-skillrepo-config" } });
|
|
193
|
+
assert.doesNotMatch(r.stderr, /Not yet implemented/);
|
|
194
|
+
assert.ok([1, 2, 5].includes(r.status));
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it("`skillrepo remove` is wired to the real module (not a stub)", async () => {
|
|
198
|
+
const r = await runCli(["remove", "@alice/pdf-helper"], { env: { HOME: "/tmp/no-skillrepo-config" } });
|
|
199
|
+
assert.doesNotMatch(r.stderr, /Not yet implemented/);
|
|
200
|
+
assert.ok([1, 2, 5].includes(r.status));
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it("`skillrepo add` without identifier exits 2 or 5 (usage hint)", async () => {
|
|
204
|
+
const r = await runCli(["add"], { env: { HOME: "/tmp/no-skillrepo-config" } });
|
|
205
|
+
assert.doesNotMatch(r.stderr, /Not yet implemented/);
|
|
206
|
+
assert.ok([2, 5].includes(r.status));
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it("`skillrepo remove` without identifier exits 2 or 5 (usage hint)", async () => {
|
|
210
|
+
const r = await runCli(["remove"], { env: { HOME: "/tmp/no-skillrepo-config" } });
|
|
211
|
+
assert.doesNotMatch(r.stderr, /Not yet implemented/);
|
|
212
|
+
assert.ok([2, 5].includes(r.status));
|
|
213
|
+
});
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
describe("dispatcher — init still works (PR1 keeps existing init untouched)", () => {
|
|
217
|
+
it("`skillrepo init --help` prints init help", async () => {
|
|
218
|
+
const r = await runCli(["init", "--help"]);
|
|
219
|
+
assert.equal(r.status, 0);
|
|
220
|
+
assert.match(r.stdout, /skillrepo init/);
|
|
221
|
+
});
|
|
222
|
+
// Real init flow is exercised by src/test/e2e/cli-init.test.mjs
|
|
223
|
+
// against the mock server. We don't duplicate that here.
|
|
224
|
+
});
|