Files
rainblogweb/cli.js
T

633 lines
31 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env node
const { execFileSync, spawn } = require('child_process');
const fs = require('fs');
const path = require('path');
const readline = require('readline');
const bcrypt = require('bcryptjs');
const http = require('http');
const Database = require('better-sqlite3');
const PROJECT_ROOT = __dirname;
const CONFIG_PATH = path.join(PROJECT_ROOT, '.env.json');
const DB_PATH = path.join(PROJECT_ROOT, 'data', 'rainweb.db');
const BACKUP_DIR = path.join(PROJECT_ROOT, 'backups');
const PID_PATH = path.join(PROJECT_ROOT, 'server.pid');
const SERVER_PATH = path.join(PROJECT_ROOT, 'server.js');
const PKG = require('./package.json');
// 只读命令不加载 db.js,避免检查状态时意外触发数据库迁移或写入。
let writableDb = null;
function getWritableDb() {
if (!writableDb) writableDb = require('./db');
return writableDb;
}
const HELP = `
RainWeb CLI v${PKG.version}
用法:node cli.js <命令> [参数] [选项]
命令:
status 查看版本、服务、数据库和备份状态
health HTTP 健康检查和 SQLite 完整性检查
db-check 只读检查 SQLite 完整性和关键表
start 启动服务器(防重复启动)
stop 优雅停止本项目的 server.js
restart 停止后等待并启动服务器
port [端口] 查看或修改监听端口
password 交互式修改管理员密码
captcha 交互式配置验证码规则
config 查看配置(敏感值统一脱敏)
backup 创建数据库备份
backup list 列出并检查备份完整性
backup prune --keep <数量> 预览清理旧备份;加 --yes 才会删除
backup restore <文件> --yes 服务停止后恢复备份,显式确认才覆盖
upgrade 备份、检查工作区、快进升级、构建并验活
help 显示帮助
通用选项:
--yes 跳过需要确认的操作
--json 以 JSON 输出(status/health/config/backup list 等支持)
--timeout <毫秒> 设置 HTTP 检查超时(100-60000
说明:
password 默认交互输入;为兼容旧脚本仍接受末尾明文参数,但不在帮助示例中推荐。
upgrade 仅用于自托管机器上的本地操作,不会修改云端或远程服务。
`;
class CliError extends Error {
constructor(message, code = 1) {
super(message);
this.name = 'CliError';
this.exitCode = code;
}
}
function parseArgs(argv) {
const positionals = [];
const options = { yes: false, json: false };
const valueOptions = new Set(['keep', 'timeout', 'type', 'login', 'register', 'forum', 'failed', 'threshold']);
const booleanOptions = new Set(['yes', 'json']);
for (let i = 0; i < argv.length; i += 1) {
const token = argv[i];
if (token === '--') {
positionals.push(...argv.slice(i + 1));
break;
}
if (!token.startsWith('--')) {
positionals.push(token);
continue;
}
const match = token.match(/^--([^=]+)(?:=(.*))?$/);
if (!match) throw new CliError(`无法解析选项:${token}`);
const name = match[1];
let value = match[2];
if (booleanOptions.has(name)) {
if (value !== undefined) throw new CliError(`选项 --${name} 不接受参数`);
options[name] = true;
continue;
}
if (!valueOptions.has(name)) throw new CliError(`未知选项:--${name}`);
if (value === undefined) {
value = argv[i + 1];
if (!value || value.startsWith('--')) throw new CliError(`选项 --${name} 缺少参数`);
i += 1;
}
options[name] = value;
}
return { positionals, ...options };
}
function readJson(file, fallback = {}) {
try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch { return fallback; }
}
function getConfigPort() {
const config = readJson(CONFIG_PATH);
const value = Number(process.env.PORT || config.port || 3001);
return Number.isInteger(value) && value >= 1 && value <= 65535 ? value : 3001;
}
function setConfigPort(port) {
const config = readJson(CONFIG_PATH);
config.port = port;
fs.writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2) + '\n');
}
function dbExists() { return fs.existsSync(DB_PATH); }
function openReadonlyDatabase(file = DB_PATH) {
if (!fs.existsSync(file)) throw new CliError(`数据库不存在:${file}`);
try { return new Database(file, { readonly: true, fileMustExist: true }); }
catch (error) { throw new CliError(`无法打开数据库:${error.message}`); }
}
function inspectDatabase(file = DB_PATH) {
const connection = openReadonlyDatabase(file);
try {
const quickCheck = connection.pragma('quick_check', { simple: true });
const foreignKeyErrors = connection.prepare('PRAGMA foreign_key_check').all();
const tables = connection.prepare("SELECT name FROM sqlite_master WHERE type = 'table' ORDER BY name").all().map((row) => row.name);
const requiredTables = ['users', 'site_settings', 'tickets', 'ticket_messages', 'ticket_events'];
const missingTables = requiredTables.filter((name) => !tables.includes(name));
return {
file,
size: fs.statSync(file).size,
userVersion: connection.pragma('user_version', { simple: true }),
quickCheck,
foreignKeyErrors,
missingTables,
valid: quickCheck === 'ok' && foreignKeyErrors.length === 0 && missingTables.length === 0,
};
} catch (error) {
throw new CliError(`数据库完整性检查失败:${error.message}`);
} finally { connection.close(); }
}
function formatBytes(bytes) {
if (!Number.isFinite(bytes)) return '未知';
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KiB`;
if (bytes < 1024 * 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MiB`;
return `${(bytes / 1024 / 1024 / 1024).toFixed(1)} GiB`;
}
function formatMtime(date) {
const pad = (value) => String(value).padStart(2, '0');
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
}
function print(value, args) {
if (args.json) console.log(JSON.stringify(value, null, 2));
else console.log(value);
}
function maskValue(key, value) {
if (value === undefined || value === null || value === '') return '(空)';
const sensitive = /(pass(word)?|secret|token|private|jwt|credential|api[_-]?key|client[_-]?secret|pin[_-]?(hash|salt)|terminal_pin)/i.test(key);
if (!sensitive) return String(value);
const text = String(value);
return text.length <= 4 ? '****' : `****${text.slice(-4)}`;
}
function getPidFromFile() {
if (!fs.existsSync(PID_PATH)) return null;
const raw = fs.readFileSync(PID_PATH, 'utf8').trim();
if (!/^\d+$/.test(raw)) return { invalid: true, raw };
return Number(raw);
}
function isRunning(pid) {
if (!Number.isInteger(pid) || pid <= 0) return false;
try { process.kill(pid, 0); return true; } catch { return false; }
}
function readProcessDetails(pid) {
if (!isRunning(pid)) return null;
let cwd = '';
let command = '';
if (process.platform === 'win32') {
try {
const script = "$p=Get-CimInstance Win32_Process -Filter 'ProcessId = ' + $args[0]; if ($p) { $p | Select-Object ProcessId,CommandLine | ConvertTo-Json -Compress }";
const output = execFileSync('powershell.exe', ['-NoProfile', '-Command', script, String(pid)], { encoding: 'utf8' }).trim();
const processInfo = output ? JSON.parse(output) : null;
command = processInfo && processInfo.CommandLine ? processInfo.CommandLine : '';
} catch {}
}
try {
if (process.platform !== 'win32') {
cwd = fs.realpathSync(`/proc/${pid}/cwd`);
command = fs.readFileSync(`/proc/${pid}/cmdline`).toString().replace(/\0/g, ' ').trim();
}
} catch {}
if (!command && process.platform !== 'win32') {
try { command = execFileSync('ps', ['-p', String(pid), '-o', 'command='], { encoding: 'utf8' }).trim(); } catch {}
}
const normalizedCommand = command.replace(/\\/g, '/');
const normalizedServer = SERVER_PATH.replace(/\\/g, '/');
const hasServerScript = normalizedCommand.includes(normalizedServer)
|| (cwd === PROJECT_ROOT && /(?:^|\s)server\.js(?:\s|$)/.test(command));
const owned = (process.platform === 'win32' ? hasServerScript : cwd === PROJECT_ROOT && hasServerScript)
&& !/cli\.js/.test(command);
return { pid, cwd, command, owned };
}
function listCandidatePids() {
try { return fs.readdirSync('/proc').filter((entry) => /^\d+$/.test(entry)).map(Number); }
catch {
try { return execFileSync('ps', ['-A', '-o', 'pid='], { encoding: 'utf8' }).split(/\s+/).filter(Boolean).map(Number); }
catch { return []; }
}
}
function findOwnedServers() {
return listCandidatePids().filter((pid) => pid !== process.pid).map(readProcessDetails).filter((details) => details && details.owned);
}
function getOwnedServer() {
const pidInfo = getPidFromFile();
if (pidInfo && pidInfo.invalid) throw new CliError(`PID 文件格式错误:${PID_PATH}`);
if (Number.isInteger(pidInfo)) {
if (!isRunning(pidInfo)) {
try { fs.unlinkSync(PID_PATH); } catch {}
} else {
const details = readProcessDetails(pidInfo);
if (!details || !details.owned) throw new CliError(`拒绝操作:${PID_PATH} 中的 PID ${pidInfo} 不属于本项目的 server.js`);
return details;
}
}
const servers = findOwnedServers();
if (servers.length > 1) throw new CliError(`发现多个本项目 server.js 进程(${servers.map((item) => item.pid).join(', ')}),请先人工处理`);
return servers[0] || null;
}
async function waitForExit(pid, timeoutMs = 10000) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
if (!isRunning(pid)) return true;
await new Promise((resolve) => setTimeout(resolve, 100));
}
return !isRunning(pid);
}
function probeHttp(port, timeoutMs = 3000) {
return new Promise((resolve) => {
const started = Date.now();
const request = http.get({ hostname: '127.0.0.1', port, path: '/', timeout: timeoutMs }, (response) => {
response.resume();
response.on('end', () => resolve({ reachable: true, healthy: response.statusCode >= 200 && response.statusCode < 400, statusCode: response.statusCode, latencyMs: Date.now() - started }));
});
request.on('timeout', () => request.destroy(new Error('请求超时')));
request.on('error', (error) => resolve({ reachable: false, healthy: false, error: error.message, latencyMs: Date.now() - started }));
});
}
function getTimeout(args) {
const value = args.timeout === undefined ? 3000 : Number(args.timeout);
if (!Number.isInteger(value) || value < 100 || value > 60000) throw new CliError('--timeout 必须是 100-60000 的整数(毫秒)');
return value;
}
async function waitForStartedServer(pid, port, timeoutMs = 12000) {
const deadline = Date.now() + timeoutMs;
let last = null;
while (Date.now() < deadline) {
const details = readProcessDetails(pid);
if (!details || !details.owned) throw new CliError(`新启动的 PID ${pid} 未能确认属于本项目 server.js`);
last = await probeHttp(port, 1000);
if (last.healthy) return last;
await new Promise((resolve) => setTimeout(resolve, 250));
}
throw new CliError(`服务器启动后健康检查失败:${last && last.error ? last.error : `HTTP ${last && last.statusCode ? last.statusCode : '无响应'}`}`);
}
async function stopServer({ quiet = false } = {}) {
const server = getOwnedServer();
if (!server) {
if (!quiet) console.log('服务器当前未运行。');
return { stopped: false, pid: null };
}
process.kill(server.pid, 'SIGTERM');
let exited = await waitForExit(server.pid, 10000);
if (!exited) {
const stillOwned = readProcessDetails(server.pid);
if (!stillOwned || !stillOwned.owned) throw new CliError(`PID ${server.pid} 在等待期间不再确认属于本项目,未强制结束`);
process.kill(server.pid, 'SIGKILL');
exited = await waitForExit(server.pid, 3000);
}
if (!exited) throw new CliError(`服务器未能退出(PID: ${server.pid}`);
if (fs.existsSync(PID_PATH) && getPidFromFile() === server.pid) fs.unlinkSync(PID_PATH);
if (!quiet) console.log(`服务器已停止(PID: ${server.pid})。`);
return { stopped: true, pid: server.pid };
}
async function startServer({ quiet = false } = {}) {
const existing = getOwnedServer();
if (existing) {
if (!quiet) console.log(`服务器已经运行(PID: ${existing.pid})。`);
return { started: false, alreadyRunning: true, pid: existing.pid, port: getConfigPort() };
}
const port = getConfigPort();
const child = spawn(process.execPath, [SERVER_PATH], { cwd: PROJECT_ROOT, stdio: 'ignore', detached: true, env: { ...process.env, PORT: String(port) } });
if (!child.pid) throw new CliError('无法启动服务器进程');
fs.writeFileSync(PID_PATH, `${child.pid}\n`, { mode: 0o644 });
try { await waitForStartedServer(child.pid, port); }
catch (error) {
if (isRunning(child.pid)) {
const details = readProcessDetails(child.pid);
if (details && details.owned) { process.kill(child.pid, 'SIGTERM'); await waitForExit(child.pid, 3000); }
}
if (fs.existsSync(PID_PATH) && getPidFromFile() === child.pid) fs.unlinkSync(PID_PATH);
throw error;
}
child.unref();
if (!quiet) console.log(`服务器已启动(端口: ${port}PID: ${child.pid})。`);
return { started: true, alreadyRunning: false, pid: child.pid, port };
}
function listBackupFiles() {
if (!fs.existsSync(BACKUP_DIR)) return [];
return fs.readdirSync(BACKUP_DIR).filter((file) => /^rainweb-.+\.db$/.test(file)).map((file) => path.join(BACKUP_DIR, file)).filter((file) => fs.statSync(file).isFile()).sort((a, b) => fs.statSync(b).mtimeMs - fs.statSync(a).mtimeMs);
}
function inspectBackup(file) {
try {
const result = inspectDatabase(file);
const integrityValid = result.quickCheck === 'ok' && result.foreignKeyErrors.length === 0;
return { file: path.basename(file), size: result.size, modifiedAt: formatMtime(fs.statSync(file).mtime), valid: integrityValid, schemaReady: result.missingTables.length === 0, quickCheck: result.quickCheck, foreignKeyErrors: result.foreignKeyErrors.length, missingTables: result.missingTables, userVersion: result.userVersion };
} catch (error) {
return { file: path.basename(file), size: fs.existsSync(file) ? fs.statSync(file).size : 0, modifiedAt: fs.existsSync(file) ? formatMtime(fs.statSync(file).mtime) : '', valid: false, schemaReady: false, error: error.message };
}
}
async function createBackup() {
if (!dbExists()) throw new CliError(`数据库不存在:${DB_PATH}`);
fs.mkdirSync(BACKUP_DIR, { recursive: true });
const now = new Date();
const stamp = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, '0')}${String(now.getDate()).padStart(2, '0')}-${String(now.getHours()).padStart(2, '0')}${String(now.getMinutes()).padStart(2, '0')}${String(now.getSeconds()).padStart(2, '0')}${String(now.getMilliseconds()).padStart(3, '0')}`;
const destination = path.join(BACKUP_DIR, `rainweb-${stamp}.db`);
const source = new Database(DB_PATH, { readonly: true, fileMustExist: true });
try { await source.backup(destination); } finally { source.close(); }
const check = inspectBackup(destination);
if (!check.valid) { try { fs.unlinkSync(destination); } catch {} throw new CliError(`备份已生成但完整性检查失败:${check.error || check.quickCheck}`); }
return { path: destination, ...check };
}
function resolveBackupFile(input) {
if (!input) throw new CliError('请指定备份文件名,例如 backup restore rainweb-YYYYMMDD-HHmmss.db');
const candidate = path.resolve(BACKUP_DIR, input);
if (path.dirname(candidate) !== path.resolve(BACKUP_DIR)) throw new CliError('备份文件必须位于 backups/ 目录内');
if (!fs.existsSync(candidate) || !fs.statSync(candidate).isFile()) throw new CliError(`备份文件不存在:${candidate}`);
return candidate;
}
function readSettingRows() {
if (!dbExists()) return [];
const connection = openReadonlyDatabase();
try { return connection.prepare('SELECT key, value FROM site_settings ORDER BY key').all(); } finally { connection.close(); }
}
function normalizeSwitch(value, label) {
const normalized = String(value || '').toLowerCase();
if (['1', 'on', 'yes', 'y', 'true'].includes(normalized)) return '1';
if (['0', 'off', 'no', 'n', 'false'].includes(normalized)) return '0';
throw new CliError(`${label} 必须是 on/off、yes/no 或 1/0`);
}
function ask(rl, question) { return new Promise((resolve) => rl.question(question, resolve)); }
async function cmdStatus(args) {
const port = getConfigPort();
const httpState = await probeHttp(port, getTimeout(args));
let server = null;
let serverError = null;
try { server = getOwnedServer(); } catch (error) { serverError = error.message; }
let database = null;
if (dbExists()) { try { database = inspectDatabase(); } catch (error) { database = { valid: false, error: error.message }; } }
const latest = listBackupFiles()[0];
const result = { version: PKG.version, node: process.version, platform: process.platform, port, server: { running: !!server, pid: server ? server.pid : null, error: serverError, http: httpState }, database: database || { valid: false, error: '数据库不存在' }, latestBackup: latest ? inspectBackup(latest) : null };
if (args.json) return print(result, args);
console.log(`RainWeb v${result.version}`);
console.log(`Node.js${result.node}`);
console.log(`平台:${result.platform}`);
console.log(`端口:${result.port}`);
console.log(`服务:${result.server.running ? `运行中(PID: ${result.server.pid}` : result.server.error || '未运行'}`);
console.log(`HTTP${httpState.healthy ? `正常(${httpState.statusCode}${httpState.latencyMs} ms` : '不可用'}`);
console.log(`数据库:${result.database.valid ? '完整性正常' : result.database.error || '检查失败'}`);
console.log(`最近备份:${result.latestBackup ? `${result.latestBackup.file}${formatBytes(result.latestBackup.size)}` : '无'}`);
}
async function cmdHealth(args) {
const port = getConfigPort();
const [httpState, database] = await Promise.all([probeHttp(port, getTimeout(args)), Promise.resolve().then(() => (dbExists() ? inspectDatabase() : { valid: false, error: '数据库不存在' }))]);
const result = { healthy: httpState.healthy && database.valid, port, http: httpState, database };
print(args.json ? result : `HTTP${httpState.healthy ? '正常' : `失败(${httpState.error || httpState.statusCode || '无响应'}`}\n数据库:${database.valid ? '完整性正常' : database.error || '检查失败'}\n总体:${result.healthy ? '健康' : '不健康'}`, args);
return result.healthy ? 0 : 2;
}
async function cmdDbCheck(args) {
const result = dbExists() ? inspectDatabase() : { valid: false, error: '数据库不存在', file: DB_PATH };
const foreignKeyCount = Array.isArray(result.foreignKeyErrors) ? result.foreignKeyErrors.length : result.foreignKeyErrors;
print(args.json ? result : `数据库:${result.file}\n版本:${result.userVersion ?? '未知'}\nquick_check${result.quickCheck || '未执行'}\n外键错误:${foreignKeyCount ?? '未知'}\n关键表缺失:${result.missingTables ? result.missingTables.join(', ') || '无' : '未知'}\n结果:${result.valid ? '通过' : result.error || '失败'}`, args);
return result.valid ? 0 : 2;
}
async function cmdPort(args) {
const value = args.positionals[1];
if (value === undefined) return print(args.json ? { port: getConfigPort() } : `当前端口:${getConfigPort()}`, args);
if (!/^\d+$/.test(value) || Number(value) < 1 || Number(value) > 65535) throw new CliError('端口必须是 1-65535 的整数');
setConfigPort(Number(value));
print(args.json ? { port: Number(value), message: '端口已保存,重启后生效' } : `端口已设置为 ${value},重启后生效。`, args);
}
async function cmdPassword(args) {
await warnIfServerRunning();
let newPass = args.positionals[1];
if (!newPass) {
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
newPass = await ask(rl, '请输入新的管理员密码(至少 6 位):');
const confirm = await ask(rl, '请再次输入密码:');
rl.close();
if (newPass !== confirm) throw new CliError('两次输入的密码不一致');
}
if (!newPass || newPass.length < 6) throw new CliError('密码至少需要 6 位');
const db = getWritableDb();
db.getDb();
const admin = db.get("SELECT id FROM users WHERE role = 'admin'");
if (!admin) throw new CliError('未找到管理员账号');
db.run('UPDATE users SET password = ? WHERE id = ?', [bcrypt.hashSync(newPass, 10), admin.id]);
print(args.json ? { message: '管理员密码已更新' } : '管理员密码已更新。', args);
}
async function cmdCaptcha(args) {
await warnIfServerRunning();
const db = getWritableDb();
db.getDb();
const scopes = ['login', 'register', 'forum'];
const currentType = db.getSetting('captcha_type') || 'builtin';
const allowedTypes = new Set(['none', 'builtin', 'recaptcha', 'turnstile', 'both']);
const typeArg = args.type;
if (typeArg && !allowedTypes.has(typeArg)) throw new CliError('--type 必须是 none、builtin、recaptcha、turnstile 或 both');
const hasOptions = typeArg || scopes.some((scope) => args[scope] !== undefined) || args.failed !== undefined || args.threshold !== undefined;
const updates = {};
if (hasOptions) {
updates.captcha_type = typeArg || currentType;
for (const scope of scopes) if (args[scope] !== undefined) updates[`captcha_${scope}`] = normalizeSwitch(args[scope], `--${scope}`);
if (args.failed !== undefined) updates.captcha_failed = normalizeSwitch(args.failed, '--failed');
if (args.threshold !== undefined) {
if (!/^\d+$/.test(String(args.threshold)) || Number(args.threshold) < 1) throw new CliError('--threshold 必须是正整数');
updates.captcha_failed_threshold = String(Number(args.threshold));
}
} else {
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
console.log('验证码配置(直接回车保留当前值)');
const type = await ask(rl, `类型 none/builtin/recaptcha/turnstile/both [${currentType}]`);
const selectedType = type || currentType;
if (!allowedTypes.has(selectedType)) { rl.close(); throw new CliError('验证码类型不合法'); }
updates.captcha_type = selectedType;
for (const scope of scopes) {
const current = db.getSetting(`captcha_${scope}`) || '0';
const answer = await ask(rl, `启用 ${scope} 验证码?(y/n) [${current === '1' ? 'y' : 'n'}]`);
updates[`captcha_${scope}`] = answer ? normalizeSwitch(answer, scope) : current;
}
const failed = await ask(rl, `失败尝试后启用验证码?(y/n) [${db.getSetting('captcha_failed') === '1' ? 'y' : 'n'}]`);
updates.captcha_failed = failed ? normalizeSwitch(failed, 'failed') : (db.getSetting('captcha_failed') || '0');
if (updates.captcha_failed === '1') {
const threshold = await ask(rl, `失败次数阈值 [${db.getSetting('captcha_failed_threshold') || '5'}]`);
if (threshold) {
if (!/^\d+$/.test(threshold) || Number(threshold) < 1) { rl.close(); throw new CliError('失败次数阈值必须是正整数'); }
updates.captcha_failed_threshold = threshold;
}
}
rl.close();
}
for (const [key, value] of Object.entries(updates)) db.setSetting(key, value);
print(args.json ? { message: '验证码规则已更新', settings: updates } : '验证码规则已更新。', args);
}
async function cmdConfig(args) {
const settings = Object.fromEntries(readSettingRows().map((row) => [row.key, maskValue(row.key, row.value)]));
const envConfig = readJson(CONFIG_PATH);
const safeEnv = Object.fromEntries(Object.entries(envConfig).map(([key, value]) => [key, maskValue(key, value)]));
const result = { settings, env: safeEnv, listen_port: getConfigPort() };
if (args.json) return print(result, args);
console.log('=== RainWeb 配置 ===');
for (const [key, value] of Object.entries(settings)) console.log(` ${key}${value}`);
for (const [key, value] of Object.entries(safeEnv)) console.log(` env.${key}${value}`);
console.log(` listen_port${result.listen_port}`);
}
async function cmdBackup(args) {
const action = args.positionals[1] || 'create';
if (action === 'create') {
const result = await createBackup();
print(args.json ? result : `备份成功:${result.path}\n完整性:通过(${formatBytes(result.size)}`, args);
return;
}
if (action === 'list') {
const result = listBackupFiles().map(inspectBackup);
print(args.json ? result : (result.length ? result.map((item) => `${item.valid ? '正常' : '损坏'}${item.valid && !item.schemaReady ? '(待迁移)' : ''} ${item.file} ${formatBytes(item.size)} ${item.modifiedAt}`).join('\n') : '暂无备份。'), args);
return;
}
if (action === 'prune') {
if (args.keep === undefined) throw new CliError('prune 必须明确指定 --keep <数量>;未指定时不会删除任何文件');
if (!/^\d+$/.test(String(args.keep)) || Number(args.keep) < 1) throw new CliError('--keep 必须是正整数');
const keep = Number(args.keep);
const remove = listBackupFiles().slice(keep);
const result = { keep, candidates: remove.map((file) => path.basename(file)), deleted: [] };
if (args.yes) for (const file of remove) { fs.unlinkSync(file); result.deleted.push(path.basename(file)); }
print(args.json ? result : (args.yes ? `已删除 ${result.deleted.length} 个旧备份,保留最新 ${keep} 个。` : `预览:将删除 ${remove.length} 个旧备份;加 --yes 才会执行。\n${result.candidates.join('\n')}`), args);
return;
}
if (action === 'restore') {
if (!args.yes) throw new CliError('restore 会覆盖当前数据库,必须显式使用 --yes;且服务必须已停止');
if (getOwnedServer()) throw new CliError('restore 必须先停止 RainWeb 服务');
const httpState = await probeHttp(getConfigPort(), getTimeout(args));
if (httpState.reachable) throw new CliError('检测到 HTTP 服务仍在运行,拒绝 restore');
const sourcePath = resolveBackupFile(args.positionals[2]);
const check = inspectBackup(sourcePath);
if (!check.valid) throw new CliError(`备份完整性检查失败,拒绝恢复:${check.error || check.quickCheck}`);
if (!dbExists()) fs.mkdirSync(path.dirname(DB_PATH), { recursive: true });
const tempPath = `${DB_PATH}.restore-${process.pid}-${Date.now()}`;
const source = new Database(sourcePath, { readonly: true, fileMustExist: true });
try { await source.backup(tempPath); } finally { source.close(); }
let oldPath = null;
try {
if (fs.existsSync(DB_PATH)) { oldPath = `${DB_PATH}.before-restore-${Date.now()}`; fs.renameSync(DB_PATH, oldPath); }
fs.renameSync(tempPath, DB_PATH);
} catch (error) {
try { if (fs.existsSync(tempPath)) fs.unlinkSync(tempPath); } catch {}
try { if (oldPath && fs.existsSync(oldPath) && !fs.existsSync(DB_PATH)) fs.renameSync(oldPath, DB_PATH); } catch {}
throw new CliError(`恢复失败,已尝试保留原数据库:${error.message}`);
}
print(args.json ? { message: '数据库已恢复', source: sourcePath, previousDatabase: oldPath } : `数据库已恢复:${sourcePath}\n原数据库保留为:${oldPath || '无'}。`, args);
return;
}
throw new CliError(`未知 backup 子命令:${action}`);
}
function runCommand(command, commandArgs, label) {
console.log(`${label}${command} ${commandArgs.join(' ')}`);
try { execFileSync(command, commandArgs, { cwd: PROJECT_ROOT, stdio: 'inherit' }); }
catch (error) { throw new CliError(`${label}失败(退出码 ${error.status ?? '未知'}`); }
}
function getNpmCommand() {
return process.platform === 'win32' ? 'npm.cmd' : 'npm';
}
async function cmdUpgrade(args) {
if (args.json) throw new CliError('upgrade 不支持 --json,请使用普通输出查看升级过程');
if (!fs.existsSync(path.join(PROJECT_ROOT, '.git'))) throw new CliError('当前目录不是 git 仓库,无法升级');
let status;
try { status = execFileSync('git', ['status', '--porcelain'], { cwd: PROJECT_ROOT, encoding: 'utf8' }); }
catch (error) { throw new CliError(`无法读取 git 工作区:${error.message}`); }
if (status.trim()) throw new CliError('git 工作区不干净,已停止升级;请先处理本地修改或未跟踪文件');
const backup = await createBackup();
console.log(`升级前备份完成:${backup.file}`);
const wasRunning = !!getOwnedServer();
if (wasRunning) await stopServer();
try {
runCommand('git', ['pull', '--ff-only'], 'git 快进更新');
runCommand(getNpmCommand(), ['install'], '依赖安装');
runCommand(getNpmCommand(), ['run', 'build'], '前端构建');
if (wasRunning) {
await startServer();
const health = await cmdHealth({ json: false });
if (health !== 0) throw new CliError('升级后健康检查未通过');
}
} catch (error) {
if (wasRunning) {
let running = false;
try { running = !!getOwnedServer(); } catch {}
if (!running) {
try { await startServer(); } catch (restartError) { console.error(`升级失败后恢复启动也失败:${restartError.message}`); }
}
}
throw error instanceof CliError ? error : new CliError(error.message);
}
console.log('升级完成。');
}
async function cmdPortlessStart() { await startServer(); }
async function cmdPortlessStop() { await stopServer(); }
async function cmdPortlessRestart() { await stopServer(); await startServer(); }
async function warnIfServerRunning() {
let owned = null;
try { owned = getOwnedServer(); } catch {}
if (owned) console.log('警告:server 正在运行,并发写库可能等待;建议先停止服务。');
}
async function dispatch(args) {
const command = args.positionals[0] || 'help';
switch (command) {
case 'status': return cmdStatus(args);
case 'health': return cmdHealth(args);
case 'db-check': return cmdDbCheck(args);
case 'start': return cmdPortlessStart(args);
case 'stop': return cmdPortlessStop(args);
case 'restart': return cmdPortlessRestart(args);
case 'port': return cmdPort(args);
case 'password': return cmdPassword(args);
case 'captcha': return cmdCaptcha(args);
case 'config': return cmdConfig(args);
case 'backup': return cmdBackup(args);
case 'upgrade': return cmdUpgrade(args);
case 'help': return console.log(HELP);
default: throw new CliError(`未知命令:${command}\n\n${HELP}`);
}
}
async function main() {
const args = parseArgs(process.argv.slice(2));
const result = await dispatch(args);
if (Number.isInteger(result)) process.exitCode = result;
}
main().catch((error) => {
const exitCode = error.exitCode || 1;
if (process.argv.includes('--json')) console.error(JSON.stringify({ error: error.message, exitCode }));
else console.error(`错误:${error.message}`);
process.exitCode = exitCode;
});