Files
mnote/scripts/desktop-hot.js
T

764 lines
23 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env node
/**
* 热启动 mnote-web 单入口,以及按需启用的 FastAPI / opencode。
* 可使用以下环境变量调整行为:
* - 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
* - MNOTE_OPENCODE_XDG_ROOT / MNOTE_OPENCODE_HOMEopencode 专用运行目录
* - MNOTE_CONTROL_PLANE_BACKEND:控制面后端,默认 libsql-local;可设 turso-remote / turso-local-replica / turso-synced
* - MNOTE_TURSO_LOCAL_PATHlibsql-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 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.MNOTE_OPENCODE_XDG_ROOT || "/mnt/Data1T/Mnote_data/opencode/runtime";
const opencodeHome = process.env.MNOTE_OPENCODE_HOME || "/mnt/Data1T/Mnote_data/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 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 isEnabledEnv(env.ENABLE_OPENCODE);
}
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}`,
...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,
},
]
: []),
];
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 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;
}
}
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", "已跳过 opencodeSKIP_OPENCODE=1)。");
} else if (!shouldStartOpencode(process.env)) {
}
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,
collectStaleMnoteWebCargoPids,
ensurePortFree,
getListeningPidsByPort,
getProcessCommandByPid,
getProcessGroupByPid,
getProcessNameByPid,
isHttpHealthy,
isPortFree,
resolveRuntimePlan,
resolveBackendExecutable,
schedulePiLabWarmup,
shouldStartBackend,
shouldStartOpencode,
stopStaleMnoteWebCargoProcesses,
terminatePid,
};