优化 RainWeb CLI 运维命令
This commit is contained in:
@@ -1,328 +1,632 @@
|
||||
#!/usr/bin/env node
|
||||
const { execSync, spawn } = require('child_process');
|
||||
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 CONFIG_PATH = path.join(__dirname, '.env.json');
|
||||
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');
|
||||
const db = require('./db');
|
||||
|
||||
// 只读命令不加载 db.js,避免检查状态时意外触发数据库迁移或写入。
|
||||
let writableDb = null;
|
||||
function getWritableDb() {
|
||||
if (!writableDb) writableDb = require('./db');
|
||||
return writableDb;
|
||||
}
|
||||
|
||||
const HELP = `
|
||||
RainWeb CLI v${PKG.version}
|
||||
Usage: node cli.js <command> [options]
|
||||
用法:node cli.js <命令> [参数] [选项]
|
||||
|
||||
Commands:
|
||||
status Show server and system status
|
||||
start Start the server
|
||||
restart Restart the server
|
||||
stop Stop the server
|
||||
port [number] Show or change listen port (default: 3001)
|
||||
password [new-pass] Change admin password (leave empty for prompt)
|
||||
captcha Interactive captcha rule configuration
|
||||
config Show all current settings
|
||||
backup Backup database to backups/ (readonly, safe to run while running)
|
||||
upgrade Git pull + npm install + restart (one-click upgrade)
|
||||
help Show this help
|
||||
命令:
|
||||
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 显示帮助
|
||||
|
||||
Examples:
|
||||
node cli.js status
|
||||
node cli.js port 8080
|
||||
node cli.js password MyNewP@ss123
|
||||
node cli.js captcha
|
||||
node cli.js backup
|
||||
node cli.js upgrade
|
||||
通用选项:
|
||||
--yes 跳过需要确认的操作
|
||||
--json 以 JSON 输出(status/health/config/backup list 等支持)
|
||||
--timeout <毫秒> 设置 HTTP 检查超时(100-60000)
|
||||
|
||||
说明:
|
||||
password 默认交互输入;为兼容旧脚本仍接受末尾明文参数,但不在帮助示例中推荐。
|
||||
upgrade 仅用于自托管机器上的本地操作,不会修改云端或远程服务。
|
||||
`;
|
||||
|
||||
async function main() {
|
||||
const cmd = process.argv[2] || 'help';
|
||||
|
||||
switch (cmd) {
|
||||
case 'status': return cmdStatus();
|
||||
case 'start': return cmdStart();
|
||||
case 'restart': return cmdRestart();
|
||||
case 'stop': return cmdStop();
|
||||
case 'port': return cmdPort();
|
||||
case 'password': return cmdPassword();
|
||||
case 'captcha': return cmdCaptcha();
|
||||
case 'config': return cmdConfig();
|
||||
case 'backup': return cmdBackup();
|
||||
case 'upgrade': return cmdUpgrade();
|
||||
case 'help':
|
||||
default:
|
||||
console.log(HELP);
|
||||
class CliError extends Error {
|
||||
constructor(message, code = 1) {
|
||||
super(message);
|
||||
this.name = 'CliError';
|
||||
this.exitCode = code;
|
||||
}
|
||||
}
|
||||
|
||||
// === Status ===
|
||||
async function cmdStatus() {
|
||||
console.log(`RainWeb v${PKG.version}`);
|
||||
console.log(`Node.js: ${process.version}`);
|
||||
console.log(`Platform: ${process.platform}`);
|
||||
console.log(`Data DB: ${fs.existsSync(path.join(__dirname, 'data', 'rainweb.db')) ? fs.statSync(path.join(__dirname, 'data', 'rainweb.db')).size + ' bytes' : 'NOT FOUND'}`);
|
||||
|
||||
// 最近一次备份时间
|
||||
const latestBackup = getLatestBackup();
|
||||
console.log('Last backup: ' + (latestBackup ? formatMtime(latestBackup.mtime) + ' (' + latestBackup.file + ')' : '从未备份'));
|
||||
|
||||
// Check if server is running
|
||||
try {
|
||||
await httpGet('http://localhost:' + (getConfigPort()));
|
||||
console.log('Server: RUNNING');
|
||||
} catch {
|
||||
console.log('Server: STOPPED');
|
||||
}
|
||||
|
||||
// Show admin info
|
||||
try {
|
||||
await db.getDb();
|
||||
const admin = db.get("SELECT id, username, email, email_verified FROM users WHERE role = 'admin'");
|
||||
if (admin) {
|
||||
console.log(`Admin: ${admin.username} (email: ${admin.email || 'not set'}, verified: ${admin.email_verified ? 'yes' : 'no'})`);
|
||||
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;
|
||||
}
|
||||
} catch {}
|
||||
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; }
|
||||
}
|
||||
|
||||
// === Port ===
|
||||
function getConfigPort() {
|
||||
try {
|
||||
const config = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf8'));
|
||||
return config.port || 3001;
|
||||
} catch { return 3001; }
|
||||
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) {
|
||||
let config = {};
|
||||
try { config = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf8')); } catch {}
|
||||
const config = readJson(CONFIG_PATH);
|
||||
config.port = port;
|
||||
fs.writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2));
|
||||
fs.writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2) + '\n');
|
||||
}
|
||||
|
||||
function cmdPort() {
|
||||
const arg = process.argv[3];
|
||||
if (arg) {
|
||||
const port = parseInt(arg);
|
||||
if (isNaN(port) || port < 1 || port > 65535) {
|
||||
console.error('Invalid port number. Use 1-65535.');
|
||||
process.exit(1);
|
||||
}
|
||||
setConfigPort(port);
|
||||
console.log(`Port set to ${port}. Restart to apply.`);
|
||||
} else {
|
||||
console.log(`Current port: ${getConfigPort()}`);
|
||||
}
|
||||
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}`); }
|
||||
}
|
||||
|
||||
// === Password ===
|
||||
async function cmdPassword() {
|
||||
await warnIfServerRunning();
|
||||
let newPass = process.argv[3];
|
||||
if (!newPass) {
|
||||
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
||||
newPass = await new Promise(resolve => {
|
||||
rl.question('New admin password (min 6 chars): ', resolve);
|
||||
});
|
||||
rl.close();
|
||||
}
|
||||
if (!newPass || newPass.length < 6) {
|
||||
console.error('Password must be at least 6 characters.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
await db.getDb();
|
||||
const admin = db.get("SELECT id FROM users WHERE role = 'admin'");
|
||||
if (!admin) { console.error('No admin user found.'); process.exit(1); }
|
||||
|
||||
const hash = bcrypt.hashSync(newPass, 10);
|
||||
db.run('UPDATE users SET password = ? WHERE id = ?', [hash, admin.id]);
|
||||
console.log('Admin password updated successfully.');
|
||||
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(); }
|
||||
}
|
||||
|
||||
// === Captcha ===
|
||||
async function cmdCaptcha() {
|
||||
await warnIfServerRunning();
|
||||
await db.getDb();
|
||||
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
||||
const q = (q) => new Promise(resolve => rl.question(q, resolve));
|
||||
|
||||
console.log('=== Captcha Rule Configuration ===\n');
|
||||
console.log('Current settings:');
|
||||
['login','register','forum','failed'].forEach(k => {
|
||||
const v = db.get("SELECT value FROM site_settings WHERE key = 'captcha_" + k + "'");
|
||||
console.log(` ${k}: ${v ? v.value : '0'}`);
|
||||
});
|
||||
const type = db.get("SELECT value FROM site_settings WHERE key = 'captcha_type'");
|
||||
console.log(` type: ${type ? type.value : 'builtin'}\n`);
|
||||
|
||||
const typeAns = await q('Captcha type (builtin/recaptcha/both) [' + (type ? type.value : 'builtin') + ']: ');
|
||||
if (typeAns) db.run("UPDATE site_settings SET value=? WHERE key='captcha_type'", [typeAns]);
|
||||
|
||||
for (const scope of ['login', 'register', 'forum']) {
|
||||
const current = db.get("SELECT value FROM site_settings WHERE key='captcha_" + scope + "'");
|
||||
const ans = await q(`Enable captcha for ${scope}? (y/n) [${current && current.value === '1' ? 'y' : 'n'}]: `);
|
||||
db.run("UPDATE site_settings SET value=? WHERE key='captcha_" + scope + "'", [ans.toLowerCase() === 'y' ? '1' : '0']);
|
||||
}
|
||||
|
||||
const failAns = await q('Enable captcha after failed attempts? (y/n): ');
|
||||
db.run("UPDATE site_settings SET value=? WHERE key='captcha_failed'", [failAns.toLowerCase() === 'y' ? '1' : '0']);
|
||||
if (failAns.toLowerCase() === 'y') {
|
||||
const threshold = await q('Failed attempts threshold (default 5): ');
|
||||
if (threshold) db.run("UPDATE site_settings SET value=? WHERE key='captcha_failed_threshold'", [threshold]);
|
||||
}
|
||||
|
||||
rl.close();
|
||||
console.log('\nCaptcha rules updated.');
|
||||
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`;
|
||||
}
|
||||
|
||||
// === Config ===
|
||||
async function cmdConfig() {
|
||||
await db.getDb();
|
||||
const rows = db.all("SELECT key, value FROM site_settings ORDER BY key");
|
||||
console.log('=== Site Configuration ===\n');
|
||||
const secrets = ['smtp_pass', 'recaptcha_secret_key'];
|
||||
rows.forEach(r => {
|
||||
let val = r.value;
|
||||
if (secrets.includes(r.key) && val) val = '****' + val.slice(-4);
|
||||
console.log(` ${r.key}: ${val || '(empty)'}`);
|
||||
});
|
||||
console.log(`\n listen_port: ${getConfigPort()}`);
|
||||
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())}`;
|
||||
}
|
||||
|
||||
// === Start / Stop / Restart ===
|
||||
function findPidFile() { return path.join(__dirname, 'server.pid'); }
|
||||
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; }
|
||||
}
|
||||
|
||||
async function cmdStop() {
|
||||
const pidFile = findPidFile();
|
||||
if (fs.existsSync(pidFile)) {
|
||||
const pid = parseInt(fs.readFileSync(pidFile, 'utf8'));
|
||||
if (isRunning(pid)) {
|
||||
try { process.kill(pid); console.log('Server stopped (PID: ' + pid + ')'); } catch { console.log('Could not stop process.'); }
|
||||
} else { console.log('Server not running.'); }
|
||||
fs.unlinkSync(pidFile);
|
||||
} else {
|
||||
// Try to find node process
|
||||
console.log('No PID file found. Try: taskkill /F /IM node.exe (Windows) or pkill node (Linux/Mac)');
|
||||
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 {}
|
||||
}
|
||||
}
|
||||
|
||||
async function cmdStart() {
|
||||
const port = getConfigPort();
|
||||
const proc = spawn('node', ['server.js'], {
|
||||
cwd: __dirname,
|
||||
stdio: 'inherit',
|
||||
env: { ...process.env, PORT: String(port) },
|
||||
detached: true,
|
||||
});
|
||||
proc.unref();
|
||||
fs.writeFileSync(findPidFile(), String(proc.pid));
|
||||
console.log(`Server starting on port ${port} (PID: ${proc.pid})`);
|
||||
}
|
||||
|
||||
async function cmdRestart() {
|
||||
await cmdStop();
|
||||
await new Promise(r => setTimeout(r, 1000));
|
||||
await cmdStart();
|
||||
}
|
||||
|
||||
// === Backup ===
|
||||
async function cmdBackup() {
|
||||
const srcPath = path.join(__dirname, 'data', 'rainweb.db');
|
||||
if (!fs.existsSync(srcPath)) {
|
||||
console.error('数据库不存在:' + srcPath + '(首次启动 server 后才会创建)');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const backupDir = path.join(__dirname, 'backups');
|
||||
fs.mkdirSync(backupDir, { recursive: true });
|
||||
|
||||
const now = new Date();
|
||||
const destPath = path.join(backupDir,
|
||||
`rainweb-${now.getFullYear()}${pad2(now.getMonth() + 1)}${pad2(now.getDate())}-${pad2(now.getHours())}${pad2(now.getMinutes())}${pad2(now.getSeconds())}.db`);
|
||||
|
||||
// 只读打开源库,不写数据,安全(server 运行中也可执行)
|
||||
// better-sqlite3 v13:src.backup(destPath) 接受目标文件路径(string),返回 Promise
|
||||
const Database = require('better-sqlite3');
|
||||
const src = new Database(srcPath, { readonly: true });
|
||||
try {
|
||||
await src.backup(destPath);
|
||||
} finally {
|
||||
src.close();
|
||||
}
|
||||
|
||||
const size = fs.statSync(destPath).size;
|
||||
console.log(`备份成功:${destPath}(${size} bytes)`);
|
||||
}
|
||||
|
||||
// 读取 backups/ 目录下最新的备份文件(按 mtime),无备份返回 null
|
||||
function getLatestBackup() {
|
||||
const backupDir = path.join(__dirname, 'backups');
|
||||
if (!fs.existsSync(backupDir)) return null;
|
||||
let latest = null;
|
||||
for (const f of fs.readdirSync(backupDir)) {
|
||||
if (!f.startsWith('rainweb-') || !f.endsWith('.db')) continue;
|
||||
const filePath = path.join(backupDir, f);
|
||||
const mtime = fs.statSync(filePath).mtime;
|
||||
if (!latest || mtime > latest.mtime) latest = { file: f, mtime };
|
||||
}
|
||||
return latest;
|
||||
}
|
||||
|
||||
function pad2(n) { return String(n).padStart(2, '0'); }
|
||||
|
||||
function formatMtime(date) {
|
||||
return `${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())} ${pad2(date.getHours())}:${pad2(date.getMinutes())}`;
|
||||
}
|
||||
|
||||
// === Upgrade ===
|
||||
async function cmdUpgrade() {
|
||||
console.log('=== RainWeb Upgrade ===\n');
|
||||
|
||||
if (!fs.existsSync(path.join(__dirname, '.git'))) {
|
||||
console.error('不是 git 仓库,无法 upgrade。请先 git clone 安装。');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log('1. Pulling latest code via git...');
|
||||
try {
|
||||
execSync('git pull', { cwd: __dirname, stdio: 'inherit' });
|
||||
} catch {
|
||||
console.error('Git pull failed. Check for conflicts.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log('\n2. Installing dependencies...');
|
||||
try {
|
||||
execSync('npm install', { cwd: __dirname, stdio: 'inherit' });
|
||||
} catch {
|
||||
console.error('npm install failed.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log('\n3. Restarting server...');
|
||||
await cmdRestart();
|
||||
console.log('\n=== Upgrade complete! ===');
|
||||
}
|
||||
|
||||
// === Helper ===
|
||||
// 写命令(password/captcha)在连接库前探测 server 是否运行,仅提示不阻止执行
|
||||
async function warnIfServerRunning() {
|
||||
try {
|
||||
await httpGet('http://localhost:' + (getConfigPort()));
|
||||
console.log('警告:server 正在运行,并发写库可能失败或等待,建议先停止 server 再执行');
|
||||
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 httpGet(url) {
|
||||
return new Promise((resolve, reject) => {
|
||||
http.get(url, res => { let d = ''; res.on('data', c => d += c); res.on('end', () => resolve(d)); })
|
||||
.on('error', reject);
|
||||
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 }));
|
||||
});
|
||||
}
|
||||
|
||||
main().catch(console.error);
|
||||
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;
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user