feat: enhance shell environment handling for CLI processes

- Added a pre-warming mechanism for the interactive shell environment at app startup to improve PATH resolution for CLI services.
- Introduced getCachedShellEnv function to retrieve the cached shell environment synchronously, aiding in non-blocking PATH enrichment.
- Updated buildChildEnv function to incorporate enriched PATH from the cached shell environment, ensuring reliable access to user-installed binaries.
- Refactored CLI process environment setup to utilize the binary path for more accurate environment configuration.
This commit is contained in:
iliya 2026-03-19 18:51:44 +02:00
parent c491ec89a1
commit 511abaa0f5
3 changed files with 96 additions and 9 deletions

View file

@ -27,6 +27,7 @@ import { ReviewApplierService } from '@main/services/team/ReviewApplierService';
import { TeamBackupService } from '@main/services/team/TeamBackupService';
import { TeamConfigReader } from '@main/services/team/TeamConfigReader';
import { TeamInboxWriter } from '@main/services/team/TeamInboxWriter';
import { resolveInteractiveShellEnv } from '@main/utils/shellEnv';
import {
CONTEXT_CHANGED,
SCHEDULE_CHANGE,
@ -1295,6 +1296,13 @@ function createWindow(): void {
*/
void app.whenReady().then(() => {
logger.info('App ready, initializing...');
// Pre-warm interactive shell env cache (non-blocking).
// On macOS, Finder-launched apps get a minimal PATH. This resolves the user's
// full shell PATH (nvm, homebrew, .local/bin, etc.) in the background so that
// CliInstallerService.getStatus() and other services get cached results instantly.
void resolveInteractiveShellEnv();
try {
// Initialize services first
initializeServices();

View file

@ -19,14 +19,15 @@
import { execCli, killProcessTree, spawnCli } from '@main/utils/childProcess';
import { getHomeDir } from '@main/utils/pathDecoder';
import { getCachedShellEnv } from '@main/utils/shellEnv';
import { getErrorMessage } from '@shared/utils/errorHandling';
import { createLogger } from '@shared/utils/logger';
import { createHash } from 'crypto';
import { createWriteStream, existsSync, promises as fsp } from 'fs';
import { createWriteStream, existsSync, realpathSync, promises as fsp } from 'fs';
import http from 'http';
import https from 'https';
import { tmpdir } from 'os';
import { join } from 'path';
import { dirname, join } from 'path';
import { ClaudeBinaryResolver } from '../team/ClaudeBinaryResolver';
@ -76,16 +77,84 @@ const AUTH_STATUS_MAX_RETRIES = 2;
const AUTH_STATUS_RETRY_DELAY_MS = 1500;
/**
* Build env for child processes with correct HOME.
* On Windows with non-ASCII usernames, process.env may have a broken HOME/USERPROFILE.
* getHomeDir() uses Electron's app.getPath('home') which handles Unicode correctly.
* Build env for child processes with correct HOME and enriched PATH.
*
* On macOS, apps launched from Finder/.dmg get a minimal PATH (/usr/bin:/bin:/usr/sbin:/sbin).
* Three layers ensure `claude auth status` and `claude --version` always work:
*
* 1. **Binary-derived**: add dirname(binaryPath) to PATH. If the binary is a symlink
* (e.g. ~/.local/bin/claude ~/.nvm/versions/node/v22/bin/claude), also add the
* resolved real directory this guarantees `node` is findable since npm installs
* claude next to node.
* 2. **Shell env cache**: if the pre-warm (fired at app startup) has completed,
* merge the user's full interactive shell PATH covers all custom entries.
* 3. **Sync fallback**: platform-specific common directories for when the cache
* is still cold (first launch, heavy .zshrc).
*
* On Windows this is effectively a no-op Explorer-launched apps inherit the full
* user PATH, and the shell env cache returns {} (no PATH override).
*/
function buildChildEnv(): NodeJS.ProcessEnv {
function buildChildEnv(binaryPath?: string | null): NodeJS.ProcessEnv {
const home = getHomeDir();
const sep = process.platform === 'win32' ? ';' : ':';
const currentPath = process.env.PATH || '';
const extraDirs: string[] = [];
// Layer 1: binary's own directory + resolved symlink target.
// This is the most reliable source — if ClaudeBinaryResolver found the binary,
// its directory almost certainly contains `node` too (npm co-installs them).
if (binaryPath) {
const binDir = dirname(binaryPath);
extraDirs.push(binDir);
try {
const realBinDir = dirname(realpathSync(binaryPath));
if (realBinDir !== binDir) {
extraDirs.push(realBinDir);
}
} catch {
// symlink resolution failed (race condition / broken link) — ignore
}
}
// Layer 2: cached shell env (pre-warmed at startup, covers nvm/volta/fnm/custom PATH).
const cachedEnv = getCachedShellEnv();
if (cachedEnv?.PATH) {
extraDirs.push(...cachedEnv.PATH.split(sep).filter(Boolean));
} else {
// Layer 3: sync fallback — common binary directories per platform.
if (process.platform === 'win32') {
extraDirs.push(join(home, 'AppData', 'Roaming', 'npm'), join(home, 'scoop', 'shims'));
if (process.env.LOCALAPPDATA) {
extraDirs.push(join(process.env.LOCALAPPDATA, 'Programs', 'claude'));
}
if (process.env.ProgramFiles) {
extraDirs.push(join(process.env.ProgramFiles, 'claude'));
}
} else {
extraDirs.push(
join(home, '.local', 'bin'),
join(home, '.npm-global', 'bin'),
'/usr/local/bin',
'/opt/homebrew/bin'
);
}
}
// Deduplicate: extra dirs first (higher priority), then existing PATH entries.
const seen = new Set<string>();
const merged: string[] = [];
for (const dir of [...extraDirs, ...currentPath.split(sep)]) {
if (dir && !seen.has(dir)) {
seen.add(dir);
merged.push(dir);
}
}
return {
...process.env,
HOME: home,
USERPROFILE: home,
PATH: merged.join(sep),
};
}
@ -292,7 +361,7 @@ export class CliInstallerService {
try {
const { stdout } = await execCli(binaryPath, ['--version'], {
timeout: VERSION_TIMEOUT_MS,
env: buildChildEnv(),
env: buildChildEnv(binaryPath),
});
r.installedVersion = normalizeVersion(stdout);
logger.info(
@ -323,7 +392,7 @@ export class CliInstallerService {
try {
const { stdout: authStdout } = await execCli(binaryPath, ['auth', 'status'], {
timeout: VERSION_TIMEOUT_MS,
env: buildChildEnv(),
env: buildChildEnv(binaryPath),
});
const auth = JSON.parse(authStdout.trim()) as {
loggedIn?: boolean;
@ -589,7 +658,7 @@ export class CliInstallerService {
private async runInstallWithStreaming(binaryPath: string, attempt = 1): Promise<void> {
return new Promise<void>((resolve, reject) => {
const child = spawnCli(binaryPath, ['install'], {
env: { ...buildChildEnv(), CLAUDE_SKIP_ANALYTICS: '1' },
env: { ...buildChildEnv(binaryPath), CLAUDE_SKIP_ANALYTICS: '1' },
stdio: ['ignore', 'pipe', 'pipe'],
});

View file

@ -140,3 +140,13 @@ export function clearShellEnvCache(): void {
cachedInteractiveShellEnv = null;
shellEnvResolvePromise = null;
}
/**
* Return the cached shell environment synchronously, or null if not yet resolved.
*
* Use this when you need the shell env but cannot afford to wait for resolution
* (e.g. synchronous PATH enrichment with async pre-warming at startup).
*/
export function getCachedShellEnv(): NodeJS.ProcessEnv | null {
return cachedInteractiveShellEnv;
}