@relayflows/sdk 2.0.23 → 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.
Files changed (162) hide show
  1. package/dist/authored-flow-executor.d.ts +3 -0
  2. package/dist/authored-flow-executor.d.ts.map +1 -1
  3. package/dist/authored-flow-executor.js +29 -1
  4. package/dist/authored-flow-executor.js.map +1 -1
  5. package/dist/authored-flow-loader.d.ts +15 -2
  6. package/dist/authored-flow-loader.d.ts.map +1 -1
  7. package/dist/authored-flow-loader.js +33 -4
  8. package/dist/authored-flow-loader.js.map +1 -1
  9. package/dist/authored-hooks.d.ts +29 -0
  10. package/dist/authored-hooks.d.ts.map +1 -0
  11. package/dist/authored-hooks.js +117 -0
  12. package/dist/authored-hooks.js.map +1 -0
  13. package/dist/authored-node-entry.js +1 -0
  14. package/dist/authored-node-entry.js.map +1 -1
  15. package/dist/authored-root.d.ts +7 -0
  16. package/dist/authored-root.d.ts.map +1 -1
  17. package/dist/authored-root.js +14 -1
  18. package/dist/authored-root.js.map +1 -1
  19. package/dist/authored-source-authority.d.ts.map +1 -1
  20. package/dist/authored-source-authority.js +6 -0
  21. package/dist/authored-source-authority.js.map +1 -1
  22. package/dist/bundle-extensions.d.ts +19 -0
  23. package/dist/bundle-extensions.d.ts.map +1 -0
  24. package/dist/bundle-extensions.js +95 -0
  25. package/dist/bundle-extensions.js.map +1 -0
  26. package/dist/bundle-typescript.js +1 -1
  27. package/dist/bundle-typescript.js.map +1 -1
  28. package/dist/bundle.d.ts +11 -0
  29. package/dist/bundle.d.ts.map +1 -1
  30. package/dist/bundle.js +13 -4
  31. package/dist/bundle.js.map +1 -1
  32. package/dist/cli/add-extension.d.ts +28 -0
  33. package/dist/cli/add-extension.d.ts.map +1 -0
  34. package/dist/cli/add-extension.js +135 -0
  35. package/dist/cli/add-extension.js.map +1 -0
  36. package/dist/cli/add.d.ts +3 -0
  37. package/dist/cli/add.d.ts.map +1 -1
  38. package/dist/cli/add.js +8 -0
  39. package/dist/cli/add.js.map +1 -1
  40. package/dist/cli/build.d.ts.map +1 -1
  41. package/dist/cli/build.js +7 -2
  42. package/dist/cli/build.js.map +1 -1
  43. package/dist/cli/check-helper-body.d.ts.map +1 -1
  44. package/dist/cli/check-helper-body.js +4 -0
  45. package/dist/cli/check-helper-body.js.map +1 -1
  46. package/dist/cli/check-triggers.d.ts.map +1 -1
  47. package/dist/cli/check-triggers.js +25 -2
  48. package/dist/cli/check-triggers.js.map +1 -1
  49. package/dist/cli/check-typescript.d.ts.map +1 -1
  50. package/dist/cli/check-typescript.js +5 -1
  51. package/dist/cli/check-typescript.js.map +1 -1
  52. package/dist/cli/check.d.ts +24 -0
  53. package/dist/cli/check.d.ts.map +1 -1
  54. package/dist/cli/check.js.map +1 -1
  55. package/dist/cli/cloud-deploy.d.ts +1 -0
  56. package/dist/cli/cloud-deploy.d.ts.map +1 -1
  57. package/dist/cli/cloud-deploy.js +11 -1
  58. package/dist/cli/cloud-deploy.js.map +1 -1
  59. package/dist/cli/plugin.d.ts +50 -0
  60. package/dist/cli/plugin.d.ts.map +1 -0
  61. package/dist/cli/plugin.js +295 -0
  62. package/dist/cli/plugin.js.map +1 -0
  63. package/dist/cli-commands.d.ts +46 -3
  64. package/dist/cli-commands.d.ts.map +1 -1
  65. package/dist/cli-commands.js +32 -2
  66. package/dist/cli-commands.js.map +1 -1
  67. package/dist/cli.d.ts +2 -1
  68. package/dist/cli.d.ts.map +1 -1
  69. package/dist/cli.js +26 -1
  70. package/dist/cli.js.map +1 -1
  71. package/dist/cloud-deploy.d.ts +5 -0
  72. package/dist/cloud-deploy.d.ts.map +1 -1
  73. package/dist/cloud-deploy.js +7 -6
  74. package/dist/cloud-deploy.js.map +1 -1
  75. package/dist/cloud-run.d.ts +2 -0
  76. package/dist/cloud-run.d.ts.map +1 -1
  77. package/dist/cloud-run.js +14 -4
  78. package/dist/cloud-run.js.map +1 -1
  79. package/dist/failure-kinds.d.ts +2 -2
  80. package/dist/failure-kinds.d.ts.map +1 -1
  81. package/dist/flow-extension-compat.d.ts +18 -0
  82. package/dist/flow-extension-compat.d.ts.map +1 -0
  83. package/dist/flow-extension-compat.js +33 -0
  84. package/dist/flow-extension-compat.js.map +1 -0
  85. package/dist/flow-extension-loader.d.ts +61 -0
  86. package/dist/flow-extension-loader.d.ts.map +1 -0
  87. package/dist/flow-extension-loader.js +173 -0
  88. package/dist/flow-extension-loader.js.map +1 -0
  89. package/dist/flow-extension-manifest.d.ts +61 -0
  90. package/dist/flow-extension-manifest.d.ts.map +1 -0
  91. package/dist/flow-extension-manifest.js +188 -0
  92. package/dist/flow-extension-manifest.js.map +1 -0
  93. package/dist/flow-extension-submit.d.ts +38 -0
  94. package/dist/flow-extension-submit.d.ts.map +1 -0
  95. package/dist/flow-extension-submit.js +94 -0
  96. package/dist/flow-extension-submit.js.map +1 -0
  97. package/dist/flow-requirements.d.ts +13 -3
  98. package/dist/flow-requirements.d.ts.map +1 -1
  99. package/dist/flow-requirements.js +30 -0
  100. package/dist/flow-requirements.js.map +1 -1
  101. package/dist/plugin-github.d.ts +35 -0
  102. package/dist/plugin-github.d.ts.map +1 -0
  103. package/dist/plugin-github.js +92 -0
  104. package/dist/plugin-github.js.map +1 -0
  105. package/dist/plugin-loader.d.ts.map +1 -1
  106. package/dist/plugin-loader.js +5 -1
  107. package/dist/plugin-loader.js.map +1 -1
  108. package/dist/plugin-lock.d.ts +79 -0
  109. package/dist/plugin-lock.d.ts.map +1 -0
  110. package/dist/plugin-lock.js +203 -0
  111. package/dist/plugin-lock.js.map +1 -0
  112. package/dist/plugin-manifest.d.ts +5 -1
  113. package/dist/plugin-manifest.d.ts.map +1 -1
  114. package/dist/plugin-manifest.js +17 -0
  115. package/dist/plugin-manifest.js.map +1 -1
  116. package/dist/plugin-source.d.ts +38 -0
  117. package/dist/plugin-source.d.ts.map +1 -0
  118. package/dist/plugin-source.js +84 -0
  119. package/dist/plugin-source.js.map +1 -0
  120. package/dist/plugin-store.d.ts +30 -0
  121. package/dist/plugin-store.d.ts.map +1 -0
  122. package/dist/plugin-store.js +133 -0
  123. package/dist/plugin-store.js.map +1 -0
  124. package/dist/semver-range.d.ts +10 -0
  125. package/dist/semver-range.d.ts.map +1 -0
  126. package/dist/semver-range.js +88 -0
  127. package/dist/semver-range.js.map +1 -0
  128. package/package.json +2 -2
  129. package/src/authored-flow-executor.ts +36 -1
  130. package/src/authored-flow-loader.ts +49 -6
  131. package/src/authored-hooks.ts +136 -0
  132. package/src/authored-node-entry.ts +1 -0
  133. package/src/authored-root.ts +22 -1
  134. package/src/authored-source-authority.ts +6 -0
  135. package/src/bundle-extensions.ts +85 -0
  136. package/src/bundle-typescript.ts +1 -1
  137. package/src/bundle.ts +14 -4
  138. package/src/cli/add-extension.ts +134 -0
  139. package/src/cli/add.ts +9 -0
  140. package/src/cli/build.ts +7 -2
  141. package/src/cli/check-helper-body.ts +4 -0
  142. package/src/cli/check-triggers.ts +27 -2
  143. package/src/cli/check-typescript.ts +5 -1
  144. package/src/cli/check.ts +23 -0
  145. package/src/cli/cloud-deploy.ts +11 -1
  146. package/src/cli/plugin.ts +286 -0
  147. package/src/cli-commands.ts +32 -2
  148. package/src/cli.ts +23 -1
  149. package/src/cloud-deploy.ts +18 -8
  150. package/src/cloud-run.ts +16 -6
  151. package/src/flow-extension-compat.ts +35 -0
  152. package/src/flow-extension-loader.ts +217 -0
  153. package/src/flow-extension-manifest.ts +190 -0
  154. package/src/flow-extension-submit.ts +120 -0
  155. package/src/flow-requirements.ts +44 -3
  156. package/src/plugin-github.ts +98 -0
  157. package/src/plugin-loader.ts +5 -1
  158. package/src/plugin-lock.ts +194 -0
  159. package/src/plugin-manifest.ts +17 -0
  160. package/src/plugin-source.ts +91 -0
  161. package/src/plugin-store.ts +105 -0
  162. 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
+ }
@@ -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: 'helper', description: 'Helper name or @flows/<helper-name>', required: true }],
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})`
@@ -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 { flowRequirements, type FlowRequirements } from './flow-requirements.js';
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
- if (loaded.graph.length !== 1) {
174
- throw new CloudFlowError('unsupported_source',
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 = flowRequirements(definition, {
208
- sources, repository: input.repository, ...(projectCli === undefined ? {} : { projectCli }),
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
- if (loaded.graph.length !== 1) {
126
- throw new CloudFlowError('unsupported_source',
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
- specHash: createHash('sha256').update(canonicalize({ authority: authored.authority, input: authoredInput })).digest('hex'),
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
+ }