1
0
Fork 0
polaris-task-force/src/scripts/run-migrations.ts

323 lines
10 KiB
TypeScript

#!/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<EnvName, EnvConfig> = {
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<void>;
function initInput(): Promise<void> {
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<string> {
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<string, string> {
const raw = readFileSync(filePath, "utf-8");
const vars: Record<string, string> = {};
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<string, string>,
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);
});