#!/usr/bin/env node import { spawn } from 'node:child_process'; import fs from 'node:fs/promises'; import path from 'node:path'; function parseArgs(argv) { const [action = '', ...rest] = argv; const options = { action }; for (let index = 0; index < rest.length; index += 1) { const item = rest[index]; if (item === '--output') options.output = rest[++index]; else if (item === '--input') options.input = rest[++index]; else if (item === '--database-url') options.databaseUrl = rest[++index]; else if (item === '--confirm-empty-target') options.confirmEmptyTarget = true; else if (item === '--allow-remote-target') options.allowRemoteTarget = true; } return options; } function run(command, args, { env = process.env } = {}) { return new Promise((resolve, reject) => { const child = spawn(command, args, { env, stdio: ['ignore', 'inherit', 'inherit'], shell: false, }); child.once('error', reject); child.once('exit', (code, signal) => { if (code === 0) resolve(); else reject(new Error(`${command} exited with ${signal ?? code}`)); }); }); } function databaseUrl(options) { const value = String( options.databaseUrl ?? process.env.MEMIND_ORCHESTRATOR_DATABASE_URL ?? '', ).trim(); if (!value) throw new Error('Provide --database-url or MEMIND_ORCHESTRATOR_DATABASE_URL'); return value; } function requireLocalOrExplicitRemote(url, options) { const hostname = new URL(url).hostname.toLowerCase(); const local = ['localhost', '127.0.0.1', '::1', 'postgres'].includes(hostname); if (!local && !options.allowRemoteTarget) { throw new Error('Remote database targets require --allow-remote-target'); } } async function backup(options) { const output = path.resolve(String(options.output ?? '')); if (!options.output || path.extname(output) !== '.dump') { throw new Error('Backup requires an explicit --output path ending in .dump'); } await fs.mkdir(path.dirname(output), { recursive: true }); await fs.access(output).then( () => Promise.reject(new Error(`Refusing to overwrite existing backup: ${output}`)), () => null, ); await run('pg_dump', [ '--format=custom', '--no-owner', '--no-privileges', '--file', output, databaseUrl(options), ]); console.log(JSON.stringify({ ok: true, action: 'backup', output })); } async function verify(options) { const input = path.resolve(String(options.input ?? '')); if (!options.input) throw new Error('Verify requires --input'); await fs.access(input); await run('pg_restore', ['--list', input]); console.log(JSON.stringify({ ok: true, action: 'verify', input })); } async function restore(options) { if (!options.confirmEmptyTarget) { throw new Error('Restore requires --confirm-empty-target and never cleans an existing database'); } const input = path.resolve(String(options.input ?? '')); if (!options.input) throw new Error('Restore requires --input'); await fs.access(input); const target = databaseUrl(options); requireLocalOrExplicitRemote(target, options); await run('pg_restore', [ '--exit-on-error', '--no-owner', '--no-privileges', '--dbname', target, input, ]); console.log(JSON.stringify({ ok: true, action: 'restore', input })); } const options = parseArgs(process.argv.slice(2)); try { if (options.action === 'backup') await backup(options); else if (options.action === 'verify') await verify(options); else if (options.action === 'restore') await restore(options); else { throw new Error( 'Usage: orchestrator-dr.mjs backup --output file.dump | verify --input file.dump | restore --input file.dump --confirm-empty-target', ); } } catch (error) { console.error(error instanceof Error ? error.message : String(error)); process.exitCode = 1; } export const orchestratorDrInternals = { databaseUrl, parseArgs, requireLocalOrExplicitRemote, run, };