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.
Files changed (49) hide show
  1. package/README.md +215 -150
  2. package/bin/skillrepo.mjs +210 -36
  3. package/package.json +6 -3
  4. package/src/commands/add.mjs +176 -0
  5. package/src/commands/get.mjs +116 -0
  6. package/src/commands/init.mjs +471 -143
  7. package/src/commands/list.mjs +176 -0
  8. package/src/commands/remove.mjs +167 -0
  9. package/src/commands/search.mjs +188 -0
  10. package/src/commands/update.mjs +67 -0
  11. package/src/lib/cli-config.mjs +230 -0
  12. package/src/lib/config.mjs +238 -0
  13. package/src/lib/detect-ides.mjs +0 -19
  14. package/src/lib/errors.mjs +264 -0
  15. package/src/lib/file-write.mjs +705 -0
  16. package/src/lib/http.mjs +817 -37
  17. package/src/lib/identifier.mjs +153 -0
  18. package/src/lib/mcp-merge.mjs +275 -0
  19. package/src/lib/mergers/gitignore.mjs +73 -18
  20. package/src/lib/paths.mjs +46 -17
  21. package/src/lib/prompt.mjs +11 -44
  22. package/src/lib/sync.mjs +305 -0
  23. package/src/test/commands/add.test.mjs +285 -0
  24. package/src/test/commands/get.test.mjs +176 -0
  25. package/src/test/commands/init.test.mjs +486 -0
  26. package/src/test/commands/list.test.mjs +172 -0
  27. package/src/test/commands/remove.test.mjs +234 -0
  28. package/src/test/commands/search.test.mjs +204 -0
  29. package/src/test/commands/update.test.mjs +164 -0
  30. package/src/test/detect-ides.test.mjs +9 -14
  31. package/src/test/dispatcher.test.mjs +224 -0
  32. package/src/test/e2e/cli-commands.test.mjs +576 -0
  33. package/src/test/e2e/mock-server.mjs +364 -22
  34. package/src/test/helpers/capture-stream.mjs +48 -0
  35. package/src/test/integration/file-write.integration.test.mjs +279 -0
  36. package/src/test/lib/cli-config.test.mjs +407 -0
  37. package/src/test/lib/config.test.mjs +257 -0
  38. package/src/test/lib/errors.test.mjs +359 -0
  39. package/src/test/lib/file-write.test.mjs +784 -0
  40. package/src/test/lib/http.test.mjs +1198 -0
  41. package/src/test/lib/identifier.test.mjs +157 -0
  42. package/src/test/lib/mcp-merge.test.mjs +345 -0
  43. package/src/test/lib/paths.test.mjs +83 -0
  44. package/src/test/lib/sync.test.mjs +514 -0
  45. package/src/test/mergers/gitignore.test.mjs +145 -20
  46. package/src/lib/write-configs.mjs +0 -202
  47. package/src/test/e2e/HANDOFF.md +0 -223
  48. package/src/test/e2e/cli-init.test.mjs +0 -213
  49. 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
+ });