Files
memind/scripts/build-mindspace-service-runtime.mjs

248 lines
8.0 KiB
JavaScript

#!/usr/bin/env node
import fs from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { execFile } from 'node:child_process';
import { promisify } from 'node:util';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const root = path.join(__dirname, '..');
const runtimeRoot = path.join(root, '.runtime', 'mindspace-service');
const serviceSourceRoot = path.join(root, 'mindspace-service');
const nodeModulesDir = path.join(root, 'node_modules');
const skipNodeModules = process.argv.includes('--skip-node-modules');
const execFileAsync = promisify(execFile);
const excludeTopLevel = new Set([
'.git',
'.claude',
'.cursor',
'.local',
'.runtime',
'node_modules',
'dist',
'MindSpace',
'data',
'users',
'logs',
]);
async function exists(targetPath) {
try {
await fs.access(targetPath);
return true;
} catch {
return false;
}
}
async function waitForFile(targetPath, { timeoutMs = 5000 } = {}) {
const deadline = Date.now() + timeoutMs;
let lastError = null;
while (Date.now() <= deadline) {
try {
const stat = await fs.stat(targetPath);
if (stat.isFile()) return;
} catch (error) {
lastError = error;
}
await new Promise((resolve) => setTimeout(resolve, 50));
}
throw new Error(
`runtime file was not copied in time: ${targetPath}${
lastError?.message ? ` (${lastError.message})` : ''
}`,
);
}
async function remove(targetPath) {
try {
await fs.rm(targetPath, {
recursive: true,
force: true,
maxRetries: 5,
retryDelay: 100,
});
return;
} catch (error) {
if (!['ENOTEMPTY', 'EBUSY', 'EPERM'].includes(error?.code)) throw error;
const tombstone = `${targetPath}.delete-${process.pid}-${Date.now()}`;
try {
await fs.rename(targetPath, tombstone);
} catch (renameError) {
if (renameError?.code === 'ENOENT') return;
throw error;
}
await fs.rm(tombstone, {
recursive: true,
force: true,
maxRetries: 10,
retryDelay: 200,
});
}
}
async function copyDir(source, target) {
await fs.mkdir(path.dirname(target), { recursive: true });
await fs.cp(source, target, { recursive: true, dereference: false });
}
function shouldCopyMemindSource(source) {
const relative = path.relative(root, source);
if (!relative) return true;
const parts = relative.split(path.sep);
const top = parts[0];
const last = parts[parts.length - 1];
if (parts.includes('node_modules')) return false;
if (excludeTopLevel.has(top)) return false;
if (last.endsWith('.pid')) return false;
if (last === '.DS_Store' || last === '.env' || last === '.env.local') return false;
if (top === 'public' && parts[1] === 'plaza-covers') return false;
return true;
}
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 copyServiceSource() {
console.log('==> 拷贝 MindSpace service 源文件');
await remove(runtimeRoot);
await fs.mkdir(runtimeRoot, { recursive: true });
await copyDir(serviceSourceRoot, runtimeRoot);
await remove(path.join(runtimeRoot, 'mindspace-rpc-server.test.mjs'));
}
async function copyMemindSourceSnapshot() {
console.log('==> 拷贝 Memind 受控源码快照');
const targetRoot = path.join(runtimeRoot, 'memind-source');
await remove(targetRoot);
await fs.mkdir(targetRoot, { recursive: true });
const entries = await fs.readdir(root);
for (const entry of entries) {
if (excludeTopLevel.has(entry)) continue;
if (entry.endsWith('.pid')) continue;
if (entry === '.DS_Store' || entry === '.env' || entry === '.env.local') continue;
const sourcePath = path.join(root, entry);
if (!shouldCopyMemindSource(sourcePath)) continue;
await fs.cp(sourcePath, path.join(targetRoot, entry), {
recursive: true,
filter: shouldCopyMemindSource,
});
}
}
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 writeMetadata() {
const head = await fs.readFile(path.join(root, '.git', 'HEAD'), 'utf8').catch(() => 'unknown');
const gitSha = await execFileAsync('git', ['-C', root, 'rev-parse', 'HEAD'])
.then(({ stdout }) => stdout.trim())
.catch(() => null);
const gitBranch = await execFileAsync('git', ['-C', root, 'branch', '--show-current'])
.then(({ stdout }) => stdout.trim())
.catch(() => null);
const builtAt = new Date().toISOString();
const buildId = gitSha ? `mindspace-${gitSha.slice(0, 12)}` : `mindspace-${Date.now()}`;
const runbook = [
'MindSpace service runtime artifact',
'',
'Production runtime target:',
' /Users/john/MindSpace',
'Standalone persisted data root:',
' /Users/john/MindSpace/data/mindspace',
'Standalone users root:',
' /Users/john/MindSpace/users',
'Shared Portal/H5 root:',
' /Users/john/Project/Memind',
'',
'This artifact contains:',
' server.mjs',
' mindspace-rpc-server.mjs',
' mindspace-service-bootstrap.mjs',
' scripts/run-mindspace-prod.sh',
' memind-source/ (controlled source snapshot, no runtime data)',
' node_modules/ (runtime dependencies)',
'',
'This artifact must not contain:',
' MindSpace/ runtime data',
' data/mindspace/ runtime data',
' users/ runtime data',
' .env / .env.local',
'',
'The production service may read shared Portal secrets from /Users/john/Project/Memind/.env,',
'but standalone MindSpace data paths must stay under /Users/john/MindSpace.',
'',
`Git head ref: ${head.trim()}`,
`Git sha: ${gitSha ?? 'unknown'}`,
`Git branch: ${gitBranch ?? 'unknown'}`,
`Build id: ${buildId}`,
`Built at: ${builtAt}`,
].join('\n');
await fs.writeFile(path.join(runtimeRoot, 'RUNBOOK.txt'), runbook, 'utf8');
await fs.writeFile(
path.join(runtimeRoot, 'build-info.json'),
`${JSON.stringify({
buildId,
gitSha,
gitBranch,
builtAt,
}, null, 2)}\n`,
'utf8',
);
}
async function main() {
if (!(await exists(serviceSourceRoot))) {
throw new Error(`缺少 MindSpace service 源目录: ${serviceSourceRoot}`);
}
await copyServiceSource();
await copyMemindSourceSnapshot();
await copyNodeModules();
await writeMetadata();
const runnerPath = path.join(runtimeRoot, 'scripts', 'run-mindspace-prod.sh');
await waitForFile(runnerPath);
await fs.chmod(runnerPath, 0o755);
console.log('');
console.log(`MindSpace service runtime 已生成: ${runtimeRoot}`);
}
main().catch((error) => {
console.error(error instanceof Error ? error.message : error);
process.exit(1);
});