skillrepo 4.8.0 → 4.8.2

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 (43) hide show
  1. package/bin/skillrepo.mjs +6 -3
  2. package/package.json +1 -1
  3. package/src/commands/add.mjs +2 -1
  4. package/src/commands/init.mjs +15 -5
  5. package/src/lib/http.mjs +25 -11
  6. package/src/test/commands/add.test.mjs +79 -2
  7. package/src/test/commands/get.test.mjs +131 -2
  8. package/src/test/commands/init-session-sync.test.mjs +724 -0
  9. package/src/test/commands/init.test.mjs +159 -2
  10. package/src/test/commands/list.test.mjs +573 -1
  11. package/src/test/commands/publish.test.mjs +136 -3
  12. package/src/test/commands/push.test.mjs +280 -1
  13. package/src/test/commands/remove.test.mjs +221 -2
  14. package/src/test/commands/search.test.mjs +203 -1
  15. package/src/test/commands/session-sync.test.mjs +227 -1
  16. package/src/test/commands/uninstall.test.mjs +216 -0
  17. package/src/test/commands/update.test.mjs +218 -0
  18. package/src/test/dispatcher.test.mjs +103 -2
  19. package/src/test/e2e/advertised-surface.test.mjs +207 -0
  20. package/src/test/e2e/cli-commands.test.mjs +1 -1
  21. package/src/test/e2e/uninstall-interactive.test.mjs +93 -0
  22. package/src/test/e2e/update-check-suppression.test.mjs +135 -0
  23. package/src/test/integration/update-list-contract.integration.test.mjs +66 -0
  24. package/src/test/lib/browser-open.test.mjs +43 -0
  25. package/src/test/lib/config.test.mjs +87 -0
  26. package/src/test/lib/crypto-shas.test.mjs +17 -0
  27. package/src/test/lib/file-write.test.mjs +244 -0
  28. package/src/test/lib/fs-utils.test.mjs +259 -0
  29. package/src/test/lib/global-install.test.mjs +134 -0
  30. package/src/test/lib/http-timeout.test.mjs +114 -0
  31. package/src/test/lib/http.test.mjs +616 -1
  32. package/src/test/lib/mcp-merge.test.mjs +157 -0
  33. package/src/test/lib/npm-update-check.test.mjs +180 -0
  34. package/src/test/lib/placement-walk.test.mjs +132 -0
  35. package/src/test/lib/skill-walk.test.mjs +39 -1
  36. package/src/test/lib/sync.test.mjs +139 -5
  37. package/src/test/lib/telemetry.test.mjs +34 -0
  38. package/src/test/mergers/claude-mcp.test.mjs +30 -0
  39. package/src/test/mergers/cursor-mcp.test.mjs +115 -0
  40. package/src/test/mergers/env-local.test.mjs +126 -0
  41. package/src/test/mergers/vscode-mcp.test.mjs +177 -0
  42. package/src/test/mergers/windsurf-mcp.test.mjs +144 -0
  43. package/src/test/resolve-key.test.mjs +33 -0
@@ -359,6 +359,140 @@ describe("installSkillrepoGlobally — happy path", () => {
359
359
  });
360
360
  });
361
361
 
