mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-26 12:35:35 +02:00
Fix npm global install permission error by using user-level prefix
- Add ~/.npm-global/bin to enhanced PATH in path.ts - Add getNpmUserPrefix() function for user-level npm directory - Modify installClaudeCodeACP, installCodexCLI, installCodexACP to use --prefix ~/.npm-global for installation without sudo - Update formatInstallError to provide actionable fix for permission errors - Add unit tests for agent-install module This fixes EACCES permission errors when installing agents via npm install -g by redirecting installations to the user's home directory. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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<InstallResult
|
||||
}
|
||||
|
||||
/**
|
||||
* Install Codex CLI via npm
|
||||
* Install Codex CLI via npm to user-level directory
|
||||
*/
|
||||
async function installCodexCLI(
|
||||
onProgress: (message: string) => 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<InstallResu
|
||||
/**
|
||||
* Format installation error messages for user display
|
||||
*/
|
||||
function formatInstallError(error: string): string {
|
||||
export function formatInstallError(error: string): string {
|
||||
if (error.includes('EACCES') || error.includes('permission denied')) {
|
||||
return 'Permission denied. Please check your permissions and try again.'
|
||||
return 'Permission denied. Please run: chmod -R u+w ~/.npm-global'
|
||||
}
|
||||
if (error.includes('Could not resolve host') || error.includes('ENOTFOUND')) {
|
||||
return 'Network error. Please check your internet connection and try again.'
|
||||
|
||||
@@ -10,11 +10,23 @@ import { homedir } from 'node:os'
|
||||
export function getEnhancedPath(): string {
|
||||
const home = homedir()
|
||||
const customPaths = [
|
||||
`${home}/.npm-global/bin`, // User-level npm global directory
|
||||
`${home}/.opencode/bin`,
|
||||
`${home}/.claude/local/bin`,
|
||||
`${home}/.claude/local`,
|
||||
`${home}/.bun/bin`,
|
||||
`${home}/.local/bin`,
|
||||
'/opt/homebrew/bin',
|
||||
'/usr/local/bin'
|
||||
]
|
||||
return `${customPaths.join(':')}:${process.env.PATH || ''}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the user-level npm global prefix directory
|
||||
* Used for installing npm packages without requiring sudo
|
||||
*/
|
||||
export function getNpmUserPrefix(): string {
|
||||
const home = homedir()
|
||||
return `${home}/.npm-global`
|
||||
}
|
||||
|
||||
95
tests/unit/main/utils/agent-install.test.ts
Normal file
95
tests/unit/main/utils/agent-install.test.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { homedir } from 'node:os'
|
||||
|
||||
// Mock child_process
|
||||
vi.mock('child_process', () => ({
|
||||
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')
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user