优化 RainWeb CLI 运维命令

This commit is contained in:
2026-09-05 21:01:32 +08:00
parent af79e5864f
commit 799403bd9e
3 changed files with 609 additions and 292 deletions
+5 -2
View File
@@ -9,10 +9,13 @@ npm start # 生产:node server.jsserve 构建产物 + /api
npm run dev # 开发:vite dev server5173/api 与 /uploads 代理到 3101
npm run build # 构建 React 前端 → public/dist/index.html + admin.html + assets
npm run preview # 预览构建产物
node cli.js <cmd> # status | start | stop | restart | port [N] | password [pwd] | config | captcha | upgrade
node cli.js <cmd> # status | health | db-check | start | stop | restart | port [N] | password | captcha | config | backup | upgrade
```
- 端口:`.env.json`gitignored`{"port": N}` 或环境变量 `PORT`,默认 3001vite 代理目标硬编码 3101vite.config.js),后端端口改了就同步改。
- CLI`status``health``db-check``config``backup list` 支持 `--json``health`/`db-check` 检查失败返回非 0。`backup prune` 必须明确 `--keep N`,默认只预览,只有 `--yes` 才删除;`backup restore <file>` 必须服务停止并带 `--yes`,不会默认覆盖。
- CLI 的 `start/stop/restart` 只操作经过 cwd 与命令行双重校验、确认属于本项目 `server.js` 的 PID,停止会先优雅等待。`upgrade` 仅供自托管机器本地执行:升级前备份,要求 git 工作区干净,执行 `git pull --ff-only``npm install``npm run build`,服务原本运行时重启并健康检查;失败返回非 0。
- CLI 密码命令默认交互输入;`config` 对密码、secret、token、JWT、私钥和 API key 等敏感配置统一脱敏。验证码参数需使用受支持的类型和值。
- 验证方式:后端改动启动后 `curl` 接口;前端改动 `npm run dev` 热更新,或 `npm run build` 后刷新验证(需强刷一次,见下)。
- **部署后浏览器必须拿新 HTML**:`serveIndex` 响应带 `Cache-Control: no-store`;构建产物 assets 文件名带 hash,经 `/assets` 挂载长期缓存(immutable)。若用户白屏且 console 报 "MIME type text/html",先查是否 `npm run build` 缺失/旧产物。
- 首次启动自动建库播种:管理员 `admin / admin123`,并写入示例博文/论坛帖子;`/setup.html` 初始化向导(`POST /api/setup/complete``setup_complete` 门禁,非 adminOnly——新装无 token)。
@@ -52,7 +55,7 @@ node cli.js <cmd> # status | start | stop | restart | port [N] | password [pw
## 版本与更新
- 版本号在根目录 `VERSION` 文件,与 `package.json` 的 version 需同步。
- **Web 更新接口已移除**(P0 删除 `/api/update/check``/api/update/run`,无 RCE 面)。升级只走 `cli.js upgrade`git pull(本地 origingit.rainnya.asia 镜像)+ npm install + 重启
- **Web 更新接口已移除**(P0 删除 `/api/update/check``/api/update/run`,无 RCE 面)。升级只走自托管机器本地的 `cli.js upgrade`升级前备份、检查干净工作区、`git pull --ff-only`(本地 origingit.rainnya.asia 镜像)+ npm install + npm run build + 服务重启/健康检查
- 上传附件在 `uploads/`gitignored,头像在 `uploads/avatars/`,白名单扩展名),壁纸在 `public/wallpaper/`(仅 .gitkeep 入库)。
## 约定
+21 -11
View File
@@ -106,15 +106,23 @@ npm run cli -- <command>
| 命令 | 说明 |
|------|------|
| `status` | 查看运行状态 |
| `start` | 启动服务器 |
| `stop` | 停止服务器 |
| `restart` | 重启服务器 |
| `status [--json]` | 查看版本、服务、数据库和备份状态 |
| `health [--json]` | HTTP 健康检查和 SQLite 完整性检查;不健康时返回非 0 |
| `db-check [--json]` | 只读检查数据库完整性和关键表 |
| `start` | 校验 PID 后启动服务器,已运行时不会重复启动 |
| `stop` | 只停止确认属于本项目的 `server.js`,优雅等待退出 |
| `restart` | 优雅停止并等待后启动服务器 |
| `port [number]` | 查看/修改端口 |
| `password [new-pass]` | 修改管理员密码 |
| `captcha` | 交互式配置验证码 |
| `config` | 查看所有配置 |
| `upgrade` | 一键升级(git pull + npm install + 重启) |
| `password` | 交互式修改管理员密码(兼容旧的末尾参数写法) |
| `captcha` | 交互式配置验证码;也支持 `--type``--login``--register``--forum``--failed``--threshold` |
| `config [--json]` | 查看配置,敏感值统一脱敏 |
| `backup` | 创建数据库备份并立即做完整性检查 |
| `backup list [--json]` | 列出备份并检查 SQLite 完整性 |
| `backup prune --keep N [--yes]` | 预览清理旧备份;只有显式 `--yes` 才删除 |
| `backup restore <file> --yes` | 服务停止后恢复指定备份;显式确认才覆盖,旧库会保留 |
| `upgrade` | 升级前备份,检查干净工作区,`git pull --ff-only`、安装、构建并在服务原本运行时重启验活 |
通用选项:`--yes` 跳过确认,`--json` 输出 JSONHTTP 检查可用 `--timeout 100-60000` 设置毫秒超时。密码默认交互输入,不建议在命令行中传递明文密码。
## 技术栈
@@ -214,16 +222,18 @@ npm start
## 升级
```bash
# 方法1: 一键升级(推荐)
# 方法1一键升级(推荐;仅限自托管机器本地执行
node cli.js upgrade
# 方法2: 手动
git pull
# 方法2手动
git pull --ff-only
npm install
npm run build
node cli.js restart
```
`upgrade` 要求 git 工作区干净,只执行 fast-forward 更新;失败会返回非 0,不会假报成功。`backup prune` 默认只预览不删除,`backup restore` 必须同时满足服务已停止和 `--yes`
## 版本
当前版本记录在项目根目录的 `VERSION` 文件中,导航栏标题右侧会显示当前版本号。
+583 -279
View File
@@ -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');
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;
}
// 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'})`);
if (!token.startsWith('--')) {
positionals.push(token);
continue;
}
} catch {}
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)');
}
}
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 v13src.backup(destPath) 接受目标文件路径(string),返回 Promise
const Database = require('better-sqlite3');
const src = new Database(srcPath, { readonly: true });
function readProcessDetails(pid) {
if (!isRunning(pid)) return null;
let cwd = '';
let command = '';
if (process.platform === 'win32') {
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 再执行');
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 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;
});