diff --git a/.env.example b/.env.example index 5b64153..8826ca0 100644 --- a/.env.example +++ b/.env.example @@ -1,30 +1,26 @@ # TKMind H5 环境变量(复制为 .env 后按需修改) # -# 本地开发(默认只做本地,不自动发布生产): -# 1. pnpm dev # 热更新 (http://127.0.0.1:5173) -# 2. pnpm run test:local # 生产构建本地跑 (http://127.0.0.1:8081) -# 3. pnpm run test:fruit-john-e2e # john 水果主题:预览图 + 可点击链接 + 我的空间 -# 4. node scripts/mindspace-rebuild-thumbnails.mjs # 批量重建预览图(可选) -# -# 发布生产:仅当明确要求「发布生产」时再执行: -# pnpm run deploy:105 或 pnpm run deploy:prod 或 ./rsync_to_server.sh -# 105 配置见 deploy/h5-105.env.example +# 本地开发(默认 test.* 子域,见 docs/local-dev.md) +# 1. sudo pnpm setup:local-test +# 2. pnpm dev +# 3. sudo pnpm dev:local-proxy # 另开终端 +# 访问:https://test.tkmind.cn/?preview=mindspace # # 后端代理(server.mjs / vite dev proxy 共用) TKMIND_API_TARGET=https://127.0.0.1:18006 TKMIND_SERVER__SECRET_KEY=local-dev-secret H5_PORT=8081 -# Agent 生成链接用(本地开发建议 8081;生产再改为 https://goo.tkmind.cn) -H5_PUBLIC_BASE_URL=http://127.0.0.1:8081 -# MindSpace 与 Plaza 跨子域共享登录(如 g2.tkmind.cn + plaza.tkmind.cn) -# H5_COOKIE_DOMAIN=.tkmind.cn +# Agent 生成链接 / OAuth 回调(本地用 test 域名) +H5_PUBLIC_BASE_URL=https://test.tkmind.cn +# MindSpace 与 Plaza / Ops 跨子域共享登录 +H5_COOKIE_DOMAIN=.tkmind.cn # ===== memind_adm(独立后台服务)===== # 平台超管 /admin-api/* 与广场运营 /api/ops/v1/* 已从公网服务拆出,独立进程运行。 # 登录仍在主域完成;后台凭 H5_COOKIE_DOMAIN=.tkmind.cn 共享的会话 Cookie 鉴权(生产务必设置)。 ADMIN_PORT=8082 ADMIN_HOST=127.0.0.1 -ADMIN_PUBLIC_HOST=gadm.tkmind.cn +ADMIN_PUBLIC_HOST=testadm.tkmind.cn # 本进程挂载哪些控制台(可拆分架构):admin=平台超管, ops=广场运营。 # 默认两者合一;将来拆成两个进程时,A 实例设 admin、B 实例设 ops,路由代码无需改动。 @@ -144,15 +140,11 @@ VITE_TKMIND_WORKING_DIR=/Users/john/PycharmProjects/tkmind # Plaza 发现广场(本机为主,105 已停用) # PLAZA_AUTO_APPROVE=true -# 文档:docs/plaza-local.md -# 首次:sudo pnpm setup:plaza-dns # 仅纯本地 HTTPS 时需要;走 Tunnel 请用 setup:plaza-dns:remove -# 日常:pnpm dev:plaza | 浏览器:pnpm open:plaza | 诊断:pnpm check:plaza -# 公网 plaza.tkmind.cn 经 Cloudflare Tunnel → 本机 :3001(需 dev:plaza 常驻) -# PLAZA_LOCAL_HOST=plaza.tkmind.cn -# PLAZA_PUBLIC_BASE=https://plaza.tkmind.cn -# VITE_PLAZA_BASE=https://plaza.tkmind.cn -# CLOUDFLARE_TUNNEL_CONFIG=/Users/john/Project/ollama/cloudflare/config.yml -# CLOUDFLARE_TUNNEL_NAME=ollama-tkmind +# 本地 test 域名(docs/local-dev.md): +# PLAZA_LOCAL_HOST=testpla.tkmind.cn +# PLAZA_PUBLIC_BASE=https://testpla.tkmind.cn +# VITE_PLAZA_BASE=https://testpla.tkmind.cn +# 公网 plaza.tkmind.cn 见 docs/plaza-local.md # MindSpace(默认启用;子功能需按需开启) # MINDSPACE_ENABLED=false diff --git a/README.md b/README.md index 5f445bc..9673a63 100644 --- a/README.md +++ b/README.md @@ -9,9 +9,20 @@ Memind 主应用与 Plaza 发现广场。**当前以本机 Mac 为主服务**, ```bash pnpm install cp .env.example .env # 按需填写 +sudo pnpm setup:local-test # 一次性:test.* 本地 DNS + TLS pnpm dev # MindSpace + Plaza + Ops +sudo pnpm dev:local-proxy # 另开终端:HTTPS :443 统一入口 ``` +**本地访问请用 test 域名**(不用 127.0.0.1 / localhost),详见 **[docs/local-dev.md](docs/local-dev.md)**: + +| 服务 | 地址 | +|------|------| +| MindSpace H5 | https://test.tkmind.cn/?preview=mindspace | +| Plaza | https://testpla.tkmind.cn/plaza | +| Ops 审核后台 | https://testops.tkmind.cn/ops/ | +| memind_adm | https://testadm.tkmind.cn | + 仅 Plaza: ```bash @@ -40,6 +51,10 @@ pnpm open:plaza # 浏览器打开 https://plaza.tkmind.cn/plaza | 命令 | 说明 | |------|------| | `pnpm dev` | 全栈开发 | +| `sudo pnpm setup:local-test` | 首次配置 test.* 本地域名 | +| `sudo pnpm dev:local-proxy` | HTTPS :443 统一入口 | +| `pnpm check:local-test` | 本地 test 域名诊断 | +| `pnpm open:local-test` | 浏览器打开 H5 | | `pnpm start:plaza` | Plaza 正式模式 + Portal | | `pnpm dev:plaza` | Plaza 开发模式 + Portal | | `pnpm open:plaza` | 浏览器强制本地解析 | @@ -48,4 +63,5 @@ pnpm open:plaza # 浏览器打开 https://plaza.tkmind.cn/plaza ## 文档 +- [本地开发(test.*.tkmind.cn)](docs/local-dev.md) - [Plaza 本机部署与 Tunnel](docs/plaza-local.md) diff --git a/docs/local-dev.md b/docs/local-dev.md new file mode 100644 index 0000000..c54157c --- /dev/null +++ b/docs/local-dev.md @@ -0,0 +1,126 @@ +# 本地开发(test.*.tkmind.cn) + +本地开发统一使用 **test 子域**,不再用 `127.0.0.1` / `localhost` 访问。结构与生产一致,`.tkmind.cn` Cookie 可跨子域共享登录态。 + +## 域名映射 + +| 域名 | 服务 | 内部端口 | +|------|------|----------| +| **test.tkmind.cn** | MindSpace H5(Vite 热更新) | 5173 | +| **testadm.tkmind.cn** | memind_adm 后台 API | 8082 | +| **testpla.tkmind.cn** | Plaza 发现广场 | 3001 | +| **testops.tkmind.cn** | Ops 审核 / 超管 SPA | 3002 | + +Portal(用户 API、登录、`/auth`)仍跑在 **8081**,由 Vite / Plaza 内部代理,**无需单独域名**。 + +``` +浏览器 + ↓ HTTPS :443(local-test-proxy,按 Host 分流) + ├─ test.tkmind.cn → :5173 Vite(/api、/auth → :8081) + ├─ testadm.tkmind.cn → :8082 memind_adm + ├─ testpla.tkmind.cn → :3001 Plaza Next.js + └─ testops.tkmind.cn → :3002 Ops SPA +``` + +## 首次配置(一次性) + +```bash +# 1. 安装依赖 +pnpm install +cd ops && npm install && cd .. + +# 2. 环境变量(按需) +cp .env.example .env + +# 3. 本地 DNS(覆盖 Tailscale / Clash fake-ip) +sudo pnpm setup:local-test + +# 或分步: +# sudo pnpm setup:local-dns +# sudo pnpm setup:local-tls +``` + +`.env` 建议至少设置: + +```bash +H5_PUBLIC_BASE_URL=https://test.tkmind.cn +H5_COOKIE_DOMAIN=.tkmind.cn +PLAZA_LOCAL_HOST=testpla.tkmind.cn +PLAZA_PUBLIC_BASE=https://testpla.tkmind.cn +ADMIN_PUBLIC_HOST=testadm.tkmind.cn +PLAZA_APP_DIR=/Users/john/PycharmProjects/goose/ui/plaza # Plaza 源码路径 +``` + +## 日常启动 + +**终端 1 — 全栈开发:** + +```bash +PLAZA_APP_DIR=/Users/john/PycharmProjects/goose/ui/plaza pnpm dev +``` + +**终端 2 — HTTPS 统一入口(需 root):** + +```bash +sudo pnpm dev:local-proxy +``` + +## 访问地址 + +| 场景 | 地址 | +|------|------| +| MindSpace | https://test.tkmind.cn/?preview=mindspace | +| Plaza | https://testpla.tkmind.cn/plaza | +| Ops 审核后台 | https://testops.tkmind.cn/ops/ | +| memind_adm 健康检查 | https://testadm.tkmind.cn/healthz | +| 一键打开 H5 | `pnpm open:local-test` | + +## 诊断 + +```bash +pnpm check:local-test +``` + +## Ops 首次授权 + +先在 MindSpace 登录,再执行: + +```bash +node scripts/grant-ops-role.mjs admin ops_admin +``` + +## 浏览器「连接不是私密连接」? + +本地 HTTPS 使用自签证书(`.local/local-test-tls/`)。首次访问各 test 域名时,在浏览器中信任证书即可。 + +若 Chrome 仍走 Secure DNS 绕过 `/etc/hosts`: + +1. 关闭「使用安全 DNS」 +2. 或执行 `pnpm open:local-test` + +## 移除本地劫持 + +```bash +sudo pnpm setup:local-dns:remove +``` + +## 与 plaza.tkmind.cn 的关系 + +- **testpla.tkmind.cn**:纯本地开发,不与公网冲突 +- **plaza.tkmind.cn**:公网 Tunnel 回源,见 [plaza-local.md](./plaza-local.md) + +两套域名可并存;日常 Memind 全栈开发优先用 `test.*`。 + +## 环境变量 + +| 变量 | 默认 | 说明 | +|------|------|------| +| `H5_LOCAL_HOST` | test.tkmind.cn | H5 测试域 | +| `ADMIN_LOCAL_HOST` | testadm.tkmind.cn | 后台 API 测试域 | +| `PLAZA_LOCAL_HOST` | testpla.tkmind.cn | Plaza 测试域 | +| `OPS_LOCAL_HOST` | testops.tkmind.cn | Ops 测试域 | +| `H5_PUBLIC_BASE_URL` | https://test.tkmind.cn | 公开链接 / OAuth 回调基址 | +| `H5_COOKIE_DOMAIN` | (建议 `.tkmind.cn`) | 跨子域共享登录 | +| `LOCAL_TEST_DNS_PORT` | 5533 | 本地 DNS 监听端口 | +| `LOCAL_TEST_PUBLIC_PORT` | 443 | HTTPS 代理端口 | +| `LOCAL_TEST_HTTPS` | 1 | 设为 `0` 时用 HTTP :80 | diff --git a/ops/vite.config.ts b/ops/vite.config.ts index cd4df0d..e18cac8 100644 --- a/ops/vite.config.ts +++ b/ops/vite.config.ts @@ -10,7 +10,9 @@ export default defineConfig({ base: '/ops/', plugins: [react()], server: { + host: '0.0.0.0', port: 3002, + allowedHosts: ['.tkmind.cn', 'testops.tkmind.cn', 'localhost'], proxy: { '/admin-api': adminProxy, '/api': adminProxy, diff --git a/package.json b/package.json index 471ea1a..8854ba7 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,14 @@ "dev:plaza": "node scripts/dev-plaza.mjs", "start:plaza": "node scripts/start-plaza-prod.mjs", "dev:plaza-proxy": "node scripts/plaza-local-proxy.mjs", + "setup:local-test": "node scripts/setup-local-test.mjs", + "setup:local-dns": "node scripts/setup-local-test-dns.mjs", + "setup:local-dns:remove": "node scripts/setup-local-test-dns.mjs --remove", + "setup:local-tls": "node scripts/local-test-tls.mjs", + "dev:local-dns": "node scripts/local-test-dns-server.mjs", + "dev:local-proxy": "node scripts/local-test-proxy.mjs", + "check:local-test": "node scripts/check-local-test.mjs", + "open:local-test": "node scripts/open-local-test.mjs", "setup:plaza-dns": "node scripts/setup-plaza-local-dns.mjs", "setup:plaza-dns:remove": "node scripts/setup-plaza-local-dns.mjs --remove", "dev:plaza-dns": "node scripts/plaza-local-dns-server.mjs", diff --git a/scripts/check-local-test.mjs b/scripts/check-local-test.mjs new file mode 100644 index 0000000..e5758d2 --- /dev/null +++ b/scripts/check-local-test.mjs @@ -0,0 +1,95 @@ +#!/usr/bin/env node +import fs from 'node:fs'; +import http from 'node:http'; +import https from 'node:https'; +import { + ADMIN_HOST, + allHostsConfigured, + DNS_PORT, + H5_HOST, + H5_PUBLIC_BASE, + OPS_HOST, + PLAZA_HOST, + PLAZA_PUBLIC_BASE, + PUBLIC_PORT, + PUBLIC_SCHEME, + publicUrl, + resolverConfigured, + hostConfigured, +} from './local-test-config.mjs'; + +function checkUrl(url) { + const parsed = new URL(url); + const transport = parsed.protocol === 'https:' ? https : http; + return new Promise((resolve) => { + const req = transport.request( + { + hostname: parsed.hostname, + port: parsed.port || (parsed.protocol === 'https:' ? 443 : 80), + path: `${parsed.pathname}${parsed.search}`, + method: 'GET', + rejectUnauthorized: false, + headers: parsed.hostname === PLAZA_HOST ? { Host: `${PLAZA_HOST}:${process.env.PLAZA_PORT ?? 3001}` } : {}, + }, + (res) => resolve(res.statusCode ?? 0), + ); + req.on('error', () => resolve(0)); + req.setTimeout(3000, () => { + req.destroy(); + resolve(0); + }); + req.end(); + }); +} + +console.log('==> 本地测试域名诊断'); +console.log(''); + +const issues = []; + +for (const host of [H5_HOST, ADMIN_HOST, PLAZA_HOST, OPS_HOST]) { + if (!hostConfigured(host)) issues.push(`${host} 未写入 /etc/hosts → sudo pnpm setup:local-dns`); + else if (!resolverConfigured(host)) issues.push(`${host} 缺少 /etc/resolver → sudo pnpm setup:local-dns`); + else console.log(`✓ DNS ${host}`); +} + +try { + const resolver = fs.readFileSync(`/etc/resolver/${H5_HOST}`, 'utf8'); + const portMatch = resolver.match(/^port\s+(\d+)/m); + if (portMatch && Number(portMatch[1]) !== DNS_PORT) { + issues.push(`resolver 端口应为 ${DNS_PORT},请重新执行 sudo pnpm setup:local-dns`); + } +} catch { + // covered above +} + +console.log(''); +console.log('==> HTTPS 入口(需 sudo pnpm dev:local-proxy)'); + +const checks = [ + ['H5', `${H5_PUBLIC_BASE}/`, 200], + ['memind_adm', `${publicUrl(ADMIN_HOST)}/healthz`, 200], + ['Plaza', `${PLAZA_PUBLIC_BASE}/plaza`, 200], + ['Ops', `${publicUrl(OPS_HOST)}/ops/`, 200], +]; + +for (const [label, url, expected] of checks) { + const code = await checkUrl(url); + if (code === expected || (label === 'Plaza' && [200, 307, 308].includes(code))) { + console.log(`✓ ${label.padEnd(12)} ${url} → ${code}`); + } else { + console.log(`✗ ${label.padEnd(12)} ${url} → ${code || '不可达'}`); + if (code === 0) { + issues.push(`${label} 不可达:先 pnpm dev,再 sudo pnpm dev:local-proxy`); + } + } +} + +console.log(''); +if (issues.length) { + console.log('待处理:'); + for (const issue of issues) console.log(` - ${issue}`); + process.exit(1); +} + +console.log('本地测试环境正常。'); diff --git a/scripts/dev.mjs b/scripts/dev.mjs index 033ddc1..62afc41 100755 --- a/scripts/dev.mjs +++ b/scripts/dev.mjs @@ -7,28 +7,6 @@ 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; @@ -46,6 +24,28 @@ function loadEnvFile(filePath) { loadEnvFile(path.join(root, '../../.env.local')); loadEnvFile(path.join(root, '.env')); +const { + ADMIN_HOST, + allHostsConfigured, + DNS_PORT, + H5_HOST, + H5_PUBLIC_BASE, + OPS_HOST, + PLAZA_HOST, + PLAZA_PUBLIC_BASE, + publicUrl, +} = await import('./local-test-config.mjs'); + +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}`; + function freePort(port) { try { execSync(`lsof -ti TCP:${port} -sTCP:LISTEN | xargs kill -9`, { stdio: 'ignore' }); @@ -73,7 +73,8 @@ function spawnChild(command, args, label, cwd = root, extraEnv = {}) { let server; let admin; let plaza; -let plazaProxy; +let localProxy; +let dnsServer; let ops; let vite; let stopping = false; @@ -84,7 +85,8 @@ function shutdown(code = 0) { server?.kill('SIGTERM'); admin?.kill('SIGTERM'); plaza?.kill('SIGTERM'); - plazaProxy?.kill('SIGTERM'); + localProxy?.kill('SIGTERM'); + dnsServer?.kill('SIGTERM'); ops?.kill('SIGTERM'); vite?.kill('SIGTERM'); setTimeout(() => process.exit(code), 300); @@ -105,30 +107,27 @@ async function waitFor(url, check, label, retries = 60) { throw new Error(`${label} 未在 ${url} 启动`); } -const mindSpacePublicBase = ( - process.env.H5_PUBLIC_BASE_URL ?? `http://localhost:${vitePort}` -).replace(/\/$/, ''); +const mindSpacePublicBase = H5_PUBLIC_BASE; const plazaEnv = { PLAZA_API_PROXY: portalUrl, PLAZA_API_BASE: portalUrl, - PLAZA_PUBLIC_BASE: plazaPublicBase, - NEXT_PUBLIC_API_BASE: plazaPublicBase, - NEXT_PUBLIC_SITE_BASE: plazaPublicBase, + PLAZA_PUBLIC_BASE: PLAZA_PUBLIC_BASE, + NEXT_PUBLIC_API_BASE: PLAZA_PUBLIC_BASE, + NEXT_PUBLIC_SITE_BASE: PLAZA_PUBLIC_BASE, NEXT_PUBLIC_MINDSPACE_BASE: mindSpacePublicBase, NEXT_PUBLIC_PLAZA_BASE: '/plaza', }; const viteEnv = { - VITE_PLAZA_BASE: plazaPublicBase, + VITE_PLAZA_BASE: PLAZA_PUBLIC_BASE, 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_PLAZA_BASE: PLAZA_PUBLIC_BASE, VITE_MINDSPACE_BASE: mindSpacePublicBase, }; @@ -138,8 +137,8 @@ function ensurePlazaApp() { } } -function plazaPublicReachable() { - const url = new URL(`${plazaPublicBase}/plaza`); +function publicReachable(urlString) { + const url = new URL(urlString); const isHttps = url.protocol === 'https:'; const port = url.port || (isHttps ? 443 : 80); @@ -166,86 +165,84 @@ function plazaPublicReachable() { }); } -async function ensurePlazaPublicProxy() { - if (await plazaPublicReachable()) { - console.log(`==> Plaza 公开入口已可用: ${plazaPublicBase}/plaza`); +async function ensureLocalTestProxy() { + const h5Ready = await publicReachable(`${H5_PUBLIC_BASE}/`); + if (h5Ready) { + console.log(`==> 本地测试 HTTPS 入口已可用: ${H5_PUBLIC_BASE}`); return; } - console.log(`==> 尝试启动 Plaza 80 端口代理 (${plazaPublicBase})...`); - const proxyScript = path.join(root, 'scripts/plaza-local-proxy.mjs'); - plazaProxy = spawn('node', [proxyScript], { + console.log(`==> 尝试启动本地 HTTPS 代理 (${H5_PUBLIC_BASE})...`); + const proxyScript = path.join(root, 'scripts/local-test-proxy.mjs'); + localProxy = 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) => { + localProxy.stdout?.on('data', (chunk) => process.stdout.write(`[local-proxy] ${chunk}`)); + localProxy.stderr?.on('data', (chunk) => { const text = String(chunk); - process.stderr.write(`[plaza-proxy] ${text}`); + process.stderr.write(`[local-proxy] ${text}`); if (/EACCES|EADDRINUSE|需要 root/.test(text)) proxyFailed = true; }); - plazaProxy.on('exit', (code) => { + localProxy.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`); + if (await publicReachable(`${H5_PUBLIC_BASE}/`)) { + console.log(`==> 本地测试 HTTPS 入口已可用: ${H5_PUBLIC_BASE}`); 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('⚠ 本地 test 域名 HTTPS 入口暂不可用'); + console.warn(' 请先:sudo pnpm setup:local-test'); + console.warn(' 再另开终端:sudo pnpm dev:local-proxy'); + console.warn(' 文档:docs/local-dev.md'); 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}...`); +console.log(`==> 清理端口 ${portalPort} / ${adminPort} / ${vitePort} / ${plazaPort} / ${opsPort} / ${DNS_PORT}...`); freePort(portalPort); freePort(adminPort); freePort(vitePort); freePort(plazaPort); freePort(opsPort); +freePort(DNS_PORT); await new Promise((resolve) => setTimeout(resolve, 500)); ensurePlazaApp(); -if (!plazaHostConfigured(plazaHost)) { +if (!allHostsConfigured()) { console.warn(''); - console.warn(`⚠ ${plazaHost} 尚未写入 /etc/hosts,浏览器无法打开 ${plazaPublicBase}`); - console.warn(' 请执行:sudo pnpm setup:plaza-dns'); + console.warn('⚠ 本地 test 域名 DNS 未完整配置(Tailscale/Clash 可能劫持解析)'); + console.warn(' 请执行:sudo pnpm setup:local-test'); + console.warn(' 文档:docs/local-dev.md'); console.warn(''); } +console.log('==> 启动本地 test DNS...'); +dnsServer = spawnChild('node', ['scripts/local-test-dns-server.mjs'], 'local-dns'); + 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(`==> 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(`==> memind_adm 就绪 (内部 ${adminUrl} → ${publicUrl(ADMIN_HOST)})`); - console.log(`==> 启动 Plaza Next.js @ ${plazaPublicBase} (bind ${plazaHost})`); + console.log(`==> 启动 Plaza Next.js @ ${PLAZA_PUBLIC_BASE} (bind ${PLAZA_HOST})`); plaza = spawnChild( 'npm', ['run', 'dev', '--', '-H', '0.0.0.0', '-p', String(plazaPort)], @@ -257,27 +254,26 @@ try { `http://127.0.0.1:${plazaPort}`, async (url) => { const res = await fetch(`${url}/plaza`, { - headers: { Host: `${plazaHost}:${plazaPort}` }, + headers: { Host: `${PLAZA_HOST}:${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(`==> Plaza 就绪: ${PLAZA_PUBLIC_BASE}/plaza`); - console.log(`==> 启动 Ops 后台 @ ${opsUrl}/ops/`); + console.log(`==> 启动 Ops 后台 @ ${publicUrl(OPS_HOST)}/ops/`); ops = spawnChild('npm', ['run', 'dev'], 'ops', opsDir, opsEnv); await waitFor( - `http://localhost:${opsPort}`, + `http://127.0.0.1:${opsPort}`, async (url) => (await fetch(`${url}/ops/`)).ok, 'Ops', 40, ); - console.log(`==> Ops 就绪: http://localhost:${opsPort}/ops/`); + console.log(`==> Ops 就绪: ${publicUrl(OPS_HOST)}/ops/`); - console.log(`==> 启动 Vite @ http://localhost:${vitePort}`); + console.log(`==> 启动 Vite @ ${H5_PUBLIC_BASE}`); vite = spawnChild('npx', ['vite'], 'vite', root, viteEnv); await waitFor( `http://127.0.0.1:${vitePort}`, @@ -290,17 +286,19 @@ try { }, 'Vite', ); - console.log(`==> Vite 就绪: http://localhost:${vitePort}`); + console.log(`==> Vite 就绪: ${H5_PUBLIC_BASE}`); + + await ensureLocalTestProxy(); 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('本地服务(请用 test 域名访问,见 docs/local-dev.md):'); + console.log(` MindSpace UI ${H5_PUBLIC_BASE}/?preview=mindspace`); + console.log(` Plaza 广场 ${PLAZA_PUBLIC_BASE}/plaza`); + console.log(` Ops 审核后台 ${publicUrl(OPS_HOST)}/ops/`); + console.log(` memind_adm ${publicUrl(ADMIN_HOST)}`); + console.log(''); + console.log('HTTPS 代理:sudo pnpm dev:local-proxy'); + console.log('诊断:pnpm check:local-test'); console.log(''); console.log('首次使用 Ops:先登录 MindSpace,再执行 node scripts/grant-ops-role.mjs admin ops_admin'); } catch (err) { diff --git a/scripts/local-test-config.mjs b/scripts/local-test-config.mjs new file mode 100644 index 0000000..2398f9a --- /dev/null +++ b/scripts/local-test-config.mjs @@ -0,0 +1,66 @@ +/** + * Shared local test domain configuration. + * + * Default layout (mirrors production subdomain split): + * test.tkmind.cn → MindSpace H5 (Vite :5173) + * testadm.tkmind.cn → memind_adm (:8082) + * testpla.tkmind.cn → Plaza Next.js (:3001) + * testops.tkmind.cn → Ops console SPA (:3002) + */ +import fs from 'node:fs'; + +export const LOCAL_IP = process.env.LOCAL_TEST_IP ?? '127.0.0.1'; +export const DNS_PORT = Number(process.env.LOCAL_TEST_DNS_PORT ?? 5533); +export const DNS_MARKER = 'tkmind-local-test'; + +export const H5_HOST = process.env.H5_LOCAL_HOST ?? 'test.tkmind.cn'; +export const ADMIN_HOST = process.env.ADMIN_LOCAL_HOST ?? 'testadm.tkmind.cn'; +export const PLAZA_HOST = process.env.PLAZA_LOCAL_HOST ?? 'testpla.tkmind.cn'; +export const OPS_HOST = process.env.OPS_LOCAL_HOST ?? 'testops.tkmind.cn'; + +export const TEST_HOSTS = [H5_HOST, ADMIN_HOST, PLAZA_HOST, OPS_HOST]; + +export const PUBLIC_SCHEME = ( + process.env.LOCAL_TEST_SCHEME ?? + (String(process.env.LOCAL_TEST_HTTPS ?? '1') === '0' ? 'http' : 'https') +).replace(/:$/, ''); + +export const PUBLIC_PORT = Number( + process.env.LOCAL_TEST_PUBLIC_PORT ?? (PUBLIC_SCHEME === 'https' ? 443 : 80), +); + +export function publicUrl(host, { path = '' } = {}) { + const defaultPort = PUBLIC_SCHEME === 'https' ? 443 : 80; + const portSuffix = PUBLIC_PORT === defaultPort ? '' : `:${PUBLIC_PORT}`; + const normalizedPath = path.startsWith('/') ? path : path ? `/${path}` : ''; + return `${PUBLIC_SCHEME}://${host}${portSuffix}${normalizedPath}`; +} + +export const H5_PUBLIC_BASE = (process.env.H5_PUBLIC_BASE_URL ?? publicUrl(H5_HOST)).replace(/\/$/, ''); +export const ADMIN_PUBLIC_BASE = publicUrl(ADMIN_HOST).replace(/\/$/, ''); +export const PLAZA_PUBLIC_BASE = (process.env.PLAZA_PUBLIC_BASE ?? publicUrl(PLAZA_HOST)).replace(/\/$/, ''); +export const OPS_PUBLIC_BASE = publicUrl(OPS_HOST, { path: '/ops/' }).replace(/\/$/, '') + '/'; + +export function hostConfigured(host) { + try { + const hosts = fs.readFileSync('/etc/hosts', 'utf8'); + return new RegExp(`^\\s*${LOCAL_IP.replace('.', '\\.')}\\s+${host.replace('.', '\\.')}(\\s|$)`, 'm').test( + hosts, + ); + } catch { + return false; + } +} + +export function resolverConfigured(host) { + try { + const body = fs.readFileSync(`/etc/resolver/${host}`, 'utf8'); + return body.includes(DNS_MARKER); + } catch { + return false; + } +} + +export function allHostsConfigured() { + return TEST_HOSTS.every((host) => hostConfigured(host) && resolverConfigured(host)); +} diff --git a/scripts/local-test-dns-server.mjs b/scripts/local-test-dns-server.mjs new file mode 100644 index 0000000..4de838a --- /dev/null +++ b/scripts/local-test-dns-server.mjs @@ -0,0 +1,108 @@ +#!/usr/bin/env node +/** + * Minimal DNS server for local test domains -> 127.0.0.1 + * Works with /etc/resolver/ (macOS per-domain DNS override). + */ +import dgram from 'node:dgram'; +import { DNS_PORT, LOCAL_IP, TEST_HOSTS } from './local-test-config.mjs'; + +const ip = LOCAL_IP.split('.').map(Number); +const bind = process.env.LOCAL_TEST_DNS_BIND ?? '127.0.0.1'; +const hosts = new Set(TEST_HOSTS.flatMap((host) => [host, `${host}.`])); + +function encodeName(name) { + const parts = name.split('.').filter(Boolean); + return Buffer.concat([ + ...parts.map((part) => Buffer.concat([Buffer.from([part.length]), Buffer.from(part, 'ascii')])), + Buffer.from([0]), + ]); +} + +function readQuestionName(msg, offset) { + const labels = []; + let pos = offset; + while (pos < msg.length) { + const len = msg[pos]; + if (len === 0) { + pos += 1; + break; + } + labels.push(msg.subarray(pos + 1, pos + 1 + len).toString('ascii')); + pos += 1 + len; + } + return { name: labels.join('.'), next: pos }; +} + +function questionMatches(name) { + return hosts.has(name); +} + +const server = dgram.createSocket('udp4'); + +server.on('message', (msg, rinfo) => { + if (msg.length < 12) return; + + const qdCount = msg.readUInt16BE(4); + if (qdCount !== 1) return; + + const question = readQuestionName(msg, 12); + const qtype = msg.readUInt16BE(question.next); + const qclass = msg.readUInt16BE(question.next + 2); + const questionEnd = question.next + 4; + + if (!questionMatches(question.name) || (qtype !== 1 && qtype !== 28) || qclass !== 1) { + return; + } + + const questionSection = msg.subarray(12, questionEnd); + const ttl = Buffer.from([0x00, 0x00, 0x00, 0x3c]); + + let answer; + if (qtype === 1) { + answer = Buffer.concat([ + Buffer.from([0xc0, 0x0c]), + Buffer.from([0x00, 0x01, 0x00, 0x01]), + ttl, + Buffer.from([0x00, 0x04]), + Buffer.from(ip), + ]); + } else { + answer = Buffer.concat([ + Buffer.from([0xc0, 0x0c]), + Buffer.from([0x00, 0x1c, 0x00, 0x01]), + ttl, + Buffer.from([0x00, 0x10]), + Buffer.from([ + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0xff, 0xff, ip[0], ip[1], ip[2], ip[3], + ]), + ]); + } + + const header = Buffer.alloc(12); + header.writeUInt16BE(msg.readUInt16BE(0), 0); + header.writeUInt16BE(0x8180, 2); + header.writeUInt16BE(1, 4); + header.writeUInt16BE(1, 6); + header.writeUInt16BE(0, 8); + header.writeUInt16BE(0, 10); + + server.send(Buffer.concat([header, questionSection, answer]), rinfo.port, rinfo.address); +}); + +server.on('error', (err) => { + if (err.code === 'EADDRINUSE') { + console.error(`Local test DNS 端口 ${DNS_PORT} 已被占用`); + process.exit(1); + } + throw err; +}); + +server.bind(DNS_PORT, bind, () => { + console.log(`Local test DNS ${TEST_HOSTS.join(', ')} -> ${LOCAL_IP} @ ${bind}:${DNS_PORT}`); +}); + +for (const signal of ['SIGINT', 'SIGTERM']) { + process.on(signal, () => { + server.close(() => process.exit(0)); + }); +} diff --git a/scripts/local-test-proxy.mjs b/scripts/local-test-proxy.mjs new file mode 100644 index 0000000..dd287aa --- /dev/null +++ b/scripts/local-test-proxy.mjs @@ -0,0 +1,169 @@ +#!/usr/bin/env node +/** + * Unified HTTPS reverse proxy for local test domains. + * + * test.tkmind.cn -> Vite :5173 (H5 + HMR) + * testadm.tkmind.cn -> admin :8082 + * testpla.tkmind.cn -> Plaza :3001 + * testops.tkmind.cn -> Ops :3002 + * + * Requires root on macOS for :443: + * sudo pnpm dev:local-proxy + */ +import http from 'node:http'; +import https from 'node:https'; +import fs from 'node:fs'; +import { + ADMIN_HOST, + H5_HOST, + OPS_HOST, + PLAZA_HOST, + PUBLIC_PORT, + PUBLIC_SCHEME, + publicUrl, +} from './local-test-config.mjs'; +import { ensureLocalTestTls } from './local-test-tls.mjs'; + +const listenHost = process.env.LOCAL_TEST_PROXY_BIND ?? '0.0.0.0'; +const targetHost = process.env.LOCAL_TEST_PROXY_TARGET ?? '127.0.0.1'; +const vitePort = Number(process.env.VITE_PORT ?? 5173); +const adminPort = Number(process.env.ADMIN_PORT ?? 8082); +const plazaPort = Number(process.env.PLAZA_PORT ?? 3001); +const opsPort = Number(process.env.OPS_PORT ?? 3002); +const useHttps = PUBLIC_SCHEME === 'https'; + +const ROUTES = { + [H5_HOST.toLowerCase()]: { port: vitePort }, + [ADMIN_HOST.toLowerCase()]: { port: adminPort }, + [PLAZA_HOST.toLowerCase()]: { port: plazaPort, rewriteHost: true }, + [OPS_HOST.toLowerCase()]: { port: opsPort, rewriteHost: true }, +}; + +function routeFor(req) { + const hostname = String(req.headers.host ?? '').split(':')[0].toLowerCase(); + return ROUTES[hostname] ? { hostname, ...ROUTES[hostname] } : null; +} + +function proxyRequest(clientReq, clientRes) { + const route = routeFor(clientReq); + if (!route) { + clientRes.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' }); + clientRes.end(`Unknown host: ${clientReq.headers.host ?? '(missing)'}`); + return; + } + + const headers = { ...clientReq.headers }; + if (route.rewriteHost) { + headers.host = `${route.hostname}:${route.port}`; + } + + const proxyReq = http.request( + { + hostname: targetHost, + port: route.port, + method: clientReq.method, + path: clientReq.url, + headers, + }, + (proxyRes) => { + clientRes.writeHead(proxyRes.statusCode ?? 502, proxyRes.headers); + proxyRes.pipe(clientRes); + }, + ); + + proxyReq.on('error', (err) => { + if (!clientRes.headersSent) { + clientRes.writeHead(502, { 'Content-Type': 'text/plain; charset=utf-8' }); + } + clientRes.end( + `Upstream unavailable (${route.hostname} -> ${targetHost}:${route.port}): ${err.message}`, + ); + }); + + clientReq.pipe(proxyReq); +} + +function proxyUpgrade(clientReq, clientSocket, head) { + const route = routeFor(clientReq); + if (!route) { + clientSocket.destroy(); + return; + } + + const headers = { ...clientReq.headers }; + if (route.rewriteHost) { + headers.host = `${route.hostname}:${route.port}`; + } + + const proxyReq = http.request({ + hostname: targetHost, + port: route.port, + method: clientReq.method, + path: clientReq.url, + headers, + }); + + proxyReq.on('upgrade', (proxyRes, proxySocket, proxyHead) => { + clientSocket.write( + `HTTP/1.1 ${proxyRes.statusCode ?? 101} ${proxyRes.statusMessage ?? 'Switching Protocols'}\r\n` + + Object.entries(proxyRes.headers) + .filter(([, value]) => value != null) + .map(([key, value]) => `${key}: ${value}`) + .join('\r\n') + + '\r\n\r\n', + ); + if (proxyHead.length) proxySocket.write(proxyHead); + proxySocket.pipe(clientSocket); + clientSocket.pipe(proxySocket); + }); + + proxyReq.on('error', () => clientSocket.destroy()); + proxyReq.end(head); +} + +function bindErrorHandler(server) { + server.on('error', (err) => { + if (err.code === 'EACCES') { + console.error(`无法监听 ${PUBLIC_PORT} 端口(需要 root)。请执行:`); + console.error(' sudo pnpm dev:local-proxy'); + process.exit(1); + } + if (err.code === 'EADDRINUSE') { + console.error(`端口 ${PUBLIC_PORT} 已被占用。`); + process.exit(1); + } + throw err; + }); +} + +let server; +if (useHttps) { + const { keyPath, certPath } = ensureLocalTestTls(); + server = https.createServer( + { + key: fs.readFileSync(keyPath), + cert: fs.readFileSync(certPath), + }, + proxyRequest, + ); +} else { + server = http.createServer(proxyRequest); +} + +server.on('upgrade', proxyUpgrade); +bindErrorHandler(server); + +server.listen(PUBLIC_PORT, listenHost, () => { + const scheme = useHttps ? 'https' : 'http'; + console.log(`Local test proxy (${scheme}:${PUBLIC_PORT})`); + console.log(` ${publicUrl(H5_HOST)} -> :${vitePort} (H5)`); + console.log(` ${publicUrl(ADMIN_HOST)} -> :${adminPort} (memind_adm)`); + console.log(` ${publicUrl(PLAZA_HOST)}/plaza -> :${plazaPort} (Plaza)`); + console.log(` ${publicUrl(OPS_HOST)}/ops/ -> :${opsPort} (Ops)`); +}); + +for (const signal of ['SIGINT', 'SIGTERM']) { + process.on(signal, () => { + server.close(() => process.exit(0)); + }); +} diff --git a/scripts/local-test-tls.mjs b/scripts/local-test-tls.mjs new file mode 100644 index 0000000..f97c33e --- /dev/null +++ b/scripts/local-test-tls.mjs @@ -0,0 +1,38 @@ +#!/usr/bin/env node +import fs from 'node:fs'; +import path from 'node:path'; +import { execSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +import { H5_HOST, TEST_HOSTS } from './local-test-config.mjs'; + +const root = path.join(path.dirname(fileURLToPath(import.meta.url)), '..'); +const certDir = path.join(root, '.local/local-test-tls'); +const keyPath = path.join(certDir, 'local-test.key.pem'); +const certPath = path.join(certDir, 'local-test.cert.pem'); + +export function ensureLocalTestTls() { + if (fs.existsSync(keyPath) && fs.existsSync(certPath)) { + return { keyPath, certPath, certDir }; + } + + fs.mkdirSync(certDir, { recursive: true }); + const sans = [ + ...TEST_HOSTS.map((host) => `DNS:${host}`), + 'DNS:localhost', + 'IP:127.0.0.1', + ].join(','); + const openssl = `openssl req -x509 -newkey rsa:2048 -sha256 -days 825 -nodes \ + -keyout "${keyPath}" -out "${certPath}" \ + -subj "/CN=${H5_HOST}" \ + -addext "subjectAltName=${sans}"`; + + execSync(openssl, { stdio: 'inherit' }); + console.log(`已生成本地 TLS 证书:${certDir}`); + console.log(`SAN: ${TEST_HOSTS.join(', ')}`); + console.log('浏览器首次访问需信任该自签证书。'); + return { keyPath, certPath, certDir }; +} + +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + ensureLocalTestTls(); +} diff --git a/scripts/open-local-test.mjs b/scripts/open-local-test.mjs new file mode 100644 index 0000000..f15b853 --- /dev/null +++ b/scripts/open-local-test.mjs @@ -0,0 +1,15 @@ +#!/usr/bin/env node +import { spawn } from 'node:child_process'; +import { H5_PUBLIC_BASE } from './local-test-config.mjs'; + +const url = `${H5_PUBLIC_BASE}/?preview=mindspace`; +const platform = process.platform; +const command = + platform === 'darwin' + ? ['open', [url]] + : platform === 'win32' + ? ['cmd', ['/c', 'start', '', url]] + : ['xdg-open', [url]]; + +spawn(command[0], command[1], { stdio: 'inherit' }); +console.log(`已打开 ${url}`); diff --git a/scripts/setup-local-test-dns.mjs b/scripts/setup-local-test-dns.mjs new file mode 100644 index 0000000..caea5d1 --- /dev/null +++ b/scripts/setup-local-test-dns.mjs @@ -0,0 +1,120 @@ +#!/usr/bin/env node +/** + * Map local test domains -> 127.0.0.1 for dev (macOS hosts + per-domain resolver). + * + * Usage: + * sudo node scripts/setup-local-test-dns.mjs + * sudo node scripts/setup-local-test-dns.mjs --remove + */ +import fs from 'node:fs'; +import path from 'node:path'; +import { execSync } from 'node:child_process'; +import { DNS_MARKER, DNS_PORT, LOCAL_IP, TEST_HOSTS } from './local-test-config.mjs'; + +const remove = process.argv.includes('--remove'); +const hostsPath = '/etc/hosts'; +const resolverDir = '/etc/resolver'; +const resolverBody = `nameserver 127.0.0.1\nport ${DNS_PORT}\n# ${DNS_MARKER}\n`; + +function flushDnsCache() { + try { + execSync('dscacheutil -flushcache', { stdio: 'ignore' }); + execSync('killall -HUP mDNSResponder', { stdio: 'ignore' }); + } catch { + // best effort + } +} + +function hostLine(host) { + return `${LOCAL_IP} ${host} # ${DNS_MARKER}`; +} + +function removeHostLines(lines, host) { + return lines.filter( + (line) => + !line.includes(DNS_MARKER) && + !new RegExp(`\\s${host.replace('.', '\\.')}(\\s|$)`).test(line), + ); +} + +function hostReady(lines, host) { + return lines.some( + (line) => + (line.includes(DNS_MARKER) && line.includes(host)) || + new RegExp(`\\s${host.replace('.', '\\.')}(\\s|$)`).test(line), + ); +} + +if (remove) { + if (process.getuid?.() !== 0) { + console.log('需要 root 权限,请执行:'); + console.log(` sudo node ${process.argv[1]} --remove`); + process.exit(1); + } + + let lines = fs.readFileSync(hostsPath, 'utf8').split('\n'); + for (const host of TEST_HOSTS) { + lines = removeHostLines(lines, host); + try { + if (fs.existsSync(path.join(resolverDir, host))) fs.unlinkSync(path.join(resolverDir, host)); + } catch { + // ignore + } + } + execSync(`tee ${hostsPath}`, { + input: `${lines.join('\n').replace(/\n?$/, '\n')}`, + stdio: ['pipe', 'inherit', 'inherit'], + }); + flushDnsCache(); + console.log(`已移除本地测试域名:${TEST_HOSTS.join(', ')}`); + process.exit(0); +} + +const currentLines = fs.readFileSync(hostsPath, 'utf8').split('\n'); +const allReady = TEST_HOSTS.every((host) => { + const resolverPath = path.join(resolverDir, host); + const resolverReady = + fs.existsSync(resolverPath) && fs.readFileSync(resolverPath, 'utf8').trim() === resolverBody.trim(); + return hostReady(currentLines, host) && resolverReady; +}); + +if (allReady) { + console.log(`本地测试域名已配置:${TEST_HOSTS.join(', ')}`); + process.exit(0); +} + +if (process.getuid?.() !== 0) { + console.log('需要 root 权限写入 /etc/hosts 和 /etc/resolver,请执行:'); + console.log(` sudo pnpm setup:local-dns`); + process.exit(1); +} + +let lines = currentLines; +for (const host of TEST_HOSTS) { + if (!hostReady(lines, host)) { + lines = lines.filter((line) => !line.includes(DNS_MARKER) || !line.includes(host)); + lines.push(hostLine(host)); + console.log(`已添加 hosts:${hostLine(host)}`); + } else { + console.log(`${host} 已在 /etc/hosts 中指向 ${LOCAL_IP}`); + } +} + +execSync(`tee ${hostsPath}`, { + input: `${lines.join('\n').replace(/\n?$/, '\n')}`, + stdio: ['pipe', 'inherit', 'inherit'], +}); + +fs.mkdirSync(resolverDir, { recursive: true }); +for (const host of TEST_HOSTS) { + const resolverPath = path.join(resolverDir, host); + fs.writeFileSync(resolverPath, resolverBody, 'utf8'); + console.log(`已添加 resolver:${resolverPath} -> 127.0.0.1:${DNS_PORT}`); +} + +flushDnsCache(); +console.log(''); +console.log('下一步:'); +console.log(' sudo pnpm setup:local-tls # 生成本地 HTTPS 证书'); +console.log(' pnpm dev # 启动全栈'); +console.log(' sudo pnpm dev:local-proxy # HTTPS :443 统一入口'); diff --git a/scripts/setup-local-test.mjs b/scripts/setup-local-test.mjs new file mode 100644 index 0000000..233504c --- /dev/null +++ b/scripts/setup-local-test.mjs @@ -0,0 +1,49 @@ +#!/usr/bin/env node +/** + * One-shot local test setup: + * 1. /etc/hosts + /etc/resolver for test.*.tkmind.cn + * 2. TLS cert with SAN for all test domains + * + * Usage: sudo pnpm setup:local-test + */ +import { spawnSync } from 'node:child_process'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { + ADMIN_HOST, + H5_HOST, + OPS_HOST, + PLAZA_HOST, + publicUrl, +} from './local-test-config.mjs'; + +const root = path.join(path.dirname(fileURLToPath(import.meta.url)), '..'); + +if (process.getuid?.() !== 0) { + console.log('需要 root 权限,请执行:'); + console.log(' sudo pnpm setup:local-test'); + process.exit(1); +} + +const dns = spawnSync('node', [path.join(root, 'scripts/setup-local-test-dns.mjs')], { + stdio: 'inherit', + env: process.env, +}); +if (dns.status !== 0) process.exit(dns.status ?? 1); + +const tls = spawnSync('node', [path.join(root, 'scripts/local-test-tls.mjs')], { + stdio: 'inherit', + env: process.env, +}); +if (tls.status !== 0) process.exit(tls.status ?? 1); + +console.log(''); +console.log('本地测试域名已配置。启动:'); +console.log(' pnpm dev # 全栈开发'); +console.log(' sudo pnpm dev:local-proxy # HTTPS :443 统一入口(另开终端)'); +console.log(''); +console.log('访问:'); +console.log(` MindSpace H5 ${publicUrl(H5_HOST)}/?preview=mindspace`); +console.log(` memind_adm ${publicUrl(ADMIN_HOST)}`); +console.log(` Plaza ${publicUrl(PLAZA_HOST)}/plaza`); +console.log(` Ops 审核后台 ${publicUrl(OPS_HOST)}/ops/`); diff --git a/vite.config.ts b/vite.config.ts index 37ff11a..dc2fdb5 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -13,6 +13,7 @@ export default defineConfig(({ mode }) => { host: true, port: 5173, strictPort: true, + allowedHosts: ['.tkmind.cn', 'test.tkmind.cn', 'localhost'], proxy: { '/api': portalTarget, '/auth': portalTarget,