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.
Files changed (72) hide show
  1. package/README.md +276 -145
  2. package/bin/skillrepo.mjs +224 -36
  3. package/package.json +6 -3
  4. package/src/commands/add.mjs +176 -0
  5. package/src/commands/get.mjs +116 -0
  6. package/src/commands/init.mjs +589 -143
  7. package/src/commands/list.mjs +176 -0
  8. package/src/commands/remove.mjs +162 -0
  9. package/src/commands/search.mjs +188 -0
  10. package/src/commands/session-sync.mjs +152 -0
  11. package/src/commands/uninstall.mjs +484 -0
  12. package/src/commands/update.mjs +184 -0
  13. package/src/lib/artifact-registry.mjs +265 -0
  14. package/src/lib/cli-config.mjs +230 -0
  15. package/src/lib/config.mjs +238 -0
  16. package/src/lib/detect-ides.mjs +0 -19
  17. package/src/lib/errors.mjs +264 -0
  18. package/src/lib/file-write.mjs +705 -0
  19. package/src/lib/fs-utils.mjs +83 -1
  20. package/src/lib/http.mjs +817 -37
  21. package/src/lib/identifier.mjs +153 -0
  22. package/src/lib/mcp-merge.mjs +275 -0
  23. package/src/lib/mergers/gitignore.mjs +73 -18
  24. package/src/lib/mergers/session-hook.mjs +298 -0
  25. package/src/lib/paths.mjs +67 -17
  26. package/src/lib/prompt.mjs +11 -44
  27. package/src/lib/removers/claude-mcp.mjs +67 -0
  28. package/src/lib/removers/cursor-mcp.mjs +60 -0
  29. package/src/lib/removers/env-local.mjs +55 -0
  30. package/src/lib/removers/gitignore.mjs +108 -0
  31. package/src/lib/removers/settings.mjs +183 -0
  32. package/src/lib/removers/vscode-mcp.mjs +87 -0
  33. package/src/lib/removers/windsurf-mcp.mjs +65 -0
  34. package/src/lib/sync.mjs +305 -0
  35. package/src/test/commands/add.test.mjs +285 -0
  36. package/src/test/commands/get.test.mjs +176 -0
  37. package/src/test/commands/init.test.mjs +697 -0
  38. package/src/test/commands/list.test.mjs +172 -0
  39. package/src/test/commands/remove.test.mjs +234 -0
  40. package/src/test/commands/search.test.mjs +204 -0
  41. package/src/test/commands/session-sync.test.mjs +350 -0
  42. package/src/test/commands/uninstall.test.mjs +768 -0
  43. package/src/test/commands/update.test.mjs +322 -0
  44. package/src/test/detect-ides.test.mjs +9 -14
  45. package/src/test/dispatcher.test.mjs +224 -0
  46. package/src/test/e2e/cli-commands.test.mjs +576 -0
  47. package/src/test/e2e/mock-server.mjs +364 -22
  48. package/src/test/helpers/capture-stream.mjs +48 -0
  49. package/src/test/integration/file-write.integration.test.mjs +279 -0
  50. package/src/test/lib/artifact-registry.test.mjs +268 -0
  51. package/src/test/lib/cli-config.test.mjs +407 -0
  52. package/src/test/lib/config.test.mjs +257 -0
  53. package/src/test/lib/errors.test.mjs +359 -0
  54. package/src/test/lib/file-write.test.mjs +784 -0
  55. package/src/test/lib/http.test.mjs +1198 -0
  56. package/src/test/lib/identifier.test.mjs +157 -0
  57. package/src/test/lib/mcp-merge.test.mjs +345 -0
  58. package/src/test/lib/paths.test.mjs +83 -0
  59. package/src/test/lib/sync.test.mjs +514 -0
  60. package/src/test/mergers/gitignore.test.mjs +145 -20
  61. package/src/test/mergers/session-hook.test.mjs +745 -0
  62. package/src/test/mergers/uninstall-claude-mcp.test.mjs +145 -0
  63. package/src/test/mergers/uninstall-cursor-mcp.test.mjs +108 -0
  64. package/src/test/mergers/uninstall-env-local.test.mjs +144 -0
  65. package/src/test/mergers/uninstall-gitignore.test.mjs +209 -0
  66. package/src/test/mergers/uninstall-settings.test.mjs +285 -0
  67. package/src/test/mergers/uninstall-vscode-mcp.test.mjs +215 -0
  68. package/src/test/mergers/uninstall-windsurf-mcp.test.mjs +122 -0
  69. package/src/lib/write-configs.mjs +0 -202
  70. package/src/test/e2e/HANDOFF.md +0 -223
  71. package/src/test/e2e/cli-init.test.mjs +0 -213
  72. package/src/test/e2e/payload-factory.mjs +0 -22
@@ -1,16 +1,36 @@
1
1
  /**
2
- * Minimal mock HTTP server for CLI E2E tests.
2
+ * Mock HTTP server for CLI E2E + integration tests.
3
3
  *
4
4
  * Serves:
5
- * GET /api/v1/setup configurable SetupPayload
6
- * GET /api/v1/skill-content mock skill content by owner/name
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
- * The payload is stored in a mutable variable so callers can call
12
- * setPayload() after start() to inject the real baseUrl once the
13
- * OS-assigned port is known.
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 {{ start: () => Promise<number>, stop: () => Promise<void>, port: number, setPayload: (p: object) => void }}
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
- if (req.method !== "GET") {
54
- res.writeHead(404, { "Content-Type": "application/json" });
55
- res.end(JSON.stringify({ error: "Not found" }));
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
- // GET /api/v1/setup
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/sync/library — used by the first sync during init
71
- if (url.pathname === "/api/v1/sync/library") {
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
- res.writeHead(200, { "Content-Type": "application/json" });
75
- res.end(JSON.stringify({
76
- skills: [],
77
- removals: [],
78
- syncedAt: new Date().toISOString(),
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
+ }