362
+ // ── installSkillrepoGlobally — synchronous + async spawn errors ──
363
+
364
+ describe("installSkillrepoGlobally — spawn error edge paths", () => {
365
+ it("maps a SYNCHRONOUS non-ENOENT spawn throw to npm-nonzero", async () => {
366
+ // Some Node versions surface certain spawn failures synchronously.
367
+ // The catch around `spawn(...)` distinguishes ENOENT (→ enoent-npm)
368
+ // from everything else (→ npm-nonzero). This covers the non-ENOENT
369
+ // synchronous branch: a thrown error WITHOUT code "ENOENT".
370
+ const spawnThatThrows = () => {
371
+ throw Object.assign(new Error("EPERM: operation not permitted"), {
372
+ code: "EPERM",
373
+ });
374
+ };
375
+ const result = await installSkillrepoGlobally({
376
+ version: "3.1.2",
377
+ spawn: spawnThatThrows,
378
+ platform: "linux",
379
+ });
380
+ assert.equal(result.success, false);
381
+ assert.equal(result.errorCode, "npm-nonzero");
382
+ assert.match(result.error, /Failed to spawn npm/);
383
+ assert.match(result.error, /EPERM/);
384
+ });
385
+
386
+ it("maps a synchronous throw with no `code` field to npm-nonzero (defensive)", async () => {
387
+ // `err && err.code === "ENOENT"` short-circuits on a falsy code —
388
+ // a bare Error with no code lands on the generic npm-nonzero return.
389
+ const spawnThatThrows = () => {
390
+ throw new Error("weird synchronous failure");
391
+ };
392
+ const result = await installSkillrepoGlobally({
393
+ version: "3.1.2",
394
+ spawn: spawnThatThrows,
395
+ platform: "linux",
396
+ });
397
+ assert.equal(result.errorCode, "npm-nonzero");
398
+ assert.match(result.error, /weird synchronous failure/);
399
+ });
400
+
401
+ it("maps a SYNCHRONOUS ENOENT spawn throw to enoent-npm", async () => {
402
+ // Some Node versions throw ENOENT synchronously from spawn() rather
403
+ // than via the async `error` event. The synchronous catch must
404
+ // recognize `code === "ENOENT"` and return enoent-npm, identical to
405
+ // the async ENOENT path. Covers the `err.code === "ENOENT"` TRUE
406
+ // branch of the synchronous catch.
407
+ const spawnThatThrows = () => {
408
+ throw Object.assign(new Error("spawn npm ENOENT"), { code: "ENOENT" });
409
+ };
410
+ const result = await installSkillrepoGlobally({
411
+ version: "3.1.2",
412
+ spawn: spawnThatThrows,
413
+ platform: "linux",
414
+ });
415
+ assert.equal(result.success, false);
416
+ assert.equal(result.errorCode, "enoent-npm");
417
+ assert.match(result.error, /npm.*not found/i);
418
+ });
419
+
420
+ it("a synchronous throw of a message-less error falls back to String(err)", async () => {
421
+ // `err?.message ?? String(err)` — the `??` triggers only on
422
+ // null/undefined, so the message must be absent (not empty) to
423
+ // exercise the String() fallback. A thrown plain object with a
424
+ // non-ENOENT code and NO message reaches the npm-nonzero return via
425
+ // String(err).
426
+ const spawnThatThrows = () => {
427
+ throw { code: "EOTHER" }; // no `message` property at all
428
+ };
429
+ const result = await installSkillrepoGlobally({
430
+ version: "3.1.2",
431
+ spawn: spawnThatThrows,
432
+ platform: "linux",
433
+ });
434
+ assert.equal(result.errorCode, "npm-nonzero");
435
+ assert.match(result.error, /Failed to spawn npm/);
436
+ });
437
+
438
+ it("maps an ASYNC non-ENOENT error event (e.g. EACCES at spawn) to npm-nonzero", async () => {
439
+ // makeMockSpawn fires the `error` event when `error` is set. An
440
+ // error whose code is NOT ENOENT hits the spawn-error branch
441
+ // (`settle({ kind: "spawn-error" })` → npm-nonzero "Failed to run npm").
442
+ const eacces = Object.assign(new Error("spawn EACCES"), { code: "EACCES" });
443
+ const spawn = makeMockSpawn({ error: eacces });
444
+ const result = await installSkillrepoGlobally({
445
+ version: "3.1.2",
446
+ spawn,
447
+ platform: "linux",
448
+ });
449
+ assert.equal(result.success, false);
450
+ assert.equal(result.errorCode, "npm-nonzero");
451
+ assert.match(result.error, /Failed to run npm/);
452
+ assert.match(result.error, /EACCES/);
453
+ });
454
+
455
+ it("an async error event with no message falls back to String(err)", async () => {
456
+ // `err?.message ?? String(err)` — `??` only triggers on
457
+ // null/undefined, so the error object must have NO message property
458
+ // (absent, not empty) to exercise the String() fallback in the
459
+ // spawn-error path.
460
+ const weird = { code: "EOTHER" }; // no `message` key
461
+ const spawn = makeMockSpawn({ error: weird });
462
+ const result = await installSkillrepoGlobally({
463
+ version: "3.1.2",
464
+ spawn,
465
+ platform: "linux",
466
+ });
467
+ assert.equal(result.errorCode, "npm-nonzero");
468
+ assert.match(result.error, /Failed to run npm/);
469
+ });
470
+
471
+ it("path-not-updated on Windows appends the new-terminal PATH addendum", async () => {
472
+ // When npm exits 0 but no stable binary is found AND the platform is
473
+ // Windows, the error message carries the Windows-specific addendum
474
+ // about PATH not refreshing in the current terminal. Covers the
475
+ // `isWindows` true branch of the platformAddendum ternary.
476
+ const spawn = makeMockSpawn({ exitCode: 0 });
477
+ const originalPath = process.env.PATH;
478
+ process.env.PATH = "/nonexistent/dir";
479
+ try {
480
+ const result = await installSkillrepoGlobally({
481
+ version: "3.1.2",
482
+ spawn,
483
+ platform: "win32",
484
+ });
485
+ assert.equal(result.success, false);
486
+ assert.equal(result.errorCode, "path-not-updated");
487
+ assert.match(result.error, /On Windows/);
488
+ assert.match(result.error, /new PowerShell or cmd\.exe window/);
489
+ } finally {
490
+ if (originalPath === undefined) delete process.env.PATH;
491
+ else process.env.PATH = originalPath;
492
+ }
493
+ });
494
+ });
495
+
362
496
  // ── resolveGlobalBinary — npx-cache filtering ───────────────────
