@capybara-hq/plugin-tools 0.1.0-rc.2 → 0.1.0-rc.4

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 CHANGED
@@ -36,13 +36,13 @@ capybara-plugin release [--dry-run] [--provenance] [--out <dir>] [--auto] [--roo
36
36
  timers, no module-level mutable state), the permission manifest (hooks, UI, storage, HTTP,
37
37
  secrets, tools), packing into a valid plugin package, the tool design rules (English
38
38
  descriptions, actionable errors), the migrations (naming, idempotent, no unmarked destructive
39
- statements), the plugin tests' imports (`plugin-test-imports`: a file under `<plugin>/test` uses
39
+ statements, one file per number in a directory), the plugin tests' imports (`plugin-test-imports`: a file under `<plugin>/test` uses
40
40
  only `@capybara-hq/plugin-sdk`, `@capybara-hq/testing`, `@capybara-hq/plugin-model-fake`, the
41
41
  plugin itself and files inside the plugin, by import, package-name string or path) and the
42
42
  release metadata (`plugin-release-metadata`: a public plugin has a `description`, a `license`, an
43
43
  https `homepage` and `repository` `{ type: "git", url: <the root package.json's
44
44
  repository.url>, directory: <its dir> }`; `example-*` plugins are private). `--base <ref>` also fails when the diff edits, deletes or renames an applied
