crust-builtins
crust has two namespaces — tool builtins (mock-server, test-fixture, test-pipes, gen-fixtures, verify-web-links, logs, dotenv, skills) that need a line of their own, and registered functions (base64, salt, jwt, bundle, sql, wait) that are space-separated stages. What the upstream item means to each one, why bundle(5) is a shell syntax error, how to reach crypto/FS from a lambda, and that -h/--help answers with a usage screen without running anything.
crust-builtins answers the question every newcomer gets wrong in the
first ten minutes: is this name a command, a stage, or a function? There are
three answers and they are spelled almost identically.
The two namespaces
| What it is | Where it runs | Argument style | |
|---|---|---|---|
| Tool builtin | mock-server, test-fixture, test-pipes, gen-fixtures, verify-web-links, logs, dotenv, skills | dispatched before pipeline parsing, in-process, own line | subcommand style: logs tail -F app.log |
| Registered function | base64, salt, jwt, bundle, sql, wait + anything you add with crust.fn | an ordinary pipeline stage, or a source when nothing is upstream | space-separated: salt 16, not salt(16) |
cd, export, alias, source, exit, history and help are builtins
too — the ones sh already has its own version of, which is why a redirect on
those is left to sh. procs, range, load, read, tail and friends are
sources, not builtins.
source is the only session builtin with its own argument grammar:
source <file> [args…] — a .ts/.js file is imported; a .sh/.bash/.zsh
file (or one whose first line is a #! shebang naming a shell) runs once in
a child shell — its exported KEY=value delta comes back into the session,
its exit code is the line’s, its side effects are real, but unsets don’t
propagate and its cwd never comes back; anything else runs line-by-line in this
session (aliases, exports and captures persist). An unmarked file that
looks like multi-line shell, or that doesn’t parse as crust, is refused,
never half-run. In the line-by-line form the args are the sourced file’s
positional arguments ($1, $2, … — $0 is its own path), and the caller’s
positionals are restored when the file ends; the shell form’s child never sees
them — its $1… are its own shell’s, not the file’s:
# demo.sh does `export DEMO=one` and changes cwd; only the export survives
source ./demo.sh
export # → … DEMO=one … — the env delta came back, the cwd did not
# line-by-line only: the args are the file's $1… ($0 its path)
source ./more.crust one two
From a shell, the two groups split again. The tool builtins are also argv
subcommands — crust mock-server ./openapi.json -p4000, crust skills install —
with the same flags as the line. The session builtins are not: cd, export,
alias, unalias, source, dotenv, exit, history and help change state
a one-shot process does not have, so crust cd /tmp exits 2 naming the builtin
rather than quietly doing nothing. logs stays line-only too, because its
argument is the rest of the line verbatim — a procs({...}) literal argv cannot
carry without re-lexing it. Before both of those, each fell through to script
mode and answered cannot read export at exit 127, which in a binary printing
its own name reads as a broken installation.
The single most common mistake is writing the JavaScript form:
bundle(5) → sh: -c: line 1: syntax error near unexpected token `5' (exit 2)
Parens are crust syntax only for lexer sources (range(0,9),
read("…")). Everything else in a stage is a shell word list, and sh is the one
that tells you so.
What the item means, per function
A registered function is called fn(item, ...lineArgs) mid-pipeline, so the
first slot is your data and each function decides what to do with it:
| Function | As a source | Mid-pipeline |
|---|---|---|
base64 [-d|decode] [-f|--file <path>] [-o|--out <path>] | encodes the literal argument — or a file’s bytes with --file | encodes (or decodes) each item; the item is data, never an option |
salt [bytes] [hex|base64|base64url] | one salt, size = the argument | one salt per item; the size is the argument trailing the item |
jwt sign|verify|decode --secret <s> | payload/token = the argument | the item is the payload (sign) or the token (verify/decode) |
sql "<query>" [params…] | streams rows | the query stays the one you wrote; the item binds as the first parameter when the line declares none |
wait <target> [--timeout <dur>] | waits for the argument | the item is the target |
bundle <entry> [--outfile…] | bundles the argument | a string item is an entrypoint |
base64 hello
echo aGk= | base64 -d
salt 32 base64
lines users.csv | salt 16
jwt sign '{"sub":"42"}' --secret k
sql "select id, email from users limit 5" | (r => r.email)
range(2,2) | (n => "beta") | sql "SELECT name FROM t WHERE name = ?" | assert (r => r.name === "beta")
wait :3001/health --timeout 30s
bundle src/index.ts --outfile dist/app.js --minify
That sql line is the whole contract in one example: the piped item binds as
the query’s first parameter, it never becomes the query. wait failing to
become ready exits 1, measured for :PORT, :PORT/path and port:PORT
and for an expiring --timeout — see crust-cli for
why there is no 124.
-h/--help answers at parse time
Each of the six functions answers -h/--help with its own usage screen and
exit 0 — before any stage runs, so an upstream stage with side effects
never runs: … | sql --help prints usage without making the request, and the
touch upstream of it never happens (measured). The scan stops at --, so
base64 -- --help encodes the flag, and a value that arrives through the pipe
is data, never an option (printf '%s' --help | base64 encodes it). A
crust.fn override of the name keeps its handler’s own argument handling —
--help is just another argument to it.
base64 --help
→ usage: base64 [text] [-d|--decode] [-f|--file <path>] [-o|--out <path>]
exit 0 — nothing ran
That is the PR #60 change. In 0.2.4 the flag is ordinary data — base64 --help prints LS1oZWxw.
A lambda cannot call them
Registered functions live in crust’s function table, not in the JS scope a lambda compiles into:
range(2,2) | (x => salt(4)) → crust: salt is not defined (exit 1)
That is not a dead end. A lambda body has Bun’s own globals, and an async
lambda can await in it, so reach the underlying capability directly:
range(0,9) | (_ => crypto.getRandomValues(new Uint8Array(8)).toHex())
range(0,0) | (async _ => JSON.parse(await Bun.file("package.json").text()).name)
Write async on the lambda. crust awaits the result either way, but a bare
await in a non-async body is a parse error in every released binary, so
the keyword is what makes the line run everywhere. Prefer the registered
function when it fits — salt 16 per item is shorter than the crypto line —
and drop to a lambda when you need something crust doesn’t ship.
Session environment
A line of pure assignments is the session environment — it runs the same code
export runs, so the two spellings cannot disagree:
FOO=bar
PATH=$PATH:/tmp/bin
CFLAGS+=$EXTRA_FLAGS
TOKEN='$literal'
FOO=bar used to be sh -c 'FOO=bar' — exit 0, value gone, and the next
line expanded $FOO to nothing. Quoting is sh’s: '…' protects $, "…"
expands it, and a backslash protects exactly one character, so FOO=C:\temp
survives.
Two shapes are deliberately not session state:
FOO=bar cmd argsis sh’s transient prefix — the variable lives for that one command — so the line goes to sh untouched and later lines see nothing.FOO=$(cmd)is refused rather than run: its value would have to cross back out of a child process. The honest route is a pipeline —git rev-parse HEADpiped into acapturestage of that name. Quoting doesn’t change that, and neither does a pipe inside the substitution (FOO=$(date | head -1)is refused too, not quietly dropped); a$()anywhere else on a line stays sh’s, soecho "$(date)"still prints the date.
These are not shell commands
base64, sort, head, wc and friends exist as real binaries too. crust
runs its own when the name is registered (base64 hello encodes the text, not
a file), and sh’s when it is not (… | sort is /usr/bin/sort). Neither takes a
redirect: crust refuses it and tells you what to write instead — with two
different messages, because a function and a tool fail differently.
A registered function with a redirect is refused as a stage and pointed at a shell tail:
sql "select 1" > one.txt
→ crust: `> one.txt`: crust has no redirect except on a shell stage, whose
text goes to sh as-is — a sql stage would read it as data. to save a
pipeline, end the line with a shell stage instead: | cat > one.txt
The refusal also catches a stage the redirect hides. Stage patterns match
the whole text, so range(1,3) > f stopped looking like a range the moment the
tail was appended and fell through to sh -c, which answered with its own
syntax error at exit 2 — a message with no way out in it. crust now strips the
tail, sees what the stage was, and refuses:
range(1,3) > nums.txt
→ crust: `> nums.txt`: crust has no redirect except on a shell stage, whose
text goes to sh as-is — a range stage would read it as data. to save a
pipeline, end the line with a shell stage instead: | cat > nums.txt
Same for capture N > f, parallel 5 > f, expect 200 > f, stats > f,
stdin > f and load 10s 100/s > f. grep is the deliberate exception: a grep
stage’s redirect belongs to sh and works, which is why
read **/*.log | grep ERROR > errors.txt is a supported way to save a filtered
log.
base64 hello | cat > out.b64
A tool builtin is refused as a program, and the message prints the exact whole-invocation redirect that works:
verify-web-links --base-url https://example.com --json > report.json
→ crust: verify-web-links is a crust builtin, it runs in-process and cannot
redirect. Redirect the whole invocation instead:
crust -c 'verify-web-links --base-url https://example.com --json' > report.json
crust -c 'verify-web-links --base-url https://example.com --json' > report.json
Tools that produce a file themselves have a flag for it (-o / --outfile /
--report); check that before reaching for >.
Traps
base64 in.tsencodes the path text, not the file — crust’s own function answers first. Measured on a file containingalpha\nbeta\n:base64 in.ts → aW4udHM= "in.ts" read in.ts | base64 → YWxwaGEKYmV0YQo= "alpha\nbeta\n" = /usr/bin/base64 lines in.ts | base64 → YWxwaGE= / YmV0YQ== once per linereadyields the whole content, so it is the byte-exact form for text;linessplits it first, so per-line encodes are what you get. A pipeline decodes UTF-8 on the way, so it is not binary-safe: on a 26-byte PNG,read logo.png | base64gives77+9UE5H…where/usr/bin/base64 -w0 logo.pnggivesiVBORw0KGgo….- A file’s bytes are
--file, and--outwrites the answer.base64 --file logo.pngreads bytes and skips the pipeline entirely;--out <path>puts the result in a file, which is what a redirect would have done if a function stage could take one.base64 --file logo.b64 -d --out logo-copy.pngis the way back, byte for byte. Saying the path out loud: crust now prints anote:on stderr when the operand you typed is a file, and leaves stdout alone. These two flags are current onmain; they are not in the published release this page’s grammar gate lints against, which is why they appear in prose here and not in a```crustfence. - Options are what you typed, never what the item says. A mid-pipeline
function used to scan every argument for
-d, so data that read-dflipped the mode and the input disappeared:printf '%s' -d | crust -c 'stdin | base64'died withbase64: missing input(exit 1). It isLWQ=now — the two characters, encoded. The flip side is that a typo’d flag is refused instead of encoded:base64 --desc helloprintedLS1kZXNjand exit 0, and now exits 1 naming the options it does have. - Decoding checks its input.
not base64!!!decoded to three replacement characters and exit 0, because Node’s base64 decoder skips characters it does not know. Anything outsideA-Za-z0-9+/=, base64url’s-and_, and whitespace is now an error. sort/head/wcare ambiguous by design. Reading… | sortas sh’s is correct; readingbase64 helloas/usr/bin/base64is not.- A redirect never belongs to crust. Either end the line with a shell stage
(
| cat > out.txt) or redirect the whole invocation from the shell. fn(item, ...args)means the item is first, so a function that also takes arguments reads them after your data. That is wheresalt’s byte count andjwt’s operation have to sit.
Related
- crust-cli — how to invoke crust, and what its numbers mean
- crust-pipelines — sources, transforms and assertions around these functions
- Pipe shorthand vs TypeScript — the same stage as a module call
- Quickstart — how to read the
```bashblocks in these pages