@botbuddy/cli 1.4.1 → 1.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/api.mjs +2 -1
- package/src/commands.mjs +7 -0
- package/src/docker-hygiene.mjs +1062 -0
- package/src/docker-hygiene.test.mjs +790 -0
- package/src/profile-bootstrap.test.mjs +1 -1
- package/src/stack-file-lock.mjs +180 -0
- package/src/stack.mjs +676 -63
- package/src/stack.test.mjs +563 -3
package/src/stack.test.mjs
CHANGED
|
@@ -5,15 +5,19 @@
|
|
|
5
5
|
import test from "node:test";
|
|
6
6
|
import assert from "node:assert/strict";
|
|
7
7
|
import { spawnSync } from "node:child_process";
|
|
8
|
+
import { EventEmitter } from "node:events";
|
|
8
9
|
import { fileURLToPath } from "node:url";
|
|
9
10
|
import { dirname, join } from "node:path";
|
|
10
|
-
import { mkdtempSync, writeFileSync, mkdirSync, realpathSync } from "node:fs";
|
|
11
|
+
import { mkdtempSync, writeFileSync, mkdirSync, realpathSync, readFileSync } from "node:fs";
|
|
11
12
|
import { tmpdir } from "node:os";
|
|
12
13
|
|
|
13
14
|
import {
|
|
14
15
|
EXIT, STACK_HELP, STACK_SCHEMA_VERSION, DEFAULT_RECEIPT_MAX_BYTES,
|
|
15
16
|
deriveSlot, parseStackArgs, buildReceipt, truncateReceipt,
|
|
16
17
|
parseSupabaseStatus, eventStreamBase, resolveLocalSupabaseUrl, resolveStackPath,
|
|
18
|
+
runLocalExecPreflight, dockerEnvForSelector, dockerTargetFromPreflight, connectionWithDockerTarget,
|
|
19
|
+
proveLegacyLocalExecTarget, localTeardown, cmdUp, cmdDone,
|
|
20
|
+
runStackLifecycle, materializeLeasedTestConfig, waitForLease,
|
|
17
21
|
} from "./stack.mjs";
|
|
18
22
|
|
|
19
23
|
const BIN = join(dirname(fileURLToPath(import.meta.url)), "..", "bin", "botbuddy.mjs");
|
|
@@ -55,7 +59,7 @@ test("deriveSlot: throws when undeterminable", () => {
|
|
|
55
59
|
test("parseStackArgs: up options", () => {
|
|
56
60
|
const { command, opts, errors } = parseStackArgs([
|
|
57
61
|
"up", "--slot", "56322", "--host", "mac", "--repo", "botbuddy-web",
|
|
58
|
-
"--ticket", "BOT-1220", "--stack-path", "infra/local", "--idle-ttl", "600", "--timeout", "120", "--no-wait", "--local-exec",
|
|
62
|
+
"--ticket", "BOT-1220", "--stack-path", "infra/local", "--idle-ttl", "600", "--timeout", "120", "--no-wait", "--local-exec", "--docker-context", "orbstack",
|
|
59
63
|
]);
|
|
60
64
|
assert.equal(errors.length, 0);
|
|
61
65
|
assert.equal(command, "up");
|
|
@@ -66,6 +70,326 @@ test("parseStackArgs: up options", () => {
|
|
|
66
70
|
assert.equal(opts.timeout, 120);
|
|
67
71
|
assert.equal(opts.noWait, true);
|
|
68
72
|
assert.equal(opts.localExec, true);
|
|
73
|
+
assert.equal(opts.dockerContext, "orbstack");
|
|
74
|
+
});
|
|
75
|
+
test("parseStackArgs: local-exec up requires exactly one explicit Docker selector", () => {
|
|
76
|
+
assert.match(parseStackArgs(["up", "--slot", "x", "--local-exec"]).errors.join(" "), /docker-context.*docker-endpoint/i);
|
|
77
|
+
assert.match(parseStackArgs([
|
|
78
|
+
"up", "--slot", "x", "--local-exec", "--docker-context", "orbstack", "--docker-endpoint", "unix:///tmp/docker.sock",
|
|
79
|
+
]).errors.join(" "), /exactly one/i);
|
|
80
|
+
assert.deepEqual(parseStackArgs([
|
|
81
|
+
"up", "--slot", "x", "--local-exec", "--docker-endpoint", "unix:///Users/test/.orbstack/run/docker.sock",
|
|
82
|
+
]).errors, []);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("parseStackArgs: local-exec done requires exactly one explicit Docker selector", () => {
|
|
86
|
+
assert.match(parseStackArgs(["done", "lease-1", "--local-exec"]).errors.join(" "), /docker-context.*docker-endpoint/i);
|
|
87
|
+
assert.equal(parseStackArgs([
|
|
88
|
+
"done", "lease-1", "--local-exec", "--docker-context", "orbstack",
|
|
89
|
+
]).errors.length, 0);
|
|
90
|
+
assert.ok(parseStackArgs([
|
|
91
|
+
"done", "lease-1", "--local-exec", "--docker-context", "orbstack", "--docker-endpoint", "unix:///tmp/docker.sock",
|
|
92
|
+
]).errors.length > 0);
|
|
93
|
+
});
|
|
94
|
+
test("parseStackArgs: a missing Docker selector value preserves the following flag", () => {
|
|
95
|
+
const parsed = parseStackArgs(["up", "--slot", "x", "--local-exec", "--docker-context", "--json"]);
|
|
96
|
+
assert.equal(parsed.opts.json, true);
|
|
97
|
+
assert.match(parsed.errors.join(" "), /--docker-context needs a value/i);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test("runLocalExecPreflight delegates the explicit selector and preserves refusal", () => {
|
|
101
|
+
const calls = [];
|
|
102
|
+
const expected = { exitCode: 2, receipt: { outcome: "refused", pressure: { status: "fail" } }, json: true };
|
|
103
|
+
const actual = runLocalExecPreflight({ dockerContext: "orbstack", dockerEndpoint: null }, (args) => {
|
|
104
|
+
calls.push(args);
|
|
105
|
+
return expected;
|
|
106
|
+
});
|
|
107
|
+
assert.deepEqual(calls, [["preflight", "--context", "orbstack", "--json"]]);
|
|
108
|
+
assert.equal(actual, expected);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test("dockerEnvForSelector replaces ambient Docker routing with the preflight target", () => {
|
|
112
|
+
assert.deepEqual(dockerEnvForSelector(
|
|
113
|
+
{ dockerContext: "orbstack", dockerEndpoint: null },
|
|
114
|
+
{ PATH: "/bin", DOCKER_CONTEXT: "desktop-linux", DOCKER_HOST: "tcp://wrong" },
|
|
115
|
+
), { PATH: "/bin", DOCKER_CONTEXT: "orbstack" });
|
|
116
|
+
assert.deepEqual(dockerEnvForSelector(
|
|
117
|
+
{ dockerContext: null, dockerEndpoint: "unix:///Users/test/.orbstack/run/docker.sock" },
|
|
118
|
+
{ PATH: "/bin", DOCKER_CONTEXT: "desktop-linux" },
|
|
119
|
+
), { PATH: "/bin", DOCKER_HOST: "unix:///Users/test/.orbstack/run/docker.sock" });
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test("localTeardown passes the explicit Docker selector to supabase stop", () => {
|
|
123
|
+
let invocation;
|
|
124
|
+
const ok = localTeardown(
|
|
125
|
+
{ dockerContext: "orbstack", dockerEndpoint: null },
|
|
126
|
+
{ validation: "orbstack", resolved_endpoint: "unix:///Users/test/.orbstack/run/docker.sock", server_id: "daemon-1" },
|
|
127
|
+
(command, args, options) => {
|
|
128
|
+
invocation = { command, args, env: options.env };
|
|
129
|
+
return { status: 0, stdout: "", stderr: "" };
|
|
130
|
+
},
|
|
131
|
+
{ PATH: "/bin", DOCKER_CONTEXT: "desktop-linux", DOCKER_HOST: "tcp://wrong" },
|
|
132
|
+
);
|
|
133
|
+
assert.equal(ok, true);
|
|
134
|
+
assert.deepEqual(invocation, {
|
|
135
|
+
command: "supabase",
|
|
136
|
+
args: ["stop", "--workdir", process.cwd()],
|
|
137
|
+
env: { PATH: "/bin", DOCKER_HOST: "unix:///Users/test/.orbstack/run/docker.sock" },
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
const targetReceipt = (serverId, endpoint = "unix:///Users/test/.orbstack/run/docker.sock") => ({
|
|
142
|
+
context: {
|
|
143
|
+
validation: "orbstack",
|
|
144
|
+
resolved_endpoint: endpoint,
|
|
145
|
+
server: { id: serverId, name: "orbstack", operating_system: "OrbStack" },
|
|
146
|
+
},
|
|
147
|
+
pressure: { status: "ok" },
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test("dockerTargetFromPreflight requires a resolved OrbStack server identity", () => {
|
|
151
|
+
assert.deepEqual(dockerTargetFromPreflight(targetReceipt("daemon-1")), {
|
|
152
|
+
validation: "orbstack",
|
|
153
|
+
resolved_endpoint: "unix:///Users/test/.orbstack/run/docker.sock",
|
|
154
|
+
server_id: "daemon-1",
|
|
155
|
+
});
|
|
156
|
+
assert.equal(dockerTargetFromPreflight(targetReceipt(null)), null);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
test("connectionWithDockerTarget persists the validated target in the lease connection", () => {
|
|
160
|
+
const target = dockerTargetFromPreflight(targetReceipt("daemon-1"));
|
|
161
|
+
assert.deepEqual(connectionWithDockerTarget({ api_url: "http://127.0.0.1:56321" }, target), {
|
|
162
|
+
api_url: "http://127.0.0.1:56321",
|
|
163
|
+
botbuddy_docker_target: target,
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
test("cmdDone refuses before release when teardown targets a different Docker daemon", async () => {
|
|
168
|
+
const tools = [];
|
|
169
|
+
let emitted;
|
|
170
|
+
const code = await cmdDone("lease-1", {
|
|
171
|
+
localExec: true, dockerContext: "orbstack", dockerEndpoint: null,
|
|
172
|
+
disposition: "destroy", receiptMaxBytes: DEFAULT_RECEIPT_MAX_BYTES,
|
|
173
|
+
}, {
|
|
174
|
+
callTool: async (name) => {
|
|
175
|
+
tools.push(name);
|
|
176
|
+
return { ok: true, data: { success: true, state: "active", connection: {
|
|
177
|
+
botbuddy_docker_target: dockerTargetFromPreflight(targetReceipt("daemon-1")),
|
|
178
|
+
} } };
|
|
179
|
+
},
|
|
180
|
+
runPreflight: () => ({ exitCode: 0, receipt: targetReceipt("daemon-2") }),
|
|
181
|
+
localTeardownFn: () => { throw new Error("must not stop"); },
|
|
182
|
+
emitResult: (receipt, _opts, exitCode) => { emitted = receipt; return exitCode; },
|
|
183
|
+
});
|
|
184
|
+
assert.equal(code, EXIT.LEASE_FAILED);
|
|
185
|
+
assert.deepEqual(tools, ["get_stack_lease"]);
|
|
186
|
+
assert.equal(emitted.outcome, "refused");
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
test("proveLegacyLocalExecTarget accepts only an exact owning-worktree and live connection match", () => {
|
|
190
|
+
const target = dockerTargetFromPreflight(targetReceipt("daemon-1"));
|
|
191
|
+
const worktreeRoot = realpathSync(process.cwd());
|
|
192
|
+
const lease = {
|
|
193
|
+
worktree_root: worktreeRoot,
|
|
194
|
+
stack_path: ".",
|
|
195
|
+
connection: {
|
|
196
|
+
api_url: "http://127.0.0.1:56321",
|
|
197
|
+
db_url: "postgresql://postgres:postgres@127.0.0.1:56322/postgres",
|
|
198
|
+
},
|
|
199
|
+
};
|
|
200
|
+
const spawned = [];
|
|
201
|
+
const proof = proveLegacyLocalExecTarget(lease, target, { stackPath: "." }, (command, args, options) => {
|
|
202
|
+
spawned.push({ command, args, env: options.env });
|
|
203
|
+
return { status: 0, stdout: JSON.stringify({
|
|
204
|
+
API_URL: lease.connection.api_url,
|
|
205
|
+
DB_URL: lease.connection.db_url,
|
|
206
|
+
}), stderr: "" };
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
assert.equal(proof.ok, true);
|
|
210
|
+
assert.equal(proof.evidence.method, "legacy_worktree_connection_match");
|
|
211
|
+
assert.equal(spawned[0].env.DOCKER_HOST, target.resolved_endpoint);
|
|
212
|
+
assert.deepEqual(spawned[0].args, ["status", "-o", "json", "--workdir", worktreeRoot]);
|
|
213
|
+
|
|
214
|
+
const mismatch = proveLegacyLocalExecTarget(lease, target, { stackPath: "." }, () => ({
|
|
215
|
+
status: 0,
|
|
216
|
+
stdout: JSON.stringify({ API_URL: "http://127.0.0.1:64321", DB_URL: lease.connection.db_url }),
|
|
217
|
+
stderr: "",
|
|
218
|
+
}));
|
|
219
|
+
assert.equal(mismatch.ok, false);
|
|
220
|
+
assert.match(mismatch.error, /connection.*does not match/i);
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
test("cmdDone safely tears down a pre-1.5.0 lease after the legacy compatibility proof", async () => {
|
|
224
|
+
const tools = [];
|
|
225
|
+
let emitted;
|
|
226
|
+
const target = dockerTargetFromPreflight(targetReceipt("daemon-1"));
|
|
227
|
+
const code = await cmdDone("lease-legacy", {
|
|
228
|
+
localExec: true, dockerContext: "orbstack", dockerEndpoint: null, stackPath: ".",
|
|
229
|
+
disposition: "destroy", receiptMaxBytes: DEFAULT_RECEIPT_MAX_BYTES,
|
|
230
|
+
}, {
|
|
231
|
+
callTool: async (name) => {
|
|
232
|
+
tools.push(name);
|
|
233
|
+
if (name === "get_stack_lease") return { ok: true, data: {
|
|
234
|
+
success: true, state: "active", worktree_root: process.cwd(), stack_path: ".",
|
|
235
|
+
connection: { api_url: "http://127.0.0.1:56321", db_url: "postgresql://127.0.0.1:56322/postgres" },
|
|
236
|
+
} };
|
|
237
|
+
if (name === "release_stack_lease") return { ok: true, data: { success: true, state: "reaping" } };
|
|
238
|
+
if (name === "finalize_stack_lease") return { ok: true, data: { success: true, state: "reaped" } };
|
|
239
|
+
throw new Error(`unexpected tool ${name}`);
|
|
240
|
+
},
|
|
241
|
+
runPreflight: () => ({ exitCode: 0, receipt: targetReceipt("daemon-1") }),
|
|
242
|
+
proveLegacyTarget: () => ({ ok: true, evidence: { method: "legacy_worktree_connection_match" } }),
|
|
243
|
+
localTeardownFn: () => true,
|
|
244
|
+
emitResult: (receipt, _opts, exitCode) => { emitted = receipt; return exitCode; },
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
assert.equal(code, EXIT.OK);
|
|
248
|
+
assert.deepEqual(tools, ["get_stack_lease", "release_stack_lease", "finalize_stack_lease"]);
|
|
249
|
+
assert.deepEqual(emitted.legacy_target_proof, { method: "legacy_worktree_connection_match" });
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
test("cmdDone refuses a legacy lease before release when compatibility proof is absent", async () => {
|
|
253
|
+
const tools = [];
|
|
254
|
+
let emitted;
|
|
255
|
+
const code = await cmdDone("lease-legacy", {
|
|
256
|
+
localExec: true, dockerContext: "orbstack", dockerEndpoint: null, stackPath: ".",
|
|
257
|
+
disposition: "destroy", receiptMaxBytes: DEFAULT_RECEIPT_MAX_BYTES,
|
|
258
|
+
}, {
|
|
259
|
+
callTool: async (name) => {
|
|
260
|
+
tools.push(name);
|
|
261
|
+
return { ok: true, data: { success: true, state: "active", connection: { api_url: "http://127.0.0.1:56321" } } };
|
|
262
|
+
},
|
|
263
|
+
runPreflight: () => ({ exitCode: 0, receipt: targetReceipt("daemon-1") }),
|
|
264
|
+
proveLegacyTarget: () => ({ ok: false, error: "legacy worktree metadata is missing" }),
|
|
265
|
+
localTeardownFn: () => { throw new Error("must not stop"); },
|
|
266
|
+
emitResult: (receipt, _opts, exitCode) => { emitted = receipt; return exitCode; },
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
assert.equal(code, EXIT.LEASE_FAILED);
|
|
270
|
+
assert.deepEqual(tools, ["get_stack_lease"]);
|
|
271
|
+
assert.match(emitted.error, /legacy worktree metadata is missing/i);
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
test("cmdDone stops and finalizes only after the fresh Docker identity matches the lease", async () => {
|
|
275
|
+
const tools = [];
|
|
276
|
+
let teardownTarget;
|
|
277
|
+
const target = dockerTargetFromPreflight(targetReceipt("daemon-1"));
|
|
278
|
+
const code = await cmdDone("lease-1", {
|
|
279
|
+
localExec: true, dockerContext: "orbstack", dockerEndpoint: null,
|
|
280
|
+
disposition: "destroy", receiptMaxBytes: DEFAULT_RECEIPT_MAX_BYTES,
|
|
281
|
+
}, {
|
|
282
|
+
callTool: async (name) => {
|
|
283
|
+
tools.push(name);
|
|
284
|
+
if (name === "get_stack_lease") return { ok: true, data: { success: true, state: "active", connection: { botbuddy_docker_target: target } } };
|
|
285
|
+
if (name === "release_stack_lease") return { ok: true, data: { success: true, state: "reaping" } };
|
|
286
|
+
if (name === "finalize_stack_lease") return { ok: true, data: { success: true, state: "reaped" } };
|
|
287
|
+
throw new Error(`unexpected tool ${name}`);
|
|
288
|
+
},
|
|
289
|
+
runPreflight: () => ({ exitCode: 0, receipt: targetReceipt("daemon-1") }),
|
|
290
|
+
localTeardownFn: (_opts, actual) => { teardownTarget = actual; return true; },
|
|
291
|
+
emitResult: (_receipt, _opts, exitCode) => exitCode,
|
|
292
|
+
});
|
|
293
|
+
assert.equal(code, EXIT.OK);
|
|
294
|
+
assert.deepEqual(tools, ["get_stack_lease", "release_stack_lease", "finalize_stack_lease"]);
|
|
295
|
+
assert.deepEqual(teardownTarget, target);
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
test("cmdUp refuses pressure before lease request or local provisioning", async () => {
|
|
299
|
+
const events = [];
|
|
300
|
+
let emitted;
|
|
301
|
+
const code = await cmdUp({
|
|
302
|
+
slot: "botbuddy-web-bot-1405", repo: "botbuddy-web", ticket: "BOT-1405",
|
|
303
|
+
stackPath: ".", localExec: true, dockerContext: "orbstack", dockerEndpoint: null,
|
|
304
|
+
}, {
|
|
305
|
+
runPreflight: () => ({
|
|
306
|
+
exitCode: 2,
|
|
307
|
+
receipt: {
|
|
308
|
+
outcome: "refused", context: { selector: "orbstack" },
|
|
309
|
+
pressure: { status: "fail", projected_pressure_units: 224 },
|
|
310
|
+
warnings: [], errors: ["interface pressure refusal"], recommendation: "run hygiene",
|
|
311
|
+
},
|
|
312
|
+
json: true,
|
|
313
|
+
}),
|
|
314
|
+
callTool: async (...args) => { events.push(["tool", ...args]); throw new Error("must not request a lease"); },
|
|
315
|
+
localProvisionFn: () => { events.push(["provision"]); throw new Error("must not start Supabase"); },
|
|
316
|
+
emitResult: (receipt, _opts, exitCode) => { emitted = receipt; return exitCode; },
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
assert.equal(code, EXIT.LEASE_FAILED);
|
|
320
|
+
assert.equal(emitted.outcome, "refused");
|
|
321
|
+
assert.equal(emitted.preflight.pressure.status, "fail");
|
|
322
|
+
assert.deepEqual(events, []);
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
test("cmdUp atomically cancels an unclaimed lease when the just-in-time preflight refuses", async () => {
|
|
326
|
+
const tools = [];
|
|
327
|
+
let preflights = 0;
|
|
328
|
+
let emitted;
|
|
329
|
+
const okReceipt = {
|
|
330
|
+
...targetReceipt("daemon-1"), outcome: "ok", pressure: { status: "ok" },
|
|
331
|
+
warnings: [], errors: [], recommendation: "none",
|
|
332
|
+
};
|
|
333
|
+
const refusedReceipt = {
|
|
334
|
+
...targetReceipt("daemon-1"), outcome: "refused", pressure: { status: "fail" },
|
|
335
|
+
warnings: [], errors: ["interface pressure refusal"], recommendation: "run hygiene",
|
|
336
|
+
};
|
|
337
|
+
const code = await cmdUp({
|
|
338
|
+
slot: "botbuddy-web-bot-1405", host: "test-mac", repo: "botbuddy-web", ticket: "BOT-1405",
|
|
339
|
+
stackPath: ".", localExec: true, dockerContext: "orbstack", dockerEndpoint: null,
|
|
340
|
+
timeout: 60, noWait: false, json: false,
|
|
341
|
+
}, {
|
|
342
|
+
authProvider: () => ({ Authorization: "test" }),
|
|
343
|
+
runPreflight: () => ({
|
|
344
|
+
exitCode: preflights++ === 0 ? 0 : 2,
|
|
345
|
+
receipt: preflights === 1 ? okReceipt : refusedReceipt,
|
|
346
|
+
json: true,
|
|
347
|
+
}),
|
|
348
|
+
callTool: async (tool, args) => {
|
|
349
|
+
tools.push([tool, args]);
|
|
350
|
+
if (tool === "request_stack_lease") return { ok: true, data: { success: true, lease_id: "lease-1", state: "provisioning" } };
|
|
351
|
+
if (tool === "cancel_unclaimed_stack_lease") return { ok: true, data: { success: true, state: "reaped", provision_job_cancelled: true } };
|
|
352
|
+
throw new Error(`unexpected tool ${tool}`);
|
|
353
|
+
},
|
|
354
|
+
localProvisionFn: () => { throw new Error("must not start Supabase"); },
|
|
355
|
+
emitResult: (receipt, _opts, exitCode) => { emitted = receipt; return exitCode; },
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
assert.equal(code, EXIT.LEASE_FAILED);
|
|
359
|
+
assert.deepEqual(tools.map(([tool]) => tool), ["request_stack_lease", "cancel_unclaimed_stack_lease"]);
|
|
360
|
+
assert.equal(emitted.outcome, "refused");
|
|
361
|
+
assert.deepEqual(emitted.lease_cancellation, { success: true, state: "reaped", provision_job_cancelled: true });
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
test("cmdUp persists the validated Docker identity used by local provisioning", async () => {
|
|
365
|
+
const target = dockerTargetFromPreflight(targetReceipt("daemon-1"));
|
|
366
|
+
let provisionTarget;
|
|
367
|
+
let activatedConnection;
|
|
368
|
+
const code = await cmdUp({
|
|
369
|
+
slot: "botbuddy-web-bot-1405", host: "test-mac", repo: "botbuddy-web", ticket: "BOT-1405",
|
|
370
|
+
stackPath: ".", localExec: true, dockerContext: "orbstack", dockerEndpoint: null,
|
|
371
|
+
timeout: 60, noWait: false, json: false,
|
|
372
|
+
}, {
|
|
373
|
+
authProvider: () => ({ Authorization: "test" }),
|
|
374
|
+
runPreflight: () => ({ exitCode: 0, receipt: { ...targetReceipt("daemon-1"), outcome: "ok", warnings: [], errors: [] } }),
|
|
375
|
+
localProvisionFn: (_opts, actualTarget) => {
|
|
376
|
+
provisionTarget = actualTarget;
|
|
377
|
+
return { api_url: "http://127.0.0.1:56321" };
|
|
378
|
+
},
|
|
379
|
+
callTool: async (name, args) => {
|
|
380
|
+
if (name === "request_stack_lease") return { ok: true, data: { success: true, lease_id: "lease-1", state: "provisioning" } };
|
|
381
|
+
if (name === "activate_stack_lease") {
|
|
382
|
+
activatedConnection = args.connection;
|
|
383
|
+
return { ok: true, data: { success: true, state: "active" } };
|
|
384
|
+
}
|
|
385
|
+
if (name === "get_stack_lease") return { ok: true, data: { success: true, state: "active", slot: "botbuddy-web-bot-1405", connection: activatedConnection } };
|
|
386
|
+
throw new Error(`unexpected tool ${name}`);
|
|
387
|
+
},
|
|
388
|
+
emitResult: (_receipt, _opts, exitCode) => exitCode,
|
|
389
|
+
});
|
|
390
|
+
assert.equal(code, EXIT.OK);
|
|
391
|
+
assert.deepEqual(provisionTarget, target);
|
|
392
|
+
assert.deepEqual(activatedConnection.botbuddy_docker_target, target);
|
|
69
393
|
});
|
|
70
394
|
test("parseStackArgs: rejects absolute and escaping stack paths", () => {
|
|
71
395
|
assert.ok(parseStackArgs(["up", "--stack-path", "/tmp/stack"]).errors.length > 0);
|
|
@@ -99,6 +423,242 @@ test("parseStackArgs: --stop sets disposition stop (default destroy)", () => {
|
|
|
99
423
|
assert.equal(parseStackArgs(["done", "id"]).opts.disposition, "destroy");
|
|
100
424
|
assert.equal(parseStackArgs(["done", "id", "--stop"]).opts.disposition, "stop");
|
|
101
425
|
});
|
|
426
|
+
test("BOT-1346: run requires an argv boundary and rejects direct Docker fallback", () => {
|
|
427
|
+
assert.ok(parseStackArgs(["run", "--repo", "botbuddy-web", "--ticket", "BOT-1346"]).errors.some((e) => e.includes("executable")));
|
|
428
|
+
assert.ok(parseStackArgs(["run", "--repo", "botbuddy-web", "--ticket", "BOT-1346", "--local-exec", "--", "echo", "ok"]).errors.some((e) => e.includes("forbidden")));
|
|
429
|
+
const parsed = parseStackArgs(["run", "--repo", "botbuddy-web", "--ticket", "BOT-1346", "--", "node", "-e", "process.exit(0)"]);
|
|
430
|
+
assert.equal(parsed.errors.length, 0);
|
|
431
|
+
assert.deepEqual(parsed.childArgv, ["node", "-e", "process.exit(0)"]);
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
test("BOT-1346: a clean lease-stream EOF honours the original deadline", async () => {
|
|
435
|
+
const requests = [];
|
|
436
|
+
const expired = await waitForLease(
|
|
437
|
+
"lease-1346", () => false, () => false,
|
|
438
|
+
{ timeoutSec: 5, deadlineMs: Date.now() - 1, auth: { Authorization: "Bearer test" } },
|
|
439
|
+
async (_url, options = {}) => {
|
|
440
|
+
requests.push(options.method ?? "GET");
|
|
441
|
+
if (options.method === "POST") return new Response(JSON.stringify({ cursor_start: null }), { status: 200 });
|
|
442
|
+
return new Response(new ReadableStream({ start(controller) { controller.close(); } }), { status: 200 });
|
|
443
|
+
},
|
|
444
|
+
);
|
|
445
|
+
assert.deepEqual(expired, { timeout: true });
|
|
446
|
+
assert.deepEqual(requests, ["POST", "GET"]);
|
|
447
|
+
});
|
|
448
|
+
|
|
449
|
+
test("BOT-1346: a stalled lease-wait registration is aborted at its deadline", async () => {
|
|
450
|
+
let registrationSignal = null;
|
|
451
|
+
const result = await waitForLease(
|
|
452
|
+
"lease-1346", () => false, () => false,
|
|
453
|
+
{ timeoutSec: 0.02, auth: { Authorization: "Bearer test" } },
|
|
454
|
+
async (_url, options = {}) => {
|
|
455
|
+
registrationSignal = options.signal;
|
|
456
|
+
return await new Promise((_resolve, reject) => options.signal.addEventListener("abort", () => reject(new Error("aborted")), { once: true }));
|
|
457
|
+
},
|
|
458
|
+
);
|
|
459
|
+
assert.equal(registrationSignal.aborted, true);
|
|
460
|
+
assert.deepEqual(result, { timeout: true });
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
test("BOT-1346: stack run owns active → argv child → signed reap and never exposes connection", async () => {
|
|
464
|
+
const calls = [];
|
|
465
|
+
const signals = new EventEmitter();
|
|
466
|
+
const fakeChild = new EventEmitter(); fakeChild.pid = 4242;
|
|
467
|
+
let written = null; const removed = []; let childCall = null;
|
|
468
|
+
const result = await runStackLifecycle(
|
|
469
|
+
{ repo: "botbuddy-web", ticket: "BOT-1346", stackPath: ".", timeout: 5, reapTimeout: 5, connectionFile: "/tmp/bot-1346-connection.json" },
|
|
470
|
+
["pnpm", "test:all"],
|
|
471
|
+
{
|
|
472
|
+
auth: { Authorization: "Bearer test" }, signals,
|
|
473
|
+
api: {
|
|
474
|
+
request: async () => ({ ok: true, data: { success: true, lease_id: "lease-1346", state: "active" } }),
|
|
475
|
+
get: async () => ({ ok: true, data: { success: true, state: "active", idle_ttl_secs: 30, connection: { db_url: "postgres://secret" } } }),
|
|
476
|
+
touch: async () => ({ ok: true, data: { success: true } }),
|
|
477
|
+
release: async (id) => { calls.push(["release", id]); return { ok: true, data: { success: true, state: "reaping" } }; },
|
|
478
|
+
},
|
|
479
|
+
wait: async (_id, done) => done("reaped") ? { woke: true, state: "reaped" } : { woke: true, state: "active" },
|
|
480
|
+
writeConnection: async (path, connection) => { written = { path, connection }; return path; },
|
|
481
|
+
materializeTestConfig: async () => ({
|
|
482
|
+
envFile: "/tmp/bot-1346-integration.env",
|
|
483
|
+
stackConfig: "/tmp/bot-1346-stack.toml",
|
|
484
|
+
}),
|
|
485
|
+
removeConnection: async (path) => { removed.push(path); },
|
|
486
|
+
startChild: (argv, env, cwd) => { childCall = { argv, env, cwd }; queueMicrotask(() => fakeChild.emit("exit", 7, null)); return fakeChild; },
|
|
487
|
+
clock: { setInterval: () => ({ unref() {} }), clearInterval() {}, setTimeout: () => ({ unref() {} }), clearTimeout() {} },
|
|
488
|
+
},
|
|
489
|
+
);
|
|
490
|
+
assert.equal(result.exitCode, 7, "successful cleanup must not mask the child failure");
|
|
491
|
+
assert.deepEqual(childCall.argv, ["pnpm", "test:all"], "argv is forwarded without shell parsing");
|
|
492
|
+
assert.equal(childCall.env.BOTBUDDY_STACK_LEASE_ID, "lease-1346");
|
|
493
|
+
assert.equal(childCall.env.BOTBUDDY_STACK_CONNECTION_FILE, written.path);
|
|
494
|
+
assert.equal(childCall.env.BB_INTEGRATION_ENV_FILE, "/tmp/bot-1346-integration.env");
|
|
495
|
+
assert.equal(childCall.env.BB_STACK_CONFIG, "/tmp/bot-1346-stack.toml");
|
|
496
|
+
assert.equal(written.connection.db_url, "postgres://secret");
|
|
497
|
+
assert.deepEqual(removed.sort(), [written.path, "/tmp/bot-1346-integration.env", "/tmp/bot-1346-stack.toml"].sort());
|
|
498
|
+
assert.deepEqual(calls, [["release", "lease-1346"]]);
|
|
499
|
+
assert.doesNotMatch(JSON.stringify(result), /postgres:\/\/secret/);
|
|
500
|
+
});
|
|
501
|
+
|
|
502
|
+
test("BOT-1346: materialized integration environment uses the leased database port", async () => {
|
|
503
|
+
const root = mkdtempSync(join(tmpdir(), "bb-stack-integration-"));
|
|
504
|
+
const integrationDir = join(root, "supabase", "functions", "_test", "integration");
|
|
505
|
+
mkdirSync(integrationDir, { recursive: true });
|
|
506
|
+
writeFileSync(join(integrationDir, ".env.integration"), "SUPABASE_URL=http://127.0.0.1:56321\nINTEGRATION_DB_PORT=56322\n");
|
|
507
|
+
const config = await materializeLeasedTestConfig(root, "lease-port", {
|
|
508
|
+
api_url: "http://127.0.0.1:64321", db_port: 64322, anon_key: "anon", service_role_key: "service", project_id: "leased-project",
|
|
509
|
+
});
|
|
510
|
+
assert.match(readFileSync(config.envFile, "utf8"), /^INTEGRATION_DB_PORT=64322$/m);
|
|
511
|
+
assert.match(readFileSync(config.stackConfig, "utf8"), /port = 64322/);
|
|
512
|
+
});
|
|
513
|
+
|
|
514
|
+
test("BOT-1346: materialized integration config removes dotenv if its TOML write fails", async () => {
|
|
515
|
+
const root = mkdtempSync(join(tmpdir(), "bb-stack-integration-"));
|
|
516
|
+
const integrationDir = join(root, "supabase", "functions", "_test", "integration");
|
|
517
|
+
mkdirSync(integrationDir, { recursive: true });
|
|
518
|
+
writeFileSync(join(integrationDir, ".env.integration"), "SUPABASE_URL=http://127.0.0.1:56321\n");
|
|
519
|
+
const writes = []; const removed = [];
|
|
520
|
+
await assert.rejects(
|
|
521
|
+
materializeLeasedTestConfig(root, "lease-cleanup", {
|
|
522
|
+
api_url: "http://127.0.0.1:64321", db_port: 64322, anon_key: "anon", service_role_key: "service", project_id: "leased-project",
|
|
523
|
+
}, {
|
|
524
|
+
writePrivate: async (path) => { writes.push(path); if (writes.length === 2) throw new Error("ENOSPC"); },
|
|
525
|
+
removePrivate: async (path) => { removed.push(path); },
|
|
526
|
+
}),
|
|
527
|
+
/ENOSPC/,
|
|
528
|
+
);
|
|
529
|
+
assert.equal(writes.length, 2);
|
|
530
|
+
assert.deepEqual(removed, [writes[0]]);
|
|
531
|
+
});
|
|
532
|
+
|
|
533
|
+
test("BOT-1346: queue wait errors retain a failed signed-cleanup receipt", async () => {
|
|
534
|
+
const result = await runStackLifecycle(
|
|
535
|
+
{ repo: "botbuddy-web", ticket: "BOT-1346", stackPath: ".", timeout: 5, reapTimeout: 5 },
|
|
536
|
+
["pnpm", "test:integration"],
|
|
537
|
+
{
|
|
538
|
+
auth: { Authorization: "Bearer test" }, signals: new EventEmitter(),
|
|
539
|
+
api: {
|
|
540
|
+
request: async () => ({ ok: true, data: { success: true, lease_id: "lease-queue-error", state: "queued" } }),
|
|
541
|
+
release: async (_leaseId, signal) => {
|
|
542
|
+
assert(signal instanceof AbortSignal, "cleanup release receives the reap deadline signal");
|
|
543
|
+
return { ok: false, error: "signed reap unavailable" };
|
|
544
|
+
},
|
|
545
|
+
},
|
|
546
|
+
wait: async () => ({ error: "lease stream broke" }),
|
|
547
|
+
},
|
|
548
|
+
);
|
|
549
|
+
assert.equal(result.exitCode, EXIT.LEASE_FAILED);
|
|
550
|
+
assert.equal(result.cleanup.ok, false);
|
|
551
|
+
assert.match(result.cleanup.error, /signed reap unavailable/);
|
|
552
|
+
});
|
|
553
|
+
|
|
554
|
+
test("BOT-1346: queue timeout receipt retains a failed cleanup result", async () => {
|
|
555
|
+
const result = await runStackLifecycle(
|
|
556
|
+
{ repo: "botbuddy-web", ticket: "BOT-1346", stackPath: ".", timeout: 5, reapTimeout: 5 }, ["pnpm", "test:integration"],
|
|
557
|
+
{
|
|
558
|
+
auth: { Authorization: "Bearer test" }, signals: new EventEmitter(),
|
|
559
|
+
api: {
|
|
560
|
+
request: async () => ({ ok: true, data: { success: true, lease_id: "lease-queued", state: "queued" } }),
|
|
561
|
+
release: async () => ({ ok: false, error: "release rejected" }),
|
|
562
|
+
},
|
|
563
|
+
wait: async () => ({ timeout: true }),
|
|
564
|
+
clock: { setInterval: () => ({ unref() {} }), clearInterval() {}, setTimeout: () => ({ unref() {} }), clearTimeout() {} },
|
|
565
|
+
},
|
|
566
|
+
);
|
|
567
|
+
assert.equal(result.exitCode, EXIT.TIMEOUT);
|
|
568
|
+
assert.equal(result.cleanup.ok, false);
|
|
569
|
+
assert.match(result.cleanup.error, /release rejected/);
|
|
570
|
+
});
|
|
571
|
+
|
|
572
|
+
test("BOT-1346: interruption during lease creation releases the lease once its ID arrives", async () => {
|
|
573
|
+
const signals = new EventEmitter();
|
|
574
|
+
let resolveRequest; let releases = 0;
|
|
575
|
+
const pending = runStackLifecycle(
|
|
576
|
+
{ repo: "botbuddy-web", ticket: "BOT-1346", stackPath: ".", timeout: 5, reapTimeout: 5 }, ["pnpm", "test:integration"],
|
|
577
|
+
{
|
|
578
|
+
auth: { Authorization: "Bearer test" }, signals,
|
|
579
|
+
api: {
|
|
580
|
+
request: () => new Promise((resolve) => { resolveRequest = resolve; }),
|
|
581
|
+
release: async () => { releases++; return { ok: true, data: { success: true, state: "reaping" } }; },
|
|
582
|
+
},
|
|
583
|
+
wait: async () => ({ woke: true, state: "reaped" }),
|
|
584
|
+
clock: { setInterval: () => ({ unref() {} }), clearInterval() {}, setTimeout: () => ({ unref() {} }), clearTimeout() {} },
|
|
585
|
+
},
|
|
586
|
+
);
|
|
587
|
+
signals.emit("SIGINT");
|
|
588
|
+
resolveRequest({ ok: true, data: { success: true, lease_id: "lease-interrupted-request", state: "queued" } });
|
|
589
|
+
const result = await pending;
|
|
590
|
+
assert.equal(result.exitCode, 130);
|
|
591
|
+
assert.equal(releases, 1);
|
|
592
|
+
assert.equal(result.cleanup.ok, true);
|
|
593
|
+
});
|
|
594
|
+
|
|
595
|
+
test("BOT-1346: forced hard TTL cannot turn an incomplete child run into success", async () => {
|
|
596
|
+
const fakeChild = new EventEmitter(); fakeChild.pid = 4244;
|
|
597
|
+
let hardTimeout;
|
|
598
|
+
const result = await runStackLifecycle(
|
|
599
|
+
{ repo: "botbuddy-web", ticket: "BOT-1346", stackPath: ".", timeout: 5, reapTimeout: 5, hardTtl: 1 }, ["node", "-e", "process.exit(0)"],
|
|
600
|
+
{
|
|
601
|
+
auth: { Authorization: "Bearer test" }, signals: new EventEmitter(),
|
|
602
|
+
api: {
|
|
603
|
+
request: async () => ({ ok: true, data: { success: true, lease_id: "lease-ttl", state: "active" } }),
|
|
604
|
+
get: async () => ({ ok: true, data: { success: true, state: "active", connection: {} } }),
|
|
605
|
+
touch: async () => ({ ok: true, data: { success: true } }),
|
|
606
|
+
release: async () => ({ ok: true, data: { success: true, state: "reaping" } }),
|
|
607
|
+
},
|
|
608
|
+
wait: async () => ({ woke: true, state: "reaped" }), writeConnection: async (path) => path, removeConnection: async () => {},
|
|
609
|
+
startChild: () => { queueMicrotask(() => { hardTimeout(); fakeChild.emit("exit", 0, null); }); return fakeChild; },
|
|
610
|
+
clock: { setInterval: () => ({ unref() {} }), clearInterval() {}, setTimeout: (fn) => { hardTimeout = fn; return { unref() {} }; }, clearTimeout() {} },
|
|
611
|
+
},
|
|
612
|
+
);
|
|
613
|
+
assert.equal(result.exitCode, EXIT.TIMEOUT);
|
|
614
|
+
});
|
|
615
|
+
|
|
616
|
+
test("BOT-1346: a fenced lease cannot report a gracefully-stopped child as success", async () => {
|
|
617
|
+
const fakeChild = new EventEmitter(); fakeChild.pid = 4245;
|
|
618
|
+
let heartbeat;
|
|
619
|
+
const result = await runStackLifecycle(
|
|
620
|
+
{ repo: "botbuddy-web", ticket: "BOT-1346", stackPath: ".", timeout: 5, reapTimeout: 5 }, ["node", "-e", "process.exit(0)"],
|
|
621
|
+
{
|
|
622
|
+
auth: { Authorization: "Bearer test" }, signals: new EventEmitter(),
|
|
623
|
+
api: {
|
|
624
|
+
request: async () => ({ ok: true, data: { success: true, lease_id: "lease-fenced", state: "active" } }),
|
|
625
|
+
get: async () => ({ ok: true, data: { success: true, state: "active", connection: {} } }),
|
|
626
|
+
touch: async () => ({ ok: false, error: "lease unavailable" }),
|
|
627
|
+
release: async () => ({ ok: true, data: { success: true, state: "reaping" } }),
|
|
628
|
+
},
|
|
629
|
+
wait: async () => ({ woke: true, state: "reaped" }), writeConnection: async (path) => path, removeConnection: async () => {},
|
|
630
|
+
startChild: () => { queueMicrotask(async () => { await heartbeat(); fakeChild.emit("exit", 0, null); }); return fakeChild; },
|
|
631
|
+
clock: { setInterval: (fn) => { heartbeat = fn; return { unref() {} }; }, clearInterval() {}, setTimeout: () => ({ unref() {} }), clearTimeout() {} },
|
|
632
|
+
},
|
|
633
|
+
);
|
|
634
|
+
assert.equal(result.exitCode, EXIT.LEASE_FAILED);
|
|
635
|
+
assert.equal(result.fenced, true);
|
|
636
|
+
});
|
|
637
|
+
|
|
638
|
+
test("BOT-1346: stack run reports failed signed cleanup after a successful child", async () => {
|
|
639
|
+
const fakeChild = new EventEmitter(); fakeChild.pid = 4243;
|
|
640
|
+
const result = await runStackLifecycle(
|
|
641
|
+
{ repo: "botbuddy-web", ticket: "BOT-1346", stackPath: ".", timeout: 5, reapTimeout: 5 },
|
|
642
|
+
["node", "-e", "process.exit(0)"],
|
|
643
|
+
{
|
|
644
|
+
auth: { Authorization: "Bearer test" }, signals: new EventEmitter(),
|
|
645
|
+
api: {
|
|
646
|
+
request: async () => ({ ok: true, data: { success: true, lease_id: "lease-unreaped", state: "active" } }),
|
|
647
|
+
get: async () => ({ ok: true, data: { success: true, state: "active", connection: {} } }),
|
|
648
|
+
touch: async () => ({ ok: true, data: { success: true } }),
|
|
649
|
+
release: async () => ({ ok: true, data: { success: true, state: "reaping" } }),
|
|
650
|
+
},
|
|
651
|
+
wait: async () => ({ timeout: true }),
|
|
652
|
+
writeConnection: async (path) => path,
|
|
653
|
+
removeConnection: async () => {},
|
|
654
|
+
startChild: () => { queueMicrotask(() => fakeChild.emit("exit", 0, null)); return fakeChild; },
|
|
655
|
+
clock: { setInterval: () => ({ unref() {} }), clearInterval() {}, setTimeout: () => ({ unref() {} }), clearTimeout() {} },
|
|
656
|
+
},
|
|
657
|
+
);
|
|
658
|
+
assert.equal(result.childExitCode, 0);
|
|
659
|
+
assert.equal(result.exitCode, EXIT.CLEANUP_FAILED);
|
|
660
|
+
assert.equal(result.cleanup.ok, false);
|
|
661
|
+
});
|
|
102
662
|
|
|
103
663
|
// ── receipts ─────────────────────────────────────────────────────────────────
|
|
104
664
|
test("buildReceipt: stamps schema_version", () => {
|
|
@@ -155,7 +715,7 @@ test("resolveLocalSupabaseUrl: ignores a NON-local exported origin (refuses →
|
|
|
155
715
|
|
|
156
716
|
// ── --help snapshot (AC-4) ───────────────────────────────────────────────────
|
|
157
717
|
test("STACK_HELP documents the four subcommands and every exit code", () => {
|
|
158
|
-
for (const s of ["stack up", "stack status", "stack touch", "stack done", "--local-exec", "--no-wait"]) {
|
|
718
|
+
for (const s of ["stack up", "stack status", "stack touch", "stack done", "--local-exec", "--docker-context", "--no-wait"]) {
|
|
159
719
|
assert.ok(STACK_HELP.includes(s), `help missing ${s}`);
|
|
160
720
|
}
|
|
161
721
|
for (const s of ["0 ok", "2 park timed out", "3 not authenticated", "4 invalid", "5 backend", "6 lease failed", "7 internal"]) {
|