45
- migration. No plugin found is an error. A plugin may list `@capybara-hq/plugin-tools` (like
45
+ migration, or adds one numbered at or below the highest number of its directory at `<ref>`. No plugin found is an error. A plugin may list `@capybara-hq/plugin-tools` (like
46
46
  `@capybara-hq/testing`) in its devDependencies.
47
47
  - **sync-versions**: after `changeset version` (which bumps only `package.json`), copies each
48
48
  plugin's version into its `.capybara-plugin/plugin.json` and its extension manifest
@@ -26,3 +26,26 @@ export declare function checkAppliedMigrations(nameStatus: string, isMigration:
26
26
  * repository; finding files are relative to `cwd` too.
27
27
  */
28
28
  export declare function diffAppliedMigrations(cwd: string, base: string, isMigration: MigrationFilePredicate): Finding[];
29
+ /** The files of a migration directory at the base (repo-relative `dir`, POSIX); `[]` when absent. */
30
+ export type BaseMigrationNames = (dir: string) => readonly string[];
31
+ /**
32
+ * PR check (rule `migration-number-behind-base`): every migration the diff adds is numbered above
33
+ * the highest number of its directory at the base. A branch that numbered its migrations before
34
+ * the base released the same or higher numbers (a branch's `0063..0068` against main's new
35
+ * `0063`) would otherwise merge with a collision or out of order: databases that already ran the
36
+ * base's files never run a lower-numbered newcomer in sequence, and a shared one ends up numbered
37
+ * differently from a fresh one.
38
+ *
39
+ * `nameStatus` is the output of `git diff --name-status -M <base>...HEAD`; the added files are the
40
+ * `A` entries and the destinations of `C` and `R` entries (`R` sources are
41
+ * {@link checkAppliedMigrations}'). `baseNames` lists a directory at the base itself (not the merge
42
+ * base: the base may have moved on since the branch started). A file that already exists at the
43
+ * base under the same name is not new to it and is skipped.
44
+ */
45
+ export declare function checkMigrationNumbers(nameStatus: string, isMigration: MigrationFilePredicate, baseNames: BaseMigrationNames): Finding[];
46
+ /**
47
+ * Runs {@link checkMigrationNumbers} on `<base>...HEAD` in `cwd`, listing each directory at
48
+ * `<base>` itself with `git ls-tree`. Paths are relative to `cwd`, as in
49
+ * {@link diffAppliedMigrations}.
50
+ */
51
+ export declare function diffMigrationNumbers(cwd: string, base: string, isMigration: MigrationFilePredicate): Finding[];
@@ -63,3 +63,89 @@ export function diffAppliedMigrations(cwd, base, isMigration) {
63
63
  const out = execFileSync("git", ["diff", "--name-status", "--no-ext-diff", "--relative", "-M", `${base}...HEAD`], { cwd, encoding: "utf8" });
64
64
  return checkAppliedMigrations(out, isMigration);
65
65
  }
66
+ const NUMBERED = /^(\d{4})_[^/]*\.sql$/;
67
+ /**
68
+ * PR check (rule `migration-number-behind-base`): every migration the diff adds is numbered above
69
+ * the highest number of its directory at the base. A branch that numbered its migrations before
70
+ * the base released the same or higher numbers (a branch's `0063..0068` against main's new
71
+ * `0063`) would otherwise merge with a collision or out of order: databases that already ran the
72
+ * base's files never run a lower-numbered newcomer in sequence, and a shared one ends up numbered
73
+ * differently from a fresh one.
74
+ *
75
+ * `nameStatus` is the output of `git diff --name-status -M <base>...HEAD`; the added files are the
76
+ * `A` entries and the destinations of `C` and `R` entries (`R` sources are
77
+ * {@link checkAppliedMigrations}'). `baseNames` lists a directory at the base itself (not the merge
78
+ * base: the base may have moved on since the branch started). A file that already exists at the
79
+ * base under the same name is not new to it and is skipped.
80
+ */
81
+ export function checkMigrationNumbers(nameStatus, isMigration, baseNames) {
82
+ const findings = [];
83
+ const highest = new Map();
84
+ const highestIn = (dir) => {
85
+ if (!highest.has(dir)) {
86
+ let top = null;
87
+ for (const name of baseNames(dir)) {
88
+ const m = NUMBERED.exec(name);
89
+ if (m?.[1] === undefined)
90
+ continue;
91
+ const n = Number(m[1]);
92
+ if (top === null || n > top.n)
93
+ top = { n, name };
94
+ }
95
+ highest.set(dir, top);
96
+ }
97
+ return highest.get(dir) ?? null;
98
+ };
99
+ for (const line of nameStatus.split("\n")) {
100
+ if (line === "")
101
+ continue;
102
+ const parts = line.split("\t");
103
+ const kind = parts[0]?.[0];
104
+ const added = kind === "A" ? parts[1] : kind === "C" || kind === "R" ? parts[2] : undefined;
105
+ if (added === undefined || !isMigration(added))
106
+ continue;
107
+ const slash = added.lastIndexOf("/");
108
+ const dir = slash < 0 ? "" : added.slice(0, slash);
109
+ const name = added.slice(slash + 1);
110
+ const m = NUMBERED.exec(name);
111
+ if (m?.[1] === undefined)
112
+ continue; // the naming rule reports it
113
+ if (baseNames(dir).includes(name))
114
+ continue;
115
+ const top = highestIn(dir);
116
+ if (top === null || Number(m[1]) > top.n)
117
+ continue;
118
+ findings.push({
119
+ file: added,
120
+ line: 1,
121
+ rule: "migration-number-behind-base",
122
+ message: `migration number ${m[1]} is not above the base's highest in ${dir || "."} (${top.name}): renumber the files this change adds to follow it`,
123
+ });
124
+ }
125
+ return findings;
126
+ }
127
+ /**
128
+ * Runs {@link checkMigrationNumbers} on `<base>...HEAD` in `cwd`, listing each directory at
129
+ * `<base>` itself with `git ls-tree`. Paths are relative to `cwd`, as in
130
+ * {@link diffAppliedMigrations}.
131
+ */
132
+ export function diffMigrationNumbers(cwd, base, isMigration) {
133
+ const out = execFileSync("git", ["diff", "--name-status", "--no-ext-diff", "--relative", "-M", `${base}...HEAD`], { cwd, encoding: "utf8" });
134
+ const listed = new Map();
135
+ const baseNames = (dir) => {
136
+ let names = listed.get(dir);
137
+ if (names === undefined) {
138
+ const tree = execFileSync("git", ["ls-tree", "--name-only", base, "--", `${dir || "."}/`], {
139
+ cwd,
140
+ encoding: "utf8",
141
+ });
142
+ names = tree
143
+ .split("\n")
144
+ .filter((p) => p !== "")
145
+ .map((p) => p.slice(p.lastIndexOf("/") + 1));
146
+ listed.set(dir, names);
147
+ }
148
+ return names;
149
+ };
150
+ return checkMigrationNumbers(out, isMigration, baseNames);
151
+ }
@@ -7,7 +7,8 @@ export declare const CHECK_USAGE = "capybara-plugin check [--root <dir>] [--plug
7
7
  * default `plugins/*` when `<root>/plugins` exists, else the root itself when it holds
8
8
  * `.capybara-plugin/plugin.json`;
9
9
  * - `--base <ref>`: also fail when `<ref>...HEAD` edits, deletes or renames a migration of a plugin
10
- * (applied migrations are immutable); for pull requests.
10
+ * (applied migrations are immutable), or adds one numbered at or below the highest number of its
11
+ * directory at `<ref>`; for pull requests.
11
12
  *
12
13
  * Finding no plugin is an error: an empty scan would pass without checking anything.
13
14
  *
package/dist/check/cli.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { join, relative, resolve } from "node:path";
2
2
  import { parseArgs } from "node:util";
3
3
  import { discoverPlugins, pluginsGlobOf } from "../discover.js";
4
- import { diffAppliedMigrations, migrationFileMatcher } from "./applied-migrations.js";
4
+ import { diffAppliedMigrations, diffMigrationNumbers, migrationFileMatcher, } from "./applied-migrations.js";
5
5
  import { pluginChecks, runChecks } from "./run.js";
6
6
  export const CHECK_USAGE = "capybara-plugin check [--root <dir>] [--plugins <glob>] [--base <ref>]";
7
7
  /**
@@ -12,7 +12,8 @@ export const CHECK_USAGE = "capybara-plugin check [--root <dir>] [--plugins <glo
12
12
  * default `plugins/*` when `<root>/plugins` exists, else the root itself when it holds
13
13
  * `.capybara-plugin/plugin.json`;
14
14
  * - `--base <ref>`: also fail when `<ref>...HEAD` edits, deletes or renames a migration of a plugin
15
- * (applied migrations are immutable); for pull requests.
15
+ * (applied migrations are immutable), or adds one numbered at or below the highest number of its
16
+ * directory at `<ref>`; for pull requests.
16
17
  *
17
18
  * Finding no plugin is an error: an empty scan would pass without checking anything.
18
19
  *
@@ -53,7 +54,10 @@ export async function checkCommand(args, cwd) {
53
54
  const isMigration = migrationFileMatcher([glob === "." ? "migrations" : `${glob}/migrations`]);
54
55
  checks.push({
55
56
  name: "Applied migrations",
56
- run: () => diffAppliedMigrations(root, base, isMigration).map((f) => ({
57
+ run: () => [
58
+ ...diffAppliedMigrations(root, base, isMigration),
59
+ ...diffMigrationNumbers(root, base, isMigration),
60
+ ].map((f) => ({
57
61
  ...f,
58
62
  file: join(root, f.file),
59
63
  })),
@@ -0,0 +1,13 @@
1
+ import type { Finding } from "./source.js";
2
+ /**
3
+ * Config forms (extension config forms spec §10): every plugin with an extension (`src/index.ts`)
4
+ * is bundled and imported the way the packer does, and its deployment and org config schemas are
5
+ * turned into the form JSON Schemas the package carries.
6
+ * - `plugin-config-ui`: a problem of their hints (`validateConfigUi` at the level, and value hints
7
+ * on secrets, which the JSON Schema drops silently); the packer refuses these too.
8
+ * - `plugin-config-labels`: a published plugin (not `"private": true`) whose rendered field has no
9
+ * label in both zh and en (secret records drawn per item by `x-ui.secrets` are skipped).
10
+ * A plugin that does not bundle or import is left to "Plugin pack", which reports it.
11
+ * Findings point at the plugin's `src/index.ts`.
12
+ */
13
+ export declare function checkConfigForms(pluginDirs: string[]): Promise<Finding[]>;
@@ -0,0 +1,71 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { extensionConfigSchemas, formatConfigSchemaProblem, missingConfigLabels, } from "../pack/config-schemas.js";
4
+ import { importPluginExtension } from "../pack/pack.js";
5
+ /** A plugin's package.json, or undefined when it is missing or not a JSON object. */
6
+ function readPackageJson(file) {
7
+ if (!existsSync(file))
8
+ return undefined;
9
+ try {
10
+ const pkg = JSON.parse(readFileSync(file, "utf8"));
11
+ return typeof pkg === "object" && pkg !== null && !Array.isArray(pkg)
12
+ ? pkg
13
+ : undefined;
14
+ }
15
+ catch {
16
+ return undefined;
17
+ }
18
+ }
19
+ /**
20
+ * Config forms (extension config forms spec §10): every plugin with an extension (`src/index.ts`)
21
+ * is bundled and imported the way the packer does, and its deployment and org config schemas are
22
+ * turned into the form JSON Schemas the package carries.
23
+ * - `plugin-config-ui`: a problem of their hints (`validateConfigUi` at the level, and value hints
24
+ * on secrets, which the JSON Schema drops silently); the packer refuses these too.
25
+ * - `plugin-config-labels`: a published plugin (not `"private": true`) whose rendered field has no
26
+ * label in both zh and en (secret records drawn per item by `x-ui.secrets` are skipped).
27
+ * A plugin that does not bundle or import is left to "Plugin pack", which reports it.
28
+ * Findings point at the plugin's `src/index.ts`.
29
+ */
30
+ export async function checkConfigForms(pluginDirs) {
31
+ const findings = [];
32
+ for (const dir of pluginDirs) {
33
+ const file = join(dir, "src/index.ts");
34
+ if (!existsSync(file))
35
+ continue;
36
+ let config;
37
+ try {
38
+ config = (await importPluginExtension(dir)).config;
39
+ }
40
+ catch {
41
+ continue;
42
+ }
43
+ const { schemas, problems } = extensionConfigSchemas(config);
44
+ for (const p of problems) {
45
+ findings.push({
46
+ file,
47
+ line: 1,
48
+ rule: "plugin-config-ui",
49
+ message: formatConfigSchemaProblem(p),
50
+ });
51
+ }
52
+ const pkg = readPackageJson(join(dir, "package.json"));
53
+ // a missing or malformed package.json is "Plugin pack"'s finding
54
+ if (pkg === undefined || pkg.private === true)
55
+ continue;
56
+ for (const level of ["deployment", "org"]) {
57
+ const schema = schemas[level];
58
+ if (schema === undefined)
59
+ continue;
60
+ for (const p of missingConfigLabels(schema)) {
61
+ findings.push({
62
+ file,
63
+ line: 1,
64
+ rule: "plugin-config-labels",
65
+ message: `${level} config: ${p.path} ${p.message}: a published plugin labels every field it shows (ui(schema, { label: { zh, en } }))`,
66
+ });
67
+ }
68
+ }
69
+ }
70
+ return findings;
71
+ }
@@ -1,10 +1,12 @@
1
- export { checkAppliedMigrations, diffAppliedMigrations, type MigrationFilePredicate, migrationFileMatcher, } from "./applied-migrations.js";
1
+ export { type BaseMigrationNames, checkAppliedMigrations, checkMigrationNumbers, diffAppliedMigrations, diffMigrationNumbers, type MigrationFilePredicate, migrationFileMatcher, } from "./applied-migrations.js";
2
2
  export { CHECK_USAGE, checkCommand } from "./cli.js";
3
+ export { checkConfigForms } from "./config-forms.js";
3
4
  export { checkDeps } from "./deps.js";
4
5
  export { checkMigrations, checkSql } from "./migrations.js";
5
6
  export { checkPluginPack, PLUGIN_PACK_MAX_BYTES } from "./plugin-pack.js";
6
7
  export { checkPluginPermissions } from "./plugin-permissions.js";
7
8
  export { checkPluginState } from "./plugin-state.js";
9
+ export { checkPluginToolKeys, TOOL_KEY_MAX } from "./plugin-tool-keys.js";
8
10
  export { syncPluginVersions } from "./plugin-versions.js";
9
11
  export { checkPluginReleaseMetadata } from "./release-metadata.js";
10
12
  export { formatFinding, printCheck } from "./report.js";
@@ -1,10 +1,12 @@
1
- export { checkAppliedMigrations, diffAppliedMigrations, migrationFileMatcher, } from "./applied-migrations.js";
1
+ export { checkAppliedMigrations, checkMigrationNumbers, diffAppliedMigrations, diffMigrationNumbers, migrationFileMatcher, } from "./applied-migrations.js";
2
2
  export { CHECK_USAGE, checkCommand } from "./cli.js";
3
+ export { checkConfigForms } from "./config-forms.js";
3
4
  export { checkDeps } from "./deps.js";
4
5
  export { checkMigrations, checkSql } from "./migrations.js";
5
6
  export { checkPluginPack, PLUGIN_PACK_MAX_BYTES } from "./plugin-pack.js";
6
7
  export { checkPluginPermissions } from "./plugin-permissions.js";
7
8
  export { checkPluginState } from "./plugin-state.js";
9
+ export { checkPluginToolKeys, TOOL_KEY_MAX } from "./plugin-tool-keys.js";
8
10
  export { syncPluginVersions } from "./plugin-versions.js";
9
11
  export { checkPluginReleaseMetadata } from "./release-metadata.js";
10
12
  export { formatFinding, printCheck } from "./report.js";
@@ -6,6 +6,6 @@ export type { Finding };
6
6
  export declare function checkSql(file: string, source: string): Finding[];
7
7
  /**
8
8
  * Checks migration directories (missing ones are ignored). Rules: naming / idempotent / destructive /
9
- * transaction-control / do-block.
9
+ * transaction-control / do-block / migration-duplicate-number.
10
10
  */
11
11
  export declare function checkMigrations(dirs: string[]): Finding[];
@@ -64,7 +64,7 @@ export function checkSql(file, source) {
64
64
  }
65
65
  /**
66
66
  * Checks migration directories (missing ones are ignored). Rules: naming / idempotent / destructive /
67
- * transaction-control / do-block.
67
+ * transaction-control / do-block / migration-duplicate-number.
68
68
  */
69
69
  export function checkMigrations(dirs) {
70
70
  const findings = [];
@@ -75,6 +75,7 @@ export function checkMigrations(dirs) {
75
75
  .filter((e) => e.isFile())
76
76
  .map((e) => e.name)
77
77
  .sort();
78
+ findings.push(...duplicateNumbers(dir, entries));
78
79
  for (const name of entries) {
79
80
  const file = join(dir, name);
80
81
  if (!NAME_RE.test(name)) {
@@ -91,3 +92,31 @@ export function checkMigrations(dirs) {
91
92
  }
92
93
  return findings;
93
94
  }
95
+ /**
96
+ * Two files of one directory with the same `NNNN` (rule `migration-duplicate-number`): the
97
+ * migrator runs files in name order and records them by name, so the pair runs in an order
98
+ * nobody chose, and a database that already ran one of them under that number ends up with a
99
+ * different numbering than a fresh one (as when a branch's `0063` met main's released `0063`).
100
+ * Every file after the first of a number is reported, naming the first.
101
+ */
102
+ function duplicateNumbers(dir, sortedNames) {
103
+ const first = new Map();
104
+ const findings = [];
105
+ for (const name of sortedNames) {
106
+ if (!NAME_RE.test(name))
107
+ continue;
108
+ const n = name.slice(0, 4);
109
+ const earlier = first.get(n);
110
+ if (earlier === undefined) {
111
+ first.set(n, name);
112
+ continue;
113
+ }
114
+ findings.push({
115
+ file: join(dir, name),
116
+ line: 1,
117
+ rule: "migration-duplicate-number",
118
+ message: `migration number ${n} is also used by ${earlier}: each number is used once per directory; renumber the file that is not applied anywhere yet to the next free number`,
119
+ });
120
+ }
121
+ return findings;
122
+ }
@@ -1,5 +1,16 @@
1
1
  import { type Finding } from "./source.js";
2
2
  export type { Finding };
3
+ /** A `ctx.tools.register({ name })` call found in an extension's setup. */
4
+ export interface ToolRegistration {
5
+ file: string;
6
+ line: number;
7
+ /** The extension name of the enclosing defineExtension (undefined: not a literal). */
8
+ extension: string | undefined;
9
+ /** The tool name (undefined: not a literal or same-file const). */
10
+ tool: string | undefined;
11
+ /** The call carries a `capybara:allow-dynamic-permission <reason>` marker. */
12
+ allowed: boolean;
13
+ }
3
14
  /**
4
15
  * 插件权限静态检查(AST):把 setup(ctx) 里对平台能力的访问与 defineExtension 清单比对。规则:
5
16
  * - perm-secrets:用了 ctx.secrets 而 permissions.secrets 不是 true;
@@ -20,3 +31,9 @@ export type { Finding };
20
31
  * 扫描范围:传入目录下的非测试源码。
21
32
  */
22
33
  export declare function checkPluginPermissions(pluginDirs: string[]): Finding[];
34
+ /**
35
+ * Every `ctx.tools.register({ name })` of the non-test sources under `pluginDirs`, found by the
36
+ * same scanner as {@link checkPluginPermissions} (so it follows ctx aliases and same-file helpers).
37
+ * Unparseable files are skipped here; the permissions check reports them.
38
+ */
39
+ export declare function findToolRegistrations(pluginDirs: string[]): ToolRegistration[];
@@ -115,6 +115,7 @@ function readManifest(obj, decls) {
115
115
  caps: new Set(),
116
116
  storage: false,
117
117
  models: false,
118
+ name: stringValue(prop(obj, "name")?.value ? resolve(prop(obj, "name")?.value, decls) : undefined),
118
119
  setup: undefined,
119
120
  };
120
121
  for (const key of ["requires", "optional", "provides"]) {
@@ -165,7 +166,7 @@ function readManifest(obj, decls) {
165
166
  }
166
167
  return m;
167
168
  }
168
- function scanSetup(f, m, decls) {
169
+ function scanSetup(f, m, decls, onTool) {
169
170
  const out = [];
170
171
  /** 明确的违规:豁免注释不起作用。 */
171
172
  const add = (at, rule, message) => {
@@ -253,6 +254,13 @@ function scanSetup(f, m, decls) {
253
254
  const spec = args[0] ? resolve(args[0], decls) : undefined;
254
255
  const nameProp = spec?.type === "ObjectExpression" ? prop(spec, "name") : undefined;
255
256
  const name = nameProp ? stringValue(resolve(nameProp.value, decls)) : undefined;
257
+ onTool?.({
258
+ file: f.file,
259
+ line: f.lineOf(n.start),
260
+ extension: m.name,
261
+ tool: name,
262
+ allowed: !!findMarker(f, n)?.reason,
263
+ });
256
264
  if (name === undefined)
257
265
  return dynamic(n, "perm-tools", "the tool name");
258
266
  if (!m.tools.has(name)) {
@@ -338,7 +346,7 @@ function scanSetup(f, m, decls) {
338
346
  }
339
347
  return out;
340
348
  }
341
- function scanFile(f) {
349
+ function scanFile(f, onTool) {
342
350
  const decls = buildDecls(f);
343
351
  const out = [];
344
352
  walk(f.program, (n) => {
@@ -351,7 +359,7 @@ function scanFile(f) {
351
359
  const obj = arg ? resolve(arg, decls) : undefined;
352
360
  if (obj?.type !== "ObjectExpression")
353
361
  return;
354
- out.push(...scanSetup(f, readManifest(obj, decls), decls));
362
+ out.push(...scanSetup(f, readManifest(obj, decls), decls, onTool));
355
363
  });
356
364
  return out;
357
365
  }
@@ -390,3 +398,22 @@ export function checkPluginPermissions(pluginDirs) {
390
398
  }
391
399
  return findings;
392
400
  }
401
+ /**
402
+ * Every `ctx.tools.register({ name })` of the non-test sources under `pluginDirs`, found by the
403
+ * same scanner as {@link checkPluginPermissions} (so it follows ctx aliases and same-file helpers).
404
+ * Unparseable files are skipped here; the permissions check reports them.
405
+ */
406
+ export function findToolRegistrations(pluginDirs) {
407
+ const out = [];
408
+ for (const dir of pluginDirs) {
409
+ for (const { file, isTest } of listSourceFiles(dir)) {
410
+ if (isTest)
411
+ continue;
412
+ const parsed = parseFile(file);
413
+ if (isParseError(parsed))
414
+ continue;
415
+ scanFile(parsed, (r) => out.push(r));
416
+ }
417
+ }
418
+ return out.sort((a, b) => a.file.localeCompare(b.file) || a.line - b.line);
419
+ }
@@ -0,0 +1,13 @@
1
+ import type { Finding } from "./source.js";
2
+ export type { Finding };
3
+ /** The model-visible key of an extension tool is `plugin__<extension>__<tool>`; 64 characters at most. */
4
+ export declare const TOOL_KEY_PREFIX = "plugin__";
5
+ export declare const TOOL_KEY_MAX = 64;
6
+ /**
7
+ * plugin-tool-key-length: the key `plugin__<extension>__<tool>` of every `ctx.tools.register`
8
+ * must fit in {@link TOOL_KEY_MAX} characters (model APIs cap tool names at 64). The names must be
9
+ * string literals or same-file consts, as for `perm-tools`; a name that is not is reported as
10
+ * "cannot be confirmed statically" unless the call carries `// capybara:allow-dynamic-permission
11
+ * <reason>`. Scans the non-test sources under `pluginDirs`.
12
+ */
13
+ export declare function checkPluginToolKeys(pluginDirs: string[]): Finding[];
@@ -0,0 +1,38 @@
1
+ import { findToolRegistrations } from "./plugin-permissions.js";
2
+ /** The model-visible key of an extension tool is `plugin__<extension>__<tool>`; 64 characters at most. */
3
+ export const TOOL_KEY_PREFIX = "plugin__";
4
+ export const TOOL_KEY_MAX = 64;
5
+ /**
6
+ * plugin-tool-key-length: the key `plugin__<extension>__<tool>` of every `ctx.tools.register`
7
+ * must fit in {@link TOOL_KEY_MAX} characters (model APIs cap tool names at 64). The names must be
8
+ * string literals or same-file consts, as for `perm-tools`; a name that is not is reported as
9
+ * "cannot be confirmed statically" unless the call carries `// capybara:allow-dynamic-permission
10
+ * <reason>`. Scans the non-test sources under `pluginDirs`.
11
+ */
12
+ export function checkPluginToolKeys(pluginDirs) {
13
+ const rule = "plugin-tool-key-length";
14
+ const out = [];
15
+ for (const r of findToolRegistrations(pluginDirs)) {
16
+ if (r.extension === undefined || r.tool === undefined) {
17
+ if (r.allowed)
18
+ continue;
19
+ out.push({
20
+ file: r.file,
21
+ line: r.line,
22
+ rule,
23
+ message: `Cannot statically confirm the length of the tool key (${r.extension === undefined ? "extension name" : "tool name"} is not a literal). Use a string literal (or a same-file const), or add // capybara:allow-dynamic-permission <reason>`,
24
+ });
25
+ continue;
26
+ }
27
+ const key = `${TOOL_KEY_PREFIX}${r.extension}__${r.tool}`;
28
+ if (key.length > TOOL_KEY_MAX) {
29
+ out.push({
30
+ file: r.file,
31
+ line: r.line,
32
+ rule,
33
+ message: `Tool key "${key}" is ${key.length} characters; the limit is ${TOOL_KEY_MAX} (plugin__<extension>__<tool>). Shorten the tool name to at most ${Math.max(0, TOOL_KEY_MAX - TOOL_KEY_PREFIX.length - r.extension.length - 2)} characters or the extension name`,
34
+ });
35
+ }
36
+ }
37
+ return out;
38
+ }
@@ -9,5 +9,7 @@ import type { Finding } from "./source.js";
9
9
  * has no `directory`. The `example-*` plugins must be private. The plugin tgz carries these fields
10
10
  * (`METADATA_FIELDS` of the packer); the libraries' half of the rule is core's
11
11
  * `check:release-metadata`.
12
+ * `release-display-name`: a published plugin's `.capybara-plugin/plugin.json` needs a
13
+ * `displayName` (`{ zh, en }`), the name every page shows instead of the package name.
12
14
  */
13
15
  export declare function checkPluginReleaseMetadata(plugins: string[], root: string): Finding[];
@@ -1,6 +1,25 @@
1
1
  import { existsSync, readFileSync } from "node:fs";
2
2
  import { basename, join, relative } from "node:path";
3
+ import { PLUGIN_MANIFEST_FILE } from "@capybara-hq/plugin-sdk";
3
4
  const RULE = "plugin-release-metadata";
5
+ const DISPLAY_NAME_RULE = "release-display-name";
6
+ /**
7
+ * Does the plugin's `.capybara-plugin/plugin.json` lack `displayName`? False when the file is
8
+ * missing or not JSON: "Plugin pack" reports those.
9
+ */
10
+ function lacksDisplayName(file) {
11
+ if (!existsSync(file))
12
+ return false;
13
+ try {
14
+ const m = JSON.parse(readFileSync(file, "utf8").replace(/^\uFEFF/, ""));
15
+ return (typeof m === "object" &&
16
+ m !== null &&
17
+ m.displayName === undefined);
18
+ }
19
+ catch {
20
+ return false;
21
+ }
22
+ }
4
23
  const readJson = (file) => JSON.parse(readFileSync(file, "utf8"));
5
24
  /** `repository.url` of a package.json (`"repository": "<url>"` or `{ url }`). */
6
25
  function repositoryUrl(pkg) {
@@ -22,6 +41,8 @@ function repositoryUrl(pkg) {
22
41
  * has no `directory`. The `example-*` plugins must be private. The plugin tgz carries these fields
23
42
  * (`METADATA_FIELDS` of the packer); the libraries' half of the rule is core's
24
43
  * `check:release-metadata`.
44
+ * `release-display-name`: a published plugin's `.capybara-plugin/plugin.json` needs a
45
+ * `displayName` (`{ zh, en }`), the name every page shows instead of the package name.
25
46
  */
26
47
  export function checkPluginReleaseMetadata(plugins, root) {
27
48
  const out = [];
@@ -47,6 +68,15 @@ export function checkPluginReleaseMetadata(plugins, root) {
47
68
  }
48
69
  if (typeof pkg.license !== "string")
49
70
  finding(`${label} is published and needs a "license"`);
71
+ const manifestFile = join(dir, PLUGIN_MANIFEST_FILE);
72
+ if (lacksDisplayName(manifestFile)) {
73
+ out.push({
74
+ file: manifestFile,
75
+ line: 1,
76
+ rule: DISPLAY_NAME_RULE,
77
+ message: `${label} is published and needs a "displayName" in ${PLUGIN_MANIFEST_FILE}: { "zh": …, "en": … }, the name pages show`,
78
+ });
79
+ }
50
80
  if (typeof pkg.homepage !== "string" || !pkg.homepage.startsWith("https://")) {
51
81
  finding(`${label} is published and needs an https "homepage"`);
52
82
  }
@@ -8,7 +8,7 @@ export interface PluginCheckScope {
8
8
  root: string;
9
9
  /**
10
10
  * Plugin directories: every check runs on each (dependency boundary, extension state,
11
- * permissions, pack, tool design, test imports, release metadata, and the migrations in
11
+ * permissions, pack, config forms, tool design, test imports, release metadata, and the migrations in
12
12
  * `<plugin>/migrations`).
13
13
  */
14
14
  plugins: string[];
package/dist/check/run.js CHANGED
@@ -1,9 +1,11 @@
1
1
  import { join } from "node:path";
2
+ import { checkConfigForms } from "./config-forms.js";
2
3
  import { checkDeps } from "./deps.js";
3
4
  import { checkMigrations } from "./migrations.js";
4
5
  import { checkPluginPack } from "./plugin-pack.js";
5
6
  import { checkPluginPermissions } from "./plugin-permissions.js";
6
7
  import { checkPluginState } from "./plugin-state.js";
8
+ import { checkPluginToolKeys } from "./plugin-tool-keys.js";
7
9
  import { checkPluginReleaseMetadata } from "./release-metadata.js";
8
10
  import { printCheck } from "./report.js";
9
11
  import { checkPluginTestImports } from "./test-imports.js";
@@ -26,10 +28,12 @@ export function pluginChecks(scope) {
26
28
  { name: "Extension state", run: () => checkPluginState(plugins) },
27
29
  { name: "Extension permissions", run: () => checkPluginPermissions(plugins) },
28
30
  { name: "Plugin pack", run: () => checkPluginPack(plugins) },
31
+ { name: "Config forms", run: () => checkConfigForms(plugins) },
29
32
  {
30
33
  name: "Tool design",
31
34
  run: () => checkToolDesign([...(scope.toolDesignDirs ?? []), ...plugins]),
32
35
  },
36
+ { name: "Tool key length", run: () => checkPluginToolKeys(plugins) },
33
37
  { name: "Plugin test imports", run: () => checkPluginTestImports(plugins) },
34
38
  {
35
39
  name: "Plugin release metadata",
@@ -5,7 +5,7 @@ export type { Finding };
5
5
  * - tool-description:`ctx.tools.register(spec, …)` 与 `BuiltinTool` 字面量(有 spec 与 run)里的 spec
6
6
  * 必须有字符串字面量 description(可 `+` 拼接)、不少于 20 字符、不含中文(模型可见文案一律英文);
7
7
  * - tool-error-actionable:`new ToolError(msg)` 的 msg 是字符串字面量 / 模板(或本文件内的常量、函数返回值),
8
- * 且含建议动词 use / call / retry / check / ask / pass / provide 之一,让模型知道下一步做什么。
8
+ * 且含建议动词 use / call / retry / check / ask / pass / provide / read 之一,让模型知道下一步做什么。
9
9
  * spec 可以直接写对象字面量,也可以引用本文件内的 const;其他写法(跨文件、动态构造)报 tool-description。
10
10
  * 已知缺口:从别的文件导入的消息常量 / 函数不追踪;非 ToolError 类的错误不查;运行时拼出的 spec 不查。
11
11
  * 扫描:传入目录下的非测试源码。
@@ -2,7 +2,7 @@ import { isParseError, listSourceFiles, parseFile, stringValue, walk, } from "./
2
2
  const MIN_DESCRIPTION = 20;
3
3
  const CJK = /[㐀-鿿豈-﫿 -〿＀-￯]/;
4
4
  /** 可执行的建议动词:错误信息里至少出现其一(整词、不分大小写)。 */
5
- const ACTION_VERB = /\b(?:use|call|retry|check|ask|pass|provide)\b/i;
5
+ const ACTION_VERB = /\b(?:use|call|retry|check|ask|pass|provide|read)\b/i;
6
6
  const WRAPPERS = new Set([
7
7
  "ParenthesizedExpression",
8
8
  "TSAsExpression",
@@ -129,7 +129,7 @@ const MSG_DESC_MISSING = "工具缺少 description,或它不是字符串字面
129
129
  const MSG_DESC_SHORT = `工具 description 至少 ${MIN_DESCRIPTION} 个字符:说清工具做什么、何时该用、何时不该用`;
130
130
  const MSG_DESC_CJK = "工具 description 是模型可见文案,必须是英文,不得含中文字符";
131
131
  const MSG_ERR_DYNAMIC = "ToolError 的消息必须是字符串字面量或模板(或指向本文件内的此类常量 / 函数),否则无法检查是否可执行";
132
- const MSG_ERR_ACTION = "ToolError 的消息要让模型知道下一步怎么做:必须含建议动词之一(use / call / retry / check / ask / pass / provide)";
132
+ const MSG_ERR_ACTION = "ToolError 的消息要让模型知道下一步怎么做:必须含建议动词之一(use / call / retry / check / ask / pass / provide / read)";
133
133
  /** 取一个 spec 表达式对应的对象字面量(直接写,或经本文件内的常量引用)。 */
134
134
  function specObject(n, env) {
135
135
  let e = unwrap(n);
@@ -204,7 +204,7 @@ function scanFile(f) {
204
204
  * - tool-description:`ctx.tools.register(spec, …)` 与 `BuiltinTool` 字面量(有 spec 与 run)里的 spec
205
205
  * 必须有字符串字面量 description(可 `+` 拼接)、不少于 20 字符、不含中文(模型可见文案一律英文);
206
206
  * - tool-error-actionable:`new ToolError(msg)` 的 msg 是字符串字面量 / 模板(或本文件内的常量、函数返回值),
207
- * 且含建议动词 use / call / retry / check / ask / pass / provide 之一,让模型知道下一步做什么。
207
+ * 且含建议动词 use / call / retry / check / ask / pass / provide / read 之一,让模型知道下一步做什么。
208
208
  * spec 可以直接写对象字面量,也可以引用本文件内的 const;其他写法(跨文件、动态构造)报 tool-description。
209
209
  * 已知缺口:从别的文件导入的消息常量 / 函数不追踪;非 ToolError 类的错误不查;运行时拼出的 spec 不查。
210
210
  * 扫描:传入目录下的非测试源码。
@@ -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
+ }
@@ -1,3 +1,4 @@
1
1
  export { PACK_USAGE, packCommand } from "./cli.js";
2
+ export { type ConfigSchemaLevel, type ConfigSchemaProblem, type ExtensionConfigSchemas, extensionConfigSchemas, formatConfigSchemaProblem, missingConfigLabels, } from "./config-schemas.js";
2
3
  export { assertNpmTarballLabel, DEFAULT_NPM_REGISTRY, downloadPinnedNpmPackage, fetchNpmTarball, NPM_TARBALL_MAX_BYTES, type NpmFetchOptions, NpmPackageError, type NpmPackageErrorKind, } from "./npm.js";
3
- 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";
@@ -1,3 +1,4 @@
1
1
  export { PACK_USAGE, packCommand } from "./cli.js";
2
+ export { extensionConfigSchemas, formatConfigSchemaProblem, missingConfigLabels, } from "./config-schemas.js";
2
3
  export { assertNpmTarballLabel, DEFAULT_NPM_REGISTRY, downloadPinnedNpmPackage, fetchNpmTarball, NPM_TARBALL_MAX_BYTES, NpmPackageError, } from "./npm.js";
3
- export { METADATA_FIELDS, NOTICES_FILE, packPlugin, STAGING_PREFIX, } from "./pack.js";
4
+ export { importPluginExtension, METADATA_FIELDS, NOTICES_FILE, packPlugin, STAGING_PREFIX, } from "./pack.js";
@@ -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
package/dist/pack/pack.js CHANGED
@@ -7,10 +7,11 @@ 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, SDK_VERSION, staticManifestOf, validatePluginPackageJsonFor, } 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 { extensionConfigSchemas, formatConfigSchemaProblem } from "./config-schemas.js";
14
15
  /** The extension entry the packer writes (relative to the package root). */
15
16
  const EXTENSION_ENTRY = "dist/index.mjs";
16
17
  /**
@@ -363,6 +364,61 @@ async function contentFiles(dir, label, m) {
363
364
  agents: agents.length,
364
365
  };
365
366
  }
367
+ /**
368
+ * Bundles `src/index.ts` into `<stage>/package/dist/index.mjs` (esbuild, single-file ESM), checks
369
+ * the bundle's imports, imports it and runs its default export through defineExtension. Returns the
370
+ * bundle, the defined manifest and the bundled inputs (for the notices).
371
+ */
372
+ async function buildAndImport(dir, label, stage) {
373
+ await linkHostPackages(dir, stage);
374
+ const entryOut = join(stage, "package", EXTENSION_ENTRY);
375
+ const result = await build({
376
+ absWorkingDir: dir,
377
+ entryPoints: [join(dir, "src/index.ts")],
378
+ outfile: entryOut,
379
+ bundle: true,
380
+ platform: "node",
381
+ format: "esm",
382
+ target: "node24",
383
+ external: [...HOST_PROVIDED_PACKAGES],
384
+ plugins: [nodeBuiltins],
385
+ banner: { js: BANNER },
386
+ define: DEFINE,
387
+ charset: "utf8",
388
+ logLevel: "silent",
389
+ metafile: true,
390
+ // Notices of bundled dependencies go to THIRD-PARTY-NOTICES.txt (built from the metafile).
391
+ legalComments: "none",
392
+ });
393
+ if (result.errors.length > 0) {
394
+ throw new Error(`${label}: esbuild failed: ${result.errors.map((e) => e.text).join("; ")}`);
395
+ }
396
+ const code = await readFile(entryOut);
397
+ const issues = await importIssues(code.toString("utf8"));
398
+ if (issues.length > 0)
399
+ throw new Error(`${label}: ${issues.join("; ")}`);
400
+ const mod = await importBundle(entryOut);
401
+ if (typeof mod.default !== "object" || mod.default === null) {
402
+ throw new Error(`${label}: the bundle has no default export object`);
403
+ }
404
+ const m = defineExtension(mod.default);
405
+ return { code, m, inputs: Object.keys(result.metafile?.inputs ?? {}) };
406
+ }
407
+ /**
408
+ * The extension a plugin directory's `src/index.ts` defines, bundled and imported the way the
409
+ * packer does (in a `.capybara-pack-*` staging dir under the system temp dir, removed afterwards).
410
+ * For checks that need the module (config forms); throws what packing would throw.
411
+ */
412
+ export async function importPluginExtension(pluginDir) {
413
+ const dir = resolve(pluginDir);
414
+ const stage = await realpath(await mkdtemp(join(tmpdir(), STAGING_PREFIX)));
415
+ try {
416
+ return (await buildAndImport(dir, dir, stage)).m;
417
+ }
418
+ finally {
419
+ await rm(stage, { recursive: true, force: true });
420
+ }
421
+ }
366
422
  /**
367
423
  * Bundles the extension `src/index.ts`:
368
424
  * 1. esbuild bundles it into the single-file ESM `dist/index.mjs` (platform node, target node24),
@@ -371,6 +427,8 @@ async function contentFiles(dir, label, m) {
371
427
  * 2. the bundle's imports are checked (allowlist, no non-literal dynamic import), the bundle is
372
428
  * imported and its default export goes through defineExtension; `staticManifestOf` gives the
373
429
  * static manifest, whose version must equal the package.json version;
430
+ * the config schemas become `extension.configSchemas` (`configJsonSchema`; refused when their
431
+ * hints have problems, {@link extensionConfigSchemas});
374
432
  * 3. `migrations/` (when the manifest declares migrations, which must resolve to the plugin's
375
433
  * `migrations/`), `ui/` (when present) and THIRD-PARTY-NOTICES.txt join the files.
376
434
  *
@@ -379,41 +437,14 @@ async function contentFiles(dir, label, m) {
379
437
  async function bundleExtension(dir, label, pkgVersion) {
380
438
  const stage = await realpath(await mkdtemp(join(tmpdir(), STAGING_PREFIX)));
381
439
  try {
382
- await linkHostPackages(dir, stage);
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);
440
+ const { code, m, inputs } = await buildAndImport(dir, label, stage);
414
441
  if (m.version !== pkgVersion) {
415
442
  throw new Error(`${label}: manifest version ${m.version} does not match package.json version ${String(pkgVersion)}`);
416
443
  }
444
+ const config = extensionConfigSchemas(m.config);
445
+ if (config.problems.length > 0) {
446
+ throw new Error(`${label}: invalid config UI hints: ${config.problems.map(formatConfigSchemaProblem).join("; ")}`);
447
+ }
417
448
  const files = new Map([[EXTENSION_ENTRY, code]]);
418
449
  const extension = {
419
450
  entry: `./${EXTENSION_ENTRY}`,
@@ -435,7 +466,10 @@ async function bundleExtension(dir, label, pkgVersion) {
435
466
  }
436
467
  extension.ui = "./ui";
437
468
  }
438
- const notices = await thirdPartyNotices(dir, Object.keys(result.metafile?.inputs ?? {}));
469
+ if (config.schemas.deployment !== undefined || config.schemas.org !== undefined) {
470
+ extension.configSchemas = config.schemas;
471
+ }
472
+ const notices = await thirdPartyNotices(dir, inputs);
439
473
  if (notices !== undefined)
440
474
  files.set(NOTICES_FILE, Buffer.from(notices));
441
475
  return { files, extension };
@@ -504,7 +538,15 @@ export async function packPlugin(pluginDir, outDir) {
504
538
  if (pkgIssues.length > 0)
505
539
  throw new Error(`${label}: ${pkgIssues.join("; ")}`);
506
540
  files.set("package.json", Buffer.from(`${JSON.stringify(pkg, null, 2)}\n`));
507
- files.set(PLUGIN_MANIFEST_FILE, Buffer.from(`${JSON.stringify(manifest, null, 2)}\n`));
541
+ // The packed text is pretty-printed while the config schema caps measure compact JSON: the text
542
+ // itself must stay under the reader's limit, or the kernel would refuse the package unread.
543
+ const manifestText = `${JSON.stringify(manifest, null, 2)}\n`;
544
+ if (manifestText.length > PLUGIN_MANIFEST_LIMITS.manifestChars) {
545
+ throw new Error(`${label}: packed ${PLUGIN_MANIFEST_FILE} is ${manifestText.length} characters, over the ` +
546
+ `${PLUGIN_MANIFEST_LIMITS.manifestChars} limit (PLUGIN_MANIFEST_LIMITS.manifestChars); ` +
547
+ "make the config schemas smaller");
548
+ }
549
+ files.set(PLUGIN_MANIFEST_FILE, Buffer.from(manifestText));
508
550
  const tgz = await makeTgz(files);
509
551
  await mkdir(outDir, { recursive: true });
510
552
  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.2",
3
+ "version": "0.1.0-rc.4",
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/kernel": "^0.1.0-rc.1",
55
- "@capybara-hq/plugin-sdk": "^0.1.0-rc.0"
54
+ "@capybara-hq/kernel": "^0.1.0-rc.4",
55
+ "@capybara-hq/plugin-sdk": "^0.1.0-rc.2"
56
56
  },
57
57
  "devDependencies": {
58
58
  "zod": "^4.6.7",
59
- "@capybara-hq/testing": "0.1.0-rc.2"
59
+ "@capybara-hq/testing": "0.1.0-rc.4"
60
60
  },
61
61
  "scripts": {
62
62
  "build": "tsc -b"