@botbuddy/cli 1.4.2 → 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.
@@ -13,7 +13,7 @@ test("BOT-1353: the published CLI identifies this profile-bootstrap release", as
13
13
  cwd: new URL("..", import.meta.url),
14
14
  });
15
15
 
16
- assert.equal(stdout.trim(), "botbuddy v1.4.2");
16
+ assert.equal(stdout.trim(), "botbuddy v1.5.0");
17
17
  });
18
18
 
19
19
  test("BOT-1382: profile --help succeeds and documents the subcommands", async () => {
@@ -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) => { if (v === undefined) { errors.push(`${name} needs a value`); return false; } return true; };
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 = { ...process.env, VITE_SUPABASE_URL: url };
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 = spawnSync("supabase", ["stop", "--workdir", process.cwd()], { encoding: "utf8" });
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 emit(buildReceipt({ command: "up", outcome: "error", error: e.message }), opts, EXIT.INVALID);
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 emit(buildReceipt({ command: "up", outcome: "error", error: (e).message }), opts, EXIT.INVALID);
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 callToolJson("request_stack_lease", {
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
- ? emit(buildReceipt({ command: "up", outcome: "error", error: req.error || "unauthorized" }), opts, EXIT.AUTH)
534
- : emit(buildReceipt({ command: "up", outcome: "error", error: req.error || "request failed" }), opts, EXIT.BACKEND);
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 emit(buildReceipt({ command: "up", outcome: "error", code: d.code, error: d.message || d.code || "request refused", slot }), opts, EXIT.BACKEND);
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 emit(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);
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 emit(buildReceipt({ command: "up", outcome: "timeout", lease_id: leaseId, state: "queued", slot, error: `parked ${opts.timeout}s without capacity` }), opts, EXIT.TIMEOUT);
550
- if (parked.auth) return emit(buildReceipt({ command: "up", outcome: "error", lease_id: leaseId, error: "unauthorized on wait stream" }), opts, EXIT.AUTH);
551
- if (parked.error) return emit(buildReceipt({ command: "up", outcome: "error", lease_id: leaseId, error: parked.error }), opts, EXIT.BACKEND);
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 { conn = localProvision(); } catch (e) {
560
- return emit(buildReceipt({ command: "up", outcome: "error", lease_id: leaseId, error: e.message }), opts, EXIT.LEASE_FAILED);
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 callToolJson("activate_stack_lease", { lease_id: leaseId, connection: conn });
741
+ const act = await callTool("activate_stack_lease", { lease_id: leaseId, connection: conn });
563
742
  if (!act.ok || !act.data?.success) {
564
- return emit(buildReceipt({ command: "up", outcome: "error", lease_id: leaseId, error: act.error || act.data?.code || "activate failed" }), opts, EXIT.BACKEND);
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 emit(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);
569
- if (active.failed) return emit(buildReceipt({ command: "up", outcome: "error", lease_id: leaseId, state: active.state, error: "lease was reaped before it became active" }), opts, EXIT.LEASE_FAILED);
570
- if (active.error) return emit(buildReceipt({ command: "up", outcome: "error", lease_id: leaseId, error: active.error }), opts, EXIT.BACKEND);
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 callToolJson("get_stack_lease", { lease_id: leaseId });
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 emit(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);
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 emit(buildReceipt({
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
- const r = await callToolJson("release_stack_lease", { lease_id: leaseId, disposition: opts.disposition });
610
- if (!r.ok) return emit(buildReceipt({ command: "done", outcome: "error", lease_id: leaseId, error: r.error }), opts, r.auth ? EXIT.AUTH : EXIT.BACKEND);
611
- if (!r.data.success) return emit(buildReceipt({ command: "done", outcome: "error", lease_id: leaseId, code: r.data.code, error: r.data.code }), opts, EXIT.BACKEND);
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 (!localTeardown()) {
620
- return emit(buildReceipt({
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 callToolJson("finalize_stack_lease", { lease_id: leaseId });
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 emit(buildReceipt({ command: "done", outcome: "released", lease_id: leaseId, state, disposition: opts.disposition }), opts, EXIT.OK);
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 });