@snap-rail/plugin-kit 0.0.0-stage → 0.2.1

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/lib/pack.js ADDED
@@ -0,0 +1,205 @@
1
+ import { CLIENT_EXTERNALS } from "./seeds.js";
2
+ import { createRequire } from "node:module";
3
+ import { readFile, readdir } from "node:fs/promises";
4
+ import { join, relative } from "node:path";
5
+ import { createHash } from "node:crypto";
6
+ import { unzipSync } from "fflate";
7
+ //#region lib/types/pack.js
8
+ /**
9
+ * Final-format assembly: turn a built plugin package into the file set of
10
+ * its installable zip. The zip is **the release artifact, not the source
11
+ * tree** — a trimmed manifest whose `main` names the host face (or, for
12
+ * `clientOnly` packages, the client bundle), the built faces themselves, and
13
+ * an `exports` map covering the subpaths pool-resident importers resolve
14
+ * (`./contract` and friends). The settings page's 安装插件, the CLI packer,
15
+ * the feed publishers, and the offline combo assembler all consume the same
16
+ * shape.
17
+ *
18
+ * @module @snap-rail/plugin-kit/pack
19
+ */
20
+ /** Files a release never carries even inside a face directory. */
21
+ function isBuildPlumbing(path) {
22
+ return path.endsWith(".d.ts") || path.endsWith(".d.ts.map") || path.endsWith(".js.map") || path.endsWith(".tsbuildinfo");
23
+ }
24
+ /**
25
+ * Derive the release manifest: the npm identity plus `main` plus the trimmed
26
+ * `exports` map. Everything else (scripts, dev dependencies) is workspace
27
+ * plumbing the pool never reads and the zip never carries. A stamped seed
28
+ * ABI merges into `snapRail.client` — the build-truth declaration replaces
29
+ * anything a source manifest may have carried.
30
+ *
31
+ * @param source - the parsed source `package.json`.
32
+ * @param main - the entry the manifest `main` names.
33
+ * @param exports - subpath exports beyond `.`; the root voice is always
34
+ * included.
35
+ * @param seedAbi - the pack-time seed-contract stamp for client faces.
36
+ * @returns the manifest serialized for the zip root.
37
+ */
38
+ function releaseManifest(source, main, exports, seedAbi) {
39
+ const snapRail = source.snapRail;
40
+ const manifest = {
41
+ name: source.name,
42
+ version: typeof source.version === "string" ? source.version : "0.0.0",
43
+ description: typeof source.description === "string" ? source.description : "",
44
+ type: "module",
45
+ main,
46
+ exports: {
47
+ ".": main,
48
+ ...exports
49
+ }
50
+ };
51
+ if (snapRail !== void 0) manifest.snapRail = seedAbi === void 0 ? snapRail : {
52
+ ...snapRail,
53
+ client: {
54
+ ...snapRail.client,
55
+ seedAbi
56
+ }
57
+ };
58
+ return `${JSON.stringify(manifest, null, 2)}\n`;
59
+ }
60
+ /** The base npm package of a seed id (`react/jsx-runtime` → `react`;
61
+ * `@scope/name/sub` → `@scope/name`). */
62
+ function basePackageOf(seedId) {
63
+ const parts = seedId.split("/");
64
+ return seedId.startsWith("@") ? parts.slice(0, 2).join("/") : parts[0];
65
+ }
66
+ /**
67
+ * Stamp the seed ABI a built client face was built against: the seeds its
68
+ * bundle actually requires (a `require("…")` literal scan — externals are
69
+ * the only bare requires the welded CJS output carries) plus each seed's
70
+ * base-package version resolved from the package's own dependency tree.
71
+ * Ids whose base package cannot be resolved here are omitted — the runtime
72
+ * check compares only the intersection, so an omitted id is unchecked,
73
+ * never failed. Returns `undefined` when the bundle requires no seeds or
74
+ * none could be versioned (nothing to enforce).
75
+ *
76
+ * @param packageDir - the package being packed (version resolution root).
77
+ * @param bundleCode - the built client bundle's source text.
78
+ */
79
+ async function seedAbiStamp(packageDir, bundleCode) {
80
+ const seeds = new Set(CLIENT_EXTERNALS);
81
+ const required = /* @__PURE__ */ new Set();
82
+ for (const match of bundleCode.matchAll(/require\(\s*(['"])([^'"\n]+)\1\s*\)/g)) {
83
+ const id = match[2];
84
+ if (id !== void 0 && seeds.has(id)) required.add(id);
85
+ }
86
+ if (required.size === 0) return void 0;
87
+ const require = createRequire(join(packageDir, "package.json"));
88
+ const versions = {};
89
+ for (const id of required) try {
90
+ const manifestPath = require.resolve(`${basePackageOf(id)}/package.json`);
91
+ const parsed = JSON.parse(await readFile(manifestPath, "utf8"));
92
+ if (typeof parsed.version === "string" && parsed.version !== "") versions[id] = parsed.version;
93
+ } catch {}
94
+ if (Object.keys(versions).length === 0) return void 0;
95
+ return {
96
+ revision: 1,
97
+ versions
98
+ };
99
+ }
100
+ /** Collect every file under `dir` (zip paths use forward slashes). */
101
+ async function collectDir(dir, base) {
102
+ const files = {};
103
+ for (const entry of await readdir(dir, { withFileTypes: true })) {
104
+ const full = join(dir, entry.name);
105
+ if (entry.isDirectory()) {
106
+ Object.assign(files, await collectDir(full, base));
107
+ continue;
108
+ }
109
+ const rel = relative(base, full).replaceAll("\\", "/");
110
+ if (isBuildPlumbing(rel)) continue;
111
+ files[rel] = new Uint8Array(await readFile(full));
112
+ }
113
+ return files;
114
+ }
115
+ /**
116
+ * Assemble one package's release file set: the release manifest, the declared
117
+ * faces, and the exports targets. Fails loud when the source manifest drifts
118
+ * from the spec or a declared face is missing on disk, so a broken build can
119
+ * never ship as a plausible zip.
120
+ *
121
+ * @param spec - what the package ships and from where.
122
+ * @returns the zip's files, keyed by package-relative path.
123
+ */
124
+ async function releaseFiles(spec) {
125
+ let source;
126
+ try {
127
+ source = JSON.parse(await readFile(join(spec.dir, "package.json"), "utf8"));
128
+ } catch (cause) {
129
+ throw new Error(`pack: cannot read the manifest of ${spec.name}`, { cause });
130
+ }
131
+ if (source.name !== spec.name) throw new Error(`pack: manifest name ${String(source.name)} does not match the spec ${spec.name}`);
132
+ const snapRail = source.snapRail;
133
+ const declaredClient = snapRail?.client !== void 0;
134
+ if (declaredClient !== (spec.clientFace !== void 0)) throw new Error(`pack: ${spec.name} declares snapRail.client ${declaredClient ? "with" : "without"} a clientFace directory`);
135
+ const clientOnly = snapRail?.clientOnly === true;
136
+ if (spec.hostFace === void 0 && !clientOnly) throw new Error(`pack: ${spec.name} has no host face and does not declare snapRail.clientOnly`);
137
+ if (spec.hostFace !== void 0 && spec.hostDir === void 0) throw new Error(`pack: ${spec.name} declares a host face without a hostDir`);
138
+ if (spec.hostFace === void 0 && spec.clientFace === void 0) throw new Error(`pack: ${spec.name} ships neither a host face nor a client bundle`);
139
+ const clientEntry = typeof snapRail?.client?.entry === "string" ? snapRail.client.entry : "lib/client.js";
140
+ const main = spec.hostFace ?? clientEntry;
141
+ const files = {};
142
+ if (spec.hostDir !== void 0) {
143
+ Object.assign(files, await collectDir(join(spec.dir, spec.hostDir), spec.dir));
144
+ for (const path of Object.keys(files)) if (path === `${spec.hostDir}/types` || path.startsWith(`${spec.hostDir}/types/`)) delete files[path];
145
+ }
146
+ if (spec.clientFace !== void 0) Object.assign(files, await collectDir(join(spec.dir, spec.clientFace), spec.dir));
147
+ if (spec.hostFace !== void 0 && files[spec.hostFace] === void 0) throw new Error(`pack: ${spec.name} is missing its host face ${spec.hostFace} under ${String(spec.hostDir)}`);
148
+ if (declaredClient && files[clientEntry] === void 0) throw new Error(`pack: ${spec.name} declares client entry ${clientEntry} but the zip does not carry it`);
149
+ const seedAbi = declaredClient && files[clientEntry] !== void 0 ? await seedAbiStamp(spec.dir, new TextDecoder().decode(files[clientEntry])) : void 0;
150
+ files["package.json"] = new TextEncoder().encode(releaseManifest(source, main, spec.exports, seedAbi));
151
+ for (const [subpath, target] of Object.entries(spec.exports ?? {})) if (files[target] === void 0) throw new Error(`pack: ${spec.name} exports ${subpath} to ${target} but the zip does not carry it`);
152
+ for (const path of Object.keys(files)) if (path.startsWith("src/")) throw new Error(`pack: ${spec.name} would ship a source file (${path})`);
153
+ return files;
154
+ }
155
+ /** The zip file name a package's release takes under dist-plugins/. */
156
+ function zipFileName(packageName) {
157
+ return `${packageName.replaceAll("/", "__")}.zip`;
158
+ }
159
+ /**
160
+ * The one feed-entry emitter every producer shares: read a plugin zip's own
161
+ * bytes back (release manifest + signature envelope), derive the
162
+ * `index.json` entry from exactly what the zip carries, and digest the exact
163
+ * bytes the client will download — the feed can never advertise something a
164
+ * zip does not. The repo's pack/collect pipeline and the standalone
165
+ * `snap-rail-plugin feed` command all converge here.
166
+ *
167
+ * @param zipped - the exact zip bytes the feed will serve.
168
+ * @param file - the pure file name the entry advertises (no path).
169
+ * @param fallbackName - the entry's name when the manifest carries none.
170
+ * @param options.requireSignature - refuse unsigned zips (feed policy).
171
+ * @param options.repository - an optional source-repository URL.
172
+ * @returns the entry, unknown fields stripped by construction.
173
+ * @throws when the zip has no readable manifest, or no signature while
174
+ * `requireSignature` is set.
175
+ */
176
+ function catalogEntryFromZip(zipped, file, fallbackName, options = {}) {
177
+ const files = unzipSync(zipped);
178
+ const manifestFile = files["package.json"];
179
+ if (manifestFile === void 0) throw new Error(`catalog: ${file} 缺少 package.json`);
180
+ const manifest = JSON.parse(new TextDecoder().decode(manifestFile));
181
+ const signatureFile = files["META-INF/signature.json"];
182
+ if (signatureFile === void 0 && options.requireSignature === true) throw new Error(`catalog: ${file} 没有签名(META-INF/signature.json 缺失)`);
183
+ const signature = signatureFile === void 0 ? void 0 : JSON.parse(new TextDecoder().decode(signatureFile));
184
+ return {
185
+ name: manifest.name ?? fallbackName,
186
+ version: manifest.version ?? "0.0.0",
187
+ ...manifest.description !== void 0 && manifest.description !== "" ? { description: manifest.description } : {},
188
+ ...manifest.snapRail?.kind !== void 0 ? { kind: manifest.snapRail.kind } : {},
189
+ ...manifest.snapRail?.title !== void 0 ? { title: manifest.snapRail.title } : {},
190
+ ...manifest.snapRail?.category !== void 0 ? { category: manifest.snapRail.category } : {},
191
+ ...manifest.snapRail?.requires !== void 0 ? { requires: manifest.snapRail.requires } : {},
192
+ ...manifest.snapRail?.permissions !== void 0 ? { permissions: manifest.snapRail.permissions } : {},
193
+ ...manifest.snapRail?.engines !== void 0 ? { engines: manifest.snapRail.engines } : {},
194
+ file,
195
+ sha256: createHash("sha256").update(zipped).digest("hex"),
196
+ size: zipped.length,
197
+ ...signature === void 0 ? {} : { signature: {
198
+ algorithm: signature.algorithm,
199
+ keyId: signature.keyId
200
+ } },
201
+ ...options.repository === void 0 ? {} : { repository: options.repository }
202
+ };
203
+ }
204
+ //#endregion
205
+ export { catalogEntryFromZip, releaseFiles, releaseManifest, zipFileName };
package/lib/recipe.js ADDED
@@ -0,0 +1,51 @@
1
+ import { z } from "zod";
2
+ /** One combo row: exactly the user-layer row shape, strict. */
3
+ const recipeRowSchema = z.object({
4
+ name: z.string().regex(/^(@[a-z0-9-]+\/[a-z0-9-]+|[a-z0-9-]+)(\/[a-z0-9-]+)*$/),
5
+ enabled: z.boolean().optional(),
6
+ config: z.unknown().optional()
7
+ }).strict();
8
+ /** A combo package's document: identity + the rows the preset carries. */
9
+ const recipeDocumentSchema = z.object({
10
+ /** Stable id; also the preset file name (`recipe-<id>.yml`). */
11
+ id: z.string().regex(/^[a-z0-9-]+$/),
12
+ version: z.string().min(1),
13
+ title: z.string().min(1),
14
+ kind: z.enum(["base", "scenario"]),
15
+ description: z.string().optional(),
16
+ plugins: z.array(recipeRowSchema).min(1)
17
+ }).strict();
18
+ /**
19
+ * The real package names a combo references, deduplicated in document order.
20
+ * A subpath row (`@snap-rail/equipment/rpc`) addresses the package
21
+ * `@snap-rail/equipment`; the combo archive carries one zip per package.
22
+ *
23
+ * @param document - the parsed combo document.
24
+ * @returns unique package names in first-reference order.
25
+ */
26
+ function recipePackageNames(document) {
27
+ const names = [];
28
+ const seen = /* @__PURE__ */ new Set();
29
+ for (const row of document.plugins) {
30
+ const name = packageNameOf(row.name);
31
+ if (seen.has(name)) continue;
32
+ seen.add(name);
33
+ names.push(name);
34
+ }
35
+ return names;
36
+ }
37
+ /**
38
+ * The npm package a row belongs to (`@scope/pkg/sub` → `@scope/pkg`).
39
+ *
40
+ * @param name - a package name or subpath entry.
41
+ * @returns the package name.
42
+ */
43
+ function packageNameOf(name) {
44
+ if (!name.startsWith("@")) return name.split("/")[0];
45
+ const slash = name.indexOf("/");
46
+ if (slash < 0) return name;
47
+ const second = name.indexOf("/", slash + 1);
48
+ return second < 0 ? name : name.slice(0, second);
49
+ }
50
+ //#endregion
51
+ export { packageNameOf, recipeDocumentSchema, recipePackageNames, recipeRowSchema };
package/lib/seeds.js ADDED
@@ -0,0 +1,117 @@
1
+ //#region lib/types/seeds.js
2
+ /**
3
+ * The renderer's shared-module vocabulary, in two tiers.
4
+ *
5
+ * `SEED_MODULES` is the **base tier**: the ids the shell itself seeds (by
6
+ * importing them statically into the app bundle) and parity-checks at boot.
7
+ * Everything a client bundle `require`s beyond this list must either be
8
+ * bundled in or contributed (below). Cross-plugin value imports are
9
+ * forbidden (collaboration goes through cordis services and slots); spine
10
+ * contract faces (`./contract` — zod + types only) and pure utility faces
11
+ * qualify; package host entries never do — the node side (store, drizzle,
12
+ * node:sqlite) must stay out of the client graph. Adding a base id is a
13
+ * spine decision.
14
+ *
15
+ * `CONTRIBUTED_SEED_MODULES` is the **installed tier**: ids that arrive with
16
+ * an installed plugin's client face (the framework's primitives, an
17
+ * extension pack's components). The provider's client entry default-exports
18
+ * a `seeds` map (`{ seeds: { ui: namespace, … } }`); the shell requires the
19
+ * face once and seeds every `<package>/<key>` id from that map, so every
20
+ * consumer shares the provider's single instances. The shell never seeds
21
+ * these itself — an absent provider simply leaves the ids unseeded and
22
+ * dependent faces skip with a load error instead of mounting half a UI.
23
+ *
24
+ * `CLIENT_EXTERNALS` (the union) is the external surface client bundles
25
+ * build against: the plugin-kit's client preset externalizes exactly these
26
+ * ids.
27
+ *
28
+ * @module @snap-rail/plugin-kit/seeds
29
+ */
30
+ /** Module ids the shell seeds and every client bundle may require. */
31
+ const SEED_MODULES = [
32
+ "react",
33
+ "react/jsx-runtime",
34
+ "react-dom",
35
+ "react-dom/client",
36
+ "@snap-rail/cordis",
37
+ "@snap-rail/cordis-plugin-timer",
38
+ "@snap-rail/client-kernel",
39
+ "@snap-rail/client-modules",
40
+ "@snap-rail/connection",
41
+ "@snap-rail/protocol",
42
+ "@snap-rail/station-rpc/contract",
43
+ "@snap-rail/app-boot/contract",
44
+ "@snap-rail/equipment/contract",
45
+ "@snap-rail/util",
46
+ "zod"
47
+ ];
48
+ /**
49
+ * Module ids installed plugins contribute through their client face's
50
+ * `seeds` map — the framework tier: every face of an installed UI framework
51
+ * (primitives, slot registry, runtime, fallback) serves as a shared
52
+ * instance for the components bound to it. The equipment wire contract
53
+ * rides the base tier instead — it is the station platform's API surface,
54
+ * not an installed framework's; a scenario's own contract stays inside its
55
+ * suite package. A third-party framework contributes its own ids; this
56
+ * list covers the in-repo builds.
57
+ */
58
+ const CONTRIBUTED_SEED_MODULES = [
59
+ "@snap-rail/framework-shadcn/ui",
60
+ "@snap-rail/framework-shadcn/slots",
61
+ "@snap-rail/framework-shadcn/semantic",
62
+ "@snap-rail/framework-shadcn/settings-pages",
63
+ "@snap-rail/framework-shadcn/session",
64
+ "@snap-rail/framework-shadcn/workflows",
65
+ "@snap-rail/framework-shadcn/variables",
66
+ "@snap-rail/framework-shadcn/runtime",
67
+ "@snap-rail/framework-shadcn/fallback"
68
+ ];
69
+ /** The full external surface for in-repo client-bundle builds. */
70
+ const CLIENT_EXTERNALS = [...SEED_MODULES, ...CONTRIBUTED_SEED_MODULES];
71
+ /**
72
+ * The seed contract's ABI revision. Bump when the contract between the
73
+ * shell's seed table and client bundles changes shape — interop semantics,
74
+ * a seed id's export shape. Packs stamp every client face with the revision
75
+ * it was built against (`snapRail.client.seedAbi`); the shell refuses a
76
+ * face stamped with a foreign revision instead of letting it crash the
77
+ * renderer (r1 exists because a seed default-import compiled to a namespace
78
+ * where the author meant a class — the shape must be checkable).
79
+ */
80
+ const SEED_ABI_REVISION = 1;
81
+ /**
82
+ * Compare a client face's stamped seed ABI against the live table. Only the
83
+ * ids present in BOTH maps are compared (a stamp covers exactly what its
84
+ * build could resolve): the revision must match exactly, every shared
85
+ * version must agree on the major. An absent declaration passes — zips
86
+ * predating the stamping stay installable, marked for the caller to log.
87
+ */
88
+ function checkSeedAbi(declared, host) {
89
+ if (declared === void 0) return {
90
+ ok: true,
91
+ undeclared: true
92
+ };
93
+ if (declared.revision !== host.revision) return {
94
+ ok: false,
95
+ undeclared: false,
96
+ reason: `插件构建于种子 ABI r${declared.revision},宿主为 r${host.revision} —— 请更新插件包或桌面应用`
97
+ };
98
+ for (const [id, version] of Object.entries(declared.versions)) {
99
+ const live = host.versions[id];
100
+ if (live === void 0) continue;
101
+ if (majorOf(version) !== majorOf(live)) return {
102
+ ok: false,
103
+ undeclared: false,
104
+ reason: `插件构建于 ${id}@${version},宿主提供 ${live}(主版本不一致)`
105
+ };
106
+ }
107
+ return {
108
+ ok: true,
109
+ undeclared: false
110
+ };
111
+ }
112
+ /** The npm major of a version string; unparsable → NaN, which never equals. */
113
+ function majorOf(version) {
114
+ return Number.parseInt(version.split(".")[0] ?? "", 10);
115
+ }
116
+ //#endregion
117
+ export { CLIENT_EXTERNALS, CONTRIBUTED_SEED_MODULES, SEED_ABI_REVISION, SEED_MODULES, checkSeedAbi };
package/lib/sign.js ADDED
@@ -0,0 +1,107 @@
1
+ import { createHash, sign, verify } from "node:crypto";
2
+ //#region lib/types/sign.js
3
+ /**
4
+ * Release metadata and Ed25519 signatures for installable plugins.
5
+ *
6
+ * The signature covers a canonical release manifest. The manifest contains a
7
+ * digest for every payload file, so the verifier authenticates both the
8
+ * publisher and the bytes that will be installed without signing the zip
9
+ * container itself.
10
+ *
11
+ * @module @snap-rail/plugin-kit/sign
12
+ */
13
+ /** The metadata file embedded in a release archive. */
14
+ const RELEASE_METADATA_PATH = "META-INF/release.json";
15
+ /** The signature envelope embedded in a release archive. */
16
+ const RELEASE_SIGNATURE_PATH = "META-INF/signature.json";
17
+ /** Stable JSON serialization used as the Ed25519 signing input. */
18
+ function canonicalJson(value) {
19
+ return `${canonicalValue(value)}\n`;
20
+ }
21
+ function canonicalValue(value) {
22
+ if (value === null || typeof value === "boolean" || typeof value === "number" || typeof value === "string") {
23
+ const encoded = JSON.stringify(value);
24
+ if (encoded === void 0) throw new TypeError("release metadata contains an unsupported primitive");
25
+ return encoded;
26
+ }
27
+ if (Array.isArray(value)) return `[${value.map(canonicalValue).join(",")}]`;
28
+ if (typeof value !== "object") throw new TypeError("release metadata contains an unsupported value");
29
+ return `{${Object.entries(value).sort(([a], [b]) => a.localeCompare(b)).map(([key, item]) => `${JSON.stringify(key)}:${canonicalValue(item)}`).join(",")}}`;
30
+ }
31
+ /** SHA-256 digest of one payload file. */
32
+ function sha256(data) {
33
+ return createHash("sha256").update(data).digest("hex");
34
+ }
35
+ /**
36
+ * Build signed metadata from a release file set. Metadata and signature files
37
+ * are excluded from the digest table so signing never becomes recursive.
38
+ */
39
+ function releaseMetadata(files, options) {
40
+ const digests = {};
41
+ for (const path of Object.keys(files).sort()) {
42
+ if (path === "META-INF/release.json" || path === "META-INF/signature.json") continue;
43
+ digests[path] = `sha256:${sha256(files[path])}`;
44
+ }
45
+ return {
46
+ format: 1,
47
+ name: options.name,
48
+ version: options.version,
49
+ ...options.publisher === void 0 ? {} : { publisher: options.publisher },
50
+ createdAt: options.createdAt ?? (/* @__PURE__ */ new Date()).toISOString(),
51
+ files: digests
52
+ };
53
+ }
54
+ /** Sign release metadata with an Ed25519 private key. */
55
+ function signReleaseMetadata(metadata, key) {
56
+ const bytes = Buffer.from(canonicalJson(metadata), "utf8");
57
+ return {
58
+ algorithm: "Ed25519",
59
+ keyId: key.keyId,
60
+ signedFile: RELEASE_METADATA_PATH,
61
+ signature: sign(null, bytes, key.privateKey).toString("base64")
62
+ };
63
+ }
64
+ /** Add signed metadata and signature files to a release file set. */
65
+ function signReleaseFiles(files, options) {
66
+ const metadata = releaseMetadata(files, options);
67
+ const signature = signReleaseMetadata(metadata, options);
68
+ return {
69
+ ...files,
70
+ [RELEASE_METADATA_PATH]: new TextEncoder().encode(canonicalJson(metadata)),
71
+ [RELEASE_SIGNATURE_PATH]: new TextEncoder().encode(canonicalJson(signature))
72
+ };
73
+ }
74
+ /** Verify release metadata, signature and every payload file digest. */
75
+ function verifyReleaseFiles(files, trustedKeys) {
76
+ const metadataBytes = files[RELEASE_METADATA_PATH];
77
+ const signatureBytes = files[RELEASE_SIGNATURE_PATH];
78
+ if (metadataBytes === void 0 || signatureBytes === void 0) throw new Error("release signature metadata is missing");
79
+ const metadata = parseJson(metadataBytes, "release metadata");
80
+ const signature = parseJson(signatureBytes, "release signature");
81
+ if (metadata.format !== 1 || signature.algorithm !== "Ed25519" || signature.signedFile !== "META-INF/release.json") throw new Error("unsupported release signature format");
82
+ if (Object.prototype.hasOwnProperty.call(metadata.files, "META-INF/release.json") || Object.prototype.hasOwnProperty.call(metadata.files, "META-INF/signature.json")) throw new Error("release digest table must not include signature metadata");
83
+ const key = trustedKeys.find((candidate) => candidate.keyId === signature.keyId);
84
+ if (key === void 0) throw new Error(`untrusted release key ${signature.keyId}`);
85
+ if (key.publisher !== void 0 && metadata.publisher !== key.publisher) throw new Error("release publisher does not match the trusted key");
86
+ if (!verify(null, Buffer.from(canonicalJson(metadata), "utf8"), key.publicKey, Buffer.from(signature.signature, "base64"))) throw new Error("release signature is invalid");
87
+ const expected = new Set(Object.keys(metadata.files));
88
+ for (const path of Object.keys(files)) {
89
+ if (path === "META-INF/release.json" || path === "META-INF/signature.json") continue;
90
+ if (!expected.has(path)) throw new Error(`release contains an unsigned file ${path}`);
91
+ }
92
+ for (const [path, digest] of Object.entries(metadata.files)) {
93
+ const data = files[path];
94
+ if (data === void 0) throw new Error(`release is missing signed file ${path}`);
95
+ if (digest !== `sha256:${sha256(data)}`) throw new Error(`release file digest mismatch for ${path}`);
96
+ }
97
+ return metadata;
98
+ }
99
+ function parseJson(bytes, label) {
100
+ try {
101
+ return JSON.parse(new TextDecoder().decode(bytes));
102
+ } catch (cause) {
103
+ throw new Error(`${label} is not valid JSON`, { cause });
104
+ }
105
+ }
106
+ //#endregion
107
+ export { RELEASE_METADATA_PATH, RELEASE_SIGNATURE_PATH, canonicalJson, releaseMetadata, sha256, signReleaseFiles, signReleaseMetadata, verifyReleaseFiles };
@@ -0,0 +1,72 @@
1
+ /**
2
+ * tsdown config factories for plugin packages — the two faces a plugin
3
+ * ships. The host face is a plain ESM node bundle; the client face is the
4
+ ** CJS factory bundle** the renderer's module loader consumes: the whole
5
+ * output wrapped in `window.__ModuleLoader__.load({ id, factory })`, with
6
+ * the seed table externalized (shared instances, zero duplication).
7
+ *
8
+ * Usage (a plugin package's `tsdown.config.ts`):
9
+ *
10
+ * ```ts
11
+ * import { clientBundle, hostBundle } from '@snap-rail/plugin-kit/build'
12
+ * export default [
13
+ * hostBundle('packages/host/src/index.ts'),
14
+ * clientBundle('@scope/my-suite', 'packages/client/src/index.tsx'),
15
+ * ]
16
+ * ```
17
+ *
18
+ * @module @snap-rail/plugin-kit/build
19
+ */
20
+ import { defineConfig } from 'tsdown';
21
+ import type { Plugin } from 'rolldown';
22
+ /**
23
+ * The seed interop guard: a client face must consume seed modules through
24
+ * **named or star imports only**. A default import compiles — under
25
+ * rolldown's CJS node-mode interop (`__toESM(require(id), 1)`) — into
26
+ * `.default` = the required namespace itself, never the module's own
27
+ * default export. A class-shaped seed (the timer service) then arrives as
28
+ * a plain object and cordis refuses it at mount: on the user's terminal,
29
+ * as a dead renderer, instead of here at build time. Star and named
30
+ * imports survive both interop modes; `import type` erases and is fine.
31
+ *
32
+ * Source-level scan (emit-independent), plugin sources only — inlined
33
+ * third-party dependencies keep their own import style.
34
+ */
35
+ export declare function seedImportGuard(): Plugin;
36
+ /**
37
+ * The host face: an ESM node bundle. The spine stays external by default
38
+ * (tsdown externalizes package.json dependencies); anything else the plugin
39
+ * needs ships inside.
40
+ *
41
+ * @param entry - the host entry source file.
42
+ */
43
+ export declare function hostBundle(entry: string): ReturnType<typeof defineConfig>;
44
+ /** Options for {@link clientBundle}. */
45
+ export interface ClientBundleOptions {
46
+ /**
47
+ * Stylesheet files to inject into the bundle beyond the `.css` imports the
48
+ * sources make (e.g. a precompiled tailwind output produced by an earlier
49
+ * build step). Each becomes an idempotent style-injection module imported
50
+ * by the entry, so the bundle carries its own styling.
51
+ */
52
+ css?: readonly string[] | undefined;
53
+ }
54
+ /**
55
+ * The client face: a CJS browser bundle welded into the module-loader
56
+ * factory wrapper. Only the seed vocabulary ({@link CLIENT_EXTERNALS} —
57
+ * base plus contributed ids) stays external; everything else (icons,
58
+ * protocol libraries, bitmaps) inlines — bitmaps and SVGs excepted: they
59
+ * emit beside the bundle as `snap-plugin://`-addressed assets instead of
60
+ * base64 bulk, and `.css` imports become style-injection modules.
61
+ *
62
+ * A provider of contributed seeds (a framework, a contract owner) builds
63
+ * the same way: its entry default-exports `{ seeds: { … } }` mapping each
64
+ * subpath to its module namespace, and the shell seeds
65
+ * `<package>/<subpath>` ids from that map after requiring the face.
66
+ *
67
+ * @param packageName - the plugin's npm name (the module id it registers as).
68
+ * @param entry - the renderer entry source file.
69
+ * @param options - see {@link ClientBundleOptions}.
70
+ */
71
+ export declare function clientBundle(packageName: string, entry: string, options?: ClientBundleOptions): ReturnType<typeof defineConfig>;
72
+ //# sourceMappingURL=build.d.ts.map
@@ -0,0 +1,18 @@
1
+ /**
2
+ * The plugin author's kit: manifest vocabulary, the renderer seed whitelist,
3
+ * the tsdown presets producing installable host + client faces, and the
4
+ * final-format packer assembling the installable zip. A new plugin starts by
5
+ * copying `driver-modbus` (host-only) or a suite package and swapping these
6
+ * presets in.
7
+ *
8
+ * @module @snap-rail/plugin-kit
9
+ */
10
+ export { PLUGIN_KINDS, clientFaceSchema, seedAbiSchema, snapRailManifestSchema, type SnapRailManifest, type SnapRailManifestInput, } from './manifest.ts';
11
+ export { SEED_MODULES } from './seeds.ts';
12
+ export { CLIENT_EXTERNALS, CONTRIBUTED_SEED_MODULES, SEED_ABI_REVISION, checkSeedAbi, type SeedAbi, type SeedAbiVerdict, } from './seeds.ts';
13
+ export { clientBundle, hostBundle, seedImportGuard } from './build.ts';
14
+ export { CUSTOM_SLOT_PATTERN, defineSlotVocabulary, type SlotIdOf, type SlotVocabulary, } from './slot-vocabulary.ts';
15
+ export { catalogEntryFromZip, releaseFiles, releaseManifest, zipFileName, type FeedEntry, type ReleaseSpec } from './pack.ts';
16
+ export { packageNameOf, recipeDocumentSchema, recipePackageNames, recipeRowSchema, type RecipeDocument, type RecipeRow, } from './recipe.ts';
17
+ export { RELEASE_METADATA_PATH, RELEASE_SIGNATURE_PATH, canonicalJson, releaseMetadata, sha256, signReleaseFiles, signReleaseMetadata, verifyReleaseFiles, type ReleaseFileDigests, type ReleaseMetadata, type ReleaseSignature, type TrustedReleaseKey, } from './sign.ts';
18
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1,72 @@
1
+ /**
2
+ * The plugin author's manifest vocabulary: the `snapRail` field of a plugin
3
+ * package's `package.json`. One source of truth for the shape — the
4
+ * installer validates against it, the pool scan records it, and authors
5
+ * import the types.
6
+ *
7
+ * @module @snap-rail/plugin-kit/manifest
8
+ */
9
+ import { z } from 'zod';
10
+ import { PLUGIN_LAYERS } from '@snap-rail/util/manifest';
11
+ /** Plugin package kinds. `suite` and `framework` packages are single-active. */
12
+ export declare const PLUGIN_KINDS: readonly ["suite", "driver", "framework", "plugin"];
13
+ /** The layer taxonomy — single source in `@snap-rail/util/manifest`. */
14
+ export { PLUGIN_LAYERS };
15
+ /** The seed-ABI stamp a pack writes into a client face's manifest (shape of
16
+ * `snapRail.client.seedAbi`). Computed by the packer from the built bundle
17
+ * and the build tree's resolved seed versions — authors never hand-write it. */
18
+ export declare const seedAbiSchema: z.ZodObject<{
19
+ revision: z.ZodNumber;
20
+ versions: z.ZodRecord<z.ZodString, z.ZodString>;
21
+ }, z.core.$strict>;
22
+ /** The renderer face declaration: where the client bundle lives. */
23
+ export declare const clientFaceSchema: z.ZodObject<{
24
+ entry: z.ZodDefault<z.ZodString>;
25
+ seeds: z.ZodOptional<z.ZodArray<z.ZodString>>;
26
+ externalSeeds: z.ZodOptional<z.ZodArray<z.ZodString>>;
27
+ seedAbi: z.ZodOptional<z.ZodObject<{
28
+ revision: z.ZodNumber;
29
+ versions: z.ZodRecord<z.ZodString, z.ZodString>;
30
+ }, z.core.$strict>>;
31
+ }, z.core.$strict>;
32
+ /** The `snapRail` manifest field's schema. */
33
+ export declare const snapRailManifestSchema: z.ZodObject<{
34
+ kind: z.ZodOptional<z.ZodEnum<{
35
+ suite: "suite";
36
+ driver: "driver";
37
+ framework: "framework";
38
+ plugin: "plugin";
39
+ }>>;
40
+ layer: z.ZodOptional<z.ZodEnum<{
41
+ suite: "suite";
42
+ base: "base";
43
+ store: "store";
44
+ equipment: "equipment";
45
+ ui: "ui";
46
+ tool: "tool";
47
+ }>>;
48
+ title: z.ZodOptional<z.ZodString>;
49
+ category: z.ZodOptional<z.ZodString>;
50
+ exclusive: z.ZodOptional<z.ZodString>;
51
+ requires: z.ZodOptional<z.ZodArray<z.ZodString>>;
52
+ clientOnly: z.ZodOptional<z.ZodBoolean>;
53
+ client: z.ZodOptional<z.ZodObject<{
54
+ entry: z.ZodDefault<z.ZodString>;
55
+ seeds: z.ZodOptional<z.ZodArray<z.ZodString>>;
56
+ externalSeeds: z.ZodOptional<z.ZodArray<z.ZodString>>;
57
+ seedAbi: z.ZodOptional<z.ZodObject<{
58
+ revision: z.ZodNumber;
59
+ versions: z.ZodRecord<z.ZodString, z.ZodString>;
60
+ }, z.core.$strict>>;
61
+ }, z.core.$strict>>;
62
+ permissions: z.ZodOptional<z.ZodArray<z.ZodString>>;
63
+ engines: z.ZodOptional<z.ZodObject<{
64
+ snapRail: z.ZodOptional<z.ZodString>;
65
+ hostProtocol: z.ZodOptional<z.ZodString>;
66
+ renderer: z.ZodOptional<z.ZodString>;
67
+ }, z.core.$strict>>;
68
+ }, z.core.$strip>;
69
+ /** The validated manifest field. */
70
+ export type SnapRailManifestInput = z.input<typeof snapRailManifestSchema>;
71
+ export type SnapRailManifest = z.output<typeof snapRailManifestSchema>;
72
+ //# sourceMappingURL=manifest.d.ts.map