Files
memind/scripts/build-portal-runtime.mjs
T
john 9b4a25799f Add smart ACK provider for WeChat MP replies
Replace fixed ackText with a rule-based AckProvider that picks
response templates by message type and intent (translate, summary,
rewrite, poster, ppt, mindmap, code, search, schedule). Pure sync,
zero I/O, auto-falls back to config.ackText on any error.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-26 15:19:03 +08:00

263 lines
8.2 KiB
JavaScript
Executable File

#!/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}`);
}
console.log('==> 拷贝生产运行依赖 node_modules');
await copyDir(nodeModulesDir, path.join(runtimeRoot, 'node_modules'));
await rewriteNodeModulesSymlinks(path.join(runtimeRoot, 'node_modules'));
}
async function rewriteNodeModulesSymlinks(runtimeNodeModulesDir) {
console.log('==> 重写 node_modules 顶层符号链接为 runtime 内相对路径');
const localNodeModulesRoot = `${nodeModulesDir}${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_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}"',
'',
'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_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);
});