diff --git a/packages/desktop/scripts/install-hermes.mjs b/packages/desktop/scripts/install-hermes.mjs index 905a10f..c735ad6 100644 --- a/packages/desktop/scripts/install-hermes.mjs +++ b/packages/desktop/scripts/install-hermes.mjs @@ -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) diff --git a/packages/server/src/controllers/hermes/jobs.ts b/packages/server/src/controllers/hermes/jobs.ts index ff6c862..96a4b98 100644 --- a/packages/server/src/controllers/hermes/jobs.ts +++ b/packages/server/src/controllers/hermes/jobs.ts @@ -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 @@ -119,7 +117,7 @@ function getSkills(body: Record): string[] | null { async function runHermesCron(profile: string, args: string[]): Promise { 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, diff --git a/packages/server/src/services/hermes/gateway-autostart.ts b/packages/server/src/services/hermes/gateway-autostart.ts index 58dc7ea..5040761 100644 --- a/packages/server/src/services/hermes/gateway-autostart.ts +++ b/packages/server/src/services/hermes/gateway-autostart.ts @@ -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> { - 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 { 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: { diff --git a/packages/server/src/services/hermes/gateway-manager.ts b/packages/server/src/services/hermes/gateway-manager.ts index e2b496e..0dc720c 100644 --- a/packages/server/src/services/hermes/gateway-manager.ts +++ b/packages/server/src/services/hermes/gateway-manager.ts @@ -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 { 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 { 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, }) diff --git a/packages/server/src/services/hermes/gateway-runner.ts b/packages/server/src/services/hermes/gateway-runner.ts index 146d393..2ade359 100644 --- a/packages/server/src/services/hermes/gateway-runner.ts +++ b/packages/server/src/services/hermes/gateway-runner.ts @@ -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, diff --git a/packages/server/src/services/hermes/hermes-cli.ts b/packages/server/src/services/hermes/hermes-cli.ts index b98a309..5d6456d 100644 --- a/packages/server/src/services/hermes/hermes-cli.ts +++ b/packages/server/src/services/hermes/hermes-cli.ts @@ -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 { 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 { 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 { */ export async function deleteSession(id: string): Promise { 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 { */ export async function deleteSessionForProfile(id: string, profile: string): Promise { 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 { 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 { 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 { 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 { * Uses "hermes gateway run" as a detached background process */ export async function startGatewayBackground(): Promise { - 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 { 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 { * Stop Hermes gateway */ export async function stopGateway(): Promise { - 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 { */ export async function listLogFiles(): Promise { 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 { const activeProfileName = getActiveProfileName() let runtimeInfo = new Map() 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 { */ export async function getProfile(name: string): Promise { 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 { 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 { */ export async function renameProfile(oldName: string, newName: string): Promise { 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 { 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 { 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 { 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, }) diff --git a/packages/server/src/services/hermes/hermes-kanban.ts b/packages/server/src/services/hermes/hermes-kanban.ts index d6b0fa4..170fa15 100644 --- a/packages/server/src/services/hermes/hermes-kanban.ts +++ b/packages/server/src/services/hermes/hermes-kanban.ts @@ -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 { 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 { 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 { 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 { 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 export async function getAssignees(opts?: KanbanBoardOptions): Promise { 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, diff --git a/packages/server/src/services/hermes/hermes-process.ts b/packages/server/src/services/hermes/hermes-process.ts new file mode 100644 index 0000000..f1df810 --- /dev/null +++ b/packages/server/src/services/hermes/hermes-process.ts @@ -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 { + 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) +} diff --git a/tests/server/hermes-kanban-service.test.ts b/tests/server/hermes-kanban-service.test.ts index 467ad9f..fbccdd4 100644 --- a/tests/server/hermes-kanban-service.test.ts +++ b/tests/server/hermes-kanban-service.test.ts @@ -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', () => ({ diff --git a/tests/server/hermes-process.test.ts b/tests/server/hermes-process.test.ts new file mode 100644 index 0000000..ebb29b9 --- /dev/null +++ b/tests/server/hermes-process.test.ts @@ -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'], + }) + }) +})