@relayflows/sdk 2.0.24 → 2.0.26

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 (167) hide show
  1. package/dist/authored-flow-executor.d.ts +8 -0
  2. package/dist/authored-flow-executor.d.ts.map +1 -1
  3. package/dist/authored-flow-executor.js +40 -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/babysitter-catalog-export.d.ts +9 -0
  23. package/dist/babysitter-catalog-export.d.ts.map +1 -0
  24. package/dist/babysitter-catalog-export.js +36 -0
  25. package/dist/babysitter-catalog-export.js.map +1 -0
  26. package/dist/bundle-extensions.d.ts +19 -0
  27. package/dist/bundle-extensions.d.ts.map +1 -0
  28. package/dist/bundle-extensions.js +95 -0
  29. package/dist/bundle-extensions.js.map +1 -0
  30. package/dist/bundle-typescript.js +1 -1
  31. package/dist/bundle-typescript.js.map +1 -1
  32. package/dist/bundle.d.ts +11 -0
  33. package/dist/bundle.d.ts.map +1 -1
  34. package/dist/bundle.js +13 -4
  35. package/dist/bundle.js.map +1 -1
  36. package/dist/cli/add-extension.d.ts +28 -0
  37. package/dist/cli/add-extension.d.ts.map +1 -0
  38. package/dist/cli/add-extension.js +135 -0
  39. package/dist/cli/add-extension.js.map +1 -0
  40. package/dist/cli/add.d.ts +3 -0
  41. package/dist/cli/add.d.ts.map +1 -1
  42. package/dist/cli/add.js +8 -0
  43. package/dist/cli/add.js.map +1 -1
  44. package/dist/cli/build.d.ts.map +1 -1
  45. package/dist/cli/build.js +7 -2
  46. package/dist/cli/build.js.map +1 -1
  47. package/dist/cli/check-helper-body.d.ts.map +1 -1
  48. package/dist/cli/check-helper-body.js +4 -0
  49. package/dist/cli/check-helper-body.js.map +1 -1
  50. package/dist/cli/check-triggers.d.ts.map +1 -1
  51. package/dist/cli/check-triggers.js +25 -2
  52. package/dist/cli/check-triggers.js.map +1 -1
  53. package/dist/cli/check-typescript.d.ts.map +1 -1
  54. package/dist/cli/check-typescript.js +5 -1
  55. package/dist/cli/check-typescript.js.map +1 -1
  56. package/dist/cli/check.d.ts +24 -0
  57. package/dist/cli/check.d.ts.map +1 -1
  58. package/dist/cli/check.js.map +1 -1
  59. package/dist/cli/cloud-deploy.d.ts +1 -0
  60. package/dist/cli/cloud-deploy.d.ts.map +1 -1
  61. package/dist/cli/cloud-deploy.js +11 -1
  62. package/dist/cli/cloud-deploy.js.map +1 -1
  63. package/dist/cli/plugin.d.ts +50 -0
  64. package/dist/cli/plugin.d.ts.map +1 -0
  65. package/dist/cli/plugin.js +295 -0
  66. package/dist/cli/plugin.js.map +1 -0
  67. package/dist/cli-commands.d.ts +46 -3
  68. package/dist/cli-commands.d.ts.map +1 -1
  69. package/dist/cli-commands.js +32 -2
  70. package/dist/cli-commands.js.map +1 -1
  71. package/dist/cli.d.ts +2 -1
  72. package/dist/cli.d.ts.map +1 -1
  73. package/dist/cli.js +26 -1
  74. package/dist/cli.js.map +1 -1
  75. package/dist/cloud-deploy.d.ts +5 -0
  76. package/dist/cloud-deploy.d.ts.map +1 -1
  77. package/dist/cloud-deploy.js +7 -6
  78. package/dist/cloud-deploy.js.map +1 -1
  79. package/dist/cloud-run.d.ts +2 -0
  80. package/dist/cloud-run.d.ts.map +1 -1
  81. package/dist/cloud-run.js +14 -4
  82. package/dist/cloud-run.js.map +1 -1
  83. package/dist/failure-kinds.d.ts +2 -2
  84. package/dist/failure-kinds.d.ts.map +1 -1
  85. package/dist/flow-extension-compat.d.ts +18 -0
  86. package/dist/flow-extension-compat.d.ts.map +1 -0
  87. package/dist/flow-extension-compat.js +33 -0
  88. package/dist/flow-extension-compat.js.map +1 -0
  89. package/dist/flow-extension-loader.d.ts +92 -0
  90. package/dist/flow-extension-loader.d.ts.map +1 -0
  91. package/dist/flow-extension-loader.js +241 -0
  92. package/dist/flow-extension-loader.js.map +1 -0
  93. package/dist/flow-extension-manifest.d.ts +61 -0
  94. package/dist/flow-extension-manifest.d.ts.map +1 -0
  95. package/dist/flow-extension-manifest.js +188 -0
  96. package/dist/flow-extension-manifest.js.map +1 -0
  97. package/dist/flow-extension-submit.d.ts +38 -0
  98. package/dist/flow-extension-submit.d.ts.map +1 -0
  99. package/dist/flow-extension-submit.js +94 -0
  100. package/dist/flow-extension-submit.js.map +1 -0
  101. package/dist/flow-requirements.d.ts +13 -3
  102. package/dist/flow-requirements.d.ts.map +1 -1
  103. package/dist/flow-requirements.js +30 -0
  104. package/dist/flow-requirements.js.map +1 -1
  105. package/dist/plugin-github.d.ts +35 -0
  106. package/dist/plugin-github.d.ts.map +1 -0
  107. package/dist/plugin-github.js +92 -0
  108. package/dist/plugin-github.js.map +1 -0
  109. package/dist/plugin-loader.d.ts.map +1 -1
  110. package/dist/plugin-loader.js +5 -1
  111. package/dist/plugin-loader.js.map +1 -1
  112. package/dist/plugin-lock.d.ts +79 -0
  113. package/dist/plugin-lock.d.ts.map +1 -0
  114. package/dist/plugin-lock.js +203 -0
  115. package/dist/plugin-lock.js.map +1 -0
  116. package/dist/plugin-manifest.d.ts +5 -1
  117. package/dist/plugin-manifest.d.ts.map +1 -1
  118. package/dist/plugin-manifest.js +18 -0
  119. package/dist/plugin-manifest.js.map +1 -1
  120. package/dist/plugin-source.d.ts +38 -0
  121. package/dist/plugin-source.d.ts.map +1 -0
  122. package/dist/plugin-source.js +84 -0
  123. package/dist/plugin-source.js.map +1 -0
  124. package/dist/plugin-store.d.ts +30 -0
  125. package/dist/plugin-store.d.ts.map +1 -0
  126. package/dist/plugin-store.js +133 -0
  127. package/dist/plugin-store.js.map +1 -0
  128. package/dist/semver-range.d.ts +10 -0
  129. package/dist/semver-range.d.ts.map +1 -0
  130. package/dist/semver-range.js +88 -0
  131. package/dist/semver-range.js.map +1 -0
  132. package/package.json +9 -4
  133. package/src/authored-flow-executor.ts +60 -1
  134. package/src/authored-flow-loader.ts +49 -6
  135. package/src/authored-hooks.ts +136 -0
  136. package/src/authored-node-entry.ts +1 -0
  137. package/src/authored-root.ts +22 -1
  138. package/src/authored-source-authority.ts +6 -0
  139. package/src/babysitter-catalog-export.ts +44 -0
  140. package/src/bundle-extensions.ts +85 -0
  141. package/src/bundle-typescript.ts +1 -1
  142. package/src/bundle.ts +14 -4
  143. package/src/cli/add-extension.ts +134 -0
  144. package/src/cli/add.ts +9 -0
  145. package/src/cli/build.ts +7 -2
  146. package/src/cli/check-helper-body.ts +4 -0
  147. package/src/cli/check-triggers.ts +27 -2
  148. package/src/cli/check-typescript.ts +5 -1
  149. package/src/cli/check.ts +23 -0
  150. package/src/cli/cloud-deploy.ts +11 -1
  151. package/src/cli/plugin.ts +286 -0
  152. package/src/cli-commands.ts +32 -2
  153. package/src/cli.ts +23 -1
  154. package/src/cloud-deploy.ts +18 -8
  155. package/src/cloud-run.ts +16 -6
  156. package/src/flow-extension-compat.ts +35 -0
  157. package/src/flow-extension-loader.ts +304 -0
  158. package/src/flow-extension-manifest.ts +190 -0
  159. package/src/flow-extension-submit.ts +120 -0
  160. package/src/flow-requirements.ts +44 -3
  161. package/src/plugin-github.ts +98 -0
  162. package/src/plugin-loader.ts +5 -1
  163. package/src/plugin-lock.ts +194 -0
  164. package/src/plugin-manifest.ts +18 -0
  165. package/src/plugin-source.ts +91 -0
  166. package/src/plugin-store.ts +105 -0
  167. package/src/semver-range.ts +74 -0
