feat(ops): 添加生产备份恢复脚本

- 新增 PostgreSQL 备份、fresh DB 恢复和 server_data volume 备份脚本\n- 恢复默认拒绝覆盖,必须显式 --confirm-overwrite\n- 补充 package scripts、deploy verify 校验和部署文档\n\nCo-Authored-By: GPT-5 Codex <codex@openai.com>
This commit is contained in:
2026-07-08 16:10:15 +08:00
parent 27cc1badc7
commit 2161970543
9 changed files with 697 additions and 1 deletions

116
scripts/ops-utils.mjs Normal file
View File

@@ -0,0 +1,116 @@
import { existsSync, mkdirSync, readFileSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { spawn } from 'node:child_process';
export function parseArgs(argv) {
const parsed = { flags: new Set(), values: new Map(), positionals: [] };
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
if (arg === '--') continue;
if (!arg.startsWith('--')) {
parsed.positionals.push(arg);
continue;
}
const eq = arg.indexOf('=');
if (eq !== -1) {
parsed.values.set(arg.slice(2, eq), arg.slice(eq + 1));
continue;
}
const key = arg.slice(2);
const next = argv[index + 1];
if (next && !next.startsWith('--')) {
parsed.values.set(key, next);
index += 1;
} else {
parsed.flags.add(key);
}
}
return parsed;
}
export function option(args, name, fallback = undefined) {
return args.values.has(name) ? args.values.get(name) : fallback;
}
export function flag(args, name) {
return args.flags.has(name);
}
export function readEnvFile(filePath, { optional = false } = {}) {
if (!existsSync(filePath)) {
if (optional) return {};
throw new Error(`Env file not found: ${filePath}`);
}
const env = {};
const content = readFileSync(filePath, 'utf8');
for (const rawLine of content.split(/\r?\n/)) {
const line = rawLine.trim();
if (!line || line.startsWith('#')) continue;
const eq = line.indexOf('=');
if (eq === -1) continue;
const key = line.slice(0, eq).trim();
let value = line.slice(eq + 1).trim();
if (
(value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))
) {
value = value.slice(1, -1);
}
env[key] = value;
}
return env;
}
export function ensureParentDir(filePath) {
mkdirSync(dirname(filePath), { recursive: true });
}
export function timestampSlug(date = new Date()) {
return date.toISOString().replace(/[-:]/g, '').replace(/\.\d{3}Z$/, 'Z');
}
export function shellQuote(value) {
if (/^[A-Za-z0-9_./:@%+=,-]+$/.test(value)) return value;
return `'${value.replace(/'/g, "'\\''")}'`;
}
export function commandToString(command, { stdin, stdout } = {}) {
const rendered = command.map((part) => shellQuote(part)).join(' ');
const withStdin = stdin ? `${rendered} < ${shellQuote(stdin)}` : rendered;
return stdout ? `${withStdin} > ${shellQuote(stdout)}` : withStdin;
}
export function composePrefix({ envFile, composeFile }) {
return ['docker', 'compose', '--env-file', envFile, '-f', composeFile];
}
export function resolvePath(path) {
return resolve(process.cwd(), path);
}
export function runCommand(command, options = {}) {
return new Promise((resolveRun, rejectRun) => {
const child = spawn(command[0], command.slice(1), {
stdio: options.stdio ?? 'inherit',
cwd: options.cwd ?? process.cwd(),
});
child.on('error', rejectRun);
child.on('close', (code) => {
if (code === 0) {
resolveRun();
} else {
rejectRun(new Error(`Command failed (${code}): ${commandToString(command)}`));
}
});
});
}
export function quotePgLiteral(value) {
return `'${value.replace(/'/g, "''")}'`;
}