diff --git a/src/main/utils/agent-install.ts b/src/main/utils/agent-install.ts index cd39a6a34..19b148e33 100644 --- a/src/main/utils/agent-install.ts +++ b/src/main/utils/agent-install.ts @@ -4,10 +4,12 @@ */ import { spawn } from 'child_process' import { platform } from 'os' +import * as fs from 'fs' import type { BrowserWindow } from 'electron' import { IPC_CHANNELS } from '../../shared/ipc-channels' import type { InstallStep, InstallResult } from '../../shared/electron-api' import { commandExists } from './agent-check' +import { getEnhancedPath, getNpmUserPrefix } from './path' interface InstallOptions { window: BrowserWindow @@ -31,8 +33,8 @@ function spawnWithProgress( useShell: boolean = false ): Promise<{ success: boolean; error?: string }> { return new Promise((resolve) => { - // Ensure common paths are in PATH for npm, node, etc. - const enhancedPath = `/usr/local/bin:/opt/homebrew/bin:${process.env.PATH || ''}` + // Use enhanced PATH that includes user-level npm global directory + const enhancedPath = getEnhancedPath() console.log(`[agent-install] Spawning: ${command} ${args.join(' ')} (shell: ${useShell})`) @@ -109,14 +111,19 @@ async function installClaudeCLI( } /** - * Install claude-code-acp via npm + * Install claude-code-acp via npm to user-level directory */ async function installClaudeCodeACP( onProgress: (message: string) => void ): Promise<{ success: boolean; error?: string }> { + const prefix = getNpmUserPrefix() + + // Ensure the user-level npm global directory exists + await fs.promises.mkdir(prefix, { recursive: true }).catch(() => {}) + return spawnWithProgress( 'npm', - ['install', '-g', '@zed-industries/claude-code-acp'], + ['install', '-g', '--prefix', prefix, '@zed-industries/claude-code-acp'], onProgress, true ) @@ -217,21 +224,41 @@ async function installClaudeCode(options: InstallOptions): Promise void ): Promise<{ success: boolean; error?: string }> { - return spawnWithProgress('npm', ['install', '-g', '@openai/codex'], onProgress, true) + const prefix = getNpmUserPrefix() + + // Ensure the user-level npm global directory exists + await fs.promises.mkdir(prefix, { recursive: true }).catch(() => {}) + + return spawnWithProgress( + 'npm', + ['install', '-g', '--prefix', prefix, '@openai/codex'], + onProgress, + true + ) } /** - * Install codex-acp via npm + * Install codex-acp via npm to user-level directory */ async function installCodexACP( onProgress: (message: string) => void ): Promise<{ success: boolean; error?: string }> { - return spawnWithProgress('npm', ['install', '-g', '@zed-industries/codex-acp'], onProgress, true) + const prefix = getNpmUserPrefix() + + // Ensure the user-level npm global directory exists + await fs.promises.mkdir(prefix, { recursive: true }).catch(() => {}) + + return spawnWithProgress( + 'npm', + ['install', '-g', '--prefix', prefix, '@zed-industries/codex-acp'], + onProgress, + true + ) } /** @@ -360,9 +387,9 @@ export async function installAgent(options: InstallOptions): Promise ({ + spawn: vi.fn() +})) + +// Mock fs +vi.mock('fs', () => ({ + promises: { + mkdir: vi.fn().mockResolvedValue(undefined) + } +})) + +// Mock os module +vi.mock('os', () => ({ + platform: vi.fn().mockReturnValue('darwin') +})) + +// Mock path utilities +vi.mock('../../../../src/main/utils/path', () => ({ + getEnhancedPath: vi + .fn() + .mockReturnValue( + '/Users/test/.npm-global/bin:/Users/test/.opencode/bin:/usr/local/bin:/usr/bin:/bin' + ), + getNpmUserPrefix: vi.fn().mockReturnValue('/Users/test/.npm-global') +})) + +// Mock agent-check +vi.mock('../../../../src/main/utils/agent-check', () => ({ + commandExists: vi.fn().mockResolvedValue({ exists: false }) +})) + +import { formatInstallError } from '../../../../src/main/utils/agent-install' +import { getNpmUserPrefix, getEnhancedPath } from '../../../../src/main/utils/path' + +describe('agent-install', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('getNpmUserPrefix', () => { + it('should return user-level npm global directory path', () => { + const prefix = getNpmUserPrefix() + expect(prefix).toBe('/Users/test/.npm-global') + }) + }) + + describe('getEnhancedPath', () => { + it('should include npm-global/bin in PATH', () => { + const path = getEnhancedPath() + expect(path).toContain('.npm-global/bin') + }) + }) + + describe('formatInstallError', () => { + it('should format EACCES permission errors', () => { + const error = 'npm ERR! code EACCES\nnpm ERR! syscall mkdir' + const result = formatInstallError(error) + expect(result).toBe('Permission denied. Please run: chmod -R u+w ~/.npm-global') + }) + + it('should format permission denied errors', () => { + const error = 'Error: EACCES: permission denied, mkdir /some/path' + const result = formatInstallError(error) + expect(result).toBe('Permission denied. Please run: chmod -R u+w ~/.npm-global') + }) + + it('should format network errors with ENOTFOUND', () => { + const error = 'npm ERR! code ENOTFOUND\nnpm ERR! network' + const result = formatInstallError(error) + expect(result).toBe('Network error. Please check your internet connection and try again.') + }) + + it('should format network errors with host resolution', () => { + const error = 'Could not resolve host: registry.npmjs.org' + const result = formatInstallError(error) + expect(result).toBe('Network error. Please check your internet connection and try again.') + }) + + it('should format command not found errors', () => { + const error = 'spawn npm ENOENT' + const result = formatInstallError(error) + expect(result).toBe('Command not found. Please ensure required tools are installed.') + }) + + it('should pass through unknown errors unchanged', () => { + const error = 'Some unknown error message' + const result = formatInstallError(error) + expect(result).toBe('Some unknown error message') + }) + }) +})