#!/usr/bin/env node /** * 热启动 mnote-web 单入口,以及按需启用的 FastAPI。 * 可使用以下环境变量调整行为: * - ENABLE_BACKEND:设为 "1" or "true" 时启用默认 FastAPI 后端 * - BACKEND_CMD:覆盖 FastAPI 启动命令;设置后即视为显式启用后端 * - SKIP_BACKEND:设为 "1" or "true" 可强制跳过 FastAPI 后端 * - ENABLE_OPENCODE:设为 "1" or "true" 时启用 opencode serve * - OPENCODE_CMD:覆盖 opencode 启动命令;设置后即视为显式启用 opencode * - SKIP_OPENCODE:设为 "1" or "true" 可强制跳过 opencode * - ENABLE_OPENHUB:设为 "1" or "true" 时启用 OpenHub FastAPI * - OPENHUB_CMD / OPENHUB_BACKEND_CMD:覆盖 OpenHub FastAPI 启动命令;设置后即视为显式启用 OpenHub * - OPENHUB_HOST / OPENHUB_BIND_HOST:OpenHub FastAPI 监听地址,默认 0.0.0.0 便于局域网访问 * - OPENHUB_PORT / OPENHUB_BACKEND_PORT:OpenHub FastAPI 端口,默认 18080 * - SKIP_OPENHUB:设为 "1" or "true" 可强制跳过 OpenHub * - OPENHUB_REDIS_URL / OPENHUB_REDIS_DB:记录 OpenHub Redis 位置;OPENHUB_REDIS_HEALTH_URL 可选做 HTTP health 检查 * - OPENHUB_REDIS_HEALTH_URL:可选 Redis health URL;dev-hot 只检查,不释放或结束 OpenHub 相关端口 * - OPENHUB_OPENCODE_BASE_URL:OpenHub 侧 opencode serve base URL;默认复用 MNOTE_OPENCODE_BASE_URL * - SKIP_OPENHUB_HEALTH:设为 "1" or "true" 可跳过 OpenHub/Redis/opencode health 预检 * - MNOTE_OPENHUB_DISABLE_GIT_SNAPSHOT_RESTORE:默认 "1",禁用 OpenHub Git snapshot/restore/revert 写链 * - MNOTE_CONTROL_PLANE_BACKEND:控制面后端,默认 libsql-local;可设 turso-remote / turso-local-replica / turso-synced * - MNOTE_TURSO_LOCAL_PATH:libsql-local 本地库路径,默认 /mnt/Data1T/Mnote_data/control-plane/control-plane-libsql.db * - MNOTE_PAGE_AI_PI_WARMUP:设为 "1" or "true" 时,在 mnote-web 可用后预启动 Pi Lab runtime / MCP cache * - MNOTE_PAGE_AI_PI_WARMUP_SEND:设为 "1" or "true" 时,额外发送一次轻量 prompt 预热模型首包(会产生真实模型请求) * - PYTHON_BIN:只在 BACKEND_CMD 未覆盖时,设置 Python 可执行文件,默认 "python" */ const { spawn, execSync } = require("child_process"); const path = require("path"); const net = require("net"); const fs = require("fs"); const { runPiLabWarmup } = require("./lib/pi-lab-warmup"); const rootDir = path.resolve(__dirname, ".."); const backendDir = path.join(rootDir, "wolai-backend"); function resolveBackendExecutable(envName, fallbackName) { const fromEnv = (process.env[envName] || "").trim(); if (fromEnv) return fromEnv; const isWin = process.platform === "win32"; const candidates = isWin ? [ path.join(backendDir, ".venv", "Scripts", `${fallbackName}.exe`), path.join(backendDir, ".venv312", "Scripts", `${fallbackName}.exe`), path.join(backendDir, "venv", "Scripts", `${fallbackName}.exe`), ] : [ path.join(backendDir, ".venv-linux", "bin", fallbackName), path.join(backendDir, ".venv", "bin", fallbackName), path.join(backendDir, "venv", "bin", fallbackName), ]; for (const candidate of candidates) { if (fs.existsSync(candidate)) { return candidate; } } return fallbackName; } const pythonBin = resolveBackendExecutable("PYTHON_BIN", "python"); const backendPortFromEnv = Number(process.env.BACKEND_PORT || 8000); const opencodePortFromEnv = Number(process.env.OPENCODE_PORT || 4096); const openhubPortFromEnv = Number(process.env.OPENHUB_BACKEND_PORT || process.env.OPENHUB_PORT || 18080); const defaultControlPlaneDir = "/mnt/Data1T/Mnote_data/control-plane"; function hasCommand(command) { try { execSync(`command -v ${command}`, { stdio: "ignore", shell: "/bin/sh" }); return true; } catch { return false; } } function shouldUseUvBackendRuntime() { if (String(process.env.PYTHON_BIN || "").trim()) return false; if (!fs.existsSync(path.join(backendDir, "requirements.txt"))) return false; return hasCommand("uv"); } function buildDefaultBackendCommand(port) { if (shouldUseUvBackendRuntime()) { return `uv run --with-requirements requirements.txt python -m uvicorn app.main:app --reload --port ${port}`; } return `${pythonBin} -m uvicorn app.main:app --reload --port ${port}`; } function buildDefaultOpencodeCommand(port) { const opencodeXdgRoot = process.env.OPENHUB_OPENCODE_XDG_ROOT || "/mnt/Data1T/Mnote_data/openhub/opencode-runtime"; const opencodeHome = process.env.OPENHUB_OPENCODE_HOME || "/mnt/Data1T/Mnote_data/openhub/opencode-home"; const modelEnvNames = [ "OPENCODE_API_KEY", "OPENAI_API_KEY", "OPENAI_BASE_URL", "ANTHROPIC_API_KEY", "GOOGLE_API_KEY", "GOOGLE_GENERATIVE_AI_API_KEY", "DEEPSEEK_API_KEY", "GEMINI_API_KEY", "MISTRAL_API_KEY", "OPENROUTER_API_KEY", "GROQ_API_KEY", "XAI_API_KEY", "AZURE_OPENAI_API_KEY", "AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY", ]; const opencodeEnv = [ "env", ...modelEnvNames.map((name) => `-u ${name}`), `HOME=${JSON.stringify(opencodeHome)}`, `XDG_CONFIG_HOME=${JSON.stringify(path.join(opencodeXdgRoot, "config"))}`, `XDG_DATA_HOME=${JSON.stringify(path.join(opencodeXdgRoot, "data"))}`, `XDG_STATE_HOME=${JSON.stringify(path.join(opencodeXdgRoot, "state"))}`, `XDG_CACHE_HOME=${JSON.stringify(path.join(opencodeXdgRoot, "cache"))}`, ].join(" "); return [ `mkdir -p ${JSON.stringify(opencodeXdgRoot)} ${JSON.stringify(opencodeHome)}`, `while true; do script -qfec ${JSON.stringify(`${opencodeEnv} opencode serve --hostname=127.0.0.1 --port ${port} --print-logs`)} /dev/null; sleep 1; done`, ].join(" && "); } function buildDefaultOpenHubCommand(port) { const openhubBackendDir = process.env.OPENHUB_BACKEND_DIR || "/mnt/Data1T/Mnote_data/openhub/OpenHub/smart-query-backend"; const openhubHost = process.env.OPENHUB_HOST || process.env.OPENHUB_BIND_HOST || "0.0.0.0"; const uvicornBin = fs.existsSync(path.join(openhubBackendDir, ".venv", "bin", "uvicorn")) ? path.join(openhubBackendDir, ".venv", "bin", "uvicorn") : "uvicorn"; const opencodeBaseUrl = process.env.OPENHUB_OPENCODE_BASE_URL || process.env.MNOTE_OPENCODE_BASE_URL || `http://127.0.0.1:${opencodePortFromEnv}`; return [ `cd ${JSON.stringify(openhubBackendDir)}`, `OPENCODE_BASE_URL=${JSON.stringify(opencodeBaseUrl)} MNOTE_OPENHUB_DISABLE_GIT_SNAPSHOT_RESTORE=${JSON.stringify(process.env.MNOTE_OPENHUB_DISABLE_GIT_SNAPSHOT_RESTORE || "1")} exec ${JSON.stringify(uvicornBin)} app.main:app --host ${JSON.stringify(openhubHost)} --port ${port}`, ].join(" && "); } function isEnabledEnv(value) { const normalized = String(value || "").toLowerCase(); return normalized === "1" || normalized === "true"; } function shouldStartBackend(env = process.env) { if (isEnabledEnv(env.SKIP_BACKEND)) return false; if (String(env.BACKEND_CMD || "").trim()) return true; return isEnabledEnv(env.ENABLE_BACKEND); } function shouldStartOpencode(env = process.env) { if (isEnabledEnv(env.SKIP_OPENCODE)) return false; if (String(env.OPENCODE_CMD || "").trim()) return true; return true; } function shouldStartOpenHub(env = process.env) { if (isEnabledEnv(env.SKIP_OPENHUB)) return false; if (String(env.OPENHUB_BACKEND_CMD || env.OPENHUB_CMD || "").trim()) return true; return isEnabledEnv(env.ENABLE_OPENHUB); } function shouldCheckOpenHubHealth(env = process.env) { if (isEnabledEnv(env.SKIP_OPENHUB_HEALTH)) return false; return shouldStartOpenHub(env) || isEnabledEnv(env.CHECK_OPENHUB_HEALTH); } function resolveOpenHubHealthPlan(env = process.env) { const openhubPort = Number(env.OPENHUB_BACKEND_PORT || env.OPENHUB_PORT || openhubPortFromEnv); const opencodePort = Number(env.OPENCODE_PORT || opencodePortFromEnv); const baseUrl = String(env.MNOTE_OPENHUB_BASE_URL || env.OPENHUB_BASE_URL || `http://127.0.0.1:${openhubPort}`).replace(/\/+$/, ""); const redisHealthUrl = String(env.OPENHUB_REDIS_HEALTH_URL || "").trim(); const redisUrl = String(env.OPENHUB_REDIS_URL || "").trim(); const redisDb = String(env.OPENHUB_REDIS_DB || "").trim(); const opencodeBaseUrl = String(env.OPENHUB_OPENCODE_BASE_URL || env.MNOTE_OPENCODE_BASE_URL || `http://127.0.0.1:${opencodePort}`).replace(/\/+$/, ""); const requireHealth = isEnabledEnv(env.REQUIRE_OPENHUB_HEALTH); return { enabled: shouldCheckOpenHubHealth(env), openhub: { label: "OpenHub FastAPI", url: env.OPENHUB_HEALTH_URL || `${baseUrl}/api/health`, required: requireHealth, }, redis: { label: "OpenHub Redis", url: redisHealthUrl, redisUrl, redisDb, required: isEnabledEnv(env.REQUIRE_OPENHUB_REDIS_HEALTH), skipped: !redisHealthUrl, }, opencode: { label: "opencode", url: env.OPENCODE_HEALTH_URL || `${opencodeBaseUrl}/global/health`, required: requireHealth, }, gitSnapshotRestore: { env: "MNOTE_OPENHUB_DISABLE_GIT_SNAPSHOT_RESTORE", value: String(env.MNOTE_OPENHUB_DISABLE_GIT_SNAPSHOT_RESTORE || "1"), defaultDisabled: true, }, }; } function resolveRuntimePlan(env = process.env) { const frontendPort = Number(env.FRONTEND_PORT || 3000); const skipGateway = false; const publicPort = Number.isFinite(frontendPort) ? Math.floor(frontendPort) : 3000; const controlPlaneBackend = String(env.MNOTE_CONTROL_PLANE_BACKEND || "libsql-local").trim() || "libsql-local"; if (controlPlaneBackend === "sqlite") { throw new Error("desktop:hot 不再支持 SQLite control-plane fallback;请使用 libsql-local/turso-local-replica/turso-remote/turso-synced"); } const controlPlaneEnv = { MNOTE_CONTROL_PLANE_BACKEND: controlPlaneBackend, }; if (controlPlaneBackend === "libsql-local" || controlPlaneBackend === "turso-local" || controlPlaneBackend === "turso") { controlPlaneEnv.MNOTE_TURSO_LOCAL_PATH = env.MNOTE_TURSO_LOCAL_PATH || path.join(defaultControlPlaneDir, "control-plane-libsql.db"); } else if (controlPlaneBackend === "turso-local-replica" || controlPlaneBackend === "turso-remote-replica") { controlPlaneEnv.MNOTE_TURSO_LOCAL_REPLICA_PATH = env.MNOTE_TURSO_LOCAL_REPLICA_PATH || path.join(defaultControlPlaneDir, "control-plane-replica.db"); } else if (controlPlaneBackend === "turso-synced") { controlPlaneEnv.MNOTE_TURSO_SYNCED_PATH = env.MNOTE_TURSO_SYNCED_PATH || env.MNOTE_TURSO_LOCAL_REPLICA_PATH || path.join(defaultControlPlaneDir, "control-plane-synced.db"); } const plan = { skipGateway, publicPort, publicUrl: `http://localhost:${publicPort}`, mnoteWebCommand: env.MNOTE_WEB_CMD || "cargo run -p mnote-web --bin mnote-web", mnoteWebEnv: { MNOTE_WEB_BIND: env.MNOTE_WEB_BIND || `0.0.0.0:${publicPort}`, MNOTE_WEB_PUBLIC_BIND: env.MNOTE_WEB_PUBLIC_BIND || `127.0.0.1:${publicPort}`, MNOTE_KNOWLEDGE_PROVIDER: env.MNOTE_KNOWLEDGE_PROVIDER || "lightrag_legacy", MNOTE_OPENCODE_BASE_URL: env.MNOTE_OPENCODE_BASE_URL || `http://127.0.0.1:${opencodePortFromEnv}`, MNOTE_OPENHUB_BASE_URL: env.MNOTE_OPENHUB_BASE_URL || `http://127.0.0.1:${openhubPortFromEnv}`, MNOTE_OPENHUB_DISABLE_GIT_SNAPSHOT_RESTORE: env.MNOTE_OPENHUB_DISABLE_GIT_SNAPSHOT_RESTORE || "1", ...controlPlaneEnv, }, }; return plan; } const runtimePlan = resolveRuntimePlan(process.env); const skipMnoteWebGateway = runtimePlan.skipGateway; const tasks = [ ...(skipMnoteWebGateway ? [] : [ { name: "mnote-web", command: process.env.MNOTE_WEB_CMD || runtimePlan.mnoteWebCommand, cwd: path.join(rootDir, "rust"), }, ]), ...(shouldStartBackend(process.env) ? [ { name: "backend", command: process.env.BACKEND_CMD || buildDefaultBackendCommand(8000), cwd: backendDir, }, ] : []), ...(shouldStartOpencode(process.env) ? [ { name: "opencode", command: process.env.OPENCODE_CMD || buildDefaultOpencodeCommand(opencodePortFromEnv), cwd: rootDir, }, ] : []), ...(shouldStartOpenHub(process.env) ? [ { name: "openhub", command: process.env.OPENHUB_BACKEND_CMD || process.env.OPENHUB_CMD || buildDefaultOpenHubCommand(openhubPortFromEnv), cwd: rootDir, }, ] : []), ]; function findTask(name) { return tasks.find((task) => task.name === name); } const children = []; let shuttingDown = false; async function isPortFree(host, port, timeoutMs = 400) { return await new Promise((resolve) => { const socket = net.createConnection({ host, port }); const timer = setTimeout(() => { socket.destroy(); resolve(true); }, timeoutMs); socket.once("connect", () => { clearTimeout(timer); socket.end(); resolve(false); }); socket.once("error", () => { clearTimeout(timer); resolve(true); }); }); } async function findFreePort(startPort) { let port = Number.isFinite(startPort) ? Math.floor(startPort) : 3000; port = Math.max(1, Math.min(65535, port)); // 最多尝试 20 个端口,避免无限循环。 for (let i = 0; i < 20; i += 1) { // eslint-disable-next-line no-await-in-loop const free = await isPortFree("127.0.0.1", port); if (free) return port; port += 1; } return startPort; } function getListeningPidsByPort(port) { const pids = new Set(); if (process.platform !== "win32") { try { const out = execSync(`ss -ltnp 'sport = :${port}'`, { encoding: "utf8" }); for (const match of out.matchAll(/pid=(\d+)/g)) { const pid = Number(match[1]); if (Number.isFinite(pid) && pid > 0) { pids.add(pid); } } } catch { // ignore } if (pids.size > 0) { return [...pids]; } try { const out = execSync(`lsof -nP -iTCP:${port} -sTCP:LISTEN -t`, { encoding: "utf8" }); for (const line of out.split(/\r?\n/)) { const pid = Number(line.trim()); if (Number.isFinite(pid) && pid > 0) { pids.add(pid); } } } catch { // ignore } return [...pids]; } try { // 说明:netstat 输出示例: // TCP 0.0.0.0:3000 0.0.0.0:0 LISTENING 12345 const out = execSync("netstat -ano -p tcp", { encoding: "utf8" }); for (const line of out.split(/\r?\n/)) { if (!line.includes(`:${port}`)) continue; if (!/LISTENING/i.test(line)) continue; const parts = line.trim().split(/\s+/); const pid = Number(parts[parts.length - 1]); if (Number.isFinite(pid) && pid > 0) { pids.add(pid); } } return [...pids]; } catch { return []; } } function getProcessNameByPid(pid) { if (process.platform !== "win32") { try { return execSync(`ps -p ${pid} -o comm=`, { encoding: "utf8" }).trim(); } catch { return ""; } } try { // 输出为 CSV,示例: // "Image Name","PID","Session Name","Session#","Mem Usage" // "python.exe","12345","Console","1","12,345 K" const out = execSync(`tasklist /FI "PID eq ${pid}" /FO CSV /NH`, { encoding: "utf8", }).trim(); if (!out || /No tasks are running/i.test(out)) return ""; const firstLine = out.split(/\r?\n/)[0].trim(); if (!firstLine) return ""; const cells = firstLine .split('","') .map((s) => s.replace(/^"/, "").replace(/"$/, "")); return (cells[0] || "").trim(); } catch { return ""; } } function getProcessCommandByPid(pid) { if (process.platform === "win32") { try { const out = execSync(`wmic process where ProcessId=${pid} get CommandLine /value`, { encoding: "utf8", }); return out.replace(/^CommandLine=/m, "").trim(); } catch { return ""; } } try { return execSync(`ps -p ${pid} -o args=`, { encoding: "utf8" }).trim(); } catch { return ""; } } function getProcessGroupByPid(pid) { if (process.platform === "win32") return pid; try { const pgid = Number(execSync(`ps -p ${pid} -o pgid=`, { encoding: "utf8" }).trim()); return Number.isFinite(pgid) && pgid > 0 ? pgid : pid; } catch { return pid; } } function terminatePid(pid) { if (process.platform === "win32") { execSync(`taskkill /PID ${pid} /T /F`, { stdio: "ignore" }); return; } try { process.kill(pid, "SIGTERM"); } catch { return; } } function forceKillPid(pid) { if (process.platform === "win32") { execSync(`taskkill /PID ${pid} /T /F`, { stdio: "ignore" }); return; } try { process.kill(pid, "SIGKILL"); } catch { // 进程可能已经退出。 } } function terminateProcessGroup(pgid) { if (process.platform === "win32") { terminatePid(pgid); return; } try { process.kill(-pgid, "SIGTERM"); } catch { terminatePid(pgid); } } function forceKillProcessGroup(pgid) { if (process.platform === "win32") { forceKillPid(pgid); return; } try { process.kill(-pgid, "SIGKILL"); } catch { forceKillPid(pgid); } } function collectStaleMnoteWebCargoPids() { if (process.platform === "win32") return []; const currentPgid = getProcessGroupByPid(process.pid); try { const out = execSync("pgrep -f 'cargo (watch|run).*mnote-web|cargo-watch watch.*mnote-web'", { encoding: "utf8", }); return out .split(/\r?\n/) .map((line) => Number(line.trim())) .filter((pid) => Number.isFinite(pid) && pid > 0 && pid !== process.pid) .filter((pid) => getProcessGroupByPid(pid) !== currentPgid) .filter((pid) => { const command = getProcessCommandByPid(pid); return !command.includes("pgrep -f"); }); } catch { return []; } } async function stopStaleMnoteWebCargoProcesses() { const pids = collectStaleMnoteWebCargoPids(); if (pids.length === 0) return true; const safePids = pids.filter((pid) => { const command = getProcessCommandByPid(pid); return ( command.includes("mnote-web") && command.includes("cargo") && (command.includes("cargo watch") || command.includes("cargo-watch watch") || command.includes("cargo run -p mnote-web")) ); }); if (safePids.length === 0) return true; const pgids = [...new Set(safePids.map(getProcessGroupByPid).filter((pgid) => pgid > 0))]; logPrefix("mnote-web", `检测到陈旧 cargo-watch/cargo run,先清理进程组:${pgids.join(", ")}`); pgids.forEach(terminateProcessGroup); for (let i = 0; i < 20; i += 1) { // eslint-disable-next-line no-await-in-loop await new Promise((r) => setTimeout(r, 150)); const remaining = collectStaleMnoteWebCargoPids().filter((pid) => safePids.includes(pid) || pgids.includes(getProcessGroupByPid(pid)), ); if (remaining.length === 0) return true; } pgids.forEach(forceKillProcessGroup); return true; } async function ensurePortFree(port, nameForLog) { const free = await isPortFree("127.0.0.1", port); if (free) return true; const pids = getListeningPidsByPort(port); if (pids.length === 0) { logPrefix(nameForLog, `检测到端口 ${port} 被占用,但无法定位 PID。`); return false; } let killPids = pids; // 安全策略:后端默认只结束 python 进程(避免误杀其他服务)。 if (nameForLog === "backend") { killPids = pids.filter((pid) => { const procName = getProcessNameByPid(pid).toLowerCase(); return procName.includes("python"); }); if (killPids.length === 0) { logPrefix( nameForLog, `检测到端口 ${port} 被占用,但没有可安全结束的 python 进程(PIDs=${pids.join(", ")})。请手动释放端口后重试。`, ); return false; } } logPrefix(nameForLog, `检测到端口 ${port} 被占用,准备重启(结束旧进程):${killPids.join(", ")}`); for (const pid of killPids) { try { terminatePid(pid); } catch { // ignore } } // 等待端口释放 for (let i = 0; i < 20; i += 1) { // eslint-disable-next-line no-await-in-loop const ok = await isPortFree("127.0.0.1", port, 250); if (ok) return true; // eslint-disable-next-line no-await-in-loop await new Promise((r) => setTimeout(r, 150)); } if (process.platform !== "win32") { for (const pid of killPids) { forceKillPid(pid); } for (let i = 0; i < 10; i += 1) { // eslint-disable-next-line no-await-in-loop const ok = await isPortFree("127.0.0.1", port, 250); if (ok) return true; // eslint-disable-next-line no-await-in-loop await new Promise((r) => setTimeout(r, 150)); } } logPrefix(nameForLog, `端口 ${port} 仍未释放,可能有其他程序占用。`); return false; } async function checkHttpHealth(url, label, required) { if (!url) { logPrefix("openhub-health", `${label} health 未配置,已跳过。`); return true; } try { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), 1200); const response = await fetch(url, { method: "GET", headers: { accept: "application/json,text/plain,*/*" }, signal: controller.signal, }); clearTimeout(timer); if (response.ok) { logPrefix("openhub-health", `${label} 可达:${url}`); return true; } logPrefix("openhub-health", `${label} 返回 HTTP ${response.status}:${url}`); return !required; } catch (error) { logPrefix("openhub-health", `${label} 不可达:${url} (${error.message})`); return !required; } } async function isHttpHealthy(url) { if (!url) return false; try { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), 1200); const response = await fetch(url, { method: "GET", headers: { accept: "application/json,text/plain,*/*" }, signal: controller.signal, }); clearTimeout(timer); return response.ok; } catch { return false; } } async function checkOpenHubHealth(plan = resolveOpenHubHealthPlan(process.env)) { if (!plan.enabled) { return true; } logPrefix( "openhub-health", `Git snapshot/restore 默认关闭:${plan.gitSnapshotRestore.env}=${plan.gitSnapshotRestore.value || "1"}`, ); const checks = [ await checkHttpHealth(plan.openhub.url, plan.openhub.label, plan.openhub.required), plan.redis.skipped ? (logPrefix("openhub-health", "OpenHub Redis health 未配置,已跳过非破坏性检查。"), true) : await checkHttpHealth(plan.redis.url, plan.redis.label, plan.redis.required), await checkHttpHealth(plan.opencode.url, plan.opencode.label, plan.opencode.required), ]; return checks.every(Boolean); } function loadEnvFile(filePath) { if (!fs.existsSync(filePath)) return {}; const content = fs.readFileSync(filePath, "utf8"); return content .split(/\r?\n/) .filter((line) => line.trim() && !line.trim().startsWith("#")) .reduce((acc, line) => { const idx = line.indexOf("="); if (idx === -1) return acc; const key = line.slice(0, idx).trim(); const value = line.slice(idx + 1).trim(); acc[key] = value; return acc; }, {}); } // 约定:全局仅使用仓库根目录的 .env.all 作为配置来源(生产优先)。 const envAllPath = path.join(rootDir, ".env.all"); if (!fs.existsSync(envAllPath)) { console.error("[system] 缺少 .env.all:请在仓库根目录创建全局唯一 env 文件后重试。"); process.exit(1); } const envFromAll = loadEnvFile(envAllPath); const mergedEnv = { ...process.env, ...envFromAll, }; function logPrefix(name, message) { console.log(`[${name}] ${message}`); } function startTask(task) { logPrefix(task.name, `启动命令:${task.command}`); const child = spawn(task.command, { cwd: task.cwd, stdio: "inherit", shell: true, env: mergedEnv, }); child.on("exit", (code, signal) => { if (shuttingDown) { return; } const status = signal !== null ? `因信号 ${signal} 退出` : `退出码 ${code ?? "null"}`; logPrefix(task.name, `进程结束(${status}),准备清理其它任务。`); shutdown(code ?? 0); }); child.on("error", (err) => { logPrefix(task.name, `启动失败:${err.message}`); shutdown(1); }); children.push(child); } function schedulePiLabWarmup(frontendUrl) { if (!isEnabledEnv(mergedEnv.MNOTE_PAGE_AI_PI_WARMUP)) return; const warmupEnv = { ...mergedEnv, MNOTE_PAGE_AI_PI_WARMUP_BASE_URL: mergedEnv.MNOTE_PAGE_AI_PI_WARMUP_BASE_URL || frontendUrl, }; runPiLabWarmup(warmupEnv, { rootDir, log: (message) => logPrefix("pi-warmup", message), }).then((result) => { if (result.skipped) { logPrefix("pi-warmup", `已跳过:${result.reason || "disabled"}`); return; } logPrefix( "pi-warmup", `完成:session=${result.sessionId} send=${result.sendEnabled ? "1" : "0"} elapsedMs=${result.elapsedMs}`, ); }).catch((error) => { logPrefix("pi-warmup", `失败但不阻塞 dev-hot:${error.message}`); }); } function shutdown(code) { if (shuttingDown) { return; } shuttingDown = true; logPrefix("system", "收到终止信号,正在关闭所有子进程…"); for (const child of children) { if (!child.pid) continue; // Windows 下,shell 子进程常常无法可靠传播 SIGINT/SIGTERM 到孙进程(例如 uvicorn --reload)。 // 这里优先用 taskkill /T /F 确保整个进程树被结束,避免残留占用端口导致下次启动失败。 if (process.platform === "win32") { try { execSync(`taskkill /PID ${child.pid} /T /F`, { stdio: "ignore" }); continue; } catch { // fallback to signals } } if (!child.killed) { child.kill("SIGINT"); setTimeout(() => { if (!child.killed) { child.kill("SIGTERM"); } }, 5000); } } setTimeout(() => process.exit(code), 200); } process.on("SIGINT", () => shutdown(0)); process.on("SIGTERM", () => shutdown(0)); async function main() { // 说明:你外网绑定了 3000 端口,这里默认强制使用 3000。 // 如果检测到 3000 被占用,则自动结束旧进程后重启,以保证始终跑在 3000。 const desiredFrontendPort = runtimePlan.publicPort; const frontendOwnerName = "mnote-web"; await stopStaleMnoteWebCargoProcesses(); const frontendPortOk = await ensurePortFree(desiredFrontendPort, frontendOwnerName); if (!frontendPortOk) { console.error(`前端端口 ${desiredFrontendPort} 无法释放,已中止启动。`); process.exit(1); } const frontendPort = desiredFrontendPort; const frontendUrl = `http://localhost:${frontendPort}`; if (skipMnoteWebGateway) { logPrefix("frontend", `前端地址:${frontendUrl}`); } else { logPrefix("mnote-web", `Rust gateway 公开入口:${frontendUrl}`); const gatewayTask = tasks.find((task) => task.name === "mnote-web"); if (gatewayTask) { gatewayTask.command = runtimePlan.mnoteWebCommand; } Object.assign(mergedEnv, runtimePlan.mnoteWebEnv); } const desiredBackendPort = backendPortFromEnv; if (shouldStartBackend(process.env) && !process.env.BACKEND_CMD) { const backendPortOk = await ensurePortFree(desiredBackendPort, "backend"); if (!backendPortOk) { console.error(`后端端口 ${desiredBackendPort} 无法释放,已中止启动。`); process.exit(1); } const backendTask = findTask("backend"); if (!backendTask) { throw new Error("缺少后端任务配置"); } backendTask.command = buildDefaultBackendCommand(desiredBackendPort); } else if (isEnabledEnv(process.env.SKIP_BACKEND)) { logPrefix("backend", "已跳过 FastAPI 后端(SKIP_BACKEND=1)。"); } else if (!shouldStartBackend(process.env)) { } const desiredOpencodePort = opencodePortFromEnv; if (shouldStartOpencode(process.env) && !process.env.OPENCODE_CMD) { const opencodePortOk = await ensurePortFree(desiredOpencodePort, "opencode"); if (!opencodePortOk) { console.error(`opencode 端口 ${desiredOpencodePort} 无法释放,已中止启动。`); process.exit(1); } const opencodeTask = findTask("opencode"); if (!opencodeTask) { throw new Error("缺少 opencode 任务配置"); } opencodeTask.command = buildDefaultOpencodeCommand(desiredOpencodePort); } else if (isEnabledEnv(process.env.SKIP_OPENCODE)) { logPrefix("opencode", "已跳过 opencode(SKIP_OPENCODE=1)。"); } else if (!shouldStartOpencode(process.env)) { } if (shouldStartOpenHub(process.env) && !process.env.OPENHUB_BACKEND_CMD && !process.env.OPENHUB_CMD) { const openhubPortOk = await ensurePortFree(openhubPortFromEnv, "openhub"); if (!openhubPortOk) { console.error(`OpenHub 端口 ${openhubPortFromEnv} 无法释放,已中止启动。`); process.exit(1); } const openhubTask = findTask("openhub"); if (!openhubTask) { throw new Error("缺少 OpenHub 任务配置"); } openhubTask.command = buildDefaultOpenHubCommand(openhubPortFromEnv); } else if (isEnabledEnv(process.env.SKIP_OPENHUB)) { logPrefix("openhub", "已跳过 OpenHub FastAPI(SKIP_OPENHUB=1)。"); } const healthOk = await checkOpenHubHealth(resolveOpenHubHealthPlan(process.env)); if (!healthOk) { console.error("OpenHub health 预检失败,已中止启动。"); process.exit(1); } if (tasks.length === 0) { console.error("未配置任何可运行的任务,检查环境变量设置。"); process.exit(1); } for (const task of tasks) { startTask(task); } schedulePiLabWarmup(frontendUrl); } if (require.main === module) { main().catch((error) => { logPrefix("system", `启动失败:${error.message}`); process.exit(1); }); } module.exports = { buildDefaultOpencodeCommand, buildDefaultOpenHubCommand, checkOpenHubHealth, collectStaleMnoteWebCargoPids, ensurePortFree, getListeningPidsByPort, getProcessCommandByPid, getProcessGroupByPid, getProcessNameByPid, isHttpHealthy, isPortFree, resolveOpenHubHealthPlan, resolveRuntimePlan, resolveBackendExecutable, schedulePiLabWarmup, shouldStartBackend, shouldStartOpenHub, stopStaleMnoteWebCargoProcesses, terminatePid, };