@flow-as-code/cli 0.1.0
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/LICENSE +202 -0
- package/README.md +446 -0
- package/dist/aws.d.ts +28 -0
- package/dist/aws.d.ts.map +1 -0
- package/dist/aws.js +104 -0
- package/dist/aws.js.map +1 -0
- package/dist/bin.d.ts +3 -0
- package/dist/bin.d.ts.map +1 -0
- package/dist/bin.js +132 -0
- package/dist/bin.js.map +1 -0
- package/dist/bridge/exportFiles.d.ts +16 -0
- package/dist/bridge/exportFiles.d.ts.map +1 -0
- package/dist/bridge/exportFiles.js +95 -0
- package/dist/bridge/exportFiles.js.map +1 -0
- package/dist/bridge/pair.d.ts +103 -0
- package/dist/bridge/pair.d.ts.map +1 -0
- package/dist/bridge/pair.js +219 -0
- package/dist/bridge/pair.js.map +1 -0
- package/dist/bridge/protocol.d.ts +187 -0
- package/dist/bridge/protocol.d.ts.map +1 -0
- package/dist/bridge/protocol.js +132 -0
- package/dist/bridge/protocol.js.map +1 -0
- package/dist/bridge/server.d.ts +95 -0
- package/dist/bridge/server.d.ts.map +1 -0
- package/dist/bridge/server.js +669 -0
- package/dist/bridge/server.js.map +1 -0
- package/dist/cdk-scaffold.d.ts +16 -0
- package/dist/cdk-scaffold.d.ts.map +1 -0
- package/dist/cdk-scaffold.js +28 -0
- package/dist/cdk-scaffold.js.map +1 -0
- package/dist/codegen.d.ts +5 -0
- package/dist/codegen.d.ts.map +1 -0
- package/dist/codegen.js +27 -0
- package/dist/codegen.js.map +1 -0
- package/dist/diff.d.ts +33 -0
- package/dist/diff.d.ts.map +1 -0
- package/dist/diff.js +225 -0
- package/dist/diff.js.map +1 -0
- package/dist/docs.d.ts +46 -0
- package/dist/docs.d.ts.map +1 -0
- package/dist/docs.js +223 -0
- package/dist/docs.js.map +1 -0
- package/dist/emit.d.ts +8 -0
- package/dist/emit.d.ts.map +1 -0
- package/dist/emit.js +55 -0
- package/dist/emit.js.map +1 -0
- package/dist/errors.d.ts +12 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +25 -0
- package/dist/errors.js.map +1 -0
- package/dist/export.d.ts +27 -0
- package/dist/export.d.ts.map +1 -0
- package/dist/export.js +126 -0
- package/dist/export.js.map +1 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +23 -0
- package/dist/index.js.map +1 -0
- package/dist/lint.d.ts +6 -0
- package/dist/lint.d.ts.map +1 -0
- package/dist/lint.js +31 -0
- package/dist/lint.js.map +1 -0
- package/dist/render.d.ts +6 -0
- package/dist/render.d.ts.map +1 -0
- package/dist/render.js +49 -0
- package/dist/render.js.map +1 -0
- package/dist/run.d.ts +13 -0
- package/dist/run.d.ts.map +1 -0
- package/dist/run.js +38 -0
- package/dist/run.js.map +1 -0
- package/dist/simulate.d.ts +35 -0
- package/dist/simulate.d.ts.map +1 -0
- package/dist/simulate.js +194 -0
- package/dist/simulate.js.map +1 -0
- package/dist/studio.d.ts +23 -0
- package/dist/studio.d.ts.map +1 -0
- package/dist/studio.js +120 -0
- package/dist/studio.js.map +1 -0
- package/dist/synth-resolve-hook.d.ts +19 -0
- package/dist/synth-resolve-hook.d.ts.map +1 -0
- package/dist/synth-resolve-hook.js +127 -0
- package/dist/synth-resolve-hook.js.map +1 -0
- package/dist/synth-runner.d.ts +12 -0
- package/dist/synth-runner.d.ts.map +1 -0
- package/dist/synth-runner.js +145 -0
- package/dist/synth-runner.js.map +1 -0
- package/dist/synth.d.ts +55 -0
- package/dist/synth.d.ts.map +1 -0
- package/dist/synth.js +327 -0
- package/dist/synth.js.map +1 -0
- package/dist/watch.d.ts +66 -0
- package/dist/watch.d.ts.map +1 -0
- package/dist/watch.js +293 -0
- package/dist/watch.js.map +1 -0
- package/package.json +85 -0
- package/schema/flowdoc-0.1.schema.json +669 -0
- package/schema/scenario-0.1.schema.json +318 -0
- package/src/aws.ts +149 -0
- package/src/bin.ts +179 -0
- package/src/bridge/exportFiles.ts +116 -0
- package/src/bridge/pair.ts +312 -0
- package/src/bridge/protocol.ts +289 -0
- package/src/bridge/server.ts +827 -0
- package/src/cdk-scaffold.ts +41 -0
- package/src/codegen.ts +36 -0
- package/src/diff.ts +279 -0
- package/src/docs.ts +249 -0
- package/src/emit.ts +69 -0
- package/src/errors.ts +27 -0
- package/src/export.ts +167 -0
- package/src/index.ts +27 -0
- package/src/lint.ts +42 -0
- package/src/render.ts +58 -0
- package/src/run.ts +42 -0
- package/src/simulate.ts +244 -0
- package/src/studio.ts +143 -0
- package/src/synth-resolve-hook.ts +152 -0
- package/src/synth-runner.ts +157 -0
- package/src/synth.ts +380 -0
- package/src/watch.ts +388 -0
package/src/synth.ts
ADDED
|
@@ -0,0 +1,380 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright 2026 The flow-as-code Authors
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
// `flow-cli synth`: execute a TypeScript builder file in a sandboxed child
|
|
6
|
+
// process and turn its exported flows into FlowDoc JSON.
|
|
7
|
+
//
|
|
8
|
+
// The builder file NEVER runs in the CLI process. It runs in a spawned node
|
|
9
|
+
// with tsx registered via --import, a stripped environment, cwd set to the
|
|
10
|
+
// source file's directory, and (where the runtime supports it) Node's
|
|
11
|
+
// permission model enabled.
|
|
12
|
+
//
|
|
13
|
+
// What the sandbox does and does not enforce, verified on node 25.9.0
|
|
14
|
+
// (https://nodejs.org/api/permissions.html):
|
|
15
|
+
//
|
|
16
|
+
// - File WRITES are denied everywhere except a private temp directory the
|
|
17
|
+
// parent creates for tsx's own cache/pipe files (tsx honors TMPDIR). The
|
|
18
|
+
// child never writes FlowDocs; it prints them on stdout and the parent
|
|
19
|
+
// writes the files.
|
|
20
|
+
// - child_process and cluster are denied (--allow-child-process not passed).
|
|
21
|
+
// - Worker threads ARE allowed (--allow-worker): tsx registers its module
|
|
22
|
+
// hooks on a loader thread, which the permission model counts as a worker.
|
|
23
|
+
// Node warns that this weakens the model; the workers a builder file could
|
|
24
|
+
// start still inherit the same fs restrictions.
|
|
25
|
+
// - File READS are NOT jailed. Scoped --allow-fs-read breaks tsx: its
|
|
26
|
+
// tsconfig discovery walks every ancestor directory and probes
|
|
27
|
+
// case-variant paths on macOS, each of which the permission model treats
|
|
28
|
+
// as a distinct denied resource. We therefore pass --allow-fs-read=* and
|
|
29
|
+
// a builder file can read anything the invoking user can.
|
|
30
|
+
// - NETWORK: the permission model on Node 22/24 does not cover network
|
|
31
|
+
// access at all (https://nodejs.org/api/permissions.html), so no network
|
|
32
|
+
// jail exists there. Newer runtimes (observed on node 25.9.0) deny network
|
|
33
|
+
// by default under --permission because a net permission exists and we do
|
|
34
|
+
// not grant it. Do not rely on the sandbox to block exfiltration on the
|
|
35
|
+
// CI-supported Node 22/24.
|
|
36
|
+
// - On runtimes without the stable --permission flag (Node < 22.13, which
|
|
37
|
+
// includes 22.12, the engines.node floor), the permission model is not
|
|
38
|
+
// applied at all; the sandbox is then only the stripped environment, the
|
|
39
|
+
// pinned cwd, and process isolation. If a spawn still fails with "bad
|
|
40
|
+
// option" we retry once without the permission flags and remember that for
|
|
41
|
+
// the process lifetime.
|
|
42
|
+
|
|
43
|
+
import { spawn } from "node:child_process";
|
|
44
|
+
import { createHash } from "node:crypto";
|
|
45
|
+
import { existsSync, realpathSync } from "node:fs";
|
|
46
|
+
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
|
47
|
+
import { createRequire } from "node:module";
|
|
48
|
+
import { tmpdir } from "node:os";
|
|
49
|
+
import { dirname, join, resolve } from "node:path";
|
|
50
|
+
import { fileURLToPath } from "node:url";
|
|
51
|
+
|
|
52
|
+
import type { FlowDoc } from "@flow-as-code/core";
|
|
53
|
+
import { SLUG_PATTERN, serialize } from "@flow-as-code/core";
|
|
54
|
+
|
|
55
|
+
/** Recorded in meta.generator of every FlowDoc this CLI writes. */
|
|
56
|
+
export const GENERATOR = "cli@0.0.1";
|
|
57
|
+
|
|
58
|
+
const SENTINEL = "__FLOW_CLI_SYNTH_ENVELOPE_V1__";
|
|
59
|
+
|
|
60
|
+
/** A synth failure with a message meant for the terminal. */
|
|
61
|
+
export class SynthError extends Error {}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Where a stack frame points, with the `:line:column` suffix taken off.
|
|
65
|
+
*
|
|
66
|
+
* V8 writes a frame as `at <name> (<location>)` when it has a function name to
|
|
67
|
+
* report and as `at <location>` when it does not, so the location is the last
|
|
68
|
+
* parenthesised run of the line or, failing that, everything after `at `. The
|
|
69
|
+
* result is a path, a `file:` URL, a `node:` specifier, or (for the nameless
|
|
70
|
+
* `at Object.<anonymous>` form) something that is none of those.
|
|
71
|
+
*/
|
|
72
|
+
function frameLocation(line: string): string {
|
|
73
|
+
const parenthesised = /\(([^()]*)\)\s*$/.exec(line)?.[1];
|
|
74
|
+
const location = parenthesised ?? line.replace(/^\s*at\s+/, "");
|
|
75
|
+
return location.replace(/:\d+:\d+$/, "");
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Whether a location has `node_modules` as a whole path segment. */
|
|
79
|
+
function underNodeModules(location: string): boolean {
|
|
80
|
+
// Both separators: a Windows stack reads `at fn (C:\w\node_modules\x\y.js:1:1)`.
|
|
81
|
+
return location.split(/[/\\]/).includes("node_modules");
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* A frame location as one comparable path, or undefined when it names no file.
|
|
86
|
+
*
|
|
87
|
+
* A stack spells the same file three ways: the plain path a compiled frame
|
|
88
|
+
* carries, the `file:` URL the ESM loader produces, and either separator
|
|
89
|
+
* depending on the platform. Symlinks are the fourth: the loader reports the
|
|
90
|
+
* real path, while a caller holding the path the user typed may still be
|
|
91
|
+
* looking at the link (on macOS `/tmp/x` is really `/private/tmp/x`). So both
|
|
92
|
+
* sides go through realpath, and a path that does not resolve (a stack from
|
|
93
|
+
* another machine, a fixture) falls back to itself rather than throwing.
|
|
94
|
+
*/
|
|
95
|
+
function comparablePath(location: string): string | undefined {
|
|
96
|
+
let asPath = location;
|
|
97
|
+
if (/^file:/i.test(asPath)) {
|
|
98
|
+
try {
|
|
99
|
+
asPath = fileURLToPath(asPath);
|
|
100
|
+
} catch {
|
|
101
|
+
return undefined;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
let real = asPath;
|
|
105
|
+
try {
|
|
106
|
+
real = realpathSync(asPath);
|
|
107
|
+
} catch {
|
|
108
|
+
// Not a path on this machine; compare the literal text instead.
|
|
109
|
+
}
|
|
110
|
+
return real.replace(/\\/g, "/");
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* The frames of a thrown value's stack that point at the user's own code.
|
|
115
|
+
*
|
|
116
|
+
* A syntax error in a builder file used to print the actionable line and then
|
|
117
|
+
* ten frames of esbuild and node stream internals under it
|
|
118
|
+
* (`at failureErrorWithLog (.../node_modules/esbuild/lib/main.js:1752:15)`,
|
|
119
|
+
* `at Pipe.onStreamRead (node:internal/stream_base_commons:191:23)`), which buries
|
|
120
|
+
* the file, line and column that the message above already names. The studio's
|
|
121
|
+
* badge shows only the first line for the same reason; the terminal now matches
|
|
122
|
+
* that intent.
|
|
123
|
+
*
|
|
124
|
+
* Frames inside node_modules and node's own internals are dropped, and so are
|
|
125
|
+
* the header lines the stack repeats from the message that is printed above it.
|
|
126
|
+
* A frame in the builder file or anywhere else the user wrote survives, because
|
|
127
|
+
* that is the one a runtime error in a flow needs. There is no verbose flag or
|
|
128
|
+
* env var in this CLI to hide the full stack behind, so what is dropped here is
|
|
129
|
+
* dropped: the retained frames plus the message carry the location.
|
|
130
|
+
*
|
|
131
|
+
* `builderPath` is the file being synthesized. Its own frames are kept even
|
|
132
|
+
* when it sits under a directory named node_modules, which a vendored or
|
|
133
|
+
* linked builder file legitimately does: the node_modules rule is about
|
|
134
|
+
* dependency code, and the file the user asked to run is never that. Without
|
|
135
|
+
* the exemption such a file reports a throw with no line or column at all.
|
|
136
|
+
*/
|
|
137
|
+
export function actionableFrames(stack: string | undefined, builderPath?: string): string[] {
|
|
138
|
+
if (stack === undefined) return [];
|
|
139
|
+
const builder = builderPath === undefined ? undefined : comparablePath(resolve(builderPath));
|
|
140
|
+
return (
|
|
141
|
+
stack
|
|
142
|
+
.split("\n")
|
|
143
|
+
.filter((line) => /^\s*at /.test(line))
|
|
144
|
+
// Vendor frames go by where the frame points, not by the text of the
|
|
145
|
+
// whole line. A substring test over the line drops a frame whose
|
|
146
|
+
// function name happens to read `loadNodeModules` or whose builder file
|
|
147
|
+
// sits in a directory called `node_modules-sandbox`, and those are the
|
|
148
|
+
// frames this function exists to keep. The segment test is what makes
|
|
149
|
+
// `node_modules` mean the dependency tree rather than eleven characters.
|
|
150
|
+
// The builder file itself is exempt: it is the user's own code wherever
|
|
151
|
+
// it lives.
|
|
152
|
+
.filter((line) => {
|
|
153
|
+
const location = frameLocation(line);
|
|
154
|
+
if (!underNodeModules(location)) return true;
|
|
155
|
+
return builder !== undefined && comparablePath(location) === builder;
|
|
156
|
+
})
|
|
157
|
+
// What is left must name a file on disk. That drops node's own frames,
|
|
158
|
+
// whose location is a `node:internal/...` specifier rather than a path
|
|
159
|
+
// (`at addChunk (node:internal/streams/readable:559:12)`), and the bare
|
|
160
|
+
// `at Object.<anonymous>` form that carries no location at all. Neither
|
|
161
|
+
// says anything about the user's flow.
|
|
162
|
+
.filter((line) => /[(\s](\/|[A-Za-z]:\\|file:)/.test(line))
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export interface SynthesizedFlow {
|
|
167
|
+
name: string;
|
|
168
|
+
doc: FlowDoc;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export interface SynthFileResult {
|
|
172
|
+
flows: SynthesizedFlow[];
|
|
173
|
+
/** `sha256:<hex>` of the builder file bytes, ready for meta.sourceHash. */
|
|
174
|
+
sourceHash: string;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export function sha256Hex(bytes: Buffer | string): string {
|
|
178
|
+
return createHash("sha256").update(bytes).digest("hex");
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function resolveSibling(base: string): string {
|
|
182
|
+
// Compiled layout: dist/<base>.js next to dist/synth.js. Under vitest this
|
|
183
|
+
// module runs from src/, where only the .ts exists; the child runs under
|
|
184
|
+
// tsx either way, so both are executable.
|
|
185
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
186
|
+
const js = join(here, `${base}.js`);
|
|
187
|
+
if (existsSync(js)) return js;
|
|
188
|
+
const ts = join(here, `${base}.ts`);
|
|
189
|
+
if (existsSync(ts)) return ts;
|
|
190
|
+
throw new SynthError(`flow-cli is broken: ${base} not found next to ${here}`);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function resolveTsxLoader(): string {
|
|
194
|
+
// tsx's package export "." is dist/loader.mjs, the --import registration.
|
|
195
|
+
return createRequire(import.meta.url).resolve("tsx");
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function supportsStablePermissionFlag(): boolean {
|
|
199
|
+
// --permission is stable from Node 23.5 and was backported to 22.13.
|
|
200
|
+
// https://nodejs.org/api/permissions.html
|
|
201
|
+
const [major = 0, minor = 0] = process.versions.node.split(".").map(Number);
|
|
202
|
+
return major >= 23 || (major === 22 && minor >= 13);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** Set after a sandbox retry so we do not pay the failed spawn twice. */
|
|
206
|
+
let permissionFlagsUnsupported = false;
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* True when the sandbox stopped the child before our runner produced anything.
|
|
210
|
+
*
|
|
211
|
+
* A denial raised by the USER's flow code must not trigger the retry: that
|
|
212
|
+
* would silently drop the sandbox for exactly the input it is protecting
|
|
213
|
+
* against. So this only matches a failure with no envelope on stdout, meaning
|
|
214
|
+
* the runner never got far enough to report.
|
|
215
|
+
*/
|
|
216
|
+
function sandboxRefusedToStart(result: ChildResult): boolean {
|
|
217
|
+
if (parseEnvelope(result.stdout) !== undefined) return false;
|
|
218
|
+
if (result.code === 9 && result.stderr.includes("bad option")) return true;
|
|
219
|
+
return (
|
|
220
|
+
result.stderr.includes("ERR_ACCESS_DENIED") || result.stderr.includes("ERR_DLOPEN_DISABLED")
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
interface ChildResult {
|
|
225
|
+
code: number | null;
|
|
226
|
+
stdout: string;
|
|
227
|
+
stderr: string;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function runChild(args: string[], cwd: string, env: NodeJS.ProcessEnv): Promise<ChildResult> {
|
|
231
|
+
return new Promise((resolveP, rejectP) => {
|
|
232
|
+
const child = spawn(process.execPath, args, { cwd, env, stdio: ["ignore", "pipe", "pipe"] });
|
|
233
|
+
let stdout = "";
|
|
234
|
+
let stderr = "";
|
|
235
|
+
child.stdout.on("data", (d: Buffer) => (stdout += d.toString("utf8")));
|
|
236
|
+
child.stderr.on("data", (d: Buffer) => (stderr += d.toString("utf8")));
|
|
237
|
+
child.on("error", rejectP);
|
|
238
|
+
child.on("close", (code) => resolveP({ code, stdout, stderr }));
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
interface Envelope {
|
|
243
|
+
flows?: { name: string; doc: FlowDoc }[];
|
|
244
|
+
error?: { message: string; stack?: string };
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function parseEnvelope(stdout: string): Envelope | undefined {
|
|
248
|
+
// The envelope is the last sentinel-prefixed line; user code writing to
|
|
249
|
+
// stdout during module evaluation cannot fake or corrupt it earlier.
|
|
250
|
+
const lines = stdout.split("\n");
|
|
251
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
252
|
+
const line = lines[i];
|
|
253
|
+
if (line !== undefined && line.startsWith(SENTINEL)) {
|
|
254
|
+
return JSON.parse(line.slice(SENTINEL.length)) as Envelope;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
return undefined;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Evaluates the builder file in the sandboxed child and returns its flows.
|
|
262
|
+
* Throws SynthError with an actionable message on any failure.
|
|
263
|
+
*/
|
|
264
|
+
export async function synthFile(sourcePath: string): Promise<SynthFileResult> {
|
|
265
|
+
const absPath = resolve(sourcePath);
|
|
266
|
+
let sourceBytes: Buffer;
|
|
267
|
+
try {
|
|
268
|
+
sourceBytes = await readFile(absPath);
|
|
269
|
+
} catch {
|
|
270
|
+
throw new SynthError(
|
|
271
|
+
`Cannot read ${absPath}: no such file. Pass the path of a .flow.ts builder file.`,
|
|
272
|
+
);
|
|
273
|
+
}
|
|
274
|
+
const sourceHash = `sha256:${sha256Hex(sourceBytes)}`;
|
|
275
|
+
|
|
276
|
+
const runner = resolveSibling("synth-runner");
|
|
277
|
+
const resolveHook = resolveSibling("synth-resolve-hook");
|
|
278
|
+
const loader = resolveTsxLoader();
|
|
279
|
+
const sourceDir = dirname(absPath);
|
|
280
|
+
|
|
281
|
+
// Private scratch dir for tsx (it writes a cache and an IPC pipe under
|
|
282
|
+
// os.tmpdir(), which honors TMPDIR/TEMP/TMP). This is the only path the
|
|
283
|
+
// permission model allows the child to write.
|
|
284
|
+
const scratch = await mkdtemp(join(tmpdir(), "flow-cli-synth-"));
|
|
285
|
+
|
|
286
|
+
const permissionArgs =
|
|
287
|
+
supportsStablePermissionFlag() && !permissionFlagsUnsupported
|
|
288
|
+
? ["--permission", "--allow-fs-read=*", `--allow-fs-write=${scratch}/`, "--allow-worker"]
|
|
289
|
+
: [];
|
|
290
|
+
// argv after the runner: the builder file, the resolution hook the runner
|
|
291
|
+
// registers before importing it, and the module URL that hook falls back to
|
|
292
|
+
// when the builder's own directory cannot resolve @flow-as-code/core. See
|
|
293
|
+
// synth-resolve-hook.ts.
|
|
294
|
+
const baseArgs = ["--import", loader, runner, absPath, resolveHook, import.meta.url];
|
|
295
|
+
const env: NodeJS.ProcessEnv = {
|
|
296
|
+
// Minimum the child needs: PATH for anything resolving binaries plus the
|
|
297
|
+
// temp override for tsx. Deliberately no NODE_OPTIONS, no AWS_*, no
|
|
298
|
+
// HOME-derived config.
|
|
299
|
+
PATH: process.env.PATH,
|
|
300
|
+
TMPDIR: scratch,
|
|
301
|
+
TEMP: scratch,
|
|
302
|
+
TMP: scratch,
|
|
303
|
+
};
|
|
304
|
+
|
|
305
|
+
try {
|
|
306
|
+
let result = await runChild([...permissionArgs, ...baseArgs], sourceDir, env);
|
|
307
|
+
if (permissionArgs.length > 0 && sandboxRefusedToStart(result)) {
|
|
308
|
+
// Two ways the permission model can stop the child from ever running our
|
|
309
|
+
// code: the runtime does not know the flags, or the TypeScript loader
|
|
310
|
+
// needs a capability we withheld (some versions shell out to compile).
|
|
311
|
+
// Both are environment problems rather than problems with the user's
|
|
312
|
+
// flow, so we retry unsandboxed rather than failing outright, and say so.
|
|
313
|
+
permissionFlagsUnsupported = true;
|
|
314
|
+
process.emitWarning(
|
|
315
|
+
"flow-cli synth: this runtime's permission model rejected the sandbox, " +
|
|
316
|
+
"so the builder file is being evaluated WITHOUT it. Environment stripping " +
|
|
317
|
+
"and the pinned working directory still apply. Upgrade Node to restore the sandbox.",
|
|
318
|
+
);
|
|
319
|
+
result = await runChild(baseArgs, sourceDir, env);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
const envelope = parseEnvelope(result.stdout);
|
|
323
|
+
if (envelope === undefined) {
|
|
324
|
+
const detail = result.stderr.trim();
|
|
325
|
+
throw new SynthError(
|
|
326
|
+
`Evaluating ${absPath} failed (exit code ${result.code ?? "unknown"}).` +
|
|
327
|
+
(detail === "" ? "" : `\n${detail}`),
|
|
328
|
+
);
|
|
329
|
+
}
|
|
330
|
+
if (envelope.error !== undefined) {
|
|
331
|
+
const frames = actionableFrames(envelope.error.stack, absPath);
|
|
332
|
+
throw new SynthError(
|
|
333
|
+
`Evaluating ${absPath} threw: ${envelope.error.message}` +
|
|
334
|
+
(frames.length === 0 ? "" : `\n${frames.join("\n")}`),
|
|
335
|
+
);
|
|
336
|
+
}
|
|
337
|
+
const flows = envelope.flows ?? [];
|
|
338
|
+
if (flows.length === 0) {
|
|
339
|
+
throw new SynthError(
|
|
340
|
+
`No flows exported from ${absPath}. Export a Flow instance or a zero-argument ` +
|
|
341
|
+
`function returning one (e.g. "export function myLine(): Flow { ... }").`,
|
|
342
|
+
);
|
|
343
|
+
}
|
|
344
|
+
return { flows, sourceHash };
|
|
345
|
+
} finally {
|
|
346
|
+
await rm(scratch, { recursive: true, force: true });
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/** Serializes a doc with the meta this CLI stamps. Byte-stable. */
|
|
351
|
+
export function serializeWithMeta(doc: FlowDoc, sourceHash: string): string {
|
|
352
|
+
return serialize({ ...doc, meta: { generator: GENERATOR, sourceHash } });
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* The `flow-cli synth` command body: synth every flow the file exports and
|
|
357
|
+
* write <flow.name>.flowdoc.json into outDir (default: the source file's
|
|
358
|
+
* directory). Returns the written paths in write order.
|
|
359
|
+
*/
|
|
360
|
+
export async function synthToFiles(sourcePath: string, outDir?: string): Promise<string[]> {
|
|
361
|
+
const absPath = resolve(sourcePath);
|
|
362
|
+
const dir = outDir === undefined ? dirname(absPath) : resolve(outDir);
|
|
363
|
+
const { flows, sourceHash } = await synthFile(absPath);
|
|
364
|
+
await mkdir(dir, { recursive: true });
|
|
365
|
+
const written: string[] = [];
|
|
366
|
+
for (const { name, doc } of flows) {
|
|
367
|
+
// The Flow constructor already rejects non-slug names, but the child
|
|
368
|
+
// process output is untrusted (a hostile module can fake a duck-typed
|
|
369
|
+
// Flow), so the name is re-checked here before it becomes a file path.
|
|
370
|
+
if (!SLUG_PATTERN.test(name)) {
|
|
371
|
+
throw new Error(
|
|
372
|
+
`Refusing to write flow named "${name}": names must be lowercase words separated by single hyphens.`,
|
|
373
|
+
);
|
|
374
|
+
}
|
|
375
|
+
const docPath = join(dir, `${name}.flowdoc.json`);
|
|
376
|
+
await writeFile(docPath, serializeWithMeta(doc, sourceHash), "utf8");
|
|
377
|
+
written.push(docPath);
|
|
378
|
+
}
|
|
379
|
+
return written;
|
|
380
|
+
}
|