@vellumai/cli 0.8.4 → 0.8.5

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.
@@ -0,0 +1,449 @@
1
+ import { readPlatformToken, getWebUrl } from "../lib/platform-client.js";
2
+
3
+ function printUsage(): void {
4
+ console.log("Usage: vellum roadmap <subcommand>");
5
+ console.log("");
6
+ console.log("Manage roadmap items.");
7
+ console.log("");
8
+ console.log("Subcommands:");
9
+ console.log(
10
+ " list [--query <q>] [--status <s>] [--tag <slug>] [--sort upvotes|created] [--limit <n>]",
11
+ );
12
+ console.log(" get <slug>");
13
+ console.log(
14
+ " create --title <title> [--description <desc>] [--tag <slug>...]",
15
+ );
16
+ console.log(
17
+ " update <slug> [--title <title>] [--description <desc>] [--status <s>] [--tag <slug>...]",
18
+ );
19
+ console.log(" delete <slug>");
20
+ console.log(" upvote <slug>");
21
+ console.log(" unvote <slug>");
22
+ console.log("");
23
+ console.log("Examples:");
24
+ console.log(' $ vellum roadmap list --query "dark mode"');
25
+ console.log(" $ vellum roadmap list --status planned --sort upvotes");
26
+ console.log(" $ vellum roadmap get my-feature-slug");
27
+ console.log(' $ vellum roadmap create --title "Add dark mode"');
28
+ console.log(
29
+ ' $ vellum roadmap update my-feature --status planned --tag integrations',
30
+ );
31
+ console.log(" $ vellum roadmap upvote my-feature-slug");
32
+ }
33
+
34
+ function consumeValue(args: string[], i: number, flag: string): string {
35
+ const next = args[i + 1];
36
+ if (next === undefined || next.startsWith("--")) {
37
+ console.error(`Error: ${flag} requires a value.`);
38
+ process.exit(1);
39
+ }
40
+ return next;
41
+ }
42
+
43
+ function requireAuth(): string {
44
+ const token = readPlatformToken();
45
+ if (!token) {
46
+ console.error("Not logged in. Run `vellum login` first.");
47
+ process.exit(1);
48
+ }
49
+ return token;
50
+ }
51
+
52
+ function requireSlug(args: string[], command: string): string {
53
+ const slug = args[0];
54
+ if (!slug || slug.startsWith("--")) {
55
+ console.error(`Usage: vellum roadmap ${command} <slug>`);
56
+ process.exit(1);
57
+ }
58
+ return slug;
59
+ }
60
+
61
+ // eslint-disable-next-line no-control-regex
62
+ const ANSI_RE = /[\x00-\x08\x0b-\x1f\x7f]|\x1b(?:\[[0-9;]*[A-Za-z]|\].*?(?:\x07|\x1b\\))/g;
63
+ function sanitize(text: string): string {
64
+ return text.replace(ANSI_RE, "");
65
+ }
66
+
67
+ function makeLink(url: string): string {
68
+ return `\x1b]8;;${url}\x1b\\${url}\x1b]8;;\x1b\\`;
69
+ }
70
+
71
+ async function apiFetch(
72
+ path: string,
73
+ options: {
74
+ method?: string;
75
+ token?: string;
76
+ body?: Record<string, unknown>;
77
+ params?: Record<string, string>;
78
+ } = {},
79
+ ): Promise<Response> {
80
+ const webUrl = getWebUrl();
81
+ let url = `${webUrl}/api/marketing${path}`;
82
+ if (options.params) {
83
+ const qs = new URLSearchParams(options.params).toString();
84
+ if (qs) url += `?${qs}`;
85
+ }
86
+
87
+ const headers: Record<string, string> = {};
88
+ if (options.token) headers["X-Session-Token"] = options.token;
89
+ if (options.body) headers["Content-Type"] = "application/json";
90
+
91
+ return fetch(url, {
92
+ method: options.method ?? "GET",
93
+ headers,
94
+ body: options.body ? JSON.stringify(options.body) : undefined,
95
+ });
96
+ }
97
+
98
+ async function handleError(
99
+ response: Response,
100
+ action: string,
101
+ ): Promise<never> {
102
+ const text = await response.text().catch(() => "");
103
+ console.error(`Failed to ${action} (${response.status}): ${text}`);
104
+ process.exit(1);
105
+ }
106
+
107
+ // ── list ──
108
+
109
+ interface ListItem {
110
+ slug: string;
111
+ title: string;
112
+ status: string;
113
+ upvote_count: number;
114
+ comment_count: number;
115
+ tags: { slug: string; name: string }[];
116
+ viewer_upvoted: boolean | null;
117
+ }
118
+
119
+ async function roadmapList(args: string[]): Promise<void> {
120
+ const params: Record<string, string> = {};
121
+ const token = readPlatformToken() ?? undefined;
122
+
123
+ for (let i = 0; i < args.length; i++) {
124
+ switch (args[i]) {
125
+ case "--query":
126
+ case "-q":
127
+ params.q = consumeValue(args, i, "--query");
128
+ i++;
129
+ break;
130
+ case "--status":
131
+ params.status = consumeValue(args, i, "--status");
132
+ i++;
133
+ break;
134
+ case "--tag":
135
+ params.tag = consumeValue(args, i, "--tag");
136
+ i++;
137
+ break;
138
+ case "--sort":
139
+ params.sort = consumeValue(args, i, "--sort");
140
+ i++;
141
+ break;
142
+ case "--limit":
143
+ params.limit = consumeValue(args, i, "--limit");
144
+ i++;
145
+ break;
146
+ case "--offset":
147
+ params.offset = consumeValue(args, i, "--offset");
148
+ i++;
149
+ break;
150
+ }
151
+ }
152
+
153
+ const response = await apiFetch("/v1/roadmap", { params, token });
154
+ if (!response.ok) return handleError(response, "list roadmap items");
155
+
156
+ const data = (await response.json()) as {
157
+ items: ListItem[];
158
+ total: number;
159
+ };
160
+
161
+ if (data.items.length === 0) {
162
+ console.log("No roadmap items found.");
163
+ return;
164
+ }
165
+
166
+ const webUrl = getWebUrl();
167
+ console.log(`Showing ${data.items.length} of ${data.total} items:\n`);
168
+
169
+ for (const item of data.items) {
170
+ const upvoted = item.viewer_upvoted ? " (upvoted)" : "";
171
+ const tags = item.tags.length > 0
172
+ ? ` [${item.tags.map((t) => sanitize(t.slug)).join(", ")}]`
173
+ : "";
174
+ console.log(
175
+ ` ${sanitize(item.title)} ▲${item.upvote_count}${upvoted} 💬${item.comment_count} ${item.status}${tags}`,
176
+ );
177
+ console.log(` ${makeLink(`${webUrl}/roadmap/${item.slug}`)}`);
178
+ }
179
+ }
180
+
181
+ // ── get ──
182
+
183
+ async function roadmapGet(args: string[]): Promise<void> {
184
+ const slug = requireSlug(args, "get");
185
+ const token = readPlatformToken() ?? undefined;
186
+ const response = await apiFetch(`/v1/roadmap/${slug}`, { token });
187
+ if (!response.ok) return handleError(response, "get roadmap item");
188
+
189
+ const item = (await response.json()) as {
190
+ slug: string;
191
+ title: string;
192
+ description: string;
193
+ status: string;
194
+ upvote_count: number;
195
+ comment_count: number;
196
+ tags: { slug: string; name: string }[];
197
+ viewer_upvoted: boolean | null;
198
+ creator_username: string;
199
+ created: string;
200
+ comments: {
201
+ id: string;
202
+ author_username: string;
203
+ author_is_staff: boolean;
204
+ body: string;
205
+ created: string;
206
+ }[];
207
+ };
208
+
209
+ const webUrl = getWebUrl();
210
+ const upvoted = item.viewer_upvoted ? " (upvoted)" : "";
211
+ const tags =
212
+ item.tags.length > 0
213
+ ? item.tags.map((t) => sanitize(t.slug)).join(", ")
214
+ : "none";
215
+
216
+ console.log(sanitize(item.title));
217
+ console.log(` slug: ${item.slug}`);
218
+ console.log(` status: ${item.status}`);
219
+ console.log(` upvotes: ${item.upvote_count}${upvoted}`);
220
+ console.log(` tags: ${tags}`);
221
+ console.log(` by: ${sanitize(item.creator_username)}`);
222
+ console.log(` created: ${item.created}`);
223
+ console.log(` url: ${makeLink(`${webUrl}/roadmap/${item.slug}`)}`);
224
+ if (item.description) {
225
+ console.log(`\n${sanitize(item.description)}`);
226
+ }
227
+
228
+ if (item.comments.length > 0) {
229
+ console.log(`\nComments (${item.comments.length}):`);
230
+ for (const c of item.comments) {
231
+ const staff = c.author_is_staff ? " [staff]" : "";
232
+ console.log(` ${sanitize(c.author_username)}${staff} (${c.created}):`);
233
+ console.log(` ${sanitize(c.body)}`);
234
+ }
235
+ }
236
+ }
237
+
238
+ // ── create ──
239
+
240
+ async function roadmapCreate(args: string[]): Promise<void> {
241
+ let title: string | undefined;
242
+ let description: string | undefined;
243
+ const tags: string[] = [];
244
+
245
+ for (let i = 0; i < args.length; i++) {
246
+ switch (args[i]) {
247
+ case "--title":
248
+ title = consumeValue(args, i, "--title");
249
+ i++;
250
+ break;
251
+ case "--description":
252
+ description = consumeValue(args, i, "--description");
253
+ i++;
254
+ break;
255
+ case "--tag":
256
+ tags.push(consumeValue(args, i, "--tag"));
257
+ i++;
258
+ break;
259
+ }
260
+ }
261
+
262
+ if (!title) {
263
+ console.error("Error: --title is required.");
264
+ console.error('Usage: vellum roadmap create --title "My feature request"');
265
+ process.exitCode = 1;
266
+ return;
267
+ }
268
+
269
+ const token = requireAuth();
270
+ const body: Record<string, unknown> = { title };
271
+ if (description) body.description = description;
272
+ if (tags.length > 0) body.tags = tags;
273
+
274
+ const response = await apiFetch("/v1/roadmap", {
275
+ method: "POST",
276
+ token,
277
+ body,
278
+ });
279
+ if (!response.ok) return handleError(response, "create roadmap item");
280
+
281
+ const item = (await response.json()) as {
282
+ slug: string;
283
+ title: string;
284
+ status: string;
285
+ };
286
+
287
+ const webUrl = getWebUrl();
288
+ console.log(`Created roadmap item: ${sanitize(item.title)}`);
289
+ console.log(` slug: ${item.slug}`);
290
+ console.log(` status: ${item.status}`);
291
+ console.log(` url: ${makeLink(`${webUrl}/roadmap/${item.slug}`)}`);
292
+ }
293
+
294
+ // ── update ──
295
+
296
+ async function roadmapUpdate(args: string[]): Promise<void> {
297
+ const slug = requireSlug(args, "update");
298
+
299
+ let title: string | undefined;
300
+ let description: string | undefined;
301
+ let status: string | undefined;
302
+ const tags: string[] = [];
303
+
304
+ for (let i = 1; i < args.length; i++) {
305
+ switch (args[i]) {
306
+ case "--title":
307
+ title = consumeValue(args, i, "--title");
308
+ i++;
309
+ break;
310
+ case "--description":
311
+ description = consumeValue(args, i, "--description");
312
+ i++;
313
+ break;
314
+ case "--status":
315
+ status = consumeValue(args, i, "--status");
316
+ i++;
317
+ break;
318
+ case "--tag":
319
+ tags.push(consumeValue(args, i, "--tag"));
320
+ i++;
321
+ break;
322
+ }
323
+ }
324
+
325
+ const body: Record<string, unknown> = {};
326
+ if (title !== undefined) body.title = title;
327
+ if (description !== undefined) body.description = description;
328
+ if (status !== undefined) body.status = status;
329
+ if (tags.length > 0) body.tags = tags;
330
+
331
+ if (Object.keys(body).length === 0) {
332
+ console.error("Error: at least one field to update is required.");
333
+ process.exitCode = 1;
334
+ return;
335
+ }
336
+
337
+ const token = requireAuth();
338
+ const response = await apiFetch(`/v1/roadmap/${slug}`, {
339
+ method: "PATCH",
340
+ token,
341
+ body,
342
+ });
343
+ if (!response.ok) return handleError(response, "update roadmap item");
344
+
345
+ const item = (await response.json()) as {
346
+ slug: string;
347
+ title: string;
348
+ status: string;
349
+ };
350
+
351
+ const webUrl = getWebUrl();
352
+ console.log(`Updated roadmap item: ${sanitize(item.title)}`);
353
+ console.log(` slug: ${item.slug}`);
354
+ console.log(` status: ${item.status}`);
355
+ console.log(` url: ${makeLink(`${webUrl}/roadmap/${item.slug}`)}`);
356
+ }
357
+
358
+ // ── delete ──
359
+
360
+ async function roadmapDelete(args: string[]): Promise<void> {
361
+ const slug = requireSlug(args, "delete");
362
+ const token = requireAuth();
363
+ const response = await apiFetch(`/v1/roadmap/${slug}`, {
364
+ method: "DELETE",
365
+ token,
366
+ });
367
+ if (!response.ok) return handleError(response, "delete roadmap item");
368
+
369
+ console.log(`Deleted roadmap item: ${slug}`);
370
+ }
371
+
372
+ // ── upvote / unvote ──
373
+
374
+ async function roadmapUpvote(args: string[]): Promise<void> {
375
+ const slug = requireSlug(args, "upvote");
376
+ const token = requireAuth();
377
+ const response = await apiFetch(`/v1/roadmap/${slug}/upvote`, {
378
+ method: "POST",
379
+ token,
380
+ });
381
+ if (!response.ok) return handleError(response, "upvote roadmap item");
382
+
383
+ const data = (await response.json()) as {
384
+ slug: string;
385
+ upvote_count: number;
386
+ };
387
+
388
+ console.log(`Upvoted: ${data.slug} (${data.upvote_count} total)`);
389
+ }
390
+
391
+ async function roadmapUnvote(args: string[]): Promise<void> {
392
+ const slug = requireSlug(args, "unvote");
393
+ const token = requireAuth();
394
+ const response = await apiFetch(`/v1/roadmap/${slug}/upvote`, {
395
+ method: "DELETE",
396
+ token,
397
+ });
398
+ if (!response.ok) return handleError(response, "remove upvote");
399
+
400
+ const data = (await response.json()) as {
401
+ slug: string;
402
+ upvote_count: number;
403
+ };
404
+
405
+ console.log(`Removed upvote: ${data.slug} (${data.upvote_count} total)`);
406
+ }
407
+
408
+ // ── main ──
409
+
410
+ export async function roadmap(): Promise<void> {
411
+ const args = process.argv.slice(3);
412
+ const sub = args[0];
413
+
414
+ if (!sub || sub === "--help" || sub === "-h") {
415
+ printUsage();
416
+ return;
417
+ }
418
+
419
+ switch (sub) {
420
+ case "list":
421
+ case "ls":
422
+ await roadmapList(args.slice(1));
423
+ break;
424
+ case "get":
425
+ case "show":
426
+ await roadmapGet(args.slice(1));
427
+ break;
428
+ case "create":
429
+ await roadmapCreate(args.slice(1));
430
+ break;
431
+ case "update":
432
+ await roadmapUpdate(args.slice(1));
433
+ break;
434
+ case "delete":
435
+ case "rm":
436
+ await roadmapDelete(args.slice(1));
437
+ break;
438
+ case "upvote":
439
+ await roadmapUpvote(args.slice(1));
440
+ break;
441
+ case "unvote":
442
+ await roadmapUnvote(args.slice(1));
443
+ break;
444
+ default:
445
+ console.error(`Unknown subcommand: ${sub}`);
446
+ printUsage();
447
+ process.exitCode = 1;
448
+ }
449
+ }
package/src/index.ts CHANGED
@@ -14,6 +14,7 @@ import { message } from "./commands/message";
14
14
  import { ps } from "./commands/ps";
