#!/usr/bin/env node import fs from 'node:fs/promises'; 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 externalPackages = [ '@node-rs/argon2', '@resvg/resvg-js', 'debug', 'express', 'http-proxy-middleware', 'jsonrepair', 'mysql2', 'mysql2/promise', 'qrcode', 'redis', '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) { await fs.rm(targetPath, { recursive: true, force: true }); } 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'); await run('npm', ['run', 'build']); } 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=node24', '--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=node24', '--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 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')); } } 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); } 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 writeMetadata() { const packageJson = JSON.parse(await fs.readFile(path.join(root, 'package.json'), 'utf8')); const runtimePackageJson = { name: `${packageJson.name}-portal-runtime`, private: true, type: 'module', }; await writeFile(path.join(runtimeRoot, 'package.json'), `${JSON.stringify(runtimePackageJson, null, 2)}\n`); await writeFile( path.join(runtimeRoot, 'scripts', 'run-memind-portal-prod.sh'), [ '#!/usr/bin/env bash', 'set -euo pipefail', '', 'ROOT="$(cd "$(dirname "$0")/.." && pwd)"', 'cd "$ROOT"', '', 'if [[ -f "${ROOT}/.env" ]]; then', ' set -a', ' # shellcheck disable=SC1091', ' source "${ROOT}/.env"', ' set +a', 'fi', '', 'export NODE_ENV=production', 'export H5_PORT="${H5_PORT:-8081}"', 'export H5_PUBLIC_BASE_URL="${H5_PUBLIC_BASE_URL:-https://m.tkmind.cn}"', 'export TKMIND_API_TARGETS="${TKMIND_API_TARGETS:-https://127.0.0.1:18006,https://127.0.0.1:18007,https://127.0.0.1:18008,https://127.0.0.1:18009}"', 'export TKMIND_API_TARGET="${TKMIND_API_TARGET:-https://127.0.0.1:18006}"', 'export TKMIND_API_TARGET_1="${TKMIND_API_TARGET_1:-https://127.0.0.1:18007}"', 'export GOOSED_MCP_NODE_PATH="${GOOSED_MCP_NODE_PATH:-/usr/local/bin/node}"', 'export GOOSED_MCP_SERVER_PATH="${GOOSED_MCP_SERVER_PATH:-/opt/portal/mindspace-sandbox-mcp.mjs}"', '', 'NODE_BIN="${NODE_BIN:-/opt/homebrew/opt/node@24/bin/node}"', 'if [[ ! -x "${NODE_BIN}" ]]; then', ' NODE_BIN="$(command -v node)"', 'fi', '', 'exec "${NODE_BIN}" "${ROOT}/server.mjs"', '', ].join('\n'), ); 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)', '', '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', '', 'Deployment and operations transport:', ' 105 fixed IP: 120.26.184.105', ' 103 / Studio fixed IP: 58.38.22.103', ' Do not switch back to 10.10.* LAN paths unless explicitly required.', '', ].join('\n'), ); } async function main() { await buildFrontend(); await copyRuntimeAssets(); await bundleServer(); await bundleSandboxMcp(); await copyNodeModules(); await writeMetadata(); await fs.chmod(path.join(runtimeRoot, 'scripts', 'run-memind-portal-prod.sh'), 0o755); console.log(''); console.log(`Portal runtime 已生成: ${runtimeRoot}`); } main().catch((error) => { console.error(error instanceof Error ? error.message : error); process.exit(1); });