@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/watch.ts ADDED
@@ -0,0 +1,388 @@
1
+ /*
2
+ * Copyright 2026 The flow-as-code Authors
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+ // Watch engine: keeps <name>.flow.ts and <name>.flowdoc.json pairs in sync
6
+ // inside one directory. A library, not a command: the studio server (A11)
7
+ // consumes these events, and `flow-cli studio` will sit on top.
8
+ //
9
+ // Sync direction here is ts -> doc only. On a ts change the file is re-synthed
10
+ // in the same sandboxed child process `flow-cli synth` uses, and the FlowDoc
11
+ // is rewritten UNLESS the doc on disk was edited by someone else since this
12
+ // watcher last wrote or observed it AND that edit does not carry the
13
+ // sourceHash of the previous ts content. That state is dirty-both: the
14
+ // watcher emits "conflict" and writes nothing. Never silently overwrite
15
+ // (docs/01-flowdoc-spec.md, invariants 3 and the sourceHash dirty guard).
16
+ //
17
+ // One event is emitted for a change that alters nothing: a builder file edited
18
+ // back to the exact bytes of the last successful sync, after a failed one.
19
+ // Consumers latch "error" (the studio keeps a badge up until the document syncs
20
+ // again), so a state that ends by being undone has to end with a "synced" or
21
+ // the badge outlives the condition it reports.
22
+
23
+ import { createHash } from "node:crypto";
24
+ import { readFile } from "node:fs/promises";
25
+ import { writeFile } from "node:fs/promises";
26
+ import { basename, join, resolve } from "node:path";
27
+
28
+ import { watch as chokidarWatch, type FSWatcher } from "chokidar";
29
+
30
+ import type { FlowDoc } from "@flow-as-code/core";
31
+ import { serializeWithMeta, synthFile } from "./synth.js";
32
+
33
+ const TS_SUFFIX = ".flow.ts";
34
+ const DOC_SUFFIX = ".flowdoc.json";
35
+
36
+ export interface SyncedEvent {
37
+ tsPath: string;
38
+ docPath: string;
39
+ name: string;
40
+ }
41
+
42
+ export interface ConflictEvent {
43
+ tsPath: string;
44
+ docPath: string;
45
+ name: string;
46
+ reason: string;
47
+ }
48
+
49
+ export interface ErrorEvent {
50
+ path: string;
51
+ message: string;
52
+ }
53
+
54
+ export interface WatcherEvents {
55
+ synced: SyncedEvent;
56
+ conflict: ConflictEvent;
57
+ error: ErrorEvent;
58
+ /** Initial scan finished and every startup synth settled. Not part of the
59
+ * A04 event contract; a convenience for consumers and tests. */
60
+ ready: Record<string, never>;
61
+ }
62
+
63
+ export interface FlowWatcher {
64
+ on<K extends keyof WatcherEvents>(event: K, listener: (payload: WatcherEvents[K]) => void): this;
65
+ off<K extends keyof WatcherEvents>(event: K, listener: (payload: WatcherEvents[K]) => void): this;
66
+ once<K extends keyof WatcherEvents>(
67
+ event: K,
68
+ listener: (payload: WatcherEvents[K]) => void,
69
+ ): this;
70
+ /**
71
+ * Records a pair another part of this process just wrote as the clean
72
+ * baseline, so the watcher recognizes the write as its own instead of
73
+ * treating it as an external edit.
74
+ *
75
+ * The studio bridge (A11) writes both halves of a pair when the canvas
76
+ * saves: the FlowDoc and the regenerated builder source. Without this the
77
+ * watcher would see a ts change whose doc "changed externally" and emit a
78
+ * conflict for an edit the same process just made.
79
+ *
80
+ * Pass the exact bytes written. Call it AFTER both files are on disk:
81
+ * chokidar's awaitWriteFinish window (50 ms) is orders of magnitude longer
82
+ * than the gap between the writes and this call, so no event can be routed
83
+ * against a half-updated ledger. Contents that are not passed leave that
84
+ * half of the ledger alone.
85
+ */
86
+ noteWrite(name: string, contents: { tsContent?: string; docContent?: string }): void;
87
+ close(): Promise<void>;
88
+ }
89
+
90
+ /** Per-pair sync ledger. Hashes are hex sha256 of file bytes. */
91
+ interface LedgerEntry {
92
+ /** Bytes of the doc as last written by us or observed in a clean state. */
93
+ lastDocHash?: string;
94
+ /** Bytes of the ts content those doc bytes were synthed from / seen with. */
95
+ lastTsHash?: string;
96
+ /**
97
+ * The last change to this pair's builder file ended in an `error` event, so
98
+ * consumers are still showing the pair as out of sync. Only then does a
99
+ * change back to the last-synced bytes deserve a `synced`: see the no-op
100
+ * branch in handleTsChange.
101
+ */
102
+ errored?: boolean;
103
+ }
104
+
105
+ function hashHex(bytes: Buffer | string): string {
106
+ return createHash("sha256").update(bytes).digest("hex");
107
+ }
108
+
109
+ class Watcher implements FlowWatcher {
110
+ private readonly listeners = new Map<keyof WatcherEvents, Set<(payload: never) => void>>();
111
+ private readonly ledger = new Map<string, LedgerEntry>();
112
+ /** Per-pair task chain so two changes to one pair never synth concurrently. */
113
+ private readonly chains = new Map<string, Promise<void>>();
114
+ private readonly fsWatcher: FSWatcher;
115
+ private readonly dir: string;
116
+ private closed = false;
117
+
118
+ constructor(dir: string) {
119
+ this.dir = resolve(dir);
120
+ // chokidar v5 takes no globs: watch the directory, filter paths here.
121
+ // awaitWriteFinish debounces editors' partial/atomic writes; the
122
+ // thresholds are far below the 1 second sync budget.
123
+ this.fsWatcher = chokidarWatch(this.dir, {
124
+ ignoreInitial: false,
125
+ depth: 0,
126
+ awaitWriteFinish: { stabilityThreshold: 50, pollInterval: 10 },
127
+ });
128
+ this.fsWatcher.on("add", (path) => this.route(path));
129
+ this.fsWatcher.on("change", (path) => this.route(path));
130
+ this.fsWatcher.on("error", (err) => {
131
+ this.emit("error", {
132
+ path: this.dir,
133
+ message: err instanceof Error ? err.message : String(err),
134
+ });
135
+ });
136
+ this.fsWatcher.on("ready", () => {
137
+ // Let the initial adds settle before declaring readiness.
138
+ void this.settled().then(() => {
139
+ if (!this.closed) this.emit("ready", {});
140
+ });
141
+ });
142
+ }
143
+
144
+ on<K extends keyof WatcherEvents>(event: K, listener: (payload: WatcherEvents[K]) => void) {
145
+ let set = this.listeners.get(event);
146
+ if (set === undefined) {
147
+ set = new Set();
148
+ this.listeners.set(event, set);
149
+ }
150
+ set.add(listener as (payload: never) => void);
151
+ return this;
152
+ }
153
+
154
+ off<K extends keyof WatcherEvents>(event: K, listener: (payload: WatcherEvents[K]) => void) {
155
+ this.listeners.get(event)?.delete(listener as (payload: never) => void);
156
+ return this;
157
+ }
158
+
159
+ once<K extends keyof WatcherEvents>(event: K, listener: (payload: WatcherEvents[K]) => void) {
160
+ const wrapped = (payload: WatcherEvents[K]) => {
161
+ this.off(event, wrapped);
162
+ listener(payload);
163
+ };
164
+ return this.on(event, wrapped);
165
+ }
166
+
167
+ noteWrite(name: string, contents: { tsContent?: string; docContent?: string }): void {
168
+ const entry = this.entry(name);
169
+ if (contents.tsContent !== undefined) entry.lastTsHash = hashHex(contents.tsContent);
170
+ if (contents.docContent !== undefined) entry.lastDocHash = hashHex(contents.docContent);
171
+ }
172
+
173
+ async close(): Promise<void> {
174
+ this.closed = true;
175
+ await this.fsWatcher.close();
176
+ await this.settled();
177
+ }
178
+
179
+ private emit<K extends keyof WatcherEvents>(event: K, payload: WatcherEvents[K]): void {
180
+ for (const listener of [...(this.listeners.get(event) ?? [])]) {
181
+ (listener as (p: WatcherEvents[K]) => void)(payload);
182
+ }
183
+ }
184
+
185
+ private settled(): Promise<void> {
186
+ return Promise.all([...this.chains.values()]).then(() => undefined);
187
+ }
188
+
189
+ private route(path: string): void {
190
+ const file = basename(path);
191
+ let name: string | undefined;
192
+ if (file.endsWith(TS_SUFFIX)) name = file.slice(0, -TS_SUFFIX.length);
193
+ else if (file.endsWith(DOC_SUFFIX)) name = file.slice(0, -DOC_SUFFIX.length);
194
+ if (name === undefined || name === "") return;
195
+ this.enqueue(name, file.endsWith(TS_SUFFIX));
196
+ }
197
+
198
+ private enqueue(name: string, tsChanged: boolean): void {
199
+ const prev = this.chains.get(name) ?? Promise.resolve();
200
+ const next = prev.then(() =>
201
+ tsChanged ? this.handleTsChange(name) : this.handleDocChange(name),
202
+ );
203
+ // Keep the chain alive even if a handler slips an exception through.
204
+ this.chains.set(
205
+ name,
206
+ next.catch((e: unknown) => {
207
+ this.emit("error", {
208
+ path: join(this.dir, name + TS_SUFFIX),
209
+ message: e instanceof Error ? e.message : String(e),
210
+ });
211
+ }),
212
+ );
213
+ }
214
+
215
+ private entry(name: string): LedgerEntry {
216
+ let e = this.ledger.get(name);
217
+ if (e === undefined) {
218
+ e = {};
219
+ this.ledger.set(name, e);
220
+ }
221
+ return e;
222
+ }
223
+
224
+ /**
225
+ * A doc file appeared or changed. Our own writes echo back here and are
226
+ * recognized by hash; anything else is an external edit. The ledger keeps
227
+ * the last CLEAN state, so an external edit deliberately does not update
228
+ * it: the divergence is detected on the next ts change.
229
+ */
230
+ private async handleDocChange(name: string): Promise<void> {
231
+ const entry = this.entry(name);
232
+ const docPath = join(this.dir, name + DOC_SUFFIX);
233
+ let bytes: Buffer;
234
+ try {
235
+ bytes = await readFile(docPath);
236
+ } catch {
237
+ return; // deleted between event and read; the next ts change re-creates it
238
+ }
239
+ const docHash = hashHex(bytes);
240
+ if (entry.lastDocHash === undefined) {
241
+ // First observation of this doc (startup scan or a doc that appeared
242
+ // before its ts): record it as the observed baseline.
243
+ entry.lastDocHash = docHash;
244
+ }
245
+ // Otherwise: external edit (or our own echo, which matches lastDocHash
246
+ // and needs nothing). Leave the ledger pointing at the clean state.
247
+ }
248
+
249
+ private async handleTsChange(name: string): Promise<void> {
250
+ if (this.closed) return;
251
+ const entry = this.entry(name);
252
+ const tsPath = join(this.dir, name + TS_SUFFIX);
253
+ const docPath = join(this.dir, name + DOC_SUFFIX);
254
+
255
+ let tsBytes: Buffer;
256
+ try {
257
+ tsBytes = await readFile(tsPath);
258
+ } catch {
259
+ return; // deleted between event and read
260
+ }
261
+ const tsHash = hashHex(tsBytes);
262
+ if (tsHash === entry.lastTsHash) {
263
+ // Normally an echo of our own write, or a touch: nothing to say. But it
264
+ // is also how a broken edit gets undone. Ctrl+Z back to the bytes we last
265
+ // synced from produces no synth and no event, so a consumer that latched
266
+ // the preceding `error` (the studio's "Code out of sync" badge) kept
267
+ // showing it until some unrelated edit happened. The doc on disk is still
268
+ // the one these bytes produced, so the pair IS in sync: say so.
269
+ if (entry.errored === true) {
270
+ entry.errored = false;
271
+ this.emit("synced", { tsPath, docPath, name });
272
+ }
273
+ return;
274
+ }
275
+
276
+ // Read the doc side to run the dirty guard.
277
+ let docBytes: Buffer | undefined;
278
+ try {
279
+ docBytes = await readFile(docPath);
280
+ } catch {
281
+ docBytes = undefined;
282
+ }
283
+
284
+ if (docBytes !== undefined) {
285
+ const docHash = hashHex(docBytes);
286
+ const docSourceHash = readSourceHash(docBytes);
287
+
288
+ if (entry.lastTsHash === undefined) {
289
+ // First look at this ts while a doc already exists (startup scan, or
290
+ // a pair dirty since startup). If the doc carries this ts content's
291
+ // hash the pair is in sync: baseline it without a synth. Anything
292
+ // else is dirty in an unknowable direction, so surface it instead of
293
+ // overwriting. Deliberately independent of lastDocHash: chokidar's
294
+ // initial add order (ts before doc or doc before ts) must not change
295
+ // the outcome.
296
+ if (docSourceHash === `sha256:${tsHash}`) {
297
+ entry.lastDocHash = docHash;
298
+ entry.lastTsHash = tsHash;
299
+ return;
300
+ }
301
+ this.emit("conflict", {
302
+ tsPath,
303
+ docPath,
304
+ name,
305
+ reason:
306
+ "doc exists but its meta.sourceHash does not match the ts content, and this " +
307
+ "watcher has not seen the pair in sync; run `flow-cli synth` explicitly or " +
308
+ "remove the stale side",
309
+ });
310
+ return;
311
+ }
312
+
313
+ const docChangedExternally = entry.lastDocHash !== undefined && docHash !== entry.lastDocHash;
314
+ if (docChangedExternally && docSourceHash !== `sha256:${entry.lastTsHash}`) {
315
+ // Dirty-both: the doc was edited externally (studio or hand edit)
316
+ // AND the ts changed. Never silently overwrite either side.
317
+ this.emit("conflict", {
318
+ tsPath,
319
+ docPath,
320
+ name,
321
+ reason:
322
+ "both sides changed: the flowdoc was edited since the last sync " +
323
+ "and the ts file changed too; resolve manually and re-save one side",
324
+ });
325
+ return;
326
+ }
327
+ }
328
+
329
+ // Clean (or doc missing): re-synth in the sandboxed child and write.
330
+ try {
331
+ const { flows, sourceHash } = await synthFile(tsPath);
332
+ const doc = pickDoc(flows, name);
333
+ if (doc === undefined) {
334
+ entry.errored = true;
335
+ this.emit("error", {
336
+ path: tsPath,
337
+ message:
338
+ `${basename(tsPath)} exports ${flows.length} flows and none is named "${name}"; ` +
339
+ `the watcher pairs ${name}${TS_SUFFIX} with ${name}${DOC_SUFFIX} by name`,
340
+ });
341
+ return;
342
+ }
343
+ const outBytes = serializeWithMeta(doc, sourceHash);
344
+ await writeFile(docPath, outBytes, "utf8");
345
+ entry.lastDocHash = hashHex(outBytes);
346
+ entry.lastTsHash = tsHash;
347
+ entry.errored = false;
348
+ this.emit("synced", { tsPath, docPath, name });
349
+ } catch (e) {
350
+ entry.errored = true;
351
+ this.emit("error", { path: tsPath, message: e instanceof Error ? e.message : String(e) });
352
+ }
353
+ }
354
+ }
355
+
356
+ /**
357
+ * The flow whose name matches the file base name, else a lone export. Exported
358
+ * so the studio bridge pairs a builder file with its FlowDoc by exactly the
359
+ * rule the watcher uses, rather than a second one that could disagree.
360
+ */
361
+ export function pickDoc(
362
+ flows: { name: string; doc: FlowDoc }[],
363
+ name: string,
364
+ ): FlowDoc | undefined {
365
+ const named = flows.find((f) => f.name === name);
366
+ if (named !== undefined) return named.doc;
367
+ const only = flows.length === 1 ? flows[0] : undefined;
368
+ return only?.doc;
369
+ }
370
+
371
+ function readSourceHash(docBytes: Buffer): string | undefined {
372
+ try {
373
+ const doc = JSON.parse(docBytes.toString("utf8")) as FlowDoc;
374
+ const hash = doc.meta?.sourceHash;
375
+ return typeof hash === "string" ? hash : undefined;
376
+ } catch {
377
+ return undefined; // unparseable doc counts as an external edit with no provenance
378
+ }
379
+ }
380
+
381
+ /**
382
+ * Watches `dir` (non-recursive) and keeps every <name>.flow.ts /
383
+ * <name>.flowdoc.json pair in sync, ts -> doc, with the sourceHash dirty
384
+ * guard described at the top of this file.
385
+ */
386
+ export function createWatcher(dir: string): FlowWatcher {
387
+ return new Watcher(dir);
388
+ }