mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-06 23:49:41 +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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user