diff --git a/packages/providers/src/detector.test.ts b/packages/providers/src/detector.test.ts index a852ac5f..7c4c2ae8 100644 --- a/packages/providers/src/detector.test.ts +++ b/packages/providers/src/detector.test.ts @@ -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' @@ -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')) }) @@ -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() diff --git a/packages/providers/src/detector.ts b/packages/providers/src/detector.ts index b6a23849..e206d1ab 100644 --- a/packages/providers/src/detector.ts +++ b/packages/providers/src/detector.ts @@ -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] const searchDirs = [ ...env.pathEnv.split(delimiter).filter((p) => p.length > 0), ...wellKnownDirs(env), @@ -66,7 +74,9 @@ export function findBinary(kind: DriverKind, env: DetectEnvironment): string | n * Probes ` --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 { +// 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 { return new Promise((resolve) => { let stdout = '' let settled = false