@lariocpt/crust-mongo 0.0.0-stage → 0.3.6
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 +299 -0
- package/skills/crust-builtins/SKILL.md +238 -0
- package/skills/crust-cli/SKILL.md +215 -0
- package/skills/crust-load-testing/SKILL.md +161 -0
- package/skills/crust-logs/SKILL.md +99 -0
- package/skills/crust-mcp/SKILL.md +136 -0
- package/skills/crust-mock-server/SKILL.md +138 -0
- package/skills/crust-pipelines/SKILL.md +209 -0
- package/skills/crust-procs/SKILL.md +108 -0
- package/skills/crust-verify-web-links/SKILL.md +160 -0
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: crust-logs
|
|
3
|
+
description: Search a live log stream with crust's `logs` session — it holds a tail/procs/shell source open, keeps a ring buffer, and re-runs every typed pipeline fragment over the buffered past and then the live stream; `json on` parses NDJSON at query time, `search` counts the buffer instantly, `buffer N` resizes without restarting. Use when tailing dev services or containers, when a grep workflow keeps losing history, or when you want to widen a filter over logs you already saw.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# crust logs
|
|
7
|
+
|
|
8
|
+
## What it is
|
|
9
|
+
|
|
10
|
+
`logs <source>` keeps the source open and a ring buffer (default 10,000 items)
|
|
11
|
+
behind it, so one query can look at the PAST you already streamed and stay
|
|
12
|
+
attached to the live firehose. Every line you type at `logs>` is an ordinary
|
|
13
|
+
crust pipeline fragment — the whole shell grammar is the query language.
|
|
14
|
+
|
|
15
|
+
```crust
|
|
16
|
+
logs tail -n 0 -F app.log
|
|
17
|
+
logs procs({web: "bun run dev", api: "bun api.ts"})
|
|
18
|
+
logs docker logs -f my-container
|
|
19
|
+
logs --buffer 50000 tail -F big.log
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Boot prints what it holds: `logs: tail -n 0 -F app.log — buffering last 10000
|
|
23
|
+
items (`help` for commands, `exit` to leave)`.
|
|
24
|
+
|
|
25
|
+
## A fragment runs TWICE
|
|
26
|
+
|
|
27
|
+
Once over the buffer snapshot, then over the live stream until Ctrl-C, past a
|
|
28
|
+
`-- live --` marker. So side effects fire twice: append with `>>`, never `>`,
|
|
29
|
+
and a `POST` fragment re-sends buffered matches.
|
|
30
|
+
|
|
31
|
+
- **Ctrl-C once** ends the live view *gracefully* — the stream finishes, so
|
|
32
|
+
terminal stages flush (a query ending in `stats` prints its summary right
|
|
33
|
+
then; `wc -l` prints its count). **Ctrl-C twice** hard-cancels a stuck query.
|
|
34
|
+
The session survives both.
|
|
35
|
+
- A query that fails on the retro pass prints its error and re-prompts. Errors
|
|
36
|
+
never kill the session or the held source.
|
|
37
|
+
|
|
38
|
+
## Session commands
|
|
39
|
+
|
|
40
|
+
| command | what it does |
|
|
41
|
+
| --- | --- |
|
|
42
|
+
| `search <text>` | fixed substring over the BUFFER only — matches highlighted, and a count that always prints, zero included (`search: 5 matching item(s) of 36 buffered`). No live view, no Ctrl-C. For regex/case-folding/live, run a `grep` query |
|
|
43
|
+
| `json on` / `json off` | parse string items at QUERY time (see below) |
|
|
44
|
+
| `buffer` / `buffer N` | report usage (`buffer: 57/10000 items (pushed 57, evicted 0)`) or resize live, newest kept — widen a too-small window without losing the held source |
|
|
45
|
+
| `clear` | empty the buffer (`buffer cleared`) |
|
|
46
|
+
| `help` | the command list, including the double-run note |
|
|
47
|
+
| `exit` / Ctrl-D | tear the source down and leave (procs get SIGTERM→SIGKILL) |
|
|
48
|
+
|
|
49
|
+
Query errors print and re-prompt; up-arrow recalls earlier queries.
|
|
50
|
+
|
|
51
|
+
## json on — and the silent zero it prevents
|
|
52
|
+
|
|
53
|
+
With `json off` (the default) items are the raw strings. A lambda that reads
|
|
54
|
+
fields therefore matches NOTHING, quietly: 50 buffered NDJSON lines, and
|
|
55
|
+
`filter (e => e.level >= 30) | wc -l` reported `0`. `json on` fixes that at
|
|
56
|
+
query time: a string item parsing to a JSON **object or array** reaches your
|
|
57
|
+
lambdas parsed; plain text, strings that parse to primitives (`"42"` stays a
|
|
58
|
+
string), and non-string items flow through unchanged and are counted out loud.
|
|
59
|
+
The ring stores raw items, so `json off` is a lossless revert.
|
|
60
|
+
|
|
61
|
+
When the buffer looks like NDJSON the session prints, once, on your first
|
|
62
|
+
*pipeline fragment* (not on `help` or `search`):
|
|
63
|
+
|
|
64
|
+
```
|
|
65
|
+
hint: buffer looks like NDJSON — `json on` parses object lines at query time (`json off` reverts)
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
It never auto-enables — changing the item type under a lambda you already typed
|
|
69
|
+
would be a false picture.
|
|
70
|
+
|
|
71
|
+
## Item types by source
|
|
72
|
+
|
|
73
|
+
- `tail`/shell sources: plain **strings**.
|
|
74
|
+
- `procs({...})`: `{proc, stream, line}` **objects** — the session tells you so
|
|
75
|
+
at boot (`logs: items are {proc, stream, line} objects — try (l => l.line) or
|
|
76
|
+
filter (l => l.proc === "web")`). `(l => l.line)` extracts text,
|
|
77
|
+
`filter (l => l.proc === "web")` selects one process, and `search` still works
|
|
78
|
+
(it matches each item's rendered form).
|
|
79
|
+
- Pretty-printing is just another stage: `grep api_request | pino-pretty
|
|
80
|
+
--colorize --singleLine` (`--colorize` forces ANSI through the pipe; local
|
|
81
|
+
`node_modules/.bin` is on PATH). Normalize `procs` objects first.
|
|
82
|
+
|
|
83
|
+
## Traps
|
|
84
|
+
|
|
85
|
+
- `logs` is interactive-only. No tty → exit 2 and a pointer:
|
|
86
|
+
`logs: interactive session needs a tty — for piped data use `cmd | crust -c 'stdin | …'``.
|
|
87
|
+
For piped data there is no session — build the pipeline directly.
|
|
88
|
+
- The `logs` line owns its line: `logs tail -F app.log | grep ERROR` refuses
|
|
89
|
+
(`logs is a crust builtin — it runs in-process, so sh cannot run it`) rather
|
|
90
|
+
than letting sh report `command not found`. Type the filter at `logs>`.
|
|
91
|
+
- A gate query (`count 1`, `expect 200`) runs against the BUFFER first — a
|
|
92
|
+
retro failure prints before a single live item arrives. That is the honest
|
|
93
|
+
order, but it means `logs`+gate reflects history, not just what follows.
|
|
94
|
+
- `search` is buffer-only and fixed-substring: it never sees live items and
|
|
95
|
+
never interprets regex.
|
|
96
|
+
- Ctrl-C at an idle `logs>` prompt is nothing: it prints `^C` and re-prompts,
|
|
97
|
+
the source keeps running. `exit` (or Ctrl-D) is the teardown — verified with a
|
|
98
|
+
`procs` source, including after a Ctrl-C: the group gets SIGTERM→SIGKILL and
|
|
99
|
+
no `sleep` survives the session.
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: crust-mcp
|
|
3
|
+
description: Drive crust directly from an MCP-capable agent (OpenCode, Claude Code, Codex, Cursor) instead of a shell — `crust mcp` exposes every capability as a first-class tool: run_line, check_line, test_fixture/test_pipes, gen_fixtures, load_run, verify_web_links, plus lifecycle mock servers, supervised procs, and buffered log sessions that stay open across calls. Use when configuring crust as an MCP server, choosing which crust_* tool fits a task, or reasoning about what persists between tool calls and what does not.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# crust over MCP
|
|
7
|
+
|
|
8
|
+
`crust mcp` speaks JSON-RPC 2.0 over stdio — one message per line. It is a
|
|
9
|
+
whole-process MODE, not a REPL builtin: the MCP host owns stdin and stdout.
|
|
10
|
+
|
|
11
|
+
## Install & configure
|
|
12
|
+
|
|
13
|
+
`crust` must be on PATH (either channel works — same binary):
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
curl -fsSL https://raw.githubusercontent.com/lariocpt/crust/main/install.sh | bash
|
|
17
|
+
# or: npm i -g @lariocpt/crust
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
Then register it with the agent host — e.g. OpenCode / Claude Code style:
|
|
21
|
+
|
|
22
|
+
```json
|
|
23
|
+
{ "mcpServers": { "crust": { "command": "crust", "args": ["mcp"] } } }
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
VS Code uses a DIFFERENT schema in `.vscode/mcp.json` (workspace) or the
|
|
27
|
+
user-level MCP settings — the `mcpServers` shape above silently fails there
|
|
28
|
+
("command is required" or the server just never appears):
|
|
29
|
+
|
|
30
|
+
```json
|
|
31
|
+
{
|
|
32
|
+
"servers": {
|
|
33
|
+
"crust": { "type": "stdio", "command": "/home/user/.local/bin/crust", "args": ["mcp"] }
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Two traps, both verified: (1) `"command"` must be the ABSOLUTE path — a
|
|
39
|
+
GUI-launched VS Code does not inherit the shell's PATH, so bare `"crust"`
|
|
40
|
+
dies `spawn crust ENOENT` if the launcher lives outside the default
|
|
41
|
+
lookup dirs; (2) VS Code's MCP support ships inside the Chat/Copilot Chat
|
|
42
|
+
extension — Code - OSS / VSCodium has no MCP host without an extension
|
|
43
|
+
(Cline, Roo Code or Continue each carry their own MCP settings).
|
|
44
|
+
|
|
45
|
+
Grammar lands automatically: the one-shot tools pass line/fixture text to a
|
|
46
|
+
fresh crust child untouched, so anything the lexer accepts (`--redirect`,
|
|
47
|
+
new builtins) is an MCP tool call the moment the installed binary has it —
|
|
48
|
+
no MCP-side change per feature.
|
|
49
|
+
|
|
50
|
+
Verify by hand — the transport is just lines on stdin:
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
printf '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"smoke","version":"0"}}}\n{"jsonrpc":"2.0","method":"notifications/initialized"}\n{"jsonrpc":"2.0","id":2,"method":"tools/list"}\n' | crust mcp
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## Two execution planes — what persists
|
|
57
|
+
|
|
58
|
+
| Plane | Tools | State |
|
|
59
|
+
| --- | --- | --- |
|
|
60
|
+
| **One-shot** | `crust_run_line`, `crust_check_line`, `crust_test_fixture`, `crust_test_pipes`, `crust_gen_fixtures`, `crust_verify_web_links`, `crust_load_run` | a fresh crust CHILD per call — `cwd`/`env` apply per call and NEVER carry over; pass them explicitly |
|
|
61
|
+
| **Lifecycle** | `crust_mock_server_start/stop/list/violations`, `crust_procs_start/stop`, `crust_logs_open/query/close` | held IN the server across calls; every held job dies when the server exits |
|
|
62
|
+
|
|
63
|
+
`crust_run_line` runs ordinary crust grammar — one line, `|`-stages, lambdas:
|
|
64
|
+
|
|
65
|
+
```crust
|
|
66
|
+
range(1,5) | (n => n * n) | stats
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
## Tool classes and teardown tiers
|
|
70
|
+
|
|
71
|
+
The `initialize` handshake carries this taxonomy in `instructions` (0.3.6):
|
|
72
|
+
|
|
73
|
+
- **Static check** — `crust_check_line`: parses, runs nothing.
|
|
74
|
+
- **Execution** — `crust_run_line` and the test/gen tools: arbitrary stages,
|
|
75
|
+
real side effects. Target approval, credentials, writes and cleanup stay
|
|
76
|
+
the caller's responsibility.
|
|
77
|
+
- **Network/load generation** — `crust_load_run`, `crust_verify_web_links`:
|
|
78
|
+
bounded generation, approved non-production targets only.
|
|
79
|
+
- **Long-lived resources** — mock server, procs, log sessions: held by the
|
|
80
|
+
server process, die when it exits.
|
|
81
|
+
|
|
82
|
+
Teardown has three tiers and no tool crosses them: **local processes** are
|
|
83
|
+
killed whole (process group, grandchildren included); **mock sessions** close
|
|
84
|
+
(port released — `--state` files/DBs are application data and stay on disk);
|
|
85
|
+
**application data** is never touched — a container, row or queue a proc
|
|
86
|
+
created remotely survives its local teardown, releasing it is your job.
|
|
87
|
+
|
|
88
|
+
Transport contract: stdio JSON-RPC, one frame per line; one-shot tools spawn
|
|
89
|
+
a fresh crust child per call (`cwd`/`env` per call, nothing persists). A
|
|
90
|
+
refusal whose error string never appears in crust's own output (an
|
|
91
|
+
"unavailable tool" style host error) never reached the server — debug the
|
|
92
|
+
host's spawn command, not crust.
|
|
93
|
+
|
|
94
|
+
## Honest failure — never a false pass over MCP
|
|
95
|
+
|
|
96
|
+
A nonzero child exit, a killed timeout, a failed `assert`, a mock validation
|
|
97
|
+
error is an **`isError` result** carrying the verbatim exit code and output.
|
|
98
|
+
Zero matches is answered with an explicit "zero items", never silence. Trust
|
|
99
|
+
`isError`; the text is evidence, not a verdict.
|
|
100
|
+
|
|
101
|
+
## Picking the right tool
|
|
102
|
+
|
|
103
|
+
- Validate generated crust first — `crust_check_line` parses without running
|
|
104
|
+
(files and ports don't need to exist).
|
|
105
|
+
- `crust_procs_start` supervises dev services (env/after/ready/restart per
|
|
106
|
+
proc). Its stream is queried through `crust_logs_query` — items are
|
|
107
|
+
`{proc, stream, line}` objects. Gate readiness in a follow-up:
|
|
108
|
+
|
|
109
|
+
```crust
|
|
110
|
+
wait :3000/health
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
- `crust_logs_open` holds a tail/shell/`procs({…})` source with a ring buffer;
|
|
114
|
+
`crust_logs_query` runs a pipeline fragment over the buffered past, then
|
|
115
|
+
live for `live_ms` — `json: true` parses NDJSON items before the fragment:
|
|
116
|
+
|
|
117
|
+
```crust
|
|
118
|
+
filter (l => l.proc === "api") | (l => l.line) | grep -i timeout
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
- `crust_mock_server_start` defaults `port: 0` (OS-assigned) — always take
|
|
122
|
+
the returned `url`. `proxy: "<upstream>"` turns it into a conformance audit;
|
|
123
|
+
read findings with `crust_mock_server_violations`.
|
|
124
|
+
- `crust_load_run` composes the documented pipeline (`load … | parallel … |
|
|
125
|
+
… | stats | assert`); gates go in `assert` — one predicate, or an array of
|
|
126
|
+
them (0.3.6), one assert stage per gate. An `expect` inside `target` runs BEFORE
|
|
127
|
+
stats and suppresses the summary when it fails; a post-stats `assert`
|
|
128
|
+
failure is `isError` with the stats still included. A run that measured
|
|
129
|
+
nothing is refused (`empty`), never a green gate.
|
|
130
|
+
|
|
131
|
+
## Limits, stated
|
|
132
|
+
|
|
133
|
+
- One tool call runs at a time (the interrupt bus is process-global).
|
|
134
|
+
- Output is tail-capped per stream and truncation is always announced.
|
|
135
|
+
- `crust_procs_stop` reports "teardown still in flight past 10s" honestly
|
|
136
|
+
when a child ignores SIGTERM — crust kills it on server exit.
|
|
@@ -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,209 @@
|
|
|
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
|
+
Choose the lightest native stage that preserves correctness, observability,
|
|
13
|
+
and safety — not merely the shortest syntax (full statement: the
|
|
14
|
+
crust-api-testing skill's shared principle).
|
|
15
|
+
|
|
16
|
+
## How a stage is classified (first token decides)
|
|
17
|
+
|
|
18
|
+
| You type | You get |
|
|
19
|
+
|---|---|
|
|
20
|
+
| `range(0, 9)` | numbers 0..9 inclusive, one item each |
|
|
21
|
+
| `**/*.ts` | glob source — file paths |
|
|
22
|
+
| `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 |
|
|
23
|
+
| `read fixtures/*.json` | whole-file contents, one item per file (sorted; zero matches = error) — the request-body form: `read fixtures/*.json \| POST …` |
|
|
24
|
+
| `tail -F app.log` | native tail source (`-n N`, `-F` follow); the `-n N` cut is a bounded backward read, safe on huge files |
|
|
25
|
+
| `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` |
|
|
26
|
+
| `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 |
|
|
27
|
+
| `load 30s 100/s` | paced load ticks (see the crust-load-testing skill) |
|
|
28
|
+
| `{"name": "x"}` | JSON-literal source — ONE parsed item (the request body). Invalid JSON is a hard error |
|
|
29
|
+
| `GET :3000/path` | HTTP. First stage: one `Response`. Mid-pipeline: per-item timed `{status, ms, url}` |
|
|
30
|
+
| `POST/PUT/PATCH/DELETE url` | per-item request; upstream item = body (objects auto-JSON) |
|
|
31
|
+
| `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 |
|
|
32
|
+
| `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` |
|
|
33
|
+
| `(x => x * 2)` | TypeScript lambda, per item, async ok — MAPS, never drops (falsy results are emitted as-is) |
|
|
34
|
+
| `filter (l => l.ok)` | keeps items whose predicate is truthy; falsy (`0`, `""`, `null`…) drop; async ok; empty result passes |
|
|
35
|
+
| `assert (r => r.ok)` | falsy FAILS the pipeline; empty upstream also fails |
|
|
36
|
+
| `capture NAME (r => r.id)` | write value to `process.env.NAME` for later lines |
|
|
37
|
+
| `expect 201` / `expect 2xx` | status assertion, counts mismatches, fails at drain |
|
|
38
|
+
| `stats [--every N] [--out f.json]` | summary: count/rps/status histogram/p50/p95/p99 + failure tallies `timedOut`/`redirectRefused` (records, not exits — gate them) |
|
|
39
|
+
| `parallel N` | modifier: fan-out for the NEXT stage (http, lambda, or fn — anything else errors) |
|
|
40
|
+
| `sql "SELECT …" ?-params` | registered fn — `?` is the bind placeholder; rows stream one item each (needs `$DATABASE_URL`) |
|
|
41
|
+
| `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 |
|
|
42
|
+
| anything else | plain shell via `sh -c` |
|
|
43
|
+
|
|
44
|
+
`:3000/path` expands to `http://localhost:3000/path` everywhere.
|
|
45
|
+
|
|
46
|
+
## HTTP result shapes — same syntax, different payloads
|
|
47
|
+
|
|
48
|
+
Similar HTTP syntax does NOT guarantee identical result shapes. Know which
|
|
49
|
+
shape a stage receives before reaching for `.json()` or gating results
|
|
50
|
+
(verified against crust v0.3.5):
|
|
51
|
+
|
|
52
|
+
| Context | What the stage emits | Authoring implication |
|
|
53
|
+
|---|---|---|
|
|
54
|
+
| First-stage ordinary verb (`GET :3000/x`) | a `Response` | body parsing is available: `(r => r.json())` |
|
|
55
|
+
| Mid-pipeline ordinary verb, non-GET (per-item, untimed) | a `Response` per item | same — bodies still parseable |
|
|
56
|
+
| Timed per-item path (mid-pipeline `GET` — with or without `parallel` — or `parallel N \| <any other verb>`) | `{status, ms, url}` records; **bodies drained**; network errors are `status: 0` records; `--timeout`/`--redirect error` outcomes are `{status: 0, timedOut/redirectRefused: true}` records | `.json()` does NOT exist on these; gate status and the failure tallies explicitly |
|
|
57
|
+
|
|
58
|
+
Canonical, version-pinned table: https://lariocpt.github.io/crust-website/docs/http-result-shapes/.
|
|
59
|
+
|
|
60
|
+
## Completion is not success — three separate outcomes
|
|
61
|
+
|
|
62
|
+
1. **Execution completed** — the pipeline ran without a fatal stage error (exit 0).
|
|
63
|
+
2. **Request correctness passed** — explicit gates (`expect`, `assert`) accepted the request outcomes.
|
|
64
|
+
3. **Performance passed** — explicit latency/throughput thresholds passed.
|
|
65
|
+
|
|
66
|
+
`stats` reports measurements; it is not itself a success gate. In timed/load
|
|
67
|
+
paths, timeouts, network failures, and redirect refusals are per-item
|
|
68
|
+
RECORDS (`status: 0` + a tally field), not exits — exit 0 alone is never
|
|
69
|
+
evidence that requests succeeded. Use an explicit expected-status gate
|
|
70
|
+
(`expect 200`/`expect 2xx`), and where stats is in play also check
|
|
71
|
+
`s.timedOut === 0` / `s.redirectRefused === 0`.
|
|
72
|
+
|
|
73
|
+
## Env expansion — parse time, opt-in per position
|
|
74
|
+
|
|
75
|
+
`$VAR`/`${VAR}` expand from `process.env` in: URLs, whole `-H` header
|
|
76
|
+
strings, JSON literals, `stats --out` paths, and registered-fn args. They do
|
|
77
|
+
NOT expand inside lambda/`assert`/`capture` bodies (those are JS — use
|
|
78
|
+
`process.env.VAR`) or shell stages (sh does its own). In a script, the
|
|
79
|
+
**positional arguments** expand in those same positions — so `$1` inside a
|
|
80
|
+
`sql "…"` string is the script's `$1`, not a SQL bind: the bound-parameter
|
|
81
|
+
placeholder is `?`, and the trailing quoted args DO env-expand:
|
|
82
|
+
`sql "… WHERE id = ?" "$MY_ID"`.
|
|
83
|
+
|
|
84
|
+
`$(…)` command substitution lives in shell stages only: sh expands it once
|
|
85
|
+
at the head of a shell stage, and a `$` anywhere in a mid-pipeline stage
|
|
86
|
+
demotes it to sh, so `range(1,3) | grep $(echo 1)` still works. A `$(…)` in
|
|
87
|
+
a *crust* stage (a URL, a lambda, an argument) is **refused before anything
|
|
88
|
+
runs** — it used to build data from the literal text (`getaddrinfo ENOTFOUND
|
|
89
|
+
$(echo`) — so the named route is capture first: `date +%s | capture TS`,
|
|
90
|
+
then `GET :3000/?t=$TS`.
|
|
91
|
+
|
|
92
|
+
Inside a parenthesized lambda/`assert`/`capture` body, anything goes: the
|
|
93
|
+
tokenizer tracks paren depth and quotes, so literal `|`, `??`, single/double
|
|
94
|
+
quotes, and backticks (template literals) are all safe. In double-quoted
|
|
95
|
+
strings and JSON literals, `\"` escapes work.
|
|
96
|
+
|
|
97
|
+
## Request chaining with capture
|
|
98
|
+
|
|
99
|
+
Each line parses right before it runs, so a capture on one line feeds `$VAR`
|
|
100
|
+
on every later line — the chaining contract for REPL, `-c` scripts, and
|
|
101
|
+
`.pipes` files:
|
|
102
|
+
|
|
103
|
+
```crust
|
|
104
|
+
{"name": "Court"} | POST $BASE/api/buildings -H "authorization: Bearer $TOKEN" | assert (r => r.status === 201) | (r => r.json()) | capture BID (b => b.building.id)
|
|
105
|
+
GET $BASE/api/buildings/$BID -H "authorization: Bearer $TOKEN" | expect 200
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
- Last item wins; omit the lambda to capture the item itself (objects are
|
|
109
|
+
JSON-stringified).
|
|
110
|
+
- A nullish captured value or empty upstream fails IMMEDIATELY — a typo'd
|
|
111
|
+
accessor dies on the capture line, not three lines later as a baffling
|
|
112
|
+
`""` expansion.
|
|
113
|
+
- Don't capture into `TOKEN`, `BASE`, or `PATH` unless you mean to overwrite
|
|
114
|
+
them.
|
|
115
|
+
- Capture is also the named route for command output you want downstream:
|
|
116
|
+
a `$(…)` in a crust stage is refused, so `date +%s | capture TS` followed
|
|
117
|
+
by `GET :3000/?t=$TS` is the round trip (sh's trailing newline is trimmed
|
|
118
|
+
off the captured value).
|
|
119
|
+
|
|
120
|
+
## Verified one-liner patterns
|
|
121
|
+
|
|
122
|
+
```crust
|
|
123
|
+
# log mining — `lines` yields one item per LINE, which is what filter wants
|
|
124
|
+
# (`read` yields whole FILES, so a filter would test the entire file at once)
|
|
125
|
+
lines **/*.log | grep ERROR | filter (l => !l.includes('healthcheck')) | wc -l
|
|
126
|
+
|
|
127
|
+
# lambda + shell mixing — items cross as lines (objects as JSON)
|
|
128
|
+
range(1, 5) | (n => n * n) | sort -rn
|
|
129
|
+
|
|
130
|
+
# HTTP fixture in one line: body | request | status | parsed-body assert
|
|
131
|
+
{"name": "x"} | POST :3000/users | assert (r => r.status === 201) | (r => r.json()) | assert (u => u.id > 0)
|
|
132
|
+
|
|
133
|
+
# DB verification — rows stream, assert sees each row object
|
|
134
|
+
sql "SELECT count(*)::int AS c FROM users WHERE email = ?" "a@b.c" | assert (r => r.c === 1)
|
|
135
|
+
|
|
136
|
+
# fan out any per-item work
|
|
137
|
+
read fixtures/*.json | parallel 8 | POST :3000/users | expect 2xx
|
|
138
|
+
|
|
139
|
+
# follow a log live — native grep emits each match immediately
|
|
140
|
+
tail -n 0 -F app.log | grep ERROR
|
|
141
|
+
|
|
142
|
+
# any command's output as a source (run under `crust -c`, data piped in)
|
|
143
|
+
stdin | (l => JSON.parse(l)) | filter (e => e.level >= 40) | (e => e.msg)
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
To ITERATE on filters over one held live stream, use the `logs` builtin
|
|
147
|
+
(`logs tail -n 0 -F app.log`, `logs procs({…})`, `logs docker logs -f c`):
|
|
148
|
+
every line typed at its prompt is a fragment like the ones above, run over
|
|
149
|
+
a buffer of the recent past and then live; Ctrl-C once ends the view and
|
|
150
|
+
flushes terminal stages (bare `stats` prints there), `exit` leaves.
|
|
151
|
+
|
|
152
|
+
## Traps
|
|
153
|
+
|
|
154
|
+
- A plain lambda MAPS — `(l => l.includes('ERROR'))` emits `true`/`false`
|
|
155
|
+
per item, and `(x => cond ? x : null)` prints literal `null` lines. To
|
|
156
|
+
drop items, use `filter (l => l.includes('ERROR'))`.
|
|
157
|
+
- `filter` passes an empty stream through silently; `assert` fails on EMPTY
|
|
158
|
+
upstream ("no items reached") — deliberate, it closes the
|
|
159
|
+
sql-returned-zero-rows silent pass. `expect` does not fail on empty either.
|
|
160
|
+
- `parallel` streams results in COMPLETION order, not input order.
|
|
161
|
+
- `parallel N` before anything except an http verb, lambda, or registered
|
|
162
|
+
fn is a parse error; so is a trailing `parallel N`.
|
|
163
|
+
- A glob source yields PATHS; `read <glob>` yields file CONTENTS. `POST`ing
|
|
164
|
+
a glob posts path strings.
|
|
165
|
+
- Builtins (`test-pipes`, `mock-server`, `logs`, …) cannot be piped. A builtin
|
|
166
|
+
name in a shell context — `mock-server spec.json | grep ok`, `range(0,2) |
|
|
167
|
+
test-fixture a.ts` — refuses naming the tool instead of letting sh answer
|
|
168
|
+
`command not found` for a tool crust runs in-process. Put the builtin on its
|
|
169
|
+
own line; `logs` takes its filters at the `logs>` prompt.
|
|
170
|
+
- No redirect on a crust stage. `<` / `>` mean something only on a SHELL
|
|
171
|
+
stage, whose text goes to `sh` verbatim: `… | cat > out.json` saves a
|
|
172
|
+
pipeline, `lines <path>` / `read <path>` feeds a file in. Anywhere else the
|
|
173
|
+
line is refused — an http stage used to swallow the operator (request ran,
|
|
174
|
+
nothing written, exit 0). A builtin carrying one refuses too, and the fix is
|
|
175
|
+
redirecting the whole call: `crust -c 'mock-server spec.json' > mock.log`.
|
|
176
|
+
- Native grep patterns are JS regexes: quoted `'a|b'` ALTERNATES (ERE)
|
|
177
|
+
where BRE grep matched the literal — use `grep -F 'a|b'` for the literal.
|
|
178
|
+
- `stdin` is single-shot per process: inside a bare `cmd | crust` script it
|
|
179
|
+
errors (the pipe was already consumed as the script itself).
|
|
180
|
+
- Shell stages get npm-run-style PATH: ancestor `node_modules/.bin` dirs
|
|
181
|
+
are prepended, so local tool binaries work bare (`| pino-pretty`,
|
|
182
|
+
`| tsc --noEmit`) — no `node_modules/.bin/` prefix, no global install.
|
|
183
|
+
- In `… | expect 200 | stats`, a failing expect throws at drain BEFORE stats
|
|
184
|
+
emits — gate status inside a stats assert instead if you need the summary.
|
|
185
|
+
|
|
186
|
+
## Exit codes
|
|
187
|
+
|
|
188
|
+
Any stage throw → `crust: <message>` on stderr, line exits 1. `crust -c`,
|
|
189
|
+
`crust file.crust`, and piped stdin all run newline-separated lines through
|
|
190
|
+
the same fail-fast loop and STOP at the first non-zero — put warmup and
|
|
191
|
+
cleanup on their own lines. Blank lines and `#` comments are skipped, so a
|
|
192
|
+
`#!/usr/bin/env crust` shebang works in `.crust` files.
|
|
193
|
+
|
|
194
|
+
**Cleanup on a later line is happy-path cleanup, not guaranteed teardown.**
|
|
195
|
+
Under fail-fast, an earlier failure means that line never runs. When
|
|
196
|
+
failure-path cleanup is a requirement (containers, temp rows, mock servers),
|
|
197
|
+
use the verified lifecycle hook: `crust.onExit = async (code) => {…}` in
|
|
198
|
+
`init.ts` — it fires on every shutdown-path exit,
|
|
199
|
+
including the fail-fast failure path of `-c`, script, and stdin runs
|
|
200
|
+
(test-pinned by `tests/onexit.test.ts`), and receives the exit code. If a needed
|
|
201
|
+
guarantee is still missing (e.g. `kill -9`), document the limitation rather
|
|
202
|
+
than implying line placement solves it.
|
|
203
|
+
|
|
204
|
+
## Version and evidence
|
|
205
|
+
|
|
206
|
+
Behavior here was verified against **crust v0.3.5** — result shapes and
|
|
207
|
+
failure-record semantics by source and through the compiled binary; keep
|
|
208
|
+
approved targets, credentials, and execution restrictions in repository
|
|
209
|
+
policy, not in this skill.
|