@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.
- package/dist/authored-flow-executor.d.ts +8 -0
- package/dist/authored-flow-executor.d.ts.map +1 -1
- package/dist/authored-flow-executor.js +40 -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/babysitter-catalog-export.d.ts +9 -0
- package/dist/babysitter-catalog-export.d.ts.map +1 -0
- package/dist/babysitter-catalog-export.js +36 -0
- package/dist/babysitter-catalog-export.js.map +1 -0
- 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 +92 -0
- package/dist/flow-extension-loader.d.ts.map +1 -0
- package/dist/flow-extension-loader.js +241 -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 +18 -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 +9 -4
- package/src/authored-flow-executor.ts +60 -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/babysitter-catalog-export.ts +44 -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 +304 -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 +18 -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,136 @@
|
|
|
1
|
+
import type { Ctx } from '@relayflows/surface';
|
|
2
|
+
import { AuthoredFlowExecutionError } from './authored-flow-error.js';
|
|
3
|
+
import type { LoadedFlowExtension } from './flow-extension-loader.js';
|
|
4
|
+
import type { JournalClient } from './journal-client.js';
|
|
5
|
+
|
|
6
|
+
const HOOK_STREAM = 'hooks';
|
|
7
|
+
|
|
8
|
+
export interface HookRecord {
|
|
9
|
+
hook: string;
|
|
10
|
+
step: string;
|
|
11
|
+
plugin: string | null;
|
|
12
|
+
verdict: 'pass' | 'fail' | 'noop';
|
|
13
|
+
because?: string;
|
|
14
|
+
/** Parent `nextStep` after this verdict, so resume does not reuse inner step ids. */
|
|
15
|
+
afterStep?: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function recordKey(record: HookRecord): string {
|
|
19
|
+
return `${record.step}:${record.plugin ?? 'noop'}`;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* AND-compose installed hook implementations in lock order. Verdicts are
|
|
24
|
+
* appended to the root run's `hooks` stream before the boolean is returned,
|
|
25
|
+
* so a resume reads the recorded verdict and never re-runs the closure.
|
|
26
|
+
* No implementations is a journaled no-op returning true. A false short-circuits.
|
|
27
|
+
*/
|
|
28
|
+
export function createHookEvaluator(options: {
|
|
29
|
+
readonly journal: JournalClient;
|
|
30
|
+
readonly rootRunId?: string;
|
|
31
|
+
readonly flowName: string;
|
|
32
|
+
readonly declared: readonly string[];
|
|
33
|
+
readonly extensions: readonly LoadedFlowExtension[];
|
|
34
|
+
readonly peekStep?: () => number;
|
|
35
|
+
readonly restoreStep?: (step: number) => void;
|
|
36
|
+
readonly signal?: AbortSignal;
|
|
37
|
+
}): (id: string, name: string, input: unknown, context: Ctx) => Promise<boolean> {
|
|
38
|
+
let recorded: Promise<Map<string, HookRecord>> | undefined;
|
|
39
|
+
|
|
40
|
+
async function load(): Promise<Map<string, HookRecord>> {
|
|
41
|
+
const verdicts = new Map<string, HookRecord>();
|
|
42
|
+
if (options.rootRunId === undefined) return verdicts;
|
|
43
|
+
let offset = 0;
|
|
44
|
+
for (;;) {
|
|
45
|
+
const page = await options.journal.streamRead(options.rootRunId, HOOK_STREAM, offset, 1000);
|
|
46
|
+
for (const message of page.messages) {
|
|
47
|
+
const raw = (message as { message?: unknown }).message ?? message;
|
|
48
|
+
if (typeof raw !== 'object' || raw === null) continue;
|
|
49
|
+
const record = raw as HookRecord;
|
|
50
|
+
if (typeof record.hook !== 'string' || typeof record.step !== 'string') continue;
|
|
51
|
+
if (record.verdict !== 'pass' && record.verdict !== 'fail' && record.verdict !== 'noop') continue;
|
|
52
|
+
if (record.plugin !== null && typeof record.plugin !== 'string') continue;
|
|
53
|
+
verdicts.set(recordKey(record), record);
|
|
54
|
+
}
|
|
55
|
+
if (page.messages.length === 0 || page.next_offset <= offset) break;
|
|
56
|
+
offset = page.next_offset;
|
|
57
|
+
}
|
|
58
|
+
return verdicts;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function lookup(key: string): Promise<HookRecord | undefined> {
|
|
62
|
+
if (options.rootRunId === undefined) return undefined;
|
|
63
|
+
recorded ??= load();
|
|
64
|
+
return (await recorded).get(key);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async function append(record: HookRecord): Promise<void> {
|
|
68
|
+
if (options.rootRunId === undefined) return;
|
|
69
|
+
recorded ??= load();
|
|
70
|
+
await options.journal.streamAppend(options.rootRunId, HOOK_STREAM, record);
|
|
71
|
+
(await recorded).set(recordKey(record), record);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return async (id, name, input, context) => {
|
|
75
|
+
if (!options.declared.includes(name)) {
|
|
76
|
+
throw new AuthoredFlowExecutionError(
|
|
77
|
+
'unsupported_verb',
|
|
78
|
+
`hook "${name}" is not declared by flow "${options.flowName}"`,
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
const impls = options.extensions.filter(extension => extension.hooks[name] !== undefined);
|
|
82
|
+
if (impls.length === 0) {
|
|
83
|
+
const existing = await lookup(`${id}:noop`);
|
|
84
|
+
const record = existing ?? { hook: name, step: id, plugin: null, verdict: 'noop' as const };
|
|
85
|
+
if (existing === undefined) await append(record);
|
|
86
|
+
else if (existing.afterStep !== undefined) options.restoreStep?.(existing.afterStep);
|
|
87
|
+
return true;
|
|
88
|
+
}
|
|
89
|
+
for (const extension of impls) {
|
|
90
|
+
const key = `${id}:${extension.name}`;
|
|
91
|
+
let record = await lookup(key);
|
|
92
|
+
if (record === undefined) {
|
|
93
|
+
let verdict = false;
|
|
94
|
+
let because: string | undefined;
|
|
95
|
+
try {
|
|
96
|
+
verdict = await boundHook(extension.hooks[name]!(context, input), options.signal) === true;
|
|
97
|
+
} catch (error) {
|
|
98
|
+
// Cancellation is control-plane state, not a plugin verdict. The
|
|
99
|
+
// shared execution signal also stops tracked f.run/f.agent work;
|
|
100
|
+
// rethrow so a later resume may run the hook again instead of
|
|
101
|
+
// replaying a permanent decline caused by an interrupted attempt.
|
|
102
|
+
if (options.signal?.aborted) throw error;
|
|
103
|
+
verdict = false;
|
|
104
|
+
because = error instanceof Error ? error.message : String(error);
|
|
105
|
+
}
|
|
106
|
+
record = {
|
|
107
|
+
hook: name, step: id, plugin: extension.name,
|
|
108
|
+
verdict: verdict ? 'pass' : 'fail',
|
|
109
|
+
...(because === undefined ? {} : { because }),
|
|
110
|
+
...(options.peekStep === undefined ? {} : { afterStep: options.peekStep() }),
|
|
111
|
+
};
|
|
112
|
+
await append(record);
|
|
113
|
+
} else if (record.afterStep !== undefined) {
|
|
114
|
+
options.restoreStep?.(record.afterStep);
|
|
115
|
+
}
|
|
116
|
+
if (record.verdict === 'fail') return false;
|
|
117
|
+
}
|
|
118
|
+
return true;
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async function boundHook(run: Promise<boolean>, signal?: AbortSignal): Promise<boolean> {
|
|
123
|
+
// The parent run's wallclock budget owns the deadline. A second timer here
|
|
124
|
+
// would not be the signal captured by the hook's Ctx operations, allowing
|
|
125
|
+
// those operations to continue after this wrapper returned.
|
|
126
|
+
if (signal === undefined) return await run;
|
|
127
|
+
if (signal.aborted) throw signal.reason ?? new Error('hook cancelled');
|
|
128
|
+
return await new Promise<boolean>((resolve, reject) => {
|
|
129
|
+
const onAbort = () => reject(signal.reason ?? new Error('hook cancelled'));
|
|
130
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
131
|
+
run.then(
|
|
132
|
+
value => { signal.removeEventListener('abort', onAbort); resolve(value); },
|
|
133
|
+
error => { signal.removeEventListener('abort', onAbort); reject(error); },
|
|
134
|
+
);
|
|
135
|
+
});
|
|
136
|
+
}
|
|
@@ -72,6 +72,7 @@ try {
|
|
|
72
72
|
request.metadata.inputPresent ? request.metadata.input : undefined, {
|
|
73
73
|
getDefinition: loaded.getDefinition, dataDir: request.dataDir,
|
|
74
74
|
flowPath: request.metadata.flowPath, rootRunId: request.rootRunId,
|
|
75
|
+
extensions: loaded.extensions,
|
|
75
76
|
localAgentStream: request.localAgentStream, signal: controller.signal,
|
|
76
77
|
onProgress: event => send({ type: 'progress', event }),
|
|
77
78
|
onWait: event => send({ type: 'wait', event }),
|
package/src/authored-root.ts
CHANGED
|
@@ -21,6 +21,12 @@ import { isSurfaceCompletionReason } from './authored-step-output.js';
|
|
|
21
21
|
|
|
22
22
|
const ROOT_KIND = 'relayflows.authored-root.v1';
|
|
23
23
|
|
|
24
|
+
export interface AuthoredRootExtension {
|
|
25
|
+
readonly name: string;
|
|
26
|
+
readonly digest: string;
|
|
27
|
+
readonly ref: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
24
30
|
export interface AuthoredRootMetadata {
|
|
25
31
|
readonly kind: typeof ROOT_KIND;
|
|
26
32
|
readonly flowName: string;
|
|
@@ -28,6 +34,8 @@ export interface AuthoredRootMetadata {
|
|
|
28
34
|
readonly sourceSha256: string;
|
|
29
35
|
readonly surface: SurfaceModuleAuthority;
|
|
30
36
|
readonly sources: readonly AuthoredRootSourceAuthority[];
|
|
37
|
+
/** Plugin-level provenance in lock order; empty when the project declares none. */
|
|
38
|
+
readonly extensions: readonly AuthoredRootExtension[];
|
|
31
39
|
readonly localAgentStream?: string;
|
|
32
40
|
readonly inputPresent: boolean;
|
|
33
41
|
readonly input?: unknown;
|
|
@@ -68,6 +76,9 @@ export async function executeDurableAuthoredFlow(
|
|
|
68
76
|
sourceSha256: sha256(source),
|
|
69
77
|
surface: loaded.surfaceAuthority,
|
|
70
78
|
sources: Object.freeze(sources),
|
|
79
|
+
extensions: Object.freeze((loaded.extensions ?? []).map(extension => Object.freeze({
|
|
80
|
+
name: extension.name, digest: extension.digest, ref: extension.ref,
|
|
81
|
+
}))),
|
|
71
82
|
...(options.localAgentStream === undefined ? {} : { localAgentStream: options.localAgentStream }),
|
|
72
83
|
inputPresent: input !== undefined,
|
|
73
84
|
...(input === undefined ? {} : { input: jsonSnapshot(input, 'authored root input') }),
|
|
@@ -172,7 +183,7 @@ export async function readAuthoredRootMetadata(
|
|
|
172
183
|
if (!isRootMetadata(value)) {
|
|
173
184
|
throw new Error('authored root journal has malformed authority metadata');
|
|
174
185
|
}
|
|
175
|
-
return value;
|
|
186
|
+
return Object.freeze({ ...value, extensions: value.extensions ?? [] });
|
|
176
187
|
}
|
|
177
188
|
|
|
178
189
|
async function driveRoot(
|
|
@@ -203,6 +214,7 @@ async function driveRoot(
|
|
|
203
214
|
flowPath: metadata.flowPath,
|
|
204
215
|
localAgentStream: options.localAgentStream,
|
|
205
216
|
rootRunId: dispatch.run_id,
|
|
217
|
+
extensions: loaded.extensions,
|
|
206
218
|
...options.lifecycle,
|
|
207
219
|
signal: callerSignal === undefined
|
|
208
220
|
? rootSignal
|
|
@@ -409,10 +421,19 @@ function isRootMetadata(value: unknown): value is AuthoredRootMetadata {
|
|
|
409
421
|
&& typeof surface.version === 'string' && /^[a-f0-9]{64}$/.test(surface.packageSha256 ?? '')
|
|
410
422
|
&& /^[a-f0-9]{64}$/.test(surface.runtimeSha256 ?? '')
|
|
411
423
|
&& Array.isArray(sources) && sources.length > 0 && sources.every(isRootSourceAuthority)
|
|
424
|
+
&& (root.extensions === undefined || (Array.isArray(root.extensions) && root.extensions.every(isRootExtension)))
|
|
412
425
|
&& (root.localAgentStream === undefined
|
|
413
426
|
|| /^local-agent-[a-f0-9-]+$/.test(root.localAgentStream));
|
|
414
427
|
}
|
|
415
428
|
|
|
429
|
+
function isRootExtension(value: unknown): value is AuthoredRootExtension {
|
|
430
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) return false;
|
|
431
|
+
const extension = value as Partial<AuthoredRootExtension>;
|
|
432
|
+
return typeof extension.name === 'string' && extension.name.length > 0
|
|
433
|
+
&& /^[a-f0-9]{64}$/.test(extension.digest ?? '')
|
|
434
|
+
&& typeof extension.ref === 'string' && extension.ref.startsWith('github:');
|
|
435
|
+
}
|
|
436
|
+
|
|
416
437
|
function isRootSourceAuthority(value: unknown): value is AuthoredRootSourceAuthority {
|
|
417
438
|
if (typeof value !== 'object' || value === null || Array.isArray(value)) return false;
|
|
418
439
|
const source = value as Partial<AuthoredRootSourceAuthority>;
|
|
@@ -35,6 +35,12 @@ export async function loadPinnedAuthoredSource(metadata: AuthoredRootMetadata, i
|
|
|
35
35
|
if (canonicalize(loadedSources) !== canonicalize(metadata.sources)) {
|
|
36
36
|
throw new Error('authored root declared source graph authority mismatch');
|
|
37
37
|
}
|
|
38
|
+
const loadedExtensions = (loaded.extensions ?? []).map(extension => ({
|
|
39
|
+
name: extension.name, digest: extension.digest, ref: extension.ref,
|
|
40
|
+
}));
|
|
41
|
+
if (canonicalize(loadedExtensions) !== canonicalize(metadata.extensions ?? [])) {
|
|
42
|
+
throw new Error('authored root declared extension authority mismatch');
|
|
43
|
+
}
|
|
38
44
|
return loaded;
|
|
39
45
|
}
|
|
40
46
|
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { assertBaseCompatible } from './flow-extension-compat.js';
|
|
2
|
+
import { resolveExtensionSubmission, type FlowExtensionSubmission } from './flow-extension-submit.js';
|
|
3
|
+
import { canonicalPluginRef, parseCanonicalPluginRef } from './plugin-source.js';
|
|
4
|
+
|
|
5
|
+
export interface BabysitterCatalogPin {
|
|
6
|
+
readonly ref: string;
|
|
7
|
+
readonly digest: string;
|
|
8
|
+
readonly manifestSha256: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** Export reviewed bytes, never execute the entry or infer runtime readiness. */
|
|
12
|
+
export async function exportBabysitterCatalogBundle(
|
|
13
|
+
pin: BabysitterCatalogPin,
|
|
14
|
+
options: Parameters<typeof resolveExtensionSubmission>[1] = {},
|
|
15
|
+
): Promise<FlowExtensionSubmission> {
|
|
16
|
+
const source = parseCanonicalPluginRef(pin.ref);
|
|
17
|
+
if (canonicalPluginRef(source) !== pin.ref || source.owner !== 'AgentWorkforce' || source.repo !== 'flows') {
|
|
18
|
+
throw new Error('Babysitter catalog source must be a canonical AgentWorkforce/flows commit ref.');
|
|
19
|
+
}
|
|
20
|
+
if (![pin.digest, pin.manifestSha256].every(value => /^[0-9a-f]{64}$/.test(value))) {
|
|
21
|
+
throw new Error('Expected reviewed bundle and manifest SHA-256 digests.');
|
|
22
|
+
}
|
|
23
|
+
const bundle = await resolveExtensionSubmission(pin.ref, options);
|
|
24
|
+
if (bundle.ref !== pin.ref || bundle.digest !== pin.digest || bundle.manifestSha256 !== pin.manifestSha256) {
|
|
25
|
+
throw new Error('Babysitter artifact differs from the reviewed pin.');
|
|
26
|
+
}
|
|
27
|
+
const manifest = bundle.manifest;
|
|
28
|
+
if (manifest.name !== 'babysitter') throw new Error('Expected the babysitter extension.');
|
|
29
|
+
assertBaseCompatible(manifest, { name: 'software-factory' });
|
|
30
|
+
const p = manifest.permissions;
|
|
31
|
+
const exactly = (values: readonly string[], expected: string) => values.length === 1 && values[0] === expected;
|
|
32
|
+
if (!exactly(p.integrations, 'github') || !exactly(p.harnesses, 'codex') || p.mcp.length !== 0
|
|
33
|
+
|| !exactly(p.writes, 'cloud:babysitter-turn')) {
|
|
34
|
+
throw new Error('Babysitter requires only github, codex, no MCP, and cloud:babysitter-turn.');
|
|
35
|
+
}
|
|
36
|
+
if (!manifest.extends.handlers || manifest.extends.hooks.length !== 0) {
|
|
37
|
+
throw new Error('Native Babysitter must contribute handlers only, without merge hooks.');
|
|
38
|
+
}
|
|
39
|
+
// The validator normalizes optional source fields. The wire manifest must
|
|
40
|
+
// remain the actual digest-bound JSON so Cloud can independently parse it.
|
|
41
|
+
const file = bundle.files.find(entry => entry.path === 'flows-plugin.json')!;
|
|
42
|
+
const rawManifest = JSON.parse(Buffer.from(file.content, file.encoding).toString('utf8'));
|
|
43
|
+
return { ...bundle, manifest: rawManifest };
|
|
44
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { readFile, readdir } from 'node:fs/promises';
|
|
2
|
+
import { dirname, join } from 'node:path';
|
|
3
|
+
import type { BundleFile } from './bundle.js';
|
|
4
|
+
import { safePath, sha256 } from './bundle.js';
|
|
5
|
+
import { findPluginProject } from './plugin-loader.js';
|
|
6
|
+
import { parsePluginLock, readPluginLock, reconcileDeclaredExtensions, type PluginLock } from './plugin-lock.js';
|
|
7
|
+
import { PluginError } from './plugin-manifest.js';
|
|
8
|
+
import { pluginStoreDirectory, readStoredPluginFiles, verifyStoredPlugin } from './plugin-store.js';
|
|
9
|
+
|
|
10
|
+
const EMPTY_LOCK: PluginLock = Object.freeze({ version: 2, plugins: Object.freeze([]) });
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Plugin provenance and bytes to put in a sealed bundle: `lockfile.json` is
|
|
14
|
+
* the same v2 lock the project records, and each extension's materialized
|
|
15
|
+
* files land under `plugins/<name>/…`. No project / no github: plugins →
|
|
16
|
+
* `{ version: 2, plugins: [] }` and no files. Fail closed on lock/store drift.
|
|
17
|
+
*/
|
|
18
|
+
export async function collectBundleExtensions(flowPath: string): Promise<{ lock: PluginLock; files: BundleFile[] }> {
|
|
19
|
+
const root = findPluginProject(dirname(flowPath));
|
|
20
|
+
if (root === undefined) return { lock: EMPTY_LOCK, files: [] };
|
|
21
|
+
const locked = reconcileDeclaredExtensions(root);
|
|
22
|
+
const files: BundleFile[] = [];
|
|
23
|
+
for (const { entry } of locked) {
|
|
24
|
+
const stored = await readStoredPluginFiles(pluginStoreDirectory(root, entry.name, entry.digest), entry.digest);
|
|
25
|
+
for (const file of stored) files.push({ path: `plugins/${entry.name}/${file.path}`, data: file.data });
|
|
26
|
+
}
|
|
27
|
+
return { lock: readPluginLock(root), files };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* After `verifyBundle` has hashed every payload file, check that `lockfile.json`
|
|
32
|
+
* is a v2 plugin lock (or a legacy v1 `{version:1, adapters}` with no plugins)
|
|
33
|
+
* and that each locked extension's store digest matches `plugins/<name>/manifest.json`.
|
|
34
|
+
*/
|
|
35
|
+
export async function verifyBundlePluginLock(bundle: string): Promise<void> {
|
|
36
|
+
let parsed: unknown;
|
|
37
|
+
try { parsed = JSON.parse(await readFile(join(bundle, 'lockfile.json'), 'utf8')); }
|
|
38
|
+
catch { throw new Error('lockfile.json: not valid JSON'); }
|
|
39
|
+
if (isLegacyV1Lock(parsed) || isLegacyNpmLock(parsed)) {
|
|
40
|
+
await assertNoLegacyPluginPayload(bundle);
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
let lock: PluginLock;
|
|
44
|
+
try { lock = parsePluginLock(parsed); }
|
|
45
|
+
catch (error) {
|
|
46
|
+
throw new Error(error instanceof PluginError ? error.message : 'lockfile.json: not a plugin lock');
|
|
47
|
+
}
|
|
48
|
+
for (const entry of lock.plugins) {
|
|
49
|
+
if (!safePath(entry.name) || entry.name.includes('/')) {
|
|
50
|
+
throw new Error(`lockfile.json: plugin name ${entry.name} is not a safe path component`);
|
|
51
|
+
}
|
|
52
|
+
const directory = join(bundle, 'plugins', entry.name);
|
|
53
|
+
await verifyStoredPlugin(directory, entry.digest);
|
|
54
|
+
let pluginManifest: Buffer;
|
|
55
|
+
try { pluginManifest = await readFile(join(directory, 'flows-plugin.json')); }
|
|
56
|
+
catch { throw new Error(`lockfile.json: plugin ${entry.name} is missing plugins/${entry.name}/flows-plugin.json`); }
|
|
57
|
+
if (sha256(pluginManifest) !== entry.manifestSha256) {
|
|
58
|
+
throw new Error(`lockfile.json: plugin ${entry.name} flows-plugin.json does not match the lockfile manifest hash`);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function assertNoLegacyPluginPayload(bundle: string): Promise<void> {
|
|
64
|
+
let entries: string[];
|
|
65
|
+
try { entries = await readdir(join(bundle, 'plugins')); }
|
|
66
|
+
catch (error) {
|
|
67
|
+
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return;
|
|
68
|
+
throw error;
|
|
69
|
+
}
|
|
70
|
+
if (entries.length > 0) {
|
|
71
|
+
throw new Error('lockfile.json: legacy locks cannot authenticate plugins/ payloads');
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function isLegacyV1Lock(value: unknown): boolean {
|
|
76
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
77
|
+
&& (value as { version?: unknown }).version === 1
|
|
78
|
+
&& Array.isArray((value as { adapters?: unknown }).adapters);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function isLegacyNpmLock(value: unknown): boolean {
|
|
82
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) return false;
|
|
83
|
+
const lock = value as { lockfileVersion?: unknown; packages?: unknown };
|
|
84
|
+
return (lock.lockfileVersion === 2 || lock.lockfileVersion === 3) && typeof lock.packages === 'object' && lock.packages !== null;
|
|
85
|
+
}
|
package/src/bundle-typescript.ts
CHANGED
|
@@ -60,7 +60,7 @@ process.stdout.write(JSON.stringify({ spec, authored }));
|
|
|
60
60
|
'--no-compile-autoload-bunfig', '--outfile', executable, input], directory);
|
|
61
61
|
const files: BundleFile[] = [
|
|
62
62
|
{ path: 'flow', data: await readFile(executable) },
|
|
63
|
-
{ path: '
|
|
63
|
+
{ path: 'package-lock.json', data: canonicalize(lock) },
|
|
64
64
|
];
|
|
65
65
|
return { spec, authored: inspected.authored === true, compiler, files };
|
|
66
66
|
} finally { await rm(staging, { recursive: true, force: true }); }
|
package/src/bundle.ts
CHANGED
|
@@ -18,12 +18,24 @@ export function sha256(data: Uint8Array | string): string {
|
|
|
18
18
|
return createHash('sha256').update(data).digest('hex');
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
-
|
|
21
|
+
/** A bundle-relative path: no empty, `.`, or `..` component, no backslash, NUL, or drive colon. */
|
|
22
|
+
export function safePath(path: string): boolean {
|
|
22
23
|
return path.length > 0 && !path.includes('\\') && !path.includes('\0')
|
|
23
24
|
&& path.split('/').every(part => part !== '' && part !== '.' && part !== '..')
|
|
24
25
|
&& !path.includes(':');
|
|
25
26
|
}
|
|
26
27
|
|
|
28
|
+
/**
|
|
29
|
+
* The canonical entry list whose sha256 is a payload's content digest. Shared
|
|
30
|
+
* by sealed flow bundles and materialized plugins so `@sha256:` means one
|
|
31
|
+
* thing everywhere: the digest of `[{bytes,path,sha256}]`, sorted by path.
|
|
32
|
+
*/
|
|
33
|
+
export function payloadManifest(files: readonly { path: string; data: Uint8Array }[]): string {
|
|
34
|
+
return canonicalize([...files]
|
|
35
|
+
.sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0)
|
|
36
|
+
.map(file => ({ path: file.path, sha256: sha256(file.data), bytes: file.data.length })));
|
|
37
|
+
}
|
|
38
|
+
|
|
27
39
|
/** Manifest and identity are envelopes, excluded to avoid circular hashing. */
|
|
28
40
|
export async function sealBundle(options: BundleOptions): Promise<string> {
|
|
29
41
|
if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(options.name)) {
|
|
@@ -41,9 +53,7 @@ export async function sealBundle(options: BundleOptions): Promise<string> {
|
|
|
41
53
|
for (const required of ['spec.canonical.json', 'preflight.json', 'lockfile.json']) {
|
|
42
54
|
if (!paths.has(required)) throw new Error(`${required}: missing bundle file`);
|
|
43
55
|
}
|
|
44
|
-
const manifest =
|
|
45
|
-
path: file.path, sha256: sha256(file.data), bytes: file.data.length,
|
|
46
|
-
})));
|
|
56
|
+
const manifest = payloadManifest(files);
|
|
47
57
|
const digest = sha256(manifest);
|
|
48
58
|
const out = resolve(options.out);
|
|
49
59
|
const target = join(out, `${options.name}@sha256:${digest}`);
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import type { CliIo } from '../cli.js';
|
|
4
|
+
import { sha256 } from '../bundle.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, type FetchedPlugin } from '../plugin-github.js';
|
|
8
|
+
import { PLUGIN_LOCK_FILE, lockWithPlugin, readPluginLock, recoverFlowsAndLock, writeFlowsAndLock } from '../plugin-lock.js';
|
|
9
|
+
import { findPluginProject } from '../plugin-loader.js';
|
|
10
|
+
import { PluginError } from '../plugin-manifest.js';
|
|
11
|
+
import { canonicalPluginRef, parsePluginSource } from '../plugin-source.js';
|
|
12
|
+
import { materializePlugin } from '../plugin-store.js';
|
|
13
|
+
|
|
14
|
+
export { assertCompatible, runtimeVersions };
|
|
15
|
+
|
|
16
|
+
/** Parse and validate the manifest inside a fetched plugin, checking that any self-declared source is the one it came from. */
|
|
17
|
+
export function extensionManifestOf(plugin: FetchedPlugin): { manifest: FlowExtensionManifest; manifestSha256: string } {
|
|
18
|
+
const file = plugin.files.find(f => f.path === 'flows-plugin.json');
|
|
19
|
+
if (file === undefined) throw new PluginError('plugin_manifest_missing', 'Plugin has no flows-plugin.json.');
|
|
20
|
+
let input: unknown;
|
|
21
|
+
try { input = JSON.parse(file.data.toString('utf8')); }
|
|
22
|
+
catch { throw new PluginError('plugin_manifest_invalid', 'flows-plugin.json is unreadable or invalid JSON.'); }
|
|
23
|
+
const manifest = validateFlowExtensionManifest(input);
|
|
24
|
+
const { source } = plugin;
|
|
25
|
+
if (manifest.source !== undefined && (manifest.source.owner !== source.owner || manifest.source.repo !== source.repo || manifest.source.path !== source.path
|
|
26
|
+
|| (manifest.source.sha !== undefined && manifest.source.sha !== source.sha))) {
|
|
27
|
+
throw new PluginError('plugin_source_drift', `flows-plugin.json declares source ${manifest.source.owner}/${manifest.source.repo}#${manifest.source.path}, but it was fetched from ${source.owner}/${source.repo}#${source.path}.`);
|
|
28
|
+
}
|
|
29
|
+
if (!plugin.files.some(f => f.path === manifest.entry)) throw new PluginError('plugin_manifest_invalid', `entry ${manifest.entry} is not in the plugin.`);
|
|
30
|
+
return { manifest, manifestSha256: sha256(file.data) };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function eventKeys(manifest: FlowExtensionManifest): readonly string[] {
|
|
34
|
+
return manifest.triggers.map(t => `${t.provider} ${t.event}[${t.actions.join(',')}]`);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function formatBudget(budget: FlowExtensionManifest['permissions']['budget']): string {
|
|
38
|
+
if (budget === undefined) return 'inherits base';
|
|
39
|
+
return [budget.dollars === undefined ? '' : `$${budget.dollars}`, budget.wallclock ?? ''].filter(Boolean).join(' / ') || 'inherits base';
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function describeExtension(manifest: FlowExtensionManifest): string[] {
|
|
43
|
+
const p = manifest.permissions;
|
|
44
|
+
return [
|
|
45
|
+
` integrations: ${p.integrations.join(', ') || 'none'}; harnesses: ${p.harnesses.join(', ') || 'none'}; mcp: ${p.mcp.join(', ') || 'none'}`,
|
|
46
|
+
` events: ${eventKeys(manifest).join('; ') || 'none'}`,
|
|
47
|
+
` hooks: ${manifest.extends.hooks.join(', ') || 'none'}; handlers: ${manifest.extends.handlers ? 'yes' : 'no'}`,
|
|
48
|
+
` writes (declared, unenforced): ${p.writes.join(', ') || 'none'}`,
|
|
49
|
+
` budget: ${formatBudget(p.budget)}`,
|
|
50
|
+
];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Permissions / events / budget (and version/hooks) changes between two manifests. */
|
|
54
|
+
export function diffExtension(before: FlowExtensionManifest, after: FlowExtensionManifest): string[] {
|
|
55
|
+
const lines: string[] = [];
|
|
56
|
+
const change = (label: string, oldValue: string, newValue: string): void => {
|
|
57
|
+
if (oldValue !== newValue) lines.push(` ${label}: ${oldValue} → ${newValue}`);
|
|
58
|
+
};
|
|
59
|
+
const setDiff = (label: string, oldList: readonly string[], newList: readonly string[]): void => {
|
|
60
|
+
const removed = oldList.filter(x => !newList.includes(x));
|
|
61
|
+
const added = newList.filter(x => !oldList.includes(x));
|
|
62
|
+
if (removed.length === 0 && added.length === 0) return;
|
|
63
|
+
lines.push(` ${label}: ${[...removed.map(x => `-${x}`), ...added.map(x => `+${x}`)].join(', ')}`);
|
|
64
|
+
};
|
|
65
|
+
change('version', before.version, after.version);
|
|
66
|
+
setDiff('integrations', before.permissions.integrations, after.permissions.integrations);
|
|
67
|
+
setDiff('harnesses', before.permissions.harnesses, after.permissions.harnesses);
|
|
68
|
+
setDiff('mcp', before.permissions.mcp, after.permissions.mcp);
|
|
69
|
+
setDiff('writes', before.permissions.writes, after.permissions.writes);
|
|
70
|
+
setDiff('events', eventKeys(before), eventKeys(after));
|
|
71
|
+
setDiff('hooks', before.extends.hooks, after.extends.hooks);
|
|
72
|
+
change('budget', formatBudget(before.permissions.budget), formatBudget(after.permissions.budget));
|
|
73
|
+
if (lines.length === 0) lines.push(' (no permissions/events/budget changes)');
|
|
74
|
+
return lines;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export interface AddExtensionOptions {
|
|
78
|
+
cwd?: string;
|
|
79
|
+
fetch?: FetchLike;
|
|
80
|
+
now?: () => Date;
|
|
81
|
+
versions?: RuntimeVersions;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* `flows add github:<owner>/<repo>@<ref>#<path>` — resolve the ref to a commit,
|
|
86
|
+
* fetch the plugin directory, validate its schema-2 manifest, materialize it
|
|
87
|
+
* under `.flows/plugins`, and record the canonical reference in `flows.json`
|
|
88
|
+
* plus the provenance in `flows.lock.json`. Runtime composition is a later
|
|
89
|
+
* slice: installing records a declaration, it does not enable execution.
|
|
90
|
+
*/
|
|
91
|
+
export async function addExtensionPlugin(input: string, io: CliIo, options: AddExtensionOptions = {}): Promise<0 | 2> {
|
|
92
|
+
try {
|
|
93
|
+
// Parse before touching the filesystem or the network: a malformed
|
|
94
|
+
// reference is refused offline, with the same code from any directory.
|
|
95
|
+
const requested = parsePluginSource(input);
|
|
96
|
+
const root = findPluginProject(options.cwd ?? process.cwd());
|
|
97
|
+
if (!root) throw new PluginError('plugin_manifest_invalid', 'flows add requires a project with flows.json.');
|
|
98
|
+
recoverFlowsAndLock(root);
|
|
99
|
+
const configPath = join(root, 'flows.json');
|
|
100
|
+
const config = JSON.parse(readFileSync(configPath, 'utf8'));
|
|
101
|
+
if (!config || Array.isArray(config) || typeof config !== 'object' || (config.plugins !== undefined && (!Array.isArray(config.plugins) || !config.plugins.every((p: unknown) => typeof p === 'string')))) throw new PluginError('plugin_manifest_invalid', 'Invalid flows.json plugins list.');
|
|
102
|
+
const lock = readPluginLock(root);
|
|
103
|
+
const source = await resolveGithubSha(requested, options.fetch);
|
|
104
|
+
const fetched = await fetchGithubPlugin(source, options.fetch);
|
|
105
|
+
const { manifest, manifestSha256 } = extensionManifestOf(fetched);
|
|
106
|
+
assertCompatible(manifest, options.versions ?? runtimeVersions());
|
|
107
|
+
const ref = canonicalPluginRef(source);
|
|
108
|
+
const declared: string[] = config.plugins ?? [];
|
|
109
|
+
for (const other of lock.plugins) {
|
|
110
|
+
if (other.name === manifest.name && canonicalPluginRef({ ...other.source, ref: other.source.sha }) !== ref) {
|
|
111
|
+
throw new PluginError('plugin_manifest_invalid', `Plugin ${manifest.name} is already installed from ${other.source.owner}/${other.source.repo}@${other.source.sha}; remove it before installing another source under the same name.`);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
const { directory, digest } = await materializePlugin(root, manifest.name, fetched.files);
|
|
115
|
+
if (digest !== fetched.digest) throw new PluginError('plugin_source_drift', 'Materialized digest differs from the fetched digest.');
|
|
116
|
+
const plugins = declared.includes(ref) ? declared : [...declared, ref];
|
|
117
|
+
const next = lockWithPlugin(lock, plugins, {
|
|
118
|
+
name: manifest.name, version: manifest.version,
|
|
119
|
+
source: { host: 'github', owner: source.owner, repo: source.repo, sha: source.sha, path: source.path },
|
|
120
|
+
digest, manifestSha256, resolvedAt: (options.now ?? (() => new Date()))().toISOString(),
|
|
121
|
+
});
|
|
122
|
+
writeFlowsAndLock(root, configPath, config, plugins, next);
|
|
123
|
+
io.stdout(`Added ${manifest.name}@${manifest.version} (flow-extension) from ${ref}`);
|
|
124
|
+
io.stdout(` digest sha256:${digest}`);
|
|
125
|
+
io.stdout(` materialized at ${directory}`);
|
|
126
|
+
for (const line of describeExtension(manifest)) io.stdout(line);
|
|
127
|
+
io.stdout(` recorded in flows.json and ${PLUGIN_LOCK_FILE}`);
|
|
128
|
+
return 0;
|
|
129
|
+
} catch (error) {
|
|
130
|
+
const refusal = error instanceof PluginError ? error : new PluginError('plugin_manifest_invalid', (error as Error).message);
|
|
131
|
+
io.stderr(`REFUSED [${refusal.code}] ${refusal.message}`);
|
|
132
|
+
return 2;
|
|
133
|
+
}
|
|
134
|
+
}
|
package/src/cli/add.ts
CHANGED
|
@@ -3,16 +3,25 @@ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
|
3
3
|
import { join } from 'node:path';
|
|
4
4
|
import type { CliIo } from '../cli.js';
|
|
5
5
|
import { findPluginProject, probePlugin, readPlugin } from '../plugin-loader.js';
|
|
6
|
+
import { recoverFlowsAndLock } from '../plugin-lock.js';
|
|
6
7
|
import { PluginError, pluginPackageName } from '../plugin-manifest.js';
|
|
8
|
+
import { isGithubPluginRef } from '../plugin-source.js';
|
|
9
|
+
import { addExtensionPlugin, type AddExtensionOptions } from './add-extension.js';
|
|
7
10
|
|
|
8
11
|
export async function addPlugin(name: string, io: CliIo, options: {
|
|
9
12
|
cwd?: string;
|
|
10
13
|
install?: (packageName: string, root: string) => void;
|
|
14
|
+
/** GitHub-sourced flow extensions only; ignored for helper packages. */
|
|
15
|
+
extension?: Omit<AddExtensionOptions, 'cwd'>;
|
|
11
16
|
} = {}): Promise<0 | 2> {
|
|
17
|
+
// A GitHub reference is a schema-2 flow extension; everything else is the
|
|
18
|
+
// helper-package path below, which this branch leaves exactly as it was.
|
|
19
|
+
if (isGithubPluginRef(name)) return addExtensionPlugin(name, io, { ...options.extension, ...(options.cwd === undefined ? {} : { cwd: options.cwd }) });
|
|
12
20
|
try {
|
|
13
21
|
const packageName = pluginPackageName(name);
|
|
14
22
|
const root = findPluginProject(options.cwd ?? process.cwd());
|
|
15
23
|
if (!root) throw new PluginError('plugin_manifest_invalid', 'flows add requires a project with flows.json.');
|
|
24
|
+
recoverFlowsAndLock(root);
|
|
16
25
|
const configPath = join(root, 'flows.json');
|
|
17
26
|
const config = JSON.parse(readFileSync(configPath, 'utf8'));
|
|
18
27
|
if (!config || Array.isArray(config) || typeof config !== 'object' || (config.plugins !== undefined && (!Array.isArray(config.plugins) || !config.plugins.every((p: unknown) => typeof p === 'string')))) throw new PluginError('plugin_manifest_invalid', 'Invalid flows.json plugins list.');
|
package/src/cli/build.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { readFile, lstat } from 'node:fs/promises';
|
|
|
2
2
|
import { dirname, extname, join, resolve } from 'node:path';
|
|
3
3
|
import { parse } from 'yaml';
|
|
4
4
|
import { sealBundle, verifyBundle, type BundleFile } from '../bundle.js';
|
|
5
|
+
import { collectBundleExtensions, verifyBundlePluginLock } from '../bundle-extensions.js';
|
|
5
6
|
import { canonicalize } from '../canonical.js';
|
|
6
7
|
import { compileSpec, toKernelSpec } from '../compile.js';
|
|
7
8
|
import { preflight } from '../preflight.js';
|
|
@@ -60,6 +61,7 @@ export async function runBuild(args: BuildArgs, io: CliIo): Promise<0 | 2> {
|
|
|
60
61
|
try {
|
|
61
62
|
if (args.verify) {
|
|
62
63
|
const digest = `sha256:${await verifyBundle(args.value)}`;
|
|
64
|
+
await verifyBundlePluginLock(args.value);
|
|
63
65
|
// `--json` is declared on the verb, not on one of its forms: a verify
|
|
64
66
|
// under it emits the same single object a `--json` consumer parses.
|
|
65
67
|
if (args.json) io.stdout(JSON.stringify({ ok: true, verified: true, bundle: args.value, digest }));
|
|
@@ -107,7 +109,7 @@ export async function buildFlow(path: string, out: string, warn: (line: string)
|
|
|
107
109
|
files.push(...result.files);
|
|
108
110
|
} else if (['.yaml', '.yml'].includes(extname(input))) {
|
|
109
111
|
authoring = compileSpec(parse(await readFile(input, 'utf8')));
|
|
110
|
-
files.push({ path: 'lockfile.json', data: canonicalize({ version:
|
|
112
|
+
files.push({ path: 'lockfile.json', data: canonicalize({ version: 2, plugins: [] }) });
|
|
111
113
|
} else throw new Error('build expects a .yaml, .yml, or .ts flow');
|
|
112
114
|
|
|
113
115
|
// The current preflight API reports uncollected environment facts as
|
|
@@ -148,7 +150,10 @@ export async function buildFlow(path: string, out: string, warn: (line: string)
|
|
|
148
150
|
...(authored ? { dynamicSteps: 'exported spec declaration checked; body was not executed during build' } : {}) },
|
|
149
151
|
}) },
|
|
150
152
|
);
|
|
151
|
-
|
|
153
|
+
const extensions = await collectBundleExtensions(input);
|
|
154
|
+
const bundled = files.filter(file => file.path !== 'lockfile.json');
|
|
155
|
+
bundled.push({ path: 'lockfile.json', data: canonicalize(extensions.lock) }, ...extensions.files);
|
|
156
|
+
return sealBundle({ name: authoring.name ?? 'flow', out, files: bundled,
|
|
152
157
|
repo: await repositoryRoot(directory), warn });
|
|
153
158
|
}
|
|
154
159
|
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { loadAuthoredFlow } from '../authored-flow-loader.js';
|
|
2
|
+
import { PluginError } from '../plugin-manifest.js';
|
|
2
3
|
import { checkSlackHelpers } from '../slack-preflight.js';
|
|
3
4
|
import { inputFailureReport, type CheckExecution } from './check.js';
|
|
4
5
|
|
|
@@ -8,6 +9,9 @@ export async function checkHelperBody(path: string): Promise<CheckExecution> {
|
|
|
8
9
|
const { handle, getDefinition } = await loadAuthoredFlow(path);
|
|
9
10
|
return { report: { ...checkSlackHelpers(getDefinition(handle)), path } };
|
|
10
11
|
} catch (error) {
|
|
12
|
+
if (error instanceof PluginError) {
|
|
13
|
+
return { report: inputFailureReport({ kind: error.code, message: error.message }, path) };
|
|
14
|
+
}
|
|
11
15
|
return { report: inputFailureReport({ kind: 'invalid_spec',
|
|
12
16
|
message: error instanceof Error ? error.message : 'Could not import authored flow.' }, path) };
|
|
13
17
|
}
|