@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.
@@ -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.1");
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
+ }