@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
|
@@ -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 = " ";
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { type ConfigUiProblem, type ExtensionManifest, type JsonSchema } from "@capybara-hq/plugin-sdk";
|
|
2
|
+
/**
|
|
3
|
+
* The config JSON Schemas an extension's package carries (extension config forms spec §4.5) and
|
|
4
|
+
* the checks the packer and `capybara-plugin check` ("Config forms") run on them.
|
|
5
|
+
*/
|
|
6
|
+
/** The config levels an extension declares a schema for. */
|
|
7
|
+
export type ConfigSchemaLevel = "deployment" | "org";
|
|
8
|
+
/** A problem of one level's schema (`path`: dot path of the node, `""` the root). */
|
|
9
|
+
export interface ConfigSchemaProblem extends ConfigUiProblem {
|
|
10
|
+
level: ConfigSchemaLevel;
|
|
11
|
+
}
|
|
12
|
+
/** The JSON Schemas of an extension's config levels and the problems of their hints. */
|
|
13
|
+
export interface ExtensionConfigSchemas {
|
|
14
|
+
schemas: {
|
|
15
|
+
deployment?: JsonSchema;
|
|
16
|
+
org?: JsonSchema;
|
|
17
|
+
};
|
|
18
|
+
problems: ConfigSchemaProblem[];
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* The JSON Schemas (`configJsonSchema`) of an extension's deployment and org config, with every
|
|
22
|
+
* problem of their hints: `validateConfigUi` at the level, plus value hints on secrets
|
|
23
|
+
* ({@link secretValueHints}). The packer refuses a plugin with problems.
|
|
24
|
+
*/
|
|
25
|
+
export declare function extensionConfigSchemas(config: ExtensionManifest["config"] | undefined): ExtensionConfigSchemas;
|
|
26
|
+
/** `<level> config: <path>: <message>` (the root as `(root)`). */
|
|
27
|
+
export declare const formatConfigSchemaProblem: (p: ConfigSchemaProblem) => string;
|
|
28
|
+
/**
|
|
29
|
+
* The rendered fields of a form schema without a label in both languages (`plugin-config-labels`):
|
|
30
|
+
* every property node the form draws, in nested objects, array items and record values. A secret
|
|
31
|
+
* record named by some `x-ui.secrets[].path` is skipped (it is drawn inside each item with that
|
|
32
|
+
* hint's own label); a secret node is drawn as one input, so its own properties are not visited.
|
|
33
|
+
*/
|
|
34
|
+
export declare function missingConfigLabels(schema: JsonSchema): ConfigUiProblem[];
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { configJsonSchema, validateConfigUi, } from "@capybara-hq/plugin-sdk";
|
|
2
|
+
const LEVELS = ["deployment", "org"];
|
|
3
|
+
const isObj = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
4
|
+
/** Keywords whose value is data, not a schema: never recursed into. */
|
|
5
|
+
const VALUE_KEYWORDS = new Set(["default", "const", "enum", "examples"]);
|
|
6
|
+
/** Hints that would reveal values; `configJsonSchema` drops them under a secret. */
|
|
7
|
+
const VALUE_HINTS = ["options", "presets"];
|
|
8
|
+
const join = (path, seg) => (path === "" ? seg : `${path}.${seg}`);
|
|
9
|
+
/**
|
|
10
|
+
* Value hints (`options`, `presets`) on a secret node or under one. `configJsonSchema` removes
|
|
11
|
+
* them before `validateConfigUi` could see them, so a plugin would ship a hint that silently does
|
|
12
|
+
* nothing; this reads the zod schema's own JSON Schema (meta `secret` / `ui` kept as written) with
|
|
13
|
+
* the schema's own zod, so plugin-tools needs no zod at run time.
|
|
14
|
+
*/
|
|
15
|
+
function secretValueHints(schema) {
|
|
16
|
+
const toJson = schema?.toJSONSchema;
|
|
17
|
+
if (typeof toJson !== "function")
|
|
18
|
+
return [];
|
|
19
|
+
const raw = toJson.call(schema, { io: "input", unrepresentable: "any" });
|
|
20
|
+
const out = new Map();
|
|
21
|
+
const walk = (node, path, inSecret) => {
|
|
22
|
+
if (Array.isArray(node)) {
|
|
23
|
+
for (const n of node)
|
|
24
|
+
walk(n, path, inSecret);
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
if (!isObj(node))
|
|
28
|
+
return;
|
|
29
|
+
const secret = inSecret || node.secret === true;
|
|
30
|
+
if (secret && isObj(node.ui)) {
|
|
31
|
+
for (const k of VALUE_HINTS) {
|
|
32
|
+
if (node.ui[k] === undefined)
|
|
33
|
+
continue;
|
|
34
|
+
const message = `x-ui.${k} on a secret field: secrets publish no values, so the form never shows them (remove ${k})`;
|
|
35
|
+
out.set(`${path}\n${message}`, { path, message });
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
for (const [k, v] of Object.entries(node)) {
|
|
39
|
+
if (k === "ui" || VALUE_KEYWORDS.has(k))
|
|
40
|
+
continue;
|
|
41
|
+
if (k === "properties" && isObj(v)) {
|
|
42
|
+
for (const [name, child] of Object.entries(v))
|
|
43
|
+
walk(child, join(path, name), secret);
|
|
44
|
+
}
|
|
45
|
+
else if (k === "items" || k === "additionalProperties") {
|
|
46
|
+
walk(v, join(path, k), secret);
|
|
47
|
+
}
|
|
48
|
+
else {
|
|
49
|
+
walk(v, path, secret);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
walk(raw, "", false);
|
|
54
|
+
return [...out.values()];
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* The JSON Schemas (`configJsonSchema`) of an extension's deployment and org config, with every
|
|
58
|
+
* problem of their hints: `validateConfigUi` at the level, plus value hints on secrets
|
|
59
|
+
* ({@link secretValueHints}). The packer refuses a plugin with problems.
|
|
60
|
+
*/
|
|
61
|
+
export function extensionConfigSchemas(config) {
|
|
62
|
+
const result = { schemas: {}, problems: [] };
|
|
63
|
+
for (const level of LEVELS) {
|
|
64
|
+
const zod = config?.[level];
|
|
65
|
+
if (zod === undefined)
|
|
66
|
+
continue;
|
|
67
|
+
const schema = configJsonSchema(zod);
|
|
68
|
+
result.schemas[level] = schema;
|
|
69
|
+
for (const p of [...validateConfigUi(schema, level), ...secretValueHints(zod)]) {
|
|
70
|
+
result.problems.push({ level, ...p });
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return result;
|
|
74
|
+
}
|
|
75
|
+
/** `<level> config: <path>: <message>` (the root as `(root)`). */
|
|
76
|
+
export const formatConfigSchemaProblem = (p) => `${p.level} config: ${p.path === "" ? "(root)" : p.path}: ${p.message}`;
|
|
77
|
+
/** The non-null shape of a node: itself, or the one non-null branch of a nullable `anyOf`. */
|
|
78
|
+
function shapeOf(node) {
|
|
79
|
+
const branches = (node.anyOf ?? node.oneOf);
|
|
80
|
+
if (node.type === undefined && Array.isArray(branches)) {
|
|
81
|
+
const rest = branches.filter((b) => isObj(b) && b.type !== "null");
|
|
82
|
+
if (rest.length === 1 && rest[0] !== undefined)
|
|
83
|
+
return rest[0];
|
|
84
|
+
}
|
|
85
|
+
return node;
|
|
86
|
+
}
|
|
87
|
+
const hasText = (v) => typeof v === "string" && v.trim() !== "";
|
|
88
|
+
/** Does the node (or its non-null shape) carry `x-ui.label` with both zh and en? */
|
|
89
|
+
function hasLabel(node) {
|
|
90
|
+
for (const n of [node, shapeOf(node)]) {
|
|
91
|
+
const label = isObj(n["x-ui"]) ? n["x-ui"].label : undefined;
|
|
92
|
+
if (isObj(label) && hasText(label.zh) && hasText(label.en))
|
|
93
|
+
return true;
|
|
94
|
+
}
|
|
95
|
+
return false;
|
|
96
|
+
}
|
|
97
|
+
/** Every `x-ui.secrets[].path` of a form schema (dot paths from the root). */
|
|
98
|
+
function itemSecretPaths(node, out = new Set()) {
|
|
99
|
+
if (Array.isArray(node)) {
|
|
100
|
+
for (const n of node)
|
|
101
|
+
itemSecretPaths(n, out);
|
|
102
|
+
return out;
|
|
103
|
+
}
|
|
104
|
+
if (!isObj(node))
|
|
105
|
+
return out;
|
|
106
|
+
const hints = node["x-ui"];
|
|
107
|
+
if (isObj(hints) && Array.isArray(hints.secrets)) {
|
|
108
|
+
for (const s of hints.secrets)
|
|
109
|
+
if (isObj(s) && typeof s.path === "string")
|
|
110
|
+
out.add(s.path);
|
|
111
|
+
}
|
|
112
|
+
for (const [k, v] of Object.entries(node)) {
|
|
113
|
+
if (k !== "x-ui" && !VALUE_KEYWORDS.has(k))
|
|
114
|
+
itemSecretPaths(v, out);
|
|
115
|
+
}
|
|
116
|
+
return out;
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* The rendered fields of a form schema without a label in both languages (`plugin-config-labels`):
|
|
120
|
+
* every property node the form draws, in nested objects, array items and record values. A secret
|
|
121
|
+
* record named by some `x-ui.secrets[].path` is skipped (it is drawn inside each item with that
|
|
122
|
+
* hint's own label); a secret node is drawn as one input, so its own properties are not visited.
|
|
123
|
+
*/
|
|
124
|
+
export function missingConfigLabels(schema) {
|
|
125
|
+
const skip = itemSecretPaths(schema);
|
|
126
|
+
const out = [];
|
|
127
|
+
const visit = (node, path) => {
|
|
128
|
+
const shape = shapeOf(node);
|
|
129
|
+
if (isObj(shape.properties)) {
|
|
130
|
+
for (const [k, child] of Object.entries(shape.properties)) {
|
|
131
|
+
if (!isObj(child))
|
|
132
|
+
continue;
|
|
133
|
+
const p = join(path, k);
|
|
134
|
+
if (skip.has(p))
|
|
135
|
+
continue;
|
|
136
|
+
if (!hasLabel(child)) {
|
|
137
|
+
out.push({ path: p, message: "has no label (x-ui.label with zh and en)" });
|
|
138
|
+
}
|
|
139
|
+
if (child["x-secret"] !== true)
|
|
140
|
+
visit(child, p);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
if (isObj(shape.items))
|
|
144
|
+
visit(shape.items, join(path, "items"));
|
|
145
|
+
if (isObj(shape.additionalProperties)) {
|
|
146
|
+
visit(shape.additionalProperties, join(path, "additionalProperties"));
|
|
147
|
+
}
|
|
148
|
+
};
|
|
149
|
+
visit(schema, "");
|
|
150
|
+
return out;
|
|
151
|
+
}
|
package/dist/pack/index.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
1
|
export { PACK_USAGE, packCommand } from "./cli.js";
|
|
2
|
+
export { type ConfigSchemaLevel, type ConfigSchemaProblem, type ExtensionConfigSchemas, extensionConfigSchemas, formatConfigSchemaProblem, missingConfigLabels, } from "./config-schemas.js";
|
|
2
3
|
export { assertNpmTarballLabel, DEFAULT_NPM_REGISTRY, downloadPinnedNpmPackage, fetchNpmTarball, NPM_TARBALL_MAX_BYTES, type NpmFetchOptions, NpmPackageError, type NpmPackageErrorKind, } from "./npm.js";
|
|
3
|
-
export { METADATA_FIELDS, NOTICES_FILE, type PackResult, packPlugin, STAGING_PREFIX, } from "./pack.js";
|
|
4
|
+
export { importPluginExtension, METADATA_FIELDS, NOTICES_FILE, type PackResult, packPlugin, STAGING_PREFIX, } from "./pack.js";
|
package/dist/pack/index.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
1
|
export { PACK_USAGE, packCommand } from "./cli.js";
|
|
2
|
+
export { extensionConfigSchemas, formatConfigSchemaProblem, missingConfigLabels, } from "./config-schemas.js";
|
|
2
3
|
export { assertNpmTarballLabel, DEFAULT_NPM_REGISTRY, downloadPinnedNpmPackage, fetchNpmTarball, NPM_TARBALL_MAX_BYTES, NpmPackageError, } from "./npm.js";
|
|
3
|
-
export { METADATA_FIELDS, NOTICES_FILE, packPlugin, STAGING_PREFIX, } from "./pack.js";
|
|
4
|
+
export { importPluginExtension, METADATA_FIELDS, NOTICES_FILE, packPlugin, STAGING_PREFIX, } from "./pack.js";
|
package/dist/pack/pack.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type PluginManifest } from "@capybara-hq/plugin-sdk";
|
|
1
|
+
import { type ExtensionManifest, type PluginManifest } from "@capybara-hq/plugin-sdk";
|
|
2
2
|
/**
|
|
3
3
|
* The result of packing: the tgz path, its hex sha256 and the `.capybara-plugin/plugin.json`
|
|
4
4
|
* written into it (the extension's static manifest is `manifest.extension?.manifest`).
|
|
@@ -25,6 +25,12 @@ export declare const STAGING_PREFIX = ".capybara-pack-";
|
|
|
25
25
|
export declare const METADATA_FIELDS: readonly ["description", "repository", "homepage", "keywords"];
|
|
26
26
|
/** Third-party license notices of the bundled dependencies (written only when there are any). */
|
|
27
27
|
export declare const NOTICES_FILE = "THIRD-PARTY-NOTICES.txt";
|
|
28
|
+
/**
|
|
29
|
+
* The extension a plugin directory's `src/index.ts` defines, bundled and imported the way the
|
|
30
|
+
* packer does (in a `.capybara-pack-*` staging dir under the system temp dir, removed afterwards).
|
|
31
|
+
* For checks that need the module (config forms); throws what packing would throw.
|
|
32
|
+
*/
|
|
33
|
+
export declare function importPluginExtension(pluginDir: string): Promise<ExtensionManifest>;
|
|
28
34
|
/**
|
|
29
35
|
* Packs a plugin directory into a plugin package (spec §4.1):
|
|
30
36
|
* 1. reads and validates the source `.capybara-plugin/plugin.json` (it must not carry
|