@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/errors.ts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright 2026 The flow-as-code Authors
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
// Terminal-facing errors. Every command surfaces failures as a CliError whose
|
|
6
|
+
// message is already written for a human: the offending path, the offending
|
|
7
|
+
// token, or the flag that was wrong, on stderr, with nothing else added.
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* A failure with a message meant for the terminal, and the exit code to use.
|
|
11
|
+
* `cause` carries the error it was written from, for callers of the command
|
|
12
|
+
* functions; the terminal only ever sees the message.
|
|
13
|
+
*/
|
|
14
|
+
export class CliError extends Error {
|
|
15
|
+
readonly exitCode: number;
|
|
16
|
+
|
|
17
|
+
constructor(message: string, exitCode = 1, options?: ErrorOptions) {
|
|
18
|
+
super(message, options);
|
|
19
|
+
this.name = "CliError";
|
|
20
|
+
this.exitCode = exitCode;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Message of an unknown thrown value, without a stack trace. */
|
|
25
|
+
export function messageOf(error: unknown): string {
|
|
26
|
+
return error instanceof Error ? error.message : String(error);
|
|
27
|
+
}
|
package/src/export.ts
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright 2026 The flow-as-code Authors
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
// `flow-cli export --instance <arn> [--out <dir>] [--no-codegen] [--on-error abort|collect]`.
|
|
6
|
+
//
|
|
7
|
+
// Reads every flow and module in a live instance through @flow-as-code/core's
|
|
8
|
+
// exportInstance and writes one `<name>.flowdoc.json` per document, plus
|
|
9
|
+
// `<name>.flow.ts` unless --no-codegen, into --out (default: the working
|
|
10
|
+
// directory). The name is the slug the exporter derives from the console name,
|
|
11
|
+
// which is also how `diff` finds the live counterpart of a local document.
|
|
12
|
+
//
|
|
13
|
+
// --on-error defaults to collect, not abort, because a fresh instance always
|
|
14
|
+
// holds the stock "Sample Lambda integration" flow, which calls a Lambda in an
|
|
15
|
+
// AWS-owned account that ListLambdaFunctions cannot return. That flow is an
|
|
16
|
+
// unknown-ARN hard error by design (packages/core/SPEC.md, Export), and
|
|
17
|
+
// aborting on it would make a first export of any new instance write nothing.
|
|
18
|
+
// Collect writes everything else and reports every failure at once; either
|
|
19
|
+
// mode exits 1 when any flow failed.
|
|
20
|
+
//
|
|
21
|
+
// Both halves of a pair are written the way `synth` and the studio write them:
|
|
22
|
+
// the document carries `meta.sourceHash` of the generated source, so the watch
|
|
23
|
+
// engine sees an exported pair as in sync rather than as a conflict. An
|
|
24
|
+
// existing `<name>.flow.ts` is read first so its `@keep` comments survive, as
|
|
25
|
+
// `codegen` does.
|
|
26
|
+
|
|
27
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
28
|
+
import { join, resolve } from "node:path";
|
|
29
|
+
|
|
30
|
+
import type { ExportFailure, ExportedFlow, FlowDoc } from "@flow-as-code/core";
|
|
31
|
+
import { codegen, collectInventory, exportInstance, serialize } from "@flow-as-code/core";
|
|
32
|
+
|
|
33
|
+
import { type LiveClients, parseInstanceArn, SDK_CLIENTS } from "./aws.js";
|
|
34
|
+
import { FLOWDOC_SUFFIX } from "./docs.js";
|
|
35
|
+
import { CliError, messageOf } from "./errors.js";
|
|
36
|
+
import { GENERATOR, sha256Hex } from "./synth.js";
|
|
37
|
+
|
|
38
|
+
export const TS_SUFFIX = ".flow.ts";
|
|
39
|
+
|
|
40
|
+
export interface ExportOptions {
|
|
41
|
+
instance: string;
|
|
42
|
+
out?: string;
|
|
43
|
+
/** commander's `--no-codegen` sets this false; absent means generate. */
|
|
44
|
+
codegen?: boolean;
|
|
45
|
+
onError?: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const ON_ERROR = new Set<string>(["abort", "collect"]);
|
|
49
|
+
|
|
50
|
+
/** One document written to disk. */
|
|
51
|
+
export interface WrittenDoc {
|
|
52
|
+
name: string;
|
|
53
|
+
kind: FlowDoc["kind"];
|
|
54
|
+
/** Paths relative to the output directory, document first. */
|
|
55
|
+
files: string[];
|
|
56
|
+
/** True when the content came through the `$SAVED` alias (never published). */
|
|
57
|
+
saved: boolean;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface ExportOutcome {
|
|
61
|
+
outDir: string;
|
|
62
|
+
written: WrittenDoc[];
|
|
63
|
+
failures: ExportFailure[];
|
|
64
|
+
warnings: string[];
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Writes one exported document, and its source when asked. */
|
|
68
|
+
function writeExported(flow: ExportedFlow, outDir: string, withCode: boolean): WrittenDoc {
|
|
69
|
+
const { doc } = flow;
|
|
70
|
+
const docFile = `${doc.name}${FLOWDOC_SUFFIX}`;
|
|
71
|
+
const files = [docFile];
|
|
72
|
+
let text: string;
|
|
73
|
+
|
|
74
|
+
if (withCode) {
|
|
75
|
+
const tsFile = `${doc.name}${TS_SUFFIX}`;
|
|
76
|
+
const tsPath = join(outDir, tsFile);
|
|
77
|
+
const previous = existsSync(tsPath) ? readFileSync(tsPath, "utf8") : undefined;
|
|
78
|
+
const source = codegen(doc, previous === undefined ? {} : { previous });
|
|
79
|
+
writeFileSync(tsPath, source, "utf8");
|
|
80
|
+
files.push(tsFile);
|
|
81
|
+
text = serialize({ ...doc, meta: { ...doc.meta, sourceHash: `sha256:${sha256Hex(source)}` } });
|
|
82
|
+
} else {
|
|
83
|
+
text = serialize(doc);
|
|
84
|
+
}
|
|
85
|
+
writeFileSync(join(outDir, docFile), text, "utf8");
|
|
86
|
+
|
|
87
|
+
return { name: doc.name, kind: doc.kind, files, saved: flow.saved === true };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export async function runExport(
|
|
91
|
+
options: ExportOptions,
|
|
92
|
+
clients: LiveClients = SDK_CLIENTS,
|
|
93
|
+
): Promise<ExportOutcome> {
|
|
94
|
+
const onError = options.onError ?? "collect";
|
|
95
|
+
if (!ON_ERROR.has(onError)) {
|
|
96
|
+
throw new CliError(`Unknown --on-error "${onError}". Use "abort" or "collect".`);
|
|
97
|
+
}
|
|
98
|
+
const target = parseInstanceArn(options.instance);
|
|
99
|
+
const outDir = resolve(options.out ?? ".");
|
|
100
|
+
const withCode = options.codegen !== false;
|
|
101
|
+
|
|
102
|
+
const client = await clients.inventory(target);
|
|
103
|
+
// The inventory is listed on its own, apart from the flows: a refused list
|
|
104
|
+
// call, throttling, or the network failing there is not a flow failure, so
|
|
105
|
+
// --on-error has no say in it and the SDK's message is passed through as is.
|
|
106
|
+
let inventory;
|
|
107
|
+
try {
|
|
108
|
+
inventory = await collectInventory(client);
|
|
109
|
+
} catch (error) {
|
|
110
|
+
throw new CliError(messageOf(error), 1, { cause: error });
|
|
111
|
+
}
|
|
112
|
+
let result;
|
|
113
|
+
try {
|
|
114
|
+
result = await exportInstance(client, {
|
|
115
|
+
inventory,
|
|
116
|
+
onError: onError === "abort" ? "throw" : "collect",
|
|
117
|
+
generator: GENERATOR,
|
|
118
|
+
});
|
|
119
|
+
} catch (error) {
|
|
120
|
+
// With the inventory in hand, exportInstance throws only for a flow, and
|
|
121
|
+
// only in abort mode; collect records every flow failure in `failures`.
|
|
122
|
+
throw new CliError(`export aborted (--on-error abort): ${messageOf(error)}`, 1, {
|
|
123
|
+
cause: error,
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
mkdirSync(outDir, { recursive: true });
|
|
128
|
+
const failures = [...result.failures];
|
|
129
|
+
const written: WrittenDoc[] = [];
|
|
130
|
+
// Flow and module names are unique per kind, not across kinds, and both
|
|
131
|
+
// kinds write <name>.flowdoc.json. The second one is a failure, not a
|
|
132
|
+
// silent overwrite.
|
|
133
|
+
const claimed = new Map<string, ExportedFlow>();
|
|
134
|
+
|
|
135
|
+
for (const flow of result.flows) {
|
|
136
|
+
const other = claimed.get(flow.doc.name);
|
|
137
|
+
if (other !== undefined) {
|
|
138
|
+
failures.push({
|
|
139
|
+
arn: flow.arn,
|
|
140
|
+
name: flow.sourceName,
|
|
141
|
+
reason:
|
|
142
|
+
`a ${other.doc.kind} and a ${flow.doc.kind} both slug to "${flow.doc.name}"; ` +
|
|
143
|
+
`not written, ${other.doc.name}${FLOWDOC_SUFFIX} holds the ${other.doc.kind}`,
|
|
144
|
+
});
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
claimed.set(flow.doc.name, flow);
|
|
148
|
+
written.push(writeExported(flow, outDir, withCode));
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
for (const warning of result.warnings) console.error(`warning: ${warning}`);
|
|
152
|
+
for (const doc of written) {
|
|
153
|
+
const tag = doc.saved ? `${doc.kind}, $SAVED` : doc.kind;
|
|
154
|
+
console.log(`${doc.name} (${tag}): ${doc.files.join(", ")}`);
|
|
155
|
+
}
|
|
156
|
+
for (const failure of failures) {
|
|
157
|
+
console.error(`failed: ${failure.name} (${failure.arn}): ${failure.reason}`);
|
|
158
|
+
}
|
|
159
|
+
console.log(
|
|
160
|
+
`Exported ${String(written.length)} of ${String(written.length + failures.length)} to ${outDir}`,
|
|
161
|
+
);
|
|
162
|
+
|
|
163
|
+
if (failures.length > 0) {
|
|
164
|
+
throw new CliError(`${String(failures.length)} flow(s) could not be exported`);
|
|
165
|
+
}
|
|
166
|
+
return { outDir, written, failures, warnings: result.warnings };
|
|
167
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright 2026 The flow-as-code Authors
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
// Library entry. Importing this package must not run the CLI, so the commander
|
|
6
|
+
// program lives in ./bin.ts and nothing here has a side effect at load time.
|
|
7
|
+
// The three subpath exports (./synth, ./watch, ./bridge) are the public API and
|
|
8
|
+
// this module is their union; src/index.test.ts holds the two in step.
|
|
9
|
+
|
|
10
|
+
import { pathToFileURL } from "node:url";
|
|
11
|
+
|
|
12
|
+
export * from "./synth.js";
|
|
13
|
+
export * from "./watch.js";
|
|
14
|
+
export * from "./bridge/server.js";
|
|
15
|
+
|
|
16
|
+
// Before the bin split this file was the CLI, so `node .../dist/index.js studio`
|
|
17
|
+
// still exists in scripts and in muscle memory. Left alone it now exits 0 having
|
|
18
|
+
// done nothing, which reads as success. Refuse instead, but only when this module
|
|
19
|
+
// is the process entry point: on the import path argv[1] is some other script (or
|
|
20
|
+
// undefined under `node -e`), the branch is not taken, and importing stays free of
|
|
21
|
+
// side effects.
|
|
22
|
+
if (process.argv[1] !== undefined && pathToFileURL(process.argv[1]).href === import.meta.url) {
|
|
23
|
+
process.stderr.write(
|
|
24
|
+
"flow-cli: this is the library entry and runs no commands. Run the CLI as dist/bin.js (or the installed flow-cli bin).\n",
|
|
25
|
+
);
|
|
26
|
+
process.exit(1);
|
|
27
|
+
}
|
package/src/lint.ts
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright 2026 The flow-as-code Authors
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
// `flow-cli lint <dir-or-file>`.
|
|
6
|
+
//
|
|
7
|
+
// The whole document set goes to @flow-as-code/core's lint in ONE call. Rules receive
|
|
8
|
+
// `all` alongside `doc` (packages/core/src/lint/types.ts), and the ones
|
|
9
|
+
// that follow module references need the rest of the set to answer at all:
|
|
10
|
+
// linting file by file would silently downgrade module-depth-5 and every other
|
|
11
|
+
// cross-document rule to a no-op.
|
|
12
|
+
//
|
|
13
|
+
// Exit status is severity, not finding count: any `error` exits 1, warnings
|
|
14
|
+
// alone exit 0.
|
|
15
|
+
|
|
16
|
+
import { lint, toJson, toText } from "@flow-as-code/core";
|
|
17
|
+
|
|
18
|
+
import { loadDocs } from "./docs.js";
|
|
19
|
+
import { CliError } from "./errors.js";
|
|
20
|
+
|
|
21
|
+
export type LintFormat = "text" | "json";
|
|
22
|
+
|
|
23
|
+
export interface LintOptions {
|
|
24
|
+
format?: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const FORMATS = new Set<string>(["text", "json"]);
|
|
28
|
+
|
|
29
|
+
export function runLint(target: string, options: LintOptions): void {
|
|
30
|
+
const format = options.format ?? "text";
|
|
31
|
+
if (!FORMATS.has(format)) {
|
|
32
|
+
throw new CliError(`Unknown --format "${format}". Use "text" or "json".`);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const findings = lint(loadDocs(target).map((l) => l.doc));
|
|
36
|
+
process.stdout.write(format === "json" ? toJson(findings) : toText(findings));
|
|
37
|
+
|
|
38
|
+
const errors = findings.filter((f) => f.severity === "error").length;
|
|
39
|
+
if (errors > 0) {
|
|
40
|
+
throw new CliError(`lint failed: ${String(errors)} error-severity finding(s) in ${target}`);
|
|
41
|
+
}
|
|
42
|
+
}
|
package/src/render.ts
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright 2026 The flow-as-code Authors
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
// `flow-cli render <dir-or-file> --resources <map.json>`: standalone
|
|
6
|
+
// materialization. FlowDoc in, deployable Flow language JSON out, with every
|
|
7
|
+
// `${cdref:...}` token replaced from the resource map.
|
|
8
|
+
//
|
|
9
|
+
// This is the one path where literal ARNs are the goal rather than a mistake
|
|
10
|
+
// (packages/core/SPEC.md, Materialization), so the map's values are not
|
|
11
|
+
// linted. What is enforced is completeness: @flow-as-code/core's MaterializeError
|
|
12
|
+
// carries every unmapped token, and all of them are printed, for every
|
|
13
|
+
// document, before the command exits 1. One run, one round of map edits.
|
|
14
|
+
|
|
15
|
+
import { mkdirSync, writeFileSync } from "node:fs";
|
|
16
|
+
import { join, resolve } from "node:path";
|
|
17
|
+
|
|
18
|
+
import { MaterializeError, materializeWithMap, serializeContent } from "@flow-as-code/core";
|
|
19
|
+
|
|
20
|
+
import { defaultOutDir, loadDocs, readStringMap } from "./docs.js";
|
|
21
|
+
import { CliError } from "./errors.js";
|
|
22
|
+
|
|
23
|
+
export interface RenderOptions {
|
|
24
|
+
resources: string;
|
|
25
|
+
out?: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function runRender(target: string, options: RenderOptions): string[] {
|
|
29
|
+
const docs = loadDocs(target);
|
|
30
|
+
const resourceMap = readStringMap(options.resources, "resource map");
|
|
31
|
+
const outDir = resolve(options.out ?? defaultOutDir(target));
|
|
32
|
+
|
|
33
|
+
const problems: string[] = [];
|
|
34
|
+
const rendered: { path: string; text: string }[] = [];
|
|
35
|
+
for (const { path, doc } of docs) {
|
|
36
|
+
try {
|
|
37
|
+
rendered.push({
|
|
38
|
+
path: join(outDir, `${doc.name}.json`),
|
|
39
|
+
text: serializeContent(materializeWithMap(doc, resourceMap)),
|
|
40
|
+
});
|
|
41
|
+
} catch (error) {
|
|
42
|
+
if (!(error instanceof MaterializeError)) throw error;
|
|
43
|
+
problems.push(`${path}: ${String(error.missingTokens.length)} unmapped token(s):`);
|
|
44
|
+
for (const token of error.missingTokens) problems.push(` ${token}`);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (problems.length > 0) {
|
|
49
|
+
throw new CliError(
|
|
50
|
+
`Cannot render:\n ${problems.join("\n ")}\n` +
|
|
51
|
+
`Add the missing key(s) to ${resolve(options.resources)}.`,
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
mkdirSync(outDir, { recursive: true });
|
|
56
|
+
for (const { path, text } of rendered) writeFileSync(path, text, "utf8");
|
|
57
|
+
return rendered.map((r) => r.path);
|
|
58
|
+
}
|
package/src/run.ts
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright 2026 The flow-as-code Authors
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
// The one place a command's failure becomes an exit code.
|
|
6
|
+
//
|
|
7
|
+
// Every action is wrapped, so no command can throw past commander: a CliError
|
|
8
|
+
// prints its own message and its own code, anything else prints its message and
|
|
9
|
+
// exits 1. Nothing prints a stack trace, because a stack trace is never the
|
|
10
|
+
// answer to "which file is wrong".
|
|
11
|
+
|
|
12
|
+
import type { CommanderError } from "commander";
|
|
13
|
+
|
|
14
|
+
import { CliError, messageOf } from "./errors.js";
|
|
15
|
+
|
|
16
|
+
/** Wraps a command body so it reports failures as a message plus an exit code. */
|
|
17
|
+
export function action<A extends unknown[]>(
|
|
18
|
+
body: (...args: A) => void | Promise<void>,
|
|
19
|
+
): (...args: A) => Promise<void> {
|
|
20
|
+
return async (...args: A): Promise<void> => {
|
|
21
|
+
try {
|
|
22
|
+
await body(...args);
|
|
23
|
+
} catch (error) {
|
|
24
|
+
console.error(messageOf(error));
|
|
25
|
+
process.exitCode = error instanceof CliError ? error.exitCode : 1;
|
|
26
|
+
}
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* For a command's `exitOverride`: commander's own usage errors (a required
|
|
32
|
+
* option not given, an unknown option, a missing argument) exit with `code`
|
|
33
|
+
* instead of its default 1, so a command that reserves 1 for a result can
|
|
34
|
+
* keep bad arguments apart from it. Commander has written the message to
|
|
35
|
+
* stderr by the time this runs; help and version exit 0 and are left alone.
|
|
36
|
+
* https://github.com/tj/commander.js#override-exit-and-output-handling
|
|
37
|
+
*/
|
|
38
|
+
export function usageErrorsExit(code: number): (error: CommanderError) => void {
|
|
39
|
+
return (error) => {
|
|
40
|
+
if (error.exitCode !== 0) process.exit(code);
|
|
41
|
+
};
|
|
42
|
+
}
|
package/src/simulate.ts
ADDED
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright 2026 The flow-as-code Authors
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
// `flow-cli simulate <scenarios> --instance <arn> [--resource-map <file>] [--format junit|json] [--out <file>]`.
|
|
6
|
+
//
|
|
7
|
+
// Runs a scenario suite through @flow-as-code/core's runScenarios, which owns the
|
|
8
|
+
// TestCase lifecycle (create published, execute, poll, collect, delete) and the
|
|
9
|
+
// documented limits: 5 concurrent, 100 in flight including the running 5,
|
|
10
|
+
// 5 minutes per scenario.
|
|
11
|
+
// https://docs.aws.amazon.com/connect/latest/adminguide/testing-simulation-execute-test-cases.html
|
|
12
|
+
//
|
|
13
|
+
// Everything that can fail offline fails before the instance is touched, and
|
|
14
|
+
// all at once: every scenario file is schema-validated against the packaged
|
|
15
|
+
// byte copy of conformance/schema/scenario-0.1.schema.json, then checked by
|
|
16
|
+
// @flow-as-code/core's validateScenario for the cross-field rules the schema cannot
|
|
17
|
+
// express, and every ${cdref:...} token is resolved against --resource-map. A
|
|
18
|
+
// suite with one broken scenario creates no test case.
|
|
19
|
+
//
|
|
20
|
+
// The report goes to --out or stdout; failures also go to stderr, one block per
|
|
21
|
+
// scenario that did not pass, so a CI log says why without opening the XML.
|
|
22
|
+
// Exit 0 means every scenario PASSED. Anything else (FAILED, TIMED_OUT,
|
|
23
|
+
// ERRORED, STOPPED) exits 1.
|
|
24
|
+
|
|
25
|
+
import { readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
|
26
|
+
import { join, resolve } from "node:path";
|
|
27
|
+
import { fileURLToPath } from "node:url";
|
|
28
|
+
|
|
29
|
+
import type { RunOptions, Scenario, ScenarioResult, SimulationRun } from "@flow-as-code/core";
|
|
30
|
+
import {
|
|
31
|
+
compileScenario,
|
|
32
|
+
jsonReport,
|
|
33
|
+
junitReport,
|
|
34
|
+
resolveScenario,
|
|
35
|
+
runScenarios,
|
|
36
|
+
validateScenario,
|
|
37
|
+
} from "@flow-as-code/core";
|
|
38
|
+
import { Ajv2020 } from "ajv/dist/2020.js";
|
|
39
|
+
import type { AnySchema, ValidateFunction } from "ajv";
|
|
40
|
+
|
|
41
|
+
import { type LiveClients, parseInstanceArn, SDK_CLIENTS } from "./aws.js";
|
|
42
|
+
import { readStringMap } from "./docs.js";
|
|
43
|
+
import { CliError, messageOf } from "./errors.js";
|
|
44
|
+
|
|
45
|
+
/** Absolute path of the packaged scenario schema (`../schema` from src or dist). */
|
|
46
|
+
export const SCENARIO_SCHEMA_PATH = fileURLToPath(
|
|
47
|
+
new URL("../schema/scenario-0.1.schema.json", import.meta.url),
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
/** The file names a directory argument picks up, matching conformance/simulate/. */
|
|
51
|
+
const SCENARIO_FILE = "scenario.json";
|
|
52
|
+
const SCENARIO_SUFFIX = ".scenario.json";
|
|
53
|
+
|
|
54
|
+
const FORMATS = new Set<string>(["junit", "json"]);
|
|
55
|
+
|
|
56
|
+
/** Errors reported for one scenario before the rest are elided. */
|
|
57
|
+
const MAX_SCHEMA_ERRORS = 10;
|
|
58
|
+
|
|
59
|
+
export interface SimulateOptions {
|
|
60
|
+
instance: string;
|
|
61
|
+
resourceMap?: string;
|
|
62
|
+
format?: string;
|
|
63
|
+
out?: string;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface LoadedScenario {
|
|
67
|
+
/** Absolute path the scenario was read from. */
|
|
68
|
+
path: string;
|
|
69
|
+
scenario: Scenario;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
let compiled: ValidateFunction | undefined;
|
|
73
|
+
|
|
74
|
+
function scenarioValidator(): ValidateFunction {
|
|
75
|
+
if (compiled === undefined) {
|
|
76
|
+
const schema = JSON.parse(readFileSync(SCENARIO_SCHEMA_PATH, "utf8")) as AnySchema;
|
|
77
|
+
compiled = new Ajv2020({ allErrors: true, strict: false }).compile(schema);
|
|
78
|
+
}
|
|
79
|
+
return compiled;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Schema violations for a value that claims to be a scenario, then the
|
|
84
|
+
* cross-field findings for one that passes the schema. Empty when it is valid.
|
|
85
|
+
*/
|
|
86
|
+
export function scenarioProblems(value: unknown): string[] {
|
|
87
|
+
const validate = scenarioValidator();
|
|
88
|
+
if (!validate(value)) {
|
|
89
|
+
const errors = validate.errors ?? [];
|
|
90
|
+
const shown = errors.slice(0, MAX_SCHEMA_ERRORS).map((error) => {
|
|
91
|
+
const where = error.instancePath === "" ? "(root)" : error.instancePath;
|
|
92
|
+
return `${where} ${error.message ?? "is invalid"}`;
|
|
93
|
+
});
|
|
94
|
+
if (errors.length > MAX_SCHEMA_ERRORS) {
|
|
95
|
+
shown.push(`... and ${String(errors.length - MAX_SCHEMA_ERRORS)} more`);
|
|
96
|
+
}
|
|
97
|
+
return shown;
|
|
98
|
+
}
|
|
99
|
+
return validateScenario(value).map((finding) => `${finding.path}: ${finding.message}`);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function isScenarioFile(name: string): boolean {
|
|
103
|
+
return name === SCENARIO_FILE || name.endsWith(SCENARIO_SUFFIX);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Absolute paths of the scenario files a `<scenarios>` argument names. A file
|
|
108
|
+
* contributes itself. A directory contributes every `scenario.json` and
|
|
109
|
+
* `*.scenario.json` in it and in its immediate subdirectories (the layout of
|
|
110
|
+
* conformance/simulate/, one `<case>/scenario.json` per case), sorted by path.
|
|
111
|
+
*/
|
|
112
|
+
export function resolveScenarioPaths(target: string): string[] {
|
|
113
|
+
const abs = resolve(target);
|
|
114
|
+
let stat;
|
|
115
|
+
try {
|
|
116
|
+
stat = statSync(abs);
|
|
117
|
+
} catch {
|
|
118
|
+
throw new CliError(`No such file or directory: ${abs}`);
|
|
119
|
+
}
|
|
120
|
+
if (!stat.isDirectory()) return [abs];
|
|
121
|
+
|
|
122
|
+
const found: string[] = [];
|
|
123
|
+
for (const entry of readdirSync(abs, { withFileTypes: true })) {
|
|
124
|
+
const path = join(abs, entry.name);
|
|
125
|
+
if (entry.isDirectory()) {
|
|
126
|
+
for (const inner of readdirSync(path)) {
|
|
127
|
+
if (isScenarioFile(inner)) found.push(join(path, inner));
|
|
128
|
+
}
|
|
129
|
+
} else if (isScenarioFile(entry.name)) {
|
|
130
|
+
found.push(path);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
if (found.length === 0) {
|
|
134
|
+
throw new CliError(
|
|
135
|
+
`No ${SCENARIO_FILE} or *${SCENARIO_SUFFIX} files in ${abs} or its subdirectories`,
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
return found.sort();
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Reads and validates every scenario a `<scenarios>` argument names. */
|
|
142
|
+
export function loadScenarios(target: string): LoadedScenario[] {
|
|
143
|
+
const problems: string[] = [];
|
|
144
|
+
const loaded: LoadedScenario[] = [];
|
|
145
|
+
|
|
146
|
+
for (const path of resolveScenarioPaths(target)) {
|
|
147
|
+
let raw: string;
|
|
148
|
+
try {
|
|
149
|
+
raw = readFileSync(path, "utf8");
|
|
150
|
+
} catch (error) {
|
|
151
|
+
problems.push(`${path}: ${messageOf(error)}`);
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
let parsed: unknown;
|
|
155
|
+
try {
|
|
156
|
+
parsed = JSON.parse(raw);
|
|
157
|
+
} catch (error) {
|
|
158
|
+
problems.push(`${path}: invalid JSON: ${messageOf(error)}`);
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
const found = scenarioProblems(parsed);
|
|
162
|
+
if (found.length > 0) {
|
|
163
|
+
for (const problem of found) problems.push(`${path}: ${problem}`);
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
loaded.push({ path, scenario: parsed as Scenario });
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
if (problems.length > 0) {
|
|
170
|
+
throw new CliError(`${String(problems.length)} problem(s):\n - ${problems.join("\n - ")}`);
|
|
171
|
+
}
|
|
172
|
+
return loaded;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** The lines stderr gets for one scenario that did not pass. */
|
|
176
|
+
function describeFailure(result: ScenarioResult): string {
|
|
177
|
+
const lines = [
|
|
178
|
+
`${result.status}: ${result.name}${result.message === undefined ? "" : `: ${result.message}`}`,
|
|
179
|
+
];
|
|
180
|
+
for (const detail of result.details ?? []) lines.push(` ${detail}`);
|
|
181
|
+
return lines.join("\n");
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Runs the suite. `timing` exists for the tests, which must not wait on real
|
|
186
|
+
* poll intervals; it is the subset of RunOptions that changes no behaviour.
|
|
187
|
+
*/
|
|
188
|
+
export async function runSimulate(
|
|
189
|
+
target: string,
|
|
190
|
+
options: SimulateOptions,
|
|
191
|
+
clients: LiveClients = SDK_CLIENTS,
|
|
192
|
+
timing: Pick<RunOptions, "now" | "sleep" | "pollIntervalMs"> = {},
|
|
193
|
+
): Promise<SimulationRun> {
|
|
194
|
+
const format = options.format ?? "junit";
|
|
195
|
+
if (!FORMATS.has(format)) {
|
|
196
|
+
throw new CliError(`Unknown --format "${format}". Use "junit" or "json".`);
|
|
197
|
+
}
|
|
198
|
+
const instance = parseInstanceArn(options.instance);
|
|
199
|
+
const scenarios = loadScenarios(target);
|
|
200
|
+
const resourceMap =
|
|
201
|
+
options.resourceMap === undefined ? {} : readStringMap(options.resourceMap, "resource map");
|
|
202
|
+
|
|
203
|
+
// Every unresolved token in the suite, before a single test case exists.
|
|
204
|
+
const unresolved: string[] = [];
|
|
205
|
+
for (const { path, scenario } of scenarios) {
|
|
206
|
+
try {
|
|
207
|
+
resolveScenario(compileScenario(scenario), resourceMap);
|
|
208
|
+
} catch (error) {
|
|
209
|
+
unresolved.push(`${path}: ${messageOf(error)}`);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
if (unresolved.length > 0) {
|
|
213
|
+
throw new CliError(
|
|
214
|
+
`${String(unresolved.length)} scenario(s) cannot be resolved` +
|
|
215
|
+
`${options.resourceMap === undefined ? " (no --resource-map given)" : ""}:\n - ` +
|
|
216
|
+
unresolved.join("\n - "),
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const client = await clients.test(instance);
|
|
221
|
+
const run = await runScenarios(
|
|
222
|
+
scenarios.map((s) => s.scenario),
|
|
223
|
+
client,
|
|
224
|
+
{ resourceMap, ...timing },
|
|
225
|
+
);
|
|
226
|
+
|
|
227
|
+
const report = format === "json" ? jsonReport(run) : junitReport(run);
|
|
228
|
+
if (options.out === undefined) {
|
|
229
|
+
process.stdout.write(report);
|
|
230
|
+
} else {
|
|
231
|
+
const out = resolve(options.out);
|
|
232
|
+
writeFileSync(out, report, "utf8");
|
|
233
|
+
console.log(out);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const notPassed = run.results.filter((result) => result.status !== "PASSED");
|
|
237
|
+
for (const result of notPassed) console.error(describeFailure(result));
|
|
238
|
+
if (notPassed.length > 0) {
|
|
239
|
+
throw new CliError(
|
|
240
|
+
`${String(notPassed.length)} of ${String(run.results.length)} scenario(s) did not pass`,
|
|
241
|
+
);
|
|
242
|
+
}
|
|
243
|
+
return run;
|
|
244
|
+
}
|