import * as OS from "node:os"; import { execFileSync } from "node:child_process"; import { accessSync, constants, statSync } from "node:fs"; import { extname, join } from "node:path"; const PATH_CAPTURE_START = "__T3CODE_PATH_START__"; const PATH_CAPTURE_END = "__T3CODE_PATH_END__"; const SHELL_ENV_NAME_PATTERN = /^[A-Z0-9_]+$/; const WINDOWS_PATH_DELIMITER = ";"; const POSIX_PATH_DELIMITER = ":"; const WINDOWS_SHELL_CANDIDATES = ["pwsh.exe", "powershell.exe"] as const; type ExecFileSyncLike = ( file: string, args: ReadonlyArray, options: { encoding: "utf8"; timeout: number }, ) => string; export interface CommandAvailabilityOptions { readonly platform?: NodeJS.Platform; readonly env?: NodeJS.ProcessEnv; } export interface WindowsEnvironmentProbeOptions { readonly loadProfile?: boolean; } function trimNonEmpty(value: string | null | undefined): string | undefined { const trimmed = value?.trim(); return trimmed && trimmed.length > 0 ? trimmed : undefined; } function readUserLoginShell(): string | undefined { try { return trimNonEmpty(OS.userInfo().shell); } catch { return undefined; } } export function listLoginShellCandidates( platform: NodeJS.Platform, shell: string | undefined, userShell = readUserLoginShell(), ): ReadonlyArray { const fallbackShell = platform === "darwin" ? "/bin/zsh" : platform === "linux" ? "/bin/bash" : undefined; const seen = new Set(); const candidates: string[] = []; for (const candidate of [trimNonEmpty(shell), trimNonEmpty(userShell), fallbackShell]) { if (!candidate || seen.has(candidate)) { continue; } seen.add(candidate); candidates.push(candidate); } return candidates; } export function extractPathFromShellOutput(output: string): string | null { const startIndex = output.indexOf(PATH_CAPTURE_START); if (startIndex === -1) return null; const valueStartIndex = startIndex + PATH_CAPTURE_START.length; const endIndex = output.indexOf(PATH_CAPTURE_END, valueStartIndex); if (endIndex === -1) return null; const pathValue = output.slice(valueStartIndex, endIndex).trim(); return pathValue.length > 0 ? pathValue : null; } export function readPathFromLoginShell( shell: string, execFile: ExecFileSyncLike = execFileSync, ): string | undefined { return readEnvironmentFromLoginShell(shell, ["PATH"], execFile).PATH; } export function readPathFromLaunchctl( execFile: ExecFileSyncLike = execFileSync, ): string | undefined { try { return trimNonEmpty( execFile("/bin/launchctl", ["getenv", "PATH"], { encoding: "utf8", timeout: 2000, }), ); } catch { return undefined; } } export function mergePathEntries( preferredPath: string | undefined, inheritedPath: string | undefined, platform: NodeJS.Platform, ): string | undefined { const delimiter = platform === "win32" ? ";" : ":"; const merged: string[] = []; const seen = new Set(); for (const pathValue of [preferredPath, inheritedPath]) { if (!pathValue) continue; for (const entry of pathValue.split(delimiter)) { const trimmedEntry = entry.trim(); if (!trimmedEntry || seen.has(trimmedEntry)) { continue; } seen.add(trimmedEntry); merged.push(trimmedEntry); } } return merged.length > 0 ? merged.join(delimiter) : undefined; } function envCaptureStart(name: string): string { return `__T3CODE_ENV_${name}_START__`; } function envCaptureEnd(name: string): string { return `__T3CODE_ENV_${name}_END__`; } function buildEnvironmentCaptureCommand(names: ReadonlyArray): string { return names .map((name) => { if (!SHELL_ENV_NAME_PATTERN.test(name)) { throw new Error(`Unsupported environment variable name: ${name}`); } return [ `printf '%s\\n' '${envCaptureStart(name)}'`, `printenv ${name} || true`, `printf '%s\\n' '${envCaptureEnd(name)}'`, ].join("; "); }) .join("; "); } function buildWindowsEnvironmentCaptureCommand(names: ReadonlyArray): string { return [ "$ErrorActionPreference = 'Stop'", ...names.flatMap((name) => { if (!SHELL_ENV_NAME_PATTERN.test(name)) { throw new Error(`Unsupported environment variable name: ${name}`); } return [ `Write-Output '${envCaptureStart(name)}'`, `$value = [Environment]::GetEnvironmentVariable('${name}')`, "if ($null -ne $value -and $value.Length -gt 0) { Write-Output $value }", `Write-Output '${envCaptureEnd(name)}'`, ]; }), ].join("; "); } function extractEnvironmentValue(output: string, name: string): string | undefined { const startMarker = envCaptureStart(name); const endMarker = envCaptureEnd(name); const startIndex = output.indexOf(startMarker); if (startIndex === -1) return undefined; const valueStartIndex = startIndex + startMarker.length; const endIndex = output.indexOf(endMarker, valueStartIndex); if (endIndex === -1) return undefined; const value = output .slice(valueStartIndex, endIndex) .replace(/^\r?\n/, "") .replace(/\r?\n$/, ""); return value.length > 0 ? value : undefined; } export type ShellEnvironmentReader = ( shell: string, names: ReadonlyArray, execFile?: ExecFileSyncLike, ) => Partial>; export const readEnvironmentFromLoginShell: ShellEnvironmentReader = ( shell, names, execFile = execFileSync, ) => { if (names.length === 0) { return {}; } const output = execFile(shell, ["-ilc", buildEnvironmentCaptureCommand(names)], { encoding: "utf8", timeout: 5000, }); const environment: Partial> = {}; for (const name of names) { const value = extractEnvironmentValue(output, name); if (value !== undefined) { environment[name] = value; } } return environment; }; export type WindowsShellEnvironmentReader = ( names: ReadonlyArray, options?: WindowsEnvironmentProbeOptions, ) => Partial>; export function readEnvironmentFromWindowsShell( names: ReadonlyArray, execFile?: ExecFileSyncLike, ): Partial>; export function readEnvironmentFromWindowsShell( names: ReadonlyArray, options?: WindowsEnvironmentProbeOptions, execFile?: ExecFileSyncLike, ): Partial>; export function readEnvironmentFromWindowsShell( names: ReadonlyArray, optionsOrExecFile?: WindowsEnvironmentProbeOptions | ExecFileSyncLike, maybeExecFile?: ExecFileSyncLike, ): Partial> { if (names.length === 0) { return {}; } const options = typeof optionsOrExecFile === "function" ? ({} satisfies WindowsEnvironmentProbeOptions) : (optionsOrExecFile ?? {}); const execFile: ExecFileSyncLike = typeof optionsOrExecFile === "function" ? optionsOrExecFile : (maybeExecFile ?? (execFileSync as ExecFileSyncLike)); const command = buildWindowsEnvironmentCaptureCommand(names); const args = [ "-NoLogo", ...(options.loadProfile ? ([] as const) : (["-NoProfile"] as const)), "-NonInteractive", "-Command", command, ]; for (const shell of WINDOWS_SHELL_CANDIDATES) { try { const output = execFile(shell, args, { encoding: "utf8", timeout: 5000 }); const environment: Partial> = {}; for (const name of names) { const value = extractEnvironmentValue(output, name); if (value !== undefined) { environment[name] = value; } } return environment; } catch { continue; } } return {}; } function stripWrappingQuotes(value: string): string { return value.replace(/^"+|"+$/g, ""); } function pathDelimiterForPlatform(platform: NodeJS.Platform): string { return platform === "win32" ? WINDOWS_PATH_DELIMITER : POSIX_PATH_DELIMITER; } function normalizePathEntryForComparison(entry: string, platform: NodeJS.Platform): string { const normalized = stripWrappingQuotes(entry.trim()); return platform === "win32" ? normalized.toLowerCase() : normalized; } export function mergePathValues( preferredPath: string | undefined, inheritedPath: string | undefined, platform: NodeJS.Platform, ): string | undefined { const delimiter = pathDelimiterForPlatform(platform); const merged: string[] = []; const seen = new Set(); for (const rawValue of [preferredPath, inheritedPath]) { if (!rawValue) continue; for (const entry of rawValue.split(delimiter)) { const trimmed = entry.trim(); if (trimmed.length === 0) continue; const normalized = normalizePathEntryForComparison(trimmed, platform); if (normalized.length === 0 || seen.has(normalized)) continue; seen.add(normalized); merged.push(trimmed); } } return merged.length > 0 ? merged.join(delimiter) : undefined; } function readEnvPath(env: NodeJS.ProcessEnv): string | undefined { return env.PATH ?? env.Path ?? env.path; } function resolvePathEnvironmentVariable(env: NodeJS.ProcessEnv): string { return readEnvPath(env) ?? ""; } function resolveWindowsPathExtensions(env: NodeJS.ProcessEnv): ReadonlyArray { const rawValue = env.PATHEXT; const fallback = [".COM", ".EXE", ".BAT", ".CMD"]; if (!rawValue) return fallback; const parsed = rawValue .split(";") .map((entry) => entry.trim()) .filter((entry) => entry.length > 0) .map((entry) => (entry.startsWith(".") ? entry.toUpperCase() : `.${entry.toUpperCase()}`)); return parsed.length > 0 ? Array.from(new Set(parsed)) : fallback; } function resolveCommandCandidates( command: string, platform: NodeJS.Platform, windowsPathExtensions: ReadonlyArray, ): ReadonlyArray { if (platform !== "win32") return [command]; const extension = extname(command); const normalizedExtension = extension.toUpperCase(); if (extension.length > 0 && windowsPathExtensions.includes(normalizedExtension)) { const commandWithoutExtension = command.slice(0, -extension.length); return Array.from( new Set([ command, `${commandWithoutExtension}${normalizedExtension}`, `${commandWithoutExtension}${normalizedExtension.toLowerCase()}`, ]), ); } const candidates: string[] = []; for (const candidateExtension of windowsPathExtensions) { candidates.push(`${command}${candidateExtension}`); candidates.push(`${command}${candidateExtension.toLowerCase()}`); } return Array.from(new Set(candidates)); } function isExecutableFile( filePath: string, platform: NodeJS.Platform, windowsPathExtensions: ReadonlyArray, ): boolean { try { const stat = statSync(filePath); if (!stat.isFile()) return false; if (platform === "win32") { const extension = extname(filePath); if (extension.length === 0) return false; return windowsPathExtensions.includes(extension.toUpperCase()); } accessSync(filePath, constants.X_OK); return true; } catch { return false; } } export function isCommandAvailable( command: string, options: CommandAvailabilityOptions = {}, ): boolean { const platform = options.platform ?? process.platform; const env = options.env ?? process.env; const windowsPathExtensions = platform === "win32" ? resolveWindowsPathExtensions(env) : []; const commandCandidates = resolveCommandCandidates(command, platform, windowsPathExtensions); if (command.includes("/") || command.includes("\\")) { return commandCandidates.some((candidate) => isExecutableFile(candidate, platform, windowsPathExtensions), ); } const pathValue = resolvePathEnvironmentVariable(env); if (pathValue.length === 0) return false; const pathEntries = pathValue .split(pathDelimiterForPlatform(platform)) .map((entry) => stripWrappingQuotes(entry.trim())) .filter((entry) => entry.length > 0); for (const pathEntry of pathEntries) { for (const candidate of commandCandidates) { if (isExecutableFile(join(pathEntry, candidate), platform, windowsPathExtensions)) { return true; } } } return false; } export function resolveKnownWindowsCliDirs(env: NodeJS.ProcessEnv): ReadonlyArray { const appData = env.APPDATA?.trim(); const localAppData = env.LOCALAPPDATA?.trim(); const userProfile = env.USERPROFILE?.trim(); return [ ...(appData ? [`${appData}\\npm`] : []), ...(localAppData ? [`${localAppData}\\Programs\\nodejs`, `${localAppData}\\Volta\\bin`] : []), ...(localAppData ? [`${localAppData}\\pnpm`] : []), ...(userProfile ? [`${userProfile}\\.bun\\bin`, `${userProfile}\\scoop\\shims`] : []), ]; } export interface WindowsEnvironmentResolverOptions { readonly readEnvironment?: WindowsShellEnvironmentReader; readonly commandAvailable?: typeof isCommandAvailable; } function readWindowsEnvironmentSafely( readEnvironment: WindowsShellEnvironmentReader, names: ReadonlyArray, options?: WindowsEnvironmentProbeOptions, ): Partial> { try { return readEnvironment(names, options); } catch { return {}; } } function mergeWindowsEnv( currentEnv: NodeJS.ProcessEnv, patch: Partial>, ): NodeJS.ProcessEnv { const nextEnv: NodeJS.ProcessEnv = { ...currentEnv }; for (const [key, value] of Object.entries(patch)) { if (value !== undefined) { nextEnv[key] = value; } } return nextEnv; } export function resolveWindowsEnvironment( env: NodeJS.ProcessEnv, options: WindowsEnvironmentResolverOptions = {}, ): Partial { const readEnvironment = options.readEnvironment ?? readEnvironmentFromWindowsShell; const commandAvailable = options.commandAvailable ?? isCommandAvailable; const inheritedPath = readEnvPath(env); const shellPath = readWindowsEnvironmentSafely(readEnvironment, ["PATH"], { loadProfile: false, }).PATH; const mergedPath = mergePathValues(shellPath, inheritedPath, "win32"); const knownCliPath = resolveKnownWindowsCliDirs(env).join(WINDOWS_PATH_DELIMITER); const baselinePath = mergePathValues(knownCliPath, mergedPath, "win32"); const baselinePatch: Partial = baselinePath ? { PATH: baselinePath } : {}; const baselineEnv = mergeWindowsEnv(env, baselinePatch); if (commandAvailable("node", { platform: "win32", env: baselineEnv })) { return baselinePatch; } const profiledEnvironment = readWindowsEnvironmentSafely( readEnvironment, ["PATH", "FNM_DIR", "FNM_MULTISHELL_PATH"], { loadProfile: true }, ); const profiledPath = mergePathValues(profiledEnvironment.PATH, baselinePath, "win32"); const profiledPatch: Partial = { ...(profiledPath ? { PATH: profiledPath } : {}), ...(profiledEnvironment.FNM_DIR ? { FNM_DIR: profiledEnvironment.FNM_DIR } : {}), ...(profiledEnvironment.FNM_MULTISHELL_PATH ? { FNM_MULTISHELL_PATH: profiledEnvironment.FNM_MULTISHELL_PATH } : {}), }; return Object.keys(profiledPatch).length > 0 ? { ...baselinePatch, ...profiledPatch } : baselinePatch; }