Improve local evidence search and AI capabilities
This commit is contained in:
@@ -0,0 +1,449 @@
|
||||
#!/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。
|
||||
*/
|
||||
|
||||
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 DEFAULT_PORT = 3003;
|
||||
const DEFAULT_CONTROL_PLANE_DB = "/mnt/Data1T/Mnote_data/control-plane/control-plane.db";
|
||||
|
||||
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 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));
|
||||
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 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);
|
||||
|
||||
const targetDir = path.join(BUILD_CACHE_DIR, "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",
|
||||
version,
|
||||
gitHead: head,
|
||||
builtAt: new Date().toISOString(),
|
||||
binaryPath,
|
||||
sourceDir,
|
||||
cargoTargetDir: targetDir,
|
||||
};
|
||||
await fsp.writeFile(metadataPath, `${JSON.stringify(metadata, null, 2)}\n`, "utf8");
|
||||
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));
|
||||
});
|
||||
}
|
||||
|
||||
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 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 });
|
||||
await stopPreviousProcess(port);
|
||||
|
||||
const binaryPath = path.join(release.releaseDir, "mnote-web");
|
||||
const logPath = path.join(release.releaseDir, "mnote-web.log");
|
||||
const logFd = fs.openSync(logPath, "a");
|
||||
const bind = process.env.MNOTE_WEB_BIND || `0.0.0.0:${port}`;
|
||||
const publicBind = process.env.MNOTE_WEB_PUBLIC_BIND || defaultPublicBind(port);
|
||||
|
||||
const child = spawn(binaryPath, [], {
|
||||
cwd: release.metadata?.sourceDir || release.releaseDir,
|
||||
detached: true,
|
||||
stdio: ["ignore", logFd, logFd],
|
||||
env: {
|
||||
...process.env,
|
||||
MNOTE_WEB_BIND: bind,
|
||||
MNOTE_WEB_PUBLIC_BIND: publicBind,
|
||||
MNOTE_CONTROL_PLANE_DB_PATH:
|
||||
process.env.MNOTE_CONTROL_PLANE_DB_PATH || DEFAULT_CONTROL_PLANE_DB,
|
||||
},
|
||||
});
|
||||
|
||||
child.unref();
|
||||
fs.closeSync(logFd);
|
||||
|
||||
const state = {
|
||||
schema: "mnote.prod_process.v1",
|
||||
pid: child.pid,
|
||||
version: release.version,
|
||||
gitHead: release.metadata?.gitHead,
|
||||
startedAt: new Date().toISOString(),
|
||||
bind,
|
||||
publicBind,
|
||||
releaseDir: release.releaseDir,
|
||||
binaryPath,
|
||||
logPath,
|
||||
};
|
||||
await fsp.writeFile(RUN_STATE_PATH, `${JSON.stringify(state, null, 2)}\n`, "utf8");
|
||||
await waitForReady(port, logPath, child.pid);
|
||||
|
||||
console.log(`[prod] 已启动 mnote-web release ${release.version}`);
|
||||
console.log(`[prod] pid=${child.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] log=${logPath}`);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
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);
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(`[prod] ${error.message}`);
|
||||
process.exit(1);
|
||||
});
|
||||
Reference in New Issue
Block a user