@flow-as-code/hcl
npm i @flow-as-code/hcl
Apache-2.0, Node 22.12 or newer. Browser-safe: no Node built-ins anywhere in its module graph, so the studio bundles it.
Site and docs: https://flow-as-code.dev/. This package on npm: https://www.npmjs.com/package/@flow-as-code/hcl.
HCL as a third view over FlowDoc (ADR-0007): a document can have a
<name>.flow.tf companion, a flowascode_contact_flow resource the
flow-as-code/flowascode Terraform provider applies, instead of a
<name>.flow.ts. The mapping is the contract in conformance/hcl/README.md,
which the provider implements too. This package is its TypeScript side.
Two layers ship. The syntax layer is a lossless lexer and parser for the HCL
native syntax, a formatter that writes exactly what terraform fmt and
tofu fmt write, literal evaluation, and HCL string quoting. The document
layer on top of it writes a FlowDoc as its companion (fromFlowDoc), reads a
companion back (toFlowDoc), regenerates a companion without losing what
the user wrote beside the document, and emits a set of documents as one
configuration (emitFlowascode).
Documents
import { fromFlowDoc, toFlowDoc } from "@flow-as-code/hcl";
const tf = fromFlowDoc(doc, {
bindings: { "queue:appointments": "aws_connect_queue.appointments.arn" },
fileName: "appointment-line.flowdoc.json",
});
const { doc: back, sidecar, warnings } = toFlowDoc(tf, { fileName: "appointment-line.flow.tf" });
fromFlowDoc writes the resource by the contract's rules 1 to 17: one
action block per action, a typed sub-block wherever the catalog's shape
accepts the action's Parameters and generic otherwise, reference fields as
keys ("queue:appointments") bound once in refs, an unbound key as null
under a # TODO comment, and position only where the canvas moved an action
off the auto-layout. The output is a terraform fmt fixed point. Given
previous, the companion on disk, it carries what rule 24 names from it
rather than from the document: refs values by key, instance_id, tags,
state, external_invocation_enabled, lint, provider, lifecycle,
depends_on, and comment lines marked @keep directly above the resource or
an action.
toFlowDoc reads a companion to its document in synth normal form and a
sidecar of what the file holds beside it (refs bindings, lint, tags, the
instance_id expression, kept comments, and every value it normalized). A
reference field may hold a key, a JSONPath, the full token
("$${cdref:queue:appointments}"), or a resource address the contract's sugar
table lists (aws_connect_queue.appointments.arn), which reads as the key
plus a binding, so the next regeneration writes the key. Anything else is an
HclError whose code is the contract's and whose path names the
attribute (action[set-queue].update_contact_target_queue.queue_id); the
message says which refs entry to write instead. A refs key no action uses
is a warning, dropped on the next regeneration.
The view holds what a canvas or a .flow.ts holds and no more:
content.Metadata, which an exported flow may carry, is not written, and a
fractional console position is written rounded.
Emitting a set
import { emitFlowascode } from "@flow-as-code/hcl";
const { files, unbound, unusedMapKeys } = emitFlowascode(docs, {
addressMap: { "queue:appointments": "aws_connect_queue.appointments.arn" },
});
// files: flows.tf, outputs.tf, variables.tf, versions.tf.example
// unbound: [{ key: "lambda:appointment-lookup", documents: ["appointment-line"] }, ...]
// unusedMapKeys: map keys no reference in the set reaches
emitFlowascode is flow-cli emit --target flowascode: one resource per
document in flows.tf, written as fromFlowDoc writes a companion, an
<name>_arn and an <name>_document_sha256 output per document in
outputs.tf (the hash is of the resource's flowdoc, references still
tokens, so a pipeline compares it across environments), with each
refs map bound from the address map (keyed as @flow-as-code/tf's is) or
to what the set emits itself, and a version and alias resource for every
module a flow in the set invokes by alias. A module nothing in the set
references gets its resource alone, no version and no alias; to release one
on its own, moduleAliases: { greeting: ["live"] } (flow-cli emit --module-alias module:greeting@live) writes the version and alias as for an
invoked module, and outputs.tf adds greeting_live_arn, the value another
root binds module:greeting@live to through its address map. What nothing binds is null under
a # TODO comment, which the provider refuses at plan time; the result's
unbound lists those keys with the documents that make them, and
unusedMapKeys the map keys no reference reaches by any of its three forms,
so a caller can refuse or warn (the CLI does both: an unbound key is an error
without --allow-unbound, an unused key a warning, or an error under
--strict). It refuses a literal ARN in the address map, a name that is not
a slug, and two documents with one address, listing every problem on
EmitFlowascodeError.problems. It never writes a <name>.flow.tf.
Parse, print, format
import { format, parse, print } from "@flow-as-code/hcl";
const file = parse(text, "appointment-line.flow.tf");
print(file) === text; // every character is kept: whitespace, comments, escapes
format(text); // the canonical layout, as terraform fmt writes it
parse returns a syntax tree over the file's full token array: every node
records its first and last token, so it can be printed as written
(sourceOf), located in an error, or have the comments around it found. A
syntax error is an HclError whose message starts with file:line:column.
format is a port of hclwrite's formatter, pass for pass: it indents each
line by its bracket depth, sets the space between each pair of tokens by
hclwrite's rules, and aligns the = of consecutive single-line attributes and
their trailing comments. A line that opens a multi-line value is left out of
the alignment run, as hclwrite leaves it. The package's conformance test holds
format to the text of every Terraform file committed to this repository, and
to tofu fmt itself when RUN_TOFU_VALIDATE=1. Terraform's fmt command also
rewrites some legacy syntax (an interpolation-only "${var.x}" becomes
var.x); format does not.
Literals and strings
import { evaluateLiteral, quote, unquoteLiteral } from "@flow-as-code/hcl";
evaluateLiteral(expr, file); // strings, numbers, booleans, null, tuples, objects
quote("Balance: ${amount}"); // "Balance: $${amount}"
evaluateLiteral refuses anything that needs an evaluation context (a
variable, a call, an interpolation) with the contract's NON_LITERAL_VALUE
code. quote writes a string by the contract's rule 16: \\, \", \n,
\r and \t escaped, any other control character as \uXXXX, ${ and %{
doubled so they stay literal, everything else raw. A lone surrogate has no
UTF-8 spelling and is refused either way with LONE_SURROGATE.