Files
OmniRoute/scripts/check/check-install-upgrade.mjs
diegosouzapw 21c488f210 chore(release): bring main's post-tag fixes into release/v3.8.51 — sync-back step 2/2
The eighteen commits main carries beyond the cycle branch, and what each one
became here:

  already in release/v3.8.51 by its own PR (no-op, verified by content):
    b090b601a5 / 026e1cadaa  deps: nanoid 3.3.18 equal, dompurify 3.4.14 newer
    918fba5e39  .gitignore: /_tasks already anchored
    5f0a394091  #10026 hide health-check-excluded models — same helper, 5 call sites
    c68cda7dfb  #11075 shared passthrough providers — superseded by #11071/#11078
  superseded, one piece kept:
    ca23eed77c  #10055 memoize models.dev pricing — the cycle memoizes on the
                catalog cache version already; only the resetDbInstance() hook is
                ported, wired to that memo
  applied as-is:
    8778ea7d18  stamp dist/BUILD_SHA before the npm provenance gate (#11721)
    aa52351113  decouple the Bun image from the release manifest (#11724)
    925feb27b8  let the bun digest artifact be absent (#11740)
    b65ef333da  size the install-upgrade gate to a measured run
    0ce21232db  #11845 converge install/upgrade schemas (migration renumbered in
                the next commit: 163 collides with 163_radar_feed_cache_generated_at)
    b7c07edad8  #11855 install-upgrade gate on disk, not tmpfs
    8e2fb04329  #11864 drop *.nft.json from the npm tarball (413)
    dea6bb8b6b  #11877 publish npm from a hosted runner (provenance 422)
  handled by the sync script that follows (CHANGELOG protocol):
    b4ec7807ab  Release v3.8.50 — squash of content this branch already carries
    5458026c21 / c44c0a29e8  CHANGELOG aggregation, stats and top-25
  applied separately (its own commit, ten files):
    65e81158ab  #11088 Ollama capability routing — a 5,094-file squash from a
                stale base; only the Ollama files are the change

Every cherry-pick that touched a file this branch had also changed was
resolved by hand and re-run through the tests both sides own for it.
2026-08-28 14:21:44 -03:00

552 lines
24 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env node
/**
* check-install-upgrade — proves the two install paths a real user takes, BEFORE publishing.
*
* `check:pack-boot` already proves a fresh install boots. It does NOT prove the path that
* actually broke us: installing the new version OVER an existing one, where ~110 SQLite
* migrations run against a populated database. v3.8.48 shipped as a hotfix precisely because
* the published 3.8.47 crashed on boot, and a v3.8.49 manual test on a real 3.8.48 box was
* what first exercised the upgrade path end to end.
*
* Phase A — clean install: fresh prefix + fresh DATA_DIR, install the packed tarball, boot.
* Phase B — upgrade install: fresh prefix + fresh DATA_DIR, install the PREVIOUS published
* version, boot it (creates + migrates the DB), stop, install the
* packed tarball over the SAME prefix, boot against the SAME DATA_DIR.
*
* Schema convergence is the third assertion, and its DIRECTION is what matters:
*
* fresh upgraded ≠ ∅ → FAIL. A table a clean install creates but an upgrade does not
* means every existing user is missing structure the code expects.
* This is the failure mode that only ever bites upgraders.
* upgraded fresh ≠ ∅ → WARN. Residue: a table whose CREATE left the migration set in
* some past cycle but survives in databases that already had it.
* Harmless, but it means the two paths do not converge — allowlist
* it explicitly so a NEW divergence is still visible.
*
* Usage:
* node scripts/check/check-install-upgrade.mjs [--from <version>] [--skip-upgrade]
*
* `--from` pins the previous version (default: the current `latest` dist-tag on npm).
* Requires `npm run build:cli` first — this is a --with-build gate, like check:pack-boot.
*/
import { execFileSync, spawn, spawnSync } from "node:child_process";
import crypto from "node:crypto";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { DatabaseSync } from "node:sqlite";
const BOOT_DEADLINE_MS = 180_000;
const POLL_INTERVAL_MS = 2_000;
const ALLOWLIST_PATH = "config/quality/install-upgrade-allowlist.json";
const log = (msg) => console.log(`[install-upgrade] ${msg}`);
const warn = (msg) => console.log(`[install-upgrade] ⚠️ ${msg}`);
/** Root of the installed package inside an `npm install -g --prefix` tree. */
function packageRootFor(prefix) {
return path.join(prefix, "lib", "node_modules", "omniroute");
}
/**
* Credential for the health probe.
*
* GHSA-mvf8-qc78-5mxm hardened /api/monitoring/health: an ANONYMOUS caller now gets only
* `{ status }` — the version, node version, pid and provider config are reserved for a
* management principal (src/app/api/monitoring/health/route.ts → publicHealthView). An
* unauthenticated probe therefore reads `body.version === undefined`, and this gate's
* version assertion could never pass again; the v3.8.50 publish run failed with
* "clean: health reports version undefined, expected 3.8.50" for exactly that reason.
*
* The gate spawns the server itself, so it can mint the credential instead of guessing one:
* `OMNIROUTE_INTERNAL_SERVICE_TOKEN` + the `x-omniroute-internal-service-token` header is
* accepted by requireManagementAuth() via isTrustedLoopbackInternalServiceRequest(), and the
* probe is loopback by construction. Unlike the machine token `check:pack-boot` derives, this
* does not depend on a readable machine-id, and an older PREVIOUS version that never gated
* health simply ignores the header. The assertion keeps its full strength — it just presents
* a credential.
*/
const INTERNAL_SERVICE_TOKEN = crypto.randomBytes(32).toString("hex");
const INTERNAL_SERVICE_HEADER = "x-omniroute-internal-service-token";
/**
* Secondary credential: the same loopback machine token `check:pack-boot` derives from the
* packaged CLI. Sent alongside the internal-service token so a build that only honours one
* of the two still answers with the full payload.
*/
function derivePackagedCliToken(prefix) {
const cliModuleUrl = pathToFileURL(
path.join(packageRootFor(prefix), "bin", "cli", "utils", "cliToken.mjs")
).href;
try {
return execFileSync(
process.execPath,
[
"--input-type=module",
"--eval",
"import(process.argv[1]).then(async m => process.stdout.write(await m.getCliToken()))",
cliModuleUrl,
],
{ encoding: "utf8", env: { ...process.env } }
).trim();
} catch {
// A truncated/broken install cannot derive a token. Returning null keeps the boot
// probe running (it will fail loudly on its own) instead of crashing the gate here.
return null;
}
}
function pickTarball(packJson) {
const filename = JSON.parse(packJson)?.[0]?.filename;
if (!filename) throw new Error("npm pack --json returned no filename");
return filename;
}
/** Free-ish port per phase so a leaked child from a previous run cannot collide. */
function pickPort(offset) {
return 21000 + offset + (process.pid % 500);
}
function loadAllowlist(root) {
const file = path.join(root, ALLOWLIST_PATH);
if (!fs.existsSync(file)) return { residualTables: {} };
return JSON.parse(fs.readFileSync(file, "utf8"));
}
/**
* Pure verdict on schema convergence — exported so the asymmetry can be tested without
* building, packing and booting anything (the same reason check-test-masking exports its
* helpers: reproducing the deterministic part must not cost a full gate run).
*
* The two directions are NOT symmetric:
* onlyFresh → always a failure. Upgraders would be missing structure.
* onlyUpgraded → residue. Fails only when not recorded in the allowlist.
*/
export function evaluateConvergence({ freshTables, upgradedTables, residualAllowlist = {} }) {
const fresh = freshTables instanceof Set ? freshTables : new Set(freshTables ?? []);
const upgraded = upgradedTables instanceof Set ? upgradedTables : new Set(upgradedTables ?? []);
const onlyFresh = [...fresh].filter((t) => !upgraded.has(t)).sort();
const onlyUpgraded = [...upgraded].filter((t) => !fresh.has(t)).sort();
const unknownResidue = onlyUpgraded.filter((t) => !(t in residualAllowlist));
const failures = [];
if (onlyFresh.length) {
failures.push(
`schema divergence — tables a CLEAN install creates but an UPGRADE does not: ${onlyFresh.join(", ")}. ` +
"Every existing user would be missing these; add the migration."
);
}
if (unknownResidue.length) {
failures.push(
`NEW residual table(s) not in ${ALLOWLIST_PATH}: ${unknownResidue.join(", ")}. ` +
"Either drop them in a migration or record them with a justification."
);
}
return { ok: failures.length === 0, failures, onlyFresh, onlyUpgraded, unknownResidue };
}
/** Table names in a SQLite file, excluding sqlite_* internals. */
function readTables(dbPath) {
if (!fs.existsSync(dbPath)) return null;
const db = new DatabaseSync(dbPath, { readOnly: true });
try {
const rows = db
.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'")
.all();
return new Set(rows.map((r) => r.name));
} finally {
db.close();
}
}
function findDb(dataDir) {
const candidates = ["storage.sqlite", "omniroute.sqlite", "data.sqlite"];
for (const name of candidates) {
const p = path.join(dataDir, name);
if (fs.existsSync(p)) return p;
}
const found = fs.readdirSync(dataDir).find((f) => f.endsWith(".sqlite"));
return found ? path.join(dataDir, found) : null;
}
/** Boot an installed CLI and poll health. Returns { ok, version, failures, tail }. */
async function bootAndProbe({ prefix, dataDir, port, expectVersion, label }) {
const binPath = path.join(prefix, "bin", "omniroute");
if (!fs.existsSync(binPath)) {
return { ok: false, failures: [`${label}: bin not found at ${binPath}`], tail: [] };
}
const cliToken = derivePackagedCliToken(prefix);
const probeHeaders = {
[INTERNAL_SERVICE_HEADER]: INTERNAL_SERVICE_TOKEN,
...(cliToken ? { "x-omniroute-cli-token": cliToken } : {}),
};
const child = spawn(binPath, ["serve", "--port", String(port)], {
env: {
...process.env,
PORT: String(port),
DATA_DIR: dataDir,
JWT_SECRET: "install-upgrade-gate-secret-with-sufficient-length",
API_KEY_SECRET: "install-upgrade-gate-api-key-secret-long",
DISABLE_SQLITE_AUTO_BACKUP: "true",
OMNIROUTE_SKIP_SYSTEM_TRUST: "1",
OMNIROUTE_INTERNAL_SERVICE_TOKEN: INTERNAL_SERVICE_TOKEN,
},
stdio: ["ignore", "pipe", "pipe"],
detached: true,
});
const tail = [];
const keepTail = (chunk) => {
tail.push(String(chunk));
while (tail.length > 80) tail.shift();
};
child.stdout.on("data", keepTail);
child.stderr.on("data", keepTail);
let childExit = null;
child.on("exit", (code) => {
childExit = code ?? -1;
});
const deadline = Date.now() + BOOT_DEADLINE_MS;
let result = { ok: false, failures: [`${label}: never became healthy`], tail };
while (Date.now() < deadline) {
if (childExit !== null) {
result = {
ok: false,
failures: [`${label}: exited with code ${childExit} before serving`],
tail,
};
break;
}
try {
const res = await fetch(`http://127.0.0.1:${port}/api/monitoring/health`, {
headers: probeHeaders,
});
const body = await res.json().catch(() => null);
if (res.status === 200 && body && typeof body === "object") {
const failures = [];
// `status` may legitimately report degraded (no providers configured) — the gate
// targets boot crashes and version mismatches, not health of a bare install.
const reportedVersion = body.version ?? body.system?.version;
if (expectVersion && reportedVersion !== expectVersion) {
failures.push(
`${label}: health reports version ${reportedVersion}, expected ${expectVersion}` +
(reportedVersion === undefined
? " — the payload carries no version at all, which is the ANONYMOUS health " +
"view: the probe's credentials were not accepted (see GHSA-mvf8-qc78-5mxm)"
: "")
);
}
result = { ok: failures.length === 0, version: reportedVersion, failures, tail };
break;
}
} catch {
// not listening yet
}
await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS));
}
try {
if (childExit === null) process.kill(-child.pid, "SIGTERM");
} catch {
/* already gone */
}
// Give the process a moment to flush and release the SQLite handle before we read the file.
await new Promise((r) => setTimeout(r, 3_000));
return result;
}
/**
* `npm install` reports ENOSPC as a *warning* per failed tar entry and still exits 0.
*
* That is not a theoretical concern: on the v3.8.50 publish run the Phase B upgrade install
* emitted 5611 `npm warn tar TAR_ENTRY_ERROR ENOSPC: no space left on device` lines, exited
* 0, and left a truncated package behind. `omniroute serve` then "exited with code 0 before
* serving", no migration ever ran, and the gate concluded the release was missing 15 tables
* — a full false alarm produced by a full disk. Each install tree is ~3 GB, and the run
* builds two of them plus a ~275 MB tarball.
*
* So: surface the truncation at the install, where it is unambiguous.
*/
function npmInstallInto(prefix, spec, label = spec) {
// spawnSync (not execFileSync): execFileSync forwards the child's stderr straight to the
// parent's, so the ENOSPC warnings scrolled past in CI without the script ever seeing
// them. spawnSync hands both streams back.
const run = spawnSync(
"npm",
["install", "-g", "--prefix", prefix, "--no-audit", "--no-fund", spec],
{ encoding: "utf8", maxBuffer: 512 * 1024 * 1024 }
);
const output = `${run.stdout ?? ""}${run.stderr ?? ""}`;
// Keep the install log visible, but a truncated package emits thousands of identical
// warnings — collapse them so the real message is not buried.
const stderrLines = String(run.stderr ?? "").split("\n");
const shown = stderrLines.length > 60 ? stderrLines.slice(0, 40) : stderrLines;
if (String(run.stderr ?? "").trim()) {
process.stderr.write(shown.join("\n") + "\n");
if (stderrLines.length > 60) {
process.stderr.write(`[install-upgrade] … ${stderrLines.length - 40} more npm line(s)\n`);
}
}
assertNoDiskExhaustion(output, label);
if (run.error) throw run.error;
if (run.status !== 0) {
throw new Error(`${label}: npm install exited with code ${run.status}`);
}
}
export function assertNoDiskExhaustion(output, label) {
if (!/ENOSPC|no space left on device/i.test(output)) return;
const count = (output.match(/ENOSPC/g) ?? []).length;
throw new Error(
`${label}: the install ran out of disk space (${count} ENOSPC error(s) from npm). ` +
`The package tree is truncated, so anything measured from it — boot, schema, ` +
`migrations — is meaningless. Free space in ${workDirForMessages} (each install tree is ` +
`~3 GB) and re-run. This is an environment failure, NOT a schema divergence.`
);
}
/** Best-effort free bytes on the filesystem backing `dir`, or null when unavailable. */
function freeBytes(dir) {
try {
return fs.statfsSync(dir).bavail * fs.statfsSync(dir).bsize;
} catch {
return null;
}
}
const GB = 1024 ** 3;
// Set once the work directory exists, so the ENOSPC message names the filesystem that
// actually ran out — pointing at /tmp when the gate works elsewhere sends the reader to
// free space on the wrong volume (which is what happened during the v3.8.50 publish).
let workDirForMessages = os.tmpdir();
function resolvePreviousVersion(current, explicit) {
if (explicit) return explicit;
const out = execFileSync("npm", ["view", "omniroute", "dist-tags.latest"], { encoding: "utf8" });
const latest = out.trim();
if (!latest) throw new Error("could not resolve omniroute@latest from npm");
if (latest === current) {
// The version under test is already published (re-run of a shipped release): step back
// to the highest published version strictly below it.
const all = JSON.parse(
execFileSync("npm", ["view", "omniroute", "versions", "--json"], { encoding: "utf8" })
);
const stable = all.filter((v) => !/-(rc|alpha|beta|pre|next)/.test(v) && v !== current);
return stable[stable.length - 1];
}
return latest;
}
async function main() {
const ROOT = process.cwd();
const args = process.argv.slice(2);
const fromIdx = args.indexOf("--from");
const explicitFrom = fromIdx >= 0 ? args[fromIdx + 1] : null;
const skipUpgrade = args.includes("--skip-upgrade");
if (!fs.existsSync(path.join(ROOT, "dist", "server.js"))) {
console.error("[install-upgrade] dist/server.js missing — run `npm run build:cli` first");
process.exit(2);
}
const version = JSON.parse(fs.readFileSync(path.join(ROOT, "package.json"), "utf8")).version;
const allowlist = loadAllowlist(ROOT);
// NOT os.tmpdir(): on the self-hosted runner /tmp is a 12 GB tmpfs backed by RAM, while
// the root filesystem has ~66 GB free. This gate needs ~12 GB, so it exhausted the tmpfs
// and npm truncated the package — 58269 ENOSPC errors on the v3.8.50 publish, which the
// previous code could only report as a crash. Freeing disk did not help because the disk
// was never the constraint. Work on real disk beside the repo instead.
const workRoot =
process.env.OMNIROUTE_INSTALL_UPGRADE_WORKDIR || path.join(ROOT, ".install-upgrade");
fs.mkdirSync(workRoot, { recursive: true });
const tmp = fs.mkdtempSync(path.join(workRoot, "omniroute-install-upgrade-"));
workDirForMessages = tmp;
const failures = [];
const warnings = [];
try {
// Timed, because this turned out to be the expensive part: on the 2026-08-27
// v3.8.50 publish `npm pack` alone took 24m37s, leaving 5 of the step's 30-minute
// budget for two installs and two boots. Without a duration here the log showed
// only "packing…" then a timeout, which reads like a hang and is not.
const packStarted = Date.now();
log(`packing v${version}`);
const packOut = execFileSync("npm", ["pack", "--json", "--pack-destination", tmp], {
cwd: ROOT,
encoding: "utf8",
maxBuffer: 128 * 1024 * 1024,
});
const tarball = path.join(tmp, pickTarball(packOut));
const packMb = (fs.statSync(tarball).size / 1024 / 1024).toFixed(1);
log(`packed in ${Math.round((Date.now() - packStarted) / 1000)}s (${packMb} MB)`);
// Each install tree is ~3 GB and this run builds two of them, side by side, plus the
// ~275 MB tarball. On the v3.8.50 publish run that overflowed the runner disk mid-way
// through the Phase B upgrade install; npm warned per truncated tar entry and still
// exited 0, and every later measurement was taken from a broken tree.
const availableBytes = freeBytes(tmp);
if (availableBytes !== null) {
log(`free space in ${tmp}: ${(availableBytes / GB).toFixed(1)} GB`);
if (availableBytes < 12 * GB) {
warn(
`only ${(availableBytes / GB).toFixed(1)} GB free — this gate needs roughly 12 GB ` +
`(two ~3 GB install trees, the second installed over twice, plus the tarball). ` +
`An install truncated by ENOSPC looks like a schema divergence.`
);
}
}
// ---- Phase A: clean install -------------------------------------------------
log("PHASE A — clean install of the packed tarball");
const aPrefix = path.join(tmp, "a-prefix");
const aData = path.join(tmp, "a-data");
fs.mkdirSync(aData, { recursive: true });
npmInstallInto(aPrefix, tarball, "clean install");
const a = await bootAndProbe({
prefix: aPrefix,
dataDir: aData,
port: pickPort(0),
expectVersion: version,
label: "clean",
});
failures.push(...a.failures);
const cleanBooted = a.ok;
if (a.ok) log(`clean install healthy on v${a.version}`);
else if (a.tail?.length) {
console.error("[install-upgrade] last output from the clean-install server:");
console.error(a.tail.join("").split("\n").slice(-40).join("\n"));
}
const aDb = findDb(aData);
const freshTables = aDb ? readTables(aDb) : null;
if (!freshTables) failures.push("clean: no SQLite database was created");
else log(`clean install schema: ${freshTables.size} tables`);
// Phase A is fully measured (boot verdict + schema snapshot); its ~3 GB install tree is
// dead weight from here on and Phase B needs the room. The DATA_DIR stays — only the
// node_modules tree goes.
if (!skipUpgrade) {
fs.rmSync(aPrefix, { recursive: true, force: true });
const reclaimed = freeBytes(tmp);
log(
"released the clean-install tree before the upgrade phase" +
(reclaimed !== null ? ` (${(reclaimed / GB).toFixed(1)} GB free)` : "")
);
}
// ---- Phase B: upgrade over the previous published version -------------------
let upgradedTables = null;
// `--skip-upgrade` never reaches the convergence block (upgradedTables stays null), so
// defaulting this to true keeps that path unchanged.
let upgradeBooted = true;
if (skipUpgrade) {
warn("PHASE B skipped (--skip-upgrade)");
} else {
const previous = resolvePreviousVersion(version, explicitFrom);
log(`PHASE B — upgrade path: omniroute@${previous} → v${version}`);
const bPrefix = path.join(tmp, "b-prefix");
const bData = path.join(tmp, "b-data");
fs.mkdirSync(bData, { recursive: true });
npmInstallInto(bPrefix, `omniroute@${previous}`, `previous(${previous}) install`);
const before = await bootAndProbe({
prefix: bPrefix,
dataDir: bData,
port: pickPort(1),
expectVersion: previous,
label: `previous(${previous})`,
});
if (!before.ok) {
// A broken PREVIOUS version is not this release's fault — degrade to a warning so a
// historically bad publish cannot block the current one.
warnings.push(
`previous version ${previous} did not boot cleanly — upgrade path unverified`
);
for (const f of before.failures) warn(f);
} else {
const beforeDb = findDb(bData);
const beforeTables = beforeDb ? readTables(beforeDb) : new Set();
log(`previous(${previous}) schema: ${beforeTables.size} tables — upgrading in place`);
npmInstallInto(bPrefix, tarball, "upgrade install");
const after = await bootAndProbe({
prefix: bPrefix,
dataDir: bData,
port: pickPort(2),
expectVersion: version,
label: "upgraded",
});
failures.push(...after.failures);
if (after.ok) log(`upgrade healthy on v${after.version}`);
upgradeBooted = after.ok;
if (!after.ok && after.tail?.length) {
console.error("[install-upgrade] last output from the upgraded server:");
console.error(after.tail.join("").split("\n").slice(-40).join("\n"));
}
const afterDb = findDb(bData);
upgradedTables = afterDb ? readTables(afterDb) : null;
if (!upgradedTables) {
failures.push("upgraded: database disappeared after the upgrade");
} else {
log(`upgraded schema: ${upgradedTables.size} tables`);
const dropped = [...beforeTables].filter((t) => !upgradedTables.has(t));
if (dropped.length) {
failures.push(`upgrade DROPPED tables that existed before: ${dropped.join(", ")}`);
}
}
}
}
// ---- Schema convergence -----------------------------------------------------
// Only meaningful when BOTH servers actually served. A boot that died before serving
// never ran a migration, so its database still holds the PREVIOUS release's schema and
// every post-baseline table shows up as "a clean install creates but an upgrade does
// not" — which is what the v3.8.50 publish run reported after ENOSPC truncated the
// upgrade install. Comparing there does not add information, it manufactures a
// 15-table false alarm on top of the real failure. The run still fails: the boot
// failure is already in `failures`.
if (freshTables && upgradedTables && !(cleanBooted && upgradeBooted)) {
warn(
"schema convergence NOT evaluated — a phase failed to boot, so its database was " +
"never migrated and any table difference would describe the broken boot, not the schema"
);
} else if (freshTables && upgradedTables) {
const verdict = evaluateConvergence({
freshTables,
upgradedTables,
residualAllowlist: allowlist.residualTables ?? {},
});
if (verdict.onlyUpgraded.length) {
warn(`residual tables present only after upgrade: ${verdict.onlyUpgraded.join(", ")}`);
}
failures.push(...verdict.failures);
if (verdict.ok) log("schema convergence OK (no new divergence)");
}
if (warnings.length) for (const w of warnings) warn(w);
if (failures.length) {
console.error(`[install-upgrade] FAIL — ${failures.length} problem(s):`);
for (const f of failures) console.error(`${f}`);
process.exit(1);
}
log("PASS — clean install and upgrade path both boot; schema converges.");
process.exit(0);
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
}
}
// Only run the (expensive) gate when invoked directly — importing this module for the pure
// helper above must not pack, install or boot anything.
if (
process.argv[1] &&
path.resolve(process.argv[1]) === path.resolve(new URL(import.meta.url).pathname)
) {
main().catch((err) => {
console.error(`[install-upgrade] crashed: ${err?.message ?? err}`);
process.exit(1);
});
}