diff --git a/src/utils/antigravity-cli-resolver.ts b/src/utils/antigravity-cli-resolver.ts index dc3466e1..d85adc0e 100644 --- a/src/utils/antigravity-cli-resolver.ts +++ b/src/utils/antigravity-cli-resolver.ts @@ -7,22 +7,37 @@ * @module utils/antigravity-cli-resolver */ -import { execSync } from 'node:child_process'; -import { existsSync } from 'node:fs'; -import { dirname, join } from 'node:path'; +import { join } from 'node:path'; import { homedir } from 'node:os'; -import { EXEC_TIMEOUT_MS } from '../config/exec-timeout.js'; +import { + createCliExecutableResolver, + formatCliNotFoundMessage, + type CliResolverHost, +} from './cli-executable-resolver.js'; /** Common directories where the Antigravity CLI binary may be installed */ const ANTIGRAVITY_SEARCH_DIRS = [ join(homedir(), '.local', 'bin'), join(homedir(), '.antigravity', 'bin'), '/usr/local/bin', + join(homedir(), '.bun', 'bin'), + join(homedir(), '.npm-global', 'bin'), join(homedir(), 'bin'), ]; -/** Cached directory containing the agy binary (empty string = searched but not found) */ -let _antigravityDir: string | null = null; +const ANTIGRAVITY_NOT_FOUND = + 'Antigravity CLI not found. Install with: curl -fsSL https://antigravity.google/cli/install.sh | bash'; + +function createAntigravityResolver(host?: CliResolverHost) { + return createCliExecutableResolver({ binary: 'agy', searchDirs: ANTIGRAVITY_SEARCH_DIRS }, host); +} + +/** Creates an isolated Antigravity wrapper around an injected resolver host. */ +export function createAntigravityResolverForTest(host: CliResolverHost) { + return createAntigravityResolver(host); +} + +const antigravityResolver = createAntigravityResolver(); /** * Finds the directory containing the `agy` binary. @@ -31,30 +46,7 @@ let _antigravityDir: string | null = null; * @returns Directory path, or null if not found */ export function resolveAntigravityDir(): string | null { - if (_antigravityDir !== null) return _antigravityDir || null; - - try { - const result = execSync('which agy', { - encoding: 'utf-8', - timeout: EXEC_TIMEOUT_MS, - }).trim(); - if (result && existsSync(result)) { - _antigravityDir = dirname(result); - return _antigravityDir; - } - } catch { - // agy not in PATH, will check common locations - } - - for (const dir of ANTIGRAVITY_SEARCH_DIRS) { - if (existsSync(join(dir, 'agy'))) { - _antigravityDir = dir; - return _antigravityDir; - } - } - - _antigravityDir = ''; - return null; + return antigravityResolver.resolve()?.directory ?? null; } /** @@ -63,3 +55,7 @@ export function resolveAntigravityDir(): string | null { export function isAntigravityAvailable(): boolean { return resolveAntigravityDir() !== null; } + +export function getAntigravityNotFoundMessage(): string { + return formatCliNotFoundMessage(ANTIGRAVITY_NOT_FOUND, antigravityResolver.diagnostics()); +} diff --git a/src/utils/claude-cli-resolver.ts b/src/utils/claude-cli-resolver.ts index 6e8f1dda..27781c34 100644 --- a/src/utils/claude-cli-resolver.ts +++ b/src/utils/claude-cli-resolver.ts @@ -8,11 +8,11 @@ * @module utils/claude-cli-resolver */ -import { execSync, execFileSync } from 'node:child_process'; -import { existsSync } from 'node:fs'; -import { delimiter, dirname, join } from 'node:path'; +import { execFileSync } from 'node:child_process'; +import { delimiter, join } from 'node:path'; import { homedir } from 'node:os'; import { EXEC_TIMEOUT_MS } from '../config/exec-timeout.js'; +import { createCliExecutableResolver, formatCliNotFoundMessage } from './cli-executable-resolver.js'; /** Common directories where the Claude CLI binary may be installed */ const CLAUDE_SEARCH_DIRS = [ @@ -23,8 +23,8 @@ const CLAUDE_SEARCH_DIRS = [ join(homedir(), 'bin'), ]; -/** Cached directory containing the claude binary (empty string = searched but not found) */ -let _claudeDir: string | null = null; +const claudeResolver = createCliExecutableResolver({ binary: 'claude', searchDirs: CLAUDE_SEARCH_DIRS }); +const CLAUDE_NOT_FOUND = 'Claude CLI not found. Install it with: curl -fsSL https://claude.ai/install.sh | bash'; /** * Returns true if the Claude CLI binary can be located (via `which` or one of @@ -43,29 +43,11 @@ export function isClaudeAvailable(): boolean { * @returns Directory path, or null if not found */ export function findClaudeDir(): string | null { - if (_claudeDir !== null) return _claudeDir || null; + return claudeResolver.resolve()?.directory ?? null; +} - // Try `which` first (respects current PATH) - try { - const result = execSync('which claude', { encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS }).trim(); - if (result && existsSync(result)) { - _claudeDir = dirname(result); - return _claudeDir; - } - } catch { - // Claude not in PATH, will check common locations - } - - // Fallback: check common installation directories - for (const dir of CLAUDE_SEARCH_DIRS) { - if (existsSync(join(dir, 'claude'))) { - _claudeDir = dir; - return _claudeDir; - } - } - - _claudeDir = ''; // mark as searched, not found - return null; +export function getClaudeNotFoundMessage(): string { + return formatCliNotFoundMessage(CLAUDE_NOT_FOUND, claudeResolver.diagnostics()); } /** @@ -99,7 +81,9 @@ export function getAugmentedPath(): string { const currentPath = process.env.PATH || ''; const claudeDir = findClaudeDir(); - if (claudeDir && !currentPath.split(delimiter).includes(claudeDir)) { + if (!claudeDir) return currentPath; + + if (!currentPath.split(delimiter).includes(claudeDir)) { _augmentedPath = `${claudeDir}${delimiter}${currentPath}`; return _augmentedPath; } diff --git a/src/utils/cli-executable-resolver.ts b/src/utils/cli-executable-resolver.ts new file mode 100644 index 00000000..cf1ec7a9 --- /dev/null +++ b/src/utils/cli-executable-resolver.ts @@ -0,0 +1,208 @@ +import { execFileSync } from 'node:child_process'; +import { accessSync, constants, statSync } from 'node:fs'; +import { basename, delimiter, dirname, isAbsolute, join } from 'node:path'; +import { EXEC_TIMEOUT_MS } from '../config/exec-timeout.js'; +import { loginShellArgs, resolveLocalShell } from './shell-resolver.js'; + +const SAFE_BINARY_NAME = /^[a-z0-9][a-z0-9._-]*$/i; +const LOGIN_SHELL_BEGIN_MARKER = '__CODEMAN_CLI_RESOLVE_BEGIN__'; +const LOGIN_SHELL_END_MARKER = '__CODEMAN_CLI_RESOLVE_END__'; +/** Maximum rendered length of each bounded diagnostic field, excluding its label. */ +const DIAGNOSTIC_FIELD_MAX_LENGTH = 1024; + +export type CliResolutionSource = 'process-path' | 'common-directory' | 'login-shell'; + +export interface CliResolutionDiagnostics { + binary: string; + processPath: string; + shellPath: string; + shellArgs: string[]; + searchDirs: string[]; +} + +export interface CliResolverHost { + processPath: string; + shellPath: string; + shellArgs: string[]; + findOnProcessPath(binary: string): string | null; + findInLoginShell(binary: string): string | null; + exists(path: string): boolean; +} + +export interface CandidateValidation { + accepted: boolean; + metadata?: T; +} + +export interface CliResolution { + binaryPath: string; + directory: string; + source: CliResolutionSource; + metadata?: T; +} + +export interface CliExecutableResolver { + resolve(): CliResolution | null; + diagnostics(): CliResolutionDiagnostics; +} + +export interface CliResolverCommandOptions { + encoding: 'utf8'; + timeout: number; + stdio: ['ignore', 'pipe', 'ignore']; +} + +export type CliResolverCommandRunner = (file: string, args: string[], options: CliResolverCommandOptions) => string; + +export interface ProductionCliResolverHostOptions { + processPath?: string; + shellPath?: string; + shellArgs?: string[]; + runCommand?: CliResolverCommandRunner; + isExecutableFile?: (path: string) => boolean; +} + +function isExecutableRegularFile(path: string): boolean { + try { + if (!statSync(path).isFile()) return false; + accessSync(path, constants.X_OK); + return true; + } catch { + return false; + } +} + +function parseLoginShellResult(output: string, binary: string): string | null { + const lines = output.split(/\r?\n/).map((line) => line.trim()); + const begin = lines.indexOf(LOGIN_SHELL_BEGIN_MARKER); + if (begin === -1) return null; + const end = lines.indexOf(LOGIN_SHELL_END_MARKER, begin + 1); + if (end === -1) return null; + + for (const candidate of lines.slice(begin + 1, end)) { + if (isAbsolute(candidate) && basename(candidate) === binary) return candidate; + } + return null; +} + +function loginShellCommand(binary: string): string { + return [ + `printf '%s\\n' '${LOGIN_SHELL_BEGIN_MARKER}'`, + `command -v -- ${binary}`, + `printf '%s\\n' '${LOGIN_SHELL_END_MARKER}'`, + ].join('; '); +} + +export function createProductionCliResolverHost(options: ProductionCliResolverHostOptions = {}): CliResolverHost { + const shellPath = options.shellPath ?? resolveLocalShell(); + const shellArgs = options.shellArgs ?? loginShellArgs(shellPath).trim().split(/\s+/).filter(Boolean); + const processPath = options.processPath ?? process.env.PATH ?? ''; + const isExecutableFile = options.isExecutableFile ?? isExecutableRegularFile; + const runCommand: CliResolverCommandRunner = + options.runCommand ?? ((file, args, commandOptions) => execFileSync(file, args, commandOptions)); + const run = (file: string, args: string[]): string => { + try { + return runCommand(file, args, { + encoding: 'utf8', + timeout: EXEC_TIMEOUT_MS, + stdio: ['ignore', 'pipe', 'ignore'], + }); + } catch { + return ''; + } + }; + + return { + processPath, + shellPath, + shellArgs: [...shellArgs], + findOnProcessPath: (binary) => { + if (!SAFE_BINARY_NAME.test(binary)) return null; + for (const directory of processPath.split(delimiter).filter(Boolean)) { + const candidate = join(directory, binary); + if (isAbsolute(candidate) && isExecutableFile(candidate)) return candidate; + } + return null; + }, + findInLoginShell: (binary) => { + if (!SAFE_BINARY_NAME.test(binary)) return null; + const candidate = parseLoginShellResult(run(shellPath, [...shellArgs, '-c', loginShellCommand(binary)]), binary); + return candidate && isExecutableFile(candidate) ? candidate : null; + }, + exists: isExecutableFile, + }; +} + +export function createCliExecutableResolver( + options: { + binary: string; + searchDirs: string[]; + validateCandidate?: (path: string) => CandidateValidation; + }, + host: CliResolverHost = createProductionCliResolverHost() +): CliExecutableResolver { + if (!SAFE_BINARY_NAME.test(options.binary)) { + throw new Error(`Unsafe CLI binary name: ${options.binary}`); + } + + let cached: CliResolution | null = null; + const accept = (path: string | null, source: CliResolutionSource): CliResolution | null => { + if (!path || !isAbsolute(path) || !host.exists(path)) return null; + const validation = options.validateCandidate?.(path) ?? ({ accepted: true } as CandidateValidation); + if (!validation.accepted) return null; + return { + binaryPath: path, + directory: dirname(path), + source, + metadata: validation.metadata, + }; + }; + + return { + resolve() { + if (cached) return cached; + + cached = accept(host.findOnProcessPath(options.binary), 'process-path'); + if (cached) return cached; + + for (const dir of options.searchDirs) { + cached = accept(join(dir, options.binary), 'common-directory'); + if (cached) return cached; + } + + cached = accept(host.findInLoginShell(options.binary), 'login-shell'); + return cached; + }, + diagnostics: () => ({ + binary: options.binary, + processPath: host.processPath, + shellPath: host.shellPath, + shellArgs: [...host.shellArgs], + searchDirs: [...options.searchDirs], + }), + }; +} + +function sanitizeDiagnosticField(value: string, emptyMarker: string): string { + const flattened = Array.from(value, (character) => { + const codePoint = character.codePointAt(0) ?? 0; + const isControl = codePoint <= 0x1f || (codePoint >= 0x7f && codePoint <= 0x9f); + return isControl || codePoint === 0x2028 || codePoint === 0x2029 ? ' ' : character; + }) + .join('') + .replace(/ +/g, ' ') + .trim(); + if (!flattened) return emptyMarker; + if (flattened.length <= DIAGNOSTIC_FIELD_MAX_LENGTH) return flattened; + return `${flattened.slice(0, DIAGNOSTIC_FIELD_MAX_LENGTH - 1)}…`; +} + +export function formatCliNotFoundMessage(base: string, diagnostics: CliResolutionDiagnostics): string { + const processPath = sanitizeDiagnosticField(diagnostics.processPath, '(empty)'); + const shell = sanitizeDiagnosticField( + [diagnostics.shellPath, ...diagnostics.shellArgs].filter(Boolean).join(' '), + '(none)' + ); + const dirs = sanitizeDiagnosticField(diagnostics.searchDirs.join(', '), '(none)'); + return `${base}\nServer PATH: ${processPath}\nLogin shell: ${shell}\nChecked directories: ${dirs}`; +} diff --git a/src/utils/codex-cli-resolver.ts b/src/utils/codex-cli-resolver.ts index e85fb75c..5cb7221a 100644 --- a/src/utils/codex-cli-resolver.ts +++ b/src/utils/codex-cli-resolver.ts @@ -7,11 +7,9 @@ * @module utils/codex-cli-resolver */ -import { execSync } from 'node:child_process'; -import { existsSync } from 'node:fs'; -import { dirname, join } from 'node:path'; +import { join } from 'node:path'; import { homedir } from 'node:os'; -import { EXEC_TIMEOUT_MS } from '../config/exec-timeout.js'; +import { createCliExecutableResolver, formatCliNotFoundMessage } from './cli-executable-resolver.js'; /** Common directories where the Codex CLI binary may be installed */ const CODEX_SEARCH_DIRS = [ @@ -23,8 +21,8 @@ const CODEX_SEARCH_DIRS = [ join(homedir(), 'bin'), // User bin ]; -/** Cached directory containing the codex binary (empty string = searched but not found) */ -let _codexDir: string | null = null; +const codexResolver = createCliExecutableResolver({ binary: 'codex', searchDirs: CODEX_SEARCH_DIRS }); +const CODEX_NOT_FOUND = 'Codex CLI not found. Install with: npm install -g @openai/codex'; /** * Finds the directory containing the `codex` binary. @@ -34,31 +32,7 @@ let _codexDir: string | null = null; * @returns Directory path, or null if not found */ export function resolveCodexDir(): string | null { - if (_codexDir !== null) return _codexDir || null; - - // Try `which` first (respects current PATH) - try { - const result = execSync('which codex', { - encoding: 'utf-8', - timeout: EXEC_TIMEOUT_MS, - }).trim(); - if (result && existsSync(result)) { - _codexDir = dirname(result); - return _codexDir; - } - } catch { - // Codex not in PATH, will check common locations - } - - for (const dir of CODEX_SEARCH_DIRS) { - if (existsSync(join(dir, 'codex'))) { - _codexDir = dir; - return _codexDir; - } - } - - _codexDir = ''; // mark as searched, not found - return null; + return codexResolver.resolve()?.directory ?? null; } /** @@ -67,3 +41,7 @@ export function resolveCodexDir(): string | null { export function isCodexAvailable(): boolean { return resolveCodexDir() !== null; } + +export function getCodexNotFoundMessage(): string { + return formatCliNotFoundMessage(CODEX_NOT_FOUND, codexResolver.diagnostics()); +} diff --git a/src/utils/gemini-cli-resolver.ts b/src/utils/gemini-cli-resolver.ts index 6f0b5099..45936d60 100644 --- a/src/utils/gemini-cli-resolver.ts +++ b/src/utils/gemini-cli-resolver.ts @@ -7,11 +7,9 @@ * @module utils/gemini-cli-resolver */ -import { execSync } from 'node:child_process'; -import { existsSync } from 'node:fs'; -import { dirname, join } from 'node:path'; +import { join } from 'node:path'; import { homedir } from 'node:os'; -import { EXEC_TIMEOUT_MS } from '../config/exec-timeout.js'; +import { createCliExecutableResolver, formatCliNotFoundMessage } from './cli-executable-resolver.js'; /** Common directories where the Gemini CLI binary may be installed */ const GEMINI_SEARCH_DIRS = [ @@ -23,8 +21,8 @@ const GEMINI_SEARCH_DIRS = [ join(homedir(), 'bin'), ]; -/** Cached directory containing the gemini binary (empty string = searched but not found) */ -let _geminiDir: string | null = null; +const geminiResolver = createCliExecutableResolver({ binary: 'gemini', searchDirs: GEMINI_SEARCH_DIRS }); +const GEMINI_NOT_FOUND = 'Gemini CLI not found. Install with: npm install -g @google/gemini-cli'; /** * Finds the directory containing the `gemini` binary. @@ -33,30 +31,7 @@ let _geminiDir: string | null = null; * @returns Directory path, or null if not found */ export function resolveGeminiDir(): string | null { - if (_geminiDir !== null) return _geminiDir || null; - - try { - const result = execSync('which gemini', { - encoding: 'utf-8', - timeout: EXEC_TIMEOUT_MS, - }).trim(); - if (result && existsSync(result)) { - _geminiDir = dirname(result); - return _geminiDir; - } - } catch { - // Gemini not in PATH, will check common locations - } - - for (const dir of GEMINI_SEARCH_DIRS) { - if (existsSync(join(dir, 'gemini'))) { - _geminiDir = dir; - return _geminiDir; - } - } - - _geminiDir = ''; - return null; + return geminiResolver.resolve()?.directory ?? null; } /** @@ -65,3 +40,7 @@ export function resolveGeminiDir(): string | null { export function isGeminiAvailable(): boolean { return resolveGeminiDir() !== null; } + +export function getGeminiNotFoundMessage(): string { + return formatCliNotFoundMessage(GEMINI_NOT_FOUND, geminiResolver.diagnostics()); +} diff --git a/src/utils/opencode-cli-resolver.ts b/src/utils/opencode-cli-resolver.ts index b144699e..3225dd6d 100644 --- a/src/utils/opencode-cli-resolver.ts +++ b/src/utils/opencode-cli-resolver.ts @@ -7,11 +7,9 @@ * @module utils/opencode-cli-resolver */ -import { execSync } from 'node:child_process'; -import { existsSync } from 'node:fs'; -import { dirname, join } from 'node:path'; +import { join } from 'node:path'; import { homedir } from 'node:os'; -import { EXEC_TIMEOUT_MS } from '../config/exec-timeout.js'; +import { createCliExecutableResolver, formatCliNotFoundMessage } from './cli-executable-resolver.js'; /** Common directories where the OpenCode CLI binary may be installed */ const OPENCODE_SEARCH_DIRS = [ @@ -24,8 +22,8 @@ const OPENCODE_SEARCH_DIRS = [ join(homedir(), 'bin'), // User bin ]; -/** Cached directory containing the opencode binary (empty string = searched but not found) */ -let _openCodeDir: string | null = null; +const openCodeResolver = createCliExecutableResolver({ binary: 'opencode', searchDirs: OPENCODE_SEARCH_DIRS }); +const OPENCODE_NOT_FOUND = 'OpenCode CLI not found. Install with: curl -fsSL https://opencode.ai/install | bash'; /** * Finds the directory containing the `opencode` binary. @@ -35,32 +33,7 @@ let _openCodeDir: string | null = null; * @returns Directory path, or null if not found */ export function resolveOpenCodeDir(): string | null { - if (_openCodeDir !== null) return _openCodeDir || null; - - // Try `which` first (respects current PATH) - try { - const result = execSync('which opencode', { - encoding: 'utf-8', - timeout: EXEC_TIMEOUT_MS, - }).trim(); - if (result && existsSync(result)) { - _openCodeDir = dirname(result); - return _openCodeDir; - } - } catch { - // OpenCode not in PATH, will check common locations - } - - // Fallback: check common installation directories - for (const dir of OPENCODE_SEARCH_DIRS) { - if (existsSync(join(dir, 'opencode'))) { - _openCodeDir = dir; - return _openCodeDir; - } - } - - _openCodeDir = ''; // mark as searched, not found - return null; + return openCodeResolver.resolve()?.directory ?? null; } /** @@ -69,3 +42,7 @@ export function resolveOpenCodeDir(): string | null { export function isOpenCodeAvailable(): boolean { return resolveOpenCodeDir() !== null; } + +export function getOpenCodeNotFoundMessage(): string { + return formatCliNotFoundMessage(OPENCODE_NOT_FOUND, openCodeResolver.diagnostics()); +} diff --git a/src/utils/pi-cli-resolver.ts b/src/utils/pi-cli-resolver.ts index 358fe835..845faa89 100644 --- a/src/utils/pi-cli-resolver.ts +++ b/src/utils/pi-cli-resolver.ts @@ -15,11 +15,15 @@ * @module utils/pi-cli-resolver */ -import { execFileSync, execSync } from 'node:child_process'; -import { existsSync } from 'node:fs'; -import { dirname, join } from 'node:path'; +import { execFileSync } from 'node:child_process'; +import { join } from 'node:path'; import { homedir } from 'node:os'; import { EXEC_TIMEOUT_MS } from '../config/exec-timeout.js'; +import { + createCliExecutableResolver, + formatCliNotFoundMessage, + type CliResolverHost, +} from './cli-executable-resolver.js'; /** Common directories where the Pi CLI binary may be installed */ const PI_SEARCH_DIRS = [ @@ -45,22 +49,15 @@ const PI_SEARCH_DIRS = [ */ export const PI_VERSION_REGEX = /(?:^|\s)(\d+\.\d+\.\d+)/; -/** Cached directory containing the pi binary (empty string = searched but not found) */ -let _piDir: string | null = null; -/** Cached version string reported by the resolved binary (empty string = probed, unusable) */ -let _piVersion: string | null = null; +const PI_NOT_FOUND = 'Pi CLI not found. Install with: npm install -g --ignore-scripts @earendil-works/pi-coding-agent'; /** * Run `pi --version` on a candidate path and return the trimmed version when it * looks like the coding agent. Returns null for anything else — a missing * binary, a non-zero exit, a hang (timeout), or output that is not semver-shaped * (which is how an unrelated `pi` on PATH gets rejected). - * - * Never runs under vitest: the suites must stay hermetic and must not depend on - * whether the dev box happens to have pi installed. */ function probePiVersion(binPath: string): string | null { - if (process.env.VITEST) return null; try { const out = execFileSync(binPath, ['--version'], { encoding: 'utf-8', @@ -77,6 +74,29 @@ function probePiVersion(binPath: string): string | null { return null; } +type PiVersionProbe = (binPath: string) => string | null; + +function createPiResolver(host?: CliResolverHost, versionProbe: PiVersionProbe = probePiVersion) { + return createCliExecutableResolver( + { + binary: 'pi', + searchDirs: PI_SEARCH_DIRS, + validateCandidate: (binPath) => { + const version = versionProbe(binPath); + return version ? { accepted: true, metadata: version } : { accepted: false }; + }, + }, + host + ); +} + +/** Creates an isolated Pi wrapper around an injected host and version probe. */ +export function createPiResolverForTest(host: CliResolverHost, versionProbe: PiVersionProbe) { + return createPiResolver(host, versionProbe); +} + +const piResolver = createPiResolver(); + /** * Finds the directory containing a verified `pi` binary. * Checks `which pi` first, then falls back to common install locations. Every @@ -86,46 +106,7 @@ function probePiVersion(binPath: string): string | null { * @returns Directory path, or null if not found */ export function resolvePiDir(): string | null { - if (_piDir !== null) return _piDir || null; - - const accept = (binPath: string): string | null => { - // Under vitest the probe never runs, so existence alone decides (keeps the - // suites hermetic and matches how the sibling resolvers behave there). - if (process.env.VITEST) { - _piDir = dirname(binPath); - _piVersion = ''; - return _piDir; - } - const version = probePiVersion(binPath); - if (!version) return null; - _piDir = dirname(binPath); - _piVersion = version; - return _piDir; - }; - - try { - const result = execSync('which pi', { - encoding: 'utf-8', - timeout: EXEC_TIMEOUT_MS, - }).trim(); - if (result && existsSync(result)) { - const dir = accept(result); - if (dir) return dir; - } - } catch { - // pi not in PATH, will check common locations - } - - for (const dir of PI_SEARCH_DIRS) { - const binPath = join(dir, 'pi'); - if (!existsSync(binPath)) continue; - const accepted = accept(binPath); - if (accepted) return accepted; - } - - _piDir = ''; - _piVersion = ''; - return null; + return piResolver.resolve()?.directory ?? null; } /** @@ -135,12 +116,14 @@ export function isPiAvailable(): boolean { return resolvePiDir() !== null; } +export function getPiNotFoundMessage(): string { + return formatCliNotFoundMessage(PI_NOT_FOUND, piResolver.diagnostics()); +} + /** - * Version reported by the resolved `pi` binary, or null when pi is unavailable - * (or when the probe was skipped, i.e. under vitest). Surfaced through - * `GET /api/pi/status` so a misresolution is diagnosable from the UI. + * Version reported by the resolved `pi` binary, or null when pi is unavailable. + * Surfaced through `GET /api/pi/status` so a misresolution is diagnosable from the UI. */ export function getPiCliVersion(): string | null { - resolvePiDir(); - return _piVersion || null; + return piResolver.resolve()?.metadata ?? null; } diff --git a/test/antigravity-cli-resolver.test.ts b/test/antigravity-cli-resolver.test.ts new file mode 100644 index 00000000..ee9e97dd --- /dev/null +++ b/test/antigravity-cli-resolver.test.ts @@ -0,0 +1,111 @@ +/** + * @fileoverview Tests for the Antigravity CLI resolver wrapper. + */ +import { homedir } from 'node:os'; +import { join } from 'node:path'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { createAntigravityResolverForTest, isAntigravityAvailable } from '../src/utils/antigravity-cli-resolver.js'; +import type { CliResolution, CliResolverHost } from '../src/utils/cli-executable-resolver.js'; + +const availabilityResolution = vi.hoisted(() => ({ current: null as CliResolution | null })); + +vi.mock('../src/utils/cli-executable-resolver.js', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + createCliExecutableResolver: (options: { binary: string; searchDirs: string[] }, host?: CliResolverHost) => + host + ? actual.createCliExecutableResolver(options, host) + : { + resolve: () => availabilityResolution.current, + diagnostics: () => ({ + binary: options.binary, + processPath: '/service/bin', + shellPath: '/bin/zsh', + shellArgs: ['-l'], + searchDirs: [...options.searchDirs], + }), + }, + }; +}); + +function createHost( + options: { + processPathResult?: string | null; + loginShellResults?: Array; + existingPaths?: string[]; + } = {} +): CliResolverHost { + const loginShellResults = [...(options.loginShellResults ?? [])]; + const existingPaths = new Set(options.existingPaths ?? []); + return { + processPath: '/service/bin', + shellPath: '/bin/zsh', + shellArgs: ['-l'], + findOnProcessPath: () => options.processPathResult ?? null, + findInLoginShell: () => loginShellResults.shift() ?? null, + exists: (path) => existingPaths.has(path), + }; +} + +describe('Antigravity CLI resolver', () => { + beforeEach(() => { + availabilityResolution.current = null; + }); + + it('resolves agy from the service PATH', () => { + const binaryPath = '/service/bin/agy'; + const resolver = createAntigravityResolverForTest( + createHost({ processPathResult: binaryPath, existingPaths: [binaryPath] }) + ); + + expect(resolver.resolve()?.directory).toBe('/service/bin'); + }); + + it('falls back to a common install directory', () => { + const binaryPath = join(homedir(), '.local', 'bin', 'agy'); + const resolver = createAntigravityResolverForTest(createHost({ existingPaths: [binaryPath] })); + + expect(resolver.resolve()?.directory).toBe(join(homedir(), '.local', 'bin')); + }); + + it('resolves agy found only by the login shell', () => { + const binaryPath = '/login-shell/bin/agy'; + const resolver = createAntigravityResolverForTest( + createHost({ loginShellResults: [binaryPath], existingPaths: [binaryPath] }) + ); + + expect(resolver.resolve()?.directory).toBe('/login-shell/bin'); + }); + + it('returns null when agy is unavailable', () => { + const resolver = createAntigravityResolverForTest(createHost()); + + expect(resolver.resolve()).toBeNull(); + }); + + it('retries a failed lookup and caches the first successful login-shell discovery', () => { + const binaryPath = '/late-login-shell/bin/agy'; + const resolver = createAntigravityResolverForTest( + createHost({ loginShellResults: [null, binaryPath], existingPaths: [binaryPath] }) + ); + + expect(resolver.resolve()).toBeNull(); + expect(resolver.resolve()?.binaryPath).toBe(binaryPath); + expect(resolver.resolve()?.binaryPath).toBe(binaryPath); + }); + + it('reports the public wrapper as available when agy resolves', () => { + availabilityResolution.current = { + binaryPath: '/service/bin/agy', + directory: '/service/bin', + source: 'process-path', + }; + + expect(isAntigravityAvailable()).toBe(true); + }); + + it('reports the public wrapper as unavailable when agy does not resolve', () => { + expect(isAntigravityAvailable()).toBe(false); + }); +}); diff --git a/test/cli-executable-resolver.test.ts b/test/cli-executable-resolver.test.ts new file mode 100644 index 00000000..b1fd56cd --- /dev/null +++ b/test/cli-executable-resolver.test.ts @@ -0,0 +1,319 @@ +import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { EXEC_TIMEOUT_MS } from '../src/config/exec-timeout.js'; +import { + createCliExecutableResolver, + createProductionCliResolverHost, + formatCliNotFoundMessage, + type CliResolverHost, +} from '../src/utils/cli-executable-resolver.js'; + +const BEGIN_MARKER = '__CODEMAN_CLI_RESOLVE_BEGIN__'; +const END_MARKER = '__CODEMAN_CLI_RESOLVE_END__'; + +const temporaryDirectories: string[] = []; + +afterEach(() => { + for (const directory of temporaryDirectories.splice(0)) { + rmSync(directory, { recursive: true, force: true }); + } +}); + +function host(overrides: Partial = {}): CliResolverHost { + return { + processPath: '/usr/bin:/bin', + shellPath: '/bin/bash', + shellArgs: ['-i', '-l'], + findOnProcessPath: vi.fn(() => null), + findInLoginShell: vi.fn(() => null), + exists: vi.fn(() => false), + ...overrides, + }; +} + +describe('createCliExecutableResolver', () => { + it('prefers the server process PATH over common directories and the login shell', () => { + const h = host({ + findOnProcessPath: vi.fn(() => '/process/bin/codex'), + findInLoginShell: vi.fn(() => '/shell/bin/codex'), + exists: vi.fn(() => true), + }); + const resolver = createCliExecutableResolver({ binary: 'codex', searchDirs: ['/known/bin'] }, h); + + expect(resolver.resolve()).toMatchObject({ binaryPath: '/process/bin/codex', source: 'process-path' }); + expect(h.exists).toHaveBeenCalledTimes(1); + expect(h.findInLoginShell).not.toHaveBeenCalled(); + }); + + it('prefers common directories in order over the login shell', () => { + const h = host({ + findInLoginShell: vi.fn(() => '/shell/bin/codex'), + exists: vi.fn((path) => path === '/second/bin/codex' || path === '/shell/bin/codex'), + }); + const resolver = createCliExecutableResolver({ binary: 'codex', searchDirs: ['/first/bin', '/second/bin'] }, h); + + expect(resolver.resolve()).toMatchObject({ binaryPath: '/second/bin/codex', source: 'common-directory' }); + expect(h.exists).toHaveBeenNthCalledWith(1, '/first/bin/codex'); + expect(h.exists).toHaveBeenNthCalledWith(2, '/second/bin/codex'); + expect(h.findInLoginShell).not.toHaveBeenCalled(); + }); + + it('finds an executable exposed only by the interactive login shell', () => { + const h = host({ + findInLoginShell: vi.fn(() => '/home/u/.nvm/versions/node/v22/bin/codex'), + exists: vi.fn((path) => path === '/home/u/.nvm/versions/node/v22/bin/codex'), + }); + const resolver = createCliExecutableResolver({ binary: 'codex', searchDirs: ['/known/bin'] }, h); + + expect(resolver.resolve()).toMatchObject({ + binaryPath: '/home/u/.nvm/versions/node/v22/bin/codex', + directory: '/home/u/.nvm/versions/node/v22/bin', + source: 'login-shell', + }); + }); + + it('continues after a validator rejects an earlier candidate', () => { + const h = host({ + findOnProcessPath: vi.fn(() => '/usr/bin/pi'), + findInLoginShell: vi.fn(() => '/home/u/.npm/bin/pi'), + exists: vi.fn(() => true), + }); + const resolver = createCliExecutableResolver( + { + binary: 'pi', + searchDirs: [], + validateCandidate: (path) => + path.includes('.npm') ? { accepted: true, metadata: '0.84.1' } : { accepted: false }, + }, + h + ); + + expect(resolver.resolve()).toMatchObject({ + binaryPath: '/home/u/.npm/bin/pi', + source: 'login-shell', + metadata: '0.84.1', + }); + }); + + it('caches success but retries failure', () => { + const findInLoginShell = vi.fn<() => string | null>().mockReturnValueOnce(null).mockReturnValue('/new/bin/codex'); + const h = host({ findInLoginShell, exists: vi.fn((path) => path === '/new/bin/codex') }); + const resolver = createCliExecutableResolver({ binary: 'codex', searchDirs: [] }, h); + + expect(resolver.resolve()).toBeNull(); + expect(resolver.resolve()?.binaryPath).toBe('/new/bin/codex'); + expect(resolver.resolve()?.binaryPath).toBe('/new/bin/codex'); + expect(findInLoginShell).toHaveBeenCalledTimes(2); + }); + + it('rejects unsafe binary names', () => { + const h = host(); + + expect(() => createCliExecutableResolver({ binary: 'codex;id', searchDirs: [] }, h)).toThrow( + 'Unsafe CLI binary name' + ); + expect(() => createCliExecutableResolver({ binary: '../codex', searchDirs: [] }, h)).toThrow( + 'Unsafe CLI binary name' + ); + }); + + it('rejects relative and nonexistent candidates', () => { + const findInLoginShell = vi + .fn<() => string | null>() + .mockReturnValueOnce('relative/codex') + .mockReturnValue('/missing/codex'); + const h = host({ findInLoginShell, exists: vi.fn(() => false) }); + const resolver = createCliExecutableResolver({ binary: 'codex', searchDirs: [] }, h); + + expect(resolver.resolve()).toBeNull(); + expect(resolver.resolve()).toBeNull(); + expect(h.exists).toHaveBeenCalledTimes(1); + expect(h.exists).toHaveBeenCalledWith('/missing/codex'); + }); +}); + +describe('formatCliNotFoundMessage', () => { + it('includes only the base install hint and bounded resolution diagnostics', () => { + const base = 'Codex CLI not found. Install with: npm install -g @openai/codex'; + const diagnostics = { + binary: 'codex', + processPath: '/usr/bin:/bin', + shellPath: '/bin/bash', + shellArgs: ['-i', '-l'], + searchDirs: ['/home/u/.local/bin', '/usr/local/bin'], + API_KEY: 'super-secret', + }; + const message = formatCliNotFoundMessage(base, diagnostics); + + expect(message).toContain(base); + expect(message).toContain('Server PATH: /usr/bin:/bin'); + expect(message).toContain('Login shell: /bin/bash -i -l'); + expect(message).toContain('Checked directories: /home/u/.local/bin, /usr/local/bin'); + expect(message).not.toContain('API_KEY'); + expect(message).not.toContain('super-secret'); + }); + + it('marks empty diagnostic values without dumping arbitrary environment data', () => { + const message = formatCliNotFoundMessage('Missing CLI', { + binary: 'codex', + processPath: '', + shellPath: '', + shellArgs: [], + searchDirs: [], + }); + + expect(message).toBe('Missing CLI\nServer PATH: (empty)\nLogin shell: (none)\nChecked directories: (none)'); + expect(message).not.toContain('HOME='); + expect(message).not.toContain('TOKEN='); + }); + + it('flattens control characters and bounds every diagnostic field', () => { + const pathological = `first\r\nforged label: value\u0000${'x'.repeat(10_000)}`; + const message = formatCliNotFoundMessage('Missing CLI', { + binary: 'codex', + processPath: pathological, + shellPath: pathological, + shellArgs: [pathological], + searchDirs: [pathological, pathological], + }); + const lines = message.split('\n'); + + expect(lines).toHaveLength(4); + expect(lines[1]).toMatch(/^Server PATH: first forged label: value x+…$/); + expect(lines[2]).toMatch(/^Login shell: first forged label: value x+…$/); + expect(lines[3]).toMatch(/^Checked directories: first forged label: value x+…$/); + expect(lines.slice(1).every((line) => line.length <= 1_050)).toBe(true); + }); +}); + +describe('createProductionCliResolverHost', () => { + it('contains no ambient VITEST branch in the production resolver source', () => { + const source = readFileSync(new URL('../src/utils/cli-executable-resolver.ts', import.meta.url), 'utf8'); + + expect(source).not.toContain('process.env.VITEST'); + }); + + it("resolves through the injected login-shell runner when VITEST is 'false'", () => { + const hadVitest = Object.hasOwn(process.env, 'VITEST'); + const previousVitest = process.env.VITEST; + process.env.VITEST = 'false'; + try { + const runCommand = vi.fn(() => `${BEGIN_MARKER}\n/home/u/.nvm/bin/codex\n${END_MARKER}`); + const productionHost = createProductionCliResolverHost({ + processPath: '', + shellPath: '/bin/bash', + shellArgs: ['-i', '-l'], + runCommand, + isExecutableFile: () => true, + }); + const resolver = createCliExecutableResolver({ binary: 'codex', searchDirs: [] }, productionHost); + + expect(resolver.resolve()).toMatchObject({ + binaryPath: '/home/u/.nvm/bin/codex', + source: 'login-shell', + }); + expect(runCommand).toHaveBeenCalledTimes(1); + } finally { + if (hadVitest) process.env.VITEST = previousVitest; + else delete process.env.VITEST; + } + }); + + it('searches the captured process PATH directly in directory order without running a command', () => { + const runCommand = vi.fn(() => ''); + const isExecutableFile = vi.fn((path: string) => path === '/second/bin/codex'); + const productionHost = createProductionCliResolverHost({ + processPath: '/first/bin:/second/bin:/third/bin', + shellPath: '/bin/bash', + shellArgs: ['-i', '-l'], + runCommand, + isExecutableFile, + }); + + expect(productionHost.findOnProcessPath('codex')).toBe('/second/bin/codex'); + expect(isExecutableFile).toHaveBeenNthCalledWith(1, '/first/bin/codex'); + expect(isExecutableFile).toHaveBeenNthCalledWith(2, '/second/bin/codex'); + expect(runCommand).not.toHaveBeenCalled(); + }); + + it('accepts only executable regular files with the production predicate', () => { + const root = mkdtempSync(join(tmpdir(), 'codeman-cli-resolver-')); + temporaryDirectories.push(root); + const executableDirectory = join(root, 'executable'); + const plainDirectory = join(root, 'plain'); + const directoryCandidate = join(root, 'directory'); + mkdirSync(executableDirectory); + mkdirSync(plainDirectory); + mkdirSync(directoryCandidate); + writeFileSync(join(executableDirectory, 'codex'), '#!/bin/sh\n'); + chmodSync(join(executableDirectory, 'codex'), 0o755); + writeFileSync(join(plainDirectory, 'codex'), '#!/bin/sh\n'); + mkdirSync(join(directoryCandidate, 'codex')); + const productionHost = createProductionCliResolverHost({ + processPath: [directoryCandidate, plainDirectory, executableDirectory].join(':'), + shellPath: '/bin/bash', + shellArgs: ['-i', '-l'], + }); + + expect(productionHost.findOnProcessPath('codex')).toBe(join(executableDirectory, 'codex')); + }); + + it('uses the resolved shell, allowlisted args, tagged command, and bounded timeout', () => { + const runCommand = vi.fn(() => + ['/profile/absolute-noise', BEGIN_MARKER, '/home/u/.nvm/bin/codex', END_MARKER, '/exit-trap/absolute-noise'].join( + '\n' + ) + ); + const productionHost = createProductionCliResolverHost({ + processPath: '', + shellPath: '/usr/bin/fish', + shellArgs: ['-i', '-l'], + runCommand, + isExecutableFile: () => true, + }); + + expect(productionHost.findInLoginShell('codex')).toBe('/home/u/.nvm/bin/codex'); + expect(runCommand).toHaveBeenCalledWith( + '/usr/bin/fish', + ['-i', '-l', '-c', `printf '%s\\n' '${BEGIN_MARKER}'; command -v -- codex; printf '%s\\n' '${END_MARKER}'`], + { + encoding: 'utf8', + timeout: EXEC_TIMEOUT_MS, + stdio: ['ignore', 'pipe', 'ignore'], + } + ); + }); + + it.each([ + ['mismatched basename', `${BEGIN_MARKER}\n/opt/bin/not-codex\n${END_MARKER}`], + ['missing begin marker', `/opt/bin/codex\n${END_MARKER}`], + ['missing end marker', `${BEGIN_MARKER}\n/opt/bin/codex`], + ['absolute output outside markers', `/profile/codex\n${BEGIN_MARKER}\nrelative/codex\n${END_MARKER}\n/exit/codex`], + ])('rejects malformed tagged shell output: %s', (_name, output) => { + const productionHost = createProductionCliResolverHost({ + processPath: '', + shellPath: '/bin/bash', + shellArgs: ['-i', '-l'], + runCommand: () => output, + isExecutableFile: () => true, + }); + + expect(productionHost.findInLoginShell('codex')).toBeNull(); + }); + + it('returns null when the shell command throws', () => { + const productionHost = createProductionCliResolverHost({ + processPath: '', + shellPath: '/bin/bash', + shellArgs: ['-i', '-l'], + runCommand: () => { + throw new Error('exit 1'); + }, + isExecutableFile: () => true, + }); + + expect(productionHost.findInLoginShell('codex')).toBeNull(); + }); +});