331 lines
11 KiB
TypeScript
331 lines
11 KiB
TypeScript
/**
|
|
* Interactive shell environment resolver.
|
|
*
|
|
* Resolves the user's interactive shell environment (PATH, etc.) by spawning
|
|
* a login/interactive shell and reading its exported variables. The result is
|
|
* cached for the lifetime of the process.
|
|
*
|
|
* Extracted from TeamProvisioningService for reuse by ScheduledTaskExecutor
|
|
* and any other service that needs the user's shell environment.
|
|
*/
|
|
|
|
import { getHomeDir } from '@main/utils/pathDecoder';
|
|
import { createLogger } from '@shared/utils/logger';
|
|
import { spawn } from 'child_process';
|
|
|
|
const logger = createLogger('Utils:shellEnv');
|
|
|
|
const SHELL_ENV_TIMEOUT_MS = 12_000;
|
|
const SHELL_ENV_BEST_EFFORT_TIMEOUT_MS = 5_000;
|
|
const SHELL_ENV_FAILURE_COOLDOWN_MS = 60_000;
|
|
|
|
let cachedInteractiveShellEnv: NodeJS.ProcessEnv | null = null;
|
|
let shellEnvResolvePromise: Promise<NodeJS.ProcessEnv> | null = null;
|
|
let shellEnvFailureCooldownUntil = 0;
|
|
let lastShellEnvFailureMessage: string | null = null;
|
|
|
|
export interface ShellEnvResolveProgress {
|
|
phase: string;
|
|
message: string;
|
|
source?: string;
|
|
}
|
|
|
|
export interface ShellEnvResolveOptions {
|
|
onProgress?: (progress: ShellEnvResolveProgress) => void;
|
|
/**
|
|
* Stable diagnostic label for the caller that initiated the shell probe.
|
|
* Keep this to a short feature/service id, not a filesystem path.
|
|
*/
|
|
source?: string;
|
|
}
|
|
|
|
export interface ShellEnvBestEffortResolveOptions extends ShellEnvResolveOptions {
|
|
/**
|
|
* Max time to wait on the critical path before returning fallbackEnv.
|
|
* By default, the full shell resolve continues in the background and caches
|
|
* on success. Set background=false for hot paths that only want cached env
|
|
* or an immediate fallback.
|
|
*/
|
|
timeoutMs?: number;
|
|
/**
|
|
* Whether a slow shell probe should continue in the background after the
|
|
* caller falls back. Disable this for startup/status hot paths where a
|
|
* delayed hard timeout would only create log noise and process pressure.
|
|
*/
|
|
background?: boolean;
|
|
/**
|
|
* Returned when shell env is not ready quickly enough. This is intentionally
|
|
* not cached as a real shell env.
|
|
*/
|
|
fallbackEnv?: NodeJS.ProcessEnv;
|
|
}
|
|
|
|
function emitProgress(
|
|
options: ShellEnvResolveOptions | undefined,
|
|
phase: string,
|
|
message: string
|
|
): void {
|
|
const source = normalizeShellEnvSource(options?.source);
|
|
options?.onProgress?.(source ? { phase, message, source } : { phase, message });
|
|
}
|
|
|
|
function normalizeShellEnvSource(source: string | undefined): string | null {
|
|
const trimmed = source?.trim();
|
|
if (!trimmed) {
|
|
return null;
|
|
}
|
|
return trimmed.replace(/[^A-Za-z0-9_.:-]/g, '_').slice(0, 80);
|
|
}
|
|
|
|
function formatShellEnvSource(options: ShellEnvResolveOptions | undefined): string {
|
|
const source = normalizeShellEnvSource(options?.source);
|
|
return source ? ` source=${source}` : '';
|
|
}
|
|
|
|
function rememberShellEnvFailure(message: string): void {
|
|
lastShellEnvFailureMessage = message;
|
|
shellEnvFailureCooldownUntil = Date.now() + SHELL_ENV_FAILURE_COOLDOWN_MS;
|
|
}
|
|
|
|
function clearShellEnvFailure(): void {
|
|
lastShellEnvFailureMessage = null;
|
|
shellEnvFailureCooldownUntil = 0;
|
|
}
|
|
|
|
function parseNullSeparatedEnv(content: string): NodeJS.ProcessEnv {
|
|
const parsed: NodeJS.ProcessEnv = {};
|
|
const lines = content.split('\0');
|
|
for (const line of lines) {
|
|
if (!line) {
|
|
continue;
|
|
}
|
|
const separatorIndex = line.indexOf('=');
|
|
if (separatorIndex <= 0) {
|
|
continue;
|
|
}
|
|
const key = line.slice(0, separatorIndex);
|
|
const value = line.slice(separatorIndex + 1);
|
|
parsed[key] = value;
|
|
}
|
|
return parsed;
|
|
}
|
|
|
|
async function readShellEnv(shellPath: string, args: string[]): Promise<NodeJS.ProcessEnv> {
|
|
const envDump = await new Promise<string>((resolve, reject) => {
|
|
const child = spawn(shellPath, args, {
|
|
env: process.env,
|
|
stdio: ['ignore', 'pipe', 'ignore'],
|
|
windowsHide: true,
|
|
});
|
|
const chunks: Buffer[] = [];
|
|
let settled = false;
|
|
let timeoutHandle: NodeJS.Timeout | null = setTimeout(() => {
|
|
timeoutHandle = null;
|
|
child.kill();
|
|
// SIGKILL fallback if SIGTERM is ignored (e.g., shell stuck on .zshrc)
|
|
setTimeout(() => {
|
|
try {
|
|
child.kill('SIGKILL');
|
|
} catch {
|
|
/* already dead */
|
|
}
|
|
}, 3000);
|
|
if (!settled) {
|
|
settled = true;
|
|
reject(new Error('shell env resolve timeout'));
|
|
}
|
|
}, SHELL_ENV_TIMEOUT_MS);
|
|
|
|
child.stdout?.on('data', (chunk: Buffer) => {
|
|
chunks.push(chunk);
|
|
});
|
|
child.once('error', (error) => {
|
|
if (timeoutHandle) {
|
|
clearTimeout(timeoutHandle);
|
|
timeoutHandle = null;
|
|
}
|
|
if (!settled) {
|
|
settled = true;
|
|
reject(error);
|
|
}
|
|
});
|
|
child.once('close', (code: number | null, signal: NodeJS.Signals | null) => {
|
|
if (timeoutHandle) {
|
|
clearTimeout(timeoutHandle);
|
|
}
|
|
if (!settled) {
|
|
settled = true;
|
|
if (chunks.length === 0 && (code !== 0 || signal)) {
|
|
reject(
|
|
new Error(
|
|
signal
|
|
? `shell env command exited with signal ${signal}`
|
|
: `shell env command exited with code ${code}`
|
|
)
|
|
);
|
|
return;
|
|
}
|
|
resolve(Buffer.concat(chunks).toString('utf8'));
|
|
}
|
|
});
|
|
});
|
|
return parseNullSeparatedEnv(envDump);
|
|
}
|
|
|
|
/**
|
|
* Resolve the user's interactive shell environment.
|
|
*
|
|
* Tries login shell first (`-lic`), falls back to interactive (`-ic`).
|
|
* On Windows returns empty object. Result is cached after first success.
|
|
*/
|
|
export async function resolveInteractiveShellEnv(
|
|
options: ShellEnvResolveOptions = {}
|
|
): Promise<NodeJS.ProcessEnv> {
|
|
if (cachedInteractiveShellEnv) {
|
|
emitProgress(options, 'shell-env-cached', 'Using cached shell environment...');
|
|
return cachedInteractiveShellEnv;
|
|
}
|
|
if (shellEnvResolvePromise) {
|
|
emitProgress(options, 'shell-env-waiting', 'Waiting for shell environment...');
|
|
return shellEnvResolvePromise;
|
|
}
|
|
if (process.platform === 'win32') {
|
|
emitProgress(options, 'shell-env-skipped', 'Skipping shell environment on Windows...');
|
|
cachedInteractiveShellEnv = {};
|
|
return cachedInteractiveShellEnv;
|
|
}
|
|
|
|
shellEnvResolvePromise = (async () => {
|
|
const shellPath = process.env.SHELL || '/bin/zsh';
|
|
try {
|
|
emitProgress(options, 'shell-env-login', 'Reading login shell environment...');
|
|
const loginEnv = await readShellEnv(shellPath, ['-lic', 'env -0']);
|
|
cachedInteractiveShellEnv = loginEnv;
|
|
clearShellEnvFailure();
|
|
return loginEnv;
|
|
} catch (loginError) {
|
|
const loginMessage = loginError instanceof Error ? loginError.message : String(loginError);
|
|
try {
|
|
emitProgress(options, 'shell-env-interactive', 'Trying interactive shell environment...');
|
|
const interactiveEnv = await readShellEnv(shellPath, ['-ic', 'env -0']);
|
|
cachedInteractiveShellEnv = interactiveEnv;
|
|
clearShellEnvFailure();
|
|
return interactiveEnv;
|
|
} catch (interactiveError) {
|
|
const interactiveMessage =
|
|
interactiveError instanceof Error ? interactiveError.message : String(interactiveError);
|
|
logger.warn(
|
|
`Failed to resolve shell env after login and interactive probes${formatShellEnvSource(
|
|
options
|
|
)}: login=${loginMessage}; interactive=${interactiveMessage}`
|
|
);
|
|
rememberShellEnvFailure(interactiveMessage);
|
|
emitProgress(options, 'shell-env-fallback', 'Using current process environment...');
|
|
return {};
|
|
}
|
|
} finally {
|
|
shellEnvResolvePromise = null;
|
|
}
|
|
})();
|
|
|
|
return shellEnvResolvePromise;
|
|
}
|
|
|
|
/**
|
|
* Resolve shell env without making the caller wait for slow prompt/plugin init.
|
|
*
|
|
* This is deliberately additive: fallbackEnv is returned only to the current
|
|
* caller, never cached. A successful background resolve still populates the
|
|
* normal interactive-shell cache used by buildMergedCliPath/buildEnrichedEnv.
|
|
*/
|
|
export async function resolveInteractiveShellEnvBestEffort(
|
|
options: ShellEnvBestEffortResolveOptions = {}
|
|
): Promise<NodeJS.ProcessEnv> {
|
|
if (cachedInteractiveShellEnv) {
|
|
emitProgress(options, 'shell-env-cached', 'Using cached shell environment...');
|
|
return cachedInteractiveShellEnv;
|
|
}
|
|
|
|
if (process.platform === 'win32') {
|
|
return resolveInteractiveShellEnv(options);
|
|
}
|
|
|
|
const fallbackEnv = options.fallbackEnv ?? {};
|
|
const timeoutMs = Math.max(0, options.timeoutMs ?? SHELL_ENV_BEST_EFFORT_TIMEOUT_MS);
|
|
const startedAt = Date.now();
|
|
if (options.background === false) {
|
|
emitProgress(options, 'shell-env-best-effort-fallback', 'Using fallback shell environment...');
|
|
return fallbackEnv;
|
|
}
|
|
if (!shellEnvResolvePromise && startedAt < shellEnvFailureCooldownUntil) {
|
|
const retryInMs = Math.max(0, shellEnvFailureCooldownUntil - startedAt);
|
|
emitProgress(
|
|
options,
|
|
'shell-env-failure-cooldown',
|
|
lastShellEnvFailureMessage
|
|
? `Using fallback shell environment after recent failure: ${lastShellEnvFailureMessage}`
|
|
: `Using fallback shell environment for ${retryInMs}ms after recent failure...`
|
|
);
|
|
return fallbackEnv;
|
|
}
|
|
|
|
const resolvePromise = resolveInteractiveShellEnv(options);
|
|
if (timeoutMs === 0) {
|
|
emitProgress(options, 'shell-env-best-effort-fallback', 'Using fallback shell environment...');
|
|
return fallbackEnv;
|
|
}
|
|
|
|
let timeoutHandle: NodeJS.Timeout | null = null;
|
|
const fallbackPromise = new Promise<NodeJS.ProcessEnv>((resolve) => {
|
|
timeoutHandle = setTimeout(() => {
|
|
timeoutHandle = null;
|
|
emitProgress(
|
|
options,
|
|
'shell-env-best-effort-timeout',
|
|
'Shell environment is still resolving; using fallback for now...'
|
|
);
|
|
resolve(fallbackEnv);
|
|
}, timeoutMs);
|
|
timeoutHandle.unref?.();
|
|
});
|
|
|
|
try {
|
|
const resolvedEnv = await Promise.race([resolvePromise, fallbackPromise]);
|
|
if (!cachedInteractiveShellEnv && shellEnvFailureCooldownUntil > startedAt) {
|
|
return fallbackEnv;
|
|
}
|
|
return resolvedEnv;
|
|
} finally {
|
|
if (timeoutHandle) {
|
|
clearTimeout(timeoutHandle);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Clear the cached shell environment. Useful for testing.
|
|
*/
|
|
export function clearShellEnvCache(): void {
|
|
cachedInteractiveShellEnv = null;
|
|
shellEnvResolvePromise = null;
|
|
clearShellEnvFailure();
|
|
}
|
|
|
|
/**
|
|
* 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;
|
|
}
|
|
|
|
/**
|
|
* HOME from login/interactive shell when resolved, else Electron/Node home.
|
|
* Matches TeamProvisioningService so CLI reads the same ~/.claude as the terminal.
|
|
*/
|
|
export function getShellPreferredHome(): string {
|
|
const fromShell = getCachedShellEnv()?.HOME?.trim();
|
|
return fromShell || getHomeDir();
|
|
}
|