// @effect-diagnostics nodeBuiltinImport:off import * as NodeOS from "node:os"; import * as NodePath from "node:path"; import * as NodeChildProcess from "node:child_process"; import * as NodeFS from "node:fs"; import * as Cache from "effect/Cache"; import * as Clock from "effect/Clock"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; import type { ShellEnvironmentPatch } from "@t3tools/contracts"; import * as HostProcess from "./HostProcess.ts"; import * as Context from "effect/Context"; 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; detached?: true; stdio?: ["ignore", "pipe", "ignore"]; }, ) => string; function canExecuteFile(filePath: string): boolean { try { NodeFS.accessSync(filePath, NodeFS.constants.X_OK); return true; } catch { return false; } } export interface CommandAvailabilityOptions { readonly env?: NodeJS.ProcessEnv; readonly extendEnv?: boolean; } export type CommandAvailabilityChecker = ( command: string, options?: CommandAvailabilityOptions, ) => Effect.Effect; const WINDOWS_SHELL_META_CHARS = /([()\][%!^"`<>&|;, *?])/g; // Escape dynamic arguments for both cmd.exe and the target's CommandLineToArgvW parsing. function escapeWindowsShellArg(arg: string): string { let escaped = arg.replace(/(\\*)"/g, '$1$1\\"'); // Double up trailing backslashes so the closing quote is not escaped away. escaped = escaped.replace(/(\\*)$/, "$1$1"); escaped = `"${escaped}"`; return escaped.replace(WINDOWS_SHELL_META_CHARS, "^$1"); } export interface ResolvedSpawnCommand { readonly command: string; readonly args: ReadonlyArray; readonly shell: boolean; } export type SpawnExecutableResolver = ( command: string, platform: NodeJS.Platform, env: NodeJS.ProcessEnv, ) => string | undefined; function resolveSpawnExecutableWithNode( command: string, platform: NodeJS.Platform, env: NodeJS.ProcessEnv, ): string | undefined { const path = platform === "win32" ? NodePath.win32 : NodePath.posix; const windowsPathExtensions = platform === "win32" ? resolveWindowsPathExtensions(env) : []; const candidates = resolveCommandCandidates( command, platform, windowsPathExtensions, path.extname, ); const isExecutable = (candidate: string) => { try { if (!NodeFS.statSync(candidate).isFile()) return false; if (platform === "win32") { return windowsPathExtensions.includes(path.extname(candidate).toUpperCase()); } return canExecuteFile(candidate); } catch { return false; } }; if (command.includes("/") || command.includes("\\")) { return candidates.find(isExecutable); } for (const pathEntry of (readEnvPath(env) ?? "").split(pathDelimiterForPlatform(platform))) { const normalizedPathEntry = stripWrappingQuotes(pathEntry.trim()); if (normalizedPathEntry.length === 0) continue; for (const candidate of candidates) { const candidatePath = path.join(normalizedPathEntry, candidate); if (isExecutable(candidatePath)) return candidatePath; } } return undefined; } export const SpawnExecutableResolution = Context.Reference( "@t3tools/shared/shell/SpawnExecutableResolution", { defaultValue: () => resolveSpawnExecutableWithNode, }, ); 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(NodeOS.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 readPathFromLaunchctl( execFile: ExecFileSyncLike = NodeChildProcess.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__`; } export function buildEnvironmentCaptureCommand( shell: string, names: ReadonlyArray, ): string { const executableName = NodePath.basename(shell).toLowerCase(); const isNushell = executableName.startsWith("nu"); return names .map((name) => { if (!SHELL_ENV_NAME_PATTERN.test(name)) { throw new Error(`Unsupported environment variable name: ${name}`); } if (isNushell) { // Without a configured conversion, Nu expands home paths only in a list PATH. const defaultValue = name === "PATH" ? "if ($env.PATH? | describe | str starts-with 'list') { " + "$env.PATH | each {|p| if ($p | str starts-with '~') { " + "$p | path expand --no-symlink } else { $p } } | str join (char esep) " + "} else { $env.PATH? | default '' }" : `$env.${name}? | default ''`; const value = `if $env.${name}? == null { '' } else if ` + `$env.ENV_CONVERSIONS?.${name}?.to_string? != null { ` + `$env.${name} | do $env.ENV_CONVERSIONS.${name}.to_string $env.${name} ` + `} else { ${defaultValue} }`; return [ `print '${envCaptureStart(name)}'`, `print (${value})`, `print '${envCaptureEnd(name)}'`, ].join("; "); } 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 = NodeChildProcess.execFileSync, ) => { if (names.length === 0) { return {}; } // Interactive startup loads PATH entries; detaching prevents job control on an // inherited controlling TTY from stopping the probe while the parent waits. const output = execFile(shell, ["-ilc", buildEnvironmentCaptureCommand(shell, names)], { encoding: "utf8", timeout: 5000, detached: true, stdio: ["ignore", "pipe", "ignore"], }); 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, ) => Effect.Effect>>; export const WindowsShellEnvironment = Context.Reference( "@t3tools/shared/shell/WindowsShellEnvironment", { defaultValue: () => readEnvironmentFromWindowsShell, }, ); export const CommandAvailability = Context.Reference( "@t3tools/shared/shell/CommandAvailability", { defaultValue: () => isCommandAvailable, }, ); const execWindowsEnvironmentProbe = ( file: string, args: ReadonlyArray, options: { encoding: "utf8"; timeout: number }, ) => Effect.callback>((resume) => { let child: NodeChildProcess.ChildProcess; try { child = NodeChildProcess.execFile( file, args, { ...options, windowsHide: true }, (error, stdout) => { resume(Effect.succeed(error ? Option.none() : Option.some(stdout))); }, ); } catch { resume(Effect.succeedNone); return; } return Effect.sync(() => { child.kill(); }); }); export const readEnvironmentFromWindowsShell = Effect.fn("shell.readEnvironmentFromWindowsShell")( function* ( names: ReadonlyArray, options: WindowsEnvironmentProbeOptions = {}, execFile: typeof execWindowsEnvironmentProbe = execWindowsEnvironmentProbe, ) { if (names.length === 0) return {}; const command = buildWindowsEnvironmentCaptureCommand(names); const args = [ "-NoLogo", ...(options.loadProfile ? ([] as const) : (["-NoProfile"] as const)), "-NonInteractive", "-Command", command, ]; for (const shell of WINDOWS_SHELL_CANDIDATES) { const output = yield* execFile(shell, args, { encoding: "utf8", timeout: 5000 }); if (Option.isNone(output)) continue; const environment: Partial> = {}; for (const name of names) { const value = extractEnvironmentValue(output.value, name); if (value !== undefined) environment[name] = value; } return environment; } 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; } function sanitizePathEntry(entry: string, platform: NodeJS.Platform): string { return platform === "win32" ? entry.replaceAll('"', "") : entry; } 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 sanitized = sanitizePathEntry(entry.trim(), platform); if (sanitized.length === 0) continue; const normalized = normalizePathEntryForComparison(sanitized, platform); if (normalized.length === 0 || seen.has(normalized)) continue; seen.add(normalized); merged.push(sanitized); } } 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: string[] = []; for (const entry of rawValue.split(";")) { const trimmed = entry.trim(); if (trimmed.length === 0) continue; parsed.push(trimmed.startsWith(".") ? trimmed.toUpperCase() : `.${trimmed.toUpperCase()}`); } return parsed.length > 0 ? Array.from(new Set(parsed)) : fallback; } function resolveCommandCandidates( command: string, platform: NodeJS.Platform, windowsPathExtensions: ReadonlyArray, extname: (path: string) => string, ): 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)); } // Explicit paths bypass the cache for new installs; cached misses can otherwise hide binaries for 30 seconds. const COMMAND_RESOLUTION_CACHE_TTL_NANOS = 30_000_000_000n; const COMMAND_RESOLUTION_CACHE_MAX_ENTRIES = 512; const COMMAND_RESOLUTION_CACHE_KEY_SEPARATOR = String.fromCharCode(0); interface CommandResolutionCacheEntry { readonly resolvedPath: string | null; readonly expiresAtNanos: bigint; } export const CommandResolutionCache = Context.Reference>( "@t3tools/shared/shell/CommandResolutionCache", { defaultValue: () => new Map(), }, ); interface PathDirectoryListing { readonly modified: number | null | undefined; readonly names: ReadonlySet | undefined; } const directoryMtime = (directory: string) => FileSystem.FileSystem.use((fileSystem) => fileSystem.stat(directory)).pipe( Effect.map((info) => info.type === "Directory" ? Option.getOrUndefined(info.mtime)?.getTime() : undefined, ), Effect.catch((error) => Effect.succeed(error.reason._tag === "NotFound" ? null : undefined)), ); // Timestamp before reading so concurrent additions change the next mtime check; missing listings fall back to direct probes. const listPathDirectory = Effect.fnUntraced(function* ( directory: string, ): Effect.fn.Return { const modified = yield* directoryMtime(directory); if (modified == null) return { modified, names: modified === null ? new Set() : undefined }; const entries = yield* FileSystem.FileSystem.use((fileSystem) => fileSystem.readDirectory(directory), ).pipe(Effect.orElseSucceed(() => undefined)); return { modified, names: entries && new Set(entries.map((entry) => entry.toLowerCase())) }; }); const PathDirectoryListings = Context.Reference< Cache.Cache | undefined >("@t3tools/shared/shell/PathDirectoryListings", { defaultValue: () => undefined }); export const withPathDirectoryListings = /* Directory reads are cached inside one shell operation; a span for each lookup adds noise. */ Effect.fnUntraced(function* (effect: Effect.Effect) { const listings = yield* Cache.make({ capacity: 1024, lookup: listPathDirectory, requireServicesAt: "lookup", }); return yield* effect.pipe(Effect.provideService(PathDirectoryListings, listings)); }); // An injected resolver or locator may answer differently for the same search, so its // entries are kept apart from every other resolver's. let spawnResolverCacheIdCount = 0; const spawnResolverCacheIds = new WeakMap(); function spawnResolverCacheId(resolver: SpawnExecutableResolver | XcodeGitLocator): number { const known = spawnResolverCacheIds.get(resolver); if (known !== undefined) return known; const id = spawnResolverCacheIdCount++; spawnResolverCacheIds.set(resolver, id); return id; } function cacheCommandResolution( cache: Map, cacheKey: string, resolvedPath: string | null, nowNanos: bigint, ): void { if (cache.size >= COMMAND_RESOLUTION_CACHE_MAX_ENTRIES) { const oldestKey = cache.keys().next().value; if (oldestKey !== undefined) { cache.delete(oldestKey); } } cache.set(cacheKey, { resolvedPath, expiresAtNanos: nowNanos + COMMAND_RESOLUTION_CACHE_TTL_NANOS, }); } const isExecutableFile = Effect.fnUntraced(function* ( filePath: string, platform: NodeJS.Platform, windowsPathExtensions: ReadonlyArray, ): Effect.fn.Return { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; const stat = yield* fileSystem.stat(filePath).pipe(Effect.orElseSucceed(() => null)); if (stat === null || stat.type !== "File") return false; if (platform === "win32") { const extension = path.extname(filePath); if (extension.length === 0) return false; return windowsPathExtensions.includes(extension.toUpperCase()); } return canExecuteFile(filePath); }); const resolveCommandPathForPlatform = Effect.fn("shell.resolveCommandPathForPlatform")(function* ( command: string, options: CommandAvailabilityOptions & { readonly platform: NodeJS.Platform }, ): Effect.fn.Return { const path = yield* Path.Path; const platform = options.platform; const env = options.env ?? process.env; const windowsPathExtensions = platform === "win32" ? resolveWindowsPathExtensions(env) : []; const commandCandidates = resolveCommandCandidates( command, platform, windowsPathExtensions, path.extname, ); if (command.includes("/") || command.includes("\\")) { for (const candidate of commandCandidates) { if (yield* isExecutableFile(candidate, platform, windowsPathExtensions)) { return candidate; } } return null; } const pathValue = resolvePathEnvironmentVariable(env); if (pathValue.length === 0) { return null; } const cacheKey = [platform, pathValue, windowsPathExtensions.join(";"), command].join( COMMAND_RESOLUTION_CACHE_KEY_SEPARATOR, ); const cache = yield* CommandResolutionCache; const nowNanos = yield* Clock.monotonicTimeNanos; const cached = cache.get(cacheKey); if (cached !== undefined && cached.expiresAtNanos > nowNanos) { return cached.resolvedPath; } // Keep case variants: Windows can make PATH directories case-sensitive. const pathEntries: string[] = []; const seenPathEntries = new Set(); for (const entry of pathValue.split(pathDelimiterForPlatform(platform))) { const pathEntry = stripWrappingQuotes(entry.trim()); if (pathEntry.length === 0 || seenPathEntries.has(pathEntry)) continue; seenPathEntries.add(pathEntry); pathEntries.push(pathEntry); } const listings = yield* PathDirectoryListings; for (const pathEntry of pathEntries) { let listing = listings && (yield* Cache.get(listings, pathEntry)); if (listings && listing?.names && listing.modified !== (yield* directoryMtime(pathEntry))) { yield* Cache.invalidate(listings, pathEntry); listing = yield* Cache.get(listings, pathEntry); } for (const candidate of commandCandidates) { if (listing?.names && !listing.names.has(candidate.toLowerCase())) continue; const candidatePath = path.join(pathEntry, candidate); if (yield* isExecutableFile(candidatePath, platform, windowsPathExtensions)) { cacheCommandResolution(cache, cacheKey, candidatePath, nowNanos); return candidatePath; } } } cacheCommandResolution(cache, cacheKey, null, nowNanos); return null; }); export const resolveCommandPath = Effect.fn("shell.resolveCommandPath")(function* ( command: string, options: CommandAvailabilityOptions = {}, ) { return yield* resolveCommandPathForPlatform(command, { env: options.env ?? (yield* HostProcess.Environment), platform: yield* HostProcess.Platform, }); }); // Git 2.56 x64 uses ucrt64; ARM64 uses clangarm64; older x64/32-bit builds use mingw64/mingw32. const GIT_FOR_WINDOWS_BUILDS = ["ucrt64", "clangarm64", "mingw64", "mingw32"] as const; function isFileSync(filePath: string): boolean { try { return NodeFS.statSync(filePath).isFile(); } catch { return false; } } /** Bypass the launcher's extra process and kernel-token leak. Retain it when MSYSTEM is set, or git.exe omits hook/helper PATH entries. */ export function preferGitForWindowsBinary( executable: string, env: NodeJS.ProcessEnv, isFile: (filePath: string) => boolean = isFileSync, ): string { if (env.MSYSTEM) return executable; const path = NodePath.win32; if (path.basename(executable).toLowerCase() !== "git.exe") return executable; const launcherDirectory = path.dirname(executable); const launcherFolder = path.basename(launcherDirectory).toLowerCase(); if (launcherFolder !== "cmd" && launcherFolder !== "bin") return executable; const installRoot = path.dirname(launcherDirectory); for (const build of GIT_FOR_WINDOWS_BUILDS) { const candidate = path.join(installRoot, build, "bin", "git.exe"); if (isFile(candidate)) return candidate; } return executable; } // npm cmd-shim format for native executables. const NPM_EXECUTABLE_SHIM_PREFIX = [ "@ECHO off", "GOTO start", ":find_dp0", "SET dp0=%~dp0", "EXIT /b", ":start", "SETLOCAL", "CALL :find_dp0", ]; const NPM_EXECUTABLE_SHIM_RUN = /^"%dp0%\\([^"%]+\.exe)" +%\*$/; function readFileSyncOrUndefined(filePath: string): string | undefined { try { return NodeFS.readFileSync(filePath, "utf8"); } catch { return undefined; } } /** Resolve shims that only launch native executables so direct spawning keeps them in libuv's kill-on-close job. */ export function npmShimExecutable( shimPath: string, readFile: (filePath: string) => string | undefined = readFileSyncOrUndefined, isFile: (filePath: string) => boolean = isFileSync, ): string | undefined { const path = NodePath.win32; if (path.extname(shimPath).toLowerCase() !== ".cmd") return undefined; // Match exactly because preserved SET whitespace can select a different file. const lines = readFile(shimPath) ?.split(/\r?\n/) .filter((line) => line.length > 0); if (lines?.length !== NPM_EXECUTABLE_SHIM_PREFIX.length + 1) return undefined; if (NPM_EXECUTABLE_SHIM_PREFIX.some((expected, index) => lines[index] !== expected)) { return undefined; } const target = NPM_EXECUTABLE_SHIM_RUN.exec(lines.at(-1)!)?.[1]; if (target === undefined) return undefined; const executable = path.join(path.dirname(shimPath), target); return isFile(executable) ? executable : undefined; } export type XcodeGitLocator = (env: NodeJS.ProcessEnv) => string | undefined; // xcrun honors DEVELOPER_DIR and the `xcode-select` choice, as the shim does. function locateXcodeGitWithXcrun(env: NodeJS.ProcessEnv): string | undefined { try { const located = NodeChildProcess.execFileSync("/usr/bin/xcrun", ["--find", "git"], { encoding: "utf8", env, timeout: 5_000, stdio: ["ignore", "pipe", "ignore"], }).trim(); return NodePath.isAbsolute(located) ? located : undefined; } catch { return undefined; } } export const XcodeGitLocation = Context.Reference( "@t3tools/shared/shell/XcodeGitLocation", { defaultValue: () => locateXcodeGitWithXcrun }, ); const MACOS_GIT_SHIM = "/usr/bin/git"; /** * Skip the xcrun shim's per-spawn lookup only when git resolves to /usr/bin/git. * User-selected git on PATH still wins, children keep PATH, and lookup failures use `git`. */ const resolveMacosGit = Effect.fnUntraced(function* ( env: NodeJS.ProcessEnv, resolveExecutable: SpawnExecutableResolver, ) { const locateXcodeGit = yield* XcodeGitLocation; const cache = yield* CommandResolutionCache; const cacheKey = [ "xcode-git", String(spawnResolverCacheId(resolveExecutable)), String(spawnResolverCacheId(locateXcodeGit)), resolvePathEnvironmentVariable(env), env.DEVELOPER_DIR ?? "", ].join(COMMAND_RESOLUTION_CACHE_KEY_SEPARATOR); const nowNanos = yield* Clock.currentTimeNanos; const cached = cache.get(cacheKey); // An Xcode update or removal deletes the binary; look again instead of failing the spawn. if ( cached !== undefined && cached.expiresAtNanos > nowNanos && (cached.resolvedPath === null || resolveExecutable(cached.resolvedPath, "darwin", env) !== undefined) ) { return cached.resolvedPath ?? "git"; } const located = resolveExecutable("git", "darwin", env) === MACOS_GIT_SHIM ? locateXcodeGit(env) : undefined; const realGit = located !== undefined && resolveExecutable(located, "darwin", env) !== undefined ? located : null; cacheCommandResolution(cache, cacheKey, realGit, nowNanos); return realGit ?? "git"; }); export interface SpawnCommandOptions extends CommandAvailabilityOptions { /** Bypass npm cmd-shims; see npmShimExecutable for process-lifetime semantics. */ readonly bypassNpmShim?: boolean; } export const resolveSpawnCommand = Effect.fnUntraced(function* ( command: string, args: ReadonlyArray, options: SpawnCommandOptions = {}, ): Effect.fn.Return { const platform = yield* HostProcess.Platform; const isMacosGit = platform === "darwin" && command === "git"; if (platform !== "win32" && !isMacosGit) { return { command, args: [...args], shell: false }; } const hostEnvironment = yield* HostProcess.Environment; const env = options.env === undefined ? hostEnvironment : options.extendEnv ? { ...hostEnvironment, ...options.env } : options.env; const resolveExecutable = yield* SpawnExecutableResolution; if (isMacosGit) { return { command: yield* resolveMacosGit(env, resolveExecutable), args: [...args], shell: false, }; } // The scan is synchronous and runs before every child process, so it shares // the PATH scan cache above. Explicit paths stay uncached for the same reason, // and so do misses: a failed spawn is how providers report "not installed", // and that has to clear the moment the binary appears. const explicitPath = command.includes("/") || command.includes("\\"); const cache = yield* CommandResolutionCache; const cacheKey = [ "spawn", String(spawnResolverCacheId(resolveExecutable)), platform, resolvePathEnvironmentVariable(env), resolveWindowsPathExtensions(env).join(";"), env.MSYSTEM ? "msystem" : "", command, ].join(COMMAND_RESOLUTION_CACHE_KEY_SEPARATOR); const nowNanos = yield* Clock.currentTimeNanos; const cached = explicitPath ? undefined : cache.get(cacheKey); let resolvedExecutable: string | null; if (cached !== undefined && cached.expiresAtNanos > nowNanos) { resolvedExecutable = cached.resolvedPath; } else { const found = resolveExecutable(command, platform, env); resolvedExecutable = found === undefined ? null : preferGitForWindowsBinary(found, env); if (!explicitPath && resolvedExecutable !== null) { cacheCommandResolution(cache, cacheKey, resolvedExecutable, nowNanos); } } const resolvedCommand = resolvedExecutable ?? command; const extension = NodePath.win32.extname(resolvedCommand).toLowerCase(); if (extension !== ".cmd" && extension !== ".bat") { return { command: resolvedCommand, args: [...args], shell: false }; } const shimExecutable = options.bypassNpmShim ? npmShimExecutable(resolvedCommand) : undefined; if (shimExecutable !== undefined) { return { command: shimExecutable, args: [...args], shell: false }; } // Build the escaped command line without separate args to avoid Node DEP0190 with shell:true. return { command: [resolvedCommand, ...args].map(escapeWindowsShellArg).join(" "), args: [], shell: true, }; }); export const isCommandAvailable = Effect.fn("shell.isCommandAvailable")(function* ( command: string, options: CommandAvailabilityOptions = {}, ) { return (yield* resolveCommandPath(command, options)) !== null; }); 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}\\.local\\bin`, `${userProfile}\\.bun\\bin`, `${userProfile}\\scoop\\shims`] : []), ]; } /** Keep one PATH spelling in copied Windows environment records. */ export function setWindowsPath(env: NodeJS.ProcessEnv, value: string | null): void { for (const key of Object.keys(env)) { if (key.toUpperCase() === "PATH") { delete env[key]; } } if (value !== null) env.PATH = value; } 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) continue; if (key === "PATH") { setWindowsPath(nextEnv, value); } else { nextEnv[key] = value; } } return nextEnv; } export const resolveWindowsEnvironment = Effect.fn("shell.resolveWindowsEnvironment")(function* ( env: NodeJS.ProcessEnv, ): Effect.fn.Return, never, FileSystem.FileSystem | Path.Path> { const readEnvironment = yield* WindowsShellEnvironment; const commandAvailable = yield* CommandAvailability; const inheritedPath = readEnvPath(env); const shellPath = (yield* readEnvironment(["PATH"], { loadProfile: false })).PATH; const mergedPath = mergePathValues(shellPath, inheritedPath, "win32"); const knownCliPath = resolveKnownWindowsCliDirs(env).join(WINDOWS_PATH_DELIMITER); const baselinePath = mergePathValues(mergedPath, knownCliPath, "win32"); const baselinePatch: Partial = baselinePath ? { PATH: baselinePath } : {}; const baselineEnv = mergeWindowsEnv(env, baselinePatch); if (yield* commandAvailable("node", { env: baselineEnv })) { return baselinePatch; } const profiledEnvironment = yield* 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; }); export function applyShellEnvironment(env: NodeJS.ProcessEnv, patch: ShellEnvironmentPatch): void { for (const [key, value] of Object.entries(patch)) { if (key === "PATH") setWindowsPath(env, value); else if (value === null) delete env[key]; else env[key] = value; } }