diff --git a/scripts/__tests__/ripgrep_launcher.test.ts b/scripts/__tests__/ripgrep_launcher.test.ts new file mode 100644 index 00000000..258dcb72 --- /dev/null +++ b/scripts/__tests__/ripgrep_launcher.test.ts @@ -0,0 +1,94 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +describe('Ripgrep Launcher Runtime Compatibility', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('has correct file structure', () => { + // Test that the launcher file has the correct structure + expect(() => { + const fs = require('fs'); + const path = require('path'); + const content = fs.readFileSync(path.join(__dirname, '../ripgrep_launcher.cjs'), 'utf8'); + + // Check for required elements + expect(content).toContain('#!/usr/bin/env node'); + expect(content).toContain('ripgrepMain'); + expect(content).toContain('loadRipgrepNative'); + }).not.toThrow(); + }); + + it('handles --version argument gracefully', () => { + // Test that --version handling logic exists + expect(() => { + const fs = require('fs'); + const path = require('path'); + const content = fs.readFileSync(path.join(__dirname, '../ripgrep_launcher.cjs'), 'utf8'); + + // Check that --version handling is present + expect(content).toContain('--version'); + expect(content).toContain('ripgrepMain'); + }).not.toThrow(); + }); + + it('detects runtime correctly', () => { + // Test runtime detection function exists + expect(() => { + const fs = require('fs'); + const path = require('path'); + const content = fs.readFileSync(path.join(__dirname, '../ripgrep_launcher.cjs'), 'utf8'); + + // Check that runtime detection logic is present + expect(content).toContain('detectRuntime'); + expect(content).toContain('typeof Bun'); + expect(content).toContain('typeof Deno'); + expect(content).toContain('process?.versions'); + }).not.toThrow(); + }); + + it('contains fallback chain logic', () => { + // Test that fallback logic is present + expect(() => { + const fs = require('fs'); + const path = require('path'); + const content = fs.readFileSync(path.join(__dirname, '../ripgrep_launcher.cjs'), 'utf8'); + + // Check that fallback chain is present + expect(content).toContain('loadRipgrepNative'); + expect(content).toContain('systemRipgrep'); + expect(content).toContain('createRipgrepWrapper'); + expect(content).toContain('createMockRipgrep'); + }).not.toThrow(); + }); + + it('contains cross-platform logic', () => { + // Test that cross-platform logic is present + expect(() => { + const fs = require('fs'); + const path = require('path'); + const content = fs.readFileSync(path.join(__dirname, '../ripgrep_launcher.cjs'), 'utf8'); + + // Check for platform-specific logic + expect(content).toContain('process.platform'); + expect(content).toContain('win32'); + expect(content).toContain('darwin'); + expect(content).toContain('linux'); + expect(content).toContain('execFileSync'); + }).not.toThrow(); + }); + + it('provides helpful error messages', () => { + // Test that helpful error messages are present + expect(() => { + const fs = require('fs'); + const path = require('path'); + const content = fs.readFileSync(path.join(__dirname, '../ripgrep_launcher.cjs'), 'utf8'); + + // Check for helpful messages + expect(content).toContain('brew install ripgrep'); + expect(content).toContain('winget install BurntSushi.ripgrep'); + expect(content).toContain('Search functionality unavailable'); + }).not.toThrow(); + }); +}); \ No newline at end of file diff --git a/scripts/claude_version_utils.cjs b/scripts/claude_version_utils.cjs index 921729e5..73776a85 100644 --- a/scripts/claude_version_utils.cjs +++ b/scripts/claude_version_utils.cjs @@ -49,72 +49,191 @@ function findNpmGlobalCliPath() { } /** - * Find path to Homebrew installed Claude Code CLI - * @returns {string|null} Path to cli.js or binary, or null if not found + * Find Claude CLI using system PATH (which/where command) + * Respects user's configuration and works across all platforms + * @returns {{path: string, source: string}|null} Path and source, or null if not found */ -function findHomebrewCliPath() { - if (process.platform !== 'darwin' && process.platform !== 'linux') { - return null; +function findClaudeInPath() { + try { + // Cross-platform: 'where' on Windows, 'which' on Unix + const command = process.platform === 'win32' ? 'where claude' : 'which claude'; + const claudePath = execSync(command, { encoding: 'utf8' }) + .trim() + .split('\n')[0]; // Take first match + + const resolvedPath = resolvePathSafe(claudePath); + + if (resolvedPath && fs.existsSync(resolvedPath)) { + // Detect source from BOTH original PATH entry and resolved path + // Original path tells us HOW user accessed it (context) + // Resolved path tells us WHERE it actually lives (content) + const originalSource = detectSourceFromPath(claudePath); + const resolvedSource = detectSourceFromPath(resolvedPath); + + // Prioritize original PATH entry for context (e.g., bun vs npm access) + // Fall back to resolved path for accurate location detection + const source = originalSource !== 'PATH' ? originalSource : resolvedSource; + + return { + path: resolvedPath, + source: source + }; + } + } catch (e) { + // Command failed (claude not in PATH) } - - // Try to get Homebrew prefix via command first - let brewPrefix = null; + return null; +} + +/** + * Detect installation source from resolved path + * Uses concrete path patterns, no assumptions + * @param {string} resolvedPath - The resolved path to cli.js + * @returns {string} Installation method/source + */ +function detectSourceFromPath(resolvedPath) { + const normalized = resolvedPath.toLowerCase(); + const path = require('path'); + + // Use path.normalize() for proper cross-platform path handling + const normalizedPath = path.normalize(resolvedPath).toLowerCase(); + + // Bun: ~/.bun/bin/claude -> ../node_modules/@anthropic-ai/claude-code/cli.js + // Works on Windows too: C:\Users\[user]\.bun\bin\claude + if (normalizedPath.includes('.bun') && normalizedPath.includes('bin') || + (normalizedPath.includes('node_modules') && normalizedPath.includes('.bun'))) { + return 'Bun'; + } + + // Homebrew cask: hashed directories like .claude-code-2DTsDk1V (NOT npm installations) + // Must check before general Homebrew paths to distinguish from npm-through-Homebrew + if (normalizedPath.includes('@anthropic-ai') && normalizedPath.includes('.claude-code-')) { + return 'Homebrew'; + } + + // npm: clean claude-code directory (even through Homebrew's npm) + // Windows: %APPDATA%\npm\node_modules\@anthropic-ai\claude-code + if (normalizedPath.includes('node_modules') && normalizedPath.includes('@anthropic-ai') && normalizedPath.includes('claude-code') && + !normalizedPath.includes('.claude-code-')) { + return 'npm'; + } + + // Windows-specific detection (detect by path patterns, not current platform) + if (normalizedPath.includes('appdata') || normalizedPath.includes('program files') || normalizedPath.endsWith('.exe')) { + // Windows npm + if (normalizedPath.includes('appdata') && normalizedPath.includes('npm') && normalizedPath.includes('node_modules')) { + return 'npm'; + } + + // Windows native installer (any location ending with claude.exe) + if (normalizedPath.endsWith('claude.exe')) { + return 'native installer'; + } + + // Windows native installer in AppData + if (normalizedPath.includes('appdata') && normalizedPath.includes('claude')) { + return 'native installer'; + } + + // Windows native installer in Program Files + if (normalizedPath.includes('program files') && normalizedPath.includes('claude')) { + return 'native installer'; + } + } + + // Homebrew general paths (for non-npm installations like Cellar binaries) + // Apple Silicon: /opt/homebrew/bin/claude + // Intel Mac: /usr/local/bin/claude (ONLY on macOS, not Linux) + // Linux Homebrew: /home/linuxbrew/.linuxbrew/bin/claude or ~/.linuxbrew/bin/claude + if (normalizedPath.includes('opt/homebrew') || + normalizedPath.includes('usr/local/homebrew') || + normalizedPath.includes('home/linuxbrew') || + normalizedPath.includes('.linuxbrew') || + normalizedPath.includes('.homebrew') || + normalizedPath.includes('cellar') || + normalizedPath.includes('caskroom') || + (normalizedPath.includes('usr/local/bin/claude') && process.platform === 'darwin')) { // Intel Mac Homebrew default only on macOS + return 'Homebrew'; + } + + // Native installer: standard Unix locations and ~/.local/bin + // /usr/local/bin/claude on Linux should be native installer + if (normalizedPath.includes('.local') && normalizedPath.includes('bin') || + normalizedPath.includes('.local') && normalizedPath.includes('share') && normalizedPath.includes('claude') || + (normalizedPath.includes('usr/local/bin/claude') && process.platform === 'linux')) { // Linux native installer + return 'native installer'; + } + + // Default: we found it in PATH but can't determine source + return 'PATH'; +} + +/** + * Find path to Bun globally installed Claude Code CLI + * FIX: Check bun's bin directory, not non-existent modules directory + * @returns {string|null} Path to cli.js or null if not found + */ +function findBunGlobalCliPath() { + // First check if bun command exists (cross-platform) try { - brewPrefix = execSync('brew --prefix 2>/dev/null', { encoding: 'utf8' }).trim(); + const bunCheckCommand = process.platform === 'win32' ? 'where bun' : 'which bun'; + execSync(bunCheckCommand, { encoding: 'utf8' }); } catch (e) { - // brew command not in PATH, try standard locations + return null; // bun not installed } - - // Standard Homebrew locations to check - const possiblePrefixes = []; - if (brewPrefix) { - possiblePrefixes.push(brewPrefix); + + // Check bun's binary directory (works on both Unix and Windows) + const bunBin = path.join(os.homedir(), '.bun', 'bin', 'claude'); + const resolved = resolvePathSafe(bunBin); + + if (resolved && resolved.endsWith('cli.js') && fs.existsSync(resolved)) { + return resolved; } - - // Add standard locations based on platform - if (process.platform === 'darwin') { - // macOS: Intel (/usr/local) or Apple Silicon (/opt/homebrew) - possiblePrefixes.push('/opt/homebrew', '/usr/local'); - } else if (process.platform === 'linux') { - // Linux: system-wide or user installation - const homeDir = os.homedir(); - possiblePrefixes.push('/home/linuxbrew/.linuxbrew', path.join(homeDir, '.linuxbrew')); + + return null; +} + +/** + * Find path to Homebrew installed Claude Code CLI + * FIX: Handle hashed directory names like .claude-code-[hash] + * @returns {string|null} Path to cli.js or binary, or null if not found + */ +function findHomebrewCliPath() { + if (process.platform !== 'darwin' && process.platform !== 'linux') { + return null; } - - // Check each possible prefix + + const possiblePrefixes = [ + '/opt/homebrew', + '/usr/local', + path.join(os.homedir(), '.linuxbrew'), + path.join(os.homedir(), '.homebrew') + ].filter(fs.existsSync); + for (const prefix of possiblePrefixes) { - if (!fs.existsSync(prefix)) { - continue; - } - - // Homebrew installs claude-code as a Cask (binary) in Caskroom - const caskroomPath = path.join(prefix, 'Caskroom', 'claude-code'); - if (fs.existsSync(caskroomPath)) { - const found = findLatestVersionBinary(caskroomPath, 'claude'); - if (found) return found; + // Check for binary symlink first (most reliable) + const binPath = path.join(prefix, 'bin', 'claude'); + const resolved = resolvePathSafe(binPath); + if (resolved && fs.existsSync(resolved)) { + return resolved; } - - // Also check Cellar (for formula installations, though claude-code is usually a Cask) - const cellarPath = path.join(prefix, 'Cellar', 'claude-code'); - if (fs.existsSync(cellarPath)) { - // Cellar has different structure - check for cli.js in libexec - const entries = fs.readdirSync(cellarPath); - if (entries.length > 0) { - const sorted = entries.sort((a, b) => compareVersions(b, a)); - const latestVersion = sorted[0]; - const cliPath = path.join(cellarPath, latestVersion, 'libexec', 'lib', 'node_modules', '@anthropic-ai', 'claude-code', 'cli.js'); - if (fs.existsSync(cliPath)) { - return cliPath; + + // Fallback: check for hashed directories in node_modules + const nodeModulesPath = path.join(prefix, 'lib', 'node_modules', '@anthropic-ai'); + if (fs.existsSync(nodeModulesPath)) { + // Look for both claude-code and .claude-code-[hash] + const entries = fs.readdirSync(nodeModulesPath); + for (const entry of entries) { + if (entry === 'claude-code' || entry.startsWith('.claude-code-')) { + const cliPath = path.join(nodeModulesPath, entry, 'cli.js'); + if (fs.existsSync(cliPath)) { + return cliPath; + } } } } - - // Check bin directory for symlink (most reliable) - const binPath = path.join(prefix, 'bin', 'claude'); - const resolvedBinPath = resolvePathSafe(binPath); - if (resolvedBinPath) return resolvedBinPath; } - + return null; } @@ -251,22 +370,24 @@ function findLatestVersionBinary(versionsDir, binaryName = null) { /** * Find path to globally installed Claude Code CLI - * Checks multiple installation methods in order of preference: - * 1. npm global (highest priority) - * 2. Homebrew - * 3. Native installer + * Priority: PATH (user preference) > npm > Bun > Homebrew > Native * @returns {{path: string, source: string}|null} Path and source, or null if not found */ function findGlobalClaudeCliPath() { - // Check npm global first (highest priority) + // 1. Check PATH first (respects user's choice) + const pathResult = findClaudeInPath(); + if (pathResult) return pathResult; + + // 2. Fall back to package manager detection const npmPath = findNpmGlobalCliPath(); if (npmPath) return { path: npmPath, source: 'npm' }; - // Check Homebrew installation + const bunPath = findBunGlobalCliPath(); + if (bunPath) return { path: bunPath, source: 'Bun' }; + const homebrewPath = findHomebrewCliPath(); if (homebrewPath) return { path: homebrewPath, source: 'Homebrew' }; - // Check native installer const nativePath = findNativeInstallerCliPath(); if (nativePath) return { path: nativePath, source: 'native installer' }; @@ -367,7 +488,10 @@ function runClaudeCli(cliPath) { module.exports = { findGlobalClaudeCliPath, + findClaudeInPath, + detectSourceFromPath, findNpmGlobalCliPath, + findBunGlobalCliPath, findHomebrewCliPath, findNativeInstallerCliPath, getVersion, diff --git a/scripts/claude_version_utils.test.ts b/scripts/claude_version_utils.test.ts new file mode 100644 index 00000000..ecd6a7ff --- /dev/null +++ b/scripts/claude_version_utils.test.ts @@ -0,0 +1,338 @@ +import { describe, it, expect } from 'vitest'; +import { + findGlobalClaudeCliPath, + findClaudeInPath, + detectSourceFromPath, + findNpmGlobalCliPath, + findBunGlobalCliPath, + findHomebrewCliPath, + findNativeInstallerCliPath, + getVersion, + compareVersions +} from '../scripts/claude_version_utils.cjs'; + +describe('Claude Version Utils - Cross-Platform Detection', () => { + + describe('detectSourceFromPath', () => { + + describe('npm installations', () => { + it('should detect npm global installation on macOS/Linux', () => { + const result = detectSourceFromPath('/usr/local/lib/node_modules/@anthropic-ai/claude-code/cli.js'); + expect(result).toBe('npm'); + }); + + it('should detect npm global installation on Windows with forward slashes', () => { + const result = detectSourceFromPath('C:/Users/test/AppData/Roaming/npm/node_modules/@anthropic-ai/claude-code/cli.js'); + expect(result).toBe('npm'); + }); + + it('should detect npm global installation on Windows with backslashes', () => { + const result = detectSourceFromPath('C:\\Users\\test\\AppData\\Roaming\\npm\\node_modules\\@anthropic-ai\\claude-code\\cli.js'); + expect(result).toBe('npm'); + }); + + it('should detect npm with different scoped packages', () => { + const result = detectSourceFromPath('C:/Users/test/AppData/Roaming/npm/node_modules/@babel/core/cli.js'); + expect(result).toBe('npm'); + }); + + it('should detect npm through Homebrew', () => { + const result = detectSourceFromPath('/opt/homebrew/lib/node_modules/@anthropic-ai/claude-code/cli.js'); + expect(result).toBe('npm'); + }); + + it('should NOT detect Homebrew cask as npm', () => { + const result = detectSourceFromPath('/opt/homebrew/lib/node_modules/@anthropic-ai/.claude-code-2DTsDk1V/cli.js'); + expect(result).toBe('Homebrew'); + }); + }); + + describe('Bun installations', () => { + it('should detect Bun global installation on Unix', () => { + const result = detectSourceFromPath('/Users/test/.bun/bin/claude'); + expect(result).toBe('Bun'); + }); + + it('should detect Bun global installation on Windows', () => { + const result = detectSourceFromPath('C:/Users/test/.bun/bin/claude'); + expect(result).toBe('Bun'); + }); + + it('should detect Bun with @ symbol in username', () => { + const result = detectSourceFromPath('C:/Users/@specialuser/.bun/bin/claude'); + expect(result).toBe('Bun'); + }); + + it('should detect Bun in node_modules context', () => { + const result = detectSourceFromPath('/Users/test/.bun/install/global/node_modules/@anthropic-ai/claude-code/cli.js'); + expect(result).toBe('Bun'); + }); + }); + + describe('Homebrew installations', () => { + it('should detect Homebrew on Apple Silicon macOS', () => { + const result = detectSourceFromPath('/opt/homebrew/bin/claude'); + expect(result).toBe('Homebrew'); + }); + + it('should detect Homebrew on Intel macOS', () => { + // Mock macOS platform + const originalPlatform = process.platform; + Object.defineProperty(process, 'platform', { value: 'darwin', configurable: true }); + + const result = detectSourceFromPath('/usr/local/bin/claude'); + expect(result).toBe('Homebrew'); + + // Restore original platform + Object.defineProperty(process, 'platform', { value: originalPlatform, configurable: true }); + }); + + it('should detect native installer on Linux for /usr/local/bin/claude', () => { + // Mock Linux platform + const originalPlatform = process.platform; + Object.defineProperty(process, 'platform', { value: 'linux', configurable: true }); + + const result = detectSourceFromPath('/usr/local/bin/claude'); + expect(result).toBe('native installer'); + + // Restore original platform + Object.defineProperty(process, 'platform', { value: originalPlatform, configurable: true }); + }); + + it('should detect Homebrew on Linux', () => { + const result = detectSourceFromPath('/home/linuxbrew/.linuxbrew/bin/claude'); + expect(result).toBe('Homebrew'); + }); + + it('should detect Homebrew user installation', () => { + const result = detectSourceFromPath('/Users/test/.linuxbrew/bin/claude'); + expect(result).toBe('Homebrew'); + }); + + it('should detect Homebrew cask with hashed directory', () => { + const result = detectSourceFromPath('/opt/homebrew/lib/node_modules/@anthropic-ai/.claude-code-2DTsDk1V/cli.js'); + expect(result).toBe('Homebrew'); + }); + + it('should detect Homebrew Cellar installation', () => { + const result = detectSourceFromPath('/opt/homebrew/Cellar/claude-code/1.0.0/bin/claude'); + expect(result).toBe('Homebrew'); + }); + }); + + describe('Native installer installations', () => { + it('should detect native installer on Unix ~/.local', () => { + const result = detectSourceFromPath('/Users/test/.local/bin/claude'); + expect(result).toBe('native installer'); + }); + + it('should detect native installer with versioned structure', () => { + const result = detectSourceFromPath('/Users/test/.local/share/claude/versions/2.0.69/claude'); + expect(result).toBe('native installer'); + }); + + it('should detect native installer on Windows Program Files', () => { + const result = detectSourceFromPath('C:/Program Files/Claude/claude.exe'); + expect(result).toBe('native installer'); + }); + + it('should detect native installer on Windows AppData', () => { + const result = detectSourceFromPath('C:/Users/test/AppData/Local/Claude/claude.exe'); + expect(result).toBe('native installer'); + }); + + it('should detect native installer on Windows custom location', () => { + const result = detectSourceFromPath('E:/Tools/Claude/claude.exe'); + expect(result).toBe('native installer'); + }); + + it('should detect native installer on Windows D: drive', () => { + const result = detectSourceFromPath('D:/Development/Claude/claude.exe'); + expect(result).toBe('native installer'); + }); + + it('should detect native installer in user profile', () => { + const result = detectSourceFromPath('C:/Users/test/.claude/claude.exe'); + expect(result).toBe('native installer'); + }); + }); + + describe('Edge cases and special characters', () => { + it('should handle @ symbols in paths correctly', () => { + const result = detectSourceFromPath('/Users/@developer/test/node_modules/@anthropic-ai/claude-code/cli.js'); + expect(result).toBe('npm'); + }); + + it('should handle case sensitivity variations on Windows', () => { + const result = detectSourceFromPath('C:/USERS/TEST/APPDATA/ROAMING/NPM/NODE_MODULES/@ANTHROPIC-AI/CLAUDE-CODE/CLI.JS'); + expect(result).toBe('npm'); + }); + + it('should return PATH for unrecognized paths', () => { + const result = detectSourceFromPath('/some/random/path/claude'); + expect(result).toBe('PATH'); + }); + + it('should handle empty paths', () => { + const result = detectSourceFromPath(''); + expect(result).toBe('PATH'); + }); + + it('should handle relative paths', () => { + const result = detectSourceFromPath('./local/bin/claude'); + expect(result).toBe('PATH'); + }); + }); + }); + + describe('Cross-platform compatibility', () => { + it('should handle both forward and backward slashes', () => { + const forward = detectSourceFromPath('C:/Users/test/AppData/Local/Claude/claude.exe'); + const backward = detectSourceFromPath('C:\\Users\\test\\AppData\\Local\\Claude\\claude.exe'); + + expect(forward).toBe('native installer'); + expect(backward).toBe('native installer'); + }); + + it('should handle Windows drive letters', () => { + const drives = ['C:', 'D:', 'E:', 'Z:']; + drives.forEach(drive => { + const result = detectSourceFromPath(`${drive}/Program Files/Claude/claude.exe`); + expect(result).toBe('native installer'); + }); + }); + + it('should handle Unix-style absolute paths', () => { + const unixPaths = [ + '/usr/local/bin/claude', + '/opt/homebrew/bin/claude', + '/home/user/.local/bin/claude' + ]; + + unixPaths.forEach(path => { + const result = detectSourceFromPath(path); + expect(['Homebrew', 'native installer']).toContain(result); + }); + }); + }); + + describe('Version comparison', () => { + it('should compare versions correctly', () => { + expect(compareVersions('2.0.69', '2.0.68')).toBe(1); + expect(compareVersions('2.0.68', '2.0.69')).toBe(-1); + expect(compareVersions('2.0.69', '2.0.69')).toBe(0); + expect(compareVersions('2.1.0', '2.0.69')).toBe(1); + expect(compareVersions('1.0.0', '2.0.0')).toBe(-1); + }); + + it('should handle malformed versions gracefully', () => { + expect(() => compareVersions('', '2.0.0')).not.toThrow(); + expect(() => compareVersions('invalid', '2.0.0')).not.toThrow(); + expect(() => compareVersions('2.0.0', '')).not.toThrow(); + }); + }); + + describe('Integration scenarios', () => { + it('should handle multiple installations scenario', () => { + const scenarios = [ + { path: '/Users/test/.bun/bin/claude', expected: 'Bun' }, + { path: '/opt/homebrew/bin/claude', expected: 'Homebrew' }, + { path: '/usr/local/lib/node_modules/@anthropic-ai/claude-code/cli.js', expected: 'npm' }, + { path: 'C:/Program Files/Claude/claude.exe', expected: 'native installer' } + ]; + + scenarios.forEach(({ path, expected }) => { + const result = detectSourceFromPath(path); + expect(result).toBe(expected); + }); + }); + + it('should maintain 100% success rate on all standard installation patterns', () => { + const standardPatterns = [ + // npm (most common) + { path: '/usr/local/lib/node_modules/@anthropic-ai/claude-code/cli.js', expected: 'npm' }, + { path: 'C:/Users/test/AppData/Roaming/npm/node_modules/@anthropic-ai/claude-code/cli.js', expected: 'npm' }, + + // bun (second most common) + { path: '/Users/test/.bun/bin/claude', expected: 'Bun' }, + { path: 'C:/Users/test/.bun/bin/claude', expected: 'Bun' }, + + // homebrew (macOS and Linux) + { path: '/opt/homebrew/bin/claude', expected: 'Homebrew' }, + { path: '/home/linuxbrew/.linuxbrew/bin/claude', expected: 'Homebrew' }, + { path: '/Users/test/.linuxbrew/bin/claude', expected: 'Homebrew' }, // LinuxBrew user installation + + // native installers + { path: 'C:/Program Files/Claude/claude.exe', expected: 'native installer' }, + { path: 'C:/Users/test/AppData/Local/Claude/claude.exe', expected: 'native installer' }, + { path: '/Users/test/.local/bin/claude', expected: 'native installer' } + ]; + + let passed = 0; + standardPatterns.forEach(({ path, expected }) => { + const result = detectSourceFromPath(path); + if (result === expected) passed++; + }); + + expect(passed).toBe(standardPatterns.length); + expect(passed / standardPatterns.length).toBe(1); // 100% success rate + }); + + it('should handle platform-specific /usr/local/bin/claude correctly', () => { + const originalPlatform = process.platform; + + // Test on macOS (should be Homebrew) + Object.defineProperty(process, 'platform', { value: 'darwin', configurable: true }); + const macosResult = detectSourceFromPath('/usr/local/bin/claude'); + expect(macosResult).toBe('Homebrew'); + + // Test on Linux (should be native installer) + Object.defineProperty(process, 'platform', { value: 'linux', configurable: true }); + const linuxResult = detectSourceFromPath('/usr/local/bin/claude'); + expect(linuxResult).toBe('native installer'); + + // Test on Windows (should fallback to PATH) + Object.defineProperty(process, 'platform', { value: 'win32', configurable: true }); + const windowsResult = detectSourceFromPath('/usr/local/bin/claude'); + expect(windowsResult).toBe('PATH'); + + // Restore original platform + Object.defineProperty(process, 'platform', { value: originalPlatform, configurable: true }); + }); + }); + + describe('Real-world edge cases', () => { + it('should handle complex user scenarios', () => { + const edgeCases = [ + // User with npm aliased to bun + { path: '/Users/test/node_modules/@anthropic-ai/claude-code/cli.js', expected: 'npm' }, + + // Multiple package managers + { path: '/Users/test/.bun/bin/claude', expected: 'Bun' }, + { path: '/opt/homebrew/bin/claude', expected: 'Homebrew' }, + + // Custom installations + { path: '/opt/custom/claude/bin/claude', expected: 'PATH' }, + { path: '/usr/local/custom/bin/claude', expected: 'PATH' } + ]; + + edgeCases.forEach(({ path, expected }) => { + const result = detectSourceFromPath(path); + expect(result).toBe(expected); + }); + }); + + it('should handle path traversal and normalization', () => { + const pathNormalizationTests = [ + { input: '/opt/homebrew/bin/../lib/claude', expected: 'Homebrew' }, + { input: '/Users/test/.bun/bin/./claude', expected: 'Bun' }, + { input: 'C:/Users/test/../test/AppData/Local/Claude/claude.exe', expected: 'native installer' } + ]; + + pathNormalizationTests.forEach(({ input, expected }) => { + const result = detectSourceFromPath(input); + expect(result).toBe(expected); + }); + }); + }); +}); \ No newline at end of file diff --git a/scripts/ripgrep_launcher.cjs b/scripts/ripgrep_launcher.cjs index 648277ab..ab00d9a8 100644 --- a/scripts/ripgrep_launcher.cjs +++ b/scripts/ripgrep_launcher.cjs @@ -3,13 +3,166 @@ /** * Ripgrep runner - executed as a subprocess to run the native module * This file is intentionally written in CommonJS to avoid ESM complexities + * + * Updated with graceful fallback chain for runtime compatibility: + * - Node.js: Try native addon first, fall back to binary + * - Bun: Use binary or system ripgrep directly + * - All runtimes: Cross-platform system detection + * - Fallback: Mock implementation with helpful guidance */ const path = require('path'); +const fs = require('fs'); -// Load the native module from unpacked directory -const modulePath = path.join(__dirname, '..', 'tools', 'unpacked', 'ripgrep.node'); -const ripgrepNative = require(modulePath); +// Runtime detection (minimal, focused) +function detectRuntime() { + if (typeof Bun !== 'undefined') return 'bun'; + if (typeof Deno !== 'undefined') return 'deno'; + if (process?.versions?.bun) return 'bun'; + if (process?.versions?.deno) return 'deno'; + if (process?.versions?.node) return 'node'; + return 'unknown'; +} + +// Find ripgrep in system PATH (cross-platform) +function findSystemRipgrep() { + const { execFileSync } = require('child_process'); + + // Platform-specific commands to find ripgrep + const commands = [ + // Windows: Use where command + process.platform === 'win32' && { cmd: 'where', args: ['rg'] }, + // Unix-like: Use which command + process.platform !== 'win32' && { cmd: 'which', args: ['rg'] } + ].filter(Boolean); + + for (const { cmd, args } of commands) { + try { + const result = execFileSync(cmd, args, { + encoding: 'utf8', + stdio: 'ignore' + }); + + if (result) { + const paths = result.trim().split('\n').filter(Boolean); + if (paths.length > 0) { + return paths[0].trim(); + } + } + } catch { + // Command failed, try next one + continue; + } + } + + // Fallback: Try common installation paths directly + const commonPaths = []; + if (process.platform === 'win32') { + commonPaths.push( + 'C:\\Program Files\\ripgrep\\rg.exe', + 'C:\\Program Files (x86)\\ripgrep\\rg.exe' + ); + } else if (process.platform === 'darwin') { + commonPaths.push( + '/opt/homebrew/bin/rg', + '/usr/local/bin/rg' + ); + } else if (process.platform === 'linux') { + commonPaths.push( + '/usr/bin/rg', + '/usr/local/bin/rg', + '/opt/homebrew/bin/rg' + ); + } + + for (const testPath of commonPaths) { + if (fs.existsSync(testPath)) { + return testPath; + } + } + + return null; +} + +// Create wrapper that mimics native addon interface +function createRipgrepWrapper(binaryPath) { + return { + ripgrepMain: (args) => { + const { spawnSync } = require('child_process'); + const result = spawnSync(binaryPath, args, { + stdio: 'inherit', + cwd: process.cwd() + }); + return result.status || 0; + } + }; +} + +// Create mock that doesn't crash but provides useful feedback +function createMockRipgrep() { + return { + ripgrepMain: (args) => { + if (args.includes('--version')) { + console.log('ripgrep 0.0.0 (mock)'); + return 0; + } + + console.error('Search functionality unavailable without ripgrep'); + console.error('See installation instructions above'); + return 1; + } + }; +} + +// Load ripgrep with graceful fallback chain +function loadRipgrepNative() { + const runtime = detectRuntime(); + const toolsDir = path.join(__dirname, '..', 'tools', 'unpacked'); + const nativePath = path.join(toolsDir, 'ripgrep.node'); + const binaryPath = path.join(toolsDir, 'rg'); + + // Try Node.js native addon first (preserves existing behavior) + if (runtime === 'node') { + try { + return require(nativePath); + } catch (error) { + console.warn('Failed to load ripgrep native addon:', error.message); + console.warn('Falling back to ripgrep binary...'); + // Fall through to binary fallback + } + } + + // Bun or Node.js fallback: Try system ripgrep + const systemRipgrep = findSystemRipgrep(); + if (systemRipgrep) { + console.info(`Using system ripgrep: ${systemRipgrep}`); + return createRipgrepWrapper(systemRipgrep); + } + + // Local binary fallback + if (fs.existsSync(binaryPath)) { + console.info('Using packaged ripgrep binary'); + return createRipgrepWrapper(binaryPath); + } + + // Final fallback: Return mock implementation that provides helpful guidance + console.warn('\n⚠️ ripgrep not available - search functionality limited'); + console.warn('Install ripgrep for full functionality:'); + + if (process.platform === 'win32') { + console.warn(' • Windows: winget install BurntSushi.ripgrep'); + console.warn(' • Or download from: https://github.com/BurntSushi/ripgrep/releases'); + } else { + console.warn(' • macOS/Linux: brew install ripgrep'); + console.warn(' • npm: npm install -g @silentsilas/ripgrep-bin'); + } + console.warn(''); + + return createMockRipgrep(); +} + +// Load ripgrep implementation +const ripgrepImplementation = loadRipgrepNative(); // Get arguments from command line (skip node and script name) const args = process.argv.slice(2); @@ -23,9 +176,9 @@ try { process.exit(1); } -// Run ripgrep +// Run ripgrep using the loaded implementation try { - const exitCode = ripgrepNative.ripgrepMain(parsedArgs); + const exitCode = ripgrepImplementation.ripgrepMain(parsedArgs); process.exit(exitCode); } catch (error) { console.error('Ripgrep error:', error.message); diff --git a/src/claude/sdk/utils.ts b/src/claude/sdk/utils.ts index 773ceb6a..0602d5a8 100644 --- a/src/claude/sdk/utils.ts +++ b/src/claude/sdk/utils.ts @@ -9,6 +9,7 @@ import { existsSync, readFileSync } from 'node:fs' import { execSync } from 'node:child_process' import { homedir } from 'node:os' import { logger } from '@/ui/logger' +import { isBun } from '@/utils/runtime' /** * Get the directory path of the current module @@ -41,16 +42,17 @@ function getGlobalClaudeVersion(): string | null { /** * Create a clean environment without local node_modules/.bin in PATH * This ensures we find the global claude, not the local one + * Also removes conflicting Bun environment variables when running in Bun */ export function getCleanEnv(): NodeJS.ProcessEnv { const env = { ...process.env } const cwd = process.cwd() const pathSep = process.platform === 'win32' ? ';' : ':' const pathKey = process.platform === 'win32' ? 'Path' : 'PATH' - + // Also check for PATH on Windows (case can vary) const actualPathKey = Object.keys(env).find(k => k.toLowerCase() === 'path') || pathKey - + if (env[actualPathKey]) { // Remove any path that contains the current working directory (local node_modules/.bin) const cleanPath = env[actualPathKey]! @@ -64,7 +66,17 @@ export function getCleanEnv(): NodeJS.ProcessEnv { env[actualPathKey] = cleanPath logger.debug(`[Claude SDK] Cleaned PATH, removed local paths from: ${cwd}`) } - + + // Remove Bun-specific environment variables that can interfere with Node.js processes + if (isBun()) { + Object.keys(env).forEach(key => { + if (key.startsWith('BUN_')) { + delete env[key] + } + }) + logger.debug('[Claude SDK] Removed Bun-specific environment variables for Node.js compatibility') + } + return env } diff --git a/src/index.ts b/src/index.ts index 72febfa8..21e70de1 100644 --- a/src/index.ts +++ b/src/index.ts @@ -333,9 +333,9 @@ ${chalk.bold.cyan('Claude Code Options (from `claude --help`):')} `) // Run claude --help and display its output - // Use execFileSync with the current Node executable for cross-platform compatibility + // Use execFileSync directly with claude CLI for runtime-agnostic compatibility try { - const claudeHelp = execFileSync(process.execPath, [claudeCliPath, '--help'], { encoding: 'utf8' }) + const claudeHelp = execFileSync(claudeCliPath, ['--help'], { encoding: 'utf8' }) console.log(claudeHelp) } catch (e) { console.log(chalk.yellow('Could not retrieve claude help. Make sure claude is installed.')) diff --git a/src/utils/__tests__/runtime.test.ts b/src/utils/__tests__/runtime.test.ts new file mode 100644 index 00000000..0baec817 --- /dev/null +++ b/src/utils/__tests__/runtime.test.ts @@ -0,0 +1,69 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +describe('Runtime Detection', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('detects Node.js runtime correctly', () => { + // Test actual runtime detection + if (process.versions.node && !process.versions.bun && !process.versions.deno) { + const { getRuntime, isNode, isBun, isDeno } = require('../runtime.js'); + expect(getRuntime()).toBe('node'); + expect(isNode()).toBe(true); + expect(isBun()).toBe(false); + expect(isDeno()).toBe(false); + } + }); + + it('detects Bun runtime correctly', () => { + if (process.versions.bun) { + const { getRuntime, isNode, isBun, isDeno } = require('../runtime.js'); + expect(getRuntime()).toBe('bun'); + expect(isNode()).toBe(false); + expect(isBun()).toBe(true); + expect(isDeno()).toBe(false); + } + }); + + it('detects Deno runtime correctly', () => { + if (process.versions.deno) { + const { getRuntime, isNode, isBun, isDeno } = require('../runtime.js'); + expect(getRuntime()).toBe('deno'); + expect(isNode()).toBe(false); + expect(isBun()).toBe(false); + expect(isDeno()).toBe(true); + } + }); + + it('returns valid runtime type', () => { + const { getRuntime } = require('../runtime.js'); + const runtime = getRuntime(); + expect(['node', 'bun', 'deno', 'unknown']).toContain(runtime); + }); + + it('provides consistent predicate functions', () => { + const { getRuntime, isNode, isBun, isDeno } = require('../runtime.js'); + const runtime = getRuntime(); + + // Only one should be true + const trues = [isNode(), isBun(), isDeno()].filter(Boolean); + expect(trues.length).toBeLessThanOrEqual(1); + + // If runtime is not unknown, exactly one should be true + if (runtime !== 'unknown') { + expect(trues.length).toBe(1); + } + }); + + it('handles edge cases gracefully', () => { + const { getRuntime } = require('../runtime.js'); + + // Should not throw + expect(() => getRuntime()).not.toThrow(); + + // Should return string + const runtime = getRuntime(); + expect(typeof runtime).toBe('string'); + }); +}); \ No newline at end of file diff --git a/src/utils/__tests__/runtimeIntegration.test.ts b/src/utils/__tests__/runtimeIntegration.test.ts new file mode 100644 index 00000000..23eb44a8 --- /dev/null +++ b/src/utils/__tests__/runtimeIntegration.test.ts @@ -0,0 +1,55 @@ +import { describe, it, expect } from 'vitest'; + +describe('Runtime Integration Tests', () => { + it('runtime detection is consistent across imports', async () => { + const { getRuntime } = await import('../runtime.js'); + const runtime1 = getRuntime(); + + // Re-import to test caching + const { getRuntime: getRuntime2 } = await import('../runtime.js'); + const runtime2 = getRuntime2(); + + expect(runtime1).toBe(runtime2); + expect(['node', 'bun', 'deno', 'unknown']).toContain(runtime1); + }); + + it('runtime detection works in actual execution environment', async () => { + const { getRuntime, isNode, isBun, isDeno } = await import('../runtime.js'); + + const runtime = getRuntime(); + + if (process.versions.node && !process.versions.bun && !process.versions.deno) { + expect(runtime).toBe('node'); + expect(isNode()).toBe(true); + expect(isBun()).toBe(false); + expect(isDeno()).toBe(false); + } else if (process.versions.bun) { + expect(runtime).toBe('bun'); + expect(isNode()).toBe(false); + expect(isBun()).toBe(true); + expect(isDeno()).toBe(false); + } else if (process.versions.deno) { + expect(runtime).toBe('deno'); + expect(isNode()).toBe(false); + expect(isBun()).toBe(false); + expect(isDeno()).toBe(true); + } + }); + + it('runtime utilities can be imported correctly', async () => { + const runtimeModule = await import('../runtime.js'); + + // Check that all expected exports are available + expect(typeof runtimeModule.getRuntime).toBe('function'); + expect(typeof runtimeModule.isBun).toBe('function'); + expect(typeof runtimeModule.isNode).toBe('function'); + expect(typeof runtimeModule.isDeno).toBe('function'); + expect(typeof runtimeModule.getRuntime()).toBe('string'); + }); + + it('provides correct runtime type', async () => { + const { getRuntime } = await import('../runtime.js'); + const runtime = getRuntime(); + expect(['node', 'bun', 'deno', 'unknown']).toContain(runtime); + }); +}); \ No newline at end of file diff --git a/src/utils/runtime.ts b/src/utils/runtime.ts new file mode 100644 index 00000000..018bf85e --- /dev/null +++ b/src/utils/runtime.ts @@ -0,0 +1,53 @@ +/** + * Runtime utilities - minimal, focused, testable + * Single responsibility: detect current JavaScript runtime + */ + +// Type safety with explicit union +export type Runtime = 'node' | 'bun' | 'deno' | 'unknown'; + +// Cache result after first detection (performance optimization) +let cachedRuntime: Runtime | null = null; + +/** + * Detect current runtime with fallback chain + * Most reliable detection first, falling back to less reliable methods + */ +export function getRuntime(): Runtime { + if (cachedRuntime) return cachedRuntime; + + // Method 1: Global runtime objects (most reliable) + if (typeof (globalThis as any).Bun !== 'undefined') { + cachedRuntime = 'bun'; + return cachedRuntime; + } + + if (typeof (globalThis as any).Deno !== 'undefined') { + cachedRuntime = 'deno'; + return cachedRuntime; + } + + // Method 2: Process versions (fallback) + if (process?.versions?.bun) { + cachedRuntime = 'bun'; + return cachedRuntime; + } + + if (process?.versions?.deno) { + cachedRuntime = 'deno'; + return cachedRuntime; + } + + if (process?.versions?.node) { + cachedRuntime = 'node'; + return cachedRuntime; + } + + cachedRuntime = 'unknown'; + return cachedRuntime; +} + +// Convenience predicates - single responsibility each +export const isBun = (): boolean => getRuntime() === 'bun'; +export const isNode = (): boolean => getRuntime() === 'node'; +export const isDeno = (): boolean => getRuntime() === 'deno'; \ No newline at end of file diff --git a/src/utils/spawnHappyCLI.ts b/src/utils/spawnHappyCLI.ts index 1ed7d2d7..560633ff 100644 --- a/src/utils/spawnHappyCLI.ts +++ b/src/utils/spawnHappyCLI.ts @@ -54,6 +54,7 @@ import { join } from 'node:path'; import { projectPath } from '@/projectPath'; import { logger } from '@/ui/logger'; import { existsSync } from 'node:fs'; +import { isBun } from './runtime'; /** * Spawn the Happy CLI with the given arguments in a cross-platform way. @@ -99,5 +100,6 @@ export function spawnHappyCLI(args: string[], options: SpawnOptions = {}): Child throw new Error(errorMessage); } - return spawn('node', nodeArgs, options); + const runtime = isBun() ? 'bun' : 'node'; + return spawn(runtime, nodeArgs, options); }