@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.
Files changed (120) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +446 -0
  3. package/dist/aws.d.ts +28 -0
  4. package/dist/aws.d.ts.map +1 -0
  5. package/dist/aws.js +104 -0
  6. package/dist/aws.js.map +1 -0
  7. package/dist/bin.d.ts +3 -0
  8. package/dist/bin.d.ts.map +1 -0
  9. package/dist/bin.js +132 -0
  10. package/dist/bin.js.map +1 -0
  11. package/dist/bridge/exportFiles.d.ts +16 -0
  12. package/dist/bridge/exportFiles.d.ts.map +1 -0
  13. package/dist/bridge/exportFiles.js +95 -0
  14. package/dist/bridge/exportFiles.js.map +1 -0
  15. package/dist/bridge/pair.d.ts +103 -0
  16. package/dist/bridge/pair.d.ts.map +1 -0
  17. package/dist/bridge/pair.js +219 -0
  18. package/dist/bridge/pair.js.map +1 -0
  19. package/dist/bridge/protocol.d.ts +187 -0
  20. package/dist/bridge/protocol.d.ts.map +1 -0
  21. package/dist/bridge/protocol.js +132 -0
  22. package/dist/bridge/protocol.js.map +1 -0
  23. package/dist/bridge/server.d.ts +95 -0
  24. package/dist/bridge/server.d.ts.map +1 -0
  25. package/dist/bridge/server.js +669 -0
  26. package/dist/bridge/server.js.map +1 -0
  27. package/dist/cdk-scaffold.d.ts +16 -0
  28. package/dist/cdk-scaffold.d.ts.map +1 -0
  29. package/dist/cdk-scaffold.js +28 -0
  30. package/dist/cdk-scaffold.js.map +1 -0
  31. package/dist/codegen.d.ts +5 -0
  32. package/dist/codegen.d.ts.map +1 -0
  33. package/dist/codegen.js +27 -0
  34. package/dist/codegen.js.map +1 -0
  35. package/dist/diff.d.ts +33 -0
  36. package/dist/diff.d.ts.map +1 -0
  37. package/dist/diff.js +225 -0
  38. package/dist/diff.js.map +1 -0
  39. package/dist/docs.d.ts +46 -0
  40. package/dist/docs.d.ts.map +1 -0
  41. package/dist/docs.js +223 -0
  42. package/dist/docs.js.map +1 -0
  43. package/dist/emit.d.ts +8 -0
  44. package/dist/emit.d.ts.map +1 -0
  45. package/dist/emit.js +55 -0
  46. package/dist/emit.js.map +1 -0
  47. package/dist/errors.d.ts +12 -0
  48. package/dist/errors.d.ts.map +1 -0
  49. package/dist/errors.js +25 -0
  50. package/dist/errors.js.map +1 -0
  51. package/dist/export.d.ts +27 -0
  52. package/dist/export.d.ts.map +1 -0
  53. package/dist/export.js +126 -0
  54. package/dist/export.js.map +1 -0
  55. package/dist/index.d.ts +4 -0
  56. package/dist/index.d.ts.map +1 -0
  57. package/dist/index.js +23 -0
  58. package/dist/index.js.map +1 -0
  59. package/dist/lint.d.ts +6 -0
  60. package/dist/lint.d.ts.map +1 -0
  61. package/dist/lint.js +31 -0
  62. package/dist/lint.js.map +1 -0
  63. package/dist/render.d.ts +6 -0
  64. package/dist/render.d.ts.map +1 -0
  65. package/dist/render.js +49 -0
  66. package/dist/render.js.map +1 -0
  67. package/dist/run.d.ts +13 -0
  68. package/dist/run.d.ts.map +1 -0
  69. package/dist/run.js +38 -0
  70. package/dist/run.js.map +1 -0
  71. package/dist/simulate.d.ts +35 -0
  72. package/dist/simulate.d.ts.map +1 -0
  73. package/dist/simulate.js +194 -0
  74. package/dist/simulate.js.map +1 -0
  75. package/dist/studio.d.ts +23 -0
  76. package/dist/studio.d.ts.map +1 -0
  77. package/dist/studio.js +120 -0
  78. package/dist/studio.js.map +1 -0
  79. package/dist/synth-resolve-hook.d.ts +19 -0
  80. package/dist/synth-resolve-hook.d.ts.map +1 -0
  81. package/dist/synth-resolve-hook.js +127 -0
  82. package/dist/synth-resolve-hook.js.map +1 -0
  83. package/dist/synth-runner.d.ts +12 -0
  84. package/dist/synth-runner.d.ts.map +1 -0
  85. package/dist/synth-runner.js +145 -0
  86. package/dist/synth-runner.js.map +1 -0
  87. package/dist/synth.d.ts +55 -0
  88. package/dist/synth.d.ts.map +1 -0
  89. package/dist/synth.js +327 -0
  90. package/dist/synth.js.map +1 -0
  91. package/dist/watch.d.ts +66 -0
  92. package/dist/watch.d.ts.map +1 -0
  93. package/dist/watch.js +293 -0
  94. package/dist/watch.js.map +1 -0
  95. package/package.json +85 -0
  96. package/schema/flowdoc-0.1.schema.json +669 -0
  97. package/schema/scenario-0.1.schema.json +318 -0
  98. package/src/aws.ts +149 -0
  99. package/src/bin.ts +179 -0
  100. package/src/bridge/exportFiles.ts +116 -0
  101. package/src/bridge/pair.ts +312 -0
  102. package/src/bridge/protocol.ts +289 -0
  103. package/src/bridge/server.ts +827 -0
  104. package/src/cdk-scaffold.ts +41 -0
  105. package/src/codegen.ts +36 -0
  106. package/src/diff.ts +279 -0
  107. package/src/docs.ts +249 -0
  108. package/src/emit.ts +69 -0
  109. package/src/errors.ts +27 -0
  110. package/src/export.ts +167 -0
  111. package/src/index.ts +27 -0
  112. package/src/lint.ts +42 -0
  113. package/src/render.ts +58 -0
  114. package/src/run.ts +42 -0
  115. package/src/simulate.ts +244 -0
  116. package/src/studio.ts +143 -0
  117. package/src/synth-resolve-hook.ts +152 -0
  118. package/src/synth-runner.ts +157 -0
  119. package/src/synth.ts +380 -0
  120. 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
+ }