@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,286 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import type { CliIo } from '../cli.js';
|
|
4
|
+
import { diffExtension, extensionManifestOf } from './add-extension.js';
|
|
5
|
+
import { assertCompatible, runtimeVersions, type RuntimeVersions } from '../flow-extension-compat.js';
|
|
6
|
+
import { validateFlowExtensionManifest, type FlowExtensionManifest } from '../flow-extension-manifest.js';
|
|
7
|
+
import { fetchGithubPlugin, resolveGithubSha, type FetchLike } from '../plugin-github.js';
|
|
8
|
+
import {
|
|
9
|
+
PLUGIN_LOCK_FILE, lockForDeclared, lockWithPlugin, lockedPlugins, readPluginLock, reconcileDeclaredExtensions, recoverFlowsAndLock,
|
|
10
|
+
writeFlowsAndLock, type PluginLock, type PluginLockEntry,
|
|
11
|
+
} from '../plugin-lock.js';
|
|
12
|
+
import { findPluginProject } from '../plugin-loader.js';
|
|
13
|
+
import { PluginError } from '../plugin-manifest.js';
|
|
14
|
+
import { canonicalPluginRef, parsePluginSource } from '../plugin-source.js';
|
|
15
|
+
import { materializePlugin, pluginStoreDirectory, removeStoredPlugin, verifyStoredPlugin } from '../plugin-store.js';
|
|
16
|
+
|
|
17
|
+
export type PluginArgs =
|
|
18
|
+
| { command: 'plugin'; sub: 'list'; json: boolean }
|
|
19
|
+
| { command: 'plugin'; sub: 'verify'; json: boolean; offline: boolean }
|
|
20
|
+
| { command: 'plugin'; sub: 'remove'; json: boolean; name: string }
|
|
21
|
+
| { command: 'plugin'; sub: 'update'; json: boolean; yes: boolean; name: string | undefined; to: string | undefined };
|
|
22
|
+
|
|
23
|
+
/** `flows plugin list|verify|remove|update` */
|
|
24
|
+
export function parsePluginArgs(args: readonly string[]): PluginArgs | undefined {
|
|
25
|
+
const [sub, ...rest] = args;
|
|
26
|
+
if (sub === 'list' || sub === 'verify') {
|
|
27
|
+
let json = false;
|
|
28
|
+
let offline = false;
|
|
29
|
+
for (const arg of rest) {
|
|
30
|
+
if (arg === '--json' && !json) json = true;
|
|
31
|
+
else if (arg === '--offline' && !offline && sub === 'verify') offline = true;
|
|
32
|
+
else return undefined;
|
|
33
|
+
}
|
|
34
|
+
return sub === 'list' ? { command: 'plugin', sub, json } : { command: 'plugin', sub, json, offline };
|
|
35
|
+
}
|
|
36
|
+
if (sub === 'remove') {
|
|
37
|
+
let json = false;
|
|
38
|
+
let name: string | undefined;
|
|
39
|
+
for (const arg of rest) {
|
|
40
|
+
if (arg === '--json' && !json) json = true;
|
|
41
|
+
else if (arg.startsWith('-') || name !== undefined) return undefined;
|
|
42
|
+
else name = arg;
|
|
43
|
+
}
|
|
44
|
+
return name === undefined ? undefined : { command: 'plugin', sub: 'remove', json, name };
|
|
45
|
+
}
|
|
46
|
+
if (sub !== 'update') return undefined;
|
|
47
|
+
let json = false;
|
|
48
|
+
let yes = false;
|
|
49
|
+
let to: string | undefined;
|
|
50
|
+
let name: string | undefined;
|
|
51
|
+
for (let i = 0; i < rest.length; i++) {
|
|
52
|
+
const arg = rest[i]!;
|
|
53
|
+
if (arg === '--json' && !json) json = true;
|
|
54
|
+
else if (arg === '--yes' && !yes) yes = true;
|
|
55
|
+
else if (arg === '--to') {
|
|
56
|
+
const value = rest[i + 1];
|
|
57
|
+
if (to !== undefined || value === undefined || value.startsWith('-')) return undefined;
|
|
58
|
+
to = value;
|
|
59
|
+
i += 1;
|
|
60
|
+
} else if (arg.startsWith('-') || name !== undefined) return undefined;
|
|
61
|
+
else name = arg;
|
|
62
|
+
}
|
|
63
|
+
return { command: 'plugin', sub: 'update', json, yes, name, to };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Cross-check the three records that must agree: `flows.json.plugins`
|
|
68
|
+
* (declaration), `flows.lock.json` (provenance), and `.flows/plugins` (bytes).
|
|
69
|
+
* With the network, the pinned commit is re-fetched and re-hashed too.
|
|
70
|
+
*/
|
|
71
|
+
export async function verifyPlugins(root: string, options: { offline: boolean; fetch?: FetchLike }): Promise<{ entry: PluginLockEntry; ref: string; directory: string; remote: 'verified' | 'skipped' }[]> {
|
|
72
|
+
const results = [];
|
|
73
|
+
for (const { ref, entry, source } of reconcileDeclaredExtensions(root)) {
|
|
74
|
+
const directory = pluginStoreDirectory(root, entry.name, entry.digest);
|
|
75
|
+
await verifyStoredPlugin(directory, entry.digest);
|
|
76
|
+
let remote: 'verified' | 'skipped' = 'skipped';
|
|
77
|
+
if (!options.offline) {
|
|
78
|
+
const fetched = await fetchGithubPlugin(source, options.fetch);
|
|
79
|
+
if (fetched.digest !== entry.digest) throw new PluginError('plugin_source_drift', `${ref}: GitHub now serves digest ${fetched.digest}, lockfile has ${entry.digest}.`);
|
|
80
|
+
remote = 'verified';
|
|
81
|
+
}
|
|
82
|
+
results.push({ entry, ref, directory, remote });
|
|
83
|
+
}
|
|
84
|
+
return results;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export interface PluginCommandOptions {
|
|
88
|
+
cwd?: string;
|
|
89
|
+
fetch?: FetchLike;
|
|
90
|
+
now?: () => Date;
|
|
91
|
+
versions?: RuntimeVersions;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export async function runPluginCommand(parsed: PluginArgs, io: CliIo, options: PluginCommandOptions = {}): Promise<0 | 2> {
|
|
95
|
+
try {
|
|
96
|
+
const root = findPluginProject(options.cwd ?? process.cwd());
|
|
97
|
+
if (!root) throw new PluginError('plugin_manifest_invalid', 'flows plugin requires a project with flows.json.');
|
|
98
|
+
if (parsed.sub === 'list') {
|
|
99
|
+
const plugins = lockedPlugins(readPluginLock(root));
|
|
100
|
+
if (parsed.json) { io.stdout(JSON.stringify({ plugins: plugins.map(p => ({ ...p.entry, ref: p.ref })) })); return 0; }
|
|
101
|
+
if (plugins.length === 0) { io.stdout('No flow-extension plugins installed.'); return 0; }
|
|
102
|
+
for (const { entry, ref } of plugins) io.stdout(`${entry.order}. ${entry.name}@${entry.version} ${ref} sha256:${entry.digest}`);
|
|
103
|
+
return 0;
|
|
104
|
+
}
|
|
105
|
+
if (parsed.sub === 'verify') {
|
|
106
|
+
const results = await verifyPlugins(root, { offline: parsed.offline, ...(options.fetch === undefined ? {} : { fetch: options.fetch }) });
|
|
107
|
+
if (parsed.json) { io.stdout(JSON.stringify({ ok: true, plugins: results.map(r => ({ name: r.entry.name, ref: r.ref, digest: r.entry.digest, remote: r.remote })) })); return 0; }
|
|
108
|
+
for (const r of results) io.stdout(`OK ${r.entry.name}@${r.entry.version} ${r.ref} local digest matches lockfile; remote ${r.remote}`);
|
|
109
|
+
if (results.length === 0) io.stdout('No flow-extension plugins to verify.');
|
|
110
|
+
return 0;
|
|
111
|
+
}
|
|
112
|
+
if (parsed.sub === 'remove') return await removePlugin(root, parsed, io);
|
|
113
|
+
return await updatePlugins(root, parsed, io, options);
|
|
114
|
+
} catch (error) {
|
|
115
|
+
const refusal = error instanceof PluginError ? error : new PluginError('plugin_manifest_invalid', (error as Error).message);
|
|
116
|
+
if (parsed.json) io.stdout(JSON.stringify({ ok: false, code: refusal.code, message: refusal.message }));
|
|
117
|
+
io.stderr(`REFUSED [${refusal.code}] ${refusal.message}`);
|
|
118
|
+
return 2;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function readFlowsConfig(root: string): { path: string; config: Record<string, unknown>; plugins: string[] } {
|
|
123
|
+
recoverFlowsAndLock(root);
|
|
124
|
+
const path = join(root, 'flows.json');
|
|
125
|
+
let parsed: unknown;
|
|
126
|
+
try { parsed = JSON.parse(readFileSync(path, 'utf8')); }
|
|
127
|
+
catch { throw new PluginError('plugin_manifest_invalid', 'Invalid flows.json.'); }
|
|
128
|
+
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)
|
|
129
|
+
|| ((parsed as { plugins?: unknown }).plugins !== undefined
|
|
130
|
+
&& (!Array.isArray((parsed as { plugins?: unknown }).plugins)
|
|
131
|
+
|| !(parsed as { plugins: unknown[] }).plugins.every(p => typeof p === 'string')))) {
|
|
132
|
+
throw new PluginError('plugin_manifest_invalid', 'Invalid flows.json plugins list.');
|
|
133
|
+
}
|
|
134
|
+
const config = parsed as Record<string, unknown>;
|
|
135
|
+
return { path, config, plugins: [...((config.plugins as string[] | undefined) ?? [])] };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function storedManifest(directory: string): FlowExtensionManifest {
|
|
139
|
+
let raw: string;
|
|
140
|
+
try { raw = readFileSync(join(directory, 'flows-plugin.json'), 'utf8'); }
|
|
141
|
+
catch { throw new PluginError('plugin_source_drift', `${directory}: flows-plugin.json is missing.`); }
|
|
142
|
+
let input: unknown;
|
|
143
|
+
try { input = JSON.parse(raw); }
|
|
144
|
+
catch { throw new PluginError('plugin_manifest_invalid', `${directory}: flows-plugin.json is unreadable or invalid JSON.`); }
|
|
145
|
+
return validateFlowExtensionManifest(input);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function storeReferenced(lock: PluginLock, name: string, digest: string): boolean {
|
|
149
|
+
return lock.plugins.some(p => p.name === name && p.digest === digest);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
async function dropUnreferencedStore(root: string, name: string, digest: string, lock: PluginLock): Promise<void> {
|
|
153
|
+
if (storeReferenced(lock, name, digest)) return;
|
|
154
|
+
await removeStoredPlugin(pluginStoreDirectory(root, name, digest));
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
async function removePlugin(root: string, parsed: Extract<PluginArgs, { sub: 'remove' }>, io: CliIo): Promise<0 | 2> {
|
|
158
|
+
const locked = lockedPlugins(readPluginLock(root));
|
|
159
|
+
const match = locked.find(p => p.entry.name === parsed.name);
|
|
160
|
+
if (match === undefined) throw new PluginError('plugin_manifest_invalid', `No flow-extension plugin named ${parsed.name}.`);
|
|
161
|
+
const { path, config, plugins } = readFlowsConfig(root);
|
|
162
|
+
const nextDeclared = plugins.filter(ref => ref !== match.ref);
|
|
163
|
+
const nextLock = lockForDeclared(readPluginLock(root), nextDeclared);
|
|
164
|
+
writeFlowsAndLock(root, path, config, nextDeclared, nextLock);
|
|
165
|
+
await dropUnreferencedStore(root, match.entry.name, match.entry.digest, nextLock);
|
|
166
|
+
if (parsed.json) {
|
|
167
|
+
io.stdout(JSON.stringify({ ok: true, removed: { name: match.entry.name, ref: match.ref, digest: match.entry.digest } }));
|
|
168
|
+
return 0;
|
|
169
|
+
}
|
|
170
|
+
io.stdout(`Removed ${match.entry.name}@${match.entry.version} ${match.ref}`);
|
|
171
|
+
return 0;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function replaceDeclaredRef(plugins: readonly string[], oldRef: string, newRef: string): string[] {
|
|
175
|
+
const without = plugins.filter(ref => ref !== oldRef);
|
|
176
|
+
if (without.includes(newRef)) return without;
|
|
177
|
+
const at = plugins.indexOf(oldRef);
|
|
178
|
+
const next = [...without];
|
|
179
|
+
next.splice(at === -1 ? next.length : at, 0, newRef);
|
|
180
|
+
return next;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
async function updatePlugins(
|
|
184
|
+
root: string,
|
|
185
|
+
parsed: Extract<PluginArgs, { sub: 'update' }>,
|
|
186
|
+
io: CliIo,
|
|
187
|
+
options: PluginCommandOptions,
|
|
188
|
+
): Promise<0 | 2> {
|
|
189
|
+
if (parsed.to !== undefined && parsed.name === undefined) {
|
|
190
|
+
throw new PluginError('plugin_manifest_invalid', 'flows plugin update --to requires a plugin name.');
|
|
191
|
+
}
|
|
192
|
+
const locked = lockedPlugins(readPluginLock(root));
|
|
193
|
+
const targets = parsed.name === undefined ? locked : locked.filter(p => p.entry.name === parsed.name);
|
|
194
|
+
if (parsed.name !== undefined && targets.length === 0) {
|
|
195
|
+
throw new PluginError('plugin_manifest_invalid', `No flow-extension plugin named ${parsed.name}.`);
|
|
196
|
+
}
|
|
197
|
+
if (targets.length === 0) {
|
|
198
|
+
if (parsed.json) { io.stdout(JSON.stringify({ ok: true, plugins: [] })); return 0; }
|
|
199
|
+
io.stdout('No flow-extension plugins to update.');
|
|
200
|
+
return 0;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
type Plan = {
|
|
204
|
+
current: (typeof locked)[number];
|
|
205
|
+
ref: string;
|
|
206
|
+
digest: string;
|
|
207
|
+
manifest: FlowExtensionManifest;
|
|
208
|
+
manifestSha256: string;
|
|
209
|
+
source: { host: 'github'; owner: string; repo: string; sha: string; path: string };
|
|
210
|
+
files: readonly { path: string; data: Uint8Array }[];
|
|
211
|
+
diff: string[];
|
|
212
|
+
changed: boolean;
|
|
213
|
+
};
|
|
214
|
+
const plans: Plan[] = [];
|
|
215
|
+
for (const current of targets) {
|
|
216
|
+
const requested = parsed.to === undefined ? { ...current.source } : parsePluginSource(parsed.to);
|
|
217
|
+
const source = await resolveGithubSha(requested, options.fetch);
|
|
218
|
+
const fetched = await fetchGithubPlugin(source, options.fetch);
|
|
219
|
+
const { manifest, manifestSha256 } = extensionManifestOf(fetched);
|
|
220
|
+
if (manifest.name !== current.entry.name) {
|
|
221
|
+
throw new PluginError('plugin_manifest_invalid', `Update of ${current.entry.name} resolved to plugin ${manifest.name}; remove it and add the new name instead.`);
|
|
222
|
+
}
|
|
223
|
+
assertCompatible(manifest, options.versions ?? runtimeVersions());
|
|
224
|
+
const directory = pluginStoreDirectory(root, current.entry.name, current.entry.digest);
|
|
225
|
+
const before = storedManifest(directory);
|
|
226
|
+
const ref = canonicalPluginRef(source);
|
|
227
|
+
const changed = ref !== current.ref || fetched.digest !== current.entry.digest || manifestSha256 !== current.entry.manifestSha256;
|
|
228
|
+
plans.push({
|
|
229
|
+
current, ref, digest: fetched.digest, manifest, manifestSha256,
|
|
230
|
+
source: { host: 'github', owner: source.owner, repo: source.repo, sha: source.sha, path: source.path },
|
|
231
|
+
files: fetched.files, diff: diffExtension(before, manifest), changed,
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const pending = plans.filter(p => p.changed);
|
|
236
|
+
const summary = plans.map(p => ({
|
|
237
|
+
name: p.current.entry.name, from: p.current.ref, to: p.ref, digest: p.digest, changed: p.changed, diff: p.diff,
|
|
238
|
+
}));
|
|
239
|
+
if (!parsed.json) {
|
|
240
|
+
for (const plan of plans) {
|
|
241
|
+
if (!plan.changed) {
|
|
242
|
+
io.stdout(`${plan.current.entry.name} is already at ${plan.ref} sha256:${plan.digest}`);
|
|
243
|
+
continue;
|
|
244
|
+
}
|
|
245
|
+
io.stdout(`Update ${plan.current.entry.name} ${plan.current.ref} → ${plan.ref}`);
|
|
246
|
+
io.stdout(` digest sha256:${plan.current.entry.digest} → sha256:${plan.digest}`);
|
|
247
|
+
for (const line of plan.diff) io.stdout(line);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
if (pending.length === 0) {
|
|
251
|
+
if (parsed.json) io.stdout(JSON.stringify({ ok: true, plugins: summary }));
|
|
252
|
+
return 0;
|
|
253
|
+
}
|
|
254
|
+
if (!parsed.yes) {
|
|
255
|
+
if (parsed.json) {
|
|
256
|
+
io.stdout(JSON.stringify({
|
|
257
|
+
ok: false, applied: false, code: 'plugin_manifest_invalid',
|
|
258
|
+
message: 'Re-run with --yes to apply this update.', plugins: summary,
|
|
259
|
+
}));
|
|
260
|
+
io.stderr('REFUSED [plugin_manifest_invalid] Re-run with --yes to apply this update.');
|
|
261
|
+
return 2;
|
|
262
|
+
}
|
|
263
|
+
throw new PluginError('plugin_manifest_invalid', 'Re-run with --yes to apply this update.');
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
let declared = readFlowsConfig(root).plugins;
|
|
267
|
+
let lock = readPluginLock(root);
|
|
268
|
+
const applied: { name: string; ref: string; digest: string }[] = [];
|
|
269
|
+
for (const plan of pending) {
|
|
270
|
+
const { directory, digest } = await materializePlugin(root, plan.manifest.name, plan.files);
|
|
271
|
+
if (digest !== plan.digest) throw new PluginError('plugin_source_drift', 'Materialized digest differs from the fetched digest.');
|
|
272
|
+
declared = replaceDeclaredRef(declared, plan.current.ref, plan.ref);
|
|
273
|
+
lock = lockWithPlugin(lock, declared, {
|
|
274
|
+
name: plan.manifest.name, version: plan.manifest.version, source: plan.source,
|
|
275
|
+
digest, manifestSha256: plan.manifestSha256,
|
|
276
|
+
resolvedAt: (options.now ?? (() => new Date()))().toISOString(),
|
|
277
|
+
});
|
|
278
|
+
const { path, config } = readFlowsConfig(root);
|
|
279
|
+
writeFlowsAndLock(root, path, config, declared, lock);
|
|
280
|
+
await dropUnreferencedStore(root, plan.current.entry.name, plan.current.entry.digest, lock);
|
|
281
|
+
applied.push({ name: plan.manifest.name, ref: plan.ref, digest });
|
|
282
|
+
}
|
|
283
|
+
if (parsed.json) io.stdout(JSON.stringify({ ok: true, plugins: applied }));
|
|
284
|
+
else for (const item of applied) io.stdout(`Updated ${item.name} ${item.ref} sha256:${item.digest} recorded in flows.json and ${PLUGIN_LOCK_FILE}`);
|
|
285
|
+
return 0;
|
|
286
|
+
}
|
package/src/cli-commands.ts
CHANGED
|
@@ -104,8 +104,8 @@ const LOCAL_EXECUTION_OPTIONS = [
|
|
|
104
104
|
export const CLI_VERBS = [
|
|
105
105
|
{
|
|
106
106
|
name: 'add',
|
|
107
|
-
description: 'Install a helper plugin into this project',
|
|
108
|
-
args: [{ name: '
|
|
107
|
+
description: 'Install a helper plugin, or a flow-extension plugin from a public GitHub repository, into this project',
|
|
108
|
+
args: [{ name: 'plugin', description: 'Helper name, @flows/<helper-name>, github:<owner>/<repo>@<ref>#<path>, or a github.com tree URL', required: true }],
|
|
109
109
|
variants: ['add'],
|
|
110
110
|
},
|
|
111
111
|
{
|
|
@@ -155,6 +155,7 @@ export const CLI_VERBS = [
|
|
|
155
155
|
{ flags: '--agents <list>', description: 'Agent harnesses to allow, as claude[,codex]' },
|
|
156
156
|
{ flags: '--name <name>', description: 'Name for the hosted listener' },
|
|
157
157
|
{ flags: '--draft', description: 'Create the listener without activating it' },
|
|
158
|
+
{ flags: '--plugin <ref>', description: 'Send-only GitHub flow-extension ref; repeatable. Does not write flows.json' },
|
|
158
159
|
NO_CONNECT_OPTION,
|
|
159
160
|
JSON_OPTION,
|
|
160
161
|
],
|
|
@@ -199,6 +200,35 @@ export const CLI_VERBS = [
|
|
|
199
200
|
options: [DATA_DIR_OPTION],
|
|
200
201
|
variants: ['observer'],
|
|
201
202
|
},
|
|
203
|
+
{
|
|
204
|
+
name: 'plugin',
|
|
205
|
+
description: 'Inspect, remove, or update the flow-extension plugins recorded in flows.lock.json',
|
|
206
|
+
subcommands: [
|
|
207
|
+
{ name: 'list', description: 'List installed flow extensions in composition order', options: [JSON_OPTION] },
|
|
208
|
+
{
|
|
209
|
+
name: 'verify',
|
|
210
|
+
description: 'Re-hash .flows/plugins against the lockfile and, unless --offline, against the pinned commit on GitHub',
|
|
211
|
+
options: [JSON_OPTION, { flags: '--offline', description: 'Skip the GitHub re-fetch; check only the local store against the lockfile' }],
|
|
212
|
+
},
|
|
213
|
+
{
|
|
214
|
+
name: 'remove',
|
|
215
|
+
description: 'Drop a flow-extension plugin from flows.json, the lockfile, and the local store',
|
|
216
|
+
args: [{ name: 'name', description: 'Plugin name as recorded in the lockfile', required: true }],
|
|
217
|
+
options: [JSON_OPTION],
|
|
218
|
+
},
|
|
219
|
+
{
|
|
220
|
+
name: 'update',
|
|
221
|
+
description: 'Re-resolve a flow-extension plugin, show the permissions/events/budget diff, and rewrite the lock with --yes',
|
|
222
|
+
args: [{ name: 'name', description: 'Plugin name; omit to update every installed flow extension', required: false }],
|
|
223
|
+
options: [
|
|
224
|
+
JSON_OPTION,
|
|
225
|
+
{ flags: '--to <ref>', description: 'GitHub reference to resolve instead of the locked commit' },
|
|
226
|
+
{ flags: '--yes', description: 'Apply the update; without this flag the diff is printed and the lock is left unchanged' },
|
|
227
|
+
],
|
|
228
|
+
},
|
|
229
|
+
],
|
|
230
|
+
variants: ['plugin'],
|
|
231
|
+
},
|
|
202
232
|
{
|
|
203
233
|
name: 'replay',
|
|
204
234
|
description: 'Replay a finished run from its local journal',
|
package/src/cli.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { addPlugin } from './cli/add.js';
|
|
3
|
+
import { parsePluginArgs, runPluginCommand, type PluginArgs } from './cli/plugin.js';
|
|
3
4
|
import { watchCheck } from './cli-watch.js';
|
|
4
5
|
import { checkHelperBody } from './cli/check-helper-body.js';
|
|
5
6
|
import { describeFlowRequirements } from './flow-requirements.js';
|
|
@@ -64,6 +65,7 @@ type CliExitCode = 0 | 1 | 2 | 3;
|
|
|
64
65
|
*/
|
|
65
66
|
export type ParsedArgs =
|
|
66
67
|
| { command: 'add'; value: string }
|
|
68
|
+
| PluginArgs
|
|
67
69
|
| ReplayArgs
|
|
68
70
|
| StatusArgs
|
|
69
71
|
| BuildArgs
|
|
@@ -92,9 +94,14 @@ export type ParsedArgs =
|
|
|
92
94
|
const USAGE = [
|
|
93
95
|
'Usage:',
|
|
94
96
|
'flows add <helper-name|@flows/helper-name>',
|
|
97
|
+
'flows add <github:owner/repo@ref#path|https://github.com/owner/repo/tree/ref/path>',
|
|
98
|
+
'flows plugin list [--json]',
|
|
99
|
+
'flows plugin verify [--json] [--offline]',
|
|
100
|
+
'flows plugin remove [--json] <name>',
|
|
101
|
+
'flows plugin update [--json] [--yes] [--to <ref>] [<name>]',
|
|
95
102
|
'flows build [--out <dir>] <flow.yaml|flow.ts>',
|
|
96
103
|
'flows build --verify <bundle-dir>',
|
|
97
|
-
'flows deploy <flow.ts> --repo <owner/name> --on <provider>[:key=value,...] [--on ...] --approver <handle> [--agents claude[,codex]] [--name <name>] [--draft] [--no-connect] [--json]',
|
|
104
|
+
'flows deploy <flow.ts> --repo <owner/name> --on <provider>[:key=value,...] [--on ...] --approver <handle> [--agents claude[,codex]] [--name <name>] [--draft] [--plugin <ref>] [--no-connect] [--json]',
|
|
98
105
|
'flows deployments [--json]',
|
|
99
106
|
'flows undeploy [--json] <deployment-id>',
|
|
100
107
|
'flows schedule <flow.yaml|flow.ts> [--cron "<expr>" | --every <n><s|m|h|d>] [--tz <IANA>] [--input <inline-json-or-file>] [--name <name>] [--no-connect] [--json]',
|
|
@@ -192,6 +199,7 @@ export async function runCli(
|
|
|
192
199
|
}
|
|
193
200
|
|
|
194
201
|
if (parsed.command === 'add') return addPlugin(parsed.value, io);
|
|
202
|
+
if (parsed.command === 'plugin') return runPluginCommand(parsed, io);
|
|
195
203
|
|
|
196
204
|
if (parsed.command === 'serve-webhook') {
|
|
197
205
|
return withInterrupt(options.signal, (signal) => runServeWebhook(parsed, io, signal));
|
|
@@ -347,6 +355,8 @@ async function checkAuthoredFlowComposed(path: string): Promise<{ report: CheckR
|
|
|
347
355
|
report: {
|
|
348
356
|
...mcp.report,
|
|
349
357
|
...(triggers?.report.schedules === undefined ? {} : { schedules: triggers.report.schedules }),
|
|
358
|
+
...(triggers?.report.extensions === undefined ? {} : { extensions: triggers.report.extensions }),
|
|
359
|
+
...(triggers?.report.hooks === undefined ? {} : { hooks: triggers.report.hooks }),
|
|
350
360
|
// The authored definition sees helper flags, body use and `cli:`
|
|
351
361
|
// declarations; the compiled view underneath knows only its steps.
|
|
352
362
|
...(triggers?.report.requirements === undefined ? {} : { requirements: triggers.report.requirements }),
|
|
@@ -521,6 +531,7 @@ function parseArgs(args: readonly string[]): ParsedArgs | undefined {
|
|
|
521
531
|
// parser, so the declared tree and the dispatched tree cannot drift apart.
|
|
522
532
|
if (command === undefined || !CLI_VERB_NAMES.has(command)) return undefined;
|
|
523
533
|
if (command === 'add') return args.length === 2 ? { command: 'add', value: args[1]! } : undefined;
|
|
534
|
+
if (command === 'plugin') return parsePluginArgs(args.slice(1));
|
|
524
535
|
if (command === 'replay') return parseReplayArgs(args.slice(1));
|
|
525
536
|
if (command === 'status') return parseStatusArgs(args.slice(1));
|
|
526
537
|
if (command === 'runs') return parseRunsArgs(args.slice(1));
|
|
@@ -937,6 +948,17 @@ function emitCheckReport(report: CheckReport, json: boolean, io: CliIo): void {
|
|
|
937
948
|
: `local: flows tick start --schedule-id ${schedule.scheduleId} --interval-ms ${schedule.intervalMs} --epoch-ms ${schedule.epochMs}`;
|
|
938
949
|
io.stdout(`SCHEDULE handler ${schedule.handler} ${declared} -> flows.tick schedule_id ${schedule.scheduleId} [${local}]`);
|
|
939
950
|
}
|
|
951
|
+
for (const extension of report.extensions ?? []) {
|
|
952
|
+
const hookList = (extension.hooks ?? []).length === 0 ? '' : `, hooks: ${extension.hooks!.join(', ')}`;
|
|
953
|
+
io.stdout(`EXTENSION ${extension.name}@${extension.version} ${extension.ref} sha256:${extension.digest} -> ${extension.handlers} handler(s) composed after the base flow${hookList}`);
|
|
954
|
+
}
|
|
955
|
+
if (report.hooks !== undefined) {
|
|
956
|
+
io.stdout(`HOOKS declared: ${report.hooks.declared.join(', ') || '(none)'}`);
|
|
957
|
+
for (const row of report.hooks.implementations) io.stdout(`HOOK ${row.hook} <- ${row.plugin}`);
|
|
958
|
+
for (const name of report.hooks.declared) {
|
|
959
|
+
if (!report.hooks.implementations.some(row => row.hook === name)) io.stdout(`HOOK ${name} <- (none)`);
|
|
960
|
+
}
|
|
961
|
+
}
|
|
940
962
|
for (const resolution of report.resolutions) {
|
|
941
963
|
const config = resolution.source === 'project' && report.projectConfigPath !== undefined
|
|
942
964
|
? ` (${report.projectConfigPath})`
|
package/src/cloud-deploy.ts
CHANGED
|
@@ -6,8 +6,11 @@ import { ensureIntegrationsConnected, type ConnectPrompt } from './cloud-connect
|
|
|
6
6
|
import {
|
|
7
7
|
CloudFlowError, cloudFetch, cloudRequest, isCloudRecord, type CloudConnectionOptions,
|
|
8
8
|
} from './cloud-http.js';
|
|
9
|
-
import {
|
|
9
|
+
import {
|
|
10
|
+
flowRequirements, mergeFlowExtensionRequirements, type FlowRequirements,
|
|
11
|
+
} from './flow-requirements.js';
|
|
10
12
|
import { readProjectConfig } from './cli/check.js';
|
|
13
|
+
import { assertNoUseDependencies, collectExtensionSubmissions } from './flow-extension-submit.js';
|
|
11
14
|
|
|
12
15
|
/**
|
|
13
16
|
* Hosted listener deployment: the CLI form of the agentrelay.com onboarding's
|
|
@@ -67,6 +70,11 @@ export interface DeployToCloudInput {
|
|
|
67
70
|
connect?: ConnectPrompt;
|
|
68
71
|
/** Skip the pre-submission integration check entirely (Cloud still checks on activation). */
|
|
69
72
|
checkConnections?: boolean;
|
|
73
|
+
/**
|
|
74
|
+
* Extra GitHub plugin refs resolved send-only (same path as `flows add`,
|
|
75
|
+
* without writing flows.json). Project-declared extensions are always sent.
|
|
76
|
+
*/
|
|
77
|
+
plugins?: readonly string[];
|
|
70
78
|
}
|
|
71
79
|
|
|
72
80
|
export const FLOW_AGENT_HARNESSES = ['claude', 'codex'] as const;
|
|
@@ -170,10 +178,8 @@ export async function deployToCloud(
|
|
|
170
178
|
throw new CloudFlowError('unsupported_source',
|
|
171
179
|
`${input.path} is not a loadable authored flow: ${error instanceof Error ? error.message : String(error)}`);
|
|
172
180
|
}
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
'Cloud deploys one self-contained .flow.ts source without use dependencies.');
|
|
176
|
-
}
|
|
181
|
+
assertNoUseDependencies(loaded);
|
|
182
|
+
const extensions = await collectExtensionSubmissions(loaded, input.plugins ?? []);
|
|
177
183
|
let projectCli: string | undefined;
|
|
178
184
|
try {
|
|
179
185
|
projectCli = readProjectConfig(dirname(resolve(input.path))).cli;
|
|
@@ -204,9 +210,12 @@ export async function deployToCloud(
|
|
|
204
210
|
}
|
|
205
211
|
// Every launched run lands in the deployment's repository, so GitHub is
|
|
206
212
|
// required even when no GitHub source wakes it.
|
|
207
|
-
const requirements =
|
|
208
|
-
|
|
209
|
-
|
|
213
|
+
const requirements = mergeFlowExtensionRequirements(
|
|
214
|
+
flowRequirements(definition, {
|
|
215
|
+
sources, repository: input.repository, ...(projectCli === undefined ? {} : { projectCli }),
|
|
216
|
+
}),
|
|
217
|
+
extensions.map(extension => ({ name: extension.name, permissions: extension.manifest.permissions })),
|
|
218
|
+
);
|
|
210
219
|
// The declared harnesses become `inputs.agents`; one Cloud cannot run is
|
|
211
220
|
// refused here rather than silently replaced by Claude, which activation
|
|
212
221
|
// would then check while the deployed runs still call the declared CLI.
|
|
@@ -237,6 +246,7 @@ export async function deployToCloud(
|
|
|
237
246
|
inputs: { approver, agents },
|
|
238
247
|
repository: input.repository,
|
|
239
248
|
sources,
|
|
249
|
+
...(extensions.length === 0 ? {} : { extensions }),
|
|
240
250
|
requirements: {
|
|
241
251
|
integrations: requirements.integrations.map(i => i.provider),
|
|
242
252
|
harnesses: requirements.harnesses,
|
package/src/cloud-run.ts
CHANGED
|
@@ -9,6 +9,7 @@ import { CompileError, compileSpec, kernelToAuthoring, toKernelSpec } from './co
|
|
|
9
9
|
import type { FlowSpec } from './spec.js';
|
|
10
10
|
import { snapshotJsonValue, type JsonValue } from './json-value.js';
|
|
11
11
|
import { loadAuthoredFlow, type SurfaceModuleAuthority } from './authored-flow-loader.js';
|
|
12
|
+
import { assertNoUseDependencies, collectExtensionSubmissions, type FlowExtensionSubmission } from './flow-extension-submit.js';
|
|
12
13
|
import {
|
|
13
14
|
CloudFlowError, cloudConnection, cloudFetch, cloudRequest, cloudRunId, isCloudRecord,
|
|
14
15
|
type CloudConnectionOptions,
|
|
@@ -61,6 +62,7 @@ export function cloudSubmissionBody(submission: CloudSubmission): Record<string,
|
|
|
61
62
|
relayflowVersion: 'v2',
|
|
62
63
|
...(submission.authoredAuthority === undefined ? {} : { authoredAuthority: submission.authoredAuthority }),
|
|
63
64
|
...(submission.inputPresent ? { inputs: submission.inputs } : {}),
|
|
65
|
+
...(submission.extensions === undefined || submission.extensions.length === 0 ? {} : { extensions: submission.extensions }),
|
|
64
66
|
};
|
|
65
67
|
}
|
|
66
68
|
|
|
@@ -90,6 +92,7 @@ export interface CloudSubmission {
|
|
|
90
92
|
readonly name: string;
|
|
91
93
|
/** Authored `schedule.*` handlers, for `flows schedule` to pick up. */
|
|
92
94
|
readonly schedules: readonly ScheduleTriggerSource[];
|
|
95
|
+
readonly extensions?: readonly FlowExtensionSubmission[];
|
|
93
96
|
}
|
|
94
97
|
|
|
95
98
|
export async function prepareCloudSubmission(
|
|
@@ -97,7 +100,7 @@ export async function prepareCloudSubmission(
|
|
|
97
100
|
options: { input?: JsonValue; signal?: AbortSignal } = {},
|
|
98
101
|
): Promise<CloudSubmission> {
|
|
99
102
|
let spec: FlowSpec | undefined;
|
|
100
|
-
let authored: { source: string; authority: CloudAuthoredAuthority; name: string; schedules: ScheduleTriggerSource[] } | undefined;
|
|
103
|
+
let authored: { source: string; authority: CloudAuthoredAuthority; name: string; schedules: ScheduleTriggerSource[]; extensions: readonly FlowExtensionSubmission[] } | undefined;
|
|
101
104
|
const inputPresent = Object.prototype.hasOwnProperty.call(options, 'input');
|
|
102
105
|
let authoredInput: JsonValue | undefined;
|
|
103
106
|
try {
|
|
@@ -122,14 +125,13 @@ export async function prepareCloudSubmission(
|
|
|
122
125
|
`${flow.path} is not a loadable authored flow: ${error instanceof Error ? error.message : String(error)}. `
|
|
123
126
|
+ 'Run `flows check` on it from the same directory.');
|
|
124
127
|
}
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
'Cloud authored submission currently accepts one self-contained .flow.ts source without use dependencies.');
|
|
128
|
-
}
|
|
128
|
+
assertNoUseDependencies(loaded);
|
|
129
|
+
const extensions = await collectExtensionSubmissions(loaded);
|
|
129
130
|
authored = {
|
|
130
131
|
source,
|
|
131
132
|
name: definition.name,
|
|
132
133
|
schedules: definition.handlers.flatMap(h => h.trigger.kind === 'schedule' ? [h.trigger] : []),
|
|
134
|
+
extensions,
|
|
133
135
|
authority: Object.freeze({
|
|
134
136
|
schemaVersion: 1,
|
|
135
137
|
sourceSha256: createHash('sha256').update(bytes).digest('hex'),
|
|
@@ -182,7 +184,15 @@ export async function prepareCloudSubmission(
|
|
|
182
184
|
return {
|
|
183
185
|
workflow: authored.source, fileType: 'ts', authoredAuthority: authored.authority,
|
|
184
186
|
inputs: authoredInput, inputPresent: true, name: authored.name, schedules: authored.schedules,
|
|
185
|
-
|
|
187
|
+
...(authored.extensions.length === 0 ? {} : { extensions: authored.extensions }),
|
|
188
|
+
specHash: createHash('sha256').update(canonicalize({
|
|
189
|
+
authority: authored.authority, input: authoredInput,
|
|
190
|
+
...(authored.extensions.length === 0 ? {} : {
|
|
191
|
+
extensions: authored.extensions.map(extension => ({
|
|
192
|
+
name: extension.name, digest: extension.digest, ref: extension.ref,
|
|
193
|
+
})),
|
|
194
|
+
}),
|
|
195
|
+
})).digest('hex'),
|
|
186
196
|
};
|
|
187
197
|
}
|
|
188
198
|
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import type { FlowExtensionManifest } from './flow-extension-manifest.js';
|
|
3
|
+
import { PluginError } from './plugin-manifest.js';
|
|
4
|
+
import { satisfiesRange } from './semver-range.js';
|
|
5
|
+
|
|
6
|
+
export interface RuntimeVersions { readonly sdk: string; readonly surface: string }
|
|
7
|
+
|
|
8
|
+
/** The versions a plugin's `compat` is checked against: this SDK and the surface it pins. */
|
|
9
|
+
export function runtimeVersions(): RuntimeVersions {
|
|
10
|
+
const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')) as { version: string; dependencies: Record<string, string> };
|
|
11
|
+
return { sdk: pkg.version, surface: pkg.dependencies['@relayflows/surface']! };
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** `compat.surface` / `compat.sdk` against the runtime: a miss is a refusal, never a warning. */
|
|
15
|
+
export function assertCompatible(manifest: FlowExtensionManifest, versions: RuntimeVersions): void {
|
|
16
|
+
for (const [what, range, actual] of [['surface', manifest.compat.surface, versions.surface], ['sdk', manifest.compat.sdk, versions.sdk]] as const) {
|
|
17
|
+
if (!satisfiesRange(actual, range)) throw new PluginError('plugin_incompatible', `${manifest.name} requires ${what} ${range}; this runtime has ${actual}.`);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* `compat.base` against the flow being extended. `FlowHeader.version` is
|
|
23
|
+
* optional: a base without it matches only `"*"`.
|
|
24
|
+
*/
|
|
25
|
+
export function assertBaseCompatible(manifest: FlowExtensionManifest, base: { readonly name: string; readonly version?: string }): void {
|
|
26
|
+
const entry = manifest.compat.base.find(b => b.name === base.name);
|
|
27
|
+
if (entry === undefined) {
|
|
28
|
+
throw new PluginError('plugin_incompatible', `${manifest.name} extends ${manifest.compat.base.map(b => b.name).join(', ')}, not "${base.name}".`);
|
|
29
|
+
}
|
|
30
|
+
if (base.version === undefined) {
|
|
31
|
+
if (entry.version !== '*') throw new PluginError('plugin_incompatible', `${manifest.name} requires ${base.name} ${entry.version}, but the base flow declares no version; only "*" can be satisfied.`);
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
if (!satisfiesRange(base.version, entry.version)) throw new PluginError('plugin_incompatible', `${manifest.name} requires ${base.name} ${entry.version}; the base flow is ${base.version}.`);
|
|
35
|
+
}
|