889 lines
29 KiB
JavaScript
889 lines
29 KiB
JavaScript
#!/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
|
||
* - 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 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 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);
|
||
}
|
||
}
|
||
|
||
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,
|
||
shouldStartBackend,
|
||
shouldStartOpenHub,
|
||
stopStaleMnoteWebCargoProcesses,
|
||
terminatePid,
|
||
};
|