UNPKG

claude-git-hooks

Version:

Git hooks with Claude CLI for code analysis and automatic commit messages

246 lines (219 loc) 8.03 kB
/** * File: which-command.js * Purpose: Cross-platform executable path resolution (like 'which' on Unix, 'where' on Windows) * * Why this exists: * - Node.js 24 deprecates passing args to spawn() with shell: true (DEP0190) * - We need to resolve executable paths WITHOUT using shell: true * - Different platforms have different mechanisms (which vs where) * - This provides a unified, safe interface for all platforms * * Key responsibilities: * - Find executables in PATH * - Handle Windows .exe/.cmd/.bat extensions * - Work consistently across Unix and Windows * - No dependency on shell: true * * Dependencies: * - child_process: For executing platform-specific which/where commands * - fs: For checking file existence * - os: For platform detection * - path: For path manipulation */ import { execSync } from 'child_process'; import { existsSync } from 'fs'; import { join, delimiter, sep } from 'path'; import os from 'os'; import logger from './logger.js'; /** * Get PATH environment variable as array of directories * Why: Different platforms use different separators (: on Unix, ; on Windows) * * @returns {Array<string>} Array of directory paths */ const getPathDirs = () => { const pathEnv = process.env.PATH || ''; return pathEnv.split(delimiter).filter(dir => dir.length > 0); }; /** * Windows executable extensions to check * Why: Windows executables can have various extensions * Order matters: .exe is most common, check first for performance */ const WINDOWS_EXTENSIONS = ['.exe', '.cmd', '.bat', '.com']; /** * Check if a file exists and is executable * Why: Not all files in PATH are actually executable * * @param {string} filePath - Absolute path to check * @returns {boolean} True if file exists and appears executable */ const isExecutable = (filePath) => { try { return existsSync(filePath); } catch (error) { logger.debug('which-command - isExecutable', 'File check failed', { filePath, error: error.message }); return false; } }; /** * Find executable in PATH manually (fallback method) * Why: More reliable than which/where commands, works even if those commands fail * * @param {string} command - Command name to find * @returns {string|null} Full path to executable or null */ const findInPath = (command) => { const pathDirs = getPathDirs(); const isWin = os.platform() === 'win32'; logger.debug('which-command - findInPath', 'Searching PATH', { command, dirCount: pathDirs.length, isWindows: isWin }); for (const dir of pathDirs) { if (isWin) { // Windows: Try command as-is, then with extensions const candidates = [ join(dir, command), ...WINDOWS_EXTENSIONS.map(ext => join(dir, command + ext)) ]; for (const candidate of candidates) { if (isExecutable(candidate)) { logger.debug('which-command - findInPath', 'Found in PATH', { path: candidate }); return candidate; } } } else { // Unix: Just check command as-is const candidate = join(dir, command); if (isExecutable(candidate)) { logger.debug('which-command - findInPath', 'Found in PATH', { path: candidate }); return candidate; } } } logger.debug('which-command - findInPath', 'Not found in PATH', { command }); return null; }; /** * Find executable using platform-specific which/where command * Why: Faster than manual PATH search, respects platform conventions * * @param {string} command - Command name to find * @returns {string|null} Full path to executable or null */ const whichViaCommand = (command) => { try { const isWin = os.platform() === 'win32'; const whichCmd = isWin ? 'where' : 'which'; logger.debug('which-command - whichViaCommand', 'Executing platform which', { command, whichCmd, platform: os.platform() }); // Why no shell: true: We're executing a simple command with no args // This is safe and doesn't trigger DEP0190 const result = execSync(`${whichCmd} ${command}`, { encoding: 'utf8', stdio: ['pipe', 'pipe', 'ignore'], // Suppress stderr timeout: 3000 // Don't wait forever }); // Windows 'where' returns multiple matches const matches = result.split('\n').map(line => line.trim()).filter(line => line.length > 0); // CRITICAL FIX: On Windows, prefer .cmd/.bat over extensionless entries // Why: npm creates both 'claude' and 'claude.cmd', but only .cmd is executable via spawn() // Example: where claude returns: // 1. C:\Users\...\npm\claude (NOT executable) // 2. C:\Users\...\npm\claude.cmd (executable) if (isWin && matches.length > 1) { const cmdMatch = matches.find(m => m.endsWith('.cmd') || m.endsWith('.bat')); if (cmdMatch) { logger.debug('which-command - whichViaCommand', 'Preferring .cmd/.bat over extensionless', { command, preferred: cmdMatch, allMatches: matches }); return cmdMatch; } } const firstMatch = matches[0]; logger.debug('which-command - whichViaCommand', 'Found via command', { command, path: firstMatch, totalMatches: matches.length }); return firstMatch; } catch (error) { // Command not found or which/where command failed logger.debug('which-command - whichViaCommand', 'Command failed', { command, error: error.message }); return null; } }; /** * Find executable in PATH (main entry point) * Why: Provides consistent cross-platform executable resolution * * Strategy: * 1. Try platform which/where command (fast) * 2. Fallback to manual PATH search (reliable) * 3. Return null if not found * * @param {string} command - Command name (e.g., 'claude', 'git', 'node') * @returns {string|null} Full path to executable or null if not found * * @example * const claudePath = which('claude'); * if (claudePath) { * spawn(claudePath, ['--version']); // No shell: true needed! * } */ export const which = (command) => { logger.debug('which-command - which', 'Resolving executable', { command }); // Quick check: Is it already an absolute path? if (command.includes(sep) && isExecutable(command)) { logger.debug('which-command - which', 'Already absolute path', { command }); return command; } // Strategy 1: Use platform which/where command const viaCmdResult = whichViaCommand(command); if (viaCmdResult) { return viaCmdResult; } // Strategy 2: Manual PATH search const viaPathResult = findInPath(command); if (viaPathResult) { return viaPathResult; } // Not found logger.debug('which-command - which', 'Executable not found', { command }); return null; }; /** * Find executable or throw error * Why: Convenient for required executables * * @param {string} command - Command name * @param {string} errorMessage - Custom error message * @returns {string} Full path to executable * @throws {Error} If executable not found */ export const whichOrThrow = (command, errorMessage) => { const result = which(command); if (!result) { throw new Error(errorMessage || `Executable not found: ${command}`); } return result; }; /** * Check if an executable exists in PATH * Why: Simple boolean check without returning path * * @param {string} command - Command name * @returns {boolean} True if executable exists in PATH */ export const hasCommand = (command) => which(command) !== null; export default which;