@vormio/plugin-sdk 0.0.3 → 0.0.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +75 -3
- package/dist/manifest.d.ts +4 -0
- package/dist/manifest.js +11 -8
- package/dist/node/catalog.d.ts +69 -0
- package/dist/node/catalog.js +240 -0
- package/dist/node/cli.js +72 -17
- package/dist/node/index.d.ts +2 -0
- package/dist/node/index.js +2 -0
- package/dist/node/publish.d.ts +42 -0
- package/dist/node/publish.js +213 -0
- package/dist/package-rules.d.ts +2 -0
- package/dist/package-rules.js +2 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# @vormio/plugin-sdk
|
|
2
2
|
|
|
3
|
-
The manifest schema, runtime helpers and packaging command for writing Vormio plugins. The package is versioned with Vormio-Core (currently 0.0.
|
|
3
|
+
The manifest schema, runtime helpers and packaging command for writing Vormio plugins, plus the commands that publish a signed plugin catalog. The package is versioned with Vormio-Core (currently 0.0.5) and published to npm under the MIT license: `npm install @vormio/plugin-sdk@0.0.5`.
|
|
4
4
|
|
|
5
5
|
Plugin contract version: `apiVersion: 1`. During development (0.0.x) there is no cross-version compatibility promise: the host loads only plugins with the same `apiVersion` as its own.
|
|
6
6
|
|
|
@@ -63,9 +63,15 @@ A plugin is a folder: a `manifest.json` at the root plus the built static files.
|
|
|
63
63
|
|
|
64
64
|
**Allowed files**: `html js mjs css json wasm png jpg jpeg webp svg woff2 txt md`. Links, `..` paths, Windows reserved names and names that differ only by case are not allowed.
|
|
65
65
|
|
|
66
|
-
**Limits**: at most 1,024 files, 16 MiB per file and 64 MiB in total.
|
|
66
|
+
**Limits**: at most 1,024 files, 16 MiB per file and 64 MiB in total; the `.vormioplugin` file itself is at most 96 MiB.
|
|
67
67
|
|
|
68
|
-
**
|
|
68
|
+
**Grants**: a library records what the user approved for each extension point (analyzers included): its ID, `formats` and `companionFormats`. A version that reads no more than that keeps running without asking again, including:
|
|
69
|
+
|
|
70
|
+
- an upgrade that changes only code, name or version
|
|
71
|
+
- an upgrade that reads less
|
|
72
|
+
- a rollback
|
|
73
|
+
|
|
74
|
+
Reading more formats or companion formats, or adding an extension point, requires the user to enable the plugin again, and that approval is added to what was approved before.
|
|
69
75
|
|
|
70
76
|
## Runtime API
|
|
71
77
|
|
|
@@ -158,3 +164,69 @@ npx vormio-plugin pack <build-output-folder> <output-file.vormioplugin>
|
|
|
158
164
|
The command validates the manifest and every file, then writes a zip-format `.vormioplugin`. It never overwrites an existing output file.
|
|
159
165
|
|
|
160
166
|
During development, open the **插件** (Plugins) page in Vormio, choose **添加开发目录…** (Add development folder…) and point it at the build output folder. The host watches the folder: every change is validated again, and a valid build reloads the plugin. If validation fails, the last valid version stays loaded and the error is shown on the plugin card. Vormio never runs build commands.
|
|
167
|
+
|
|
168
|
+
## Plugin catalogs
|
|
169
|
+
|
|
170
|
+
A plugin catalog is a signed `catalog.json` plus the plugin packages it lists, on static hosting. Vormio installs and updates plugins from it. Publishers produce one with two commands:
|
|
171
|
+
|
|
172
|
+
```bash
|
|
173
|
+
npx vormio-plugin keygen <key-path-prefix>
|
|
174
|
+
npx vormio-plugin catalog <release-config.json> --key <private-key.pem> --out <new-folder>
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
- `keygen` generates an Ed25519 key pair: `<prefix>.private.pem` (PKCS#8) and `<prefix>.public.txt` (the base64 of the 32-byte public key; hosts build this line in). It refuses to write inside a Git working tree and never overwrites a file. The private key stays on the publisher's machine: never commit or upload it.
|
|
178
|
+
- The release configuration lists every package in the new catalog, relative to the configuration file:
|
|
179
|
+
|
|
180
|
+
```json
|
|
181
|
+
{
|
|
182
|
+
"previous": "published/3/catalog.json",
|
|
183
|
+
"packages": ["artifacts/model-viewer-0.2.0.vormioplugin"]
|
|
184
|
+
}
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
`previous` is the catalog currently online. The new catalog's sequence is its sequence plus 1, and it must be signed with the same key. A first publication has no previous catalog and uses `"initialSequence": 1` instead.
|
|
188
|
+
|
|
189
|
+
- `catalog` does the following:
|
|
190
|
+
- Validates every package with the rules a host applies when installing it.
|
|
191
|
+
- Copies each package to `packages/<plugin ID>-<version>-<first 12 hex digits of its SHA-256>.vormioplugin`.
|
|
192
|
+
- Writes and signs `catalog.json`, then verifies it with the public key.
|
|
193
|
+
|
|
194
|
+
The output folder must be new or empty. If any check fails, nothing is written.
|
|
195
|
+
|
|
196
|
+
- Upload `packages/` first and `catalog.json` last. Do not cache `catalog.json`. Package file names change with their content, so packages can be cached for a long time.
|
|
197
|
+
|
|
198
|
+
**Format**: `catalog.json` is an envelope `{ format: 1, keyId, payload, signature }`:
|
|
199
|
+
|
|
200
|
+
- `payload` is the base64 of the content's UTF-8 JSON.
|
|
201
|
+
- `signature` is the Ed25519 signature of those bytes.
|
|
202
|
+
- `keyId` is the first 16 hex digits of the public key's SHA-256.
|
|
203
|
+
|
|
204
|
+
The content:
|
|
205
|
+
|
|
206
|
+
```ts
|
|
207
|
+
interface CatalogPayload {
|
|
208
|
+
sequence: number // Grows by 1 per publication; hosts refuse a sequence lower than one they have seen
|
|
209
|
+
publishedAt: string // ISO 8601, UTC
|
|
210
|
+
plugins: {
|
|
211
|
+
id: string
|
|
212
|
+
releases: {
|
|
213
|
+
manifest: object // The package's manifest.json, as is
|
|
214
|
+
file: string // packages/<name>.vormioplugin, relative to catalog.json
|
|
215
|
+
bytes: number
|
|
216
|
+
sha256: string // SHA-256 of the whole package file, lowercase hex
|
|
217
|
+
}[]
|
|
218
|
+
}[]
|
|
219
|
+
}
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
- Limits:
|
|
223
|
+
- `catalog.json` is at most 1 MiB.
|
|
224
|
+
- At most 256 plugins, with at most 8 releases each.
|
|
225
|
+
- Package files are at most 96 MiB.
|
|
226
|
+
|
|
227
|
+
Plugin IDs, and the versions of one plugin, must not repeat. `manifest.id` must match the plugin ID.
|
|
228
|
+
|
|
229
|
+
- One plugin can list releases for hosts of different `apiVersion`s side by side. A host installs only releases with its own `apiVersion` whose manifest passes its own validation; it shows the rest as needing a newer Vormio.
|
|
230
|
+
- Hosts verify the signature before reading the content. A downloaded package must match `bytes` and `sha256`, then pass the rules of a local install, and its manifest must equal the one in the catalog.
|
|
231
|
+
|
|
232
|
+
`@vormio/plugin-sdk/node` exports the same functionality: `verifyCatalog`, `signCatalog`, `writeCatalog`, `writeCatalogKeys`, `releaseManifest` and `compareVersions`.
|
package/dist/manifest.d.ts
CHANGED
|
@@ -8,6 +8,10 @@ declare const contributionSchema: z.ZodObject<{
|
|
|
8
8
|
companionFormats: z.ZodDefault<z.ZodArray<z.ZodString>>;
|
|
9
9
|
}, z.core.$strict>;
|
|
10
10
|
export type Contribution = z.output<typeof contributionSchema>;
|
|
11
|
+
/** `publisher.name`. The `vormio` publisher is reserved for the host. */
|
|
12
|
+
export declare const pluginIdSchema: z.ZodString;
|
|
13
|
+
/** A semantic version without build metadata. */
|
|
14
|
+
export declare const versionSchema: z.ZodString;
|
|
11
15
|
export declare const manifestSchema: z.ZodObject<{
|
|
12
16
|
apiVersion: z.ZodLiteral<1>;
|
|
13
17
|
id: z.ZodString;
|
package/dist/manifest.js
CHANGED
|
@@ -22,16 +22,19 @@ const contributionList = z
|
|
|
22
22
|
.max(8)
|
|
23
23
|
.default([])
|
|
24
24
|
.refine((items) => unique(items.map((item) => item.id)), 'Contribution IDs must not repeat');
|
|
25
|
+
/** `publisher.name`. The `vormio` publisher is reserved for the host. */
|
|
26
|
+
export const pluginIdSchema = z
|
|
27
|
+
.string()
|
|
28
|
+
.regex(/^[a-z0-9][a-z0-9-]{0,39}\.[a-z0-9][a-z0-9-]{0,39}$/, 'Use publisher.name')
|
|
29
|
+
.refine((id) => !id.startsWith('vormio.'), 'The vormio publisher is reserved');
|
|
30
|
+
/** A semantic version without build metadata. */
|
|
31
|
+
export const versionSchema = z
|
|
32
|
+
.string()
|
|
33
|
+
.regex(/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[0-9A-Za-z.-]+)?$/, 'Use a semantic version');
|
|
25
34
|
export const manifestSchema = z.strictObject({
|
|
26
35
|
apiVersion: z.literal(pluginApiVersion),
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
.string()
|
|
30
|
-
.regex(/^[a-z0-9][a-z0-9-]{0,39}\.[a-z0-9][a-z0-9-]{0,39}$/, 'Use publisher.name')
|
|
31
|
-
.refine((id) => !id.startsWith('vormio.'), 'The vormio publisher is reserved'),
|
|
32
|
-
version: z
|
|
33
|
-
.string()
|
|
34
|
-
.regex(/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[0-9A-Za-z.-]+)?$/, 'Use a semantic version'),
|
|
36
|
+
id: pluginIdSchema,
|
|
37
|
+
version: versionSchema,
|
|
35
38
|
name: z.string().trim().min(1).max(80),
|
|
36
39
|
description: z.string().max(400).default(''),
|
|
37
40
|
contributions: z
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { type ManifestResult } from '../manifest.ts';
|
|
3
|
+
/** Limits for a signed plugin catalog. */
|
|
4
|
+
export declare const catalogLimits: {
|
|
5
|
+
readonly maxCatalogBytes: number;
|
|
6
|
+
readonly maxPlugins: 256;
|
|
7
|
+
readonly maxReleasesPerPlugin: 8;
|
|
8
|
+
};
|
|
9
|
+
/** Which check a catalog failed: its shape, the key it was signed with, or the signature itself. */
|
|
10
|
+
export type CatalogErrorCode = 'format' | 'key' | 'signature';
|
|
11
|
+
export declare class CatalogError extends Error {
|
|
12
|
+
readonly code: CatalogErrorCode;
|
|
13
|
+
readonly problems: readonly string[];
|
|
14
|
+
constructor(code: CatalogErrorCode, problems: readonly string[]);
|
|
15
|
+
}
|
|
16
|
+
declare const payloadSchema: z.ZodObject<{
|
|
17
|
+
sequence: z.ZodNumber;
|
|
18
|
+
publishedAt: z.ZodISODateTime;
|
|
19
|
+
plugins: z.ZodArray<z.ZodObject<{
|
|
20
|
+
id: z.ZodString;
|
|
21
|
+
releases: z.ZodArray<z.ZodObject<{
|
|
22
|
+
manifest: z.ZodObject<{
|
|
23
|
+
apiVersion: z.ZodNumber;
|
|
24
|
+
id: z.ZodString;
|
|
25
|
+
version: z.ZodString;
|
|
26
|
+
name: z.ZodString;
|
|
27
|
+
description: z.ZodOptional<z.ZodString>;
|
|
28
|
+
}, z.core.$loose>;
|
|
29
|
+
file: z.ZodString;
|
|
30
|
+
bytes: z.ZodNumber;
|
|
31
|
+
sha256: z.ZodString;
|
|
32
|
+
}, z.core.$strict>>;
|
|
33
|
+
}, z.core.$strict>>;
|
|
34
|
+
}, z.core.$strict>;
|
|
35
|
+
/** What `signCatalog` signs. */
|
|
36
|
+
export type CatalogPayload = z.input<typeof payloadSchema>;
|
|
37
|
+
/** A catalog whose signature has been verified. */
|
|
38
|
+
export type Catalog = z.output<typeof payloadSchema> & {
|
|
39
|
+
keyId: string;
|
|
40
|
+
};
|
|
41
|
+
export type CatalogPlugin = Catalog['plugins'][number];
|
|
42
|
+
export type CatalogRelease = CatalogPlugin['releases'][number];
|
|
43
|
+
/** The first 16 hexadecimal digits of the public key's SHA-256; catalogs name their key by it. */
|
|
44
|
+
export declare function catalogKeyId(publicKey: string): string;
|
|
45
|
+
/** The public key that belongs to a PEM private key. */
|
|
46
|
+
export declare function catalogPublicKey(privateKey: string): string;
|
|
47
|
+
export interface CatalogKeys {
|
|
48
|
+
/** Base64 of the raw key; hosts build it in. */
|
|
49
|
+
publicKey: string;
|
|
50
|
+
/** PKCS#8 PEM. */
|
|
51
|
+
privateKey: string;
|
|
52
|
+
keyId: string;
|
|
53
|
+
}
|
|
54
|
+
export declare function generateCatalogKeys(): CatalogKeys;
|
|
55
|
+
/** Validates a payload and returns the signed `catalog.json` bytes. */
|
|
56
|
+
export declare function signCatalog(payload: CatalogPayload, privateKey: string): Buffer;
|
|
57
|
+
/**
|
|
58
|
+
* Checks `catalog.json` against a public key: shape of the envelope, key ID, signature, and only
|
|
59
|
+
* then the payload. Throws `CatalogError` at the first check that fails.
|
|
60
|
+
*/
|
|
61
|
+
export declare function verifyCatalog(bytes: Uint8Array, publicKey: string): Catalog;
|
|
62
|
+
/**
|
|
63
|
+
* The release's manifest when this SDK's hosts can install it: same apiVersion, and valid under
|
|
64
|
+
* this SDK's manifest schema. Otherwise the reasons, typically that the release needs a newer host.
|
|
65
|
+
*/
|
|
66
|
+
export declare function releaseManifest(release: CatalogRelease): ManifestResult;
|
|
67
|
+
/** Orders two semantic versions by precedence: negative, zero or positive, like a sort callback. */
|
|
68
|
+
export declare function compareVersions(a: string, b: string): number;
|
|
69
|
+
export {};
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
import { createHash, createPrivateKey, createPublicKey, generateKeyPairSync, sign, verify, } from 'node:crypto';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import { parseManifest, pluginApiVersion, pluginIdSchema, versionSchema, } from "../manifest.js";
|
|
4
|
+
import { packageLimits } from "../package-rules.js";
|
|
5
|
+
/** Limits for a signed plugin catalog. */
|
|
6
|
+
export const catalogLimits = {
|
|
7
|
+
maxCatalogBytes: 1024 * 1024,
|
|
8
|
+
maxPlugins: 256,
|
|
9
|
+
maxReleasesPerPlugin: 8,
|
|
10
|
+
};
|
|
11
|
+
export class CatalogError extends Error {
|
|
12
|
+
code;
|
|
13
|
+
problems;
|
|
14
|
+
constructor(code, problems) {
|
|
15
|
+
super(problems.join('\n'));
|
|
16
|
+
this.code = code;
|
|
17
|
+
this.problems = problems;
|
|
18
|
+
this.name = 'CatalogError';
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* The manifest fields every host reads, whatever the release's apiVersion. The rest of the manifest
|
|
23
|
+
* is kept as is: a host parses it fully only for releases of its own apiVersion (`releaseManifest`).
|
|
24
|
+
*/
|
|
25
|
+
const releaseManifestSchema = z.looseObject({
|
|
26
|
+
apiVersion: z.number().int().min(1),
|
|
27
|
+
id: pluginIdSchema,
|
|
28
|
+
version: versionSchema,
|
|
29
|
+
name: z.string().trim().min(1).max(80),
|
|
30
|
+
description: z.string().max(400).optional(),
|
|
31
|
+
});
|
|
32
|
+
const releaseSchema = z.strictObject({
|
|
33
|
+
/** The package's `manifest.json`. */
|
|
34
|
+
manifest: releaseManifestSchema,
|
|
35
|
+
/** The package file, relative to the catalog: always directly inside `packages/`. */
|
|
36
|
+
file: z
|
|
37
|
+
.string()
|
|
38
|
+
.regex(/^packages\/[A-Za-z0-9][A-Za-z0-9._-]{0,199}\.vormioplugin$/, 'Use packages/<name>.vormioplugin'),
|
|
39
|
+
bytes: z.number().int().min(1).max(packageLimits.maxArchiveBytes),
|
|
40
|
+
sha256: z.string().regex(/^[0-9a-f]{64}$/, 'Use lower-case hexadecimal'),
|
|
41
|
+
});
|
|
42
|
+
const payloadSchema = z
|
|
43
|
+
.strictObject({
|
|
44
|
+
/** Grows with every publication; hosts refuse a catalog older than one they have seen. */
|
|
45
|
+
sequence: z.number().int().min(1).max(Number.MAX_SAFE_INTEGER),
|
|
46
|
+
publishedAt: z.iso.datetime(),
|
|
47
|
+
plugins: z
|
|
48
|
+
.array(z.strictObject({
|
|
49
|
+
id: pluginIdSchema,
|
|
50
|
+
releases: z.array(releaseSchema).min(1).max(catalogLimits.maxReleasesPerPlugin),
|
|
51
|
+
}))
|
|
52
|
+
.max(catalogLimits.maxPlugins),
|
|
53
|
+
})
|
|
54
|
+
.superRefine((payload, context) => {
|
|
55
|
+
const ids = new Set();
|
|
56
|
+
const files = new Set();
|
|
57
|
+
payload.plugins.forEach((plugin, index) => {
|
|
58
|
+
const issue = (path, message) => context.addIssue({ code: 'custom', path: ['plugins', index, ...path], message });
|
|
59
|
+
if (ids.has(plugin.id))
|
|
60
|
+
issue(['id'], 'Plugin IDs must not repeat');
|
|
61
|
+
ids.add(plugin.id);
|
|
62
|
+
const versions = new Set();
|
|
63
|
+
plugin.releases.forEach((release, at) => {
|
|
64
|
+
if (release.manifest.id !== plugin.id)
|
|
65
|
+
issue(['releases', at, 'manifest', 'id'], 'The manifest ID must match the plugin ID');
|
|
66
|
+
if (versions.has(release.manifest.version))
|
|
67
|
+
issue(['releases', at, 'manifest', 'version'], 'Versions must not repeat');
|
|
68
|
+
versions.add(release.manifest.version);
|
|
69
|
+
const file = release.file.toLowerCase();
|
|
70
|
+
if (files.has(file))
|
|
71
|
+
issue(['releases', at, 'file'], 'Files must not repeat');
|
|
72
|
+
files.add(file);
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
const envelopeSchema = z.strictObject({
|
|
77
|
+
format: z.literal(1),
|
|
78
|
+
keyId: z.string().regex(/^[0-9a-f]{16}$/),
|
|
79
|
+
/** The payload's UTF-8 JSON, base64; the signature covers exactly these bytes. */
|
|
80
|
+
payload: z.string().min(1),
|
|
81
|
+
signature: z.string().min(1),
|
|
82
|
+
});
|
|
83
|
+
function problemsOf(error) {
|
|
84
|
+
return error.issues.map((issue) => `${issue.path.length ? issue.path.join('.') : 'catalog'}: ${issue.message}`);
|
|
85
|
+
}
|
|
86
|
+
/** Decodes canonical base64 only, so that one envelope has exactly one meaning. */
|
|
87
|
+
function decodeBase64(text) {
|
|
88
|
+
const bytes = Buffer.from(text, 'base64');
|
|
89
|
+
return bytes.length > 0 && bytes.toString('base64') === text ? bytes : undefined;
|
|
90
|
+
}
|
|
91
|
+
const utf8 = new TextDecoder('utf-8', { fatal: true });
|
|
92
|
+
function parseJson(bytes, what) {
|
|
93
|
+
try {
|
|
94
|
+
return JSON.parse(utf8.decode(bytes));
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
throw new CatalogError('format', [`${what} is not valid UTF-8 JSON`]);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
// --- Keys ------------------------------------------------------------------------------------
|
|
101
|
+
// A public key is written as the base64 of its 32 raw Ed25519 bytes, short enough to build into the
|
|
102
|
+
// app. Private keys are PKCS#8 PEM files that never leave the publisher's machine.
|
|
103
|
+
function rawPublicKey(publicKey) {
|
|
104
|
+
const bytes = decodeBase64(publicKey.trim());
|
|
105
|
+
if (bytes?.length !== 32)
|
|
106
|
+
throw new CatalogError('key', ['A public key is 32 raw Ed25519 bytes in base64']);
|
|
107
|
+
return bytes;
|
|
108
|
+
}
|
|
109
|
+
function publicKeyObject(publicKey) {
|
|
110
|
+
const x = rawPublicKey(publicKey).toString('base64url');
|
|
111
|
+
return createPublicKey({ key: { kty: 'OKP', crv: 'Ed25519', x }, format: 'jwk' });
|
|
112
|
+
}
|
|
113
|
+
function publicKeyText(key) {
|
|
114
|
+
return Buffer.from(key.export({ format: 'jwk' }).x, 'base64url').toString('base64');
|
|
115
|
+
}
|
|
116
|
+
function privateKeyObject(privateKey) {
|
|
117
|
+
let key;
|
|
118
|
+
try {
|
|
119
|
+
key = createPrivateKey(privateKey);
|
|
120
|
+
}
|
|
121
|
+
catch {
|
|
122
|
+
throw new CatalogError('key', ['The private key is not a PEM private key']);
|
|
123
|
+
}
|
|
124
|
+
if (key.asymmetricKeyType !== 'ed25519')
|
|
125
|
+
throw new CatalogError('key', ['The private key is not an Ed25519 key']);
|
|
126
|
+
return key;
|
|
127
|
+
}
|
|
128
|
+
/** The first 16 hexadecimal digits of the public key's SHA-256; catalogs name their key by it. */
|
|
129
|
+
export function catalogKeyId(publicKey) {
|
|
130
|
+
return createHash('sha256').update(rawPublicKey(publicKey)).digest('hex').slice(0, 16);
|
|
131
|
+
}
|
|
132
|
+
/** The public key that belongs to a PEM private key. */
|
|
133
|
+
export function catalogPublicKey(privateKey) {
|
|
134
|
+
return publicKeyText(createPublicKey(privateKeyObject(privateKey)));
|
|
135
|
+
}
|
|
136
|
+
export function generateCatalogKeys() {
|
|
137
|
+
const { publicKey, privateKey } = generateKeyPairSync('ed25519');
|
|
138
|
+
const text = publicKeyText(publicKey);
|
|
139
|
+
return {
|
|
140
|
+
publicKey: text,
|
|
141
|
+
privateKey: privateKey.export({ format: 'pem', type: 'pkcs8' }).toString(),
|
|
142
|
+
keyId: catalogKeyId(text),
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
// --- Signing and verification ----------------------------------------------------------------
|
|
146
|
+
/** Validates a payload and returns the signed `catalog.json` bytes. */
|
|
147
|
+
export function signCatalog(payload, privateKey) {
|
|
148
|
+
const result = payloadSchema.safeParse(payload);
|
|
149
|
+
if (!result.success)
|
|
150
|
+
throw new CatalogError('format', problemsOf(result.error));
|
|
151
|
+
const key = privateKeyObject(privateKey);
|
|
152
|
+
const bytes = Buffer.from(JSON.stringify(payload));
|
|
153
|
+
const envelope = {
|
|
154
|
+
format: 1,
|
|
155
|
+
keyId: catalogKeyId(publicKeyText(createPublicKey(key))),
|
|
156
|
+
payload: bytes.toString('base64'),
|
|
157
|
+
signature: sign(null, bytes, key).toString('base64'),
|
|
158
|
+
};
|
|
159
|
+
const text = Buffer.from(`${JSON.stringify(envelope, null, 2)}\n`);
|
|
160
|
+
if (text.length > catalogLimits.maxCatalogBytes)
|
|
161
|
+
throw new CatalogError('format', ['The catalog is larger than 1 MiB']);
|
|
162
|
+
return text;
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* Checks `catalog.json` against a public key: shape of the envelope, key ID, signature, and only
|
|
166
|
+
* then the payload. Throws `CatalogError` at the first check that fails.
|
|
167
|
+
*/
|
|
168
|
+
export function verifyCatalog(bytes, publicKey) {
|
|
169
|
+
if (bytes.length > catalogLimits.maxCatalogBytes)
|
|
170
|
+
throw new CatalogError('format', ['The catalog is larger than 1 MiB']);
|
|
171
|
+
const envelope = envelopeSchema.safeParse(parseJson(bytes, 'The catalog'));
|
|
172
|
+
if (!envelope.success)
|
|
173
|
+
throw new CatalogError('format', problemsOf(envelope.error));
|
|
174
|
+
const keyId = catalogKeyId(publicKey);
|
|
175
|
+
if (envelope.data.keyId !== keyId)
|
|
176
|
+
throw new CatalogError('key', [
|
|
177
|
+
`The catalog is signed with key ${envelope.data.keyId}, not ${keyId}`,
|
|
178
|
+
]);
|
|
179
|
+
const payload = decodeBase64(envelope.data.payload);
|
|
180
|
+
const signature = decodeBase64(envelope.data.signature);
|
|
181
|
+
if (!payload || signature?.length !== 64)
|
|
182
|
+
throw new CatalogError('format', ['The payload or signature is not canonical base64']);
|
|
183
|
+
if (!verify(null, payload, publicKeyObject(publicKey), signature))
|
|
184
|
+
throw new CatalogError('signature', ['The signature does not match the catalog']);
|
|
185
|
+
const result = payloadSchema.safeParse(parseJson(payload, 'The payload'));
|
|
186
|
+
if (!result.success)
|
|
187
|
+
throw new CatalogError('format', problemsOf(result.error));
|
|
188
|
+
return { keyId, ...result.data };
|
|
189
|
+
}
|
|
190
|
+
/**
|
|
191
|
+
* The release's manifest when this SDK's hosts can install it: same apiVersion, and valid under
|
|
192
|
+
* this SDK's manifest schema. Otherwise the reasons, typically that the release needs a newer host.
|
|
193
|
+
*/
|
|
194
|
+
export function releaseManifest(release) {
|
|
195
|
+
if (release.manifest.apiVersion !== pluginApiVersion)
|
|
196
|
+
return {
|
|
197
|
+
ok: false,
|
|
198
|
+
problems: [
|
|
199
|
+
`apiVersion ${release.manifest.apiVersion}; this version supports ${pluginApiVersion}`,
|
|
200
|
+
],
|
|
201
|
+
};
|
|
202
|
+
return parseManifest(release.manifest);
|
|
203
|
+
}
|
|
204
|
+
// --- Versions --------------------------------------------------------------------------------
|
|
205
|
+
function versionParts(version) {
|
|
206
|
+
const dash = version.indexOf('-');
|
|
207
|
+
const core = dash < 0 ? version : version.slice(0, dash);
|
|
208
|
+
return {
|
|
209
|
+
core: core.split('.').map(Number),
|
|
210
|
+
prerelease: dash < 0 ? [] : version.slice(dash + 1).split('.'),
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
/** Orders two semantic versions by precedence: negative, zero or positive, like a sort callback. */
|
|
214
|
+
export function compareVersions(a, b) {
|
|
215
|
+
const left = versionParts(a);
|
|
216
|
+
const right = versionParts(b);
|
|
217
|
+
for (let at = 0; at < 3; at++) {
|
|
218
|
+
const difference = (left.core[at] ?? 0) - (right.core[at] ?? 0);
|
|
219
|
+
if (difference)
|
|
220
|
+
return Math.sign(difference);
|
|
221
|
+
}
|
|
222
|
+
// A version without a pre-release ranks above any pre-release of it.
|
|
223
|
+
if (!left.prerelease.length || !right.prerelease.length)
|
|
224
|
+
return Math.sign(right.prerelease.length - left.prerelease.length);
|
|
225
|
+
const count = Math.min(left.prerelease.length, right.prerelease.length);
|
|
226
|
+
for (let at = 0; at < count; at++) {
|
|
227
|
+
const x = left.prerelease[at];
|
|
228
|
+
const y = right.prerelease[at];
|
|
229
|
+
if (x === y)
|
|
230
|
+
continue;
|
|
231
|
+
const xNumeric = /^\d+$/.test(x);
|
|
232
|
+
const yNumeric = /^\d+$/.test(y);
|
|
233
|
+
if (xNumeric && yNumeric)
|
|
234
|
+
return Math.sign(Number(x) - Number(y));
|
|
235
|
+
if (xNumeric !== yNumeric)
|
|
236
|
+
return xNumeric ? -1 : 1;
|
|
237
|
+
return x < y ? -1 : 1;
|
|
238
|
+
}
|
|
239
|
+
return Math.sign(left.prerelease.length - right.prerelease.length);
|
|
240
|
+
}
|
package/dist/node/cli.js
CHANGED
|
@@ -2,16 +2,18 @@
|
|
|
2
2
|
import { existsSync } from 'node:fs';
|
|
3
3
|
import { writeFile } from 'node:fs/promises';
|
|
4
4
|
import { resolve } from 'node:path';
|
|
5
|
+
import { CatalogError } from "./catalog.js";
|
|
5
6
|
import { checkPackage, PackageError, readPackageDirectory } from "./package.js";
|
|
7
|
+
import { PublishError, writeCatalog, writeCatalogKeys } from "./publish.js";
|
|
6
8
|
import { writeZip, ZipError } from "./zip.js";
|
|
7
|
-
const usage =
|
|
9
|
+
const usage = [
|
|
10
|
+
'Usage:',
|
|
11
|
+
' vormio-plugin pack <plugin directory> <output.vormioplugin>',
|
|
12
|
+
' vormio-plugin keygen <output path prefix>',
|
|
13
|
+
' vormio-plugin catalog <release config.json> --key <private key.pem> --out <new folder>',
|
|
14
|
+
].join('\n');
|
|
8
15
|
/** `vormio-plugin pack`: validates a built plugin directory and writes a `.vormioplugin` file. */
|
|
9
|
-
async function
|
|
10
|
-
const [command, source, output] = args;
|
|
11
|
-
if (command !== 'pack' || !source || !output) {
|
|
12
|
-
console.error(usage);
|
|
13
|
-
return 2;
|
|
14
|
-
}
|
|
16
|
+
async function pack(source, output) {
|
|
15
17
|
if (!output.endsWith('.vormioplugin')) {
|
|
16
18
|
console.error('The output file must end with .vormioplugin');
|
|
17
19
|
return 2;
|
|
@@ -21,23 +23,76 @@ async function main(args) {
|
|
|
21
23
|
console.error(`${target} already exists; choose another name or remove it first`);
|
|
22
24
|
return 1;
|
|
23
25
|
}
|
|
26
|
+
const contents = await readPackageDirectory(resolve(source));
|
|
27
|
+
const checked = checkPackage(contents);
|
|
28
|
+
const entries = checked.files.map((file) => ({
|
|
29
|
+
path: file.path,
|
|
30
|
+
data: contents.get(file.path),
|
|
31
|
+
}));
|
|
32
|
+
await writeFile(target, writeZip(entries), { flag: 'wx' });
|
|
33
|
+
console.log(`${checked.manifest.id} ${checked.manifest.version}: ${entries.length} files → ${target}`);
|
|
34
|
+
console.log(`digest ${checked.digest}`);
|
|
35
|
+
return 0;
|
|
36
|
+
}
|
|
37
|
+
/** `vormio-plugin keygen`: creates the key pair a catalog is signed with. */
|
|
38
|
+
async function keygen(prefix) {
|
|
39
|
+
const keys = await writeCatalogKeys(prefix);
|
|
40
|
+
console.log(`key ${keys.keyId}`);
|
|
41
|
+
console.log(`public key ${keys.publicKey}`);
|
|
42
|
+
console.log(`private key → ${keys.privateKeyFile}`);
|
|
43
|
+
console.log(`public key → ${keys.publicKeyFile}`);
|
|
44
|
+
console.log('Keep the private key offline. Never commit, upload or share it.');
|
|
45
|
+
return 0;
|
|
46
|
+
}
|
|
47
|
+
/** `vormio-plugin catalog`: publishes a signed catalog and its packages into a new folder. */
|
|
48
|
+
async function catalog(args) {
|
|
49
|
+
let config;
|
|
50
|
+
let key;
|
|
51
|
+
let out;
|
|
52
|
+
for (let at = 0; at < args.length; at++) {
|
|
53
|
+
const arg = args[at];
|
|
54
|
+
if (arg === '--key')
|
|
55
|
+
key = args[++at];
|
|
56
|
+
else if (arg === '--out')
|
|
57
|
+
out = args[++at];
|
|
58
|
+
else if (config === undefined && arg && !arg.startsWith('--'))
|
|
59
|
+
config = arg;
|
|
60
|
+
else
|
|
61
|
+
return usageError();
|
|
62
|
+
}
|
|
63
|
+
if (!config || !key || !out)
|
|
64
|
+
return usageError();
|
|
65
|
+
const written = await writeCatalog({ config, privateKey: key, out });
|
|
66
|
+
console.log(`catalog.json: sequence ${written.sequence}, key ${written.keyId}`);
|
|
67
|
+
for (const release of written.releases)
|
|
68
|
+
console.log(` ${release.id} ${release.version} → ${release.file}`);
|
|
69
|
+
console.log(`Upload the packages folder of ${resolve(out)} first, then catalog.json.`);
|
|
70
|
+
return 0;
|
|
71
|
+
}
|
|
72
|
+
function usageError() {
|
|
73
|
+
console.error(usage);
|
|
74
|
+
return 2;
|
|
75
|
+
}
|
|
76
|
+
async function main(args) {
|
|
77
|
+
const [command, ...rest] = args;
|
|
24
78
|
try {
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
console.log(`${checked.manifest.id} ${checked.manifest.version}: ${entries.length} files → ${target}`);
|
|
33
|
-
console.log(`digest ${checked.digest}`);
|
|
34
|
-
return 0;
|
|
79
|
+
if (command === 'pack' && rest.length === 2)
|
|
80
|
+
return await pack(rest[0], rest[1]);
|
|
81
|
+
if (command === 'keygen' && rest.length === 1)
|
|
82
|
+
return await keygen(rest[0]);
|
|
83
|
+
if (command === 'catalog')
|
|
84
|
+
return await catalog(rest);
|
|
85
|
+
return usageError();
|
|
35
86
|
}
|
|
36
87
|
catch (error) {
|
|
37
88
|
if (error instanceof PackageError || error instanceof ZipError) {
|
|
38
89
|
console.error(`The plugin is not valid:\n${error.message}`);
|
|
39
90
|
return 1;
|
|
40
91
|
}
|
|
92
|
+
if (error instanceof PublishError || error instanceof CatalogError) {
|
|
93
|
+
console.error(error.message);
|
|
94
|
+
return 1;
|
|
95
|
+
}
|
|
41
96
|
throw error;
|
|
42
97
|
}
|
|
43
98
|
}
|
package/dist/node/index.d.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
export { checkPackage, PackageError, readPackageArchive, readPackageDirectory, type CheckedPackage, } from './package.ts';
|
|
2
2
|
export { readZip, writeZip, ZipError, type ZipEntry } from './zip.ts';
|
|
3
|
+
export { CatalogError, catalogKeyId, catalogLimits, catalogPublicKey, compareVersions, generateCatalogKeys, releaseManifest, signCatalog, verifyCatalog, type Catalog, type CatalogErrorCode, type CatalogKeys, type CatalogPayload, type CatalogPlugin, type CatalogRelease, } from './catalog.ts';
|
|
4
|
+
export { PublishError, writeCatalog, writeCatalogKeys, type WrittenCatalog, type WrittenKeys, } from './publish.ts';
|
|
3
5
|
export { contributionsOf, manifestSchema, missingEntries, parseManifest, permissionSummary, pluginApiVersion, type Contribution, type ContributionKind, type PluginManifest, } from '../manifest.ts';
|
|
4
6
|
export { checkPackageFiles, checkPackagePath, packageLimits } from '../package-rules.ts';
|
|
5
7
|
export { compositionReportSchema, parseReport, reportLimits } from '../report.ts';
|
package/dist/node/index.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
export { checkPackage, PackageError, readPackageArchive, readPackageDirectory, } from "./package.js";
|
|
2
2
|
export { readZip, writeZip, ZipError } from "./zip.js";
|
|
3
|
+
export { CatalogError, catalogKeyId, catalogLimits, catalogPublicKey, compareVersions, generateCatalogKeys, releaseManifest, signCatalog, verifyCatalog, } from "./catalog.js";
|
|
4
|
+
export { PublishError, writeCatalog, writeCatalogKeys, } from "./publish.js";
|
|
3
5
|
// The manifest and package rules again, so Node code needs no DOM types from the runtime entry.
|
|
4
6
|
export { contributionsOf, manifestSchema, missingEntries, parseManifest, permissionSummary, pluginApiVersion, } from "../manifest.js";
|
|
5
7
|
export { checkPackageFiles, checkPackagePath, packageLimits } from "../package-rules.js";
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/** A publication that cannot go ahead; nothing has been written. */
|
|
2
|
+
export declare class PublishError extends Error {
|
|
3
|
+
readonly problems: readonly string[];
|
|
4
|
+
constructor(problems: readonly string[]);
|
|
5
|
+
}
|
|
6
|
+
export interface WrittenKeys {
|
|
7
|
+
keyId: string;
|
|
8
|
+
publicKey: string;
|
|
9
|
+
privateKeyFile: string;
|
|
10
|
+
publicKeyFile: string;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Generates a catalog key pair into `<prefix>.private.pem` and `<prefix>.public.txt`. Refuses to
|
|
14
|
+
* write inside a Git working tree, where the private key could end up in a commit, and never
|
|
15
|
+
* overwrites a file.
|
|
16
|
+
*/
|
|
17
|
+
export declare function writeCatalogKeys(prefix: string): Promise<WrittenKeys>;
|
|
18
|
+
export interface WrittenCatalog {
|
|
19
|
+
keyId: string;
|
|
20
|
+
sequence: number;
|
|
21
|
+
releases: {
|
|
22
|
+
id: string;
|
|
23
|
+
version: string;
|
|
24
|
+
file: string;
|
|
25
|
+
bytes: number;
|
|
26
|
+
sha256: string;
|
|
27
|
+
}[];
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Publishes a catalog into a new, empty folder: validates every package the way a host does before
|
|
31
|
+
* installing it, copies each under a name derived from its content, signs `catalog.json` and checks
|
|
32
|
+
* the result with the public key. Every check runs before anything is written. Upload the
|
|
33
|
+
* `packages` folder first and `catalog.json` last.
|
|
34
|
+
*/
|
|
35
|
+
export declare function writeCatalog(options: {
|
|
36
|
+
/** Release configuration; relative paths in it are resolved against its folder. */
|
|
37
|
+
config: string;
|
|
38
|
+
/** PKCS#8 PEM file of the catalog's private key. */
|
|
39
|
+
privateKey: string;
|
|
40
|
+
out: string;
|
|
41
|
+
now?: Date;
|
|
42
|
+
}): Promise<WrittenCatalog>;
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
3
|
+
import { mkdir, readdir, readFile, stat, writeFile } from 'node:fs/promises';
|
|
4
|
+
import { dirname, join, resolve } from 'node:path';
|
|
5
|
+
import { z } from 'zod';
|
|
6
|
+
import { packageLimits } from "../package-rules.js";
|
|
7
|
+
import { CatalogError, catalogKeyId, catalogLimits, catalogPublicKey, compareVersions, generateCatalogKeys, signCatalog, verifyCatalog, } from "./catalog.js";
|
|
8
|
+
import { PackageError, readPackageArchive } from "./package.js";
|
|
9
|
+
import { ZipError } from "./zip.js";
|
|
10
|
+
/** A publication that cannot go ahead; nothing has been written. */
|
|
11
|
+
export class PublishError extends Error {
|
|
12
|
+
problems;
|
|
13
|
+
constructor(problems) {
|
|
14
|
+
super(problems.join('\n'));
|
|
15
|
+
this.problems = problems;
|
|
16
|
+
this.name = 'PublishError';
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
/** The Git working tree containing a folder, if any. */
|
|
20
|
+
function gitWorkingTree(folder) {
|
|
21
|
+
for (let current = resolve(folder);;) {
|
|
22
|
+
if (existsSync(join(current, '.git')))
|
|
23
|
+
return current;
|
|
24
|
+
const parent = dirname(current);
|
|
25
|
+
if (parent === current)
|
|
26
|
+
return undefined;
|
|
27
|
+
current = parent;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
async function readInput(path, what) {
|
|
31
|
+
try {
|
|
32
|
+
return await readFile(path);
|
|
33
|
+
}
|
|
34
|
+
catch (error) {
|
|
35
|
+
if (error.code === 'ENOENT')
|
|
36
|
+
throw new PublishError([`${what} not found: ${path}`]);
|
|
37
|
+
throw error;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Generates a catalog key pair into `<prefix>.private.pem` and `<prefix>.public.txt`. Refuses to
|
|
42
|
+
* write inside a Git working tree, where the private key could end up in a commit, and never
|
|
43
|
+
* overwrites a file.
|
|
44
|
+
*/
|
|
45
|
+
export async function writeCatalogKeys(prefix) {
|
|
46
|
+
const base = resolve(prefix);
|
|
47
|
+
const repository = gitWorkingTree(dirname(base));
|
|
48
|
+
if (repository)
|
|
49
|
+
throw new PublishError([
|
|
50
|
+
`${dirname(base)} is inside the Git working tree ${repository}; keep private keys outside every repository`,
|
|
51
|
+
]);
|
|
52
|
+
const privateKeyFile = `${base}.private.pem`;
|
|
53
|
+
const publicKeyFile = `${base}.public.txt`;
|
|
54
|
+
for (const file of [privateKeyFile, publicKeyFile])
|
|
55
|
+
if (existsSync(file))
|
|
56
|
+
throw new PublishError([`${file} already exists`]);
|
|
57
|
+
const keys = generateCatalogKeys();
|
|
58
|
+
await mkdir(dirname(base), { recursive: true });
|
|
59
|
+
await writeFile(privateKeyFile, keys.privateKey, { flag: 'wx', mode: 0o600 });
|
|
60
|
+
await writeFile(publicKeyFile, `${keys.publicKey}\n`, { flag: 'wx' });
|
|
61
|
+
return { keyId: keys.keyId, publicKey: keys.publicKey, privateKeyFile, publicKeyFile };
|
|
62
|
+
}
|
|
63
|
+
const releaseConfigSchema = z
|
|
64
|
+
.strictObject({
|
|
65
|
+
/** The catalog currently published; the new one continues its sequence. */
|
|
66
|
+
previous: z.string().min(1).optional(),
|
|
67
|
+
/** The sequence of a first publication, when there is no previous catalog. */
|
|
68
|
+
initialSequence: z.number().int().min(1).max(Number.MAX_SAFE_INTEGER).optional(),
|
|
69
|
+
/** Every package the new catalog offers, as `.vormioplugin` files. */
|
|
70
|
+
packages: z
|
|
71
|
+
.array(z.string().min(1))
|
|
72
|
+
.min(1)
|
|
73
|
+
.max(catalogLimits.maxPlugins * catalogLimits.maxReleasesPerPlugin),
|
|
74
|
+
})
|
|
75
|
+
.refine((config) => (config.previous === undefined) !== (config.initialSequence === undefined), {
|
|
76
|
+
message: 'Give either previous or initialSequence',
|
|
77
|
+
});
|
|
78
|
+
async function readRelease(path) {
|
|
79
|
+
const size = (await stat(path).catch(() => undefined))?.size;
|
|
80
|
+
if (size === undefined)
|
|
81
|
+
throw new PublishError([`Package not found: ${path}`]);
|
|
82
|
+
if (size > packageLimits.maxArchiveBytes)
|
|
83
|
+
throw new PublishError([`${path}: larger than ${packageLimits.maxArchiveBytes} bytes`]);
|
|
84
|
+
const data = await readInput(path, 'Package');
|
|
85
|
+
try {
|
|
86
|
+
const { checked, contents } = readPackageArchive(data);
|
|
87
|
+
const sha256 = createHash('sha256').update(data).digest('hex');
|
|
88
|
+
const { id, version } = checked.manifest;
|
|
89
|
+
return {
|
|
90
|
+
id,
|
|
91
|
+
version,
|
|
92
|
+
// The manifest as the package has it, so hosts compare like with like after downloading.
|
|
93
|
+
manifest: JSON.parse(contents.get('manifest.json').toString('utf8')),
|
|
94
|
+
data,
|
|
95
|
+
sha256,
|
|
96
|
+
file: `packages/${id}-${version}-${sha256.slice(0, 12)}.vormioplugin`,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
catch (error) {
|
|
100
|
+
if (error instanceof PackageError)
|
|
101
|
+
throw new PublishError(error.problems.map((problem) => `${path}: ${problem}`));
|
|
102
|
+
if (error instanceof ZipError)
|
|
103
|
+
throw new PublishError([`${path}: ${error.message}`]);
|
|
104
|
+
throw error;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Publishes a catalog into a new, empty folder: validates every package the way a host does before
|
|
109
|
+
* installing it, copies each under a name derived from its content, signs `catalog.json` and checks
|
|
110
|
+
* the result with the public key. Every check runs before anything is written. Upload the
|
|
111
|
+
* `packages` folder first and `catalog.json` last.
|
|
112
|
+
*/
|
|
113
|
+
export async function writeCatalog(options) {
|
|
114
|
+
const configFile = resolve(options.config);
|
|
115
|
+
const base = dirname(configFile);
|
|
116
|
+
let json;
|
|
117
|
+
try {
|
|
118
|
+
json = JSON.parse((await readInput(configFile, 'Release configuration')).toString('utf8'));
|
|
119
|
+
}
|
|
120
|
+
catch (error) {
|
|
121
|
+
if (error instanceof SyntaxError)
|
|
122
|
+
throw new PublishError([`${configFile}: not valid JSON`]);
|
|
123
|
+
throw error;
|
|
124
|
+
}
|
|
125
|
+
const parsed = releaseConfigSchema.safeParse(json);
|
|
126
|
+
if (!parsed.success)
|
|
127
|
+
throw new PublishError(parsed.error.issues.map((issue) => `${issue.path.join('.') || 'config'}: ${issue.message}`));
|
|
128
|
+
const config = parsed.data;
|
|
129
|
+
const privateKey = (await readInput(resolve(options.privateKey), 'Private key')).toString('utf8');
|
|
130
|
+
const publicKey = catalogPublicKey(privateKey);
|
|
131
|
+
const keyId = catalogKeyId(publicKey);
|
|
132
|
+
// The private key cannot be inside an empty folder, so this also keeps it out of the upload.
|
|
133
|
+
const out = resolve(options.out);
|
|
134
|
+
if (existsSync(out) && (await readdir(out)).length > 0)
|
|
135
|
+
throw new PublishError([`${out} is not empty; publish into a new folder`]);
|
|
136
|
+
let sequence = config.initialSequence;
|
|
137
|
+
if (config.previous !== undefined) {
|
|
138
|
+
const previousFile = resolve(base, config.previous);
|
|
139
|
+
try {
|
|
140
|
+
sequence =
|
|
141
|
+
verifyCatalog(await readInput(previousFile, 'Previous catalog'), publicKey).sequence + 1;
|
|
142
|
+
}
|
|
143
|
+
catch (error) {
|
|
144
|
+
if (error instanceof CatalogError)
|
|
145
|
+
throw new PublishError(error.problems.map((problem) => `${previousFile}: ${problem}`));
|
|
146
|
+
throw error;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
const problems = [];
|
|
150
|
+
const plugins = new Map();
|
|
151
|
+
for (const path of config.packages) {
|
|
152
|
+
try {
|
|
153
|
+
const release = await readRelease(resolve(base, path));
|
|
154
|
+
const releases = plugins.get(release.id) ?? [];
|
|
155
|
+
if (releases.some((other) => other.version === release.version))
|
|
156
|
+
problems.push(`${release.id} ${release.version} is listed more than once`);
|
|
157
|
+
releases.push(release);
|
|
158
|
+
plugins.set(release.id, releases);
|
|
159
|
+
}
|
|
160
|
+
catch (error) {
|
|
161
|
+
if (!(error instanceof PublishError))
|
|
162
|
+
throw error;
|
|
163
|
+
problems.push(...error.problems);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
if (problems.length)
|
|
167
|
+
throw new PublishError(problems);
|
|
168
|
+
const ordered = [...plugins.entries()]
|
|
169
|
+
.sort(([a], [b]) => (a < b ? -1 : 1))
|
|
170
|
+
.map(([id, releases]) => ({
|
|
171
|
+
id,
|
|
172
|
+
releases: releases.sort((a, b) => compareVersions(b.version, a.version)),
|
|
173
|
+
}));
|
|
174
|
+
const payload = {
|
|
175
|
+
sequence,
|
|
176
|
+
publishedAt: (options.now ?? new Date()).toISOString(),
|
|
177
|
+
plugins: ordered.map(({ id, releases }) => ({
|
|
178
|
+
id,
|
|
179
|
+
releases: releases.map(({ manifest, file, data, sha256 }) => ({
|
|
180
|
+
manifest: manifest,
|
|
181
|
+
file,
|
|
182
|
+
bytes: data.length,
|
|
183
|
+
sha256,
|
|
184
|
+
})),
|
|
185
|
+
})),
|
|
186
|
+
};
|
|
187
|
+
let catalog;
|
|
188
|
+
try {
|
|
189
|
+
catalog = signCatalog(payload, privateKey);
|
|
190
|
+
}
|
|
191
|
+
catch (error) {
|
|
192
|
+
if (error instanceof CatalogError)
|
|
193
|
+
throw new PublishError([...error.problems]);
|
|
194
|
+
throw error;
|
|
195
|
+
}
|
|
196
|
+
await mkdir(join(out, 'packages'), { recursive: true });
|
|
197
|
+
const releases = ordered.flatMap(({ releases }) => releases);
|
|
198
|
+
for (const release of releases)
|
|
199
|
+
await writeFile(join(out, release.file), release.data, { flag: 'wx' });
|
|
200
|
+
await writeFile(join(out, 'catalog.json'), catalog, { flag: 'wx' });
|
|
201
|
+
verifyCatalog(await readFile(join(out, 'catalog.json')), publicKey);
|
|
202
|
+
return {
|
|
203
|
+
keyId,
|
|
204
|
+
sequence,
|
|
205
|
+
releases: releases.map(({ id, version, file, data, sha256 }) => ({
|
|
206
|
+
id,
|
|
207
|
+
version,
|
|
208
|
+
file,
|
|
209
|
+
bytes: data.length,
|
|
210
|
+
sha256,
|
|
211
|
+
})),
|
|
212
|
+
};
|
|
213
|
+
}
|
package/dist/package-rules.d.ts
CHANGED
|
@@ -4,6 +4,8 @@ export declare const packageLimits: {
|
|
|
4
4
|
readonly maxFileBytes: number;
|
|
5
5
|
readonly maxTotalBytes: number;
|
|
6
6
|
readonly maxPathLength: 240;
|
|
7
|
+
/** A `.vormioplugin` file. Archives are read whole, so this bounds memory before validation. */
|
|
8
|
+
readonly maxArchiveBytes: number;
|
|
7
9
|
};
|
|
8
10
|
/** Static file types a package may contain. Executables and archives are never accepted. */
|
|
9
11
|
export declare const packageExtensions: ReadonlySet<string>;
|
package/dist/package-rules.js
CHANGED
|
@@ -4,6 +4,8 @@ export const packageLimits = {
|
|
|
4
4
|
maxFileBytes: 16 * 1024 * 1024,
|
|
5
5
|
maxTotalBytes: 64 * 1024 * 1024,
|
|
6
6
|
maxPathLength: 240,
|
|
7
|
+
/** A `.vormioplugin` file. Archives are read whole, so this bounds memory before validation. */
|
|
8
|
+
maxArchiveBytes: 96 * 1024 * 1024,
|
|
7
9
|
};
|
|
8
10
|
/** Static file types a package may contain. Executables and archives are never accepted. */
|
|
9
11
|
export const packageExtensions = new Set([
|