#!/usr/bin/env node /** * Local cutover rehearsal (read-only / dry-run). * * Runs (in order): * 1) cutover-deploy-check (goose 039+040+041+042, /readyz, adapter-node notes) * 2) migrator -list-legacy-emails (no patch / export) * 3) migrator -list-member-memberships * 4) migrator -list-companies-without-plans * 5) orphan-processed cleanup POST without confirm (report-only) * * Excludes Clerk patch, SMTP/mailhooks, Stripe, assign/promote -confirm writes. * Assign / promote / orphan live deletes stay separate operator commands * (-confirm / confirm=true). This script never passes those flags. * * Usage: * node scripts/cutover-local-rehearsal.mjs * node scripts/cutover-local-rehearsal.mjs --skip-deploy-check * node scripts/cutover-local-rehearsal.mjs --skip-orphan * make cutover-rehearsal * * Orphan step needs a platform-admin session: set DESCRYBE_SMOKE_PASS (or * CUTOVER_REHEARSAL_PASS) and optional CUTOVER_REHEARSAL_EMAIL (default * demo@descrybe.local). See docs/demo-user.md. */ import { spawnSync } from "node:child_process"; 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 args = new Set(process.argv.slice(2)); const wantHelp = args.has("-h") || args.has("--help"); const skipDeploy = args.has("--skip-deploy-check"); const skipLists = args.has("--skip-lists"); const skipOrphan = args.has("--skip-orphan"); const deployPassthrough = []; if (args.has("--skip-goose")) deployPassthrough.push("--skip-goose"); if (args.has("--skip-readyz")) deployPassthrough.push("--skip-readyz"); if (wantHelp) { console.log(`Usage: node scripts/cutover-local-rehearsal.mjs [flags] Local dry-run rehearsal (no Clerk patch / SMTP / Stripe). --skip-deploy-check skip scripts/cutover-deploy-check.mjs --skip-lists skip migrator list-* steps --skip-orphan skip orphan-processed dry-run --skip-goose forward to deploy-check --skip-readyz forward to deploy-check Env: DATABASE_URL (lists), PUBLIC_API_URL / HEALTH_BASE_URL (orphan), DESCRYBE_SMOKE_PASS or CUTOVER_REHEARSAL_PASS (+ optional CUTOVER_REHEARSAL_EMAIL). Never passes -confirm / confirm=true (assign/promote/orphan deletes stay separate).`); 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 run(cmd, argv, opts = {}) { const r = spawnSync(cmd, argv, { cwd: opts.cwd ?? repoRoot, env, encoding: "utf8", shell: process.platform === "win32", stdio: "inherit", }); if (r.error) { fail(`${opts.label ?? cmd}: spawn failed: ${r.error.message}`); return false; } if (r.status !== 0) { fail(`${opts.label ?? cmd}: exited ${r.status}`); return false; } return true; } function runMigratorList(flag, label) { section(label); if (!env.DATABASE_URL) { fail(`DATABASE_URL unset - cannot run ${flag}`); return; } const ok = run( "go", ["run", "./cmd/migrator", flag, "-postgres", env.DATABASE_URL], { cwd: apiDir, label: flag }, ); if (ok) pass(`${flag} ok`); } function cookieMapFromResponse(res, jar) { const raw = typeof res.headers.getSetCookie === "function" ? res.headers.getSetCookie() : []; const single = res.headers.get("set-cookie"); const lines = raw.length > 0 ? raw : single ? [single] : []; for (const line of lines) { const part = String(line).split(";")[0]; const eq = part.indexOf("="); if (eq <= 0) continue; jar.set(part.slice(0, eq).trim(), part.slice(eq + 1).trim()); } return jar; } function cookieHeader(jar) { return [...jar.entries()].map(([k, v]) => `${k}=${v}`).join("; "); } async function platformLogin(base) { const email = env.CUTOVER_REHEARSAL_EMAIL || env.DEMO_EMAIL || "demo@descrybe.local"; const password = env.CUTOVER_REHEARSAL_PASS || env.DESCRYBE_SMOKE_PASS || ""; if (!password) { return { ok: false, reason: "set DESCRYBE_SMOKE_PASS or CUTOVER_REHEARSAL_PASS for platform-admin orphan dry-run", }; } const jar = new Map(); const me = await fetch(`${base}/api/auth/me`, { headers: { Cookie: cookieHeader(jar) }, }); cookieMapFromResponse(me, jar); const csrf = jar.get("descrybe_csrf"); if (!csrf) { return { ok: false, reason: "missing descrybe_csrf cookie from /api/auth/me" }; } const login = await fetch(`${base}/api/auth/login`, { method: "POST", headers: { "Content-Type": "application/json", "X-CSRF-Token": csrf, Cookie: cookieHeader(jar), }, body: JSON.stringify({ email, password }), }); cookieMapFromResponse(login, jar); const text = await login.text(); if (!login.ok) { return { ok: false, reason: `login ${login.status}: ${text.slice(0, 200)}`, }; } return { ok: true, jar, email }; } async function orphanDryRun() { section("orphan-processed dry-run (no confirm)"); if (skipOrphan) { info("skipped (--skip-orphan)"); return; } const base = ( env.HEALTH_BASE_URL || env.PUBLIC_API_URL || DEV_PUBLIC_API_URL || `http://127.0.0.1:${DEV_API_PORT}` ).replace(/\/$/, ""); let session; try { session = await platformLogin(base); } catch (err) { fail( `orphan login failed: ${err instanceof Error ? err.message : err} (is API up?)`, ); return; } if (!session.ok) { fail(`orphan dry-run skipped: ${session.reason}`); info( `Manual: GET ${base}/api/admin/jobs/orphan-processed or POST …/orphan-processed-cleanup (no confirm=true)`, ); return; } const csrf = session.jar.get("descrybe_csrf"); const url = `${base}/api/admin/jobs/orphan-processed-cleanup`; let res; let text; try { res = await fetch(url, { method: "POST", headers: { "Content-Type": "application/json", "X-CSRF-Token": csrf || "", Cookie: cookieHeader(session.jar), }, body: "{}", }); text = await res.text(); } catch (err) { fail(`orphan POST failed: ${err instanceof Error ? err.message : err}`); return; } console.log(`POST ${url} -> ${res.status}`); console.log(text); if (!res.ok) { fail(`orphan cleanup HTTP ${res.status}`); return; } pass(`orphan dry-run report-only (as ${session.email}; no confirm)`); } console.log( "cutover-local-rehearsal (deploy-check + list-* + orphan dry-run; no Clerk/SMTP/Stripe)", ); if (!skipDeploy) { section("deploy-check"); const ok = run( "node", [path.join("scripts", "cutover-deploy-check.mjs"), ...deployPassthrough], { label: "cutover-deploy-check" }, ); if (ok) pass("deploy-check ok"); } else { section("deploy-check"); info("skipped (--skip-deploy-check)"); } if (!skipLists) { runMigratorList("-list-legacy-emails", "list-legacy-emails (no patch)"); runMigratorList("-list-member-memberships", "list-member-memberships"); runMigratorList( "-list-companies-without-plans", "list-companies-without-plans", ); } else { section("migrator lists"); info("skipped (--skip-lists)"); } await orphanDryRun(); section("summary"); if (failures.length === 0) { console.log( "ok - local rehearsal green (lists + deploy-check + orphan dry-run).", ); console.log( "Not included: Clerk patch, SMTP/mailhooks, Stripe, assign/promote -confirm.", ); process.exit(0); } console.log(`${failures.length} failure(s):`); for (const f of failures) console.log(` - ${f}`); process.exit(1);