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:
@@ -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)
|
||||
}
|
||||
@@ -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', () => ({
|
||||
|
||||
@@ -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'],
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user