diff --git a/apps/frontend/src/main/changelog/git-integration.ts b/apps/frontend/src/main/changelog/git-integration.ts index a9dbff56..226556a4 100644 --- a/apps/frontend/src/main/changelog/git-integration.ts +++ b/apps/frontend/src/main/changelog/git-integration.ts @@ -1,4 +1,4 @@ -import { execSync } from 'child_process'; +import { execSync, execFileSync } from 'child_process'; import type { GitBranchInfo, GitTagInfo, @@ -7,6 +7,7 @@ import type { BranchDiffOptions } from '../../shared/types'; import { parseGitLogOutput } from './parser'; +import { getToolPath } from '../cli-tool-manager'; /** * Debug logging helper @@ -25,7 +26,7 @@ export function getBranches(projectPath: string, debugEnabled = false): GitBranc // Get current branch let currentBranch = ''; try { - currentBranch = execSync('git rev-parse --abbrev-ref HEAD', { + currentBranch = execFileSync(getToolPath('git'), ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: projectPath, encoding: 'utf-8' }).trim(); @@ -34,7 +35,7 @@ export function getBranches(projectPath: string, debugEnabled = false): GitBranc } // Get all branches (local and remote) - const output = execSync('git branch -a --format="%(refname:short)|%(HEAD)"', { + const output = execFileSync(getToolPath('git'), ['branch', '-a', '--format=%(refname:short)|%(HEAD)'], { cwd: projectPath, encoding: 'utf-8' }); @@ -125,7 +126,7 @@ export function getTags(projectPath: string, debugEnabled = false): GitTagInfo[] */ export function getCurrentBranch(projectPath: string): string { try { - return execSync('git rev-parse --abbrev-ref HEAD', { + return execFileSync(getToolPath('git'), ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: projectPath, encoding: 'utf-8' }).trim(); @@ -140,7 +141,7 @@ export function getCurrentBranch(projectPath: string): string { export function getDefaultBranch(projectPath: string): string { try { // Try to get from origin/HEAD - const result = execSync('git rev-parse --abbrev-ref origin/HEAD', { + const result = execFileSync(getToolPath('git'), ['rev-parse', '--abbrev-ref', 'origin/HEAD'], { cwd: projectPath, encoding: 'utf-8' }).trim(); @@ -148,14 +149,14 @@ export function getDefaultBranch(projectPath: string): string { } catch { // Fallback: check if main or master exists try { - execSync('git rev-parse --verify main', { + execFileSync(getToolPath('git'), ['rev-parse', '--verify', 'main'], { cwd: projectPath, encoding: 'utf-8' }); return 'main'; } catch { try { - execSync('git rev-parse --verify master', { + execFileSync(getToolPath('git'), ['rev-parse', '--verify', 'master'], { cwd: projectPath, encoding: 'utf-8' }); diff --git a/apps/frontend/src/main/cli-tool-manager.ts b/apps/frontend/src/main/cli-tool-manager.ts new file mode 100644 index 00000000..8c455da0 --- /dev/null +++ b/apps/frontend/src/main/cli-tool-manager.ts @@ -0,0 +1,734 @@ +/** + * CLI Tool Manager + * + * Centralized management for CLI tools (Python, Git, GitHub CLI) used throughout + * the application. Provides intelligent multi-level detection with user + * configuration support. + * + * Detection Priority (for each tool): + * 1. User configuration (from settings.json) + * 2. Virtual environment (Python only - project-specific venv) + * 3. Homebrew (macOS - architecture-aware for Apple Silicon vs Intel) + * 4. System PATH (augmented with common binary locations) + * 5. Platform-specific standard locations + * + * Features: + * - Session-based caching (no TTL - cache persists until app restart or settings + * change) + * - Version validation (Python 3.10+ required for claude-agent-sdk) + * - Platform-aware detection (macOS, Windows, Linux) + * - Graceful fallbacks when tools not found + */ + +import { execSync, execFileSync } from 'child_process'; +import { existsSync } from 'fs'; +import path from 'path'; +import { app } from 'electron'; +import { findExecutable } from './env-utils'; +import type { ToolDetectionResult } from '../shared/types'; + +/** + * Supported CLI tools managed by this system + */ +export type CLITool = 'python' | 'git' | 'gh'; + +/** + * User configuration for CLI tool paths + * Maps to settings stored in settings.json + */ +export interface ToolConfig { + pythonPath?: string; + gitPath?: string; + githubCLIPath?: string; +} + +/** + * Internal validation result for a CLI tool + */ +interface ToolValidation { + valid: boolean; + version?: string; + message: string; +} + +/** + * Cache entry for detected tool path + * No timestamp - cache persists for entire app session + */ +interface CacheEntry { + path: string; + version?: string; + source: string; +} + +/** + * Centralized CLI Tool Manager + * + * Singleton class that manages detection, validation, and caching of CLI tool + * paths. Supports user configuration overrides and intelligent auto-detection. + * + * Usage: + * import { getToolPath, configureTools } from './cli-tool-manager'; + * + * // Configure with user settings (optional) + * configureTools({ pythonPath: '/custom/python3', gitPath: '/custom/git' }); + * + * // Get tool path (auto-detects if not configured) + * const pythonPath = getToolPath('python'); + * const gitPath = getToolPath('git'); + */ +class CLIToolManager { + private cache: Map = new Map(); + private userConfig: ToolConfig = {}; + + /** + * Configure the tool manager with user settings + * + * Clears the cache to force re-detection with new configuration. + * Call this when user changes CLI tool paths in Settings. + * + * @param config - User configuration for CLI tool paths + */ + configure(config: ToolConfig): void { + this.userConfig = config; + this.cache.clear(); + console.warn('[CLI Tools] Configuration updated, cache cleared'); + } + + /** + * Get the path for a specific CLI tool + * + * Uses cached path if available, otherwise detects and caches. + * Cache persists for entire app session (no expiration). + * + * @param tool - The CLI tool to get the path for + * @returns The resolved path to the tool executable + */ + getToolPath(tool: CLITool): string { + // Check cache first + const cached = this.cache.get(tool); + if (cached) { + console.warn( + `[CLI Tools] Using cached ${tool}: ${cached.path} (${cached.source})` + ); + return cached.path; + } + + // Detect and cache + const result = this.detectToolPath(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 the path for a specific CLI tool + * + * Implements multi-level detection strategy based on tool type. + * + * @param tool - The tool to detect + * @returns Detection result with path and metadata + */ + private detectToolPath(tool: CLITool): ToolDetectionResult { + switch (tool) { + case 'python': + return this.detectPython(); + case 'git': + return this.detectGit(); + case 'gh': + return this.detectGitHubCLI(); + default: + return { + found: false, + source: 'fallback', + message: `Unknown tool: ${tool}`, + }; + } + } + + /** + * Detect Python with multi-level priority + * + * Priority order: + * 1. User configuration + * 2. Bundled Python (packaged apps only) + * 3. Homebrew Python (macOS) + * 4. System PATH (py -3, python3, python) + * + * Validates Python version >= 3.10.0 (required by claude-agent-sdk) + * + * @returns Detection result for Python + */ + private detectPython(): ToolDetectionResult { + const MINIMUM_VERSION = '3.10.0'; + + // 1. User configuration + if (this.userConfig.pythonPath) { + const validation = this.validatePython(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 = this.validatePython(bundledPath); + if (validation.valid) { + return { + found: true, + path: bundledPath, + version: validation.version, + source: 'bundled', + message: `Using bundled Python: ${bundledPath}`, + }; + } + } + } + + // 3. Homebrew Python (macOS) + if (process.platform === 'darwin') { + const homebrewPath = this.findHomebrewPython(); + if (homebrewPath) { + const validation = this.validatePython(homebrewPath); + if (validation.valid) { + return { + found: true, + path: homebrewPath, + version: validation.version, + source: 'homebrew', + message: `Using Homebrew Python: ${homebrewPath}`, + }; + } + } + } + + // 4. System PATH (augmented) + const candidates = + process.platform === 'win32' + ? ['py -3', 'python', 'python3', 'py'] + : ['python3', 'python']; + + for (const cmd of candidates) { + // Special handling for Windows 'py -3' launcher + if (cmd.startsWith('py ')) { + const validation = this.validatePython(cmd); + if (validation.valid) { + return { + found: true, + path: cmd, + version: validation.version, + source: 'system-path', + message: `Using system Python: ${cmd}`, + }; + } + } else { + // For regular python/python3, find the actual path + const pythonPath = findExecutable(cmd); + if (pythonPath) { + const validation = this.validatePython(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 with multi-level priority + * + * Priority order: + * 1. User configuration + * 2. Homebrew Git (macOS) + * 3. System PATH + * + * @returns Detection result for Git + */ + private detectGit(): ToolDetectionResult { + // 1. User configuration + if (this.userConfig.gitPath) { + const validation = this.validateGit(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', // Apple Silicon + '/usr/local/bin/git', // Intel Mac + ]; + + for (const gitPath of homebrewPaths) { + if (existsSync(gitPath)) { + const validation = this.validateGit(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 = findExecutable('git'); + if (gitPath) { + const validation = this.validateGit(gitPath); + if (validation.valid) { + return { + found: true, + path: gitPath, + version: validation.version, + source: 'system-path', + message: `Using system Git: ${gitPath}`, + }; + } + } + + // 4. Not found - fallback to 'git' + return { + found: false, + source: 'fallback', + message: 'Git not found in standard locations. Using fallback "git".', + }; + } + + /** + * Detect GitHub CLI with multi-level priority + * + * Priority order: + * 1. User configuration + * 2. Homebrew gh (macOS) + * 3. System PATH + * 4. Windows Program Files + * + * @returns Detection result for GitHub CLI + */ + private detectGitHubCLI(): ToolDetectionResult { + // 1. User configuration + if (this.userConfig.githubCLIPath) { + const validation = this.validateGitHubCLI(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', // Apple Silicon + '/usr/local/bin/gh', // Intel Mac + ]; + + for (const ghPath of homebrewPaths) { + if (existsSync(ghPath)) { + const validation = this.validateGitHubCLI(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 = findExecutable('gh'); + if (ghPath) { + const validation = this.validateGitHubCLI(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 ghPath of windowsPaths) { + if (existsSync(ghPath)) { + const validation = this.validateGitHubCLI(ghPath); + if (validation.valid) { + return { + found: true, + path: ghPath, + version: validation.version, + source: 'system-path', + message: `Using Windows GitHub CLI: ${ghPath}`, + }; + } + } + } + } + + // 5. Not found + return { + found: false, + source: 'fallback', + message: 'GitHub CLI (gh) not found. Install from https://cli.github.com', + }; + } + + /** + * Validate Python version and availability + * + * Checks that Python executable exists and meets minimum version requirement + * (3.10.0+) for claude-agent-sdk compatibility. + * + * @param pythonCmd - The Python command to validate + * @returns Validation result with version information + */ + private validatePython(pythonCmd: string): ToolValidation { + const MINIMUM_VERSION = '3.10.0'; + + try { + const version = execSync(`${pythonCmd} --version`, { + stdio: 'pipe', + timeout: 5000, + windowsHide: true, + }) + .toString() + .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 availability and version + * + * @param gitCmd - The Git command to validate + * @returns Validation result with version information + */ + private validateGit(gitCmd: string): ToolValidation { + try { + const version = execFileSync(gitCmd, ['--version'], { + encoding: 'utf-8', + timeout: 5000, + windowsHide: true, + }).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 availability and version + * + * @param ghCmd - The GitHub CLI command to validate + * @returns Validation result with version information + */ + private validateGitHubCLI(ghCmd: string): ToolValidation { + try { + const version = execFileSync(ghCmd, ['--version'], { + encoding: 'utf-8', + timeout: 5000, + windowsHide: true, + }).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)}`, + }; + } + } + + /** + * Get bundled Python path for packaged apps + * + * Only available in packaged Electron apps where Python is bundled + * in the resources directory. + * + * @returns Path to bundled Python or null if not found + */ + private getBundledPythonPath(): string | null { + if (!app.isPackaged) { + return null; + } + + const resourcesPath = process.resourcesPath; + const isWindows = process.platform === 'win32'; + + const pythonPath = isWindows + ? path.join(resourcesPath, 'python', 'python.exe') + : path.join(resourcesPath, 'python', 'bin', 'python3'); + + return existsSync(pythonPath) ? pythonPath : null; + } + + /** + * Find Homebrew Python on macOS + * + * Checks both Apple Silicon and Intel Homebrew locations. + * Searches for python3, python3.13, python3.12, etc. in order. + * + * @returns Path to Homebrew Python or null if not found + */ + private findHomebrewPython(): string | null { + const homebrewDirs = [ + '/opt/homebrew/bin', // Apple Silicon + '/usr/local/bin', // Intel Mac + ]; + + // Check for generic python3 first, then specific versions (newest first) + const pythonNames = [ + 'python3', + 'python3.13', + 'python3.12', + 'python3.11', + 'python3.10', + ]; + + for (const dir of homebrewDirs) { + for (const name of pythonNames) { + const pythonPath = path.join(dir, name); + if (existsSync(pythonPath)) { + return pythonPath; + } + } + } + + return null; + } + + /** + * Clear cache manually + * + * Useful for testing or forcing re-detection. + * Normally not needed as cache is cleared automatically on settings change. + */ + clearCache(): void { + this.cache.clear(); + console.warn('[CLI Tools] Cache cleared'); + } + + /** + * Get tool detection info for diagnostics + * + * Performs fresh detection without using cache. + * Useful for Settings UI to show current detection status. + * + * @param tool - The tool to get detection info for + * @returns Detection result with full metadata + */ + getToolInfo(tool: CLITool): ToolDetectionResult { + return this.detectToolPath(tool); + } +} + +// Singleton instance +const cliToolManager = new CLIToolManager(); + +/** + * Get the path for a CLI tool + * + * Convenience function for accessing the tool manager singleton. + * Uses cached path if available, otherwise auto-detects. + * + * @param tool - The CLI tool to get the path for + * @returns The resolved path to the tool executable + * + * @example + * ```typescript + * import { getToolPath } from './cli-tool-manager'; + * + * const pythonPath = getToolPath('python'); + * const gitPath = getToolPath('git'); + * const ghPath = getToolPath('gh'); + * + * execSync(`${gitPath} status`, { cwd: projectPath }); + * ``` + */ +export function getToolPath(tool: CLITool): string { + return cliToolManager.getToolPath(tool); +} + +/** + * Configure CLI tools with user settings + * + * Call this when user updates CLI tool paths in Settings. + * Clears cache to force re-detection with new configuration. + * + * @param config - User configuration for CLI tool paths + * + * @example + * ```typescript + * import { configureTools } from './cli-tool-manager'; + * + * // When settings are loaded or updated + * configureTools({ + * pythonPath: settings.pythonPath, + * gitPath: settings.gitPath, + * githubCLIPath: settings.githubCLIPath, + * }); + * ``` + */ +export function configureTools(config: ToolConfig): void { + cliToolManager.configure(config); +} + +/** + * Get tool detection info for diagnostics + * + * Performs fresh detection and returns full metadata. + * Useful for Settings UI to show detection status and version. + * + * @param tool - The tool to get detection info for + * @returns Detection result with path, version, and source + * + * @example + * ```typescript + * import { getToolInfo } from './cli-tool-manager'; + * + * const pythonInfo = getToolInfo('python'); + * console.log(`Found: ${pythonInfo.found}`); + * console.log(`Path: ${pythonInfo.path}`); + * console.log(`Version: ${pythonInfo.version}`); + * console.log(`Source: ${pythonInfo.source}`); + * ``` + */ +export function getToolInfo(tool: CLITool): ToolDetectionResult { + return cliToolManager.getToolInfo(tool); +} + +/** + * Clear tool path cache manually + * + * Forces re-detection on next getToolPath() call. + * Normally not needed as cache is cleared automatically on settings change. + * + * @example + * ```typescript + * import { clearToolCache } from './cli-tool-manager'; + * + * // Force re-detection (e.g., after installing new tools) + * clearToolCache(); + * ``` + */ +export function clearToolCache(): void { + cliToolManager.clearCache(); +} diff --git a/apps/frontend/src/main/ipc-handlers/github/__tests__/oauth-handlers.spec.ts b/apps/frontend/src/main/ipc-handlers/github/__tests__/oauth-handlers.spec.ts index 6136fdf3..48b8c255 100644 --- a/apps/frontend/src/main/ipc-handlers/github/__tests__/oauth-handlers.spec.ts +++ b/apps/frontend/src/main/ipc-handlers/github/__tests__/oauth-handlers.spec.ts @@ -423,11 +423,8 @@ describe('GitHub OAuth Handlers', () => { describe('gh CLI Check Handler', () => { it('should return installed: true when gh CLI is found', async () => { - mockExecSync.mockImplementation((cmd: string) => { - if (cmd.includes('which gh') || cmd.includes('where gh')) { - return '/usr/local/bin/gh\n'; - } - if (cmd === 'gh --version') { + mockExecFileSync.mockImplementation((cmd: string, args?: string[]) => { + if (args && args[0] === '--version') { return 'gh version 2.65.0 (2024-01-15)\n'; } return ''; @@ -445,7 +442,7 @@ describe('GitHub OAuth Handlers', () => { }); it('should return installed: false when gh CLI is not found', async () => { - mockExecSync.mockImplementation(() => { + mockExecFileSync.mockImplementation(() => { throw new Error('Command not found'); }); @@ -462,11 +459,11 @@ describe('GitHub OAuth Handlers', () => { describe('gh Auth Check Handler', () => { it('should return authenticated: true with username when logged in', async () => { - mockExecSync.mockImplementation((cmd: string) => { - if (cmd === 'gh auth status') { + mockExecFileSync.mockImplementation((cmd: string, args?: string[]) => { + if (args && args[0] === 'auth' && args[1] === 'status') { return 'Logged in to github.com as testuser\n'; } - if (cmd === 'gh api user --jq .login') { + if (args && args[0] === 'api' && args[1] === 'user' && args[2] === '--jq' && args[3] === '.login') { return 'testuser\n'; } return ''; @@ -484,7 +481,7 @@ describe('GitHub OAuth Handlers', () => { }); it('should return authenticated: false when not logged in', async () => { - mockExecSync.mockImplementation(() => { + mockExecFileSync.mockImplementation(() => { throw new Error('You are not logged into any GitHub hosts'); }); diff --git a/apps/frontend/src/main/ipc-handlers/github/oauth-handlers.ts b/apps/frontend/src/main/ipc-handlers/github/oauth-handlers.ts index dfe3a1a8..81d8cd81 100644 --- a/apps/frontend/src/main/ipc-handlers/github/oauth-handlers.ts +++ b/apps/frontend/src/main/ipc-handlers/github/oauth-handlers.ts @@ -8,6 +8,7 @@ import { execSync, execFileSync, spawn } from 'child_process'; import { IPC_CHANNELS } from '../../../shared/constants'; import type { IPCResult } from '../../../shared/types'; import { getAugmentedEnv, findExecutable } from '../../env-utils'; +import { getToolPath } from '../../cli-tool-manager'; /** * Send device code info to all renderer windows immediately when extracted @@ -138,7 +139,7 @@ export function registerCheckGhCli(): void { // Get version using augmented environment debugLog('Getting gh version...'); - const versionOutput = execSync('gh --version', { + const versionOutput = execFileSync(getToolPath('gh'), ['--version'], { encoding: 'utf-8', stdio: 'pipe', env: getAugmentedEnv() @@ -174,13 +175,13 @@ export function registerCheckGhAuth(): void { try { // Check auth status debugLog('Running: gh auth status'); - const authStatus = execSync('gh auth status', { encoding: 'utf-8', stdio: 'pipe', env }); + const authStatus = execFileSync(getToolPath('gh'), ['auth', 'status'], { encoding: 'utf-8', stdio: 'pipe', env }); debugLog('Auth status output:', authStatus); // Get username if authenticated try { debugLog('Getting username via: gh api user --jq .login'); - const username = execSync('gh api user --jq .login', { + const username = execFileSync(getToolPath('gh'), ['api', 'user', '--jq', '.login'], { encoding: 'utf-8', stdio: 'pipe', env @@ -398,7 +399,7 @@ export function registerGetGhToken(): void { debugLog('getGitHubToken handler called'); try { debugLog('Running: gh auth token'); - const token = execSync('gh auth token', { + const token = execFileSync(getToolPath('gh'), ['auth', 'token'], { encoding: 'utf-8', stdio: 'pipe', env: getAugmentedEnv() @@ -438,7 +439,7 @@ export function registerGetGhUser(): void { debugLog('getGitHubUser handler called'); try { debugLog('Running: gh api user'); - const userJson = execSync('gh api user', { + const userJson = execFileSync(getToolPath('gh'), ['api', 'user'], { encoding: 'utf-8', stdio: 'pipe', env: getAugmentedEnv() @@ -522,7 +523,7 @@ export function registerDetectGitHubRepo(): void { try { // Get the remote URL debugLog('Running: git remote get-url origin'); - const remoteUrl = execSync('git remote get-url origin', { + const remoteUrl = execFileSync(getToolPath('git'), ['remote', 'get-url', 'origin'], { encoding: 'utf-8', cwd: projectPath, stdio: 'pipe' @@ -635,7 +636,7 @@ export function registerCreateGitHubRepo(): void { try { // Get the authenticated username - const username = execSync('gh api user --jq .login', { + const username = execFileSync(getToolPath('gh'), ['api', 'user', '--jq', '.login'], { encoding: 'utf-8', stdio: 'pipe', env: getAugmentedEnv() @@ -721,14 +722,14 @@ export function registerAddGitRemote(): void { try { // Check if origin already exists try { - execSync('git remote get-url origin', { + execFileSync(getToolPath('git'), ['remote', 'get-url', 'origin'], { cwd: projectPath, encoding: 'utf-8', stdio: 'pipe' }); // Origin exists, remove it first debugLog('Removing existing origin remote'); - execSync('git remote remove origin', { + execFileSync(getToolPath('git'), ['remote', 'remove', 'origin'], { cwd: projectPath, encoding: 'utf-8', stdio: 'pipe' @@ -773,7 +774,7 @@ export function registerListGitHubOrgs(): void { try { // Get user's organizations - const output = execSync('gh api user/orgs --jq \'.[] | {login: .login, avatarUrl: .avatar_url}\'', { + const output = execFileSync(getToolPath('gh'), ['api', 'user/orgs', '--jq', '.[] | {login: .login, avatarUrl: .avatar_url}'], { encoding: 'utf-8', stdio: 'pipe', env: getAugmentedEnv() diff --git a/apps/frontend/src/main/ipc-handlers/github/release-handlers.ts b/apps/frontend/src/main/ipc-handlers/github/release-handlers.ts index 120ae63f..6dde8db7 100644 --- a/apps/frontend/src/main/ipc-handlers/github/release-handlers.ts +++ b/apps/frontend/src/main/ipc-handlers/github/release-handlers.ts @@ -3,7 +3,7 @@ */ import { ipcMain } from 'electron'; -import { execSync } from 'child_process'; +import { execSync, execFileSync } from 'child_process'; import { existsSync, readFileSync } from 'fs'; import path from 'path'; import { IPC_CHANNELS } from '../../../shared/constants'; @@ -11,6 +11,7 @@ import type { IPCResult, GitCommit, VersionSuggestion } from '../../../shared/ty import { projectStore } from '../../project-store'; import { changelogService } from '../../changelog-service'; import type { ReleaseOptions } from './types'; +import { getToolPath } from '../../cli-tool-manager'; /** * Check if gh CLI is installed @@ -33,7 +34,7 @@ function checkGhCli(): { installed: boolean; error?: string } { */ function checkGhAuth(projectPath: string): { authenticated: boolean; error?: string } { try { - execSync('gh auth status', { cwd: projectPath, encoding: 'utf-8', stdio: 'pipe' }); + execFileSync(getToolPath('gh'), ['auth', 'status'], { cwd: projectPath, encoding: 'utf-8', stdio: 'pipe' }); return { authenticated: true }; } catch { return { @@ -126,7 +127,7 @@ export function registerCreateRelease(): void { */ function getLatestTag(projectPath: string): string | null { try { - const tag = execSync('git describe --tags --abbrev=0 2>/dev/null || echo ""', { + const tag = execFileSync(getToolPath('git'), ['describe', '--tags', '--abbrev=0'], { cwd: projectPath, encoding: 'utf-8' }).trim(); @@ -143,7 +144,7 @@ function getCommitsSinceTag(projectPath: string, tag: string | null): GitCommit[ try { const range = tag ? `${tag}..HEAD` : 'HEAD'; const format = '%H|%s|%an|%ae|%aI'; - const output = execSync(`git log ${range} --pretty=format:"${format}"`, { + const output = execFileSync(getToolPath('git'), ['log', range, `--pretty=format:${format}`], { cwd: projectPath, encoding: 'utf-8' }).trim(); diff --git a/apps/frontend/src/main/ipc-handlers/github/utils.ts b/apps/frontend/src/main/ipc-handlers/github/utils.ts index 1885d435..3b0799b4 100644 --- a/apps/frontend/src/main/ipc-handlers/github/utils.ts +++ b/apps/frontend/src/main/ipc-handlers/github/utils.ts @@ -3,12 +3,13 @@ */ import { existsSync, readFileSync } from 'fs'; -import { execSync } from 'child_process'; +import { execFileSync } from 'child_process'; import path from 'path'; import type { Project } from '../../../shared/types'; import { parseEnvFile } from '../utils'; import type { GitHubConfig } from './types'; import { getAugmentedEnv } from '../../env-utils'; +import { getToolPath } from '../../cli-tool-manager'; /** * Get GitHub token from gh CLI if available @@ -16,7 +17,7 @@ import { getAugmentedEnv } from '../../env-utils'; */ function getTokenFromGhCli(): string | null { try { - const token = execSync('gh auth token', { + const token = execFileSync(getToolPath('gh'), ['auth', 'token'], { encoding: 'utf-8', stdio: 'pipe', env: getAugmentedEnv() diff --git a/apps/frontend/src/main/ipc-handlers/project-handlers.ts b/apps/frontend/src/main/ipc-handlers/project-handlers.ts index deb01102..c0c65a20 100644 --- a/apps/frontend/src/main/ipc-handlers/project-handlers.ts +++ b/apps/frontend/src/main/ipc-handlers/project-handlers.ts @@ -1,7 +1,7 @@ import { ipcMain, app } from 'electron'; import { existsSync, readFileSync } from 'fs'; import path from 'path'; -import { execSync } from 'child_process'; +import { execFileSync } from 'child_process'; import { is } from '@electron-toolkit/utils'; import { IPC_CHANNELS } from '../../shared/constants'; import type { @@ -23,6 +23,7 @@ import { import { PythonEnvManager, type PythonEnvStatus } from '../python-env-manager'; import { AgentManager } from '../agent'; import { changelogService } from '../changelog-service'; +import { getToolPath } from '../cli-tool-manager'; import { insightsService } from '../insights-service'; import { titleGenerator } from '../title-generator'; import type { BrowserWindow } from 'electron'; @@ -37,7 +38,7 @@ import { getEffectiveSourcePath } from '../updater/path-resolver'; */ function getGitBranches(projectPath: string): string[] { try { - const result = execSync('git branch --list --format="%(refname:short)"', { + const result = execFileSync(getToolPath('git'), ['branch', '--list', '--format=%(refname:short)'], { cwd: projectPath, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] @@ -53,7 +54,7 @@ function getGitBranches(projectPath: string): string[] { */ function getCurrentGitBranch(projectPath: string): string | null { try { - const result = execSync('git rev-parse --abbrev-ref HEAD', { + const result = execFileSync(getToolPath('git'), ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: projectPath, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] @@ -82,7 +83,7 @@ function detectMainBranch(projectPath: string): string | null { // If none of the common names found, check for origin/HEAD reference try { - const result = execSync('git symbolic-ref refs/remotes/origin/HEAD', { + const result = execFileSync(getToolPath('git'), ['symbolic-ref', 'refs/remotes/origin/HEAD'], { cwd: projectPath, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] diff --git a/apps/frontend/src/main/ipc-handlers/settings-handlers.ts b/apps/frontend/src/main/ipc-handlers/settings-handlers.ts index d1b47692..2a1815c5 100644 --- a/apps/frontend/src/main/ipc-handlers/settings-handlers.ts +++ b/apps/frontend/src/main/ipc-handlers/settings-handlers.ts @@ -1,6 +1,6 @@ import { ipcMain, dialog, app, shell } from 'electron'; import { existsSync, writeFileSync, mkdirSync } from 'fs'; -import { execSync } from 'child_process'; +import { execFileSync } from 'child_process'; import path from 'path'; import { is } from '@electron-toolkit/utils'; import { IPC_CHANNELS, DEFAULT_APP_SETTINGS } from '../../shared/constants'; @@ -13,6 +13,7 @@ import type { BrowserWindow } from 'electron'; import { getEffectiveVersion } from '../auto-claude-updater'; import { setUpdateChannel } from '../app-updater'; import { getSettingsPath, readSettingsFile } from '../settings-utils'; +import { configureTools, getToolPath, getToolInfo } from '../cli-tool-manager'; const settingsPath = getSettingsPath(); @@ -128,6 +129,13 @@ export function registerSettingsHandlers( } } + // Configure CLI tools with current settings + configureTools({ + pythonPath: settings.pythonPath, + gitPath: settings.gitPath, + githubCLIPath: settings.githubCLIPath, + }); + return { success: true, data: settings as AppSettings }; } ); @@ -147,6 +155,19 @@ export function registerSettingsHandlers( agentManager.configure(settings.pythonPath, settings.autoBuildPath); } + // Configure CLI tools if any paths changed + if ( + settings.pythonPath !== undefined || + settings.gitPath !== undefined || + settings.githubCLIPath !== undefined + ) { + configureTools({ + pythonPath: newSettings.pythonPath, + gitPath: newSettings.gitPath, + githubCLIPath: newSettings.githubCLIPath, + }); + } + // Update auto-updater channel if betaUpdates setting changed if (settings.betaUpdates !== undefined) { const channel = settings.betaUpdates ? 'beta' : 'latest'; @@ -163,6 +184,31 @@ export function registerSettingsHandlers( } ); + ipcMain.handle( + IPC_CHANNELS.SETTINGS_GET_CLI_TOOLS_INFO, + async (): Promise; + git: ReturnType; + gh: ReturnType; + }>> => { + try { + return { + success: true, + data: { + python: getToolInfo('python'), + git: getToolInfo('git'), + gh: getToolInfo('gh'), + }, + }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to get CLI tools info', + }; + } + } + ); + // ============================================ // Dialog Operations // ============================================ @@ -226,7 +272,7 @@ export function registerSettingsHandlers( let gitInitialized = false; if (initGit) { try { - execSync('git init', { cwd: projectPath, stdio: 'ignore' }); + execFileSync(getToolPath('git'), ['init'], { cwd: projectPath, stdio: 'ignore' }); gitInitialized = true; } catch { // Git init failed, but folder was created - continue without git diff --git a/apps/frontend/src/main/ipc-handlers/task/worktree-handlers.ts b/apps/frontend/src/main/ipc-handlers/task/worktree-handlers.ts index 354c861c..a098a9f7 100644 --- a/apps/frontend/src/main/ipc-handlers/task/worktree-handlers.ts +++ b/apps/frontend/src/main/ipc-handlers/task/worktree-handlers.ts @@ -3,13 +3,14 @@ import { IPC_CHANNELS, AUTO_BUILD_PATHS } from '../../../shared/constants'; import type { IPCResult, WorktreeStatus, WorktreeDiff, WorktreeDiffFile, WorktreeMergeResult, WorktreeDiscardResult, WorktreeListResult, WorktreeListItem } from '../../../shared/types'; import path from 'path'; import { existsSync, readdirSync, statSync, readFileSync } from 'fs'; -import { execSync, spawn, spawnSync } from 'child_process'; +import { execSync, execFileSync, spawn, spawnSync } from 'child_process'; import { projectStore } from '../../project-store'; import { getConfiguredPythonPath, PythonEnvManager } from '../../python-env-manager'; import { getEffectiveSourcePath } from '../../auto-claude-updater'; import { getProfileEnv } from '../../rate-limit-detector'; import { findTaskAndProject } from './shared'; import { parsePythonCommand } from '../../python-detector'; +import { getToolPath } from '../../cli-tool-manager'; /** * Read the stored base branch from task_metadata.json @@ -64,7 +65,7 @@ export function registerWorktreeHandlers( // Get branch info from git try { // Get current branch in worktree - const branch = execSync('git rev-parse --abbrev-ref HEAD', { + const branch = execFileSync(getToolPath('git'), ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: worktreePath, encoding: 'utf-8' }).trim(); @@ -73,7 +74,7 @@ export function registerWorktreeHandlers( // This matches the Python merge logic which merges into the user's current branch let baseBranch = 'main'; try { - baseBranch = execSync('git rev-parse --abbrev-ref HEAD', { + baseBranch = execFileSync(getToolPath('git'), ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: project.path, encoding: 'utf-8' }).trim(); @@ -84,7 +85,7 @@ export function registerWorktreeHandlers( // Get commit count (cross-platform - no shell syntax) let commitCount = 0; try { - const countOutput = execSync(`git rev-list --count ${baseBranch}..HEAD`, { + const countOutput = execFileSync(getToolPath('git'), ['rev-list', '--count', `${baseBranch}..HEAD`], { cwd: worktreePath, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] @@ -101,7 +102,7 @@ export function registerWorktreeHandlers( let diffStat = ''; try { - diffStat = execSync(`git diff --stat ${baseBranch}...HEAD`, { + diffStat = execFileSync(getToolPath('git'), ['diff', '--stat', `${baseBranch}...HEAD`], { cwd: worktreePath, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] @@ -171,7 +172,7 @@ export function registerWorktreeHandlers( // Get base branch - the current branch in the main project (where changes will be merged) let baseBranch = 'main'; try { - baseBranch = execSync('git rev-parse --abbrev-ref HEAD', { + baseBranch = execFileSync(getToolPath('git'), ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: project.path, encoding: 'utf-8' }).trim(); @@ -186,14 +187,14 @@ export function registerWorktreeHandlers( let nameStatus = ''; try { // Get numstat for additions/deletions per file (cross-platform) - numstat = execSync(`git diff --numstat ${baseBranch}...HEAD`, { + numstat = execFileSync(getToolPath('git'), ['diff', '--numstat', `${baseBranch}...HEAD`], { cwd: worktreePath, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }).trim(); // Get name-status for file status (cross-platform) - nameStatus = execSync(`git diff --name-status ${baseBranch}...HEAD`, { + nameStatus = execFileSync(getToolPath('git'), ['diff', '--name-status', `${baseBranch}...HEAD`], { cwd: worktreePath, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] @@ -327,9 +328,9 @@ export function registerWorktreeHandlers( // Get git status before merge try { - const gitStatusBefore = execSync('git status --short', { cwd: project.path, encoding: 'utf-8' }); + const gitStatusBefore = execFileSync(getToolPath('git'), ['status', '--short'], { cwd: project.path, encoding: 'utf-8' }); debug('Git status BEFORE merge in main project:\n', gitStatusBefore || '(clean)'); - const gitBranch = execSync('git branch --show-current', { cwd: project.path, encoding: 'utf-8' }).trim(); + const gitBranch = execFileSync(getToolPath('git'), ['branch', '--show-current'], { cwd: project.path, encoding: 'utf-8' }).trim(); debug('Current branch:', gitBranch); } catch (e) { debug('Failed to get git status before:', e); @@ -454,9 +455,9 @@ export function registerWorktreeHandlers( // Get git status after merge try { - const gitStatusAfter = execSync('git status --short', { cwd: project.path, encoding: 'utf-8' }); + const gitStatusAfter = execFileSync(getToolPath('git'), ['status', '--short'], { cwd: project.path, encoding: 'utf-8' }); debug('Git status AFTER merge in main project:\n', gitStatusAfter || '(clean)'); - const gitDiffStaged = execSync('git diff --staged --stat', { cwd: project.path, encoding: 'utf-8' }); + const gitDiffStaged = execFileSync(getToolPath('git'), ['diff', '--staged', '--stat'], { cwd: project.path, encoding: 'utf-8' }); debug('Staged changes:\n', gitDiffStaged || '(none)'); } catch (e) { debug('Failed to get git status after:', e); @@ -472,7 +473,7 @@ export function registerWorktreeHandlers( if (isStageOnly) { try { - const gitDiffStaged = execSync('git diff --staged --stat', { cwd: project.path, encoding: 'utf-8' }); + const gitDiffStaged = execFileSync(getToolPath('git'), ['diff', '--staged', '--stat'], { cwd: project.path, encoding: 'utf-8' }); hasActualStagedChanges = gitDiffStaged.trim().length > 0; debug('Stage-only verification: hasActualStagedChanges:', hasActualStagedChanges); @@ -695,7 +696,7 @@ export function registerWorktreeHandlers( let hasUncommittedChanges = false; let uncommittedFiles: string[] = []; try { - const gitStatus = execSync('git status --porcelain', { + const gitStatus = execFileSync(getToolPath('git'), ['status', '--porcelain'], { cwd: project.path, encoding: 'utf-8' }); @@ -864,20 +865,20 @@ export function registerWorktreeHandlers( try { // Get the branch name before removing - const branch = execSync('git rev-parse --abbrev-ref HEAD', { + const branch = execFileSync(getToolPath('git'), ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: worktreePath, encoding: 'utf-8' }).trim(); // Remove the worktree - execSync(`git worktree remove --force "${worktreePath}"`, { + execFileSync(getToolPath('git'), ['worktree', 'remove', '--force', worktreePath], { cwd: project.path, encoding: 'utf-8' }); // Delete the branch try { - execSync(`git branch -D "${branch}"`, { + execFileSync(getToolPath('git'), ['branch', '-D', branch], { cwd: project.path, encoding: 'utf-8' }); @@ -947,7 +948,7 @@ export function registerWorktreeHandlers( try { // Get branch info - const branch = execSync('git rev-parse --abbrev-ref HEAD', { + const branch = execFileSync(getToolPath('git'), ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: entryPath, encoding: 'utf-8' }).trim(); @@ -955,7 +956,7 @@ export function registerWorktreeHandlers( // Get base branch - the current branch in the main project (where changes will be merged) let baseBranch = 'main'; try { - baseBranch = execSync('git rev-parse --abbrev-ref HEAD', { + baseBranch = execFileSync(getToolPath('git'), ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: project.path, encoding: 'utf-8' }).trim(); @@ -966,7 +967,7 @@ export function registerWorktreeHandlers( // Get commit count (cross-platform - no shell syntax) let commitCount = 0; try { - const countOutput = execSync(`git rev-list --count ${baseBranch}..HEAD`, { + const countOutput = execFileSync(getToolPath('git'), ['rev-list', '--count', `${baseBranch}..HEAD`], { cwd: entryPath, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] @@ -983,7 +984,7 @@ export function registerWorktreeHandlers( let diffStat = ''; try { - diffStat = execSync(`git diff --shortstat ${baseBranch}...HEAD`, { + diffStat = execFileSync(getToolPath('git'), ['diff', '--shortstat', `${baseBranch}...HEAD`], { cwd: entryPath, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] diff --git a/apps/frontend/src/main/project-initializer.ts b/apps/frontend/src/main/project-initializer.ts index 9e46edbd..702ac61c 100644 --- a/apps/frontend/src/main/project-initializer.ts +++ b/apps/frontend/src/main/project-initializer.ts @@ -1,6 +1,7 @@ import { existsSync, mkdirSync, writeFileSync, readFileSync, appendFileSync } from 'fs'; import path from 'path'; -import { execSync } from 'child_process'; +import { execFileSync } from 'child_process'; +import { getToolPath } from './cli-tool-manager'; /** * Debug logging - only logs when DEBUG=true or in development mode @@ -31,9 +32,11 @@ export interface GitStatus { * Check if a directory is a git repository and has at least one commit */ export function checkGitStatus(projectPath: string): GitStatus { + const git = getToolPath('git'); + try { // Check if it's a git repository - execSync('git rev-parse --git-dir', { + execFileSync(git, ['rev-parse', '--git-dir'], { cwd: projectPath, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] @@ -50,7 +53,7 @@ export function checkGitStatus(projectPath: string): GitStatus { // Check if there are any commits let hasCommits = false; try { - execSync('git rev-parse HEAD', { + execFileSync(git, ['rev-parse', 'HEAD'], { cwd: projectPath, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] @@ -64,7 +67,7 @@ export function checkGitStatus(projectPath: string): GitStatus { // Get current branch let currentBranch: string | null = null; try { - currentBranch = execSync('git rev-parse --abbrev-ref HEAD', { + currentBranch = execFileSync(git, ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: projectPath, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] @@ -98,12 +101,13 @@ export function initializeGit(projectPath: string): InitializationResult { // Check current git status const status = checkGitStatus(projectPath); + const git = getToolPath('git'); try { // Step 1: Initialize git if needed if (!status.isGitRepo) { debug('Initializing git repository'); - execSync('git init', { + execFileSync(git, ['init'], { cwd: projectPath, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] @@ -111,7 +115,7 @@ export function initializeGit(projectPath: string): InitializationResult { } // Step 2: Check if there are files to commit - const statusOutput = execSync('git status --porcelain', { + const statusOutput = execFileSync(git, ['status', '--porcelain'], { cwd: projectPath, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] @@ -122,14 +126,14 @@ export function initializeGit(projectPath: string): InitializationResult { debug('Adding files and creating initial commit'); // Add all files - execSync('git add -A', { + execFileSync(git, ['add', '-A'], { cwd: projectPath, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }); // Create initial commit - execSync('git commit -m "Initial commit" --allow-empty', { + execFileSync(git, ['commit', '-m', 'Initial commit', '--allow-empty'], { cwd: projectPath, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] diff --git a/apps/frontend/src/main/release-service.ts b/apps/frontend/src/main/release-service.ts index f17501ce..ed7367d5 100644 --- a/apps/frontend/src/main/release-service.ts +++ b/apps/frontend/src/main/release-service.ts @@ -1,7 +1,7 @@ import { EventEmitter } from 'events'; import path from 'path'; import { existsSync, readFileSync, readdirSync, writeFileSync } from 'fs'; -import { execSync, spawn } from 'child_process'; +import { execFileSync, spawn } from 'child_process'; import type { ReleaseableVersion, ReleasePreflightStatus, @@ -14,6 +14,7 @@ import type { TaskStatus } from '../shared/types'; import { DEFAULT_CHANGELOG_PATH } from '../shared/constants'; +import { getToolPath } from './cli-tool-manager'; /** * Service for creating GitHub releases with worktree-aware pre-flight checks. @@ -126,10 +127,10 @@ export class ReleaseService extends EventEmitter { * Check if a git tag exists (locally or remote). */ private checkTagExists(projectPath: string, tagName: string): boolean { + const git = getToolPath('git'); try { // Check local tags - execSync(`git tag -l "${tagName}"`, { cwd: projectPath, encoding: 'utf-8' }); - const localTags = execSync(`git tag -l "${tagName}"`, { + const localTags = execFileSync(git, ['tag', '-l', tagName], { cwd: projectPath, encoding: 'utf-8' }).trim(); @@ -138,12 +139,7 @@ export class ReleaseService extends EventEmitter { // Check remote tags try { - execSync(`git ls-remote --tags origin refs/tags/${tagName}`, { - cwd: projectPath, - encoding: 'utf-8', - stdio: ['pipe', 'pipe', 'pipe'] - }); - const remoteTags = execSync(`git ls-remote --tags origin refs/tags/${tagName}`, { + const remoteTags = execFileSync(git, ['ls-remote', '--tags', 'origin', `refs/tags/${tagName}`], { cwd: projectPath, encoding: 'utf-8' }).trim(); @@ -161,8 +157,9 @@ export class ReleaseService extends EventEmitter { * Get GitHub release URL for a tag (if release exists). */ private getGitHubReleaseUrl(projectPath: string, tagName: string): string | undefined { + const gh = getToolPath('gh'); try { - const result = execSync(`gh release view ${tagName} --json url -q .url 2>/dev/null`, { + const result = execFileSync(gh, ['release', 'view', tagName, '--json', 'url', '-q', '.url'], { cwd: projectPath, encoding: 'utf-8' }).trim(); @@ -201,7 +198,7 @@ export class ReleaseService extends EventEmitter { // Check 1: Git working directory is clean try { - const gitStatus = execSync('git status --porcelain', { + const gitStatus = execFileSync(getToolPath('git'), ['status', '--porcelain'], { cwd: projectPath, encoding: 'utf-8' }).trim(); @@ -230,10 +227,16 @@ export class ReleaseService extends EventEmitter { // Check 2: All commits are pushed try { - const unpushed = execSync('git log @{u}..HEAD --oneline 2>/dev/null || echo ""', { - cwd: projectPath, - encoding: 'utf-8' - }).trim(); + let unpushed = ''; + try { + unpushed = execFileSync(getToolPath('git'), ['log', '@{u}..HEAD', '--oneline'], { + cwd: projectPath, + encoding: 'utf-8' + }).trim(); + } catch { + // No upstream branch or other error - treat as empty + unpushed = ''; + } if (!unpushed) { status.checks.commitsPushed = { @@ -275,7 +278,7 @@ export class ReleaseService extends EventEmitter { // Check 4: GitHub CLI is available and authenticated try { - execSync('gh auth status', { + execFileSync(getToolPath('gh'), ['auth', 'status'], { cwd: projectPath, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] @@ -387,7 +390,7 @@ export class ReleaseService extends EventEmitter { // Get branch name let branch = 'unknown'; try { - branch = execSync('git rev-parse --abbrev-ref HEAD', { + branch = execFileSync(getToolPath('git'), ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: worktreePath, encoding: 'utf-8' }).trim(); @@ -417,28 +420,38 @@ export class ReleaseService extends EventEmitter { ): Promise { try { // Get the current branch in the worktree - const worktreeBranch = execSync('git rev-parse --abbrev-ref HEAD', { + const worktreeBranch = execFileSync(getToolPath('git'), ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: worktreePath, encoding: 'utf-8' }).trim(); // Get the main branch - const mainBranch = execSync( - 'git rev-parse --abbrev-ref origin/HEAD 2>/dev/null || echo main', - { cwd: projectPath, encoding: 'utf-8' } - ).trim().replace('origin/', ''); + let mainBranch: string; + try { + mainBranch = execFileSync(getToolPath('git'), ['rev-parse', '--abbrev-ref', 'origin/HEAD'], { + cwd: projectPath, + encoding: 'utf-8' + }).trim().replace('origin/', ''); + } catch { + mainBranch = 'main'; + } // Check if worktree branch is fully merged into main // This returns empty if all commits are merged - const unmergedCommits = execSync( - `git log ${mainBranch}..${worktreeBranch} --oneline 2>/dev/null || echo "error"`, - { cwd: projectPath, encoding: 'utf-8' } - ).trim(); + let unmergedCommits: string; + try { + unmergedCommits = execFileSync(getToolPath('git'), ['log', `${mainBranch}..${worktreeBranch}`, '--oneline'], { + cwd: projectPath, + encoding: 'utf-8' + }).trim(); + } catch { + unmergedCommits = 'error'; + } // If empty or error checking, assume merged for safety if (unmergedCommits === 'error') { // Try alternative: check if worktree has any uncommitted changes - const hasChanges = execSync('git status --porcelain', { + const hasChanges = execFileSync(getToolPath('git'), ['status', '--porcelain'], { cwd: worktreePath, encoding: 'utf-8' }).trim(); @@ -469,7 +482,7 @@ export class ReleaseService extends EventEmitter { let stashCreated = false; try { - originalBranch = execSync('git rev-parse --abbrev-ref HEAD', { + originalBranch = execFileSync(getToolPath('git'), ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: projectPath, encoding: 'utf-8' }).trim(); @@ -478,7 +491,7 @@ export class ReleaseService extends EventEmitter { } // Check for uncommitted changes - const gitStatus = execSync('git status --porcelain', { + const gitStatus = execFileSync(getToolPath('git'), ['status', '--porcelain'], { cwd: projectPath, encoding: 'utf-8' }).trim(); @@ -493,7 +506,7 @@ export class ReleaseService extends EventEmitter { message: 'Stashing current changes...' }); - execSync('git stash push -m "auto-claude-release-temp"', { + execFileSync(getToolPath('git'), ['stash', 'push', '-m', 'auto-claude-release-temp'], { cwd: projectPath, encoding: 'utf-8' }); @@ -508,7 +521,7 @@ export class ReleaseService extends EventEmitter { }); if (originalBranch !== mainBranch) { - execSync(`git checkout "${mainBranch}"`, { + execFileSync(getToolPath('git'), ['checkout', mainBranch], { cwd: projectPath, encoding: 'utf-8' }); @@ -522,7 +535,7 @@ export class ReleaseService extends EventEmitter { }); try { - execSync(`git pull origin "${mainBranch}"`, { + execFileSync(getToolPath('git'), ['pull', 'origin', mainBranch], { cwd: projectPath, encoding: 'utf-8' }); @@ -557,12 +570,12 @@ export class ReleaseService extends EventEmitter { message: 'Committing version bump...' }); - execSync('git add package.json', { + execFileSync(getToolPath('git'), ['add', 'package.json'], { cwd: projectPath, encoding: 'utf-8' }); - execSync(`git commit -m "chore: release v${version}"`, { + execFileSync(getToolPath('git'), ['commit', '-m', `chore: release v${version}`], { cwd: projectPath, encoding: 'utf-8' }); @@ -574,7 +587,7 @@ export class ReleaseService extends EventEmitter { message: `Pushing to origin/${mainBranch}...` }); - execSync(`git push origin "${mainBranch}"`, { + execFileSync(getToolPath('git'), ['push', 'origin', mainBranch], { cwd: projectPath, encoding: 'utf-8' }); @@ -589,7 +602,7 @@ export class ReleaseService extends EventEmitter { // Always restore user's original state try { if (originalBranch !== mainBranch) { - execSync(`git checkout "${originalBranch}"`, { + execFileSync(getToolPath('git'), ['checkout', originalBranch], { cwd: projectPath, encoding: 'utf-8' }); @@ -601,7 +614,7 @@ export class ReleaseService extends EventEmitter { if (stashCreated) { try { - execSync('git stash pop', { + execFileSync(getToolPath('git'), ['stash', 'pop'], { cwd: projectPath, encoding: 'utf-8' }); @@ -655,7 +668,7 @@ export class ReleaseService extends EventEmitter { message: `Creating tag ${tagName}...` }); - execSync(`git tag -a "${tagName}" -m "Release ${tagName}"`, { + execFileSync(getToolPath('git'), ['tag', '-a', tagName, '-m', `Release ${tagName}`], { cwd: projectPath, encoding: 'utf-8' }); @@ -667,7 +680,7 @@ export class ReleaseService extends EventEmitter { message: `Pushing tag ${tagName} to origin...` }); - execSync(`git push origin "${tagName}"`, { + execFileSync(getToolPath('git'), ['push', 'origin', tagName], { cwd: projectPath, encoding: 'utf-8' }); @@ -727,7 +740,7 @@ export class ReleaseService extends EventEmitter { if (!releaseUrl.startsWith('http')) { // Try to fetch the URL try { - releaseUrl = execSync(`gh release view ${tagName} --json url -q .url`, { + releaseUrl = execFileSync(getToolPath('gh'), ['release', 'view', tagName, '--json', 'url', '-q', '.url'], { cwd: projectPath, encoding: 'utf-8' }).trim(); @@ -754,7 +767,7 @@ export class ReleaseService extends EventEmitter { // Try to clean up the tag if it was created but release failed try { - execSync(`git tag -d "${tagName}" 2>/dev/null || true`, { + execFileSync(getToolPath('git'), ['tag', '-d', tagName], { cwd: projectPath, encoding: 'utf-8' }); diff --git a/apps/frontend/src/preload/api/settings-api.ts b/apps/frontend/src/preload/api/settings-api.ts index 9ce29fec..d896d74c 100644 --- a/apps/frontend/src/preload/api/settings-api.ts +++ b/apps/frontend/src/preload/api/settings-api.ts @@ -4,7 +4,8 @@ import type { AppSettings, IPCResult, SourceEnvConfig, - SourceEnvCheckResult + SourceEnvCheckResult, + ToolDetectionResult } from '../../shared/types'; export interface SettingsAPI { @@ -12,6 +13,13 @@ export interface SettingsAPI { getSettings: () => Promise>; saveSettings: (settings: Partial) => Promise; + // CLI Tools Detection + getCliToolsInfo: () => Promise>; + // App Info getAppVersion: () => Promise; @@ -29,6 +37,14 @@ export const createSettingsAPI = (): SettingsAPI => ({ saveSettings: (settings: Partial): Promise => ipcRenderer.invoke(IPC_CHANNELS.SETTINGS_SAVE, settings), + // CLI Tools Detection + getCliToolsInfo: (): Promise> => + ipcRenderer.invoke(IPC_CHANNELS.SETTINGS_GET_CLI_TOOLS_INFO), + // App Info getAppVersion: (): Promise => ipcRenderer.invoke(IPC_CHANNELS.APP_VERSION), diff --git a/apps/frontend/src/renderer/components/settings/GeneralSettings.tsx b/apps/frontend/src/renderer/components/settings/GeneralSettings.tsx index 5be87c85..eea5d605 100644 --- a/apps/frontend/src/renderer/components/settings/GeneralSettings.tsx +++ b/apps/frontend/src/renderer/components/settings/GeneralSettings.tsx @@ -1,4 +1,5 @@ import { useTranslation } from 'react-i18next'; +import { useEffect, useState } from 'react'; import { Label } from '../ui/label'; import { Input } from '../ui/input'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select'; @@ -12,7 +13,14 @@ import { DEFAULT_FEATURE_THINKING, FEATURE_LABELS } from '../../../shared/constants'; -import type { AppSettings, FeatureModelConfig, FeatureThinkingConfig, ModelTypeShort, ThinkingLevel } from '../../../shared/types'; +import type { + AppSettings, + FeatureModelConfig, + FeatureThinkingConfig, + ModelTypeShort, + ThinkingLevel, + ToolDetectionResult +} from '../../../shared/types'; interface GeneralSettingsProps { settings: AppSettings; @@ -20,11 +28,95 @@ interface GeneralSettingsProps { section: 'agent' | 'paths'; } +/** + * Helper component to display auto-detected CLI tool information + */ +interface ToolDetectionDisplayProps { + info: ToolDetectionResult | null; + isLoading: boolean; + t: (key: string) => string; +} + +function ToolDetectionDisplay({ info, isLoading, t }: ToolDetectionDisplayProps) { + if (isLoading) { + return ( +
+ Detecting... +
+ ); + } + + if (!info || !info.found) { + return ( +
+ {t('general.notDetected')} +
+ ); + } + + const getSourceLabel = (source: ToolDetectionResult['source']): string => { + const sourceMap: Record = { + 'user-config': t('general.sourceUserConfig'), + 'venv': t('general.sourceVenv'), + 'homebrew': t('general.sourceHomebrew'), + 'system-path': t('general.sourceSystemPath'), + 'bundled': t('general.sourceBundled'), + 'fallback': t('general.sourceFallback'), + }; + return sourceMap[source] || source; + }; + + return ( +
+
+ {t('general.detectedPath')}:{' '} + {info.path} +
+ {info.version && ( +
+ {t('general.detectedVersion')}:{' '} + {info.version} +
+ )} +
+ {t('general.detectedSource')}:{' '} + {getSourceLabel(info.source)} +
+
+ ); +} + /** * General settings component for agent configuration and paths */ export function GeneralSettings({ settings, onSettingsChange, section }: GeneralSettingsProps) { const { t } = useTranslation('settings'); + const [toolsInfo, setToolsInfo] = useState<{ + python: ToolDetectionResult; + git: ToolDetectionResult; + gh: ToolDetectionResult; + } | null>(null); + const [isLoadingTools, setIsLoadingTools] = useState(false); + + // Fetch CLI tools detection info when component mounts (paths section only) + useEffect(() => { + if (section === 'paths') { + setIsLoadingTools(true); + window.electronAPI + .getCliToolsInfo() + .then((result: { success: boolean; data?: { python: ToolDetectionResult; git: ToolDetectionResult; gh: ToolDetectionResult } }) => { + if (result.success && result.data) { + setToolsInfo(result.data); + } + }) + .catch((error: unknown) => { + console.error('Failed to fetch CLI tools info:', error); + }) + .finally(() => { + setIsLoadingTools(false); + }); + } + }, [section]); if (section === 'agent') { return ( @@ -167,6 +259,49 @@ export function GeneralSettings({ settings, onSettingsChange, section }: General value={settings.pythonPath || ''} onChange={(e) => onSettingsChange({ ...settings, pythonPath: e.target.value })} /> + {!settings.pythonPath && ( + + )} + +
+ +

{t('general.gitPathDescription')}

+ onSettingsChange({ ...settings, gitPath: e.target.value })} + /> + {!settings.gitPath && ( + + )} +
+
+ +

{t('general.githubCLIPathDescription')}

+ onSettingsChange({ ...settings, githubCLIPath: e.target.value })} + /> + {!settings.githubCLIPath && ( + + )}
diff --git a/apps/frontend/src/renderer/lib/mocks/settings-mock.ts b/apps/frontend/src/renderer/lib/mocks/settings-mock.ts index d8e99b0d..d7634a4d 100644 --- a/apps/frontend/src/renderer/lib/mocks/settings-mock.ts +++ b/apps/frontend/src/renderer/lib/mocks/settings-mock.ts @@ -13,6 +13,15 @@ export const settingsMock = { saveSettings: async () => ({ success: true }), + getCliToolsInfo: async () => ({ + success: true, + data: { + python: { found: false, source: 'fallback' as const, message: 'Not available in browser mode' }, + git: { found: false, source: 'fallback' as const, message: 'Not available in browser mode' }, + gh: { found: false, source: 'fallback' as const, message: 'Not available in browser mode' } + } + }), + // App Info getAppVersion: async () => '0.1.0-browser', diff --git a/apps/frontend/src/shared/constants/config.ts b/apps/frontend/src/shared/constants/config.ts index 9692a98d..81bdb990 100644 --- a/apps/frontend/src/shared/constants/config.ts +++ b/apps/frontend/src/shared/constants/config.ts @@ -22,6 +22,8 @@ export const DEFAULT_APP_SETTINGS = { defaultModel: 'opus', agentFramework: 'auto-claude', pythonPath: undefined as string | undefined, + gitPath: undefined as string | undefined, + githubCLIPath: undefined as string | undefined, autoBuildPath: undefined as string | undefined, autoUpdateAutoBuild: true, autoNameTerminals: true, diff --git a/apps/frontend/src/shared/constants/ipc.ts b/apps/frontend/src/shared/constants/ipc.ts index 44c914e8..261b3e8e 100644 --- a/apps/frontend/src/shared/constants/ipc.ts +++ b/apps/frontend/src/shared/constants/ipc.ts @@ -106,6 +106,7 @@ export const IPC_CHANNELS = { // Settings SETTINGS_GET: 'settings:get', SETTINGS_SAVE: 'settings:save', + SETTINGS_GET_CLI_TOOLS_INFO: 'settings:getCliToolsInfo', // Dialogs DIALOG_SELECT_DIRECTORY: 'dialog:selectDirectory', diff --git a/apps/frontend/src/shared/i18n/locales/en/settings.json b/apps/frontend/src/shared/i18n/locales/en/settings.json index 99d51bec..da7a34fa 100644 --- a/apps/frontend/src/shared/i18n/locales/en/settings.json +++ b/apps/frontend/src/shared/i18n/locales/en/settings.json @@ -23,7 +23,7 @@ }, "paths": { "title": "Paths", - "description": "Python and framework paths" + "description": "CLI tools and framework paths" }, "integrations": { "title": "Integrations", @@ -67,8 +67,24 @@ "paths": "Paths", "pathsDescription": "Configure executable and framework paths", "pythonPath": "Python Path", - "pythonPathDescription": "Path to Python executable (leave empty for default)", + "pythonPathDescription": "Path to Python executable (leave empty for auto-detection)", "pythonPathPlaceholder": "python3 (default)", + "gitPath": "Git Path", + "gitPathDescription": "Path to Git executable (leave empty for auto-detection)", + "gitPathPlaceholder": "git (default)", + "githubCLIPath": "GitHub CLI Path", + "githubCLIPathDescription": "Path to GitHub CLI (gh) executable (leave empty for auto-detection)", + "githubCLIPathPlaceholder": "gh (default)", + "detectedPath": "Auto-detected", + "detectedVersion": "Version", + "detectedSource": "Source", + "sourceUserConfig": "User Configuration", + "sourceVenv": "Virtual Environment", + "sourceHomebrew": "Homebrew", + "sourceSystemPath": "System PATH", + "sourceBundled": "Bundled", + "sourceFallback": "Fallback", + "notDetected": "Not detected", "autoClaudePath": "Auto Claude Path", "autoClaudePathDescription": "Relative path to auto-claude directory in projects", "autoClaudePathPlaceholder": "auto-claude (default)", diff --git a/apps/frontend/src/shared/i18n/locales/fr/settings.json b/apps/frontend/src/shared/i18n/locales/fr/settings.json index eef0f006..76486585 100644 --- a/apps/frontend/src/shared/i18n/locales/fr/settings.json +++ b/apps/frontend/src/shared/i18n/locales/fr/settings.json @@ -23,7 +23,7 @@ }, "paths": { "title": "Chemins", - "description": "Chemins Python et framework" + "description": "Outils CLI et chemins framework" }, "integrations": { "title": "Intégrations", @@ -67,8 +67,24 @@ "paths": "Chemins", "pathsDescription": "Configurer les chemins des exécutables et du framework", "pythonPath": "Chemin Python", - "pythonPathDescription": "Chemin vers l'exécutable Python (laisser vide pour la valeur par défaut)", + "pythonPathDescription": "Chemin vers l'exécutable Python (laisser vide pour détection automatique)", "pythonPathPlaceholder": "python3 (par défaut)", + "gitPath": "Chemin Git", + "gitPathDescription": "Chemin vers l'exécutable Git (laisser vide pour détection automatique)", + "gitPathPlaceholder": "git (par défaut)", + "githubCLIPath": "Chemin GitHub CLI", + "githubCLIPathDescription": "Chemin vers l'exécutable GitHub CLI (gh) (laisser vide pour détection automatique)", + "githubCLIPathPlaceholder": "gh (par défaut)", + "detectedPath": "Détecté automatiquement", + "detectedVersion": "Version", + "detectedSource": "Source", + "sourceUserConfig": "Configuration utilisateur", + "sourceVenv": "Environnement virtuel", + "sourceHomebrew": "Homebrew", + "sourceSystemPath": "PATH système", + "sourceBundled": "Intégré", + "sourceFallback": "Valeur par défaut", + "notDetected": "Non détecté", "autoClaudePath": "Chemin Auto Claude", "autoClaudePathDescription": "Chemin relatif vers le répertoire auto-claude dans les projets", "autoClaudePathPlaceholder": "auto-claude (par défaut)", diff --git a/apps/frontend/src/shared/types/cli.ts b/apps/frontend/src/shared/types/cli.ts new file mode 100644 index 00000000..e550fa84 --- /dev/null +++ b/apps/frontend/src/shared/types/cli.ts @@ -0,0 +1,24 @@ +/** + * CLI Tool Types + * + * Shared types for CLI tool detection and management. + * Used by both main process (cli-tool-manager) and renderer process (Settings UI). + */ + +/** + * Result of tool detection operation + * Contains path, version, and metadata about detection source + */ +export interface ToolDetectionResult { + found: boolean; + path?: string; + version?: string; + source: + | 'user-config' + | 'venv' + | 'homebrew' + | 'system-path' + | 'bundled' + | 'fallback'; + message: string; +} diff --git a/apps/frontend/src/shared/types/index.ts b/apps/frontend/src/shared/types/index.ts index 5da875a6..38fbe58d 100644 --- a/apps/frontend/src/shared/types/index.ts +++ b/apps/frontend/src/shared/types/index.ts @@ -16,6 +16,7 @@ export * from './insights'; export * from './roadmap'; export * from './integrations'; export * from './app-update'; +export * from './cli'; // IPC types (must be last to use types from other modules) export * from './ipc'; diff --git a/apps/frontend/src/shared/types/ipc.ts b/apps/frontend/src/shared/types/ipc.ts index 778dbdff..48e69a0e 100644 --- a/apps/frontend/src/shared/types/ipc.ts +++ b/apps/frontend/src/shared/types/ipc.ts @@ -237,6 +237,11 @@ export interface ElectronAPI { // App settings getSettings: () => Promise>; saveSettings: (settings: Partial) => Promise; + getCliToolsInfo: () => Promise>; // Dialog operations selectDirectory: () => Promise; diff --git a/apps/frontend/src/shared/types/settings.ts b/apps/frontend/src/shared/types/settings.ts index 38143744..626019f9 100644 --- a/apps/frontend/src/shared/types/settings.ts +++ b/apps/frontend/src/shared/types/settings.ts @@ -84,6 +84,8 @@ export interface AppSettings { defaultModel: string; agentFramework: string; pythonPath?: string; + gitPath?: string; + githubCLIPath?: string; autoBuildPath?: string; autoUpdateAutoBuild: boolean; autoNameTerminals: boolean;