@capybara-hq/plugin-tools 0.1.0-rc.1 → 0.1.0-rc.10
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/README.md +20 -19
- package/bin/capybara-plugin.mjs +1 -1
- package/dist/check/applied-migrations.d.ts +23 -0
- package/dist/check/applied-migrations.js +86 -0
- package/dist/check/cli.d.ts +2 -1
- package/dist/check/cli.js +7 -3
- package/dist/check/config-forms.d.ts +13 -0
- package/dist/check/config-forms.js +71 -0
- package/dist/check/deps.d.ts +4 -2
- package/dist/check/deps.js +14 -2
- package/dist/check/index.d.ts +4 -1
- package/dist/check/index.js +4 -1
- package/dist/check/migrations.d.ts +1 -1
- package/dist/check/migrations.js +30 -1
- package/dist/check/org-override.d.ts +10 -0
- package/dist/check/org-override.js +62 -0
- package/dist/check/plugin-pack.js +2 -0
- package/dist/check/plugin-permissions.d.ts +34 -1
- package/dist/check/plugin-permissions.js +66 -6
- package/dist/check/plugin-tool-keys.d.ts +13 -0
- package/dist/check/plugin-tool-keys.js +38 -0
- package/dist/check/release-metadata.d.ts +5 -0
- package/dist/check/release-metadata.js +47 -0
- package/dist/check/report.d.ts +4 -3
- package/dist/check/report.js +13 -3
- package/dist/check/run.d.ts +3 -1
- package/dist/check/run.js +12 -1
- package/dist/check/source.d.ts +7 -0
- package/dist/check/source.js +2 -0
- package/dist/check/themes.d.ts +19 -0
- package/dist/check/themes.js +112 -0
- package/dist/check/tool-design.d.ts +1 -1
- package/dist/check/tool-design.js +3 -3
- package/dist/check/ui-slots.d.ts +7 -0
- package/dist/check/ui-slots.js +30 -0
- package/dist/cli.js +0 -2
- package/dist/marketplace/cli.d.ts +10 -7
- package/dist/marketplace/cli.js +20 -10
- package/dist/marketplace/marketplace.d.ts +46 -24
- package/dist/marketplace/marketplace.js +232 -60
- package/dist/pack/cli.d.ts +3 -10
- package/dist/pack/cli.js +3 -40
- package/dist/pack/config-schemas.d.ts +34 -0
- package/dist/pack/config-schemas.js +151 -0
- package/dist/pack/index.d.ts +2 -2
- package/dist/pack/index.js +2 -2
- package/dist/pack/pack.d.ts +8 -2
- package/dist/pack/pack.js +154 -38
- package/package.json +4 -4
- package/dist/pack/bootstrap.d.ts +0 -25
- package/dist/pack/bootstrap.js +0 -70
package/dist/pack/cli.js
CHANGED
|
@@ -1,27 +1,16 @@
|
|
|
1
1
|
import { relative, resolve } from "node:path";
|
|
2
2
|
import { parseArgs } from "node:util";
|
|
3
3
|
import { discoverPlugins } from "../discover.js";
|
|
4
|
-
import { packBootstrap } from "./bootstrap.js";
|
|
5
|
-
import { DEFAULT_NPM_REGISTRY } from "./npm.js";
|
|
6
4
|
import { packPlugin } from "./pack.js";
|
|
7
|
-
export const PACK_USAGE = "capybara-plugin pack [<dir>...] [--all <root>] [--out <dir>]
|
|
8
|
-
" capybara-plugin pack --bootstrap <manifest> [--root <dir>] [--out <dir>] [--registry <url>]\n" +
|
|
9
|
-
" [--npm-token <token>]";
|
|
5
|
+
export const PACK_USAGE = "capybara-plugin pack [<dir>...] [--all <root>] [--out <dir>]";
|
|
10
6
|
/**
|
|
11
7
|
* `capybara-plugin pack`: packs plugin directories into plugin tgz files.
|
|
12
8
|
*
|
|
13
9
|
* - `<dir>...`: the plugin directories to pack;
|
|
14
10
|
* - `--all <root>`: also every plugin of the repository at `<root>` (`<root>/plugins/*`, or `<root>`
|
|
15
11
|
* itself when it is a plugin);
|
|
16
|
-
* - `--
|
|
17
|
-
*
|
|
18
|
-
* relative to `--root`, default the current directory; npm entries are downloaded from
|
|
19
|
-
* `--registry`, default `CAPYBARA_PACKAGES__NPM_REGISTRY` (the kernel's `packages.npmRegistry`)
|
|
20
|
-
* or else the public npm registry, and must match their pinned sha512; `--npm-token`, default
|
|
21
|
-
* `CAPYBARA_PACKAGES__NPM_TOKEN` (the kernel's `packages.npmToken`), is sent as a Bearer token to
|
|
22
|
-
* the registry origin only);
|
|
23
|
-
* - `--out <dir>`: output directory, default `artifacts/plugins` (`artifacts/bootstrap` with
|
|
24
|
-
* `--bootstrap`) under the current directory. The last `--out` wins.
|
|
12
|
+
* - `--out <dir>`: output directory, default `artifacts/plugins` under the current directory. The
|
|
13
|
+
* last `--out` wins.
|
|
25
14
|
*
|
|
26
15
|
* Relative paths resolve against `cwd` (the current directory). Returns the exit code.
|
|
27
16
|
*/
|
|
@@ -33,10 +22,6 @@ export async function packCommand(args, cwd) {
|
|
|
33
22
|
help: { type: "boolean", short: "h", default: false },
|
|
34
23
|
all: { type: "string" },
|
|
35
24
|
out: { type: "string" },
|
|
36
|
-
bootstrap: { type: "string" },
|
|
37
|
-
root: { type: "string" },
|
|
38
|
-
registry: { type: "string" },
|
|
39
|
-
"npm-token": { type: "string" },
|
|
40
25
|
},
|
|
41
26
|
});
|
|
42
27
|
if (values.help) {
|
|
@@ -48,28 +33,6 @@ export async function packCommand(args, cwd) {
|
|
|
48
33
|
const rel = relative(cwd, p);
|
|
49
34
|
return rel === "" ? "." : rel.startsWith("..") ? p : rel;
|
|
50
35
|
};
|
|
51
|
-
if (values.bootstrap !== undefined) {
|
|
52
|
-
if (values.all !== undefined || positionals.length > 0) {
|
|
53
|
-
return usage("--bootstrap takes no plugin directory");
|
|
54
|
-
}
|
|
55
|
-
try {
|
|
56
|
-
const root = values.root === undefined ? cwd : resolve(cwd, values.root);
|
|
57
|
-
const manifest = resolve(cwd, values.bootstrap);
|
|
58
|
-
const registry = values.registry ?? (process.env.CAPYBARA_PACKAGES__NPM_REGISTRY || DEFAULT_NPM_REGISTRY);
|
|
59
|
-
const token = values["npm-token"] ?? process.env.CAPYBARA_PACKAGES__NPM_TOKEN;
|
|
60
|
-
for (const p of await packBootstrap(manifest, root, outOf("artifacts/bootstrap"), {
|
|
61
|
-
registry,
|
|
62
|
-
...(token !== undefined && token !== "" && { token }),
|
|
63
|
-
})) {
|
|
64
|
-
console.log(`${shown(p.file)} ${p.integrity}`);
|
|
65
|
-
}
|
|
66
|
-
return 0;
|
|
67
|
-
}
|
|
68
|
-
catch (e) {
|
|
69
|
-
console.error(e instanceof Error ? e.message : String(e));
|
|
70
|
-
return 1;
|
|
71
|
-
}
|
|
72
|
-
}
|
|
73
36
|
const plugins = positionals.map((d) => resolve(cwd, d));
|
|
74
37
|
if (values.all !== undefined) {
|
|
75
38
|
const root = resolve(cwd, values.all);
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { type ConfigUiProblem, type ExtensionManifest, type JsonSchema } from "@capybara-hq/plugin-sdk";
|
|
2
|
+
/**
|
|
3
|
+
* The config JSON Schemas an extension's package carries (extension config forms spec §4.5) and
|
|
4
|
+
* the checks the packer and `capybara-plugin check` ("Config forms") run on them.
|
|
5
|
+
*/
|
|
6
|
+
/** The config levels an extension declares a schema for. */
|
|
7
|
+
export type ConfigSchemaLevel = "deployment" | "org";
|
|
8
|
+
/** A problem of one level's schema (`path`: dot path of the node, `""` the root). */
|
|
9
|
+
export interface ConfigSchemaProblem extends ConfigUiProblem {
|
|
10
|
+
level: ConfigSchemaLevel;
|
|
11
|
+
}
|
|
12
|
+
/** The JSON Schemas of an extension's config levels and the problems of their hints. */
|
|
13
|
+
export interface ExtensionConfigSchemas {
|
|
14
|
+
schemas: {
|
|
15
|
+
deployment?: JsonSchema;
|
|
16
|
+
org?: JsonSchema;
|
|
17
|
+
};
|
|
18
|
+
problems: ConfigSchemaProblem[];
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* The JSON Schemas (`configJsonSchema`) of an extension's deployment and org config, with every
|
|
22
|
+
* problem of their hints: `validateConfigUi` at the level, plus value hints on secrets
|
|
23
|
+
* ({@link secretValueHints}). The packer refuses a plugin with problems.
|
|
24
|
+
*/
|
|
25
|
+
export declare function extensionConfigSchemas(config: ExtensionManifest["config"] | undefined): ExtensionConfigSchemas;
|
|
26
|
+
/** `<level> config: <path>: <message>` (the root as `(root)`). */
|
|
27
|
+
export declare const formatConfigSchemaProblem: (p: ConfigSchemaProblem) => string;
|
|
28
|
+
/**
|
|
29
|
+
* The rendered fields of a form schema without a label in both languages (`plugin-config-labels`):
|
|
30
|
+
* every property node the form draws, in nested objects, array items and record values. A secret
|
|
31
|
+
* record named by some `x-ui.secrets[].path` is skipped (it is drawn inside each item with that
|
|
32
|
+
* hint's own label); a secret node is drawn as one input, so its own properties are not visited.
|
|
33
|
+
*/
|
|
34
|
+
export declare function missingConfigLabels(schema: JsonSchema): ConfigUiProblem[];
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { configJsonSchema, validateConfigUi, } from "@capybara-hq/plugin-sdk";
|
|
2
|
+
const LEVELS = ["deployment", "org"];
|
|
3
|
+
const isObj = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
4
|
+
/** Keywords whose value is data, not a schema: never recursed into. */
|
|
5
|
+
const VALUE_KEYWORDS = new Set(["default", "const", "enum", "examples"]);
|
|
6
|
+
/** Hints that would reveal values; `configJsonSchema` drops them under a secret. */
|
|
7
|
+
const VALUE_HINTS = ["options", "presets"];
|
|
8
|
+
const join = (path, seg) => (path === "" ? seg : `${path}.${seg}`);
|
|
9
|
+
/**
|
|
10
|
+
* Value hints (`options`, `presets`) on a secret node or under one. `configJsonSchema` removes
|
|
11
|
+
* them before `validateConfigUi` could see them, so a plugin would ship a hint that silently does
|
|
12
|
+
* nothing; this reads the zod schema's own JSON Schema (meta `secret` / `ui` kept as written) with
|
|
13
|
+
* the schema's own zod, so plugin-tools needs no zod at run time.
|
|
14
|
+
*/
|
|
15
|
+
function secretValueHints(schema) {
|
|
16
|
+
const toJson = schema?.toJSONSchema;
|
|
17
|
+
if (typeof toJson !== "function")
|
|
18
|
+
return [];
|
|
19
|
+
const raw = toJson.call(schema, { io: "input", unrepresentable: "any" });
|
|
20
|
+
const out = new Map();
|
|
21
|
+
const walk = (node, path, inSecret) => {
|
|
22
|
+
if (Array.isArray(node)) {
|
|
23
|
+
for (const n of node)
|
|
24
|
+
walk(n, path, inSecret);
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
if (!isObj(node))
|
|
28
|
+
return;
|
|
29
|
+
const secret = inSecret || node.secret === true;
|
|
30
|
+
if (secret && isObj(node.ui)) {
|
|
31
|
+
for (const k of VALUE_HINTS) {
|
|
32
|
+
if (node.ui[k] === undefined)
|
|
33
|
+
continue;
|
|
34
|
+
const message = `x-ui.${k} on a secret field: secrets publish no values, so the form never shows them (remove ${k})`;
|
|
35
|
+
out.set(`${path}\n${message}`, { path, message });
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
for (const [k, v] of Object.entries(node)) {
|
|
39
|
+
if (k === "ui" || VALUE_KEYWORDS.has(k))
|
|
40
|
+
continue;
|
|
41
|
+
if (k === "properties" && isObj(v)) {
|
|
42
|
+
for (const [name, child] of Object.entries(v))
|
|
43
|
+
walk(child, join(path, name), secret);
|
|
44
|
+
}
|
|
45
|
+
else if (k === "items" || k === "additionalProperties") {
|
|
46
|
+
walk(v, join(path, k), secret);
|
|
47
|
+
}
|
|
48
|
+
else {
|
|
49
|
+
walk(v, path, secret);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
walk(raw, "", false);
|
|
54
|
+
return [...out.values()];
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* The JSON Schemas (`configJsonSchema`) of an extension's deployment and org config, with every
|
|
58
|
+
* problem of their hints: `validateConfigUi` at the level, plus value hints on secrets
|
|
59
|
+
* ({@link secretValueHints}). The packer refuses a plugin with problems.
|
|
60
|
+
*/
|
|
61
|
+
export function extensionConfigSchemas(config) {
|
|
62
|
+
const result = { schemas: {}, problems: [] };
|
|
63
|
+
for (const level of LEVELS) {
|
|
64
|
+
const zod = config?.[level];
|
|
65
|
+
if (zod === undefined)
|
|
66
|
+
continue;
|
|
67
|
+
const schema = configJsonSchema(zod);
|
|
68
|
+
result.schemas[level] = schema;
|
|
69
|
+
for (const p of [...validateConfigUi(schema, level), ...secretValueHints(zod)]) {
|
|
70
|
+
result.problems.push({ level, ...p });
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return result;
|
|
74
|
+
}
|
|
75
|
+
/** `<level> config: <path>: <message>` (the root as `(root)`). */
|
|
76
|
+
export const formatConfigSchemaProblem = (p) => `${p.level} config: ${p.path === "" ? "(root)" : p.path}: ${p.message}`;
|
|
77
|
+
/** The non-null shape of a node: itself, or the one non-null branch of a nullable `anyOf`. */
|
|
78
|
+
function shapeOf(node) {
|
|
79
|
+
const branches = (node.anyOf ?? node.oneOf);
|
|
80
|
+
if (node.type === undefined && Array.isArray(branches)) {
|
|
81
|
+
const rest = branches.filter((b) => isObj(b) && b.type !== "null");
|
|
82
|
+
if (rest.length === 1 && rest[0] !== undefined)
|
|
83
|
+
return rest[0];
|
|
84
|
+
}
|
|
85
|
+
return node;
|
|
86
|
+
}
|
|
87
|
+
const hasText = (v) => typeof v === "string" && v.trim() !== "";
|
|
88
|
+
/** Does the node (or its non-null shape) carry `x-ui.label` with both zh and en? */
|
|
89
|
+
function hasLabel(node) {
|
|
90
|
+
for (const n of [node, shapeOf(node)]) {
|
|
91
|
+
const label = isObj(n["x-ui"]) ? n["x-ui"].label : undefined;
|
|
92
|
+
if (isObj(label) && hasText(label.zh) && hasText(label.en))
|
|
93
|
+
return true;
|
|
94
|
+
}
|
|
95
|
+
return false;
|
|
96
|
+
}
|
|
97
|
+
/** Every `x-ui.secrets[].path` of a form schema (dot paths from the root). */
|
|
98
|
+
function itemSecretPaths(node, out = new Set()) {
|
|
99
|
+
if (Array.isArray(node)) {
|
|
100
|
+
for (const n of node)
|
|
101
|
+
itemSecretPaths(n, out);
|
|
102
|
+
return out;
|
|
103
|
+
}
|
|
104
|
+
if (!isObj(node))
|
|
105
|
+
return out;
|
|
106
|
+
const hints = node["x-ui"];
|
|
107
|
+
if (isObj(hints) && Array.isArray(hints.secrets)) {
|
|
108
|
+
for (const s of hints.secrets)
|
|
109
|
+
if (isObj(s) && typeof s.path === "string")
|
|
110
|
+
out.add(s.path);
|
|
111
|
+
}
|
|
112
|
+
for (const [k, v] of Object.entries(node)) {
|
|
113
|
+
if (k !== "x-ui" && !VALUE_KEYWORDS.has(k))
|
|
114
|
+
itemSecretPaths(v, out);
|
|
115
|
+
}
|
|
116
|
+
return out;
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* The rendered fields of a form schema without a label in both languages (`plugin-config-labels`):
|
|
120
|
+
* every property node the form draws, in nested objects, array items and record values. A secret
|
|
121
|
+
* record named by some `x-ui.secrets[].path` is skipped (it is drawn inside each item with that
|
|
122
|
+
* hint's own label); a secret node is drawn as one input, so its own properties are not visited.
|
|
123
|
+
*/
|
|
124
|
+
export function missingConfigLabels(schema) {
|
|
125
|
+
const skip = itemSecretPaths(schema);
|
|
126
|
+
const out = [];
|
|
127
|
+
const visit = (node, path) => {
|
|
128
|
+
const shape = shapeOf(node);
|
|
129
|
+
if (isObj(shape.properties)) {
|
|
130
|
+
for (const [k, child] of Object.entries(shape.properties)) {
|
|
131
|
+
if (!isObj(child))
|
|
132
|
+
continue;
|
|
133
|
+
const p = join(path, k);
|
|
134
|
+
if (skip.has(p))
|
|
135
|
+
continue;
|
|
136
|
+
if (!hasLabel(child)) {
|
|
137
|
+
out.push({ path: p, message: "has no label (x-ui.label with zh and en)" });
|
|
138
|
+
}
|
|
139
|
+
if (child["x-secret"] !== true)
|
|
140
|
+
visit(child, p);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
if (isObj(shape.items))
|
|
144
|
+
visit(shape.items, join(path, "items"));
|
|
145
|
+
if (isObj(shape.additionalProperties)) {
|
|
146
|
+
visit(shape.additionalProperties, join(path, "additionalProperties"));
|
|
147
|
+
}
|
|
148
|
+
};
|
|
149
|
+
visit(schema, "");
|
|
150
|
+
return out;
|
|
151
|
+
}
|
package/dist/pack/index.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export { type BootstrapPack, packBootstrap } from "./bootstrap.js";
|
|
2
1
|
export { PACK_USAGE, packCommand } from "./cli.js";
|
|
2
|
+
export { type ConfigSchemaLevel, type ConfigSchemaProblem, type ExtensionConfigSchemas, extensionConfigSchemas, formatConfigSchemaProblem, missingConfigLabels, } from "./config-schemas.js";
|
|
3
3
|
export { assertNpmTarballLabel, DEFAULT_NPM_REGISTRY, downloadPinnedNpmPackage, fetchNpmTarball, NPM_TARBALL_MAX_BYTES, type NpmFetchOptions, NpmPackageError, type NpmPackageErrorKind, } from "./npm.js";
|
|
4
|
-
export { METADATA_FIELDS, NOTICES_FILE, type PackResult, packPlugin, STAGING_PREFIX, } from "./pack.js";
|
|
4
|
+
export { importPluginExtension, METADATA_FIELDS, NOTICES_FILE, type PackResult, packPlugin, STAGING_PREFIX, } from "./pack.js";
|
package/dist/pack/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export { packBootstrap } from "./bootstrap.js";
|
|
2
1
|
export { PACK_USAGE, packCommand } from "./cli.js";
|
|
2
|
+
export { extensionConfigSchemas, formatConfigSchemaProblem, missingConfigLabels, } from "./config-schemas.js";
|
|
3
3
|
export { assertNpmTarballLabel, DEFAULT_NPM_REGISTRY, downloadPinnedNpmPackage, fetchNpmTarball, NPM_TARBALL_MAX_BYTES, NpmPackageError, } from "./npm.js";
|
|
4
|
-
export { METADATA_FIELDS, NOTICES_FILE, packPlugin, STAGING_PREFIX, } from "./pack.js";
|
|
4
|
+
export { importPluginExtension, METADATA_FIELDS, NOTICES_FILE, packPlugin, STAGING_PREFIX, } from "./pack.js";
|
package/dist/pack/pack.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type PluginManifest } from "@capybara-hq/plugin-sdk";
|
|
1
|
+
import { type ExtensionManifest, type PluginManifest } from "@capybara-hq/plugin-sdk";
|
|
2
2
|
/**
|
|
3
3
|
* The result of packing: the tgz path, its hex sha256 and the `.capybara-plugin/plugin.json`
|
|
4
4
|
* written into it (the extension's static manifest is `manifest.extension?.manifest`).
|
|
@@ -25,6 +25,12 @@ export declare const STAGING_PREFIX = ".capybara-pack-";
|
|
|
25
25
|
export declare const METADATA_FIELDS: readonly ["description", "repository", "homepage", "keywords"];
|
|
26
26
|
/** Third-party license notices of the bundled dependencies (written only when there are any). */
|
|
27
27
|
export declare const NOTICES_FILE = "THIRD-PARTY-NOTICES.txt";
|
|
28
|
+
/**
|
|
29
|
+
* The extension a plugin directory's `src/index.ts` defines, bundled and imported the way the
|
|
30
|
+
* packer does (in a `.capybara-pack-*` staging dir under the system temp dir, removed afterwards).
|
|
31
|
+
* For checks that need the module (config forms); throws what packing would throw.
|
|
32
|
+
*/
|
|
33
|
+
export declare function importPluginExtension(pluginDir: string): Promise<ExtensionManifest>;
|
|
28
34
|
/**
|
|
29
35
|
* Packs a plugin directory into a plugin package (spec §4.1):
|
|
30
36
|
* 1. reads and validates the source `.capybara-plugin/plugin.json` (it must not carry
|
|
@@ -32,7 +38,7 @@ export declare const NOTICES_FILE = "THIRD-PARTY-NOTICES.txt";
|
|
|
32
38
|
* 2. when `src/index.ts` exists, bundles the extension ({@link bundleExtension}) and sets
|
|
33
39
|
* `extension = { entry: "./dist/index.mjs", manifest, migrations?: "./migrations", ui?: "./ui" }`;
|
|
34
40
|
* a declarative plugin (no `src/index.ts`) runs no esbuild and creates no staging dir;
|
|
35
|
-
* 3. ships `skills/**`, `connectors/*.json`, `agents/*.md` and the listed paths ({@link contentFiles}), README,
|
|
41
|
+
* 3. ships `skills/**`, `connectors/*.json`, `agents/*.md`, `themes/*.json` with their assets and the listed paths ({@link contentFiles}), README,
|
|
36
42
|
* LICENSE, NOTICE and the plain files of package.json `files` ({@link extraFiles});
|
|
37
43
|
* 4. writes a package.json with only name / version / type / license / {@link METADATA_FIELDS}
|
|
38
44
|
* and, with an extension, `main` / `exports` (the entry) and peerDependencies (no `capybara`
|
package/dist/pack/pack.js
CHANGED
|
@@ -7,10 +7,12 @@ import { dirname, join, posix, relative, resolve } from "node:path";
|
|
|
7
7
|
import { pathToFileURL } from "node:url";
|
|
8
8
|
import { promisify } from "node:util";
|
|
9
9
|
import { gzip } from "node:zlib";
|
|
10
|
-
import { AGENT_TEMPLATE_LIMITS, checkEntryImports, defineExtension, HOST_PROVIDED_PACKAGES, PLUGIN_MANIFEST_FILE, parseAgentTemplate, parseConnectorTemplateFile, parsePluginManifest, SDK_VERSION, staticManifestOf, validatePluginPackageJsonFor, } from "@capybara-hq/plugin-sdk";
|
|
10
|
+
import { AGENT_TEMPLATE_LIMITS, checkEntryImports, defineExtension, HOST_PROVIDED_PACKAGES, PLUGIN_MANIFEST_FILE, PLUGIN_MANIFEST_LIMITS, parseAgentTemplate, parseConnectorTemplateFile, parsePluginManifest, parseThemeFile, SDK_VERSION, setupGuideFileProblem, staticManifestOf, THEME_LIMITS, themeIdOf, unknownSetupGuidePlaceholders, validatePluginPackageJsonFor, validateThemeAssets, } from "@capybara-hq/plugin-sdk";
|
|
11
11
|
import { init as lexerInit, parse as lexerParse } from "es-module-lexer";
|
|
12
12
|
import { build } from "esbuild";
|
|
13
13
|
import { pack as tarPack } from "tar-stream";
|
|
14
|
+
import { assetRefs, themePathsOf } from "../check/themes.js";
|
|
15
|
+
import { extensionConfigSchemas, formatConfigSchemaProblem } from "./config-schemas.js";
|
|
14
16
|
/** The extension entry the packer writes (relative to the package root). */
|
|
15
17
|
const EXTENSION_ENTRY = "dist/index.mjs";
|
|
16
18
|
/**
|
|
@@ -166,6 +168,8 @@ const SHIPPED_ELSEWHERE = [
|
|
|
166
168
|
"skills",
|
|
167
169
|
"connectors",
|
|
168
170
|
"agents",
|
|
171
|
+
// setup guide templates: the listed files are shipped by contentFiles
|
|
172
|
+
"guides",
|
|
169
173
|
".capybara-plugin",
|
|
170
174
|
"README.md",
|
|
171
175
|
"LICENSE",
|
|
@@ -356,13 +360,132 @@ async function contentFiles(dir, label, m) {
|
|
|
356
360
|
}
|
|
357
361
|
agentNames.set(t.template.name, rel);
|
|
358
362
|
}
|
|
363
|
+
// Themes: listed, else scanned from themes/*.json; each is parsed, its assets are checked and
|
|
364
|
+
// shipped (spec §4-§5), so a bad theme fails the pack before import
|
|
365
|
+
const { paths: themePaths } = themePathsOf(dir);
|
|
366
|
+
if (themePaths.length > THEME_LIMITS.perPlugin) {
|
|
367
|
+
throw new Error(`${label}: ${themePaths.length} themes; a plugin may have at most ${THEME_LIMITS.perPlugin}`);
|
|
368
|
+
}
|
|
369
|
+
const themeFiles = new Map();
|
|
370
|
+
const assetSizes = new Map();
|
|
371
|
+
for (const rel of themePaths) {
|
|
372
|
+
await addFile(rel, "theme");
|
|
373
|
+
const t = parseThemeFile(themeIdOf(rel), files.get(rel).toString("utf8"));
|
|
374
|
+
if ("errors" in t)
|
|
375
|
+
throw new Error(`${label}: ${rel}: ${t.errors.join("; ")}`);
|
|
376
|
+
for (const ref of assetRefs(t)) {
|
|
377
|
+
const key = stripDot(ref);
|
|
378
|
+
if (themeFiles.has(key))
|
|
379
|
+
continue;
|
|
380
|
+
const abs = join(dir, key);
|
|
381
|
+
if (!existsSync(abs) || !(await stat(abs)).isFile())
|
|
382
|
+
continue;
|
|
383
|
+
themeFiles.set(key, await readFile(abs));
|
|
384
|
+
}
|
|
385
|
+
const checked = validateThemeAssets(t, themeFiles);
|
|
386
|
+
if (checked.errors.length > 0) {
|
|
387
|
+
throw new Error(`${label}: ${rel}: ${checked.errors.join("; ")}`);
|
|
388
|
+
}
|
|
389
|
+
for (const key of checked.assets.keys()) {
|
|
390
|
+
files.set(key, Buffer.from(themeFiles.get(key)));
|
|
391
|
+
assetSizes.set(key, themeFiles.get(key).length);
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
let assetTotal = 0;
|
|
395
|
+
for (const n of assetSizes.values())
|
|
396
|
+
assetTotal += n;
|
|
397
|
+
if (assetTotal > THEME_LIMITS.assetsBytes) {
|
|
398
|
+
throw new Error(`${label}: theme assets are larger than ${THEME_LIMITS.assetsBytes} bytes in total (${assetTotal})`);
|
|
399
|
+
}
|
|
400
|
+
// Setup guides (spec 2026-11-08-plugin-setup-guides-design.md §2): the listed template files,
|
|
401
|
+
// text within the size limit, and only placeholders the place defines (the kernel leaves an
|
|
402
|
+
// unknown one visible; the toolchain refuses it so a typo never ships)
|
|
403
|
+
for (const g of m.setupGuides ?? []) {
|
|
404
|
+
const at = `${label}: setupGuides ${g.id}`;
|
|
405
|
+
const unknown = (text, where) => {
|
|
406
|
+
const names = unknownSetupGuidePlaceholders(text, g.place);
|
|
407
|
+
if (names.length > 0) {
|
|
408
|
+
throw new Error(`${at}: ${where} uses ${names.map((n) => `{{${n}}}`).join(", ")}, which place ${g.place} does not define`);
|
|
409
|
+
}
|
|
410
|
+
};
|
|
411
|
+
g.steps?.forEach((step, i) => {
|
|
412
|
+
unknown(step.en, `step ${i + 1}`);
|
|
413
|
+
if (step.zh !== undefined)
|
|
414
|
+
unknown(step.zh, `step ${i + 1} (zh)`);
|
|
415
|
+
});
|
|
416
|
+
for (const f of g.files ?? []) {
|
|
417
|
+
const rel = stripDot(f.path);
|
|
418
|
+
await addFile(rel, `setup guide ${g.id} file`);
|
|
419
|
+
const data = files.get(rel);
|
|
420
|
+
const problem = setupGuideFileProblem(f.path, new Uint8Array(data));
|
|
421
|
+
if (problem !== undefined)
|
|
422
|
+
throw new Error(`${at}: ${problem}`);
|
|
423
|
+
unknown(data.toString("utf8"), f.path);
|
|
424
|
+
}
|
|
425
|
+
}
|
|
359
426
|
return {
|
|
360
427
|
files,
|
|
361
428
|
skills: m.skills?.length ?? scannedSkills,
|
|
362
429
|
templates: m.connectorTemplates?.length ?? templates.length,
|
|
363
430
|
agents: agents.length,
|
|
431
|
+
themes: themePaths.length,
|
|
364
432
|
};
|
|
365
433
|
}
|
|
434
|
+
/**
|
|
435
|
+
* Bundles `src/index.ts` into `<stage>/package/dist/index.mjs` (esbuild, single-file ESM), checks
|
|
436
|
+
* the bundle's imports, imports it and runs its default export through defineExtension. Returns the
|
|
437
|
+
* bundle, the defined manifest and the bundled inputs (for the notices).
|
|
438
|
+
*/
|
|
439
|
+
async function buildAndImport(dir, label, stage) {
|
|
440
|
+
await linkHostPackages(dir, stage);
|
|
441
|
+
const entryOut = join(stage, "package", EXTENSION_ENTRY);
|
|
442
|
+
const result = await build({
|
|
443
|
+
absWorkingDir: dir,
|
|
444
|
+
entryPoints: [join(dir, "src/index.ts")],
|
|
445
|
+
outfile: entryOut,
|
|
446
|
+
bundle: true,
|
|
447
|
+
platform: "node",
|
|
448
|
+
format: "esm",
|
|
449
|
+
target: "node24",
|
|
450
|
+
external: [...HOST_PROVIDED_PACKAGES],
|
|
451
|
+
plugins: [nodeBuiltins],
|
|
452
|
+
banner: { js: BANNER },
|
|
453
|
+
define: DEFINE,
|
|
454
|
+
charset: "utf8",
|
|
455
|
+
logLevel: "silent",
|
|
456
|
+
metafile: true,
|
|
457
|
+
// Notices of bundled dependencies go to THIRD-PARTY-NOTICES.txt (built from the metafile).
|
|
458
|
+
legalComments: "none",
|
|
459
|
+
});
|
|
460
|
+
if (result.errors.length > 0) {
|
|
461
|
+
throw new Error(`${label}: esbuild failed: ${result.errors.map((e) => e.text).join("; ")}`);
|
|
462
|
+
}
|
|
463
|
+
const code = await readFile(entryOut);
|
|
464
|
+
const issues = await importIssues(code.toString("utf8"));
|
|
465
|
+
if (issues.length > 0)
|
|
466
|
+
throw new Error(`${label}: ${issues.join("; ")}`);
|
|
467
|
+
const mod = await importBundle(entryOut);
|
|
468
|
+
if (typeof mod.default !== "object" || mod.default === null) {
|
|
469
|
+
throw new Error(`${label}: the bundle has no default export object`);
|
|
470
|
+
}
|
|
471
|
+
const m = defineExtension(mod.default);
|
|
472
|
+
return { code, m, inputs: Object.keys(result.metafile?.inputs ?? {}) };
|
|
473
|
+
}
|
|
474
|
+
/**
|
|
475
|
+
* The extension a plugin directory's `src/index.ts` defines, bundled and imported the way the
|
|
476
|
+
* packer does (in a `.capybara-pack-*` staging dir under the system temp dir, removed afterwards).
|
|
477
|
+
* For checks that need the module (config forms); throws what packing would throw.
|
|
478
|
+
*/
|
|
479
|
+
export async function importPluginExtension(pluginDir) {
|
|
480
|
+
const dir = resolve(pluginDir);
|
|
481
|
+
const stage = await realpath(await mkdtemp(join(tmpdir(), STAGING_PREFIX)));
|
|
482
|
+
try {
|
|
483
|
+
return (await buildAndImport(dir, dir, stage)).m;
|
|
484
|
+
}
|
|
485
|
+
finally {
|
|
486
|
+
await rm(stage, { recursive: true, force: true });
|
|
487
|
+
}
|
|
488
|
+
}
|
|
366
489
|
/**
|
|
367
490
|
* Bundles the extension `src/index.ts`:
|
|
368
491
|
* 1. esbuild bundles it into the single-file ESM `dist/index.mjs` (platform node, target node24),
|
|
@@ -371,6 +494,8 @@ async function contentFiles(dir, label, m) {
|
|
|
371
494
|
* 2. the bundle's imports are checked (allowlist, no non-literal dynamic import), the bundle is
|
|
372
495
|
* imported and its default export goes through defineExtension; `staticManifestOf` gives the
|
|
373
496
|
* static manifest, whose version must equal the package.json version;
|
|
497
|
+
* the config schemas become `extension.configSchemas` (`configJsonSchema`; refused when their
|
|
498
|
+
* hints have problems, {@link extensionConfigSchemas});
|
|
374
499
|
* 3. `migrations/` (when the manifest declares migrations, which must resolve to the plugin's
|
|
375
500
|
* `migrations/`), `ui/` (when present) and THIRD-PARTY-NOTICES.txt join the files.
|
|
376
501
|
*
|
|
@@ -379,41 +504,14 @@ async function contentFiles(dir, label, m) {
|
|
|
379
504
|
async function bundleExtension(dir, label, pkgVersion) {
|
|
380
505
|
const stage = await realpath(await mkdtemp(join(tmpdir(), STAGING_PREFIX)));
|
|
381
506
|
try {
|
|
382
|
-
await
|
|
383
|
-
const entryOut = join(stage, "package", EXTENSION_ENTRY);
|
|
384
|
-
const result = await build({
|
|
385
|
-
absWorkingDir: dir,
|
|
386
|
-
entryPoints: [join(dir, "src/index.ts")],
|
|
387
|
-
outfile: entryOut,
|
|
388
|
-
bundle: true,
|
|
389
|
-
platform: "node",
|
|
390
|
-
format: "esm",
|
|
391
|
-
target: "node24",
|
|
392
|
-
external: [...HOST_PROVIDED_PACKAGES],
|
|
393
|
-
plugins: [nodeBuiltins],
|
|
394
|
-
banner: { js: BANNER },
|
|
395
|
-
define: DEFINE,
|
|
396
|
-
charset: "utf8",
|
|
397
|
-
logLevel: "silent",
|
|
398
|
-
metafile: true,
|
|
399
|
-
// Notices of bundled dependencies go to THIRD-PARTY-NOTICES.txt (built from the metafile).
|
|
400
|
-
legalComments: "none",
|
|
401
|
-
});
|
|
402
|
-
if (result.errors.length > 0) {
|
|
403
|
-
throw new Error(`${label}: esbuild failed: ${result.errors.map((e) => e.text).join("; ")}`);
|
|
404
|
-
}
|
|
405
|
-
const code = await readFile(entryOut);
|
|
406
|
-
const issues = await importIssues(code.toString("utf8"));
|
|
407
|
-
if (issues.length > 0)
|
|
408
|
-
throw new Error(`${label}: ${issues.join("; ")}`);
|
|
409
|
-
const mod = await importBundle(entryOut);
|
|
410
|
-
if (typeof mod.default !== "object" || mod.default === null) {
|
|
411
|
-
throw new Error(`${label}: the bundle has no default export object`);
|
|
412
|
-
}
|
|
413
|
-
const m = defineExtension(mod.default);
|
|
507
|
+
const { code, m, inputs } = await buildAndImport(dir, label, stage);
|
|
414
508
|
if (m.version !== pkgVersion) {
|
|
415
509
|
throw new Error(`${label}: manifest version ${m.version} does not match package.json version ${String(pkgVersion)}`);
|
|
416
510
|
}
|
|
511
|
+
const config = extensionConfigSchemas(m.config);
|
|
512
|
+
if (config.problems.length > 0) {
|
|
513
|
+
throw new Error(`${label}: invalid config UI hints: ${config.problems.map(formatConfigSchemaProblem).join("; ")}`);
|
|
514
|
+
}
|
|
417
515
|
const files = new Map([[EXTENSION_ENTRY, code]]);
|
|
418
516
|
const extension = {
|
|
419
517
|
entry: `./${EXTENSION_ENTRY}`,
|
|
@@ -435,7 +533,10 @@ async function bundleExtension(dir, label, pkgVersion) {
|
|
|
435
533
|
}
|
|
436
534
|
extension.ui = "./ui";
|
|
437
535
|
}
|
|
438
|
-
|
|
536
|
+
if (config.schemas.deployment !== undefined || config.schemas.org !== undefined) {
|
|
537
|
+
extension.configSchemas = config.schemas;
|
|
538
|
+
}
|
|
539
|
+
const notices = await thirdPartyNotices(dir, inputs);
|
|
439
540
|
if (notices !== undefined)
|
|
440
541
|
files.set(NOTICES_FILE, Buffer.from(notices));
|
|
441
542
|
return { files, extension };
|
|
@@ -451,7 +552,7 @@ async function bundleExtension(dir, label, pkgVersion) {
|
|
|
451
552
|
* 2. when `src/index.ts` exists, bundles the extension ({@link bundleExtension}) and sets
|
|
452
553
|
* `extension = { entry: "./dist/index.mjs", manifest, migrations?: "./migrations", ui?: "./ui" }`;
|
|
453
554
|
* a declarative plugin (no `src/index.ts`) runs no esbuild and creates no staging dir;
|
|
454
|
-
* 3. ships `skills/**`, `connectors/*.json`, `agents/*.md` and the listed paths ({@link contentFiles}), README,
|
|
555
|
+
* 3. ships `skills/**`, `connectors/*.json`, `agents/*.md`, `themes/*.json` with their assets and the listed paths ({@link contentFiles}), README,
|
|
455
556
|
* LICENSE, NOTICE and the plain files of package.json `files` ({@link extraFiles});
|
|
456
557
|
* 4. writes a package.json with only name / version / type / license / {@link METADATA_FIELDS}
|
|
457
558
|
* and, with an extension, `main` / `exports` (the entry) and peerDependencies (no `capybara`
|
|
@@ -467,10 +568,17 @@ export async function packPlugin(pluginDir, outDir) {
|
|
|
467
568
|
const source = await readSourceManifest(dir, label);
|
|
468
569
|
const content = await contentFiles(dir, label, source);
|
|
469
570
|
const hasExtension = existsSync(join(dir, "src/index.ts"));
|
|
470
|
-
if (!hasExtension &&
|
|
471
|
-
|
|
571
|
+
if (!hasExtension &&
|
|
572
|
+
content.skills === 0 &&
|
|
573
|
+
content.templates === 0 &&
|
|
574
|
+
content.agents === 0 &&
|
|
575
|
+
content.themes === 0) {
|
|
576
|
+
throw new Error(`${label}: a plugin must contain an extension, a skill, a connector template, an agent template or a theme (src/index.ts, skills/<name>/SKILL.md, connectors/<id>.json, agents/<name>.md or themes/<id>.json)`);
|
|
472
577
|
}
|
|
473
578
|
const ext = hasExtension ? await bundleExtension(dir, label, srcPkg.version) : undefined;
|
|
579
|
+
if (ext === undefined && source.setupGuides?.some((g) => g.place === "channel-connection")) {
|
|
580
|
+
throw new Error(`${label}: a channel-connection setup guide needs the plugin's extension to provide channel@1 (src/index.ts)`);
|
|
581
|
+
}
|
|
474
582
|
const files = new Map([...(ext?.files ?? []), ...content.files]);
|
|
475
583
|
const extra = await extraFiles(dir, srcPkg.files);
|
|
476
584
|
for (const [path, data] of extra.files)
|
|
@@ -504,7 +612,15 @@ export async function packPlugin(pluginDir, outDir) {
|
|
|
504
612
|
if (pkgIssues.length > 0)
|
|
505
613
|
throw new Error(`${label}: ${pkgIssues.join("; ")}`);
|
|
506
614
|
files.set("package.json", Buffer.from(`${JSON.stringify(pkg, null, 2)}\n`));
|
|
507
|
-
|
|
615
|
+
// The packed text is pretty-printed while the config schema caps measure compact JSON: the text
|
|
616
|
+
// itself must stay under the reader's limit, or the kernel would refuse the package unread.
|
|
617
|
+
const manifestText = `${JSON.stringify(manifest, null, 2)}\n`;
|
|
618
|
+
if (manifestText.length > PLUGIN_MANIFEST_LIMITS.manifestChars) {
|
|
619
|
+
throw new Error(`${label}: packed ${PLUGIN_MANIFEST_FILE} is ${manifestText.length} characters, over the ` +
|
|
620
|
+
`${PLUGIN_MANIFEST_LIMITS.manifestChars} limit (PLUGIN_MANIFEST_LIMITS.manifestChars); ` +
|
|
621
|
+
"make the config schemas smaller");
|
|
622
|
+
}
|
|
623
|
+
files.set(PLUGIN_MANIFEST_FILE, Buffer.from(manifestText));
|
|
508
624
|
const tgz = await makeTgz(files);
|
|
509
625
|
await mkdir(outDir, { recursive: true });
|
|
510
626
|
const file = resolve(outDir, tgzName(String(srcPkg.name), String(srcPkg.version)));
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@capybara-hq/plugin-tools",
|
|
3
|
-
"version": "0.1.0-rc.
|
|
3
|
+
"version": "0.1.0-rc.10",
|
|
4
4
|
"description": "Capybara plugin toolchain: the capybara-plugin CLI to pack, check and release Capybara plugins",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"repository": {
|
|
@@ -51,12 +51,12 @@
|
|
|
51
51
|
"esbuild": "0.28.2",
|
|
52
52
|
"oxc-parser": "^0.154.0",
|
|
53
53
|
"tar-stream": "3.2.2",
|
|
54
|
-
"@capybara-hq/
|
|
55
|
-
"@capybara-hq/
|
|
54
|
+
"@capybara-hq/kernel": "^0.1.0-rc.13",
|
|
55
|
+
"@capybara-hq/plugin-sdk": "^0.1.0-rc.9"
|
|
56
56
|
},
|
|
57
57
|
"devDependencies": {
|
|
58
58
|
"zod": "^4.6.7",
|
|
59
|
-
"@capybara-hq/testing": "0.1.0-rc.
|
|
59
|
+
"@capybara-hq/testing": "0.1.0-rc.12"
|
|
60
60
|
},
|
|
61
61
|
"scripts": {
|
|
62
62
|
"build": "tsc -b"
|
package/dist/pack/bootstrap.d.ts
DELETED
|
@@ -1,25 +0,0 @@
|
|
|
1
|
-
import { type NpmFetchOptions } from "./npm.js";
|
|
2
|
-
/** One package {@link packBootstrap} wrote. */
|
|
3
|
-
export interface BootstrapPack {
|
|
4
|
-
name: string;
|
|
5
|
-
version: string;
|
|
6
|
-
file: string;
|
|
7
|
-
integrity: string;
|
|
8
|
-
}
|
|
9
|
-
/**
|
|
10
|
-
* Produces the bootstrap directory the image bakes (package ecosystem spec §7): every entry of the
|
|
11
|
-
* bootstrap manifest (`packages/kernel/bootstrap.json`) as `<outDir>/<name>-<version>.tgz`. A
|
|
12
|
-
* `source: "workspace"` entry is packed from `<repoRoot>/<path>` with {@link packPlugin}
|
|
13
|
-
* (byte-reproducible) and must equal the entry: the plugin name, the version and the pinned sha512,
|
|
14
|
-
* otherwise this throws naming the plugin and writes nothing for it (the image build fails rather
|
|
15
|
-
* than baking a package `capybara bootstrap` would refuse). A `source: "npm"` entry is downloaded
|
|
16
|
-
* from `opts.registry` (default the public npm registry, the kernel's `packages.npmRegistry`
|
|
17
|
-
* default; `opts.token` goes to the registry origin only) and written only when its bytes match
|
|
18
|
-
* the pinned sha512 and its package.json names the entry's package and version; a tampered,
|
|
19
|
-
* mislabelled or oversized (`bootstrap.too_large`) tarball or an unpublished package or version
|
|
20
|
-
* throws naming the plugin. So npm entries need the network at
|
|
21
|
-
* build time only: init installs from the baked directory and never downloads.
|
|
22
|
-
*/
|
|
23
|
-
export declare function packBootstrap(manifestFile: string, repoRoot: string, outDir: string, opts?: {
|
|
24
|
-
registry?: string;
|
|
25
|
-
} & NpmFetchOptions): Promise<BootstrapPack[]>;
|