@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.
- package/README.md +174 -2
- package/bin/crust +64 -0
- package/lib/install.mjs +330 -0
- package/lib/skills-prompt.mjs +134 -0
- package/package.json +55 -4
- package/scripts/postinstall.mjs +36 -0
- package/skills/crust-api-testing/SKILL.md +154 -0
- package/skills/crust-builtins/SKILL.md +238 -0
- package/skills/crust-cli/SKILL.md +215 -0
- package/skills/crust-load-testing/SKILL.md +123 -0
- package/skills/crust-logs/SKILL.md +99 -0
- package/skills/crust-mcp/SKILL.md +108 -0
- package/skills/crust-mock-server/SKILL.md +138 -0
- package/skills/crust-pipelines/SKILL.md +161 -0
- package/skills/crust-procs/SKILL.md +108 -0
- package/skills/crust-verify-web-links/SKILL.md +126 -0
|
@@ -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.
|