@@ -0,0 +1,120 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { sha256 } from './bundle.js';
4
+ import { CloudFlowError } from './cloud-http.js';
5
+ import { extensionManifestOf } from './cli/add-extension.js';
6
+ import { assertCompatible, runtimeVersions } from './flow-extension-compat.js';
7
+ import type { LoadedAuthoredFlow } from './authored-flow-loader.js';
8
+ import type { FlowExtensionManifest } from './flow-extension-manifest.js';
9
+ import { probeFlowExtension, type LoadedFlowExtension } from './flow-extension-loader.js';
10
+ import { fetchGithubPlugin, MAX_PLUGIN_TOTAL_BYTES, resolveGithubSha, type FetchLike } from './plugin-github.js';
11
+ import { PluginError } from './plugin-manifest.js';
12
+ import { canonicalPluginRef, parsePluginSource } from './plugin-source.js';
13
+ import { readStoredPluginFiles } from './plugin-store.js';
14
+
15
+ export const MAX_EXTENSIONS_BYTES = MAX_PLUGIN_TOTAL_BYTES;
16
+
17
+ export interface FlowExtensionFileSubmission {
18
+ readonly path: string;
19
+ readonly sha256: string;
20
+ readonly bytes: number;
21
+ readonly encoding: 'utf8' | 'base64';
22
+ readonly content: string;
23
+ }
24
+
25
+ export interface FlowExtensionSubmission {
26
+ readonly name: string;
27
+ readonly version: string;
28
+ readonly ref: string;
29
+ readonly digest: string;
30
+ readonly manifestSha256: string;
31
+ readonly manifest: FlowExtensionManifest;
32
+ readonly files: readonly FlowExtensionFileSubmission[];
33
+ }
34
+
35
+ function encodeFile(path: string, data: Uint8Array): FlowExtensionFileSubmission {
36
+ const text = Buffer.from(data).toString('utf8');
37
+ const utf8 = Buffer.from(text, 'utf8').equals(Buffer.from(data));
38
+ return {
39
+ path,
40
+ sha256: sha256(data),
41
+ bytes: data.length,
42
+ encoding: utf8 ? 'utf8' : 'base64',
43
+ content: utf8 ? text : Buffer.from(data).toString('base64'),
44
+ };
45
+ }
46
+
47
+ async function submissionFromStore(extension: LoadedFlowExtension): Promise<FlowExtensionSubmission> {
48
+ const stored = await readStoredPluginFiles(extension.directory, extension.digest);
49
+ const files = stored.filter(file => file.path !== 'manifest.json').map(file => encodeFile(file.path, file.data));
50
+ const manifestBytes = readFileSync(join(extension.directory, 'flows-plugin.json'));
51
+ return {
52
+ name: extension.name, version: extension.version, ref: extension.ref, digest: extension.digest,
53
+ manifestSha256: sha256(manifestBytes),
54
+ manifest: extension.manifest,
55
+ files,
56
+ };
57
+ }
58
+
59
+ /** Resolve a GitHub plugin reference without writing the working tree. */
60
+ export async function resolveExtensionSubmission(
61
+ input: string,
62
+ options: { fetch?: FetchLike; versions?: { sdk: string; surface: string } } = {},
63
+ ): Promise<FlowExtensionSubmission> {
64
+ try {
65
+ const source = await resolveGithubSha(parsePluginSource(input), options.fetch);
66
+ const fetched = await fetchGithubPlugin(source, options.fetch);
67
+ const { manifest, manifestSha256 } = extensionManifestOf(fetched);
68
+ assertCompatible(manifest, options.versions ?? runtimeVersions());
69
+ const files = fetched.files.map(file => encodeFile(file.path, file.data));
70
+ return {
71
+ name: manifest.name, version: manifest.version, ref: canonicalPluginRef(source),
72
+ digest: fetched.digest, manifestSha256, manifest, files,
73
+ };
74
+ } catch (error) {
75
+ if (error instanceof CloudFlowError) throw error;
76
+ if (error instanceof PluginError) throw new CloudFlowError('invalid_input', error.message);
77
+ throw new CloudFlowError('invalid_input', error instanceof Error ? error.message : String(error));
78
+ }
79
+ }
80
+
81
+ /**
82
+ * Installed extensions plus optional send-only `--plugin` refs. Caps the
83
+ * extensions field at 2 MB separately from the 256 KB source cap.
84
+ */
85
+ export async function collectExtensionSubmissions(
86
+ loaded: LoadedAuthoredFlow,
87
+ extraRefs: readonly string[] = [],
88
+ options: { fetch?: FetchLike } = {},
89
+ ): Promise<readonly FlowExtensionSubmission[]> {
90
+ const submissions: FlowExtensionSubmission[] = [];
91
+ for (const extension of loaded.extensions) submissions.push(await submissionFromStore(extension));
92
+ const names = new Set(submissions.map(s => s.name));
93
+ for (const ref of extraRefs) {
94
+ const extra = await resolveExtensionSubmission(ref, options);
95
+ if (names.has(extra.name)) {
96
+ throw new CloudFlowError('invalid_input', `Plugin ${extra.name} is already in this project; omit --plugin or remove the installed copy.`);
97
+ }
98
+ names.add(extra.name);
99
+ submissions.push(extra);
100
+ }
101
+ const total = Buffer.byteLength(JSON.stringify(submissions), 'utf8');
102
+ if (total > MAX_EXTENSIONS_BYTES) {
103
+ throw new CloudFlowError('invalid_input', `Flow extensions exceed Cloud's ${MAX_EXTENSIONS_BYTES}-byte extensions cap.`);
104
+ }
105
+ try {
106
+ for (const submission of submissions) await probeFlowExtension(submission.manifest);
107
+ } catch (error) {
108
+ if (error instanceof PluginError) throw new CloudFlowError('invalid_input', error.message);
109
+ throw error;
110
+ }
111
+ return submissions;
112
+ }
113
+
114
+ /** Graph nodes besides the root must be the composed extensions, not `use:` children. */
115
+ export function assertNoUseDependencies(loaded: LoadedAuthoredFlow): void {
116
+ if (loaded.graph.length !== 1 + loaded.extensions.length) {
117
+ throw new CloudFlowError('unsupported_source',
118
+ 'Cloud deploys one self-contained .flow.ts source without use dependencies.');
119
+ }
120
+ }
@@ -26,9 +26,9 @@ export type FlowHarness = (typeof FLOW_HARNESSES)[number];
26
26
  export interface FlowIntegrationRequirement {
27
27
  /** Cloud integration provider id (`slack`, `github`, `linear`, …). */
28
28
  provider: string;
29
- /** `tools`: a header declaration; `source`: a trigger or deploy target; `helper`: body use without a flag, or a YAML helper step. */
30
- from: 'tools' | 'source' | 'helper' | 'human';
31
- /** The declaration that requires it, as a reader would name it: `tools.slack`, `--on github`, `f.slack`, `f.human to`. */
29
+ /** Where this need was declared: a header/source/helper/human route, or an extension manifest. */
30
+ from: 'tools' | 'source' | 'helper' | 'human' | 'extension';
31
+ /** The declaration that requires it, as a reader would name it: `tools.slack`, `--on github`, `f.slack`, or `plugin "name"`. */
32
32
  detail: string;
33
33
  }
