Files
Hermes-ui/packages/desktop/scripts/install-hermes.mjs
T
sir1st ce04b10eee 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>
2026-05-30 18:57:04 +08:00

147 lines
5.1 KiB
JavaScript

#!/usr/bin/env node
// Install hermes-agent into the bundled Python at resources/python/<os>-<arch>/.
// Prefers `uv` (10-100x faster, more deterministic) and falls back to pip.
import { existsSync } from 'node:fs'
import { resolve, dirname } from 'node:path'
import { fileURLToPath } from 'node:url'
import { spawnSync } from 'node:child_process'
import { platform as osPlatform, arch as osArch } from 'node:os'
const __dirname = dirname(fileURLToPath(import.meta.url))
const ROOT = resolve(__dirname, '..')
const TARGET_OS = process.env.TARGET_OS || osPlatform()
const TARGET_ARCH = process.env.TARGET_ARCH || osArch()
const HERMES_VERSION = process.env.HERMES_VERSION || '0.15.2'
const HERMES_PACKAGE = process.env.HERMES_PACKAGE || `hermes-agent[mcp]==${HERMES_VERSION}`
const OS_LABEL = TARGET_OS === 'win32' ? 'win' : TARGET_OS === 'darwin' ? 'mac' : TARGET_OS
const PY_DIR = resolve(ROOT, 'resources', 'python', `${OS_LABEL}-${TARGET_ARCH}`)
const pyBin = TARGET_OS === 'win32'
? resolve(PY_DIR, 'python.exe')
: resolve(PY_DIR, 'bin', 'python3')
if (!existsSync(pyBin)) {
console.error(`Python not found at ${pyBin}. Run: npm run fetch:python`)
process.exit(1)
}
function hasUv() {
const r = spawnSync('uv', ['--version'], { stdio: 'ignore' })
return r.status === 0
}
let r
if (hasUv()) {
console.log(`→ Installing ${HERMES_PACKAGE} via uv`)
r = spawnSync('uv', [
'pip', 'install',
'--python', pyBin,
HERMES_PACKAGE,
], { stdio: 'inherit' })
} else {
console.log(`→ Installing ${HERMES_PACKAGE} via pip`)
r = spawnSync(pyBin, [
'-m', 'pip', 'install',
HERMES_PACKAGE,
'--no-warn-script-location',
'--disable-pip-version-check',
], { stdio: 'inherit' })
}
if (r.status !== 0) process.exit(r.status ?? 1)
r = spawnSync(pyBin, [
'-c',
'import mcp; import tools.mcp_tool as t; assert t._MCP_AVAILABLE',
], { stdio: 'inherit' })
if (r.status !== 0) {
console.error('MCP Python SDK sanity check failed')
process.exit(r.status ?? 1)
}
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`)
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
// into the packaged .app/.exe (an absolute symlink would break the moment the bundle
// is moved to /Applications/...).
const { readdirSync, symlinkSync, copyFileSync, unlinkSync, lstatSync } = await import('node:fs')
function siteRunAgentRelative() {
if (TARGET_OS === 'win32') {
return ['Lib', 'site-packages', 'run_agent.py'].join('\\')
}
const libDir = resolve(PY_DIR, 'lib')
const py = readdirSync(libDir).find(n => /^python\d+\.\d+$/.test(n))
return ['lib', py, 'site-packages', 'run_agent.py'].join('/')
}
{
const relSrc = siteRunAgentRelative()
const absSrc = resolve(PY_DIR, relSrc)
const dst = resolve(PY_DIR, 'run_agent.py')
if (existsSync(absSrc)) {
try { lstatSync(dst); unlinkSync(dst) } catch {}
if (TARGET_OS === 'win32') copyFileSync(absSrc, dst)
else symlinkSync(relSrc, dst)
console.log(`✓ run_agent.py linked at venv root (relative → ${relSrc})`)
} else {
console.warn(`! run_agent.py not found at ${absSrc} — agent-bridge may fail`)
}
}
// Relocate: replace the pip-generated launcher (which embeds an absolute
// shebang to the build-time Python path) with a relative wrapper so the
// bundled venv works after being moved into the .app/.exe payload.
const { writeFileSync, chmodSync } = await import('node:fs')
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'),
)
} else {
const launcher = [
'#!/bin/sh',
'DIR="$(cd "$(dirname "$0")" && pwd)"',
'exec "$DIR/python3" -m hermes_cli.main "$@"',
'',
].join('\n')
writeFileSync(hermesBin, launcher, { mode: 0o755 })
chmodSync(hermesBin, 0o755)
// Same for hermes-agent / hermes-acp (they all just dispatch into modules)
for (const [name, mod] of [
['hermes-agent', 'run_agent'],
['hermes-acp', 'acp_adapter.entry'],
]) {
const p = resolve(PY_DIR, 'bin', name)
if (existsSync(p)) {
writeFileSync(p, launcher.replace('hermes_cli.main', mod), { mode: 0o755 })
chmodSync(p, 0o755)
}
}
}
console.log(`✓ hermes installed at ${hermesBin} (relocatable launcher)`)
r = spawnSync(hermesCheckCommand, hermesCheckArgs, { stdio: 'inherit' })
if (r.status !== 0) {
console.error('hermes --version failed')
process.exit(r.status ?? 1)
}