Files
Hermes-ui/packages/server/src/index.ts
T
ekkoandClaude Opus 4.6 3d2b1c5e47 fix: job edit schedule format error and refactor services directory
- Fix #25: job update sends schedule as plain string but upstream expects
  { kind, expr, display } object, causing "'str' object has no attribute 'get'"
- Move hermes-cli.ts, hermes.ts, hermes-profile.ts into services/hermes/
  for multi-agent namespacing consistency
- Fix ts-node Set spread compatibility in filesystem.ts

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-17 16:48:24 +08:00

304 lines
8.2 KiB
TypeScript
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.
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 { readFileSync } from 'fs'
import { config } from './config'
import { hermesRoutes, setupTerminalWebSocket, proxyMiddleware } from './routes/hermes'
import { uploadRoutes } from './routes/upload'
import { webhookRoutes } from './routes/webhook'
import * as hermesCli from './services/hermes/hermes-cli'
import { getToken, authMiddleware } from './services/auth'
function getLocalVersion(): string {
// production: dist/server → ../../package.json
// dev: packages/server/src → ../../../package.json
const candidates = [
resolve(__dirname, '../../package.json'),
resolve(__dirname, '../../../package.json'),
]
for (const p of candidates) {
try {
return JSON.parse(readFileSync(p, 'utf-8')).version
} catch { }
}
return '0.0.0'
}
const LOCAL_VERSION = getLocalVersion()
let cachedLatestVersion = ''
async function checkLatestVersion(): Promise<void> {
try {
const res = await fetch('https://registry.npmjs.org/hermes-web-ui/latest', {
signal: AbortSignal.timeout(5000),
})
if (res.ok) {
const data = await res.json()
const latest = data.version || ''
if (latest && latest !== cachedLatestVersion) {
cachedLatestVersion = latest
if (latest !== LOCAL_VERSION) {
console.log(`⬆ New version available: v${LOCAL_VERSION} → v${latest}`)
}
}
}
} catch { }
}
const app = new Koa()
const { restartGateway, startGateway, startGatewayBackground, getVersion } = hermesCli
let server: any = null
let isShuttingDown = false
// 👉 如果你有子进程,一定要存
let gatewayPid: number | null = null
export async function bootstrap() {
await mkdir(config.uploadDir, { recursive: true })
await mkdir(config.dataDir, { recursive: true })
// Auth (after mkdir so data dir exists)
const authToken = await getToken()
if (authToken) {
app.use(await authMiddleware(authToken))
console.log(`🔐 Auth enabled — token: ${authToken}`)
}
await ensureApiServerConfig()
await ensureGatewayRunning()
app.use(cors({ origin: config.corsOrigins }))
app.use(bodyParser())
app.use(webhookRoutes.routes())
app.use(uploadRoutes.routes())
// update (must be before hermesRoutes which includes proxy routes)
app.use(async (ctx, next) => {
if (ctx.path === '/api/hermes/update' && ctx.method === 'POST') {
const isWin = process.platform === 'win32'
const cmd = isWin
? 'cmd /c hermes-web-ui update'
: 'hermes-web-ui update'
try {
const { execSync } = await import('child_process')
const output = execSync(cmd, {
encoding: 'utf-8',
timeout: 120000,
stdio: ['pipe', 'pipe', 'pipe'],
})
ctx.body = { success: true, message: output.trim() }
} catch (err: any) {
ctx.status = 500
ctx.body = { success: false, message: err.stderr || err.message }
}
return
}
await next()
})
app.use(hermesRoutes.routes())
app.use(proxyMiddleware)
// health
app.use(async (ctx, next) => {
if (ctx.path === '/health') {
const raw = await getVersion()
const hermesVersion = raw.split('\n')[0].replace('Hermes Agent ', '') || ''
let gatewayOk = false
try {
const res = await fetch(`${config.upstream.replace(/\/$/, '')}/health`, {
signal: AbortSignal.timeout(5000),
})
gatewayOk = res.ok
} catch { }
ctx.body = {
status: gatewayOk ? 'ok' : 'error',
platform: 'hermes-agent',
version: hermesVersion,
gateway: gatewayOk ? 'running' : 'stopped',
webui_version: LOCAL_VERSION,
webui_latest: cachedLatestVersion,
webui_update_available: cachedLatestVersion && cachedLatestVersion !== LOCAL_VERSION,
}
return
}
await next()
})
// SPA
const distDir = resolve(__dirname, '..', 'client')
app.use(serve(distDir))
app.use(async (ctx) => {
if (!ctx.path.startsWith('/api') &&
ctx.path !== '/health' &&
ctx.path !== '/upload' &&
ctx.path !== '/webhook') {
await send(ctx, 'index.html', { root: distDir })
}
})
// 🚀 启动服务
server = app.listen(config.port, '0.0.0.0')
// Terminal WebSocket (must be after server is created)
setupTerminalWebSocket(server)
server.on('listening', () => {
console.log(`➜ Server: http://localhost:${config.port}`)
console.log(`➜ Upstream: ${config.upstream}`)
})
server.on('error', (err: any) => {
console.error('Server error:', err.message)
})
// 👇 绑定退出信号
bindShutdown()
// Check for updates every 4 hours
checkLatestVersion()
setInterval(checkLatestVersion, 4 * 60 * 60 * 1000)
}
// ============================
// ✅ 统一关闭逻辑(核心)
// ============================
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')
})
}
// ============================
// 你的原逻辑(基本不动)
// ============================
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()
const defaults: Record<string, any> = {
enabled: true,
host: '127.0.0.1',
port: 8642,
key: '',
cors_origins: '*',
}
try {
if (!existsSync(configPath)) {
console.log('✗ config.yaml not found')
return
}
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)
}
}
bootstrap()