mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39: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
|
||||
*/
|
||||
|
||||
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());
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
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());
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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<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.
|
||||
* 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;
|
||||
}
|
||||
|
||||
@@ -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