633 lines
31 KiB
JavaScript
633 lines
31 KiB
JavaScript
#!/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;
|
||
});
|