diff --git a/packages/desktop/src/main/cli-constants.ts b/packages/desktop/src/main/cli-constants.ts new file mode 100644 index 0000000..c34be5a --- /dev/null +++ b/packages/desktop/src/main/cli-constants.ts @@ -0,0 +1 @@ +export const HERMES_CLI_ARG = '--hermes-cli' diff --git a/packages/desktop/src/main/cli-shim.ts b/packages/desktop/src/main/cli-shim.ts new file mode 100644 index 0000000..1435218 --- /dev/null +++ b/packages/desktop/src/main/cli-shim.ts @@ -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>): 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 { + 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 { + if (platform === 'win32') { + return await ensureWindowsUserPath(binDir) + } + return ensureUnixShellPath(homeDir, binDir, platform, env) +} + +export async function installHermesStudioCliShim(options: CliShimInstallOptions = {}): Promise { + 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, + } +} diff --git a/packages/desktop/src/main/hermes-cli.ts b/packages/desktop/src/main/hermes-cli.ts new file mode 100644 index 0000000..50f5143 --- /dev/null +++ b/packages/desktop/src/main/hermes-cli.ts @@ -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 { + 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) + }) + }) +} diff --git a/packages/desktop/src/main/index.ts b/packages/desktop/src/main/index.ts index ab7052e..19f08e9 100644 --- a/packages/desktop/src/main/index.ts +++ b/packages/desktop/src/main/index.ts @@ -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) { - app.quit() -} else { +function runDesktopApp() { + const gotLock = app.requestSingleInstanceLock() + if (!gotLock) { + app.quit() + 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() +} diff --git a/packages/desktop/src/main/paths.ts b/packages/desktop/src/main/paths.ts index 04a2dd5..02cd9ff 100644 --- a/packages/desktop/src/main/paths.ts +++ b/packages/desktop/src/main/paths.ts @@ -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 { diff --git a/tests/desktop/cli-shim.test.ts b/tests/desktop/cli-shim.test.ts new file mode 100644 index 0000000..f3d0aee --- /dev/null +++ b/tests/desktop/cli-shim.test.ts @@ -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"') + }) +})