@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 CHANGED
@@ -1,3 +1,175 @@
1
- # Temporary Holding Version
1
+ # crust
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
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
+ });
@@ -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
+ }