@capybara-hq/plugin-tools 0.1.0-rc.1 → 0.1.0-rc.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +20 -19
- package/bin/capybara-plugin.mjs +1 -1
- package/dist/check/applied-migrations.d.ts +23 -0
- package/dist/check/applied-migrations.js +86 -0
- package/dist/check/cli.d.ts +2 -1
- package/dist/check/cli.js +7 -3
- package/dist/check/config-forms.d.ts +13 -0
- package/dist/check/config-forms.js +71 -0
- package/dist/check/deps.d.ts +4 -2
- package/dist/check/deps.js +14 -2
- package/dist/check/index.d.ts +4 -1
- package/dist/check/index.js +4 -1
- package/dist/check/migrations.d.ts +1 -1
- package/dist/check/migrations.js +30 -1
- package/dist/check/org-override.d.ts +10 -0
- package/dist/check/org-override.js +62 -0
- package/dist/check/plugin-pack.js +2 -0
- package/dist/check/plugin-permissions.d.ts +34 -1
- package/dist/check/plugin-permissions.js +66 -6
- package/dist/check/plugin-tool-keys.d.ts +13 -0
- package/dist/check/plugin-tool-keys.js +38 -0
- package/dist/check/release-metadata.d.ts +5 -0
- package/dist/check/release-metadata.js +47 -0
- package/dist/check/report.d.ts +4 -3
- package/dist/check/report.js +13 -3
- package/dist/check/run.d.ts +3 -1
- package/dist/check/run.js +12 -1
- package/dist/check/source.d.ts +7 -0
- package/dist/check/source.js +2 -0
- package/dist/check/themes.d.ts +19 -0
- package/dist/check/themes.js +112 -0
- package/dist/check/tool-design.d.ts +1 -1
- package/dist/check/tool-design.js +3 -3
- package/dist/check/ui-slots.d.ts +7 -0
- package/dist/check/ui-slots.js +30 -0
- package/dist/cli.js +0 -2
- package/dist/marketplace/cli.d.ts +10 -7
- package/dist/marketplace/cli.js +20 -10
- package/dist/marketplace/marketplace.d.ts +46 -24
- package/dist/marketplace/marketplace.js +232 -60
- package/dist/pack/cli.d.ts +3 -10
- package/dist/pack/cli.js +3 -40
- package/dist/pack/config-schemas.d.ts +34 -0
- package/dist/pack/config-schemas.js +151 -0
- package/dist/pack/index.d.ts +2 -2
- package/dist/pack/index.js +2 -2
- package/dist/pack/pack.d.ts +8 -2
- package/dist/pack/pack.js +154 -38
- package/package.json +4 -4
- package/dist/pack/bootstrap.d.ts +0 -25
- package/dist/pack/bootstrap.js +0 -70
package/README.md
CHANGED
|
@@ -14,8 +14,6 @@ npm install --save-dev @capybara-hq/plugin-tools
|
|
|
14
14
|
|
|
15
15
|
```bash
|
|
16
16
|
capybara-plugin pack [<dir>...] [--all <root>] [--out <dir>]
|
|
17
|
-
capybara-plugin pack --bootstrap <manifest> [--root <dir>] [--out <dir>] [--registry <url>]
|
|
18
|
-
[--npm-token <token>]
|
|
19
17
|
capybara-plugin check [--root <dir>] [--plugins <glob>] [--base <ref>]
|
|
20
18
|
capybara-plugin sync-versions [--root <dir>] [--plugins <glob>]
|
|
21
19
|
capybara-plugin marketplace --write|--check [--base <rev>] [--root <dir>] [--file <path>]
|
|
@@ -30,38 +28,41 @@ capybara-plugin release [--dry-run] [--provenance] [--out <dir>] [--auto] [--roo
|
|
|
30
28
|
manifest written into `plugin.json`, LICENSE / NOTICE and the bundled dependencies' notices
|
|
31
29
|
included. A plugin with an extension gets `"main"` and `"exports": { ".": "./dist/index.mjs" }`,
|
|
32
30
|
so once installed it resolves by package name (a test loads `@capybara-hq/plugin-model-fake`
|
|
33
|
-
from npm). `--all <root>` packs every plugin of a repository
|
|
34
|
-
a
|
|
35
|
-
`--registry`, default `CAPYBARA_PACKAGES__NPM_REGISTRY` or the public registry, with
|
|
36
|
-
`--npm-token` / `CAPYBARA_PACKAGES__NPM_TOKEN` sent to the registry origin only, tarballs capped
|
|
37
|
-
at 50 MiB) and checks them against their pinned sha512 and their `package.json` name and version.
|
|
38
|
-
Output: `artifacts/plugins`.
|
|
31
|
+
from npm). `--all <root>` packs every plugin of a repository. Output: `artifacts/plugins`. (The
|
|
32
|
+
bootstrap plugins a kernel pins are no longer packed here: a starting kernel downloads them.)
|
|
39
33
|
- **check**: every plugin check, on the plugins under `<root>/plugins/*` (or `--plugins <glob>`, or
|
|
40
34
|
the root itself when it is a plugin): the dependency boundary (a plugin depends only on
|
|
41
35
|
`@capybara-hq/plugin-sdk` and third-party packages), extension state under multiple replicas (no
|
|
42
36
|
timers, no module-level mutable state), the permission manifest (hooks, UI, storage, HTTP,
|
|
43
37
|
secrets, tools), packing into a valid plugin package, the tool design rules (English
|
|
44
38
|
descriptions, actionable errors), the migrations (naming, idempotent, no unmarked destructive
|
|
45
|
-
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
|
|
46
40
|
only `@capybara-hq/plugin-sdk`, `@capybara-hq/testing`, `@capybara-hq/plugin-model-fake`, the
|
|
47
41
|
plugin itself and files inside the plugin, by import, package-name string or path) and the
|
|
48
42
|
release metadata (`plugin-release-metadata`: a public plugin has a `description`, a `license`, an
|
|
49
43
|
https `homepage` and `repository` `{ type: "git", url: <the root package.json's
|
|
50
|
-
repository.url>, directory: <its dir> }`; `example-*` plugins are private
|
|
51
|
-
|
|
44
|
+
repository.url>, directory: <its dir> }`; `example-*` plugins are private; its `plugin.json`
|
|
45
|
+
has a `displayName`, and `release-description-zh` warns, without failing, when its `description`
|
|
46
|
+
has no `zh`). Warnings are printed under `passed with N warning(s)`. `--base <ref>` also fails when the diff edits, deletes or renames an applied
|
|
47
|
+
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
|
|
52
48
|
`@capybara-hq/testing`) in its devDependencies.
|
|
53
49
|
- **sync-versions**: after `changeset version` (which bumps only `package.json`), copies each
|
|
54
50
|
plugin's version into its `.capybara-plugin/plugin.json` and its extension manifest
|
|
55
51
|
(`src/index.ts`, ` version: "x"`), which the packer requires equal.
|
|
56
|
-
- **marketplace**: `--write`
|
|
57
|
-
|
|
52
|
+
- **marketplace**: `--write` keeps one npm entry per public plugin in
|
|
53
|
+
`.capybara-plugin/marketplace.json` (`--file`), its current version (`name`, `version`,
|
|
54
|
+
`summary` from the `plugin.json` `description`, `displayName` from `plugin.json`, `homepage`,
|
|
58
55
|
`permissions`, `source.npm` with the sha512 of a reproducible pack), formatted as Biome formats
|
|
59
|
-
it
|
|
60
|
-
|
|
56
|
+
it: a plugin whose version moved gets its entry replaced in place, its other versions are
|
|
57
|
+
dropped, a listed entry's `summary` / `displayName` are refreshed from `plugin.json`
|
|
58
|
+
(`marketplace-display-stale` in `--check`; source and integrity untouched), and entries of
|
|
59
|
+
plugins not in the tree are kept. `--check` fails when a plugin's
|
|
60
|
+
current version is missing or not its only entry (`marketplace-missing`) or the file does not
|
|
61
|
+
parse (`marketplace-invalid`). A version already listed is not compared with the tree: once
|
|
61
62
|
published, its bytes are the release gate's business. `--check --base <rev>` (pull requests)
|
|
62
|
-
also requires
|
|
63
|
-
(`marketplace-
|
|
64
|
-
(`marketplace-integrity`).
|
|
63
|
+
also requires a plugin's version to only increase over the versions `<rev>` lists
|
|
64
|
+
(`marketplace-version-decrease`), a version `<rev>` lists to keep its integrity, and the entries
|
|
65
|
+
added since to match their packs (`marketplace-integrity`).
|
|
65
66
|
- **release**: publishes every non-private workspace package whose version is not on npm yet:
|
|
66
67
|
plugins (the packages under `plugins/`) as their plugin tgz, libraries through `pnpm pack`, with
|
|
67
68
|
the dist-tag rule (prerelease `next`, backport `release-<major>.<minor>`, else `latest`), then
|
|
@@ -101,7 +102,7 @@ and hides packaging bugs.
|
|
|
101
102
|
|
|
102
103
|
## Library
|
|
103
104
|
|
|
104
|
-
The same code as functions: `packPlugin`, `
|
|
105
|
+
The same code as functions: `packPlugin`, `downloadPinnedNpmPackage`, `pluginChecks` / `runChecks` and each
|
|
105
106
|
check (`checkDeps`, `checkPluginState`, `checkPluginPermissions`, `checkPluginPack`,
|
|
106
107
|
`checkToolDesign`, `checkMigrations`, `checkAppliedMigrations`, `checkPluginTestImports`,
|
|
107
108
|
`checkPluginReleaseMetadata`), `syncPluginVersions` (after `changeset version`),
|
package/bin/capybara-plugin.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
// The `capybara-plugin` bin: runs the compiled CLI (dist/cli.js, built by `tsc -b`). In this
|
|
3
|
-
// repository, the root scripts (pnpm pack-plugin, pack-plugins,
|
|
3
|
+
// repository, the root scripts (pnpm pack-plugin, pack-plugins, release) run the
|
|
4
4
|
// TypeScript sources through tsx instead.
|
|
5
5
|
import { existsSync } from "node:fs";
|
|
6
6
|
|
|
@@ -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/deps.d.ts
CHANGED
|
@@ -5,7 +5,9 @@ export type { Finding };
|
|
|
5
5
|
* - pluginDirs: each plugin directory (source imports and package.json); a violation is
|
|
6
6
|
* plugin-imports-kernel (a path leaving the plugin directory: plugin-imports-outside);
|
|
7
7
|
* - sdkDirs: the packages/plugin-sdk directory; any @capybara-hq/* package or path leaving the
|
|
8
|
-
* package directory is sdk-imports-internal
|
|
8
|
+
* package directory is sdk-imports-internal;
|
|
9
|
+
* - deviceDirs: the packages/device directory (source, tests and package.json); any @capybara-hq/*
|
|
10
|
+
* package other than plugin-sdk or path leaving the package directory is device-imports-internal.
|
|
9
11
|
*
|
|
10
12
|
* Covers: import / export from / export * / import type / dynamic import("literal or concatenated
|
|
11
13
|
* literals") / require("…") / import x = require() / import("x") types; package.json dependencies /
|
|
@@ -20,4 +22,4 @@ export type { Finding };
|
|
|
20
22
|
* `module.require` or `createRequire(...)`; `import.meta.resolve`; paths built at run time to read
|
|
21
23
|
* files; nested package.json files and TS `paths` mappings.
|
|
22
24
|
*/
|
|
23
|
-
export declare function checkDeps(pluginDirs: string[], sdkDirs?: string[]): Finding[];
|
|
25
|
+
export declare function checkDeps(pluginDirs: string[], sdkDirs?: string[], deviceDirs?: string[]): Finding[];
|
package/dist/check/deps.js
CHANGED
|
@@ -33,6 +33,13 @@ const SDK_RULES = {
|
|
|
33
33
|
outsideMessage: (what) => `packages/plugin-sdk must not reference files outside its package directory (${what})`,
|
|
34
34
|
allowed: () => false,
|
|
35
35
|
};
|
|
36
|
+
const DEVICE_RULES = {
|
|
37
|
+
pkgRule: "device-imports-internal",
|
|
38
|
+
outsideRule: "device-imports-internal",
|
|
39
|
+
pkgMessage: (what) => `packages/device runs on a person's machine and may depend only on ${SDK} and third-party npm packages, not on ${what}; the kernel is reached over HTTP and WebSocket only`,
|
|
40
|
+
outsideMessage: (what) => `packages/device must not reference files outside its package directory (${what})`,
|
|
41
|
+
allowed: (pkg) => pkg === SDK,
|
|
42
|
+
};
|
|
36
43
|
/** The violation a reference string (package name / relative path / file: URL / protocol dependency value) makes, if any. */
|
|
37
44
|
function judge(value, baseDir, rootDir, rules, where) {
|
|
38
45
|
const pkgs = [...value.matchAll(SCOPED_RE)].map((m) => `${SCOPE}${m[1]}`);
|
|
@@ -180,7 +187,9 @@ function sourceFindings(dir, rules, srcDir) {
|
|
|
180
187
|
* - pluginDirs: each plugin directory (source imports and package.json); a violation is
|
|
181
188
|
* plugin-imports-kernel (a path leaving the plugin directory: plugin-imports-outside);
|
|
182
189
|
* - sdkDirs: the packages/plugin-sdk directory; any @capybara-hq/* package or path leaving the
|
|
183
|
-
* package directory is sdk-imports-internal
|
|
190
|
+
* package directory is sdk-imports-internal;
|
|
191
|
+
* - deviceDirs: the packages/device directory (source, tests and package.json); any @capybara-hq/*
|
|
192
|
+
* package other than plugin-sdk or path leaving the package directory is device-imports-internal.
|
|
184
193
|
*
|
|
185
194
|
* Covers: import / export from / export * / import type / dynamic import("literal or concatenated
|
|
186
195
|
* literals") / require("…") / import x = require() / import("x") types; package.json dependencies /
|
|
@@ -195,7 +204,7 @@ function sourceFindings(dir, rules, srcDir) {
|
|
|
195
204
|
* `module.require` or `createRequire(...)`; `import.meta.resolve`; paths built at run time to read
|
|
196
205
|
* files; nested package.json files and TS `paths` mappings.
|
|
197
206
|
*/
|
|
198
|
-
export function checkDeps(pluginDirs, sdkDirs = []) {
|
|
207
|
+
export function checkDeps(pluginDirs, sdkDirs = [], deviceDirs = []) {
|
|
199
208
|
const findings = [];
|
|
200
209
|
for (const dir of pluginDirs) {
|
|
201
210
|
findings.push(...packageFindings(dir, PLUGIN_RULES), ...sourceFindings(dir, PLUGIN_RULES, dir));
|
|
@@ -203,5 +212,8 @@ export function checkDeps(pluginDirs, sdkDirs = []) {
|
|
|
203
212
|
for (const dir of sdkDirs) {
|
|
204
213
|
findings.push(...packageFindings(dir, SDK_RULES), ...sourceFindings(dir, SDK_RULES, join(dir, "src")));
|
|
205
214
|
}
|
|
215
|
+
for (const dir of deviceDirs) {
|
|
216
|
+
findings.push(...packageFindings(dir, DEVICE_RULES), ...sourceFindings(dir, DEVICE_RULES, dir));
|
|
217
|
+
}
|
|
206
218
|
return findings;
|
|
207
219
|
}
|
package/dist/check/index.d.ts
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
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";
|
|
6
|
+
export { checkOrgOverride } from "./org-override.js";
|
|
5
7
|
export { checkPluginPack, PLUGIN_PACK_MAX_BYTES } from "./plugin-pack.js";
|
|
6
8
|
export { checkPluginPermissions } from "./plugin-permissions.js";
|
|
7
9
|
export { checkPluginState } from "./plugin-state.js";
|
|
10
|
+
export { checkPluginToolKeys, TOOL_KEY_MAX } from "./plugin-tool-keys.js";
|
|
8
11
|
export { syncPluginVersions } from "./plugin-versions.js";
|
|
9
12
|
export { checkPluginReleaseMetadata } from "./release-metadata.js";
|
|
10
13
|
export { formatFinding, printCheck } from "./report.js";
|
package/dist/check/index.js
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
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";
|
|
6
|
+
export { checkOrgOverride } from "./org-override.js";
|
|
5
7
|
export { checkPluginPack, PLUGIN_PACK_MAX_BYTES } from "./plugin-pack.js";
|
|
6
8
|
export { checkPluginPermissions } from "./plugin-permissions.js";
|
|
7
9
|
export { checkPluginState } from "./plugin-state.js";
|
|
10
|
+
export { checkPluginToolKeys, TOOL_KEY_MAX } from "./plugin-tool-keys.js";
|
|
8
11
|
export { syncPluginVersions } from "./plugin-versions.js";
|
|
9
12
|
export { checkPluginReleaseMetadata } from "./release-metadata.js";
|
|
10
13
|
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[];
|
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
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { Finding } from "./source.js";
|
|
2
|
+
/**
|
|
3
|
+
* `plugin-org-override-unbound` (a warning): an extension whose deployment config has an
|
|
4
|
+
* `orgAccess(…, "override")` field, but that registers no tool, no `ctx.ui` view or action, no
|
|
5
|
+
* hook and provides none of `web-search`, `web-fetch`, `directory`, `model-provider` or
|
|
6
|
+
* `file-preview`. An override reaches the extension only in the per-call `config` of those entry
|
|
7
|
+
* points, so the field can never take effect. Findings point at the plugin's `src/index.ts`.
|
|
8
|
+
* A plugin that does not bundle or import is left to "Plugin pack".
|
|
9
|
+
*/
|
|
10
|
+
export declare function checkOrgOverride(pluginDirs: string[]): Promise<Finding[]>;
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { extensionConfigSchemas } from "../pack/config-schemas.js";
|
|
4
|
+
import { importPluginExtension } from "../pack/pack.js";
|
|
5
|
+
import { findCtxCalls } from "./plugin-permissions.js";
|
|
6
|
+
/** Extension points whose calls carry the org's per-call config on their own. */
|
|
7
|
+
const ORG_AWARE_POINTS = new Set([
|
|
8
|
+
"web-search",
|
|
9
|
+
"web-fetch",
|
|
10
|
+
"directory",
|
|
11
|
+
"model-provider",
|
|
12
|
+
"file-preview",
|
|
13
|
+
]);
|
|
14
|
+
/** The top-level properties of a deployment schema marked `x-org: "override"`. */
|
|
15
|
+
function overrideFields(schema) {
|
|
16
|
+
const props = schema?.properties;
|
|
17
|
+
if (typeof props !== "object" || props === null)
|
|
18
|
+
return [];
|
|
19
|
+
return Object.entries(props)
|
|
20
|
+
.filter(([, v]) => v?.["x-org"] === "override")
|
|
21
|
+
.map(([k]) => k);
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* `plugin-org-override-unbound` (a warning): an extension whose deployment config has an
|
|
25
|
+
* `orgAccess(…, "override")` field, but that registers no tool, no `ctx.ui` view or action, no
|
|
26
|
+
* hook and provides none of `web-search`, `web-fetch`, `directory`, `model-provider` or
|
|
27
|
+
* `file-preview`. An override reaches the extension only in the per-call `config` of those entry
|
|
28
|
+
* points, so the field can never take effect. Findings point at the plugin's `src/index.ts`.
|
|
29
|
+
* A plugin that does not bundle or import is left to "Plugin pack".
|
|
30
|
+
*/
|
|
31
|
+
export async function checkOrgOverride(pluginDirs) {
|
|
32
|
+
const findings = [];
|
|
33
|
+
for (const dir of pluginDirs) {
|
|
34
|
+
const file = join(dir, "src/index.ts");
|
|
35
|
+
if (!existsSync(file))
|
|
36
|
+
continue;
|
|
37
|
+
let ext;
|
|
38
|
+
try {
|
|
39
|
+
ext = await importPluginExtension(dir);
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
const fields = overrideFields(extensionConfigSchemas(ext.config).schemas.deployment);
|
|
45
|
+
if (fields.length === 0)
|
|
46
|
+
continue;
|
|
47
|
+
const calls = findCtxCalls(dir);
|
|
48
|
+
if (calls.tools || calls.ui || calls.hooks)
|
|
49
|
+
continue;
|
|
50
|
+
const provided = (ext.provides ?? []).map((p) => String(p).split("@")[0] ?? "");
|
|
51
|
+
if (provided.some((p) => ORG_AWARE_POINTS.has(p)))
|
|
52
|
+
continue;
|
|
53
|
+
findings.push({
|
|
54
|
+
file,
|
|
55
|
+
line: 1,
|
|
56
|
+
rule: "plugin-org-override-unbound",
|
|
57
|
+
severity: "warning",
|
|
58
|
+
message: `deployment config field ${fields.join(", ")} allows an org override, but the extension registers no tool, ui view, action or hook and provides no org-aware extension point (${[...ORG_AWARE_POINTS].join(", ")}), so no per-call config ever reaches it`,
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
return findings;
|
|
62
|
+
}
|
|
@@ -4,6 +4,7 @@ import { join } from "node:path";
|
|
|
4
4
|
import { ExtensionPackageError, PACKAGES_MAX_BYTES_DEFAULT, PluginPackageError, readExtensionPackage, readPluginPackage, } from "@capybara-hq/kernel";
|
|
5
5
|
import { SDK_VERSION } from "@capybara-hq/plugin-sdk";
|
|
6
6
|
import { packPlugin } from "../pack/pack.js";
|
|
7
|
+
import { uiSlotFindings } from "./ui-slots.js";
|
|
7
8
|
/**
|
|
8
9
|
* The default of `packages.maxBytes` (from the kernel config schema): an official plugin's package
|
|
9
10
|
* must install under the default limit.
|
|
@@ -38,6 +39,7 @@ export async function checkPluginPack(pluginDirs) {
|
|
|
38
39
|
}
|
|
39
40
|
const bytes = new Uint8Array(await readFile(r.file));
|
|
40
41
|
const plugin = await readPluginPackage(bytes, opts);
|
|
42
|
+
findings.push(...uiSlotFindings(file, plugin.manifest.extension?.manifest.ui));
|
|
41
43
|
if (plugin.manifest.extension !== undefined)
|
|
42
44
|
await readExtensionPackage(bytes, opts);
|
|
43
45
|
}
|
|
@@ -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;
|
|
@@ -11,7 +22,9 @@ export type { Finding };
|
|
|
11
22
|
* - perm-hooks: the mount point of ctx.hooks.on(mount, …) is not in permissions.hooks (the mount
|
|
12
23
|
* point must be a string literal or a same-file const, like the names below), or the options are
|
|
13
24
|
* `{ mode: "policy" }` and the mount point is not in permissions.policyHooks;
|
|
14
|
-
* - perm-
|
|
25
|
+
* - perm-models: ctx.models is used but permissions.models is not declared (the purposes it may use
|
|
26
|
+
* are checked at run time);
|
|
27
|
+
* - perm-reserved: platform access that cannot be confirmed (a destructured ctx, a computed member).
|
|
15
28
|
* 路由路径、工具名、能力 ref、界面 id 须是字符串字面量或本文件的 const;动态的写法报「无法静态确认」,
|
|
16
29
|
* 豁免:`// capybara:allow-dynamic-permission <原因>`(行尾或紧邻上一行,原因必填)。
|
|
17
30
|
* ctx 会跟踪本地别名,以及按位置传给同文件函数的参数。
|
|
@@ -20,3 +33,23 @@ export type { Finding };
|
|
|
20
33
|
* 扫描范围:传入目录下的非测试源码。
|
|
21
34
|
*/
|
|
22
35
|
export declare function checkPluginPermissions(pluginDirs: string[]): Finding[];
|
|
36
|
+
/**
|
|
37
|
+
* Every `ctx.tools.register({ name })` of the non-test sources under `pluginDirs`, found by the
|
|
38
|
+
* same scanner as {@link checkPluginPermissions} (so it follows ctx aliases and same-file helpers).
|
|
39
|
+
* Unparseable files are skipped here; the permissions check reports them.
|
|
40
|
+
*/
|
|
41
|
+
export declare function findToolRegistrations(pluginDirs: string[]): ToolRegistration[];
|
|
42
|
+
/** The `ctx.<area>.<method>` calls an extension's setup makes (see {@link findCtxCalls}). */
|
|
43
|
+
export interface CtxCalls {
|
|
44
|
+
/** `ctx.tools.register` is called. */
|
|
45
|
+
tools: boolean;
|
|
46
|
+
/** `ctx.ui.view` or `ctx.ui.action` is called. */
|
|
47
|
+
ui: boolean;
|
|
48
|
+
/** `ctx.hooks.on` is called. */
|
|
49
|
+
hooks: boolean;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Which of `ctx.tools.register`, `ctx.ui.view|action` and `ctx.hooks.on` the non-test sources
|
|
53
|
+
* under `pluginDir` call, found by the same scanner as {@link checkPluginPermissions}.
|
|
54
|
+
*/
|
|
55
|
+
export declare function findCtxCalls(pluginDir: string): CtxCalls;
|