#!/usr/bin/env bun /** * Interactive migration runner — safely run Payload migrations against any * environment database (dev / stg / prd) from your local machine. * * Usage: * bun run migrate # interactive menu * bun run migrate dev # skip menu, target dev * bun run migrate stg # skip menu, target staging * bun run migrate prd # skip menu, target production * bun run migrate --status # show migration status only (no changes) * bun run migrate prd --status # status for a specific env * bun run migrate --fresh # drop + recreate (dev only!) * * Environment files: * .env — dev (DATABASE_URI for local PostgreSQL) * .env.stg — staging (DATABASE_URI for staging PostgreSQL) * .env.prd — production (DATABASE_URI for production PostgreSQL) * * These files are gitignored. Copy the DATABASE_URI from Coolify and paste * into the appropriate file. Template: * DATABASE_URI=postgres://user:password@host:5432/dbname */ import { spawn } from "node:child_process"; import { existsSync, readFileSync } from "node:fs"; import * as path from "node:path"; // ── Config ────────────────────────────────────────────────────────────── type EnvName = "dev" | "stg" | "prd"; interface EnvConfig { label: string; file: string; color: string; } const ENVS: Record = { dev: { label: "Development", file: ".env", color: "\x1b[36m", }, stg: { label: "Staging", file: ".env.stg", color: "\x1b[33m", }, prd: { label: "Production", file: ".env.prd", color: "\x1b[31m", }, }; const ENV_ORDER: EnvName[] = ["dev", "stg", "prd"]; // ── ANSI helpers ──────────────────────────────────────────────────────── const RESET = "\x1b[0m"; const BOLD = "\x1b[1m"; const DIM = "\x1b[2m"; const RED = "\x1b[31m"; const GREEN = "\x1b[32m"; const YELLOW = "\x1b[33m"; function log(msg: string) { console.log(msg); } // ── stdin abstraction (handles both TTY and piped) ───────────────────── const inputLines: string[] = []; let inputReady: Promise; function initInput(): Promise { if (!process.stdin.isTTY) { // Piped input: buffer everything up front so readline never closes early inputReady = new Promise((resolve) => { let buf = ""; process.stdin.setEncoding("utf-8"); process.stdin.on("data", (chunk: string) => { buf += chunk; }); process.stdin.on("end", () => { for (const line of buf.split("\n")) { const trimmed = line.trim(); if (trimmed) inputLines.push(trimmed); } resolve(); }); }); return inputReady; } inputReady = Promise.resolve(); return inputReady; } async function ask(question: string): Promise { if (!process.stdin.isTTY) { // Consume from buffer if (inputLines.length === 0) return ""; const line = inputLines.shift()!; process.stdout.write(`${question}${line}\n`); return line; } // Interactive: use readline for proper prompt const { createInterface } = await import("node:readline"); return new Promise((resolve) => { const rl = createInterface({ input: process.stdin, output: process.stdout, }); rl.question(question, (answer) => { rl.close(); resolve(answer.trim()); }); }); } // ── Helpers ───────────────────────────────────────────────────────────── function redactUri(uri: string): string { return uri.replace(/(\/\/[^:]+:)[^@]+(@)/, "$1****$2"); } function parseEnvFile(filePath: string): Record { const raw = readFileSync(filePath, "utf-8"); const vars: Record = {}; for (const line of raw.split("\n")) { const trimmed = line.trim(); if (!trimmed || trimmed.startsWith("#")) continue; const eqIdx = trimmed.indexOf("="); if (eqIdx === -1) continue; const key = trimmed.slice(0, eqIdx).trim(); const val = trimmed.slice(eqIdx + 1).trim(); vars[key] = val.replace(/^["']|["']$/g, ""); } return vars; } function loadDatabaseUri(envName: EnvName): string | null { const config = ENVS[envName]; const filePath = path.resolve(process.cwd(), config.file); if (!existsSync(filePath)) return null; const vars = parseEnvFile(filePath); return vars.DATABASE_URI ?? null; } function run( cmd: string, args: string[], envOverrides: Record, stdinData?: string, ): Promise<{ code: number; stdout: string; stderr: string }> { return new Promise((resolve) => { const child = spawn(cmd, args, { env: { ...process.env, ...envOverrides }, stdio: [stdinData != null ? "pipe" : "ignore", "pipe", "pipe"], }); if (stdinData != null && child.stdin) { child.stdin.write(stdinData); child.stdin.end(); } let stdout = ""; let stderr = ""; child.stdout?.on("data", (d: Buffer) => { const s = d.toString(); stdout += s; process.stdout.write(s); }); child.stderr?.on("data", (d: Buffer) => { const s = d.toString(); stderr += s; process.stderr.write(s); }); child.on("close", (code) => resolve({ code: code ?? 1, stdout, stderr })); }); } // ── Menu ──────────────────────────────────────────────────────────────── async function pickEnv(forced?: string): Promise<{ env: EnvName; uri: string }> { if (forced && forced in ENVS) { const uri = loadDatabaseUri(forced as EnvName); if (!uri) { log(`${RED}Cannot find DATABASE_URI in ${ENVS[forced as EnvName].file}${RESET}`); log(`Create the file with: DATABASE_URI=postgres://user:pass@host:5432/db`); process.exit(1); } return { env: forced as EnvName, uri }; } log(`\n${BOLD}⚡ Polaris Task Force: Migration Runner${RESET}\n`); log("Target environment:\n"); for (let i = 0; i < ENV_ORDER.length; i++) { const key = ENV_ORDER[i]; const cfg = ENVS[key]; const exists = loadDatabaseUri(key) !== null; const status = exists ? `${GREEN}●${RESET}` : `${DIM}○ missing${RESET}`; log( ` ${BOLD}${i + 1}${RESET}) ${cfg.color}${cfg.label}${RESET} ${DIM}(${cfg.file})${RESET} ${status}`, ); } log(""); const answer = (await ask(` Select (1-${ENV_ORDER.length}): `)).trim(); const idx = parseInt(answer, 10) - 1; if (isNaN(idx) || idx < 0 || idx >= ENV_ORDER.length) { log(`${RED}Invalid selection.${RESET}`); process.exit(1); } const env = ENV_ORDER[idx]; const uri = loadDatabaseUri(env); if (!uri) { log(`\n${RED}DATABASE_URI not found in ${ENVS[env].file}${RESET}`); log(`Create the file with: ${BOLD}DATABASE_URI=postgres://user:pass@host:5432/db${RESET}`); process.exit(1); } return { env, uri }; } // ── Main ──────────────────────────────────────────────────────────────── async function main() { await initInput(); const args = process.argv.slice(2); const statusOnly = args.includes("--status") || args.includes("-s"); const freshOnly = args.includes("--fresh"); const positionalArgs = args.filter((a) => !a.startsWith("-")); const forced = positionalArgs[0]; const { env, uri } = await pickEnv(forced); const cfg = ENVS[env]; const redacted = redactUri(uri); // Header log(`\n${cfg.color}${BOLD}━━━ ${cfg.label.toUpperCase()} ━━━${RESET}`); log(`${DIM} ${redacted}${RESET}\n`); if (statusOnly) { log(`${DIM}Migration status:${RESET}\n`); await run("bun", ["run", "payload", "migrate:status"], { DATABASE_URI: uri, }); return; } if (freshOnly) { if (env !== "dev") { log(`${RED}${BOLD}--fresh is only allowed for dev.${RESET}`); log(`${DIM}For staging/production, use migrate (up only) or migrate:fresh manually.${RESET}`); process.exit(1); } const confirm = await ask( `${YELLOW}This will DROP and recreate all tables on ${cfg.label}. Continue? (y/N): ${RESET}`, ); if (confirm.toLowerCase() !== "y") { log("Aborted."); return; } log(""); await run("bun", ["run", "payload", "migrate:fresh"], { DATABASE_URI: uri, }); log(`\n${GREEN}${BOLD}✓ Fresh migration complete.${RESET}`); return; } // Show current status log(`${DIM}Migration status:${RESET}\n`); const statusResult = await run("bun", ["run", "payload", "migrate:status"], { DATABASE_URI: uri, }); // Check for pending migrations const hasPending = statusResult.stdout.includes("│ No │"); if (!hasPending && !statusResult.stderr) { log(`\n${GREEN}All migrations applied. Nothing to do.${RESET}`); return; } // Confirm log(""); if (env === "prd") { log(`${RED}${BOLD} ╔══════════════════════════════════════════╗${RESET}`); log(`${RED}${BOLD} ║ YOU ARE ABOUT TO MIGRATE PRODUCTION ║${RESET}`); log(`${RED}${BOLD} ╚══════════════════════════════════════════╝${RESET}\n`); const confirm = await ask(`${RED}Type "migrate" to confirm: ${RESET}`); if (confirm.trim() !== "migrate") { log("Aborted."); return; } } else { const confirm = await ask(`${YELLOW}Run migrations on ${cfg.label}? (y/N): ${RESET}`); if (confirm.toLowerCase() !== "y") { log("Aborted."); return; } } // Run migration — pipe "y\n" so Payload's dev-mode prompt (triggered by // push:true's batch=-1 record) is auto-accepted instead of blocking on // non-interactive stdin. log(`\n${BOLD}Running migrations...${RESET}\n`); const result = await run("bun", ["run", "payload", "migrate"], { DATABASE_URI: uri }, "y\n"); if (result.code !== 0) { log(`\n${RED}${BOLD}✗ Migration failed (exit ${result.code})${RESET}`); process.exit(result.code); } log(`\n${GREEN}${BOLD}✓ Migrations applied successfully.${RESET}`); } main().catch((err) => { console.error(`${RED}Fatal error:${RESET}`, err); process.exit(1); });