diff --git a/electron/main.ts b/electron/main.ts index 1165a590..76ededff 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -484,27 +484,54 @@ function findGitBashSync(): boolean { */ function getExpandedShellPath(): string { const home = os.homedir(); - const shellPath = userShellEnv.PATH || process.env.PATH || ''; + const shellEnv = { ...process.env, ...userShellEnv }; + const shellPath = shellEnv.PATH || process.env.PATH || ''; const sep = path.delimiter; - - if (process.platform === 'win32') { - const appData = process.env.APPDATA || path.join(home, 'AppData', 'Roaming'); - const localAppData = process.env.LOCALAPPDATA || path.join(home, 'AppData', 'Local'); - const winExtra = [ - path.join(appData, 'npm'), - path.join(localAppData, 'npm'), - path.join(home, '.npm-global', 'bin'), - path.join(home, '.local', 'bin'), - path.join(home, '.claude', 'bin'), - ]; - const allParts = [shellPath, ...winExtra].join(sep).split(sep).filter(Boolean); - return [...new Set(allParts)].join(sep); - } else { - const basePath = `/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin`; - const raw = `${basePath}:${home}/.npm-global/bin:${home}/.local/bin:${home}/.claude/bin:${shellPath}`; - const allParts = raw.split(':').filter(Boolean); - return [...new Set(allParts)].join(':'); - } + const pnpmHome = shellEnv.PNPM_HOME; + const voltaHome = shellEnv.VOLTA_HOME ? path.join(shellEnv.VOLTA_HOME, 'bin') : ''; + + const extraDirs = process.platform === 'win32' + ? [ + path.join(home, '.local', 'bin'), + path.join(home, '.claude', 'bin'), + path.join(home, '.claude', 'local'), + path.join(home, '.bun', 'bin'), + path.join(home, '.yarn', 'bin'), + path.join(home, '.config', 'yarn', 'global', 'node_modules', '.bin'), + voltaHome, + path.join(home, '.fnm', 'current', 'bin'), + path.join(home, '.nvm', 'current', 'bin'), + path.join(home, '.asdf', 'shims'), + path.join(shellEnv.APPDATA || path.join(home, 'AppData', 'Roaming'), 'npm'), + path.join(shellEnv.LOCALAPPDATA || path.join(home, 'AppData', 'Local'), 'npm'), + path.join(shellEnv.APPDATA || path.join(home, 'AppData', 'Roaming'), 'pnpm'), + path.join(shellEnv.LOCALAPPDATA || path.join(home, 'AppData', 'Local'), 'pnpm'), + pnpmHome, + path.join(home, '.npm-global', 'bin'), + ] + : [ + path.join(home, '.local', 'bin'), + path.join(home, '.claude', 'bin'), + path.join(home, '.claude', 'local'), + path.join(home, '.bun', 'bin'), + '/opt/homebrew/bin', + '/usr/local/bin', + '/usr/bin', + '/bin', + path.join(home, '.npm-global', 'bin'), + path.join(home, '.yarn', 'bin'), + path.join(home, '.config', 'yarn', 'global', 'node_modules', '.bin'), + path.join(home, '.volta', 'bin'), + path.join(home, '.fnm', 'current', 'bin'), + path.join(home, '.nvm', 'current', 'bin'), + path.join(home, '.asdf', 'shims'), + pnpmHome, + path.join(home, '.local', 'share', 'pnpm'), + ...(process.platform === 'darwin' ? [path.join(home, 'Library', 'pnpm')] : []), + ]; + + const allParts = [shellPath, ...extraDirs].join(sep).split(sep).filter(Boolean); + return [...new Set(allParts)].join(sep); } function getPort(): Promise { @@ -770,34 +797,70 @@ app.whenReady().then(async () => { ipcMain.handle('install:check-prerequisites', async () => { const expandedPath = getExpandedShellPath(); const execEnv = { ...sanitizedProcessEnv(), ...userShellEnv, PATH: expandedPath }; + const shellEnv = { ...process.env, ...userShellEnv }; // Candidate paths — native first, then bun, then homebrew, then npm const home = os.homedir(); - const candidatePaths = process.platform === 'win32' + const claudeSearchDirs = process.platform === 'win32' ? [ - path.join(home, '.local', 'bin', 'claude.exe'), - path.join(home, '.local', 'bin', 'claude.cmd'), - path.join(home, '.claude', 'bin', 'claude.exe'), - path.join(home, '.claude', 'bin', 'claude.cmd'), - path.join(home, '.bun', 'bin', 'claude.exe'), - path.join(home, '.bun', 'bin', 'claude.cmd'), - path.join(process.env.APPDATA || '', 'npm', 'claude.cmd'), - path.join(process.env.LOCALAPPDATA || '', 'npm', 'claude.cmd'), - ].filter(p => p && !p.startsWith(path.sep)) + path.join(home, '.local', 'bin'), + path.join(home, '.claude', 'bin'), + path.join(home, '.claude', 'local'), + path.join(home, '.bun', 'bin'), + path.join(home, '.yarn', 'bin'), + path.join(home, '.config', 'yarn', 'global', 'node_modules', '.bin'), + shellEnv.VOLTA_HOME ? path.join(shellEnv.VOLTA_HOME, 'bin') : '', + path.join(home, '.fnm', 'current', 'bin'), + path.join(home, '.nvm', 'current', 'bin'), + path.join(home, '.asdf', 'shims'), + path.join(shellEnv.APPDATA || path.join(home, 'AppData', 'Roaming'), 'npm'), + path.join(shellEnv.LOCALAPPDATA || path.join(home, 'AppData', 'Local'), 'npm'), + path.join(shellEnv.APPDATA || path.join(home, 'AppData', 'Roaming'), 'pnpm'), + path.join(shellEnv.LOCALAPPDATA || path.join(home, 'AppData', 'Local'), 'pnpm'), + shellEnv.PNPM_HOME || '', + path.join(home, '.npm-global', 'bin'), + ] : [ - path.join(home, '.local', 'bin', 'claude'), - path.join(home, '.claude', 'bin', 'claude'), - path.join(home, '.bun', 'bin', 'claude'), - '/opt/homebrew/bin/claude', - '/usr/local/bin/claude', - path.join(home, '.npm-global', 'bin', 'claude'), + path.join(home, '.local', 'bin'), + path.join(home, '.claude', 'bin'), + path.join(home, '.claude', 'local'), + path.join(home, '.bun', 'bin'), + '/opt/homebrew/bin', + '/usr/local/bin', + path.join(home, '.npm-global', 'bin'), + path.join(home, '.yarn', 'bin'), + path.join(home, '.config', 'yarn', 'global', 'node_modules', '.bin'), + path.join(home, '.volta', 'bin'), + path.join(home, '.fnm', 'current', 'bin'), + path.join(home, '.nvm', 'current', 'bin'), + path.join(home, '.asdf', 'shims'), + shellEnv.PNPM_HOME || '', + path.join(home, '.local', 'share', 'pnpm'), + ...(process.platform === 'darwin' ? [path.join(home, 'Library', 'pnpm')] : []), ]; + const candidatePaths = process.platform === 'win32' + ? [...new Set(claudeSearchDirs.filter(Boolean).flatMap(dir => [ + path.join(dir, 'claude.exe'), + path.join(dir, 'claude.cmd'), + path.join(dir, 'claude.bat'), + path.join(dir, 'claude'), + ]))] + : [...new Set(claudeSearchDirs.filter(Boolean).map(dir => path.join(dir, 'claude')))]; + function classifyPath(p: string): 'native' | 'homebrew' | 'npm' | 'bun' | 'unknown' { const n = p.replace(/\\/g, '/'); - if (n.includes('/.local/bin/') || n.includes('/.claude/bin/')) return 'native'; + if (n.includes('/.local/bin/') || n.includes('/.claude/bin/') || n.includes('/.claude/local/')) return 'native'; if (n.includes('/.bun/bin/') || n.includes('/.bun/install/')) return 'bun'; if (n.includes('/homebrew/') || n.includes('/Cellar/')) return 'homebrew'; + if (n.includes('/Library/pnpm/') + || n.includes('/.local/share/pnpm/') + || n.includes('/.config/yarn/') + || n.includes('/.yarn/bin/') + || n.includes('/.volta/bin/') + || n.includes('/.fnm/current/bin/') + || n.includes('/.asdf/shims/') + || n.includes('/.nvm/current/bin/')) return 'npm'; if (n.includes('/npm')) return 'npm'; if (n === '/usr/local/bin/claude') { try { @@ -808,6 +871,14 @@ app.whenReady().then(async () => { } catch { /* ignore */ } return 'unknown'; } + if (process.platform === 'win32') { + const appData = (shellEnv.APPDATA || '').replace(/\\/g, '/'); + const localAppData = (shellEnv.LOCALAPPDATA || '').replace(/\\/g, '/'); + if (appData && n.startsWith(appData + '/npm')) return 'npm'; + if (localAppData && n.startsWith(localAppData + '/npm')) return 'npm'; + if (appData && n.startsWith(appData + '/pnpm')) return 'npm'; + if (localAppData && n.startsWith(localAppData + '/pnpm')) return 'npm'; + } return 'unknown'; } diff --git a/src/__tests__/unit/platform-claude-paths.test.ts b/src/__tests__/unit/platform-claude-paths.test.ts new file mode 100644 index 00000000..7a17c394 --- /dev/null +++ b/src/__tests__/unit/platform-claude-paths.test.ts @@ -0,0 +1,64 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; + +import { getClaudeCandidatePathsFor, getExtraPathDirsFor } from '../../lib/platform'; + +describe('Claude path discovery helpers', () => { + it('darwin search dirs include pnpm, yarn, and claude local compatibility paths', () => { + const home = '/Users/tester'; + const dirs = getExtraPathDirsFor('darwin', home, { + PNPM_HOME: '/Users/tester/Library/pnpm', + } as unknown as NodeJS.ProcessEnv); + + assert.ok(dirs.includes('/Users/tester/Library/pnpm')); + assert.ok(dirs.includes('/Users/tester/.claude/local')); + assert.ok(dirs.includes('/Users/tester/.yarn/bin')); + }); + + it('darwin candidates include executable paths for pnpm and claude local wrappers', () => { + const home = '/Users/tester'; + const candidates = getClaudeCandidatePathsFor('darwin', home, { + PNPM_HOME: '/Users/tester/Library/pnpm', + } as unknown as NodeJS.ProcessEnv); + + assert.ok(candidates.includes('/Users/tester/Library/pnpm/claude')); + assert.ok(candidates.includes('/Users/tester/.claude/local/claude')); + assert.ok( + candidates.indexOf('/opt/homebrew/bin/claude') < candidates.indexOf('/Users/tester/Library/pnpm/claude'), + 'homebrew candidates should stay ahead of npm-family shims', + ); + assert.ok( + candidates.indexOf('/Users/tester/.claude/local/claude') < candidates.indexOf('/Users/tester/Library/pnpm/claude'), + 'native compatibility wrappers should stay ahead of pnpm shims', + ); + }); + + it('win32 search dirs include pnpm and claude local compatibility paths', () => { + const home = 'C:\\Users\\tester'; + const dirs = getExtraPathDirsFor('win32', home, { + APPDATA: 'C:\\Users\\tester\\AppData\\Roaming', + LOCALAPPDATA: 'C:\\Users\\tester\\AppData\\Local', + PNPM_HOME: 'C:\\Users\\tester\\AppData\\Local\\pnpm', + } as unknown as NodeJS.ProcessEnv); + + assert.ok(dirs.includes('C:\\Users\\tester\\AppData\\Local\\pnpm')); + assert.ok(dirs.includes('C:\\Users\\tester\\.claude\\local')); + }); + + it('win32 candidates include pnpm cmd wrappers', () => { + const home = 'C:\\Users\\tester'; + const candidates = getClaudeCandidatePathsFor('win32', home, { + APPDATA: 'C:\\Users\\tester\\AppData\\Roaming', + LOCALAPPDATA: 'C:\\Users\\tester\\AppData\\Local', + PNPM_HOME: 'C:\\Users\\tester\\AppData\\Local\\pnpm', + } as unknown as NodeJS.ProcessEnv); + + assert.ok(candidates.includes('C:\\Users\\tester\\AppData\\Local\\pnpm\\claude.cmd')); + assert.ok(candidates.includes('C:\\Users\\tester\\.claude\\local\\claude.cmd')); + assert.ok( + candidates.indexOf('C:\\Users\\tester\\.claude\\local\\claude.cmd') < + candidates.indexOf('C:\\Users\\tester\\AppData\\Local\\pnpm\\claude.cmd'), + 'native compatibility wrappers should stay ahead of pnpm shims on Windows', + ); + }); +}); diff --git a/src/lib/platform.ts b/src/lib/platform.ts index 416d52c4..d674946b 100644 --- a/src/lib/platform.ts +++ b/src/lib/platform.ts @@ -62,31 +62,92 @@ export function getRuntimeArchitectureInfo(): RuntimeArchitectureInfo { * Extra PATH directories to search for Claude CLI and other tools. */ export function getExtraPathDirs(): string[] { - const home = os.homedir(); - if (isWindows) { - const appData = process.env.APPDATA || path.join(home, 'AppData', 'Roaming'); - const localAppData = process.env.LOCALAPPDATA || path.join(home, 'AppData', 'Local'); - return [ - path.join(home, '.local', 'bin'), - path.join(home, '.claude', 'bin'), - path.join(home, '.bun', 'bin'), - path.join(appData, 'npm'), - path.join(localAppData, 'npm'), - path.join(home, '.npm-global', 'bin'), - path.join(home, '.nvm', 'current', 'bin'), - ]; + return getExtraPathDirsFor(process.platform, os.homedir(), process.env); +} + +function getPathApiForPlatform(platform: NodeJS.Platform): typeof path.posix | typeof path.win32 { + return platform === 'win32' ? path.win32 : path.posix; +} + +export function getClaudeSearchDirsFor( + platform: NodeJS.Platform, + home: string, + env: NodeJS.ProcessEnv = process.env, +): string[] { + const pathApi = getPathApiForPlatform(platform); + const pnpmHome = env.PNPM_HOME; + const voltaHome = env.VOLTA_HOME ? pathApi.join(env.VOLTA_HOME, 'bin') : undefined; + + const dirs = platform === 'win32' + ? [ + pathApi.join(home, '.local', 'bin'), + pathApi.join(home, '.claude', 'bin'), + pathApi.join(home, '.claude', 'local'), + pathApi.join(home, '.bun', 'bin'), + pathApi.join(home, '.yarn', 'bin'), + pathApi.join(home, '.config', 'yarn', 'global', 'node_modules', '.bin'), + voltaHome, + pathApi.join(home, '.fnm', 'current', 'bin'), + pathApi.join(home, '.nvm', 'current', 'bin'), + pathApi.join(home, '.asdf', 'shims'), + pathApi.join(env.APPDATA || pathApi.join(home, 'AppData', 'Roaming'), 'npm'), + pathApi.join(env.LOCALAPPDATA || pathApi.join(home, 'AppData', 'Local'), 'npm'), + pathApi.join(env.APPDATA || pathApi.join(home, 'AppData', 'Roaming'), 'pnpm'), + pathApi.join(env.LOCALAPPDATA || pathApi.join(home, 'AppData', 'Local'), 'pnpm'), + pnpmHome, + pathApi.join(home, '.npm-global', 'bin'), + ] + : [ + pathApi.join(home, '.local', 'bin'), + pathApi.join(home, '.claude', 'bin'), + pathApi.join(home, '.claude', 'local'), + pathApi.join(home, '.bun', 'bin'), + '/opt/homebrew/bin', + '/usr/local/bin', + '/usr/bin', + '/bin', + pathApi.join(home, '.npm-global', 'bin'), + pathApi.join(home, '.yarn', 'bin'), + pathApi.join(home, '.config', 'yarn', 'global', 'node_modules', '.bin'), + pathApi.join(home, '.volta', 'bin'), + pathApi.join(home, '.fnm', 'current', 'bin'), + pathApi.join(home, '.nvm', 'current', 'bin'), + pathApi.join(home, '.asdf', 'shims'), + pnpmHome, + pathApi.join(home, '.local', 'share', 'pnpm'), + ...(platform === 'darwin' ? [pathApi.join(home, 'Library', 'pnpm')] : []), + ]; + + return [...new Set(dirs.filter((dir): dir is string => !!dir))]; +} + +export function getExtraPathDirsFor( + platform: NodeJS.Platform, + home: string, + env: NodeJS.ProcessEnv = process.env, +): string[] { + return getClaudeSearchDirsFor(platform, home, env); +} + +export function getClaudeCandidatePathsFor( + platform: NodeJS.Platform, + home: string, + env: NodeJS.ProcessEnv = process.env, +): string[] { + const pathApi = getPathApiForPlatform(platform); + const dirs = getClaudeSearchDirsFor(platform, home, env); + if (platform === 'win32') { + const exts = ['.cmd', '.exe', '.bat', '']; + const candidates: string[] = []; + for (const dir of dirs) { + for (const ext of exts) { + candidates.push(pathApi.join(dir, 'claude' + ext)); + } + } + return [...new Set(candidates)]; } - return [ - path.join(home, '.local', 'bin'), - path.join(home, '.claude', 'bin'), - path.join(home, '.bun', 'bin'), - '/usr/local/bin', - '/opt/homebrew/bin', - '/usr/bin', - '/bin', - path.join(home, '.npm-global', 'bin'), - path.join(home, '.nvm', 'current', 'bin'), - ]; + + return [...new Set(dirs.map(dir => pathApi.join(dir, 'claude')))]; } /** @@ -100,10 +161,22 @@ export function classifyClaudePath(binPath: string): ClaudeInstallType { // Native installer: ~/.local/bin/claude or ~/.claude/bin/claude if (normalized.includes('/.local/bin/')) return 'native'; if (normalized.includes('/.claude/bin/')) return 'native'; + if (normalized.includes('/.claude/local/')) return 'native'; // Bun: ~/.bun/bin/claude if (normalized.includes('/.bun/bin/') || normalized.includes('/.bun/install/')) return 'bun'; // Homebrew: /opt/homebrew/bin or /usr/local/Cellar or homebrew in path if (normalized.includes('/homebrew/') || normalized.includes('/Cellar/')) return 'homebrew'; + // npm-family: npm/pnpm/yarn/Node manager shims that resolve to the same package channel + if (normalized.includes('/Library/pnpm/') + || normalized.includes('/.local/share/pnpm/') + || normalized.includes('/.config/yarn/') + || normalized.includes('/.yarn/bin/') + || normalized.includes('/.volta/bin/') + || normalized.includes('/.fnm/current/bin/') + || normalized.includes('/.asdf/shims/') + || normalized.includes('/.nvm/current/bin/')) { + return 'npm'; + } // npm: npm-global, .npm, AppData/npm if (normalized.includes('/npm') || normalized.includes('npm-global')) return 'npm'; if (normalized === '/usr/local/bin/claude') { @@ -121,6 +194,8 @@ export function classifyClaudePath(binPath: string): ClaudeInstallType { const localAppData = (process.env.LOCALAPPDATA || '').replace(/\\/g, '/'); if (appData && normalized.startsWith(appData + '/npm')) return 'npm'; if (localAppData && normalized.startsWith(localAppData + '/npm')) return 'npm'; + if (appData && normalized.startsWith(appData + '/pnpm')) return 'npm'; + if (localAppData && normalized.startsWith(localAppData + '/pnpm')) return 'npm'; if (normalized.includes(home.replace(/\\/g, '/') + '/.local/bin')) return 'native'; } return 'unknown'; @@ -131,37 +206,7 @@ export function classifyClaudePath(binPath: string): ClaudeInstallType { * Priority: native install > homebrew > npm (deprecated). */ export function getClaudeCandidatePaths(): string[] { - const home = os.homedir(); - if (isWindows) { - const appData = process.env.APPDATA || path.join(home, 'AppData', 'Roaming'); - const localAppData = process.env.LOCALAPPDATA || path.join(home, 'AppData', 'Local'); - const exts = ['.cmd', '.exe', '.bat', '']; - // Native first, then bun, then npm paths - const baseDirs = [ - path.join(home, '.local', 'bin'), - path.join(home, '.claude', 'bin'), - path.join(home, '.bun', 'bin'), - path.join(appData, 'npm'), - path.join(localAppData, 'npm'), - path.join(home, '.npm-global', 'bin'), - ]; - const candidates: string[] = []; - for (const dir of baseDirs) { - for (const ext of exts) { - candidates.push(path.join(dir, 'claude' + ext)); - } - } - return candidates; - } - // macOS/Linux: native first, then bun, then homebrew, then npm paths - return [ - path.join(home, '.local', 'bin', 'claude'), // native installer - path.join(home, '.claude', 'bin', 'claude'), // native alt - path.join(home, '.bun', 'bin', 'claude'), // bun global - '/opt/homebrew/bin/claude', // homebrew (Apple Silicon) - '/usr/local/bin/claude', // homebrew (Intel) or npm global - path.join(home, '.npm-global', 'bin', 'claude'), // npm custom prefix - ]; + return getClaudeCandidatePathsFor(process.platform, os.homedir(), process.env); } export interface ClaudeInstallInfo {