@capybara-hq/plugin-tools 0.0.0-stage → 0.1.0-rc.0
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/LICENSE +202 -0
- package/NOTICE +2 -0
- package/README.md +105 -2
- package/bin/capybara-plugin.mjs +12 -0
- package/dist/check/applied-migrations.d.ts +28 -0
- package/dist/check/applied-migrations.js +65 -0
- package/dist/check/cli.d.ts +16 -0
- package/dist/check/cli.js +69 -0
- package/dist/check/deps.d.ts +23 -0
- package/dist/check/deps.js +207 -0
- package/dist/check/index.d.ts +15 -0
- package/dist/check/index.js +14 -0
- package/dist/check/migrations.d.ts +11 -0
- package/dist/check/migrations.js +93 -0
- package/dist/check/plugin-pack.d.ts +17 -0
- package/dist/check/plugin-pack.js +56 -0
- package/dist/check/plugin-permissions.d.ts +22 -0
- package/dist/check/plugin-permissions.js +392 -0
- package/dist/check/plugin-state.d.ts +19 -0
- package/dist/check/plugin-state.js +181 -0
- package/dist/check/plugin-versions.d.ts +13 -0
- package/dist/check/plugin-versions.js +56 -0
- package/dist/check/release-metadata.d.ts +13 -0
- package/dist/check/release-metadata.js +80 -0
- package/dist/check/report.d.ts +8 -0
- package/dist/check/report.js +17 -0
- package/dist/check/run.d.ts +36 -0
- package/dist/check/run.js +60 -0
- package/dist/check/source.d.ts +53 -0
- package/dist/check/source.js +153 -0
- package/dist/check/sync-versions-cli.d.ts +10 -0
- package/dist/check/sync-versions-cli.js +44 -0
- package/dist/check/test-imports.d.ts +20 -0
- package/dist/check/test-imports.js +158 -0
- package/dist/check/tool-design.d.ts +13 -0
- package/dist/check/tool-design.js +227 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +48 -0
- package/dist/discover.d.ts +21 -0
- package/dist/discover.js +69 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.js +10 -0
- package/dist/integrity.d.ts +2 -0
- package/dist/integrity.js +3 -0
- package/dist/marketplace/cli.d.ts +20 -0
- package/dist/marketplace/cli.js +80 -0
- package/dist/marketplace/file.d.ts +2 -0
- package/dist/marketplace/file.js +2 -0
- package/dist/marketplace/index.d.ts +2 -0
- package/dist/marketplace/index.js +2 -0
- package/dist/marketplace/marketplace.d.ts +57 -0
- package/dist/marketplace/marketplace.js +245 -0
- package/dist/pack/bootstrap.d.ts +25 -0
- package/dist/pack/bootstrap.js +70 -0
- package/dist/pack/cli.d.ts +20 -0
- package/dist/pack/cli.js +109 -0
- package/dist/pack/index.d.ts +4 -0
- package/dist/pack/index.js +4 -0
- package/dist/pack/npm.d.ts +66 -0
- package/dist/pack/npm.js +177 -0
- package/dist/pack/pack.d.ts +44 -0
- package/dist/pack/pack.js +518 -0
- package/dist/release/cli.d.ts +30 -0
- package/dist/release/cli.js +93 -0
- package/dist/release/index.d.ts +4 -0
- package/dist/release/index.js +4 -0
- package/dist/release/publish-plan.d.ts +68 -0
- package/dist/release/publish-plan.js +117 -0
- package/dist/release/publish.d.ts +91 -0
- package/dist/release/publish.js +274 -0
- package/dist/release/semver.d.ts +11 -0
- package/dist/release/semver.js +44 -0
- package/package.json +61 -3
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
3
|
+
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join, relative } from "node:path";
|
|
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";
|
|
8
|
+
import { discoverPlugins } from "../discover.js";
|
|
9
|
+
import { integrityOf } from "../integrity.js";
|
|
10
|
+
import { packPlugin } from "../pack/pack.js";
|
|
11
|
+
export { MARKETPLACE_FILE } from "./file.js";
|
|
12
|
+
const isObj = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
13
|
+
/** The marketplace summary of a plugin: its plugin.json description, cut to the summary limit. */
|
|
14
|
+
function summaryOf(description) {
|
|
15
|
+
const max = MARKETPLACE_LIMITS.summaryChars;
|
|
16
|
+
return description.length <= max ? description : `${description.slice(0, max - 1)}…`;
|
|
17
|
+
}
|
|
18
|
+
/** The permission summary of a packed plugin, as the kernel computes it at import (display only). */
|
|
19
|
+
async function permissionsOf(bytes, hasExtension) {
|
|
20
|
+
if (!hasExtension)
|
|
21
|
+
return { keys: [], sensitive: [] };
|
|
22
|
+
const ext = await readExtensionPackage(bytes, {
|
|
23
|
+
maxBytes: PACKAGES_MAX_BYTES_DEFAULT,
|
|
24
|
+
hostSdkVersion: SDK_VERSION,
|
|
25
|
+
});
|
|
26
|
+
const keys = permissionKeys(permissionSetOfPackage(ext))
|
|
27
|
+
.filter((k) => !k.startsWith(WARNINGS_KEY_PREFIX))
|
|
28
|
+
.sort();
|
|
29
|
+
return { keys, sensitive: keys.filter(isSensitivePermissionKey) };
|
|
30
|
+
}
|
|
31
|
+
/** An entry's version: `version`, else its npm source's. */
|
|
32
|
+
const versionOf = (e) => e.version ?? ("npm" in e.source ? e.source.npm.version : undefined);
|
|
33
|
+
/**
|
|
34
|
+
* `capybara-plugin marketplace`: keeps a plugin repository's `marketplace.json` listing every
|
|
35
|
+
* published version of its public plugins (package ecosystem spec §5.2; one entry per
|
|
36
|
+
* `(name, version)`, Decision 7 of phase A3b). Each public plugin (not `"private": true`) is packed
|
|
37
|
+
* reproducibly with {@link packPlugin}; when the file has no entry for its name and version, an
|
|
38
|
+
* entry `{ name, version, summary, homepage?, permissions, source: { npm: { package, version,
|
|
39
|
+
* integrity } } }` is added (`integrity` the pack's sha512, which the release then publishes
|
|
40
|
+
* byte for byte; `permissions` display only). A version already listed is not packed or compared:
|
|
41
|
+
* once published, its bytes are the release gate's business (`release --marketplace`), so a later
|
|
42
|
+
* source or toolchain change at the same version is not a finding. With `base`, published entries
|
|
43
|
+
* must be unchanged and first in order, and entries added since must match the tree's packs.
|
|
44
|
+
* Entries of versions no longer in the tree are kept, so a pinned commit lists what it pins and
|
|
45
|
+
* older versions stay importable. The file is written formatted as Biome formats it
|
|
46
|
+
* ({@link formatMarketplaceJson}).
|
|
47
|
+
* With `write`, the new entries are appended in plugin order (nothing is written when there is any
|
|
48
|
+
* other finding).
|
|
49
|
+
*/
|
|
50
|
+
export async function syncMarketplace(opts) {
|
|
51
|
+
const findings = [];
|
|
52
|
+
const at = (rule, message) => findings.push({ file: opts.file, line: 1, rule, message });
|
|
53
|
+
if (!existsSync(opts.file)) {
|
|
54
|
+
at("marketplace-invalid", `${opts.file} does not exist: create it with schemaVersion, name, owner and "plugins": []`);
|
|
55
|
+
return { added: [], findings };
|
|
56
|
+
}
|
|
57
|
+
let raw;
|
|
58
|
+
try {
|
|
59
|
+
raw = JSON.parse(readFileSync(opts.file, "utf8"));
|
|
60
|
+
}
|
|
61
|
+
catch (e) {
|
|
62
|
+
at("marketplace-invalid", `not valid JSON: ${e instanceof Error ? e.message : String(e)}`);
|
|
63
|
+
return { added: [], findings };
|
|
64
|
+
}
|
|
65
|
+
const parsed = parseMarketplaceManifest(raw);
|
|
66
|
+
if ("errors" in parsed) {
|
|
67
|
+
at("marketplace-invalid", parsed.errors.join("; "));
|
|
68
|
+
return { added: [], findings };
|
|
69
|
+
}
|
|
70
|
+
const rawEntries = isObj(raw) && Array.isArray(raw.plugins) ? raw.plugins : [];
|
|
71
|
+
/** `name@version` of the entries added since `opts.base` (all of them without a base). */
|
|
72
|
+
let addedSinceBase;
|
|
73
|
+
if (opts.base !== undefined) {
|
|
74
|
+
const baseEntries = entriesAt(opts.root, opts.file, opts.base);
|
|
75
|
+
findings.push(...appendOnly(baseEntries, rawEntries, opts.file));
|
|
76
|
+
addedSinceBase = new Set(rawEntries.slice(baseEntries.length).map((e) => keyOfRaw(e)));
|
|
77
|
+
}
|
|
78
|
+
const added = [];
|
|
79
|
+
const out = await mkdtemp(join(tmpdir(), "capybara-marketplace-"));
|
|
80
|
+
try {
|
|
81
|
+
for (const dir of discoverPlugins(opts.root, opts.plugins)) {
|
|
82
|
+
const pkgFile = join(dir, "package.json");
|
|
83
|
+
const manifestFile = join(dir, PLUGIN_MANIFEST_FILE);
|
|
84
|
+
if (!existsSync(pkgFile) || !existsSync(manifestFile))
|
|
85
|
+
continue;
|
|
86
|
+
const pkg = JSON.parse(await readFile(pkgFile, "utf8"));
|
|
87
|
+
if (pkg.private === true)
|
|
88
|
+
continue;
|
|
89
|
+
const source = JSON.parse(await readFile(manifestFile, "utf8"));
|
|
90
|
+
const name = String(source.name);
|
|
91
|
+
const version = String(pkg.version);
|
|
92
|
+
const listed = parsed.plugins.filter((e) => e.name === name && versionOf(e) === version);
|
|
93
|
+
// A version already listed is published (or about to be): its bytes are the release gate's
|
|
94
|
+
// business, so a later source or toolchain change at the same version is not a finding here.
|
|
95
|
+
// Only an entry added since the base is compared with the tree's pack.
|
|
96
|
+
const toCompare = listed.filter((e) => addedSinceBase?.has(`${e.name}@${versionOf(e)}`));
|
|
97
|
+
if (listed.length > 0 && toCompare.length === 0)
|
|
98
|
+
continue;
|
|
99
|
+
const r = await packPlugin(dir, out);
|
|
100
|
+
const bytes = new Uint8Array(await readFile(r.file));
|
|
101
|
+
const npm = {
|
|
102
|
+
package: String(pkg.name),
|
|
103
|
+
version: r.manifest.version,
|
|
104
|
+
integrity: integrityOf(bytes),
|
|
105
|
+
};
|
|
106
|
+
for (const e of toCompare) {
|
|
107
|
+
const src = "npm" in e.source ? e.source.npm : undefined;
|
|
108
|
+
if (src?.package !== npm.package || src.integrity !== npm.integrity) {
|
|
109
|
+
const was = src === undefined
|
|
110
|
+
? `a ${Object.keys(e.source)[0]} source`
|
|
111
|
+
: `npm ${src.package} with integrity ${src.integrity}`;
|
|
112
|
+
at("marketplace-integrity", `${name}@${npm.version} is added as ${was}, but ${dir} packs npm ${npm.package} ` +
|
|
113
|
+
`with integrity ${npm.integrity}: run capybara-plugin marketplace --write`);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
if (listed.length > 0)
|
|
117
|
+
continue;
|
|
118
|
+
added.push({
|
|
119
|
+
name: r.manifest.name,
|
|
120
|
+
version: npm.version,
|
|
121
|
+
summary: { en: summaryOf(r.manifest.description) },
|
|
122
|
+
...(r.manifest.homepage !== undefined && { homepage: r.manifest.homepage }),
|
|
123
|
+
permissions: await permissionsOf(bytes, r.manifest.extension !== undefined),
|
|
124
|
+
source: { npm },
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
finally {
|
|
129
|
+
await rm(out, { recursive: true, force: true });
|
|
130
|
+
}
|
|
131
|
+
if (!opts.write) {
|
|
132
|
+
for (const e of added) {
|
|
133
|
+
at("marketplace-missing", `${e.name}@${e.version} is not listed: run capybara-plugin marketplace --write`);
|
|
134
|
+
}
|
|
135
|
+
return { added, findings };
|
|
136
|
+
}
|
|
137
|
+
if (findings.length > 0 || added.length === 0)
|
|
138
|
+
return { added, findings };
|
|
139
|
+
const next = isObj(raw) ? { ...raw } : {};
|
|
140
|
+
next.plugins = [...(Array.isArray(next.plugins) ? next.plugins : []), ...added];
|
|
141
|
+
const check = parseMarketplaceManifest(next);
|
|
142
|
+
if ("errors" in check) {
|
|
143
|
+
at("marketplace-invalid", `the new entries do not parse: ${check.errors.join("; ")}`);
|
|
144
|
+
return { added, findings };
|
|
145
|
+
}
|
|
146
|
+
await writeFile(opts.file, formatMarketplaceJson(next));
|
|
147
|
+
return { added, findings };
|
|
148
|
+
}
|
|
149
|
+
/** `name@version` of a raw entry (`?` for what is missing). */
|
|
150
|
+
function keyOfRaw(e) {
|
|
151
|
+
if (!isObj(e))
|
|
152
|
+
return "?@?";
|
|
153
|
+
const npm = isObj(e.source) && isObj(e.source.npm) ? e.source.npm : {};
|
|
154
|
+
return `${String(e.name ?? "?")}@${String(e.version ?? npm.version ?? "?")}`;
|
|
155
|
+
}
|
|
156
|
+
/** JSON with object keys sorted (deep equality of entries regardless of key order). */
|
|
157
|
+
function canonical(v) {
|
|
158
|
+
if (Array.isArray(v))
|
|
159
|
+
return `[${v.map(canonical).join(",")}]`;
|
|
160
|
+
if (isObj(v)) {
|
|
161
|
+
return `{${Object.keys(v)
|
|
162
|
+
.sort()
|
|
163
|
+
.map((k) => `${JSON.stringify(k)}:${canonical(v[k])}`)
|
|
164
|
+
.join(",")}}`;
|
|
165
|
+
}
|
|
166
|
+
return JSON.stringify(v);
|
|
167
|
+
}
|
|
168
|
+
/** The raw `plugins` of the marketplace file at git revision `base` (`[]` when it has no file). */
|
|
169
|
+
function entriesAt(root, file, base) {
|
|
170
|
+
let text;
|
|
171
|
+
try {
|
|
172
|
+
text = execFileSync("git", ["show", `${base}:./${relative(root, file).split("\\").join("/")}`], {
|
|
173
|
+
cwd: root,
|
|
174
|
+
encoding: "utf8",
|
|
175
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
catch (e) {
|
|
179
|
+
const stderr = String(e.stderr ?? "");
|
|
180
|
+
if (/does not exist|exists on disk, but not in/.test(stderr))
|
|
181
|
+
return [];
|
|
182
|
+
throw new Error(`cannot read ${file} at ${base}: ${stderr.trim() || String(e)}`);
|
|
183
|
+
}
|
|
184
|
+
const raw = JSON.parse(text);
|
|
185
|
+
return isObj(raw) && Array.isArray(raw.plugins) ? raw.plugins : [];
|
|
186
|
+
}
|
|
187
|
+
/**
|
|
188
|
+
* `marketplace-append-only`: the base's entries are published, so the current list must start with
|
|
189
|
+
* exactly them, in the same order (an entry is never edited, removed or moved; new ones go last).
|
|
190
|
+
*/
|
|
191
|
+
function appendOnly(base, current, file) {
|
|
192
|
+
const out = [];
|
|
193
|
+
base.forEach((b, i) => {
|
|
194
|
+
const c = current[i];
|
|
195
|
+
if (c !== undefined && canonical(c) === canonical(b))
|
|
196
|
+
return;
|
|
197
|
+
const key = keyOfRaw(b);
|
|
198
|
+
const moved = current.some((x) => canonical(x) === canonical(b));
|
|
199
|
+
out.push({
|
|
200
|
+
file,
|
|
201
|
+
line: 1,
|
|
202
|
+
rule: "marketplace-append-only",
|
|
203
|
+
message: `${key} is published at the base and ${moved ? "moved" : c !== undefined && keyOfRaw(c) === key ? "was edited" : "went away"}: published entries never change and new ones are appended at the end`,
|
|
204
|
+
});
|
|
205
|
+
});
|
|
206
|
+
return out;
|
|
207
|
+
}
|
|
208
|
+
const INDENT = " ";
|
|
209
|
+
/** The formatter's line width (Biome, as the Capybara repositories configure it). */
|
|
210
|
+
const LINE_WIDTH = 100;
|
|
211
|
+
const isScalar = (v) => v === null || typeof v !== "object";
|
|
212
|
+
/**
|
|
213
|
+
* `marketplace.json` as Biome formats it with a 2-space indent and a 100-column line width: a
|
|
214
|
+
* non-empty object puts each member on its own line; an array of scalars stays on one line when
|
|
215
|
+
* it fits (the trailing comma counted), otherwise one item per line; empty ones are `[]` / `{}`.
|
|
216
|
+
*/
|
|
217
|
+
export function formatMarketplaceJson(value) {
|
|
218
|
+
const render = (v, depth, lead, tail) => {
|
|
219
|
+
const pad = INDENT.repeat(depth + 1);
|
|
220
|
+
const close = INDENT.repeat(depth);
|
|
221
|
+
if (Array.isArray(v)) {
|
|
222
|
+
if (v.length === 0)
|
|
223
|
+
return "[]";
|
|
224
|
+
if (v.every(isScalar)) {
|
|
225
|
+
const flat = `[${v.map((x) => JSON.stringify(x)).join(", ")}]`;
|
|
226
|
+
if (lead + flat.length + tail <= LINE_WIDTH)
|
|
227
|
+
return flat;
|
|
228
|
+
}
|
|
229
|
+
const items = v.map((x, i) => `${pad}${render(x, depth + 1, pad.length, i < v.length - 1 ? 1 : 0)}`);
|
|
230
|
+
return `[\n${items.join(",\n")}\n${close}]`;
|
|
231
|
+
}
|
|
232
|
+
if (isObj(v)) {
|
|
233
|
+
const keys = Object.keys(v);
|
|
234
|
+
if (keys.length === 0)
|
|
235
|
+
return "{}";
|
|
236
|
+
const items = keys.map((k, i) => {
|
|
237
|
+
const head = `${pad}${JSON.stringify(k)}: `;
|
|
238
|
+
return `${head}${render(v[k], depth + 1, head.length, i < keys.length - 1 ? 1 : 0)}`;
|
|
239
|
+
});
|
|
240
|
+
return `{\n${items.join(",\n")}\n${close}}`;
|
|
241
|
+
}
|
|
242
|
+
return JSON.stringify(v);
|
|
243
|
+
};
|
|
244
|
+
return `${render(value, 0, 0, 0)}\n`;
|
|
245
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { type NpmFetchOptions } from "./npm.js";
|
|
2
|
+
/** One package {@link packBootstrap} wrote. */
|
|
3
|
+
export interface BootstrapPack {
|
|
4
|
+
name: string;
|
|
5
|
+
version: string;
|
|
6
|
+
file: string;
|
|
7
|
+
integrity: string;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Produces the bootstrap directory the image bakes (package ecosystem spec §7): every entry of the
|
|
11
|
+
* bootstrap manifest (`packages/kernel/bootstrap.json`) as `<outDir>/<name>-<version>.tgz`. A
|
|
12
|
+
* `source: "workspace"` entry is packed from `<repoRoot>/<path>` with {@link packPlugin}
|
|
13
|
+
* (byte-reproducible) and must equal the entry: the plugin name, the version and the pinned sha512,
|
|
14
|
+
* otherwise this throws naming the plugin and writes nothing for it (the image build fails rather
|
|
15
|
+
* than baking a package `capybara bootstrap` would refuse). A `source: "npm"` entry is downloaded
|
|
16
|
+
* from `opts.registry` (default the public npm registry, the kernel's `packages.npmRegistry`
|
|
17
|
+
* default; `opts.token` goes to the registry origin only) and written only when its bytes match
|
|
18
|
+
* the pinned sha512 and its package.json names the entry's package and version; a tampered,
|
|
19
|
+
* mislabelled or oversized (`bootstrap.too_large`) tarball or an unpublished package or version
|
|
20
|
+
* throws naming the plugin. So npm entries need the network at
|
|
21
|
+
* build time only: init installs from the baked directory and never downloads.
|
|
22
|
+
*/
|
|
23
|
+
export declare function packBootstrap(manifestFile: string, repoRoot: string, outDir: string, opts?: {
|
|
24
|
+
registry?: string;
|
|
25
|
+
} & NpmFetchOptions): Promise<BootstrapPack[]>;
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { copyFile, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { bootstrapFileName, parseBootstrapManifest } from "@capybara-hq/plugin-sdk";
|
|
5
|
+
import { integrityOf } from "../integrity.js";
|
|
6
|
+
import { DEFAULT_NPM_REGISTRY, downloadPinnedNpmPackage } from "./npm.js";
|
|
7
|
+
import { packPlugin } from "./pack.js";
|
|
8
|
+
/**
|
|
9
|
+
* Produces the bootstrap directory the image bakes (package ecosystem spec §7): every entry of the
|
|
10
|
+
* bootstrap manifest (`packages/kernel/bootstrap.json`) as `<outDir>/<name>-<version>.tgz`. A
|
|
11
|
+
* `source: "workspace"` entry is packed from `<repoRoot>/<path>` with {@link packPlugin}
|
|
12
|
+
* (byte-reproducible) and must equal the entry: the plugin name, the version and the pinned sha512,
|
|
13
|
+
* otherwise this throws naming the plugin and writes nothing for it (the image build fails rather
|
|
14
|
+
* than baking a package `capybara bootstrap` would refuse). A `source: "npm"` entry is downloaded
|
|
15
|
+
* from `opts.registry` (default the public npm registry, the kernel's `packages.npmRegistry`
|
|
16
|
+
* default; `opts.token` goes to the registry origin only) and written only when its bytes match
|
|
17
|
+
* the pinned sha512 and its package.json names the entry's package and version; a tampered,
|
|
18
|
+
* mislabelled or oversized (`bootstrap.too_large`) tarball or an unpublished package or version
|
|
19
|
+
* throws naming the plugin. So npm entries need the network at
|
|
20
|
+
* build time only: init installs from the baked directory and never downloads.
|
|
21
|
+
*/
|
|
22
|
+
export async function packBootstrap(manifestFile, repoRoot, outDir, opts = {}) {
|
|
23
|
+
const parsed = parseBootstrapManifest(JSON.parse(await readFile(manifestFile, "utf8")));
|
|
24
|
+
if ("errors" in parsed) {
|
|
25
|
+
throw new Error(`invalid bootstrap manifest ${manifestFile}: ${parsed.errors.join("; ")}`);
|
|
26
|
+
}
|
|
27
|
+
await mkdir(outDir, { recursive: true });
|
|
28
|
+
const staging = await mkdtemp(join(tmpdir(), "pack-bootstrap-"));
|
|
29
|
+
const out = [];
|
|
30
|
+
try {
|
|
31
|
+
for (const e of parsed.plugins) {
|
|
32
|
+
if (e.source === "npm") {
|
|
33
|
+
let bytes;
|
|
34
|
+
try {
|
|
35
|
+
bytes = await downloadPinnedNpmPackage(e, opts.registry ?? DEFAULT_NPM_REGISTRY, {
|
|
36
|
+
...(opts.token !== undefined && { token: opts.token }),
|
|
37
|
+
...(opts.maxBytes !== undefined && { maxBytes: opts.maxBytes }),
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
catch (err) {
|
|
41
|
+
throw new Error(`${e.name}: ${err instanceof Error ? err.message : String(err)}`, {
|
|
42
|
+
cause: err,
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
const file = join(outDir, bootstrapFileName(e));
|
|
46
|
+
await writeFile(file, bytes);
|
|
47
|
+
out.push({ name: e.name, version: e.version, file, integrity: e.integrity });
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
const r = await packPlugin(join(repoRoot, e.path), staging);
|
|
51
|
+
const integrity = integrityOf(new Uint8Array(await readFile(r.file)));
|
|
52
|
+
const problems = [
|
|
53
|
+
r.manifest.name !== e.name && `${e.path} packs the plugin "${r.manifest.name}"`,
|
|
54
|
+
r.manifest.version !== e.version &&
|
|
55
|
+
`version is ${e.version} but ${e.path} packs ${r.manifest.version}`,
|
|
56
|
+
integrity !== e.integrity && `integrity is ${e.integrity} but ${e.path} packs ${integrity}`,
|
|
57
|
+
].filter((p) => typeof p === "string");
|
|
58
|
+
if (problems.length > 0) {
|
|
59
|
+
throw new Error(`${e.name}: ${problems.join("; ")}; the bootstrap manifest is stale (pnpm check:bootstrap --write)`);
|
|
60
|
+
}
|
|
61
|
+
const file = join(outDir, bootstrapFileName(e));
|
|
62
|
+
await copyFile(r.file, file);
|
|
63
|
+
out.push({ name: e.name, version: e.version, file, integrity });
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
finally {
|
|
67
|
+
await rm(staging, { recursive: true, force: true });
|
|
68
|
+
}
|
|
69
|
+
return out;
|
|
70
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export declare const PACK_USAGE: string;
|
|
2
|
+
/**
|
|
3
|
+
* `capybara-plugin pack`: packs plugin directories into plugin tgz files.
|
|
4
|
+
*
|
|
5
|
+
* - `<dir>...`: the plugin directories to pack;
|
|
6
|
+
* - `--all <root>`: also every plugin of the repository at `<root>` (`<root>/plugins/*`, or `<root>`
|
|
7
|
+
* itself when it is a plugin);
|
|
8
|
+
* - `--bootstrap <manifest>`: the bootstrap directory an image bakes: every entry of the bootstrap
|
|
9
|
+
* manifest as `<name>-<version>.tgz`, checked against its pins (workspace entries' `path` is
|
|
10
|
+
* relative to `--root`, default the current directory; npm entries are downloaded from
|
|
11
|
+
* `--registry`, default `CAPYBARA_PACKAGES__NPM_REGISTRY` (the kernel's `packages.npmRegistry`)
|
|
12
|
+
* or else the public npm registry, and must match their pinned sha512; `--npm-token`, default
|
|
13
|
+
* `CAPYBARA_PACKAGES__NPM_TOKEN` (the kernel's `packages.npmToken`), is sent as a Bearer token to
|
|
14
|
+
* the registry origin only);
|
|
15
|
+
* - `--out <dir>`: output directory, default `artifacts/plugins` (`artifacts/bootstrap` with
|
|
16
|
+
* `--bootstrap`) under the current directory. The last `--out` wins.
|
|
17
|
+
*
|
|
18
|
+
* Relative paths resolve against `cwd` (the current directory). Returns the exit code.
|
|
19
|
+
*/
|
|
20
|
+
export declare function packCommand(args: string[], cwd: string): Promise<number>;
|
package/dist/pack/cli.js
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { relative, resolve } from "node:path";
|
|
2
|
+
import { parseArgs } from "node:util";
|
|
3
|
+
import { discoverPlugins } from "../discover.js";
|
|
4
|
+
import { packBootstrap } from "./bootstrap.js";
|
|
5
|
+
import { DEFAULT_NPM_REGISTRY } from "./npm.js";
|
|
6
|
+
import { packPlugin } from "./pack.js";
|
|
7
|
+
export const PACK_USAGE = "capybara-plugin pack [<dir>...] [--all <root>] [--out <dir>]\n" +
|
|
8
|
+
" capybara-plugin pack --bootstrap <manifest> [--root <dir>] [--out <dir>] [--registry <url>]\n" +
|
|
9
|
+
" [--npm-token <token>]";
|
|
10
|
+
/**
|
|
11
|
+
* `capybara-plugin pack`: packs plugin directories into plugin tgz files.
|
|
12
|
+
*
|
|
13
|
+
* - `<dir>...`: the plugin directories to pack;
|
|
14
|
+
* - `--all <root>`: also every plugin of the repository at `<root>` (`<root>/plugins/*`, or `<root>`
|
|
15
|
+
* itself when it is a plugin);
|
|
16
|
+
* - `--bootstrap <manifest>`: the bootstrap directory an image bakes: every entry of the bootstrap
|
|
17
|
+
* manifest as `<name>-<version>.tgz`, checked against its pins (workspace entries' `path` is
|
|
18
|
+
* relative to `--root`, default the current directory; npm entries are downloaded from
|
|
19
|
+
* `--registry`, default `CAPYBARA_PACKAGES__NPM_REGISTRY` (the kernel's `packages.npmRegistry`)
|
|
20
|
+
* or else the public npm registry, and must match their pinned sha512; `--npm-token`, default
|
|
21
|
+
* `CAPYBARA_PACKAGES__NPM_TOKEN` (the kernel's `packages.npmToken`), is sent as a Bearer token to
|
|
22
|
+
* the registry origin only);
|
|
23
|
+
* - `--out <dir>`: output directory, default `artifacts/plugins` (`artifacts/bootstrap` with
|
|
24
|
+
* `--bootstrap`) under the current directory. The last `--out` wins.
|
|
25
|
+
*
|
|
26
|
+
* Relative paths resolve against `cwd` (the current directory). Returns the exit code.
|
|
27
|
+
*/
|
|
28
|
+
export async function packCommand(args, cwd) {
|
|
29
|
+
const { values, positionals } = parseArgs({
|
|
30
|
+
args,
|
|
31
|
+
allowPositionals: true,
|
|
32
|
+
options: {
|
|
33
|
+
help: { type: "boolean", short: "h", default: false },
|
|
34
|
+
all: { type: "string" },
|
|
35
|
+
out: { type: "string" },
|
|
36
|
+
bootstrap: { type: "string" },
|
|
37
|
+
root: { type: "string" },
|
|
38
|
+
registry: { type: "string" },
|
|
39
|
+
"npm-token": { type: "string" },
|
|
40
|
+
},
|
|
41
|
+
});
|
|
42
|
+
if (values.help) {
|
|
43
|
+
console.log(`usage: ${PACK_USAGE}`);
|
|
44
|
+
return 0;
|
|
45
|
+
}
|
|
46
|
+
const outOf = (fallback) => resolve(cwd, values.out ?? fallback);
|
|
47
|
+
const shown = (p) => {
|
|
48
|
+
const rel = relative(cwd, p);
|
|
49
|
+
return rel === "" ? "." : rel.startsWith("..") ? p : rel;
|
|
50
|
+
};
|
|
51
|
+
if (values.bootstrap !== undefined) {
|
|
52
|
+
if (values.all !== undefined || positionals.length > 0) {
|
|
53
|
+
return usage("--bootstrap takes no plugin directory");
|
|
54
|
+
}
|
|
55
|
+
try {
|
|
56
|
+
const root = values.root === undefined ? cwd : resolve(cwd, values.root);
|
|
57
|
+
const manifest = resolve(cwd, values.bootstrap);
|
|
58
|
+
const registry = values.registry ?? (process.env.CAPYBARA_PACKAGES__NPM_REGISTRY || DEFAULT_NPM_REGISTRY);
|
|
59
|
+
const token = values["npm-token"] ?? process.env.CAPYBARA_PACKAGES__NPM_TOKEN;
|
|
60
|
+
for (const p of await packBootstrap(manifest, root, outOf("artifacts/bootstrap"), {
|
|
61
|
+
registry,
|
|
62
|
+
...(token !== undefined && token !== "" && { token }),
|
|
63
|
+
})) {
|
|
64
|
+
console.log(`${shown(p.file)} ${p.integrity}`);
|
|
65
|
+
}
|
|
66
|
+
return 0;
|
|
67
|
+
}
|
|
68
|
+
catch (e) {
|
|
69
|
+
console.error(e instanceof Error ? e.message : String(e));
|
|
70
|
+
return 1;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
const plugins = positionals.map((d) => resolve(cwd, d));
|
|
74
|
+
if (values.all !== undefined) {
|
|
75
|
+
const root = resolve(cwd, values.all);
|
|
76
|
+
const found = discoverPlugins(root);
|
|
77
|
+
if (found.length === 0) {
|
|
78
|
+
console.error(`${shown(root)}: no plugin found (no plugins/* and no .capybara-plugin/)`);
|
|
79
|
+
return 1;
|
|
80
|
+
}
|
|
81
|
+
plugins.push(...found.filter((d) => !plugins.includes(d)));
|
|
82
|
+
}
|
|
83
|
+
if (plugins.length === 0)
|
|
84
|
+
return usage("no plugin directory given");
|
|
85
|
+
const out = outOf("artifacts/plugins");
|
|
86
|
+
let failed = 0;
|
|
87
|
+
for (const dir of plugins) {
|
|
88
|
+
try {
|
|
89
|
+
const r = await packPlugin(dir, out);
|
|
90
|
+
console.log(`${shown(r.file)} sha256=${r.sha256}`);
|
|
91
|
+
for (const d of r.dropped) {
|
|
92
|
+
console.warn(`${shown(dir)}: warning: "files" entry "${d}" is not in the package`);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
catch (e) {
|
|
96
|
+
failed++;
|
|
97
|
+
console.error(`${shown(dir)}: ${e instanceof Error ? e.message : String(e)}`);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
if (failed > 0) {
|
|
101
|
+
console.error(`\n${failed} plugin(s) failed to pack`);
|
|
102
|
+
return 1;
|
|
103
|
+
}
|
|
104
|
+
return 0;
|
|
105
|
+
}
|
|
106
|
+
function usage(msg) {
|
|
107
|
+
console.error(`${msg}\nusage: ${PACK_USAGE}`);
|
|
108
|
+
return 2;
|
|
109
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export { type BootstrapPack, packBootstrap } from "./bootstrap.js";
|
|
2
|
+
export { PACK_USAGE, packCommand } from "./cli.js";
|
|
3
|
+
export { assertNpmTarballLabel, DEFAULT_NPM_REGISTRY, downloadPinnedNpmPackage, fetchNpmTarball, NPM_TARBALL_MAX_BYTES, type NpmFetchOptions, NpmPackageError, type NpmPackageErrorKind, } from "./npm.js";
|
|
4
|
+
export { METADATA_FIELDS, NOTICES_FILE, type PackResult, packPlugin, STAGING_PREFIX, } from "./pack.js";
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export { packBootstrap } from "./bootstrap.js";
|
|
2
|
+
export { PACK_USAGE, packCommand } from "./cli.js";
|
|
3
|
+
export { assertNpmTarballLabel, DEFAULT_NPM_REGISTRY, downloadPinnedNpmPackage, fetchNpmTarball, NPM_TARBALL_MAX_BYTES, NpmPackageError, } from "./npm.js";
|
|
4
|
+
export { METADATA_FIELDS, NOTICES_FILE, packPlugin, STAGING_PREFIX, } from "./pack.js";
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/** The npm registry the kernel uses by default (`packages.npmRegistry`). */
|
|
2
|
+
export declare const DEFAULT_NPM_REGISTRY = "https://registry.npmjs.org";
|
|
3
|
+
/**
|
|
4
|
+
* The largest tarball downloaded: the kernel's `packages.maxBytes` default
|
|
5
|
+
* (`PACKAGES_MAX_BYTES_DEFAULT`, 50 MiB; this subpath does not import the kernel, a test keeps the
|
|
6
|
+
* two equal). A bigger plugin could not be installed by a kernel with the default limit anyway.
|
|
7
|
+
*/
|
|
8
|
+
export declare const NPM_TARBALL_MAX_BYTES = 52428800;
|
|
9
|
+
/** Why {@link fetchNpmTarball} or {@link downloadPinnedNpmPackage} failed. */
|
|
10
|
+
export type NpmPackageErrorKind =
|
|
11
|
+
/** The package or that version is not on the registry. */
|
|
12
|
+
"unpublished"
|
|
13
|
+
/** The registry's metadata or bytes do not match the pinned sha512. */
|
|
14
|
+
| "integrity"
|
|
15
|
+
/** Network error, HTTP status, or a packument that cannot be read. */
|
|
16
|
+
| "fetch"
|
|
17
|
+
/** The tarball is larger than the cap ({@link NPM_TARBALL_MAX_BYTES}): `bootstrap.too_large`. */
|
|
18
|
+
| "too_large"
|
|
19
|
+
/** The tarball's `package/package.json` names another package or version than was asked for. */
|
|
20
|
+
| "mislabelled";
|
|
21
|
+
/** Options of {@link fetchNpmTarball} and {@link downloadPinnedNpmPackage}. */
|
|
22
|
+
export interface NpmFetchOptions {
|
|
23
|
+
/**
|
|
24
|
+
* Bearer token for a private registry (`CAPYBARA_PACKAGES__NPM_TOKEN`, the kernel's
|
|
25
|
+
* `packages.npmToken`): sent only to the registry's origin, never to a tarball URL elsewhere.
|
|
26
|
+
*/
|
|
27
|
+
token?: string;
|
|
28
|
+
/** Tarball size cap in bytes; default {@link NPM_TARBALL_MAX_BYTES}. */
|
|
29
|
+
maxBytes?: number;
|
|
30
|
+
}
|
|
31
|
+
/** A pinned npm package could not be fetched or does not match its pin. English message. */
|
|
32
|
+
export declare class NpmPackageError extends Error {
|
|
33
|
+
readonly kind: NpmPackageErrorKind;
|
|
34
|
+
constructor(kind: NpmPackageErrorKind, message: string);
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* `<pkg>@<version>` from an npm registry: its tarball bytes and the packument's `dist.integrity`
|
|
38
|
+
* (whatever the registry claims; callers compare it with their pin). The packument is asked for in
|
|
39
|
+
* npm's abbreviated form; `opts.token` goes to the registry origin only; the tarball is read up to
|
|
40
|
+
* `opts.maxBytes`. Throws {@link NpmPackageError}: `unpublished` (404 or no such version),
|
|
41
|
+
* `too_large`, `fetch` (anything else). The bytes are not judged here: see
|
|
42
|
+
* {@link downloadPinnedNpmPackage} and {@link assertNpmTarballLabel}.
|
|
43
|
+
*/
|
|
44
|
+
export declare function fetchNpmTarball(pkg: string, version: string, registry?: string, opts?: NpmFetchOptions): Promise<{
|
|
45
|
+
bytes: Uint8Array;
|
|
46
|
+
distIntegrity: string | undefined;
|
|
47
|
+
tarball: string;
|
|
48
|
+
}>;
|
|
49
|
+
/**
|
|
50
|
+
* Downloads `<pkg>@<version>` and checks its bytes against the pinned sha512 `integrity`; returns the
|
|
51
|
+
* bytes only when they match (a tampered tarball, or a registry serving other bytes than the pin,
|
|
52
|
+
* throws {@link NpmPackageError} `integrity`), then checks its label
|
|
53
|
+
* ({@link assertNpmTarballLabel}). The registry's own `dist.integrity` plays no part: only the
|
|
54
|
+
* bytes are judged.
|
|
55
|
+
*/
|
|
56
|
+
export declare function downloadPinnedNpmPackage(pin: {
|
|
57
|
+
npm: string;
|
|
58
|
+
version: string;
|
|
59
|
+
integrity: string;
|
|
60
|
+
}, registry?: string, opts?: NpmFetchOptions): Promise<Uint8Array>;
|
|
61
|
+
/**
|
|
62
|
+
* The tarball's `package/package.json` must name `pkg` at `version` (a pin taken from another
|
|
63
|
+
* package's tarball, or a registry serving one under the wrong name); throws
|
|
64
|
+
* {@link NpmPackageError} `mislabelled` otherwise (`fetch` when the bytes are no tgz).
|
|
65
|
+
*/
|
|
66
|
+
export declare function assertNpmTarballLabel(bytes: Uint8Array, pkg: string, version: string): Promise<void>;
|