@botbuddy/cli 1.33.4 → 1.33.6

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@botbuddy/cli",
3
- "version": "1.33.4",
3
+ "version": "1.33.6",
4
4
  "description": "BotBuddy — Swarm coordination CLI for multi-agent workflows",
5
5
  "type": "module",
6
6
  "bin": {
@@ -36,7 +36,9 @@ export const LEASED_STACKS_DIR = ".botbuddy/stacks";
36
36
  // therefore gets a contiguous block above all of them, and every block stays a
37
37
  // valid TCP port (61000 + 200×20 − 1 = 64999).
38
38
  export const LEASED_PORT_BASE = 61000;
39
- /** Ports reserved per leased stack (the repo declares 10; the rest is headroom). */
39
+ /** Ports reserved per leased stack. botbuddy-web declares 10; a repo that declares
40
+ * none (Supply Guard) has 7 materialized for it from the Supabase defaults
41
+ * (BOT-1835). The rest is headroom. */
40
42
  export const LEASED_PORT_STRIDE = 20;
41
43
  /** Distinct blocks available. Two worktree+slot pairs CAN hash to one block; as
42
44
  * with the hermetic/e2e ports that is a loud `supabase start` port-bind failure,
@@ -119,6 +121,115 @@ export function portMapInConfig(toml) {
119
121
  return map;
120
122
  }
121
123
 
124
+ /**
125
+ * BOT-1835 — every host port the Supabase CLI binds, with the value it uses when
126
+ * the config names none.
127
+ *
128
+ * An UNDECLARED port is not an absent port. The CLI decodes its own embedded
129
+ * `config.toml` template first and the repo's file on top, so a config that says
130
+ * nothing about `[studio]` still binds 54323 — the same 54323 every other stack
131
+ * on the machine falls back to. BOT-1798 rewrote only the ports a repo DECLARED,
132
+ * which is why botbuddy-web's leases landed on 61xxx while Supply Guard's — whose
133
+ * `supabase/config.toml` is one `project_id` line — kept the whole 543xx default
134
+ * block and collided with the SG shared stack and with each other.
135
+ *
136
+ * `bindsWhenUnset: false` means the CLI publishes that port only when the config
137
+ * names one (inbucket's smtp/pop3 ports), so such a service is remapped when it
138
+ * IS declared and never newly exposed when it is not. Its documented default
139
+ * still belongs in the avoid-set: a config that names 54325 collides with every
140
+ * other config that names 54325.
141
+ *
142
+ * `sections` lists the spellings of one service, canonical first: 2.116 renamed
143
+ * `[inbucket]` to `[local_smtp]` and still accepts the old name. The canonical
144
+ * entry is the one materialization CREATES when a config names none of them —
145
+ * `[inbucket]` deliberately, because every CLI in the field understands it
146
+ * (a version that only knows `[local_smtp]` would ignore an unknown section and
147
+ * silently bind the default, which is the bug).
148
+ *
149
+ * `[realtime]` is NOT here: the CLI publishes no host port for it (its section
150
+ * has `enabled`/`ip_version` only). A `[realtime] port` a repo declares anyway is
151
+ * still remapped by the generic declared-port pass below.
152
+ */
153
+ export const SUPABASE_SERVICE_PORTS = Object.freeze([
154
+ { service: "api", label: "api gateway", sections: ["api"], key: "port", defaultPort: 54321, defaultEnabled: true },
155
+ { service: "db", label: "database", sections: ["db"], key: "port", defaultPort: 54322, defaultEnabled: true },
156
+ { service: "db-shadow", label: "shadow database", sections: ["db"], key: "shadow_port", defaultPort: 54320, defaultEnabled: true },
157
+ { service: "pooler", label: "connection pooler", sections: ["db.pooler"], key: "port", defaultPort: 54329, defaultEnabled: false },
158
+ { service: "studio", label: "studio", sections: ["studio"], key: "port", defaultPort: 54323, defaultEnabled: true },
159
+ { service: "mail", label: "mail testing UI", sections: ["inbucket", "local_smtp", "mailpit"], key: "port", defaultPort: 54324, defaultEnabled: true },
160
+ { service: "mail-smtp", label: "mail SMTP", sections: ["inbucket", "local_smtp", "mailpit"], key: "smtp_port", defaultPort: 54325, defaultEnabled: true, bindsWhenUnset: false },
161
+ { service: "mail-pop3", label: "mail POP3", sections: ["inbucket", "local_smtp", "mailpit"], key: "pop3_port", defaultPort: 54326, defaultEnabled: true, bindsWhenUnset: false },
162
+ { service: "analytics", label: "analytics", sections: ["analytics"], key: "port", defaultPort: 54327, defaultEnabled: true },
163
+ { service: "edge-inspector", label: "edge runtime inspector", sections: ["edge_runtime"], key: "inspector_port", defaultPort: 8083, defaultEnabled: true },
164
+ ].map(Object.freeze));
165
+
166
+ /** Every port value the Supabase CLI would pick on its own — the set a leased
167
+ * stack's ports must avoid entirely, whichever repo it was materialized from. */
168
+ export const SUPABASE_DEFAULT_PORTS = Object.freeze(
169
+ new Set(SUPABASE_SERVICE_PORTS.map((s) => s.defaultPort).filter((p) => p > 0)),
170
+ );
171
+
172
+ /** `[section] enabled = true|false` as a map, for the sections that declare it.
173
+ * Comments are not configuration; a value the CLI would not accept is ignored so
174
+ * the caller falls back to the documented default. */
175
+ function enabledFlagsInConfig(toml) {
176
+ const flags = new Map();
177
+ let section = "";
178
+ for (const raw of String(toml).split(/\r?\n/)) {
179
+ const line = raw.trim();
180
+ if (line.startsWith("#")) continue;
181
+ const sec = /^\[([^\]]+)\]/.exec(line);
182
+ if (sec) { section = sec[1].trim(); continue; }
183
+ const dotted = /^([A-Za-z0-9_.]+)\.enabled\s*=\s*(true|false)\b/.exec(line);
184
+ if (dotted) { if (!flags.has(dotted[1])) flags.set(dotted[1], dotted[2] === "true"); continue; }
185
+ if (!section) continue;
186
+ const m = /^enabled\s*=\s*(true|false)\b/.exec(line);
187
+ if (m && !flags.has(section)) flags.set(section, m[1] === "true");
188
+ }
189
+ return flags;
190
+ }
191
+
192
+ /**
193
+ * What the Supabase CLI would actually do with `configText`, per service: which
194
+ * section spells it, whether the config declares its port, whether the CLI binds
195
+ * it at all, and the port it would end up using (`effectivePort` — declared, or
196
+ * the CLI's own default). This is the view BOTH the materializer (which ports
197
+ * must be made explicit) and the isolation assertion (which ports must differ
198
+ * from the default and from the shared stack's) are written against.
199
+ */
200
+ export function supabaseServicePortState(configText) {
201
+ const ports = portMapInConfig(configText);
202
+ const flags = enabledFlagsInConfig(configText);
203
+ return SUPABASE_SERVICE_PORTS.map((svc) => {
204
+ const declaredSection = svc.sections.find((s) => ports.has(`${s}.${svc.key}`)) ?? null;
205
+ // A service is spelled by whichever of its aliases the config already uses —
206
+ // for its port, for any of its other ports, or for its `enabled` flag — so a
207
+ // `[local_smtp]` repo never gets an `[inbucket]` section appended beside it.
208
+ const usedSection = declaredSection
209
+ ?? svc.sections.find((s) => SUPABASE_SERVICE_PORTS.some((o) => o.service !== svc.service && o.sections.includes(s) && ports.has(`${s}.${o.key}`)))
210
+ ?? svc.sections.find((s) => flags.has(s))
211
+ ?? svc.sections[0];
212
+ const enabledSection = svc.sections.find((s) => flags.has(s));
213
+ const enabled = enabledSection === undefined ? svc.defaultEnabled : flags.get(enabledSection);
214
+ const declaredPort = declaredSection === null ? null : Number(ports.get(`${declaredSection}.${svc.key}`));
215
+ const fallback = svc.bindsWhenUnset === false ? null : svc.defaultPort;
216
+ const effectivePort = !enabled ? null : (declaredPort ?? fallback);
217
+ return {
218
+ service: svc.service,
219
+ label: svc.label,
220
+ key: svc.key,
221
+ defaultPort: svc.defaultPort,
222
+ section: usedSection,
223
+ qualifiedKey: `${usedSection}.${svc.key}`,
224
+ declared: declaredPort !== null,
225
+ declaredPort,
226
+ enabled,
227
+ bound: effectivePort !== null,
228
+ effectivePort,
229
+ };
230
+ });
231
+ }
232
+
122
233
  /** Lowercase-alphanumeric tail of a ticket/slot, e.g. `BOT-1798` → `bot1798`. */
