@botbuddy/cli 1.2.3 → 1.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,196 @@
1
+ // BOT-1220 — `botbuddy stack` unit + spawn tests (bb-wait pattern).
2
+ // * pure helpers: slot derivation, arg parsing, receipt build/truncate, status parse
3
+ // * --help snapshot (AC-4)
4
+ // * spawn tests: documented exit codes + exactly ONE JSON-line receipt to stdout (AC-4)
5
+ import test from "node:test";
6
+ import assert from "node:assert/strict";
7
+ import { spawnSync } from "node:child_process";
8
+ import { fileURLToPath } from "node:url";
9
+ import { dirname, join } from "node:path";
10
+ import { mkdtempSync, writeFileSync, mkdirSync, realpathSync } from "node:fs";
11
+ import { tmpdir } from "node:os";
12
+
13
+ import {
14
+ EXIT, STACK_HELP, STACK_SCHEMA_VERSION, DEFAULT_RECEIPT_MAX_BYTES,
15
+ deriveSlot, parseStackArgs, buildReceipt, truncateReceipt,
16
+ parseSupabaseStatus, eventStreamBase, resolveLocalSupabaseUrl, resolveStackPath,
17
+ } from "./stack.mjs";
18
+
19
+ const BIN = join(dirname(fileURLToPath(import.meta.url)), "..", "bin", "botbuddy.mjs");
20
+
21
+ // Run the real CLI with a throwaway HOME so no ambient ~/.botbuddy config leaks in.
22
+ function runStack(args, extraEnv = {}) {
23
+ const home = mkdtempSync(join(tmpdir(), "bb-stack-home-"));
24
+ return spawnSync("node", [BIN, "stack", ...args], {
25
+ encoding: "utf8",
26
+ env: { ...process.env, HOME: home, USERPROFILE: home, ...extraEnv },
27
+ });
28
+ }
29
+
30
+ // The single JSON-line receipt discipline: stdout is exactly one line and it parses.
31
+ function soleReceipt(res) {
32
+ const lines = res.stdout.split("\n").filter((l) => l.trim() !== "");
33
+ assert.equal(lines.length, 1, `expected exactly one stdout line, got ${lines.length}:\n${res.stdout}`);
34
+ return JSON.parse(lines[0]);
35
+ }
36
+
37
+ // ── deriveSlot ───────────────────────────────────────────────────────────────
38
+ test("deriveSlot: explicit --slot wins", () => {
39
+ assert.equal(deriveSlot({ slot: "56322" }, {}), "56322");
40
+ });
41
+ test("deriveSlot: derives <repo>-<ticket> when both given", () => {
42
+ assert.equal(deriveSlot({ repo: "botbuddy-web", ticket: "BOT-1220" }, {}), "botbuddy-web-bot-1220");
43
+ });
44
+ test("deriveSlot: BB_STACK_SLOT env fallback", () => {
45
+ assert.equal(deriveSlot({}, { BB_STACK_SLOT: "56999" }), "56999");
46
+ });
47
+ test('deriveSlot: rejects "default"', () => {
48
+ assert.throws(() => deriveSlot({ slot: "default" }, {}), /default.*rejected/i);
49
+ });
50
+ test("deriveSlot: throws when undeterminable", () => {
51
+ assert.throws(() => deriveSlot({}, {}), /cannot determine a stack slot/);
52
+ });
53
+
54
+ // ── parseStackArgs ───────────────────────────────────────────────────────────
55
+ test("parseStackArgs: up options", () => {
56
+ const { command, opts, errors } = parseStackArgs([
57
+ "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",
59
+ ]);
60
+ assert.equal(errors.length, 0);
61
+ assert.equal(command, "up");
62
+ assert.equal(opts.slot, "56322");
63
+ assert.equal(opts.host, "mac");
64
+ assert.equal(opts.stackPath, "infra/local");
65
+ assert.equal(opts.idleTtl, 600);
66
+ assert.equal(opts.timeout, 120);
67
+ assert.equal(opts.noWait, true);
68
+ assert.equal(opts.localExec, true);
69
+ });
70
+ test("parseStackArgs: rejects absolute and escaping stack paths", () => {
71
+ assert.ok(parseStackArgs(["up", "--stack-path", "/tmp/stack"]).errors.length > 0);
72
+ assert.ok(parseStackArgs(["up", "--stack-path", "../stack"]).errors.length > 0);
73
+ assert.ok(parseStackArgs(["up", "--stack-path", "sub/../../stack"]).errors.length > 0);
74
+ });
75
+ test("resolveStackPath: canonicalizes an in-worktree directory", () => {
76
+ const root = mkdtempSync(join(tmpdir(), "bb-stack-root-"));
77
+ mkdirSync(join(root, "stack"));
78
+ assert.deepEqual(resolveStackPath(root, "stack"), { worktreeRoot: realpathSync(root), stackPath: "stack" });
79
+ });
80
+ test("parseStackArgs: status/touch/done require a lease_id", () => {
81
+ for (const c of ["status", "touch", "done"]) {
82
+ const { errors } = parseStackArgs([c]);
83
+ assert.ok(errors.some((e) => e.includes("lease_id")), `${c} should require lease_id`);
84
+ }
85
+ const { leaseId, errors } = parseStackArgs(["status", "abc-123"]);
86
+ assert.equal(errors.length, 0);
87
+ assert.equal(leaseId, "abc-123");
88
+ });
89
+ test("parseStackArgs: unknown option is an error", () => {
90
+ const { errors } = parseStackArgs(["up", "--bogus"]);
91
+ assert.ok(errors.some((e) => e.includes("--bogus")));
92
+ });
93
+ test("parseStackArgs: --idle-ttl / --timeout must be positive integers", () => {
94
+ assert.ok(parseStackArgs(["up", "--idle-ttl", "0"]).errors.length > 0);
95
+ assert.ok(parseStackArgs(["up", "--timeout", "-5"]).errors.length > 0);
96
+ assert.ok(parseStackArgs(["up", "--idle-ttl", "x"]).errors.length > 0);
97
+ });
98
+ test("parseStackArgs: --stop sets disposition stop (default destroy)", () => {
99
+ assert.equal(parseStackArgs(["done", "id"]).opts.disposition, "destroy");
100
+ assert.equal(parseStackArgs(["done", "id", "--stop"]).opts.disposition, "stop");
101
+ });
102
+
103
+ // ── receipts ─────────────────────────────────────────────────────────────────
104
+ test("buildReceipt: stamps schema_version", () => {
105
+ assert.equal(buildReceipt({ command: "up" }).schema_version, STACK_SCHEMA_VERSION);
106
+ });
107
+ test("truncateReceipt: in-budget receipt is returned unchanged", () => {
108
+ const r = buildReceipt({ command: "up", outcome: "active", lease_id: "x", exit_code: 0 });
109
+ assert.deepEqual(truncateReceipt(r, DEFAULT_RECEIPT_MAX_BYTES), r);
110
+ });
111
+ test("truncateReceipt: collapses the oversized connection block first, keeps lease_id + exit_code", () => {
112
+ const big = "k".repeat(20000);
113
+ const r = buildReceipt({ command: "up", outcome: "active", lease_id: "lease-1", exit_code: 0, connection: { service_role_key: big } });
114
+ const t = truncateReceipt(r, DEFAULT_RECEIPT_MAX_BYTES);
115
+ assert.ok(Buffer.byteLength(JSON.stringify(t), "utf8") <= DEFAULT_RECEIPT_MAX_BYTES);
116
+ assert.equal(t.lease_id, "lease-1");
117
+ assert.equal(t.exit_code, 0);
118
+ assert.ok(t.connection.connection_truncated || t.truncated);
119
+ });
120
+
121
+ // ── parseSupabaseStatus ──────────────────────────────────────────────────────
122
+ test("parseSupabaseStatus: JSON form", () => {
123
+ const conn = parseSupabaseStatus(JSON.stringify({ API_URL: "http://127.0.0.1:56321", DB_URL: "postgresql://x", ANON_KEY: "anon", SERVICE_ROLE_KEY: "svc" }));
124
+ assert.equal(conn.api_url, "http://127.0.0.1:56321");
125
+ assert.equal(conn.service_role_key, "svc");
126
+ });
127
+ test("parseSupabaseStatus: plain key/value fallback", () => {
128
+ const conn = parseSupabaseStatus("API URL: http://127.0.0.1:56321\nanon key: theanon\nservice_role key: thesvc\n");
129
+ assert.equal(conn.api_url, "http://127.0.0.1:56321");
130
+ assert.equal(conn.anon_key, "theanon");
131
+ });
132
+
133
+ // ── eventStreamBase ──────────────────────────────────────────────────────────
134
+ test("eventStreamBase: derives the event-stream URL from the mcp-server URL", () => {
135
+ assert.equal(eventStreamBase("https://api.bot-buddy.ai/functions/v1/mcp-server"), "https://api.bot-buddy.ai/functions/v1/event-stream");
136
+ assert.equal(eventStreamBase("http://127.0.0.1:56321/functions/v1/mcp-server/"), "http://127.0.0.1:56321/functions/v1/event-stream");
137
+ });
138
+
139
+ // ── resolveLocalSupabaseUrl (Codex P1: never bake the prod origin) ───────────
140
+ test("resolveLocalSupabaseUrl: honours an already-local VITE_SUPABASE_URL", () => {
141
+ assert.equal(resolveLocalSupabaseUrl({ VITE_SUPABASE_URL: "http://127.0.0.1:56321" }), "http://127.0.0.1:56321");
142
+ assert.equal(resolveLocalSupabaseUrl({ VITE_SUPABASE_URL: "http://localhost:54321" }), "http://localhost:54321");
143
+ });
144
+ test("resolveLocalSupabaseUrl: derives the [api] port from supabase/config.toml", () => {
145
+ const dir = mkdtempSync(join(tmpdir(), "bb-stack-cfg-"));
146
+ mkdirSync(join(dir, "supabase"));
147
+ writeFileSync(join(dir, "supabase", "config.toml"), "[api]\nenabled = true\nport = 56321\n\n[db]\nport = 56322\n");
148
+ assert.equal(resolveLocalSupabaseUrl({}, dir), "http://127.0.0.1:56321");
149
+ });
150
+ test("resolveLocalSupabaseUrl: ignores a NON-local exported origin (refuses → null)", () => {
151
+ // A prod VITE_SUPABASE_URL must NOT be trusted, and no config.toml here ⇒ null (caller refuses).
152
+ const empty = mkdtempSync(join(tmpdir(), "bb-stack-nocfg-"));
153
+ assert.equal(resolveLocalSupabaseUrl({ VITE_SUPABASE_URL: "https://api.bot-buddy.ai" }, empty), null);
154
+ });
155
+
156
+ // ── --help snapshot (AC-4) ───────────────────────────────────────────────────
157
+ 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"]) {
159
+ assert.ok(STACK_HELP.includes(s), `help missing ${s}`);
160
+ }
161
+ for (const s of ["0 ok", "2 park timed out", "3 not authenticated", "4 invalid", "5 backend", "6 lease failed", "7 internal"]) {
162
+ assert.ok(STACK_HELP.includes(s), `help missing exit-code doc ${s}`);
163
+ }
164
+ });
165
+
166
+ // ── spawn tests: exit codes + single-line receipt (bb-wait pattern) ──────────
167
+ test("spawn: `stack help` prints usage and exits 0", () => {
168
+ const res = runStack(["help"]);
169
+ assert.equal(res.status, 0);
170
+ assert.ok(res.stdout.includes("botbuddy stack"));
171
+ });
172
+ test("spawn: invalid slot 'default' → exit 4 with a single JSON-line receipt", () => {
173
+ const res = runStack(["up", "--slot", "default"]);
174
+ assert.equal(res.status, EXIT.INVALID);
175
+ const r = soleReceipt(res);
176
+ assert.equal(r.schema_version, STACK_SCHEMA_VERSION);
177
+ assert.equal(r.command, "up");
178
+ assert.equal(r.outcome, "error");
179
+ assert.equal(r.exit_code, EXIT.INVALID);
180
+ });
181
+ test("spawn: `stack status` without a lease_id → exit 4", () => {
182
+ const res = runStack(["status"]);
183
+ assert.equal(res.status, EXIT.INVALID);
184
+ soleReceipt(res);
185
+ });
186
+ test("spawn: `stack up` with a valid slot but no auth → exit 3", () => {
187
+ const res = runStack(["up", "--slot", "56322"]);
188
+ assert.equal(res.status, EXIT.AUTH);
189
+ const r = soleReceipt(res);
190
+ assert.equal(r.exit_code, EXIT.AUTH);
191
+ assert.equal(r.command, "up");
192
+ });
193
+ test("spawn: unknown subcommand → exit 4", () => {
194
+ const res = runStack(["frobnicate"]);
195
+ assert.equal(res.status, EXIT.INVALID);
196
+ });