Files
mnote/scripts/prod-build-start.js
T
Agent Board 2deaf59f7b feat: run mnote-web prod under systemd --user with auto-restart
Replace detached spawn with mnote-web-prod.service (Restart=always,
enable + Linger). prod:start installs the unit, writes env/current
symlink, and restarts via systemctl so the gateway survives kills and
logout. Also drop unused std::fs import in web_shell.
2026-07-25 14:56:42 +08:00

828 lines
25 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
"use strict";
/**
* mnote-web 生产构建与启动入口。
*
* 规则:
* - 只按已提交的 git HEAD 判断 release 是否已存在,未提交工作区改动不参与判定。
* - 新 release 从 0.0.1 开始递增,0.0.9 后是 0.1.0。
* - 每个 release 保留独立源码快照和二进制,不覆盖旧 release。
* - 默认启动端口是 3003,避免占用开发端口 3000。
* - 进程由 systemd --usermnote-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 未给出 MainPIDstatus:\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);
});
}