363
497
 
364
498
  describe("resolveGlobalBinary — transient-cache filtering", () => {
@@ -0,0 +1,114 @@
1
+ /**
2
+ * Timeout-branch coverage for `src/lib/http.mjs`'s `safeFetch`.
3
+ *
4
+ * `DEFAULT_TIMEOUT_MS` is evaluated ONCE at module import from
5
+ * `SKILLREPO_TIMEOUT_MS`, so the timeout branches can't be flipped
6
+ * inside the main http.test.mjs process (the module is already cached
7
+ * with the default 30s). This file sets the env var BEFORE a
8
+ * cache-busting dynamic import (`?bust=` makes a distinct ESM module
9
+ * key) so each scenario gets a fresh module evaluation:
10
+ *
11
+ * - `SKILLREPO_TIMEOUT_MS=50` + a slow server → the AbortController
12
+ * fires, `safeFetch` maps the AbortError to a networkError with the
13
+ * "timed out" hint (covers the `err.name === "AbortError"` branch
14
+ * and the finite-timeout `setTimeout` + `clearTimeout` branch).
15
+ * - `SKILLREPO_TIMEOUT_MS=0` → `DEFAULT_TIMEOUT_MS === Infinity`, so
16
+ * `safeFetch` skips installing the timer entirely (covers the
17
+ * `Number.isFinite(DEFAULT_TIMEOUT_MS)` false branch and the
18
+ * `timeoutId !== null` false branch in the finally).
19
+ *
20
+ * Each import is isolated; the module's other behavior is covered by
21
+ * http.test.mjs. We point at `getSkill` (a single read endpoint) to
22
+ * keep the surface minimal.
23
+ */
24
+
25
+ import { describe, it } from "node:test";
26
+ import assert from "node:assert/strict";
27
+ import { createServer } from "node:http";
28
+ import { once } from "node:events";
29
+
30
+ import { CliError, EXIT_NETWORK } from "../../lib/errors.mjs";
31
+
32
+ const VALID_KEY = "sk_live_timeout_test";
33
+
34
+ /** Spin up a server whose response is delayed by `delayMs`. */
35
+ async function startSlowServer(delayMs) {
36
+ const server = createServer((req, res) => {
37
+ setTimeout(() => {
38
+ res.statusCode = 200;
39
+ res.setHeader("Content-Type", "application/json");
40
+ res.end(JSON.stringify({ skill: { owner: "a", name: "b", files: [] } }));
41
+ }, delayMs);
42
+ });
43
+ server.listen(0, "127.0.0.1");
44
+ await once(server, "listening");
45
+ const { port } = server.address();
46
+ return {
47
+ url: `http://127.0.0.1:${port}`,
48
+ close: () =>
49
+ new Promise((resolve) => {
50
+ // Force-destroy any lingering sockets (aborted client fetches +
51
+ // undici keep-alive) so `server.close` can't hang waiting for the
52
+ // pending response timer to drain. Without this, the slow-server
53
+ // connection a client aborted at 50ms keeps `close` blocked until
54
+ // the server's own delay elapses — and on Windows it could hang
55
+ // indefinitely.
56
+ server.closeAllConnections?.();
57
+ server.close(() => resolve());
58
+ }),
59
+ };
60
+ }
61
+
62
+ /** Import a fresh copy of http.mjs with the current env honored. */
63
+ async function freshHttp() {
64
+ return import(`../../lib/http.mjs?bust=${Date.now()}-${Math.random()}`);
65
+ }
66
+
67
+ // Skipped on Windows: these exercise AbortController timing + local-server
68
+ // teardown, which are timing-sensitive on the Windows runner (a no-timeout
69
+ // fetch / a slow-server `close` can hang the 10-minute CLI-Windows job).
70
+ // The branches under test (finite-timeout abort, Infinity = no-timer) are
71
+ // platform-agnostic and fully covered on the Linux/macOS CI run.
72
+ describe("safeFetch — timeout branches", { skip: process.platform === "win32" }, () => {
73
+ it("a slow server past SKILLREPO_TIMEOUT_MS surfaces a 'timed out' networkError", { timeout: 20_000 }, async () => {
74
+ const prev = process.env.SKILLREPO_TIMEOUT_MS;
75
+ process.env.SKILLREPO_TIMEOUT_MS = "50";
76
+ const mod = await freshHttp();
77
+ // The server delays well past the 50ms cap so the AbortController
78
+ // fires on every attempt.
79
+ const srv = await startSlowServer(2000);
80
+ try {
81
+ await assert.rejects(
82
+ () => mod.getSkill(srv.url, VALID_KEY, "a", "b"),
83
+ (err) =>
84
+ err instanceof CliError &&
85
+ err.exitCode === EXIT_NETWORK &&
86
+ /timed out/.test(err.message) &&
87
+ typeof err.hint === "string" &&
88
+ /SKILLREPO_TIMEOUT_MS/.test(err.hint),
89
+ );
90
+ } finally {
91
+ if (prev === undefined) delete process.env.SKILLREPO_TIMEOUT_MS;
92
+ else process.env.SKILLREPO_TIMEOUT_MS = prev;
93
+ await srv.close();
94
+ }
95
+ });
96
+
97
+ it("SKILLREPO_TIMEOUT_MS=0 disables the timer (Infinity) and lets a request complete", { timeout: 20_000 }, async () => {
98
+ const prev = process.env.SKILLREPO_TIMEOUT_MS;
99
+ process.env.SKILLREPO_TIMEOUT_MS = "0";
100
+ const mod = await freshHttp();
101
+ // No timer is installed (DEFAULT_TIMEOUT_MS === Infinity), so even a
102
+ // slightly-delayed response succeeds rather than aborting.
103
+ const srv = await startSlowServer(20);
104
+ try {
105
+ const result = await mod.getSkill(srv.url, VALID_KEY, "a", "b");
106
+ assert.ok(result, "request must complete with the timeout disabled");
107
+ assert.equal(result.owner, "a");
108
+ } finally {
109
+ if (prev === undefined) delete process.env.SKILLREPO_TIMEOUT_MS;
110
+ else process.env.SKILLREPO_TIMEOUT_MS = prev;
111
+ await srv.close();
112
+ }
113
+ });
114
+ });