@relayflows/sdk 2.0.24 → 2.0.25
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/dist/authored-flow-executor.d.ts +3 -0
- package/dist/authored-flow-executor.d.ts.map +1 -1
- package/dist/authored-flow-executor.js +29 -1
- package/dist/authored-flow-executor.js.map +1 -1
- package/dist/authored-flow-loader.d.ts +15 -2
- package/dist/authored-flow-loader.d.ts.map +1 -1
- package/dist/authored-flow-loader.js +33 -4
- package/dist/authored-flow-loader.js.map +1 -1
- package/dist/authored-hooks.d.ts +29 -0
- package/dist/authored-hooks.d.ts.map +1 -0
- package/dist/authored-hooks.js +117 -0
- package/dist/authored-hooks.js.map +1 -0
- package/dist/authored-node-entry.js +1 -0
- package/dist/authored-node-entry.js.map +1 -1
- package/dist/authored-root.d.ts +7 -0
- package/dist/authored-root.d.ts.map +1 -1
- package/dist/authored-root.js +14 -1
- package/dist/authored-root.js.map +1 -1
- package/dist/authored-source-authority.d.ts.map +1 -1
- package/dist/authored-source-authority.js +6 -0
- package/dist/authored-source-authority.js.map +1 -1
- package/dist/bundle-extensions.d.ts +19 -0
- package/dist/bundle-extensions.d.ts.map +1 -0
- package/dist/bundle-extensions.js +95 -0
- package/dist/bundle-extensions.js.map +1 -0
- package/dist/bundle-typescript.js +1 -1
- package/dist/bundle-typescript.js.map +1 -1
- package/dist/bundle.d.ts +11 -0
- package/dist/bundle.d.ts.map +1 -1
- package/dist/bundle.js +13 -4
- package/dist/bundle.js.map +1 -1
- package/dist/cli/add-extension.d.ts +28 -0
- package/dist/cli/add-extension.d.ts.map +1 -0
- package/dist/cli/add-extension.js +135 -0
- package/dist/cli/add-extension.js.map +1 -0
- package/dist/cli/add.d.ts +3 -0
- package/dist/cli/add.d.ts.map +1 -1
- package/dist/cli/add.js +8 -0
- package/dist/cli/add.js.map +1 -1
- package/dist/cli/build.d.ts.map +1 -1
- package/dist/cli/build.js +7 -2
- package/dist/cli/build.js.map +1 -1
- package/dist/cli/check-helper-body.d.ts.map +1 -1
- package/dist/cli/check-helper-body.js +4 -0
- package/dist/cli/check-helper-body.js.map +1 -1
- package/dist/cli/check-triggers.d.ts.map +1 -1
- package/dist/cli/check-triggers.js +25 -2
- package/dist/cli/check-triggers.js.map +1 -1
- package/dist/cli/check-typescript.d.ts.map +1 -1
- package/dist/cli/check-typescript.js +5 -1
- package/dist/cli/check-typescript.js.map +1 -1
- package/dist/cli/check.d.ts +24 -0
- package/dist/cli/check.d.ts.map +1 -1
- package/dist/cli/check.js.map +1 -1
- package/dist/cli/cloud-deploy.d.ts +1 -0
- package/dist/cli/cloud-deploy.d.ts.map +1 -1
- package/dist/cli/cloud-deploy.js +11 -1
- package/dist/cli/cloud-deploy.js.map +1 -1
- package/dist/cli/plugin.d.ts +50 -0
- package/dist/cli/plugin.d.ts.map +1 -0
- package/dist/cli/plugin.js +295 -0
- package/dist/cli/plugin.js.map +1 -0
- package/dist/cli-commands.d.ts +46 -3
- package/dist/cli-commands.d.ts.map +1 -1
- package/dist/cli-commands.js +32 -2
- package/dist/cli-commands.js.map +1 -1
- package/dist/cli.d.ts +2 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +26 -1
- package/dist/cli.js.map +1 -1
- package/dist/cloud-deploy.d.ts +5 -0
- package/dist/cloud-deploy.d.ts.map +1 -1
- package/dist/cloud-deploy.js +7 -6
- package/dist/cloud-deploy.js.map +1 -1
- package/dist/cloud-run.d.ts +2 -0
- package/dist/cloud-run.d.ts.map +1 -1
- package/dist/cloud-run.js +14 -4
- package/dist/cloud-run.js.map +1 -1
- package/dist/failure-kinds.d.ts +2 -2
- package/dist/failure-kinds.d.ts.map +1 -1
- package/dist/flow-extension-compat.d.ts +18 -0
- package/dist/flow-extension-compat.d.ts.map +1 -0
- package/dist/flow-extension-compat.js +33 -0
- package/dist/flow-extension-compat.js.map +1 -0
- package/dist/flow-extension-loader.d.ts +61 -0
- package/dist/flow-extension-loader.d.ts.map +1 -0
- package/dist/flow-extension-loader.js +173 -0
- package/dist/flow-extension-loader.js.map +1 -0
- package/dist/flow-extension-manifest.d.ts +61 -0
- package/dist/flow-extension-manifest.d.ts.map +1 -0
- package/dist/flow-extension-manifest.js +188 -0
- package/dist/flow-extension-manifest.js.map +1 -0
- package/dist/flow-extension-submit.d.ts +38 -0
- package/dist/flow-extension-submit.d.ts.map +1 -0
- package/dist/flow-extension-submit.js +94 -0
- package/dist/flow-extension-submit.js.map +1 -0
- package/dist/flow-requirements.d.ts +13 -3
- package/dist/flow-requirements.d.ts.map +1 -1
- package/dist/flow-requirements.js +30 -0
- package/dist/flow-requirements.js.map +1 -1
- package/dist/plugin-github.d.ts +35 -0
- package/dist/plugin-github.d.ts.map +1 -0
- package/dist/plugin-github.js +92 -0
- package/dist/plugin-github.js.map +1 -0
- package/dist/plugin-loader.d.ts.map +1 -1
- package/dist/plugin-loader.js +5 -1
- package/dist/plugin-loader.js.map +1 -1
- package/dist/plugin-lock.d.ts +79 -0
- package/dist/plugin-lock.d.ts.map +1 -0
- package/dist/plugin-lock.js +203 -0
- package/dist/plugin-lock.js.map +1 -0
- package/dist/plugin-manifest.d.ts +5 -1
- package/dist/plugin-manifest.d.ts.map +1 -1
- package/dist/plugin-manifest.js +17 -0
- package/dist/plugin-manifest.js.map +1 -1
- package/dist/plugin-source.d.ts +38 -0
- package/dist/plugin-source.d.ts.map +1 -0
- package/dist/plugin-source.js +84 -0
- package/dist/plugin-source.js.map +1 -0
- package/dist/plugin-store.d.ts +30 -0
- package/dist/plugin-store.d.ts.map +1 -0
- package/dist/plugin-store.js +133 -0
- package/dist/plugin-store.js.map +1 -0
- package/dist/semver-range.d.ts +10 -0
- package/dist/semver-range.d.ts.map +1 -0
- package/dist/semver-range.js +88 -0
- package/dist/semver-range.js.map +1 -0
- package/package.json +2 -2
- package/src/authored-flow-executor.ts +36 -1
- package/src/authored-flow-loader.ts +49 -6
- package/src/authored-hooks.ts +136 -0
- package/src/authored-node-entry.ts +1 -0
- package/src/authored-root.ts +22 -1
- package/src/authored-source-authority.ts +6 -0
- package/src/bundle-extensions.ts +85 -0
- package/src/bundle-typescript.ts +1 -1
- package/src/bundle.ts +14 -4
- package/src/cli/add-extension.ts +134 -0
- package/src/cli/add.ts +9 -0
- package/src/cli/build.ts +7 -2
- package/src/cli/check-helper-body.ts +4 -0
- package/src/cli/check-triggers.ts +27 -2
- package/src/cli/check-typescript.ts +5 -1
- package/src/cli/check.ts +23 -0
- package/src/cli/cloud-deploy.ts +11 -1
- package/src/cli/plugin.ts +286 -0
- package/src/cli-commands.ts +32 -2
- package/src/cli.ts +23 -1
- package/src/cloud-deploy.ts +18 -8
- package/src/cloud-run.ts +16 -6
- package/src/flow-extension-compat.ts +35 -0
- package/src/flow-extension-loader.ts +217 -0
- package/src/flow-extension-manifest.ts +190 -0
- package/src/flow-extension-submit.ts +120 -0
- package/src/flow-requirements.ts +44 -3
- package/src/plugin-github.ts +98 -0
- package/src/plugin-loader.ts +5 -1
- package/src/plugin-lock.ts +194 -0
- package/src/plugin-manifest.ts +17 -0
- package/src/plugin-source.ts +91 -0
- package/src/plugin-store.ts +105 -0
- package/src/semver-range.ts +74 -0
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { payloadManifest, safePath, sha256 } from './bundle.js';
|
|
2
|
+
import { PluginError } from './plugin-manifest.js';
|
|
3
|
+
import { SHA, type PluginSourceInput, type PluginSourceRef } from './plugin-source.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Public, unauthenticated GitHub reads for flow-extension plugins — the same
|
|
7
|
+
* posture as Cloud's deploy links: no credential ever travels to GitHub, so a
|
|
8
|
+
* private repository simply answers 404 and is reported as unresolved.
|
|
9
|
+
*
|
|
10
|
+
* Two calls resolve a ref and enumerate a tree; blobs come from
|
|
11
|
+
* raw.githubusercontent.com pinned to the resolved commit. Every byte count is
|
|
12
|
+
* checked against the tree listing, and the tree is refused if GitHub
|
|
13
|
+
* truncated it, if it contains a symlink or submodule under the plugin path,
|
|
14
|
+
* or if any file or the whole plugin exceeds the bounds below.
|
|
15
|
+
*/
|
|
16
|
+
export type FetchLike = (url: string, init: { headers: Record<string, string>; signal: AbortSignal }) => Promise<Response>;
|
|
17
|
+
|
|
18
|
+
export const MAX_PLUGIN_FILE_BYTES = 256_000;
|
|
19
|
+
export const MAX_PLUGIN_TOTAL_BYTES = 2_000_000;
|
|
20
|
+
export const MAX_PLUGIN_FILES = 500;
|
|
21
|
+
const TIMEOUT_MS = 15_000;
|
|
22
|
+
const API = 'https://api.github.com';
|
|
23
|
+
const RAW = 'https://raw.githubusercontent.com';
|
|
24
|
+
|
|
25
|
+
export interface FetchedPluginFile { readonly path: string; readonly data: Buffer }
|
|
26
|
+
export interface FetchedPlugin {
|
|
27
|
+
readonly source: PluginSourceRef;
|
|
28
|
+
/** Plugin-relative paths, sorted. */
|
|
29
|
+
readonly files: readonly FetchedPluginFile[];
|
|
30
|
+
/** sha256 of `payloadManifest(files)`: the content identity persisted as `@sha256:`. */
|
|
31
|
+
readonly digest: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async function request(fetch: FetchLike, url: string, accept: string): Promise<Response> {
|
|
35
|
+
let response: Response;
|
|
36
|
+
try {
|
|
37
|
+
response = await fetch(url, { headers: { accept, 'user-agent': 'relayflows-sdk' }, signal: AbortSignal.timeout(TIMEOUT_MS) });
|
|
38
|
+
} catch (error) {
|
|
39
|
+
throw new PluginError('plugin_fetch_failed', `GitHub request failed: ${url} (${error instanceof Error ? error.message : String(error)}).`);
|
|
40
|
+
}
|
|
41
|
+
if (response.status === 404) throw new PluginError('plugin_source_unresolved', `Not found on GitHub (or not public): ${url}.`);
|
|
42
|
+
if (!response.ok) throw new PluginError('plugin_fetch_failed', `GitHub answered ${response.status} for ${url}.`);
|
|
43
|
+
return response;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Branch, tag, or commit → the commit sha it names right now; a sha input is verified to exist. */
|
|
47
|
+
export async function resolveGithubSha(source: PluginSourceInput, fetch: FetchLike = globalThis.fetch): Promise<PluginSourceRef> {
|
|
48
|
+
const url = `${API}/repos/${source.owner}/${source.repo}/commits/${encodeURIComponent(source.ref)}`;
|
|
49
|
+
const sha = (await (await request(fetch, url, 'application/vnd.github.sha')).text()).trim();
|
|
50
|
+
if (!SHA.test(sha)) throw new PluginError('plugin_fetch_failed', `GitHub returned a malformed commit sha for ${source.ref}.`);
|
|
51
|
+
if (SHA.test(source.ref) && sha !== source.ref) throw new PluginError('plugin_source_unresolved', `Commit ${source.ref} resolved to ${sha}.`);
|
|
52
|
+
return Object.freeze({ ...source, sha });
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
interface TreeEntry { path: string; mode: string; type: string; size?: number }
|
|
56
|
+
|
|
57
|
+
async function listTree(source: PluginSourceRef, fetch: FetchLike): Promise<TreeEntry[]> {
|
|
58
|
+
const url = `${API}/repos/${source.owner}/${source.repo}/git/trees/${source.sha}?recursive=1`;
|
|
59
|
+
let body: unknown;
|
|
60
|
+
try { body = await (await request(fetch, url, 'application/vnd.github+json')).json(); }
|
|
61
|
+
catch { throw new PluginError('plugin_fetch_failed', 'GitHub tree listing is not JSON.'); }
|
|
62
|
+
const tree = typeof body === 'object' && body !== null ? (body as { tree?: unknown; truncated?: unknown }) : {};
|
|
63
|
+
if (tree.truncated === true) throw new PluginError('plugin_fetch_failed', 'GitHub truncated the tree listing; the repository is too large to enumerate safely.');
|
|
64
|
+
if (!Array.isArray(tree.tree)) throw new PluginError('plugin_fetch_failed', 'GitHub tree listing has no entries.');
|
|
65
|
+
return tree.tree.filter((e): e is TreeEntry => typeof e === 'object' && e !== null
|
|
66
|
+
&& typeof (e as TreeEntry).path === 'string' && typeof (e as TreeEntry).mode === 'string' && typeof (e as TreeEntry).type === 'string');
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Enumerate and download the plugin directory at the pinned commit, bounded and verified. */
|
|
70
|
+
export async function fetchGithubPlugin(source: PluginSourceRef, fetch: FetchLike = globalThis.fetch): Promise<FetchedPlugin> {
|
|
71
|
+
const prefix = source.path === '' ? '' : `${source.path}/`;
|
|
72
|
+
const entries = (await listTree(source, fetch)).filter(e => e.path.startsWith(prefix));
|
|
73
|
+
if (source.path !== '' && entries.length === 0) throw new PluginError('plugin_source_unresolved', `${source.path} does not exist at ${source.sha}.`);
|
|
74
|
+
const blobs: { path: string; size: number }[] = [];
|
|
75
|
+
let total = 0;
|
|
76
|
+
for (const entry of entries) {
|
|
77
|
+
const relative = entry.path.slice(prefix.length);
|
|
78
|
+
if (entry.type === 'tree') continue;
|
|
79
|
+
if (entry.type === 'commit') throw new PluginError('plugin_path_invalid', `${entry.path} is a submodule; plugins must be plain files.`);
|
|
80
|
+
if (entry.mode === '120000') throw new PluginError('plugin_path_invalid', `${entry.path} is a symlink; plugins must be plain files.`);
|
|
81
|
+
if (entry.type !== 'blob' || !safePath(relative)) throw new PluginError('plugin_path_invalid', `${entry.path} is not a plain repository file.`);
|
|
82
|
+
if (!Number.isSafeInteger(entry.size) || entry.size! < 0) throw new PluginError('plugin_fetch_failed', `${entry.path} has no size in the tree listing.`);
|
|
83
|
+
if (entry.size! > MAX_PLUGIN_FILE_BYTES) throw new PluginError('plugin_too_large', `${entry.path} is ${entry.size} bytes; the limit is ${MAX_PLUGIN_FILE_BYTES}.`);
|
|
84
|
+
total += entry.size!;
|
|
85
|
+
if (total > MAX_PLUGIN_TOTAL_BYTES) throw new PluginError('plugin_too_large', `Plugin exceeds ${MAX_PLUGIN_TOTAL_BYTES} bytes in total.`);
|
|
86
|
+
blobs.push({ path: relative, size: entry.size! });
|
|
87
|
+
if (blobs.length > MAX_PLUGIN_FILES) throw new PluginError('plugin_too_large', `Plugin has more than ${MAX_PLUGIN_FILES} files.`);
|
|
88
|
+
}
|
|
89
|
+
if (!blobs.some(b => b.path === 'flows-plugin.json')) throw new PluginError('plugin_manifest_missing', `${source.owner}/${source.repo}@${source.sha}${prefix === '' ? '' : `#${source.path}`} has no flows-plugin.json.`);
|
|
90
|
+
const files: FetchedPluginFile[] = [];
|
|
91
|
+
for (const blob of blobs.sort((a, b) => a.path < b.path ? -1 : 1)) {
|
|
92
|
+
const url = `${RAW}/${source.owner}/${source.repo}/${source.sha}/${prefix}${blob.path}`;
|
|
93
|
+
const data = Buffer.from(await (await request(fetch, url, 'application/octet-stream')).arrayBuffer());
|
|
94
|
+
if (data.length !== blob.size) throw new PluginError('plugin_source_drift', `${blob.path}: fetched ${data.length} bytes, tree lists ${blob.size}.`);
|
|
95
|
+
files.push(Object.freeze({ path: blob.path, data }));
|
|
96
|
+
}
|
|
97
|
+
return Object.freeze({ source, files: Object.freeze(files), digest: sha256(payloadManifest(files)) });
|
|
98
|
+
}
|
package/src/plugin-loader.ts
CHANGED
|
@@ -5,6 +5,7 @@ import { Ajv } from 'ajv';
|
|
|
5
5
|
import type { Step } from '@relayflows/surface';
|
|
6
6
|
import { snapshotJsonValue } from './json-value.js';
|
|
7
7
|
import { assertSupportedPlugin, PluginError, pluginPackageName, validatePluginManifest, type PluginManifest, type PluginVerb } from './plugin-manifest.js';
|
|
8
|
+
import { isGithubPluginRef } from './plugin-source.js';
|
|
8
9
|
|
|
9
10
|
export interface LoadedPlugin { readonly directory: string; readonly manifest: PluginManifest }
|
|
10
11
|
export function findPluginProject(start: string): string | undefined {
|
|
@@ -47,8 +48,11 @@ export async function loadPlugins(start: string): Promise<readonly LoadedPlugin[
|
|
|
47
48
|
if (config === null || typeof config !== 'object' || (config.plugins !== undefined && (!Array.isArray(config.plugins) || !config.plugins.every(p => typeof p === 'string')))) {
|
|
48
49
|
throw new PluginError('plugin_manifest_invalid', 'flows.json plugins must be package names.');
|
|
49
50
|
}
|
|
51
|
+
// Flow extensions (github:… entries) are not helpers: the authored flow
|
|
52
|
+
// loader verifies and composes them (flow-extension-loader.ts). Here they
|
|
53
|
+
// are simply not helper packages, so they are left out of the helper set.
|
|
50
54
|
const scope = join(root, 'node_modules/@flows');
|
|
51
|
-
const names = new Set<string>((config.plugins as string[] | undefined)?.map(pluginPackageName));
|
|
55
|
+
const names = new Set<string>((config.plugins as string[] | undefined)?.filter(p => !isGithubPluginRef(p)).map(pluginPackageName));
|
|
52
56
|
if (existsSync(scope)) for (const name of readdirSync(scope).sort()) {
|
|
53
57
|
if (name.startsWith('helper-') && !names.has(`@flows/${name}`)) {
|
|
54
58
|
throw new PluginError('plugin_unlisted', `@flows/${name} is installed but not declared in flows.json plugins. Run flows add ${name}.`);
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import { existsSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { PluginError } from './plugin-manifest.js';
|
|
4
|
+
import { SHA, canonicalPluginRef, isGithubPluginRef, parseCanonicalPluginRef, type PluginSourceRef } from './plugin-source.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* `flows.lock.json` — the project's plugin provenance. `flows.json.plugins`
|
|
8
|
+
* says *what* is declared; the lockfile says exactly which bytes that meant:
|
|
9
|
+
* the commit, the content digest, the manifest hash, and the order the
|
|
10
|
+
* operator declared. Version 2 is also the sealed bundle `lockfile.json`
|
|
11
|
+
* shape when the bundle carries plugins (RFC-0001 decision 14).
|
|
12
|
+
*/
|
|
13
|
+
export const PLUGIN_LOCK_FILE = 'flows.lock.json';
|
|
14
|
+
export const PLUGIN_LOCK_VERSION = 2;
|
|
15
|
+
const FLOWS_FILE = 'flows.json';
|
|
16
|
+
|
|
17
|
+
export interface PluginLockEntry {
|
|
18
|
+
readonly name: string;
|
|
19
|
+
readonly kind: 'flow-extension';
|
|
20
|
+
readonly version: string;
|
|
21
|
+
readonly source: { readonly host: 'github'; readonly owner: string; readonly repo: string; readonly sha: string; readonly path: string };
|
|
22
|
+
/** sha256 of the payload manifest — the `@sha256:` in `.flows/plugins`. */
|
|
23
|
+
readonly digest: string;
|
|
24
|
+
/** sha256 of the `flows-plugin.json` bytes as installed. */
|
|
25
|
+
readonly manifestSha256: string;
|
|
26
|
+
/**
|
|
27
|
+
* 1-based position among the flow-extension (`github:`) entries of
|
|
28
|
+
* `flows.json.plugins`, in declaration order. Helper entries interspersed in
|
|
29
|
+
* that list do not count, so the order is the composition order exactly.
|
|
30
|
+
*/
|
|
31
|
+
readonly order: number;
|
|
32
|
+
readonly resolvedAt: string;
|
|
33
|
+
}
|
|
34
|
+
export interface PluginLock { readonly version: 2; readonly plugins: readonly PluginLockEntry[] }
|
|
35
|
+
|
|
36
|
+
const HEX64 = /^[0-9a-f]{64}$/;
|
|
37
|
+
const object = (v: unknown): v is Record<string, unknown> => typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
38
|
+
const invalid = (message: string): never => { throw new PluginError('plugin_lock_invalid', `${PLUGIN_LOCK_FILE}: ${message}`); };
|
|
39
|
+
|
|
40
|
+
export function parsePluginLock(input: unknown): PluginLock {
|
|
41
|
+
if (!object(input) || input.version !== PLUGIN_LOCK_VERSION || !Array.isArray(input.plugins)
|
|
42
|
+
|| Object.keys(input).some(k => !['version', 'plugins'].includes(k))) return invalid(`expected { version: ${PLUGIN_LOCK_VERSION}, plugins: [] }.`);
|
|
43
|
+
const names = new Set<string>();
|
|
44
|
+
const plugins = input.plugins.map((entry, index) => {
|
|
45
|
+
if (!object(entry) || Object.keys(entry).sort().join(',') !== 'digest,kind,manifestSha256,name,order,resolvedAt,source,version'
|
|
46
|
+
|| entry.kind !== 'flow-extension' || typeof entry.name !== 'string' || typeof entry.version !== 'string'
|
|
47
|
+
|| typeof entry.digest !== 'string' || !HEX64.test(entry.digest)
|
|
48
|
+
|| typeof entry.manifestSha256 !== 'string' || !HEX64.test(entry.manifestSha256)
|
|
49
|
+
|| entry.order !== index + 1 || typeof entry.resolvedAt !== 'string' || Number.isNaN(Date.parse(entry.resolvedAt))
|
|
50
|
+
|| !object(entry.source) || entry.source.host !== 'github' || typeof entry.source.owner !== 'string'
|
|
51
|
+
|| typeof entry.source.repo !== 'string' || typeof entry.source.sha !== 'string' || !SHA.test(entry.source.sha)
|
|
52
|
+
|| typeof entry.source.path !== 'string') return invalid(`plugins[${index}] is malformed.`);
|
|
53
|
+
if (names.has(entry.name)) return invalid(`plugin ${entry.name} is listed twice.`);
|
|
54
|
+
names.add(entry.name);
|
|
55
|
+
const source = parseCanonicalPluginRef(canonicalPluginRef({ host: 'github', owner: entry.source.owner, repo: entry.source.repo, ref: entry.source.sha, sha: entry.source.sha, path: entry.source.path }));
|
|
56
|
+
return Object.freeze({
|
|
57
|
+
name: entry.name, kind: 'flow-extension' as const, version: entry.version,
|
|
58
|
+
source: Object.freeze({ host: 'github' as const, owner: source.owner, repo: source.repo, sha: source.sha, path: source.path }),
|
|
59
|
+
digest: entry.digest, manifestSha256: entry.manifestSha256, order: entry.order, resolvedAt: entry.resolvedAt,
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
return Object.freeze({ version: PLUGIN_LOCK_VERSION, plugins: Object.freeze(plugins) });
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Absent file → empty lock; unreadable or malformed → refusal. */
|
|
66
|
+
export function readPluginLock(root: string): PluginLock {
|
|
67
|
+
recoverFlowsAndLock(root);
|
|
68
|
+
const path = join(root, PLUGIN_LOCK_FILE);
|
|
69
|
+
if (!existsSync(path)) return Object.freeze({ version: PLUGIN_LOCK_VERSION, plugins: Object.freeze([]) });
|
|
70
|
+
let parsed: unknown;
|
|
71
|
+
try { parsed = JSON.parse(readFileSync(path, 'utf8')); }
|
|
72
|
+
catch { return invalid('not valid JSON.'); }
|
|
73
|
+
return parsePluginLock(parsed);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function writePluginLock(root: string, lock: PluginLock): void {
|
|
77
|
+
writeFileSync(join(root, PLUGIN_LOCK_FILE), `${JSON.stringify(parsePluginLock(lock), null, 2)}\n`);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Finish or abort a two-file update left by a process crash. The lock temp is
|
|
82
|
+
* written first: by itself it is only preparation and can be discarded. Once
|
|
83
|
+
* the config temp exists, both complete snapshots exist and recovery rolls
|
|
84
|
+
* them forward in the same lock-then-declaration order as the writer.
|
|
85
|
+
*/
|
|
86
|
+
export function recoverFlowsAndLock(root: string, configPath = join(root, FLOWS_FILE)): void {
|
|
87
|
+
const jsonTmp = `${configPath}.tmp`;
|
|
88
|
+
const lockPath = join(root, PLUGIN_LOCK_FILE);
|
|
89
|
+
const lockTmp = `${lockPath}.tmp`;
|
|
90
|
+
const hasJson = existsSync(jsonTmp);
|
|
91
|
+
const hasLock = existsSync(lockTmp);
|
|
92
|
+
if (!hasJson && !hasLock) return;
|
|
93
|
+
if (!hasJson) {
|
|
94
|
+
unlinkSync(lockTmp);
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
let pendingConfig: unknown;
|
|
98
|
+
try { pendingConfig = JSON.parse(readFileSync(jsonTmp, 'utf8')); }
|
|
99
|
+
catch {
|
|
100
|
+
if (hasLock) { unlinkSync(jsonTmp); unlinkSync(lockTmp); return; }
|
|
101
|
+
return invalid('pending transaction has invalid flows.json.tmp.');
|
|
102
|
+
}
|
|
103
|
+
if (!object(pendingConfig) || !Array.isArray(pendingConfig.plugins)
|
|
104
|
+
|| !pendingConfig.plugins.every(plugin => typeof plugin === 'string')) {
|
|
105
|
+
if (hasLock) { unlinkSync(jsonTmp); unlinkSync(lockTmp); return; }
|
|
106
|
+
return invalid('pending transaction has invalid flows.json.tmp.');
|
|
107
|
+
}
|
|
108
|
+
const lockSource = hasLock ? lockTmp : lockPath;
|
|
109
|
+
let pendingLock: unknown;
|
|
110
|
+
try { pendingLock = JSON.parse(readFileSync(lockSource, 'utf8')); }
|
|
111
|
+
catch {
|
|
112
|
+
if (hasLock) { unlinkSync(jsonTmp); unlinkSync(lockTmp); return; }
|
|
113
|
+
return invalid('pending transaction has no valid lock snapshot.');
|
|
114
|
+
}
|
|
115
|
+
try { parsePluginLock(pendingLock); }
|
|
116
|
+
catch (error) {
|
|
117
|
+
if (hasLock) { unlinkSync(jsonTmp); unlinkSync(lockTmp); return; }
|
|
118
|
+
throw error;
|
|
119
|
+
}
|
|
120
|
+
if (hasLock) renameSync(lockTmp, lockPath);
|
|
121
|
+
renameSync(jsonTmp, configPath);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Write complete snapshots, then recover them as one roll-forward transaction. */
|
|
125
|
+
export function writeFlowsAndLock(root: string, configPath: string, config: Record<string, unknown>, plugins: readonly string[], lock: PluginLock): void {
|
|
126
|
+
const jsonTmp = `${configPath}.tmp`;
|
|
127
|
+
const lockPath = join(root, PLUGIN_LOCK_FILE);
|
|
128
|
+
const lockTmp = `${lockPath}.tmp`;
|
|
129
|
+
writeFileSync(lockTmp, `${JSON.stringify(parsePluginLock(lock), null, 2)}\n`);
|
|
130
|
+
writeFileSync(jsonTmp, `${JSON.stringify({ ...config, plugins: [...plugins] }, null, 2)}\n`);
|
|
131
|
+
recoverFlowsAndLock(root, configPath);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Rebuild the entry list in `flows.json.plugins` order: `order` is derived from
|
|
136
|
+
* the declaration list, never stored independently, so the two cannot disagree.
|
|
137
|
+
* Lock entries that are no longer declared are dropped; a declaration with no
|
|
138
|
+
* matching lock entry is a refusal.
|
|
139
|
+
*/
|
|
140
|
+
export function lockForDeclared(lock: PluginLock, declared: readonly string[]): PluginLock {
|
|
141
|
+
const byRef = new Map(lock.plugins.map(p => [canonicalPluginRef({ ...p.source, ref: p.source.sha }), p]));
|
|
142
|
+
const plugins = declared.filter(isGithubPluginRef).map((ref, index) => {
|
|
143
|
+
const found = byRef.get(ref);
|
|
144
|
+
if (found === undefined) return invalid(`flows.json declares ${ref} but the lockfile has no entry for it; run flows add ${ref}.`);
|
|
145
|
+
return Object.freeze({ ...found, order: index + 1 });
|
|
146
|
+
});
|
|
147
|
+
return Object.freeze({ version: PLUGIN_LOCK_VERSION, plugins: Object.freeze(plugins) });
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export function lockWithPlugin(
|
|
151
|
+
lock: PluginLock, declared: readonly string[],
|
|
152
|
+
entry: Omit<PluginLockEntry, 'kind' | 'order'>,
|
|
153
|
+
): PluginLock {
|
|
154
|
+
const byRef = new Map(lock.plugins.map(p => [canonicalPluginRef({ ...p.source, ref: p.source.sha }), p]));
|
|
155
|
+
byRef.set(canonicalPluginRef({ ...entry.source, ref: entry.source.sha }), { ...entry, kind: 'flow-extension', order: 0 });
|
|
156
|
+
return lockForDeclared({ version: PLUGIN_LOCK_VERSION, plugins: Object.freeze([...byRef.values()]) }, declared);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Lock entries paired with their declared reference, in declaration order. */
|
|
160
|
+
export function lockedPlugins(lock: PluginLock): readonly { ref: string; entry: PluginLockEntry; source: PluginSourceRef }[] {
|
|
161
|
+
return lock.plugins.map(entry => {
|
|
162
|
+
const source = { ...entry.source, ref: entry.source.sha };
|
|
163
|
+
return { ref: canonicalPluginRef(source), entry, source };
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** The `github:` entries of `flows.json.plugins`, in declaration order; helper entries are left out. */
|
|
168
|
+
export function declaredExtensionRefs(root: string): readonly string[] {
|
|
169
|
+
recoverFlowsAndLock(root);
|
|
170
|
+
let config: { plugins?: unknown };
|
|
171
|
+
try { config = JSON.parse(readFileSync(join(root, 'flows.json'), 'utf8')); }
|
|
172
|
+
catch { throw new PluginError('plugin_manifest_invalid', 'Invalid flows.json.'); }
|
|
173
|
+
if (config === null || typeof config !== 'object' || (config.plugins !== undefined && (!Array.isArray(config.plugins) || !config.plugins.every(p => typeof p === 'string')))) {
|
|
174
|
+
throw new PluginError('plugin_manifest_invalid', 'flows.json plugins must be strings.');
|
|
175
|
+
}
|
|
176
|
+
return Object.freeze(((config.plugins as string[] | undefined) ?? []).filter(isGithubPluginRef));
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* The three records that must agree before an extension is trusted:
|
|
181
|
+
* `flows.json.plugins` (declaration), `flows.lock.json` (provenance), and —
|
|
182
|
+
* checked by the caller against the returned digests — `.flows/plugins`
|
|
183
|
+
* (bytes). Any declaration without a lock entry, lock entry without a
|
|
184
|
+
* declaration, or order disagreement is `plugin_lock_invalid`.
|
|
185
|
+
*/
|
|
186
|
+
export function reconcileDeclaredExtensions(root: string): readonly { ref: string; entry: PluginLockEntry; source: PluginSourceRef }[] {
|
|
187
|
+
const declared = declaredExtensionRefs(root);
|
|
188
|
+
const locked = lockedPlugins(readPluginLock(root));
|
|
189
|
+
const lockedRefs = locked.map(p => p.ref);
|
|
190
|
+
for (const ref of declared) if (!lockedRefs.includes(ref)) throw new PluginError('plugin_lock_invalid', `flows.json declares ${ref} but flows.lock.json has no entry for it.`);
|
|
191
|
+
for (const ref of lockedRefs) if (!declared.includes(ref)) throw new PluginError('plugin_lock_invalid', `flows.lock.json records ${ref} but flows.json does not declare it.`);
|
|
192
|
+
if (declared.some((ref, index) => lockedRefs[index] !== ref)) throw new PluginError('plugin_lock_invalid', 'flows.lock.json order differs from flows.json.plugins.');
|
|
193
|
+
return locked;
|
|
194
|
+
}
|
package/src/plugin-manifest.ts
CHANGED
|
@@ -5,6 +5,10 @@ export const PLUGIN_FAILURE_KINDS = [
|
|
|
5
5
|
'plugin_unknown', 'plugin_unlisted', 'plugin_install_failed', 'plugin_manifest_missing',
|
|
6
6
|
'plugin_manifest_invalid', 'plugin_preflight_missing', 'plugin_verb_unknown_primitive',
|
|
7
7
|
'plugin_unsupported', 'plugin_credential_missing', 'plugin_server_unreachable',
|
|
8
|
+
// schema 2 flow extensions (see flow-extension-manifest.ts, plugin-source.ts, plugin-github.ts)
|
|
9
|
+
'plugin_kind_invalid', 'plugin_incompatible', 'plugin_event_unroutable', 'plugin_source_invalid',
|
|
10
|
+
'plugin_source_unresolved', 'plugin_fetch_failed', 'plugin_path_invalid', 'plugin_too_large',
|
|
11
|
+
'plugin_source_drift', 'plugin_lock_invalid',
|
|
8
12
|
] as const;
|
|
9
13
|
export type PluginFailureKind = typeof PLUGIN_FAILURE_KINDS[number];
|
|
10
14
|
export class PluginError extends Error {
|
|
@@ -16,6 +20,15 @@ export interface PluginVerb {
|
|
|
16
20
|
lowersTo: 'run' | 'llm' | 'agent' | 'effect' | 'wait';
|
|
17
21
|
args: Record<string, unknown>;
|
|
18
22
|
}
|
|
23
|
+
/** The kind a `flows-plugin.json` declares; absent means the schema-1 helper plugin. */
|
|
24
|
+
export type PluginKind = 'helper' | 'flow-extension';
|
|
25
|
+
export function pluginKindOf(input: unknown): PluginKind {
|
|
26
|
+
const kind = typeof input === 'object' && input !== null && !Array.isArray(input) ? (input as { kind?: unknown }).kind : undefined;
|
|
27
|
+
if (kind === undefined || kind === 'helper') return 'helper';
|
|
28
|
+
if (kind === 'flow-extension') return 'flow-extension';
|
|
29
|
+
throw new PluginError('plugin_kind_invalid', `Unknown plugin kind ${JSON.stringify(kind)}; expected "helper" or "flow-extension".`);
|
|
30
|
+
}
|
|
31
|
+
/** A schema-1 helper plugin: verbs that lower to kernel primitives. */
|
|
19
32
|
export interface PluginManifest {
|
|
20
33
|
name: string;
|
|
21
34
|
version: string;
|
|
@@ -41,6 +54,10 @@ export function validatePluginManifest(input: unknown, packageName?: string): Pl
|
|
|
41
54
|
catch { throw new PluginError('plugin_manifest_invalid', 'Plugin manifest must be JSON data.'); }
|
|
42
55
|
const invalid = (message: string): never => { throw new PluginError('plugin_manifest_invalid', message); };
|
|
43
56
|
if (!object(v)) return invalid('Expected a plugin manifest object.');
|
|
57
|
+
if (pluginKindOf(v) !== 'helper') {
|
|
58
|
+
throw new PluginError('plugin_kind_invalid', 'A flow-extension manifest installs from a GitHub reference (flows add github:<owner>/<repo>@<ref>#<path>), not as a helper package.');
|
|
59
|
+
}
|
|
60
|
+
if (v.schema !== undefined && v.schema !== 1) return invalid('Helper plugin manifests are schema 1.');
|
|
44
61
|
if (!Object.hasOwn(v, 'preflight')) throw new PluginError('plugin_preflight_missing', 'Plugin must declare preflight.');
|
|
45
62
|
if (typeof v.name !== 'string' || typeof v.version !== 'string' || !v.version.trim()) return invalid('Plugin name and version are required.');
|
|
46
63
|
const resolved = pluginPackageName(v.name);
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { safePath } from './bundle.js';
|
|
2
|
+
import { PluginError } from './plugin-manifest.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Where a flow-extension plugin comes from: a public GitHub repository, pinned
|
|
6
|
+
* to a commit. A branch or tag is accepted as *input* only; what gets written
|
|
7
|
+
* to `flows.json` and the lockfile is always the canonical 40-hex form,
|
|
8
|
+
* `github:<owner>/<repo>@<sha>#<path>`, so a later reader can never resolve
|
|
9
|
+
* to different bytes than the installer saw.
|
|
10
|
+
*/
|
|
11
|
+
export interface PluginSourceInput {
|
|
12
|
+
readonly host: 'github';
|
|
13
|
+
readonly owner: string;
|
|
14
|
+
readonly repo: string;
|
|
15
|
+
/** Branch, tag, or commit as typed. */
|
|
16
|
+
readonly ref: string;
|
|
17
|
+
/** Directory inside the repository holding `flows-plugin.json`; `''` is the root. */
|
|
18
|
+
readonly path: string;
|
|
19
|
+
}
|
|
20
|
+
export interface PluginSourceRef extends PluginSourceInput {
|
|
21
|
+
/** Exactly 40 lowercase hex characters. */
|
|
22
|
+
readonly sha: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const OWNER = /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})$/;
|
|
26
|
+
const REPO = /^[A-Za-z0-9_.-]{1,100}$/;
|
|
27
|
+
const REF = /^[A-Za-z0-9][A-Za-z0-9._/-]{0,254}$/;
|
|
28
|
+
export const SHA = /^[0-9a-f]{40}$/;
|
|
29
|
+
|
|
30
|
+
function invalid(message: string): never {
|
|
31
|
+
throw new PluginError('plugin_source_invalid', message);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function isGithubPluginRef(value: string): boolean {
|
|
35
|
+
return value.startsWith('github:') || /^https:\/\/github\.com\//.test(value);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Accepts `github:<owner>/<repo>@<ref>#<path>`,
|
|
40
|
+
* `https://github.com/<owner>/<repo>/tree/<ref>/<path>` (or `/blob/`), and
|
|
41
|
+
* `github:<owner>/<repo>@<ref>` for a root-level plugin.
|
|
42
|
+
*
|
|
43
|
+
* In the URL form the ref is the single segment after `tree/`: GitHub itself
|
|
44
|
+
* disambiguates `tree/feat/x/dir` against the repository's refs, which an
|
|
45
|
+
* offline parser cannot. A ref containing `/` must use the `github:` form,
|
|
46
|
+
* where `@ref#path` is unambiguous.
|
|
47
|
+
*/
|
|
48
|
+
export function parsePluginSource(input: string): PluginSourceInput {
|
|
49
|
+
if (input.length > 2048) return invalid('Plugin source is too long.');
|
|
50
|
+
let owner: string | undefined, repo: string | undefined, ref: string | undefined, path = '';
|
|
51
|
+
if (input.startsWith('github:')) {
|
|
52
|
+
const m = /^github:([^/@#]+)\/([^/@#]+)@([^#]+)(?:#(.*))?$/.exec(input);
|
|
53
|
+
if (!m) return invalid('Expected github:<owner>/<repo>@<ref>[#<path>].');
|
|
54
|
+
[, owner, repo, ref] = m;
|
|
55
|
+
path = m[4] ?? '';
|
|
56
|
+
} else if (/^https:\/\/github\.com\//.test(input)) {
|
|
57
|
+
let url: URL;
|
|
58
|
+
try { url = new URL(input); } catch { return invalid('Plugin source is not a valid URL.'); }
|
|
59
|
+
if (url.username || url.password || url.search || url.hash) return invalid('Plugin URL must not carry credentials, a query, or a fragment.');
|
|
60
|
+
const m = /^\/([^/]+)\/([^/]+)\/(?:tree|blob)\/([^/]+)(?:\/(.*))?$/.exec(url.pathname);
|
|
61
|
+
if (!m) return invalid('Expected https://github.com/<owner>/<repo>/tree/<ref>/<path>.');
|
|
62
|
+
try {
|
|
63
|
+
[, owner, repo, ref] = m.map(part => part === undefined ? part : decodeURIComponent(part));
|
|
64
|
+
path = m[4] === undefined ? '' : decodeURIComponent(m[4]);
|
|
65
|
+
} catch {
|
|
66
|
+
return invalid('Plugin URL contains invalid percent-encoding.');
|
|
67
|
+
}
|
|
68
|
+
} else return invalid('Expected a github: reference or a https://github.com/ URL.');
|
|
69
|
+
if (owner === undefined || !OWNER.test(owner)) return invalid('Invalid GitHub owner.');
|
|
70
|
+
if (repo === undefined || !REPO.test(repo) || repo === '.' || repo === '..') return invalid('Invalid GitHub repository name.');
|
|
71
|
+
if (repo.endsWith('.git')) repo = repo.slice(0, -4);
|
|
72
|
+
if (ref === undefined || !REF.test(ref) || ref.includes('..') || ref.endsWith('/') || ref.endsWith('.lock')) return invalid('Invalid git ref.');
|
|
73
|
+
path = path.replace(/\/+$/, '');
|
|
74
|
+
if (path !== '' && !safePath(path)) return invalid('Plugin path must be repository-relative without traversal.');
|
|
75
|
+
if (path.split('/').some(part => part === 'flows-plugin.json')) return invalid('Plugin path names the directory holding flows-plugin.json, not the file.');
|
|
76
|
+
return Object.freeze({ host: 'github', owner, repo, ref, path });
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** The one spelling that is ever persisted. */
|
|
80
|
+
export function canonicalPluginRef(source: PluginSourceRef): string {
|
|
81
|
+
return `github:${source.owner}/${source.repo}@${source.sha}${source.path === '' ? '' : `#${source.path}`}`;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Parses a persisted reference; refuses anything but the canonical sha form. */
|
|
85
|
+
export function parseCanonicalPluginRef(value: string): PluginSourceRef {
|
|
86
|
+
const parsed = parsePluginSource(value);
|
|
87
|
+
if (!value.startsWith('github:') || !SHA.test(parsed.ref)) {
|
|
88
|
+
throw new PluginError('plugin_source_invalid', `Persisted plugin reference must be github:<owner>/<repo>@<sha>[#<path>], got ${value}.`);
|
|
89
|
+
}
|
|
90
|
+
return Object.freeze({ ...parsed, sha: parsed.ref });
|
|
91
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { lstat, mkdir, mkdtemp, readFile, readdir, rename, rm, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { dirname, join, resolve } from 'node:path';
|
|
3
|
+
import { payloadManifest, safePath, sha256 } from './bundle.js';
|
|
4
|
+
import { PluginError } from './plugin-manifest.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Where a flow-extension plugin's bytes live inside a project:
|
|
8
|
+
* `<project>/.flows/plugins/<name>@sha256:<digest>/`, content-addressed like
|
|
9
|
+
* the bundle cache and never `node_modules`. `manifest.json` in that directory
|
|
10
|
+
* is the payload manifest whose sha256 is the digest, so a directory can be
|
|
11
|
+
* re-verified without the network — and, because the lockfile records the
|
|
12
|
+
* same digest, drift between what was installed and what is on disk is a
|
|
13
|
+
* refusal, not a surprise.
|
|
14
|
+
*/
|
|
15
|
+
export const PLUGIN_STORE = '.flows/plugins';
|
|
16
|
+
|
|
17
|
+
export function pluginStoreDirectory(root: string, name: string, digest: string): string {
|
|
18
|
+
return join(resolve(root), PLUGIN_STORE, `${name}@sha256:${digest}`);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface StoredPluginFile { readonly path: string; readonly data: Uint8Array }
|
|
22
|
+
|
|
23
|
+
/** Write the files atomically; an existing directory is verified instead of overwritten. */
|
|
24
|
+
export async function materializePlugin(root: string, name: string, files: readonly StoredPluginFile[]): Promise<{ directory: string; digest: string }> {
|
|
25
|
+
const manifest = payloadManifest(files);
|
|
26
|
+
const digest = sha256(manifest);
|
|
27
|
+
const directory = pluginStoreDirectory(root, name, digest);
|
|
28
|
+
let exists = false;
|
|
29
|
+
try { await lstat(directory); exists = true; }
|
|
30
|
+
catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; }
|
|
31
|
+
if (exists) { await verifyStoredPlugin(directory, digest); return { directory, digest }; }
|
|
32
|
+
const parent = dirname(directory);
|
|
33
|
+
await mkdir(parent, { recursive: true });
|
|
34
|
+
const staging = await mkdtemp(join(parent, '.install-'));
|
|
35
|
+
try {
|
|
36
|
+
for (const file of files) {
|
|
37
|
+
if (!safePath(file.path) || file.path === 'manifest.json') throw new PluginError('plugin_path_invalid', `${file.path}: invalid plugin path.`);
|
|
38
|
+
await mkdir(dirname(join(staging, file.path)), { recursive: true });
|
|
39
|
+
await writeFile(join(staging, file.path), file.data, { mode: 0o644 });
|
|
40
|
+
}
|
|
41
|
+
await writeFile(join(staging, 'manifest.json'), manifest);
|
|
42
|
+
try { await rename(staging, directory); }
|
|
43
|
+
catch (error) {
|
|
44
|
+
if (!['EEXIST', 'ENOTEMPTY'].includes((error as NodeJS.ErrnoException).code ?? '')) throw error;
|
|
45
|
+
await verifyStoredPlugin(directory, digest);
|
|
46
|
+
}
|
|
47
|
+
} finally { await rm(staging, { recursive: true, force: true }); }
|
|
48
|
+
return { directory, digest };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async function regularFile(root: string, path: string): Promise<Buffer> {
|
|
52
|
+
const parts = path.split('/');
|
|
53
|
+
for (let i = 1; i <= parts.length; i++) {
|
|
54
|
+
const stat = await lstat(join(root, ...parts.slice(0, i)));
|
|
55
|
+
if (i === parts.length ? !stat.isFile() : !stat.isDirectory()) throw new PluginError('plugin_source_drift', `${path}: expected a regular file, without symlinks.`);
|
|
56
|
+
}
|
|
57
|
+
return readFile(join(root, path));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Re-hash a materialized plugin and compare with the digest the lockfile recorded. */
|
|
61
|
+
export async function verifyStoredPlugin(directory: string, expectedDigest: string): Promise<void> {
|
|
62
|
+
const drift = (message: string): never => { throw new PluginError('plugin_source_drift', `${directory}: ${message}`); };
|
|
63
|
+
let raw: string;
|
|
64
|
+
try {
|
|
65
|
+
if (!(await lstat(directory)).isDirectory()) return drift('not a directory');
|
|
66
|
+
raw = (await regularFile(directory, 'manifest.json')).toString('utf8');
|
|
67
|
+
} catch (error) { return drift(error instanceof PluginError ? error.message : 'manifest.json is missing'); }
|
|
68
|
+
if (sha256(raw) !== expectedDigest) return drift('manifest.json digest differs from the lockfile');
|
|
69
|
+
let entries: { path: string; sha256: string; bytes: number }[];
|
|
70
|
+
try { entries = JSON.parse(raw); if (!Array.isArray(entries)) throw new Error(); }
|
|
71
|
+
catch { return drift('manifest.json is not a manifest'); }
|
|
72
|
+
const paths = new Set<string>();
|
|
73
|
+
for (const entry of entries) {
|
|
74
|
+
if (typeof entry?.path !== 'string' || !safePath(entry.path)) return drift('manifest.json lists an invalid path');
|
|
75
|
+
let data: Buffer;
|
|
76
|
+
try { data = await regularFile(directory, entry.path); }
|
|
77
|
+
catch (error) { return drift(error instanceof PluginError ? error.message : `${entry.path} is missing`); }
|
|
78
|
+
if (data.length !== entry.bytes || sha256(data) !== entry.sha256) return drift(`${entry.path} changed since installation`);
|
|
79
|
+
paths.add(entry.path);
|
|
80
|
+
}
|
|
81
|
+
await rejectExtras(directory, '', new Set([...paths, 'manifest.json']), drift);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Re-verify, then return every stored file including the payload `manifest.json`. */
|
|
85
|
+
export async function readStoredPluginFiles(directory: string, expectedDigest: string): Promise<readonly { path: string; data: Buffer }[]> {
|
|
86
|
+
await verifyStoredPlugin(directory, expectedDigest);
|
|
87
|
+
const manifest = await regularFile(directory, 'manifest.json');
|
|
88
|
+
const entries = JSON.parse(manifest.toString('utf8')) as { path: string }[];
|
|
89
|
+
const files = [{ path: 'manifest.json', data: manifest }];
|
|
90
|
+
for (const entry of entries) files.push({ path: entry.path, data: await regularFile(directory, entry.path) });
|
|
91
|
+
return files;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Drop a materialized plugin directory. Missing is a no-op. */
|
|
95
|
+
export async function removeStoredPlugin(directory: string): Promise<void> {
|
|
96
|
+
await rm(directory, { recursive: true, force: true });
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async function rejectExtras(root: string, prefix: string, paths: Set<string>, drift: (m: string) => never): Promise<void> {
|
|
100
|
+
for (const entry of await readdir(join(root, prefix), { withFileTypes: true })) {
|
|
101
|
+
const path = prefix + entry.name;
|
|
102
|
+
if (entry.isDirectory() && [...paths].some(file => file.startsWith(`${path}/`))) await rejectExtras(root, `${path}/`, paths, drift);
|
|
103
|
+
else if (!entry.isFile() || !paths.has(path)) drift(`${path}: unlisted file or unsupported file type`);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The small semver subset plugin `compat` ranges may use. Deliberately not a
|
|
3
|
+
* dependency on `semver`: a plugin's compatibility claim has to be checkable by
|
|
4
|
+
* every reader — Cloud, the CLI, a catalog — from one short, obvious rule.
|
|
5
|
+
*
|
|
6
|
+
* Accepted: `*`, `x.y.z`, `^x.y.z`, `~x.y.z`, `>=x.y.z`, `>=x.y.z <a.b.c`.
|
|
7
|
+
* Prerelease tags are ordered lexically after the numeric triple and only
|
|
8
|
+
* compare against a range whose bounds name the same triple.
|
|
9
|
+
*/
|
|
10
|
+
const VERSION = /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?$/;
|
|
11
|
+
const RANGE = /^(\*|(?:[\^~]|>=)?\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?: <\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)?)$/;
|
|
12
|
+
|
|
13
|
+
interface Parsed { readonly triple: readonly [number, number, number]; readonly pre?: string }
|
|
14
|
+
|
|
15
|
+
export function parseVersion(value: string): Parsed | undefined {
|
|
16
|
+
const m = VERSION.exec(value);
|
|
17
|
+
if (!m) return undefined;
|
|
18
|
+
const triple = [Number(m[1]), Number(m[2]), Number(m[3])] as const;
|
|
19
|
+
if (triple.some(n => !Number.isSafeInteger(n))) return undefined;
|
|
20
|
+
return m[4] === undefined ? { triple } : { triple, pre: m[4] };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function isVersionRange(value: string): boolean {
|
|
24
|
+
return RANGE.test(value);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function compare(a: Parsed, b: Parsed): number {
|
|
28
|
+
for (let i = 0; i < 3; i++) if (a.triple[i] !== b.triple[i]) return a.triple[i]! - b.triple[i]!;
|
|
29
|
+
if (a.pre === b.pre) return 0;
|
|
30
|
+
if (a.pre === undefined) return 1;
|
|
31
|
+
if (b.pre === undefined) return -1;
|
|
32
|
+
return comparePrerelease(a.pre, b.pre);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function comparePrerelease(a: string, b: string): number {
|
|
36
|
+
const as = a.split('.');
|
|
37
|
+
const bs = b.split('.');
|
|
38
|
+
const n = Math.max(as.length, bs.length);
|
|
39
|
+
for (let i = 0; i < n; i++) {
|
|
40
|
+
const left = as[i];
|
|
41
|
+
const right = bs[i];
|
|
42
|
+
if (left === undefined) return -1;
|
|
43
|
+
if (right === undefined) return 1;
|
|
44
|
+
const leftNum = /^\d+$/.test(left) ? Number(left) : undefined;
|
|
45
|
+
const rightNum = /^\d+$/.test(right) ? Number(right) : undefined;
|
|
46
|
+
if (leftNum !== undefined && rightNum !== undefined) {
|
|
47
|
+
if (leftNum !== rightNum) return leftNum - rightNum;
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
if (leftNum !== undefined) return -1;
|
|
51
|
+
if (rightNum !== undefined) return 1;
|
|
52
|
+
if (left !== right) return left < right ? -1 : 1;
|
|
53
|
+
}
|
|
54
|
+
return 0;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Whether `version` satisfies `range`; false for malformed input rather than a throw. */
|
|
58
|
+
export function satisfiesRange(version: string, range: string): boolean {
|
|
59
|
+
const v = parseVersion(version);
|
|
60
|
+
if (v === undefined || !isVersionRange(range)) return false;
|
|
61
|
+
if (range === '*') return true;
|
|
62
|
+
const [lowerText, upperText] = range.split(' ') as [string, string | undefined];
|
|
63
|
+
const operator = /^[\^~]|^>=/.exec(lowerText)?.[0] ?? '';
|
|
64
|
+
const lower = parseVersion(lowerText.slice(operator.length))!;
|
|
65
|
+
if (compare(v, lower) < 0) return false;
|
|
66
|
+
if (operator === '') return compare(v, lower) === 0;
|
|
67
|
+
let upper: Parsed | undefined;
|
|
68
|
+
if (upperText !== undefined) upper = parseVersion(upperText.slice(1));
|
|
69
|
+
else if (operator === '^') {
|
|
70
|
+
const [major, minor] = lower.triple;
|
|
71
|
+
upper = major > 0 ? { triple: [major + 1, 0, 0] } : minor > 0 ? { triple: [0, minor + 1, 0] } : { triple: [0, 0, lower.triple[2] + 1] };
|
|
72
|
+
} else if (operator === '~') upper = { triple: [lower.triple[0], lower.triple[1] + 1, 0] };
|
|
73
|
+
return upper === undefined || compare(v, upper) < 0;
|
|
74
|
+
}
|