Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 43 additions & 2 deletions packages/providers/src/detector.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { mkdtemp, mkdir, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { delimiter, join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { findBinary, detectDriver, readAuthStatus, wellKnownDirs } from './detector'
import type { DetectEnvironment } from './types'
Expand Down Expand Up @@ -29,7 +29,9 @@ describe('findBinary', () => {
const binDir = join(dir, 'bin')
await mkdir(binDir, { recursive: true })
await writeFileSafe(join(binDir, 'claude'), '')
const env: DetectEnvironment = { ...makeEnv(), pathEnv: binDir }
// Pinned to posix: on win32 the bare name is not a runnable candidate at
// all, which the cases below cover.
const env: DetectEnvironment = { ...makeEnv(), platform: 'linux', pathEnv: binDir }
expect(findBinary('claude', env)).toBe(join(binDir, 'claude'))
})

Expand All @@ -49,6 +51,45 @@ describe('findBinary', () => {
expect(findBinary('codex', withPath)).toBe(join(install, 'codex.cmd'))
})

it('prefers runnable .exe/.cmd over the extensionless npm shim on win32', async () => {
const npmDir = join(dir, 'npm')
await mkdir(npmDir, { recursive: true })
await writeFileSafe(join(npmDir, 'opencode'), '')
await writeFileSafe(join(npmDir, 'opencode.cmd'), '')
const env: DetectEnvironment = { ...makeEnv(), pathEnv: npmDir }
expect(findBinary('opencode', { ...env, platform: 'win32' })).toBe(join(npmDir, 'opencode.cmd'))
await writeFileSafe(join(npmDir, 'opencode.exe'), '')
expect(findBinary('opencode', { ...env, platform: 'win32' })).toBe(join(npmDir, 'opencode.exe'))
expect(findBinary('opencode', { ...env, platform: 'linux' })).toBe(join(npmDir, 'opencode'))
})

it('skips a bare shim in an earlier PATH entry for a runnable one later', async () => {
// The scan is directory-first, so ranking extensions inside a directory is
// not enough: an unrunnable bare shim earlier on PATH would end the search
// before the .cmd that actually works is ever reached.
const stale = join(dir, 'stale')
const npmDir = join(dir, 'npm-later')
await mkdir(stale, { recursive: true })
await mkdir(npmDir, { recursive: true })
await writeFileSafe(join(stale, 'opencode'), '')
await writeFileSafe(join(npmDir, 'opencode.cmd'), '')
const env: DetectEnvironment = {
...makeEnv(),
platform: 'win32',
pathEnv: [stale, npmDir].join(delimiter),
}
expect(findBinary('opencode', env)).toBe(join(npmDir, 'opencode.cmd'))
})

it('finds nothing on win32 when only the bare shim exists', async () => {
// Honest over hopeful: reporting a path Windows cannot spawn produced a
// provider that looked installed and failed on use.
const npmDir = join(dir, 'bare-only')
await mkdir(npmDir, { recursive: true })
await writeFileSafe(join(npmDir, 'opencode'), '')
expect(findBinary('opencode', { ...makeEnv(), platform: 'win32', pathEnv: npmDir })).toBeNull()
})

it('returns null for missing binaries and for ari-core', () => {
expect(findBinary('hermes', makeEnv())).toBeNull()
expect(findBinary('ari-core', makeEnv())).toBeNull()
Expand Down
14 changes: 12 additions & 2 deletions packages/providers/src/detector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,15 @@ export function wellKnownDirs(env: DetectEnvironment): string[] {
/** Resolves a binary across PATH plus platform-specific install dirs. */
export function findBinary(kind: DriverKind, env: DetectEnvironment): string | null {
if (kind === 'ari-core') return null
const names = BINARY_NAMES[kind]
// npm drops an extensionless sh shim next to the .cmd one, and Windows cannot
// spawn it (ENOENT). Dropping those candidates outright, rather than merely
// ranking them last, is what matters: the search is directory-first, so a
// bare shim in an earlier PATH entry would still beat a runnable .cmd in a
// later one. Nothing is lost — a Windows executable needs its extension.
const names =
env.platform === 'win32'
? BINARY_NAMES[kind].filter((name) => /\.(?:exe|cmd)$/i.test(name)).reverse()
: BINARY_NAMES[kind]
Comment thread
j35dev marked this conversation as resolved.
const searchDirs = [
...env.pathEnv.split(delimiter).filter((p) => p.length > 0),
...wellKnownDirs(env),
Expand All @@ -66,7 +74,9 @@ export function findBinary(kind: DriverKind, env: DetectEnvironment): string | n
* Probes `<binary> --version`. On Windows, .cmd shims are executed through an
* escaped cmd.exe wrapper (direct spawn is refused with EINVAL on Node ≥20).
*/
function probeVersion(binaryPath: string, timeoutMs = 5000): Promise<string | null> {
// Large single-file CLIs (opencode.exe is ~180 MB) can take >5s to answer when
// every provider is probed concurrently at startup, so allow a generous window.
function probeVersion(binaryPath: string, timeoutMs = 15000): Promise<string | null> {
return new Promise((resolve) => {
let stdout = ''
let settled = false
Expand Down
Loading