claude-git-hooks
Version:
Git hooks with Claude CLI for code analysis and automatic commit messages
246 lines (219 loc) • 8.03 kB
JavaScript
/**
* 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;