feat(cli): postinstall ABI sanity check for better-sqlite3

npm install @openrig/cli now runs a postinstall check that fails
loudly if the Node runtime can't load better-sqlite3's native
binary. Layered on top of the engines.node constraint from 587206d:
engines warns at npm level; this hard-blocks at install time with
a copy-paste fix command.

Two-phase check (Option C hybrid):
1. Version-range fast path: rejects odd-numbered Node majors
   (21/23/25) and versions below 20 before attempting any native
   load. Short-circuits with clear error + nvm install fix.
2. Native addon load: on supported versions, requires
   better-sqlite3 and catches ABI mismatch (wrong
   NODE_MODULE_VERSION). Reports npm rebuild fix command.

Error messages follow honest-errors principle: what failed + why
it matters + copy-paste fix (nvm install 22 or npm rebuild).

Files:
- packages/cli/scripts/check-abi.mjs (postinstall script)
- packages/cli/package.json scripts.postinstall wiring
- packages/cli/test/check-abi.test.ts (8 unit tests covering
  supported versions, odd versions, old versions, ABI mismatch,
  and fast-path short-circuit verification)

Gates: check-abi 8/8; CLI 444/445 (1 preexisting chatroom fail);
daemon 1792/1792; tsc + build clean.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
mvschwarz
2026-04-22 16:02:43 -07:00
co-authored by Claude Opus 4.6
parent 587206db6d
commit 23e77a3acf
3 changed files with 187 additions and 0 deletions
+1
View File
@@ -35,6 +35,7 @@
],
"scripts": {
"build": "tsc",
"postinstall": "node scripts/check-abi.mjs",
"prepublishOnly": "bash ../../scripts/build-package.sh",
"test": "vitest run",
"test:watch": "vitest"
+101
View File
@@ -0,0 +1,101 @@
#!/usr/bin/env node
// Postinstall ABI sanity check for @openrig/cli.
// Fails loudly if better-sqlite3 native binary does not match the active
// Node runtime. Layered on top of the engines.node constraint in
// package.json — engines warns at npm level; this hard-blocks at install
// time with a copy-paste fix.
/**
* @param {{ nodeVersion: string, loadNativeAddon: () => void }} deps
* @returns {{ ok: true } | { ok: false, message: string }}
*/
export function checkAbi({ nodeVersion, loadNativeAddon }) {
// Phase 1: version-range check (fast path).
// Even-numbered Node majors (20, 22, 24) are LTS lines with native addon
// prebuilds. Odd-numbered majors (21, 23, 25) generally lack them.
const match = nodeVersion.match(/^v?(\d+)/);
const major = match ? parseInt(match[1], 10) : 0;
if (major < 20) {
return {
ok: false,
message: [
"",
" ╔══════════════════════════════════════════════════════════════╗",
` ║ @openrig/cli requires Node.js 20, 22, or 24 (LTS). ║`,
` ║ Current: ${nodeVersion.padEnd(49)}║`,
" ║ ║",
" ║ Fix: nvm install 22 && npm install -g @openrig/cli ║",
" ╚══════════════════════════════════════════════════════════════╝",
"",
].join("\n"),
};
}
if (major % 2 !== 0) {
return {
ok: false,
message: [
"",
" ╔══════════════════════════════════════════════════════════════╗",
` ║ @openrig/cli does not support odd-numbered Node releases. ║`,
` ║ Current: ${nodeVersion.padEnd(49)}║`,
" ║ ║",
" ║ Odd Node versions (21, 23, 25, …) lack native addon ║",
" ║ prebuilds for better-sqlite3. The daemon will fail. ║",
" ║ ║",
" ║ Fix: nvm install 22 && npm install -g @openrig/cli ║",
" ╚══════════════════════════════════════════════════════════════╝",
"",
].join("\n"),
};
}
// Phase 2: ABI load check (catches edge cases like local Node builds
// or prebuild packaging gaps on supported versions).
try {
loadNativeAddon();
} catch (err) {
const detail = err instanceof Error ? err.message : String(err);
return {
ok: false,
message: [
"",
" ╔══════════════════════════════════════════════════════════════╗",
" ║ better-sqlite3 native binary does not match this Node. ║",
` ║ Current: ${nodeVersion.padEnd(49)}║`,
" ║ ║",
" ║ Fix: npm rebuild better-sqlite3 ║",
" ║ or: nvm install 22 && npm install -g @openrig/cli ║",
" ╚══════════════════════════════════════════════════════════════╝",
"",
` Detail: ${detail}`,
"",
].join("\n"),
};
}
return { ok: true };
}
// --- Run when executed as postinstall script ---
const isMain =
process.argv[1] &&
(import.meta.url === `file://${process.argv[1]}` ||
process.argv[1].endsWith("check-abi.mjs"));
if (isMain) {
const { createRequire } = await import("node:module");
const require = createRequire(import.meta.url);
const result = checkAbi({
nodeVersion: process.version,
loadNativeAddon: () => require("better-sqlite3"),
});
if (!result.ok) {
console.error(result.message);
process.exitCode = 1;
}
}
+85
View File
@@ -0,0 +1,85 @@
import { describe, it, expect } from "vitest";
import { checkAbi } from "../scripts/check-abi.mjs";
describe("postinstall ABI check", () => {
it("passes on supported even-numbered LTS with working native addon", () => {
const result = checkAbi({
nodeVersion: "v22.22.1",
loadNativeAddon: () => {},
});
expect(result.ok).toBe(true);
});
it("passes on Node 20 LTS", () => {
const result = checkAbi({
nodeVersion: "v20.18.0",
loadNativeAddon: () => {},
});
expect(result.ok).toBe(true);
});
it("passes on Node 24 LTS", () => {
const result = checkAbi({
nodeVersion: "v24.0.0",
loadNativeAddon: () => {},
});
expect(result.ok).toBe(true);
});
it("fails on odd-numbered Node 25 with honest error + fix command", () => {
const result = checkAbi({
nodeVersion: "v25.8.0",
loadNativeAddon: () => {},
});
expect(result.ok).toBe(false);
expect(result.message).toContain("odd-numbered");
expect(result.message).toContain("v25.8.0");
expect(result.message).toContain("nvm install 22");
});
it("fails on odd-numbered Node 23 with honest error", () => {
const result = checkAbi({
nodeVersion: "v23.5.0",
loadNativeAddon: () => {},
});
expect(result.ok).toBe(false);
expect(result.message).toContain("odd-numbered");
});
it("fails on Node below 20 with version-too-low error", () => {
const result = checkAbi({
nodeVersion: "v18.20.0",
loadNativeAddon: () => {},
});
expect(result.ok).toBe(false);
expect(result.message).toContain("requires Node.js 20, 22, or 24");
expect(result.message).toContain("nvm install 22");
});
it("fails with ABI mismatch error when native addon throws on supported version", () => {
const result = checkAbi({
nodeVersion: "v22.22.1",
loadNativeAddon: () => {
throw new Error(
"The module was compiled against a different Node.js version using NODE_MODULE_VERSION 127. " +
"This version of Node.js requires NODE_MODULE_VERSION 131."
);
},
});
expect(result.ok).toBe(false);
expect(result.message).toContain("native binary does not match");
expect(result.message).toContain("npm rebuild better-sqlite3");
expect(result.message).toContain("NODE_MODULE_VERSION");
});
it("skips native addon check entirely for odd versions (fast path)", () => {
let addonCalled = false;
const result = checkAbi({
nodeVersion: "v25.8.0",
loadNativeAddon: () => { addonCalled = true; },
});
expect(result.ok).toBe(false);
// Version check short-circuits before trying to load the addon
expect(addonCalled).toBe(false);
});
});