@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
package/README.md
CHANGED
|
@@ -1,3 +1,175 @@
|
|
|
1
|
-
#
|
|
1
|
+
# crust
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
**Mock it, test it, load it, tail it: the whole test stack baked into one
|
|
4
|
+
binary.** Spec-driven, generated, zero dependencies.
|
|
5
|
+
|
|
6
|
+
Never reports a false pass — a run that measured nothing fails.
|
|
7
|
+
|
|
8
|
+
An OpenAPI mock server, fixture suites generated from the spec, load gates CI
|
|
9
|
+
can fail on, a process orchestrator, and a link checker — every tool composes
|
|
10
|
+
on the same `|` stream, driven from an interactive REPL, a `crust -c`
|
|
11
|
+
one-liner, a `.crust` script, or as a one-shot command from your shell
|
|
12
|
+
(`crust mock-server openapi.yaml -p 4000`).
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
npm i -g @lariocpt/crust
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
**Documentation: https://lariocpt.github.io/crust-website/**
|
|
19
|
+
|
|
20
|
+
Source and issues: https://github.com/lariocpt/crust
|
|
21
|
+
|
|
22
|
+
## What it looks like
|
|
23
|
+
|
|
24
|
+
Lines below are parsed against the live grammar on every build, so an example
|
|
25
|
+
that stopped working fails CI rather than misleading you.
|
|
26
|
+
|
|
27
|
+
**Fixture tests** — `.crust.ts` fixtures with `setup()` context and async
|
|
28
|
+
matchers, JUnit for CI, and `-n/-j` to turn any fixture into a stress run:
|
|
29
|
+
|
|
30
|
+
```crust
|
|
31
|
+
test-fixture "tests/api/**/*.crust.ts" -j 8 -o reports/api.junit.xml
|
|
32
|
+
test-fixture tests/api/checkout.crust.ts -n 500 -j 32 -o reports/stress.json
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
**Load testing with a gate CI can trust** — thresholds are `assert`
|
|
36
|
+
composition, and a run that measured nothing is refused rather than passing:
|
|
37
|
+
|
|
38
|
+
```crust
|
|
39
|
+
load 30s 200/s | parallel 50 | GET :3000/health | expect 200 | stats | assert (s => s.p95 < 250)
|
|
40
|
+
load 10s 50/s, 5m 200/s | parallel 200 | GET $BASE/api/search | stats --every 15 | assert (s => !s.window || s.p99 < 800)
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
**A persistent mock server from a Swagger/OpenAPI spec** — stateful CRUD
|
|
44
|
+
persisted to sqlite so it survives a restart, seeded from a file, with
|
|
45
|
+
spec-violating requests rejected as 422:
|
|
46
|
+
|
|
47
|
+
```crust
|
|
48
|
+
mock-server ./openapi.yaml -p 4010 --state ./.crust/mock.sqlite --seed seeds/dev.json --validate
|
|
49
|
+
{"thing": {"name": "x"}} | POST :4010/api/things | expect 201 | (r => r.json()) | capture TID (t => t.thing.id)
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Point it at a real API instead and it audits that API's spec conformance:
|
|
53
|
+
|
|
54
|
+
```crust
|
|
55
|
+
mock-server ./swagger.json -p 4747 --proxy http://localhost:3001 --report violations.ndjson
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
**SQL assertions, inline** — rows stream one item each, and an empty result
|
|
59
|
+
fails instead of passing quietly. The bind placeholder is `?` for every
|
|
60
|
+
dialect (crust rewrites it to `$n` for Postgres URLs):
|
|
61
|
+
|
|
62
|
+
```crust
|
|
63
|
+
sql "SELECT count(*)::int AS c FROM buildings WHERE name = 'Court'" | assert (r => r.c === 1)
|
|
64
|
+
sql "SELECT status FROM orders WHERE id = ?" $OID | assert (r => r.status === "paid")
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
**Pretty logs from every service at once** — `procs` merges stdout and stderr
|
|
68
|
+
into one stream of `{proc, stream, line}` items, so you filter them as objects
|
|
69
|
+
and hand the text to `pino-pretty` (local `node_modules/.bin` is on PATH):
|
|
70
|
+
|
|
71
|
+
```crust
|
|
72
|
+
procs({api: "bun run dev", worker: "bun run worker", db: "docker compose logs -f postgres"}) | (l => l.line) | pino-pretty --colorize
|
|
73
|
+
procs({api: "bun run dev", worker: "bun run worker"}) | filter (l => l.stream === "stdout") | (l => l.line) | pino-pretty --colorize --minimumLevel warn
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
**Interactive log querying** — `logs` holds one live stream and lets you iterate
|
|
77
|
+
on filters against its buffered past and live future:
|
|
78
|
+
|
|
79
|
+
```crust
|
|
80
|
+
logs procs({api: "bun run dev", worker: "bun run worker"})
|
|
81
|
+
logs docker logs -f my-app
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
At its prompt each line runs over the buffer, then live until Ctrl-C:
|
|
85
|
+
|
|
86
|
+
```text
|
|
87
|
+
logs> filter (l => l.proc === "api") | (l => l.line) | grep -i timeout
|
|
88
|
+
logs> json on
|
|
89
|
+
logs> filter (e => e.level >= 50) | stats --every 5
|
|
90
|
+
logs> search "connection reset"
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
## Interactive shell
|
|
94
|
+
|
|
95
|
+
The REPL continues a line — a trailing `\`, an unbalanced quote, or an unclosed
|
|
96
|
+
`(` holds the line instead of erroring, so pasted multi-line JSON survives the
|
|
97
|
+
Enter. `Ctrl-R` searches history; `&`, `jobs`, `fg`, `bg`, `wait %N`,
|
|
98
|
+
`kill %N` and `Ctrl-Z` are job control. Scripts take positional arguments
|
|
99
|
+
(`$0`, `$1…`, `$#`, `$@`), a bare `FOO=x` line persists for the session, and
|
|
100
|
+
`source` runs `.ts` files, `.crust` scripts, and shell scripts alike.
|
|
101
|
+
|
|
102
|
+
## MCP server
|
|
103
|
+
|
|
104
|
+
`crust mcp` speaks the Model Context Protocol over stdio, so an MCP-capable
|
|
105
|
+
agent (OpenCode, Claude Code, Codex, Cursor) drives crust directly — every
|
|
106
|
+
capability is a first-class tool, not a shell to fumble through. One-shot tools
|
|
107
|
+
(`crust_run_line`, `crust_test_fixture`, `crust_load_run`, …) run a fresh crust
|
|
108
|
+
process per call; lifecycle tools keep a mock server, a supervised `procs`
|
|
109
|
+
runner, or a buffered `logs` session open across calls and tear them down
|
|
110
|
+
honestly on exit. A nonzero run is always an `isError` result with the verbatim
|
|
111
|
+
output — never a false pass.
|
|
112
|
+
|
|
113
|
+
```json
|
|
114
|
+
{ "mcpServers": { "crust": { "command": "crust", "args": ["mcp"] } } }
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
The `crust-mcp` skill teaches an agent which `crust_*` tool fits which task.
|
|
118
|
+
|
|
119
|
+
## Agent skills
|
|
120
|
+
|
|
121
|
+
Crust ships the SKILL.md guides that teach a coding agent how to drive it —
|
|
122
|
+
pipelines, fixture suites, load gates, the mock server, `procs`, the log
|
|
123
|
+
sessions, the link checker, the MCP server — embedded in the binary, so they
|
|
124
|
+
always match the version you run.
|
|
125
|
+
They go into `.agents/skills/`, the cross-client directory Codex, Cursor and
|
|
126
|
+
OpenCode read, plus a link per skill in `.claude/skills/` for Claude Code, the
|
|
127
|
+
one mainstream host that doesn't read the standard path:
|
|
128
|
+
|
|
129
|
+
```bash
|
|
130
|
+
bunx crust skills install # on a terminal: pick which ones
|
|
131
|
+
bunx crust skills install --all # everything, into ./.agents/skills
|
|
132
|
+
bunx crust skills install --select crust-cli,crust-logs
|
|
133
|
+
bunx crust skills install --global # into ~/.agents/skills
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
The menu lists each skill with its status in the target it would write to
|
|
137
|
+
(`current`, `not installed`, `EDITED locally`) and `g` switches between the
|
|
138
|
+
project and `~/.agents/skills`; declining exits `0`. A `.claude/skills/<name>`
|
|
139
|
+
directory crust did not write is left alone and reported — `--force` consents
|
|
140
|
+
to replacing it with a link.
|
|
141
|
+
|
|
142
|
+
`npm install` asks once whether to install them. It never asks on CI, with
|
|
143
|
+
piped output, for a global install, or once crust skills are already in the
|
|
144
|
+
project under either path, and it can never fail the install. Answering no
|
|
145
|
+
writes nothing; `CRUST_NO_SKILLS=1` stops the question permanently.
|
|
146
|
+
|
|
147
|
+
## What this package installs
|
|
148
|
+
|
|
149
|
+
This package is a ~4 KB launcher, not the binary. On first use it downloads the
|
|
150
|
+
`bun build --compile` executable for your platform from the matching GitHub
|
|
151
|
+
Release, verifies its published sha256 before the file is ever made executable,
|
|
152
|
+
and caches it under `~/.cache/crust`. Every install route resolves that same
|
|
153
|
+
artifact.
|
|
154
|
+
|
|
155
|
+
What it installed is recorded in `install-manifest.json` next to `package.json`
|
|
156
|
+
(plane, binary version, the package version that asked for it, the cached path
|
|
157
|
+
and its sha256). Every run re-checks that the manifest was written for THIS
|
|
158
|
+
package version on THIS plane: a version bump or a plane switch is a cache
|
|
159
|
+
miss, so the launcher re-resolves, re-verifies the sha256 and rewrites the
|
|
160
|
+
manifest — a stale install never shadows a new release, and no file has to be
|
|
161
|
+
deleted by hand.
|
|
162
|
+
|
|
163
|
+
Supported platforms: linux x64/arm64, macOS x64/arm64. On anything else, build
|
|
164
|
+
from source — see the repository README.
|
|
165
|
+
|
|
166
|
+
### Environment variables
|
|
167
|
+
|
|
168
|
+
| Variable | Effect |
|
|
169
|
+
|---|---|
|
|
170
|
+
| `CRUST_SOURCE` | `github` (default for this package) or `apps` — which artifact plane to resolve from. |
|
|
171
|
+
| `CRUST_GITHUB_REPO` | Override the `owner/repo` the release is fetched from. |
|
|
172
|
+
| `CRUST_APPS_URL` | Base URL of a self-hosted artifact plane, used when `CRUST_SOURCE=apps`. |
|
|
173
|
+
| `CRUST_NO_SKILLS` | Set to any value to stop the postinstall question about installing agent skills. `crust skills install` still works. |
|
|
174
|
+
|
|
175
|
+
MIT © Lario Borges
|
package/bin/crust
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// crust — launcher shim.
|
|
3
|
+
//
|
|
4
|
+
// The real binary is a ~91 MB Bun-compiled ELF living in the user's cache. This shim finds
|
|
5
|
+
// it, downloading it on first use if the postinstall never ran, and hands over the process.
|
|
6
|
+
//
|
|
7
|
+
// The lazy download is the load-bearing part: npm 12 blocks install scripts by DEFAULT, so
|
|
8
|
+
// a package that only fetches in postinstall installs "successfully" and then cannot run.
|
|
9
|
+
// Fetching here means `npm i -g crust` works under any script policy — the cost is that the
|
|
10
|
+
// very first invocation pays the download.
|
|
11
|
+
import { spawn } from "node:child_process";
|
|
12
|
+
import { ensureBinary, readManifest, installHint, resolveSource, readPkg } from "../lib/install.mjs";
|
|
13
|
+
|
|
14
|
+
const say = (m) => console.error(`[crust] ${m}`);
|
|
15
|
+
|
|
16
|
+
function bail(lines, code = 127) {
|
|
17
|
+
for (const l of lines) console.error(`[crust] ${l}`);
|
|
18
|
+
process.exit(code);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Pass the plane AND the package: readManifest's whole job is to reject a cached
|
|
22
|
+
// binary that belongs to a DIFFERENT artifact plane or a DIFFERENT package version,
|
|
23
|
+
// and with no arguments those guards are skipped — so CRUST_SOURCE was silently
|
|
24
|
+
// ignored on every run after the first, and a version bump kept the old binary in
|
|
25
|
+
// play until a human deleted the manifest by hand.
|
|
26
|
+
const pkg = readPkg();
|
|
27
|
+
let manifest = readManifest(pkg, resolveSource(pkg));
|
|
28
|
+
|
|
29
|
+
if (!manifest) {
|
|
30
|
+
try {
|
|
31
|
+
// Progress goes to stderr, never stdout: crust is a shell and gets piped, so anything on
|
|
32
|
+
// stdout here would corrupt the first command someone runs through it.
|
|
33
|
+
manifest = await ensureBinary({ log: say, warn: say });
|
|
34
|
+
} catch (e) {
|
|
35
|
+
bail([`could not install the crust binary: ${e.message}`, ...installHint()]);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Async spawn with inherited stdio rather than exec or spawnSync: crust is interactive, so it
|
|
40
|
+
// needs the real tty — and the shim must be a transparent SIGNAL conduit. spawnSync was not:
|
|
41
|
+
// a single-pid SIGTERM (Playwright stopping a webServer, systemd, a bare `kill`) hit only this
|
|
42
|
+
// node process, libuv put the binary down without ceremony, and a crust `procs` supervisor
|
|
43
|
+
// died WITHOUT running its group-kill teardown — orphaning every dev server it held. Forward
|
|
44
|
+
// the termination signals and let the binary do its own graceful shutdown.
|
|
45
|
+
const child = spawn(manifest.bin, process.argv.slice(2), { stdio: "inherit" });
|
|
46
|
+
child.on("error", (e) => bail([`failed to launch ${manifest.bin}: ${e.message}`]));
|
|
47
|
+
for (const sig of ["SIGINT", "SIGTERM", "SIGHUP"]) {
|
|
48
|
+
process.on(sig, () => {
|
|
49
|
+
try {
|
|
50
|
+
child.kill(sig);
|
|
51
|
+
} catch {
|
|
52
|
+
/* already gone */
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
child.on("exit", (code, signal) => {
|
|
57
|
+
// A plain exit-code passthrough loses signals, which makes `crust` dying to SIGINT look
|
|
58
|
+
// different from the binary dying to SIGINT. Re-raise instead.
|
|
59
|
+
if (signal) {
|
|
60
|
+
process.kill(process.pid, signal);
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
process.exit(code ?? 0);
|
|
64
|
+
});
|
package/lib/install.mjs
ADDED
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
// crust — resolve and install the prebuilt binary for this package.
|
|
2
|
+
//
|
|
3
|
+
// WHY THIS PACKAGE IS A LAUNCHER RATHER THAN THE BINARY
|
|
4
|
+
// `bun build --compile` produces a ~91 MB self-contained executable. Shipping that inside the
|
|
5
|
+
// npm tarball would put ~91 MB into the registry per published version and hard-lock the
|
|
6
|
+
// package to one platform. Instead the binary is published ONCE per build to an artifact
|
|
7
|
+
// plane, and every install route resolves that one artifact — same file, same sha256.
|
|
8
|
+
//
|
|
9
|
+
// TWO PLANES, ONE RESOLVER. The public release publishes binaries to GitHub Releases; the
|
|
10
|
+
// LAN build publishes to apps.in.drlario.org. Which one this copy of the package uses is
|
|
11
|
+
// stamped into package.json as `crust.source` at publish time ("github" or "apps") and can be
|
|
12
|
+
// overridden with CRUST_SOURCE. The verify-then-rename download path below is shared, because
|
|
13
|
+
// "did the bytes we ran match the bytes that were published" must not depend on the plane.
|
|
14
|
+
//
|
|
15
|
+
// WHY THIS IS A LIBRARY AND NOT JUST A POSTINSTALL
|
|
16
|
+
// npm 12 blocks install scripts BY DEFAULT (`allowScripts`), so a postinstall-only design
|
|
17
|
+
// silently ships a package that cannot run — verified on npm 12.0.2 with no local config.
|
|
18
|
+
// So the binary is fetched lazily by bin/crust on first use, and the postinstall is only a
|
|
19
|
+
// fast path for environments that still permit it. Either entry point calls ensureBinary().
|
|
20
|
+
//
|
|
21
|
+
// Node stdlib only, deliberately: this runs before the package's own dependencies are
|
|
22
|
+
// guaranteed usable in every npm/pnpm/bun layout, so it must not have any.
|
|
23
|
+
import { createWriteStream, existsSync, mkdirSync, readFileSync, renameSync, chmodSync, writeFileSync, createReadStream, unlinkSync } from "node:fs";
|
|
24
|
+
import { createHash } from "node:crypto";
|
|
25
|
+
import { Readable } from "node:stream";
|
|
26
|
+
import { pipeline } from "node:stream/promises";
|
|
27
|
+
import { homedir } from "node:os";
|
|
28
|
+
import path from "node:path";
|
|
29
|
+
import { fileURLToPath } from "node:url";
|
|
30
|
+
|
|
31
|
+
export const PKG_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
32
|
+
export const MANIFEST_PATH = path.join(PKG_ROOT, "install-manifest.json");
|
|
33
|
+
|
|
34
|
+
const APPS_URL = (process.env.CRUST_APPS_URL || "https://apps.in.drlario.org").replace(/\/+$/, "");
|
|
35
|
+
const CACHE_ROOT = path.join(process.env.XDG_CACHE_HOME || path.join(homedir(), ".cache"), "crust");
|
|
36
|
+
const DEFAULT_REPO = "lariocpt/crust";
|
|
37
|
+
|
|
38
|
+
export function readPkg() {
|
|
39
|
+
return JSON.parse(readFileSync(path.join(PKG_ROOT, "package.json"), "utf8"));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Env beats the stamp so someone on the LAN can point a public install at the internal plane
|
|
43
|
+
// (and vice versa) without editing an installed package.
|
|
44
|
+
export function resolveSource(pkg = readPkg()) {
|
|
45
|
+
const raw = (process.env.CRUST_SOURCE || pkg.crust?.source || "apps").toLowerCase();
|
|
46
|
+
if (raw !== "apps" && raw !== "github") {
|
|
47
|
+
throw new Error(`unknown CRUST_SOURCE '${raw}' — expected 'github' or 'apps'`);
|
|
48
|
+
}
|
|
49
|
+
return raw;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// `crust-linux-x64`, `crust-darwin-arm64`, … — the names the release workflow uploads.
|
|
53
|
+
/**
|
|
54
|
+
* @param {string} [platform]
|
|
55
|
+
* @param {string} [arch]
|
|
56
|
+
* @param {string|null} [variant] e.g. "mongo" (from package.json crust.variant)
|
|
57
|
+
*/
|
|
58
|
+
export function assetName(platform = process.platform, arch = process.arch, variant = null) {
|
|
59
|
+
// The mongo variant ships as a separate asset (crust-linux-x64-mongo): same
|
|
60
|
+
// version, different binary. Clean builds have no variant and keep the
|
|
61
|
+
// original name forever.
|
|
62
|
+
return `crust-${platform}-${arch}${variant ? `-${variant}` : ""}`;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function ghRepo(pkg) {
|
|
66
|
+
if (process.env.CRUST_GITHUB_REPO) return process.env.CRUST_GITHUB_REPO;
|
|
67
|
+
if (pkg.crust?.repo) return pkg.crust.repo;
|
|
68
|
+
const m = /github\.com[/:]([^/]+\/[^/.]+)/.exec(pkg.repository?.url || "");
|
|
69
|
+
return m ? m[1] : DEFAULT_REPO;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// The two LAN planes version the same build differently, and that is deliberate — see the
|
|
73
|
+
// header of publish/bin/apps-publish:
|
|
74
|
+
//
|
|
75
|
+
// apps plane : <pkgversion>+<shortsha> e.g. 0.1.0+78f2a43
|
|
76
|
+
// npm plane : <pkgversion>-ci.<n>.<sha> e.g. 0.1.0-ci.42.78f2a43
|
|
77
|
+
//
|
|
78
|
+
// semver ignores build metadata after `+` when ordering, so the registry cannot use the apps
|
|
79
|
+
// form. The shared short sha is what ties one back to the other.
|
|
80
|
+
function shaFromNpmVersion(v) {
|
|
81
|
+
const m = /-ci\.\d+\.([0-9a-f]{7,40})$/.exec(v);
|
|
82
|
+
return m ? m[1] : null;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function parseIndex(tsv) {
|
|
86
|
+
// Columns: kind name version file sha256 bytes path
|
|
87
|
+
return tsv
|
|
88
|
+
.split("\n")
|
|
89
|
+
.filter((l) => l && !l.startsWith("#"))
|
|
90
|
+
.map((l) => l.split("\t"))
|
|
91
|
+
.filter((c) => c.length >= 7 && c[0] === "tool" && c[1] === "crust")
|
|
92
|
+
.map(([kind, name, version, file, sha256, bytes, p]) => ({ kind, name, version, file, sha256, bytes, path: p }));
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Prefer the artifact this exact package was built alongside; fall back to whatever `latest`
|
|
96
|
+
// points at. The fallback matters because apps-publish prunes old versions (KEEP_VERSIONS),
|
|
97
|
+
// so an npm version can outlive its binary — a working newer crust with a warning beats a
|
|
98
|
+
// hard failure.
|
|
99
|
+
function pickRow(rows, wantSha) {
|
|
100
|
+
if (wantSha) {
|
|
101
|
+
const exact = rows.find((r) => r.version.endsWith(`+${wantSha}`) && !r.path.includes("/latest/"));
|
|
102
|
+
if (exact) return { row: exact, exact: true };
|
|
103
|
+
}
|
|
104
|
+
const latest = rows.find((r) => r.path.includes("/latest/"));
|
|
105
|
+
return latest ? { row: latest, exact: false } : null;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// SHA256SUMS is the plain `sha256sum` format: "<hex> <filename>" per line.
|
|
109
|
+
function parseSums(text) {
|
|
110
|
+
const out = new Map();
|
|
111
|
+
for (const line of text.split("\n")) {
|
|
112
|
+
const m = /^([0-9a-f]{64})\s+\*?(.+?)\s*$/.exec(line);
|
|
113
|
+
if (m) out.set(m[2], m[1]);
|
|
114
|
+
}
|
|
115
|
+
return out;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async function get(url) {
|
|
119
|
+
const res = await fetch(url, { redirect: "follow" });
|
|
120
|
+
if (!res.ok) throw new Error(`GET ${url} -> ${res.status} ${res.statusText}`);
|
|
121
|
+
return res;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function sha256OfFile(p) {
|
|
125
|
+
return new Promise((resolve, reject) => {
|
|
126
|
+
const h = createHash("sha256");
|
|
127
|
+
createReadStream(p).on("data", (c) => h.update(c)).on("end", () => resolve(h.digest("hex"))).on("error", reject);
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async function downloadVerified(url, dest, wantSha) {
|
|
132
|
+
const res = await get(url);
|
|
133
|
+
const tmp = `${dest}.part.${process.pid}`;
|
|
134
|
+
const hash = createHash("sha256");
|
|
135
|
+
// Stream rather than buffer: ~91 MB held in memory purely to hash it is waste on a machine
|
|
136
|
+
// that is probably doing something else.
|
|
137
|
+
await pipeline(
|
|
138
|
+
Readable.fromWeb(res.body),
|
|
139
|
+
async function* (src) { for await (const chunk of src) { hash.update(chunk); yield chunk; } },
|
|
140
|
+
createWriteStream(tmp, { mode: 0o755 }),
|
|
141
|
+
);
|
|
142
|
+
const got = hash.digest("hex");
|
|
143
|
+
if (got !== wantSha) {
|
|
144
|
+
unlinkSync(tmp);
|
|
145
|
+
throw new Error(`checksum mismatch for ${url}\n expected ${wantSha}\n got ${got}`);
|
|
146
|
+
}
|
|
147
|
+
// Verify BEFORE the file reaches its final name, never after: a truncated or tampered
|
|
148
|
+
// download must not be executable on disk even briefly. The rename is atomic, and the
|
|
149
|
+
// pid-suffixed temp name keeps two concurrent installs from fighting over one .part file.
|
|
150
|
+
renameSync(tmp, dest);
|
|
151
|
+
chmodSync(dest, 0o755);
|
|
152
|
+
return dest;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Both resolvers return the same shape: { version, url, sha256, bytes, from, note }.
|
|
156
|
+
// `note` is a non-fatal warning for the caller to surface.
|
|
157
|
+
async function resolveFromApps(pkg) {
|
|
158
|
+
const wantSha = shaFromNpmVersion(pkg.version);
|
|
159
|
+
|
|
160
|
+
const res = await get(`${APPS_URL}/index.tsv`);
|
|
161
|
+
const rows = parseIndex(await res.text());
|
|
162
|
+
if (!rows.length) throw new Error(`no 'crust' rows in ${APPS_URL}/index.tsv — has the crust binary job run yet?`);
|
|
163
|
+
|
|
164
|
+
const picked = pickRow(rows, wantSha);
|
|
165
|
+
if (!picked) throw new Error("index.tsv has crust rows but none resolvable");
|
|
166
|
+
const { row, exact } = picked;
|
|
167
|
+
|
|
168
|
+
return {
|
|
169
|
+
version: row.version,
|
|
170
|
+
url: `${APPS_URL}/${row.path}`,
|
|
171
|
+
sha256: row.sha256,
|
|
172
|
+
bytes: Number(row.bytes) || 0,
|
|
173
|
+
from: APPS_URL,
|
|
174
|
+
note: wantSha && !exact
|
|
175
|
+
? `binary for ${pkg.version} (sha ${wantSha}) is no longer published — falling back to latest (${row.version})`
|
|
176
|
+
: null,
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
async function resolveFromGithub(pkg) {
|
|
181
|
+
const repo = ghRepo(pkg);
|
|
182
|
+
const tag = `v${pkg.version}`;
|
|
183
|
+
const base = `https://github.com/${repo}/releases/download/${tag}`;
|
|
184
|
+
const asset = assetName(process.platform, process.arch, pkg.crust?.variant || null);
|
|
185
|
+
|
|
186
|
+
// The checksums file is the source of truth for what this release actually contains, so an
|
|
187
|
+
// unsupported platform fails here with the real list rather than as a 404 on the binary.
|
|
188
|
+
const sums = parseSums(await (await get(`${base}/SHA256SUMS`)).text());
|
|
189
|
+
const sha256 = sums.get(asset);
|
|
190
|
+
if (!sha256) {
|
|
191
|
+
const have = [...sums.keys()].filter((k) => k.startsWith("crust-")).join(", ") || "(none)";
|
|
192
|
+
throw new Error(
|
|
193
|
+
`release ${tag} has no ${asset}\n platforms in this release: ${have}\n` +
|
|
194
|
+
` build from source instead: https://github.com/${repo}#build-from-source`,
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
return { version: pkg.version, url: `${base}/${asset}`, sha256, bytes: 0, from: `github.com/${repo}@${tag}`, note: null };
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// The part of a version both planes share: the apps plane writes a build as
|
|
202
|
+
// `<base>+<sha>`, the LAN npm plane as `<base>-ci.<n>.<sha>` — strip the suffix
|
|
203
|
+
// and both collapse to the same core version.
|
|
204
|
+
function coreVersion(v) {
|
|
205
|
+
return typeof v === "string" ? v.split(/[+-]/)[0] : null;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* @typedef {object} Manifest
|
|
210
|
+
* @property {string} source the artifact plane ("apps" or "github")
|
|
211
|
+
* @property {string} version the binary's version as the plane publishes it
|
|
212
|
+
* @property {string} [packageVersion] the package version the manifest was written for
|
|
213
|
+
* (absent on manifests written by older launcher releases)
|
|
214
|
+
* @property {string} bin absolute path of the verified binary in the cache
|
|
215
|
+
* @property {string} sha256 verified sha256 of the binary
|
|
216
|
+
* @property {string} from the plane the binary was resolved from
|
|
217
|
+
*/
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* @param {object} [pkg] the installed package's package.json (read from disk if omitted)
|
|
221
|
+
* @param {string|null} [source] the artifact plane this run resolved to
|
|
222
|
+
* @returns {Manifest|null} the manifest if it validates, otherwise a cache miss
|
|
223
|
+
*/
|
|
224
|
+
export function readManifest(pkg = readPkg(), source = null) {
|
|
225
|
+
if (!existsSync(MANIFEST_PATH)) return null;
|
|
226
|
+
try {
|
|
227
|
+
const m = JSON.parse(readFileSync(MANIFEST_PATH, "utf8"));
|
|
228
|
+
if (!m || typeof m !== "object" || !m.bin || !existsSync(m.bin)) return null;
|
|
229
|
+
// A manifest written under a different plane describes a binary this run did not ask for
|
|
230
|
+
// (someone set CRUST_SOURCE between runs). Re-resolve rather than launch it.
|
|
231
|
+
if (source && m.source && m.source !== source) return null;
|
|
232
|
+
// Same for a different package version: after a bump (0.3.0 -> 0.3.1) the old manifest
|
|
233
|
+
// would keep shadowing the new release until a human deleted the file by hand. The
|
|
234
|
+
// package version the manifest was WRITTEN FOR is `packageVersion`; manifests written by
|
|
235
|
+
// older launcher releases predate that field, so fall back to the binary's own version
|
|
236
|
+
// at core granularity — one re-resolve upgrades them to the new shape and pins them.
|
|
237
|
+
if (typeof pkg?.version !== "string") return null;
|
|
238
|
+
if (typeof m.packageVersion === "string") {
|
|
239
|
+
if (m.packageVersion !== pkg.version) return null;
|
|
240
|
+
} else if (coreVersion(m.version) !== coreVersion(pkg.version)) {
|
|
241
|
+
return null;
|
|
242
|
+
}
|
|
243
|
+
return m;
|
|
244
|
+
} catch {
|
|
245
|
+
return null;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// Resolve, download if needed, record what was verified, return the manifest.
|
|
250
|
+
// `log` is a sink so the shim can stay quiet on the happy path while the postinstall talks.
|
|
251
|
+
/**
|
|
252
|
+
* @param {object} [options]
|
|
253
|
+
* @param {(message: string) => void} [options.log]
|
|
254
|
+
* @param {(message: string) => void} [options.warn]
|
|
255
|
+
*/
|
|
256
|
+
export async function ensureBinary({ log = () => {}, warn = () => {} } = {}) {
|
|
257
|
+
const pkg = readPkg();
|
|
258
|
+
const source = resolveSource(pkg);
|
|
259
|
+
|
|
260
|
+
const cached = readManifest(pkg, source);
|
|
261
|
+
if (cached) return cached;
|
|
262
|
+
|
|
263
|
+
const r = source === "github" ? await resolveFromGithub(pkg) : await resolveFromApps(pkg);
|
|
264
|
+
if (r.note) warn(r.note);
|
|
265
|
+
|
|
266
|
+
// The cache key carries the platform because a home directory can be shared across machines
|
|
267
|
+
// of different architectures; two hosts must not race for one path holding one arch's ELF.
|
|
268
|
+
// The variant is in the key too: clean and mongo binaries share a version but are not
|
|
269
|
+
// interchangeable, and one ~/.cache must not let them overwrite each other.
|
|
270
|
+
const variant = pkg.crust?.variant ? `-${pkg.crust.variant}` : "";
|
|
271
|
+
const dir = path.join(CACHE_ROOT, `${r.version}-${process.platform}-${process.arch}${variant}`);
|
|
272
|
+
const bin = path.join(dir, "crust");
|
|
273
|
+
mkdirSync(dir, { recursive: true });
|
|
274
|
+
|
|
275
|
+
if (existsSync(bin) && (await sha256OfFile(bin)) === r.sha256) {
|
|
276
|
+
log(`already cached: ${r.version}`);
|
|
277
|
+
} else {
|
|
278
|
+
const size = r.bytes ? ` (${(r.bytes / 1048576).toFixed(0)} MB)` : "";
|
|
279
|
+
log(`downloading crust ${r.version}${size} from ${r.from}`);
|
|
280
|
+
await downloadVerified(r.url, bin, r.sha256);
|
|
281
|
+
log(`installed -> ${bin}`);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const manifest = {
|
|
285
|
+
source,
|
|
286
|
+
version: r.version,
|
|
287
|
+
packageVersion: pkg.version,
|
|
288
|
+
bin,
|
|
289
|
+
sha256: r.sha256,
|
|
290
|
+
from: r.from,
|
|
291
|
+
};
|
|
292
|
+
// Recorded rather than re-derived: after a `latest` fallback the resolved version is no
|
|
293
|
+
// longer derivable from this package's own version, and the shim must launch the binary
|
|
294
|
+
// that was actually verified — not whatever latest points at next week. `packageVersion`
|
|
295
|
+
// records the package version that ASKED FOR that binary: readManifest rejects a manifest
|
|
296
|
+
// written for a different one, so a version bump self-heals (re-resolve, re-verify,
|
|
297
|
+
// rewrite) while a pin written under X.Y.Z still stands on the next run under X.Y.Z.
|
|
298
|
+
try {
|
|
299
|
+
writeFileSync(MANIFEST_PATH, `${JSON.stringify(manifest, null, 2)}\n`);
|
|
300
|
+
} catch (e) {
|
|
301
|
+
// A global install under a root-owned prefix is read-only for a normal user. The cache
|
|
302
|
+
// write already succeeded, so this is recoverable — the next run just re-resolves.
|
|
303
|
+
warn(`could not write install-manifest.json (${e.message}); will re-resolve on each run`);
|
|
304
|
+
}
|
|
305
|
+
return manifest;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// What to tell a user whose download just failed — the recovery differs per plane, and a LAN
|
|
309
|
+
// URL printed at someone on the public internet is worse than no hint at all.
|
|
310
|
+
export function installHint() {
|
|
311
|
+
let pkg;
|
|
312
|
+
try {
|
|
313
|
+
pkg = readPkg();
|
|
314
|
+
} catch {
|
|
315
|
+
return [];
|
|
316
|
+
}
|
|
317
|
+
if (resolveSource(pkg) === "github") {
|
|
318
|
+
const repo = ghRepo(pkg);
|
|
319
|
+
return [
|
|
320
|
+
`Binaries for v${pkg.version} come from https://github.com/${repo}/releases.`,
|
|
321
|
+
"If that host is unreachable, install directly with:",
|
|
322
|
+
` curl -fsSL https://raw.githubusercontent.com/${repo}/main/install.sh | bash`,
|
|
323
|
+
];
|
|
324
|
+
}
|
|
325
|
+
return [
|
|
326
|
+
`The binary is hosted on the LAN at ${APPS_URL}, so this needs to run on that network.`,
|
|
327
|
+
"Override the host with CRUST_APPS_URL, or install directly with:",
|
|
328
|
+
` curl -fsSL ${APPS_URL}/install.sh | bash -s -- crust`,
|
|
329
|
+
];
|
|
330
|
+
}
|