@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,138 @@
1
+ ---
2
+ name: crust-mock-server
3
+ description: Mock an API from an OpenAPI spec with crust mock-server — example-first responses, stateful CRUD (in-memory or persisted to sqlite/postgres with --state, seedable with --seed), request validation with 422s, and validation-proxy mode that checks a REAL API's spec conformance. Use for mocking backends, frontend dev without a server, contract/conformance testing, or checking whether an API matches its openapi.json.
4
+ ---
5
+
6
+ # crust mock-server
7
+
8
+ One builtin, four modes, all driven by an OpenAPI 3.x spec (Swagger 2.0
9
+ auto-converts; URL or local json/yaml):
10
+
11
+ ```
12
+ mock-server ./openapi.json -p4000 # example-first mock
13
+ mock-server ./openapi.json -p4000 --stateful # + in-memory CRUD
14
+ mock-server ./openapi.json -p4000 --state ./m.sqlite # + persistent CRUD (implies --stateful)
15
+ mock-server ./openapi.json -p4000 --validate # + reject bad requests (422)
16
+ mock-server ./openapi.json -p4747 --proxy http://localhost:3001 # conformance proxy
17
+ ```
18
+
19
+ Builtins can't be piped — run the server in one terminal (or `procs()`), and
20
+ pipe against it from another line.
21
+
22
+ ## Mock + stateful
23
+
24
+ Responses prefer the spec's `example`/`examples`, then synthesize from the
25
+ schema (enum[0], format-aware strings, nested objects). Status pick:
26
+ 200 → 201 → other 2xx → `default`. Unknown path → 404; known path, wrong
27
+ method → 405.
28
+
29
+ `--stateful` pairs `/things` with `/things/{id}` automatically: POST stores
30
+ (id from body or a UUID), GET lists what you stored (shaped to the spec's
31
+ envelope), GET/{id} → 404 for unknown ids, PUT/PATCH shallow-merge, DELETE
32
+ → 204. Untouched collections keep serving spec examples. Default is
33
+ per-process memory; `--state` persists (below).
34
+
35
+ **Entity envelopes round-trip.** If the POST's 201 example wraps the entity
36
+ (`{"thing": {"id": …}}` — one object-valued prop, no top-level id), the mock
37
+ detects it: responses are wrapped with the real stored entity inside, request
38
+ bodies wrapped the same way (per the POST's request example) are unwrapped,
39
+ and the store keeps the BARE entity. So capturing a wrapped id works against
40
+ the mock:
41
+
42
+ ```crust
43
+ {"thing": {"name": "x"}} | POST :4000/api/things | assert (r => r.status === 201) | (r => r.json()) | capture TID (t => t.thing.id)
44
+ GET :4000/api/things/$TID | expect 200
45
+ ```
46
+
47
+ Flat specs capture `t.id` exactly as before. Ambiguous shapes (two
48
+ object-valued props, a top-level `id`) stay flat — deterministic, never
49
+ guessed.
50
+
51
+ ## Persistent state (`--state`) and seeding (`--seed`)
52
+
53
+ Both imply `--stateful` and exclude `--proxy`.
54
+
55
+ - `--state <path|url>` — bare path or `sqlite://` file, or
56
+ `postgres://`/`postgresql://` URL (other schemes exit 2). Survives
57
+ restarts; shared cross-process (reads hit the DB every request, writes are
58
+ single-statement upserts, last write wins). Boot line shows
59
+ `(state: sqlite)` / `(state: postgres)`.
60
+ - `--seed <file.json>` — `{ "/api/things": [ {…}, … ] }`; items without an
61
+ `id` get a uuid. EMPTY collections only: re-booting over a persistent
62
+ store never duplicates or clobbers. Unknown collection keys warn and boot
63
+ anyway; invalid file exits 1. Boot line shows `(seeded N)`.
64
+
65
+ Table contract (created idempotently; assert on it from pipelines):
66
+ `crust_mock_state(collection TEXT, id TEXT, doc TEXT|JSONB, updated_at, PRIMARY KEY (collection, id))`
67
+ where `doc` is the BARE entity as JSON. With the `sql` builtin
68
+ (`DATABASE_URL=sqlite://./m.sqlite` works — Bun.SQL speaks sqlite URLs;
69
+ rows stream one item each):
70
+
71
+ ```crust
72
+ sql "SELECT json_extract(doc, '$.name') AS name FROM crust_mock_state WHERE collection = '/api/things' AND id = '$TID'" | assert (r => r.name === "x")
73
+ ```
74
+
75
+ (postgres: `doc->>'name'` instead of `json_extract`.)
76
+
77
+ ## Failure injection (`PUT /__crust/override`) — mock mode only
78
+
79
+ Arm the mock to fail when the APP owns the request (no test-controlled
80
+ header possible): `PUT /__crust/override` with `{method, path, status,
81
+ body?, contentType?, times?, match?: {bodyContains}}`. Under `-j/--threads`
82
+ ALWAYS scope with `match.bodyContains` (a unique value the fixture put in
83
+ its own request body) + `times: 1` — unscoped overrides race sibling
84
+ fixtures. An armed override beats --validate/--stateful; `GET` lists,
85
+ `DELETE` clears. Never consulted in --proxy mode.
86
+
87
+ ## Request validation (`--validate`)
88
+
89
+ Spec-violating requests get **422** (not the API's own 400 — deliberately
90
+ distinguishable) with header `x-crust-validation: request` and body
91
+ `{error, violations: [{pointer, rule, message, expected?, received?, location}]}`.
92
+ Checks: JSON body (type/required/enum/format/pattern/lengths/ranges,
93
+ anyOf/oneOf/allOf), path + query params (coerced by declared type). The
94
+ governing rule: a schema the validator can't judge PASSES — it never
95
+ invents a violation (notably `additionalProperties: false` is not
96
+ enforced unless you add `--strict`). Composes with `--stateful`: an
97
+ invalid POST creates nothing.
98
+
99
+ `--strict` (implies `--validate`; composes with `--proxy`, both
100
+ directions) enforces a LITERAL `additionalProperties: false` at plain
101
+ object nodes only: allOf-merged objects, nodes with combinator siblings,
102
+ and patternProperties stay exempt — so it still never invents a
103
+ violation for spec composition idioms. Inside anyOf/oneOf branches an
104
+ extra key just fails that branch's selection.
105
+
106
+ ## Validation proxy (`--proxy <upstream>`) — spec-conformance testing
107
+
108
+ Forwards every request to the real upstream, returns the response
109
+ UNTOUCHED, and records spec violations out-of-band in BOTH directions:
110
+ request violations, undocumented response statuses, undocumented
111
+ content-types, response-schema mismatches (only where the spec has response
112
+ schemas), and `undocumented-operation` for paths the spec doesn't know
113
+ (still forwarded — a browsing frontend keeps working). Upstream down → 502,
114
+ not a violation. Mutually exclusive with `--stateful`.
115
+
116
+ The findings are pipeable — this is the conformance gate:
117
+
118
+ ```crust
119
+ GET :4747/__crust/violations | (r => r.json()) | (v => v.violations.filter(x => x.direction === "response")) | assert (a => a.length === 0)
120
+ ```
121
+
122
+ The `(r => r.json())` stage is not optional: an HTTP stage hands you the
123
+ response object, so reading `.violations` off it straight away is `undefined`
124
+ and the gate dies with `crust: undefined is not an object (evaluating 'v.violations.filter')` — which looks like a broken assert rather than a missing decode.
125
+
126
+ `DELETE /__crust/violations` clears between runs; `--report file.ndjson`
127
+ appends every violation as NDJSON for CI artifacts (requires `--proxy`).
128
+
129
+ Workflow: point your existing test suite's base URL at the proxy port, run
130
+ the suite, then assert zero response-direction violations. (Request-direction
131
+ violations are EXPECTED if your suite includes negative cases — filter by
132
+ direction.)
133
+
134
+ ## Exit codes
135
+
136
+ 0 clean shutdown (SIGINT/SIGTERM) · 1 runtime failure (spec load, bad seed
137
+ file) · 2 bad args (including `--proxy` + `--stateful`/`--state`/`--seed`,
138
+ an unsupported `--state` scheme, or `--report` without `--proxy`).
@@ -0,0 +1,161 @@
1
+ ---
2
+ name: crust-pipelines
3
+ description: Compose crust pipelines — sources (range, glob, read, tail, GET, load, procs, sql), transforms (TS lambdas, filter, HTTP verbs, parallel), assertions (expect, assert), and request chaining with capture. Use when writing or debugging crust one-liners, `crust -c` scripts, .crust script files, or .pipes lines that mix HTTP, SQL, and processes.
4
+ ---
5
+
6
+ # crust pipelines
7
+
8
+ crust is a Bun-powered pipeline runner where every command is a typed stream
9
+ (`Pipeline<T>`). Stages compose under `|`. Plain shell still works — a line
10
+ whose stages are all ordinary commands is handed to `sh -c` untouched.
11
+
12
+ ## How a stage is classified (first token decides)
13
+
14
+ | You type | You get |
15
+ |---|---|
16
+ | `range(0, 9)` | numbers 0..9 inclusive, one item each |
17
+ | `**/*.ts` | glob source — file paths |
18
+ | `lines **/*.log` | one item per LINE across every match (sorted; zero matches = error). Use this for log mining — `read` gives whole files. Bare `lines` mid-pipeline splits upstream items |
19
+ | `read fixtures/*.json` | whole-file contents, one item per file (sorted; zero matches = error) — the request-body form: `read fixtures/*.json \| POST …` |
20
+ | `tail -F app.log` | native tail source (`-n N`, `-F` follow); the `-n N` cut is a bounded backward read, safe on huge files |
21
+ | `stdin` (alias `-`) | piped-stdin source, one item per line — `docker logs -f X \| crust -c 'stdin \| …'`. Source position only. Bare `cmd \| crust` treats stdin as a SCRIPT, so data pipes need `-c` |
22
+ | `grep ERROR` mid-pipeline | native line-buffered grep (`-i`/`-v`/`-F`, ONE pattern; per-line even on multi-line items) — follow streams don't stall on grep's 4KB pipe buffer. Combined/unknown flags, two positionals, any `$`, or GNU/JS-divergent regex bits (`\`, `[[:`, `{,`, `(?`) = exact system grep via sh; first-stage grep = file grep via sh |
23
+ | `load 30s 100/s` | paced load ticks (see the crust-load-testing skill) |
24
+ | `{"name": "x"}` | JSON-literal source — ONE parsed item (the request body). Invalid JSON is a hard error |
25
+ | `GET :3000/path` | HTTP. First stage: one `Response`. Mid-pipeline: per-item timed `{status, ms, url}` |
26
+ | `POST/PUT/PATCH/DELETE url` | per-item request; upstream item = body (objects auto-JSON) |
27
+ | `GET url --timeout 2s` | per-request timeout on any http stage (`ms`/`s`/`m`); timed paths yield `{status: 0, timedOut: true}`, plain verbs fail the pipeline; unknown `--flags` are errors |
28
+ | `GET url --redirect error\|manual\|follow` | 3xx handling (fetch's `redirect` init): `error` refuses a redirect — plain verbs fail the line, timed paths yield `{status: 0, redirectRefused: true}`; `manual` yields the 3xx so `expect 301` asserts it; `follow` is the default. Fixtures carry it as `input.redirect` |
29
+ | `(x => x * 2)` | TypeScript lambda, per item, async ok — MAPS, never drops (falsy results are emitted as-is) |
30
+ | `filter (l => l.ok)` | keeps items whose predicate is truthy; falsy (`0`, `""`, `null`…) drop; async ok; empty result passes |
31
+ | `assert (r => r.ok)` | falsy FAILS the pipeline; empty upstream also fails |
32
+ | `capture NAME (r => r.id)` | write value to `process.env.NAME` for later lines |
33
+ | `expect 201` / `expect 2xx` | status assertion, counts mismatches, fails at drain |
34
+ | `stats [--every N] [--out f.json]` | summary: count/rps/status histogram/p50/p95/p99 + failure tallies `timedOut`/`redirectRefused` (records, not exits — gate them) |
35
+ | `parallel N` | modifier: fan-out for the NEXT stage (http, lambda, or fn — anything else errors) |
36
+ | `sql "SELECT …" ?-params` | registered fn — `?` is the bind placeholder; rows stream one item each (needs `$DATABASE_URL`) |
37
+ | `range(1,1) \| sql "… WHERE id = ?"` | mid-pipeline, the upstream item BINDS as the first parameter when the line declares none (an explicit param wins); rows stream one item each in both positions |
38
+ | anything else | plain shell via `sh -c` |
39
+
40
+ `:3000/path` expands to `http://localhost:3000/path` everywhere.
41
+
42
+ ## Env expansion — parse time, opt-in per position
43
+
44
+ `$VAR`/`${VAR}` expand from `process.env` in: URLs, whole `-H` header
45
+ strings, JSON literals, `stats --out` paths, and registered-fn args. They do
46
+ NOT expand inside lambda/`assert`/`capture` bodies (those are JS — use
47
+ `process.env.VAR`) or shell stages (sh does its own). In a script, the
48
+ **positional arguments** expand in those same positions — so `$1` inside a
49
+ `sql "…"` string is the script's `$1`, not a SQL bind: the bound-parameter
50
+ placeholder is `?`, and the trailing quoted args DO env-expand:
51
+ `sql "… WHERE id = ?" "$MY_ID"`.
52
+
53
+ `$(…)` command substitution lives in shell stages only: sh expands it once
54
+ at the head of a shell stage, and a `$` anywhere in a mid-pipeline stage
55
+ demotes it to sh, so `range(1,3) | grep $(echo 1)` still works. A `$(…)` in
56
+ a *crust* stage (a URL, a lambda, an argument) is **refused before anything
57
+ runs** — it used to build data from the literal text (`getaddrinfo ENOTFOUND
58
+ $(echo`) — so the named route is capture first: `date +%s | capture TS`,
59
+ then `GET :3000/?t=$TS`.
60
+
61
+ Inside a parenthesized lambda/`assert`/`capture` body, anything goes: the
62
+ tokenizer tracks paren depth and quotes, so literal `|`, `??`, single/double
63
+ quotes, and backticks (template literals) are all safe. In double-quoted
64
+ strings and JSON literals, `\"` escapes work.
65
+
66
+ ## Request chaining with capture
67
+
68
+ Each line parses right before it runs, so a capture on one line feeds `$VAR`
69
+ on every later line — the chaining contract for REPL, `-c` scripts, and
70
+ `.pipes` files:
71
+
72
+ ```crust
73
+ {"name": "Court"} | POST $BASE/api/buildings -H "authorization: Bearer $TOKEN" | assert (r => r.status === 201) | (r => r.json()) | capture BID (b => b.building.id)
74
+ GET $BASE/api/buildings/$BID -H "authorization: Bearer $TOKEN" | expect 200
75
+ ```
76
+
77
+ - Last item wins; omit the lambda to capture the item itself (objects are
78
+ JSON-stringified).
79
+ - A nullish captured value or empty upstream fails IMMEDIATELY — a typo'd
80
+ accessor dies on the capture line, not three lines later as a baffling
81
+ `""` expansion.
82
+ - Don't capture into `TOKEN`, `BASE`, or `PATH` unless you mean to overwrite
83
+ them.
84
+ - Capture is also the named route for command output you want downstream:
85
+ a `$(…)` in a crust stage is refused, so `date +%s | capture TS` followed
86
+ by `GET :3000/?t=$TS` is the round trip (sh's trailing newline is trimmed
87
+ off the captured value).
88
+
89
+ ## Verified one-liner patterns
90
+
91
+ ```crust
92
+ # log mining — `lines` yields one item per LINE, which is what filter wants
93
+ # (`read` yields whole FILES, so a filter would test the entire file at once)
94
+ lines **/*.log | grep ERROR | filter (l => !l.includes('healthcheck')) | wc -l
95
+
96
+ # lambda + shell mixing — items cross as lines (objects as JSON)
97
+ range(1, 5) | (n => n * n) | sort -rn
98
+
99
+ # HTTP fixture in one line: body | request | status | parsed-body assert
100
+ {"name": "x"} | POST :3000/users | assert (r => r.status === 201) | (r => r.json()) | assert (u => u.id > 0)
101
+
102
+ # DB verification — rows stream, assert sees each row object
103
+ sql "SELECT count(*)::int AS c FROM users WHERE email = ?" "a@b.c" | assert (r => r.c === 1)
104
+
105
+ # fan out any per-item work
106
+ read fixtures/*.json | parallel 8 | POST :3000/users | expect 2xx
107
+
108
+ # follow a log live — native grep emits each match immediately
109
+ tail -n 0 -F app.log | grep ERROR
110
+
111
+ # any command's output as a source (run under `crust -c`, data piped in)
112
+ stdin | (l => JSON.parse(l)) | filter (e => e.level >= 40) | (e => e.msg)
113
+ ```
114
+
115
+ To ITERATE on filters over one held live stream, use the `logs` builtin
116
+ (`logs tail -n 0 -F app.log`, `logs procs({…})`, `logs docker logs -f c`):
117
+ every line typed at its prompt is a fragment like the ones above, run over
118
+ a buffer of the recent past and then live; Ctrl-C once ends the view and
119
+ flushes terminal stages (bare `stats` prints there), `exit` leaves.
120
+
121
+ ## Traps
122
+
123
+ - A plain lambda MAPS — `(l => l.includes('ERROR'))` emits `true`/`false`
124
+ per item, and `(x => cond ? x : null)` prints literal `null` lines. To
125
+ drop items, use `filter (l => l.includes('ERROR'))`.
126
+ - `filter` passes an empty stream through silently; `assert` fails on EMPTY
127
+ upstream ("no items reached") — deliberate, it closes the
128
+ sql-returned-zero-rows silent pass. `expect` does not fail on empty either.
129
+ - `parallel` streams results in COMPLETION order, not input order.
130
+ - `parallel N` before anything except an http verb, lambda, or registered
131
+ fn is a parse error; so is a trailing `parallel N`.
132
+ - A glob source yields PATHS; `read <glob>` yields file CONTENTS. `POST`ing
133
+ a glob posts path strings.
134
+ - Builtins (`test-pipes`, `mock-server`, `logs`, …) cannot be piped. A builtin
135
+ name in a shell context — `mock-server spec.json | grep ok`, `range(0,2) |
136
+ test-fixture a.ts` — refuses naming the tool instead of letting sh answer
137
+ `command not found` for a tool crust runs in-process. Put the builtin on its
138
+ own line; `logs` takes its filters at the `logs>` prompt.
139
+ - No redirect on a crust stage. `<` / `>` mean something only on a SHELL
140
+ stage, whose text goes to `sh` verbatim: `… | cat > out.json` saves a
141
+ pipeline, `lines <path>` / `read <path>` feeds a file in. Anywhere else the
142
+ line is refused — an http stage used to swallow the operator (request ran,
143
+ nothing written, exit 0). A builtin carrying one refuses too, and the fix is
144
+ redirecting the whole call: `crust -c 'mock-server spec.json' > mock.log`.
145
+ - Native grep patterns are JS regexes: quoted `'a|b'` ALTERNATES (ERE)
146
+ where BRE grep matched the literal — use `grep -F 'a|b'` for the literal.
147
+ - `stdin` is single-shot per process: inside a bare `cmd | crust` script it
148
+ errors (the pipe was already consumed as the script itself).
149
+ - Shell stages get npm-run-style PATH: ancestor `node_modules/.bin` dirs
150
+ are prepended, so local tool binaries work bare (`| pino-pretty`,
151
+ `| tsc --noEmit`) — no `node_modules/.bin/` prefix, no global install.
152
+ - In `… | expect 200 | stats`, a failing expect throws at drain BEFORE stats
153
+ emits — gate status inside a stats assert instead if you need the summary.
154
+
155
+ ## Exit codes
156
+
157
+ Any stage throw → `crust: <message>` on stderr, line exits 1. `crust -c`,
158
+ `crust file.crust`, and piped stdin all run newline-separated lines through
159
+ the same fail-fast loop and STOP at the first non-zero — put warmup and
160
+ cleanup on their own lines. Blank lines and `#` comments are skipped, so a
161
+ `#!/usr/bin/env crust` shebang works in `.crust` files.
@@ -0,0 +1,108 @@
1
+ ---
2
+ name: crust-procs
3
+ description: Run and supervise dev processes with crust — procs() merges multiple long-lived commands into one pipeable stream with per-proc env, restart backoff, readiness probes (ready:), dependency ordering (after:), and process-group teardown; wait blocks until a port/URL is up. Use for dev runners ("start db, api, web in order"), readiness gating in CI, tailing multiple services, or mining merged process logs.
4
+ ---
5
+
6
+ # crust procs & wait
7
+
8
+ ## procs() — the one-tail dev runner
9
+
10
+ ```crust
11
+ procs({web: "bun run dev", api: "bun api.ts"}) | (l => l.proc + " | " + l.line)
12
+ ```
13
+
14
+ Every stdout/stderr line becomes `{proc, stream, line}` — a PIPELINE ITEM,
15
+ not text. Grep it, transform it, count it. Streams: `stdout`, `stderr`,
16
+ `exit`, `ready`, `live`.
17
+
18
+ Full spec form:
19
+
20
+ ```crust
21
+ procs({ db: {cmd: "docker compose up pg", ready: "port:5432"}, api: {cmd: "bun api.ts", after: "db", ready: ":3001/health", live: {url: ":3001/health", failures: 3}, restart: {max: 3}}, web: {cmd: "bun run dev", after: "api", env: {PORT: "3001"}} })
22
+ ```
23
+
24
+ - `env` — per-proc overlay on the inherited environment.
25
+ - `restart: true` — respawn on unexpected exit (backoff 250ms→2s; >10s
26
+ uptime while READY resets it — a proc that hangs un-ready until its
27
+ ready-timeout kill never does, and with `live:` uptime ends when a fatal
28
+ probe streak BEGAN, so a proc that wedges right after ready still accrues
29
+ strikes). `restart: {max: N}` gives up after N consecutive restarts.
30
+ Ctrl-C never respawns. **Giving up is not a failure**: `giving up after N
31
+ restart(s)` is an `exit`-stream line and the line still exits 0 (same for a
32
+ child that exits nonzero with no `restart:` — its stream just ends). Gate it
33
+ in CI: `| (l => { if (/giving up|unhealthy/.test(l.line)) throw new Error(l.line); return null })`.
34
+ - `ready:` — `":3001/health"` / `"http(s)://…"` (any 2xx) or `"port:5432"`
35
+ (TCP connect). Long form `{url?, port?, timeoutMs?, intervalMs?,
36
+ probeTimeoutMs?}` (defaults 30s / 250ms; each probe capped at
37
+ `min(intervalMs*4, 2s)` unless `probeTimeoutMs` raises it). `timeoutMs`
38
+ belongs INSIDE the ready object: as a sibling of `cmd` it is ignored
39
+ silently — `{cmd: "sleep 40", ready: "port:4199", timeoutMs: 1200}` waits
40
+ the full default 30s. Timeout: a restartable proc is killed and respawned
41
+ (readiness re-awaited each spawn); a non-restartable one FAILS the whole
42
+ pipeline — CI semantics.
43
+ - `live:` — liveness for the unhealthy-but-alive case. Same target forms;
44
+ long form `{url?, port?, intervalMs?, probeTimeoutMs?, failures?,
45
+ graceMs?}` (defaults 5s / 3 failures / 0 grace). Use `url:` here: a
46
+ `port:` probe is a TCP connect, so it cannot see a wedged listener — a
47
+ server answering 503 for four seconds with `live: {port, failures: 3}`
48
+ logged nothing and the run exited 0. Arms once the proc is up
49
+ (after `ready:`, else at spawn); `live`-stream lines: `probe failed
50
+ (k/N)`, `recovered after k failed probe(s)`, `unhealthy after N
51
+ consecutive failed probe(s)`. Fatal streak: restartable → killed +
52
+ respawned (re-arms after next ready); non-restartable → FAILS the
53
+ pipeline, like a ready-timeout.
54
+ - `after:` — name or list; the proc spawns only once its dependencies are
55
+ READY (no `ready:` on the dep = ready at spawn). One-shot gate; a dep
56
+ that dies before ever becoming ready fails the pipeline. Unknown names /
57
+ self-deps / cycles throw before anything spawns.
58
+ - Teardown: children run in their own process groups; SIGTERM to all, 3s
59
+ grace, then SIGKILL — grandchildren from `sh -c` don't survive.
60
+
61
+ ## Log mining on the merged stream
62
+
63
+ ```crust
64
+ procs({api: "bun api.ts", worker: "bun worker.ts"}) | (l => { try { return JSON.parse(l.line) } catch { return { level: 30, msg: l.line, proc: l.proc } } }) | filter (e => e.level >= 40) | (e => "WARN+ " + (e.msg ?? ""))
65
+ ```
66
+
67
+ (`filter` drops the sub-WARN entries; a plain lambda with `: null` would
68
+ print a literal `null` line for every one of them.)
69
+
70
+ Objects printed to stdout are JSON lines — pipe the whole thing into
71
+ `pino-pretty` via a shell stage, or count errors with `grep`/`wc`.
72
+
73
+ To iterate on these filters WITHOUT restarting the dev stack, hold it in a
74
+ `logs` session instead: `logs procs({api: "bun api.ts", worker: "bun
75
+ worker.ts"})` keeps the group running and buffers its recent output; each
76
+ line you type (`filter (l => l.proc === "api")`, `(l => l.line) | grep -i
77
+ error`) runs over the buffer, then live until Ctrl-C. Items stay `{proc,
78
+ stream, line}` objects. `exit`/Ctrl-D tears the group down (SIGTERM→SIGKILL).
79
+
80
+ ## wait — readiness as a one-liner (CI's friend)
81
+
82
+ ```crust
83
+ wait :3001/api/health --timeout 40s --interval 2s
84
+ ```
85
+
86
+ Blocks until the target answers (URL = any 2xx; `port:5432` = TCP connect),
87
+ then emits `{target, ready: true, ms, attempts}` and exits 0. Not ready in
88
+ time → error, exit 1. Durations: `300ms`, `30s`, `2m` (defaults 30s/500ms).
89
+ `--probe-timeout 5s` raises the per-probe cap (default `min(interval*4, 2s)`)
90
+ for targets that take longer than that to first byte.
91
+ Works in the REPL, `crust -c` (replaces hand-rolled curl-sleep retry loops
92
+ in CI), and `.pipes` files. Env-expanded args work:
93
+ `wait "$BASE_URL/health" --timeout 40s`.
94
+
95
+ ## Traps
96
+
97
+ - procs is source-only (first stage). Spec keys are JS object-literal keys, so
98
+ a duplicate collapses BEFORE crust sees it (`{a: "x", a: "y"}` runs only
99
+ `y`) — nothing warns you.
100
+ - The merged stream never ends while a `restart: true` child keeps
101
+ respawning — consume it with `for await`/pipe, bound it with a downstream
102
+ condition, or stop it with Ctrl-C: at the REPL that cancels the running
103
+ line and tears the whole process group down (SIGTERM, then SIGKILL).
104
+ - `FORCE_COLOR=0` is forced on children so lines stay parseable.
105
+ - To capture the stream, END with a shell stage: `procs({…}) | cat > file`
106
+ writes each item as its JSON line. A bare `procs(...) > file` is refused —
107
+ crust has no redirect on its own stages (the `>` would be read as data by a
108
+ pipeline stage); the outer shell can still wrap the whole invocation.
@@ -0,0 +1,126 @@
1
+ ---
2
+ name: crust-verify-web-links
3
+ description: Crawl a site from its sitemap and verify every link, #anchor, redirect chain, OG image and meta tag against .crust.ts fixtures. `--base-url` auto-discovers the sitemap (robots.txt → /sitemap.xml → /sitemap-index.xml), `--site-map-url` takes a local sitemap so you can check build output, `--fixtures` diffs meta. Use when link or SEO health must gate a deploy, when a built site should be checked before publishing, or when OG/meta regressions need catching.
4
+ ---
5
+
6
+ # crust verify-web-links
7
+
8
+ ## What it verifies
9
+
10
+ | Check | Fails when |
11
+ | --- | --- |
12
+ | internal + external links | any status that is not 2xx |
13
+ | `#fragment` targets | the id/name is not on the fetched page |
14
+ | redirect chains | a link 301s/302s somewhere (informational with `--no-redirect-warnings`) |
15
+ | OG / twitter images | the image URL is unreachable |
16
+ | meta vs `--fixtures` | a keyed value differs, or a predicate returns falsy |
17
+
18
+ Exit codes: `0` all clear, `1` verification failures, `2` bad args or an
19
+ unreachable sitemap. With `--strict`, `1` also covers a run that finished
20
+ having left discovered links unchecked. `2` also covers `--site-map-url`
21
+ together with `--base-url`, neither of them given, `no fixture files matched
22
+ <glob>`, `--concurrency must be >= 1`, and `unexpected argument`.
23
+
24
+ ## Getting it pointed at the right thing
25
+
26
+ ```crust
27
+ verify-web-links --base-url https://example.com
28
+ verify-web-links --site-map-url https://example.com/sitemap.xml
29
+ verify-web-links --site-map-url ./dist/sitemap.xml --no-recurse
30
+ verify-web-links --base-url https://staging.example.com --fixtures site/*.meta.crust.ts
31
+ ```
32
+
33
+ `--base-url` probes `robots.txt` for `Sitemap:` lines, then `/sitemap.xml`, then
34
+ `/sitemap-index.xml`; when none answer it names all three. `--site-map-url`
35
+ (aliased `--sitemap`) also takes a **local path**, which is the CI move: crawl
36
+ the build output rather than a deployed site. With `--no-recurse` only the URLs
37
+ in the sitemap are fetched.
38
+
39
+ ## Green does not mean checked
40
+
41
+ Three ways to get exit 0 with nothing actually verified. Read the note lines and
42
+ `totals` before believing a green run:
43
+
44
+ | Symptom | What it means |
45
+ | --- | --- |
46
+ | `N page(s) had to be recognised from their body` | the server sends HTML without saying `text/html`. crust followed the links anyway, but the mislabel is the site's bug |
47
+ | `N page(s) NOT read as HTML` | a sitemap entry or `<a>` target that is neither declared HTML nor smells like it (a JSON endpoint). Its links were **not** followed and the crawl ended there |
48
+ | a note naming a count (`anchors skipped…`, `stopped at --max-pages…`) | that work did not happen. `--no-recurse` and `--no-anchors` produce it deliberately |
49
+ | `"parsed": false` on a page in `--json` | no links and no ids were extracted from that page |
50
+
51
+ `totals.complete` is the one field that means "nothing was left unchecked":
52
+ false on any of the above. `--max-pages N` is a safety valve, not a fast mode —
53
+ the crawl stops early, the unchecked count is reported, and the exit code still
54
+ says 0 without `--strict`. Treat a `complete: false` run as inconclusive.
55
+
56
+ ## In CI
57
+
58
+ **`--strict` is the CI flag.** It exits 1 when the run finished having left
59
+ discovered links unchecked — a `--max-pages` cut-off, an unchecked `#fragment`,
60
+ a page crust could not read — so a bounded crawl cannot pass as a clean one:
61
+
62
+ ```crust
63
+ verify-web-links --base-url https://example.com --strict --max-pages 500
64
+ ```
65
+
66
+ A builtin runs in-process and cannot sit mid-pipeline —
67
+ `verify-web-links … | assert (…)` is refused with *"give it a line of its own"*.
68
+ Redirect the whole invocation instead, then judge the report:
69
+
70
+ ```bash
71
+ crust -c 'verify-web-links --base-url https://example.com --json --no-progress' > report.json
72
+ ```
73
+
74
+ ```crust
75
+ read report.json | (s => JSON.parse(s)) | assert (r => r.totals.failures === 0)
76
+ read report.json | (s => JSON.parse(s)) | assert (r => r.totals.complete)
77
+ ```
78
+
79
+ `read` yields each file's **contents as one item**, so `JSON.parse` receives the
80
+ whole document. The first line fails on a real failure; the second catches every
81
+ inconclusive case at once — `complete` is false if anything was dropped, any
82
+ `#fragment` went unchecked, or any page could not be read.
83
+
84
+ ## Meta fixtures
85
+
86
+ ```ts
87
+ export default {
88
+ url: "https://example.com/about",
89
+ meta: {
90
+ title: "About Us",
91
+ description: (d: string) => d.length > 50 && d.length < 160,
92
+ "og:image": (u: string) => u.endsWith(".png") || u.endsWith(".jpg"),
93
+ "twitter:card": "summary_large_image",
94
+ },
95
+ };
96
+ ```
97
+
98
+ A value is compared for equality; a function is a predicate over the extracted
99
+ value. A key a page does not have is **asked about**, not ignored — so asserting
100
+ `og:image` on a page that lost it is a failure, which is the point.
101
+
102
+ Fixture `url`s match **URL-normalized**: scheme+host case and default ports
103
+ don't matter, `https://x.com` and `https://x.com/` are the same page, but a
104
+ trailing slash on a non-root path stays significant (`/login/` ≠ `/login`). A
105
+ fixture whose url never appears in the crawl fails as `meta-fixture-no-page` —
106
+ a fixture for a page you deleted is a failure, not a skip.
107
+
108
+ ## Tuning
109
+
110
+ ```crust
111
+ verify-web-links --base-url https://example.com --exclude /checkout/ --exclude /go/
112
+ verify-web-links --base-url https://example.com --include-external --max-depth 2
113
+ verify-web-links --base-url https://example.com --concurrency 16 --timeout 3000
114
+ ```
115
+
116
+ - `--exclude <substring>` is repeatable. Use it for subtrees that redirect **by
117
+ design** (short-link senders, locale switches); redirect chains are failures
118
+ otherwise. `--no-redirect-warnings` silences them globally — only when you
119
+ mean it.
120
+ - Off-origin links are not queued at all without `--include-external`, and even
121
+ then they are never recursed into.
122
+ - `--max-depth` (default 5) bounds the crawl, `--concurrency` (default 4) and
123
+ `--timeout` (default 10000 ms) bound each fetch.
124
+ - Anchors come from the crawl: with `--no-recurse` the internal links found on
125
+ sitemap pages are not fetched, so their `#fragments` are not checked — the
126
+ report says how many were skipped.