diff --git a/apps/frontend/src/main/__tests__/cli-tool-manager.test.ts b/apps/frontend/src/main/__tests__/cli-tool-manager.test.ts index 45bfad47..b39c588a 100644 --- a/apps/frontend/src/main/__tests__/cli-tool-manager.test.ts +++ b/apps/frontend/src/main/__tests__/cli-tool-manager.test.ts @@ -8,7 +8,13 @@ import { existsSync, readdirSync } from 'fs'; import os from 'os'; import { execFileSync } from 'child_process'; import { app } from 'electron'; -import { getToolInfo, clearToolCache } from '../cli-tool-manager'; +import { + getToolInfo, + clearToolCache, + getClaudeDetectionPaths, + sortNvmVersionDirs, + buildClaudeDetectionResult +} from '../cli-tool-manager'; // Mock Electron app vi.mock('electron', () => ({ @@ -42,9 +48,10 @@ vi.mock('fs', () => { }; }); -// Mock child_process for execFileSync (used in validation) +// Mock child_process for execFileSync and execFile (used in validation) vi.mock('child_process', () => ({ - execFileSync: vi.fn() + execFileSync: vi.fn(), + execFile: vi.fn() })); // Mock env-utils to avoid PATH augmentation complexity @@ -312,3 +319,151 @@ describe('cli-tool-manager - Claude CLI NVM detection', () => { }); }); }); + +/** + * Unit tests for helper functions + */ +describe('cli-tool-manager - Helper Functions', () => { + describe('getClaudeDetectionPaths', () => { + it('should return homebrew paths on macOS', () => { + Object.defineProperty(process, 'platform', { + value: 'darwin', + writable: true + }); + + const paths = getClaudeDetectionPaths('/Users/test'); + + expect(paths.homebrewPaths).toContain('/opt/homebrew/bin/claude'); + expect(paths.homebrewPaths).toContain('/usr/local/bin/claude'); + }); + + it('should return Windows paths on win32', () => { + Object.defineProperty(process, 'platform', { + value: 'win32', + writable: true + }); + + const paths = getClaudeDetectionPaths('C:\\Users\\test'); + + // Windows paths should include AppData and Program Files + expect(paths.platformPaths.some(p => p.includes('AppData'))).toBe(true); + expect(paths.platformPaths.some(p => p.includes('Program Files'))).toBe(true); + }); + + it('should return Unix paths on Linux', () => { + Object.defineProperty(process, 'platform', { + value: 'linux', + writable: true + }); + + const paths = getClaudeDetectionPaths('/home/test'); + + expect(paths.platformPaths.some(p => p.includes('.local/bin/claude'))).toBe(true); + expect(paths.platformPaths.some(p => p.includes('bin/claude'))).toBe(true); + }); + + it('should return correct NVM versions directory', () => { + const paths = getClaudeDetectionPaths('/home/test'); + + expect(paths.nvmVersionsDir).toBe('/home/test/.nvm/versions/node'); + }); + }); + + describe('sortNvmVersionDirs', () => { + it('should sort versions in descending order (newest first)', () => { + const entries = [ + { name: 'v18.20.0', isDirectory: () => true }, + { name: 'v22.17.0', isDirectory: () => true }, + { name: 'v20.11.0', isDirectory: () => true } + ]; + + const sorted = sortNvmVersionDirs(entries); + + expect(sorted).toEqual(['v22.17.0', 'v20.11.0', 'v18.20.0']); + }); + + it('should filter out non-version directories', () => { + const entries = [ + { name: 'v20.11.0', isDirectory: () => true }, + { name: '.DS_Store', isDirectory: () => false }, + { name: 'node_modules', isDirectory: () => true }, + { name: 'current', isDirectory: () => true }, + { name: 'v22.17.0', isDirectory: () => true } + ]; + + const sorted = sortNvmVersionDirs(entries); + + expect(sorted).toEqual(['v22.17.0', 'v20.11.0']); + expect(sorted).not.toContain('.DS_Store'); + expect(sorted).not.toContain('node_modules'); + expect(sorted).not.toContain('current'); + }); + + it('should return empty array when no valid versions', () => { + const entries = [ + { name: 'current', isDirectory: () => true }, + { name: 'system', isDirectory: () => true } + ]; + + const sorted = sortNvmVersionDirs(entries); + + expect(sorted).toEqual([]); + }); + + it('should handle single entry', () => { + const entries = [{ name: 'v20.11.0', isDirectory: () => true }]; + + const sorted = sortNvmVersionDirs(entries); + + expect(sorted).toEqual(['v20.11.0']); + }); + + it('should handle empty array', () => { + const sorted = sortNvmVersionDirs([]); + + expect(sorted).toEqual([]); + }); + }); + + describe('buildClaudeDetectionResult', () => { + it('should return null when validation fails', () => { + const result = buildClaudeDetectionResult( + '/path/to/claude', + { valid: false, message: 'Invalid CLI' }, + 'nvm', + 'Found via NVM' + ); + + expect(result).toBeNull(); + }); + + it('should return proper result when validation succeeds', () => { + const result = buildClaudeDetectionResult( + '/path/to/claude', + { valid: true, version: '1.0.0', message: 'Valid' }, + 'nvm', + 'Found via NVM' + ); + + expect(result).not.toBeNull(); + expect(result?.found).toBe(true); + expect(result?.path).toBe('/path/to/claude'); + expect(result?.version).toBe('1.0.0'); + expect(result?.source).toBe('nvm'); + expect(result?.message).toContain('Found via NVM'); + expect(result?.message).toContain('/path/to/claude'); + }); + + it('should include path in message', () => { + const result = buildClaudeDetectionResult( + '/home/user/.nvm/versions/node/v22.17.0/bin/claude', + { valid: true, version: '2.0.0', message: 'OK' }, + 'nvm', + 'Detected Claude CLI' + ); + + expect(result?.message).toContain('Detected Claude CLI'); + expect(result?.message).toContain('/home/user/.nvm/versions/node/v22.17.0/bin/claude'); + }); + }); +}); diff --git a/apps/frontend/src/main/__tests__/env-handlers-claude-cli.test.ts b/apps/frontend/src/main/__tests__/env-handlers-claude-cli.test.ts index a0d3caf3..bbcbdc35 100644 --- a/apps/frontend/src/main/__tests__/env-handlers-claude-cli.test.ts +++ b/apps/frontend/src/main/__tests__/env-handlers-claude-cli.test.ts @@ -4,6 +4,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { IPC_CHANNELS } from '../../shared/constants'; const { mockGetClaudeCliInvocation, + mockGetClaudeCliInvocationAsync, mockGetProject, spawnMock, mockIpcMain, @@ -22,6 +23,7 @@ const { return { mockGetClaudeCliInvocation: vi.fn(), + mockGetClaudeCliInvocationAsync: vi.fn(), mockGetProject: vi.fn(), spawnMock: vi.fn(), mockIpcMain: ipcMain, @@ -30,6 +32,7 @@ const { vi.mock('../claude-cli-utils', () => ({ getClaudeCliInvocation: mockGetClaudeCliInvocation, + getClaudeCliInvocationAsync: mockGetClaudeCliInvocationAsync, })); vi.mock('../project-store', () => ({ @@ -64,9 +67,15 @@ function createProc(): EventEmitter & { stdout?: EventEmitter; stderr?: EventEmi return proc; } +// Helper to flush all pending promises (needed for async mock resolution) +function flushPromises(): Promise { + return new Promise(resolve => setTimeout(resolve, 0)); +} + describe('env-handlers Claude CLI usage', () => { beforeEach(() => { mockGetClaudeCliInvocation.mockReset(); + mockGetClaudeCliInvocationAsync.mockReset(); mockGetProject.mockReset(); spawnMock.mockReset(); }); @@ -74,7 +83,7 @@ describe('env-handlers Claude CLI usage', () => { it('uses resolved Claude CLI path/env for auth checks', async () => { const claudeEnv = { PATH: '/opt/claude/bin:/usr/bin' }; const command = '/opt/claude/bin/claude'; - mockGetClaudeCliInvocation.mockReturnValue({ + mockGetClaudeCliInvocationAsync.mockResolvedValue({ command, env: claudeEnv, }); @@ -94,6 +103,8 @@ describe('env-handlers Claude CLI usage', () => { } const resultPromise = handler({}, 'p1'); + // Wait for async CLI resolution before checking spawn + await flushPromises(); expect(spawnMock).toHaveBeenCalledTimes(1); expect(spawnMock).toHaveBeenCalledWith( command, @@ -120,7 +131,7 @@ describe('env-handlers Claude CLI usage', () => { it('uses resolved Claude CLI path/env for setup-token', async () => { const claudeEnv = { PATH: '/opt/claude/bin:/usr/bin' }; const command = '/opt/claude/bin/claude'; - mockGetClaudeCliInvocation.mockReturnValue({ + mockGetClaudeCliInvocationAsync.mockResolvedValue({ command, env: claudeEnv, }); @@ -136,6 +147,8 @@ describe('env-handlers Claude CLI usage', () => { } const resultPromise = handler({}, 'p2'); + // Wait for async CLI resolution before checking spawn + await flushPromises(); expect(spawnMock).toHaveBeenCalledWith( command, ['setup-token'], @@ -153,9 +166,7 @@ describe('env-handlers Claude CLI usage', () => { }); it('returns an error when Claude CLI resolution throws', async () => { - mockGetClaudeCliInvocation.mockImplementation(() => { - throw new Error('Claude CLI exploded'); - }); + mockGetClaudeCliInvocationAsync.mockRejectedValue(new Error('Claude CLI exploded')); mockGetProject.mockReturnValue({ id: 'p3', path: '/tmp/project' }); registerEnvHandlers(() => null); @@ -171,7 +182,7 @@ describe('env-handlers Claude CLI usage', () => { }); it('returns an error when Claude CLI command is missing', async () => { - mockGetClaudeCliInvocation.mockReturnValue({ command: '', env: {} }); + mockGetClaudeCliInvocationAsync.mockResolvedValue({ command: '', env: {} }); mockGetProject.mockReturnValue({ id: 'p4', path: '/tmp/project' }); registerEnvHandlers(() => null); @@ -189,7 +200,7 @@ describe('env-handlers Claude CLI usage', () => { it('returns an error when Claude CLI exits with a non-zero code', async () => { const claudeEnv = { PATH: '/opt/claude/bin:/usr/bin' }; const command = '/opt/claude/bin/claude'; - mockGetClaudeCliInvocation.mockReturnValue({ + mockGetClaudeCliInvocationAsync.mockResolvedValue({ command, env: claudeEnv, }); @@ -205,6 +216,8 @@ describe('env-handlers Claude CLI usage', () => { } const resultPromise = handler({}, 'p5'); + // Wait for async CLI resolution before checking spawn + await flushPromises(); expect(spawnMock).toHaveBeenCalledWith( command, ['--version'], diff --git a/apps/frontend/src/main/claude-cli-utils.ts b/apps/frontend/src/main/claude-cli-utils.ts index 3d83a555..49a0c49c 100644 --- a/apps/frontend/src/main/claude-cli-utils.ts +++ b/apps/frontend/src/main/claude-cli-utils.ts @@ -1,6 +1,6 @@ import path from 'path'; -import { getAugmentedEnv } from './env-utils'; -import { getToolPath } from './cli-tool-manager'; +import { getAugmentedEnv, getAugmentedEnvAsync } from './env-utils'; +import { getToolPath, getToolPathAsync } from './cli-tool-manager'; export type ClaudeCliInvocation = { command: string; @@ -37,6 +37,9 @@ function ensureCommandDirInPath(command: string, env: Record): R /** * Returns the Claude CLI command path and an environment with PATH updated to include the CLI directory. + * + * WARNING: This function uses synchronous subprocess calls that block the main process. + * For use in Electron main process, prefer getClaudeCliInvocationAsync() instead. */ export function getClaudeCliInvocation(): ClaudeCliInvocation { const command = getToolPath('claude'); @@ -47,3 +50,28 @@ export function getClaudeCliInvocation(): ClaudeCliInvocation { env: ensureCommandDirInPath(command, env), }; } + +/** + * Returns the Claude CLI command path and environment asynchronously (non-blocking). + * + * Safe to call from Electron main process without blocking the event loop. + * Uses cached values if available for instant response. + * + * @example + * ```typescript + * const { command, env } = await getClaudeCliInvocationAsync(); + * spawn(command, ['--version'], { env }); + * ``` + */ +export async function getClaudeCliInvocationAsync(): Promise { + // Run both detections in parallel for efficiency + const [command, env] = await Promise.all([ + getToolPathAsync('claude'), + getAugmentedEnvAsync(), + ]); + + return { + command, + env: ensureCommandDirInPath(command, env), + }; +} diff --git a/apps/frontend/src/main/claude-profile-manager.ts b/apps/frontend/src/main/claude-profile-manager.ts index 0f9c88f6..f64ef42d 100644 --- a/apps/frontend/src/main/claude-profile-manager.ts +++ b/apps/frontend/src/main/claude-profile-manager.ts @@ -13,7 +13,7 @@ import { app } from 'electron'; import { join } from 'path'; -import { existsSync, mkdirSync } from 'fs'; +import { mkdir } from 'fs/promises'; import type { ClaudeProfile, ClaudeProfileSettings, @@ -32,6 +32,7 @@ import { } from './claude-profile/rate-limit-manager'; import { loadProfileStore, + loadProfileStoreAsync, saveProfileStore, ProfileStoreData, DEFAULT_AUTO_SWITCH_SETTINGS @@ -57,19 +58,45 @@ import { */ export class ClaudeProfileManager { private storePath: string; + private configDir: string; private data: ProfileStoreData; + private initialized: boolean = false; constructor() { - const configDir = join(app.getPath('userData'), 'config'); - this.storePath = join(configDir, 'claude-profiles.json'); + this.configDir = join(app.getPath('userData'), 'config'); + this.storePath = join(this.configDir, 'claude-profiles.json'); - // Ensure directory exists - if (!existsSync(configDir)) { - mkdirSync(configDir, { recursive: true }); + // DON'T do file I/O here - defer to async initialize() + // Start with default data until initialized + this.data = this.createDefaultData(); + } + + /** + * Initialize the profile manager asynchronously (non-blocking) + * This should be called at app startup via initializeClaudeProfileManager() + */ + async initialize(): Promise { + if (this.initialized) return; + + // Ensure directory exists (async) - mkdir with recursive:true is idempotent + await mkdir(this.configDir, { recursive: true }); + + // Load existing data asynchronously + const loadedData = await loadProfileStoreAsync(this.storePath); + if (loadedData) { + this.data = loadedData; } + // else: keep the default data from constructor - // Load existing data or initialize with default profile - this.data = this.load(); + this.initialized = true; + console.warn('[ClaudeProfileManager] Initialized asynchronously'); + } + + /** + * Check if the profile manager has been initialized + */ + isInitialized(): boolean { + return this.initialized; } /** @@ -522,11 +549,13 @@ export class ClaudeProfileManager { } } -// Singleton instance +// Singleton instance and initialization promise let profileManager: ClaudeProfileManager | null = null; +let initPromise: Promise | null = null; /** * Get the singleton Claude profile manager instance + * Note: For async contexts, prefer initializeClaudeProfileManager() to ensure initialization */ export function getClaudeProfileManager(): ClaudeProfileManager { if (!profileManager) { @@ -534,3 +563,28 @@ export function getClaudeProfileManager(): ClaudeProfileManager { } return profileManager; } + +/** + * Initialize and get the singleton Claude profile manager instance (async) + * This ensures the profile manager is fully initialized before use. + * Uses promise caching to prevent concurrent initialization. + */ +export async function initializeClaudeProfileManager(): Promise { + if (!profileManager) { + profileManager = new ClaudeProfileManager(); + } + + // If already initialized, return immediately + if (profileManager.isInitialized()) { + return profileManager; + } + + // If initialization is in progress, wait for it (promise caching) + if (!initPromise) { + initPromise = profileManager.initialize().then(() => { + return profileManager!; + }); + } + + return initPromise; +} diff --git a/apps/frontend/src/main/claude-profile/profile-storage.ts b/apps/frontend/src/main/claude-profile/profile-storage.ts index bd5b89c3..a4c825e2 100644 --- a/apps/frontend/src/main/claude-profile/profile-storage.ts +++ b/apps/frontend/src/main/claude-profile/profile-storage.ts @@ -4,6 +4,7 @@ */ import { existsSync, readFileSync, writeFileSync } from 'fs'; +import { readFile } from 'fs/promises'; import type { ClaudeProfile, ClaudeAutoSwitchSettings } from '../../shared/types'; export const STORE_VERSION = 3; // Bumped for encrypted token storage @@ -30,6 +31,42 @@ export interface ProfileStoreData { autoSwitch?: ClaudeAutoSwitchSettings; } +/** + * Parse and migrate profile data from JSON. + * Handles version migration and date parsing. + * Shared helper used by both sync and async loaders. + */ +function parseAndMigrateProfileData(data: Record): ProfileStoreData | null { + // Handle version migration + if (data.version === 1) { + // Migrate v1 to v2: add usage and rateLimitEvents fields + data.version = STORE_VERSION; + data.autoSwitch = DEFAULT_AUTO_SWITCH_SETTINGS; + } + + if (data.version === STORE_VERSION) { + // Parse dates + const profiles = data.profiles as ClaudeProfile[]; + data.profiles = profiles.map((p: ClaudeProfile) => ({ + ...p, + createdAt: new Date(p.createdAt), + lastUsedAt: p.lastUsedAt ? new Date(p.lastUsedAt) : undefined, + usage: p.usage ? { + ...p.usage, + lastUpdated: new Date(p.usage.lastUpdated) + } : undefined, + rateLimitEvents: p.rateLimitEvents?.map(e => ({ + ...e, + hitAt: new Date(e.hitAt), + resetAt: new Date(e.resetAt) + })) + })); + return data as unknown as ProfileStoreData; + } + + return null; +} + /** * Load profiles from disk */ @@ -38,32 +75,7 @@ export function loadProfileStore(storePath: string): ProfileStoreData | null { if (existsSync(storePath)) { const content = readFileSync(storePath, 'utf-8'); const data = JSON.parse(content); - - // Handle version migration - if (data.version === 1) { - // Migrate v1 to v2: add usage and rateLimitEvents fields - data.version = STORE_VERSION; - data.autoSwitch = DEFAULT_AUTO_SWITCH_SETTINGS; - } - - if (data.version === STORE_VERSION) { - // Parse dates - data.profiles = data.profiles.map((p: ClaudeProfile) => ({ - ...p, - createdAt: new Date(p.createdAt), - lastUsedAt: p.lastUsedAt ? new Date(p.lastUsedAt) : undefined, - usage: p.usage ? { - ...p.usage, - lastUpdated: new Date(p.usage.lastUpdated) - } : undefined, - rateLimitEvents: p.rateLimitEvents?.map(e => ({ - ...e, - hitAt: new Date(e.hitAt), - resetAt: new Date(e.resetAt) - })) - })); - return data; - } + return parseAndMigrateProfileData(data); } } catch (error) { console.error('[ProfileStorage] Error loading profiles:', error); @@ -72,6 +84,27 @@ export function loadProfileStore(storePath: string): ProfileStoreData | null { return null; } +/** + * Load profiles from disk (async, non-blocking) + * Use this version for initialization to avoid blocking the main process. + */ +export async function loadProfileStoreAsync(storePath: string): Promise { + try { + // Read file directly - avoid TOCTOU race condition by not checking existence first + // If file doesn't exist, readFile will throw ENOENT which we handle below + const content = await readFile(storePath, 'utf-8'); + const data = JSON.parse(content); + return parseAndMigrateProfileData(data); + } catch (error) { + // ENOENT is expected if file doesn't exist yet + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + console.error('[ProfileStorage] Error loading profiles:', error); + } + } + + return null; +} + /** * Save profiles to disk */ diff --git a/apps/frontend/src/main/cli-tool-manager.ts b/apps/frontend/src/main/cli-tool-manager.ts index 6e6403c5..3f51a1a3 100644 --- a/apps/frontend/src/main/cli-tool-manager.ts +++ b/apps/frontend/src/main/cli-tool-manager.ts @@ -20,18 +20,40 @@ * - Graceful fallbacks when tools not found */ -import { execFileSync } from 'child_process'; -import { existsSync, readdirSync } from 'fs'; +import { execFileSync, execFile } from 'child_process'; +import { existsSync, readdirSync, promises as fsPromises } from 'fs'; import path from 'path'; import os from 'os'; +import { promisify } from 'util'; import { app } from 'electron'; -import { findExecutable, getAugmentedEnv } from './env-utils'; +import { findExecutable, findExecutableAsync, getAugmentedEnv, getAugmentedEnvAsync } from './env-utils'; + +const execFileAsync = promisify(execFile); + +/** + * Check if a path exists asynchronously (non-blocking) + * + * Uses fs.promises.access which is non-blocking, unlike fs.existsSync. + * + * @param filePath - The path to check + * @returns Promise resolving to true if path exists, false otherwise + */ +async function existsAsync(filePath: string): Promise { + try { + await fsPromises.access(filePath); + return true; + } catch { + return false; + } +} import type { ToolDetectionResult } from '../shared/types'; import { findHomebrewPython as findHomebrewPythonUtil } from './utils/homebrew-python'; import { getWindowsExecutablePaths, + getWindowsExecutablePathsAsync, WINDOWS_GIT_PATHS, findWindowsExecutableViaWhere, + findWindowsExecutableViaWhereAsync, } from './utils/windows-paths'; /** @@ -108,6 +130,139 @@ function isWrongPlatformPath(pathStr: string | undefined): boolean { return false; } +// ============================================================================ +// SHARED HELPERS - Used by both sync and async Claude detection +// ============================================================================ + +/** + * Configuration for Claude CLI detection paths + */ +interface ClaudeDetectionPaths { + /** Homebrew paths for macOS (Apple Silicon and Intel) */ + homebrewPaths: string[]; + /** Platform-specific standard installation paths */ + platformPaths: string[]; + /** Path to NVM versions directory for Node.js-installed Claude */ + nvmVersionsDir: string; +} + +/** + * Get all candidate paths for Claude CLI detection. + * + * Returns platform-specific paths where Claude CLI might be installed. + * This pure function consolidates path configuration used by both sync + * and async detection methods. + * + * @param homeDir - User's home directory (from os.homedir()) + * @returns Object containing homebrew, platform, and NVM paths + * + * @example + * const paths = getClaudeDetectionPaths('/Users/john'); + * // On macOS: { homebrewPaths: ['/opt/homebrew/bin/claude', ...], ... } + */ +export function getClaudeDetectionPaths(homeDir: string): ClaudeDetectionPaths { + const homebrewPaths = [ + '/opt/homebrew/bin/claude', // Apple Silicon + '/usr/local/bin/claude', // Intel Mac + ]; + + const platformPaths = process.platform === 'win32' + ? [ + path.join(homeDir, 'AppData', 'Local', 'Programs', 'claude', 'claude.exe'), + path.join(homeDir, 'AppData', 'Roaming', 'npm', 'claude.cmd'), + path.join(homeDir, '.local', 'bin', 'claude.exe'), + 'C:\\Program Files\\Claude\\claude.exe', + 'C:\\Program Files (x86)\\Claude\\claude.exe', + ] + : [ + path.join(homeDir, '.local', 'bin', 'claude'), + path.join(homeDir, 'bin', 'claude'), + ]; + + const nvmVersionsDir = path.join(homeDir, '.nvm', 'versions', 'node'); + + return { homebrewPaths, platformPaths, nvmVersionsDir }; +} + +/** + * Sort NVM version directories by semantic version (newest first). + * + * Filters entries to only include directories starting with 'v' (version directories) + * and sorts them in descending order so the newest Node.js version is checked first. + * + * @param entries - Directory entries from readdir with { name, isDirectory() } + * @returns Array of version directory names sorted newest first + * + * @example + * const entries = [ + * { name: 'v18.0.0', isDirectory: () => true }, + * { name: 'v20.0.0', isDirectory: () => true }, + * { name: '.DS_Store', isDirectory: () => false }, + * ]; + * sortNvmVersionDirs(entries); // ['v20.0.0', 'v18.0.0'] + */ +export function sortNvmVersionDirs( + entries: Array<{ name: string; isDirectory(): boolean }> +): string[] { + // Regex to match valid semver directories: v20.0.0, v18.17.1, etc. + // This prevents NaN from malformed versions (e.g., v20.abc.1) breaking sort + const semverRegex = /^v\d+\.\d+\.\d+$/; + + return entries + .filter((entry) => entry.isDirectory() && semverRegex.test(entry.name)) + .sort((a, b) => { + // Parse version numbers: v20.0.0 -> [20, 0, 0] + const vA = a.name.slice(1).split('.').map(Number); + const vB = b.name.slice(1).split('.').map(Number); + // Compare major, minor, patch in order (descending) + for (let i = 0; i < 3; i++) { + const diff = (vB[i] ?? 0) - (vA[i] ?? 0); + if (diff !== 0) return diff; + } + return 0; + }) + .map((entry) => entry.name); +} + +/** + * Build a ToolDetectionResult from a validation result. + * + * Returns null if validation failed, otherwise constructs the full result object. + * This helper consolidates the result-building logic used throughout detection. + * + * @param claudePath - The path that was validated + * @param validation - The validation result from validateClaude/validateClaudeAsync + * @param source - The source of detection ('user-config', 'homebrew', 'system-path', 'nvm') + * @param messagePrefix - Prefix for the success message (e.g., 'Using Homebrew Claude CLI') + * @returns ToolDetectionResult if valid, null if validation failed + * + * @example + * const result = buildClaudeDetectionResult( + * '/opt/homebrew/bin/claude', + * { valid: true, version: '1.0.0', message: 'OK' }, + * 'homebrew', + * 'Using Homebrew Claude CLI' + * ); + * // Returns: { found: true, path: '/opt/homebrew/bin/claude', version: '1.0.0', ... } + */ +export function buildClaudeDetectionResult( + claudePath: string, + validation: ToolValidation, + source: ToolDetectionResult['source'], + messagePrefix: string +): ToolDetectionResult | null { + if (!validation.valid) { + return null; + } + return { + found: true, + path: claudePath, + version: validation.version, + source, + message: `${messagePrefix}: ${claudePath}`, + }; +} + /** * Centralized CLI Tool Manager * @@ -555,143 +710,75 @@ class CLIToolManager { * @returns Detection result for Claude CLI */ private detectClaude(): ToolDetectionResult { + const homeDir = os.homedir(); + const paths = getClaudeDetectionPaths(homeDir); + // 1. User configuration if (this.userConfig.claudePath) { - // Check if path is from wrong platform (e.g., Windows path on macOS) if (isWrongPlatformPath(this.userConfig.claudePath)) { console.warn( `[Claude CLI] User-configured path is from different platform, ignoring: ${this.userConfig.claudePath}` ); } else { const validation = this.validateClaude(this.userConfig.claudePath); - if (validation.valid) { - return { - found: true, - path: this.userConfig.claudePath, - version: validation.version, - source: 'user-config', - message: `Using user-configured Claude CLI: ${this.userConfig.claudePath}`, - }; - } - console.warn( - `[Claude CLI] User-configured path invalid: ${validation.message}` + const result = buildClaudeDetectionResult( + this.userConfig.claudePath, validation, 'user-config', 'Using user-configured Claude CLI' ); + if (result) return result; + console.warn(`[Claude CLI] User-configured path invalid: ${validation.message}`); } } // 2. Homebrew (macOS) if (process.platform === 'darwin') { - const homebrewPaths = [ - '/opt/homebrew/bin/claude', // Apple Silicon - '/usr/local/bin/claude', // Intel Mac - ]; - - for (const claudePath of homebrewPaths) { + for (const claudePath of paths.homebrewPaths) { if (existsSync(claudePath)) { const validation = this.validateClaude(claudePath); - if (validation.valid) { - return { - found: true, - path: claudePath, - version: validation.version, - source: 'homebrew', - message: `Using Homebrew Claude CLI: ${claudePath}`, - }; - } + const result = buildClaudeDetectionResult(claudePath, validation, 'homebrew', 'Using Homebrew Claude CLI'); + if (result) return result; } } } // 3. System PATH (augmented) - const claudePath = findExecutable('claude'); - if (claudePath) { - const validation = this.validateClaude(claudePath); - if (validation.valid) { - return { - found: true, - path: claudePath, - version: validation.version, - source: 'system-path', - message: `Using system Claude CLI: ${claudePath}`, - }; - } + const systemClaudePath = findExecutable('claude'); + if (systemClaudePath) { + const validation = this.validateClaude(systemClaudePath); + const result = buildClaudeDetectionResult(systemClaudePath, validation, 'system-path', 'Using system Claude CLI'); + if (result) return result; } - // 4. Platform-specific standard locations - const homeDir = os.homedir(); - const platformPaths = process.platform === 'win32' - ? [ - path.join(homeDir, 'AppData', 'Local', 'Programs', 'claude', 'claude.exe'), - path.join(homeDir, 'AppData', 'Roaming', 'npm', 'claude.cmd'), - path.join(homeDir, '.local', 'bin', 'claude.exe'), - 'C:\\Program Files\\Claude\\claude.exe', - 'C:\\Program Files (x86)\\Claude\\claude.exe', - ] - : [ - path.join(homeDir, '.local', 'bin', 'claude'), - path.join(homeDir, 'bin', 'claude'), - ]; - - // 4.5. NVM (Node Version Manager) paths for Unix/Linux/macOS - // NVM installs global npm packages in ~/.nvm/versions/node/vX.X.X/bin/ - // This is important when the app launches from GUI without NVM sourced + // 4. NVM paths (Unix only) - check before platform paths for better Node.js integration if (process.platform !== 'win32') { - const nvmVersionsDir = path.join(homeDir, '.nvm', 'versions', 'node'); try { - if (existsSync(nvmVersionsDir)) { - const nodeVersions = readdirSync(nvmVersionsDir, { withFileTypes: true }); - const versionDirs = nodeVersions - .filter((entry) => entry.isDirectory() && entry.name.startsWith('v')) - .sort((a, b) => { - const vA = a.name.slice(1).split('.').map(Number); - const vB = b.name.slice(1).split('.').map(Number); - for (let i = 0; i < 3; i++) { - const diff = (vB[i] ?? 0) - (vA[i] ?? 0); - if (diff !== 0) { - return diff; - } - } - return 0; - }); + if (existsSync(paths.nvmVersionsDir)) { + const nodeVersions = readdirSync(paths.nvmVersionsDir, { withFileTypes: true }); + const versionNames = sortNvmVersionDirs(nodeVersions); - for (const entry of versionDirs) { - const nvmClaudePath = path.join(nvmVersionsDir, entry.name, 'bin', 'claude'); + for (const versionName of versionNames) { + const nvmClaudePath = path.join(paths.nvmVersionsDir, versionName, 'bin', 'claude'); if (existsSync(nvmClaudePath)) { const validation = this.validateClaude(nvmClaudePath); - if (validation.valid) { - return { - found: true, - path: nvmClaudePath, - version: validation.version, - source: 'nvm', - message: `Using NVM Claude CLI: ${nvmClaudePath}`, - }; - } + const result = buildClaudeDetectionResult(nvmClaudePath, validation, 'nvm', 'Using NVM Claude CLI'); + if (result) return result; } } } } catch (error) { - // Silently fail if unable to read NVM directory console.warn(`[Claude CLI] Unable to read NVM directory: ${error}`); } } - for (const claudePath of platformPaths) { + // 5. Platform-specific standard locations + for (const claudePath of paths.platformPaths) { if (existsSync(claudePath)) { const validation = this.validateClaude(claudePath); - if (validation.valid) { - return { - found: true, - path: claudePath, - version: validation.version, - source: 'system-path', - message: `Using Claude CLI: ${claudePath}`, - }; - } + const result = buildClaudeDetectionResult(claudePath, validation, 'system-path', 'Using Claude CLI'); + if (result) return result; } } - // 5. Not found + // 6. Not found return { found: false, source: 'fallback', @@ -861,6 +948,637 @@ class CLIToolManager { } } + // ============================================================================ + // ASYNC METHODS - Non-blocking alternatives for Electron main process + // ============================================================================ + + /** + * Get the path for a CLI tool asynchronously (non-blocking) + * + * Uses cached path if available, otherwise detects asynchronously. + * Safe to call from Electron main process without blocking. + * + * @param tool - The CLI tool to get the path for + * @returns Promise resolving to the tool path + */ + async getToolPathAsync(tool: CLITool): Promise { + // Check cache first (instant return if cached) + const cached = this.cache.get(tool); + if (cached) { + console.warn( + `[CLI Tools] Using cached ${tool}: ${cached.path} (${cached.source})` + ); + return cached.path; + } + + // Detect asynchronously + const result = await this.detectToolPathAsync(tool); + if (result.found && result.path) { + this.cache.set(tool, { + path: result.path, + version: result.version, + source: result.source, + }); + console.warn(`[CLI Tools] Detected ${tool}: ${result.path} (${result.source})`); + return result.path; + } + + // Fallback to tool name (let system PATH resolve it) + console.warn(`[CLI Tools] ${tool} not found, using fallback: "${tool}"`); + return tool; + } + + /** + * Detect tool path asynchronously + * + * All tools now use async detection methods to prevent blocking the main process. + * + * @param tool - The tool to detect + * @returns Promise resolving to detection result + */ + private async detectToolPathAsync(tool: CLITool): Promise { + switch (tool) { + case 'claude': + return this.detectClaudeAsync(); + case 'python': + return this.detectPythonAsync(); + case 'git': + return this.detectGitAsync(); + case 'gh': + return this.detectGitHubCLIAsync(); + default: + return { + found: false, + source: 'fallback', + message: `Unknown tool: ${tool}`, + }; + } + } + + /** + * Validate Claude CLI asynchronously (non-blocking) + * + * @param claudeCmd - The Claude CLI command to validate + * @returns Promise resolving to validation result + */ + private async validateClaudeAsync(claudeCmd: string): Promise { + try { + const needsShell = process.platform === 'win32' && + (claudeCmd.endsWith('.cmd') || claudeCmd.endsWith('.bat')); + + const { stdout } = await execFileAsync(claudeCmd, ['--version'], { + encoding: 'utf-8', + timeout: 5000, + windowsHide: true, + shell: needsShell, + env: await getAugmentedEnvAsync(), + }); + + const version = stdout.trim(); + const match = version.match(/(\d+\.\d+\.\d+)/); + const versionStr = match ? match[1] : version.split('\n')[0]; + + return { + valid: true, + version: versionStr, + message: `Claude CLI ${versionStr} is available`, + }; + } catch (error) { + return { + valid: false, + message: `Failed to validate Claude CLI: ${error instanceof Error ? error.message : String(error)}`, + }; + } + } + + /** + * Validate Python version asynchronously (non-blocking) + * + * @param pythonCmd - The Python command to validate + * @returns Promise resolving to validation result + */ + private async validatePythonAsync(pythonCmd: string): Promise { + const MINIMUM_VERSION = '3.10.0'; + + try { + const parts = pythonCmd.split(' '); + const cmd = parts[0]; + const args = [...parts.slice(1), '--version']; + + const { stdout } = await execFileAsync(cmd, args, { + encoding: 'utf-8', + timeout: 5000, + windowsHide: true, + env: await getAugmentedEnvAsync(), + }); + + const version = stdout.trim(); + const match = version.match(/Python (\d+\.\d+\.\d+)/); + if (!match) { + return { + valid: false, + message: 'Unable to detect Python version', + }; + } + + const versionStr = match[1]; + const [major, minor] = versionStr.split('.').map(Number); + const [reqMajor, reqMinor] = MINIMUM_VERSION.split('.').map(Number); + + const meetsRequirement = + major > reqMajor || (major === reqMajor && minor >= reqMinor); + + if (!meetsRequirement) { + return { + valid: false, + version: versionStr, + message: `Python ${versionStr} is too old. Requires ${MINIMUM_VERSION}+`, + }; + } + + return { + valid: true, + version: versionStr, + message: `Python ${versionStr} meets requirements`, + }; + } catch (error) { + return { + valid: false, + message: `Failed to validate Python: ${error}`, + }; + } + } + + /** + * Validate Git asynchronously (non-blocking) + * + * @param gitCmd - The Git command to validate + * @returns Promise resolving to validation result + */ + private async validateGitAsync(gitCmd: string): Promise { + try { + const { stdout } = await execFileAsync(gitCmd, ['--version'], { + encoding: 'utf-8', + timeout: 5000, + windowsHide: true, + env: await getAugmentedEnvAsync(), + }); + + const version = stdout.trim(); + const match = version.match(/git version (\d+\.\d+\.\d+)/); + const versionStr = match ? match[1] : version; + + return { + valid: true, + version: versionStr, + message: `Git ${versionStr} is available`, + }; + } catch (error) { + return { + valid: false, + message: `Failed to validate Git: ${error instanceof Error ? error.message : String(error)}`, + }; + } + } + + /** + * Validate GitHub CLI asynchronously (non-blocking) + * + * @param ghCmd - The GitHub CLI command to validate + * @returns Promise resolving to validation result + */ + private async validateGitHubCLIAsync(ghCmd: string): Promise { + try { + const { stdout } = await execFileAsync(ghCmd, ['--version'], { + encoding: 'utf-8', + timeout: 5000, + windowsHide: true, + env: await getAugmentedEnvAsync(), + }); + + const version = stdout.trim(); + const match = version.match(/gh version (\d+\.\d+\.\d+)/); + const versionStr = match ? match[1] : version.split('\n')[0]; + + return { + valid: true, + version: versionStr, + message: `GitHub CLI ${versionStr} is available`, + }; + } catch (error) { + return { + valid: false, + message: `Failed to validate GitHub CLI: ${error instanceof Error ? error.message : String(error)}`, + }; + } + } + + /** + * Detect Claude CLI asynchronously (non-blocking) + * + * Same detection logic as detectClaude but uses async validation. + * + * @returns Promise resolving to detection result + */ + private async detectClaudeAsync(): Promise { + const homeDir = os.homedir(); + const paths = getClaudeDetectionPaths(homeDir); + + // 1. User configuration + if (this.userConfig.claudePath) { + if (isWrongPlatformPath(this.userConfig.claudePath)) { + console.warn( + `[Claude CLI] User-configured path is from different platform, ignoring: ${this.userConfig.claudePath}` + ); + } else { + const validation = await this.validateClaudeAsync(this.userConfig.claudePath); + const result = buildClaudeDetectionResult( + this.userConfig.claudePath, validation, 'user-config', 'Using user-configured Claude CLI' + ); + if (result) return result; + console.warn(`[Claude CLI] User-configured path invalid: ${validation.message}`); + } + } + + // 2. Homebrew (macOS) + if (process.platform === 'darwin') { + for (const claudePath of paths.homebrewPaths) { + if (await existsAsync(claudePath)) { + const validation = await this.validateClaudeAsync(claudePath); + const result = buildClaudeDetectionResult(claudePath, validation, 'homebrew', 'Using Homebrew Claude CLI'); + if (result) return result; + } + } + } + + // 3. System PATH (augmented) - using async findExecutable + const systemClaudePath = await findExecutableAsync('claude'); + if (systemClaudePath) { + const validation = await this.validateClaudeAsync(systemClaudePath); + const result = buildClaudeDetectionResult(systemClaudePath, validation, 'system-path', 'Using system Claude CLI'); + if (result) return result; + } + + // 4. NVM paths (Unix only) - check before platform paths for better Node.js integration + if (process.platform !== 'win32') { + try { + if (await existsAsync(paths.nvmVersionsDir)) { + const nodeVersions = await fsPromises.readdir(paths.nvmVersionsDir, { withFileTypes: true }); + const versionNames = sortNvmVersionDirs(nodeVersions); + + for (const versionName of versionNames) { + const nvmClaudePath = path.join(paths.nvmVersionsDir, versionName, 'bin', 'claude'); + if (await existsAsync(nvmClaudePath)) { + const validation = await this.validateClaudeAsync(nvmClaudePath); + const result = buildClaudeDetectionResult(nvmClaudePath, validation, 'nvm', 'Using NVM Claude CLI'); + if (result) return result; + } + } + } + } catch (error) { + console.warn(`[Claude CLI] Unable to read NVM directory: ${error}`); + } + } + + // 5. Platform-specific standard locations + for (const claudePath of paths.platformPaths) { + if (await existsAsync(claudePath)) { + const validation = await this.validateClaudeAsync(claudePath); + const result = buildClaudeDetectionResult(claudePath, validation, 'system-path', 'Using Claude CLI'); + if (result) return result; + } + } + + // 6. Not found + return { + found: false, + source: 'fallback', + message: 'Claude CLI not found. Install from https://claude.ai/download', + }; + } + + /** + * Detect Python asynchronously (non-blocking) + * + * Same detection logic as detectPython but uses async validation. + * + * @returns Promise resolving to detection result + */ + private async detectPythonAsync(): Promise { + const MINIMUM_VERSION = '3.10.0'; + + // 1. User configuration + if (this.userConfig.pythonPath) { + if (isWrongPlatformPath(this.userConfig.pythonPath)) { + console.warn( + `[Python] User-configured path is from different platform, ignoring: ${this.userConfig.pythonPath}` + ); + } else { + const validation = await this.validatePythonAsync(this.userConfig.pythonPath); + if (validation.valid) { + return { + found: true, + path: this.userConfig.pythonPath, + version: validation.version, + source: 'user-config', + message: `Using user-configured Python: ${this.userConfig.pythonPath}`, + }; + } + console.warn(`[Python] User-configured path invalid: ${validation.message}`); + } + } + + // 2. Bundled Python (packaged apps only) + if (app.isPackaged) { + const bundledPath = this.getBundledPythonPath(); + if (bundledPath) { + const validation = await this.validatePythonAsync(bundledPath); + if (validation.valid) { + return { + found: true, + path: bundledPath, + version: validation.version, + source: 'bundled', + message: `Using bundled Python: ${bundledPath}`, + }; + } + } + } + + // 3. Homebrew Python (macOS) - simplified async version + if (process.platform === 'darwin') { + const homebrewPaths = [ + '/opt/homebrew/bin/python3', + '/opt/homebrew/bin/python3.12', + '/opt/homebrew/bin/python3.11', + '/opt/homebrew/bin/python3.10', + '/usr/local/bin/python3', + ]; + for (const pythonPath of homebrewPaths) { + if (await existsAsync(pythonPath)) { + const validation = await this.validatePythonAsync(pythonPath); + if (validation.valid) { + return { + found: true, + path: pythonPath, + version: validation.version, + source: 'homebrew', + message: `Using Homebrew Python: ${pythonPath}`, + }; + } + } + } + } + + // 4. System PATH (augmented) + const candidates = + process.platform === 'win32' + ? ['py -3', 'python', 'python3', 'py'] + : ['python3', 'python']; + + for (const cmd of candidates) { + if (cmd.startsWith('py ')) { + const validation = await this.validatePythonAsync(cmd); + if (validation.valid) { + return { + found: true, + path: cmd, + version: validation.version, + source: 'system-path', + message: `Using system Python: ${cmd}`, + }; + } + } else { + const pythonPath = await findExecutableAsync(cmd); + if (pythonPath) { + const validation = await this.validatePythonAsync(pythonPath); + if (validation.valid) { + return { + found: true, + path: pythonPath, + version: validation.version, + source: 'system-path', + message: `Using system Python: ${pythonPath}`, + }; + } + } + } + } + + // 5. Not found + return { + found: false, + source: 'fallback', + message: + `Python ${MINIMUM_VERSION}+ not found. ` + + 'Please install Python or configure in Settings.', + }; + } + + /** + * Detect Git asynchronously (non-blocking) + * + * Same detection logic as detectGit but uses async validation. + * + * @returns Promise resolving to detection result + */ + private async detectGitAsync(): Promise { + // 1. User configuration + if (this.userConfig.gitPath) { + if (isWrongPlatformPath(this.userConfig.gitPath)) { + console.warn( + `[Git] User-configured path is from different platform, ignoring: ${this.userConfig.gitPath}` + ); + } else { + const validation = await this.validateGitAsync(this.userConfig.gitPath); + if (validation.valid) { + return { + found: true, + path: this.userConfig.gitPath, + version: validation.version, + source: 'user-config', + message: `Using user-configured Git: ${this.userConfig.gitPath}`, + }; + } + console.warn(`[Git] User-configured path invalid: ${validation.message}`); + } + } + + // 2. Homebrew (macOS) + if (process.platform === 'darwin') { + const homebrewPaths = [ + '/opt/homebrew/bin/git', + '/usr/local/bin/git', + ]; + + for (const gitPath of homebrewPaths) { + if (await existsAsync(gitPath)) { + const validation = await this.validateGitAsync(gitPath); + if (validation.valid) { + return { + found: true, + path: gitPath, + version: validation.version, + source: 'homebrew', + message: `Using Homebrew Git: ${gitPath}`, + }; + } + } + } + } + + // 3. System PATH (augmented) + const gitPath = await findExecutableAsync('git'); + if (gitPath) { + const validation = await this.validateGitAsync(gitPath); + if (validation.valid) { + return { + found: true, + path: gitPath, + version: validation.version, + source: 'system-path', + message: `Using system Git: ${gitPath}`, + }; + } + } + + // 4. Windows-specific detection (async to avoid blocking main process) + if (process.platform === 'win32') { + const whereGitPath = await findWindowsExecutableViaWhereAsync('git', '[Git]'); + if (whereGitPath) { + const validation = await this.validateGitAsync(whereGitPath); + if (validation.valid) { + return { + found: true, + path: whereGitPath, + version: validation.version, + source: 'system-path', + message: `Using Windows Git: ${whereGitPath}`, + }; + } + } + + const windowsPaths = await getWindowsExecutablePathsAsync(WINDOWS_GIT_PATHS, '[Git]'); + for (const winGitPath of windowsPaths) { + const validation = await this.validateGitAsync(winGitPath); + if (validation.valid) { + return { + found: true, + path: winGitPath, + version: validation.version, + source: 'system-path', + message: `Using Windows Git: ${winGitPath}`, + }; + } + } + } + + // 5. Not found + return { + found: false, + source: 'fallback', + message: 'Git not found in standard locations. Using fallback "git".', + }; + } + + /** + * Detect GitHub CLI asynchronously (non-blocking) + * + * Same detection logic as detectGitHubCLI but uses async validation. + * + * @returns Promise resolving to detection result + */ + private async detectGitHubCLIAsync(): Promise { + // 1. User configuration + if (this.userConfig.githubCLIPath) { + if (isWrongPlatformPath(this.userConfig.githubCLIPath)) { + console.warn( + `[GitHub CLI] User-configured path is from different platform, ignoring: ${this.userConfig.githubCLIPath}` + ); + } else { + const validation = await this.validateGitHubCLIAsync(this.userConfig.githubCLIPath); + if (validation.valid) { + return { + found: true, + path: this.userConfig.githubCLIPath, + version: validation.version, + source: 'user-config', + message: `Using user-configured GitHub CLI: ${this.userConfig.githubCLIPath}`, + }; + } + console.warn(`[GitHub CLI] User-configured path invalid: ${validation.message}`); + } + } + + // 2. Homebrew (macOS) + if (process.platform === 'darwin') { + const homebrewPaths = [ + '/opt/homebrew/bin/gh', + '/usr/local/bin/gh', + ]; + + for (const ghPath of homebrewPaths) { + if (await existsAsync(ghPath)) { + const validation = await this.validateGitHubCLIAsync(ghPath); + if (validation.valid) { + return { + found: true, + path: ghPath, + version: validation.version, + source: 'homebrew', + message: `Using Homebrew GitHub CLI: ${ghPath}`, + }; + } + } + } + } + + // 3. System PATH (augmented) + const ghPath = await findExecutableAsync('gh'); + if (ghPath) { + const validation = await this.validateGitHubCLIAsync(ghPath); + if (validation.valid) { + return { + found: true, + path: ghPath, + version: validation.version, + source: 'system-path', + message: `Using system GitHub CLI: ${ghPath}`, + }; + } + } + + // 4. Windows Program Files + if (process.platform === 'win32') { + const windowsPaths = [ + 'C:\\Program Files\\GitHub CLI\\gh.exe', + 'C:\\Program Files (x86)\\GitHub CLI\\gh.exe', + ]; + + for (const winGhPath of windowsPaths) { + if (await existsAsync(winGhPath)) { + const validation = await this.validateGitHubCLIAsync(winGhPath); + if (validation.valid) { + return { + found: true, + path: winGhPath, + version: validation.version, + source: 'system-path', + message: `Using Windows GitHub CLI: ${winGhPath}`, + }; + } + } + } + } + + // 5. Not found + return { + found: false, + source: 'fallback', + message: 'GitHub CLI (gh) not found. Install from https://cli.github.com', + }; + } + /** * Get bundled Python path for packaged apps * @@ -1034,3 +1752,52 @@ export function clearToolCache(): void { export function isPathFromWrongPlatform(pathStr: string | undefined): boolean { return isWrongPlatformPath(pathStr); } + +// ============================================================================ +// ASYNC EXPORTS - Non-blocking alternatives for Electron main process +// ============================================================================ + +/** + * Get the path for a CLI tool asynchronously (non-blocking) + * + * Safe to call from Electron main process without blocking the event loop. + * Uses cached path if available, otherwise detects asynchronously. + * + * @param tool - The CLI tool to get the path for + * @returns Promise resolving to the tool path + * + * @example + * ```typescript + * import { getToolPathAsync } from './cli-tool-manager'; + * + * const claudePath = await getToolPathAsync('claude'); + * ``` + */ +export async function getToolPathAsync(tool: CLITool): Promise { + return cliToolManager.getToolPathAsync(tool); +} + +/** + * Pre-warm the CLI tool cache asynchronously + * + * Call this during app startup to detect tools in the background. + * Subsequent calls to getToolPath/getToolPathAsync will use cached values. + * + * @param tools - Array of tools to pre-warm (defaults to ['claude']) + * + * @example + * ```typescript + * import { preWarmToolCache } from './cli-tool-manager'; + * + * // In app startup + * app.whenReady().then(() => { + * // ... setup code ... + * preWarmToolCache(['claude', 'git', 'gh']); + * }); + * ``` + */ +export async function preWarmToolCache(tools: CLITool[] = ['claude']): Promise { + console.warn('[CLI Tools] Pre-warming cache for:', tools.join(', ')); + await Promise.all(tools.map(tool => cliToolManager.getToolPathAsync(tool))); + console.warn('[CLI Tools] Cache pre-warming complete'); +} diff --git a/apps/frontend/src/main/env-utils.ts b/apps/frontend/src/main/env-utils.ts index bf13a839..bf863c8f 100644 --- a/apps/frontend/src/main/env-utils.ts +++ b/apps/frontend/src/main/env-utils.ts @@ -12,7 +12,32 @@ import * as os from 'os'; import * as path from 'path'; import * as fs from 'fs'; -import { execFileSync } from 'child_process'; +import { promises as fsPromises } from 'fs'; +import { execFileSync, execFile } from 'child_process'; +import { promisify } from 'util'; + +const execFileAsync = promisify(execFile); + +/** + * Check if a path exists asynchronously (non-blocking) + * + * Uses fs.promises.access which is non-blocking, unlike fs.existsSync. + * + * @param filePath - The path to check + * @returns Promise resolving to true if path exists, false otherwise + */ +async function existsAsync(filePath: string): Promise { + try { + await fsPromises.access(filePath); + return true; + } catch { + return false; + } +} + +// Cache for npm global prefix to avoid repeated async calls +let npmGlobalPrefixCache: string | null | undefined = undefined; +let npmGlobalPrefixCachePromise: Promise | null = null; /** * Get npm global prefix directory dynamically @@ -35,6 +60,7 @@ function getNpmGlobalPrefix(): string | null { encoding: 'utf-8', timeout: 3000, windowsHide: true, + cwd: os.homedir(), // Run from home dir to avoid ENOWORKSPACES error in monorepos shell: process.platform === 'win32', // Enable shell on Windows for .cmd resolution }).trim(); @@ -61,7 +87,7 @@ function getNpmGlobalPrefix(): string | null { * Common binary directories that should be in PATH * These are locations where commonly used tools are installed */ -const COMMON_BIN_PATHS: Record = { +export const COMMON_BIN_PATHS: Record = { darwin: [ '/opt/homebrew/bin', // Apple Silicon Homebrew '/usr/local/bin', // Intel Homebrew / system @@ -86,6 +112,71 @@ const COMMON_BIN_PATHS: Record = { ], }; +/** + * Get expanded platform paths for PATH augmentation + * + * Shared helper used by both sync and async getAugmentedEnv functions. + * Expands home directory (~) in paths and returns the list of candidate paths. + * + * @param additionalPaths - Optional additional paths to include + * @returns Array of expanded paths (without existence checking) + */ +function getExpandedPlatformPaths(additionalPaths?: string[]): string[] { + const platform = process.platform as 'darwin' | 'linux' | 'win32'; + const homeDir = os.homedir(); + + // Get platform-specific paths and expand home directory + const platformPaths = COMMON_BIN_PATHS[platform] || []; + const expandedPaths = platformPaths.map(p => + p.startsWith('~') ? p.replace('~', homeDir) : p + ); + + // Add user-requested additional paths (expanded) + if (additionalPaths) { + for (const p of additionalPaths) { + const expanded = p.startsWith('~') ? p.replace('~', homeDir) : p; + expandedPaths.push(expanded); + } + } + + return expandedPaths; +} + +/** + * Build augmented PATH by filtering existing paths + * + * Shared helper that takes candidate paths and a set of current PATH entries, + * returning only paths that should be added. + * + * @param candidatePaths - Array of paths to consider adding + * @param currentPathSet - Set of paths already in PATH + * @param existingPaths - Array of paths that actually exist on the filesystem + * @param npmPrefix - npm global prefix path (or null if not found) + * @returns Array of paths to prepend to PATH + */ +function buildPathsToAdd( + candidatePaths: string[], + currentPathSet: Set, + existingPaths: Set, + npmPrefix: string | null +): string[] { + const pathsToAdd: string[] = []; + + // Add platform-specific paths that exist + for (const p of candidatePaths) { + if (!currentPathSet.has(p) && existingPaths.has(p)) { + pathsToAdd.push(p); + } + } + + // Add npm global prefix if it exists + if (npmPrefix && !currentPathSet.has(npmPrefix) && existingPaths.has(npmPrefix)) { + pathsToAdd.push(npmPrefix); + } + + return pathsToAdd; +} + /** * Get augmented environment with additional PATH entries * @@ -101,43 +192,24 @@ export function getAugmentedEnv(additionalPaths?: string[]): Record - p.startsWith('~') ? p.replace('~', homeDir) : p - ); + // Get all candidate paths (platform + additional) + const candidatePaths = getExpandedPlatformPaths(additionalPaths); // Collect paths to add (only if they exist and aren't already in PATH) const currentPath = env.PATH || ''; const currentPathSet = new Set(currentPath.split(pathSeparator)); - const pathsToAdd: string[] = []; + // Check existence synchronously and build existing paths set + const existingPaths = new Set(candidatePaths.filter(p => fs.existsSync(p))); - // Add platform-specific paths - for (const p of expandedPaths) { - if (!currentPathSet.has(p) && fs.existsSync(p)) { - pathsToAdd.push(p); - } - } - - // Add npm global prefix dynamically (cross-platform: works with standard npm, nvm, nvm-windows) + // Get npm global prefix dynamically const npmPrefix = getNpmGlobalPrefix(); - if (npmPrefix && !currentPathSet.has(npmPrefix) && fs.existsSync(npmPrefix)) { - pathsToAdd.push(npmPrefix); + if (npmPrefix && fs.existsSync(npmPrefix)) { + existingPaths.add(npmPrefix); } - // Add user-requested additional paths - if (additionalPaths) { - for (const p of additionalPaths) { - const expanded = p.startsWith('~') ? p.replace('~', homeDir) : p; - if (!currentPathSet.has(expanded) && fs.existsSync(expanded)) { - pathsToAdd.push(expanded); - } - } - } + // Build final paths to add using shared helper + const pathsToAdd = buildPathsToAdd(candidatePaths, currentPathSet, existingPaths, npmPrefix); // Prepend new paths to PATH (prepend so they take priority) if (pathsToAdd.length > 0) { @@ -188,3 +260,149 @@ export function findExecutable(command: string): string | null { export function isCommandAvailable(command: string): boolean { return findExecutable(command) !== null; } + +// ============================================================================ +// ASYNC VERSIONS - Non-blocking alternatives for Electron main process +// ============================================================================ + +/** + * Get npm global prefix directory asynchronously (non-blocking) + * + * Uses caching to avoid repeated subprocess calls. Safe to call from + * Electron main process without blocking the event loop. + * + * @returns Promise resolving to npm global binaries directory, or null + */ +async function getNpmGlobalPrefixAsync(): Promise { + // Return cached value if available + if (npmGlobalPrefixCache !== undefined) { + return npmGlobalPrefixCache; + } + + // If a fetch is already in progress, wait for it + if (npmGlobalPrefixCachePromise) { + return npmGlobalPrefixCachePromise; + } + + // Start the async fetch + npmGlobalPrefixCachePromise = (async () => { + try { + const npmCommand = process.platform === 'win32' ? 'npm.cmd' : 'npm'; + + const { stdout } = await execFileAsync(npmCommand, ['config', 'get', 'prefix', '--location=global'], { + encoding: 'utf-8', + timeout: 3000, + windowsHide: true, + cwd: os.homedir(), // Run from home dir to avoid ENOWORKSPACES error in monorepos + shell: process.platform === 'win32', + }); + + const rawPrefix = stdout.trim(); + if (!rawPrefix) { + npmGlobalPrefixCache = null; + return null; + } + + const binPath = process.platform === 'win32' + ? rawPrefix + : path.join(rawPrefix, 'bin'); + + const normalizedPath = path.normalize(binPath); + npmGlobalPrefixCache = await existsAsync(normalizedPath) ? normalizedPath : null; + return npmGlobalPrefixCache; + } catch (error) { + console.warn(`[env-utils] Failed to get npm global prefix: ${error}`); + npmGlobalPrefixCache = null; + return null; + } finally { + npmGlobalPrefixCachePromise = null; + } + })(); + + return npmGlobalPrefixCachePromise; +} + +/** + * Get augmented environment asynchronously (non-blocking) + * + * Same as getAugmentedEnv but uses async npm prefix detection. + * Safe to call from Electron main process without blocking. + * + * @param additionalPaths - Optional array of additional paths to include + * @returns Promise resolving to environment object with augmented PATH + */ +export async function getAugmentedEnvAsync(additionalPaths?: string[]): Promise> { + const env = { ...process.env } as Record; + const platform = process.platform as 'darwin' | 'linux' | 'win32'; + const pathSeparator = platform === 'win32' ? ';' : ':'; + + // Get all candidate paths (platform + additional) + const candidatePaths = getExpandedPlatformPaths(additionalPaths); + + // Collect paths to add (only if they exist and aren't already in PATH) + const currentPath = env.PATH || ''; + const currentPathSet = new Set(currentPath.split(pathSeparator)); + + // Check existence asynchronously in parallel for performance + const pathChecks = await Promise.all( + candidatePaths.map(async (p) => ({ path: p, exists: await existsAsync(p) })) + ); + const existingPaths = new Set( + pathChecks.filter(({ exists }) => exists).map(({ path: p }) => p) + ); + + // Get npm global prefix dynamically (async - non-blocking) + const npmPrefix = await getNpmGlobalPrefixAsync(); + if (npmPrefix && await existsAsync(npmPrefix)) { + existingPaths.add(npmPrefix); + } + + // Build final paths to add using shared helper + const pathsToAdd = buildPathsToAdd(candidatePaths, currentPathSet, existingPaths, npmPrefix); + + // Prepend new paths to PATH (prepend so they take priority) + if (pathsToAdd.length > 0) { + env.PATH = [...pathsToAdd, currentPath].filter(Boolean).join(pathSeparator); + } + + return env; +} + +/** + * Find the full path to an executable asynchronously (non-blocking) + * + * Same as findExecutable but uses async environment augmentation. + * + * @param command - The command name to find (e.g., 'gh', 'git') + * @returns Promise resolving to the full path to the executable, or null + */ +export async function findExecutableAsync(command: string): Promise { + const env = await getAugmentedEnvAsync(); + const pathSeparator = process.platform === 'win32' ? ';' : ':'; + const pathDirs = (env.PATH || '').split(pathSeparator); + + const extensions = process.platform === 'win32' + ? ['.exe', '.cmd', '.bat', '.ps1', ''] + : ['']; + + for (const dir of pathDirs) { + for (const ext of extensions) { + const fullPath = path.join(dir, command + ext); + if (await existsAsync(fullPath)) { + return fullPath; + } + } + } + + return null; +} + +/** + * Clear the npm global prefix cache + * + * Call this if npm configuration changes and you need fresh detection. + */ +export function clearNpmPrefixCache(): void { + npmGlobalPrefixCache = undefined; + npmGlobalPrefixCachePromise = null; +} diff --git a/apps/frontend/src/main/index.ts b/apps/frontend/src/main/index.ts index 041ec161..8ee2eaf7 100644 --- a/apps/frontend/src/main/index.ts +++ b/apps/frontend/src/main/index.ts @@ -35,6 +35,8 @@ import { DEFAULT_APP_SETTINGS } from '../shared/constants'; import { readSettingsFile } from './settings-utils'; import { setupErrorLogging } from './app-logger'; import { initSentryMain } from './sentry'; +import { preWarmToolCache } from './cli-tool-manager'; +import { initializeClaudeProfileManager } from './claude-profile-manager'; import type { AppSettings } from '../shared/types'; // ───────────────────────────────────────────────────────────────────────────── @@ -348,6 +350,23 @@ app.whenReady().then(() => { // Create window createWindow(); + // Pre-warm CLI tool cache in background (non-blocking) + // This ensures CLI detection is done before user needs it + // Include all commonly used tools to prevent sync blocking on first use + setImmediate(() => { + preWarmToolCache(['claude', 'git', 'gh', 'python']).catch((error) => { + console.warn('[main] Failed to pre-warm CLI cache:', error); + }); + }); + + // Pre-initialize Claude profile manager in background (non-blocking) + // This ensures profile data is loaded before user clicks "Start Claude Code" + setImmediate(() => { + initializeClaudeProfileManager().catch((error) => { + console.warn('[main] Failed to pre-initialize profile manager:', error); + }); + }); + // Initialize usage monitoring after window is created if (mainWindow) { // Setup event forwarding from usage monitor to renderer diff --git a/apps/frontend/src/main/ipc-handlers/env-handlers.ts b/apps/frontend/src/main/ipc-handlers/env-handlers.ts index b5038003..99ab0790 100644 --- a/apps/frontend/src/main/ipc-handlers/env-handlers.ts +++ b/apps/frontend/src/main/ipc-handlers/env-handlers.ts @@ -8,7 +8,7 @@ import { existsSync, readFileSync, writeFileSync } from 'fs'; import { spawn } from 'child_process'; import { projectStore } from '../project-store'; import { parseEnvFile } from './utils'; -import { getClaudeCliInvocation } from '../claude-cli-utils'; +import { getClaudeCliInvocation, getClaudeCliInvocationAsync } from '../claude-cli-utils'; import { debugError } from '../../shared/utils/debug-logger'; // GitLab environment variable keys @@ -46,6 +46,24 @@ function resolveClaudeCliInvocation(): ResolvedClaudeCliInvocation { } } +/** + * Async version of resolveClaudeCliInvocation - non-blocking for main process + */ +async function resolveClaudeCliInvocationAsync(): Promise { + try { + const invocation = await getClaudeCliInvocationAsync(); + if (!invocation?.command) { + throw new Error('Claude CLI path not resolved'); + } + return { command: invocation.command, env: invocation.env }; + } catch (error) { + debugError('[IPC] Failed to resolve Claude CLI path:', error); + return { + error: error instanceof Error ? error.message : 'Failed to resolve Claude CLI path', + }; + } +} + /** * Register all env-related IPC handlers @@ -573,7 +591,8 @@ ${existingVars['GRAPHITI_DB_PATH'] ? `GRAPHITI_DB_PATH=${existingVars['GRAPHITI_ return { success: false, error: 'Project not found' }; } - const resolved = resolveClaudeCliInvocation(); + // Use async version to avoid blocking main process during CLI detection + const resolved = await resolveClaudeCliInvocationAsync(); if ('error' in resolved) { return { success: false, error: resolved.error }; } @@ -663,7 +682,8 @@ ${existingVars['GRAPHITI_DB_PATH'] ? `GRAPHITI_DB_PATH=${existingVars['GRAPHITI_ return { success: false, error: 'Project not found' }; } - const resolved = resolveClaudeCliInvocation(); + // Use async version to avoid blocking main process during CLI detection + const resolved = await resolveClaudeCliInvocationAsync(); if ('error' in resolved) { return { success: false, error: resolved.error }; } diff --git a/apps/frontend/src/main/ipc-handlers/settings-handlers.ts b/apps/frontend/src/main/ipc-handlers/settings-handlers.ts index 493f9c46..9aecfca9 100644 --- a/apps/frontend/src/main/ipc-handlers/settings-handlers.ts +++ b/apps/frontend/src/main/ipc-handlers/settings-handlers.ts @@ -14,7 +14,7 @@ import { AgentManager } from '../agent'; import type { BrowserWindow } from 'electron'; import { setUpdateChannel, setUpdateChannelWithDowngradeCheck } from '../app-updater'; import { getSettingsPath, readSettingsFile } from '../settings-utils'; -import { configureTools, getToolPath, getToolInfo, isPathFromWrongPlatform } from '../cli-tool-manager'; +import { configureTools, getToolPath, getToolInfo, isPathFromWrongPlatform, preWarmToolCache } from '../cli-tool-manager'; import { parseEnvFile } from './utils'; const settingsPath = getSettingsPath(); @@ -171,6 +171,11 @@ export function registerSettingsHandlers( claudePath: settings.claudePath, }); + // Re-warm cache asynchronously after configuring (non-blocking) + preWarmToolCache(['claude']).catch((error) => { + console.warn('[SETTINGS_GET] Failed to re-warm CLI cache:', error); + }); + return { success: true, data: settings as AppSettings }; } ); @@ -212,6 +217,11 @@ export function registerSettingsHandlers( githubCLIPath: newSettings.githubCLIPath, claudePath: newSettings.claudePath, }); + + // Re-warm cache asynchronously after configuring (non-blocking) + preWarmToolCache(['claude']).catch((error) => { + console.warn('[SETTINGS_SAVE] Failed to re-warm CLI cache:', error); + }); } // Update auto-updater channel if betaUpdates setting changed diff --git a/apps/frontend/src/main/ipc-handlers/terminal-handlers.ts b/apps/frontend/src/main/ipc-handlers/terminal-handlers.ts index c5e98984..d68e8ab9 100644 --- a/apps/frontend/src/main/ipc-handlers/terminal-handlers.ts +++ b/apps/frontend/src/main/ipc-handlers/terminal-handlers.ts @@ -9,7 +9,7 @@ import { projectStore } from '../project-store'; import { terminalNameGenerator } from '../terminal-name-generator'; import { debugLog, debugError } from '../../shared/utils/debug-logger'; import { escapeShellArg, escapeShellArgWindows } from '../../shared/utils/shell-escape'; -import { getClaudeCliInvocation } from '../claude-cli-utils'; +import { getClaudeCliInvocationAsync } from '../claude-cli-utils'; /** @@ -54,7 +54,10 @@ export function registerTerminalHandlers( ipcMain.on( IPC_CHANNELS.TERMINAL_INVOKE_CLAUDE, (_, id: string, cwd?: string) => { - terminalManager.invokeClaude(id, cwd); + // Use async version to avoid blocking main process during CLI detection + terminalManager.invokeClaudeAsync(id, cwd).catch((error) => { + console.error('[terminal-handlers] Failed to invoke Claude:', error); + }); } ); @@ -354,7 +357,7 @@ export function registerTerminalHandlers( // Build the login command with the profile's config dir // Use platform-specific syntax and escaping for environment variables let loginCommand: string; - const { command: claudeCmd, env: claudeEnv } = getClaudeCliInvocation(); + const { command: claudeCmd, env: claudeEnv } = await getClaudeCliInvocationAsync(); const pathPrefix = claudeEnv.PATH ? (process.platform === 'win32' ? `set "PATH=${escapeShellArgWindows(claudeEnv.PATH)}" && ` @@ -635,7 +638,10 @@ export function registerTerminalHandlers( ipcMain.on( IPC_CHANNELS.TERMINAL_RESUME_CLAUDE, (_, id: string, sessionId?: string) => { - terminalManager.resumeClaude(id, sessionId); + // Use async version to avoid blocking main process during CLI detection + terminalManager.resumeClaudeAsync(id, sessionId).catch((error) => { + console.error('[terminal-handlers] Failed to resume Claude:', error); + }); } ); diff --git a/apps/frontend/src/main/terminal/__tests__/claude-integration-handler.test.ts b/apps/frontend/src/main/terminal/__tests__/claude-integration-handler.test.ts index a72f5550..739b58bd 100644 --- a/apps/frontend/src/main/terminal/__tests__/claude-integration-handler.test.ts +++ b/apps/frontend/src/main/terminal/__tests__/claude-integration-handler.test.ts @@ -60,6 +60,14 @@ vi.mock('../session-handler', () => ({ releaseSessionId: mockReleaseSessionId, })); +vi.mock('os', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + tmpdir: vi.fn(() => '/tmp'), + }; +}); + describe('claude-integration-handler', () => { beforeEach(() => { mockGetClaudeCliInvocation.mockClear(); @@ -407,3 +415,195 @@ describe('claude-integration-handler', () => { expect(mockPersistSession).not.toHaveBeenCalled(); }); }); + +/** + * Unit tests for helper functions + */ +describe('claude-integration-handler - Helper Functions', () => { + describe('buildClaudeShellCommand', () => { + it('should build default command without cwd or PATH prefix', async () => { + const { buildClaudeShellCommand } = await import('../claude-integration-handler'); + const result = buildClaudeShellCommand('', '', "'/opt/bin/claude'", { method: 'default' }); + + expect(result).toBe("'/opt/bin/claude'\r"); + }); + + it('should build command with cwd', async () => { + const { buildClaudeShellCommand } = await import('../claude-integration-handler'); + const result = buildClaudeShellCommand("cd '/tmp/project' && ", '', "'/opt/bin/claude'", { method: 'default' }); + + expect(result).toBe("cd '/tmp/project' && '/opt/bin/claude'\r"); + }); + + it('should build command with PATH prefix', async () => { + const { buildClaudeShellCommand } = await import('../claude-integration-handler'); + const result = buildClaudeShellCommand('', "PATH='/custom/path' ", "'/opt/bin/claude'", { method: 'default' }); + + expect(result).toBe("PATH='/custom/path' '/opt/bin/claude'\r"); + }); + + it('should build temp-file method command with history-safe prefixes', async () => { + const { buildClaudeShellCommand } = await import('../claude-integration-handler'); + const result = buildClaudeShellCommand( + "cd '/tmp/project' && ", + "PATH='/opt/bin' ", + "'/opt/bin/claude'", + { method: 'temp-file', escapedTempFile: "'/tmp/.token-123'" } + ); + + expect(result).toContain('clear && '); + expect(result).toContain("cd '/tmp/project' && "); + expect(result).toContain('HISTFILE= HISTCONTROL=ignorespace'); + expect(result).toContain("PATH='/opt/bin' "); + expect(result).toContain("source '/tmp/.token-123'"); + expect(result).toContain("rm -f '/tmp/.token-123'"); + expect(result).toContain("exec '/opt/bin/claude'"); + }); + + it('should build config-dir method command with CLAUDE_CONFIG_DIR', async () => { + const { buildClaudeShellCommand } = await import('../claude-integration-handler'); + const result = buildClaudeShellCommand( + "cd '/tmp/project' && ", + "PATH='/opt/bin' ", + "'/opt/bin/claude'", + { method: 'config-dir', escapedConfigDir: "'/home/user/.claude-work'" } + ); + + expect(result).toContain('clear && '); + expect(result).toContain("cd '/tmp/project' && "); + expect(result).toContain('HISTFILE= HISTCONTROL=ignorespace'); + expect(result).toContain("CLAUDE_CONFIG_DIR='/home/user/.claude-work'"); + expect(result).toContain("PATH='/opt/bin' "); + expect(result).toContain("exec '/opt/bin/claude'"); + }); + + it('should handle empty cwdCommand for temp-file method', async () => { + const { buildClaudeShellCommand } = await import('../claude-integration-handler'); + const result = buildClaudeShellCommand( + '', + '', + "'/opt/bin/claude'", + { method: 'temp-file', escapedTempFile: "'/tmp/.token'" } + ); + + expect(result).toContain('clear && '); + expect(result).toContain('HISTFILE= HISTCONTROL=ignorespace'); + expect(result).not.toContain('cd '); + expect(result).toContain("source '/tmp/.token'"); + }); + }); + + describe('finalizeClaudeInvoke', () => { + it('should set terminal title to "Claude" for default profile', async () => { + const { finalizeClaudeInvoke } = await import('../claude-integration-handler'); + const terminal = createMockTerminal(); + const mockWindow = { + webContents: { send: vi.fn() } + }; + + finalizeClaudeInvoke( + terminal, + { name: 'Default', isDefault: true }, + '/tmp/project', + Date.now(), + () => mockWindow as any, + vi.fn() + ); + + expect(terminal.title).toBe('Claude'); + }); + + it('should set terminal title to "Claude (ProfileName)" for non-default profile', async () => { + const { finalizeClaudeInvoke } = await import('../claude-integration-handler'); + const terminal = createMockTerminal(); + const mockWindow = { + webContents: { send: vi.fn() } + }; + + finalizeClaudeInvoke( + terminal, + { name: 'Work Profile', isDefault: false }, + '/tmp/project', + Date.now(), + () => mockWindow as any, + vi.fn() + ); + + expect(terminal.title).toBe('Claude (Work Profile)'); + }); + + it('should send IPC message to renderer', async () => { + const { finalizeClaudeInvoke } = await import('../claude-integration-handler'); + const terminal = createMockTerminal(); + const mockSend = vi.fn(); + const mockWindow = { + webContents: { send: mockSend } + }; + + finalizeClaudeInvoke( + terminal, + undefined, + '/tmp/project', + Date.now(), + () => mockWindow as any, + vi.fn() + ); + + expect(mockSend).toHaveBeenCalledWith( + expect.stringContaining('title'), + terminal.id, + 'Claude' + ); + }); + + it('should persist session when terminal has projectPath', async () => { + const { finalizeClaudeInvoke } = await import('../claude-integration-handler'); + const terminal = createMockTerminal({ projectPath: '/tmp/project' }); + + finalizeClaudeInvoke( + terminal, + undefined, + '/tmp/project', + Date.now(), + () => null, + vi.fn() + ); + + expect(mockPersistSession).toHaveBeenCalledWith(terminal); + }); + + it('should call onSessionCapture when projectPath is provided', async () => { + const { finalizeClaudeInvoke } = await import('../claude-integration-handler'); + const terminal = createMockTerminal(); + const mockOnSessionCapture = vi.fn(); + const startTime = Date.now(); + + finalizeClaudeInvoke( + terminal, + undefined, + '/tmp/project', + startTime, + () => null, + mockOnSessionCapture + ); + + expect(mockOnSessionCapture).toHaveBeenCalledWith(terminal.id, '/tmp/project', startTime); + }); + + it('should not crash when getWindow returns null', async () => { + const { finalizeClaudeInvoke } = await import('../claude-integration-handler'); + const terminal = createMockTerminal(); + + expect(() => { + finalizeClaudeInvoke( + terminal, + undefined, + '/tmp/project', + Date.now(), + () => null, + vi.fn() + ); + }).not.toThrow(); + }); + }); +}); diff --git a/apps/frontend/src/main/terminal/claude-integration-handler.ts b/apps/frontend/src/main/terminal/claude-integration-handler.ts index 27b6865a..ae420b2d 100644 --- a/apps/frontend/src/main/terminal/claude-integration-handler.ts +++ b/apps/frontend/src/main/terminal/claude-integration-handler.ts @@ -5,15 +5,16 @@ import * as os from 'os'; import * as fs from 'fs'; +import { promises as fsPromises } from 'fs'; import * as path from 'path'; import * as crypto from 'crypto'; import { IPC_CHANNELS } from '../../shared/constants'; -import { getClaudeProfileManager } from '../claude-profile-manager'; +import { getClaudeProfileManager, initializeClaudeProfileManager } from '../claude-profile-manager'; import * as OutputParser from './output-parser'; import * as SessionHandler from './session-handler'; import { debugLog, debugError } from '../../shared/utils/debug-logger'; import { escapeShellArg, buildCdCommand } from '../../shared/utils/shell-escape'; -import { getClaudeCliInvocation } from '../claude-cli-utils'; +import { getClaudeCliInvocation, getClaudeCliInvocationAsync } from '../claude-cli-utils'; import type { TerminalProcess, WindowGetter, @@ -25,6 +26,133 @@ function normalizePathForBash(envPath: string): string { return process.platform === 'win32' ? envPath.replace(/;/g, ':') : envPath; } +// ============================================================================ +// SHARED HELPERS - Used by both sync and async invokeClaude +// ============================================================================ + +/** + * Configuration for building Claude shell commands using discriminated union. + * This provides type safety by ensuring the correct options are provided for each method. + */ +type ClaudeCommandConfig = + | { method: 'default' } + | { method: 'temp-file'; escapedTempFile: string } + | { method: 'config-dir'; escapedConfigDir: string }; + +/** + * Build the shell command for invoking Claude CLI. + * + * Generates the appropriate command string based on the invocation method: + * - 'default': Simple command execution + * - 'temp-file': Sources OAuth token from temp file, then removes it + * - 'config-dir': Sets CLAUDE_CONFIG_DIR for custom profile location + * + * All non-default methods include history-safe prefixes (HISTFILE=, HISTCONTROL=) + * to prevent sensitive data from appearing in shell history. + * + * @param cwdCommand - Command to change directory (empty string if no change needed) + * @param pathPrefix - PATH prefix for Claude CLI (empty string if not needed) + * @param escapedClaudeCmd - Shell-escaped Claude CLI command + * @param config - Configuration object with method and required options (discriminated union) + * @returns Complete shell command string ready for terminal.pty.write() + * + * @example + * // Default method + * buildClaudeShellCommand('cd /path && ', 'PATH=/bin ', 'claude', { method: 'default' }); + * // Returns: 'cd /path && PATH=/bin claude\r' + * + * // Temp file method + * buildClaudeShellCommand('', '', 'claude', { method: 'temp-file', escapedTempFile: '/tmp/token' }); + * // Returns: 'clear && HISTFILE= HISTCONTROL=ignorespace bash -c "source /tmp/token && rm -f /tmp/token && exec claude"\r' + */ +export function buildClaudeShellCommand( + cwdCommand: string, + pathPrefix: string, + escapedClaudeCmd: string, + config: ClaudeCommandConfig +): string { + switch (config.method) { + case 'temp-file': + return `clear && ${cwdCommand}HISTFILE= HISTCONTROL=ignorespace ${pathPrefix}bash -c "source ${config.escapedTempFile} && rm -f ${config.escapedTempFile} && exec ${escapedClaudeCmd}"\r`; + + case 'config-dir': + return `clear && ${cwdCommand}HISTFILE= HISTCONTROL=ignorespace CLAUDE_CONFIG_DIR=${config.escapedConfigDir} ${pathPrefix}bash -c "exec ${escapedClaudeCmd}"\r`; + + default: + return `${cwdCommand}${pathPrefix}${escapedClaudeCmd}\r`; + } +} + +/** + * Profile information for terminal title generation + */ +interface ProfileInfo { + /** Profile name for display */ + name?: string; + /** Whether this is the default profile */ + isDefault?: boolean; +} + +/** + * Callback type for session capture + */ +type SessionCaptureCallback = (terminalId: string, projectPath: string, startTime: number) => void; + +/** + * Finalize terminal state after invoking Claude. + * + * Updates terminal title, sends IPC notification to renderer, persists session, + * and calls the session capture callback. This consolidates the post-invocation + * logic used by both sync and async invoke methods. + * + * @param terminal - The terminal process to update + * @param activeProfile - The profile being used (or undefined for default) + * @param projectPath - The project path (for session capture) + * @param startTime - Timestamp when invocation started + * @param getWindow - Function to get the BrowserWindow + * @param onSessionCapture - Callback for session capture + * + * @example + * finalizeClaudeInvoke( + * terminal, + * { name: 'Work', isDefault: false }, + * '/path/to/project', + * Date.now(), + * () => mainWindow, + * (id, path, time) => console.log('Session captured') + * ); + */ +export function finalizeClaudeInvoke( + terminal: TerminalProcess, + activeProfile: ProfileInfo | undefined, + projectPath: string | undefined, + startTime: number, + getWindow: WindowGetter, + onSessionCapture: SessionCaptureCallback +): void { + // Set terminal title based on profile + const title = activeProfile && !activeProfile.isDefault + ? `Claude (${activeProfile.name})` + : 'Claude'; + terminal.title = title; + + // Notify renderer of title change + const win = getWindow(); + if (win) { + win.webContents.send(IPC_CHANNELS.TERMINAL_TITLE_CHANGE, terminal.id, title); + } + + // Persist session if project path is available + if (terminal.projectPath) { + SessionHandler.persistSession(terminal); + } + + // Call session capture callback if project path provided + if (projectPath) { + onSessionCapture(terminal.id, projectPath, startTime); + } +} + /** * Handle rate limit detection and profile switching */ @@ -217,7 +345,6 @@ export function invokeClaude( debugLog('[ClaudeIntegration:invokeClaude] CWD:', cwd); terminal.isClaudeMode = true; - // Release any previously claimed session ID before starting new session SessionHandler.releaseSessionId(terminal.id); terminal.claudeSessionId = undefined; @@ -240,7 +367,6 @@ export function invokeClaude( isDefault: activeProfile?.isDefault }); - // Use safe shell escaping to prevent command injection const cwdCommand = buildCdCommand(cwd); const { command: claudeCmd, env: claudeEnv } = getClaudeCliInvocation(); const escapedClaudeCmd = escapeShellArg(claudeCmd); @@ -273,58 +399,20 @@ export function invokeClaude( { mode: 0o600 } ); - // Clear terminal and run command without adding to shell history: - // - HISTFILE= disables history file writing for the current command - // - HISTCONTROL=ignorespace causes commands starting with space to be ignored - // - Leading space ensures the command is ignored even if HISTCONTROL was already set - // - Uses subshell (...) to isolate environment changes - // This prevents temp file paths from appearing in shell history - const command = `clear && ${cwdCommand}HISTFILE= HISTCONTROL=ignorespace ${pathPrefix}bash -c "source ${escapedTempFile} && rm -f ${escapedTempFile} && exec ${escapedClaudeCmd}"\r`; + const command = buildClaudeShellCommand(cwdCommand, pathPrefix, escapedClaudeCmd, { method: 'temp-file', escapedTempFile }); debugLog('[ClaudeIntegration:invokeClaude] Executing command (temp file method, history-safe)'); terminal.pty.write(command); profileManager.markProfileUsed(activeProfile.id); - - // Update terminal title and persist session - const title = `Claude (${activeProfile.name})`; - terminal.title = title; - const win = getWindow(); - if (win) { - win.webContents.send(IPC_CHANNELS.TERMINAL_TITLE_CHANGE, terminal.id, title); - } - if (terminal.projectPath) { - SessionHandler.persistSession(terminal); - } - if (projectPath) { - onSessionCapture(terminal.id, projectPath, startTime); - } - + finalizeClaudeInvoke(terminal, activeProfile, projectPath, startTime, getWindow, onSessionCapture); debugLog('[ClaudeIntegration:invokeClaude] ========== INVOKE CLAUDE COMPLETE (temp file) =========='); return; } else if (activeProfile.configDir) { - // Clear terminal and run command without adding to shell history: - // Same history-disabling technique as temp file method above - // SECURITY: Use escapeShellArg for configDir to prevent command injection - // Set CLAUDE_CONFIG_DIR as env var before bash -c to avoid embedding user input in the command string const escapedConfigDir = escapeShellArg(activeProfile.configDir); - const command = `clear && ${cwdCommand}HISTFILE= HISTCONTROL=ignorespace CLAUDE_CONFIG_DIR=${escapedConfigDir} ${pathPrefix}bash -c "exec ${escapedClaudeCmd}"\r`; + const command = buildClaudeShellCommand(cwdCommand, pathPrefix, escapedClaudeCmd, { method: 'config-dir', escapedConfigDir }); debugLog('[ClaudeIntegration:invokeClaude] Executing command (configDir method, history-safe)'); terminal.pty.write(command); profileManager.markProfileUsed(activeProfile.id); - - // Update terminal title and persist session - const title = `Claude (${activeProfile.name})`; - terminal.title = title; - const win = getWindow(); - if (win) { - win.webContents.send(IPC_CHANNELS.TERMINAL_TITLE_CHANGE, terminal.id, title); - } - if (terminal.projectPath) { - SessionHandler.persistSession(terminal); - } - if (projectPath) { - onSessionCapture(terminal.id, projectPath, startTime); - } - + finalizeClaudeInvoke(terminal, activeProfile, projectPath, startTime, getWindow, onSessionCapture); debugLog('[ClaudeIntegration:invokeClaude] ========== INVOKE CLAUDE COMPLETE (configDir) =========='); return; } else { @@ -336,7 +424,7 @@ export function invokeClaude( debugLog('[ClaudeIntegration:invokeClaude] Using terminal environment for non-default profile:', activeProfile.name); } - const command = `${cwdCommand}${pathPrefix}${escapedClaudeCmd}\r`; + const command = buildClaudeShellCommand(cwdCommand, pathPrefix, escapedClaudeCmd, { method: 'default' }); debugLog('[ClaudeIntegration:invokeClaude] Executing command (default method):', command); terminal.pty.write(command); @@ -344,25 +432,7 @@ export function invokeClaude( profileManager.markProfileUsed(activeProfile.id); } - // Update terminal title in main process and notify renderer - const title = activeProfile && !activeProfile.isDefault - ? `Claude (${activeProfile.name})` - : 'Claude'; - terminal.title = title; - - const win = getWindow(); - if (win) { - win.webContents.send(IPC_CHANNELS.TERMINAL_TITLE_CHANGE, terminal.id, title); - } - - if (terminal.projectPath) { - SessionHandler.persistSession(terminal); - } - - if (projectPath) { - onSessionCapture(terminal.id, projectPath, startTime); - } - + finalizeClaudeInvoke(terminal, activeProfile, projectPath, startTime, getWindow, onSessionCapture); debugLog('[ClaudeIntegration:invokeClaude] ========== INVOKE CLAUDE COMPLETE (default) =========='); } @@ -421,6 +491,171 @@ export function resumeClaude( } } +// ============================================================================ +// ASYNC VERSIONS - Non-blocking alternatives for Electron main process +// ============================================================================ + +/** + * Invoke Claude asynchronously (non-blocking) + * + * Safe to call from Electron main process without blocking the event loop. + * Uses async CLI detection which doesn't block on subprocess calls. + */ +export async function invokeClaudeAsync( + terminal: TerminalProcess, + cwd: string | undefined, + profileId: string | undefined, + getWindow: WindowGetter, + onSessionCapture: (terminalId: string, projectPath: string, startTime: number) => void +): Promise { + debugLog('[ClaudeIntegration:invokeClaudeAsync] ========== INVOKE CLAUDE START (async) =========='); + debugLog('[ClaudeIntegration:invokeClaudeAsync] Terminal ID:', terminal.id); + debugLog('[ClaudeIntegration:invokeClaudeAsync] Requested profile ID:', profileId); + debugLog('[ClaudeIntegration:invokeClaudeAsync] CWD:', cwd); + + terminal.isClaudeMode = true; + SessionHandler.releaseSessionId(terminal.id); + terminal.claudeSessionId = undefined; + + const startTime = Date.now(); + const projectPath = cwd || terminal.projectPath || terminal.cwd; + + // Ensure profile manager is initialized (async, yields to event loop) + const profileManager = await initializeClaudeProfileManager(); + const activeProfile = profileId + ? profileManager.getProfile(profileId) + : profileManager.getActiveProfile(); + + const previousProfileId = terminal.claudeProfileId; + terminal.claudeProfileId = activeProfile?.id; + + debugLog('[ClaudeIntegration:invokeClaudeAsync] Profile resolution:', { + previousProfileId, + newProfileId: activeProfile?.id, + profileName: activeProfile?.name, + hasOAuthToken: !!activeProfile?.oauthToken, + isDefault: activeProfile?.isDefault + }); + + // Async CLI invocation - non-blocking + const cwdCommand = buildCdCommand(cwd); + const { command: claudeCmd, env: claudeEnv } = await getClaudeCliInvocationAsync(); + const escapedClaudeCmd = escapeShellArg(claudeCmd); + const pathPrefix = claudeEnv.PATH + ? `PATH=${escapeShellArg(normalizePathForBash(claudeEnv.PATH))} ` + : ''; + const needsEnvOverride = profileId && profileId !== previousProfileId; + + debugLog('[ClaudeIntegration:invokeClaudeAsync] Environment override check:', { + profileIdProvided: !!profileId, + previousProfileId, + needsEnvOverride + }); + + if (needsEnvOverride && activeProfile && !activeProfile.isDefault) { + const token = profileManager.getProfileToken(activeProfile.id); + debugLog('[ClaudeIntegration:invokeClaudeAsync] Token retrieval:', { + hasToken: !!token, + tokenLength: token?.length + }); + + if (token) { + const nonce = crypto.randomBytes(8).toString('hex'); + const tempFile = path.join(os.tmpdir(), `.claude-token-${Date.now()}-${nonce}`); + const escapedTempFile = escapeShellArg(tempFile); + debugLog('[ClaudeIntegration:invokeClaudeAsync] Writing token to temp file:', tempFile); + await fsPromises.writeFile( + tempFile, + `export CLAUDE_CODE_OAUTH_TOKEN=${escapeShellArg(token)}\n`, + { mode: 0o600 } + ); + + const command = buildClaudeShellCommand(cwdCommand, pathPrefix, escapedClaudeCmd, { method: 'temp-file', escapedTempFile }); + debugLog('[ClaudeIntegration:invokeClaudeAsync] Executing command (temp file method, history-safe)'); + terminal.pty.write(command); + profileManager.markProfileUsed(activeProfile.id); + finalizeClaudeInvoke(terminal, activeProfile, projectPath, startTime, getWindow, onSessionCapture); + debugLog('[ClaudeIntegration:invokeClaudeAsync] ========== INVOKE CLAUDE COMPLETE (temp file) =========='); + return; + } else if (activeProfile.configDir) { + const escapedConfigDir = escapeShellArg(activeProfile.configDir); + const command = buildClaudeShellCommand(cwdCommand, pathPrefix, escapedClaudeCmd, { method: 'config-dir', escapedConfigDir }); + debugLog('[ClaudeIntegration:invokeClaudeAsync] Executing command (configDir method, history-safe)'); + terminal.pty.write(command); + profileManager.markProfileUsed(activeProfile.id); + finalizeClaudeInvoke(terminal, activeProfile, projectPath, startTime, getWindow, onSessionCapture); + debugLog('[ClaudeIntegration:invokeClaudeAsync] ========== INVOKE CLAUDE COMPLETE (configDir) =========='); + return; + } else { + debugLog('[ClaudeIntegration:invokeClaudeAsync] WARNING: No token or configDir available for non-default profile'); + } + } + + if (activeProfile && !activeProfile.isDefault) { + debugLog('[ClaudeIntegration:invokeClaudeAsync] Using terminal environment for non-default profile:', activeProfile.name); + } + + const command = buildClaudeShellCommand(cwdCommand, pathPrefix, escapedClaudeCmd, { method: 'default' }); + debugLog('[ClaudeIntegration:invokeClaudeAsync] Executing command (default method):', command); + terminal.pty.write(command); + + if (activeProfile) { + profileManager.markProfileUsed(activeProfile.id); + } + + finalizeClaudeInvoke(terminal, activeProfile, projectPath, startTime, getWindow, onSessionCapture); + debugLog('[ClaudeIntegration:invokeClaudeAsync] ========== INVOKE CLAUDE COMPLETE (default) =========='); +} + +/** + * Resume Claude asynchronously (non-blocking) + * + * Safe to call from Electron main process without blocking the event loop. + * Uses async CLI detection which doesn't block on subprocess calls. + */ +export async function resumeClaudeAsync( + terminal: TerminalProcess, + sessionId: string | undefined, + getWindow: WindowGetter +): Promise { + terminal.isClaudeMode = true; + SessionHandler.releaseSessionId(terminal.id); + + // Async CLI invocation - non-blocking + const { command: claudeCmd, env: claudeEnv } = await getClaudeCliInvocationAsync(); + const escapedClaudeCmd = escapeShellArg(claudeCmd); + const pathPrefix = claudeEnv.PATH + ? `PATH=${escapeShellArg(normalizePathForBash(claudeEnv.PATH))} ` + : ''; + + // Always use --continue which resumes the most recent session in the current directory. + // This is more reliable than --resume with session IDs since Auto Claude already restores + // terminals to their correct cwd/projectPath. + // + // Note: We clear claudeSessionId because --continue doesn't track specific sessions, + // and we don't want stale IDs persisting through SessionHandler.persistSession(). + terminal.claudeSessionId = undefined; + + // Deprecation warning for callers still passing sessionId + if (sessionId) { + console.warn('[ClaudeIntegration:resumeClaudeAsync] sessionId parameter is deprecated and ignored; using claude --continue instead'); + } + + const command = `${pathPrefix}${escapedClaudeCmd} --continue`; + + terminal.pty.write(`${command}\r`); + + terminal.title = 'Claude'; + const win = getWindow(); + if (win) { + win.webContents.send(IPC_CHANNELS.TERMINAL_TITLE_CHANGE, terminal.id, 'Claude'); + } + + if (terminal.projectPath) { + SessionHandler.persistSession(terminal); + } +} + /** * Configuration for waiting for Claude to exit */ @@ -525,7 +760,7 @@ export async function switchClaudeProfile( terminal: TerminalProcess, profileId: string, getWindow: WindowGetter, - invokeClaudeCallback: (terminalId: string, cwd: string | undefined, profileId: string) => void, + invokeClaudeCallback: (terminalId: string, cwd: string | undefined, profileId: string) => Promise, clearRateLimitCallback: (terminalId: string) => void ): Promise<{ success: boolean; error?: string }> { // Always-on tracing @@ -543,7 +778,8 @@ export async function switchClaudeProfile( cwd: terminal.cwd }); - const profileManager = getClaudeProfileManager(); + // Ensure profile manager is initialized (async, yields to event loop) + const profileManager = await initializeClaudeProfileManager(); const profile = profileManager.getProfile(profileId); console.warn('[ClaudeIntegration:switchClaudeProfile] Profile found:', profile?.name || 'NOT FOUND'); @@ -611,7 +847,7 @@ export async function switchClaudeProfile( projectPath, profileId }); - invokeClaudeCallback(terminal.id, projectPath, profileId); + await invokeClaudeCallback(terminal.id, projectPath, profileId); debugLog('[ClaudeIntegration:switchClaudeProfile] Setting active profile in profile manager'); profileManager.setActiveProfile(profileId); diff --git a/apps/frontend/src/main/terminal/terminal-manager.ts b/apps/frontend/src/main/terminal/terminal-manager.ts index 72e7b983..2f86c218 100644 --- a/apps/frontend/src/main/terminal/terminal-manager.ts +++ b/apps/frontend/src/main/terminal/terminal-manager.ts @@ -82,7 +82,10 @@ export class TerminalManager { ); }, onResumeNeeded: (terminalId, sessionId) => { - this.resumeClaude(terminalId, sessionId); + // Use async version to avoid blocking main process + this.resumeClaudeAsync(terminalId, sessionId).catch((error) => { + console.error('[terminal-manager] Failed to resume Claude session:', error); + }); } }, cols, @@ -133,8 +136,35 @@ export class TerminalManager { } } + /** + * Invoke Claude in a terminal with optional profile override (async - non-blocking) + */ + async invokeClaudeAsync(id: string, cwd?: string, profileId?: string): Promise { + const terminal = this.terminals.get(id); + if (!terminal) { + return; + } + + await ClaudeIntegration.invokeClaudeAsync( + terminal, + cwd, + profileId, + this.getWindow, + (terminalId, projectPath, startTime) => { + SessionHandler.captureClaudeSessionId( + terminalId, + projectPath, + startTime, + this.terminals, + this.getWindow + ); + } + ); + } + /** * Invoke Claude in a terminal with optional profile override + * @deprecated Use invokeClaudeAsync for non-blocking behavior */ invokeClaude(id: string, cwd?: string, profileId?: string): void { const terminal = this.terminals.get(id); @@ -172,13 +202,26 @@ export class TerminalManager { terminal, profileId, this.getWindow, - (terminalId, cwd, profileId) => this.invokeClaude(terminalId, cwd, profileId), + async (terminalId, cwd, profileId) => this.invokeClaudeAsync(terminalId, cwd, profileId), (terminalId) => this.lastNotifiedRateLimitReset.delete(terminalId) ); } + /** + * Resume Claude in a terminal asynchronously (non-blocking) + */ + async resumeClaudeAsync(id: string, sessionId?: string): Promise { + const terminal = this.terminals.get(id); + if (!terminal) { + return; + } + + await ClaudeIntegration.resumeClaudeAsync(terminal, sessionId, this.getWindow); + } + /** * Resume Claude in a terminal with a specific session ID + * @deprecated Use resumeClaudeAsync for non-blocking behavior */ resumeClaude(id: string, sessionId?: string): void { const terminal = this.terminals.get(id); @@ -244,7 +287,10 @@ export class TerminalManager { ); }, onResumeNeeded: (terminalId, sessionId) => { - this.resumeClaude(terminalId, sessionId); + // Use async version to avoid blocking main process + this.resumeClaudeAsync(terminalId, sessionId).catch((error) => { + console.error('[terminal-manager] Failed to resume Claude session:', error); + }); } }, cols, diff --git a/apps/frontend/src/main/utils/windows-paths.ts b/apps/frontend/src/main/utils/windows-paths.ts index 27ce4f9b..355640ac 100644 --- a/apps/frontend/src/main/utils/windows-paths.ts +++ b/apps/frontend/src/main/utils/windows-paths.ts @@ -9,10 +9,14 @@ */ import { existsSync } from 'fs'; -import { execFileSync } from 'child_process'; +import { access, constants } from 'fs/promises'; +import { execFileSync, execFile } from 'child_process'; +import { promisify } from 'util'; import path from 'path'; import os from 'os'; +const execFileAsync = promisify(execFile); + export interface WindowsToolPaths { toolName: string; executable: string; @@ -170,3 +174,110 @@ export function findWindowsExecutableViaWhere( return null; } } + +/** + * Async version of getWindowsExecutablePaths. + * Use this in async contexts to avoid blocking the main process. + */ +export async function getWindowsExecutablePathsAsync( + toolPaths: WindowsToolPaths, + logPrefix: string = '[Windows Paths]' +): Promise { + // Only run on Windows + if (process.platform !== 'win32') { + return []; + } + + const validPaths: string[] = []; + + for (const pattern of toolPaths.patterns) { + const expandedDir = expandWindowsPath(pattern); + + if (!expandedDir) { + console.warn(`${logPrefix} Could not expand path pattern: ${pattern}`); + continue; + } + + const fullPath = path.join(expandedDir, toolPaths.executable); + + // Security validation - reject potentially dangerous paths + if (!isSecurePath(fullPath)) { + console.warn(`${logPrefix} Path failed security validation: ${fullPath}`); + continue; + } + + try { + await access(fullPath, constants.F_OK); + validPaths.push(fullPath); + } catch { + // File doesn't exist, skip + } + } + + return validPaths; +} + +/** + * Async version of findWindowsExecutableViaWhere. + * Use this in async contexts to avoid blocking the main process. + * + * Find a Windows executable using the `where` command. + * This is the most reliable method as it searches: + * - All directories in PATH + * - App Paths registry entries + * - Current directory + * + * Works regardless of where the tool is installed (custom paths, different drives, etc.) + * + * @param executable - The executable name (e.g., 'git', 'gh', 'python') + * @param logPrefix - Prefix for console logging + * @returns The full path to the executable, or null if not found + */ +export async function findWindowsExecutableViaWhereAsync( + executable: string, + logPrefix: string = '[Windows Where]' +): Promise { + if (process.platform !== 'win32') { + return null; + } + + // Security: Only allow simple executable names (alphanumeric, dash, underscore, dot) + if (!/^[\w.-]+$/.test(executable)) { + console.warn(`${logPrefix} Invalid executable name: ${executable}`); + return null; + } + + try { + // Use 'where' command to find the executable + // where.exe is a built-in Windows command that finds executables + const { stdout } = await execFileAsync('where.exe', [executable], { + encoding: 'utf-8', + timeout: 5000, + windowsHide: true, + }); + + // 'where' returns multiple paths separated by newlines if found in multiple locations + // We take the first one (highest priority in PATH) + const paths = stdout.trim().split(/\r?\n/).filter(p => p.trim()); + + if (paths.length > 0) { + const foundPath = paths[0].trim(); + + // Validate the path exists and is secure + try { + await access(foundPath, constants.F_OK); + if (isSecurePath(foundPath)) { + console.log(`${logPrefix} Found via where: ${foundPath}`); + return foundPath; + } + } catch { + // Path doesn't exist + } + } + + return null; + } catch { + // 'where' returns exit code 1 if not found, which throws an error + return null; + } +} diff --git a/package-lock.json b/package-lock.json index d16fa47c..842c9c6a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -118,6 +118,98 @@ "npm": ">=10.0.0" } }, + "apps/frontend/node_modules/@lydell/node-pty": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@lydell/node-pty/-/node-pty-1.1.0.tgz", + "integrity": "sha512-VDD8LtlMTOrPKWMXUAcB9+LTktzuunqrMwkYR1DMRBkS6LQrCt+0/Ws1o2rMml/n3guePpS7cxhHF7Nm5K4iMw==", + "license": "MIT", + "optionalDependencies": { + "@lydell/node-pty-darwin-arm64": "1.1.0", + "@lydell/node-pty-darwin-x64": "1.1.0", + "@lydell/node-pty-linux-arm64": "1.1.0", + "@lydell/node-pty-linux-x64": "1.1.0", + "@lydell/node-pty-win32-arm64": "1.1.0", + "@lydell/node-pty-win32-x64": "1.1.0" + } + }, + "apps/frontend/node_modules/@lydell/node-pty-darwin-arm64": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@lydell/node-pty-darwin-arm64/-/node-pty-darwin-arm64-1.1.0.tgz", + "integrity": "sha512-7kFD+owAA61qmhJCtoMbqj3Uvff3YHDiU+4on5F2vQdcMI3MuwGi7dM6MkFG/yuzpw8LF2xULpL71tOPUfxs0w==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "apps/frontend/node_modules/@lydell/node-pty-darwin-x64": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@lydell/node-pty-darwin-x64/-/node-pty-darwin-x64-1.1.0.tgz", + "integrity": "sha512-XZdvqj5FjAMjH8bdp0YfaZjur5DrCIDD1VYiE9EkkYVMDQqRUPHYV3U8BVEQVT9hYfjmpr7dNaELF2KyISWSNA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "apps/frontend/node_modules/@lydell/node-pty-linux-arm64": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@lydell/node-pty-linux-arm64/-/node-pty-linux-arm64-1.1.0.tgz", + "integrity": "sha512-yyDBmalCfHpLiQMT2zyLcqL2Fay4Xy7rIs8GH4dqKLnEviMvPGOK7LADVkKAsbsyXBSISL3Lt1m1MtxhPH6ckg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "apps/frontend/node_modules/@lydell/node-pty-linux-x64": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@lydell/node-pty-linux-x64/-/node-pty-linux-x64-1.1.0.tgz", + "integrity": "sha512-NcNqRTD14QT+vXcEuqSSvmWY+0+WUBn2uRE8EN0zKtDpIEr9d+YiFj16Uqds6QfcLCHfZmC+Ls7YzwTaqDnanA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "apps/frontend/node_modules/@lydell/node-pty-win32-arm64": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@lydell/node-pty-win32-arm64/-/node-pty-win32-arm64-1.1.0.tgz", + "integrity": "sha512-JOMbCou+0fA7d/m97faIIfIU0jOv8sn2OR7tI45u3AmldKoKoLP8zHY6SAvDDnI3fccO1R2HeR1doVjpS7HM0w==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "apps/frontend/node_modules/@lydell/node-pty-win32-x64": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@lydell/node-pty-win32-x64/-/node-pty-win32-x64-1.1.0.tgz", + "integrity": "sha512-3N56BZ+WDFnUMYRtsrr7Ky2mhWGl9xXcyqR6cexfuCqcz9RNWL+KoXRv/nZylY5dYaXkft4JaR1uVu+roiZDAw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, "node_modules/@acemir/cssom": { "version": "0.9.30", "resolved": "https://registry.npmjs.org/@acemir/cssom/-/cssom-0.9.30.tgz", @@ -2563,98 +2655,6 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, - "node_modules/@lydell/node-pty": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@lydell/node-pty/-/node-pty-1.1.0.tgz", - "integrity": "sha512-VDD8LtlMTOrPKWMXUAcB9+LTktzuunqrMwkYR1DMRBkS6LQrCt+0/Ws1o2rMml/n3guePpS7cxhHF7Nm5K4iMw==", - "license": "MIT", - "optionalDependencies": { - "@lydell/node-pty-darwin-arm64": "1.1.0", - "@lydell/node-pty-darwin-x64": "1.1.0", - "@lydell/node-pty-linux-arm64": "1.1.0", - "@lydell/node-pty-linux-x64": "1.1.0", - "@lydell/node-pty-win32-arm64": "1.1.0", - "@lydell/node-pty-win32-x64": "1.1.0" - } - }, - "node_modules/@lydell/node-pty-darwin-arm64": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@lydell/node-pty-darwin-arm64/-/node-pty-darwin-arm64-1.1.0.tgz", - "integrity": "sha512-7kFD+owAA61qmhJCtoMbqj3Uvff3YHDiU+4on5F2vQdcMI3MuwGi7dM6MkFG/yuzpw8LF2xULpL71tOPUfxs0w==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@lydell/node-pty-darwin-x64": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@lydell/node-pty-darwin-x64/-/node-pty-darwin-x64-1.1.0.tgz", - "integrity": "sha512-XZdvqj5FjAMjH8bdp0YfaZjur5DrCIDD1VYiE9EkkYVMDQqRUPHYV3U8BVEQVT9hYfjmpr7dNaELF2KyISWSNA==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@lydell/node-pty-linux-arm64": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@lydell/node-pty-linux-arm64/-/node-pty-linux-arm64-1.1.0.tgz", - "integrity": "sha512-yyDBmalCfHpLiQMT2zyLcqL2Fay4Xy7rIs8GH4dqKLnEviMvPGOK7LADVkKAsbsyXBSISL3Lt1m1MtxhPH6ckg==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@lydell/node-pty-linux-x64": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@lydell/node-pty-linux-x64/-/node-pty-linux-x64-1.1.0.tgz", - "integrity": "sha512-NcNqRTD14QT+vXcEuqSSvmWY+0+WUBn2uRE8EN0zKtDpIEr9d+YiFj16Uqds6QfcLCHfZmC+Ls7YzwTaqDnanA==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@lydell/node-pty-win32-arm64": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@lydell/node-pty-win32-arm64/-/node-pty-win32-arm64-1.1.0.tgz", - "integrity": "sha512-JOMbCou+0fA7d/m97faIIfIU0jOv8sn2OR7tI45u3AmldKoKoLP8zHY6SAvDDnI3fccO1R2HeR1doVjpS7HM0w==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@lydell/node-pty-win32-x64": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@lydell/node-pty-win32-x64/-/node-pty-win32-x64-1.1.0.tgz", - "integrity": "sha512-3N56BZ+WDFnUMYRtsrr7Ky2mhWGl9xXcyqR6cexfuCqcz9RNWL+KoXRv/nZylY5dYaXkft4JaR1uVu+roiZDAw==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, "node_modules/@malept/cross-spawn-promise": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/@malept/cross-spawn-promise/-/cross-spawn-promise-2.0.0.tgz",