34
34
 
@@ -56,6 +56,47 @@ export interface FlowRequirementsContext {
56
56
  projectCli?: string;
57
57
  }
58
58
 
59
+ export interface FlowExtensionRequirements {
60
+ readonly name: string;
61
+ readonly permissions: {
62
+ readonly integrations: readonly string[];
63
+ readonly harnesses: readonly string[];
64
+ readonly mcp: readonly string[];
65
+ };
66
+ }
67
+
68
+ /** Add manifest-only needs without replacing the base flow's first declaration. */
69
+ export function mergeFlowExtensionRequirements(
70
+ base: FlowRequirements,
71
+ extensions: readonly FlowExtensionRequirements[],
72
+ ): FlowRequirements {
73
+ const integrations = [...base.integrations];
74
+ const integrationNames = new Set(integrations.map(entry => entry.provider));
75
+ const harnessUses = [...base.harnessUses];
76
+ const harnessNames = new Set<string>(base.harnesses);
77
+ const mcp = [...base.mcp];
78
+ const mcpNames = new Set(mcp);
79
+
80
+ for (const extension of extensions) {
81
+ for (const provider of extension.permissions.integrations) {
82
+ if (integrationNames.has(provider)) continue;
83
+ integrationNames.add(provider);
84
+ integrations.push({ provider, from: 'extension', detail: `plugin "${extension.name}"` });
85
+ }
86
+ for (const harness of extension.permissions.harnesses) {
87
+ if (harnessNames.has(harness)) continue;
88
+ harnessNames.add(harness);
89
+ harnessUses.push({ harness: harness as FlowHarness, detail: `plugin "${extension.name}"` });
90
+ }
91
+ for (const server of extension.permissions.mcp) {
92
+ if (mcpNames.has(server)) continue;
93
+ mcpNames.add(server);
94
+ mcp.push(server);
95
+ }
96
+ }
97
+ return { integrations, harnesses: harnessUses.map(use => use.harness), harnessUses, mcp };
98
+ }
99
+
59
100
  /** The inert subset of an authored definition this module reads. */
