fix: add version manager bin paths to startup PATH for GUI-launched apps (#702)
This commit is contained in:
parent
e17d917afe
commit
ca75b16b33
|
|
@ -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))
|
||||
})
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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'))
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
})
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
Loading…
Reference in New Issue