@capybara-hq/plugin-tools 0.1.0-rc.4 → 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 +15 -8
- package/dist/check/index.d.ts +1 -0
- package/dist/check/index.js +1 -0
- 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 +3 -0
- package/dist/check/release-metadata.js +26 -9
- package/dist/check/report.d.ts +4 -3
- package/dist/check/report.js +13 -3
- package/dist/check/run.js +2 -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/package.json +4 -4
package/README.md
CHANGED
|
@@ -41,21 +41,28 @@ capybara-plugin release [--dry-run] [--provenance] [--out <dir>] [--auto] [--roo
|
|
|
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
|
|
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
|
|
45
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
|
package/dist/check/index.d.ts
CHANGED
|
@@ -3,6 +3,7 @@ export { CHECK_USAGE, checkCommand } from "./cli.js";
|
|
|
3
3
|
export { checkConfigForms } from "./config-forms.js";
|
|
4
4
|
export { checkDeps } from "./deps.js";
|
|
5
5
|
export { checkMigrations, checkSql } from "./migrations.js";
|
|
6
|
+
export { checkOrgOverride } from "./org-override.js";
|
|
6
7
|
export { checkPluginPack, PLUGIN_PACK_MAX_BYTES } from "./plugin-pack.js";
|
|
7
8
|
export { checkPluginPermissions } from "./plugin-permissions.js";
|
|
8
9
|
export { checkPluginState } from "./plugin-state.js";
|
package/dist/check/index.js
CHANGED
|
@@ -3,6 +3,7 @@ export { CHECK_USAGE, checkCommand } from "./cli.js";
|
|
|
3
3
|
export { checkConfigForms } from "./config-forms.js";
|
|
4
4
|
export { checkDeps } from "./deps.js";
|
|
5
5
|
export { checkMigrations, checkSql } from "./migrations.js";
|
|
6
|
+
export { checkOrgOverride } from "./org-override.js";
|
|
6
7
|
export { checkPluginPack, PLUGIN_PACK_MAX_BYTES } from "./plugin-pack.js";
|
|
7
8
|
export { checkPluginPermissions } from "./plugin-permissions.js";
|
|
8
9
|
export { checkPluginState } from "./plugin-state.js";
|
|
@@ -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
|
+
}
|
|
@@ -11,5 +11,8 @@ import type { Finding } from "./source.js";
|
|
|
11
11
|
* `check:release-metadata`.
|
|
12
12
|
* `release-display-name`: a published plugin's `.capybara-plugin/plugin.json` needs a
|
|
13
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).
|
|
14
17
|
*/
|
|
15
18
|
export declare function checkPluginReleaseMetadata(plugins: string[], root: string): Finding[];
|
|
@@ -3,23 +3,27 @@ import { basename, join, relative } from "node:path";
|
|
|
3
3
|
import { PLUGIN_MANIFEST_FILE } from "@capybara-hq/plugin-sdk";
|
|
4
4
|
const RULE = "plugin-release-metadata";
|
|
5
5
|
const DISPLAY_NAME_RULE = "release-display-name";
|
|
6
|
+
const DESCRIPTION_ZH_RULE = "release-description-zh";
|
|
6
7
|
/**
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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).
|
|
9
10
|
*/
|
|
10
|
-
function
|
|
11
|
+
function readManifest(file) {
|
|
11
12
|
if (!existsSync(file))
|
|
12
|
-
return
|
|
13
|
+
return undefined;
|
|
13
14
|
try {
|
|
14
15
|
const m = JSON.parse(readFileSync(file, "utf8").replace(/^\uFEFF/, ""));
|
|
15
|
-
return
|
|
16
|
-
m
|
|
17
|
-
|
|
16
|
+
return typeof m === "object" && m !== null && !Array.isArray(m)
|
|
17
|
+
? m
|
|
18
|
+
: undefined;
|
|
18
19
|
}
|
|
19
20
|
catch {
|
|
20
|
-
return
|
|
21
|
+
return undefined;
|
|
21
22
|
}
|
|
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);
|
|
23
27
|
const readJson = (file) => JSON.parse(readFileSync(file, "utf8"));
|
|
24
28
|
/** `repository.url` of a package.json (`"repository": "<url>"` or `{ url }`). */
|
|
25
29
|
function repositoryUrl(pkg) {
|
|
@@ -43,6 +47,9 @@ function repositoryUrl(pkg) {
|
|
|
43
47
|
* `check:release-metadata`.
|
|
44
48
|
* `release-display-name`: a published plugin's `.capybara-plugin/plugin.json` needs a
|
|
45
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).
|
|
46
53
|
*/
|
|
47
54
|
export function checkPluginReleaseMetadata(plugins, root) {
|
|
48
55
|
const out = [];
|
|
@@ -69,7 +76,8 @@ export function checkPluginReleaseMetadata(plugins, root) {
|
|
|
69
76
|
if (typeof pkg.license !== "string")
|
|
70
77
|
finding(`${label} is published and needs a "license"`);
|
|
71
78
|
const manifestFile = join(dir, PLUGIN_MANIFEST_FILE);
|
|
72
|
-
|
|
79
|
+
const manifest = readManifest(manifestFile);
|
|
80
|
+
if (manifest !== undefined && manifest.displayName === undefined) {
|
|
73
81
|
out.push({
|
|
74
82
|
file: manifestFile,
|
|
75
83
|
line: 1,
|
|
@@ -77,6 +85,15 @@ export function checkPluginReleaseMetadata(plugins, root) {
|
|
|
77
85
|
message: `${label} is published and needs a "displayName" in ${PLUGIN_MANIFEST_FILE}: { "zh": …, "en": … }, the name pages show`,
|
|
78
86
|
});
|
|
79
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
|
+
}
|
|
80
97
|
if (typeof pkg.homepage !== "string" || !pkg.homepage.startsWith("https://")) {
|
|
81
98
|
finding(`${label} is published and needs an https "homepage"`);
|
|
82
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;
|
package/dist/check/report.js
CHANGED
|
@@ -1,16 +1,26 @@
|
|
|
1
1
|
import { relative } from "node:path";
|
|
2
|
+
import { errorsOf } from "./source.js";
|
|
2
3
|
/** One finding as a line: `<file relative to root>:<line> <rule> <message>`. */
|
|
3
4
|
export const formatFinding = (f, root) => `${relative(root, f.file) || f.file}:${f.line} ${f.rule} ${f.message}`;
|
|
4
5
|
/**
|
|
5
|
-
* Prints the result of one check: `[name] passed
|
|
6
|
-
*
|
|
6
|
+
* Prints the result of one check: `[name] passed` (`passed with N warning(s)` and one line per
|
|
7
|
+
* warning when it has only warnings), or the finding count and one line per finding (stderr).
|
|
8
|
+
* Returns whether it passed (warnings do not fail it).
|
|
7
9
|
*/
|
|
8
10
|
export function printCheck(name, findings, root) {
|
|
11
|
+
const errors = errorsOf(findings);
|
|
9
12
|
if (findings.length === 0) {
|
|
10
13
|
console.log(`[${name}] passed`);
|
|
11
14
|
return true;
|
|
12
15
|
}
|
|
13
|
-
|
|
16
|
+
if (errors.length === 0) {
|
|
17
|
+
console.error(`[${name}] passed with ${findings.length} warning(s)`);
|
|
18
|
+
for (const f of findings)
|
|
19
|
+
console.error(` ${formatFinding(f, root)}`);
|
|
20
|
+
return true;
|
|
21
|
+
}
|
|
22
|
+
const warnings = findings.length - errors.length;
|
|
23
|
+
console.error(`[${name}] ${errors.length} finding(s)${warnings > 0 ? ` and ${warnings} warning(s)` : ""}`);
|
|
14
24
|
for (const f of findings)
|
|
15
25
|
console.error(` ${formatFinding(f, root)}`);
|
|
16
26
|
return false;
|
package/dist/check/run.js
CHANGED
|
@@ -2,6 +2,7 @@ import { join } from "node:path";
|
|
|
2
2
|
import { checkConfigForms } from "./config-forms.js";
|
|
3
3
|
import { checkDeps } from "./deps.js";
|
|
4
4
|
import { checkMigrations } from "./migrations.js";
|
|
5
|
+
import { checkOrgOverride } from "./org-override.js";
|
|
5
6
|
import { checkPluginPack } from "./plugin-pack.js";
|
|
6
7
|
import { checkPluginPermissions } from "./plugin-permissions.js";
|
|
7
8
|
import { checkPluginState } from "./plugin-state.js";
|
|
@@ -29,6 +30,7 @@ export function pluginChecks(scope) {
|
|
|
29
30
|
{ name: "Extension permissions", run: () => checkPluginPermissions(plugins) },
|
|
30
31
|
{ name: "Plugin pack", run: () => checkPluginPack(plugins) },
|
|
31
32
|
{ name: "Config forms", run: () => checkConfigForms(plugins) },
|
|
33
|
+
{ name: "Org override", run: () => checkOrgOverride(plugins) },
|
|
32
34
|
{
|
|
33
35
|
name: "Tool design",
|
|
34
36
|
run: () => checkToolDesign([...(scope.toolDesignDirs ?? []), ...plugins]),
|
package/dist/check/source.d.ts
CHANGED
|
@@ -1,10 +1,17 @@
|
|
|
1
1
|
import { type Comment } from "oxc-parser";
|
|
2
|
+
/**
|
|
3
|
+
* One finding of a check. `severity: "warning"` is printed but does not fail the check (only a
|
|
4
|
+
* few rules warn, e.g. `release-description-zh`); without it the finding is an error.
|
|
5
|
+
*/
|
|
2
6
|
export type Finding = {
|
|
3
7
|
file: string;
|
|
4
8
|
line: number;
|
|
5
9
|
rule: string;
|
|
6
10
|
message: string;
|
|
11
|
+
severity?: "warning";
|
|
7
12
|
};
|
|
13
|
+
/** The findings that fail a check (every finding that is not a warning). */
|
|
14
|
+
export declare const errorsOf: (findings: Finding[]) => Finding[];
|
|
8
15
|
/** ESTree 节点(oxc-parser 的输出)。只声明本检查用到的公共字段。 */
|
|
9
16
|
export interface Node {
|
|
10
17
|
type: string;
|
package/dist/check/source.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { parseSync } from "oxc-parser";
|
|
4
|
+
/** The findings that fail a check (every finding that is not a warning). */
|
|
5
|
+
export const errorsOf = (findings) => findings.filter((f) => f.severity !== "warning");
|
|
4
6
|
const SOURCE_EXT = /\.(?:ts|tsx|mts|cts|js|jsx|mjs|cjs)$/;
|
|
5
7
|
const TEST_FILE = /\.(?:test|spec)\.[cm]?[jt]sx?$/;
|
|
6
8
|
/** 只在插件根目录下才有特殊含义的目录名(更深处同名目录是普通源码目录,必须扫描)。 */
|
|
@@ -1,20 +1,22 @@
|
|
|
1
1
|
export declare const MARKETPLACE_USAGE: string;
|
|
2
2
|
/**
|
|
3
3
|
* `capybara-plugin marketplace`: writes (`--write`) or checks (`--check`) the repository's
|
|
4
|
-
* marketplace manifest
|
|
4
|
+
* marketplace manifest, one entry per public plugin at its current version, against reproducible
|
|
5
|
+
* packs of its public plugins ({@link syncMarketplace}).
|
|
5
6
|
*
|
|
6
7
|
* - `--root <dir>`: the repository (default: the current directory);
|
|
7
8
|
* - `--file <path>`: the manifest, relative to the root (default `.capybara-plugin/marketplace.json`);
|
|
8
9
|
* - `--plugins <glob>`: the plugin directories, relative to the root (default `plugins/*`, or the
|
|
9
10
|
* root itself when it is a plugin).
|
|
10
11
|
*
|
|
11
|
-
* - `--base <rev>` (with `--check`, on pull requests):
|
|
12
|
-
*
|
|
13
|
-
* match the tree's packs (`marketplace-integrity`).
|
|
12
|
+
* - `--base <rev>` (with `--check`, on pull requests): an entry's version may only increase over the
|
|
13
|
+
* versions `<rev>` lists (`marketplace-version-decrease`), a version `<rev>` lists keeps its
|
|
14
|
+
* integrity, and the entries added since must match the tree's packs (`marketplace-integrity`).
|
|
14
15
|
*
|
|
15
|
-
* `--check` exits 1 on any finding (`marketplace-missing`,
|
|
16
|
-
* `marketplace-
|
|
17
|
-
*
|
|
16
|
+
* `--check` exits 1 on any finding that is not a warning (`marketplace-missing`,
|
|
17
|
+
* `marketplace-display-stale`, `marketplace-integrity`, `marketplace-version-decrease`,
|
|
18
|
+
* `marketplace-invalid`); `--write` writes each plugin's entry (replacing its other versions,
|
|
19
|
+
* refreshing a listed entry's summary and displayName) and exits 1 only on `marketplace-invalid`.
|
|
18
20
|
* Relative paths resolve against `cwd`. Returns the exit code.
|
|
19
21
|
*/
|
|
20
22
|
export declare function marketplaceCommand(args: string[], cwd: string): Promise<number>;
|
package/dist/marketplace/cli.js
CHANGED
|
@@ -1,25 +1,28 @@
|
|
|
1
1
|
import { resolve } from "node:path";
|
|
2
2
|
import { parseArgs } from "node:util";
|
|
3
3
|
import { formatFinding } from "../check/report.js";
|
|
4
|
+
import { errorsOf } from "../check/source.js";
|
|
4
5
|
import { MARKETPLACE_FILE, syncMarketplace } from "./marketplace.js";
|
|
5
6
|
export const MARKETPLACE_USAGE = "capybara-plugin marketplace --write|--check [--base <rev>] [--root <dir>] [--file <path>]\n" +
|
|
6
7
|
" [--plugins <glob>]";
|
|
7
8
|
/**
|
|
8
9
|
* `capybara-plugin marketplace`: writes (`--write`) or checks (`--check`) the repository's
|
|
9
|
-
* marketplace manifest
|
|
10
|
+
* marketplace manifest, one entry per public plugin at its current version, against reproducible
|
|
11
|
+
* packs of its public plugins ({@link syncMarketplace}).
|
|
10
12
|
*
|
|
11
13
|
* - `--root <dir>`: the repository (default: the current directory);
|
|
12
14
|
* - `--file <path>`: the manifest, relative to the root (default `.capybara-plugin/marketplace.json`);
|
|
13
15
|
* - `--plugins <glob>`: the plugin directories, relative to the root (default `plugins/*`, or the
|
|
14
16
|
* root itself when it is a plugin).
|
|
15
17
|
*
|
|
16
|
-
* - `--base <rev>` (with `--check`, on pull requests):
|
|
17
|
-
*
|
|
18
|
-
* match the tree's packs (`marketplace-integrity`).
|
|
18
|
+
* - `--base <rev>` (with `--check`, on pull requests): an entry's version may only increase over the
|
|
19
|
+
* versions `<rev>` lists (`marketplace-version-decrease`), a version `<rev>` lists keeps its
|
|
20
|
+
* integrity, and the entries added since must match the tree's packs (`marketplace-integrity`).
|
|
19
21
|
*
|
|
20
|
-
* `--check` exits 1 on any finding (`marketplace-missing`,
|
|
21
|
-
* `marketplace-
|
|
22
|
-
*
|
|
22
|
+
* `--check` exits 1 on any finding that is not a warning (`marketplace-missing`,
|
|
23
|
+
* `marketplace-display-stale`, `marketplace-integrity`, `marketplace-version-decrease`,
|
|
24
|
+
* `marketplace-invalid`); `--write` writes each plugin's entry (replacing its other versions,
|
|
25
|
+
* refreshing a listed entry's summary and displayName) and exits 1 only on `marketplace-invalid`.
|
|
23
26
|
* Relative paths resolve against `cwd`. Returns the exit code.
|
|
24
27
|
*/
|
|
25
28
|
export async function marketplaceCommand(args, cwd) {
|
|
@@ -59,14 +62,20 @@ export async function marketplaceCommand(args, cwd) {
|
|
|
59
62
|
});
|
|
60
63
|
for (const f of r.findings)
|
|
61
64
|
console.error(formatFinding(f, root));
|
|
62
|
-
|
|
63
|
-
|
|
65
|
+
const errors = errorsOf(r.findings);
|
|
66
|
+
if (errors.length > 0) {
|
|
67
|
+
console.error(`\n${errors.length} marketplace finding(s)`);
|
|
64
68
|
return 1;
|
|
65
69
|
}
|
|
66
70
|
if (values.write) {
|
|
67
71
|
for (const e of r.added)
|
|
68
72
|
console.log(`added ${e.name}@${e.version}`);
|
|
69
|
-
|
|
73
|
+
for (const k of r.removed)
|
|
74
|
+
console.log(`removed ${k}`);
|
|
75
|
+
for (const k of r.refreshed)
|
|
76
|
+
console.log(`refreshed ${k}`);
|
|
77
|
+
const changed = r.added.length + r.removed.length + r.refreshed.length > 0;
|
|
78
|
+
console.log(changed ? `wrote ${file}` : "marketplace is up to date");
|
|
70
79
|
}
|
|
71
80
|
else {
|
|
72
81
|
console.log("marketplace is up to date");
|
|
@@ -9,44 +9,58 @@ export interface SyncMarketplaceOptions {
|
|
|
9
9
|
file: string;
|
|
10
10
|
/** Plugin directories relative to `root` (`*` within one segment); default as `discoverPlugins`. */
|
|
11
11
|
plugins?: string;
|
|
12
|
-
/**
|
|
12
|
+
/** Write the plugins' entries into `file` (`--write`); otherwise only report them (`--check`). */
|
|
13
13
|
write: boolean;
|
|
14
14
|
/**
|
|
15
15
|
* A git revision (`--check --base <rev>`, pull requests): the entries the file had there are
|
|
16
|
-
* published
|
|
17
|
-
*
|
|
18
|
-
* (`marketplace-
|
|
16
|
+
* published. An entry of a version the base lists must keep its source and integrity
|
|
17
|
+
* (`marketplace-integrity`); an entry of a version the base does not list must be newer than
|
|
18
|
+
* every version the base lists for that plugin (`marketplace-version-decrease`) and, when it is
|
|
19
|
+
* the tree's version, match the tree's pack (`marketplace-integrity`). A file the base does not
|
|
20
|
+
* have constrains nothing.
|
|
19
21
|
*/
|
|
20
22
|
base?: string;
|
|
21
23
|
}
|
|
22
24
|
/** The result of {@link syncMarketplace}. */
|
|
23
25
|
export interface SyncMarketplaceResult {
|
|
24
|
-
/**
|
|
26
|
+
/** New entries: plugins not listed at their current version (written with `write`). */
|
|
25
27
|
added: MarketplaceEntry[];
|
|
28
|
+
/** `name@version` of the other entries of the tree's plugins (dropped with `write`). */
|
|
29
|
+
removed: string[];
|
|
26
30
|
/**
|
|
27
|
-
* `
|
|
28
|
-
*
|
|
29
|
-
|
|
30
|
-
|
|
31
|
+
* `name@version` of listed entries whose `summary` / `displayName` differ from plugin.json
|
|
32
|
+
* (rewritten with `write`; source and integrity never change).
|
|
33
|
+
*/
|
|
34
|
+
refreshed: string[];
|
|
35
|
+
/**
|
|
36
|
+
* `marketplace-invalid` (the file does not parse, or would not after writing),
|
|
37
|
+
* `marketplace-integrity` and `marketplace-version-decrease` (with a base, see
|
|
38
|
+
* {@link SyncMarketplaceOptions.base}) and, without `write`, `marketplace-missing` (one per
|
|
39
|
+
* plugin whose current version is not listed, or not as its only entry) and
|
|
40
|
+
* `marketplace-display-stale` (a listed entry's summary / displayName differ from plugin.json).
|
|
31
41
|
*/
|
|
32
42
|
findings: Finding[];
|
|
33
43
|
}
|
|
34
44
|
/**
|
|
35
|
-
* `capybara-plugin marketplace`: keeps a plugin repository's `marketplace.json` listing
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
* entry `{ name, version, summary, homepage?, permissions, source: { npm: {
|
|
40
|
-
* integrity } } }`
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
* Entries of
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
45
|
+
* `capybara-plugin marketplace`: keeps a plugin repository's `marketplace.json` listing one entry
|
|
46
|
+
* per public plugin, its current version (plugin component management spec §13.2, replacing the
|
|
47
|
+
* one-entry-per-version rule of the package ecosystem spec §5.2). Each public plugin (not
|
|
48
|
+
* `"private": true`) not listed at its version is packed reproducibly with {@link packPlugin} and
|
|
49
|
+
* gets an entry `{ name, version, summary, displayName?, homepage?, permissions, source: { npm: {
|
|
50
|
+
* package, version, integrity } } }` (`summary` from the plugin.json `description`, a string being
|
|
51
|
+
* `en`; `displayName` copied from plugin.json; `integrity` the pack's sha512, which the release
|
|
52
|
+
* then publishes byte for byte; `permissions` display only). The new entry takes the place of the
|
|
53
|
+
* plugin's first entry (a version that moved), the plugin's other entries are dropped (also those
|
|
54
|
+
* of a file written in the older one-entry-per-version form), and a plugin not listed yet is
|
|
55
|
+
* appended. Entries of plugins not in the tree are kept. A version already listed is not packed or
|
|
56
|
+
* compared: once published, its bytes are the release gate's business (`release --marketplace`),
|
|
57
|
+
* so a later source or toolchain change at the same version is not a finding; only its display
|
|
58
|
+
* fields (`summary`, `displayName`) are refreshed from the source plugin.json. Every other field
|
|
59
|
+
* of a kept entry stays as written, among them `deprecated`, which the maintainer sets by hand on
|
|
60
|
+
* a published version's entry (not in plugin.json: that would change the packed bytes); a new
|
|
61
|
+
* version's entry has none, and no rule compares it with the base. With `base`, see
|
|
62
|
+
* {@link SyncMarketplaceOptions.base}. The file is written formatted as Biome formats it
|
|
63
|
+
* ({@link formatMarketplaceJson}); nothing is written when there is any other finding.
|
|
50
64
|
*/
|
|
51
65
|
export declare function syncMarketplace(opts: SyncMarketplaceOptions): Promise<SyncMarketplaceResult>;
|
|
52
66
|
/**
|
|
@@ -4,16 +4,22 @@ import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
|
|
4
4
|
import { tmpdir } from "node:os";
|
|
5
5
|
import { join, relative } from "node:path";
|
|
6
6
|
import { isSensitivePermissionKey, PACKAGES_MAX_BYTES_DEFAULT, permissionKeys, permissionSetOfPackage, readExtensionPackage, WARNINGS_KEY_PREFIX, } from "@capybara-hq/kernel";
|
|
7
|
-
import { MARKETPLACE_LIMITS, PLUGIN_MANIFEST_FILE, parseMarketplaceManifest, SDK_VERSION, } from "@capybara-hq/plugin-sdk";
|
|
7
|
+
import { MARKETPLACE_LIMITS, PLUGIN_MANIFEST_FILE, parseMarketplaceManifest, parsePluginManifest, pluginDescription, SDK_VERSION, } from "@capybara-hq/plugin-sdk";
|
|
8
8
|
import { discoverPlugins } from "../discover.js";
|
|
9
9
|
import { integrityOf } from "../integrity.js";
|
|
10
10
|
import { packPlugin } from "../pack/pack.js";
|
|
11
|
+
import { compareSemver, parseSemver } from "../release/semver.js";
|
|
11
12
|
export { MARKETPLACE_FILE } from "./file.js";
|
|
12
13
|
const isObj = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
13
|
-
/** The marketplace summary
|
|
14
|
-
function
|
|
14
|
+
/** The marketplace summary text: a description cut to the summary limit. */
|
|
15
|
+
function cut(text) {
|
|
15
16
|
const max = MARKETPLACE_LIMITS.summaryChars;
|
|
16
|
-
return
|
|
17
|
+
return text.length <= max ? text : `${text.slice(0, max - 1)}…`;
|
|
18
|
+
}
|
|
19
|
+
/** The marketplace summary of a plugin: its plugin.json description (`{ en, zh? }`), each cut. */
|
|
20
|
+
function summaryOf(description) {
|
|
21
|
+
const d = pluginDescription(description);
|
|
22
|
+
return { en: cut(d.en), ...(d.zh !== undefined && { zh: cut(d.zh) }) };
|
|
17
23
|
}
|
|
18
24
|
/** The permission summary of a packed plugin, as the kernel computes it at import (display only). */
|
|
19
25
|
async function permissionsOf(bytes, hasExtension) {
|
|
@@ -30,29 +36,70 @@ async function permissionsOf(bytes, hasExtension) {
|
|
|
30
36
|
}
|
|
31
37
|
/** An entry's version: `version`, else its npm source's. */
|
|
32
38
|
const versionOf = (e) => e.version ?? ("npm" in e.source ? e.source.npm.version : undefined);
|
|
39
|
+
/** The display fields of a plugin's source plugin.json; undefined when it does not parse. */
|
|
40
|
+
function displayOf(json) {
|
|
41
|
+
const m = parsePluginManifest(json);
|
|
42
|
+
if ("errors" in m)
|
|
43
|
+
return undefined;
|
|
44
|
+
return {
|
|
45
|
+
summary: summaryOf(m.description),
|
|
46
|
+
...(m.displayName !== undefined && { displayName: m.displayName }),
|
|
47
|
+
};
|
|
48
|
+
}
|
|
33
49
|
/**
|
|
34
|
-
* `
|
|
35
|
-
*
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
50
|
+
* `raw` with its `summary` and `displayName` set to `d` (in place, `displayName` right after
|
|
51
|
+
* `summary` when it is new, removed when plugin.json has none); undefined when they already equal.
|
|
52
|
+
*/
|
|
53
|
+
function refreshDisplay(raw, d) {
|
|
54
|
+
if (!isObj(raw))
|
|
55
|
+
return undefined;
|
|
56
|
+
if (canonical(raw.summary) === canonical(d.summary) &&
|
|
57
|
+
canonical(raw.displayName) === canonical(d.displayName)) {
|
|
58
|
+
return undefined;
|
|
59
|
+
}
|
|
60
|
+
const out = {};
|
|
61
|
+
for (const [k, v] of Object.entries(raw)) {
|
|
62
|
+
if (k === "displayName")
|
|
63
|
+
continue;
|
|
64
|
+
out[k] = k === "summary" ? d.summary : v;
|
|
65
|
+
if (k === "summary" && d.displayName !== undefined)
|
|
66
|
+
out.displayName = d.displayName;
|
|
67
|
+
}
|
|
68
|
+
if (!("summary" in raw)) {
|
|
69
|
+
out.summary = d.summary;
|
|
70
|
+
if (d.displayName !== undefined)
|
|
71
|
+
out.displayName = d.displayName;
|
|
72
|
+
}
|
|
73
|
+
return out;
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* `capybara-plugin marketplace`: keeps a plugin repository's `marketplace.json` listing one entry
|
|
77
|
+
* per public plugin, its current version (plugin component management spec §13.2, replacing the
|
|
78
|
+
* one-entry-per-version rule of the package ecosystem spec §5.2). Each public plugin (not
|
|
79
|
+
* `"private": true`) not listed at its version is packed reproducibly with {@link packPlugin} and
|
|
80
|
+
* gets an entry `{ name, version, summary, displayName?, homepage?, permissions, source: { npm: {
|
|
81
|
+
* package, version, integrity } } }` (`summary` from the plugin.json `description`, a string being
|
|
82
|
+
* `en`; `displayName` copied from plugin.json; `integrity` the pack's sha512, which the release
|
|
83
|
+
* then publishes byte for byte; `permissions` display only). The new entry takes the place of the
|
|
84
|
+
* plugin's first entry (a version that moved), the plugin's other entries are dropped (also those
|
|
85
|
+
* of a file written in the older one-entry-per-version form), and a plugin not listed yet is
|
|
86
|
+
* appended. Entries of plugins not in the tree are kept. A version already listed is not packed or
|
|
87
|
+
* compared: once published, its bytes are the release gate's business (`release --marketplace`),
|
|
88
|
+
* so a later source or toolchain change at the same version is not a finding; only its display
|
|
89
|
+
* fields (`summary`, `displayName`) are refreshed from the source plugin.json. Every other field
|
|
90
|
+
* of a kept entry stays as written, among them `deprecated`, which the maintainer sets by hand on
|
|
91
|
+
* a published version's entry (not in plugin.json: that would change the packed bytes); a new
|
|
92
|
+
* version's entry has none, and no rule compares it with the base. With `base`, see
|
|
93
|
+
* {@link SyncMarketplaceOptions.base}. The file is written formatted as Biome formats it
|
|
94
|
+
* ({@link formatMarketplaceJson}); nothing is written when there is any other finding.
|
|
49
95
|
*/
|
|
50
96
|
export async function syncMarketplace(opts) {
|
|
51
97
|
const findings = [];
|
|
52
98
|
const at = (rule, message) => findings.push({ file: opts.file, line: 1, rule, message });
|
|
99
|
+
const none = () => ({ added: [], removed: [], refreshed: [], findings });
|
|
53
100
|
if (!existsSync(opts.file)) {
|
|
54
101
|
at("marketplace-invalid", `${opts.file} does not exist: create it with schemaVersion, name, owner and "plugins": []`);
|
|
55
|
-
return
|
|
102
|
+
return none();
|
|
56
103
|
}
|
|
57
104
|
let raw;
|
|
58
105
|
try {
|
|
@@ -60,22 +107,23 @@ export async function syncMarketplace(opts) {
|
|
|
60
107
|
}
|
|
61
108
|
catch (e) {
|
|
62
109
|
at("marketplace-invalid", `not valid JSON: ${e instanceof Error ? e.message : String(e)}`);
|
|
63
|
-
return
|
|
110
|
+
return none();
|
|
64
111
|
}
|
|
65
112
|
const parsed = parseMarketplaceManifest(raw);
|
|
66
113
|
if ("errors" in parsed) {
|
|
67
114
|
at("marketplace-invalid", parsed.errors.join("; "));
|
|
68
|
-
return
|
|
115
|
+
return none();
|
|
69
116
|
}
|
|
117
|
+
// The parser keeps the order, so parsed.plugins[i] is rawEntries[i].
|
|
70
118
|
const rawEntries = isObj(raw) && Array.isArray(raw.plugins) ? raw.plugins : [];
|
|
71
|
-
/** `name@version` of the entries
|
|
72
|
-
let
|
|
119
|
+
/** `name@version` of the entries the base lists (undefined without a base). */
|
|
120
|
+
let baseKeys;
|
|
73
121
|
if (opts.base !== undefined) {
|
|
74
122
|
const baseEntries = entriesAt(opts.root, opts.file, opts.base);
|
|
75
|
-
findings.push(...
|
|
76
|
-
|
|
123
|
+
findings.push(...publishedRules(baseEntries, rawEntries, opts.file));
|
|
124
|
+
baseKeys = new Set(baseEntries.map(keyOfRaw));
|
|
77
125
|
}
|
|
78
|
-
const
|
|
126
|
+
const updates = [];
|
|
79
127
|
const out = await mkdtemp(join(tmpdir(), "capybara-marketplace-"));
|
|
80
128
|
try {
|
|
81
129
|
for (const dir of discoverPlugins(opts.root, opts.plugins)) {
|
|
@@ -87,15 +135,33 @@ export async function syncMarketplace(opts) {
|
|
|
87
135
|
if (pkg.private === true)
|
|
88
136
|
continue;
|
|
89
137
|
const source = JSON.parse(await readFile(manifestFile, "utf8"));
|
|
138
|
+
// (the display fields of a listed entry are refreshed from this source plugin.json)
|
|
90
139
|
const name = String(source.name);
|
|
91
140
|
const version = String(pkg.version);
|
|
92
|
-
const
|
|
141
|
+
const mine = parsed.plugins.flatMap((e, i) => (e.name === name ? [{ e, i }] : []));
|
|
142
|
+
const listed = mine.filter(({ e }) => versionOf(e) === version);
|
|
143
|
+
const dropped = mine
|
|
144
|
+
.filter(({ e }) => versionOf(e) !== version)
|
|
145
|
+
.map(({ e }) => `${name}@${versionOf(e) ?? "(no version)"}`);
|
|
93
146
|
// A version already listed is published (or about to be): its bytes are the release gate's
|
|
94
147
|
// business, so a later source or toolchain change at the same version is not a finding here.
|
|
95
148
|
// Only an entry added since the base is compared with the tree's pack.
|
|
96
|
-
const toCompare = listed.filter((e) =>
|
|
97
|
-
|
|
149
|
+
const toCompare = listed.filter(({ e }) => baseKeys !== undefined && !baseKeys.has(keyOf(e)));
|
|
150
|
+
const kept = listed[0];
|
|
151
|
+
/** Keeps the listed entry: drops the other versions, refreshes its display fields. */
|
|
152
|
+
const keep = (raw) => {
|
|
153
|
+
const display = displayOf(source);
|
|
154
|
+
const fresh = display === undefined ? undefined : refreshDisplay(raw, display);
|
|
155
|
+
if (dropped.length > 0 || fresh !== undefined) {
|
|
156
|
+
const entry = fresh ?? raw;
|
|
157
|
+
updates.push({ name, version, entry, isNew: false, dropped, refreshed: !!fresh });
|
|
158
|
+
}
|
|
159
|
+
};
|
|
160
|
+
if (kept !== undefined && toCompare.length === 0) {
|
|
161
|
+
keep(rawEntries[kept.i]);
|
|
98
162
|
continue;
|
|
163
|
+
}
|
|
164
|
+
// Packed: the plugin is not listed at its version, or its entry is new since the base.
|
|
99
165
|
const r = await packPlugin(dir, out);
|
|
100
166
|
const bytes = new Uint8Array(await readFile(r.file));
|
|
101
167
|
const npm = {
|
|
@@ -103,7 +169,7 @@ export async function syncMarketplace(opts) {
|
|
|
103
169
|
version: r.manifest.version,
|
|
104
170
|
integrity: integrityOf(bytes),
|
|
105
171
|
};
|
|
106
|
-
for (const e of toCompare) {
|
|
172
|
+
for (const { e } of toCompare) {
|
|
107
173
|
const src = "npm" in e.source ? e.source.npm : undefined;
|
|
108
174
|
if (src?.package !== npm.package || src.integrity !== npm.integrity) {
|
|
109
175
|
const was = src === undefined
|
|
@@ -113,39 +179,76 @@ export async function syncMarketplace(opts) {
|
|
|
113
179
|
`with integrity ${npm.integrity}: run capybara-plugin marketplace --write`);
|
|
114
180
|
}
|
|
115
181
|
}
|
|
116
|
-
if (
|
|
182
|
+
if (kept !== undefined) {
|
|
183
|
+
keep(rawEntries[kept.i]);
|
|
117
184
|
continue;
|
|
118
|
-
|
|
185
|
+
}
|
|
186
|
+
const entry = {
|
|
119
187
|
name: r.manifest.name,
|
|
120
188
|
version: npm.version,
|
|
121
|
-
summary:
|
|
189
|
+
summary: summaryOf(r.manifest.description),
|
|
190
|
+
...(r.manifest.displayName !== undefined && { displayName: r.manifest.displayName }),
|
|
122
191
|
...(r.manifest.homepage !== undefined && { homepage: r.manifest.homepage }),
|
|
123
192
|
permissions: await permissionsOf(bytes, r.manifest.extension !== undefined),
|
|
124
193
|
source: { npm },
|
|
125
|
-
}
|
|
194
|
+
};
|
|
195
|
+
updates.push({ name, version, entry, isNew: true, dropped, refreshed: false });
|
|
126
196
|
}
|
|
127
197
|
}
|
|
128
198
|
finally {
|
|
129
199
|
await rm(out, { recursive: true, force: true });
|
|
130
200
|
}
|
|
201
|
+
const added = updates.filter((u) => u.isNew).map((u) => u.entry);
|
|
202
|
+
const removed = updates.flatMap((u) => u.dropped);
|
|
203
|
+
const refreshed = updates.filter((u) => u.refreshed).map((u) => `${u.name}@${u.version}`);
|
|
131
204
|
if (!opts.write) {
|
|
132
|
-
for (const
|
|
133
|
-
|
|
205
|
+
for (const u of updates) {
|
|
206
|
+
if (u.refreshed) {
|
|
207
|
+
at("marketplace-display-stale", `${u.name}@${u.version} has a summary or displayName other than its plugin.json's: ` +
|
|
208
|
+
"run capybara-plugin marketplace --write");
|
|
209
|
+
}
|
|
210
|
+
if (!u.isNew && u.dropped.length === 0)
|
|
211
|
+
continue;
|
|
212
|
+
const also = u.dropped.map((d) => d.slice(u.name.length + 1)).join(", ");
|
|
213
|
+
at("marketplace-missing", u.isNew
|
|
214
|
+
? `${u.name}@${u.version} is not listed${also === "" ? "" : ` (the marketplace lists ${also})`}: ` +
|
|
215
|
+
"one entry per plugin, its current version; run capybara-plugin marketplace --write"
|
|
216
|
+
: `${u.name}@${u.version} is not the plugin's only entry (also ${also}): ` +
|
|
217
|
+
"one entry per plugin, its current version; run capybara-plugin marketplace --write");
|
|
218
|
+
}
|
|
219
|
+
return { added, removed, refreshed, findings };
|
|
220
|
+
}
|
|
221
|
+
if (findings.length > 0 || updates.length === 0)
|
|
222
|
+
return { added, removed, refreshed, findings };
|
|
223
|
+
const byName = new Map(updates.map((u) => [u.name, u]));
|
|
224
|
+
const placed = new Set();
|
|
225
|
+
const plugins = [];
|
|
226
|
+
for (const e of rawEntries) {
|
|
227
|
+
const name = isObj(e) ? String(e.name) : "";
|
|
228
|
+
const u = byName.get(name);
|
|
229
|
+
if (u === undefined) {
|
|
230
|
+
plugins.push(e);
|
|
231
|
+
}
|
|
232
|
+
else if (!placed.has(name)) {
|
|
233
|
+
placed.add(name);
|
|
234
|
+
plugins.push(u.entry);
|
|
134
235
|
}
|
|
135
|
-
return { added, findings };
|
|
136
236
|
}
|
|
137
|
-
|
|
138
|
-
|
|
237
|
+
for (const u of updates)
|
|
238
|
+
if (!placed.has(u.name))
|
|
239
|
+
plugins.push(u.entry);
|
|
139
240
|
const next = isObj(raw) ? { ...raw } : {};
|
|
140
|
-
next.plugins =
|
|
241
|
+
next.plugins = plugins;
|
|
141
242
|
const check = parseMarketplaceManifest(next);
|
|
142
243
|
if ("errors" in check) {
|
|
143
244
|
at("marketplace-invalid", `the new entries do not parse: ${check.errors.join("; ")}`);
|
|
144
|
-
return { added, findings };
|
|
245
|
+
return { added, removed, refreshed, findings };
|
|
145
246
|
}
|
|
146
247
|
await writeFile(opts.file, formatMarketplaceJson(next));
|
|
147
|
-
return { added, findings };
|
|
248
|
+
return { added, removed, refreshed, findings };
|
|
148
249
|
}
|
|
250
|
+
/** `name@version` of a parsed entry (`?` without a version). */
|
|
251
|
+
const keyOf = (e) => `${e.name}@${versionOf(e) ?? "?"}`;
|
|
149
252
|
/** `name@version` of a raw entry (`?` for what is missing). */
|
|
150
253
|
function keyOfRaw(e) {
|
|
151
254
|
if (!isObj(e))
|
|
@@ -153,7 +256,7 @@ function keyOfRaw(e) {
|
|
|
153
256
|
const npm = isObj(e.source) && isObj(e.source.npm) ? e.source.npm : {};
|
|
154
257
|
return `${String(e.name ?? "?")}@${String(e.version ?? npm.version ?? "?")}`;
|
|
155
258
|
}
|
|
156
|
-
/** JSON with object keys sorted (deep equality
|
|
259
|
+
/** JSON with object keys sorted (deep equality regardless of key order). */
|
|
157
260
|
function canonical(v) {
|
|
158
261
|
if (Array.isArray(v))
|
|
159
262
|
return `[${v.map(canonical).join(",")}]`;
|
|
@@ -184,25 +287,68 @@ function entriesAt(root, file, base) {
|
|
|
184
287
|
const raw = JSON.parse(text);
|
|
185
288
|
return isObj(raw) && Array.isArray(raw.plugins) ? raw.plugins : [];
|
|
186
289
|
}
|
|
290
|
+
/** A raw entry's version when it is a plain semver (else undefined: not ordered). */
|
|
291
|
+
function semverOfRaw(e) {
|
|
292
|
+
const v = keyOfRaw(e).split("@").pop();
|
|
293
|
+
return v !== undefined && parseSemver(v) !== undefined ? v : undefined;
|
|
294
|
+
}
|
|
187
295
|
/**
|
|
188
|
-
*
|
|
189
|
-
*
|
|
296
|
+
* The rules on the entries published at the base: `marketplace-integrity`, a version the base
|
|
297
|
+
* lists keeps its source (package and integrity); `marketplace-version-decrease`, a version the
|
|
298
|
+
* base does not list must be newer than every version the base lists for that plugin, and a
|
|
299
|
+
* plugin's newest listed version may not be older than the newest the base lists. Display
|
|
300
|
+
* fields (summary, displayName, permissions, homepage) may change, entries may move, and a
|
|
301
|
+
* plugin's older versions may go (one entry per plugin).
|
|
190
302
|
*/
|
|
191
|
-
function
|
|
303
|
+
function publishedRules(base, current, file) {
|
|
192
304
|
const out = [];
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
const
|
|
198
|
-
const
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
305
|
+
const finding = (rule, message) => out.push({ file, line: 1, rule, message });
|
|
306
|
+
const baseByKey = new Map(base.map((b) => [keyOfRaw(b), b]));
|
|
307
|
+
const newest = new Map();
|
|
308
|
+
for (const b of base) {
|
|
309
|
+
const v = semverOfRaw(b);
|
|
310
|
+
const name = isObj(b) ? String(b.name) : "?";
|
|
311
|
+
const top = newest.get(name);
|
|
312
|
+
if (v !== undefined && (top === undefined || compareSemver(v, top) > 0))
|
|
313
|
+
newest.set(name, v);
|
|
314
|
+
}
|
|
315
|
+
/** `name@version` of the entries reported as `marketplace-version-decrease`. */
|
|
316
|
+
const decreased = new Set();
|
|
317
|
+
for (const c of current) {
|
|
318
|
+
const key = keyOfRaw(c);
|
|
319
|
+
const b = baseByKey.get(key);
|
|
320
|
+
const src = (e) => (isObj(e) ? e.source : undefined);
|
|
321
|
+
if (b !== undefined) {
|
|
322
|
+
if (canonical(src(c)) !== canonical(src(b))) {
|
|
323
|
+
finding("marketplace-integrity", `${key} is published at the base with the source ${canonical(src(b))}, now ` +
|
|
324
|
+
`${canonical(src(c))}: a published version keeps its integrity (bump the version instead)`);
|
|
325
|
+
}
|
|
326
|
+
continue;
|
|
327
|
+
}
|
|
328
|
+
const v = semverOfRaw(c);
|
|
329
|
+
const top = newest.get(isObj(c) ? String(c.name) : "?");
|
|
330
|
+
if (v !== undefined && top !== undefined && compareSemver(v, top) < 0)
|
|
331
|
+
decreased.add(key);
|
|
332
|
+
}
|
|
333
|
+
// A plugin's newest listed version may not fall below the newest the base lists, also when it
|
|
334
|
+
// moves back to an older version the base lists too (a base in the older form).
|
|
335
|
+
const newestNow = new Map();
|
|
336
|
+
for (const c of current) {
|
|
337
|
+
const v = semverOfRaw(c);
|
|
338
|
+
const name = isObj(c) ? String(c.name) : "?";
|
|
339
|
+
const top = newestNow.get(name);
|
|
340
|
+
if (v !== undefined && (top === undefined || compareSemver(v, top) > 0))
|
|
341
|
+
newestNow.set(name, v);
|
|
342
|
+
}
|
|
343
|
+
for (const [name, v] of newestNow) {
|
|
344
|
+
const top = newest.get(name);
|
|
345
|
+
if (top !== undefined && compareSemver(v, top) < 0)
|
|
346
|
+
decreased.add(`${name}@${v}`);
|
|
347
|
+
}
|
|
348
|
+
for (const key of decreased) {
|
|
349
|
+
const name = key.slice(0, key.lastIndexOf("@"));
|
|
350
|
+
finding("marketplace-version-decrease", `${key} is older than ${newest.get(name)}, which the base lists: an entry's version may only increase`);
|
|
351
|
+
}
|
|
206
352
|
return out;
|
|
207
353
|
}
|
|
208
354
|
const INDENT = " ";
|
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.5",
|
|
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.5",
|
|
55
|
+
"@capybara-hq/plugin-sdk": "^0.1.0-rc.3"
|
|
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.6"
|
|
60
60
|
},
|
|
61
61
|
"scripts": {
|
|
62
62
|
"build": "tsc -b"
|