mirror of
https://github.com/EKKOLearnAI/hermes-web-ui.git
synced 2026-07-16 13:40:14 +00:00
fix windows desktop runtime trampolines
This commit is contained in:
@@ -482,16 +482,11 @@ run(pyBin, [
|
||||
])
|
||||
|
||||
const hermesBin = TARGET_OS === 'win32'
|
||||
? resolve(PY_DIR, 'Scripts', 'hermes.exe')
|
||||
? resolve(PY_DIR, 'Scripts', 'hermes.cmd')
|
||||
: 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`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
// hermes-web-ui's agent-bridge searches for `run_agent.py` at <python_root>/run_agent.py
|
||||
// (and a few neighbouring dirs). pip places it at site-packages/run_agent.py — surface
|
||||
// it at the venv root with a *relative* symlink so the venv stays portable when copied
|
||||
@@ -521,17 +516,26 @@ function siteRunAgentRelative() {
|
||||
// shebang to the build-time Python path) with a relative wrapper so the
|
||||
// bundled venv works after being moved into the .app/.exe payload.
|
||||
if (TARGET_OS === 'win32') {
|
||||
// Windows: pip generates a .exe launcher that embeds a relative shebang
|
||||
// already. Add a .cmd wrapper that prefers the colocated python.exe.
|
||||
const cmdPath = resolve(PY_DIR, 'Scripts', 'hermes.cmd')
|
||||
writeFileSync(
|
||||
cmdPath,
|
||||
[
|
||||
'@echo off',
|
||||
'set "PY=%~dp0..\\python.exe"',
|
||||
'"%PY%" -m hermes_cli.main %*',
|
||||
].join('\r\n'),
|
||||
)
|
||||
// Windows: uv/pip .exe launchers can embed build-machine paths. Replace the
|
||||
// Hermes entry points with relocatable .cmd wrappers and remove the .exe
|
||||
// trampolines so PATHEXT lookups cannot pick a stale launcher first.
|
||||
const wrappers = [
|
||||
['hermes', 'hermes_cli.main'],
|
||||
['hermes-agent', 'run_agent'],
|
||||
['hermes-acp', 'acp_adapter.entry'],
|
||||
]
|
||||
for (const [name, mod] of wrappers) {
|
||||
const cmdPath = resolve(PY_DIR, 'Scripts', `${name}.cmd`)
|
||||
writeFileSync(
|
||||
cmdPath,
|
||||
[
|
||||
'@echo off',
|
||||
'set "PY=%~dp0..\\python.exe"',
|
||||
`"%PY%" -m ${mod} %*`,
|
||||
].join('\r\n'),
|
||||
)
|
||||
rmSync(resolve(PY_DIR, 'Scripts', `${name}.exe`), { force: true })
|
||||
}
|
||||
} else {
|
||||
const launcher = [
|
||||
'#!/bin/sh',
|
||||
@@ -556,6 +560,11 @@ if (TARGET_OS === 'win32') {
|
||||
|
||||
console.log(`✓ hermes installed at ${hermesBin} (relocatable launcher)`)
|
||||
|
||||
if (!existsSync(hermesBin)) {
|
||||
console.error(`hermes binary not found at ${hermesBin} after install`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
run(hermesCheckCommand, hermesCheckArgs)
|
||||
|
||||
if (!SKIP_BROWSER_RUNTIME) {
|
||||
|
||||
@@ -41,7 +41,7 @@ type ActiveRuntimeVersion = {
|
||||
|
||||
function runtimeRequiredFiles(root: string): string[] {
|
||||
const python = isWin ? join(root, 'python', 'python.exe') : join(root, 'python', 'bin', 'python3')
|
||||
const hermes = isWin ? join(root, 'python', 'Scripts', 'hermes.exe') : join(root, 'python', 'bin', 'hermes')
|
||||
const hermes = isWin ? join(root, 'python', 'Scripts', 'hermes.cmd') : join(root, 'python', 'bin', 'hermes')
|
||||
const node = isWin ? join(root, 'node', 'node.exe') : join(root, 'node', 'bin', 'node')
|
||||
const files = [python, hermes, node]
|
||||
if (isWin) files.push(join(root, 'git', 'cmd', 'git.exe'))
|
||||
@@ -348,7 +348,7 @@ export function bundledPython(): string {
|
||||
}
|
||||
|
||||
export function hermesBin(): string {
|
||||
return isWin ? join(pythonBinDir(), 'hermes.exe') : join(pythonBinDir(), 'hermes')
|
||||
return isWin ? join(pythonBinDir(), 'hermes.cmd') : join(pythonBinDir(), 'hermes')
|
||||
}
|
||||
|
||||
export function hermesBinExists(): boolean {
|
||||
|
||||
@@ -86,7 +86,7 @@ function requiredRuntimeFiles(root: string): string[] {
|
||||
? join(root, 'python', 'python.exe')
|
||||
: join(root, 'python', 'bin', 'python3')
|
||||
const hermesBin = process.platform === 'win32'
|
||||
? join(root, 'python', 'Scripts', 'hermes.exe')
|
||||
? join(root, 'python', 'Scripts', 'hermes.cmd')
|
||||
: join(root, 'python', 'bin', 'hermes')
|
||||
const nodeBin = process.platform === 'win32'
|
||||
? join(root, 'node', 'node.exe')
|
||||
@@ -107,7 +107,7 @@ function runtimeReady(): boolean {
|
||||
function rootRuntimeReady(root: string): boolean {
|
||||
const gitPath = process.platform === 'win32' ? join(root, 'git', 'cmd', 'git.exe') : null
|
||||
return existsSync(process.platform === 'win32' ? join(root, 'python', 'python.exe') : join(root, 'python', 'bin', 'python3'))
|
||||
&& existsSync(process.platform === 'win32' ? join(root, 'python', 'Scripts', 'hermes.exe') : join(root, 'python', 'bin', 'hermes'))
|
||||
&& existsSync(process.platform === 'win32' ? join(root, 'python', 'Scripts', 'hermes.cmd') : join(root, 'python', 'bin', 'hermes'))
|
||||
&& existsSync(process.platform === 'win32' ? join(root, 'node', 'node.exe') : join(root, 'node', 'bin', 'node'))
|
||||
&& (!gitPath || existsSync(gitPath))
|
||||
}
|
||||
|
||||
@@ -21,7 +21,8 @@ function bundledCliPythonForWindows(hermesBin: string): string | null {
|
||||
const envPython = process.env.HERMES_AGENT_CLI_PYTHON?.trim()
|
||||
if (envPython) return envPython
|
||||
|
||||
if (basename(hermesBin).toLowerCase() !== 'hermes.exe') return null
|
||||
const launcher = basename(hermesBin).toLowerCase()
|
||||
if (launcher !== 'hermes.exe' && launcher !== 'hermes.cmd') return null
|
||||
const python = resolve(dirname(hermesBin), '..', 'python.exe')
|
||||
return existsSync(python) ? python : null
|
||||
}
|
||||
|
||||
@@ -164,7 +164,7 @@ function requiredRuntimeFiles(root: string): string[] {
|
||||
? join(root, 'python', 'python.exe')
|
||||
: join(root, 'python', 'bin', 'python3')
|
||||
const hermesBin = process.platform === 'win32'
|
||||
? join(root, 'python', 'Scripts', 'hermes.exe')
|
||||
? join(root, 'python', 'Scripts', 'hermes.cmd')
|
||||
: join(root, 'python', 'bin', 'hermes')
|
||||
const nodeBin = process.platform === 'win32'
|
||||
? join(root, 'node', 'node.exe')
|
||||
|
||||
@@ -17,6 +17,7 @@ vi.mock('electron', () => ({
|
||||
}))
|
||||
|
||||
const originalEnv = { ...process.env }
|
||||
const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform')
|
||||
const tempDirs: string[] = []
|
||||
const servers: Server[] = []
|
||||
|
||||
@@ -32,7 +33,7 @@ function createRuntimeFiles(root: string) {
|
||||
mkdirSync(join(root, 'node'), { recursive: true })
|
||||
mkdirSync(join(root, 'git', 'cmd'), { recursive: true })
|
||||
writeFileSync(join(root, 'python', 'python.exe'), '')
|
||||
writeFileSync(join(root, 'python', 'Scripts', 'hermes.exe'), '')
|
||||
writeFileSync(join(root, 'python', 'Scripts', 'hermes.cmd'), '')
|
||||
writeFileSync(join(root, 'node', 'node.exe'), '')
|
||||
writeFileSync(join(root, 'git', 'cmd', 'git.exe'), '')
|
||||
} else {
|
||||
@@ -50,6 +51,10 @@ function createRuntimeFiles(root: string) {
|
||||
}))
|
||||
}
|
||||
|
||||
function setPlatform(platform: NodeJS.Platform): void {
|
||||
Object.defineProperty(process, 'platform', { value: platform })
|
||||
}
|
||||
|
||||
async function createRuntimeArchive(): Promise<string> {
|
||||
const source = tempDir('hermes-runtime-source-')
|
||||
const archive = join(tempDir('hermes-runtime-archive-'), 'hermes-runtime-test.tar.gz')
|
||||
@@ -87,6 +92,7 @@ describe('desktop runtime manager', () => {
|
||||
|
||||
afterEach(async () => {
|
||||
process.env = { ...originalEnv }
|
||||
if (originalPlatform) Object.defineProperty(process, 'platform', originalPlatform)
|
||||
vi.resetModules()
|
||||
await Promise.all(servers.splice(0).map(server => new Promise<void>(resolve => server.close(() => resolve()))))
|
||||
for (const dir of tempDirs.splice(0)) {
|
||||
@@ -122,4 +128,24 @@ describe('desktop runtime manager', () => {
|
||||
expect(existsSync(join(runtimeRoot, 'runtime-manifest.json'))).toBe(true)
|
||||
expect(existsSync(join(process.env.HERMES_WEB_UI_HOME!, 'desktop-runtime', 'active-version.json'))).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts Windows runtime archives that contain hermes.cmd without hermes.exe', async () => {
|
||||
setPlatform('win32')
|
||||
const archive = await createRuntimeArchive()
|
||||
process.env.HERMES_DESKTOP_RUNTIME_URL = await serveFile(archive)
|
||||
|
||||
const { runtimePlatformKey } = await import('../../packages/desktop/src/main/runtime-paths')
|
||||
const { ensureDesktopRuntime } = await import('../../packages/desktop/src/main/runtime-manager')
|
||||
await ensureDesktopRuntime()
|
||||
|
||||
const runtimeRoot = join(
|
||||
process.env.HERMES_WEB_UI_HOME!,
|
||||
'desktop-runtime',
|
||||
'hermes',
|
||||
'0.17.0',
|
||||
runtimePlatformKey(),
|
||||
)
|
||||
expect(existsSync(join(runtimeRoot, 'python', 'Scripts', 'hermes.cmd'))).toBe(true)
|
||||
expect(existsSync(join(runtimeRoot, 'python', 'Scripts', 'hermes.exe'))).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -15,6 +15,7 @@ vi.mock('electron', () => ({
|
||||
}))
|
||||
|
||||
const originalEnv = { ...process.env }
|
||||
const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform')
|
||||
const tempDirs: string[] = []
|
||||
|
||||
function tempDir(): string {
|
||||
@@ -29,7 +30,7 @@ function createRuntime(root: string, version: string) {
|
||||
mkdirSync(join(root, 'node'), { recursive: true })
|
||||
mkdirSync(join(root, 'git', 'cmd'), { recursive: true })
|
||||
writeFileSync(join(root, 'python', 'python.exe'), '')
|
||||
writeFileSync(join(root, 'python', 'Scripts', 'hermes.exe'), '')
|
||||
writeFileSync(join(root, 'python', 'Scripts', 'hermes.cmd'), '')
|
||||
writeFileSync(join(root, 'node', 'node.exe'), '')
|
||||
writeFileSync(join(root, 'git', 'cmd', 'git.exe'), '')
|
||||
} else {
|
||||
@@ -52,7 +53,7 @@ function createRuntimeWithoutManifest(root: string) {
|
||||
mkdirSync(join(root, 'node'), { recursive: true })
|
||||
mkdirSync(join(root, 'git', 'cmd'), { recursive: true })
|
||||
writeFileSync(join(root, 'python', 'python.exe'), '')
|
||||
writeFileSync(join(root, 'python', 'Scripts', 'hermes.exe'), '')
|
||||
writeFileSync(join(root, 'python', 'Scripts', 'hermes.cmd'), '')
|
||||
writeFileSync(join(root, 'node', 'node.exe'), '')
|
||||
writeFileSync(join(root, 'git', 'cmd', 'git.exe'), '')
|
||||
} else {
|
||||
@@ -64,6 +65,10 @@ function createRuntimeWithoutManifest(root: string) {
|
||||
}
|
||||
}
|
||||
|
||||
function setPlatform(platform: NodeJS.Platform): void {
|
||||
Object.defineProperty(process, 'platform', { value: platform })
|
||||
}
|
||||
|
||||
describe('desktop runtime paths', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules()
|
||||
@@ -77,6 +82,7 @@ describe('desktop runtime paths', () => {
|
||||
|
||||
afterEach(() => {
|
||||
process.env = { ...originalEnv }
|
||||
if (originalPlatform) Object.defineProperty(process, 'platform', originalPlatform)
|
||||
vi.resetModules()
|
||||
for (const dir of tempDirs.splice(0)) {
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
@@ -258,4 +264,21 @@ describe('desktop runtime paths', () => {
|
||||
expect(desktopRuntimeDir()).toBe(runtimeDir)
|
||||
expect(targetDesktopRuntimeDir()).toBe(targetRuntimeDir)
|
||||
})
|
||||
|
||||
it('treats hermes.cmd as the required Windows runtime entry point', async () => {
|
||||
setPlatform('win32')
|
||||
const homeDir = tempDir()
|
||||
process.env.HERMES_WEB_UI_HOME = homeDir
|
||||
mockElectronApp.isPackaged = true
|
||||
|
||||
const { runtimePlatformKey } = await import('../../packages/desktop/src/main/runtime-paths')
|
||||
const runtimeDir = join(homeDir, 'desktop-runtime', 'hermes', '0.15.2', runtimePlatformKey())
|
||||
createRuntime(runtimeDir, '0.15.2')
|
||||
|
||||
const { desktopRuntimeDir, hermesBin } = await import('../../packages/desktop/src/main/paths')
|
||||
|
||||
expect(desktopRuntimeDir()).toBe(runtimeDir)
|
||||
expect(hermesBin()).toBe(join(runtimeDir, 'python', 'Scripts', 'hermes.cmd'))
|
||||
expect(existsSync(join(runtimeDir, 'python', 'Scripts', 'hermes.exe'))).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -74,6 +74,28 @@ describe('Hermes process invocation', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('discovers sibling python.exe for a Windows hermes.cmd 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.cmd'), '')
|
||||
const { execHermesWithBin } = await import('../../packages/server/src/services/hermes/hermes-process')
|
||||
|
||||
await execHermesWithBin(join(scripts, 'hermes.cmd'), ['--version'])
|
||||
|
||||
expect(execFileCalls[0]).toMatchObject({
|
||||
command: join(root, 'python.exe'),
|
||||
args: ['-m', 'hermes_cli.main', '--version'],
|
||||
options: expect.objectContaining({ windowsHide: true }),
|
||||
})
|
||||
} 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')
|
||||
|
||||
Reference in New Issue
Block a user