busa-cli 0.18.0 → 0.19.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.js +1 -1
- package/dist/index.js +1 -1
- package/dist/{run-DD52HeGA.js → run-FjzYcGKi.js} +1696 -527
- 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
|
|
@@ -3958,6 +3958,210 @@ async function runLogout(options) {
|
|
|
3958
3958
|
config: activeCredentialPath()
|
|
3959
3959
|
};
|
|
3960
3960
|
}
|
|
3961
|
+
//#endregion
|
|
3962
|
+
//#region ../../packages/busabase-contract/src/domains/package/template.ts
|
|
3963
|
+
/**
|
|
3964
|
+
* Template extension of the `busabase-package@1` format (pure zod, client-safe).
|
|
3965
|
+
*
|
|
3966
|
+
* A **template** is a package that is ALSO an Agent Skill: the same directory
|
|
3967
|
+
* carries a root `SKILL.md` (what an agent reads) next to `busabase.json` +
|
|
3968
|
+
* `content/` (what installs into a space). Nothing under `content/` changes —
|
|
3969
|
+
* the whole increment lives OUTSIDE it:
|
|
3970
|
+
*
|
|
3971
|
+
* my-app/
|
|
3972
|
+
* ├── SKILL.md ← root skill entry (+ references/ agents/ scripts/)
|
|
3973
|
+
* ├── busabase.json ← manifest, now with an optional `template` object
|
|
3974
|
+
* └── content/ ← byte-identical to a plain busabase-package@1 tree
|
|
3975
|
+
*
|
|
3976
|
+
* Consequences worth stating once, because they are what make the format
|
|
3977
|
+
* "multiplexed" rather than a second format:
|
|
3978
|
+
* - A template directory is already a VALID package. Today's `install` can
|
|
3979
|
+
* install it (it just won't land the Skill node, stamp ownership, or merge
|
|
3980
|
+
* the sample records).
|
|
3981
|
+
* - Any package becomes a template by adding a root `SKILL.md` plus the
|
|
3982
|
+
* `template` object — no migration, no v2.
|
|
3983
|
+
* - `export --template` emits a `content/` byte-identical to plain `export`.
|
|
3984
|
+
*
|
|
3985
|
+
* See `apps/busabase/content/spec/template-center.md` (§5, §6.2a, §13).
|
|
3986
|
+
*/
|
|
3987
|
+
/**
|
|
3988
|
+
* The root skill entry file. Deliberately the same name a `skill` NODE uses as
|
|
3989
|
+
* its entry file, because it becomes exactly that on install: the reader lifts
|
|
3990
|
+
* it (plus the sidecar dirs below) into a `skill` node inside the target
|
|
3991
|
+
* folder, so the manual travels with the resources instead of staying on the
|
|
3992
|
+
* publisher's disk.
|
|
3993
|
+
*/
|
|
3994
|
+
const PACKAGE_SKILL_ENTRY = "SKILL.md";
|
|
3995
|
+
/**
|
|
3996
|
+
* Directories carried into the Skill node alongside `SKILL.md`.
|
|
3997
|
+
*
|
|
3998
|
+
* `scripts/` is included on purpose even though Busabase never executes it: the
|
|
3999
|
+
* Skill node is a file tree, a shell agent that pulls the skill back out gets a
|
|
4000
|
+
* working copy, and dropping the scripts would make the round-trip lossy. The
|
|
4001
|
+
* safety property comes from *nothing in Busabase running them*, not from
|
|
4002
|
+
* refusing to store them.
|
|
4003
|
+
*/
|
|
4004
|
+
const PACKAGE_SKILL_SIDECAR_DIRS = [
|
|
4005
|
+
"references",
|
|
4006
|
+
"agents",
|
|
4007
|
+
"scripts"
|
|
4008
|
+
];
|
|
4009
|
+
/**
|
|
4010
|
+
* Per-AirApp ignore file (gitignore syntax), read at
|
|
4011
|
+
* `content/<airapp>/.busabaseignore`.
|
|
4012
|
+
*
|
|
4013
|
+
* An AirApp node IS the project — nodepod runs `npm run dev` against it — so the
|
|
4014
|
+
* package cannot simply carry "the deployable subset" as a separate copy without
|
|
4015
|
+
* reintroducing the two-sources-of-truth problem the template format exists to
|
|
4016
|
+
* remove. The ignore file lets one directory be both: the repo keeps `test/`,
|
|
4017
|
+
* lockfiles and coverage; the node receives only what has to run.
|
|
4018
|
+
*
|
|
4019
|
+
* `package.json` and the entry it declares can never be ignored — a validator
|
|
4020
|
+
* error rather than a warning, because the resulting AirApp would install fine
|
|
4021
|
+
* and then fail to boot, which is the failure mode hardest to attribute.
|
|
4022
|
+
*/
|
|
4023
|
+
const PACKAGE_AIRAPP_IGNORE = ".busabaseignore";
|
|
4024
|
+
const TemplateAirAppRefSchema = z.object({
|
|
4025
|
+
/** Slug of the `content/<dir>` holding the AirApp. */
|
|
4026
|
+
slug: z.string().min(1),
|
|
4027
|
+
role: z.enum([
|
|
4028
|
+
"primary",
|
|
4029
|
+
"admin",
|
|
4030
|
+
"public",
|
|
4031
|
+
"tool"
|
|
4032
|
+
]),
|
|
4033
|
+
label: z.string().optional()
|
|
4034
|
+
});
|
|
4035
|
+
/**
|
|
4036
|
+
* Secrets the app expects to find in the Vault.
|
|
4037
|
+
*
|
|
4038
|
+
* DECLARED, never created: the package format has no slot for secret values and
|
|
4039
|
+
* must not grow one (the same "you cannot leak what the format cannot express"
|
|
4040
|
+
* rule the whole format is built on). Install surfaces these as a post-install
|
|
4041
|
+
* prompt; the user fills them in the Vault themselves.
|
|
4042
|
+
*/
|
|
4043
|
+
const TemplateSecretSchema = z.object({
|
|
4044
|
+
key: z.string().min(1),
|
|
4045
|
+
description: z.string().default(""),
|
|
4046
|
+
required: z.boolean().default(true)
|
|
4047
|
+
});
|
|
4048
|
+
const TemplateManifestSchema = z.object({
|
|
4049
|
+
/** Template Center category, e.g. `"crm"`, `"email"`, `"content"`. */
|
|
4050
|
+
category: z.string().min(1),
|
|
4051
|
+
tags: z.array(z.string()).default([]),
|
|
4052
|
+
/** Card/detail screenshots, package-relative (`assets/screenshots/overview.webp`). */
|
|
4053
|
+
screenshots: z.array(z.string()).default([]),
|
|
4054
|
+
/**
|
|
4055
|
+
* Ready-made prompts shown after install ("Ask agent" prefills the first).
|
|
4056
|
+
*
|
|
4057
|
+
* They are the difference between a folder of tables and something a user can
|
|
4058
|
+
* *use*: the point of a template is that the agent already knows the job, and
|
|
4059
|
+
* these are how that is made visible rather than left for the user to guess.
|
|
4060
|
+
*/
|
|
4061
|
+
agentPrompts: z.array(z.string()).default([]),
|
|
4062
|
+
/** Single-AirApp shorthand. Mutually exclusive with `airapps`. */
|
|
4063
|
+
airapp: z.string().optional(),
|
|
4064
|
+
/** Multi-AirApp form. Exactly one entry must have `role: "primary"`. */
|
|
4065
|
+
airapps: z.array(TemplateAirAppRefSchema).optional(),
|
|
4066
|
+
/**
|
|
4067
|
+
* Bumped by the author when the declared resource shape changes.
|
|
4068
|
+
*
|
|
4069
|
+
* Part of the ownership stamp, so BOTH doors must agree on it: the installer
|
|
4070
|
+
* writes it, and a skill's own `setup.mjs` compares against it to decide
|
|
4071
|
+
* whether a node it finds is its own current shape or an older one to repair.
|
|
4072
|
+
* Defaulted rather than required so an author who never versions their app
|
|
4073
|
+
* still gets a stamp both sides recognise.
|
|
4074
|
+
*/
|
|
4075
|
+
schemaVersion: z.number().int().nonnegative().default(1),
|
|
4076
|
+
vaultNamespace: z.string().optional(),
|
|
4077
|
+
secrets: z.array(TemplateSecretSchema).default([]),
|
|
4078
|
+
requires: z.object({ airapp: z.boolean().optional() }).default({})
|
|
4079
|
+
});
|
|
4080
|
+
/**
|
|
4081
|
+
* `metadata.busabase` inside the root `SKILL.md`'s YAML frontmatter.
|
|
4082
|
+
*
|
|
4083
|
+
* `template: true` is an EXPLICIT opt-in, not an inference from "this skill
|
|
4084
|
+
* happens to contain a package". Publishing a template means accepting that
|
|
4085
|
+
* installers will run its AirApp code and feed its SKILL.md to their agent; that
|
|
4086
|
+
* deserves a deliberate flag rather than a side effect of directory shape.
|
|
4087
|
+
*/
|
|
4088
|
+
const SkillBusabaseMetadataSchema = z.object({
|
|
4089
|
+
template: z.boolean().default(false),
|
|
4090
|
+
folderSlug: z.string().optional(),
|
|
4091
|
+
/** Resource keys the manual talks about; each must exist under `content/`. */
|
|
4092
|
+
resources: z.array(z.string()).default([]),
|
|
4093
|
+
risk: z.string().optional()
|
|
4094
|
+
});
|
|
4095
|
+
/** Root `SKILL.md` frontmatter, as far as the template format cares about it. */
|
|
4096
|
+
const SkillFrontmatterSchema = z.object({
|
|
4097
|
+
name: z.string().min(1),
|
|
4098
|
+
description: z.string().default(""),
|
|
4099
|
+
metadata: z.object({ busabase: SkillBusabaseMetadataSchema.optional() }).passthrough().optional()
|
|
4100
|
+
});
|
|
4101
|
+
/**
|
|
4102
|
+
* Resolve which AirApp is the app, or explain why it cannot be resolved.
|
|
4103
|
+
*
|
|
4104
|
+
* Order matters and mirrors §5.4: an explicit declaration always wins, a single
|
|
4105
|
+
* candidate is unambiguous enough to accept, and anything else is an error the
|
|
4106
|
+
* publisher must resolve. Ambiguity is never resolved by picking the first
|
|
4107
|
+
* alphabetically — a template that silently opens the wrong app is worse than
|
|
4108
|
+
* one that refuses to publish.
|
|
4109
|
+
*/
|
|
4110
|
+
const resolvePrimaryAirApp = (template, airAppSlugsInContent) => {
|
|
4111
|
+
if (template?.airapp && template.airapps?.length) return { error: "`template.airapp` and `template.airapps` are mutually exclusive." };
|
|
4112
|
+
if (template?.airapp) return airAppSlugsInContent.includes(template.airapp) ? { slug: template.airapp } : { error: `template.airapp "${template.airapp}" has no matching AirApp under content/.` };
|
|
4113
|
+
if (template?.airapps?.length) {
|
|
4114
|
+
const primaries = template.airapps.filter((entry) => entry.role === "primary");
|
|
4115
|
+
if (primaries.length !== 1) return { error: `template.airapps must declare exactly one entry with role "primary" (found ${primaries.length}).` };
|
|
4116
|
+
const [primary] = primaries;
|
|
4117
|
+
const unknown = template.airapps.filter((entry) => !airAppSlugsInContent.includes(entry.slug));
|
|
4118
|
+
if (unknown.length) return { error: `template.airapps references AirApps missing from content/: ${unknown.map((entry) => entry.slug).join(", ")}.` };
|
|
4119
|
+
return { slug: primary.slug };
|
|
4120
|
+
}
|
|
4121
|
+
if (airAppSlugsInContent.length === 1) return { slug: airAppSlugsInContent[0] };
|
|
4122
|
+
if (airAppSlugsInContent.length === 0) return { error: "This package contains no AirApp." };
|
|
4123
|
+
return { error: `content/ has ${airAppSlugsInContent.length} AirApps; declare which one is primary via template.airapp or template.airapps.` };
|
|
4124
|
+
};
|
|
4125
|
+
/** Stamp on every resource node (Base, Drive, AirApp, …) an app owns. */
|
|
4126
|
+
const AppResourceOwnershipSchema = z.object({
|
|
4127
|
+
appId: z.string().min(1),
|
|
4128
|
+
/** Stable internal handle (`"contacts"`), NOT the installed slug. */
|
|
4129
|
+
resourceKey: z.string().min(1),
|
|
4130
|
+
schemaVersion: z.number().int().nonnegative()
|
|
4131
|
+
});
|
|
4132
|
+
/**
|
|
4133
|
+
* The `resourceKey` reserved for an app's root Folder.
|
|
4134
|
+
*
|
|
4135
|
+
* `busabase-sdk` recognises an app's own Folder by looking for exactly this
|
|
4136
|
+
* value (`ownsAppRoot`), so the installer must write it too — a Folder stamped
|
|
4137
|
+
* with anything else reads as a stranger's, and the skill's own `setup.mjs`
|
|
4138
|
+
* then refuses to touch its own workspace with `SETUP_CONFLICT`. Exported so
|
|
4139
|
+
* neither side carries the string literal privately.
|
|
4140
|
+
*/
|
|
4141
|
+
const APP_ROOT_RESOURCE_KEY = "app-root";
|
|
4142
|
+
AppResourceOwnershipSchema.extend({
|
|
4143
|
+
resourceKey: z.literal(APP_ROOT_RESOURCE_KEY),
|
|
4144
|
+
version: z.string().optional(),
|
|
4145
|
+
source: z.object({
|
|
4146
|
+
repo: z.string().optional(),
|
|
4147
|
+
ref: z.string().optional(),
|
|
4148
|
+
subdir: z.string().optional()
|
|
4149
|
+
}).optional(),
|
|
4150
|
+
installedAt: z.string().optional()
|
|
4151
|
+
});
|
|
4152
|
+
/**
|
|
4153
|
+
* Marks the Skill node lifted from the package root.
|
|
4154
|
+
*
|
|
4155
|
+
* Needed in both directions: export lifts exactly this node back out to the
|
|
4156
|
+
* package root instead of writing it under `content/` (which would create the
|
|
4157
|
+
* duplicate the format exists to avoid), and the agent wiring lists exactly
|
|
4158
|
+
* these nodes as "app manuals available in this space".
|
|
4159
|
+
*/
|
|
4160
|
+
const TEMPLATE_SKILL_METADATA_KEY = "isTemplateSkill";
|
|
4161
|
+
z.object({
|
|
4162
|
+
appId: z.string().min(1),
|
|
4163
|
+
[TEMPLATE_SKILL_METADATA_KEY]: z.literal(true)
|
|
4164
|
+
});
|
|
3961
4165
|
const LocaleSchema = z.enum({
|
|
3962
4166
|
defaultLocale: "en",
|
|
3963
4167
|
locales: [
|
|
@@ -5080,7 +5284,15 @@ const PackageManifestSchema = z.object({
|
|
|
5080
5284
|
author: z.string().optional(),
|
|
5081
5285
|
license: z.string().optional(),
|
|
5082
5286
|
homepage: z.string().url().optional(),
|
|
5083
|
-
tags: z.array(z.string()).default([])
|
|
5287
|
+
tags: z.array(z.string()).default([]),
|
|
5288
|
+
/**
|
|
5289
|
+
* Present ⇢ this package is also an Agent Skill (a "template"): see
|
|
5290
|
+
* `./template.ts`. Optional and additive — a package without it behaves
|
|
5291
|
+
* exactly as before, and a reader that ignores it still installs correctly.
|
|
5292
|
+
* The flag alone is not enough to be treated as a template; the root
|
|
5293
|
+
* `SKILL.md` must opt in too (`validateTemplate`).
|
|
5294
|
+
*/
|
|
5295
|
+
template: TemplateManifestSchema.optional()
|
|
5084
5296
|
});
|
|
5085
5297
|
/** `_node.json` — marks a directory as a verbatim-content file-tree node. */
|
|
5086
5298
|
const PackageFileTreeNodeMetaSchema = z.object({
|
|
@@ -5327,6 +5539,18 @@ const compareNodes = (a, b) => {
|
|
|
5327
5539
|
const sortNodes = (nodes) => [...nodes].sort(compareNodes);
|
|
5328
5540
|
/** `getting-started` → `Getting Started`; the default name for a folder with no `_folder.json`. */
|
|
5329
5541
|
const humanizeSlug = (slug) => slug.split("-").filter(Boolean).map((word) => word.charAt(0).toUpperCase() + word.slice(1)).join(" ");
|
|
5542
|
+
/**
|
|
5543
|
+
* Extension → mime type, which decides one thing that matters: a text file goes
|
|
5544
|
+
* INLINE into a file-tree create, and anything else has to be uploaded as an
|
|
5545
|
+
* asset first (see `isTextMimeType`). Getting it wrong on a source file means
|
|
5546
|
+
* the app's own code takes the binary path — it needs an upload endpoint to
|
|
5547
|
+
* install at all, and it lands as an opaque blob rather than something a
|
|
5548
|
+
* reviewer or an agent can read.
|
|
5549
|
+
*
|
|
5550
|
+
* The module-flavoured JavaScript and TypeScript extensions are here because
|
|
5551
|
+
* real projects use them: `scripts/setup.mjs` is the ordinary name for a Node
|
|
5552
|
+
* script, and installing kelly-email surfaced exactly that gap.
|
|
5553
|
+
*/
|
|
5330
5554
|
const MIME_BY_EXT$1 = {
|
|
5331
5555
|
".css": "text/css",
|
|
5332
5556
|
".csv": "text/csv",
|
|
@@ -5335,7 +5559,12 @@ const MIME_BY_EXT$1 = {
|
|
|
5335
5559
|
".html": "text/html",
|
|
5336
5560
|
".jpeg": "image/jpeg",
|
|
5337
5561
|
".jpg": "image/jpeg",
|
|
5562
|
+
".cjs": "text/javascript",
|
|
5563
|
+
".cts": "text/typescript",
|
|
5338
5564
|
".js": "text/javascript",
|
|
5565
|
+
".jsx": "text/javascript",
|
|
5566
|
+
".mjs": "text/javascript",
|
|
5567
|
+
".mts": "text/typescript",
|
|
5339
5568
|
".json": "application/json",
|
|
5340
5569
|
".md": "text/markdown",
|
|
5341
5570
|
".pdf": "application/pdf",
|
|
@@ -5343,6 +5572,7 @@ const MIME_BY_EXT$1 = {
|
|
|
5343
5572
|
".py": "text/x-python",
|
|
5344
5573
|
".svg": "image/svg+xml",
|
|
5345
5574
|
".ts": "text/typescript",
|
|
5575
|
+
".tsx": "text/typescript",
|
|
5346
5576
|
".txt": "text/plain",
|
|
5347
5577
|
".webp": "image/webp",
|
|
5348
5578
|
".yaml": "text/yaml",
|
|
@@ -5470,6 +5700,7 @@ const applyInstall = async (client, plan, options) => {
|
|
|
5470
5700
|
const fieldIds = /* @__PURE__ */ new Map();
|
|
5471
5701
|
const bases = [];
|
|
5472
5702
|
const progress = options.onProgress ?? (() => {});
|
|
5703
|
+
const app = resolveAppContext(plan, options);
|
|
5473
5704
|
const docAssetIds = await uploadDocAssets(client, plan.tree.assets ?? [], state, progress, options.serverUrl);
|
|
5474
5705
|
progress("Pass 1/5: creating the node tree…");
|
|
5475
5706
|
if (plan.existingTargetFolderNodeId) state.targetFolderNodeId = plan.existingTargetFolderNodeId;
|
|
@@ -5479,11 +5710,23 @@ const applyInstall = async (client, plan, options) => {
|
|
|
5479
5710
|
slug: plan.targetFolderSlug,
|
|
5480
5711
|
name: plan.tree.manifest.name,
|
|
5481
5712
|
description: plan.tree.manifest.description,
|
|
5482
|
-
submittedBy: options.submittedBy
|
|
5713
|
+
submittedBy: options.submittedBy,
|
|
5714
|
+
metadata: app?.rootMetadata
|
|
5483
5715
|
});
|
|
5484
5716
|
state.created.folders++;
|
|
5485
5717
|
}
|
|
5486
|
-
await createStructure(client, plan.tree.nodes, state.targetFolderNodeId, options, state, bases, fieldIds, docAssetIds);
|
|
5718
|
+
await createStructure(client, plan.tree.nodes, state.targetFolderNodeId, options, state, bases, fieldIds, docAssetIds, app);
|
|
5719
|
+
if (app?.rootSkill) {
|
|
5720
|
+
progress("Pass 1/5: installing the app skill…");
|
|
5721
|
+
await createFileTreeNode(client, {
|
|
5722
|
+
type: "skill",
|
|
5723
|
+
slug: app.rootSkill.slug,
|
|
5724
|
+
name: app.rootSkill.name,
|
|
5725
|
+
description: app.rootSkill.description,
|
|
5726
|
+
position: void 0,
|
|
5727
|
+
files: app.rootSkill.files
|
|
5728
|
+
}, state.targetFolderNodeId, options, state, app, { [TEMPLATE_SKILL_METADATA_KEY]: true });
|
|
5729
|
+
}
|
|
5487
5730
|
const deferred = bases.filter((base) => base.node.base.fields.some(isDeferredField));
|
|
5488
5731
|
if (deferred.length > 0) {
|
|
5489
5732
|
progress("Pass 2/5: adding relation and AI fields…");
|
|
@@ -5526,7 +5769,7 @@ const applyInstall = async (client, plan, options) => {
|
|
|
5526
5769
|
const withRecords = bases.filter((base) => base.node.records.length > 0);
|
|
5527
5770
|
if (withRecords.length > 0) {
|
|
5528
5771
|
progress("Pass 4/5: creating records…");
|
|
5529
|
-
for (const base of withRecords) await createRecords(client, base, plan, options, state, recordIdsByKey);
|
|
5772
|
+
for (const base of withRecords) await createRecords(client, base, plan, options, state, recordIdsByKey, app);
|
|
5530
5773
|
}
|
|
5531
5774
|
const relationFieldSlugs = /* @__PURE__ */ new Map();
|
|
5532
5775
|
for (const base of bases) {
|
|
@@ -5543,6 +5786,58 @@ const applyInstall = async (client, plan, options) => {
|
|
|
5543
5786
|
}
|
|
5544
5787
|
return state;
|
|
5545
5788
|
};
|
|
5789
|
+
const resolveAppContext = (plan, options) => {
|
|
5790
|
+
if (!plan.isTemplate) return void 0;
|
|
5791
|
+
const manifest = plan.tree.manifest;
|
|
5792
|
+
const schemaVersion = manifest.template?.schemaVersion ?? 1;
|
|
5793
|
+
const now = options.now ?? (() => (/* @__PURE__ */ new Date()).toISOString());
|
|
5794
|
+
const rootStamp = {
|
|
5795
|
+
appId: manifest.name,
|
|
5796
|
+
resourceKey: APP_ROOT_RESOURCE_KEY,
|
|
5797
|
+
schemaVersion,
|
|
5798
|
+
...manifest.version ? { version: manifest.version } : {},
|
|
5799
|
+
...options.source ? { source: options.source } : {},
|
|
5800
|
+
installedAt: now()
|
|
5801
|
+
};
|
|
5802
|
+
return {
|
|
5803
|
+
appId: manifest.name,
|
|
5804
|
+
schemaVersion,
|
|
5805
|
+
rootMetadata: rootStamp,
|
|
5806
|
+
rootSkill: plan.tree.rootSkill,
|
|
5807
|
+
resourceKeysBySlug: plan.resourceKeysBySlug,
|
|
5808
|
+
mergeSampleRecords: options.installSampleRecords ?? true
|
|
5809
|
+
};
|
|
5810
|
+
};
|
|
5811
|
+
/**
|
|
5812
|
+
* Stamp a node the installer just created.
|
|
5813
|
+
*
|
|
5814
|
+
* Separate from creation because `bases.create` and `fileTrees.create` take no
|
|
5815
|
+
* metadata — only `nodes.createChangeRequest` does, which is why the Folder can
|
|
5816
|
+
* be stamped inline and these cannot. A stamp that fails is a warning, never a
|
|
5817
|
+
* failed install: the resources are already there and useful, and the only loss
|
|
5818
|
+
* is that the skill's own `setup.mjs` will later offer to re-claim them, which
|
|
5819
|
+
* it is built to do (that is its "repair" path).
|
|
5820
|
+
*/
|
|
5821
|
+
const resourceStamp = (app, installedSlug, extra = {}) => {
|
|
5822
|
+
return {
|
|
5823
|
+
appId: app.appId,
|
|
5824
|
+
resourceKey: app.resourceKeysBySlug[installedSlug] ?? installedSlug,
|
|
5825
|
+
schemaVersion: app.schemaVersion,
|
|
5826
|
+
...extra
|
|
5827
|
+
};
|
|
5828
|
+
};
|
|
5829
|
+
const stampResource = async (client, app, nodeId, installedSlug, state, extra = {}) => {
|
|
5830
|
+
if (!app) return;
|
|
5831
|
+
const stamp = resourceStamp(app, installedSlug, extra);
|
|
5832
|
+
try {
|
|
5833
|
+
await client.nodes.updateMetadata({
|
|
5834
|
+
nodeId,
|
|
5835
|
+
metadata: stamp
|
|
5836
|
+
});
|
|
5837
|
+
} catch (error) {
|
|
5838
|
+
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.`);
|
|
5839
|
+
}
|
|
5840
|
+
};
|
|
5546
5841
|
/**
|
|
5547
5842
|
* Re-create the package's carried Doc images on THIS host and return the
|
|
5548
5843
|
* old id → new id map the Doc bodies are rewritten through.
|
|
@@ -5566,7 +5861,7 @@ const uploadDocAssets = async (client, assets, state, progress, serverUrl) => {
|
|
|
5566
5861
|
}
|
|
5567
5862
|
return idMap;
|
|
5568
5863
|
};
|
|
5569
|
-
const createStructure = async (client, nodes, parentNodeId, options, state, bases, fieldIds, docAssetIds) => {
|
|
5864
|
+
const createStructure = async (client, nodes, parentNodeId, options, state, bases, fieldIds, docAssetIds, app) => {
|
|
5570
5865
|
for (const node of nodes) switch (node.type) {
|
|
5571
5866
|
case "folder": {
|
|
5572
5867
|
const nodeId = await createFolderNode(client, {
|
|
@@ -5577,7 +5872,7 @@ const createStructure = async (client, nodes, parentNodeId, options, state, base
|
|
|
5577
5872
|
submittedBy: options.submittedBy
|
|
5578
5873
|
});
|
|
5579
5874
|
state.created.folders++;
|
|
5580
|
-
await createStructure(client, node.children, nodeId, options, state, bases, fieldIds, docAssetIds);
|
|
5875
|
+
await createStructure(client, node.children, nodeId, options, state, bases, fieldIds, docAssetIds, app);
|
|
5581
5876
|
break;
|
|
5582
5877
|
}
|
|
5583
5878
|
case "doc": {
|
|
@@ -5595,12 +5890,12 @@ const createStructure = async (client, nodes, parentNodeId, options, state, base
|
|
|
5595
5890
|
break;
|
|
5596
5891
|
}
|
|
5597
5892
|
case "base":
|
|
5598
|
-
await createBase(client, node, parentNodeId, options, state, bases, fieldIds);
|
|
5893
|
+
await createBase(client, node, parentNodeId, options, state, bases, fieldIds, app);
|
|
5599
5894
|
break;
|
|
5600
5895
|
case "skill":
|
|
5601
5896
|
case "airapp":
|
|
5602
5897
|
case "drive":
|
|
5603
|
-
await createFileTreeNode(client, node, parentNodeId, options, state);
|
|
5898
|
+
await createFileTreeNode(client, node, parentNodeId, options, state, app);
|
|
5604
5899
|
break;
|
|
5605
5900
|
case "file": {
|
|
5606
5901
|
let asset;
|
|
@@ -5639,13 +5934,14 @@ const createFolderNode = async (client, input) => {
|
|
|
5639
5934
|
slug: input.slug,
|
|
5640
5935
|
name: input.name,
|
|
5641
5936
|
description: input.description,
|
|
5642
|
-
parentNodeId: input.parentNodeId
|
|
5937
|
+
parentNodeId: input.parentNodeId,
|
|
5938
|
+
...input.metadata ? { metadata: input.metadata } : {}
|
|
5643
5939
|
}]
|
|
5644
5940
|
})).operations[0]?.nodeId;
|
|
5645
5941
|
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
5942
|
return nodeId;
|
|
5647
5943
|
};
|
|
5648
|
-
const createBase = async (client, node, parentNodeId, options, state, bases, fieldIds) => {
|
|
5944
|
+
const createBase = async (client, node, parentNodeId, options, state, bases, fieldIds, app) => {
|
|
5649
5945
|
const immediateFields = node.base.fields.filter((field) => !isDeferredField(field)).map((field) => ({
|
|
5650
5946
|
slug: field.slug,
|
|
5651
5947
|
name: field.name,
|
|
@@ -5668,6 +5964,7 @@ const createBase = async (client, node, parentNodeId, options, state, bases, fie
|
|
|
5668
5964
|
baseId: result.id
|
|
5669
5965
|
});
|
|
5670
5966
|
indexFields(fieldIds, node.slug, result.fields);
|
|
5967
|
+
await stampResource(client, app, result.nodeId, node.slug, state);
|
|
5671
5968
|
for (const view of node.base.views) {
|
|
5672
5969
|
if (!(await client.views.changeRequest({
|
|
5673
5970
|
operation: "create",
|
|
@@ -5682,7 +5979,7 @@ const createBase = async (client, node, parentNodeId, options, state, bases, fie
|
|
|
5682
5979
|
state.created.views++;
|
|
5683
5980
|
}
|
|
5684
5981
|
};
|
|
5685
|
-
const createFileTreeNode = async (client, node, parentNodeId, options, state) => {
|
|
5982
|
+
const createFileTreeNode = async (client, node, parentNodeId, options, state, app, extraMetadata = {}) => {
|
|
5686
5983
|
const files = (await Promise.all(node.files.map(async (entry) => {
|
|
5687
5984
|
const mimeType = guessMimeType$1(entry.path);
|
|
5688
5985
|
if (isTextMimeType(mimeType)) return {
|
|
@@ -5709,6 +6006,7 @@ const createFileTreeNode = async (client, node, parentNodeId, options, state) =>
|
|
|
5709
6006
|
name: node.name,
|
|
5710
6007
|
description: node.description,
|
|
5711
6008
|
files,
|
|
6009
|
+
...app ? { metadata: resourceStamp(app, node.slug, extraMetadata) } : {},
|
|
5712
6010
|
mergeMode: "replace",
|
|
5713
6011
|
autoMerge: options.autoMerge
|
|
5714
6012
|
})).materialized) {
|
|
@@ -5856,7 +6154,8 @@ const toCreatableFields = (node, fields) => {
|
|
|
5856
6154
|
}
|
|
5857
6155
|
return result;
|
|
5858
6156
|
};
|
|
5859
|
-
const createRecords = async (client, base, plan, options, state, recordIdsByKey) => {
|
|
6157
|
+
const createRecords = async (client, base, plan, options, state, recordIdsByKey, app) => {
|
|
6158
|
+
const mergeRecords = options.autoMerge || (app?.mergeSampleRecords ?? false);
|
|
5860
6159
|
const records = [...base.node.records].sort((a, b) => a.key.localeCompare(b.key, "en"));
|
|
5861
6160
|
for (let offset = 0; offset < records.length; offset += 200) {
|
|
5862
6161
|
const batch = records.slice(offset, offset + 200);
|
|
@@ -5866,9 +6165,9 @@ const createRecords = async (client, base, plan, options, state, recordIdsByKey)
|
|
|
5866
6165
|
message: `Install ${batch.length} record(s) into ${base.node.slug}`,
|
|
5867
6166
|
submittedBy: options.submittedBy,
|
|
5868
6167
|
idempotencyKey: batchIdempotencyKey(plan.tree.manifest.name, batch[0].key),
|
|
5869
|
-
autoMerge:
|
|
6168
|
+
autoMerge: mergeRecords
|
|
5870
6169
|
});
|
|
5871
|
-
if (!
|
|
6170
|
+
if (!mergeRecords) {
|
|
5872
6171
|
state.pendingChangeRequests++;
|
|
5873
6172
|
continue;
|
|
5874
6173
|
}
|
|
@@ -5970,19 +6269,115 @@ const findSourceNode = (nodes, slugOrId) => {
|
|
|
5970
6269
|
};
|
|
5971
6270
|
const collectPackageTree = async (client, root, options) => {
|
|
5972
6271
|
const children = root.children ?? [];
|
|
5973
|
-
const
|
|
6272
|
+
const { skillSource, rest } = partitionTemplateSkill(children);
|
|
6273
|
+
const nodes = await collectNodes(client, rest, options);
|
|
5974
6274
|
const tree = {
|
|
5975
6275
|
manifest: {
|
|
5976
6276
|
...options.manifest,
|
|
5977
6277
|
format: PACKAGE_FORMAT
|
|
5978
6278
|
},
|
|
5979
|
-
nodes,
|
|
6279
|
+
nodes: restorePackageSlugs(nodes, children),
|
|
6280
|
+
...skillSource ? { rootSkill: await collectRootSkill(client, skillSource, options) } : {},
|
|
5980
6281
|
assets: await collectDocAssets(client, nodes, options)
|
|
5981
6282
|
};
|
|
5982
6283
|
assertSelfContained(tree, options);
|
|
5983
6284
|
return tree;
|
|
5984
6285
|
};
|
|
5985
6286
|
/**
|
|
6287
|
+
* Undo the slugs INSTALL chose, restoring the ones the package declared.
|
|
6288
|
+
*
|
|
6289
|
+
* Install prefixes a template's Base slugs with the target folder (`contacts` →
|
|
6290
|
+
* `kelly-crm-contacts`) and records the original as the node's `resourceKey`.
|
|
6291
|
+
* Exporting the installed slug would make the package say something its author
|
|
6292
|
+
* never wrote — and re-installing it would prefix the prefix, or collide
|
|
6293
|
+
* outright. Reading the stamp back makes export→install→export a fixed point,
|
|
6294
|
+
* which is the property that lets a user install a template, adjust it, and
|
|
6295
|
+
* publish the result without the names drifting a little further each time.
|
|
6296
|
+
*
|
|
6297
|
+
* The root folder's own stamp is skipped: its `resourceKey` is the reserved
|
|
6298
|
+
* `app-root` marker, not a slug anything should be named after.
|
|
6299
|
+
*/
|
|
6300
|
+
const restorePackageSlugs = (nodes, sources) => {
|
|
6301
|
+
const renames = /* @__PURE__ */ new Map();
|
|
6302
|
+
const index = (list) => {
|
|
6303
|
+
for (const source of list) {
|
|
6304
|
+
const key = source.metadata?.resourceKey;
|
|
6305
|
+
if (typeof key === "string" && key !== "app-root" && key !== source.slug && NODE_SLUG_PATTERN.test(key)) renames.set(source.slug, key);
|
|
6306
|
+
if (source.children) index(source.children);
|
|
6307
|
+
}
|
|
6308
|
+
};
|
|
6309
|
+
index(sources);
|
|
6310
|
+
if (renames.size === 0) return [...nodes];
|
|
6311
|
+
const rewriteField = (field) => {
|
|
6312
|
+
const target = field.options.targetBaseSlug;
|
|
6313
|
+
if (!target) return field;
|
|
6314
|
+
const renamed = renames.get(target);
|
|
6315
|
+
return renamed ? {
|
|
6316
|
+
...field,
|
|
6317
|
+
options: {
|
|
6318
|
+
...field.options,
|
|
6319
|
+
targetBaseSlug: renamed
|
|
6320
|
+
}
|
|
6321
|
+
} : field;
|
|
6322
|
+
};
|
|
6323
|
+
const rewrite = (node) => {
|
|
6324
|
+
const slug = renames.get(node.slug) ?? node.slug;
|
|
6325
|
+
if (node.type === "folder") return {
|
|
6326
|
+
...node,
|
|
6327
|
+
slug,
|
|
6328
|
+
children: node.children.map(rewrite)
|
|
6329
|
+
};
|
|
6330
|
+
if (node.type === "base") return {
|
|
6331
|
+
...node,
|
|
6332
|
+
slug,
|
|
6333
|
+
base: {
|
|
6334
|
+
...node.base,
|
|
6335
|
+
fields: node.base.fields.map(rewriteField)
|
|
6336
|
+
}
|
|
6337
|
+
};
|
|
6338
|
+
return {
|
|
6339
|
+
...node,
|
|
6340
|
+
slug
|
|
6341
|
+
};
|
|
6342
|
+
};
|
|
6343
|
+
return nodes.map(rewrite);
|
|
6344
|
+
};
|
|
6345
|
+
/**
|
|
6346
|
+
* Separate the app's manual from its resources.
|
|
6347
|
+
*
|
|
6348
|
+
* The Skill node stamped `isTemplateSkill` is the one an install lifted out of a
|
|
6349
|
+
* package root, so export has to put it back there rather than under `content/`.
|
|
6350
|
+
* Writing it as a normal node would make every export→install cycle add another
|
|
6351
|
+
* copy of the manual beside the previous one.
|
|
6352
|
+
*
|
|
6353
|
+
* Only the FIRST such node is lifted: two of them in one folder is a state no
|
|
6354
|
+
* install can produce, and silently merging them would be a guess. The extra one
|
|
6355
|
+
* is exported as an ordinary Skill node, which is visible and fixable, rather
|
|
6356
|
+
* than dropped.
|
|
6357
|
+
*/
|
|
6358
|
+
const partitionTemplateSkill = (children) => {
|
|
6359
|
+
const index = children.findIndex((node) => node.type === "skill" && node.metadata?.["isTemplateSkill"] === true);
|
|
6360
|
+
if (index === -1) return { rest: [...children] };
|
|
6361
|
+
return {
|
|
6362
|
+
skillSource: children[index],
|
|
6363
|
+
rest: children.filter((_, position) => position !== index)
|
|
6364
|
+
};
|
|
6365
|
+
};
|
|
6366
|
+
const collectRootSkill = async (client, source, options) => {
|
|
6367
|
+
const node = await collectFileTree(client, source, "skill", {
|
|
6368
|
+
slug: source.slug,
|
|
6369
|
+
name: source.name,
|
|
6370
|
+
description: source.description ?? "",
|
|
6371
|
+
position: source.position
|
|
6372
|
+
}, options);
|
|
6373
|
+
return {
|
|
6374
|
+
slug: source.slug,
|
|
6375
|
+
name: source.name,
|
|
6376
|
+
description: source.description ?? "",
|
|
6377
|
+
files: node.files
|
|
6378
|
+
};
|
|
6379
|
+
};
|
|
6380
|
+
/**
|
|
5986
6381
|
* The bytes behind every `` in every exported Doc.
|
|
5987
6382
|
*
|
|
5988
6383
|
* A Doc's images are the one part of a Doc that is not in its markdown, and
|
|
@@ -6303,224 +6698,131 @@ const assertSelfContained = (tree, options) => {
|
|
|
6303
6698
|
}
|
|
6304
6699
|
};
|
|
6305
6700
|
//#endregion
|
|
6306
|
-
//#region ../../packages/busabase-package/src/
|
|
6701
|
+
//#region ../../packages/busabase-package/src/frontmatter.ts
|
|
6307
6702
|
/**
|
|
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:
|
|
6703
|
+
* YAML frontmatter for doc nodes (§6.3): `name`, `description?`, `position?` above
|
|
6704
|
+
* the markdown body.
|
|
6314
6705
|
*
|
|
6315
|
-
*
|
|
6316
|
-
*
|
|
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.
|
|
6706
|
+
* Writing is deterministic (§6.6): a fixed key order and LF endings, so re-exporting
|
|
6707
|
+
* an unchanged doc is byte-identical.
|
|
6323
6708
|
*/
|
|
6324
|
-
|
|
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());
|
|
6709
|
+
const DELIMITER = "---";
|
|
6333
6710
|
/**
|
|
6334
|
-
*
|
|
6335
|
-
*
|
|
6336
|
-
*
|
|
6337
|
-
*
|
|
6338
|
-
* https://github.com/owner/repo.git
|
|
6339
|
-
* https://github.com/owner/repo/tree/<ref>
|
|
6340
|
-
* https://github.com/owner/repo/tree/<ref>/<subdir…>
|
|
6711
|
+
* The canonical on-disk body: LF endings, no leading or trailing blank lines. Applied
|
|
6712
|
+
* on both read and write so the round trip is a fixed point — without it, the blank
|
|
6713
|
+
* line the writer puts after the closing `---` and the file's trailing newline would
|
|
6714
|
+
* accumulate into the body on every export→install→export cycle.
|
|
6341
6715
|
*
|
|
6342
|
-
*
|
|
6343
|
-
*
|
|
6344
|
-
* segment after `/tree/` is taken as the ref and the rest as the subdir.
|
|
6716
|
+
* Leading/trailing blank lines around a doc body are therefore normalized away on the
|
|
6717
|
+
* first export; internal blank lines are untouched.
|
|
6345
6718
|
*/
|
|
6346
|
-
const
|
|
6347
|
-
|
|
6719
|
+
const normalizeDocBody = (body) => body.replaceAll("\r\n", "\n").replace(/^\n+/, "").replace(/\n+$/, "");
|
|
6720
|
+
/** Split `---\n<yaml>\n---\n<body>`. A doc with no frontmatter is all body. */
|
|
6721
|
+
const parseFrontmatter = (text, filePath) => {
|
|
6722
|
+
const normalized = text.replaceAll("\r\n", "\n");
|
|
6723
|
+
if (!normalized.startsWith(`${DELIMITER}\n`)) return {
|
|
6724
|
+
data: {},
|
|
6725
|
+
body: normalizeDocBody(normalized)
|
|
6726
|
+
};
|
|
6727
|
+
const end = normalized.indexOf(`\n${DELIMITER}`, 3);
|
|
6728
|
+
if (end === -1) throw new Error(`${filePath} opens YAML frontmatter with "---" but never closes it. Add a closing "---" line.`);
|
|
6729
|
+
const rawYaml = normalized.slice(4, end);
|
|
6730
|
+
const body = normalizeDocBody(normalized.slice(end + 1 + 3));
|
|
6731
|
+
let data;
|
|
6348
6732
|
try {
|
|
6349
|
-
|
|
6350
|
-
} catch {
|
|
6351
|
-
throw new Error(
|
|
6733
|
+
data = parse(rawYaml);
|
|
6734
|
+
} catch (error) {
|
|
6735
|
+
throw new Error(`${filePath} has invalid YAML frontmatter: ${error.message}`);
|
|
6352
6736
|
}
|
|
6353
|
-
if (
|
|
6354
|
-
|
|
6355
|
-
|
|
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
|
|
6737
|
+
if (data === null || data === void 0) return {
|
|
6738
|
+
data: {},
|
|
6739
|
+
body
|
|
6363
6740
|
};
|
|
6364
|
-
if (
|
|
6365
|
-
if (!ref) throw new Error(`GitHub URL has "/tree/" but no ref: "${rawUrl}".`);
|
|
6741
|
+
if (typeof data !== "object" || Array.isArray(data)) throw new Error(`${filePath} frontmatter must be a YAML mapping (key: value pairs).`);
|
|
6366
6742
|
return {
|
|
6367
|
-
|
|
6368
|
-
|
|
6369
|
-
ref: decodeURIComponent(ref),
|
|
6370
|
-
subdir: rest.length > 0 ? rest.map((segment) => decodeURIComponent(segment)).join("/") : void 0
|
|
6743
|
+
data,
|
|
6744
|
+
body
|
|
6371
6745
|
};
|
|
6372
6746
|
};
|
|
6373
|
-
/**
|
|
6374
|
-
const
|
|
6747
|
+
/** Serialize a doc to `---\n<frontmatter>\n---\n<body>` with a trailing newline. */
|
|
6748
|
+
const serializeDoc = (fields, body) => {
|
|
6749
|
+
const data = { name: fields.name };
|
|
6750
|
+
if (fields.description) data.description = fields.description;
|
|
6751
|
+
if (fields.position !== void 0) data.position = fields.position;
|
|
6752
|
+
const yaml = stringify(data, { lineWidth: 0 }).replace(/\n$/, "");
|
|
6753
|
+
return `${DELIMITER}\n${yaml}\n${DELIMITER}\n\n${normalizeDocBody(body)}\n`;
|
|
6754
|
+
};
|
|
6755
|
+
//#endregion
|
|
6756
|
+
//#region ../../packages/busabase-package/src/ignore.ts
|
|
6757
|
+
const escapeLiteral = (value) => value.replace(/[.+^${}()|[\]\\]/g, "\\$&");
|
|
6375
6758
|
/**
|
|
6376
|
-
*
|
|
6377
|
-
*
|
|
6378
|
-
*
|
|
6759
|
+
* Translate one gitignore-ish pattern into a regex over a POSIX relative path.
|
|
6760
|
+
*
|
|
6761
|
+
* The three globs are handled in one pass so that `**` is recognised before the
|
|
6762
|
+
* `*` it starts with — splitting on `*` first would turn `**` into two
|
|
6763
|
+
* segment-local wildcards and quietly stop matching across directories.
|
|
6379
6764
|
*/
|
|
6380
|
-
const
|
|
6381
|
-
|
|
6382
|
-
|
|
6383
|
-
|
|
6384
|
-
|
|
6385
|
-
|
|
6386
|
-
|
|
6387
|
-
|
|
6388
|
-
|
|
6389
|
-
|
|
6390
|
-
|
|
6391
|
-
|
|
6392
|
-
|
|
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.`);
|
|
6765
|
+
const patternToRegex = (pattern, anchored) => {
|
|
6766
|
+
let source = "";
|
|
6767
|
+
for (let index = 0; index < pattern.length; index += 1) {
|
|
6768
|
+
const char = pattern[index];
|
|
6769
|
+
if (char === "*") {
|
|
6770
|
+
if (pattern[index + 1] === "*") {
|
|
6771
|
+
index += 1;
|
|
6772
|
+
if (pattern[index + 1] === "/") {
|
|
6773
|
+
index += 1;
|
|
6774
|
+
source += "(?:.*/)?";
|
|
6775
|
+
} else source += ".*";
|
|
6776
|
+
} else source += "[^/]*";
|
|
6777
|
+
continue;
|
|
6447
6778
|
}
|
|
6448
|
-
|
|
6449
|
-
|
|
6450
|
-
|
|
6451
|
-
if (!entry.getData) continue;
|
|
6452
|
-
const data = await entry.arrayBuffer();
|
|
6453
|
-
result.set(relativePath, Buffer.from(data));
|
|
6779
|
+
if (char === "?") {
|
|
6780
|
+
source += "[^/]";
|
|
6781
|
+
continue;
|
|
6454
6782
|
}
|
|
6455
|
-
|
|
6456
|
-
|
|
6457
|
-
|
|
6458
|
-
|
|
6459
|
-
|
|
6460
|
-
|
|
6461
|
-
|
|
6462
|
-
|
|
6463
|
-
|
|
6464
|
-
|
|
6783
|
+
if (char === "/") {
|
|
6784
|
+
source += "/";
|
|
6785
|
+
continue;
|
|
6786
|
+
}
|
|
6787
|
+
source += escapeLiteral(char);
|
|
6788
|
+
}
|
|
6789
|
+
return new RegExp(`${anchored ? "^" : "^(?:.*/)?"}${source}(?:/.*)?$`);
|
|
6790
|
+
};
|
|
6791
|
+
const parseRule = (line) => {
|
|
6792
|
+
let pattern = line.trim();
|
|
6793
|
+
if (!pattern || pattern.startsWith("#")) return void 0;
|
|
6794
|
+
const negated = pattern.startsWith("!");
|
|
6795
|
+
if (negated) pattern = pattern.slice(1);
|
|
6796
|
+
const directoryOnly = pattern.endsWith("/");
|
|
6797
|
+
if (directoryOnly) pattern = pattern.slice(0, -1);
|
|
6798
|
+
const anchored = pattern.startsWith("/");
|
|
6799
|
+
if (anchored) pattern = pattern.slice(1);
|
|
6800
|
+
if (!pattern) return void 0;
|
|
6465
6801
|
return {
|
|
6466
|
-
|
|
6467
|
-
|
|
6802
|
+
regex: patternToRegex(pattern, anchored),
|
|
6803
|
+
negated,
|
|
6804
|
+
directoryOnly
|
|
6468
6805
|
};
|
|
6469
6806
|
};
|
|
6470
|
-
|
|
6471
|
-
|
|
6472
|
-
/**
|
|
6473
|
-
* YAML frontmatter for doc nodes (§6.3): `name`, `description?`, `position?` above
|
|
6474
|
-
* the markdown body.
|
|
6475
|
-
*
|
|
6476
|
-
* Writing is deterministic (§6.6): a fixed key order and LF endings, so re-exporting
|
|
6477
|
-
* an unchanged doc is byte-identical.
|
|
6478
|
-
*/
|
|
6479
|
-
const DELIMITER = "---";
|
|
6480
|
-
/**
|
|
6481
|
-
* The canonical on-disk body: LF endings, no leading or trailing blank lines. Applied
|
|
6482
|
-
* on both read and write so the round trip is a fixed point — without it, the blank
|
|
6483
|
-
* line the writer puts after the closing `---` and the file's trailing newline would
|
|
6484
|
-
* accumulate into the body on every export→install→export cycle.
|
|
6485
|
-
*
|
|
6486
|
-
* Leading/trailing blank lines around a doc body are therefore normalized away on the
|
|
6487
|
-
* first export; internal blank lines are untouched.
|
|
6488
|
-
*/
|
|
6489
|
-
const normalizeDocBody = (body) => body.replaceAll("\r\n", "\n").replace(/^\n+/, "").replace(/\n+$/, "");
|
|
6490
|
-
/** Split `---\n<yaml>\n---\n<body>`. A doc with no frontmatter is all body. */
|
|
6491
|
-
const parseFrontmatter = (text, filePath) => {
|
|
6492
|
-
const normalized = text.replaceAll("\r\n", "\n");
|
|
6493
|
-
if (!normalized.startsWith(`${DELIMITER}\n`)) return {
|
|
6494
|
-
data: {},
|
|
6495
|
-
body: normalizeDocBody(normalized)
|
|
6496
|
-
};
|
|
6497
|
-
const end = normalized.indexOf(`\n${DELIMITER}`, 3);
|
|
6498
|
-
if (end === -1) throw new Error(`${filePath} opens YAML frontmatter with "---" but never closes it. Add a closing "---" line.`);
|
|
6499
|
-
const rawYaml = normalized.slice(4, end);
|
|
6500
|
-
const body = normalizeDocBody(normalized.slice(end + 1 + 3));
|
|
6501
|
-
let data;
|
|
6502
|
-
try {
|
|
6503
|
-
data = parse(rawYaml);
|
|
6504
|
-
} catch (error) {
|
|
6505
|
-
throw new Error(`${filePath} has invalid YAML frontmatter: ${error.message}`);
|
|
6506
|
-
}
|
|
6507
|
-
if (data === null || data === void 0) return {
|
|
6508
|
-
data: {},
|
|
6509
|
-
body
|
|
6510
|
-
};
|
|
6511
|
-
if (typeof data !== "object" || Array.isArray(data)) throw new Error(`${filePath} frontmatter must be a YAML mapping (key: value pairs).`);
|
|
6807
|
+
const parseIgnoreFile = (contents) => {
|
|
6808
|
+
const rules = contents.split(/\r?\n/).map(parseRule).filter((rule) => rule !== void 0);
|
|
6512
6809
|
return {
|
|
6513
|
-
|
|
6514
|
-
|
|
6810
|
+
isEmpty: rules.length === 0,
|
|
6811
|
+
ignores: (relativePath) => {
|
|
6812
|
+
let ignored = false;
|
|
6813
|
+
for (const rule of rules) {
|
|
6814
|
+
if (rule.directoryOnly && !rule.regex.test(`${relativePath}/`)) continue;
|
|
6815
|
+
if (!rule.regex.test(relativePath)) continue;
|
|
6816
|
+
ignored = !rule.negated;
|
|
6817
|
+
}
|
|
6818
|
+
return ignored;
|
|
6819
|
+
}
|
|
6515
6820
|
};
|
|
6516
6821
|
};
|
|
6517
|
-
/**
|
|
6518
|
-
const
|
|
6519
|
-
|
|
6520
|
-
|
|
6521
|
-
if (fields.position !== void 0) data.position = fields.position;
|
|
6522
|
-
const yaml = stringify(data, { lineWidth: 0 }).replace(/\n$/, "");
|
|
6523
|
-
return `${DELIMITER}\n${yaml}\n${DELIMITER}\n\n${normalizeDocBody(body)}\n`;
|
|
6822
|
+
/** A matcher that installs everything — used when no ignore file is present. */
|
|
6823
|
+
const IGNORE_NOTHING = {
|
|
6824
|
+
ignores: () => false,
|
|
6825
|
+
isEmpty: true
|
|
6524
6826
|
};
|
|
6525
6827
|
//#endregion
|
|
6526
6828
|
//#region ../../packages/busabase-package/src/layout-read.ts
|
|
@@ -6537,6 +6839,12 @@ const serializeDoc = (fields, body) => {
|
|
|
6537
6839
|
*
|
|
6538
6840
|
* Outside `content/`, the optional `assets/` directory carries the bytes for the
|
|
6539
6841
|
* images the Docs embed — see {@link readDocAssets}. Nothing in there is a node.
|
|
6842
|
+
*
|
|
6843
|
+
* Rule 0 sits ahead of all of them but reads a different place: a `SKILL.md` at
|
|
6844
|
+
* the package ROOT (beside the manifest, not under `content/`) makes this
|
|
6845
|
+
* package a *template*, and is lifted into `tree.rootSkill` — see
|
|
6846
|
+
* {@link readRootSkill}. `content/` itself is untouched by that rule, which is
|
|
6847
|
+
* what keeps a template byte-identical to a plain package below the root.
|
|
6540
6848
|
*/
|
|
6541
6849
|
const decodeText = (bytes, filePath) => {
|
|
6542
6850
|
const text = bytes.toString("utf8");
|
|
@@ -6602,13 +6910,65 @@ const readPackageTree = (files, options = {}) => {
|
|
|
6602
6910
|
assertKnownFormat(rawManifest, manifestPath);
|
|
6603
6911
|
const manifest = zodParse(PackageManifestSchema, rawManifest, manifestPath);
|
|
6604
6912
|
const contentDir = `${root}${PACKAGE_CONTENT_DIRNAME}/`;
|
|
6913
|
+
const nodes = readNodes(files, contentDir);
|
|
6914
|
+
const rootSkill = readRootSkill(files, root, manifest.name, manifest.description);
|
|
6605
6915
|
return {
|
|
6606
6916
|
manifest,
|
|
6607
|
-
nodes
|
|
6917
|
+
nodes,
|
|
6918
|
+
...rootSkill ? { rootSkill } : {},
|
|
6608
6919
|
assets: readDocAssets(files, root)
|
|
6609
6920
|
};
|
|
6610
6921
|
};
|
|
6611
6922
|
/**
|
|
6923
|
+
* Rule 0 — the package root's `SKILL.md` plus its sidecar directories.
|
|
6924
|
+
*
|
|
6925
|
+
* Absent → a plain package, and every downstream consumer behaves exactly as it
|
|
6926
|
+
* did before. Present → the manual travels with the resources: the installer
|
|
6927
|
+
* turns this into a Skill node inside the target folder, which is what lets the
|
|
6928
|
+
* agent that a user talks to after installing actually know what the app is for.
|
|
6929
|
+
*
|
|
6930
|
+
* The node's slug comes from the MANIFEST name rather than the skill
|
|
6931
|
+
* frontmatter's, because the manifest name is already the default target folder
|
|
6932
|
+
* name and is validated as a slug elsewhere; the validator separately requires
|
|
6933
|
+
* the two names to agree, so this is not a silent tie-break.
|
|
6934
|
+
*/
|
|
6935
|
+
const readRootSkill = (files, root, manifestName, manifestDescription) => {
|
|
6936
|
+
const entryPath = `${root}${PACKAGE_SKILL_ENTRY}`;
|
|
6937
|
+
const entryBytes = files.get(entryPath);
|
|
6938
|
+
if (!entryBytes) return void 0;
|
|
6939
|
+
const entries = [{
|
|
6940
|
+
path: PACKAGE_SKILL_ENTRY,
|
|
6941
|
+
bytes: entryBytes
|
|
6942
|
+
}];
|
|
6943
|
+
for (const dirName of PACKAGE_SKILL_SIDECAR_DIRS) {
|
|
6944
|
+
const dir = `${root}${dirName}/`;
|
|
6945
|
+
for (const entry of collectSubtree(files, dir)) entries.push({
|
|
6946
|
+
path: `${dirName}/${entry.path}`,
|
|
6947
|
+
bytes: entry.bytes
|
|
6948
|
+
});
|
|
6949
|
+
}
|
|
6950
|
+
assertNoReservedNames(entries.map((entry) => entry.path), `root skill "${manifestName}"`);
|
|
6951
|
+
const parsed = parseSkillFrontmatterSafely(decodeText(entryBytes, entryPath), entryPath);
|
|
6952
|
+
return {
|
|
6953
|
+
slug: manifestName,
|
|
6954
|
+
name: parsed?.name ?? manifestName,
|
|
6955
|
+
description: parsed?.description ?? manifestDescription,
|
|
6956
|
+
files: entries.sort((a, b) => a.path.localeCompare(b.path, "en"))
|
|
6957
|
+
};
|
|
6958
|
+
};
|
|
6959
|
+
const parseSkillFrontmatterSafely = (text, filePath) => {
|
|
6960
|
+
try {
|
|
6961
|
+
const { data } = parseFrontmatter(text, filePath);
|
|
6962
|
+
const record = data;
|
|
6963
|
+
return {
|
|
6964
|
+
name: typeof record.name === "string" ? record.name : void 0,
|
|
6965
|
+
description: typeof record.description === "string" ? record.description : void 0
|
|
6966
|
+
};
|
|
6967
|
+
} catch {
|
|
6968
|
+
return;
|
|
6969
|
+
}
|
|
6970
|
+
};
|
|
6971
|
+
/**
|
|
6612
6972
|
* `assets/` — the bytes for the images the Docs embed. Driven off the sidecars,
|
|
6613
6973
|
* not off the byte files: the sidecar is what carries the source asset id, so a
|
|
6614
6974
|
* stray file with no `.asset.json` beside it is simply not an asset this package
|
|
@@ -6667,8 +7027,10 @@ const readDirNode = (files, dir, slug) => {
|
|
|
6667
7027
|
if (files.has(`${dir}_node.json`)) {
|
|
6668
7028
|
const metaPath = `${dir}${PACKAGE_NODE_META_FILENAME}`;
|
|
6669
7029
|
const meta = zodParse(PackageFileTreeNodeMetaSchema, parseJsonFile(files, metaPath), metaPath);
|
|
6670
|
-
const
|
|
7030
|
+
const ignore = readIgnoreFile(files, dir);
|
|
7031
|
+
const entries = collectSubtree(files, dir).filter((entry) => entry.path !== "_node.json" && entry.path !== ".busabaseignore" && !ignore.ignores(entry.path));
|
|
6671
7032
|
assertNoReservedNames(entries.map((entry) => entry.path), `${meta.type} "${slug}"`);
|
|
7033
|
+
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
7034
|
return {
|
|
6673
7035
|
type: meta.type,
|
|
6674
7036
|
slug,
|
|
@@ -6707,6 +7069,19 @@ const readDirNode = (files, dir, slug) => {
|
|
|
6707
7069
|
children: readNodes(files, dir)
|
|
6708
7070
|
};
|
|
6709
7071
|
};
|
|
7072
|
+
/**
|
|
7073
|
+
* `.busabaseignore` inside a file-tree node directory.
|
|
7074
|
+
*
|
|
7075
|
+
* Read here rather than at the package level because the exclusions are a
|
|
7076
|
+
* property of one project (an AirApp's `test/` is noise; a Drive's files are
|
|
7077
|
+
* the point), and a package-wide ignore list would make it possible to
|
|
7078
|
+
* accidentally strip another node's content from a distance.
|
|
7079
|
+
*/
|
|
7080
|
+
const readIgnoreFile = (files, dir) => {
|
|
7081
|
+
const bytes = files.get(`${dir}${PACKAGE_AIRAPP_IGNORE}`);
|
|
7082
|
+
if (!bytes) return IGNORE_NOTHING;
|
|
7083
|
+
return parseIgnoreFile(decodeText(bytes, `${dir}${PACKAGE_AIRAPP_IGNORE}`));
|
|
7084
|
+
};
|
|
6710
7085
|
const readRecords = (files, dir, slug) => {
|
|
6711
7086
|
const recordsPath = `${dir}${PACKAGE_RECORDS_FILENAME}`;
|
|
6712
7087
|
const bytes = files.get(recordsPath);
|
|
@@ -6782,202 +7157,160 @@ const stripExtension = (fileName) => {
|
|
|
6782
7157
|
return dot > 0 ? fileName.slice(0, dot) : fileName;
|
|
6783
7158
|
};
|
|
6784
7159
|
//#endregion
|
|
6785
|
-
//#region ../../packages/busabase-package/src/
|
|
7160
|
+
//#region ../../packages/busabase-package/src/template.ts
|
|
6786
7161
|
/**
|
|
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.
|
|
7162
|
+
* Is this package a **template** — a package that is also an Agent Skill?
|
|
6791
7163
|
*
|
|
6792
|
-
*
|
|
6793
|
-
*
|
|
6794
|
-
*
|
|
6795
|
-
*
|
|
6796
|
-
*
|
|
6797
|
-
*
|
|
7164
|
+
* One function answers that for all four consumers, which is the point: the
|
|
7165
|
+
* Template Center's index CI, `busabase-cli install`, the dashboard's install
|
|
7166
|
+
* preview, and an agent checking its own output must never disagree about what
|
|
7167
|
+
* counts. A skill that shows up as a card but installs as a plain package (or
|
|
7168
|
+
* the reverse) is a trust bug, not a cosmetic one.
|
|
7169
|
+
*
|
|
7170
|
+
* Purely static: no AirApp is started, no script is executed, no server is
|
|
7171
|
+
* contacted. That is what lets it run in CI over a whole repo and inline in an
|
|
7172
|
+
* install preview without a timeout budget.
|
|
7173
|
+
*
|
|
7174
|
+
* **Hard conditions** (any failure ⇒ not a template). They are deliberately few,
|
|
7175
|
+
* and every one of them is something that would otherwise become a confusing
|
|
7176
|
+
* runtime failure for the installing user rather than a publishing error for
|
|
7177
|
+
* the author.
|
|
7178
|
+
*
|
|
7179
|
+
* **Soft conditions** are warnings: the package still installs and still lists,
|
|
7180
|
+
* the card is just honest about what is missing. A data-only template is a
|
|
7181
|
+
* legitimate thing to publish; one that silently claims to have an app is not.
|
|
7182
|
+
*
|
|
7183
|
+
* Crucially, a package that fails validation is NOT rejected from installing —
|
|
7184
|
+
* it installs as a plain `busabase-package@1`, exactly as it did before
|
|
7185
|
+
* templates existed. Failing to be a template is never a reason to refuse a
|
|
7186
|
+
* valid package.
|
|
7187
|
+
*
|
|
7188
|
+
* Spec: `apps/busabase/content/spec/template-center.md` §5.3.
|
|
6798
7189
|
*/
|
|
6799
7190
|
/**
|
|
6800
|
-
*
|
|
6801
|
-
*
|
|
7191
|
+
* `PackageFileTreeNode.type` is the whole skill|airapp|drive union rather than a
|
|
7192
|
+
* literal, so a discriminated-union `Extract` narrows to `never` here — the
|
|
7193
|
+
* predicate does the narrowing instead.
|
|
6802
7194
|
*/
|
|
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
|
-
|
|
7195
|
+
const isAirApp = (node) => node.type === "airapp";
|
|
7196
|
+
const airAppSlugs = (tree) => [...walkNodes(tree.nodes)].filter(isAirApp).map((node) => node.slug);
|
|
7197
|
+
const baseSlugs = (tree) => new Set([...walkNodes(tree.nodes)].filter((node) => node.type === "base").map((node) => node.slug));
|
|
7198
|
+
const validateTemplate = (tree) => {
|
|
7199
|
+
const errors = [];
|
|
7200
|
+
const warnings = [];
|
|
7201
|
+
const template = tree.manifest.template;
|
|
7202
|
+
if (!tree.rootSkill) return {
|
|
7203
|
+
ok: false,
|
|
7204
|
+
errors: [`No ${PACKAGE_SKILL_ENTRY} at the package root — this is a plain package.`],
|
|
7205
|
+
warnings
|
|
7206
|
+
};
|
|
7207
|
+
const entry = tree.rootSkill.files.find((file) => file.path === PACKAGE_SKILL_ENTRY);
|
|
7208
|
+
let frontmatter;
|
|
7209
|
+
if (entry) try {
|
|
7210
|
+
const { data } = parseFrontmatter(entry.bytes.toString("utf8"), PACKAGE_SKILL_ENTRY);
|
|
7211
|
+
frontmatter = SkillFrontmatterSchema.safeParse(data);
|
|
7212
|
+
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("; ")}`);
|
|
7213
|
+
} catch (error) {
|
|
7214
|
+
errors.push(`${PACKAGE_SKILL_ENTRY}: ${error.message}`);
|
|
7215
|
+
}
|
|
7216
|
+
const skill = frontmatter?.success ? frontmatter.data : void 0;
|
|
7217
|
+
const busabaseMeta = skill?.metadata?.busabase;
|
|
7218
|
+
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.`);
|
|
7219
|
+
if (!template) errors.push(`${PACKAGE_SKILL_ENTRY} declares a template but busabase.json has no \`template\` object (at least \`category\` is required).`);
|
|
7220
|
+
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.`);
|
|
7221
|
+
const bases = baseSlugs(tree);
|
|
7222
|
+
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.`);
|
|
7223
|
+
const airapps = airAppSlugs(tree);
|
|
7224
|
+
const resolved = resolvePrimaryAirApp(template, airapps);
|
|
7225
|
+
let primaryAirApp;
|
|
7226
|
+
if ("slug" in resolved) primaryAirApp = resolved.slug;
|
|
7227
|
+
else if (airapps.length > 0 || template?.airapp || template?.airapps?.length) errors.push(resolved.error);
|
|
7228
|
+
for (const node of walkNodes(tree.nodes)) {
|
|
7229
|
+
if (!isAirApp(node)) continue;
|
|
7230
|
+
const manifestFile = node.files.find((file) => file.path === "package.json");
|
|
7231
|
+
if (!manifestFile) continue;
|
|
7232
|
+
let scripts;
|
|
7233
|
+
try {
|
|
7234
|
+
scripts = JSON.parse(manifestFile.bytes.toString("utf8")).scripts;
|
|
7235
|
+
} catch (error) {
|
|
7236
|
+
errors.push(`AirApp "${node.slug}": package.json is not valid JSON (${error.message}).`);
|
|
7237
|
+
continue;
|
|
7238
|
+
}
|
|
7239
|
+
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.`);
|
|
7240
|
+
}
|
|
7241
|
+
if (airapps.length === 0) warnings.push("No AirApp — this template installs data only. Users will see tables, not an app.");
|
|
7242
|
+
if (template?.requires?.airapp && airapps.length === 0) errors.push("template.requires.airapp is true, but content/ contains no AirApp.");
|
|
7243
|
+
let hasSamples = false;
|
|
7244
|
+
for (const node of walkNodes(tree.nodes)) {
|
|
7245
|
+
if (node.type !== "base") continue;
|
|
7246
|
+
if (node.records.length > 0) hasSamples = true;
|
|
7247
|
+
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.`);
|
|
7248
|
+
}
|
|
7249
|
+
if (!hasSamples) warnings.push("No sample records — the app will be empty on first open.");
|
|
7250
|
+
if (!template?.screenshots.length) warnings.push("No screenshots declared — the Template Center card will use a placeholder.");
|
|
7251
|
+
if (!template?.agentPrompts.length) warnings.push("No agentPrompts declared — \"Ask agent\" will open with an empty prompt.");
|
|
7252
|
+
return {
|
|
7253
|
+
ok: errors.length === 0,
|
|
7254
|
+
errors,
|
|
7255
|
+
warnings,
|
|
7256
|
+
...primaryAirApp ? { primaryAirApp } : {},
|
|
7257
|
+
...template ? { template } : {}
|
|
7258
|
+
};
|
|
6865
7259
|
};
|
|
6866
7260
|
/**
|
|
6867
|
-
*
|
|
6868
|
-
*
|
|
7261
|
+
* A starting SKILL.md for a folder that has resources but no manual yet.
|
|
7262
|
+
*
|
|
7263
|
+
* Deterministic, never generated by a model: this text becomes instructions an
|
|
7264
|
+
* agent will act on, and a plausible-sounding invention about what a table means
|
|
7265
|
+
* is worse than an obvious blank to fill in. Everything here is read off the
|
|
7266
|
+
* structure that actually exists — table names, their fields, whether there is
|
|
7267
|
+
* an app — and every judgement the author has to make is left as an explicit
|
|
7268
|
+
* TODO rather than guessed at.
|
|
7269
|
+
*
|
|
7270
|
+
* Used by `export --template` and by "Publish as template" in the dashboard.
|
|
6869
7271
|
*/
|
|
6870
|
-
const
|
|
6871
|
-
const
|
|
6872
|
-
|
|
6873
|
-
|
|
6874
|
-
|
|
6875
|
-
|
|
7272
|
+
const deriveSkillDraft = (tree) => {
|
|
7273
|
+
const nodes = [...walkNodes(tree.nodes)];
|
|
7274
|
+
const bases = nodes.filter((node) => node.type === "base");
|
|
7275
|
+
const airapps = nodes.filter(isAirApp);
|
|
7276
|
+
const name = tree.manifest.name;
|
|
7277
|
+
const description = tree.manifest.description || `TODO: one line on what ${name} does.`;
|
|
7278
|
+
const yamlString = (value) => JSON.stringify(value);
|
|
7279
|
+
const resourceLines = bases.map((node) => ` - ${yamlString(node.slug)}`).join("\n");
|
|
7280
|
+
const resourceDocs = bases.map((node) => {
|
|
7281
|
+
if (node.type !== "base") return "";
|
|
7282
|
+
const fields = node.base.fields.map((field) => field.slug).join(", ");
|
|
7283
|
+
return `- \`${node.slug}\`: ${node.description || "TODO: what this table is for."}${fields ? ` Fields: ${fields}.` : ""}`;
|
|
7284
|
+
}).filter(Boolean).join("\n");
|
|
7285
|
+
return `---
|
|
7286
|
+
name: ${yamlString(name)}
|
|
7287
|
+
description: ${yamlString(description)}
|
|
7288
|
+
metadata:
|
|
7289
|
+
busabase:
|
|
7290
|
+
template: true
|
|
7291
|
+
${bases.length ? ` resources:\n${resourceLines}\n` : ""}---
|
|
7292
|
+
|
|
7293
|
+
# ${name}
|
|
7294
|
+
|
|
7295
|
+
TODO: describe the job this app does, and when an agent should reach for it.
|
|
7296
|
+
|
|
7297
|
+
## Busabase resources
|
|
7298
|
+
|
|
7299
|
+
${resourceDocs || "TODO: list this app's tables and what each one holds."}
|
|
7300
|
+
${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` : ""}
|
|
7301
|
+
## Boundary
|
|
7302
|
+
|
|
7303
|
+
TODO: state what this app must never do — send messages, call external APIs,
|
|
7304
|
+
merge its own proposals. Be explicit; an agent follows what is written here.
|
|
7305
|
+
|
|
7306
|
+
## Workflow
|
|
7307
|
+
|
|
7308
|
+
Propose changes as Change Requests and wait for a human to review them. Never
|
|
7309
|
+
review or merge your own proposal.
|
|
7310
|
+
`;
|
|
6876
7311
|
};
|
|
6877
|
-
|
|
6878
|
-
|
|
6879
|
-
* embed. Deduped by asset id (one image in three Docs is one pair of files) and
|
|
6880
|
-
* emitted in a fixed order, so a re-export of an unchanged space is still
|
|
6881
|
-
* byte-identical (§6.6).
|
|
6882
|
-
*/
|
|
6883
|
-
const renderDocAssets = (assets, files) => {
|
|
6884
|
-
const byId = /* @__PURE__ */ new Map();
|
|
6885
|
-
for (const asset of assets) if (!byId.has(asset.assetId)) byId.set(asset.assetId, asset);
|
|
6886
|
-
for (const asset of [...byId.values()].sort((a, b) => a.assetId.localeCompare(b.assetId, "en"))) {
|
|
6887
|
-
const bytesPath = docAssetBytesPath(asset);
|
|
6888
|
-
assertSafeFilePath(bytesPath, `doc asset "${asset.assetId}"`);
|
|
6889
|
-
files.set(bytesPath, asset.bytes);
|
|
6890
|
-
files.set(`${bytesPath}${PACKAGE_ASSET_META_SUFFIX}`, toJsonFile(compact({
|
|
6891
|
-
assetId: asset.assetId,
|
|
6892
|
-
fileName: asset.fileName,
|
|
6893
|
-
mimeType: asset.mimeType,
|
|
6894
|
-
contentHash: asset.contentHash
|
|
6895
|
-
})));
|
|
6896
|
-
}
|
|
6897
|
-
};
|
|
6898
|
-
const renderNodes = (nodes, dir, files) => {
|
|
6899
|
-
const ordered = sortNodes(nodes);
|
|
6900
|
-
assertNoSiblingCaseCollisions(ordered.map((node) => node.slug), `in ${dir}`);
|
|
6901
|
-
const seen = /* @__PURE__ */ new Set();
|
|
6902
|
-
for (const node of ordered) {
|
|
6903
|
-
assertSafeNodeSlug(node.slug, `${node.type} node`);
|
|
6904
|
-
if (seen.has(node.slug)) throw new Error(`Two nodes in ${dir} would be written as "${node.slug}". Sibling slugs must be unique.`);
|
|
6905
|
-
seen.add(node.slug);
|
|
6906
|
-
renderNode(node, dir, files);
|
|
6907
|
-
}
|
|
6908
|
-
};
|
|
6909
|
-
const renderNode = (node, dir, files) => {
|
|
6910
|
-
switch (node.type) {
|
|
6911
|
-
case "folder":
|
|
6912
|
-
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({
|
|
6913
|
-
name: node.name,
|
|
6914
|
-
description: node.description || void 0,
|
|
6915
|
-
position: node.position
|
|
6916
|
-
})));
|
|
6917
|
-
renderNodes(node.children, `${dir}${node.slug}/`, files);
|
|
6918
|
-
return;
|
|
6919
|
-
case "doc":
|
|
6920
|
-
files.set(`${dir}${node.slug}.md`, Buffer.from(serializeDoc({
|
|
6921
|
-
name: node.name,
|
|
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
|
-
}
|
|
6978
|
-
};
|
|
6979
|
-
//#endregion
|
|
6980
|
-
//#region ../../packages/busabase-package/src/plan.ts
|
|
7312
|
+
//#endregion
|
|
7313
|
+
//#region ../../packages/busabase-package/src/plan.ts
|
|
6981
7314
|
/**
|
|
6982
7315
|
* Install planning: package tree + the target space's current state → a plan, a
|
|
6983
7316
|
* collision report, and the `--dry-run` rendering. Creates nothing.
|
|
@@ -7025,9 +7358,16 @@ const buildInstallPlan = (tree, target, options = {}) => {
|
|
|
7025
7358
|
const targetFolderSlug = options.intoFolder ?? suggestSlug(tree.manifest.name);
|
|
7026
7359
|
const warnings = [];
|
|
7027
7360
|
assertPackageNodeIdentitiesUnique(tree);
|
|
7028
|
-
const
|
|
7029
|
-
|
|
7030
|
-
|
|
7361
|
+
const isTemplate = validateTemplate(tree).ok;
|
|
7362
|
+
const prefixed = options.prefixBaseSlugs ?? isTemplate ? prefixBaseSlugs(tree, targetFolderSlug) : tree;
|
|
7363
|
+
const resourceKeysBySlug = {};
|
|
7364
|
+
for (const [installedSlug, originalSlug] of collectSlugRewrites(tree, prefixed)) resourceKeysBySlug[installedSlug] = originalSlug;
|
|
7365
|
+
const collisions = findCollisions(prefixed, targetFolderSlug, target);
|
|
7366
|
+
let planned = prefixed;
|
|
7367
|
+
if (options.rename && collisions.length > 0) {
|
|
7368
|
+
planned = applyRenames(prefixed, collisions, target.existingNodeSlugsByType);
|
|
7369
|
+
for (const [installedSlug, originalSlug] of collectSlugRewrites(tree, planned)) resourceKeysBySlug[installedSlug] = originalSlug;
|
|
7370
|
+
}
|
|
7031
7371
|
collectWarnings(planned, warnings);
|
|
7032
7372
|
const counts = countTree(planned);
|
|
7033
7373
|
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 +7378,79 @@ const buildInstallPlan = (tree, target, options = {}) => {
|
|
|
7038
7378
|
collisions,
|
|
7039
7379
|
warnings,
|
|
7040
7380
|
counts,
|
|
7041
|
-
requiresAutoMerge: collectBaseNodes(planned.nodes).some(hasUnlinkableRelationValues)
|
|
7381
|
+
requiresAutoMerge: collectBaseNodes(planned.nodes).some(hasUnlinkableRelationValues) && !(isTemplate && (options.installSampleRecords ?? true)),
|
|
7382
|
+
resourceKeysBySlug,
|
|
7383
|
+
isTemplate
|
|
7384
|
+
};
|
|
7385
|
+
};
|
|
7386
|
+
/**
|
|
7387
|
+
* Rewrite every Base slug to `<folder>-<slug>`, following relation targets.
|
|
7388
|
+
*
|
|
7389
|
+
* A Base already carrying the prefix is left alone, so re-planning an installed
|
|
7390
|
+
* package (an upgrade, a retry) does not stack `kelly-email-kelly-email-…`.
|
|
7391
|
+
* Only Bases are prefixed: every other node type is scoped to its parent
|
|
7392
|
+
* folder, so only Bases can collide across folders in the first place.
|
|
7393
|
+
*/
|
|
7394
|
+
const prefixBaseSlugs = (tree, folderSlug) => {
|
|
7395
|
+
const renames = /* @__PURE__ */ new Map();
|
|
7396
|
+
for (const node of walkNodes(tree.nodes)) {
|
|
7397
|
+
if (node.type !== "base") continue;
|
|
7398
|
+
if (node.slug === folderSlug || node.slug.startsWith(`${folderSlug}-`)) continue;
|
|
7399
|
+
renames.set(node.slug, `${folderSlug}-${node.slug}`);
|
|
7400
|
+
}
|
|
7401
|
+
if (renames.size === 0) return tree;
|
|
7402
|
+
const rewriteField = (field) => {
|
|
7403
|
+
const target = field.options.targetBaseSlug;
|
|
7404
|
+
if (!target) return field;
|
|
7405
|
+
const renamed = renames.get(target);
|
|
7406
|
+
return renamed ? {
|
|
7407
|
+
...field,
|
|
7408
|
+
options: {
|
|
7409
|
+
...field.options,
|
|
7410
|
+
targetBaseSlug: renamed
|
|
7411
|
+
}
|
|
7412
|
+
} : field;
|
|
7413
|
+
};
|
|
7414
|
+
const rewrite = (node) => {
|
|
7415
|
+
if (node.type === "folder") return {
|
|
7416
|
+
...node,
|
|
7417
|
+
children: node.children.map(rewrite)
|
|
7418
|
+
};
|
|
7419
|
+
if (node.type !== "base") return node;
|
|
7420
|
+
return {
|
|
7421
|
+
...node,
|
|
7422
|
+
slug: renames.get(node.slug) ?? node.slug,
|
|
7423
|
+
base: {
|
|
7424
|
+
...node.base,
|
|
7425
|
+
fields: node.base.fields.map(rewriteField)
|
|
7426
|
+
}
|
|
7427
|
+
};
|
|
7428
|
+
};
|
|
7429
|
+
return {
|
|
7430
|
+
...tree,
|
|
7431
|
+
nodes: tree.nodes.map(rewrite)
|
|
7432
|
+
};
|
|
7433
|
+
};
|
|
7434
|
+
/**
|
|
7435
|
+
* Pair up two trees that differ only in slugs, walking them in lockstep.
|
|
7436
|
+
*
|
|
7437
|
+
* Position-based rather than slug-based on purpose: after a rewrite the slugs
|
|
7438
|
+
* are exactly what no longer match, so they cannot be the join key. Both trees
|
|
7439
|
+
* come from the same source and every rewrite preserves order, so position is
|
|
7440
|
+
* stable.
|
|
7441
|
+
*/
|
|
7442
|
+
const collectSlugRewrites = (original, rewritten) => {
|
|
7443
|
+
const pairs = [];
|
|
7444
|
+
const walk = (before, after) => {
|
|
7445
|
+
for (const [index, beforeNode] of before.entries()) {
|
|
7446
|
+
const afterNode = after[index];
|
|
7447
|
+
if (!afterNode) continue;
|
|
7448
|
+
pairs.push([afterNode.slug, beforeNode.slug]);
|
|
7449
|
+
if (beforeNode.type === "folder" && afterNode.type === "folder") walk(beforeNode.children, afterNode.children);
|
|
7450
|
+
}
|
|
7042
7451
|
};
|
|
7452
|
+
walk(original.nodes, rewritten.nodes);
|
|
7453
|
+
return pairs;
|
|
7043
7454
|
};
|
|
7044
7455
|
/**
|
|
7045
7456
|
* The ONLY thing that genuinely can't survive a review-first install: a record that
|
|
@@ -7119,129 +7530,696 @@ const applyRenames = (tree, collisions, existingNodeSlugsByType) => {
|
|
|
7119
7530
|
if (node.type === "base") baseRenames.set(node.slug, renamed);
|
|
7120
7531
|
collision.renamedTo = renamed;
|
|
7121
7532
|
}
|
|
7122
|
-
const rewriteNode = (node) => {
|
|
7123
|
-
const slug = nodeRenames.get(nodeIdentityKey(node)) ?? node.slug;
|
|
7124
|
-
if (node.type === "base") return {
|
|
7125
|
-
...node,
|
|
7126
|
-
slug,
|
|
7127
|
-
base: {
|
|
7128
|
-
...node.base,
|
|
7129
|
-
fields: node.base.fields.map(rewriteFieldTarget)
|
|
7130
|
-
}
|
|
7131
|
-
};
|
|
7132
|
-
if (node.type === "folder") return {
|
|
7133
|
-
...node,
|
|
7134
|
-
slug,
|
|
7135
|
-
children: node.children.map(rewriteNode)
|
|
7136
|
-
};
|
|
7137
|
-
return {
|
|
7138
|
-
...node,
|
|
7139
|
-
slug
|
|
7140
|
-
};
|
|
7141
|
-
};
|
|
7142
|
-
const rewriteFieldTarget = (field) => {
|
|
7143
|
-
const targetBaseSlug = field.options.targetBaseSlug;
|
|
7144
|
-
if (!targetBaseSlug) return field;
|
|
7145
|
-
const renamed = baseRenames.get(targetBaseSlug);
|
|
7146
|
-
if (!renamed) return field;
|
|
7147
|
-
return {
|
|
7148
|
-
...field,
|
|
7149
|
-
options: {
|
|
7150
|
-
...field.options,
|
|
7151
|
-
targetBaseSlug: renamed
|
|
7152
|
-
}
|
|
7153
|
-
};
|
|
7154
|
-
};
|
|
7155
|
-
return {
|
|
7156
|
-
...tree,
|
|
7157
|
-
nodes: tree.nodes.map(rewriteNode)
|
|
7158
|
-
};
|
|
7533
|
+
const rewriteNode = (node) => {
|
|
7534
|
+
const slug = nodeRenames.get(nodeIdentityKey(node)) ?? node.slug;
|
|
7535
|
+
if (node.type === "base") return {
|
|
7536
|
+
...node,
|
|
7537
|
+
slug,
|
|
7538
|
+
base: {
|
|
7539
|
+
...node.base,
|
|
7540
|
+
fields: node.base.fields.map(rewriteFieldTarget)
|
|
7541
|
+
}
|
|
7542
|
+
};
|
|
7543
|
+
if (node.type === "folder") return {
|
|
7544
|
+
...node,
|
|
7545
|
+
slug,
|
|
7546
|
+
children: node.children.map(rewriteNode)
|
|
7547
|
+
};
|
|
7548
|
+
return {
|
|
7549
|
+
...node,
|
|
7550
|
+
slug
|
|
7551
|
+
};
|
|
7552
|
+
};
|
|
7553
|
+
const rewriteFieldTarget = (field) => {
|
|
7554
|
+
const targetBaseSlug = field.options.targetBaseSlug;
|
|
7555
|
+
if (!targetBaseSlug) return field;
|
|
7556
|
+
const renamed = baseRenames.get(targetBaseSlug);
|
|
7557
|
+
if (!renamed) return field;
|
|
7558
|
+
return {
|
|
7559
|
+
...field,
|
|
7560
|
+
options: {
|
|
7561
|
+
...field.options,
|
|
7562
|
+
targetBaseSlug: renamed
|
|
7563
|
+
}
|
|
7564
|
+
};
|
|
7565
|
+
};
|
|
7566
|
+
return {
|
|
7567
|
+
...tree,
|
|
7568
|
+
nodes: tree.nodes.map(rewriteNode)
|
|
7569
|
+
};
|
|
7570
|
+
};
|
|
7571
|
+
const collectWarnings = (tree, warnings) => {
|
|
7572
|
+
for (const baseNode of collectBaseNodes(tree.nodes)) if (baseNode.base.reviewPolicy) warnings.push(`Base "${baseNode.slug}" declares a reviewPolicy (${baseNode.base.reviewPolicy.kind}, ${baseNode.base.reviewPolicy.requiredApprovals} approval(s)), but reviewPolicy cannot be set when creating a Base — the target's default applies. Adjust it in the UI afterwards if needed.`);
|
|
7573
|
+
};
|
|
7574
|
+
const countTree = (tree) => {
|
|
7575
|
+
const counts = {
|
|
7576
|
+
folders: 0,
|
|
7577
|
+
docs: 0,
|
|
7578
|
+
bases: 0,
|
|
7579
|
+
records: 0,
|
|
7580
|
+
skills: 0,
|
|
7581
|
+
airapps: 0,
|
|
7582
|
+
drives: 0,
|
|
7583
|
+
files: 0
|
|
7584
|
+
};
|
|
7585
|
+
for (const node of walkNodes(tree.nodes)) switch (node.type) {
|
|
7586
|
+
case "folder":
|
|
7587
|
+
counts.folders++;
|
|
7588
|
+
break;
|
|
7589
|
+
case "doc":
|
|
7590
|
+
counts.docs++;
|
|
7591
|
+
break;
|
|
7592
|
+
case "base":
|
|
7593
|
+
counts.bases++;
|
|
7594
|
+
counts.records += node.records.length;
|
|
7595
|
+
break;
|
|
7596
|
+
case "skill":
|
|
7597
|
+
counts.skills++;
|
|
7598
|
+
counts.files += node.files.length;
|
|
7599
|
+
break;
|
|
7600
|
+
case "airapp":
|
|
7601
|
+
counts.airapps++;
|
|
7602
|
+
counts.files += node.files.length;
|
|
7603
|
+
break;
|
|
7604
|
+
case "drive":
|
|
7605
|
+
counts.drives++;
|
|
7606
|
+
counts.files += node.files.length;
|
|
7607
|
+
break;
|
|
7608
|
+
case "file": counts.files++;
|
|
7609
|
+
}
|
|
7610
|
+
counts.files += (tree.assets ?? []).length;
|
|
7611
|
+
return counts;
|
|
7612
|
+
};
|
|
7613
|
+
/** §4: "What will this do?" — the `--dry-run` report. Creates nothing. */
|
|
7614
|
+
const renderPlan = (plan) => {
|
|
7615
|
+
const lines = [];
|
|
7616
|
+
lines.push(`Package: ${plan.tree.manifest.name}`);
|
|
7617
|
+
if (plan.tree.manifest.description) lines.push(` ${plan.tree.manifest.description}`);
|
|
7618
|
+
lines.push("");
|
|
7619
|
+
lines.push(`Target folder: ${plan.targetFolderSlug}`);
|
|
7620
|
+
lines.push("");
|
|
7621
|
+
lines.push("Node tree:");
|
|
7622
|
+
for (const line of renderNodeTree(plan.tree.nodes, " ")) lines.push(line);
|
|
7623
|
+
lines.push("");
|
|
7624
|
+
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)`);
|
|
7625
|
+
if (plan.collisions.length > 0) {
|
|
7626
|
+
lines.push("");
|
|
7627
|
+
lines.push("Collisions:");
|
|
7628
|
+
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})`);
|
|
7629
|
+
}
|
|
7630
|
+
if (plan.warnings.length > 0) {
|
|
7631
|
+
lines.push("");
|
|
7632
|
+
lines.push("Warnings:");
|
|
7633
|
+
for (const warning of plan.warnings) lines.push(` • ${warning}`);
|
|
7634
|
+
}
|
|
7635
|
+
return lines.join("\n");
|
|
7636
|
+
};
|
|
7637
|
+
const renderNodeTree = (nodes, indent) => {
|
|
7638
|
+
const lines = [];
|
|
7639
|
+
for (const node of nodes) {
|
|
7640
|
+
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))`;
|
|
7641
|
+
lines.push(`${indent}${node.slug}${detail}`);
|
|
7642
|
+
if (node.type === "folder") lines.push(...renderNodeTree(node.children, `${indent} `));
|
|
7643
|
+
}
|
|
7644
|
+
return lines;
|
|
7645
|
+
};
|
|
7646
|
+
/**
|
|
7647
|
+
* §4 + §12: without `--auto-merge` an unmerged base has no field ids to patch and no
|
|
7648
|
+
* records to link, so passes 2-5 cannot run. Fail fast and say exactly why.
|
|
7649
|
+
*/
|
|
7650
|
+
const assertPlanIsApplicable = (plan, autoMerge) => {
|
|
7651
|
+
if (plan.collisions.some((collision) => !collision.renamedTo)) {
|
|
7652
|
+
const list = plan.collisions.filter((collision) => !collision.renamedTo).map((collision) => ` • ${collision.kind} "${collision.slug}" (${collision.path})`).join("\n");
|
|
7653
|
+
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.`);
|
|
7654
|
+
}
|
|
7655
|
+
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).");
|
|
7656
|
+
};
|
|
7657
|
+
//#endregion
|
|
7658
|
+
//#region ../../packages/busabase-package/src/discover.ts
|
|
7659
|
+
/**
|
|
7660
|
+
* What is at the end of this URL — one package, one template, or a repository
|
|
7661
|
+
* full of them?
|
|
7662
|
+
*
|
|
7663
|
+
* A user pasting a GitHub URL does not think in terms of "package roots". They
|
|
7664
|
+
* paste the repo they were looking at. Sometimes that repo IS the package;
|
|
7665
|
+
* sometimes it is `busabase/skills`, which holds dozens under `skills/*`. Before
|
|
7666
|
+
* this, the second case was only ever an error message with a list of
|
|
7667
|
+
* subdirectories to paste instead — correct, and a dead end for anyone who did
|
|
7668
|
+
* not want to edit a URL by hand.
|
|
7669
|
+
*
|
|
7670
|
+
* Discovery answers it once for every caller: the CLI turns the result into a
|
|
7671
|
+
* pick-one list, the dashboard's install dialog into a chooser, the Template
|
|
7672
|
+
* Center's index CI into the set of cards to publish. All three must agree on
|
|
7673
|
+
* what counts as a template, which is why the verdict comes from the same
|
|
7674
|
+
* `validateTemplate` they would each otherwise re-implement.
|
|
7675
|
+
*
|
|
7676
|
+
* Spec: `apps/busabase/content/spec/template-center.md` §6.2a.
|
|
7677
|
+
*/
|
|
7678
|
+
/**
|
|
7679
|
+
* Only one level deep, matching the `skills/<name>/` layout the skills CLI
|
|
7680
|
+
* already established and the Template Center reads. Walking arbitrarily deep
|
|
7681
|
+
* would turn one paste into an unbounded scan of a monorepo and surface vendored
|
|
7682
|
+
* copies inside `node_modules` as installable products.
|
|
7683
|
+
*/
|
|
7684
|
+
const MAX_DISCOVERY_DEPTH = 2;
|
|
7685
|
+
const manifestDirectories = (files) => {
|
|
7686
|
+
const dirs = /* @__PURE__ */ new Set();
|
|
7687
|
+
for (const filePath of files.keys()) {
|
|
7688
|
+
if (filePath === "busabase.json") {
|
|
7689
|
+
dirs.add("");
|
|
7690
|
+
continue;
|
|
7691
|
+
}
|
|
7692
|
+
if (!filePath.endsWith(`/busabase.json`)) continue;
|
|
7693
|
+
const dir = filePath.slice(0, -14);
|
|
7694
|
+
if (dir.split("/").length > MAX_DISCOVERY_DEPTH) continue;
|
|
7695
|
+
dirs.add(dir);
|
|
7696
|
+
}
|
|
7697
|
+
return [...dirs].sort((a, b) => a.localeCompare(b, "en"));
|
|
7698
|
+
};
|
|
7699
|
+
/**
|
|
7700
|
+
* Every package at or below the fetched root, root first.
|
|
7701
|
+
*
|
|
7702
|
+
* A directory whose manifest or content does not parse is SKIPPED rather than
|
|
7703
|
+
* failing the whole discovery: one malformed skill in a repository of fifty must
|
|
7704
|
+
* not make the other forty-nine unreachable. The user is choosing from a list,
|
|
7705
|
+
* and an entry that cannot be read has nothing to offer that list.
|
|
7706
|
+
*/
|
|
7707
|
+
const discoverPackages = (files) => {
|
|
7708
|
+
const found = [];
|
|
7709
|
+
for (const subdir of manifestDirectories(files)) {
|
|
7710
|
+
if (found.length >= 200) break;
|
|
7711
|
+
let tree;
|
|
7712
|
+
try {
|
|
7713
|
+
tree = readPackageTree(files, subdir ? { root: subdir } : {});
|
|
7714
|
+
} catch {
|
|
7715
|
+
continue;
|
|
7716
|
+
}
|
|
7717
|
+
const validation = validateTemplate(tree);
|
|
7718
|
+
const claimsTemplate = Boolean(tree.rootSkill) || Boolean(tree.manifest.template);
|
|
7719
|
+
found.push({
|
|
7720
|
+
subdir,
|
|
7721
|
+
name: tree.manifest.name,
|
|
7722
|
+
description: tree.manifest.description,
|
|
7723
|
+
isTemplate: validation.ok,
|
|
7724
|
+
templateErrors: validation.ok || !claimsTemplate ? [] : validation.errors,
|
|
7725
|
+
counts: countTree(tree),
|
|
7726
|
+
...validation.primaryAirApp ? { primaryAirApp: validation.primaryAirApp } : {},
|
|
7727
|
+
...tree.manifest.template?.category ? { category: tree.manifest.template.category } : {},
|
|
7728
|
+
screenshots: tree.manifest.template?.screenshots ?? [],
|
|
7729
|
+
tags: [.../* @__PURE__ */ new Set([...tree.manifest.tags, ...tree.manifest.template?.tags ?? []])],
|
|
7730
|
+
agentPrompts: tree.manifest.template?.agentPrompts ?? [],
|
|
7731
|
+
...tree.manifest.version ? { version: tree.manifest.version } : {},
|
|
7732
|
+
...tree.manifest.author ? { author: tree.manifest.author } : {},
|
|
7733
|
+
...tree.manifest.license ? { license: tree.manifest.license } : {}
|
|
7734
|
+
});
|
|
7735
|
+
}
|
|
7736
|
+
return found;
|
|
7737
|
+
};
|
|
7738
|
+
/**
|
|
7739
|
+
* Resolve a pasted URL to the ONE package to install, or explain the choice.
|
|
7740
|
+
*
|
|
7741
|
+
* `wanted` is a name or subdir the caller already picked (the CLI's `--skill`,
|
|
7742
|
+
* the dialog's selection). Matching on either is deliberate: a user reading a
|
|
7743
|
+
* list sees names, a user reading a repo sees directories, and asking them to
|
|
7744
|
+
* know which one the tool wants is a distinction without a purpose.
|
|
7745
|
+
*/
|
|
7746
|
+
const resolvePackageToInstall = (files, wanted) => {
|
|
7747
|
+
const discovered = discoverPackages(files);
|
|
7748
|
+
if (discovered.length === 0) return { kind: "none" };
|
|
7749
|
+
if (wanted) {
|
|
7750
|
+
const match = discovered.find((entry) => entry.name === wanted || entry.subdir === wanted || entry.subdir.endsWith(`/${wanted}`));
|
|
7751
|
+
if (match) return {
|
|
7752
|
+
kind: "package",
|
|
7753
|
+
subdir: match.subdir,
|
|
7754
|
+
discovered: match
|
|
7755
|
+
};
|
|
7756
|
+
return {
|
|
7757
|
+
kind: "choose",
|
|
7758
|
+
candidates: discovered
|
|
7759
|
+
};
|
|
7760
|
+
}
|
|
7761
|
+
const root = discovered.find((entry) => entry.subdir === "");
|
|
7762
|
+
if (root) return {
|
|
7763
|
+
kind: "package",
|
|
7764
|
+
subdir: "",
|
|
7765
|
+
discovered: root
|
|
7766
|
+
};
|
|
7767
|
+
if (discovered.length === 1) return {
|
|
7768
|
+
kind: "package",
|
|
7769
|
+
subdir: discovered[0].subdir,
|
|
7770
|
+
discovered: discovered[0]
|
|
7771
|
+
};
|
|
7772
|
+
return {
|
|
7773
|
+
kind: "choose",
|
|
7774
|
+
candidates: discovered
|
|
7775
|
+
};
|
|
7776
|
+
};
|
|
7777
|
+
//#endregion
|
|
7778
|
+
//#region ../../packages/busabase-package/src/github.ts
|
|
7779
|
+
/**
|
|
7780
|
+
* GitHub source for `busabase-cli install`: parse a repo URL, fetch the ref's
|
|
7781
|
+
* zipball, and extract it into memory.
|
|
7782
|
+
*
|
|
7783
|
+
* Ported from Buda's GitHub skill importer (Buda stays as-is; a shared package
|
|
7784
|
+
* is warranted only once the Phase-2 server consumer actually exists). Three
|
|
7785
|
+
* deliberate divergences from that original:
|
|
7786
|
+
*
|
|
7787
|
+
* 1. **Host is parsed, not pattern-matched.** buda's regex matches `github.com/…`
|
|
7788
|
+
* anywhere in the string, so `https://evil.example/github.com/o/r` passes. This
|
|
7789
|
+
* parses with `URL` and checks `hostname` against an allowlist.
|
|
7790
|
+
* 2. **Any git ref, not just branches.** buda fetches `/archive/refs/heads/<b>.zip`,
|
|
7791
|
+
* which resolves branches only — tags (the package format's version scheme)
|
|
7792
|
+
* would 404. This uses codeload's `/zip/<ref>`, which takes a branch, tag, or SHA.
|
|
7793
|
+
* 3. **Extraction is to memory with the caps applied first**, so an oversized or
|
|
7794
|
+
* zip-bomb archive never reaches the disk.
|
|
7795
|
+
*/
|
|
7796
|
+
/** Hosts the installer may ever connect to. Also the Phase-2 server guard's allowlist. */
|
|
7797
|
+
const GITHUB_ALLOWED_HOSTS = [
|
|
7798
|
+
"github.com",
|
|
7799
|
+
"api.github.com",
|
|
7800
|
+
"codeload.github.com"
|
|
7801
|
+
];
|
|
7802
|
+
/** Hosts accepted in a user-supplied install URL (the web UI form of a repo). */
|
|
7803
|
+
const GITHUB_URL_HOSTS = ["github.com", "www.github.com"];
|
|
7804
|
+
const isAllowedGithubHost = (hostname) => GITHUB_ALLOWED_HOSTS.includes(hostname.toLowerCase());
|
|
7805
|
+
/**
|
|
7806
|
+
* Parse a GitHub repo URL.
|
|
7807
|
+
*
|
|
7808
|
+
* Supported forms:
|
|
7809
|
+
* https://github.com/owner/repo
|
|
7810
|
+
* https://github.com/owner/repo.git
|
|
7811
|
+
* https://github.com/owner/repo/tree/<ref>
|
|
7812
|
+
* https://github.com/owner/repo/tree/<ref>/<subdir…>
|
|
7813
|
+
*
|
|
7814
|
+
* A ref containing `/` (e.g. `feature/x`) is indistinguishable from `<ref>/<subdir>`
|
|
7815
|
+
* in a `/tree/` URL — GitHub's own web URLs have the same ambiguity — so the first
|
|
7816
|
+
* segment after `/tree/` is taken as the ref and the rest as the subdir.
|
|
7817
|
+
*/
|
|
7818
|
+
const parseGithubUrl = (rawUrl) => {
|
|
7819
|
+
let url;
|
|
7820
|
+
try {
|
|
7821
|
+
url = new URL(rawUrl.trim());
|
|
7822
|
+
} catch {
|
|
7823
|
+
throw new Error(`Not a valid URL: "${rawUrl}". Expected a GitHub repo URL, e.g. https://github.com/acme/support-kb-template`);
|
|
7824
|
+
}
|
|
7825
|
+
if (url.protocol !== "https:" && url.protocol !== "http:") throw new Error(`Unsupported URL scheme "${url.protocol}" — expected an https:// GitHub URL.`);
|
|
7826
|
+
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.`);
|
|
7827
|
+
const [owner, rawRepo, marker, ref, ...rest] = url.pathname.split("/").filter(Boolean);
|
|
7828
|
+
if (!owner || !rawRepo) throw new Error(`GitHub URL is missing owner/repo: "${rawUrl}". Expected https://github.com/<owner>/<repo>[/tree/<ref>[/<subdir>]]`);
|
|
7829
|
+
const repo = rawRepo.replace(/\.git$/, "");
|
|
7830
|
+
if (!marker) return {
|
|
7831
|
+
owner,
|
|
7832
|
+
repo,
|
|
7833
|
+
ref: void 0,
|
|
7834
|
+
subdir: void 0
|
|
7835
|
+
};
|
|
7836
|
+
if (marker !== "tree") throw new Error(`Unsupported GitHub URL form "/${marker}/" in "${rawUrl}". Expected https://github.com/<owner>/<repo>[/tree/<ref>[/<subdir>]]`);
|
|
7837
|
+
if (!ref) throw new Error(`GitHub URL has "/tree/" but no ref: "${rawUrl}".`);
|
|
7838
|
+
return {
|
|
7839
|
+
owner,
|
|
7840
|
+
repo,
|
|
7841
|
+
ref: decodeURIComponent(ref),
|
|
7842
|
+
subdir: rest.length > 0 ? rest.map((segment) => decodeURIComponent(segment)).join("/") : void 0
|
|
7843
|
+
};
|
|
7844
|
+
};
|
|
7845
|
+
/** Encode a ref for a URL path while keeping its `/` separators (e.g. `feature/x`). */
|
|
7846
|
+
const encodeRefPath = (ref) => ref.split("/").map(encodeURIComponent).join("/");
|
|
7847
|
+
/**
|
|
7848
|
+
* Download the zipball for `owner/repo` at `ref` (default branch when `ref` is
|
|
7849
|
+
* undefined). Tries the public codeload host first, then the authenticated API when
|
|
7850
|
+
* a token is available — so a private repo works iff `GITHUB_TOKEN` is set.
|
|
7851
|
+
*/
|
|
7852
|
+
const downloadGithubZip = async (source, options = {}) => {
|
|
7853
|
+
const fetcher = options.fetcher ?? fetch;
|
|
7854
|
+
const { owner, repo, ref } = source;
|
|
7855
|
+
const slug = `${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`;
|
|
7856
|
+
const label = `${owner}/${repo}${ref ? `@${ref}` : ""}`;
|
|
7857
|
+
const publicUrl = `https://codeload.github.com/${slug}/zip/${ref ? encodeRefPath(ref) : "HEAD"}`;
|
|
7858
|
+
assertAllowedUrl(publicUrl);
|
|
7859
|
+
const publicResponse = await fetcher(publicUrl);
|
|
7860
|
+
if (publicResponse.ok) return Buffer.from(await publicResponse.arrayBuffer());
|
|
7861
|
+
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}).`);
|
|
7862
|
+
const apiUrl = `https://api.github.com/repos/${slug}/zipball${ref ? `/${encodeRefPath(ref)}` : ""}`;
|
|
7863
|
+
assertAllowedUrl(apiUrl);
|
|
7864
|
+
const authedResponse = await fetcher(apiUrl, { headers: {
|
|
7865
|
+
Accept: "application/vnd.github+json",
|
|
7866
|
+
Authorization: `Bearer ${options.githubToken}`,
|
|
7867
|
+
"X-GitHub-Api-Version": "2022-11-28"
|
|
7868
|
+
} });
|
|
7869
|
+
if (authedResponse.ok) return Buffer.from(await authedResponse.arrayBuffer());
|
|
7870
|
+
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.`);
|
|
7871
|
+
throw new Error(`GitHub repo or ref not found: ${label} (HTTP ${authedResponse.status}), even with GITHUB_TOKEN. Check the owner, repo, and ref.`);
|
|
7872
|
+
};
|
|
7873
|
+
/** SSRF guard: refuse anything off the GitHub allowlist before a connection is made. */
|
|
7874
|
+
const assertAllowedUrl = (rawUrl) => {
|
|
7875
|
+
const url = new URL(rawUrl);
|
|
7876
|
+
if (url.protocol !== "https:" || !isAllowedGithubHost(url.hostname)) throw new Error(`Refusing to fetch ${rawUrl} — only https on ${GITHUB_ALLOWED_HOSTS.join(", ")} is allowed.`);
|
|
7877
|
+
};
|
|
7878
|
+
/**
|
|
7879
|
+
* Zip-slip guard. Normalizes an archive entry path to a repo-relative path and
|
|
7880
|
+
* rejects anything that could escape the extraction root: `..`/`.` segments,
|
|
7881
|
+
* and Windows drive prefixes. A leading `/` is stripped rather than rejected —
|
|
7882
|
+
* that makes the path relative, which is already safe.
|
|
7883
|
+
*/
|
|
7884
|
+
const normalizeArchivePath = (value) => {
|
|
7885
|
+
const normalized = value.replaceAll("\\", "/").replace(/^\/+|\/+$/g, "");
|
|
7886
|
+
const segments = normalized.split("/");
|
|
7887
|
+
if (!normalized || segments.some((segment) => segment === "." || segment === ".." || /^[a-zA-Z]:$/.test(segment))) throw new Error(`Unsafe archive path rejected: ${value}`);
|
|
7888
|
+
return normalized;
|
|
7889
|
+
};
|
|
7890
|
+
/**
|
|
7891
|
+
* Extract a GitHub zipball into memory, keyed by repo-relative path (the archive's
|
|
7892
|
+
* single generated root directory — `owner-repo-<sha>/` — is stripped).
|
|
7893
|
+
*
|
|
7894
|
+
* Caps are enforced against the archive's declared entry sizes BEFORE any bytes are
|
|
7895
|
+
* read, so an oversized archive or zip bomb is refused up front and nothing is ever
|
|
7896
|
+
* written to disk.
|
|
7897
|
+
*/
|
|
7898
|
+
const extractZip = async (zipBuffer, options = {}) => {
|
|
7899
|
+
const maxFiles = options.maxFiles ?? 5e3;
|
|
7900
|
+
const maxTotalBytes = options.maxTotalBytes ?? 262144e3;
|
|
7901
|
+
const maxFileBytes = options.maxFileBytes ?? 26214400;
|
|
7902
|
+
const reader = new ZipReader(new Uint8ArrayReader(new Uint8Array(zipBuffer)));
|
|
7903
|
+
try {
|
|
7904
|
+
const files = (await reader.getEntries()).filter((entry) => !entry.directory);
|
|
7905
|
+
const archiveRoot = files[0]?.filename.split("/")[0];
|
|
7906
|
+
if (!archiveRoot) throw new Error("The downloaded repository archive is empty.");
|
|
7907
|
+
const subdir = options.subdir ? normalizeArchivePath(options.subdir) : void 0;
|
|
7908
|
+
const prefix = `${archiveRoot}/${subdir ? `${subdir}/` : ""}`;
|
|
7909
|
+
for (const entry of files) normalizeArchivePath(entry.filename);
|
|
7910
|
+
const selected = files.filter((entry) => entry.filename.startsWith(prefix));
|
|
7911
|
+
if (selected.length > maxFiles) throw new Error(`Package has ${selected.length} files, above the ${maxFiles}-file limit. Nothing was installed.`);
|
|
7912
|
+
let totalBytes = 0;
|
|
7913
|
+
for (const entry of selected) {
|
|
7914
|
+
const size = entry.uncompressedSize ?? 0;
|
|
7915
|
+
const relativePath = entry.filename.slice(prefix.length);
|
|
7916
|
+
if (size > maxFileBytes) throw new Error(`File "${relativePath}" is ${formatBytes(size)}, above the ${formatBytes(maxFileBytes)} per-file limit. Nothing was installed.`);
|
|
7917
|
+
totalBytes += size;
|
|
7918
|
+
if (totalBytes > maxTotalBytes) throw new Error(`Package unpacks to more than the ${formatBytes(maxTotalBytes)} total limit. Nothing was installed.`);
|
|
7919
|
+
}
|
|
7920
|
+
const result = /* @__PURE__ */ new Map();
|
|
7921
|
+
for (const entry of selected) {
|
|
7922
|
+
const relativePath = normalizeArchivePath(entry.filename.slice(prefix.length));
|
|
7923
|
+
if (!entry.getData) continue;
|
|
7924
|
+
const data = await entry.arrayBuffer();
|
|
7925
|
+
result.set(relativePath, Buffer.from(data));
|
|
7926
|
+
}
|
|
7927
|
+
return result;
|
|
7928
|
+
} finally {
|
|
7929
|
+
await reader.close();
|
|
7930
|
+
}
|
|
7931
|
+
};
|
|
7932
|
+
const formatBytes = (bytes) => bytes >= 1048576 ? `${Math.round(bytes / 1048576)}MB` : `${Math.round(bytes / 1024)}KB`;
|
|
7933
|
+
/** Parse, fetch, and extract in one step — the `install` command's entry point. */
|
|
7934
|
+
const fetchGithubPackageFiles = async (repoUrl, options = {}) => {
|
|
7935
|
+
const source = parseGithubUrl(repoUrl);
|
|
7936
|
+
const zipBuffer = await downloadGithubZip(source, options);
|
|
7937
|
+
return {
|
|
7938
|
+
source,
|
|
7939
|
+
files: await extractZip(zipBuffer, { subdir: source.subdir })
|
|
7940
|
+
};
|
|
7941
|
+
};
|
|
7942
|
+
//#endregion
|
|
7943
|
+
//#region ../../packages/busabase-package/src/index-build.ts
|
|
7944
|
+
/**
|
|
7945
|
+
* Build the Template Center's catalog from a directory of skills.
|
|
7946
|
+
*
|
|
7947
|
+
* The catalog is not hand-maintained. A card that says "6 tables" for a package
|
|
7948
|
+
* that installs five is a trust bug, not a typo — so every field here is read
|
|
7949
|
+
* off the package itself, and whether an entry appears at all is decided by the
|
|
7950
|
+
* SAME `validateTemplate` the installer uses. A skill that would install as a
|
|
7951
|
+
* plain package can never be listed as a template, because one function answers
|
|
7952
|
+
* that question for both.
|
|
7953
|
+
*
|
|
7954
|
+
* Deliberately a plain function over a file map rather than something that
|
|
7955
|
+
* walks a disk: the CLI hands it a checkout, and a future CI job could hand it
|
|
7956
|
+
* a fetched zipball, without either needing its own notion of what counts.
|
|
7957
|
+
*
|
|
7958
|
+
* Spec: `apps/busabase/content/spec/template-center.md` §6.4.
|
|
7959
|
+
*/
|
|
7960
|
+
/** Bumped when a consumer would misread an older file. */
|
|
7961
|
+
const TEMPLATE_INDEX_FORMAT = "busabase-template-index@1";
|
|
7962
|
+
const toEntry = (found, airapps) => ({
|
|
7963
|
+
subdir: found.subdir,
|
|
7964
|
+
name: found.name,
|
|
7965
|
+
description: found.description,
|
|
7966
|
+
category: found.category ?? "uncategorized",
|
|
7967
|
+
tags: found.tags ?? [],
|
|
7968
|
+
screenshots: found.screenshots,
|
|
7969
|
+
agentPrompts: found.agentPrompts ?? [],
|
|
7970
|
+
...found.version ? { version: found.version } : {},
|
|
7971
|
+
...found.author ? { author: found.author } : {},
|
|
7972
|
+
...found.license ? { license: found.license } : {},
|
|
7973
|
+
stats: {
|
|
7974
|
+
folders: found.counts.folders,
|
|
7975
|
+
docs: found.counts.docs,
|
|
7976
|
+
bases: found.counts.bases,
|
|
7977
|
+
records: found.counts.records,
|
|
7978
|
+
files: found.counts.files,
|
|
7979
|
+
airapps,
|
|
7980
|
+
skill: true
|
|
7981
|
+
}
|
|
7982
|
+
});
|
|
7983
|
+
const buildTemplateIndex = (files, options) => {
|
|
7984
|
+
const discovered = discoverPackages(files);
|
|
7985
|
+
const templates = [];
|
|
7986
|
+
const rejected = [];
|
|
7987
|
+
for (const found of discovered) {
|
|
7988
|
+
if (found.isTemplate) {
|
|
7989
|
+
templates.push(toEntry(found, found.primaryAirApp ? 1 : 0));
|
|
7990
|
+
continue;
|
|
7991
|
+
}
|
|
7992
|
+
if (found.templateErrors.length > 0) rejected.push({
|
|
7993
|
+
subdir: found.subdir,
|
|
7994
|
+
name: found.name,
|
|
7995
|
+
errors: found.templateErrors
|
|
7996
|
+
});
|
|
7997
|
+
}
|
|
7998
|
+
return {
|
|
7999
|
+
format: TEMPLATE_INDEX_FORMAT,
|
|
8000
|
+
repo: options.repo,
|
|
8001
|
+
ref: options.ref,
|
|
8002
|
+
templates: templates.sort((a, b) => a.name.localeCompare(b.name, "en")),
|
|
8003
|
+
rejected: rejected.sort((a, b) => a.name.localeCompare(b.name, "en"))
|
|
8004
|
+
};
|
|
8005
|
+
};
|
|
8006
|
+
const renderTemplateIndex = (index) => `${JSON.stringify(index, null, 2)}\n`;
|
|
8007
|
+
//#endregion
|
|
8008
|
+
//#region ../../packages/busabase-package/src/layout-write.ts
|
|
8009
|
+
/**
|
|
8010
|
+
* Layout writer: the in-memory tree → a repo directory (§6.1), deterministically
|
|
8011
|
+
* (§6.6). "Clean diffs" is a feature: for an unchanged space, `export` twice must
|
|
8012
|
+
* produce byte-identical output, so re-exporting after one record edit diffs as
|
|
8013
|
+
* exactly one changed NDJSON line.
|
|
8014
|
+
*
|
|
8015
|
+
* Determinism rules, all enforced here:
|
|
8016
|
+
* • 2-space-indented JSON with a schema-defined key order
|
|
8017
|
+
* • `options` blobs (carried verbatim from the server, whose JSONB key order is
|
|
8018
|
+
* not a contract) are deep key-sorted
|
|
8019
|
+
* • records sorted by `key`, one per line
|
|
8020
|
+
* • LF endings, trailing newline on every file
|
|
8021
|
+
*/
|
|
8022
|
+
/**
|
|
8023
|
+
* Deep key-sort. Applied to blobs the server owns the key order of (field
|
|
8024
|
+
* `options`), never to arrays — `choices`, `filters`, and `sorts` are ordered data.
|
|
8025
|
+
*/
|
|
8026
|
+
const sortKeysDeep = (value) => {
|
|
8027
|
+
if (Array.isArray(value)) return value.map(sortKeysDeep);
|
|
8028
|
+
if (value === null || typeof value !== "object") return value;
|
|
8029
|
+
const entries = Object.entries(value).filter(([, item]) => item !== void 0).sort(([a], [b]) => a.localeCompare(b, "en"));
|
|
8030
|
+
return Object.fromEntries(entries.map(([key, item]) => [key, sortKeysDeep(item)]));
|
|
8031
|
+
};
|
|
8032
|
+
/** 2-space JSON + trailing LF. Key order is the insertion order of `value`. */
|
|
8033
|
+
const toJsonFile = (value) => Buffer.from(`${JSON.stringify(value, null, 2)}\n`, "utf8");
|
|
8034
|
+
/** Drop `undefined` so optional keys vanish rather than serializing inconsistently. */
|
|
8035
|
+
const compact = (value) => Object.fromEntries(Object.entries(value).filter(([, item]) => item !== void 0));
|
|
8036
|
+
const serializeManifest = (manifest) => toJsonFile(compact({
|
|
8037
|
+
format: manifest.format,
|
|
8038
|
+
name: manifest.name,
|
|
8039
|
+
description: manifest.description || void 0,
|
|
8040
|
+
version: manifest.version,
|
|
8041
|
+
author: manifest.author,
|
|
8042
|
+
license: manifest.license,
|
|
8043
|
+
homepage: manifest.homepage,
|
|
8044
|
+
tags: manifest.tags.length > 0 ? manifest.tags : void 0,
|
|
8045
|
+
template: manifest.template
|
|
8046
|
+
}));
|
|
8047
|
+
const serializeField = (field) => compact({
|
|
8048
|
+
slug: field.slug,
|
|
8049
|
+
name: field.name,
|
|
8050
|
+
type: field.type,
|
|
8051
|
+
required: field.required,
|
|
8052
|
+
position: field.position,
|
|
8053
|
+
options: sortKeysDeep(field.options)
|
|
8054
|
+
});
|
|
8055
|
+
const serializeView = (view) => compact({
|
|
8056
|
+
slug: view.slug,
|
|
8057
|
+
name: view.name,
|
|
8058
|
+
description: view.description || void 0,
|
|
8059
|
+
type: view.type,
|
|
8060
|
+
config: compact({
|
|
8061
|
+
filters: view.config.filters.map((filter) => compact({
|
|
8062
|
+
fieldSlug: filter.fieldSlug,
|
|
8063
|
+
operator: filter.operator,
|
|
8064
|
+
value: filter.value
|
|
8065
|
+
})),
|
|
8066
|
+
sorts: view.config.sorts.map((sort) => ({
|
|
8067
|
+
direction: sort.direction,
|
|
8068
|
+
fieldSlug: sort.fieldSlug
|
|
8069
|
+
})),
|
|
8070
|
+
visibleFieldSlugs: view.config.visibleFieldSlugs ?? void 0,
|
|
8071
|
+
fieldWidths: view.config.fieldWidths
|
|
8072
|
+
})
|
|
8073
|
+
});
|
|
8074
|
+
const serializeBase = (base, position) => toJsonFile(compact({
|
|
8075
|
+
name: base.name,
|
|
8076
|
+
description: base.description || void 0,
|
|
8077
|
+
position,
|
|
8078
|
+
reviewPolicy: base.reviewPolicy,
|
|
8079
|
+
fields: base.fields.map(serializeField),
|
|
8080
|
+
views: base.views.map(serializeView)
|
|
8081
|
+
}));
|
|
8082
|
+
/** One JSON object per line, sorted by `key` — determinism (§6.4). */
|
|
8083
|
+
const serializeRecords = (records) => {
|
|
8084
|
+
const lines = [...records].sort((a, b) => a.key.localeCompare(b.key, "en")).map((record) => JSON.stringify({
|
|
8085
|
+
key: record.key,
|
|
8086
|
+
fields: record.fields
|
|
8087
|
+
}));
|
|
8088
|
+
return Buffer.from(lines.length > 0 ? `${lines.join("\n")}\n` : "", "utf8");
|
|
8089
|
+
};
|
|
8090
|
+
/**
|
|
8091
|
+
* Render a tree to package-relative paths + bytes, validating §6.3 as it goes.
|
|
8092
|
+
* Pure: no disk access, so the round-trip and determinism tests exercise it directly.
|
|
8093
|
+
*/
|
|
8094
|
+
const renderPackageTree = (tree) => {
|
|
8095
|
+
const files = /* @__PURE__ */ new Map();
|
|
8096
|
+
files.set(PACKAGE_MANIFEST_FILENAME, serializeManifest(tree.manifest));
|
|
8097
|
+
renderRootSkill(tree.rootSkill, files);
|
|
8098
|
+
renderNodes(tree.nodes, `${PACKAGE_CONTENT_DIRNAME}/`, files);
|
|
8099
|
+
renderDocAssets(tree.assets ?? [], files);
|
|
8100
|
+
return files;
|
|
8101
|
+
};
|
|
8102
|
+
/**
|
|
8103
|
+
* The root skill's files, back at the package root — NOT under `content/`.
|
|
8104
|
+
*
|
|
8105
|
+
* This asymmetry with every other node is the whole point: writing it as
|
|
8106
|
+
* `content/<slug>/` would produce a package whose next install created a second
|
|
8107
|
+
* Skill node beside the first, and each export→install cycle would add another.
|
|
8108
|
+
* Round-tripping through the root is what keeps exactly one copy in existence.
|
|
8109
|
+
*/
|
|
8110
|
+
const renderRootSkill = (rootSkill, files) => {
|
|
8111
|
+
if (!rootSkill) return;
|
|
8112
|
+
for (const entry of rootSkill.files) {
|
|
8113
|
+
assertSafeFilePath(entry.path, `root skill "${rootSkill.slug}"`);
|
|
8114
|
+
files.set(entry.path, entry.bytes);
|
|
8115
|
+
}
|
|
7159
8116
|
};
|
|
7160
|
-
|
|
7161
|
-
|
|
8117
|
+
/**
|
|
8118
|
+
* `assets/<assetId><ext>` + its `.asset.json` sidecar, for every image the Docs
|
|
8119
|
+
* embed. Deduped by asset id (one image in three Docs is one pair of files) and
|
|
8120
|
+
* emitted in a fixed order, so a re-export of an unchanged space is still
|
|
8121
|
+
* byte-identical (§6.6).
|
|
8122
|
+
*/
|
|
8123
|
+
const renderDocAssets = (assets, files) => {
|
|
8124
|
+
const byId = /* @__PURE__ */ new Map();
|
|
8125
|
+
for (const asset of assets) if (!byId.has(asset.assetId)) byId.set(asset.assetId, asset);
|
|
8126
|
+
for (const asset of [...byId.values()].sort((a, b) => a.assetId.localeCompare(b.assetId, "en"))) {
|
|
8127
|
+
const bytesPath = docAssetBytesPath(asset);
|
|
8128
|
+
assertSafeFilePath(bytesPath, `doc asset "${asset.assetId}"`);
|
|
8129
|
+
files.set(bytesPath, asset.bytes);
|
|
8130
|
+
files.set(`${bytesPath}${PACKAGE_ASSET_META_SUFFIX}`, toJsonFile(compact({
|
|
8131
|
+
assetId: asset.assetId,
|
|
8132
|
+
fileName: asset.fileName,
|
|
8133
|
+
mimeType: asset.mimeType,
|
|
8134
|
+
contentHash: asset.contentHash
|
|
8135
|
+
})));
|
|
8136
|
+
}
|
|
7162
8137
|
};
|
|
7163
|
-
const
|
|
7164
|
-
const
|
|
7165
|
-
|
|
7166
|
-
|
|
7167
|
-
|
|
7168
|
-
|
|
7169
|
-
|
|
7170
|
-
|
|
7171
|
-
|
|
7172
|
-
|
|
7173
|
-
|
|
7174
|
-
|
|
8138
|
+
const renderNodes = (nodes, dir, files) => {
|
|
8139
|
+
const ordered = sortNodes(nodes);
|
|
8140
|
+
assertNoSiblingCaseCollisions(ordered.map((node) => node.slug), `in ${dir}`);
|
|
8141
|
+
const seen = /* @__PURE__ */ new Set();
|
|
8142
|
+
for (const node of ordered) {
|
|
8143
|
+
assertSafeNodeSlug(node.slug, `${node.type} node`);
|
|
8144
|
+
if (seen.has(node.slug)) throw new Error(`Two nodes in ${dir} would be written as "${node.slug}". Sibling slugs must be unique.`);
|
|
8145
|
+
seen.add(node.slug);
|
|
8146
|
+
renderNode(node, dir, files);
|
|
8147
|
+
}
|
|
8148
|
+
};
|
|
8149
|
+
const renderNode = (node, dir, files) => {
|
|
8150
|
+
switch (node.type) {
|
|
7175
8151
|
case "folder":
|
|
7176
|
-
|
|
7177
|
-
|
|
8152
|
+
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({
|
|
8153
|
+
name: node.name,
|
|
8154
|
+
description: node.description || void 0,
|
|
8155
|
+
position: node.position
|
|
8156
|
+
})));
|
|
8157
|
+
renderNodes(node.children, `${dir}${node.slug}/`, files);
|
|
8158
|
+
return;
|
|
7178
8159
|
case "doc":
|
|
7179
|
-
|
|
7180
|
-
|
|
7181
|
-
|
|
7182
|
-
|
|
7183
|
-
|
|
7184
|
-
|
|
8160
|
+
files.set(`${dir}${node.slug}.md`, Buffer.from(serializeDoc({
|
|
8161
|
+
name: node.name,
|
|
8162
|
+
description: node.description,
|
|
8163
|
+
position: node.position
|
|
8164
|
+
}, node.body), "utf8"));
|
|
8165
|
+
return;
|
|
8166
|
+
case "base": {
|
|
8167
|
+
files.set(`${dir}${node.slug}/${PACKAGE_BASE_FILENAME}`, serializeBase(node.base, node.position));
|
|
8168
|
+
const records = serializeRecords(node.records);
|
|
8169
|
+
if (records.byteLength > 0) files.set(`${dir}${node.slug}/${PACKAGE_RECORDS_FILENAME}`, records);
|
|
8170
|
+
return;
|
|
8171
|
+
}
|
|
7185
8172
|
case "skill":
|
|
7186
|
-
counts.skills++;
|
|
7187
|
-
counts.files += node.files.length;
|
|
7188
|
-
break;
|
|
7189
8173
|
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}`);
|
|
8174
|
+
case "drive": {
|
|
8175
|
+
const nodeDir = `${dir}${node.slug}/`;
|
|
8176
|
+
assertNoReservedNames(node.files.map((entry) => entry.path), `${node.type} "${node.slug}"`);
|
|
8177
|
+
files.set(`${nodeDir}${PACKAGE_NODE_META_FILENAME}`, toJsonFile(compact({
|
|
8178
|
+
type: node.type,
|
|
8179
|
+
name: node.name,
|
|
8180
|
+
description: node.description || void 0,
|
|
8181
|
+
position: node.position
|
|
8182
|
+
})));
|
|
8183
|
+
for (const entry of [...node.files].sort((a, b) => a.path.localeCompare(b.path, "en"))) {
|
|
8184
|
+
assertSafeFilePath(entry.path, `${node.type} "${node.slug}"`);
|
|
8185
|
+
files.set(`${nodeDir}${entry.path}`, entry.bytes);
|
|
8186
|
+
}
|
|
8187
|
+
return;
|
|
8188
|
+
}
|
|
8189
|
+
case "file":
|
|
8190
|
+
assertSafeFilePath(node.fileName, `file node "${node.slug}"`);
|
|
8191
|
+
files.set(`${dir}${node.fileName}`, node.bytes);
|
|
8192
|
+
if (node.name !== node.fileName || node.description !== "" || node.position !== void 0) files.set(`${dir}${node.fileName}${PACKAGE_NODE_META_SUFFIX}`, toJsonFile(compact({
|
|
8193
|
+
name: node.name,
|
|
8194
|
+
description: node.description || void 0,
|
|
8195
|
+
position: node.position
|
|
8196
|
+
})));
|
|
8197
|
+
return;
|
|
7223
8198
|
}
|
|
7224
|
-
return lines.join("\n");
|
|
7225
8199
|
};
|
|
7226
|
-
|
|
7227
|
-
|
|
7228
|
-
|
|
7229
|
-
|
|
7230
|
-
|
|
7231
|
-
|
|
8200
|
+
/** Write rendered files under `outDir`, creating directories as needed. */
|
|
8201
|
+
const writePackageFiles = async (files, outDir, options = {}) => {
|
|
8202
|
+
if (options.clean) {
|
|
8203
|
+
await rm(join(outDir, PACKAGE_CONTENT_DIRNAME), {
|
|
8204
|
+
recursive: true,
|
|
8205
|
+
force: true
|
|
8206
|
+
});
|
|
8207
|
+
await rm(join(outDir, PACKAGE_ASSETS_DIRNAME), {
|
|
8208
|
+
recursive: true,
|
|
8209
|
+
force: true
|
|
8210
|
+
});
|
|
8211
|
+
await rm(join(outDir, PACKAGE_MANIFEST_FILENAME), { force: true });
|
|
8212
|
+
await rm(join(outDir, PACKAGE_SKILL_ENTRY), { force: true });
|
|
8213
|
+
for (const dirName of PACKAGE_SKILL_SIDECAR_DIRS) await rm(join(outDir, dirName), {
|
|
8214
|
+
recursive: true,
|
|
8215
|
+
force: true
|
|
8216
|
+
});
|
|
7232
8217
|
}
|
|
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.`);
|
|
8218
|
+
for (const [filePath, bytes] of [...files].sort(([a], [b]) => a.localeCompare(b, "en"))) {
|
|
8219
|
+
const target = join(outDir, filePath);
|
|
8220
|
+
await mkdir(dirname(target), { recursive: true });
|
|
8221
|
+
await writeFile(target, bytes);
|
|
7243
8222
|
}
|
|
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
8223
|
};
|
|
7246
8224
|
//#endregion
|
|
7247
8225
|
//#region src/package/commands.ts
|
|
@@ -7257,11 +8235,26 @@ const runInstall = async (client, repoUrl, options) => {
|
|
|
7257
8235
|
reportProgress(`Fetching ${repoUrl} …`);
|
|
7258
8236
|
const { source, files } = await fetchGithubPackageFiles(repoUrl, { githubToken: options.githubToken });
|
|
7259
8237
|
reportProgress(`Downloaded ${files.size} file(s) from ${source.owner}/${source.repo}.`);
|
|
7260
|
-
const
|
|
8238
|
+
const resolved = resolvePackageToInstall(files, options.skill);
|
|
8239
|
+
if (resolved.kind === "none") {
|
|
8240
|
+
readPackageTree(files);
|
|
8241
|
+
throw new Error("Not a Busabase package.");
|
|
8242
|
+
}
|
|
8243
|
+
if (resolved.kind === "choose") {
|
|
8244
|
+
const report = renderPackageChoices(repoUrl, resolved.candidates, options.skill);
|
|
8245
|
+
if (options.json) return {
|
|
8246
|
+
chooseOne: true,
|
|
8247
|
+
candidates: resolved.candidates.map(toCandidateSummary)
|
|
8248
|
+
};
|
|
8249
|
+
return report;
|
|
8250
|
+
}
|
|
8251
|
+
const tree = readPackageTree(files, resolved.subdir ? { root: resolved.subdir } : {});
|
|
8252
|
+
if (resolved.subdir) reportProgress(`Installing "${resolved.discovered.name}" from ${resolved.subdir}/ …`);
|
|
7261
8253
|
const targetFolderSlug = options.intoFolder ?? suggestSlug(tree.manifest.name);
|
|
7262
8254
|
const plan = buildInstallPlan(tree, await resolveTargetState(client, targetFolderSlug), {
|
|
7263
8255
|
intoFolder: options.intoFolder,
|
|
7264
|
-
rename: options.rename
|
|
8256
|
+
rename: options.rename,
|
|
8257
|
+
installSampleRecords: !options.noSampleRecords
|
|
7265
8258
|
});
|
|
7266
8259
|
if (options.dryRun) {
|
|
7267
8260
|
const report = `${renderPlan(plan)}\n\nDry run — nothing was created.`;
|
|
@@ -7275,7 +8268,13 @@ const runInstall = async (client, repoUrl, options) => {
|
|
|
7275
8268
|
autoMerge: Boolean(options.autoMerge),
|
|
7276
8269
|
submittedBy: `busabase-cli install (${tree.manifest.name})`,
|
|
7277
8270
|
onProgress: reportProgress,
|
|
7278
|
-
serverUrl: options.serverUrl
|
|
8271
|
+
serverUrl: options.serverUrl,
|
|
8272
|
+
installSampleRecords: !options.noSampleRecords,
|
|
8273
|
+
source: {
|
|
8274
|
+
repo: `${source.owner}/${source.repo}`,
|
|
8275
|
+
ref: source.ref,
|
|
8276
|
+
...resolved.subdir ? { subdir: resolved.subdir } : source.subdir ? { subdir: source.subdir } : {}
|
|
8277
|
+
}
|
|
7279
8278
|
});
|
|
7280
8279
|
if (options.json) return {
|
|
7281
8280
|
installed: true,
|
|
@@ -7283,6 +8282,45 @@ const runInstall = async (client, repoUrl, options) => {
|
|
|
7283
8282
|
};
|
|
7284
8283
|
return renderInstallReport(plan.targetFolderSlug, result);
|
|
7285
8284
|
};
|
|
8285
|
+
const toCandidateSummary = (candidate) => ({
|
|
8286
|
+
name: candidate.name,
|
|
8287
|
+
subdir: candidate.subdir,
|
|
8288
|
+
description: candidate.description,
|
|
8289
|
+
isTemplate: candidate.isTemplate,
|
|
8290
|
+
counts: candidate.counts
|
|
8291
|
+
});
|
|
8292
|
+
/**
|
|
8293
|
+
* The list a user sees when their URL pointed at a repository of packages.
|
|
8294
|
+
*
|
|
8295
|
+
* Templates are listed first and labelled, because that is the difference the
|
|
8296
|
+
* user actually cares about: one installs an app, the other installs tables. A
|
|
8297
|
+
* package that TRIED to be a template and failed says so with its reasons —
|
|
8298
|
+
* that reader is usually its author.
|
|
8299
|
+
*/
|
|
8300
|
+
const renderPackageChoices = (repoUrl, candidates, wanted) => {
|
|
8301
|
+
const lines = [];
|
|
8302
|
+
if (wanted) lines.push(`No package named "${wanted}" in ${repoUrl}. It contains:`);
|
|
8303
|
+
else lines.push(`${repoUrl} contains ${candidates.length} packages. Pick one:`);
|
|
8304
|
+
lines.push("");
|
|
8305
|
+
const ordered = [...candidates].sort((a, b) => {
|
|
8306
|
+
if (a.isTemplate !== b.isTemplate) return a.isTemplate ? -1 : 1;
|
|
8307
|
+
return a.name.localeCompare(b.name, "en");
|
|
8308
|
+
});
|
|
8309
|
+
for (const candidate of ordered) {
|
|
8310
|
+
const shape = [
|
|
8311
|
+
candidate.isTemplate ? "template" : "package",
|
|
8312
|
+
candidate.counts.bases > 0 ? `${candidate.counts.bases} base(s)` : void 0,
|
|
8313
|
+
candidate.primaryAirApp ? "app" : void 0
|
|
8314
|
+
].filter(Boolean).join(" · ");
|
|
8315
|
+
lines.push(` ${candidate.name} (${shape})`);
|
|
8316
|
+
if (candidate.description) lines.push(` ${candidate.description}`);
|
|
8317
|
+
for (const error of candidate.templateErrors) lines.push(` not a template: ${error}`);
|
|
8318
|
+
}
|
|
8319
|
+
lines.push("");
|
|
8320
|
+
lines.push("Install one with:");
|
|
8321
|
+
lines.push(` busabase-cli install ${repoUrl} --skill ${ordered[0]?.name ?? "<name>"}`);
|
|
8322
|
+
return lines.join("\n");
|
|
8323
|
+
};
|
|
7286
8324
|
const toPlanSummary = (plan) => ({
|
|
7287
8325
|
targetFolder: plan.targetFolderSlug,
|
|
7288
8326
|
package: plan.tree.manifest.name,
|
|
@@ -7320,6 +8358,27 @@ const runExport = async (client, nodeSlugOrId, options) => {
|
|
|
7320
8358
|
warn: (message) => warnings.push(message),
|
|
7321
8359
|
baseUrl: options.baseUrl
|
|
7322
8360
|
});
|
|
8361
|
+
if (options.template) {
|
|
8362
|
+
if (!tree.rootSkill) {
|
|
8363
|
+
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.`);
|
|
8364
|
+
tree.rootSkill = {
|
|
8365
|
+
slug: manifest.name,
|
|
8366
|
+
name: manifest.name,
|
|
8367
|
+
description: manifest.description,
|
|
8368
|
+
files: [{
|
|
8369
|
+
path: PACKAGE_SKILL_ENTRY,
|
|
8370
|
+
bytes: Buffer.from(deriveSkillDraft(tree), "utf8")
|
|
8371
|
+
}]
|
|
8372
|
+
};
|
|
8373
|
+
}
|
|
8374
|
+
if (!tree.manifest.template) tree.manifest = {
|
|
8375
|
+
...tree.manifest,
|
|
8376
|
+
template: TemplateManifestSchema.parse({ category: "uncategorized" })
|
|
8377
|
+
};
|
|
8378
|
+
const validation = validateTemplate(tree);
|
|
8379
|
+
for (const warning of validation.warnings) warnings.push(warning);
|
|
8380
|
+
for (const error of validation.errors) warnings.push(`Not yet a valid template: ${error}`);
|
|
8381
|
+
}
|
|
7323
8382
|
const files = renderPackageTree(tree);
|
|
7324
8383
|
if (!options.dryRun) await writePackageFiles(files, options.outDir, { clean: true });
|
|
7325
8384
|
if (options.json) return {
|
|
@@ -7327,6 +8386,7 @@ const runExport = async (client, nodeSlugOrId, options) => {
|
|
|
7327
8386
|
package: manifest.name,
|
|
7328
8387
|
outDir: options.outDir,
|
|
7329
8388
|
files: [...files.keys()].sort(),
|
|
8389
|
+
...options.template ? { isTemplate: validateTemplate(tree).ok } : {},
|
|
7330
8390
|
warnings
|
|
7331
8391
|
};
|
|
7332
8392
|
return renderExportReport(options, manifest, files, warnings);
|
|
@@ -7378,6 +8438,62 @@ const renderExportReport = (options, manifest, files, warnings) => {
|
|
|
7378
8438
|
}
|
|
7379
8439
|
return lines.join("\n");
|
|
7380
8440
|
};
|
|
8441
|
+
/**
|
|
8442
|
+
* Build the Template Center's catalog from a checkout.
|
|
8443
|
+
*
|
|
8444
|
+
* Lives here rather than in the skills repository so the catalog and the
|
|
8445
|
+
* installer share one answer to "is this a template". A repository that decided
|
|
8446
|
+
* for itself could list a card whose install then behaves differently — which
|
|
8447
|
+
* is a trust problem, not a formatting one.
|
|
8448
|
+
*/
|
|
8449
|
+
const runIndex = async (options) => {
|
|
8450
|
+
const files = await readDirectoryFiles(options.dir);
|
|
8451
|
+
const index = buildTemplateIndex(files, {
|
|
8452
|
+
repo: options.repo,
|
|
8453
|
+
ref: options.ref
|
|
8454
|
+
});
|
|
8455
|
+
const rendered = renderTemplateIndex(index);
|
|
8456
|
+
if (options.check) {
|
|
8457
|
+
if (!options.out) throw new Error("--check needs --out: there is nothing to compare against.");
|
|
8458
|
+
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.`);
|
|
8459
|
+
return options.json ? {
|
|
8460
|
+
upToDate: true,
|
|
8461
|
+
templates: index.templates.length
|
|
8462
|
+
} : `${options.out} is up to date (${index.templates.length} template(s)).`;
|
|
8463
|
+
}
|
|
8464
|
+
if (options.out) {
|
|
8465
|
+
await mkdir(dirname(options.out), { recursive: true });
|
|
8466
|
+
await writeFile(options.out, rendered);
|
|
8467
|
+
}
|
|
8468
|
+
if (options.json) return index;
|
|
8469
|
+
if (!options.out) return rendered.trimEnd();
|
|
8470
|
+
const lines = [`Wrote ${options.out}: ${index.templates.length} template(s).`];
|
|
8471
|
+
for (const entry of index.templates) lines.push(` ${entry.name} (${entry.category} · ${entry.stats.bases} base(s))`);
|
|
8472
|
+
if (index.rejected.length > 0) {
|
|
8473
|
+
lines.push("", "Declared a template but did not qualify:");
|
|
8474
|
+
for (const entry of index.rejected) {
|
|
8475
|
+
lines.push(` ${entry.name}`);
|
|
8476
|
+
for (const error of entry.errors) lines.push(` ${error}`);
|
|
8477
|
+
}
|
|
8478
|
+
}
|
|
8479
|
+
return lines.join("\n");
|
|
8480
|
+
};
|
|
8481
|
+
/** Every file under `dir`, keyed by its POSIX path relative to it. */
|
|
8482
|
+
const readDirectoryFiles = async (dir) => {
|
|
8483
|
+
const files = /* @__PURE__ */ new Map();
|
|
8484
|
+
const walk = async (current) => {
|
|
8485
|
+
for (const entry of await readdir(current, { withFileTypes: true })) {
|
|
8486
|
+
if (entry.name === "node_modules" || entry.name === ".git") continue;
|
|
8487
|
+
const absolute = join(current, entry.name);
|
|
8488
|
+
const info = await stat(absolute).catch(() => null);
|
|
8489
|
+
if (!info) continue;
|
|
8490
|
+
if (info.isDirectory()) await walk(absolute);
|
|
8491
|
+
else if (info.isFile()) files.set(relative(dir, absolute).split(sep).join("/"), await readFile(absolute));
|
|
8492
|
+
}
|
|
8493
|
+
};
|
|
8494
|
+
await walk(dir);
|
|
8495
|
+
return files;
|
|
8496
|
+
};
|
|
7381
8497
|
//#endregion
|
|
7382
8498
|
//#region src/task-command.ts
|
|
7383
8499
|
const collect = (value, previous = []) => [...previous, value];
|
|
@@ -7782,6 +8898,23 @@ function runArgAction(state, handler) {
|
|
|
7782
8898
|
console.log(render(result, config.output));
|
|
7783
8899
|
};
|
|
7784
8900
|
}
|
|
8901
|
+
/**
|
|
8902
|
+
* A command that touches only the local filesystem.
|
|
8903
|
+
*
|
|
8904
|
+
* `runArgAction` resolves credentials and builds a client before it calls the
|
|
8905
|
+
* handler, which is right for everything that talks to a workspace — and wrong
|
|
8906
|
+
* for `index`, whose whole job is to read a checkout. A CI runner building the
|
|
8907
|
+
* catalog has no Busabase server and no key, and demanding one would make the
|
|
8908
|
+
* catalog impossible to build in exactly the place it is meant to be built.
|
|
8909
|
+
*/
|
|
8910
|
+
function runLocalArgAction(handler) {
|
|
8911
|
+
return async (arg, _opts, cmd) => {
|
|
8912
|
+
const opts = cmd.optsWithGlobals();
|
|
8913
|
+
const config = resolveConfig(opts);
|
|
8914
|
+
const result = await handler(arg, opts, config);
|
|
8915
|
+
console.log(render(result, config.output));
|
|
8916
|
+
};
|
|
8917
|
+
}
|
|
7785
8918
|
function pkgVersion() {
|
|
7786
8919
|
try {
|
|
7787
8920
|
return JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version ?? "0.0.0";
|
|
@@ -8311,7 +9444,7 @@ Examples:
|
|
|
8311
9444
|
startLine: opts.startLine,
|
|
8312
9445
|
endLine: opts.endLine
|
|
8313
9446
|
})));
|
|
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", `
|
|
9447
|
+
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
9448
|
The URL's git ref is the version pin — a tag installs that tag's content forever,
|
|
8316
9449
|
even after the branch moves on. GITHUB_TOKEN is honored for private repos.
|
|
8317
9450
|
|
|
@@ -8325,6 +9458,16 @@ Folders, Bases, their fields and their views are structure and are always create
|
|
|
8325
9458
|
immediately. Records are content: by default they land as change requests for you
|
|
8326
9459
|
to review, and --auto-merge merges them on the spot instead.
|
|
8327
9460
|
|
|
9461
|
+
A TEMPLATE (a package that also carries a SKILL.md) installs as an app: its
|
|
9462
|
+
manual lands as a Skill node, its nodes are stamped as belonging to that app, its
|
|
9463
|
+
Base slugs are prefixed with the target folder so two templates cannot collide on
|
|
9464
|
+
a name like "settings", and its sample rows are merged so the app is not empty
|
|
9465
|
+
when you open it (--no-sample-records opts out). Its AirApp code and its Skill
|
|
9466
|
+
still wait for your review.
|
|
9467
|
+
|
|
9468
|
+
If the URL is a repository that holds several packages rather than being one,
|
|
9469
|
+
install lists them and you pick with --skill <name>.
|
|
9470
|
+
|
|
8328
9471
|
A package whose records carry relation values requires --auto-merge — a relation
|
|
8329
9472
|
stores the ids of the records it points at, and those exist only once the records
|
|
8330
9473
|
are merged, so review-first would install every relation empty. Defining a relation
|
|
@@ -8333,20 +9476,46 @@ field with nothing linked yet does not trigger this.`).action(runArgAction(state
|
|
|
8333
9476
|
dryRun: Boolean(opts.dryRun),
|
|
8334
9477
|
autoMerge: Boolean(opts.autoMerge),
|
|
8335
9478
|
rename: Boolean(opts.rename),
|
|
9479
|
+
skill: opts.skill,
|
|
9480
|
+
noSampleRecords: opts.sampleRecords === false,
|
|
8336
9481
|
json: config.output === "json",
|
|
8337
9482
|
githubToken: process.env.GITHUB_TOKEN,
|
|
8338
9483
|
serverUrl: config.baseUrl
|
|
8339
9484
|
})));
|
|
8340
|
-
addGlobalFlags(program.command("
|
|
9485
|
+
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", `
|
|
9486
|
+
Whether a skill is listed is decided by the same rules that decide how it
|
|
9487
|
+
installs, so a card can never promise something its install does not do. A skill
|
|
9488
|
+
that declared itself a template and did not qualify is reported with the reason
|
|
9489
|
+
rather than silently left out.
|
|
9490
|
+
|
|
9491
|
+
Examples:
|
|
9492
|
+
busabase-cli index . --repo busabase/skills -o templates.json
|
|
9493
|
+
busabase-cli index . --repo busabase/skills -o templates.json --check`).action(runLocalArgAction((dir, opts, config) => runIndex({
|
|
9494
|
+
dir,
|
|
9495
|
+
repo: opts.repo,
|
|
9496
|
+
ref: opts.ref || "main",
|
|
9497
|
+
out: opts.out,
|
|
9498
|
+
check: Boolean(opts.check),
|
|
9499
|
+
json: config.output === "json"
|
|
9500
|
+
})));
|
|
9501
|
+
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
9502
|
Output is deterministic: exporting twice from an unchanged space produces
|
|
8342
9503
|
byte-identical files, so a GitHub diff shows exactly what changed.
|
|
8343
9504
|
|
|
8344
9505
|
Examples:
|
|
8345
9506
|
busabase-cli export support-kb -o ./support-kb-template
|
|
8346
|
-
busabase-cli export support-kb -o ./support-kb-template --name "Support KB" --dry-run
|
|
9507
|
+
busabase-cli export support-kb -o ./support-kb-template --name "Support KB" --dry-run
|
|
9508
|
+
busabase-cli export kelly-email -o ./kelly-email --template
|
|
9509
|
+
|
|
9510
|
+
--template makes the result installable as an APP rather than as a pile of
|
|
9511
|
+
tables: the folder's Skill node is lifted to the package root as SKILL.md (or a
|
|
9512
|
+
draft is written from the folder's structure for you to fill in), and anything
|
|
9513
|
+
still missing is reported. The same directory then works both as an Agent Skill
|
|
9514
|
+
and as something \`busabase-cli install\` can install.`).action(runArgAction(state, (nodeSlugOrId, client, opts, config) => runExport(client, nodeSlugOrId, {
|
|
8347
9515
|
outDir: opts.outDir,
|
|
8348
9516
|
name: opts.name,
|
|
8349
9517
|
dryRun: Boolean(opts.dryRun),
|
|
9518
|
+
template: Boolean(opts.template),
|
|
8350
9519
|
json: config.output === "json",
|
|
8351
9520
|
baseUrl: config.baseUrl
|
|
8352
9521
|
})));
|