busa-cli 0.19.0 → 0.19.2

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.
@@ -56,7 +56,7 @@ const OPERATIONS$1 = [
56
56
  * audience, the exact silent-drop failure the server-side rejection exists to
57
57
  * end. `params.appliesWhen` still documents which operations accept it.
58
58
  */
59
- const mergeIntent$4 = (input) => {
59
+ const mergeIntent$5 = (input) => {
60
60
  if (input.requireReview) return { autoMerge: false };
61
61
  if (input.autoMerge) return { autoMerge: true };
62
62
  return {};
@@ -246,7 +246,7 @@ const baseFieldChangeTask = {
246
246
  baseId: input.baseId,
247
247
  ...input.message ? { message: input.message } : {},
248
248
  ...input.submittedBy ? { submittedBy: input.submittedBy } : {},
249
- ...mergeIntent$4(input)
249
+ ...mergeIntent$5(input)
250
250
  };
251
251
  switch (input.operation) {
252
252
  case "add": return client.bases.fieldChangeRequest({
@@ -629,7 +629,7 @@ const nodeReadFileTask = {
629
629
  * A batch containing a `delete` is review-first server-side no matter what this
630
630
  * sends, so don't imply otherwise in the flag's description.
631
631
  */
632
- const mergeIntent$3 = (input) => {
632
+ const mergeIntent$4 = (input) => {
633
633
  if (input.requireReview) return { autoMerge: false };
634
634
  if (input.autoMerge) return { autoMerge: true };
635
635
  return {};
@@ -699,7 +699,7 @@ const nodeFilesChangeRequestTask = {
699
699
  operations: input.operations,
700
700
  ...input.message ? { message: input.message } : {},
701
701
  ...input.submittedBy ? { submittedBy: input.submittedBy } : {},
702
- ...mergeIntent$3(input)
702
+ ...mergeIntent$4(input)
703
703
  };
704
704
  return client.fileTrees.createChangeRequest(payload);
705
705
  }
@@ -1424,7 +1424,7 @@ const rejectMismatchedPayload = (input) => {
1424
1424
  if (wrong.length > 0) throw new Error(`${wrong.join(", ")} ${wrong.length > 1 ? "are" : "is"} not valid for --type ${type}.`);
1425
1425
  };
1426
1426
  /** `autoMerge` is tri-state: unset (permission-aware default), forced on, forced off. */
1427
- const mergeIntent$2 = (input) => {
1427
+ const mergeIntent$3 = (input) => {
1428
1428
  if (input.requireReview) return { autoMerge: false };
1429
1429
  if (input.autoMerge) return { autoMerge: true };
1430
1430
  return {};
@@ -1637,7 +1637,7 @@ const nodeCreateTask = {
1637
1637
  name: input.name,
1638
1638
  description: input.description,
1639
1639
  parentNodeId: input.parentNodeId,
1640
- ...mergeIntent$2(input)
1640
+ ...mergeIntent$3(input)
1641
1641
  };
1642
1642
  if (FILE_TREE_TYPES.has(type)) {
1643
1643
  const payload = {
@@ -1675,7 +1675,7 @@ const nodeCreateTask = {
1675
1675
  return client.nodes.createChangeRequest({
1676
1676
  message: input.message ?? `Create ${type} ${input.name}`,
1677
1677
  submittedBy: input.submittedBy,
1678
- ...mergeIntent$2(input),
1678
+ ...mergeIntent$3(input),
1679
1679
  operations: [{
1680
1680
  kind: "create",
1681
1681
  nodeType: type,
@@ -1688,6 +1688,74 @@ const nodeCreateTask = {
1688
1688
  }
1689
1689
  };
1690
1690
  //#endregion
1691
+ //#region ../../packages/busabase-contract/src/tasks/record-bulk-update.ts
1692
+ const mergeIntent$2 = (input) => {
1693
+ if (input.requireReview) return { autoMerge: false };
1694
+ if (input.autoMerge) return { autoMerge: true };
1695
+ return {};
1696
+ };
1697
+ const recordBulkUpdateTask = {
1698
+ name: "record_bulk_update_change_request",
1699
+ cliPath: ["records", "bulk-update-change-request"],
1700
+ summary: "Propose partial updates to many records in one ChangeRequest",
1701
+ guidance: "All updates must target active records in the same Base. Each recordId may appear once. Each fields object is a partial update: omitted keys stay unchanged and null clears a field. The batch is reviewed and merged atomically. Use baseCommitId per update when the caller must pin the version it read. Review is permission-aware; pass requireReview to force a pending ChangeRequest even when the key has write access.",
1702
+ annotations: {
1703
+ readOnly: false,
1704
+ destructive: false
1705
+ },
1706
+ params: [
1707
+ {
1708
+ name: "baseId",
1709
+ kind: "string",
1710
+ required: true,
1711
+ description: "Base owning every record."
1712
+ },
1713
+ {
1714
+ name: "updates",
1715
+ kind: "json",
1716
+ required: true,
1717
+ description: "JSON array of {recordId, fields, baseCommitId?, message?}, e.g. [{\"recordId\":\"rec_1\",\"fields\":{\"status\":\"published\"}}]."
1718
+ },
1719
+ {
1720
+ name: "message",
1721
+ kind: "string",
1722
+ description: "Reviewer-facing message for the batch."
1723
+ },
1724
+ {
1725
+ name: "submittedBy",
1726
+ kind: "string",
1727
+ description: "Producer label recorded on the change."
1728
+ },
1729
+ {
1730
+ name: "idempotencyKey",
1731
+ kind: "string",
1732
+ description: "Retry key scoped to this Base and submitter."
1733
+ },
1734
+ {
1735
+ name: "autoMerge",
1736
+ kind: "boolean",
1737
+ description: "Apply immediately when the actor has write access."
1738
+ },
1739
+ {
1740
+ name: "requireReview",
1741
+ kind: "boolean",
1742
+ description: "Force one pending ChangeRequest even when the actor has write access."
1743
+ }
1744
+ ],
1745
+ examples: ["busabase-cli records bulk-update-change-request --base-id bas_1 --updates-json '[{\"recordId\":\"rec_1\",\"fields\":{\"status\":\"published\"}}]' --require-review", "busabase-cli records bulk-update-change-request --base-id bas_1 --updates-json @updates.json --idempotency-key august-review-v1"],
1746
+ execute: async (client, input) => {
1747
+ if (!Array.isArray(input.updates)) throw new Error("`updates` must be a JSON array.");
1748
+ return client.bases.createBulkUpdateChangeRequest({
1749
+ baseId: input.baseId,
1750
+ updates: input.updates,
1751
+ ...input.message ? { message: input.message } : {},
1752
+ ...input.submittedBy ? { submittedBy: input.submittedBy } : {},
1753
+ ...input.idempotencyKey ? { idempotencyKey: input.idempotencyKey } : {},
1754
+ ...mergeIntent$2(input)
1755
+ });
1756
+ }
1757
+ };
1758
+ //#endregion
1691
1759
  //#region ../../packages/busabase-contract/src/tasks/record-change.ts
1692
1760
  /**
1693
1761
  * Changing an existing record: update, delete (archive), or restore — one task
@@ -1905,7 +1973,7 @@ const recordQueryTask = {
1905
1973
  examples: [
1906
1974
  "busabase-cli records query --base-id bas_123 --limit 100",
1907
1975
  "busabase-cli records count --base-id bas_123",
1908
- "busabase-cli records count --base-id bas_123 --filters '[{\"fieldSlug\":\"branch\",\"fieldType\":\"text\",\"operator\":\"equals\",\"value\":\"main\"}]'",
1976
+ "busabase-cli records count --base-id bas_123 --filters-json '[{\"fieldSlug\":\"branch\",\"fieldType\":\"text\",\"operator\":\"equals\",\"value\":\"main\"}]'",
1909
1977
  "busabase-cli records count --base-id bas_123 --view-id viw_456"
1910
1978
  ],
1911
1979
  execute: async (client, input) => {
@@ -2120,11 +2188,60 @@ const BUSABASE_TASKS = [
2120
2188
  changeRequestMergeTask,
2121
2189
  baseFieldChangeTask,
2122
2190
  recordChangeTask,
2191
+ recordBulkUpdateTask,
2123
2192
  viewChangeTask,
2124
2193
  nodePermissionTask,
2125
2194
  nodeShareTask,
2126
2195
  listArchivedTask
2127
2196
  ];
2197
+ /**
2198
+ * Endpoint-level MCP tools fully covered by a task above, keyed by the tool name
2199
+ * the OpenAPI generator produces. The MCP adapter excludes these so a catalog
2200
+ * carries one way to do each job rather than two.
2201
+ *
2202
+ * Kept as tool names (not contract paths) because that is what the MCP layer
2203
+ * filters on, and it is the name an agent would actually have seen.
2204
+ *
2205
+ * NOT listed on purpose:
2206
+ * - `nodes_create_change_request` — `node_create` covers only a single `create`
2207
+ * operation. The endpoint also does `rename`, and composes several operations
2208
+ * into one change request (`ref`/`parentNodeRef` builds a whole subtree
2209
+ * atomically). Excluding it would be a capability regression, not a cleanup.
2210
+ * - `forms_create` — configures an existing node, so it is a second step after
2211
+ * `node_create --type form`, not a duplicate of it.
2212
+ * - `nodes_purge` — permanent delete is a genuinely different operation from
2213
+ * `node_archive`, and `node_archive`'s guidance now tells an agent when each
2214
+ * one applies.
2215
+ */
2216
+ const TASK_SUPERSEDED_MCP_TOOLS = [
2217
+ "file_trees_create",
2218
+ "docs_create",
2219
+ "bases_create",
2220
+ "files_create",
2221
+ "records_search",
2222
+ "file_trees_list_files",
2223
+ "file_trees_read_file",
2224
+ "file_trees_create_change_request",
2225
+ "records_list",
2226
+ "records_count",
2227
+ "change_requests_list",
2228
+ "change_requests_counts",
2229
+ "change_requests_review",
2230
+ "change_requests_review_many",
2231
+ "change_requests_merge",
2232
+ "change_requests_merge_many",
2233
+ "bases_field_change_request",
2234
+ "records_change_request",
2235
+ "bases_create_bulk_update_change_request",
2236
+ "views_change_request",
2237
+ "nodes_principals_list",
2238
+ "nodes_principals_add",
2239
+ "nodes_principals_remove",
2240
+ "nodes_share_get",
2241
+ "nodes_share_set",
2242
+ "nodes_share_disable",
2243
+ "bases_list_deleted_fields"
2244
+ ];
2128
2245
  //#endregion
2129
2246
  //#region src/config-file.ts
2130
2247
  /**
@@ -3269,15 +3386,168 @@ function banner(baseUrl) {
3269
3386
  return out.join("\n");
3270
3387
  }
3271
3388
  //#endregion
3389
+ //#region src/errors.ts
3390
+ /**
3391
+ * Process exit code per class.
3392
+ *
3393
+ * `1` stays the code for an unclassified error, so a script testing `!= 0` is
3394
+ * unaffected and only a script testing `== 1` sees a change.
3395
+ */
3396
+ const EXIT_CODES = {
3397
+ ERROR: 1,
3398
+ USAGE: 2,
3399
+ UNAUTHORIZED: 3,
3400
+ FORBIDDEN: 3,
3401
+ NOT_FOUND: 4,
3402
+ VALIDATION: 5,
3403
+ CONFLICT: 6,
3404
+ RATE_LIMITED: 7,
3405
+ SERVER_ERROR: 8,
3406
+ NETWORK: 9,
3407
+ REVIEW_REJECTED: 10,
3408
+ TIMEOUT: 11
3409
+ };
3410
+ const RETRYABLE = /* @__PURE__ */ new Set([
3411
+ "RATE_LIMITED",
3412
+ "SERVER_ERROR",
3413
+ "NETWORK"
3414
+ ]);
3415
+ /** `data.issues` as the server sends it, flattened. Shared with `explainError`'s prose rendering. */
3416
+ function toIssues(raw) {
3417
+ if (!Array.isArray(raw) || raw.length === 0) return void 0;
3418
+ const issues = raw.map((issue) => {
3419
+ if (!issue || typeof issue !== "object") return {
3420
+ path: "",
3421
+ message: String(issue)
3422
+ };
3423
+ const { path, message } = issue;
3424
+ return {
3425
+ path: Array.isArray(path) ? path.join(".") : typeof path === "string" ? path : "",
3426
+ message: String(message ?? issue)
3427
+ };
3428
+ });
3429
+ return issues.length > 0 ? issues : void 0;
3430
+ }
3431
+ const codeForStatus = (status) => {
3432
+ if (status === 401) return "UNAUTHORIZED";
3433
+ if (status === 403) return "FORBIDDEN";
3434
+ if (status === 404) return "NOT_FOUND";
3435
+ if (status === 409) return "CONFLICT";
3436
+ if (status === 429) return "RATE_LIMITED";
3437
+ if (status === 400 || status === 422) return "VALIDATION";
3438
+ if (status >= 500) return "SERVER_ERROR";
3439
+ return "ERROR";
3440
+ };
3441
+ /**
3442
+ * A transport failure never reaches the server, so it has no status to read.
3443
+ * Node reports every one of them as a bare `TypeError: fetch failed` with the
3444
+ * real reason on `.cause`, which is why this matches on text rather than type.
3445
+ */
3446
+ const isNetworkFailure = (message) => /fetch failed|econnrefused|enotfound|getaddrinfo|econnreset|etimedout|socket hang up|network/i.test(message);
3447
+ /**
3448
+ * `rawFetch` (health / openapi / `api` passthrough) throws a plain `Error`
3449
+ * carrying `HTTP <status> <statusText>: …` rather than an `ORPCError`, so the
3450
+ * status has to be read back out of the message for those paths.
3451
+ */
3452
+ const statusFromMessage = (message) => {
3453
+ const match = /\bHTTP (\d{3})\b/.exec(message);
3454
+ return match ? Number(match[1]) : void 0;
3455
+ };
3456
+ /** Classify any thrown value into the stable shape above. Never throws. */
3457
+ function classifyError(error) {
3458
+ const message = error instanceof Error ? error.message : String(error);
3459
+ const outcome = error?.cliCode;
3460
+ if (typeof outcome === "string" && outcome in EXIT_CODES) {
3461
+ const code = outcome;
3462
+ return {
3463
+ code,
3464
+ message,
3465
+ retryable: RETRYABLE.has(code),
3466
+ exitCode: EXIT_CODES[code]
3467
+ };
3468
+ }
3469
+ const issues = toIssues(error?.data?.issues);
3470
+ const status = typeof error?.status === "number" ? error.status : statusFromMessage(message);
3471
+ let code;
3472
+ if (status !== void 0) code = codeForStatus(status);
3473
+ else if (isNetworkFailure(message)) code = "NETWORK";
3474
+ else code = "ERROR";
3475
+ const orpcCode = error?.code;
3476
+ if (code === "ERROR" && typeof orpcCode === "string") {
3477
+ if (orpcCode === "NOT_FOUND") code = "NOT_FOUND";
3478
+ else if (orpcCode === "UNAUTHORIZED") code = "UNAUTHORIZED";
3479
+ else if (orpcCode === "FORBIDDEN") code = "FORBIDDEN";
3480
+ else if (orpcCode === "CONFLICT") code = "CONFLICT";
3481
+ else if (orpcCode === "BAD_REQUEST" || orpcCode === "UNPROCESSABLE_CONTENT") code = "VALIDATION";
3482
+ else if (orpcCode === "TOO_MANY_REQUESTS") code = "RATE_LIMITED";
3483
+ }
3484
+ return {
3485
+ code,
3486
+ ...status !== void 0 ? { status } : {},
3487
+ message,
3488
+ ...issues ? { issues } : {},
3489
+ retryable: RETRYABLE.has(code),
3490
+ exitCode: EXIT_CODES[code]
3491
+ };
3492
+ }
3493
+ /** The stdout envelope for a failed command under `--output json`. */
3494
+ function errorEnvelope(classified) {
3495
+ return {
3496
+ ok: false,
3497
+ code: classified.code,
3498
+ ...classified.status !== void 0 ? { status: classified.status } : {},
3499
+ message: classified.message,
3500
+ ...classified.issues ? { issues: classified.issues } : {},
3501
+ retryable: classified.retryable
3502
+ };
3503
+ }
3504
+ /**
3505
+ * A command that ran fine but ended in an outcome the caller must branch on —
3506
+ * a proposal a reviewer turned down, or a `--wait` that ran out of budget.
3507
+ *
3508
+ * Carries its own class so {@link classifyError} does not have to guess from a
3509
+ * message, and so the envelope reports it exactly like any other failure.
3510
+ */
3511
+ var CliOutcomeError = class extends Error {
3512
+ cliCode;
3513
+ constructor(cliCode, message) {
3514
+ super(message);
3515
+ this.name = "CliOutcomeError";
3516
+ this.cliCode = cliCode;
3517
+ }
3518
+ };
3519
+ //#endregion
3272
3520
  //#region src/format.ts
3273
3521
  /** Render a command result as JSON, a compact text view, or an aligned table. */
3274
3522
  function render(value, output) {
3275
3523
  if (output === "json") return JSON.stringify(value, null, 2);
3276
3524
  if (output === "text") return renderText(value);
3277
3525
  if (Array.isArray(value)) return renderTable(value);
3526
+ const envelope = asEnvelope(value);
3527
+ if (envelope) return renderEnvelope(envelope, renderTable);
3278
3528
  if (value && typeof value === "object") return renderRecord(value);
3279
3529
  return String(value);
3280
3530
  }
3531
+ function asEnvelope(value) {
3532
+ if (!value || typeof value !== "object" || Array.isArray(value)) return void 0;
3533
+ const entries = Object.entries(value);
3534
+ const arrays = entries.filter((entry) => Array.isArray(entry[1]));
3535
+ if (arrays.length !== 1) return void 0;
3536
+ const meta = entries.filter(([, item]) => !Array.isArray(item));
3537
+ if (!meta.every(([, item]) => item === null || item === void 0 || typeof item !== "object")) return void 0;
3538
+ return {
3539
+ key: arrays[0][0],
3540
+ rows: arrays[0][1],
3541
+ meta
3542
+ };
3543
+ }
3544
+ function renderEnvelope(envelope, renderRows) {
3545
+ const body = envelope.rows.length === 0 ? `(no ${envelope.key})` : renderRows(envelope.rows);
3546
+ const meta = envelope.meta.filter(([, item]) => item !== null && item !== void 0);
3547
+ if (meta.length === 0) return body;
3548
+ const width = Math.max(...meta.map(([key]) => key.length));
3549
+ return `${body}\n\n${meta.map(([key, item]) => `${key.padEnd(width)} ${cell(item)}`).join("\n")}`;
3550
+ }
3281
3551
  function cell(value) {
3282
3552
  if (value === null || value === void 0) return "";
3283
3553
  if (typeof value === "object") return compactJson(value);
@@ -3320,6 +3590,8 @@ function renderTable(rows) {
3320
3590
  ].join("\n");
3321
3591
  }
3322
3592
  function renderText(value) {
3593
+ const envelope = asEnvelope(value);
3594
+ if (envelope) return renderEnvelope(envelope, renderText);
3323
3595
  if (Array.isArray(value)) {
3324
3596
  if (value.length === 0) return "(no rows)";
3325
3597
  if (isNodeTree(value)) return renderNodeTree$1(value);
@@ -3369,6 +3641,48 @@ function nodeIcon(type) {
3369
3641
  default: return "[node]";
3370
3642
  }
3371
3643
  }
3644
+ /**
3645
+ * Drop hydrated parents from a result, keeping the id that points at them.
3646
+ *
3647
+ * A record VO is ~4.5 KB, of which 3.6 KB is a full copy of its parent `base`
3648
+ * — repeated identically on every row, so ten records cost 151 KB and nine
3649
+ * copies of that Base are pure waste to any caller that already knows which
3650
+ * Base it asked about. `createdByUser` and `headCommit` are the same pattern.
3651
+ *
3652
+ * The test for "this is a hydration, not content" is deliberately structural
3653
+ * rather than a list of field names, so it does not rot as VOs change: an
3654
+ * object-valued key is dropped only when a sibling STRING key already
3655
+ * identifies the same thing — `base`/`baseId`, `headCommit`/`headCommitId`,
3656
+ * `createdByUser`/`createdBy`. Nothing that lacks such a sibling is touched,
3657
+ * which is why a Base's `fields`, a node's `children`, and `whoami`'s `space`
3658
+ * all survive: dropping those would remove the answer, not a duplicate.
3659
+ */
3660
+ function slim(value) {
3661
+ if (Array.isArray(value)) return value.map(slim);
3662
+ if (!value || typeof value !== "object") return value;
3663
+ const row = value;
3664
+ const scalarKeys = Object.entries(row).filter(([, item]) => typeof item === "string").map(([key]) => key);
3665
+ const out = {};
3666
+ for (const [key, item] of Object.entries(row)) {
3667
+ if (isHydrated(key, item, scalarKeys)) continue;
3668
+ out[key] = slim(item);
3669
+ }
3670
+ return out;
3671
+ }
3672
+ /**
3673
+ * `carriesPayload` is the guard that stops this from eating the answer. A
3674
+ * record's `headCommit` is structurally indistinguishable from its `base` — an
3675
+ * object whose `id` is already on the row as `headCommitId` — but it is where
3676
+ * the record's FIELD VALUES live (`headCommit.payload`), so dropping it would
3677
+ * return ten rows of ids and no data. Every commit-shaped object in this
3678
+ * contract carries `payload`; no hydrated parent does.
3679
+ */
3680
+ const isHydrated = (key, item, scalarKeys) => {
3681
+ if (item === null || typeof item !== "object") return false;
3682
+ if (carriesPayload(item)) return false;
3683
+ return scalarKeys.some((scalar) => scalar !== key && (scalar === `${key}Id` || key.startsWith(scalar)));
3684
+ };
3685
+ const carriesPayload = (item) => Array.isArray(item) ? item.some((entry) => entry !== null && typeof entry === "object" && carriesPayload(entry)) : "payload" in item;
3372
3686
  //#endregion
3373
3687
  //#region ../../packages/busabase-contract/src/auth/device-authorization.ts
3374
3688
  /** Public client identifier shared by the Busabase CLI and Cloud auth server. */
@@ -4545,8 +4859,7 @@ z.discriminatedUnion("operation", [archiveBaseInputSchema.extend({
4545
4859
  * when a node carries no `icon`, every host falls back to the type icon, same
4546
4860
  * as before this field existed.
4547
4861
  *
4548
- * The `attachment` variant mirrors buda's `NodeLogo` shape (see
4549
- * `apps/buda/src/domains/agent-controller/components/use-logo-crop-upload.tsx`):
4862
+ * The `attachment` variant follows the shared avatar-cropping model:
4550
4863
  * `url`/`attachmentId` are the CROPPED display image actually rendered, while
4551
4864
  * `originalUrl`/`originalAttachmentId` + `crop` are kept so the crop dialog can
4552
4865
  * re-open non-destructively against the untouched source image instead of
@@ -4988,14 +5301,14 @@ z.object({
4988
5301
  page: 1,
4989
5302
  pageSize: 50
4990
5303
  });
4991
- z.object({
5304
+ const listChangeRequestsPageResponseSchema = z.object({
4992
5305
  changeRequests: z.array(changeRequestSchema),
4993
5306
  total: z.number().int().nonnegative(),
4994
5307
  totalPages: z.number().int().nonnegative(),
4995
5308
  page: z.number().int().min(1),
4996
5309
  pageSize: z.number().int().min(1).max(100)
4997
5310
  });
4998
- z.object({
5311
+ const changeRequestCountsSchema = z.object({
4999
5312
  review: z.number().int().nonnegative(),
5000
5313
  changes: z.number().int().nonnegative(),
5001
5314
  created: z.number().int().nonnegative(),
@@ -5003,6 +5316,7 @@ z.object({
5003
5316
  merged: z.number().int().nonnegative(),
5004
5317
  rejected: z.number().int().nonnegative()
5005
5318
  });
5319
+ listChangeRequestsPageResponseSchema.extend({ counts: changeRequestCountsSchema });
5006
5320
  const SEARCH_SOURCES = [
5007
5321
  "records",
5008
5322
  "files",
@@ -5681,8 +5995,75 @@ const reportUnresolvableDocAssets = (slug, body, carried = /* @__PURE__ */ new S
5681
5995
  if (unresolved.length === 0) return void 0;
5682
5996
  return `Doc "${slug}" embeds ${unresolved.length} image(s) that this package does not carry (${unresolved.join(", ")}). Everything else about the doc is intact, but those images will not render on another host. Re-upload them in the Doc editor, or check that the source host can still serve them.`;
5683
5997
  };
5998
+ //#endregion
5999
+ //#region ../../packages/busabase-package/src/record-dependencies.ts
6000
+ const recordIdentity = (baseSlug, recordKey) => `${baseSlug}\u0000${recordKey}`;
6001
+ const toRecordKeyArray = (value) => {
6002
+ if (Array.isArray(value)) return value.filter((item) => typeof item === "string" && item.length > 0);
6003
+ if (typeof value === "string" && value.length > 0) return [value];
6004
+ return [];
6005
+ };
6006
+ const collectBases = (nodes) => {
6007
+ const bases = [];
6008
+ for (const node of nodes) {
6009
+ if (node.type === "base") bases.push(node);
6010
+ if (node.type === "folder") bases.push(...collectBases(node.children));
6011
+ }
6012
+ return bases;
6013
+ };
6014
+ const label = (baseSlug, recordKey) => `${baseSlug}/${recordKey}`;
6015
+ /**
6016
+ * Order sample records so every required relation points at an already-created row.
6017
+ *
6018
+ * Optional relations remain a separate linking pass. Required relations cannot: the
6019
+ * Base rejects a record before that later pass if its required value is absent.
6020
+ */
6021
+ const buildRecordCreateLayers = (nodes) => {
6022
+ const bases = collectBases(nodes);
6023
+ const basesBySlug = new Map(bases.map((base) => [base.slug, base]));
6024
+ const records = /* @__PURE__ */ new Map();
6025
+ for (const base of bases) for (const record of base.records) records.set(recordIdentity(base.slug, record.key), {
6026
+ base,
6027
+ record
6028
+ });
6029
+ const dependencies = /* @__PURE__ */ new Map();
6030
+ for (const planned of records.values()) {
6031
+ const id = recordIdentity(planned.base.slug, planned.record.key);
6032
+ const required = /* @__PURE__ */ new Set();
6033
+ for (const field of planned.base.base.fields) {
6034
+ if (field.type !== "relation" || !field.required) continue;
6035
+ const targetBaseSlug = field.options.targetBaseSlug;
6036
+ if (!targetBaseSlug || !basesBySlug.has(targetBaseSlug)) throw new Error(`Required relation "${planned.base.slug}.${field.slug}" targets missing Base "${targetBaseSlug || "(unset)"}". Nothing was installed.`);
6037
+ const keys = toRecordKeyArray(planned.record.fields[field.slug]);
6038
+ if (keys.length === 0) throw new Error(`Sample record "${label(planned.base.slug, planned.record.key)}" is missing required relation "${field.slug}". Nothing was installed.`);
6039
+ for (const key of keys) {
6040
+ const dependency = recordIdentity(targetBaseSlug, key);
6041
+ if (!records.has(dependency)) throw new Error(`Sample record "${label(planned.base.slug, planned.record.key)}" requires "${targetBaseSlug}/${key}" through relation "${field.slug}", but that target is not in this package. Nothing was installed.`);
6042
+ required.add(dependency);
6043
+ }
6044
+ }
6045
+ dependencies.set(id, required);
6046
+ }
6047
+ const remaining = new Map(records);
6048
+ const created = /* @__PURE__ */ new Set();
6049
+ const layers = [];
6050
+ while (remaining.size > 0) {
6051
+ const ready = [...remaining.entries()].filter(([id]) => [...dependencies.get(id) ?? []].every((dependency) => created.has(dependency))).sort(([, a], [, b]) => `${a.base.slug}\u0000${a.record.key}`.localeCompare(`${b.base.slug}\u0000${b.record.key}`, "en"));
6052
+ if (ready.length === 0) {
6053
+ const cycle = [...remaining.values()].map((item) => label(item.base.slug, item.record.key)).sort((a, b) => a.localeCompare(b, "en"));
6054
+ throw new Error(`Required sample relations contain a cycle involving: ${cycle.join(", ")}. Required relation targets must be creatable first. Nothing was installed.`);
6055
+ }
6056
+ layers.push(ready.map(([, planned]) => planned));
6057
+ for (const [id] of ready) {
6058
+ remaining.delete(id);
6059
+ created.add(id);
6060
+ }
6061
+ }
6062
+ return layers;
6063
+ };
5684
6064
  const fieldKey = (baseSlug, fieldSlug) => `${baseSlug}\u0000${fieldSlug}`;
5685
6065
  const applyInstall = async (client, plan, options) => {
6066
+ const recordCreateLayers = buildRecordCreateLayers(plan.tree.nodes);
5686
6067
  const state = {
5687
6068
  targetFolderNodeId: "",
5688
6069
  created: {
@@ -5701,90 +6082,144 @@ const applyInstall = async (client, plan, options) => {
5701
6082
  const bases = [];
5702
6083
  const progress = options.onProgress ?? (() => {});
5703
6084
  const app = resolveAppContext(plan, options);
5704
- const docAssetIds = await uploadDocAssets(client, plan.tree.assets ?? [], state, progress, options.serverUrl);
5705
- progress("Pass 1/5: creating the node tree…");
5706
- if (plan.existingTargetFolderNodeId) state.targetFolderNodeId = plan.existingTargetFolderNodeId;
5707
- else {
5708
- state.targetFolderNodeId = await createFolderNode(client, {
5709
- parentNodeId: void 0,
5710
- slug: plan.targetFolderSlug,
5711
- name: plan.tree.manifest.name,
5712
- description: plan.tree.manifest.description,
5713
- submittedBy: options.submittedBy,
5714
- metadata: app?.rootMetadata
5715
- });
5716
- state.created.folders++;
5717
- }
5718
- await createStructure(client, plan.tree.nodes, state.targetFolderNodeId, options, state, bases, fieldIds, docAssetIds, app);
5719
- if (app?.rootSkill) {
5720
- progress("Pass 1/5: installing the app skill…");
5721
- await createFileTreeNode(client, {
5722
- type: "skill",
5723
- slug: app.rootSkill.slug,
5724
- name: app.rootSkill.name,
5725
- description: app.rootSkill.description,
5726
- position: void 0,
5727
- files: app.rootSkill.files
5728
- }, state.targetFolderNodeId, options, state, app, { [TEMPLATE_SKILL_METADATA_KEY]: true });
5729
- }
5730
- const deferred = bases.filter((base) => base.node.base.fields.some(isDeferredField));
5731
- if (deferred.length > 0) {
5732
- progress("Pass 2/5: adding relation and AI fields…");
5733
- for (const base of deferred) for (const field of base.node.base.fields.filter(isDeferredField)) {
5734
- const updated = await client.bases.createField({
5735
- baseId: base.baseId,
5736
- slug: field.slug,
5737
- name: field.name,
5738
- type: field.type,
5739
- required: field.required,
5740
- options: toApiFieldOptions(field.options)
5741
- });
5742
- indexFields(fieldIds, base.node.slug, updated.fields);
5743
- }
5744
- }
5745
- const patchable = bases.flatMap((base) => base.node.base.fields.filter(needsFieldIdPatch).map((field) => ({
5746
- base,
5747
- field
5748
- })));
5749
- if (patchable.length > 0) {
5750
- progress("Pass 3/5: resolving inverse and AI source field references…");
5751
- for (const { base, field } of patchable) {
5752
- const fieldId = fieldIds.get(fieldKey(base.node.slug, field.slug));
5753
- if (!fieldId) throw new Error(`Internal error: no field id for ${base.node.slug}.${field.slug} in pass 3.`);
5754
- const patched = resolveFieldIdOptions(field, base.node.slug, fieldIds, state.warnings);
5755
- if (!patched) continue;
5756
- const changeRequest = await client.bases.fieldChangeRequest({
5757
- operation: "update",
5758
- baseId: base.baseId,
5759
- fieldId,
5760
- patch: { options: patched },
5761
- message: `Wire up ${base.node.slug}.${field.slug} references`,
6085
+ let phase = "Pass 1/5 (structure)";
6086
+ try {
6087
+ const docAssetIds = await uploadDocAssets(client, plan.tree.assets ?? [], state, progress, options.serverUrl);
6088
+ progress("Pass 1/5: creating the node tree…");
6089
+ if (plan.existingTargetFolderNodeId) state.targetFolderNodeId = plan.existingTargetFolderNodeId;
6090
+ else {
6091
+ state.targetFolderNodeId = await createFolderNode(client, {
6092
+ parentNodeId: void 0,
6093
+ slug: plan.targetFolderSlug,
6094
+ name: plan.tree.manifest.name,
6095
+ description: plan.tree.manifest.description,
5762
6096
  submittedBy: options.submittedBy,
5763
- autoMerge: true
6097
+ metadata: app?.rootMetadata
5764
6098
  });
5765
- if (changeRequest.status !== "merged") throw new Error(`Field reference wiring for ${base.node.slug}.${field.slug} was requested immediately but came back "${changeRequest.status}"; install cannot leave a Base's relation fields unwired.`);
6099
+ state.created.folders++;
5766
6100
  }
5767
- }
5768
- const recordIdsByKey = /* @__PURE__ */ new Map();
5769
- const withRecords = bases.filter((base) => base.node.records.length > 0);
5770
- if (withRecords.length > 0) {
5771
- progress("Pass 4/5: creating records…");
5772
- for (const base of withRecords) await createRecords(client, base, plan, options, state, recordIdsByKey, app);
5773
- }
5774
- const relationFieldSlugs = /* @__PURE__ */ new Map();
5775
- for (const base of bases) {
5776
- const slugs = base.node.base.fields.filter((field) => field.type === "relation").map((field) => field.slug);
5777
- if (slugs.length > 0) relationFieldSlugs.set(base.node.slug, slugs);
5778
- }
5779
- if (relationFieldSlugs.size > 0) {
5780
- progress("Pass 5/5: linking relations…");
6101
+ await createStructure(client, plan.tree.nodes, state.targetFolderNodeId, options, state, bases, fieldIds, docAssetIds, app);
6102
+ if (app?.rootSkill) {
6103
+ progress("Pass 1/5: installing the app skill…");
6104
+ await createFileTreeNode(client, {
6105
+ type: "skill",
6106
+ slug: app.rootSkill.slug,
6107
+ name: app.rootSkill.name,
6108
+ description: app.rootSkill.description,
6109
+ position: void 0,
6110
+ files: app.rootSkill.files
6111
+ }, state.targetFolderNodeId, options, state, app, { [TEMPLATE_SKILL_METADATA_KEY]: true });
6112
+ }
6113
+ phase = "Pass 2/5 (relation and AI fields)";
6114
+ const deferred = bases.filter((base) => base.node.base.fields.some(isDeferredField));
6115
+ if (deferred.length > 0) {
6116
+ progress("Pass 2/5: adding relation and AI fields…");
6117
+ for (const base of deferred) for (const field of base.node.base.fields.filter(isDeferredField)) {
6118
+ const updated = await client.bases.createField({
6119
+ baseId: base.baseId,
6120
+ slug: field.slug,
6121
+ name: field.name,
6122
+ type: field.type,
6123
+ required: field.required,
6124
+ options: toApiFieldOptions(field.options)
6125
+ });
6126
+ indexFields(fieldIds, base.node.slug, updated.fields);
6127
+ }
6128
+ }
6129
+ phase = "Pass 3/5 (field reference wiring)";
6130
+ const patchable = bases.flatMap((base) => base.node.base.fields.filter(needsFieldIdPatch).map((field) => ({
6131
+ base,
6132
+ field
6133
+ })));
6134
+ if (patchable.length > 0) {
6135
+ progress("Pass 3/5: resolving inverse and AI source field references…");
6136
+ for (const { base, field } of patchable) {
6137
+ const fieldId = fieldIds.get(fieldKey(base.node.slug, field.slug));
6138
+ if (!fieldId) throw new Error(`Internal error: no field id for ${base.node.slug}.${field.slug} in pass 3.`);
6139
+ const patched = resolveFieldIdOptions(field, base.node.slug, fieldIds, state.warnings);
6140
+ if (!patched) continue;
6141
+ const changeRequest = await client.bases.fieldChangeRequest({
6142
+ operation: "update",
6143
+ baseId: base.baseId,
6144
+ fieldId,
6145
+ patch: { options: patched },
6146
+ message: `Wire up ${base.node.slug}.${field.slug} references`,
6147
+ submittedBy: options.submittedBy,
6148
+ autoMerge: true
6149
+ });
6150
+ if (changeRequest.status !== "merged") throw new Error(`Field reference wiring for ${base.node.slug}.${field.slug} was requested immediately but came back "${changeRequest.status}"; install cannot leave a Base's relation fields unwired.`);
6151
+ }
6152
+ }
6153
+ phase = "Pass 4/5 (sample records)";
6154
+ const recordIdsByKey = /* @__PURE__ */ new Map();
6155
+ if (recordCreateLayers.length > 0) {
6156
+ progress("Pass 4/5: creating records…");
6157
+ const basesBySlug = new Map(bases.map((base) => [base.node.slug, base]));
6158
+ for (const layer of recordCreateLayers) {
6159
+ const byBase = /* @__PURE__ */ new Map();
6160
+ for (const planned of layer) {
6161
+ const group = byBase.get(planned.base.slug) ?? [];
6162
+ group.push(planned);
6163
+ byBase.set(planned.base.slug, group);
6164
+ }
6165
+ for (const [baseSlug, planned] of [...byBase].sort(([a], [b]) => a.localeCompare(b, "en"))) {
6166
+ const base = basesBySlug.get(baseSlug);
6167
+ if (!base) throw new Error(`Internal error: no materialized Base for ${baseSlug} in pass 4.`);
6168
+ await createRecords(client, base, planned.map((item) => item.record), plan, options, state, recordIdsByKey, app);
6169
+ }
6170
+ }
6171
+ }
6172
+ phase = "Pass 5/5 (relation links)";
6173
+ const relationFields = /* @__PURE__ */ new Map();
5781
6174
  for (const base of bases) {
5782
- const slugs = relationFieldSlugs.get(base.node.slug);
5783
- if (!slugs) continue;
5784
- await linkRelations(client, base, slugs, recordIdsByKey, options, state);
6175
+ const fields = base.node.base.fields.filter((field) => field.type === "relation");
6176
+ if (fields.length > 0) relationFields.set(base.node.slug, fields);
5785
6177
  }
6178
+ if (relationFields.size > 0) {
6179
+ progress("Pass 5/5: linking relations…");
6180
+ for (const base of bases) {
6181
+ const fields = relationFields.get(base.node.slug);
6182
+ if (!fields) continue;
6183
+ await linkRelations(client, base, fields, recordIdsByKey, options, state);
6184
+ }
6185
+ }
6186
+ return state;
6187
+ } catch (error) {
6188
+ const created = Object.entries(state.created).filter(([, count]) => count > 0).map(([kind, count]) => `${count} ${kind}`).join(", ");
6189
+ const partial = created ? `Created before failure: ${created}. Target folder: ${plan.targetFolderSlug}.` : "No package resources were created.";
6190
+ const pending = state.pendingChangeRequests ? ` ${state.pendingChangeRequests} ChangeRequest(s) may still be pending.` : "";
6191
+ const reason = error instanceof Error ? error.message : String(error);
6192
+ const message = `Install stopped during ${phase}. ${partial}${pending} Cause: ${reason}`;
6193
+ const details = {
6194
+ phase,
6195
+ targetFolderSlug: plan.targetFolderSlug,
6196
+ created: { ...state.created },
6197
+ pendingChangeRequests: state.pendingChangeRequests
6198
+ };
6199
+ if (error instanceof Error) {
6200
+ error.message = message;
6201
+ throw attachInstallFailureDetails(error, details);
6202
+ }
6203
+ throw attachInstallFailureDetails(new Error(message), details);
5786
6204
  }
5787
- return state;
6205
+ };
6206
+ /**
6207
+ * A property rather than a subclass: the whole point of the rethrow above is to
6208
+ * keep the original error's prototype (an `ORPCError`'s `code`/`status`/`data`
6209
+ * are what make a permission or quota failure actionable), so the diagnostics
6210
+ * have to ride along on it instead of replacing it.
6211
+ */
6212
+ const INSTALL_FAILURE_DETAILS_KEY = "busabaseInstallFailure";
6213
+ const attachInstallFailureDetails = (error, details) => {
6214
+ try {
6215
+ Object.defineProperty(error, INSTALL_FAILURE_DETAILS_KEY, {
6216
+ value: details,
6217
+ enumerable: false,
6218
+ configurable: true,
6219
+ writable: true
6220
+ });
6221
+ } catch {}
6222
+ return error;
5788
6223
  };
5789
6224
  const resolveAppContext = (plan, options) => {
5790
6225
  if (!plan.isTemplate) return void 0;
@@ -6140,28 +6575,39 @@ const resolveFieldIdOptions = (field, baseSlug, fieldIds, warnings) => {
6140
6575
  const indexFields = (fieldIds, baseSlug, fields) => {
6141
6576
  for (const field of fields) fieldIds.set(fieldKey(baseSlug, field.slug), field.id);
6142
6577
  };
6143
- /** Values the server computes, and relation values (pass 5), are never sent in pass 4. */
6144
- const toCreatableFields = (node, fields) => {
6145
- const typeBySlug = new Map(node.base.fields.map((field) => [field.slug, field.type]));
6578
+ /**
6579
+ * Values the server computes and optional relations are omitted in pass 4.
6580
+ * Required relations are resolved to canonical ids once their dependency layer exists.
6581
+ */
6582
+ const toCreatableFields = (node, fields, recordIdsByKey) => {
6583
+ const fieldBySlug = new Map(node.base.fields.map((field) => [field.slug, field]));
6146
6584
  const result = {};
6147
6585
  for (const [slug, value] of Object.entries(fields)) {
6148
- const type = typeBySlug.get(slug);
6149
- if (!type) continue;
6150
- if (type === "relation") continue;
6151
- if (PACKAGE_COMPUTED_FIELD_TYPES.includes(type)) continue;
6152
- if (type === "attachment") continue;
6586
+ const field = fieldBySlug.get(slug);
6587
+ if (!field) continue;
6588
+ if (field.type === "relation") {
6589
+ if (!field.required) continue;
6590
+ const targetBaseSlug = field.options.targetBaseSlug;
6591
+ const keys = toRecordKeyArray(value);
6592
+ const targetIds = keys.map((key) => targetBaseSlug ? recordIdsByKey.get(recordIdentity(targetBaseSlug, key)) : void 0).filter((id) => Boolean(id));
6593
+ if (targetIds.length !== keys.length) throw new Error(`Internal error: required relation ${node.slug}.${slug} was scheduled before its targets existed.`);
6594
+ result[slug] = targetIds;
6595
+ continue;
6596
+ }
6597
+ if (PACKAGE_COMPUTED_FIELD_TYPES.includes(field.type)) continue;
6598
+ if (field.type === "attachment") continue;
6153
6599
  result[slug] = value;
6154
6600
  }
6155
6601
  return result;
6156
6602
  };
6157
- const createRecords = async (client, base, plan, options, state, recordIdsByKey, app) => {
6603
+ const createRecords = async (client, base, recordsToCreate, plan, options, state, recordIdsByKey, app) => {
6158
6604
  const mergeRecords = options.autoMerge || (app?.mergeSampleRecords ?? false);
6159
- const records = [...base.node.records].sort((a, b) => a.key.localeCompare(b.key, "en"));
6605
+ const records = [...recordsToCreate].sort((a, b) => a.key.localeCompare(b.key, "en"));
6160
6606
  for (let offset = 0; offset < records.length; offset += 200) {
6161
6607
  const batch = records.slice(offset, offset + 200);
6162
6608
  const changeRequest = await client.bases.createBulkChangeRequest({
6163
6609
  baseId: base.baseId,
6164
- records: batch.map((record) => toCreatableFields(base.node, record.fields)),
6610
+ records: batch.map((record) => toCreatableFields(base.node, record.fields, recordIdsByKey)),
6165
6611
  message: `Install ${batch.length} record(s) into ${base.node.slug}`,
6166
6612
  submittedBy: options.submittedBy,
6167
6613
  idempotencyKey: batchIdempotencyKey(plan.tree.manifest.name, batch[0].key),
@@ -6174,7 +6620,7 @@ const createRecords = async (client, base, plan, options, state, recordIdsByKey,
6174
6620
  const created = (await approveAndMerge(client, changeRequest.id)).changeRequest.operations.filter((operation) => operation.operation === "record_create").sort((a, b) => a.position - b.position);
6175
6621
  for (const [index, record] of batch.entries()) {
6176
6622
  const recordId = created[index]?.mergedRecordId;
6177
- if (recordId) recordIdsByKey.set(record.key, recordId);
6623
+ if (recordId) recordIdsByKey.set(recordIdentity(base.node.slug, record.key), recordId);
6178
6624
  }
6179
6625
  state.created.records += batch.length;
6180
6626
  }
@@ -6189,30 +6635,31 @@ const createRecords = async (client, base, plan, options, state, recordIdsByKey,
6189
6635
  * so the same package always produces the same batches and therefore the same keys.
6190
6636
  */
6191
6637
  const batchIdempotencyKey = (packageName, firstRecordKey) => `pkg:${packageName}:${firstRecordKey}`;
6192
- const linkRelations = async (client, base, relationFieldSlugs, recordIdsByKey, options, state) => {
6638
+ const linkRelations = async (client, base, relationFields, recordIdsByKey, options, state) => {
6193
6639
  for (const record of base.node.records) {
6194
6640
  const relationValues = {};
6195
- let hasRelation = false;
6196
- for (const slug of relationFieldSlugs) {
6197
- const value = record.fields[slug];
6198
- const keys = toKeyArray(value);
6641
+ let needsUpdate = false;
6642
+ for (const field of relationFields) {
6643
+ const value = record.fields[field.slug];
6644
+ const keys = toRecordKeyArray(value);
6199
6645
  if (keys.length === 0) continue;
6200
- const targetIds = keys.map((key) => recordIdsByKey.get(key)).filter((id) => Boolean(id));
6201
- const unresolved = keys.filter((key) => !recordIdsByKey.has(key));
6202
- if (unresolved.length > 0) state.warnings.push(`Record "${record.key}" in base "${base.node.slug}" links field "${slug}" to ${unresolved.length} record(s) that are not in this package — those links were skipped.`);
6646
+ const targetBaseSlug = field.options.targetBaseSlug;
6647
+ const targetIds = keys.map((key) => targetBaseSlug ? recordIdsByKey.get(recordIdentity(targetBaseSlug, key)) : void 0).filter((id) => Boolean(id));
6648
+ const unresolved = keys.filter((key) => !targetBaseSlug || !recordIdsByKey.has(recordIdentity(targetBaseSlug, key)));
6649
+ if (unresolved.length > 0) state.warnings.push(`Record "${record.key}" in base "${base.node.slug}" links field "${field.slug}" to ${unresolved.length} record(s) that are not in target Base "${targetBaseSlug || "(unset)"}" — those links were skipped.`);
6203
6650
  if (targetIds.length > 0) {
6204
- relationValues[slug] = targetIds;
6205
- hasRelation = true;
6651
+ relationValues[field.slug] = targetIds;
6652
+ if (!field.required) needsUpdate = true;
6206
6653
  }
6207
6654
  }
6208
- if (!hasRelation) continue;
6209
- const recordId = recordIdsByKey.get(record.key);
6655
+ if (!needsUpdate) continue;
6656
+ const recordId = recordIdsByKey.get(recordIdentity(base.node.slug, record.key));
6210
6657
  if (!recordId) continue;
6211
6658
  const changeRequest = await client.records.changeRequest({
6212
6659
  operation: "update",
6213
6660
  recordId,
6214
6661
  fields: {
6215
- ...toCreatableFields(base.node, record.fields),
6662
+ ...toCreatableFields(base.node, record.fields, recordIdsByKey),
6216
6663
  ...relationValues
6217
6664
  },
6218
6665
  message: `Link relations for ${base.node.slug}`,
@@ -6223,11 +6670,6 @@ const linkRelations = async (client, base, relationFieldSlugs, recordIdsByKey, o
6223
6670
  await approveAndMerge(client, changeRequest.id);
6224
6671
  }
6225
6672
  };
6226
- const toKeyArray = (value) => {
6227
- if (Array.isArray(value)) return value.filter((item) => typeof item === "string" && item.length > 0);
6228
- if (typeof value === "string" && value.length > 0) return [value];
6229
- return [];
6230
- };
6231
6673
  /** Merging requires an `approved` status, so every auto-merge is review-then-merge. */
6232
6674
  const approveAndMerge = async (client, changeRequestId) => {
6233
6675
  const reviewResult = (await client.changeRequests.review({
@@ -7368,6 +7810,7 @@ const buildInstallPlan = (tree, target, options = {}) => {
7368
7810
  planned = applyRenames(prefixed, collisions, target.existingNodeSlugsByType);
7369
7811
  for (const [installedSlug, originalSlug] of collectSlugRewrites(tree, planned)) resourceKeysBySlug[installedSlug] = originalSlug;
7370
7812
  }
7813
+ buildRecordCreateLayers(planned.nodes);
7371
7814
  collectWarnings(planned, warnings);
7372
7815
  const counts = countTree(planned);
7373
7816
  if (counts.files > 5e3) throw new Error(`Package has ${counts.files} files, above the ${PACKAGE_MAX_FILE_COUNT}-file limit. Nothing was installed.`);
@@ -8495,6 +8938,151 @@ const readDirectoryFiles = async (dir) => {
8495
8938
  return files;
8496
8939
  };
8497
8940
  //#endregion
8941
+ //#region src/paginate.ts
8942
+ /**
8943
+ * `--all`: follow the cursor to the end instead of making the caller loop.
8944
+ *
8945
+ * Every listing in this CLI is bounded — that is the point of the `limit` work —
8946
+ * but "bounded" moves the burden onto the caller, who now has to notice
8947
+ * `nextCursor`, thread it back in, and know when to stop. An agent that misses
8948
+ * that reports a truncated answer as the whole answer, which is the failure the
8949
+ * `record_query` task guidance warns about in prose. This does it in code.
8950
+ *
8951
+ * Rows stream out as NDJSON — one JSON object per line — rather than
8952
+ * accumulating into one array, so memory stays flat and a consumer can start
8953
+ * work on page 1 while page 9 is still in flight. That is why `--all` overrides
8954
+ * `--output`: a 40,000-row table is not a thing anyone wanted.
8955
+ */
8956
+ /**
8957
+ * Read a page out of whatever the command returned.
8958
+ *
8959
+ * Two protocols exist and both are honoured. Most paginated endpoints return
8960
+ * `{ rows, nextCursor }`. `GET /assets` returns a bare array and pages by the
8961
+ * last row's `id`, with a short page meaning the end — a shape chosen there to
8962
+ * keep the response backwards compatible for the web and mobile clients.
8963
+ */
8964
+ function readPage(result, limit) {
8965
+ const envelope = asEnvelope(result);
8966
+ if (envelope) {
8967
+ const cursor = envelope.meta.find(([key]) => key === "nextCursor")?.[1];
8968
+ if (cursor === void 0) return void 0;
8969
+ return {
8970
+ rows: envelope.rows,
8971
+ nextCursor: typeof cursor === "string" ? cursor : null
8972
+ };
8973
+ }
8974
+ if (!Array.isArray(result)) return void 0;
8975
+ if (limit === void 0 || result.length < limit) return {
8976
+ rows: result,
8977
+ nextCursor: null
8978
+ };
8979
+ const id = result[result.length - 1]?.id;
8980
+ return {
8981
+ rows: result,
8982
+ nextCursor: typeof id === "string" ? id : null
8983
+ };
8984
+ }
8985
+ /** Stops a broken server or a cursor that never advances from looping forever. */
8986
+ const MAX_PAGES = 1e4;
8987
+ /**
8988
+ * Stream every page as NDJSON. Returns the number of rows written.
8989
+ *
8990
+ * A cap that truncates is always announced on stderr: a silently short answer
8991
+ * that looks complete is the exact failure this is meant to prevent.
8992
+ */
8993
+ async function paginateAll(options) {
8994
+ const { fetchPage, limit, maxItems, write, notify } = options;
8995
+ let cursor;
8996
+ let written = 0;
8997
+ let seen = 0;
8998
+ for (let pageIndex = 0; pageIndex < MAX_PAGES; pageIndex += 1) {
8999
+ const result = await fetchPage(cursor);
9000
+ const page = readPage(result, limit);
9001
+ if (!page) {
9002
+ write(JSON.stringify(result));
9003
+ return 1;
9004
+ }
9005
+ seen += page.rows.length;
9006
+ for (const row of page.rows) {
9007
+ if (maxItems !== void 0 && written >= maxItems) {
9008
+ notify(`[busabase-cli] --max-items ${maxItems} reached; stopped early with more rows available.`);
9009
+ return written;
9010
+ }
9011
+ write(JSON.stringify(row));
9012
+ written += 1;
9013
+ }
9014
+ if (!page.nextCursor) return written;
9015
+ if (page.nextCursor === cursor) {
9016
+ notify("[busabase-cli] the server returned the same cursor twice; stopping to avoid a loop.");
9017
+ return written;
9018
+ }
9019
+ cursor = page.nextCursor;
9020
+ }
9021
+ notify(`[busabase-cli] stopped after ${MAX_PAGES} pages (${seen} rows scanned).`);
9022
+ return written;
9023
+ }
9024
+ //#endregion
9025
+ //#region src/retry.ts
9026
+ const BASE_DELAY_MS = 500;
9027
+ const MAX_DELAY_MS = 2e4;
9028
+ const READ_METHODS = /* @__PURE__ */ new Set([
9029
+ "GET",
9030
+ "HEAD",
9031
+ "OPTIONS"
9032
+ ]);
9033
+ const defaultSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
9034
+ const isNetworkError = (error) => error instanceof TypeError || /fetch failed|econnreset|econnrefused|etimedout|socket hang up/i.test(error instanceof Error ? error.message : String(error));
9035
+ /** `Retry-After` is either delay-seconds or an HTTP-date; both are legal. */
9036
+ function parseRetryAfter(header, now = Date.now()) {
9037
+ if (!header) return void 0;
9038
+ const seconds = Number(header.trim());
9039
+ if (Number.isFinite(seconds) && seconds >= 0) return Math.min(seconds * 1e3, MAX_DELAY_MS);
9040
+ const at = Date.parse(header);
9041
+ if (Number.isNaN(at)) return void 0;
9042
+ return Math.min(Math.max(at - now, 0), MAX_DELAY_MS);
9043
+ }
9044
+ /** Whether this outcome is worth another attempt for a request using `method`. */
9045
+ function shouldRetry(method, status) {
9046
+ if (status === 429) return true;
9047
+ if (!READ_METHODS.has(method.toUpperCase())) return false;
9048
+ return status === void 0 || status >= 500;
9049
+ }
9050
+ /**
9051
+ * Wrap a fetch so transient failures are retried with exponential backoff.
9052
+ * Returns `base` unchanged when `retries` is 0, so the no-retry path adds no
9053
+ * wrapper and no behaviour.
9054
+ */
9055
+ function withRetry(base, options) {
9056
+ const { retries } = options;
9057
+ if (retries <= 0) return base;
9058
+ const sleep = options.sleep ?? defaultSleep;
9059
+ const notify = options.notify ?? ((message) => console.error(message));
9060
+ const jitter = options.jitter ?? Math.random;
9061
+ return (async (input, init) => {
9062
+ const method = init?.method ?? (input instanceof Request ? input.method : "GET");
9063
+ for (let attempt = 0;; attempt += 1) {
9064
+ let response;
9065
+ let failure;
9066
+ try {
9067
+ response = await base(input, init);
9068
+ } catch (error) {
9069
+ if (!isNetworkError(error)) throw error;
9070
+ failure = error;
9071
+ }
9072
+ const status = response?.status;
9073
+ const last = attempt >= retries;
9074
+ if (!shouldRetry(method, status) || last) {
9075
+ if (response) return response;
9076
+ throw failure;
9077
+ }
9078
+ const backoff = Math.min(BASE_DELAY_MS * 2 ** attempt, MAX_DELAY_MS);
9079
+ const delay = parseRetryAfter(response?.headers.get("retry-after") ?? null) ?? Math.round(backoff * (.5 + jitter() * .5));
9080
+ notify(`[busabase-cli] ${status ? `HTTP ${status}` : "connection failed"} — retrying in ${Math.round(delay / 1e3)}s (attempt ${attempt + 2} of ${retries + 1})`);
9081
+ await sleep(delay);
9082
+ }
9083
+ });
9084
+ }
9085
+ //#endregion
8498
9086
  //#region src/task-command.ts
8499
9087
  const collect = (value, previous = []) => [...previous, value];
8500
9088
  /** Range-checks before the request goes out, so a cap surfaces as a usage error. */
@@ -8533,10 +9121,13 @@ const applyParam = (cmd, param) => {
8533
9121
  case "stringArray":
8534
9122
  cmd.option(`${flag} <value>`, `${description} (repeatable)`, collect);
8535
9123
  return;
8536
- case "json":
8537
- cmd.option(`${jsonFlagFor(param)} <json|@file>`, description);
9124
+ case "json": {
9125
+ const jsonOption = `${jsonFlagFor(param)} <json|@file>`;
9126
+ if (param.required) cmd.requiredOption(jsonOption, description);
9127
+ else cmd.option(jsonOption, description);
8538
9128
  if (param.cliShorthand) cmd.option(`${param.cliShorthand.flag} ${param.cliShorthand.placeholder}`, param.cliShorthand.description, collect);
8539
9129
  return;
9130
+ }
8540
9131
  default: if (param.required) cmd.requiredOption(`${flag} <value>`, description);
8541
9132
  else cmd.option(`${flag} <value>`, description);
8542
9133
  }
@@ -8586,6 +9177,7 @@ const registerOne = (program, task, deps, path, preset = {}, summaryOverride) =>
8586
9177
  }
8587
9178
  applyParam(cmd, param);
8588
9179
  }
9180
+ if (task.guidance) cmd.addHelpText("after", `\n${wrapGuidance(rewriteTaskNames(task.guidance))}`);
8589
9181
  if (task.examples && task.examples.length > 0 && Object.keys(preset).length === 0) cmd.addHelpText("after", `\nExamples:\n ${task.examples.join("\n ")}`);
8590
9182
  cmd.action(deps.runAction(async (client, opts) => task.execute(client, {
8591
9183
  ...buildInput(task, opts, deps),
@@ -8593,11 +9185,108 @@ const registerOne = (program, task, deps, path, preset = {}, summaryOverride) =>
8593
9185
  })));
8594
9186
  };
8595
9187
  const registerTaskCommands = (program, tasks, deps) => {
9188
+ for (const task of tasks) cliPathByTaskName.set(task.name, `busabase-cli ${task.cliPath.join(" ")}`);
8596
9189
  for (const task of tasks) {
8597
9190
  registerOne(program, task, deps, task.cliPath);
8598
9191
  for (const variant of task.cliVariants ?? []) registerOne(program, task, deps, variant.path, variant.preset, variant.summary);
8599
9192
  }
8600
9193
  };
9194
+ /**
9195
+ * Guidance is written once and read by two surfaces, so it names sibling tasks
9196
+ * by their MCP tool name (`record_find_by_field`). That is not something a CLI
9197
+ * caller can type — the command is `records by-field-text` — so the shared text
9198
+ * is rewritten to this surface's vocabulary rather than forked.
9199
+ */
9200
+ const rewriteTaskNames = (text) => text.replace(/`([a-z][a-z0-9_]*)`/g, (match, name) => {
9201
+ const path = cliPathByTaskName.get(name);
9202
+ return path ? `\`${path}\`` : match;
9203
+ });
9204
+ /** Filled by {@link registerTaskCommands}; empty until the surface is built. */
9205
+ const cliPathByTaskName = /* @__PURE__ */ new Map();
9206
+ /** Reflow guidance to the width commander uses for the rest of the help screen. */
9207
+ const wrapGuidance = (text, width = 78) => {
9208
+ const lines = [];
9209
+ let line = "";
9210
+ for (const word of text.split(/\s+/).filter(Boolean)) if (line && `${line} ${word}`.length > width) {
9211
+ lines.push(line);
9212
+ line = word;
9213
+ } else line = line ? `${line} ${word}` : word;
9214
+ if (line) lines.push(line);
9215
+ return lines.join("\n");
9216
+ };
9217
+ //#endregion
9218
+ //#region src/wait.ts
9219
+ /**
9220
+ * `--wait`: block until a submitted proposal is actually decided.
9221
+ *
9222
+ * Review-first is the product's whole point, so the useful unit of work for a
9223
+ * caller is not "the change request was filed" — it is "the change request was
9224
+ * merged, or it wasn't". Without this every caller writes the same poll loop
9225
+ * against `change-requests get`, and an agent that skips it reports a proposal
9226
+ * nobody has looked at as a completed task.
9227
+ */
9228
+ /** Statuses that will not change again on their own. */
9229
+ const TERMINAL = /* @__PURE__ */ new Set([
9230
+ "merged",
9231
+ "rejected",
9232
+ "closed",
9233
+ "abandoned"
9234
+ ]);
9235
+ const isChangeRequest = (value) => {
9236
+ if (!value || typeof value !== "object") return false;
9237
+ const row = value;
9238
+ return typeof row.id === "string" && row.id.startsWith("crq") && typeof row.status === "string";
9239
+ };
9240
+ /**
9241
+ * Find the change request a command's result is about.
9242
+ *
9243
+ * The shape differs across the surface — some routes return the change request
9244
+ * itself, some wrap it (`{ changeRequest }`), some return the materialized thing
9245
+ * with `materialized: false` alongside — so this looks for the id rather than
9246
+ * asking every command to declare it.
9247
+ */
9248
+ function findChangeRequestId(result) {
9249
+ if (isChangeRequest(result)) return result.id;
9250
+ if (Array.isArray(result)) {
9251
+ for (const item of result) {
9252
+ const found = findChangeRequestId(item);
9253
+ if (found) return found;
9254
+ }
9255
+ return;
9256
+ }
9257
+ if (!result || typeof result !== "object") return void 0;
9258
+ const row = result;
9259
+ if (typeof row.changeRequestId === "string") return row.changeRequestId;
9260
+ for (const key of ["changeRequest", "changeRequests"]) {
9261
+ const found = findChangeRequestId(row[key]);
9262
+ if (found) return found;
9263
+ }
9264
+ }
9265
+ const DEFAULT_POLL_MS = 3e3;
9266
+ /** Poll until the change request settles or the budget runs out. */
9267
+ async function waitForChangeRequest(options) {
9268
+ const { changeRequestId, read, timeoutMs, pollMs = DEFAULT_POLL_MS, sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)), now = Date.now, notify = () => {} } = options;
9269
+ const deadline = now() + timeoutMs;
9270
+ let latest;
9271
+ let status = "unknown";
9272
+ for (;;) {
9273
+ latest = await read(changeRequestId);
9274
+ status = isChangeRequest(latest) ? latest.status : "unknown";
9275
+ if (TERMINAL.has(status)) return {
9276
+ kind: "settled",
9277
+ status,
9278
+ changeRequest: latest
9279
+ };
9280
+ const remaining = deadline - now();
9281
+ if (remaining <= 0) return {
9282
+ kind: "timeout",
9283
+ status,
9284
+ changeRequest: latest
9285
+ };
9286
+ notify(`[busabase-cli] ${changeRequestId} is ${status}; waiting for a reviewer (${Math.round(remaining / 1e3)}s left)`);
9287
+ await sleep(Math.min(pollMs, remaining));
9288
+ }
9289
+ }
8601
9290
  //#endregion
8602
9291
  //#region src/run.ts
8603
9292
  /** Public docs page covering every error below, for both Cloud and local. Linked from each error. */
@@ -8629,9 +9318,12 @@ function resolveConfig(opts) {
8629
9318
  webUrl: process.env.BUSABASE_WEB_URL ?? file.BUSABASE_WEB_URL ?? baseUrl,
8630
9319
  apiKey: opts.apiKey ?? process.env.BUSABASE_API_KEY ?? file.BUSABASE_API_KEY,
8631
9320
  spaceId: opts.spaceId ?? process.env.BUSABASE_SPACE_ID ?? file.BUSABASE_SPACE_ID,
8632
- output: opts.output ?? configuredOutput() ?? "text"
9321
+ output: opts.output ?? configuredOutput() ?? "text",
9322
+ fetch: withRetry(fetch, { retries: opts.retries ?? DEFAULT_RETRIES })
8633
9323
  };
8634
9324
  }
9325
+ /** Enough to ride out a rate limiter or a proxy blip; not enough to hide a real outage. */
9326
+ const DEFAULT_RETRIES = 2;
8635
9327
  /**
8636
9328
  * Config flags accepted anywhere on the line (`busabase-cli --output json bases list` and
8637
9329
  * `busabase-cli bases list --output json` both work): declared on the root program AND on every
@@ -8644,14 +9336,20 @@ const GLOBAL_LONG_FLAGS = /* @__PURE__ */ new Set([
8644
9336
  "--space-id",
8645
9337
  "--output",
8646
9338
  "--profile",
8647
- "--config"
9339
+ "--config",
9340
+ "--slim",
9341
+ "--retries",
9342
+ "--all",
9343
+ "--max-items",
9344
+ "--wait",
9345
+ "--wait-timeout"
8648
9346
  ]);
8649
9347
  function addGlobalFlags(cmd) {
8650
9348
  return cmd.option("--base-url <url>", `server base URL (env BUSABASE_BASE_URL, default ${DEFAULT_BASE_URL})`).option("--api-key <token>", "bearer token for cloud hosts (env BUSABASE_API_KEY)").option("--space-id <id>", "target Busabase space (env BUSABASE_SPACE_ID)").option("--profile <name>", "account to use for this command (env BUSABASE_PROFILE)").option("--config <path>", "read credentials from this file instead (env BUSABASE_CONFIG)").addOption(new Option("--output <fmt>", "text | table | json (default text)").choices([
8651
9349
  "text",
8652
9350
  "table",
8653
9351
  "json"
8654
- ]));
9352
+ ])).option("--slim", "drop hydrated parents (a record's full `base`, `createdByUser`, `headCommit`) and keep their ids").option("--retries <n>", "extra attempts on 429 / 5xx / dropped connections (default 2, 0 disables)", parseNonNegativeInt).option("--all", "follow the cursor to the end and stream every row as NDJSON").option("--max-items <n>", "stop --all after this many rows", parsePositiveInt).option("--wait", "after submitting a Change Request, block until a reviewer merges or rejects it").option("--wait-timeout <seconds>", "budget for --wait (default 300)", parsePositiveInt);
8655
9353
  }
8656
9354
  const parseNum = (value) => {
8657
9355
  const parsed = Number(value);
@@ -8663,6 +9361,11 @@ const parsePositiveInt = (value) => {
8663
9361
  if (!Number.isInteger(parsed) || parsed < 1) throw new InvalidArgumentError("expected a positive integer");
8664
9362
  return parsed;
8665
9363
  };
9364
+ const parseNonNegativeInt = (value) => {
9365
+ const parsed = parseNum(value);
9366
+ if (!Number.isInteger(parsed) || parsed < 0) throw new InvalidArgumentError("expected a non-negative integer");
9367
+ return parsed;
9368
+ };
8666
9369
  const parseBoolean = (value) => {
8667
9370
  if (value === "true") return true;
8668
9371
  if (value === "false") return false;
@@ -8739,13 +9442,9 @@ function parseJsonValue(raw, flagName) {
8739
9442
  * better off than when the field was silently dropped.
8740
9443
  */
8741
9444
  function formatIssues(issues) {
8742
- if (!Array.isArray(issues) || issues.length === 0) return void 0;
8743
- return issues.map((issue) => {
8744
- if (!issue || typeof issue !== "object") return String(issue);
8745
- const { path, message } = issue;
8746
- const pathLabel = Array.isArray(path) && path.length > 0 ? path.join(".") : void 0;
8747
- return pathLabel ? `${pathLabel}: ${message}` : String(message ?? issue);
8748
- }).join("; ");
9445
+ const parsed = toIssues(issues);
9446
+ if (!parsed) return void 0;
9447
+ return parsed.map(({ path, message }) => path ? `${path}: ${message}` : message).join("; ");
8749
9448
  }
8750
9449
  function formatRawErrorBody(status, statusText, text) {
8751
9450
  try {
@@ -8760,7 +9459,7 @@ function formatRawErrorBody(status, statusText, text) {
8760
9459
  /** Raw fetch for endpoints outside the typed contract (health / openapi / api passthrough). */
8761
9460
  async function rawFetch(config, method, path, body) {
8762
9461
  const url = `${normalizeBaseUrl(config.baseUrl)}${path}`;
8763
- const res = await fetch(url, {
9462
+ const res = await (config.fetch ?? fetch)(url, {
8764
9463
  method: method.toUpperCase(),
8765
9464
  headers: {
8766
9465
  ...body !== void 0 ? { "content-type": "application/json" } : {},
@@ -8878,10 +9577,60 @@ function runAction(state, handler) {
8878
9577
  if (refreshedToken) config.apiKey = refreshedToken;
8879
9578
  state.config = config;
8880
9579
  assertCredentialNotExpired(config.apiKey);
8881
- const result = await handler(createBusabaseClient(config), opts, config);
8882
- console.log(render(result, config.output));
9580
+ const client = createBusabaseClient(config);
9581
+ if (opts.all) {
9582
+ await streamAllPages(client, opts, config, handler);
9583
+ return;
9584
+ }
9585
+ const result = await handler(client, opts, config);
9586
+ console.log(render(opts.slim ? slim(result) : result, config.output));
9587
+ if (opts.wait) await awaitReview(client, opts, config, result);
8883
9588
  };
8884
9589
  }
9590
+ /** Default `--wait` budget: long enough for a reviewer who is at their desk. */
9591
+ const DEFAULT_WAIT_SECONDS = 300;
9592
+ /**
9593
+ * Block until the Change Request this command just filed is decided.
9594
+ *
9595
+ * The result is printed first, so a caller that times out still has the id it
9596
+ * needs to come back to. Exit codes carry the outcome (see `errors.ts`): merged
9597
+ * is success, rejected is a decision the caller must branch on, and a timeout is
9598
+ * neither — nothing was decided, so the write must NOT be retried.
9599
+ */
9600
+ async function awaitReview(client, opts, config, result) {
9601
+ const changeRequestId = findChangeRequestId(result);
9602
+ if (!changeRequestId) {
9603
+ console.error("[busabase-cli] --wait: this command filed no Change Request; nothing to wait for.");
9604
+ return;
9605
+ }
9606
+ const outcome = await waitForChangeRequest({
9607
+ changeRequestId,
9608
+ read: (id) => client.changeRequests.get({ changeRequestId: id }),
9609
+ timeoutMs: (opts.waitTimeout ?? DEFAULT_WAIT_SECONDS) * 1e3,
9610
+ notify: (message) => console.error(message)
9611
+ });
9612
+ console.log(render(opts.slim ? slim(outcome.changeRequest) : outcome.changeRequest, config.output));
9613
+ if (outcome.kind === "timeout") throw new CliOutcomeError("TIMEOUT", `--wait timed out: ${changeRequestId} is still ${outcome.status}. Nothing was decided — do NOT resubmit; poll it with \`busabase-cli change-requests get --change-request-id ${changeRequestId}\`.`);
9614
+ if (outcome.status !== "merged") throw new CliOutcomeError("REVIEW_REJECTED", `${changeRequestId} was ${outcome.status}, not merged.`);
9615
+ }
9616
+ /**
9617
+ * `--all` re-runs the same handler with a moving cursor. The handler reads its
9618
+ * input off `opts`, so advancing the page is a matter of setting `opts.cursor`
9619
+ * before each call rather than teaching every command a second code path.
9620
+ */
9621
+ async function streamAllPages(client, opts, config, handler) {
9622
+ if (await paginateAll({
9623
+ fetchPage: async (cursor) => {
9624
+ opts.cursor = cursor;
9625
+ const result = await handler(client, opts, config);
9626
+ return opts.slim ? slim(result) : result;
9627
+ },
9628
+ limit: typeof opts.limit === "number" ? opts.limit : void 0,
9629
+ maxItems: typeof opts.maxItems === "number" ? opts.maxItems : void 0,
9630
+ write: (line) => console.log(line),
9631
+ notify: (message) => console.error(message)
9632
+ }) === 0) console.error("[busabase-cli] no rows.");
9633
+ }
8885
9634
  /**
8886
9635
  * {@link runAction} for a command with one positional argument. Commander passes
8887
9636
  * `(arg, opts, cmd)` to such an action, so the arity differs from a flags-only leaf.
@@ -8895,7 +9644,7 @@ function runArgAction(state, handler) {
8895
9644
  state.config = config;
8896
9645
  assertCredentialNotExpired(config.apiKey);
8897
9646
  const result = await handler(arg, createBusabaseClient(config), opts, config);
8898
- console.log(render(result, config.output));
9647
+ console.log(render(opts.slim ? slim(result) : result, config.output));
8899
9648
  };
8900
9649
  }
8901
9650
  /**
@@ -8912,7 +9661,7 @@ function runLocalArgAction(handler) {
8912
9661
  const opts = cmd.optsWithGlobals();
8913
9662
  const config = resolveConfig(opts);
8914
9663
  const result = await handler(arg, opts, config);
8915
- console.log(render(result, config.output));
9664
+ console.log(render(opts.slim ? slim(result) : result, config.output));
8916
9665
  };
8917
9666
  }
8918
9667
  function pkgVersion() {
@@ -9045,11 +9794,33 @@ function unionVariants(inputSchema, discriminator = "operation") {
9045
9794
  return variants;
9046
9795
  }
9047
9796
  /**
9797
+ * Words of an identifier, however it was spelled: `changeRequests` + `reviewMany`
9798
+ * and `change_requests_review_many` both reduce to
9799
+ * `change requests review many`. Lets the CLI compare its own generated command
9800
+ * paths against the MCP tool names in {@link TASK_SUPERSEDED_MCP_TOOLS} without
9801
+ * either side having to know how the other spells things.
9802
+ */
9803
+ const identifierWords = (parts) => parts.flatMap((part) => part.split(/[_\-\s]+/)).flatMap((part) => part.replace(/([a-z0-9])([A-Z])/g, "$1 $2").split(" ")).filter(Boolean).map((word) => word.toLowerCase()).join(" ");
9804
+ /**
9805
+ * Endpoint commands a curated task command already covers.
9806
+ *
9807
+ * The MCP adapter has always dropped these so "a catalog carries one way to do
9808
+ * each job rather than two" — and then the CLI published BOTH spellings, which
9809
+ * is a third of a 30 KB help screen an agent reads to orient itself. Same list,
9810
+ * same reason, one more consumer.
9811
+ *
9812
+ * Hidden, never removed: every one of them still runs, so no existing script
9813
+ * breaks, and `--help-all` prints them.
9814
+ */
9815
+ const SUPERSEDED_COMMAND_PATHS = new Set(TASK_SUPERSEDED_MCP_TOOLS.map((tool) => identifierWords([tool])));
9816
+ /**
9048
9817
  * Procedures a curated command already exposes under a DIFFERENT name (so the
9049
9818
  * name-collision guard wouldn't catch them). Keyed `group.procKey`.
9050
9819
  */
9051
9820
  const GENERATED_SKIP = /* @__PURE__ */ new Set([
9052
9821
  "search",
9822
+ "guides.list",
9823
+ "guides.read",
9053
9824
  "auth.verify",
9054
9825
  "records.search",
9055
9826
  "records.listChangeRequests",
@@ -9079,7 +9850,8 @@ function registerGeneratedCommands(program, state) {
9079
9850
  const addOneLeaf = (parent, navPath, procKey, proc, name, fields, description) => {
9080
9851
  const { route } = proc["~orpc"];
9081
9852
  if (parent.commands.some((c) => c.name() === name || c.aliases().includes(name))) return;
9082
- const leaf = parent.command(name).description(description ?? route.summary ?? `${route.method} ${route.path}`);
9853
+ const superseded = SUPERSEDED_COMMAND_PATHS.has(identifierWords([parent.name(), name]));
9854
+ const leaf = parent.command(name, superseded ? { hidden: true } : void 0).description(description ?? route.summary ?? `${route.method} ${route.path}`);
9083
9855
  for (const f of fields) {
9084
9856
  if (f.pinned !== void 0) continue;
9085
9857
  const flag = `--${kebab(f.key)}`;
@@ -9154,14 +9926,26 @@ function flagSignature(cmd) {
9154
9926
  }
9155
9927
  return parts.join(" ");
9156
9928
  }
9157
- const isHelpHidden = (cmd) => Boolean(cmd._noHelp);
9929
+ /**
9930
+ * Commander records `command(name, { hidden })` and `{ noHelp }` on `_hidden`;
9931
+ * this read `_noHelp`, which commander never sets, so it always answered false
9932
+ * and nothing was ever actually hidden from the root help.
9933
+ */
9934
+ const isHelpHidden = (cmd) => Boolean(cmd._hidden) || Boolean(cmd._noHelp);
9935
+ /**
9936
+ * Set for the duration of a `--help-all` render. A module-level flag rather than
9937
+ * a parameter because commander calls the help hook itself, with no way to
9938
+ * thread an argument through.
9939
+ */
9940
+ let showAllCommands = false;
9158
9941
  /**
9159
9942
  * Root help "Commands:" section: every leaf with its full flag signature (the flat
9160
9943
  * commander listing would only show group names). Generated from the command tree,
9161
9944
  * never hand-written.
9162
9945
  */
9163
- function commandsSection(program) {
9946
+ function commandsSection(program, showAll = false) {
9164
9947
  const lines = ["Commands:"];
9948
+ const skip = (cmd) => cmd.name() === "help" || !showAll && isHelpHidden(cmd);
9165
9949
  const entry = (prefix, cmd) => {
9166
9950
  const sig = [prefix, flagSignature(cmd)].filter(Boolean).join(" ");
9167
9951
  const desc = cmd.description();
@@ -9170,12 +9954,12 @@ function commandsSection(program) {
9170
9954
  return ` ${sig}\n${" ".repeat(44)}${desc}`;
9171
9955
  };
9172
9956
  for (const cmd of program.commands) {
9173
- if (cmd.name() === "help" || isHelpHidden(cmd)) continue;
9174
- const leaves = cmd.commands.filter((leaf) => leaf.name() !== "help" && !isHelpHidden(leaf));
9957
+ if (skip(cmd)) continue;
9958
+ const leaves = cmd.commands.filter((leaf) => !skip(leaf));
9175
9959
  if (leaves.length > 0) {
9176
9960
  lines.push("");
9177
9961
  for (const leaf of leaves) lines.push(entry(`${cmd.name()} ${leaf.name()}`, leaf));
9178
- } else lines.push(entry(cmd.name(), cmd));
9962
+ } else if (cmd.commands.length === 0) lines.push(entry(cmd.name(), cmd));
9179
9963
  }
9180
9964
  return lines.join("\n");
9181
9965
  }
@@ -9187,7 +9971,12 @@ Several accounts: \`login --profile <name>\` adds one, \`auth status\` lists the
9187
9971
  \`auth switch\` changes which is active. Several spaces on one account (the norm on
9188
9972
  Cloud): \`space list\` / \`space use\`.
9189
9973
 
9974
+ New here? \`busabase-cli guide\` is the operating manual — read \`workspace\` before
9975
+ proposing changes. Endpoint commands a task command already covers are hidden;
9976
+ \`--help-all\` prints them (they all still run).
9977
+
9190
9978
  Docs: https://busabase.com/docs · Troubleshooting: ${DOCS_TROUBLESHOOTING}`;
9979
+ /** Exported for the help-example test, which walks the command tree in-process. */
9191
9980
  function buildProgram(state = {}) {
9192
9981
  const program = new Command("busabase-cli");
9193
9982
  program.description("Client for the Busabase OpenAPI REST API — 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));
@@ -9195,6 +9984,29 @@ function buildProgram(state = {}) {
9195
9984
  addGlobalFlags(program.command("health")).description("Server health check (GET /api/health)").action(runAction(state, (_client, _opts, config) => rawFetch(config, "GET", "/api/health")));
9196
9985
  addGlobalFlags(program.command("openapi")).description("Fetch the OpenAPI document").action(runAction(state, (_client, _opts, config) => rawFetch(config, "GET", "/api/v1/openapi.json")));
9197
9986
  addGlobalFlags(program.command("whoami")).description("Active space, user, and membership").action(runAction(state, (client) => client.auth.verify()));
9987
+ addGlobalFlags(program.command("guide")).argument("[topic]", "which guide to read; omit to list the topics this server serves").description("Read the Busabase operating manual (approval-first rules, AirApp contract)").addHelpText("after", `
9988
+ Read this BEFORE unfamiliar work. It carries the conventions this workspace
9989
+ expects, which no flag or schema can express on its own.
9990
+
9991
+ Examples:
9992
+ busabase-cli guide # what is available here
9993
+ busabase-cli guide workspace # the approval-first workflow
9994
+ busabase-cli guide airapp # REQUIRED before writing any AirApp file`).action(async (topic, _opts, cmd) => {
9995
+ const config = resolveConfig(cmd.optsWithGlobals());
9996
+ state.config = config;
9997
+ const client = createBusabaseClient(config);
9998
+ if (!topic) {
9999
+ console.log(render(await client.guides.list(), config.output));
10000
+ return;
10001
+ }
10002
+ const guide = await client.guides.read({ topic });
10003
+ if (config.output === "json") {
10004
+ console.log(render(guide, config.output));
10005
+ return;
10006
+ }
10007
+ console.log(guide.content);
10008
+ console.error(`\n[busabase-cli] other topics: ${guide.otherTopics.join(", ") || "(none)"}`);
10009
+ });
9198
10010
  addGlobalFlags(program.command("login")).description("Connect the CLI to Busabase — Personal/local, Cloud, or self-hosted").option("--device-code", "force device authorization and skip the method prompt").option("--oauth", "legacy same-machine OAuth callback flow").option("--no-browser", "print the sign-in URL instead of opening a browser").option("--refresh", "rotate the saved OAuth token set (no browser, no re-login)").addHelpText("after", `
9199
10011
  Busabase is an approval-first database and knowledge base for AI agents: agents
9200
10012
  propose changes, humans review and merge what becomes trusted data.
@@ -9614,7 +10426,7 @@ To add content to a space that is already in use, use \`busabase-cli install\` i
9614
10426
  leaf.usage(`${sig ? `${sig} ` : ""}[global flags]`);
9615
10427
  }
9616
10428
  }
9617
- program.addHelpText("after", () => `\n${commandsSection(program)}\n${HELP_FOOTER}`);
10429
+ program.addHelpText("after", () => `\n${commandsSection(program, showAllCommands)}\n${HELP_FOOTER}`);
9618
10430
  program.configureHelp({ visibleCommands: () => [] });
9619
10431
  return program;
9620
10432
  }
@@ -9629,17 +10441,48 @@ async function runCli(argv) {
9629
10441
  program.outputHelp();
9630
10442
  return 0;
9631
10443
  }
10444
+ if (argv.includes("--help-all")) {
10445
+ console.log(renderedHelp({ showAll: true }));
10446
+ return 0;
10447
+ }
9632
10448
  try {
9633
10449
  await program.parseAsync(argv, { from: "user" });
9634
10450
  return 0;
9635
10451
  } catch (error) {
9636
- if (error instanceof CommanderError) return error.exitCode === 0 ? 0 : 1;
10452
+ const json = errorOutputIsJson(state, argv);
10453
+ if (error instanceof CommanderError) {
10454
+ if (error.exitCode === 0) return 0;
10455
+ if (json) console.log(JSON.stringify({
10456
+ ok: false,
10457
+ code: "USAGE",
10458
+ message: error.message,
10459
+ retryable: false
10460
+ }, null, 2));
10461
+ return EXIT_CODES.USAGE;
10462
+ }
10463
+ const classified = classifyError(error);
10464
+ if (json) console.log(JSON.stringify(errorEnvelope(classified), null, 2));
9637
10465
  console.error(explainError(error, state.config ?? resolveConfig({})));
9638
- return 1;
10466
+ return classified.exitCode;
9639
10467
  }
9640
10468
  }
9641
- function renderedHelp() {
10469
+ /**
10470
+ * Whether a failed run should print the machine-readable envelope.
10471
+ *
10472
+ * `state.config` is set by the first command that resolves one, but a failure
10473
+ * can happen before that (an unknown option, a bad `--config` path), so the raw
10474
+ * argv is the fallback. Both spellings commander accepts are checked.
10475
+ */
10476
+ function errorOutputIsJson(state, argv) {
10477
+ if (state.config) return state.config.output === "json";
10478
+ const flag = argv.indexOf("--output");
10479
+ if (flag !== -1 && argv[flag + 1] === "json") return true;
10480
+ if (argv.includes("--output=json")) return true;
10481
+ return configuredOutput() === "json";
10482
+ }
10483
+ function renderedHelp({ showAll = false } = {}) {
9642
10484
  let out = "";
10485
+ showAllCommands = showAll;
9643
10486
  const program = buildProgram();
9644
10487
  program.configureOutput({
9645
10488
  writeOut: (str) => {
@@ -9648,10 +10491,12 @@ function renderedHelp() {
9648
10491
  writeErr: () => {}
9649
10492
  });
9650
10493
  program.outputHelp();
10494
+ showAllCommands = false;
9651
10495
  return out;
9652
10496
  }
9653
10497
  /** Full root help text (what `busabase-cli --help` prints). Generated from the command tree. */
9654
10498
  const HELP = renderedHelp();
10499
+ renderedHelp({ showAll: true });
9655
10500
  /**
9656
10501
  * Turn a low-level transport/HTTP error into an actionable message: which host was tried, the
9657
10502
  * concrete next step (set a key for Cloud, point at a local server, or check connectivity), and a
@@ -9661,7 +10506,7 @@ function explainError(error, config) {
9661
10506
  const base = config.baseUrl;
9662
10507
  const rawMessage = error instanceof Error ? error.message : String(error);
9663
10508
  const issueDetail = formatIssues(error.data?.issues);
9664
- const msg = issueDetail ? `${rawMessage} — ${issueDetail}` : rawMessage;
10509
+ const msg = issueDetail && !rawMessage.includes(issueDetail) ? `${rawMessage} — ${issueDetail}` : rawMessage;
9665
10510
  const status = error.status;
9666
10511
  const lower = msg.toLowerCase();
9667
10512
  let body;