Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 2 additions & 22 deletions electron/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -321,28 +322,7 @@ function loadUserShellEnv(): Record<string, string> {
* 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<number> {
Expand Down
48 changes: 48 additions & 0 deletions electron/path-utils.ts
Original file line number Diff line number Diff line change
@@ -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(':');
}
40 changes: 40 additions & 0 deletions src/__tests__/unit/electron-path-and-error-classifier.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
9 changes: 8 additions & 1 deletion src/lib/error-classifier.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down