feat: 灵犀 Studio Web UI 定制版
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,45 @@
|
||||
import * as esbuild from 'esbuild'
|
||||
import { resolve, dirname } from 'path'
|
||||
import { fileURLToPath } from 'url'
|
||||
import { chmodSync, cpSync, mkdirSync, readFileSync, rmSync } from 'fs'
|
||||
|
||||
const rootDir = resolve(dirname(fileURLToPath(import.meta.url)), '..')
|
||||
const pkg = JSON.parse(readFileSync(resolve(rootDir, 'package.json'), 'utf-8'))
|
||||
const version = pkg.version
|
||||
const serverOutDir = resolve(rootDir, 'dist/server')
|
||||
|
||||
rmSync(serverOutDir, { recursive: true, force: true })
|
||||
mkdirSync(serverOutDir, { recursive: true })
|
||||
|
||||
await esbuild.build({
|
||||
entryPoints: [resolve(rootDir, 'packages/server/src/index.ts')],
|
||||
bundle: true,
|
||||
platform: 'node',
|
||||
target: 'node23',
|
||||
format: 'cjs',
|
||||
outfile: resolve(serverOutDir, 'index.js'),
|
||||
external: ['node-pty', 'node:sqlite', 'socket.io'],
|
||||
define: {
|
||||
__APP_VERSION__: JSON.stringify(version),
|
||||
},
|
||||
sourcemap: true,
|
||||
minify: true,
|
||||
treeShaking: true,
|
||||
logLevel: 'info',
|
||||
})
|
||||
|
||||
const bridgeOutDir = resolve(serverOutDir, 'agent-bridge')
|
||||
mkdirSync(bridgeOutDir, { recursive: true })
|
||||
cpSync(
|
||||
resolve(rootDir, 'packages/server/src/services/hermes/agent-bridge/hermes_bridge.py'),
|
||||
resolve(bridgeOutDir, 'hermes_bridge.py'),
|
||||
)
|
||||
chmodSync(resolve(bridgeOutDir, 'hermes_bridge.py'), 0o755)
|
||||
|
||||
const skillsOutDir = resolve(rootDir, 'dist/skills')
|
||||
rmSync(skillsOutDir, { recursive: true, force: true })
|
||||
cpSync(
|
||||
resolve(rootDir, 'packages/skills'),
|
||||
skillsOutDir,
|
||||
{ recursive: true },
|
||||
)
|
||||
@@ -0,0 +1,609 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Auto-generate OpenAPI specification from existing Koa routes and controllers
|
||||
*
|
||||
* This script scans both route files and controller files to generate comprehensive
|
||||
* OpenAPI documentation without requiring code changes or decorators.
|
||||
*/
|
||||
|
||||
import { readFileSync, writeFileSync, readdirSync } from 'fs'
|
||||
import { resolve, join } from 'path'
|
||||
import { fileURLToPath } from 'url'
|
||||
|
||||
const __dirname = fileURLToPath(new URL('.', import.meta.url))
|
||||
const rootDir = resolve(__dirname, '..')
|
||||
const routesDir = join(rootDir, 'packages/server/src/routes')
|
||||
const controllersDir = join(rootDir, 'packages/server/src/controllers')
|
||||
|
||||
// OpenAPI template
|
||||
const openapi = {
|
||||
openapi: '3.0.3',
|
||||
info: {
|
||||
title: 'Hermes Web UI API',
|
||||
description: 'BFF server API for Hermes Web UI — chat sessions, scheduled jobs, platform channels, model management, skills, memory, logs, file browser, group chat, and terminal.',
|
||||
version: '0.5.9',
|
||||
},
|
||||
servers: [
|
||||
{ url: 'http://localhost:8648', description: 'Local development' },
|
||||
],
|
||||
tags: [],
|
||||
paths: {},
|
||||
components: {
|
||||
securitySchemes: {
|
||||
BearerAuth: {
|
||||
type: 'http',
|
||||
scheme: 'bearer',
|
||||
bearerFormat: 'API Token',
|
||||
},
|
||||
},
|
||||
schemas: {},
|
||||
responses: {},
|
||||
},
|
||||
}
|
||||
|
||||
// Tag mappings based on route directories
|
||||
const tagMappings = {
|
||||
'routes/hermes/sessions.ts': { name: 'Sessions', description: 'Chat session management' },
|
||||
'routes/hermes/profiles.ts': { name: 'Profiles', description: 'Hermes profile management' },
|
||||
'routes/hermes/gateways.ts': { name: 'Gateways', description: 'Gateway process management' },
|
||||
'routes/hermes/models.ts': { name: 'Models', description: 'Model configuration' },
|
||||
'routes/hermes/providers.ts': { name: 'Providers', description: 'Model provider management' },
|
||||
'routes/hermes/skills.ts': { name: 'Skills', description: 'Skill browsing and management' },
|
||||
'routes/hermes/memory.ts': { name: 'Memory', description: 'Agent memory files' },
|
||||
'routes/hermes/logs.ts': { name: 'Logs', description: 'Log file access' },
|
||||
'routes/hermes/jobs.ts': { name: 'Jobs', description: 'Scheduled job management' },
|
||||
'routes/hermes/cron-history.ts': { name: 'Jobs', description: 'Cron job history' },
|
||||
'routes/hermes/weixin.ts': { name: 'Weixin', description: 'WeChat QR code login' },
|
||||
'routes/hermes/codex-auth.ts': { name: 'Codex Auth', description: 'OpenAI Codex OAuth' },
|
||||
'routes/hermes/nous-auth.ts': { name: 'Nous Auth', description: 'Nous Research OAuth' },
|
||||
'routes/hermes/copilot-auth.ts': { name: 'Copilot Auth', description: 'GitHub Copilot OAuth' },
|
||||
'routes/hermes/group-chat.ts': { name: 'Group Chat', description: 'Group chat management' },
|
||||
'routes/hermes/chat-run.ts': { name: 'Chat', description: 'Chat run and streaming' },
|
||||
'routes/hermes/config.ts': { name: 'Config', description: 'Configuration management' },
|
||||
'routes/hermes/files.ts': { name: 'Files', description: 'Hermes file browser' },
|
||||
'routes/hermes/download.ts': { name: 'Download', description: 'File download' },
|
||||
'routes/hermes/terminal.ts': { name: 'Terminal', description: 'WebSocket terminal' },
|
||||
'routes/hermes/proxy.ts': { name: 'Proxy', description: 'Gateway proxy' },
|
||||
'routes/health.ts': { name: 'Health', description: 'Health check' },
|
||||
'routes/update.ts': { name: 'Update', description: 'Self-update management' },
|
||||
'routes/upload.ts': { name: 'Upload', description: 'File upload' },
|
||||
'routes/webhook.ts': { name: 'Webhook', description: 'Incoming webhooks' },
|
||||
'routes/auth.ts': { name: 'Auth', description: 'Authentication management' },
|
||||
}
|
||||
|
||||
// Extract route definitions from route files
|
||||
function scanRoutes() {
|
||||
const paths = {}
|
||||
|
||||
// Scan hermes routes
|
||||
const hermesRoutesDir = join(routesDir, 'hermes')
|
||||
const hermesRouteFiles = readdirSync(hermesRoutesDir).filter(f => f.endsWith('.ts'))
|
||||
|
||||
for (const file of hermesRouteFiles) {
|
||||
const routePath = join('hermes', file)
|
||||
const tagInfo = tagMappings[`routes/${routePath}`]
|
||||
if (tagInfo) {
|
||||
scanRouteFile(join(hermesRoutesDir, file), tagInfo, paths)
|
||||
}
|
||||
}
|
||||
|
||||
// Scan top-level routes
|
||||
for (const [routeFile, tagInfo] of Object.entries(tagMappings)) {
|
||||
if (!routeFile.startsWith('routes/hermes/')) {
|
||||
const filePath = join(routesDir, routeFile.replace('routes/', ''))
|
||||
try {
|
||||
scanRouteFile(filePath, tagInfo, paths)
|
||||
} catch (e) {
|
||||
// File might not exist, skip
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return paths
|
||||
}
|
||||
|
||||
function scanRouteFile(filePath, tagInfo, paths) {
|
||||
const content = readFileSync(filePath, 'utf-8')
|
||||
|
||||
// Pattern 1: controller functions - sessionRoutes.get('/path', ctrl.method)
|
||||
const ctrlRouteRegex = /\w+Routes?\.(get|post|put|delete|patch)\(['"]([^'"]+)['"],\s*ctrl\.(\w+)/g
|
||||
|
||||
let match
|
||||
while ((match = ctrlRouteRegex.exec(content)) !== null) {
|
||||
const [, method, path, controllerMethod] = match
|
||||
addEndpoint(paths, method, path, controllerMethod, tagInfo, content, match.index)
|
||||
}
|
||||
|
||||
// Pattern 2: inline functions - groupChatRoutes.post('/path', async (ctx) => {...})
|
||||
const inlineRouteRegex = /\w+Routes?\.(get|post|put|delete|patch)\(['"]([^'"]+)['"],\s*async\s*\(ctx\)/g
|
||||
|
||||
while ((match = inlineRouteRegex.exec(content)) !== null) {
|
||||
const [, method, path] = match
|
||||
const controllerMethod = generateOperationIdFromPath(path, method)
|
||||
addEndpoint(paths, method, path, controllerMethod, tagInfo, content, match.index)
|
||||
}
|
||||
}
|
||||
|
||||
function addEndpoint(paths, method, path, controllerMethod, tagInfo, content, matchIndex) {
|
||||
// Clean path parameters
|
||||
const openapiPath = path
|
||||
.replace(/:([^/]+)/g, '{$1}')
|
||||
.replace(/\*\*([^/]*)/g, '{$1}')
|
||||
|
||||
if (!paths[openapiPath]) {
|
||||
paths[openapiPath] = {}
|
||||
}
|
||||
|
||||
// Generate operation ID
|
||||
const operationId = `${controllerMethod}`
|
||||
|
||||
// Generate description from JSDoc comments above the route
|
||||
const precedingContent = content.substring(Math.max(0, matchIndex - 500), matchIndex)
|
||||
const description = extractJsDocDescription(precedingContent) || `${method.toUpperCase()} ${path}`
|
||||
|
||||
paths[openapiPath][method] = {
|
||||
tags: [tagInfo.name],
|
||||
summary: generateSummary(path, method, controllerMethod),
|
||||
description,
|
||||
operationId,
|
||||
security: [{ BearerAuth: [] }],
|
||||
responses: generateResponses(path, method),
|
||||
}
|
||||
}
|
||||
|
||||
function generateOperationIdFromPath(path, method) {
|
||||
const parts = path.split('/').filter(Boolean)
|
||||
const lastPart = parts[parts.length - 1]
|
||||
|
||||
if (lastPart && !lastPart.includes(':') && !lastPart.includes('*')) {
|
||||
const actionMap = {
|
||||
get: 'get',
|
||||
post: 'create',
|
||||
put: 'update',
|
||||
patch: 'patch',
|
||||
delete: 'delete',
|
||||
}
|
||||
return `${actionMap[method]}${lastPart.charAt(0).toUpperCase() + lastPart.slice(1)}`
|
||||
}
|
||||
|
||||
const parentPart = parts[parts.length - 2]
|
||||
if (parentPart) {
|
||||
return `${method}${parentPart.charAt(0).toUpperCase() + parentPart.slice(1)}`
|
||||
}
|
||||
|
||||
return method
|
||||
}
|
||||
|
||||
function extractJsDocDescription(content) {
|
||||
const jsDocRegex = /\/\*\*[\s\S]*?\*\//
|
||||
const match = content.match(jsDocRegex)
|
||||
if (match) {
|
||||
const jsDoc = match[0]
|
||||
// Extract description text
|
||||
const description = jsDoc
|
||||
.replace(/\/\*\*|\*\//g, '')
|
||||
.split('\n')
|
||||
.map(line => line.replace(/^\s*\*\s?/, '').trim())
|
||||
.filter(line => line && !line.startsWith('@'))
|
||||
.join('\n')
|
||||
return description || null
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function generateSummary(path, method, controllerMethod) {
|
||||
const parts = path.split('/').filter(Boolean)
|
||||
const resource = parts[parts.length - 1] || 'root'
|
||||
|
||||
// Use controller method name to generate better summary
|
||||
const methodMap = {
|
||||
list: 'List',
|
||||
get: 'Get',
|
||||
create: 'Create',
|
||||
update: 'Update',
|
||||
remove: 'Delete',
|
||||
delete: 'Delete',
|
||||
rename: 'Rename',
|
||||
pause: 'Pause',
|
||||
resume: 'Resume',
|
||||
run: 'Run',
|
||||
search: 'Search',
|
||||
add: 'Add',
|
||||
}
|
||||
|
||||
const action = methodMap[controllerMethod] || {
|
||||
get: 'Get',
|
||||
post: 'Create',
|
||||
put: 'Update',
|
||||
patch: 'Update',
|
||||
delete: 'Delete',
|
||||
}[method]
|
||||
|
||||
if (resource.includes('{')) {
|
||||
const paramName = resource.match(/\{([^}]+)\}/)?.[1] || 'id'
|
||||
const parentResource = parts[parts.length - 2] || 'resource'
|
||||
return `${action} ${parentResource} by ${paramName}`
|
||||
}
|
||||
|
||||
return `${action} ${resource}`
|
||||
}
|
||||
|
||||
function generateResponses(path, method) {
|
||||
const responses = {
|
||||
'200': {
|
||||
description: 'Success',
|
||||
},
|
||||
'401': {
|
||||
$ref: '#/components/responses/Unauthorized',
|
||||
},
|
||||
}
|
||||
|
||||
if (method === 'get' && path.includes('/')) {
|
||||
responses['404'] = { description: 'Not found' }
|
||||
}
|
||||
|
||||
if (method === 'post' || method === 'put' || method === 'patch') {
|
||||
responses['400'] = { $ref: '#/components/responses/BadRequest' }
|
||||
}
|
||||
|
||||
return responses
|
||||
}
|
||||
|
||||
// Add standard responses
|
||||
openapi.components.responses = {
|
||||
Unauthorized: {
|
||||
description: 'Unauthorized - Invalid or missing authentication token',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
error: { type: 'string', example: 'Unauthorized' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
BadRequest: {
|
||||
description: 'Bad Request - Invalid parameters',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
error: { type: 'string', example: 'Invalid request' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
NotFound: {
|
||||
description: 'Resource not found',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
error: { type: 'string', example: 'Not found' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// Add proxy endpoints that forward to upstream Hermes API
|
||||
openapi.paths['/api/hermes/{*any}'] = {
|
||||
'get': {
|
||||
tags: ['Proxy'],
|
||||
summary: 'Proxy to upstream Hermes API',
|
||||
description: 'Forwards unmatched /api/hermes/* requests to upstream Hermes gateway. Supports all upstream endpoints.',
|
||||
operationId: 'proxyHermes',
|
||||
responses: {
|
||||
'200': { description: 'Proxied response from upstream' },
|
||||
'401': { $ref: '#/components/responses/Unauthorized' },
|
||||
'502': { description: 'Proxy failure' },
|
||||
},
|
||||
},
|
||||
'post': {
|
||||
tags: ['Proxy'],
|
||||
summary: 'Proxy to upstream Hermes API',
|
||||
description: 'Forwards unmatched /api/hermes/* requests to upstream Hermes gateway. Supports all upstream endpoints.',
|
||||
operationId: 'proxyHermesPost',
|
||||
responses: {
|
||||
'200': { description: 'Proxied response from upstream' },
|
||||
'401': { $ref: '#/components/responses/Unauthorized' },
|
||||
'502': { description: 'Proxy failure' },
|
||||
},
|
||||
},
|
||||
'put': {
|
||||
tags: ['Proxy'],
|
||||
summary: 'Proxy to upstream Hermes API',
|
||||
description: 'Forwards unmatched /api/hermes/* requests to upstream Hermes gateway. Supports all upstream endpoints.',
|
||||
operationId: 'proxyHermesPut',
|
||||
responses: {
|
||||
'200': { description: 'Proxied response from upstream' },
|
||||
'401': { $ref: '#/components/responses/Unauthorized' },
|
||||
'502': { description: 'Proxy failure' },
|
||||
},
|
||||
},
|
||||
'delete': {
|
||||
tags: ['Proxy'],
|
||||
summary: 'Proxy to upstream Hermes API',
|
||||
description: 'Forwards unmatched /api/hermes/* requests to upstream Hermes gateway. Supports all upstream endpoints.',
|
||||
operationId: 'proxyHermesDelete',
|
||||
responses: {
|
||||
'200': { description: 'Proxied response from upstream' },
|
||||
'401': { $ref: '#/components/responses/Unauthorized' },
|
||||
'502': { description: 'Proxy failure' },
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
openapi.paths['/v1/{*any}'] = {
|
||||
'get': {
|
||||
tags: ['Proxy'],
|
||||
summary: 'Proxy to upstream Hermes v1 API',
|
||||
description: 'Forwards /v1/* requests to upstream Hermes gateway. Supports all upstream v1 endpoints.',
|
||||
operationId: 'proxyV1',
|
||||
responses: {
|
||||
'200': { description: 'Proxied response from upstream' },
|
||||
'401': { $ref: '#/components/responses/Unauthorized' },
|
||||
'502': { description: 'Proxy failure' },
|
||||
},
|
||||
},
|
||||
'post': {
|
||||
tags: ['Proxy'],
|
||||
summary: 'Proxy to upstream Hermes v1 API',
|
||||
description: 'Forwards /v1/* requests to upstream Hermes gateway. Supports all upstream v1 endpoints.',
|
||||
operationId: 'proxyV1Post',
|
||||
responses: {
|
||||
'200': { description: 'Proxied response from upstream' },
|
||||
'401': { $ref: '#/components/responses/Unauthorized' },
|
||||
'502': { description: 'Proxy failure' },
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// Add Proxy tag
|
||||
if (!openapi.tags.find(t => t.name === 'Proxy')) {
|
||||
openapi.tags.push({ name: 'Proxy', description: 'Gateway proxy to upstream Hermes API' })
|
||||
}
|
||||
|
||||
// Add WebSocket terminal endpoint
|
||||
openapi.paths['/api/hermes/terminal'] = {
|
||||
'get': {
|
||||
tags: ['Terminal'],
|
||||
summary: 'WebSocket terminal connection',
|
||||
description: 'Establish a WebSocket connection for interactive terminal access. Uses the `ws` or `wss` protocol with `?token=` for authentication.',
|
||||
operationId: 'terminalWebSocket',
|
||||
responses: {
|
||||
'101': { description: 'Switching Protocols - WebSocket connection established' },
|
||||
'401': { $ref: '#/components/responses/Unauthorized' },
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// Add Chat streaming endpoint
|
||||
openapi.paths['/api/hermes/v1/runs/{runId}/events'] = {
|
||||
'get': {
|
||||
tags: ['Chat'],
|
||||
summary: 'Server-Sent Events for chat streaming',
|
||||
description: 'Stream chat events using Server-Sent Events (SSE). Authentication via `?token=` query parameter.',
|
||||
operationId: 'chatStreamEvents',
|
||||
parameters: [
|
||||
{
|
||||
name: 'runId',
|
||||
in: 'path',
|
||||
required: true,
|
||||
description: 'Chat run ID',
|
||||
schema: { type: 'string' },
|
||||
},
|
||||
{
|
||||
name: 'token',
|
||||
in: 'query',
|
||||
required: true,
|
||||
description: 'Authentication token',
|
||||
schema: { type: 'string' },
|
||||
},
|
||||
],
|
||||
responses: {
|
||||
'200': {
|
||||
description: 'SSE stream established',
|
||||
content: {
|
||||
'text/event-stream': {
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
event: { type: 'string', enum: ['run.created', 'run.queued', 'run.started', 'run.streaming', 'run.completed', 'run.failed'] },
|
||||
data: { type: 'object' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
'401': { $ref: '#/components/responses/Unauthorized' },
|
||||
'404': { description: 'Run not found' },
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// Add Terminal tag
|
||||
if (!openapi.tags.find(t => t.name === 'Terminal')) {
|
||||
openapi.tags.push({ name: 'Terminal', description: 'WebSocket terminal access' })
|
||||
}
|
||||
|
||||
// Run scanner
|
||||
console.log('Scanning routes...')
|
||||
openapi.paths = scanRoutes()
|
||||
|
||||
// Collect all tags
|
||||
const tagSet = new Set()
|
||||
Object.values(openapi.paths).forEach(pathItem => {
|
||||
Object.values(pathItem).forEach(operation => {
|
||||
operation.tags?.forEach(tag => tagSet.add(tag))
|
||||
})
|
||||
})
|
||||
|
||||
openapi.tags = Array.from(tagSet).map(tag => {
|
||||
const tagInfo = Object.values(tagMappings).find(t => t.name === tag)
|
||||
return {
|
||||
name: tag,
|
||||
description: tagInfo?.description || '',
|
||||
}
|
||||
})
|
||||
|
||||
// Sort paths
|
||||
const sortedPaths = {}
|
||||
Object.keys(openapi.paths).sort().forEach(key => {
|
||||
sortedPaths[key] = openapi.paths[key]
|
||||
})
|
||||
openapi.paths = sortedPaths
|
||||
|
||||
// Add special endpoints after sorting
|
||||
// Add proxy endpoints that forward to upstream Hermes API
|
||||
openapi.paths['/api/hermes/{*any}'] = {
|
||||
'get': {
|
||||
tags: ['Proxy'],
|
||||
summary: 'Proxy to upstream Hermes API',
|
||||
description: 'Forwards unmatched /api/hermes/* requests to upstream Hermes gateway. Supports all upstream endpoints.',
|
||||
operationId: 'proxyHermes',
|
||||
responses: {
|
||||
'200': { description: 'Proxied response from upstream' },
|
||||
'401': { $ref: '#/components/responses/Unauthorized' },
|
||||
'502': { description: 'Proxy failure' },
|
||||
},
|
||||
},
|
||||
'post': {
|
||||
tags: ['Proxy'],
|
||||
summary: 'Proxy to upstream Hermes API',
|
||||
description: 'Forwards unmatched /api/hermes/* requests to upstream Hermes gateway. Supports all upstream endpoints.',
|
||||
operationId: 'proxyHermesPost',
|
||||
responses: {
|
||||
'200': { description: 'Proxied response from upstream' },
|
||||
'401': { $ref: '#/components/responses/Unauthorized' },
|
||||
'502': { description: 'Proxy failure' },
|
||||
},
|
||||
},
|
||||
'put': {
|
||||
tags: ['Proxy'],
|
||||
summary: 'Proxy to upstream Hermes API',
|
||||
description: 'Forwards unmatched /api/hermes/* requests to upstream Hermes gateway. Supports all upstream endpoints.',
|
||||
operationId: 'proxyHermesPut',
|
||||
responses: {
|
||||
'200': { description: 'Proxied response from upstream' },
|
||||
'401': { $ref: '#/components/responses/Unauthorized' },
|
||||
'502': { description: 'Proxy failure' },
|
||||
},
|
||||
},
|
||||
'delete': {
|
||||
tags: ['Proxy'],
|
||||
summary: 'Proxy to upstream Hermes API',
|
||||
description: 'Forwards unmatched /api/hermes/* requests to upstream Hermes gateway. Supports all upstream endpoints.',
|
||||
operationId: 'proxyHermesDelete',
|
||||
responses: {
|
||||
'200': { description: 'Proxied response from upstream' },
|
||||
'401': { $ref: '#/components/responses/Unauthorized' },
|
||||
'502': { description: 'Proxy failure' },
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
openapi.paths['/v1/{*any}'] = {
|
||||
'get': {
|
||||
tags: ['Proxy'],
|
||||
summary: 'Proxy to upstream Hermes v1 API',
|
||||
description: 'Forwards /v1/* requests to upstream Hermes gateway. Supports all upstream v1 endpoints.',
|
||||
operationId: 'proxyV1',
|
||||
responses: {
|
||||
'200': { description: 'Proxied response from upstream' },
|
||||
'401': { $ref: '#/components/responses/Unauthorized' },
|
||||
'502': { description: 'Proxy failure' },
|
||||
},
|
||||
},
|
||||
'post': {
|
||||
tags: ['Proxy'],
|
||||
summary: 'Proxy to upstream Hermes v1 API',
|
||||
description: 'Forwards /v1/* requests to upstream Hermes gateway. Supports all upstream v1 endpoints.',
|
||||
operationId: 'proxyV1Post',
|
||||
responses: {
|
||||
'200': { description: 'Proxied response from upstream' },
|
||||
'401': { $ref: '#/components/responses/Unauthorized' },
|
||||
'502': { description: 'Proxy failure' },
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// Add WebSocket terminal endpoint
|
||||
openapi.paths['/api/hermes/terminal'] = {
|
||||
'get': {
|
||||
tags: ['Terminal'],
|
||||
summary: 'WebSocket terminal connection',
|
||||
description: 'Establish a WebSocket connection for interactive terminal access. Uses the `ws` or `wss` protocol with `?token=` for authentication.',
|
||||
operationId: 'terminalWebSocket',
|
||||
responses: {
|
||||
'101': { description: 'Switching Protocols - WebSocket connection established' },
|
||||
'401': { $ref: '#/components/responses/Unauthorized' },
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// Add Chat streaming endpoint
|
||||
openapi.paths['/api/hermes/v1/runs/{runId}/events'] = {
|
||||
'get': {
|
||||
tags: ['Chat'],
|
||||
summary: 'Server-Sent Events for chat streaming',
|
||||
description: 'Stream chat events using Server-Sent Events (SSE). Authentication via `?token=` query parameter.',
|
||||
operationId: 'chatStreamEvents',
|
||||
parameters: [
|
||||
{
|
||||
name: 'runId',
|
||||
in: 'path',
|
||||
required: true,
|
||||
description: 'Chat run ID',
|
||||
schema: { type: 'string' },
|
||||
},
|
||||
{
|
||||
name: 'token',
|
||||
in: 'query',
|
||||
required: true,
|
||||
description: 'Authentication token',
|
||||
schema: { type: 'string' },
|
||||
},
|
||||
],
|
||||
responses: {
|
||||
'200': {
|
||||
description: 'SSE stream established',
|
||||
content: {
|
||||
'text/event-stream': {
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
event: { type: 'string', enum: ['run.created', 'run.queued', 'run.started', 'run.streaming', 'run.completed', 'run.failed'] },
|
||||
data: { type: 'object' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
'401': { $ref: '#/components/responses/Unauthorized' },
|
||||
'404': { description: 'Run not found' },
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// Add Proxy and Terminal tags
|
||||
if (!openapi.tags.find(t => t.name === 'Proxy')) {
|
||||
openapi.tags.push({ name: 'Proxy', description: 'Gateway proxy to upstream Hermes API' })
|
||||
}
|
||||
if (!openapi.tags.find(t => t.name === 'Terminal')) {
|
||||
openapi.tags.push({ name: 'Terminal', description: 'WebSocket terminal access' })
|
||||
}
|
||||
|
||||
// Write output
|
||||
const outputPath = join(rootDir, 'docs/openapi.json')
|
||||
writeFileSync(outputPath, JSON.stringify(openapi, null, 2))
|
||||
|
||||
console.log(`✓ Generated OpenAPI spec: ${outputPath}`)
|
||||
console.log(` ${Object.keys(openapi.paths).length} endpoints`)
|
||||
console.log(` ${openapi.tags.length} tags`)
|
||||
@@ -0,0 +1,236 @@
|
||||
#!/usr/bin/env node
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { existsSync } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
|
||||
const root = process.cwd()
|
||||
const failures = []
|
||||
|
||||
function fail(message) {
|
||||
failures.push(message)
|
||||
}
|
||||
|
||||
async function readText(relativePath) {
|
||||
return readFile(path.join(root, relativePath), 'utf8')
|
||||
}
|
||||
|
||||
function requireFile(relativePath) {
|
||||
if (!existsSync(path.join(root, relativePath))) {
|
||||
fail(`Missing required harness file: ${relativePath}`)
|
||||
}
|
||||
}
|
||||
|
||||
function requireDir(relativePath) {
|
||||
if (!existsSync(path.join(root, relativePath))) {
|
||||
fail(`Missing required project directory: ${relativePath}`)
|
||||
}
|
||||
}
|
||||
|
||||
for (const file of [
|
||||
'AGENTS.md',
|
||||
'ARCHITECTURE.md',
|
||||
'DEVELOPMENT.md',
|
||||
'docs/harness/README.md',
|
||||
'docs/harness/validation.md',
|
||||
'docs/harness/worktree-runbook.md',
|
||||
'docs/harness/pr-review.md',
|
||||
]) {
|
||||
requireFile(file)
|
||||
}
|
||||
|
||||
for (const dir of [
|
||||
'packages/client/src',
|
||||
'packages/server/src',
|
||||
'packages/desktop',
|
||||
'packages/desktop/build/icons',
|
||||
'tests/client',
|
||||
'tests/server',
|
||||
'tests/e2e',
|
||||
'.github/workflows',
|
||||
]) {
|
||||
requireDir(dir)
|
||||
}
|
||||
|
||||
for (const icon of [
|
||||
'packages/desktop/build/icon.png',
|
||||
'packages/desktop/build/icon.icns',
|
||||
'packages/desktop/build/icon.ico',
|
||||
'packages/desktop/build/icons/16x16.png',
|
||||
'packages/desktop/build/icons/32x32.png',
|
||||
'packages/desktop/build/icons/48x48.png',
|
||||
'packages/desktop/build/icons/64x64.png',
|
||||
'packages/desktop/build/icons/128x128.png',
|
||||
'packages/desktop/build/icons/256x256.png',
|
||||
'packages/desktop/build/icons/512x512.png',
|
||||
]) {
|
||||
requireFile(icon)
|
||||
}
|
||||
|
||||
const agents = await readText('AGENTS.md')
|
||||
const agentLines = agents.trimEnd().split(/\r?\n/)
|
||||
if (agentLines.length > 120) {
|
||||
fail(`AGENTS.md should stay short; found ${agentLines.length} lines, expected <= 120`)
|
||||
}
|
||||
|
||||
for (const requiredLink of [
|
||||
'DEVELOPMENT.md',
|
||||
'ARCHITECTURE.md',
|
||||
'docs/harness/README.md',
|
||||
'docs/harness/validation.md',
|
||||
'docs/harness/worktree-runbook.md',
|
||||
'docs/harness/pr-review.md',
|
||||
]) {
|
||||
if (!agents.includes(requiredLink)) {
|
||||
fail(`AGENTS.md must link to ${requiredLink}`)
|
||||
}
|
||||
}
|
||||
|
||||
const packageJson = JSON.parse(await readText('package.json'))
|
||||
for (const scriptName of [
|
||||
'harness:check',
|
||||
'test',
|
||||
'test:coverage',
|
||||
'test:e2e',
|
||||
'build',
|
||||
]) {
|
||||
if (!packageJson.scripts?.[scriptName]) {
|
||||
fail(`package.json is missing script: ${scriptName}`)
|
||||
}
|
||||
}
|
||||
|
||||
const architecture = await readText('ARCHITECTURE.md')
|
||||
for (const phrase of [
|
||||
'packages/client/src',
|
||||
'packages/server/src',
|
||||
'packages/desktop',
|
||||
'HERMES_WEB_UI_HOME',
|
||||
'fail_on_unmatched_files: true',
|
||||
]) {
|
||||
if (!architecture.includes(phrase)) {
|
||||
fail(`ARCHITECTURE.md should document: ${phrase}`)
|
||||
}
|
||||
}
|
||||
|
||||
const buildWorkflow = await readText('.github/workflows/build.yml')
|
||||
if (!buildWorkflow.includes('npm run harness:check')) {
|
||||
fail('Build workflow must run npm run harness:check')
|
||||
}
|
||||
|
||||
const desktopReleaseWorkflow = await readText('.github/workflows/desktop-release.yml')
|
||||
const desktopRuntimeWorkflow = await readText('.github/workflows/desktop-runtime.yml')
|
||||
const electronBuilderConfig = await readText('packages/desktop/electron-builder.yml')
|
||||
const desktopPackageJson = await readText('packages/desktop/package.json')
|
||||
const desktopInstallHermes = await readText('packages/desktop/scripts/install-hermes.mjs')
|
||||
const desktopWebuiServer = await readText('packages/desktop/src/main/webui-server.ts')
|
||||
const desktopRuntimeManager = await readText('packages/desktop/src/main/runtime-manager.ts')
|
||||
const desktopPaths = await readText('packages/desktop/src/main/paths.ts')
|
||||
const desktopRuntimeAssetName = await readText('packages/desktop/scripts/runtime-asset-name.mjs')
|
||||
if (!desktopReleaseWorkflow.includes('files: ${{ matrix.artifact_files }}')) {
|
||||
fail('desktop-release.yml must upload matrix-specific artifact_files')
|
||||
}
|
||||
|
||||
if (!electronBuilderConfig.includes('icon: build/icons')) {
|
||||
fail('electron-builder.yml must configure the Linux icon set')
|
||||
}
|
||||
|
||||
for (const target of ['target_os: darwin', 'target_os: win32', 'target_os: linux']) {
|
||||
if (!desktopReleaseWorkflow.includes(target)) {
|
||||
fail(`desktop-release.yml is missing matrix target ${target}`)
|
||||
}
|
||||
}
|
||||
|
||||
for (const expectedGlob of ['*.dmg', '*.exe', '*.AppImage']) {
|
||||
if (!desktopReleaseWorkflow.includes(expectedGlob)) {
|
||||
fail(`desktop-release.yml is missing expected artifact glob ${expectedGlob}`)
|
||||
}
|
||||
}
|
||||
|
||||
if (!desktopReleaseWorkflow.includes('fail_on_unmatched_files: true')) {
|
||||
fail('desktop-release.yml must keep fail_on_unmatched_files: true')
|
||||
}
|
||||
|
||||
for (const phrase of [
|
||||
'resources/python/${os}-${arch}',
|
||||
'resources/node/${os}-${arch}',
|
||||
'resources/git/${os}-${arch}',
|
||||
]) {
|
||||
if (electronBuilderConfig.includes(phrase)) {
|
||||
fail(`electron-builder.yml must not bundle desktop runtime resource: ${phrase}`)
|
||||
}
|
||||
}
|
||||
|
||||
for (const phrase of [
|
||||
'"fetch:node"',
|
||||
'"fetch:git"',
|
||||
'"prepare:runtime"',
|
||||
'"package:runtime"',
|
||||
'"runtime:asset-name"',
|
||||
]) {
|
||||
if (!desktopPackageJson.includes(phrase)) {
|
||||
fail(`packages/desktop/package.json must support runtime package publishing: ${phrase}`)
|
||||
}
|
||||
}
|
||||
|
||||
for (const phrase of [
|
||||
'steps.check.outputs.missing',
|
||||
'npm --prefix packages/desktop run prepare:runtime',
|
||||
'npm --prefix packages/desktop run package:runtime',
|
||||
]) {
|
||||
if (!desktopRuntimeWorkflow.includes(phrase)) {
|
||||
fail(`desktop-runtime.yml must build and publish missing runtime package assets: ${phrase}`)
|
||||
}
|
||||
}
|
||||
|
||||
if (!desktopRuntimeAssetName.includes('hermes-runtime-hermes-agent-')) {
|
||||
fail('runtime asset naming must include hermes-agent version')
|
||||
}
|
||||
|
||||
for (const phrase of [
|
||||
'websockets',
|
||||
'agent-browser@^0.26.0',
|
||||
'AGENT_BROWSER_HOME',
|
||||
'AGENT_BROWSER_EXECUTABLE_PATH',
|
||||
'PLAYWRIGHT_BROWSERS_PATH',
|
||||
'ms-playwright',
|
||||
'removeBrokenDashboardAuthPlugin',
|
||||
]) {
|
||||
if (!desktopInstallHermes.includes(phrase)) {
|
||||
fail(`install-hermes.mjs must bundle Hermes browser runtime support: ${phrase}`)
|
||||
}
|
||||
}
|
||||
|
||||
for (const phrase of [
|
||||
'bundledNodeBin',
|
||||
'HERMES_AGENT_NODE',
|
||||
'HERMES_AGENT_GIT',
|
||||
'PLAYWRIGHT_BROWSERS_PATH',
|
||||
'ms-playwright',
|
||||
]) {
|
||||
if (!desktopWebuiServer.includes(phrase)) {
|
||||
fail(`desktop webui server must expose bundled browser runtime: ${phrase}`)
|
||||
}
|
||||
}
|
||||
|
||||
for (const phrase of [
|
||||
'HERMES_DESKTOP_RUNTIME_URL',
|
||||
'HERMES_DESKTOP_RUNTIME_BASE_URL',
|
||||
'runtime-manifest.json',
|
||||
]) {
|
||||
if (!desktopRuntimeManager.includes(phrase)) {
|
||||
fail(`desktop runtime manager must support downloadable runtime packages: ${phrase}`)
|
||||
}
|
||||
}
|
||||
|
||||
if (!desktopPaths.includes('HERMES_DESKTOP_RUNTIME_DIR')) {
|
||||
fail('desktop paths must allow HERMES_DESKTOP_RUNTIME_DIR override')
|
||||
}
|
||||
|
||||
if (failures.length > 0) {
|
||||
console.error('Harness check failed:')
|
||||
for (const failure of failures) {
|
||||
console.error(`- ${failure}`)
|
||||
}
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
console.log('Harness check passed')
|
||||
Reference in New Issue
Block a user