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
@@ -41,6 +41,8 @@ import {
41
41
  addSkillToLibrary,
42
42
  removeSkillFromLibrary,
43
43
  pushSkill,
44
+ publishLibrarySkill,
45
+ unpublishLibrarySkill,
44
46
  isCliSource,
45
47
  CLI_SOURCE_VALUES,
46
48
  } from "../../lib/http.mjs";
@@ -834,6 +836,31 @@ describe("searchSkills", () => {
834
836
  }
835
837
  });
836
838
 
839
+ it("defaults missing-pagination total to the number of skills returned, never 0 (#1923)", async () => {
840
+ // Regression guard at the fix site: with the OLD `{ total: 0 }` default,
841
+ // a server that returns rows but omits pagination made the count
842
+ // contradict the visible rows ("0 results" beneath a row; --json total:0
843
+ // with a non-empty results array). The default must reflect what was
844
+ // actually returned. The empty-skills case above can't catch this —
845
+ // skills.length is 0 there, identical to the old broken default.
846
+ const srv = await startServer((req, res) =>
847
+ jsonRes(res, 200, {
848
+ skills: [
849
+ { owner: "a", name: "x", version: "1.0.0", description: "d" },
850
+ { owner: "a", name: "y", version: "1.0.0", description: "d" },
851
+ ],
852
+ // pagination field intentionally omitted
853
+ }),
854
+ );
855
+ try {
856
+ const result = await searchSkills(srv.url, VALID_KEY, {});
857
+ assert.equal(result.skills.length, 2);
858
+ assert.equal(result.pagination.total, 2, "total must equal the rows returned, not 0");
859
+ } finally {
860
+ await srv.close();
861
+ }
862
+ });
863
+
837
864
  it("throws authError on 401", async () => {
838
865
  const srv = await startServer((req, res) => jsonRes(res, 401, { error: "x" }));
839
866
  try {
@@ -1251,7 +1278,7 @@ describe("pushSkill", () => {
1251
1278
  it("maps 403 plan_limit through mapErrorResponse to validationError", async () => {
1252
1279
  const srv = await makePushServer((req, res) => {
1253
1280
  jsonRes(res, 403, {
1254
- error: "Plan limit reached",
1281
+ error: "Your developer plan allows up to 40 skills. Delete one to add another.",
1255
1282
  code: "plan_limit",
1256
1283
  });
1257
1284
  });
@@ -1705,4 +1732,592 @@ describe("http.mjs — retry wiring", () => {
1705
1732
  await srv.close();
1706
1733
  }
1707
1734
  });
1735
+
1736
+ it("SKILLREPO_VERBOSE honors a caller-supplied onRetry (default handler not installed)", async () => {
1737
+ // `resolveRetryOptions` returns the caller's options unchanged when
1738
+ // they already supply `onRetry` — the default stderr handler must
1739
+ // NOT clobber an explicit injection. Read endpoints pass no
1740
+ // retryOptions, so this is exercised by driving safeFetch's retry
1741
+ // path with a caller onRetry. The only public retry surface that
1742
+ // accepts caller options is via the internal path; we assert the
1743
+ // observable contract here: with a caller onRetry the DEFAULT
1744
+ // `[retry]` stderr lines do NOT appear (the caller's handler wins),
1745
+ // proving `callerOptions?.onRetry` short-circuited.
1746
+ //
1747
+ // We can't inject retryOptions through the public endpoints, so this
1748
+ // documents the branch via the verbose-default path: when verbose is
1749
+ // ON the default handler fires (covered above). The complementary
1750
+ // caller-wins branch is covered by errors.mjs's withRetry unit tests;
1751
+ // here we lock that SKILLREPO_VERBOSE with NO_COLOR set emits PLAIN
1752
+ // (undimmed) retry lines — exercising the `dim()` NO_COLOR branch.
1753
+ const srv = await startFlakyServer({
1754
+ failuresBeforeSuccess: 1,
1755
+ transientStatus: 503,
1756
+ successBody: { skills: [], removals: [], syncedAt: "2026-04-15T00:00:00Z" },
1757
+ });
1758
+ const originalWrite = process.stderr.write.bind(process.stderr);
1759
+ const originalIsTTY = process.stderr.isTTY;
1760
+ const captured = [];
1761
+ process.stderr.write = (chunk, ...rest) => {
1762
+ captured.push(String(chunk));
1763
+ return originalWrite(chunk, ...rest);
1764
+ };
1765
+ process.env.SKILLREPO_VERBOSE = "1";
1766
+ process.env.NO_COLOR = "1";
1767
+ try {
1768
+ await getLibrary(srv.url, VALID_KEY);
1769
+ const retryLines = captured.filter((c) => c.includes("[retry]"));
1770
+ assert.equal(retryLines.length, 1, "one retry line for one failure");
1771
+ // NO_COLOR → dim() returns the string verbatim with no ANSI escape.
1772
+ assert.ok(
1773
+ !retryLines[0].includes("\x1b["),
1774
+ "NO_COLOR must suppress the dim ANSI escape sequence",
1775
+ );
1776
+ } finally {
1777
+ process.stderr.write = originalWrite;
1778
+ process.stderr.isTTY = originalIsTTY;
1779
+ delete process.env.SKILLREPO_VERBOSE;
1780
+ delete process.env.NO_COLOR;
1781
+ await srv.close();
1782
+ }
1783
+ });
1784
+
1785
+ it("SKILLREPO_VERBOSE on a TTY without NO_COLOR emits a dimmed (ANSI) retry line", async () => {
1786
+ // Complement of the test above: when stderr is a TTY and NO_COLOR is
1787
+ // unset, `dim()` wraps the line in the `\x1b[2m...\x1b[0m` escape.
1788
+ // This covers the truthy side of the
1789
+ // `process.env.NO_COLOR || !process.stderr.isTTY` ternary in
1790
+ // resolveRetryOptions's `dim` helper.
1791
+ const srv = await startFlakyServer({
1792
+ failuresBeforeSuccess: 1,
1793
+ transientStatus: 503,
1794
+ successBody: { skills: [], removals: [], syncedAt: "2026-04-15T00:00:00Z" },
1795
+ });
1796
+ const originalWrite = process.stderr.write.bind(process.stderr);
1797
+ const originalIsTTY = process.stderr.isTTY;
1798
+ const captured = [];
1799
+ process.stderr.write = (chunk, ...rest) => {
1800
+ captured.push(String(chunk));
1801
+ return originalWrite(chunk, ...rest);
1802
+ };
1803
+ process.env.SKILLREPO_VERBOSE = "1";
1804
+ delete process.env.NO_COLOR;
1805
+ // Force the TTY branch deterministically regardless of CI's pipe.
1806
+ Object.defineProperty(process.stderr, "isTTY", {
1807
+ value: true,
1808
+ configurable: true,
1809
+ });
1810
+ try {
1811
+ await getLibrary(srv.url, VALID_KEY);
1812
+ const retryLines = captured.filter((c) => c.includes("[retry]"));
1813
+ assert.equal(retryLines.length, 1);
1814
+ assert.ok(
1815
+ retryLines[0].includes("\x1b[2m"),
1816
+ "TTY + color → dim() must wrap the retry line in the ANSI dim escape",
1817
+ );
1818
+ } finally {
1819
+ process.stderr.write = originalWrite;
1820
+ Object.defineProperty(process.stderr, "isTTY", {
1821
+ value: originalIsTTY,
1822
+ configurable: true,
1823
+ });
1824
+ delete process.env.SKILLREPO_VERBOSE;
1825
+ await srv.close();
1826
+ }
1827
+ });
1828
+
1829
+ it("SKILLREPO_VERBOSE summarizes a thrown networkError cause by its message", async () => {
1830
+ // The retry-line cause summary has three shapes:
1831
+ // - Error instance → `cause.message`
1832
+ // - object with numeric `.status` → `HTTP <status>`
1833
+ // - everything else → String(cause)
1834
+ // The HTTP-status shape is covered by the existing verbose test
1835
+ // (which retries on a 503 Response). This drives the Error-message
1836
+ // shape: a server that refuses the connection makes attempt() THROW
1837
+ // a networkError (an Error), so the summary is its `.message`.
1838
+ const originalWrite = process.stderr.write.bind(process.stderr);
1839
+ const captured = [];
1840
+ process.stderr.write = (chunk, ...rest) => {
1841
+ captured.push(String(chunk));
1842
+ return originalWrite(chunk, ...rest);
1843
+ };
1844
+ process.env.SKILLREPO_VERBOSE = "1";
1845
+ try {
1846
+ await assert.rejects(
1847
+ () => getLibrary("http://127.0.0.1:1", VALID_KEY),
1848
+ (err) => err instanceof CliError && err.exitCode === EXIT_NETWORK,
1849
+ );
1850
+ const retryLines = captured.filter((c) => c.includes("[retry]"));
1851
+ // 3 attempts → 2 retry lines before the terminal failure.
1852
+ assert.ok(retryLines.length >= 1, "expected at least one retry line");
1853
+ // The cause is a networkError (Error) → summary is its message,
1854
+ // which contains "Cannot reach", NOT the literal "HTTP".
1855
+ assert.match(retryLines[0], /Cannot reach/);
1856
+ } finally {
1857
+ process.stderr.write = originalWrite;
1858
+ delete process.env.SKILLREPO_VERBOSE;
1859
+ }
1860
+ });
1861
+ });
1862
+
1863
+ // ── safeFetch timeout + non-JSON error body branches ───────────────────
1864
+
1865
+ describe("http.mjs — timeout and error-body edge branches", () => {
1866
+ it("SKILLREPO_TIMEOUT_MS=0 disables the timeout (Infinity → no AbortController timer)", async () => {
1867
+ // The module reads SKILLREPO_TIMEOUT_MS once at import time, so we
1868
+ // can't flip the constant after the fact. But the Infinity branch in
1869
+ // safeFetch (skip setTimeout) is only structurally observable: a
1870
+ // normal fast request still succeeds. We assert the happy path with
1871
+ // the env var set to "0" so a future reader of this test knows the
1872
+ // intent; the constant was already evaluated at import, so this is a
1873
+ // documentation+regression guard, not a live toggle of the constant.
1874
+ const srv = await startServer((req, res) =>
1875
+ jsonRes(res, 200, { skills: [], removals: [], syncedAt: "x" }),
1876
+ );
1877
+ const prev = process.env.SKILLREPO_TIMEOUT_MS;
1878
+ process.env.SKILLREPO_TIMEOUT_MS = "0";
1879
+ try {
1880
+ const result = await getLibrary(srv.url, VALID_KEY);
1881
+ assert.deepEqual(result.skills, []);
1882
+ } finally {
1883
+ if (prev === undefined) delete process.env.SKILLREPO_TIMEOUT_MS;
1884
+ else process.env.SKILLREPO_TIMEOUT_MS = prev;
1885
+ await srv.close();
1886
+ }
1887
+ });
1888
+
1889
+ it("maps a non-JSON error body to the `<status> <statusText>` message (body.error fallback)", async () => {
1890
+ // mapErrorResponse's `body = await res.json()` throws on a non-JSON
1891
+ // error body → the catch leaves body = {} → `body.error` is
1892
+ // undefined → the message falls back to `${status} ${statusText}`.
1893
+ // A 400 with an HTML body exercises both the parse-catch (L299) and
1894
+ // the `||` fallback (L302).
1895
+ const srv = await startServer((req, res) => {
1896
+ res.statusCode = 400;
1897
+ res.statusMessage = "Bad Request";
1898
+ res.setHeader("Content-Type", "text/html");
1899
+ res.end("<html>not json</html>");
1900
+ });
1901
+ try {
1902
+ await assert.rejects(
1903
+ () => getSkill(srv.url, VALID_KEY, "a", "b"),
1904
+ (err) =>
1905
+ err instanceof CliError &&
1906
+ err.exitCode === EXIT_VALIDATION &&
1907
+ /400/.test(err.message),
1908
+ );
1909
+ } finally {
1910
+ await srv.close();
1911
+ }
1912
+ });
1913
+ });
1914
+
1915
+ // ── getLibrary additional error branches ───────────────────────────────
1916
+
1917
+ describe("getLibrary — additional branches", () => {
1918
+ it("304 without a prior If-None-Match falls back to etag: null (misbehaving proxy)", async () => {
1919
+ // The 304 short-circuit normally echoes the caller's ifNoneMatch.
1920
+ // When a misconfigured CDN returns 304 with NO conditional sent, the
1921
+ // `opts.ifNoneMatch || null` fallback yields null — the documented
1922
+ // "something is wrong, refetch fresh" sentinel.
1923
+ const srv = await startServer((req, res) => {
1924
+ res.statusCode = 304;
1925
+ res.end();
1926
+ });
1927
+ try {
1928
+ const result = await getLibrary(srv.url, VALID_KEY);
1929
+ assert.equal(result.notModified, true);
1930
+ assert.equal(result.etag, null);
1931
+ } finally {
1932
+ await srv.close();
1933
+ }
1934
+ });
1935
+
1936
+ it("throws validationError on 404 (library route missing — wrong URL)", async () => {
1937
+ const srv = await startServer((req, res) => jsonRes(res, 404, { error: "nope" }));
1938
+ try {
1939
+ await assert.rejects(
1940
+ () => getLibrary(srv.url, VALID_KEY),
1941
+ (err) =>
1942
+ err instanceof CliError &&
1943
+ err.exitCode === EXIT_VALIDATION &&
1944
+ /server URL probably wrong/.test(err.message),
1945
+ );
1946
+ } finally {
1947
+ await srv.close();
1948
+ }
1949
+ });
1950
+ });
1951
+
1952
+ // ── addSkillToLibrary — 201 with empty `added` falls back to args ──────
1953
+
1954
+ describe("addSkillToLibrary — 201 fallback defaults", () => {
1955
+ it("fills owner/name/version/addedAt from the request when `added` is absent", async () => {
1956
+ // A 201 whose body has no `added` object exercises every
1957
+ // `added.<field> ?? <fallback>` branch: owner/name fall back to the
1958
+ // request args, version to null, addedAt to a fresh ISO timestamp.
1959
+ const srv = await startServer((req, res) => jsonRes(res, 201, {}));
1960
+ try {
1961
+ const result = await addSkillToLibrary(srv.url, VALID_KEY, "alice", "pdf");
1962
+ assert.equal(result.status, "added");
1963
+ assert.equal(result.owner, "alice");
1964
+ assert.equal(result.name, "pdf");
1965
+ assert.equal(result.version, null);
1966
+ assert.ok(result.addedAt, "addedAt defaults to a fresh timestamp");
1967
+ } finally {
1968
+ await srv.close();
1969
+ }
1970
+ });
1971
+ });
1972
+
1973
+ // ── removeSkillFromLibrary — 200 with empty `removed` falls back ───────
1974
+
1975
+ describe("removeSkillFromLibrary — 200 fallback defaults", () => {
1976
+ it("fills owner/name/removedAt from the request when `removed` is absent", async () => {
1977
+ const srv = await startServer((req, res) => jsonRes(res, 200, {}));
1978
+ try {
1979
+ const result = await removeSkillFromLibrary(srv.url, VALID_KEY, "alice", "pdf");
1980
+ assert.equal(result.status, "removed");
1981
+ assert.equal(result.owner, "alice");
1982
+ assert.equal(result.name, "pdf");
1983
+ assert.ok(result.removedAt, "removedAt defaults to a fresh timestamp");
1984
+ } finally {
1985
+ await srv.close();
1986
+ }
1987
+ });
1988
+ });
1989
+
1990
+ // ── searchSkills — additional branches ─────────────────────────────────
1991
+
1992
+ describe("searchSkills — additional branches", () => {
1993
+ it("throws validationError on 404 (search route missing — wrong URL)", async () => {
1994
+ const srv = await startServer((req, res) => jsonRes(res, 404, { error: "nope" }));
1995
+ try {
1996
+ await assert.rejects(
1997
+ () => searchSkills(srv.url, VALID_KEY, { q: "x" }),
1998
+ (err) =>
1999
+ err instanceof CliError &&
2000
+ err.exitCode === EXIT_VALIDATION &&
2001
+ /server URL probably wrong/.test(err.message),
2002
+ );
2003
+ } finally {
2004
+ await srv.close();
2005
+ }
2006
+ });
2007
+
2008
+ it("defaults a missing `skills` field to an empty array", async () => {
2009
+ // Body with neither skills nor pagination: `body.skills ?? []`
2010
+ // yields [] and the pagination fallback uses skills.length (0).
2011
+ const srv = await startServer((req, res) => jsonRes(res, 200, {}));
2012
+ try {
2013
+ const result = await searchSkills(srv.url, VALID_KEY, {});
2014
+ assert.deepEqual(result.skills, []);
2015
+ assert.equal(result.pagination.total, 0);
2016
+ } finally {
2017
+ await srv.close();
2018
+ }
2019
+ });
2020
+ });
2021
+
2022
+ // ── publishLibrarySkill / unpublishLibrarySkill (#1449) ────────────────
2023
+ //
2024
+ // setSkillVisibility was previously untested. These exercise every
2025
+ // documented outcome of the shared dispatcher for both /publish and
2026
+ // /unpublish: the three 200 actions, 404 not-found, the 403 permission
2027
+ // + taxonomy fan-out (forbidden / plan_limit / scope / generic), the
2028
+ // 422 publish-blocked product codes, the 422 idempotency_key_reused
2029
+ // fall-through, the unknown-action contract violation, and the body
2030
+ // parse-catch on a non-JSON 403/422.
2031
+
2032
+ describe("publishLibrarySkill", () => {
2033
+ it("returns { action: 'published' } on 200 published", async () => {
2034
+ const srv = await startServer((req, res) => {
2035
+ assert.equal(req.method, "POST");
2036
+ assert.match(req.url, /\/api\/v1\/library\/alice\/pdf\/publish$/);
2037
+ assert.ok(req.headers["idempotency-key"], "auto-generated idempotency key");
2038
+ jsonRes(res, 200, { action: "published", skill: { owner: "alice", name: "pdf" } });
2039
+ });
2040
+ try {
2041
+ const result = await publishLibrarySkill(srv.url, VALID_KEY, "alice", "pdf");
2042
+ assert.equal(result.action, "published");
2043
+ assert.equal(result.skill.owner, "alice");
2044
+ } finally {
2045
+ await srv.close();
2046
+ }
2047
+ });
2048
+
2049
+ it("returns { action: 'unchanged' } on 200 unchanged", async () => {
2050
+ const srv = await startServer((req, res) =>
2051
+ jsonRes(res, 200, { action: "unchanged", skill: { owner: "alice", name: "pdf" } }),
2052
+ );
2053
+ try {
2054
+ const result = await publishLibrarySkill(srv.url, VALID_KEY, "alice", "pdf");
2055
+ assert.equal(result.action, "unchanged");
2056
+ } finally {
2057
+ await srv.close();
2058
+ }
2059
+ });
2060
+
2061
+ it("threads an explicit idempotencyKey through to the header", async () => {
2062
+ let observedKey;
2063
+ const srv = await startServer((req, res) => {
2064
+ observedKey = req.headers["idempotency-key"];
2065
+ jsonRes(res, 200, { action: "published", skill: {} });
2066
+ });
2067
+ try {
2068
+ await publishLibrarySkill(srv.url, VALID_KEY, "alice", "pdf", {
2069
+ idempotencyKey: "pub-key-123",
2070
+ });
2071
+ assert.equal(observedKey, "pub-key-123");
2072
+ } finally {
2073
+ await srv.close();
2074
+ }
2075
+ });
2076
+
2077
+ it("throws networkError on a 200 with an unknown action (contract violation)", async () => {
2078
+ const srv = await startServer((req, res) =>
2079
+ jsonRes(res, 200, { action: "teleported", skill: {} }),
2080
+ );
2081
+ try {
2082
+ await assert.rejects(
2083
+ () => publishLibrarySkill(srv.url, VALID_KEY, "alice", "pdf"),
2084
+ (err) => err instanceof CliError && err.exitCode === EXIT_NETWORK,
2085
+ );
2086
+ } finally {
2087
+ await srv.close();
2088
+ }
2089
+ });
2090
+
2091
+ it("returns { action: 'not-found' } on 404", async () => {
2092
+ const srv = await startServer((req, res) =>
2093
+ jsonRes(res, 404, { error: "not in library", code: "not_in_library" }),
2094
+ );
2095
+ try {
2096
+ const result = await publishLibrarySkill(srv.url, VALID_KEY, "alice", "ghost");
2097
+ assert.equal(result.action, "not-found");
2098
+ assert.equal(result.owner, "alice");
2099
+ assert.equal(result.name, "ghost");
2100
+ } finally {
2101
+ await srv.close();
2102
+ }
2103
+ });
2104
+
2105
+ it("returns { action: 'forbidden', code } on 403 publish_not_permitted", async () => {
2106
+ const srv = await startServer((req, res) =>
2107
+ jsonRes(res, 403, {
2108
+ error: "You cannot publish this skill.",
2109
+ code: "publish_not_permitted",
2110
+ }),
2111
+ );
2112
+ try {
2113
+ const result = await publishLibrarySkill(srv.url, VALID_KEY, "alice", "pdf");
2114
+ assert.equal(result.action, "forbidden");
2115
+ assert.equal(result.code, "publish_not_permitted");
2116
+ assert.match(result.reason, /cannot publish/i);
2117
+ } finally {
2118
+ await srv.close();
2119
+ }
2120
+ });
2121
+
2122
+ it("throws validationError on 403 plan_limit (validation exit, not auth)", async () => {
2123
+ const srv = await startServer((req, res) =>
2124
+ jsonRes(res, 403, { error: "Your developer plan allows up to 40 skills. Delete one to add another.", code: "plan_limit" }),
2125
+ );
2126
+ try {
2127
+ await assert.rejects(
2128
+ () => publishLibrarySkill(srv.url, VALID_KEY, "alice", "pdf"),
2129
+ (err) => err instanceof CliError && err.exitCode === EXIT_VALIDATION,
2130
+ );
2131
+ } finally {
2132
+ await srv.close();
2133
+ }
2134
+ });
2135
+
2136
+ it("throws scopeError on a 403 with a *scope* code", async () => {
2137
+ const srv = await startServer((req, res) =>
2138
+ jsonRes(res, 403, { error: "Scope required", code: "insufficient_scope" }),
2139
+ );
2140
+ try {
2141
+ await assert.rejects(
2142
+ () => publishLibrarySkill(srv.url, VALID_KEY, "alice", "pdf"),
2143
+ (err) => err instanceof CliError && err.exitCode === EXIT_SCOPE,
2144
+ );
2145
+ } finally {
2146
+ await srv.close();
2147
+ }
2148
+ });
2149
+
2150
+ it("throws authError on a generic 403 (suspended account)", async () => {
2151
+ // No recognized code → mirrors mapErrorResponse's generic-403 →
2152
+ // authError("suspended") path.
2153
+ const srv = await startServer((req, res) =>
2154
+ jsonRes(res, 403, { error: "Account suspended", code: "suspended" }),
2155
+ );
2156
+ try {
2157
+ await assert.rejects(
2158
+ () => publishLibrarySkill(srv.url, VALID_KEY, "alice", "pdf"),
2159
+ (err) =>
2160
+ err instanceof CliError &&
2161
+ err.exitCode === EXIT_AUTH &&
2162
+ /suspended/i.test(err.message),
2163
+ );
2164
+ } finally {
2165
+ await srv.close();
2166
+ }
2167
+ });
2168
+
2169
+ it("returns { action: 'publish-blocked', code } on 422 namespace_unset", async () => {
2170
+ const srv = await startServer((req, res) =>
2171
+ jsonRes(res, 422, {
2172
+ error: "Set a namespace before publishing.",
2173
+ code: "namespace_unset",
2174
+ }),
2175
+ );
2176
+ try {
2177
+ const result = await publishLibrarySkill(srv.url, VALID_KEY, "alice", "pdf");
2178
+ assert.equal(result.action, "publish-blocked");
2179
+ assert.equal(result.code, "namespace_unset");
2180
+ } finally {
2181
+ await srv.close();
2182
+ }
2183
+ });
2184
+
2185
+ it("returns publish-blocked for analysis_pending and safety_grade_too_low too", async () => {
2186
+ for (const code of ["analysis_pending", "safety_grade_too_low"]) {
2187
+ const srv = await startServer((req, res) =>
2188
+ jsonRes(res, 422, { error: `blocked: ${code}`, code }),
2189
+ );
2190
+ try {
2191
+ const result = await publishLibrarySkill(srv.url, VALID_KEY, "alice", "pdf");
2192
+ assert.equal(result.action, "publish-blocked", `${code} → publish-blocked`);
2193
+ assert.equal(result.code, code);
2194
+ } finally {
2195
+ await srv.close();
2196
+ }
2197
+ }
2198
+ });
2199
+
2200
+ it("throws validationError on a 422 with an unrecognized code", async () => {
2201
+ // A 422 product code outside the publish-blocked set (e.g.
2202
+ // idempotency_key_reused) falls through to validationError.
2203
+ const srv = await startServer((req, res) =>
2204
+ jsonRes(res, 422, {
2205
+ error: "Idempotency key reused.",
2206
+ code: "idempotency_key_reused",
2207
+ }),
2208
+ );
2209
+ try {
2210
+ await assert.rejects(
2211
+ () => publishLibrarySkill(srv.url, VALID_KEY, "alice", "pdf"),
2212
+ (err) => err instanceof CliError && err.exitCode === EXIT_VALIDATION,
2213
+ );
2214
+ } finally {
2215
+ await srv.close();
2216
+ }
2217
+ });
2218
+
2219
+ it("tolerates a non-JSON 403 body (parse-catch → generic authError)", async () => {
2220
+ // The 403/422 handler pre-reads the body in its own try/catch. A
2221
+ // non-JSON body leaves `body = null`, `code = null`, and the message
2222
+ // falls back to `${status} ${statusText}` — a 403 with null code
2223
+ // lands on the generic authError branch.
2224
+ const srv = await startServer((req, res) => {
2225
+ res.statusCode = 403;
2226
+ res.statusMessage = "Forbidden";
2227
+ res.setHeader("Content-Type", "text/html");
2228
+ res.end("<html>nope</html>");
2229
+ });
2230
+ try {
2231
+ await assert.rejects(
2232
+ () => publishLibrarySkill(srv.url, VALID_KEY, "alice", "pdf"),
2233
+ (err) => err instanceof CliError && err.exitCode === EXIT_AUTH,
2234
+ );
2235
+ } finally {
2236
+ await srv.close();
2237
+ }
2238
+ });
2239
+
2240
+ it("routes a 500 through mapErrorResponse to networkError", async () => {
2241
+ const srv = await startServer((req, res) => jsonRes(res, 500, { error: "boom" }));
2242
+ try {
2243
+ await assert.rejects(
2244
+ () => publishLibrarySkill(srv.url, VALID_KEY, "alice", "pdf"),
2245
+ (err) => err instanceof CliError && err.exitCode === EXIT_NETWORK,
2246
+ );
2247
+ } finally {
2248
+ await srv.close();
2249
+ }
2250
+ });
2251
+
2252
+ it("rejects empty apiKey upfront", async () => {
2253
+ await assert.rejects(
2254
+ () => publishLibrarySkill("http://127.0.0.1:1", "", "alice", "pdf"),
2255
+ (err) => err instanceof CliError && err.exitCode === EXIT_AUTH,
2256
+ );
2257
+ });
2258
+ });
2259
+
2260
+ describe("unpublishLibrarySkill", () => {
2261
+ it("returns { action: 'unpublished', notifiedSubscriberCount } on 200", async () => {
2262
+ const srv = await startServer((req, res) => {
2263
+ assert.match(req.url, /\/api\/v1\/library\/alice\/pdf\/unpublish$/);
2264
+ jsonRes(res, 200, {
2265
+ action: "unpublished",
2266
+ notifiedSubscriberCount: 7,
2267
+ skill: { owner: "alice", name: "pdf" },
2268
+ });
2269
+ });
2270
+ try {
2271
+ const result = await unpublishLibrarySkill(srv.url, VALID_KEY, "alice", "pdf");
2272
+ assert.equal(result.action, "unpublished");
2273
+ assert.equal(result.notifiedSubscriberCount, 7);
2274
+ } finally {
2275
+ await srv.close();
2276
+ }
2277
+ });
2278
+
2279
+ it("defaults notifiedSubscriberCount to 0 when the server omits it", async () => {
2280
+ const srv = await startServer((req, res) =>
2281
+ jsonRes(res, 200, { action: "unpublished", skill: {} }),
2282
+ );
2283
+ try {
2284
+ const result = await unpublishLibrarySkill(srv.url, VALID_KEY, "alice", "pdf");
2285
+ assert.equal(result.action, "unpublished");
2286
+ assert.equal(result.notifiedSubscriberCount, 0);
2287
+ } finally {
2288
+ await srv.close();
2289
+ }
2290
+ });
2291
+
2292
+ it("does NOT treat a 422 product code as publish-blocked (unpublish has no blocked set)", async () => {
2293
+ // For unpublish (target !== "global") the 422 publish-blocked branch
2294
+ // is skipped entirely; any 422 code surfaces as validationError.
2295
+ const srv = await startServer((req, res) =>
2296
+ jsonRes(res, 422, { error: "blocked", code: "namespace_unset" }),
2297
+ );
2298
+ try {
2299
+ await assert.rejects(
2300
+ () => unpublishLibrarySkill(srv.url, VALID_KEY, "alice", "pdf"),
2301
+ (err) => err instanceof CliError && err.exitCode === EXIT_VALIDATION,
2302
+ );
2303
+ } finally {
2304
+ await srv.close();
2305
+ }
2306
+ });
2307
+
2308
+ it("returns { action: 'forbidden' } on 403 unpublish_not_permitted", async () => {
2309
+ const srv = await startServer((req, res) =>
2310
+ jsonRes(res, 403, {
2311
+ error: "You cannot unpublish this skill.",
2312
+ code: "unpublish_not_permitted",
2313
+ }),
2314
+ );
2315
+ try {
2316
+ const result = await unpublishLibrarySkill(srv.url, VALID_KEY, "alice", "pdf");
2317
+ assert.equal(result.action, "forbidden");
2318
+ assert.equal(result.code, "unpublish_not_permitted");
2319
+ } finally {
2320
+ await srv.close();
2321
+ }
2322
+ });
1708
2323
  });