busa-cli 0.18.0 → 0.19.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.js +1 -1
- package/dist/index.js +1 -1
- package/dist/{run-DD52HeGA.js → run--bA-jLSP.js} +2359 -475
- package/package.json +2 -2
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { createHash, randomBytes } from "node:crypto";
|
|
2
2
|
import { chmodSync, createReadStream, createWriteStream, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
-
import { basename, dirname, extname, join } from "node:path";
|
|
3
|
+
import { basename, dirname, extname, join, relative, sep } from "node:path";
|
|
4
4
|
import { z } from "zod";
|
|
5
5
|
import { DEFAULT_BASE_URL, cloudContract, createBusabaseClient, getRecordByField, grepAssets, normalizeBaseUrl } from "busabase-sdk";
|
|
6
6
|
import { Command, CommanderError, InvalidArgumentError, Option } from "commander";
|
|
@@ -12,9 +12,9 @@ import { ZstdCompress, ZstdDecompress } from "node:zlib";
|
|
|
12
12
|
import * as tar from "tar-stream";
|
|
13
13
|
import { spawn } from "node:child_process";
|
|
14
14
|
import { createServer } from "node:http";
|
|
15
|
-
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
16
|
-
import { Uint8ArrayReader, ZipReader } from "@zip.js/zip.js";
|
|
15
|
+
import { mkdir, readFile, readdir, rm, stat, writeFile } from "node:fs/promises";
|
|
17
16
|
import { parse, stringify } from "yaml";
|
|
17
|
+
import { Uint8ArrayReader, ZipReader } from "@zip.js/zip.js";
|
|
18
18
|
//#region ../../packages/busabase-contract/src/tasks/base-field.ts
|
|
19
19
|
/**
|
|
20
20
|
* Changing a Base's schema, as one task with an `operation` instead of six
|
|
@@ -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. */
|
|
@@ -3958,6 +4272,210 @@ async function runLogout(options) {
|
|
|
3958
4272
|
config: activeCredentialPath()
|
|
3959
4273
|
};
|
|
3960
4274
|
}
|
|
4275
|
+
//#endregion
|
|
4276
|
+
//#region ../../packages/busabase-contract/src/domains/package/template.ts
|
|
4277
|
+
/**
|
|
4278
|
+
* Template extension of the `busabase-package@1` format (pure zod, client-safe).
|
|
4279
|
+
*
|
|
4280
|
+
* A **template** is a package that is ALSO an Agent Skill: the same directory
|
|
4281
|
+
* carries a root `SKILL.md` (what an agent reads) next to `busabase.json` +
|
|
4282
|
+
* `content/` (what installs into a space). Nothing under `content/` changes —
|
|
4283
|
+
* the whole increment lives OUTSIDE it:
|
|
4284
|
+
*
|
|
4285
|
+
* my-app/
|
|
4286
|
+
* ├── SKILL.md ← root skill entry (+ references/ agents/ scripts/)
|
|
4287
|
+
* ├── busabase.json ← manifest, now with an optional `template` object
|
|
4288
|
+
* └── content/ ← byte-identical to a plain busabase-package@1 tree
|
|
4289
|
+
*
|
|
4290
|
+
* Consequences worth stating once, because they are what make the format
|
|
4291
|
+
* "multiplexed" rather than a second format:
|
|
4292
|
+
* - A template directory is already a VALID package. Today's `install` can
|
|
4293
|
+
* install it (it just won't land the Skill node, stamp ownership, or merge
|
|
4294
|
+
* the sample records).
|
|
4295
|
+
* - Any package becomes a template by adding a root `SKILL.md` plus the
|
|
4296
|
+
* `template` object — no migration, no v2.
|
|
4297
|
+
* - `export --template` emits a `content/` byte-identical to plain `export`.
|
|
4298
|
+
*
|
|
4299
|
+
* See `apps/busabase/content/spec/template-center.md` (§5, §6.2a, §13).
|
|
4300
|
+
*/
|
|
4301
|
+
/**
|
|
4302
|
+
* The root skill entry file. Deliberately the same name a `skill` NODE uses as
|
|
4303
|
+
* its entry file, because it becomes exactly that on install: the reader lifts
|
|
4304
|
+
* it (plus the sidecar dirs below) into a `skill` node inside the target
|
|
4305
|
+
* folder, so the manual travels with the resources instead of staying on the
|
|
4306
|
+
* publisher's disk.
|
|
4307
|
+
*/
|
|
4308
|
+
const PACKAGE_SKILL_ENTRY = "SKILL.md";
|
|
4309
|
+
/**
|
|
4310
|
+
* Directories carried into the Skill node alongside `SKILL.md`.
|
|
4311
|
+
*
|
|
4312
|
+
* `scripts/` is included on purpose even though Busabase never executes it: the
|
|
4313
|
+
* Skill node is a file tree, a shell agent that pulls the skill back out gets a
|
|
4314
|
+
* working copy, and dropping the scripts would make the round-trip lossy. The
|
|
4315
|
+
* safety property comes from *nothing in Busabase running them*, not from
|
|
4316
|
+
* refusing to store them.
|
|
4317
|
+
*/
|
|
4318
|
+
const PACKAGE_SKILL_SIDECAR_DIRS = [
|
|
4319
|
+
"references",
|
|
4320
|
+
"agents",
|
|
4321
|
+
"scripts"
|
|
4322
|
+
];
|
|
4323
|
+
/**
|
|
4324
|
+
* Per-AirApp ignore file (gitignore syntax), read at
|
|
4325
|
+
* `content/<airapp>/.busabaseignore`.
|
|
4326
|
+
*
|
|
4327
|
+
* An AirApp node IS the project — nodepod runs `npm run dev` against it — so the
|
|
4328
|
+
* package cannot simply carry "the deployable subset" as a separate copy without
|
|
4329
|
+
* reintroducing the two-sources-of-truth problem the template format exists to
|
|
4330
|
+
* remove. The ignore file lets one directory be both: the repo keeps `test/`,
|
|
4331
|
+
* lockfiles and coverage; the node receives only what has to run.
|
|
4332
|
+
*
|
|
4333
|
+
* `package.json` and the entry it declares can never be ignored — a validator
|
|
4334
|
+
* error rather than a warning, because the resulting AirApp would install fine
|
|
4335
|
+
* and then fail to boot, which is the failure mode hardest to attribute.
|
|
4336
|
+
*/
|
|
4337
|
+
const PACKAGE_AIRAPP_IGNORE = ".busabaseignore";
|
|
4338
|
+
const TemplateAirAppRefSchema = z.object({
|
|
4339
|
+
/** Slug of the `content/<dir>` holding the AirApp. */
|
|
4340
|
+
slug: z.string().min(1),
|
|
4341
|
+
role: z.enum([
|
|
4342
|
+
"primary",
|
|
4343
|
+
"admin",
|
|
4344
|
+
"public",
|
|
4345
|
+
"tool"
|
|
4346
|
+
]),
|
|
4347
|
+
label: z.string().optional()
|
|
4348
|
+
});
|
|
4349
|
+
/**
|
|
4350
|
+
* Secrets the app expects to find in the Vault.
|
|
4351
|
+
*
|
|
4352
|
+
* DECLARED, never created: the package format has no slot for secret values and
|
|
4353
|
+
* must not grow one (the same "you cannot leak what the format cannot express"
|
|
4354
|
+
* rule the whole format is built on). Install surfaces these as a post-install
|
|
4355
|
+
* prompt; the user fills them in the Vault themselves.
|
|
4356
|
+
*/
|
|
4357
|
+
const TemplateSecretSchema = z.object({
|
|
4358
|
+
key: z.string().min(1),
|
|
4359
|
+
description: z.string().default(""),
|
|
4360
|
+
required: z.boolean().default(true)
|
|
4361
|
+
});
|
|
4362
|
+
const TemplateManifestSchema = z.object({
|
|
4363
|
+
/** Template Center category, e.g. `"crm"`, `"email"`, `"content"`. */
|
|
4364
|
+
category: z.string().min(1),
|
|
4365
|
+
tags: z.array(z.string()).default([]),
|
|
4366
|
+
/** Card/detail screenshots, package-relative (`assets/screenshots/overview.webp`). */
|
|
4367
|
+
screenshots: z.array(z.string()).default([]),
|
|
4368
|
+
/**
|
|
4369
|
+
* Ready-made prompts shown after install ("Ask agent" prefills the first).
|
|
4370
|
+
*
|
|
4371
|
+
* They are the difference between a folder of tables and something a user can
|
|
4372
|
+
* *use*: the point of a template is that the agent already knows the job, and
|
|
4373
|
+
* these are how that is made visible rather than left for the user to guess.
|
|
4374
|
+
*/
|
|
4375
|
+
agentPrompts: z.array(z.string()).default([]),
|
|
4376
|
+
/** Single-AirApp shorthand. Mutually exclusive with `airapps`. */
|
|
4377
|
+
airapp: z.string().optional(),
|
|
4378
|
+
/** Multi-AirApp form. Exactly one entry must have `role: "primary"`. */
|
|
4379
|
+
airapps: z.array(TemplateAirAppRefSchema).optional(),
|
|
4380
|
+
/**
|
|
4381
|
+
* Bumped by the author when the declared resource shape changes.
|
|
4382
|
+
*
|
|
4383
|
+
* Part of the ownership stamp, so BOTH doors must agree on it: the installer
|
|
4384
|
+
* writes it, and a skill's own `setup.mjs` compares against it to decide
|
|
4385
|
+
* whether a node it finds is its own current shape or an older one to repair.
|
|
4386
|
+
* Defaulted rather than required so an author who never versions their app
|
|
4387
|
+
* still gets a stamp both sides recognise.
|
|
4388
|
+
*/
|
|
4389
|
+
schemaVersion: z.number().int().nonnegative().default(1),
|
|
4390
|
+
vaultNamespace: z.string().optional(),
|
|
4391
|
+
secrets: z.array(TemplateSecretSchema).default([]),
|
|
4392
|
+
requires: z.object({ airapp: z.boolean().optional() }).default({})
|
|
4393
|
+
});
|
|
4394
|
+
/**
|
|
4395
|
+
* `metadata.busabase` inside the root `SKILL.md`'s YAML frontmatter.
|
|
4396
|
+
*
|
|
4397
|
+
* `template: true` is an EXPLICIT opt-in, not an inference from "this skill
|
|
4398
|
+
* happens to contain a package". Publishing a template means accepting that
|
|
4399
|
+
* installers will run its AirApp code and feed its SKILL.md to their agent; that
|
|
4400
|
+
* deserves a deliberate flag rather than a side effect of directory shape.
|
|
4401
|
+
*/
|
|
4402
|
+
const SkillBusabaseMetadataSchema = z.object({
|
|
4403
|
+
template: z.boolean().default(false),
|
|
4404
|
+
folderSlug: z.string().optional(),
|
|
4405
|
+
/** Resource keys the manual talks about; each must exist under `content/`. */
|
|
4406
|
+
resources: z.array(z.string()).default([]),
|
|
4407
|
+
risk: z.string().optional()
|
|
4408
|
+
});
|
|
4409
|
+
/** Root `SKILL.md` frontmatter, as far as the template format cares about it. */
|
|
4410
|
+
const SkillFrontmatterSchema = z.object({
|
|
4411
|
+
name: z.string().min(1),
|
|
4412
|
+
description: z.string().default(""),
|
|
4413
|
+
metadata: z.object({ busabase: SkillBusabaseMetadataSchema.optional() }).passthrough().optional()
|
|
4414
|
+
});
|
|
4415
|
+
/**
|
|
4416
|
+
* Resolve which AirApp is the app, or explain why it cannot be resolved.
|
|
4417
|
+
*
|
|
4418
|
+
* Order matters and mirrors §5.4: an explicit declaration always wins, a single
|
|
4419
|
+
* candidate is unambiguous enough to accept, and anything else is an error the
|
|
4420
|
+
* publisher must resolve. Ambiguity is never resolved by picking the first
|
|
4421
|
+
* alphabetically — a template that silently opens the wrong app is worse than
|
|
4422
|
+
* one that refuses to publish.
|
|
4423
|
+
*/
|
|
4424
|
+
const resolvePrimaryAirApp = (template, airAppSlugsInContent) => {
|
|
4425
|
+
if (template?.airapp && template.airapps?.length) return { error: "`template.airapp` and `template.airapps` are mutually exclusive." };
|
|
4426
|
+
if (template?.airapp) return airAppSlugsInContent.includes(template.airapp) ? { slug: template.airapp } : { error: `template.airapp "${template.airapp}" has no matching AirApp under content/.` };
|
|
4427
|
+
if (template?.airapps?.length) {
|
|
4428
|
+
const primaries = template.airapps.filter((entry) => entry.role === "primary");
|
|
4429
|
+
if (primaries.length !== 1) return { error: `template.airapps must declare exactly one entry with role "primary" (found ${primaries.length}).` };
|
|
4430
|
+
const [primary] = primaries;
|
|
4431
|
+
const unknown = template.airapps.filter((entry) => !airAppSlugsInContent.includes(entry.slug));
|
|
4432
|
+
if (unknown.length) return { error: `template.airapps references AirApps missing from content/: ${unknown.map((entry) => entry.slug).join(", ")}.` };
|
|
4433
|
+
return { slug: primary.slug };
|
|
4434
|
+
}
|
|
4435
|
+
if (airAppSlugsInContent.length === 1) return { slug: airAppSlugsInContent[0] };
|
|
4436
|
+
if (airAppSlugsInContent.length === 0) return { error: "This package contains no AirApp." };
|
|
4437
|
+
return { error: `content/ has ${airAppSlugsInContent.length} AirApps; declare which one is primary via template.airapp or template.airapps.` };
|
|
4438
|
+
};
|
|
4439
|
+
/** Stamp on every resource node (Base, Drive, AirApp, …) an app owns. */
|
|
4440
|
+
const AppResourceOwnershipSchema = z.object({
|
|
4441
|
+
appId: z.string().min(1),
|
|
4442
|
+
/** Stable internal handle (`"contacts"`), NOT the installed slug. */
|
|
4443
|
+
resourceKey: z.string().min(1),
|
|
4444
|
+
schemaVersion: z.number().int().nonnegative()
|
|
4445
|
+
});
|
|
4446
|
+
/**
|
|
4447
|
+
* The `resourceKey` reserved for an app's root Folder.
|
|
4448
|
+
*
|
|
4449
|
+
* `busabase-sdk` recognises an app's own Folder by looking for exactly this
|
|
4450
|
+
* value (`ownsAppRoot`), so the installer must write it too — a Folder stamped
|
|
4451
|
+
* with anything else reads as a stranger's, and the skill's own `setup.mjs`
|
|
4452
|
+
* then refuses to touch its own workspace with `SETUP_CONFLICT`. Exported so
|
|
4453
|
+
* neither side carries the string literal privately.
|
|
4454
|
+
*/
|
|
4455
|
+
const APP_ROOT_RESOURCE_KEY = "app-root";
|
|
4456
|
+
AppResourceOwnershipSchema.extend({
|
|
4457
|
+
resourceKey: z.literal(APP_ROOT_RESOURCE_KEY),
|
|
4458
|
+
version: z.string().optional(),
|
|
4459
|
+
source: z.object({
|
|
4460
|
+
repo: z.string().optional(),
|
|
4461
|
+
ref: z.string().optional(),
|
|
4462
|
+
subdir: z.string().optional()
|
|
4463
|
+
}).optional(),
|
|
4464
|
+
installedAt: z.string().optional()
|
|
4465
|
+
});
|
|
4466
|
+
/**
|
|
4467
|
+
* Marks the Skill node lifted from the package root.
|
|
4468
|
+
*
|
|
4469
|
+
* Needed in both directions: export lifts exactly this node back out to the
|
|
4470
|
+
* package root instead of writing it under `content/` (which would create the
|
|
4471
|
+
* duplicate the format exists to avoid), and the agent wiring lists exactly
|
|
4472
|
+
* these nodes as "app manuals available in this space".
|
|
4473
|
+
*/
|
|
4474
|
+
const TEMPLATE_SKILL_METADATA_KEY = "isTemplateSkill";
|
|
4475
|
+
z.object({
|
|
4476
|
+
appId: z.string().min(1),
|
|
4477
|
+
[TEMPLATE_SKILL_METADATA_KEY]: z.literal(true)
|
|
4478
|
+
});
|
|
3961
4479
|
const LocaleSchema = z.enum({
|
|
3962
4480
|
defaultLocale: "en",
|
|
3963
4481
|
locales: [
|
|
@@ -4341,8 +4859,7 @@ z.discriminatedUnion("operation", [archiveBaseInputSchema.extend({
|
|
|
4341
4859
|
* when a node carries no `icon`, every host falls back to the type icon, same
|
|
4342
4860
|
* as before this field existed.
|
|
4343
4861
|
*
|
|
4344
|
-
* The `attachment` variant
|
|
4345
|
-
* `apps/buda/src/domains/agent-controller/components/use-logo-crop-upload.tsx`):
|
|
4862
|
+
* The `attachment` variant follows the shared avatar-cropping model:
|
|
4346
4863
|
* `url`/`attachmentId` are the CROPPED display image actually rendered, while
|
|
4347
4864
|
* `originalUrl`/`originalAttachmentId` + `crop` are kept so the crop dialog can
|
|
4348
4865
|
* re-open non-destructively against the untouched source image instead of
|
|
@@ -5080,7 +5597,15 @@ const PackageManifestSchema = z.object({
|
|
|
5080
5597
|
author: z.string().optional(),
|
|
5081
5598
|
license: z.string().optional(),
|
|
5082
5599
|
homepage: z.string().url().optional(),
|
|
5083
|
-
tags: z.array(z.string()).default([])
|
|
5600
|
+
tags: z.array(z.string()).default([]),
|
|
5601
|
+
/**
|
|
5602
|
+
* Present ⇢ this package is also an Agent Skill (a "template"): see
|
|
5603
|
+
* `./template.ts`. Optional and additive — a package without it behaves
|
|
5604
|
+
* exactly as before, and a reader that ignores it still installs correctly.
|
|
5605
|
+
* The flag alone is not enough to be treated as a template; the root
|
|
5606
|
+
* `SKILL.md` must opt in too (`validateTemplate`).
|
|
5607
|
+
*/
|
|
5608
|
+
template: TemplateManifestSchema.optional()
|
|
5084
5609
|
});
|
|
5085
5610
|
/** `_node.json` — marks a directory as a verbatim-content file-tree node. */
|
|
5086
5611
|
const PackageFileTreeNodeMetaSchema = z.object({
|
|
@@ -5327,6 +5852,18 @@ const compareNodes = (a, b) => {
|
|
|
5327
5852
|
const sortNodes = (nodes) => [...nodes].sort(compareNodes);
|
|
5328
5853
|
/** `getting-started` → `Getting Started`; the default name for a folder with no `_folder.json`. */
|
|
5329
5854
|
const humanizeSlug = (slug) => slug.split("-").filter(Boolean).map((word) => word.charAt(0).toUpperCase() + word.slice(1)).join(" ");
|
|
5855
|
+
/**
|
|
5856
|
+
* Extension → mime type, which decides one thing that matters: a text file goes
|
|
5857
|
+
* INLINE into a file-tree create, and anything else has to be uploaded as an
|
|
5858
|
+
* asset first (see `isTextMimeType`). Getting it wrong on a source file means
|
|
5859
|
+
* the app's own code takes the binary path — it needs an upload endpoint to
|
|
5860
|
+
* install at all, and it lands as an opaque blob rather than something a
|
|
5861
|
+
* reviewer or an agent can read.
|
|
5862
|
+
*
|
|
5863
|
+
* The module-flavoured JavaScript and TypeScript extensions are here because
|
|
5864
|
+
* real projects use them: `scripts/setup.mjs` is the ordinary name for a Node
|
|
5865
|
+
* script, and installing kelly-email surfaced exactly that gap.
|
|
5866
|
+
*/
|
|
5330
5867
|
const MIME_BY_EXT$1 = {
|
|
5331
5868
|
".css": "text/css",
|
|
5332
5869
|
".csv": "text/csv",
|
|
@@ -5335,7 +5872,12 @@ const MIME_BY_EXT$1 = {
|
|
|
5335
5872
|
".html": "text/html",
|
|
5336
5873
|
".jpeg": "image/jpeg",
|
|
5337
5874
|
".jpg": "image/jpeg",
|
|
5875
|
+
".cjs": "text/javascript",
|
|
5876
|
+
".cts": "text/typescript",
|
|
5338
5877
|
".js": "text/javascript",
|
|
5878
|
+
".jsx": "text/javascript",
|
|
5879
|
+
".mjs": "text/javascript",
|
|
5880
|
+
".mts": "text/typescript",
|
|
5339
5881
|
".json": "application/json",
|
|
5340
5882
|
".md": "text/markdown",
|
|
5341
5883
|
".pdf": "application/pdf",
|
|
@@ -5343,6 +5885,7 @@ const MIME_BY_EXT$1 = {
|
|
|
5343
5885
|
".py": "text/x-python",
|
|
5344
5886
|
".svg": "image/svg+xml",
|
|
5345
5887
|
".ts": "text/typescript",
|
|
5888
|
+
".tsx": "text/typescript",
|
|
5346
5889
|
".txt": "text/plain",
|
|
5347
5890
|
".webp": "image/webp",
|
|
5348
5891
|
".yaml": "text/yaml",
|
|
@@ -5470,6 +6013,7 @@ const applyInstall = async (client, plan, options) => {
|
|
|
5470
6013
|
const fieldIds = /* @__PURE__ */ new Map();
|
|
5471
6014
|
const bases = [];
|
|
5472
6015
|
const progress = options.onProgress ?? (() => {});
|
|
6016
|
+
const app = resolveAppContext(plan, options);
|
|
5473
6017
|
const docAssetIds = await uploadDocAssets(client, plan.tree.assets ?? [], state, progress, options.serverUrl);
|
|
5474
6018
|
progress("Pass 1/5: creating the node tree…");
|
|
5475
6019
|
if (plan.existingTargetFolderNodeId) state.targetFolderNodeId = plan.existingTargetFolderNodeId;
|
|
@@ -5479,11 +6023,23 @@ const applyInstall = async (client, plan, options) => {
|
|
|
5479
6023
|
slug: plan.targetFolderSlug,
|
|
5480
6024
|
name: plan.tree.manifest.name,
|
|
5481
6025
|
description: plan.tree.manifest.description,
|
|
5482
|
-
submittedBy: options.submittedBy
|
|
6026
|
+
submittedBy: options.submittedBy,
|
|
6027
|
+
metadata: app?.rootMetadata
|
|
5483
6028
|
});
|
|
5484
6029
|
state.created.folders++;
|
|
5485
6030
|
}
|
|
5486
|
-
await createStructure(client, plan.tree.nodes, state.targetFolderNodeId, options, state, bases, fieldIds, docAssetIds);
|
|
6031
|
+
await createStructure(client, plan.tree.nodes, state.targetFolderNodeId, options, state, bases, fieldIds, docAssetIds, app);
|
|
6032
|
+
if (app?.rootSkill) {
|
|
6033
|
+
progress("Pass 1/5: installing the app skill…");
|
|
6034
|
+
await createFileTreeNode(client, {
|
|
6035
|
+
type: "skill",
|
|
6036
|
+
slug: app.rootSkill.slug,
|
|
6037
|
+
name: app.rootSkill.name,
|
|
6038
|
+
description: app.rootSkill.description,
|
|
6039
|
+
position: void 0,
|
|
6040
|
+
files: app.rootSkill.files
|
|
6041
|
+
}, state.targetFolderNodeId, options, state, app, { [TEMPLATE_SKILL_METADATA_KEY]: true });
|
|
6042
|
+
}
|
|
5487
6043
|
const deferred = bases.filter((base) => base.node.base.fields.some(isDeferredField));
|
|
5488
6044
|
if (deferred.length > 0) {
|
|
5489
6045
|
progress("Pass 2/5: adding relation and AI fields…");
|
|
@@ -5526,7 +6082,7 @@ const applyInstall = async (client, plan, options) => {
|
|
|
5526
6082
|
const withRecords = bases.filter((base) => base.node.records.length > 0);
|
|
5527
6083
|
if (withRecords.length > 0) {
|
|
5528
6084
|
progress("Pass 4/5: creating records…");
|
|
5529
|
-
for (const base of withRecords) await createRecords(client, base, plan, options, state, recordIdsByKey);
|
|
6085
|
+
for (const base of withRecords) await createRecords(client, base, plan, options, state, recordIdsByKey, app);
|
|
5530
6086
|
}
|
|
5531
6087
|
const relationFieldSlugs = /* @__PURE__ */ new Map();
|
|
5532
6088
|
for (const base of bases) {
|
|
@@ -5543,6 +6099,58 @@ const applyInstall = async (client, plan, options) => {
|
|
|
5543
6099
|
}
|
|
5544
6100
|
return state;
|
|
5545
6101
|
};
|
|
6102
|
+
const resolveAppContext = (plan, options) => {
|
|
6103
|
+
if (!plan.isTemplate) return void 0;
|
|
6104
|
+
const manifest = plan.tree.manifest;
|
|
6105
|
+
const schemaVersion = manifest.template?.schemaVersion ?? 1;
|
|
6106
|
+
const now = options.now ?? (() => (/* @__PURE__ */ new Date()).toISOString());
|
|
6107
|
+
const rootStamp = {
|
|
6108
|
+
appId: manifest.name,
|
|
6109
|
+
resourceKey: APP_ROOT_RESOURCE_KEY,
|
|
6110
|
+
schemaVersion,
|
|
6111
|
+
...manifest.version ? { version: manifest.version } : {},
|
|
6112
|
+
...options.source ? { source: options.source } : {},
|
|
6113
|
+
installedAt: now()
|
|
6114
|
+
};
|
|
6115
|
+
return {
|
|
6116
|
+
appId: manifest.name,
|
|
6117
|
+
schemaVersion,
|
|
6118
|
+
rootMetadata: rootStamp,
|
|
6119
|
+
rootSkill: plan.tree.rootSkill,
|
|
6120
|
+
resourceKeysBySlug: plan.resourceKeysBySlug,
|
|
6121
|
+
mergeSampleRecords: options.installSampleRecords ?? true
|
|
6122
|
+
};
|
|
6123
|
+
};
|
|
6124
|
+
/**
|
|
6125
|
+
* Stamp a node the installer just created.
|
|
6126
|
+
*
|
|
6127
|
+
* Separate from creation because `bases.create` and `fileTrees.create` take no
|
|
6128
|
+
* metadata — only `nodes.createChangeRequest` does, which is why the Folder can
|
|
6129
|
+
* be stamped inline and these cannot. A stamp that fails is a warning, never a
|
|
6130
|
+
* failed install: the resources are already there and useful, and the only loss
|
|
6131
|
+
* is that the skill's own `setup.mjs` will later offer to re-claim them, which
|
|
6132
|
+
* it is built to do (that is its "repair" path).
|
|
6133
|
+
*/
|
|
6134
|
+
const resourceStamp = (app, installedSlug, extra = {}) => {
|
|
6135
|
+
return {
|
|
6136
|
+
appId: app.appId,
|
|
6137
|
+
resourceKey: app.resourceKeysBySlug[installedSlug] ?? installedSlug,
|
|
6138
|
+
schemaVersion: app.schemaVersion,
|
|
6139
|
+
...extra
|
|
6140
|
+
};
|
|
6141
|
+
};
|
|
6142
|
+
const stampResource = async (client, app, nodeId, installedSlug, state, extra = {}) => {
|
|
6143
|
+
if (!app) return;
|
|
6144
|
+
const stamp = resourceStamp(app, installedSlug, extra);
|
|
6145
|
+
try {
|
|
6146
|
+
await client.nodes.updateMetadata({
|
|
6147
|
+
nodeId,
|
|
6148
|
+
metadata: stamp
|
|
6149
|
+
});
|
|
6150
|
+
} catch (error) {
|
|
6151
|
+
state.warnings.push(`Could not record app ownership on "${installedSlug}": ${error.message}. The node installed fine; running the app's own setup will claim it.`);
|
|
6152
|
+
}
|
|
6153
|
+
};
|
|
5546
6154
|
/**
|
|
5547
6155
|
* Re-create the package's carried Doc images on THIS host and return the
|
|
5548
6156
|
* old id → new id map the Doc bodies are rewritten through.
|
|
@@ -5566,7 +6174,7 @@ const uploadDocAssets = async (client, assets, state, progress, serverUrl) => {
|
|
|
5566
6174
|
}
|
|
5567
6175
|
return idMap;
|
|
5568
6176
|
};
|
|
5569
|
-
const createStructure = async (client, nodes, parentNodeId, options, state, bases, fieldIds, docAssetIds) => {
|
|
6177
|
+
const createStructure = async (client, nodes, parentNodeId, options, state, bases, fieldIds, docAssetIds, app) => {
|
|
5570
6178
|
for (const node of nodes) switch (node.type) {
|
|
5571
6179
|
case "folder": {
|
|
5572
6180
|
const nodeId = await createFolderNode(client, {
|
|
@@ -5577,7 +6185,7 @@ const createStructure = async (client, nodes, parentNodeId, options, state, base
|
|
|
5577
6185
|
submittedBy: options.submittedBy
|
|
5578
6186
|
});
|
|
5579
6187
|
state.created.folders++;
|
|
5580
|
-
await createStructure(client, node.children, nodeId, options, state, bases, fieldIds, docAssetIds);
|
|
6188
|
+
await createStructure(client, node.children, nodeId, options, state, bases, fieldIds, docAssetIds, app);
|
|
5581
6189
|
break;
|
|
5582
6190
|
}
|
|
5583
6191
|
case "doc": {
|
|
@@ -5595,12 +6203,12 @@ const createStructure = async (client, nodes, parentNodeId, options, state, base
|
|
|
5595
6203
|
break;
|
|
5596
6204
|
}
|
|
5597
6205
|
case "base":
|
|
5598
|
-
await createBase(client, node, parentNodeId, options, state, bases, fieldIds);
|
|
6206
|
+
await createBase(client, node, parentNodeId, options, state, bases, fieldIds, app);
|
|
5599
6207
|
break;
|
|
5600
6208
|
case "skill":
|
|
5601
6209
|
case "airapp":
|
|
5602
6210
|
case "drive":
|
|
5603
|
-
await createFileTreeNode(client, node, parentNodeId, options, state);
|
|
6211
|
+
await createFileTreeNode(client, node, parentNodeId, options, state, app);
|
|
5604
6212
|
break;
|
|
5605
6213
|
case "file": {
|
|
5606
6214
|
let asset;
|
|
@@ -5639,13 +6247,14 @@ const createFolderNode = async (client, input) => {
|
|
|
5639
6247
|
slug: input.slug,
|
|
5640
6248
|
name: input.name,
|
|
5641
6249
|
description: input.description,
|
|
5642
|
-
parentNodeId: input.parentNodeId
|
|
6250
|
+
parentNodeId: input.parentNodeId,
|
|
6251
|
+
...input.metadata ? { metadata: input.metadata } : {}
|
|
5643
6252
|
}]
|
|
5644
6253
|
})).operations[0]?.nodeId;
|
|
5645
6254
|
if (!nodeId) throw new Error(`Folder "${input.slug}" was created but the server returned no node id, so nothing could be installed inside it.`);
|
|
5646
6255
|
return nodeId;
|
|
5647
6256
|
};
|
|
5648
|
-
const createBase = async (client, node, parentNodeId, options, state, bases, fieldIds) => {
|
|
6257
|
+
const createBase = async (client, node, parentNodeId, options, state, bases, fieldIds, app) => {
|
|
5649
6258
|
const immediateFields = node.base.fields.filter((field) => !isDeferredField(field)).map((field) => ({
|
|
5650
6259
|
slug: field.slug,
|
|
5651
6260
|
name: field.name,
|
|
@@ -5668,6 +6277,7 @@ const createBase = async (client, node, parentNodeId, options, state, bases, fie
|
|
|
5668
6277
|
baseId: result.id
|
|
5669
6278
|
});
|
|
5670
6279
|
indexFields(fieldIds, node.slug, result.fields);
|
|
6280
|
+
await stampResource(client, app, result.nodeId, node.slug, state);
|
|
5671
6281
|
for (const view of node.base.views) {
|
|
5672
6282
|
if (!(await client.views.changeRequest({
|
|
5673
6283
|
operation: "create",
|
|
@@ -5682,7 +6292,7 @@ const createBase = async (client, node, parentNodeId, options, state, bases, fie
|
|
|
5682
6292
|
state.created.views++;
|
|
5683
6293
|
}
|
|
5684
6294
|
};
|
|
5685
|
-
const createFileTreeNode = async (client, node, parentNodeId, options, state) => {
|
|
6295
|
+
const createFileTreeNode = async (client, node, parentNodeId, options, state, app, extraMetadata = {}) => {
|
|
5686
6296
|
const files = (await Promise.all(node.files.map(async (entry) => {
|
|
5687
6297
|
const mimeType = guessMimeType$1(entry.path);
|
|
5688
6298
|
if (isTextMimeType(mimeType)) return {
|
|
@@ -5709,6 +6319,7 @@ const createFileTreeNode = async (client, node, parentNodeId, options, state) =>
|
|
|
5709
6319
|
name: node.name,
|
|
5710
6320
|
description: node.description,
|
|
5711
6321
|
files,
|
|
6322
|
+
...app ? { metadata: resourceStamp(app, node.slug, extraMetadata) } : {},
|
|
5712
6323
|
mergeMode: "replace",
|
|
5713
6324
|
autoMerge: options.autoMerge
|
|
5714
6325
|
})).materialized) {
|
|
@@ -5856,7 +6467,8 @@ const toCreatableFields = (node, fields) => {
|
|
|
5856
6467
|
}
|
|
5857
6468
|
return result;
|
|
5858
6469
|
};
|
|
5859
|
-
const createRecords = async (client, base, plan, options, state, recordIdsByKey) => {
|
|
6470
|
+
const createRecords = async (client, base, plan, options, state, recordIdsByKey, app) => {
|
|
6471
|
+
const mergeRecords = options.autoMerge || (app?.mergeSampleRecords ?? false);
|
|
5860
6472
|
const records = [...base.node.records].sort((a, b) => a.key.localeCompare(b.key, "en"));
|
|
5861
6473
|
for (let offset = 0; offset < records.length; offset += 200) {
|
|
5862
6474
|
const batch = records.slice(offset, offset + 200);
|
|
@@ -5866,9 +6478,9 @@ const createRecords = async (client, base, plan, options, state, recordIdsByKey)
|
|
|
5866
6478
|
message: `Install ${batch.length} record(s) into ${base.node.slug}`,
|
|
5867
6479
|
submittedBy: options.submittedBy,
|
|
5868
6480
|
idempotencyKey: batchIdempotencyKey(plan.tree.manifest.name, batch[0].key),
|
|
5869
|
-
autoMerge:
|
|
6481
|
+
autoMerge: mergeRecords
|
|
5870
6482
|
});
|
|
5871
|
-
if (!
|
|
6483
|
+
if (!mergeRecords) {
|
|
5872
6484
|
state.pendingChangeRequests++;
|
|
5873
6485
|
continue;
|
|
5874
6486
|
}
|
|
@@ -5970,19 +6582,115 @@ const findSourceNode = (nodes, slugOrId) => {
|
|
|
5970
6582
|
};
|
|
5971
6583
|
const collectPackageTree = async (client, root, options) => {
|
|
5972
6584
|
const children = root.children ?? [];
|
|
5973
|
-
const
|
|
6585
|
+
const { skillSource, rest } = partitionTemplateSkill(children);
|
|
6586
|
+
const nodes = await collectNodes(client, rest, options);
|
|
5974
6587
|
const tree = {
|
|
5975
6588
|
manifest: {
|
|
5976
6589
|
...options.manifest,
|
|
5977
6590
|
format: PACKAGE_FORMAT
|
|
5978
6591
|
},
|
|
5979
|
-
nodes,
|
|
6592
|
+
nodes: restorePackageSlugs(nodes, children),
|
|
6593
|
+
...skillSource ? { rootSkill: await collectRootSkill(client, skillSource, options) } : {},
|
|
5980
6594
|
assets: await collectDocAssets(client, nodes, options)
|
|
5981
6595
|
};
|
|
5982
6596
|
assertSelfContained(tree, options);
|
|
5983
6597
|
return tree;
|
|
5984
6598
|
};
|
|
5985
6599
|
/**
|
|
6600
|
+
* Undo the slugs INSTALL chose, restoring the ones the package declared.
|
|
6601
|
+
*
|
|
6602
|
+
* Install prefixes a template's Base slugs with the target folder (`contacts` →
|
|
6603
|
+
* `kelly-crm-contacts`) and records the original as the node's `resourceKey`.
|
|
6604
|
+
* Exporting the installed slug would make the package say something its author
|
|
6605
|
+
* never wrote — and re-installing it would prefix the prefix, or collide
|
|
6606
|
+
* outright. Reading the stamp back makes export→install→export a fixed point,
|
|
6607
|
+
* which is the property that lets a user install a template, adjust it, and
|
|
6608
|
+
* publish the result without the names drifting a little further each time.
|
|
6609
|
+
*
|
|
6610
|
+
* The root folder's own stamp is skipped: its `resourceKey` is the reserved
|
|
6611
|
+
* `app-root` marker, not a slug anything should be named after.
|
|
6612
|
+
*/
|
|
6613
|
+
const restorePackageSlugs = (nodes, sources) => {
|
|
6614
|
+
const renames = /* @__PURE__ */ new Map();
|
|
6615
|
+
const index = (list) => {
|
|
6616
|
+
for (const source of list) {
|
|
6617
|
+
const key = source.metadata?.resourceKey;
|
|
6618
|
+
if (typeof key === "string" && key !== "app-root" && key !== source.slug && NODE_SLUG_PATTERN.test(key)) renames.set(source.slug, key);
|
|
6619
|
+
if (source.children) index(source.children);
|
|
6620
|
+
}
|
|
6621
|
+
};
|
|
6622
|
+
index(sources);
|
|
6623
|
+
if (renames.size === 0) return [...nodes];
|
|
6624
|
+
const rewriteField = (field) => {
|
|
6625
|
+
const target = field.options.targetBaseSlug;
|
|
6626
|
+
if (!target) return field;
|
|
6627
|
+
const renamed = renames.get(target);
|
|
6628
|
+
return renamed ? {
|
|
6629
|
+
...field,
|
|
6630
|
+
options: {
|
|
6631
|
+
...field.options,
|
|
6632
|
+
targetBaseSlug: renamed
|
|
6633
|
+
}
|
|
6634
|
+
} : field;
|
|
6635
|
+
};
|
|
6636
|
+
const rewrite = (node) => {
|
|
6637
|
+
const slug = renames.get(node.slug) ?? node.slug;
|
|
6638
|
+
if (node.type === "folder") return {
|
|
6639
|
+
...node,
|
|
6640
|
+
slug,
|
|
6641
|
+
children: node.children.map(rewrite)
|
|
6642
|
+
};
|
|
6643
|
+
if (node.type === "base") return {
|
|
6644
|
+
...node,
|
|
6645
|
+
slug,
|
|
6646
|
+
base: {
|
|
6647
|
+
...node.base,
|
|
6648
|
+
fields: node.base.fields.map(rewriteField)
|
|
6649
|
+
}
|
|
6650
|
+
};
|
|
6651
|
+
return {
|
|
6652
|
+
...node,
|
|
6653
|
+
slug
|
|
6654
|
+
};
|
|
6655
|
+
};
|
|
6656
|
+
return nodes.map(rewrite);
|
|
6657
|
+
};
|
|
6658
|
+
/**
|
|
6659
|
+
* Separate the app's manual from its resources.
|
|
6660
|
+
*
|
|
6661
|
+
* The Skill node stamped `isTemplateSkill` is the one an install lifted out of a
|
|
6662
|
+
* package root, so export has to put it back there rather than under `content/`.
|
|
6663
|
+
* Writing it as a normal node would make every export→install cycle add another
|
|
6664
|
+
* copy of the manual beside the previous one.
|
|
6665
|
+
*
|
|
6666
|
+
* Only the FIRST such node is lifted: two of them in one folder is a state no
|
|
6667
|
+
* install can produce, and silently merging them would be a guess. The extra one
|
|
6668
|
+
* is exported as an ordinary Skill node, which is visible and fixable, rather
|
|
6669
|
+
* than dropped.
|
|
6670
|
+
*/
|
|
6671
|
+
const partitionTemplateSkill = (children) => {
|
|
6672
|
+
const index = children.findIndex((node) => node.type === "skill" && node.metadata?.["isTemplateSkill"] === true);
|
|
6673
|
+
if (index === -1) return { rest: [...children] };
|
|
6674
|
+
return {
|
|
6675
|
+
skillSource: children[index],
|
|
6676
|
+
rest: children.filter((_, position) => position !== index)
|
|
6677
|
+
};
|
|
6678
|
+
};
|
|
6679
|
+
const collectRootSkill = async (client, source, options) => {
|
|
6680
|
+
const node = await collectFileTree(client, source, "skill", {
|
|
6681
|
+
slug: source.slug,
|
|
6682
|
+
name: source.name,
|
|
6683
|
+
description: source.description ?? "",
|
|
6684
|
+
position: source.position
|
|
6685
|
+
}, options);
|
|
6686
|
+
return {
|
|
6687
|
+
slug: source.slug,
|
|
6688
|
+
name: source.name,
|
|
6689
|
+
description: source.description ?? "",
|
|
6690
|
+
files: node.files
|
|
6691
|
+
};
|
|
6692
|
+
};
|
|
6693
|
+
/**
|
|
5986
6694
|
* The bytes behind every `` in every exported Doc.
|
|
5987
6695
|
*
|
|
5988
6696
|
* A Doc's images are the one part of a Doc that is not in its markdown, and
|
|
@@ -6303,175 +7011,10 @@ const assertSelfContained = (tree, options) => {
|
|
|
6303
7011
|
}
|
|
6304
7012
|
};
|
|
6305
7013
|
//#endregion
|
|
6306
|
-
//#region ../../packages/busabase-package/src/
|
|
7014
|
+
//#region ../../packages/busabase-package/src/frontmatter.ts
|
|
6307
7015
|
/**
|
|
6308
|
-
*
|
|
6309
|
-
*
|
|
6310
|
-
*
|
|
6311
|
-
* Ported from Buda's GitHub skill importer (Buda stays as-is; a shared package
|
|
6312
|
-
* is warranted only once the Phase-2 server consumer actually exists). Three
|
|
6313
|
-
* deliberate divergences from that original:
|
|
6314
|
-
*
|
|
6315
|
-
* 1. **Host is parsed, not pattern-matched.** buda's regex matches `github.com/…`
|
|
6316
|
-
* anywhere in the string, so `https://evil.example/github.com/o/r` passes. This
|
|
6317
|
-
* parses with `URL` and checks `hostname` against an allowlist.
|
|
6318
|
-
* 2. **Any git ref, not just branches.** buda fetches `/archive/refs/heads/<b>.zip`,
|
|
6319
|
-
* which resolves branches only — tags (the package format's version scheme)
|
|
6320
|
-
* would 404. This uses codeload's `/zip/<ref>`, which takes a branch, tag, or SHA.
|
|
6321
|
-
* 3. **Extraction is to memory with the caps applied first**, so an oversized or
|
|
6322
|
-
* zip-bomb archive never reaches the disk.
|
|
6323
|
-
*/
|
|
6324
|
-
/** Hosts the installer may ever connect to. Also the Phase-2 server guard's allowlist. */
|
|
6325
|
-
const GITHUB_ALLOWED_HOSTS = [
|
|
6326
|
-
"github.com",
|
|
6327
|
-
"api.github.com",
|
|
6328
|
-
"codeload.github.com"
|
|
6329
|
-
];
|
|
6330
|
-
/** Hosts accepted in a user-supplied install URL (the web UI form of a repo). */
|
|
6331
|
-
const GITHUB_URL_HOSTS = ["github.com", "www.github.com"];
|
|
6332
|
-
const isAllowedGithubHost = (hostname) => GITHUB_ALLOWED_HOSTS.includes(hostname.toLowerCase());
|
|
6333
|
-
/**
|
|
6334
|
-
* Parse a GitHub repo URL.
|
|
6335
|
-
*
|
|
6336
|
-
* Supported forms:
|
|
6337
|
-
* https://github.com/owner/repo
|
|
6338
|
-
* https://github.com/owner/repo.git
|
|
6339
|
-
* https://github.com/owner/repo/tree/<ref>
|
|
6340
|
-
* https://github.com/owner/repo/tree/<ref>/<subdir…>
|
|
6341
|
-
*
|
|
6342
|
-
* A ref containing `/` (e.g. `feature/x`) is indistinguishable from `<ref>/<subdir>`
|
|
6343
|
-
* in a `/tree/` URL — GitHub's own web URLs have the same ambiguity — so the first
|
|
6344
|
-
* segment after `/tree/` is taken as the ref and the rest as the subdir.
|
|
6345
|
-
*/
|
|
6346
|
-
const parseGithubUrl = (rawUrl) => {
|
|
6347
|
-
let url;
|
|
6348
|
-
try {
|
|
6349
|
-
url = new URL(rawUrl.trim());
|
|
6350
|
-
} catch {
|
|
6351
|
-
throw new Error(`Not a valid URL: "${rawUrl}". Expected a GitHub repo URL, e.g. https://github.com/acme/support-kb-template`);
|
|
6352
|
-
}
|
|
6353
|
-
if (url.protocol !== "https:" && url.protocol !== "http:") throw new Error(`Unsupported URL scheme "${url.protocol}" — expected an https:// GitHub URL.`);
|
|
6354
|
-
if (!GITHUB_URL_HOSTS.includes(url.hostname.toLowerCase())) throw new Error(`Not a GitHub URL: host "${url.hostname}" is not github.com. Only GitHub packages can be installed.`);
|
|
6355
|
-
const [owner, rawRepo, marker, ref, ...rest] = url.pathname.split("/").filter(Boolean);
|
|
6356
|
-
if (!owner || !rawRepo) throw new Error(`GitHub URL is missing owner/repo: "${rawUrl}". Expected https://github.com/<owner>/<repo>[/tree/<ref>[/<subdir>]]`);
|
|
6357
|
-
const repo = rawRepo.replace(/\.git$/, "");
|
|
6358
|
-
if (!marker) return {
|
|
6359
|
-
owner,
|
|
6360
|
-
repo,
|
|
6361
|
-
ref: void 0,
|
|
6362
|
-
subdir: void 0
|
|
6363
|
-
};
|
|
6364
|
-
if (marker !== "tree") throw new Error(`Unsupported GitHub URL form "/${marker}/" in "${rawUrl}". Expected https://github.com/<owner>/<repo>[/tree/<ref>[/<subdir>]]`);
|
|
6365
|
-
if (!ref) throw new Error(`GitHub URL has "/tree/" but no ref: "${rawUrl}".`);
|
|
6366
|
-
return {
|
|
6367
|
-
owner,
|
|
6368
|
-
repo,
|
|
6369
|
-
ref: decodeURIComponent(ref),
|
|
6370
|
-
subdir: rest.length > 0 ? rest.map((segment) => decodeURIComponent(segment)).join("/") : void 0
|
|
6371
|
-
};
|
|
6372
|
-
};
|
|
6373
|
-
/** Encode a ref for a URL path while keeping its `/` separators (e.g. `feature/x`). */
|
|
6374
|
-
const encodeRefPath = (ref) => ref.split("/").map(encodeURIComponent).join("/");
|
|
6375
|
-
/**
|
|
6376
|
-
* Download the zipball for `owner/repo` at `ref` (default branch when `ref` is
|
|
6377
|
-
* undefined). Tries the public codeload host first, then the authenticated API when
|
|
6378
|
-
* a token is available — so a private repo works iff `GITHUB_TOKEN` is set.
|
|
6379
|
-
*/
|
|
6380
|
-
const downloadGithubZip = async (source, options = {}) => {
|
|
6381
|
-
const fetcher = options.fetcher ?? fetch;
|
|
6382
|
-
const { owner, repo, ref } = source;
|
|
6383
|
-
const slug = `${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`;
|
|
6384
|
-
const label = `${owner}/${repo}${ref ? `@${ref}` : ""}`;
|
|
6385
|
-
const publicUrl = `https://codeload.github.com/${slug}/zip/${ref ? encodeRefPath(ref) : "HEAD"}`;
|
|
6386
|
-
assertAllowedUrl(publicUrl);
|
|
6387
|
-
const publicResponse = await fetcher(publicUrl);
|
|
6388
|
-
if (publicResponse.ok) return Buffer.from(await publicResponse.arrayBuffer());
|
|
6389
|
-
if (!options.githubToken) throw new Error(publicResponse.status === 404 ? `GitHub repo or ref not found: ${label} (HTTP 404).\n • Check the URL, and that the branch/tag exists.\n • Private repo? Set GITHUB_TOKEN=<a token with repo read access> and re-run.` : `Failed to download ${label} from GitHub (HTTP ${publicResponse.status} ${publicResponse.statusText}).`);
|
|
6390
|
-
const apiUrl = `https://api.github.com/repos/${slug}/zipball${ref ? `/${encodeRefPath(ref)}` : ""}`;
|
|
6391
|
-
assertAllowedUrl(apiUrl);
|
|
6392
|
-
const authedResponse = await fetcher(apiUrl, { headers: {
|
|
6393
|
-
Accept: "application/vnd.github+json",
|
|
6394
|
-
Authorization: `Bearer ${options.githubToken}`,
|
|
6395
|
-
"X-GitHub-Api-Version": "2022-11-28"
|
|
6396
|
-
} });
|
|
6397
|
-
if (authedResponse.ok) return Buffer.from(await authedResponse.arrayBuffer());
|
|
6398
|
-
if (authedResponse.status === 401 || authedResponse.status === 403) throw new Error(`GitHub rejected GITHUB_TOKEN for ${label} (HTTP ${authedResponse.status}). The token is invalid, expired, or lacks read access to this repo.`);
|
|
6399
|
-
throw new Error(`GitHub repo or ref not found: ${label} (HTTP ${authedResponse.status}), even with GITHUB_TOKEN. Check the owner, repo, and ref.`);
|
|
6400
|
-
};
|
|
6401
|
-
/** SSRF guard: refuse anything off the GitHub allowlist before a connection is made. */
|
|
6402
|
-
const assertAllowedUrl = (rawUrl) => {
|
|
6403
|
-
const url = new URL(rawUrl);
|
|
6404
|
-
if (url.protocol !== "https:" || !isAllowedGithubHost(url.hostname)) throw new Error(`Refusing to fetch ${rawUrl} — only https on ${GITHUB_ALLOWED_HOSTS.join(", ")} is allowed.`);
|
|
6405
|
-
};
|
|
6406
|
-
/**
|
|
6407
|
-
* Zip-slip guard. Normalizes an archive entry path to a repo-relative path and
|
|
6408
|
-
* rejects anything that could escape the extraction root: `..`/`.` segments,
|
|
6409
|
-
* and Windows drive prefixes. A leading `/` is stripped rather than rejected —
|
|
6410
|
-
* that makes the path relative, which is already safe.
|
|
6411
|
-
*/
|
|
6412
|
-
const normalizeArchivePath = (value) => {
|
|
6413
|
-
const normalized = value.replaceAll("\\", "/").replace(/^\/+|\/+$/g, "");
|
|
6414
|
-
const segments = normalized.split("/");
|
|
6415
|
-
if (!normalized || segments.some((segment) => segment === "." || segment === ".." || /^[a-zA-Z]:$/.test(segment))) throw new Error(`Unsafe archive path rejected: ${value}`);
|
|
6416
|
-
return normalized;
|
|
6417
|
-
};
|
|
6418
|
-
/**
|
|
6419
|
-
* Extract a GitHub zipball into memory, keyed by repo-relative path (the archive's
|
|
6420
|
-
* single generated root directory — `owner-repo-<sha>/` — is stripped).
|
|
6421
|
-
*
|
|
6422
|
-
* Caps are enforced against the archive's declared entry sizes BEFORE any bytes are
|
|
6423
|
-
* read, so an oversized archive or zip bomb is refused up front and nothing is ever
|
|
6424
|
-
* written to disk.
|
|
6425
|
-
*/
|
|
6426
|
-
const extractZip = async (zipBuffer, options = {}) => {
|
|
6427
|
-
const maxFiles = options.maxFiles ?? 5e3;
|
|
6428
|
-
const maxTotalBytes = options.maxTotalBytes ?? 262144e3;
|
|
6429
|
-
const maxFileBytes = options.maxFileBytes ?? 26214400;
|
|
6430
|
-
const reader = new ZipReader(new Uint8ArrayReader(new Uint8Array(zipBuffer)));
|
|
6431
|
-
try {
|
|
6432
|
-
const files = (await reader.getEntries()).filter((entry) => !entry.directory);
|
|
6433
|
-
const archiveRoot = files[0]?.filename.split("/")[0];
|
|
6434
|
-
if (!archiveRoot) throw new Error("The downloaded repository archive is empty.");
|
|
6435
|
-
const subdir = options.subdir ? normalizeArchivePath(options.subdir) : void 0;
|
|
6436
|
-
const prefix = `${archiveRoot}/${subdir ? `${subdir}/` : ""}`;
|
|
6437
|
-
for (const entry of files) normalizeArchivePath(entry.filename);
|
|
6438
|
-
const selected = files.filter((entry) => entry.filename.startsWith(prefix));
|
|
6439
|
-
if (selected.length > maxFiles) throw new Error(`Package has ${selected.length} files, above the ${maxFiles}-file limit. Nothing was installed.`);
|
|
6440
|
-
let totalBytes = 0;
|
|
6441
|
-
for (const entry of selected) {
|
|
6442
|
-
const size = entry.uncompressedSize ?? 0;
|
|
6443
|
-
const relativePath = entry.filename.slice(prefix.length);
|
|
6444
|
-
if (size > maxFileBytes) throw new Error(`File "${relativePath}" is ${formatBytes(size)}, above the ${formatBytes(maxFileBytes)} per-file limit. Nothing was installed.`);
|
|
6445
|
-
totalBytes += size;
|
|
6446
|
-
if (totalBytes > maxTotalBytes) throw new Error(`Package unpacks to more than the ${formatBytes(maxTotalBytes)} total limit. Nothing was installed.`);
|
|
6447
|
-
}
|
|
6448
|
-
const result = /* @__PURE__ */ new Map();
|
|
6449
|
-
for (const entry of selected) {
|
|
6450
|
-
const relativePath = normalizeArchivePath(entry.filename.slice(prefix.length));
|
|
6451
|
-
if (!entry.getData) continue;
|
|
6452
|
-
const data = await entry.arrayBuffer();
|
|
6453
|
-
result.set(relativePath, Buffer.from(data));
|
|
6454
|
-
}
|
|
6455
|
-
return result;
|
|
6456
|
-
} finally {
|
|
6457
|
-
await reader.close();
|
|
6458
|
-
}
|
|
6459
|
-
};
|
|
6460
|
-
const formatBytes = (bytes) => bytes >= 1048576 ? `${Math.round(bytes / 1048576)}MB` : `${Math.round(bytes / 1024)}KB`;
|
|
6461
|
-
/** Parse, fetch, and extract in one step — the `install` command's entry point. */
|
|
6462
|
-
const fetchGithubPackageFiles = async (repoUrl, options = {}) => {
|
|
6463
|
-
const source = parseGithubUrl(repoUrl);
|
|
6464
|
-
const zipBuffer = await downloadGithubZip(source, options);
|
|
6465
|
-
return {
|
|
6466
|
-
source,
|
|
6467
|
-
files: await extractZip(zipBuffer, { subdir: source.subdir })
|
|
6468
|
-
};
|
|
6469
|
-
};
|
|
6470
|
-
//#endregion
|
|
6471
|
-
//#region ../../packages/busabase-package/src/frontmatter.ts
|
|
6472
|
-
/**
|
|
6473
|
-
* YAML frontmatter for doc nodes (§6.3): `name`, `description?`, `position?` above
|
|
6474
|
-
* the markdown body.
|
|
7016
|
+
* YAML frontmatter for doc nodes (§6.3): `name`, `description?`, `position?` above
|
|
7017
|
+
* the markdown body.
|
|
6475
7018
|
*
|
|
6476
7019
|
* Writing is deterministic (§6.6): a fixed key order and LF endings, so re-exporting
|
|
6477
7020
|
* an unchanged doc is byte-identical.
|
|
@@ -6523,6 +7066,78 @@ const serializeDoc = (fields, body) => {
|
|
|
6523
7066
|
return `${DELIMITER}\n${yaml}\n${DELIMITER}\n\n${normalizeDocBody(body)}\n`;
|
|
6524
7067
|
};
|
|
6525
7068
|
//#endregion
|
|
7069
|
+
//#region ../../packages/busabase-package/src/ignore.ts
|
|
7070
|
+
const escapeLiteral = (value) => value.replace(/[.+^${}()|[\]\\]/g, "\\$&");
|
|
7071
|
+
/**
|
|
7072
|
+
* Translate one gitignore-ish pattern into a regex over a POSIX relative path.
|
|
7073
|
+
*
|
|
7074
|
+
* The three globs are handled in one pass so that `**` is recognised before the
|
|
7075
|
+
* `*` it starts with — splitting on `*` first would turn `**` into two
|
|
7076
|
+
* segment-local wildcards and quietly stop matching across directories.
|
|
7077
|
+
*/
|
|
7078
|
+
const patternToRegex = (pattern, anchored) => {
|
|
7079
|
+
let source = "";
|
|
7080
|
+
for (let index = 0; index < pattern.length; index += 1) {
|
|
7081
|
+
const char = pattern[index];
|
|
7082
|
+
if (char === "*") {
|
|
7083
|
+
if (pattern[index + 1] === "*") {
|
|
7084
|
+
index += 1;
|
|
7085
|
+
if (pattern[index + 1] === "/") {
|
|
7086
|
+
index += 1;
|
|
7087
|
+
source += "(?:.*/)?";
|
|
7088
|
+
} else source += ".*";
|
|
7089
|
+
} else source += "[^/]*";
|
|
7090
|
+
continue;
|
|
7091
|
+
}
|
|
7092
|
+
if (char === "?") {
|
|
7093
|
+
source += "[^/]";
|
|
7094
|
+
continue;
|
|
7095
|
+
}
|
|
7096
|
+
if (char === "/") {
|
|
7097
|
+
source += "/";
|
|
7098
|
+
continue;
|
|
7099
|
+
}
|
|
7100
|
+
source += escapeLiteral(char);
|
|
7101
|
+
}
|
|
7102
|
+
return new RegExp(`${anchored ? "^" : "^(?:.*/)?"}${source}(?:/.*)?$`);
|
|
7103
|
+
};
|
|
7104
|
+
const parseRule = (line) => {
|
|
7105
|
+
let pattern = line.trim();
|
|
7106
|
+
if (!pattern || pattern.startsWith("#")) return void 0;
|
|
7107
|
+
const negated = pattern.startsWith("!");
|
|
7108
|
+
if (negated) pattern = pattern.slice(1);
|
|
7109
|
+
const directoryOnly = pattern.endsWith("/");
|
|
7110
|
+
if (directoryOnly) pattern = pattern.slice(0, -1);
|
|
7111
|
+
const anchored = pattern.startsWith("/");
|
|
7112
|
+
if (anchored) pattern = pattern.slice(1);
|
|
7113
|
+
if (!pattern) return void 0;
|
|
7114
|
+
return {
|
|
7115
|
+
regex: patternToRegex(pattern, anchored),
|
|
7116
|
+
negated,
|
|
7117
|
+
directoryOnly
|
|
7118
|
+
};
|
|
7119
|
+
};
|
|
7120
|
+
const parseIgnoreFile = (contents) => {
|
|
7121
|
+
const rules = contents.split(/\r?\n/).map(parseRule).filter((rule) => rule !== void 0);
|
|
7122
|
+
return {
|
|
7123
|
+
isEmpty: rules.length === 0,
|
|
7124
|
+
ignores: (relativePath) => {
|
|
7125
|
+
let ignored = false;
|
|
7126
|
+
for (const rule of rules) {
|
|
7127
|
+
if (rule.directoryOnly && !rule.regex.test(`${relativePath}/`)) continue;
|
|
7128
|
+
if (!rule.regex.test(relativePath)) continue;
|
|
7129
|
+
ignored = !rule.negated;
|
|
7130
|
+
}
|
|
7131
|
+
return ignored;
|
|
7132
|
+
}
|
|
7133
|
+
};
|
|
7134
|
+
};
|
|
7135
|
+
/** A matcher that installs everything — used when no ignore file is present. */
|
|
7136
|
+
const IGNORE_NOTHING = {
|
|
7137
|
+
ignores: () => false,
|
|
7138
|
+
isEmpty: true
|
|
7139
|
+
};
|
|
7140
|
+
//#endregion
|
|
6526
7141
|
//#region ../../packages/busabase-package/src/layout-read.ts
|
|
6527
7142
|
/**
|
|
6528
7143
|
* Layout reader: a package's files (from a repo dir, or an extracted zip held in
|
|
@@ -6537,6 +7152,12 @@ const serializeDoc = (fields, body) => {
|
|
|
6537
7152
|
*
|
|
6538
7153
|
* Outside `content/`, the optional `assets/` directory carries the bytes for the
|
|
6539
7154
|
* images the Docs embed — see {@link readDocAssets}. Nothing in there is a node.
|
|
7155
|
+
*
|
|
7156
|
+
* Rule 0 sits ahead of all of them but reads a different place: a `SKILL.md` at
|
|
7157
|
+
* the package ROOT (beside the manifest, not under `content/`) makes this
|
|
7158
|
+
* package a *template*, and is lifted into `tree.rootSkill` — see
|
|
7159
|
+
* {@link readRootSkill}. `content/` itself is untouched by that rule, which is
|
|
7160
|
+
* what keeps a template byte-identical to a plain package below the root.
|
|
6540
7161
|
*/
|
|
6541
7162
|
const decodeText = (bytes, filePath) => {
|
|
6542
7163
|
const text = bytes.toString("utf8");
|
|
@@ -6602,13 +7223,65 @@ const readPackageTree = (files, options = {}) => {
|
|
|
6602
7223
|
assertKnownFormat(rawManifest, manifestPath);
|
|
6603
7224
|
const manifest = zodParse(PackageManifestSchema, rawManifest, manifestPath);
|
|
6604
7225
|
const contentDir = `${root}${PACKAGE_CONTENT_DIRNAME}/`;
|
|
7226
|
+
const nodes = readNodes(files, contentDir);
|
|
7227
|
+
const rootSkill = readRootSkill(files, root, manifest.name, manifest.description);
|
|
6605
7228
|
return {
|
|
6606
7229
|
manifest,
|
|
6607
|
-
nodes
|
|
7230
|
+
nodes,
|
|
7231
|
+
...rootSkill ? { rootSkill } : {},
|
|
6608
7232
|
assets: readDocAssets(files, root)
|
|
6609
7233
|
};
|
|
6610
7234
|
};
|
|
6611
7235
|
/**
|
|
7236
|
+
* Rule 0 — the package root's `SKILL.md` plus its sidecar directories.
|
|
7237
|
+
*
|
|
7238
|
+
* Absent → a plain package, and every downstream consumer behaves exactly as it
|
|
7239
|
+
* did before. Present → the manual travels with the resources: the installer
|
|
7240
|
+
* turns this into a Skill node inside the target folder, which is what lets the
|
|
7241
|
+
* agent that a user talks to after installing actually know what the app is for.
|
|
7242
|
+
*
|
|
7243
|
+
* The node's slug comes from the MANIFEST name rather than the skill
|
|
7244
|
+
* frontmatter's, because the manifest name is already the default target folder
|
|
7245
|
+
* name and is validated as a slug elsewhere; the validator separately requires
|
|
7246
|
+
* the two names to agree, so this is not a silent tie-break.
|
|
7247
|
+
*/
|
|
7248
|
+
const readRootSkill = (files, root, manifestName, manifestDescription) => {
|
|
7249
|
+
const entryPath = `${root}${PACKAGE_SKILL_ENTRY}`;
|
|
7250
|
+
const entryBytes = files.get(entryPath);
|
|
7251
|
+
if (!entryBytes) return void 0;
|
|
7252
|
+
const entries = [{
|
|
7253
|
+
path: PACKAGE_SKILL_ENTRY,
|
|
7254
|
+
bytes: entryBytes
|
|
7255
|
+
}];
|
|
7256
|
+
for (const dirName of PACKAGE_SKILL_SIDECAR_DIRS) {
|
|
7257
|
+
const dir = `${root}${dirName}/`;
|
|
7258
|
+
for (const entry of collectSubtree(files, dir)) entries.push({
|
|
7259
|
+
path: `${dirName}/${entry.path}`,
|
|
7260
|
+
bytes: entry.bytes
|
|
7261
|
+
});
|
|
7262
|
+
}
|
|
7263
|
+
assertNoReservedNames(entries.map((entry) => entry.path), `root skill "${manifestName}"`);
|
|
7264
|
+
const parsed = parseSkillFrontmatterSafely(decodeText(entryBytes, entryPath), entryPath);
|
|
7265
|
+
return {
|
|
7266
|
+
slug: manifestName,
|
|
7267
|
+
name: parsed?.name ?? manifestName,
|
|
7268
|
+
description: parsed?.description ?? manifestDescription,
|
|
7269
|
+
files: entries.sort((a, b) => a.path.localeCompare(b.path, "en"))
|
|
7270
|
+
};
|
|
7271
|
+
};
|
|
7272
|
+
const parseSkillFrontmatterSafely = (text, filePath) => {
|
|
7273
|
+
try {
|
|
7274
|
+
const { data } = parseFrontmatter(text, filePath);
|
|
7275
|
+
const record = data;
|
|
7276
|
+
return {
|
|
7277
|
+
name: typeof record.name === "string" ? record.name : void 0,
|
|
7278
|
+
description: typeof record.description === "string" ? record.description : void 0
|
|
7279
|
+
};
|
|
7280
|
+
} catch {
|
|
7281
|
+
return;
|
|
7282
|
+
}
|
|
7283
|
+
};
|
|
7284
|
+
/**
|
|
6612
7285
|
* `assets/` — the bytes for the images the Docs embed. Driven off the sidecars,
|
|
6613
7286
|
* not off the byte files: the sidecar is what carries the source asset id, so a
|
|
6614
7287
|
* stray file with no `.asset.json` beside it is simply not an asset this package
|
|
@@ -6667,8 +7340,10 @@ const readDirNode = (files, dir, slug) => {
|
|
|
6667
7340
|
if (files.has(`${dir}_node.json`)) {
|
|
6668
7341
|
const metaPath = `${dir}${PACKAGE_NODE_META_FILENAME}`;
|
|
6669
7342
|
const meta = zodParse(PackageFileTreeNodeMetaSchema, parseJsonFile(files, metaPath), metaPath);
|
|
6670
|
-
const
|
|
7343
|
+
const ignore = readIgnoreFile(files, dir);
|
|
7344
|
+
const entries = collectSubtree(files, dir).filter((entry) => entry.path !== "_node.json" && entry.path !== ".busabaseignore" && !ignore.ignores(entry.path));
|
|
6671
7345
|
assertNoReservedNames(entries.map((entry) => entry.path), `${meta.type} "${slug}"`);
|
|
7346
|
+
if (meta.type === "airapp" && !entries.some((entry) => entry.path === "package.json")) throw new Error(`AirApp "${slug}" has no package.json to install${ignore.isEmpty ? "" : ` — check ${dir}${PACKAGE_AIRAPP_IGNORE}, which must never exclude it`}. Nothing was installed.`);
|
|
6672
7347
|
return {
|
|
6673
7348
|
type: meta.type,
|
|
6674
7349
|
slug,
|
|
@@ -6707,6 +7382,19 @@ const readDirNode = (files, dir, slug) => {
|
|
|
6707
7382
|
children: readNodes(files, dir)
|
|
6708
7383
|
};
|
|
6709
7384
|
};
|
|
7385
|
+
/**
|
|
7386
|
+
* `.busabaseignore` inside a file-tree node directory.
|
|
7387
|
+
*
|
|
7388
|
+
* Read here rather than at the package level because the exclusions are a
|
|
7389
|
+
* property of one project (an AirApp's `test/` is noise; a Drive's files are
|
|
7390
|
+
* the point), and a package-wide ignore list would make it possible to
|
|
7391
|
+
* accidentally strip another node's content from a distance.
|
|
7392
|
+
*/
|
|
7393
|
+
const readIgnoreFile = (files, dir) => {
|
|
7394
|
+
const bytes = files.get(`${dir}${PACKAGE_AIRAPP_IGNORE}`);
|
|
7395
|
+
if (!bytes) return IGNORE_NOTHING;
|
|
7396
|
+
return parseIgnoreFile(decodeText(bytes, `${dir}${PACKAGE_AIRAPP_IGNORE}`));
|
|
7397
|
+
};
|
|
6710
7398
|
const readRecords = (files, dir, slug) => {
|
|
6711
7399
|
const recordsPath = `${dir}${PACKAGE_RECORDS_FILENAME}`;
|
|
6712
7400
|
const bytes = files.get(recordsPath);
|
|
@@ -6782,199 +7470,157 @@ const stripExtension = (fileName) => {
|
|
|
6782
7470
|
return dot > 0 ? fileName.slice(0, dot) : fileName;
|
|
6783
7471
|
};
|
|
6784
7472
|
//#endregion
|
|
6785
|
-
//#region ../../packages/busabase-package/src/
|
|
7473
|
+
//#region ../../packages/busabase-package/src/template.ts
|
|
6786
7474
|
/**
|
|
6787
|
-
*
|
|
6788
|
-
* (§6.6). "Clean diffs" is a feature: for an unchanged space, `export` twice must
|
|
6789
|
-
* produce byte-identical output, so re-exporting after one record edit diffs as
|
|
6790
|
-
* exactly one changed NDJSON line.
|
|
7475
|
+
* Is this package a **template** — a package that is also an Agent Skill?
|
|
6791
7476
|
*
|
|
6792
|
-
*
|
|
6793
|
-
*
|
|
6794
|
-
*
|
|
6795
|
-
*
|
|
6796
|
-
*
|
|
6797
|
-
*
|
|
7477
|
+
* One function answers that for all four consumers, which is the point: the
|
|
7478
|
+
* Template Center's index CI, `busabase-cli install`, the dashboard's install
|
|
7479
|
+
* preview, and an agent checking its own output must never disagree about what
|
|
7480
|
+
* counts. A skill that shows up as a card but installs as a plain package (or
|
|
7481
|
+
* the reverse) is a trust bug, not a cosmetic one.
|
|
7482
|
+
*
|
|
7483
|
+
* Purely static: no AirApp is started, no script is executed, no server is
|
|
7484
|
+
* contacted. That is what lets it run in CI over a whole repo and inline in an
|
|
7485
|
+
* install preview without a timeout budget.
|
|
7486
|
+
*
|
|
7487
|
+
* **Hard conditions** (any failure ⇒ not a template). They are deliberately few,
|
|
7488
|
+
* and every one of them is something that would otherwise become a confusing
|
|
7489
|
+
* runtime failure for the installing user rather than a publishing error for
|
|
7490
|
+
* the author.
|
|
7491
|
+
*
|
|
7492
|
+
* **Soft conditions** are warnings: the package still installs and still lists,
|
|
7493
|
+
* the card is just honest about what is missing. A data-only template is a
|
|
7494
|
+
* legitimate thing to publish; one that silently claims to have an app is not.
|
|
7495
|
+
*
|
|
7496
|
+
* Crucially, a package that fails validation is NOT rejected from installing —
|
|
7497
|
+
* it installs as a plain `busabase-package@1`, exactly as it did before
|
|
7498
|
+
* templates existed. Failing to be a template is never a reason to refuse a
|
|
7499
|
+
* valid package.
|
|
7500
|
+
*
|
|
7501
|
+
* Spec: `apps/busabase/content/spec/template-center.md` §5.3.
|
|
6798
7502
|
*/
|
|
6799
7503
|
/**
|
|
6800
|
-
*
|
|
6801
|
-
*
|
|
7504
|
+
* `PackageFileTreeNode.type` is the whole skill|airapp|drive union rather than a
|
|
7505
|
+
* literal, so a discriminated-union `Extract` narrows to `never` here — the
|
|
7506
|
+
* predicate does the narrowing instead.
|
|
6802
7507
|
*/
|
|
6803
|
-
const
|
|
6804
|
-
|
|
6805
|
-
|
|
6806
|
-
|
|
6807
|
-
|
|
6808
|
-
|
|
6809
|
-
|
|
6810
|
-
|
|
6811
|
-
|
|
6812
|
-
|
|
6813
|
-
|
|
6814
|
-
|
|
6815
|
-
|
|
6816
|
-
|
|
6817
|
-
|
|
6818
|
-
|
|
6819
|
-
|
|
6820
|
-
|
|
6821
|
-
|
|
6822
|
-
})
|
|
6823
|
-
|
|
6824
|
-
|
|
6825
|
-
|
|
6826
|
-
|
|
6827
|
-
|
|
6828
|
-
|
|
6829
|
-
|
|
6830
|
-
});
|
|
6831
|
-
const
|
|
6832
|
-
|
|
6833
|
-
|
|
6834
|
-
|
|
6835
|
-
|
|
6836
|
-
|
|
6837
|
-
|
|
6838
|
-
|
|
6839
|
-
|
|
6840
|
-
|
|
6841
|
-
|
|
6842
|
-
|
|
6843
|
-
|
|
6844
|
-
|
|
6845
|
-
|
|
6846
|
-
|
|
6847
|
-
|
|
6848
|
-
}
|
|
6849
|
-
|
|
6850
|
-
|
|
6851
|
-
|
|
6852
|
-
|
|
6853
|
-
|
|
6854
|
-
|
|
6855
|
-
|
|
6856
|
-
|
|
6857
|
-
|
|
6858
|
-
|
|
6859
|
-
|
|
6860
|
-
|
|
6861
|
-
|
|
6862
|
-
|
|
6863
|
-
|
|
6864
|
-
|
|
6865
|
-
}
|
|
6866
|
-
|
|
6867
|
-
* Render a tree to package-relative paths + bytes, validating §6.3 as it goes.
|
|
6868
|
-
* Pure: no disk access, so the round-trip and determinism tests exercise it directly.
|
|
6869
|
-
*/
|
|
6870
|
-
const renderPackageTree = (tree) => {
|
|
6871
|
-
const files = /* @__PURE__ */ new Map();
|
|
6872
|
-
files.set(PACKAGE_MANIFEST_FILENAME, serializeManifest(tree.manifest));
|
|
6873
|
-
renderNodes(tree.nodes, `${PACKAGE_CONTENT_DIRNAME}/`, files);
|
|
6874
|
-
renderDocAssets(tree.assets ?? [], files);
|
|
6875
|
-
return files;
|
|
7508
|
+
const isAirApp = (node) => node.type === "airapp";
|
|
7509
|
+
const airAppSlugs = (tree) => [...walkNodes(tree.nodes)].filter(isAirApp).map((node) => node.slug);
|
|
7510
|
+
const baseSlugs = (tree) => new Set([...walkNodes(tree.nodes)].filter((node) => node.type === "base").map((node) => node.slug));
|
|
7511
|
+
const validateTemplate = (tree) => {
|
|
7512
|
+
const errors = [];
|
|
7513
|
+
const warnings = [];
|
|
7514
|
+
const template = tree.manifest.template;
|
|
7515
|
+
if (!tree.rootSkill) return {
|
|
7516
|
+
ok: false,
|
|
7517
|
+
errors: [`No ${PACKAGE_SKILL_ENTRY} at the package root — this is a plain package.`],
|
|
7518
|
+
warnings
|
|
7519
|
+
};
|
|
7520
|
+
const entry = tree.rootSkill.files.find((file) => file.path === PACKAGE_SKILL_ENTRY);
|
|
7521
|
+
let frontmatter;
|
|
7522
|
+
if (entry) try {
|
|
7523
|
+
const { data } = parseFrontmatter(entry.bytes.toString("utf8"), PACKAGE_SKILL_ENTRY);
|
|
7524
|
+
frontmatter = SkillFrontmatterSchema.safeParse(data);
|
|
7525
|
+
if (!frontmatter.success) errors.push(`${PACKAGE_SKILL_ENTRY} frontmatter is not a valid skill declaration: ${frontmatter.error.issues.map((issue) => `${issue.path.join(".") || "(root)"} ${issue.message}`).join("; ")}`);
|
|
7526
|
+
} catch (error) {
|
|
7527
|
+
errors.push(`${PACKAGE_SKILL_ENTRY}: ${error.message}`);
|
|
7528
|
+
}
|
|
7529
|
+
const skill = frontmatter?.success ? frontmatter.data : void 0;
|
|
7530
|
+
const busabaseMeta = skill?.metadata?.busabase;
|
|
7531
|
+
if (skill && busabaseMeta?.template !== true) errors.push(`${PACKAGE_SKILL_ENTRY} does not opt in as a template. Add \`metadata.busabase.template: true\` to publish it in the Template Center.`);
|
|
7532
|
+
if (!template) errors.push(`${PACKAGE_SKILL_ENTRY} declares a template but busabase.json has no \`template\` object (at least \`category\` is required).`);
|
|
7533
|
+
if (skill && skill.name !== tree.manifest.name) errors.push(`Name mismatch: ${PACKAGE_SKILL_ENTRY} declares "${skill.name}" but busabase.json declares "${tree.manifest.name}". They are the same app and must agree.`);
|
|
7534
|
+
const bases = baseSlugs(tree);
|
|
7535
|
+
for (const key of busabaseMeta?.resources ?? []) if (!bases.has(key)) errors.push(`${PACKAGE_SKILL_ENTRY} lists resource "${key}", but content/ has no Base with that slug. An agent told about a table that does not exist will write to the wrong one.`);
|
|
7536
|
+
const airapps = airAppSlugs(tree);
|
|
7537
|
+
const resolved = resolvePrimaryAirApp(template, airapps);
|
|
7538
|
+
let primaryAirApp;
|
|
7539
|
+
if ("slug" in resolved) primaryAirApp = resolved.slug;
|
|
7540
|
+
else if (airapps.length > 0 || template?.airapp || template?.airapps?.length) errors.push(resolved.error);
|
|
7541
|
+
for (const node of walkNodes(tree.nodes)) {
|
|
7542
|
+
if (!isAirApp(node)) continue;
|
|
7543
|
+
const manifestFile = node.files.find((file) => file.path === "package.json");
|
|
7544
|
+
if (!manifestFile) continue;
|
|
7545
|
+
let scripts;
|
|
7546
|
+
try {
|
|
7547
|
+
scripts = JSON.parse(manifestFile.bytes.toString("utf8")).scripts;
|
|
7548
|
+
} catch (error) {
|
|
7549
|
+
errors.push(`AirApp "${node.slug}": package.json is not valid JSON (${error.message}).`);
|
|
7550
|
+
continue;
|
|
7551
|
+
}
|
|
7552
|
+
if (typeof scripts?.dev !== "string") errors.push(`AirApp "${node.slug}": package.json has no \`dev\` script. Busabase starts an AirApp with \`npm run dev\`, so without it the app installs and then never boots.`);
|
|
7553
|
+
}
|
|
7554
|
+
if (airapps.length === 0) warnings.push("No AirApp — this template installs data only. Users will see tables, not an app.");
|
|
7555
|
+
if (template?.requires?.airapp && airapps.length === 0) errors.push("template.requires.airapp is true, but content/ contains no AirApp.");
|
|
7556
|
+
let hasSamples = false;
|
|
7557
|
+
for (const node of walkNodes(tree.nodes)) {
|
|
7558
|
+
if (node.type !== "base") continue;
|
|
7559
|
+
if (node.records.length > 0) hasSamples = true;
|
|
7560
|
+
if (node.records.length > 50) errors.push(`Base "${node.slug}" carries ${node.records.length} sample records, above the 50 per-Base limit for templates. A template seeds a demo; it does not ship a dataset.`);
|
|
7561
|
+
}
|
|
7562
|
+
if (!hasSamples) warnings.push("No sample records — the app will be empty on first open.");
|
|
7563
|
+
if (!template?.screenshots.length) warnings.push("No screenshots declared — the Template Center card will use a placeholder.");
|
|
7564
|
+
if (!template?.agentPrompts.length) warnings.push("No agentPrompts declared — \"Ask agent\" will open with an empty prompt.");
|
|
7565
|
+
return {
|
|
7566
|
+
ok: errors.length === 0,
|
|
7567
|
+
errors,
|
|
7568
|
+
warnings,
|
|
7569
|
+
...primaryAirApp ? { primaryAirApp } : {},
|
|
7570
|
+
...template ? { template } : {}
|
|
7571
|
+
};
|
|
6876
7572
|
};
|
|
6877
7573
|
/**
|
|
6878
|
-
*
|
|
6879
|
-
*
|
|
6880
|
-
*
|
|
6881
|
-
*
|
|
7574
|
+
* A starting SKILL.md for a folder that has resources but no manual yet.
|
|
7575
|
+
*
|
|
7576
|
+
* Deterministic, never generated by a model: this text becomes instructions an
|
|
7577
|
+
* agent will act on, and a plausible-sounding invention about what a table means
|
|
7578
|
+
* is worse than an obvious blank to fill in. Everything here is read off the
|
|
7579
|
+
* structure that actually exists — table names, their fields, whether there is
|
|
7580
|
+
* an app — and every judgement the author has to make is left as an explicit
|
|
7581
|
+
* TODO rather than guessed at.
|
|
7582
|
+
*
|
|
7583
|
+
* Used by `export --template` and by "Publish as template" in the dashboard.
|
|
6882
7584
|
*/
|
|
6883
|
-
const
|
|
6884
|
-
const
|
|
6885
|
-
|
|
6886
|
-
|
|
6887
|
-
|
|
6888
|
-
|
|
6889
|
-
|
|
6890
|
-
|
|
6891
|
-
|
|
6892
|
-
|
|
6893
|
-
|
|
6894
|
-
|
|
6895
|
-
|
|
6896
|
-
|
|
6897
|
-
}
|
|
6898
|
-
|
|
6899
|
-
|
|
6900
|
-
|
|
6901
|
-
|
|
6902
|
-
|
|
6903
|
-
|
|
6904
|
-
|
|
6905
|
-
|
|
6906
|
-
|
|
6907
|
-
|
|
6908
|
-
|
|
6909
|
-
|
|
6910
|
-
|
|
6911
|
-
|
|
6912
|
-
|
|
6913
|
-
|
|
6914
|
-
|
|
6915
|
-
|
|
6916
|
-
|
|
6917
|
-
|
|
6918
|
-
|
|
6919
|
-
|
|
6920
|
-
|
|
6921
|
-
|
|
6922
|
-
description: node.description,
|
|
6923
|
-
position: node.position
|
|
6924
|
-
}, node.body), "utf8"));
|
|
6925
|
-
return;
|
|
6926
|
-
case "base": {
|
|
6927
|
-
files.set(`${dir}${node.slug}/${PACKAGE_BASE_FILENAME}`, serializeBase(node.base, node.position));
|
|
6928
|
-
const records = serializeRecords(node.records);
|
|
6929
|
-
if (records.byteLength > 0) files.set(`${dir}${node.slug}/${PACKAGE_RECORDS_FILENAME}`, records);
|
|
6930
|
-
return;
|
|
6931
|
-
}
|
|
6932
|
-
case "skill":
|
|
6933
|
-
case "airapp":
|
|
6934
|
-
case "drive": {
|
|
6935
|
-
const nodeDir = `${dir}${node.slug}/`;
|
|
6936
|
-
assertNoReservedNames(node.files.map((entry) => entry.path), `${node.type} "${node.slug}"`);
|
|
6937
|
-
files.set(`${nodeDir}${PACKAGE_NODE_META_FILENAME}`, toJsonFile(compact({
|
|
6938
|
-
type: node.type,
|
|
6939
|
-
name: node.name,
|
|
6940
|
-
description: node.description || void 0,
|
|
6941
|
-
position: node.position
|
|
6942
|
-
})));
|
|
6943
|
-
for (const entry of [...node.files].sort((a, b) => a.path.localeCompare(b.path, "en"))) {
|
|
6944
|
-
assertSafeFilePath(entry.path, `${node.type} "${node.slug}"`);
|
|
6945
|
-
files.set(`${nodeDir}${entry.path}`, entry.bytes);
|
|
6946
|
-
}
|
|
6947
|
-
return;
|
|
6948
|
-
}
|
|
6949
|
-
case "file":
|
|
6950
|
-
assertSafeFilePath(node.fileName, `file node "${node.slug}"`);
|
|
6951
|
-
files.set(`${dir}${node.fileName}`, node.bytes);
|
|
6952
|
-
if (node.name !== node.fileName || node.description !== "" || node.position !== void 0) files.set(`${dir}${node.fileName}${PACKAGE_NODE_META_SUFFIX}`, toJsonFile(compact({
|
|
6953
|
-
name: node.name,
|
|
6954
|
-
description: node.description || void 0,
|
|
6955
|
-
position: node.position
|
|
6956
|
-
})));
|
|
6957
|
-
return;
|
|
6958
|
-
}
|
|
6959
|
-
};
|
|
6960
|
-
/** Write rendered files under `outDir`, creating directories as needed. */
|
|
6961
|
-
const writePackageFiles = async (files, outDir, options = {}) => {
|
|
6962
|
-
if (options.clean) {
|
|
6963
|
-
await rm(join(outDir, PACKAGE_CONTENT_DIRNAME), {
|
|
6964
|
-
recursive: true,
|
|
6965
|
-
force: true
|
|
6966
|
-
});
|
|
6967
|
-
await rm(join(outDir, PACKAGE_ASSETS_DIRNAME), {
|
|
6968
|
-
recursive: true,
|
|
6969
|
-
force: true
|
|
6970
|
-
});
|
|
6971
|
-
await rm(join(outDir, PACKAGE_MANIFEST_FILENAME), { force: true });
|
|
6972
|
-
}
|
|
6973
|
-
for (const [filePath, bytes] of [...files].sort(([a], [b]) => a.localeCompare(b, "en"))) {
|
|
6974
|
-
const target = join(outDir, filePath);
|
|
6975
|
-
await mkdir(dirname(target), { recursive: true });
|
|
6976
|
-
await writeFile(target, bytes);
|
|
6977
|
-
}
|
|
7585
|
+
const deriveSkillDraft = (tree) => {
|
|
7586
|
+
const nodes = [...walkNodes(tree.nodes)];
|
|
7587
|
+
const bases = nodes.filter((node) => node.type === "base");
|
|
7588
|
+
const airapps = nodes.filter(isAirApp);
|
|
7589
|
+
const name = tree.manifest.name;
|
|
7590
|
+
const description = tree.manifest.description || `TODO: one line on what ${name} does.`;
|
|
7591
|
+
const yamlString = (value) => JSON.stringify(value);
|
|
7592
|
+
const resourceLines = bases.map((node) => ` - ${yamlString(node.slug)}`).join("\n");
|
|
7593
|
+
const resourceDocs = bases.map((node) => {
|
|
7594
|
+
if (node.type !== "base") return "";
|
|
7595
|
+
const fields = node.base.fields.map((field) => field.slug).join(", ");
|
|
7596
|
+
return `- \`${node.slug}\`: ${node.description || "TODO: what this table is for."}${fields ? ` Fields: ${fields}.` : ""}`;
|
|
7597
|
+
}).filter(Boolean).join("\n");
|
|
7598
|
+
return `---
|
|
7599
|
+
name: ${yamlString(name)}
|
|
7600
|
+
description: ${yamlString(description)}
|
|
7601
|
+
metadata:
|
|
7602
|
+
busabase:
|
|
7603
|
+
template: true
|
|
7604
|
+
${bases.length ? ` resources:\n${resourceLines}\n` : ""}---
|
|
7605
|
+
|
|
7606
|
+
# ${name}
|
|
7607
|
+
|
|
7608
|
+
TODO: describe the job this app does, and when an agent should reach for it.
|
|
7609
|
+
|
|
7610
|
+
## Busabase resources
|
|
7611
|
+
|
|
7612
|
+
${resourceDocs || "TODO: list this app's tables and what each one holds."}
|
|
7613
|
+
${airapps.length ? `\n## App\n\n\`${airapps[0].slug}\` is the app's interface. Point the user at it rather than describing rows back to them.\n` : ""}
|
|
7614
|
+
## Boundary
|
|
7615
|
+
|
|
7616
|
+
TODO: state what this app must never do — send messages, call external APIs,
|
|
7617
|
+
merge its own proposals. Be explicit; an agent follows what is written here.
|
|
7618
|
+
|
|
7619
|
+
## Workflow
|
|
7620
|
+
|
|
7621
|
+
Propose changes as Change Requests and wait for a human to review them. Never
|
|
7622
|
+
review or merge your own proposal.
|
|
7623
|
+
`;
|
|
6978
7624
|
};
|
|
6979
7625
|
//#endregion
|
|
6980
7626
|
//#region ../../packages/busabase-package/src/plan.ts
|
|
@@ -7025,9 +7671,16 @@ const buildInstallPlan = (tree, target, options = {}) => {
|
|
|
7025
7671
|
const targetFolderSlug = options.intoFolder ?? suggestSlug(tree.manifest.name);
|
|
7026
7672
|
const warnings = [];
|
|
7027
7673
|
assertPackageNodeIdentitiesUnique(tree);
|
|
7028
|
-
const
|
|
7029
|
-
|
|
7030
|
-
|
|
7674
|
+
const isTemplate = validateTemplate(tree).ok;
|
|
7675
|
+
const prefixed = options.prefixBaseSlugs ?? isTemplate ? prefixBaseSlugs(tree, targetFolderSlug) : tree;
|
|
7676
|
+
const resourceKeysBySlug = {};
|
|
7677
|
+
for (const [installedSlug, originalSlug] of collectSlugRewrites(tree, prefixed)) resourceKeysBySlug[installedSlug] = originalSlug;
|
|
7678
|
+
const collisions = findCollisions(prefixed, targetFolderSlug, target);
|
|
7679
|
+
let planned = prefixed;
|
|
7680
|
+
if (options.rename && collisions.length > 0) {
|
|
7681
|
+
planned = applyRenames(prefixed, collisions, target.existingNodeSlugsByType);
|
|
7682
|
+
for (const [installedSlug, originalSlug] of collectSlugRewrites(tree, planned)) resourceKeysBySlug[installedSlug] = originalSlug;
|
|
7683
|
+
}
|
|
7031
7684
|
collectWarnings(planned, warnings);
|
|
7032
7685
|
const counts = countTree(planned);
|
|
7033
7686
|
if (counts.files > 5e3) throw new Error(`Package has ${counts.files} files, above the ${PACKAGE_MAX_FILE_COUNT}-file limit. Nothing was installed.`);
|
|
@@ -7038,8 +7691,79 @@ const buildInstallPlan = (tree, target, options = {}) => {
|
|
|
7038
7691
|
collisions,
|
|
7039
7692
|
warnings,
|
|
7040
7693
|
counts,
|
|
7041
|
-
requiresAutoMerge: collectBaseNodes(planned.nodes).some(hasUnlinkableRelationValues)
|
|
7694
|
+
requiresAutoMerge: collectBaseNodes(planned.nodes).some(hasUnlinkableRelationValues) && !(isTemplate && (options.installSampleRecords ?? true)),
|
|
7695
|
+
resourceKeysBySlug,
|
|
7696
|
+
isTemplate
|
|
7697
|
+
};
|
|
7698
|
+
};
|
|
7699
|
+
/**
|
|
7700
|
+
* Rewrite every Base slug to `<folder>-<slug>`, following relation targets.
|
|
7701
|
+
*
|
|
7702
|
+
* A Base already carrying the prefix is left alone, so re-planning an installed
|
|
7703
|
+
* package (an upgrade, a retry) does not stack `kelly-email-kelly-email-…`.
|
|
7704
|
+
* Only Bases are prefixed: every other node type is scoped to its parent
|
|
7705
|
+
* folder, so only Bases can collide across folders in the first place.
|
|
7706
|
+
*/
|
|
7707
|
+
const prefixBaseSlugs = (tree, folderSlug) => {
|
|
7708
|
+
const renames = /* @__PURE__ */ new Map();
|
|
7709
|
+
for (const node of walkNodes(tree.nodes)) {
|
|
7710
|
+
if (node.type !== "base") continue;
|
|
7711
|
+
if (node.slug === folderSlug || node.slug.startsWith(`${folderSlug}-`)) continue;
|
|
7712
|
+
renames.set(node.slug, `${folderSlug}-${node.slug}`);
|
|
7713
|
+
}
|
|
7714
|
+
if (renames.size === 0) return tree;
|
|
7715
|
+
const rewriteField = (field) => {
|
|
7716
|
+
const target = field.options.targetBaseSlug;
|
|
7717
|
+
if (!target) return field;
|
|
7718
|
+
const renamed = renames.get(target);
|
|
7719
|
+
return renamed ? {
|
|
7720
|
+
...field,
|
|
7721
|
+
options: {
|
|
7722
|
+
...field.options,
|
|
7723
|
+
targetBaseSlug: renamed
|
|
7724
|
+
}
|
|
7725
|
+
} : field;
|
|
7726
|
+
};
|
|
7727
|
+
const rewrite = (node) => {
|
|
7728
|
+
if (node.type === "folder") return {
|
|
7729
|
+
...node,
|
|
7730
|
+
children: node.children.map(rewrite)
|
|
7731
|
+
};
|
|
7732
|
+
if (node.type !== "base") return node;
|
|
7733
|
+
return {
|
|
7734
|
+
...node,
|
|
7735
|
+
slug: renames.get(node.slug) ?? node.slug,
|
|
7736
|
+
base: {
|
|
7737
|
+
...node.base,
|
|
7738
|
+
fields: node.base.fields.map(rewriteField)
|
|
7739
|
+
}
|
|
7740
|
+
};
|
|
7741
|
+
};
|
|
7742
|
+
return {
|
|
7743
|
+
...tree,
|
|
7744
|
+
nodes: tree.nodes.map(rewrite)
|
|
7745
|
+
};
|
|
7746
|
+
};
|
|
7747
|
+
/**
|
|
7748
|
+
* Pair up two trees that differ only in slugs, walking them in lockstep.
|
|
7749
|
+
*
|
|
7750
|
+
* Position-based rather than slug-based on purpose: after a rewrite the slugs
|
|
7751
|
+
* are exactly what no longer match, so they cannot be the join key. Both trees
|
|
7752
|
+
* come from the same source and every rewrite preserves order, so position is
|
|
7753
|
+
* stable.
|
|
7754
|
+
*/
|
|
7755
|
+
const collectSlugRewrites = (original, rewritten) => {
|
|
7756
|
+
const pairs = [];
|
|
7757
|
+
const walk = (before, after) => {
|
|
7758
|
+
for (const [index, beforeNode] of before.entries()) {
|
|
7759
|
+
const afterNode = after[index];
|
|
7760
|
+
if (!afterNode) continue;
|
|
7761
|
+
pairs.push([afterNode.slug, beforeNode.slug]);
|
|
7762
|
+
if (beforeNode.type === "folder" && afterNode.type === "folder") walk(beforeNode.children, afterNode.children);
|
|
7763
|
+
}
|
|
7042
7764
|
};
|
|
7765
|
+
walk(original.nodes, rewritten.nodes);
|
|
7766
|
+
return pairs;
|
|
7043
7767
|
};
|
|
7044
7768
|
/**
|
|
7045
7769
|
* The ONLY thing that genuinely can't survive a review-first install: a record that
|
|
@@ -7183,65 +7907,632 @@ const countTree = (tree) => {
|
|
|
7183
7907
|
counts.records += node.records.length;
|
|
7184
7908
|
break;
|
|
7185
7909
|
case "skill":
|
|
7186
|
-
counts.skills++;
|
|
7187
|
-
counts.files += node.files.length;
|
|
7188
|
-
break;
|
|
7910
|
+
counts.skills++;
|
|
7911
|
+
counts.files += node.files.length;
|
|
7912
|
+
break;
|
|
7913
|
+
case "airapp":
|
|
7914
|
+
counts.airapps++;
|
|
7915
|
+
counts.files += node.files.length;
|
|
7916
|
+
break;
|
|
7917
|
+
case "drive":
|
|
7918
|
+
counts.drives++;
|
|
7919
|
+
counts.files += node.files.length;
|
|
7920
|
+
break;
|
|
7921
|
+
case "file": counts.files++;
|
|
7922
|
+
}
|
|
7923
|
+
counts.files += (tree.assets ?? []).length;
|
|
7924
|
+
return counts;
|
|
7925
|
+
};
|
|
7926
|
+
/** §4: "What will this do?" — the `--dry-run` report. Creates nothing. */
|
|
7927
|
+
const renderPlan = (plan) => {
|
|
7928
|
+
const lines = [];
|
|
7929
|
+
lines.push(`Package: ${plan.tree.manifest.name}`);
|
|
7930
|
+
if (plan.tree.manifest.description) lines.push(` ${plan.tree.manifest.description}`);
|
|
7931
|
+
lines.push("");
|
|
7932
|
+
lines.push(`Target folder: ${plan.targetFolderSlug}`);
|
|
7933
|
+
lines.push("");
|
|
7934
|
+
lines.push("Node tree:");
|
|
7935
|
+
for (const line of renderNodeTree(plan.tree.nodes, " ")) lines.push(line);
|
|
7936
|
+
lines.push("");
|
|
7937
|
+
lines.push(`Totals: ${plan.counts.folders} folder(s), ${plan.counts.docs} doc(s), ${plan.counts.bases} base(s) with ${plan.counts.records} record(s), ${plan.counts.skills} skill(s), ${plan.counts.airapps} airapp(s), ${plan.counts.drives} drive(s), ${plan.counts.files} file(s)`);
|
|
7938
|
+
if (plan.collisions.length > 0) {
|
|
7939
|
+
lines.push("");
|
|
7940
|
+
lines.push("Collisions:");
|
|
7941
|
+
for (const collision of plan.collisions) lines.push(collision.renamedTo ? ` • ${collision.kind} "${collision.slug}" already exists → will be created as "${collision.renamedTo}"` : ` • ${collision.kind} "${collision.slug}" already exists (${collision.path})`);
|
|
7942
|
+
}
|
|
7943
|
+
if (plan.warnings.length > 0) {
|
|
7944
|
+
lines.push("");
|
|
7945
|
+
lines.push("Warnings:");
|
|
7946
|
+
for (const warning of plan.warnings) lines.push(` • ${warning}`);
|
|
7947
|
+
}
|
|
7948
|
+
return lines.join("\n");
|
|
7949
|
+
};
|
|
7950
|
+
const renderNodeTree = (nodes, indent) => {
|
|
7951
|
+
const lines = [];
|
|
7952
|
+
for (const node of nodes) {
|
|
7953
|
+
const detail = node.type === "base" ? ` (base, ${node.base.fields.length} field(s), ${node.records.length} record(s))` : node.type === "folder" ? " (folder)" : node.type === "doc" ? " (doc)" : node.type === "file" ? " (file)" : ` (${node.type}, ${node.files.length} file(s))`;
|
|
7954
|
+
lines.push(`${indent}${node.slug}${detail}`);
|
|
7955
|
+
if (node.type === "folder") lines.push(...renderNodeTree(node.children, `${indent} `));
|
|
7956
|
+
}
|
|
7957
|
+
return lines;
|
|
7958
|
+
};
|
|
7959
|
+
/**
|
|
7960
|
+
* §4 + §12: without `--auto-merge` an unmerged base has no field ids to patch and no
|
|
7961
|
+
* records to link, so passes 2-5 cannot run. Fail fast and say exactly why.
|
|
7962
|
+
*/
|
|
7963
|
+
const assertPlanIsApplicable = (plan, autoMerge) => {
|
|
7964
|
+
if (plan.collisions.some((collision) => !collision.renamedTo)) {
|
|
7965
|
+
const list = plan.collisions.filter((collision) => !collision.renamedTo).map((collision) => ` • ${collision.kind} "${collision.slug}" (${collision.path})`).join("\n");
|
|
7966
|
+
throw new Error(`Install would collide with content that already exists:\n${list}\n\nNothing was created. Re-run with --rename to install the colliding items under suffixed slugs (e.g. "-2"), or pick a different --into-folder.`);
|
|
7967
|
+
}
|
|
7968
|
+
if (plan.requiresAutoMerge && !autoMerge) throw new Error("This package's records carry relation values, and a relation stores the ids of the records it points at — which only exist once the records are merged. Installing review-first would leave every relation empty.\n\nRe-run with --auto-merge to install it (this trusts the package author: records are created immediately instead of landing as change requests you review first).");
|
|
7969
|
+
};
|
|
7970
|
+
//#endregion
|
|
7971
|
+
//#region ../../packages/busabase-package/src/discover.ts
|
|
7972
|
+
/**
|
|
7973
|
+
* What is at the end of this URL — one package, one template, or a repository
|
|
7974
|
+
* full of them?
|
|
7975
|
+
*
|
|
7976
|
+
* A user pasting a GitHub URL does not think in terms of "package roots". They
|
|
7977
|
+
* paste the repo they were looking at. Sometimes that repo IS the package;
|
|
7978
|
+
* sometimes it is `busabase/skills`, which holds dozens under `skills/*`. Before
|
|
7979
|
+
* this, the second case was only ever an error message with a list of
|
|
7980
|
+
* subdirectories to paste instead — correct, and a dead end for anyone who did
|
|
7981
|
+
* not want to edit a URL by hand.
|
|
7982
|
+
*
|
|
7983
|
+
* Discovery answers it once for every caller: the CLI turns the result into a
|
|
7984
|
+
* pick-one list, the dashboard's install dialog into a chooser, the Template
|
|
7985
|
+
* Center's index CI into the set of cards to publish. All three must agree on
|
|
7986
|
+
* what counts as a template, which is why the verdict comes from the same
|
|
7987
|
+
* `validateTemplate` they would each otherwise re-implement.
|
|
7988
|
+
*
|
|
7989
|
+
* Spec: `apps/busabase/content/spec/template-center.md` §6.2a.
|
|
7990
|
+
*/
|
|
7991
|
+
/**
|
|
7992
|
+
* Only one level deep, matching the `skills/<name>/` layout the skills CLI
|
|
7993
|
+
* already established and the Template Center reads. Walking arbitrarily deep
|
|
7994
|
+
* would turn one paste into an unbounded scan of a monorepo and surface vendored
|
|
7995
|
+
* copies inside `node_modules` as installable products.
|
|
7996
|
+
*/
|
|
7997
|
+
const MAX_DISCOVERY_DEPTH = 2;
|
|
7998
|
+
const manifestDirectories = (files) => {
|
|
7999
|
+
const dirs = /* @__PURE__ */ new Set();
|
|
8000
|
+
for (const filePath of files.keys()) {
|
|
8001
|
+
if (filePath === "busabase.json") {
|
|
8002
|
+
dirs.add("");
|
|
8003
|
+
continue;
|
|
8004
|
+
}
|
|
8005
|
+
if (!filePath.endsWith(`/busabase.json`)) continue;
|
|
8006
|
+
const dir = filePath.slice(0, -14);
|
|
8007
|
+
if (dir.split("/").length > MAX_DISCOVERY_DEPTH) continue;
|
|
8008
|
+
dirs.add(dir);
|
|
8009
|
+
}
|
|
8010
|
+
return [...dirs].sort((a, b) => a.localeCompare(b, "en"));
|
|
8011
|
+
};
|
|
8012
|
+
/**
|
|
8013
|
+
* Every package at or below the fetched root, root first.
|
|
8014
|
+
*
|
|
8015
|
+
* A directory whose manifest or content does not parse is SKIPPED rather than
|
|
8016
|
+
* failing the whole discovery: one malformed skill in a repository of fifty must
|
|
8017
|
+
* not make the other forty-nine unreachable. The user is choosing from a list,
|
|
8018
|
+
* and an entry that cannot be read has nothing to offer that list.
|
|
8019
|
+
*/
|
|
8020
|
+
const discoverPackages = (files) => {
|
|
8021
|
+
const found = [];
|
|
8022
|
+
for (const subdir of manifestDirectories(files)) {
|
|
8023
|
+
if (found.length >= 200) break;
|
|
8024
|
+
let tree;
|
|
8025
|
+
try {
|
|
8026
|
+
tree = readPackageTree(files, subdir ? { root: subdir } : {});
|
|
8027
|
+
} catch {
|
|
8028
|
+
continue;
|
|
8029
|
+
}
|
|
8030
|
+
const validation = validateTemplate(tree);
|
|
8031
|
+
const claimsTemplate = Boolean(tree.rootSkill) || Boolean(tree.manifest.template);
|
|
8032
|
+
found.push({
|
|
8033
|
+
subdir,
|
|
8034
|
+
name: tree.manifest.name,
|
|
8035
|
+
description: tree.manifest.description,
|
|
8036
|
+
isTemplate: validation.ok,
|
|
8037
|
+
templateErrors: validation.ok || !claimsTemplate ? [] : validation.errors,
|
|
8038
|
+
counts: countTree(tree),
|
|
8039
|
+
...validation.primaryAirApp ? { primaryAirApp: validation.primaryAirApp } : {},
|
|
8040
|
+
...tree.manifest.template?.category ? { category: tree.manifest.template.category } : {},
|
|
8041
|
+
screenshots: tree.manifest.template?.screenshots ?? [],
|
|
8042
|
+
tags: [.../* @__PURE__ */ new Set([...tree.manifest.tags, ...tree.manifest.template?.tags ?? []])],
|
|
8043
|
+
agentPrompts: tree.manifest.template?.agentPrompts ?? [],
|
|
8044
|
+
...tree.manifest.version ? { version: tree.manifest.version } : {},
|
|
8045
|
+
...tree.manifest.author ? { author: tree.manifest.author } : {},
|
|
8046
|
+
...tree.manifest.license ? { license: tree.manifest.license } : {}
|
|
8047
|
+
});
|
|
8048
|
+
}
|
|
8049
|
+
return found;
|
|
8050
|
+
};
|
|
8051
|
+
/**
|
|
8052
|
+
* Resolve a pasted URL to the ONE package to install, or explain the choice.
|
|
8053
|
+
*
|
|
8054
|
+
* `wanted` is a name or subdir the caller already picked (the CLI's `--skill`,
|
|
8055
|
+
* the dialog's selection). Matching on either is deliberate: a user reading a
|
|
8056
|
+
* list sees names, a user reading a repo sees directories, and asking them to
|
|
8057
|
+
* know which one the tool wants is a distinction without a purpose.
|
|
8058
|
+
*/
|
|
8059
|
+
const resolvePackageToInstall = (files, wanted) => {
|
|
8060
|
+
const discovered = discoverPackages(files);
|
|
8061
|
+
if (discovered.length === 0) return { kind: "none" };
|
|
8062
|
+
if (wanted) {
|
|
8063
|
+
const match = discovered.find((entry) => entry.name === wanted || entry.subdir === wanted || entry.subdir.endsWith(`/${wanted}`));
|
|
8064
|
+
if (match) return {
|
|
8065
|
+
kind: "package",
|
|
8066
|
+
subdir: match.subdir,
|
|
8067
|
+
discovered: match
|
|
8068
|
+
};
|
|
8069
|
+
return {
|
|
8070
|
+
kind: "choose",
|
|
8071
|
+
candidates: discovered
|
|
8072
|
+
};
|
|
8073
|
+
}
|
|
8074
|
+
const root = discovered.find((entry) => entry.subdir === "");
|
|
8075
|
+
if (root) return {
|
|
8076
|
+
kind: "package",
|
|
8077
|
+
subdir: "",
|
|
8078
|
+
discovered: root
|
|
8079
|
+
};
|
|
8080
|
+
if (discovered.length === 1) return {
|
|
8081
|
+
kind: "package",
|
|
8082
|
+
subdir: discovered[0].subdir,
|
|
8083
|
+
discovered: discovered[0]
|
|
8084
|
+
};
|
|
8085
|
+
return {
|
|
8086
|
+
kind: "choose",
|
|
8087
|
+
candidates: discovered
|
|
8088
|
+
};
|
|
8089
|
+
};
|
|
8090
|
+
//#endregion
|
|
8091
|
+
//#region ../../packages/busabase-package/src/github.ts
|
|
8092
|
+
/**
|
|
8093
|
+
* GitHub source for `busabase-cli install`: parse a repo URL, fetch the ref's
|
|
8094
|
+
* zipball, and extract it into memory.
|
|
8095
|
+
*
|
|
8096
|
+
* Ported from Buda's GitHub skill importer (Buda stays as-is; a shared package
|
|
8097
|
+
* is warranted only once the Phase-2 server consumer actually exists). Three
|
|
8098
|
+
* deliberate divergences from that original:
|
|
8099
|
+
*
|
|
8100
|
+
* 1. **Host is parsed, not pattern-matched.** buda's regex matches `github.com/…`
|
|
8101
|
+
* anywhere in the string, so `https://evil.example/github.com/o/r` passes. This
|
|
8102
|
+
* parses with `URL` and checks `hostname` against an allowlist.
|
|
8103
|
+
* 2. **Any git ref, not just branches.** buda fetches `/archive/refs/heads/<b>.zip`,
|
|
8104
|
+
* which resolves branches only — tags (the package format's version scheme)
|
|
8105
|
+
* would 404. This uses codeload's `/zip/<ref>`, which takes a branch, tag, or SHA.
|
|
8106
|
+
* 3. **Extraction is to memory with the caps applied first**, so an oversized or
|
|
8107
|
+
* zip-bomb archive never reaches the disk.
|
|
8108
|
+
*/
|
|
8109
|
+
/** Hosts the installer may ever connect to. Also the Phase-2 server guard's allowlist. */
|
|
8110
|
+
const GITHUB_ALLOWED_HOSTS = [
|
|
8111
|
+
"github.com",
|
|
8112
|
+
"api.github.com",
|
|
8113
|
+
"codeload.github.com"
|
|
8114
|
+
];
|
|
8115
|
+
/** Hosts accepted in a user-supplied install URL (the web UI form of a repo). */
|
|
8116
|
+
const GITHUB_URL_HOSTS = ["github.com", "www.github.com"];
|
|
8117
|
+
const isAllowedGithubHost = (hostname) => GITHUB_ALLOWED_HOSTS.includes(hostname.toLowerCase());
|
|
8118
|
+
/**
|
|
8119
|
+
* Parse a GitHub repo URL.
|
|
8120
|
+
*
|
|
8121
|
+
* Supported forms:
|
|
8122
|
+
* https://github.com/owner/repo
|
|
8123
|
+
* https://github.com/owner/repo.git
|
|
8124
|
+
* https://github.com/owner/repo/tree/<ref>
|
|
8125
|
+
* https://github.com/owner/repo/tree/<ref>/<subdir…>
|
|
8126
|
+
*
|
|
8127
|
+
* A ref containing `/` (e.g. `feature/x`) is indistinguishable from `<ref>/<subdir>`
|
|
8128
|
+
* in a `/tree/` URL — GitHub's own web URLs have the same ambiguity — so the first
|
|
8129
|
+
* segment after `/tree/` is taken as the ref and the rest as the subdir.
|
|
8130
|
+
*/
|
|
8131
|
+
const parseGithubUrl = (rawUrl) => {
|
|
8132
|
+
let url;
|
|
8133
|
+
try {
|
|
8134
|
+
url = new URL(rawUrl.trim());
|
|
8135
|
+
} catch {
|
|
8136
|
+
throw new Error(`Not a valid URL: "${rawUrl}". Expected a GitHub repo URL, e.g. https://github.com/acme/support-kb-template`);
|
|
8137
|
+
}
|
|
8138
|
+
if (url.protocol !== "https:" && url.protocol !== "http:") throw new Error(`Unsupported URL scheme "${url.protocol}" — expected an https:// GitHub URL.`);
|
|
8139
|
+
if (!GITHUB_URL_HOSTS.includes(url.hostname.toLowerCase())) throw new Error(`Not a GitHub URL: host "${url.hostname}" is not github.com. Only GitHub packages can be installed.`);
|
|
8140
|
+
const [owner, rawRepo, marker, ref, ...rest] = url.pathname.split("/").filter(Boolean);
|
|
8141
|
+
if (!owner || !rawRepo) throw new Error(`GitHub URL is missing owner/repo: "${rawUrl}". Expected https://github.com/<owner>/<repo>[/tree/<ref>[/<subdir>]]`);
|
|
8142
|
+
const repo = rawRepo.replace(/\.git$/, "");
|
|
8143
|
+
if (!marker) return {
|
|
8144
|
+
owner,
|
|
8145
|
+
repo,
|
|
8146
|
+
ref: void 0,
|
|
8147
|
+
subdir: void 0
|
|
8148
|
+
};
|
|
8149
|
+
if (marker !== "tree") throw new Error(`Unsupported GitHub URL form "/${marker}/" in "${rawUrl}". Expected https://github.com/<owner>/<repo>[/tree/<ref>[/<subdir>]]`);
|
|
8150
|
+
if (!ref) throw new Error(`GitHub URL has "/tree/" but no ref: "${rawUrl}".`);
|
|
8151
|
+
return {
|
|
8152
|
+
owner,
|
|
8153
|
+
repo,
|
|
8154
|
+
ref: decodeURIComponent(ref),
|
|
8155
|
+
subdir: rest.length > 0 ? rest.map((segment) => decodeURIComponent(segment)).join("/") : void 0
|
|
8156
|
+
};
|
|
8157
|
+
};
|
|
8158
|
+
/** Encode a ref for a URL path while keeping its `/` separators (e.g. `feature/x`). */
|
|
8159
|
+
const encodeRefPath = (ref) => ref.split("/").map(encodeURIComponent).join("/");
|
|
8160
|
+
/**
|
|
8161
|
+
* Download the zipball for `owner/repo` at `ref` (default branch when `ref` is
|
|
8162
|
+
* undefined). Tries the public codeload host first, then the authenticated API when
|
|
8163
|
+
* a token is available — so a private repo works iff `GITHUB_TOKEN` is set.
|
|
8164
|
+
*/
|
|
8165
|
+
const downloadGithubZip = async (source, options = {}) => {
|
|
8166
|
+
const fetcher = options.fetcher ?? fetch;
|
|
8167
|
+
const { owner, repo, ref } = source;
|
|
8168
|
+
const slug = `${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`;
|
|
8169
|
+
const label = `${owner}/${repo}${ref ? `@${ref}` : ""}`;
|
|
8170
|
+
const publicUrl = `https://codeload.github.com/${slug}/zip/${ref ? encodeRefPath(ref) : "HEAD"}`;
|
|
8171
|
+
assertAllowedUrl(publicUrl);
|
|
8172
|
+
const publicResponse = await fetcher(publicUrl);
|
|
8173
|
+
if (publicResponse.ok) return Buffer.from(await publicResponse.arrayBuffer());
|
|
8174
|
+
if (!options.githubToken) throw new Error(publicResponse.status === 404 ? `GitHub repo or ref not found: ${label} (HTTP 404).\n • Check the URL, and that the branch/tag exists.\n • Private repo? Set GITHUB_TOKEN=<a token with repo read access> and re-run.` : `Failed to download ${label} from GitHub (HTTP ${publicResponse.status} ${publicResponse.statusText}).`);
|
|
8175
|
+
const apiUrl = `https://api.github.com/repos/${slug}/zipball${ref ? `/${encodeRefPath(ref)}` : ""}`;
|
|
8176
|
+
assertAllowedUrl(apiUrl);
|
|
8177
|
+
const authedResponse = await fetcher(apiUrl, { headers: {
|
|
8178
|
+
Accept: "application/vnd.github+json",
|
|
8179
|
+
Authorization: `Bearer ${options.githubToken}`,
|
|
8180
|
+
"X-GitHub-Api-Version": "2022-11-28"
|
|
8181
|
+
} });
|
|
8182
|
+
if (authedResponse.ok) return Buffer.from(await authedResponse.arrayBuffer());
|
|
8183
|
+
if (authedResponse.status === 401 || authedResponse.status === 403) throw new Error(`GitHub rejected GITHUB_TOKEN for ${label} (HTTP ${authedResponse.status}). The token is invalid, expired, or lacks read access to this repo.`);
|
|
8184
|
+
throw new Error(`GitHub repo or ref not found: ${label} (HTTP ${authedResponse.status}), even with GITHUB_TOKEN. Check the owner, repo, and ref.`);
|
|
8185
|
+
};
|
|
8186
|
+
/** SSRF guard: refuse anything off the GitHub allowlist before a connection is made. */
|
|
8187
|
+
const assertAllowedUrl = (rawUrl) => {
|
|
8188
|
+
const url = new URL(rawUrl);
|
|
8189
|
+
if (url.protocol !== "https:" || !isAllowedGithubHost(url.hostname)) throw new Error(`Refusing to fetch ${rawUrl} — only https on ${GITHUB_ALLOWED_HOSTS.join(", ")} is allowed.`);
|
|
8190
|
+
};
|
|
8191
|
+
/**
|
|
8192
|
+
* Zip-slip guard. Normalizes an archive entry path to a repo-relative path and
|
|
8193
|
+
* rejects anything that could escape the extraction root: `..`/`.` segments,
|
|
8194
|
+
* and Windows drive prefixes. A leading `/` is stripped rather than rejected —
|
|
8195
|
+
* that makes the path relative, which is already safe.
|
|
8196
|
+
*/
|
|
8197
|
+
const normalizeArchivePath = (value) => {
|
|
8198
|
+
const normalized = value.replaceAll("\\", "/").replace(/^\/+|\/+$/g, "");
|
|
8199
|
+
const segments = normalized.split("/");
|
|
8200
|
+
if (!normalized || segments.some((segment) => segment === "." || segment === ".." || /^[a-zA-Z]:$/.test(segment))) throw new Error(`Unsafe archive path rejected: ${value}`);
|
|
8201
|
+
return normalized;
|
|
8202
|
+
};
|
|
8203
|
+
/**
|
|
8204
|
+
* Extract a GitHub zipball into memory, keyed by repo-relative path (the archive's
|
|
8205
|
+
* single generated root directory — `owner-repo-<sha>/` — is stripped).
|
|
8206
|
+
*
|
|
8207
|
+
* Caps are enforced against the archive's declared entry sizes BEFORE any bytes are
|
|
8208
|
+
* read, so an oversized archive or zip bomb is refused up front and nothing is ever
|
|
8209
|
+
* written to disk.
|
|
8210
|
+
*/
|
|
8211
|
+
const extractZip = async (zipBuffer, options = {}) => {
|
|
8212
|
+
const maxFiles = options.maxFiles ?? 5e3;
|
|
8213
|
+
const maxTotalBytes = options.maxTotalBytes ?? 262144e3;
|
|
8214
|
+
const maxFileBytes = options.maxFileBytes ?? 26214400;
|
|
8215
|
+
const reader = new ZipReader(new Uint8ArrayReader(new Uint8Array(zipBuffer)));
|
|
8216
|
+
try {
|
|
8217
|
+
const files = (await reader.getEntries()).filter((entry) => !entry.directory);
|
|
8218
|
+
const archiveRoot = files[0]?.filename.split("/")[0];
|
|
8219
|
+
if (!archiveRoot) throw new Error("The downloaded repository archive is empty.");
|
|
8220
|
+
const subdir = options.subdir ? normalizeArchivePath(options.subdir) : void 0;
|
|
8221
|
+
const prefix = `${archiveRoot}/${subdir ? `${subdir}/` : ""}`;
|
|
8222
|
+
for (const entry of files) normalizeArchivePath(entry.filename);
|
|
8223
|
+
const selected = files.filter((entry) => entry.filename.startsWith(prefix));
|
|
8224
|
+
if (selected.length > maxFiles) throw new Error(`Package has ${selected.length} files, above the ${maxFiles}-file limit. Nothing was installed.`);
|
|
8225
|
+
let totalBytes = 0;
|
|
8226
|
+
for (const entry of selected) {
|
|
8227
|
+
const size = entry.uncompressedSize ?? 0;
|
|
8228
|
+
const relativePath = entry.filename.slice(prefix.length);
|
|
8229
|
+
if (size > maxFileBytes) throw new Error(`File "${relativePath}" is ${formatBytes(size)}, above the ${formatBytes(maxFileBytes)} per-file limit. Nothing was installed.`);
|
|
8230
|
+
totalBytes += size;
|
|
8231
|
+
if (totalBytes > maxTotalBytes) throw new Error(`Package unpacks to more than the ${formatBytes(maxTotalBytes)} total limit. Nothing was installed.`);
|
|
8232
|
+
}
|
|
8233
|
+
const result = /* @__PURE__ */ new Map();
|
|
8234
|
+
for (const entry of selected) {
|
|
8235
|
+
const relativePath = normalizeArchivePath(entry.filename.slice(prefix.length));
|
|
8236
|
+
if (!entry.getData) continue;
|
|
8237
|
+
const data = await entry.arrayBuffer();
|
|
8238
|
+
result.set(relativePath, Buffer.from(data));
|
|
8239
|
+
}
|
|
8240
|
+
return result;
|
|
8241
|
+
} finally {
|
|
8242
|
+
await reader.close();
|
|
8243
|
+
}
|
|
8244
|
+
};
|
|
8245
|
+
const formatBytes = (bytes) => bytes >= 1048576 ? `${Math.round(bytes / 1048576)}MB` : `${Math.round(bytes / 1024)}KB`;
|
|
8246
|
+
/** Parse, fetch, and extract in one step — the `install` command's entry point. */
|
|
8247
|
+
const fetchGithubPackageFiles = async (repoUrl, options = {}) => {
|
|
8248
|
+
const source = parseGithubUrl(repoUrl);
|
|
8249
|
+
const zipBuffer = await downloadGithubZip(source, options);
|
|
8250
|
+
return {
|
|
8251
|
+
source,
|
|
8252
|
+
files: await extractZip(zipBuffer, { subdir: source.subdir })
|
|
8253
|
+
};
|
|
8254
|
+
};
|
|
8255
|
+
//#endregion
|
|
8256
|
+
//#region ../../packages/busabase-package/src/index-build.ts
|
|
8257
|
+
/**
|
|
8258
|
+
* Build the Template Center's catalog from a directory of skills.
|
|
8259
|
+
*
|
|
8260
|
+
* The catalog is not hand-maintained. A card that says "6 tables" for a package
|
|
8261
|
+
* that installs five is a trust bug, not a typo — so every field here is read
|
|
8262
|
+
* off the package itself, and whether an entry appears at all is decided by the
|
|
8263
|
+
* SAME `validateTemplate` the installer uses. A skill that would install as a
|
|
8264
|
+
* plain package can never be listed as a template, because one function answers
|
|
8265
|
+
* that question for both.
|
|
8266
|
+
*
|
|
8267
|
+
* Deliberately a plain function over a file map rather than something that
|
|
8268
|
+
* walks a disk: the CLI hands it a checkout, and a future CI job could hand it
|
|
8269
|
+
* a fetched zipball, without either needing its own notion of what counts.
|
|
8270
|
+
*
|
|
8271
|
+
* Spec: `apps/busabase/content/spec/template-center.md` §6.4.
|
|
8272
|
+
*/
|
|
8273
|
+
/** Bumped when a consumer would misread an older file. */
|
|
8274
|
+
const TEMPLATE_INDEX_FORMAT = "busabase-template-index@1";
|
|
8275
|
+
const toEntry = (found, airapps) => ({
|
|
8276
|
+
subdir: found.subdir,
|
|
8277
|
+
name: found.name,
|
|
8278
|
+
description: found.description,
|
|
8279
|
+
category: found.category ?? "uncategorized",
|
|
8280
|
+
tags: found.tags ?? [],
|
|
8281
|
+
screenshots: found.screenshots,
|
|
8282
|
+
agentPrompts: found.agentPrompts ?? [],
|
|
8283
|
+
...found.version ? { version: found.version } : {},
|
|
8284
|
+
...found.author ? { author: found.author } : {},
|
|
8285
|
+
...found.license ? { license: found.license } : {},
|
|
8286
|
+
stats: {
|
|
8287
|
+
folders: found.counts.folders,
|
|
8288
|
+
docs: found.counts.docs,
|
|
8289
|
+
bases: found.counts.bases,
|
|
8290
|
+
records: found.counts.records,
|
|
8291
|
+
files: found.counts.files,
|
|
8292
|
+
airapps,
|
|
8293
|
+
skill: true
|
|
8294
|
+
}
|
|
8295
|
+
});
|
|
8296
|
+
const buildTemplateIndex = (files, options) => {
|
|
8297
|
+
const discovered = discoverPackages(files);
|
|
8298
|
+
const templates = [];
|
|
8299
|
+
const rejected = [];
|
|
8300
|
+
for (const found of discovered) {
|
|
8301
|
+
if (found.isTemplate) {
|
|
8302
|
+
templates.push(toEntry(found, found.primaryAirApp ? 1 : 0));
|
|
8303
|
+
continue;
|
|
8304
|
+
}
|
|
8305
|
+
if (found.templateErrors.length > 0) rejected.push({
|
|
8306
|
+
subdir: found.subdir,
|
|
8307
|
+
name: found.name,
|
|
8308
|
+
errors: found.templateErrors
|
|
8309
|
+
});
|
|
8310
|
+
}
|
|
8311
|
+
return {
|
|
8312
|
+
format: TEMPLATE_INDEX_FORMAT,
|
|
8313
|
+
repo: options.repo,
|
|
8314
|
+
ref: options.ref,
|
|
8315
|
+
templates: templates.sort((a, b) => a.name.localeCompare(b.name, "en")),
|
|
8316
|
+
rejected: rejected.sort((a, b) => a.name.localeCompare(b.name, "en"))
|
|
8317
|
+
};
|
|
8318
|
+
};
|
|
8319
|
+
const renderTemplateIndex = (index) => `${JSON.stringify(index, null, 2)}\n`;
|
|
8320
|
+
//#endregion
|
|
8321
|
+
//#region ../../packages/busabase-package/src/layout-write.ts
|
|
8322
|
+
/**
|
|
8323
|
+
* Layout writer: the in-memory tree → a repo directory (§6.1), deterministically
|
|
8324
|
+
* (§6.6). "Clean diffs" is a feature: for an unchanged space, `export` twice must
|
|
8325
|
+
* produce byte-identical output, so re-exporting after one record edit diffs as
|
|
8326
|
+
* exactly one changed NDJSON line.
|
|
8327
|
+
*
|
|
8328
|
+
* Determinism rules, all enforced here:
|
|
8329
|
+
* • 2-space-indented JSON with a schema-defined key order
|
|
8330
|
+
* • `options` blobs (carried verbatim from the server, whose JSONB key order is
|
|
8331
|
+
* not a contract) are deep key-sorted
|
|
8332
|
+
* • records sorted by `key`, one per line
|
|
8333
|
+
* • LF endings, trailing newline on every file
|
|
8334
|
+
*/
|
|
8335
|
+
/**
|
|
8336
|
+
* Deep key-sort. Applied to blobs the server owns the key order of (field
|
|
8337
|
+
* `options`), never to arrays — `choices`, `filters`, and `sorts` are ordered data.
|
|
8338
|
+
*/
|
|
8339
|
+
const sortKeysDeep = (value) => {
|
|
8340
|
+
if (Array.isArray(value)) return value.map(sortKeysDeep);
|
|
8341
|
+
if (value === null || typeof value !== "object") return value;
|
|
8342
|
+
const entries = Object.entries(value).filter(([, item]) => item !== void 0).sort(([a], [b]) => a.localeCompare(b, "en"));
|
|
8343
|
+
return Object.fromEntries(entries.map(([key, item]) => [key, sortKeysDeep(item)]));
|
|
8344
|
+
};
|
|
8345
|
+
/** 2-space JSON + trailing LF. Key order is the insertion order of `value`. */
|
|
8346
|
+
const toJsonFile = (value) => Buffer.from(`${JSON.stringify(value, null, 2)}\n`, "utf8");
|
|
8347
|
+
/** Drop `undefined` so optional keys vanish rather than serializing inconsistently. */
|
|
8348
|
+
const compact = (value) => Object.fromEntries(Object.entries(value).filter(([, item]) => item !== void 0));
|
|
8349
|
+
const serializeManifest = (manifest) => toJsonFile(compact({
|
|
8350
|
+
format: manifest.format,
|
|
8351
|
+
name: manifest.name,
|
|
8352
|
+
description: manifest.description || void 0,
|
|
8353
|
+
version: manifest.version,
|
|
8354
|
+
author: manifest.author,
|
|
8355
|
+
license: manifest.license,
|
|
8356
|
+
homepage: manifest.homepage,
|
|
8357
|
+
tags: manifest.tags.length > 0 ? manifest.tags : void 0,
|
|
8358
|
+
template: manifest.template
|
|
8359
|
+
}));
|
|
8360
|
+
const serializeField = (field) => compact({
|
|
8361
|
+
slug: field.slug,
|
|
8362
|
+
name: field.name,
|
|
8363
|
+
type: field.type,
|
|
8364
|
+
required: field.required,
|
|
8365
|
+
position: field.position,
|
|
8366
|
+
options: sortKeysDeep(field.options)
|
|
8367
|
+
});
|
|
8368
|
+
const serializeView = (view) => compact({
|
|
8369
|
+
slug: view.slug,
|
|
8370
|
+
name: view.name,
|
|
8371
|
+
description: view.description || void 0,
|
|
8372
|
+
type: view.type,
|
|
8373
|
+
config: compact({
|
|
8374
|
+
filters: view.config.filters.map((filter) => compact({
|
|
8375
|
+
fieldSlug: filter.fieldSlug,
|
|
8376
|
+
operator: filter.operator,
|
|
8377
|
+
value: filter.value
|
|
8378
|
+
})),
|
|
8379
|
+
sorts: view.config.sorts.map((sort) => ({
|
|
8380
|
+
direction: sort.direction,
|
|
8381
|
+
fieldSlug: sort.fieldSlug
|
|
8382
|
+
})),
|
|
8383
|
+
visibleFieldSlugs: view.config.visibleFieldSlugs ?? void 0,
|
|
8384
|
+
fieldWidths: view.config.fieldWidths
|
|
8385
|
+
})
|
|
8386
|
+
});
|
|
8387
|
+
const serializeBase = (base, position) => toJsonFile(compact({
|
|
8388
|
+
name: base.name,
|
|
8389
|
+
description: base.description || void 0,
|
|
8390
|
+
position,
|
|
8391
|
+
reviewPolicy: base.reviewPolicy,
|
|
8392
|
+
fields: base.fields.map(serializeField),
|
|
8393
|
+
views: base.views.map(serializeView)
|
|
8394
|
+
}));
|
|
8395
|
+
/** One JSON object per line, sorted by `key` — determinism (§6.4). */
|
|
8396
|
+
const serializeRecords = (records) => {
|
|
8397
|
+
const lines = [...records].sort((a, b) => a.key.localeCompare(b.key, "en")).map((record) => JSON.stringify({
|
|
8398
|
+
key: record.key,
|
|
8399
|
+
fields: record.fields
|
|
8400
|
+
}));
|
|
8401
|
+
return Buffer.from(lines.length > 0 ? `${lines.join("\n")}\n` : "", "utf8");
|
|
8402
|
+
};
|
|
8403
|
+
/**
|
|
8404
|
+
* Render a tree to package-relative paths + bytes, validating §6.3 as it goes.
|
|
8405
|
+
* Pure: no disk access, so the round-trip and determinism tests exercise it directly.
|
|
8406
|
+
*/
|
|
8407
|
+
const renderPackageTree = (tree) => {
|
|
8408
|
+
const files = /* @__PURE__ */ new Map();
|
|
8409
|
+
files.set(PACKAGE_MANIFEST_FILENAME, serializeManifest(tree.manifest));
|
|
8410
|
+
renderRootSkill(tree.rootSkill, files);
|
|
8411
|
+
renderNodes(tree.nodes, `${PACKAGE_CONTENT_DIRNAME}/`, files);
|
|
8412
|
+
renderDocAssets(tree.assets ?? [], files);
|
|
8413
|
+
return files;
|
|
8414
|
+
};
|
|
8415
|
+
/**
|
|
8416
|
+
* The root skill's files, back at the package root — NOT under `content/`.
|
|
8417
|
+
*
|
|
8418
|
+
* This asymmetry with every other node is the whole point: writing it as
|
|
8419
|
+
* `content/<slug>/` would produce a package whose next install created a second
|
|
8420
|
+
* Skill node beside the first, and each export→install cycle would add another.
|
|
8421
|
+
* Round-tripping through the root is what keeps exactly one copy in existence.
|
|
8422
|
+
*/
|
|
8423
|
+
const renderRootSkill = (rootSkill, files) => {
|
|
8424
|
+
if (!rootSkill) return;
|
|
8425
|
+
for (const entry of rootSkill.files) {
|
|
8426
|
+
assertSafeFilePath(entry.path, `root skill "${rootSkill.slug}"`);
|
|
8427
|
+
files.set(entry.path, entry.bytes);
|
|
8428
|
+
}
|
|
8429
|
+
};
|
|
8430
|
+
/**
|
|
8431
|
+
* `assets/<assetId><ext>` + its `.asset.json` sidecar, for every image the Docs
|
|
8432
|
+
* embed. Deduped by asset id (one image in three Docs is one pair of files) and
|
|
8433
|
+
* emitted in a fixed order, so a re-export of an unchanged space is still
|
|
8434
|
+
* byte-identical (§6.6).
|
|
8435
|
+
*/
|
|
8436
|
+
const renderDocAssets = (assets, files) => {
|
|
8437
|
+
const byId = /* @__PURE__ */ new Map();
|
|
8438
|
+
for (const asset of assets) if (!byId.has(asset.assetId)) byId.set(asset.assetId, asset);
|
|
8439
|
+
for (const asset of [...byId.values()].sort((a, b) => a.assetId.localeCompare(b.assetId, "en"))) {
|
|
8440
|
+
const bytesPath = docAssetBytesPath(asset);
|
|
8441
|
+
assertSafeFilePath(bytesPath, `doc asset "${asset.assetId}"`);
|
|
8442
|
+
files.set(bytesPath, asset.bytes);
|
|
8443
|
+
files.set(`${bytesPath}${PACKAGE_ASSET_META_SUFFIX}`, toJsonFile(compact({
|
|
8444
|
+
assetId: asset.assetId,
|
|
8445
|
+
fileName: asset.fileName,
|
|
8446
|
+
mimeType: asset.mimeType,
|
|
8447
|
+
contentHash: asset.contentHash
|
|
8448
|
+
})));
|
|
8449
|
+
}
|
|
8450
|
+
};
|
|
8451
|
+
const renderNodes = (nodes, dir, files) => {
|
|
8452
|
+
const ordered = sortNodes(nodes);
|
|
8453
|
+
assertNoSiblingCaseCollisions(ordered.map((node) => node.slug), `in ${dir}`);
|
|
8454
|
+
const seen = /* @__PURE__ */ new Set();
|
|
8455
|
+
for (const node of ordered) {
|
|
8456
|
+
assertSafeNodeSlug(node.slug, `${node.type} node`);
|
|
8457
|
+
if (seen.has(node.slug)) throw new Error(`Two nodes in ${dir} would be written as "${node.slug}". Sibling slugs must be unique.`);
|
|
8458
|
+
seen.add(node.slug);
|
|
8459
|
+
renderNode(node, dir, files);
|
|
8460
|
+
}
|
|
8461
|
+
};
|
|
8462
|
+
const renderNode = (node, dir, files) => {
|
|
8463
|
+
switch (node.type) {
|
|
8464
|
+
case "folder":
|
|
8465
|
+
if (node.children.length === 0 || node.description !== "" || node.position !== void 0 || node.name !== void 0) files.set(`${dir}${node.slug}/${PACKAGE_FOLDER_META_FILENAME}`, toJsonFile(compact({
|
|
8466
|
+
name: node.name,
|
|
8467
|
+
description: node.description || void 0,
|
|
8468
|
+
position: node.position
|
|
8469
|
+
})));
|
|
8470
|
+
renderNodes(node.children, `${dir}${node.slug}/`, files);
|
|
8471
|
+
return;
|
|
8472
|
+
case "doc":
|
|
8473
|
+
files.set(`${dir}${node.slug}.md`, Buffer.from(serializeDoc({
|
|
8474
|
+
name: node.name,
|
|
8475
|
+
description: node.description,
|
|
8476
|
+
position: node.position
|
|
8477
|
+
}, node.body), "utf8"));
|
|
8478
|
+
return;
|
|
8479
|
+
case "base": {
|
|
8480
|
+
files.set(`${dir}${node.slug}/${PACKAGE_BASE_FILENAME}`, serializeBase(node.base, node.position));
|
|
8481
|
+
const records = serializeRecords(node.records);
|
|
8482
|
+
if (records.byteLength > 0) files.set(`${dir}${node.slug}/${PACKAGE_RECORDS_FILENAME}`, records);
|
|
8483
|
+
return;
|
|
8484
|
+
}
|
|
8485
|
+
case "skill":
|
|
7189
8486
|
case "airapp":
|
|
7190
|
-
|
|
7191
|
-
|
|
7192
|
-
|
|
7193
|
-
|
|
7194
|
-
|
|
7195
|
-
|
|
7196
|
-
|
|
7197
|
-
|
|
7198
|
-
|
|
7199
|
-
|
|
7200
|
-
|
|
7201
|
-
};
|
|
7202
|
-
|
|
7203
|
-
|
|
7204
|
-
|
|
7205
|
-
|
|
7206
|
-
|
|
7207
|
-
|
|
7208
|
-
|
|
7209
|
-
|
|
7210
|
-
|
|
7211
|
-
|
|
7212
|
-
|
|
7213
|
-
|
|
7214
|
-
if (plan.collisions.length > 0) {
|
|
7215
|
-
lines.push("");
|
|
7216
|
-
lines.push("Collisions:");
|
|
7217
|
-
for (const collision of plan.collisions) lines.push(collision.renamedTo ? ` • ${collision.kind} "${collision.slug}" already exists → will be created as "${collision.renamedTo}"` : ` • ${collision.kind} "${collision.slug}" already exists (${collision.path})`);
|
|
7218
|
-
}
|
|
7219
|
-
if (plan.warnings.length > 0) {
|
|
7220
|
-
lines.push("");
|
|
7221
|
-
lines.push("Warnings:");
|
|
7222
|
-
for (const warning of plan.warnings) lines.push(` • ${warning}`);
|
|
8487
|
+
case "drive": {
|
|
8488
|
+
const nodeDir = `${dir}${node.slug}/`;
|
|
8489
|
+
assertNoReservedNames(node.files.map((entry) => entry.path), `${node.type} "${node.slug}"`);
|
|
8490
|
+
files.set(`${nodeDir}${PACKAGE_NODE_META_FILENAME}`, toJsonFile(compact({
|
|
8491
|
+
type: node.type,
|
|
8492
|
+
name: node.name,
|
|
8493
|
+
description: node.description || void 0,
|
|
8494
|
+
position: node.position
|
|
8495
|
+
})));
|
|
8496
|
+
for (const entry of [...node.files].sort((a, b) => a.path.localeCompare(b.path, "en"))) {
|
|
8497
|
+
assertSafeFilePath(entry.path, `${node.type} "${node.slug}"`);
|
|
8498
|
+
files.set(`${nodeDir}${entry.path}`, entry.bytes);
|
|
8499
|
+
}
|
|
8500
|
+
return;
|
|
8501
|
+
}
|
|
8502
|
+
case "file":
|
|
8503
|
+
assertSafeFilePath(node.fileName, `file node "${node.slug}"`);
|
|
8504
|
+
files.set(`${dir}${node.fileName}`, node.bytes);
|
|
8505
|
+
if (node.name !== node.fileName || node.description !== "" || node.position !== void 0) files.set(`${dir}${node.fileName}${PACKAGE_NODE_META_SUFFIX}`, toJsonFile(compact({
|
|
8506
|
+
name: node.name,
|
|
8507
|
+
description: node.description || void 0,
|
|
8508
|
+
position: node.position
|
|
8509
|
+
})));
|
|
8510
|
+
return;
|
|
7223
8511
|
}
|
|
7224
|
-
return lines.join("\n");
|
|
7225
8512
|
};
|
|
7226
|
-
|
|
7227
|
-
|
|
7228
|
-
|
|
7229
|
-
|
|
7230
|
-
|
|
7231
|
-
|
|
8513
|
+
/** Write rendered files under `outDir`, creating directories as needed. */
|
|
8514
|
+
const writePackageFiles = async (files, outDir, options = {}) => {
|
|
8515
|
+
if (options.clean) {
|
|
8516
|
+
await rm(join(outDir, PACKAGE_CONTENT_DIRNAME), {
|
|
8517
|
+
recursive: true,
|
|
8518
|
+
force: true
|
|
8519
|
+
});
|
|
8520
|
+
await rm(join(outDir, PACKAGE_ASSETS_DIRNAME), {
|
|
8521
|
+
recursive: true,
|
|
8522
|
+
force: true
|
|
8523
|
+
});
|
|
8524
|
+
await rm(join(outDir, PACKAGE_MANIFEST_FILENAME), { force: true });
|
|
8525
|
+
await rm(join(outDir, PACKAGE_SKILL_ENTRY), { force: true });
|
|
8526
|
+
for (const dirName of PACKAGE_SKILL_SIDECAR_DIRS) await rm(join(outDir, dirName), {
|
|
8527
|
+
recursive: true,
|
|
8528
|
+
force: true
|
|
8529
|
+
});
|
|
7232
8530
|
}
|
|
7233
|
-
|
|
7234
|
-
|
|
7235
|
-
|
|
7236
|
-
|
|
7237
|
-
* records to link, so passes 2-5 cannot run. Fail fast and say exactly why.
|
|
7238
|
-
*/
|
|
7239
|
-
const assertPlanIsApplicable = (plan, autoMerge) => {
|
|
7240
|
-
if (plan.collisions.some((collision) => !collision.renamedTo)) {
|
|
7241
|
-
const list = plan.collisions.filter((collision) => !collision.renamedTo).map((collision) => ` • ${collision.kind} "${collision.slug}" (${collision.path})`).join("\n");
|
|
7242
|
-
throw new Error(`Install would collide with content that already exists:\n${list}\n\nNothing was created. Re-run with --rename to install the colliding items under suffixed slugs (e.g. "-2"), or pick a different --into-folder.`);
|
|
8531
|
+
for (const [filePath, bytes] of [...files].sort(([a], [b]) => a.localeCompare(b, "en"))) {
|
|
8532
|
+
const target = join(outDir, filePath);
|
|
8533
|
+
await mkdir(dirname(target), { recursive: true });
|
|
8534
|
+
await writeFile(target, bytes);
|
|
7243
8535
|
}
|
|
7244
|
-
if (plan.requiresAutoMerge && !autoMerge) throw new Error("This package's records carry relation values, and a relation stores the ids of the records it points at — which only exist once the records are merged. Installing review-first would leave every relation empty.\n\nRe-run with --auto-merge to install it (this trusts the package author: records are created immediately instead of landing as change requests you review first).");
|
|
7245
8536
|
};
|
|
7246
8537
|
//#endregion
|
|
7247
8538
|
//#region src/package/commands.ts
|
|
@@ -7257,11 +8548,26 @@ const runInstall = async (client, repoUrl, options) => {
|
|
|
7257
8548
|
reportProgress(`Fetching ${repoUrl} …`);
|
|
7258
8549
|
const { source, files } = await fetchGithubPackageFiles(repoUrl, { githubToken: options.githubToken });
|
|
7259
8550
|
reportProgress(`Downloaded ${files.size} file(s) from ${source.owner}/${source.repo}.`);
|
|
7260
|
-
const
|
|
8551
|
+
const resolved = resolvePackageToInstall(files, options.skill);
|
|
8552
|
+
if (resolved.kind === "none") {
|
|
8553
|
+
readPackageTree(files);
|
|
8554
|
+
throw new Error("Not a Busabase package.");
|
|
8555
|
+
}
|
|
8556
|
+
if (resolved.kind === "choose") {
|
|
8557
|
+
const report = renderPackageChoices(repoUrl, resolved.candidates, options.skill);
|
|
8558
|
+
if (options.json) return {
|
|
8559
|
+
chooseOne: true,
|
|
8560
|
+
candidates: resolved.candidates.map(toCandidateSummary)
|
|
8561
|
+
};
|
|
8562
|
+
return report;
|
|
8563
|
+
}
|
|
8564
|
+
const tree = readPackageTree(files, resolved.subdir ? { root: resolved.subdir } : {});
|
|
8565
|
+
if (resolved.subdir) reportProgress(`Installing "${resolved.discovered.name}" from ${resolved.subdir}/ …`);
|
|
7261
8566
|
const targetFolderSlug = options.intoFolder ?? suggestSlug(tree.manifest.name);
|
|
7262
8567
|
const plan = buildInstallPlan(tree, await resolveTargetState(client, targetFolderSlug), {
|
|
7263
8568
|
intoFolder: options.intoFolder,
|
|
7264
|
-
rename: options.rename
|
|
8569
|
+
rename: options.rename,
|
|
8570
|
+
installSampleRecords: !options.noSampleRecords
|
|
7265
8571
|
});
|
|
7266
8572
|
if (options.dryRun) {
|
|
7267
8573
|
const report = `${renderPlan(plan)}\n\nDry run — nothing was created.`;
|
|
@@ -7275,7 +8581,13 @@ const runInstall = async (client, repoUrl, options) => {
|
|
|
7275
8581
|
autoMerge: Boolean(options.autoMerge),
|
|
7276
8582
|
submittedBy: `busabase-cli install (${tree.manifest.name})`,
|
|
7277
8583
|
onProgress: reportProgress,
|
|
7278
|
-
serverUrl: options.serverUrl
|
|
8584
|
+
serverUrl: options.serverUrl,
|
|
8585
|
+
installSampleRecords: !options.noSampleRecords,
|
|
8586
|
+
source: {
|
|
8587
|
+
repo: `${source.owner}/${source.repo}`,
|
|
8588
|
+
ref: source.ref,
|
|
8589
|
+
...resolved.subdir ? { subdir: resolved.subdir } : source.subdir ? { subdir: source.subdir } : {}
|
|
8590
|
+
}
|
|
7279
8591
|
});
|
|
7280
8592
|
if (options.json) return {
|
|
7281
8593
|
installed: true,
|
|
@@ -7283,6 +8595,45 @@ const runInstall = async (client, repoUrl, options) => {
|
|
|
7283
8595
|
};
|
|
7284
8596
|
return renderInstallReport(plan.targetFolderSlug, result);
|
|
7285
8597
|
};
|
|
8598
|
+
const toCandidateSummary = (candidate) => ({
|
|
8599
|
+
name: candidate.name,
|
|
8600
|
+
subdir: candidate.subdir,
|
|
8601
|
+
description: candidate.description,
|
|
8602
|
+
isTemplate: candidate.isTemplate,
|
|
8603
|
+
counts: candidate.counts
|
|
8604
|
+
});
|
|
8605
|
+
/**
|
|
8606
|
+
* The list a user sees when their URL pointed at a repository of packages.
|
|
8607
|
+
*
|
|
8608
|
+
* Templates are listed first and labelled, because that is the difference the
|
|
8609
|
+
* user actually cares about: one installs an app, the other installs tables. A
|
|
8610
|
+
* package that TRIED to be a template and failed says so with its reasons —
|
|
8611
|
+
* that reader is usually its author.
|
|
8612
|
+
*/
|
|
8613
|
+
const renderPackageChoices = (repoUrl, candidates, wanted) => {
|
|
8614
|
+
const lines = [];
|
|
8615
|
+
if (wanted) lines.push(`No package named "${wanted}" in ${repoUrl}. It contains:`);
|
|
8616
|
+
else lines.push(`${repoUrl} contains ${candidates.length} packages. Pick one:`);
|
|
8617
|
+
lines.push("");
|
|
8618
|
+
const ordered = [...candidates].sort((a, b) => {
|
|
8619
|
+
if (a.isTemplate !== b.isTemplate) return a.isTemplate ? -1 : 1;
|
|
8620
|
+
return a.name.localeCompare(b.name, "en");
|
|
8621
|
+
});
|
|
8622
|
+
for (const candidate of ordered) {
|
|
8623
|
+
const shape = [
|
|
8624
|
+
candidate.isTemplate ? "template" : "package",
|
|
8625
|
+
candidate.counts.bases > 0 ? `${candidate.counts.bases} base(s)` : void 0,
|
|
8626
|
+
candidate.primaryAirApp ? "app" : void 0
|
|
8627
|
+
].filter(Boolean).join(" · ");
|
|
8628
|
+
lines.push(` ${candidate.name} (${shape})`);
|
|
8629
|
+
if (candidate.description) lines.push(` ${candidate.description}`);
|
|
8630
|
+
for (const error of candidate.templateErrors) lines.push(` not a template: ${error}`);
|
|
8631
|
+
}
|
|
8632
|
+
lines.push("");
|
|
8633
|
+
lines.push("Install one with:");
|
|
8634
|
+
lines.push(` busabase-cli install ${repoUrl} --skill ${ordered[0]?.name ?? "<name>"}`);
|
|
8635
|
+
return lines.join("\n");
|
|
8636
|
+
};
|
|
7286
8637
|
const toPlanSummary = (plan) => ({
|
|
7287
8638
|
targetFolder: plan.targetFolderSlug,
|
|
7288
8639
|
package: plan.tree.manifest.name,
|
|
@@ -7320,6 +8671,27 @@ const runExport = async (client, nodeSlugOrId, options) => {
|
|
|
7320
8671
|
warn: (message) => warnings.push(message),
|
|
7321
8672
|
baseUrl: options.baseUrl
|
|
7322
8673
|
});
|
|
8674
|
+
if (options.template) {
|
|
8675
|
+
if (!tree.rootSkill) {
|
|
8676
|
+
warnings.push(`No Skill node in "${source.slug}", so a ${PACKAGE_SKILL_ENTRY} draft was written from the folder's structure. Fill in its TODOs before publishing — an agent acts on what it says.`);
|
|
8677
|
+
tree.rootSkill = {
|
|
8678
|
+
slug: manifest.name,
|
|
8679
|
+
name: manifest.name,
|
|
8680
|
+
description: manifest.description,
|
|
8681
|
+
files: [{
|
|
8682
|
+
path: PACKAGE_SKILL_ENTRY,
|
|
8683
|
+
bytes: Buffer.from(deriveSkillDraft(tree), "utf8")
|
|
8684
|
+
}]
|
|
8685
|
+
};
|
|
8686
|
+
}
|
|
8687
|
+
if (!tree.manifest.template) tree.manifest = {
|
|
8688
|
+
...tree.manifest,
|
|
8689
|
+
template: TemplateManifestSchema.parse({ category: "uncategorized" })
|
|
8690
|
+
};
|
|
8691
|
+
const validation = validateTemplate(tree);
|
|
8692
|
+
for (const warning of validation.warnings) warnings.push(warning);
|
|
8693
|
+
for (const error of validation.errors) warnings.push(`Not yet a valid template: ${error}`);
|
|
8694
|
+
}
|
|
7323
8695
|
const files = renderPackageTree(tree);
|
|
7324
8696
|
if (!options.dryRun) await writePackageFiles(files, options.outDir, { clean: true });
|
|
7325
8697
|
if (options.json) return {
|
|
@@ -7327,6 +8699,7 @@ const runExport = async (client, nodeSlugOrId, options) => {
|
|
|
7327
8699
|
package: manifest.name,
|
|
7328
8700
|
outDir: options.outDir,
|
|
7329
8701
|
files: [...files.keys()].sort(),
|
|
8702
|
+
...options.template ? { isTemplate: validateTemplate(tree).ok } : {},
|
|
7330
8703
|
warnings
|
|
7331
8704
|
};
|
|
7332
8705
|
return renderExportReport(options, manifest, files, warnings);
|
|
@@ -7378,6 +8751,207 @@ const renderExportReport = (options, manifest, files, warnings) => {
|
|
|
7378
8751
|
}
|
|
7379
8752
|
return lines.join("\n");
|
|
7380
8753
|
};
|
|
8754
|
+
/**
|
|
8755
|
+
* Build the Template Center's catalog from a checkout.
|
|
8756
|
+
*
|
|
8757
|
+
* Lives here rather than in the skills repository so the catalog and the
|
|
8758
|
+
* installer share one answer to "is this a template". A repository that decided
|
|
8759
|
+
* for itself could list a card whose install then behaves differently — which
|
|
8760
|
+
* is a trust problem, not a formatting one.
|
|
8761
|
+
*/
|
|
8762
|
+
const runIndex = async (options) => {
|
|
8763
|
+
const files = await readDirectoryFiles(options.dir);
|
|
8764
|
+
const index = buildTemplateIndex(files, {
|
|
8765
|
+
repo: options.repo,
|
|
8766
|
+
ref: options.ref
|
|
8767
|
+
});
|
|
8768
|
+
const rendered = renderTemplateIndex(index);
|
|
8769
|
+
if (options.check) {
|
|
8770
|
+
if (!options.out) throw new Error("--check needs --out: there is nothing to compare against.");
|
|
8771
|
+
if (await readFile(options.out, "utf8").catch(() => null) !== rendered) throw new Error(`${options.out} is out of date. Run the same command without --check to rebuild it.`);
|
|
8772
|
+
return options.json ? {
|
|
8773
|
+
upToDate: true,
|
|
8774
|
+
templates: index.templates.length
|
|
8775
|
+
} : `${options.out} is up to date (${index.templates.length} template(s)).`;
|
|
8776
|
+
}
|
|
8777
|
+
if (options.out) {
|
|
8778
|
+
await mkdir(dirname(options.out), { recursive: true });
|
|
8779
|
+
await writeFile(options.out, rendered);
|
|
8780
|
+
}
|
|
8781
|
+
if (options.json) return index;
|
|
8782
|
+
if (!options.out) return rendered.trimEnd();
|
|
8783
|
+
const lines = [`Wrote ${options.out}: ${index.templates.length} template(s).`];
|
|
8784
|
+
for (const entry of index.templates) lines.push(` ${entry.name} (${entry.category} · ${entry.stats.bases} base(s))`);
|
|
8785
|
+
if (index.rejected.length > 0) {
|
|
8786
|
+
lines.push("", "Declared a template but did not qualify:");
|
|
8787
|
+
for (const entry of index.rejected) {
|
|
8788
|
+
lines.push(` ${entry.name}`);
|
|
8789
|
+
for (const error of entry.errors) lines.push(` ${error}`);
|
|
8790
|
+
}
|
|
8791
|
+
}
|
|
8792
|
+
return lines.join("\n");
|
|
8793
|
+
};
|
|
8794
|
+
/** Every file under `dir`, keyed by its POSIX path relative to it. */
|
|
8795
|
+
const readDirectoryFiles = async (dir) => {
|
|
8796
|
+
const files = /* @__PURE__ */ new Map();
|
|
8797
|
+
const walk = async (current) => {
|
|
8798
|
+
for (const entry of await readdir(current, { withFileTypes: true })) {
|
|
8799
|
+
if (entry.name === "node_modules" || entry.name === ".git") continue;
|
|
8800
|
+
const absolute = join(current, entry.name);
|
|
8801
|
+
const info = await stat(absolute).catch(() => null);
|
|
8802
|
+
if (!info) continue;
|
|
8803
|
+
if (info.isDirectory()) await walk(absolute);
|
|
8804
|
+
else if (info.isFile()) files.set(relative(dir, absolute).split(sep).join("/"), await readFile(absolute));
|
|
8805
|
+
}
|
|
8806
|
+
};
|
|
8807
|
+
await walk(dir);
|
|
8808
|
+
return files;
|
|
8809
|
+
};
|
|
8810
|
+
//#endregion
|
|
8811
|
+
//#region src/paginate.ts
|
|
8812
|
+
/**
|
|
8813
|
+
* `--all`: follow the cursor to the end instead of making the caller loop.
|
|
8814
|
+
*
|
|
8815
|
+
* Every listing in this CLI is bounded — that is the point of the `limit` work —
|
|
8816
|
+
* but "bounded" moves the burden onto the caller, who now has to notice
|
|
8817
|
+
* `nextCursor`, thread it back in, and know when to stop. An agent that misses
|
|
8818
|
+
* that reports a truncated answer as the whole answer, which is the failure the
|
|
8819
|
+
* `record_query` task guidance warns about in prose. This does it in code.
|
|
8820
|
+
*
|
|
8821
|
+
* Rows stream out as NDJSON — one JSON object per line — rather than
|
|
8822
|
+
* accumulating into one array, so memory stays flat and a consumer can start
|
|
8823
|
+
* work on page 1 while page 9 is still in flight. That is why `--all` overrides
|
|
8824
|
+
* `--output`: a 40,000-row table is not a thing anyone wanted.
|
|
8825
|
+
*/
|
|
8826
|
+
/**
|
|
8827
|
+
* Read a page out of whatever the command returned.
|
|
8828
|
+
*
|
|
8829
|
+
* Two protocols exist and both are honoured. Most paginated endpoints return
|
|
8830
|
+
* `{ rows, nextCursor }`. `GET /assets` returns a bare array and pages by the
|
|
8831
|
+
* last row's `id`, with a short page meaning the end — a shape chosen there to
|
|
8832
|
+
* keep the response backwards compatible for the web and mobile clients.
|
|
8833
|
+
*/
|
|
8834
|
+
function readPage(result, limit) {
|
|
8835
|
+
const envelope = asEnvelope(result);
|
|
8836
|
+
if (envelope) {
|
|
8837
|
+
const cursor = envelope.meta.find(([key]) => key === "nextCursor")?.[1];
|
|
8838
|
+
if (cursor === void 0) return void 0;
|
|
8839
|
+
return {
|
|
8840
|
+
rows: envelope.rows,
|
|
8841
|
+
nextCursor: typeof cursor === "string" ? cursor : null
|
|
8842
|
+
};
|
|
8843
|
+
}
|
|
8844
|
+
if (!Array.isArray(result)) return void 0;
|
|
8845
|
+
if (limit === void 0 || result.length < limit) return {
|
|
8846
|
+
rows: result,
|
|
8847
|
+
nextCursor: null
|
|
8848
|
+
};
|
|
8849
|
+
const id = result[result.length - 1]?.id;
|
|
8850
|
+
return {
|
|
8851
|
+
rows: result,
|
|
8852
|
+
nextCursor: typeof id === "string" ? id : null
|
|
8853
|
+
};
|
|
8854
|
+
}
|
|
8855
|
+
/** Stops a broken server or a cursor that never advances from looping forever. */
|
|
8856
|
+
const MAX_PAGES = 1e4;
|
|
8857
|
+
/**
|
|
8858
|
+
* Stream every page as NDJSON. Returns the number of rows written.
|
|
8859
|
+
*
|
|
8860
|
+
* A cap that truncates is always announced on stderr: a silently short answer
|
|
8861
|
+
* that looks complete is the exact failure this is meant to prevent.
|
|
8862
|
+
*/
|
|
8863
|
+
async function paginateAll(options) {
|
|
8864
|
+
const { fetchPage, limit, maxItems, write, notify } = options;
|
|
8865
|
+
let cursor;
|
|
8866
|
+
let written = 0;
|
|
8867
|
+
let seen = 0;
|
|
8868
|
+
for (let pageIndex = 0; pageIndex < MAX_PAGES; pageIndex += 1) {
|
|
8869
|
+
const result = await fetchPage(cursor);
|
|
8870
|
+
const page = readPage(result, limit);
|
|
8871
|
+
if (!page) {
|
|
8872
|
+
write(JSON.stringify(result));
|
|
8873
|
+
return 1;
|
|
8874
|
+
}
|
|
8875
|
+
seen += page.rows.length;
|
|
8876
|
+
for (const row of page.rows) {
|
|
8877
|
+
if (maxItems !== void 0 && written >= maxItems) {
|
|
8878
|
+
notify(`[busabase-cli] --max-items ${maxItems} reached; stopped early with more rows available.`);
|
|
8879
|
+
return written;
|
|
8880
|
+
}
|
|
8881
|
+
write(JSON.stringify(row));
|
|
8882
|
+
written += 1;
|
|
8883
|
+
}
|
|
8884
|
+
if (!page.nextCursor) return written;
|
|
8885
|
+
if (page.nextCursor === cursor) {
|
|
8886
|
+
notify("[busabase-cli] the server returned the same cursor twice; stopping to avoid a loop.");
|
|
8887
|
+
return written;
|
|
8888
|
+
}
|
|
8889
|
+
cursor = page.nextCursor;
|
|
8890
|
+
}
|
|
8891
|
+
notify(`[busabase-cli] stopped after ${MAX_PAGES} pages (${seen} rows scanned).`);
|
|
8892
|
+
return written;
|
|
8893
|
+
}
|
|
8894
|
+
//#endregion
|
|
8895
|
+
//#region src/retry.ts
|
|
8896
|
+
const BASE_DELAY_MS = 500;
|
|
8897
|
+
const MAX_DELAY_MS = 2e4;
|
|
8898
|
+
const READ_METHODS = /* @__PURE__ */ new Set([
|
|
8899
|
+
"GET",
|
|
8900
|
+
"HEAD",
|
|
8901
|
+
"OPTIONS"
|
|
8902
|
+
]);
|
|
8903
|
+
const defaultSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
8904
|
+
const isNetworkError = (error) => error instanceof TypeError || /fetch failed|econnreset|econnrefused|etimedout|socket hang up/i.test(error instanceof Error ? error.message : String(error));
|
|
8905
|
+
/** `Retry-After` is either delay-seconds or an HTTP-date; both are legal. */
|
|
8906
|
+
function parseRetryAfter(header, now = Date.now()) {
|
|
8907
|
+
if (!header) return void 0;
|
|
8908
|
+
const seconds = Number(header.trim());
|
|
8909
|
+
if (Number.isFinite(seconds) && seconds >= 0) return Math.min(seconds * 1e3, MAX_DELAY_MS);
|
|
8910
|
+
const at = Date.parse(header);
|
|
8911
|
+
if (Number.isNaN(at)) return void 0;
|
|
8912
|
+
return Math.min(Math.max(at - now, 0), MAX_DELAY_MS);
|
|
8913
|
+
}
|
|
8914
|
+
/** Whether this outcome is worth another attempt for a request using `method`. */
|
|
8915
|
+
function shouldRetry(method, status) {
|
|
8916
|
+
if (status === 429) return true;
|
|
8917
|
+
if (!READ_METHODS.has(method.toUpperCase())) return false;
|
|
8918
|
+
return status === void 0 || status >= 500;
|
|
8919
|
+
}
|
|
8920
|
+
/**
|
|
8921
|
+
* Wrap a fetch so transient failures are retried with exponential backoff.
|
|
8922
|
+
* Returns `base` unchanged when `retries` is 0, so the no-retry path adds no
|
|
8923
|
+
* wrapper and no behaviour.
|
|
8924
|
+
*/
|
|
8925
|
+
function withRetry(base, options) {
|
|
8926
|
+
const { retries } = options;
|
|
8927
|
+
if (retries <= 0) return base;
|
|
8928
|
+
const sleep = options.sleep ?? defaultSleep;
|
|
8929
|
+
const notify = options.notify ?? ((message) => console.error(message));
|
|
8930
|
+
const jitter = options.jitter ?? Math.random;
|
|
8931
|
+
return (async (input, init) => {
|
|
8932
|
+
const method = init?.method ?? (input instanceof Request ? input.method : "GET");
|
|
8933
|
+
for (let attempt = 0;; attempt += 1) {
|
|
8934
|
+
let response;
|
|
8935
|
+
let failure;
|
|
8936
|
+
try {
|
|
8937
|
+
response = await base(input, init);
|
|
8938
|
+
} catch (error) {
|
|
8939
|
+
if (!isNetworkError(error)) throw error;
|
|
8940
|
+
failure = error;
|
|
8941
|
+
}
|
|
8942
|
+
const status = response?.status;
|
|
8943
|
+
const last = attempt >= retries;
|
|
8944
|
+
if (!shouldRetry(method, status) || last) {
|
|
8945
|
+
if (response) return response;
|
|
8946
|
+
throw failure;
|
|
8947
|
+
}
|
|
8948
|
+
const backoff = Math.min(BASE_DELAY_MS * 2 ** attempt, MAX_DELAY_MS);
|
|
8949
|
+
const delay = parseRetryAfter(response?.headers.get("retry-after") ?? null) ?? Math.round(backoff * (.5 + jitter() * .5));
|
|
8950
|
+
notify(`[busabase-cli] ${status ? `HTTP ${status}` : "connection failed"} — retrying in ${Math.round(delay / 1e3)}s (attempt ${attempt + 2} of ${retries + 1})`);
|
|
8951
|
+
await sleep(delay);
|
|
8952
|
+
}
|
|
8953
|
+
});
|
|
8954
|
+
}
|
|
7381
8955
|
//#endregion
|
|
7382
8956
|
//#region src/task-command.ts
|
|
7383
8957
|
const collect = (value, previous = []) => [...previous, value];
|
|
@@ -7417,10 +8991,13 @@ const applyParam = (cmd, param) => {
|
|
|
7417
8991
|
case "stringArray":
|
|
7418
8992
|
cmd.option(`${flag} <value>`, `${description} (repeatable)`, collect);
|
|
7419
8993
|
return;
|
|
7420
|
-
case "json":
|
|
7421
|
-
|
|
8994
|
+
case "json": {
|
|
8995
|
+
const jsonOption = `${jsonFlagFor(param)} <json|@file>`;
|
|
8996
|
+
if (param.required) cmd.requiredOption(jsonOption, description);
|
|
8997
|
+
else cmd.option(jsonOption, description);
|
|
7422
8998
|
if (param.cliShorthand) cmd.option(`${param.cliShorthand.flag} ${param.cliShorthand.placeholder}`, param.cliShorthand.description, collect);
|
|
7423
8999
|
return;
|
|
9000
|
+
}
|
|
7424
9001
|
default: if (param.required) cmd.requiredOption(`${flag} <value>`, description);
|
|
7425
9002
|
else cmd.option(`${flag} <value>`, description);
|
|
7426
9003
|
}
|
|
@@ -7470,6 +9047,7 @@ const registerOne = (program, task, deps, path, preset = {}, summaryOverride) =>
|
|
|
7470
9047
|
}
|
|
7471
9048
|
applyParam(cmd, param);
|
|
7472
9049
|
}
|
|
9050
|
+
if (task.guidance) cmd.addHelpText("after", `\n${wrapGuidance(rewriteTaskNames(task.guidance))}`);
|
|
7473
9051
|
if (task.examples && task.examples.length > 0 && Object.keys(preset).length === 0) cmd.addHelpText("after", `\nExamples:\n ${task.examples.join("\n ")}`);
|
|
7474
9052
|
cmd.action(deps.runAction(async (client, opts) => task.execute(client, {
|
|
7475
9053
|
...buildInput(task, opts, deps),
|
|
@@ -7477,11 +9055,108 @@ const registerOne = (program, task, deps, path, preset = {}, summaryOverride) =>
|
|
|
7477
9055
|
})));
|
|
7478
9056
|
};
|
|
7479
9057
|
const registerTaskCommands = (program, tasks, deps) => {
|
|
9058
|
+
for (const task of tasks) cliPathByTaskName.set(task.name, `busabase-cli ${task.cliPath.join(" ")}`);
|
|
7480
9059
|
for (const task of tasks) {
|
|
7481
9060
|
registerOne(program, task, deps, task.cliPath);
|
|
7482
9061
|
for (const variant of task.cliVariants ?? []) registerOne(program, task, deps, variant.path, variant.preset, variant.summary);
|
|
7483
9062
|
}
|
|
7484
9063
|
};
|
|
9064
|
+
/**
|
|
9065
|
+
* Guidance is written once and read by two surfaces, so it names sibling tasks
|
|
9066
|
+
* by their MCP tool name (`record_find_by_field`). That is not something a CLI
|
|
9067
|
+
* caller can type — the command is `records by-field-text` — so the shared text
|
|
9068
|
+
* is rewritten to this surface's vocabulary rather than forked.
|
|
9069
|
+
*/
|
|
9070
|
+
const rewriteTaskNames = (text) => text.replace(/`([a-z][a-z0-9_]*)`/g, (match, name) => {
|
|
9071
|
+
const path = cliPathByTaskName.get(name);
|
|
9072
|
+
return path ? `\`${path}\`` : match;
|
|
9073
|
+
});
|
|
9074
|
+
/** Filled by {@link registerTaskCommands}; empty until the surface is built. */
|
|
9075
|
+
const cliPathByTaskName = /* @__PURE__ */ new Map();
|
|
9076
|
+
/** Reflow guidance to the width commander uses for the rest of the help screen. */
|
|
9077
|
+
const wrapGuidance = (text, width = 78) => {
|
|
9078
|
+
const lines = [];
|
|
9079
|
+
let line = "";
|
|
9080
|
+
for (const word of text.split(/\s+/).filter(Boolean)) if (line && `${line} ${word}`.length > width) {
|
|
9081
|
+
lines.push(line);
|
|
9082
|
+
line = word;
|
|
9083
|
+
} else line = line ? `${line} ${word}` : word;
|
|
9084
|
+
if (line) lines.push(line);
|
|
9085
|
+
return lines.join("\n");
|
|
9086
|
+
};
|
|
9087
|
+
//#endregion
|
|
9088
|
+
//#region src/wait.ts
|
|
9089
|
+
/**
|
|
9090
|
+
* `--wait`: block until a submitted proposal is actually decided.
|
|
9091
|
+
*
|
|
9092
|
+
* Review-first is the product's whole point, so the useful unit of work for a
|
|
9093
|
+
* caller is not "the change request was filed" — it is "the change request was
|
|
9094
|
+
* merged, or it wasn't". Without this every caller writes the same poll loop
|
|
9095
|
+
* against `change-requests get`, and an agent that skips it reports a proposal
|
|
9096
|
+
* nobody has looked at as a completed task.
|
|
9097
|
+
*/
|
|
9098
|
+
/** Statuses that will not change again on their own. */
|
|
9099
|
+
const TERMINAL = /* @__PURE__ */ new Set([
|
|
9100
|
+
"merged",
|
|
9101
|
+
"rejected",
|
|
9102
|
+
"closed",
|
|
9103
|
+
"abandoned"
|
|
9104
|
+
]);
|
|
9105
|
+
const isChangeRequest = (value) => {
|
|
9106
|
+
if (!value || typeof value !== "object") return false;
|
|
9107
|
+
const row = value;
|
|
9108
|
+
return typeof row.id === "string" && row.id.startsWith("crq") && typeof row.status === "string";
|
|
9109
|
+
};
|
|
9110
|
+
/**
|
|
9111
|
+
* Find the change request a command's result is about.
|
|
9112
|
+
*
|
|
9113
|
+
* The shape differs across the surface — some routes return the change request
|
|
9114
|
+
* itself, some wrap it (`{ changeRequest }`), some return the materialized thing
|
|
9115
|
+
* with `materialized: false` alongside — so this looks for the id rather than
|
|
9116
|
+
* asking every command to declare it.
|
|
9117
|
+
*/
|
|
9118
|
+
function findChangeRequestId(result) {
|
|
9119
|
+
if (isChangeRequest(result)) return result.id;
|
|
9120
|
+
if (Array.isArray(result)) {
|
|
9121
|
+
for (const item of result) {
|
|
9122
|
+
const found = findChangeRequestId(item);
|
|
9123
|
+
if (found) return found;
|
|
9124
|
+
}
|
|
9125
|
+
return;
|
|
9126
|
+
}
|
|
9127
|
+
if (!result || typeof result !== "object") return void 0;
|
|
9128
|
+
const row = result;
|
|
9129
|
+
if (typeof row.changeRequestId === "string") return row.changeRequestId;
|
|
9130
|
+
for (const key of ["changeRequest", "changeRequests"]) {
|
|
9131
|
+
const found = findChangeRequestId(row[key]);
|
|
9132
|
+
if (found) return found;
|
|
9133
|
+
}
|
|
9134
|
+
}
|
|
9135
|
+
const DEFAULT_POLL_MS = 3e3;
|
|
9136
|
+
/** Poll until the change request settles or the budget runs out. */
|
|
9137
|
+
async function waitForChangeRequest(options) {
|
|
9138
|
+
const { changeRequestId, read, timeoutMs, pollMs = DEFAULT_POLL_MS, sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)), now = Date.now, notify = () => {} } = options;
|
|
9139
|
+
const deadline = now() + timeoutMs;
|
|
9140
|
+
let latest;
|
|
9141
|
+
let status = "unknown";
|
|
9142
|
+
for (;;) {
|
|
9143
|
+
latest = await read(changeRequestId);
|
|
9144
|
+
status = isChangeRequest(latest) ? latest.status : "unknown";
|
|
9145
|
+
if (TERMINAL.has(status)) return {
|
|
9146
|
+
kind: "settled",
|
|
9147
|
+
status,
|
|
9148
|
+
changeRequest: latest
|
|
9149
|
+
};
|
|
9150
|
+
const remaining = deadline - now();
|
|
9151
|
+
if (remaining <= 0) return {
|
|
9152
|
+
kind: "timeout",
|
|
9153
|
+
status,
|
|
9154
|
+
changeRequest: latest
|
|
9155
|
+
};
|
|
9156
|
+
notify(`[busabase-cli] ${changeRequestId} is ${status}; waiting for a reviewer (${Math.round(remaining / 1e3)}s left)`);
|
|
9157
|
+
await sleep(Math.min(pollMs, remaining));
|
|
9158
|
+
}
|
|
9159
|
+
}
|
|
7485
9160
|
//#endregion
|
|
7486
9161
|
//#region src/run.ts
|
|
7487
9162
|
/** Public docs page covering every error below, for both Cloud and local. Linked from each error. */
|
|
@@ -7513,9 +9188,12 @@ function resolveConfig(opts) {
|
|
|
7513
9188
|
webUrl: process.env.BUSABASE_WEB_URL ?? file.BUSABASE_WEB_URL ?? baseUrl,
|
|
7514
9189
|
apiKey: opts.apiKey ?? process.env.BUSABASE_API_KEY ?? file.BUSABASE_API_KEY,
|
|
7515
9190
|
spaceId: opts.spaceId ?? process.env.BUSABASE_SPACE_ID ?? file.BUSABASE_SPACE_ID,
|
|
7516
|
-
output: opts.output ?? configuredOutput() ?? "text"
|
|
9191
|
+
output: opts.output ?? configuredOutput() ?? "text",
|
|
9192
|
+
fetch: withRetry(fetch, { retries: opts.retries ?? DEFAULT_RETRIES })
|
|
7517
9193
|
};
|
|
7518
9194
|
}
|
|
9195
|
+
/** Enough to ride out a rate limiter or a proxy blip; not enough to hide a real outage. */
|
|
9196
|
+
const DEFAULT_RETRIES = 2;
|
|
7519
9197
|
/**
|
|
7520
9198
|
* Config flags accepted anywhere on the line (`busabase-cli --output json bases list` and
|
|
7521
9199
|
* `busabase-cli bases list --output json` both work): declared on the root program AND on every
|
|
@@ -7528,14 +9206,20 @@ const GLOBAL_LONG_FLAGS = /* @__PURE__ */ new Set([
|
|
|
7528
9206
|
"--space-id",
|
|
7529
9207
|
"--output",
|
|
7530
9208
|
"--profile",
|
|
7531
|
-
"--config"
|
|
9209
|
+
"--config",
|
|
9210
|
+
"--slim",
|
|
9211
|
+
"--retries",
|
|
9212
|
+
"--all",
|
|
9213
|
+
"--max-items",
|
|
9214
|
+
"--wait",
|
|
9215
|
+
"--wait-timeout"
|
|
7532
9216
|
]);
|
|
7533
9217
|
function addGlobalFlags(cmd) {
|
|
7534
9218
|
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([
|
|
7535
9219
|
"text",
|
|
7536
9220
|
"table",
|
|
7537
9221
|
"json"
|
|
7538
|
-
]));
|
|
9222
|
+
])).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);
|
|
7539
9223
|
}
|
|
7540
9224
|
const parseNum = (value) => {
|
|
7541
9225
|
const parsed = Number(value);
|
|
@@ -7547,6 +9231,11 @@ const parsePositiveInt = (value) => {
|
|
|
7547
9231
|
if (!Number.isInteger(parsed) || parsed < 1) throw new InvalidArgumentError("expected a positive integer");
|
|
7548
9232
|
return parsed;
|
|
7549
9233
|
};
|
|
9234
|
+
const parseNonNegativeInt = (value) => {
|
|
9235
|
+
const parsed = parseNum(value);
|
|
9236
|
+
if (!Number.isInteger(parsed) || parsed < 0) throw new InvalidArgumentError("expected a non-negative integer");
|
|
9237
|
+
return parsed;
|
|
9238
|
+
};
|
|
7550
9239
|
const parseBoolean = (value) => {
|
|
7551
9240
|
if (value === "true") return true;
|
|
7552
9241
|
if (value === "false") return false;
|
|
@@ -7623,13 +9312,9 @@ function parseJsonValue(raw, flagName) {
|
|
|
7623
9312
|
* better off than when the field was silently dropped.
|
|
7624
9313
|
*/
|
|
7625
9314
|
function formatIssues(issues) {
|
|
7626
|
-
|
|
7627
|
-
|
|
7628
|
-
|
|
7629
|
-
const { path, message } = issue;
|
|
7630
|
-
const pathLabel = Array.isArray(path) && path.length > 0 ? path.join(".") : void 0;
|
|
7631
|
-
return pathLabel ? `${pathLabel}: ${message}` : String(message ?? issue);
|
|
7632
|
-
}).join("; ");
|
|
9315
|
+
const parsed = toIssues(issues);
|
|
9316
|
+
if (!parsed) return void 0;
|
|
9317
|
+
return parsed.map(({ path, message }) => path ? `${path}: ${message}` : message).join("; ");
|
|
7633
9318
|
}
|
|
7634
9319
|
function formatRawErrorBody(status, statusText, text) {
|
|
7635
9320
|
try {
|
|
@@ -7644,7 +9329,7 @@ function formatRawErrorBody(status, statusText, text) {
|
|
|
7644
9329
|
/** Raw fetch for endpoints outside the typed contract (health / openapi / api passthrough). */
|
|
7645
9330
|
async function rawFetch(config, method, path, body) {
|
|
7646
9331
|
const url = `${normalizeBaseUrl(config.baseUrl)}${path}`;
|
|
7647
|
-
const res = await fetch(url, {
|
|
9332
|
+
const res = await (config.fetch ?? fetch)(url, {
|
|
7648
9333
|
method: method.toUpperCase(),
|
|
7649
9334
|
headers: {
|
|
7650
9335
|
...body !== void 0 ? { "content-type": "application/json" } : {},
|
|
@@ -7762,10 +9447,60 @@ function runAction(state, handler) {
|
|
|
7762
9447
|
if (refreshedToken) config.apiKey = refreshedToken;
|
|
7763
9448
|
state.config = config;
|
|
7764
9449
|
assertCredentialNotExpired(config.apiKey);
|
|
7765
|
-
const
|
|
7766
|
-
|
|
9450
|
+
const client = createBusabaseClient(config);
|
|
9451
|
+
if (opts.all) {
|
|
9452
|
+
await streamAllPages(client, opts, config, handler);
|
|
9453
|
+
return;
|
|
9454
|
+
}
|
|
9455
|
+
const result = await handler(client, opts, config);
|
|
9456
|
+
console.log(render(opts.slim ? slim(result) : result, config.output));
|
|
9457
|
+
if (opts.wait) await awaitReview(client, opts, config, result);
|
|
7767
9458
|
};
|
|
7768
9459
|
}
|
|
9460
|
+
/** Default `--wait` budget: long enough for a reviewer who is at their desk. */
|
|
9461
|
+
const DEFAULT_WAIT_SECONDS = 300;
|
|
9462
|
+
/**
|
|
9463
|
+
* Block until the Change Request this command just filed is decided.
|
|
9464
|
+
*
|
|
9465
|
+
* The result is printed first, so a caller that times out still has the id it
|
|
9466
|
+
* needs to come back to. Exit codes carry the outcome (see `errors.ts`): merged
|
|
9467
|
+
* is success, rejected is a decision the caller must branch on, and a timeout is
|
|
9468
|
+
* neither — nothing was decided, so the write must NOT be retried.
|
|
9469
|
+
*/
|
|
9470
|
+
async function awaitReview(client, opts, config, result) {
|
|
9471
|
+
const changeRequestId = findChangeRequestId(result);
|
|
9472
|
+
if (!changeRequestId) {
|
|
9473
|
+
console.error("[busabase-cli] --wait: this command filed no Change Request; nothing to wait for.");
|
|
9474
|
+
return;
|
|
9475
|
+
}
|
|
9476
|
+
const outcome = await waitForChangeRequest({
|
|
9477
|
+
changeRequestId,
|
|
9478
|
+
read: (id) => client.changeRequests.get({ changeRequestId: id }),
|
|
9479
|
+
timeoutMs: (opts.waitTimeout ?? DEFAULT_WAIT_SECONDS) * 1e3,
|
|
9480
|
+
notify: (message) => console.error(message)
|
|
9481
|
+
});
|
|
9482
|
+
console.log(render(opts.slim ? slim(outcome.changeRequest) : outcome.changeRequest, config.output));
|
|
9483
|
+
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}\`.`);
|
|
9484
|
+
if (outcome.status !== "merged") throw new CliOutcomeError("REVIEW_REJECTED", `${changeRequestId} was ${outcome.status}, not merged.`);
|
|
9485
|
+
}
|
|
9486
|
+
/**
|
|
9487
|
+
* `--all` re-runs the same handler with a moving cursor. The handler reads its
|
|
9488
|
+
* input off `opts`, so advancing the page is a matter of setting `opts.cursor`
|
|
9489
|
+
* before each call rather than teaching every command a second code path.
|
|
9490
|
+
*/
|
|
9491
|
+
async function streamAllPages(client, opts, config, handler) {
|
|
9492
|
+
if (await paginateAll({
|
|
9493
|
+
fetchPage: async (cursor) => {
|
|
9494
|
+
opts.cursor = cursor;
|
|
9495
|
+
const result = await handler(client, opts, config);
|
|
9496
|
+
return opts.slim ? slim(result) : result;
|
|
9497
|
+
},
|
|
9498
|
+
limit: typeof opts.limit === "number" ? opts.limit : void 0,
|
|
9499
|
+
maxItems: typeof opts.maxItems === "number" ? opts.maxItems : void 0,
|
|
9500
|
+
write: (line) => console.log(line),
|
|
9501
|
+
notify: (message) => console.error(message)
|
|
9502
|
+
}) === 0) console.error("[busabase-cli] no rows.");
|
|
9503
|
+
}
|
|
7769
9504
|
/**
|
|
7770
9505
|
* {@link runAction} for a command with one positional argument. Commander passes
|
|
7771
9506
|
* `(arg, opts, cmd)` to such an action, so the arity differs from a flags-only leaf.
|
|
@@ -7779,7 +9514,24 @@ function runArgAction(state, handler) {
|
|
|
7779
9514
|
state.config = config;
|
|
7780
9515
|
assertCredentialNotExpired(config.apiKey);
|
|
7781
9516
|
const result = await handler(arg, createBusabaseClient(config), opts, config);
|
|
7782
|
-
console.log(render(result, config.output));
|
|
9517
|
+
console.log(render(opts.slim ? slim(result) : result, config.output));
|
|
9518
|
+
};
|
|
9519
|
+
}
|
|
9520
|
+
/**
|
|
9521
|
+
* A command that touches only the local filesystem.
|
|
9522
|
+
*
|
|
9523
|
+
* `runArgAction` resolves credentials and builds a client before it calls the
|
|
9524
|
+
* handler, which is right for everything that talks to a workspace — and wrong
|
|
9525
|
+
* for `index`, whose whole job is to read a checkout. A CI runner building the
|
|
9526
|
+
* catalog has no Busabase server and no key, and demanding one would make the
|
|
9527
|
+
* catalog impossible to build in exactly the place it is meant to be built.
|
|
9528
|
+
*/
|
|
9529
|
+
function runLocalArgAction(handler) {
|
|
9530
|
+
return async (arg, _opts, cmd) => {
|
|
9531
|
+
const opts = cmd.optsWithGlobals();
|
|
9532
|
+
const config = resolveConfig(opts);
|
|
9533
|
+
const result = await handler(arg, opts, config);
|
|
9534
|
+
console.log(render(opts.slim ? slim(result) : result, config.output));
|
|
7783
9535
|
};
|
|
7784
9536
|
}
|
|
7785
9537
|
function pkgVersion() {
|
|
@@ -7912,11 +9664,33 @@ function unionVariants(inputSchema, discriminator = "operation") {
|
|
|
7912
9664
|
return variants;
|
|
7913
9665
|
}
|
|
7914
9666
|
/**
|
|
9667
|
+
* Words of an identifier, however it was spelled: `changeRequests` + `reviewMany`
|
|
9668
|
+
* and `change_requests_review_many` both reduce to
|
|
9669
|
+
* `change requests review many`. Lets the CLI compare its own generated command
|
|
9670
|
+
* paths against the MCP tool names in {@link TASK_SUPERSEDED_MCP_TOOLS} without
|
|
9671
|
+
* either side having to know how the other spells things.
|
|
9672
|
+
*/
|
|
9673
|
+
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(" ");
|
|
9674
|
+
/**
|
|
9675
|
+
* Endpoint commands a curated task command already covers.
|
|
9676
|
+
*
|
|
9677
|
+
* The MCP adapter has always dropped these so "a catalog carries one way to do
|
|
9678
|
+
* each job rather than two" — and then the CLI published BOTH spellings, which
|
|
9679
|
+
* is a third of a 30 KB help screen an agent reads to orient itself. Same list,
|
|
9680
|
+
* same reason, one more consumer.
|
|
9681
|
+
*
|
|
9682
|
+
* Hidden, never removed: every one of them still runs, so no existing script
|
|
9683
|
+
* breaks, and `--help-all` prints them.
|
|
9684
|
+
*/
|
|
9685
|
+
const SUPERSEDED_COMMAND_PATHS = new Set(TASK_SUPERSEDED_MCP_TOOLS.map((tool) => identifierWords([tool])));
|
|
9686
|
+
/**
|
|
7915
9687
|
* Procedures a curated command already exposes under a DIFFERENT name (so the
|
|
7916
9688
|
* name-collision guard wouldn't catch them). Keyed `group.procKey`.
|
|
7917
9689
|
*/
|
|
7918
9690
|
const GENERATED_SKIP = /* @__PURE__ */ new Set([
|
|
7919
9691
|
"search",
|
|
9692
|
+
"guides.list",
|
|
9693
|
+
"guides.read",
|
|
7920
9694
|
"auth.verify",
|
|
7921
9695
|
"records.search",
|
|
7922
9696
|
"records.listChangeRequests",
|
|
@@ -7946,7 +9720,8 @@ function registerGeneratedCommands(program, state) {
|
|
|
7946
9720
|
const addOneLeaf = (parent, navPath, procKey, proc, name, fields, description) => {
|
|
7947
9721
|
const { route } = proc["~orpc"];
|
|
7948
9722
|
if (parent.commands.some((c) => c.name() === name || c.aliases().includes(name))) return;
|
|
7949
|
-
const
|
|
9723
|
+
const superseded = SUPERSEDED_COMMAND_PATHS.has(identifierWords([parent.name(), name]));
|
|
9724
|
+
const leaf = parent.command(name, superseded ? { hidden: true } : void 0).description(description ?? route.summary ?? `${route.method} ${route.path}`);
|
|
7950
9725
|
for (const f of fields) {
|
|
7951
9726
|
if (f.pinned !== void 0) continue;
|
|
7952
9727
|
const flag = `--${kebab(f.key)}`;
|
|
@@ -8021,14 +9796,26 @@ function flagSignature(cmd) {
|
|
|
8021
9796
|
}
|
|
8022
9797
|
return parts.join(" ");
|
|
8023
9798
|
}
|
|
8024
|
-
|
|
9799
|
+
/**
|
|
9800
|
+
* Commander records `command(name, { hidden })` and `{ noHelp }` on `_hidden`;
|
|
9801
|
+
* this read `_noHelp`, which commander never sets, so it always answered false
|
|
9802
|
+
* and nothing was ever actually hidden from the root help.
|
|
9803
|
+
*/
|
|
9804
|
+
const isHelpHidden = (cmd) => Boolean(cmd._hidden) || Boolean(cmd._noHelp);
|
|
9805
|
+
/**
|
|
9806
|
+
* Set for the duration of a `--help-all` render. A module-level flag rather than
|
|
9807
|
+
* a parameter because commander calls the help hook itself, with no way to
|
|
9808
|
+
* thread an argument through.
|
|
9809
|
+
*/
|
|
9810
|
+
let showAllCommands = false;
|
|
8025
9811
|
/**
|
|
8026
9812
|
* Root help "Commands:" section: every leaf with its full flag signature (the flat
|
|
8027
9813
|
* commander listing would only show group names). Generated from the command tree,
|
|
8028
9814
|
* never hand-written.
|
|
8029
9815
|
*/
|
|
8030
|
-
function commandsSection(program) {
|
|
9816
|
+
function commandsSection(program, showAll = false) {
|
|
8031
9817
|
const lines = ["Commands:"];
|
|
9818
|
+
const skip = (cmd) => cmd.name() === "help" || !showAll && isHelpHidden(cmd);
|
|
8032
9819
|
const entry = (prefix, cmd) => {
|
|
8033
9820
|
const sig = [prefix, flagSignature(cmd)].filter(Boolean).join(" ");
|
|
8034
9821
|
const desc = cmd.description();
|
|
@@ -8037,12 +9824,12 @@ function commandsSection(program) {
|
|
|
8037
9824
|
return ` ${sig}\n${" ".repeat(44)}${desc}`;
|
|
8038
9825
|
};
|
|
8039
9826
|
for (const cmd of program.commands) {
|
|
8040
|
-
if (
|
|
8041
|
-
const leaves = cmd.commands.filter((leaf) =>
|
|
9827
|
+
if (skip(cmd)) continue;
|
|
9828
|
+
const leaves = cmd.commands.filter((leaf) => !skip(leaf));
|
|
8042
9829
|
if (leaves.length > 0) {
|
|
8043
9830
|
lines.push("");
|
|
8044
9831
|
for (const leaf of leaves) lines.push(entry(`${cmd.name()} ${leaf.name()}`, leaf));
|
|
8045
|
-
} else lines.push(entry(cmd.name(), cmd));
|
|
9832
|
+
} else if (cmd.commands.length === 0) lines.push(entry(cmd.name(), cmd));
|
|
8046
9833
|
}
|
|
8047
9834
|
return lines.join("\n");
|
|
8048
9835
|
}
|
|
@@ -8054,7 +9841,12 @@ Several accounts: \`login --profile <name>\` adds one, \`auth status\` lists the
|
|
|
8054
9841
|
\`auth switch\` changes which is active. Several spaces on one account (the norm on
|
|
8055
9842
|
Cloud): \`space list\` / \`space use\`.
|
|
8056
9843
|
|
|
9844
|
+
New here? \`busabase-cli guide\` is the operating manual — read \`workspace\` before
|
|
9845
|
+
proposing changes. Endpoint commands a task command already covers are hidden;
|
|
9846
|
+
\`--help-all\` prints them (they all still run).
|
|
9847
|
+
|
|
8057
9848
|
Docs: https://busabase.com/docs · Troubleshooting: ${DOCS_TROUBLESHOOTING}`;
|
|
9849
|
+
/** Exported for the help-example test, which walks the command tree in-process. */
|
|
8058
9850
|
function buildProgram(state = {}) {
|
|
8059
9851
|
const program = new Command("busabase-cli");
|
|
8060
9852
|
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));
|
|
@@ -8062,6 +9854,29 @@ function buildProgram(state = {}) {
|
|
|
8062
9854
|
addGlobalFlags(program.command("health")).description("Server health check (GET /api/health)").action(runAction(state, (_client, _opts, config) => rawFetch(config, "GET", "/api/health")));
|
|
8063
9855
|
addGlobalFlags(program.command("openapi")).description("Fetch the OpenAPI document").action(runAction(state, (_client, _opts, config) => rawFetch(config, "GET", "/api/v1/openapi.json")));
|
|
8064
9856
|
addGlobalFlags(program.command("whoami")).description("Active space, user, and membership").action(runAction(state, (client) => client.auth.verify()));
|
|
9857
|
+
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", `
|
|
9858
|
+
Read this BEFORE unfamiliar work. It carries the conventions this workspace
|
|
9859
|
+
expects, which no flag or schema can express on its own.
|
|
9860
|
+
|
|
9861
|
+
Examples:
|
|
9862
|
+
busabase-cli guide # what is available here
|
|
9863
|
+
busabase-cli guide workspace # the approval-first workflow
|
|
9864
|
+
busabase-cli guide airapp # REQUIRED before writing any AirApp file`).action(async (topic, _opts, cmd) => {
|
|
9865
|
+
const config = resolveConfig(cmd.optsWithGlobals());
|
|
9866
|
+
state.config = config;
|
|
9867
|
+
const client = createBusabaseClient(config);
|
|
9868
|
+
if (!topic) {
|
|
9869
|
+
console.log(render(await client.guides.list(), config.output));
|
|
9870
|
+
return;
|
|
9871
|
+
}
|
|
9872
|
+
const guide = await client.guides.read({ topic });
|
|
9873
|
+
if (config.output === "json") {
|
|
9874
|
+
console.log(render(guide, config.output));
|
|
9875
|
+
return;
|
|
9876
|
+
}
|
|
9877
|
+
console.log(guide.content);
|
|
9878
|
+
console.error(`\n[busabase-cli] other topics: ${guide.otherTopics.join(", ") || "(none)"}`);
|
|
9879
|
+
});
|
|
8065
9880
|
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", `
|
|
8066
9881
|
Busabase is an approval-first database and knowledge base for AI agents: agents
|
|
8067
9882
|
propose changes, humans review and merge what becomes trusted data.
|
|
@@ -8311,7 +10126,7 @@ Examples:
|
|
|
8311
10126
|
startLine: opts.startLine,
|
|
8312
10127
|
endLine: opts.endLine
|
|
8313
10128
|
})));
|
|
8314
|
-
addGlobalFlags(program.command("install")).description("Install a Busabase package from a GitHub repo into this space").argument("<github-url>", "GitHub repo URL, optionally /tree/<ref>[/<subdir>]").option("--into-folder <name>", "target folder slug (default: the package's manifest name)").option("--dry-run", "print the plan (tree, record counts, collisions) and create nothing").option("--auto-merge", "merge the package's records and docs on the spot instead of leaving them as change requests to review — this TRUSTS THE PACKAGE AUTHOR, since skills and AirApps carry code your agents will run (default: review first)").option("--rename", "install colliding items under suffixed slugs (-2, -3, …) instead of failing").addHelpText("after", `
|
|
10129
|
+
addGlobalFlags(program.command("install")).description("Install a Busabase package from a GitHub repo into this space").argument("<github-url>", "GitHub repo URL, optionally /tree/<ref>[/<subdir>]").option("--into-folder <name>", "target folder slug (default: the package's manifest name)").option("--dry-run", "print the plan (tree, record counts, collisions) and create nothing").option("--auto-merge", "merge the package's records and docs on the spot instead of leaving them as change requests to review — this TRUSTS THE PACKAGE AUTHOR, since skills and AirApps carry code your agents will run (default: review first)").option("--rename", "install colliding items under suffixed slugs (-2, -3, …) instead of failing").option("--skill <name>", "which package to install when the URL is a repository holding several (e.g. busabase/skills)").option("--no-sample-records", "propose a template's sample rows for review instead of merging them (default: merged, so the app is not empty on first open)").addHelpText("after", `
|
|
8315
10130
|
The URL's git ref is the version pin — a tag installs that tag's content forever,
|
|
8316
10131
|
even after the branch moves on. GITHUB_TOKEN is honored for private repos.
|
|
8317
10132
|
|
|
@@ -8325,6 +10140,16 @@ Folders, Bases, their fields and their views are structure and are always create
|
|
|
8325
10140
|
immediately. Records are content: by default they land as change requests for you
|
|
8326
10141
|
to review, and --auto-merge merges them on the spot instead.
|
|
8327
10142
|
|
|
10143
|
+
A TEMPLATE (a package that also carries a SKILL.md) installs as an app: its
|
|
10144
|
+
manual lands as a Skill node, its nodes are stamped as belonging to that app, its
|
|
10145
|
+
Base slugs are prefixed with the target folder so two templates cannot collide on
|
|
10146
|
+
a name like "settings", and its sample rows are merged so the app is not empty
|
|
10147
|
+
when you open it (--no-sample-records opts out). Its AirApp code and its Skill
|
|
10148
|
+
still wait for your review.
|
|
10149
|
+
|
|
10150
|
+
If the URL is a repository that holds several packages rather than being one,
|
|
10151
|
+
install lists them and you pick with --skill <name>.
|
|
10152
|
+
|
|
8328
10153
|
A package whose records carry relation values requires --auto-merge — a relation
|
|
8329
10154
|
stores the ids of the records it points at, and those exist only once the records
|
|
8330
10155
|
are merged, so review-first would install every relation empty. Defining a relation
|
|
@@ -8333,20 +10158,46 @@ field with nothing linked yet does not trigger this.`).action(runArgAction(state
|
|
|
8333
10158
|
dryRun: Boolean(opts.dryRun),
|
|
8334
10159
|
autoMerge: Boolean(opts.autoMerge),
|
|
8335
10160
|
rename: Boolean(opts.rename),
|
|
10161
|
+
skill: opts.skill,
|
|
10162
|
+
noSampleRecords: opts.sampleRecords === false,
|
|
8336
10163
|
json: config.output === "json",
|
|
8337
10164
|
githubToken: process.env.GITHUB_TOKEN,
|
|
8338
10165
|
serverUrl: config.baseUrl
|
|
8339
10166
|
})));
|
|
8340
|
-
addGlobalFlags(program.command("
|
|
10167
|
+
addGlobalFlags(program.command("index")).description("Build the Template Center catalog from a directory of skills").argument("<dir>", "directory to scan (a checkout of the skills repository)").requiredOption("--repo <owner/repo>", "repository the entries' subdirs are relative to").option("--ref <ref>", "git ref the entries are read at", "main").option("-o, --out <file>", "write the catalog here (default: print it)").option("--check", "exit non-zero if the file on disk is not what would be written").addHelpText("after", `
|
|
10168
|
+
Whether a skill is listed is decided by the same rules that decide how it
|
|
10169
|
+
installs, so a card can never promise something its install does not do. A skill
|
|
10170
|
+
that declared itself a template and did not qualify is reported with the reason
|
|
10171
|
+
rather than silently left out.
|
|
10172
|
+
|
|
10173
|
+
Examples:
|
|
10174
|
+
busabase-cli index . --repo busabase/skills -o templates.json
|
|
10175
|
+
busabase-cli index . --repo busabase/skills -o templates.json --check`).action(runLocalArgAction((dir, opts, config) => runIndex({
|
|
10176
|
+
dir,
|
|
10177
|
+
repo: opts.repo,
|
|
10178
|
+
ref: opts.ref || "main",
|
|
10179
|
+
out: opts.out,
|
|
10180
|
+
check: Boolean(opts.check),
|
|
10181
|
+
json: config.output === "json"
|
|
10182
|
+
})));
|
|
10183
|
+
addGlobalFlags(program.command("export")).description("Export a node subtree as a Busabase package directory you can push to GitHub").argument("<node-slug-or-id>", "the folder/node to export").requiredOption("-o, --out-dir <dir>", "output directory for the package").option("--name <name>", "package name (default: reuse busabase.json, else the node slug)").option("--dry-run", "list the files that would be written and write nothing").option("--template", "export as a template (an installable app): check it against the template rules and write a SKILL.md draft if the folder has no Skill node yet").addHelpText("after", `
|
|
8341
10184
|
Output is deterministic: exporting twice from an unchanged space produces
|
|
8342
10185
|
byte-identical files, so a GitHub diff shows exactly what changed.
|
|
8343
10186
|
|
|
8344
10187
|
Examples:
|
|
8345
10188
|
busabase-cli export support-kb -o ./support-kb-template
|
|
8346
|
-
busabase-cli export support-kb -o ./support-kb-template --name "Support KB" --dry-run
|
|
10189
|
+
busabase-cli export support-kb -o ./support-kb-template --name "Support KB" --dry-run
|
|
10190
|
+
busabase-cli export kelly-email -o ./kelly-email --template
|
|
10191
|
+
|
|
10192
|
+
--template makes the result installable as an APP rather than as a pile of
|
|
10193
|
+
tables: the folder's Skill node is lifted to the package root as SKILL.md (or a
|
|
10194
|
+
draft is written from the folder's structure for you to fill in), and anything
|
|
10195
|
+
still missing is reported. The same directory then works both as an Agent Skill
|
|
10196
|
+
and as something \`busabase-cli install\` can install.`).action(runArgAction(state, (nodeSlugOrId, client, opts, config) => runExport(client, nodeSlugOrId, {
|
|
8347
10197
|
outDir: opts.outDir,
|
|
8348
10198
|
name: opts.name,
|
|
8349
10199
|
dryRun: Boolean(opts.dryRun),
|
|
10200
|
+
template: Boolean(opts.template),
|
|
8350
10201
|
json: config.output === "json",
|
|
8351
10202
|
baseUrl: config.baseUrl
|
|
8352
10203
|
})));
|
|
@@ -8445,7 +10296,7 @@ To add content to a space that is already in use, use \`busabase-cli install\` i
|
|
|
8445
10296
|
leaf.usage(`${sig ? `${sig} ` : ""}[global flags]`);
|
|
8446
10297
|
}
|
|
8447
10298
|
}
|
|
8448
|
-
program.addHelpText("after", () => `\n${commandsSection(program)}\n${HELP_FOOTER}`);
|
|
10299
|
+
program.addHelpText("after", () => `\n${commandsSection(program, showAllCommands)}\n${HELP_FOOTER}`);
|
|
8449
10300
|
program.configureHelp({ visibleCommands: () => [] });
|
|
8450
10301
|
return program;
|
|
8451
10302
|
}
|
|
@@ -8460,17 +10311,48 @@ async function runCli(argv) {
|
|
|
8460
10311
|
program.outputHelp();
|
|
8461
10312
|
return 0;
|
|
8462
10313
|
}
|
|
10314
|
+
if (argv.includes("--help-all")) {
|
|
10315
|
+
console.log(renderedHelp({ showAll: true }));
|
|
10316
|
+
return 0;
|
|
10317
|
+
}
|
|
8463
10318
|
try {
|
|
8464
10319
|
await program.parseAsync(argv, { from: "user" });
|
|
8465
10320
|
return 0;
|
|
8466
10321
|
} catch (error) {
|
|
8467
|
-
|
|
10322
|
+
const json = errorOutputIsJson(state, argv);
|
|
10323
|
+
if (error instanceof CommanderError) {
|
|
10324
|
+
if (error.exitCode === 0) return 0;
|
|
10325
|
+
if (json) console.log(JSON.stringify({
|
|
10326
|
+
ok: false,
|
|
10327
|
+
code: "USAGE",
|
|
10328
|
+
message: error.message,
|
|
10329
|
+
retryable: false
|
|
10330
|
+
}, null, 2));
|
|
10331
|
+
return EXIT_CODES.USAGE;
|
|
10332
|
+
}
|
|
10333
|
+
const classified = classifyError(error);
|
|
10334
|
+
if (json) console.log(JSON.stringify(errorEnvelope(classified), null, 2));
|
|
8468
10335
|
console.error(explainError(error, state.config ?? resolveConfig({})));
|
|
8469
|
-
return
|
|
10336
|
+
return classified.exitCode;
|
|
8470
10337
|
}
|
|
8471
10338
|
}
|
|
8472
|
-
|
|
10339
|
+
/**
|
|
10340
|
+
* Whether a failed run should print the machine-readable envelope.
|
|
10341
|
+
*
|
|
10342
|
+
* `state.config` is set by the first command that resolves one, but a failure
|
|
10343
|
+
* can happen before that (an unknown option, a bad `--config` path), so the raw
|
|
10344
|
+
* argv is the fallback. Both spellings commander accepts are checked.
|
|
10345
|
+
*/
|
|
10346
|
+
function errorOutputIsJson(state, argv) {
|
|
10347
|
+
if (state.config) return state.config.output === "json";
|
|
10348
|
+
const flag = argv.indexOf("--output");
|
|
10349
|
+
if (flag !== -1 && argv[flag + 1] === "json") return true;
|
|
10350
|
+
if (argv.includes("--output=json")) return true;
|
|
10351
|
+
return configuredOutput() === "json";
|
|
10352
|
+
}
|
|
10353
|
+
function renderedHelp({ showAll = false } = {}) {
|
|
8473
10354
|
let out = "";
|
|
10355
|
+
showAllCommands = showAll;
|
|
8474
10356
|
const program = buildProgram();
|
|
8475
10357
|
program.configureOutput({
|
|
8476
10358
|
writeOut: (str) => {
|
|
@@ -8479,10 +10361,12 @@ function renderedHelp() {
|
|
|
8479
10361
|
writeErr: () => {}
|
|
8480
10362
|
});
|
|
8481
10363
|
program.outputHelp();
|
|
10364
|
+
showAllCommands = false;
|
|
8482
10365
|
return out;
|
|
8483
10366
|
}
|
|
8484
10367
|
/** Full root help text (what `busabase-cli --help` prints). Generated from the command tree. */
|
|
8485
10368
|
const HELP = renderedHelp();
|
|
10369
|
+
renderedHelp({ showAll: true });
|
|
8486
10370
|
/**
|
|
8487
10371
|
* Turn a low-level transport/HTTP error into an actionable message: which host was tried, the
|
|
8488
10372
|
* concrete next step (set a key for Cloud, point at a local server, or check connectivity), and a
|
|
@@ -8492,7 +10376,7 @@ function explainError(error, config) {
|
|
|
8492
10376
|
const base = config.baseUrl;
|
|
8493
10377
|
const rawMessage = error instanceof Error ? error.message : String(error);
|
|
8494
10378
|
const issueDetail = formatIssues(error.data?.issues);
|
|
8495
|
-
const msg = issueDetail ? `${rawMessage} — ${issueDetail}` : rawMessage;
|
|
10379
|
+
const msg = issueDetail && !rawMessage.includes(issueDetail) ? `${rawMessage} — ${issueDetail}` : rawMessage;
|
|
8496
10380
|
const status = error.status;
|
|
8497
10381
|
const lower = msg.toLowerCase();
|
|
8498
10382
|
let body;
|