Fix Windows bundled Hermes CLI launcher (#1159)

* Fix Windows bundled Hermes CLI launcher

* Update kanban service tests for Hermes process wrapper

---------

Co-authored-by: xingzhi <chuzihao.czh@alibaba-inc.com>
This commit is contained in:
sir1st
2026-05-30 18:57:04 +08:00
committed by GitHub
parent 046c8d4f8f
commit ce04b10eee
10 changed files with 219 additions and 74 deletions
+3 -1
View File
@@ -63,6 +63,8 @@ if (r.status !== 0) {
const hermesBin = TARGET_OS === 'win32'
? resolve(PY_DIR, 'Scripts', 'hermes.exe')
: resolve(PY_DIR, 'bin', 'hermes')
const hermesCheckCommand = TARGET_OS === 'win32' ? pyBin : hermesBin
const hermesCheckArgs = TARGET_OS === 'win32' ? ['-m', 'hermes_cli.main', '--version'] : ['--version']
if (!existsSync(hermesBin)) {
console.error(`hermes binary not found at ${hermesBin} after install`)
@@ -137,7 +139,7 @@ if (TARGET_OS === 'win32') {
console.log(`✓ hermes installed at ${hermesBin} (relocatable launcher)`)
r = spawnSync(hermesBin, ['--version'], { stdio: 'inherit' })
r = spawnSync(hermesCheckCommand, hermesCheckArgs, { stdio: 'inherit' })
if (r.status !== 0) {
console.error('hermes --version failed')
process.exit(r.status ?? 1)
@@ -1,12 +1,10 @@
import type { Context } from 'koa'
import { execFile } from 'child_process'
import { existsSync, readFileSync } from 'fs'
import { join } from 'path'
import { promisify } from 'util'
import { getHermesBin } from '../../services/hermes/hermes-path'
import { getActiveProfileName, getProfileDir } from '../../services/hermes/hermes-profile'
import { execHermesWithBin } from '../../services/hermes/hermes-process'
const execFileAsync = promisify(execFile)
const TIMEOUT_MS = 60_000
type JobRecord = Record<string, any>
@@ -119,7 +117,7 @@ function getSkills(body: Record<string, any>): string[] | null {
async function runHermesCron(profile: string, args: string[]): Promise<void> {
const profileDir = resolveProfileDir(profile)
try {
await execFileAsync(getHermesBin(), args, {
await execHermesWithBin(getHermesBin(), args, {
cwd: process.cwd(),
env: { ...process.env, HERMES_HOME: profileDir },
timeout: TIMEOUT_MS,
@@ -1,15 +1,12 @@
import { execFile } from 'child_process'
import { existsSync, readFileSync } from 'fs'
import { join } from 'path'
import { promisify } from 'util'
import { stripLegacyApiServerGatewayConfig } from '../config-helpers'
import { logger } from '../logger'
import { safeFileStore } from '../safe-file-store'
import { getProfileDir, listProfileNamesFromDisk } from './hermes-profile'
import { startGatewayRunManaged } from './gateway-runner'
import { parseGatewayStatusesFromProfileList } from './profile-list-parser'
const execFileAsync = promisify(execFile)
import { execHermesWithBin } from './hermes-process'
const RESERVED_PROFILE_NAMES = new Set([
'hermes', 'test', 'tmp', 'root', 'sudo',
@@ -115,7 +112,7 @@ export function parseGatewayStatusesFromProfileListOutput(stdout: string, profil
}
async function listGatewayStatusesFromProfileList(hermesBin: string): Promise<Map<string, string>> {
const { stdout } = await execFileAsync(hermesBin, ['profile', 'list'], {
const { stdout } = await execHermesWithBin(hermesBin, ['profile', 'list'], {
timeout: 10000,
windowsHide: true,
})
@@ -132,7 +129,7 @@ export async function isGatewayRunningForProfile(hermesBin: string, profileDir:
if (gatewayStateLooksRunningForProfile(profileDir)) return true
try {
const { stdout, stderr } = await execFileAsync(hermesBin, ['gateway', 'status'], {
const { stdout, stderr } = await execHermesWithBin(hermesBin, ['gateway', 'status'], {
timeout: 10000,
windowsHide: true,
env: {
@@ -170,7 +167,7 @@ async function waitForGatewayRunning(hermesBin: string, profile: string, profile
async function stopGatewayForProfile(hermesBin: string, profile: string, profileDir: string): Promise<void> {
try {
await execFileAsync(hermesBin, ['gateway', 'stop'], {
await execHermesWithBin(hermesBin, ['gateway', 'stop'], {
timeout: 30000,
windowsHide: true,
env: {
@@ -202,7 +199,7 @@ export async function startGatewayForProfile(
}
try {
await execFileAsync(hermesBin, ['gateway', 'start'], {
await execHermesWithBin(hermesBin, ['gateway', 'start'], {
timeout: 30000,
windowsHide: true,
env: {
@@ -27,7 +27,7 @@
* - 停止时先尝试 `hermes gateway stop`,再根据 PID / 监听端口清理进程
*/
import { spawn, type ChildProcess } from 'child_process'
import type { ChildProcess } from 'child_process'
import { join } from 'path'
import { readFileSync, existsSync, readdirSync, unlinkSync } from 'fs'
import { execFile } from 'child_process'
@@ -36,6 +36,7 @@ import { createServer } from 'net'
import yaml from 'js-yaml'
import { logger } from '../logger'
import { detectHermesHome, getHermesBin } from './hermes-path'
import { execHermesWithBin, spawnHermesWithBin } from './hermes-process'
const execFileAsync = promisify(execFile)
@@ -430,7 +431,7 @@ export class GatewayManager {
/** 列出所有已知 profile 名称(通过 hermes CLI 或文件系统扫描) */
async listProfiles(): Promise<string[]> {
try {
const { stdout } = await execFileAsync(HERMES_BIN, ['profile', 'list'], {
const { stdout } = await execHermesWithBin(HERMES_BIN, ['profile', 'list'], {
timeout: 10000,
windowsHide: true,
})
@@ -573,7 +574,7 @@ export class GatewayManager {
return new Promise((resolve, reject) => {
const env = buildGatewayProcessEnv(name, hermesHome)
const detachGateway = shouldDetachGatewayProcess()
const child = spawn(HERMES_BIN, ['gateway', 'run', '--replace'], {
const child = spawnHermesWithBin(HERMES_BIN, ['gateway', 'run', '--replace'], {
stdio: 'ignore',
detached: detachGateway,
windowsHide: true,
@@ -680,7 +681,7 @@ export class GatewayManager {
private async stopViaHermesCli(name: string): Promise<void> {
const hermesHome = this.profileDir(name)
try {
const { stdout, stderr } = await execFileAsync(HERMES_BIN, ['gateway', 'stop'], {
const { stdout, stderr } = await execHermesWithBin(HERMES_BIN, ['gateway', 'stop'], {
timeout: 15000,
windowsHide: true,
env: buildGatewayProcessEnv(name, hermesHome),
@@ -838,7 +839,7 @@ export class GatewayManager {
if (currentProfile !== 'default') {
logger.info('Current profile is "%s", switching to "default" for gateway startup', currentProfile)
try {
await execFileAsync(HERMES_BIN, ['profile', 'use', 'default'], {
await execHermesWithBin(HERMES_BIN, ['profile', 'use', 'default'], {
timeout: 10000,
windowsHide: true,
})
@@ -1,12 +1,12 @@
import { spawn } from 'child_process'
import { getActiveProfileDir } from './hermes-profile'
import { spawnHermesWithBin } from './hermes-process'
export function startGatewayRunManaged(
hermesBin: string,
opts: { profileDir?: string } = {},
): { pid: number | null; reused: boolean } {
const profileDir = opts.profileDir || getActiveProfileDir()
const child = spawn(hermesBin, ['gateway', 'run', '--replace'], {
const child = spawnHermesWithBin(hermesBin, ['gateway', 'run', '--replace'], {
detached: true,
stdio: 'ignore',
windowsHide: true,
@@ -9,6 +9,7 @@ import { getActiveProfileDir, getActiveProfileName, getProfileDir, listProfileNa
import { startGatewayRunManaged } from './gateway-runner'
import { isGatewayRunningForProfile } from './gateway-autostart'
import { parseProfileListRuntimeInfo, type ProfileListRuntimeInfo } from './profile-list-parser'
import { execHermesWithBin, spawnHermesWithBin } from './hermes-process'
const execFileAsync = promisify(execFile)
@@ -39,7 +40,7 @@ async function waitForGatewayRunning(profileDir: string, timeoutMs = 15000): Pro
async function stopGatewayForActiveProfile(): Promise<void> {
try {
await execFileAsync(HERMES_BIN, ['gateway', 'stop'], {
await execHermesWithBin(HERMES_BIN, ['gateway', 'stop'], {
timeout: 30000,
...activeGatewayExecOpts(),
})
@@ -248,7 +249,7 @@ export async function exportSessionsRaw(source?: string): Promise<HermesSessionF
if (source) args.push('--source', source)
try {
const { stdout } = await execFileAsync(HERMES_BIN, args, {
const { stdout } = await execHermesWithBin(HERMES_BIN, args, {
maxBuffer: 50 * 1024 * 1024, // 50MB
timeout: 30000,
...execOpts,
@@ -315,7 +316,7 @@ export async function getSession(id: string): Promise<HermesSession | null> {
const args = ['sessions', 'export', '-', '--session-id', id]
try {
const { stdout } = await execFileAsync(HERMES_BIN, args, {
const { stdout } = await execHermesWithBin(HERMES_BIN, args, {
maxBuffer: 50 * 1024 * 1024,
timeout: 30000,
...execOpts,
@@ -359,7 +360,7 @@ export async function getSession(id: string): Promise<HermesSession | null> {
*/
export async function deleteSession(id: string): Promise<boolean> {
try {
await execFileAsync(HERMES_BIN, ['sessions', 'delete', id, '--yes'], {
await execHermesWithBin(HERMES_BIN, ['sessions', 'delete', id, '--yes'], {
timeout: 10000,
...execOpts,
})
@@ -375,7 +376,7 @@ export async function deleteSession(id: string): Promise<boolean> {
*/
export async function deleteSessionForProfile(id: string, profile: string): Promise<boolean> {
try {
await execFileAsync(HERMES_BIN, ['sessions', 'delete', id, '--yes'], {
await execHermesWithBin(HERMES_BIN, ['sessions', 'delete', id, '--yes'], {
timeout: 10000,
...execOpts,
env: {
@@ -395,7 +396,7 @@ export async function deleteSessionForProfile(id: string, profile: string): Prom
*/
export async function renameSession(id: string, title: string): Promise<boolean> {
try {
await execFileAsync(HERMES_BIN, ['sessions', 'rename', id, title], {
await execHermesWithBin(HERMES_BIN, ['sessions', 'rename', id, title], {
timeout: 10000,
...execOpts,
})
@@ -417,7 +418,7 @@ export interface LogFileInfo {
*/
export async function getVersion(): Promise<string> {
try {
const { stdout } = await execFileAsync(HERMES_BIN, ['--version'], { timeout: 5000, ...execOpts })
const { stdout } = await execHermesWithBin(HERMES_BIN, ['--version'], { timeout: 5000, ...execOpts })
return stdout.trim()
} catch {
return ''
@@ -433,7 +434,7 @@ export async function startGateway(): Promise<string> {
return pid ? `Gateway started (PID: ${pid})` : 'Gateway start triggered'
}
const { stdout, stderr } = await execFileAsync(HERMES_BIN, ['gateway', 'start'], {
const { stdout, stderr } = await execHermesWithBin(HERMES_BIN, ['gateway', 'start'], {
timeout: 30000,
...activeGatewayExecOpts(),
})
@@ -445,7 +446,7 @@ export async function startGateway(): Promise<string> {
* Uses "hermes gateway run" as a detached background process
*/
export async function startGatewayBackground(): Promise<number | null> {
const child = spawn(HERMES_BIN, ['gateway', 'run'], {
const child = spawnHermesWithBin(HERMES_BIN, ['gateway', 'run'], {
detached: true,
stdio: 'ignore',
windowsHide: true,
@@ -475,7 +476,7 @@ export async function restartGateway(): Promise<string> {
return result.pid ? `Gateway run replaced (PID: ${result.pid})` : 'Gateway run replaced'
}
try {
const { stdout, stderr } = await execFileAsync(HERMES_BIN, ['gateway', 'restart'], {
const { stdout, stderr } = await execHermesWithBin(HERMES_BIN, ['gateway', 'restart'], {
timeout: 30000,
...activeGatewayExecOpts(),
})
@@ -498,7 +499,7 @@ export async function restartGateway(): Promise<string> {
* Stop Hermes gateway
*/
export async function stopGateway(): Promise<string> {
const { stdout, stderr } = await execFileAsync(HERMES_BIN, ['gateway', 'stop'], {
const { stdout, stderr } = await execHermesWithBin(HERMES_BIN, ['gateway', 'stop'], {
timeout: 30000,
...activeGatewayExecOpts(),
})
@@ -510,7 +511,7 @@ export async function stopGateway(): Promise<string> {
*/
export async function listLogFiles(): Promise<LogFileInfo[]> {
try {
const { stdout } = await execFileAsync(HERMES_BIN, ['logs', 'list'], {
const { stdout } = await execHermesWithBin(HERMES_BIN, ['logs', 'list'], {
timeout: 10000,
...execOpts,
})
@@ -552,7 +553,7 @@ export async function readLogs(
if (since) args.push('--since', since)
try {
const { stdout } = await execFileAsync(HERMES_BIN, args, {
const { stdout } = await execHermesWithBin(HERMES_BIN, args, {
maxBuffer: 10 * 1024 * 1024,
timeout: 15000,
...execOpts,
@@ -608,7 +609,7 @@ export async function listProfiles(): Promise<HermesProfile[]> {
const activeProfileName = getActiveProfileName()
let runtimeInfo = new Map<string, ProfileListRuntimeInfo>()
try {
const { stdout } = await execFileAsync(HERMES_BIN, ['profile', 'list'], {
const { stdout } = await execHermesWithBin(HERMES_BIN, ['profile', 'list'], {
timeout: 10000,
...execOpts,
})
@@ -635,7 +636,7 @@ export async function listProfiles(): Promise<HermesProfile[]> {
*/
export async function getProfile(name: string): Promise<HermesProfileDetail> {
try {
const { stdout } = await execFileAsync(HERMES_BIN, ['profile', 'show', name], {
const { stdout } = await execHermesWithBin(HERMES_BIN, ['profile', 'show', name], {
timeout: 10000,
...execOpts,
})
@@ -678,7 +679,7 @@ export async function createProfile(name: string, clone?: boolean): Promise<stri
if (clone) args.push('--clone')
try {
const { stdout, stderr } = await execFileAsync(HERMES_BIN, args, {
const { stdout, stderr } = await execHermesWithBin(HERMES_BIN, args, {
timeout: 15000,
...execOpts,
})
@@ -694,7 +695,7 @@ export async function createProfile(name: string, clone?: boolean): Promise<stri
*/
export async function deleteProfile(name: string): Promise<boolean> {
try {
await execFileAsync(HERMES_BIN, ['profile', 'delete', name, '--yes'], {
await execHermesWithBin(HERMES_BIN, ['profile', 'delete', name, '--yes'], {
timeout: 10000,
...execOpts,
})
@@ -710,7 +711,7 @@ export async function deleteProfile(name: string): Promise<boolean> {
*/
export async function renameProfile(oldName: string, newName: string): Promise<boolean> {
try {
await execFileAsync(HERMES_BIN, ['profile', 'rename', oldName, newName], {
await execHermesWithBin(HERMES_BIN, ['profile', 'rename', oldName, newName], {
timeout: 10000,
...execOpts,
})
@@ -726,7 +727,7 @@ export async function renameProfile(oldName: string, newName: string): Promise<b
*/
export async function useProfile(name: string): Promise<string> {
try {
const { stdout, stderr } = await execFileAsync(HERMES_BIN, ['profile', 'use', name], {
const { stdout, stderr } = await execHermesWithBin(HERMES_BIN, ['profile', 'use', name], {
timeout: 10000,
...execOpts,
})
@@ -745,7 +746,7 @@ export async function exportProfile(name: string, outputPath?: string): Promise<
if (outputPath) args.push('--output', outputPath)
try {
const { stdout, stderr } = await execFileAsync(HERMES_BIN, args, {
const { stdout, stderr } = await execHermesWithBin(HERMES_BIN, args, {
timeout: 60000,
...execOpts,
})
@@ -761,7 +762,7 @@ export async function exportProfile(name: string, outputPath?: string): Promise<
*/
export async function setupReset(): Promise<string> {
try {
const { stdout, stderr } = await execFileAsync(HERMES_BIN, ['setup', '--non-interactive', '--reset'], {
const { stdout, stderr } = await execHermesWithBin(HERMES_BIN, ['setup', '--non-interactive', '--reset'], {
timeout: 30000,
...execOpts,
})
@@ -780,7 +781,7 @@ export async function importProfile(archivePath: string, name?: string): Promise
if (name) args.push('--name', name)
try {
const { stdout, stderr } = await execFileAsync(HERMES_BIN, args, {
const { stdout, stderr } = await execHermesWithBin(HERMES_BIN, args, {
timeout: 60000,
...execOpts,
})
@@ -797,7 +798,7 @@ export async function importProfile(archivePath: string, name?: string): Promise
export async function pinSkill(name: string, pinned: boolean): Promise<string> {
const subcmd = pinned ? 'pin' : 'unpin'
try {
const { stdout, stderr } = await execFileAsync(HERMES_BIN, ['curator', subcmd, name], {
const { stdout, stderr } = await execHermesWithBin(HERMES_BIN, ['curator', subcmd, name], {
timeout: 15000,
...execOpts,
})
@@ -1,9 +1,6 @@
import { execFile, spawn } from 'child_process'
import type { ChildProcess } from 'child_process'
import { promisify } from 'util'
import { logger } from '../logger'
const execFileAsync = promisify(execFile)
import { execHermes, spawnHermes } from './hermes-process'
const execOpts = { windowsHide: true }
const BOARD_SLUG_RE = /^[a-z0-9][a-z0-9_-]{0,63}$/
@@ -12,14 +9,6 @@ const NO_WORKER_LOG_PATTERNS = [
/^no worker log(?: for [^\n]+)?$/i,
]
function resolveHermesBin(): string {
const envBin = process.env.HERMES_BIN?.trim()
if (envBin) return envBin
return 'hermes'
}
const HERMES_BIN = resolveHermesBin()
export function normalizeBoardSlug(board?: string | null): string {
if (board === undefined || board === null) return 'default'
const trimmed = board.trim().toLowerCase()
@@ -186,7 +175,7 @@ export async function listBoards(opts?: { includeArchived?: boolean }): Promise<
if (opts?.includeArchived) args.push('--all')
try {
const { stdout } = await execFileAsync(HERMES_BIN, args, {
const { stdout } = await execHermes(args, {
maxBuffer: 50 * 1024 * 1024,
timeout: 30000,
...execOpts,
@@ -213,7 +202,7 @@ export async function createBoard(opts: KanbanBoardCreateOptions): Promise<Kanba
if (opts.switchCurrent) args.push('--switch')
try {
await execFileAsync(HERMES_BIN, args, {
await execHermes(args, {
maxBuffer: 50 * 1024 * 1024,
timeout: 30000,
...execOpts,
@@ -232,7 +221,7 @@ export async function archiveBoard(slugInput: string): Promise<void> {
if (slug === 'default') throw new Error('Cannot archive the default kanban board')
try {
await execFileAsync(HERMES_BIN, ['kanban', 'boards', 'rm', slug], {
await execHermes(['kanban', 'boards', 'rm', slug], {
maxBuffer: 50 * 1024 * 1024,
timeout: 30000,
...execOpts,
@@ -297,7 +286,7 @@ function textFromExecValue(value: unknown): string {
async function execKanbanMutation(args: string[], logMessage: string, errorPrefix: string): Promise<string> {
try {
const { stdout, stderr } = await execFileAsync(HERMES_BIN, args, {
const { stdout, stderr } = await execHermes(args, {
maxBuffer: 50 * 1024 * 1024,
timeout: 30000,
...execOpts,
@@ -318,7 +307,7 @@ export function buildWatchArgs(opts?: KanbanWatchOptions): string[] {
}
export function watchEvents(opts?: KanbanWatchOptions): ChildProcess {
return spawn(HERMES_BIN, buildWatchArgs(opts), {
return spawnHermes(buildWatchArgs(opts), {
stdio: ['ignore', 'pipe', 'pipe'],
...execOpts,
})
@@ -346,7 +335,7 @@ export async function addComment(taskId: string, body: string, opts?: KanbanBoar
const args = [...boardArgs(opts?.board), 'comment', taskId, body]
pushOptional(args, '--author', opts?.author)
try {
const { stdout } = await execFileAsync(HERMES_BIN, args, {
const { stdout } = await execHermes(args, {
maxBuffer: 50 * 1024 * 1024,
timeout: 30000,
...execOpts,
@@ -362,7 +351,7 @@ export async function getTaskLog(taskId: string, opts?: KanbanBoardOptions & { t
const args = [...boardArgs(opts?.board), 'log', taskId]
pushOptional(args, '--tail', opts?.tail)
try {
const { stdout } = await execFileAsync(HERMES_BIN, args, {
const { stdout } = await execHermes(args, {
maxBuffer: 50 * 1024 * 1024,
timeout: 30000,
...execOpts,
@@ -399,7 +388,7 @@ export async function getDiagnostics(opts?: KanbanBoardOptions & { task?: string
pushOptional(args, '--task', opts?.task)
pushOptional(args, '--severity', opts?.severity)
try {
const { stdout } = await execFileAsync(HERMES_BIN, args, {
const { stdout } = await execHermes(args, {
maxBuffer: 50 * 1024 * 1024,
timeout: 30000,
...execOpts,
@@ -415,7 +404,7 @@ export async function reclaimTask(taskId: string, opts?: KanbanBoardOptions & {
const args = [...boardArgs(opts?.board), 'reclaim', taskId]
pushOptional(args, '--reason', opts?.reason)
try {
const { stdout } = await execFileAsync(HERMES_BIN, args, {
const { stdout } = await execHermes(args, {
maxBuffer: 50 * 1024 * 1024,
timeout: 30000,
...execOpts,
@@ -432,7 +421,7 @@ export async function reassignTask(taskId: string, profile: string, opts?: Kanba
if (opts?.reclaim) args.push('--reclaim')
pushOptional(args, '--reason', opts?.reason)
try {
const { stdout } = await execFileAsync(HERMES_BIN, args, {
const { stdout } = await execHermes(args, {
maxBuffer: 50 * 1024 * 1024,
timeout: 30000,
...execOpts,
@@ -448,7 +437,7 @@ export async function specifyTask(taskId: string, opts?: KanbanBoardOptions & {
const args = [...boardArgs(opts?.board), 'specify', taskId, '--json']
pushOptional(args, '--author', opts?.author)
try {
const { stdout } = await execFileAsync(HERMES_BIN, args, {
const { stdout } = await execHermes(args, {
maxBuffer: 50 * 1024 * 1024,
timeout: 30000,
...execOpts,
@@ -466,7 +455,7 @@ export async function dispatch(opts?: KanbanBoardOptions & { dryRun?: boolean; m
pushOptional(args, '--max', opts?.max)
pushOptional(args, '--failure-limit', opts?.failureLimit)
try {
const { stdout } = await execFileAsync(HERMES_BIN, args, {
const { stdout } = await execHermes(args, {
maxBuffer: 50 * 1024 * 1024,
timeout: 30000,
...execOpts,
@@ -492,7 +481,7 @@ export async function listTasks(opts?: {
if (opts?.tenant) args.push('--tenant', opts.tenant)
try {
const { stdout } = await execFileAsync(HERMES_BIN, args, {
const { stdout } = await execHermes(args, {
maxBuffer: 50 * 1024 * 1024,
timeout: 30000,
...execOpts,
@@ -506,7 +495,7 @@ export async function listTasks(opts?: {
export async function getTask(taskId: string, opts?: KanbanBoardOptions): Promise<KanbanTaskDetail | null> {
try {
const { stdout } = await execFileAsync(HERMES_BIN, [...boardArgs(opts?.board), 'show', taskId, '--json'], {
const { stdout } = await execHermes([...boardArgs(opts?.board), 'show', taskId, '--json'], {
maxBuffer: 50 * 1024 * 1024,
timeout: 30000,
...execOpts,
@@ -536,7 +525,7 @@ export async function createTask(
if (opts?.tenant) args.push('--tenant', opts.tenant)
try {
const { stdout } = await execFileAsync(HERMES_BIN, args, {
const { stdout } = await execHermes(args, {
maxBuffer: 50 * 1024 * 1024,
timeout: 30000,
...execOpts,
@@ -622,7 +611,7 @@ export async function bulkUpdateTasks(opts: KanbanBulkTaskUpdateOptions): Promis
export async function getStats(opts?: KanbanBoardOptions): Promise<KanbanStats> {
try {
const { stdout } = await execFileAsync(HERMES_BIN, [...boardArgs(opts?.board), 'stats', '--json'], {
const { stdout } = await execHermes([...boardArgs(opts?.board), 'stats', '--json'], {
maxBuffer: 50 * 1024 * 1024,
timeout: 30000,
...execOpts,
@@ -642,7 +631,7 @@ export async function getStats(opts?: KanbanBoardOptions): Promise<KanbanStats>
export async function getAssignees(opts?: KanbanBoardOptions): Promise<KanbanAssignee[]> {
try {
const { stdout } = await execFileAsync(HERMES_BIN, [...boardArgs(opts?.board), 'assignees', '--json'], {
const { stdout } = await execHermes([...boardArgs(opts?.board), 'assignees', '--json'], {
maxBuffer: 50 * 1024 * 1024,
timeout: 30000,
...execOpts,
@@ -0,0 +1,75 @@
import { execFile, spawn } from 'child_process'
import type { ChildProcess, ExecFileOptions, SpawnOptions } from 'child_process'
import { existsSync } from 'fs'
import { basename, dirname, resolve } from 'path'
export interface HermesInvocation {
command: string
argsPrefix: string[]
}
export interface HermesExecResult {
stdout: string
stderr: string
}
export function resolveHermesBin(customBin?: string): string {
return customBin?.trim() || process.env.HERMES_BIN?.trim() || 'hermes'
}
function bundledPythonForWindows(hermesBin: string): string | null {
const envPython = process.env.HERMES_AGENT_BRIDGE_PYTHON?.trim()
if (envPython) return envPython
if (basename(hermesBin).toLowerCase() !== 'hermes.exe') return null
const python = resolve(dirname(hermesBin), '..', 'python.exe')
return existsSync(python) ? python : null
}
export function resolveHermesInvocation(hermesBin = resolveHermesBin()): HermesInvocation {
if (process.platform === 'win32') {
const python = bundledPythonForWindows(hermesBin)
if (python) return { command: python, argsPrefix: ['-m', 'hermes_cli.main'] }
}
return { command: hermesBin, argsPrefix: [] }
}
export function execHermesWithBin(
hermesBin: string,
args: readonly string[],
options?: ExecFileOptions,
): Promise<HermesExecResult> {
const invocation = resolveHermesInvocation(hermesBin)
return new Promise((resolveExec, rejectExec) => {
execFile(
invocation.command,
[...invocation.argsPrefix, ...args],
{ ...options, encoding: 'utf8' },
(error, stdout, stderr) => {
if (error) {
rejectExec(Object.assign(error, { stdout, stderr }))
return
}
resolveExec({ stdout: String(stdout || ''), stderr: String(stderr || '') })
},
)
})
}
export function execHermes(args: readonly string[], options?: ExecFileOptions) {
return execHermesWithBin(resolveHermesBin(), args, options)
}
export function spawnHermesWithBin(
hermesBin: string,
args: readonly string[],
options?: SpawnOptions,
): ChildProcess {
const invocation = resolveHermesInvocation(hermesBin)
return spawn(invocation.command, [...invocation.argsPrefix, ...args], options || {})
}
export function spawnHermes(args: readonly string[], options?: SpawnOptions): ChildProcess {
return spawnHermesWithBin(resolveHermesBin(), args, options)
}
+4 -2
View File
@@ -1,10 +1,12 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const mockExecFileAsync = vi.hoisted(() => vi.fn())
const mockSpawnHermes = vi.hoisted(() => vi.fn())
const mockLoggerError = vi.hoisted(() => vi.fn())
vi.mock('util', () => ({
promisify: () => mockExecFileAsync,
vi.mock('../../packages/server/src/services/hermes/hermes-process', () => ({
execHermes: (args: string[], options: unknown) => mockExecFileAsync('hermes', args, options),
spawnHermes: mockSpawnHermes,
}))
vi.mock('../../packages/server/src/services/logger', () => ({
+80
View File
@@ -0,0 +1,80 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'fs'
import { tmpdir } from 'os'
import { join } from 'path'
const execFileCalls = vi.hoisted(() => [] as Array<{ command: string; args: string[]; options: any }>)
vi.mock('child_process', () => ({
execFile: vi.fn((command: string, args: string[], options: any, callback: (error: Error | null, stdout: string, stderr: string) => void) => {
execFileCalls.push({ command, args, options })
callback(null, 'ok\n', '')
}),
spawn: vi.fn(),
}))
const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform')
function setPlatform(platform: NodeJS.Platform): void {
Object.defineProperty(process, 'platform', { value: platform })
}
afterEach(() => {
execFileCalls.length = 0
delete process.env.HERMES_AGENT_BRIDGE_PYTHON
if (originalPlatform) Object.defineProperty(process, 'platform', originalPlatform)
vi.resetModules()
})
describe('Hermes process invocation', () => {
it('bypasses the uv hermes.exe trampoline on Windows packaged installs', async () => {
setPlatform('win32')
process.env.HERMES_AGENT_BRIDGE_PYTHON = 'C:\\Users\\me\\AppData\\Local\\Programs\\Hermes Studio\\resources\\python\\python.exe'
const { execHermesWithBin } = await import('../../packages/server/src/services/hermes/hermes-process')
const result = await execHermesWithBin(
'C:\\Users\\me\\AppData\\Local\\Programs\\Hermes Studio\\resources\\python\\Scripts\\hermes.exe',
['kanban', '--board', 'default', 'create', 'demo', '--json'],
{ windowsHide: true },
)
expect(result.stdout).toBe('ok\n')
expect(execFileCalls[0]).toMatchObject({
command: process.env.HERMES_AGENT_BRIDGE_PYTHON,
args: ['-m', 'hermes_cli.main', 'kanban', '--board', 'default', 'create', 'demo', '--json'],
})
})
it('discovers sibling python.exe for a Windows hermes.exe launcher', async () => {
setPlatform('win32')
const root = mkdtempSync(join(tmpdir(), 'hermes-process-'))
try {
const scripts = join(root, 'Scripts')
mkdirSync(scripts)
writeFileSync(join(root, 'python.exe'), '')
writeFileSync(join(scripts, 'hermes.exe'), '')
const { execHermesWithBin } = await import('../../packages/server/src/services/hermes/hermes-process')
await execHermesWithBin(join(scripts, 'hermes.exe'), ['--version'], { windowsHide: true })
expect(execFileCalls[0]).toMatchObject({
command: join(root, 'python.exe'),
args: ['-m', 'hermes_cli.main', '--version'],
})
} finally {
rmSync(root, { recursive: true, force: true })
}
})
it('keeps normal Hermes command execution unchanged on non-Windows platforms', async () => {
setPlatform('darwin')
const { execHermesWithBin } = await import('../../packages/server/src/services/hermes/hermes-process')
await execHermesWithBin('/opt/hermes/bin/hermes', ['--version'], { windowsHide: true })
expect(execFileCalls[0]).toMatchObject({
command: '/opt/hermes/bin/hermes',
args: ['--version'],
})
})
})