15
15
  import { recover } from "./commands/recover";
16
16
  import { restore } from "./commands/restore";
17
+ import { roadmap } from "./commands/roadmap";
17
18
  import { retire } from "./commands/retire";
18
19
  import { rollback } from "./commands/rollback";
19
20
  import { setup } from "./commands/setup";
@@ -45,6 +46,7 @@ const commands = {
45
46
  recover,
46
47
  restore,
47
48
  retire,
49
+ roadmap,
48
50
  rollback,
49
51
  setup,
50
52
  sleep,
@@ -83,6 +85,7 @@ function printHelp(): void {
83
85
  " restore Restore data (and optionally version) from a .vbundle backup",
84
86
  );
85
87
  console.log(" retire Delete an assistant instance");
88
+ console.log(" roadmap Manage roadmap items");
86
89
  console.log(" rollback Roll back an assistant to a previous version");
87
90
  console.log(" setup Configure API keys interactively");
88
91
  console.log(" sleep Stop the assistant process");
@@ -0,0 +1,117 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { createServer, type Server } from "net";
3
+
4
+ import { findOpenPort } from "../port-allocator.js";
5
+
6
+ const HOST = "127.0.0.1";
7
+
8
+ async function bindBlocker(port: number, host: string = HOST): Promise<Server> {
9
+ return new Promise((resolve, reject) => {
10
+ const server = createServer();
11
+ server.once("error", reject);
12
+ server.once("listening", () => resolve(server));
13
+ server.listen(port, host);
14
+ });
15
+ }
16
+
17
+ async function closeServer(server: Server): Promise<void> {
18
+ return new Promise((resolve, reject) => {
19
+ server.close((err) => (err ? reject(err) : resolve()));
20
+ });
21
+ }
22
+
23
+ async function getEphemeralPort(): Promise<number> {
24
+ const server = await new Promise<Server>((resolve, reject) => {
25
+ const s = createServer();
26
+ s.once("error", reject);
27
+ s.once("listening", () => resolve(s));
28
+ s.listen(0, HOST);
29
+ });
30
+ const addr = server.address();
31
+ if (!addr || typeof addr === "string" || addr.port == null) {
32
+ throw new Error("Could not obtain ephemeral port");
33
+ }
34
+ const port = addr.port;
35
+ await closeServer(server);
36
+ return port;
37
+ }
38
+
39
+ describe("findOpenPort", () => {
40
+ test("returns the preferred port when it is free", async () => {
41
+ const port = await getEphemeralPort();
42
+ const result = await findOpenPort(port, { host: HOST });
43
+ expect(result).toBe(port);
44
+ });
45
+
46
+ test("walks past an in-use port and returns the next free one", async () => {
47
+ const blocked = await getEphemeralPort();
48
+ const blocker = await bindBlocker(blocked);
49
+ try {
50
+ const result = await findOpenPort(blocked, { host: HOST });
51
+ expect(result).toBeGreaterThan(blocked);
52
+ expect(result).toBeLessThanOrEqual(blocked + 50);
53
+ } finally {
54
+ await closeServer(blocker);
55
+ }
56
+ });
57
+
58
+ test("walks past two consecutive in-use ports", async () => {
59
+ const first = await getEphemeralPort();
60
+ const blockerA = await bindBlocker(first);
61
+ let blockerB: Server | null = null;
62
+ try {
63
+ // Best-effort grab of the next consecutive port; if the kernel
64
+ // handed it to someone else just before we got here, that's still a
65
+ // valid "two consecutive blockers" scenario for the walk.
66
+ try {
67
+ blockerB = await bindBlocker(first + 1);
68
+ } catch {
69
+ blockerB = null;
70
+ }
71
+ const result = await findOpenPort(first, { host: HOST });
72
+ expect(result).toBeGreaterThan(first + (blockerB ? 1 : 0));
73
+ } finally {
74
+ await closeServer(blockerA);
75
+ if (blockerB) await closeServer(blockerB);
76
+ }
77
+ });
78
+
79
+ test("throws when the entire requested window is in use", async () => {
80
+ const blocked = await getEphemeralPort();
81
+ const blocker = await bindBlocker(blocked);
82
+ try {
83
+ await expect(
84
+ findOpenPort(blocked, { host: HOST, maxAttempts: 1 }),
85
+ ).rejects.toThrow(/no open port/i);
86
+ } finally {
87
+ await closeServer(blocker);
88
+ }
89
+ });
90
+
91
+ test("rejects non-integer or out-of-range preferred port", async () => {
92
+ await expect(findOpenPort(0, { host: HOST })).rejects.toThrow(
93
+ /not a valid TCP port/i,
94
+ );
95
+ await expect(findOpenPort(65536, { host: HOST })).rejects.toThrow(
96
+ /not a valid TCP port/i,
97
+ );
98
+ await expect(findOpenPort(1.5, { host: HOST })).rejects.toThrow(
99
+ /not a valid TCP port/i,
100
+ );
101
+ });
102
+
103
+ test("rejects non-positive maxAttempts", async () => {
104
+ await expect(
105
+ findOpenPort(20100, { host: HOST, maxAttempts: 0 }),
106
+ ).rejects.toThrow(/maxAttempts/i);
107
+ });
108
+
109
+ test("does not leak the probe port — port is rebindable after resolution", async () => {
110
+ const port = await getEphemeralPort();
111
+ const found = await findOpenPort(port, { host: HOST });
112
+ expect(found).toBe(port);
113
+ // If the probe leaked a listener on `port`, this would throw EADDRINUSE.
114
+ const reuse = await bindBlocker(found);
115
+ await closeServer(reuse);
116
+ });
117
+ });
@@ -0,0 +1,85 @@
1
+ import { describe, expect, it } from "bun:test";
2
+
3
+ import { buildExecErrorMessage, exec, execOutput } from "../step-runner";
4
+
5
+ describe("buildExecErrorMessage", () => {
6
+ it("omits the argv from the header so secrets in args can't leak", () => {
7
+ // Realistic shape — docker hatch invocations pass `-e <NAME>=<val>`
8
+ // flags inline. If we ever regress and put argv in the header, this
9
+ // assertion catches it immediately.
10
+ const msg = buildExecErrorMessage("docker", 125, "stderr text", "");
11
+ expect(msg).not.toContain("ANTHROPIC_API_KEY");
12
+ expect(msg).not.toContain("OPENAI_API_KEY");
13
+ expect(msg.startsWith("docker exited with code 125")).toBe(true);
14
+ });
15
+
16
+ it("appends stderr below the header when present", () => {
17
+ const msg = buildExecErrorMessage("docker", 125, " bind failed\n", "");
18
+ expect(msg).toBe("docker exited with code 125\nbind failed");
19
+ });
20
+
21
+ it("appends stdout when stderr is empty", () => {
22
+ const msg = buildExecErrorMessage("docker", 1, "", "stdout-only\n");
23
+ expect(msg).toBe("docker exited with code 1\nstdout-only");
24
+ });
25
+
26
+ it("appends both streams joined by newline when both present", () => {
27
+ const msg = buildExecErrorMessage("docker", 1, "stderr-line", "stdout-line");
28
+ expect(msg).toBe("docker exited with code 1\nstderr-line\nstdout-line");
29
+ });
30
+
31
+ it("collapses an empty output to just the header", () => {
32
+ const msg = buildExecErrorMessage("docker", 1, " ", "\n");
33
+ expect(msg).toBe("docker exited with code 1");
34
+ });
35
+
36
+ it("handles a null exit code (signal-terminated child)", () => {
37
+ const msg = buildExecErrorMessage("docker", null, "killed", "");
38
+ expect(msg).toBe("docker exited with an unknown code\nkilled");
39
+ });
40
+ });
41
+
42
+ describe("exec — secret leak regression", () => {
43
+ it("rejects with an Error whose message contains neither the args nor any -e KEY=VALUE pair", async () => {
44
+ // The classic hatch failure shape: docker invoked with several
45
+ // -e flags, exiting non-zero. Without the fix, args.join(" ")
46
+ // would put `-e ANTHROPIC_API_KEY=sk-ant-…` into err.message.
47
+ const fakeSecret = "sk-ant-this-should-never-appear-in-logs";
48
+ try {
49
+ await exec("sh", [
50
+ "-c",
51
+ `echo "bind for 0.0.0.0:20100 failed: port is already allocated" 1>&2 && exit 125`,
52
+ "-e",
53
+ `ANTHROPIC_API_KEY=${fakeSecret}`,
54
+ ]);
55
+ throw new Error("exec should have rejected");
56
+ } catch (err) {
57
+ const message = err instanceof Error ? err.message : String(err);
58
+ expect(message).not.toContain(fakeSecret);
59
+ expect(message).not.toContain("ANTHROPIC_API_KEY");
60
+ expect(message).toContain("sh exited with code 125");
61
+ expect(message).toContain("port is already allocated");
62
+ }
63
+ });
64
+ });
65
+
66
+ describe("execOutput — secret leak regression", () => {
67
+ it("rejects with an Error whose message contains neither the args nor any -e KEY=VALUE pair", async () => {
68
+ const fakeSecret = "sk-openai-leak-canary";
69
+ try {
70
+ await execOutput("sh", [
71
+ "-c",
72
+ `echo "no such container" 1>&2 && exit 1`,
73
+ "-e",
74
+ `OPENAI_API_KEY=${fakeSecret}`,
75
+ ]);
76
+ throw new Error("execOutput should have rejected");
77
+ } catch (err) {
78
+ const message = err instanceof Error ? err.message : String(err);
79
+ expect(message).not.toContain(fakeSecret);
80
+ expect(message).not.toContain("OPENAI_API_KEY");
81
+ expect(message).toContain("sh exited with code 1");
82
+ expect(message).toContain("no such container");
83
+ }
84
+ });
85
+ });