@flow-as-code/cli
Install with npm i -D @flow-as-code/cli. Apache-2.0, Node 22.12 or newer.
Installing it brings the other four, so npx flow-cli works straight away.
npx flow-cli init flows writes a document to open, and npx flow-cli studio flows
opens it. examples/promote-across-environments/
is a worked run of lint and emit against one document and two environments.
Site and docs: https://flow-as-code.dev/. This package on npm: https://www.npmjs.com/package/@flow-as-code/cli.
flow-cli init [dir] scaffold a directory to open
flow-cli lint <dir-or-file> [--format text|json] rule set over the whole document set
flow-cli render <dir-or-file> --resources map.json standalone materialization
flow-cli codegen <doc.flowdoc.json> [--out <file.ts>] FlowDoc -> typed builder TS
flow-cli synth <file.flow.ts> [--out <dir>] TS -> FlowDoc (sandboxed child process)
flow-cli emit <dir> --target cdk|tf [--address-map refs.tfmap.json]
flow-cli diff <dir> --instance <arn> local FlowDocs vs the live instance
flow-cli export --instance <arn> [--out <dir>] [--no-codegen] [--on-error abort|collect]
flow-cli simulate <scenarios> --instance <arn> [--resource-map <file>] [--format junit|json] [--out <file>]
flow-cli studio [dir] [--port <port>] local visual editor, live sync both ways
Availability
Every command above is wired, so --help is the complete surface; a test in
src/cli.test.ts holds --help and the usage block above to the same list.
| Command | Status | Notes |
|---|---|---|
init |
available | One demo FlowDoc plus its .flow.ts; refuses to overwrite |
lint |
available | Whole set in one pass, so cross-document rules resolve module references |
codegen |
available | @keep comments in an existing output file survive regeneration |
synth |
available | Sandboxed child process; see below |
render |
available | Strict resource map; every unmapped token is listed at once |
emit |
available | --target tf writes @flow-as-code/tf output, --target cdk writes a scaffold |
export |
available | Live instance to FlowDoc pairs; collects failures by default |
simulate |
available | Scenario suite through the TestCase API, JUnit or JSON report |
diff |
available | Local documents against the live instance; exit 2 when it cannot tell |
studio |
available | Local bridge on 127.0.0.1 serving the studio, with live sync both ways |
Exit codes: 0 on success, 1 for a failure (a missing path, malformed JSON, a
schema-invalid document, an unmapped token, an unknown flag value, a lint
finding with error severity, a flow that could not be exported, a scenario that
did not pass, or a document that differs from the instance). diff alone also
uses 2, for an error in the comparison itself, so a script can tell "differs"
from "could not tell".
Importing it
The command lives in dist/bin.js and nothing else loads it, so importing the
package never parses argv or exits the host process. The package root is a
library entry, the union of the three subpaths:
import { synthFile, createWatcher, startStudioServer } from "@flow-as-code/cli";
// or, narrower:
import { synthFile } from "@flow-as-code/cli/synth";
import { createWatcher } from "@flow-as-code/cli/watch";
import { startStudioServer } from "@flow-as-code/cli/bridge";
src/index.test.ts imports the built root in a child process and fails if it
writes anything or exits, and holds the root's exports equal to the union of
the three subpaths.
Connecting to an instance
export, simulate, and diff take --instance <arn>, the instance ARN as
the console's account overview shows it
(https://docs.aws.amazon.com/connect/latest/adminguide/find-instance-arn.html).
Only an instance ARN is accepted: a bare instance id carries no Region, and a
resource ARN names the wrong thing.
The Region is read from the ARN. Nothing consults AWS_REGION or a profile's
region, so an instance in one Region is never addressed through a client
configured for another
(https://docs.aws.amazon.com/sdk-for-javascript/v3/developer-guide/setting-region.html).
Credentials come from the AWS SDK's default provider chain: environment
variables, the shared config and credentials files with their profiles and SSO
sessions, web identity, and the container and instance metadata endpoints
(https://docs.aws.amazon.com/sdkref/latest/guide/standardized-credentials.html).
The CLI takes no credential flags and stores nothing. Pick a profile with
AWS_PROFILE as you would for the AWS CLI.
@aws-sdk/client-connect is an optional peer dependency, loaded only when one
of these three commands runs, so the offline commands do not pay for it. When
it is not installed the command exits 1 with one line naming the package and
the install command, no stack trace. An SDK that is installed but will not
load (a dependency of its own missing, a broken install) is a different
failure and is reported as @aws-sdk/client-connect could not be loaded: with
the loader's reason, since the install command is not the fix. simulate also
checks that the installed SDK exposes the TestCase operations, which are
recent, and names the version that has them all when it does not.
Every other command works with no SDK installed and makes no network calls.
init
flow-cli init [dir] writes the smallest complete thing to open, so the npm
path has a first step:
npm i -D @flow-as-code/cli
npx flow-cli init flows
npx flow-cli studio flows
Three files, into the working directory or [dir]:
| File | What it is |
|---|---|
appointment-line.flowdoc.json |
The demo FlowDoc, the same one the hosted studio demo opens |
appointment-line.flow.ts |
Its paired builder file, generated by codegen |
package.json |
Only when no package.json exists above the directory |
The pair is written through the same path a canvas save takes, so the document
carries the meta.sourceHash of the builder file beside it and the two open in
sync rather than as a conflict on the first edit. The package.json declares
"type": "module"; a directory that already sits inside a project gets none,
because synth evaluates a builder file as ESM whatever the project declares
and there is nothing to fix.
A directory that already holds other files is fine. A directory that already holds one of the two files this writes is not: nothing is written at all and the command exits 1 naming every collision, since half a pair is worse than none.
Input and output conventions
lint, render, and emit take a directory or a single file. A directory
contributes every *.flowdoc.json in it, sorted by name, and is not walked
recursively. Every document is validated against flowdoc-0.1.schema.json, the
byte copy of conformance/schema/flowdoc-0.1.schema.json this package ships,
before any command looks at it, so a malformed document fails with its own path
named rather than deep inside an emitter.
--out defaults to the input directory for every command that writes, and
codegen names its file after the document (appointment-line.flowdoc.json
generates appointment-line.flow.ts), which is the inverse of what synth
writes.
lint
The whole document set goes to @flow-as-code/core's lint in one call. Rules
receive the other documents alongside their own, and the ones that follow module
references cannot answer without them: module-depth-5 on a single file reports
nothing, correctly, because depth is not computable from one document.
--format json prints the stable machine-readable report (a summary plus
findings) on stdout; the failure line goes to stderr, so the JSON stays clean
for a pipe. Exit status follows severity, not count: any error exits 1,
warnings alone exit 0.
Every document is schema-checked before any rule runs, so a document the schema
rejects never reaches the report. A literal ARN is the one violation that is
both a schema failure and a named rule, and the schema states it four ways per
field without ever saying "literal ARN", so it is reported once per offending
field as no-literal-arn: "<field>" contains a literal ARN. Schema errors the
ARN did not cause are still listed.
render
Materializes each document with materializeWithMap and writes
<doc name>.json. This is the one path where a literal ARN is the goal rather
than a mistake, so map values are not linted. Completeness is enforced: a
reference with no entry in the map is fatal, and every missing reference across
every document is printed in one run, each with the key forms it would have
taken.
Reference map keys
render --resources, simulate --resource-map, and
emit --target tf --address-map read the same shape of file and accept the same
three keys per reference, so one map's keys serve all three:
| Key | Where it comes from |
|---|---|
${cdref:module:survey@prod} |
The token, exactly as the document writes it |
module:survey@prod |
The token without its wrapper |
module_survey_prod_arn |
The variable @flow-as-code/tf emits in flow_refs |
The middle form is what @flow-as-code/tf writes into its own TODO comments and
what examples/promote-across-environments/
uses, and it is the one to prefer: it is the shortest that still says which
resource it means. render and simulate took only the token before 0.1.2, so
a map written from an emit run was rejected by the next command.
The keys are shared; the values are not. render and simulate want the ARN of
a resource that exists, and render is the one path where a literal ARN is the
goal rather than a mistake. emit --target tf --address-map wants an HCL address
expression and refuses a literal ARN, because an emitted flow that hardcodes one
is the thing this tooling exists to stop. So a render map and an emit map are
two files with the same keys.
emit
--target tf is a thin wrapper over @flow-as-code/tf, writing exactly
the bytes the emitter returns and nothing of its own. --address-map is passed
through as options.addressMap and takes the same three key forms
("Reference map keys" above); without one, unresolved references become the
emitter's loud TODO_MISSING_ADDRESS_* placeholders, which fail
terraform validate rather than deploying a broken flow.
--target cdk is not a code generator, because @flow-as-code/cdk is a
library: FlowSet reads the FlowDoc directory itself at synth time. What the
command writes is flow-stack.ts, a compiling scaffold that constructs a
FlowSet over the directory and declares a TokenBinder carrying one TODO
per reference type the documents actually use, with the referenced names listed.
Reference types the set does not use still get a method, because TokenBinder
requires them, but theirs throws instead of carrying a TODO. Module references
never appear: FlowSet resolves those itself against the modules it manages.
Re-running the command overwrites the file, so move or rename it once you have
filled it in.
The generator lives in @flow-as-code/cdk/scaffold, not here, because the
studio's CDK export button has to produce the same bytes and cannot import this
package (@flow-as-code/cli depends on @flow-as-code/studio for its built
assets). What stays here is the path arithmetic: turning --out and the
documents' directory into the relative FLOW_DOCS expression. The scaffold
resolves that expression against its own file (new URL(..., import.meta.url)),
so cdk synth works from the project root, where cdk.json normally lives, and
not only from the directory the scaffold was written to.
synth
flow-cli synth <file.flow.ts> [--out <dir>] evaluates a TypeScript builder
file and writes one <flow.name>.flowdoc.json per flow it exports, into
--out or, by default, next to the source file. A flow is any exported Flow
instance and the result of any exported zero-argument function that returns one.
Output is byte-stable (@flow-as-code/core's canonical serializer) and carries
meta.generator (cli@<version>) and meta.sourceHash (sha256:<hex> of the
source file bytes).
A builder file does not need @flow-as-code/core installed beside it. The child
resolves that import normally first, so a copy in the file's own node_modules
wins; only when nothing resolves does it fall back to the copy flow-cli itself
is running against. That is what lets a folder holding nothing but FlowDocs and
the .flow.ts files codegen wrote next to them synth at all. The fallback
covers @flow-as-code/core and nothing else, so a builder file cannot reach the
rest of @flow-as-code/cli's dependencies through it.
The builder file itself is evaluated as ES module source whatever the enclosing
package.json says, including an explicit "type": "commonjs" (which is what
npm init -y writes). That is what codegen emits and the only module system
that can import @flow-as-code/core, which ships ES modules only. The override
covers that one file and not the project: a module the builder imports keeps
its project's module system, so in a CommonJS project a helper .ts that
imports @flow-as-code/core still fails, and synth says so by name. Set
"type": "module" in the nearest package.json.
The builder file never runs in the CLI process. It runs in a child node with
tsx registered via --import, a stripped environment (PATH plus a private
temp dir for tsx), and cwd pinned to the source directory. Where the runtime
has the stable --permission flag (Node 22.13+), Node's permission model is
enabled: file writes are denied everywhere except tsx's private temp dir (the
parent process writes the FlowDocs), and child processes are denied. File
reads are not jailed, and on Node 22/24 the permission model does not cover
network access at all; see the header comment in src/synth.ts for exactly
what is and is not enforced, with citations.
diff
flow-cli diff <dir> --instance <arn> answers whether what is checked in
matches what is deployed. Every *.flowdoc.json in <dir> is validated and
paired with the live flow or module of the same kind and name, where the live
name is the slug export derives from the console name. The live side goes
through the same exporter, so it carries tokens rather than ARNs, and the two
documents are compared as canonical JSON with layout and meta removed:
positions and provenance are not differences anyone deploys.
One line per local document, in path order, then a unified diff of the canonical JSON under each changed one:
flows/appointment-line.flowdoc.json: changed
--- local/flows/appointment-line.flowdoc.json
+++ live/appointment-line
@@ -13,7 +13,7 @@
...
flows/recording-consent.flowdoc.json: unchanged
flows/callback-offer.flowdoc.json: missing-live
Live flows with no local document are not reported. The same two states always print the same bytes, so the output can be compared across runs.
A live flow that has never been published has no published content, so the
exporter reads its saved content through the $SAVED alias, the same way
export does. The line says so, flows/draft-line.flowdoc.json: unchanged ($SAVED), and a diff under it is labelled +++ live/draft-line:$SAVED.
Unchanged against a draft means the draft matches, not that anything is
deployed.
Exit 0 when nothing differs, 1 when any document is changed or has no live
counterpart, and 2 when the comparison itself failed: bad arguments (a missing
--instance or an unknown flag included, where every other command exits 1),
the SDK not installed, the instance unreadable, or a matched live flow the
exporter could not convert (that document prints as error: with the reason,
and the run still reports the rest).
export
flow-cli export --instance <arn> [--out <dir>] [--no-codegen] [--on-error abort|collect]
reads every flow and module in the instance and writes <name>.flowdoc.json
plus <name>.flow.ts for each into --out, which defaults to the working
directory and is created if needed. --no-codegen writes the documents only.
References come out as tokens, never ARNs, and the name is the slug the
exporter derives from the console name (Appointment Line becomes
appointment-line). A flow and a module that slug to the same name would
share a file name; the second one is reported as a failure rather than
overwriting the first.
Each pair is written the way synth and the studio write it: the document
carries meta.sourceHash of the generated source, so the watch engine sees an
exported pair as in sync, and an existing <name>.flow.ts is read first so
its @keep comments survive, as with codegen. A flow that has never been
published is read through the documented $SAVED alias and marked as such in
the summary.
stdout carries one line per document written and a closing count; stderr
carries the exporter's warnings (warning: ..., for content it had to skip or
could not fully represent) and one failed: <name> (<arn>): <reason> line per
flow it could not export.
appointment-line (flow): appointment-line.flowdoc.json, appointment-line.flow.ts
recording-consent (module): recording-consent.flowdoc.json, recording-consent.flow.ts
Exported 2 of 3 to /work/flows
--on-error defaults to collect, which writes everything that exported and
reports every failure at once. abort stops at the first failure and writes
nothing. The default is collect because a fresh instance always holds the
stock "Sample Lambda integration" flow, which calls a Lambda in an AWS-owned
account that the instance's Lambda list cannot return; that is an unknown-ARN
hard error by design (see the Export section of packages/core/SPEC.md),
and aborting on it would make a first export of any new instance write
nothing. Either mode exits 1 when any flow failed.
--on-error governs flows only. The inventory listing that precedes them
(flows, modules, queues, hours, prompts, Lambda functions, bots) fails in
either mode with the SDK's own message, an AccessDeniedException naming the
missing action for instance, and nothing is written.
simulate
flow-cli simulate <scenarios> --instance <arn> [--resource-map <file>] [--format junit|json] [--out <file>]
runs a scenario suite through the instance's TestCase operations
(https://docs.aws.amazon.com/connect/latest/adminguide/testing-simulation-execute-test-cases.html)
within the documented limits, 5 concurrent, 100 in flight, 5 minutes per
scenario, and writes a report.
<scenarios> is one scenario file, or a directory. A directory contributes
every scenario.json and *.scenario.json directly in it and one level down,
which is the layout of conformance/simulate/ (one scenario.json per case
directory); other files are ignored.
Everything that can fail offline fails before the instance is touched, and
all at once. Every scenario is validated against scenario-0.1.schema.json,
the byte copy of conformance/schema/scenario-0.1.schema.json this package
ships, then against the cross-field rules the schema cannot express, and every
${cdref:...} token in the suite is resolved through --resource-map, a JSON
object from reference to ARN in the same shape render takes, with the same
three key forms ("Reference map keys" above). A suite with one broken scenario
or one unmapped reference creates no test case.
The report goes to stdout, or to --out, in which case the path is printed
instead. --format junit (the default) is the <testsuites> document CI
systems ingest; --format json is the stable flow-simulate-report/0.1
document. Scenarios that did not pass are also described on stderr, one block
per scenario with the failed observations, so a CI log says why without
opening the report.
Exit 0 only when every scenario PASSED. FAILED, TIMED_OUT (the harness
stops the execution when the 5 minutes are up), STOPPED, and ERRORED (an
API error for that scenario) each exit 1, and the summary line says how many
of the suite did not pass.
watch (library)
@flow-as-code/cli/watch exports createWatcher(dir), the engine
behind studio sync (A11). It watches one directory (non-recursive) and pairs
<name>.flow.ts with <name>.flowdoc.json by base name. On a ts change it
re-synths in the same sandboxed child and rewrites the doc, guarded by
meta.sourceHash (docs/01-flowdoc-spec.md, invariant 3): if the doc on disk
was edited externally since the watcher last wrote or observed it, and that
edit does not carry the previous ts content's hash, the pair is dirty on both
sides. The watcher then emits conflict and writes nothing. A pair that is
already diverged when the watcher starts also gets a conflict rather than
an overwrite.
The re-synth uses the same resolution rules synth documents above, so a
watched directory needs no node_modules of its own for the builder files in
it to resolve @flow-as-code/core.
Events: synced {tsPath, docPath, name}, conflict {tsPath, docPath, name, reason}, error {path, message}, plus a ready convenience event after the
initial scan settles. A ts file whose flow names do not include the file's
base name syncs only when it exports exactly one flow; otherwise the watcher
emits error for that pair.
A change that leaves the builder file byte-identical to the last synced content
is normally silent (it is how the watcher recognizes its own writes), with one
exception: when the preceding change ended in error, the same no-op emits
synced. Undoing a broken edit is the ordinary way to fix one, and consumers
latch the error, so without that event the studio's out-of-sync badge outlived
the state it reported and cleared only on some later, unrelated edit.
noteWrite(name, {tsContent, docContent}) records a pair another part of the
same process just wrote as the clean baseline, so the studio bridge's own
writes are not read back as external edits. Pass the exact bytes written, and
call it after both files are on disk.
studio
flow-cli studio [dir] [--port <port>] serves the visual editor over a
directory of FlowDocs and builder files, with live sync in both directions. It
prints the URL to open; dir defaults to the working directory and the port
defaults to a free one.
A document with no <name>.flow.ts beside it gets one written before the
server starts, and the command prints a line per file:
wrote appointment-line.flow.ts from appointment-line.flowdoc.json
That is the same codegen the canvas runs on save, so the bytes are the ones
the first save would have written, and the document is stamped with the
meta.sourceHash of the source just generated. Without it a directory holding
only documents (exported from an instance, copied out of conformance/, sent
by a colleague) had no builder file to edit, so the loop this command exists
for could not start there. An existing builder file is never overwritten. A
document codegen refuses is named and skipped, and the rest of the directory
still opens:
skipped broken.flowdoc.json: broken.flowdoc.json is not a valid FlowDoc: ...
A builder file the watcher cannot synth is reported on the same stream:
error appointment-line.flow.ts: Evaluating /w/appointment-line.flow.ts threw: Transform failed with 1 error:
/w/appointment-line.flow.ts:101:16: ERROR: Unexpected ";"
Stack frames inside node_modules and node's own internals are dropped from
that message. A transform failure carries ten frames of esbuild and stream
internals under a line that already names the file, line and column, and the
studio's badge shows only the first line for the same reason. Frames in files
you wrote survive, because a runtime throw inside a flow needs them. There is
no switch that restores the dropped frames. node_modules has to be a whole
path segment of the frame's own location for the frame to count as vendor code,
so a project under a directory called node_modules-sandbox, or a function
named loadNodeModules, keeps its frames.
A pair assembled by hand is a different case and still reports a conflict: the
document has to carry the meta.sourceHash of the source next to it, or the
watcher has no evidence the two ever agreed. flow-cli synth <name>.flow.ts
writes that stamp; removing the stale side works too.
The server binds 127.0.0.1 and only 127.0.0.1. This process writes files
you own, so a bridge reachable from the network would be a remote file writer;
the bind address is not configurable for that reason. Binding loopback is not
by itself protection against DNS rebinding (a page on the internet can resolve
its own hostname to 127.0.0.1), so the Host header is checked as well and a
request addressed to anything else is refused. No CORS headers are sent, and
the server makes no outbound connections.
What it serves: the built studio assets from the installed
@flow-as-code/studio package, and a small JSON API under /bridge.
If the studio package is present but has not been built, the command says so
and names the build command.
The API is these six routes and nothing else; anything else under /bridge
answers 404 with the method and path it was given
(src/bridge/server.ts):
| Route | Method | What it does |
|---|---|---|
/bridge/info |
GET | Served directory, version, and the session token |
/bridge/docs |
GET | Names of the documents in the served directory |
/bridge/docs/<name> |
GET | One pair: the FlowDoc and its <name>.flow.ts |
/bridge/docs/<name> |
PUT | Save that pair; 409 when the pair diverged |
/bridge/docs/<name>/resolve |
POST | Settle a refused save by choosing a side |
/bridge/export |
POST | Write an emitted file map under the served directory |
/bridge/events?cursor=<n> |
GET | Long-polled watch events from <n> on |
Every request to /bridge and to the document itself carries the session
token, either as the token query parameter (which is how the printed URL
opens the page) or as the x-flow-studio-token header (which is how the page's
own calls send it). Subresources are served without one so the token never
appears in an asset URL. src/bridge/forgery.test.ts holds this contract,
along with the Sec-Fetch-Site and Origin checks described above.
POST /bridge/export is how the studio's export buttons reach disk: the studio
emits the file map (the same emitters this CLI's emit and render use) and
this process writes it under the served directory. Paths are checked against
the protocol's path rule and then again by resolving them and requiring the
result to be inside that directory. Nothing is paired and no event is
published: emitted terraform and CDK files are output, not documents.
Saving from the canvas writes both halves of the pair: the FlowDoc, and
<name>.flow.ts regenerated with codegen, passing the existing file so
@keep comments survive. The document is stamped with meta.sourceHash of
the source just written, which is what keeps the watcher from reading the
save as a divergence.
Conflicts are never merged. A save whose builder file has changed since the document was generated from it is refused with 409 and both sides are offered in the studio's dialog; so is a pair the watcher finds diverged on disk. Every write stays refused until the choice is made.