busa-cli 0.40.1 → 0.42.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
|
@@ -17,6 +17,7 @@ import { parse, stringify } from "yaml";
|
|
|
17
17
|
import { checkAirApp } from "busabase-sdk/airapp-check";
|
|
18
18
|
import { fileURLToPath } from "node:url";
|
|
19
19
|
import { Uint8ArrayReader, ZipReader } from "@zip.js/zip.js";
|
|
20
|
+
import QRCode from "qrcode";
|
|
20
21
|
const LocaleSchema = z.enum({
|
|
21
22
|
defaultLocale: "en",
|
|
22
23
|
locales: [
|
|
@@ -106,8 +107,8 @@ const iStringSchema = z.union([z.string(), iStringRecordSchema]).describe("i18n
|
|
|
106
107
|
//#region ../../packages/busabase-contract/src/contract/node-agent-prompt-schemas.ts
|
|
107
108
|
/**
|
|
108
109
|
* Per-node custom scenario prompts (`node.metadata.agentPrompts`) — the single
|
|
109
|
-
* schema shared by BOTH write and read paths, per
|
|
110
|
-
*
|
|
110
|
+
* schema shared by BOTH write and read paths, per the node-agent-prompts
|
|
111
|
+
* design (v2) §7.3:
|
|
111
112
|
*
|
|
112
113
|
* - Write: `busabase-cli nodes set-agent-prompts` validates a file against this
|
|
113
114
|
* schema client-side, before calling `nodes.updateMetadata` — a malformed file
|
|
@@ -219,7 +220,7 @@ const OPERATIONS$1 = [
|
|
|
219
220
|
* audience, the exact silent-drop failure the server-side rejection exists to
|
|
220
221
|
* end. `params.appliesWhen` still documents which operations accept it.
|
|
221
222
|
*/
|
|
222
|
-
const mergeIntent$
|
|
223
|
+
const mergeIntent$6 = (input) => {
|
|
223
224
|
if (input.requireReview) return { autoMerge: false };
|
|
224
225
|
if (input.autoMerge) return { autoMerge: true };
|
|
225
226
|
return {};
|
|
@@ -244,7 +245,7 @@ const baseFieldChangeTask = {
|
|
|
244
245
|
name: "base_field_change_request",
|
|
245
246
|
cliPath: ["bases", "field-change-request"],
|
|
246
247
|
summary: "Propose a Base schema change (add / update / delete / convert / reorder / restore)",
|
|
247
|
-
guidance: "Review is permission-aware for
|
|
248
|
+
guidance: "Review is permission-aware for every operation, decided server-side: the change merges immediately when your key has write access on the Base's node and lands as a pending ChangeRequest otherwise — check the response's `status`. Pass requireReview to always propose instead. Worth doing on `delete` and `convert` when the data matters: delete soft-deletes the field's stored values with it (restore brings both back), and convert can drop values that do not fit the new type. Pick `operation` first, then supply only that operation's arguments — add needs slug+name; update needs fieldId+patch; delete/restore need fieldId; convert needs fieldId+newType; reorder needs the complete fieldIds order. Before a convert, `bases_preview_field_conversion` shows what the data would become.",
|
|
248
249
|
annotations: {
|
|
249
250
|
readOnly: false,
|
|
250
251
|
destructive: false
|
|
@@ -372,30 +373,12 @@ const baseFieldChangeTask = {
|
|
|
372
373
|
{
|
|
373
374
|
name: "autoMerge",
|
|
374
375
|
kind: "boolean",
|
|
375
|
-
appliesWhen: {
|
|
376
|
-
param: "operation",
|
|
377
|
-
values: [
|
|
378
|
-
"add",
|
|
379
|
-
"update",
|
|
380
|
-
"reorder",
|
|
381
|
-
"restore"
|
|
382
|
-
]
|
|
383
|
-
},
|
|
384
376
|
description: "Skip review and apply the schema change immediately if you have write access. Not a permission override — a changeRequest-level key still gets a pending CR. Default is permission-aware: merge when you can, otherwise propose."
|
|
385
377
|
},
|
|
386
378
|
{
|
|
387
379
|
name: "requireReview",
|
|
388
380
|
kind: "boolean",
|
|
389
|
-
|
|
390
|
-
param: "operation",
|
|
391
|
-
values: [
|
|
392
|
-
"add",
|
|
393
|
-
"update",
|
|
394
|
-
"reorder",
|
|
395
|
-
"restore"
|
|
396
|
-
]
|
|
397
|
-
},
|
|
398
|
-
description: "Always propose a pending ChangeRequest, even with write access. delete and convert are review-first regardless."
|
|
381
|
+
description: "Always propose a pending ChangeRequest, even with write access. Worth passing on delete and convert when the field holds data you would not want dropped without a second look."
|
|
399
382
|
}
|
|
400
383
|
],
|
|
401
384
|
examples: [
|
|
@@ -409,7 +392,7 @@ const baseFieldChangeTask = {
|
|
|
409
392
|
baseId: input.baseId,
|
|
410
393
|
...input.message ? { message: input.message } : {},
|
|
411
394
|
...input.submittedBy ? { submittedBy: input.submittedBy } : {},
|
|
412
|
-
...mergeIntent$
|
|
395
|
+
...mergeIntent$6(input)
|
|
413
396
|
};
|
|
414
397
|
switch (input.operation) {
|
|
415
398
|
case "add": return client.bases.fieldChangeRequest({
|
|
@@ -799,10 +782,10 @@ const nodeReadFileTask = {
|
|
|
799
782
|
* off — but a CLI boolean flag is presence-only, so `--auto-merge` alone could
|
|
800
783
|
* never express "forced off". Same two-flag shape as `node_create`.
|
|
801
784
|
*
|
|
802
|
-
*
|
|
803
|
-
*
|
|
785
|
+
* Applies to every operation kind, deletes included — a delete batch used to be
|
|
786
|
+
* pinned review-first server-side regardless of this flag, and no longer is.
|
|
804
787
|
*/
|
|
805
|
-
const mergeIntent$
|
|
788
|
+
const mergeIntent$5 = (input) => {
|
|
806
789
|
if (input.requireReview) return { autoMerge: false };
|
|
807
790
|
if (input.autoMerge) return { autoMerge: true };
|
|
808
791
|
return {};
|
|
@@ -828,7 +811,7 @@ const nodeFilesChangeRequestTask = {
|
|
|
828
811
|
}
|
|
829
812
|
],
|
|
830
813
|
summary: "Propose file changes inside a Skill, Drive, or AirApp node",
|
|
831
|
-
guidance: "Review is permission-aware, decided server-side: the change merges immediately when your key has write access on the node and lands as a pending ChangeRequest otherwise — check the response's `status`. Pass requireReview to always propose instead
|
|
814
|
+
guidance: "Review is permission-aware, decided server-side: the change merges immediately when your key has write access on the node and lands as a pending ChangeRequest otherwise — check the response's `status`. Pass requireReview to always propose instead — worth doing for a batch containing a `delete`, since that removes a mounted file (its previous bytes stay in the change request's history, and a batch is never partially merged). Each operation is one of create / update / delete / metadata_update. Include `baseContentHash` (from `node_file_read`) on an update so a concurrent edit is caught. For kind \"airapp\", keep the project runnable by `npm run dev` — editing package.json must leave a \"dev\" script starting a plain Node server, not a bundler dev server.",
|
|
832
815
|
annotations: {
|
|
833
816
|
readOnly: false,
|
|
834
817
|
destructive: false
|
|
@@ -872,7 +855,7 @@ const nodeFilesChangeRequestTask = {
|
|
|
872
855
|
operations: input.operations,
|
|
873
856
|
...input.message ? { message: input.message } : {},
|
|
874
857
|
...input.submittedBy ? { submittedBy: input.submittedBy } : {},
|
|
875
|
-
...mergeIntent$
|
|
858
|
+
...mergeIntent$5(input)
|
|
876
859
|
};
|
|
877
860
|
return client.fileTrees.createChangeRequest(payload);
|
|
878
861
|
}
|
|
@@ -1118,11 +1101,21 @@ const nodeShareTask = {
|
|
|
1118
1101
|
};
|
|
1119
1102
|
//#endregion
|
|
1120
1103
|
//#region ../../packages/busabase-contract/src/tasks/node-archive.ts
|
|
1104
|
+
/**
|
|
1105
|
+
* `autoMerge` is tri-state — unset (permission-aware default), forced on, forced
|
|
1106
|
+
* off — but a CLI boolean flag is presence-only, so `--auto-merge` alone could
|
|
1107
|
+
* never express "forced off". Same two-flag shape as `node_create`.
|
|
1108
|
+
*/
|
|
1109
|
+
const mergeIntent$4 = (input) => {
|
|
1110
|
+
if (input.requireReview) return { autoMerge: false };
|
|
1111
|
+
if (input.autoMerge) return { autoMerge: true };
|
|
1112
|
+
return {};
|
|
1113
|
+
};
|
|
1121
1114
|
const nodeArchiveTask = {
|
|
1122
1115
|
name: "node_archive",
|
|
1123
1116
|
cliPath: ["nodes", "archive"],
|
|
1124
1117
|
summary: "Archive a node (reversible; the node moves to Trash)",
|
|
1125
|
-
guidance: "This is the only way to move a node into the archived state, and it is reversible — the node appears in the Trash view and can be restored.
|
|
1118
|
+
guidance: "This is the only way to move a node into the archived state, and it is reversible — the node appears in the Trash view and can be restored. Review is permission-aware, decided server-side: it archives immediately when your key has write access on the node and lands as a pending ChangeRequest otherwise. Pass requireReview to always propose instead. Do NOT use node_purge to remove a node: purge is permanent and only accepts a node that has ALREADY been archived by this task.",
|
|
1126
1119
|
annotations: {
|
|
1127
1120
|
readOnly: false,
|
|
1128
1121
|
destructive: false
|
|
@@ -1147,14 +1140,19 @@ const nodeArchiveTask = {
|
|
|
1147
1140
|
{
|
|
1148
1141
|
name: "autoMerge",
|
|
1149
1142
|
kind: "boolean",
|
|
1150
|
-
description: "Archive immediately
|
|
1143
|
+
description: "Archive immediately if you have write access. Not a permission override — a changeRequest-level key still gets a pending CR. Default is permission-aware: archive when you can, otherwise propose."
|
|
1144
|
+
},
|
|
1145
|
+
{
|
|
1146
|
+
name: "requireReview",
|
|
1147
|
+
kind: "boolean",
|
|
1148
|
+
description: "Always propose a pending ChangeRequest instead of archiving, even with write access."
|
|
1151
1149
|
}
|
|
1152
1150
|
],
|
|
1153
|
-
examples: ["busabase-cli nodes archive --node-id nod_123", "busabase-cli nodes archive --node-id nod_123 --
|
|
1151
|
+
examples: ["busabase-cli nodes archive --node-id nod_123", "busabase-cli nodes archive --node-id nod_123 --require-review # leave it for a human"],
|
|
1154
1152
|
execute: async (client, input) => client.nodes.createChangeRequest({
|
|
1155
1153
|
message: input.message ?? "Archive node",
|
|
1156
1154
|
submittedBy: input.submittedBy,
|
|
1157
|
-
|
|
1155
|
+
...mergeIntent$4(input),
|
|
1158
1156
|
operations: [{
|
|
1159
1157
|
kind: "delete",
|
|
1160
1158
|
nodeId: input.nodeId
|
|
@@ -1383,7 +1381,7 @@ const folderNodeType = {
|
|
|
1383
1381
|
/**
|
|
1384
1382
|
* Form node: an agent-authored, sandboxed web page bound to a Base via an
|
|
1385
1383
|
* explicit field-binding contract. A submission does NOT write a record
|
|
1386
|
-
* directly — it produces
|
|
1384
|
+
* directly — it produces a review-first record-create ChangeRequest on the
|
|
1387
1385
|
* target Base (so the submitting act reuses the base's `record_create` op, not a
|
|
1388
1386
|
* form-specific one). The form's own config (bindings/page/share) is owner-
|
|
1389
1387
|
* managed and edited directly, so this node contributes no CR operations of its
|
|
@@ -1659,7 +1657,7 @@ const nodeCreateTask = {
|
|
|
1659
1657
|
}
|
|
1660
1658
|
],
|
|
1661
1659
|
summary: "Create a workspace node of any type",
|
|
1662
|
-
guidance: "One call creates any of the 11 node types with its type-specific payload: `fields` for a Base, `body` for a Doc, `files` for a Skill/Drive/AirApp, `assetId` for a File.
|
|
1660
|
+
guidance: "One call creates any of the 11 node types with its type-specific payload: `fields` for a Base, `body` for a Doc, `files` for a Skill/Drive/AirApp, `assetId` for a File. Review is permission-aware, decided server-side: this merges immediately when you already have write access on the parent node, and proposes a ChangeRequest for a human otherwise. Pass requireReview to always propose instead of merging.",
|
|
1663
1661
|
annotations: {
|
|
1664
1662
|
readOnly: false,
|
|
1665
1663
|
destructive: false
|
|
@@ -1969,7 +1967,7 @@ const recordChangeTask = {
|
|
|
1969
1967
|
name: "record_change_request",
|
|
1970
1968
|
cliPath: ["records", "change-request"],
|
|
1971
1969
|
summary: "Propose a change to an existing record (update / delete / restore)",
|
|
1972
|
-
guidance: "Review is permission-aware, decided server-side:
|
|
1970
|
+
guidance: "Review is permission-aware for all three operations, decided server-side: the change merges immediately when your key has write access on the Base's node and lands as a pending ChangeRequest otherwise — check the response's `materialized` field to see which happened. Pass explicit `autoMerge: false` to force review even when you could write directly. `delete` ARCHIVES the record — it is reversible with `restore`, not an erase. For update, `fields` is keyed by field slug and only needs the fields you are changing; if you set the Base's PRIMARY (first) field, keep it a short human-readable title.",
|
|
1973
1971
|
annotations: {
|
|
1974
1972
|
readOnly: false,
|
|
1975
1973
|
destructive: false
|
|
@@ -2010,20 +2008,12 @@ const recordChangeTask = {
|
|
|
2010
2008
|
{
|
|
2011
2009
|
name: "autoMerge",
|
|
2012
2010
|
kind: "boolean",
|
|
2013
|
-
|
|
2014
|
-
param: "operation",
|
|
2015
|
-
values: ["update"]
|
|
2016
|
-
},
|
|
2017
|
-
description: "Skip review and apply the update immediately if you have write access. Not a permission override — a changeRequest-level key still gets a pending CR. Default is permission-aware: merge when you can, otherwise propose."
|
|
2011
|
+
description: "Skip review and apply the change immediately if you have write access. Not a permission override — a changeRequest-level key still gets a pending CR. Default is permission-aware: merge when you can, otherwise propose."
|
|
2018
2012
|
},
|
|
2019
2013
|
{
|
|
2020
2014
|
name: "requireReview",
|
|
2021
2015
|
kind: "boolean",
|
|
2022
|
-
|
|
2023
|
-
param: "operation",
|
|
2024
|
-
values: ["update"]
|
|
2025
|
-
},
|
|
2026
|
-
description: "Always propose a pending ChangeRequest for this update, even with write access. delete/restore are review-first regardless."
|
|
2016
|
+
description: "Always propose a pending ChangeRequest instead of applying the change, even with write access."
|
|
2027
2017
|
}
|
|
2028
2018
|
],
|
|
2029
2019
|
examples: [
|
|
@@ -2049,11 +2039,13 @@ const recordChangeTask = {
|
|
|
2049
2039
|
if (input.operation === "delete") return client.records.changeRequest({
|
|
2050
2040
|
...common,
|
|
2051
2041
|
operation: "delete",
|
|
2052
|
-
deleteMode: "archive"
|
|
2042
|
+
deleteMode: "archive",
|
|
2043
|
+
...mergeIntent$1(input)
|
|
2053
2044
|
});
|
|
2054
2045
|
return client.records.changeRequest({
|
|
2055
2046
|
...common,
|
|
2056
|
-
operation: "restore"
|
|
2047
|
+
operation: "restore",
|
|
2048
|
+
...mergeIntent$1(input)
|
|
2057
2049
|
});
|
|
2058
2050
|
}
|
|
2059
2051
|
};
|
|
@@ -3074,7 +3066,7 @@ z.object({ ok: z.boolean() });
|
|
|
3074
3066
|
//#region src/retry.ts
|
|
3075
3067
|
const BASE_DELAY_MS = 500;
|
|
3076
3068
|
const MAX_DELAY_MS = 2e4;
|
|
3077
|
-
const READ_METHODS = /* @__PURE__ */ new Set([
|
|
3069
|
+
const READ_METHODS$1 = /* @__PURE__ */ new Set([
|
|
3078
3070
|
"GET",
|
|
3079
3071
|
"HEAD",
|
|
3080
3072
|
"OPTIONS"
|
|
@@ -3111,7 +3103,7 @@ function parseRetryAfter(header, now = Date.now()) {
|
|
|
3111
3103
|
/** Whether this outcome is worth another attempt for a request using `method`. */
|
|
3112
3104
|
function shouldRetry(method, status) {
|
|
3113
3105
|
if (status === 429) return true;
|
|
3114
|
-
if (!READ_METHODS.has(method.toUpperCase())) return false;
|
|
3106
|
+
if (!READ_METHODS$1.has(method.toUpperCase())) return false;
|
|
3115
3107
|
return status === void 0 || status >= 500;
|
|
3116
3108
|
}
|
|
3117
3109
|
/**
|
|
@@ -4369,6 +4361,145 @@ function banner(baseUrl) {
|
|
|
4369
4361
|
return out.join("\n");
|
|
4370
4362
|
}
|
|
4371
4363
|
//#endregion
|
|
4364
|
+
//#region src/contract-catalog.ts
|
|
4365
|
+
const isProcedure = (node) => typeof node === "object" && node !== null && typeof node["~orpc"]?.route?.method === "string";
|
|
4366
|
+
/** `spaceId` → `space-id`. Shared so command names and `schema` agree. */
|
|
4367
|
+
const kebab = (value) => value.replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase();
|
|
4368
|
+
/** Depth-first walk of the contract tree, yielding every procedure it holds. */
|
|
4369
|
+
function* contractProcedures(node = cloudContract, navPath = []) {
|
|
4370
|
+
for (const key of Object.keys(node)) {
|
|
4371
|
+
if (key === "~orpc") continue;
|
|
4372
|
+
const child = node[key];
|
|
4373
|
+
if (isProcedure(child)) yield {
|
|
4374
|
+
id: [...navPath, key].join("."),
|
|
4375
|
+
navPath,
|
|
4376
|
+
key,
|
|
4377
|
+
proc: child
|
|
4378
|
+
};
|
|
4379
|
+
else if (child && typeof child === "object") yield* contractProcedures(child, [...navPath, key]);
|
|
4380
|
+
}
|
|
4381
|
+
}
|
|
4382
|
+
/**
|
|
4383
|
+
* The CLI command a contract procedure is reachable as: the top-level group plus
|
|
4384
|
+
* a leaf whose name folds any deeper contract groups into itself, so
|
|
4385
|
+
* `nodes.principals.list` is `busabase-cli nodes principals-list` (not a third
|
|
4386
|
+
* command level). Mirrors `addLeaf` in `run.ts`.
|
|
4387
|
+
*
|
|
4388
|
+
* This is the *generated* spelling. A curated task command may supersede it under
|
|
4389
|
+
* a friendlier name, which is why `schema` labels the field "generated command".
|
|
4390
|
+
*/
|
|
4391
|
+
function generatedCommandPath(navPath, key) {
|
|
4392
|
+
if (navPath.length === 0) return [kebab(key)];
|
|
4393
|
+
return [kebab(navPath[0]), [...navPath.slice(1), key].map(kebab).join("-")];
|
|
4394
|
+
}
|
|
4395
|
+
/**
|
|
4396
|
+
* Resolve a live request back to the contract endpoint that serves it, so an
|
|
4397
|
+
* error can name the endpoint even when the command that failed was a curated
|
|
4398
|
+
* task (which may call several endpoints and therefore declares none).
|
|
4399
|
+
*
|
|
4400
|
+
* Matches the route's `{param}` segments positionally. When more than one route
|
|
4401
|
+
* fits, the one with the most *literal* segments matched wins, so a concrete
|
|
4402
|
+
* `/nodes/favorites` is never shadowed by a parameterized `/nodes/{nodeId}`.
|
|
4403
|
+
* Ties are impossible: two routes with identical literals at identical positions
|
|
4404
|
+
* and the same length are the same route.
|
|
4405
|
+
*/
|
|
4406
|
+
function matchRoute(method, pathname) {
|
|
4407
|
+
const wanted = pathname.split("?")[0].split("/").filter(Boolean);
|
|
4408
|
+
let best;
|
|
4409
|
+
for (const entry of contractProcedures()) {
|
|
4410
|
+
const { route } = entry.proc["~orpc"];
|
|
4411
|
+
if (route.method.toUpperCase() !== method.toUpperCase()) continue;
|
|
4412
|
+
const segments = route.path.split("/").filter(Boolean);
|
|
4413
|
+
if (segments.length !== wanted.length) continue;
|
|
4414
|
+
let literals = 0;
|
|
4415
|
+
if (segments.every((segment, index) => {
|
|
4416
|
+
if (segment.startsWith("{") && segment.endsWith("}")) return true;
|
|
4417
|
+
if (segment !== wanted[index]) return false;
|
|
4418
|
+
literals += 1;
|
|
4419
|
+
return true;
|
|
4420
|
+
}) && (!best || literals > best.literals)) best = {
|
|
4421
|
+
entry,
|
|
4422
|
+
literals
|
|
4423
|
+
};
|
|
4424
|
+
}
|
|
4425
|
+
return best?.entry;
|
|
4426
|
+
}
|
|
4427
|
+
//#endregion
|
|
4428
|
+
//#region src/dry-run.ts
|
|
4429
|
+
/** Methods that change nothing, and so run for real even under `--dry-run`. */
|
|
4430
|
+
const READ_METHODS = /* @__PURE__ */ new Set([
|
|
4431
|
+
"GET",
|
|
4432
|
+
"HEAD",
|
|
4433
|
+
"OPTIONS"
|
|
4434
|
+
]);
|
|
4435
|
+
/**
|
|
4436
|
+
* Thrown by {@link dryRunFetch} instead of performing a write. Carries the plan
|
|
4437
|
+
* so the caller can print it. Deliberately not an `Error` subclass that any retry
|
|
4438
|
+
* or transient-failure predicate would match — see `retry.ts`'s `isTransientError`.
|
|
4439
|
+
*/
|
|
4440
|
+
var DryRunInterception = class extends Error {
|
|
4441
|
+
plan;
|
|
4442
|
+
constructor(plan) {
|
|
4443
|
+
super(`dry run: ${plan.method} ${plan.url} was not sent`);
|
|
4444
|
+
this.name = "DryRunInterception";
|
|
4445
|
+
this.plan = plan;
|
|
4446
|
+
}
|
|
4447
|
+
};
|
|
4448
|
+
const readBody = (init) => {
|
|
4449
|
+
const body = init?.body;
|
|
4450
|
+
if (body === void 0 || body === null) return void 0;
|
|
4451
|
+
if (typeof body !== "string") return `<${typeof body} body>`;
|
|
4452
|
+
try {
|
|
4453
|
+
return JSON.parse(body);
|
|
4454
|
+
} catch {
|
|
4455
|
+
return body;
|
|
4456
|
+
}
|
|
4457
|
+
};
|
|
4458
|
+
/** Wrap a fetch so the first mutating request throws {@link DryRunInterception}. */
|
|
4459
|
+
function dryRunFetch(inner) {
|
|
4460
|
+
return async (input, init) => {
|
|
4461
|
+
const request = input instanceof Request ? input : void 0;
|
|
4462
|
+
const method = (init?.method ?? request?.method ?? "GET").toUpperCase();
|
|
4463
|
+
if (READ_METHODS.has(method)) return inner(input, init);
|
|
4464
|
+
throw new DryRunInterception({
|
|
4465
|
+
method,
|
|
4466
|
+
url: request?.url ?? String(input),
|
|
4467
|
+
body: readBody(init)
|
|
4468
|
+
});
|
|
4469
|
+
};
|
|
4470
|
+
}
|
|
4471
|
+
/** What a human (or an agent) sees instead of the write happening. */
|
|
4472
|
+
function renderDryRunPlan(plan) {
|
|
4473
|
+
const lines = ["[busabase-cli] --dry-run: nothing was sent. This is the request that would have gone out:", ` ${plan.method} ${plan.url}`];
|
|
4474
|
+
if (plan.body !== void 0) lines.push(" body:", JSON.stringify(plan.body, null, 2));
|
|
4475
|
+
return lines.join("\n");
|
|
4476
|
+
}
|
|
4477
|
+
/**
|
|
4478
|
+
* Note printed when `--dry-run` was asked for but the command never tried to
|
|
4479
|
+
* write. Silence would read as "the write was suppressed"; it was not — the
|
|
4480
|
+
* output the command printed is real data from a real read.
|
|
4481
|
+
*/
|
|
4482
|
+
const DRY_RUN_NO_WRITE_NOTE = "[busabase-cli] --dry-run: this command sent no write, so nothing was suppressed — the output above is real.";
|
|
4483
|
+
/**
|
|
4484
|
+
* Record every request's method and path. A curated task command may call several
|
|
4485
|
+
* endpoints, so the one that actually failed is only knowable from the wire —
|
|
4486
|
+
* which is what lets a `403` name the endpoint (and its required permission level)
|
|
4487
|
+
* for tasks too, not just for the generated endpoint commands.
|
|
4488
|
+
*/
|
|
4489
|
+
function observeRequests(inner, sink) {
|
|
4490
|
+
return async (input, init) => {
|
|
4491
|
+
const request = input instanceof Request ? input : void 0;
|
|
4492
|
+
const url = request?.url ?? String(input);
|
|
4493
|
+
try {
|
|
4494
|
+
sink({
|
|
4495
|
+
method: (init?.method ?? request?.method ?? "GET").toUpperCase(),
|
|
4496
|
+
pathname: new URL(url).pathname
|
|
4497
|
+
});
|
|
4498
|
+
} catch {}
|
|
4499
|
+
return inner(input, init);
|
|
4500
|
+
};
|
|
4501
|
+
}
|
|
4502
|
+
//#endregion
|
|
4372
4503
|
//#region src/errors.ts
|
|
4373
4504
|
/**
|
|
4374
4505
|
* Process exit code per class.
|
|
@@ -4481,7 +4612,8 @@ function errorEnvelope(classified) {
|
|
|
4481
4612
|
...classified.status !== void 0 ? { status: classified.status } : {},
|
|
4482
4613
|
message: classified.message,
|
|
4483
4614
|
...classified.issues ? { issues: classified.issues } : {},
|
|
4484
|
-
retryable: classified.retryable
|
|
4615
|
+
retryable: classified.retryable,
|
|
4616
|
+
...classified.hint ? { hint: classified.hint } : {}
|
|
4485
4617
|
};
|
|
4486
4618
|
}
|
|
4487
4619
|
/**
|
|
@@ -4686,6 +4818,13 @@ const BUSABASE_CLI_CLIENT_ID = "busabase-cli";
|
|
|
4686
4818
|
*
|
|
4687
4819
|
* Both end the same way: verify against `/api/v1/auth`, pick the target space, and
|
|
4688
4820
|
* write `BUSABASE_BASE_URL` / `BUSABASE_API_KEY` / `BUSABASE_SPACE_ID`.
|
|
4821
|
+
*
|
|
4822
|
+
* Device authorization additionally splits into a **two-turn agent flow**:
|
|
4823
|
+
* `--no-wait` starts it and returns the verification URL plus a resume code at
|
|
4824
|
+
* once (no blocking, no credential written), and `--resume-code <code>` finishes
|
|
4825
|
+
* it in a later invocation. This exists because many agent harnesses only relay
|
|
4826
|
+
* a turn's FINAL message — a URL printed mid-command while this process blocks
|
|
4827
|
+
* polling never reaches the user, so the blocking flow silently times out.
|
|
4689
4828
|
*/
|
|
4690
4829
|
const OAUTH_TIMEOUT_MS = 3e5;
|
|
4691
4830
|
/** Env key holding the OAuth access-token expiry (ISO), used to drive built-in auto-refresh. */
|
|
@@ -4882,8 +5021,10 @@ const delay = (durationMs) => new Promise((resolve) => setTimeout(resolve, durat
|
|
|
4882
5021
|
async function readDeviceResponse(response) {
|
|
4883
5022
|
return await response.json().catch(() => ({}));
|
|
4884
5023
|
}
|
|
4885
|
-
/**
|
|
4886
|
-
|
|
5024
|
+
/** Fallback lifetime for a device code when the server does not declare one. */
|
|
5025
|
+
const DEVICE_CODE_TTL_SECONDS = 900;
|
|
5026
|
+
/** Step 1 of RFC 8628: ask the server for a device code + verification URL. */
|
|
5027
|
+
async function requestDeviceAuthorization(baseUrl) {
|
|
4887
5028
|
const origin = new URL(baseUrl).origin;
|
|
4888
5029
|
let codeResponse;
|
|
4889
5030
|
try {
|
|
@@ -4904,17 +5045,26 @@ async function deviceLogin(baseUrl, useBrowser) {
|
|
|
4904
5045
|
const code = await codeResponse.json().catch(() => ({}));
|
|
4905
5046
|
if (!codeResponse.ok) throw new Error(`Could not start device sign-in (HTTP ${codeResponse.status})${code.error_description ? `: ${code.error_description}` : "."}`);
|
|
4906
5047
|
if (!code.device_code || !code.user_code || !code.verification_uri) throw new Error("Device sign-in returned an incomplete authorization response.");
|
|
4907
|
-
|
|
4908
|
-
|
|
4909
|
-
|
|
4910
|
-
|
|
4911
|
-
|
|
4912
|
-
|
|
4913
|
-
|
|
4914
|
-
|
|
4915
|
-
|
|
4916
|
-
|
|
4917
|
-
|
|
5048
|
+
return {
|
|
5049
|
+
deviceCode: code.device_code,
|
|
5050
|
+
userCode: code.user_code,
|
|
5051
|
+
verificationUri: code.verification_uri,
|
|
5052
|
+
verificationUrl: code.verification_uri_complete ?? code.verification_uri,
|
|
5053
|
+
expiresInSeconds: Math.max(1, code.expires_in ?? DEVICE_CODE_TTL_SECONDS),
|
|
5054
|
+
intervalSeconds: Math.max(1, code.interval ?? 5)
|
|
5055
|
+
};
|
|
5056
|
+
}
|
|
5057
|
+
/**
|
|
5058
|
+
* Step 2 of RFC 8628: poll until the user approves, then finalize into an API
|
|
5059
|
+
* key. Callable on its own for `--resume-code` (the device code outlives this
|
|
5060
|
+
* process server-side, so resuming needs no new endpoint). `restartCommand`
|
|
5061
|
+
* names the exact command to rerun once a code has expired — a resumed sign-in
|
|
5062
|
+
* must not tell the user to plain "run login" and silently drop their flags.
|
|
5063
|
+
*/
|
|
5064
|
+
async function pollDeviceApiKey(baseUrl, auth, restartCommand) {
|
|
5065
|
+
const origin = new URL(baseUrl).origin;
|
|
5066
|
+
const deadline = Date.now() + auth.expiresInSeconds * 1e3;
|
|
5067
|
+
let intervalSeconds = auth.intervalSeconds;
|
|
4918
5068
|
let consecutiveNetworkFailures = 0;
|
|
4919
5069
|
let pendingPollCount = 0;
|
|
4920
5070
|
let reassuredAfterApprovalHint = false;
|
|
@@ -4931,14 +5081,14 @@ async function deviceLogin(baseUrl, useBrowser) {
|
|
|
4931
5081
|
},
|
|
4932
5082
|
body: JSON.stringify({
|
|
4933
5083
|
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
|
|
4934
|
-
device_code:
|
|
5084
|
+
device_code: auth.deviceCode,
|
|
4935
5085
|
client_id: BUSABASE_CLI_CLIENT_ID
|
|
4936
5086
|
})
|
|
4937
5087
|
});
|
|
4938
5088
|
consecutiveNetworkFailures = 0;
|
|
4939
5089
|
} catch {
|
|
4940
5090
|
consecutiveNetworkFailures += 1;
|
|
4941
|
-
if (consecutiveNetworkFailures >= 3) throw new Error(
|
|
5091
|
+
if (consecutiveNetworkFailures >= 3) throw new Error(`Device sign-in lost its network connection. Check connectivity and run \`${restartCommand}\` again.`);
|
|
4942
5092
|
continue;
|
|
4943
5093
|
}
|
|
4944
5094
|
const payload = await readDeviceResponse(tokenResponse);
|
|
@@ -4949,7 +5099,7 @@ async function deviceLogin(baseUrl, useBrowser) {
|
|
|
4949
5099
|
authorization: `Bearer ${payload.access_token}`,
|
|
4950
5100
|
"content-type": "application/json"
|
|
4951
5101
|
},
|
|
4952
|
-
body: JSON.stringify({ deviceCode:
|
|
5102
|
+
body: JSON.stringify({ deviceCode: auth.deviceCode })
|
|
4953
5103
|
});
|
|
4954
5104
|
const finalized = await finalizeResponse.json().catch(() => ({}));
|
|
4955
5105
|
if (!finalizeResponse.ok || !finalized.apiKey || finalized.credentialType !== "api_key") {
|
|
@@ -4973,11 +5123,24 @@ async function deviceLogin(baseUrl, useBrowser) {
|
|
|
4973
5123
|
intervalSeconds += 5;
|
|
4974
5124
|
continue;
|
|
4975
5125
|
case "access_denied": throw new Error("Device sign-in was denied. No credential was saved.");
|
|
4976
|
-
case "expired_token": throw new Error(
|
|
5126
|
+
case "expired_token": throw new Error(`The device code expired. Run \`${restartCommand}\` to request a new one.`);
|
|
4977
5127
|
default: throw new Error(`Device sign-in failed${payload.error_description ? `: ${payload.error_description}` : ` (HTTP ${tokenResponse.status})`}.`);
|
|
4978
5128
|
}
|
|
4979
5129
|
}
|
|
4980
|
-
throw new Error(
|
|
5130
|
+
throw new Error(`The device code expired. Run \`${restartCommand}\` to request a new one.`);
|
|
5131
|
+
}
|
|
5132
|
+
/** RFC 8628 login: no local callback and no credential ever written to output. */
|
|
5133
|
+
async function deviceLogin(baseUrl, useBrowser, profile) {
|
|
5134
|
+
const auth = await requestDeviceAuthorization(baseUrl);
|
|
5135
|
+
say("");
|
|
5136
|
+
say("Authorize Busabase CLI in any browser:");
|
|
5137
|
+
say(` ${auth.verificationUri}`);
|
|
5138
|
+
say(` Code: ${auth.userCode}`);
|
|
5139
|
+
say("");
|
|
5140
|
+
if (useBrowser) openBrowser(auth.verificationUrl);
|
|
5141
|
+
if (!isInteractive()) say(`[AI agent] This command blocks (up to ${Math.round(auth.expiresInSeconds / 60)} minutes) until the user approves in a browser. If your harness only delivers a turn's final message, the user will never see the URL above. Instead run \`${buildLoginCommand(baseUrl, profile, "--no-wait --output json")}\`, send verification_url to the user as your final message, end the turn, and finish later with \`--resume-code\`. Do not restart login after showing the URL — a restart invalidates it.`);
|
|
5142
|
+
say("Waiting for authorization…");
|
|
5143
|
+
return pollDeviceApiKey(baseUrl, auth, buildLoginCommand(baseUrl, profile, ""));
|
|
4981
5144
|
}
|
|
4982
5145
|
/** Exchange a rotating refresh token for a new OAuth token set. */
|
|
4983
5146
|
async function refreshToken(baseUrl, refreshToken) {
|
|
@@ -5020,14 +5183,37 @@ function pickSpaceId(verify, preselected) {
|
|
|
5020
5183
|
return fallback;
|
|
5021
5184
|
}
|
|
5022
5185
|
/**
|
|
5186
|
+
* Assemble a copy-paste-runnable `busabase-cli login …` for THIS connection —
|
|
5187
|
+
* non-default base URL and active profile included — so an agent (or the JSON
|
|
5188
|
+
* error envelope) never hands the user a command that targets the wrong host
|
|
5189
|
+
* or the wrong account.
|
|
5190
|
+
*/
|
|
5191
|
+
const buildLoginCommand = (baseUrl, profile, suffix) => {
|
|
5192
|
+
const parts = ["busabase-cli login"];
|
|
5193
|
+
if (normalizeBaseUrl(baseUrl) !== normalizeBaseUrl(DEFAULT_BASE_URL)) parts.push(`--base-url ${normalizeBaseUrl(baseUrl)}`);
|
|
5194
|
+
if (profile) parts.push(`--profile ${profile}`);
|
|
5195
|
+
if (suffix) parts.push(suffix);
|
|
5196
|
+
return parts.join(" ");
|
|
5197
|
+
};
|
|
5198
|
+
/**
|
|
5199
|
+
* Machine-readable next step for an auth failure, attached to the JSON error
|
|
5200
|
+
* envelope (the human prose in `explainError` stays as-is). Written for an
|
|
5201
|
+
* agent recovering without a terminal: the two-turn device flow is the one
|
|
5202
|
+
* path that works when only a turn's final message reaches the user.
|
|
5203
|
+
*/
|
|
5204
|
+
function authRecoveryHint(code, baseUrl, profile, endpoint) {
|
|
5205
|
+
if (code === "FORBIDDEN") return "The credential was accepted but lacks permission for this action. " + (endpoint?.procedureId && endpoint.level ? `\`${endpoint.procedureId}\` requires an API key at level \`${endpoint.level}\`${endpoint.declared ? "" : " (no explicit policy for it; the server fails closed to this)"}. Check yours with \`busabase-cli whoami\`. ` : "") + "Confirm the target space (`busabase-cli space list`, then `busabase-cli space use <id>`), or ask a space admin to issue a key at the level above. Note a `changeRequest`-level key can propose but deliberately cannot merge its own proposal.";
|
|
5206
|
+
return `Run \`${buildLoginCommand(baseUrl, profile, "--no-wait --output json")}\` to get verification_url and resume_code. Show verification_url to the user EXACTLY as returned (an opaque string — never re-encode, trim, or re-wrap it) and end this turn so they can approve it on any device. After they confirm, run \`${buildLoginCommand(baseUrl, profile, "--resume-code <resume_code>")}\` to finish signing in, then retry this command. Do not restart login after showing the URL — a restart invalidates it. For headless automation, pass --api-key <token> from the environment instead.`;
|
|
5207
|
+
}
|
|
5208
|
+
/**
|
|
5023
5209
|
* Interactive "where is your Busabase?" menu. Every option resolves to the same two
|
|
5024
5210
|
* axes: which base URL, and how (if at all) to obtain a token — the rest of login just
|
|
5025
5211
|
* writes that to ~/.busabase/.env.
|
|
5026
5212
|
*/
|
|
5027
5213
|
async function chooseTarget() {
|
|
5028
5214
|
const cloud = normalizeBaseUrl(DEFAULT_BASE_URL);
|
|
5029
|
-
say("Busabase is
|
|
5030
|
-
say("
|
|
5215
|
+
say("Busabase is your AI agents' database, knowledge base, apps library, and skills registry.");
|
|
5216
|
+
say("Every change goes in as a change request, so it keeps a message, a diff, and a history.");
|
|
5031
5217
|
say("This login connects the CLI to the Busabase instance you want to use.");
|
|
5032
5218
|
say("");
|
|
5033
5219
|
say("How should this CLI connect?");
|
|
@@ -5074,8 +5260,23 @@ async function chooseTarget() {
|
|
|
5074
5260
|
async function runLogin(options) {
|
|
5075
5261
|
let baseUrl = normalizeBaseUrl(options.baseUrl);
|
|
5076
5262
|
let apiKey = options.apiKey;
|
|
5263
|
+
if (options.noWait && apiKey) throw new Error("--no-wait starts a device sign-in and cannot be combined with --api-key.");
|
|
5264
|
+
if (options.noWait && options.resumeCode) throw new Error("--no-wait and --resume-code are the two halves of one sign-in — pass one, not both.");
|
|
5265
|
+
if (options.noWait) {
|
|
5266
|
+
const auth = await requestDeviceAuthorization(baseUrl);
|
|
5267
|
+
const resume = buildLoginCommand(baseUrl, options.profile, `--resume-code ${auth.deviceCode}`);
|
|
5268
|
+
return {
|
|
5269
|
+
status: "authorization pending",
|
|
5270
|
+
verification_url: auth.verificationUrl,
|
|
5271
|
+
user_code: auth.userCode,
|
|
5272
|
+
resume_code: auth.deviceCode,
|
|
5273
|
+
expires_in: String(auth.expiresInSeconds),
|
|
5274
|
+
hint: `Show verification_url to the user EXACTLY as returned — it is an opaque string; never re-encode, trim, or re-wrap it — then END THIS TURN so they can approve it on any device (a QR code helps: \`busabase-cli qrcode <verification_url> --out-file qr.png\`). After the user confirms, run \`${resume}\` to finish signing in. Do NOT start a new login in the meantime — a fresh login invalidates this link.`
|
|
5275
|
+
};
|
|
5276
|
+
}
|
|
5077
5277
|
let method;
|
|
5078
|
-
if (
|
|
5278
|
+
if (options.resumeCode) method = "device-resume";
|
|
5279
|
+
else if (apiKey) method = "api-key";
|
|
5079
5280
|
else if (options.deviceCode) method = "device";
|
|
5080
5281
|
else if (options.oauth) method = "loopback-oauth";
|
|
5081
5282
|
else if (isInteractive()) {
|
|
@@ -5104,7 +5305,16 @@ async function runLogin(options) {
|
|
|
5104
5305
|
let apiKeyExpiresAt;
|
|
5105
5306
|
let refreshTokenValue;
|
|
5106
5307
|
if (method === "device") {
|
|
5107
|
-
const result = await deviceLogin(baseUrl, options.browser);
|
|
5308
|
+
const result = await deviceLogin(baseUrl, options.browser, options.profile);
|
|
5309
|
+
token = result.token;
|
|
5310
|
+
apiKeyExpiresAt = result.apiKeyExpiresAt;
|
|
5311
|
+
} else if (method === "device-resume") {
|
|
5312
|
+
say("Resuming device sign-in…");
|
|
5313
|
+
const result = await pollDeviceApiKey(baseUrl, {
|
|
5314
|
+
deviceCode: options.resumeCode ?? "",
|
|
5315
|
+
intervalSeconds: 5,
|
|
5316
|
+
expiresInSeconds: DEVICE_CODE_TTL_SECONDS
|
|
5317
|
+
}, buildLoginCommand(baseUrl, options.profile, "--no-wait"));
|
|
5108
5318
|
token = result.token;
|
|
5109
5319
|
apiKeyExpiresAt = result.apiKeyExpiresAt;
|
|
5110
5320
|
} else if (method === "loopback-oauth") {
|
|
@@ -5135,8 +5345,8 @@ async function runLogin(options) {
|
|
|
5135
5345
|
say(`✓ Signed in and saved to ${activeCredentialPath()}`);
|
|
5136
5346
|
return {
|
|
5137
5347
|
status: "signed in",
|
|
5138
|
-
method: method === "loopback-oauth" ? "oauth" : method,
|
|
5139
|
-
credentialType: method === "device" ? "api_key" : expiresAt ? "oauth" : "api_key",
|
|
5348
|
+
method: method === "loopback-oauth" ? "oauth" : method === "device-resume" ? "device" : method,
|
|
5349
|
+
credentialType: method === "device" || method === "device-resume" ? "api_key" : expiresAt ? "oauth" : "api_key",
|
|
5140
5350
|
user: verify.user?.email ?? verify.user?.name ?? verify.user?.id ?? "(unknown)",
|
|
5141
5351
|
space: spaceId ?? "(server default)",
|
|
5142
5352
|
availableSpaces: (verify.spaces ?? []).map((space) => `${space.name} [${space.id}]`).join(", "),
|
|
@@ -5698,24 +5908,30 @@ const PACKAGE_SIDECARS = PACKAGE_SKILL_SIDECAR_DIRS.join("|");
|
|
|
5698
5908
|
//#endregion
|
|
5699
5909
|
//#region ../../packages/busabase-contract/src/contract/auto-merge.ts
|
|
5700
5910
|
/**
|
|
5701
|
-
* `autoMerge` on
|
|
5702
|
-
*
|
|
5703
|
-
* These
|
|
5704
|
-
*
|
|
5705
|
-
*
|
|
5706
|
-
*
|
|
5707
|
-
*
|
|
5708
|
-
*
|
|
5709
|
-
*
|
|
5710
|
-
*
|
|
5711
|
-
*
|
|
5712
|
-
*
|
|
5713
|
-
*
|
|
5714
|
-
*
|
|
5715
|
-
*
|
|
5716
|
-
*
|
|
5717
|
-
*
|
|
5718
|
-
*
|
|
5911
|
+
* `autoMerge` on a destructive-but-reversible operation.
|
|
5912
|
+
*
|
|
5913
|
+
* These six operations (record delete/restore, field delete/convert, Base
|
|
5914
|
+
* archive, mounted-asset content edits, and file batches containing a delete)
|
|
5915
|
+
* used to reject `autoMerge: true` outright: they queued for human review no
|
|
5916
|
+
* matter who called them. That made sense while the product was positioned as
|
|
5917
|
+
* approval-first, but it is not how Busabase is positioned or how the rest of
|
|
5918
|
+
* the write surface behaves — every other write is *permission-aware*
|
|
5919
|
+
* (`shouldAutoMerge`): unset + write access on the target node means "just do
|
|
5920
|
+
* it", and only an explicit `false` forces review.
|
|
5921
|
+
*
|
|
5922
|
+
* A single person running their own workspace hit the old behaviour as pure
|
|
5923
|
+
* friction: archiving one duplicate record left a change request they then had
|
|
5924
|
+
* to go and approve themselves. Reversibility is what makes the permission-aware
|
|
5925
|
+
* default safe here — delete ARCHIVES, archive is undone by restore, and a
|
|
5926
|
+
* field convert is preceded by `previewFieldConversion`. Callers who do want a
|
|
5927
|
+
* second pair of eyes still get it by passing an explicit `autoMerge: false`,
|
|
5928
|
+
* which is exactly how every other endpoint in this family expresses that.
|
|
5929
|
+
*
|
|
5930
|
+
* The schemas that use this are NOT `.strict()`, and making them strict is not
|
|
5931
|
+
* the fix: the SDK and CLI ship on their own npm cadence and busabase is
|
|
5932
|
+
* self-hosted, so a newer client sending a newer optional field to an older
|
|
5933
|
+
* server is normal traffic. Blanket strictness turns that graceful degradation
|
|
5934
|
+
* into a hard 400 for every field, to catch one.
|
|
5719
5935
|
*
|
|
5720
5936
|
* This lives in its own leaf module (zod only, no sibling imports) on purpose:
|
|
5721
5937
|
* putting it in `contract/schemas.ts` created an import cycle with the domain
|
|
@@ -5723,7 +5939,7 @@ const PACKAGE_SIDECARS = PACKAGE_SKILL_SIDECAR_DIRS.join("|");
|
|
|
5723
5939
|
* `ReferenceError: Cannot access 'autoMergeNotAccepted' before initialization`
|
|
5724
5940
|
* that `tsc` reported as clean.
|
|
5725
5941
|
*/
|
|
5726
|
-
const
|
|
5942
|
+
const destructiveAutoMerge = (undoNote) => z.boolean().optional().describe(`Whether to approve and merge this change immediately. Omitted defaults to merging immediately if the actor has write access on the target node, otherwise falling back to a pending Change Request; pass explicit false to force review even with write access. ${undoNote}`);
|
|
5727
5943
|
//#endregion
|
|
5728
5944
|
//#region ../../packages/busabase-contract/src/domains/base/contract/base-schemas.ts
|
|
5729
5945
|
const fieldNameSchema = z.union([z.string().min(1), iStringRecordSchema.refine((record) => Object.values(record).some((value) => value && value.trim().length > 0), "field name must have at least one non-empty locale value")]);
|
|
@@ -5870,16 +6086,13 @@ const createBaseFieldInputSchema = z.object({
|
|
|
5870
6086
|
* node, otherwise falls back to a pending ChangeRequest. Explicit `false` forces
|
|
5871
6087
|
* review even with write access.
|
|
5872
6088
|
*
|
|
5873
|
-
*
|
|
5874
|
-
*
|
|
5875
|
-
*
|
|
5876
|
-
*
|
|
5877
|
-
*
|
|
5878
|
-
* it, and converting one can drop values outright (which is why
|
|
5879
|
-
* `previewFieldConversion` exists as a dry run). Those two stay in front of a
|
|
5880
|
-
* human.
|
|
6089
|
+
* The `delete` and `convert` branches below used to reject `autoMerge` outright.
|
|
6090
|
+
* They no longer do — see `contract/auto-merge.ts` for why. They carry a
|
|
6091
|
+
* `destructiveAutoMerge` schema instead: the same tri-state, with a description
|
|
6092
|
+
* that says what the operation costs to undo, so a caller who genuinely wants a
|
|
6093
|
+
* second pair of eyes knows to pass `false`.
|
|
5881
6094
|
*/
|
|
5882
|
-
const fieldAutoMergeSchema = z.boolean().optional().describe("Whether to approve and merge this field change immediately. Omitted defaults to merging immediately if the actor has write access on the Base's node, otherwise falling back to a pending Change Request; pass explicit false to force review even with write access.
|
|
6095
|
+
const fieldAutoMergeSchema = z.boolean().optional().describe("Whether to approve and merge this field change immediately. Omitted defaults to merging immediately if the actor has write access on the Base's node, otherwise falling back to a pending Change Request; pass explicit false to force review even with write access.");
|
|
5883
6096
|
const createFieldChangeRequestInputSchema = createBaseFieldInputSchema.extend({
|
|
5884
6097
|
message: z.string().optional().default("Add field"),
|
|
5885
6098
|
submittedBy: z.string().optional().default("local-editor"),
|
|
@@ -5889,7 +6102,7 @@ const deleteFieldChangeRequestInputSchema = z.object({
|
|
|
5889
6102
|
fieldId: z.string().min(1),
|
|
5890
6103
|
message: z.string().optional(),
|
|
5891
6104
|
submittedBy: z.string().optional().default("local-editor"),
|
|
5892
|
-
autoMerge:
|
|
6105
|
+
autoMerge: destructiveAutoMerge("A deleted field is soft-deleted with its stored values and is brought back by the `restore` operation.")
|
|
5893
6106
|
});
|
|
5894
6107
|
const updateFieldChangeRequestInputSchema = z.object({
|
|
5895
6108
|
fieldId: z.string().min(1),
|
|
@@ -5904,7 +6117,15 @@ const updateFieldChangeRequestInputSchema = z.object({
|
|
|
5904
6117
|
});
|
|
5905
6118
|
z.object({
|
|
5906
6119
|
fieldId: z.string().min(1),
|
|
5907
|
-
newType: fieldTypeSchema
|
|
6120
|
+
newType: fieldTypeSchema,
|
|
6121
|
+
/**
|
|
6122
|
+
* Mirrors `convertFieldChangeRequest.selectChoiceMode` so the dry run models the
|
|
6123
|
+
* conversion the caller is actually going to submit. Under `auto_create` a value with
|
|
6124
|
+
* no matching choice is not a conflict — the merge mints a choice for it — while under
|
|
6125
|
+
* `null_on_missing` the same value is dropped. Defaults to `null_on_missing`, matching
|
|
6126
|
+
* the mutation's own default.
|
|
6127
|
+
*/
|
|
6128
|
+
selectChoiceMode: z.enum(["auto_create", "null_on_missing"]).default("null_on_missing")
|
|
5908
6129
|
});
|
|
5909
6130
|
z.object({
|
|
5910
6131
|
totalCount: z.number(),
|
|
@@ -5921,7 +6142,7 @@ const convertFieldChangeRequestInputSchema = z.object({
|
|
|
5921
6142
|
selectChoiceMode: z.enum(["auto_create", "null_on_missing"]).default("null_on_missing"),
|
|
5922
6143
|
message: z.string().optional(),
|
|
5923
6144
|
submittedBy: z.string().optional().default("local-editor"),
|
|
5924
|
-
autoMerge:
|
|
6145
|
+
autoMerge: destructiveAutoMerge("A convert can drop values that do not fit the new type — call `previewFieldConversion` first to see exactly which, and pass `autoMerge: false` if you want a human to sign off on that preview.")
|
|
5925
6146
|
});
|
|
5926
6147
|
const reorderFieldsChangeRequestInputSchema = z.object({
|
|
5927
6148
|
fieldIds: z.array(z.string()).min(1),
|
|
@@ -5932,7 +6153,7 @@ const reorderFieldsChangeRequestInputSchema = z.object({
|
|
|
5932
6153
|
const archiveBaseInputSchema = z.object({
|
|
5933
6154
|
message: z.string().optional(),
|
|
5934
6155
|
submittedBy: z.string().optional().default("local-editor"),
|
|
5935
|
-
autoMerge:
|
|
6156
|
+
autoMerge: destructiveAutoMerge("Archiving takes the Base and every record in it out of every listing at once. That is reversible via `operation: \"restore\"`, but it is the widest-blast-radius write in this family — pass `autoMerge: false` when it should stop for a human.")
|
|
5936
6157
|
});
|
|
5937
6158
|
const restoreBaseInputSchema = z.object({
|
|
5938
6159
|
message: z.string().optional(),
|
|
@@ -6037,7 +6258,8 @@ const nodeSchema = z.lazy(() => z.object({
|
|
|
6037
6258
|
updatedAt: z.string(),
|
|
6038
6259
|
baseId: z.string().nullable(),
|
|
6039
6260
|
children: z.array(nodeSchema),
|
|
6040
|
-
hasChildren: z.boolean().optional()
|
|
6261
|
+
hasChildren: z.boolean().optional(),
|
|
6262
|
+
shared: z.boolean().optional()
|
|
6041
6263
|
}));
|
|
6042
6264
|
z.object({
|
|
6043
6265
|
id: z.string(),
|
|
@@ -6315,6 +6537,51 @@ const commentSchema = z.object({
|
|
|
6315
6537
|
createdAt: z.string(),
|
|
6316
6538
|
updatedAt: z.string()
|
|
6317
6539
|
});
|
|
6540
|
+
/**
|
|
6541
|
+
* One row of the Inbox's Mentions tab: a comment the caller was `@`-mentioned
|
|
6542
|
+
* in, plus where to go to read it in context.
|
|
6543
|
+
*
|
|
6544
|
+
* Keyed by COMMENT, not by mention row — being named twice in one comment is
|
|
6545
|
+
* legitimate (each occurrence needs its own span to render its own chip), but
|
|
6546
|
+
* it is still one thing to go read, so it is one entry here.
|
|
6547
|
+
*/
|
|
6548
|
+
const mentionInboxItemSchema = z.object({
|
|
6549
|
+
commentId: z.string(),
|
|
6550
|
+
subjectType: commentSubjectTypeSchema,
|
|
6551
|
+
/** Comment body, for the row's preview line. */
|
|
6552
|
+
body: z.string(),
|
|
6553
|
+
authorId: z.string(),
|
|
6554
|
+
author: userRefSchema.nullable().optional().default(null),
|
|
6555
|
+
createdAt: z.string(),
|
|
6556
|
+
/** Null once read. The newest unread stamp across this comment's mentions. */
|
|
6557
|
+
unread: z.boolean(),
|
|
6558
|
+
/**
|
|
6559
|
+
* Dashboard-relative path to the comment's context, or null when the subject
|
|
6560
|
+
* has no page of its own (a `commit`-scoped comment on a comment that is not
|
|
6561
|
+
* attached to a change request — the repo has no commit detail route).
|
|
6562
|
+
* A null href still renders a row: swallowing the notification because we
|
|
6563
|
+
* cannot link it would leave the recipient never knowing they were mentioned.
|
|
6564
|
+
*/
|
|
6565
|
+
href: z.string().nullable()
|
|
6566
|
+
});
|
|
6567
|
+
z.object({
|
|
6568
|
+
items: z.array(mentionInboxItemSchema),
|
|
6569
|
+
total: z.number().int(),
|
|
6570
|
+
/** Distinct unread comments — what the tab badge shows. */
|
|
6571
|
+
unreadCount: z.number().int()
|
|
6572
|
+
});
|
|
6573
|
+
z.object({
|
|
6574
|
+
page: z.number().int().min(1).optional().default(1),
|
|
6575
|
+
pageSize: z.number().int().min(1).max(100).optional().default(50)
|
|
6576
|
+
});
|
|
6577
|
+
z.object({
|
|
6578
|
+
/** Stamps every unread mention row this caller has on that comment. */
|
|
6579
|
+
commentId: z.string() });
|
|
6580
|
+
z.object({
|
|
6581
|
+
/** How many rows were stamped; 0 when it was already read. */
|
|
6582
|
+
marked: z.number().int(),
|
|
6583
|
+
unreadCount: z.number().int()
|
|
6584
|
+
});
|
|
6318
6585
|
const changeRequestStatusSchema = z.enum([
|
|
6319
6586
|
"in_review",
|
|
6320
6587
|
"changes_requested",
|
|
@@ -6530,7 +6797,7 @@ z.object({
|
|
|
6530
6797
|
message: z.string().optional().default("Delete record").describe("Explanation shown to the human reviewer. Say what is being removed and why, e.g. \"Archive duplicate contact — merged into Acme Corp\"."),
|
|
6531
6798
|
submittedBy: z.string().optional().default("local-producer"),
|
|
6532
6799
|
deleteMode: z.enum(["archive"]).optional().default("archive"),
|
|
6533
|
-
autoMerge:
|
|
6800
|
+
autoMerge: destructiveAutoMerge("Archiving is reversible: the record leaves every listing but is restored intact by `operation: \"restore\"`.")
|
|
6534
6801
|
});
|
|
6535
6802
|
z.object({
|
|
6536
6803
|
fields: z.record(z.string(), z.unknown()).describe("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 — it is the record's display title everywhere."),
|
|
@@ -10700,6 +10967,379 @@ async function paginateAll(options) {
|
|
|
10700
10967
|
return written;
|
|
10701
10968
|
}
|
|
10702
10969
|
//#endregion
|
|
10970
|
+
//#region src/qrcode-command.ts
|
|
10971
|
+
/**
|
|
10972
|
+
* `busabase-cli qrcode <url>` — render a URL as a QR code.
|
|
10973
|
+
*
|
|
10974
|
+
* Exists for one moment: an agent has a login `verification_url` and the human
|
|
10975
|
+
* who must approve it is holding a phone. A QR code removes the copy-a-long-URL
|
|
10976
|
+
* step entirely. The URL is treated as an OPAQUE string — encoded byte-for-byte,
|
|
10977
|
+
* never re-encoded, trimmed, or re-assembled — because a "helpfully" reworded
|
|
10978
|
+
* URL is a 404 on the user's phone.
|
|
10979
|
+
*/
|
|
10980
|
+
/** Same shape as lark-cli's hint: generating the file alone is a classic agent miss. */
|
|
10981
|
+
const QR_DISPLAY_HINT = "You MUST include this QR image in your reply to the user — generating the file alone is not enough. Show the URL first (verbatim — it is an opaque string; never re-encode, trim, or re-wrap it) and place the QR image below it.";
|
|
10982
|
+
const QR_MAX_SIZE = 1024;
|
|
10983
|
+
/**
|
|
10984
|
+
* A `--out-file` path may live under the working directory, the OS temp
|
|
10985
|
+
* directory, or the user's home — the places an agent legitimately writes
|
|
10986
|
+
* artifacts. Anything else (e.g. `/etc/...`) is refused rather than written.
|
|
10987
|
+
*/
|
|
10988
|
+
function safeQrOutputPath(raw) {
|
|
10989
|
+
const abs = resolve(raw);
|
|
10990
|
+
if (![
|
|
10991
|
+
process.cwd(),
|
|
10992
|
+
tmpdir(),
|
|
10993
|
+
homedir()
|
|
10994
|
+
].some((root) => {
|
|
10995
|
+
const prefix = root.endsWith(sep) ? root : root + sep;
|
|
10996
|
+
return abs === root || abs.startsWith(prefix);
|
|
10997
|
+
})) throw new Error(`Unsafe --out-file path "${raw}" — write inside the current directory, ${tmpdir()}, or your home directory.`);
|
|
10998
|
+
return abs;
|
|
10999
|
+
}
|
|
11000
|
+
/** Encode `url` into a PNG at `filePath` (path must already be validated). */
|
|
11001
|
+
async function writeQrPng(url, filePath, size) {
|
|
11002
|
+
if (!Number.isInteger(size) || size < 64 || size > 1024) throw new Error(`--size must be an integer between 64 and ${QR_MAX_SIZE}.`);
|
|
11003
|
+
await QRCode.toFile(filePath, url, {
|
|
11004
|
+
type: "png",
|
|
11005
|
+
width: size,
|
|
11006
|
+
errorCorrectionLevel: "M"
|
|
11007
|
+
});
|
|
11008
|
+
}
|
|
11009
|
+
/** Encode `url` as a small terminal (ANSI half-block) QR code. */
|
|
11010
|
+
async function renderQrAscii(url) {
|
|
11011
|
+
return QRCode.toString(url, {
|
|
11012
|
+
type: "terminal",
|
|
11013
|
+
small: true,
|
|
11014
|
+
errorCorrectionLevel: "M"
|
|
11015
|
+
});
|
|
11016
|
+
}
|
|
11017
|
+
z.object({
|
|
11018
|
+
level: z.enum([
|
|
11019
|
+
"read",
|
|
11020
|
+
"changeRequest",
|
|
11021
|
+
"write",
|
|
11022
|
+
"manage"
|
|
11023
|
+
]),
|
|
11024
|
+
nodeScope: z.array(z.string()).optional()
|
|
11025
|
+
}).nullable();
|
|
11026
|
+
z.object({ level: z.enum([
|
|
11027
|
+
"read",
|
|
11028
|
+
"changeRequest",
|
|
11029
|
+
"write",
|
|
11030
|
+
"manage"
|
|
11031
|
+
]) }).strict().nullable();
|
|
11032
|
+
/**
|
|
11033
|
+
* Explicit path → level+scope table for every current procedure. Nothing is
|
|
11034
|
+
* inferred from HTTP method: POST grep/preview routes can be reads, while GET
|
|
11035
|
+
* Webhook/Vault configuration routes are manage. Any unknown path defaults to
|
|
11036
|
+
* workspace manage, and the parity test prevents contract additions from
|
|
11037
|
+
* remaining unknown in reviewed code. Keyed by the dotted path *relative to
|
|
11038
|
+
* `busabaseRouter`* (e.g. `"bases.create"`, `"changeRequests.merge"`) — see
|
|
11039
|
+
* `resolveRequiredLevel` for why the real oRPC `path` seen at runtime carries
|
|
11040
|
+
* a leading `"workbench"` mount-key segment that must be stripped first.
|
|
11041
|
+
*
|
|
11042
|
+
* Enumerated against the real `packages/busabase-core/src/router.ts` +
|
|
11043
|
+
* every `packages/busabase-contract/src/domains/*/contract.ts` route
|
|
11044
|
+
* definition (not guessed) — see the implementation report for the full
|
|
11045
|
+
* cross-check, including known method/semantics deviations:
|
|
11046
|
+
* - `dump.exportTables` is a POST route in the real contract (the design
|
|
11047
|
+
* doc's prose called it a GET) — already force-classified to `manage`
|
|
11048
|
+
* below either way, so this doesn't change behavior, just the reasoning.
|
|
11049
|
+
* - The top-level `grep` route is POST because it takes a request body, but
|
|
11050
|
+
* is explicitly classified as a node-scoped read.
|
|
11051
|
+
*/
|
|
11052
|
+
const node = (level) => ({
|
|
11053
|
+
level,
|
|
11054
|
+
scope: "node"
|
|
11055
|
+
});
|
|
11056
|
+
const workspace = (level) => ({
|
|
11057
|
+
level,
|
|
11058
|
+
scope: "workspace"
|
|
11059
|
+
});
|
|
11060
|
+
/**
|
|
11061
|
+
* Exhaustive policy for the current Busabase contract. A contract-parity test
|
|
11062
|
+
* fails when a procedure is added without an explicit decision here.
|
|
11063
|
+
*/
|
|
11064
|
+
const PROCEDURE_PERMISSION_POLICY = {
|
|
11065
|
+
"auth.verify": workspace("read"),
|
|
11066
|
+
search: node("read"),
|
|
11067
|
+
grep: node("read"),
|
|
11068
|
+
"nodes.list": node("read"),
|
|
11069
|
+
"nodes.get": node("read"),
|
|
11070
|
+
"nodes.getAgentPrompts": node("read"),
|
|
11071
|
+
"nodes.searchByName": node("read"),
|
|
11072
|
+
"nodes.isDescendant": node("read"),
|
|
11073
|
+
"nodes.ancestors": node("read"),
|
|
11074
|
+
"nodes.resolveRouteState": node("read"),
|
|
11075
|
+
"nodes.createChangeRequest": node("changeRequest"),
|
|
11076
|
+
"operations.revise": node("changeRequest"),
|
|
11077
|
+
"bases.createChangeRequest": node("changeRequest"),
|
|
11078
|
+
"bases.createBulkChangeRequest": node("changeRequest"),
|
|
11079
|
+
"bases.createBulkUpdateChangeRequest": node("changeRequest"),
|
|
11080
|
+
"bases.fieldChangeRequest": node("changeRequest"),
|
|
11081
|
+
"bases.lifecycleChangeRequest": node("changeRequest"),
|
|
11082
|
+
"records.changeRequest": node("changeRequest"),
|
|
11083
|
+
"views.changeRequest": node("changeRequest"),
|
|
11084
|
+
"nodes.updateContent": node("changeRequest"),
|
|
11085
|
+
"fileTrees.createChangeRequest": node("changeRequest"),
|
|
11086
|
+
"assets.createUploadUrl": workspace("changeRequest"),
|
|
11087
|
+
"assets.confirm": workspace("changeRequest"),
|
|
11088
|
+
"nodes.icon.createUploadUrl": node("changeRequest"),
|
|
11089
|
+
"nodes.icon.confirm": node("changeRequest"),
|
|
11090
|
+
"assets.putText": node("write"),
|
|
11091
|
+
"assets.createTextUploadUrl": node("write"),
|
|
11092
|
+
"changeRequests.review": node("write"),
|
|
11093
|
+
"changeRequests.merge": node("write"),
|
|
11094
|
+
"changeRequests.close": node("write"),
|
|
11095
|
+
"bases.create": node("write"),
|
|
11096
|
+
"bases.createField": node("write"),
|
|
11097
|
+
"docs.create": node("write"),
|
|
11098
|
+
"fileTrees.create": node("write"),
|
|
11099
|
+
"files.create": node("write"),
|
|
11100
|
+
"nodes.move": node("write"),
|
|
11101
|
+
"nodes.updateMetadata": node("write"),
|
|
11102
|
+
"nodes.updateSettings": node("write"),
|
|
11103
|
+
"nodes.updateAgentPrompts": node("write"),
|
|
11104
|
+
"nodes.toggleFavorite": node("write"),
|
|
11105
|
+
"comments.create": node("write"),
|
|
11106
|
+
"auditEvents.create": workspace("manage"),
|
|
11107
|
+
"assets.updateMetadata": node("write"),
|
|
11108
|
+
"assets.delete": node("write"),
|
|
11109
|
+
"assets.editContent": node("write"),
|
|
11110
|
+
"webhooks.list": workspace("manage"),
|
|
11111
|
+
"webhooks.get": workspace("manage"),
|
|
11112
|
+
"webhooks.create": workspace("manage"),
|
|
11113
|
+
"webhooks.update": workspace("manage"),
|
|
11114
|
+
"webhooks.delete": workspace("manage"),
|
|
11115
|
+
"webhooks.deliveries": workspace("manage"),
|
|
11116
|
+
"webhooks.testFire": workspace("manage"),
|
|
11117
|
+
"vault.get": workspace("manage"),
|
|
11118
|
+
"vault.update": workspace("manage"),
|
|
11119
|
+
"vault.clear": workspace("manage"),
|
|
11120
|
+
"dump.exportTables": workspace("manage"),
|
|
11121
|
+
"dump.exportAssetText": workspace("manage"),
|
|
11122
|
+
"dump.exportDocBodies": workspace("manage"),
|
|
11123
|
+
"dump.importBegin": workspace("manage"),
|
|
11124
|
+
"dump.importTables": workspace("manage"),
|
|
11125
|
+
"dump.importCommit": workspace("manage"),
|
|
11126
|
+
"dump.importAbort": workspace("manage"),
|
|
11127
|
+
"nodes.purge": node("manage"),
|
|
11128
|
+
"nodes.updateVisibility": node("manage"),
|
|
11129
|
+
"nodes.principals.list": node("manage"),
|
|
11130
|
+
"nodes.principals.add": node("manage"),
|
|
11131
|
+
"nodes.principals.remove": node("manage"),
|
|
11132
|
+
"nodes.share.get": node("manage"),
|
|
11133
|
+
"nodes.share.set": node("manage"),
|
|
11134
|
+
"nodes.share.disable": node("manage"),
|
|
11135
|
+
"embedLinks.create": workspace("manage"),
|
|
11136
|
+
"embedLinks.list": workspace("manage"),
|
|
11137
|
+
"embedLinks.revoke": workspace("manage"),
|
|
11138
|
+
"nodes.listFavorites": node("read"),
|
|
11139
|
+
"auditEvents.list": node("read"),
|
|
11140
|
+
"activity.listPaged": node("read"),
|
|
11141
|
+
"activity.listForNode": node("read"),
|
|
11142
|
+
"activity.listForRecord": node("read"),
|
|
11143
|
+
"comments.list": node("read"),
|
|
11144
|
+
"comments.listMentions": node("read"),
|
|
11145
|
+
"comments.markMentionsRead": node("read"),
|
|
11146
|
+
"agent.listTasks": node("read"),
|
|
11147
|
+
"live.subscribe": workspace("manage"),
|
|
11148
|
+
"bases.list": node("read"),
|
|
11149
|
+
"bases.get": node("read"),
|
|
11150
|
+
"bases.listDeletedFields": node("read"),
|
|
11151
|
+
"bases.listViews": node("read"),
|
|
11152
|
+
"bases.previewFieldConversion": node("read"),
|
|
11153
|
+
"fileTrees.listFiles": node("read"),
|
|
11154
|
+
"fileTrees.readFile": node("read"),
|
|
11155
|
+
"airapps.runLocal": node("write"),
|
|
11156
|
+
"airapps.stopLocal": node("write"),
|
|
11157
|
+
"nodes.readLines": node("read"),
|
|
11158
|
+
"forms.list": node("read"),
|
|
11159
|
+
"forms.getByNode": node("read"),
|
|
11160
|
+
"forms.create": node("manage"),
|
|
11161
|
+
"forms.update": node("manage"),
|
|
11162
|
+
"forms.submit": node("changeRequest"),
|
|
11163
|
+
"assets.list": node("read"),
|
|
11164
|
+
"assets.get": node("read"),
|
|
11165
|
+
"assets.download": node("read"),
|
|
11166
|
+
"assets.readTextLines": node("read"),
|
|
11167
|
+
"install.planFromGithub": workspace("read"),
|
|
11168
|
+
"install.fromGithub": workspace("manage"),
|
|
11169
|
+
"templates.list": workspace("read"),
|
|
11170
|
+
"guides.list": workspace("read"),
|
|
11171
|
+
"guides.read": workspace("read"),
|
|
11172
|
+
"agents.catalog": workspace("manage"),
|
|
11173
|
+
"agents.connections.list": workspace("manage"),
|
|
11174
|
+
"agents.disconnect": workspace("manage"),
|
|
11175
|
+
"agents.sessions.list": workspace("manage"),
|
|
11176
|
+
"agents.sessions.create": workspace("manage"),
|
|
11177
|
+
"agents.sessions.prompt": workspace("manage"),
|
|
11178
|
+
"agents.sessions.cancel": workspace("manage"),
|
|
11179
|
+
"agents.sessions.close": workspace("manage"),
|
|
11180
|
+
"agents.sessions.subscribe": workspace("manage"),
|
|
11181
|
+
"agents.sessions.respondToPermission": workspace("manage"),
|
|
11182
|
+
"changeRequests.list": node("read"),
|
|
11183
|
+
"changeRequests.listPage": node("read"),
|
|
11184
|
+
"changeRequests.inboxSnapshot": node("read"),
|
|
11185
|
+
"changeRequests.counts": node("read"),
|
|
11186
|
+
"changeRequests.get": node("read"),
|
|
11187
|
+
"records.list": node("read"),
|
|
11188
|
+
"records.listPage": node("read"),
|
|
11189
|
+
"records.count": node("read"),
|
|
11190
|
+
"records.groupBy": node("read"),
|
|
11191
|
+
"records.get": node("read"),
|
|
11192
|
+
"records.search": node("read"),
|
|
11193
|
+
"records.listChangeRequests": node("read"),
|
|
11194
|
+
"records.listLinks": node("read")
|
|
11195
|
+
};
|
|
11196
|
+
/** Backward-compatible level-only export used by older tests/callers. */
|
|
11197
|
+
const PROCEDURE_LEVEL_OVERRIDES = Object.fromEntries(Object.entries(PROCEDURE_PERMISSION_POLICY).map(([path, policy]) => [path, policy.level]));
|
|
11198
|
+
/**
|
|
11199
|
+
* `workbenchProcedure.router(busabaseRouter)` is later spread into the
|
|
11200
|
+
* `workbench` key of the router the host actually serves, and oRPC resolves
|
|
11201
|
+
* `path` against the FULL served router tree, not the sub-router the
|
|
11202
|
+
* middleware was attached to — confirmed empirically against both
|
|
11203
|
+
* `OpenAPIHandler` and `RPCHandler` (the two handlers actually used in
|
|
11204
|
+
* production): a call to `workbench.bases.create` delivers
|
|
11205
|
+
* `path: ["workbench", "bases", "create"]` to the middleware, not
|
|
11206
|
+
* `["bases", "create"]` as originally assumed. Strip that one known mount-key
|
|
11207
|
+
* segment; leave any other shape untouched (e.g. a future direct mount, or a
|
|
11208
|
+
* unit test that passes a bare `busabaseRouter`-relative path).
|
|
11209
|
+
*/
|
|
11210
|
+
const normalizePath = (path) => path[0] === "workbench" ? path.slice(1) : [...path];
|
|
11211
|
+
/**
|
|
11212
|
+
* Backward-compatible level-only resolver. HTTP method is deliberately ignored:
|
|
11213
|
+
* known paths use the explicit semantic policy and unknown paths fail closed to
|
|
11214
|
+
* manage regardless of whether they happen to be GET, POST, or RPC-only.
|
|
11215
|
+
*/
|
|
11216
|
+
function resolveRequiredLevel(path, _routeMethod) {
|
|
11217
|
+
const key = normalizePath(path).join(".");
|
|
11218
|
+
const override = PROCEDURE_LEVEL_OVERRIDES[key];
|
|
11219
|
+
if (override) return override;
|
|
11220
|
+
return "manage";
|
|
11221
|
+
}
|
|
11222
|
+
//#endregion
|
|
11223
|
+
//#region src/schema-command.ts
|
|
11224
|
+
/**
|
|
11225
|
+
* The level an endpoint needs, from the contract's own policy table — the same
|
|
11226
|
+
* table the server enforces with, so this can never disagree with a real 403.
|
|
11227
|
+
*
|
|
11228
|
+
* A handful of endpoints have no explicit entry and the policy fails closed to
|
|
11229
|
+
* `manage`. That is the right answer for the gate, but a *guess* as far as advice
|
|
11230
|
+
* goes, so callers that phrase it for a human should know which it is.
|
|
11231
|
+
*/
|
|
11232
|
+
function requiredLevelFor(id) {
|
|
11233
|
+
return {
|
|
11234
|
+
level: resolveRequiredLevel(id.split("."), void 0),
|
|
11235
|
+
declared: id in PROCEDURE_PERMISSION_POLICY
|
|
11236
|
+
};
|
|
11237
|
+
}
|
|
11238
|
+
/** Zod → JSON Schema. `io` matters: defaults/coercion make input and output differ. */
|
|
11239
|
+
function toJsonSchema(schema, io) {
|
|
11240
|
+
if (schema === void 0) return void 0;
|
|
11241
|
+
try {
|
|
11242
|
+
return z.toJSONSchema(schema, {
|
|
11243
|
+
io,
|
|
11244
|
+
unrepresentable: "any"
|
|
11245
|
+
});
|
|
11246
|
+
} catch {
|
|
11247
|
+
return;
|
|
11248
|
+
}
|
|
11249
|
+
}
|
|
11250
|
+
const describe = (entry) => {
|
|
11251
|
+
const { route } = entry.proc["~orpc"];
|
|
11252
|
+
return {
|
|
11253
|
+
id: entry.id,
|
|
11254
|
+
method: route.method,
|
|
11255
|
+
path: route.path,
|
|
11256
|
+
...route.summary ? { summary: route.summary } : {},
|
|
11257
|
+
...route.successDescription ? { description: route.successDescription } : {},
|
|
11258
|
+
command: `busabase-cli ${generatedCommandPath(entry.navPath, entry.key).join(" ")}`,
|
|
11259
|
+
requiredLevel: requiredLevelFor(entry.id).level
|
|
11260
|
+
};
|
|
11261
|
+
};
|
|
11262
|
+
/** Every endpoint the contract carries, in contract order. */
|
|
11263
|
+
function listEndpoints() {
|
|
11264
|
+
return [...contractProcedures()].map(describe);
|
|
11265
|
+
}
|
|
11266
|
+
/**
|
|
11267
|
+
* Resolve a user-typed id. Accepts the dotted contract path (`bases.list`), the
|
|
11268
|
+
* space-separated form (`bases list`, already joined by the caller), and the
|
|
11269
|
+
* kebab spelling the command tree uses (`change-requests.list`), so an agent can
|
|
11270
|
+
* paste back whatever it saw without translating.
|
|
11271
|
+
*/
|
|
11272
|
+
function findEntry(query) {
|
|
11273
|
+
const normalize = (value) => value.replace(/[\s/]+/g, ".").replace(/-/g, "").toLowerCase();
|
|
11274
|
+
const wanted = normalize(query);
|
|
11275
|
+
const entries = [...contractProcedures()];
|
|
11276
|
+
return entries.find((entry) => entry.id === query) ?? entries.find((entry) => normalize(entry.id) === wanted);
|
|
11277
|
+
}
|
|
11278
|
+
/** Ids closest to a miss, so the error can suggest instead of just refusing. */
|
|
11279
|
+
function suggestions(query, limit = 5) {
|
|
11280
|
+
const needle = query.replace(/[\s./-]+/g, "").toLowerCase();
|
|
11281
|
+
if (!needle) return [];
|
|
11282
|
+
const scored = [];
|
|
11283
|
+
for (const entry of contractProcedures()) {
|
|
11284
|
+
const hay = entry.id.replace(/[.-]/g, "").toLowerCase();
|
|
11285
|
+
if (hay.includes(needle)) scored.push({
|
|
11286
|
+
id: entry.id,
|
|
11287
|
+
score: 0
|
|
11288
|
+
});
|
|
11289
|
+
else if (needle.length >= 3 && hay.includes(needle.slice(0, 3))) scored.push({
|
|
11290
|
+
id: entry.id,
|
|
11291
|
+
score: 1
|
|
11292
|
+
});
|
|
11293
|
+
}
|
|
11294
|
+
return scored.sort((a, b) => a.score - b.score || a.id.length - b.id.length).slice(0, limit).map((s) => s.id);
|
|
11295
|
+
}
|
|
11296
|
+
/**
|
|
11297
|
+
* A misspelled id is a usage error, not a runtime failure: it exits `2` like any
|
|
11298
|
+
* other bad argument, so a script can tell "you typed it wrong" from "the call failed".
|
|
11299
|
+
*/
|
|
11300
|
+
var SchemaNotFoundError = class extends CliOutcomeError {
|
|
11301
|
+
constructor(query, near) {
|
|
11302
|
+
super("USAGE", near.length ? `Unknown endpoint "${query}". Did you mean: ${near.join(", ")}? Run \`busabase-cli schema\` for the full list.` : `Unknown endpoint "${query}". Run \`busabase-cli schema\` for the full list.`);
|
|
11303
|
+
this.name = "SchemaNotFoundError";
|
|
11304
|
+
}
|
|
11305
|
+
};
|
|
11306
|
+
/** One endpoint, with the halves the caller asked for. */
|
|
11307
|
+
function describeEndpoint(query, io) {
|
|
11308
|
+
const entry = findEntry(query);
|
|
11309
|
+
if (!entry) throw new SchemaNotFoundError(query, suggestions(query));
|
|
11310
|
+
const { inputSchema, outputSchema } = entry.proc["~orpc"];
|
|
11311
|
+
return {
|
|
11312
|
+
...describe(entry),
|
|
11313
|
+
...io === "output" ? {} : { input: toJsonSchema(inputSchema, "input") },
|
|
11314
|
+
...io === "input" ? {} : { output: toJsonSchema(outputSchema, "output") }
|
|
11315
|
+
};
|
|
11316
|
+
}
|
|
11317
|
+
/** Human-readable rendering for a terminal; `--output json` prints the object instead. */
|
|
11318
|
+
function renderEndpoint(endpoint) {
|
|
11319
|
+
const lines = [
|
|
11320
|
+
`${endpoint.id}`,
|
|
11321
|
+
` ${endpoint.method} ${endpoint.path}`,
|
|
11322
|
+
...endpoint.summary ? [` ${endpoint.summary}`] : [],
|
|
11323
|
+
...endpoint.description ? [` ${endpoint.description}`] : [],
|
|
11324
|
+
` command: ${endpoint.command}`,
|
|
11325
|
+
` requires API key level: ${endpoint.requiredLevel}`
|
|
11326
|
+
];
|
|
11327
|
+
if (endpoint.input !== void 0) lines.push("", "input:", JSON.stringify(endpoint.input, null, 2));
|
|
11328
|
+
if (endpoint.output !== void 0) lines.push("", "output:", JSON.stringify(endpoint.output, null, 2));
|
|
11329
|
+
return lines.join("\n");
|
|
11330
|
+
}
|
|
11331
|
+
/** The endpoint index, one line each — the map before you pick a target. */
|
|
11332
|
+
function renderList(entries) {
|
|
11333
|
+
const width = Math.max(...entries.map((e) => e.id.length));
|
|
11334
|
+
return [
|
|
11335
|
+
...entries.map((e) => `${e.id.padEnd(width)} ${e.method.padEnd(6)} ${e.path}${e.summary ? ` — ${e.summary}` : ""}`),
|
|
11336
|
+
"",
|
|
11337
|
+
`${entries.length} endpoints. \`busabase-cli schema <id>\` prints one endpoint's input and output schema.`
|
|
11338
|
+
].join("\n");
|
|
11339
|
+
}
|
|
11340
|
+
/** Exported for the help text, so the example ids can never name a dead endpoint. */
|
|
11341
|
+
const sampleEndpointId = () => findEntry("bases.list")?.id ?? [...contractProcedures()][0]?.id ?? "bases.list";
|
|
11342
|
+
//#endregion
|
|
10703
11343
|
//#region src/task-command.ts
|
|
10704
11344
|
const collect = (value, previous = []) => [...previous, value];
|
|
10705
11345
|
/** Range-checks before the request goes out, so a cap surfaces as a usage error. */
|
|
@@ -10794,6 +11434,7 @@ const registerOne = (program, task, deps, path, preset = {}, summaryOverride) =>
|
|
|
10794
11434
|
}
|
|
10795
11435
|
applyParam(cmd, param);
|
|
10796
11436
|
}
|
|
11437
|
+
if (!task.annotations.readOnly) cmd.option("--dry-run", "print the request this would send and send nothing");
|
|
10797
11438
|
if (task.guidance) cmd.addHelpText("after", `\n${wrapGuidance(rewriteTaskNames(task.guidance))}`);
|
|
10798
11439
|
if (task.examples && task.examples.length > 0 && Object.keys(preset).length === 0) cmd.addHelpText("after", `\nExamples:\n ${task.examples.join("\n ")}`);
|
|
10799
11440
|
cmd.action(deps.runAction(async (client, opts) => task.execute(client, {
|
|
@@ -11062,10 +11703,10 @@ function parseJsonValue(raw, flagName) {
|
|
|
11062
11703
|
* Render a validation error's per-issue detail as `path: message`.
|
|
11063
11704
|
*
|
|
11064
11705
|
* Load-bearing, not cosmetic: a schema that refuses a field explains WHY in the
|
|
11065
|
-
* issue message (e.g. "
|
|
11066
|
-
*
|
|
11067
|
-
*
|
|
11068
|
-
*
|
|
11706
|
+
* issue message (e.g. "deleteMode: Invalid option: expected \"archive\"" — hard
|
|
11707
|
+
* delete after retention was never implemented). Without this the caller sees
|
|
11708
|
+
* only the generic envelope "Input validation failed" and is no better off than
|
|
11709
|
+
* when the field was silently dropped.
|
|
11069
11710
|
*/
|
|
11070
11711
|
function formatIssues(issues) {
|
|
11071
11712
|
const parsed = toIssues(issues);
|
|
@@ -11222,14 +11863,28 @@ function runAction(state, handler) {
|
|
|
11222
11863
|
if (refreshedToken) config.apiKey = refreshedToken;
|
|
11223
11864
|
state.config = config;
|
|
11224
11865
|
assertCredentialNotExpired(config.apiKey);
|
|
11866
|
+
const dryRun = Boolean(opts.dryRun);
|
|
11867
|
+
config.fetch = observeRequests(config.fetch ?? fetch, (seen) => {
|
|
11868
|
+
state.lastRequest = seen;
|
|
11869
|
+
});
|
|
11870
|
+
if (dryRun) config.fetch = dryRunFetch(config.fetch);
|
|
11225
11871
|
const client = createBusabaseClient(config);
|
|
11226
|
-
|
|
11227
|
-
|
|
11228
|
-
|
|
11872
|
+
try {
|
|
11873
|
+
if (opts.all) {
|
|
11874
|
+
await streamAllPages(client, opts, config, handler);
|
|
11875
|
+
return;
|
|
11876
|
+
}
|
|
11877
|
+
const result = await handler(client, opts, config);
|
|
11878
|
+
console.log(render(opts.slim ? slim(result) : result, config.output));
|
|
11879
|
+
if (dryRun) console.error(DRY_RUN_NO_WRITE_NOTE);
|
|
11880
|
+
if (opts.wait) await awaitReview(client, opts, config, result);
|
|
11881
|
+
} catch (error) {
|
|
11882
|
+
if (!(error instanceof DryRunInterception)) throw error;
|
|
11883
|
+
console.log(config.output === "json" ? render({
|
|
11884
|
+
dryRun: true,
|
|
11885
|
+
wouldSend: error.plan
|
|
11886
|
+
}, config.output) : renderDryRunPlan(error.plan));
|
|
11229
11887
|
}
|
|
11230
|
-
const result = await handler(client, opts, config);
|
|
11231
|
-
console.log(render(opts.slim ? slim(result) : result, config.output));
|
|
11232
|
-
if (opts.wait) await awaitReview(client, opts, config, result);
|
|
11233
11888
|
};
|
|
11234
11889
|
}
|
|
11235
11890
|
/** Default `--wait` budget: long enough for a reviewer who is at their desk. */
|
|
@@ -11316,9 +11971,6 @@ function pkgVersion() {
|
|
|
11316
11971
|
return "0.0.0";
|
|
11317
11972
|
}
|
|
11318
11973
|
}
|
|
11319
|
-
/** camelCase → kebab-case for flag and command names (createChangeRequest → create-change-request). */
|
|
11320
|
-
const kebab = (value) => value.replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase();
|
|
11321
|
-
const isProcedure = (node) => typeof node === "object" && node !== null && typeof node["~orpc"]?.route?.method === "string";
|
|
11322
11974
|
/** Peel ZodOptional/ZodDefault/ZodNullable wrappers; a default or optional wrapper means not-required. */
|
|
11323
11975
|
function unwrapSchema(schema) {
|
|
11324
11976
|
let inner = schema;
|
|
@@ -11474,6 +12126,12 @@ const GENERATED_SKIP = /* @__PURE__ */ new Set([
|
|
|
11474
12126
|
"fileTrees.readFile",
|
|
11475
12127
|
"fileTrees.createChangeRequest"
|
|
11476
12128
|
]);
|
|
12129
|
+
/** Routes that change nothing — they run for real under `--dry-run`, so no flag is offered. */
|
|
12130
|
+
const READ_ROUTE_METHODS = /* @__PURE__ */ new Set([
|
|
12131
|
+
"GET",
|
|
12132
|
+
"HEAD",
|
|
12133
|
+
"OPTIONS"
|
|
12134
|
+
]);
|
|
11477
12135
|
/** Walk `cloudContract` and register a leaf command per procedure not already covered by hand. */
|
|
11478
12136
|
function registerGeneratedCommands(program, state) {
|
|
11479
12137
|
const getGroup = (displayName, tag) => program.commands.find((c) => c.name() === displayName) ?? program.command(displayName).description(tag ?? `${displayName} endpoints`);
|
|
@@ -11524,8 +12182,11 @@ function registerGeneratedCommands(program, state) {
|
|
|
11524
12182
|
}
|
|
11525
12183
|
}
|
|
11526
12184
|
addGlobalFlags(leaf);
|
|
11527
|
-
leaf.
|
|
12185
|
+
if (!READ_ROUTE_METHODS.has(route.method.toUpperCase())) leaf.option("--dry-run", "print the request this would send and send nothing");
|
|
12186
|
+
const procedureId = [...navPath, procKey].join(".");
|
|
12187
|
+
leaf.addHelpText("after", `\nOpenAPI: ${route.method} ${route.path}\nRequires API key level: ${requiredLevelFor(procedureId).level}`);
|
|
11528
12188
|
leaf.action(runAction(state, (client, opts) => {
|
|
12189
|
+
state.procedureId = procedureId;
|
|
11529
12190
|
let input = {};
|
|
11530
12191
|
for (const f of fields) {
|
|
11531
12192
|
if (f.pinned !== void 0) {
|
|
@@ -11544,19 +12205,10 @@ function registerGeneratedCommands(program, state) {
|
|
|
11544
12205
|
return fields.length > 0 ? target[procKey](input) : target[procKey]();
|
|
11545
12206
|
}));
|
|
11546
12207
|
};
|
|
11547
|
-
const
|
|
11548
|
-
|
|
11549
|
-
|
|
11550
|
-
|
|
11551
|
-
const id = [...navPath, key].join(".");
|
|
11552
|
-
if (isProcedure(child)) {
|
|
11553
|
-
if (GENERATED_SKIP.has(id)) continue;
|
|
11554
|
-
const parent = navPath.length ? getGroup(kebab(navPath[0]), child["~orpc"].route.tags?.[0]) : program;
|
|
11555
|
-
addLeaf(parent, navPath, key, child);
|
|
11556
|
-
} else if (child && typeof child === "object") walk(child, [...navPath, key]);
|
|
11557
|
-
}
|
|
11558
|
-
};
|
|
11559
|
-
walk(cloudContract, []);
|
|
12208
|
+
for (const { id, navPath, key, proc } of contractProcedures()) {
|
|
12209
|
+
if (GENERATED_SKIP.has(id)) continue;
|
|
12210
|
+
addLeaf(navPath.length ? getGroup(kebab(navPath[0]), proc["~orpc"].route.tags?.[0]) : program, navPath, key, proc);
|
|
12211
|
+
}
|
|
11560
12212
|
}
|
|
11561
12213
|
/**
|
|
11562
12214
|
* Per-command flag signature derived from the actual option definitions, e.g.
|
|
@@ -11628,14 +12280,42 @@ function buildProgram(state = {}) {
|
|
|
11628
12280
|
addGlobalFlags(program);
|
|
11629
12281
|
addGlobalFlags(program.command("health")).description("Server health check (GET /api/health)").action(runAction(state, (_client, _opts, config) => rawFetch(config, "GET", "/api/health")));
|
|
11630
12282
|
addGlobalFlags(program.command("openapi")).description("Fetch the OpenAPI document").action(runAction(state, (_client, _opts, config) => rawFetch(config, "GET", "/api/v1/openapi.json")));
|
|
12283
|
+
addGlobalFlags(program.command("schema")).argument("[endpoint...]", "contract id, e.g. `bases.list` or `bases list`; omit to list all").description("Print one endpoint's input/output schema — offline, no credential").addOption(new Option("--io <half>", "print only one half of the schema").choices(["input", "output"])).addHelpText("after", `
|
|
12284
|
+
Answers "what do I send, and what comes back" without fetching anything. The
|
|
12285
|
+
contract is compiled into this CLI, so \`schema\` works offline and unauthenticated.
|
|
12286
|
+
|
|
12287
|
+
Prefer it over \`busabase-cli openapi\`: that returns the WHOLE document — 936 KB
|
|
12288
|
+
across 103 endpoints as of 2026-09-04, with every type inlined per endpoint — so
|
|
12289
|
+
reading one endpoint's arguments out of it costs roughly 234k tokens. One
|
|
12290
|
+
endpoint through \`schema\` is a few KB.
|
|
12291
|
+
|
|
12292
|
+
busabase-cli schema # every endpoint: id, method, path
|
|
12293
|
+
busabase-cli schema ${sampleEndpointId()} # input + output schema
|
|
12294
|
+
busabase-cli schema records.changeRequest --io input # just what to send
|
|
12295
|
+
busabase-cli schema records list --output json # machine-readable
|
|
12296
|
+
|
|
12297
|
+
Ids are the contract path. \`schema\` also accepts the space-separated and kebab
|
|
12298
|
+
spellings, so you can paste back whatever you saw.`).action((endpoint, _opts, cmd) => {
|
|
12299
|
+
const opts = cmd.optsWithGlobals();
|
|
12300
|
+
const config = resolveConfig(opts);
|
|
12301
|
+
state.config = config;
|
|
12302
|
+
const io = opts.io;
|
|
12303
|
+
if (!endpoint || endpoint.length === 0) {
|
|
12304
|
+
const entries = listEndpoints();
|
|
12305
|
+
console.log(config.output === "json" ? render(entries, config.output) : renderList(entries));
|
|
12306
|
+
return;
|
|
12307
|
+
}
|
|
12308
|
+
const described = describeEndpoint(endpoint.join("."), io);
|
|
12309
|
+
console.log(config.output === "json" ? render(described, config.output) : renderEndpoint(described));
|
|
12310
|
+
});
|
|
11631
12311
|
addGlobalFlags(program.command("whoami")).description("Active space, user, and membership").action(runAction(state, (client) => client.auth.verify()));
|
|
11632
|
-
addGlobalFlags(program.command("guide")).argument("[topic]", "which guide to read; omit to list the topics this server serves").description("Read the Busabase operating manual (
|
|
12312
|
+
addGlobalFlags(program.command("guide")).argument("[topic]", "which guide to read; omit to list the topics this server serves").description("Read the Busabase operating manual (workspace conventions, AirApp contract)").addHelpText("after", `
|
|
11633
12313
|
Read this BEFORE unfamiliar work. It carries the conventions this workspace
|
|
11634
12314
|
expects, which no flag or schema can express on its own.
|
|
11635
12315
|
|
|
11636
12316
|
Examples:
|
|
11637
12317
|
busabase-cli guide # what is available here
|
|
11638
|
-
busabase-cli guide workspace # the
|
|
12318
|
+
busabase-cli guide workspace # the change-request workflow
|
|
11639
12319
|
busabase-cli guide airapp # REQUIRED before writing any AirApp file`).action(async (topic, _opts, cmd) => {
|
|
11640
12320
|
const config = resolveConfig(cmd.optsWithGlobals());
|
|
11641
12321
|
state.config = config;
|
|
@@ -11652,9 +12332,9 @@ Examples:
|
|
|
11652
12332
|
console.log(guide.content);
|
|
11653
12333
|
console.error(`\n[busabase-cli] other topics: ${guide.otherTopics.join(", ") || "(none)"}`);
|
|
11654
12334
|
});
|
|
11655
|
-
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", `
|
|
11656
|
-
Busabase is
|
|
11657
|
-
|
|
12335
|
+
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)").option("--no-wait", "start a device sign-in and return verification_url + resume_code at once (for agents)").option("--resume-code <code>", "finish a device sign-in started earlier with --no-wait").addHelpText("after", `
|
|
12336
|
+
Busabase is your AI agents' database, knowledge base, apps library, and skills registry.
|
|
12337
|
+
Every change goes in as a change request, so it keeps a message, a diff, and a history.
|
|
11658
12338
|
|
|
11659
12339
|
Run interactively, login explains the connection choices and writes the selected
|
|
11660
12340
|
Busabase instance to ~/.busabase/.env:
|
|
@@ -11672,10 +12352,20 @@ The flags below skip the menu (handy for scripts / CI):
|
|
|
11672
12352
|
busabase-cli login --base-url http://localhost:15419 # connect to a local server (no auth)
|
|
11673
12353
|
busabase-cli login --refresh # rotate the OAuth token set (auto-runs too)
|
|
11674
12354
|
busabase-cli login --profile work # add a SECOND account, then switch to it
|
|
12355
|
+
busabase-cli login --no-wait --output json # agent: URL + resume code, returns at once
|
|
12356
|
+
busabase-cli login --resume-code <code> # agent: finish after the user authorized
|
|
11675
12357
|
|
|
11676
12358
|
Login never blocks on a Space question, interactive or not: with more than one Space it always
|
|
11677
12359
|
accepts the server-resolved default (the one you were most recently active in) and prints the
|
|
11678
|
-
full list — switch anytime with \`busabase-cli space use <id
|
|
12360
|
+
full list — switch anytime with \`busabase-cli space use <id>\`.
|
|
12361
|
+
|
|
12362
|
+
For AI agents: the blocking flows above print the verification URL mid-command, and
|
|
12363
|
+
a harness that only relays a turn's final message will drop it — the user never
|
|
12364
|
+
sees the URL and login silently times out. Use the two-turn flow instead: run
|
|
12365
|
+
\`--no-wait --output json\` now, send verification_url to the user as your final
|
|
12366
|
+
message (verbatim — never re-encode or re-wrap it; \`busabase-cli qrcode\` renders
|
|
12367
|
+
it scannable), end the turn, then run \`--resume-code <code>\` after they confirm.
|
|
12368
|
+
Do not restart login after showing the URL — a restart invalidates it.`).action(async (_opts, cmd) => {
|
|
11679
12369
|
const opts = cmd.optsWithGlobals();
|
|
11680
12370
|
const config = resolveConfig(opts);
|
|
11681
12371
|
state.config = config;
|
|
@@ -11688,7 +12378,10 @@ full list — switch anytime with \`busabase-cli space use <id>\`.`).action(asyn
|
|
|
11688
12378
|
spaceId: config.spaceId,
|
|
11689
12379
|
deviceCode: Boolean(opts.deviceCode),
|
|
11690
12380
|
oauth: Boolean(opts.oauth),
|
|
11691
|
-
browser: opts.browser !== false
|
|
12381
|
+
browser: opts.browser !== false,
|
|
12382
|
+
noWait: opts.wait === false,
|
|
12383
|
+
resumeCode: opts.resumeCode,
|
|
12384
|
+
profile: opts.profile
|
|
11692
12385
|
});
|
|
11693
12386
|
const profile = opts.profile;
|
|
11694
12387
|
if (profile && !opts.refresh) activateLoggedInProfile(profile);
|
|
@@ -11703,6 +12396,33 @@ full list — switch anytime with \`busabase-cli space use <id>\`.`).action(asyn
|
|
|
11703
12396
|
});
|
|
11704
12397
|
console.log(render(summary, config.output));
|
|
11705
12398
|
});
|
|
12399
|
+
addGlobalFlags(program.command("qrcode")).description("Render a URL (e.g. a login verification URL) as a QR code").argument("<url>", "URL to encode — treated as an opaque string, byte-for-byte").option("-o, --out-file <path>", "write a PNG here (under cwd, the temp dir, or your home)").option("--ascii", "print a terminal QR code to stdout instead of writing a PNG").option("--size <px>", `PNG size in pixels (64-${QR_MAX_SIZE}, default 256)`, parsePositiveInt).addHelpText("after", `
|
|
12400
|
+
Made for the two-turn login: \`login --no-wait\` returns a verification_url the
|
|
12401
|
+
user often approves on a phone — a QR code removes the copy-a-long-URL step.
|
|
12402
|
+
|
|
12403
|
+
busabase-cli qrcode "<verification_url>" --out-file qr.png
|
|
12404
|
+
busabase-cli qrcode "<verification_url>" --ascii
|
|
12405
|
+
|
|
12406
|
+
Forward URLs verbatim: never re-encode, trim, or re-wrap a verification URL —
|
|
12407
|
+
a reworded URL is a 404 on the user's phone.`).action(async (url, _opts, cmd) => {
|
|
12408
|
+
const opts = cmd.optsWithGlobals();
|
|
12409
|
+
const config = resolveConfig(opts);
|
|
12410
|
+
state.config = config;
|
|
12411
|
+
if (opts.ascii) {
|
|
12412
|
+
console.log(await renderQrAscii(url));
|
|
12413
|
+
return;
|
|
12414
|
+
}
|
|
12415
|
+
const outFile = opts.outFile;
|
|
12416
|
+
if (!outFile) throw new Error("Pass --out-file <path> for a PNG, or --ascii for terminal output.");
|
|
12417
|
+
const filePath = safeQrOutputPath(outFile);
|
|
12418
|
+
await writeQrPng(url, filePath, opts.size ?? 256);
|
|
12419
|
+
console.log(render({
|
|
12420
|
+
status: "qr code written",
|
|
12421
|
+
filePath,
|
|
12422
|
+
url,
|
|
12423
|
+
hint: QR_DISPLAY_HINT
|
|
12424
|
+
}, config.output));
|
|
12425
|
+
});
|
|
11706
12426
|
const auth = program.command("auth").description("Accounts (profiles) and CLI settings");
|
|
11707
12427
|
const authStatus = (cmd) => cmd.action(async (_opts, sub) => {
|
|
11708
12428
|
const config = resolveConfig(sub.optsWithGlobals());
|
|
@@ -11767,11 +12487,11 @@ than as a 403 later. The choice is stored in the active profile and mirrored to
|
|
|
11767
12487
|
});
|
|
11768
12488
|
const nodes = program.command("nodes").description("Workspace node tree");
|
|
11769
12489
|
addGlobalFlags(nodes.command("list")).description("Workspace node tree").action(runAction(state, (client) => client.nodes.list()));
|
|
11770
|
-
addGlobalFlags(nodes.command("purge")).description("Permanently delete an ALREADY-archived node and its subtree (irreversible). Archive it first with `nodes archive` if it isn't archived yet.").requiredOption("--node-id <id>", "archived node id to purge").action(runAction(state, (client, opts) => client.nodes.purge({ nodeId: opts.nodeId })));
|
|
12490
|
+
addGlobalFlags(nodes.command("purge")).description("Permanently delete an ALREADY-archived node and its subtree (irreversible). Archive it first with `nodes archive` if it isn't archived yet.").requiredOption("--node-id <id>", "archived node id to purge").option("--dry-run", "print the request this would send and send nothing").action(runAction(state, (client, opts) => client.nodes.purge({ nodeId: opts.nodeId })));
|
|
11771
12491
|
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("--position <n>", "new position among the target parent's children", (v) => Number.parseInt(v, 10)).option("--message <text>", "Change Request message").option("--submitted-by <name>", "producer label").addHelpText("after", `
|
|
11772
12492
|
Examples:
|
|
11773
12493
|
busabase-cli nodes move --node-id nod_123 --position 0
|
|
11774
|
-
busabase-cli nodes move --node-id nod_123 --parent-node-id nod_456 --position 2`).action(runAction(state, (client, opts) => client.nodes.move({
|
|
12494
|
+
busabase-cli nodes move --node-id nod_123 --parent-node-id nod_456 --position 2`).option("--dry-run", "print the request this would send and send nothing").action(runAction(state, (client, opts) => client.nodes.move({
|
|
11775
12495
|
nodeId: opts.nodeId,
|
|
11776
12496
|
parentNodeId: opts.parentNodeId,
|
|
11777
12497
|
position: opts.position,
|
|
@@ -11793,7 +12513,7 @@ locale) is reported entry-by-entry and nothing is written. \`--file\`'s
|
|
|
11793
12513
|
contents ARE the array to store; this command does not reshape it.
|
|
11794
12514
|
|
|
11795
12515
|
Examples:
|
|
11796
|
-
busabase-cli nodes set-agent-prompts --node-id nod_123 --file prompts.json`).action(runAction(state, async (client, opts) => {
|
|
12516
|
+
busabase-cli nodes set-agent-prompts --node-id nod_123 --file prompts.json`).option("--dry-run", "print the request this would send and send nothing").action(runAction(state, async (client, opts) => {
|
|
11797
12517
|
const filePath = opts.file;
|
|
11798
12518
|
let raw;
|
|
11799
12519
|
try {
|
|
@@ -11828,7 +12548,7 @@ Examples:
|
|
|
11828
12548
|
if (!found) throw new Error(`no Base with slug "${slug}"`);
|
|
11829
12549
|
return found;
|
|
11830
12550
|
}));
|
|
11831
|
-
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("--allowed-mime <mime>", "attachment option: allowed MIME type, repeatable", collectValues).action(runAction(state, (client, opts) => client.bases.createField({
|
|
12551
|
+
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("--allowed-mime <mime>", "attachment option: allowed MIME type, repeatable", collectValues).option("--dry-run", "print the request this would send and send nothing").action(runAction(state, (client, opts) => client.bases.createField({
|
|
11832
12552
|
baseId: opts.baseId,
|
|
11833
12553
|
slug: opts.slug,
|
|
11834
12554
|
name: opts.name,
|
|
@@ -11839,7 +12559,7 @@ Examples:
|
|
|
11839
12559
|
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("--allowed-mime <mime>", "attachment option: allowed MIME type, repeatable", collectValues).option("--message <text>", "reviewer-facing Change Request message").option("--submitted-by <name>", "producer label").addHelpText("after", `
|
|
11840
12560
|
Examples:
|
|
11841
12561
|
busabase-cli bases update-field-change-request --base-id bse_123 --field-id bsf_123 --name "封面 Cover Image" --max-files 1 --allowed-mime image/png --allowed-mime image/svg+xml
|
|
11842
|
-
busabase-cli bases update-field-change-request --base-id bse_123 --field-id bsf_123 --options-json @field-options.json`).action(runAction(state, (client, opts) => {
|
|
12562
|
+
busabase-cli bases update-field-change-request --base-id bse_123 --field-id bsf_123 --options-json @field-options.json`).option("--dry-run", "print the request this would send and send nothing").action(runAction(state, (client, opts) => {
|
|
11843
12563
|
const patch = {};
|
|
11844
12564
|
if (opts.name) patch.name = opts.name;
|
|
11845
12565
|
if (opts.required !== void 0) patch.required = opts.required;
|
|
@@ -11861,7 +12581,7 @@ Examples:
|
|
|
11861
12581
|
busabase-cli bases create-change-request --base-id bse_123 --fields-json '{"title":"Hello","status":"draft"}'
|
|
11862
12582
|
busabase-cli bases create-change-request --base-id bse_123 --fields-json @record.json
|
|
11863
12583
|
busabase-cli bases create-change-request --base-id bse_123 --fields-json '{"title":"Hello"}' --auto-merge # skip review, create immediately
|
|
11864
|
-
busabase-cli bases create-change-request --base-id bse_123 --fields-json '{"title":"Hello"}' --require-review # always propose a Change Request`).action(runAction(state, (client, opts) => client.bases.createChangeRequest({
|
|
12584
|
+
busabase-cli bases create-change-request --base-id bse_123 --fields-json '{"title":"Hello"}' --require-review # always propose a Change Request`).option("--dry-run", "print the request this would send and send nothing").action(runAction(state, (client, opts) => client.bases.createChangeRequest({
|
|
11865
12585
|
baseId: opts.baseId,
|
|
11866
12586
|
fields: parseJsonValue(opts.fieldsJson, "fields-json"),
|
|
11867
12587
|
message: opts.message,
|
|
@@ -11919,7 +12639,7 @@ Examples:
|
|
|
11919
12639
|
Example:
|
|
11920
12640
|
busabase-cli assets upload --file ./cover.png --output json
|
|
11921
12641
|
|
|
11922
|
-
Use the JSON output directly in an attachment field value, e.g. {"cover_image":[<output>]}.`).action(runAction(state, (_client, opts, config) => uploadAsset(config, opts)));
|
|
12642
|
+
Use the JSON output directly in an attachment field value, e.g. {"cover_image":[<output>]}.`).option("--dry-run", "print the request this would send and send nothing").action(runAction(state, (_client, opts, config) => uploadAsset(config, opts)));
|
|
11923
12643
|
const assetsCommand = program.command("assets").description("Assets");
|
|
11924
12644
|
addUploadCommand(assetsCommand);
|
|
11925
12645
|
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 (≤1MB)").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("after", `
|
|
@@ -12090,7 +12810,7 @@ Examples:
|
|
|
12090
12810
|
|
|
12091
12811
|
To add content to a space that is already in use, use \`busabase-cli install\` instead.
|
|
12092
12812
|
To copy a still-live space's current content into another space, use
|
|
12093
|
-
\`busabase-cli export\` + \`busabase-cli install\` instead of backup/restore.`).action(runArgAction(state, (file, client, opts, config) => runRestore(client, {
|
|
12813
|
+
\`busabase-cli export\` + \`busabase-cli install\` instead of backup/restore.`).option("--dry-run", "print the request this would send and send nothing").action(runArgAction(state, (file, client, opts, config) => runRestore(client, {
|
|
12094
12814
|
file,
|
|
12095
12815
|
resume: Boolean(opts.resume),
|
|
12096
12816
|
intoFolder: opts.intoFolder,
|
|
@@ -12101,7 +12821,7 @@ To copy a still-live space's current content into another space, use
|
|
|
12101
12821
|
"post",
|
|
12102
12822
|
"put",
|
|
12103
12823
|
"delete"
|
|
12104
|
-
]).makeOptionMandatory()).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(runAction(state, (_client, opts, config) => {
|
|
12824
|
+
]).makeOptionMandatory()).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").option("--dry-run", "print the request this would send and send nothing").action(runAction(state, (_client, opts, config) => {
|
|
12105
12825
|
const query = new URLSearchParams();
|
|
12106
12826
|
for (const pair of opts.query ?? []) {
|
|
12107
12827
|
const [key, ...rest] = pair.split("=");
|
|
@@ -12191,6 +12911,15 @@ async function runCli(argv) {
|
|
|
12191
12911
|
return EXIT_CODES.USAGE;
|
|
12192
12912
|
}
|
|
12193
12913
|
const classified = classifyError(error);
|
|
12914
|
+
if (classified.code === "UNAUTHORIZED" || classified.code === "FORBIDDEN") {
|
|
12915
|
+
const hintConfig = state.config ?? resolveConfig({});
|
|
12916
|
+
const profile = isMultiProfile() ? activeCredentialTarget().profile : void 0;
|
|
12917
|
+
const procedureId = state.procedureId ?? (state.lastRequest ? matchRoute(state.lastRequest.method, state.lastRequest.pathname)?.id : void 0);
|
|
12918
|
+
classified.hint = authRecoveryHint(classified.code, hintConfig.baseUrl, profile, {
|
|
12919
|
+
procedureId,
|
|
12920
|
+
...procedureId ? requiredLevelFor(procedureId) : {}
|
|
12921
|
+
});
|
|
12922
|
+
}
|
|
12194
12923
|
if (json) console.log(JSON.stringify(errorEnvelope(classified), null, 2));
|
|
12195
12924
|
console.error(explainError(error, state.config ?? resolveConfig({})));
|
|
12196
12925
|
return classified.exitCode;
|