@capybara-hq/plugin-tools 0.1.0-rc.3 → 0.1.0-rc.5
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 +17 -10
- 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 +3 -1
- package/dist/check/index.js +3 -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-permissions.d.ts +14 -0
- package/dist/check/plugin-permissions.js +28 -3
- 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 +1 -1
- package/dist/check/run.js +4 -0
- package/dist/check/source.d.ts +7 -0
- package/dist/check/source.js +2 -0
- package/dist/marketplace/cli.d.ts +9 -7
- package/dist/marketplace/cli.js +19 -10
- package/dist/marketplace/marketplace.d.ts +38 -24
- package/dist/marketplace/marketplace.js +206 -60
- 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,26 +36,33 @@ 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
|
-
repository.url>, directory: <its dir> }`; `example-*` plugins are private
|
|
45
|
-
|
|
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
|
|
46
48
|
`@capybara-hq/testing`) in its devDependencies.
|
|
47
49
|
- **sync-versions**: after `changeset version` (which bumps only `package.json`), copies each
|
|
48
50
|
plugin's version into its `.capybara-plugin/plugin.json` and its extension manifest
|
|
49
51
|
(`src/index.ts`, ` version: "x"`), which the packer requires equal.
|
|
50
|
-
- **marketplace**: `--write`
|
|
51
|
-
|
|
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`,
|
|
52
55
|
`permissions`, `source.npm` with the sha512 of a reproducible pack), formatted as Biome formats
|
|
53
|
-
it
|
|
54
|
-
|
|
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
|
|
55
62
|
published, its bytes are the release gate's business. `--check --base <rev>` (pull requests)
|
|
56
|
-
also requires
|
|
57
|
-
(`marketplace-
|
|
58
|
-
(`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`).
|
|
59
66
|
- **release**: publishes every non-private workspace package whose version is not on npm yet:
|
|
60
67
|
plugins (the packages under `plugins/`) as their plugin tgz, libraries through `pnpm pack`, with
|
|
61
68
|
the dist-tag rule (prerelease `next`, backport `release-<major>.<minor>`, else `latest`), then
|
|
@@ -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,7 +1,9 @@
|
|
|
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";
|
package/dist/check/index.js
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
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";
|
|
@@ -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
|
+
}
|
|
@@ -37,3 +37,17 @@ export declare function checkPluginPermissions(pluginDirs: string[]): Finding[];
|
|
|
37
37
|
* Unparseable files are skipped here; the permissions check reports them.
|
|
38
38
|
*/
|
|
39
39
|
export declare function findToolRegistrations(pluginDirs: string[]): ToolRegistration[];
|
|
40
|
+
/** The `ctx.<area>.<method>` calls an extension's setup makes (see {@link findCtxCalls}). */
|
|
41
|
+
export interface CtxCalls {
|
|
42
|
+
/** `ctx.tools.register` is called. */
|
|
43
|
+
tools: boolean;
|
|
44
|
+
/** `ctx.ui.view` or `ctx.ui.action` is called. */
|
|
45
|
+
ui: boolean;
|
|
46
|
+
/** `ctx.hooks.on` is called. */
|
|
47
|
+
hooks: boolean;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Which of `ctx.tools.register`, `ctx.ui.view|action` and `ctx.hooks.on` the non-test sources
|
|
51
|
+
* under `pluginDir` call, found by the same scanner as {@link checkPluginPermissions}.
|
|
52
|
+
*/
|
|
53
|
+
export declare function findCtxCalls(pluginDir: string): CtxCalls;
|
|
@@ -166,7 +166,7 @@ function readManifest(obj, decls) {
|
|
|
166
166
|
}
|
|
167
167
|
return m;
|
|
168
168
|
}
|
|
169
|
-
function scanSetup(f, m, decls, onTool) {
|
|
169
|
+
function scanSetup(f, m, decls, onTool, onCall) {
|
|
170
170
|
const out = [];
|
|
171
171
|
/** 明确的违规:豁免注释不起作用。 */
|
|
172
172
|
const add = (at, rule, message) => {
|
|
@@ -240,6 +240,8 @@ function scanSetup(f, m, decls, onTool) {
|
|
|
240
240
|
if (obj.type !== "MemberExpression" || !isCtx(obj.object))
|
|
241
241
|
return;
|
|
242
242
|
const area = memberName(obj);
|
|
243
|
+
if (area !== undefined)
|
|
244
|
+
onCall?.(area, method);
|
|
243
245
|
if (area === "http" && method === "route") {
|
|
244
246
|
const verb = args[0] ? stringValue(resolve(args[0], decls)) : undefined;
|
|
245
247
|
const path = args[1] ? stringValue(resolve(args[1], decls)) : undefined;
|
|
@@ -346,7 +348,7 @@ function scanSetup(f, m, decls, onTool) {
|
|
|
346
348
|
}
|
|
347
349
|
return out;
|
|
348
350
|
}
|
|
349
|
-
function scanFile(f, onTool) {
|
|
351
|
+
function scanFile(f, onTool, onCall) {
|
|
350
352
|
const decls = buildDecls(f);
|
|
351
353
|
const out = [];
|
|
352
354
|
walk(f.program, (n) => {
|
|
@@ -359,7 +361,7 @@ function scanFile(f, onTool) {
|
|
|
359
361
|
const obj = arg ? resolve(arg, decls) : undefined;
|
|
360
362
|
if (obj?.type !== "ObjectExpression")
|
|
361
363
|
return;
|
|
362
|
-
out.push(...scanSetup(f, readManifest(obj, decls), decls, onTool));
|
|
364
|
+
out.push(...scanSetup(f, readManifest(obj, decls), decls, onTool, onCall));
|
|
363
365
|
});
|
|
364
366
|
return out;
|
|
365
367
|
}
|
|
@@ -417,3 +419,26 @@ export function findToolRegistrations(pluginDirs) {
|
|
|
417
419
|
}
|
|
418
420
|
return out.sort((a, b) => a.file.localeCompare(b.file) || a.line - b.line);
|
|
419
421
|
}
|
|
422
|
+
/**
|
|
423
|
+
* Which of `ctx.tools.register`, `ctx.ui.view|action` and `ctx.hooks.on` the non-test sources
|
|
424
|
+
* under `pluginDir` call, found by the same scanner as {@link checkPluginPermissions}.
|
|
425
|
+
*/
|
|
426
|
+
export function findCtxCalls(pluginDir) {
|
|
427
|
+
const out = { tools: false, ui: false, hooks: false };
|
|
428
|
+
for (const { file, isTest } of listSourceFiles(pluginDir)) {
|
|
429
|
+
if (isTest)
|
|
430
|
+
continue;
|
|
431
|
+
const parsed = parseFile(file);
|
|
432
|
+
if (isParseError(parsed))
|
|
433
|
+
continue;
|
|
434
|
+
scanFile(parsed, undefined, (area, method) => {
|
|
435
|
+
if (area === "tools" && method === "register")
|
|
436
|
+
out.tools = true;
|
|
437
|
+
else if (area === "ui" && (method === "view" || method === "action"))
|
|
438
|
+
out.ui = true;
|
|
439
|
+
else if (area === "hooks" && method === "on")
|
|
440
|
+
out.hooks = true;
|
|
441
|
+
});
|
|
442
|
+
}
|
|
443
|
+
return out;
|
|
444
|
+
}
|
|
@@ -9,5 +9,10 @@ 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.
|
|
14
|
+
* `release-description-zh` (a warning, never an error, so third-party marketplaces are not
|
|
15
|
+
* blocked): a published plugin's `description` has no `zh` (`marketplace --write` copies it into
|
|
16
|
+
* the marketplace `summary`, which the Chinese UI shows).
|
|
12
17
|
*/
|
|
13
18
|
export declare function checkPluginReleaseMetadata(plugins: string[], root: string): Finding[];
|
|
@@ -1,6 +1,29 @@
|
|
|
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
|
+
const DESCRIPTION_ZH_RULE = "release-description-zh";
|
|
7
|
+
/**
|
|
8
|
+
* The plugin's `.capybara-plugin/plugin.json` as an object; undefined when the file is missing or
|
|
9
|
+
* not a JSON object ("Plugin pack" reports those).
|
|
10
|
+
*/
|
|
11
|
+
function readManifest(file) {
|
|
12
|
+
if (!existsSync(file))
|
|
13
|
+
return undefined;
|
|
14
|
+
try {
|
|
15
|
+
const m = JSON.parse(readFileSync(file, "utf8").replace(/^\uFEFF/, ""));
|
|
16
|
+
return typeof m === "object" && m !== null && !Array.isArray(m)
|
|
17
|
+
? m
|
|
18
|
+
: undefined;
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
return undefined;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
/** A description without a `zh` text: a string, or an object without `zh`. */
|
|
25
|
+
const lacksZh = (d) => typeof d === "string" ||
|
|
26
|
+
(typeof d === "object" && d !== null && d.zh === undefined);
|
|
4
27
|
const readJson = (file) => JSON.parse(readFileSync(file, "utf8"));
|
|
5
28
|
/** `repository.url` of a package.json (`"repository": "<url>"` or `{ url }`). */
|
|
6
29
|
function repositoryUrl(pkg) {
|
|
@@ -22,6 +45,11 @@ function repositoryUrl(pkg) {
|
|
|
22
45
|
* has no `directory`. The `example-*` plugins must be private. The plugin tgz carries these fields
|
|
23
46
|
* (`METADATA_FIELDS` of the packer); the libraries' half of the rule is core's
|
|
24
47
|
* `check:release-metadata`.
|
|
48
|
+
* `release-display-name`: a published plugin's `.capybara-plugin/plugin.json` needs a
|
|
49
|
+
* `displayName` (`{ zh, en }`), the name every page shows instead of the package name.
|
|
50
|
+
* `release-description-zh` (a warning, never an error, so third-party marketplaces are not
|
|
51
|
+
* blocked): a published plugin's `description` has no `zh` (`marketplace --write` copies it into
|
|
52
|
+
* the marketplace `summary`, which the Chinese UI shows).
|
|
25
53
|
*/
|
|
26
54
|
export function checkPluginReleaseMetadata(plugins, root) {
|
|
27
55
|
const out = [];
|
|
@@ -47,6 +75,25 @@ export function checkPluginReleaseMetadata(plugins, root) {
|
|
|
47
75
|
}
|
|
48
76
|
if (typeof pkg.license !== "string")
|
|
49
77
|
finding(`${label} is published and needs a "license"`);
|
|
78
|
+
const manifestFile = join(dir, PLUGIN_MANIFEST_FILE);
|
|
79
|
+
const manifest = readManifest(manifestFile);
|
|
80
|
+
if (manifest !== undefined && manifest.displayName === undefined) {
|
|
81
|
+
out.push({
|
|
82
|
+
file: manifestFile,
|
|
83
|
+
line: 1,
|
|
84
|
+
rule: DISPLAY_NAME_RULE,
|
|
85
|
+
message: `${label} is published and needs a "displayName" in ${PLUGIN_MANIFEST_FILE}: { "zh": …, "en": … }, the name pages show`,
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
if (manifest !== undefined && lacksZh(manifest.description)) {
|
|
89
|
+
out.push({
|
|
90
|
+
file: manifestFile,
|
|
91
|
+
line: 1,
|
|
92
|
+
rule: DESCRIPTION_ZH_RULE,
|
|
93
|
+
severity: "warning",
|
|
94
|
+
message: `${label} is published and its description has no zh: write "description": { "en": …, "zh": … } in ${PLUGIN_MANIFEST_FILE} (the marketplace summary the Chinese UI shows)`,
|
|
95
|
+
});
|
|
96
|
+
}
|
|
50
97
|
if (typeof pkg.homepage !== "string" || !pkg.homepage.startsWith("https://")) {
|
|
51
98
|
finding(`${label} is published and needs an https "homepage"`);
|
|
52
99
|
}
|
package/dist/check/report.d.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { type Finding } from "./source.js";
|
|
2
2
|
/** One finding as a line: `<file relative to root>:<line> <rule> <message>`. */
|
|
3
3
|
export declare const formatFinding: (f: Finding, root: string) => string;
|
|
4
4
|
/**
|
|
5
|
-
* Prints the result of one check: `[name] passed
|
|
6
|
-
*
|
|
5
|
+
* Prints the result of one check: `[name] passed` (`passed with N warning(s)` and one line per
|
|
6
|
+
* warning when it has only warnings), or the finding count and one line per finding (stderr).
|
|
7
|
+
* Returns whether it passed (warnings do not fail it).
|
|
7
8
|
*/
|
|
8
9
|
export declare function printCheck(name: string, findings: Finding[], root: string): boolean;
|