@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,41 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright 2026 The flow-as-code Authors
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
// The `emit --target cdk` scaffold, from the CLI's side.
|
|
6
|
+
//
|
|
7
|
+
// The generator itself lives in @flow-as-code/cdk/scaffold, because the
|
|
8
|
+
// studio's CDK export button has to produce the same bytes and the studio
|
|
9
|
+
// cannot import this package (@flow-as-code/cli depends on
|
|
10
|
+
// @flow-as-code/studio for its assets, so the reverse import would be a
|
|
11
|
+
// cycle). What is left here is the one thing that needs a filesystem view of
|
|
12
|
+
// the world: turning an output directory and a docs directory into the
|
|
13
|
+
// relative FLOW_DOCS expression.
|
|
14
|
+
|
|
15
|
+
import { relative, resolve, sep } from "node:path";
|
|
16
|
+
|
|
17
|
+
import { type CdkScaffoldInput, cdkScaffold } from "@flow-as-code/cdk/scaffold";
|
|
18
|
+
import type { FlowDoc } from "@flow-as-code/core";
|
|
19
|
+
|
|
20
|
+
export { CDK_SCAFFOLD_FILE, cdkScaffold, referencedNames } from "@flow-as-code/cdk/scaffold";
|
|
21
|
+
export type { CdkScaffoldInput };
|
|
22
|
+
|
|
23
|
+
/** `./flows`, `../flows`, or `.`: POSIX, always relative, always prefixed. */
|
|
24
|
+
export function sourceExpression(outDir: string, docsDir: string): string {
|
|
25
|
+
const rel = relative(resolve(outDir), resolve(docsDir)).split(sep).join("/");
|
|
26
|
+
if (rel === "") return ".";
|
|
27
|
+
return rel.startsWith(".") ? rel : `./${rel}`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface CdkScaffoldForDirs {
|
|
31
|
+
docs: readonly FlowDoc[];
|
|
32
|
+
/** Directory the scaffold is written to. */
|
|
33
|
+
outDir: string;
|
|
34
|
+
/** Directory holding the `*.flowdoc.json` files FlowSet will read. */
|
|
35
|
+
docsDir: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** The scaffold for a pair of real directories. */
|
|
39
|
+
export function cdkScaffoldForDirs({ docs, outDir, docsDir }: CdkScaffoldForDirs): string {
|
|
40
|
+
return cdkScaffold({ docs, source: sourceExpression(outDir, docsDir) });
|
|
41
|
+
}
|
package/src/codegen.ts
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright 2026 The flow-as-code Authors
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
// `flow-cli codegen <doc.flowdoc.json>`: FlowDoc in, typed builder TypeScript
|
|
6
|
+
// out. The inverse of `flow-cli synth`, and named to match: synth writes
|
|
7
|
+
// `<flow.name>.flowdoc.json`, so codegen writes `<doc.name>.flow.ts` next to
|
|
8
|
+
// its input unless `--out` says otherwise.
|
|
9
|
+
//
|
|
10
|
+
// When the output file already exists its current text is passed as
|
|
11
|
+
// `options.previous`, which is how `@keep` comments survive regeneration
|
|
12
|
+
// (packages/core/src/codegen.ts). Overwriting without reading first would
|
|
13
|
+
// silently delete the one thing in a generated file a human is invited to own.
|
|
14
|
+
|
|
15
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
16
|
+
import { dirname, join, resolve } from "node:path";
|
|
17
|
+
|
|
18
|
+
import { codegen } from "@flow-as-code/core";
|
|
19
|
+
|
|
20
|
+
import { loadOneDoc } from "./docs.js";
|
|
21
|
+
|
|
22
|
+
export interface CodegenOptions {
|
|
23
|
+
out?: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function runCodegen(file: string, options: CodegenOptions): string {
|
|
27
|
+
const { path, doc } = loadOneDoc(file, "codegen");
|
|
28
|
+
const target = resolve(options.out ?? join(dirname(path), `${doc.name}.flow.ts`));
|
|
29
|
+
|
|
30
|
+
const previous = existsSync(target) ? readFileSync(target, "utf8") : undefined;
|
|
31
|
+
const source = codegen(doc, previous === undefined ? {} : { previous });
|
|
32
|
+
|
|
33
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
34
|
+
writeFileSync(target, source, "utf8");
|
|
35
|
+
return target;
|
|
36
|
+
}
|
package/src/diff.ts
ADDED
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright 2026 The flow-as-code Authors
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
// `flow-cli diff <dir> --instance <arn>`.
|
|
6
|
+
//
|
|
7
|
+
// For every FlowDoc in <dir>, finds the live flow or module of the same kind
|
|
8
|
+
// and name, exports it to FlowDoc through the same exporter `export` uses (so
|
|
9
|
+
// the live side carries tokens, not ARNs, and the same slug names), and
|
|
10
|
+
// compares the two canonical serializations with `layout` and `meta` removed.
|
|
11
|
+
// Positions and provenance are not differences anyone deploys.
|
|
12
|
+
//
|
|
13
|
+
// Output is one line per local document, in path order, and a unified diff of
|
|
14
|
+
// the canonical JSON under each changed one, so the same two states always
|
|
15
|
+
// print the same bytes. Live flows with no local document are not reported:
|
|
16
|
+
// the question is whether what is checked in matches what is deployed.
|
|
17
|
+
//
|
|
18
|
+
// A live flow that has never been published has no published content to
|
|
19
|
+
// compare; the exporter reads its saved content through the `$SAVED` alias
|
|
20
|
+
// (packages/core/src/export.ts, savedFallback), and this command says
|
|
21
|
+
// so on the status line, `unchanged ($SAVED)`, and in the diff label,
|
|
22
|
+
// `live/<name>:$SAVED`. "Unchanged" against a draft is not "deployed".
|
|
23
|
+
//
|
|
24
|
+
// Exit codes are three-way so a script can tell "differs" from "could not
|
|
25
|
+
// tell": 0 when nothing differs, 1 when any document is changed or has no live
|
|
26
|
+
// counterpart, 2 when the comparison itself failed (bad arguments, no SDK,
|
|
27
|
+
// the instance unreadable, or a matched live flow that cannot be exported).
|
|
28
|
+
|
|
29
|
+
import { relative } from "node:path";
|
|
30
|
+
|
|
31
|
+
import type { ExportedFlow, ExportFailure, FlowDoc } from "@flow-as-code/core";
|
|
32
|
+
import { exportInstance, normalizeArn, serialize } from "@flow-as-code/core";
|
|
33
|
+
|
|
34
|
+
import { type LiveClients, parseInstanceArn, SDK_CLIENTS } from "./aws.js";
|
|
35
|
+
import { loadDocs } from "./docs.js";
|
|
36
|
+
import { CliError, messageOf } from "./errors.js";
|
|
37
|
+
|
|
38
|
+
export interface DiffOptions {
|
|
39
|
+
instance: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export type DiffStatus = "unchanged" | "changed" | "missing-live" | "error";
|
|
43
|
+
|
|
44
|
+
export interface DiffEntry {
|
|
45
|
+
/** Path of the local document, relative to the working directory. */
|
|
46
|
+
path: string;
|
|
47
|
+
name: string;
|
|
48
|
+
kind: FlowDoc["kind"];
|
|
49
|
+
status: DiffStatus;
|
|
50
|
+
/** Unified diff for `changed`, the exporter's reason for `error`. */
|
|
51
|
+
detail?: string;
|
|
52
|
+
/**
|
|
53
|
+
* True when the live twin has never been published and its saved content
|
|
54
|
+
* was compared instead (read through the `$SAVED` alias).
|
|
55
|
+
*/
|
|
56
|
+
saved?: boolean;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** The marker on a status line and a diff label for a `$SAVED` twin. */
|
|
60
|
+
export const SAVED_MARKER = "$SAVED";
|
|
61
|
+
|
|
62
|
+
/** Exit code for a failure of the comparison itself. */
|
|
63
|
+
export const EXIT_DIFF_ERROR = 2;
|
|
64
|
+
|
|
65
|
+
/** Lines of the canonical text with layout and meta removed. */
|
|
66
|
+
export function comparableLines(doc: FlowDoc): string[] {
|
|
67
|
+
const { layout: _layout, meta: _meta, ...rest } = doc;
|
|
68
|
+
const lines = serialize(rest as FlowDoc).split("\n");
|
|
69
|
+
if (lines.at(-1) === "") lines.pop();
|
|
70
|
+
return lines;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// --- Unified diff ------------------------------------------------------------
|
|
74
|
+
// A plain longest-common-subsequence over lines, common prefix and suffix
|
|
75
|
+
// trimmed first. FlowDocs are small, but the table is quadratic, so a middle
|
|
76
|
+
// larger than MAX_CELLS is reported as wholly replaced rather than aligned.
|
|
77
|
+
|
|
78
|
+
/** Cap on the LCS table. 1e6 cells is 4 MB in a Uint32Array. */
|
|
79
|
+
const MAX_CELLS = 1_000_000;
|
|
80
|
+
|
|
81
|
+
/** Context lines on each side of a change. */
|
|
82
|
+
const CONTEXT = 3;
|
|
83
|
+
|
|
84
|
+
interface Edit {
|
|
85
|
+
kind: " " | "-" | "+";
|
|
86
|
+
text: string;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function align(left: string[], right: string[]): Edit[] {
|
|
90
|
+
const edits: Edit[] = [];
|
|
91
|
+
let start = 0;
|
|
92
|
+
while (start < left.length && start < right.length && left[start] === right[start]) {
|
|
93
|
+
edits.push({ kind: " ", text: left[start]! });
|
|
94
|
+
start++;
|
|
95
|
+
}
|
|
96
|
+
let endL = left.length;
|
|
97
|
+
let endR = right.length;
|
|
98
|
+
const suffix: Edit[] = [];
|
|
99
|
+
while (endL > start && endR > start && left[endL - 1] === right[endR - 1]) {
|
|
100
|
+
endL--;
|
|
101
|
+
endR--;
|
|
102
|
+
suffix.unshift({ kind: " ", text: left[endL]! });
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const a = left.slice(start, endL);
|
|
106
|
+
const b = right.slice(start, endR);
|
|
107
|
+
if (a.length === 0 || b.length === 0 || a.length * b.length > MAX_CELLS) {
|
|
108
|
+
for (const text of a) edits.push({ kind: "-", text });
|
|
109
|
+
for (const text of b) edits.push({ kind: "+", text });
|
|
110
|
+
return [...edits, ...suffix];
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const width = b.length + 1;
|
|
114
|
+
const table = new Uint32Array((a.length + 1) * width);
|
|
115
|
+
for (let i = a.length - 1; i >= 0; i--) {
|
|
116
|
+
for (let j = b.length - 1; j >= 0; j--) {
|
|
117
|
+
table[i * width + j] =
|
|
118
|
+
a[i] === b[j]
|
|
119
|
+
? table[(i + 1) * width + j + 1]! + 1
|
|
120
|
+
: Math.max(table[(i + 1) * width + j]!, table[i * width + j + 1]!);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
let i = 0;
|
|
124
|
+
let j = 0;
|
|
125
|
+
while (i < a.length && j < b.length) {
|
|
126
|
+
if (a[i] === b[j]) {
|
|
127
|
+
edits.push({ kind: " ", text: a[i]! });
|
|
128
|
+
i++;
|
|
129
|
+
j++;
|
|
130
|
+
} else if (table[(i + 1) * width + j]! >= table[i * width + j + 1]!) {
|
|
131
|
+
edits.push({ kind: "-", text: a[i]! });
|
|
132
|
+
i++;
|
|
133
|
+
} else {
|
|
134
|
+
edits.push({ kind: "+", text: b[j]! });
|
|
135
|
+
j++;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
while (i < a.length) edits.push({ kind: "-", text: a[i++]! });
|
|
139
|
+
while (j < b.length) edits.push({ kind: "+", text: b[j++]! });
|
|
140
|
+
return [...edits, ...suffix];
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** `start,count` as unified diff writes it: no count when 1, previous line when 0. */
|
|
144
|
+
function range(start: number, count: number): string {
|
|
145
|
+
const shown = count === 0 ? start - 1 : start;
|
|
146
|
+
return count === 1 ? String(shown) : `${String(shown)},${String(count)}`;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Unified diff of two line arrays, with the usual three lines of context.
|
|
151
|
+
* Empty when the sides are identical.
|
|
152
|
+
*/
|
|
153
|
+
export function unifiedDiff(
|
|
154
|
+
left: string[],
|
|
155
|
+
right: string[],
|
|
156
|
+
leftLabel: string,
|
|
157
|
+
rightLabel: string,
|
|
158
|
+
): string {
|
|
159
|
+
const edits = align(left, right);
|
|
160
|
+
if (edits.every((edit) => edit.kind === " ")) return "";
|
|
161
|
+
|
|
162
|
+
const out = [`--- ${leftLabel}`, `+++ ${rightLabel}`];
|
|
163
|
+
// Line numbers of the edit at each index, for hunk headers.
|
|
164
|
+
let leftNo = 1;
|
|
165
|
+
let rightNo = 1;
|
|
166
|
+
const positions = edits.map((edit) => {
|
|
167
|
+
const at = { left: leftNo, right: rightNo };
|
|
168
|
+
if (edit.kind !== "+") leftNo++;
|
|
169
|
+
if (edit.kind !== "-") rightNo++;
|
|
170
|
+
return at;
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
let index = 0;
|
|
174
|
+
while (index < edits.length) {
|
|
175
|
+
if (edits[index]!.kind === " ") {
|
|
176
|
+
index++;
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
const hunkStart = Math.max(0, index - CONTEXT);
|
|
180
|
+
let lastChange = index;
|
|
181
|
+
let cursor = index + 1;
|
|
182
|
+
// Extend while the next change is within two contexts of the last one.
|
|
183
|
+
while (cursor < edits.length && cursor - lastChange <= 2 * CONTEXT) {
|
|
184
|
+
if (edits[cursor]!.kind !== " ") lastChange = cursor;
|
|
185
|
+
cursor++;
|
|
186
|
+
}
|
|
187
|
+
const hunkEnd = Math.min(edits.length, lastChange + CONTEXT + 1);
|
|
188
|
+
|
|
189
|
+
const slice = edits.slice(hunkStart, hunkEnd);
|
|
190
|
+
const leftCount = slice.filter((edit) => edit.kind !== "+").length;
|
|
191
|
+
const rightCount = slice.filter((edit) => edit.kind !== "-").length;
|
|
192
|
+
const at = positions[hunkStart]!;
|
|
193
|
+
out.push(`@@ -${range(at.left, leftCount)} +${range(at.right, rightCount)} @@`);
|
|
194
|
+
for (const edit of slice) out.push(`${edit.kind}${edit.text}`);
|
|
195
|
+
index = hunkEnd;
|
|
196
|
+
}
|
|
197
|
+
return out.join("\n") + "\n";
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// --- The command -------------------------------------------------------------
|
|
201
|
+
|
|
202
|
+
/** Runs `work`, turning any failure into the operational exit code. */
|
|
203
|
+
async function operational<T>(work: () => T | Promise<T>): Promise<T> {
|
|
204
|
+
try {
|
|
205
|
+
return await work();
|
|
206
|
+
} catch (error) {
|
|
207
|
+
throw new CliError(messageOf(error), EXIT_DIFF_ERROR, { cause: error });
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const keyOf = (kind: string, name: string): string => `${kind}/${name}`;
|
|
212
|
+
|
|
213
|
+
export async function runDiff(
|
|
214
|
+
dir: string,
|
|
215
|
+
options: DiffOptions,
|
|
216
|
+
clients: LiveClients = SDK_CLIENTS,
|
|
217
|
+
): Promise<DiffEntry[]> {
|
|
218
|
+
const target = parseInstanceArn(options.instance, EXIT_DIFF_ERROR);
|
|
219
|
+
const docs = await operational(() => loadDocs(dir));
|
|
220
|
+
const client = await operational(() => clients.inventory(target));
|
|
221
|
+
const result = await operational(() => exportInstance(client, { onError: "collect" }));
|
|
222
|
+
|
|
223
|
+
const live = new Map<string, ExportedFlow>();
|
|
224
|
+
for (const flow of result.flows) live.set(keyOf(flow.doc.kind, flow.doc.name), flow);
|
|
225
|
+
// A failed export still has a reverse-map entry, which says what it would
|
|
226
|
+
// have been called; that is how a local document learns its live twin is
|
|
227
|
+
// unreadable rather than absent.
|
|
228
|
+
const failed = new Map<string, ExportFailure>();
|
|
229
|
+
for (const failure of result.failures) {
|
|
230
|
+
const entry = result.reverseMap.byArn.get(normalizeArn(failure.arn));
|
|
231
|
+
if (entry !== undefined) failed.set(keyOf(entry.type, entry.name), failure);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const entries: DiffEntry[] = docs.map(({ path, doc }) => {
|
|
235
|
+
const rel = relative(process.cwd(), path);
|
|
236
|
+
const key = keyOf(doc.kind, doc.name);
|
|
237
|
+
const base = { path: rel, name: doc.name, kind: doc.kind };
|
|
238
|
+
const twin = live.get(key);
|
|
239
|
+
if (twin !== undefined) {
|
|
240
|
+
const saved = twin.saved === true;
|
|
241
|
+
const diff = unifiedDiff(
|
|
242
|
+
comparableLines(doc),
|
|
243
|
+
comparableLines(twin.doc),
|
|
244
|
+
`local/${rel}`,
|
|
245
|
+
saved ? `live/${doc.name}:${SAVED_MARKER}` : `live/${doc.name}`,
|
|
246
|
+
);
|
|
247
|
+
const matched =
|
|
248
|
+
diff === ""
|
|
249
|
+
? { status: "unchanged" as const }
|
|
250
|
+
: { status: "changed" as const, detail: diff };
|
|
251
|
+
return { ...base, ...matched, ...(saved ? { saved } : {}) };
|
|
252
|
+
}
|
|
253
|
+
const failure = failed.get(key);
|
|
254
|
+
if (failure !== undefined) return { ...base, status: "error", detail: failure.reason };
|
|
255
|
+
return { ...base, status: "missing-live" };
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
for (const entry of entries) {
|
|
259
|
+
const marker = entry.saved === true ? ` (${SAVED_MARKER})` : "";
|
|
260
|
+
const suffix = entry.status === "error" ? `: ${entry.detail ?? ""}` : "";
|
|
261
|
+
console.log(`${entry.path}: ${entry.status}${marker}${suffix}`);
|
|
262
|
+
if (entry.status === "changed") process.stdout.write(entry.detail ?? "");
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const errors = entries.filter((entry) => entry.status === "error").length;
|
|
266
|
+
if (errors > 0) {
|
|
267
|
+
throw new CliError(
|
|
268
|
+
`${String(errors)} document(s) could not be compared with ${target.arn}`,
|
|
269
|
+
EXIT_DIFF_ERROR,
|
|
270
|
+
);
|
|
271
|
+
}
|
|
272
|
+
const differing = entries.filter((entry) => entry.status !== "unchanged").length;
|
|
273
|
+
if (differing > 0) {
|
|
274
|
+
throw new CliError(
|
|
275
|
+
`${String(differing)} of ${String(entries.length)} document(s) differ from ${target.arn}`,
|
|
276
|
+
);
|
|
277
|
+
}
|
|
278
|
+
return entries;
|
|
279
|
+
}
|
package/src/docs.ts
ADDED
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright 2026 The flow-as-code Authors
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
// Loading FlowDocs for the commands that take a `<dir-or-file>` argument.
|
|
6
|
+
//
|
|
7
|
+
// One entry point, one set of diagnostics: a path that does not exist, a file
|
|
8
|
+
// that is not JSON, and a document that does not satisfy the FlowDoc schema all
|
|
9
|
+
// come out as a CliError naming the offending path. Every problem in the set is
|
|
10
|
+
// reported, not just the first, so one run fixes one round of edits.
|
|
11
|
+
//
|
|
12
|
+
// schema/flowdoc-0.1.schema.json is a byte copy of
|
|
13
|
+
// conformance/schema/flowdoc-0.1.schema.json, kept inside the package because
|
|
14
|
+
// the conformance tree is not published. src/schema.test.ts fails if the two
|
|
15
|
+
// drift, so the CLI and the cross-language contract cannot disagree.
|
|
16
|
+
|
|
17
|
+
import { readFileSync, readdirSync, statSync } from "node:fs";
|
|
18
|
+
import { dirname, join, resolve } from "node:path";
|
|
19
|
+
import { fileURLToPath } from "node:url";
|
|
20
|
+
|
|
21
|
+
import type { FlowDoc } from "@flow-as-code/core";
|
|
22
|
+
import { NO_LITERAL_ARN, literalArnMessage, literalArnPaths } from "@flow-as-code/core";
|
|
23
|
+
import { Ajv2020 } from "ajv/dist/2020.js";
|
|
24
|
+
import type { AnySchema, ValidateFunction } from "ajv";
|
|
25
|
+
|
|
26
|
+
import { CliError, messageOf } from "./errors.js";
|
|
27
|
+
|
|
28
|
+
/** A directory argument loads every file with this suffix, sorted by name. */
|
|
29
|
+
export const FLOWDOC_SUFFIX = ".flowdoc.json";
|
|
30
|
+
|
|
31
|
+
/** Errors reported for one document before the rest are elided. */
|
|
32
|
+
const MAX_SCHEMA_ERRORS = 10;
|
|
33
|
+
|
|
34
|
+
/** Absolute path of the packaged FlowDoc schema (`../schema` from src or dist). */
|
|
35
|
+
export const SCHEMA_PATH = fileURLToPath(
|
|
36
|
+
new URL("../schema/flowdoc-0.1.schema.json", import.meta.url),
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
export interface LoadedDoc {
|
|
40
|
+
/** Absolute path the document was read from. */
|
|
41
|
+
path: string;
|
|
42
|
+
doc: FlowDoc;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
let compiled: ValidateFunction | undefined;
|
|
46
|
+
|
|
47
|
+
function flowDocValidator(): ValidateFunction {
|
|
48
|
+
if (compiled === undefined) {
|
|
49
|
+
const schema = JSON.parse(readFileSync(SCHEMA_PATH, "utf8")) as AnySchema;
|
|
50
|
+
compiled = new Ajv2020({ allErrors: true, strict: false }).compile(schema);
|
|
51
|
+
}
|
|
52
|
+
return compiled;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** One schema violation, as "<where> <what>". */
|
|
56
|
+
function describeSchemaError(error: { instancePath: string; message?: string }): string {
|
|
57
|
+
const where = error.instancePath === "" ? "(root)" : error.instancePath;
|
|
58
|
+
return `${where} ${error.message ?? "is invalid"}`;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* An ajv instancePath (`/content/Actions/3/Parameters/QueueId`) in the dotted
|
|
63
|
+
* form @flow-as-code/core's string walker produces
|
|
64
|
+
* (`content.Actions[3].Parameters.QueueId`), so the two can be compared.
|
|
65
|
+
*/
|
|
66
|
+
function pointerToPath(instancePath: string): string {
|
|
67
|
+
let out = "";
|
|
68
|
+
for (const raw of instancePath.split("/").slice(1)) {
|
|
69
|
+
const segment = raw.replace(/~1/g, "/").replace(/~0/g, "~");
|
|
70
|
+
if (/^\d+$/.test(segment)) out += `[${segment}]`;
|
|
71
|
+
else out += out === "" ? segment : `.${segment}`;
|
|
72
|
+
}
|
|
73
|
+
return out;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** True when this schema error is describing one of the literal ARNs found. */
|
|
77
|
+
function causedByLiteralArn(instancePath: string, arnPaths: readonly string[]): boolean {
|
|
78
|
+
const path = pointerToPath(instancePath);
|
|
79
|
+
const under = (parent: string, child: string): boolean =>
|
|
80
|
+
child.startsWith(`${parent}.`) || child.startsWith(`${parent}[`);
|
|
81
|
+
return arnPaths.some((arn) => arn === path || under(path, arn) || under(arn, path));
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Problems with a value that claims to be a FlowDoc, empty when it is one. The
|
|
86
|
+
* studio bridge validates documents arriving over HTTP with exactly the
|
|
87
|
+
* validator every command uses on documents read from disk.
|
|
88
|
+
*
|
|
89
|
+
* A literal ARN is reported once per offending field, by rule id, rather than
|
|
90
|
+
* as the several schema pattern errors one ARN produces: the token pattern
|
|
91
|
+
* fails, the alternative `$.` pattern fails, their `oneOf` fails, and the
|
|
92
|
+
* enclosing `if/then` fails, none of which say "literal ARN". The schema stays
|
|
93
|
+
* the backstop, and every schema error the ARN did not cause is still shown.
|
|
94
|
+
*
|
|
95
|
+
* The ARN scan runs whether or not the schema is satisfied. Only ref-shaped
|
|
96
|
+
* fields carry a `${cdref:...}` pattern, so an ARN pasted into free text
|
|
97
|
+
* (`Parameters.Text`, a prompt, a label) is schema-valid; `no-literal-arn` is
|
|
98
|
+
* a hard rule and the bridge write path has no other lint step, so scanning
|
|
99
|
+
* only after a schema failure would let exactly those documents onto disk.
|
|
100
|
+
*/
|
|
101
|
+
export function flowDocProblems(value: unknown): string[] {
|
|
102
|
+
const validate = flowDocValidator();
|
|
103
|
+
const valid = validate(value);
|
|
104
|
+
const errors = valid ? [] : (validate.errors ?? []);
|
|
105
|
+
const arnPaths = literalArnPaths(value);
|
|
106
|
+
if (valid && arnPaths.length === 0) return [];
|
|
107
|
+
const problems = [
|
|
108
|
+
...arnPaths.map((path) => `${NO_LITERAL_ARN}: ${literalArnMessage(path)}`),
|
|
109
|
+
...errors.filter((e) => !causedByLiteralArn(e.instancePath, arnPaths)).map(describeSchemaError),
|
|
110
|
+
];
|
|
111
|
+
const shown = problems.slice(0, MAX_SCHEMA_ERRORS);
|
|
112
|
+
if (problems.length > MAX_SCHEMA_ERRORS) {
|
|
113
|
+
shown.push(`... and ${String(problems.length - MAX_SCHEMA_ERRORS)} more`);
|
|
114
|
+
}
|
|
115
|
+
return shown;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** A problem that names a lint rule already reads as a sentence on its own. */
|
|
119
|
+
function isRuleProblem(problem: string): boolean {
|
|
120
|
+
return problem.startsWith(`${NO_LITERAL_ARN}: `);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Absolute paths of the documents a `<dir-or-file>` argument names. A directory
|
|
125
|
+
* contributes every `*.flowdoc.json` in it, sorted, and is not walked
|
|
126
|
+
* recursively; a file contributes itself whatever its extension.
|
|
127
|
+
*/
|
|
128
|
+
export function resolveDocPaths(target: string): string[] {
|
|
129
|
+
const abs = resolve(target);
|
|
130
|
+
let stat;
|
|
131
|
+
try {
|
|
132
|
+
stat = statSync(abs);
|
|
133
|
+
} catch {
|
|
134
|
+
throw new CliError(`No such file or directory: ${abs}`);
|
|
135
|
+
}
|
|
136
|
+
if (!stat.isDirectory()) return [abs];
|
|
137
|
+
|
|
138
|
+
const files = readdirSync(abs)
|
|
139
|
+
.filter((f) => f.endsWith(FLOWDOC_SUFFIX))
|
|
140
|
+
.sort()
|
|
141
|
+
.map((f) => join(abs, f));
|
|
142
|
+
if (files.length === 0) {
|
|
143
|
+
throw new CliError(`No *${FLOWDOC_SUFFIX} files in ${abs}`);
|
|
144
|
+
}
|
|
145
|
+
return files;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Reads and schema-validates every document a `<dir-or-file>` argument names. */
|
|
149
|
+
export function loadDocs(target: string): LoadedDoc[] {
|
|
150
|
+
const validate = flowDocValidator();
|
|
151
|
+
const problems: string[] = [];
|
|
152
|
+
const loaded: LoadedDoc[] = [];
|
|
153
|
+
|
|
154
|
+
for (const path of resolveDocPaths(target)) {
|
|
155
|
+
let raw: string;
|
|
156
|
+
try {
|
|
157
|
+
raw = readFileSync(path, "utf8");
|
|
158
|
+
} catch (error) {
|
|
159
|
+
problems.push(`${path}: ${messageOf(error)}`);
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
let parsed: unknown;
|
|
164
|
+
try {
|
|
165
|
+
parsed = JSON.parse(raw);
|
|
166
|
+
} catch (error) {
|
|
167
|
+
problems.push(`${path}: invalid JSON: ${messageOf(error)}`);
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
if (!validate(parsed)) {
|
|
172
|
+
for (const problem of flowDocProblems(parsed)) {
|
|
173
|
+
problems.push(
|
|
174
|
+
problem.startsWith("... and ") || isRuleProblem(problem)
|
|
175
|
+
? `${path}: ${problem}`
|
|
176
|
+
: `${path}: not a valid FlowDoc: ${problem}`,
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
loaded.push({ path, doc: parsed as FlowDoc });
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if (problems.length > 0) {
|
|
186
|
+
throw new CliError(`${String(problems.length)} problem(s):\n - ${problems.join("\n - ")}`);
|
|
187
|
+
}
|
|
188
|
+
return loaded;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** Loads the one document a single-file argument names, refusing a directory. */
|
|
192
|
+
export function loadOneDoc(target: string, command: string): LoadedDoc {
|
|
193
|
+
const abs = resolve(target);
|
|
194
|
+
let stat;
|
|
195
|
+
try {
|
|
196
|
+
stat = statSync(abs);
|
|
197
|
+
} catch {
|
|
198
|
+
throw new CliError(`No such file or directory: ${abs}`);
|
|
199
|
+
}
|
|
200
|
+
if (stat.isDirectory()) {
|
|
201
|
+
throw new CliError(`${command} takes a single FlowDoc file, not a directory: ${abs}`);
|
|
202
|
+
}
|
|
203
|
+
const [only] = loadDocs(abs);
|
|
204
|
+
if (only === undefined) throw new CliError(`No FlowDoc loaded from ${abs}`);
|
|
205
|
+
return only;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** Where a command writes when `--out` is absent: the directory of its input. */
|
|
209
|
+
export function defaultOutDir(target: string): string {
|
|
210
|
+
const abs = resolve(target);
|
|
211
|
+
try {
|
|
212
|
+
return statSync(abs).isDirectory() ? abs : dirname(abs);
|
|
213
|
+
} catch {
|
|
214
|
+
throw new CliError(`No such file or directory: ${abs}`);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Reads a JSON file that must hold a flat string to string object: the resource
|
|
220
|
+
* map for `render` and the address map for `emit --target tf`.
|
|
221
|
+
*/
|
|
222
|
+
export function readStringMap(path: string, what: string): Record<string, string> {
|
|
223
|
+
const abs = resolve(path);
|
|
224
|
+
let raw: string;
|
|
225
|
+
try {
|
|
226
|
+
raw = readFileSync(abs, "utf8");
|
|
227
|
+
} catch {
|
|
228
|
+
throw new CliError(`No such file or directory: ${abs}`);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
let parsed: unknown;
|
|
232
|
+
try {
|
|
233
|
+
parsed = JSON.parse(raw);
|
|
234
|
+
} catch (error) {
|
|
235
|
+
throw new CliError(`${abs}: invalid JSON: ${messageOf(error)}`);
|
|
236
|
+
}
|
|
237
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
238
|
+
throw new CliError(`${abs}: the ${what} must be a JSON object of string to string.`);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const bad = Object.entries(parsed as Record<string, unknown>)
|
|
242
|
+
.filter(([, value]) => typeof value !== "string")
|
|
243
|
+
.map(([key]) => key)
|
|
244
|
+
.sort();
|
|
245
|
+
if (bad.length > 0) {
|
|
246
|
+
throw new CliError(`${abs}: the ${what} has non-string value(s) for: ${bad.join(", ")}`);
|
|
247
|
+
}
|
|
248
|
+
return parsed as Record<string, string>;
|
|
249
|
+
}
|
package/src/emit.ts
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright 2026 The flow-as-code Authors
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
// `flow-cli emit <dir> --target cdk|tf`.
|
|
6
|
+
//
|
|
7
|
+
// The two targets are not symmetric, because the two emitters are not.
|
|
8
|
+
//
|
|
9
|
+
// - tf is a real code generator: @flow-as-code/tf turns the document set into HCL plus
|
|
10
|
+
// .tftpl files. This command is a thin wrapper over `writeTf`, deliberately
|
|
11
|
+
// adding nothing of its own so the bytes it writes are exactly the bytes
|
|
12
|
+
// `emitTf` returns. src/cli.test.ts asserts that equality.
|
|
13
|
+
// - cdk is a library binding: FlowSet reads the FlowDoc directory itself at
|
|
14
|
+
// synth time, so there is nothing per-document to emit and what gets written
|
|
15
|
+
// is a stack scaffold with a TODO binder. See cdk-scaffold.ts.
|
|
16
|
+
|
|
17
|
+
import { mkdirSync, writeFileSync } from "node:fs";
|
|
18
|
+
import { join, resolve } from "node:path";
|
|
19
|
+
|
|
20
|
+
import { EmitTfError, writeTf } from "@flow-as-code/tf";
|
|
21
|
+
|
|
22
|
+
import { CDK_SCAFFOLD_FILE, cdkScaffoldForDirs } from "./cdk-scaffold.js";
|
|
23
|
+
import { defaultOutDir, loadDocs, readStringMap } from "./docs.js";
|
|
24
|
+
import { CliError } from "./errors.js";
|
|
25
|
+
|
|
26
|
+
export type EmitTarget = "cdk" | "tf";
|
|
27
|
+
|
|
28
|
+
export interface EmitOptions {
|
|
29
|
+
target: string;
|
|
30
|
+
addressMap?: string;
|
|
31
|
+
out?: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const TARGETS = new Set<string>(["cdk", "tf"]);
|
|
35
|
+
|
|
36
|
+
export function runEmit(input: string, options: EmitOptions): string[] {
|
|
37
|
+
if (!TARGETS.has(options.target)) {
|
|
38
|
+
throw new CliError(`Unknown --target "${options.target}". Use "cdk" or "tf".`);
|
|
39
|
+
}
|
|
40
|
+
if (options.target === "cdk" && options.addressMap !== undefined) {
|
|
41
|
+
throw new CliError("--address-map applies to --target tf only; the cdk target uses a binder.");
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const docsDir = defaultOutDir(input);
|
|
45
|
+
const docs = loadDocs(input).map((l) => l.doc);
|
|
46
|
+
const outDir = resolve(options.out ?? docsDir);
|
|
47
|
+
mkdirSync(outDir, { recursive: true });
|
|
48
|
+
|
|
49
|
+
if (options.target === "tf") {
|
|
50
|
+
const addressMap =
|
|
51
|
+
options.addressMap === undefined
|
|
52
|
+
? undefined
|
|
53
|
+
: readStringMap(options.addressMap, "address map");
|
|
54
|
+
let result;
|
|
55
|
+
try {
|
|
56
|
+
result = writeTf(docs, outDir, addressMap === undefined ? {} : { addressMap });
|
|
57
|
+
} catch (error) {
|
|
58
|
+
if (error instanceof EmitTfError) throw new CliError(error.message);
|
|
59
|
+
throw error;
|
|
60
|
+
}
|
|
61
|
+
return Object.keys(result.files)
|
|
62
|
+
.sort()
|
|
63
|
+
.map((file) => join(outDir, file));
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const path = join(outDir, CDK_SCAFFOLD_FILE);
|
|
67
|
+
writeFileSync(path, cdkScaffoldForDirs({ docs, outDir, docsDir }), "utf8");
|
|
68
|
+
return [path];
|
|
69
|
+
}
|