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