@lariocpt/crust-mongo 0.0.0-stage → 0.3.5

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.
@@ -0,0 +1,134 @@
1
+ // The "install the agent skills?" question, asked once, right after the binary
2
+ // lands in the cache.
3
+ //
4
+ // WHY HERE. The skills are the difference between an agent that guesses at
5
+ // crust's grammar and one that gets it right on the first try, and a user who
6
+ // installs the package is exactly the moment that decision is live. Asking once,
7
+ // at install, beats documenting the verb in a README nobody re-reads.
8
+ //
9
+ // WHAT IT MUST NEVER DO. Break `npm install`. Every failure mode — no TTY, no
10
+ // binary, spawn refused, user typed noise — resolves to "declined", and the
11
+ // caller keeps exit 0. A postinstall that exits non-zero aborts the whole
12
+ // install, and "I did not want to answer a question" is not a reason to refuse
13
+ // to install a package.
14
+ //
15
+ // The gating is a separate pure function because it is the part with real blast
16
+ // radius: prompt in CI and the build hangs on a question nobody is there to
17
+ // answer; skip the cwd check and the skills land inside node_modules/crust.
18
+ import { spawn } from "node:child_process";
19
+ import { existsSync, readdirSync } from "node:fs";
20
+ import path from "node:path";
21
+
22
+ // Where the skills live. `.agents/skills` is the canonical, cross-client path;
23
+ // `.claude/skills` is where Claude Code looks, and crust keeps a per-skill link
24
+ // there. Both are checked for "already installed" so someone upgrading from a
25
+ // crust that wrote only the Claude path is not asked to start over.
26
+ const SKILLS_SUBDIR = ".agents/skills";
27
+ const OTHER_SKILL_SUBDIRS = [".claude/skills"];
28
+
29
+ /**
30
+ * Decide whether to ask, and where "this project" is.
31
+ *
32
+ * `INIT_CWD` is the directory `npm`/`bun`/`pnpm` were actually run from; the
33
+ * process cwd during a lifecycle script is the package directory
34
+ * (node_modules/crust), which is NOT where a project-scoped skill belongs.
35
+ */
36
+ export function shouldAsk({
37
+ env = {},
38
+ stdinTty = false,
39
+ stdoutTty = false,
40
+ dir = env.INIT_CWD,
41
+ } = {}) {
42
+ if (!stdinTty || !stdoutTty) return { ask: false, reason: "not an interactive terminal" };
43
+ // A CI runner has no human to answer, and `CI` is set by every mainstream one.
44
+ if (env.CI) return { ask: false, reason: "CI" };
45
+ if (env.CRUST_NO_SKILLS) return { ask: false, reason: "CRUST_NO_SKILLS is set" };
46
+ // A global install has no project to write into; suggest the global verb instead.
47
+ if (env.npm_config_global === "true" || env.npm_config_global === "1") {
48
+ return { ask: false, reason: "global install" };
49
+ }
50
+ // Nested install (crust pulled in as somebody's dependency): the skills are for
51
+ // the developer's own agent, not for the package that happens to depend on us.
52
+ if (!dir || dir.split(path.sep).includes("node_modules")) {
53
+ return { ask: false, reason: "not run from a project directory" };
54
+ }
55
+ if (!existsSync(dir)) return { ask: false, reason: "project directory is gone" };
56
+ const installedHere = [SKILLS_SUBDIR, ...OTHER_SKILL_SUBDIRS].some((sub) =>
57
+ hasCrustSkills(path.join(dir, sub)),
58
+ );
59
+ if (installedHere) {
60
+ return { ask: false, reason: "skills already installed" };
61
+ }
62
+ return { ask: true, dir };
63
+ }
64
+
65
+ /** Any `crust-*` skill already on disk counts as "this user has done this". */
66
+ function hasCrustSkills(skillsDir) {
67
+ let names;
68
+ try {
69
+ names = readdirSync(skillsDir);
70
+ } catch {
71
+ return false;
72
+ }
73
+ return names.some((n) => n.startsWith("crust-"));
74
+ }
75
+
76
+ /**
77
+ * Ask, and on yes hand the terminal to `crust skills install` so the picker —
78
+ * not this script — decides which skills land where.
79
+ *
80
+ * stdio is inherited precisely so the picker can take raw mode over the same
81
+ * terminal. Reading the answer here and then re-reading keystrokes there would
82
+ * otherwise fight over stdin.
83
+ */
84
+ export async function askSkills({ bin, env = {}, log = () => {}, cwd = env.INIT_CWD }) {
85
+ // No binary, no question: the picker lives inside it, and asking something we
86
+ // cannot act on turns a helpful prompt into a broken one.
87
+ if (!bin || !existsSync(bin)) return { asked: false, reason: "binary not available yet" };
88
+ const gate = shouldAsk({
89
+ env,
90
+ stdinTty: process.stdin.isTTY === true,
91
+ stdoutTty: process.stdout.isTTY === true,
92
+ dir: cwd,
93
+ });
94
+ if (!gate.ask) return { asked: false, reason: gate.reason };
95
+
96
+ log("");
97
+ log("crust ships agent skills — the SKILL.md guides Codex, Cursor, Claude Code");
98
+ log("and OpenCode read to drive crust correctly.");
99
+ log(`Install them into ${gate.dir}/.agents/skills? [y/N] `);
100
+
101
+ const answer = await readAnswer();
102
+ if (!/^y(es)?$/i.test(answer.trim())) {
103
+ log("skipped — `crust skills install` does it later (add --global for ~/.agents).");
104
+ log("to stop being asked: CRUST_NO_SKILLS=1");
105
+ return { asked: true, installed: false };
106
+ }
107
+ const code = await new Promise((resolve) => {
108
+ const child = spawn(bin, ["skills", "install"], { cwd: gate.dir, stdio: "inherit" });
109
+ child.on("error", () => resolve(-1));
110
+ child.on("close", (c) => resolve(c ?? -1));
111
+ });
112
+ return { asked: true, installed: code === 0, code };
113
+ }
114
+
115
+ /** One line from stdin. Resolves empty on EOF so a half-open pipe cannot hang. */
116
+ function readAnswer() {
117
+ return new Promise((resolve) => {
118
+ let buf = "";
119
+ const done = (value) => {
120
+ process.stdin.off("data", onData);
121
+ process.stdin.off("end", onEnd);
122
+ resolve(value);
123
+ };
124
+ const onData = (chunk) => {
125
+ buf += chunk.toString("utf8");
126
+ const nl = buf.indexOf("\n");
127
+ if (nl !== -1) done(buf.slice(0, nl));
128
+ };
129
+ const onEnd = () => done(buf);
130
+ process.stdin.on("data", onData);
131
+ process.stdin.once("end", onEnd);
132
+ process.stdin.resume();
133
+ });
134
+ }
package/package.json CHANGED
@@ -1,6 +1,57 @@
1
1
  {
2
2
  "name": "@lariocpt/crust-mongo",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.3.5",
4
+ "description": "crust with the MongoDB driver bundled — the mongo variant of the crust binary launcher",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "author": "Lario Borges",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/lariocpt/crust.git"
11
+ },
12
+ "homepage": "https://lariocpt.github.io/crust-website/",
13
+ "bugs": "https://github.com/lariocpt/crust/issues",
14
+ "keywords": [
15
+ "bun",
16
+ "devops",
17
+ "pipeline",
18
+ "load-testing",
19
+ "mock-server",
20
+ "openapi",
21
+ "testing",
22
+ "fixtures",
23
+ "test-generation",
24
+ "cli",
25
+ "mongo",
26
+ "mongodb"
27
+ ],
28
+ "bin": {
29
+ "crust": "./bin/crust"
30
+ },
31
+ "crust": {
32
+ "source": "github",
33
+ "repo": "lariocpt/crust",
34
+ "variant": "mongo"
35
+ },
36
+ "files": [
37
+ "bin",
38
+ "lib",
39
+ "scripts",
40
+ "skills",
41
+ "README.md"
42
+ ],
43
+ "scripts": {
44
+ "postinstall": "node scripts/postinstall.mjs"
45
+ },
46
+ "engines": {
47
+ "node": ">=18"
48
+ },
49
+ "os": [
50
+ "linux",
51
+ "darwin"
52
+ ],
53
+ "cpu": [
54
+ "x64",
55
+ "arm64"
56
+ ]
57
+ }
@@ -0,0 +1,36 @@
1
+ // crust — npm postinstall.
2
+ //
3
+ // A fast path, not the mechanism. npm 12 blocks install scripts by default, so bin/crust
4
+ // fetches the binary lazily on first use and this script only front-loads that work for
5
+ // environments where scripts still run. All the logic lives in lib/install.mjs so the two
6
+ // entry points cannot drift apart.
7
+ //
8
+ // It must never fail the install. A postinstall that exits non-zero aborts the whole
9
+ // `npm install`, and "you are not on the LAN right now" is not a reason to refuse to install
10
+ // a package whose launcher can recover later.
11
+ import { ensureBinary, installHint } from "../lib/install.mjs";
12
+ import { askSkills } from "../lib/skills-prompt.mjs";
13
+
14
+ const say = (m) => console.log(`[crust] ${m}`);
15
+ const warn = (m) => console.warn(`[crust] ${m}`);
16
+
17
+ let manifest = null;
18
+ try {
19
+ manifest = await ensureBinary({ log: say, warn });
20
+ } catch (e) {
21
+ warn(e.message);
22
+ warn("it will be fetched on first run instead.");
23
+ for (const l of installHint()) warn(l);
24
+ }
25
+
26
+ // The one question this installer asks. Its own gate keeps it silent on CI, on a
27
+ // non-tty, on a global install, and once the skills are already in place — and a
28
+ // throw here must not take the install down with it, so it is caught rather than
29
+ // trusted.
30
+ try {
31
+ await askSkills({ bin: manifest?.bin, env: process.env, log: say });
32
+ } catch (e) {
33
+ warn(`skills prompt skipped: ${e.message}`);
34
+ }
35
+
36
+ process.exit(0);
@@ -0,0 +1,154 @@
1
+ ---
2
+ name: crust-api-testing
3
+ description: Test HTTP APIs with crust — .pipes suites (one fixture pipeline per line, request chaining via capture, inline SQL verification), .crust.ts fixtures with setup/matchers, and gen-fixtures which derives negative-case matrices and CRUD flow suites from an OpenAPI spec. Use for API integration tests, contract tests from a spec, auth/authz negative cases, or DB-verified endpoint tests.
4
+ ---
5
+
6
+ # crust API testing
7
+
8
+ Three layers, one binary. Pick the lightest that fits:
9
+
10
+ | Layer | File | Best for |
11
+ |---|---|---|
12
+ | `test-pipes` | `.pipes` — one pipeline per line | readable CRUD suites, chaining, SQL cross-checks |
13
+ | `test-fixture` | `.crust.ts` — TS fixture modules | complex setup, matcher functions, stress (`-n`) |
14
+ | `gen-fixtures` | generated from `openapi.json` | negative matrices + CRUD flows for every documented op |
15
+
16
+ ## .pipes suites (test-pipes)
17
+
18
+ One shorthand pipeline per line; `#` comments; lines run sequentially per
19
+ file. `capture` chains requests; `sql` verifies the database inline:
20
+
21
+ ```crust
22
+ {"name": "Court", "floors": 3} | POST $BASE/api/buildings -H "authorization: Bearer $TOKEN" | assert (r => r.status === 201) | (r => r.json()) | capture BID (b => b.building.id)
23
+ GET $BASE/api/buildings/$BID -H "authorization: Bearer $TOKEN" | expect 200
24
+ sql "SELECT count(*)::int AS c FROM buildings WHERE name = 'Court'" | assert (r => r.c === 1)
25
+ DELETE $BASE/api/buildings/$BID -H "authorization: Bearer $TOKEN" | expect 204
26
+ GET $BASE/api/buildings/$BID -H "authorization: Bearer $TOKEN" | expect 404
27
+ ```
28
+
29
+ Run: `test-pipes 'tests/**/*.pipes' [-b] [-t ms] [-s mod.ts] [-o report.xml]`.
30
+ PASS/FAIL report lines are prefixed `file:LINE` with real file line numbers.
31
+ `-o`'s extension picks the format: `.xml` is JUnit (testcase per line),
32
+ `.json` the raw report — both CI-ingestable. Any other extension (or none)
33
+ writes the same text report stdout shows; the file never changes the exit code.
34
+
35
+ - **Setup module**: `-s mod.ts`, else sibling `<name>.setup.ts`; its
36
+ default export is awaited before the file and seeds `process.env`
37
+ ($BASE/$TOKEN). `sql` needs `$DATABASE_URL`.
38
+ - **SQL row types**: rows arrive as the driver returns them — uuid and enum
39
+ columns as strings, but `count(*)` and `numeric` may arrive as
40
+ strings/bigints. Cast in SQL when comparing numbers
41
+ (`count(*)::int AS c`), and compare ids as strings.
42
+ - **Hermetic per file**: `process.env` is snapshotted/restored around each
43
+ file — captures and setup vars never leak across files.
44
+ - Status-check placement: `expect N` when the check ends the line;
45
+ `assert (r => r.status === N)` when more stages follow (expect only
46
+ throws at drain).
47
+ - Redirect control: `--redirect error|manual|follow` on any http line —
48
+ `error` refuses a 3xx (a moved endpoint cannot pass by landing elsewhere;
49
+ the line fails with `redirect refused (--redirect error)`), `manual`
50
+ yields the 3xx so `GET … --redirect manual | expect 301` asserts a
51
+ redirect exists, `follow` is the default. This is the pipe-side twin of a
52
+ fixture's `input.redirect` — when a pipe cannot express a case, the
53
+ fixture can, and it has always had this control. In per-item/load position
54
+ a refusal is a `{status: 0, redirectRefused: true}` RECORD and the line
55
+ exits 0 — gate with `expect 200` or `stats | assert (s =>
56
+ s.redirectRefused === 0)`; exit 0 alone is never request success in load
57
+ mode.
58
+ - Exit codes: 0 all pass · 1 any fail · 2 no files/bad args.
59
+
60
+ ## .crust.ts fixtures (test-fixture)
61
+
62
+ One fixture = one request. `setup()` returns a ctx; `input` fields that are
63
+ 1-arg functions receive it; `output` fields that are 1+-arg functions are
64
+ matchers `(actual, ctx)` — async matchers awaited:
65
+
66
+ ```ts
67
+ export default {
68
+ name: "creates a user",
69
+ setup: async () => ({ token: await login() }),
70
+ input: {
71
+ url: "http://localhost:3000/api/users",
72
+ method: "POST",
73
+ headers: (ctx) => ({ authorization: `Bearer ${ctx.token}` }),
74
+ body: { name: "x" },
75
+ },
76
+ output: {
77
+ status: 201,
78
+ data: async (d, ctx) => (await db.userById(d.id)) !== null,
79
+ },
80
+ };
81
+ ```
82
+
83
+ Run: `test-fixture 'tests/*.crust.ts' -j8 [-n N] [-t ms] [-b] [-o report.xml]`.
84
+ `-o`'s extension picks the format — `.xml` is JUnit for CI: testcase per
85
+ run (stress iterations individually), percentiles in `<system-out>`; `.json` is
86
+ the raw report, `.md` markdown, anything else the text report.
87
+
88
+ `input.redirect: "error" | "manual" | "follow"` reaches fetch like any other
89
+ `RequestInit` field — the fixture-side twin of the http stage's `--redirect`
90
+ flag. `"error"` fails the fixture with the field named when the upstream
91
+ answers 3xx (a moved endpoint cannot pass by landing elsewhere); `"manual"`
92
+ records the 3xx so `output: { status: 301 }` asserts a redirect exists. Any
93
+ other value is a loud fixture error, not a fetch mystery.
94
+
95
+ `output.schema`: give it an inline JSON Schema and the response body must
96
+ conform — violations fail with per-field pointer paths. Inline means
97
+ $ref-free: a `$ref` anywhere in the schema is a loud error (the runner has
98
+ no spec to resolve it against — it would otherwise pass silently).
99
+ Unknown schema keywords pass (never-invent-a-violation). gen-fixtures emits
100
+ this automatically when the spec documents a response schema for a case's
101
+ expected status.
102
+ Fixtures may run CONCURRENTLY under -j/--threads — share state only via a
103
+ module-scope promise-cached factory. Fixture files may import ONLY relative
104
+ modules and Bun builtins (the binary can't resolve npm at runtime) —
105
+ `Bun.SQL`, `Bun.file`, `Bun.jwt`.
106
+
107
+ ## gen-fixtures — spec-driven cases
108
+
109
+ ```
110
+ gen-fixtures ./openapi.json [--no-flows] # --out/--setup default to tests/gen
111
+ ```
112
+
113
+ Emits (deterministic, byte-stable — check the output in, regenerate, review
114
+ the `git diff`):
115
+
116
+ - **Negative matrix** per op (`<tag>.gen.crust.ts`): 401 no-creds, 403
117
+ authenticated-outsider (scope-gated paths), 404 unknown-id, and a 400
118
+ matrix per body field — missing required, wrong type, bad enum, too
119
+ short/long (minLength/maxLength), below/above numeric bounds, pattern
120
+ violations, unexpected extra property. Nullable `anyOf` fields unwrapped.
121
+ - **CRUD flows** (`flows/flows.gen.pipes` + sibling setup, run by
122
+ test-pipes with zero extra flags): create → read (body round-trips the
123
+ captured id) → update → delete → read-after-delete-404, chained with
124
+ `capture`, auth via `$GEN_AUTH_HEADER`. SQL assertions are NOT derivable
125
+ from a spec — add those by hand in your own .pipes.
126
+
127
+ Setup-module contract (imported lazily): `shared()` promise-cached context,
128
+ `headersFor(ctx, "none"|"member"|"outsider")`, `resolvePath(ctx, template)`,
129
+ `scopeParam`, `scopeRoots?`, `JSON_HEADERS`, and optional
130
+ `flowOverrides: { "<template>": { body?: {...}, skip?: true } }` for creates
131
+ whose values a spec can't express (business date rules → `body` merge;
132
+ required foreign keys → `skip`).
133
+
134
+ The `--out` dir is DELETED and recreated every run — never hand-edit
135
+ generated files.
136
+
137
+ What crust cannot attribute it does not emit. The pattern sampler returns a
138
+ knowingly-wrong placeholder for a pattern it cannot read — a lookahead, Python
139
+ `\A`/`\Z` anchors, a JavaScript regex literal written into `pattern` (groups
140
+ it reads: `(?:…)`, `(a|b)`, `(…){n}`, so an AWS id like
141
+ `^(subnet-[0-9a-f]{8}|subnet-[0-9a-f]{17})$` yields `subnet-00000000`); in a
142
+ base body a placeholder makes a case about `name` invalid in `subnetId`, so its
143
+ 400 names the wrong field. Those cases — and a CRUD flow
144
+ whose create body cannot be filled — are dropped and NAMED on stdout
145
+ (`gen-fixtures: 2 case(s) skipped for POST /things …`, `skipping flow for
146
+ /things …`). A silently smaller suite would look like coverage.
147
+
148
+ ## Workflow for a new API
149
+
150
+ 1. `gen-fixtures` against the spec → run matrix + flows → fix what fails.
151
+ 2. Hand-write `.pipes` for business flows the spec can't express (SQL
152
+ checks, multi-resource invariants).
153
+ 3. Reach for `.crust.ts` only when a case needs real code (crypto, files,
154
+ custom matchers).
@@ -0,0 +1,238 @@
1
+ ---
2
+ name: crust-builtins
3
+ description: crust has two separate 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 usable as a source or mid-pipeline. Explains what the upstream item means to each one, why `bundle(5)` is a shell syntax error, and how to reach crypto/FS from a lambda. Use when a crust stage exits 2 with `syntax error near unexpected token`, when a mid-pipeline function eats the wrong argument, or when a lambda says `salt is not defined`.
4
+ ---
5
+
6
+ # crust's two namespaces
7
+
8
+ | | What it is | Where it runs | Parens? |
9
+ | --- | --- | --- | --- |
10
+ | **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` |
11
+ | **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)` |
12
+
13
+ `cd`, `export`, `alias`, `source`, `exit`, `history`, `help` and the job verbs
14
+ `jobs`, `fg`, `bg` are builtins too — the ones sh has its own version of, which
15
+ is why a redirect on *those* is left to sh. `procs`, `range`, `load`, `read`, `tail` and friends are **sources**,
16
+ not builtins.
17
+
18
+ `source` is the only session builtin with its own argument grammar:
19
+ `source <file> [args…]` — a `.ts`/`.js` file is imported; a `.sh`/`.bash`/`.zsh`
20
+ file (or one whose first line is a `#!` shebang naming a shell) runs **once in
21
+ a child shell** — its exported `KEY=value` delta comes back into the session,
22
+ its exit code is the line's, its side effects are real, but unsets don't
23
+ propagate and its cwd never comes back; anything else runs line-by-line in this
24
+ session (aliases, `export`s and `capture`s persist). An unmarked file that
25
+ looks like multi-line shell, or that doesn't parse as crust, is refused,
26
+ never half-run. In the line-by-line form the args are the *sourced file's*
27
+ positional arguments (`$1`, `$2`, … — `$0` is its own path), and the caller's
28
+ positionals are restored when the file ends; the shell form's child never sees
29
+ them — its `$1…` are its own shell's, not the file's:
30
+
31
+ ```crust
32
+ # demo.sh does `export DEMO=one` and changes cwd; only the export survives
33
+ source ./demo.sh
34
+ export # → … DEMO=one … — the env delta came back, the cwd did not
35
+ # line-by-line only: the args are the file's $1… ($0 its path)
36
+ source ./more.crust one two
37
+ ```
38
+
39
+ ## Job control is a third shape
40
+
41
+ `jobs`, `fg`, `bg` are ordinary builtins. `wait %1` and `kill %1` are **not** —
42
+ registering either name wholesale would take a word sh (or crust) already owns,
43
+ so only the `%job` operand form is intercepted and every other line keeps its
44
+ owner:
45
+
46
+ ```crust
47
+ wait :3000/health --timeout 5s # the registered `wait` function — readiness
48
+ kill -9 4242 # sh's: a PID is not a job spec
49
+ wait %1 # the job wait
50
+ kill %1 # the job signal — reports the SEND, not the death
51
+ ```
52
+
53
+ A trailing `&` on a shell line is crust's too (its own process group, stdin
54
+ `/dev/null`, outlives the session), and `jobs`/`fg`/`bg`/`wait %N` are how it
55
+ comes back. `Ctrl-Z` suspends a crust pipeline, builtin or `fg`/`wait` with
56
+ SIGSTOP; it cannot stop a foreground inherit-stdio child, because the runtime
57
+ ignores SIGTSTP and children inherit that ignore.
58
+
59
+ The single most common mistake is writing the JS form:
60
+
61
+ ```
62
+ bundle(5) → sh: -c: line 1: syntax error near unexpected token `(' (exit 2)
63
+ ```
64
+
65
+ Parens are crust syntax only for **lexer sources** (`range(0,9)`,
66
+ `read("…")`) — everything else in a stage is a shell word list.
67
+
68
+ A stage that *starts* with `{` or `[` is a JSON literal when it parses, or when
69
+ tight text still looks like someone typing one — a quote, colon, comma or a `$`
70
+ inside the brackets (`{"n":$N}`, `[1,$N]`). The shell's own bracket shapes are
71
+ always spaced, so `[ … ]` and `{ …; }` are shell stages, in any position — and a
72
+ mid-pipeline one works too, which it used not to:
73
+
74
+ ```crust
75
+ { echo a; echo b; } | cat
76
+ [ -f package.json ] && echo there
77
+ ```
78
+
79
+ ## What the item means, per function
80
+
81
+ A registered function is called `fn(item, ...lineArgs)` mid-pipeline. So the
82
+ first slot is **your data**, and each function decides what to do with it:
83
+
84
+ | Function | As a source | Mid-pipeline |
85
+ | --- | --- | --- |
86
+ | `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 |
87
+ | `salt [bytes] [hex\|base64\|base64url]` | one salt, size = the argument | **one salt per item**; the size is the argument *trailing* the item |
88
+ | `jwt sign\|verify\|decode --secret <s>` | payload/token = the argument | the item **is** the payload (sign) or token (verify/decode) |
89
+ | `sql "<query>" [params…]` | streams rows | the query stays the one you wrote; the item **binds as the first parameter** when the line declares none |
90
+ | `mongo "<expr>"` | streams documents — an expression on `db` (`db.users.find({}).limit(10)`), cursor drained per item | the item binds as `item` in the expression — `… \| mongo "db.users.insertMany(item)"`; needs the mongo-bundled binary (`@lariocpt/crust-mongo`) |
91
+ | `wait <target> [--timeout <dur>]` | waits for the argument | the item is the target |
92
+ | `bundle <entry> [--outfile…]` | bundles the argument | a string item **is an entrypoint** |
93
+
94
+ ```crust
95
+ base64 hello
96
+ echo aGk= | base64 -d
97
+ salt 32 base64
98
+ lines users.csv | salt 16
99
+ jwt sign '{"sub":"42"}' --secret k
100
+ sql "select id, email from users limit 5" | (r => r.email)
101
+ range(2,2) | (n => "beta") | sql "SELECT name FROM t WHERE name = ?" | assert (r => r.name === "beta")
102
+ mongo "db.users.find({active: true}).limit(10)"
103
+ read docs.json | (s => JSON.parse(s)) | mongo "db.users.insertMany(item)"
104
+ wait :3001/health --timeout 30s
105
+ bundle src/index.ts --outfile dist/app.js --minify
106
+ ```
107
+
108
+ Options come from the line, never from the item: `printf '%s' -d | base64` is
109
+ `LWQ=`, the two characters encoded, not a mode flip. A dash that is not one of
110
+ that function's options is refused rather than encoded (`base64 --desc hello`
111
+ used to print `LS1kZXNj` and exit 0), and decoding refuses a character outside
112
+ the alphabet — Node's decoder skips unknown characters, which printed three
113
+ replacement characters at exit 0 for `not base64!!!`.
114
+
115
+ Each of the six also answers `-h`/`--help` with its own usage screen and exit
116
+ **0** — at parse time, **before any stage runs**, so `… | sql --help` prints
117
+ usage without making the request (the `touch` upstream of it never happens).
118
+ The scan stops at `--`, so `base64 -- --help` encodes the flag, and a value
119
+ that arrives through the pipe is data, never an option
120
+ (`printf '%s' --help | base64` encodes it). A `crust.fn` override of the name
121
+ keeps its own argument handling — `--help` is just another argument to it.
122
+
123
+ ```crust
124
+ base64 --help
125
+ ```
126
+
127
+ `wait` failing to become ready exits **1** — measured for every target form
128
+ (`:PORT`, `:PORT/path`, `port:PORT`) and for a `--timeout` that expires. crust
129
+ has no exit **124**: that number is `timeout(1)` killing crust from outside, so
130
+ wrapping a probe in `timeout` steals the code you were gating on.
131
+
132
+ ## A lambda cannot call them
133
+
134
+ Registered functions live in crust's function table, not in the JS scope a
135
+ lambda compiles into:
136
+
137
+ ```
138
+ range(2,2) | (x => salt(4)) → crust: salt is not defined (exit 1)
139
+ ```
140
+
141
+ That is not a dead end: a lambda body has Bun's own globals, and `await` works
142
+ in it, so reach the underlying capability directly.
143
+
144
+ ```crust
145
+ range(0,9) | (_ => crypto.getRandomValues(new Uint8Array(8)).toHex())
146
+ range(0,0) | (_ => JSON.parse(await Bun.file("package.json").text()).name)
147
+ ```
148
+
149
+ Prefer the registered function when it fits (`salt 16` per item is shorter than
150
+ the `crypto` line); drop to a lambda when you need something crust doesn't ship.
151
+
152
+ ## Session environment
153
+
154
+ A line of pure assignments *is* the session environment — it runs the same code
155
+ `export` runs, so the two spellings cannot disagree:
156
+
157
+ ```crust
158
+ FOO=bar
159
+ PATH=$PATH:/tmp/bin
160
+ CFLAGS+=$EXTRA_FLAGS
161
+ TOKEN='$literal'
162
+ ```
163
+
164
+ `FOO=bar` used to be `sh -c 'FOO=bar'` — exit **0**, value gone, and the next
165
+ line expanded `$FOO` to nothing. Quoting is sh's: `'…'` protects `$`, `"…"`
166
+ expands it, and a backslash protects exactly one character, so `FOO=C:\temp`
167
+ survives.
168
+
169
+ Two shapes are deliberately **not** session state:
170
+
171
+ - `FOO=bar cmd args` is sh's *transient* prefix — the variable lives for that
172
+ one command — so the line goes to sh untouched and later lines see nothing.
173
+ - `FOO=$(cmd)` is refused rather than run: its value would have to cross back
174
+ out of a child process. The honest route is a pipeline — `git rev-parse HEAD`
175
+ piped into a `capture` stage of that name. Quoting doesn't change that, and
176
+ neither does a pipe inside the substitution (`FOO=$(date | head -1)` is
177
+ refused too, not quietly dropped); a `$()` in a *shell* stage stays
178
+ sh's, so `echo "$(date)"` still prints the date — but a `$()` in a
179
+ *crust* stage is refused before anything runs, and the named route is
180
+ capture: `date +%s | capture TS`, then `GET :3000/?t=$TS`.
181
+
182
+ ## These are not shell commands
183
+
184
+ `base64`, `sort`, `head`, `wc` and friends exist as **real binaries** too. crust
185
+ runs its own when the name is registered (`base64 hello` encodes the *text*, not
186
+ a file), and sh's when it is not (`… | sort` is `/usr/bin/sort`). Neither one
187
+ takes a redirect: crust refuses it and tells you what to write instead — with
188
+ two different messages, because a function and a tool fail differently.
189
+
190
+ `base64` is the shadow that bites: `/usr/bin/base64 in.ts` encodes the FILE,
191
+ crust's encodes the five characters of the path (`aW4udHM=`) and now says so on
192
+ stderr. A file's bytes are `base64 --file logo.png` — crust's only binary-safe
193
+ route, because a pipeline decodes UTF-8 on the way (`read logo.png | base64`
194
+ gives `77+9…` for a PNG whose real answer is `iVBORw0…`) — and `--out <path>` is
195
+ how the answer reaches a file, since a function stage cannot be redirected.
196
+
197
+ A **registered function** with a redirect is refused as a stage, and pointed at
198
+ a shell tail:
199
+
200
+ ```
201
+ sql "select 1" > one.txt
202
+ → crust: `> one.txt`: crust has no redirect except on a shell stage, whose
203
+ text goes to sh as-is — a sql stage would read it as data. to save a
204
+ pipeline, end the line with a shell stage instead: | cat > one.txt
205
+ ```
206
+
207
+ The refusal also catches a stage the redirect **hides**. Stage patterns match the
208
+ whole text, so `range(1,3) > f` stopped matching as a `range` and fell through to
209
+ `sh -c`, where sh's own syntax error answered at exit 2 (and `parallel 5 > f`
210
+ ran GNU parallel and exited 0 with an empty file). crust strips the tail, sees
211
+ the stage, and refuses. Affected: `range`, `capture`, `parallel`, `expect`,
212
+ `stats`, `stdin`, and `load` — where the tail used to break load's parser first
213
+ and the complaint was about the rate plan. `grep` is the exception: its redirect
214
+ is the shell's and works, so `read **/*.log | grep ERROR > errors.txt` is a
215
+ supported way to save a filtered log.
216
+
217
+ ```crust
218
+ base64 hello | cat > out.b64
219
+ base64 --file logo.png --out logo.b64
220
+ base64 --file logo.b64 -d --out logo-copy.png
221
+ ```
222
+
223
+ A **tool builtin** is refused as a program, and the message prints the exact
224
+ whole-invocation redirect that works:
225
+
226
+ ```
227
+ verify-web-links --base-url https://example.com --json > report.json
228
+ → crust: verify-web-links is a crust builtin, it runs in-process and cannot
229
+ redirect. Redirect the whole invocation instead:
230
+ crust -c 'verify-web-links --base-url https://example.com --json' > report.json
231
+ ```
232
+
233
+ ```bash
234
+ crust -c 'verify-web-links --base-url https://example.com --json' > report.json
235
+ ```
236
+
237
+ Tools that produce a file themselves have a flag for it (`-o` / `--outfile` /
238
+ `--report`); check that before reaching for `>`.