busa-cli 0.9.6 → 0.9.8

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,4596 @@
1
+ // src/format.ts
2
+ function render(value, output) {
3
+ if (output === "json") {
4
+ return JSON.stringify(value, null, 2);
5
+ }
6
+ if (output === "text") {
7
+ return renderText(value);
8
+ }
9
+ if (Array.isArray(value)) {
10
+ return renderTable(value);
11
+ }
12
+ if (value && typeof value === "object") {
13
+ return renderRecord(value);
14
+ }
15
+ return String(value);
16
+ }
17
+ function cell(value) {
18
+ if (value === null || value === void 0) return "";
19
+ if (typeof value === "object") return compactJson(value);
20
+ return truncate(String(value));
21
+ }
22
+ function compactJson(value) {
23
+ if (Array.isArray(value)) {
24
+ if (value.length === 0) return "[]";
25
+ if (value.every((item) => typeof item !== "object" || item === null)) {
26
+ return truncate(value.map((item) => String(item)).join(", "));
27
+ }
28
+ return `[${value.length} items]`;
29
+ }
30
+ if (value && typeof value === "object") {
31
+ const keys = Object.keys(value);
32
+ if (keys.length === 0) return "{}";
33
+ return `{${keys.slice(0, 3).join(", ")}${keys.length > 3 ? ", ..." : ""}}`;
34
+ }
35
+ return truncate(String(value));
36
+ }
37
+ function truncate(value, max = 72) {
38
+ const singleLine = value.replace(/\s+/g, " ").trim();
39
+ if (singleLine.length <= max) return singleLine;
40
+ return `${singleLine.slice(0, max - 1)}\u2026`;
41
+ }
42
+ function renderRecord(row) {
43
+ const keys = Object.keys(row);
44
+ const width = Math.max(0, ...keys.map((k) => k.length));
45
+ return keys.map((k) => `${k.padEnd(width)} ${cell(row[k])}`).join("\n");
46
+ }
47
+ function renderTable(rows) {
48
+ if (rows.length === 0) return "(no rows)";
49
+ if (typeof rows[0] !== "object" || rows[0] === null) {
50
+ return rows.map((r) => cell(r)).join("\n");
51
+ }
52
+ const records = rows;
53
+ const columns = [...new Set(records.flatMap((r) => Object.keys(r)))];
54
+ const widths = columns.map((c2) => Math.max(c2.length, ...records.map((r) => cell(r[c2]).length)));
55
+ const line = (cells) => cells.map((value, i) => value.padEnd(widths[i])).join(" ").trimEnd();
56
+ const header = line(columns);
57
+ const sep = widths.map((w) => "-".repeat(w)).join(" ");
58
+ const body = records.map((r) => line(columns.map((c2) => cell(r[c2]))));
59
+ return [header, sep, ...body].join("\n");
60
+ }
61
+ function renderText(value) {
62
+ if (Array.isArray(value)) {
63
+ if (value.length === 0) return "(no rows)";
64
+ if (isNodeTree(value)) return renderNodeTree(value);
65
+ if (typeof value[0] !== "object" || value[0] === null) {
66
+ return value.map((item) => cell(item)).join("\n");
67
+ }
68
+ return renderTable(value);
69
+ }
70
+ if (value && typeof value === "object") {
71
+ return renderRecord(value);
72
+ }
73
+ return String(value);
74
+ }
75
+ function isNodeTree(value) {
76
+ return value.every(isNodeLike);
77
+ }
78
+ function isNodeLike(value) {
79
+ if (!value || typeof value !== "object" || Array.isArray(value)) return false;
80
+ const row = value;
81
+ return typeof row.id === "string" && typeof row.type === "string" && typeof row.slug === "string" && typeof row.name === "string" && Array.isArray(row.children);
82
+ }
83
+ function renderNodeTree(nodes) {
84
+ const lines = [];
85
+ const walk2 = (items, prefix, isRoot) => {
86
+ items.forEach((node, index) => {
87
+ const isLast = index === items.length - 1;
88
+ const branch = isRoot ? "" : isLast ? "\u2514\u2500 " : "\u251C\u2500 ";
89
+ const childPrefix = isRoot ? "" : `${prefix}${isLast ? " " : "\u2502 "}`;
90
+ lines.push(`${prefix}${branch}${formatNode(node)}`);
91
+ const children = Array.isArray(node.children) ? node.children.filter(isNodeLike) : [];
92
+ walk2(children, childPrefix, false);
93
+ });
94
+ };
95
+ walk2(nodes, "", true);
96
+ return lines.join("\n");
97
+ }
98
+ function formatNode(node) {
99
+ const type = String(node.type ?? "node");
100
+ const name = String(node.name ?? node.slug ?? node.id ?? "Untitled");
101
+ const slug = typeof node.slug === "string" && node.slug !== name ? ` /${node.slug}` : "";
102
+ const base = typeof node.baseId === "string" && node.baseId ? ` base=${node.baseId}` : "";
103
+ return `${nodeIcon(type)} ${name}${slug} (${type}${base}, id=${node.id})`;
104
+ }
105
+ function nodeIcon(type) {
106
+ switch (type) {
107
+ case "folder":
108
+ return "[folder]";
109
+ case "base":
110
+ return "[base]";
111
+ case "doc":
112
+ return "[doc]";
113
+ case "skill":
114
+ return "[skill]";
115
+ case "drive":
116
+ return "[drive]";
117
+ default:
118
+ return "[node]";
119
+ }
120
+ }
121
+
122
+ // src/run.ts
123
+ import { createHash as createHash3 } from "crypto";
124
+ import { readFileSync as readFileSync2 } from "fs";
125
+ import { basename, extname as extname2 } from "path";
126
+ import {
127
+ CREATABLE_NODE_TYPES as CREATABLE_NODE_TYPES2,
128
+ cloudContract,
129
+ createBusabaseClient,
130
+ DEFAULT_BASE_URL as DEFAULT_BASE_URL2,
131
+ normalizeBaseUrl as normalizeBaseUrl2
132
+ } from "busabase-sdk";
133
+ import {
134
+ Command,
135
+ CommanderError,
136
+ InvalidArgumentError,
137
+ Option
138
+ } from "commander";
139
+
140
+ // src/banner.ts
141
+ var useColor = Boolean(process.stdout.isTTY) && !process.env.NO_COLOR;
142
+ var paint = (code, s) => useColor ? `\x1B[${code}m${s}\x1B[0m` : s;
143
+ var c = {
144
+ brand: (s) => paint("38;5;43", s),
145
+ // teal — Busabase accent
146
+ bold: (s) => paint("1", s),
147
+ dim: (s) => paint("2", s),
148
+ cyan: (s) => paint("36", s)
149
+ };
150
+ var LOGO = [
151
+ " ____ _ ",
152
+ " | __ ) _ _ ___ __ _| |__ __ _ ___ ___ ",
153
+ " | _ \\| | | / __|/ _` | '_ \\ / _` / __|/ _ \\ ",
154
+ " | |_) | |_| \\__ \\ (_| | |_) | (_| \\__ \\ __/ ",
155
+ " |____/ \\__,_|___/\\__,_|_.__/ \\__,_|___/\\___| "
156
+ ];
157
+ function banner(baseUrl) {
158
+ const out = [""];
159
+ for (const line of LOGO) out.push(c.brand(line));
160
+ out.push(`${c.dim(" OpenAPI client for Busabase")} ${c.dim("\xB7")} ${c.dim("talks to /api/v1")}`);
161
+ out.push(` ${c.bold("Server")} ${c.cyan(baseUrl)}`);
162
+ out.push("");
163
+ return out.join("\n");
164
+ }
165
+
166
+ // src/config-file.ts
167
+ import { chmodSync, mkdirSync, readFileSync, writeFileSync } from "fs";
168
+ import { homedir } from "os";
169
+ import { join } from "path";
170
+ var dotEnvPath = () => join(homedir(), ".busabase", ".env");
171
+ function loadDotEnvFile() {
172
+ let text;
173
+ try {
174
+ text = readFileSync(dotEnvPath(), "utf8");
175
+ } catch {
176
+ return {};
177
+ }
178
+ const out = {};
179
+ for (const line of text.split("\n")) {
180
+ const trimmed = line.trim();
181
+ if (!trimmed || trimmed.startsWith("#")) continue;
182
+ const eq = trimmed.indexOf("=");
183
+ if (eq === -1) continue;
184
+ const key = trimmed.slice(0, eq).trim();
185
+ const value = trimmed.slice(eq + 1).trim().replace(/^["']|["']$/g, "");
186
+ if (key) out[key] = value;
187
+ }
188
+ return out;
189
+ }
190
+ function writeDotEnvFile(updates) {
191
+ const merged = { ...loadDotEnvFile() };
192
+ for (const [key, value] of Object.entries(updates)) {
193
+ if (value === null) delete merged[key];
194
+ else merged[key] = value;
195
+ }
196
+ const path = dotEnvPath();
197
+ mkdirSync(join(homedir(), ".busabase"), { recursive: true });
198
+ const body = Object.entries(merged).map(([key, value]) => `${key}=${value}`).join("\n");
199
+ writeFileSync(path, body ? `${body}
200
+ ` : "", "utf8");
201
+ try {
202
+ chmodSync(path, 384);
203
+ } catch {
204
+ }
205
+ }
206
+
207
+ // src/login.ts
208
+ import { spawn } from "child_process";
209
+ import { createHash, randomBytes } from "crypto";
210
+ import { createServer } from "http";
211
+ import { createInterface } from "readline/promises";
212
+ import { DEFAULT_BASE_URL, normalizeBaseUrl } from "busabase-sdk";
213
+ var CLI_CLIENT_ID = "busabase-cli";
214
+ var CLI_CLIENT_PLATFORM = "cli";
215
+ var OAUTH_TIMEOUT_MS = 5 * 60 * 1e3;
216
+ var EXPIRES_AT_KEY = "BUSABASE_TOKEN_EXPIRES_AT";
217
+ var SESSION_TOKEN_PREFIX = "bss_";
218
+ var AUTO_REFRESH_THRESHOLD_MS = 2 * 24 * 60 * 60 * 1e3;
219
+ var DEFAULT_LOCAL_URL = "http://localhost:15419";
220
+ var base64url = (buffer) => buffer.toString("base64url");
221
+ var isInteractive = () => Boolean(process.stdin.isTTY && process.stdout.isTTY);
222
+ var say = (message) => {
223
+ process.stderr.write(`${message}
224
+ `);
225
+ };
226
+ var isLocalHost = (baseUrl) => {
227
+ try {
228
+ const { hostname } = new URL(baseUrl);
229
+ return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1";
230
+ } catch {
231
+ return false;
232
+ }
233
+ };
234
+ async function probeAuthRequired(baseUrl) {
235
+ let res;
236
+ try {
237
+ res = await fetch(`${baseUrl}/api/v1/bases`);
238
+ } catch {
239
+ throw new Error(
240
+ `Could not reach ${baseUrl}. Is the server running? Start a local one with \`busabase server\`, or pass a reachable --base-url.`
241
+ );
242
+ }
243
+ return res.status === 401 || res.status === 403;
244
+ }
245
+ async function ask(question) {
246
+ const rl = createInterface({ input: process.stdin, output: process.stderr });
247
+ try {
248
+ return (await rl.question(question)).trim();
249
+ } finally {
250
+ rl.close();
251
+ }
252
+ }
253
+ function openBrowser(url) {
254
+ const platform = process.platform;
255
+ const [command, args] = platform === "darwin" ? ["open", [url]] : platform === "win32" ? ["cmd", ["/c", "start", "", url]] : ["xdg-open", [url]];
256
+ try {
257
+ spawn(command, args, { stdio: "ignore", detached: true }).unref();
258
+ } catch {
259
+ }
260
+ }
261
+ async function verifyAuth(baseUrl, token) {
262
+ const res = await fetch(`${baseUrl}/api/v1/auth`, {
263
+ headers: { authorization: `Bearer ${token}` }
264
+ });
265
+ if (res.status === 401) {
266
+ throw new Error(
267
+ `The credential was rejected (401) by ${baseUrl}. If it's an API key, check it in Dashboard \u2192 Settings \u2192 API Keys; if it's a login session, run \`busabase-cli login\` again.`
268
+ );
269
+ }
270
+ if (!res.ok) {
271
+ throw new Error(`Could not verify the credential (HTTP ${res.status}) from ${baseUrl}.`);
272
+ }
273
+ return await res.json();
274
+ }
275
+ async function oauthLogin(baseUrl, useBrowser) {
276
+ const codeVerifier = base64url(randomBytes(32));
277
+ const codeChallenge = base64url(createHash("sha256").update(codeVerifier).digest());
278
+ const state = base64url(randomBytes(16));
279
+ const { code, redirectUri } = await new Promise(
280
+ (resolve, reject) => {
281
+ const server = createServer((req, res) => {
282
+ const url = new URL(req.url ?? "/", "http://127.0.0.1");
283
+ if (url.pathname !== "/callback") {
284
+ res.writeHead(404).end("Not found");
285
+ return;
286
+ }
287
+ const respond = (title, body) => {
288
+ res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
289
+ res.end(
290
+ `<!doctype html><meta charset="utf-8"><title>${title}</title><body style="font-family:system-ui;max-width:32rem;margin:4rem auto;text-align:center"><h2>${title}</h2><p>${body}</p></body>`
291
+ );
292
+ };
293
+ const error = url.searchParams.get("error");
294
+ const returnedState = url.searchParams.get("state");
295
+ const returnedCode = url.searchParams.get("code");
296
+ if (error) {
297
+ respond("Sign-in failed", "You can close this tab and return to the terminal.");
298
+ cleanup();
299
+ reject(new Error(`Authorization failed: ${error}`));
300
+ return;
301
+ }
302
+ if (!returnedCode || returnedState !== state) {
303
+ respond("Sign-in failed", "State mismatch. You can close this tab and try again.");
304
+ cleanup();
305
+ reject(new Error("OAuth state mismatch \u2014 the callback did not match this request."));
306
+ return;
307
+ }
308
+ respond("Signed in \u2713", "You can close this tab and return to the terminal.");
309
+ const address = server.address();
310
+ cleanup();
311
+ resolve({ code: returnedCode, redirectUri: `http://127.0.0.1:${address.port}/callback` });
312
+ });
313
+ const timeout = setTimeout(() => {
314
+ cleanup();
315
+ reject(new Error("Timed out waiting for the browser sign-in (5 minutes)."));
316
+ }, OAUTH_TIMEOUT_MS);
317
+ function cleanup() {
318
+ clearTimeout(timeout);
319
+ server.close();
320
+ }
321
+ server.on("error", (err) => {
322
+ cleanup();
323
+ reject(err);
324
+ });
325
+ server.listen(0, "127.0.0.1", () => {
326
+ const { port } = server.address();
327
+ const redirect = `http://127.0.0.1:${port}/callback`;
328
+ const authorizeUrl = new URL(`${baseUrl}/api/oauth/authorize`);
329
+ authorizeUrl.searchParams.set("response_type", "code");
330
+ authorizeUrl.searchParams.set("client_id", CLI_CLIENT_ID);
331
+ authorizeUrl.searchParams.set("client_platform", CLI_CLIENT_PLATFORM);
332
+ authorizeUrl.searchParams.set("code_challenge", codeChallenge);
333
+ authorizeUrl.searchParams.set("code_challenge_method", "S256");
334
+ authorizeUrl.searchParams.set("redirect_uri", redirect);
335
+ authorizeUrl.searchParams.set("state", state);
336
+ authorizeUrl.searchParams.set("prompt", "login");
337
+ const href = authorizeUrl.toString();
338
+ say("");
339
+ say("Open this URL in your browser to sign in:");
340
+ say(` ${href}`);
341
+ say("");
342
+ if (useBrowser) {
343
+ say("Opening your browser\u2026");
344
+ openBrowser(href);
345
+ }
346
+ say("Waiting for you to finish signing in\u2026");
347
+ });
348
+ }
349
+ );
350
+ const tokenRes = await fetch(`${baseUrl}/api/oauth/token`, {
351
+ method: "POST",
352
+ headers: { "content-type": "application/json" },
353
+ body: JSON.stringify({
354
+ grant_type: "authorization_code",
355
+ client_id: CLI_CLIENT_ID,
356
+ client_platform: CLI_CLIENT_PLATFORM,
357
+ code,
358
+ code_verifier: codeVerifier,
359
+ redirect_uri: redirectUri,
360
+ state
361
+ })
362
+ });
363
+ if (!tokenRes.ok) {
364
+ const text = await tokenRes.text();
365
+ throw new Error(`Token exchange failed (HTTP ${tokenRes.status})${text ? `: ${text}` : ""}`);
366
+ }
367
+ const payload = await tokenRes.json();
368
+ const token = payload.apiKey ?? payload.token ?? payload.accessToken;
369
+ if (!token) throw new Error("Token exchange returned no credential.");
370
+ return { token, expiresAt: payload.expiresAt ?? void 0 };
371
+ }
372
+ async function refreshToken(baseUrl, token) {
373
+ const res = await fetch(`${baseUrl}/api/oauth/refresh`, {
374
+ method: "POST",
375
+ headers: { authorization: `Bearer ${token}` }
376
+ });
377
+ if (res.status === 401) throw new Error("SESSION_EXPIRED");
378
+ if (!res.ok) throw new Error(`Refresh failed (HTTP ${res.status}) from ${baseUrl}.`);
379
+ const payload = await res.json();
380
+ return { token: payload.token ?? payload.accessToken ?? token, expiresAt: payload.expiresAt };
381
+ }
382
+ async function pickSpaceId(verify, preselected) {
383
+ if (preselected) return preselected;
384
+ const spaces = verify.spaces ?? [];
385
+ if (spaces.length <= 1) return verify.space?.id ?? spaces[0]?.id;
386
+ if (!isInteractive()) {
387
+ const choices = spaces.map((space) => `${space.name} [${space.id}]`).join(", ");
388
+ throw new Error(
389
+ `You belong to ${spaces.length} spaces; pass --space-id <id> to choose one. Available spaces: ${choices}`
390
+ );
391
+ }
392
+ say("");
393
+ say("Which space should this CLI target?");
394
+ spaces.forEach((space, index) => {
395
+ const marker = space.id === verify.space?.id ? " (default)" : "";
396
+ say(` ${index + 1}. ${space.name}${marker} [${space.id}]`);
397
+ });
398
+ const answer = await ask(`Choose 1-${spaces.length} (Enter for default): `);
399
+ if (!answer) return verify.space?.id ?? spaces[0]?.id;
400
+ const choice = Number(answer);
401
+ if (Number.isInteger(choice) && choice >= 1 && choice <= spaces.length) {
402
+ return spaces[choice - 1]?.id;
403
+ }
404
+ throw new Error("Not a valid space choice. Re-run login and choose one of the listed spaces.");
405
+ }
406
+ async function chooseTarget() {
407
+ const cloud = normalizeBaseUrl(DEFAULT_BASE_URL);
408
+ say("Busabase is an approval-first database and knowledge base for AI agents.");
409
+ say("Agents propose changes; humans review and merge what becomes trusted data.");
410
+ say("This login connects the CLI to the Busabase instance you want to use.");
411
+ say("");
412
+ say("How should this CLI connect?");
413
+ say(" 1. Local/Desktop on this computer \u2014 no account, no login");
414
+ say(" Use when you run `busabase server` or the Busabase Desktop app locally.");
415
+ say(" 2. Busabase Cloud \u2014 browser sign-in (recommended)");
416
+ say(" Best for humans: opens the browser and saves a refreshable CLI session.");
417
+ say(" 3. Busabase Cloud \u2014 paste an API key");
418
+ say(" Best for CI, servers, or agents where a browser is not available.");
419
+ say(" 4. Self-hosted Busabase \u2014 browser sign-in");
420
+ say(" Use your team's Busabase URL when it supports OAuth login.");
421
+ say(" 5. Self-hosted Busabase \u2014 paste an API key");
422
+ say(" Use your team's Busabase URL with a long-lived key for automation.");
423
+ const choice = await ask("Choose 1-5 [2]: ");
424
+ const askSelfHostedUrl = async () => {
425
+ const url = await ask("Self-hosted base URL (e.g. https://busabase.example.com): ");
426
+ if (!url) throw new Error("A self-hosted base URL is required.");
427
+ return url;
428
+ };
429
+ switch (choice) {
430
+ case "1": {
431
+ const url = await ask(`Local server URL [${DEFAULT_LOCAL_URL}]: `) || DEFAULT_LOCAL_URL;
432
+ return { baseUrl: url, method: "none" };
433
+ }
434
+ case "3":
435
+ return { baseUrl: cloud, method: "api-key" };
436
+ case "4":
437
+ return { baseUrl: await askSelfHostedUrl(), method: "oauth" };
438
+ case "5":
439
+ return { baseUrl: await askSelfHostedUrl(), method: "api-key" };
440
+ default:
441
+ return { baseUrl: cloud, method: "oauth" };
442
+ }
443
+ }
444
+ async function runLogin(options) {
445
+ let baseUrl = normalizeBaseUrl(options.baseUrl);
446
+ let apiKey = options.apiKey;
447
+ let method;
448
+ if (apiKey) {
449
+ method = "api-key";
450
+ } else if (options.oauth) {
451
+ method = "oauth";
452
+ } else if (isInteractive()) {
453
+ const target = await chooseTarget();
454
+ baseUrl = normalizeBaseUrl(target.baseUrl);
455
+ method = target.method;
456
+ } else {
457
+ method = isLocalHost(baseUrl) ? "none" : "oauth";
458
+ }
459
+ if (method === "none") {
460
+ if (await probeAuthRequired(baseUrl)) {
461
+ throw new Error(
462
+ `${baseUrl} requires sign-in. Re-run \`busabase-cli login\` and pick a Cloud or self-hosted option (OAuth or API key).`
463
+ );
464
+ }
465
+ writeDotEnvFile({
466
+ BUSABASE_BASE_URL: baseUrl,
467
+ BUSABASE_API_KEY: null,
468
+ BUSABASE_SPACE_ID: null,
469
+ [EXPIRES_AT_KEY]: null
470
+ });
471
+ say(`\u2713 Connected to ${baseUrl} \u2014 open server, no login needed.`);
472
+ say(` Saved to ${dotEnvPath()}. Try: busabase-cli bases list`);
473
+ return { status: "connected (no auth)", baseUrl, config: dotEnvPath() };
474
+ }
475
+ let token;
476
+ let expiresAt;
477
+ if (method === "oauth") {
478
+ const result = await oauthLogin(baseUrl, options.browser);
479
+ token = result.token;
480
+ expiresAt = result.expiresAt;
481
+ } else {
482
+ if (!apiKey) {
483
+ say("Create a key in the dashboard \u2192 Settings \u2192 API Keys (shown once).");
484
+ apiKey = await ask("Paste your API key (sk_\u2026): ");
485
+ }
486
+ if (!apiKey) throw new Error("No API key provided.");
487
+ token = apiKey;
488
+ }
489
+ say("Verifying\u2026");
490
+ const verify = await verifyAuth(baseUrl, token);
491
+ const spaceId = await pickSpaceId(verify, options.spaceId);
492
+ writeDotEnvFile({
493
+ BUSABASE_BASE_URL: baseUrl,
494
+ BUSABASE_API_KEY: token,
495
+ BUSABASE_SPACE_ID: spaceId ?? null,
496
+ // Clear any stale expiry when switching to an API key.
497
+ [EXPIRES_AT_KEY]: expiresAt ?? null
498
+ });
499
+ say("");
500
+ say(`\u2713 Signed in and saved to ${dotEnvPath()}`);
501
+ return {
502
+ status: "signed in",
503
+ method,
504
+ user: verify.user?.email ?? verify.user?.name ?? verify.user?.id ?? "(unknown)",
505
+ space: spaceId ?? "(server default)",
506
+ expiresAt: expiresAt ?? "(no expiry \u2014 API key)",
507
+ baseUrl,
508
+ config: dotEnvPath()
509
+ };
510
+ }
511
+ async function runRefresh(options) {
512
+ const baseUrl = normalizeBaseUrl(options.baseUrl);
513
+ const token = options.apiKey;
514
+ if (!token) throw new Error("Not signed in \u2014 run `busabase-cli login` first.");
515
+ if (!token.startsWith(SESSION_TOKEN_PREFIX)) {
516
+ return {
517
+ status: "nothing to refresh",
518
+ detail: "This credential is an API key, which doesn't expire. Only OAuth sessions refresh."
519
+ };
520
+ }
521
+ let refreshed;
522
+ try {
523
+ refreshed = await refreshToken(baseUrl, token);
524
+ } catch (error) {
525
+ if (error instanceof Error && error.message === "SESSION_EXPIRED") {
526
+ throw new Error(
527
+ "Your login session has expired \u2014 run `busabase-cli login` to sign in again."
528
+ );
529
+ }
530
+ throw error;
531
+ }
532
+ writeDotEnvFile({
533
+ BUSABASE_API_KEY: refreshed.token,
534
+ [EXPIRES_AT_KEY]: refreshed.expiresAt ?? null
535
+ });
536
+ say(`\u2713 Session refreshed${refreshed.expiresAt ? ` \u2014 valid until ${refreshed.expiresAt}` : ""}`);
537
+ return {
538
+ status: "refreshed",
539
+ expiresAt: refreshed.expiresAt ?? "(unknown)",
540
+ config: dotEnvPath()
541
+ };
542
+ }
543
+ async function maybeAutoRefresh(baseUrl, apiKey) {
544
+ if (!apiKey?.startsWith(SESSION_TOKEN_PREFIX)) return;
545
+ const file = loadDotEnvFile();
546
+ if (file.BUSABASE_API_KEY !== apiKey) return;
547
+ const expiresRaw = file[EXPIRES_AT_KEY];
548
+ if (!expiresRaw) return;
549
+ const expiresAt = Date.parse(expiresRaw);
550
+ if (Number.isNaN(expiresAt)) return;
551
+ if (expiresAt - Date.now() > AUTO_REFRESH_THRESHOLD_MS) return;
552
+ try {
553
+ const refreshed = await refreshToken(normalizeBaseUrl(baseUrl), apiKey);
554
+ writeDotEnvFile({
555
+ BUSABASE_API_KEY: refreshed.token,
556
+ [EXPIRES_AT_KEY]: refreshed.expiresAt ?? null
557
+ });
558
+ } catch {
559
+ }
560
+ }
561
+ async function runLogout(options) {
562
+ const baseUrl = normalizeBaseUrl(options.baseUrl);
563
+ const token = options.apiKey;
564
+ let revoked = "no session to revoke";
565
+ if (token?.startsWith("bss_")) {
566
+ try {
567
+ const res = await fetch(`${baseUrl}/api/oauth/revoke`, {
568
+ method: "POST",
569
+ headers: { authorization: `Bearer ${token}` }
570
+ });
571
+ revoked = res.ok ? "session revoked" : `revoke returned HTTP ${res.status}`;
572
+ } catch {
573
+ revoked = "revoke request failed (cleared locally anyway)";
574
+ }
575
+ } else if (token) {
576
+ revoked = "API key cleared locally (revoke it in the dashboard to disable it)";
577
+ }
578
+ writeDotEnvFile({ BUSABASE_API_KEY: null, BUSABASE_SPACE_ID: null, [EXPIRES_AT_KEY]: null });
579
+ say(`\u2713 Cleared the saved credential from ${dotEnvPath()}`);
580
+ return { status: "signed out", detail: revoked, config: dotEnvPath() };
581
+ }
582
+
583
+ // src/package/commands.ts
584
+ import { readFile } from "fs/promises";
585
+ import { join as join3 } from "path";
586
+
587
+ // ../../packages/busabase-contract/src/domains/package/types.ts
588
+ import { z as z6 } from "zod";
589
+
590
+ // ../../packages/openlib/i18n/i-string.ts
591
+ import { z as z2 } from "zod";
592
+
593
+ // ../../packages/openlib/i18n/i18n.ts
594
+ import { z } from "zod";
595
+ var i18n = {
596
+ defaultLocale: "en",
597
+ locales: ["en", "zh-CN", "zh-TW", "ja", "ko", "de", "fr", "es", "pt"],
598
+ extendLocales: ["en", "zh-CN", "zh-TW", "ja", "ko", "fr", "de", "es", "ru", "it", "vi", "pt"]
599
+ };
600
+ var LocaleSchema = z.enum(i18n.locales);
601
+
602
+ // ../../packages/openlib/i18n/i-string.ts
603
+ var iStringRecordSchema = z2.partialRecord(LocaleSchema, z2.string());
604
+ var iStringSchema = z2.union([z2.string(), iStringRecordSchema]).describe("i18n string");
605
+
606
+ // ../../packages/busabase-contract/src/domains/base/contract/base-schemas.ts
607
+ import { z as z3 } from "zod";
608
+ var fieldNameSchema = z3.union([
609
+ z3.string().min(1),
610
+ iStringRecordSchema.refine(
611
+ (record) => Object.values(record).some((value) => value && value.trim().length > 0),
612
+ "field name must have at least one non-empty locale value"
613
+ )
614
+ ]);
615
+ var fieldTypeSchema = z3.enum([
616
+ "text",
617
+ "longtext",
618
+ "markdown",
619
+ "html",
620
+ "attachment",
621
+ "relation",
622
+ "number",
623
+ "date",
624
+ "checkbox",
625
+ "select",
626
+ "multiselect",
627
+ "url",
628
+ "embed",
629
+ "email",
630
+ "phone",
631
+ "created_time",
632
+ "updated_time",
633
+ "created_by",
634
+ "updated_by",
635
+ "auto_number",
636
+ "ai_summary",
637
+ "ai_tags",
638
+ "code",
639
+ "json",
640
+ "yaml"
641
+ ]);
642
+ var fieldOptionsSchema = z3.object({
643
+ ai: z3.object({
644
+ model: z3.string().optional(),
645
+ prompt: z3.string().optional(),
646
+ reviewRequired: z3.boolean().optional(),
647
+ sourceFieldIds: z3.array(z3.string()).optional()
648
+ }).optional(),
649
+ // Per-field config for `attachment` columns (all optional; logic enforces a
650
+ // 25MB ceiling regardless).
651
+ attachment: z3.object({
652
+ maxFiles: z3.number().int().positive().optional(),
653
+ allowedMimeTypes: z3.array(z3.string()).optional(),
654
+ maxFileSize: z3.number().int().positive().optional()
655
+ }).optional(),
656
+ choices: z3.array(
657
+ z3.object({
658
+ color: z3.string().optional(),
659
+ id: z3.string(),
660
+ name: z3.string()
661
+ })
662
+ ).optional(),
663
+ code: z3.object({
664
+ language: z3.string().optional()
665
+ }).optional(),
666
+ embed: z3.object({
667
+ aspectRatio: z3.enum(["16:9", "4:3", "1:1"]).optional(),
668
+ height: z3.number().int().positive().max(1200).optional(),
669
+ providers: z3.array(z3.string()).optional()
670
+ }).optional(),
671
+ inverseFieldId: z3.string().optional(),
672
+ multiple: z3.boolean().optional(),
673
+ // Display formatting for `number` columns (Notion-style: one number type,
674
+ // a format option). `currency` renders via Intl.NumberFormat.
675
+ number: z3.object({
676
+ format: z3.enum(["plain", "currency"]).optional(),
677
+ currency: z3.string().optional(),
678
+ locale: z3.string().optional()
679
+ }).optional(),
680
+ targetBaseId: z3.string().optional().describe("Relation target Base id (bse_\u2026). Or pass targetBaseSlug to name it by slug."),
681
+ targetBaseSlug: z3.string().optional().describe(
682
+ "Relation target Base by slug \u2014 a convenience alias for targetBaseId, resolved server-side (active bases in the current space). If both are given, targetBaseId wins."
683
+ )
684
+ }).default({});
685
+ var baseFieldSchema = z3.object({
686
+ id: z3.string(),
687
+ baseId: z3.string(),
688
+ slug: z3.string(),
689
+ name: fieldNameSchema,
690
+ type: fieldTypeSchema,
691
+ required: z3.boolean(),
692
+ position: z3.number(),
693
+ options: fieldOptionsSchema
694
+ });
695
+ var baseSchema = z3.object({
696
+ id: z3.string(),
697
+ nodeId: z3.string(),
698
+ slug: z3.string(),
699
+ name: z3.string(),
700
+ description: z3.string(),
701
+ reviewPolicy: z3.object({
702
+ kind: z3.literal("single"),
703
+ requiredApprovals: z3.number()
704
+ }),
705
+ createdAt: z3.string(),
706
+ fields: z3.array(baseFieldSchema)
707
+ });
708
+ var createBaseInputSchema = z3.object({
709
+ parentNodeId: z3.string().optional().describe(
710
+ "Parent node id. Must be a folder or the space root; container-incapable node types (Base, Doc, AirApp, etc.) cannot hold children."
711
+ ),
712
+ slug: z3.string().min(1).regex(/^[a-z0-9-]+$/),
713
+ name: z3.string().min(1),
714
+ description: z3.string().optional().default(""),
715
+ fields: z3.array(
716
+ z3.object({
717
+ slug: z3.string().min(1),
718
+ name: fieldNameSchema,
719
+ type: fieldTypeSchema.default("text"),
720
+ required: z3.boolean().default(false),
721
+ options: fieldOptionsSchema.optional().default({})
722
+ })
723
+ ).default([]),
724
+ // Review-first by default: without `autoMerge: true`, this proposes the Base
725
+ // as a pending ChangeRequest (status "in_review") instead of creating it
726
+ // immediately. Pass `autoMerge: true` only for callers that don't need human
727
+ // review (seed/migration scripts, an explicit no-review agent task).
728
+ autoMerge: z3.boolean().optional().default(false)
729
+ });
730
+ var createBaseFieldInputSchema = z3.object({
731
+ name: fieldNameSchema,
732
+ // Field slugs are snake_case identifiers (e.g. `cover_image`, `publish_date`) — the
733
+ // seed and the inline-fields path on `POST /bases` already allow underscores, so the
734
+ // add-field endpoint must too. (Base/folder/view slugs stay kebab-case: they go in URLs.)
735
+ slug: z3.string().min(1).regex(/^[a-z0-9_-]+$/),
736
+ type: fieldTypeSchema.default("text"),
737
+ required: z3.boolean().optional().default(false),
738
+ options: fieldOptionsSchema.optional().default({})
739
+ });
740
+ var createFieldChangeRequestInputSchema = createBaseFieldInputSchema.extend({
741
+ message: z3.string().optional().default("Add field"),
742
+ submittedBy: z3.string().optional().default("local-editor")
743
+ });
744
+ var deleteFieldChangeRequestInputSchema = z3.object({
745
+ fieldId: z3.string().min(1),
746
+ message: z3.string().optional(),
747
+ submittedBy: z3.string().optional().default("local-editor")
748
+ });
749
+ var updateFieldChangeRequestInputSchema = z3.object({
750
+ fieldId: z3.string().min(1),
751
+ patch: z3.object({
752
+ name: fieldNameSchema.optional(),
753
+ required: z3.boolean().optional(),
754
+ options: fieldOptionsSchema.optional()
755
+ }),
756
+ message: z3.string().optional(),
757
+ submittedBy: z3.string().optional().default("local-editor")
758
+ });
759
+ var previewFieldConversionInputSchema = z3.object({
760
+ fieldId: z3.string().min(1),
761
+ newType: fieldTypeSchema
762
+ });
763
+ var previewFieldConversionOutputSchema = z3.object({
764
+ totalCount: z3.number(),
765
+ convertibleCount: z3.number(),
766
+ nullCount: z3.number(),
767
+ conflicts: z3.array(z3.object({ recordId: z3.string(), currentValue: z3.unknown() }))
768
+ });
769
+ var convertFieldChangeRequestInputSchema = z3.object({
770
+ fieldId: z3.string().min(1),
771
+ newType: fieldTypeSchema,
772
+ selectChoiceMode: z3.enum(["auto_create", "null_on_missing"]).default("null_on_missing"),
773
+ message: z3.string().optional(),
774
+ submittedBy: z3.string().optional().default("local-editor")
775
+ });
776
+ var reorderFieldsChangeRequestInputSchema = z3.object({
777
+ fieldIds: z3.array(z3.string()).min(1),
778
+ message: z3.string().optional(),
779
+ submittedBy: z3.string().optional().default("local-editor")
780
+ });
781
+ var archiveBaseInputSchema = z3.object({
782
+ message: z3.string().optional(),
783
+ submittedBy: z3.string().optional().default("local-editor")
784
+ });
785
+ var restoreBaseInputSchema = z3.object({
786
+ message: z3.string().optional(),
787
+ submittedBy: z3.string().optional().default("local-editor")
788
+ });
789
+ var restoreFieldChangeRequestInputSchema = z3.object({
790
+ fieldId: z3.string().min(1),
791
+ message: z3.string().optional(),
792
+ submittedBy: z3.string().optional().default("local-editor")
793
+ });
794
+
795
+ // ../../packages/busabase-contract/src/domains/base/contract/view-schemas.ts
796
+ import { z as z5 } from "zod";
797
+
798
+ // ../../packages/busabase-contract/src/contract/schemas.ts
799
+ import { z as z4 } from "zod";
800
+
801
+ // ../../packages/busabase-contract/src/domains/filetree/definition.ts
802
+ var fileTreeOperations = (type) => [
803
+ {
804
+ kind: `${type}_file_create`,
805
+ label: "Create file",
806
+ tone: "border-emerald-200 bg-emerald-50 text-emerald-800"
807
+ },
808
+ {
809
+ kind: `${type}_file_update`,
810
+ label: "Update file",
811
+ tone: "border-blue-200 bg-blue-50 text-blue-800"
812
+ },
813
+ {
814
+ kind: `${type}_file_delete`,
815
+ label: "Delete file",
816
+ tone: "border-rose-200 bg-rose-50 text-rose-800"
817
+ },
818
+ {
819
+ kind: `${type}_metadata_update`,
820
+ label: `Update ${type}`,
821
+ tone: "border-violet-200 bg-violet-50 text-violet-800"
822
+ }
823
+ ];
824
+ var makeFileTreeNodeType = (config) => ({
825
+ type: config.type,
826
+ label: config.label,
827
+ icon: config.icon,
828
+ capabilities: { hasDetail: true, creatable: true },
829
+ operations: fileTreeOperations(config.type)
830
+ });
831
+
832
+ // ../../packages/busabase-contract/src/domains/airapp/definition.ts
833
+ var airappNodeType = makeFileTreeNodeType({
834
+ type: "airapp",
835
+ label: "AirApp",
836
+ icon: "app-window",
837
+ routeBase: "airapps",
838
+ tag: "AirApps",
839
+ entryFile: "package.json"
840
+ });
841
+
842
+ // ../../packages/busabase-contract/src/domains/base/definition.ts
843
+ var baseNodeType = {
844
+ type: "base",
845
+ label: "Base",
846
+ icon: "table",
847
+ capabilities: { hasDetail: true, creatable: true },
848
+ operations: [
849
+ {
850
+ kind: "record_create",
851
+ label: "Create",
852
+ tone: "border-emerald-200 bg-emerald-50 text-emerald-800"
853
+ },
854
+ { kind: "record_update", label: "Update", tone: "border-sky-200 bg-sky-50 text-sky-800" },
855
+ { kind: "record_delete", label: "Delete", tone: "border-red-200 bg-red-50 text-red-800" },
856
+ {
857
+ kind: "record_variant",
858
+ label: "Variant",
859
+ tone: "border-violet-200 bg-violet-50 text-violet-800"
860
+ },
861
+ {
862
+ kind: "view_create",
863
+ label: "Create view",
864
+ tone: "border-indigo-200 bg-indigo-50 text-indigo-800"
865
+ },
866
+ { kind: "view_update", label: "Update view", tone: "border-blue-200 bg-blue-50 text-blue-800" },
867
+ { kind: "view_delete", label: "Delete view", tone: "border-rose-200 bg-rose-50 text-rose-800" },
868
+ {
869
+ kind: "view_restore",
870
+ label: "Restore view",
871
+ tone: "border-emerald-200 bg-emerald-50 text-emerald-800"
872
+ },
873
+ {
874
+ kind: "base_add_field",
875
+ label: "Add field",
876
+ tone: "border-emerald-200 bg-emerald-50 text-emerald-800"
877
+ },
878
+ {
879
+ kind: "base_delete_field",
880
+ label: "Delete field",
881
+ tone: "border-red-200 bg-red-50 text-red-800"
882
+ },
883
+ {
884
+ kind: "base_update_field",
885
+ label: "Update field",
886
+ tone: "border-sky-200 bg-sky-50 text-sky-800"
887
+ },
888
+ {
889
+ kind: "base_convert_field",
890
+ label: "Convert field",
891
+ tone: "border-violet-200 bg-violet-50 text-violet-800"
892
+ },
893
+ {
894
+ kind: "base_reorder_fields",
895
+ label: "Reorder fields",
896
+ tone: "border-indigo-200 bg-indigo-50 text-indigo-800"
897
+ },
898
+ {
899
+ kind: "base_restore_field",
900
+ label: "Restore field",
901
+ tone: "border-teal-200 bg-teal-50 text-teal-800"
902
+ },
903
+ {
904
+ kind: "base_archive",
905
+ label: "Archive base",
906
+ tone: "border-orange-200 bg-orange-50 text-orange-800"
907
+ },
908
+ {
909
+ kind: "base_restore",
910
+ label: "Restore base",
911
+ tone: "border-teal-200 bg-teal-50 text-teal-800"
912
+ },
913
+ {
914
+ kind: "record_restore",
915
+ label: "Restore record",
916
+ tone: "border-teal-200 bg-teal-50 text-teal-800"
917
+ }
918
+ ]
919
+ };
920
+
921
+ // ../../packages/busabase-contract/src/domains/doc/definition.ts
922
+ var docNodeType = {
923
+ type: "doc",
924
+ label: "Doc",
925
+ icon: "file-text",
926
+ capabilities: { hasDetail: true, creatable: true },
927
+ operations: [
928
+ {
929
+ kind: "doc_update",
930
+ label: "Update doc",
931
+ tone: "border-blue-200 bg-blue-50 text-blue-800"
932
+ }
933
+ ]
934
+ };
935
+
936
+ // ../../packages/busabase-contract/src/domains/drive/definition.ts
937
+ var driveNodeType = makeFileTreeNodeType({
938
+ type: "drive",
939
+ label: "Drive",
940
+ icon: "hard-drive",
941
+ routeBase: "drives",
942
+ tag: "Drives",
943
+ entryFile: "README.md"
944
+ });
945
+
946
+ // ../../packages/busabase-contract/src/domains/file-node/definition.ts
947
+ var fileNodeType = {
948
+ type: "file",
949
+ label: "File",
950
+ icon: "file",
951
+ capabilities: { hasDetail: true, creatable: true },
952
+ operations: []
953
+ };
954
+
955
+ // ../../packages/busabase-contract/src/domains/folder/definition.ts
956
+ var folderNodeType = {
957
+ type: "folder",
958
+ label: "Folder",
959
+ icon: "folder",
960
+ capabilities: { container: true, creatable: true, hasDetail: true },
961
+ operations: []
962
+ };
963
+
964
+ // ../../packages/busabase-contract/src/domains/skill/definition.ts
965
+ var skillNodeType = makeFileTreeNodeType({
966
+ type: "skill",
967
+ label: "Skill",
968
+ icon: "sparkles",
969
+ routeBase: "skills",
970
+ tag: "Skills",
971
+ entryFile: "SKILL.md"
972
+ });
973
+
974
+ // ../../packages/busabase-contract/src/domains/registry.ts
975
+ var GENERIC_NODE_OPERATIONS = [
976
+ {
977
+ kind: "node_create",
978
+ label: "Create node",
979
+ tone: "border-emerald-200 bg-emerald-50 text-emerald-800"
980
+ },
981
+ { kind: "node_rename", label: "Rename node", tone: "border-sky-200 bg-sky-50 text-sky-800" },
982
+ { kind: "node_delete", label: "Delete node", tone: "border-red-200 bg-red-50 text-red-800" },
983
+ {
984
+ kind: "node_restore",
985
+ label: "Restore node",
986
+ tone: "border-emerald-200 bg-emerald-50 text-emerald-800"
987
+ },
988
+ { kind: "node_move", label: "Move node", tone: "border-indigo-200 bg-indigo-50 text-indigo-800" }
989
+ ];
990
+ var registry = /* @__PURE__ */ new Map();
991
+ function registerNodeType(definition) {
992
+ registry.set(definition.type, definition);
993
+ }
994
+ var BUILTIN_NODE_TYPES = [
995
+ folderNodeType,
996
+ baseNodeType,
997
+ skillNodeType,
998
+ driveNodeType,
999
+ airappNodeType,
1000
+ fileNodeType,
1001
+ docNodeType
1002
+ ];
1003
+ for (const definition of BUILTIN_NODE_TYPES) {
1004
+ registerNodeType(definition);
1005
+ }
1006
+ var NODE_TYPES = BUILTIN_NODE_TYPES.map((definition) => definition.type);
1007
+ var CREATABLE_NODE_TYPES = BUILTIN_NODE_TYPES.filter(
1008
+ (definition) => Boolean(definition.capabilities.creatable)
1009
+ ).map((definition) => definition.type);
1010
+ var ALL_OPERATIONS = [
1011
+ ...GENERIC_NODE_OPERATIONS,
1012
+ ...BUILTIN_NODE_TYPES.flatMap(
1013
+ (definition) => definition.operations
1014
+ )
1015
+ ];
1016
+ var OPERATION_KINDS = ALL_OPERATIONS.map((operation) => operation.kind);
1017
+ var GENERIC_NODE_OPERATION_KINDS = GENERIC_NODE_OPERATIONS.map(
1018
+ (operation) => operation.kind
1019
+ );
1020
+ var OPERATION_META = Object.fromEntries(
1021
+ ALL_OPERATIONS.map((operation) => [
1022
+ operation.kind,
1023
+ { label: operation.label, tone: operation.tone }
1024
+ ])
1025
+ );
1026
+
1027
+ // ../../packages/busabase-contract/src/contract/schemas.ts
1028
+ var nodeSchema = z4.lazy(
1029
+ () => z4.object({
1030
+ id: z4.string(),
1031
+ parentId: z4.string().nullable(),
1032
+ type: z4.enum(NODE_TYPES),
1033
+ slug: z4.string(),
1034
+ name: z4.string(),
1035
+ description: z4.string(),
1036
+ metadata: z4.object({
1037
+ entryFile: z4.string().optional(),
1038
+ visibility: z4.enum(["private", "workspace", "public"]).optional(),
1039
+ version: z4.string().optional(),
1040
+ assetId: z4.string().optional()
1041
+ }).default({}),
1042
+ position: z4.number(),
1043
+ createdAt: z4.string(),
1044
+ updatedAt: z4.string(),
1045
+ baseId: z4.string().nullable(),
1046
+ children: z4.array(nodeSchema),
1047
+ hasChildren: z4.boolean().optional()
1048
+ })
1049
+ );
1050
+ var nodePrincipalSchema = z4.object({
1051
+ id: z4.string(),
1052
+ nodeId: z4.string(),
1053
+ principalType: z4.enum(["user", "team", "space"]),
1054
+ principalId: z4.string(),
1055
+ role: z4.enum(["read", "changeRequest", "write", "manage"]),
1056
+ grantedBy: z4.string(),
1057
+ createdAt: z4.string(),
1058
+ updatedAt: z4.string()
1059
+ });
1060
+ var listNodesInputSchema = z4.object({
1061
+ parentId: z4.string().nullable().optional().describe("Node to start from. Omit or null to start from the space root."),
1062
+ depth: z4.coerce.number().int().min(1).max(5).optional().describe(
1063
+ "How many levels beneath the start point to eagerly include (default 2 once either field is set). Capped at 5."
1064
+ )
1065
+ }).optional();
1066
+ var isDescendantInputSchema = z4.object({
1067
+ nodeId: z4.string(),
1068
+ potentialAncestorId: z4.string()
1069
+ });
1070
+ var isDescendantOutputSchema = z4.object({
1071
+ isDescendant: z4.boolean()
1072
+ });
1073
+ var userRefSchema = z4.object({
1074
+ id: z4.string(),
1075
+ name: z4.string().nullable(),
1076
+ email: z4.string().nullable(),
1077
+ image: z4.string().nullable(),
1078
+ role: z4.string().nullable().optional()
1079
+ });
1080
+ var commitSchema = z4.object({
1081
+ id: z4.string(),
1082
+ baseId: z4.string().nullable(),
1083
+ targetType: z4.enum(["base", "node"]),
1084
+ nodeId: z4.string().nullable(),
1085
+ operationId: z4.string().nullable(),
1086
+ parentCommitId: z4.string().nullable(),
1087
+ fields: z4.record(z4.string(), z4.unknown()),
1088
+ operation: z4.enum(OPERATION_KINDS),
1089
+ message: z4.string(),
1090
+ author: z4.string(),
1091
+ authorUser: userRefSchema.nullable().optional().default(null),
1092
+ createdAt: z4.string()
1093
+ });
1094
+ var operationSchema = z4.object({
1095
+ id: z4.string(),
1096
+ changeRequestId: z4.string(),
1097
+ baseId: z4.string().nullable(),
1098
+ targetType: z4.enum(["base", "node"]),
1099
+ nodeId: z4.string().nullable(),
1100
+ operation: z4.enum(OPERATION_KINDS),
1101
+ status: z4.enum(["pending", "merged", "archived", "failed"]),
1102
+ targetRecordId: z4.string().nullable(),
1103
+ targetViewId: z4.string().nullable(),
1104
+ filePath: z4.string().nullable(),
1105
+ sourceRecordId: z4.string().nullable(),
1106
+ sourceCommitId: z4.string().nullable(),
1107
+ baseCommitId: z4.string().nullable(),
1108
+ headCommitId: z4.string(),
1109
+ deleteMode: z4.enum(["archive"]),
1110
+ mergedRecordId: z4.string().nullable(),
1111
+ mergedViewId: z4.string().nullable(),
1112
+ position: z4.number(),
1113
+ createdAt: z4.string(),
1114
+ updatedAt: z4.string(),
1115
+ headCommit: commitSchema,
1116
+ // Resolved canonical "before" values for the operation's target, so the UI can
1117
+ // render a true before → after field diff. Null for creations (no prior state)
1118
+ // and for kinds whose prior state isn't a field map (e.g. skill files, whose
1119
+ // previous content lives in storage). Records resolve from the base commit;
1120
+ // views resolve from the current view row ({ name, description, config }).
1121
+ baseFields: z4.record(z4.string(), z4.unknown()).nullable()
1122
+ });
1123
+ var reviewSchema = z4.object({
1124
+ id: z4.string(),
1125
+ changeRequestId: z4.string(),
1126
+ reviewerId: z4.string(),
1127
+ reviewer: userRefSchema.nullable().optional().default(null),
1128
+ verdict: z4.enum(["approved", "rejected"]),
1129
+ reason: z4.string().nullable(),
1130
+ visibleOperationHeads: z4.record(z4.string(), z4.string()),
1131
+ createdAt: z4.string()
1132
+ });
1133
+ var commentSubjectTypeSchema = z4.enum(["record", "change_request", "operation", "commit"]);
1134
+ var commentSchema = z4.object({
1135
+ id: z4.string(),
1136
+ subjectType: commentSubjectTypeSchema,
1137
+ subjectId: z4.string(),
1138
+ recordId: z4.string().nullable(),
1139
+ changeRequestId: z4.string().nullable(),
1140
+ operationId: z4.string().nullable(),
1141
+ commitId: z4.string().nullable(),
1142
+ authorId: z4.string(),
1143
+ author: userRefSchema.nullable().optional().default(null),
1144
+ body: z4.string(),
1145
+ mentionsAi: z4.boolean(),
1146
+ createdAt: z4.string(),
1147
+ updatedAt: z4.string()
1148
+ });
1149
+ var changeRequestStatusSchema = z4.enum([
1150
+ "in_review",
1151
+ "changes_requested",
1152
+ "approved",
1153
+ "rejected",
1154
+ "merged",
1155
+ "abandoned",
1156
+ "conflict"
1157
+ ]);
1158
+ var changeRequestSchema = z4.object({
1159
+ id: z4.string(),
1160
+ baseId: z4.string().nullable(),
1161
+ targetType: z4.enum(["base", "node"]),
1162
+ nodeId: z4.string().nullable(),
1163
+ status: changeRequestStatusSchema,
1164
+ submittedBy: z4.string(),
1165
+ submittedByUser: userRefSchema.nullable().optional().default(null),
1166
+ sourceMeta: z4.record(z4.string(), z4.unknown()),
1167
+ reviewPolicySnapshot: z4.record(z4.string(), z4.unknown()),
1168
+ mergeSummary: z4.record(z4.string(), z4.unknown()),
1169
+ rejectedReason: z4.string().nullable(),
1170
+ reviewedAt: z4.string().nullable(),
1171
+ mergedAt: z4.string().nullable(),
1172
+ createdAt: z4.string(),
1173
+ updatedAt: z4.string(),
1174
+ base: baseSchema.nullable(),
1175
+ node: nodeSchema.nullable(),
1176
+ operations: z4.array(operationSchema),
1177
+ primaryOperation: operationSchema.nullable(),
1178
+ operationCount: z4.number(),
1179
+ reviews: z4.array(reviewSchema)
1180
+ });
1181
+ var agentTaskSchema = z4.object({
1182
+ changeRequest: changeRequestSchema,
1183
+ trigger: z4.enum(["changes_requested", "ai_mention"]),
1184
+ reviewReason: z4.string().nullable(),
1185
+ aiComments: z4.array(commentSchema)
1186
+ });
1187
+ var searchResultSchema = z4.object({
1188
+ id: z4.string(),
1189
+ kind: z4.enum(["record", "change_request", "base", "file"]),
1190
+ title: z4.string(),
1191
+ body: z4.string(),
1192
+ eyebrow: z4.string(),
1193
+ href: z4.string(),
1194
+ updatedAt: z4.string().nullable()
1195
+ });
1196
+ var searchResponseSchema = z4.object({
1197
+ query: z4.string(),
1198
+ limit: z4.number(),
1199
+ offset: z4.number(),
1200
+ hasMore: z4.boolean(),
1201
+ results: z4.array(searchResultSchema)
1202
+ });
1203
+ var liveEventSchema = z4.object({
1204
+ kind: z4.enum([
1205
+ "change_request.created",
1206
+ "change_request.updated",
1207
+ "change_request.deleted",
1208
+ "change_request.reviewed",
1209
+ "change_request.merged",
1210
+ // Fired only when a CONTENT change request freshly enters human review
1211
+ // (record_* ops created via record-ops.ts) — never for structural ops
1212
+ // that auto-merge instantly (those still fire "change_request.created"
1213
+ // via the audit funnel, but nothing needs reviewing). Consumed by
1214
+ // `use-live-sync.ts` to pop a desktop Notification, and by
1215
+ // busabase-cloud's host hook to persist an inbox notification row.
1216
+ "change_request.pending_review"
1217
+ ]),
1218
+ spaceId: z4.string(),
1219
+ actorId: z4.string(),
1220
+ changeRequestId: z4.string(),
1221
+ baseId: z4.string().nullable(),
1222
+ nodeIds: z4.array(z4.string()),
1223
+ recordIds: z4.array(z4.string()),
1224
+ viewIds: z4.array(z4.string()),
1225
+ operationCount: z4.number()
1226
+ });
1227
+ var auditActionSchema = z4.enum([
1228
+ "record.viewed",
1229
+ "change_request.created",
1230
+ "change_request.updated",
1231
+ "change_request.deleted",
1232
+ "change_request.reviewed",
1233
+ "change_request.merged",
1234
+ // Direct (non-change-request) mutations — recorded so the audit trail stays
1235
+ // complete even for operations that bypass the propose → review → merge flow
1236
+ // (container bootstrap, direct edits, library/asset deletes, trash purge).
1237
+ "base.created",
1238
+ "field.created",
1239
+ "doc.created",
1240
+ "doc.updated",
1241
+ "file.created",
1242
+ "skill.created",
1243
+ "drive.created",
1244
+ "airapp.created",
1245
+ "asset.deleted",
1246
+ "asset.metadata_updated",
1247
+ "asset.text_written",
1248
+ "asset.text_marked_none",
1249
+ "node.purged"
1250
+ ]);
1251
+ var auditEventSchema = z4.object({
1252
+ id: z4.string(),
1253
+ action: auditActionSchema,
1254
+ actorId: z4.string(),
1255
+ actor: userRefSchema.nullable().optional().default(null),
1256
+ baseId: z4.string().nullable(),
1257
+ recordId: z4.string().nullable(),
1258
+ changeRequestId: z4.string().nullable(),
1259
+ operationId: z4.string().nullable(),
1260
+ commitId: z4.string().nullable(),
1261
+ metadata: z4.record(z4.string(), z4.unknown()),
1262
+ createdAt: z4.string()
1263
+ });
1264
+ var createAuditEventInputSchema = z4.object({
1265
+ action: auditActionSchema,
1266
+ actorId: z4.string().optional().default("local-viewer"),
1267
+ baseId: z4.string().optional().nullable(),
1268
+ recordId: z4.string().optional().nullable(),
1269
+ changeRequestId: z4.string().optional().nullable(),
1270
+ operationId: z4.string().optional().nullable(),
1271
+ commitId: z4.string().optional().nullable(),
1272
+ metadata: z4.record(z4.string(), z4.unknown()).optional().default({})
1273
+ });
1274
+ var nodeOperationInputSchema = z4.discriminatedUnion("kind", [
1275
+ z4.object({
1276
+ kind: z4.literal("create"),
1277
+ ref: z4.string().min(1).optional().describe(
1278
+ 'Optional in-change-request temp id for this node. A later operation can set parentNodeRef to this value to nest under it \u2014 e.g. create a folder with ref "growth", then create Bases with parentNodeRef "growth", all in one change request.'
1279
+ ),
1280
+ parentNodeId: z4.string().optional(),
1281
+ parentNodeRef: z4.string().min(1).optional().describe(
1282
+ "Parent this node under a node an EARLIER operation in the same change request created (matched by its ref). Mutually exclusive with parentNodeId."
1283
+ ),
1284
+ nodeType: z4.enum(CREATABLE_NODE_TYPES),
1285
+ slug: z4.string().min(1).regex(/^[a-z0-9-]+$/),
1286
+ name: z4.string().min(1),
1287
+ description: z4.string().optional().default(""),
1288
+ metadata: z4.record(z4.string(), z4.unknown()).optional().default({}),
1289
+ // Base fields (nodeType "base" only); a base needs at least one field.
1290
+ fields: z4.array(
1291
+ z4.object({
1292
+ slug: z4.string().min(1).regex(/^[a-z0-9-]+$/),
1293
+ name: fieldNameSchema,
1294
+ type: fieldTypeSchema.default("text"),
1295
+ required: z4.boolean().optional().default(false),
1296
+ options: fieldOptionsSchema.optional().default({})
1297
+ })
1298
+ ).optional()
1299
+ }),
1300
+ z4.object({
1301
+ kind: z4.literal("rename"),
1302
+ nodeId: z4.string(),
1303
+ slug: z4.string().min(1).regex(/^[a-z0-9-]+$/).optional(),
1304
+ name: z4.string().min(1).optional(),
1305
+ description: z4.string().optional()
1306
+ }),
1307
+ z4.object({
1308
+ kind: z4.literal("delete"),
1309
+ nodeId: z4.string()
1310
+ }),
1311
+ z4.object({
1312
+ kind: z4.literal("restore"),
1313
+ nodeId: z4.string()
1314
+ }),
1315
+ z4.object({
1316
+ kind: z4.literal("move"),
1317
+ nodeId: z4.string(),
1318
+ // Exactly one of parentNodeId / parentNodeRef (a ref created earlier in this CR).
1319
+ parentNodeId: z4.string().optional(),
1320
+ parentNodeRef: z4.string().min(1).optional(),
1321
+ position: z4.number().int().optional()
1322
+ })
1323
+ ]);
1324
+ var createNodeChangeRequestInputSchema = z4.object({
1325
+ message: z4.string().optional().default("Update node tree").describe(
1326
+ 'Explanation shown to the human reviewer. Write a conventional-commit style subject \u2014 imperative verb + what + why, e.g. "Reorganize marketing docs under a Campaigns folder".'
1327
+ ),
1328
+ submittedBy: z4.string().optional().default("local-producer"),
1329
+ autoMerge: z4.boolean().optional().default(false).describe(
1330
+ "Whether to approve and merge this structural node change immediately. Defaults to false so node Change Request creation is review-first unless explicitly requested."
1331
+ ),
1332
+ operations: z4.array(nodeOperationInputSchema).min(1)
1333
+ });
1334
+ var moveNodeInputSchema = z4.object({
1335
+ nodeId: z4.string(),
1336
+ parentNodeId: z4.string().optional().describe("New parent folder node id. Omit to keep the current parent and only reorder."),
1337
+ position: z4.number().int().optional().describe("New position among the target parent's children."),
1338
+ message: z4.string().optional().describe("Reviewer-facing Change Request message."),
1339
+ submittedBy: z4.string().optional()
1340
+ });
1341
+ var createDeleteChangeRequestInputSchema = z4.object({
1342
+ message: z4.string().optional().default("Delete record").describe(
1343
+ 'Explanation shown to the human reviewer. Say what is being removed and why, e.g. "Archive duplicate contact \u2014 merged into Acme Corp".'
1344
+ ),
1345
+ submittedBy: z4.string().optional().default("local-producer"),
1346
+ // Only "archive" is supported — hard delete after retention was never
1347
+ // implemented, so the API no longer accepts it (breaking change).
1348
+ deleteMode: z4.enum(["archive"]).optional().default("archive")
1349
+ });
1350
+ var reviseOperationInputSchema = z4.object({
1351
+ fields: z4.record(z4.string(), z4.unknown()).describe(
1352
+ "Updated field values keyed by field slug. If you set the base's PRIMARY field (its first field), keep it a short human-readable name \u2014 it is the record's display title everywhere."
1353
+ ),
1354
+ message: z4.string().optional().default("Revise operation").describe(
1355
+ 'Explanation shown to the human reviewer. Write a conventional-commit style subject \u2014 imperative verb + what + why, e.g. "Update deal stage to qualified \u2014 demo booked for July 8".'
1356
+ ),
1357
+ author: z4.string().optional().default("local-producer"),
1358
+ baseCommitId: z4.string().optional()
1359
+ });
1360
+ var reviewChangeRequestInputSchema = z4.object({
1361
+ verdict: z4.enum(["approved", "rejected"]),
1362
+ reason: z4.string().optional()
1363
+ });
1364
+ var commentSubjectInputSchema = z4.object({
1365
+ subjectType: commentSubjectTypeSchema,
1366
+ subjectId: z4.string().min(1)
1367
+ });
1368
+ var createCommentInputSchema = commentSubjectInputSchema.extend({
1369
+ authorId: z4.string().optional().default("local-admin"),
1370
+ body: z4.string().trim().min(1),
1371
+ mentionsAi: z4.boolean().optional().default(false)
1372
+ });
1373
+ var listInputSchema = z4.object({
1374
+ limit: z4.coerce.number().int().min(1).max(100).optional().default(50)
1375
+ }).optional().default({ limit: 50 });
1376
+ var listChangeRequestsPagedInputSchema = z4.object({
1377
+ limit: z4.coerce.number().int().min(1).max(100).optional().default(50),
1378
+ /** Opaque base64 cursor (`createdAt|id`) for keyset pagination. */
1379
+ cursor: z4.string().optional(),
1380
+ status: z4.array(changeRequestStatusSchema).optional(),
1381
+ mine: z4.boolean().optional()
1382
+ }).optional().default({ limit: 50 });
1383
+ var listChangeRequestsResponseSchema = z4.object({
1384
+ changeRequests: z4.array(changeRequestSchema),
1385
+ nextCursor: z4.string().nullable()
1386
+ });
1387
+ var changeRequestCountsSchema = z4.object({
1388
+ review: z4.number().int().nonnegative(),
1389
+ changes: z4.number().int().nonnegative(),
1390
+ created: z4.number().int().nonnegative(),
1391
+ approved: z4.number().int().nonnegative(),
1392
+ merged: z4.number().int().nonnegative(),
1393
+ rejected: z4.number().int().nonnegative()
1394
+ });
1395
+ var SEARCH_SOURCES = ["records", "files", "names"];
1396
+ var searchInputSchema = z4.object({
1397
+ query: z4.string().default(""),
1398
+ limit: z4.coerce.number().int().min(1).max(100).optional().default(20),
1399
+ offset: z4.coerce.number().int().min(0).optional().default(0),
1400
+ /**
1401
+ * Restrict which content this call searches. Omitted means all three
1402
+ * (unchanged behavior for every caller before this parameter existed).
1403
+ *
1404
+ * A GET query param that occurs exactly once (`?sources=records`) arrives
1405
+ * as a bare string, not a 1-element array — only a REPEATED occurrence
1406
+ * (`?sources=records&sources=files`) becomes an array. Accept both shapes
1407
+ * and normalize to an array.
1408
+ */
1409
+ sources: z4.union([z4.array(z4.enum(SEARCH_SOURCES)), z4.enum(SEARCH_SOURCES)]).transform((value) => Array.isArray(value) ? value : [value]).optional()
1410
+ });
1411
+ var authSpaceSchema = z4.object({
1412
+ id: z4.string(),
1413
+ name: z4.string(),
1414
+ slug: z4.string().nullable(),
1415
+ plan: z4.string().nullable()
1416
+ });
1417
+ var authUserSchema = z4.object({
1418
+ id: z4.string(),
1419
+ name: z4.string(),
1420
+ email: z4.string().nullable(),
1421
+ image: z4.string().nullable()
1422
+ });
1423
+ var authMemberSchema = z4.object({
1424
+ userId: z4.string(),
1425
+ spaceId: z4.string(),
1426
+ role: z4.string()
1427
+ });
1428
+ var authInfoSchema = z4.object({
1429
+ /** The space this request is scoped to (explicit `x-busabase-space` header, else the default). */
1430
+ space: authSpaceSchema,
1431
+ user: authUserSchema,
1432
+ member: authMemberSchema,
1433
+ /**
1434
+ * Every space the authenticated user belongs to. An API key is user-scoped, not
1435
+ * space-scoped — when this has more than one entry, callers should confirm the
1436
+ * intended space and target it explicitly via the `x-busabase-space` header.
1437
+ */
1438
+ spaces: z4.array(authSpaceSchema)
1439
+ });
1440
+
1441
+ // ../../packages/busabase-contract/src/domains/base/contract/view-schemas.ts
1442
+ var viewFilterOperatorSchema = z5.enum([
1443
+ "contains",
1444
+ "equals",
1445
+ "not_empty",
1446
+ "is_empty",
1447
+ "is_true",
1448
+ "is_false"
1449
+ ]);
1450
+ var viewFilterSchema = z5.object({
1451
+ fieldSlug: z5.string(),
1452
+ // Stable field identity — survives slug reuse; populated on merge.
1453
+ fieldId: z5.string().optional(),
1454
+ operator: viewFilterOperatorSchema,
1455
+ value: z5.unknown().optional()
1456
+ });
1457
+ var viewSortSchema = z5.object({
1458
+ direction: z5.enum(["asc", "desc"]),
1459
+ fieldSlug: z5.string(),
1460
+ fieldId: z5.string().optional()
1461
+ });
1462
+ var viewTypeSchema = z5.enum(["table", "gallery", "kanban", "calendar", "gantt"]);
1463
+ var ganttScaleSchema = z5.enum(["week", "month"]);
1464
+ var galleryCoverFitSchema = z5.enum(["cover", "fit"]);
1465
+ var galleryCardSizeSchema = z5.enum(["small", "medium", "large"]);
1466
+ var viewConfigSchema = z5.object({
1467
+ filters: z5.array(viewFilterSchema).default([]),
1468
+ sorts: z5.array(viewSortSchema).default([]),
1469
+ visibleFieldSlugs: z5.array(z5.string()).nullable().optional(),
1470
+ // ── Gallery-only presentation config (ignored by table views) ──
1471
+ // Which attachment field supplies the cover image (null = no cover).
1472
+ coverFieldSlug: z5.string().nullable().optional(),
1473
+ coverFit: galleryCoverFitSchema.optional(),
1474
+ cardSize: galleryCardSizeSchema.optional(),
1475
+ // Whether to render the field label above each value on a card.
1476
+ showFieldLabels: z5.boolean().optional(),
1477
+ // ── Kanban-only: which single-select field stacks records into columns.
1478
+ stackByFieldSlug: z5.string().nullable().optional(),
1479
+ // ── Calendar-only: which date field positions records on the month grid.
1480
+ dateFieldSlug: z5.string().nullable().optional(),
1481
+ // ── Gantt-only: the start/end date fields bounding each record's bar, plus
1482
+ // the time-axis granularity.
1483
+ startFieldSlug: z5.string().nullable().optional(),
1484
+ endFieldSlug: z5.string().nullable().optional(),
1485
+ ganttScale: ganttScaleSchema.optional()
1486
+ });
1487
+ var viewSchema = z5.object({
1488
+ id: z5.string(),
1489
+ baseId: z5.string(),
1490
+ slug: z5.string(),
1491
+ name: z5.string(),
1492
+ description: z5.string(),
1493
+ type: viewTypeSchema,
1494
+ config: viewConfigSchema,
1495
+ status: z5.enum(["active", "archived"]),
1496
+ createdBy: z5.string(),
1497
+ createdByUser: userRefSchema.nullable().optional().default(null),
1498
+ archivedAt: z5.string().nullable(),
1499
+ createdAt: z5.string(),
1500
+ updatedAt: z5.string()
1501
+ });
1502
+ var createViewInputSchema = z5.object({
1503
+ config: viewConfigSchema.optional().default({ filters: [], sorts: [] }),
1504
+ description: z5.string().optional().default(""),
1505
+ message: z5.string().optional().default("Create view"),
1506
+ name: z5.string().min(1),
1507
+ type: viewTypeSchema.optional().default("table"),
1508
+ slug: z5.string().min(1).regex(/^[a-z0-9-]+$/).optional(),
1509
+ submittedBy: z5.string().optional().default("local-producer")
1510
+ });
1511
+ var updateViewInputSchema = z5.object({
1512
+ config: viewConfigSchema.optional(),
1513
+ description: z5.string().optional(),
1514
+ message: z5.string().optional().default("Update view"),
1515
+ name: z5.string().min(1).optional(),
1516
+ type: viewTypeSchema.optional(),
1517
+ submittedBy: z5.string().optional().default("local-producer")
1518
+ });
1519
+ var deleteViewInputSchema = z5.object({
1520
+ message: z5.string().optional().default("Delete view"),
1521
+ submittedBy: z5.string().optional().default("local-producer")
1522
+ });
1523
+ var restoreViewInputSchema = z5.object({
1524
+ message: z5.string().optional().default("Restore view"),
1525
+ submittedBy: z5.string().optional().default("local-producer")
1526
+ });
1527
+
1528
+ // ../../packages/busabase-contract/src/domains/package/types.ts
1529
+ var PACKAGE_FORMAT = "busabase-package@1";
1530
+ var PACKAGE_MANIFEST_FILENAME = "busabase.json";
1531
+ var PACKAGE_CONTENT_DIRNAME = "content";
1532
+ var PACKAGE_NODE_META_FILENAME = "_node.json";
1533
+ var PACKAGE_FOLDER_META_FILENAME = "_folder.json";
1534
+ var PACKAGE_BASE_FILENAME = "base.json";
1535
+ var PACKAGE_RECORDS_FILENAME = "records.ndjson";
1536
+ var PACKAGE_NODE_META_SUFFIX = ".node.json";
1537
+ var PACKAGE_FILE_TREE_NODE_TYPES = ["skill", "airapp", "drive"];
1538
+ var PACKAGE_AI_FIELD_TYPES = ["ai_summary", "ai_tags"];
1539
+ var PACKAGE_COMPUTED_FIELD_TYPES = [
1540
+ "created_time",
1541
+ "updated_time",
1542
+ "created_by",
1543
+ "updated_by",
1544
+ "auto_number",
1545
+ ...PACKAGE_AI_FIELD_TYPES
1546
+ ];
1547
+ var PACKAGE_DEFERRED_FIELD_TYPES = [
1548
+ "relation",
1549
+ ...PACKAGE_AI_FIELD_TYPES
1550
+ ];
1551
+ var PACKAGE_MAX_FILE_BYTES = 25 * 1024 * 1024;
1552
+ var PACKAGE_MAX_TOTAL_BYTES = 250 * 1024 * 1024;
1553
+ var PACKAGE_MAX_FILE_COUNT = 5e3;
1554
+ var PACKAGE_MAX_RECORDS_PER_BASE = 5e4;
1555
+ var PackageManifestSchema = z6.object({
1556
+ format: z6.literal(PACKAGE_FORMAT),
1557
+ /** Default `--into-folder` name. Not the repo name — a repo can host many packages. */
1558
+ name: z6.string().min(1),
1559
+ description: z6.string().default(""),
1560
+ /**
1561
+ * Informational / reserved for a future marketplace index. The real version pin
1562
+ * is always the git ref in the install URL.
1563
+ */
1564
+ version: z6.string().optional(),
1565
+ author: z6.string().optional(),
1566
+ license: z6.string().optional(),
1567
+ homepage: z6.string().url().optional(),
1568
+ tags: z6.array(z6.string()).default([])
1569
+ });
1570
+ var PackageFileTreeNodeMetaSchema = z6.object({
1571
+ type: z6.enum(PACKAGE_FILE_TREE_NODE_TYPES),
1572
+ name: z6.string().min(1),
1573
+ description: z6.string().default(""),
1574
+ position: z6.number().int().optional()
1575
+ });
1576
+ var PackageFolderMetaSchema = z6.object({
1577
+ /** Defaults to the humanized slug when absent. */
1578
+ name: z6.string().optional(),
1579
+ description: z6.string().default(""),
1580
+ position: z6.number().int().optional()
1581
+ });
1582
+ var PackageFileNodeMetaSchema = PackageFolderMetaSchema;
1583
+ var PackageDocFrontmatterSchema = z6.object({
1584
+ name: z6.string().min(1),
1585
+ description: z6.string().default(""),
1586
+ position: z6.number().int().optional()
1587
+ });
1588
+ var PackageFieldOptionsSchema = z6.object({
1589
+ ai: z6.object({
1590
+ model: z6.string().optional(),
1591
+ prompt: z6.string().optional(),
1592
+ reviewRequired: z6.boolean().optional(),
1593
+ /** Package-only — replaces `ai.sourceFieldIds`. */
1594
+ sourceFieldSlugs: z6.array(z6.string()).optional()
1595
+ }).optional(),
1596
+ attachment: z6.object({
1597
+ maxFiles: z6.number().int().positive().optional(),
1598
+ allowedMimeTypes: z6.array(z6.string()).optional(),
1599
+ maxFileSize: z6.number().int().positive().optional()
1600
+ }).optional(),
1601
+ choices: z6.array(
1602
+ z6.object({
1603
+ color: z6.string().optional(),
1604
+ id: z6.string(),
1605
+ name: z6.string()
1606
+ })
1607
+ ).optional(),
1608
+ code: z6.object({
1609
+ language: z6.string().optional()
1610
+ }).optional(),
1611
+ embed: z6.object({
1612
+ aspectRatio: z6.enum(["16:9", "4:3", "1:1"]).optional(),
1613
+ height: z6.number().int().positive().max(1200).optional(),
1614
+ providers: z6.array(z6.string()).optional()
1615
+ }).optional(),
1616
+ /** Package-only — replaces `inverseFieldId`. */
1617
+ inverseFieldSlug: z6.string().optional(),
1618
+ multiple: z6.boolean().optional(),
1619
+ number: z6.object({
1620
+ format: z6.enum(["plain", "currency"]).optional(),
1621
+ currency: z6.string().optional(),
1622
+ locale: z6.string().optional()
1623
+ }).optional(),
1624
+ /** Native API alias — the server resolves it to `targetBaseId`. */
1625
+ targetBaseSlug: z6.string().optional()
1626
+ }).default({});
1627
+ var PackageBaseFieldSchema = z6.object({
1628
+ slug: z6.string().min(1),
1629
+ name: fieldNameSchema,
1630
+ type: fieldTypeSchema,
1631
+ required: z6.boolean().default(false),
1632
+ position: z6.number().int(),
1633
+ options: PackageFieldOptionsSchema
1634
+ });
1635
+ var PackageViewFilterSchema = z6.object({
1636
+ fieldSlug: z6.string().min(1),
1637
+ operator: viewFilterOperatorSchema,
1638
+ value: z6.unknown().optional()
1639
+ });
1640
+ var PackageViewSortSchema = z6.object({
1641
+ direction: z6.enum(["asc", "desc"]),
1642
+ fieldSlug: z6.string().min(1)
1643
+ });
1644
+ var PackageViewConfigSchema = z6.object({
1645
+ filters: z6.array(PackageViewFilterSchema).default([]),
1646
+ sorts: z6.array(PackageViewSortSchema).default([]),
1647
+ visibleFieldSlugs: z6.array(z6.string()).nullable().optional()
1648
+ });
1649
+ var PackageViewSchema = z6.object({
1650
+ slug: z6.string().min(1),
1651
+ name: z6.string().min(1),
1652
+ description: z6.string().default(""),
1653
+ type: z6.literal("table").default("table"),
1654
+ config: PackageViewConfigSchema.default({ filters: [], sorts: [] })
1655
+ });
1656
+ var PackageBaseSchema = z6.object({
1657
+ name: z6.string().min(1),
1658
+ description: z6.string().default(""),
1659
+ position: z6.number().int().optional(),
1660
+ /**
1661
+ * Informational only — `reviewPolicy` is NOT settable via `bases.create`, so
1662
+ * install cannot apply it and warns when it differs from the target's default.
1663
+ */
1664
+ reviewPolicy: z6.object({
1665
+ kind: z6.literal("single"),
1666
+ requiredApprovals: z6.number()
1667
+ }).optional(),
1668
+ fields: z6.array(PackageBaseFieldSchema).default([]),
1669
+ views: z6.array(PackageViewSchema).default([])
1670
+ });
1671
+ var PackageRecordLineSchema = z6.object({
1672
+ key: z6.string().min(1),
1673
+ fields: z6.record(z6.string(), z6.unknown()).default({})
1674
+ });
1675
+
1676
+ // src/package/apply.ts
1677
+ import { createHash as createHash2 } from "crypto";
1678
+
1679
+ // src/package/tree.ts
1680
+ import { extname } from "path";
1681
+ var NODE_SLUG_PATTERN = /^[a-z0-9-]+$/;
1682
+ var assertSafeNodeSlug = (slug, context) => {
1683
+ if (!NODE_SLUG_PATTERN.test(slug)) {
1684
+ throw new Error(
1685
+ `Invalid slug "${slug}" (${context}). Slugs must be lowercase letters, digits, and hyphens only (a-z, 0-9, -) \u2014 that keeps them safe as file names on every OS and valid for the Busabase API. Rename it, e.g. "${suggestSlug(slug)}".`
1686
+ );
1687
+ }
1688
+ };
1689
+ var suggestSlug = (value) => {
1690
+ const suggestion = value.normalize("NFKD").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
1691
+ return suggestion || "renamed";
1692
+ };
1693
+ var UNSAFE_PATH_CHARS = /[/\\:*?"<>|]/;
1694
+ var assertSafeFilePath = (filePath, context) => {
1695
+ for (const segment of filePath.split("/")) {
1696
+ if (!segment) {
1697
+ throw new Error(`Invalid file path "${filePath}" (${context}): empty path segment.`);
1698
+ }
1699
+ if (UNSAFE_PATH_CHARS.test(segment)) {
1700
+ throw new Error(
1701
+ `Invalid file path "${filePath}" (${context}): the segment "${segment}" contains a character that is illegal in a file name on Windows or macOS (/ \\ : * ? " < > |). Rename the file.`
1702
+ );
1703
+ }
1704
+ if (/^[. ]|[. ]$/.test(segment)) {
1705
+ throw new Error(
1706
+ `Invalid file path "${filePath}" (${context}): the segment "${segment}" starts or ends with a dot or space, which does not survive a checkout on every OS. Rename the file.`
1707
+ );
1708
+ }
1709
+ }
1710
+ };
1711
+ var assertNoSiblingCaseCollisions = (names, context) => {
1712
+ const seenByLower = /* @__PURE__ */ new Map();
1713
+ for (const name of names) {
1714
+ const lower = name.toLowerCase();
1715
+ const existing = seenByLower.get(lower);
1716
+ if (existing !== void 0 && existing !== name) {
1717
+ throw new Error(
1718
+ `Sibling name collision (${context}): "${existing}" and "${name}" differ only by case. macOS and Windows treat them as the same file, so one would silently overwrite the other on checkout. Rename one.`
1719
+ );
1720
+ }
1721
+ seenByLower.set(lower, name);
1722
+ }
1723
+ };
1724
+ var isReservedPackageFilename = (name) => name === PACKAGE_MANIFEST_FILENAME || name === PACKAGE_NODE_META_FILENAME || name === PACKAGE_FOLDER_META_FILENAME || name === PACKAGE_BASE_FILENAME || name === PACKAGE_RECORDS_FILENAME || name.endsWith(PACKAGE_NODE_META_SUFFIX);
1725
+ var assertNoReservedNames = (filePaths, context) => {
1726
+ for (const filePath of filePaths) {
1727
+ const [rootName, ...rest] = filePath.split("/");
1728
+ if (rest.length === 0 && isReservedPackageFilename(rootName)) {
1729
+ throw new Error(
1730
+ `Reserved file name at the root of ${context}: "${rootName}" is part of the package format, so it cannot also be this node's own content. Rename it (e.g. "${renameReserved(rootName)}"), or move it into a subdirectory \u2014 names are only interpreted at the node's root.`
1731
+ );
1732
+ }
1733
+ }
1734
+ };
1735
+ var renameReserved = (name) => {
1736
+ const ext = extname(name);
1737
+ return ext ? `${name.slice(0, -ext.length)}-file${ext}` : `${name}-file`;
1738
+ };
1739
+ var compareNodes = (a, b) => {
1740
+ if (a.position !== void 0 && b.position !== void 0 && a.position !== b.position) {
1741
+ return a.position - b.position;
1742
+ }
1743
+ if (a.position !== void 0 && b.position === void 0) return -1;
1744
+ if (a.position === void 0 && b.position !== void 0) return 1;
1745
+ return a.slug.localeCompare(b.slug, "en");
1746
+ };
1747
+ var sortNodes = (nodes) => [...nodes].sort(compareNodes);
1748
+ var humanizeSlug = (slug) => slug.split("-").filter(Boolean).map((word) => word.charAt(0).toUpperCase() + word.slice(1)).join(" ");
1749
+ var MIME_BY_EXT = {
1750
+ ".css": "text/css",
1751
+ ".csv": "text/csv",
1752
+ ".gif": "image/gif",
1753
+ ".htm": "text/html",
1754
+ ".html": "text/html",
1755
+ ".jpeg": "image/jpeg",
1756
+ ".jpg": "image/jpeg",
1757
+ ".js": "text/javascript",
1758
+ ".json": "application/json",
1759
+ ".md": "text/markdown",
1760
+ ".pdf": "application/pdf",
1761
+ ".png": "image/png",
1762
+ ".py": "text/x-python",
1763
+ ".svg": "image/svg+xml",
1764
+ ".ts": "text/typescript",
1765
+ ".txt": "text/plain",
1766
+ ".webp": "image/webp",
1767
+ ".yaml": "text/yaml",
1768
+ ".yml": "text/yaml"
1769
+ };
1770
+ var guessMimeType = (filePath) => MIME_BY_EXT[extname(filePath).toLowerCase()] ?? "application/octet-stream";
1771
+ var isTextMimeType = (mimeType) => mimeType.startsWith("text/") || mimeType === "application/json" || mimeType === "image/svg+xml";
1772
+ var walkNodes = function* (nodes) {
1773
+ for (const node of nodes) {
1774
+ yield node;
1775
+ if (node.type === "folder") yield* walkNodes(node.children);
1776
+ }
1777
+ };
1778
+ var collectBaseNodes = (nodes) => [...walkNodes(nodes)].filter((node) => node.type === "base");
1779
+
1780
+ // src/package/apply.ts
1781
+ var RECORD_BATCH_SIZE = 200;
1782
+ var fieldKey = (baseSlug, fieldSlug) => `${baseSlug}\0${fieldSlug}`;
1783
+ var applyInstall = async (client, plan, options) => {
1784
+ const state = {
1785
+ targetFolderNodeId: "",
1786
+ created: { folders: 0, docs: 0, bases: 0, views: 0, records: 0, fileTreeNodes: 0, files: 0 },
1787
+ pendingChangeRequests: 0,
1788
+ warnings: [...plan.warnings]
1789
+ };
1790
+ const fieldIds = /* @__PURE__ */ new Map();
1791
+ const bases = [];
1792
+ const progress = options.onProgress ?? (() => {
1793
+ });
1794
+ progress("Pass 1/5: creating the node tree\u2026");
1795
+ state.targetFolderNodeId = await createFolderNode(client, {
1796
+ parentNodeId: void 0,
1797
+ slug: plan.targetFolderSlug,
1798
+ name: plan.tree.manifest.name,
1799
+ description: plan.tree.manifest.description,
1800
+ submittedBy: options.submittedBy
1801
+ });
1802
+ state.created.folders++;
1803
+ await createStructure(
1804
+ client,
1805
+ plan.tree.nodes,
1806
+ state.targetFolderNodeId,
1807
+ options,
1808
+ state,
1809
+ bases,
1810
+ fieldIds
1811
+ );
1812
+ const deferred = bases.filter((base) => base.node.base.fields.some(isDeferredField));
1813
+ if (deferred.length > 0) {
1814
+ progress("Pass 2/5: adding relation and AI fields\u2026");
1815
+ for (const base of deferred) {
1816
+ for (const field of base.node.base.fields.filter(isDeferredField)) {
1817
+ const updated = await client.bases.createField({
1818
+ baseId: base.baseId,
1819
+ slug: field.slug,
1820
+ name: field.name,
1821
+ type: field.type,
1822
+ required: field.required,
1823
+ options: toApiFieldOptions(field.options)
1824
+ });
1825
+ indexFields(fieldIds, base.node.slug, updated.fields);
1826
+ }
1827
+ }
1828
+ }
1829
+ const patchable = bases.flatMap(
1830
+ (base) => base.node.base.fields.filter(needsFieldIdPatch).map((field) => ({ base, field }))
1831
+ );
1832
+ if (patchable.length > 0) {
1833
+ progress("Pass 3/5: resolving inverse and AI source field references\u2026");
1834
+ for (const { base, field } of patchable) {
1835
+ const fieldId = fieldIds.get(fieldKey(base.node.slug, field.slug));
1836
+ if (!fieldId) {
1837
+ throw new Error(
1838
+ `Internal error: no field id for ${base.node.slug}.${field.slug} in pass 3.`
1839
+ );
1840
+ }
1841
+ const patched = resolveFieldIdOptions(field, base.node.slug, fieldIds, state.warnings);
1842
+ if (!patched) continue;
1843
+ const changeRequest = await client.bases.updateFieldChangeRequest({
1844
+ baseId: base.baseId,
1845
+ fieldId,
1846
+ patch: { options: patched },
1847
+ message: `Wire up ${base.node.slug}.${field.slug} references`,
1848
+ submittedBy: options.submittedBy
1849
+ });
1850
+ await approveAndMerge(client, changeRequest.id);
1851
+ }
1852
+ }
1853
+ const recordIdsByKey = /* @__PURE__ */ new Map();
1854
+ const withRecords = bases.filter((base) => base.node.records.length > 0);
1855
+ if (withRecords.length > 0) {
1856
+ progress("Pass 4/5: creating records\u2026");
1857
+ for (const base of withRecords) {
1858
+ await createRecords(client, base, plan, options, state, recordIdsByKey);
1859
+ }
1860
+ }
1861
+ const relationFieldSlugs = /* @__PURE__ */ new Map();
1862
+ for (const base of bases) {
1863
+ const slugs = base.node.base.fields.filter((field) => field.type === "relation").map((field) => field.slug);
1864
+ if (slugs.length > 0) relationFieldSlugs.set(base.node.slug, slugs);
1865
+ }
1866
+ if (relationFieldSlugs.size > 0) {
1867
+ progress("Pass 5/5: linking relations\u2026");
1868
+ for (const base of bases) {
1869
+ const slugs = relationFieldSlugs.get(base.node.slug);
1870
+ if (!slugs) continue;
1871
+ await linkRelations(client, base, slugs, recordIdsByKey, options, state);
1872
+ }
1873
+ }
1874
+ return state;
1875
+ };
1876
+ var createStructure = async (client, nodes, parentNodeId, options, state, bases, fieldIds) => {
1877
+ for (const node of nodes) {
1878
+ switch (node.type) {
1879
+ case "folder": {
1880
+ const nodeId = await createFolderNode(client, {
1881
+ parentNodeId,
1882
+ slug: node.slug,
1883
+ name: node.name,
1884
+ description: node.description,
1885
+ submittedBy: options.submittedBy
1886
+ });
1887
+ state.created.folders++;
1888
+ await createStructure(client, node.children, nodeId, options, state, bases, fieldIds);
1889
+ break;
1890
+ }
1891
+ case "doc": {
1892
+ const result = await client.docs.create({
1893
+ parentNodeId,
1894
+ slug: node.slug,
1895
+ name: node.name,
1896
+ description: node.description,
1897
+ body: node.body,
1898
+ autoMerge: options.autoMerge
1899
+ });
1900
+ if (result.materialized) state.created.docs++;
1901
+ else state.pendingChangeRequests++;
1902
+ break;
1903
+ }
1904
+ case "base": {
1905
+ await createBase(client, node, parentNodeId, options, state, bases, fieldIds);
1906
+ break;
1907
+ }
1908
+ case "skill":
1909
+ case "airapp":
1910
+ case "drive": {
1911
+ await createFileTreeNode(client, node, parentNodeId, options, state);
1912
+ break;
1913
+ }
1914
+ case "file": {
1915
+ let asset;
1916
+ try {
1917
+ asset = await uploadAsset(client, node.fileName, node.mimeType, node.bytes);
1918
+ } catch (error) {
1919
+ state.warnings.push(asSkippableUpload(error, node.fileName));
1920
+ break;
1921
+ }
1922
+ const result = await client.files.create({
1923
+ parentNodeId,
1924
+ slug: node.slug,
1925
+ name: node.name,
1926
+ description: node.description,
1927
+ assetId: asset.assetId,
1928
+ autoMerge: options.autoMerge
1929
+ });
1930
+ if (result.materialized) state.created.files++;
1931
+ else state.pendingChangeRequests++;
1932
+ break;
1933
+ }
1934
+ }
1935
+ }
1936
+ };
1937
+ var createFolderNode = async (client, input) => {
1938
+ const changeRequest = await client.nodes.createChangeRequest({
1939
+ message: `Install folder ${input.slug}`,
1940
+ submittedBy: input.submittedBy,
1941
+ autoMerge: true,
1942
+ operations: [
1943
+ {
1944
+ kind: "create",
1945
+ nodeType: "folder",
1946
+ slug: input.slug,
1947
+ name: input.name,
1948
+ description: input.description,
1949
+ parentNodeId: input.parentNodeId
1950
+ }
1951
+ ]
1952
+ });
1953
+ const nodeId = changeRequest.operations[0]?.nodeId;
1954
+ if (!nodeId) {
1955
+ throw new Error(
1956
+ `Folder "${input.slug}" was created but the server returned no node id, so nothing could be installed inside it.`
1957
+ );
1958
+ }
1959
+ return nodeId;
1960
+ };
1961
+ var createBase = async (client, node, parentNodeId, options, state, bases, fieldIds) => {
1962
+ const immediateFields = node.base.fields.filter((field) => !isDeferredField(field)).map((field) => ({
1963
+ slug: field.slug,
1964
+ name: field.name,
1965
+ type: field.type,
1966
+ required: field.required,
1967
+ options: toApiFieldOptions(field.options)
1968
+ }));
1969
+ const result = await client.bases.create({
1970
+ parentNodeId,
1971
+ slug: node.slug,
1972
+ name: node.name,
1973
+ description: node.description,
1974
+ fields: immediateFields,
1975
+ // A Base's schema is structure, so it is created immediately even on a
1976
+ // review-first install — the same reasoning as folders (see createFolderNode).
1977
+ // This is load-bearing, not a shortcut: a PENDING Base has no id, and without
1978
+ // an id there is nothing to hang views, field patches, or records on. When the
1979
+ // base was left pending, its records were dropped on the floor with no error —
1980
+ // the user merged the change request and got an empty Base. Materializing the
1981
+ // container is what lets the CONTENT (records, below) be proposed for review at
1982
+ // all, which is the part a reviewer actually wants to see.
1983
+ autoMerge: true
1984
+ });
1985
+ if (!result.materialized) {
1986
+ throw new Error(
1987
+ `Base "${node.slug}" was requested immediately but the server returned a pending change request; install cannot attach its views or records to it.`
1988
+ );
1989
+ }
1990
+ state.created.bases++;
1991
+ bases.push({ node, baseId: result.id });
1992
+ indexFields(fieldIds, node.slug, result.fields);
1993
+ for (const view of node.base.views) {
1994
+ const changeRequest = await client.bases.createViewChangeRequest({
1995
+ baseId: result.id,
1996
+ slug: view.slug,
1997
+ name: view.name,
1998
+ description: view.description,
1999
+ config: view.config,
2000
+ submittedBy: options.submittedBy
2001
+ });
2002
+ await approveAndMerge(client, changeRequest.id);
2003
+ state.created.views++;
2004
+ }
2005
+ };
2006
+ var createFileTreeNode = async (client, node, parentNodeId, options, state) => {
2007
+ const uploaded = await Promise.all(
2008
+ node.files.map(async (entry) => {
2009
+ const mimeType = guessMimeType(entry.path);
2010
+ if (isTextMimeType(mimeType)) {
2011
+ return { path: entry.path, content: entry.bytes.toString("utf8"), mimeType };
2012
+ }
2013
+ try {
2014
+ const asset = await uploadAsset(
2015
+ client,
2016
+ entry.path.split("/").at(-1) ?? entry.path,
2017
+ mimeType,
2018
+ entry.bytes
2019
+ );
2020
+ return { path: entry.path, assetId: asset.assetId, mimeType };
2021
+ } catch (error) {
2022
+ state.warnings.push(asSkippableUpload(error, `${node.slug}/${entry.path}`));
2023
+ return void 0;
2024
+ }
2025
+ })
2026
+ );
2027
+ const files = uploaded.filter((entry) => entry !== void 0);
2028
+ const api = node.type === "skill" ? client.skills : node.type === "airapp" ? client.airapps : client.drives;
2029
+ const result = await api.create({
2030
+ parentNodeId,
2031
+ slug: node.slug,
2032
+ name: node.name,
2033
+ description: node.description,
2034
+ files,
2035
+ // The package hands over a complete, self-contained node; "merge" would layer the
2036
+ // default scaffold underneath and leave stray unrelated files behind.
2037
+ mergeMode: "replace",
2038
+ autoMerge: options.autoMerge
2039
+ });
2040
+ if (result.materialized) {
2041
+ state.created.fileTreeNodes++;
2042
+ state.created.files += node.files.length;
2043
+ } else {
2044
+ state.pendingChangeRequests++;
2045
+ }
2046
+ };
2047
+ var UploadTargetUnsupportedError = class extends Error {
2048
+ };
2049
+ var uploadAsset = async (client, fileName, mimeType, bytes) => {
2050
+ const contentHash = `sha256:${createHash2("sha256").update(bytes).digest("hex")}`;
2051
+ const requested = await client.assets.createUploadUrl({
2052
+ fileName,
2053
+ mimeType,
2054
+ sizeBytes: bytes.byteLength,
2055
+ context: "record-field",
2056
+ contentHash
2057
+ });
2058
+ if (requested.duplicate) {
2059
+ const assetId2 = requested.assetId ?? requested.attachmentId;
2060
+ if (!assetId2)
2061
+ throw new Error(`Server reported "${fileName}" as a duplicate but returned no asset id.`);
2062
+ return { assetId: assetId2 };
2063
+ }
2064
+ if (!/^https?:\/\//.test(requested.uploadUrl)) {
2065
+ throw new UploadTargetUnsupportedError(
2066
+ `this server issued a non-absolute upload url (${requested.uploadUrl}), which only its own web UI knows how to use`
2067
+ );
2068
+ }
2069
+ const uploaded = await fetch(requested.uploadUrl, {
2070
+ method: "PUT",
2071
+ body: new Uint8Array(bytes),
2072
+ headers: { "content-type": mimeType }
2073
+ });
2074
+ if (!uploaded.ok) {
2075
+ throw new Error(`Upload of "${fileName}" failed (${uploaded.status} ${uploaded.statusText}).`);
2076
+ }
2077
+ const confirmed = await client.assets.confirm({
2078
+ storageKey: requested.storageKey,
2079
+ fileName,
2080
+ mimeType,
2081
+ sizeBytes: bytes.byteLength,
2082
+ context: "record-field",
2083
+ contentHash
2084
+ });
2085
+ const assetId = confirmed.assetId ?? confirmed.attachmentId;
2086
+ if (!assetId) throw new Error(`Upload of "${fileName}" was confirmed but returned no asset id.`);
2087
+ return { assetId };
2088
+ };
2089
+ var asSkippableUpload = (error, label) => {
2090
+ if (error instanceof UploadTargetUnsupportedError) {
2091
+ return `Skipped binary file "${label}": ${error.message}. Everything else in the package was installed.`;
2092
+ }
2093
+ throw error;
2094
+ };
2095
+ var isDeferredField = (field) => PACKAGE_DEFERRED_FIELD_TYPES.includes(field.type);
2096
+ var needsFieldIdPatch = (field) => Boolean(field.options.inverseFieldSlug) || Boolean(field.options.ai?.sourceFieldSlugs && field.options.ai.sourceFieldSlugs.length > 0);
2097
+ var toApiFieldOptions = (options) => {
2098
+ const { inverseFieldSlug: _inverseFieldSlug, ai, ...rest } = options;
2099
+ const apiOptions = { ...rest };
2100
+ if (ai) {
2101
+ const { sourceFieldSlugs: _sourceFieldSlugs, ...aiRest } = ai;
2102
+ apiOptions.ai = aiRest;
2103
+ }
2104
+ return apiOptions;
2105
+ };
2106
+ var resolveFieldIdOptions = (field, baseSlug, fieldIds, warnings) => {
2107
+ const options = toApiFieldOptions(field.options);
2108
+ let resolvedAny = false;
2109
+ const inverseFieldSlug = field.options.inverseFieldSlug;
2110
+ if (inverseFieldSlug) {
2111
+ const targetBaseSlug = field.options.targetBaseSlug;
2112
+ const inverseId = targetBaseSlug ? fieldIds.get(fieldKey(targetBaseSlug, inverseFieldSlug)) : void 0;
2113
+ if (inverseId) {
2114
+ options.inverseFieldId = inverseId;
2115
+ resolvedAny = true;
2116
+ } else {
2117
+ warnings.push(
2118
+ `Could not resolve the inverse field "${inverseFieldSlug}" of ${baseSlug}.${field.slug} in base "${targetBaseSlug ?? "?"}" \u2014 the relation is installed, but its inverse link is not set.`
2119
+ );
2120
+ }
2121
+ }
2122
+ const sourceFieldSlugs = field.options.ai?.sourceFieldSlugs;
2123
+ if (sourceFieldSlugs && sourceFieldSlugs.length > 0) {
2124
+ const sourceFieldIds = sourceFieldSlugs.map((slug) => fieldIds.get(fieldKey(baseSlug, slug)));
2125
+ const missing = sourceFieldSlugs.filter((_, index) => !sourceFieldIds[index]);
2126
+ if (missing.length > 0) {
2127
+ warnings.push(
2128
+ `Could not resolve AI source field(s) ${missing.map((slug) => `"${slug}"`).join(", ")} for ${baseSlug}.${field.slug} \u2014 the field is installed without them.`
2129
+ );
2130
+ }
2131
+ const resolved = sourceFieldIds.filter((id) => Boolean(id));
2132
+ if (resolved.length > 0) {
2133
+ const ai = options.ai ?? {};
2134
+ options.ai = { ...ai, sourceFieldIds: resolved };
2135
+ resolvedAny = true;
2136
+ }
2137
+ }
2138
+ return resolvedAny ? options : void 0;
2139
+ };
2140
+ var indexFields = (fieldIds, baseSlug, fields) => {
2141
+ for (const field of fields) fieldIds.set(fieldKey(baseSlug, field.slug), field.id);
2142
+ };
2143
+ var toCreatableFields = (node, fields) => {
2144
+ const typeBySlug = new Map(node.base.fields.map((field) => [field.slug, field.type]));
2145
+ const result = {};
2146
+ for (const [slug, value] of Object.entries(fields)) {
2147
+ const type = typeBySlug.get(slug);
2148
+ if (!type) continue;
2149
+ if (type === "relation") continue;
2150
+ if (PACKAGE_COMPUTED_FIELD_TYPES.includes(type)) continue;
2151
+ if (type === "attachment") continue;
2152
+ result[slug] = value;
2153
+ }
2154
+ return result;
2155
+ };
2156
+ var createRecords = async (client, base, plan, options, state, recordIdsByKey) => {
2157
+ const records = [...base.node.records].sort((a, b) => a.key.localeCompare(b.key, "en"));
2158
+ for (let offset = 0; offset < records.length; offset += RECORD_BATCH_SIZE) {
2159
+ const batch = records.slice(offset, offset + RECORD_BATCH_SIZE);
2160
+ const changeRequest = await client.bases.createBulkChangeRequest({
2161
+ baseId: base.baseId,
2162
+ records: batch.map((record) => toCreatableFields(base.node, record.fields)),
2163
+ message: `Install ${batch.length} record(s) into ${base.node.slug}`,
2164
+ submittedBy: options.submittedBy,
2165
+ idempotencyKey: batchIdempotencyKey(plan.tree.manifest.name, batch[0].key)
2166
+ });
2167
+ if (!options.autoMerge) {
2168
+ state.pendingChangeRequests++;
2169
+ continue;
2170
+ }
2171
+ const merged = await approveAndMerge(client, changeRequest.id);
2172
+ const created = merged.changeRequest.operations.filter((operation) => operation.operation === "record_create").sort((a, b) => a.position - b.position);
2173
+ for (const [index, record] of batch.entries()) {
2174
+ const recordId = created[index]?.mergedRecordId;
2175
+ if (recordId) recordIdsByKey.set(record.key, recordId);
2176
+ }
2177
+ state.created.records += batch.length;
2178
+ }
2179
+ };
2180
+ var batchIdempotencyKey = (packageName, firstRecordKey) => `pkg:${packageName}:${firstRecordKey}`;
2181
+ var linkRelations = async (client, base, relationFieldSlugs, recordIdsByKey, options, state) => {
2182
+ for (const record of base.node.records) {
2183
+ const relationValues = {};
2184
+ let hasRelation = false;
2185
+ for (const slug of relationFieldSlugs) {
2186
+ const value = record.fields[slug];
2187
+ const keys = toKeyArray(value);
2188
+ if (keys.length === 0) continue;
2189
+ const targetIds = keys.map((key) => recordIdsByKey.get(key)).filter((id) => Boolean(id));
2190
+ const unresolved = keys.filter((key) => !recordIdsByKey.has(key));
2191
+ if (unresolved.length > 0) {
2192
+ state.warnings.push(
2193
+ `Record "${record.key}" in base "${base.node.slug}" links field "${slug}" to ${unresolved.length} record(s) that are not in this package \u2014 those links were skipped.`
2194
+ );
2195
+ }
2196
+ if (targetIds.length > 0) {
2197
+ relationValues[slug] = targetIds;
2198
+ hasRelation = true;
2199
+ }
2200
+ }
2201
+ if (!hasRelation) continue;
2202
+ const recordId = recordIdsByKey.get(record.key);
2203
+ if (!recordId) continue;
2204
+ const changeRequest = await client.records.updateChangeRequest({
2205
+ recordId,
2206
+ fields: { ...toCreatableFields(base.node, record.fields), ...relationValues },
2207
+ message: `Link relations for ${base.node.slug}`,
2208
+ author: options.submittedBy
2209
+ });
2210
+ await approveAndMerge(client, changeRequest.id);
2211
+ }
2212
+ };
2213
+ var toKeyArray = (value) => {
2214
+ if (Array.isArray(value))
2215
+ return value.filter((item) => typeof item === "string" && item.length > 0);
2216
+ if (typeof value === "string" && value.length > 0) return [value];
2217
+ return [];
2218
+ };
2219
+ var approveAndMerge = async (client, changeRequestId) => {
2220
+ await client.changeRequests.review({ changeRequestId, verdict: "approved" });
2221
+ return client.changeRequests.merge({ changeRequestId });
2222
+ };
2223
+
2224
+ // src/package/collect.ts
2225
+ var findSourceNode = (nodes, slugOrId) => {
2226
+ const stack = [...nodes];
2227
+ while (stack.length > 0) {
2228
+ const node = stack.shift();
2229
+ if (!node) continue;
2230
+ if (node.id === slugOrId || node.slug === slugOrId) return node;
2231
+ if (node.children) stack.push(...node.children);
2232
+ }
2233
+ throw new Error(
2234
+ `No node found with slug or id "${slugOrId}" in this space. Run \`busabase-cli nodes list\` to see the tree.`
2235
+ );
2236
+ };
2237
+ var collectPackageTree = async (client, root, options) => {
2238
+ const children = root.children ?? [];
2239
+ const nodes = await collectNodes(client, children, options);
2240
+ const tree = { manifest: { ...options.manifest, format: PACKAGE_FORMAT }, nodes };
2241
+ assertSelfContained(tree, options);
2242
+ return tree;
2243
+ };
2244
+ var collectNodes = async (client, sources, options) => {
2245
+ const nodes = [];
2246
+ for (const source of sources) {
2247
+ const node = await collectNode(client, source, options);
2248
+ if (node) nodes.push(node);
2249
+ }
2250
+ return sortNodes(nodes);
2251
+ };
2252
+ var collectNode = async (client, source, options) => {
2253
+ const common = {
2254
+ slug: source.slug,
2255
+ name: source.name,
2256
+ description: source.description ?? "",
2257
+ // `publish` always writes position, so round trips preserve sibling order exactly.
2258
+ position: source.position
2259
+ };
2260
+ switch (source.type) {
2261
+ case "folder":
2262
+ return {
2263
+ ...common,
2264
+ type: "folder",
2265
+ children: await collectNodes(client, source.children ?? [], options)
2266
+ };
2267
+ case "doc": {
2268
+ const doc = await client.docs.get({ nodeId: source.id });
2269
+ return { ...common, type: "doc", body: doc.body };
2270
+ }
2271
+ case "base":
2272
+ return collectBase(client, source, common, options);
2273
+ case "skill":
2274
+ case "airapp":
2275
+ case "drive":
2276
+ return collectFileTree(client, source, common, options);
2277
+ case "file":
2278
+ return collectFile(client, source, common, options);
2279
+ default:
2280
+ options.warn(`Skipped node "${source.slug}": unsupported node type "${source.type}".`);
2281
+ return void 0;
2282
+ }
2283
+ };
2284
+ var collectBase = async (client, source, common, options) => {
2285
+ const baseId = source.baseId ?? source.slug;
2286
+ const base = await client.bases.get({ baseId });
2287
+ if (!base) {
2288
+ throw new Error(
2289
+ `Base node "${source.slug}" has no Base behind it (looked up ${baseId}). The space may be mid-migration; re-run publish.`
2290
+ );
2291
+ }
2292
+ const views = await client.bases.listViews({ baseId: base.id });
2293
+ const { baseSlugById, fieldSlugById } = await buildIndexes(client);
2294
+ const fields = [...base.fields].sort((a, b) => a.position - b.position).map(
2295
+ (field) => toPackageField(field, {
2296
+ fieldSlugById,
2297
+ baseSlugById,
2298
+ baseSlug: base.slug,
2299
+ warn: options.warn
2300
+ })
2301
+ );
2302
+ const records = await collectRecords(client, base.id, base.fields, options);
2303
+ return {
2304
+ ...common,
2305
+ type: "base",
2306
+ base: {
2307
+ name: base.name,
2308
+ description: base.description ?? "",
2309
+ position: common.position,
2310
+ reviewPolicy: base.reviewPolicy,
2311
+ fields,
2312
+ views: views.map(toPackageView)
2313
+ },
2314
+ records
2315
+ };
2316
+ };
2317
+ var buildIndexes = async (client) => {
2318
+ const bases = await client.bases.list();
2319
+ const baseSlugById = /* @__PURE__ */ new Map();
2320
+ const fieldSlugById = /* @__PURE__ */ new Map();
2321
+ for (const base of bases) {
2322
+ baseSlugById.set(base.id, base.slug);
2323
+ for (const field of base.fields ?? []) fieldSlugById.set(field.id, field.slug);
2324
+ }
2325
+ return { baseSlugById, fieldSlugById };
2326
+ };
2327
+ var toPackageField = (field, context) => {
2328
+ const {
2329
+ targetBaseId,
2330
+ inverseFieldId,
2331
+ ai,
2332
+ targetBaseSlug: existingTargetBaseSlug,
2333
+ ...rest
2334
+ } = field.options;
2335
+ const options = { ...rest };
2336
+ if (typeof targetBaseId === "string") {
2337
+ const targetSlug = context.baseSlugById.get(targetBaseId);
2338
+ if (!targetSlug) {
2339
+ throw new Error(
2340
+ `Field "${context.baseSlug}.${field.slug}" relates to a Base that no longer exists (${targetBaseId}). Fix the field before publishing.`
2341
+ );
2342
+ }
2343
+ options.targetBaseSlug = targetSlug;
2344
+ } else if (typeof existingTargetBaseSlug === "string") {
2345
+ options.targetBaseSlug = existingTargetBaseSlug;
2346
+ }
2347
+ if (typeof inverseFieldId === "string") {
2348
+ const inverseSlug = context.fieldSlugById.get(inverseFieldId);
2349
+ if (inverseSlug) {
2350
+ options.inverseFieldSlug = inverseSlug;
2351
+ } else {
2352
+ context.warn(
2353
+ `Field "${context.baseSlug}.${field.slug}" names an inverse field (${inverseFieldId}) that no longer exists; the package ships the relation without its inverse link.`
2354
+ );
2355
+ }
2356
+ }
2357
+ if (ai) {
2358
+ const { sourceFieldIds, ...aiRest } = ai;
2359
+ const aiOptions = { ...aiRest };
2360
+ if (Array.isArray(sourceFieldIds) && sourceFieldIds.length > 0) {
2361
+ const slugs = sourceFieldIds.map((id) => context.fieldSlugById.get(id)).filter((slug) => Boolean(slug));
2362
+ if (slugs.length !== sourceFieldIds.length) {
2363
+ context.warn(
2364
+ `Field "${context.baseSlug}.${field.slug}" references AI source field(s) that no longer exist \u2014 they were dropped from the package.`
2365
+ );
2366
+ }
2367
+ if (slugs.length > 0) aiOptions.sourceFieldSlugs = slugs;
2368
+ }
2369
+ options.ai = aiOptions;
2370
+ }
2371
+ return {
2372
+ slug: field.slug,
2373
+ name: field.name,
2374
+ type: field.type,
2375
+ required: field.required,
2376
+ position: field.position,
2377
+ options
2378
+ };
2379
+ };
2380
+ var toPackageView = (view) => ({
2381
+ slug: view.slug,
2382
+ name: view.name,
2383
+ description: view.description ?? "",
2384
+ type: "table",
2385
+ config: {
2386
+ filters: view.config.filters.map((filter) => ({
2387
+ fieldSlug: filter.fieldSlug,
2388
+ operator: filter.operator,
2389
+ value: filter.value
2390
+ })),
2391
+ sorts: view.config.sorts.map((sort) => ({
2392
+ direction: sort.direction,
2393
+ fieldSlug: sort.fieldSlug
2394
+ })),
2395
+ visibleFieldSlugs: view.config.visibleFieldSlugs ?? void 0
2396
+ }
2397
+ });
2398
+ var collectRecords = async (client, baseId, fields, options) => {
2399
+ const typeBySlug = new Map(fields.map((field) => [field.slug, field.type]));
2400
+ const records = [];
2401
+ let droppedAttachments = 0;
2402
+ let cursor;
2403
+ do {
2404
+ const page = await client.records.listPaged({ baseId, limit: 100, cursor });
2405
+ for (const record of page.records) {
2406
+ if (record.status !== "active") continue;
2407
+ const values = {};
2408
+ for (const [slug, value] of Object.entries(record.headCommit.fields ?? {})) {
2409
+ const type = typeBySlug.get(slug);
2410
+ if (!type) continue;
2411
+ if (PACKAGE_COMPUTED_FIELD_TYPES.includes(type)) continue;
2412
+ if (type === "attachment") {
2413
+ if (value !== null && value !== void 0) droppedAttachments++;
2414
+ continue;
2415
+ }
2416
+ if (value === null || value === void 0) continue;
2417
+ values[slug] = value;
2418
+ }
2419
+ records.push({ key: record.id, fields: values });
2420
+ }
2421
+ cursor = page.nextCursor ?? void 0;
2422
+ } while (cursor);
2423
+ if (droppedAttachments > 0) {
2424
+ options.warn(
2425
+ `Dropped ${droppedAttachments} attachment value(s) from this Base \u2014 attachment values are not carried by ${PACKAGE_FORMAT} (the field definitions are kept).`
2426
+ );
2427
+ }
2428
+ return records.sort((a, b) => a.key.localeCompare(b.key, "en"));
2429
+ };
2430
+ var collectFileTree = async (client, source, common, options) => {
2431
+ const api = source.type === "skill" ? client.skills : source.type === "airapp" ? client.airapps : client.drives;
2432
+ const listed = await api.listFiles({ nodeId: source.id });
2433
+ const files = [];
2434
+ for (const file of listed) {
2435
+ const read = await api.readFile({ nodeId: source.id, filePath: file.path });
2436
+ files.push({
2437
+ path: file.path,
2438
+ bytes: read.encoding === "url" ? await downloadBytes(read.content, file.path, options) : Buffer.from(read.content, "utf8")
2439
+ });
2440
+ }
2441
+ return {
2442
+ ...common,
2443
+ type: source.type,
2444
+ files
2445
+ };
2446
+ };
2447
+ var collectFile = async (client, source, common, options) => {
2448
+ const fileNode = await client.files.get({ nodeId: source.id });
2449
+ const asset = fileNode.asset;
2450
+ if (!asset?.url) {
2451
+ options.warn(`Skipped file node "${source.slug}": it has no downloadable asset.`);
2452
+ return void 0;
2453
+ }
2454
+ const fileName = asset.fileName ?? source.slug;
2455
+ return {
2456
+ ...common,
2457
+ type: "file",
2458
+ fileName,
2459
+ mimeType: asset.mimeType ?? guessMimeType(fileName),
2460
+ bytes: await downloadBytes(asset.url, fileName, options)
2461
+ };
2462
+ };
2463
+ var downloadBytes = async (url, label, options) => {
2464
+ const resolved = new URL(url, options.baseUrl).toString();
2465
+ const response = await fetch(resolved);
2466
+ if (!response.ok) {
2467
+ throw new Error(`Failed to download "${label}" (${response.status} ${response.statusText}).`);
2468
+ }
2469
+ return Buffer.from(await response.arrayBuffer());
2470
+ };
2471
+ var assertSelfContained = (tree, options) => {
2472
+ const baseNodes = [...walk(tree.nodes)].filter((node) => node.type === "base");
2473
+ const packagedBaseSlugs = new Set(baseNodes.map((node) => node.slug));
2474
+ const packagedRecordKeys = new Set(
2475
+ baseNodes.flatMap(
2476
+ (node) => node.type === "base" ? node.records.map((record) => record.key) : []
2477
+ )
2478
+ );
2479
+ for (const node of baseNodes) {
2480
+ if (node.type !== "base") continue;
2481
+ for (const field of node.base.fields) {
2482
+ const targetBaseSlug = field.options.targetBaseSlug;
2483
+ if (field.type !== "relation" || !targetBaseSlug) continue;
2484
+ if (!packagedBaseSlugs.has(targetBaseSlug)) {
2485
+ throw new Error(
2486
+ `Field "${node.slug}.${field.slug}" relates to Base "${targetBaseSlug}", which is outside the subtree being published. A package must be self-contained \u2014 publish a subtree that includes both Bases, or remove the field.`
2487
+ );
2488
+ }
2489
+ }
2490
+ let dangling = 0;
2491
+ for (const record of node.records) {
2492
+ for (const field of node.base.fields) {
2493
+ if (field.type !== "relation") continue;
2494
+ const value = record.fields[field.slug];
2495
+ const keys = Array.isArray(value) ? value : typeof value === "string" ? [value] : [];
2496
+ for (const key of keys) {
2497
+ if (typeof key === "string" && !packagedRecordKeys.has(key)) dangling++;
2498
+ }
2499
+ }
2500
+ }
2501
+ if (dangling > 0) {
2502
+ options.warn(
2503
+ `Base "${node.slug}" has ${dangling} relation value(s) pointing at records outside the published subtree \u2014 they were dropped.`
2504
+ );
2505
+ }
2506
+ }
2507
+ };
2508
+ var walk = function* (nodes) {
2509
+ for (const node of nodes) {
2510
+ yield node;
2511
+ if (node.type === "folder") yield* walk(node.children);
2512
+ }
2513
+ };
2514
+
2515
+ // src/package/github.ts
2516
+ import { Uint8ArrayReader, ZipReader } from "@zip.js/zip.js";
2517
+ var GITHUB_ALLOWED_HOSTS = [
2518
+ "github.com",
2519
+ "api.github.com",
2520
+ "codeload.github.com"
2521
+ ];
2522
+ var GITHUB_URL_HOSTS = ["github.com", "www.github.com"];
2523
+ var isAllowedGithubHost = (hostname) => GITHUB_ALLOWED_HOSTS.includes(hostname.toLowerCase());
2524
+ var parseGithubUrl = (rawUrl) => {
2525
+ let url;
2526
+ try {
2527
+ url = new URL(rawUrl.trim());
2528
+ } catch {
2529
+ throw new Error(
2530
+ `Not a valid URL: "${rawUrl}". Expected a GitHub repo URL, e.g. https://github.com/acme/support-kb-template`
2531
+ );
2532
+ }
2533
+ if (url.protocol !== "https:" && url.protocol !== "http:") {
2534
+ throw new Error(`Unsupported URL scheme "${url.protocol}" \u2014 expected an https:// GitHub URL.`);
2535
+ }
2536
+ if (!GITHUB_URL_HOSTS.includes(url.hostname.toLowerCase())) {
2537
+ throw new Error(
2538
+ `Not a GitHub URL: host "${url.hostname}" is not github.com. Only GitHub packages can be installed.`
2539
+ );
2540
+ }
2541
+ const segments = url.pathname.split("/").filter(Boolean);
2542
+ const [owner, rawRepo, marker, ref, ...rest] = segments;
2543
+ if (!owner || !rawRepo) {
2544
+ throw new Error(
2545
+ `GitHub URL is missing owner/repo: "${rawUrl}". Expected https://github.com/<owner>/<repo>[/tree/<ref>[/<subdir>]]`
2546
+ );
2547
+ }
2548
+ const repo = rawRepo.replace(/\.git$/, "");
2549
+ if (!marker) {
2550
+ return { owner, repo, ref: void 0, subdir: void 0 };
2551
+ }
2552
+ if (marker !== "tree") {
2553
+ throw new Error(
2554
+ `Unsupported GitHub URL form "/${marker}/" in "${rawUrl}". Expected https://github.com/<owner>/<repo>[/tree/<ref>[/<subdir>]]`
2555
+ );
2556
+ }
2557
+ if (!ref) {
2558
+ throw new Error(`GitHub URL has "/tree/" but no ref: "${rawUrl}".`);
2559
+ }
2560
+ return {
2561
+ owner,
2562
+ repo,
2563
+ ref: decodeURIComponent(ref),
2564
+ subdir: rest.length > 0 ? rest.map((segment) => decodeURIComponent(segment)).join("/") : void 0
2565
+ };
2566
+ };
2567
+ var encodeRefPath = (ref) => ref.split("/").map(encodeURIComponent).join("/");
2568
+ var downloadGithubZip = async (source, options = {}) => {
2569
+ const fetcher = options.fetcher ?? fetch;
2570
+ const { owner, repo, ref } = source;
2571
+ const slug = `${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`;
2572
+ const label = `${owner}/${repo}${ref ? `@${ref}` : ""}`;
2573
+ const publicUrl = `https://codeload.github.com/${slug}/zip/${ref ? encodeRefPath(ref) : "HEAD"}`;
2574
+ assertAllowedUrl(publicUrl);
2575
+ const publicResponse = await fetcher(publicUrl);
2576
+ if (publicResponse.ok) return Buffer.from(await publicResponse.arrayBuffer());
2577
+ if (!options.githubToken) {
2578
+ throw new Error(
2579
+ publicResponse.status === 404 ? `GitHub repo or ref not found: ${label} (HTTP 404).
2580
+ \u2022 Check the URL, and that the branch/tag exists.
2581
+ \u2022 Private repo? Set GITHUB_TOKEN=<a token with repo read access> and re-run.` : `Failed to download ${label} from GitHub (HTTP ${publicResponse.status} ${publicResponse.statusText}).`
2582
+ );
2583
+ }
2584
+ const apiUrl = `https://api.github.com/repos/${slug}/zipball${ref ? `/${encodeRefPath(ref)}` : ""}`;
2585
+ assertAllowedUrl(apiUrl);
2586
+ const authedResponse = await fetcher(apiUrl, {
2587
+ headers: {
2588
+ Accept: "application/vnd.github+json",
2589
+ Authorization: `Bearer ${options.githubToken}`,
2590
+ "X-GitHub-Api-Version": "2022-11-28"
2591
+ }
2592
+ });
2593
+ if (authedResponse.ok) return Buffer.from(await authedResponse.arrayBuffer());
2594
+ if (authedResponse.status === 401 || authedResponse.status === 403) {
2595
+ throw new Error(
2596
+ `GitHub rejected GITHUB_TOKEN for ${label} (HTTP ${authedResponse.status}). The token is invalid, expired, or lacks read access to this repo.`
2597
+ );
2598
+ }
2599
+ throw new Error(
2600
+ `GitHub repo or ref not found: ${label} (HTTP ${authedResponse.status}), even with GITHUB_TOKEN. Check the owner, repo, and ref.`
2601
+ );
2602
+ };
2603
+ var assertAllowedUrl = (rawUrl) => {
2604
+ const url = new URL(rawUrl);
2605
+ if (url.protocol !== "https:" || !isAllowedGithubHost(url.hostname)) {
2606
+ throw new Error(
2607
+ `Refusing to fetch ${rawUrl} \u2014 only https on ${GITHUB_ALLOWED_HOSTS.join(", ")} is allowed.`
2608
+ );
2609
+ }
2610
+ };
2611
+ var normalizeArchivePath = (value) => {
2612
+ const normalized = value.replaceAll("\\", "/").replace(/^\/+|\/+$/g, "");
2613
+ const segments = normalized.split("/");
2614
+ if (!normalized || segments.some((segment) => segment === "." || segment === ".." || /^[a-zA-Z]:$/.test(segment))) {
2615
+ throw new Error(`Unsafe archive path rejected: ${value}`);
2616
+ }
2617
+ return normalized;
2618
+ };
2619
+ var extractZip = async (zipBuffer, options = {}) => {
2620
+ const maxFiles = options.maxFiles ?? PACKAGE_MAX_FILE_COUNT;
2621
+ const maxTotalBytes = options.maxTotalBytes ?? PACKAGE_MAX_TOTAL_BYTES;
2622
+ const maxFileBytes = options.maxFileBytes ?? PACKAGE_MAX_FILE_BYTES;
2623
+ const reader = new ZipReader(new Uint8ArrayReader(new Uint8Array(zipBuffer)));
2624
+ try {
2625
+ const entries = await reader.getEntries();
2626
+ const files = entries.filter((entry) => !entry.directory);
2627
+ const archiveRoot = files[0]?.filename.split("/")[0];
2628
+ if (!archiveRoot) throw new Error("The downloaded repository archive is empty.");
2629
+ const subdir = options.subdir ? normalizeArchivePath(options.subdir) : void 0;
2630
+ const prefix = `${archiveRoot}/${subdir ? `${subdir}/` : ""}`;
2631
+ for (const entry of files) normalizeArchivePath(entry.filename);
2632
+ const selected = files.filter((entry) => entry.filename.startsWith(prefix));
2633
+ if (selected.length > maxFiles) {
2634
+ throw new Error(
2635
+ `Package has ${selected.length} files, above the ${maxFiles}-file limit. Nothing was installed.`
2636
+ );
2637
+ }
2638
+ let totalBytes = 0;
2639
+ for (const entry of selected) {
2640
+ const size = entry.uncompressedSize ?? 0;
2641
+ const relativePath = entry.filename.slice(prefix.length);
2642
+ if (size > maxFileBytes) {
2643
+ throw new Error(
2644
+ `File "${relativePath}" is ${formatBytes(size)}, above the ${formatBytes(maxFileBytes)} per-file limit. Nothing was installed.`
2645
+ );
2646
+ }
2647
+ totalBytes += size;
2648
+ if (totalBytes > maxTotalBytes) {
2649
+ throw new Error(
2650
+ `Package unpacks to more than the ${formatBytes(maxTotalBytes)} total limit. Nothing was installed.`
2651
+ );
2652
+ }
2653
+ }
2654
+ const result = /* @__PURE__ */ new Map();
2655
+ for (const entry of selected) {
2656
+ const relativePath = normalizeArchivePath(entry.filename.slice(prefix.length));
2657
+ if (!entry.getData) continue;
2658
+ const data = await entry.arrayBuffer();
2659
+ result.set(relativePath, Buffer.from(data));
2660
+ }
2661
+ return result;
2662
+ } finally {
2663
+ await reader.close();
2664
+ }
2665
+ };
2666
+ var formatBytes = (bytes) => bytes >= 1024 * 1024 ? `${Math.round(bytes / (1024 * 1024))}MB` : `${Math.round(bytes / 1024)}KB`;
2667
+ var fetchGithubPackageFiles = async (repoUrl, options = {}) => {
2668
+ const source = parseGithubUrl(repoUrl);
2669
+ const zipBuffer = await downloadGithubZip(source, options);
2670
+ const files = await extractZip(zipBuffer, { subdir: source.subdir });
2671
+ return { source, files };
2672
+ };
2673
+
2674
+ // src/package/frontmatter.ts
2675
+ import { parse, stringify } from "yaml";
2676
+ var DELIMITER = "---";
2677
+ var normalizeDocBody = (body) => body.replaceAll("\r\n", "\n").replace(/^\n+/, "").replace(/\n+$/, "");
2678
+ var parseFrontmatter = (text, filePath) => {
2679
+ const normalized = text.replaceAll("\r\n", "\n");
2680
+ if (!normalized.startsWith(`${DELIMITER}
2681
+ `)) {
2682
+ return { data: {}, body: normalizeDocBody(normalized) };
2683
+ }
2684
+ const end = normalized.indexOf(`
2685
+ ${DELIMITER}`, DELIMITER.length);
2686
+ if (end === -1) {
2687
+ throw new Error(
2688
+ `${filePath} opens YAML frontmatter with "---" but never closes it. Add a closing "---" line.`
2689
+ );
2690
+ }
2691
+ const rawYaml = normalized.slice(DELIMITER.length + 1, end);
2692
+ const body = normalizeDocBody(normalized.slice(end + 1 + DELIMITER.length));
2693
+ let data;
2694
+ try {
2695
+ data = parse(rawYaml);
2696
+ } catch (error) {
2697
+ throw new Error(`${filePath} has invalid YAML frontmatter: ${error.message}`);
2698
+ }
2699
+ if (data === null || data === void 0) return { data: {}, body };
2700
+ if (typeof data !== "object" || Array.isArray(data)) {
2701
+ throw new Error(`${filePath} frontmatter must be a YAML mapping (key: value pairs).`);
2702
+ }
2703
+ return { data, body };
2704
+ };
2705
+ var serializeDoc = (fields, body) => {
2706
+ const data = { name: fields.name };
2707
+ if (fields.description) data.description = fields.description;
2708
+ if (fields.position !== void 0) data.position = fields.position;
2709
+ const yaml = stringify(data, { lineWidth: 0 }).replace(/\n$/, "");
2710
+ return `${DELIMITER}
2711
+ ${yaml}
2712
+ ${DELIMITER}
2713
+
2714
+ ${normalizeDocBody(body)}
2715
+ `;
2716
+ };
2717
+
2718
+ // src/package/layout-read.ts
2719
+ var decodeText = (bytes, filePath) => {
2720
+ const text = bytes.toString("utf8");
2721
+ if (text.includes("\uFFFD")) {
2722
+ throw new Error(`${filePath} is not valid UTF-8 text.`);
2723
+ }
2724
+ return text;
2725
+ };
2726
+ var parseJsonFile = (files, filePath) => {
2727
+ const bytes = files.get(filePath);
2728
+ if (!bytes) throw new Error(`Missing ${filePath}`);
2729
+ try {
2730
+ return JSON.parse(decodeText(bytes, filePath));
2731
+ } catch (error) {
2732
+ throw new Error(`${filePath} is not valid JSON: ${error.message}`);
2733
+ }
2734
+ };
2735
+ var zodParse = (schema, value, filePath) => {
2736
+ const result = schema.safeParse(value);
2737
+ if (!result.success) {
2738
+ const issues = result.error.issues.map((issue) => ` - ${issue.path.join(".") || "(root)"}: ${issue.message}`).join("\n");
2739
+ throw new Error(`${filePath} does not match the ${PACKAGE_FORMAT} schema:
2740
+ ${issues}`);
2741
+ }
2742
+ return result.data;
2743
+ };
2744
+ var listDir = (files, dir) => {
2745
+ const fileNames = /* @__PURE__ */ new Set();
2746
+ const dirNames = /* @__PURE__ */ new Set();
2747
+ for (const filePath of files.keys()) {
2748
+ if (!filePath.startsWith(dir)) continue;
2749
+ const rest = filePath.slice(dir.length);
2750
+ if (!rest) continue;
2751
+ const slash = rest.indexOf("/");
2752
+ if (slash === -1) fileNames.add(rest);
2753
+ else dirNames.add(rest.slice(0, slash));
2754
+ }
2755
+ return { files: [...fileNames].sort(), dirs: [...dirNames].sort() };
2756
+ };
2757
+ var collectSubtree = (files, dir) => {
2758
+ const entries = [];
2759
+ for (const [filePath, bytes] of files) {
2760
+ if (!filePath.startsWith(dir)) continue;
2761
+ const relativePath = filePath.slice(dir.length);
2762
+ if (relativePath) entries.push({ path: relativePath, bytes });
2763
+ }
2764
+ return entries.sort((a, b) => a.path.localeCompare(b.path, "en"));
2765
+ };
2766
+ var readPackageTree = (files, options = {}) => {
2767
+ const root = options.root ? `${options.root.replace(/\/+$/, "")}/` : "";
2768
+ const manifestPath = `${root}${PACKAGE_MANIFEST_FILENAME}`;
2769
+ if (!files.has(manifestPath)) {
2770
+ throw new Error(buildMissingManifestError(files, root));
2771
+ }
2772
+ const rawManifest = parseJsonFile(files, manifestPath);
2773
+ assertKnownFormat(rawManifest, manifestPath);
2774
+ const manifest = zodParse(PackageManifestSchema, rawManifest, manifestPath);
2775
+ const contentDir = `${root}${PACKAGE_CONTENT_DIRNAME}/`;
2776
+ const nodes = readNodes(files, contentDir);
2777
+ return { manifest, nodes };
2778
+ };
2779
+ var assertKnownFormat = (rawManifest, manifestPath) => {
2780
+ const format = typeof rawManifest === "object" && rawManifest !== null ? rawManifest.format : void 0;
2781
+ if (typeof format === "string" && format !== PACKAGE_FORMAT) {
2782
+ throw new Error(
2783
+ `${manifestPath} declares format "${format}", but this busabase-cli only understands "${PACKAGE_FORMAT}". Upgrade busabase-cli (npm i -g busabase-cli@latest) and try again.`
2784
+ );
2785
+ }
2786
+ };
2787
+ var buildMissingManifestError = (files, root) => {
2788
+ const at = root || "the repository root";
2789
+ const nested = [...files.keys()].filter((filePath) => filePath.endsWith(`/${PACKAGE_MANIFEST_FILENAME}`)).map((filePath) => filePath.slice(0, -PACKAGE_MANIFEST_FILENAME.length - 1)).filter((dir) => dir && dir !== root.replace(/\/+$/, "")).sort();
2790
+ const base = `Not a Busabase package \u2014 expected ${PACKAGE_MANIFEST_FILENAME} at ${at}.`;
2791
+ if (nested.length === 0) return base;
2792
+ const suggestions = nested.slice(0, 10).map((dir) => ` \u2022 .../tree/<ref>/${dir}`).join("\n");
2793
+ return `${base}
2794
+
2795
+ This repo does contain packages in subdirectories \u2014 install one of them by URL:
2796
+ ${suggestions}`;
2797
+ };
2798
+ var readNodes = (files, dir) => {
2799
+ const { files: fileNames, dirs } = listDir(files, dir);
2800
+ assertNoSiblingCaseCollisions([...fileNames, ...dirs], `in ${dir || "content/"}`);
2801
+ const nodes = [];
2802
+ for (const dirName of dirs) nodes.push(readDirNode(files, `${dir}${dirName}/`, dirName));
2803
+ for (const fileName of fileNames) {
2804
+ const node = readFileNode(files, dir, fileName);
2805
+ if (node) nodes.push(node);
2806
+ }
2807
+ return sortNodes(nodes);
2808
+ };
2809
+ var readDirNode = (files, dir, slug) => {
2810
+ assertSafeNodeSlug(slug, `directory ${dir}`);
2811
+ if (files.has(`${dir}${PACKAGE_NODE_META_FILENAME}`)) {
2812
+ const metaPath2 = `${dir}${PACKAGE_NODE_META_FILENAME}`;
2813
+ const meta2 = zodParse(PackageFileTreeNodeMetaSchema, parseJsonFile(files, metaPath2), metaPath2);
2814
+ const entries = collectSubtree(files, dir).filter(
2815
+ (entry) => entry.path !== PACKAGE_NODE_META_FILENAME
2816
+ );
2817
+ assertNoReservedNames(
2818
+ entries.map((entry) => entry.path),
2819
+ `${meta2.type} "${slug}"`
2820
+ );
2821
+ return {
2822
+ type: meta2.type,
2823
+ slug,
2824
+ name: meta2.name,
2825
+ description: meta2.description,
2826
+ position: meta2.position,
2827
+ files: entries
2828
+ };
2829
+ }
2830
+ if (files.has(`${dir}${PACKAGE_BASE_FILENAME}`)) {
2831
+ const basePath = `${dir}${PACKAGE_BASE_FILENAME}`;
2832
+ const base = zodParse(PackageBaseSchema, parseJsonFile(files, basePath), basePath);
2833
+ const records = readRecords(files, dir, slug);
2834
+ return {
2835
+ type: "base",
2836
+ slug,
2837
+ name: base.name,
2838
+ description: base.description,
2839
+ position: base.position,
2840
+ base,
2841
+ records
2842
+ };
2843
+ }
2844
+ const metaPath = `${dir}${PACKAGE_FOLDER_META_FILENAME}`;
2845
+ const meta = files.has(metaPath) ? zodParse(PackageFolderMetaSchema, parseJsonFile(files, metaPath), metaPath) : { name: void 0, description: "", position: void 0 };
2846
+ return {
2847
+ type: "folder",
2848
+ slug,
2849
+ name: meta.name ?? humanizeSlug(slug),
2850
+ description: meta.description,
2851
+ position: meta.position,
2852
+ children: readNodes(files, dir)
2853
+ };
2854
+ };
2855
+ var readRecords = (files, dir, slug) => {
2856
+ const recordsPath = `${dir}${PACKAGE_RECORDS_FILENAME}`;
2857
+ const bytes = files.get(recordsPath);
2858
+ if (!bytes) return [];
2859
+ return parseRecordLines(decodeText(bytes, recordsPath), recordsPath, slug);
2860
+ };
2861
+ var parseRecordLines = (text, recordsPath, slug) => {
2862
+ const lines = text.split("\n").filter((line) => line.trim().length > 0);
2863
+ if (lines.length > PACKAGE_MAX_RECORDS_PER_BASE) {
2864
+ throw new Error(
2865
+ `Base "${slug}" has ${lines.length} records, above the ${PACKAGE_MAX_RECORDS_PER_BASE} per-base limit. Nothing was installed.`
2866
+ );
2867
+ }
2868
+ const seenKeys = /* @__PURE__ */ new Set();
2869
+ return lines.map((line, index) => {
2870
+ let parsed;
2871
+ try {
2872
+ parsed = JSON.parse(line);
2873
+ } catch (error) {
2874
+ throw new Error(
2875
+ `${recordsPath} line ${index + 1} is not valid JSON: ${error.message}`
2876
+ );
2877
+ }
2878
+ const record = zodParse(PackageRecordLineSchema, parsed, `${recordsPath} line ${index + 1}`);
2879
+ if (seenKeys.has(record.key)) {
2880
+ throw new Error(
2881
+ `${recordsPath} has a duplicate record key "${record.key}" on line ${index + 1}.`
2882
+ );
2883
+ }
2884
+ seenKeys.add(record.key);
2885
+ return record;
2886
+ });
2887
+ };
2888
+ var readFileNode = (files, dir, fileName) => {
2889
+ if (fileName === PACKAGE_MANIFEST_FILENAME || fileName === PACKAGE_FOLDER_META_FILENAME || fileName === PACKAGE_NODE_META_FILENAME || fileName.endsWith(PACKAGE_NODE_META_SUFFIX)) {
2890
+ return void 0;
2891
+ }
2892
+ const bytes = files.get(`${dir}${fileName}`);
2893
+ if (!bytes) return void 0;
2894
+ if (fileName.toLowerCase().endsWith(".md")) {
2895
+ return readDocNode(bytes, dir, fileName);
2896
+ }
2897
+ const slug = stripExtension(fileName);
2898
+ assertSafeNodeSlug(slug, `file ${dir}${fileName}`);
2899
+ const sidecarPath = `${dir}${fileName}${PACKAGE_NODE_META_SUFFIX}`;
2900
+ const meta = files.has(sidecarPath) ? zodParse(PackageFileNodeMetaSchema, parseJsonFile(files, sidecarPath), sidecarPath) : { name: void 0, description: "", position: void 0 };
2901
+ return {
2902
+ type: "file",
2903
+ slug,
2904
+ name: meta.name ?? fileName,
2905
+ description: meta.description,
2906
+ position: meta.position,
2907
+ fileName,
2908
+ mimeType: guessMimeType(fileName),
2909
+ bytes
2910
+ };
2911
+ };
2912
+ var readDocNode = (bytes, dir, fileName) => {
2913
+ const filePath = `${dir}${fileName}`;
2914
+ const slug = fileName.slice(0, -".md".length);
2915
+ assertSafeNodeSlug(slug, `doc ${filePath}`);
2916
+ const { data, body } = parseFrontmatter(decodeText(bytes, filePath), filePath);
2917
+ const frontmatter = zodParse(
2918
+ PackageDocFrontmatterSchema,
2919
+ { name: humanizeSlug(slug), ...data },
2920
+ `${filePath} frontmatter`
2921
+ );
2922
+ return {
2923
+ type: "doc",
2924
+ slug,
2925
+ name: frontmatter.name,
2926
+ description: frontmatter.description,
2927
+ position: frontmatter.position,
2928
+ body
2929
+ };
2930
+ };
2931
+ var stripExtension = (fileName) => {
2932
+ const dot = fileName.lastIndexOf(".");
2933
+ return dot > 0 ? fileName.slice(0, dot) : fileName;
2934
+ };
2935
+
2936
+ // src/package/layout-write.ts
2937
+ import { mkdir, rm, writeFile } from "fs/promises";
2938
+ import { dirname, join as join2 } from "path";
2939
+ var sortKeysDeep = (value) => {
2940
+ if (Array.isArray(value)) return value.map(sortKeysDeep);
2941
+ if (value === null || typeof value !== "object") return value;
2942
+ const entries = Object.entries(value).filter(([, item]) => item !== void 0).sort(([a], [b]) => a.localeCompare(b, "en"));
2943
+ return Object.fromEntries(entries.map(([key, item]) => [key, sortKeysDeep(item)]));
2944
+ };
2945
+ var toJsonFile = (value) => Buffer.from(`${JSON.stringify(value, null, 2)}
2946
+ `, "utf8");
2947
+ var compact = (value) => Object.fromEntries(Object.entries(value).filter(([, item]) => item !== void 0));
2948
+ var serializeManifest = (manifest) => toJsonFile(
2949
+ compact({
2950
+ format: manifest.format,
2951
+ name: manifest.name,
2952
+ description: manifest.description || void 0,
2953
+ version: manifest.version,
2954
+ author: manifest.author,
2955
+ license: manifest.license,
2956
+ homepage: manifest.homepage,
2957
+ tags: manifest.tags.length > 0 ? manifest.tags : void 0
2958
+ })
2959
+ );
2960
+ var serializeField = (field) => compact({
2961
+ slug: field.slug,
2962
+ name: field.name,
2963
+ type: field.type,
2964
+ required: field.required,
2965
+ position: field.position,
2966
+ options: sortKeysDeep(field.options)
2967
+ });
2968
+ var serializeView = (view) => compact({
2969
+ slug: view.slug,
2970
+ name: view.name,
2971
+ description: view.description || void 0,
2972
+ type: view.type,
2973
+ config: compact({
2974
+ filters: view.config.filters.map(
2975
+ (filter) => compact({
2976
+ fieldSlug: filter.fieldSlug,
2977
+ operator: filter.operator,
2978
+ value: filter.value
2979
+ })
2980
+ ),
2981
+ sorts: view.config.sorts.map((sort) => ({
2982
+ direction: sort.direction,
2983
+ fieldSlug: sort.fieldSlug
2984
+ })),
2985
+ visibleFieldSlugs: view.config.visibleFieldSlugs ?? void 0
2986
+ })
2987
+ });
2988
+ var serializeBase = (base, position) => toJsonFile(
2989
+ compact({
2990
+ name: base.name,
2991
+ description: base.description || void 0,
2992
+ position,
2993
+ reviewPolicy: base.reviewPolicy,
2994
+ fields: base.fields.map(serializeField),
2995
+ views: base.views.map(serializeView)
2996
+ })
2997
+ );
2998
+ var serializeRecords = (records) => {
2999
+ const sorted = [...records].sort((a, b) => a.key.localeCompare(b.key, "en"));
3000
+ const lines = sorted.map((record) => JSON.stringify({ key: record.key, fields: record.fields }));
3001
+ return Buffer.from(lines.length > 0 ? `${lines.join("\n")}
3002
+ ` : "", "utf8");
3003
+ };
3004
+ var renderPackageTree = (tree) => {
3005
+ const files = /* @__PURE__ */ new Map();
3006
+ files.set(PACKAGE_MANIFEST_FILENAME, serializeManifest(tree.manifest));
3007
+ renderNodes(tree.nodes, `${PACKAGE_CONTENT_DIRNAME}/`, files);
3008
+ return files;
3009
+ };
3010
+ var renderNodes = (nodes, dir, files) => {
3011
+ const ordered = sortNodes(nodes);
3012
+ assertNoSiblingCaseCollisions(
3013
+ ordered.map((node) => node.slug),
3014
+ `in ${dir}`
3015
+ );
3016
+ const seen = /* @__PURE__ */ new Set();
3017
+ for (const node of ordered) {
3018
+ assertSafeNodeSlug(node.slug, `${node.type} node`);
3019
+ if (seen.has(node.slug)) {
3020
+ throw new Error(
3021
+ `Two nodes in ${dir} would be written as "${node.slug}". Sibling slugs must be unique.`
3022
+ );
3023
+ }
3024
+ seen.add(node.slug);
3025
+ renderNode(node, dir, files);
3026
+ }
3027
+ };
3028
+ var renderNode = (node, dir, files) => {
3029
+ switch (node.type) {
3030
+ case "folder": {
3031
+ const needsMeta = node.children.length === 0 || node.description !== "" || node.position !== void 0 || node.name !== void 0;
3032
+ if (needsMeta) {
3033
+ files.set(
3034
+ `${dir}${node.slug}/${PACKAGE_FOLDER_META_FILENAME}`,
3035
+ toJsonFile(
3036
+ compact({
3037
+ name: node.name,
3038
+ description: node.description || void 0,
3039
+ position: node.position
3040
+ })
3041
+ )
3042
+ );
3043
+ }
3044
+ renderNodes(node.children, `${dir}${node.slug}/`, files);
3045
+ return;
3046
+ }
3047
+ case "doc": {
3048
+ files.set(
3049
+ `${dir}${node.slug}.md`,
3050
+ Buffer.from(
3051
+ serializeDoc(
3052
+ { name: node.name, description: node.description, position: node.position },
3053
+ node.body
3054
+ ),
3055
+ "utf8"
3056
+ )
3057
+ );
3058
+ return;
3059
+ }
3060
+ case "base": {
3061
+ files.set(
3062
+ `${dir}${node.slug}/${PACKAGE_BASE_FILENAME}`,
3063
+ serializeBase(node.base, node.position)
3064
+ );
3065
+ const records = serializeRecords(node.records);
3066
+ if (records.byteLength > 0)
3067
+ files.set(`${dir}${node.slug}/${PACKAGE_RECORDS_FILENAME}`, records);
3068
+ return;
3069
+ }
3070
+ case "skill":
3071
+ case "airapp":
3072
+ case "drive": {
3073
+ const nodeDir = `${dir}${node.slug}/`;
3074
+ assertNoReservedNames(
3075
+ node.files.map((entry) => entry.path),
3076
+ `${node.type} "${node.slug}"`
3077
+ );
3078
+ files.set(
3079
+ `${nodeDir}${PACKAGE_NODE_META_FILENAME}`,
3080
+ toJsonFile(
3081
+ compact({
3082
+ type: node.type,
3083
+ name: node.name,
3084
+ description: node.description || void 0,
3085
+ position: node.position
3086
+ })
3087
+ )
3088
+ );
3089
+ for (const entry of [...node.files].sort((a, b) => a.path.localeCompare(b.path, "en"))) {
3090
+ assertSafeFilePath(entry.path, `${node.type} "${node.slug}"`);
3091
+ files.set(`${nodeDir}${entry.path}`, entry.bytes);
3092
+ }
3093
+ return;
3094
+ }
3095
+ case "file": {
3096
+ assertSafeFilePath(node.fileName, `file node "${node.slug}"`);
3097
+ files.set(`${dir}${node.fileName}`, node.bytes);
3098
+ const hasMeta = node.name !== node.fileName || node.description !== "" || node.position !== void 0;
3099
+ if (hasMeta) {
3100
+ files.set(
3101
+ `${dir}${node.fileName}${PACKAGE_NODE_META_SUFFIX}`,
3102
+ toJsonFile(
3103
+ compact({
3104
+ name: node.name,
3105
+ description: node.description || void 0,
3106
+ position: node.position
3107
+ })
3108
+ )
3109
+ );
3110
+ }
3111
+ return;
3112
+ }
3113
+ }
3114
+ };
3115
+ var writePackageFiles = async (files, outDir, options = {}) => {
3116
+ if (options.clean) {
3117
+ await rm(join2(outDir, PACKAGE_CONTENT_DIRNAME), { recursive: true, force: true });
3118
+ await rm(join2(outDir, PACKAGE_MANIFEST_FILENAME), { force: true });
3119
+ }
3120
+ for (const [filePath, bytes] of [...files].sort(([a], [b]) => a.localeCompare(b, "en"))) {
3121
+ const target = join2(outDir, filePath);
3122
+ await mkdir(dirname(target), { recursive: true });
3123
+ await writeFile(target, bytes);
3124
+ }
3125
+ };
3126
+
3127
+ // src/package/plan.ts
3128
+ var buildInstallPlan = (tree, target, options = {}) => {
3129
+ const targetFolderSlug = options.intoFolder ?? tree.manifest.name;
3130
+ const warnings = [];
3131
+ const existingChildSlugs = new Set(
3132
+ (target.targetFolder?.children ?? []).map((child) => child.slug)
3133
+ );
3134
+ const collisions = findCollisions(tree, targetFolderSlug, existingChildSlugs, target);
3135
+ let planned = tree;
3136
+ if (options.rename && collisions.length > 0) {
3137
+ planned = applyRenames(tree, collisions, existingChildSlugs, target.existingBaseSlugs);
3138
+ }
3139
+ collectWarnings(planned, warnings);
3140
+ const counts = countTree(planned);
3141
+ if (counts.files > PACKAGE_MAX_FILE_COUNT) {
3142
+ throw new Error(
3143
+ `Package has ${counts.files} files, above the ${PACKAGE_MAX_FILE_COUNT}-file limit. Nothing was installed.`
3144
+ );
3145
+ }
3146
+ return {
3147
+ targetFolderSlug,
3148
+ tree: planned,
3149
+ collisions,
3150
+ warnings,
3151
+ counts,
3152
+ requiresAutoMerge: collectBaseNodes(planned.nodes).some(hasUnlinkableRelationValues)
3153
+ };
3154
+ };
3155
+ var hasUnlinkableRelationValues = (node) => {
3156
+ const relationSlugs = node.base.fields.filter((field) => field.type === "relation").map((field) => field.slug);
3157
+ if (relationSlugs.length === 0) return false;
3158
+ return node.records.some(
3159
+ (record) => relationSlugs.some((slug) => {
3160
+ const value = record.fields[slug];
3161
+ return Array.isArray(value) ? value.length > 0 : Boolean(value);
3162
+ })
3163
+ );
3164
+ };
3165
+ var findCollisions = (tree, targetFolderSlug, existingChildSlugs, target) => {
3166
+ const collisions = [];
3167
+ for (const node of tree.nodes) {
3168
+ if (existingChildSlugs.has(node.slug)) {
3169
+ collisions.push({ kind: "node", slug: node.slug, path: `${targetFolderSlug}/${node.slug}` });
3170
+ }
3171
+ }
3172
+ for (const baseNode of collectBaseNodes(tree.nodes)) {
3173
+ if (target.existingBaseSlugs.has(baseNode.slug)) {
3174
+ collisions.push({
3175
+ kind: "base",
3176
+ slug: baseNode.slug,
3177
+ path: `${targetFolderSlug}/\u2026 /${baseNode.slug} (base slugs are unique per space)`
3178
+ });
3179
+ }
3180
+ }
3181
+ return collisions;
3182
+ };
3183
+ var nextFreeSlug = (slug, taken) => {
3184
+ for (let suffix = 2; suffix < 1e3; suffix++) {
3185
+ const candidate = `${slug}-${suffix}`;
3186
+ if (!taken.has(candidate)) return candidate;
3187
+ }
3188
+ throw new Error(`Could not find a free slug for "${slug}" after 999 attempts.`);
3189
+ };
3190
+ var applyRenames = (tree, collisions, existingChildSlugs, existingBaseSlugs) => {
3191
+ const packageBaseSlugs = new Set(collectBaseNodes(tree.nodes).map((node) => node.slug));
3192
+ const takenNodeSlugs = /* @__PURE__ */ new Set([...existingChildSlugs, ...tree.nodes.map((node) => node.slug)]);
3193
+ const takenBaseSlugs = /* @__PURE__ */ new Set([...existingBaseSlugs, ...packageBaseSlugs]);
3194
+ const nodeRenames = /* @__PURE__ */ new Map();
3195
+ const baseRenames = /* @__PURE__ */ new Map();
3196
+ for (const collision of collisions) {
3197
+ if (collision.kind === "node") {
3198
+ const renamed = nextFreeSlug(collision.slug, takenNodeSlugs);
3199
+ takenNodeSlugs.add(renamed);
3200
+ nodeRenames.set(collision.slug, renamed);
3201
+ collision.renamedTo = renamed;
3202
+ } else {
3203
+ const renamed = nextFreeSlug(collision.slug, takenBaseSlugs);
3204
+ takenBaseSlugs.add(renamed);
3205
+ baseRenames.set(collision.slug, renamed);
3206
+ collision.renamedTo = renamed;
3207
+ }
3208
+ }
3209
+ const rewriteNode = (node, isTopLevel) => {
3210
+ if (node.type === "base") {
3211
+ const renamed = baseRenames.get(node.slug) ?? (isTopLevel ? nodeRenames.get(node.slug) : void 0);
3212
+ const slug2 = renamed ?? node.slug;
3213
+ return {
3214
+ ...node,
3215
+ slug: slug2,
3216
+ base: { ...node.base, fields: node.base.fields.map(rewriteFieldTarget) }
3217
+ };
3218
+ }
3219
+ const slug = (isTopLevel ? nodeRenames.get(node.slug) : void 0) ?? node.slug;
3220
+ if (node.type === "folder") {
3221
+ return { ...node, slug, children: node.children.map((child) => rewriteNode(child, false)) };
3222
+ }
3223
+ return { ...node, slug };
3224
+ };
3225
+ const rewriteFieldTarget = (field) => {
3226
+ const targetBaseSlug = field.options.targetBaseSlug;
3227
+ if (!targetBaseSlug) return field;
3228
+ const renamed = baseRenames.get(targetBaseSlug);
3229
+ if (!renamed) return field;
3230
+ return { ...field, options: { ...field.options, targetBaseSlug: renamed } };
3231
+ };
3232
+ return { ...tree, nodes: tree.nodes.map((node) => rewriteNode(node, true)) };
3233
+ };
3234
+ var collectWarnings = (tree, warnings) => {
3235
+ for (const baseNode of collectBaseNodes(tree.nodes)) {
3236
+ if (baseNode.base.reviewPolicy) {
3237
+ warnings.push(
3238
+ `Base "${baseNode.slug}" declares a reviewPolicy (${baseNode.base.reviewPolicy.kind}, ${baseNode.base.reviewPolicy.requiredApprovals} approval(s)), but reviewPolicy cannot be set when creating a Base \u2014 the target's default applies. Adjust it in the UI afterwards if needed.`
3239
+ );
3240
+ }
3241
+ }
3242
+ };
3243
+ var countTree = (tree) => {
3244
+ const counts = {
3245
+ folders: 0,
3246
+ docs: 0,
3247
+ bases: 0,
3248
+ records: 0,
3249
+ skills: 0,
3250
+ airapps: 0,
3251
+ drives: 0,
3252
+ files: 0
3253
+ };
3254
+ for (const node of walkNodes(tree.nodes)) {
3255
+ switch (node.type) {
3256
+ case "folder":
3257
+ counts.folders++;
3258
+ break;
3259
+ case "doc":
3260
+ counts.docs++;
3261
+ break;
3262
+ case "base":
3263
+ counts.bases++;
3264
+ counts.records += node.records.length;
3265
+ break;
3266
+ case "skill":
3267
+ counts.skills++;
3268
+ counts.files += node.files.length;
3269
+ break;
3270
+ case "airapp":
3271
+ counts.airapps++;
3272
+ counts.files += node.files.length;
3273
+ break;
3274
+ case "drive":
3275
+ counts.drives++;
3276
+ counts.files += node.files.length;
3277
+ break;
3278
+ case "file":
3279
+ counts.files++;
3280
+ break;
3281
+ }
3282
+ }
3283
+ return counts;
3284
+ };
3285
+ var renderPlan = (plan) => {
3286
+ const lines = [];
3287
+ lines.push(`Package: ${plan.tree.manifest.name}`);
3288
+ if (plan.tree.manifest.description) lines.push(` ${plan.tree.manifest.description}`);
3289
+ lines.push("");
3290
+ lines.push(`Target folder: ${plan.targetFolderSlug}`);
3291
+ lines.push("");
3292
+ lines.push("Node tree:");
3293
+ for (const line of renderNodeTree2(plan.tree.nodes, " ")) lines.push(line);
3294
+ lines.push("");
3295
+ lines.push(
3296
+ `Totals: ${plan.counts.folders} folder(s), ${plan.counts.docs} doc(s), ${plan.counts.bases} base(s) with ${plan.counts.records} record(s), ${plan.counts.skills} skill(s), ${plan.counts.airapps} airapp(s), ${plan.counts.drives} drive(s), ${plan.counts.files} file(s)`
3297
+ );
3298
+ if (plan.collisions.length > 0) {
3299
+ lines.push("");
3300
+ lines.push("Collisions:");
3301
+ for (const collision of plan.collisions) {
3302
+ lines.push(
3303
+ collision.renamedTo ? ` \u2022 ${collision.kind} "${collision.slug}" already exists \u2192 will be created as "${collision.renamedTo}"` : ` \u2022 ${collision.kind} "${collision.slug}" already exists (${collision.path})`
3304
+ );
3305
+ }
3306
+ }
3307
+ if (plan.warnings.length > 0) {
3308
+ lines.push("");
3309
+ lines.push("Warnings:");
3310
+ for (const warning of plan.warnings) lines.push(` \u2022 ${warning}`);
3311
+ }
3312
+ return lines.join("\n");
3313
+ };
3314
+ var renderNodeTree2 = (nodes, indent) => {
3315
+ const lines = [];
3316
+ for (const node of nodes) {
3317
+ const detail = node.type === "base" ? ` (base, ${node.base.fields.length} field(s), ${node.records.length} record(s))` : node.type === "folder" ? " (folder)" : node.type === "doc" ? " (doc)" : node.type === "file" ? " (file)" : ` (${node.type}, ${node.files.length} file(s))`;
3318
+ lines.push(`${indent}${node.slug}${detail}`);
3319
+ if (node.type === "folder") lines.push(...renderNodeTree2(node.children, `${indent} `));
3320
+ }
3321
+ return lines;
3322
+ };
3323
+ var assertPlanIsApplicable = (plan, autoMerge) => {
3324
+ if (plan.collisions.some((collision) => !collision.renamedTo)) {
3325
+ const list = plan.collisions.filter((collision) => !collision.renamedTo).map((collision) => ` \u2022 ${collision.kind} "${collision.slug}" (${collision.path})`).join("\n");
3326
+ throw new Error(
3327
+ `Install would collide with content that already exists:
3328
+ ${list}
3329
+
3330
+ Nothing was created. Re-run with --rename to install the colliding items under suffixed slugs (e.g. "-2"), or pick a different --into-folder.`
3331
+ );
3332
+ }
3333
+ if (plan.requiresAutoMerge && !autoMerge) {
3334
+ throw new Error(
3335
+ "This package's records carry relation values, and a relation stores the ids of the records it points at \u2014 which only exist once the records are merged. Installing review-first would leave every relation empty.\n\nRe-run with --auto-merge to install it (this trusts the package author: records are created immediately instead of landing as change requests you review first)."
3336
+ );
3337
+ }
3338
+ };
3339
+
3340
+ // src/package/commands.ts
3341
+ var reportProgress = (message) => {
3342
+ console.error(message);
3343
+ };
3344
+ var runInstall = async (client, repoUrl, options) => {
3345
+ reportProgress(`Fetching ${repoUrl} \u2026`);
3346
+ const { source, files } = await fetchGithubPackageFiles(repoUrl, {
3347
+ githubToken: options.githubToken
3348
+ });
3349
+ reportProgress(`Downloaded ${files.size} file(s) from ${source.owner}/${source.repo}.`);
3350
+ const tree = readPackageTree(files);
3351
+ const [nodes, bases] = await Promise.all([client.nodes.list(), client.bases.list()]);
3352
+ const targetFolderSlug = options.intoFolder ?? tree.manifest.name;
3353
+ const plan = buildInstallPlan(
3354
+ tree,
3355
+ {
3356
+ targetFolder: findTargetFolder(nodes, targetFolderSlug),
3357
+ existingBaseSlugs: new Set(bases.map((base) => base.slug))
3358
+ },
3359
+ { intoFolder: options.intoFolder, rename: options.rename }
3360
+ );
3361
+ if (options.dryRun) {
3362
+ const report = `${renderPlan(plan)}
3363
+
3364
+ Dry run \u2014 nothing was created.`;
3365
+ return options.json ? { dryRun: true, ...toPlanSummary(plan) } : report;
3366
+ }
3367
+ assertPlanIsApplicable(plan, Boolean(options.autoMerge));
3368
+ const result = await applyInstall(client, plan, {
3369
+ autoMerge: Boolean(options.autoMerge),
3370
+ submittedBy: `busabase-cli install (${tree.manifest.name})`,
3371
+ onProgress: reportProgress
3372
+ });
3373
+ if (options.json) return { installed: true, ...result };
3374
+ return renderInstallReport(plan.targetFolderSlug, result);
3375
+ };
3376
+ var findTargetFolder = (nodes, slug) => {
3377
+ const roots = nodes.length === 1 && nodes[0].children ? nodes[0].children : nodes;
3378
+ return roots.find((node) => node.slug === slug && node.type === "folder");
3379
+ };
3380
+ var toPlanSummary = (plan) => ({
3381
+ targetFolder: plan.targetFolderSlug,
3382
+ package: plan.tree.manifest.name,
3383
+ counts: plan.counts,
3384
+ collisions: plan.collisions,
3385
+ warnings: plan.warnings,
3386
+ requiresAutoMerge: plan.requiresAutoMerge
3387
+ });
3388
+ var renderInstallReport = (targetFolderSlug, result) => {
3389
+ const lines = [];
3390
+ lines.push(`Installed into folder "${targetFolderSlug}".`);
3391
+ lines.push("");
3392
+ lines.push("Created:");
3393
+ for (const [label, count] of Object.entries(result.created)) {
3394
+ if (count > 0) lines.push(` ${count} ${label}`);
3395
+ }
3396
+ if (result.pendingChangeRequests > 0) {
3397
+ lines.push("");
3398
+ lines.push(
3399
+ `${result.pendingChangeRequests} change request(s) are pending your review \u2014 nothing is live until you merge them:`
3400
+ );
3401
+ lines.push(" busabase-cli change-requests list");
3402
+ }
3403
+ if (result.warnings.length > 0) {
3404
+ lines.push("");
3405
+ lines.push("Warnings:");
3406
+ for (const warning of result.warnings) lines.push(` \u2022 ${warning}`);
3407
+ }
3408
+ return lines.join("\n");
3409
+ };
3410
+ var runPublish = async (client, nodeSlugOrId, options) => {
3411
+ const nodes = await client.nodes.list();
3412
+ const source = findSourceNode(nodes, nodeSlugOrId);
3413
+ const warnings = [];
3414
+ const manifest = await resolveManifest(options.outDir, options.name, source.name, source.slug);
3415
+ reportProgress(`Reading "${source.slug}" \u2026`);
3416
+ const tree = await collectPackageTree(client, source, {
3417
+ manifest,
3418
+ warn: (message) => warnings.push(message),
3419
+ baseUrl: options.baseUrl
3420
+ });
3421
+ const files = renderPackageTree(tree);
3422
+ if (!options.dryRun) {
3423
+ await writePackageFiles(files, options.outDir, { clean: true });
3424
+ }
3425
+ if (options.json) {
3426
+ return {
3427
+ published: !options.dryRun,
3428
+ package: manifest.name,
3429
+ outDir: options.outDir,
3430
+ files: [...files.keys()].sort(),
3431
+ warnings
3432
+ };
3433
+ }
3434
+ return renderPublishReport(options, manifest, files, warnings);
3435
+ };
3436
+ var resolveManifest = async (outDir, nameFlag, nodeName, nodeSlug) => {
3437
+ const existing = await readExistingManifest(outDir);
3438
+ if (existing) {
3439
+ return { ...existing, name: nameFlag ?? existing.name };
3440
+ }
3441
+ return PackageManifestSchema.parse({
3442
+ format: PACKAGE_FORMAT,
3443
+ name: nameFlag ?? nodeSlug,
3444
+ description: nodeName === nodeSlug ? "" : nodeName
3445
+ });
3446
+ };
3447
+ var readExistingManifest = async (outDir) => {
3448
+ try {
3449
+ const raw = await readFile(join3(outDir, PACKAGE_MANIFEST_FILENAME), "utf8");
3450
+ const parsed = PackageManifestSchema.safeParse(JSON.parse(raw));
3451
+ return parsed.success ? parsed.data : void 0;
3452
+ } catch {
3453
+ return void 0;
3454
+ }
3455
+ };
3456
+ var renderPublishReport = (options, manifest, files, warnings) => {
3457
+ const lines = [];
3458
+ lines.push(
3459
+ options.dryRun ? `Dry run \u2014 would write ${files.size} file(s) to ${options.outDir}:` : `Wrote ${files.size} file(s) to ${options.outDir}:`
3460
+ );
3461
+ for (const filePath of [...files.keys()].sort()) lines.push(` ${filePath}`);
3462
+ if (warnings.length > 0) {
3463
+ lines.push("");
3464
+ lines.push("Warnings:");
3465
+ for (const warning of warnings) lines.push(` \u2022 ${warning}`);
3466
+ }
3467
+ if (!options.dryRun) {
3468
+ lines.push("");
3469
+ lines.push("Now publish it to GitHub:");
3470
+ lines.push(` cd ${options.outDir}`);
3471
+ lines.push(
3472
+ ' git init && git add . && git commit -m "Add ${name} package"'.replace(
3473
+ "${name}",
3474
+ manifest.name
3475
+ )
3476
+ );
3477
+ lines.push(
3478
+ " git remote add origin https://github.com/<you>/<repo>.git && git push -u origin main"
3479
+ );
3480
+ lines.push("");
3481
+ lines.push("Then anyone can install it:");
3482
+ lines.push(" busabase-cli install https://github.com/<you>/<repo>");
3483
+ }
3484
+ return lines.join("\n");
3485
+ };
3486
+
3487
+ // src/run.ts
3488
+ var DOCS_TROUBLESHOOTING = "https://busabase.com/docs/troubleshooting";
3489
+ function resolveConfig(opts) {
3490
+ const file = loadDotEnvFile();
3491
+ return {
3492
+ baseUrl: opts.baseUrl ?? process.env.BUSABASE_BASE_URL ?? file.BUSABASE_BASE_URL ?? DEFAULT_BASE_URL2,
3493
+ apiKey: opts.apiKey ?? process.env.BUSABASE_API_KEY ?? file.BUSABASE_API_KEY,
3494
+ spaceId: opts.spaceId ?? process.env.BUSABASE_SPACE_ID ?? file.BUSABASE_SPACE_ID,
3495
+ output: opts.output ?? "text"
3496
+ };
3497
+ }
3498
+ var GLOBAL_LONG_FLAGS = /* @__PURE__ */ new Set(["--base-url", "--api-key", "--space-id", "--output"]);
3499
+ function addGlobalFlags(cmd) {
3500
+ return cmd.option(
3501
+ "--base-url <url>",
3502
+ `server base URL (env BUSABASE_BASE_URL, default ${DEFAULT_BASE_URL2})`
3503
+ ).option("--api-key <token>", "bearer token for cloud hosts (env BUSABASE_API_KEY)").option("--space-id <id>", "target Busabase space (env BUSABASE_SPACE_ID)").addOption(
3504
+ new Option("--output <fmt>", "text | table | json (default text)").choices([
3505
+ "text",
3506
+ "table",
3507
+ "json"
3508
+ ])
3509
+ );
3510
+ }
3511
+ var parseNum = (value) => {
3512
+ const parsed = Number(value);
3513
+ if (!Number.isFinite(parsed)) throw new InvalidArgumentError("expected a number");
3514
+ return parsed;
3515
+ };
3516
+ var parseLimit = (value) => {
3517
+ const parsed = parseNum(value);
3518
+ if (!Number.isInteger(parsed) || parsed < 1 || parsed > 100) {
3519
+ throw new InvalidArgumentError("expected an integer from 1 to 100");
3520
+ }
3521
+ return parsed;
3522
+ };
3523
+ var parsePositiveInt = (value) => {
3524
+ const parsed = parseNum(value);
3525
+ if (!Number.isInteger(parsed) || parsed < 1) {
3526
+ throw new InvalidArgumentError("expected a positive integer");
3527
+ }
3528
+ return parsed;
3529
+ };
3530
+ var parseBoolean = (value) => {
3531
+ if (value === "true") return true;
3532
+ if (value === "false") return false;
3533
+ throw new InvalidArgumentError("expected true or false");
3534
+ };
3535
+ var collectValues = (value, previous = []) => [...previous, value];
3536
+ var MIME_BY_EXT2 = {
3537
+ ".gif": "image/gif",
3538
+ ".jpeg": "image/jpeg",
3539
+ ".jpg": "image/jpeg",
3540
+ ".md": "text/markdown",
3541
+ ".pdf": "application/pdf",
3542
+ ".png": "image/png",
3543
+ ".svg": "image/svg+xml",
3544
+ ".txt": "text/plain",
3545
+ ".webp": "image/webp"
3546
+ };
3547
+ var guessMimeType2 = (filePath) => MIME_BY_EXT2[extname2(filePath).toLowerCase()] ?? "application/octet-stream";
3548
+ function createAttachmentOptions(opts) {
3549
+ const maxFiles = opts.maxFiles;
3550
+ const maxFileSize = opts.maxFileSize;
3551
+ const allowedMimeTypes = opts.allowedMime;
3552
+ if (!maxFiles && !maxFileSize && (!allowedMimeTypes || allowedMimeTypes.length === 0)) {
3553
+ return void 0;
3554
+ }
3555
+ return {
3556
+ attachment: {
3557
+ ...maxFiles ? { maxFiles } : {},
3558
+ ...maxFileSize ? { maxFileSize } : {},
3559
+ ...allowedMimeTypes && allowedMimeTypes.length > 0 ? { allowedMimeTypes } : {}
3560
+ }
3561
+ };
3562
+ }
3563
+ function mergeFieldOptions(existing, attachmentOptions) {
3564
+ if (!attachmentOptions) return existing;
3565
+ if (!existing || typeof existing !== "object" || Array.isArray(existing))
3566
+ return attachmentOptions;
3567
+ return { ...existing, ...attachmentOptions };
3568
+ }
3569
+ function parseFieldSpecs(specs) {
3570
+ return specs.map((spec) => {
3571
+ const [slug, name, type] = spec.split(":");
3572
+ return {
3573
+ slug,
3574
+ name: name ?? slug,
3575
+ ...type ? { type } : {}
3576
+ };
3577
+ });
3578
+ }
3579
+ function parseFieldDefinitions(opts) {
3580
+ const specs = opts.field ?? [];
3581
+ const fieldsJson = opts.fieldsJson;
3582
+ if (specs.length > 0 && fieldsJson) {
3583
+ throw new Error("Pass either --field or --fields-json, not both.");
3584
+ }
3585
+ if (fieldsJson) {
3586
+ const parsed = parseJsonValue(fieldsJson, "fields-json");
3587
+ if (!Array.isArray(parsed)) {
3588
+ throw new Error(
3589
+ '--fields-json must be a JSON array of field definitions. Example: [{"slug":"status","name":"Status","type":"select","options":{"choices":[{"id":"live","name":"Live"}]}}]'
3590
+ );
3591
+ }
3592
+ return parsed;
3593
+ }
3594
+ if (specs.length === 0) {
3595
+ throw new Error("Pass at least one --field or provide --fields-json @fields.json.");
3596
+ }
3597
+ return parseFieldSpecs(specs);
3598
+ }
3599
+ function parseFileNodeMetadata(opts) {
3600
+ const assetId = opts.assetId;
3601
+ if (!assetId) {
3602
+ throw new Error("--asset-id is required with --type file.");
3603
+ }
3604
+ return { assetId };
3605
+ }
3606
+ function parseJsonValue(raw, flagName) {
3607
+ const text = raw.startsWith("@") ? readFileSync2(raw.slice(1), "utf8") : raw;
3608
+ try {
3609
+ return JSON.parse(text);
3610
+ } catch (error) {
3611
+ const hint = raw.startsWith("@") ? `File ${raw.slice(1)} is not valid JSON.` : `Flag --${flagName} must be valid JSON. For complex values, write a file and pass --${flagName} @record.json.`;
3612
+ const reason = error instanceof Error ? error.message : String(error);
3613
+ throw new Error(`${hint}
3614
+ JSON parse error: ${reason}`);
3615
+ }
3616
+ }
3617
+ function formatRawErrorBody(status, statusText, text) {
3618
+ try {
3619
+ const parsed = JSON.parse(text);
3620
+ if (typeof parsed.error === "string") {
3621
+ const issues = parsed.data?.issues;
3622
+ let message = parsed.error;
3623
+ if (Array.isArray(issues) && issues.length > 0) {
3624
+ const details = issues.map((issue) => {
3625
+ if (!issue || typeof issue !== "object") return String(issue);
3626
+ const { path, message: issueMessage } = issue;
3627
+ const pathLabel = Array.isArray(path) ? path.join(".") : void 0;
3628
+ return pathLabel ? `${pathLabel}: ${issueMessage}` : String(issueMessage ?? issue);
3629
+ }).join("; ");
3630
+ message = `${message} \u2014 ${details}`;
3631
+ }
3632
+ return `HTTP ${status} ${statusText}: ${message}`;
3633
+ }
3634
+ } catch {
3635
+ }
3636
+ return `HTTP ${status} ${statusText}: ${text}`;
3637
+ }
3638
+ async function rawFetch(config, method, path, body) {
3639
+ const url = `${normalizeBaseUrl2(config.baseUrl)}${path}`;
3640
+ const res = await fetch(url, {
3641
+ method: method.toUpperCase(),
3642
+ headers: {
3643
+ ...body !== void 0 ? { "content-type": "application/json" } : {},
3644
+ ...config.apiKey ? { authorization: `Bearer ${config.apiKey}` } : {},
3645
+ ...config.spaceId ? { "x-busabase-space": config.spaceId } : {}
3646
+ },
3647
+ body: body !== void 0 ? JSON.stringify(body) : void 0
3648
+ });
3649
+ const text = await res.text();
3650
+ if (!res.ok) {
3651
+ throw new Error(formatRawErrorBody(res.status, res.statusText, text));
3652
+ }
3653
+ if (!text) return null;
3654
+ try {
3655
+ return JSON.parse(text);
3656
+ } catch {
3657
+ return text;
3658
+ }
3659
+ }
3660
+ async function rawRequest(config, method, path, body) {
3661
+ return rawFetch(config, method, path, body);
3662
+ }
3663
+ async function uploadAsset2(config, opts) {
3664
+ const filePath = opts.file;
3665
+ const file = readFileSync2(filePath);
3666
+ const fileName = opts.fileName ?? basename(filePath);
3667
+ const mimeType = opts.mimeType ?? guessMimeType2(filePath);
3668
+ const context = opts.context ?? "record-field";
3669
+ const contentHash = `sha256:${createHash3("sha256").update(file).digest("hex")}`;
3670
+ const requested = await rawRequest(config, "POST", "/api/v1/assets/upload-urls", {
3671
+ fileName,
3672
+ mimeType,
3673
+ sizeBytes: file.byteLength,
3674
+ context,
3675
+ contentHash
3676
+ });
3677
+ if (requested.duplicate) {
3678
+ return {
3679
+ id: requested.assetId ?? requested.attachmentId,
3680
+ assetId: requested.assetId,
3681
+ attachmentId: requested.attachmentId,
3682
+ url: requested.publicUrl,
3683
+ fileName,
3684
+ mimeType,
3685
+ size: file.byteLength
3686
+ };
3687
+ }
3688
+ if (requested.uploadUrl.startsWith("/")) {
3689
+ throw new Error(
3690
+ `Server returned a browser-relative upload URL (${requested.uploadUrl}). Use the Busabase UI for local/dev uploads or a cloud host with a presigned absolute upload URL.`
3691
+ );
3692
+ }
3693
+ const uploadResponse = await fetch(requested.uploadUrl, {
3694
+ body: new Uint8Array(file),
3695
+ headers: { "content-type": mimeType },
3696
+ method: "PUT"
3697
+ });
3698
+ if (!uploadResponse.ok) {
3699
+ const text = await uploadResponse.text();
3700
+ throw new Error(
3701
+ `Asset byte upload failed (${uploadResponse.status} ${uploadResponse.statusText})${text ? `: ${text}` : ""}`
3702
+ );
3703
+ }
3704
+ const confirmed = await rawRequest(config, "POST", "/api/v1/assets/confirmations", {
3705
+ storageKey: requested.storageKey,
3706
+ fileName,
3707
+ mimeType,
3708
+ sizeBytes: file.byteLength,
3709
+ context,
3710
+ contentHash
3711
+ });
3712
+ return {
3713
+ id: confirmed.assetId ?? confirmed.attachmentId,
3714
+ assetId: confirmed.assetId,
3715
+ attachmentId: confirmed.attachmentId,
3716
+ url: confirmed.publicUrl,
3717
+ fileName,
3718
+ mimeType,
3719
+ size: file.byteLength
3720
+ };
3721
+ }
3722
+ var INLINE_TEXT_MAX_BYTES = 1024 * 1024;
3723
+ async function putTextCommand(client, opts) {
3724
+ const assetId = opts.assetId;
3725
+ const text = opts.file ? readFileSync2(opts.file, "utf8") : opts.text;
3726
+ if (opts.none) {
3727
+ return client.assets.putText({ assetId, none: true });
3728
+ }
3729
+ if (text === void 0) {
3730
+ throw new Error("Provide --text <string>, --file <path>, or --none.");
3731
+ }
3732
+ const sizeBytes = Buffer.byteLength(text, "utf8");
3733
+ if (sizeBytes <= INLINE_TEXT_MAX_BYTES) {
3734
+ return client.assets.putText({ assetId, text });
3735
+ }
3736
+ const upload = await client.assets.createTextUploadUrl({ assetId, sizeBytes });
3737
+ const uploadResponse = await fetch(upload.uploadUrl, {
3738
+ method: "PUT",
3739
+ headers: { "content-type": "text/plain; charset=utf-8" },
3740
+ body: text
3741
+ });
3742
+ if (!uploadResponse.ok) {
3743
+ const body = await uploadResponse.text();
3744
+ throw new Error(
3745
+ `Text byte upload failed (${uploadResponse.status} ${uploadResponse.statusText})${body ? `: ${body}` : ""}`
3746
+ );
3747
+ }
3748
+ return client.assets.putText({ assetId, storageKey: upload.storageKey });
3749
+ }
3750
+ function runAction(state, handler) {
3751
+ return async (_opts, cmd) => {
3752
+ const opts = cmd.optsWithGlobals();
3753
+ const config = resolveConfig(opts);
3754
+ state.config = config;
3755
+ await maybeAutoRefresh(config.baseUrl, config.apiKey);
3756
+ const client = createBusabaseClient(config);
3757
+ const result = await handler(client, opts, config);
3758
+ console.log(render(result, config.output));
3759
+ };
3760
+ }
3761
+ function runArgAction(state, handler) {
3762
+ return async (arg, _opts, cmd) => {
3763
+ const opts = cmd.optsWithGlobals();
3764
+ const config = resolveConfig(opts);
3765
+ state.config = config;
3766
+ await maybeAutoRefresh(config.baseUrl, config.apiKey);
3767
+ const client = createBusabaseClient(config);
3768
+ const result = await handler(arg, client, opts, config);
3769
+ console.log(render(result, config.output));
3770
+ };
3771
+ }
3772
+ function pkgVersion() {
3773
+ try {
3774
+ const pkg = JSON.parse(readFileSync2(new URL("../package.json", import.meta.url), "utf8"));
3775
+ return pkg.version ?? "0.0.0";
3776
+ } catch {
3777
+ return "0.0.0";
3778
+ }
3779
+ }
3780
+ var kebab = (value) => value.replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase();
3781
+ var isProcedure = (node) => typeof node === "object" && node !== null && typeof node["~orpc"]?.route?.method === "string";
3782
+ function unwrapSchema(schema) {
3783
+ let inner = schema;
3784
+ let optional = false;
3785
+ for (let i = 0; i < 6 && inner?.def; i++) {
3786
+ const type = inner.def.type;
3787
+ if (type === "optional" || type === "default") {
3788
+ optional = true;
3789
+ inner = inner.def.innerType;
3790
+ } else if (type === "nullable") {
3791
+ inner = inner.def.innerType;
3792
+ } else break;
3793
+ }
3794
+ return { inner, optional };
3795
+ }
3796
+ function classifyKind(inner) {
3797
+ const type = inner?.def?.type;
3798
+ if (type === "string") return { kind: "string" };
3799
+ if (type === "number") return { kind: "number" };
3800
+ if (type === "boolean") return { kind: "boolean" };
3801
+ if (type === "literal") return { kind: "string" };
3802
+ if (type === "enum") {
3803
+ const choices = Array.isArray(inner.options) ? inner.options : Object.values(inner.def?.entries ?? {});
3804
+ return choices.length ? { kind: "enum", choices } : { kind: "string" };
3805
+ }
3806
+ return { kind: "json" };
3807
+ }
3808
+ function inputFields(inputSchema) {
3809
+ if (inputSchema === void 0) return [];
3810
+ const { inner } = unwrapSchema(inputSchema);
3811
+ const shape = inner?.shape ?? (inner?.def?.type === "object" ? inner.def.shape : void 0);
3812
+ if (!shape || typeof shape !== "object") {
3813
+ return [{ key: "input", kind: "json", required: true, root: true }];
3814
+ }
3815
+ const fields = [];
3816
+ for (const [key, sub] of Object.entries(shape)) {
3817
+ const { inner: unwrapped, optional } = unwrapSchema(sub);
3818
+ const { kind, choices } = classifyKind(unwrapped);
3819
+ fields.push({ key, kind, required: !optional, choices });
3820
+ }
3821
+ return fields;
3822
+ }
3823
+ var GENERATED_SKIP = /* @__PURE__ */ new Set([
3824
+ "search",
3825
+ // top-level `search`
3826
+ "auth.verify",
3827
+ // `whoami`
3828
+ "records.search",
3829
+ // `records by-field-text`
3830
+ "records.listChangeRequests",
3831
+ // `records change-requests`
3832
+ "airapps.listFiles"
3833
+ // `airapps files`
3834
+ ]);
3835
+ function registerGeneratedCommands(program, state) {
3836
+ const getGroup = (displayName, tag) => program.commands.find((c2) => c2.name() === displayName) ?? program.command(displayName).description(tag ?? `${displayName} endpoints`);
3837
+ const addLeaf = (parent, navPath, procKey, proc) => {
3838
+ const { route, inputSchema } = proc["~orpc"];
3839
+ const name = kebab(procKey);
3840
+ if (parent.commands.some((c2) => c2.name() === name)) return;
3841
+ const fields = inputFields(inputSchema);
3842
+ const leaf = parent.command(name).description(route.summary ?? `${route.method} ${route.path}`);
3843
+ for (const f of fields) {
3844
+ const flag = `--${kebab(f.key)}`;
3845
+ if (GLOBAL_LONG_FLAGS.has(flag)) continue;
3846
+ if (f.kind === "boolean") {
3847
+ leaf.option(flag, `${f.key} (boolean)`);
3848
+ } else if (f.kind === "json") {
3849
+ const jsonFlag = `${flag}-json <json|@file>`;
3850
+ const desc = `${f.key} as JSON${f.required ? "" : " (optional)"}`;
3851
+ if (f.required) leaf.requiredOption(jsonFlag, desc);
3852
+ else leaf.option(jsonFlag, desc);
3853
+ } else if (f.kind === "enum") {
3854
+ const opt = new Option(`${flag} <value>`, f.key).choices(f.choices ?? []);
3855
+ if (f.required) opt.makeOptionMandatory();
3856
+ leaf.addOption(opt);
3857
+ } else if (f.kind === "number") {
3858
+ const label = `${flag} <value>`;
3859
+ const desc = `${f.key}${f.required ? "" : " (optional)"}`;
3860
+ if (f.required) leaf.requiredOption(label, desc, parseNum);
3861
+ else leaf.option(label, desc, parseNum);
3862
+ } else {
3863
+ const label = `${flag} <value>`;
3864
+ const desc = `${f.key}${f.required ? "" : " (optional)"}`;
3865
+ if (f.required) leaf.requiredOption(label, desc);
3866
+ else leaf.option(label, desc);
3867
+ }
3868
+ }
3869
+ addGlobalFlags(leaf);
3870
+ leaf.addHelpText("after", `
3871
+ OpenAPI: ${route.method} ${route.path}`);
3872
+ leaf.action(
3873
+ runAction(state, (client, opts) => {
3874
+ let input = {};
3875
+ for (const f of fields) {
3876
+ if (GLOBAL_LONG_FLAGS.has(`--${kebab(f.key)}`)) continue;
3877
+ const optKey = f.kind === "json" ? `${f.key}Json` : f.key;
3878
+ const value = opts[optKey];
3879
+ if (value === void 0) continue;
3880
+ const parsed = f.kind === "json" ? parseJsonValue(value, `${kebab(f.key)}-json`) : value;
3881
+ if (f.root) input = parsed;
3882
+ else input[f.key] = parsed;
3883
+ }
3884
+ let target = client;
3885
+ for (const segment of navPath) target = target[segment];
3886
+ return fields.length > 0 ? target[procKey](input) : target[procKey]();
3887
+ })
3888
+ );
3889
+ };
3890
+ const walk2 = (node, navPath) => {
3891
+ for (const key of Object.keys(node)) {
3892
+ if (key === "~orpc") continue;
3893
+ const child = node[key];
3894
+ const id = [...navPath, key].join(".");
3895
+ if (isProcedure(child)) {
3896
+ if (GENERATED_SKIP.has(id)) continue;
3897
+ if (navPath.length > 1) continue;
3898
+ const parent = navPath.length ? getGroup(kebab(navPath[0]), child["~orpc"].route.tags?.[0]) : program;
3899
+ addLeaf(parent, navPath, key, child);
3900
+ } else if (child && typeof child === "object") {
3901
+ walk2(child, [...navPath, key]);
3902
+ }
3903
+ }
3904
+ };
3905
+ walk2(cloudContract, []);
3906
+ }
3907
+ function flagSignature(cmd) {
3908
+ const parts = [];
3909
+ for (const option of cmd.options) {
3910
+ if (option.name() === "help" || GLOBAL_LONG_FLAGS.has(option.long ?? "")) continue;
3911
+ parts.push(option.mandatory ? option.flags : `[${option.flags}]`);
3912
+ }
3913
+ return parts.join(" ");
3914
+ }
3915
+ var isHelpHidden = (cmd) => Boolean(cmd._noHelp);
3916
+ function commandsSection(program) {
3917
+ const lines = ["Commands:"];
3918
+ const entry = (prefix, cmd) => {
3919
+ const sig = [prefix, flagSignature(cmd)].filter(Boolean).join(" ");
3920
+ const desc = cmd.description();
3921
+ if (!desc) return ` ${sig}`;
3922
+ if (sig.length <= 40) return ` ${sig.padEnd(42)}${desc}`;
3923
+ return ` ${sig}
3924
+ ${" ".repeat(44)}${desc}`;
3925
+ };
3926
+ for (const cmd of program.commands) {
3927
+ if (cmd.name() === "help" || isHelpHidden(cmd)) continue;
3928
+ const leaves = cmd.commands.filter((leaf) => leaf.name() !== "help" && !isHelpHidden(leaf));
3929
+ if (leaves.length > 0) {
3930
+ lines.push("");
3931
+ for (const leaf of leaves) lines.push(entry(`${cmd.name()} ${leaf.name()}`, leaf));
3932
+ } else {
3933
+ lines.push(entry(cmd.name(), cmd));
3934
+ }
3935
+ }
3936
+ return lines.join("\n");
3937
+ }
3938
+ var HELP_FOOTER = `
3939
+ Config is read from flags, then env vars, then ~/.busabase/.env (auto-loaded \u2014 no
3940
+ need to source it). An exported env var overrides the file.
3941
+
3942
+ Docs: https://busabase.com/docs \xB7 Troubleshooting: ${DOCS_TROUBLESHOOTING}`;
3943
+ function buildProgram(state = {}) {
3944
+ const program = new Command("busabase-cli");
3945
+ program.description("Client for the Busabase OpenAPI REST API \u2014 talks to `busabase server` or Cloud.").usage("[global flags] <command> [flags]").version(pkgVersion(), "-v, --version", "print busabase-cli version").exitOverride().showHelpAfterError("(run `busabase-cli --help` for the full command list)").addHelpText("beforeAll", () => banner(resolveConfig(program.opts()).baseUrl));
3946
+ addGlobalFlags(program);
3947
+ addGlobalFlags(program.command("health")).description("Server health check (GET /api/health)").action(runAction(state, (_client, _opts, config) => rawFetch(config, "GET", "/api/health")));
3948
+ addGlobalFlags(program.command("openapi")).description("Fetch the OpenAPI document").action(
3949
+ runAction(state, (_client, _opts, config) => rawFetch(config, "GET", "/api/v1/openapi.json"))
3950
+ );
3951
+ addGlobalFlags(program.command("whoami")).description("Active space, user, and membership").action(runAction(state, (client) => client.auth.verify()));
3952
+ addGlobalFlags(program.command("login")).description("Connect the CLI to Busabase \u2014 Personal/local, Cloud, or self-hosted").option("--oauth", "force browser OAuth and skip the method prompt").option("--no-browser", "OAuth: print the sign-in URL instead of opening a browser").option("--refresh", "slide the saved OAuth session forward (no browser, no re-login)").addHelpText(
3953
+ "after",
3954
+ `
3955
+ Busabase is an approval-first database and knowledge base for AI agents: agents
3956
+ propose changes, humans review and merge what becomes trusted data.
3957
+
3958
+ Run interactively, login explains the connection choices and writes the selected
3959
+ Busabase instance to ~/.busabase/.env:
3960
+ 1. Local/Desktop on this computer \u2014 no account, no login
3961
+ 2. Busabase Cloud \u2014 browser sign-in (recommended)
3962
+ 3. Busabase Cloud \u2014 paste an API key
3963
+ 4. Self-hosted Busabase \u2014 browser sign-in
3964
+ 5. Self-hosted Busabase \u2014 paste an API key
3965
+ The flags below skip the menu (handy for scripts / CI):
3966
+
3967
+ busabase-cli login # pick from the menu
3968
+ busabase-cli login --oauth # Cloud browser sign-in
3969
+ busabase-cli login --api-key sk_\u2026 # Cloud API key (headless/CI)
3970
+ busabase-cli login --base-url http://localhost:15419 # connect to a local server (no auth)
3971
+ busabase-cli login --refresh # extend the current session (auto-runs too)`
3972
+ ).action(async (_opts, cmd) => {
3973
+ const opts = cmd.optsWithGlobals();
3974
+ const config = resolveConfig(opts);
3975
+ state.config = config;
3976
+ const summary = opts.refresh ? await runRefresh({ baseUrl: config.baseUrl, apiKey: config.apiKey }) : await runLogin({
3977
+ baseUrl: config.baseUrl,
3978
+ apiKey: opts.apiKey,
3979
+ spaceId: config.spaceId,
3980
+ oauth: Boolean(opts.oauth),
3981
+ browser: opts.browser !== false
3982
+ });
3983
+ console.log(render(summary, config.output));
3984
+ });
3985
+ addGlobalFlags(program.command("logout")).description("Revoke the saved OAuth session (if any) and clear ~/.busabase/.env").action(async (_opts, cmd) => {
3986
+ const opts = cmd.optsWithGlobals();
3987
+ const config = resolveConfig(opts);
3988
+ state.config = config;
3989
+ const summary = await runLogout({ baseUrl: config.baseUrl, apiKey: config.apiKey });
3990
+ console.log(render(summary, config.output));
3991
+ });
3992
+ const nodes = program.command("nodes").description("Workspace node tree");
3993
+ addGlobalFlags(nodes.command("list")).description("Workspace node tree").action(runAction(state, (client) => client.nodes.list()));
3994
+ addGlobalFlags(nodes.command("create-change-request")).description("Propose a new node via a Change Request").addOption(
3995
+ new Option(`--type <${CREATABLE_NODE_TYPES2.join("|")}>`, "node type").choices(CREATABLE_NODE_TYPES2).makeOptionMandatory()
3996
+ ).requiredOption("--slug <slug>", "node slug").requiredOption("--name <name>", "node name").option("--description <text>", "optional node description").option("--parent-node-id <id>", "parent folder node id; omit for root").option("--message <text>", "reviewer-facing Change Request message").option("--submitted-by <name>", "producer label").option("--field <slug:name:type...>", "base field, repeatable (for --type base)").option("--fields-json <json|@file>", "base fields as JSON array (for --type base)").option("--asset-id <id>", "backing Asset id (required for --type file)").addHelpText(
3997
+ "after",
3998
+ `
3999
+ Examples:
4000
+ busabase-cli nodes create-change-request --type folder --slug cms --name "\u5185\u5BB9\u7BA1\u7406 CMS"
4001
+ busabase-cli nodes create-change-request --type base --slug blog --name "\u535A\u5BA2\u6587\u7AE0 Blog Posts" --field title:Title:text --field body:Body:markdown
4002
+ busabase-cli nodes create-change-request --type base --slug products --name "\u4EA7\u54C1\u76EE\u5F55 Products" --fields-json @fields.json
4003
+ busabase-cli nodes create-change-request --type file --slug board-plan --name "Board Plan" --asset-id ast_123`
4004
+ ).action(
4005
+ runAction(state, (client, opts) => {
4006
+ const nodeType = opts.type;
4007
+ const name = opts.name;
4008
+ if (nodeType !== "base" && (opts.field || opts.fieldsJson)) {
4009
+ throw new Error("--field and --fields-json are only valid with --type base.");
4010
+ }
4011
+ if (nodeType !== "file" && opts.assetId) {
4012
+ throw new Error("--asset-id is only valid with --type file.");
4013
+ }
4014
+ return client.nodes.createChangeRequest({
4015
+ message: opts.message ?? `Create ${nodeType} ${name}`,
4016
+ submittedBy: opts.submittedBy,
4017
+ operations: [
4018
+ {
4019
+ kind: "create",
4020
+ nodeType,
4021
+ slug: opts.slug,
4022
+ name,
4023
+ description: opts.description,
4024
+ parentNodeId: opts.parentNodeId,
4025
+ ...nodeType === "base" ? { fields: parseFieldDefinitions(opts) } : {},
4026
+ ...nodeType === "file" ? { metadata: parseFileNodeMetadata(opts) } : {}
4027
+ }
4028
+ ]
4029
+ });
4030
+ })
4031
+ );
4032
+ addGlobalFlags(nodes.command("archive")).description(
4033
+ "Propose archiving a node via a Change Request (review-first by default) \u2014 the only way to move a node into the archived state; DELETE /nodes/{nodeId} (`nodes purge`) only permanently removes a node that is ALREADY archived"
4034
+ ).requiredOption("--node-id <id>", "node id to archive").option("--message <text>", "reviewer-facing Change Request message").option("--submitted-by <name>", "producer label").option(
4035
+ "--auto-merge",
4036
+ "skip review and archive immediately (default: propose a pending Change Request)"
4037
+ ).addHelpText(
4038
+ "after",
4039
+ `
4040
+ Examples:
4041
+ busabase-cli nodes archive --node-id nod_123
4042
+ busabase-cli nodes archive --node-id nod_123 --auto-merge # skip review, archive immediately`
4043
+ ).action(
4044
+ runAction(
4045
+ state,
4046
+ (client, opts) => client.nodes.createChangeRequest({
4047
+ message: opts.message ?? "Archive node",
4048
+ submittedBy: opts.submittedBy,
4049
+ autoMerge: Boolean(opts.autoMerge),
4050
+ operations: [{ kind: "delete", nodeId: opts.nodeId }]
4051
+ })
4052
+ )
4053
+ );
4054
+ addGlobalFlags(nodes.command("list-archived")).description(
4055
+ "List archived (soft-deleted) nodes \u2014 folders, Docs, Skills, etc. (the Trash view)"
4056
+ ).action(runAction(state, (client) => client.nodes.listArchived()));
4057
+ addGlobalFlags(nodes.command("purge")).description(
4058
+ "Permanently delete an ALREADY-archived node and its subtree (irreversible). Archive it first with `nodes archive` if it isn't archived yet."
4059
+ ).requiredOption("--node-id <id>", "archived node id to purge").action(
4060
+ runAction(state, (client, opts) => client.nodes.purge({ nodeId: opts.nodeId }))
4061
+ );
4062
+ addGlobalFlags(nodes.command("move")).description("Move or reorder a node (applied immediately, no review needed)").requiredOption("--node-id <id>", "node id to move").option("--parent-node-id <id>", "new parent folder node id; omit to keep the current parent").option(
4063
+ "--position <n>",
4064
+ "new position among the target parent's children",
4065
+ (v) => Number.parseInt(v, 10)
4066
+ ).option("--message <text>", "Change Request message").option("--submitted-by <name>", "producer label").addHelpText(
4067
+ "after",
4068
+ `
4069
+ Examples:
4070
+ busabase-cli nodes move --node-id nod_123 --position 0
4071
+ busabase-cli nodes move --node-id nod_123 --parent-node-id nod_456 --position 2`
4072
+ ).action(
4073
+ runAction(
4074
+ state,
4075
+ (client, opts) => client.nodes.move({
4076
+ nodeId: opts.nodeId,
4077
+ parentNodeId: opts.parentNodeId,
4078
+ position: opts.position,
4079
+ message: opts.message,
4080
+ submittedBy: opts.submittedBy
4081
+ })
4082
+ )
4083
+ );
4084
+ const bases = program.command("bases").description("Bases (structured tables)");
4085
+ addGlobalFlags(bases.command("list")).description("List Bases in the active space").action(runAction(state, (client) => client.bases.list()));
4086
+ addGlobalFlags(bases.command("get")).description("Get one Base by slug").requiredOption("--slug <slug>", "Base slug").action(
4087
+ runAction(state, async (client, opts) => {
4088
+ const slug = opts.slug;
4089
+ const found = (await client.bases.list()).find((base) => base.slug === slug);
4090
+ if (!found) throw new Error(`no Base with slug "${slug}"`);
4091
+ return found;
4092
+ })
4093
+ );
4094
+ addGlobalFlags(bases.command("create")).description("Create a Base \u2014 review-first by default: returns a pending Change Request").requiredOption("--slug <slug>", "Base slug").requiredOption("--name <name>", "Base name").option("--field <slug:name:type...>", "field definition, repeatable").option("--fields-json <json|@file>", "field definitions as JSON array").option("--description <text>", "optional description").option("--parent-node-id <id>", "parent folder node id; omit for root").option(
4095
+ "--auto-merge",
4096
+ "skip review and create the Base immediately (default: propose a pending Change Request)"
4097
+ ).addHelpText(
4098
+ "after",
4099
+ `
4100
+ Examples:
4101
+ busabase-cli bases create --slug products --name "\u4EA7\u54C1\u76EE\u5F55 Products" --field product_name:"Product Name":text
4102
+ busabase-cli bases create --slug products --name "\u4EA7\u54C1\u76EE\u5F55 Products" --fields-json @fields.json
4103
+ busabase-cli bases create --slug products --name "Products" --auto-merge # skip review, create immediately`
4104
+ ).action(
4105
+ runAction(
4106
+ state,
4107
+ (client, opts) => client.bases.create({
4108
+ slug: opts.slug,
4109
+ name: opts.name,
4110
+ description: opts.description,
4111
+ parentNodeId: opts.parentNodeId,
4112
+ fields: parseFieldDefinitions(opts),
4113
+ ...opts.autoMerge ? { autoMerge: true } : {}
4114
+ })
4115
+ )
4116
+ );
4117
+ addGlobalFlags(bases.command("create-field")).description("Add a field to a Base").requiredOption("--base-id <id>", "Base id").requiredOption("--slug <slug>", "field slug").requiredOption("--name <name>", "field name").option("--field-type <type>", "field type (default text)").option("--required", "mark the field as required").option("--max-files <n>", "attachment option: max files", parsePositiveInt).option("--max-file-size <bytes>", "attachment option: max size in bytes", parsePositiveInt).option(
4118
+ "--allowed-mime <mime>",
4119
+ "attachment option: allowed MIME type, repeatable",
4120
+ collectValues
4121
+ ).action(
4122
+ runAction(
4123
+ state,
4124
+ (client, opts) => client.bases.createField({
4125
+ baseId: opts.baseId,
4126
+ slug: opts.slug,
4127
+ name: opts.name,
4128
+ ...opts.fieldType ? { type: opts.fieldType } : {},
4129
+ required: Boolean(opts.required),
4130
+ ...createAttachmentOptions(opts) ? { options: createAttachmentOptions(opts) } : {}
4131
+ })
4132
+ )
4133
+ );
4134
+ addGlobalFlags(bases.command("update-field-change-request")).description("Propose updating field metadata/options via a Change Request").requiredOption("--base-id <id>", "Base id").requiredOption("--field-id <id>", "field id").option("--name <name>", "new field name").option("--required <true|false>", "set required flag", parseBoolean).option("--options-json <json|@file>", "full field options JSON, or @file.json").option("--max-files <n>", "attachment option: max files", parsePositiveInt).option("--max-file-size <bytes>", "attachment option: max size in bytes", parsePositiveInt).option(
4135
+ "--allowed-mime <mime>",
4136
+ "attachment option: allowed MIME type, repeatable",
4137
+ collectValues
4138
+ ).option("--message <text>", "reviewer-facing Change Request message").option("--submitted-by <name>", "producer label").addHelpText(
4139
+ "after",
4140
+ `
4141
+ Examples:
4142
+ busabase-cli bases update-field-change-request --base-id bse_123 --field-id bsf_123 --name "\u5C01\u9762 Cover Image" --max-files 1 --allowed-mime image/png --allowed-mime image/svg+xml
4143
+ busabase-cli bases update-field-change-request --base-id bse_123 --field-id bsf_123 --options-json @field-options.json`
4144
+ ).action(
4145
+ runAction(state, (client, opts) => {
4146
+ const patch = {};
4147
+ if (opts.name) patch.name = opts.name;
4148
+ if (opts.required !== void 0) patch.required = opts.required;
4149
+ if (opts.optionsJson) {
4150
+ patch.options = parseJsonValue(opts.optionsJson, "options-json");
4151
+ }
4152
+ const attachmentOptions = createAttachmentOptions(opts);
4153
+ patch.options = mergeFieldOptions(patch.options, attachmentOptions);
4154
+ if (Object.keys(patch).length === 0) {
4155
+ throw new Error(
4156
+ "No field patch supplied. Pass --name, --required, --options-json, or attachment option flags."
4157
+ );
4158
+ }
4159
+ return client.bases.updateFieldChangeRequest({
4160
+ baseId: opts.baseId,
4161
+ fieldId: opts.fieldId,
4162
+ patch,
4163
+ message: opts.message,
4164
+ submittedBy: opts.submittedBy
4165
+ });
4166
+ })
4167
+ );
4168
+ addGlobalFlags(bases.command("create-change-request")).description("Propose a new record via a Change Request \u2014 review-first by default").requiredOption("--base-id <id>", "target Base id").requiredOption("--fields-json <json|@file>", "record fields as JSON, or @file.json").option("--message <text>", "reviewer-facing Change Request message").option("--submitted-by <name>", "producer label").option(
4169
+ "--auto-merge",
4170
+ "skip review and create the record immediately (default: propose a pending Change Request)"
4171
+ ).addHelpText(
4172
+ "after",
4173
+ `
4174
+ Examples:
4175
+ busabase-cli bases create-change-request --base-id bse_123 --fields-json '{"title":"Hello","status":"draft"}'
4176
+ busabase-cli bases create-change-request --base-id bse_123 --fields-json @record.json
4177
+ busabase-cli bases create-change-request --base-id bse_123 --fields-json '{"title":"Hello"}' --auto-merge # skip review, create immediately`
4178
+ ).action(
4179
+ runAction(
4180
+ state,
4181
+ (client, opts) => client.bases.createChangeRequest({
4182
+ baseId: opts.baseId,
4183
+ fields: parseJsonValue(opts.fieldsJson, "fields-json"),
4184
+ message: opts.message,
4185
+ submittedBy: opts.submittedBy,
4186
+ ...opts.autoMerge ? { autoMerge: true } : {}
4187
+ })
4188
+ )
4189
+ );
4190
+ const airapps = program.command("airapps").description("AirApps (sandboxed mini-apps)");
4191
+ addGlobalFlags(airapps.command("list")).description("List AirApps in the active space").action(runAction(state, (client) => client.airapps.list()));
4192
+ addGlobalFlags(airapps.command("get")).description("Get one AirApp by node id").requiredOption("--node-id <id>", "AirApp node id").action(
4193
+ runAction(state, (client, opts) => client.airapps.get({ nodeId: opts.nodeId }))
4194
+ );
4195
+ addGlobalFlags(airapps.command("create")).description("Create an AirApp \u2014 review-first by default: returns a pending Change Request").requiredOption("--slug <slug>", "AirApp slug").requiredOption("--name <name>", "AirApp name").option("--description <text>", "optional description").option("--parent-node-id <id>", "parent folder node id; omit for root").addOption(
4196
+ new Option(
4197
+ "--visibility <private|workspace|public>",
4198
+ "AirApp visibility (default private)"
4199
+ ).choices(["private", "workspace", "public"])
4200
+ ).option("--version <semver>", "AirApp version (default 0.1.0)").option(
4201
+ "--files-json <json|@file>",
4202
+ 'files as a JSON array, or @file.json \u2014 text files {"path","content","mimeType?"} or asset-backed files {"path","assetId","displayName?","mimeType?"}'
4203
+ ).addOption(
4204
+ new Option(
4205
+ "--merge-mode <merge|replace>",
4206
+ "how --files-json combines with the default scaffold (default merge)"
4207
+ ).choices(["merge", "replace"])
4208
+ ).option(
4209
+ "--auto-merge",
4210
+ "skip review and create the AirApp immediately (default: propose a pending Change Request)"
4211
+ ).addHelpText(
4212
+ "after",
4213
+ `
4214
+ mergeMode explains how --files-json combines with the AirApp's default scaffold:
4215
+ merge (default) \u2014 your files are layered on top of the default Hono-template
4216
+ scaffold by path; supply just a few files (e.g. one custom
4217
+ route) and still get the rest of the scaffold for any path
4218
+ you didn't provide yourself.
4219
+ replace \u2014 your files replace the scaffold entirely; use this when
4220
+ handing over a complete, self-contained project (e.g. a Vite
4221
+ app) so you don't end up with stray unrelated default files
4222
+ mixed in.
4223
+
4224
+ Examples:
4225
+ busabase-cli airapps create --slug hello-app --name "Hello App" --files-json @files.json
4226
+ busabase-cli airapps create --slug vite-app --name "Vite App" --files-json @files.json --merge-mode replace
4227
+ busabase-cli airapps create --slug hello-app --name "Hello App" --files-json @files.json --auto-merge # skip review, create immediately`
4228
+ ).action(
4229
+ runAction(
4230
+ state,
4231
+ (client, opts) => client.airapps.create({
4232
+ slug: opts.slug,
4233
+ name: opts.name,
4234
+ description: opts.description,
4235
+ parentNodeId: opts.parentNodeId,
4236
+ visibility: opts.visibility,
4237
+ version: opts.version,
4238
+ files: opts.filesJson ? parseJsonValue(opts.filesJson, "files-json") : void 0,
4239
+ mergeMode: opts.mergeMode,
4240
+ ...opts.autoMerge ? { autoMerge: true } : {}
4241
+ })
4242
+ )
4243
+ );
4244
+ addGlobalFlags(airapps.command("files")).description("List an AirApp's files").requiredOption("--node-id <id>", "AirApp node id").action(
4245
+ runAction(
4246
+ state,
4247
+ (client, opts) => client.airapps.listFiles({ nodeId: opts.nodeId })
4248
+ )
4249
+ );
4250
+ addGlobalFlags(airapps.command("read-file")).description("Read one AirApp file's content").requiredOption("--node-id <id>", "AirApp node id").requiredOption("--path <path>", "file path within the AirApp").action(
4251
+ runAction(
4252
+ state,
4253
+ (client, opts) => client.airapps.readFile({ nodeId: opts.nodeId, filePath: opts.path })
4254
+ )
4255
+ );
4256
+ addGlobalFlags(airapps.command("create-change-request")).description("Propose file changes to an existing AirApp via a Change Request").requiredOption("--node-id <id>", "AirApp node id").requiredOption(
4257
+ "--operations-json <json|@file>",
4258
+ 'file operations as a JSON array, or @file.json \u2014 "create"/"update" (text {"kind","path","content","mimeType?"} or asset-backed {"kind","path","assetId","displayName?","mimeType?"}), "delete" {"kind","path"}, or "metadata_update" {"kind","metadata":{"entryFile?","visibility?","version?"}}'
4259
+ ).option("--message <text>", "reviewer-facing Change Request message").option("--submitted-by <name>", "producer label").addHelpText(
4260
+ "after",
4261
+ `
4262
+ Examples:
4263
+ busabase-cli airapps create-change-request --node-id nod_123 --operations-json @operations.json
4264
+ busabase-cli airapps create-change-request --node-id nod_123 --operations-json '[{"kind":"update","path":"index.js","content":"..."}]' --message "Fix handler bug"`
4265
+ ).action(
4266
+ runAction(
4267
+ state,
4268
+ (client, opts) => client.airapps.createChangeRequest({
4269
+ nodeId: opts.nodeId,
4270
+ message: opts.message,
4271
+ submittedBy: opts.submittedBy,
4272
+ operations: parseJsonValue(
4273
+ opts.operationsJson,
4274
+ "operations-json"
4275
+ )
4276
+ })
4277
+ )
4278
+ );
4279
+ const records = program.command("records").description("Records");
4280
+ addGlobalFlags(records.command("list")).description("List records").option("--limit <n>", "max results (1-100)", parseLimit).option("--base-id <id>", "restrict to one Base").option("--cursor <cursor>", "opaque nextCursor from a previous page").action(
4281
+ runAction(state, (_client, opts, config) => {
4282
+ const query = new URLSearchParams();
4283
+ if (opts.limit !== void 0) query.set("limit", String(opts.limit));
4284
+ if (opts.baseId) query.set("baseId", opts.baseId);
4285
+ if (opts.cursor) query.set("cursor", opts.cursor);
4286
+ const qs = query.toString();
4287
+ return rawRequest(config, "GET", `/api/v1/records/paged${qs ? `?${qs}` : ""}`);
4288
+ })
4289
+ );
4290
+ addGlobalFlags(records.command("get")).description("Get one record").requiredOption("--record-id <id>", "record id").action(
4291
+ runAction(state, (client, opts) => client.records.get({ recordId: opts.recordId }))
4292
+ );
4293
+ addGlobalFlags(records.command("by-field-text")).description("Find records by text field value").requiredOption("--field-slug <slug>", "field slug to match").requiredOption("--value-text <text>", "text value to match").option("--base-id <id>", "restrict to one Base").option("--limit <n>", "max results", parseNum).action(
4294
+ runAction(
4295
+ state,
4296
+ (client, opts) => client.records.search({
4297
+ fieldSlug: opts.fieldSlug,
4298
+ valueText: opts.valueText,
4299
+ baseId: opts.baseId,
4300
+ limit: opts.limit
4301
+ })
4302
+ )
4303
+ );
4304
+ addGlobalFlags(records.command("change-requests")).description("Change Requests for a record").requiredOption("--record-id <id>", "record id").action(
4305
+ runAction(
4306
+ state,
4307
+ (client, opts) => client.records.listChangeRequests({ recordId: opts.recordId })
4308
+ )
4309
+ );
4310
+ const changeRequests = program.command("change-requests").description("Change Requests");
4311
+ addGlobalFlags(changeRequests.command("list")).description("List Change Requests").option("--limit <n>", "max results", parseNum).action(
4312
+ runAction(
4313
+ state,
4314
+ (client, opts) => client.changeRequests.list({ limit: opts.limit })
4315
+ )
4316
+ );
4317
+ addGlobalFlags(changeRequests.command("get")).description("Get a Change Request").requiredOption("--change-request-id <id>", "Change Request id").action(
4318
+ runAction(
4319
+ state,
4320
+ (client, opts) => client.changeRequests.get({ changeRequestId: opts.changeRequestId })
4321
+ )
4322
+ );
4323
+ addGlobalFlags(changeRequests.command("review")).description("Review a Change Request (rejected = request changes, not terminal)").requiredOption("--change-request-id <id>", "Change Request id").addOption(
4324
+ new Option("--verdict <approved|rejected>", "review verdict").choices(["approved", "rejected"]).makeOptionMandatory()
4325
+ ).option("--reason <text>", "review reason").action(
4326
+ runAction(
4327
+ state,
4328
+ (client, opts) => client.changeRequests.review({
4329
+ changeRequestId: opts.changeRequestId,
4330
+ verdict: opts.verdict,
4331
+ reason: opts.reason
4332
+ })
4333
+ )
4334
+ );
4335
+ addGlobalFlags(changeRequests.command("close")).description("Terminally abandon/reject a Change Request").requiredOption("--change-request-id <id>", "Change Request id").option("--reason <text>", "close reason").action(
4336
+ runAction(
4337
+ state,
4338
+ (client, opts) => client.changeRequests.close({
4339
+ changeRequestId: opts.changeRequestId,
4340
+ reason: opts.reason
4341
+ })
4342
+ )
4343
+ );
4344
+ addGlobalFlags(changeRequests.command("merge")).description("Merge a Change Request into its Base").requiredOption("--change-request-id <id>", "Change Request id").action(
4345
+ runAction(
4346
+ state,
4347
+ (client, opts) => client.changeRequests.merge({ changeRequestId: opts.changeRequestId })
4348
+ )
4349
+ );
4350
+ addGlobalFlags(program.command("search")).description("Full-text search").requiredOption("--query <q>", "search query").option("--limit <n>", "max results", parseNum).option("--offset <n>", "results offset", parseNum).option(
4351
+ "--sources <sources...>",
4352
+ 'restrict which content to search: "records", "files", and/or "names" (default: all three)'
4353
+ ).action(
4354
+ runAction(
4355
+ state,
4356
+ (client, opts) => client.search({
4357
+ query: opts.query,
4358
+ limit: opts.limit,
4359
+ offset: opts.offset,
4360
+ sources: opts.sources
4361
+ })
4362
+ )
4363
+ );
4364
+ addGlobalFlags(program.command("grep")).description(
4365
+ "Search files, Docs, and Base records with one pattern (unified grep; use `assets grep` for files-only)"
4366
+ ).requiredOption("--pattern <regex>", "literal or regex pattern").option("--flags <flags>", 'RegExp flags, e.g. "i" for case-insensitive').option(
4367
+ "--sources <sources...>",
4368
+ 'sources to scan: "files", "docs", and/or "records" (default: all three)'
4369
+ ).option("--asset-ids <ids...>", "files scope: specific asset ids").option("--drive-path <path>", "files scope: Drive/Skill mounted path prefix").option("--mime-types <types...>", "files scope: MIME types").option("--node-ids <ids...>", "docs scope: specific Doc node ids").option("--base-ids <ids...>", "records scope: specific Base ids").option("--base-slugs <slugs...>", "records scope: specific Base slugs").option(
4370
+ "--max-matches <n>",
4371
+ "max matches, shared across sources (default 100, cap 1000)",
4372
+ parsePositiveInt
4373
+ ).option("--context-lines <n>", "lines of before/after context (default 0, cap 10)", parseNum).action(
4374
+ runAction(state, (client, opts) => {
4375
+ const filesScope = opts.assetIds || opts.drivePath || opts.mimeTypes ? {
4376
+ assetIds: opts.assetIds,
4377
+ drivePath: opts.drivePath,
4378
+ mimeTypes: opts.mimeTypes
4379
+ } : void 0;
4380
+ const docsScope = opts.nodeIds ? { nodeIds: opts.nodeIds } : void 0;
4381
+ const recordsScope = opts.baseIds || opts.baseSlugs ? {
4382
+ baseIds: opts.baseIds,
4383
+ baseSlugs: opts.baseSlugs
4384
+ } : void 0;
4385
+ return client.grep({
4386
+ pattern: opts.pattern,
4387
+ flags: opts.flags,
4388
+ sources: opts.sources,
4389
+ scope: filesScope || docsScope || recordsScope ? { files: filesScope, docs: docsScope, records: recordsScope } : void 0,
4390
+ maxMatches: opts.maxMatches,
4391
+ contextLines: opts.contextLines
4392
+ });
4393
+ })
4394
+ );
4395
+ const addUploadCommand = (parent, hidden = false) => addGlobalFlags(parent.command("upload", { noHelp: hidden })).description("Upload a file and print an asset ref for record fields").requiredOption("--file <path>", "local file to upload").option("--file-name <name>", "stored file name (default: basename of --file)").option("--mime-type <mime>", "MIME type (default: inferred from extension)").option("--context <value>", "upload context (default record-field)").addHelpText(
4396
+ "after",
4397
+ `
4398
+ Example:
4399
+ busabase-cli assets upload --file ./cover.png --output json
4400
+
4401
+ Use the JSON output directly in an attachment field value, e.g. {"cover_image":[<output>]}.`
4402
+ ).action(runAction(state, (_client, opts, config) => uploadAsset2(config, opts)));
4403
+ const assetsCommand = program.command("assets").description("Assets");
4404
+ addUploadCommand(assetsCommand);
4405
+ addGlobalFlags(assetsCommand.command("put-text")).description("Write (or mark none) an asset's Drive Grep Retrieval text slot").requiredOption("--asset-id <id>", "asset id").option("--text <string>", "inline text (\u22641MB)").option("--file <path>", "read text from a local file instead of --text").option("--none", "mark as having no extractable text (e.g. a scanned, image-only PDF)").addHelpText(
4406
+ "after",
4407
+ `
4408
+ Examples:
4409
+ busabase-cli assets put-text --asset-id ast_123 --file ./extracted.txt
4410
+ busabase-cli assets put-text --asset-id ast_123 --text "plain text"
4411
+ busabase-cli assets put-text --asset-id ast_123 --none`
4412
+ ).action(runAction(state, (client, opts) => putTextCommand(client, opts)));
4413
+ addGlobalFlags(assetsCommand.command("grep")).description("Search every text-bearing asset in scope").requiredOption("--pattern <regex>", "literal or regex pattern").option("--flags <flags>", 'RegExp flags, e.g. "i" for case-insensitive').option("--asset-ids <ids...>", "scope: specific asset ids").option("--drive-path <path>", "scope: Drive/Skill mounted path prefix").option("--mime-types <types...>", "scope: MIME types").option("--max-matches <n>", "max matches (default 100, cap 1000)", parsePositiveInt).option("--context-lines <n>", "lines of before/after context (default 0, cap 10)", parseNum).action(
4414
+ runAction(
4415
+ state,
4416
+ (client, opts) => client.assets.grep({
4417
+ pattern: opts.pattern,
4418
+ flags: opts.flags,
4419
+ scope: opts.assetIds || opts.drivePath || opts.mimeTypes ? {
4420
+ assetIds: opts.assetIds,
4421
+ drivePath: opts.drivePath,
4422
+ mimeTypes: opts.mimeTypes
4423
+ } : void 0,
4424
+ maxMatches: opts.maxMatches,
4425
+ contextLines: opts.contextLines
4426
+ })
4427
+ )
4428
+ );
4429
+ addGlobalFlags(assetsCommand.command("read-lines")).description("Read an exact line range from an asset's text (range capped at 2000 lines)").requiredOption("--asset-id <id>", "asset id").requiredOption("--start-line <n>", "first line (1-based)", parsePositiveInt).requiredOption("--end-line <n>", "last line (1-based)", parsePositiveInt).action(
4430
+ runAction(
4431
+ state,
4432
+ (client, opts) => client.assets.readTextLines({
4433
+ assetId: opts.assetId,
4434
+ startLine: opts.startLine,
4435
+ endLine: opts.endLine
4436
+ })
4437
+ )
4438
+ );
4439
+ addGlobalFlags(program.command("install")).description("Install a Busabase package from a GitHub repo into this space").argument("<github-url>", "GitHub repo URL, optionally /tree/<ref>[/<subdir>]").option("--into-folder <name>", "target folder slug (default: the package's manifest name)").option("--dry-run", "print the plan (tree, record counts, collisions) and create nothing").option(
4440
+ "--auto-merge",
4441
+ "merge the package's records and docs on the spot instead of leaving them as change requests to review \u2014 this TRUSTS THE PACKAGE AUTHOR, since skills and AirApps carry code your agents will run (default: review first)"
4442
+ ).option(
4443
+ "--rename",
4444
+ "install colliding items under suffixed slugs (-2, -3, \u2026) instead of failing"
4445
+ ).addHelpText(
4446
+ "after",
4447
+ `
4448
+ The URL's git ref is the version pin \u2014 a tag installs that tag's content forever,
4449
+ even after the branch moves on. GITHUB_TOKEN is honored for private repos.
4450
+
4451
+ Examples:
4452
+ busabase-cli install https://github.com/acme/support-kb-template
4453
+ busabase-cli install https://github.com/acme/packages/tree/v1.2.0/skills/pdf-summarizer
4454
+ busabase-cli install https://github.com/acme/support-kb-template --dry-run
4455
+ busabase-cli install https://github.com/acme/support-kb-template --into-folder support --auto-merge
4456
+
4457
+ Folders, Bases, their fields and their views are structure and are always created
4458
+ immediately. Records are content: by default they land as change requests for you
4459
+ to review, and --auto-merge merges them on the spot instead.
4460
+
4461
+ A package whose records carry relation values requires --auto-merge \u2014 a relation
4462
+ stores the ids of the records it points at, and those exist only once the records
4463
+ are merged, so review-first would install every relation empty. Defining a relation
4464
+ field with nothing linked yet does not trigger this.`
4465
+ ).action(
4466
+ runArgAction(
4467
+ state,
4468
+ (repoUrl, client, opts, config) => runInstall(client, repoUrl, {
4469
+ intoFolder: opts.intoFolder,
4470
+ dryRun: Boolean(opts.dryRun),
4471
+ autoMerge: Boolean(opts.autoMerge),
4472
+ rename: Boolean(opts.rename),
4473
+ json: config.output === "json",
4474
+ githubToken: process.env.GITHUB_TOKEN
4475
+ })
4476
+ )
4477
+ );
4478
+ addGlobalFlags(program.command("publish")).description("Export a node subtree as a Busabase package directory you can push to GitHub").argument("<node-slug-or-id>", "the folder/node to publish").requiredOption("-o, --out-dir <dir>", "output directory for the package").option("--name <name>", "package name (default: reuse busabase.json, else the node slug)").option("--dry-run", "list the files that would be written and write nothing").addHelpText(
4479
+ "after",
4480
+ `
4481
+ Output is deterministic: publishing twice from an unchanged space produces
4482
+ byte-identical files, so a GitHub diff shows exactly what changed.
4483
+
4484
+ Examples:
4485
+ busabase-cli publish support-kb -o ./support-kb-template
4486
+ busabase-cli publish support-kb -o ./support-kb-template --name "Support KB" --dry-run`
4487
+ ).action(
4488
+ runArgAction(
4489
+ state,
4490
+ (nodeSlugOrId, client, opts, config) => runPublish(client, nodeSlugOrId, {
4491
+ outDir: opts.outDir,
4492
+ name: opts.name,
4493
+ dryRun: Boolean(opts.dryRun),
4494
+ json: config.output === "json",
4495
+ baseUrl: config.baseUrl
4496
+ })
4497
+ )
4498
+ );
4499
+ addGlobalFlags(program.command("api")).description("Raw request to any /api/v1 endpoint").addOption(
4500
+ new Option("--method <get|post|put|delete>", "HTTP method").choices(["get", "post", "put", "delete"]).makeOptionMandatory()
4501
+ ).requiredOption("--path <p>", "path under /api/v1, e.g. /bases").option("--query <k=v...>", "query-string param, repeatable").option("--body-json <json>", "JSON request body").action(
4502
+ runAction(state, (_client, opts, config) => {
4503
+ const query = new URLSearchParams();
4504
+ for (const pair of opts.query ?? []) {
4505
+ const [key, ...rest] = pair.split("=");
4506
+ query.set(key, rest.join("="));
4507
+ }
4508
+ const qs = query.toString();
4509
+ const path = `/api/v1${opts.path}${qs ? `?${qs}` : ""}`;
4510
+ const bodyJson = opts.bodyJson;
4511
+ return rawFetch(
4512
+ config,
4513
+ opts.method,
4514
+ path,
4515
+ bodyJson ? parseJsonValue(bodyJson, "body-json") : void 0
4516
+ );
4517
+ })
4518
+ );
4519
+ registerGeneratedCommands(program, state);
4520
+ for (const group of program.commands) {
4521
+ const leaves = group.commands.length > 0 ? group.commands : [group];
4522
+ for (const leaf of leaves) {
4523
+ const sig = flagSignature(leaf);
4524
+ leaf.usage(`${sig ? `${sig} ` : ""}[global flags]`);
4525
+ }
4526
+ }
4527
+ program.addHelpText("after", () => `
4528
+ ${commandsSection(program)}
4529
+ ${HELP_FOOTER}`);
4530
+ program.configureHelp({ visibleCommands: () => [] });
4531
+ return program;
4532
+ }
4533
+ async function runCli(argv) {
4534
+ const state = {};
4535
+ const program = buildProgram(state);
4536
+ if (argv.length === 0) {
4537
+ program.outputHelp();
4538
+ return 0;
4539
+ }
4540
+ try {
4541
+ await program.parseAsync(argv, { from: "user" });
4542
+ return 0;
4543
+ } catch (error) {
4544
+ if (error instanceof CommanderError) {
4545
+ return error.exitCode === 0 ? 0 : 1;
4546
+ }
4547
+ console.error(explainError(error, state.config ?? resolveConfig({})));
4548
+ return 1;
4549
+ }
4550
+ }
4551
+ function renderedHelp() {
4552
+ let out = "";
4553
+ const program = buildProgram();
4554
+ program.configureOutput({
4555
+ writeOut: (str) => {
4556
+ out += str;
4557
+ },
4558
+ writeErr: () => {
4559
+ }
4560
+ });
4561
+ program.outputHelp();
4562
+ return out;
4563
+ }
4564
+ var HELP = renderedHelp();
4565
+ function explainError(error, config) {
4566
+ const base = config.baseUrl;
4567
+ const msg = error instanceof Error ? error.message : String(error);
4568
+ const status = error.status;
4569
+ const lower = msg.toLowerCase();
4570
+ let body;
4571
+ if (status === 401 || lower.includes("401") || lower.includes("unauthorized")) {
4572
+ body = [
4573
+ `Unauthorized (401) from ${base}.`,
4574
+ config.apiKey ? " The credential was rejected or expired \u2014 run `busabase-cli login` to sign in again (browser OAuth or an API key)." : " This host needs sign-in. Run `busabase-cli login` (browser OAuth or an API key), or pass --api-key <token> / export BUSABASE_API_KEY=\u2026.",
4575
+ " Meant to hit a local server? Add --base-url http://localhost:15419 (or export BUSABASE_BASE_URL=\u2026)."
4576
+ ].join("\n");
4577
+ } else if (lower.includes("fetch failed") || lower.includes("econnrefused") || lower.includes("enotfound") || lower.includes("getaddrinfo") || lower.includes("network")) {
4578
+ body = [
4579
+ `Could not reach ${base}.`,
4580
+ " \u2022 Cloud: check your internet connection and that the URL is correct.",
4581
+ " \u2022 Local: start it with `npx busabase server` (http://localhost:15419), then add --base-url http://localhost:15419 (or export BUSABASE_BASE_URL=\u2026).",
4582
+ ` (underlying error: ${msg})`
4583
+ ].join("\n");
4584
+ } else {
4585
+ body = `${msg}
4586
+ (base URL: ${base}${config.apiKey ? ", with API key" : ", no API key"})`;
4587
+ }
4588
+ return `${body}
4589
+ \u2192 Docs: ${DOCS_TROUBLESHOOTING}`;
4590
+ }
4591
+
4592
+ export {
4593
+ render,
4594
+ runCli,
4595
+ HELP
4596
+ };