From ce4fa4fda0ac106965be4bf0ee576ac7b5fa1dd2 Mon Sep 17 00:00:00 2001 From: Reagan Hsu Date: Thu, 7 May 2026 12:50:27 -0700 Subject: [PATCH] Make provider onboarding depend on real CLI availability Codex and Claude onboarding now separate CLI installation from authentication, so missing CLIs show install actions instead of sign-in prompts. CLI launches use one high-level router that enriches GUI PATHs on macOS/Linux/Windows, includes Codex.app bundle locations on macOS, and resolves Windows npm shims before spawning. Installer actions now run in the background and resolve from the installer process close event. The result includes completion status, exit code/signal, output tails, and a post-exit install probe; onboarding keeps polling after installer completion so delayed CLI detection still updates the UI. Constraint: Electron GUI launches can miss user shell PATH entries, Codex.app can ship its CLI inside the macOS app bundle, and Windows npm-installed CLIs often resolve to .cmd or .ps1 shims. Rejected: Keep per-call Windows launch fixes | leaves Codex PTY login and future CLI calls easy to miss. Rejected: Poll with fixed timers after install start | does not distinguish slow installs, failed installs, or hung installers. Confidence: high Scope-risk: moderate Tested: npm run test Tested: task typecheck Tested: npm run lint -- --quiet Tested: git diff --check origin/main...HEAD Not-tested: Manual Windows/Linux Electron installer flow on physical machines Co-authored-by: Rich --- app/src/main/hl/engines/cliSpawn.ts | 7 +- app/src/main/hl/engines/installer.ts | 263 ++++++++++-------- app/src/main/hl/engines/pathEnrich.ts | 178 ++++++++++-- app/src/main/identity/codexLogin.ts | 34 ++- app/src/main/identity/onboardingHandlers.ts | 20 +- app/src/main/index.ts | 14 +- app/src/preload/onboarding.ts | 14 + app/src/preload/pill.ts | 13 +- app/src/preload/shell.ts | 13 +- app/src/renderer/globals.d.ts | 13 +- app/src/renderer/hub/ConnectionsPane.tsx | 54 ++-- app/src/renderer/hub/EnginePicker.tsx | 22 +- app/src/renderer/onboarding/OnboardingApp.tsx | 236 +++++++++++----- app/src/renderer/shared/installStatus.ts | 42 +++ app/tests/unit/hl/installer.test.ts | 94 +++++++ app/tests/unit/hub/ConnectionsPane.spec.tsx | 113 ++++++++ app/tests/unit/hub/EnginePicker.spec.tsx | 116 ++++++++ app/tests/unit/identity/codexLogin.test.ts | 28 ++ .../unit/identity/onboardingHandlers.test.ts | 53 ++++ .../unit/onboarding/OnboardingApp.spec.tsx | 166 +++++++++++ .../unit/onboardingInstallStatus.test.ts | 38 +++ app/tests/unit/pathEnrich.test.ts | 126 ++++++++- 22 files changed, 1402 insertions(+), 255 deletions(-) create mode 100644 app/src/renderer/shared/installStatus.ts create mode 100644 app/tests/unit/hl/installer.test.ts create mode 100644 app/tests/unit/identity/codexLogin.test.ts create mode 100644 app/tests/unit/onboarding/OnboardingApp.spec.tsx create mode 100644 app/tests/unit/onboardingInstallStatus.test.ts 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 }); }); });