@botbuddy/cli 1.5.0 → 1.5.1

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.
@@ -1,756 +0,0 @@
1
- // BOT-1220 — `botbuddy stack` unit + spawn tests (bb-wait pattern).
2
- // * pure helpers: slot derivation, arg parsing, receipt build/truncate, status parse
3
- // * --help snapshot (AC-4)
4
- // * spawn tests: documented exit codes + exactly ONE JSON-line receipt to stdout (AC-4)
5
- import test from "node:test";
6
- import assert from "node:assert/strict";
7
- import { spawnSync } from "node:child_process";
8
- import { EventEmitter } from "node:events";
9
- import { fileURLToPath } from "node:url";
10
- import { dirname, join } from "node:path";
11
- import { mkdtempSync, writeFileSync, mkdirSync, realpathSync, readFileSync } from "node:fs";
12
- import { tmpdir } from "node:os";
13
-
14
- import {
15
- EXIT, STACK_HELP, STACK_SCHEMA_VERSION, DEFAULT_RECEIPT_MAX_BYTES,
16
- deriveSlot, parseStackArgs, buildReceipt, truncateReceipt,
17
- parseSupabaseStatus, eventStreamBase, resolveLocalSupabaseUrl, resolveStackPath,
18
- runLocalExecPreflight, dockerEnvForSelector, dockerTargetFromPreflight, connectionWithDockerTarget,
19
- proveLegacyLocalExecTarget, localTeardown, cmdUp, cmdDone,
20
- runStackLifecycle, materializeLeasedTestConfig, waitForLease,
21
- } from "./stack.mjs";
22
-
23
- const BIN = join(dirname(fileURLToPath(import.meta.url)), "..", "bin", "botbuddy.mjs");
24
-
25
- // Run the real CLI with a throwaway HOME so no ambient ~/.botbuddy config leaks in.
26
- function runStack(args, extraEnv = {}) {
27
- const home = mkdtempSync(join(tmpdir(), "bb-stack-home-"));
28
- return spawnSync("node", [BIN, "stack", ...args], {
29
- encoding: "utf8",
30
- env: { ...process.env, HOME: home, USERPROFILE: home, ...extraEnv },
31
- });
32
- }
33
-
34
- // The single JSON-line receipt discipline: stdout is exactly one line and it parses.
35
- function soleReceipt(res) {
36
- const lines = res.stdout.split("\n").filter((l) => l.trim() !== "");
37
- assert.equal(lines.length, 1, `expected exactly one stdout line, got ${lines.length}:\n${res.stdout}`);
38
- return JSON.parse(lines[0]);
39
- }
40
-
41
- // ── deriveSlot ───────────────────────────────────────────────────────────────
42
- test("deriveSlot: explicit --slot wins", () => {
43
- assert.equal(deriveSlot({ slot: "56322" }, {}), "56322");
44
- });
45
- test("deriveSlot: derives <repo>-<ticket> when both given", () => {
46
- assert.equal(deriveSlot({ repo: "botbuddy-web", ticket: "BOT-1220" }, {}), "botbuddy-web-bot-1220");
47
- });
48
- test("deriveSlot: BB_STACK_SLOT env fallback", () => {
49
- assert.equal(deriveSlot({}, { BB_STACK_SLOT: "56999" }), "56999");
50
- });
51
- test('deriveSlot: rejects "default"', () => {
52
- assert.throws(() => deriveSlot({ slot: "default" }, {}), /default.*rejected/i);
53
- });
54
- test("deriveSlot: throws when undeterminable", () => {
55
- assert.throws(() => deriveSlot({}, {}), /cannot determine a stack slot/);
56
- });
57
-
58
- // ── parseStackArgs ───────────────────────────────────────────────────────────
59
- test("parseStackArgs: up options", () => {
60
- const { command, opts, errors } = parseStackArgs([
61
- "up", "--slot", "56322", "--host", "mac", "--repo", "botbuddy-web",
62
- "--ticket", "BOT-1220", "--stack-path", "infra/local", "--idle-ttl", "600", "--timeout", "120", "--no-wait", "--local-exec", "--docker-context", "orbstack",
63
- ]);
64
- assert.equal(errors.length, 0);
65
- assert.equal(command, "up");
66
- assert.equal(opts.slot, "56322");
67
- assert.equal(opts.host, "mac");
68
- assert.equal(opts.stackPath, "infra/local");
69
- assert.equal(opts.idleTtl, 600);
70
- assert.equal(opts.timeout, 120);
71
- assert.equal(opts.noWait, true);
72
- assert.equal(opts.localExec, true);
73
- assert.equal(opts.dockerContext, "orbstack");
74
- });
75
- test("parseStackArgs: local-exec up requires exactly one explicit Docker selector", () => {
76
- assert.match(parseStackArgs(["up", "--slot", "x", "--local-exec"]).errors.join(" "), /docker-context.*docker-endpoint/i);
77
- assert.match(parseStackArgs([
78
- "up", "--slot", "x", "--local-exec", "--docker-context", "orbstack", "--docker-endpoint", "unix:///tmp/docker.sock",
79
- ]).errors.join(" "), /exactly one/i);
80
- assert.deepEqual(parseStackArgs([
81
- "up", "--slot", "x", "--local-exec", "--docker-endpoint", "unix:///Users/test/.orbstack/run/docker.sock",
82
- ]).errors, []);
83
- });
84
-
85
- test("parseStackArgs: local-exec done requires exactly one explicit Docker selector", () => {
86
- assert.match(parseStackArgs(["done", "lease-1", "--local-exec"]).errors.join(" "), /docker-context.*docker-endpoint/i);
87
- assert.equal(parseStackArgs([
88
- "done", "lease-1", "--local-exec", "--docker-context", "orbstack",
89
- ]).errors.length, 0);
90
- assert.ok(parseStackArgs([
91
- "done", "lease-1", "--local-exec", "--docker-context", "orbstack", "--docker-endpoint", "unix:///tmp/docker.sock",
92
- ]).errors.length > 0);
93
- });
94
- test("parseStackArgs: a missing Docker selector value preserves the following flag", () => {
95
- const parsed = parseStackArgs(["up", "--slot", "x", "--local-exec", "--docker-context", "--json"]);
96
- assert.equal(parsed.opts.json, true);
97
- assert.match(parsed.errors.join(" "), /--docker-context needs a value/i);
98
- });
99
-
100
- test("runLocalExecPreflight delegates the explicit selector and preserves refusal", () => {
101
- const calls = [];
102
- const expected = { exitCode: 2, receipt: { outcome: "refused", pressure: { status: "fail" } }, json: true };
103
- const actual = runLocalExecPreflight({ dockerContext: "orbstack", dockerEndpoint: null }, (args) => {
104
- calls.push(args);
105
- return expected;
106
- });
107
- assert.deepEqual(calls, [["preflight", "--context", "orbstack", "--json"]]);
108
- assert.equal(actual, expected);
109
- });
110
-
111
- test("dockerEnvForSelector replaces ambient Docker routing with the preflight target", () => {
112
- assert.deepEqual(dockerEnvForSelector(
113
- { dockerContext: "orbstack", dockerEndpoint: null },
114
- { PATH: "/bin", DOCKER_CONTEXT: "desktop-linux", DOCKER_HOST: "tcp://wrong" },
115
- ), { PATH: "/bin", DOCKER_CONTEXT: "orbstack" });
116
- assert.deepEqual(dockerEnvForSelector(
117
- { dockerContext: null, dockerEndpoint: "unix:///Users/test/.orbstack/run/docker.sock" },
118
- { PATH: "/bin", DOCKER_CONTEXT: "desktop-linux" },
119
- ), { PATH: "/bin", DOCKER_HOST: "unix:///Users/test/.orbstack/run/docker.sock" });
120
- });
121
-
122
- test("localTeardown passes the explicit Docker selector to supabase stop", () => {
123
- let invocation;
124
- const ok = localTeardown(
125
- { dockerContext: "orbstack", dockerEndpoint: null },
126
- { validation: "orbstack", resolved_endpoint: "unix:///Users/test/.orbstack/run/docker.sock", server_id: "daemon-1" },
127
- (command, args, options) => {
128
- invocation = { command, args, env: options.env };
129
- return { status: 0, stdout: "", stderr: "" };
130
- },
131
- { PATH: "/bin", DOCKER_CONTEXT: "desktop-linux", DOCKER_HOST: "tcp://wrong" },
132
- );
133
- assert.equal(ok, true);
134
- assert.deepEqual(invocation, {
135
- command: "supabase",
136
- args: ["stop", "--workdir", process.cwd()],
137
- env: { PATH: "/bin", DOCKER_HOST: "unix:///Users/test/.orbstack/run/docker.sock" },
138
- });
139
- });
140
-
141
- const targetReceipt = (serverId, endpoint = "unix:///Users/test/.orbstack/run/docker.sock") => ({
142
- context: {
143
- validation: "orbstack",
144
- resolved_endpoint: endpoint,
145
- server: { id: serverId, name: "orbstack", operating_system: "OrbStack" },
146
- },
147
- pressure: { status: "ok" },
148
- });
149
-
150
- test("dockerTargetFromPreflight requires a resolved OrbStack server identity", () => {
151
- assert.deepEqual(dockerTargetFromPreflight(targetReceipt("daemon-1")), {
152
- validation: "orbstack",
153
- resolved_endpoint: "unix:///Users/test/.orbstack/run/docker.sock",
154
- server_id: "daemon-1",
155
- });
156
- assert.equal(dockerTargetFromPreflight(targetReceipt(null)), null);
157
- });
158
-
159
- test("connectionWithDockerTarget persists the validated target in the lease connection", () => {
160
- const target = dockerTargetFromPreflight(targetReceipt("daemon-1"));
161
- assert.deepEqual(connectionWithDockerTarget({ api_url: "http://127.0.0.1:56321" }, target), {
162
- api_url: "http://127.0.0.1:56321",
163
- botbuddy_docker_target: target,
164
- });
165
- });
166
-
167
- test("cmdDone refuses before release when teardown targets a different Docker daemon", async () => {
168
- const tools = [];
169
- let emitted;
170
- const code = await cmdDone("lease-1", {
171
- localExec: true, dockerContext: "orbstack", dockerEndpoint: null,
172
- disposition: "destroy", receiptMaxBytes: DEFAULT_RECEIPT_MAX_BYTES,
173
- }, {
174
- callTool: async (name) => {
175
- tools.push(name);
176
- return { ok: true, data: { success: true, state: "active", connection: {
177
- botbuddy_docker_target: dockerTargetFromPreflight(targetReceipt("daemon-1")),
178
- } } };
179
- },
180
- runPreflight: () => ({ exitCode: 0, receipt: targetReceipt("daemon-2") }),
181
- localTeardownFn: () => { throw new Error("must not stop"); },
182
- emitResult: (receipt, _opts, exitCode) => { emitted = receipt; return exitCode; },
183
- });
184
- assert.equal(code, EXIT.LEASE_FAILED);
185
- assert.deepEqual(tools, ["get_stack_lease"]);
186
- assert.equal(emitted.outcome, "refused");
187
- });
188
-
189
- test("proveLegacyLocalExecTarget accepts only an exact owning-worktree and live connection match", () => {
190
- const target = dockerTargetFromPreflight(targetReceipt("daemon-1"));
191
- const worktreeRoot = realpathSync(process.cwd());
192
- const lease = {
193
- worktree_root: worktreeRoot,
194
- stack_path: ".",
195
- connection: {
196
- api_url: "http://127.0.0.1:56321",
197
- db_url: "postgresql://postgres:postgres@127.0.0.1:56322/postgres",
198
- },
199
- };
200
- const spawned = [];
201
- const proof = proveLegacyLocalExecTarget(lease, target, { stackPath: "." }, (command, args, options) => {
202
- spawned.push({ command, args, env: options.env });
203
- return { status: 0, stdout: JSON.stringify({
204
- API_URL: lease.connection.api_url,
205
- DB_URL: lease.connection.db_url,
206
- }), stderr: "" };
207
- });
208
-
209
- assert.equal(proof.ok, true);
210
- assert.equal(proof.evidence.method, "legacy_worktree_connection_match");
211
- assert.equal(spawned[0].env.DOCKER_HOST, target.resolved_endpoint);
212
- assert.deepEqual(spawned[0].args, ["status", "-o", "json", "--workdir", worktreeRoot]);
213
-
214
- const mismatch = proveLegacyLocalExecTarget(lease, target, { stackPath: "." }, () => ({
215
- status: 0,
216
- stdout: JSON.stringify({ API_URL: "http://127.0.0.1:64321", DB_URL: lease.connection.db_url }),
217
- stderr: "",
218
- }));
219
- assert.equal(mismatch.ok, false);
220
- assert.match(mismatch.error, /connection.*does not match/i);
221
- });
222
-
223
- test("cmdDone safely tears down a pre-1.5.0 lease after the legacy compatibility proof", async () => {
224
- const tools = [];
225
- let emitted;
226
- const target = dockerTargetFromPreflight(targetReceipt("daemon-1"));
227
- const code = await cmdDone("lease-legacy", {
228
- localExec: true, dockerContext: "orbstack", dockerEndpoint: null, stackPath: ".",
229
- disposition: "destroy", receiptMaxBytes: DEFAULT_RECEIPT_MAX_BYTES,
230
- }, {
231
- callTool: async (name) => {
232
- tools.push(name);
233
- if (name === "get_stack_lease") return { ok: true, data: {
234
- success: true, state: "active", worktree_root: process.cwd(), stack_path: ".",
235
- connection: { api_url: "http://127.0.0.1:56321", db_url: "postgresql://127.0.0.1:56322/postgres" },
236
- } };
237
- if (name === "release_stack_lease") return { ok: true, data: { success: true, state: "reaping" } };
238
- if (name === "finalize_stack_lease") return { ok: true, data: { success: true, state: "reaped" } };
239
- throw new Error(`unexpected tool ${name}`);
240
- },
241
- runPreflight: () => ({ exitCode: 0, receipt: targetReceipt("daemon-1") }),
242
- proveLegacyTarget: () => ({ ok: true, evidence: { method: "legacy_worktree_connection_match" } }),
243
- localTeardownFn: () => true,
244
- emitResult: (receipt, _opts, exitCode) => { emitted = receipt; return exitCode; },
245
- });
246
-
247
- assert.equal(code, EXIT.OK);
248
- assert.deepEqual(tools, ["get_stack_lease", "release_stack_lease", "finalize_stack_lease"]);
249
- assert.deepEqual(emitted.legacy_target_proof, { method: "legacy_worktree_connection_match" });
250
- });
251
-
252
- test("cmdDone refuses a legacy lease before release when compatibility proof is absent", async () => {
253
- const tools = [];
254
- let emitted;
255
- const code = await cmdDone("lease-legacy", {
256
- localExec: true, dockerContext: "orbstack", dockerEndpoint: null, stackPath: ".",
257
- disposition: "destroy", receiptMaxBytes: DEFAULT_RECEIPT_MAX_BYTES,
258
- }, {
259
- callTool: async (name) => {
260
- tools.push(name);
261
- return { ok: true, data: { success: true, state: "active", connection: { api_url: "http://127.0.0.1:56321" } } };
262
- },
263
- runPreflight: () => ({ exitCode: 0, receipt: targetReceipt("daemon-1") }),
264
- proveLegacyTarget: () => ({ ok: false, error: "legacy worktree metadata is missing" }),
265
- localTeardownFn: () => { throw new Error("must not stop"); },
266
- emitResult: (receipt, _opts, exitCode) => { emitted = receipt; return exitCode; },
267
- });
268
-
269
- assert.equal(code, EXIT.LEASE_FAILED);
270
- assert.deepEqual(tools, ["get_stack_lease"]);
271
- assert.match(emitted.error, /legacy worktree metadata is missing/i);
272
- });
273
-
274
- test("cmdDone stops and finalizes only after the fresh Docker identity matches the lease", async () => {
275
- const tools = [];
276
- let teardownTarget;
277
- const target = dockerTargetFromPreflight(targetReceipt("daemon-1"));
278
- const code = await cmdDone("lease-1", {
279
- localExec: true, dockerContext: "orbstack", dockerEndpoint: null,
280
- disposition: "destroy", receiptMaxBytes: DEFAULT_RECEIPT_MAX_BYTES,
281
- }, {
282
- callTool: async (name) => {
283
- tools.push(name);
284
- if (name === "get_stack_lease") return { ok: true, data: { success: true, state: "active", connection: { botbuddy_docker_target: target } } };
285
- if (name === "release_stack_lease") return { ok: true, data: { success: true, state: "reaping" } };
286
- if (name === "finalize_stack_lease") return { ok: true, data: { success: true, state: "reaped" } };
287
- throw new Error(`unexpected tool ${name}`);
288
- },
289
- runPreflight: () => ({ exitCode: 0, receipt: targetReceipt("daemon-1") }),
290
- localTeardownFn: (_opts, actual) => { teardownTarget = actual; return true; },
291
- emitResult: (_receipt, _opts, exitCode) => exitCode,
292
- });
293
- assert.equal(code, EXIT.OK);
294
- assert.deepEqual(tools, ["get_stack_lease", "release_stack_lease", "finalize_stack_lease"]);
295
- assert.deepEqual(teardownTarget, target);
296
- });
297
-
298
- test("cmdUp refuses pressure before lease request or local provisioning", async () => {
299
- const events = [];
300
- let emitted;
301
- const code = await cmdUp({
302
- slot: "botbuddy-web-bot-1405", repo: "botbuddy-web", ticket: "BOT-1405",
303
- stackPath: ".", localExec: true, dockerContext: "orbstack", dockerEndpoint: null,
304
- }, {
305
- runPreflight: () => ({
306
- exitCode: 2,
307
- receipt: {
308
- outcome: "refused", context: { selector: "orbstack" },
309
- pressure: { status: "fail", projected_pressure_units: 224 },
310
- warnings: [], errors: ["interface pressure refusal"], recommendation: "run hygiene",
311
- },
312
- json: true,
313
- }),
314
- callTool: async (...args) => { events.push(["tool", ...args]); throw new Error("must not request a lease"); },
315
- localProvisionFn: () => { events.push(["provision"]); throw new Error("must not start Supabase"); },
316
- emitResult: (receipt, _opts, exitCode) => { emitted = receipt; return exitCode; },
317
- });
318
-
319
- assert.equal(code, EXIT.LEASE_FAILED);
320
- assert.equal(emitted.outcome, "refused");
321
- assert.equal(emitted.preflight.pressure.status, "fail");
322
- assert.deepEqual(events, []);
323
- });
324
-
325
- test("cmdUp atomically cancels an unclaimed lease when the just-in-time preflight refuses", async () => {
326
- const tools = [];
327
- let preflights = 0;
328
- let emitted;
329
- const okReceipt = {
330
- ...targetReceipt("daemon-1"), outcome: "ok", pressure: { status: "ok" },
331
- warnings: [], errors: [], recommendation: "none",
332
- };
333
- const refusedReceipt = {
334
- ...targetReceipt("daemon-1"), outcome: "refused", pressure: { status: "fail" },
335
- warnings: [], errors: ["interface pressure refusal"], recommendation: "run hygiene",
336
- };
337
- const code = await cmdUp({
338
- slot: "botbuddy-web-bot-1405", host: "test-mac", repo: "botbuddy-web", ticket: "BOT-1405",
339
- stackPath: ".", localExec: true, dockerContext: "orbstack", dockerEndpoint: null,
340
- timeout: 60, noWait: false, json: false,
341
- }, {
342
- authProvider: () => ({ Authorization: "test" }),
343
- runPreflight: () => ({
344
- exitCode: preflights++ === 0 ? 0 : 2,
345
- receipt: preflights === 1 ? okReceipt : refusedReceipt,
346
- json: true,
347
- }),
348
- callTool: async (tool, args) => {
349
- tools.push([tool, args]);
350
- if (tool === "request_stack_lease") return { ok: true, data: { success: true, lease_id: "lease-1", state: "provisioning" } };
351
- if (tool === "cancel_unclaimed_stack_lease") return { ok: true, data: { success: true, state: "reaped", provision_job_cancelled: true } };
352
- throw new Error(`unexpected tool ${tool}`);
353
- },
354
- localProvisionFn: () => { throw new Error("must not start Supabase"); },
355
- emitResult: (receipt, _opts, exitCode) => { emitted = receipt; return exitCode; },
356
- });
357
-
358
- assert.equal(code, EXIT.LEASE_FAILED);
359
- assert.deepEqual(tools.map(([tool]) => tool), ["request_stack_lease", "cancel_unclaimed_stack_lease"]);
360
- assert.equal(emitted.outcome, "refused");
361
- assert.deepEqual(emitted.lease_cancellation, { success: true, state: "reaped", provision_job_cancelled: true });
362
- });
363
-
364
- test("cmdUp persists the validated Docker identity used by local provisioning", async () => {
365
- const target = dockerTargetFromPreflight(targetReceipt("daemon-1"));
366
- let provisionTarget;
367
- let activatedConnection;
368
- const code = await cmdUp({
369
- slot: "botbuddy-web-bot-1405", host: "test-mac", repo: "botbuddy-web", ticket: "BOT-1405",
370
- stackPath: ".", localExec: true, dockerContext: "orbstack", dockerEndpoint: null,
371
- timeout: 60, noWait: false, json: false,
372
- }, {
373
- authProvider: () => ({ Authorization: "test" }),
374
- runPreflight: () => ({ exitCode: 0, receipt: { ...targetReceipt("daemon-1"), outcome: "ok", warnings: [], errors: [] } }),
375
- localProvisionFn: (_opts, actualTarget) => {
376
- provisionTarget = actualTarget;
377
- return { api_url: "http://127.0.0.1:56321" };
378
- },
379
- callTool: async (name, args) => {
380
- if (name === "request_stack_lease") return { ok: true, data: { success: true, lease_id: "lease-1", state: "provisioning" } };
381
- if (name === "activate_stack_lease") {
382
- activatedConnection = args.connection;
383
- return { ok: true, data: { success: true, state: "active" } };
384
- }
385
- if (name === "get_stack_lease") return { ok: true, data: { success: true, state: "active", slot: "botbuddy-web-bot-1405", connection: activatedConnection } };
386
- throw new Error(`unexpected tool ${name}`);
387
- },
388
- emitResult: (_receipt, _opts, exitCode) => exitCode,
389
- });
390
- assert.equal(code, EXIT.OK);
391
- assert.deepEqual(provisionTarget, target);
392
- assert.deepEqual(activatedConnection.botbuddy_docker_target, target);
393
- });
394
- test("parseStackArgs: rejects absolute and escaping stack paths", () => {
395
- assert.ok(parseStackArgs(["up", "--stack-path", "/tmp/stack"]).errors.length > 0);
396
- assert.ok(parseStackArgs(["up", "--stack-path", "../stack"]).errors.length > 0);
397
- assert.ok(parseStackArgs(["up", "--stack-path", "sub/../../stack"]).errors.length > 0);
398
- });
399
- test("resolveStackPath: canonicalizes an in-worktree directory", () => {
400
- const root = mkdtempSync(join(tmpdir(), "bb-stack-root-"));
401
- mkdirSync(join(root, "stack"));
402
- assert.deepEqual(resolveStackPath(root, "stack"), { worktreeRoot: realpathSync(root), stackPath: "stack" });
403
- });
404
- test("parseStackArgs: status/touch/done require a lease_id", () => {
405
- for (const c of ["status", "touch", "done"]) {
406
- const { errors } = parseStackArgs([c]);
407
- assert.ok(errors.some((e) => e.includes("lease_id")), `${c} should require lease_id`);
408
- }
409
- const { leaseId, errors } = parseStackArgs(["status", "abc-123"]);
410
- assert.equal(errors.length, 0);
411
- assert.equal(leaseId, "abc-123");
412
- });
413
- test("parseStackArgs: unknown option is an error", () => {
414
- const { errors } = parseStackArgs(["up", "--bogus"]);
415
- assert.ok(errors.some((e) => e.includes("--bogus")));
416
- });
417
- test("parseStackArgs: --idle-ttl / --timeout must be positive integers", () => {
418
- assert.ok(parseStackArgs(["up", "--idle-ttl", "0"]).errors.length > 0);
419
- assert.ok(parseStackArgs(["up", "--timeout", "-5"]).errors.length > 0);
420
- assert.ok(parseStackArgs(["up", "--idle-ttl", "x"]).errors.length > 0);
421
- });
422
- test("parseStackArgs: --stop sets disposition stop (default destroy)", () => {
423
- assert.equal(parseStackArgs(["done", "id"]).opts.disposition, "destroy");
424
- assert.equal(parseStackArgs(["done", "id", "--stop"]).opts.disposition, "stop");
425
- });
426
- test("BOT-1346: run requires an argv boundary and rejects direct Docker fallback", () => {
427
- assert.ok(parseStackArgs(["run", "--repo", "botbuddy-web", "--ticket", "BOT-1346"]).errors.some((e) => e.includes("executable")));
428
- assert.ok(parseStackArgs(["run", "--repo", "botbuddy-web", "--ticket", "BOT-1346", "--local-exec", "--", "echo", "ok"]).errors.some((e) => e.includes("forbidden")));
429
- const parsed = parseStackArgs(["run", "--repo", "botbuddy-web", "--ticket", "BOT-1346", "--", "node", "-e", "process.exit(0)"]);
430
- assert.equal(parsed.errors.length, 0);
431
- assert.deepEqual(parsed.childArgv, ["node", "-e", "process.exit(0)"]);
432
- });
433
-
434
- test("BOT-1346: a clean lease-stream EOF honours the original deadline", async () => {
435
- const requests = [];
436
- const expired = await waitForLease(
437
- "lease-1346", () => false, () => false,
438
- { timeoutSec: 5, deadlineMs: Date.now() - 1, auth: { Authorization: "Bearer test" } },
439
- async (_url, options = {}) => {
440
- requests.push(options.method ?? "GET");
441
- if (options.method === "POST") return new Response(JSON.stringify({ cursor_start: null }), { status: 200 });
442
- return new Response(new ReadableStream({ start(controller) { controller.close(); } }), { status: 200 });
443
- },
444
- );
445
- assert.deepEqual(expired, { timeout: true });
446
- assert.deepEqual(requests, ["POST", "GET"]);
447
- });
448
-
449
- test("BOT-1346: a stalled lease-wait registration is aborted at its deadline", async () => {
450
- let registrationSignal = null;
451
- const result = await waitForLease(
452
- "lease-1346", () => false, () => false,
453
- { timeoutSec: 0.02, auth: { Authorization: "Bearer test" } },
454
- async (_url, options = {}) => {
455
- registrationSignal = options.signal;
456
- return await new Promise((_resolve, reject) => options.signal.addEventListener("abort", () => reject(new Error("aborted")), { once: true }));
457
- },
458
- );
459
- assert.equal(registrationSignal.aborted, true);
460
- assert.deepEqual(result, { timeout: true });
461
- });
462
-
463
- test("BOT-1346: stack run owns active → argv child → signed reap and never exposes connection", async () => {
464
- const calls = [];
465
- const signals = new EventEmitter();
466
- const fakeChild = new EventEmitter(); fakeChild.pid = 4242;
467
- let written = null; const removed = []; let childCall = null;
468
- const result = await runStackLifecycle(
469
- { repo: "botbuddy-web", ticket: "BOT-1346", stackPath: ".", timeout: 5, reapTimeout: 5, connectionFile: "/tmp/bot-1346-connection.json" },
470
- ["pnpm", "test:all"],
471
- {
472
- auth: { Authorization: "Bearer test" }, signals,
473
- api: {
474
- request: async () => ({ ok: true, data: { success: true, lease_id: "lease-1346", state: "active" } }),
475
- get: async () => ({ ok: true, data: { success: true, state: "active", idle_ttl_secs: 30, connection: { db_url: "postgres://secret" } } }),
476
- touch: async () => ({ ok: true, data: { success: true } }),
477
- release: async (id) => { calls.push(["release", id]); return { ok: true, data: { success: true, state: "reaping" } }; },
478
- },
479
- wait: async (_id, done) => done("reaped") ? { woke: true, state: "reaped" } : { woke: true, state: "active" },
480
- writeConnection: async (path, connection) => { written = { path, connection }; return path; },
481
- materializeTestConfig: async () => ({
482
- envFile: "/tmp/bot-1346-integration.env",
483
- stackConfig: "/tmp/bot-1346-stack.toml",
484
- }),
485
- removeConnection: async (path) => { removed.push(path); },
486
- startChild: (argv, env, cwd) => { childCall = { argv, env, cwd }; queueMicrotask(() => fakeChild.emit("exit", 7, null)); return fakeChild; },
487
- clock: { setInterval: () => ({ unref() {} }), clearInterval() {}, setTimeout: () => ({ unref() {} }), clearTimeout() {} },
488
- },
489
- );
490
- assert.equal(result.exitCode, 7, "successful cleanup must not mask the child failure");
491
- assert.deepEqual(childCall.argv, ["pnpm", "test:all"], "argv is forwarded without shell parsing");
492
- assert.equal(childCall.env.BOTBUDDY_STACK_LEASE_ID, "lease-1346");
493
- assert.equal(childCall.env.BOTBUDDY_STACK_CONNECTION_FILE, written.path);
494
- assert.equal(childCall.env.BB_INTEGRATION_ENV_FILE, "/tmp/bot-1346-integration.env");
495
- assert.equal(childCall.env.BB_STACK_CONFIG, "/tmp/bot-1346-stack.toml");
496
- assert.equal(written.connection.db_url, "postgres://secret");
497
- assert.deepEqual(removed.sort(), [written.path, "/tmp/bot-1346-integration.env", "/tmp/bot-1346-stack.toml"].sort());
498
- assert.deepEqual(calls, [["release", "lease-1346"]]);
499
- assert.doesNotMatch(JSON.stringify(result), /postgres:\/\/secret/);
500
- });
501
-
502
- test("BOT-1346: materialized integration environment uses the leased database port", async () => {
503
- const root = mkdtempSync(join(tmpdir(), "bb-stack-integration-"));
504
- const integrationDir = join(root, "supabase", "functions", "_test", "integration");
505
- mkdirSync(integrationDir, { recursive: true });
506
- writeFileSync(join(integrationDir, ".env.integration"), "SUPABASE_URL=http://127.0.0.1:56321\nINTEGRATION_DB_PORT=56322\n");
507
- const config = await materializeLeasedTestConfig(root, "lease-port", {
508
- api_url: "http://127.0.0.1:64321", db_port: 64322, anon_key: "anon", service_role_key: "service", project_id: "leased-project",
509
- });
510
- assert.match(readFileSync(config.envFile, "utf8"), /^INTEGRATION_DB_PORT=64322$/m);
511
- assert.match(readFileSync(config.stackConfig, "utf8"), /port = 64322/);
512
- });
513
-
514
- test("BOT-1346: materialized integration config removes dotenv if its TOML write fails", async () => {
515
- const root = mkdtempSync(join(tmpdir(), "bb-stack-integration-"));
516
- const integrationDir = join(root, "supabase", "functions", "_test", "integration");
517
- mkdirSync(integrationDir, { recursive: true });
518
- writeFileSync(join(integrationDir, ".env.integration"), "SUPABASE_URL=http://127.0.0.1:56321\n");
519
- const writes = []; const removed = [];
520
- await assert.rejects(
521
- materializeLeasedTestConfig(root, "lease-cleanup", {
522
- api_url: "http://127.0.0.1:64321", db_port: 64322, anon_key: "anon", service_role_key: "service", project_id: "leased-project",
523
- }, {
524
- writePrivate: async (path) => { writes.push(path); if (writes.length === 2) throw new Error("ENOSPC"); },
525
- removePrivate: async (path) => { removed.push(path); },
526
- }),
527
- /ENOSPC/,
528
- );
529
- assert.equal(writes.length, 2);
530
- assert.deepEqual(removed, [writes[0]]);
531
- });
532
-
533
- test("BOT-1346: queue wait errors retain a failed signed-cleanup receipt", async () => {
534
- const result = await runStackLifecycle(
535
- { repo: "botbuddy-web", ticket: "BOT-1346", stackPath: ".", timeout: 5, reapTimeout: 5 },
536
- ["pnpm", "test:integration"],
537
- {
538
- auth: { Authorization: "Bearer test" }, signals: new EventEmitter(),
539
- api: {
540
- request: async () => ({ ok: true, data: { success: true, lease_id: "lease-queue-error", state: "queued" } }),
541
- release: async (_leaseId, signal) => {
542
- assert(signal instanceof AbortSignal, "cleanup release receives the reap deadline signal");
543
- return { ok: false, error: "signed reap unavailable" };
544
- },
545
- },
546
- wait: async () => ({ error: "lease stream broke" }),
547
- },
548
- );
549
- assert.equal(result.exitCode, EXIT.LEASE_FAILED);
550
- assert.equal(result.cleanup.ok, false);
551
- assert.match(result.cleanup.error, /signed reap unavailable/);
552
- });
553
-
554
- test("BOT-1346: queue timeout receipt retains a failed cleanup result", async () => {
555
- const result = await runStackLifecycle(
556
- { repo: "botbuddy-web", ticket: "BOT-1346", stackPath: ".", timeout: 5, reapTimeout: 5 }, ["pnpm", "test:integration"],
557
- {
558
- auth: { Authorization: "Bearer test" }, signals: new EventEmitter(),
559
- api: {
560
- request: async () => ({ ok: true, data: { success: true, lease_id: "lease-queued", state: "queued" } }),
561
- release: async () => ({ ok: false, error: "release rejected" }),
562
- },
563
- wait: async () => ({ timeout: true }),
564
- clock: { setInterval: () => ({ unref() {} }), clearInterval() {}, setTimeout: () => ({ unref() {} }), clearTimeout() {} },
565
- },
566
- );
567
- assert.equal(result.exitCode, EXIT.TIMEOUT);
568
- assert.equal(result.cleanup.ok, false);
569
- assert.match(result.cleanup.error, /release rejected/);
570
- });
571
-
572
- test("BOT-1346: interruption during lease creation releases the lease once its ID arrives", async () => {
573
- const signals = new EventEmitter();
574
- let resolveRequest; let releases = 0;
575
- const pending = runStackLifecycle(
576
- { repo: "botbuddy-web", ticket: "BOT-1346", stackPath: ".", timeout: 5, reapTimeout: 5 }, ["pnpm", "test:integration"],
577
- {
578
- auth: { Authorization: "Bearer test" }, signals,
579
- api: {
580
- request: () => new Promise((resolve) => { resolveRequest = resolve; }),
581
- release: async () => { releases++; return { ok: true, data: { success: true, state: "reaping" } }; },
582
- },
583
- wait: async () => ({ woke: true, state: "reaped" }),
584
- clock: { setInterval: () => ({ unref() {} }), clearInterval() {}, setTimeout: () => ({ unref() {} }), clearTimeout() {} },
585
- },
586
- );
587
- signals.emit("SIGINT");
588
- resolveRequest({ ok: true, data: { success: true, lease_id: "lease-interrupted-request", state: "queued" } });
589
- const result = await pending;
590
- assert.equal(result.exitCode, 130);
591
- assert.equal(releases, 1);
592
- assert.equal(result.cleanup.ok, true);
593
- });
594
-
595
- test("BOT-1346: forced hard TTL cannot turn an incomplete child run into success", async () => {
596
- const fakeChild = new EventEmitter(); fakeChild.pid = 4244;
597
- let hardTimeout;
598
- const result = await runStackLifecycle(
599
- { repo: "botbuddy-web", ticket: "BOT-1346", stackPath: ".", timeout: 5, reapTimeout: 5, hardTtl: 1 }, ["node", "-e", "process.exit(0)"],
600
- {
601
- auth: { Authorization: "Bearer test" }, signals: new EventEmitter(),
602
- api: {
603
- request: async () => ({ ok: true, data: { success: true, lease_id: "lease-ttl", state: "active" } }),
604
- get: async () => ({ ok: true, data: { success: true, state: "active", connection: {} } }),
605
- touch: async () => ({ ok: true, data: { success: true } }),
606
- release: async () => ({ ok: true, data: { success: true, state: "reaping" } }),
607
- },
608
- wait: async () => ({ woke: true, state: "reaped" }), writeConnection: async (path) => path, removeConnection: async () => {},
609
- startChild: () => { queueMicrotask(() => { hardTimeout(); fakeChild.emit("exit", 0, null); }); return fakeChild; },
610
- clock: { setInterval: () => ({ unref() {} }), clearInterval() {}, setTimeout: (fn) => { hardTimeout = fn; return { unref() {} }; }, clearTimeout() {} },
611
- },
612
- );
613
- assert.equal(result.exitCode, EXIT.TIMEOUT);
614
- });
615
-
616
- test("BOT-1346: a fenced lease cannot report a gracefully-stopped child as success", async () => {
617
- const fakeChild = new EventEmitter(); fakeChild.pid = 4245;
618
- let heartbeat;
619
- const result = await runStackLifecycle(
620
- { repo: "botbuddy-web", ticket: "BOT-1346", stackPath: ".", timeout: 5, reapTimeout: 5 }, ["node", "-e", "process.exit(0)"],
621
- {
622
- auth: { Authorization: "Bearer test" }, signals: new EventEmitter(),
623
- api: {
624
- request: async () => ({ ok: true, data: { success: true, lease_id: "lease-fenced", state: "active" } }),
625
- get: async () => ({ ok: true, data: { success: true, state: "active", connection: {} } }),
626
- touch: async () => ({ ok: false, error: "lease unavailable" }),
627
- release: async () => ({ ok: true, data: { success: true, state: "reaping" } }),
628
- },
629
- wait: async () => ({ woke: true, state: "reaped" }), writeConnection: async (path) => path, removeConnection: async () => {},
630
- startChild: () => { queueMicrotask(async () => { await heartbeat(); fakeChild.emit("exit", 0, null); }); return fakeChild; },
631
- clock: { setInterval: (fn) => { heartbeat = fn; return { unref() {} }; }, clearInterval() {}, setTimeout: () => ({ unref() {} }), clearTimeout() {} },
632
- },
633
- );
634
- assert.equal(result.exitCode, EXIT.LEASE_FAILED);
635
- assert.equal(result.fenced, true);
636
- });
637
-
638
- test("BOT-1346: stack run reports failed signed cleanup after a successful child", async () => {
639
- const fakeChild = new EventEmitter(); fakeChild.pid = 4243;
640
- const result = await runStackLifecycle(
641
- { repo: "botbuddy-web", ticket: "BOT-1346", stackPath: ".", timeout: 5, reapTimeout: 5 },
642
- ["node", "-e", "process.exit(0)"],
643
- {
644
- auth: { Authorization: "Bearer test" }, signals: new EventEmitter(),
645
- api: {
646
- request: async () => ({ ok: true, data: { success: true, lease_id: "lease-unreaped", state: "active" } }),
647
- get: async () => ({ ok: true, data: { success: true, state: "active", connection: {} } }),
648
- touch: async () => ({ ok: true, data: { success: true } }),
649
- release: async () => ({ ok: true, data: { success: true, state: "reaping" } }),
650
- },
651
- wait: async () => ({ timeout: true }),
652
- writeConnection: async (path) => path,
653
- removeConnection: async () => {},
654
- startChild: () => { queueMicrotask(() => fakeChild.emit("exit", 0, null)); return fakeChild; },
655
- clock: { setInterval: () => ({ unref() {} }), clearInterval() {}, setTimeout: () => ({ unref() {} }), clearTimeout() {} },
656
- },
657
- );
658
- assert.equal(result.childExitCode, 0);
659
- assert.equal(result.exitCode, EXIT.CLEANUP_FAILED);
660
- assert.equal(result.cleanup.ok, false);
661
- });
662
-
663
- // ── receipts ─────────────────────────────────────────────────────────────────
664
- test("buildReceipt: stamps schema_version", () => {
665
- assert.equal(buildReceipt({ command: "up" }).schema_version, STACK_SCHEMA_VERSION);
666
- });
667
- test("truncateReceipt: in-budget receipt is returned unchanged", () => {
668
- const r = buildReceipt({ command: "up", outcome: "active", lease_id: "x", exit_code: 0 });
669
- assert.deepEqual(truncateReceipt(r, DEFAULT_RECEIPT_MAX_BYTES), r);
670
- });
671
- test("truncateReceipt: collapses the oversized connection block first, keeps lease_id + exit_code", () => {
672
- const big = "k".repeat(20000);
673
- const r = buildReceipt({ command: "up", outcome: "active", lease_id: "lease-1", exit_code: 0, connection: { service_role_key: big } });
674
- const t = truncateReceipt(r, DEFAULT_RECEIPT_MAX_BYTES);
675
- assert.ok(Buffer.byteLength(JSON.stringify(t), "utf8") <= DEFAULT_RECEIPT_MAX_BYTES);
676
- assert.equal(t.lease_id, "lease-1");
677
- assert.equal(t.exit_code, 0);
678
- assert.ok(t.connection.connection_truncated || t.truncated);
679
- });
680
-
681
- // ── parseSupabaseStatus ──────────────────────────────────────────────────────
682
- test("parseSupabaseStatus: JSON form", () => {
683
- const conn = parseSupabaseStatus(JSON.stringify({ API_URL: "http://127.0.0.1:56321", DB_URL: "postgresql://x", ANON_KEY: "anon", SERVICE_ROLE_KEY: "svc" }));
684
- assert.equal(conn.api_url, "http://127.0.0.1:56321");
685
- assert.equal(conn.service_role_key, "svc");
686
- });
687
- test("parseSupabaseStatus: plain key/value fallback", () => {
688
- const conn = parseSupabaseStatus("API URL: http://127.0.0.1:56321\nanon key: theanon\nservice_role key: thesvc\n");
689
- assert.equal(conn.api_url, "http://127.0.0.1:56321");
690
- assert.equal(conn.anon_key, "theanon");
691
- });
692
-
693
- // ── eventStreamBase ──────────────────────────────────────────────────────────
694
- test("eventStreamBase: derives the event-stream URL from the mcp-server URL", () => {
695
- assert.equal(eventStreamBase("https://api.bot-buddy.ai/functions/v1/mcp-server"), "https://api.bot-buddy.ai/functions/v1/event-stream");
696
- assert.equal(eventStreamBase("http://127.0.0.1:56321/functions/v1/mcp-server/"), "http://127.0.0.1:56321/functions/v1/event-stream");
697
- });
698
-
699
- // ── resolveLocalSupabaseUrl (Codex P1: never bake the prod origin) ───────────
700
- test("resolveLocalSupabaseUrl: honours an already-local VITE_SUPABASE_URL", () => {
701
- assert.equal(resolveLocalSupabaseUrl({ VITE_SUPABASE_URL: "http://127.0.0.1:56321" }), "http://127.0.0.1:56321");
702
- assert.equal(resolveLocalSupabaseUrl({ VITE_SUPABASE_URL: "http://localhost:54321" }), "http://localhost:54321");
703
- });
704
- test("resolveLocalSupabaseUrl: derives the [api] port from supabase/config.toml", () => {
705
- const dir = mkdtempSync(join(tmpdir(), "bb-stack-cfg-"));
706
- mkdirSync(join(dir, "supabase"));
707
- writeFileSync(join(dir, "supabase", "config.toml"), "[api]\nenabled = true\nport = 56321\n\n[db]\nport = 56322\n");
708
- assert.equal(resolveLocalSupabaseUrl({}, dir), "http://127.0.0.1:56321");
709
- });
710
- test("resolveLocalSupabaseUrl: ignores a NON-local exported origin (refuses → null)", () => {
711
- // A prod VITE_SUPABASE_URL must NOT be trusted, and no config.toml here ⇒ null (caller refuses).
712
- const empty = mkdtempSync(join(tmpdir(), "bb-stack-nocfg-"));
713
- assert.equal(resolveLocalSupabaseUrl({ VITE_SUPABASE_URL: "https://api.bot-buddy.ai" }, empty), null);
714
- });
715
-
716
- // ── --help snapshot (AC-4) ───────────────────────────────────────────────────
717
- test("STACK_HELP documents the four subcommands and every exit code", () => {
718
- for (const s of ["stack up", "stack status", "stack touch", "stack done", "--local-exec", "--docker-context", "--no-wait"]) {
719
- assert.ok(STACK_HELP.includes(s), `help missing ${s}`);
720
- }
721
- for (const s of ["0 ok", "2 park timed out", "3 not authenticated", "4 invalid", "5 backend", "6 lease failed", "7 internal"]) {
722
- assert.ok(STACK_HELP.includes(s), `help missing exit-code doc ${s}`);
723
- }
724
- });
725
-
726
- // ── spawn tests: exit codes + single-line receipt (bb-wait pattern) ──────────
727
- test("spawn: `stack help` prints usage and exits 0", () => {
728
- const res = runStack(["help"]);
729
- assert.equal(res.status, 0);
730
- assert.ok(res.stdout.includes("botbuddy stack"));
731
- });
732
- test("spawn: invalid slot 'default' → exit 4 with a single JSON-line receipt", () => {
733
- const res = runStack(["up", "--slot", "default"]);
734
- assert.equal(res.status, EXIT.INVALID);
735
- const r = soleReceipt(res);
736
- assert.equal(r.schema_version, STACK_SCHEMA_VERSION);
737
- assert.equal(r.command, "up");
738
- assert.equal(r.outcome, "error");
739
- assert.equal(r.exit_code, EXIT.INVALID);
740
- });
741
- test("spawn: `stack status` without a lease_id → exit 4", () => {
742
- const res = runStack(["status"]);
743
- assert.equal(res.status, EXIT.INVALID);
744
- soleReceipt(res);
745
- });
746
- test("spawn: `stack up` with a valid slot but no auth → exit 3", () => {
747
- const res = runStack(["up", "--slot", "56322"]);
748
- assert.equal(res.status, EXIT.AUTH);
749
- const r = soleReceipt(res);
750
- assert.equal(r.exit_code, EXIT.AUTH);
751
- assert.equal(r.command, "up");
752
- });
753
- test("spawn: unknown subcommand → exit 4", () => {
754
- const res = runStack(["frobnicate"]);
755
- assert.equal(res.status, EXIT.INVALID);
756
- });