@capybara-hq/plugin-tools 0.1.0-rc.7 → 0.1.0-rc.9
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/dist/check/run.js +2 -0
- package/dist/check/themes.d.ts +19 -0
- package/dist/check/themes.js +112 -0
- package/dist/marketplace/cli.d.ts +2 -1
- package/dist/marketplace/cli.js +2 -1
- package/dist/marketplace/marketplace.d.ts +14 -6
- package/dist/marketplace/marketplace.js +41 -15
- package/dist/pack/pack.d.ts +1 -1
- package/dist/pack/pack.js +78 -4
- package/package.json +4 -4
package/dist/check/run.js
CHANGED
|
@@ -10,6 +10,7 @@ import { checkPluginToolKeys } from "./plugin-tool-keys.js";
|
|
|
10
10
|
import { checkPluginReleaseMetadata } from "./release-metadata.js";
|
|
11
11
|
import { printCheck } from "./report.js";
|
|
12
12
|
import { checkPluginTestImports } from "./test-imports.js";
|
|
13
|
+
import { checkThemes } from "./themes.js";
|
|
13
14
|
import { checkToolDesign } from "./tool-design.js";
|
|
14
15
|
/**
|
|
15
16
|
* Every plugin-scoped check over `scope`, in the order `capybara-plugin check` and core's `pnpm
|
|
@@ -34,6 +35,7 @@ export function pluginChecks(scope) {
|
|
|
34
35
|
{ name: "Plugin pack", run: () => checkPluginPack(plugins) },
|
|
35
36
|
{ name: "Config forms", run: () => checkConfigForms(plugins) },
|
|
36
37
|
{ name: "Org override", run: () => checkOrgOverride(plugins) },
|
|
38
|
+
{ name: "Themes", run: () => checkThemes(plugins) },
|
|
37
39
|
{
|
|
38
40
|
name: "Tool design",
|
|
39
41
|
run: () => checkToolDesign([...(scope.toolDesignDirs ?? []), ...plugins]),
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { type ParsedTheme } from "@capybara-hq/plugin-sdk";
|
|
2
|
+
import type { Finding } from "./source.js";
|
|
3
|
+
/** The theme paths of a plugin (relative, no leading `./`): `manifest.themes`, else `themes/*.json`. */
|
|
4
|
+
export declare function themePathsOf(dir: string): {
|
|
5
|
+
paths: string[];
|
|
6
|
+
listed: boolean;
|
|
7
|
+
};
|
|
8
|
+
/**
|
|
9
|
+
* The theme checks of a plugin (spec §4-§5), reading `manifest.themes` or `themes/*.json`:
|
|
10
|
+
* - `theme-invalid`: a theme file that is missing, over the size limit, not JSON or rejected by
|
|
11
|
+
* `parseThemeFile`, or more themes than {@link THEME_LIMITS}`.perPlugin`;
|
|
12
|
+
* - `theme-readability`: a theme whose merged scheme fails the readability pairs (`unreadable:`);
|
|
13
|
+
* - `theme-asset`: a font or logo that is missing or invalid (`validateThemeAssets`), and the sum
|
|
14
|
+
* of the distinct asset sizes over all of the plugin's themes above
|
|
15
|
+
* {@link THEME_LIMITS}`.assetsBytes` (the limit is per plugin; the SDK checks one theme).
|
|
16
|
+
*/
|
|
17
|
+
export declare function checkThemes(pluginDirs: string[]): Finding[];
|
|
18
|
+
/** The asset paths a parsed theme references (fonts, logo mark and favicon). */
|
|
19
|
+
export declare function assetRefs(theme: ParsedTheme): string[];
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
2
|
+
import { join, posix } from "node:path";
|
|
3
|
+
import { PLUGIN_MANIFEST_FILE, parseThemeFile, THEME_LIMITS, themeIdOf, validateThemeAssets, } from "@capybara-hq/plugin-sdk";
|
|
4
|
+
const stripDot = (p) => p.replace(/^\.\//, "");
|
|
5
|
+
/** The theme paths of a plugin (relative, no leading `./`): `manifest.themes`, else `themes/*.json`. */
|
|
6
|
+
export function themePathsOf(dir) {
|
|
7
|
+
let listed;
|
|
8
|
+
try {
|
|
9
|
+
const m = JSON.parse(readFileSync(join(dir, PLUGIN_MANIFEST_FILE), "utf8"));
|
|
10
|
+
if (Array.isArray(m.themes)) {
|
|
11
|
+
listed = m.themes.flatMap((t) => (typeof t?.path === "string" ? [stripDot(t.path)] : []));
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
// a missing or broken manifest is the pack check's business
|
|
16
|
+
}
|
|
17
|
+
if (listed !== undefined)
|
|
18
|
+
return { paths: [...new Set(listed)], listed: true };
|
|
19
|
+
const root = join(dir, "themes");
|
|
20
|
+
const scanned = existsSync(root)
|
|
21
|
+
? readdirSync(root, { withFileTypes: true })
|
|
22
|
+
.filter((e) => e.isFile() && e.name.endsWith(".json"))
|
|
23
|
+
.map((e) => `themes/${e.name}`)
|
|
24
|
+
.sort()
|
|
25
|
+
: [];
|
|
26
|
+
return { paths: scanned, listed: false };
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* The theme checks of a plugin (spec §4-§5), reading `manifest.themes` or `themes/*.json`:
|
|
30
|
+
* - `theme-invalid`: a theme file that is missing, over the size limit, not JSON or rejected by
|
|
31
|
+
* `parseThemeFile`, or more themes than {@link THEME_LIMITS}`.perPlugin`;
|
|
32
|
+
* - `theme-readability`: a theme whose merged scheme fails the readability pairs (`unreadable:`);
|
|
33
|
+
* - `theme-asset`: a font or logo that is missing or invalid (`validateThemeAssets`), and the sum
|
|
34
|
+
* of the distinct asset sizes over all of the plugin's themes above
|
|
35
|
+
* {@link THEME_LIMITS}`.assetsBytes` (the limit is per plugin; the SDK checks one theme).
|
|
36
|
+
*/
|
|
37
|
+
export function checkThemes(pluginDirs) {
|
|
38
|
+
const findings = [];
|
|
39
|
+
for (const dir of pluginDirs) {
|
|
40
|
+
const { paths } = themePathsOf(dir);
|
|
41
|
+
if (paths.length === 0)
|
|
42
|
+
continue;
|
|
43
|
+
const manifest = join(dir, PLUGIN_MANIFEST_FILE);
|
|
44
|
+
if (paths.length > THEME_LIMITS.perPlugin) {
|
|
45
|
+
findings.push({
|
|
46
|
+
file: manifest,
|
|
47
|
+
line: 1,
|
|
48
|
+
rule: "theme-invalid",
|
|
49
|
+
message: `the plugin has ${paths.length} themes; at most ${THEME_LIMITS.perPlugin} are allowed`,
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
const files = new Map();
|
|
53
|
+
const sizes = new Map();
|
|
54
|
+
for (const rel of paths) {
|
|
55
|
+
const file = join(dir, rel);
|
|
56
|
+
const bad = (rule, message) => findings.push({ file, line: 1, rule, message });
|
|
57
|
+
if (!existsSync(file) || !statSync(file).isFile()) {
|
|
58
|
+
bad("theme-invalid", `theme ./${rel} does not exist`);
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
if (statSync(file).size > THEME_LIMITS.fileBytes) {
|
|
62
|
+
bad("theme-invalid", `theme file is larger than ${THEME_LIMITS.fileBytes} bytes`);
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
const r = parseThemeFile(themeIdOf(rel), readFileSync(file, "utf8"));
|
|
66
|
+
if ("errors" in r) {
|
|
67
|
+
for (const e of r.errors) {
|
|
68
|
+
bad(e.startsWith("unreadable:") ? "theme-readability" : "theme-invalid", e);
|
|
69
|
+
}
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
const used = assetRefs(r);
|
|
73
|
+
for (const ref of used) {
|
|
74
|
+
const key = stripDot(ref);
|
|
75
|
+
const p = posix.normalize(key);
|
|
76
|
+
if (files.has(p) || p.startsWith("..") || p.startsWith("/"))
|
|
77
|
+
continue;
|
|
78
|
+
const abs = join(dir, p);
|
|
79
|
+
if (existsSync(abs) && statSync(abs).isFile())
|
|
80
|
+
files.set(p, readFileSync(abs));
|
|
81
|
+
}
|
|
82
|
+
const { errors } = validateThemeAssets(r, files);
|
|
83
|
+
for (const e of errors)
|
|
84
|
+
bad("theme-asset", e);
|
|
85
|
+
for (const ref of used) {
|
|
86
|
+
const bytes = files.get(stripDot(ref));
|
|
87
|
+
if (bytes !== undefined)
|
|
88
|
+
sizes.set(stripDot(ref), bytes.length);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
let total = 0;
|
|
92
|
+
for (const n of sizes.values())
|
|
93
|
+
total += n;
|
|
94
|
+
if (total > THEME_LIMITS.assetsBytes) {
|
|
95
|
+
findings.push({
|
|
96
|
+
file: manifest,
|
|
97
|
+
line: 1,
|
|
98
|
+
rule: "theme-asset",
|
|
99
|
+
message: `the plugin's theme assets are larger than ${THEME_LIMITS.assetsBytes} bytes in total (${total}); the limit covers all of the plugin's themes`,
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return findings;
|
|
104
|
+
}
|
|
105
|
+
/** The asset paths a parsed theme references (fonts, logo mark and favicon). */
|
|
106
|
+
export function assetRefs(theme) {
|
|
107
|
+
const refs = (theme.fonts ?? []).map((f) => f.file);
|
|
108
|
+
for (const ref of [theme.logo?.mark, theme.logo?.favicon])
|
|
109
|
+
if (ref !== undefined)
|
|
110
|
+
refs.push(ref);
|
|
111
|
+
return refs;
|
|
112
|
+
}
|
|
@@ -16,7 +16,8 @@ export declare const MARKETPLACE_USAGE: string;
|
|
|
16
16
|
* `--check` exits 1 on any finding that is not a warning (`marketplace-missing`,
|
|
17
17
|
* `marketplace-display-stale`, `marketplace-integrity`, `marketplace-version-decrease`,
|
|
18
18
|
* `marketplace-invalid`); `--write` writes each plugin's entry (replacing its other versions,
|
|
19
|
-
* refreshing a listed entry's summary and
|
|
19
|
+
* refreshing a listed entry's summary, displayName and icon) and exits 1 only on
|
|
20
|
+
* `marketplace-invalid`.
|
|
20
21
|
* Relative paths resolve against `cwd`. Returns the exit code.
|
|
21
22
|
*/
|
|
22
23
|
export declare function marketplaceCommand(args: string[], cwd: string): Promise<number>;
|
package/dist/marketplace/cli.js
CHANGED
|
@@ -22,7 +22,8 @@ export const MARKETPLACE_USAGE = "capybara-plugin marketplace --write|--check [-
|
|
|
22
22
|
* `--check` exits 1 on any finding that is not a warning (`marketplace-missing`,
|
|
23
23
|
* `marketplace-display-stale`, `marketplace-integrity`, `marketplace-version-decrease`,
|
|
24
24
|
* `marketplace-invalid`); `--write` writes each plugin's entry (replacing its other versions,
|
|
25
|
-
* refreshing a listed entry's summary and
|
|
25
|
+
* refreshing a listed entry's summary, displayName and icon) and exits 1 only on
|
|
26
|
+
* `marketplace-invalid`.
|
|
26
27
|
* Relative paths resolve against `cwd`. Returns the exit code.
|
|
27
28
|
*/
|
|
28
29
|
export async function marketplaceCommand(args, cwd) {
|
|
@@ -28,7 +28,7 @@ export interface SyncMarketplaceResult {
|
|
|
28
28
|
/** `name@version` of the other entries of the tree's plugins (dropped with `write`). */
|
|
29
29
|
removed: string[];
|
|
30
30
|
/**
|
|
31
|
-
* `name@version` of listed entries whose `summary` / `displayName` differ from plugin.json
|
|
31
|
+
* `name@version` of listed entries whose `summary` / `displayName` / `icon` differ from plugin.json
|
|
32
32
|
* (rewritten with `write`; source and integrity never change).
|
|
33
33
|
*/
|
|
34
34
|
refreshed: string[];
|
|
@@ -37,25 +37,33 @@ export interface SyncMarketplaceResult {
|
|
|
37
37
|
* `marketplace-integrity` and `marketplace-version-decrease` (with a base, see
|
|
38
38
|
* {@link SyncMarketplaceOptions.base}) and, without `write`, `marketplace-missing` (one per
|
|
39
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).
|
|
40
|
+
* `marketplace-display-stale` (a listed entry's summary / displayName / icon differ from plugin.json).
|
|
41
41
|
*/
|
|
42
42
|
findings: Finding[];
|
|
43
43
|
}
|
|
44
|
+
/**
|
|
45
|
+
* The repository path of a plugin's icon: the plugin.json `icon` (relative to the plugin's
|
|
46
|
+
* directory `dir`) as `./<path from the root>` (an entry's `icon`, read at the marketplace's
|
|
47
|
+
* commit, so it follows every release without naming a tag).
|
|
48
|
+
*/
|
|
49
|
+
export declare function repoIconPath(root: string, dir: string, icon: string): string;
|
|
44
50
|
/**
|
|
45
51
|
* `capybara-plugin marketplace`: keeps a plugin repository's `marketplace.json` listing one entry
|
|
46
52
|
* per public plugin, its current version (plugin component management spec §13.2, replacing the
|
|
47
53
|
* one-entry-per-version rule of the package ecosystem spec §5.2). Each public plugin (not
|
|
48
54
|
* `"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: {
|
|
50
|
-
* package, version, integrity } } }` (`summary` from the plugin.json `description`, a string
|
|
51
|
-
* `en`; `displayName` copied from plugin.json; `
|
|
55
|
+
* gets an entry `{ name, version, summary, displayName?, icon?, homepage?, permissions, source: {
|
|
56
|
+
* npm: { package, version, integrity } } }` (`summary` from the plugin.json `description`, a string
|
|
57
|
+
* being `en`; `displayName` copied from plugin.json; `icon` the plugin.json `icon` as a path in the
|
|
58
|
+
* repository, `./<plugin dir>/<icon>`, which a git marketplace reads at its refreshed commit;
|
|
59
|
+
* `integrity` the pack's sha512, which the release
|
|
52
60
|
* then publishes byte for byte; `permissions` display only). The new entry takes the place of the
|
|
53
61
|
* plugin's first entry (a version that moved), the plugin's other entries are dropped (also those
|
|
54
62
|
* of a file written in the older one-entry-per-version form), and a plugin not listed yet is
|
|
55
63
|
* appended. Entries of plugins not in the tree are kept. A version already listed is not packed or
|
|
56
64
|
* compared: once published, its bytes are the release gate's business (`release --marketplace`),
|
|
57
65
|
* 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
|
|
66
|
+
* fields (`summary`, `displayName`, `icon`) are refreshed from the source plugin.json. Every other field
|
|
59
67
|
* of a kept entry stays as written, among them `deprecated`, which the maintainer sets by hand on
|
|
60
68
|
* a published version's entry (not in plugin.json: that would change the packed bytes); a new
|
|
61
69
|
* version's entry has none, and no rule compares it with the base. With `base`, see
|
|
@@ -36,39 +36,60 @@ async function permissionsOf(bytes, hasExtension) {
|
|
|
36
36
|
}
|
|
37
37
|
/** An entry's version: `version`, else its npm source's. */
|
|
38
38
|
const versionOf = (e) => e.version ?? ("npm" in e.source ? e.source.npm.version : undefined);
|
|
39
|
-
/**
|
|
40
|
-
|
|
39
|
+
/**
|
|
40
|
+
* The repository path of a plugin's icon: the plugin.json `icon` (relative to the plugin's
|
|
41
|
+
* directory `dir`) as `./<path from the root>` (an entry's `icon`, read at the marketplace's
|
|
42
|
+
* commit, so it follows every release without naming a tag).
|
|
43
|
+
*/
|
|
44
|
+
export function repoIconPath(root, dir, icon) {
|
|
45
|
+
const rel = relative(root, dir).split("\\").join("/");
|
|
46
|
+
const file = icon.replace(/^\.\//, "");
|
|
47
|
+
return `./${rel === "" ? file : `${rel}/${file}`}`;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* The display fields of a plugin's source plugin.json (`dir` its directory under `root`); undefined
|
|
51
|
+
* when it does not parse.
|
|
52
|
+
*/
|
|
53
|
+
function displayOf(json, root, dir) {
|
|
41
54
|
const m = parsePluginManifest(json);
|
|
42
55
|
if ("errors" in m)
|
|
43
56
|
return undefined;
|
|
44
57
|
return {
|
|
45
58
|
summary: summaryOf(m.description),
|
|
46
59
|
...(m.displayName !== undefined && { displayName: m.displayName }),
|
|
60
|
+
...(m.icon !== undefined && { icon: repoIconPath(root, dir, m.icon) }),
|
|
47
61
|
};
|
|
48
62
|
}
|
|
63
|
+
/** The display fields after `summary`, in this order. */
|
|
64
|
+
const AFTER_SUMMARY = ["displayName", "icon"];
|
|
49
65
|
/**
|
|
50
|
-
* `raw` with its `summary` and `
|
|
51
|
-
* `
|
|
66
|
+
* `raw` with its `summary`, `displayName` and `icon` set to `d` (in place; a new `displayName` /
|
|
67
|
+
* `icon` right after `summary`, removed when plugin.json has none); undefined when they already
|
|
68
|
+
* equal.
|
|
52
69
|
*/
|
|
53
70
|
function refreshDisplay(raw, d) {
|
|
54
71
|
if (!isObj(raw))
|
|
55
72
|
return undefined;
|
|
56
73
|
if (canonical(raw.summary) === canonical(d.summary) &&
|
|
57
|
-
canonical(raw
|
|
74
|
+
AFTER_SUMMARY.every((k) => canonical(raw[k]) === canonical(d[k]))) {
|
|
58
75
|
return undefined;
|
|
59
76
|
}
|
|
77
|
+
const after = (out) => {
|
|
78
|
+
for (const k of AFTER_SUMMARY)
|
|
79
|
+
if (d[k] !== undefined)
|
|
80
|
+
out[k] = d[k];
|
|
81
|
+
};
|
|
60
82
|
const out = {};
|
|
61
83
|
for (const [k, v] of Object.entries(raw)) {
|
|
62
|
-
if (k
|
|
84
|
+
if (AFTER_SUMMARY.includes(k))
|
|
63
85
|
continue;
|
|
64
86
|
out[k] = k === "summary" ? d.summary : v;
|
|
65
|
-
if (k === "summary"
|
|
66
|
-
out
|
|
87
|
+
if (k === "summary")
|
|
88
|
+
after(out);
|
|
67
89
|
}
|
|
68
90
|
if (!("summary" in raw)) {
|
|
69
91
|
out.summary = d.summary;
|
|
70
|
-
|
|
71
|
-
out.displayName = d.displayName;
|
|
92
|
+
after(out);
|
|
72
93
|
}
|
|
73
94
|
return out;
|
|
74
95
|
}
|
|
@@ -77,16 +98,18 @@ function refreshDisplay(raw, d) {
|
|
|
77
98
|
* per public plugin, its current version (plugin component management spec §13.2, replacing the
|
|
78
99
|
* one-entry-per-version rule of the package ecosystem spec §5.2). Each public plugin (not
|
|
79
100
|
* `"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: {
|
|
81
|
-
* package, version, integrity } } }` (`summary` from the plugin.json `description`, a string
|
|
82
|
-
* `en`; `displayName` copied from plugin.json; `
|
|
101
|
+
* gets an entry `{ name, version, summary, displayName?, icon?, homepage?, permissions, source: {
|
|
102
|
+
* npm: { package, version, integrity } } }` (`summary` from the plugin.json `description`, a string
|
|
103
|
+
* being `en`; `displayName` copied from plugin.json; `icon` the plugin.json `icon` as a path in the
|
|
104
|
+
* repository, `./<plugin dir>/<icon>`, which a git marketplace reads at its refreshed commit;
|
|
105
|
+
* `integrity` the pack's sha512, which the release
|
|
83
106
|
* then publishes byte for byte; `permissions` display only). The new entry takes the place of the
|
|
84
107
|
* plugin's first entry (a version that moved), the plugin's other entries are dropped (also those
|
|
85
108
|
* of a file written in the older one-entry-per-version form), and a plugin not listed yet is
|
|
86
109
|
* appended. Entries of plugins not in the tree are kept. A version already listed is not packed or
|
|
87
110
|
* compared: once published, its bytes are the release gate's business (`release --marketplace`),
|
|
88
111
|
* 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
|
|
112
|
+
* fields (`summary`, `displayName`, `icon`) are refreshed from the source plugin.json. Every other field
|
|
90
113
|
* of a kept entry stays as written, among them `deprecated`, which the maintainer sets by hand on
|
|
91
114
|
* a published version's entry (not in plugin.json: that would change the packed bytes); a new
|
|
92
115
|
* version's entry has none, and no rule compares it with the base. With `base`, see
|
|
@@ -150,7 +173,7 @@ export async function syncMarketplace(opts) {
|
|
|
150
173
|
const kept = listed[0];
|
|
151
174
|
/** Keeps the listed entry: drops the other versions, refreshes its display fields. */
|
|
152
175
|
const keep = (raw) => {
|
|
153
|
-
const display = displayOf(source);
|
|
176
|
+
const display = displayOf(source, opts.root, dir);
|
|
154
177
|
const fresh = display === undefined ? undefined : refreshDisplay(raw, display);
|
|
155
178
|
if (dropped.length > 0 || fresh !== undefined) {
|
|
156
179
|
const entry = fresh ?? raw;
|
|
@@ -188,6 +211,9 @@ export async function syncMarketplace(opts) {
|
|
|
188
211
|
version: npm.version,
|
|
189
212
|
summary: summaryOf(r.manifest.description),
|
|
190
213
|
...(r.manifest.displayName !== undefined && { displayName: r.manifest.displayName }),
|
|
214
|
+
...(r.manifest.icon !== undefined && {
|
|
215
|
+
icon: repoIconPath(opts.root, dir, r.manifest.icon),
|
|
216
|
+
}),
|
|
191
217
|
...(r.manifest.homepage !== undefined && { homepage: r.manifest.homepage }),
|
|
192
218
|
permissions: await permissionsOf(bytes, r.manifest.extension !== undefined),
|
|
193
219
|
source: { npm },
|
package/dist/pack/pack.d.ts
CHANGED
|
@@ -38,7 +38,7 @@ export declare function importPluginExtension(pluginDir: string): Promise<Extens
|
|
|
38
38
|
* 2. when `src/index.ts` exists, bundles the extension ({@link bundleExtension}) and sets
|
|
39
39
|
* `extension = { entry: "./dist/index.mjs", manifest, migrations?: "./migrations", ui?: "./ui" }`;
|
|
40
40
|
* a declarative plugin (no `src/index.ts`) runs no esbuild and creates no staging dir;
|
|
41
|
-
* 3. ships `skills/**`, `connectors/*.json`, `agents/*.md` and the listed paths ({@link contentFiles}), README,
|
|
41
|
+
* 3. ships `skills/**`, `connectors/*.json`, `agents/*.md`, `themes/*.json` with their assets and the listed paths ({@link contentFiles}), README,
|
|
42
42
|
* LICENSE, NOTICE and the plain files of package.json `files` ({@link extraFiles});
|
|
43
43
|
* 4. writes a package.json with only name / version / type / license / {@link METADATA_FIELDS}
|
|
44
44
|
* and, with an extension, `main` / `exports` (the entry) and peerDependencies (no `capybara`
|
package/dist/pack/pack.js
CHANGED
|
@@ -7,10 +7,11 @@ import { dirname, join, posix, relative, resolve } from "node:path";
|
|
|
7
7
|
import { pathToFileURL } from "node:url";
|
|
8
8
|
import { promisify } from "node:util";
|
|
9
9
|
import { gzip } from "node:zlib";
|
|
10
|
-
import { AGENT_TEMPLATE_LIMITS, checkEntryImports, defineExtension, HOST_PROVIDED_PACKAGES, PLUGIN_MANIFEST_FILE, PLUGIN_MANIFEST_LIMITS, parseAgentTemplate, parseConnectorTemplateFile, parsePluginManifest, SDK_VERSION, staticManifestOf, validatePluginPackageJsonFor, } from "@capybara-hq/plugin-sdk";
|
|
10
|
+
import { AGENT_TEMPLATE_LIMITS, checkEntryImports, defineExtension, HOST_PROVIDED_PACKAGES, PLUGIN_MANIFEST_FILE, PLUGIN_MANIFEST_LIMITS, parseAgentTemplate, parseConnectorTemplateFile, parsePluginManifest, parseThemeFile, SDK_VERSION, setupGuideFileProblem, staticManifestOf, THEME_LIMITS, themeIdOf, unknownSetupGuidePlaceholders, validatePluginPackageJsonFor, validateThemeAssets, } from "@capybara-hq/plugin-sdk";
|
|
11
11
|
import { init as lexerInit, parse as lexerParse } from "es-module-lexer";
|
|
12
12
|
import { build } from "esbuild";
|
|
13
13
|
import { pack as tarPack } from "tar-stream";
|
|
14
|
+
import { assetRefs, themePathsOf } from "../check/themes.js";
|
|
14
15
|
import { extensionConfigSchemas, formatConfigSchemaProblem } from "./config-schemas.js";
|
|
15
16
|
/** The extension entry the packer writes (relative to the package root). */
|
|
16
17
|
const EXTENSION_ENTRY = "dist/index.mjs";
|
|
@@ -167,6 +168,8 @@ const SHIPPED_ELSEWHERE = [
|
|
|
167
168
|
"skills",
|
|
168
169
|
"connectors",
|
|
169
170
|
"agents",
|
|
171
|
+
// setup guide templates: the listed files are shipped by contentFiles
|
|
172
|
+
"guides",
|
|
170
173
|
".capybara-plugin",
|
|
171
174
|
"README.md",
|
|
172
175
|
"LICENSE",
|
|
@@ -357,11 +360,75 @@ async function contentFiles(dir, label, m) {
|
|
|
357
360
|
}
|
|
358
361
|
agentNames.set(t.template.name, rel);
|
|
359
362
|
}
|
|
363
|
+
// Themes: listed, else scanned from themes/*.json; each is parsed, its assets are checked and
|
|
364
|
+
// shipped (spec §4-§5), so a bad theme fails the pack before import
|
|
365
|
+
const { paths: themePaths } = themePathsOf(dir);
|
|
366
|
+
if (themePaths.length > THEME_LIMITS.perPlugin) {
|
|
367
|
+
throw new Error(`${label}: ${themePaths.length} themes; a plugin may have at most ${THEME_LIMITS.perPlugin}`);
|
|
368
|
+
}
|
|
369
|
+
const themeFiles = new Map();
|
|
370
|
+
const assetSizes = new Map();
|
|
371
|
+
for (const rel of themePaths) {
|
|
372
|
+
await addFile(rel, "theme");
|
|
373
|
+
const t = parseThemeFile(themeIdOf(rel), files.get(rel).toString("utf8"));
|
|
374
|
+
if ("errors" in t)
|
|
375
|
+
throw new Error(`${label}: ${rel}: ${t.errors.join("; ")}`);
|
|
376
|
+
for (const ref of assetRefs(t)) {
|
|
377
|
+
const key = stripDot(ref);
|
|
378
|
+
if (themeFiles.has(key))
|
|
379
|
+
continue;
|
|
380
|
+
const abs = join(dir, key);
|
|
381
|
+
if (!existsSync(abs) || !(await stat(abs)).isFile())
|
|
382
|
+
continue;
|
|
383
|
+
themeFiles.set(key, await readFile(abs));
|
|
384
|
+
}
|
|
385
|
+
const checked = validateThemeAssets(t, themeFiles);
|
|
386
|
+
if (checked.errors.length > 0) {
|
|
387
|
+
throw new Error(`${label}: ${rel}: ${checked.errors.join("; ")}`);
|
|
388
|
+
}
|
|
389
|
+
for (const key of checked.assets.keys()) {
|
|
390
|
+
files.set(key, Buffer.from(themeFiles.get(key)));
|
|
391
|
+
assetSizes.set(key, themeFiles.get(key).length);
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
let assetTotal = 0;
|
|
395
|
+
for (const n of assetSizes.values())
|
|
396
|
+
assetTotal += n;
|
|
397
|
+
if (assetTotal > THEME_LIMITS.assetsBytes) {
|
|
398
|
+
throw new Error(`${label}: theme assets are larger than ${THEME_LIMITS.assetsBytes} bytes in total (${assetTotal})`);
|
|
399
|
+
}
|
|
400
|
+
// Setup guides (spec 2026-11-08-plugin-setup-guides-design.md §2): the listed template files,
|
|
401
|
+
// text within the size limit, and only placeholders the place defines (the kernel leaves an
|
|
402
|
+
// unknown one visible; the toolchain refuses it so a typo never ships)
|
|
403
|
+
for (const g of m.setupGuides ?? []) {
|
|
404
|
+
const at = `${label}: setupGuides ${g.id}`;
|
|
405
|
+
const unknown = (text, where) => {
|
|
406
|
+
const names = unknownSetupGuidePlaceholders(text, g.place);
|
|
407
|
+
if (names.length > 0) {
|
|
408
|
+
throw new Error(`${at}: ${where} uses ${names.map((n) => `{{${n}}}`).join(", ")}, which place ${g.place} does not define`);
|
|
409
|
+
}
|
|
410
|
+
};
|
|
411
|
+
g.steps?.forEach((step, i) => {
|
|
412
|
+
unknown(step.en, `step ${i + 1}`);
|
|
413
|
+
if (step.zh !== undefined)
|
|
414
|
+
unknown(step.zh, `step ${i + 1} (zh)`);
|
|
415
|
+
});
|
|
416
|
+
for (const f of g.files ?? []) {
|
|
417
|
+
const rel = stripDot(f.path);
|
|
418
|
+
await addFile(rel, `setup guide ${g.id} file`);
|
|
419
|
+
const data = files.get(rel);
|
|
420
|
+
const problem = setupGuideFileProblem(f.path, new Uint8Array(data));
|
|
421
|
+
if (problem !== undefined)
|
|
422
|
+
throw new Error(`${at}: ${problem}`);
|
|
423
|
+
unknown(data.toString("utf8"), f.path);
|
|
424
|
+
}
|
|
425
|
+
}
|
|
360
426
|
return {
|
|
361
427
|
files,
|
|
362
428
|
skills: m.skills?.length ?? scannedSkills,
|
|
363
429
|
templates: m.connectorTemplates?.length ?? templates.length,
|
|
364
430
|
agents: agents.length,
|
|
431
|
+
themes: themePaths.length,
|
|
365
432
|
};
|
|
366
433
|
}
|
|
367
434
|
/**
|
|
@@ -485,7 +552,7 @@ async function bundleExtension(dir, label, pkgVersion) {
|
|
|
485
552
|
* 2. when `src/index.ts` exists, bundles the extension ({@link bundleExtension}) and sets
|
|
486
553
|
* `extension = { entry: "./dist/index.mjs", manifest, migrations?: "./migrations", ui?: "./ui" }`;
|
|
487
554
|
* a declarative plugin (no `src/index.ts`) runs no esbuild and creates no staging dir;
|
|
488
|
-
* 3. ships `skills/**`, `connectors/*.json`, `agents/*.md` and the listed paths ({@link contentFiles}), README,
|
|
555
|
+
* 3. ships `skills/**`, `connectors/*.json`, `agents/*.md`, `themes/*.json` with their assets and the listed paths ({@link contentFiles}), README,
|
|
489
556
|
* LICENSE, NOTICE and the plain files of package.json `files` ({@link extraFiles});
|
|
490
557
|
* 4. writes a package.json with only name / version / type / license / {@link METADATA_FIELDS}
|
|
491
558
|
* and, with an extension, `main` / `exports` (the entry) and peerDependencies (no `capybara`
|
|
@@ -501,10 +568,17 @@ export async function packPlugin(pluginDir, outDir) {
|
|
|
501
568
|
const source = await readSourceManifest(dir, label);
|
|
502
569
|
const content = await contentFiles(dir, label, source);
|
|
503
570
|
const hasExtension = existsSync(join(dir, "src/index.ts"));
|
|
504
|
-
if (!hasExtension &&
|
|
505
|
-
|
|
571
|
+
if (!hasExtension &&
|
|
572
|
+
content.skills === 0 &&
|
|
573
|
+
content.templates === 0 &&
|
|
574
|
+
content.agents === 0 &&
|
|
575
|
+
content.themes === 0) {
|
|
576
|
+
throw new Error(`${label}: a plugin must contain an extension, a skill, a connector template, an agent template or a theme (src/index.ts, skills/<name>/SKILL.md, connectors/<id>.json, agents/<name>.md or themes/<id>.json)`);
|
|
506
577
|
}
|
|
507
578
|
const ext = hasExtension ? await bundleExtension(dir, label, srcPkg.version) : undefined;
|
|
579
|
+
if (ext === undefined && source.setupGuides?.some((g) => g.place === "channel-connection")) {
|
|
580
|
+
throw new Error(`${label}: a channel-connection setup guide needs the plugin's extension to provide channel@1 (src/index.ts)`);
|
|
581
|
+
}
|
|
508
582
|
const files = new Map([...(ext?.files ?? []), ...content.files]);
|
|
509
583
|
const extra = await extraFiles(dir, srcPkg.files);
|
|
510
584
|
for (const [path, data] of extra.files)
|
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.9",
|
|
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/
|
|
55
|
-
"@capybara-hq/
|
|
54
|
+
"@capybara-hq/kernel": "^0.1.0-rc.11",
|
|
55
|
+
"@capybara-hq/plugin-sdk": "^0.1.0-rc.8"
|
|
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.11"
|
|
60
60
|
},
|
|
61
61
|
"scripts": {
|
|
62
62
|
"build": "tsc -b"
|