#!/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; });