Files
memind/scripts/build-portal-runtime.mjs

545 lines
21 KiB
JavaScript
Executable File

#!/usr/bin/env node
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { spawn } from 'node:child_process';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const root = path.join(__dirname, '..');
const runtimeRoot = path.join(root, '.runtime', 'portal');
const distDir = path.join(root, 'dist');
const publicDir = path.join(root, 'public');
const nodeModulesDir = path.join(root, 'node_modules');
const schemaFile = path.join(root, 'schema.sql');
const esbuildBin = path.join(root, 'node_modules', '.pnpm', 'node_modules', '.bin', 'esbuild');
const skipBuild = process.argv.includes('--skip-build');
const skipNodeModules = process.argv.includes('--skip-node-modules');
const runtimeNodeTarget = process.env.PORTAL_RUNTIME_NODE_TARGET || 'node24';
const runtimeInstallMode = process.env.PORTAL_RUNTIME_INSTALL_MODE || 'bundle-node-modules';
const linuxArm64ResvgPackage = '@resvg/resvg-js-linux-arm64-gnu';
const externalPackages = [
'@img/sharp-darwin-arm64',
'@img/sharp-libvips-darwin-arm64',
'@node-rs/argon2',
'@resvg/resvg-js',
'chromium-bidi',
'chromium-bidi/*',
'debug',
'express',
'fsevents',
'http-proxy-middleware',
'jsonrepair',
'mysql2',
'mysql2/promise',
'playwright',
'playwright-core',
'qrcode',
'redis',
'sharp',
'undici',
];
function run(command, args, options = {}) {
return new Promise((resolve, reject) => {
const child = spawn(command, args, {
cwd: root,
stdio: 'inherit',
env: process.env,
...options,
});
child.on('exit', (code) => {
if (code === 0) resolve();
else reject(new Error(`${command} ${args.join(' ')} exited with code ${code ?? 1}`));
});
child.on('error', reject);
});
}
async function exists(targetPath) {
try {
await fs.access(targetPath);
return true;
} catch {
return false;
}
}
async function remove(targetPath) {
if (!(await exists(targetPath))) return;
try {
await fs.rm(targetPath, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 });
} catch (error) {
if (error?.code !== 'ENOTEMPTY' && error?.code !== 'EBUSY') throw error;
await run('rm', ['-rf', targetPath]);
}
}
async function copyDir(source, target) {
await fs.mkdir(path.dirname(target), { recursive: true });
await fs.cp(source, target, { recursive: true, dereference: false });
}
async function writeFile(targetPath, content) {
await fs.mkdir(path.dirname(targetPath), { recursive: true });
await fs.writeFile(targetPath, content, 'utf8');
}
async function buildFrontend() {
if (skipBuild) return;
console.log('==> 构建 Portal 前端 dist');
const buildEnv = {
...process.env,
VITE_PLAZA_BASE:
process.env.VITE_PLAZA_BASE ??
process.env.PLAZA_PUBLIC_BASE ??
'https://plaza.tkmind.cn',
VITE_MINDSPACE_BASE:
process.env.VITE_MINDSPACE_BASE ??
process.env.H5_PUBLIC_BASE_URL ??
'https://m.tkmind.cn',
};
await run('npm', ['run', 'build'], { env: buildEnv });
}
async function bundleServer() {
if (!(await exists(esbuildBin))) {
throw new Error(`未找到 esbuild: ${esbuildBin}`);
}
console.log('==> 打包 Portal 后端为单文件 runtime');
const args = [
'server.mjs',
'--bundle',
'--platform=node',
'--format=esm',
`--target=${runtimeNodeTarget}`,
'--outfile=.runtime/portal/server.mjs',
'--banner:js=import { createRequire as __createRequire } from "node:module"; const require = __createRequire(import.meta.url);',
];
for (const pkg of externalPackages) {
args.push(`--external:${pkg}`);
}
await run(esbuildBin, args);
}
async function bundleSandboxMcp() {
if (!(await exists(esbuildBin))) {
throw new Error(`未找到 esbuild: ${esbuildBin}`);
}
console.log('==> 打包 sandbox MCP 为单文件 runtime');
const args = [
'mindspace-sandbox-mcp.mjs',
'--bundle',
'--platform=node',
'--format=esm',
`--target=${runtimeNodeTarget}`,
'--outfile=.runtime/portal/mindspace-sandbox-mcp.mjs',
'--banner:js=import { createRequire as __createRequire } from "node:module"; const require = __createRequire(import.meta.url);',
];
for (const pkg of externalPackages) {
args.push(`--external:${pkg}`);
}
await run(esbuildBin, args);
}
async function bundleMindSearchMcp() {
if (!(await exists(esbuildBin))) {
throw new Error(`未找到 esbuild: ${esbuildBin}`);
}
console.log('==> 打包 MindSearch MCP 为单文件 runtime');
const args = [
'tkmind-search-mcp.mjs',
'--bundle',
'--platform=node',
'--format=esm',
`--target=${runtimeNodeTarget}`,
'--outfile=.runtime/portal/tkmind-search-mcp.mjs',
'--banner:js=import { createRequire as __createRequire } from "node:module"; const require = __createRequire(import.meta.url);',
];
for (const pkg of externalPackages) {
args.push(`--external:${pkg}`);
}
await run(esbuildBin, args);
}
async function bundleAgentRunWorker() {
if (!(await exists(esbuildBin))) {
throw new Error(`未找到 esbuild: ${esbuildBin}`);
}
console.log('==> 打包 agent run worker 为单文件 runtime');
const args = [
'scripts/agent-run-worker.mjs',
'--bundle',
'--platform=node',
'--format=esm',
`--target=${runtimeNodeTarget}`,
'--outfile=.runtime/portal/scripts/agent-run-worker.mjs',
'--banner:js=import { createRequire as __createRequire } from "node:module"; const require = __createRequire(import.meta.url);',
];
for (const pkg of externalPackages) {
args.push(`--external:${pkg}`);
}
await run(esbuildBin, args);
}
async function copyRuntimeAssets() {
console.log('==> 拷贝运行时静态资源');
await remove(runtimeRoot);
await fs.mkdir(runtimeRoot, { recursive: true });
if (!(await exists(distDir))) {
throw new Error(`缺少 dist 目录: ${distDir}`);
}
await copyDir(distDir, path.join(runtimeRoot, 'dist'));
if (await exists(publicDir)) {
await copyDir(publicDir, path.join(runtimeRoot, 'public'));
}
if (await exists(schemaFile)) {
await fs.copyFile(schemaFile, path.join(runtimeRoot, 'schema.sql'));
}
const skillsDir = path.join(root, 'skills');
if (await exists(skillsDir)) {
console.log('==> 拷贝平台 skills 目录');
await copyDir(skillsDir, path.join(runtimeRoot, 'skills'));
}
}
async function copyNodeModules() {
if (skipNodeModules) return;
if (!(await exists(nodeModulesDir))) {
throw new Error(`缺少 node_modules 目录: ${nodeModulesDir}`);
}
const resolvedNodeModulesDir = await fs.realpath(nodeModulesDir);
console.log('==> 拷贝生产运行依赖 node_modules');
await copyDir(resolvedNodeModulesDir, path.join(runtimeRoot, 'node_modules'));
await rewriteNodeModulesSymlinks(path.join(runtimeRoot, 'node_modules'), resolvedNodeModulesDir);
await installLinuxArm64ResvgBinary();
}
// The runtime artifact is assembled on macOS but runs in a Linux ARM64 Colima VM.
// pnpm therefore copies the macOS optional resvg binary only. Keep the target
// native package alongside the copied node_modules so the dynamic require in
// @resvg/resvg-js resolves in production.
async function installLinuxArm64ResvgBinary() {
const resvgPackageJson = JSON.parse(
await fs.readFile(path.join(nodeModulesDir, '@resvg', 'resvg-js', 'package.json'), 'utf8'),
);
const version = resvgPackageJson.optionalDependencies?.[linuxArm64ResvgPackage];
if (!version) {
throw new Error(`无法确定 ${linuxArm64ResvgPackage} 的版本`);
}
const stagingDir = await fs.mkdtemp(path.join(os.tmpdir(), 'memind-resvg-linux-arm64-'));
const targetDir = path.join(runtimeRoot, 'node_modules', '@resvg', 'resvg-js-linux-arm64-gnu');
try {
console.log(`==> 补齐 Linux ARM64 resvg 原生依赖 (${version})`);
await run('npm', ['pack', `${linuxArm64ResvgPackage}@${version}`, '--pack-destination', stagingDir]);
const archive = (await fs.readdir(stagingDir)).find((name) => name.endsWith('.tgz'));
if (!archive) throw new Error(`未生成 ${linuxArm64ResvgPackage} 安装包`);
await remove(targetDir);
await fs.mkdir(targetDir, { recursive: true });
await run('tar', ['-xzf', path.join(stagingDir, archive), '--strip-components=1', '-C', targetDir]);
const files = await fs.readdir(targetDir);
if (!files.some((name) => name.endsWith('.node'))) {
throw new Error(`${linuxArm64ResvgPackage} 缺少原生 .node 文件`);
}
} finally {
await remove(stagingDir);
}
}
async function rewriteNodeModulesSymlinks(runtimeNodeModulesDir, sourceNodeModulesDir) {
console.log('==> 重写 node_modules 顶层符号链接为 runtime 内相对路径');
const localNodeModulesRoot = `${sourceNodeModulesDir}${path.sep}`;
async function visit(dir) {
const entries = await fs.readdir(dir);
for (const entryName of entries) {
const fullPath = path.join(dir, entryName);
const stats = await fs.lstat(fullPath);
if (stats.isSymbolicLink()) {
const linkTarget = await fs.readlink(fullPath);
const normalizedTarget = path.normalize(linkTarget);
if (!normalizedTarget.startsWith(localNodeModulesRoot)) continue;
const relativeInsideNodeModules = normalizedTarget.slice(localNodeModulesRoot.length);
const runtimeTarget = path.join(runtimeNodeModulesDir, relativeInsideNodeModules);
const replacement = path.relative(path.dirname(fullPath), runtimeTarget);
await fs.unlink(fullPath);
await fs.symlink(replacement, fullPath);
continue;
}
if (stats.isDirectory()) {
await visit(fullPath);
}
}
}
await visit(runtimeNodeModulesDir);
}
async function copyMindspacePublicLinkTools() {
console.log('==> 拷贝 MindSpace 公开页链接检查工具');
await fs.copyFile(
path.join(root, 'mindspace-public-links.mjs'),
path.join(runtimeRoot, 'mindspace-public-links.mjs'),
);
await fs.mkdir(path.join(runtimeRoot, 'scripts'), { recursive: true });
await fs.copyFile(
path.join(root, 'scripts', 'check-mindspace-public-links.mjs'),
path.join(runtimeRoot, 'scripts', 'check-mindspace-public-links.mjs'),
);
await fs.copyFile(
path.join(root, 'scripts', 'repair-mindspace-public-downloads.mjs'),
path.join(runtimeRoot, 'scripts', 'repair-mindspace-public-downloads.mjs'),
);
}
async function writeMetadata() {
const packageJson = JSON.parse(await fs.readFile(path.join(root, 'package.json'), 'utf8'));
const runtimeDependencies = Object.fromEntries(
externalPackages
.filter((pkg) => packageJson.dependencies?.[pkg])
.map((pkg) => [pkg, packageJson.dependencies[pkg]]),
);
const runtimePackageJson = {
name: `${packageJson.name}-portal-runtime`,
private: true,
type: 'module',
engines: {
node: '>=22',
},
dependencies: runtimeDependencies,
};
await writeFile(path.join(runtimeRoot, 'package.json'), `${JSON.stringify(runtimePackageJson, null, 2)}\n`);
const tunnelScript = path.join(root, 'scripts', 'memind-portal-tunnel.sh');
const runtimeScriptsDir = path.join(runtimeRoot, 'scripts');
await fs.mkdir(runtimeScriptsDir, { recursive: true });
if (await exists(tunnelScript)) {
await fs.copyFile(tunnelScript, path.join(runtimeScriptsDir, 'memind-portal-tunnel.sh'));
await fs.chmod(path.join(runtimeScriptsDir, 'memind-portal-tunnel.sh'), 0o755);
} else {
throw new Error(`缺少隧道脚本: ${tunnelScript}`);
}
const prodStartScript = path.join(root, 'scripts', 'run-memind-portal-prod.sh');
if (await exists(prodStartScript)) {
await fs.copyFile(prodStartScript, path.join(runtimeScriptsDir, 'run-memind-portal-prod.sh'));
await fs.chmod(path.join(runtimeScriptsDir, 'run-memind-portal-prod.sh'), 0o755);
} else {
throw new Error(`缺少 Portal 启动脚本: ${prodStartScript}`);
}
await fs.copyFile(
path.join(root, 'scripts', 'load-env.mjs'),
path.join(runtimeRoot, 'scripts', 'load-env.mjs'),
);
await fs.copyFile(
path.join(root, 'scripts', 'wechat-mp-menu.mjs'),
path.join(runtimeRoot, 'scripts', 'wechat-mp-menu.mjs'),
);
await fs.copyFile(
path.join(root, 'scripts', 'check-stream-runtime.mjs'),
path.join(runtimeRoot, 'scripts', 'check-stream-runtime.mjs'),
);
await fs.copyFile(
path.join(root, 'scripts', 'runtime-worker-drain.mjs'),
path.join(runtimeRoot, 'scripts', 'runtime-worker-drain.mjs'),
);
await fs.copyFile(
path.join(root, 'scripts', 'runtime-worker-metrics.mjs'),
path.join(runtimeRoot, 'scripts', 'runtime-worker-metrics.mjs'),
);
await fs.copyFile(
path.join(root, 'scripts', 'runtime-worker-heartbeat.mjs'),
path.join(runtimeRoot, 'scripts', 'runtime-worker-heartbeat.mjs'),
);
await fs.copyFile(
path.join(root, 'scripts', 'runtime-slo-report.mjs'),
path.join(runtimeRoot, 'scripts', 'runtime-slo-report.mjs'),
);
await fs.copyFile(
path.join(root, 'scripts', 'embed-memory-v2-local-hash.mjs'),
path.join(runtimeRoot, 'scripts', 'embed-memory-v2-local-hash.mjs'),
);
await fs.copyFile(
path.join(root, 'scripts', 'install-runtime-metrics-agent.sh'),
path.join(runtimeRoot, 'scripts', 'install-runtime-metrics-agent.sh'),
);
await fs.copyFile(
path.join(root, 'scripts', 'install-runtime-heartbeat-agent.sh'),
path.join(runtimeRoot, 'scripts', 'install-runtime-heartbeat-agent.sh'),
);
await fs.copyFile(
path.join(root, 'scripts', 'install-runtime-slo-report-agent.sh'),
path.join(runtimeRoot, 'scripts', 'install-runtime-slo-report-agent.sh'),
);
await fs.copyFile(
path.join(root, 'scripts', 'install-runtime-slo-soak-agent.sh'),
path.join(runtimeRoot, 'scripts', 'install-runtime-slo-soak-agent.sh'),
);
await fs.copyFile(
path.join(root, 'scripts', 'install-agent-run-worker-agent.sh'),
path.join(runtimeRoot, 'scripts', 'install-agent-run-worker-agent.sh'),
);
await fs.copyFile(
path.join(root, 'scripts', 'check-tool-runtime.mjs'),
path.join(runtimeRoot, 'scripts', 'check-tool-runtime.mjs'),
);
await fs.copyFile(
path.join(root, 'scripts', 'check-agent-code-run-entry.mjs'),
path.join(runtimeRoot, 'scripts', 'check-agent-code-run-entry.mjs'),
);
await fs.copyFile(
path.join(root, 'scripts', 'check-agent-run-worker.mjs'),
path.join(runtimeRoot, 'scripts', 'check-agent-run-worker.mjs'),
);
await fs.copyFile(
path.join(root, 'scripts', 'agent-run-guard.mjs'),
path.join(runtimeRoot, 'scripts', 'agent-run-guard.mjs'),
);
await fs.copyFile(
path.join(root, 'scripts', 'agent-run-guard-lib.mjs'),
path.join(runtimeRoot, 'scripts', 'agent-run-guard-lib.mjs'),
);
await fs.copyFile(
path.join(root, 'scripts', 'install-agent-run-guard-agent.sh'),
path.join(runtimeRoot, 'scripts', 'install-agent-run-guard-agent.sh'),
);
await fs.copyFile(
path.join(root, 'scripts', 'resume-agent-run-prod.sh'),
path.join(runtimeRoot, 'scripts', 'resume-agent-run-prod.sh'),
);
await fs.copyFile(
path.join(root, 'scripts', 'ensure-goose-session-postgres.sh'),
path.join(runtimeRoot, 'scripts', 'ensure-goose-session-postgres.sh'),
);
await writeFile(
path.join(runtimeRoot, 'RUNBOOK.txt'),
[
'Portal runtime artifact',
'',
'This directory is meant to run on production without the source tree.',
'Required persisted items to inherit on the host:',
' .env',
' MindSpace/',
' data/',
' users/',
' .tailscale/',
' public/plaza-covers/',
' logs/',
'',
'Bundled alongside server.mjs (required for sandbox-fs MCP):',
' mindspace-sandbox-mcp.mjs (esbuild bundle; includes schedule-service deps)',
' tkmind-search-mcp.mjs (esbuild bundle; required when MindSearch is enabled)',
' wechat-mp.bundle.mjs (esbuild bundle; hot-swappable WeChat MP module)',
'',
'Post-deploy validation (scripts/check-mindspace-public-links.mjs):',
' Scans MindSpace/*/public/*.html for missing relative href/src/cover targets.',
'',
`Runtime install mode: ${runtimeInstallMode}`,
`Bundle target: ${runtimeNodeTarget}`,
'',
'Platform agent skills (synced into user workspaces):',
' skills/',
'Key runtime differences must stay in .env, not in the artifact:',
' DATABASE_URL / MYSQL_*',
' H5_PUBLIC_BASE_URL',
' TKMIND_API_TARGETS / TKMIND_API_TARGET / TKMIND_API_TARGET_1',
' H5_USERS_ROOT / MINDSPACE_STORAGE_ROOT / MEMIND_SHARED_PUBLISH_ROOT',
'',
'Frontend compile-time public URLs (baked into dist at build):',
' VITE_PLAZA_BASE defaults to https://plaza.tkmind.cn in build-portal-runtime.mjs',
' VITE_MINDSPACE_BASE defaults to H5_PUBLIC_BASE_URL or https://m.tkmind.cn',
' Do not build production dist with VITE_PLAZA_BASE=http://127.0.0.1:* in .env',
'',
'Deployment and operations transport:',
' H5 public domain: m.tkmind.cn',
' Current public path: m.tkmind.cn -> 105 nginx -> 103 Portal :8081',
' No separate public test domain remains; use explicit local/test overrides only when needed.',
' Production H5 and WeChat links should use m.tkmind.cn unless a test rollout explicitly overrides H5_PUBLIC_BASE_URL.',
'',
'Streaming runtime operations:',
' node scripts/check-stream-runtime.mjs',
' node scripts/runtime-worker-drain.mjs status',
' node scripts/runtime-worker-drain.mjs reconcile',
' node scripts/runtime-worker-drain.mjs reconcile --apply',
' node scripts/runtime-worker-metrics.mjs status',
' node scripts/runtime-worker-metrics.mjs sample',
' bash scripts/install-runtime-metrics-agent.sh',
' node scripts/runtime-worker-heartbeat.mjs once',
' node scripts/runtime-worker-heartbeat.mjs serve',
' bash scripts/install-runtime-heartbeat-agent.sh',
' node scripts/runtime-slo-report.mjs',
' node scripts/runtime-slo-report.mjs --write-report',
' node scripts/runtime-slo-report.mjs --write-report --prune --retention-days 30',
' bash scripts/install-runtime-slo-report-agent.sh',
' bash scripts/install-runtime-slo-soak-agent.sh',
' node scripts/runtime-worker-drain.mjs drain goosed-3',
' node scripts/runtime-worker-drain.mjs undrain goosed-3',
' node scripts/check-tool-runtime.mjs',
' node scripts/check-agent-code-run-entry.mjs',
' curl -sk https://m.tkmind.cn/api/runtime/status # includes toolRuntime.queue',
' node scripts/agent-run-worker.mjs --status',
' node scripts/agent-run-worker.mjs --once',
' bash scripts/install-agent-run-worker-agent.sh # installs disabled by default',
' node scripts/check-agent-run-worker.mjs # read-only LaunchAgent/queue check',
' node scripts/agent-run-guard.mjs # dry-run auto-pause guard check',
' node scripts/agent-run-guard.mjs --apply # first-time pause only; repeat checks are no-ops',
' bash scripts/install-agent-run-guard-agent.sh # installs guard LaunchAgent',
' bash scripts/resume-agent-run-prod.sh # manual recovery after guard pause',
'',
].join('\n'),
);
}
async function bundleWechatMp() {
console.log('==> 打包 WeChat MP runtime bundle');
await run('node', ['scripts/build-wechat-mp-runtime.mjs']);
}
async function main() {
await buildFrontend();
await copyRuntimeAssets();
await bundleServer();
await bundleWechatMp();
await bundleSandboxMcp();
await bundleMindSearchMcp();
await bundleAgentRunWorker();
if (runtimeInstallMode === 'bundle-node-modules' && !skipNodeModules) {
await copyNodeModules();
}
await copyMindspacePublicLinkTools();
await writeMetadata();
await fs.chmod(path.join(runtimeRoot, 'scripts', 'run-memind-portal-prod.sh'), 0o755);
await fs.chmod(path.join(runtimeRoot, 'scripts', 'wechat-mp-menu.mjs'), 0o755);
await fs.chmod(path.join(runtimeRoot, 'scripts', 'check-stream-runtime.mjs'), 0o755);
await fs.chmod(path.join(runtimeRoot, 'scripts', 'runtime-worker-drain.mjs'), 0o755);
await fs.chmod(path.join(runtimeRoot, 'scripts', 'runtime-worker-metrics.mjs'), 0o755);
await fs.chmod(path.join(runtimeRoot, 'scripts', 'runtime-worker-heartbeat.mjs'), 0o755);
await fs.chmod(path.join(runtimeRoot, 'scripts', 'runtime-slo-report.mjs'), 0o755);
await fs.chmod(path.join(runtimeRoot, 'scripts', 'agent-run-worker.mjs'), 0o755);
await fs.chmod(path.join(runtimeRoot, 'scripts', 'install-runtime-metrics-agent.sh'), 0o755);
await fs.chmod(path.join(runtimeRoot, 'scripts', 'install-runtime-heartbeat-agent.sh'), 0o755);
await fs.chmod(path.join(runtimeRoot, 'scripts', 'install-runtime-slo-report-agent.sh'), 0o755);
await fs.chmod(path.join(runtimeRoot, 'scripts', 'install-runtime-slo-soak-agent.sh'), 0o755);
await fs.chmod(path.join(runtimeRoot, 'scripts', 'install-agent-run-worker-agent.sh'), 0o755);
await fs.chmod(path.join(runtimeRoot, 'scripts', 'agent-run-guard.mjs'), 0o755);
await fs.chmod(path.join(runtimeRoot, 'scripts', 'install-agent-run-guard-agent.sh'), 0o755);
await fs.chmod(path.join(runtimeRoot, 'scripts', 'resume-agent-run-prod.sh'), 0o755);
await fs.chmod(path.join(runtimeRoot, 'scripts', 'ensure-goose-session-postgres.sh'), 0o755);
await fs.chmod(path.join(runtimeRoot, 'scripts', 'check-tool-runtime.mjs'), 0o755);
await fs.chmod(path.join(runtimeRoot, 'scripts', 'check-agent-run-worker.mjs'), 0o755);
await fs.chmod(path.join(runtimeRoot, 'scripts', 'memind-portal-tunnel.sh'), 0o755);
console.log('');
console.log(`Portal runtime 已生成: ${runtimeRoot}`);
}
main().catch((error) => {
console.error(error instanceof Error ? error.message : error);
process.exit(1);
});