#!/usr/bin/env node "use strict"; /** * mnote-web 生产构建与启动入口。 * * 规则: * - 只按已提交的 git HEAD 判断 release 是否已存在,未提交工作区改动不参与判定。 * - 新 release 从 0.0.1 开始递增,0.0.9 后是 0.1.0。 * - 每个 release 保留独立源码快照和二进制,不覆盖旧 release。 * - 默认启动端口是 3003,避免占用开发端口 3000。 * - 进程由 systemd --user(mnote-web-prod.service)托管:Restart=always + enable, * 本机需 loginctl Linger=yes(已配置则登出后仍运行)。 * - 管理:systemctl --user status|restart|stop mnote-web-prod.service * 日志:journalctl --user -u mnote-web-prod.service -f */ const fs = require("node:fs"); const fsp = require("node:fs/promises"); const http = require("node:http"); const os = require("node:os"); const path = require("node:path"); const { spawn, spawnSync, execFileSync } = require("node:child_process"); const ROOT = path.resolve(__dirname, ".."); const RELEASE_ROOT = path.join(ROOT, "dist", "releases", "mnote-web"); const BUILD_CACHE_DIR = path.join(ROOT, "dist", "prod-build-cache", "mnote-web"); const RUN_DIR = path.join(ROOT, "dist", "run", "mnote-web-prod"); const RUN_STATE_PATH = path.join(RUN_DIR, "process.json"); const RUN_ENV_PATH = path.join(RUN_DIR, "env"); const RUN_CURRENT_LINK = path.join(RUN_DIR, "current"); const SYSTEMD_UNIT_NAME = "mnote-web-prod.service"; const SYSTEMD_UNIT_SOURCE = path.join(ROOT, "deploy", "systemd", "mnote-web-prod.service"); const SYSTEMD_USER_DIR = path.join(os.homedir(), ".config", "systemd", "user"); const SYSTEMD_UNIT_TARGET = path.join(SYSTEMD_USER_DIR, SYSTEMD_UNIT_NAME); const DEFAULT_PORT = 3003; const DEFAULT_TURSO_LOCAL_PATH = "/mnt/Data1T/Mnote_data/control-plane/control-plane-prod-libsql.db"; const DEFAULT_CONTROL_PLANE_BACKEND = "libsql-local"; const ENV_ALL_PATH = path.join(ROOT, ".env.all"); const RELEASE_BUILD_SCHEMA = "mnote-web-prod-runtime-assets-v2"; // 不写入 systemd EnvironmentFile 的敏感/会话壳变量 const ENV_FILE_DENYLIST = new Set([ "DBUS_SESSION_BUS_ADDRESS", "DISPLAY", "SSH_AUTH_SOCK", "SSH_CLIENT", "SSH_CONNECTION", "SSH_TTY", "XAUTHORITY", "WAYLAND_DISPLAY", "GPG_AGENT_INFO", "OLDPWD", "PWD", "SHLVL", "_", "npm_config_user_agent", "npm_lifecycle_event", "npm_lifecycle_script", "npm_package_name", "npm_package_version", "npm_command", "npm_execpath", "npm_node_execpath", "INIT_CWD", "COLOR", "COLORTERM", "TERM", "TERM_PROGRAM", "TERM_PROGRAM_VERSION", ]); const SUPPLEMENTAL_RUNTIME_ASSETS = [ { source: "node_modules/jszip/dist/jszip.min.js", target: "node_modules/jszip/dist/jszip.min.js", }, { source: "node_modules/docx-preview/dist/docx-preview.min.js", target: "node_modules/docx-preview/dist/docx-preview.min.js", }, { source: "node_modules/@e965/xlsx/dist/xlsx.full.min.js", target: "node_modules/@e965/xlsx/dist/xlsx.full.min.js", }, { source: "node_modules/pptx-preview/dist/pptx-preview.umd.js", target: "node_modules/pptx-preview/dist/pptx-preview.umd.js", }, { source: "node_modules/pdfjs-dist/build/pdf.mjs", target: "node_modules/pdfjs-dist/build/pdf.mjs", }, { source: "node_modules/pdfjs-dist/build/pdf.worker.mjs", target: "node_modules/pdfjs-dist/build/pdf.worker.mjs", }, { source: "reference-code/leptos-tiptap/src/js/generated/tiptap_mindmap_paragraph_runtime.js", target: "reference-code/leptos-tiptap/src/js/generated/tiptap_mindmap_paragraph_runtime.js", }, ]; const STARTUP_ASSET_PROBES = [ "/api/office-preview/vendor/docx-preview.min.js", "/api/office-preview/vendor/xlsx.full.min.js", "/api/office-preview/vendor/pptx-preview.umd.js", "/api/pdfjs/pdf.mjs", "/api/pdfjs/pdf.worker.mjs", "/api/leptos-tiptap-runtime/tiptap_mindmap_paragraph_runtime.js", ]; function loadEnvFile(filePath) { if (!fs.existsSync(filePath)) return {}; const content = fs.readFileSync(filePath, "utf8"); return content .split(/\r?\n/) .map((line) => line.trim()) .filter((line) => line && !line.startsWith("#")) .reduce((acc, line) => { const normalized = line.startsWith("export ") ? line.slice("export ".length).trim() : line; const idx = normalized.indexOf("="); if (idx === -1) return acc; const key = normalized.slice(0, idx).trim(); let value = normalized.slice(idx + 1).trim(); if (!key) return acc; if ( (value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'")) ) { value = value.slice(1, -1); } acc[key] = value; return acc; }, {}); } function loadProdEnv() { const envFromAll = loadEnvFile(ENV_ALL_PATH); if (Object.keys(envFromAll).length > 0) { Object.assign(process.env, envFromAll); console.log(`[prod] 已加载 .env.all (${Object.keys(envFromAll).length} 项)`); } } function runChecked(command, args, options = {}) { const result = spawnSync(command, args, { cwd: ROOT, env: process.env, encoding: "utf8", stdio: options.stdio || "pipe", ...options, }); if (result.error) { throw result.error; } if (result.status !== 0) { const stderr = result.stderr ? `\n${result.stderr.trim()}` : ""; throw new Error(`${command} ${args.join(" ")} failed with code ${result.status}${stderr}`); } return result.stdout || ""; } function currentGitHead() { return runChecked("git", ["rev-parse", "HEAD"]).trim(); } function parseVersion(version) { const match = String(version || "").match(/^(\d+)\.(\d+)\.(\d+)$/); if (!match) return null; return match.slice(1).map((part) => Number(part)); } function compareVersion(a, b) { const av = parseVersion(a); const bv = parseVersion(b); if (!av || !bv) return 0; for (let i = 0; i < 3; i += 1) { if (av[i] !== bv[i]) return av[i] - bv[i]; } return 0; } function nextVersionAfter(version) { if (!version) return "0.0.1"; let [major, minor, patch] = parseVersion(version) || [0, 0, 0]; patch += 1; if (patch > 9) { patch = 0; minor += 1; } if (minor > 9) { minor = 0; major += 1; } return `${major}.${minor}.${patch}`; } async function pathExists(filePath) { try { await fsp.access(filePath); return true; } catch { return false; } } async function releaseRuntimeAssetsReady(sourceDir) { for (const asset of SUPPLEMENTAL_RUNTIME_ASSETS) { if (!(await pathExists(path.join(sourceDir, asset.target)))) { return false; } } return true; } async function listReleases() { if (!(await pathExists(RELEASE_ROOT))) return []; const entries = await fsp.readdir(RELEASE_ROOT, { withFileTypes: true }); const releases = []; for (const entry of entries) { if (!entry.isDirectory() || !parseVersion(entry.name)) continue; const releaseDir = path.join(RELEASE_ROOT, entry.name); const metadataPath = path.join(releaseDir, "metadata.json"); if (!(await pathExists(metadataPath))) { releases.push({ version: entry.name, releaseDir, metadata: null, usable: false }); continue; } try { const metadata = JSON.parse(await fsp.readFile(metadataPath, "utf8")); const binaryPath = path.join(releaseDir, "mnote-web"); const sourceDir = path.join(releaseDir, "source"); const usable = (await pathExists(binaryPath)) && (await pathExists(sourceDir)) && metadata.buildSchema === RELEASE_BUILD_SCHEMA && (await releaseRuntimeAssetsReady(sourceDir)); releases.push({ version: entry.name, releaseDir, metadata, usable }); } catch { releases.push({ version: entry.name, releaseDir, metadata: null, usable: false }); } } releases.sort((a, b) => compareVersion(a.version, b.version)); return releases; } async function findReleaseForHead(head) { const releases = await listReleases(); return releases.find((release) => release.usable && release.metadata?.gitHead === head) || null; } async function nextReleaseVersion() { const releases = await listReleases(); let version = releases.length > 0 ? releases[releases.length - 1].version : null; let next = nextVersionAfter(version); while (await pathExists(path.join(RELEASE_ROOT, next))) { next = nextVersionAfter(next); } return next; } function pipeGitArchive(head, destination) { return new Promise((resolve, reject) => { const git = spawn("git", ["archive", "--format=tar", head], { cwd: ROOT, stdio: ["ignore", "pipe", "inherit"], }); const tar = spawn("tar", ["-x", "-C", destination], { cwd: ROOT, stdio: ["pipe", "inherit", "inherit"], }); git.stdout.pipe(tar.stdin); let gitCode = null; let tarCode = null; const maybeDone = () => { if (gitCode === null || tarCode === null) return; if (gitCode !== 0) { reject(new Error(`git archive failed with code ${gitCode}`)); return; } if (tarCode !== 0) { reject(new Error(`tar extract failed with code ${tarCode}`)); return; } resolve(); }; git.on("error", reject); tar.on("error", reject); git.on("close", (code) => { gitCode = code; maybeDone(); }); tar.on("close", (code) => { tarCode = code; maybeDone(); }); }); } async function copyRuntimeAsset(sourceDir, asset) { const from = path.join(ROOT, asset.source); const to = path.join(sourceDir, asset.target); if (!(await pathExists(from))) { throw new Error(`缺少生产运行时资产:${asset.source},请先在仓库根目录运行 npm install`); } await fsp.mkdir(path.dirname(to), { recursive: true }); await fsp.copyFile(from, to); } async function copySupplementalRuntimeAssets(sourceDir) { console.log("[prod] 打包 Office/PDF/思维导图运行时资产 ..."); for (const asset of SUPPLEMENTAL_RUNTIME_ASSETS) { await copyRuntimeAsset(sourceDir, asset); } } async function buildRelease(head) { const version = await nextReleaseVersion(); const releaseDir = path.join(RELEASE_ROOT, version); const sourceDir = path.join(releaseDir, "source"); const binaryPath = path.join(releaseDir, "mnote-web"); const metadataPath = path.join(releaseDir, "metadata.json"); await fsp.mkdir(sourceDir, { recursive: true }); console.log(`[prod] 创建 release ${version}: ${releaseDir}`); await pipeGitArchive(head, sourceDir); await copySupplementalRuntimeAssets(sourceDir); const targetDir = path.join(BUILD_CACHE_DIR, version, "target"); await fsp.mkdir(targetDir, { recursive: true }); console.log(`[prod] 构建已提交 HEAD ${head.slice(0, 12)} ...`); runChecked( "cargo", ["build", "--manifest-path", "rust/Cargo.toml", "-p", "mnote-web", "--bin", "mnote-web", "--release"], { cwd: sourceDir, env: { ...process.env, CARGO_TARGET_DIR: targetDir, }, stdio: "inherit", }, ); const builtBinary = path.join(targetDir, "release", "mnote-web"); await fsp.copyFile(builtBinary, binaryPath); await fsp.chmod(binaryPath, 0o755); const metadata = { schema: "mnote.prod_release.v1", buildSchema: RELEASE_BUILD_SCHEMA, version, gitHead: head, builtAt: new Date().toISOString(), binaryPath, sourceDir, cargoTargetDir: targetDir, runtimeAssets: SUPPLEMENTAL_RUNTIME_ASSETS.map((asset) => asset.target), }; await fsp.writeFile(metadataPath, `${JSON.stringify(metadata, null, 2)}\n`, "utf8"); await fsp.rm(targetDir, { recursive: true, force: true }); return { version, releaseDir, metadata, usable: true }; } function readJsonIfExists(filePath) { try { return JSON.parse(fs.readFileSync(filePath, "utf8")); } catch { return null; } } function isProcessAlive(pid) { if (!Number.isInteger(pid) || pid <= 0) return false; try { process.kill(pid, 0); return true; } catch { return false; } } function processCommandLine(pid) { try { return fs.readFileSync(`/proc/${pid}/cmdline`, "utf8").replace(/\0/g, " ").trim(); } catch { return ""; } } function sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } function isPrivateLanAddress(address) { if (/^10\./.test(address)) return true; if (/^192\.168\./.test(address)) return true; const match = address.match(/^172\.(\d+)\./); if (!match) return false; const second = Number(match[1]); return second >= 16 && second <= 31; } function lanAddresses() { const ignoredInterface = /^(docker|br-|veth|virbr|tailscale|zt|lo)/; const addresses = []; for (const [name, entries] of Object.entries(os.networkInterfaces())) { if (ignoredInterface.test(name)) continue; for (const entry of entries || []) { if (entry.family !== "IPv4" || entry.internal) continue; if (!isPrivateLanAddress(entry.address)) continue; addresses.push(entry.address); } } return [...new Set(addresses)]; } function defaultPublicBind(port) { return `${lanAddresses()[0] || "127.0.0.1"}:${port}`; } async function stopProcess(pid) { if (!isProcessAlive(pid)) return; console.log(`[prod] 停止旧进程 pid=${pid}`); process.kill(pid, "SIGTERM"); for (let i = 0; i < 40; i += 1) { if (!isProcessAlive(pid)) return; await sleep(250); } if (isProcessAlive(pid)) { console.log(`[prod] 旧进程未及时退出,发送 SIGKILL pid=${pid}`); process.kill(pid, "SIGKILL"); } } function isManagedMnoteProcess(pid, expectedBinaryPath = "") { const commandLine = processCommandLine(pid); if (!commandLine) return false; if (expectedBinaryPath && commandLine.includes(expectedBinaryPath)) return true; return commandLine.includes(RELEASE_ROOT) || /\bmnote-web\b/.test(commandLine); } function pidsListeningOnPort(port) { const pids = new Set(); try { const out = execFileSync("ss", ["-ltnp", `sport = :${port}`], { encoding: "utf8" }); for (const match of out.matchAll(/pid=(\d+)/g)) { pids.add(Number(match[1])); } } catch { // ignore } if (pids.size > 0) return [...pids]; try { const out = execFileSync("lsof", ["-nP", `-iTCP:${port}`, "-sTCP:LISTEN", "-t"], { encoding: "utf8" }); for (const line of out.split(/\r?\n/)) { const pid = Number(line.trim()); if (Number.isInteger(pid) && pid > 0) pids.add(pid); } } catch { // ignore } return [...pids]; } async function stopPreviousProcess(port) { const state = readJsonIfExists(RUN_STATE_PATH); if (state?.pid) { const pid = Number(state.pid); if (isManagedMnoteProcess(pid, state.binaryPath || "")) { await stopProcess(pid); } } for (const pid of pidsListeningOnPort(port)) { if (isManagedMnoteProcess(pid)) { await stopProcess(pid); continue; } const commandLine = processCommandLine(pid); throw new Error(`端口 ${port} 已被非 mnote-web 进程占用: pid=${pid} ${commandLine}`); } } function requestLocalAuth(port) { return new Promise((resolve) => { const req = http.get( { host: "127.0.0.1", port, path: "/auth", timeout: 1000, }, (res) => { res.resume(); resolve(res.statusCode && res.statusCode < 500); }, ); req.on("timeout", () => { req.destroy(); resolve(false); }); req.on("error", () => resolve(false)); }); } function requestLocalPath(port, requestPath) { return new Promise((resolve) => { const req = http.get( { host: "127.0.0.1", port, path: requestPath, timeout: 1000, }, (res) => { res.resume(); resolve(res.statusCode && res.statusCode >= 200 && res.statusCode < 300); }, ); req.on("timeout", () => { req.destroy(); resolve(false); }); req.on("error", () => resolve(false)); }); } async function waitForReady(port, logPath, pid) { for (let i = 0; i < 60; i += 1) { if (!isProcessAlive(pid)) break; if (await requestLocalAuth(port)) return; await sleep(250); } let logTail = ""; try { const content = await fsp.readFile(logPath, "utf8"); logTail = content.split(/\r?\n/).slice(-40).join(os.EOL); } catch { // ignore } throw new Error(`mnote-web 启动失败或未就绪,日志:\n${logTail}`); } async function verifyStartupAssets(port) { const failed = []; for (const requestPath of STARTUP_ASSET_PROBES) { // eslint-disable-next-line no-await-in-loop const ok = await requestLocalPath(port, requestPath); if (!ok) failed.push(requestPath); } if (failed.length > 0) { throw new Error(`生产运行时资产探测失败:${failed.join(", ")}`); } } function escapeEnvFileValue(value) { const text = String(value ?? ""); // systemd EnvironmentFile:含空白/引号/反斜杠时用双引号并转义 if (/[\s"'\\$`]/.test(text) || text === "") { return `"${text.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\$/g, "\\$")}"`; } return text; } function buildProdRuntimeEnv(port, bind, publicBind, controlPlaneBackend) { // 只落 .env.all + 生产覆盖,不把整份 shell/npm 环境写进 systemd const env = { ...loadEnvFile(ENV_ALL_PATH), }; // 允许调用方在进程环境里临时覆盖关键项(如 MNOTE_PROD_PORT) const passThroughKeys = [ "MNOTE_PROD_PORT", "MNOTE_WEB_BIND", "MNOTE_WEB_PUBLIC_BIND", "MNOTE_CONTROL_PLANE_BACKEND", "MNOTE_TURSO_LOCAL_PATH", "MNOTE_TURSO_URL", "MNOTE_TURSO_AUTH_TOKEN", "MNOTE_TURSO_SYNC_URL", "RUST_LOG", "MNOTE_LIGHTRAG_API_KEY", "MNOTE_LIGHTRAG_BASE_URL", "LIGHTRAG_URL", "LIGHTRAG_API_KEY", ]; for (const key of passThroughKeys) { if (process.env[key] !== undefined && process.env[key] !== "") { env[key] = process.env[key]; } } env.MNOTE_WEB_BIND = bind; env.MNOTE_WEB_PUBLIC_BIND = publicBind; env.MNOTE_CONTROL_PLANE_BACKEND = controlPlaneBackend; env.RUST_LOG = env.RUST_LOG || process.env.RUST_LOG || "info"; if ( controlPlaneBackend === "libsql-local" || controlPlaneBackend === "turso-local" || controlPlaneBackend === "turso" ) { env.MNOTE_TURSO_LOCAL_PATH = env.MNOTE_TURSO_LOCAL_PATH || process.env.MNOTE_TURSO_LOCAL_PATH || DEFAULT_TURSO_LOCAL_PATH; } env.MNOTE_PROD_PORT = String(port); // PATH 给子进程一个干净可预期值(不依赖 npm 注入的 node_modules/.bin) env.PATH = [ path.join(os.homedir(), ".local", "bin"), path.join(os.homedir(), ".cargo", "bin"), path.join(os.homedir(), ".npm-global", "bin"), "/usr/local/bin", "/usr/bin", "/bin", ].join(":"); env.HOME = os.homedir(); env.LANG = process.env.LANG || "C.UTF-8"; return env; } async function writeSystemdEnvFile(env) { const lines = [ "# Generated by scripts/prod-build-start.js — do not edit by hand", `# generatedAt=${new Date().toISOString()}`, ]; const keys = Object.keys(env).sort(); for (const key of keys) { if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) continue; if (ENV_FILE_DENYLIST.has(key)) continue; if (key.startsWith("npm_") || key.startsWith("NPM_")) continue; const value = env[key]; if (value === undefined || value === null) continue; // 跳过多行值,避免 EnvironmentFile 解析失败 if (String(value).includes("\n") || String(value).includes("\r")) continue; lines.push(`${key}=${escapeEnvFileValue(value)}`); } await fsp.mkdir(RUN_DIR, { recursive: true }); await fsp.writeFile(RUN_ENV_PATH, `${lines.join("\n")}\n`, { encoding: "utf8", mode: 0o600 }); await fsp.chmod(RUN_ENV_PATH, 0o600); } async function installSystemdUserUnit() { if (!(await pathExists(SYSTEMD_UNIT_SOURCE))) { throw new Error(`缺少 systemd unit 模板: ${SYSTEMD_UNIT_SOURCE}`); } await fsp.mkdir(SYSTEMD_USER_DIR, { recursive: true }); const sourceText = await fsp.readFile(SYSTEMD_UNIT_SOURCE, "utf8"); let needWrite = true; if (await pathExists(SYSTEMD_UNIT_TARGET)) { const existing = await fsp.readFile(SYSTEMD_UNIT_TARGET, "utf8"); needWrite = existing !== sourceText; } if (needWrite) { await fsp.writeFile(SYSTEMD_UNIT_TARGET, sourceText, "utf8"); console.log(`[prod] 已安装 user unit: ${SYSTEMD_UNIT_TARGET}`); } runChecked("systemctl", ["--user", "daemon-reload"]); // enable 保证登录/linger 后自动拉起 runChecked("systemctl", ["--user", "enable", SYSTEMD_UNIT_NAME]); } async function pointCurrentRelease(releaseDir) { await fsp.mkdir(RUN_DIR, { recursive: true }); const tmpLink = `${RUN_CURRENT_LINK}.new-${process.pid}`; try { await fsp.unlink(tmpLink); } catch { // ignore } await fsp.symlink(releaseDir, tmpLink); // 原子替换 current → 新 release await fsp.rename(tmpLink, RUN_CURRENT_LINK); } function systemctlUser(args, options = {}) { return runChecked("systemctl", ["--user", ...args], options); } function systemctlUserStatusText() { try { return execFileSync("systemctl", ["--user", "status", SYSTEMD_UNIT_NAME, "--no-pager"], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], }); } catch (error) { return (error.stdout || "") + (error.stderr || "") || String(error.message || error); } } function mainPidFromSystemd() { try { const out = execFileSync( "systemctl", ["--user", "show", SYSTEMD_UNIT_NAME, "-p", "MainPID", "--value"], { encoding: "utf8" }, ).trim(); const pid = Number(out); return Number.isInteger(pid) && pid > 0 ? pid : null; } catch { return null; } } async function stopSystemdManagedService(port) { // 先停 systemd 托管(若已 enable) try { const active = execFileSync( "systemctl", ["--user", "is-active", SYSTEMD_UNIT_NAME], { encoding: "utf8" }, ).trim(); if (active === "active" || active === "activating" || active === "reloading") { console.log(`[prod] systemctl --user stop ${SYSTEMD_UNIT_NAME}`); systemctlUser(["stop", SYSTEMD_UNIT_NAME]); } } catch { // unit 未安装 / 未 active:忽略 } // 再清掉历史 detached 裸进程 / process.json await stopPreviousProcess(port); } async function startRelease(release) { const port = Number(process.env.MNOTE_PROD_PORT || DEFAULT_PORT); if (!Number.isInteger(port) || port <= 0 || port > 65535) { throw new Error(`MNOTE_PROD_PORT 非法: ${process.env.MNOTE_PROD_PORT}`); } await fsp.mkdir(RUN_DIR, { recursive: true }); const binaryPath = path.join(release.releaseDir, "mnote-web"); const logPath = path.join(release.releaseDir, "mnote-web.log"); const bind = process.env.MNOTE_WEB_BIND || `0.0.0.0:${port}`; const publicBind = process.env.MNOTE_WEB_PUBLIC_BIND || defaultPublicBind(port); const controlPlaneBackend = process.env.MNOTE_CONTROL_PLANE_BACKEND || DEFAULT_CONTROL_PLANE_BACKEND; if (controlPlaneBackend === "sqlite") { throw new Error("prod runtime 不再支持 SQLite control-plane fallback;请使用 libsql-local/turso-remote/turso-local-replica/turso-synced"); } if (!(await pathExists(binaryPath))) { throw new Error(`release 二进制不存在: ${binaryPath}`); } // 1) 停旧(systemd + 裸进程) await stopSystemdManagedService(port); // 2) 写 env / current 软链 / unit const runtimeEnv = buildProdRuntimeEnv(port, bind, publicBind, controlPlaneBackend); await writeSystemdEnvFile(runtimeEnv); await pointCurrentRelease(release.releaseDir); await installSystemdUserUnit(); // 3) 由 systemd 拉起(崩溃自动重启、linger 登录后自启) console.log(`[prod] systemctl --user restart ${SYSTEMD_UNIT_NAME}`); systemctlUser(["restart", SYSTEMD_UNIT_NAME]); // 等 MainPID 出现 let pid = null; for (let i = 0; i < 40; i += 1) { pid = mainPidFromSystemd(); if (pid) break; await sleep(250); } if (!pid) { throw new Error( `systemd 未给出 MainPID,status:\n${systemctlUserStatusText()}`, ); } const state = { schema: "mnote.prod_process.v2", managedBy: "systemd-user", unit: SYSTEMD_UNIT_NAME, pid, version: release.version, gitHead: release.metadata?.gitHead, startedAt: new Date().toISOString(), bind, publicBind, releaseDir: release.releaseDir, binaryPath, currentLink: RUN_CURRENT_LINK, envPath: RUN_ENV_PATH, logPath, journal: `journalctl --user -u ${SYSTEMD_UNIT_NAME} -f`, }; await fsp.writeFile(RUN_STATE_PATH, `${JSON.stringify(state, null, 2)}\n`, "utf8"); await waitForReady(port, logPath, pid); await verifyStartupAssets(port); console.log(`[prod] 已启动 mnote-web release ${release.version}(systemd --user)`); console.log(`[prod] unit=${SYSTEMD_UNIT_NAME} pid=${pid}`); console.log(`[prod] local URL=http://127.0.0.1:${port}`); for (const address of lanAddresses()) { console.log(`[prod] LAN URL=http://${address}:${port}`); } console.log(`[prod] journal: journalctl --user -u ${SYSTEMD_UNIT_NAME} -f`); console.log(`[prod] log file=${logPath}(历史;新日志优先 journal)`); console.log(`[prod] enable+linger: 开机/登出后仍保持运行(本机 Linger=yes)`); } async function main() { loadProdEnv(); const head = currentGitHead(); await fsp.mkdir(RELEASE_ROOT, { recursive: true }); let release = await findReleaseForHead(head); if (release) { console.log(`[prod] 当前 HEAD ${head.slice(0, 12)} 已包含在 release ${release.version},跳过 build`); } else { release = await buildRelease(head); } await startRelease(release); } if (require.main === module) { main().catch((error) => { console.error(`[prod] ${error.message}`); process.exit(1); }); }