2026-04-11 21:33:04 +08:00
|
|
|
|
import Koa from 'koa'
|
|
|
|
|
|
import cors from '@koa/cors'
|
|
|
|
|
|
import bodyParser from '@koa/bodyparser'
|
|
|
|
|
|
import serve from 'koa-static'
|
|
|
|
|
|
import send from 'koa-send'
|
|
|
|
|
|
import { resolve } from 'path'
|
|
|
|
|
|
import { mkdir } from 'fs/promises'
|
|
|
|
|
|
import { config } from './config'
|
|
|
|
|
|
import { proxyRoutes } from './routes/proxy'
|
|
|
|
|
|
import { uploadRoutes } from './routes/upload'
|
|
|
|
|
|
import { sessionRoutes } from './routes/sessions'
|
|
|
|
|
|
import { webhookRoutes } from './routes/webhook'
|
|
|
|
|
|
import { logRoutes } from './routes/logs'
|
2026-04-12 23:23:50 +08:00
|
|
|
|
import { fsRoutes } from './routes/filesystem'
|
2026-04-13 15:15:14 +08:00
|
|
|
|
import { configRoutes } from './routes/config'
|
|
|
|
|
|
import { weixinRoutes } from './routes/weixin'
|
2026-04-15 16:36:04 +08:00
|
|
|
|
import { setupTerminalWebSocket } from './routes/terminal'
|
2026-04-11 21:33:04 +08:00
|
|
|
|
import * as hermesCli from './services/hermes-cli'
|
2026-04-14 21:48:53 +08:00
|
|
|
|
import { getToken, authMiddleware } from './services/auth'
|
2026-04-14 20:17:12 +08:00
|
|
|
|
|
|
|
|
|
|
const app = new Koa()
|
2026-04-14 10:22:29 +08:00
|
|
|
|
const { restartGateway, startGateway, startGatewayBackground, getVersion } = hermesCli
|
2026-04-11 21:33:04 +08:00
|
|
|
|
|
2026-04-14 20:17:12 +08:00
|
|
|
|
let server: any = null
|
|
|
|
|
|
let isShuttingDown = false
|
|
|
|
|
|
|
|
|
|
|
|
// 👉 如果你有子进程,一定要存
|
|
|
|
|
|
let gatewayPid: number | null = null
|
|
|
|
|
|
|
2026-04-11 21:33:04 +08:00
|
|
|
|
export async function bootstrap() {
|
|
|
|
|
|
await mkdir(config.uploadDir, { recursive: true })
|
|
|
|
|
|
await mkdir(config.dataDir, { recursive: true })
|
2026-04-14 21:48:53 +08:00
|
|
|
|
|
|
|
|
|
|
// Auth (after mkdir so data dir exists)
|
|
|
|
|
|
const authToken = await getToken()
|
|
|
|
|
|
if (authToken) {
|
|
|
|
|
|
app.use(await authMiddleware(authToken))
|
|
|
|
|
|
console.log(`🔐 Auth enabled — token: ${authToken}`)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-11 21:33:04 +08:00
|
|
|
|
await ensureApiServerConfig()
|
2026-04-13 20:08:32 +08:00
|
|
|
|
await ensureGatewayRunning()
|
2026-04-11 21:33:04 +08:00
|
|
|
|
|
|
|
|
|
|
app.use(cors({ origin: config.corsOrigins }))
|
|
|
|
|
|
app.use(bodyParser())
|
|
|
|
|
|
|
|
|
|
|
|
app.use(webhookRoutes.routes())
|
|
|
|
|
|
app.use(logRoutes.routes())
|
|
|
|
|
|
app.use(uploadRoutes.routes())
|
|
|
|
|
|
app.use(sessionRoutes.routes())
|
2026-04-12 23:23:50 +08:00
|
|
|
|
app.use(fsRoutes.routes())
|
2026-04-13 15:15:14 +08:00
|
|
|
|
app.use(configRoutes.routes())
|
|
|
|
|
|
app.use(weixinRoutes.routes())
|
2026-04-11 21:33:04 +08:00
|
|
|
|
|
2026-04-14 20:17:12 +08:00
|
|
|
|
// health
|
2026-04-11 21:33:04 +08:00
|
|
|
|
app.use(async (ctx, next) => {
|
|
|
|
|
|
if (ctx.path === '/health') {
|
2026-04-13 20:08:32 +08:00
|
|
|
|
const raw = await getVersion()
|
2026-04-11 21:33:04 +08:00
|
|
|
|
const version = raw.split('\n')[0].replace('Hermes Agent ', '') || ''
|
2026-04-13 20:08:32 +08:00
|
|
|
|
|
|
|
|
|
|
let gatewayOk = false
|
|
|
|
|
|
try {
|
|
|
|
|
|
const res = await fetch(`${config.upstream.replace(/\/$/, '')}/health`, {
|
|
|
|
|
|
signal: AbortSignal.timeout(5000),
|
|
|
|
|
|
})
|
|
|
|
|
|
gatewayOk = res.ok
|
2026-04-14 20:17:12 +08:00
|
|
|
|
} catch { }
|
2026-04-13 20:08:32 +08:00
|
|
|
|
|
|
|
|
|
|
ctx.body = {
|
|
|
|
|
|
status: gatewayOk ? 'ok' : 'error',
|
|
|
|
|
|
platform: 'hermes-agent',
|
|
|
|
|
|
version,
|
|
|
|
|
|
gateway: gatewayOk ? 'running' : 'stopped',
|
|
|
|
|
|
}
|
2026-04-11 21:33:04 +08:00
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
await next()
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
app.use(proxyRoutes.routes())
|
|
|
|
|
|
|
2026-04-14 20:17:12 +08:00
|
|
|
|
// SPA
|
2026-04-11 21:33:04 +08:00
|
|
|
|
const distDir = resolve(__dirname, '..')
|
|
|
|
|
|
app.use(serve(distDir))
|
|
|
|
|
|
app.use(async (ctx) => {
|
2026-04-14 20:17:12 +08:00
|
|
|
|
if (!ctx.path.startsWith('/api') &&
|
|
|
|
|
|
!ctx.path.startsWith('/v1') &&
|
|
|
|
|
|
ctx.path !== '/health' &&
|
|
|
|
|
|
ctx.path !== '/upload' &&
|
|
|
|
|
|
ctx.path !== '/webhook') {
|
2026-04-11 21:33:04 +08:00
|
|
|
|
await send(ctx, 'index.html', { root: distDir })
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2026-04-14 20:17:12 +08:00
|
|
|
|
// 🚀 启动服务
|
|
|
|
|
|
server = app.listen(config.port, '0.0.0.0')
|
|
|
|
|
|
|
2026-04-15 16:36:04 +08:00
|
|
|
|
// Terminal WebSocket (must be after server is created)
|
|
|
|
|
|
setupTerminalWebSocket(server)
|
|
|
|
|
|
|
2026-04-14 20:17:12 +08:00
|
|
|
|
server.on('listening', () => {
|
|
|
|
|
|
console.log(`➜ Server: http://localhost:${config.port}`)
|
|
|
|
|
|
console.log(`➜ Upstream: ${config.upstream}`)
|
2026-04-11 21:33:04 +08:00
|
|
|
|
})
|
2026-04-14 20:17:12 +08:00
|
|
|
|
|
|
|
|
|
|
server.on('error', (err: any) => {
|
|
|
|
|
|
console.error('Server error:', err.message)
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
// 👇 绑定退出信号
|
|
|
|
|
|
bindShutdown()
|
2026-04-11 21:33:04 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-14 20:17:12 +08:00
|
|
|
|
// ============================
|
|
|
|
|
|
// ✅ 统一关闭逻辑(核心)
|
|
|
|
|
|
// ============================
|
|
|
|
|
|
function bindShutdown() {
|
|
|
|
|
|
const shutdown = async (signal: string) => {
|
|
|
|
|
|
if (isShuttingDown) return
|
|
|
|
|
|
isShuttingDown = true
|
|
|
|
|
|
|
|
|
|
|
|
console.log(`\n[${signal}] shutting down...`)
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
// ✅ 1. 关闭 HTTP server
|
|
|
|
|
|
if (server) {
|
|
|
|
|
|
await new Promise<void>((resolve) => {
|
|
|
|
|
|
server.close(() => {
|
|
|
|
|
|
console.log('✓ http server closed')
|
|
|
|
|
|
resolve()
|
|
|
|
|
|
})
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ✅ 2. 关闭子进程(如果有)
|
|
|
|
|
|
if (gatewayPid) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
process.kill(gatewayPid)
|
|
|
|
|
|
console.log(`✓ gateway process killed: ${gatewayPid}`)
|
|
|
|
|
|
} catch { }
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
console.error('shutdown error:', err)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
process.exit(0)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 👉 nodemon 专用(必须 once)
|
|
|
|
|
|
process.once('SIGUSR2', shutdown)
|
|
|
|
|
|
|
|
|
|
|
|
// 👉 正常退出
|
|
|
|
|
|
process.on('SIGINT', shutdown)
|
|
|
|
|
|
process.on('SIGTERM', shutdown)
|
|
|
|
|
|
|
|
|
|
|
|
// 👉 防止异常退出没处理
|
|
|
|
|
|
process.on('uncaughtException', (err) => {
|
|
|
|
|
|
console.error('uncaughtException:', err)
|
|
|
|
|
|
shutdown('uncaughtException')
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
process.on('unhandledRejection', (err) => {
|
|
|
|
|
|
console.error('unhandledRejection:', err)
|
|
|
|
|
|
shutdown('unhandledRejection')
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ============================
|
|
|
|
|
|
// 你的原逻辑(基本不动)
|
|
|
|
|
|
// ============================
|
|
|
|
|
|
|
2026-04-11 21:33:04 +08:00
|
|
|
|
async function ensureApiServerConfig() {
|
|
|
|
|
|
const { homedir } = await import('os')
|
2026-04-14 10:22:29 +08:00
|
|
|
|
const { readFileSync, writeFileSync, existsSync, copyFileSync } = await import('fs')
|
|
|
|
|
|
const yaml = (await import('js-yaml')).default
|
2026-04-11 21:33:04 +08:00
|
|
|
|
const configPath = resolve(homedir(), '.hermes/config.yaml')
|
|
|
|
|
|
|
2026-04-14 20:17:12 +08:00
|
|
|
|
const defaults: Record<string, any> = {
|
2026-04-14 10:22:29 +08:00
|
|
|
|
enabled: true,
|
|
|
|
|
|
host: '127.0.0.1',
|
|
|
|
|
|
port: 8642,
|
|
|
|
|
|
key: '',
|
|
|
|
|
|
cors_origins: '*',
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-11 21:33:04 +08:00
|
|
|
|
try {
|
|
|
|
|
|
if (!existsSync(configPath)) {
|
2026-04-14 20:17:12 +08:00
|
|
|
|
console.log('✗ config.yaml not found')
|
2026-04-11 21:33:04 +08:00
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const content = readFileSync(configPath, 'utf-8')
|
2026-04-14 20:17:12 +08:00
|
|
|
|
const cfg = yaml.load(content) as any || {}
|
2026-04-11 21:33:04 +08:00
|
|
|
|
|
2026-04-14 20:17:12 +08:00
|
|
|
|
if (!cfg.platforms) cfg.platforms = {}
|
|
|
|
|
|
if (!cfg.platforms.api_server) cfg.platforms.api_server = {}
|
2026-04-14 14:47:18 +08:00
|
|
|
|
|
2026-04-14 20:17:12 +08:00
|
|
|
|
const api = cfg.platforms.api_server
|
|
|
|
|
|
let changed = false
|
2026-04-14 14:47:18 +08:00
|
|
|
|
|
2026-04-14 20:17:12 +08:00
|
|
|
|
for (const [k, v] of Object.entries(defaults)) {
|
2026-04-15 08:28:36 +08:00
|
|
|
|
if (api[k] != null && api[k] !== v) {
|
2026-04-14 20:17:12 +08:00
|
|
|
|
api[k] = v
|
|
|
|
|
|
changed = true
|
2026-04-14 14:47:18 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-14 20:17:12 +08:00
|
|
|
|
if (!changed) return
|
2026-04-11 21:33:04 +08:00
|
|
|
|
|
2026-04-14 10:22:29 +08:00
|
|
|
|
copyFileSync(configPath, configPath + '.bak')
|
2026-04-14 20:17:12 +08:00
|
|
|
|
writeFileSync(configPath, yaml.dump(cfg), 'utf-8')
|
2026-04-11 21:33:04 +08:00
|
|
|
|
|
|
|
|
|
|
await restartGateway()
|
|
|
|
|
|
} catch (err: any) {
|
2026-04-14 20:17:12 +08:00
|
|
|
|
console.error('config error:', err.message)
|
2026-04-11 21:33:04 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-13 20:08:32 +08:00
|
|
|
|
async function ensureGatewayRunning() {
|
|
|
|
|
|
const upstream = config.upstream.replace(/\/$/, '')
|
2026-04-14 20:17:12 +08:00
|
|
|
|
|
2026-04-13 20:08:32 +08:00
|
|
|
|
try {
|
|
|
|
|
|
const res = await fetch(`${upstream}/health`, { signal: AbortSignal.timeout(5000) })
|
2026-04-14 20:17:12 +08:00
|
|
|
|
if (res.ok) return
|
|
|
|
|
|
} catch { }
|
2026-04-14 10:22:29 +08:00
|
|
|
|
|
2026-04-14 20:17:12 +08:00
|
|
|
|
console.log('⚠ Gateway not running, starting...')
|
2026-04-14 10:22:29 +08:00
|
|
|
|
|
2026-04-13 20:08:32 +08:00
|
|
|
|
try {
|
2026-04-14 20:17:12 +08:00
|
|
|
|
// 👉 关键:保存 PID
|
|
|
|
|
|
gatewayPid = await startGatewayBackground()
|
|
|
|
|
|
|
2026-04-13 20:08:32 +08:00
|
|
|
|
await new Promise(r => setTimeout(r, 3000))
|
2026-04-14 20:17:12 +08:00
|
|
|
|
|
2026-04-13 20:08:32 +08:00
|
|
|
|
const res = await fetch(`${upstream}/health`, { signal: AbortSignal.timeout(5000) })
|
|
|
|
|
|
if (res.ok) {
|
2026-04-14 20:17:12 +08:00
|
|
|
|
console.log(`✓ Gateway started (PID: ${gatewayPid})`)
|
2026-04-13 20:08:32 +08:00
|
|
|
|
}
|
|
|
|
|
|
} catch (err: any) {
|
2026-04-14 20:17:12 +08:00
|
|
|
|
console.error('gateway start failed:', err.message)
|
2026-04-13 20:08:32 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-14 20:17:12 +08:00
|
|
|
|
bootstrap()
|