@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/LICENSE +202 -0
- package/lib/build.js +217 -0
- package/lib/index.js +43 -0
- package/lib/manifest.js +122 -0
- package/lib/pack.js +205 -0
- package/lib/recipe.js +51 -0
- package/lib/seeds.js +117 -0
- package/lib/sign.js +107 -0
- package/lib/types/build.d.ts +72 -0
- package/lib/types/index.d.ts +18 -0
- package/lib/types/manifest.d.ts +72 -0
- package/lib/types/pack.d.ts +125 -0
- package/lib/types/recipe.d.ts +53 -0
- package/lib/types/seeds.d.ts +78 -0
- package/lib/types/sign.d.ts +72 -0
- package/lib/types/slot-vocabulary.d.ts +33 -0
- package/package.json +70 -3
- package/README.md +0 -3
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
|