fix: add version manager bin paths to startup PATH for GUI-launched apps (#702)

This commit is contained in:
Jinwoo Hong 2026-04-16 02:37:16 -04:00 committed by GitHub
parent e17d917afe
commit ca75b16b33
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 102 additions and 26 deletions

View File

@ -282,7 +282,8 @@ export class CodexAccountService {
private async runCodexLogin(managedHomePath: string): Promise<void> {
await new Promise<void>((resolvePromise, rejectPromise) => {
const child = spawn(resolveCodexCommand(), ['login'], {
const codexCommand = resolveCodexCommand()
const child = spawn(codexCommand, ['login'], {
stdio: ['ignore', 'pipe', 'pipe'],
// Why: on Windows, resolveCodexCommand() may return a .cmd/.bat file
// (e.g. codex.cmd from npm). Node's child_process.spawn cannot execute
@ -325,8 +326,17 @@ export class CodexAccountService {
child.on('error', (error) => {
settle(() => {
const cause = (error as NodeJS.ErrnoException).code === 'ENOENT'
rejectPromise(new Error(cause ? 'Codex CLI not found.' : error.message))
const isEnoent = (error as NodeJS.ErrnoException).code === 'ENOENT'
// Why: ENOENT can mean either the codex binary doesn't exist OR the
// script's shebang interpreter (node) isn't in PATH. When we resolved
// codex to a full path, ENOENT almost certainly means node is missing.
const isBareCommand = codexCommand === 'codex'
const message = isEnoent
? isBareCommand
? 'Codex CLI not found.'
: 'Codex CLI found but could not run — Node.js may not be in your PATH.'
: error.message
rejectPromise(new Error(message))
})
})

View File

@ -2,7 +2,7 @@ import { mkdtempSync, mkdirSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { dirname, join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { resolveClaudeCommand, resolveCodexCommand } from './command'
import { getVersionManagerBinPaths, resolveClaudeCommand, resolveCodexCommand } from './command'
function makeExecutable(path: string): void {
mkdirSync(dirname(path), { recursive: true })
@ -92,6 +92,14 @@ describe('resolveCodexCommand', () => {
expect(resolveCodexCommand({ platform: 'win32', pathEnv: '', homePath: root })).toBe(bunPath)
})
it('finds Codex in mise shims directory', () => {
const root = mkdtempSync(join(tmpdir(), 'orca-codex-command-'))
const misePath = join(root, '.local', 'share', 'mise', 'shims', 'codex')
makeExecutable(misePath)
expect(resolveCodexCommand({ platform: 'linux', pathEnv: '', homePath: root })).toBe(misePath)
})
it('returns the bare command when no filesystem candidate exists', () => {
const root = mkdtempSync(join(tmpdir(), 'orca-codex-command-'))
@ -156,3 +164,35 @@ describe('resolveClaudeCommand', () => {
expect(resolveClaudeCommand({ platform: 'linux', pathEnv: '', homePath: root })).toBe('claude')
})
})
describe('getVersionManagerBinPaths', () => {
it('includes volta, asdf, fnm, mise, pnpm, yarn, and bun directories', () => {
const root = mkdtempSync(join(tmpdir(), 'orca-vm-paths-'))
const paths = getVersionManagerBinPaths({ platform: 'darwin', pathEnv: '', homePath: root })
expect(paths).toContain(join(root, '.volta', 'bin'))
expect(paths).toContain(join(root, '.asdf', 'shims'))
expect(paths).toContain(join(root, '.fnm', 'aliases', 'default', 'bin'))
expect(paths).toContain(join(root, '.local', 'share', 'mise', 'shims'))
expect(paths).toContain(join(root, 'Library', 'pnpm'))
expect(paths).toContain(join(root, '.yarn', 'bin'))
expect(paths).toContain(join(root, '.bun', 'bin'))
})
it('includes nvm bin dir when node versions exist', () => {
const root = mkdtempSync(join(tmpdir(), 'orca-vm-paths-'))
const nodeBin = join(root, '.nvm', 'versions', 'node', 'v22.14.0', 'bin', 'node')
makeExecutable(nodeBin)
const paths = getVersionManagerBinPaths({ platform: 'darwin', pathEnv: '', homePath: root })
expect(paths).toContain(join(root, '.nvm', 'versions', 'node', 'v22.14.0', 'bin'))
})
it('uses platform-specific pnpm path on Linux', () => {
const root = mkdtempSync(join(tmpdir(), 'orca-vm-paths-'))
const paths = getVersionManagerBinPaths({ platform: 'linux', pathEnv: '', homePath: root })
expect(paths).toContain(join(root, '.local', 'share', 'pnpm'))
expect(paths).not.toContain(join(root, 'Library', 'pnpm'))
})
})

View File

@ -71,7 +71,11 @@ function getVersionManagerDirectories(
const directories = [
join(homePath, '.volta', 'bin'),
join(homePath, '.asdf', 'shims'),
join(homePath, '.fnm', 'aliases', 'default', 'bin')
join(homePath, '.fnm', 'aliases', 'default', 'bin'),
// Why: mise (formerly rtx) exposes managed tool binaries via a shims
// directory, similar to asdf. Without this, users who installed node
// or CLI tools through mise can't be found by the fallback probe.
join(homePath, '.local', 'share', 'mise', 'shims')
]
// Why: GUI-launched Electron apps do not inherit shell init from version
@ -141,3 +145,15 @@ export function resolveCodexCommand(options: ResolveCommandOptions = {}): string
export function resolveClaudeCommand(options: ResolveCommandOptions = {}): string {
return resolveCommand('claude', options)
}
// Why: GUI-launched Electron apps inherit a minimal PATH that excludes Node
// version manager directories. CLI tools like codex/claude are Node scripts
// with #!/usr/bin/env node shebangs — they need `node` in PATH to execute,
// not just to be *found*. This function returns the version manager bin paths
// so the caller can augment process.env.PATH at startup.
export function getVersionManagerBinPaths(options: ResolveCommandOptions = {}): string[] {
const platform = options.platform ?? process.platform
const homePath = options.homePath ?? homedir()
const nodeNames = getExecutableNames(platform, 'node')
return getVersionManagerDirectories(platform, homePath, nodeNames)
}

View File

@ -85,25 +85,22 @@ async function fetchViaRpc(options?: FetchCodexRateLimitsOptions): Promise<Provi
let resolved = false
let rpcId = 0
const child = spawn(
resolveCodexCommand(),
['-s', 'read-only', '-a', 'untrusted', 'app-server'],
{
stdio: ['pipe', 'pipe', 'pipe'],
// Why: on Windows, resolveCodexCommand() may return a .cmd/.bat file
// (e.g. codex.cmd from npm). Node's child_process.spawn cannot execute
// batch scripts directly — it needs cmd.exe as an intermediary. Setting
// shell: true on win32 avoids the EINVAL error this would otherwise cause.
shell: process.platform === 'win32',
// Why: the selected Codex rate-limit account must only affect this fetch
// subprocess. Never mutate process.env globally or other Codex features
// would inherit the managed account unintentionally.
env: {
...process.env,
...(options?.codexHomePath ? { CODEX_HOME: options.codexHomePath } : {})
}
const codexCommand = resolveCodexCommand()
const child = spawn(codexCommand, ['-s', 'read-only', '-a', 'untrusted', 'app-server'], {
stdio: ['pipe', 'pipe', 'pipe'],
// Why: on Windows, resolveCodexCommand() may return a .cmd/.bat file
// (e.g. codex.cmd from npm). Node's child_process.spawn cannot execute
// batch scripts directly — it needs cmd.exe as an intermediary. Setting
// shell: true on win32 avoids the EINVAL error this would otherwise cause.
shell: process.platform === 'win32',
// Why: the selected Codex rate-limit account must only affect this fetch
// subprocess. Never mutate process.env globally or other Codex features
// would inherit the managed account unintentionally.
env: {
...process.env,
...(options?.codexHomePath ? { CODEX_HOME: options.codexHomePath } : {})
}
)
})
const timeout = setTimeout(() => {
if (!resolved) {
@ -213,14 +210,19 @@ async function fetchViaRpc(options?: FetchCodexRateLimitsOptions): Promise<Provi
if (!resolved) {
resolved = true
clearTimeout(timeout)
const isNotInstalled = (err as NodeJS.ErrnoException).code === 'ENOENT'
const isEnoent = (err as NodeJS.ErrnoException).code === 'ENOENT'
const isBareCommand = codexCommand === 'codex'
resolve({
provider: 'codex',
session: null,
weekly: null,
updatedAt: Date.now(),
error: isNotInstalled ? 'Codex CLI not found' : err.message,
status: isNotInstalled ? 'unavailable' : 'error'
error: isEnoent
? isBareCommand
? 'Codex CLI not found'
: 'Codex CLI found but could not run — Node.js may not be in your PATH'
: err.message,
status: isEnoent && isBareCommand ? 'unavailable' : 'error'
})
}
})

View File

@ -1,5 +1,6 @@
import { app } from 'electron'
import { join } from 'path'
import { getVersionManagerBinPaths } from '../codex-cli/command'
const DEV_PARENT_SHUTDOWN_GRACE_MS = 3000
@ -53,6 +54,13 @@ export function patchPackagedProcessPath(): void {
extraPaths.push(join(home, '.local/bin'), join(home, '.nix-profile/bin'))
}
// Why: CLI tools installed via Node version managers (nvm, volta, asdf, fnm,
// pnpm, yarn, bun) use #!/usr/bin/env node shebangs that need `node` in PATH.
// resolveCodexCommand() can locate the codex binary in these directories, but
// spawning it still fails if node itself isn't in PATH. Adding version manager
// bin paths here fixes all spawn sites (login, rate limits, usage tracking).
extraPaths.push(...getVersionManagerBinPaths())
const currentPath = process.env.PATH ?? ''
const existing = new Set(currentPath.split(':'))
const missing = extraPaths.filter((path) => !existing.has(path))