Intro

Crust is the whole test stack baked into one Bun binary. Bash and TypeScript are equal citizens on the same pipeline.

What crust is

Crust is effectively an inline JavaScript global-scope script runner in Bun, shell-flavoured. Every line you type runs in a Bun context where Pipeline, range, GET/POST, parallel, $ (Bun.$), and anything you registered in ~/.config/crust/init.ts are globals.

Shell commands and TypeScript lambdas are equal citizens. Both are stages on the same pipeline. You don’t pick one or the other β€” you mix them as needed.

The same pipeline can be expressed either at the shell line or as a .ts script run with bun β€” every feature in crust has a TypeScript-API equivalent. Use whichever surface fits the moment:

# 1. Load-test a health endpoint
range(0, 10000) | parallel 100 | GET :3000/health | expect 200 | stats

# 2. Smoke-test against fixtures on disk β€” read yields file CONTENTS
read fixtures/*.json | POST :3000/users | expect 201

# 3. Pure POSIX shell still works
ls -la | grep .ts | wc -l

# 4. SQL rows as a streaming source β€” assert fails on falsy OR empty
sql "select email from users limit 5" | assert (r => r.email.includes("@"))

# 5. A whole HTTP fixture on one line: JSON body, header, status assert
{"name": "Court"} | POST $BASE/api/buildings -H "authorization: Bearer $TOKEN" | expect 201

That first pipeline is a range source, then a parallel operator, then GET (HTTP verb), then expect and stats (sinks) β€” none of which are real binaries on your PATH, but all of which compose under | as if they were. The third line is pure POSIX shell. The fourth mixes a SQL row source with an assert predicate. The fifth is a complete HTTP fixture on one line β€” a JSON-literal body, an env-expanded auth header, a status assertion β€” the shorthand fixture grammar that test-pipes runs from .pipes files. Each form has the same observable behavior β€” pick the surface that fits where you’re working.

The mental model

Three roles, one stream type β€” Pipeline<T>:

RoleWhat it doesExamples
SourceProduces a streamls, range(0,9), **/*.ts, GET <url>
TransformStream-in, stream-outshell command, (x => …) lambda, filter (x => …), POST <url>
SinkStream-in, value-outstdout (default), write(path), stats()

The shell parser classifies each |-separated stage by looking at its first token:

TriggerStage kind
Contains * / ? / […]Glob source
Matches range(a, b)Range source
Starts with { or [JSON-literal source (invalid JSON = hard error, never shell)
Starts with read <path|glob>Whole-file source β€” one item per file
Starts with ( and contains =>TypeScript lambda
Starts with assert (Assert stage β€” falsy or empty upstream fails the pipeline
Starts with filter (Filter stage β€” keeps items whose predicate is truthy; falsy items drop
Starts with GET / POST / PUT / PATCH / DELETEHTTP stage (-H headers, $VAR expansion, :port shorthand)
First token is a builtin nameBuiltin (in-process dispatch)
First token is a crust.fn-registered functionRegistered function (per-item transform)
Anything elseShell stage β€” handed to sh -c "<text>"

Single-stage pure-shell lines (ls -la, git status) short-circuit: crust execs sh -c with inherited stdio so colors, paging, and TUIs work normally. Mixed pipelines stream items through TS land.

Why bash and TypeScript

Both languages are tools. Bash wins for launching processes and piping bytes. TypeScript wins for shaping data, awaiting promises, and asserting structure. Crust stops forcing you to pick:

  • Use grep when you want grep. Don’t reinvent it.
  • Use filter (l => …) when the predicate is awkward in awk β€” it drops non-matching items, with full TS in the predicate.
  • Use a lambda when you need to parse JSON, await a fetch, or reshape items (a lambda maps; it never drops).
  • Use HTTP verbs as first-class stages β€” no need to script curl invocations or shell out for assertions.

Install

curl -fsSL https://raw.githubusercontent.com/lariocpt/crust/main/install.sh | bash

Then run it whichever way fits the moment. Crust is not a login shell and doesn’t want to be one β€” your shell stays your shell; crust is the tool you reach for when the line involves streams, HTTP, fixtures, or load:

# 1. Interactive REPL β€” line editing, history, completions
crust

# 2. One-liner caller from bash/zsh/fish β€” for the lines where
#    pipeline syntax wins. -c loads your init.ts and exits with
#    the line's status. Multi-line strings split on \n.
crust -c 'range(0,99) | parallel 20 | GET :3000/health | expect 200 | stats'
crust -c 'ls *.json | (s => JSON.parse(await Bun.file(s).text())) | (j => j.id)'

# 3. Script files β€” same parser as the REPL, fail-fast, honest
#    exit codes. A #!/usr/bin/env crust shebang works, and so
#    does piping lines in on stdin.
crust deploy-checks.crust
echo 'range(1,3) | filter (n => n > 1)' | crust

# 4. crust globals available to any .ts file you run with bun
bun script.ts

The TypeScript surface is identical between the REPL and a script β€” the difference is just where you’re typing. There is no feature that exists in the shell line but not in TS, or vice versa.

Next

  • Quickstart β€” every command crust ships, shell-line + TS-API side by side.
  • npm packages β€” globally-installed packages become pipeline stages automatically.
  • DB drivers β€” pointing sql at Postgres, MySQL, SQLite.