@botbuddy/cli 1.4.1 → 1.5.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/package.json +1 -1
- package/src/api.mjs +2 -1
- package/src/commands.mjs +7 -0
- package/src/docker-hygiene.mjs +1062 -0
- package/src/docker-hygiene.test.mjs +790 -0
- package/src/profile-bootstrap.test.mjs +1 -1
- package/src/stack-file-lock.mjs +180 -0
- package/src/stack.mjs +676 -63
- package/src/stack.test.mjs +563 -3
package/src/stack.mjs
CHANGED
|
@@ -21,11 +21,15 @@
|
|
|
21
21
|
// labelled fallback for when no Helper is available: it runs `supabase start`/
|
|
22
22
|
// `stop` itself and self-activates/finalizes via the fallback MCP tools.
|
|
23
23
|
|
|
24
|
-
import { spawnSync } from "child_process";
|
|
24
|
+
import { spawn, spawnSync } from "child_process";
|
|
25
25
|
import { readFileSync, realpathSync } from "fs";
|
|
26
|
-
import {
|
|
26
|
+
import { open, unlink, readFile } from "fs/promises";
|
|
27
|
+
import { tmpdir } from "os";
|
|
28
|
+
import { basename, dirname, join, relative, isAbsolute } from "path";
|
|
29
|
+
import { randomUUID } from "crypto";
|
|
27
30
|
import { callToolJson } from "./api.mjs";
|
|
28
31
|
import { SERVER_URL, getConfig } from "./config.mjs";
|
|
32
|
+
import { runDockerWorkflow } from "./docker-hygiene.mjs";
|
|
29
33
|
import { bold, dim, yellow } from "./utils.mjs";
|
|
30
34
|
|
|
31
35
|
export const STACK_SCHEMA_VERSION = 1;
|
|
@@ -42,6 +46,7 @@ export const EXIT = Object.freeze({
|
|
|
42
46
|
BACKEND: 5, // server/RPC/transport error, or the coordination request failed
|
|
43
47
|
LEASE_FAILED: 6, // the lease was reaped / provision failed instead of going active
|
|
44
48
|
INTERNAL: 7, // unexpected local error
|
|
49
|
+
CLEANUP_FAILED: 8, // child completed but signed physical reap was not proven
|
|
45
50
|
});
|
|
46
51
|
|
|
47
52
|
export const STACK_HELP = `${bold("botbuddy stack")} — one command for a batch-scoped local stack lease (BOT-1218)
|
|
@@ -51,6 +56,8 @@ ${bold("USAGE")}
|
|
|
51
56
|
botbuddy stack status <lease_id> Show a lease's state + connection
|
|
52
57
|
botbuddy stack touch <lease_id> Bump the idle clock so the reaper doesn't stop it
|
|
53
58
|
botbuddy stack done <lease_id> Release (done): tear the stack down
|
|
59
|
+
botbuddy stack run [options] -- <executable> [args...]
|
|
60
|
+
Own one Helper-backed test/fix batch end-to-end
|
|
54
61
|
|
|
55
62
|
${bold("up OPTIONS")}
|
|
56
63
|
--slot <slot> Stable stack identity (BOT-1186): the shared CLI stack DB port
|
|
@@ -65,10 +72,24 @@ ${bold("up OPTIONS")}
|
|
|
65
72
|
--timeout <seconds> Max seconds to park for capacity before giving up (default ${DEFAULT_TIMEOUT_SEC}).
|
|
66
73
|
--no-wait If the host is full, print the queue position and exit (don't park).
|
|
67
74
|
--local-exec FALLBACK (no Helper): run 'supabase start' locally and self-activate.
|
|
75
|
+
--docker-context <name> Explicit OrbStack Docker context for the mandatory local preflight.
|
|
76
|
+
--docker-endpoint <uri> Explicit OrbStack endpoint instead of --docker-context.
|
|
68
77
|
|
|
69
78
|
${bold("done OPTIONS")}
|
|
70
79
|
--stop Keep volumes (cheap re-provision next batch). Default: destroy.
|
|
71
80
|
--local-exec FALLBACK (no Helper): run 'supabase stop' locally and self-finalize.
|
|
81
|
+
--docker-context <name> Explicit OrbStack context used by the matching local-exec up.
|
|
82
|
+
--docker-endpoint <uri> Explicit OrbStack endpoint instead of --docker-context.
|
|
83
|
+
Pre-1.5.0 leases also require exact worktree + live connection proof.
|
|
84
|
+
|
|
85
|
+
${bold("run OPTIONS")}
|
|
86
|
+
--repo <repo> Required approved repository name.
|
|
87
|
+
--ticket <BOT-123> Required ticket for the batch.
|
|
88
|
+
--provision-timeout N Seconds to wait for a physical Helper provision (default: --timeout).
|
|
89
|
+
--reap-timeout N Seconds to wait for signed physical reap proof (default: 300).
|
|
90
|
+
--connection-file P Optional empty file path for the mode-0600 connection JSON.
|
|
91
|
+
--hard-ttl N Bound the child runtime; timeout requests normal cleanup.
|
|
92
|
+
--local-exec Rejected: stack run never starts Docker directly.
|
|
72
93
|
|
|
73
94
|
${bold("GLOBAL")}
|
|
74
95
|
--json Pretty-print the receipt (default is one compact JSON line).
|
|
@@ -77,13 +98,11 @@ ${bold("GLOBAL")}
|
|
|
77
98
|
${bold("EXIT CODES")}
|
|
78
99
|
0 ok/held (or queued+--no-wait) 2 park timed out 3 not authenticated
|
|
79
100
|
4 invalid arguments 5 backend/coordination 6 lease failed/reaped
|
|
80
|
-
7 internal error
|
|
101
|
+
7 internal error 8 cleanup/reap proof failed
|
|
81
102
|
|
|
82
103
|
${bold("EXAMPLE")}
|
|
83
104
|
# request a per-worktree stack for this batch, run a lane against it, then reap it
|
|
84
|
-
|
|
85
|
-
pnpm test:integration
|
|
86
|
-
botbuddy stack done "$id"`;
|
|
105
|
+
botbuddy stack run --repo botbuddy-web --ticket BOT-1346 -- pnpm test:integration`;
|
|
87
106
|
|
|
88
107
|
// ── pure helpers (unit-tested) ───────────────────────────────────────────────
|
|
89
108
|
|
|
@@ -124,15 +143,26 @@ export function deriveSlot({ slot, repo, ticket } = {}, env = process.env) {
|
|
|
124
143
|
*/
|
|
125
144
|
export function parseStackArgs(argv) {
|
|
126
145
|
const errors = [];
|
|
127
|
-
const [command, ...
|
|
146
|
+
const [command, ...rawRest] = argv;
|
|
147
|
+
const divider = rawRest.indexOf("--");
|
|
148
|
+
const rest = divider === -1 ? rawRest : rawRest.slice(0, divider);
|
|
149
|
+
const childArgv = divider === -1 ? [] : rawRest.slice(divider + 1);
|
|
128
150
|
const opts = {
|
|
129
151
|
slot: null, host: null, repo: null, ticket: null, ticketUrl: null,
|
|
130
152
|
prId: null, prUrl: null, purpose: null, idleTtl: null, stackPath: ".",
|
|
131
|
-
timeout: DEFAULT_TIMEOUT_SEC,
|
|
153
|
+
timeout: DEFAULT_TIMEOUT_SEC, provisionTimeout: null, reapTimeout: 300, hardTtl: null,
|
|
154
|
+
connectionFile: null, noWait: false, localExec: false,
|
|
155
|
+
dockerContext: null, dockerEndpoint: null,
|
|
132
156
|
disposition: "destroy", json: false, receiptMaxBytes: DEFAULT_RECEIPT_MAX_BYTES,
|
|
133
157
|
};
|
|
134
158
|
const positionals = [];
|
|
135
|
-
const need = (name, v) => {
|
|
159
|
+
const need = (name, v) => {
|
|
160
|
+
if (v === undefined || String(v).startsWith("--")) {
|
|
161
|
+
errors.push(`${name} needs a value`);
|
|
162
|
+
return false;
|
|
163
|
+
}
|
|
164
|
+
return true;
|
|
165
|
+
};
|
|
136
166
|
for (let i = 0; i < rest.length; i++) {
|
|
137
167
|
const a = rest[i];
|
|
138
168
|
switch (a) {
|
|
@@ -161,6 +191,31 @@ export function parseStackArgs(argv) {
|
|
|
161
191
|
}
|
|
162
192
|
break;
|
|
163
193
|
}
|
|
194
|
+
case "--provision-timeout": {
|
|
195
|
+
if (need(a, rest[i + 1])) {
|
|
196
|
+
const n = Number(rest[++i]);
|
|
197
|
+
if (!Number.isFinite(n) || !Number.isInteger(n) || n <= 0) errors.push("--provision-timeout must be a positive integer number of seconds");
|
|
198
|
+
else opts.provisionTimeout = n;
|
|
199
|
+
}
|
|
200
|
+
break;
|
|
201
|
+
}
|
|
202
|
+
case "--reap-timeout": {
|
|
203
|
+
if (need(a, rest[i + 1])) {
|
|
204
|
+
const n = Number(rest[++i]);
|
|
205
|
+
if (!Number.isFinite(n) || !Number.isInteger(n) || n <= 0) errors.push("--reap-timeout must be a positive integer number of seconds");
|
|
206
|
+
else opts.reapTimeout = n;
|
|
207
|
+
}
|
|
208
|
+
break;
|
|
209
|
+
}
|
|
210
|
+
case "--hard-ttl": {
|
|
211
|
+
if (need(a, rest[i + 1])) {
|
|
212
|
+
const n = Number(rest[++i]);
|
|
213
|
+
if (!Number.isFinite(n) || !Number.isInteger(n) || n <= 0) errors.push("--hard-ttl must be a positive integer number of seconds");
|
|
214
|
+
else opts.hardTtl = n;
|
|
215
|
+
}
|
|
216
|
+
break;
|
|
217
|
+
}
|
|
218
|
+
case "--connection-file": if (need(a, rest[i + 1])) opts.connectionFile = rest[++i]; break;
|
|
164
219
|
case "--receipt-max-bytes": {
|
|
165
220
|
if (need(a, rest[i + 1])) {
|
|
166
221
|
const n = Number(rest[++i]);
|
|
@@ -171,6 +226,8 @@ export function parseStackArgs(argv) {
|
|
|
171
226
|
}
|
|
172
227
|
case "--no-wait": opts.noWait = true; break;
|
|
173
228
|
case "--local-exec": opts.localExec = true; break;
|
|
229
|
+
case "--docker-context": if (need(a, rest[i + 1])) opts.dockerContext = rest[++i]; break;
|
|
230
|
+
case "--docker-endpoint": if (need(a, rest[i + 1])) opts.dockerEndpoint = rest[++i]; break;
|
|
174
231
|
case "--stop": opts.disposition = "stop"; break;
|
|
175
232
|
case "--destroy": opts.disposition = "destroy"; break;
|
|
176
233
|
case "--json": opts.json = true; break;
|
|
@@ -187,7 +244,23 @@ export function parseStackArgs(argv) {
|
|
|
187
244
|
if (command && ["status", "touch", "done"].includes(command) && !leaseId) {
|
|
188
245
|
errors.push(`${command} needs a <lease_id>`);
|
|
189
246
|
}
|
|
190
|
-
|
|
247
|
+
if (["up", "done"].includes(command) && opts.localExec && Boolean(opts.dockerContext) === Boolean(opts.dockerEndpoint)) {
|
|
248
|
+
errors.push(`--local-exec ${command} requires exactly one of --docker-context <name> or --docker-endpoint <uri>`);
|
|
249
|
+
}
|
|
250
|
+
if ((opts.dockerContext || opts.dockerEndpoint) && !(["up", "done"].includes(command) && opts.localExec)) {
|
|
251
|
+
errors.push("--docker-context and --docker-endpoint are valid only with `stack up --local-exec` or `stack done --local-exec`");
|
|
252
|
+
}
|
|
253
|
+
if (command === "run") {
|
|
254
|
+
if (!opts.repo) errors.push("run needs --repo <approved-repo>");
|
|
255
|
+
if (!opts.ticket || !/^[A-Z][A-Z0-9]*-\d+$/i.test(opts.ticket)) errors.push("run needs --ticket <BOT-n|ENT-n>");
|
|
256
|
+
if (divider === -1 || childArgv.length === 0) errors.push("run needs an executable after --");
|
|
257
|
+
if (opts.noWait) errors.push("--no-wait is incompatible with run; the command owns its queue wait");
|
|
258
|
+
if (opts.localExec) errors.push("--local-exec is forbidden for run; Helper-backed physical lifecycle is required");
|
|
259
|
+
if (opts.connectionFile && !isAbsolute(opts.connectionFile)) errors.push("--connection-file must be an absolute path");
|
|
260
|
+
} else if (divider !== -1) {
|
|
261
|
+
errors.push("-- <executable> is only valid with stack run");
|
|
262
|
+
}
|
|
263
|
+
return { command, leaseId, opts, errors, childArgv };
|
|
191
264
|
}
|
|
192
265
|
|
|
193
266
|
/** Build the canonical receipt object (single JSON line to stdout). */
|
|
@@ -308,12 +381,13 @@ function parseSseFrames(buffer) {
|
|
|
308
381
|
}
|
|
309
382
|
|
|
310
383
|
/** Register a wait_session for this lease (so it shows on /waits) + return {cursorStart}. Best-effort. */
|
|
311
|
-
async function registerLeaseWait(leaseId, timeoutSec, auth, fetchImpl = fetch) {
|
|
384
|
+
async function registerLeaseWait(leaseId, timeoutSec, auth, signal, fetchImpl = fetch) {
|
|
312
385
|
const deadline = new Date(Date.now() + timeoutSec * 1000).toISOString();
|
|
313
386
|
const res = await fetchImpl(`${eventStreamBase()}`, {
|
|
314
387
|
method: "POST",
|
|
315
388
|
headers: { ...auth, "Content-Type": "application/json" },
|
|
316
389
|
body: JSON.stringify({ action: "register", conditions: [{ type: "lease", params: { id: leaseId } }], deadline, mode: "any" }),
|
|
390
|
+
signal,
|
|
317
391
|
});
|
|
318
392
|
if (!res.ok) throw new Error(`register responded ${res.status}`);
|
|
319
393
|
const body = await res.json();
|
|
@@ -326,29 +400,59 @@ async function registerLeaseWait(leaseId, timeoutSec, auth, fetchImpl = fetch) {
|
|
|
326
400
|
* Resolves { woke, state } on a matching frame, { timeout:true } on --timeout,
|
|
327
401
|
* { failed, state } if the lease was reaped, or { error } on a transport failure.
|
|
328
402
|
*/
|
|
329
|
-
async function waitForLease(leaseId, isDone, isFailed, { timeoutSec, auth }, fetchImpl = fetch) {
|
|
403
|
+
export async function waitForLease(leaseId, isDone, isFailed, { timeoutSec, auth, signal = null, deadlineMs = null }, fetchImpl = fetch) {
|
|
404
|
+
const absoluteDeadlineMs = deadlineMs ?? Date.now() + timeoutSec * 1000;
|
|
330
405
|
let cursorStart = null;
|
|
406
|
+
let waitSessionId = null;
|
|
407
|
+
const ac = new AbortController();
|
|
408
|
+
const abortFromParent = () => ac.abort("interrupted");
|
|
409
|
+
if (signal?.aborted) ac.abort("interrupted");
|
|
410
|
+
else signal?.addEventListener?.("abort", abortFromParent, { once: true });
|
|
411
|
+
const timer = setTimeout(() => ac.abort("timeout"), Math.max(0, absoluteDeadlineMs - Date.now()));
|
|
412
|
+
const finish = async (result, status) => {
|
|
413
|
+
clearTimeout(timer);
|
|
414
|
+
signal?.removeEventListener?.("abort", abortFromParent);
|
|
415
|
+
if (waitSessionId) {
|
|
416
|
+
try {
|
|
417
|
+
const finalizer = new AbortController();
|
|
418
|
+
const finalizerTimer = setTimeout(() => finalizer.abort(), 5_000);
|
|
419
|
+
try {
|
|
420
|
+
const response = await fetchImpl(eventStreamBase(), {
|
|
421
|
+
method: "POST", headers: { ...auth, "Content-Type": "application/json" },
|
|
422
|
+
body: JSON.stringify({ action: "finalize", wait_session_id: waitSessionId, status, receipt: { outcome: status, lease_id: leaseId } }),
|
|
423
|
+
signal: finalizer.signal,
|
|
424
|
+
});
|
|
425
|
+
if (!response.ok) process.stderr.write(`${yellow("⚠")} stack: wait finalization returned ${response.status}\n`);
|
|
426
|
+
} finally {
|
|
427
|
+
clearTimeout(finalizerTimer);
|
|
428
|
+
}
|
|
429
|
+
} catch (err) {
|
|
430
|
+
process.stderr.write(`${yellow("⚠")} stack: wait finalization failed (${err?.message ?? err})\n`);
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
return result;
|
|
434
|
+
};
|
|
435
|
+
if (ac.signal.aborted) return finish(ac.signal.reason === "interrupted" ? { interrupted: true } : { timeout: true }, ac.signal.reason === "interrupted" ? "error" : "timeout");
|
|
331
436
|
try {
|
|
332
|
-
({ cursorStart } = await registerLeaseWait(leaseId,
|
|
437
|
+
({ cursorStart, waitSessionId } = await registerLeaseWait(leaseId, Math.max(0, Math.ceil((absoluteDeadlineMs - Date.now()) / 1000)), auth, ac.signal, fetchImpl));
|
|
333
438
|
} catch (err) {
|
|
439
|
+
if (ac.signal.aborted) return finish(ac.signal.reason === "interrupted" ? { interrupted: true } : { timeout: true }, ac.signal.reason === "interrupted" ? "error" : "timeout");
|
|
334
440
|
process.stderr.write(`${yellow("⚠")} stack: wait registration failed (${err?.message ?? err}); the lease will not appear on /waits — parking live-only.\n`);
|
|
335
441
|
}
|
|
336
442
|
const url = new URL(eventStreamBase());
|
|
337
443
|
url.searchParams.set("tables", "agent_signal_events");
|
|
338
444
|
if (cursorStart != null) url.searchParams.set("since", String(cursorStart));
|
|
445
|
+
if (waitSessionId) url.searchParams.set("wait_session", waitSessionId);
|
|
339
446
|
url.searchParams.set("heartbeat", "1");
|
|
340
|
-
const ac = new AbortController();
|
|
341
|
-
const timer = setTimeout(() => ac.abort("timeout"), timeoutSec * 1000);
|
|
342
447
|
let res;
|
|
343
448
|
try {
|
|
344
449
|
res = await fetchImpl(url, { headers: { ...auth, Accept: "text/event-stream", "Accept-Encoding": "identity" }, signal: ac.signal });
|
|
345
450
|
} catch (err) {
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
return { error: `sse connect: ${err?.message ?? err}` };
|
|
451
|
+
if (ac.signal.aborted) return finish(ac.signal.reason === "interrupted" ? { interrupted: true } : { timeout: true }, ac.signal.reason === "interrupted" ? "error" : "timeout");
|
|
452
|
+
return finish({ error: `sse connect: ${err?.message ?? err}` }, "error");
|
|
349
453
|
}
|
|
350
|
-
if (res.status === 401 || res.status === 403)
|
|
351
|
-
if (!res.ok || !res.body)
|
|
454
|
+
if (res.status === 401 || res.status === 403) return finish({ auth: true }, "error");
|
|
455
|
+
if (!res.ok || !res.body) return finish({ error: `sse responded ${res.status}` }, "error");
|
|
352
456
|
const decoder = new TextDecoder();
|
|
353
457
|
let buf = "";
|
|
354
458
|
try {
|
|
@@ -361,25 +465,143 @@ async function waitForLease(leaseId, isDone, isFailed, { timeoutSec, auth }, fet
|
|
|
361
465
|
try { sig = JSON.parse(f.data); } catch { continue; }
|
|
362
466
|
if (sig.signal_type !== "stack_lease" || sig.subject_key !== `lease:${leaseId}`) continue;
|
|
363
467
|
const st = sig.payload?.state ?? null;
|
|
364
|
-
if (isFailed(st)) {
|
|
365
|
-
if (isDone(st)) {
|
|
468
|
+
if (isFailed(st)) { ac.abort(); return finish({ failed: true, state: st }, "error"); }
|
|
469
|
+
if (isDone(st)) { ac.abort(); return finish({ woke: true, state: st }, "matched"); }
|
|
366
470
|
}
|
|
367
471
|
}
|
|
368
472
|
} catch (err) {
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
473
|
+
if (ac.signal.aborted) return finish(ac.signal.reason === "interrupted" ? { interrupted: true } : { timeout: true }, ac.signal.reason === "interrupted" ? "error" : "timeout");
|
|
474
|
+
return finish({ error: `sse stream: ${err?.message ?? err}` }, "error");
|
|
475
|
+
}
|
|
476
|
+
// A clean relay EOF is not a timeout: the server intentionally closes on
|
|
477
|
+
// scope changes and proxies can recycle idle streams. Re-arm from a fresh
|
|
478
|
+
// cursor until the original deadline, never silently fall back to polling.
|
|
479
|
+
const remainingSec = Math.ceil((absoluteDeadlineMs - Date.now()) / 1000);
|
|
480
|
+
if (remainingSec > 0 && !ac.signal.aborted) {
|
|
481
|
+
await finish({ reconnected: true }, "error");
|
|
482
|
+
return waitForLease(leaseId, isDone, isFailed, { timeoutSec: remainingSec, auth, signal, deadlineMs: absoluteDeadlineMs }, fetchImpl);
|
|
372
483
|
}
|
|
373
|
-
|
|
374
|
-
return { timeout: true };
|
|
484
|
+
return finish({ timeout: true }, "timeout");
|
|
375
485
|
}
|
|
376
486
|
|
|
377
487
|
const isActive = (s) => s === "active";
|
|
378
488
|
const nonQueued = (s) => s != null && s !== "queued";
|
|
379
489
|
const isReaped = (s) => s === "reaping" || s === "reaped";
|
|
380
490
|
|
|
491
|
+
/** Read-only pressure gate used before local managed-stack provisioning. */
|
|
492
|
+
export function runLocalExecPreflight(opts, runWorkflow = runDockerWorkflow) {
|
|
493
|
+
const selector = opts.dockerContext
|
|
494
|
+
? ["--context", opts.dockerContext]
|
|
495
|
+
: ["--endpoint", opts.dockerEndpoint];
|
|
496
|
+
return runWorkflow(["preflight", ...selector, "--json"]);
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
export function dockerEnvForSelector(opts, env = process.env) {
|
|
500
|
+
const selected = { ...env };
|
|
501
|
+
delete selected.DOCKER_CONTEXT;
|
|
502
|
+
delete selected.DOCKER_HOST;
|
|
503
|
+
if (opts.dockerContext) selected.DOCKER_CONTEXT = opts.dockerContext;
|
|
504
|
+
else selected.DOCKER_HOST = opts.dockerEndpoint;
|
|
505
|
+
return selected;
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
export function dockerTargetFromPreflight(receipt) {
|
|
509
|
+
const context = receipt?.context;
|
|
510
|
+
const endpoint = context?.resolved_endpoint;
|
|
511
|
+
const serverId = context?.server?.id;
|
|
512
|
+
if (context?.validation !== "orbstack" || typeof endpoint !== "string" || !endpoint ||
|
|
513
|
+
typeof serverId !== "string" || !serverId) return null;
|
|
514
|
+
return { validation: "orbstack", resolved_endpoint: endpoint, server_id: serverId };
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
export function connectionWithDockerTarget(connection, target) {
|
|
518
|
+
if (!target?.resolved_endpoint || !target?.server_id) {
|
|
519
|
+
throw new Error("cannot persist an incomplete Docker target identity");
|
|
520
|
+
}
|
|
521
|
+
return { ...(connection || {}), botbuddy_docker_target: { ...target } };
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
function dockerTargetsMatch(expected, actual) {
|
|
525
|
+
return expected?.validation === "orbstack" && actual?.validation === "orbstack" &&
|
|
526
|
+
expected.resolved_endpoint === actual.resolved_endpoint && expected.server_id === actual.server_id;
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
function dockerEnvForTarget(target, env = process.env) {
|
|
530
|
+
const selected = { ...env };
|
|
531
|
+
delete selected.DOCKER_CONTEXT;
|
|
532
|
+
delete selected.DOCKER_HOST;
|
|
533
|
+
selected.DOCKER_HOST = target.resolved_endpoint;
|
|
534
|
+
return selected;
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
/**
|
|
538
|
+
* Compatibility fence for leases activated by CLI <1.5.0, before the daemon
|
|
539
|
+
* identity was persisted in `connection`. We cannot reconstruct the old daemon
|
|
540
|
+
* ID by guesswork. Instead, prove that the caller is in the exact registered
|
|
541
|
+
* worktree/stack path and that `supabase status`, pinned to the freshly validated
|
|
542
|
+
* OrbStack endpoint, reports the same non-secret API + DB endpoints stored on
|
|
543
|
+
* the lease. Only then may that observed daemon be used for teardown.
|
|
544
|
+
*/
|
|
545
|
+
export function proveLegacyLocalExecTarget(lease, observedTarget, opts, run = spawnSync, cwd = process.cwd()) {
|
|
546
|
+
if (!observedTarget?.resolved_endpoint || !observedTarget?.server_id) {
|
|
547
|
+
return { ok: false, error: "fresh OrbStack target identity is incomplete" };
|
|
548
|
+
}
|
|
549
|
+
let execution;
|
|
550
|
+
try {
|
|
551
|
+
execution = resolveStackPath(cwd, opts?.stackPath || ".");
|
|
552
|
+
} catch (error) {
|
|
553
|
+
return { ok: false, error: `could not resolve the invoking stack path: ${error.message}` };
|
|
554
|
+
}
|
|
555
|
+
if (!lease?.worktree_root || lease.worktree_root !== execution.worktreeRoot ||
|
|
556
|
+
(lease.stack_path || ".") !== execution.stackPath) {
|
|
557
|
+
return { ok: false, error: "legacy lease worktree/stack metadata does not exactly match the invoking worktree" };
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
const stackDir = execution.stackPath === "."
|
|
561
|
+
? execution.worktreeRoot
|
|
562
|
+
: join(execution.worktreeRoot, execution.stackPath);
|
|
563
|
+
const status = run("supabase", ["status", "-o", "json", "--workdir", stackDir], {
|
|
564
|
+
encoding: "utf8",
|
|
565
|
+
env: dockerEnvForTarget(observedTarget),
|
|
566
|
+
});
|
|
567
|
+
if (status?.error || status?.status !== 0) {
|
|
568
|
+
return { ok: false, error: "legacy stack is not provably live on the freshly validated OrbStack endpoint" };
|
|
569
|
+
}
|
|
570
|
+
const live = parseSupabaseStatus(status.stdout || "");
|
|
571
|
+
const stored = lease.connection || {};
|
|
572
|
+
if (typeof stored.api_url !== "string" || typeof stored.db_url !== "string" ||
|
|
573
|
+
live.api_url !== stored.api_url || live.db_url !== stored.db_url) {
|
|
574
|
+
return { ok: false, error: "live Supabase connection on the observed OrbStack daemon does not match the legacy lease connection" };
|
|
575
|
+
}
|
|
576
|
+
let dbPort = null;
|
|
577
|
+
try { dbPort = new URL(live.db_url).port || null; } catch { /* exact URL already matched; omit display-only port */ }
|
|
578
|
+
return {
|
|
579
|
+
ok: true,
|
|
580
|
+
evidence: {
|
|
581
|
+
method: "legacy_worktree_connection_match",
|
|
582
|
+
worktree_root: execution.worktreeRoot,
|
|
583
|
+
stack_path: execution.stackPath,
|
|
584
|
+
api_url: live.api_url,
|
|
585
|
+
db_port: dbPort,
|
|
586
|
+
resolved_endpoint: observedTarget.resolved_endpoint,
|
|
587
|
+
server_id: observedTarget.server_id,
|
|
588
|
+
},
|
|
589
|
+
};
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
function compactPreflight(receipt) {
|
|
593
|
+
return {
|
|
594
|
+
outcome: receipt.outcome,
|
|
595
|
+
context: receipt.context,
|
|
596
|
+
pressure: receipt.pressure,
|
|
597
|
+
warnings: receipt.warnings,
|
|
598
|
+
errors: receipt.errors,
|
|
599
|
+
recommendation: receipt.recommendation,
|
|
600
|
+
};
|
|
601
|
+
}
|
|
602
|
+
|
|
381
603
|
/** LOUD local-exec fallback: bring a stack up in the cwd via the Supabase CLI. */
|
|
382
|
-
function localProvision() {
|
|
604
|
+
function localProvision(opts, dockerTarget) {
|
|
383
605
|
// Pin a LOCAL origin so `supabase start` never bakes the repo's prod origin into the
|
|
384
606
|
// edge runtime (BOT-903 / Codex P1). Refuse rather than risk crossing into production.
|
|
385
607
|
const url = resolveLocalSupabaseUrl();
|
|
@@ -391,7 +613,7 @@ function localProvision() {
|
|
|
391
613
|
"export VITE_SUPABASE_URL=http://127.0.0.1:<port> first.",
|
|
392
614
|
);
|
|
393
615
|
}
|
|
394
|
-
const spawnEnv = { ...
|
|
616
|
+
const spawnEnv = { ...dockerEnvForTarget(dockerTarget), VITE_SUPABASE_URL: url };
|
|
395
617
|
process.stderr.write(`${yellow("⚠ LOCAL-EXEC FALLBACK")} — no BotBuddy Helper; running ${bold("supabase start")} in this worktree (VITE_SUPABASE_URL=${url}).\n`);
|
|
396
618
|
const start = spawnSync("supabase", ["start", "--workdir", process.cwd()], { encoding: "utf8", env: spawnEnv });
|
|
397
619
|
if (start.status !== 0) {
|
|
@@ -402,9 +624,12 @@ function localProvision() {
|
|
|
402
624
|
}
|
|
403
625
|
|
|
404
626
|
/** LOUD local-exec fallback: tear the stack down in the cwd. Returns true iff it succeeded. */
|
|
405
|
-
function localTeardown() {
|
|
627
|
+
export function localTeardown(_opts, dockerTarget, spawn = spawnSync, env = process.env) {
|
|
406
628
|
process.stderr.write(`${yellow("⚠ LOCAL-EXEC FALLBACK")} — running ${bold("supabase stop")} in this worktree.\n`);
|
|
407
|
-
const res =
|
|
629
|
+
const res = spawn("supabase", ["stop", "--workdir", process.cwd()], {
|
|
630
|
+
encoding: "utf8",
|
|
631
|
+
env: dockerEnvForTarget(dockerTarget, env),
|
|
632
|
+
});
|
|
408
633
|
if (res.status !== 0) {
|
|
409
634
|
process.stderr.write(`${yellow("⚠")} stack: supabase stop returned ${res.status}: ${(res.stderr || res.stdout || "").slice(0, 300)}\n`);
|
|
410
635
|
}
|
|
@@ -419,19 +644,42 @@ function emit(receipt, opts, code) {
|
|
|
419
644
|
|
|
420
645
|
// ── command orchestration ────────────────────────────────────────────────────
|
|
421
646
|
|
|
422
|
-
async function cmdUp(opts
|
|
647
|
+
export async function cmdUp(opts, {
|
|
648
|
+
runPreflight = runLocalExecPreflight,
|
|
649
|
+
callTool = callToolJson,
|
|
650
|
+
authProvider = stackAuthHeader,
|
|
651
|
+
localProvisionFn = localProvision,
|
|
652
|
+
emitResult = emit,
|
|
653
|
+
} = {}) {
|
|
423
654
|
let slot;
|
|
424
655
|
try { slot = deriveSlot(opts); } catch (e) {
|
|
425
|
-
return
|
|
656
|
+
return emitResult(buildReceipt({ command: "up", outcome: "error", error: e.message }), opts, EXIT.INVALID);
|
|
426
657
|
}
|
|
427
|
-
const auth = stackAuthHeader();
|
|
428
|
-
if (!auth) return emit(buildReceipt({ command: "up", outcome: "error", error: "not authenticated — run `botbuddy login`" }), opts, EXIT.AUTH);
|
|
429
658
|
let execution;
|
|
430
659
|
try { execution = resolveStackPath(process.cwd(), opts.stackPath); } catch (e) {
|
|
431
|
-
return
|
|
660
|
+
return emitResult(buildReceipt({ command: "up", outcome: "error", error: (e).message }), opts, EXIT.INVALID);
|
|
661
|
+
}
|
|
662
|
+
let localPreflight = null;
|
|
663
|
+
let localDockerTarget = null;
|
|
664
|
+
if (opts.localExec) {
|
|
665
|
+
const checked = runPreflight(opts);
|
|
666
|
+
localPreflight = compactPreflight(checked.receipt);
|
|
667
|
+
localDockerTarget = dockerTargetFromPreflight(checked.receipt);
|
|
668
|
+
if (checked.exitCode !== 0 || !localDockerTarget) {
|
|
669
|
+
return emitResult(buildReceipt({
|
|
670
|
+
command: "up", outcome: "refused", slot,
|
|
671
|
+
error: checked.receipt.errors?.[0] || "OrbStack preflight did not return a stable Docker server identity",
|
|
672
|
+
preflight: localPreflight,
|
|
673
|
+
}), opts, EXIT.LEASE_FAILED);
|
|
674
|
+
}
|
|
675
|
+
if (checked.receipt.outcome === "warn") {
|
|
676
|
+
process.stderr.write(`${yellow("⚠")} stack: OrbStack preflight warns of interface pressure; cleanup is recommended before another stack.\n`);
|
|
677
|
+
}
|
|
432
678
|
}
|
|
679
|
+
const auth = authProvider();
|
|
680
|
+
if (!auth) return emitResult(buildReceipt({ command: "up", outcome: "error", error: "not authenticated — run `botbuddy login`" }), opts, EXIT.AUTH);
|
|
433
681
|
|
|
434
|
-
const req = await
|
|
682
|
+
const req = await callTool("request_stack_lease", {
|
|
435
683
|
slot, host_key: opts.host || undefined, repo: opts.repo || undefined,
|
|
436
684
|
ticket_id: opts.ticket || undefined, ticket_url: opts.ticketUrl || undefined,
|
|
437
685
|
pr_id: opts.prId || undefined, pr_url: opts.prUrl || undefined,
|
|
@@ -441,57 +689,78 @@ async function cmdUp(opts) {
|
|
|
441
689
|
});
|
|
442
690
|
if (!req.ok) {
|
|
443
691
|
return req.auth
|
|
444
|
-
?
|
|
445
|
-
:
|
|
692
|
+
? emitResult(buildReceipt({ command: "up", outcome: "error", error: req.error || "unauthorized" }), opts, EXIT.AUTH)
|
|
693
|
+
: emitResult(buildReceipt({ command: "up", outcome: "error", error: req.error || "request failed" }), opts, EXIT.BACKEND);
|
|
446
694
|
}
|
|
447
695
|
const d = req.data;
|
|
448
696
|
if (!d.success) {
|
|
449
|
-
return
|
|
697
|
+
return emitResult(buildReceipt({ command: "up", outcome: "error", code: d.code, error: d.message || d.code || "request refused", slot }), opts, EXIT.BACKEND);
|
|
450
698
|
}
|
|
451
699
|
let leaseId = d.lease_id;
|
|
452
700
|
let state = d.state;
|
|
453
701
|
|
|
454
702
|
if (state === "queued") {
|
|
455
703
|
if (opts.noWait) {
|
|
456
|
-
return
|
|
704
|
+
return emitResult(buildReceipt({ command: "up", outcome: "queued", lease_id: leaseId, state, host_key: d.host_key, slot, queued: true, queue_position: d.queue_position, holders: d.holders }), opts, EXIT.QUEUED);
|
|
457
705
|
}
|
|
458
706
|
// Park zero-poll until the lease leaves the queue (granted in BOT-1187 order).
|
|
459
707
|
const parked = await waitForLease(leaseId, nonQueued, () => false, { timeoutSec: opts.timeout, auth });
|
|
460
|
-
if (parked.timeout) return
|
|
461
|
-
if (parked.auth) return
|
|
462
|
-
if (parked.error) return
|
|
708
|
+
if (parked.timeout) return emitResult(buildReceipt({ command: "up", outcome: "timeout", lease_id: leaseId, state: "queued", slot, error: `parked ${opts.timeout}s without capacity` }), opts, EXIT.TIMEOUT);
|
|
709
|
+
if (parked.auth) return emitResult(buildReceipt({ command: "up", outcome: "error", lease_id: leaseId, error: "unauthorized on wait stream" }), opts, EXIT.AUTH);
|
|
710
|
+
if (parked.error) return emitResult(buildReceipt({ command: "up", outcome: "error", lease_id: leaseId, error: parked.error }), opts, EXIT.BACKEND);
|
|
463
711
|
state = parked.state || "provisioning";
|
|
464
712
|
}
|
|
465
713
|
|
|
466
714
|
// Reach `active`: local-exec provisions itself; otherwise wait for the Helper.
|
|
467
715
|
if (state !== "active") {
|
|
468
716
|
if (opts.localExec) {
|
|
717
|
+
// Capacity may have changed while this command was queued. Re-run the
|
|
718
|
+
// non-mutating gate immediately before `supabase start`; on refusal,
|
|
719
|
+
// release the minted lease and never invoke the local provisioner.
|
|
720
|
+
const checked = runPreflight(opts);
|
|
721
|
+
localPreflight = compactPreflight(checked.receipt);
|
|
722
|
+
localDockerTarget = dockerTargetFromPreflight(checked.receipt);
|
|
723
|
+
if (checked.exitCode !== 0 || !localDockerTarget) {
|
|
724
|
+
const cancelled = await callTool("cancel_unclaimed_stack_lease", { lease_id: leaseId });
|
|
725
|
+
const leaseCancellation = cancelled.ok && cancelled.data?.success
|
|
726
|
+
? { success: true, state: cancelled.data.state, provision_job_cancelled: cancelled.data.provision_job_cancelled === true }
|
|
727
|
+
: { success: false, error: cancelled.error || cancelled.data?.code || "atomic cancellation failed" };
|
|
728
|
+
return emitResult(buildReceipt({
|
|
729
|
+
command: "up", outcome: "refused", lease_id: leaseId, state, slot,
|
|
730
|
+
error: checked.receipt.errors?.[0] || "OrbStack preflight did not return a stable Docker server identity",
|
|
731
|
+
preflight: localPreflight,
|
|
732
|
+
lease_cancellation: leaseCancellation,
|
|
733
|
+
}), opts, EXIT.LEASE_FAILED);
|
|
734
|
+
}
|
|
469
735
|
let conn;
|
|
470
|
-
try {
|
|
471
|
-
|
|
736
|
+
try {
|
|
737
|
+
conn = connectionWithDockerTarget(localProvisionFn(opts, localDockerTarget), localDockerTarget);
|
|
738
|
+
} catch (e) {
|
|
739
|
+
return emitResult(buildReceipt({ command: "up", outcome: "error", lease_id: leaseId, error: e.message }), opts, EXIT.LEASE_FAILED);
|
|
472
740
|
}
|
|
473
|
-
const act = await
|
|
741
|
+
const act = await callTool("activate_stack_lease", { lease_id: leaseId, connection: conn });
|
|
474
742
|
if (!act.ok || !act.data?.success) {
|
|
475
|
-
return
|
|
743
|
+
return emitResult(buildReceipt({ command: "up", outcome: "error", lease_id: leaseId, error: act.error || act.data?.code || "activate failed" }), opts, EXIT.BACKEND);
|
|
476
744
|
}
|
|
477
745
|
} else {
|
|
478
746
|
const active = await waitForLease(leaseId, isActive, isReaped, { timeoutSec: opts.timeout, auth });
|
|
479
|
-
if (active.timeout) return
|
|
480
|
-
if (active.failed) return
|
|
481
|
-
if (active.error) return
|
|
747
|
+
if (active.timeout) return emitResult(buildReceipt({ command: "up", outcome: "timeout", lease_id: leaseId, state, slot, error: `provisioning did not reach active in ${opts.timeout}s (Helper may be down — retry with --local-exec)` }), opts, EXIT.TIMEOUT);
|
|
748
|
+
if (active.failed) return emitResult(buildReceipt({ command: "up", outcome: "error", lease_id: leaseId, state: active.state, error: "lease was reaped before it became active" }), opts, EXIT.LEASE_FAILED);
|
|
749
|
+
if (active.error) return emitResult(buildReceipt({ command: "up", outcome: "error", lease_id: leaseId, error: active.error }), opts, EXIT.BACKEND);
|
|
482
750
|
}
|
|
483
751
|
}
|
|
484
752
|
|
|
485
753
|
// Authoritative final read (connection block, current state).
|
|
486
|
-
const got = await
|
|
754
|
+
const got = await callTool("get_stack_lease", { lease_id: leaseId });
|
|
487
755
|
const g = got.ok && got.data?.success ? got.data : null;
|
|
488
756
|
if (!g || g.state !== "active") {
|
|
489
|
-
return
|
|
757
|
+
return emitResult(buildReceipt({ command: "up", outcome: "error", lease_id: leaseId, state: g?.state, error: g ? `lease is ${g.state}, not active` : (got.error || "could not read lease") }), opts, g?.state && isReaped(g.state) ? EXIT.LEASE_FAILED : EXIT.BACKEND);
|
|
490
758
|
}
|
|
491
|
-
return
|
|
759
|
+
return emitResult(buildReceipt({
|
|
492
760
|
command: "up", outcome: "active", lease_id: leaseId, state: "active",
|
|
493
761
|
host_key: g.host_key, slot: g.slot, connection: g.connection,
|
|
494
762
|
idle_ttl_secs: g.idle_ttl_secs, resource_name: g.resource_name,
|
|
763
|
+
...(localPreflight ? { preflight: localPreflight } : {}),
|
|
495
764
|
}), opts, EXIT.OK);
|
|
496
765
|
}
|
|
497
766
|
|
|
@@ -516,10 +785,50 @@ async function cmdTouch(leaseId, opts) {
|
|
|
516
785
|
return emit(buildReceipt({ command: "touch", outcome: "touched", lease_id: leaseId, last_used_at: r.data.last_used_at }), opts, EXIT.OK);
|
|
517
786
|
}
|
|
518
787
|
|
|
519
|
-
async function cmdDone(leaseId, opts
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
788
|
+
export async function cmdDone(leaseId, opts, {
|
|
789
|
+
callTool = callToolJson,
|
|
790
|
+
runPreflight = runLocalExecPreflight,
|
|
791
|
+
proveLegacyTarget = proveLegacyLocalExecTarget,
|
|
792
|
+
localTeardownFn = localTeardown,
|
|
793
|
+
emitResult = emit,
|
|
794
|
+
} = {}) {
|
|
795
|
+
let dockerTarget = null;
|
|
796
|
+
let legacyTargetProof = null;
|
|
797
|
+
if (opts.localExec) {
|
|
798
|
+
const current = await callTool("get_stack_lease", { lease_id: leaseId });
|
|
799
|
+
if (!current.ok || !current.data?.success) {
|
|
800
|
+
return emitResult(buildReceipt({ command: "done", outcome: "error", lease_id: leaseId,
|
|
801
|
+
error: current.error || current.data?.code || "could not verify the lease Docker target" }), opts,
|
|
802
|
+
current.auth ? EXIT.AUTH : EXIT.BACKEND);
|
|
803
|
+
}
|
|
804
|
+
const expected = current.data.connection?.botbuddy_docker_target;
|
|
805
|
+
let checked;
|
|
806
|
+
try { checked = runPreflight(opts); } catch (error) {
|
|
807
|
+
return emitResult(buildReceipt({ command: "done", outcome: "refused", lease_id: leaseId,
|
|
808
|
+
error: `could not validate teardown Docker target: ${error.message}` }), opts, EXIT.LEASE_FAILED);
|
|
809
|
+
}
|
|
810
|
+
dockerTarget = dockerTargetFromPreflight(checked.receipt);
|
|
811
|
+
if (expected && !dockerTargetsMatch(expected, dockerTarget)) {
|
|
812
|
+
return emitResult(buildReceipt({ command: "done", outcome: "refused", lease_id: leaseId,
|
|
813
|
+
error: "teardown Docker target does not exactly match the OrbStack server persisted at provisioning; lease and slot remain fenced",
|
|
814
|
+
expected_docker_target: expected || null, observed_docker_target: dockerTarget,
|
|
815
|
+
}), opts, EXIT.LEASE_FAILED);
|
|
816
|
+
}
|
|
817
|
+
if (!expected) {
|
|
818
|
+
const proof = proveLegacyTarget(current.data, dockerTarget, opts);
|
|
819
|
+
if (!proof?.ok) {
|
|
820
|
+
return emitResult(buildReceipt({ command: "done", outcome: "refused", lease_id: leaseId,
|
|
821
|
+
error: `pre-1.5.0 lease has no persisted Docker target and compatibility proof failed: ${proof?.error || "unknown proof failure"}; lease and slot remain fenced`,
|
|
822
|
+
expected_docker_target: null, observed_docker_target: dockerTarget,
|
|
823
|
+
}), opts, EXIT.LEASE_FAILED);
|
|
824
|
+
}
|
|
825
|
+
legacyTargetProof = proof.evidence;
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
const r = await callTool("release_stack_lease", { lease_id: leaseId, disposition: opts.disposition });
|
|
830
|
+
if (!r.ok) return emitResult(buildReceipt({ command: "done", outcome: "error", lease_id: leaseId, error: r.error }), opts, r.auth ? EXIT.AUTH : EXIT.BACKEND);
|
|
831
|
+
if (!r.data.success) return emitResult(buildReceipt({ command: "done", outcome: "error", lease_id: leaseId, code: r.data.code, error: r.data.code }), opts, EXIT.BACKEND);
|
|
523
832
|
let state = r.data.state;
|
|
524
833
|
if (opts.localExec && state === "reaping") {
|
|
525
834
|
// Only finalize once the stack is PROVABLY down. A failed `supabase stop` (Docker
|
|
@@ -527,17 +836,320 @@ async function cmdDone(leaseId, opts) {
|
|
|
527
836
|
// the next queued lease, which would collide with containers still running on this
|
|
528
837
|
// slot (Codex P1). Leave the lease in `reaping` (slot stays fenced) for a retry /
|
|
529
838
|
// the reaper. Fail with a non-zero exit so the caller knows teardown is incomplete.
|
|
530
|
-
if (!
|
|
531
|
-
return
|
|
839
|
+
if (!localTeardownFn(opts, dockerTarget)) {
|
|
840
|
+
return emitResult(buildReceipt({
|
|
532
841
|
command: "done", outcome: "error", lease_id: leaseId, state,
|
|
533
842
|
error: "local `supabase stop` failed — NOT finalizing; the slot stays fenced. Tear the stack down and re-run `stack done --local-exec`, or let the reaper reconcile.",
|
|
534
843
|
}), opts, EXIT.LEASE_FAILED);
|
|
535
844
|
}
|
|
536
|
-
const fin = await
|
|
845
|
+
const fin = await callTool("finalize_stack_lease", { lease_id: leaseId });
|
|
537
846
|
if (fin.ok && fin.data?.success) state = fin.data.state;
|
|
538
847
|
else process.stderr.write(`${yellow("⚠")} stack: finalize failed (${fin.error || fin.data?.code}); the reaper will reconcile.\n`);
|
|
539
848
|
}
|
|
540
|
-
return
|
|
849
|
+
return emitResult(buildReceipt({ command: "done", outcome: "released", lease_id: leaseId, state, disposition: opts.disposition,
|
|
850
|
+
...(legacyTargetProof ? { legacy_target_proof: legacyTargetProof } : {}) }), opts, EXIT.OK);
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
const SIGNAL_EXIT = Object.freeze({ SIGINT: 130, SIGTERM: 143, SIGHUP: 129 });
|
|
854
|
+
|
|
855
|
+
function safeConnectionPath(requested, leaseId) {
|
|
856
|
+
if (requested) {
|
|
857
|
+
// `wx` below prevents clobbering; canonicalising the parent prevents a
|
|
858
|
+
// symlinked directory from redirecting the only credential-bearing file.
|
|
859
|
+
const filename = basename(requested);
|
|
860
|
+
if (!filename || filename === "." || filename === "..") throw new Error("--connection-file must name a file");
|
|
861
|
+
return `${realpathSync(dirname(requested))}/${filename}`;
|
|
862
|
+
}
|
|
863
|
+
return `${tmpdir()}/botbuddy-stack-${leaseId}-${randomUUID()}.json`;
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
/** Write credentials atomically without ever putting them into argv, output, or a receipt. */
|
|
867
|
+
async function writeConnectionFile(path, connection) {
|
|
868
|
+
return writePrivateTextFile(path, JSON.stringify(connection));
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
/** Write a credential-bearing text artifact (dotenv/TOML/JSON) mode 0600. */
|
|
872
|
+
async function writePrivateTextFile(path, text) {
|
|
873
|
+
const handle = await open(path, "wx", 0o600);
|
|
874
|
+
try {
|
|
875
|
+
await handle.writeFile(text);
|
|
876
|
+
} catch (error) {
|
|
877
|
+
await unlink(path).catch(() => {});
|
|
878
|
+
throw error;
|
|
879
|
+
} finally {
|
|
880
|
+
await handle.close();
|
|
881
|
+
}
|
|
882
|
+
return path;
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
function childGroupSignal(child, signal) {
|
|
886
|
+
if (!child?.pid) return;
|
|
887
|
+
try { process.kill(-child.pid, signal); }
|
|
888
|
+
catch { try { child.kill(signal); } catch { /* already gone */ } }
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
function waitForChild(child) {
|
|
892
|
+
return new Promise((resolveChild, rejectChild) => {
|
|
893
|
+
child.once("error", rejectChild);
|
|
894
|
+
child.once("exit", (code, signal) => resolveChild({ code: code ?? (signal ? SIGNAL_EXIT[signal] ?? 1 : 1), signal }));
|
|
895
|
+
});
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
function isDenoIntegrationLane(argv) {
|
|
899
|
+
// `test:all` is the supported package wrapper that transitively invokes
|
|
900
|
+
// `test:integration`; both must receive the leased runner configuration.
|
|
901
|
+
return argv.includes("test:integration") || argv.includes("test:integration:coverage") || argv.includes("test:all") || argv.includes("scripts/run-deno-integration.sh");
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
function requireConnectionText(connection, key) {
|
|
905
|
+
const value = connection?.[key];
|
|
906
|
+
if (typeof value !== "string" || value.trim() === "") throw new Error(`active stack connection is missing ${key}`);
|
|
907
|
+
return value.trim();
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
/** Materialise the established integration runner inputs from a Helper receipt.
|
|
911
|
+
* The runner already understands BB_INTEGRATION_ENV_FILE / BB_STACK_CONFIG;
|
|
912
|
+
* never let its shared-stack defaults silently validate the wrong stack. */
|
|
913
|
+
export async function materializeLeasedTestConfig(worktreeRoot, leaseId, connection, { writePrivate = writePrivateTextFile, removePrivate = (path) => unlink(path).catch(() => {}) } = {}) {
|
|
914
|
+
const apiUrl = requireConnectionText(connection, "api_url");
|
|
915
|
+
const anonKey = requireConnectionText(connection, "anon_key");
|
|
916
|
+
const serviceRoleKey = requireConnectionText(connection, "service_role_key");
|
|
917
|
+
const projectId = requireConnectionText(connection, "project_id");
|
|
918
|
+
const parsedApi = new URL(apiUrl);
|
|
919
|
+
if (!parsedApi.port || !["127.0.0.1", "localhost"].includes(parsedApi.hostname)) {
|
|
920
|
+
throw new Error("active stack connection api_url must be a local host URL with an explicit port");
|
|
921
|
+
}
|
|
922
|
+
const dbPort = Number(connection.db_port ?? new URL(requireConnectionText(connection, "db_url")).port);
|
|
923
|
+
if (!Number.isInteger(dbPort) || dbPort <= 0 || dbPort > 65535) throw new Error("active stack connection is missing a valid db_port");
|
|
924
|
+
|
|
925
|
+
const basePath = `${worktreeRoot}/supabase/functions/_test/integration/.env.integration`;
|
|
926
|
+
const base = await readFile(basePath, "utf8");
|
|
927
|
+
const values = {
|
|
928
|
+
SUPABASE_URL: apiUrl,
|
|
929
|
+
VITE_SUPABASE_URL: apiUrl,
|
|
930
|
+
SUPABASE_ANON_KEY: anonKey,
|
|
931
|
+
VITE_SUPABASE_PUBLISHABLE_KEY: anonKey,
|
|
932
|
+
SUPABASE_SERVICE_ROLE_KEY: serviceRoleKey,
|
|
933
|
+
INTEGRATION_DB_PORT: String(dbPort),
|
|
934
|
+
};
|
|
935
|
+
const seen = new Set();
|
|
936
|
+
const env = base.split(/\r?\n/).map((line) => {
|
|
937
|
+
const key = /^([A-Z0-9_]+)=/.exec(line)?.[1];
|
|
938
|
+
if (!key || !(key in values)) return line;
|
|
939
|
+
seen.add(key);
|
|
940
|
+
return `${key}=${values[key]}`;
|
|
941
|
+
});
|
|
942
|
+
for (const [key, value] of Object.entries(values)) if (!seen.has(key)) env.push(`${key}=${value}`);
|
|
943
|
+
|
|
944
|
+
const stamp = `${tmpdir()}/botbuddy-stack-${leaseId}-${randomUUID()}`;
|
|
945
|
+
const envFile = `${stamp}.env`;
|
|
946
|
+
const stackConfig = `${stamp}.toml`;
|
|
947
|
+
await writePrivate(envFile, env.join("\n"));
|
|
948
|
+
try {
|
|
949
|
+
await writePrivate(stackConfig, `project_id = "${projectId}"\n\n[api]\nport = ${parsedApi.port}\n\n[db]\nport = ${dbPort}\n`);
|
|
950
|
+
} catch (error) {
|
|
951
|
+
await removePrivate(envFile);
|
|
952
|
+
throw error;
|
|
953
|
+
}
|
|
954
|
+
return { envFile, stackConfig, edgeMountRoot: worktreeRoot };
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
/**
|
|
958
|
+
* The command-owned lifecycle. The optional adapters are intentionally narrow so
|
|
959
|
+
* its queue, heartbeat, process-group, and cleanup contract is spawn-testable
|
|
960
|
+
* without Docker or a live BotBuddy service.
|
|
961
|
+
*/
|
|
962
|
+
export async function runStackLifecycle(opts, childArgv, adapters = {}) {
|
|
963
|
+
const api = adapters.api ?? {
|
|
964
|
+
request: (args) => callToolJson("request_stack_lease", args),
|
|
965
|
+
get: (leaseId) => callToolJson("get_stack_lease", { lease_id: leaseId }),
|
|
966
|
+
touch: (leaseId) => callToolJson("touch_stack_lease", { lease_id: leaseId }),
|
|
967
|
+
release: (leaseId, signal) => callToolJson("release_stack_lease", { lease_id: leaseId, disposition: "destroy" }, { signal }),
|
|
968
|
+
};
|
|
969
|
+
const auth = adapters.auth ?? stackAuthHeader();
|
|
970
|
+
const wait = adapters.wait ?? ((leaseId, done, failed, options) => waitForLease(leaseId, done, failed, options));
|
|
971
|
+
// nosemgrep: javascript.lang.security.detect-child-process.detect-child-process -- validated executable + argv only; shell is never used.
|
|
972
|
+
const startChild = adapters.startChild ?? ((argv, env, cwd) => spawn(argv[0], argv.slice(1), { cwd, env, stdio: "inherit", detached: true }));
|
|
973
|
+
const writeConnection = adapters.writeConnection ?? writeConnectionFile;
|
|
974
|
+
const removeConnection = adapters.removeConnection ?? ((path) => unlink(path).catch(() => {}));
|
|
975
|
+
const materializeTestConfig = adapters.materializeTestConfig ?? materializeLeasedTestConfig;
|
|
976
|
+
const clock = adapters.clock ?? { setInterval, clearInterval, setTimeout, clearTimeout };
|
|
977
|
+
const signals = adapters.signals ?? process;
|
|
978
|
+
const provisionTimeout = opts.provisionTimeout ?? opts.timeout;
|
|
979
|
+
let leaseId = null;
|
|
980
|
+
let child = null;
|
|
981
|
+
let childResult = null;
|
|
982
|
+
let connectionFile = null;
|
|
983
|
+
let heartbeat = null;
|
|
984
|
+
let hardTimer = null;
|
|
985
|
+
let cleanupResult = null;
|
|
986
|
+
let cleanupPromise = null;
|
|
987
|
+
let leasedTestConfig = null;
|
|
988
|
+
let receivedSignal = null;
|
|
989
|
+
let forcedStopCode = null;
|
|
990
|
+
let fencing = false;
|
|
991
|
+
let activeWaitAbort = null;
|
|
992
|
+
|
|
993
|
+
const requestCleanup = () => {
|
|
994
|
+
// A signal can arrive while the lease-request RPC is in flight. There is
|
|
995
|
+
// nothing to release yet, so do not cache this no-op; once the RPC returns
|
|
996
|
+
// the interruption branch must perform the real signed cleanup.
|
|
997
|
+
if (!leaseId) return Promise.resolve({ ok: true, state: null });
|
|
998
|
+
if (cleanupPromise) return cleanupPromise;
|
|
999
|
+
cleanupPromise = (async () => {
|
|
1000
|
+
if (!leaseId) return cleanupResult = { ok: true, state: null };
|
|
1001
|
+
const cleanupAbort = new AbortController();
|
|
1002
|
+
const cleanupTimer = clock.setTimeout(() => cleanupAbort.abort("reap-timeout"), opts.reapTimeout * 1000);
|
|
1003
|
+
let release;
|
|
1004
|
+
try {
|
|
1005
|
+
release = await api.release(leaseId, cleanupAbort.signal);
|
|
1006
|
+
} finally {
|
|
1007
|
+
clock.clearTimeout(cleanupTimer);
|
|
1008
|
+
}
|
|
1009
|
+
if (!release?.ok || !release?.data?.success) {
|
|
1010
|
+
return cleanupResult = { ok: false, error: release?.error || release?.data?.code || "release failed" };
|
|
1011
|
+
}
|
|
1012
|
+
if (release.data.state === "reaped") return cleanupResult = { ok: true, state: "reaped" };
|
|
1013
|
+
const reaped = await wait(leaseId, (state) => state === "reaped", () => false, { timeoutSec: opts.reapTimeout, auth });
|
|
1014
|
+
if (reaped?.woke || reaped?.state === "reaped") return cleanupResult = { ok: true, state: "reaped" };
|
|
1015
|
+
return cleanupResult = { ok: false, error: reaped?.timeout ? `signed reap did not arrive within ${opts.reapTimeout}s` : (reaped?.error || "signed reap was not proven") };
|
|
1016
|
+
})();
|
|
1017
|
+
return cleanupPromise;
|
|
1018
|
+
};
|
|
1019
|
+
|
|
1020
|
+
const stopChild = (signal = "SIGTERM", forcedCode = null) => {
|
|
1021
|
+
if (!child || childResult) return;
|
|
1022
|
+
forcedStopCode ??= forcedCode;
|
|
1023
|
+
childGroupSignal(child, signal);
|
|
1024
|
+
const kill = clock.setTimeout(() => childGroupSignal(child, "SIGKILL"), 10_000);
|
|
1025
|
+
kill.unref?.();
|
|
1026
|
+
};
|
|
1027
|
+
|
|
1028
|
+
const onSignal = (signal) => {
|
|
1029
|
+
if (receivedSignal) return;
|
|
1030
|
+
receivedSignal = signal;
|
|
1031
|
+
activeWaitAbort?.abort("interrupted");
|
|
1032
|
+
stopChild(signal);
|
|
1033
|
+
// If capacity is still queued/provisioning there is no child to hold open;
|
|
1034
|
+
// release immediately so an interrupted invocation cannot later provision.
|
|
1035
|
+
if (!child) void requestCleanup();
|
|
1036
|
+
};
|
|
1037
|
+
const signalHandlers = new Map();
|
|
1038
|
+
for (const signal of Object.keys(SIGNAL_EXIT)) {
|
|
1039
|
+
const handler = () => onSignal(signal);
|
|
1040
|
+
signalHandlers.set(signal, handler);
|
|
1041
|
+
signals.on?.(signal, handler);
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
try {
|
|
1045
|
+
if (!auth) return { exitCode: EXIT.AUTH, outcome: "error", error: "not authenticated — run botbuddy profile setup botbuddy-dev" };
|
|
1046
|
+
const slot = deriveSlot(opts);
|
|
1047
|
+
const execution = resolveStackPath(process.cwd(), opts.stackPath);
|
|
1048
|
+
const request = await api.request({
|
|
1049
|
+
slot, host_key: opts.host || undefined, repo: opts.repo, ticket_id: opts.ticket,
|
|
1050
|
+
ticket_url: opts.ticketUrl || undefined, pr_id: opts.prId || undefined, pr_url: opts.prUrl || undefined,
|
|
1051
|
+
purpose: opts.purpose || "stack run", idle_ttl_secs: opts.idleTtl ?? undefined,
|
|
1052
|
+
stack_path: execution.stackPath, worktree_root: execution.worktreeRoot,
|
|
1053
|
+
});
|
|
1054
|
+
if (!request?.ok) return { exitCode: request?.auth ? EXIT.AUTH : EXIT.BACKEND, outcome: "error", error: request?.error || "lease request failed" };
|
|
1055
|
+
if (!request.data?.success) return { exitCode: EXIT.BACKEND, outcome: "error", error: request.data?.message || request.data?.code || "lease request refused" };
|
|
1056
|
+
leaseId = request.data.lease_id;
|
|
1057
|
+
if (request.data.reused) {
|
|
1058
|
+
return { exitCode: EXIT.BACKEND, outcome: "error", leaseId, error: "a live lease already exists for this agent and stack slot; wait for that batch to finish instead of sharing its stack" };
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
let state = request.data.state;
|
|
1062
|
+
if (receivedSignal) {
|
|
1063
|
+
const cleanup = await requestCleanup();
|
|
1064
|
+
return { exitCode: SIGNAL_EXIT[receivedSignal], outcome: "interrupted", leaseId, cleanup };
|
|
1065
|
+
}
|
|
1066
|
+
if (state === "queued") {
|
|
1067
|
+
activeWaitAbort = new AbortController();
|
|
1068
|
+
const parked = await wait(leaseId, (s) => s !== "queued" && s != null, (s) => s === "reaped", { timeoutSec: opts.timeout, auth, signal: activeWaitAbort.signal });
|
|
1069
|
+
activeWaitAbort = null;
|
|
1070
|
+
if (parked?.interrupted || receivedSignal) { const cleanup = await requestCleanup(); return { exitCode: SIGNAL_EXIT[receivedSignal], outcome: "interrupted", leaseId, cleanup }; }
|
|
1071
|
+
if (parked?.timeout) { const cleanup = await requestCleanup(); return { exitCode: EXIT.TIMEOUT, outcome: "timeout", leaseId, error: `parked ${opts.timeout}s without capacity`, cleanup }; }
|
|
1072
|
+
if (parked?.failed || parked?.error || parked?.auth) { const cleanup = await requestCleanup(); return { exitCode: parked?.auth ? EXIT.AUTH : EXIT.LEASE_FAILED, outcome: "error", leaseId, error: parked?.error || "lease did not leave queue", cleanup }; }
|
|
1073
|
+
state = parked.state;
|
|
1074
|
+
}
|
|
1075
|
+
if (state !== "active") {
|
|
1076
|
+
activeWaitAbort = new AbortController();
|
|
1077
|
+
const active = await wait(leaseId, (s) => s === "active", (s) => s === "reaped", { timeoutSec: provisionTimeout, auth, signal: activeWaitAbort.signal });
|
|
1078
|
+
activeWaitAbort = null;
|
|
1079
|
+
if (active?.interrupted || receivedSignal) { const cleanup = await requestCleanup(); return { exitCode: SIGNAL_EXIT[receivedSignal], outcome: "interrupted", leaseId, cleanup }; }
|
|
1080
|
+
if (active?.timeout) { const cleanup = await requestCleanup(); return { exitCode: EXIT.TIMEOUT, outcome: "timeout", leaseId, error: `physical provision did not reach active in ${provisionTimeout}s`, cleanup }; }
|
|
1081
|
+
if (active?.failed || active?.error || active?.auth) { const cleanup = await requestCleanup(); return { exitCode: active?.auth ? EXIT.AUTH : EXIT.LEASE_FAILED, outcome: "error", leaseId, error: active?.error || "lease was reaped before active", cleanup }; }
|
|
1082
|
+
}
|
|
1083
|
+
const current = await api.get(leaseId);
|
|
1084
|
+
if (!current?.ok || !current.data?.success || current.data.state !== "active" || !current.data.connection || typeof current.data.connection !== "object") {
|
|
1085
|
+
const cleanup = await requestCleanup();
|
|
1086
|
+
return { exitCode: EXIT.LEASE_FAILED, outcome: "error", leaseId, error: current?.error || `lease is ${current?.data?.state ?? "unreadable"}, not active with a connection`, cleanup };
|
|
1087
|
+
}
|
|
1088
|
+
if (receivedSignal) {
|
|
1089
|
+
const cleanup = await requestCleanup();
|
|
1090
|
+
return { exitCode: SIGNAL_EXIT[receivedSignal], outcome: "interrupted", leaseId, cleanup };
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
connectionFile = await writeConnection(safeConnectionPath(opts.connectionFile, leaseId), current.data.connection);
|
|
1094
|
+
if (receivedSignal) {
|
|
1095
|
+
const cleanup = await requestCleanup();
|
|
1096
|
+
return { exitCode: SIGNAL_EXIT[receivedSignal], outcome: "interrupted", leaseId, cleanup };
|
|
1097
|
+
}
|
|
1098
|
+
if (isDenoIntegrationLane(childArgv)) leasedTestConfig = await materializeTestConfig(execution.worktreeRoot, leaseId, current.data.connection);
|
|
1099
|
+
if (receivedSignal) {
|
|
1100
|
+
const cleanup = await requestCleanup();
|
|
1101
|
+
return { exitCode: SIGNAL_EXIT[receivedSignal], outcome: "interrupted", leaseId, cleanup };
|
|
1102
|
+
}
|
|
1103
|
+
const childEnv = {
|
|
1104
|
+
...process.env,
|
|
1105
|
+
BOTBUDDY_STACK_LEASE_ID: leaseId,
|
|
1106
|
+
BOTBUDDY_STACK_CONNECTION_FILE: connectionFile,
|
|
1107
|
+
...(leasedTestConfig ? {
|
|
1108
|
+
BB_INTEGRATION_ENV_FILE: leasedTestConfig.envFile,
|
|
1109
|
+
BB_STACK_CONFIG: leasedTestConfig.stackConfig,
|
|
1110
|
+
BB_EDGE_MOUNT_ROOT: leasedTestConfig.edgeMountRoot,
|
|
1111
|
+
} : {}),
|
|
1112
|
+
};
|
|
1113
|
+
// resolveStackPath already canonicalised this bounded relative path.
|
|
1114
|
+
const childCwd = execution.stackPath === "." ? execution.worktreeRoot : `${execution.worktreeRoot}/${execution.stackPath}`;
|
|
1115
|
+
child = startChild(childArgv, childEnv, childCwd);
|
|
1116
|
+
const cadenceMs = Math.max(1_000, Math.floor((current.data.idle_ttl_secs ?? opts.idleTtl ?? 1800) * 1000 / 3));
|
|
1117
|
+
heartbeat = clock.setInterval(async () => {
|
|
1118
|
+
if (fencing || childResult) return;
|
|
1119
|
+
const touched = await api.touch(leaseId);
|
|
1120
|
+
if (!touched?.ok || !touched?.data?.success) {
|
|
1121
|
+
fencing = true;
|
|
1122
|
+
stopChild("SIGTERM", EXIT.LEASE_FAILED);
|
|
1123
|
+
}
|
|
1124
|
+
}, cadenceMs);
|
|
1125
|
+
heartbeat.unref?.();
|
|
1126
|
+
if (opts.hardTtl) hardTimer = clock.setTimeout(() => stopChild("SIGTERM", EXIT.TIMEOUT), opts.hardTtl * 1000);
|
|
1127
|
+
hardTimer?.unref?.();
|
|
1128
|
+
childResult = await waitForChild(child);
|
|
1129
|
+
if (heartbeat) clock.clearInterval(heartbeat);
|
|
1130
|
+
if (hardTimer) clock.clearTimeout(hardTimer);
|
|
1131
|
+
const cleanup = await requestCleanup();
|
|
1132
|
+
const exitCode = receivedSignal ? SIGNAL_EXIT[receivedSignal] : forcedStopCode ?? (childResult.code !== 0 ? childResult.code : !cleanup.ok ? EXIT.CLEANUP_FAILED : 0);
|
|
1133
|
+
return { exitCode, outcome: exitCode === 0 ? "completed" : "failed", leaseId, childExitCode: childResult.code, cleanup, fenced: fencing };
|
|
1134
|
+
} catch (error) {
|
|
1135
|
+
const cleanup = await requestCleanup();
|
|
1136
|
+
return { exitCode: cleanup?.ok === false ? EXIT.CLEANUP_FAILED : EXIT.INTERNAL, outcome: "error", leaseId, error: String(error?.message ?? error), cleanup };
|
|
1137
|
+
} finally {
|
|
1138
|
+
if (heartbeat) clock.clearInterval(heartbeat);
|
|
1139
|
+
if (hardTimer) clock.clearTimeout(hardTimer);
|
|
1140
|
+
if (connectionFile) await removeConnection(connectionFile);
|
|
1141
|
+
if (leasedTestConfig) await Promise.all([removeConnection(leasedTestConfig.envFile), removeConnection(leasedTestConfig.stackConfig)]);
|
|
1142
|
+
for (const [signal, handler] of signalHandlers) signals.off?.(signal, handler);
|
|
1143
|
+
}
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
async function cmdRun(opts, childArgv) {
|
|
1147
|
+
const result = await runStackLifecycle(opts, childArgv);
|
|
1148
|
+
return emit(buildReceipt({
|
|
1149
|
+
command: "run", outcome: result.outcome, lease_id: result.leaseId ?? null,
|
|
1150
|
+
child_exit_code: result.childExitCode ?? null, cleanup: result.cleanup?.ok ?? null,
|
|
1151
|
+
fenced: result.fenced ?? false, error: result.error ?? result.cleanup?.error ?? null,
|
|
1152
|
+
}), opts, result.exitCode);
|
|
541
1153
|
}
|
|
542
1154
|
|
|
543
1155
|
/** Entry point wired from commands.mjs (`botbuddy stack ...`). Returns/sets the exit code. */
|
|
@@ -546,7 +1158,7 @@ export async function cmdStack(argv) {
|
|
|
546
1158
|
process.stdout.write(STACK_HELP + "\n");
|
|
547
1159
|
return EXIT.OK;
|
|
548
1160
|
}
|
|
549
|
-
const { command, leaseId, opts, errors } = parseStackArgs(argv);
|
|
1161
|
+
const { command, leaseId, opts, errors, childArgv } = parseStackArgs(argv);
|
|
550
1162
|
if (errors.length) {
|
|
551
1163
|
for (const e of errors) process.stderr.write(`${yellow("⚠")} stack: ${e}\n`);
|
|
552
1164
|
const code = emit(buildReceipt({ command: command || "?", outcome: "error", error: errors[0] }), opts, EXIT.INVALID);
|
|
@@ -560,6 +1172,7 @@ export async function cmdStack(argv) {
|
|
|
560
1172
|
case "status": code = await cmdStatus(leaseId, opts); break;
|
|
561
1173
|
case "touch": code = await cmdTouch(leaseId, opts); break;
|
|
562
1174
|
case "done": code = await cmdDone(leaseId, opts); break;
|
|
1175
|
+
case "run": code = await cmdRun(opts, childArgv); break;
|
|
563
1176
|
default:
|
|
564
1177
|
process.stderr.write(`${yellow("⚠")} stack: unknown subcommand "${command}". Try ${bold("botbuddy stack help")}.\n`);
|
|
565
1178
|
code = emit(buildReceipt({ command: command || "?", outcome: "error", error: `unknown subcommand: ${command}` }), opts, EXIT.INVALID);
|