add hermes studio cli shim (#1209)
This commit is contained in:
@@ -0,0 +1 @@
|
||||
export const HERMES_CLI_ARG = '--hermes-cli'
|
||||
@@ -0,0 +1,232 @@
|
||||
import { execFile } from 'node:child_process'
|
||||
import {
|
||||
appendFileSync,
|
||||
chmodSync,
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
readFileSync,
|
||||
writeFileSync,
|
||||
} from 'node:fs'
|
||||
import { homedir } from 'node:os'
|
||||
import { delimiter, dirname, join, resolve } from 'node:path'
|
||||
import { promisify } from 'node:util'
|
||||
import { HERMES_CLI_ARG } from './cli-constants'
|
||||
|
||||
const execFileAsync = promisify(execFile)
|
||||
|
||||
const SHIM_MARKER = 'HERMES_STUDIO_CLI_SHIM'
|
||||
const PATH_MARKER_START = '# >>> Hermes Studio CLI shim >>>'
|
||||
const PATH_MARKER_END = '# <<< Hermes Studio CLI shim <<<'
|
||||
|
||||
type ShimInstallStatus = 'installed' | 'updated' | 'unchanged' | 'skipped'
|
||||
|
||||
export interface CliShimInstallResult {
|
||||
shimPath: string
|
||||
status: ShimInstallStatus
|
||||
pathUpdated: boolean
|
||||
reason?: string
|
||||
}
|
||||
|
||||
interface CliShimInstallOptions {
|
||||
env?: NodeJS.ProcessEnv
|
||||
executablePath?: string
|
||||
homeDir?: string
|
||||
platform?: NodeJS.Platform
|
||||
}
|
||||
|
||||
function platformDelimiter(platform: NodeJS.Platform): string {
|
||||
return platform === 'win32' ? ';' : delimiter
|
||||
}
|
||||
|
||||
function pathKey(value: string, platform: NodeJS.Platform): string {
|
||||
const normalized = resolve(value)
|
||||
return platform === 'win32' ? normalized.toLowerCase() : normalized
|
||||
}
|
||||
|
||||
export function pathContainsDir(pathValue: string | undefined, binDir: string, platform: NodeJS.Platform = process.platform): boolean {
|
||||
if (!pathValue) return false
|
||||
const target = pathKey(binDir, platform)
|
||||
return pathValue
|
||||
.split(platformDelimiter(platform))
|
||||
.map(entry => entry.trim())
|
||||
.filter(Boolean)
|
||||
.some(entry => pathKey(entry, platform) === target)
|
||||
}
|
||||
|
||||
function executableForShim(options: Required<Pick<CliShimInstallOptions, 'env' | 'executablePath' | 'platform'>>): string {
|
||||
const appImage = options.platform === 'linux' ? options.env.APPIMAGE?.trim() : ''
|
||||
return appImage || options.executablePath
|
||||
}
|
||||
|
||||
export function shimPathForPlatform(binDir: string, platform: NodeJS.Platform = process.platform): string {
|
||||
return join(binDir, platform === 'win32' ? 'hermes-studio.cmd' : 'hermes-studio')
|
||||
}
|
||||
|
||||
function shellQuote(value: string): string {
|
||||
return `'${value.replace(/'/g, `'\\''`)}'`
|
||||
}
|
||||
|
||||
export function createShimContent(executablePath: string, platform: NodeJS.Platform = process.platform): string {
|
||||
if (platform === 'win32') {
|
||||
return [
|
||||
'@echo off',
|
||||
`rem ${SHIM_MARKER}`,
|
||||
`set "APP=${executablePath}"`,
|
||||
'if not exist "%APP%" (',
|
||||
' echo Hermes Studio executable not found at "%APP%" 1>&2',
|
||||
' exit /b 127',
|
||||
')',
|
||||
'set ELECTRON_RUN_AS_NODE=',
|
||||
`"${'%APP%'}" -- ${HERMES_CLI_ARG} %*`,
|
||||
'exit /b %ERRORLEVEL%',
|
||||
'',
|
||||
].join('\r\n')
|
||||
}
|
||||
|
||||
return [
|
||||
'#!/bin/sh',
|
||||
`# ${SHIM_MARKER}`,
|
||||
`APP=${shellQuote(executablePath)}`,
|
||||
'if [ ! -x "$APP" ]; then',
|
||||
' echo "Hermes Studio executable not found at $APP" >&2',
|
||||
' exit 127',
|
||||
'fi',
|
||||
'unset ELECTRON_RUN_AS_NODE',
|
||||
`exec "$APP" -- ${HERMES_CLI_ARG} "$@"`,
|
||||
'',
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
function isManagedShim(content: string): boolean {
|
||||
return content.includes(SHIM_MARKER) && content.includes(HERMES_CLI_ARG)
|
||||
}
|
||||
|
||||
function writeShim(shimPath: string, content: string, platform: NodeJS.Platform): ShimInstallStatus {
|
||||
if (existsSync(shimPath)) {
|
||||
const existing = readFileSync(shimPath, 'utf-8')
|
||||
if (existing === content) return 'unchanged'
|
||||
if (!isManagedShim(existing)) return 'skipped'
|
||||
writeFileSync(shimPath, content, 'utf-8')
|
||||
if (platform !== 'win32') chmodSync(shimPath, 0o755)
|
||||
return 'updated'
|
||||
}
|
||||
|
||||
writeFileSync(shimPath, content, { encoding: 'utf-8', mode: platform === 'win32' ? 0o644 : 0o755 })
|
||||
if (platform !== 'win32') chmodSync(shimPath, 0o755)
|
||||
return 'installed'
|
||||
}
|
||||
|
||||
function shellProfilePaths(homeDir: string, platform: NodeJS.Platform, env: NodeJS.ProcessEnv): string[] {
|
||||
if (platform === 'win32') return []
|
||||
|
||||
const shell = env.SHELL?.trim() || ''
|
||||
const name = shell.split('/').pop() || ''
|
||||
if (name === 'fish') return [join(homeDir, '.config', 'fish', 'conf.d', 'hermes-studio.fish')]
|
||||
if (name === 'bash') return [join(homeDir, '.bash_profile'), join(homeDir, '.bashrc')]
|
||||
if (name === 'zsh' || platform === 'darwin') return [join(homeDir, '.zprofile'), join(homeDir, '.zshrc')]
|
||||
return [join(homeDir, '.profile')]
|
||||
}
|
||||
|
||||
function profileMentionsUserBin(content: string, homeDir: string): boolean {
|
||||
return content.includes('$HOME/bin')
|
||||
|| content.includes('~/bin')
|
||||
|| content.includes(resolve(homeDir, 'bin'))
|
||||
}
|
||||
|
||||
function shellPathSnippet(platform: NodeJS.Platform, profilePath: string): string {
|
||||
if (platform !== 'win32' && profilePath.endsWith('.fish')) {
|
||||
return [
|
||||
'',
|
||||
PATH_MARKER_START,
|
||||
'fish_add_path -m "$HOME/bin"',
|
||||
PATH_MARKER_END,
|
||||
'',
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
return [
|
||||
'',
|
||||
PATH_MARKER_START,
|
||||
'case ":$PATH:" in',
|
||||
' *":$HOME/bin:"*) ;;',
|
||||
' *) export PATH="$HOME/bin:$PATH" ;;',
|
||||
'esac',
|
||||
PATH_MARKER_END,
|
||||
'',
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
async function ensureWindowsUserPath(binDir: string): Promise<boolean> {
|
||||
let currentPath = ''
|
||||
try {
|
||||
const { stdout } = await execFileAsync('reg.exe', ['query', 'HKCU\\Environment', '/v', 'Path'], {
|
||||
encoding: 'utf-8',
|
||||
timeout: 1500,
|
||||
windowsHide: true,
|
||||
})
|
||||
const line = stdout.split(/\r?\n/).find(row => /^\s*Path\s+REG_/.test(row))
|
||||
if (line) currentPath = line.replace(/^\s*Path\s+REG_\w+\s+/, '').trim()
|
||||
} catch {
|
||||
currentPath = process.env.Path || process.env.PATH || ''
|
||||
}
|
||||
|
||||
if (pathContainsDir(currentPath, binDir, 'win32')) return false
|
||||
|
||||
const separator = currentPath ? ';' : ''
|
||||
await execFileAsync('reg.exe', ['add', 'HKCU\\Environment', '/v', 'Path', '/t', 'REG_EXPAND_SZ', '/d', `${binDir}${separator}${currentPath}`, '/f'], {
|
||||
encoding: 'utf-8',
|
||||
timeout: 1500,
|
||||
windowsHide: true,
|
||||
})
|
||||
return true
|
||||
}
|
||||
|
||||
function ensureUnixShellPath(homeDir: string, binDir: string, platform: NodeJS.Platform, env: NodeJS.ProcessEnv): boolean {
|
||||
if (pathContainsDir(env.PATH, binDir, platform)) return false
|
||||
|
||||
let updated = false
|
||||
for (const profilePath of shellProfilePaths(homeDir, platform, env)) {
|
||||
const existing = existsSync(profilePath) ? readFileSync(profilePath, 'utf-8') : ''
|
||||
if (existing.includes(PATH_MARKER_START) || profileMentionsUserBin(existing, homeDir)) continue
|
||||
|
||||
mkdirSync(dirname(profilePath), { recursive: true })
|
||||
appendFileSync(profilePath, shellPathSnippet(platform, profilePath), 'utf-8')
|
||||
updated = true
|
||||
break
|
||||
}
|
||||
return updated
|
||||
}
|
||||
|
||||
async function ensureUserBinOnPath(homeDir: string, binDir: string, platform: NodeJS.Platform, env: NodeJS.ProcessEnv): Promise<boolean> {
|
||||
if (platform === 'win32') {
|
||||
return await ensureWindowsUserPath(binDir)
|
||||
}
|
||||
return ensureUnixShellPath(homeDir, binDir, platform, env)
|
||||
}
|
||||
|
||||
export async function installHermesStudioCliShim(options: CliShimInstallOptions = {}): Promise<CliShimInstallResult> {
|
||||
const platform = options.platform || process.platform
|
||||
const env = options.env || process.env
|
||||
const homeDir = options.homeDir || homedir()
|
||||
const binDir = resolve(homeDir, 'bin')
|
||||
const executablePath = executableForShim({
|
||||
env,
|
||||
executablePath: options.executablePath || process.execPath,
|
||||
platform,
|
||||
})
|
||||
const shimPath = shimPathForPlatform(binDir, platform)
|
||||
|
||||
mkdirSync(binDir, { recursive: true })
|
||||
const status = writeShim(shimPath, createShimContent(executablePath, platform), platform)
|
||||
const pathUpdated = await ensureUserBinOnPath(homeDir, binDir, platform, env).catch((err) => {
|
||||
console.warn(`[cli-shim] failed to update PATH: ${err instanceof Error ? err.message : String(err)}`)
|
||||
return false
|
||||
})
|
||||
|
||||
return {
|
||||
shimPath,
|
||||
status,
|
||||
pathUpdated,
|
||||
reason: status === 'skipped' ? 'existing hermes-studio shim is not managed by Hermes Studio' : undefined,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { spawn } from 'node:child_process'
|
||||
import { existsSync, mkdirSync } from 'node:fs'
|
||||
import { delimiter, dirname } from 'node:path'
|
||||
import { bundledPython, hermesBin, hermesHome, pythonDir, webUiHome } from './paths'
|
||||
import { HERMES_CLI_ARG } from './cli-constants'
|
||||
|
||||
export function parseHermesCliArgs(argv: string[] = process.argv): string[] | null {
|
||||
const index = argv.indexOf(HERMES_CLI_ARG)
|
||||
if (index < 0) return null
|
||||
return argv.slice(index + 1)
|
||||
}
|
||||
|
||||
export async function runBundledHermesCli(args: string[]): Promise<number> {
|
||||
const command = hermesBin()
|
||||
if (!existsSync(command)) {
|
||||
console.error(`hermes binary missing at ${command}`)
|
||||
console.error('Run: npm run prepare:python (to bundle Python + hermes-agent)')
|
||||
return 127
|
||||
}
|
||||
|
||||
mkdirSync(webUiHome(), { recursive: true })
|
||||
mkdirSync(hermesHome(), { recursive: true })
|
||||
|
||||
const binDir = dirname(command)
|
||||
const pathValue = process.env.PATH ? `${binDir}${delimiter}${process.env.PATH}` : binDir
|
||||
const env: NodeJS.ProcessEnv = {
|
||||
...process.env,
|
||||
HERMES_DESKTOP: 'true',
|
||||
HERMES_BIN: command,
|
||||
HERMES_AGENT_BRIDGE_PYTHON: bundledPython(),
|
||||
HERMES_AGENT_CLI_PYTHON: bundledPython(),
|
||||
HERMES_AGENT_ROOT: pythonDir(),
|
||||
HERMES_HOME: hermesHome(),
|
||||
HERMES_WEB_UI_HOME: webUiHome(),
|
||||
HERMES_WEBUI_STATE_DIR: webUiHome(),
|
||||
PATH: pathValue,
|
||||
}
|
||||
|
||||
return await new Promise(resolve => {
|
||||
const child = spawn(command, args, {
|
||||
env,
|
||||
stdio: 'inherit',
|
||||
windowsHide: false,
|
||||
})
|
||||
child.once('error', (err) => {
|
||||
console.error(`Failed to run bundled Hermes CLI: ${err.message}`)
|
||||
resolve(1)
|
||||
})
|
||||
child.once('exit', (code, signal) => {
|
||||
if (typeof code === 'number') {
|
||||
resolve(code)
|
||||
return
|
||||
}
|
||||
console.error(`Bundled Hermes CLI exited from signal ${signal || 'unknown'}`)
|
||||
resolve(1)
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -4,6 +4,8 @@ import { startWebUiServer, stopWebUiServer, getToken } from './webui-server'
|
||||
import { desktopIcon, desktopTrayTemplateIcon, desktopWindowsTrayIcon, hermesBinExists, hermesBin } from './paths'
|
||||
import { checkForDesktopUpdates, initAutoUpdater } from './updater'
|
||||
import { t } from './desktop-i18n'
|
||||
import { installHermesStudioCliShim } from './cli-shim'
|
||||
import { parseHermesCliArgs, runBundledHermesCli } from './hermes-cli'
|
||||
|
||||
const PORT = Number(process.env.HERMES_DESKTOP_PORT) || 8748
|
||||
const START_HIDDEN = process.argv.includes('--hidden')
|
||||
@@ -206,10 +208,13 @@ async function bootstrap() {
|
||||
|
||||
ipcMain.handle('hermes-desktop:get-token', () => getToken())
|
||||
|
||||
const gotLock = app.requestSingleInstanceLock()
|
||||
if (!gotLock) {
|
||||
function runDesktopApp() {
|
||||
const gotLock = app.requestSingleInstanceLock()
|
||||
if (!gotLock) {
|
||||
app.quit()
|
||||
} else {
|
||||
return
|
||||
}
|
||||
|
||||
app.on('second-instance', (_event, argv) => {
|
||||
if (argv.includes('--quit')) {
|
||||
quitApp()
|
||||
@@ -229,6 +234,15 @@ if (!gotLock) {
|
||||
// visual clutter. macOS keeps a menu (system requirement) but Electron's
|
||||
// default is fine there.
|
||||
if (process.platform !== 'darwin') Menu.setApplicationMenu(null)
|
||||
if (app.isPackaged) {
|
||||
installHermesStudioCliShim().then(result => {
|
||||
if (result.status === 'skipped') {
|
||||
console.warn(`[cli-shim] ${result.reason}: ${result.shimPath}`)
|
||||
}
|
||||
}).catch(err => {
|
||||
console.warn(`[cli-shim] failed to install hermes-studio command: ${err instanceof Error ? err.message : String(err)}`)
|
||||
})
|
||||
}
|
||||
createTray()
|
||||
createWindow()
|
||||
bootstrap()
|
||||
@@ -262,3 +276,15 @@ if (!gotLock) {
|
||||
app.exit(0)
|
||||
})
|
||||
}
|
||||
|
||||
const hermesCliArgs = parseHermesCliArgs(process.argv)
|
||||
if (hermesCliArgs) {
|
||||
runBundledHermesCli(hermesCliArgs)
|
||||
.then(code => app.exit(code))
|
||||
.catch(err => {
|
||||
console.error(`Failed to run bundled Hermes CLI: ${err instanceof Error ? err.message : String(err)}`)
|
||||
app.exit(1)
|
||||
})
|
||||
} else {
|
||||
runDesktopApp()
|
||||
}
|
||||
|
||||
@@ -31,9 +31,18 @@ export function pythonDir(): string {
|
||||
return resolve(app.getAppPath(), 'resources', 'python', `${osLabel}-${archLabel}`)
|
||||
}
|
||||
|
||||
export function hermesBin(): string {
|
||||
export function pythonBinDir(): string {
|
||||
const dir = pythonDir()
|
||||
return isWin ? join(dir, 'Scripts', 'hermes.exe') : join(dir, 'bin', 'hermes')
|
||||
return isWin ? join(dir, 'Scripts') : join(dir, 'bin')
|
||||
}
|
||||
|
||||
export function bundledPython(): string {
|
||||
const dir = pythonDir()
|
||||
return isWin ? join(dir, 'python.exe') : join(dir, 'bin', 'python3')
|
||||
}
|
||||
|
||||
export function hermesBin(): string {
|
||||
return isWin ? join(pythonBinDir(), 'hermes.exe') : join(pythonBinDir(), 'hermes')
|
||||
}
|
||||
|
||||
export function hermesBinExists(): boolean {
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { mkdtempSync, readFileSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import {
|
||||
createShimContent,
|
||||
installHermesStudioCliShim,
|
||||
pathContainsDir,
|
||||
shimPathForPlatform,
|
||||
} from '../../packages/desktop/src/main/cli-shim'
|
||||
|
||||
let tempDirs: string[] = []
|
||||
|
||||
afterEach(() => {
|
||||
for (const dir of tempDirs) {
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
}
|
||||
tempDirs = []
|
||||
})
|
||||
|
||||
function tempHome(): string {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'hermes-studio-shim-'))
|
||||
tempDirs.push(dir)
|
||||
return dir
|
||||
}
|
||||
|
||||
describe('Hermes Studio CLI shim', () => {
|
||||
it('quotes Unix app paths and forwards args through --hermes-cli', () => {
|
||||
const content = createShimContent("/Applications/Hermes Studio's.app/Contents/MacOS/Hermes Studio", 'darwin')
|
||||
|
||||
expect(content).toContain("--hermes-cli")
|
||||
expect(content).toContain("APP='/Applications/Hermes Studio'\\''s.app/Contents/MacOS/Hermes Studio'")
|
||||
expect(content).toContain('unset ELECTRON_RUN_AS_NODE')
|
||||
expect(content).toContain('exec "$APP" -- --hermes-cli "$@"')
|
||||
})
|
||||
|
||||
it('clears Electron Node mode in Windows shims before launching the app', () => {
|
||||
const content = createShimContent('C:\\Users\\Example\\AppData\\Local\\Programs\\Hermes Studio\\Hermes Studio.exe', 'win32')
|
||||
|
||||
expect(content).toContain('set ELECTRON_RUN_AS_NODE=')
|
||||
expect(content).toContain('"%APP%" -- --hermes-cli %*')
|
||||
})
|
||||
|
||||
it('detects user bin paths with platform-specific separators', () => {
|
||||
expect(pathContainsDir('/usr/bin:/Users/example/bin', '/Users/example/bin', 'darwin')).toBe(true)
|
||||
expect(pathContainsDir('C:\\Windows;C:\\Users\\Example\\bin', 'C:\\Users\\Example\\bin', 'win32')).toBe(true)
|
||||
})
|
||||
|
||||
it('installs a managed Unix shim and adds ~/bin to a shell profile', async () => {
|
||||
const homeDir = tempHome()
|
||||
const result = await installHermesStudioCliShim({
|
||||
homeDir,
|
||||
platform: 'darwin',
|
||||
executablePath: '/Applications/Hermes Studio.app/Contents/MacOS/Hermes Studio',
|
||||
env: { PATH: '/usr/bin', SHELL: '/bin/zsh' },
|
||||
})
|
||||
|
||||
expect(result.status).toBe('installed')
|
||||
expect(result.pathUpdated).toBe(true)
|
||||
expect(result.shimPath).toBe(shimPathForPlatform(join(homeDir, 'bin'), 'darwin'))
|
||||
expect(readFileSync(result.shimPath, 'utf-8')).toContain('exec "$APP" -- --hermes-cli "$@"')
|
||||
expect(readFileSync(join(homeDir, '.zprofile'), 'utf-8')).toContain('export PATH="$HOME/bin:$PATH"')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user