diff --git a/scripts/boot-smoke.mjs b/scripts/boot-smoke.mjs new file mode 100644 index 0000000000..0ffd16d583 --- /dev/null +++ b/scripts/boot-smoke.mjs @@ -0,0 +1,161 @@ +#!/usr/bin/env node +/** + * Boot smoke check — the merge gate's "the app starts and serves" proof. + * + * Verifies, against the *built* workspace (run `pnpm build` first): + * 1. The CLI answers `--help` with exit 0. + * 2. `fn serve` boots a real HTTP server on an ephemeral port and + * GET /api/health returns 200 within the timeout. + * 3. The server shuts down cleanly on SIGTERM. + * + * Safety properties (see scripts/check-no-kill-4040.mjs and AGENTS.md): + * - Never binds or touches port 4040 / FUSION_RESERVED_PORTS — an ephemeral + * port is requested from the OS (listen on 0) and double-checked against + * the reserved list. + * - Never kills anything except the child process it spawned itself. + * - Runs with an isolated $HOME (mkdtemp) so it cannot read or corrupt a + * developer's real fusion.db or auth state. + * + * Exit code is the verdict: 0 = boots and serves, non-zero = broken, with + * captured child stderr on stdout for CI logs. + */ + +import { spawn, spawnSync } from "node:child_process"; +import { mkdtempSync, rmSync } from "node:fs"; +import { createServer } from "node:net"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const cliBin = path.join(repoRoot, "packages/cli/bin.mjs"); + +const HEALTH_TIMEOUT_MS = 60_000; +const SHUTDOWN_TIMEOUT_MS = 15_000; + +function parsePortList(raw) { + return String(raw ?? "") + .split(",") + .map((p) => Number.parseInt(p.trim(), 10)) + .filter((p) => Number.isInteger(p) && p > 0); +} + +const RESERVED_PORTS = new Set([4040, ...parsePortList(process.env.FUSION_RESERVED_PORTS)]); + +/** Ask the OS for a free ephemeral port, retrying if it lands on a reserved one. */ +async function getEphemeralPort() { + for (let attempt = 0; attempt < 10; attempt++) { + const port = await new Promise((resolve, reject) => { + const srv = createServer(); + srv.once("error", reject); + srv.listen(0, "127.0.0.1", () => { + const { port } = srv.address(); + srv.close(() => resolve(port)); + }); + }); + if (!RESERVED_PORTS.has(port)) return port; + } + throw new Error("could not obtain a non-reserved ephemeral port"); +} + +function fail(message, stderr = "") { + console.error(`boot-smoke: FAIL — ${message}`); + if (stderr.trim()) { + console.error("--- child stderr (tail) ---"); + console.error(stderr.split("\n").slice(-40).join("\n")); + } + process.exit(1); +} + +async function pollHealth(port, deadline) { + const url = `http://127.0.0.1:${port}/api/health`; + let lastError = "no response"; + while (Date.now() < deadline) { + try { + const res = await fetch(url, { signal: AbortSignal.timeout(2_000) }); + if (res.status === 200) return; + lastError = `HTTP ${res.status}`; + } catch (err) { + lastError = err?.cause?.code ?? err?.name ?? String(err); + } + await new Promise((r) => setTimeout(r, 500)); + } + throw new Error(`health check never returned 200 (last: ${lastError})`); +} + +async function main() { + // 1. CLI answers --help. + const help = spawnSync(process.execPath, [cliBin, "--help"], { + encoding: "utf8", + timeout: 30_000, + }); + if (help.status !== 0) { + fail(`\`fn --help\` exited ${help.status ?? `signal ${help.signal}`}`, help.stderr ?? ""); + } + if (!/serve/i.test(help.stdout ?? "")) { + fail("`fn --help` output does not mention the serve command", help.stderr ?? ""); + } + console.log("boot-smoke: `fn --help` OK"); + + // 2. Real server boot on an ephemeral port with an isolated HOME. + const port = await getEphemeralPort(); + const isolatedHome = mkdtempSync(path.join(tmpdir(), "fusion-boot-smoke-")); + let stderrBuf = ""; + + const child = spawn( + process.execPath, + [cliBin, "serve", "--port", String(port), "--host", "127.0.0.1"], + { + cwd: repoRoot, + env: { + ...process.env, + HOME: isolatedHome, + FUSION_SKIP_ONBOARDING: "1", + // Make sure nothing inherits a PORT that fights the explicit flag. + PORT: undefined, + }, + stdio: ["ignore", "pipe", "pipe"], + }, + ); + child.stderr.on("data", (d) => (stderrBuf += d)); + child.stdout.on("data", (d) => (stderrBuf += d)); + + const cleanup = () => { + if (child.exitCode === null && !child.killed) child.kill("SIGTERM"); + rmSync(isolatedHome, { recursive: true, force: true }); + }; + process.on("exit", cleanup); + + const exitedEarly = new Promise((resolve) => { + child.once("exit", (code, signal) => resolve({ code, signal })); + }); + + try { + await Promise.race([ + pollHealth(port, Date.now() + HEALTH_TIMEOUT_MS), + exitedEarly.then(({ code, signal }) => { + throw new Error(`server exited before becoming healthy (${code ?? `signal ${signal}`})`); + }), + ]); + } catch (err) { + fail(err.message, stderrBuf); + } + console.log(`boot-smoke: GET /api/health 200 on :${port}`); + + // 3. Clean shutdown of OUR child only. + child.kill("SIGTERM"); + const { code, signal } = await Promise.race([ + exitedEarly, + new Promise((resolve) => + setTimeout(() => resolve({ code: null, signal: "timeout" }), SHUTDOWN_TIMEOUT_MS), + ), + ]); + if (signal === "timeout") { + child.kill("SIGKILL"); + fail("server did not shut down within 15s of SIGTERM", stderrBuf); + } + console.log(`boot-smoke: clean shutdown (${code ?? signal})`); + console.log("boot-smoke: PASS"); +} + +main().catch((err) => fail(err.message ?? String(err)));