fix: add version manager bin paths to startup PATH for GUI-launched apps

GUI-launched Electron apps inherit a minimal PATH that excludes Node
version manager directories (nvm, volta, asdf, fnm, pnpm, yarn, bun,
mise). PR #649 fixed *finding* the codex binary in these directories,
but spawning it still failed because the codex script's shebang
(#!/usr/bin/env node) couldn't locate node.

Augment patchPackagedProcessPath() with version manager bin paths so
node is discoverable for all spawn sites (login, rate limits, usage).
Also improve the ENOENT error message to distinguish "CLI not found"
from "CLI found but node missing" and add mise shims support.

Closes #589
This commit is contained in:
Jinwoo-H 2026-04-16 01:46:48 -04:00
parent 53b75f6f16
commit 79084209e7
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))