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.
- package/bin/skillrepo.mjs +6 -3
- package/package.json +1 -1
- package/src/commands/add.mjs +2 -1
- package/src/commands/init.mjs +15 -5
- package/src/lib/http.mjs +25 -11
- package/src/test/commands/add.test.mjs +79 -2
- package/src/test/commands/get.test.mjs +131 -2
- package/src/test/commands/init-session-sync.test.mjs +724 -0
- package/src/test/commands/init.test.mjs +159 -2
- package/src/test/commands/list.test.mjs +573 -1
- package/src/test/commands/publish.test.mjs +136 -3
- package/src/test/commands/push.test.mjs +280 -1
- package/src/test/commands/remove.test.mjs +221 -2
- package/src/test/commands/search.test.mjs +203 -1
- package/src/test/commands/session-sync.test.mjs +227 -1
- package/src/test/commands/uninstall.test.mjs +216 -0
- package/src/test/commands/update.test.mjs +218 -0
- package/src/test/dispatcher.test.mjs +103 -2
- package/src/test/e2e/advertised-surface.test.mjs +207 -0
- package/src/test/e2e/cli-commands.test.mjs +1 -1
- package/src/test/e2e/uninstall-interactive.test.mjs +93 -0
- package/src/test/e2e/update-check-suppression.test.mjs +135 -0
- package/src/test/integration/update-list-contract.integration.test.mjs +66 -0
- package/src/test/lib/browser-open.test.mjs +43 -0
- package/src/test/lib/config.test.mjs +87 -0
- package/src/test/lib/crypto-shas.test.mjs +17 -0
- package/src/test/lib/file-write.test.mjs +244 -0
- package/src/test/lib/fs-utils.test.mjs +259 -0
- package/src/test/lib/global-install.test.mjs +134 -0
- package/src/test/lib/http-timeout.test.mjs +114 -0
- package/src/test/lib/http.test.mjs +616 -1
- package/src/test/lib/mcp-merge.test.mjs +157 -0
- package/src/test/lib/npm-update-check.test.mjs +180 -0
- package/src/test/lib/placement-walk.test.mjs +132 -0
- package/src/test/lib/skill-walk.test.mjs +39 -1
- package/src/test/lib/sync.test.mjs +139 -5
- package/src/test/lib/telemetry.test.mjs +34 -0
- package/src/test/mergers/claude-mcp.test.mjs +30 -0
- package/src/test/mergers/cursor-mcp.test.mjs +115 -0
- package/src/test/mergers/env-local.test.mjs +126 -0
- package/src/test/mergers/vscode-mcp.test.mjs +177 -0
- package/src/test/mergers/windsurf-mcp.test.mjs +144 -0
- 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: "
|
|
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
|
});
|