skillrepo 2.0.0 → 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +215 -150
- package/bin/skillrepo.mjs +210 -36
- package/package.json +6 -3
- package/src/commands/add.mjs +176 -0
- package/src/commands/get.mjs +116 -0
- package/src/commands/init.mjs +471 -143
- package/src/commands/list.mjs +176 -0
- package/src/commands/remove.mjs +167 -0
- package/src/commands/search.mjs +188 -0
- package/src/commands/update.mjs +67 -0
- package/src/lib/cli-config.mjs +230 -0
- package/src/lib/config.mjs +238 -0
- package/src/lib/detect-ides.mjs +0 -19
- package/src/lib/errors.mjs +264 -0
- package/src/lib/file-write.mjs +705 -0
- package/src/lib/http.mjs +817 -37
- package/src/lib/identifier.mjs +153 -0
- package/src/lib/mcp-merge.mjs +275 -0
- package/src/lib/mergers/gitignore.mjs +73 -18
- package/src/lib/paths.mjs +46 -17
- package/src/lib/prompt.mjs +11 -44
- package/src/lib/sync.mjs +305 -0
- package/src/test/commands/add.test.mjs +285 -0
- package/src/test/commands/get.test.mjs +176 -0
- package/src/test/commands/init.test.mjs +486 -0
- package/src/test/commands/list.test.mjs +172 -0
- package/src/test/commands/remove.test.mjs +234 -0
- package/src/test/commands/search.test.mjs +204 -0
- package/src/test/commands/update.test.mjs +164 -0
- package/src/test/detect-ides.test.mjs +9 -14
- package/src/test/dispatcher.test.mjs +224 -0
- package/src/test/e2e/cli-commands.test.mjs +576 -0
- package/src/test/e2e/mock-server.mjs +364 -22
- package/src/test/helpers/capture-stream.mjs +48 -0
- package/src/test/integration/file-write.integration.test.mjs +279 -0
- package/src/test/lib/cli-config.test.mjs +407 -0
- package/src/test/lib/config.test.mjs +257 -0
- package/src/test/lib/errors.test.mjs +359 -0
- package/src/test/lib/file-write.test.mjs +784 -0
- package/src/test/lib/http.test.mjs +1198 -0
- package/src/test/lib/identifier.test.mjs +157 -0
- package/src/test/lib/mcp-merge.test.mjs +345 -0
- package/src/test/lib/paths.test.mjs +83 -0
- package/src/test/lib/sync.test.mjs +514 -0
- package/src/test/mergers/gitignore.test.mjs +145 -20
- package/src/lib/write-configs.mjs +0 -202
- package/src/test/e2e/HANDOFF.md +0 -223
- package/src/test/e2e/cli-init.test.mjs +0 -213
- package/src/test/e2e/payload-factory.mjs +0 -22
|
@@ -0,0 +1,1198 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for src/lib/http.mjs (PR2 of #646).
|
|
3
|
+
*
|
|
4
|
+
* Strategy: spin up a minimal in-process HTTP server per test (or per
|
|
5
|
+
* suite when shape is identical) and point the http.mjs functions at
|
|
6
|
+
* it. This is the same pattern as the existing E2E mock-server, scoped
|
|
7
|
+
* down to the unit-test layer so we exercise every status-code branch
|
|
8
|
+
* and every error contract without spawning a subprocess.
|
|
9
|
+
*
|
|
10
|
+
* Coverage targets:
|
|
11
|
+
* • validateAccessKey: 200 happy, 401, 403 (suspended), 403 (scope),
|
|
12
|
+
* 403 (plan_limit), 404 (route missing), 429, 500, network failure
|
|
13
|
+
* • getLibrary: 200 fresh, 304 short-circuit, since param, ETag echo,
|
|
14
|
+
* 400 invalid since, 401, 5xx, missing fields tolerance
|
|
15
|
+
* • getSkill: 200 happy, 200 with `skill: null`, 404 (returns null),
|
|
16
|
+
* 401, 403 (scope), encodeURIComponent applied
|
|
17
|
+
* • searchSkills: 200 happy with results, 200 empty, query
|
|
18
|
+
* encoding, limit/offset/sort, 401
|
|
19
|
+
* • mapErrorResponse: 401 → authError, 403 plan_limit → validationError,
|
|
20
|
+
* 403 scope → scopeError, 403 generic → authError, 404 → null,
|
|
21
|
+
* 429 → networkError, 5xx → networkError, generic 4xx → validationError
|
|
22
|
+
* • Edge cases: empty apiKey rejected upfront, empty serverUrl
|
|
23
|
+
* rejected, non-JSON success body wrapped as networkError, AbortError
|
|
24
|
+
* wrapped as networkError, SKILLREPO_TIMEOUT_MS environment handling
|
|
25
|
+
*
|
|
26
|
+
* `parseJsonOrThrow` and `mapErrorResponse` are not exported; we
|
|
27
|
+
* exercise them indirectly via the public functions.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import { describe, it } from "node:test";
|
|
31
|
+
import assert from "node:assert/strict";
|
|
32
|
+
import { createServer } from "node:http";
|
|
33
|
+
import { once } from "node:events";
|
|
34
|
+
|
|
35
|
+
import {
|
|
36
|
+
validateAccessKey,
|
|
37
|
+
getLibrary,
|
|
38
|
+
getSkill,
|
|
39
|
+
searchSkills,
|
|
40
|
+
addSkillToLibrary,
|
|
41
|
+
removeSkillFromLibrary,
|
|
42
|
+
} from "../../lib/http.mjs";
|
|
43
|
+
import {
|
|
44
|
+
CliError,
|
|
45
|
+
EXIT_AUTH,
|
|
46
|
+
EXIT_NETWORK,
|
|
47
|
+
EXIT_SCOPE,
|
|
48
|
+
EXIT_VALIDATION,
|
|
49
|
+
} from "../../lib/errors.mjs";
|
|
50
|
+
|
|
51
|
+
// ── Tiny test HTTP server ──────────────────────────────────────────────
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Spin up an HTTP server with a per-test handler. Returns
|
|
55
|
+
* { url, close, lastRequest }. The handler signature mirrors node:http.
|
|
56
|
+
*/
|
|
57
|
+
async function startServer(handler) {
|
|
58
|
+
let lastRequest = null;
|
|
59
|
+
const server = createServer((req, res) => {
|
|
60
|
+
let body = "";
|
|
61
|
+
req.on("data", (chunk) => { body += chunk; });
|
|
62
|
+
req.on("end", () => {
|
|
63
|
+
lastRequest = {
|
|
64
|
+
method: req.method,
|
|
65
|
+
url: req.url,
|
|
66
|
+
headers: req.headers,
|
|
67
|
+
body,
|
|
68
|
+
};
|
|
69
|
+
handler(req, res, body);
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
server.listen(0, "127.0.0.1");
|
|
73
|
+
await once(server, "listening");
|
|
74
|
+
const { port } = server.address();
|
|
75
|
+
return {
|
|
76
|
+
url: `http://127.0.0.1:${port}`,
|
|
77
|
+
close: () => new Promise((resolve) => server.close(() => resolve())),
|
|
78
|
+
get lastRequest() { return lastRequest; },
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Send a JSON response. */
|
|
83
|
+
function jsonRes(res, status, body, extraHeaders = {}) {
|
|
84
|
+
res.statusCode = status;
|
|
85
|
+
res.setHeader("Content-Type", "application/json");
|
|
86
|
+
for (const [k, v] of Object.entries(extraHeaders)) res.setHeader(k, v);
|
|
87
|
+
res.end(JSON.stringify(body));
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const VALID_KEY = "sk_live_test_abc123";
|
|
91
|
+
|
|
92
|
+
// ── validateAccessKey ──────────────────────────────────────────────────
|
|
93
|
+
|
|
94
|
+
describe("validateAccessKey", () => {
|
|
95
|
+
it("returns the account context on 200", async () => {
|
|
96
|
+
const srv = await startServer((req, res) => {
|
|
97
|
+
jsonRes(res, 200, {
|
|
98
|
+
userId: "user-1",
|
|
99
|
+
accountId: "acc-1",
|
|
100
|
+
accountSlug: "alice",
|
|
101
|
+
accountName: "Alice's Org",
|
|
102
|
+
scopes: ["registry:read", "registry:write"],
|
|
103
|
+
keyId: "key-1",
|
|
104
|
+
tier: "free",
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
try {
|
|
108
|
+
const result = await validateAccessKey(srv.url, VALID_KEY);
|
|
109
|
+
assert.equal(result.userId, "user-1");
|
|
110
|
+
assert.equal(result.accountSlug, "alice");
|
|
111
|
+
assert.deepEqual(result.scopes, ["registry:read", "registry:write"]);
|
|
112
|
+
// Verify the request was actually a POST with the bearer header
|
|
113
|
+
assert.equal(srv.lastRequest.method, "POST");
|
|
114
|
+
assert.equal(srv.lastRequest.headers.authorization, `Bearer ${VALID_KEY}`);
|
|
115
|
+
assert.match(srv.lastRequest.headers["user-agent"], /^skillrepo-cli\//);
|
|
116
|
+
} finally {
|
|
117
|
+
await srv.close();
|
|
118
|
+
}
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("throws authError on 401", async () => {
|
|
122
|
+
const srv = await startServer((req, res) => {
|
|
123
|
+
jsonRes(res, 401, { error: "Invalid access key" });
|
|
124
|
+
});
|
|
125
|
+
try {
|
|
126
|
+
await assert.rejects(
|
|
127
|
+
() => validateAccessKey(srv.url, VALID_KEY),
|
|
128
|
+
(err) => err instanceof CliError && err.exitCode === EXIT_AUTH,
|
|
129
|
+
);
|
|
130
|
+
} finally {
|
|
131
|
+
await srv.close();
|
|
132
|
+
}
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it("throws authError on 403 generic (suspended account)", async () => {
|
|
136
|
+
const srv = await startServer((req, res) => {
|
|
137
|
+
jsonRes(res, 403, { error: "Account suspended", code: "suspended" });
|
|
138
|
+
});
|
|
139
|
+
try {
|
|
140
|
+
await assert.rejects(
|
|
141
|
+
() => validateAccessKey(srv.url, VALID_KEY),
|
|
142
|
+
(err) => err instanceof CliError && err.exitCode === EXIT_AUTH && /suspended/i.test(err.message),
|
|
143
|
+
);
|
|
144
|
+
} finally {
|
|
145
|
+
await srv.close();
|
|
146
|
+
}
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it("throws scopeError on 403 with scope code", async () => {
|
|
150
|
+
const srv = await startServer((req, res) => {
|
|
151
|
+
jsonRes(res, 403, { error: "Insufficient scope", code: "scope_required" });
|
|
152
|
+
});
|
|
153
|
+
try {
|
|
154
|
+
await assert.rejects(
|
|
155
|
+
() => validateAccessKey(srv.url, VALID_KEY),
|
|
156
|
+
(err) => err instanceof CliError && err.exitCode === EXIT_SCOPE,
|
|
157
|
+
);
|
|
158
|
+
} finally {
|
|
159
|
+
await srv.close();
|
|
160
|
+
}
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it("throws validationError on 403 plan_limit (NOT authError)", async () => {
|
|
164
|
+
// This is the central proof of the round-3 review fix: plan_limit
|
|
165
|
+
// must not be misrouted to authError ("contact support").
|
|
166
|
+
const srv = await startServer((req, res) => {
|
|
167
|
+
jsonRes(res, 403, {
|
|
168
|
+
error: "Your free plan allows up to 5 library skills.",
|
|
169
|
+
code: "plan_limit",
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
try {
|
|
173
|
+
await assert.rejects(
|
|
174
|
+
() => validateAccessKey(srv.url, VALID_KEY),
|
|
175
|
+
(err) =>
|
|
176
|
+
err instanceof CliError &&
|
|
177
|
+
err.exitCode === EXIT_VALIDATION &&
|
|
178
|
+
/plan/i.test(err.message),
|
|
179
|
+
);
|
|
180
|
+
} finally {
|
|
181
|
+
await srv.close();
|
|
182
|
+
}
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it("throws validationError on 404 (route missing)", async () => {
|
|
186
|
+
const srv = await startServer((req, res) => {
|
|
187
|
+
jsonRes(res, 404, { error: "Not found" });
|
|
188
|
+
});
|
|
189
|
+
try {
|
|
190
|
+
await assert.rejects(
|
|
191
|
+
() => validateAccessKey(srv.url, VALID_KEY),
|
|
192
|
+
(err) => err instanceof CliError && err.exitCode === EXIT_VALIDATION,
|
|
193
|
+
);
|
|
194
|
+
} finally {
|
|
195
|
+
await srv.close();
|
|
196
|
+
}
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it("exhausts retries on persistent 429 and surfaces networkError", async () => {
|
|
200
|
+
// PR4 added `retry: true` to validateAccessKey, so a persistent
|
|
201
|
+
// 429 should trigger the full 3-attempt retry loop before the
|
|
202
|
+
// terminal networkError surfaces. Before PR4, this test was a
|
|
203
|
+
// single-shot "429 → networkError" check; after the retry
|
|
204
|
+
// wiring it needs an explicit hit count or it silently stops
|
|
205
|
+
// verifying what it claims to test.
|
|
206
|
+
let hits = 0;
|
|
207
|
+
const srv = await startServer((req, res) => {
|
|
208
|
+
hits++;
|
|
209
|
+
jsonRes(res, 429, { error: "Rate limit exceeded" });
|
|
210
|
+
});
|
|
211
|
+
try {
|
|
212
|
+
await assert.rejects(
|
|
213
|
+
() => validateAccessKey(srv.url, VALID_KEY),
|
|
214
|
+
(err) => err instanceof CliError && err.exitCode === EXIT_NETWORK,
|
|
215
|
+
);
|
|
216
|
+
assert.equal(hits, 3, "expected 3 attempts (initial + 2 retries) before giving up");
|
|
217
|
+
} finally {
|
|
218
|
+
await srv.close();
|
|
219
|
+
}
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it("throws networkError on 500", async () => {
|
|
223
|
+
const srv = await startServer((req, res) => {
|
|
224
|
+
jsonRes(res, 500, { error: "Internal Server Error" });
|
|
225
|
+
});
|
|
226
|
+
try {
|
|
227
|
+
await assert.rejects(
|
|
228
|
+
() => validateAccessKey(srv.url, VALID_KEY),
|
|
229
|
+
(err) => err instanceof CliError && err.exitCode === EXIT_NETWORK,
|
|
230
|
+
);
|
|
231
|
+
} finally {
|
|
232
|
+
await srv.close();
|
|
233
|
+
}
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
it("throws networkError when the server is unreachable", async () => {
|
|
237
|
+
// Port 1 is reserved and refuses connections on most systems.
|
|
238
|
+
await assert.rejects(
|
|
239
|
+
() => validateAccessKey("http://127.0.0.1:1", VALID_KEY),
|
|
240
|
+
(err) => err instanceof CliError && err.exitCode === EXIT_NETWORK,
|
|
241
|
+
);
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
it("throws networkError on a non-JSON 200 response", async () => {
|
|
245
|
+
const srv = await startServer((req, res) => {
|
|
246
|
+
res.statusCode = 200;
|
|
247
|
+
res.setHeader("Content-Type", "text/html");
|
|
248
|
+
res.end("<html>upstream broken</html>");
|
|
249
|
+
});
|
|
250
|
+
try {
|
|
251
|
+
await assert.rejects(
|
|
252
|
+
() => validateAccessKey(srv.url, VALID_KEY),
|
|
253
|
+
(err) => err instanceof CliError && err.exitCode === EXIT_NETWORK,
|
|
254
|
+
);
|
|
255
|
+
} finally {
|
|
256
|
+
await srv.close();
|
|
257
|
+
}
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
it("rejects empty apiKey upfront with authError (no network round-trip)", async () => {
|
|
261
|
+
// No server needed — the upfront guard fires before any fetch.
|
|
262
|
+
await assert.rejects(
|
|
263
|
+
() => validateAccessKey("http://127.0.0.1:1", ""),
|
|
264
|
+
(err) => err instanceof CliError && err.exitCode === EXIT_AUTH && /No access key/.test(err.message),
|
|
265
|
+
);
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
it("rejects null apiKey upfront with authError", async () => {
|
|
269
|
+
await assert.rejects(
|
|
270
|
+
() => validateAccessKey("http://127.0.0.1:1", null),
|
|
271
|
+
(err) => err instanceof CliError && err.exitCode === EXIT_AUTH,
|
|
272
|
+
);
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
it("rejects empty serverUrl with validationError", async () => {
|
|
276
|
+
await assert.rejects(
|
|
277
|
+
() => validateAccessKey("", VALID_KEY),
|
|
278
|
+
(err) => err instanceof CliError && err.exitCode === EXIT_VALIDATION && /Invalid server URL/.test(err.message),
|
|
279
|
+
);
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
it("accepts undefined serverUrl (does NOT throw validationError upfront)", async () => {
|
|
283
|
+
// `undefined` is the documented "use the default URL" sentinel.
|
|
284
|
+
// It must NOT trip the empty-string guard. The downstream fetch
|
|
285
|
+
// will hit the real default URL and either fail (no network /
|
|
286
|
+
// sandbox) or get 401 from the real server (test key is bogus).
|
|
287
|
+
// Both outcomes are acceptable — the test only locks in that
|
|
288
|
+
// the upfront validationError DOESN'T fire for undefined.
|
|
289
|
+
await assert.rejects(
|
|
290
|
+
() => validateAccessKey(undefined, VALID_KEY),
|
|
291
|
+
(err) => err instanceof CliError && err.exitCode !== EXIT_VALIDATION,
|
|
292
|
+
);
|
|
293
|
+
});
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
// ── getLibrary ──────────────────────────────────────────────────────────
|
|
297
|
+
|
|
298
|
+
describe("getLibrary", () => {
|
|
299
|
+
it("returns skills + removals + etag on 200", async () => {
|
|
300
|
+
const srv = await startServer((req, res) => {
|
|
301
|
+
assert.equal(req.method, "GET");
|
|
302
|
+
assert.match(req.url, /^\/api\/v1\/library/);
|
|
303
|
+
jsonRes(res, 200, {
|
|
304
|
+
skills: [{ owner: "alice", name: "pdf", version: "1.0.0", files: [] }],
|
|
305
|
+
removals: [],
|
|
306
|
+
syncedAt: "2025-01-01T00:00:00Z",
|
|
307
|
+
}, { ETag: '"abc123"' });
|
|
308
|
+
});
|
|
309
|
+
try {
|
|
310
|
+
const result = await getLibrary(srv.url, VALID_KEY);
|
|
311
|
+
assert.equal(result.skills.length, 1);
|
|
312
|
+
assert.equal(result.skills[0].owner, "alice");
|
|
313
|
+
assert.equal(result.etag, '"abc123"');
|
|
314
|
+
assert.equal(result.notModified, false);
|
|
315
|
+
} finally {
|
|
316
|
+
await srv.close();
|
|
317
|
+
}
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
it("sends If-None-Match when ifNoneMatch is provided", async () => {
|
|
321
|
+
const srv = await startServer((req, res) => {
|
|
322
|
+
// Echo the conditional check
|
|
323
|
+
if (req.headers["if-none-match"] === '"abc123"') {
|
|
324
|
+
res.statusCode = 304;
|
|
325
|
+
res.end();
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
328
|
+
jsonRes(res, 200, { skills: [], removals: [], syncedAt: "x" });
|
|
329
|
+
});
|
|
330
|
+
try {
|
|
331
|
+
const result = await getLibrary(srv.url, VALID_KEY, { ifNoneMatch: '"abc123"' });
|
|
332
|
+
assert.equal(result.notModified, true);
|
|
333
|
+
assert.equal(result.etag, '"abc123"');
|
|
334
|
+
assert.deepEqual(result.skills, []);
|
|
335
|
+
assert.deepEqual(result.removals, []);
|
|
336
|
+
} finally {
|
|
337
|
+
await srv.close();
|
|
338
|
+
}
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
it("sends since query param when provided", async () => {
|
|
342
|
+
let capturedUrl;
|
|
343
|
+
const srv = await startServer((req, res) => {
|
|
344
|
+
capturedUrl = req.url;
|
|
345
|
+
jsonRes(res, 200, { skills: [], removals: [{ owner: "a", name: "b", removedAt: "x" }], syncedAt: "x" });
|
|
346
|
+
});
|
|
347
|
+
try {
|
|
348
|
+
const result = await getLibrary(srv.url, VALID_KEY, { since: "2025-01-01T00:00:00Z" });
|
|
349
|
+
assert.match(capturedUrl, /\?since=/);
|
|
350
|
+
assert.equal(result.removals.length, 1);
|
|
351
|
+
} finally {
|
|
352
|
+
await srv.close();
|
|
353
|
+
}
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
it("tolerates missing fields in the response (defaults applied)", async () => {
|
|
357
|
+
const srv = await startServer((req, res) => {
|
|
358
|
+
jsonRes(res, 200, {});
|
|
359
|
+
});
|
|
360
|
+
try {
|
|
361
|
+
const result = await getLibrary(srv.url, VALID_KEY);
|
|
362
|
+
assert.deepEqual(result.skills, []);
|
|
363
|
+
assert.deepEqual(result.removals, []);
|
|
364
|
+
assert.ok(result.syncedAt); // defaults to new Date().toISOString()
|
|
365
|
+
} finally {
|
|
366
|
+
await srv.close();
|
|
367
|
+
}
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
it("throws authError on 401", async () => {
|
|
371
|
+
const srv = await startServer((req, res) => jsonRes(res, 401, { error: "nope" }));
|
|
372
|
+
try {
|
|
373
|
+
await assert.rejects(
|
|
374
|
+
() => getLibrary(srv.url, VALID_KEY),
|
|
375
|
+
(err) => err instanceof CliError && err.exitCode === EXIT_AUTH,
|
|
376
|
+
);
|
|
377
|
+
} finally {
|
|
378
|
+
await srv.close();
|
|
379
|
+
}
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
it("throws networkError on non-JSON 200 body", async () => {
|
|
383
|
+
const srv = await startServer((req, res) => {
|
|
384
|
+
res.statusCode = 200;
|
|
385
|
+
res.setHeader("Content-Type", "text/html");
|
|
386
|
+
res.end("<!doctype html><body>broken</body>");
|
|
387
|
+
});
|
|
388
|
+
try {
|
|
389
|
+
await assert.rejects(
|
|
390
|
+
() => getLibrary(srv.url, VALID_KEY),
|
|
391
|
+
(err) => err instanceof CliError && err.exitCode === EXIT_NETWORK,
|
|
392
|
+
);
|
|
393
|
+
} finally {
|
|
394
|
+
await srv.close();
|
|
395
|
+
}
|
|
396
|
+
});
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
// ── getSkill ────────────────────────────────────────────────────────────
|
|
400
|
+
|
|
401
|
+
describe("getSkill", () => {
|
|
402
|
+
it("returns the skill on 200", async () => {
|
|
403
|
+
const srv = await startServer((req, res) => {
|
|
404
|
+
assert.equal(req.method, "GET");
|
|
405
|
+
jsonRes(res, 200, {
|
|
406
|
+
skill: {
|
|
407
|
+
owner: "alice",
|
|
408
|
+
name: "pdf",
|
|
409
|
+
version: "1.0.0",
|
|
410
|
+
files: [{ path: "SKILL.md", content: "x", sha256: "x", size: 1, contentType: "text/markdown" }],
|
|
411
|
+
},
|
|
412
|
+
});
|
|
413
|
+
});
|
|
414
|
+
try {
|
|
415
|
+
const result = await getSkill(srv.url, VALID_KEY, "alice", "pdf");
|
|
416
|
+
assert.ok(result);
|
|
417
|
+
assert.equal(result.owner, "alice");
|
|
418
|
+
assert.equal(result.files.length, 1);
|
|
419
|
+
} finally {
|
|
420
|
+
await srv.close();
|
|
421
|
+
}
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
it("returns null on 404", async () => {
|
|
425
|
+
const srv = await startServer((req, res) => jsonRes(res, 404, { error: "not found" }));
|
|
426
|
+
try {
|
|
427
|
+
const result = await getSkill(srv.url, VALID_KEY, "alice", "missing");
|
|
428
|
+
assert.equal(result, null);
|
|
429
|
+
} finally {
|
|
430
|
+
await srv.close();
|
|
431
|
+
}
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
it("returns null when body has skill: null on 200 (defensive)", async () => {
|
|
435
|
+
const srv = await startServer((req, res) => jsonRes(res, 200, { skill: null }));
|
|
436
|
+
try {
|
|
437
|
+
const result = await getSkill(srv.url, VALID_KEY, "alice", "x");
|
|
438
|
+
assert.equal(result, null);
|
|
439
|
+
} finally {
|
|
440
|
+
await srv.close();
|
|
441
|
+
}
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
it("encodes owner and name in the URL path", async () => {
|
|
445
|
+
let capturedUrl;
|
|
446
|
+
const srv = await startServer((req, res) => {
|
|
447
|
+
capturedUrl = req.url;
|
|
448
|
+
jsonRes(res, 200, { skill: null });
|
|
449
|
+
});
|
|
450
|
+
try {
|
|
451
|
+
await getSkill(srv.url, VALID_KEY, "name with spaces", "name/with/slashes");
|
|
452
|
+
// Verify encoding: spaces become %20, slashes become %2F
|
|
453
|
+
assert.match(capturedUrl, /%20/);
|
|
454
|
+
assert.match(capturedUrl, /%2F/);
|
|
455
|
+
} finally {
|
|
456
|
+
await srv.close();
|
|
457
|
+
}
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
it("throws authError on 401", async () => {
|
|
461
|
+
const srv = await startServer((req, res) => jsonRes(res, 401, { error: "x" }));
|
|
462
|
+
try {
|
|
463
|
+
await assert.rejects(
|
|
464
|
+
() => getSkill(srv.url, VALID_KEY, "a", "b"),
|
|
465
|
+
(err) => err instanceof CliError && err.exitCode === EXIT_AUTH,
|
|
466
|
+
);
|
|
467
|
+
} finally {
|
|
468
|
+
await srv.close();
|
|
469
|
+
}
|
|
470
|
+
});
|
|
471
|
+
|
|
472
|
+
it("throws scopeError on 403 with scope code", async () => {
|
|
473
|
+
const srv = await startServer((req, res) =>
|
|
474
|
+
jsonRes(res, 403, { error: "scope required", code: "scope_required" }),
|
|
475
|
+
);
|
|
476
|
+
try {
|
|
477
|
+
await assert.rejects(
|
|
478
|
+
() => getSkill(srv.url, VALID_KEY, "a", "b"),
|
|
479
|
+
(err) => err instanceof CliError && err.exitCode === EXIT_SCOPE,
|
|
480
|
+
);
|
|
481
|
+
} finally {
|
|
482
|
+
await srv.close();
|
|
483
|
+
}
|
|
484
|
+
});
|
|
485
|
+
|
|
486
|
+
it("throws networkError on non-JSON 200 body", async () => {
|
|
487
|
+
const srv = await startServer((req, res) => {
|
|
488
|
+
res.statusCode = 200;
|
|
489
|
+
res.setHeader("Content-Type", "text/plain");
|
|
490
|
+
res.end("not json");
|
|
491
|
+
});
|
|
492
|
+
try {
|
|
493
|
+
await assert.rejects(
|
|
494
|
+
() => getSkill(srv.url, VALID_KEY, "a", "b"),
|
|
495
|
+
(err) => err instanceof CliError && err.exitCode === EXIT_NETWORK,
|
|
496
|
+
);
|
|
497
|
+
} finally {
|
|
498
|
+
await srv.close();
|
|
499
|
+
}
|
|
500
|
+
});
|
|
501
|
+
});
|
|
502
|
+
|
|
503
|
+
// ── searchSkills ────────────────────────────────────────────────────────
|
|
504
|
+
|
|
505
|
+
describe("searchSkills", () => {
|
|
506
|
+
it("returns skills + pagination on 200", async () => {
|
|
507
|
+
const srv = await startServer((req, res) => {
|
|
508
|
+
jsonRes(res, 200, {
|
|
509
|
+
skills: [
|
|
510
|
+
{ owner: "a", name: "x", version: "1.0", description: "d", license: null, compatibility: null, installs: 1, avgRating: null, safetyGrade: null, publishedAt: null },
|
|
511
|
+
],
|
|
512
|
+
pagination: { total: 1, limit: 20, offset: 0 },
|
|
513
|
+
});
|
|
514
|
+
});
|
|
515
|
+
try {
|
|
516
|
+
const result = await searchSkills(srv.url, VALID_KEY, { q: "test" });
|
|
517
|
+
assert.equal(result.skills.length, 1);
|
|
518
|
+
assert.equal(result.pagination.total, 1);
|
|
519
|
+
} finally {
|
|
520
|
+
await srv.close();
|
|
521
|
+
}
|
|
522
|
+
});
|
|
523
|
+
|
|
524
|
+
it("encodes query parameters with special characters", async () => {
|
|
525
|
+
let capturedUrl;
|
|
526
|
+
const srv = await startServer((req, res) => {
|
|
527
|
+
capturedUrl = req.url;
|
|
528
|
+
jsonRes(res, 200, { skills: [], pagination: { total: 0, limit: 20, offset: 0 } });
|
|
529
|
+
});
|
|
530
|
+
try {
|
|
531
|
+
await searchSkills(srv.url, VALID_KEY, { q: "foo & bar?baz=qux+more" });
|
|
532
|
+
// URLSearchParams correctly percent-encodes these
|
|
533
|
+
assert.match(capturedUrl, /q=foo\+%26\+bar%3Fbaz%3Dqux%2Bmore|q=foo%20%26%20bar%3Fbaz%3Dqux%2Bmore/);
|
|
534
|
+
} finally {
|
|
535
|
+
await srv.close();
|
|
536
|
+
}
|
|
537
|
+
});
|
|
538
|
+
|
|
539
|
+
it("passes limit, offset, sort to the URL", async () => {
|
|
540
|
+
let capturedUrl;
|
|
541
|
+
const srv = await startServer((req, res) => {
|
|
542
|
+
capturedUrl = req.url;
|
|
543
|
+
jsonRes(res, 200, { skills: [], pagination: { total: 0, limit: 50, offset: 100 } });
|
|
544
|
+
});
|
|
545
|
+
try {
|
|
546
|
+
await searchSkills(srv.url, VALID_KEY, { q: "x", limit: 50, offset: 100, sort: "recent" });
|
|
547
|
+
assert.match(capturedUrl, /limit=50/);
|
|
548
|
+
assert.match(capturedUrl, /offset=100/);
|
|
549
|
+
assert.match(capturedUrl, /sort=recent/);
|
|
550
|
+
} finally {
|
|
551
|
+
await srv.close();
|
|
552
|
+
}
|
|
553
|
+
});
|
|
554
|
+
|
|
555
|
+
it("tolerates missing pagination in response (provides default)", async () => {
|
|
556
|
+
const srv = await startServer((req, res) => jsonRes(res, 200, { skills: [] }));
|
|
557
|
+
try {
|
|
558
|
+
const result = await searchSkills(srv.url, VALID_KEY, { limit: 10, offset: 5 });
|
|
559
|
+
assert.equal(result.pagination.limit, 10);
|
|
560
|
+
assert.equal(result.pagination.offset, 5);
|
|
561
|
+
assert.equal(result.pagination.total, 0);
|
|
562
|
+
} finally {
|
|
563
|
+
await srv.close();
|
|
564
|
+
}
|
|
565
|
+
});
|
|
566
|
+
|
|
567
|
+
it("throws authError on 401", async () => {
|
|
568
|
+
const srv = await startServer((req, res) => jsonRes(res, 401, { error: "x" }));
|
|
569
|
+
try {
|
|
570
|
+
await assert.rejects(
|
|
571
|
+
() => searchSkills(srv.url, VALID_KEY, { q: "test" }),
|
|
572
|
+
(err) => err instanceof CliError && err.exitCode === EXIT_AUTH,
|
|
573
|
+
);
|
|
574
|
+
} finally {
|
|
575
|
+
await srv.close();
|
|
576
|
+
}
|
|
577
|
+
});
|
|
578
|
+
});
|
|
579
|
+
|
|
580
|
+
// ── Cross-cutting: every endpoint guards empty apiKey and serverUrl ────
|
|
581
|
+
|
|
582
|
+
describe("http.mjs — credential and URL guards", () => {
|
|
583
|
+
it("getLibrary rejects empty apiKey", async () => {
|
|
584
|
+
await assert.rejects(
|
|
585
|
+
() => getLibrary("http://127.0.0.1:1", ""),
|
|
586
|
+
(err) => err instanceof CliError && err.exitCode === EXIT_AUTH,
|
|
587
|
+
);
|
|
588
|
+
});
|
|
589
|
+
|
|
590
|
+
it("getSkill rejects empty apiKey", async () => {
|
|
591
|
+
await assert.rejects(
|
|
592
|
+
() => getSkill("http://127.0.0.1:1", "", "a", "b"),
|
|
593
|
+
(err) => err instanceof CliError && err.exitCode === EXIT_AUTH,
|
|
594
|
+
);
|
|
595
|
+
});
|
|
596
|
+
|
|
597
|
+
it("searchSkills rejects empty apiKey", async () => {
|
|
598
|
+
await assert.rejects(
|
|
599
|
+
() => searchSkills("http://127.0.0.1:1", "", { q: "x" }),
|
|
600
|
+
(err) => err instanceof CliError && err.exitCode === EXIT_AUTH,
|
|
601
|
+
);
|
|
602
|
+
});
|
|
603
|
+
|
|
604
|
+
it("getLibrary rejects empty serverUrl", async () => {
|
|
605
|
+
await assert.rejects(
|
|
606
|
+
() => getLibrary("", VALID_KEY),
|
|
607
|
+
(err) => err instanceof CliError && err.exitCode === EXIT_VALIDATION,
|
|
608
|
+
);
|
|
609
|
+
});
|
|
610
|
+
|
|
611
|
+
it("getSkill rejects whitespace-only serverUrl", async () => {
|
|
612
|
+
await assert.rejects(
|
|
613
|
+
() => getSkill(" ", VALID_KEY, "a", "b"),
|
|
614
|
+
(err) => err instanceof CliError && err.exitCode === EXIT_VALIDATION,
|
|
615
|
+
);
|
|
616
|
+
});
|
|
617
|
+
|
|
618
|
+
it("getLibrary rejects non-string serverUrl (number)", async () => {
|
|
619
|
+
await assert.rejects(
|
|
620
|
+
() => getLibrary(42, VALID_KEY),
|
|
621
|
+
(err) => err instanceof CliError && err.exitCode === EXIT_VALIDATION,
|
|
622
|
+
);
|
|
623
|
+
});
|
|
624
|
+
});
|
|
625
|
+
|
|
626
|
+
// ── addSkillToLibrary (PR3a) ───────────────────────────────────────────
|
|
627
|
+
|
|
628
|
+
describe("addSkillToLibrary", () => {
|
|
629
|
+
it("returns { status: 'added' } on 201", async () => {
|
|
630
|
+
const srv = await startServer((req, res) => {
|
|
631
|
+
assert.equal(req.method, "POST");
|
|
632
|
+
assert.match(req.url, /^\/api\/v1\/library$/);
|
|
633
|
+
assert.equal(req.headers["content-type"], "application/json");
|
|
634
|
+
jsonRes(res, 201, {
|
|
635
|
+
added: {
|
|
636
|
+
owner: "alice",
|
|
637
|
+
name: "pdf",
|
|
638
|
+
version: "1.0.0",
|
|
639
|
+
addedAt: "2025-01-01T00:00:00Z",
|
|
640
|
+
},
|
|
641
|
+
});
|
|
642
|
+
});
|
|
643
|
+
try {
|
|
644
|
+
const result = await addSkillToLibrary(srv.url, VALID_KEY, "alice", "pdf");
|
|
645
|
+
assert.equal(result.status, "added");
|
|
646
|
+
assert.equal(result.owner, "alice");
|
|
647
|
+
assert.equal(result.name, "pdf");
|
|
648
|
+
assert.equal(result.version, "1.0.0");
|
|
649
|
+
assert.equal(result.addedAt, "2025-01-01T00:00:00Z");
|
|
650
|
+
} finally {
|
|
651
|
+
await srv.close();
|
|
652
|
+
}
|
|
653
|
+
});
|
|
654
|
+
|
|
655
|
+
it("sends the owner/name JSON body", async () => {
|
|
656
|
+
const srv = await startServer((req, res) => {
|
|
657
|
+
jsonRes(res, 201, {
|
|
658
|
+
added: { owner: "alice", name: "pdf", version: "1", addedAt: "x" },
|
|
659
|
+
});
|
|
660
|
+
});
|
|
661
|
+
try {
|
|
662
|
+
await addSkillToLibrary(srv.url, VALID_KEY, "alice", "pdf");
|
|
663
|
+
assert.equal(srv.lastRequest.body, JSON.stringify({ owner: "alice", name: "pdf" }));
|
|
664
|
+
} finally {
|
|
665
|
+
await srv.close();
|
|
666
|
+
}
|
|
667
|
+
});
|
|
668
|
+
|
|
669
|
+
it("returns { status: 'not-found' } on 404", async () => {
|
|
670
|
+
const srv = await startServer((req, res) =>
|
|
671
|
+
jsonRes(res, 404, { error: "Skill not found", code: "not_found" }),
|
|
672
|
+
);
|
|
673
|
+
try {
|
|
674
|
+
const result = await addSkillToLibrary(srv.url, VALID_KEY, "alice", "ghost");
|
|
675
|
+
assert.equal(result.status, "not-found");
|
|
676
|
+
assert.equal(result.owner, "alice");
|
|
677
|
+
assert.equal(result.name, "ghost");
|
|
678
|
+
} finally {
|
|
679
|
+
await srv.close();
|
|
680
|
+
}
|
|
681
|
+
});
|
|
682
|
+
|
|
683
|
+
it("returns { status: 'already-in-library' } on 409 default", async () => {
|
|
684
|
+
const srv = await startServer((req, res) =>
|
|
685
|
+
jsonRes(res, 409, {
|
|
686
|
+
error: "Skill is already in your library",
|
|
687
|
+
code: "already_in_library",
|
|
688
|
+
}),
|
|
689
|
+
);
|
|
690
|
+
try {
|
|
691
|
+
const result = await addSkillToLibrary(srv.url, VALID_KEY, "alice", "pdf");
|
|
692
|
+
assert.equal(result.status, "already-in-library");
|
|
693
|
+
} finally {
|
|
694
|
+
await srv.close();
|
|
695
|
+
}
|
|
696
|
+
});
|
|
697
|
+
|
|
698
|
+
it("returns { status: 'self-ownership' } on 409 with self_ownership code", async () => {
|
|
699
|
+
const srv = await startServer((req, res) =>
|
|
700
|
+
jsonRes(res, 409, {
|
|
701
|
+
error: "Cannot add your own skill to your library",
|
|
702
|
+
code: "self_ownership",
|
|
703
|
+
}),
|
|
704
|
+
);
|
|
705
|
+
try {
|
|
706
|
+
const result = await addSkillToLibrary(srv.url, VALID_KEY, "alice", "my-skill");
|
|
707
|
+
assert.equal(result.status, "self-ownership");
|
|
708
|
+
} finally {
|
|
709
|
+
await srv.close();
|
|
710
|
+
}
|
|
711
|
+
});
|
|
712
|
+
|
|
713
|
+
it("throws networkError on 409 with unparseable body (round-1 review fix)", async () => {
|
|
714
|
+
// Regression for the round-1 finding both reviewers caught:
|
|
715
|
+
// the previous handler used `.catch(() => ({}))` which silently
|
|
716
|
+
// mapped a malformed 409 body to `already-in-library`. Now a
|
|
717
|
+
// malformed body surfaces as a networkError so a self_ownership
|
|
718
|
+
// outcome can't be silently dropped.
|
|
719
|
+
const srv = await startServer((req, res) => {
|
|
720
|
+
res.statusCode = 409;
|
|
721
|
+
res.setHeader("Content-Type", "text/html");
|
|
722
|
+
res.end("<html>upstream broken</html>");
|
|
723
|
+
});
|
|
724
|
+
try {
|
|
725
|
+
await assert.rejects(
|
|
726
|
+
() => addSkillToLibrary(srv.url, VALID_KEY, "alice", "pdf"),
|
|
727
|
+
(err) => err instanceof CliError && err.exitCode === EXIT_NETWORK,
|
|
728
|
+
);
|
|
729
|
+
} finally {
|
|
730
|
+
await srv.close();
|
|
731
|
+
}
|
|
732
|
+
});
|
|
733
|
+
|
|
734
|
+
it("throws validationError on 409 with unknown code (round-1 review fix)", async () => {
|
|
735
|
+
// A 409 with a code we don't recognize (e.g., a future server
|
|
736
|
+
// contract change) surfaces as a validationError rather than
|
|
737
|
+
// silently assuming already-in-library.
|
|
738
|
+
const srv = await startServer((req, res) =>
|
|
739
|
+
jsonRes(res, 409, { error: "Weird edge case", code: "brand_new_conflict" }),
|
|
740
|
+
);
|
|
741
|
+
try {
|
|
742
|
+
await assert.rejects(
|
|
743
|
+
() => addSkillToLibrary(srv.url, VALID_KEY, "alice", "pdf"),
|
|
744
|
+
(err) => err instanceof CliError && err.exitCode === EXIT_VALIDATION && /brand_new_conflict/.test(err.message),
|
|
745
|
+
);
|
|
746
|
+
} finally {
|
|
747
|
+
await srv.close();
|
|
748
|
+
}
|
|
749
|
+
});
|
|
750
|
+
|
|
751
|
+
it("throws validationError on 403 plan_limit (mapErrorResponse path)", async () => {
|
|
752
|
+
const srv = await startServer((req, res) =>
|
|
753
|
+
jsonRes(res, 403, {
|
|
754
|
+
error: "Your free plan allows up to 5 library skills.",
|
|
755
|
+
code: "plan_limit",
|
|
756
|
+
}),
|
|
757
|
+
);
|
|
758
|
+
try {
|
|
759
|
+
await assert.rejects(
|
|
760
|
+
() => addSkillToLibrary(srv.url, VALID_KEY, "alice", "pdf"),
|
|
761
|
+
(err) => err instanceof CliError && err.exitCode === EXIT_VALIDATION,
|
|
762
|
+
);
|
|
763
|
+
} finally {
|
|
764
|
+
await srv.close();
|
|
765
|
+
}
|
|
766
|
+
});
|
|
767
|
+
|
|
768
|
+
it("throws scopeError on 403 scope_required", async () => {
|
|
769
|
+
const srv = await startServer((req, res) =>
|
|
770
|
+
jsonRes(res, 403, { error: "Scope required", code: "scope_required" }),
|
|
771
|
+
);
|
|
772
|
+
try {
|
|
773
|
+
await assert.rejects(
|
|
774
|
+
() => addSkillToLibrary(srv.url, VALID_KEY, "alice", "pdf"),
|
|
775
|
+
(err) => err instanceof CliError && err.exitCode === EXIT_SCOPE,
|
|
776
|
+
);
|
|
777
|
+
} finally {
|
|
778
|
+
await srv.close();
|
|
779
|
+
}
|
|
780
|
+
});
|
|
781
|
+
|
|
782
|
+
it("throws authError on 401", async () => {
|
|
783
|
+
const srv = await startServer((req, res) =>
|
|
784
|
+
jsonRes(res, 401, { error: "Invalid access key" }),
|
|
785
|
+
);
|
|
786
|
+
try {
|
|
787
|
+
await assert.rejects(
|
|
788
|
+
() => addSkillToLibrary(srv.url, VALID_KEY, "alice", "pdf"),
|
|
789
|
+
(err) => err instanceof CliError && err.exitCode === EXIT_AUTH,
|
|
790
|
+
);
|
|
791
|
+
} finally {
|
|
792
|
+
await srv.close();
|
|
793
|
+
}
|
|
794
|
+
});
|
|
795
|
+
|
|
796
|
+
it("throws networkError on 500", async () => {
|
|
797
|
+
const srv = await startServer((req, res) =>
|
|
798
|
+
jsonRes(res, 500, { error: "Internal Server Error" }),
|
|
799
|
+
);
|
|
800
|
+
try {
|
|
801
|
+
await assert.rejects(
|
|
802
|
+
() => addSkillToLibrary(srv.url, VALID_KEY, "alice", "pdf"),
|
|
803
|
+
(err) => err instanceof CliError && err.exitCode === EXIT_NETWORK,
|
|
804
|
+
);
|
|
805
|
+
} finally {
|
|
806
|
+
await srv.close();
|
|
807
|
+
}
|
|
808
|
+
});
|
|
809
|
+
|
|
810
|
+
it("rejects empty apiKey upfront", async () => {
|
|
811
|
+
await assert.rejects(
|
|
812
|
+
() => addSkillToLibrary("http://127.0.0.1:1", "", "alice", "pdf"),
|
|
813
|
+
(err) => err instanceof CliError && err.exitCode === EXIT_AUTH,
|
|
814
|
+
);
|
|
815
|
+
});
|
|
816
|
+
|
|
817
|
+
it("rejects empty serverUrl", async () => {
|
|
818
|
+
await assert.rejects(
|
|
819
|
+
() => addSkillToLibrary("", VALID_KEY, "alice", "pdf"),
|
|
820
|
+
(err) => err instanceof CliError && err.exitCode === EXIT_VALIDATION,
|
|
821
|
+
);
|
|
822
|
+
});
|
|
823
|
+
});
|
|
824
|
+
|
|
825
|
+
// ── removeSkillFromLibrary (PR3a) ──────────────────────────────────────
|
|
826
|
+
|
|
827
|
+
describe("removeSkillFromLibrary", () => {
|
|
828
|
+
it("returns { status: 'removed' } on 200", async () => {
|
|
829
|
+
const srv = await startServer((req, res) => {
|
|
830
|
+
assert.equal(req.method, "DELETE");
|
|
831
|
+
assert.match(req.url, /^\/api\/v1\/library\/alice\/pdf$/);
|
|
832
|
+
jsonRes(res, 200, {
|
|
833
|
+
removed: {
|
|
834
|
+
owner: "alice",
|
|
835
|
+
name: "pdf",
|
|
836
|
+
removedAt: "2025-01-01T00:00:00Z",
|
|
837
|
+
},
|
|
838
|
+
});
|
|
839
|
+
});
|
|
840
|
+
try {
|
|
841
|
+
const result = await removeSkillFromLibrary(srv.url, VALID_KEY, "alice", "pdf");
|
|
842
|
+
assert.equal(result.status, "removed");
|
|
843
|
+
assert.equal(result.owner, "alice");
|
|
844
|
+
assert.equal(result.name, "pdf");
|
|
845
|
+
assert.equal(result.removedAt, "2025-01-01T00:00:00Z");
|
|
846
|
+
} finally {
|
|
847
|
+
await srv.close();
|
|
848
|
+
}
|
|
849
|
+
});
|
|
850
|
+
|
|
851
|
+
it("encodes owner and name in the URL path", async () => {
|
|
852
|
+
let capturedUrl;
|
|
853
|
+
const srv = await startServer((req, res) => {
|
|
854
|
+
capturedUrl = req.url;
|
|
855
|
+
jsonRes(res, 200, { removed: { owner: "x", name: "y", removedAt: "z" } });
|
|
856
|
+
});
|
|
857
|
+
try {
|
|
858
|
+
await removeSkillFromLibrary(srv.url, VALID_KEY, "owner with space", "name/slash");
|
|
859
|
+
assert.match(capturedUrl, /%20/);
|
|
860
|
+
assert.match(capturedUrl, /%2F/);
|
|
861
|
+
} finally {
|
|
862
|
+
await srv.close();
|
|
863
|
+
}
|
|
864
|
+
});
|
|
865
|
+
|
|
866
|
+
it("returns { status: 'not-in-library' } on 404", async () => {
|
|
867
|
+
const srv = await startServer((req, res) =>
|
|
868
|
+
jsonRes(res, 404, {
|
|
869
|
+
error: "Skill is not in your library",
|
|
870
|
+
code: "not_in_library",
|
|
871
|
+
}),
|
|
872
|
+
);
|
|
873
|
+
try {
|
|
874
|
+
const result = await removeSkillFromLibrary(srv.url, VALID_KEY, "alice", "ghost");
|
|
875
|
+
assert.equal(result.status, "not-in-library");
|
|
876
|
+
} finally {
|
|
877
|
+
await srv.close();
|
|
878
|
+
}
|
|
879
|
+
});
|
|
880
|
+
|
|
881
|
+
it("throws scopeError on 403 scope_required", async () => {
|
|
882
|
+
const srv = await startServer((req, res) =>
|
|
883
|
+
jsonRes(res, 403, { error: "Scope required", code: "scope_required" }),
|
|
884
|
+
);
|
|
885
|
+
try {
|
|
886
|
+
await assert.rejects(
|
|
887
|
+
() => removeSkillFromLibrary(srv.url, VALID_KEY, "alice", "pdf"),
|
|
888
|
+
(err) => err instanceof CliError && err.exitCode === EXIT_SCOPE,
|
|
889
|
+
);
|
|
890
|
+
} finally {
|
|
891
|
+
await srv.close();
|
|
892
|
+
}
|
|
893
|
+
});
|
|
894
|
+
|
|
895
|
+
it("throws authError on 401", async () => {
|
|
896
|
+
const srv = await startServer((req, res) =>
|
|
897
|
+
jsonRes(res, 401, { error: "Invalid access key" }),
|
|
898
|
+
);
|
|
899
|
+
try {
|
|
900
|
+
await assert.rejects(
|
|
901
|
+
() => removeSkillFromLibrary(srv.url, VALID_KEY, "alice", "pdf"),
|
|
902
|
+
(err) => err instanceof CliError && err.exitCode === EXIT_AUTH,
|
|
903
|
+
);
|
|
904
|
+
} finally {
|
|
905
|
+
await srv.close();
|
|
906
|
+
}
|
|
907
|
+
});
|
|
908
|
+
|
|
909
|
+
it("throws networkError on 500", async () => {
|
|
910
|
+
const srv = await startServer((req, res) =>
|
|
911
|
+
jsonRes(res, 500, { error: "boom" }),
|
|
912
|
+
);
|
|
913
|
+
try {
|
|
914
|
+
await assert.rejects(
|
|
915
|
+
() => removeSkillFromLibrary(srv.url, VALID_KEY, "alice", "pdf"),
|
|
916
|
+
(err) => err instanceof CliError && err.exitCode === EXIT_NETWORK,
|
|
917
|
+
);
|
|
918
|
+
} finally {
|
|
919
|
+
await srv.close();
|
|
920
|
+
}
|
|
921
|
+
});
|
|
922
|
+
|
|
923
|
+
it("rejects empty apiKey upfront", async () => {
|
|
924
|
+
await assert.rejects(
|
|
925
|
+
() => removeSkillFromLibrary("http://127.0.0.1:1", "", "alice", "pdf"),
|
|
926
|
+
(err) => err instanceof CliError && err.exitCode === EXIT_AUTH,
|
|
927
|
+
);
|
|
928
|
+
});
|
|
929
|
+
|
|
930
|
+
it("rejects empty serverUrl", async () => {
|
|
931
|
+
await assert.rejects(
|
|
932
|
+
() => removeSkillFromLibrary("", VALID_KEY, "alice", "pdf"),
|
|
933
|
+
(err) => err instanceof CliError && err.exitCode === EXIT_VALIDATION,
|
|
934
|
+
);
|
|
935
|
+
});
|
|
936
|
+
});
|
|
937
|
+
|
|
938
|
+
// ── Retry behavior (PR4, #683) ─────────────────────────────────────────
|
|
939
|
+
|
|
940
|
+
/**
|
|
941
|
+
* Start a flaky HTTP server that returns `failuresBeforeSuccess`
|
|
942
|
+
* transient errors before returning a final successful response.
|
|
943
|
+
* Used to prove withRetry wires through to the read endpoints.
|
|
944
|
+
*/
|
|
945
|
+
async function startFlakyServer({ failuresBeforeSuccess, transientStatus, successBody }) {
|
|
946
|
+
let hits = 0;
|
|
947
|
+
const server = createServer((req, res) => {
|
|
948
|
+
// Drain the request body even though we don't read it — without
|
|
949
|
+
// this the client can hang waiting for the server to ack the
|
|
950
|
+
// POST body on validateAccessKey retries.
|
|
951
|
+
req.on("data", () => {});
|
|
952
|
+
req.on("end", () => {
|
|
953
|
+
hits++;
|
|
954
|
+
if (hits <= failuresBeforeSuccess) {
|
|
955
|
+
jsonRes(res, transientStatus, { error: `flaky-${hits}` });
|
|
956
|
+
} else {
|
|
957
|
+
jsonRes(res, 200, successBody);
|
|
958
|
+
}
|
|
959
|
+
});
|
|
960
|
+
});
|
|
961
|
+
server.listen(0, "127.0.0.1");
|
|
962
|
+
await once(server, "listening");
|
|
963
|
+
const { port } = server.address();
|
|
964
|
+
return {
|
|
965
|
+
url: `http://127.0.0.1:${port}`,
|
|
966
|
+
get hits() { return hits; },
|
|
967
|
+
close: () => new Promise((resolve) => server.close(() => resolve())),
|
|
968
|
+
};
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
describe("http.mjs — retry wiring", () => {
|
|
972
|
+
// Note: we can't directly override baseMs/capMs here because the
|
|
973
|
+
// commands pass `retry: true` with no options, so safeFetch uses
|
|
974
|
+
// the errors.mjs defaults. The default base is 500ms, so each
|
|
975
|
+
// retry sleeps up to ~500ms on attempt 2 and ~1000ms on attempt 3.
|
|
976
|
+
// The total wall-time for a 2-failure test is < 2s, which is
|
|
977
|
+
// within the node:test default timeout.
|
|
978
|
+
|
|
979
|
+
it("getLibrary retries through 503 and eventually succeeds", async () => {
|
|
980
|
+
const srv = await startFlakyServer({
|
|
981
|
+
failuresBeforeSuccess: 2,
|
|
982
|
+
transientStatus: 503,
|
|
983
|
+
successBody: { skills: [], removals: [], syncedAt: "2026-04-15T00:00:00Z" },
|
|
984
|
+
});
|
|
985
|
+
try {
|
|
986
|
+
const result = await getLibrary(srv.url, VALID_KEY);
|
|
987
|
+
assert.deepEqual(result.skills, []);
|
|
988
|
+
assert.equal(srv.hits, 3, "expected 2 failures + 1 success = 3 total attempts");
|
|
989
|
+
} finally {
|
|
990
|
+
await srv.close();
|
|
991
|
+
}
|
|
992
|
+
});
|
|
993
|
+
|
|
994
|
+
it("getLibrary retries through 429 rate-limit and eventually succeeds", async () => {
|
|
995
|
+
const srv = await startFlakyServer({
|
|
996
|
+
failuresBeforeSuccess: 1,
|
|
997
|
+
transientStatus: 429,
|
|
998
|
+
successBody: { skills: [], removals: [], syncedAt: "2026-04-15T00:00:00Z" },
|
|
999
|
+
});
|
|
1000
|
+
try {
|
|
1001
|
+
const result = await getLibrary(srv.url, VALID_KEY);
|
|
1002
|
+
assert.deepEqual(result.skills, []);
|
|
1003
|
+
assert.equal(srv.hits, 2);
|
|
1004
|
+
} finally {
|
|
1005
|
+
await srv.close();
|
|
1006
|
+
}
|
|
1007
|
+
});
|
|
1008
|
+
|
|
1009
|
+
it("getLibrary does NOT retry a 401 auth error", async () => {
|
|
1010
|
+
const srv = await startFlakyServer({
|
|
1011
|
+
failuresBeforeSuccess: 2,
|
|
1012
|
+
transientStatus: 401, // NOT transient — should not retry
|
|
1013
|
+
successBody: { skills: [] },
|
|
1014
|
+
});
|
|
1015
|
+
try {
|
|
1016
|
+
await assert.rejects(
|
|
1017
|
+
() => getLibrary(srv.url, VALID_KEY),
|
|
1018
|
+
(err) => err instanceof CliError && err.exitCode === EXIT_AUTH,
|
|
1019
|
+
);
|
|
1020
|
+
assert.equal(srv.hits, 1, "401 must not trigger a retry");
|
|
1021
|
+
} finally {
|
|
1022
|
+
await srv.close();
|
|
1023
|
+
}
|
|
1024
|
+
});
|
|
1025
|
+
|
|
1026
|
+
it("getLibrary exhausts retries and surfaces the terminal error", async () => {
|
|
1027
|
+
// 5 failures + default 3 attempts = never succeeds. The terminal
|
|
1028
|
+
// response is mapped via mapErrorResponse which returns a
|
|
1029
|
+
// networkError (exit 1) for transient codes.
|
|
1030
|
+
const srv = await startFlakyServer({
|
|
1031
|
+
failuresBeforeSuccess: 5,
|
|
1032
|
+
transientStatus: 503,
|
|
1033
|
+
successBody: { skills: [] },
|
|
1034
|
+
});
|
|
1035
|
+
try {
|
|
1036
|
+
await assert.rejects(
|
|
1037
|
+
() => getLibrary(srv.url, VALID_KEY),
|
|
1038
|
+
(err) => err instanceof CliError && err.exitCode === EXIT_NETWORK,
|
|
1039
|
+
);
|
|
1040
|
+
// Attempt count equals DEFAULT_RETRY_ATTEMPTS
|
|
1041
|
+
assert.equal(srv.hits, 3, "expected exactly 3 attempts before giving up");
|
|
1042
|
+
} finally {
|
|
1043
|
+
await srv.close();
|
|
1044
|
+
}
|
|
1045
|
+
});
|
|
1046
|
+
|
|
1047
|
+
it("searchSkills retries through transient failures", async () => {
|
|
1048
|
+
const srv = await startFlakyServer({
|
|
1049
|
+
failuresBeforeSuccess: 1,
|
|
1050
|
+
transientStatus: 502,
|
|
1051
|
+
successBody: { skills: [], pagination: { total: 0, limit: 20, offset: 0 } },
|
|
1052
|
+
});
|
|
1053
|
+
try {
|
|
1054
|
+
const result = await searchSkills(srv.url, VALID_KEY, { q: "test" });
|
|
1055
|
+
assert.deepEqual(result.skills, []);
|
|
1056
|
+
assert.equal(srv.hits, 2);
|
|
1057
|
+
} finally {
|
|
1058
|
+
await srv.close();
|
|
1059
|
+
}
|
|
1060
|
+
});
|
|
1061
|
+
|
|
1062
|
+
it("getSkill retries and returns null on terminal 404 (not retried)", async () => {
|
|
1063
|
+
// 404 is NOT transient. getSkill should return null without retrying.
|
|
1064
|
+
const srv = await startFlakyServer({
|
|
1065
|
+
failuresBeforeSuccess: 5,
|
|
1066
|
+
transientStatus: 404,
|
|
1067
|
+
successBody: { skill: null },
|
|
1068
|
+
});
|
|
1069
|
+
try {
|
|
1070
|
+
const result = await getSkill(srv.url, VALID_KEY, "alice", "pdf");
|
|
1071
|
+
assert.equal(result, null);
|
|
1072
|
+
assert.equal(srv.hits, 1, "404 must not trigger retry");
|
|
1073
|
+
} finally {
|
|
1074
|
+
await srv.close();
|
|
1075
|
+
}
|
|
1076
|
+
});
|
|
1077
|
+
|
|
1078
|
+
it("validateAccessKey retries through transient 504", async () => {
|
|
1079
|
+
const srv = await startFlakyServer({
|
|
1080
|
+
failuresBeforeSuccess: 2,
|
|
1081
|
+
transientStatus: 504,
|
|
1082
|
+
successBody: {
|
|
1083
|
+
userId: "u", accountId: "a", accountSlug: "alice",
|
|
1084
|
+
accountName: "Alice", scopes: ["registry:read"],
|
|
1085
|
+
keyId: "k", tier: "free",
|
|
1086
|
+
},
|
|
1087
|
+
});
|
|
1088
|
+
try {
|
|
1089
|
+
const result = await validateAccessKey(srv.url, VALID_KEY);
|
|
1090
|
+
assert.equal(result.accountSlug, "alice");
|
|
1091
|
+
assert.equal(srv.hits, 3);
|
|
1092
|
+
} finally {
|
|
1093
|
+
await srv.close();
|
|
1094
|
+
}
|
|
1095
|
+
});
|
|
1096
|
+
|
|
1097
|
+
it("addSkillToLibrary does NOT retry (write-path stays single-shot)", async () => {
|
|
1098
|
+
// Write endpoints are deliberately NOT retried in PR4 scope to
|
|
1099
|
+
// minimize behavioral risk around PR3a's idempotent add flow.
|
|
1100
|
+
// This test locks the decision so a future drive-by edit can't
|
|
1101
|
+
// enable retry on writes without updating the comment + tests.
|
|
1102
|
+
const srv = await startFlakyServer({
|
|
1103
|
+
failuresBeforeSuccess: 5,
|
|
1104
|
+
transientStatus: 503,
|
|
1105
|
+
successBody: { skill: { owner: "alice", name: "pdf" } },
|
|
1106
|
+
});
|
|
1107
|
+
try {
|
|
1108
|
+
await assert.rejects(
|
|
1109
|
+
() => addSkillToLibrary(srv.url, VALID_KEY, "alice", "pdf"),
|
|
1110
|
+
(err) => err instanceof CliError && err.exitCode === EXIT_NETWORK,
|
|
1111
|
+
);
|
|
1112
|
+
assert.equal(srv.hits, 1, "add must not retry");
|
|
1113
|
+
} finally {
|
|
1114
|
+
await srv.close();
|
|
1115
|
+
}
|
|
1116
|
+
});
|
|
1117
|
+
|
|
1118
|
+
it("removeSkillFromLibrary does NOT retry (parallel lock to addSkillToLibrary)", async () => {
|
|
1119
|
+
// The round-1 architect review flagged that only addSkillToLibrary
|
|
1120
|
+
// had a single-shot lock test. removeSkillFromLibrary is also a
|
|
1121
|
+
// write endpoint that the PR4 comment rationale covers, but
|
|
1122
|
+
// without a parallel test a drive-by edit adding `retry: true`
|
|
1123
|
+
// to the DELETE call would go undetected. This test is the
|
|
1124
|
+
// second half of the belt-and-suspenders.
|
|
1125
|
+
const srv = await startFlakyServer({
|
|
1126
|
+
failuresBeforeSuccess: 5,
|
|
1127
|
+
transientStatus: 503,
|
|
1128
|
+
successBody: { ok: true },
|
|
1129
|
+
});
|
|
1130
|
+
try {
|
|
1131
|
+
await assert.rejects(
|
|
1132
|
+
() => removeSkillFromLibrary(srv.url, VALID_KEY, "alice", "pdf"),
|
|
1133
|
+
(err) => err instanceof CliError && err.exitCode === EXIT_NETWORK,
|
|
1134
|
+
);
|
|
1135
|
+
assert.equal(srv.hits, 1, "remove must not retry");
|
|
1136
|
+
} finally {
|
|
1137
|
+
await srv.close();
|
|
1138
|
+
}
|
|
1139
|
+
});
|
|
1140
|
+
|
|
1141
|
+
it("SKILLREPO_VERBOSE emits [retry] lines to stderr during retry", async () => {
|
|
1142
|
+
// Cross-review gap: the retry helper has unit tests for the
|
|
1143
|
+
// onRetry callback but nothing verified the end-to-end wiring
|
|
1144
|
+
// from SKILLREPO_VERBOSE env var → safeFetch → withRetry →
|
|
1145
|
+
// process.stderr. This test locks the full path so a regression
|
|
1146
|
+
// in bin/skillrepo.mjs's --verbose → env var handoff or in
|
|
1147
|
+
// http.mjs's resolveRetryOptions default-onRetry installer
|
|
1148
|
+
// fails loudly.
|
|
1149
|
+
const srv = await startFlakyServer({
|
|
1150
|
+
failuresBeforeSuccess: 2,
|
|
1151
|
+
transientStatus: 503,
|
|
1152
|
+
successBody: { skills: [], removals: [], syncedAt: "2026-04-15T00:00:00Z" },
|
|
1153
|
+
});
|
|
1154
|
+
const originalWrite = process.stderr.write.bind(process.stderr);
|
|
1155
|
+
const captured = [];
|
|
1156
|
+
process.stderr.write = (chunk, ...rest) => {
|
|
1157
|
+
captured.push(String(chunk));
|
|
1158
|
+
return originalWrite(chunk, ...rest);
|
|
1159
|
+
};
|
|
1160
|
+
process.env.SKILLREPO_VERBOSE = "1";
|
|
1161
|
+
try {
|
|
1162
|
+
await getLibrary(srv.url, VALID_KEY);
|
|
1163
|
+
const retryLines = captured.filter((c) => c.includes("[retry]"));
|
|
1164
|
+
// 2 failures + 1 success = 2 retry messages before the successful attempt
|
|
1165
|
+
assert.equal(retryLines.length, 2, "expected 2 [retry] stderr lines");
|
|
1166
|
+
assert.match(retryLines[0], /attempt 1 failed \(HTTP 503\)/);
|
|
1167
|
+
assert.match(retryLines[1], /attempt 2 failed \(HTTP 503\)/);
|
|
1168
|
+
} finally {
|
|
1169
|
+
process.stderr.write = originalWrite;
|
|
1170
|
+
delete process.env.SKILLREPO_VERBOSE;
|
|
1171
|
+
await srv.close();
|
|
1172
|
+
}
|
|
1173
|
+
});
|
|
1174
|
+
|
|
1175
|
+
it("without SKILLREPO_VERBOSE, retries are silent", async () => {
|
|
1176
|
+
const srv = await startFlakyServer({
|
|
1177
|
+
failuresBeforeSuccess: 1,
|
|
1178
|
+
transientStatus: 503,
|
|
1179
|
+
successBody: { skills: [], removals: [], syncedAt: "2026-04-15T00:00:00Z" },
|
|
1180
|
+
});
|
|
1181
|
+
const originalWrite = process.stderr.write.bind(process.stderr);
|
|
1182
|
+
const captured = [];
|
|
1183
|
+
process.stderr.write = (chunk, ...rest) => {
|
|
1184
|
+
captured.push(String(chunk));
|
|
1185
|
+
return originalWrite(chunk, ...rest);
|
|
1186
|
+
};
|
|
1187
|
+
// Ensure SKILLREPO_VERBOSE is NOT set
|
|
1188
|
+
delete process.env.SKILLREPO_VERBOSE;
|
|
1189
|
+
try {
|
|
1190
|
+
await getLibrary(srv.url, VALID_KEY);
|
|
1191
|
+
const retryLines = captured.filter((c) => c.includes("[retry]"));
|
|
1192
|
+
assert.equal(retryLines.length, 0, "retries must be silent without SKILLREPO_VERBOSE");
|
|
1193
|
+
} finally {
|
|
1194
|
+
process.stderr.write = originalWrite;
|
|
1195
|
+
await srv.close();
|
|
1196
|
+
}
|
|
1197
|
+
});
|
|
1198
|
+
});
|