mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-21 03:39:54 +00:00
c858484b45
The previous dependency, @homebridge/node-pty-prebuilt-multiarch@0.13.1, publishes no win32-arm64 prebuilds on its v0.13.x line, and its v0.14.x betas (which do add an arm64 Windows build) ship no electron-vXXX-win32- arm64 prebuilds at all -- so packaged Electron 40 builds (NMV 143) would fail at runtime even on a successful npm install. Net effect: the desktop's integrated terminal was unbuildable on Windows-on-ARM, in both dev (npm install fails: 404 fetching the node-vXXX-win32-arm64 prebuilt) and packaged builds (no Electron-ABI prebuilt exists). The homebridge fork was originally created because upstream node-pty shipped no prebuilds at all. That hasn't been true since node-pty@1.0 (April 2024), which: - bundles prebuilts for mac (arm64+x64) and Windows (arm64+x64) directly inside the npm tarball -- no GitHub-Releases fetch, no missing-binary failure mode - uses N-API (node-addon-api) for ABI stability across Node and Electron major versions, so the same pty.node binary loads under Node 22 (dev) and Electron 40+ (packaged) without per-ABI rebuilds - is what VS Code, Hyper, and Theia actually ship API surface is identical (spawn / onData / onExit / write / resize / kill) -- no call-site changes needed. Specifically: - apps/desktop/package.json: replace the @homebridge fork with node-pty@1.1.0 (exact pin). Widen `asarUnpack` from `["**/*.node"]` to also unpack `**/prebuilds/**`, because node-pty ships runtime- execed helpers alongside its .node files (darwin spawn-helper has no extension and would not be matched by `**/*.node`; conpty.dll, OpenConsole.exe, winpty.dll, winpty-agent.exe on Windows are also exec'd at runtime and cannot live inside asar). - apps/desktop/electron/main.cjs: update both require() strings to match the new package name and the new staged path under resources/native-deps/node-pty/. - apps/desktop/scripts/stage-native-deps.cjs: point at node_modules/ node-pty. node-pty's prebuilts live under prebuilds/<plat>-<arch>/ (not build/Release/), so update the include glob to copy that dir. Per-arch staging keeps the resource bundle small (target arch comes from npm_config_arch when electron-builder cross-builds, else process.arch). Explicitly enumerate file types in the prebuilds glob so the ~25 MB of .pdb debug symbols that prebuild-install bundles for Windows crash analysis don't bloat the installer (29 MB -> 2.6 MB staged on win32-arm64). Re-assert +x on the darwin spawn-helper defensively, since a stripped mode bit would manifest as a silent ENOENT at first pty.spawn(). - apps/desktop/scripts/test-desktop.mjs: update expectedNativeDepPaths() and its assertion site to look at prebuilds/<plat>-<arch>/ instead of build/Release/. Add an explicit spawn-helper-exists check on darwin so a regression in the asarUnpack glob would fail loudly in CI rather than at first PTY spawn. Trade-off: Linux end-users lose prebuilts and fall back to building node-pty from source on `npm install`. Acceptable because Hermes ships no Linux desktop builds (desktop-release.yml matrix is mac + win only, package.json declares no `linux` target), and Linux developers hacking on the desktop already need a C++ toolchain for the rest of the stack. Verified on Windows 11 ARM64 (Snapdragon): npm install -> exit 0 node -e "require('node-pty').spawn(...)" round-trip -> OK stage-native-deps -> 27 files, 2.6 MB load from staged tree (simulates packaged fallback) -> ConPTY round-trip OK
426 lines
14 KiB
JavaScript
426 lines
14 KiB
JavaScript
import fs from 'node:fs'
|
|
import os from 'node:os'
|
|
import path from 'node:path'
|
|
import { spawn, spawnSync } from 'node:child_process'
|
|
import { fileURLToPath } from 'node:url'
|
|
import { listPackage } from '@electron/asar'
|
|
|
|
const DESKTOP_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
|
|
const PACKAGE_JSON = JSON.parse(fs.readFileSync(path.join(DESKTOP_ROOT, 'package.json'), 'utf8'))
|
|
const MODE = process.argv[2] || 'help'
|
|
const ARCH = process.arch === 'arm64' ? 'arm64' : 'x64'
|
|
const RELEASE_ROOT = path.join(DESKTOP_ROOT, 'release')
|
|
const PLATFORM = process.platform
|
|
|
|
// Platform-specific packaged-app layout. The thin installer ships an Electron
|
|
// app shell plus extraResources (install-stamp.json + native-deps/) -- it
|
|
// no longer bundles the Hermes Agent Python payload (that's fetched at first
|
|
// launch via install.ps1 / install.sh, per the Phase 1 thin-installer flow).
|
|
const APP = (() => {
|
|
if (PLATFORM === 'darwin') {
|
|
const appPath = path.join(RELEASE_ROOT, `mac-${ARCH}`, 'Hermes.app')
|
|
return {
|
|
appPath,
|
|
binary: path.join(appPath, 'Contents', 'MacOS', 'Hermes'),
|
|
resourcesPath: path.join(appPath, 'Contents', 'Resources'),
|
|
asarPath: path.join(appPath, 'Contents', 'Resources', 'app.asar'),
|
|
unpackedDistIndex: path.join(appPath, 'Contents', 'Resources', 'app.asar.unpacked', 'dist', 'index.html')
|
|
}
|
|
}
|
|
if (PLATFORM === 'win32') {
|
|
const unpacked = path.join(RELEASE_ROOT, 'win-unpacked')
|
|
return {
|
|
appPath: unpacked,
|
|
binary: path.join(unpacked, 'Hermes.exe'),
|
|
resourcesPath: path.join(unpacked, 'resources'),
|
|
asarPath: path.join(unpacked, 'resources', 'app.asar'),
|
|
unpackedDistIndex: path.join(unpacked, 'resources', 'app.asar.unpacked', 'dist', 'index.html')
|
|
}
|
|
}
|
|
// linux unpacked layout matches windows but with different binary name
|
|
const unpacked = path.join(RELEASE_ROOT, 'linux-unpacked')
|
|
return {
|
|
appPath: unpacked,
|
|
binary: path.join(unpacked, 'hermes'),
|
|
resourcesPath: path.join(unpacked, 'resources'),
|
|
asarPath: path.join(unpacked, 'resources', 'app.asar'),
|
|
unpackedDistIndex: path.join(unpacked, 'resources', 'app.asar.unpacked', 'dist', 'index.html')
|
|
}
|
|
})()
|
|
|
|
// Default HERMES_HOME for non-sandboxed runs -- matches main.cjs's
|
|
// resolveHermesHome(). On Windows it's %LOCALAPPDATA%\hermes; elsewhere
|
|
// it's ~/.hermes. The fresh-install sandbox launchFresh() sets its own
|
|
// HERMES_HOME and never touches this.
|
|
const DEFAULT_HERMES_HOME = (() => {
|
|
if (PLATFORM === 'win32' && process.env.LOCALAPPDATA) {
|
|
return path.join(process.env.LOCALAPPDATA, 'hermes')
|
|
}
|
|
return path.join(os.homedir(), '.hermes')
|
|
})()
|
|
const VENV_ROOT = path.join(DEFAULT_HERMES_HOME, 'hermes-agent', 'venv')
|
|
const FRESH_SANDBOX_ROOT = path.join(os.tmpdir(), 'hermes-desktop-fresh-install')
|
|
|
|
function die(message) {
|
|
console.error(`\n${message}`)
|
|
process.exit(1)
|
|
}
|
|
|
|
function run(command, args, options = {}) {
|
|
const result = spawnSync(command, args, {
|
|
cwd: options.cwd || DESKTOP_ROOT,
|
|
env: options.env || process.env,
|
|
shell: Boolean(options.shell) || PLATFORM === 'win32',
|
|
stdio: 'inherit'
|
|
})
|
|
|
|
if (result.status !== 0) {
|
|
die(`${command} ${args.join(' ')} failed`)
|
|
}
|
|
}
|
|
|
|
function exists(target) {
|
|
return fs.existsSync(target)
|
|
}
|
|
|
|
// Match nodepty native binding location to what main.cjs's resolver fallback
|
|
// expects (apps/desktop/electron/main.cjs, packaged-build branch). Upstream
|
|
// node-pty 1.x is N-API based and ships per-arch prebuilts under
|
|
// prebuilds/<platform>-<arch>/ instead of build/Release/. We check the
|
|
// per-arch dir since that's what stage-native-deps actually copies.
|
|
function expectedNativeDepPaths() {
|
|
const root = path.join(APP.resourcesPath, 'native-deps', 'node-pty')
|
|
const prebuildsDir = path.join(root, 'prebuilds', `${PLATFORM}-${ARCH}`)
|
|
return {
|
|
packageJson: path.join(root, 'package.json'),
|
|
prebuildsDir,
|
|
libIndex: path.join(root, 'lib', 'index.js')
|
|
}
|
|
}
|
|
|
|
function ensurePlatformBuilds() {
|
|
if (PLATFORM === 'darwin') return
|
|
if (PLATFORM === 'win32') return
|
|
die(
|
|
`Desktop bundle validation is only wired for darwin / win32 today; platform=${PLATFORM} ` +
|
|
`is not yet supported. The thin-installer story for Linux ships in Phase 2 alongside ` +
|
|
`install.sh's stage protocol.`
|
|
)
|
|
}
|
|
|
|
function ensurePackagedApp() {
|
|
if (process.env.HERMES_DESKTOP_SKIP_BUILD === '1' && exists(APP.binary)) {
|
|
return
|
|
}
|
|
|
|
run('npm', ['run', 'pack'])
|
|
}
|
|
|
|
function resolveDmgPath() {
|
|
if (!exists(RELEASE_ROOT)) {
|
|
return path.join(RELEASE_ROOT, `Hermes-${PACKAGE_JSON.version}-${ARCH}.dmg`)
|
|
}
|
|
|
|
const prefix = `Hermes-${PACKAGE_JSON.version}`
|
|
const candidates = fs
|
|
.readdirSync(RELEASE_ROOT)
|
|
.filter(name => name.endsWith('.dmg'))
|
|
.filter(name => name.startsWith(prefix))
|
|
.filter(name => name.includes(ARCH))
|
|
.sort((a, b) => {
|
|
const aMtime = fs.statSync(path.join(RELEASE_ROOT, a)).mtimeMs
|
|
const bMtime = fs.statSync(path.join(RELEASE_ROOT, b)).mtimeMs
|
|
return bMtime - aMtime
|
|
})
|
|
|
|
return candidates.length > 0
|
|
? path.join(RELEASE_ROOT, candidates[0])
|
|
: path.join(RELEASE_ROOT, `Hermes-${PACKAGE_JSON.version}-${ARCH}.dmg`)
|
|
}
|
|
|
|
function resolveNsisPath() {
|
|
// electron-builder NSIS artifactName template is 'Hermes-${version}-${os}-${arch}.${ext}'
|
|
if (!exists(RELEASE_ROOT)) return null
|
|
const candidates = fs
|
|
.readdirSync(RELEASE_ROOT)
|
|
.filter(name => /\.exe$/i.test(name) && /win/i.test(name))
|
|
.sort((a, b) => {
|
|
const aMtime = fs.statSync(path.join(RELEASE_ROOT, a)).mtimeMs
|
|
const bMtime = fs.statSync(path.join(RELEASE_ROOT, b)).mtimeMs
|
|
return bMtime - aMtime
|
|
})
|
|
return candidates.length > 0 ? path.join(RELEASE_ROOT, candidates[0]) : null
|
|
}
|
|
|
|
function ensureDmg() {
|
|
if (PLATFORM !== 'darwin') {
|
|
die('DMG mode is macOS-only; on Windows use the `nsis` mode instead.')
|
|
}
|
|
if (process.env.HERMES_DESKTOP_SKIP_BUILD === '1' && exists(resolveDmgPath())) {
|
|
return
|
|
}
|
|
run('npm', ['run', 'dist:mac:dmg'])
|
|
}
|
|
|
|
function ensureNsis() {
|
|
if (PLATFORM !== 'win32') {
|
|
die('NSIS mode is win32-only; on macOS use the `dmg` mode instead.')
|
|
}
|
|
if (process.env.HERMES_DESKTOP_SKIP_BUILD === '1' && resolveNsisPath()) {
|
|
return
|
|
}
|
|
run('npm', ['run', 'dist:win:nsis'])
|
|
}
|
|
|
|
function openApp() {
|
|
if (!exists(APP.binary)) {
|
|
die(`Missing packaged app: ${APP.binary}`)
|
|
}
|
|
|
|
if (PLATFORM === 'darwin') {
|
|
run('open', ['-n', APP.appPath])
|
|
} else if (PLATFORM === 'win32') {
|
|
// Spawn detached so the test script exits while the app keeps running.
|
|
spawn(APP.binary, [], { detached: true, stdio: 'ignore' }).unref()
|
|
} else {
|
|
spawn(APP.binary, [], { detached: true, stdio: 'ignore' }).unref()
|
|
}
|
|
}
|
|
|
|
function openDmg() {
|
|
if (PLATFORM !== 'darwin') {
|
|
die('DMG mode is macOS-only.')
|
|
}
|
|
const dmgPath = resolveDmgPath()
|
|
if (!exists(dmgPath)) {
|
|
die(`Missing DMG: ${dmgPath}`)
|
|
}
|
|
run('open', [dmgPath])
|
|
}
|
|
|
|
const CREDENTIAL_ENV_SUFFIXES = [
|
|
'_API_KEY',
|
|
'_TOKEN',
|
|
'_SECRET',
|
|
'_PASSWORD',
|
|
'_CREDENTIALS',
|
|
'_ACCESS_KEY',
|
|
'_PRIVATE_KEY',
|
|
'_OAUTH_TOKEN'
|
|
]
|
|
|
|
const CREDENTIAL_ENV_NAMES = new Set([
|
|
'ANTHROPIC_BASE_URL',
|
|
'ANTHROPIC_TOKEN',
|
|
'AWS_ACCESS_KEY_ID',
|
|
'AWS_SECRET_ACCESS_KEY',
|
|
'AWS_SESSION_TOKEN',
|
|
'CUSTOM_API_KEY',
|
|
'GEMINI_BASE_URL',
|
|
'OPENAI_BASE_URL',
|
|
'OPENROUTER_BASE_URL',
|
|
'OLLAMA_BASE_URL',
|
|
'GROQ_BASE_URL',
|
|
'XAI_BASE_URL'
|
|
])
|
|
|
|
function isCredentialEnvVar(name) {
|
|
if (CREDENTIAL_ENV_NAMES.has(name)) return true
|
|
return CREDENTIAL_ENV_SUFFIXES.some(suffix => name.endsWith(suffix))
|
|
}
|
|
|
|
function launchFresh() {
|
|
if (!exists(APP.binary)) {
|
|
die(`Missing app executable: ${APP.binary}`)
|
|
}
|
|
|
|
const sandbox = fs.mkdtempSync(`${FRESH_SANDBOX_ROOT}-`)
|
|
const userDataDir = path.join(sandbox, 'electron-user-data')
|
|
const hermesHome = path.join(sandbox, 'hermes-home')
|
|
const cwd = path.join(sandbox, 'workspace')
|
|
|
|
fs.mkdirSync(userDataDir, { recursive: true })
|
|
fs.mkdirSync(hermesHome, { recursive: true })
|
|
fs.mkdirSync(cwd, { recursive: true })
|
|
|
|
// Strip every credential-shaped env var so the sandbox is actually fresh.
|
|
const env = {}
|
|
for (const [key, value] of Object.entries(process.env)) {
|
|
if (isCredentialEnvVar(key)) continue
|
|
env[key] = value
|
|
}
|
|
|
|
env.HERMES_DESKTOP_CWD = cwd
|
|
env.HERMES_DESKTOP_IGNORE_EXISTING = '1'
|
|
env.HERMES_DESKTOP_TEST_MODE = 'fresh-install'
|
|
env.HERMES_DESKTOP_USER_DATA_DIR = userDataDir
|
|
env.HERMES_HOME = hermesHome
|
|
delete env.HERMES_DESKTOP_HERMES
|
|
delete env.HERMES_DESKTOP_HERMES_ROOT
|
|
|
|
const child = spawn(APP.binary, [], {
|
|
cwd: os.homedir(),
|
|
detached: true,
|
|
env,
|
|
stdio: 'ignore'
|
|
})
|
|
child.unref()
|
|
|
|
console.log('\nFresh install sandbox:')
|
|
console.log(` root: ${sandbox}`)
|
|
console.log(` electron userData: ${userDataDir}`)
|
|
console.log(` HERMES_HOME: ${hermesHome}`)
|
|
console.log(` cwd: ${cwd}`)
|
|
|
|
return { runtimeRoot: path.join(hermesHome, 'hermes-agent', 'venv') }
|
|
}
|
|
|
|
// Validate the packaged bundle matches the thin-installer architecture:
|
|
// - The Hermes Agent Python payload is NOT shipped (it's fetched at first
|
|
// launch via install.ps1's stage protocol).
|
|
// - install-stamp.json IS shipped in resources/ with a valid commit + branch.
|
|
// - native-deps/@homebridge/node-pty-prebuilt-multiarch/ IS shipped with
|
|
// the package.json + lib/ + at least one .node binary (the renderer's
|
|
// integrated terminal needs this; see Phase 1F.6).
|
|
// - The renderer's dist/index.html is reachable (either unpacked or
|
|
// inside app.asar).
|
|
function validateBundle() {
|
|
if (!exists(APP.binary)) {
|
|
die(`Missing packaged app binary: ${APP.binary}`)
|
|
}
|
|
|
|
// Negative assertion: the OLD fat-installer factory payload must NOT be
|
|
// present anymore. If a stray ship of hermes_cli sneaks back in we want
|
|
// to fail loudly rather than re-introduce the 400MB delta we just removed.
|
|
const staleFactoryMarker = path.join(APP.resourcesPath, 'hermes-agent', 'hermes_cli', 'main.py')
|
|
if (exists(staleFactoryMarker)) {
|
|
die(
|
|
`Thin-installer regression: factory-payload file should NOT be in the package: ${staleFactoryMarker}`
|
|
)
|
|
}
|
|
|
|
// Positive assertion: install-stamp.json carries a sane commit + branch
|
|
const stampPath = path.join(APP.resourcesPath, 'install-stamp.json')
|
|
if (!exists(stampPath)) {
|
|
die(`Missing install-stamp.json (required for first-launch bootstrap pinning): ${stampPath}`)
|
|
}
|
|
let stamp
|
|
try {
|
|
stamp = JSON.parse(fs.readFileSync(stampPath, 'utf8'))
|
|
} catch (err) {
|
|
die(`install-stamp.json is not valid JSON: ${err.message}`)
|
|
}
|
|
if (!stamp.commit || typeof stamp.commit !== 'string' || stamp.commit.length < 7) {
|
|
die(`install-stamp.json is missing a usable commit field: ${JSON.stringify(stamp)}`)
|
|
}
|
|
if (!stamp.branch || typeof stamp.branch !== 'string') {
|
|
die(`install-stamp.json is missing the branch field: ${JSON.stringify(stamp)}`)
|
|
}
|
|
|
|
// Positive assertion: node-pty native deps shipped
|
|
const native = expectedNativeDepPaths()
|
|
if (!exists(native.packageJson)) {
|
|
die(`Missing node-pty package.json in resources/native-deps: ${native.packageJson}`)
|
|
}
|
|
if (!exists(native.libIndex)) {
|
|
die(`Missing node-pty lib/index.js in resources/native-deps: ${native.libIndex}`)
|
|
}
|
|
if (!exists(native.prebuildsDir)) {
|
|
die(`Missing node-pty prebuilds dir for ${PLATFORM}-${ARCH}: ${native.prebuildsDir}`)
|
|
}
|
|
const nodeBinaries = fs.readdirSync(native.prebuildsDir).filter(name => name.endsWith('.node'))
|
|
if (nodeBinaries.length === 0) {
|
|
die(`No .node native binaries found in: ${native.prebuildsDir}`)
|
|
}
|
|
// Darwin requires a runtime-execed spawn-helper alongside pty.node; missing
|
|
// it manifests as "ENOENT: spawn-helper" on first pty.spawn() call.
|
|
if (PLATFORM === 'darwin') {
|
|
const spawnHelper = path.join(native.prebuildsDir, 'spawn-helper')
|
|
if (!exists(spawnHelper)) {
|
|
die(`Missing node-pty spawn-helper (required on darwin): ${spawnHelper}`)
|
|
}
|
|
}
|
|
|
|
// Renderer payload check (either unpacked or in the asar)
|
|
if (exists(APP.unpackedDistIndex)) {
|
|
return { stamp, nodeBinaries }
|
|
}
|
|
if (!exists(APP.asarPath)) {
|
|
die(`Missing renderer payload: neither ${APP.unpackedDistIndex} nor ${APP.asarPath} exists`)
|
|
}
|
|
const files = listPackage(APP.asarPath)
|
|
// Normalize separators because @electron/asar's listPackage returns
|
|
// backslash-prefixed entries on Windows ('\\dist\\index.html') and
|
|
// forward-slash on Unix.
|
|
const normalized = files.map(f => f.replace(/\\/g, '/').replace(/^\/+/, ''))
|
|
if (!normalized.includes('dist/index.html')) {
|
|
die(`Missing renderer payload file in app.asar: ${APP.asarPath} (expected dist/index.html)`)
|
|
}
|
|
return { stamp, nodeBinaries }
|
|
}
|
|
|
|
function printArtifacts(options = {}) {
|
|
const runtimeRoot = options.runtimeRoot || VENV_ROOT
|
|
const stamp = options.stamp
|
|
|
|
console.log('\nDesktop artifacts:')
|
|
console.log(` app: ${APP.appPath}`)
|
|
if (PLATFORM === 'darwin') {
|
|
console.log(` dmg: ${resolveDmgPath()}`)
|
|
} else if (PLATFORM === 'win32') {
|
|
const exe = resolveNsisPath()
|
|
if (exe) console.log(` installer: ${exe}`)
|
|
}
|
|
console.log(` runtime: ${runtimeRoot}`)
|
|
if (stamp) {
|
|
console.log(` install-stamp: ${stamp.commit.slice(0, 12)} on ${stamp.branch}`)
|
|
}
|
|
if (options.nodeBinaries && options.nodeBinaries.length > 0) {
|
|
console.log(` node-pty binaries: ${options.nodeBinaries.join(', ')}`)
|
|
}
|
|
}
|
|
|
|
function help() {
|
|
console.log(`Usage:
|
|
npm run test:desktop:existing # build packaged app, launch with normal PATH/existing Hermes
|
|
npm run test:desktop:fresh # build packaged app, launch with temp userData + HERMES_HOME
|
|
npm run test:desktop:dmg # (macOS only) build DMG and open it
|
|
npm run test:desktop:nsis # (win32 only) build NSIS installer
|
|
npm run test:desktop:all # build installer, validate app payload, print paths
|
|
|
|
Fast rerun (skip rebuild if the packaged app already exists):
|
|
HERMES_DESKTOP_SKIP_BUILD=1 npm run test:desktop:fresh
|
|
`)
|
|
}
|
|
|
|
ensurePlatformBuilds()
|
|
|
|
if (MODE === 'existing') {
|
|
ensurePackagedApp()
|
|
const result = validateBundle()
|
|
openApp()
|
|
printArtifacts(result)
|
|
} else if (MODE === 'fresh') {
|
|
ensurePackagedApp()
|
|
const result = validateBundle()
|
|
printArtifacts({ ...launchFresh(), ...result })
|
|
} else if (MODE === 'dmg') {
|
|
ensureDmg()
|
|
openDmg()
|
|
printArtifacts()
|
|
} else if (MODE === 'nsis') {
|
|
ensureNsis()
|
|
printArtifacts(validateBundle())
|
|
} else if (MODE === 'all') {
|
|
if (PLATFORM === 'darwin') {
|
|
ensureDmg()
|
|
} else if (PLATFORM === 'win32') {
|
|
ensureNsis()
|
|
} else {
|
|
ensurePackagedApp()
|
|
}
|
|
printArtifacts(validateBundle())
|
|
} else {
|
|
help()
|
|
}
|