Files
mnote/scripts/desktop-prod.js
T
lix-2026 e8ba12e461 收口 Rust Web 入口与 AI 写入链
- 将 3000 主入口继续收口到 mnote-web,补齐 /favicon.ico、/api/auth、session alias、AI run 等 Rust Web 路由边界。

- 更新登录页与 Convex Auth 代理,支持测试账号快速登录写入真实 Convex Auth cookie。

- 推进页面设置、Wolai 对齐、Phase 7 AI kernel/CLI-first 设计文档与相关 smoke 脚本。

- 更新 leptos-tiptap 生成资产、mnote-cli/bridge-runtime、前端依赖和 dev/prod 启动脚本。
2026-05-06 21:44:20 +08:00

363 lines
12 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
/**
* 一键启动“生产模式”的前后端与相关服务(适用于 Cloudflare Tunnel / 公网访问)。
*
* 目标:
* - 前后端与内部服务统一读取仓库根目录 `.env.all`(生产优先,全局唯一 env 文件)
*
* 可选环境变量:
* - SKIP_FRONTEND_BUILD=1:跳过前端 build(仅 start
* - FRONTEND_PORT:默认 3000
* - BACKEND_PORT:默认 8000
* - RAG_GATEWAY_PORT:默认 8778
* - INGEST_PORT:默认 8779
* - PYTHON_BIN:默认 python
* - CELERY_BIN:默认 celery
* - ENABLE_CELERY=1:启用默认 Celery worker
* - CELERY_CMD:覆盖 Celery 启动命令;设置后即视为显式启用 Celery
* - SKIP_CELERY=1:强制跳过 Celery worker
* - REDIS_URL:用于探测 Redis,默认 redis://127.0.0.1:6379/0
*/
const { spawn } = require("child_process");
const fs = require("fs");
const net = require("net");
const path = require("path");
const { URL } = require("url");
const rootDir = path.resolve(__dirname, "..");
const frontendDir = path.join(rootDir, "wolai-frontend");
const backendDir = path.join(rootDir, "wolai-backend");
const ingestDir = path.join(rootDir, "services", "ingest_service");
const ragGatewayDir = path.join(rootDir, "services", "rag_gateway");
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 celeryBin = resolveBackendExecutable("CELERY_BIN", "celery");
const celeryCmdFromEnv = (process.env.CELERY_CMD || "").trim();
const redisUrl = process.env.REDIS_URL || "redis://127.0.0.1:6379/0";
const frontendPort = Number(process.env.FRONTEND_PORT || 3000);
const backendPort = Number(process.env.BACKEND_PORT || 8000);
const ragGatewayPort = Number(process.env.RAG_GATEWAY_PORT || 8778);
const ingestPort = Number(process.env.INGEST_PORT || 8779);
const children = [];
let shuttingDown = false;
function logPrefix(name, message) {
console.log(`[${name}] ${message}`);
}
function isEnabledEnv(value) {
const normalized = String(value || "").toLowerCase();
return normalized === "1" || normalized === "true";
}
function shouldStartCelery(env = process.env) {
if (isEnabledEnv(env.SKIP_CELERY)) return false;
if (String(env.CELERY_CMD || "").trim()) return true;
return isEnabledEnv(env.ENABLE_CELERY);
}
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;
}, {});
}
async function waitForExit(child, name) {
return await new Promise((resolve) => {
child.on("exit", (code, signal) => {
const status = signal ? `信号 ${signal}` : `退出码 ${code ?? "null"}`;
logPrefix(name, `进程结束(${status}`);
resolve(code ?? 0);
});
child.on("error", () => resolve(1));
});
}
function spawnTask({ name, command, cwd, env }) {
logPrefix(name, `启动命令:${command}`);
const child = spawn(command, {
cwd,
stdio: "inherit",
shell: true,
env,
});
child.on("exit", (code, signal) => {
if (shuttingDown) return;
const status = signal !== null ? `因信号 ${signal} 退出` : `退出码 ${code ?? "null"}`;
logPrefix(name, `进程结束(${status}),准备清理其它任务。`);
shutdown(code ?? 0);
});
child.on("error", (err) => {
logPrefix(name, `启动失败:${err.message}`);
shutdown(1);
});
children.push(child);
return child;
}
function shutdown(code) {
if (shuttingDown) return;
shuttingDown = true;
logPrefix("system", "收到终止信号,正在关闭所有子进程…");
for (const child of children) {
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 checkPortOpen(host, port, timeoutMs = 1200) {
return await new Promise((resolve) => {
const socket = net.createConnection({ host, port });
const timer = setTimeout(() => {
socket.destroy();
resolve(false);
}, timeoutMs);
socket.once("connect", () => {
clearTimeout(timer);
socket.end();
resolve(true);
});
socket.once("error", () => {
clearTimeout(timer);
resolve(false);
});
});
}
async function checkRedisReachable(urlString, timeoutMs = 2000) {
try {
const url = new URL(urlString);
const host = url.hostname || "127.0.0.1";
const port = Number(url.port) || 6379;
return await checkPortOpen(host, port, timeoutMs);
} catch (error) {
logPrefix("celery", `REDIS_URL (${urlString}) 解析失败:${error.message},跳过连通性检查。`);
return true;
}
}
function requireFile(filePath, hint) {
if (!fs.existsSync(filePath)) {
console.error(`[system] 缺少文件:${filePath}`);
if (hint) console.error(`[system] 提示:${hint}`);
process.exit(1);
}
}
function ensureStandaloneStatic(frontendDir) {
// 说明:当 next.config.ts 配置了 output: "standalone" 时,推荐使用
// `node .next/standalone/server.js` 启动。
// 但 Next 不会自动把 `.next/static` 放入 `.next/standalone/.next/static`
// 若缺失会导致 `/_next/static/*` 404(远程访问尤其明显)。
const standaloneServer = path.join(frontendDir, ".next", "standalone", "server.js");
if (!fs.existsSync(standaloneServer)) return false;
const src = path.join(frontendDir, ".next", "static");
const dest = path.join(frontendDir, ".next", "standalone", ".next", "static");
if (!fs.existsSync(src)) return true;
try {
fs.mkdirSync(path.dirname(dest), { recursive: true });
// Node 16+fs.cpSync
fs.cpSync(src, dest, { recursive: true });
return true;
} catch (err) {
logPrefix(
"frontend",
`复制 .next/static 到 standalone 失败:${err instanceof Error ? err.message : String(err)}`,
);
// 失败时不强制退出,仍尝试启动(方便你自行排查)
return true;
}
}
async function main() {
// 约定:全局仅使用仓库根目录的 .env.all 作为配置来源(生产优先)。
const envAllPath = path.join(rootDir, ".env.all");
requireFile(envAllPath, "请先在仓库根目录创建 .env.all(全局唯一 env 文件)");
const envAll = loadEnvFile(envAllPath);
// 生产模式:前端必须使用 production env,避免本地开发配置污染公网用户
const envFrontend = {
...process.env,
...envAll,
NODE_ENV: "production",
NEXT_TELEMETRY_DISABLED: "1",
};
// 后端/内部服务:尽量使用本机/内网配置(不会暴露给浏览器)
const envBackend = {
...process.env,
...envAll,
PYTHONUTF8: "1",
};
const envInternalServices = {
...process.env,
...envAll,
PYTHONUTF8: "1",
};
const shouldSkipBuild =
(process.env.SKIP_FRONTEND_BUILD || "").toLowerCase() === "1" ||
(process.env.SKIP_FRONTEND_BUILD || "").toLowerCase() === "true";
if (!shouldSkipBuild) {
logPrefix("frontend", "开始 production build…");
const build = spawn("pnpm -C wolai-frontend build", {
cwd: rootDir,
stdio: "inherit",
shell: true,
env: envFrontend,
});
const code = await waitForExit(build, "frontend");
if (code !== 0) {
process.exit(code);
}
} else {
logPrefix("frontend", "已跳过 buildSKIP_FRONTEND_BUILD=1");
}
const useStandalone = ensureStandaloneStatic(frontendDir);
// 预检查:提醒必要端口是否已就绪(不强制退出,避免误伤已有进程)
const portChecks = [
{ name: "frontend", port: frontendPort },
{ name: "backend", port: backendPort },
{ name: "rag_gateway", port: ragGatewayPort },
{ name: "ingest_service", port: ingestPort },
];
for (const item of portChecks) {
// 仅检查本机 127.0.0.1 是否已有服务在监听,方便你快速判断“是否重复启动”
// 监听着不代表冲突:可能是你已经手动启动了。
// eslint-disable-next-line no-await-in-loop
const open = await checkPortOpen("127.0.0.1", item.port);
if (open) {
logPrefix("system", `检测到端口 ${item.port} 已有服务监听(${item.name}),若你准备全量启动,请确认不会端口冲突。`);
}
}
// 启动前端(production
const envFrontendServer = {
...envFrontend,
PORT: String(frontendPort),
HOSTNAME: "0.0.0.0",
};
spawnTask({
name: "frontend",
// 说明:next.config.ts 配置了 output: "standalone" 时,应使用 standalone server 启动,避免 next start 行为不一致。
command: useStandalone
? "node .next/standalone/server.js"
: `pnpm -C wolai-frontend exec next start -p ${frontendPort} -H 0.0.0.0`,
cwd: useStandalone ? frontendDir : rootDir,
env: envFrontendServer,
});
// 启动 wolai-backendFastAPI
spawnTask({
name: "backend",
command: `${pythonBin} -m uvicorn app.main:app --host 0.0.0.0 --port ${backendPort}`,
cwd: backendDir,
env: envBackend,
});
// Celery(可选)
if (shouldStartCelery(process.env)) {
const ok = await checkRedisReachable(envBackend.REDIS_URL || redisUrl);
if (ok) {
spawnTask({
name: "celery",
command: celeryCmdFromEnv || `${celeryBin} -A app.workers.celery_app worker --loglevel=info`,
cwd: backendDir,
env: envBackend,
});
} else {
logPrefix("celery", `检测到 Redis 不可达(${envBackend.REDIS_URL || redisUrl}),跳过 Celery。`);
}
} else if (isEnabledEnv(process.env.SKIP_CELERY)) {
logPrefix("celery", "已跳过 CelerySKIP_CELERY=1");
} else {
logPrefix("celery", "默认不启动 Celery;当前主线页面不依赖 Redis/Celery。如需启用请设置 ENABLE_CELERY=1 或 CELERY_CMD。");
}
// 启动 ingest_service(自动入库、OCR 触发、延迟删除清理)
spawnTask({
name: "ingest",
command: `${pythonBin} -m uvicorn app.main:app --host 0.0.0.0 --port ${ingestPort}`,
cwd: ingestDir,
env: envInternalServices,
});
// 启动 rag_gateway/rag 查询网关)
spawnTask({
name: "rag_gateway",
command: `${pythonBin} -m uvicorn app.main:app --host 0.0.0.0 --port ${ragGatewayPort}`,
cwd: ragGatewayDir,
env: envInternalServices,
});
logPrefix(
"system",
`已启动:frontend:${frontendPort} backend:${backendPort} ingest:${ingestPort} rag_gateway:${ragGatewayPort}(如需公网访问,请确保 cloudflared tunnel 正在运行)`,
);
}
if (require.main === module) {
main().catch((error) => {
console.error(`[system] 启动失败:${error instanceof Error ? error.message : String(error)}`);
process.exit(1);
});
}
module.exports = {
shouldStartCelery,
};