Files
memind/scripts/dev.mjs
T
john b5c600ad81 Split back-office into standalone memind_adm service
Carve the platform super-admin API (/admin-api) and plaza operations console
API (/api/ops/v1) out of the public server.mjs into their own process, so the
user-facing fleet can no longer be taken down or scaled by back-office traffic.

Architecture (split-ready, single process for now):
- admin-routes.mjs: createAdminApi / createOpsApi route factories (DI, single
  source of truth; the route logic moved verbatim out of server.mjs).
- admin-bootstrap.mjs: lean service container (user-auth, LLM providers, minimal
  plaza graph) with no user-facing daemons.
- admin-server.mjs: standalone entry with a console registry. ADMIN_CONSOLES
  selects which consoles a process mounts (default both), so splitting into two
  processes later is a config change, not a code change.
- admin-guard.mjs (+ tests): per-console host / IP-CIDR allowlists, letting the
  super-admin surface be locked down harder than moderation.

server.mjs no longer serves or mounts either surface. user-auth.mjs gains
pagination on listUsers/listUsageRecords/listBillingLedger to back the admin
dashboards. dev.mjs launches memind_adm; the ops SPA proxies /api to it while
/auth stays on the portal. Sessions are read from the shared cookie
(H5_COOKIE_DOMAIN=.tkmind.cn); login stays on the main domain.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 20:55:23 +08:00

