mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-10 09:19:42 +02:00
Merge pull request #329 from aakhter/cli-login-shell-resolution
CLIs installed via nvm/Homebrew are not found when Codeman runs as a service
This commit is contained in:
@@ -7,22 +7,37 @@
|
|||||||
* @module utils/antigravity-cli-resolver
|
* @module utils/antigravity-cli-resolver
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { execSync } from 'node:child_process';
|
import { join } from 'node:path';
|
||||||
import { existsSync } from 'node:fs';
|
|
||||||
import { dirname, join } from 'node:path';
|
|
||||||
import { homedir } from 'node:os';
|
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 */
|
/** Common directories where the Antigravity CLI binary may be installed */
|
||||||
const ANTIGRAVITY_SEARCH_DIRS = [
|
const ANTIGRAVITY_SEARCH_DIRS = [
|
||||||
join(homedir(), '.local', 'bin'),
|
join(homedir(), '.local', 'bin'),
|
||||||
join(homedir(), '.antigravity', 'bin'),
|
join(homedir(), '.antigravity', 'bin'),
|
||||||
'/usr/local/bin',
|
'/usr/local/bin',
|
||||||
|
join(homedir(), '.bun', 'bin'),
|
||||||
|
join(homedir(), '.npm-global', 'bin'),
|
||||||
join(homedir(), 'bin'),
|
join(homedir(), 'bin'),
|
||||||
];
|
];
|
||||||
|
|
||||||
/** Cached directory containing the agy binary (empty string = searched but not found) */
|
const ANTIGRAVITY_NOT_FOUND =
|
||||||
let _antigravityDir: string | null = null;
|
'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.
|
* Finds the directory containing the `agy` binary.
|
||||||
@@ -31,30 +46,7 @@ let _antigravityDir: string | null = null;
|
|||||||
* @returns Directory path, or null if not found
|
* @returns Directory path, or null if not found
|
||||||
*/
|
*/
|
||||||
export function resolveAntigravityDir(): string | null {
|
export function resolveAntigravityDir(): string | null {
|
||||||
if (_antigravityDir !== null) return _antigravityDir || null;
|
return antigravityResolver.resolve()?.directory ?? 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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -63,3 +55,7 @@ export function resolveAntigravityDir(): string | null {
|
|||||||
export function isAntigravityAvailable(): boolean {
|
export function isAntigravityAvailable(): boolean {
|
||||||
return resolveAntigravityDir() !== null;
|
return resolveAntigravityDir() !== null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getAntigravityNotFoundMessage(): string {
|
||||||
|
return formatCliNotFoundMessage(ANTIGRAVITY_NOT_FOUND, antigravityResolver.diagnostics());
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,11 +8,11 @@
|
|||||||
* @module utils/claude-cli-resolver
|
* @module utils/claude-cli-resolver
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { execSync, execFileSync } from 'node:child_process';
|
import { execFileSync } from 'node:child_process';
|
||||||
import { existsSync } from 'node:fs';
|
import { delimiter, join } from 'node:path';
|
||||||
import { delimiter, dirname, join } from 'node:path';
|
|
||||||
import { homedir } from 'node:os';
|
import { homedir } from 'node:os';
|
||||||
import { EXEC_TIMEOUT_MS } from '../config/exec-timeout.js';
|
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 */
|
/** Common directories where the Claude CLI binary may be installed */
|
||||||
const CLAUDE_SEARCH_DIRS = [
|
const CLAUDE_SEARCH_DIRS = [
|
||||||
@@ -23,8 +23,8 @@ const CLAUDE_SEARCH_DIRS = [
|
|||||||
join(homedir(), 'bin'),
|
join(homedir(), 'bin'),
|
||||||
];
|
];
|
||||||
|
|
||||||
/** Cached directory containing the claude binary (empty string = searched but not found) */
|
const claudeResolver = createCliExecutableResolver({ binary: 'claude', searchDirs: CLAUDE_SEARCH_DIRS });
|
||||||
let _claudeDir: string | null = null;
|
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
|
* 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
|
* @returns Directory path, or null if not found
|
||||||
*/
|
*/
|
||||||
export function findClaudeDir(): string | null {
|
export function findClaudeDir(): string | null {
|
||||||
if (_claudeDir !== null) return _claudeDir || null;
|
return claudeResolver.resolve()?.directory ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
// Try `which` first (respects current PATH)
|
export function getClaudeNotFoundMessage(): string {
|
||||||
try {
|
return formatCliNotFoundMessage(CLAUDE_NOT_FOUND, claudeResolver.diagnostics());
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -99,7 +81,9 @@ export function getAugmentedPath(): string {
|
|||||||
const currentPath = process.env.PATH || '';
|
const currentPath = process.env.PATH || '';
|
||||||
const claudeDir = findClaudeDir();
|
const claudeDir = findClaudeDir();
|
||||||
|
|
||||||
if (claudeDir && !currentPath.split(delimiter).includes(claudeDir)) {
|
if (!claudeDir) return currentPath;
|
||||||
|
|
||||||
|
if (!currentPath.split(delimiter).includes(claudeDir)) {
|
||||||
_augmentedPath = `${claudeDir}${delimiter}${currentPath}`;
|
_augmentedPath = `${claudeDir}${delimiter}${currentPath}`;
|
||||||
return _augmentedPath;
|
return _augmentedPath;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<T> {
|
||||||
|
accepted: boolean;
|
||||||
|
metadata?: T;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CliResolution<T = undefined> {
|
||||||
|
binaryPath: string;
|
||||||
|
directory: string;
|
||||||
|
source: CliResolutionSource;
|
||||||
|
metadata?: T;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CliExecutableResolver<T = undefined> {
|
||||||
|
resolve(): CliResolution<T> | 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<T = undefined>(
|
||||||
|
options: {
|
||||||
|
binary: string;
|
||||||
|
searchDirs: string[];
|
||||||
|
validateCandidate?: (path: string) => CandidateValidation<T>;
|
||||||
|
},
|
||||||
|
host: CliResolverHost = createProductionCliResolverHost()
|
||||||
|
): CliExecutableResolver<T> {
|
||||||
|
if (!SAFE_BINARY_NAME.test(options.binary)) {
|
||||||
|
throw new Error(`Unsafe CLI binary name: ${options.binary}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
let cached: CliResolution<T> | null = null;
|
||||||
|
const accept = (path: string | null, source: CliResolutionSource): CliResolution<T> | null => {
|
||||||
|
if (!path || !isAbsolute(path) || !host.exists(path)) return null;
|
||||||
|
const validation = options.validateCandidate?.(path) ?? ({ accepted: true } as CandidateValidation<T>);
|
||||||
|
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}`;
|
||||||
|
}
|
||||||
@@ -7,11 +7,9 @@
|
|||||||
* @module utils/codex-cli-resolver
|
* @module utils/codex-cli-resolver
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { execSync } from 'node:child_process';
|
import { join } from 'node:path';
|
||||||
import { existsSync } from 'node:fs';
|
|
||||||
import { dirname, join } from 'node:path';
|
|
||||||
import { homedir } from 'node:os';
|
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 */
|
/** Common directories where the Codex CLI binary may be installed */
|
||||||
const CODEX_SEARCH_DIRS = [
|
const CODEX_SEARCH_DIRS = [
|
||||||
@@ -23,8 +21,8 @@ const CODEX_SEARCH_DIRS = [
|
|||||||
join(homedir(), 'bin'), // User bin
|
join(homedir(), 'bin'), // User bin
|
||||||
];
|
];
|
||||||
|
|
||||||
/** Cached directory containing the codex binary (empty string = searched but not found) */
|
const codexResolver = createCliExecutableResolver({ binary: 'codex', searchDirs: CODEX_SEARCH_DIRS });
|
||||||
let _codexDir: string | null = null;
|
const CODEX_NOT_FOUND = 'Codex CLI not found. Install with: npm install -g @openai/codex';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Finds the directory containing the `codex` binary.
|
* Finds the directory containing the `codex` binary.
|
||||||
@@ -34,31 +32,7 @@ let _codexDir: string | null = null;
|
|||||||
* @returns Directory path, or null if not found
|
* @returns Directory path, or null if not found
|
||||||
*/
|
*/
|
||||||
export function resolveCodexDir(): string | null {
|
export function resolveCodexDir(): string | null {
|
||||||
if (_codexDir !== null) return _codexDir || null;
|
return codexResolver.resolve()?.directory ?? 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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -67,3 +41,7 @@ export function resolveCodexDir(): string | null {
|
|||||||
export function isCodexAvailable(): boolean {
|
export function isCodexAvailable(): boolean {
|
||||||
return resolveCodexDir() !== null;
|
return resolveCodexDir() !== null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getCodexNotFoundMessage(): string {
|
||||||
|
return formatCliNotFoundMessage(CODEX_NOT_FOUND, codexResolver.diagnostics());
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,11 +7,9 @@
|
|||||||
* @module utils/gemini-cli-resolver
|
* @module utils/gemini-cli-resolver
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { execSync } from 'node:child_process';
|
import { join } from 'node:path';
|
||||||
import { existsSync } from 'node:fs';
|
|
||||||
import { dirname, join } from 'node:path';
|
|
||||||
import { homedir } from 'node:os';
|
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 */
|
/** Common directories where the Gemini CLI binary may be installed */
|
||||||
const GEMINI_SEARCH_DIRS = [
|
const GEMINI_SEARCH_DIRS = [
|
||||||
@@ -23,8 +21,8 @@ const GEMINI_SEARCH_DIRS = [
|
|||||||
join(homedir(), 'bin'),
|
join(homedir(), 'bin'),
|
||||||
];
|
];
|
||||||
|
|
||||||
/** Cached directory containing the gemini binary (empty string = searched but not found) */
|
const geminiResolver = createCliExecutableResolver({ binary: 'gemini', searchDirs: GEMINI_SEARCH_DIRS });
|
||||||
let _geminiDir: string | null = null;
|
const GEMINI_NOT_FOUND = 'Gemini CLI not found. Install with: npm install -g @google/gemini-cli';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Finds the directory containing the `gemini` binary.
|
* Finds the directory containing the `gemini` binary.
|
||||||
@@ -33,30 +31,7 @@ let _geminiDir: string | null = null;
|
|||||||
* @returns Directory path, or null if not found
|
* @returns Directory path, or null if not found
|
||||||
*/
|
*/
|
||||||
export function resolveGeminiDir(): string | null {
|
export function resolveGeminiDir(): string | null {
|
||||||
if (_geminiDir !== null) return _geminiDir || null;
|
return geminiResolver.resolve()?.directory ?? 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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -65,3 +40,7 @@ export function resolveGeminiDir(): string | null {
|
|||||||
export function isGeminiAvailable(): boolean {
|
export function isGeminiAvailable(): boolean {
|
||||||
return resolveGeminiDir() !== null;
|
return resolveGeminiDir() !== null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getGeminiNotFoundMessage(): string {
|
||||||
|
return formatCliNotFoundMessage(GEMINI_NOT_FOUND, geminiResolver.diagnostics());
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,11 +7,9 @@
|
|||||||
* @module utils/opencode-cli-resolver
|
* @module utils/opencode-cli-resolver
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { execSync } from 'node:child_process';
|
import { join } from 'node:path';
|
||||||
import { existsSync } from 'node:fs';
|
|
||||||
import { dirname, join } from 'node:path';
|
|
||||||
import { homedir } from 'node:os';
|
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 */
|
/** Common directories where the OpenCode CLI binary may be installed */
|
||||||
const OPENCODE_SEARCH_DIRS = [
|
const OPENCODE_SEARCH_DIRS = [
|
||||||
@@ -24,8 +22,8 @@ const OPENCODE_SEARCH_DIRS = [
|
|||||||
join(homedir(), 'bin'), // User bin
|
join(homedir(), 'bin'), // User bin
|
||||||
];
|
];
|
||||||
|
|
||||||
/** Cached directory containing the opencode binary (empty string = searched but not found) */
|
const openCodeResolver = createCliExecutableResolver({ binary: 'opencode', searchDirs: OPENCODE_SEARCH_DIRS });
|
||||||
let _openCodeDir: string | null = null;
|
const OPENCODE_NOT_FOUND = 'OpenCode CLI not found. Install with: curl -fsSL https://opencode.ai/install | bash';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Finds the directory containing the `opencode` binary.
|
* Finds the directory containing the `opencode` binary.
|
||||||
@@ -35,32 +33,7 @@ let _openCodeDir: string | null = null;
|
|||||||
* @returns Directory path, or null if not found
|
* @returns Directory path, or null if not found
|
||||||
*/
|
*/
|
||||||
export function resolveOpenCodeDir(): string | null {
|
export function resolveOpenCodeDir(): string | null {
|
||||||
if (_openCodeDir !== null) return _openCodeDir || null;
|
return openCodeResolver.resolve()?.directory ?? 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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -69,3 +42,7 @@ export function resolveOpenCodeDir(): string | null {
|
|||||||
export function isOpenCodeAvailable(): boolean {
|
export function isOpenCodeAvailable(): boolean {
|
||||||
return resolveOpenCodeDir() !== null;
|
return resolveOpenCodeDir() !== null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getOpenCodeNotFoundMessage(): string {
|
||||||
|
return formatCliNotFoundMessage(OPENCODE_NOT_FOUND, openCodeResolver.diagnostics());
|
||||||
|
}
|
||||||
|
|||||||
@@ -15,11 +15,15 @@
|
|||||||
* @module utils/pi-cli-resolver
|
* @module utils/pi-cli-resolver
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { execFileSync, execSync } from 'node:child_process';
|
import { execFileSync } from 'node:child_process';
|
||||||
import { existsSync } from 'node:fs';
|
import { join } from 'node:path';
|
||||||
import { dirname, join } from 'node:path';
|
|
||||||
import { homedir } from 'node:os';
|
import { homedir } from 'node:os';
|
||||||
import { EXEC_TIMEOUT_MS } from '../config/exec-timeout.js';
|
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 */
|
/** Common directories where the Pi CLI binary may be installed */
|
||||||
const PI_SEARCH_DIRS = [
|
const PI_SEARCH_DIRS = [
|
||||||
@@ -45,22 +49,15 @@ const PI_SEARCH_DIRS = [
|
|||||||
*/
|
*/
|
||||||
export const PI_VERSION_REGEX = /(?:^|\s)(\d+\.\d+\.\d+)/;
|
export const PI_VERSION_REGEX = /(?:^|\s)(\d+\.\d+\.\d+)/;
|
||||||
|
|
||||||
/** Cached directory containing the pi binary (empty string = searched but not found) */
|
const PI_NOT_FOUND = 'Pi CLI not found. Install with: npm install -g --ignore-scripts @earendil-works/pi-coding-agent';
|
||||||
let _piDir: string | null = null;
|
|
||||||
/** Cached version string reported by the resolved binary (empty string = probed, unusable) */
|
|
||||||
let _piVersion: string | null = null;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Run `pi --version` on a candidate path and return the trimmed version when it
|
* 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
|
* 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
|
* 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).
|
* (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 {
|
function probePiVersion(binPath: string): string | null {
|
||||||
if (process.env.VITEST) return null;
|
|
||||||
try {
|
try {
|
||||||
const out = execFileSync(binPath, ['--version'], {
|
const out = execFileSync(binPath, ['--version'], {
|
||||||
encoding: 'utf-8',
|
encoding: 'utf-8',
|
||||||
@@ -77,6 +74,29 @@ function probePiVersion(binPath: string): string | null {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type PiVersionProbe = (binPath: string) => string | null;
|
||||||
|
|
||||||
|
function createPiResolver(host?: CliResolverHost, versionProbe: PiVersionProbe = probePiVersion) {
|
||||||
|
return createCliExecutableResolver<string>(
|
||||||
|
{
|
||||||
|
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.
|
* Finds the directory containing a verified `pi` binary.
|
||||||
* Checks `which pi` first, then falls back to common install locations. Every
|
* 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
|
* @returns Directory path, or null if not found
|
||||||
*/
|
*/
|
||||||
export function resolvePiDir(): string | null {
|
export function resolvePiDir(): string | null {
|
||||||
if (_piDir !== null) return _piDir || null;
|
return piResolver.resolve()?.directory ?? 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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -135,12 +116,14 @@ export function isPiAvailable(): boolean {
|
|||||||
return resolvePiDir() !== null;
|
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
|
* 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
|
* Surfaced through `GET /api/pi/status` so a misresolution is diagnosable from the UI.
|
||||||
* `GET /api/pi/status` so a misresolution is diagnosable from the UI.
|
|
||||||
*/
|
*/
|
||||||
export function getPiCliVersion(): string | null {
|
export function getPiCliVersion(): string | null {
|
||||||
resolvePiDir();
|
return piResolver.resolve()?.metadata ?? null;
|
||||||
return _piVersion || null;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<typeof import('../src/utils/cli-executable-resolver.js')>();
|
||||||
|
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<string | null>;
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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> = {}): 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user