@botbuddy/cli 1.4.2 → 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.
- package/package.json +3 -2
- package/src/commands.mjs +7 -0
- package/src/docker-hygiene.mjs +1062 -0
- package/src/run.mjs +5 -1
- package/src/stack-file-lock.mjs +180 -0
- package/src/stack.mjs +257 -36
- package/src/auth.test.mjs +0 -404
- package/src/discovery.test.mjs +0 -195
- package/src/locks.test.mjs +0 -60
- package/src/profile-bootstrap.test.mjs +0 -205
- package/src/publish-equal.test.mjs +0 -176
- package/src/publish-workflow.test.mjs +0 -122
- package/src/quiet-runner.test.mjs +0 -109
- package/src/run.test.mjs +0 -173
- package/src/stack.test.mjs +0 -434
- package/src/wait-profile.test.mjs +0 -30
- package/src/wait.test.mjs +0 -266
package/src/run.mjs
CHANGED
|
@@ -132,10 +132,14 @@ export async function runWorker(contextFile, { spawnImpl = spawn, call = callToo
|
|
|
132
132
|
// Subscribe before any filesystem I/O so a busy parallel test run (or a real
|
|
133
133
|
// fast failure) cannot lose the sole terminal event.
|
|
134
134
|
const completion = waitForChild(child);
|
|
135
|
-
|
|
135
|
+
// Capture pipes before the first await too. A short-lived command can emit
|
|
136
|
+
// all of its output while context persistence yields to the filesystem; if
|
|
137
|
+
// these listeners are attached afterwards, its terminal receipt silently
|
|
138
|
+
// reports zero bytes even though the command wrote successfully (BOT-1411).
|
|
136
139
|
const capture = boundedCapture();
|
|
137
140
|
child.stdout?.on("data", (chunk) => capture.append(chunk));
|
|
138
141
|
child.stderr?.on("data", (chunk) => capture.append(chunk));
|
|
142
|
+
await writeJson(context.context_path, { ...context, worker_pid: process.pid, workload_pid: child.pid });
|
|
139
143
|
let killTimer;
|
|
140
144
|
const timer = setTimeout(() => {
|
|
141
145
|
timedOut = true;
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
// BOT-904 / BOT-1405 — canonical cross-worktree local Supabase mutex.
|
|
2
|
+
//
|
|
3
|
+
// This module lives in the published CLI so both repository test wrappers and
|
|
4
|
+
// `botbuddy docker hygiene --apply` use the exact same lock path and protocol.
|
|
5
|
+
// macOS has no flock(1), so acquisition uses an atomically linked lockfile with
|
|
6
|
+
// dead/stale-holder recovery.
|
|
7
|
+
import { randomUUID } from "node:crypto";
|
|
8
|
+
import { linkSync, readFileSync, statSync, unlinkSync, writeFileSync } from "node:fs";
|
|
9
|
+
import { hostname, tmpdir } from "node:os";
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
|
|
12
|
+
let tmpCounter = 0;
|
|
13
|
+
|
|
14
|
+
export function projectIdFromConfig(configText) {
|
|
15
|
+
const match = String(configText).match(/^\s*project_id\s*=\s*"([^"]+)"/m);
|
|
16
|
+
return match ? match[1] : null;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function dbPortFromConfig(configText) {
|
|
20
|
+
const lines = String(configText).split(/\r?\n/);
|
|
21
|
+
let inDb = false;
|
|
22
|
+
for (const line of lines) {
|
|
23
|
+
const section = /^\s*\[([^\]]+)\]/.exec(line);
|
|
24
|
+
if (section) {
|
|
25
|
+
inDb = section[1].trim() === "db";
|
|
26
|
+
continue;
|
|
27
|
+
}
|
|
28
|
+
if (!inDb) continue;
|
|
29
|
+
const match = /^\s*port\s*=\s*(\d+)/.exec(line);
|
|
30
|
+
if (match) return match[1];
|
|
31
|
+
}
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function lockPathForProject(projectId) {
|
|
36
|
+
return join(tmpdir(), `botbuddy-stack-${projectId}.lock`);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function isAlive(pid) {
|
|
40
|
+
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
41
|
+
try {
|
|
42
|
+
process.kill(pid, 0);
|
|
43
|
+
return true;
|
|
44
|
+
} catch (error) {
|
|
45
|
+
return error.code === "EPERM";
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const defaultSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
50
|
+
|
|
51
|
+
export function reclaimPathForLock(path) {
|
|
52
|
+
return `${path}.reclaim`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Serialize stale replacement. An abandoned guard fails closed; recursively
|
|
56
|
+
* stale-stealing a reclaim guard would recreate the race this guard prevents. */
|
|
57
|
+
export async function acquireReclaimGuard(
|
|
58
|
+
path,
|
|
59
|
+
{ timeoutMs = 300_000, pollMs = 500, now = () => Date.now(), sleep = defaultSleep } = {},
|
|
60
|
+
) {
|
|
61
|
+
const deadline = now() + timeoutMs;
|
|
62
|
+
const ownerToken = randomUUID();
|
|
63
|
+
const tmp = `${path}.${process.pid}.${tmpCounter++}.tmp`;
|
|
64
|
+
const payload = JSON.stringify({ pid: process.pid, host: hostname(), owner_token: ownerToken });
|
|
65
|
+
for (;;) {
|
|
66
|
+
try {
|
|
67
|
+
writeFileSync(tmp, payload);
|
|
68
|
+
linkSync(tmp, path);
|
|
69
|
+
try { unlinkSync(tmp); } catch { /* best-effort temp cleanup */ }
|
|
70
|
+
let released = false;
|
|
71
|
+
return {
|
|
72
|
+
path,
|
|
73
|
+
release() {
|
|
74
|
+
if (released) return;
|
|
75
|
+
released = true;
|
|
76
|
+
try {
|
|
77
|
+
const current = JSON.parse(readFileSync(path, "utf8"));
|
|
78
|
+
if (current.owner_token !== ownerToken) return;
|
|
79
|
+
unlinkSync(path);
|
|
80
|
+
} catch { /* already gone, corrupt, or superseded */ }
|
|
81
|
+
},
|
|
82
|
+
};
|
|
83
|
+
} catch (error) {
|
|
84
|
+
try { unlinkSync(tmp); } catch { /* temp may not exist */ }
|
|
85
|
+
if (error.code !== "EEXIST") throw error;
|
|
86
|
+
if (now() >= deadline) {
|
|
87
|
+
throw new Error(`stack-lock: timed out after ${timeoutMs}ms waiting for stale-reclaim guard ${path}`);
|
|
88
|
+
}
|
|
89
|
+
await sleep(pollMs);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function inspectExistingLock(path, now, staleMs) {
|
|
95
|
+
let raw;
|
|
96
|
+
try { raw = readFileSync(path, "utf8"); } catch { return { exists: false }; }
|
|
97
|
+
let holder = null;
|
|
98
|
+
try { holder = JSON.parse(raw); } catch { /* corrupt: use mtime */ }
|
|
99
|
+
const sameHost = holder && holder.host === hostname();
|
|
100
|
+
const sameHostDead = sameHost && !isAlive(holder.pid);
|
|
101
|
+
let tooOld;
|
|
102
|
+
if (holder) {
|
|
103
|
+
tooOld = now() - (holder.startedAt ?? 0) > staleMs;
|
|
104
|
+
} else {
|
|
105
|
+
let mtimeMs;
|
|
106
|
+
try { mtimeMs = statSync(path).mtimeMs; } catch { return { exists: false }; }
|
|
107
|
+
tooOld = now() - mtimeMs > staleMs;
|
|
108
|
+
}
|
|
109
|
+
return { exists: true, raw, holder, stealable: sameHost ? sameHostDead : tooOld };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export async function acquireStackLock(
|
|
113
|
+
path,
|
|
114
|
+
{
|
|
115
|
+
timeoutMs = 300_000,
|
|
116
|
+
staleMs = 900_000,
|
|
117
|
+
pollMs = 500,
|
|
118
|
+
now = () => Date.now(),
|
|
119
|
+
sleep = defaultSleep,
|
|
120
|
+
acquireReclaim = acquireReclaimGuard,
|
|
121
|
+
} = {},
|
|
122
|
+
) {
|
|
123
|
+
const deadline = now() + timeoutMs;
|
|
124
|
+
const tmp = `${path}.${process.pid}.${tmpCounter++}.tmp`;
|
|
125
|
+
const ownerToken = randomUUID();
|
|
126
|
+
const payload = JSON.stringify({
|
|
127
|
+
pid: process.pid,
|
|
128
|
+
host: hostname(),
|
|
129
|
+
startedAt: now(),
|
|
130
|
+
owner_token: ownerToken,
|
|
131
|
+
});
|
|
132
|
+
for (;;) {
|
|
133
|
+
try {
|
|
134
|
+
writeFileSync(tmp, payload);
|
|
135
|
+
linkSync(tmp, path);
|
|
136
|
+
try { unlinkSync(tmp); } catch { /* best-effort temp cleanup */ }
|
|
137
|
+
let released = false;
|
|
138
|
+
return {
|
|
139
|
+
path,
|
|
140
|
+
release() {
|
|
141
|
+
if (released) return;
|
|
142
|
+
released = true;
|
|
143
|
+
try {
|
|
144
|
+
const current = JSON.parse(readFileSync(path, "utf8"));
|
|
145
|
+
if (current.owner_token !== ownerToken) return;
|
|
146
|
+
unlinkSync(path);
|
|
147
|
+
} catch { /* already gone, corrupt, or superseded: never unlink blindly */ }
|
|
148
|
+
},
|
|
149
|
+
};
|
|
150
|
+
} catch (error) {
|
|
151
|
+
try { unlinkSync(tmp); } catch { /* temp may not exist */ }
|
|
152
|
+
if (error.code !== "EEXIST") throw error;
|
|
153
|
+
|
|
154
|
+
const observed = inspectExistingLock(path, now, staleMs);
|
|
155
|
+
if (!observed.exists) continue;
|
|
156
|
+
if (observed.stealable) {
|
|
157
|
+
const guard = await acquireReclaim(reclaimPathForLock(path), {
|
|
158
|
+
timeoutMs: Math.max(1, deadline - now()), pollMs, now, sleep,
|
|
159
|
+
});
|
|
160
|
+
try {
|
|
161
|
+
const current = inspectExistingLock(path, now, staleMs);
|
|
162
|
+
if (current.exists && current.raw === observed.raw && current.stealable) {
|
|
163
|
+
try { unlinkSync(path); } catch { /* vanished: retry */ }
|
|
164
|
+
}
|
|
165
|
+
} finally {
|
|
166
|
+
guard.release();
|
|
167
|
+
}
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
if (now() >= deadline) {
|
|
171
|
+
throw new Error(
|
|
172
|
+
`stack-lock: timed out after ${timeoutMs}ms waiting for ${path} ` +
|
|
173
|
+
`(held by pid ${observed.holder?.pid} on ${observed.holder?.host}). Another worktree ` +
|
|
174
|
+
"is using the shared local Supabase stack.",
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
await sleep(pollMs);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
package/src/stack.mjs
CHANGED
|
@@ -29,6 +29,7 @@ import { basename, dirname, join, relative, isAbsolute } from "path";
|
|
|
29
29
|
import { randomUUID } from "crypto";
|
|
30
30
|
import { callToolJson } from "./api.mjs";
|
|
31
31
|
import { SERVER_URL, getConfig } from "./config.mjs";
|
|
32
|
+
import { runDockerWorkflow } from "./docker-hygiene.mjs";
|
|
32
33
|
import { bold, dim, yellow } from "./utils.mjs";
|
|
33
34
|
|
|
34
35
|
export const STACK_SCHEMA_VERSION = 1;
|
|
@@ -71,10 +72,15 @@ ${bold("up OPTIONS")}
|
|
|
71
72
|
--timeout <seconds> Max seconds to park for capacity before giving up (default ${DEFAULT_TIMEOUT_SEC}).
|
|
72
73
|
--no-wait If the host is full, print the queue position and exit (don't park).
|
|
73
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.
|
|
74
77
|
|
|
75
78
|
${bold("done OPTIONS")}
|
|
76
79
|
--stop Keep volumes (cheap re-provision next batch). Default: destroy.
|
|
77
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.
|
|
78
84
|
|
|
79
85
|
${bold("run OPTIONS")}
|
|
80
86
|
--repo <repo> Required approved repository name.
|
|
@@ -146,10 +152,17 @@ export function parseStackArgs(argv) {
|
|
|
146
152
|
prId: null, prUrl: null, purpose: null, idleTtl: null, stackPath: ".",
|
|
147
153
|
timeout: DEFAULT_TIMEOUT_SEC, provisionTimeout: null, reapTimeout: 300, hardTtl: null,
|
|
148
154
|
connectionFile: null, noWait: false, localExec: false,
|
|
155
|
+
dockerContext: null, dockerEndpoint: null,
|
|
149
156
|
disposition: "destroy", json: false, receiptMaxBytes: DEFAULT_RECEIPT_MAX_BYTES,
|
|
150
157
|
};
|
|
151
158
|
const positionals = [];
|
|
152
|
-
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
|
+
};
|
|
153
166
|
for (let i = 0; i < rest.length; i++) {
|
|
154
167
|
const a = rest[i];
|
|
155
168
|
switch (a) {
|
|
@@ -213,6 +226,8 @@ export function parseStackArgs(argv) {
|
|
|
213
226
|
}
|
|
214
227
|
case "--no-wait": opts.noWait = true; break;
|
|
215
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;
|
|
216
231
|
case "--stop": opts.disposition = "stop"; break;
|
|
217
232
|
case "--destroy": opts.disposition = "destroy"; break;
|
|
218
233
|
case "--json": opts.json = true; break;
|
|
@@ -229,6 +244,12 @@ export function parseStackArgs(argv) {
|
|
|
229
244
|
if (command && ["status", "touch", "done"].includes(command) && !leaseId) {
|
|
230
245
|
errors.push(`${command} needs a <lease_id>`);
|
|
231
246
|
}
|
|
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
|
+
}
|
|
232
253
|
if (command === "run") {
|
|
233
254
|
if (!opts.repo) errors.push("run needs --repo <approved-repo>");
|
|
234
255
|
if (!opts.ticket || !/^[A-Z][A-Z0-9]*-\d+$/i.test(opts.ticket)) errors.push("run needs --ticket <BOT-n|ENT-n>");
|
|
@@ -467,8 +488,120 @@ const isActive = (s) => s === "active";
|
|
|
467
488
|
const nonQueued = (s) => s != null && s !== "queued";
|
|
468
489
|
const isReaped = (s) => s === "reaping" || s === "reaped";
|
|
469
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
|
+
|
|
470
603
|
/** LOUD local-exec fallback: bring a stack up in the cwd via the Supabase CLI. */
|
|
471
|
-
function localProvision() {
|
|
604
|
+
function localProvision(opts, dockerTarget) {
|
|
472
605
|
// Pin a LOCAL origin so `supabase start` never bakes the repo's prod origin into the
|
|
473
606
|
// edge runtime (BOT-903 / Codex P1). Refuse rather than risk crossing into production.
|
|
474
607
|
const url = resolveLocalSupabaseUrl();
|
|
@@ -480,7 +613,7 @@ function localProvision() {
|
|
|
480
613
|
"export VITE_SUPABASE_URL=http://127.0.0.1:<port> first.",
|
|
481
614
|
);
|
|
482
615
|
}
|
|
483
|
-
const spawnEnv = { ...
|
|
616
|
+
const spawnEnv = { ...dockerEnvForTarget(dockerTarget), VITE_SUPABASE_URL: url };
|
|
484
617
|
process.stderr.write(`${yellow("⚠ LOCAL-EXEC FALLBACK")} — no BotBuddy Helper; running ${bold("supabase start")} in this worktree (VITE_SUPABASE_URL=${url}).\n`);
|
|
485
618
|
const start = spawnSync("supabase", ["start", "--workdir", process.cwd()], { encoding: "utf8", env: spawnEnv });
|
|
486
619
|
if (start.status !== 0) {
|
|
@@ -491,9 +624,12 @@ function localProvision() {
|
|
|
491
624
|
}
|
|
492
625
|
|
|
493
626
|
/** LOUD local-exec fallback: tear the stack down in the cwd. Returns true iff it succeeded. */
|
|
494
|
-
function localTeardown() {
|
|
627
|
+
export function localTeardown(_opts, dockerTarget, spawn = spawnSync, env = process.env) {
|
|
495
628
|
process.stderr.write(`${yellow("⚠ LOCAL-EXEC FALLBACK")} — running ${bold("supabase stop")} in this worktree.\n`);
|
|
496
|
-
const res =
|
|
629
|
+
const res = spawn("supabase", ["stop", "--workdir", process.cwd()], {
|
|
630
|
+
encoding: "utf8",
|
|
631
|
+
env: dockerEnvForTarget(dockerTarget, env),
|
|
632
|
+
});
|
|
497
633
|
if (res.status !== 0) {
|
|
498
634
|
process.stderr.write(`${yellow("⚠")} stack: supabase stop returned ${res.status}: ${(res.stderr || res.stdout || "").slice(0, 300)}\n`);
|
|
499
635
|
}
|
|
@@ -508,19 +644,42 @@ function emit(receipt, opts, code) {
|
|
|
508
644
|
|
|
509
645
|
// ── command orchestration ────────────────────────────────────────────────────
|
|
510
646
|
|
|
511
|
-
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
|
+
} = {}) {
|
|
512
654
|
let slot;
|
|
513
655
|
try { slot = deriveSlot(opts); } catch (e) {
|
|
514
|
-
return
|
|
656
|
+
return emitResult(buildReceipt({ command: "up", outcome: "error", error: e.message }), opts, EXIT.INVALID);
|
|
515
657
|
}
|
|
516
|
-
const auth = stackAuthHeader();
|
|
517
|
-
if (!auth) return emit(buildReceipt({ command: "up", outcome: "error", error: "not authenticated — run `botbuddy login`" }), opts, EXIT.AUTH);
|
|
518
658
|
let execution;
|
|
519
659
|
try { execution = resolveStackPath(process.cwd(), opts.stackPath); } catch (e) {
|
|
520
|
-
return
|
|
660
|
+
return emitResult(buildReceipt({ command: "up", outcome: "error", error: (e).message }), opts, EXIT.INVALID);
|
|
521
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
|
+
}
|
|
678
|
+
}
|
|
679
|
+
const auth = authProvider();
|
|
680
|
+
if (!auth) return emitResult(buildReceipt({ command: "up", outcome: "error", error: "not authenticated — run `botbuddy login`" }), opts, EXIT.AUTH);
|
|
522
681
|
|
|
523
|
-
const req = await
|
|
682
|
+
const req = await callTool("request_stack_lease", {
|
|
524
683
|
slot, host_key: opts.host || undefined, repo: opts.repo || undefined,
|
|
525
684
|
ticket_id: opts.ticket || undefined, ticket_url: opts.ticketUrl || undefined,
|
|
526
685
|
pr_id: opts.prId || undefined, pr_url: opts.prUrl || undefined,
|
|
@@ -530,57 +689,78 @@ async function cmdUp(opts) {
|
|
|
530
689
|
});
|
|
531
690
|
if (!req.ok) {
|
|
532
691
|
return req.auth
|
|
533
|
-
?
|
|
534
|
-
:
|
|
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);
|
|
535
694
|
}
|
|
536
695
|
const d = req.data;
|
|
537
696
|
if (!d.success) {
|
|
538
|
-
return
|
|
697
|
+
return emitResult(buildReceipt({ command: "up", outcome: "error", code: d.code, error: d.message || d.code || "request refused", slot }), opts, EXIT.BACKEND);
|
|
539
698
|
}
|
|
540
699
|
let leaseId = d.lease_id;
|
|
541
700
|
let state = d.state;
|
|
542
701
|
|
|
543
702
|
if (state === "queued") {
|
|
544
703
|
if (opts.noWait) {
|
|
545
|
-
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);
|
|
546
705
|
}
|
|
547
706
|
// Park zero-poll until the lease leaves the queue (granted in BOT-1187 order).
|
|
548
707
|
const parked = await waitForLease(leaseId, nonQueued, () => false, { timeoutSec: opts.timeout, auth });
|
|
549
|
-
if (parked.timeout) return
|
|
550
|
-
if (parked.auth) return
|
|
551
|
-
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);
|
|
552
711
|
state = parked.state || "provisioning";
|
|
553
712
|
}
|
|
554
713
|
|
|
555
714
|
// Reach `active`: local-exec provisions itself; otherwise wait for the Helper.
|
|
556
715
|
if (state !== "active") {
|
|
557
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
|
+
}
|
|
558
735
|
let conn;
|
|
559
|
-
try {
|
|
560
|
-
|
|
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);
|
|
561
740
|
}
|
|
562
|
-
const act = await
|
|
741
|
+
const act = await callTool("activate_stack_lease", { lease_id: leaseId, connection: conn });
|
|
563
742
|
if (!act.ok || !act.data?.success) {
|
|
564
|
-
return
|
|
743
|
+
return emitResult(buildReceipt({ command: "up", outcome: "error", lease_id: leaseId, error: act.error || act.data?.code || "activate failed" }), opts, EXIT.BACKEND);
|
|
565
744
|
}
|
|
566
745
|
} else {
|
|
567
746
|
const active = await waitForLease(leaseId, isActive, isReaped, { timeoutSec: opts.timeout, auth });
|
|
568
|
-
if (active.timeout) return
|
|
569
|
-
if (active.failed) return
|
|
570
|
-
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);
|
|
571
750
|
}
|
|
572
751
|
}
|
|
573
752
|
|
|
574
753
|
// Authoritative final read (connection block, current state).
|
|
575
|
-
const got = await
|
|
754
|
+
const got = await callTool("get_stack_lease", { lease_id: leaseId });
|
|
576
755
|
const g = got.ok && got.data?.success ? got.data : null;
|
|
577
756
|
if (!g || g.state !== "active") {
|
|
578
|
-
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);
|
|
579
758
|
}
|
|
580
|
-
return
|
|
759
|
+
return emitResult(buildReceipt({
|
|
581
760
|
command: "up", outcome: "active", lease_id: leaseId, state: "active",
|
|
582
761
|
host_key: g.host_key, slot: g.slot, connection: g.connection,
|
|
583
762
|
idle_ttl_secs: g.idle_ttl_secs, resource_name: g.resource_name,
|
|
763
|
+
...(localPreflight ? { preflight: localPreflight } : {}),
|
|
584
764
|
}), opts, EXIT.OK);
|
|
585
765
|
}
|
|
586
766
|
|
|
@@ -605,10 +785,50 @@ async function cmdTouch(leaseId, opts) {
|
|
|
605
785
|
return emit(buildReceipt({ command: "touch", outcome: "touched", lease_id: leaseId, last_used_at: r.data.last_used_at }), opts, EXIT.OK);
|
|
606
786
|
}
|
|
607
787
|
|
|
608
|
-
async function cmdDone(leaseId, opts
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
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);
|
|
612
832
|
let state = r.data.state;
|
|
613
833
|
if (opts.localExec && state === "reaping") {
|
|
614
834
|
// Only finalize once the stack is PROVABLY down. A failed `supabase stop` (Docker
|
|
@@ -616,17 +836,18 @@ async function cmdDone(leaseId, opts) {
|
|
|
616
836
|
// the next queued lease, which would collide with containers still running on this
|
|
617
837
|
// slot (Codex P1). Leave the lease in `reaping` (slot stays fenced) for a retry /
|
|
618
838
|
// the reaper. Fail with a non-zero exit so the caller knows teardown is incomplete.
|
|
619
|
-
if (!
|
|
620
|
-
return
|
|
839
|
+
if (!localTeardownFn(opts, dockerTarget)) {
|
|
840
|
+
return emitResult(buildReceipt({
|
|
621
841
|
command: "done", outcome: "error", lease_id: leaseId, state,
|
|
622
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.",
|
|
623
843
|
}), opts, EXIT.LEASE_FAILED);
|
|
624
844
|
}
|
|
625
|
-
const fin = await
|
|
845
|
+
const fin = await callTool("finalize_stack_lease", { lease_id: leaseId });
|
|
626
846
|
if (fin.ok && fin.data?.success) state = fin.data.state;
|
|
627
847
|
else process.stderr.write(`${yellow("⚠")} stack: finalize failed (${fin.error || fin.data?.code}); the reaper will reconcile.\n`);
|
|
628
848
|
}
|
|
629
|
-
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);
|
|
630
851
|
}
|
|
631
852
|
|
|
632
853
|
const SIGNAL_EXIT = Object.freeze({ SIGINT: 130, SIGTERM: 143, SIGHUP: 129 });
|