#!/usr/bin/env node /** * Read-only cutover deploy gates (no migrate/up, no restarts, no infra changes). * * Checks: * 1) goose status includes applied 039_worker_heartbeats + 040_job_hotpath_indexes + 041_password_reset_tokens + 042_user_session_version * 2) GET /readyz reports checks.worker=ok (fresh processing heartbeat <=60s) * 3) adapter-node host gates (pin, svelte.config import, build/start scripts) * * Usage: * node scripts/cutover-deploy-check.mjs * npm run cutover:deploy-check * npm run cutover:deploy-check:code * node scripts/cutover-deploy-check.mjs --skip-goose * node scripts/cutover-deploy-check.mjs --skip-readyz * HEALTH_BASE_URL=https://api.example.com node scripts/cutover-deploy-check.mjs */ import { spawnSync } from "node:child_process"; import fs from "node:fs"; import path from "node:path"; import { DEV_API_PORT, DEV_PUBLIC_API_URL } from "./dev-ports.mjs"; import { loadRootEnv, repoRoot } from "./root-env.mjs"; const REQUIRED_MIGRATIONS = [ "039_worker_heartbeats", "040_job_hotpath_indexes", "041_password_reset_tokens", "042_user_session_version", ]; const ADAPTER_NODE_PIN = "5.5.7"; const args = new Set(process.argv.slice(2)); const skipGoose = args.has("--skip-goose"); const skipReadyz = args.has("--skip-readyz"); const wantHelp = args.has("-h") || args.has("--help"); if (wantHelp) { console.log(`Usage: node scripts/cutover-deploy-check.mjs [--skip-goose] [--skip-readyz] Read-only cutover gates for goose 039+040+041+042, worker /readyz heartbeat, and adapter-node host gates (pin + svelte.config + build/start scripts). Does not run goose up, restart processes, or change infra. Env: DATABASE_URL (goose), PUBLIC_API_URL / HEALTH_BASE_URL (readyz). Alias: npm run cutover:deploy-check npm run cutover:deploy-check:code (--skip-goose --skip-readyz)`); process.exit(0); } const env = loadRootEnv(process.env); const apiDir = path.join(repoRoot, "apps", "api"); const failures = []; function section(title) { console.log(`\n==> ${title}`); } function pass(msg) { console.log(`PASS ${msg}`); } function fail(msg) { console.log(`FAIL ${msg}`); failures.push(msg); } function info(msg) { console.log(`NOTE ${msg}`); } function parseReadyz(text) { try { return JSON.parse(text); } catch { return null; } } function migrationApplied(statusText, id) { const lineMatch = statusText .split(/\r?\n/) .map((l) => l.trim()) .filter((l) => l.includes(id)); if (lineMatch.length === 0) return { found: false, pending: false }; const pending = lineMatch.every((l) => /\bPending\b/i.test(l)); if (pending) return { found: true, pending: true }; return { found: true, pending: false }; } function checkGoose() { section("goose status (039 + 040 + 041 + 042)"); if (skipGoose) { info("skipped (--skip-goose)"); return; } if (!env.DATABASE_URL) { fail("DATABASE_URL unset - cannot verify goose 039/040/041/042 (set root .env or shell)"); return; } const r = spawnSync( "go", [ "run", "github.com/pressly/goose/v3/cmd/goose@v3.24.3", "-dir", "sql/schema", "postgres", env.DATABASE_URL, "status", ], { cwd: apiDir, env, encoding: "utf8", shell: process.platform === "win32", }, ); if (r.error) { fail(`goose status spawn failed: ${r.error.message}`); return; } const out = `${r.stdout || ""}${r.stderr || ""}`; if (r.status !== 0) { fail(`goose status exited ${r.status}`); if (out.trim()) console.log(out.trim()); return; } console.log(out.trim() || "(empty goose status)"); for (const id of REQUIRED_MIGRATIONS) { const st = migrationApplied(out, id); if (!st.found) { fail(`migration ${id} not listed in goose status`); } else if (st.pending) { fail(`migration ${id} is Pending - run npm run migrate (ops window), then re-check`); } else { pass(`${id} applied`); } } } async function checkReadyz() { section("/readyz worker heartbeat"); if (skipReadyz) { info("skipped (--skip-readyz)"); return; } const base = ( env.HEALTH_BASE_URL || env.PUBLIC_API_URL || DEV_PUBLIC_API_URL || `http://127.0.0.1:${DEV_API_PORT}` ).replace(/\/$/, ""); const url = `${base}/readyz`; let res; let text; try { res = await fetch(url); text = await res.text(); } catch (err) { fail( `GET ${url} failed: ${err instanceof Error ? err.message : err} (start api+worker; npm run dev)`, ); return; } console.log(`GET ${url} -> ${res.status}`); console.log(text); const body = parseReadyz(text); const worker = body?.checks?.worker; const age = body?.worker_last_seen_age_s; if (res.status === 200 && worker === "ok") { pass( `worker heartbeat ok` + (typeof age === "number" ? ` (age_s=${age}, stale after 60s)` : ""), ); return; } if (res.status === 503 && (worker === "missing" || worker === "stale")) { fail( `checks.worker=${worker} - restart cmd/worker after goose 039+040+041+042 (API-only -> 503 is expected)`, ); return; } fail( `/readyz not ready (status=${res.status}, checks.worker=${worker ?? "n/a"})`, ); } function checkAdapterNodeNotes() { section("adapter-node host notes (ops)"); const pkgPath = path.join(repoRoot, "apps", "web", "package.json"); const svelteCfgPath = path.join(repoRoot, "apps", "web", "svelte.config.js"); const rootPkgPath = path.join(repoRoot, "package.json"); let pkg = null; let rootPkg = null; try { pkg = JSON.parse(fs.readFileSync(pkgPath, "utf8")); } catch (err) { fail(`could not read apps/web/package.json: ${err instanceof Error ? err.message : err}`); return; } const pin = pkg?.dependencies?.["@sveltejs/adapter-node"] ?? pkg?.devDependencies?.["@sveltejs/adapter-node"] ?? null; if (pin === ADAPTER_NODE_PIN) { pass(`@sveltejs/adapter-node@${pin} pinned in apps/web/package.json`); } else { fail( `@sveltejs/adapter-node pin is ${JSON.stringify(pin)} (expected ${ADAPTER_NODE_PIN})`, ); } try { const svelteCfg = fs.readFileSync(svelteCfgPath, "utf8"); if (/from\s+["']@sveltejs\/adapter-node["']/.test(svelteCfg)) { pass("apps/web/svelte.config.js imports @sveltejs/adapter-node"); } else { fail("apps/web/svelte.config.js does not import @sveltejs/adapter-node"); } } catch (err) { fail(`could not read apps/web/svelte.config.js: ${err instanceof Error ? err.message : err}`); } const webBuild = pkg?.scripts?.build; const webStart = pkg?.scripts?.start; if (typeof webBuild === "string" && webBuild.trim()) { pass(`apps/web build script: ${webBuild}`); } else { fail("apps/web package.json missing scripts.build"); } if (webStart === "node build") { pass("apps/web start script: node build (adapter-node output)"); } else { fail( `apps/web scripts.start is ${JSON.stringify(webStart)} (expected "node build")`, ); } try { rootPkg = JSON.parse(fs.readFileSync(rootPkgPath, "utf8")); } catch (err) { fail(`could not read root package.json: ${err instanceof Error ? err.message : err}`); } if (rootPkg) { if (rootPkg?.scripts?.["start:web"] === "npm run start --workspace=web") { pass("root start:web -> npm run start --workspace=web"); } else { fail( `root scripts.start:web is ${JSON.stringify(rootPkg?.scripts?.["start:web"])} (expected "npm run start --workspace=web")`, ); } if (rootPkg?.scripts?.["cutover:deploy-check"] === "node scripts/cutover-deploy-check.mjs") { pass("root cutover:deploy-check script present"); } else { fail("root package.json missing scripts[\"cutover:deploy-check\"]"); } if ( rootPkg?.scripts?.["cutover:deploy-check:code"] === "node scripts/cutover-deploy-check.mjs --skip-goose --skip-readyz" ) { pass("root cutover:deploy-check:code script present"); } else { fail("root package.json missing scripts[\"cutover:deploy-check:code\"]"); } } info("Host still required: npm run build --workspace=web -> npm run start:web (or node build in apps/web)."); info("Point WEB_ORIGIN / reverse proxy at that Node process; API stays on PUBLIC_API_URL."); info("Do not deploy as static-CDN-only - hooks.server.ts + server routes need Node."); info("See docs/production-checklist.md section 0 and docs/production-readiness.md deploy note."); } console.log("cutover-deploy-check (read-only; no infra changes)"); checkGoose(); await checkReadyz(); checkAdapterNodeNotes(); section("summary"); if (failures.length === 0) { console.log("ok - goose 039+040+041+042 + worker heartbeat gates green; adapter-node host gates green."); console.log( "Reminder: this does not clear full Prod GO (Clerk/SMTP/Stripe/DNS/orphan run still ops).", ); process.exit(0); } console.log(`${failures.length} failure(s):`); for (const f of failures) console.log(` - ${f}`); process.exit(1);