agent-ecosystem/src/main/services/team/ClaudeBinaryResolver.ts
iliya 0cb85d463c feat: enhance project editor with new error boundary and file handling improvements
- Introduced `EditorErrorBoundary` component to catch runtime errors in CodeMirror, providing a fallback UI to prevent crashes.
- Updated file handling logic to utilize `isbinaryfile` for more reliable binary detection, replacing manual null-byte scans.
- Enhanced `openFile` method to prevent duplicate tabs for already opened files.
- Improved documentation for new components and updated file lists to reflect recent changes.
- Optimized file watcher and path validation processes for better performance and reliability.
2026-02-28 17:55:21 +02:00

214 lines
6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { getHomeDir } from '@main/utils/pathDecoder';
import * as fs from 'fs';
import * as path from 'path';
async function isExecutable(filePath: string): Promise<boolean> {
if (process.platform === 'win32') {
try {
const stat = await fs.promises.stat(filePath);
return stat.isFile();
} catch {
return false;
}
}
try {
await fs.promises.access(filePath, fs.constants.X_OK);
return true;
} catch {
return false;
}
}
function stripSurroundingQuotes(value: string): string {
const trimmed = value.trim();
if (trimmed.length >= 2 && trimmed.startsWith('"') && trimmed.endsWith('"')) {
return trimmed.slice(1, -1);
}
return trimmed;
}
function getWindowsExecutableExtensions(): string[] {
const raw = process.env.PATHEXT;
if (!raw) {
return ['.exe', '.cmd', '.bat', '.com'];
}
const exts = raw
.split(';')
.map((ext) => ext.trim())
.filter((ext) => ext.length > 0)
.map((ext) => (ext.startsWith('.') ? ext : `.${ext}`))
.map((ext) => ext.toLowerCase());
return Array.from(new Set(exts));
}
function expandWindowsBinaryNames(binaryName: string): string[] {
const trimmed = binaryName.trim();
if (!trimmed) {
return [];
}
const ext = path.extname(trimmed);
if (ext) {
return [trimmed];
}
const exts = getWindowsExecutableExtensions();
const withExt = exts.map((e) => `${trimmed}${e}`);
return [...withExt, trimmed];
}
async function collectNvmCandidates(): Promise<string[]> {
const nvmNodeRoot = path.join(getHomeDir(), '.nvm', 'versions', 'node');
let versions: string[];
try {
versions = await fs.promises.readdir(nvmNodeRoot);
} catch {
return [];
}
return versions
.map((version) => path.join(nvmNodeRoot, version, 'bin', 'claude'))
.sort((a, b) => a.localeCompare(b))
.reverse();
}
async function resolveFromPathEnv(binaryName: string): Promise<string | null> {
const rawPath = process.env.PATH;
if (!rawPath) {
return null;
}
const pathParts = rawPath.split(path.delimiter);
const binaryNames =
process.platform === 'win32' ? expandWindowsBinaryNames(binaryName) : [binaryName];
// Check all PATH directories in parallel. Each directory checks all extension
// variants concurrently. This turns N_dirs × N_exts sequential stat() calls
// into a single parallel batch, dramatically reducing startup time on Windows.
const dirResults = await Promise.all(
pathParts.map(async (part) => {
if (!part) return null;
const cleanedPart = stripSurroundingQuotes(part);
if (!cleanedPart) return null;
const candidates = binaryNames.map((name) => path.join(cleanedPart, name));
const results = await Promise.all(
candidates.map(async (candidate) => ({
path: candidate,
ok: await isExecutable(candidate),
}))
);
// Return the first matching extension variant within this directory
return results.find((r) => r.ok)?.path ?? null;
})
);
// Return first non-null result, preserving PATH priority order
return dirResults.find((r) => r !== null) ?? null;
}
async function resolveFromExplicitPath(inputPath: string): Promise<string | null> {
const trimmed = inputPath.trim();
if (!trimmed) {
return null;
}
if (await isExecutable(trimmed)) {
return trimmed;
}
if (process.platform !== 'win32') {
return null;
}
if (path.extname(trimmed)) {
return null;
}
for (const ext of getWindowsExecutableExtensions()) {
const candidate = `${trimmed}${ext}`;
if (await isExecutable(candidate)) {
return candidate;
}
}
return null;
}
let cachedPath: string | null | undefined;
export class ClaudeBinaryResolver {
/**
* Clear the cached binary path.
* Call after CLI install/update so the next resolve() picks up the new location.
*/
static clearCache(): void {
cachedPath = undefined;
}
static async resolve(): Promise<string | null> {
if (cachedPath !== undefined) return cachedPath;
const overrideRaw = process.env.CLAUDE_CLI_PATH?.trim();
if (overrideRaw) {
const looksLikePath =
path.isAbsolute(overrideRaw) || overrideRaw.includes('\\') || overrideRaw.includes('/');
const resolvedOverride = looksLikePath
? await resolveFromExplicitPath(overrideRaw)
: await resolveFromPathEnv(overrideRaw);
if (resolvedOverride) {
cachedPath = resolvedOverride;
return cachedPath;
}
}
const baseBinaryName = 'claude';
const fromPath = await resolveFromPathEnv(baseBinaryName);
if (fromPath) {
cachedPath = fromPath;
return cachedPath;
}
const platformBinaryNames =
process.platform === 'win32' ? expandWindowsBinaryNames(baseBinaryName) : [baseBinaryName];
const candidateDirs: string[] = [
// Native binary installation path (claude install)
path.join(getHomeDir(), '.local', 'bin'),
path.join(getHomeDir(), '.npm-global', 'bin'),
path.join(getHomeDir(), '.npm', 'bin'),
process.platform === 'win32'
? path.join(getHomeDir(), 'AppData', 'Roaming', 'npm')
: '/usr/local/bin',
process.platform === 'win32' ? '' : '/opt/homebrew/bin',
].filter((candidate) => candidate.length > 0);
const candidates = candidateDirs.flatMap((dir) =>
platformBinaryNames.map((name) => path.join(dir, name))
);
const nvmCandidates = process.platform === 'win32' ? [] : await collectNvmCandidates();
const allCandidates = [...candidates, ...nvmCandidates];
// Check all fallback candidates in parallel for speed
const results = await Promise.all(
allCandidates.map(async (candidate) => ({
path: candidate,
ok: await isExecutable(candidate),
}))
);
// Return first match, preserving candidate priority order
const found = results.find((r) => r.ok);
if (found) {
cachedPath = found.path;
return cachedPath;
}
// Don't cache null — CLI may be installed later without app restart
return null;
}
}