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:
yushen
2026-01-16 11:54:19 +08:00
parent a53dfac07f
commit 7d37bbc172
3 changed files with 144 additions and 10 deletions

View File

@@ -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.'

View File

@@ -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`
}

View 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')
})
})
})