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:
parent
c491ec89a1
commit
511abaa0f5
3 changed files with 96 additions and 9 deletions
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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'],
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue