9router/cli/cli.js
2026-05-16 11:39:39 +07:00

809 lines
27 KiB
JavaScript
Executable file

#!/usr/bin/env node
const { spawn, exec, execSync } = require("child_process");
const path = require("path");
const fs = require("fs");
const https = require("https");
const os = require("os");
// Native spinner - no external dependency
function createSpinner(text) {
const frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
let i = 0;
let interval = null;
let currentText = text;
return {
start() {
if (process.stdout.isTTY) {
process.stdout.write(`\r${frames[0]} ${currentText}`);
interval = setInterval(() => {
process.stdout.write(`\r${frames[i++ % frames.length]} ${currentText}`);
}, 80);
}
return this;
},
stop() {
if (interval) {
clearInterval(interval);
interval = null;
}
if (process.stdout.isTTY) {
process.stdout.write("\r\x1b[K");
}
},
succeed(msg) {
this.stop();
console.log(`${msg}`);
},
fail(msg) {
this.stop();
console.log(`${msg}`);
}
};
}
const pkg = require("./package.json");
const { ensureSqliteRuntime, buildEnvWithRuntime } = require("./hooks/sqliteRuntime");
const { ensureTrayRuntime } = require("./hooks/trayRuntime");
const args = process.argv.slice(2);
// Self-heal SQLite runtime deps (sql.js + better-sqlite3) into ~/.9router/runtime
// so the server can resolve them via NODE_PATH. Best-effort — sql.js is required,
// better-sqlite3 is optional. Logs to stderr only on failure.
try { ensureSqliteRuntime({ silent: true }); } catch {}
// Self-heal tray runtime (systray for macOS/Linux only). Windows skipped.
try { ensureTrayRuntime({ silent: true }); } catch {}
// Configuration constants
const APP_NAME = pkg.name; // Use from package.json
const INSTALL_CMD_LATEST = `npm i -g ${APP_NAME}@latest --prefer-online`;
const DEFAULT_PORT = 20128;
const DEFAULT_HOST = "0.0.0.0";
const MAX_PORT_ATTEMPTS = 10;
// Identifiers for killAllAppProcesses - only kill 9router specifically
const PROCESS_IDENTIFIERS = [
'9router' // Only package name - avoid killing other apps
];
// Parse arguments
let port = DEFAULT_PORT;
let host = DEFAULT_HOST;
let noBrowser = false;
let skipUpdate = false;
let showLog = false;
let trayMode = false;
for (let i = 0; i < args.length; i++) {
if (args[i] === "--port" || args[i] === "-p") {
port = parseInt(args[i + 1], 10) || DEFAULT_PORT;
i++;
} else if (args[i] === "--host" || args[i] === "-H") {
host = args[i + 1] || DEFAULT_HOST;
i++;
} else if (args[i] === "--no-browser" || args[i] === "-n") {
noBrowser = true;
} else if (args[i] === "--log" || args[i] === "-l") {
showLog = true;
} else if (args[i] === "--skip-update") {
skipUpdate = true;
} else if (args[i] === "--tray" || args[i] === "-t") {
trayMode = true;
process.env.TRAY_MODE = "1";
} else if (args[i] === "--help" || args[i] === "-h") {
console.log(`
Usage: ${APP_NAME} [options]
Options:
-p, --port <port> Port to run the server (default: ${DEFAULT_PORT})
-H, --host <host> Host to bind (default: ${DEFAULT_HOST})
-n, --no-browser Don't open browser automatically
-l, --log Show server logs (default: hidden)
-t, --tray Run in system tray mode (background)
--skip-update Skip auto-update check
-h, --help Show this help message
-v, --version Show version
`);
process.exit(0);
} else if (args[i] === "--version" || args[i] === "-v") {
console.log(pkg.version);
process.exit(0);
}
}
// Auto-relaunch after update: detached process has no TTY → fallback to tray
if (skipUpdate && !trayMode && !process.stdin.isTTY) {
trayMode = true;
process.env.TRAY_MODE = "1";
}
// Always use Node.js runtime with absolute path
const RUNTIME = process.execPath;
// Compare semver versions: returns 1 if a > b, -1 if a < b, 0 if equal
function compareVersions(a, b) {
const partsA = a.split(".").map(Number);
const partsB = b.split(".").map(Number);
for (let i = 0; i < 3; i++) {
if (partsA[i] > partsB[i]) return 1;
if (partsA[i] < partsB[i]) return -1;
}
return 0;
}
// Get app data dir (matches app/src/lib/dataDir.js convention)
function getAppDataDir() {
return process.platform === "win32"
? path.join(process.env.APPDATA || "", "9router")
: path.join(os.homedir(), ".9router");
}
// Kill PID from file (best-effort, removes file after)
function killByPidFile(pidFile) {
try {
if (!fs.existsSync(pidFile)) return;
const pid = parseInt(fs.readFileSync(pidFile, "utf8").trim(), 10);
if (!pid) return;
try {
if (process.platform === "win32") {
execSync(`taskkill /F /T /PID ${pid}`, { stdio: "ignore", windowsHide: true, timeout: 3000 });
} else {
process.kill(pid, "SIGKILL");
}
} catch { }
try { fs.unlinkSync(pidFile); } catch { }
} catch { }
}
// Kill tunnel processes (cloudflared/tailscale) by their PID files
function killTunnelByPidFile() {
const tunnelDir = path.join(getAppDataDir(), "tunnel");
killByPidFile(path.join(tunnelDir, "cloudflared.pid"));
killByPidFile(path.join(tunnelDir, "tailscale.pid"));
}
// Kill cloudflared whose --url targets this app's port (covers stale PID file case)
function killCloudflaredByAppPort(appPort) {
if (!appPort) return [];
const portMatchers = [`localhost:${appPort}`, `127.0.0.1:${appPort}`];
const pids = [];
try {
if (process.platform === "win32") {
const psCmd = `powershell -NonInteractive -WindowStyle Hidden -Command "Get-WmiObject Win32_Process -Filter 'Name=\\"cloudflared.exe\\"' | Select-Object ProcessId,CommandLine | ConvertTo-Csv -NoTypeInformation"`;
const output = execSync(psCmd, { encoding: "utf8", windowsHide: true, timeout: 5000 });
const lines = output.split("\n").slice(1).filter(l => l.trim());
lines.forEach(line => {
if (portMatchers.some(m => line.includes(m))) {
const match = line.match(/^"(\d+)"/);
if (match && match[1]) pids.push(match[1]);
}
});
} else {
const output = execSync("ps -eo pid,command 2>/dev/null", { encoding: "utf8", timeout: 5000 });
output.split("\n").forEach(line => {
if (line.includes("cloudflared") && portMatchers.some(m => line.includes(m))) {
const parts = line.trim().split(/\s+/);
const pid = parts[0];
if (pid && !isNaN(pid)) pids.push(pid);
}
});
}
} catch { }
return pids;
}
// Kill all 9router processes
function killAllAppProcesses(appPort) {
return new Promise((resolve) => {
try {
// Kill MITM first (admin/sudo process, needs special handling)
killMitmByPidFile();
// Kill cloudflared/tailscale by PID file (precise, only this app's tunnel)
killTunnelByPidFile();
const platform = process.platform;
let pids = [];
// Catch stale PID files: kill cloudflared bound to this app's port
pids.push(...killCloudflaredByAppPort(appPort));
if (platform === "win32") {
// Windows: use WMI to get full CommandLine (tasklist /V doesn't include it)
try {
const psCmd = `powershell -NonInteractive -WindowStyle Hidden -Command "Get-WmiObject Win32_Process -Filter 'Name=\\"node.exe\\"' | Select-Object ProcessId,CommandLine | ConvertTo-Csv -NoTypeInformation"`;
const output = execSync(psCmd, {
encoding: "utf8",
windowsHide: true,
timeout: 5000
});
const lines = output.split("\n").slice(1).filter(l => l.trim());
lines.forEach(line => {
// Whitelist: real node process running 9router/cli.js, or next-server.
// Avoids killing editors/grep/strace/cursor that just have "9router" in cmdline.
const cmd = line.toLowerCase();
const isAppProcess =
(cmd.includes("node") && cmd.includes("9router") && (cmd.includes("cli.js") || cmd.includes("\\9router") || cmd.includes("/9router")))
|| cmd.includes("next-server");
if (isAppProcess) {
const match = line.match(/^"(\d+)"/);
if (match && match[1] && match[1] !== process.pid.toString()) {
pids.push(match[1]);
}
}
});
} catch (e) {
// No processes found or error - continue
}
} else {
// macOS/Linux: use ps to find all matching processes
try {
const output = execSync('ps aux 2>/dev/null', {
encoding: 'utf8',
timeout: 5000
});
const lines = output.split('\n');
lines.forEach(line => {
// Whitelist: real node process running 9router/cli.js, or next-server.
// Avoids killing grep/strace/editors/cursor that incidentally match "9router".
const cmd = line.toLowerCase();
const isAppProcess =
(cmd.includes("node") && cmd.includes("9router") && (cmd.includes("cli.js") || cmd.includes("/9router")))
|| cmd.includes("next-server");
if (isAppProcess) {
const parts = line.trim().split(/\s+/);
const pid = parts[1];
if (pid && !isNaN(pid) && pid !== process.pid.toString()) {
pids.push(pid);
}
}
});
} catch (e) {
// No processes found or error - continue
}
}
// Kill all found processes
if (pids.length > 0) {
pids.forEach(pid => {
try {
if (platform === "win32") {
execSync(`taskkill /F /PID ${pid} 2>nul`, { stdio: 'ignore', shell: true, windowsHide: true, timeout: 3000 });
} else {
execSync(`kill -9 ${pid} 2>/dev/null`, { stdio: 'ignore', timeout: 3000 });
}
} catch (err) {
// Process already dead or can't kill - continue
}
});
// Wait for processes to fully terminate
setTimeout(() => resolve(), 1000);
} else {
resolve();
}
} catch (err) {
// Silent fail - continue anyway
resolve();
}
});
}
// Sleep helper using SharedArrayBuffer wait (sync, no busy-loop)
function sleepSync(ms) {
try { Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); } catch { /* ignore */ }
}
// Wait until process dies or timeout reached
function waitForExit(pid, timeoutMs) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
try { process.kill(pid, 0); } catch { return true; }
sleepSync(100);
}
return false;
}
// Kill MITM server by PID file (MITM runs as admin/sudo, needs special handling)
// Sends SIGTERM first so MITM can clean up /etc/hosts entries before dying.
function killMitmByPidFile() {
try {
const mitmPidFile = path.join(getAppDataDir(), "mitm", ".mitm.pid");
if (!fs.existsSync(mitmPidFile)) return;
const pid = parseInt(fs.readFileSync(mitmPidFile, "utf8").trim(), 10);
if (!pid) return;
if (process.platform === "win32") {
// Graceful first (lets server cleanup hosts), then force
try { execSync(`taskkill /T /PID ${pid}`, { stdio: "ignore", windowsHide: true, timeout: 2000 }); } catch { }
if (!waitForExit(pid, 1500)) {
try { execSync(`taskkill /F /T /PID ${pid}`, { stdio: "ignore", windowsHide: true, timeout: 3000 }); } catch { }
}
// Last-resort: PowerShell Stop-Process (sometimes succeeds where taskkill fails on admin processes)
if (!waitForExit(pid, 500)) {
try { execSync(`powershell -NonInteractive -WindowStyle Hidden -Command "Stop-Process -Id ${pid} -Force"`, { stdio: "ignore", windowsHide: true, timeout: 3000 }); } catch { }
}
} else {
// SIGTERM via cached sudo token first
try { execSync(`sudo -n kill -TERM ${pid} 2>/dev/null`, { stdio: "ignore", timeout: 2000 }); }
catch { try { process.kill(pid, "SIGTERM"); } catch { } }
if (!waitForExit(pid, 1500)) {
try { execSync(`sudo -n kill -9 ${pid} 2>/dev/null`, { stdio: "ignore", timeout: 2000 }); }
catch { try { process.kill(pid, "SIGKILL"); } catch { } }
}
}
try { fs.unlinkSync(mitmPidFile); } catch { }
} catch { }
}
// Kill any process on specific port
function killProcessOnPort(port) {
return new Promise((resolve) => {
try {
const platform = process.platform;
let pid;
if (platform === "win32") {
try {
const output = execSync(`netstat -ano | findstr :${port}`, {
encoding: 'utf8',
shell: true,
windowsHide: true,
timeout: 5000
}).trim();
const lines = output.split('\n').filter(l => l.includes('LISTENING'));
if (lines.length > 0) {
pid = lines[0].trim().split(/\s+/).pop();
execSync(`taskkill /F /PID ${pid} 2>nul`, { stdio: 'ignore', shell: true, windowsHide: true, timeout: 3000 });
}
} catch (e) {
// Port is free or error
}
} else {
// macOS/Linux
try {
const pidOutput = execSync(`lsof -ti:${port}`, {
encoding: 'utf8',
stdio: ['pipe', 'pipe', 'ignore']
}).trim();
if (pidOutput) {
pid = pidOutput.split('\n')[0];
execSync(`kill -9 ${pid} 2>/dev/null`, { stdio: 'ignore', timeout: 3000 });
}
} catch (e) {
// Port is free or error
}
}
// Wait for port to be released
setTimeout(() => resolve(), 500);
} catch (err) {
// Silent fail - continue anyway
resolve();
}
});
}
// Detect if running in restricted environment (Codespaces, Docker)
function isRestrictedEnvironment() {
// Check for Codespaces
if (process.env.CODESPACES === "true" || process.env.GITHUB_CODESPACES_PORT_FORWARDING_DOMAIN) {
return "GitHub Codespaces";
}
// Check for Docker
if (fs.existsSync("/.dockerenv") || (fs.existsSync("/proc/1/cgroup") && fs.readFileSync("/proc/1/cgroup", "utf8").includes("docker"))) {
return "Docker";
}
return null;
}
// Check if new version available, return latest version or null
function checkForUpdate() {
return new Promise((resolve) => {
if (skipUpdate) {
resolve(null);
return;
}
const spinner = createSpinner("Checking for updates...").start();
let resolved = false;
const safetyTimeout = setTimeout(() => {
if (!resolved) {
resolved = true;
spinner.stop();
resolve(null);
}
}, 8000);
const done = (version) => {
if (resolved) return;
resolved = true;
clearTimeout(safetyTimeout);
spinner.stop();
resolve(version);
};
const req = https.get(`https://registry.npmjs.org/${pkg.name}/latest`, { timeout: 3000 }, (res) => {
let data = "";
res.on("data", chunk => data += chunk);
res.on("end", () => {
try {
const latest = JSON.parse(data);
if (latest.version && compareVersions(latest.version, pkg.version) > 0) {
done(latest.version);
} else {
done(null);
}
} catch (e) {
done(null);
}
});
});
req.on("error", () => done(null));
req.on("timeout", () => { req.destroy(); done(null); });
});
}
// Open browser
function openBrowser(url) {
const platform = process.platform;
let cmd;
if (platform === "darwin") {
cmd = `open "${url}"`;
} else if (platform === "win32") {
cmd = `start "" "${url}"`;
} else {
cmd = `xdg-open "${url}"`;
}
exec(cmd, { windowsHide: true }, (err) => {
if (err) {
console.log(`Open browser manually: ${url}`);
}
});
}
// Find standalone server (bundled in bin/app for published package)
const standaloneDir = path.join(__dirname, "app");
const serverPath = path.join(standaloneDir, "server.js");
if (!fs.existsSync(serverPath)) {
console.error("Error: Standalone build not found.");
console.error("Please run 'npm run build:cli' first.");
process.exit(1);
}
// Check for updates FIRST, then start server
checkForUpdate().then((latestVersion) => {
killAllAppProcesses(port).then(() => {
return killProcessOnPort(port);
}).then(() => {
startServer(latestVersion);
});
});
// Show interface selection menu
async function showInterfaceMenu(latestVersion) {
const { selectMenu } = require("./src/cli/utils/input");
const { clearScreen } = require("./src/cli/utils/display");
const { getEndpoint } = require("./src/cli/utils/endpoint");
clearScreen();
const displayHost = host === DEFAULT_HOST ? "localhost" : host;
// Detect tunnel/local mode for server URL display
let serverUrl;
try {
const { endpoint, tunnelEnabled } = await getEndpoint(port);
serverUrl = tunnelEnabled ? endpoint.replace(/\/v1$/, "") : `http://${displayHost}:${port}`;
} catch (e) {
serverUrl = `http://${displayHost}:${port}`;
}
const subtitle = `🚀 Server: \x1b[32m${serverUrl}\x1b[0m`;
const menuItems = [];
if (latestVersion) {
menuItems.push({ label: `Update to v${latestVersion} (current: v${pkg.version})`, icon: "⬆" });
}
menuItems.push(
{ label: "Web UI (Open in Browser)", icon: "🌐" },
{ label: "Terminal UI (Interactive CLI)", icon: "💻" },
{ label: "Hide to Tray (Background)", icon: "🔔" },
{ label: "Exit", icon: "🚪" }
);
const selected = await selectMenu(`Choose Interface (v${pkg.version})`, menuItems, 0, subtitle);
const offset = latestVersion ? 1 : 0;
if (latestVersion && selected === 0) return "update";
if (selected === offset) return "web";
if (selected === offset + 1) return "terminal";
if (selected === offset + 2) return "hide";
return "exit";
}
const MAX_RESTARTS = 2;
const RESTART_RESET_MS = 30000; // Reset counter if alive > 30s
function startServer(latestVersion) {
const displayHost = host === DEFAULT_HOST ? "localhost" : host;
const url = `http://${displayHost}:${port}/dashboard`;
let restartCount = 0;
let serverStartTime = Date.now();
const CRASH_LOG_LINES = 50;
let crashLog = [];
function spawnServer() {
serverStartTime = Date.now();
crashLog = [];
const child = spawn(RUNTIME, ["--max-old-space-size=6144", serverPath], {
cwd: standaloneDir,
stdio: showLog ? "inherit" : ["ignore", "ignore", "pipe"],
detached: true,
windowsHide: true,
env: {
...buildEnvWithRuntime(process.env),
PORT: port.toString(),
HOSTNAME: host
}
});
if (!showLog && child.stderr) {
child.stderr.on("data", (data) => {
const lines = data.toString().split("\n").filter(Boolean);
crashLog.push(...lines);
if (crashLog.length > CRASH_LOG_LINES) crashLog = crashLog.slice(-CRASH_LOG_LINES);
});
}
return child;
}
let server = spawnServer();
// Cleanup function - force kill server process
let isCleaningUp = false;
function cleanup() {
if (isCleaningUp) return;
isCleaningUp = true;
try {
// Kill tray if running
try {
const { killTray } = require("./src/cli/tray/tray");
killTray();
} catch (e) { }
// Kill MITM server (admin/sudo process) via PID file
killMitmByPidFile();
// Kill cloudflared/tailscale via PID file (only this app's tunnel)
killTunnelByPidFile();
// Kill server process directly
if (server.pid) {
process.kill(server.pid, "SIGKILL");
}
// Also try to kill process group
process.kill(-server.pid, "SIGKILL");
} catch (e) { }
}
// Suppress all errors during shutdown (systray lib throws JSON parse errors)
let isShuttingDown = false;
process.on("uncaughtException", (err) => {
if (isShuttingDown) return;
console.error("Error:", err.message);
});
// Handle all exit scenarios
process.on("SIGINT", () => {
if (isShuttingDown) return;
isShuttingDown = true;
console.log("\nExiting...");
cleanup();
setTimeout(() => process.exit(0), 100);
});
process.on("SIGTERM", () => {
if (isShuttingDown) return;
isShuttingDown = true;
cleanup();
setTimeout(() => process.exit(0), 100);
});
process.on("SIGHUP", () => {
if (isShuttingDown) return;
isShuttingDown = true;
cleanup();
setTimeout(() => process.exit(0), 100);
});
// Initialize tray icon (runs alongside TUI)
const initTrayIcon = () => {
try {
const { initTray } = require("./src/cli/tray/tray");
initTray({
port,
onQuit: () => {
isShuttingDown = true;
console.log("\n👋 Shutting down from tray...");
cleanup();
setTimeout(() => process.exit(0), 100);
},
onOpenDashboard: () => openBrowser(url)
});
} catch (err) {
// Tray not available - continue without it
}
};
// Tray-only mode: no TUI, just tray icon
if (trayMode) {
console.log(`\n🚀 ${pkg.name} v${pkg.version}`);
console.log(`Server: http://${displayHost}:${port}`);
setTimeout(() => {
initTrayIcon();
console.log("\n💡 Router is now running in system tray. Close this terminal if you want.");
console.log(" Right-click tray icon to open dashboard or quit.\n");
}, 2000);
return;
}
// Wait for server to be ready, then show interface menu loop + tray
setTimeout(async () => {
// Start tray icon alongside TUI
initTrayIcon();
try {
while (true) {
const choice = await showInterfaceMenu(latestVersion);
if (choice === "update") {
isShuttingDown = true;
const { clearScreen } = require("./src/cli/utils/display");
clearScreen();
console.log(`\n⬆ Update v${pkg.version} → v${latestVersion}\n`);
console.log(`Run this after exit:\n`);
console.log(` \x1b[33m${INSTALL_CMD_LATEST}\x1b[0m\n`);
cleanup();
await killAllAppProcesses(port);
await killProcessOnPort(port);
setTimeout(() => process.exit(0), 200);
return;
} else if (choice === "web") {
openBrowser(url);
// Wait for user to come back
const { pause } = require("./src/cli/utils/input");
await pause("\nPress Enter to go back to menu...");
} else if (choice === "terminal") {
// Start Terminal UI - it will return when user selects Back
const { startTerminalUI } = require("./src/cli/terminalUI");
await startTerminalUI(port);
// Loop continues, show menu again
} else if (choice === "hide") {
// Hide to tray - spawn detached background process
const { clearScreen } = require("./src/cli/utils/display");
clearScreen();
// Kill current tray and AWAIT Go binary fully exit. macOS needs the
// old NSStatusItem released before a new tray process can register;
// otherwise the bgProcess tray silently fails ("works sometimes").
try { await require("./src/cli/tray/tray").killTray(); } catch (e) { }
// Extra delay so macOS NSStatusBar fully removes the old icon before
// bgProcess spawns a new one. Without this, two icons appear briefly.
await new Promise((r) => setTimeout(r, 400));
// Enable auto startup on OS boot
try {
const { enableAutoStart } = require("./src/cli/tray/autostart");
const enabled = enableAutoStart(__filename);
if (enabled) {
console.log("✅ Auto-start enabled (will run on OS boot)");
}
} catch (e) { }
// Log bgProcess stderr to file so silent tray failures are debuggable.
// Previously stdio:"ignore" swallowed every error from systray2 init.
const logDir = path.join(getAppDataDir(), "logs");
try { fs.mkdirSync(logDir, { recursive: true }); } catch (e) { }
const bgLogPath = path.join(logDir, "tray-bg.log");
let bgLogFd = "ignore";
try { bgLogFd = fs.openSync(bgLogPath, "a"); } catch (e) { }
// Spawn new detached process with --tray flag
const bgProcess = spawn(process.execPath, [__filename, "--tray", "--skip-update", "-p", port.toString()], {
detached: true,
stdio: ["ignore", bgLogFd, bgLogFd],
windowsHide: true,
env: { ...process.env }
});
bgProcess.unref();
console.log(`\n🔔 9Router is now running in background (PID: ${bgProcess.pid})`);
console.log(` Server: http://${displayHost}:${port}`);
console.log(`\n💡 You can close this terminal. Right-click tray icon to:`);
console.log(` • Open Dashboard`);
console.log(` • Quit\n`);
// Exit current process - background process takes over.
// Don't call cleanup() here: tray already killed above, and cleanup()
// would kill the server which bgProcess relies on staying alive.
isShuttingDown = true;
process.exit(0);
} else if (choice === "exit") {
isShuttingDown = true;
console.log("\nExiting...");
cleanup();
setTimeout(() => process.exit(0), 100);
}
}
} catch (err) {
console.error("Error:", err.message);
cleanup();
process.exit(1);
}
}, 3000);
function attachServerEvents() {
server.on("error", (err) => {
console.error("Failed to start server:", err.message);
if (!isShuttingDown) tryRestart();
else { cleanup(); process.exit(1); }
});
server.on("close", (code) => {
if (isShuttingDown || code === 0) {
process.exit(code || 0);
return;
}
tryRestart(code);
});
}
function tryRestart(code) {
const aliveMs = Date.now() - serverStartTime;
// Reset counter if last run was stable
if (aliveMs >= RESTART_RESET_MS) restartCount = 0;
if (restartCount >= MAX_RESTARTS) {
console.error(`\n⚠️ Server crashed ${MAX_RESTARTS} times. Disabling MITM and restarting...`);
try {
const dbPath = path.join(os.homedir(), process.platform === "win32" ? path.join("AppData", "Roaming", "9router", "db.json") : path.join(".9router", "db.json"));
if (fs.existsSync(dbPath)) {
const db = JSON.parse(fs.readFileSync(dbPath, "utf-8"));
if (db.settings) db.settings.mitmEnabled = false;
fs.writeFileSync(dbPath, JSON.stringify(db, null, 2));
}
} catch { /* best effort */ }
restartCount = 0;
server = spawnServer();
attachServerEvents();
return;
}
restartCount++;
const delay = Math.min(1000 * restartCount, 10000);
console.error(`\n⚠️ Server exited (code=${code ?? "unknown"}). Restarting in ${delay / 1000}s... (${restartCount}/${MAX_RESTARTS})`);
if (crashLog.length) {
console.error("\n--- Server crash log ---");
crashLog.forEach(l => console.error(l));
console.error("--- End crash log ---\n");
}
setTimeout(() => {
server = spawnServer();
attachServerEvents();
}, delay);
}
attachServerEvents();
}