@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
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright 2026 The flow-as-code Authors
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
// Writing a studio export to disk (POST /bridge/export).
|
|
6
|
+
//
|
|
7
|
+
// The studio computes the bytes: it holds the documents, the address map, and
|
|
8
|
+
// the resource map, and it runs the same emitters the CLI runs
|
|
9
|
+
// (@flow-as-code/tf, the @flow-as-code/cdk scaffold, @flow-as-code/core
|
|
10
|
+
// materialization). All that is left here is the half a browser cannot do,
|
|
11
|
+
// and it is deliberately dumb: check, then write.
|
|
12
|
+
//
|
|
13
|
+
// The checks are the same ones every other bridge path gets. Paths are
|
|
14
|
+
// validated by the shared protocol rule and then, independently, by resolving
|
|
15
|
+
// them and requiring the result to be inside the served directory: the first
|
|
16
|
+
// check states the rule, the second is what actually holds if the rule is ever
|
|
17
|
+
// loosened. Nothing is sanitized into shape, because a sanitizer is a guess
|
|
18
|
+
// about intent and this writes files the user owns.
|
|
19
|
+
//
|
|
20
|
+
// Unlike a document write this publishes no event and pairs nothing: emitted
|
|
21
|
+
// terraform and CDK files are outputs, not documents, and the watch engine
|
|
22
|
+
// ignores them (it pairs *.flow.ts with *.flowdoc.json only).
|
|
23
|
+
|
|
24
|
+
import { mkdir, writeFile } from "node:fs/promises";
|
|
25
|
+
import { dirname, resolve, sep } from "node:path";
|
|
26
|
+
|
|
27
|
+
import { BridgeError } from "./pair.js";
|
|
28
|
+
import {
|
|
29
|
+
EXPORT_MAX_BYTES,
|
|
30
|
+
EXPORT_MAX_FILES,
|
|
31
|
+
EXPORT_TARGETS,
|
|
32
|
+
isExportPath,
|
|
33
|
+
isExportSubdir,
|
|
34
|
+
type BridgeExportRequest,
|
|
35
|
+
type BridgeExportResult,
|
|
36
|
+
type ExportTarget,
|
|
37
|
+
} from "./protocol.js";
|
|
38
|
+
|
|
39
|
+
const TARGETS = new Set<string>(EXPORT_TARGETS);
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The absolute path an export file lands on, or undefined when it would leave
|
|
43
|
+
* the served directory. Containment is checked against `dir` itself rather
|
|
44
|
+
* than against `dir/subdir`, so neither half can escape on its own.
|
|
45
|
+
*/
|
|
46
|
+
export function exportPathFor(dir: string, subdir: string, relative: string): string | undefined {
|
|
47
|
+
const base = resolve(dir);
|
|
48
|
+
const target = resolve(base, subdir, relative);
|
|
49
|
+
return target !== base && target.startsWith(base + sep) ? target : undefined;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Validates a request body from the wire. Throws BridgeError with a status. */
|
|
53
|
+
export function checkExportRequest(body: unknown): BridgeExportRequest {
|
|
54
|
+
if (body === null || typeof body !== "object" || Array.isArray(body)) {
|
|
55
|
+
throw new BridgeError(400, "The body must be a JSON object.");
|
|
56
|
+
}
|
|
57
|
+
const request = body as Partial<BridgeExportRequest>;
|
|
58
|
+
if (typeof request.target !== "string" || !TARGETS.has(request.target)) {
|
|
59
|
+
throw new BridgeError(400, `"target" must be one of: ${[...TARGETS].sort().join(", ")}.`);
|
|
60
|
+
}
|
|
61
|
+
const subdir = request.subdir ?? "";
|
|
62
|
+
if (typeof subdir !== "string" || !isExportSubdir(subdir)) {
|
|
63
|
+
throw new BridgeError(400, `"${subdir}" is not a usable export subdirectory.`);
|
|
64
|
+
}
|
|
65
|
+
const files = request.files;
|
|
66
|
+
if (files === null || typeof files !== "object" || Array.isArray(files)) {
|
|
67
|
+
throw new BridgeError(400, '"files" must be a JSON object of path to content.');
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const entries = Object.entries(files as Record<string, unknown>);
|
|
71
|
+
if (entries.length === 0) throw new BridgeError(400, "An export must carry at least one file.");
|
|
72
|
+
if (entries.length > EXPORT_MAX_FILES) {
|
|
73
|
+
throw new BridgeError(413, `An export may carry at most ${String(EXPORT_MAX_FILES)} files.`);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
let bytes = 0;
|
|
77
|
+
for (const [path, content] of entries) {
|
|
78
|
+
if (typeof content !== "string") {
|
|
79
|
+
throw new BridgeError(400, `The content of "${path}" is not a string.`);
|
|
80
|
+
}
|
|
81
|
+
if (!isExportPath(path)) {
|
|
82
|
+
throw new BridgeError(400, `"${path}" is not a usable export path.`);
|
|
83
|
+
}
|
|
84
|
+
bytes += Buffer.byteLength(content, "utf8");
|
|
85
|
+
}
|
|
86
|
+
if (bytes > EXPORT_MAX_BYTES) {
|
|
87
|
+
throw new BridgeError(413, `An export may carry at most ${String(EXPORT_MAX_BYTES)} bytes.`);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return { target: request.target as ExportTarget, files: files as Record<string, string>, subdir };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Writes a checked export under `dir`, creating directories as needed, and
|
|
95
|
+
* returns the absolute paths written, sorted. Existing files are overwritten
|
|
96
|
+
* and nothing else is touched, exactly as `flow-cli emit` behaves.
|
|
97
|
+
*/
|
|
98
|
+
export async function writeExport(
|
|
99
|
+
dir: string,
|
|
100
|
+
request: BridgeExportRequest,
|
|
101
|
+
): Promise<BridgeExportResult> {
|
|
102
|
+
const subdir = request.subdir ?? "";
|
|
103
|
+
const written: string[] = [];
|
|
104
|
+
for (const [relative, content] of Object.entries(request.files).sort(([a], [b]) =>
|
|
105
|
+
a < b ? -1 : a > b ? 1 : 0,
|
|
106
|
+
)) {
|
|
107
|
+
const target = exportPathFor(dir, subdir, relative);
|
|
108
|
+
if (target === undefined) {
|
|
109
|
+
throw new BridgeError(400, `"${relative}" resolves outside the served directory.`);
|
|
110
|
+
}
|
|
111
|
+
await mkdir(dirname(target), { recursive: true });
|
|
112
|
+
await writeFile(target, content, "utf8");
|
|
113
|
+
written.push(target);
|
|
114
|
+
}
|
|
115
|
+
return { target: request.target, paths: written };
|
|
116
|
+
}
|
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright 2026 The flow-as-code Authors
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
// Reading and writing one <name>.flowdoc.json / <name>.flow.ts pair.
|
|
6
|
+
//
|
|
7
|
+
// This is the only place the studio bridge touches disk, and it is deliberately
|
|
8
|
+
// the mirror image of the A04 watch engine: the watcher owns the ts -> doc
|
|
9
|
+
// direction, this module owns doc -> ts.
|
|
10
|
+
//
|
|
11
|
+
// The rule that keeps the two directions from fighting each other is
|
|
12
|
+
// meta.sourceHash. A pair is "in sync" when the doc carries the sha256 of the
|
|
13
|
+
// exact builder source sitting next to it (docs/01-flowdoc-spec.md), which is
|
|
14
|
+
// what the watcher's dirty guard reads. So a canvas save must write BOTH
|
|
15
|
+
// halves and stamp the doc with the hash of the source it just generated;
|
|
16
|
+
// writing only the doc would leave a pair the watcher then reports as dirty
|
|
17
|
+
// in an unknowable direction.
|
|
18
|
+
//
|
|
19
|
+
// Generation goes through @flow-as-code/core's codegen with the existing file passed as
|
|
20
|
+
// options.previous, which is how `// @keep` comments a human added to the
|
|
21
|
+
// generated file survive a canvas edit (packages/core/src/codegen.ts).
|
|
22
|
+
|
|
23
|
+
import { existsSync } from "node:fs";
|
|
24
|
+
import { readFile, readdir, writeFile } from "node:fs/promises";
|
|
25
|
+
import { join, resolve } from "node:path";
|
|
26
|
+
|
|
27
|
+
import { codegen, type FlowDoc } from "@flow-as-code/core";
|
|
28
|
+
|
|
29
|
+
import { flowDocProblems } from "../docs.js";
|
|
30
|
+
import { serializeWithMeta, sha256Hex, synthFile } from "../synth.js";
|
|
31
|
+
import { pickDoc, type FlowWatcher } from "../watch.js";
|
|
32
|
+
import {
|
|
33
|
+
DOC_SUFFIX,
|
|
34
|
+
TS_SUFFIX,
|
|
35
|
+
isBridgeDocName,
|
|
36
|
+
type BridgeDocPayload,
|
|
37
|
+
type BridgeWriteResult,
|
|
38
|
+
} from "./protocol.js";
|
|
39
|
+
|
|
40
|
+
/** A failure with the HTTP status the bridge should answer with. */
|
|
41
|
+
export class BridgeError extends Error {
|
|
42
|
+
readonly status: number;
|
|
43
|
+
|
|
44
|
+
constructor(status: number, message: string) {
|
|
45
|
+
super(message);
|
|
46
|
+
this.name = "BridgeError";
|
|
47
|
+
this.status = status;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Thrown by writePair when the builder file has moved since the document being
|
|
53
|
+
* written was generated from it. Both sides changed, so the write is refused
|
|
54
|
+
* and the caller turns this into the "which side wins" question.
|
|
55
|
+
*/
|
|
56
|
+
export class PairConflict extends Error {
|
|
57
|
+
readonly name = "PairConflict";
|
|
58
|
+
readonly docName: string;
|
|
59
|
+
readonly reason: string;
|
|
60
|
+
|
|
61
|
+
constructor(docName: string, reason: string) {
|
|
62
|
+
super(reason);
|
|
63
|
+
this.docName = docName;
|
|
64
|
+
this.reason = reason;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface PairPaths {
|
|
69
|
+
docPath: string;
|
|
70
|
+
tsPath: string;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Both file paths for a document name. The name is re-checked here rather than
|
|
75
|
+
* only at the route, because this function is what turns a client string into
|
|
76
|
+
* a filesystem path: a slug cannot contain a separator, a dot, or a NUL, so
|
|
77
|
+
* traversal is impossible by construction rather than by sanitizing.
|
|
78
|
+
*/
|
|
79
|
+
export function pairPaths(dir: string, name: string): PairPaths {
|
|
80
|
+
if (!isBridgeDocName(name)) {
|
|
81
|
+
throw new BridgeError(
|
|
82
|
+
400,
|
|
83
|
+
`"${name}" is not a document name: names are lowercase words separated by single hyphens.`,
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
const base = resolve(dir);
|
|
87
|
+
return { docPath: join(base, name + DOC_SUFFIX), tsPath: join(base, name + TS_SUFFIX) };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Every FlowDoc name in the directory, sorted, non-recursive. */
|
|
91
|
+
export async function listDocNames(dir: string): Promise<string[]> {
|
|
92
|
+
let entries: string[];
|
|
93
|
+
try {
|
|
94
|
+
entries = await readdir(resolve(dir));
|
|
95
|
+
} catch {
|
|
96
|
+
throw new BridgeError(500, `Cannot read ${resolve(dir)}.`);
|
|
97
|
+
}
|
|
98
|
+
return entries
|
|
99
|
+
.filter((f) => f.endsWith(DOC_SUFFIX))
|
|
100
|
+
.map((f) => f.slice(0, -DOC_SUFFIX.length))
|
|
101
|
+
.filter((name) => isBridgeDocName(name))
|
|
102
|
+
.sort();
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Parses and schema-validates a FlowDoc, naming what is wrong with it. */
|
|
106
|
+
export function parseDoc(text: string, what: string): FlowDoc {
|
|
107
|
+
let parsed: unknown;
|
|
108
|
+
try {
|
|
109
|
+
parsed = JSON.parse(text);
|
|
110
|
+
} catch (err) {
|
|
111
|
+
throw new BridgeError(
|
|
112
|
+
422,
|
|
113
|
+
`${what} is not JSON: ${err instanceof Error ? err.message : String(err)}`,
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
const problems = flowDocProblems(parsed);
|
|
117
|
+
if (problems.length > 0) {
|
|
118
|
+
throw new BridgeError(422, `${what} is not a valid FlowDoc: ${problems.join("; ")}`);
|
|
119
|
+
}
|
|
120
|
+
return parsed as FlowDoc;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** The document on disk, exactly as the file holds it. */
|
|
124
|
+
export async function readPair(dir: string, name: string): Promise<BridgeDocPayload> {
|
|
125
|
+
const { docPath } = pairPaths(dir, name);
|
|
126
|
+
let text: string;
|
|
127
|
+
try {
|
|
128
|
+
text = await readFile(docPath, "utf8");
|
|
129
|
+
} catch {
|
|
130
|
+
throw new BridgeError(404, `No document named "${name}" in ${resolve(dir)}.`);
|
|
131
|
+
}
|
|
132
|
+
return { name, doc: parseDoc(text, `${name}${DOC_SUFFIX}`), text };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Writes both halves of the pair from a FlowDoc the canvas produced.
|
|
137
|
+
*
|
|
138
|
+
* Order matters and is: generate, hash the generated source, serialize the doc
|
|
139
|
+
* with that hash, write both, then tell the watcher these exact bytes are
|
|
140
|
+
* ours. The watcher is told last because chokidar's awaitWriteFinish window
|
|
141
|
+
* (50 ms) is far longer than the gap, so no change event can be routed against
|
|
142
|
+
* a half-updated ledger, and a write that throws leaves the ledger untouched.
|
|
143
|
+
*/
|
|
144
|
+
export interface WritePairOptions {
|
|
145
|
+
/**
|
|
146
|
+
* Write even though the builder file no longer matches the document's
|
|
147
|
+
* meta.sourceHash. Set only when the user has answered the conflict dialog.
|
|
148
|
+
*/
|
|
149
|
+
force?: boolean;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export async function writePair(
|
|
153
|
+
dir: string,
|
|
154
|
+
name: string,
|
|
155
|
+
doc: FlowDoc,
|
|
156
|
+
watcher?: FlowWatcher,
|
|
157
|
+
options: WritePairOptions = {},
|
|
158
|
+
): Promise<BridgeWriteResult> {
|
|
159
|
+
const { docPath, tsPath } = pairPaths(dir, name);
|
|
160
|
+
if (doc.name !== name) {
|
|
161
|
+
throw new BridgeError(
|
|
162
|
+
400,
|
|
163
|
+
`Refusing to write flow "${doc.name}" as ${name}${DOC_SUFFIX}: the watcher pairs ` +
|
|
164
|
+
`${name}${TS_SUFFIX} with ${name}${DOC_SUFFIX} by name, so the two must agree.`,
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
const problems = flowDocProblems(doc);
|
|
168
|
+
if (problems.length > 0) {
|
|
169
|
+
throw new BridgeError(422, `Refusing to write an invalid FlowDoc: ${problems.join("; ")}`);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Read the current source first: @keep comments live in it and would be
|
|
173
|
+
// deleted by a blind overwrite.
|
|
174
|
+
const previous = existsSync(tsPath) ? await readFile(tsPath, "utf8") : undefined;
|
|
175
|
+
|
|
176
|
+
// The dirty guard, in the doc -> ts direction. The document says which
|
|
177
|
+
// builder source it was generated from; if that file has changed since,
|
|
178
|
+
// regenerating it would throw away an edit nobody has seen. That is the same
|
|
179
|
+
// dirty-both state the watcher detects in the other direction, and it gets
|
|
180
|
+
// the same answer: refuse, and let the user choose.
|
|
181
|
+
//
|
|
182
|
+
// A document with no meta.sourceHash has no provenance to check (it was
|
|
183
|
+
// authored outside this loop), so the write proceeds; @keep comments in the
|
|
184
|
+
// existing source still survive it.
|
|
185
|
+
const provenance = doc.meta?.sourceHash;
|
|
186
|
+
if (options.force !== true && previous !== undefined && typeof provenance === "string") {
|
|
187
|
+
const onDisk = `sha256:${sha256Hex(previous)}`;
|
|
188
|
+
if (onDisk !== provenance) {
|
|
189
|
+
throw new PairConflict(
|
|
190
|
+
name,
|
|
191
|
+
`both sides changed: ${name}${TS_SUFFIX} was edited since this document was ` +
|
|
192
|
+
`generated from it, and saving would overwrite that edit`,
|
|
193
|
+
);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
let tsText: string;
|
|
198
|
+
try {
|
|
199
|
+
tsText = codegen(doc, previous === undefined ? {} : { previous });
|
|
200
|
+
} catch (err) {
|
|
201
|
+
throw new BridgeError(
|
|
202
|
+
422,
|
|
203
|
+
`Cannot generate ${name}${TS_SUFFIX} from this document: ${
|
|
204
|
+
err instanceof Error ? err.message : String(err)
|
|
205
|
+
}`,
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
const text = serializeWithMeta(doc, `sha256:${sha256Hex(tsText)}`);
|
|
209
|
+
|
|
210
|
+
await writeFile(tsPath, tsText, "utf8");
|
|
211
|
+
await writeFile(docPath, text, "utf8");
|
|
212
|
+
watcher?.noteWrite(name, { tsContent: tsText, docContent: text });
|
|
213
|
+
|
|
214
|
+
return { name, doc: JSON.parse(text) as FlowDoc, text, docPath, tsPath, tsText };
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** The FlowDoc the builder file synths to right now. */
|
|
218
|
+
export async function synthPair(dir: string, name: string): Promise<FlowDoc> {
|
|
219
|
+
const { tsPath } = pairPaths(dir, name);
|
|
220
|
+
const { flows } = await synthFile(tsPath);
|
|
221
|
+
const doc = pickDoc(flows, name);
|
|
222
|
+
if (doc === undefined) {
|
|
223
|
+
throw new BridgeError(
|
|
224
|
+
422,
|
|
225
|
+
`${name}${TS_SUFFIX} exports ${String(flows.length)} flows and none is named "${name}".`,
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
return doc;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Resolution in the code direction: the builder file wins, so the FlowDoc is
|
|
233
|
+
* rewritten from it. The source is NOT regenerated, because the user chose the
|
|
234
|
+
* source they have; only the doc moves.
|
|
235
|
+
*/
|
|
236
|
+
export async function adoptCode(
|
|
237
|
+
dir: string,
|
|
238
|
+
name: string,
|
|
239
|
+
watcher?: FlowWatcher,
|
|
240
|
+
): Promise<BridgeDocPayload> {
|
|
241
|
+
const { docPath, tsPath } = pairPaths(dir, name);
|
|
242
|
+
const tsText = await readFile(tsPath, "utf8").catch(() => {
|
|
243
|
+
throw new BridgeError(404, `No builder file named "${name}${TS_SUFFIX}" in ${resolve(dir)}.`);
|
|
244
|
+
});
|
|
245
|
+
const doc = await synthPair(dir, name);
|
|
246
|
+
const text = serializeWithMeta(doc, `sha256:${sha256Hex(tsText)}`);
|
|
247
|
+
await writeFile(docPath, text, "utf8");
|
|
248
|
+
watcher?.noteWrite(name, { tsContent: tsText, docContent: text });
|
|
249
|
+
return { name, doc: JSON.parse(text) as FlowDoc, text };
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** One document whose builder file was written by ensureBuilderFiles. */
|
|
253
|
+
export interface GeneratedBuilderFile {
|
|
254
|
+
name: string;
|
|
255
|
+
tsPath: string;
|
|
256
|
+
/** Exact bytes written, so the caller can seed a watcher's ledger. */
|
|
257
|
+
tsText: string;
|
|
258
|
+
docText: string;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/** One document ensureBuilderFiles could not generate a builder file for. */
|
|
262
|
+
export interface UnbuildableDoc {
|
|
263
|
+
name: string;
|
|
264
|
+
message: string;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
export interface EnsureBuilderFilesResult {
|
|
268
|
+
generated: GeneratedBuilderFile[];
|
|
269
|
+
problems: UnbuildableDoc[];
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Writes `<name>.flow.ts` for every document in `dir` that has none.
|
|
274
|
+
*
|
|
275
|
+
* A directory holding only FlowDocs (an export from a live instance, a doc
|
|
276
|
+
* copied out of conformance/, a file a colleague sent) had no builder file, so
|
|
277
|
+
* the loop the studio is built around - edit the builder file, watch the canvas
|
|
278
|
+
* follow - could not start there: there was nothing to edit. Generating the
|
|
279
|
+
* file on open is safe because codegen is deterministic and the studio already
|
|
280
|
+
* regenerates it on every canvas save, so this writes exactly the bytes the
|
|
281
|
+
* first save would have written.
|
|
282
|
+
*
|
|
283
|
+
* It goes through writePair, which also stamps the document with the
|
|
284
|
+
* meta.sourceHash of the source just generated. Without that stamp the watcher
|
|
285
|
+
* would meet a pair it has never seen in sync and report a conflict on the
|
|
286
|
+
* first edit, which is the state a hand-assembled pair lands in. The bytes
|
|
287
|
+
* come back so the caller can hand them to the watcher's noteWrite as well:
|
|
288
|
+
* the stamp alone leaves the outcome resting on the initial scan winning a
|
|
289
|
+
* race against the user's first edit.
|
|
290
|
+
*
|
|
291
|
+
* A document that cannot be generated from (invalid, or holding something
|
|
292
|
+
* codegen cannot express) is reported and skipped, not thrown: the rest of the
|
|
293
|
+
* directory still opens.
|
|
294
|
+
*/
|
|
295
|
+
export async function ensureBuilderFiles(dir: string): Promise<EnsureBuilderFilesResult> {
|
|
296
|
+
const generated: GeneratedBuilderFile[] = [];
|
|
297
|
+
const problems: UnbuildableDoc[] = [];
|
|
298
|
+
|
|
299
|
+
for (const name of await listDocNames(dir)) {
|
|
300
|
+
const { tsPath } = pairPaths(dir, name);
|
|
301
|
+
if (existsSync(tsPath)) continue;
|
|
302
|
+
try {
|
|
303
|
+
const { doc } = await readPair(dir, name);
|
|
304
|
+
const written = await writePair(dir, name, doc);
|
|
305
|
+
generated.push({ name, tsPath, tsText: written.tsText, docText: written.text });
|
|
306
|
+
} catch (err) {
|
|
307
|
+
problems.push({ name, message: err instanceof Error ? err.message : String(err) });
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
return { generated, problems };
|
|
312
|
+
}
|