skillrepo 2.0.0 → 3.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +276 -145
- package/bin/skillrepo.mjs +224 -36
- package/package.json +6 -3
- package/src/commands/add.mjs +176 -0
- package/src/commands/get.mjs +116 -0
- package/src/commands/init.mjs +589 -143
- package/src/commands/list.mjs +176 -0
- package/src/commands/remove.mjs +162 -0
- package/src/commands/search.mjs +188 -0
- package/src/commands/session-sync.mjs +152 -0
- package/src/commands/uninstall.mjs +484 -0
- package/src/commands/update.mjs +184 -0
- package/src/lib/artifact-registry.mjs +265 -0
- package/src/lib/cli-config.mjs +230 -0
- package/src/lib/config.mjs +238 -0
- package/src/lib/detect-ides.mjs +0 -19
- package/src/lib/errors.mjs +264 -0
- package/src/lib/file-write.mjs +705 -0
- package/src/lib/fs-utils.mjs +83 -1
- package/src/lib/http.mjs +817 -37
- package/src/lib/identifier.mjs +153 -0
- package/src/lib/mcp-merge.mjs +275 -0
- package/src/lib/mergers/gitignore.mjs +73 -18
- package/src/lib/mergers/session-hook.mjs +298 -0
- package/src/lib/paths.mjs +67 -17
- package/src/lib/prompt.mjs +11 -44
- package/src/lib/removers/claude-mcp.mjs +67 -0
- package/src/lib/removers/cursor-mcp.mjs +60 -0
- package/src/lib/removers/env-local.mjs +55 -0
- package/src/lib/removers/gitignore.mjs +108 -0
- package/src/lib/removers/settings.mjs +183 -0
- package/src/lib/removers/vscode-mcp.mjs +87 -0
- package/src/lib/removers/windsurf-mcp.mjs +65 -0
- package/src/lib/sync.mjs +305 -0
- package/src/test/commands/add.test.mjs +285 -0
- package/src/test/commands/get.test.mjs +176 -0
- package/src/test/commands/init.test.mjs +697 -0
- package/src/test/commands/list.test.mjs +172 -0
- package/src/test/commands/remove.test.mjs +234 -0
- package/src/test/commands/search.test.mjs +204 -0
- package/src/test/commands/session-sync.test.mjs +350 -0
- package/src/test/commands/uninstall.test.mjs +768 -0
- package/src/test/commands/update.test.mjs +322 -0
- package/src/test/detect-ides.test.mjs +9 -14
- package/src/test/dispatcher.test.mjs +224 -0
- package/src/test/e2e/cli-commands.test.mjs +576 -0
- package/src/test/e2e/mock-server.mjs +364 -22
- package/src/test/helpers/capture-stream.mjs +48 -0
- package/src/test/integration/file-write.integration.test.mjs +279 -0
- package/src/test/lib/artifact-registry.test.mjs +268 -0
- package/src/test/lib/cli-config.test.mjs +407 -0
- package/src/test/lib/config.test.mjs +257 -0
- package/src/test/lib/errors.test.mjs +359 -0
- package/src/test/lib/file-write.test.mjs +784 -0
- package/src/test/lib/http.test.mjs +1198 -0
- package/src/test/lib/identifier.test.mjs +157 -0
- package/src/test/lib/mcp-merge.test.mjs +345 -0
- package/src/test/lib/paths.test.mjs +83 -0
- package/src/test/lib/sync.test.mjs +514 -0
- package/src/test/mergers/gitignore.test.mjs +145 -20
- package/src/test/mergers/session-hook.test.mjs +745 -0
- package/src/test/mergers/uninstall-claude-mcp.test.mjs +145 -0
- package/src/test/mergers/uninstall-cursor-mcp.test.mjs +108 -0
- package/src/test/mergers/uninstall-env-local.test.mjs +144 -0
- package/src/test/mergers/uninstall-gitignore.test.mjs +209 -0
- package/src/test/mergers/uninstall-settings.test.mjs +285 -0
- package/src/test/mergers/uninstall-vscode-mcp.test.mjs +215 -0
- package/src/test/mergers/uninstall-windsurf-mcp.test.mjs +122 -0
- package/src/lib/write-configs.mjs +0 -202
- package/src/test/e2e/HANDOFF.md +0 -223
- package/src/test/e2e/cli-init.test.mjs +0 -213
- package/src/test/e2e/payload-factory.mjs +0 -22
|
@@ -1,16 +1,36 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* Mock HTTP server for CLI E2E + integration tests.
|
|
3
3
|
*
|
|
4
4
|
* Serves:
|
|
5
|
-
* GET
|
|
6
|
-
* GET
|
|
5
|
+
* GET /api/v1/setup — legacy setup payload (init flow)
|
|
6
|
+
* GET /api/v1/skill-content — legacy skill content
|
|
7
|
+
* POST /api/v1/auth/validate — credential check (PR2)
|
|
8
|
+
* GET /api/v1/library — library sync with ETag + since (PR2)
|
|
9
|
+
* POST /api/v1/library — add to library (PR3a)
|
|
10
|
+
* DELETE /api/v1/library/[owner]/[name] — remove from library (PR3a)
|
|
11
|
+
* GET /api/v1/skills/[owner]/[name] — single skill fetch (PR2)
|
|
12
|
+
* GET /api/v1/skills/search — keyword search (PR2)
|
|
7
13
|
*
|
|
8
|
-
* Validates Authorization: Bearer sk_live_* header.
|
|
14
|
+
* Validates Authorization: Bearer sk_live_* header on every endpoint.
|
|
9
15
|
* Listens on port 0 (OS-assigned) for parallel-safe tests.
|
|
10
16
|
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
17
|
+
* Mutable slots:
|
|
18
|
+
* setPayload(payload) — legacy setup payload
|
|
19
|
+
* setLibraryResponse(response) — what GET /api/v1/library returns
|
|
20
|
+
* setSkillResponse(owner, name, s) — per-skill payload for getSkill
|
|
21
|
+
* setSearchResponse(response) — what GET /api/v1/skills/search returns
|
|
22
|
+
* setValidateResponse(response) — what POST /auth/validate returns
|
|
23
|
+
* setForcedStatus(status, body) — force the next request to error
|
|
24
|
+
* setEtag(etag) — what library responses include
|
|
25
|
+
*
|
|
26
|
+
* (PR3a additions)
|
|
27
|
+
* setAddResponse(owner, name, resp) — per-skill response for POST /library
|
|
28
|
+
* setRemoveResponse(owner, name, resp) — per-skill response for DELETE /library/<owner>/<name>
|
|
29
|
+
* getLastPostBody() — inspect the most recent POST body (JSON-parsed)
|
|
30
|
+
*
|
|
31
|
+
* The slot APIs let unit/integration tests configure each scenario
|
|
32
|
+
* without spinning up multiple servers per test. Default behavior
|
|
33
|
+
* for an unconfigured slot is a sensible empty payload.
|
|
14
34
|
*/
|
|
15
35
|
|
|
16
36
|
import { createServer } from "node:http";
|
|
@@ -20,13 +40,67 @@ import { createServer } from "node:http";
|
|
|
20
40
|
* @param {object} initialPayload - The SetupPayload to return from /api/v1/setup
|
|
21
41
|
* @param {object} [options]
|
|
22
42
|
* @param {string} [options.validKey] - Exact key to accept (default: any sk_live_* key)
|
|
23
|
-
* @returns {
|
|
43
|
+
* @returns {object} server controls — see top-of-file slot list
|
|
24
44
|
*/
|
|
25
45
|
export function createMockServer(initialPayload, options = {}) {
|
|
26
46
|
const { validKey } = options;
|
|
27
47
|
let port = 0;
|
|
28
48
|
let payload = initialPayload;
|
|
29
49
|
|
|
50
|
+
// PR2 mutable slots
|
|
51
|
+
let libraryResponse = { skills: [], removals: [], syncedAt: new Date().toISOString() };
|
|
52
|
+
let libraryEtag = null;
|
|
53
|
+
// PR3b round-2 slot: persistent override for the library endpoint.
|
|
54
|
+
// Tests set this to simulate a repeatable failure (e.g. 500) on
|
|
55
|
+
// just the /api/v1/library route WITHOUT tripping forced-status on
|
|
56
|
+
// the validate call that runs first. Unlike setForcedStatus() —
|
|
57
|
+
// which fires once and applies to ANY path — this slot only
|
|
58
|
+
// affects GET /api/v1/library and stays set until explicitly
|
|
59
|
+
// cleared. null means "use the normal handler".
|
|
60
|
+
let libraryStatusOverride = null;
|
|
61
|
+
let skillResponses = new Map(); // key: "owner/name" → SyncSkill
|
|
62
|
+
let searchResponse = { skills: [], pagination: { total: 0, limit: 20, offset: 0 } };
|
|
63
|
+
let validateResponse = {
|
|
64
|
+
userId: "user-mock",
|
|
65
|
+
accountId: "acc-mock",
|
|
66
|
+
accountSlug: "mock",
|
|
67
|
+
accountName: "Mock Account",
|
|
68
|
+
scopes: ["registry:read", "registry:write"],
|
|
69
|
+
keyId: "key-mock",
|
|
70
|
+
tier: "free",
|
|
71
|
+
};
|
|
72
|
+
/** @type {{ status: number, body: any } | null} */
|
|
73
|
+
let forcedError = null;
|
|
74
|
+
|
|
75
|
+
// PR3a mutable slots for POST/DELETE library routes
|
|
76
|
+
//
|
|
77
|
+
// Each entry is `{ status: number, body: any }` describing the
|
|
78
|
+
// per-request response. Defaults:
|
|
79
|
+
// POST /api/v1/library → 201 { added: {...} } (successful add)
|
|
80
|
+
// DELETE /api/v1/library/x/y → 200 { removed: {...} } (successful remove)
|
|
81
|
+
//
|
|
82
|
+
// Tests call setAddResponse / setRemoveResponse to inject specific
|
|
83
|
+
// outcomes (404, 409, 403, etc.) by owner/name. A `*` wildcard
|
|
84
|
+
// matches any owner/name when no exact match is registered.
|
|
85
|
+
let addResponses = new Map(); // key: "owner/name" or "*" → { status, body }
|
|
86
|
+
let removeResponses = new Map(); // key: "owner/name" or "*" → { status, body }
|
|
87
|
+
|
|
88
|
+
// Most recent POST body (JSON-parsed) for test inspection.
|
|
89
|
+
//
|
|
90
|
+
// IMPORTANT:
|
|
91
|
+
// - POST ONLY. DELETE/PUT/PATCH bodies are buffered (for streaming
|
|
92
|
+
// correctness) but NOT captured here. `getLastPostBody()` always
|
|
93
|
+
// returns the most recent POST, or null if no POST has happened.
|
|
94
|
+
// Tests that need to inspect a DELETE body should add a dedicated
|
|
95
|
+
// `lastDeleteBody` slot.
|
|
96
|
+
// - Not concurrent-safe. Multiple parallel POSTs to the same
|
|
97
|
+
// server would overwrite each other. The entire test suite is
|
|
98
|
+
// sequential (node:test runs describe blocks in order and each
|
|
99
|
+
// `it` awaits the full `runCli` / `runCommand` call), so in
|
|
100
|
+
// practice this is fine — but a future parallel test runner
|
|
101
|
+
// would need per-request capture.
|
|
102
|
+
let lastPostBody = null;
|
|
103
|
+
|
|
30
104
|
/**
|
|
31
105
|
* Validate the Authorization header.
|
|
32
106
|
* Returns true if valid, false otherwise (and sends a 401 response).
|
|
@@ -50,15 +124,168 @@ export function createMockServer(initialPayload, options = {}) {
|
|
|
50
124
|
}
|
|
51
125
|
|
|
52
126
|
const server = createServer((req, res) => {
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
127
|
+
// Buffer the request body for POST/DELETE before dispatching.
|
|
128
|
+
// The old PR2 handler ignored request bodies entirely because no
|
|
129
|
+
// endpoint actually consumed one (the POST /auth/validate
|
|
130
|
+
// endpoint takes a bearer header and no body). PR3a's add
|
|
131
|
+
// command sends a JSON body to POST /api/v1/library, so we
|
|
132
|
+
// now collect chunks and re-dispatch once the stream ends.
|
|
133
|
+
if (req.method === "POST" || req.method === "DELETE" || req.method === "PUT" || req.method === "PATCH") {
|
|
134
|
+
let bodyChunks = "";
|
|
135
|
+
req.on("data", (chunk) => {
|
|
136
|
+
bodyChunks += chunk.toString("utf-8");
|
|
137
|
+
});
|
|
138
|
+
req.on("end", () => {
|
|
139
|
+
if (req.method === "POST" && bodyChunks.length > 0) {
|
|
140
|
+
try {
|
|
141
|
+
lastPostBody = JSON.parse(bodyChunks);
|
|
142
|
+
} catch {
|
|
143
|
+
lastPostBody = bodyChunks; // preserve as string for tests
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
handleRequest(req, res, bodyChunks);
|
|
147
|
+
});
|
|
148
|
+
req.on("error", () => {
|
|
149
|
+
// Connection dropped — nothing to do, client is gone
|
|
150
|
+
});
|
|
56
151
|
return;
|
|
57
152
|
}
|
|
153
|
+
handleRequest(req, res, "");
|
|
154
|
+
});
|
|
58
155
|
|
|
156
|
+
function handleRequest(req, res, rawBody) {
|
|
59
157
|
const url = new URL(req.url, `http://${req.headers.host}`);
|
|
60
158
|
|
|
61
|
-
//
|
|
159
|
+
// ── Forced-error short-circuit (PR2 test slot) ──────────────────
|
|
160
|
+
// Set via setForcedStatus(); fires once and clears itself so the
|
|
161
|
+
// next request behaves normally. Bypasses auth so tests can
|
|
162
|
+
// simulate 5xx without setting up a valid key.
|
|
163
|
+
if (forcedError !== null) {
|
|
164
|
+
const { status, body } = forcedError;
|
|
165
|
+
forcedError = null;
|
|
166
|
+
res.writeHead(status, { "Content-Type": "application/json" });
|
|
167
|
+
res.end(typeof body === "string" ? body : JSON.stringify(body));
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// ── PR2: POST /api/v1/auth/validate ─────────────────────────────
|
|
172
|
+
if (url.pathname === "/api/v1/auth/validate" && req.method === "POST") {
|
|
173
|
+
if (!checkAuth(req, res)) return;
|
|
174
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
175
|
+
res.end(JSON.stringify(validateResponse));
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// ── PR3a: POST /api/v1/library (add to library) ─────────────────
|
|
180
|
+
if (url.pathname === "/api/v1/library" && req.method === "POST") {
|
|
181
|
+
if (!checkAuth(req, res)) return;
|
|
182
|
+
let body;
|
|
183
|
+
try {
|
|
184
|
+
body = rawBody.length > 0 ? JSON.parse(rawBody) : {};
|
|
185
|
+
} catch {
|
|
186
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
187
|
+
res.end(JSON.stringify({ error: "Invalid JSON", code: "invalid_json" }));
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
const { owner, name } = body ?? {};
|
|
191
|
+
if (!owner || !name) {
|
|
192
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
193
|
+
res.end(
|
|
194
|
+
JSON.stringify({
|
|
195
|
+
error: "Missing owner or name",
|
|
196
|
+
code: "validation_error",
|
|
197
|
+
}),
|
|
198
|
+
);
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
const configured =
|
|
202
|
+
addResponses.get(`${owner}/${name}`) ?? addResponses.get("*");
|
|
203
|
+
if (configured) {
|
|
204
|
+
res.writeHead(configured.status, { "Content-Type": "application/json" });
|
|
205
|
+
res.end(JSON.stringify(configured.body));
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
// Default: 201 added
|
|
209
|
+
res.writeHead(201, { "Content-Type": "application/json" });
|
|
210
|
+
res.end(
|
|
211
|
+
JSON.stringify({
|
|
212
|
+
added: {
|
|
213
|
+
owner,
|
|
214
|
+
name,
|
|
215
|
+
version: "1.0.0",
|
|
216
|
+
addedAt: new Date().toISOString(),
|
|
217
|
+
},
|
|
218
|
+
}),
|
|
219
|
+
);
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// ── PR3a: DELETE /api/v1/library/[owner]/[name] (remove) ────────
|
|
224
|
+
{
|
|
225
|
+
const m = url.pathname.match(/^\/api\/v1\/library\/([^\/]+)\/([^\/]+)$/);
|
|
226
|
+
if (m && req.method === "DELETE") {
|
|
227
|
+
if (!checkAuth(req, res)) return;
|
|
228
|
+
const owner = decodeURIComponent(m[1]);
|
|
229
|
+
const name = decodeURIComponent(m[2]);
|
|
230
|
+
const configured =
|
|
231
|
+
removeResponses.get(`${owner}/${name}`) ?? removeResponses.get("*");
|
|
232
|
+
if (configured) {
|
|
233
|
+
res.writeHead(configured.status, { "Content-Type": "application/json" });
|
|
234
|
+
res.end(JSON.stringify(configured.body));
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
// Default: 200 removed
|
|
238
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
239
|
+
res.end(
|
|
240
|
+
JSON.stringify({
|
|
241
|
+
removed: {
|
|
242
|
+
owner,
|
|
243
|
+
name,
|
|
244
|
+
removedAt: new Date().toISOString(),
|
|
245
|
+
},
|
|
246
|
+
}),
|
|
247
|
+
);
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// ── PR2: GET /api/v1/skills/[owner]/[name] ──────────────────────
|
|
253
|
+
// Match before the legacy /skill-content path. Uses the canonical
|
|
254
|
+
// route shape — encoded path params decoded via URL.
|
|
255
|
+
{
|
|
256
|
+
const m = url.pathname.match(/^\/api\/v1\/skills\/([^\/]+)\/([^\/]+)$/);
|
|
257
|
+
if (m && req.method === "GET") {
|
|
258
|
+
if (!checkAuth(req, res)) return;
|
|
259
|
+
const owner = decodeURIComponent(m[1]);
|
|
260
|
+
const name = decodeURIComponent(m[2]);
|
|
261
|
+
const skill = skillResponses.get(`${owner}/${name}`);
|
|
262
|
+
if (!skill) {
|
|
263
|
+
res.writeHead(404, { "Content-Type": "application/json" });
|
|
264
|
+
res.end(JSON.stringify({ error: "Skill not found" }));
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
268
|
+
res.end(JSON.stringify({ skill }));
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// ── PR2: GET /api/v1/skills/search ──────────────────────────────
|
|
274
|
+
if (url.pathname === "/api/v1/skills/search" && req.method === "GET") {
|
|
275
|
+
if (!checkAuth(req, res)) return;
|
|
276
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
277
|
+
res.end(JSON.stringify(searchResponse));
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// Reject unsupported methods on unknown paths
|
|
282
|
+
if (req.method !== "GET" && req.method !== "POST" && req.method !== "DELETE") {
|
|
283
|
+
res.writeHead(405, { "Content-Type": "application/json" });
|
|
284
|
+
res.end(JSON.stringify({ error: "Method not allowed" }));
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// GET /api/v1/setup (legacy)
|
|
62
289
|
if (url.pathname === "/api/v1/setup") {
|
|
63
290
|
if (!checkAuth(req, res)) return;
|
|
64
291
|
|
|
@@ -67,20 +294,37 @@ export function createMockServer(initialPayload, options = {}) {
|
|
|
67
294
|
return;
|
|
68
295
|
}
|
|
69
296
|
|
|
70
|
-
// GET /api/v1/
|
|
71
|
-
if (url.pathname === "/api/v1/
|
|
297
|
+
// GET /api/v1/library — canonical library sync endpoint
|
|
298
|
+
if (url.pathname === "/api/v1/library" && req.method === "GET") {
|
|
72
299
|
if (!checkAuth(req, res)) return;
|
|
73
300
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
301
|
+
// Persistent status override (PR3b round-2 slot). When set,
|
|
302
|
+
// every library request returns the configured status +
|
|
303
|
+
// body until the test clears it. Used to simulate a broken
|
|
304
|
+
// sync without tripping the one-shot forced-status slot on
|
|
305
|
+
// the validate call that runs earlier in init's flow.
|
|
306
|
+
if (libraryStatusOverride) {
|
|
307
|
+
res.writeHead(libraryStatusOverride.status, { "Content-Type": "application/json" });
|
|
308
|
+
res.end(JSON.stringify(libraryStatusOverride.body ?? { error: "Library override" }));
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// ETag short-circuit
|
|
313
|
+
const ifNoneMatch = req.headers["if-none-match"];
|
|
314
|
+
if (libraryEtag && ifNoneMatch === libraryEtag) {
|
|
315
|
+
res.writeHead(304, { ETag: libraryEtag });
|
|
316
|
+
res.end();
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
const headers = { "Content-Type": "application/json" };
|
|
321
|
+
if (libraryEtag) headers.ETag = libraryEtag;
|
|
322
|
+
res.writeHead(200, headers);
|
|
323
|
+
res.end(JSON.stringify(libraryResponse));
|
|
80
324
|
return;
|
|
81
325
|
}
|
|
82
326
|
|
|
83
|
-
// GET /api/v1/skill-content?owner=X&name=Y
|
|
327
|
+
// GET /api/v1/skill-content?owner=X&name=Y (legacy)
|
|
84
328
|
if (url.pathname === "/api/v1/skill-content") {
|
|
85
329
|
if (!checkAuth(req, res)) return;
|
|
86
330
|
|
|
@@ -107,7 +351,7 @@ export function createMockServer(initialPayload, options = {}) {
|
|
|
107
351
|
// Fallback 404
|
|
108
352
|
res.writeHead(404, { "Content-Type": "application/json" });
|
|
109
353
|
res.end(JSON.stringify({ error: "Not found" }));
|
|
110
|
-
}
|
|
354
|
+
}
|
|
111
355
|
|
|
112
356
|
return {
|
|
113
357
|
/** Start the server and return the assigned port. */
|
|
@@ -142,5 +386,103 @@ export function createMockServer(initialPayload, options = {}) {
|
|
|
142
386
|
setPayload(newPayload) {
|
|
143
387
|
payload = newPayload;
|
|
144
388
|
},
|
|
389
|
+
|
|
390
|
+
// ── PR2 mutable slots ─────────────────────────────────────────
|
|
391
|
+
|
|
392
|
+
/** Replace the GET /api/v1/library response. */
|
|
393
|
+
setLibraryResponse(response) {
|
|
394
|
+
libraryResponse = response;
|
|
395
|
+
},
|
|
396
|
+
|
|
397
|
+
/**
|
|
398
|
+
* Persistently override the HTTP status/body returned by
|
|
399
|
+
* GET /api/v1/library. Pass null to clear. Unlike setForcedStatus
|
|
400
|
+
* which is one-shot and global, this stays in effect and only
|
|
401
|
+
* affects the library endpoint.
|
|
402
|
+
*/
|
|
403
|
+
setLibraryStatus(override) {
|
|
404
|
+
libraryStatusOverride = override;
|
|
405
|
+
},
|
|
406
|
+
|
|
407
|
+
/** Set the ETag served by /api/v1/library and used for 304 short-circuit. */
|
|
408
|
+
setEtag(etag) {
|
|
409
|
+
libraryEtag = etag;
|
|
410
|
+
},
|
|
411
|
+
|
|
412
|
+
/** Register a single-skill response keyed by `owner/name`. */
|
|
413
|
+
setSkillResponse(owner, name, skill) {
|
|
414
|
+
skillResponses.set(`${owner}/${name}`, skill);
|
|
415
|
+
},
|
|
416
|
+
|
|
417
|
+
/** Clear all registered single-skill responses. */
|
|
418
|
+
clearSkillResponses() {
|
|
419
|
+
skillResponses = new Map();
|
|
420
|
+
},
|
|
421
|
+
|
|
422
|
+
/** Replace the GET /api/v1/skills/search response. */
|
|
423
|
+
setSearchResponse(response) {
|
|
424
|
+
searchResponse = response;
|
|
425
|
+
},
|
|
426
|
+
|
|
427
|
+
/** Replace the POST /api/v1/auth/validate response. */
|
|
428
|
+
setValidateResponse(response) {
|
|
429
|
+
validateResponse = response;
|
|
430
|
+
},
|
|
431
|
+
|
|
432
|
+
/**
|
|
433
|
+
* Force the next request (any path, any method) to return a
|
|
434
|
+
* specific status + body. Fires once and clears itself, so a
|
|
435
|
+
* test can simulate a single 5xx without affecting subsequent
|
|
436
|
+
* requests. Bypasses auth so callers can simulate unauth errors.
|
|
437
|
+
*/
|
|
438
|
+
setForcedStatus(status, body) {
|
|
439
|
+
forcedError = { status, body };
|
|
440
|
+
},
|
|
441
|
+
|
|
442
|
+
// ── PR3a slots ────────────────────────────────────────────────
|
|
443
|
+
|
|
444
|
+
/**
|
|
445
|
+
* Register a per-skill POST /api/v1/library response. Key by
|
|
446
|
+
* `owner/name` or use `"*"` as a wildcard for any skill.
|
|
447
|
+
* Example: setAddResponse("alice", "pdf", { status: 404, body: { error: "Skill not found", code: "not_found" } })
|
|
448
|
+
*/
|
|
449
|
+
setAddResponse(owner, name, response) {
|
|
450
|
+
addResponses.set(`${owner}/${name}`, response);
|
|
451
|
+
},
|
|
452
|
+
|
|
453
|
+
/** Shortcut for a wildcard add response (applies to any owner/name). */
|
|
454
|
+
setAddResponseForAny(response) {
|
|
455
|
+
addResponses.set("*", response);
|
|
456
|
+
},
|
|
457
|
+
|
|
458
|
+
clearAddResponses() {
|
|
459
|
+
addResponses = new Map();
|
|
460
|
+
},
|
|
461
|
+
|
|
462
|
+
/**
|
|
463
|
+
* Register a per-skill DELETE /api/v1/library/<owner>/<name>
|
|
464
|
+
* response. Key by `owner/name` or `"*"` for a wildcard.
|
|
465
|
+
*/
|
|
466
|
+
setRemoveResponse(owner, name, response) {
|
|
467
|
+
removeResponses.set(`${owner}/${name}`, response);
|
|
468
|
+
},
|
|
469
|
+
|
|
470
|
+
/** Shortcut for a wildcard remove response. */
|
|
471
|
+
setRemoveResponseForAny(response) {
|
|
472
|
+
removeResponses.set("*", response);
|
|
473
|
+
},
|
|
474
|
+
|
|
475
|
+
clearRemoveResponses() {
|
|
476
|
+
removeResponses = new Map();
|
|
477
|
+
},
|
|
478
|
+
|
|
479
|
+
/**
|
|
480
|
+
* Return the most recent POST body the server received, or null
|
|
481
|
+
* if no POST has been made yet. Body is JSON-parsed when
|
|
482
|
+
* possible, otherwise returned as a string.
|
|
483
|
+
*/
|
|
484
|
+
getLastPostBody() {
|
|
485
|
+
return lastPostBody;
|
|
486
|
+
},
|
|
145
487
|
};
|
|
146
488
|
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Small Writable-stream helper for capturing command output in unit
|
|
3
|
+
* tests. Replaces the previous `process.stdout.write` monkey-patch
|
|
4
|
+
* pattern, which collided with node:test's TAP IPC protocol when
|
|
5
|
+
* running with subtests (see commit history for the ugly debug
|
|
6
|
+
* session that uncovered this).
|
|
7
|
+
*
|
|
8
|
+
* Usage:
|
|
9
|
+
*
|
|
10
|
+
* import { createCaptureStream } from "../helpers/capture-stream.mjs";
|
|
11
|
+
*
|
|
12
|
+
* const out = createCaptureStream();
|
|
13
|
+
* await runCommand(argv, { stdout: out });
|
|
14
|
+
* assert.match(out.text(), /expected text/);
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { Writable } from "node:stream";
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Create a Writable stream that buffers everything written to it
|
|
21
|
+
* into an in-memory string. Returns the stream itself with two
|
|
22
|
+
* extra helpers attached: `text()` returns the captured content,
|
|
23
|
+
* and `clear()` resets the buffer.
|
|
24
|
+
*
|
|
25
|
+
* The stream accepts both string and Buffer chunks. Strings are
|
|
26
|
+
* appended directly; Buffers are decoded as UTF-8.
|
|
27
|
+
*/
|
|
28
|
+
export function createCaptureStream() {
|
|
29
|
+
const chunks = [];
|
|
30
|
+
const stream = new Writable({
|
|
31
|
+
write(chunk, encoding, callback) {
|
|
32
|
+
if (typeof chunk === "string") {
|
|
33
|
+
chunks.push(chunk);
|
|
34
|
+
} else if (Buffer.isBuffer(chunk)) {
|
|
35
|
+
chunks.push(chunk.toString("utf-8"));
|
|
36
|
+
} else {
|
|
37
|
+
// Unknown type — fall back to String() so we never lose data
|
|
38
|
+
chunks.push(String(chunk));
|
|
39
|
+
}
|
|
40
|
+
callback();
|
|
41
|
+
},
|
|
42
|
+
});
|
|
43
|
+
stream.text = () => chunks.join("");
|
|
44
|
+
stream.clear = () => {
|
|
45
|
+
chunks.length = 0;
|
|
46
|
+
};
|
|
47
|
+
return stream;
|
|
48
|
+
}
|