diff --git a/electron/main.ts b/electron/main.ts index fb3f9682..5800d450 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -5,6 +5,7 @@ import fs from 'fs'; import net from 'net'; import os from 'os'; import { TerminalManager } from './terminal-manager'; +import { buildExpandedShellPath } from './path-utils'; /** * Return a copy of process.env without __NEXT_PRIVATE_* variables. @@ -321,28 +322,7 @@ function loadUserShellEnv(): Record { * claude, nvm, homebrew, etc. Shared by the server launcher and install orchestrator. */ function getExpandedShellPath(): string { - const home = os.homedir(); - const shellPath = userShellEnv.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(':'); - } + return buildExpandedShellPath({ shellPath: userShellEnv.PATH || process.env.PATH || '' }); } function getPort(): Promise { diff --git a/electron/path-utils.ts b/electron/path-utils.ts new file mode 100644 index 00000000..4e23a3e9 --- /dev/null +++ b/electron/path-utils.ts @@ -0,0 +1,48 @@ +import os from 'os'; +import path from 'path'; + +interface BuildExpandedShellPathOptions { + shellPath?: string; + home?: string; + platform?: NodeJS.Platform; +} + +/** + * Build an expanded PATH for child processes. + * + * Preserve the user's shell PATH first so `#!/usr/bin/env node` scripts keep + * using the same Node runtime the user would get in their shell. This avoids + * accidentally downgrading tools like Claude Code to an older system Node. + */ +export function buildExpandedShellPath(options: BuildExpandedShellPathOptions = {}): string { + const home = options.home || os.homedir(); + const shellPath = options.shellPath || process.env.PATH || ''; + const platform = options.platform || process.platform; + const sep = path.delimiter; + + if (platform === 'win32') { + const appData = process.env.APPDATA || path.join(home, 'AppData', 'Roaming'); + const localAppData = process.env.LOCALAPPDATA || path.join(home, 'AppData', 'Local'); + const extras = [ + 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, ...extras].join(sep).split(sep).filter(Boolean); + return [...new Set(allParts)].join(sep); + } + + const extras = [ + path.join(home, '.npm-global', 'bin'), + path.join(home, '.local', 'bin'), + path.join(home, '.claude', 'bin'), + '/opt/homebrew/bin', + '/usr/local/bin', + '/usr/bin', + '/bin', + ]; + const allParts = [shellPath, ...extras].join(':').split(':').filter(Boolean); + return [...new Set(allParts)].join(':'); +} diff --git a/src/__tests__/unit/electron-path-and-error-classifier.test.ts b/src/__tests__/unit/electron-path-and-error-classifier.test.ts new file mode 100644 index 00000000..3012df27 --- /dev/null +++ b/src/__tests__/unit/electron-path-and-error-classifier.test.ts @@ -0,0 +1,40 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { buildExpandedShellPath } from '../../../electron/path-utils'; +import { classifyError } from '../../lib/error-classifier'; + +describe('buildExpandedShellPath', () => { + it('keeps the user shell PATH ahead of system fallback paths on macOS/Linux', () => { + const shellPath = '/Users/test/.nvm/versions/node/v22.22.1/bin:/usr/local/bin:/usr/bin'; + const expanded = buildExpandedShellPath({ + shellPath, + home: '/Users/test', + platform: 'darwin', + }); + + const parts = expanded.split(':'); + assert.equal(parts[0], '/Users/test/.nvm/versions/node/v22.22.1/bin'); + assert.ok(parts.indexOf('/usr/local/bin') > parts.indexOf('/Users/test/.nvm/versions/node/v22.22.1/bin')); + }); +}); + +describe('classifyError', () => { + it('does not misclassify a generic Node.js version string as CLI_VERSION_TOO_OLD', () => { + const result = classifyError({ + error: new Error('Claude Code process exited with code 1'), + stderr: 'Node.js v18.16.0\n at file:///path/to/cli.js:1:1', + providerName: '联通云', + }); + + assert.equal(result.category, 'PROCESS_CRASH'); + }); + + it('still classifies genuine minimum-version errors as CLI_VERSION_TOO_OLD', () => { + const result = classifyError({ + error: new Error('upgrade required'), + stderr: 'Claude Code CLI upgrade required: minimum supported version is 2.2.0', + }); + + assert.equal(result.category, 'CLI_VERSION_TOO_OLD'); + }); +}); diff --git a/src/lib/error-classifier.ts b/src/lib/error-classifier.ts index c2e4e50a..cb90d6d8 100644 --- a/src/lib/error-classifier.ts +++ b/src/lib/error-classifier.ts @@ -176,7 +176,14 @@ const ERROR_PATTERNS: ErrorPattern[] = [ // ── CLI version too old ── { category: 'CLI_VERSION_TOO_OLD', - patterns: ['version', 'upgrade required', 'minimum version'], + patterns: [ + 'upgrade required', + 'minimum supported version', + 'minimum claude code version', + 'requires a newer claude code cli', + /claude code cli .*too old/i, + /minimum version .*claude/i, + ], userMessage: () => 'Your Claude Code CLI version is too old.', actionHint: () => 'Update to the latest version: npm update -g @anthropic-ai/claude-code', retryable: false,