@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/studio.ts
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright 2026 The flow-as-code Authors
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
// `flow-cli studio [dir]`: serve the local visual editor over a directory.
|
|
6
|
+
//
|
|
7
|
+
// The command is thin on purpose. Everything it does is start the bridge in
|
|
8
|
+
// src/bridge/server.ts, point it at the studio's built assets, and print the
|
|
9
|
+
// URL; the interesting behaviour is testable there without a subprocess. It
|
|
10
|
+
// asks the bridge for `ensurePairs`, which writes the missing `<name>.flow.ts`
|
|
11
|
+
// for every document in the directory so that a directory of FlowDocs alone
|
|
12
|
+
// can start the edit-the-builder-file loop, and prints what that wrote.
|
|
13
|
+
//
|
|
14
|
+
// The assets are resolved through the studio package's own exports rather than
|
|
15
|
+
// a relative path, because @flow-as-code/cli is published and installed on its
|
|
16
|
+
// own: in a user's node_modules the studio is a sibling package, not a sibling
|
|
17
|
+
// directory. A studio that is installed but not built is the common local
|
|
18
|
+
// failure and gets its own message naming the command that fixes it.
|
|
19
|
+
|
|
20
|
+
import { existsSync } from "node:fs";
|
|
21
|
+
import { createRequire } from "node:module";
|
|
22
|
+
import { basename, dirname, relative, resolve } from "node:path";
|
|
23
|
+
|
|
24
|
+
import { PACKAGE_NAMES } from "@flow-as-code/core";
|
|
25
|
+
|
|
26
|
+
import type { BridgeEvent } from "./bridge/protocol.js";
|
|
27
|
+
import { startStudioServer, type StudioServer } from "./bridge/server.js";
|
|
28
|
+
import { CliError } from "./errors.js";
|
|
29
|
+
|
|
30
|
+
/** The studio's published entry point, resolved through its package exports. */
|
|
31
|
+
const STUDIO_INDEX = `${PACKAGE_NAMES.studio}/dist/index.html`;
|
|
32
|
+
const STUDIO_PACKAGE = `${PACKAGE_NAMES.studio}/package.json`;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Absolute path of the built studio assets. Throws a CliError naming the fix
|
|
36
|
+
* when the package is missing or has not been built.
|
|
37
|
+
*/
|
|
38
|
+
export function resolveStudioAssets(): string {
|
|
39
|
+
const require = createRequire(import.meta.url);
|
|
40
|
+
let packageJson: string;
|
|
41
|
+
try {
|
|
42
|
+
packageJson = require.resolve(STUDIO_PACKAGE);
|
|
43
|
+
} catch {
|
|
44
|
+
throw new CliError(
|
|
45
|
+
`flow-cli studio needs ${PACKAGE_NAMES.studio}, which is not installed. ` +
|
|
46
|
+
`It ships as a dependency of ${PACKAGE_NAMES.cli}, so this usually means a partial install: ` +
|
|
47
|
+
`run "npm install".`,
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
const dist = resolve(dirname(packageJson), "dist");
|
|
51
|
+
if (!existsSync(resolve(dist, "index.html"))) {
|
|
52
|
+
throw new CliError(
|
|
53
|
+
`The studio has not been built: ${resolve(dist, "index.html")} does not exist. ` +
|
|
54
|
+
`Run "npm run build --workspace ${PACKAGE_NAMES.studio}" (or "npm run build" ` +
|
|
55
|
+
`at the repository root) and try again.`,
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
// Resolving the entry through the exports map as well, so a package whose
|
|
59
|
+
// exports stop publishing dist/ fails here rather than serving nothing.
|
|
60
|
+
try {
|
|
61
|
+
require.resolve(STUDIO_INDEX);
|
|
62
|
+
} catch {
|
|
63
|
+
throw new CliError(
|
|
64
|
+
`${PACKAGE_NAMES.studio} does not export ${STUDIO_INDEX.split("/").pop() ?? "dist"}; ` +
|
|
65
|
+
`this flow-cli is not compatible with the installed studio.`,
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
return dist;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export interface StudioOptions {
|
|
72
|
+
/** Commander passes strings; 0 or absent means "pick a free port". */
|
|
73
|
+
port?: string;
|
|
74
|
+
/** Test seam: skip the asset directory (API only). */
|
|
75
|
+
assetsDir?: string;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** One line per bridge event, so the terminal shows the sync happening. */
|
|
79
|
+
export function eventLine(event: BridgeEvent, dir: string): string {
|
|
80
|
+
const where = (path: string): string => relative(dir, path) || path;
|
|
81
|
+
switch (event.kind) {
|
|
82
|
+
case "synced":
|
|
83
|
+
return `synced ${event.name}`;
|
|
84
|
+
case "conflict":
|
|
85
|
+
return `conflict ${event.name}: ${event.reason}`;
|
|
86
|
+
case "error":
|
|
87
|
+
return `error ${where(event.path)}: ${event.message}`;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Starts the studio server and returns it. The caller keeps the process alive;
|
|
93
|
+
* the command body below waits for a signal.
|
|
94
|
+
*/
|
|
95
|
+
export async function runStudio(dir: string | undefined, options: StudioOptions = {}) {
|
|
96
|
+
const target = resolve(dir ?? process.cwd());
|
|
97
|
+
const port = options.port === undefined ? 0 : Number(options.port);
|
|
98
|
+
if (!Number.isInteger(port) || port < 0 || port > 65535) {
|
|
99
|
+
throw new CliError(`--port must be a whole number between 0 and 65535, not "${options.port}".`);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const assetsDir = options.assetsDir ?? resolveStudioAssets();
|
|
103
|
+
let server: StudioServer;
|
|
104
|
+
try {
|
|
105
|
+
server = await startStudioServer({
|
|
106
|
+
dir: target,
|
|
107
|
+
assetsDir,
|
|
108
|
+
port,
|
|
109
|
+
// A directory of FlowDocs with no builder files beside them cannot start
|
|
110
|
+
// the loop this command exists for, because there is nothing to edit.
|
|
111
|
+
ensurePairs: true,
|
|
112
|
+
onEvent: (event) => console.log(eventLine(event, target)),
|
|
113
|
+
});
|
|
114
|
+
} catch (error) {
|
|
115
|
+
throw new CliError(error instanceof Error ? error.message : String(error));
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
for (const { tsPath, name } of server.prepared.generated) {
|
|
119
|
+
console.log(`wrote ${basename(tsPath)} from ${name}.flowdoc.json`);
|
|
120
|
+
}
|
|
121
|
+
for (const { name, message } of server.prepared.problems) {
|
|
122
|
+
console.log(`skipped ${name}.flowdoc.json: ${message}`);
|
|
123
|
+
}
|
|
124
|
+
console.log(`flow-cli studio serving ${target}`);
|
|
125
|
+
console.log(` open ${server.url}`);
|
|
126
|
+
console.log(` the bridge listens on ${server.host} only; press Ctrl+C to stop`);
|
|
127
|
+
return server;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** The command body: run until the terminal interrupts it. */
|
|
131
|
+
export async function studioCommand(
|
|
132
|
+
dir: string | undefined,
|
|
133
|
+
options: StudioOptions,
|
|
134
|
+
): Promise<void> {
|
|
135
|
+
const server = await runStudio(dir, options);
|
|
136
|
+
await new Promise<void>((resolveP) => {
|
|
137
|
+
const stop = () => {
|
|
138
|
+
void server.close().then(() => resolveP());
|
|
139
|
+
};
|
|
140
|
+
process.once("SIGINT", stop);
|
|
141
|
+
process.once("SIGTERM", stop);
|
|
142
|
+
});
|
|
143
|
+
}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright 2026 The flow-as-code Authors
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
// Module-resolution hook for the `flow-cli synth` child process. Two jobs,
|
|
6
|
+
// both aimed at the same thing: a `<name>.flow.ts` sitting in a plain folder
|
|
7
|
+
// of FlowDocs must synth, because that is where `flow-cli codegen` writes it
|
|
8
|
+
// and what `flow-cli studio` re-synths on every save.
|
|
9
|
+
//
|
|
10
|
+
// 1. TypeScript in the builder graph is ESM when nothing says otherwise.
|
|
11
|
+
// Without a package.json above it, the TypeScript loader compiles the file
|
|
12
|
+
// to CommonJS, so its `import { Flow } from "@flow-as-code/core"`
|
|
13
|
+
// becomes a `require` of an ESM-only package, which is not the module
|
|
14
|
+
// system codegen emits for.
|
|
15
|
+
// A directory that does declare a type keeps it: this only fills in a
|
|
16
|
+
// decision nobody made.
|
|
17
|
+
//
|
|
18
|
+
// "Nobody made a decision" is read off the filesystem rather than off the
|
|
19
|
+
// resolver's answer, because runtimes have answered differently for the same
|
|
20
|
+
// plain folder: Node 22 and later return a null `format` for the builder
|
|
21
|
+
// file, while Node 20 returned "commonjs". Trusting a nullish `format` alone
|
|
22
|
+
// therefore fixed the plain-folder case on 22 and left it broken on 20.
|
|
23
|
+
// Node 20 is no longer supported, but the answer is not a contract, so the
|
|
24
|
+
// walk stays: a TypeScript file with no package.json anywhere above it is
|
|
25
|
+
// ESM whatever the resolver said, and one with a package.json above it is
|
|
26
|
+
// left exactly as resolved.
|
|
27
|
+
//
|
|
28
|
+
// 2. @flow-as-code/core resolves from @flow-as-code/cli when it resolves from
|
|
29
|
+
// nowhere else. A folder of FlowDocs has no node_modules, and requiring the
|
|
30
|
+
// user to install @flow-as-code/core beside every such folder to edit a file
|
|
31
|
+
// the CLI generated is not a reasonable price. It is a hard dependency of
|
|
32
|
+
// @flow-as-code/cli, so resolving from the CLI's own module always finds the
|
|
33
|
+
// copy the CLI is running against.
|
|
34
|
+
//
|
|
35
|
+
// The fallback runs after the default resolver, never before it, so a copy
|
|
36
|
+
// installed next to the builder file still wins and version skew stays the
|
|
37
|
+
// user's choice. It is scoped to @flow-as-code/core alone: a builder file
|
|
38
|
+
// cannot use it to reach the rest of the CLI's dependency tree.
|
|
39
|
+
//
|
|
40
|
+
// Nothing about the sandbox changes. The child keeps its stripped environment,
|
|
41
|
+
// its pinned working directory, and the permission flags synth.ts passes; the
|
|
42
|
+
// fallback only reads a file the child could already read under
|
|
43
|
+
// `--allow-fs-read=*`. See SECURITY.md.
|
|
44
|
+
//
|
|
45
|
+
// Loaded on the module-customization hooks thread by `module.register`
|
|
46
|
+
// (https://nodejs.org/api/module.html#customization-hooks), which is why the
|
|
47
|
+
// fallback target arrives through `initialize` rather than a module-level
|
|
48
|
+
// import.
|
|
49
|
+
|
|
50
|
+
import { existsSync } from "node:fs";
|
|
51
|
+
import { dirname, join } from "node:path";
|
|
52
|
+
import { fileURLToPath } from "node:url";
|
|
53
|
+
|
|
54
|
+
/** Module URL whose resolution scope holds the CLI's own @flow-as-code/core. */
|
|
55
|
+
let fallbackParentURL: string | undefined;
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* @flow-as-code/core's package name, from PACKAGE_NAMES rather than a literal: the npm
|
|
59
|
+
* scope is written down in exactly one module (packages/core/src/package-names.ts)
|
|
60
|
+
* and this file runs on a thread that should not import it to find out.
|
|
61
|
+
*/
|
|
62
|
+
let flowCorePackage: string | undefined;
|
|
63
|
+
|
|
64
|
+
export interface HookData {
|
|
65
|
+
fallbackParentURL?: string;
|
|
66
|
+
flowCorePackage?: string;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function initialize(data: HookData | undefined): void {
|
|
70
|
+
fallbackParentURL = data?.fallbackParentURL;
|
|
71
|
+
flowCorePackage = data?.flowCorePackage;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
interface ResolveContext {
|
|
75
|
+
parentURL?: string;
|
|
76
|
+
conditions?: readonly string[];
|
|
77
|
+
importAttributes?: Record<string, string>;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
interface Resolution {
|
|
81
|
+
url: string;
|
|
82
|
+
format?: string | null;
|
|
83
|
+
shortCircuit?: boolean;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
type NextResolve = (specifier: string, context: ResolveContext) => Promise<Resolution>;
|
|
87
|
+
|
|
88
|
+
function isFlowCore(specifier: string): boolean {
|
|
89
|
+
if (flowCorePackage === undefined) return false;
|
|
90
|
+
return specifier === flowCorePackage || specifier.startsWith(`${flowCorePackage}/`);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function isTypeScript(url: string): boolean {
|
|
94
|
+
return url.startsWith("file:") && (url.endsWith(".ts") || url.endsWith(".tsx"));
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* True when no package.json exists in the file's directory or any directory
|
|
99
|
+
* above it, so nothing has declared a module type for it. This is the same walk
|
|
100
|
+
* node itself does to answer that question
|
|
101
|
+
* (https://nodejs.org/api/packages.html#determining-module-system); doing it
|
|
102
|
+
* here is what makes the answer the same whatever the resolver reports.
|
|
103
|
+
*/
|
|
104
|
+
function nothingDeclaredATypeFor(url: string): boolean {
|
|
105
|
+
let dir: string;
|
|
106
|
+
try {
|
|
107
|
+
dir = dirname(fileURLToPath(url));
|
|
108
|
+
} catch {
|
|
109
|
+
return false;
|
|
110
|
+
}
|
|
111
|
+
for (;;) {
|
|
112
|
+
if (existsSync(join(dir, "package.json"))) return false;
|
|
113
|
+
const parent = dirname(dir);
|
|
114
|
+
if (parent === dir) return true;
|
|
115
|
+
dir = parent;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export async function resolve(
|
|
120
|
+
specifier: string,
|
|
121
|
+
context: ResolveContext,
|
|
122
|
+
nextResolve: NextResolve,
|
|
123
|
+
): Promise<Resolution> {
|
|
124
|
+
let resolution: Resolution;
|
|
125
|
+
try {
|
|
126
|
+
resolution = await nextResolve(specifier, context);
|
|
127
|
+
} catch (error) {
|
|
128
|
+
// Only a specifier that could not be found at all, and only
|
|
129
|
+
// @flow-as-code/core: an export map violation or a syntax error in the
|
|
130
|
+
// installed copy is the user's problem to see, not something to paper
|
|
131
|
+
// over with a second copy.
|
|
132
|
+
if (fallbackParentURL === undefined) throw error;
|
|
133
|
+
if (!isFlowCore(specifier)) throw error;
|
|
134
|
+
if ((error as NodeJS.ErrnoException).code !== "ERR_MODULE_NOT_FOUND") throw error;
|
|
135
|
+
resolution = await nextResolve(specifier, { ...context, parentURL: fallbackParentURL });
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// A nullish `format` (Node 22 and later) or a defaulted "commonjs" (the
|
|
139
|
+
// answer Node 20 gave) on a TypeScript file with no package.json above it is
|
|
140
|
+
// the plain-folder case described above, and nothing else. Any other format
|
|
141
|
+
// is a decision somebody made and is left alone.
|
|
142
|
+
if (
|
|
143
|
+
isTypeScript(resolution.url) &&
|
|
144
|
+
(resolution.format === null ||
|
|
145
|
+
resolution.format === undefined ||
|
|
146
|
+
resolution.format === "commonjs") &&
|
|
147
|
+
nothingDeclaredATypeFor(resolution.url)
|
|
148
|
+
) {
|
|
149
|
+
return { ...resolution, format: "module" };
|
|
150
|
+
}
|
|
151
|
+
return resolution;
|
|
152
|
+
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright 2026 The flow-as-code Authors
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
// Child-process entry point for `flow-cli synth`. Never imported by the
|
|
6
|
+
// parent: it is spawned (see synth.ts) so that evaluating an untrusted
|
|
7
|
+
// builder file cannot touch the CLI process. Runs under tsx (--import), so
|
|
8
|
+
// this file works both compiled (dist/synth-runner.js) and straight from
|
|
9
|
+
// source in tests (src/synth-runner.ts).
|
|
10
|
+
//
|
|
11
|
+
// Contract with the parent: argv[2] is the absolute path of the builder file,
|
|
12
|
+
// argv[3] the path of the resolution hook module, and argv[4] the module URL
|
|
13
|
+
// that hook resolves @flow-as-code/core from when the builder file's own directory
|
|
14
|
+
// cannot. The runner prints exactly one envelope line to stdout, prefixed with
|
|
15
|
+
// SYNTH_ENVELOPE_SENTINEL, so user code writing to stdout during module
|
|
16
|
+
// evaluation cannot corrupt the channel. Everything else on stdout is ignored
|
|
17
|
+
// by the parent.
|
|
18
|
+
|
|
19
|
+
import { register } from "node:module";
|
|
20
|
+
import { pathToFileURL } from "node:url";
|
|
21
|
+
|
|
22
|
+
import type { Flow } from "@flow-as-code/core";
|
|
23
|
+
import { PACKAGE_NAMES, synth } from "@flow-as-code/core";
|
|
24
|
+
|
|
25
|
+
export const SYNTH_ENVELOPE_SENTINEL = "__FLOW_CLI_SYNTH_ENVELOPE_V1__";
|
|
26
|
+
|
|
27
|
+
export interface SynthEnvelope {
|
|
28
|
+
flows?: { name: string; doc: unknown }[];
|
|
29
|
+
error?: { message: string; stack?: string };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Structural check instead of `instanceof Flow`: the builder file may resolve
|
|
34
|
+
* its own copy of @flow-as-code/core (dual-package or version skew), and a Flow from
|
|
35
|
+
* that copy is still a Flow for our purposes.
|
|
36
|
+
*/
|
|
37
|
+
function isFlowLike(value: unknown): value is Flow {
|
|
38
|
+
if (typeof value !== "object" || value === null) return false;
|
|
39
|
+
const v = value as Record<string, unknown>;
|
|
40
|
+
return (
|
|
41
|
+
typeof v.name === "string" &&
|
|
42
|
+
(v.kind === "flow" || v.kind === "module") &&
|
|
43
|
+
typeof v.all === "function" &&
|
|
44
|
+
typeof v.startId === "function"
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Convention: every exported Flow instance, plus the result of every exported
|
|
50
|
+
* zero-argument function that returns one. Functions that throw when called
|
|
51
|
+
* (including classes, which throw without `new`) are skipped; a throw during
|
|
52
|
+
* module evaluation itself is a hard error and is reported by main().
|
|
53
|
+
*/
|
|
54
|
+
function collectFlows(mod: Record<string, unknown>): Flow[] {
|
|
55
|
+
const flows: Flow[] = [];
|
|
56
|
+
const seen = new Set<unknown>();
|
|
57
|
+
const push = (f: Flow) => {
|
|
58
|
+
if (!seen.has(f)) {
|
|
59
|
+
seen.add(f);
|
|
60
|
+
flows.push(f);
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
for (const value of Object.values(mod)) {
|
|
64
|
+
if (isFlowLike(value)) {
|
|
65
|
+
push(value);
|
|
66
|
+
} else if (typeof value === "function" && value.length === 0) {
|
|
67
|
+
let result: unknown;
|
|
68
|
+
try {
|
|
69
|
+
result = (value as () => unknown)();
|
|
70
|
+
} catch {
|
|
71
|
+
continue; // not a flow factory (e.g. a class, or a helper that needs setup)
|
|
72
|
+
}
|
|
73
|
+
if (isFlowLike(result)) push(result);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return flows;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function emit(envelope: SynthEnvelope): void {
|
|
80
|
+
process.stdout.write("\n" + SYNTH_ENVELOPE_SENTINEL + JSON.stringify(envelope) + "\n");
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Registers the @flow-as-code/core resolution fallback before the builder file is
|
|
85
|
+
* imported. See synth-resolve-hook.ts for what it does and why it is safe.
|
|
86
|
+
*
|
|
87
|
+
* `module.register` is the only registration API present on every Node this
|
|
88
|
+
* package supports (`registerHooks` landed in 22.15). Its deprecation warning
|
|
89
|
+
* on newer runtimes would otherwise reach the user inside a synth failure
|
|
90
|
+
* message, which is why it is silenced for the length of the call and no
|
|
91
|
+
* longer.
|
|
92
|
+
*/
|
|
93
|
+
function registerResolveFallback(hookPath: string, fallbackParentURL: string): void {
|
|
94
|
+
const { noDeprecation } = process;
|
|
95
|
+
process.noDeprecation = true;
|
|
96
|
+
try {
|
|
97
|
+
register(pathToFileURL(hookPath).href, {
|
|
98
|
+
parentURL: import.meta.url,
|
|
99
|
+
data: { fallbackParentURL, flowCorePackage: PACKAGE_NAMES.core },
|
|
100
|
+
});
|
|
101
|
+
} finally {
|
|
102
|
+
process.noDeprecation = noDeprecation;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async function main(): Promise<void> {
|
|
107
|
+
const sourcePath = process.argv[2];
|
|
108
|
+
if (sourcePath === undefined) {
|
|
109
|
+
emit({ error: { message: "synth-runner: missing source file argument" } });
|
|
110
|
+
process.exitCode = 1;
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
const hookPath = process.argv[3];
|
|
114
|
+
const fallbackParentURL = process.argv[4];
|
|
115
|
+
if (hookPath !== undefined && fallbackParentURL !== undefined) {
|
|
116
|
+
registerResolveFallback(hookPath, fallbackParentURL);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
let mod: Record<string, unknown>;
|
|
120
|
+
try {
|
|
121
|
+
mod = (await import(pathToFileURL(sourcePath).href)) as Record<string, unknown>;
|
|
122
|
+
} catch (e) {
|
|
123
|
+
const err = e instanceof Error ? e : new Error(String(e));
|
|
124
|
+
emit({ error: { message: err.message, stack: err.stack } });
|
|
125
|
+
process.exitCode = 1;
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const flows = collectFlows(mod);
|
|
130
|
+
const byName = new Map<string, Flow>();
|
|
131
|
+
for (const flow of flows) {
|
|
132
|
+
if (byName.has(flow.name)) {
|
|
133
|
+
emit({
|
|
134
|
+
error: {
|
|
135
|
+
message: `Two exported flows share the name "${flow.name}". Flow names must be unique per file because each becomes <name>.flowdoc.json.`,
|
|
136
|
+
},
|
|
137
|
+
});
|
|
138
|
+
process.exitCode = 1;
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
byName.set(flow.name, flow);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
try {
|
|
145
|
+
// meta is stamped by the parent (generator + sourceHash of the file it
|
|
146
|
+
// actually read), so the runner synths without one.
|
|
147
|
+
emit({
|
|
148
|
+
flows: flows.map((f) => ({ name: f.name, doc: synth(f, { includeMeta: false }) })),
|
|
149
|
+
});
|
|
150
|
+
} catch (e) {
|
|
151
|
+
const err = e instanceof Error ? e : new Error(String(e));
|
|
152
|
+
emit({ error: { message: err.message, stack: err.stack } });
|
|
153
|
+
process.exitCode = 1;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
void main();
|