@capybara-hq/plugin-tools 0.1.0-rc.3 → 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 +2 -2
- 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/index.d.ts +2 -1
- package/dist/check/index.js +2 -1
- package/dist/check/migrations.d.ts +1 -1
- package/dist/check/migrations.js +30 -1
- package/dist/check/release-metadata.d.ts +2 -0
- package/dist/check/release-metadata.js +30 -0
- package/dist/check/run.d.ts +1 -1
- package/dist/check/run.js +2 -0
- package/dist/pack/config-schemas.d.ts +34 -0
- package/dist/pack/config-schemas.js +151 -0
- package/dist/pack/index.d.ts +2 -1
- package/dist/pack/index.js +2 -1
- package/dist/pack/pack.d.ts +7 -1
- package/dist/pack/pack.js +77 -35
- package/package.json +4 -4
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
|
|
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
|
+
}
|
package/dist/check/cli.d.ts
CHANGED
|
@@ -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)
|
|
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)
|
|
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: () =>
|
|
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
|
+
}
|
package/dist/check/index.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
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";
|
package/dist/check/index.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
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,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[];
|
package/dist/check/migrations.js
CHANGED
|
@@ -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
|
+
}
|
|
@@ -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
|
}
|
package/dist/check/run.d.ts
CHANGED
|
@@ -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,4 +1,5 @@
|
|
|
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";
|
|
@@ -27,6 +28,7 @@ export function pluginChecks(scope) {
|
|
|
27
28
|
{ name: "Extension state", run: () => checkPluginState(plugins) },
|
|
28
29
|
{ name: "Extension permissions", run: () => checkPluginPermissions(plugins) },
|
|
29
30
|
{ name: "Plugin pack", run: () => checkPluginPack(plugins) },
|
|
31
|
+
{ name: "Config forms", run: () => checkConfigForms(plugins) },
|
|
30
32
|
{
|
|
31
33
|
name: "Tool design",
|
|
32
34
|
run: () => checkToolDesign([...(scope.toolDesignDirs ?? []), ...plugins]),
|
|
@@ -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,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";
|
package/dist/pack/index.js
CHANGED
|
@@ -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";
|
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
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
55
|
-
"@capybara-hq/plugin-sdk": "^0.1.0-rc.
|
|
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.
|
|
59
|
+
"@capybara-hq/testing": "0.1.0-rc.4"
|
|
60
60
|
},
|
|
61
61
|
"scripts": {
|
|
62
62
|
"build": "tsc -b"
|