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