diff --git a/apps/frontend/src/main/__tests__/cli-tool-manager.test.ts b/apps/frontend/src/main/__tests__/cli-tool-manager.test.ts new file mode 100644 index 00000000..45bfad47 --- /dev/null +++ b/apps/frontend/src/main/__tests__/cli-tool-manager.test.ts @@ -0,0 +1,314 @@ +/** + * Unit tests for cli-tool-manager + * Tests CLI tool detection with focus on NVM path detection + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { existsSync, readdirSync } from 'fs'; +import os from 'os'; +import { execFileSync } from 'child_process'; +import { app } from 'electron'; +import { getToolInfo, clearToolCache } from '../cli-tool-manager'; + +// Mock Electron app +vi.mock('electron', () => ({ + app: { + isPackaged: false, + getPath: vi.fn() + } +})); + +// Mock os module +vi.mock('os', () => ({ + default: { + homedir: vi.fn(() => '/mock/home') + } +})); + +// Mock fs module - need to mock both sync and promises +vi.mock('fs', () => { + const mockDirent = ( + name: string, + isDir: boolean + ): { name: string; isDirectory: () => boolean } => ({ + name, + isDirectory: () => isDir + }); + + return { + existsSync: vi.fn(), + readdirSync: vi.fn(), + promises: {} + }; +}); + +// Mock child_process for execFileSync (used in validation) +vi.mock('child_process', () => ({ + execFileSync: vi.fn() +})); + +// Mock env-utils to avoid PATH augmentation complexity +vi.mock('../env-utils', () => ({ + findExecutable: vi.fn(() => null), // Return null to force platform-specific path checking + getAugmentedEnv: vi.fn(() => ({ PATH: '' })) +})); + +// Mock homebrew-python utility +vi.mock('../utils/homebrew-python', () => ({ + findHomebrewPython: vi.fn(() => null) +})); + +describe('cli-tool-manager - Claude CLI NVM detection', () => { + beforeEach(() => { + vi.clearAllMocks(); + // Set default platform to Linux + Object.defineProperty(process, 'platform', { + value: 'linux', + writable: true + }); + }); + + afterEach(() => { + clearToolCache(); + }); + + const mockHomeDir = '/mock/home'; + + describe('NVM path detection on Unix/Linux/macOS', () => { + it('should detect Claude CLI in NVM directory when multiple Node versions exist', () => { + // Mock home directory + vi.mocked(os.homedir).mockReturnValue(mockHomeDir); + + // Mock NVM directory exists + vi.mocked(existsSync).mockImplementation((filePath) => { + const pathStr = String(filePath); + // NVM versions directory exists + if (pathStr.includes('.nvm/versions/node')) { + return true; + } + // Claude CLI exists in v22.17.0 + if (pathStr.includes('v22.17.0/bin/claude')) { + return true; + } + return false; + }); + + // Mock readdirSync to return Node version directories + vi.mocked(readdirSync).mockImplementation((filePath, options) => { + const pathStr = String(filePath); + if (pathStr.includes('.nvm/versions/node')) { + return [ + { name: 'v20.11.0', isDirectory: () => true }, + { name: 'v22.17.0', isDirectory: () => true } + ] as any; + } + return [] as any; + }); + + // Mock execFileSync to return version for validation + vi.mocked(execFileSync).mockReturnValue('claude-code version 1.0.0\n'); + + const result = getToolInfo('claude'); + + expect(result.found).toBe(true); + expect(result.path).toContain('v22.17.0'); + expect(result.path).toContain('bin/claude'); + expect(result.source).toBe('nvm'); + }); + + it('should try multiple NVM Node versions until finding Claude CLI', () => { + vi.mocked(os.homedir).mockReturnValue(mockHomeDir); + + vi.mocked(existsSync).mockImplementation((filePath) => { + const pathStr = String(filePath); + if (pathStr.includes('.nvm/versions/node')) { + return true; + } + // Only v24.12.0 has Claude CLI + if (pathStr.includes('v24.12.0/bin/claude')) { + return true; + } + return false; + }); + + vi.mocked(readdirSync).mockImplementation((filePath) => { + const pathStr = String(filePath); + if (pathStr.includes('.nvm/versions/node')) { + return [ + { name: 'v18.20.0', isDirectory: () => true }, + { name: 'v20.11.0', isDirectory: () => true }, + { name: 'v24.12.0', isDirectory: () => true } + ] as any; + } + return [] as any; + }); + + vi.mocked(execFileSync).mockReturnValue('claude-code version 1.0.0\n'); + + const result = getToolInfo('claude'); + + expect(result.found).toBe(true); + expect(result.path).toContain('v24.12.0'); + expect(result.source).toBe('nvm'); + }); + + it('should skip non-version directories in NVM (e.g., does not start with "v")', () => { + vi.mocked(os.homedir).mockReturnValue(mockHomeDir); + + vi.mocked(existsSync).mockImplementation((filePath) => { + const pathStr = String(filePath); + if (pathStr.includes('.nvm/versions/node')) { + return true; + } + // Only the correctly named version has Claude + if (pathStr.includes('v22.17.0/bin/claude')) { + return true; + } + return false; + }); + + vi.mocked(readdirSync).mockImplementation((filePath) => { + const pathStr = String(filePath); + if (pathStr.includes('.nvm/versions/node')) { + return [ + { name: 'current', isDirectory: () => true }, // Should be skipped + { name: 'system', isDirectory: () => true }, // Should be skipped + { name: 'v22.17.0', isDirectory: () => true } // Should be checked + ] as any; + } + return [] as any; + }); + + vi.mocked(execFileSync).mockReturnValue('claude-code version 1.0.0\n'); + + const result = getToolInfo('claude'); + + expect(result.found).toBe(true); + expect(result.path).toContain('v22.17.0'); + }); + + it('should not check NVM paths on Windows', () => { + Object.defineProperty(process, 'platform', { + value: 'win32', + writable: true + }); + + vi.mocked(os.homedir).mockReturnValue('C:\\Users\\test'); + + // Even if NVM directory exists on Windows, should not check it + vi.mocked(existsSync).mockReturnValue(false); + vi.mocked(readdirSync).mockReturnValue([]); + + const result = getToolInfo('claude'); + + // Should not be found from NVM on Windows + expect(result.source).not.toBe('nvm'); + }); + + it('should handle missing NVM directory gracefully', () => { + vi.mocked(os.homedir).mockReturnValue(mockHomeDir); + + // NVM directory does not exist + vi.mocked(existsSync).mockReturnValue(false); + + const result = getToolInfo('claude'); + + // Should not find via NVM + expect(result.source).not.toBe('nvm'); + expect(result.found).toBe(false); + }); + + it('should handle readdirSync errors gracefully', () => { + vi.mocked(os.homedir).mockReturnValue(mockHomeDir); + + vi.mocked(existsSync).mockReturnValue(true); + vi.mocked(readdirSync).mockImplementation(() => { + throw new Error('Permission denied'); + }); + + const result = getToolInfo('claude'); + + // Should not crash, should fall back to other detection methods + expect(result.source).not.toBe('nvm'); + }); + + it('should validate Claude CLI before returning NVM path', () => { + vi.mocked(os.homedir).mockReturnValue(mockHomeDir); + + vi.mocked(existsSync).mockImplementation((filePath) => { + const pathStr = String(filePath); + if (pathStr.includes('.nvm/versions/node')) { + return true; + } + if (pathStr.includes('v22.17.0/bin/claude')) { + return true; + } + return false; + }); + + vi.mocked(readdirSync).mockImplementation(() => { + return [{ name: 'v22.17.0', isDirectory: () => true }] as any; + }); + + // Mock validation failure (execFileSync throws) + vi.mocked(execFileSync).mockImplementation(() => { + throw new Error('Command failed'); + }); + + const result = getToolInfo('claude'); + + // Should not return unvalidated path + expect(result.found).toBe(false); + expect(result.source).not.toBe('nvm'); + }); + + it('should handle NVM directory with no version subdirectories', () => { + vi.mocked(os.homedir).mockReturnValue(mockHomeDir); + + vi.mocked(existsSync).mockImplementation((filePath) => { + return String(filePath).includes('.nvm/versions/node'); + }); + + // Empty NVM directory + vi.mocked(readdirSync).mockReturnValue([]); + + const result = getToolInfo('claude'); + + expect(result.source).not.toBe('nvm'); + }); + }); + + describe('NVM on macOS', () => { + it('should detect Claude CLI via NVM on macOS', () => { + Object.defineProperty(process, 'platform', { + value: 'darwin', + writable: true + }); + + vi.mocked(os.homedir).mockReturnValue('/Users/test'); + + vi.mocked(existsSync).mockImplementation((filePath) => { + const pathStr = String(filePath); + if (pathStr.includes('.nvm/versions/node')) { + return true; + } + if (pathStr.includes('v22.17.0/bin/claude')) { + return true; + } + return false; + }); + + vi.mocked(readdirSync).mockImplementation(() => { + return [{ name: 'v22.17.0', isDirectory: () => true }] as any; + }); + + vi.mocked(execFileSync).mockReturnValue('claude-code version 1.0.0\n'); + + const result = getToolInfo('claude'); + + expect(result.found).toBe(true); + expect(result.source).toBe('nvm'); + expect(result.path).toContain('v22.17.0'); + }); + }); +}); diff --git a/apps/frontend/src/main/cli-tool-manager.ts b/apps/frontend/src/main/cli-tool-manager.ts index a92bbc96..d2f715b8 100644 --- a/apps/frontend/src/main/cli-tool-manager.ts +++ b/apps/frontend/src/main/cli-tool-manager.ts @@ -21,7 +21,7 @@ */ import { execFileSync } from 'child_process'; -import { existsSync } from 'fs'; +import { existsSync, readdirSync } from 'fs'; import path from 'path'; import os from 'os'; import { app } from 'electron'; @@ -594,6 +594,50 @@ class CLIToolManager { path.join(homeDir, 'bin', 'claude'), ]; + // 4.5. NVM (Node Version Manager) paths for Unix/Linux/macOS + // NVM installs global npm packages in ~/.nvm/versions/node/vX.X.X/bin/ + // This is important when the app launches from GUI without NVM sourced + if (process.platform !== 'win32') { + const nvmVersionsDir = path.join(homeDir, '.nvm', 'versions', 'node'); + try { + if (existsSync(nvmVersionsDir)) { + const nodeVersions = readdirSync(nvmVersionsDir, { withFileTypes: true }); + const versionDirs = nodeVersions + .filter((entry) => entry.isDirectory() && entry.name.startsWith('v')) + .sort((a, b) => { + const vA = a.name.slice(1).split('.').map(Number); + const vB = b.name.slice(1).split('.').map(Number); + for (let i = 0; i < 3; i++) { + const diff = (vB[i] ?? 0) - (vA[i] ?? 0); + if (diff !== 0) { + return diff; + } + } + return 0; + }); + + for (const entry of versionDirs) { + const nvmClaudePath = path.join(nvmVersionsDir, entry.name, 'bin', 'claude'); + if (existsSync(nvmClaudePath)) { + const validation = this.validateClaude(nvmClaudePath); + if (validation.valid) { + return { + found: true, + path: nvmClaudePath, + version: validation.version, + source: 'nvm', + message: `Using NVM Claude CLI: ${nvmClaudePath}`, + }; + } + } + } + } + } catch (error) { + // Silently fail if unable to read NVM directory + console.warn(`[Claude CLI] Unable to read NVM directory: ${error}`); + } + } + for (const claudePath of platformPaths) { if (existsSync(claudePath)) { const validation = this.validateClaude(claudePath); diff --git a/apps/frontend/src/renderer/components/settings/GeneralSettings.tsx b/apps/frontend/src/renderer/components/settings/GeneralSettings.tsx index 134700bd..b03e1b8f 100644 --- a/apps/frontend/src/renderer/components/settings/GeneralSettings.tsx +++ b/apps/frontend/src/renderer/components/settings/GeneralSettings.tsx @@ -59,6 +59,7 @@ function ToolDetectionDisplay({ info, isLoading, t }: ToolDetectionDisplayProps) 'user-config': t('general.sourceUserConfig'), 'venv': t('general.sourceVenv'), 'homebrew': t('general.sourceHomebrew'), + 'nvm': t('general.sourceNvm'), 'system-path': t('general.sourceSystemPath'), 'bundled': t('general.sourceBundled'), 'fallback': t('general.sourceFallback'), diff --git a/apps/frontend/src/shared/i18n/locales/en/settings.json b/apps/frontend/src/shared/i18n/locales/en/settings.json index d8293ba8..c4e28939 100644 --- a/apps/frontend/src/shared/i18n/locales/en/settings.json +++ b/apps/frontend/src/shared/i18n/locales/en/settings.json @@ -217,6 +217,7 @@ "sourceUserConfig": "User Configuration", "sourceVenv": "Virtual Environment", "sourceHomebrew": "Homebrew", + "sourceNvm": "NVM", "sourceSystemPath": "System PATH", "sourceBundled": "Bundled", "sourceFallback": "Fallback", diff --git a/apps/frontend/src/shared/i18n/locales/fr/settings.json b/apps/frontend/src/shared/i18n/locales/fr/settings.json index 03be3fae..93ca5620 100644 --- a/apps/frontend/src/shared/i18n/locales/fr/settings.json +++ b/apps/frontend/src/shared/i18n/locales/fr/settings.json @@ -217,6 +217,7 @@ "sourceUserConfig": "Configuration utilisateur", "sourceVenv": "Environnement virtuel", "sourceHomebrew": "Homebrew", + "sourceNvm": "NVM", "sourceSystemPath": "PATH système", "sourceBundled": "Intégré", "sourceFallback": "Valeur par défaut", diff --git a/apps/frontend/src/shared/types/cli.ts b/apps/frontend/src/shared/types/cli.ts index 92f76452..a104cda3 100644 --- a/apps/frontend/src/shared/types/cli.ts +++ b/apps/frontend/src/shared/types/cli.ts @@ -17,6 +17,7 @@ export interface ToolDetectionResult { | 'user-config' | 'venv' | 'homebrew' + | 'nvm' | 'system-path' | 'bundled' | 'fallback';