60
101
  export interface RequirementsFlowDefinition {
61
102
  readonly header?: { readonly tools?: Readonly<Record<string, unknown>> };
@@ -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
+ }
@@ -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
+ }
@@ -5,6 +5,11 @@ 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_event_ambiguous',
11
+ 'plugin_source_unresolved', 'plugin_fetch_failed', 'plugin_path_invalid', 'plugin_too_large',
12
+ 'plugin_source_drift', 'plugin_lock_invalid',
8
13
  ] as const;
9
14
  export type PluginFailureKind = typeof PLUGIN_FAILURE_KINDS[number];
10
15
  export class PluginError extends Error {
@@ -16,6 +21,15 @@ export interface PluginVerb {
16
21
  lowersTo: 'run' | 'llm' | 'agent' | 'effect' | 'wait';
17
22
  args: Record<string, unknown>;
18
23
  }
24
+ /** The kind a `flows-plugin.json` declares; absent means the schema-1 helper plugin. */
25
+ export type PluginKind = 'helper' | 'flow-extension';
26
+ export function pluginKindOf(input: unknown): PluginKind {
27
+ const kind = typeof input === 'object' && input !== null && !Array.isArray(input) ? (input as { kind?: unknown }).kind : undefined;
28
+ if (kind === undefined || kind === 'helper') return 'helper';
29
+ if (kind === 'flow-extension') return 'flow-extension';
30
+ throw new PluginError('plugin_kind_invalid', `Unknown plugin kind ${JSON.stringify(kind)}; expected "helper" or "flow-extension".`);
31
+ }
32
+ /** A schema-1 helper plugin: verbs that lower to kernel primitives. */
19
33
  export interface PluginManifest {
20
34
  name: string;
21
35
  version: string;
@@ -41,6 +55,10 @@ export function validatePluginManifest(input: unknown, packageName?: string): Pl
41
55
  catch { throw new PluginError('plugin_manifest_invalid', 'Plugin manifest must be JSON data.'); }
42
56
  const invalid = (message: string): never => { throw new PluginError('plugin_manifest_invalid', message); };
43
57
  if (!object(v)) return invalid('Expected a plugin manifest object.');
58
+ if (pluginKindOf(v) !== 'helper') {
59
+ 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.');
60
+ }
61
+ if (v.schema !== undefined && v.schema !== 1) return invalid('Helper plugin manifests are schema 1.');
44
62
  if (!Object.hasOwn(v, 'preflight')) throw new PluginError('plugin_preflight_missing', 'Plugin must declare preflight.');
45
63
  if (typeof v.name !== 'string' || typeof v.version !== 'string' || !v.version.trim()) return invalid('Plugin name and version are required.');
46
64
  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
+ }