Files
orca/src/main/codex-cli/command.test.ts
T
27a8963806 fix(windows): probe fnm's real default alias dir for Node fallback (#11359)
* fix(windows): probe fnm's real default alias dir for Node fallback

On Windows, fnm stores the default alias under FNM_DIR, else
%APPDATA%\fnm (legacy ~\.fnm), and node.exe sits directly in
aliases\default with no bin subdir. Probe those dirs and append them to
PATH so fnm's default only backfills a missing Node instead of shadowing
an inherited one.

Fixes #11356

Co-authored-by: niceSprite <16523864+iFwu@users.noreply.github.com>
Co-authored-by: Claude <noreply@anthropic.com>

* test: keep onTestFinished callbacks returning void

---------

Co-authored-by: Neil <neil@stably.ai>
Co-authored-by: Claude <noreply@anthropic.com>
2026-10-27 06:08:14 -07:00

445 lines
17 KiB
TypeScript

import { chmodSync, mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { delimiter, dirname, join } from 'node:path'
import { afterEach, describe, expect, it, onTestFinished, vi } from 'vitest'
import {
getVersionManagerBinPaths,
withCliRuntimeOnPath,
resolveClaudeCommand,
resolveCliCommands,
resolveCodexCommand
} from './command'
function makeCommandFixture(prefix: string): string {
const root = mkdtempSync(join(tmpdir(), prefix))
onTestFinished(() => rmSync(root, { recursive: true, force: true }))
return root
}
function makeExecutable(path: string): void {
mkdirSync(dirname(path), { recursive: true })
writeFileSync(path, '')
if (process.platform !== 'win32') {
chmodSync(path, 0o755)
}
}
function makeNonExecutableFile(path: string): void {
mkdirSync(dirname(path), { recursive: true })
writeFileSync(path, '')
if (process.platform !== 'win32') {
chmodSync(path, 0o644)
}
}
describe('resolveCodexCommand', () => {
afterEach(() => {
delete process.env.PATH
delete process.env.Path
})
it('prefers Codex already present on PATH', () => {
const root = makeCommandFixture('orca-codex-command-')
const pathDir = join(root, 'bin')
const commandPath = join(pathDir, 'codex')
makeExecutable(commandPath)
expect(resolveCodexCommand({ platform: 'darwin', pathEnv: pathDir, homePath: root })).toBe(
commandPath
)
})
it.skipIf(process.platform === 'win32')(
'skips non-runnable PATH entries and keeps scanning',
() => {
const root = makeCommandFixture('orca-codex-command-')
const badDir = join(root, 'bad-bin')
const goodDir = join(root, 'good-bin')
const badCommandPath = join(badDir, 'codex')
const goodCommandPath = join(goodDir, 'codex')
makeNonExecutableFile(badCommandPath)
makeExecutable(goodCommandPath)
expect(
resolveCodexCommand({
platform: 'linux',
pathEnv: [badDir, goodDir].join(delimiter),
homePath: root
})
).toBe(goodCommandPath)
}
)
it('skips PATH directories named like the command', () => {
const root = makeCommandFixture('orca-codex-command-')
const badDir = join(root, 'bad-bin')
const goodDir = join(root, 'good-bin')
mkdirSync(join(badDir, 'codex'), { recursive: true })
const goodCommandPath = join(goodDir, 'codex')
makeExecutable(goodCommandPath)
expect(
resolveCodexCommand({
platform: 'linux',
pathEnv: [badDir, goodDir].join(delimiter),
homePath: root
})
).toBe(goodCommandPath)
})
it('falls back to the newest nvm-installed Codex when PATH misses it', () => {
const root = makeCommandFixture('orca-codex-command-')
const v22Path = join(root, '.nvm', 'versions', 'node', 'v22.14.0', 'bin', 'codex')
const v24Path = join(root, '.nvm', 'versions', 'node', 'v24.13.0', 'bin', 'codex')
makeExecutable(v22Path)
makeExecutable(v24Path)
expect(resolveCodexCommand({ platform: 'darwin', pathEnv: '', homePath: root })).toBe(v24Path)
})
it('finds Codex in pnpm global bin on macOS', () => {
const root = makeCommandFixture('orca-codex-command-')
const pnpmPath = join(root, 'Library', 'pnpm', 'codex')
makeExecutable(pnpmPath)
expect(resolveCodexCommand({ platform: 'darwin', pathEnv: '', homePath: root })).toBe(pnpmPath)
})
it('finds Codex in pnpm global bin on Linux', () => {
const root = makeCommandFixture('orca-codex-command-')
const pnpmPath = join(root, '.local', 'share', 'pnpm', 'codex')
makeExecutable(pnpmPath)
expect(resolveCodexCommand({ platform: 'linux', pathEnv: '', homePath: root })).toBe(pnpmPath)
})
it('finds Codex in pnpm global bin on Windows', () => {
const root = makeCommandFixture('orca-codex-command-')
const pnpmPath = join(root, 'AppData', 'Local', 'pnpm', 'codex.cmd')
makeExecutable(pnpmPath)
expect(resolveCodexCommand({ platform: 'win32', pathEnv: '', homePath: root })).toBe(pnpmPath)
})
it('finds Codex in yarn global bin on macOS', () => {
const root = makeCommandFixture('orca-codex-command-')
const yarnPath = join(root, '.yarn', 'bin', 'codex')
makeExecutable(yarnPath)
expect(resolveCodexCommand({ platform: 'darwin', pathEnv: '', homePath: root })).toBe(yarnPath)
})
it('finds Codex in yarn global bin on Windows', () => {
const root = makeCommandFixture('orca-codex-command-')
const yarnPath = join(root, 'AppData', 'Local', 'Yarn', 'bin', 'codex.cmd')
makeExecutable(yarnPath)
expect(resolveCodexCommand({ platform: 'win32', pathEnv: '', homePath: root })).toBe(yarnPath)
})
it('finds Codex in bun global bin', () => {
const root = makeCommandFixture('orca-codex-command-')
const bunPath = join(root, '.bun', 'bin', 'codex')
makeExecutable(bunPath)
expect(resolveCodexCommand({ platform: 'linux', pathEnv: '', homePath: root })).toBe(bunPath)
})
it('finds Codex in bun global bin on Windows', () => {
const root = makeCommandFixture('orca-codex-command-')
const bunPath = join(root, '.bun', 'bin', 'codex.exe')
makeExecutable(bunPath)
expect(resolveCodexCommand({ platform: 'win32', pathEnv: '', homePath: root })).toBe(bunPath)
})
it('finds Codex in mise shims directory', () => {
const root = makeCommandFixture('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 = makeCommandFixture('orca-codex-command-')
expect(resolveCodexCommand({ platform: 'linux', pathEnv: '', homePath: root })).toBe('codex')
})
})
describe('resolveClaudeCommand', () => {
afterEach(() => {
delete process.env.PATH
delete process.env.Path
})
it('prefers claude already present on PATH', () => {
const root = makeCommandFixture('orca-claude-command-')
const pathDir = join(root, 'bin')
const commandPath = join(pathDir, 'claude')
makeExecutable(commandPath)
expect(resolveClaudeCommand({ platform: 'darwin', pathEnv: pathDir, homePath: root })).toBe(
commandPath
)
})
it('falls back to the newest nvm-installed claude when PATH misses it', () => {
const root = makeCommandFixture('orca-claude-command-')
const v22Path = join(root, '.nvm', 'versions', 'node', 'v22.14.0', 'bin', 'claude')
const v24Path = join(root, '.nvm', 'versions', 'node', 'v24.13.0', 'bin', 'claude')
makeExecutable(v22Path)
makeExecutable(v24Path)
expect(resolveClaudeCommand({ platform: 'darwin', pathEnv: '', homePath: root })).toBe(v24Path)
})
it('finds claude in pnpm global bin on macOS', () => {
const root = makeCommandFixture('orca-claude-command-')
const pnpmPath = join(root, 'Library', 'pnpm', 'claude')
makeExecutable(pnpmPath)
expect(resolveClaudeCommand({ platform: 'darwin', pathEnv: '', homePath: root })).toBe(pnpmPath)
})
it('finds claude in yarn global bin', () => {
const root = makeCommandFixture('orca-claude-command-')
const yarnPath = join(root, '.yarn', 'bin', 'claude')
makeExecutable(yarnPath)
expect(resolveClaudeCommand({ platform: 'linux', pathEnv: '', homePath: root })).toBe(yarnPath)
})
it('finds claude in bun global bin', () => {
const root = makeCommandFixture('orca-claude-command-')
const bunPath = join(root, '.bun', 'bin', 'claude')
makeExecutable(bunPath)
expect(resolveClaudeCommand({ platform: 'darwin', pathEnv: '', homePath: root })).toBe(bunPath)
})
it('finds native Windows claude.exe in user-local bin', () => {
const root = makeCommandFixture('orca-claude-command-')
const nativePath = join(root, '.local', 'bin', 'claude.exe')
makeExecutable(nativePath)
expect(resolveClaudeCommand({ platform: 'win32', pathEnv: '', homePath: root })).toBe(
nativePath
)
})
it('returns the bare command when no filesystem candidate exists', () => {
const root = makeCommandFixture('orca-claude-command-')
expect(resolveClaudeCommand({ platform: 'linux', pathEnv: '', homePath: root })).toBe('claude')
})
})
describe('resolveCliCommands', () => {
afterEach(() => {
delete process.env.PATH
delete process.env.Path
})
it('resolves a batch from PATH and install directories', () => {
const root = makeCommandFixture('orca-cli-commands-')
const pathDir = join(root, 'bin')
const pathClaude = join(pathDir, 'claude')
const nvmCodex = join(root, '.nvm', 'versions', 'node', 'v24.13.0', 'bin', 'codex')
const pnpmOpencode = join(root, 'Library', 'pnpm', 'opencode')
makeExecutable(pathClaude)
makeExecutable(nvmCodex)
makeExecutable(pnpmOpencode)
const resolved = resolveCliCommands(['claude', 'codex', 'opencode', 'missing'], {
platform: 'darwin',
pathEnv: pathDir,
homePath: root
})
expect(resolved.get('claude')).toBe(pathClaude)
expect(resolved.get('codex')).toBe(nvmCodex)
expect(resolved.get('opencode')).toBe(pnpmOpencode)
expect(resolved.get('missing')).toBe('missing')
})
it('deduplicates command names in the returned map', () => {
const root = makeCommandFixture('orca-cli-commands-')
const pathDir = join(root, 'bin')
const pathClaude = join(pathDir, 'claude')
makeExecutable(pathClaude)
const resolved = resolveCliCommands(['claude', 'claude'], {
platform: 'linux',
pathEnv: pathDir,
homePath: root
})
expect([...resolved.keys()]).toEqual(['claude'])
expect(resolved.get('claude')).toBe(pathClaude)
})
})
describe('getVersionManagerBinPaths', () => {
it('includes volta, asdf, fnm, mise, pnpm, yarn, and bun directories', () => {
const root = makeCommandFixture('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 = makeCommandFixture('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 = makeCommandFixture('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'))
})
it('includes Windows user-local bin for native CLI installers', () => {
const root = makeCommandFixture('orca-vm-paths-')
const paths = getVersionManagerBinPaths({ platform: 'win32', pathEnv: '', homePath: root })
expect(paths).toContain(join(root, '.local', 'bin'))
expect(paths).toContain(join(root, 'AppData', 'Roaming', 'npm'))
})
it('uses the Windows fnm default alias layout, honoring FNM_DIR (stablyai/orca#11356)', () => {
const root = makeCommandFixture('orca-vm-paths-')
const appDataAlias = join(root, 'AppData', 'Roaming', 'fnm', 'aliases', 'default')
vi.stubEnv('FNM_DIR', '')
vi.stubEnv('APPDATA', '')
onTestFinished(() => {
vi.unstubAllEnvs()
})
const paths = getVersionManagerBinPaths({ platform: 'win32', homePath: root })
expect(paths).toContain(appDataAlias)
expect(paths).not.toContain(join(root, '.fnm', 'aliases', 'default', 'bin'))
const redirectedAppData = join(root, 'redirected')
const redirectedAlias = join(redirectedAppData, 'fnm', 'aliases', 'default')
vi.stubEnv('APPDATA', redirectedAppData)
expect(getVersionManagerBinPaths({ platform: 'win32', homePath: root })).toContain(
redirectedAlias
)
const fnmDir = join(root, 'custom-fnm')
vi.stubEnv('FNM_DIR', fnmDir)
const overridden = getVersionManagerBinPaths({ platform: 'win32', homePath: root })
expect(overridden).toContain(join(fnmDir, 'aliases', 'default'))
expect(overridden).not.toContain(redirectedAlias)
})
})
describe('withCliRuntimeOnPath', () => {
it('pairs a version-manager CLI with its sibling node (stablyai/orca#10932)', () => {
const root = makeCommandFixture('orca-pair-')
const v20 = join(root, '.nvm', 'versions', 'node', 'v20.11.0', 'bin')
const v22 = join(root, '.nvm', 'versions', 'node', 'v22.9.0', 'bin')
makeExecutable(join(v20, 'node'))
makeExecutable(join(v20, 'codex'))
makeExecutable(join(v22, 'node'))
const env = { PATH: [v22, '/usr/bin'].join(delimiter) }
const codex = resolveCodexCommand({ platform: 'darwin', pathEnv: env.PATH, homePath: root })
expect(codex).toBe(join(v20, 'codex'))
const paired = withCliRuntimeOnPath(codex, env, { platform: 'darwin' })
expect(paired.PATH.split(delimiter)[0]).toBe(v20)
})
it('leaves PATH untouched for a CLI whose directory ships no node', () => {
const root = makeCommandFixture('orca-pair-')
const brew = join(root, 'opt', 'homebrew', 'bin')
makeExecutable(join(brew, 'codex'))
const env = { PATH: '/usr/bin' }
expect(withCliRuntimeOnPath(join(brew, 'codex'), env, { platform: 'darwin' })).toBe(env)
})
it('leaves PATH untouched for a compiled CLI beside a node (Homebrew, /usr/bin)', () => {
const root = makeCommandFixture('orca-pair-')
const brew = join(root, 'opt', 'homebrew', 'bin')
makeExecutable(join(brew, 'node'))
makeExecutable(join(brew, 'codex'))
writeFileSync(join(brew, 'codex'), Buffer.from([0xcf, 0xfa, 0xed, 0xfe, 0x07]))
const env = { PATH: '/usr/bin' }
expect(withCliRuntimeOnPath(join(brew, 'codex'), env, { platform: 'darwin' })).toBe(env)
})
it('pairs over the inherited PATH when the env names none, instead of replacing it', () => {
const root = makeCommandFixture('orca-pair-')
const v20 = join(root, '.nvm', 'versions', 'node', 'v20.11.0', 'bin')
makeExecutable(join(v20, 'node'))
makeExecutable(join(v20, 'codex'))
const inherited = process.env.PATH
onTestFinished(() => {
process.env.PATH = inherited
})
process.env.PATH = ['/opt/inherited/bin', '/usr/bin'].join(delimiter)
const noPath: NodeJS.ProcessEnv = {}
const paired = withCliRuntimeOnPath(join(v20, 'codex'), noPath, { platform: 'darwin' })
expect(paired.PATH).toBe([v20, '/opt/inherited/bin', '/usr/bin'].join(delimiter))
})
it('leaves PATH untouched for a bare command name', () => {
const env = { PATH: '/usr/bin' }
expect(withCliRuntimeOnPath('codex', env, { platform: 'darwin' })).toBe(env)
})
it('is a no-op when the runtime directory already leads PATH', () => {
const root = makeCommandFixture('orca-pair-')
const v20 = join(root, '.nvm', 'versions', 'node', 'v20.11.0', 'bin')
makeExecutable(join(v20, 'node'))
makeExecutable(join(v20, 'codex'))
const env = { PATH: [v20, '/usr/bin'].join(delimiter) }
expect(withCliRuntimeOnPath(join(v20, 'codex'), env, { platform: 'darwin' })).toBe(env)
})
it('reads the Windows path key the child will actually use, whatever its casing', () => {
const root = makeCommandFixture('orca-pair-')
const v20 = join(root, '.nvm', 'versions', 'node', 'v20.11.0', 'bin')
makeExecutable(join(v20, 'node.exe'))
makeExecutable(join(v20, 'codex.cmd'))
const env = { path: 'C:\\Windows;C:\\Windows\\System32', HOME: 'x' }
const paired = withCliRuntimeOnPath(join(v20, 'codex.cmd'), env, { platform: 'win32' })
expect(paired.path).toBe([v20, 'C:\\Windows', 'C:\\Windows\\System32'].join(';'))
expect(Object.keys(paired).filter((key) => key.toLowerCase() === 'path')).toEqual(['path'])
})
it('writes the Windows Path key without leaving a differently-cased twin', () => {
const root = makeCommandFixture('orca-pair-')
const v20 = join(root, '.nvm', 'versions', 'node', 'v20.11.0', 'bin')
makeExecutable(join(v20, 'node.exe'))
makeExecutable(join(v20, 'codex.cmd'))
// Seed both casings so removing the duplicate-key cleanup would fail this test.
const env = { Path: 'C:\\Windows;C:\\Windows\\System32', PATH: 'C:\\Stale' }
const paired = withCliRuntimeOnPath(join(v20, 'codex.cmd'), env, { platform: 'win32' })
expect(Object.keys(paired)).toEqual(['Path'])
// Compare the whole PATH to catch drive letters split by the host's delimiter.
expect(paired.Path).toBe([v20, 'C:\\Windows', 'C:\\Windows\\System32'].join(';'))
})
})