123
234
  function identityTag(ticket, slot) {
124
235
  const source = String(ticket ?? "").trim() || String(slot ?? "").trim();
@@ -153,6 +264,49 @@ export function leasedStackIdentity({ worktreeRoot, slot, ticket, nonce } = {})
153
264
 
154
265
  const PROJECT_ID_RE = /^[a-z][a-z0-9]{0,19}$/;
155
266
 
267
+ const escapeRe = (s) => String(s).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
268
+
269
+ /**
270
+ * BOT-1835 — write `<key> = <port>` for a service the source config left implicit,
271
+ * into the config body `out` (an array of lines, mutated in place).
272
+ *
273
+ * Three placements, in order of how the source already spells that table:
274
+ * 1. a `[section]` header exists → the key joins it, directly under the header;
275
+ * 2. only ROOT-LEVEL dotted keys exist (`api.port = …`) → the key joins them in
276
+ * the same dotted form, because TOML forbids re-opening with `[api]` a table
277
+ * that dotted keys already defined;
278
+ * 3. the table is absent → append it. Appending only ever adds a port, never an
279
+ * `enabled` flag, so materialization can never start a service the source
280
+ * does not run.
281
+ */
282
+ function insertSectionPort(out, section, key, port, defaultPort) {
283
+ const note = defaultPort > 0 ? ` # BOT-1835: undeclared, so the CLI would bind its default ${defaultPort}` : "";
284
+ const isComment = (line) => line.trim().startsWith("#");
285
+ const headerAt = out.findIndex((line) => !isComment(line) && new RegExp(`^\\s*\\[${escapeRe(section)}\\]\\s*(?:#.*)?$`).test(line));
286
+ if (headerAt >= 0) {
287
+ // After the section's LAST key, so a service needing two ports (`[db]`) reads
288
+ // in declaration order and a trailing comment keeps the line it introduces.
289
+ let at = headerAt;
290
+ for (let i = headerAt + 1; i < out.length; i++) {
291
+ if (!isComment(out[i]) && /^\s*\[[^\]]+\]/.test(out[i])) break;
292
+ if (!isComment(out[i]) && /^\s*[A-Za-z0-9_."']+.*=/.test(out[i])) at = i;
293
+ }
294
+ out.splice(at + 1, 0, `${key} = ${port}${note}`);
295
+ return;
296
+ }
297
+ const firstHeader = out.findIndex((line) => !isComment(line) && /^\s*\[[^\]]+\]/.test(line));
298
+ const limit = firstHeader < 0 ? out.length : firstHeader;
299
+ const dotted = new RegExp(`^\\s*${escapeRe(section)}\\.[A-Za-z0-9_]+\\s*=`);
300
+ let lastDotted = -1;
301
+ for (let i = 0; i < limit; i++) if (!isComment(out[i]) && dotted.test(out[i])) lastDotted = i;
302
+ if (lastDotted >= 0) {
303
+ out.splice(lastDotted + 1, 0, `${section}.${key} = ${port}${note}`);
304
+ return;
305
+ }
306
+ while (out.length && out[out.length - 1].trim() === "") out.pop();
307
+ out.push("", `[${section}]`, `${key} = ${port}${note}`, "");
308
+ }
309
+
156
310
  /**
157
311
  * The worktree's own config with exactly THREE things changed: `project_id`,
158
312
  * every declared host port, and the edge runtime's own public origin. Everything
@@ -184,12 +338,12 @@ export function rewriteLeasedStackConfig(configText, { projectId, portBase } = {
184
338
  const bare = new RegExp(`^(\\s*)((?:[A-Za-z0-9]+_)?port)(\\s*=\\s*)${TOML_INT_PORT}(.*)$`);
185
339
  const dotted = new RegExp(`^(\\s*)([A-Za-z0-9_]+\\.(?:[A-Za-z0-9]+_)?port)(\\s*=\\s*)${TOML_INT_PORT}(.*)$`);
186
340
  const ports = new Map();
187
- const assign = (key) => {
341
+ const assign = (key, why = `supabase/config.toml declares more than ${LEASED_PORT_STRIDE} host ports`) => {
188
342
  if (!ports.has(key)) {
189
343
  if (ports.size >= LEASED_PORT_STRIDE) {
190
344
  throw new Error(
191
- `refusing to materialize an isolated leased stack: supabase/config.toml declares more than ${LEASED_PORT_STRIDE} host ports ` +
192
- `(at "${key}") — the leased port block cannot hold them all, and reusing a port would collide inside the stack. ` +
345
+ `refusing to materialize an isolated leased stack: ${why} (at "${key}") the leased port block ` +
346
+ `of ${LEASED_PORT_STRIDE} cannot hold them all, and reusing a port would collide inside the stack. ` +
193
347
  "Widen LEASED_PORT_STRIDE (and the band in docs/docker-capacity.md) before adding more services.",
194
348
  );
195
349
  }
@@ -224,6 +378,19 @@ export function rewriteLeasedStackConfig(configText, { projectId, portBase } = {
224
378
  }
225
379
  out.push(line);
226
380
  }
381
+ // BOT-1835 — then make the UNDECLARED ports explicit. Everything above remaps
382
+ // what the repo wrote down; a repo that writes nothing down (Supply Guard's
383
+ // config is a single `project_id` line) would otherwise inherit Supabase's own
384
+ // 543xx defaults and collide with the shared stack and with every other lease.
385
+ // Only services the CLI would actually bind are given a port — a disabled
386
+ // section, and a port the CLI publishes only when named (inbucket's
387
+ // smtp/pop3), stay exactly as the source left them.
388
+ for (const svc of supabaseServicePortState(configText)) {
389
+ if (svc.declared || !svc.bound) continue;
390
+ const port = assign(svc.qualifiedKey, `supabase/config.toml leaves the ${svc.label} port to Supabase's default (${svc.defaultPort}) and the leased block is already full`);
391
+ insertSectionPort(out, svc.section, svc.key, port, svc.defaultPort);
392
+ }
393
+
227
394
  // Pin the edge origin AFTER the port pass: [edge_runtime.secrets] may precede
228
395
  // [api] in the file, so the leased api port is only known once every line is seen.
229
396
  const apiPort = ports.get("api.port");
@@ -315,6 +482,34 @@ export function assertLeasedStackIsolated(rootConfigText, stackConfigText) {
315
482
  if (shared.length) {
316
483
  throw new Error(`refusing to lease a materialized stack that reuses the shared stack's port(s) ${shared.join(", ")}.`);
317
484
  }
485
+ // BOT-1835 — the checks above compare what the two configs WRITE DOWN, which is
486
+ // vacuous for a repo that writes nothing down: the CLI then binds its own
487
+ // defaults and the "isolated" stack is the 543xx block every other stack falls
488
+ // back to. So compare what the CLI would actually BIND, service by service.
489
+ const rootServices = new Map(supabaseServicePortState(rootConfigText).map((s) => [s.service, s]));
490
+ for (const svc of supabaseServicePortState(stackConfigText)) {
491
+ if (!svc.bound) continue;
492
+ if (!svc.declared) {
493
+ throw new Error(
494
+ `refusing to lease a materialized stack that leaves the ${svc.label} port undeclared: the Supabase CLI binds its own ` +
495
+ `default (${svc.defaultPort}) there, and so does every other stack on this host that declares none.`,
496
+ );
497
+ }
498
+ if (svc.effectivePort === svc.defaultPort) {
499
+ throw new Error(
500
+ `refusing to lease a materialized stack whose ${svc.label} port is Supabase's default (${svc.defaultPort}) — ` +
501
+ "the shared canonical stack and every other default-porting stack bind exactly that port.",
502
+ );
503
+ }
504
+ const rootPort = rootServices.get(svc.service)?.effectivePort ?? null;
505
+ if (rootPort !== null && svc.effectivePort === rootPort) {
506
+ throw new Error(
507
+ `refusing to lease a materialized stack whose ${svc.label} port (${svc.effectivePort}) is the port the worktree's own ` +
508
+ "stack uses — declared there or inherited from Supabase's defaults, it is the same bound socket.",
509
+ );
510
+ }
511
+ }
512
+
318
513
  // The edge runtime must emit THIS stack's public URLs (BOT-1798, Codex #928 R9).
319
514
  // A config that leaves `env(VITE_SUPABASE_URL)` in place — the repo default,
320
515
  // because a local `supabase start` exports the origin into the shell — resolves
package/src/stack.mjs CHANGED
@@ -76,10 +76,12 @@ ${bold("up OPTIONS")}
76
76
  --host <host_key> Pin to a canonical host. Omit to auto-select a beacon-fresh host.
77
77
  --repo <repo> Repository the batch is for (e.g. botbuddy-web).
78
78
  --ticket <BOT-123> Ticket the batch is for (also used to derive the slot).
79
- --stack-path <relative> Stack directory inside the registered worktree (default: .).
80
- REQUIRED with --local-exec: point it at a slot-derived stack whose
81
- supabase/config.toml declares a DISTINCT project_id + remapped ports
82
- (never the worktree root's shared canonical project).
79
+ --stack-path <relative> ISOLATED stack directory inside the registered worktree. Required:
80
+ the control plane refuses the worktree root (stack_path_root_refused,
81
+ BOT-1797) because its supabase/config.toml is the repo's SHARED
82
+ canonical project. Point it at a slot-derived stack whose
83
+ supabase/config.toml declares a DISTINCT project_id + remapped ports,
84
+ or use 'stack run', which materializes one for you.
83
85
  --purpose <text> Free-text purpose recorded on the lease.
84
86
  --idle-ttl <seconds> Idle seconds before the reaper STOPS an unused stack (default 1800).
85
87
  --timeout <seconds> Max seconds to park for capacity before giving up (default ${DEFAULT_TIMEOUT_SEC}).
@@ -669,6 +671,39 @@ export function worktreeRegistrationHint(code, auth) {
669
671
  + `Run \`bb doctor --fix\` here to register its host + machine id, then retry.`;
670
672
  }
671
673
 
674
+ // BOT-1797: the control plane refuses a Helper-backed lease whose stack_path is the
675
+ // worktree root — its supabase/config.toml is the repo's SHARED canonical project, so
676
+ // the Helper would start (and on reap destroy) the shared dev stack. This CLI always
677
+ // materializes an isolated stack (BOT-1798), so seeing this code means a stale binary,
678
+ // an explicit root --stack-path, or a wrapper that hand-rolls the lease call.
679
+ export const STACK_PATH_ROOT_REFUSED_CODE = "stack_path_root_refused";
680
+
681
+ // Only used when an older/leaner server sends the code with no message; the server's
682
+ // own message is always preferred so the two can never drift.
683
+ const STACK_PATH_ROOT_REFUSED_FALLBACK =
684
+ "the control plane refused a stack lease at the worktree root (the repo's shared stack). " +
685
+ "Upgrade with `npm i -g @botbuddy/cli@latest`, or pass --stack-path <isolated dir>.";
686
+
687
+ /** The server's root refusal, verbatim, as one operator-facing line (or null). */
688
+ export function stackPathRootRefusalNotice(code, message) {
689
+ if (code !== STACK_PATH_ROOT_REFUSED_CODE) return null;
690
+ return `${yellow("⚠")} stack: ${message || STACK_PATH_ROOT_REFUSED_FALLBACK}`;
691
+ }
692
+
693
+ /**
694
+ * Exit code for a lease the server REFUSED (`success: false`). A refused root path is a
695
+ * bad argument the caller must fix — INVALID (4) — not a backend fault, which automation
696
+ * reads as "the control plane is unhealthy, retry". Every other refusal keeps BACKEND (5).
697
+ */
698
+ export function leaseRefusalExit(code) {
699
+ return code === STACK_PATH_ROOT_REFUSED_CODE ? EXIT.INVALID : EXIT.BACKEND;
700
+ }
701
+
702
+ /** The refusal text for a receipt: the server's message, else its code. */
703
+ export function leaseRefusalError(data) {
704
+ return data?.message || data?.code || "lease request refused";
705
+ }
706
+
672
707
  // The MCP lease endpoint accepts a session token in x-agent-api-key, whereas
673
708
  // the event-stream relay requires that same token in its bearer form too. Keep
674
709
  // the lease RPC shape unchanged and derive the relay-compatible form only at
@@ -1340,9 +1375,9 @@ export async function cmdUp(opts, {
1340
1375
  }
1341
1376
  const d = req.data;
1342
1377
  if (!d.success) {
1343
- const hint = worktreeRegistrationHint(d.code, auth);
1378
+ const hint = worktreeRegistrationHint(d.code, auth) ?? stackPathRootRefusalNotice(d.code, d.message);
1344
1379
  if (hint) process.stderr.write(`${hint}\n`);
1345
- return emitResult(buildReceipt({ command: "up", outcome: "error", code: d.code, error: d.message || d.code || "request refused", slot }), opts, EXIT.BACKEND);
1380
+ return emitResult(buildReceipt({ command: "up", outcome: "error", code: d.code, error: leaseRefusalError(d), slot }), opts, leaseRefusalExit(d.code));
1346
1381
  }
1347
1382
  let leaseId = d.lease_id;
1348
1383
  let state = d.state;
@@ -2007,9 +2042,10 @@ export async function runStackLifecycle(opts, childArgv, adapters = {}) {
2007
2042
  return { exitCode: request?.auth ? EXIT.AUTH : EXIT.BACKEND, outcome: "error", error: request?.error || "lease request failed" };
2008
2043
  }
2009
2044
  if (!request.data?.success) {
2010
- const hint = worktreeRegistrationHint(request.data?.code, rpc.auth);
2045
+ const hint = worktreeRegistrationHint(request.data?.code, rpc.auth)
2046
+ ?? stackPathRootRefusalNotice(request.data?.code, request.data?.message);
2011
2047
  if (hint) process.stderr.write(`${hint}\n`);
2012
- return { exitCode: EXIT.BACKEND, outcome: "error", error: request.data?.message || request.data?.code || "lease request refused" };
2048
+ return { exitCode: leaseRefusalExit(request.data?.code), outcome: "error", code: request.data?.code, error: leaseRefusalError(request.data) };
2013
2049
  }
2014
2050
  leaseId = request.data.lease_id;
2015
2051
  if (request.data.reused) {
@@ -2154,7 +2190,9 @@ export async function runStackLifecycle(opts, childArgv, adapters = {}) {
2154
2190
  async function cmdRun(opts, childArgv) {
2155
2191
  const result = await runStackLifecycle(opts, childArgv);
2156
2192
  return emit(buildReceipt({
2157
- command: "run", outcome: result.outcome, lease_id: result.leaseId ?? null,
2193
+ // BOT-1797: carry the server's typed refusal code (e.g. stack_path_root_refused)
2194
+ // so automation can branch on it instead of matching the message text.
2195
+ command: "run", outcome: result.outcome, code: result.code ?? null, lease_id: result.leaseId ?? null,
2158
2196
  child_exit_code: result.childExitCode ?? null, cleanup: result.cleanup?.ok ?? null,
2159
2197
  fenced: result.fenced ?? false, error: result.error ?? result.cleanup?.error ?? null,
2160
2198
  }), opts, result.exitCode);