310 lines
9.6 KiB
JavaScript
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env node
import { spawn, execSync } from 'node:child_process';
import fs from 'node:fs';
import http from 'node:http';
import https from 'node:https';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const root = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
const plazaDir = process.env.PLAZA_APP_DIR ?? path.join(root, '../tkmind_go/ui/plaza');
const opsDir = path.join(root, 'ops');
const portalPort = Number(process.env.H5_PORT ?? 8081);
const vitePort = Number(process.env.VITE_PORT ?? 5173);
const plazaPort = Number(process.env.PLAZA_PORT ?? 3001);
const opsPort = Number(process.env.OPS_PORT ?? 3002);
const adminPort = Number(process.env.ADMIN_PORT ?? 8082);
const portalUrl = `http://127.0.0.1:${portalPort}`;
const adminUrl = `http://127.0.0.1:${adminPort}`;
const plazaHost = process.env.PLAZA_LOCAL_HOST ?? 'plaza.tkmind.cn';
const plazaPublicPort = Number(process.env.PLAZA_PUBLIC_PORT ?? 443);
const plazaPublicScheme = String(process.env.PLAZA_PUBLIC_BASE ?? 'https://plaza.tkmind.cn').startsWith('http://')
? 'http'
: 'https';
const plazaPublicBase = (
process.env.PLAZA_PUBLIC_BASE ??
(plazaPublicPort === 80 || plazaPublicPort === 443
? `${plazaPublicScheme}://${plazaHost}`
: `${plazaPublicScheme}://${plazaHost}:${plazaPublicPort}`)
).replace(/\/$/, '');
const plazaUrl = plazaPublicBase;
const opsUrl = `http://127.0.0.1:${opsPort}`;
function loadEnvFile(filePath) {
if (!fs.existsSync(filePath)) return;
for (const line of fs.readFileSync(filePath, 'utf8').split('\n')) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) continue;
const eq = trimmed.indexOf('=');
if (eq < 0) continue;
const key = trimmed.slice(0, eq).trim();
const value = trimmed.slice(eq + 1).trim();
if (!process.env[key]) process.env[key] = value;
}
}
loadEnvFile(path.join(root, '../../.env.local'));
loadEnvFile(path.join(root, '.env'));
function freePort(port) {
try {
execSync(`lsof -ti TCP:${port} -sTCP:LISTEN | xargs kill -9`, { stdio: 'ignore' });
} catch {
// port already free
}
}
function spawnChild(command, args, label, cwd = root, extraEnv = {}) {
const child = spawn(command, args, {
cwd,
env: { ...process.env, ...extraEnv },
stdio: 'inherit',
});
child.on('exit', (code, signal) => {
if (signal) return;
if (code && code !== 0) {
console.error(`[${label}] exited with code ${code}`);
shutdown(code ?? 1);
}
});
return child;
}
let server;
let admin;
let plaza;
let plazaProxy;
let ops;
let vite;
let stopping = false;
function shutdown(code = 0) {
if (stopping) return;
stopping = true;
server?.kill('SIGTERM');
admin?.kill('SIGTERM');
plaza?.kill('SIGTERM');
plazaProxy?.kill('SIGTERM');
ops?.kill('SIGTERM');
vite?.kill('SIGTERM');
setTimeout(() => process.exit(code), 300);
}
process.on('SIGINT', () => shutdown(0));
process.on('SIGTERM', () => shutdown(0));
async function waitFor(url, check, label, retries = 60) {
for (let i = 0; i < retries; i += 1) {
try {
if (await check(url)) return;
} catch {
// still starting
}
await new Promise((resolve) => setTimeout(resolve, 500));
}
throw new Error(`${label} 未在 ${url} 启动`);
}
const mindSpacePublicBase = (
process.env.H5_PUBLIC_BASE_URL ?? `http://localhost:${vitePort}`
).replace(/\/$/, '');
const plazaEnv = {
PLAZA_API_PROXY: portalUrl,
PLAZA_API_BASE: portalUrl,
PLAZA_PUBLIC_BASE: plazaPublicBase,
NEXT_PUBLIC_API_BASE: plazaPublicBase,
NEXT_PUBLIC_SITE_BASE: plazaPublicBase,
NEXT_PUBLIC_MINDSPACE_BASE: mindSpacePublicBase,
NEXT_PUBLIC_PLAZA_BASE: '/plaza',
};
const viteEnv = {
VITE_PLAZA_BASE: plazaPublicBase,
VITE_MINDSPACE_BASE: mindSpacePublicBase,
};
const opsEnv = {
// /auth (login/session) stays on the public portal; /api (ops console) hits memind_adm.
OPS_API_PROXY: portalUrl,
OPS_ADMIN_PROXY: adminUrl,
VITE_PLAZA_BASE: plazaPublicBase,
VITE_MINDSPACE_BASE: mindSpacePublicBase,
};
function ensurePlazaApp() {
if (!fs.existsSync(path.join(plazaDir, 'package.json'))) {
throw new Error(`未找到 Plaza Next.js${plazaDir}`);
}
}
function plazaPublicReachable() {
const url = new URL(`${plazaPublicBase}/plaza`);
const isHttps = url.protocol === 'https:';
const port = url.port || (isHttps ? 443 : 80);
return new Promise((resolve) => {
const transport = isHttps ? https : http;
const req = transport.request(
{
hostname: url.hostname,
port,
path: `${url.pathname}${url.search}`,
method: 'GET',
rejectUnauthorized: false,
},
(res) => {
resolve(res.statusCode === 200 || res.statusCode === 307 || res.statusCode === 308);
},
);
req.on('error', () => resolve(false));
req.setTimeout(3000, () => {
req.destroy();
resolve(false);
});
req.end();
});
}
async function ensurePlazaPublicProxy() {
if (await plazaPublicReachable()) {
console.log(`==> Plaza 公开入口已可用: ${plazaPublicBase}/plaza`);
return;
}
console.log(`==> 尝试启动 Plaza 80 端口代理 (${plazaPublicBase})...`);
const proxyScript = path.join(root, 'scripts/plaza-local-proxy.mjs');
plazaProxy = spawn('node', [proxyScript], {
cwd: root,
env: process.env,
stdio: ['ignore', 'pipe', 'pipe'],
});
let proxyFailed = false;
plazaProxy.stdout?.on('data', (chunk) => process.stdout.write(`[plaza-proxy] ${chunk}`));
plazaProxy.stderr?.on('data', (chunk) => {
const text = String(chunk);
process.stderr.write(`[plaza-proxy] ${text}`);
if (/EACCES|EADDRINUSE|需要 root/.test(text)) proxyFailed = true;
});
plazaProxy.on('exit', (code) => {
if (code && code !== 0) proxyFailed = true;
});
for (let i = 0; i < 20; i += 1) {
if (proxyFailed) break;
if (await plazaPublicReachable()) {
console.log(`==> Plaza 公开入口已可用: ${plazaPublicBase}/plaza`);
return;
}
await new Promise((resolve) => setTimeout(resolve, 250));
}
console.warn('');
console.warn(`⚠ 无法在本进程绑定 ${plazaPublicPort} 端口,${plazaPublicBase} 暂不可用`);
console.warn(' 请先:sudo pnpm setup:plaza-local');
console.warn(' 再另开终端:sudo pnpm dev:plaza-proxy');
console.warn('');
}
function plazaHostConfigured(host) {
try {
const hosts = fs.readFileSync('/etc/hosts', 'utf8');
return new RegExp(`^\\s*127\\.0\\.0\\.1\\s+${host.replace('.', '\\.')}(\\s|$)`, 'm').test(hosts);
} catch {
return false;
}
}
console.log(`==> 清理端口 ${portalPort} / ${adminPort} / ${vitePort} / ${plazaPort} / ${opsPort}...`);
freePort(portalPort);
freePort(adminPort);
freePort(vitePort);
freePort(plazaPort);
freePort(opsPort);
await new Promise((resolve) => setTimeout(resolve, 500));
ensurePlazaApp();
if (!plazaHostConfigured(plazaHost)) {
console.warn('');
console.warn(`${plazaHost} 尚未写入 /etc/hosts,浏览器无法打开 ${plazaPublicBase}`);
console.warn(' 请执行:sudo pnpm setup:plaza-dns');
console.warn('');
}
console.log('==> 启动 portal (server.mjs)...');
server = spawnChild('node', ['server.mjs'], 'portal');
try {
await waitFor(portalUrl, async (url) => (await fetch(`${url}/auth/status`)).ok, 'Portal');
console.log(`==> Portal 就绪: ${portalUrl}`);
console.log('==> 启动 memind_adm (admin-server.mjs)...');
admin = spawnChild('node', ['admin-server.mjs'], 'admin');
await waitFor(adminUrl, async (url) => (await fetch(`${url}/healthz`)).ok, 'Admin');
console.log(`==> memind_adm 就绪: ${adminUrl}`);
console.log(`==> 启动 Plaza Next.js @ ${plazaPublicBase} (bind ${plazaHost})`);
plaza = spawnChild(
'npm',
['run', 'dev', '--', '-H', '0.0.0.0', '-p', String(plazaPort)],
'plaza',
plazaDir,
plazaEnv,
);
await waitFor(
`http://127.0.0.1:${plazaPort}`,
async (url) => {
const res = await fetch(`${url}/plaza`, {
headers: { Host: `${plazaHost}:${plazaPort}` },
});
return res.ok || res.status === 307 || res.status === 308;
},
'Plaza',
80,
);
console.log(`==> Plaza 内部就绪: http://127.0.0.1:${plazaPort}/plaza`);
await ensurePlazaPublicProxy();
console.log(`==> 启动 Ops 后台 @ ${opsUrl}/ops/`);
ops = spawnChild('npm', ['run', 'dev'], 'ops', opsDir, opsEnv);
await waitFor(
`http://localhost:${opsPort}`,
async (url) => (await fetch(`${url}/ops/`)).ok,
'Ops',
40,
);
console.log(`==> Ops 就绪: http://localhost:${opsPort}/ops/`);
console.log(`==> 启动 Vite @ http://localhost:${vitePort}`);
vite = spawnChild('npx', ['vite'], 'vite', root, viteEnv);
await waitFor(
`http://127.0.0.1:${vitePort}`,
async (url) => {
const [rootRes, mainRes] = await Promise.all([
fetch(`${url}/`),
fetch(`${url}/src/main.tsx`),
]);
return rootRes.ok && mainRes.ok;
},
'Vite',
);
console.log(`==> Vite 就绪: http://localhost:${vitePort}`);
console.log('');
console.log('本地服务:');
console.log(` MindSpace UI http://localhost:${vitePort}/?preview=mindspace`);
console.log(` Plaza 广场 ${plazaPublicBase}/plaza`);
console.log(` Plaza 内部 http://127.0.0.1:${plazaPort}/plaza`);
console.log(` (setup) sudo pnpm setup:plaza-local && sudo pnpm dev:plaza-proxy`);
console.log(` Ops 审核后台 http://localhost:${opsPort}/ops/`);
console.log(` API / Portal ${portalUrl}`);
console.log(` memind_adm ${adminUrl} (gadm.tkmind.cn)`);
console.log('');
console.log('首次使用 Ops:先登录 MindSpace,再执行 node scripts/grant-ops-role.mjs admin ops_admin');
} catch (err) {
console.error(err instanceof Error ? err.message : err);
shutdown(1);
}