348 lines
11 KiB
TypeScript
348 lines
11 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);
|
|
});
|