2026-06-05 23:00:53 +08:00
|
|
|
|
#!/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。
|
2026-07-25 14:56:42 +08:00
|
|
|
|
* - 进程由 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
|
2026-06-05 23:00:53 +08:00
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
|
|
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");
|
2026-07-25 14:56:42 +08:00
|
|
|
|
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);
|
2026-06-05 23:00:53 +08:00
|
|
|
|
const DEFAULT_PORT = 3003;
|
2026-07-03 23:20:16 +08:00
|
|
|
|
const DEFAULT_TURSO_LOCAL_PATH = "/mnt/Data1T/Mnote_data/control-plane/control-plane-prod-libsql.db";
|
|
|
|
|
|
const DEFAULT_CONTROL_PLANE_BACKEND = "libsql-local";
|
2026-06-06 00:38:54 +08:00
|
|
|
|
const ENV_ALL_PATH = path.join(ROOT, ".env.all");
|
|
|
|
|
|
const RELEASE_BUILD_SCHEMA = "mnote-web-prod-runtime-assets-v2";
|
2026-07-25 14:56:42 +08:00
|
|
|
|
// 不写入 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",
|
|
|
|
|
|
]);
|
2026-06-06 00:38:54 +08:00
|
|
|
|
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} 项)`);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-06-05 23:00:53 +08:00
|
|
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-06 00:38:54 +08:00
|
|
|
|
async function releaseRuntimeAssetsReady(sourceDir) {
|
|
|
|
|
|
for (const asset of SUPPLEMENTAL_RUNTIME_ASSETS) {
|
|
|
|
|
|
if (!(await pathExists(path.join(sourceDir, asset.target)))) {
|
|
|
|
|
|
return false;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return true;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-05 23:00:53 +08:00
|
|
|
|
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");
|
2026-06-06 00:38:54 +08:00
|
|
|
|
const usable = (await pathExists(binaryPath))
|
|
|
|
|
|
&& (await pathExists(sourceDir))
|
|
|
|
|
|
&& metadata.buildSchema === RELEASE_BUILD_SCHEMA
|
|
|
|
|
|
&& (await releaseRuntimeAssetsReady(sourceDir));
|
2026-06-05 23:00:53 +08:00
|
|
|
|
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();
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-06 00:38:54 +08:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-05 23:00:53 +08:00
|
|
|
|
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);
|
2026-06-06 00:38:54 +08:00
|
|
|
|
await copySupplementalRuntimeAssets(sourceDir);
|
2026-06-05 23:00:53 +08:00
|
|
|
|
|
2026-06-06 00:38:54 +08:00
|
|
|
|
const targetDir = path.join(BUILD_CACHE_DIR, version, "target");
|
2026-06-05 23:00:53 +08:00
|
|
|
|
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",
|
2026-06-06 00:38:54 +08:00
|
|
|
|
buildSchema: RELEASE_BUILD_SCHEMA,
|
2026-06-05 23:00:53 +08:00
|
|
|
|
version,
|
|
|
|
|
|
gitHead: head,
|
|
|
|
|
|
builtAt: new Date().toISOString(),
|
|
|
|
|
|
binaryPath,
|
|
|
|
|
|
sourceDir,
|
|
|
|
|
|
cargoTargetDir: targetDir,
|
2026-06-06 00:38:54 +08:00
|
|
|
|
runtimeAssets: SUPPLEMENTAL_RUNTIME_ASSETS.map((asset) => asset.target),
|
2026-06-05 23:00:53 +08:00
|
|
|
|
};
|
|
|
|
|
|
await fsp.writeFile(metadataPath, `${JSON.stringify(metadata, null, 2)}\n`, "utf8");
|
2026-06-06 00:38:54 +08:00
|
|
|
|
await fsp.rm(targetDir, { recursive: true, force: true });
|
2026-06-05 23:00:53 +08:00
|
|
|
|
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));
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-06 00:38:54 +08:00
|
|
|
|
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));
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-05 23:00:53 +08:00
|
|
|
|
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}`);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-06 00:38:54 +08:00
|
|
|
|
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(", ")}`);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-25 14:56:42 +08:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-05 23:00:53 +08:00
|
|
|
|
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);
|
2026-07-03 23:20:16 +08:00
|
|
|
|
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");
|
|
|
|
|
|
}
|
2026-07-25 14:56:42 +08:00
|
|
|
|
if (!(await pathExists(binaryPath))) {
|
|
|
|
|
|
throw new Error(`release 二进制不存在: ${binaryPath}`);
|
|
|
|
|
|
}
|
2026-06-05 23:00:53 +08:00
|
|
|
|
|
2026-07-25 14:56:42 +08:00
|
|
|
|
// 1) 停旧(systemd + 裸进程)
|
|
|
|
|
|
await stopSystemdManagedService(port);
|
2026-06-05 23:00:53 +08:00
|
|
|
|
|
2026-07-25 14:56:42 +08:00
|
|
|
|
// 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()}`,
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
2026-06-05 23:00:53 +08:00
|
|
|
|
|
|
|
|
|
|
const state = {
|
2026-07-25 14:56:42 +08:00
|
|
|
|
schema: "mnote.prod_process.v2",
|
|
|
|
|
|
managedBy: "systemd-user",
|
|
|
|
|
|
unit: SYSTEMD_UNIT_NAME,
|
|
|
|
|
|
pid,
|
2026-06-05 23:00:53 +08:00
|
|
|
|
version: release.version,
|
|
|
|
|
|
gitHead: release.metadata?.gitHead,
|
|
|
|
|
|
startedAt: new Date().toISOString(),
|
|
|
|
|
|
bind,
|
|
|
|
|
|
publicBind,
|
|
|
|
|
|
releaseDir: release.releaseDir,
|
|
|
|
|
|
binaryPath,
|
2026-07-25 14:56:42 +08:00
|
|
|
|
currentLink: RUN_CURRENT_LINK,
|
|
|
|
|
|
envPath: RUN_ENV_PATH,
|
2026-06-05 23:00:53 +08:00
|
|
|
|
logPath,
|
2026-07-25 14:56:42 +08:00
|
|
|
|
journal: `journalctl --user -u ${SYSTEMD_UNIT_NAME} -f`,
|
2026-06-05 23:00:53 +08:00
|
|
|
|
};
|
|
|
|
|
|
await fsp.writeFile(RUN_STATE_PATH, `${JSON.stringify(state, null, 2)}\n`, "utf8");
|
2026-07-25 14:56:42 +08:00
|
|
|
|
|
|
|
|
|
|
await waitForReady(port, logPath, pid);
|
2026-06-06 00:38:54 +08:00
|
|
|
|
await verifyStartupAssets(port);
|
2026-06-05 23:00:53 +08:00
|
|
|
|
|
2026-07-25 14:56:42 +08:00
|
|
|
|
console.log(`[prod] 已启动 mnote-web release ${release.version}(systemd --user)`);
|
|
|
|
|
|
console.log(`[prod] unit=${SYSTEMD_UNIT_NAME} pid=${pid}`);
|
2026-06-05 23:00:53 +08:00
|
|
|
|
console.log(`[prod] local URL=http://127.0.0.1:${port}`);
|
|
|
|
|
|
for (const address of lanAddresses()) {
|
|
|
|
|
|
console.log(`[prod] LAN URL=http://${address}:${port}`);
|
|
|
|
|
|
}
|
2026-07-25 14:56:42 +08:00
|
|
|
|
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)`);
|
2026-06-05 23:00:53 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function main() {
|
2026-06-06 00:38:54 +08:00
|
|
|
|
loadProdEnv();
|
2026-06-05 23:00:53 +08:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-06 00:38:54 +08:00
|
|
|
|
if (require.main === module) {
|
|
|
|
|
|
main().catch((error) => {
|
|
|
|
|
|
console.error(`[prod] ${error.message}`);
|
|
|
|
|
|
process.exit(1);
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|