From 4b6de351bdfd1cd0a9ed880f45b0553921190840 Mon Sep 17 00:00:00 2001 From: ekko Date: Sat, 18 Apr 2026 13:07:12 +0800 Subject: [PATCH] feat: add multi-gateway management with auto port detection - Add GatewayManager for multi-profile gateway lifecycle management - Auto-detect running gateways on startup via PID + health check - Port conflict detection: check managed gateways, allocated ports, and system-level port availability (TCP bind test) - Two-phase startup: sequential port resolution, parallel process launch - Use `gateway start/restart` on normal systems, `gateway run --replace` on WSL/Docker - Wait for health check before returning start/stop responses - Add Gateways page with card-based layout showing profile status - Reorganize sidebar navigation into collapsible groups - Hide API server settings (now auto-managed by GatewayManager) - Profile switch reloads page; Ctrl+C no longer stops gateways - Remove redundant ensureApiServerConfig from index.ts and profiles.ts Co-Authored-By: Claude Opus 4.6 --- packages/client/src/api/hermes/gateways.ts | 29 + .../src/components/layout/AppSidebar.vue | 442 ++++++------- .../src/components/layout/ProfileSelector.vue | 4 +- packages/client/src/i18n/locales/en.ts | 19 + packages/client/src/i18n/locales/zh.ts | 21 + packages/client/src/router/index.ts | 5 + packages/client/src/stores/hermes/gateways.ts | 51 ++ .../client/src/views/hermes/GatewaysView.vue | 136 ++++ .../client/src/views/hermes/SettingsView.vue | 66 -- packages/server/src/index.ts | 94 +-- packages/server/src/routes/hermes/gateways.ts | 71 +++ packages/server/src/routes/hermes/index.ts | 2 + packages/server/src/routes/hermes/profiles.ts | 95 +-- .../server/src/routes/hermes/proxy-handler.ts | 19 +- .../src/services/hermes/gateway-manager.ts | 583 ++++++++++++++++++ 15 files changed, 1170 insertions(+), 467 deletions(-) create mode 100644 packages/client/src/api/hermes/gateways.ts create mode 100644 packages/client/src/stores/hermes/gateways.ts create mode 100644 packages/client/src/views/hermes/GatewaysView.vue create mode 100644 packages/server/src/routes/hermes/gateways.ts create mode 100644 packages/server/src/services/hermes/gateway-manager.ts diff --git a/packages/client/src/api/hermes/gateways.ts b/packages/client/src/api/hermes/gateways.ts new file mode 100644 index 00000000..24cf9c32 --- /dev/null +++ b/packages/client/src/api/hermes/gateways.ts @@ -0,0 +1,29 @@ +import { request } from '../client' + +export interface GatewayStatus { + profile: string + port: number + host: string + url: string + running: boolean + pid?: number +} + +export async function fetchGateways(): Promise { + const res = await request<{ gateways: GatewayStatus[] }>('/api/hermes/gateways') + return res.gateways +} + +export async function startGateway(name: string): Promise { + const res = await request<{ success: boolean; gateway: GatewayStatus }>(`/api/hermes/gateways/${name}/start`, { method: 'POST' }) + return res.gateway +} + +export async function stopGateway(name: string): Promise { + await request(`/api/hermes/gateways/${name}/stop`, { method: 'POST' }) +} + +export async function checkGatewayHealth(name: string): Promise { + const res = await request<{ gateway: GatewayStatus }>(`/api/hermes/gateways/${name}/health`) + return res.gateway +} diff --git a/packages/client/src/components/layout/AppSidebar.vue b/packages/client/src/components/layout/AppSidebar.vue index 5841ac76..4476d166 100644 --- a/packages/client/src/components/layout/AppSidebar.vue +++ b/packages/client/src/components/layout/AppSidebar.vue @@ -1,5 +1,5 @@ + + + + diff --git a/packages/client/src/views/hermes/SettingsView.vue b/packages/client/src/views/hermes/SettingsView.vue index 4ee4236b..3620a3f9 100644 --- a/packages/client/src/views/hermes/SettingsView.vue +++ b/packages/client/src/views/hermes/SettingsView.vue @@ -63,72 +63,6 @@ async function saveApiServer(values: Record) { - -
- - - - - - - - - - - - - - - -
-
diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 1978569d..285a96b8 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -58,6 +58,7 @@ let isShuttingDown = false // 👉 如果你有子进程,一定要存 let gatewayPid: number | null = null +let gatewayManager: any = null export async function bootstrap() { await mkdir(config.uploadDir, { recursive: true }) @@ -70,8 +71,7 @@ export async function bootstrap() { console.log(`🔐 Auth enabled — token: ${authToken}`) } - await ensureApiServerConfig() - await ensureGatewayRunning() + await initGatewayManager() app.use(cors({ origin: config.corsOrigins })) app.use(bodyParser()) @@ -115,7 +115,8 @@ export async function bootstrap() { let gatewayOk = false try { - const res = await fetch(`${config.upstream.replace(/\/$/, '')}/health`, { + const upstream = gatewayManager?.getUpstream() || config.upstream + const res = await fetch(`${upstream.replace(/\/$/, '')}/health`, { signal: AbortSignal.timeout(5000), }) gatewayOk = res.ok @@ -191,13 +192,7 @@ function bindShutdown() { }) } - // ✅ 2. 关闭子进程(如果有) - if (gatewayPid) { - try { - process.kill(gatewayPid) - console.log(`✓ gateway process killed: ${gatewayPid}`) - } catch { } - } + // gateway 是系统服务,不随 dev server 退出而停止 } catch (err) { console.error('shutdown error:', err) @@ -226,78 +221,23 @@ function bindShutdown() { } // ============================ -// 你的原逻辑(基本不动) +// Gateway Manager // ============================ -async function ensureApiServerConfig() { - const { readFileSync, writeFileSync, existsSync, copyFileSync } = await import('fs') - const yaml = (await import('js-yaml')).default - const { getActiveConfigPath } = await import('./services/hermes/hermes-profile') - const configPath = getActiveConfigPath() +async function initGatewayManager() { + const { GatewayManager } = await import('./services/hermes/gateway-manager') + const { getActiveProfileName } = await import('./services/hermes/hermes-profile') + const { setGatewayManager } = await import('./routes/hermes/gateways') - const defaults: Record = { - enabled: true, - host: '127.0.0.1', - port: 8642, - key: '', - cors_origins: '*', - } + const activeProfile = getActiveProfileName() + gatewayManager = new GatewayManager(activeProfile) + setGatewayManager(gatewayManager) - try { - if (!existsSync(configPath)) { - console.log('✗ config.yaml not found') - return - } + // Detect all running gateways + await gatewayManager.detectAllOnStartup() - const content = readFileSync(configPath, 'utf-8') - const cfg = yaml.load(content) as any || {} - - if (!cfg.platforms) cfg.platforms = {} - if (!cfg.platforms.api_server) cfg.platforms.api_server = {} - - cfg.platforms.api_server = defaults - - copyFileSync(configPath, configPath + '.bak') - writeFileSync(configPath, yaml.dump(cfg), 'utf-8') - - await restartGateway() - } catch (err: any) { - console.error('config error:', err.message) - } -} - -async function ensureGatewayRunning() { - const upstream = config.upstream.replace(/\/$/, '') - const waitForGatewayReady = async (timeoutMs: number = 15000) => { - const deadline = Date.now() + timeoutMs - while (Date.now() < deadline) { - try { - const res = await fetch(`${upstream}/health`, { signal: AbortSignal.timeout(2000) }) - if (res.ok) return true - } catch { } - await new Promise(r => setTimeout(r, 300)) - } - return false - } - - try { - const res = await fetch(`${upstream}/health`, { signal: AbortSignal.timeout(5000) }) - if (res.ok) return - } catch { } - - console.log('⚠ Gateway not running, starting...') - - try { - // 👉 关键:保存 PID - gatewayPid = await startGatewayBackground() - if (await waitForGatewayReady()) { - console.log(`✓ Gateway started (PID: ${gatewayPid})`) - } else { - console.error('gateway start failed: timed out waiting for health') - } - } catch (err: any) { - console.error('gateway start failed:', err.message) - } + // Start all gateways that aren't running + await gatewayManager.startAll() } bootstrap() diff --git a/packages/server/src/routes/hermes/gateways.ts b/packages/server/src/routes/hermes/gateways.ts new file mode 100644 index 00000000..47ff165a --- /dev/null +++ b/packages/server/src/routes/hermes/gateways.ts @@ -0,0 +1,71 @@ +import Router from '@koa/router' + +export const gatewayRoutes = new Router() + +// Get singleton instance — set during bootstrap +let manager: any = null + +export function setGatewayManager(mgr: any) { + manager = mgr +} + +export function getGatewayManager(): any { + return manager +} + +// List all gateway statuses +gatewayRoutes.get('/api/hermes/gateways', async (ctx) => { + if (!manager) { + ctx.status = 503 + ctx.body = { error: 'GatewayManager not initialized' } + return + } + const gateways = await manager.listAll() + ctx.body = { gateways } +}) + +// Start a profile's gateway +gatewayRoutes.post('/api/hermes/gateways/:name/start', async (ctx) => { + if (!manager) { + ctx.status = 503 + ctx.body = { error: 'GatewayManager not initialized' } + return + } + const { name } = ctx.params + try { + const status = await manager.start(name) + ctx.body = { success: true, gateway: status } + } catch (err: any) { + ctx.status = 500 + ctx.body = { error: err.message } + } +}) + +// Stop a profile's gateway +gatewayRoutes.post('/api/hermes/gateways/:name/stop', async (ctx) => { + if (!manager) { + ctx.status = 503 + ctx.body = { error: 'GatewayManager not initialized' } + return + } + const { name } = ctx.params + try { + await manager.stop(name) + ctx.body = { success: true } + } catch (err: any) { + ctx.status = 500 + ctx.body = { error: err.message } + } +}) + +// Check a profile's gateway health +gatewayRoutes.get('/api/hermes/gateways/:name/health', async (ctx) => { + if (!manager) { + ctx.status = 503 + ctx.body = { error: 'GatewayManager not initialized' } + return + } + const { name } = ctx.params + const status = await manager.detectStatus(name) + ctx.body = { gateway: status } +}) diff --git a/packages/server/src/routes/hermes/index.ts b/packages/server/src/routes/hermes/index.ts index 1cba783c..8db08ebc 100644 --- a/packages/server/src/routes/hermes/index.ts +++ b/packages/server/src/routes/hermes/index.ts @@ -6,6 +6,7 @@ import { fsRoutes } from './filesystem' import { logRoutes } from './logs' import { weixinRoutes } from './weixin' import { codexAuthRoutes } from './codex-auth' +import { gatewayRoutes } from './gateways' import { proxyRoutes, proxyMiddleware } from './proxy' import { setupTerminalWebSocket } from './terminal' @@ -18,6 +19,7 @@ hermesRoutes.use(fsRoutes.routes()) hermesRoutes.use(logRoutes.routes()) hermesRoutes.use(weixinRoutes.routes()) hermesRoutes.use(codexAuthRoutes.routes()) +hermesRoutes.use(gatewayRoutes.routes()) hermesRoutes.use(proxyRoutes.routes()) export { setupTerminalWebSocket, proxyMiddleware } diff --git a/packages/server/src/routes/hermes/profiles.ts b/packages/server/src/routes/hermes/profiles.ts index b774635b..23203b3b 100644 --- a/packages/server/src/routes/hermes/profiles.ts +++ b/packages/server/src/routes/hermes/profiles.ts @@ -5,36 +5,7 @@ import { basename, join } from 'path' import { tmpdir, homedir } from 'os' import YAML from 'js-yaml' import * as hermesCli from '../../services/hermes/hermes-cli' - -const apiServerDefaults = { - enabled: true, - host: '127.0.0.1', - port: 8642, - key: '', - cors_origins: '*', -} - -function ensureApiServerConfig(profilePath: string) { - const configPath = join(profilePath, 'config.yaml') - try { - if (!existsSync(configPath)) { - // Profile has no config.yaml — run hermes setup --reset to generate full defaults, - // then inject api_server config (setup itself doesn't add it) - console.log(`[Profile] No config.yaml for ${profilePath}, running setup --reset`) - return { needSetup: true, path: profilePath } - } - const content = readFileSync(configPath, 'utf-8') - const cfg = YAML.load(content) as any || {} - if (!cfg.platforms) cfg.platforms = {} - if (!cfg.platforms.api_server) { - cfg.platforms.api_server = { ...apiServerDefaults } - writeFileSync(configPath, YAML.dump(cfg), 'utf-8') - console.log(`[Profile] Ensured api_server config for: ${profilePath}`) - } - return { needSetup: false, path: profilePath } - } catch { } - return { needSetup: false, path: profilePath } -} +import { getGatewayManager } from './gateways' export const profileRoutes = new Router() @@ -92,6 +63,12 @@ profileRoutes.delete('/api/hermes/profiles/:name', async (ctx) => { } try { + // Stop gateway for this profile before deleting + const mgr = getGatewayManager() + if (mgr) { + try { await mgr.stop(name) } catch { } + } + const ok = await hermesCli.deleteProfile(name) if (ok) { ctx.body = { success: true } @@ -141,49 +118,26 @@ profileRoutes.put('/api/hermes/profiles/active', async (ctx) => { } try { - // 1. Stop gateway - try { await hermesCli.stopGateway() } catch { } - - // 2. Kill gateway by port if still running - try { - const { execSync } = await import('child_process') - const isWin = process.platform === 'win32' - let pids = '' - if (isWin) { - const out = execSync('netstat -aon | findstr :8642', { encoding: 'utf-8', timeout: 5000 }).trim() - const lines = out.split('\n').filter(l => l.includes('LISTENING')) - pids = Array.from(new Set(lines.map(l => l.trim().split(/\s+/).pop()).filter(Boolean))).join(' ') - } else { - pids = execSync('lsof -ti:8642', { encoding: 'utf-8', timeout: 5000 }).trim() - } - if (pids) { - if (isWin) { - execSync(`taskkill /F /PID ${pids.split(' ').join(' /PID ')}`, { timeout: 5000 }) - } else { - execSync(`kill -9 ${pids}`, { timeout: 5000 }) - } - await new Promise(r => setTimeout(r, 2000)) - } - } catch { } - - // 3. Switch profile + // 1. Switch profile only (no gateway stop/restart) const output = await hermesCli.useProfile(name) await new Promise(r => setTimeout(r, 1000)) - // 4. Ensure api_server config for new profile + // 2. Update GatewayManager active profile + const mgr = getGatewayManager() + if (mgr) { + mgr.setActiveProfile(name) + } + + // 3. Ensure api_server config for new profile try { const detail = await hermesCli.getProfile(name) console.log(`[Profile] detail.path = ${detail.path}`) - const result = ensureApiServerConfig(detail.path) - if (result?.needSetup) { - // No config.yaml — run setup --reset to create full default config, - // then ensure api_server is present + if (!existsSync(join(detail.path, 'config.yaml'))) { + // No config.yaml — run setup --reset to create full default config try { await hermesCli.setupReset() } catch { } - ensureApiServerConfig(detail.path) } // Create .env if target has none const profileEnv = join(detail.path, '.env') - console.log(`[Profile] .env exists: ${existsSync(profileEnv)}, path: ${profileEnv}`) if (!existsSync(profileEnv)) { writeFileSync(profileEnv, '# Hermes Agent Environment Configuration\n', 'utf-8') console.log(`[Profile] Created .env for: ${detail.path}`) @@ -192,21 +146,6 @@ profileRoutes.put('/api/hermes/profiles/active', async (ctx) => { console.error(`[Profile] Ensure config failed:`, err.message) } - // 5. Start gateway - try { - await hermesCli.startGateway() - console.log('[Profile] Gateway started') - } catch { - // Fallback: background mode (for WSL etc.) - try { - const pid = await hermesCli.startGatewayBackground() - await new Promise(r => setTimeout(r, 3000)) - console.log(`[Profile] Gateway started in background mode (PID: ${pid})`) - } catch (err: any) { - console.error('[Profile] Gateway start failed:', err.message) - } - } - ctx.body = { success: true, message: output.trim() } } catch (err: any) { ctx.status = 500 diff --git a/packages/server/src/routes/hermes/proxy-handler.ts b/packages/server/src/routes/hermes/proxy-handler.ts index dd861147..8d480304 100644 --- a/packages/server/src/routes/hermes/proxy-handler.ts +++ b/packages/server/src/routes/hermes/proxy-handler.ts @@ -1,5 +1,6 @@ import type { Context } from 'koa' import { config } from '../../config' +import { getGatewayManager } from './gateways' function isTransientGatewayError(err: any): boolean { const msg = String(err?.message || '') @@ -27,8 +28,24 @@ async function waitForGatewayReady(upstream: string, timeoutMs: number = 5000): return false } +/** Resolve upstream URL for a request based on profile header/query */ +function resolveUpstream(ctx: Context): string { + const mgr = getGatewayManager() + if (mgr) { + // Check X-Hermes-Profile header or ?profile= query param + const profile = ctx.get('x-hermes-profile') || (ctx.query.profile as string) + if (profile) { + return mgr.getUpstream(profile) + } + // Default to active profile's upstream + return mgr.getUpstream() + } + // Fallback: static upstream from config + return config.upstream.replace(/\/$/, '') +} + export async function proxy(ctx: Context) { - const upstream = config.upstream.replace(/\/$/, '') + const upstream = resolveUpstream(ctx) // Rewrite path for upstream gateway: // /api/hermes/v1/* -> /v1/* (upstream uses /v1/ prefix) // /api/hermes/* -> /api/* (upstream uses /api/ prefix) diff --git a/packages/server/src/services/hermes/gateway-manager.ts b/packages/server/src/services/hermes/gateway-manager.ts new file mode 100644 index 00000000..17dccb61 --- /dev/null +++ b/packages/server/src/services/hermes/gateway-manager.ts @@ -0,0 +1,583 @@ +/** + * GatewayManager — 多 Profile 网关生命周期管理 + * + * 核心职责: + * 1. 启动时检测所有 profile 的网关运行状态(PID、端口、健康检查) + * 2. 自动发现端口冲突并重新分配 + * 3. 启动/停止网关进程 + * + * 启动检测流程(detectStatus): + * ① 读取 gateway.pid → 获取 PID + * ② 读取 config.yaml (platforms.api_server.extra.port/host) → 获取配置端口 + * ③ PID 存活? + * - 否 → 标记为 stopped + * - 是 → 继续 + * ④ 对配置端口做 health check? + * - 通过 → 配置与运行状态匹配,注册网关 + * - 失败 → 用 lsof 查 PID 实际监听端口 + * ⑤ 实际端口 ≠ 配置端口? + * - 是 → 更新 config.yaml 到实际端口,重新 health check,通过则注册 + * - 否 → 标记为 stopped + * + * 端口分配流程(resolvePort,启动前调用): + * ① 读取配置端口 + * ② 检查是否被已管理的网关占用 + * ③ 检查是否被外部系统进程占用(TCP bind 测试) + * ④ 冲突则从 base+1 递增找空闲端口,并写入 config.yaml + * + * 启动模式: + * - 正常系统(macOS/Linux):hermes gateway start/stop(系统服务管理) + * - WSL / Docker:hermes gateway run(detached 子进程,手动 kill) + */ + +import { spawn, type ChildProcess } from 'child_process' +import { resolve, join } from 'path' +import { homedir } from 'os' +import { readFileSync, writeFileSync, existsSync } from 'fs' +import { execFile } from 'child_process' +import { promisify } from 'util' +import { createServer } from 'net' + +const execFileAsync = promisify(execFile) + +// ============================ +// 常量 & 环境检测 +// ============================ + +const HERMES_BASE = resolve(homedir(), '.hermes') +const HERMES_BIN = process.env.HERMES_BIN?.trim() || 'hermes' + +// WSL / Docker 没有 systemd 或 launchd,需要用 "gateway run" 代替 "gateway start" +const isWsl = existsSync('/proc/version') && require('fs').readFileSync('/proc/version', 'utf-8').toLowerCase().includes('microsoft') +const isDocker = existsSync('/.dockerenv') +const needsRunMode = isWsl || isDocker + +// ============================ +// 类型定义 +// ============================ + +export interface GatewayStatus { + profile: string + port: number + host: string + url: string + running: boolean + pid?: number +} + +interface ManagedGateway { + pid: number + port: number + host: string + url: string + process?: ChildProcess +} + +// ============================ +// GatewayManager +// ============================ + +export class GatewayManager { + /** 已注册的网关:profile name → { pid, port, host, url } */ + private gateways = new Map() + + /** 本次启动过程中已分配的端口集合(防止并发分配到相同端口) */ + private allocatedPorts = new Set() + + /** 当前活跃的 profile(用于代理路由的默认上游) */ + private activeProfile: string + + constructor(activeProfile: string) { + this.activeProfile = activeProfile + } + + // ============================ + // Profile 目录 & 配置读取 + // ============================ + + /** 获取 profile 的 home 目录路径 */ + private profileDir(name: string): string { + if (name === 'default') return HERMES_BASE + return join(HERMES_BASE, 'profiles', name) + } + + /** + * 从 profile 的 config.yaml 读取 api_server 端口和主机 + * 读取路径:platforms.api_server.extra.port / extra.host + */ + private readProfilePort(name: string): { port: number; host: string } { + const configPath = join(this.profileDir(name), 'config.yaml') + if (!existsSync(configPath)) return { port: 8642, host: '127.0.0.1' } + + try { + const yaml = require('js-yaml') + const content = readFileSync(configPath, 'utf-8') + const cfg = yaml.load(content) as any || {} + + const extra = cfg?.platforms?.api_server?.extra + const rawPort = extra?.port || 8642 + const port = typeof rawPort === 'number' ? rawPort : parseInt(rawPort, 10) || 8642 + const host = extra?.host || '127.0.0.1' + // 端口超出合法范围时回退到默认值 + return { port: port > 0 && port <= 65535 ? port : 8642, host } + } catch { + return { port: 8642, host: '127.0.0.1' } + } + } + + /** 从 profile 的 gateway.pid 文件读取 PID(JSON 格式 { "pid": 12345 }) */ + private readPidFile(name: string): number | null { + const pidPath = join(this.profileDir(name), 'gateway.pid') + if (!existsSync(pidPath)) return null + + try { + const content = readFileSync(pidPath, 'utf-8').trim() + const data = JSON.parse(content) + return typeof data.pid === 'number' ? data.pid : parseInt(data.pid, 10) || null + } catch { + return null + } + } + + // ============================ + // 进程 & 端口检测工具 + // ============================ + + /** 检查进程是否存活(发送信号 0,不实际杀死进程) */ + private isProcessAlive(pid: number): boolean { + try { + process.kill(pid, 0) + return true + } catch { + return false + } + } + + /** 请求 /health 端点,判断网关是否真正就绪 */ + private async checkHealth(url: string, timeoutMs = 3000): Promise { + try { + const res = await fetch(`${url.replace(/\/$/, '')}/health`, { + signal: AbortSignal.timeout(timeoutMs), + }) + return res.ok + } catch { + return false + } + } + + /** 尝试绑定端口,检测端口是否被系统级进程占用 */ + private checkPortAvailable(port: number, host: string): Promise { + if (port < 0 || port > 65535) return Promise.resolve(false) + return new Promise((resolve) => { + const server = createServer() + server.once('error', () => { + server.close() + resolve(false) + }) + server.once('listening', () => { + server.close() + resolve(true) + }) + server.listen(port, host) + }) + } + + /** 从 base 端口开始递增查找空闲端口(上限 65535) */ + private findFreePort(base: number, host = '127.0.0.1'): Promise { + return new Promise((resolve, reject) => { + const tryPort = (port: number) => { + if (port > 65535) { + reject(new Error(`No free port found in range ${base}-65535`)) + return + } + const server = createServer() + server.once('error', () => { + server.close() + tryPort(port + 1) + }) + server.once('listening', () => { + server.close() + resolve(port) + }) + server.listen(port, host) + } + tryPort(base) + }) + } + + // ============================ + // 配置写入 + // ============================ + + /** + * 将端口和主机写入 profile 的 config.yaml + * 写入完整结构: + * platforms: + * api_server: + * enabled: true + * key: '' + * cors_origins: '*' + * extra: + * port: + * host: + * 同时清理旧的顶层 port/host(避免 Hermes 读取错误) + */ + private writeProfilePort(name: string, port: number, host: string): void { + const configPath = join(this.profileDir(name), 'config.yaml') + try { + const yaml = require('js-yaml') + const content = existsSync(configPath) ? readFileSync(configPath, 'utf-8') : '' + const cfg = (yaml.load(content) as any) || {} + + if (!cfg.platforms) cfg.platforms = {} + if (!cfg.platforms.api_server) cfg.platforms.api_server = {} + if (!cfg.platforms.api_server.extra) cfg.platforms.api_server.extra = {} + + cfg.platforms.api_server.enabled = true + cfg.platforms.api_server.key = '' + cfg.platforms.api_server.cors_origins = '*' + cfg.platforms.api_server.extra.port = port + cfg.platforms.api_server.extra.host = host + + // 清理旧的顶层 port/host,Hermes 只从 extra 读取 + if (cfg.platforms.api_server.port !== undefined) { + delete cfg.platforms.api_server.port + } + if (cfg.platforms.api_server.host !== undefined) { + delete cfg.platforms.api_server.host + } + + writeFileSync(configPath, yaml.dump(cfg, { lineWidth: -1 }), 'utf-8') + console.log(`[GatewayManager] Updated ${configPath}: api_server.extra.port = ${port}`) + } catch (err) { + console.error(`[GatewayManager] Failed to write config for profile "${name}":`, err) + } + } + + // ============================ + // 端口分配 + // ============================ + + /** + * 为 profile 分配可用端口(启动前调用) + * + * 检测顺序: + * 1. 已管理的网关 + 已分配的端口 → 内存级检查(快) + * 2. 系统 TCP bind 测试 → 检测外部进程占用 + * 3. 冲突则从 base+1 递增找空闲端口,写入 config.yaml + */ + private async resolvePort(name: string): Promise<{ port: number; host: string }> { + let { port, host } = this.readProfilePort(name) + + // 收集已占用端口:正在运行的网关 + 本次启动已分配的端口 + const usedPorts = new Set(this.allocatedPorts) + for (const gw of Array.from(this.gateways.values())) { + if (gw.host === host && this.isProcessAlive(gw.pid)) { + usedPorts.add(gw.port) + } + } + + if (usedPorts.has(port)) { + // 已管理端口冲突 → 找空闲端口 + const newPort = await this.findFreePort(port, host) + console.log(`[GatewayManager] Port ${port} is in use for profile "${name}", reassigning to ${newPort}`) + this.writeProfilePort(name, newPort, host) + port = newPort + } else { + // 检查系统级端口占用(外部进程) + const available = await this.checkPortAvailable(port, host) + if (!available) { + const newPort = await this.findFreePort(port, host) + console.log(`[GatewayManager] Port ${port} is occupied by another process for profile "${name}", reassigning to ${newPort}`) + this.writeProfilePort(name, newPort, host) + port = newPort + } else { + // 端口空闲,写入完整配置(确保 api_server 配置齐全) + this.writeProfilePort(name, port, host) + } + } + + this.allocatedPorts.add(port) + return { port, host } + } + + // ============================ + // 公开方法:状态查询 + // ============================ + + /** 获取指定 profile 的网关 URL(代理路由使用) */ + getUpstream(profileName?: string): string { + const name = profileName || this.activeProfile + const gw = this.gateways.get(name) + if (gw?.url) return gw.url + const { port, host } = this.readProfilePort(name) + return `http://${host}:${port}` + } + + getActiveProfile(): string { + return this.activeProfile + } + + setActiveProfile(name: string) { + this.activeProfile = name + } + + /** 列出所有已知 profile 名称(通过 hermes CLI 或文件系统扫描) */ + async listProfiles(): Promise { + try { + const { stdout } = await execFileAsync(HERMES_BIN, ['profile', 'list'], { + timeout: 10000, + windowsHide: true, + }) + const profiles: string[] = [] + for (const line of stdout.trim().split('\n')) { + if (line.startsWith(' Profile') || line.match(/^ ─/)) continue + const match = line.match(/^\s+(?:◆)?(\S+)\s{2,}/) + if (match) profiles.push(match[1]) + } + return profiles + } catch { + // CLI 不可用时回退到文件系统扫描 + const profiles = ['default'] + const profilesDir = join(HERMES_BASE, 'profiles') + const { existsSync, readdirSync } = require('fs') + if (existsSync(profilesDir)) { + for (const entry of readdirSync(profilesDir, { withFileTypes: true })) { + if (entry.isDirectory() && existsSync(join(profilesDir, entry.name, 'config.yaml'))) { + profiles.push(entry.name) + } + } + } + return profiles + } + } + + /** + * 检测单个 profile 的网关状态(只读,不修改任何进程或配置) + * + * 流程: + * ① 读 PID 文件 → 检查进程是否存活 + * ② 读配置端口 → health check + * ③ 两者都通过 → 匹配,注册 + * ④ 否则 → 标记为未运行(不杀进程,由 startAll 处理) + */ + async detectStatus(name: string): Promise { + const pid = this.readPidFile(name) + const { port, host } = this.readProfilePort(name) + const url = `http://${host}:${port}` + + if (pid && this.isProcessAlive(pid) && await this.checkHealth(url)) { + this.gateways.set(name, { pid, port, host, url }) + return { profile: name, port, host, url, running: true, pid } + } + + // 未运行或端口不匹配 + this.gateways.delete(name) + return { profile: name, port, host, url, running: false } + } + + /** 检测所有 profile 的网关状态 */ + async listAll(): Promise { + const profiles = await this.listProfiles() + const statuses = await Promise.all(profiles.map(name => this.detectStatus(name))) + return statuses + } + + // ============================ + // 公开方法:启动 & 停止 + // ============================ + + /** + * 启动单个 profile 的网关 + * 启动前自动调用 resolvePort() 确保端口可用且配置完整 + */ + async start(name: string): Promise { + const { port, host } = await this.resolvePort(name) + const hermesHome = this.profileDir(name) + const url = `http://${host}:${port}` + + if (needsRunMode) { + // WSL / Docker:无 systemd/launchd,用 "gateway run" 作为 detached 子进程 + return new Promise((resolve, reject) => { + const env = { ...process.env, HERMES_HOME: hermesHome } + const child = spawn(HERMES_BIN, ['gateway', 'run', '--replace'], { + detached: true, + stdio: 'ignore', + windowsHide: true, + env, + }) + child.unref() + + const pid = child.pid ?? 0 + console.log(`[GatewayManager] Starting gateway for profile "${name}" (run mode, PID: ${pid}, port: ${port})`) + + this.waitForReady(name, pid, port, host, url) + .then(resolve) + .catch(reject) + }) + } + + // 正常系统:先 start,失败则 restart(处理服务已运行的情况) + console.log(`[GatewayManager] Starting gateway for profile "${name}" (start mode, port: ${port})`) + const env = { ...process.env, HERMES_HOME: hermesHome } + try { + const { stdout } = await execFileAsync(HERMES_BIN, ['gateway', 'start'], { + timeout: 30000, + env, + windowsHide: true, + }) + console.log(`[GatewayManager] gateway start output: ${stdout?.trim()}`) + } catch { + // start 失败(可能服务已运行),用 restart + try { + const { stdout } = await execFileAsync(HERMES_BIN, ['gateway', 'restart'], { + timeout: 30000, + env, + windowsHide: true, + }) + console.log(`[GatewayManager] gateway restart output: ${stdout?.trim()}`) + } catch (err: any) { + console.log(`[GatewayManager] gateway start/restart (non-fatal): ${err.stderr?.trim() || err.message}`) + } + } + + return this.waitForReady(name, 0, port, host, url) + } + + /** 等待网关健康检查通过,最多 15 秒 */ + private async waitForReady(name: string, pid: number, port: number, host: string, url: string): Promise { + const deadline = Date.now() + 15000 + while (Date.now() < deadline) { + if (pid && !this.isProcessAlive(pid)) { + throw new Error(`Gateway process exited unexpectedly (PID: ${pid})`) + } + if (await this.checkHealth(url, 2000)) { + // "gateway start" 自行管理进程,重新从 pid 文件读取实际 PID + const actualPid = this.readPidFile(name) ?? pid + this.gateways.set(name, { pid: actualPid, port, host, url }) + return { profile: name, port, host, url, running: true, pid: actualPid || undefined } + } + await new Promise(r => setTimeout(r, 500)) + } + throw new Error(`Gateway health check timed out after 15000ms`) + } + + /** + * 停止单个 profile 的网关 + * 正常系统用 "gateway stop",WSL/Docker 直接 kill 进程组 + * 返回前等待 health check 确认网关已真正停止 + */ + async stop(name: string, timeoutMs = 10000): Promise { + // 记录当前 URL,用于确认停止 + const gw = this.gateways.get(name) + const url = gw?.url || (() => { + const { port, host } = this.readProfilePort(name) + return `http://${host}:${port}` + })() + + if (!needsRunMode) { + // 正常系统:通过 hermes CLI 停止系统服务 + try { + const hermesHome = this.profileDir(name) + const env = { ...process.env, HERMES_HOME: hermesHome } + await execFileAsync(HERMES_BIN, ['gateway', 'stop'], { + timeout: 10000, + env, + windowsHide: true, + }) + } catch { } + } else { + // WSL / Docker:直接杀进程组 + let pid = gw?.pid + if (!pid) { + pid = this.readPidFile(name) ?? undefined + } + if (pid) { + try { process.kill(-pid, 'SIGTERM') } catch { + try { process.kill(pid, 'SIGTERM') } catch { } + } + } + } + + // 等待 health check 失败,确认网关已真正停止 + const deadline = Date.now() + timeoutMs + while (Date.now() < deadline) { + if (!(await this.checkHealth(url, 1000))) { + this.gateways.delete(name) + console.log(`[GatewayManager] Stopped gateway for profile "${name}"`) + return + } + await new Promise(r => setTimeout(r, 300)) + } + // 超时也清理 + this.gateways.delete(name) + console.log(`[GatewayManager] Stopped gateway for profile "${name}" (timeout)`) + } + + /** 停止所有已管理的网关(并行执行) */ + async stopAll(): Promise { + const entries = Array.from(this.gateways.keys()) + await Promise.allSettled(entries.map(name => this.stop(name))) + } + + // ============================ + // 批量操作(启动时调用) + // ============================ + + /** 扫描所有 profile,检测网关运行状态并注册 */ + async detectAllOnStartup(): Promise { + console.log('[GatewayManager] Scanning profiles for running gateways...') + const profiles = await this.listProfiles() + + for (const name of profiles) { + const status = await this.detectStatus(name) + if (status.running) { + console.log(`[GatewayManager] ✓ ${name}: running (PID: ${status.pid}, port: ${status.port})`) + } else { + console.log(`[GatewayManager] ○ ${name}: stopped`) + } + } + } + + /** + * 启动所有未运行的网关 + * + * 两阶段执行: + * Phase 1 — 顺序处理:检查状态、清理旧进程、分配端口 + * Phase 2 — 并行启动网关进程 + */ + async startAll(): Promise { + const profiles = await this.listProfiles() + + // Phase 1: 顺序处理 + const toStart: string[] = [] + for (const name of profiles) { + const existing = this.gateways.get(name) + if (existing && this.isProcessAlive(existing.pid)) { + console.log(`[GatewayManager] ${name}: already running (PID: ${existing.pid})`) + continue + } + + // 有 PID 文件但进程未在正确端口运行 → 旧进程,先停掉 + const pid = this.readPidFile(name) + if (pid && this.isProcessAlive(pid)) { + console.log(`[GatewayManager] ${name}: stale process (PID: ${pid}), stopping`) + try { await this.stop(name) } catch { } + } + + await this.resolvePort(name) + toStart.push(name) + } + + // Phase 2: 并行启动 + const tasks = toStart.map(async (name) => { + try { + await this.start(name) + } catch (err: any) { + console.error(`[GatewayManager] ✗ ${name}: failed to start — ${err.message}`) + } + }) + + await Promise.allSettled(tasks) + } +}