diff --git a/app/src/main/hl/engines/cliSpawn.ts b/app/src/main/hl/engines/cliSpawn.ts index dadaedd3..58249aba 100644 --- a/app/src/main/hl/engines/cliSpawn.ts +++ b/app/src/main/hl/engines/cliSpawn.ts @@ -1,6 +1,6 @@ import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process'; import path from 'node:path'; -import { enrichedEnv, resolveCliSpawn } from './pathEnrich'; +import { resolveCliLaunch } from './pathEnrich'; export type CliStdinMode = 'ignore' | 'pipe'; @@ -33,13 +33,12 @@ function assertSafeExecutable(value: string, label: string): void { export function spawnCli(bin: string, args: readonly string[], opts: SpawnCliOptions = {}): ChildProcessWithoutNullStreams { assertSafeExecutable(bin, 'executable name'); - const env = opts.env ?? enrichedEnv(); const stdio = opts.stdio ?? ['ignore', 'pipe', 'pipe']; - const resolved = resolveCliSpawn(bin, args, { env }); + const resolved = resolveCliLaunch(bin, args, { env: opts.env }); assertSafeExecutable(resolved.command, 'resolved executable'); return spawn(resolved.command, resolved.args, { cwd: opts.cwd, - env, + env: resolved.env, stdio, ...resolved.spawnOptions, }) as ChildProcessWithoutNullStreams; diff --git a/app/src/main/hl/engines/installer.ts b/app/src/main/hl/engines/installer.ts index 40dde390..65a6cf8e 100644 --- a/app/src/main/hl/engines/installer.ts +++ b/app/src/main/hl/engines/installer.ts @@ -1,14 +1,17 @@ -import { spawn, spawnSync } from 'node:child_process'; -import fs from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; +import { spawn } from 'node:child_process'; import { mainLogger } from '../../logger'; +import { enrichedEnv, resetPathEnrichmentCache } from './pathEnrich'; export interface EngineInstallResult { opened: boolean; + completed?: boolean; + exitCode?: number | null; + signal?: NodeJS.Signals | null; error?: string; command?: string; displayName?: string; + stdout?: string; + stderr?: string; } interface InstallSpec { @@ -16,6 +19,13 @@ interface InstallSpec { command: (platform: NodeJS.Platform) => string; } +export interface InstallerSpawnSpec { + command: string; + args: string[]; + env: NodeJS.ProcessEnv; + spawnOptions: { windowsHide?: boolean }; +} + const INSTALLERS: Record = { 'claude-code': { displayName: 'Claude Code', @@ -39,137 +49,166 @@ const INSTALLERS: Record = { }, }; -function shellScript(displayName: string, command: string): string { - return [ - posixPrintLine(`Installing ${displayName}...`), - posixPrintLine(`$ ${command}`), - command, - 'status=$?', - posixPrintLine(''), - 'if [ "$status" -eq 0 ]; then', - ` ${posixPrintLine(`${displayName} install finished. Return to Browser Use and refresh the connection.`)}`, - 'else', - ` printf '%s%s.\\n' ${posixSingleQuote(`${displayName} install failed with exit code `)} "$status"`, - 'fi', - posixPrintLine(''), - 'read -r -p "Press Enter to close this terminal..."', - ].join('\n'); -} +const INSTALL_TIMEOUT_MS = 10 * 60 * 1000; +const OUTPUT_TAIL_LIMIT = 8192; -function posixSingleQuote(value: string): string { - return `'${value.replace(/'/g, "'\\''")}'`; +function trimTail(value: string): string { + return value.length > OUTPUT_TAIL_LIMIT ? value.slice(-OUTPUT_TAIL_LIMIT) : value; } -function posixPrintLine(value: string): string { - return `printf '%s\\n' ${posixSingleQuote(value)}`; +function installerExitError(displayName: string, exitCode: number | null, signal: NodeJS.Signals | null): string { + if (signal) return `${displayName} installer exited from signal ${signal}`; + return `${displayName} installer exited ${exitCode}`; } -function appleScriptString(value: string): string { - return `"${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`; -} - -function openMacTerminal(displayName: string, command: string): EngineInstallResult { - const script = shellScript(displayName, command); - const osa = spawn('osascript', [ - '-e', 'tell application "Terminal"', - '-e', 'activate', - '-e', `do script ${appleScriptString(script)}`, - '-e', 'end tell', - ], { detached: true, stdio: 'ignore' }); - osa.unref(); - return { opened: true, command, displayName }; +export function installerSpawnSpec( + installCommand: string, + opts: { platform?: NodeJS.Platform; env?: NodeJS.ProcessEnv } = {}, +): InstallerSpawnSpec { + if (/[\r\n\0]/.test(installCommand)) throw new Error('installer command contains unsupported control characters'); + const platform = opts.platform ?? process.platform; + const env = enrichedEnv(opts.env ?? process.env, { platform }); + if (platform === 'win32') { + return { + command: env.ComSpec || 'cmd.exe', + args: ['/d', '/s', '/c', installCommand], + env, + spawnOptions: { windowsHide: true }, + }; + } + return { + command: 'sh', + args: ['-lc', installCommand], + env, + spawnOptions: {}, + }; } -function commandExists(bin: string): boolean { - const r = spawnSync('sh', ['-lc', `command -v -- ${posixSingleQuote(bin)}`], { stdio: 'ignore' }); - return r.status === 0; -} +export function runInstallCommand( + displayName: string, + installCommand: string, + opts: { platform?: NodeJS.Platform; env?: NodeJS.ProcessEnv; timeoutMs?: number } = {}, +): Promise { + const timeoutMs = opts.timeoutMs ?? INSTALL_TIMEOUT_MS; + return new Promise((resolve) => { + let spawnSpec: InstallerSpawnSpec; + try { + spawnSpec = installerSpawnSpec(installCommand, opts); + } catch (err) { + resolve({ + opened: false, + completed: false, + error: (err as Error).message, + command: installCommand, + displayName, + }); + return; + } -function openLinuxTerminal(displayName: string, command: string): EngineInstallResult { - const script = shellScript(displayName, command); - const candidates: Array<{ bin: string; args: string[] }> = [ - { bin: 'x-terminal-emulator', args: ['-e', 'sh', '-lc', script] }, - { bin: 'gnome-terminal', args: ['--', 'sh', '-lc', script] }, - { bin: 'konsole', args: ['-e', 'sh', '-lc', script] }, - { bin: 'xterm', args: ['-e', 'sh', '-lc', script] }, - ]; - const candidate = candidates.find((c) => commandExists(c.bin)); - if (!candidate) return { opened: false, error: 'No supported terminal emulator found', command, displayName }; - const child = spawn(candidate.bin, candidate.args, { detached: true, stdio: 'ignore' }); - child.unref(); - return { opened: true, command, displayName }; -} + let child: ReturnType; + try { + child = spawn(spawnSpec.command, spawnSpec.args, { + env: spawnSpec.env, + stdio: ['ignore', 'pipe', 'pipe'], + ...spawnSpec.spawnOptions, + }); + } catch (err) { + resolve({ + opened: false, + completed: false, + error: (err as Error).message, + command: installCommand, + displayName, + }); + return; + } -function escapeCmdEcho(value: string): string { - if (/[\r\n\0]/.test(value)) throw new Error('installer text contains unsupported control characters'); - return value - .replace(/\^/g, '^^') - .replace(/%/g, '%%') - .replace(/&/g, '^&') - .replace(/\|/g, '^|') - .replace(//g, '^>') - .replace(/\(/g, '^(') - .replace(/\)/g, '^)'); -} + let stdout = ''; + let stderr = ''; + let settled = false; + let timedOut = false; + let timer: ReturnType; + let forceKillTimer: ReturnType | undefined; -function quoteCmdToken(value: string): string { - if (/[\r\n\0"]/.test(value)) throw new Error('installer command token contains unsupported characters'); - return `"${value}"`; -} + const finish = (result: EngineInstallResult): void => { + if (settled) return; + settled = true; + clearTimeout(timer); + if (forceKillTimer) clearTimeout(forceKillTimer); + resetPathEnrichmentCache(); + resolve(result); + }; -function writeWindowsInstallScript(displayName: string, command: string): string { - const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'browser-use-install-')); - const scriptPath = path.join(dir, 'install.cmd'); - const body = [ - '@echo off', - `echo ${escapeCmdEcho(`Installing ${displayName}...`)}`, - `echo ${escapeCmdEcho(`$ ${command}`)}`, - command, - 'set "status=%ERRORLEVEL%"', - 'echo.', - 'if "%status%"=="0" (', - ` echo ${escapeCmdEcho(`${displayName} install finished. Return to Browser Use and refresh the connection.`)}`, - ') else (', - ` echo ${escapeCmdEcho(`${displayName} install failed with exit code`)} %status%.`, - ')', - 'echo.', - 'pause', - ].join('\r\n'); - fs.writeFileSync(scriptPath, body, 'utf-8'); - return scriptPath; -} + timer = setTimeout(() => { + timedOut = true; + try { child.kill('SIGTERM'); } catch { /* already closed */ } + forceKillTimer = setTimeout(() => { + try { child.kill('SIGKILL'); } catch { /* already closed */ } + }, 1000); + }, timeoutMs); -function openWindowsTerminal(displayName: string, command: string): EngineInstallResult { - const scriptPath = writeWindowsInstallScript(displayName, command); - const comspec = process.env.ComSpec || 'cmd.exe'; - const child = spawn(comspec, [ - '/d', - '/s', - '/c', - `start ${quoteCmdToken(`${displayName} Installer`)} ${quoteCmdToken(comspec)} /k ${quoteCmdToken(scriptPath)}`, - ], { detached: true, stdio: 'ignore', windowsHide: false }); - child.unref(); - return { opened: true, command, displayName }; + child.stdout?.on('data', (chunk) => { + stdout = trimTail(stdout + String(chunk)); + }); + child.stderr?.on('data', (chunk) => { + stderr = trimTail(stderr + String(chunk)); + }); + child.on('error', (err) => { + finish({ + opened: false, + completed: false, + error: err.message, + command: installCommand, + displayName, + stdout, + stderr, + }); + }); + child.on('close', (exitCode, signal) => { + const ok = exitCode === 0 && !timedOut; + finish({ + opened: ok, + completed: !timedOut, + exitCode, + signal, + error: ok + ? undefined + : timedOut + ? `Installer timed out after ${timeoutMs}ms` + : stderr.trim() || stdout.trim() || installerExitError(displayName, exitCode, signal), + command: installCommand, + displayName, + stdout, + stderr, + }); + }); + }); } -export function openEngineInstallTerminal(engineId: string): EngineInstallResult { +export async function runEngineInstall(engineId: string): Promise { const spec = INSTALLERS[engineId]; - if (!spec) return { opened: false, error: `No installer configured for ${engineId}` }; + if (!spec) return { opened: false, completed: false, error: `No installer configured for ${engineId}` }; const command = spec.command(process.platform); - mainLogger.info('engineInstaller.open.request', { + mainLogger.info('engineInstaller.start.request', { engineId, displayName: spec.displayName, platform: process.platform, command, }); try { - if (process.platform === 'darwin') return openMacTerminal(spec.displayName, command); - if (process.platform === 'win32') return openWindowsTerminal(spec.displayName, command); - return openLinuxTerminal(spec.displayName, command); + const result = await runInstallCommand(spec.displayName, command); + mainLogger.info('engineInstaller.start.result', { + engineId, + displayName: spec.displayName, + completed: result.completed, + exitCode: result.exitCode, + signal: result.signal, + hasError: Boolean(result.error), + }); + return result; } catch (err) { const error = (err as Error).message; - mainLogger.warn('engineInstaller.open.failed', { engineId, error }); - return { opened: false, error, command, displayName: spec.displayName }; + mainLogger.warn('engineInstaller.start.failed', { engineId, error }); + return { opened: false, completed: false, error, command, displayName: spec.displayName }; } } diff --git a/app/src/main/hl/engines/pathEnrich.ts b/app/src/main/hl/engines/pathEnrich.ts index 9f6d90da..6f47b2e8 100644 --- a/app/src/main/hl/engines/pathEnrich.ts +++ b/app/src/main/hl/engines/pathEnrich.ts @@ -21,6 +21,14 @@ interface EnrichOptions { homedir?: string; } +interface CliLaunchOptions extends EnrichOptions { + env?: NodeJS.ProcessEnv; +} + +type PosixPathMod = typeof path.posix; +type WindowsPathMod = typeof path.win32; +type ExtraDirResult = string | string[] | null; + /** * Spawn the user's login shell once and capture its PATH. Catches custom * dirs set in ~/.zshrc / ~/.bashrc / chruby / mise / asdf / etc. that @@ -32,6 +40,11 @@ interface EnrichOptions { let cachedShellPath: string | null = null; let cachedShellPathTried = false; +export function resetPathEnrichmentCache(): void { + cachedShellPath = null; + cachedShellPathTried = false; +} + function queryLoginShellPath(env: NodeJS.ProcessEnv = process.env, platform: Platform = process.platform): string | null { if (platform === 'win32') return null; if (cachedShellPathTried) return cachedShellPath; @@ -53,36 +66,101 @@ function queryLoginShellPath(env: NodeJS.ProcessEnv = process.env, platform: Pla return cachedShellPath; } -const POSIX_EXTRA_DIRS_FNS: Array<(home: string, platform: Platform, pathMod: typeof path) => string | null> = [ +function existingChildBins(root: string, pathMod: PosixPathMod | WindowsPathMod, childToBin: (child: string) => string): string[] { + try { + return fs.readdirSync(root, { withFileTypes: true }) + .filter((entry) => entry.isDirectory() || entry.isSymbolicLink()) + .map((entry) => childToBin(pathMod.join(root, entry.name))) + .filter((dir) => { + try { return fs.statSync(dir).isDirectory(); } + catch { return false; } + }) + .sort(); + } catch { + return []; + } +} + +function envPath(env: NodeJS.ProcessEnv, ...keys: string[]): string | null { + for (const key of keys) { + const value = env[key]; + if (value) return value; + } + return null; +} + +const POSIX_EXTRA_DIRS_FNS: Array<(home: string, platform: Platform, env: NodeJS.ProcessEnv, pathMod: PosixPathMod) => ExtraDirResult> = [ + (_home, _platform, env) => envPath(env, 'PNPM_HOME'), + (_home, _platform, env, pathMod) => { + const prefix = envPath(env, 'NPM_CONFIG_PREFIX', 'npm_config_prefix'); + return prefix ? pathMod.join(prefix, 'bin') : null; + }, () => '/opt/homebrew/bin', () => '/opt/homebrew/sbin', () => '/usr/local/bin', () => '/usr/local/sbin', - (home, _platform, pathMod) => pathMod.join(home, '.npm-global', 'bin'), - (home, _platform, pathMod) => pathMod.join(home, '.volta', 'bin'), - (home, _platform, pathMod) => pathMod.join(home, '.nvm', 'versions', 'node'), - (home, _platform, pathMod) => pathMod.join(home, '.bun', 'bin'), - (home, _platform, pathMod) => pathMod.join(home, '.bcode', 'bin'), - (home, _platform, pathMod) => pathMod.join(home, '.deno', 'bin'), - (home, _platform, pathMod) => pathMod.join(home, '.cargo', 'bin'), - (home, _platform, pathMod) => pathMod.join(home, '.local', 'bin'), - (home, _platform, pathMod) => pathMod.join(home, '.yarn', 'bin'), - (home, _platform, pathMod) => pathMod.join(home, 'bin'), + (_home, _platform, _env, pathMod) => platformDir(pathMod, '/snap/bin'), + (_home, platform) => platform === 'darwin' ? '/Applications/Codex.app/Contents/Resources' : null, + (home, platform, _env, pathMod) => platform === 'darwin' ? pathMod.join(home, 'Applications', 'Codex.app', 'Contents', 'Resources') : null, + (home, _platform, _env, pathMod) => pathMod.join(home, '.npm-global', 'bin'), + (home, _platform, _env, pathMod) => pathMod.join(home, '.npm-packages', 'bin'), + (home, _platform, _env, pathMod) => pathMod.join(home, '.volta', 'bin'), + (home, _platform, _env, pathMod) => existingChildBins(pathMod.join(home, '.nvm', 'versions', 'node'), pathMod, (child) => pathMod.join(child, 'bin')), + (home, _platform, _env, pathMod) => pathMod.join(home, '.nodebrew', 'current', 'bin'), + (home, _platform, _env, pathMod) => pathMod.join(home, '.n', 'bin'), + (home, _platform, _env, pathMod) => existingChildBins(pathMod.join(home, '.fnm', 'node-versions'), pathMod, (child) => pathMod.join(child, 'installation', 'bin')), + (home, _platform, _env, pathMod) => existingChildBins(pathMod.join(home, '.local', 'share', 'fnm', 'node-versions'), pathMod, (child) => pathMod.join(child, 'installation', 'bin')), + (home, _platform, _env, pathMod) => pathMod.join(home, '.asdf', 'shims'), + (home, _platform, _env, pathMod) => pathMod.join(home, '.local', 'share', 'mise', 'shims'), + (home, _platform, _env, pathMod) => pathMod.join(home, '.local', 'share', 'rtx', 'shims'), + (home, platform, _env, pathMod) => platform === 'darwin' ? pathMod.join(home, 'Library', 'pnpm') : null, + (home, _platform, _env, pathMod) => pathMod.join(home, '.local', 'share', 'pnpm'), + (home, _platform, _env, pathMod) => pathMod.join(home, '.bun', 'bin'), + (home, _platform, _env, pathMod) => pathMod.join(home, '.bcode', 'bin'), + (home, _platform, _env, pathMod) => pathMod.join(home, '.deno', 'bin'), + (home, _platform, _env, pathMod) => pathMod.join(home, '.cargo', 'bin'), + (home, _platform, _env, pathMod) => pathMod.join(home, '.local', 'bin'), + (home, _platform, _env, pathMod) => pathMod.join(home, '.yarn', 'bin'), + (home, _platform, _env, pathMod) => pathMod.join(home, '.config', 'yarn', 'global', 'node_modules', '.bin'), + (home, _platform, _env, pathMod) => pathMod.join(home, 'bin'), ]; -const WINDOWS_EXTRA_DIRS_FNS: Array<(home: string, env: NodeJS.ProcessEnv, pathMod: typeof path.win32) => string | null> = [ +function platformDir(pathMod: PosixPathMod, dir: string): string { + return pathMod.normalize(dir); +} + +const WINDOWS_EXTRA_DIRS_FNS: Array<(home: string, env: NodeJS.ProcessEnv, pathMod: WindowsPathMod) => ExtraDirResult> = [ + (_home, env) => envPath(env, 'PNPM_HOME'), + (_home, env) => { + const prefix = envPath(env, 'NPM_CONFIG_PREFIX', 'npm_config_prefix'); + return prefix; + }, (_home, env, pathMod) => env.LOCALAPPDATA ? pathMod.join(env.LOCALAPPDATA, 'Programs', 'Microsoft VS Code', 'bin') : null, (_home, env, pathMod) => env.LOCALAPPDATA ? pathMod.join(env.LOCALAPPDATA, 'Programs', 'cursor', 'resources', 'app', 'bin') : null, (_home, env, pathMod) => env.LOCALAPPDATA ? pathMod.join(env.LOCALAPPDATA, 'Programs', 'Windsurf', 'resources', 'app', 'bin') : null, + (_home, env, pathMod) => env.LOCALAPPDATA ? pathMod.join(env.LOCALAPPDATA, 'pnpm') : null, + (_home, env, pathMod) => env.LOCALAPPDATA ? pathMod.join(env.LOCALAPPDATA, 'Microsoft', 'WindowsApps') : null, + (_home, env, pathMod) => env.APPDATA ? pathMod.join(env.APPDATA, 'npm') : null, + (_home, env, pathMod) => env.APPDATA ? existingChildBins(pathMod.join(env.APPDATA, 'fnm', 'node-versions'), pathMod, (child) => pathMod.join(child, 'installation')) : null, (home, _env, pathMod) => pathMod.join(home, 'AppData', 'Roaming', 'npm'), + (home, _env, pathMod) => pathMod.join(home, '.npm-global'), + (home, _env, pathMod) => pathMod.join(home, '.npm-packages', 'bin'), + (home, _env, pathMod) => pathMod.join(home, '.volta', 'bin'), + (home, _env, pathMod) => existingChildBins(pathMod.join(home, '.fnm', 'node-versions'), pathMod, (child) => pathMod.join(child, 'installation')), (home, _env, pathMod) => pathMod.join(home, '.bun', 'bin'), (home, _env, pathMod) => pathMod.join(home, '.bcode', 'bin'), (home, _env, pathMod) => pathMod.join(home, '.deno', 'bin'), (home, _env, pathMod) => pathMod.join(home, '.cargo', 'bin'), + (home, _env, pathMod) => pathMod.join(home, 'scoop', 'shims'), + (_home, env, pathMod) => env.ProgramData ? pathMod.join(env.ProgramData, 'scoop', 'shims') : pathMod.join('C:\\', 'ProgramData', 'scoop', 'shims'), + (_home, env, pathMod) => env.ChocolateyInstall ? pathMod.join(env.ChocolateyInstall, 'bin') : pathMod.join('C:\\', 'ProgramData', 'chocolatey', 'bin'), ]; function pathValueFromEnv(env: NodeJS.ProcessEnv, platform: Platform): string { - if (platform === 'win32') return env.Path ?? env.PATH ?? ''; + if (platform === 'win32') { + const values = [env.Path, env.PATH].filter((value): value is string => Boolean(value)); + return Array.from(new Set(values)).join(';'); + } return env.PATH ?? ''; } @@ -91,25 +169,29 @@ function pathKeyForEnv(env: NodeJS.ProcessEnv, platform: Platform): 'PATH' | 'Pa return 'PATH'; } -export function enrichedPath(base = pathValueFromEnv(process.env, process.platform), opts: EnrichOptions = {}): string { +export function enrichedPath(base?: string, opts: EnrichOptions = {}): string { const platform = opts.platform ?? process.platform; const env = opts.env ?? process.env; const home = opts.homedir ?? os.homedir(); - const pathMod = platform === 'win32' ? path.win32 : path; + const pathMod = platform === 'win32' ? path.win32 : path.posix; const delimiter = platform === 'win32' ? ';' : ':'; - const existing = base.split(delimiter).filter(Boolean); + const existing = (base ?? pathValueFromEnv(env, platform)).split(delimiter).filter(Boolean); const set = new Set(existing); const out = [...existing]; + const addDir = (dir: string): void => { + if (!set.has(dir)) { + set.add(dir); + out.push(dir); + } + }; + // First: anything the user's login shell knows about on POSIX — covers // custom setups like chruby, asdf, mise, direnv, or ad-hoc PATH exports. const shellPath = queryLoginShellPath(env, platform); if (shellPath) { for (const dir of shellPath.split(delimiter).filter(Boolean)) { - if (!set.has(dir)) { - set.add(dir); - out.push(dir); - } + addDir(dir); } } @@ -117,21 +199,28 @@ export function enrichedPath(base = pathValueFromEnv(process.env, process.platfo // shell query failed or the platform has no login-shell convention. const extraFns = platform === 'win32' ? WINDOWS_EXTRA_DIRS_FNS.map((fn) => () => fn(home, env, pathMod)) - : POSIX_EXTRA_DIRS_FNS.map((fn) => () => fn(home, platform, pathMod)); + : POSIX_EXTRA_DIRS_FNS.map((fn) => () => fn(home, platform, env, pathMod)); for (const fn of extraFns) { - const dir = fn(); - if (dir && !set.has(dir)) { - set.add(dir); - out.push(dir); + const result = fn(); + const dirs = Array.isArray(result) ? result : result ? [result] : []; + for (const dir of dirs) { + addDir(dir); } } return out.join(delimiter); } -export function enrichedEnv(baseEnv: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv { - const platform = process.platform; +export function enrichedEnv(baseEnv: NodeJS.ProcessEnv = process.env, opts: Omit = {}): NodeJS.ProcessEnv { + const platform = opts.platform ?? process.platform; const key = pathKeyForEnv(baseEnv, platform); - return { ...baseEnv, [key]: enrichedPath(pathValueFromEnv(baseEnv, platform), { platform, env: baseEnv }) }; + return { + ...baseEnv, + [key]: enrichedPath(pathValueFromEnv(baseEnv, platform), { + platform, + env: baseEnv, + homedir: opts.homedir, + }), + }; } /** @@ -210,7 +299,7 @@ export function resolveCliSpawn( const platform = opts.platform ?? process.platform; if (platform !== 'win32') return { command: name, args: [...args], viaCmdShell: false, spawnOptions: {} }; - const env = opts.env ?? enrichedEnv(); + const env = opts.env ?? enrichedEnv(process.env, { platform }); const resolved = findOnWindowsPath(name, env); if (!resolved) return { command: name, args: [...args], viaCmdShell: false, spawnOptions: {} }; @@ -248,9 +337,38 @@ export function resolveCliSpawn( // GitHub Actions windows-latest in tests/unit/hl/codexStdinWindows.test.ts. const cmdline = [resolved, ...args].map(quoteForCmdExe).join(' '); return { - command: process.env.ComSpec || 'cmd.exe', + command: env.ComSpec || 'cmd.exe', args: ['/d', '/s', '/c', `"${cmdline}"`], viaCmdShell: true, spawnOptions: { windowsVerbatimArguments: true }, }; } + +export interface CliLaunchSpec extends ResolvedCli { + env: NodeJS.ProcessEnv; +} + +/** + * High-level router for launching user-installed CLIs from the Electron app. + * + * Every call gets a GUI-safe PATH first. On macOS/Linux, that is the main + * compatibility fix: CLIs installed by Homebrew, pnpm, Volta, asdf, npm, + * mise, etc. become visible even when the app was opened outside a shell. + * + * Windows gets the same PATH enrichment, then adds shim resolution for npm's + * `.cmd`/`.ps1` launchers so callers can keep passing plain names like + * `codex` or `claude` without knowing how the package manager installed them. + */ +export function resolveCliLaunch( + name: string, + args: readonly string[], + opts: CliLaunchOptions = {}, +): CliLaunchSpec { + const platform = opts.platform ?? process.platform; + const env = enrichedEnv(opts.env ?? process.env, { + platform, + homedir: opts.homedir, + }); + const resolved = resolveCliSpawn(name, args, { platform, env }); + return { ...resolved, env }; +} diff --git a/app/src/main/identity/codexLogin.ts b/app/src/main/identity/codexLogin.ts index 260dfabd..bea4bf7b 100644 --- a/app/src/main/identity/codexLogin.ts +++ b/app/src/main/identity/codexLogin.ts @@ -18,7 +18,7 @@ import { shell } from 'electron'; import * as pty from 'node-pty'; import { mainLogger } from '../logger'; -import { enrichedEnv } from '../hl/engines/pathEnrich'; +import { resolveCliLaunch } from '../hl/engines/pathEnrich'; const LOGIN_BIN = 'codex'; const TIMEOUT_MS = 15 * 60 * 1000; // Device-auth codes expire in 15m; cap plain-OAuth at the same. @@ -50,6 +50,29 @@ export interface CodexLoginResult { deviceCode?: string; } +type CodexLoginPtyArgs = string[] | string; +type CodexLoginPtyOptions = (pty.IPtyForkOptions | pty.IWindowsPtyForkOptions) & { + windowsVerbatimArguments?: boolean; +}; + +export function codexLoginPtySpawnSpec( + args: readonly string[], + opts: { env?: NodeJS.ProcessEnv; platform?: NodeJS.Platform } = {}, +): { + command: string; + args: CodexLoginPtyArgs; + env: { [key: string]: string }; + spawnOptions: { windowsVerbatimArguments?: boolean }; +} { + const resolved = resolveCliLaunch(LOGIN_BIN, args, { env: opts.env, platform: opts.platform }); + return { + command: resolved.command, + args: resolved.spawnOptions.windowsVerbatimArguments ? resolved.args.join(' ') : resolved.args, + env: resolved.env as { [key: string]: string }, + spawnOptions: resolved.spawnOptions, + }; +} + // Only one login attempt at a time. A second call kills the previous PTY // (user clicked "login" twice or restarted the flow) before spawning again. let activePty: pty.IPty | null = null; @@ -86,11 +109,16 @@ export function runCodexDeviceLogin(opts: CodexLoginOptions = {}): Promise { const adapter = getAdapter('codex'); if (!adapter) throw new Error('codex adapter not registered'); + const installed = await adapter.probeInstalled(); + if (!installed.installed) throw new Error(installed.error ?? 'Install Codex CLI before signing in.'); const auth = await adapter.probeAuthed(); if (!auth.authed) throw new Error('Codex CLI is not logged in. Run `codex login` first.'); mainLogger.info('onboardingHandlers.useCodex.ok'); diff --git a/app/src/main/index.ts b/app/src/main/index.ts index 0fe5a8be..5a16b5b1 100644 --- a/app/src/main/index.ts +++ b/app/src/main/index.ts @@ -1210,15 +1210,23 @@ app.whenReady().then(async () => { const { getAdapter } = await import('./hl/engines'); const adapter = getAdapter(validated); if (!adapter) throw new Error(`unknown engine: ${validated}`); - const { openEngineInstallTerminal } = await import('./hl/engines/installer'); - const result = openEngineInstallTerminal(adapter.id); + const { runEngineInstall } = await import('./hl/engines/installer'); + const result = await runEngineInstall(adapter.id); + const installed = await adapter.probeInstalled().catch((err) => ({ + installed: false, + error: (err as Error).message, + })); mainLogger.info('sessions.engine-install.result', { engineId: adapter.id, opened: result.opened, + completed: result.completed, + exitCode: result.exitCode, hasError: !!result.error, + installed: installed.installed, + installedError: installed.error, command: result.command, }); - return result; + return { ...result, installed }; }); ipcMain.handle('sessions:reveal-output', async (_event, filePath: string) => { diff --git a/app/src/preload/onboarding.ts b/app/src/preload/onboarding.ts index b2ac56d7..7cdd7611 100644 --- a/app/src/preload/onboarding.ts +++ b/app/src/preload/onboarding.ts @@ -90,6 +90,20 @@ const onboardingAPI = { openCodexLoginTerminal: (opts?: { deviceAuth?: boolean }): Promise<{ opened: boolean; error?: string; verificationUrl?: string; deviceCode?: string }> => ipcRenderer.invoke('onboarding:open-codex-login-terminal', opts), + installEngine: (engineId: 'claude-code' | 'codex'): Promise<{ + opened: boolean; + completed?: boolean; + exitCode?: number | null; + signal?: string | null; + error?: string; + command?: string; + displayName?: string; + stdout?: string; + stderr?: string; + installed?: { installed: boolean; version?: string; error?: string }; + }> => + ipcRenderer.invoke('sessions:engine-install', engineId), + openExternal: (url: string): Promise<{ opened: boolean }> => ipcRenderer.invoke('onboarding:open-external', url), diff --git a/app/src/preload/pill.ts b/app/src/preload/pill.ts index 4de808d8..e12f46ba 100644 --- a/app/src/preload/pill.ts +++ b/app/src/preload/pill.ts @@ -258,7 +258,18 @@ contextBridge.exposeInMainWorld('electronAPI', { }> => ipcRenderer.invoke('sessions:engine-status', engineId), engineLogin: (engineId: string): Promise<{ opened: boolean; error?: string }> => ipcRenderer.invoke('sessions:engine-login', engineId), - engineInstall: (engineId: string): Promise<{ opened: boolean; error?: string; command?: string; displayName?: string }> => + engineInstall: (engineId: string): Promise<{ + opened: boolean; + completed?: boolean; + exitCode?: number | null; + signal?: string | null; + error?: string; + command?: string; + displayName?: string; + stdout?: string; + stderr?: string; + installed?: { installed: boolean; version?: string; error?: string }; + }> => ipcRenderer.invoke('sessions:engine-install', engineId), }, settings: { diff --git a/app/src/preload/shell.ts b/app/src/preload/shell.ts index b8dd1069..70a004f5 100644 --- a/app/src/preload/shell.ts +++ b/app/src/preload/shell.ts @@ -228,7 +228,18 @@ contextBridge.exposeInMainWorld('electronAPI', { }> => ipcRenderer.invoke('sessions:engine-status', engineId), engineLogin: (engineId: string): Promise<{ opened: boolean; error?: string }> => ipcRenderer.invoke('sessions:engine-login', engineId), - engineInstall: (engineId: string): Promise<{ opened: boolean; error?: string; command?: string; displayName?: string }> => + engineInstall: (engineId: string): Promise<{ + opened: boolean; + completed?: boolean; + exitCode?: number | null; + signal?: string | null; + error?: string; + command?: string; + displayName?: string; + stdout?: string; + stderr?: string; + installed?: { installed: boolean; version?: string; error?: string }; + }> => ipcRenderer.invoke('sessions:engine-install', engineId), resume: ( id: string, diff --git a/app/src/renderer/globals.d.ts b/app/src/renderer/globals.d.ts index a2cdadb8..8005025e 100644 --- a/app/src/renderer/globals.d.ts +++ b/app/src/renderer/globals.d.ts @@ -54,7 +54,18 @@ interface ElectronSessionAPI { authed: { authed: boolean; error?: string }; }>; engineLogin: (engineId: string) => Promise<{ opened: boolean; error?: string }>; - engineInstall: (engineId: string) => Promise<{ opened: boolean; error?: string; command?: string; displayName?: string }>; + engineInstall: (engineId: string) => Promise<{ + opened: boolean; + completed?: boolean; + exitCode?: number | null; + signal?: string | null; + error?: string; + command?: string; + displayName?: string; + stdout?: string; + stderr?: string; + installed?: { installed: boolean; version?: string; error?: string }; + }>; resume: ( id: string, prompt: string, diff --git a/app/src/renderer/hub/ConnectionsPane.tsx b/app/src/renderer/hub/ConnectionsPane.tsx index 5ff4bafc..417cd7ec 100644 --- a/app/src/renderer/hub/ConnectionsPane.tsx +++ b/app/src/renderer/hub/ConnectionsPane.tsx @@ -8,6 +8,7 @@ import kimiLogo from './kimi-color.svg'; import qwenLogo from './qwen-color.svg'; import minimaxLogo from './minimax-color.svg'; import { CookieBrowser, type CookieBrowserApi } from '../shared/CookieBrowser'; +import { pollInstalledStatus } from '../shared/installStatus'; type WaStatus = 'disconnected' | 'connecting' | 'qr_ready' | 'connected' | 'error'; type AuthType = 'oauth' | 'apiKey' | 'none'; @@ -183,55 +184,61 @@ export function ConnectionsPane({ } }, []); - const refreshClaudeCli = useCallback(async () => { + const refreshClaudeCli = useCallback(async (): Promise => { const api = window.electronAPI; if (!api?.sessions?.engineStatus) { setClaudeStatusLoaded(true); - return; + return null; } try { const s = await api.sessions.engineStatus('claude-code'); - setClaudeStatus({ + const status = { installed: s.installed.installed, authed: s.authed.authed, version: s.installed.version, error: s.installed.error ?? s.authed.error, - }); + }; + setClaudeStatus(status); if (s.installed.installed && installingEngine === 'claude-code') setInstallingEngine(null); + return status; } catch (err) { console.error('[connections] refreshClaudeCli failed', err); + return null; } finally { setClaudeStatusLoaded(true); } }, [installingEngine]); - const refreshCodex = useCallback(async () => { + const refreshCodex = useCallback(async (): Promise => { const api = window.electronAPI; if (!api?.settings?.codex) { setCodexStatusLoaded(true); - return; + return null; } try { const s = await api.settings.codex.status(); - setCodexStatus({ + const status = { installed: s.installed.installed, authed: s.authed.authed, version: s.installed.version, error: s.installed.error ?? s.authed.error, - }); + }; + setCodexStatus(status); if (s.installed.installed && installingEngine === 'codex') setInstallingEngine(null); + return status; } catch (err) { console.error('[connections] refreshCodex failed', err); + return null; } finally { setCodexStatusLoaded(true); } }, [installingEngine]); - const refreshBrowserCode = useCallback(async () => { + const refreshBrowserCode = useCallback(async (): Promise => { const api = window.electronAPI; if (!api?.settings?.browserCode) { setBrowserCodeLoaded(true); - return; + return null; } try { const s = await api.settings.browserCode.getStatus(); @@ -243,8 +250,10 @@ export function ConnectionsPane({ }); setBrowserCodeStatus(s); if (s.installed?.installed && installingEngine === 'browsercode') setInstallingEngine(null); + return s.installed ?? null; } catch (err) { console.error('[connections] refreshBrowserCode failed', err); + return null; } finally { setBrowserCodeLoaded(true); } @@ -260,27 +269,28 @@ export function ConnectionsPane({ try { const result = await api.sessions.engineInstall(engineId); console.info('[connections] engine.install.result', { engineId, result }); - if (!result.opened) { - setInstallingEngine(null); - const msg = result.error ?? `Failed to open installer for ${engineId}`; + const refreshInstalledStatus = async () => { + if (engineId === 'claude-code') return refreshClaudeCli(); + if (engineId === 'codex') return refreshCodex(); + if (engineId === 'browsercode') return refreshBrowserCode(); + return null; + }; + const status = result.opened + ? await pollInstalledStatus(refreshInstalledStatus, { initialInstalled: result.installed }) + : await refreshInstalledStatus(); + if (!status?.installed) { + const msg = result.error ?? result.installed?.error ?? `Installer finished but ${engineId} was not detected.`; if (engineId === 'claude-code') setKeyError(msg); else if (engineId === 'codex') setOpenaiError(msg); else if (engineId === 'browsercode') setBrowserCodeError(msg); } - setTimeout(() => { - if (engineId === 'claude-code') void refreshClaudeCli(); - if (engineId === 'codex') void refreshCodex(); - if (engineId === 'browsercode') void refreshBrowserCode(); - }, 3000); - setTimeout(() => { - setInstallingEngine((current) => (current === engineId ? null : current)); - }, 120000); } catch (err) { - setInstallingEngine(null); const msg = (err as Error).message; if (engineId === 'claude-code') setKeyError(msg); else if (engineId === 'codex') setOpenaiError(msg); else if (engineId === 'browsercode') setBrowserCodeError(msg); + } finally { + setInstallingEngine((current) => (current === engineId ? null : current)); } }, [refreshBrowserCode, refreshClaudeCli, refreshCodex]); diff --git a/app/src/renderer/hub/EnginePicker.tsx b/app/src/renderer/hub/EnginePicker.tsx index 0258b726..d43bcea3 100644 --- a/app/src/renderer/hub/EnginePicker.tsx +++ b/app/src/renderer/hub/EnginePicker.tsx @@ -3,6 +3,7 @@ import claudeLogoSrc from './claude-logo.svg?raw'; import openaiLogoSrc from './openai-logo.svg?raw'; import opencodeLogoSrc from './opencode-logo-dark.svg?raw'; import { BrowserCodeProviderSubmenu } from './BrowserCodeModelPicker'; +import { pollInstalledStatus } from '../shared/installStatus'; export interface EngineInfo { id: string; @@ -212,19 +213,22 @@ export function EnginePicker({ value, onChange, onOpenChange }: EnginePickerProp try { const result = await window.electronAPI?.sessions?.engineInstall?.(id); console.info('[EnginePicker] install.result', { id, result }); - if (!result?.opened) { - installingRef.current = null; - setInstalling(null); + if (result?.opened) { + const status = await pollInstalledStatus(async () => { + const updates = await refreshStatus([id]); + const next = updates.find((u) => u.id === id); + return next?.installed ?? null; + }, { initialInstalled: result.installed }); + if (!status?.installed) console.warn('[EnginePicker] engineInstall failed', { id, result }); + } else { + console.warn('[EnginePicker] engineInstall failed', { id, result }); + await refreshStatus([id]); } - setTimeout(() => { void refreshStatus([id]); }, 3000); - setTimeout(() => { - if (installingRef.current === id) installingRef.current = null; - setInstalling((current) => (current === id ? null : current)); - }, 120000); } catch (err) { console.error('[EnginePicker] engineInstall failed', err); + } finally { installingRef.current = null; - setInstalling(null); + setInstalling((current) => (current === id ? null : current)); } }; diff --git a/app/src/renderer/onboarding/OnboardingApp.tsx b/app/src/renderer/onboarding/OnboardingApp.tsx index 06509a57..48acb25a 100644 --- a/app/src/renderer/onboarding/OnboardingApp.tsx +++ b/app/src/renderer/onboarding/OnboardingApp.tsx @@ -12,6 +12,7 @@ import { normalizeShortcutPlatform, rendererToAccelerator, } from '../../shared/hotkeys'; +import { pollInstalledStatus } from '../shared/installStatus'; interface ChromeProfile { id: string; @@ -86,6 +87,18 @@ declare global { }>; useCodex: () => Promise<{ ok: boolean }>; openCodexLoginTerminal: (opts?: { deviceAuth?: boolean }) => Promise<{ opened: boolean; error?: string; verificationUrl?: string; deviceCode?: string }>; + installEngine: (engineId: 'claude-code' | 'codex') => Promise<{ + opened: boolean; + completed?: boolean; + exitCode?: number | null; + signal?: string | null; + error?: string; + command?: string; + displayName?: string; + stdout?: string; + stderr?: string; + installed?: { installed: boolean; version?: string; error?: string }; + }>; openExternal: (url: string) => Promise<{ opened: boolean }>; requestNotifications: () => Promise<{ supported: boolean }>; platform: string; @@ -115,6 +128,8 @@ declare global { } type Step = 'intro' | 'profile' | 'apikey' | 'notifications' | 'shortcut'; +type InstallableOnboardingEngine = 'claude-code' | 'codex'; +type InstallingEngines = Record; function buildAccelerator(e: KeyboardEvent, platform: string): string | null { const shortcut = keyboardEventToShortcut(e, platform); @@ -322,6 +337,14 @@ export function OnboardingApp() { // paste. Populated by handleStartCodexLogin and cleared once auth completes. const [codexDeviceCode, setCodexDeviceCode] = useState(null); const [codexVerificationUrl, setCodexVerificationUrl] = useState(null); + const [installingEngines, setInstallingEngines] = useState({ + 'claude-code': false, + codex: false, + }); + const installingEnginesRef = useRef({ + 'claude-code': false, + codex: false, + }); const refreshClaudeStatus = useCallback(async () => { try { @@ -392,6 +415,7 @@ export function OnboardingApp() { }, [waitingForCodexLogin, refreshCodexStatus]); const handleUseCodex = useCallback(async () => { + if (!codex?.installed) return; console.log('[onboarding] handleUseCodex: invoking useCodex'); try { const res = await window.onboardingAPI.useCodex(); @@ -402,9 +426,10 @@ export function OnboardingApp() { } catch (err) { console.error('[onboarding] handleUseCodex: useCodex threw', err); } - }, []); + }, [codex?.installed]); const handleStartCodexLogin = useCallback(async (opts?: { deviceAuth?: boolean }) => { + if (!codex?.installed) return; console.log('[onboarding] handleStartCodexLogin: invoking openCodexLoginTerminal', opts); setWaitingForCodexLogin(true); setCodexDeviceCode(null); @@ -423,7 +448,7 @@ export function OnboardingApp() { console.error('[onboarding] openCodexLoginTerminal threw', err); setWaitingForCodexLogin(false); } - }, []); + }, [codex?.installed]); // Click handlers for the card + the explicit device-auth fallback link. // Keeping these as plain references so React binds identity-stable functions. @@ -487,10 +512,60 @@ export function OnboardingApp() { } }, [refreshClaudeStatus]); - const handleInstallClaudeCode = useCallback(() => { - window.onboardingAPI.openExternal?.('https://docs.anthropic.com/en/docs/claude-code/overview'); + const waitForInstalledStatus = useCallback(async ( + engineId: InstallableOnboardingEngine, + initialInstalled?: { installed: boolean; version?: string; error?: string }, + ) => { + const refreshStatus = engineId === 'claude-code' ? refreshClaudeStatus : refreshCodexStatus; + return pollInstalledStatus(refreshStatus, { initialInstalled }); + }, [refreshClaudeStatus, refreshCodexStatus]); + + const setEngineInstalling = useCallback((engineId: InstallableOnboardingEngine, installing: boolean) => { + const current = installingEnginesRef.current; + if (current[engineId] === installing) return; + const next = { ...current, [engineId]: installing }; + installingEnginesRef.current = next; + setInstallingEngines(next); }, []); + const handleInstallEngine = useCallback(async (engineId: InstallableOnboardingEngine) => { + if (installingEnginesRef.current[engineId]) return; + setEngineInstalling(engineId, true); + try { + const res = await window.onboardingAPI.installEngine(engineId); + const status = res.opened + ? await waitForInstalledStatus(engineId, res.installed) + : engineId === 'claude-code' + ? await refreshClaudeStatus() + : await refreshCodexStatus(); + if (!res.opened || !status?.installed) { + console.warn('[onboarding] installEngine failed', engineId, res.error); + return; + } + } catch (err) { + console.error('[onboarding] installEngine threw', engineId, err); + } finally { + setEngineInstalling(engineId, false); + } + }, [refreshClaudeStatus, refreshCodexStatus, setEngineInstalling, waitForInstalledStatus]); + + const handleInstallClaudeCode = useCallback(() => { + void handleInstallEngine('claude-code'); + }, [handleInstallEngine]); + + const handleInstallCodex = useCallback(() => { + void handleInstallEngine('codex'); + }, [handleInstallEngine]); + + const claudeCodeReady = Boolean(claudeCode?.installed && claudeCode.authed); + const codexReady = Boolean(codex?.installed && codex.authed); + const hasUsableAnthropicKey = Boolean(claudeCode?.installed && apiKey.trim()); + const hasUsableOpenaiKey = Boolean(codex?.installed && openaiKey.trim()); + + const canContinueProviderSetup = claudeCodeReady || codexReady || hasUsableAnthropicKey || hasUsableOpenaiKey; + const installingClaudeCode = installingEngines['claude-code']; + const installingCodex = installingEngines.codex; + const [accelerator, setAccelerator] = useState(() => defaultGlobalCmdbarAccelerator(window.onboardingAPI.platform)); const [recording, setRecording] = useState(false); const [shortcutError, setShortcutError] = useState(null); @@ -612,27 +687,27 @@ export function OnboardingApp() { // advances. Works alongside the provider-subscription path (usingX), which // doesn't need a save step. Verbose logging so we can trace the path taken. const [stepSaving, setStepSaving] = useState(false); - const stepCanContinue = Boolean(claudeCode?.authed) || Boolean(codex?.authed) || apiKey.trim().length > 0 || openaiKey.trim().length > 0; const handleStepSaveAndContinue = useCallback(async () => { console.log('[onboarding] handleStepSaveAndContinue', { - claudeAuthed: Boolean(claudeCode?.authed), - codexAuthed: Boolean(codex?.authed), + claudeAuthed: claudeCodeReady, + codexAuthed: codexReady, hasAnthropicKey: apiKey.trim().length > 0, hasOpenaiKey: openaiKey.trim().length > 0, }); + if (!canContinueProviderSetup) return; setStepSaving(true); try { const ops: Promise[] = []; - if (apiKey.trim()) ops.push(window.onboardingAPI.saveApiKey(apiKey.trim())); - if (openaiKey.trim()) ops.push(window.onboardingAPI.saveOpenAIKey(openaiKey.trim())); + if (claudeCode?.installed && apiKey.trim()) ops.push(window.onboardingAPI.saveApiKey(apiKey.trim())); + if (codex?.installed && openaiKey.trim()) ops.push(window.onboardingAPI.saveOpenAIKey(openaiKey.trim())); if (ops.length > 0) { console.log('[onboarding] handleStepSaveAndContinue: saving', ops.length, 'key(s)'); await Promise.all(ops); } - if (apiKey.trim()) { + if (claudeCode?.installed && apiKey.trim()) { window.onboardingAPI.capture?.('onboarding_provider_selected', { provider: 'anthropic-key' }); } - if (openaiKey.trim()) { + if (codex?.installed && openaiKey.trim()) { window.onboardingAPI.capture?.('onboarding_provider_selected', { provider: 'openai-key' }); } console.log('[onboarding] handleStepSaveAndContinue: advancing to notifications step'); @@ -642,7 +717,7 @@ export function OnboardingApp() { } finally { setStepSaving(false); } - }, [claudeCode?.authed, codex?.authed, apiKey, openaiKey]); + }, [apiKey, canContinueProviderSetup, claudeCode?.installed, claudeCodeReady, codex?.installed, codexReady, openaiKey]); const handleFinish = useCallback(async () => { window.onboardingAPI.capture?.('onboarding_completed'); @@ -933,11 +1008,11 @@ export function OnboardingApp() {

Vendor setup

- Sign in with Claude Code or Codex, or paste an API key. Credentials are stored locally in the system keychain. + Install each provider CLI once, then sign in or add that provider’s API key. Credentials are stored locally in the system keychain.

{/* Installed + authed → selectable card. Click flips to configured state. */} - {claudeCode?.installed && claudeCode?.authed && ( + {claudeCodeReady && (
@@ -954,28 +1029,30 @@ export function OnboardingApp() { {/* Not authed → one card with two interior options: subscription or API key. Switching once configured happens in Settings. */} - {claudeCode && !claudeCode.authed && !usingClaudeCode && ( + {claudeCode && !claudeCodeReady && !usingClaudeCode && (
-
- - -
+ {claudeCode.installed && ( +
+ + +
+ )}
{!showAnthropicInput && claudeCode.installed && ( @@ -1002,26 +1079,29 @@ export function OnboardingApp() { )} - {!showAnthropicInput && !claudeCode.installed && ( + {!claudeCode.installed && ( )} - {showAnthropicInput && ( + {claudeCode.installed && showAnthropicInput && (
@@ -1064,31 +1144,55 @@ export function OnboardingApp() { )} {/* Codex not authed → same merged card pattern as Claude. */} - {codex && !codex.authed && !usingCodex && ( + {codex && !codexReady && !usingCodex && (
-
- - -
+ {codex.installed && ( +
+ + +
+ )}
- {!showOpenaiInput && ( + {!codex.installed && ( + + )} + + {codex.installed && !showOpenaiInput && ( <>
{waitingForCodexLogin ? '↻' : '›'}
@@ -1139,7 +1243,7 @@ export function OnboardingApp() { )} - {showOpenaiInput && ( + {codex.installed && showOpenaiInput && (
{stepSaving ? 'Saving...' : 'Save & Continue'} diff --git a/app/src/renderer/shared/installStatus.ts b/app/src/renderer/shared/installStatus.ts new file mode 100644 index 00000000..f444d2f8 --- /dev/null +++ b/app/src/renderer/shared/installStatus.ts @@ -0,0 +1,42 @@ +export interface InstallStatus { + installed: boolean; + version?: string | null; + error?: string | null; +} + +export const INSTALL_STATUS_POLL_INTERVAL_MS = 1000; +export const INSTALL_STATUS_MAX_POLLS = 120; +export const INSTALL_STATUS_VERIFIED_MAX_POLLS = 10; + +interface PollInstalledStatusOptions { + initialInstalled?: InstallStatus; + maxPolls?: number; + verifiedMaxPolls?: number; + intervalMs?: number; + wait?: (ms: number) => Promise; +} + +function delay(ms: number): Promise { + return new Promise((resolve) => { setTimeout(resolve, ms); }); +} + +export async function pollInstalledStatus( + refreshStatus: () => Promise, + opts: PollInstalledStatusOptions = {}, +): Promise { + const maxPolls = opts.initialInstalled?.installed + ? opts.verifiedMaxPolls ?? INSTALL_STATUS_VERIFIED_MAX_POLLS + : opts.maxPolls ?? INSTALL_STATUS_MAX_POLLS; + const intervalMs = opts.intervalMs ?? INSTALL_STATUS_POLL_INTERVAL_MS; + const wait = opts.wait ?? delay; + + for (let attempt = 0; attempt < maxPolls; attempt++) { + const status = await refreshStatus(); + if (status?.installed) return status; + if (attempt < maxPolls - 1) { + await wait(intervalMs); + } + } + + return null; +} diff --git a/app/tests/unit/hl/installer.test.ts b/app/tests/unit/hl/installer.test.ts new file mode 100644 index 00000000..81af961b --- /dev/null +++ b/app/tests/unit/hl/installer.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, it } from 'vitest'; +import { installerSpawnSpec, runInstallCommand } from '../../../src/main/hl/engines/installer'; + +const posixIt = process.platform === 'win32' ? it.skip : it; + +function nodeCommand(script: string): string { + return `${JSON.stringify(process.execPath)} -e ${JSON.stringify(script)}`; +} + +describe('engine installer background runner', () => { + it('routes Windows installs through hidden cmd.exe without opening a terminal', () => { + const command = 'npm install -g @openai/codex'; + + const spec = installerSpawnSpec(command, { + platform: 'win32', + env: { + Path: 'C:\\Windows\\System32', + ComSpec: 'C:\\Windows\\System32\\cmd.exe', + }, + }); + + expect(spec.command).toBe('C:\\Windows\\System32\\cmd.exe'); + expect(spec.args).toEqual(['/d', '/s', '/c', command]); + expect(spec.spawnOptions).toEqual({ windowsHide: true }); + expect(spec.args.join(' ')).not.toContain('start'); + expect(spec.args.join(' ')).not.toContain('Installer'); + }); + + it('routes POSIX installs through a background shell command runner', () => { + const command = 'curl -fsSL https://claude.ai/install.sh | bash'; + + const spec = installerSpawnSpec(command, { + platform: 'linux', + env: { PATH: '/usr/bin' }, + }); + + expect(spec.command).toBe('sh'); + expect(spec.args).toEqual(['-lc', command]); + expect(spec.spawnOptions).toEqual({}); + }); + + it('resolves after the installer process exits successfully', async () => { + const result = await runInstallCommand( + 'Test Installer', + nodeCommand("process.stdout.write('installed');"), + { timeoutMs: 5000 }, + ); + + expect(result).toMatchObject({ + opened: true, + completed: true, + exitCode: 0, + signal: null, + displayName: 'Test Installer', + }); + expect(result.stdout).toContain('installed'); + expect(result.error).toBeUndefined(); + }); + + it('returns installer stderr and exit code when the process fails', async () => { + const result = await runInstallCommand( + 'Test Installer', + nodeCommand("process.stderr.write('no permission'); process.exit(7);"), + { timeoutMs: 5000 }, + ); + + expect(result).toMatchObject({ + opened: false, + completed: true, + exitCode: 7, + signal: null, + displayName: 'Test Installer', + stderr: 'no permission', + error: 'no permission', + }); + }); + + posixIt('uses the signal in the fallback error when the installer is externally killed', async () => { + const result = await runInstallCommand( + 'Test Installer', + 'kill -TERM $$', + { timeoutMs: 5000 }, + ); + + expect(result).toMatchObject({ + opened: false, + completed: true, + exitCode: null, + signal: 'SIGTERM', + displayName: 'Test Installer', + error: 'Test Installer installer exited from signal SIGTERM', + }); + }); +}); diff --git a/app/tests/unit/hub/ConnectionsPane.spec.tsx b/app/tests/unit/hub/ConnectionsPane.spec.tsx index a8747699..c2bee46f 100644 --- a/app/tests/unit/hub/ConnectionsPane.spec.tsx +++ b/app/tests/unit/hub/ConnectionsPane.spec.tsx @@ -120,9 +120,25 @@ function cardByName(container: HTMLElement, name: string): HTMLElement { return card; } +async function flush(): Promise { + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); +} + +function buttonByText(container: HTMLElement, text: string): HTMLButtonElement { + const button = Array.from(container.querySelectorAll('button')).find((candidate) => ( + candidate.textContent?.includes(text) + )); + if (!(button instanceof HTMLButtonElement)) throw new Error(`Missing button: ${text}`); + return button; +} + describe('ConnectionsPane provider loading', () => { afterEach(() => { document.body.innerHTML = ''; + vi.useRealTimers(); vi.restoreAllMocks(); }); @@ -157,4 +173,101 @@ describe('ConnectionsPane provider loading', () => { act(() => root.unmount()); }); + + it('keeps install failure hidden while CLI detection catches up after installer exit', async () => { + vi.useFakeTimers(); + let claudeInstalled = false; + + Object.defineProperty(window, 'electronAPI', { + configurable: true, + value: { + channels: { + whatsapp: { + status: vi.fn(async () => ({ status: 'disconnected', identity: null })), + connect: vi.fn(), + clearAuth: vi.fn(), + disconnect: vi.fn(), + }, + }, + on: { + channelStatus: vi.fn(() => undefined), + whatsappQr: vi.fn(() => undefined), + }, + sessions: { + engineStatus: vi.fn(async (engineId: string) => ({ + id: engineId, + displayName: engineId === 'claude-code' ? 'Claude Code' : 'Codex', + installed: { installed: engineId === 'claude-code' ? claudeInstalled : true }, + authed: { authed: false }, + })), + engineInstall: vi.fn(async () => ({ + opened: true, + completed: true, + installed: { installed: false, error: 'codex not found on PATH' }, + })), + }, + settings: { + apiKey: { + getStatus: vi.fn(async () => ({ type: 'none' })), + test: vi.fn(), + save: vi.fn(), + delete: vi.fn(), + }, + claudeCode: { + available: vi.fn(async () => ({ available: false })), + use: vi.fn(), + login: vi.fn(), + logout: vi.fn(), + }, + openaiKey: { + getStatus: vi.fn(async () => ({ present: false })), + test: vi.fn(), + save: vi.fn(), + delete: vi.fn(), + }, + codex: { + status: vi.fn(async () => ({ + id: 'codex', + displayName: 'Codex', + installed: { installed: true }, + authed: { authed: false }, + })), + login: vi.fn(), + logout: vi.fn(), + }, + browserCode: { + getStatus: vi.fn(async () => ({ keys: {}, active: null, installed: { installed: true }, providers: [] })), + save: vi.fn(), + test: vi.fn(), + delete: vi.fn(), + setActive: vi.fn(), + }, + }, + }, + }); + + const { container, root } = renderConnectionsPane(); + await flush(); + + act(() => { + buttonByText(container, 'Install Claude Code').click(); + }); + await flush(); + + expect(container.textContent).not.toContain('codex not found on PATH'); + + claudeInstalled = true; + await act(async () => { + vi.advanceTimersByTime(1000); + await Promise.resolve(); + await Promise.resolve(); + }); + await flush(); + + expect(container.textContent).not.toContain('codex not found on PATH'); + expect(container.textContent).not.toContain('Installer finished but claude-code was not detected.'); + expect(container.textContent).toContain('Sign in with Claude'); + + act(() => root.unmount()); + }); }); diff --git a/app/tests/unit/hub/EnginePicker.spec.tsx b/app/tests/unit/hub/EnginePicker.spec.tsx index 0a4e40ba..4faba0f8 100644 --- a/app/tests/unit/hub/EnginePicker.spec.tsx +++ b/app/tests/unit/hub/EnginePicker.spec.tsx @@ -72,6 +72,14 @@ function getMenuItemButton(container: HTMLElement, text: string): HTMLButtonElem return button; } +function deferred(): { promise: Promise; resolve: (value: T) => void } { + let resolve!: (value: T) => void; + const promise = new Promise((innerResolve) => { + resolve = innerResolve; + }); + return { promise, resolve }; +} + describe('EnginePicker', () => { beforeEach(() => { vi.restoreAllMocks(); @@ -79,6 +87,7 @@ describe('EnginePicker', () => { afterEach(() => { document.body.innerHTML = ''; + vi.useRealTimers(); vi.restoreAllMocks(); }); @@ -115,6 +124,113 @@ describe('EnginePicker', () => { act(() => root.unmount()); }); + it('keeps install pending until the background installer process resolves', async () => { + let status: EngineStatus = { + id: 'codex', + displayName: 'Codex', + installed: { installed: false }, + authed: { authed: false }, + }; + const install = deferred<{ opened: boolean; completed: boolean; installed: { installed: boolean } }>(); + const sessions = installElectronApi(status, { + engineStatus: vi.fn(async () => status), + engineInstall: vi.fn(() => install.promise), + }); + const { container, root } = renderPicker('codex'); + + await flush(); + + act(() => { + getToggleButton(container).click(); + }); + await flush(); + + act(() => { + getMenuItemButton(container, 'Codex').click(); + }); + await flush(); + + expect(sessions.engineInstall).toHaveBeenCalledTimes(1); + expect(getMenuItemButton(container, 'Codex').disabled).toBe(true); + + status = { + id: 'codex', + displayName: 'Codex', + installed: { installed: true, version: '1.2.3' }, + authed: { authed: false }, + }; + await act(async () => { + install.resolve({ opened: true, completed: true, installed: { installed: true } }); + await install.promise; + await Promise.resolve(); + await Promise.resolve(); + }); + await flush(); + + expect(sessions.engineStatus).toHaveBeenCalled(); + expect(getMenuItemButton(container, 'Codex').disabled).toBe(false); + + act(() => root.unmount()); + }); + + it('keeps install pending while post-install CLI detection catches up', async () => { + vi.useFakeTimers(); + let installed = false; + const install = deferred<{ opened: boolean; completed: boolean; installed: { installed: boolean; error?: string } }>(); + const sessions = installElectronApi({ + id: 'codex', + displayName: 'Codex', + installed: { installed: false }, + authed: { authed: false }, + }, { + engineStatus: vi.fn(async () => ({ + id: 'codex', + displayName: 'Codex', + installed: { installed }, + authed: { authed: false }, + })), + engineInstall: vi.fn(() => install.promise), + }); + const { container, root } = renderPicker('codex'); + + await flush(); + + act(() => { + getToggleButton(container).click(); + }); + await flush(); + + act(() => { + getMenuItemButton(container, 'Codex').click(); + }); + await flush(); + + await act(async () => { + install.resolve({ opened: true, completed: true, installed: { installed: false, error: 'codex not found on PATH' } }); + await install.promise; + await Promise.resolve(); + await Promise.resolve(); + }); + await flush(); + + expect(sessions.engineInstall).toHaveBeenCalledTimes(1); + expect(getMenuItemButton(container, 'Codex').disabled).toBe(true); + expect(getMenuItemButton(container, 'Codex').textContent).toContain('Installing'); + + installed = true; + await act(async () => { + vi.advanceTimersByTime(1000); + await Promise.resolve(); + await Promise.resolve(); + }); + await flush(); + + expect(getMenuItemButton(container, 'Codex').disabled).toBe(false); + expect(getMenuItemButton(container, 'Codex').textContent).toContain('Log in'); + + act(() => root.unmount()); + }); + it('does not retrigger login while the same engine login is already in progress', async () => { const sessions = installElectronApi({ id: 'claude-code', diff --git a/app/tests/unit/identity/codexLogin.test.ts b/app/tests/unit/identity/codexLogin.test.ts new file mode 100644 index 00000000..715f1374 --- /dev/null +++ b/app/tests/unit/identity/codexLogin.test.ts @@ -0,0 +1,28 @@ +import fs from 'node:fs'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { codexLoginPtySpawnSpec } from '../../../src/main/identity/codexLogin'; + +describe('codex login PTY spawn spec', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('uses the shared Windows shim resolver for npm-installed codex.cmd', () => { + vi.spyOn(fs, 'statSync').mockImplementation((candidate) => ({ + isFile: () => String(candidate).endsWith('codex.cmd'), + }) as fs.Stats); + + const spec = codexLoginPtySpawnSpec(['login'], { + platform: 'win32', + env: { + PATH: 'C:\\Program Files\\npm', + ComSpec: 'C:\\Windows\\System32\\cmd.exe', + PATHEXT: '.COM;.EXE;.BAT;.CMD', + }, + }); + + expect(spec.command).toBe('C:\\Windows\\System32\\cmd.exe'); + expect(spec.args).toBe('/d /s /c ""C:\\Program Files\\npm\\codex.cmd" login"'); + expect(spec.spawnOptions).toEqual({ windowsVerbatimArguments: true }); + }); +}); diff --git a/app/tests/unit/identity/onboardingHandlers.test.ts b/app/tests/unit/identity/onboardingHandlers.test.ts index 5edfa051..f618a5f5 100644 --- a/app/tests/unit/identity/onboardingHandlers.test.ts +++ b/app/tests/unit/identity/onboardingHandlers.test.ts @@ -24,6 +24,7 @@ const { mockGetGlobalCmdbarAccelerator, mockRegisterHotkeys, mockSetGlobalCmdbarAccelerator, + mockGetAdapter, hotkeyState, } = vi.hoisted(() => { const state = { accelerator: 'CommandOrControl+Shift+Space' }; @@ -44,6 +45,7 @@ const { state.accelerator = accelerator; return { ok: true, accelerator }; }), + mockGetAdapter: vi.fn(), }; }); @@ -83,6 +85,10 @@ vi.mock('../../../src/main/hotkeys', () => ({ setGlobalCmdbarAccelerator: mockSetGlobalCmdbarAccelerator, })); +vi.mock('../../../src/main/hl/engines', () => ({ + getAdapter: mockGetAdapter, +})); + const mockSetPassword = vi.fn(async () => {}); vi.mock('keytar', () => ({ setPassword: mockSetPassword, @@ -144,6 +150,7 @@ describe('onboardingHandlers.ts', () => { return { ok: true, accelerator }; }); handlers.clear(); + mockGetAdapter.mockReset(); accountStore = makeAccountStore(); onboardingWindow = makeWindow(); openShellWindow = vi.fn(() => ({ id: 2 })); @@ -183,6 +190,52 @@ describe('onboardingHandlers.ts', () => { }); }); + describe('codex onboarding handlers', () => { + it('reports Codex as unauthenticated when the CLI is not installed', async () => { + const probeInstalled = vi.fn(async () => ({ installed: false, error: 'codex not found on PATH' })); + const probeAuthed = vi.fn(async () => ({ authed: true })); + mockGetAdapter.mockReturnValue({ + probeInstalled, + probeAuthed, + }); + + const result = await invokeHandler('onboarding:detect-codex'); + + expect(result).toEqual({ + available: false, + installed: false, + authed: false, + version: null, + error: 'codex not found on PATH', + }); + expect(probeAuthed).not.toHaveBeenCalled(); + }); + + it('does not start Codex login when the CLI is not installed', async () => { + const openLoginInTerminal = vi.fn(); + mockGetAdapter.mockReturnValue({ + probeInstalled: vi.fn(async () => ({ installed: false, error: 'codex not found on PATH' })), + openLoginInTerminal, + }); + + const result = await invokeHandler('onboarding:open-codex-login-terminal'); + + expect(result).toEqual({ opened: false, error: 'codex not found on PATH' }); + expect(openLoginInTerminal).not.toHaveBeenCalled(); + }); + + it('does not accept Codex as selected when the CLI is not installed', async () => { + const probeAuthed = vi.fn(async () => ({ authed: true })); + mockGetAdapter.mockReturnValue({ + probeInstalled: vi.fn(async () => ({ installed: false, error: 'codex not found on PATH' })), + probeAuthed, + }); + + await expect(invokeHandler('onboarding:use-codex')).rejects.toThrow('codex not found on PATH'); + expect(probeAuthed).not.toHaveBeenCalled(); + }); + }); + describe('onboarding:complete', () => { it('saves onboarding_completed_at to account store', async () => { await invokeHandler('onboarding:complete'); diff --git a/app/tests/unit/onboarding/OnboardingApp.spec.tsx b/app/tests/unit/onboarding/OnboardingApp.spec.tsx new file mode 100644 index 00000000..673e063e --- /dev/null +++ b/app/tests/unit/onboarding/OnboardingApp.spec.tsx @@ -0,0 +1,166 @@ +// @vitest-environment jsdom + +import React, { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { OnboardingApp } from '../../../src/renderer/onboarding/OnboardingApp'; + +(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +type OnboardingApi = Window['onboardingAPI']; +type InstallResult = Awaited>; + +function installOnboardingApi(overrides: Partial = {}): OnboardingApi { + const api = { + detectChromeProfiles: vi.fn(async () => []), + importChromeProfileCookies: vi.fn(), + listSessionCookies: vi.fn(async () => []), + getChromeProfileSyncs: vi.fn(async () => ({})), + saveApiKey: vi.fn(async () => undefined), + testApiKey: vi.fn(async () => ({ success: true })), + saveOpenAIKey: vi.fn(async () => undefined), + testOpenAIKey: vi.fn(async () => ({ success: true })), + detectClaudeCode: vi.fn(async () => ({ + available: true, + installed: false, + authed: false, + version: null, + subscriptionType: null, + })), + useClaudeCode: vi.fn(async () => ({ subscriptionType: null })), + runClaudeLogin: vi.fn(async () => ({ ok: true })), + openClaudeLoginTerminal: vi.fn(async () => ({ opened: true })), + detectCodex: vi.fn(async () => ({ + available: true, + installed: false, + authed: false, + version: null, + })), + useCodex: vi.fn(async () => ({ ok: true })), + openCodexLoginTerminal: vi.fn(async () => ({ opened: true })), + installEngine: vi.fn(async () => ({ + opened: true, + completed: true, + installed: { installed: false }, + })), + openExternal: vi.fn(async () => ({ opened: true })), + requestNotifications: vi.fn(async () => ({ supported: true })), + platform: 'darwin', + getPlatform: vi.fn(async () => 'darwin'), + listenShortcut: vi.fn(async () => ({ ok: true, accelerator: 'CommandOrControl+Alt+Space' })), + setShortcut: vi.fn(async (accelerator: string) => ({ ok: true, accelerator })), + triggerShortcut: vi.fn(async () => ({ ok: true })), + onShortcutActivated: vi.fn(() => () => undefined), + onTaskSubmitted: vi.fn(() => () => undefined), + onPillShown: vi.fn(() => () => undefined), + onPillHidden: vi.fn(() => () => undefined), + getConsent: vi.fn(async () => ({ telemetry: false, telemetryUpdatedAt: null, version: 1 })), + setTelemetryConsent: vi.fn(async (telemetry: boolean) => ({ telemetry, telemetryUpdatedAt: null, version: 1 })), + capture: vi.fn(), + complete: vi.fn(async () => undefined), + getState: vi.fn(async () => ({ lastStep: 'apikey' })), + setStep: vi.fn(async () => undefined), + whatsapp: { + connect: vi.fn(async () => ({ status: 'connected' })), + disconnect: vi.fn(async () => ({ status: 'disconnected' })), + status: vi.fn(async () => ({ status: 'disconnected', identity: null })), + }, + onWhatsappQr: vi.fn(() => () => undefined), + onChannelStatus: vi.fn(() => () => undefined), + ...overrides, + } satisfies OnboardingApi; + + Object.defineProperty(window, 'onboardingAPI', { + configurable: true, + value: api, + }); + + return api; +} + +function renderOnboarding(): { container: HTMLDivElement; root: Root } { + const container = document.createElement('div'); + document.body.appendChild(container); + const root = createRoot(container); + act(() => { + root.render(); + }); + return { container, root }; +} + +async function flush(times = 4): Promise { + await act(async () => { + for (let i = 0; i < times; i++) await Promise.resolve(); + }); +} + +function buttonByText(container: HTMLElement, text: string): HTMLButtonElement { + const button = Array.from(container.querySelectorAll('button')).find((candidate) => ( + candidate.textContent?.includes(text) + )); + if (!(button instanceof HTMLButtonElement)) throw new Error(`Missing button: ${text}`); + return button; +} + +function deferred(): { promise: Promise; resolve: (value: T) => void } { + let resolve!: (value: T) => void; + const promise = new Promise((innerResolve) => { + resolve = innerResolve; + }); + return { promise, resolve }; +} + +describe('OnboardingApp provider installs', () => { + afterEach(() => { + document.body.innerHTML = ''; + vi.restoreAllMocks(); + }); + + it('keeps each install button pending independently when provider installs overlap', async () => { + const claudeInstall = deferred(); + const codexInstall = deferred(); + const api = installOnboardingApi({ + installEngine: vi.fn((engineId: 'claude-code' | 'codex') => ( + engineId === 'claude-code' ? claudeInstall.promise : codexInstall.promise + )), + }); + const { container, root } = renderOnboarding(); + + await flush(); + expect(container.textContent).toContain('Vendor setup'); + + act(() => { + buttonByText(container, 'Install Claude Code').click(); + }); + await flush(); + + expect(buttonByText(container, 'Installing Claude Code').disabled).toBe(true); + expect(buttonByText(container, 'Install Codex CLI').disabled).toBe(false); + + act(() => { + buttonByText(container, 'Install Codex CLI').click(); + }); + await flush(); + + expect(api.installEngine).toHaveBeenCalledTimes(2); + expect(api.installEngine).toHaveBeenNthCalledWith(1, 'claude-code'); + expect(api.installEngine).toHaveBeenNthCalledWith(2, 'codex'); + expect(buttonByText(container, 'Installing Claude Code').disabled).toBe(true); + expect(buttonByText(container, 'Installing Codex').disabled).toBe(true); + + act(() => root.unmount()); + }); + + it('describes the Codex install button as an automatic background installer', async () => { + installOnboardingApi(); + const { container, root } = renderOnboarding(); + + await flush(); + const codexButton = buttonByText(container, 'Install Codex CLI'); + + expect(codexButton.textContent).toContain('Runs the installer in the background. We\u2019ll detect it when it finishes.'); + expect(codexButton.textContent).not.toContain('npm i -g @openai/codex'); + + act(() => root.unmount()); + }); +}); diff --git a/app/tests/unit/onboardingInstallStatus.test.ts b/app/tests/unit/onboardingInstallStatus.test.ts new file mode 100644 index 00000000..0396b861 --- /dev/null +++ b/app/tests/unit/onboardingInstallStatus.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it, vi } from 'vitest'; +import { pollInstalledStatus } from '../../src/renderer/shared/installStatus'; + +describe('onboarding install status polling', () => { + it('keeps probing after installer completion until the CLI is detected', async () => { + const refreshStatus = vi.fn() + .mockResolvedValueOnce({ installed: false }) + .mockResolvedValueOnce({ installed: false }) + .mockResolvedValueOnce({ installed: true, version: '1.2.3' }); + const wait = vi.fn(async () => undefined); + + const status = await pollInstalledStatus(refreshStatus, { + initialInstalled: { installed: false }, + maxPolls: 5, + intervalMs: 25, + wait, + }); + + expect(status).toEqual({ installed: true, version: '1.2.3' }); + expect(refreshStatus).toHaveBeenCalledTimes(3); + expect(wait).toHaveBeenCalledTimes(2); + expect(wait).toHaveBeenCalledWith(25); + }); + + it('returns null after the bounded retry window without one extra delay', async () => { + const refreshStatus = vi.fn(async () => ({ installed: false })); + const wait = vi.fn(async () => undefined); + + const status = await pollInstalledStatus(refreshStatus, { + maxPolls: 3, + wait, + }); + + expect(status).toBeNull(); + expect(refreshStatus).toHaveBeenCalledTimes(3); + expect(wait).toHaveBeenCalledTimes(2); + }); +}); diff --git a/app/tests/unit/pathEnrich.test.ts b/app/tests/unit/pathEnrich.test.ts index 4f647613..f06a148d 100644 --- a/app/tests/unit/pathEnrich.test.ts +++ b/app/tests/unit/pathEnrich.test.ts @@ -1,7 +1,14 @@ -import { describe, expect, it } from 'vitest'; -import { enrichedPath } from '../../src/main/hl/engines/pathEnrich'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { enrichedPath, resolveCliLaunch } from '../../src/main/hl/engines/pathEnrich'; describe('pathEnrich', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + it('keeps Windows PATH semicolon-delimited and adds common user CLI dirs', () => { const result = enrichedPath('C:\\Windows\\System32;C:\\Tools', { platform: 'win32', @@ -16,6 +23,31 @@ describe('pathEnrich', () => { expect(parts).toContain('C:\\Users\\Ada\\AppData\\Roaming\\npm'); expect(parts).toContain('C:\\Users\\Ada\\.bcode\\bin'); expect(parts).toContain('C:\\Users\\Ada\\.cargo\\bin'); + expect(parts).toContain('C:\\Users\\Ada\\AppData\\Local\\Microsoft\\WindowsApps'); + expect(parts).toContain('C:\\Users\\Ada\\scoop\\shims'); + expect(parts).toContain('C:\\ProgramData\\chocolatey\\bin'); + }); + + it('combines Windows Path and PATH values before adding fallbacks', () => { + const result = enrichedPath(undefined, { + platform: 'win32', + homedir: 'C:\\Users\\Ada', + env: { + Path: 'C:\\Windows\\System32', + PATH: 'C:\\Tools', + APPDATA: 'C:\\Users\\Ada\\AppData\\Roaming', + LOCALAPPDATA: 'C:\\Users\\Ada\\AppData\\Local', + PNPM_HOME: 'C:\\Users\\Ada\\AppData\\Local\\pnpm', + NPM_CONFIG_PREFIX: 'D:\\node-global', + }, + }); + + const parts = result.split(';'); + expect(parts.slice(0, 2)).toEqual(['C:\\Windows\\System32', 'C:\\Tools']); + expect(parts).toContain('C:\\Users\\Ada\\AppData\\Roaming\\npm'); + expect(parts).toContain('C:\\Users\\Ada\\AppData\\Local\\pnpm'); + expect(parts).toContain('D:\\node-global'); + expect(parts).not.toContain('D:\\node-global\\bin'); }); it('uses POSIX delimiters for Linux-style paths', () => { @@ -29,5 +61,95 @@ describe('pathEnrich', () => { expect(parts.slice(0, 2)).toEqual(['/usr/bin', '/bin']); expect(parts).toContain('/home/ada/.local/bin'); expect(parts).toContain('/home/ada/.cargo/bin'); + expect(parts).toContain('/home/ada/.asdf/shims'); + expect(parts).toContain('/home/ada/.local/share/mise/shims'); + expect(parts).toContain('/home/ada/.local/share/pnpm'); + expect(parts).toContain('/snap/bin'); + }); + + it('adds bundled Codex.app CLI locations on macOS', () => { + const result = enrichedPath('/usr/bin:/bin', { + platform: 'darwin', + homedir: '/Users/ada', + env: { SHELL: '/missing-shell' }, + }); + + const parts = result.split(':'); + expect(parts).toContain('/Applications/Codex.app/Contents/Resources'); + expect(parts).toContain('/Users/ada/Applications/Codex.app/Contents/Resources'); + }); + + it('adds existing POSIX Node version-manager bins', () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'path-enrich-')); + try { + const home = path.join(tmp, 'home'); + const nvmBin = path.join(home, '.nvm', 'versions', 'node', 'v22.12.0', 'bin'); + const fnmBin = path.join(home, '.local', 'share', 'fnm', 'node-versions', 'v20.18.1', 'installation', 'bin'); + fs.mkdirSync(nvmBin, { recursive: true }); + fs.mkdirSync(fnmBin, { recursive: true }); + + const result = enrichedPath('/usr/bin', { + platform: 'darwin', + homedir: home, + env: { + SHELL: path.join(tmp, 'missing-shell'), + NPM_CONFIG_PREFIX: path.join(home, '.npm-prefix'), + PNPM_HOME: path.join(home, 'Library', 'pnpm'), + }, + }); + + const parts = result.split(':'); + expect(parts).toContain(nvmBin); + expect(parts).toContain(fnmBin); + expect(parts).toContain(path.join(home, '.npm-prefix', 'bin')); + expect(parts).toContain(path.join(home, 'Library', 'pnpm')); + expect(parts).toContain(path.join(home, '.volta', 'bin')); + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } + }); + + it('routes macOS/Linux CLI launches through a PATH-enriched env without a shell', () => { + const spec = resolveCliLaunch('codex', ['--version'], { + platform: 'linux', + homedir: '/home/ada', + env: { PATH: '/usr/bin' }, + }); + + expect(spec.command).toBe('codex'); + expect(spec.args).toEqual(['--version']); + expect(spec.viaCmdShell).toBe(false); + expect(spec.spawnOptions).toEqual({}); + expect(spec.env.PATH?.split(':')).toEqual(expect.arrayContaining([ + '/usr/bin', + '/home/ada/.local/bin', + '/home/ada/.cargo/bin', + '/home/ada/.asdf/shims', + ])); + }); + + it('routes Windows CLI launches through the same PATH enrichment and shim resolver', () => { + vi.spyOn(fs, 'statSync').mockImplementation((candidate) => ({ + isFile: () => String(candidate).endsWith('codex.cmd'), + isDirectory: () => false, + }) as fs.Stats); + + const spec = resolveCliLaunch('codex', ['login'], { + platform: 'win32', + homedir: 'C:\\Users\\Ada', + env: { + PATH: 'C:\\Users\\Ada\\AppData\\Roaming\\npm', + ComSpec: 'C:\\Windows\\System32\\cmd.exe', + PATHEXT: '.COM;.EXE;.BAT;.CMD', + }, + }); + + expect(spec.command).toBe('C:\\Windows\\System32\\cmd.exe'); + expect(spec.args.slice(0, 3)).toEqual(['/d', '/s', '/c']); + expect(spec.args[3]).toContain('codex.cmd'); + expect(spec.args[3]).toContain('login'); + expect(spec.env.PATH?.split(';')).toContain('C:\\Users\\Ada\\AppData\\Roaming\\npm'); + expect(spec.env.PATH?.split(';')).toContain('C:\\Users\\Ada\\.volta\\bin'); + expect(spec.spawnOptions).toEqual({ windowsVerbatimArguments: true }); }); });