| 1 | // @effect-diagnostics nodeBuiltinImport:off |
| 2 | import * as NodeOS from "node:os"; |
| 3 | import * as NodePath from "node:path"; |
| 4 | import * as NodeChildProcess from "node:child_process"; |
| 5 | import * as NodeFS from "node:fs"; |
| 6 | import * as Cache from "effect/Cache"; |
| 7 | import * as Clock from "effect/Clock"; |
| 8 | import * as Effect from "effect/Effect"; |
| 9 | import * as FileSystem from "effect/FileSystem"; |
| 10 | import * as Option from "effect/Option"; |
| 11 | import * as Path from "effect/Path"; |
| 12 | |
| 13 | import * as HostProcess from "./HostProcess.ts"; |
| 14 | import * as Context from "effect/Context"; |
| 15 | const SHELL_ENV_NAME_PATTERN = /^[A-Z0-9_]+$/; |
| 16 | const WINDOWS_PATH_DELIMITER = ";"; |
| 17 | const POSIX_PATH_DELIMITER = ":"; |
| 18 | const WINDOWS_SHELL_CANDIDATES = ["pwsh.exe", "powershell.exe"] as const; |
| 19 | |
| 20 | type ExecFileSyncLike = ( |
| 21 | file: string, |
| 22 | args: ReadonlyArray<string>, |
| 23 | options: { encoding: "utf8"; timeout: number }, |
| 24 | ) => string; |
| 25 | |
| 26 | function canExecuteFile(filePath: string): boolean { |
| 27 | try { |
| 28 | NodeFS.accessSync(filePath, NodeFS.constants.X_OK); |
| 29 | return true; |
| 30 | } catch { |
| 31 | return false; |
| 32 | } |
| 33 | } |
| 34 | |
| 35 | export interface CommandAvailabilityOptions { |
| 36 | readonly env?: NodeJS.ProcessEnv; |
| 37 | readonly extendEnv?: boolean; |
| 38 | } |
| 39 | |
| 40 | export type CommandAvailabilityChecker = ( |
| 41 | command: string, |
| 42 | options?: CommandAvailabilityOptions, |
| 43 | ) => Effect.Effect<boolean, never, FileSystem.FileSystem | Path.Path>; |
| 44 | |
| 45 | const WINDOWS_SHELL_META_CHARS = /([()\][%!^"`<>&|;, *?])/g; |
| 46 | |
| 47 | /** |
| 48 | * Escapes a single argument for `cmd.exe` shell mode (`spawn(..., { shell: true })` |
| 49 | * on Windows). Node hands the command string to `cmd.exe /d /s /c "<command>"` |
| 50 | * without any quoting, so every dynamic argument must be escaped to survive both |
| 51 | * cmd.exe parsing and the target program's `CommandLineToArgvW` parsing. |
| 52 | * Mirrors cross-spawn's argument escaping. |
| 53 | */ |
| 54 | function escapeWindowsShellArg(arg: string): string { |
| 55 | // Double up backslashes that precede a double quote, then escape the quote |
| 56 | // itself so it survives CommandLineToArgvW. |
| 57 | let escaped = arg.replace(/(\\*)"/g, '$1$1\\"'); |
| 58 | // Double up trailing backslashes so the closing quote is not escaped away. |
| 59 | escaped = escaped.replace(/(\\*)$/, "$1$1"); |
| 60 | // Quote the whole argument so embedded whitespace is preserved. |
| 61 | escaped = `"${escaped}"`; |
| 62 | // Escape cmd.exe metacharacters so cmd passes them through verbatim. |
| 63 | return escaped.replace(WINDOWS_SHELL_META_CHARS, "^$1"); |
| 64 | } |
| 65 | |
| 66 | export interface ResolvedSpawnCommand { |
| 67 | readonly command: string; |
| 68 | readonly args: ReadonlyArray<string>; |
| 69 | readonly shell: boolean; |
| 70 | } |
| 71 | |
| 72 | export type SpawnExecutableResolver = ( |
| 73 | command: string, |
| 74 | platform: NodeJS.Platform, |
| 75 | env: NodeJS.ProcessEnv, |
| 76 | ) => string | undefined; |
| 77 | |
| 78 | function resolveSpawnExecutableWithNode( |
| 79 | command: string, |
| 80 | platform: NodeJS.Platform, |
| 81 | env: NodeJS.ProcessEnv, |
| 82 | ): string | undefined { |
| 83 | const path = platform === "win32" ? NodePath.win32 : NodePath.posix; |
| 84 | const windowsPathExtensions = platform === "win32" ? resolveWindowsPathExtensions(env) : []; |
| 85 | const candidates = resolveCommandCandidates( |
| 86 | command, |
| 87 | platform, |
| 88 | windowsPathExtensions, |
| 89 | path.extname, |
| 90 | ); |
| 91 | const isExecutable = (candidate: string) => { |
| 92 | try { |
| 93 | if (!NodeFS.statSync(candidate).isFile()) return false; |
| 94 | if (platform === "win32") { |
| 95 | return windowsPathExtensions.includes(path.extname(candidate).toUpperCase()); |
| 96 | } |
| 97 | return canExecuteFile(candidate); |
| 98 | } catch { |
| 99 | return false; |
| 100 | } |
| 101 | }; |
| 102 | |
| 103 | if (command.includes("/") || command.includes("\\")) { |
| 104 | return candidates.find(isExecutable); |
| 105 | } |
| 106 | |
| 107 | for (const pathEntry of (readEnvPath(env) ?? "").split(pathDelimiterForPlatform(platform))) { |
| 108 | const normalizedPathEntry = stripWrappingQuotes(pathEntry.trim()); |
| 109 | if (normalizedPathEntry.length === 0) continue; |
| 110 | for (const candidate of candidates) { |
| 111 | const candidatePath = path.join(normalizedPathEntry, candidate); |
| 112 | if (isExecutable(candidatePath)) return candidatePath; |
| 113 | } |
| 114 | } |
| 115 | return undefined; |
| 116 | } |
| 117 | |
| 118 | export const SpawnExecutableResolution = Context.Reference<SpawnExecutableResolver>( |
| 119 | "@t3tools/shared/shell/SpawnExecutableResolution", |
| 120 | { |
| 121 | defaultValue: () => resolveSpawnExecutableWithNode, |
| 122 | }, |
| 123 | ); |
| 124 | |
| 125 | export interface WindowsEnvironmentProbeOptions { |
| 126 | readonly loadProfile?: boolean; |
| 127 | } |
| 128 | |
| 129 | function trimNonEmpty(value: string | null | undefined): string | undefined { |
| 130 | const trimmed = value?.trim(); |
| 131 | return trimmed && trimmed.length > 0 ? trimmed : undefined; |
| 132 | } |
| 133 | |
| 134 | function readUserLoginShell(): string | undefined { |
| 135 | try { |
| 136 | return trimNonEmpty(NodeOS.userInfo().shell); |
| 137 | } catch { |
| 138 | return undefined; |
| 139 | } |
| 140 | } |
| 141 | |
| 142 | export function listLoginShellCandidates( |
| 143 | platform: NodeJS.Platform, |
| 144 | shell: string | undefined, |
| 145 | userShell = readUserLoginShell(), |
| 146 | ): ReadonlyArray<string> { |
| 147 | const fallbackShell = |
| 148 | platform === "darwin" ? "/bin/zsh" : platform === "linux" ? "/bin/bash" : undefined; |
| 149 | const seen = new Set<string>(); |
| 150 | const candidates: string[] = []; |
| 151 | |
| 152 | for (const candidate of [trimNonEmpty(shell), trimNonEmpty(userShell), fallbackShell]) { |
| 153 | if (!candidate || seen.has(candidate)) { |
| 154 | continue; |
| 155 | } |
| 156 | seen.add(candidate); |
| 157 | candidates.push(candidate); |
| 158 | } |
| 159 | |
| 160 | return candidates; |
| 161 | } |
| 162 | |
| 163 | export function readPathFromLaunchctl( |
| 164 | execFile: ExecFileSyncLike = NodeChildProcess.execFileSync, |
| 165 | ): string | undefined { |
| 166 | try { |
| 167 | return trimNonEmpty( |
| 168 | execFile("/bin/launchctl", ["getenv", "PATH"], { |
| 169 | encoding: "utf8", |
| 170 | timeout: 2000, |
| 171 | }), |
| 172 | ); |
| 173 | } catch { |
| 174 | return undefined; |
| 175 | } |
| 176 | } |
| 177 | |
| 178 | export function mergePathEntries( |
| 179 | preferredPath: string | undefined, |
| 180 | inheritedPath: string | undefined, |
| 181 | platform: NodeJS.Platform, |
| 182 | ): string | undefined { |
| 183 | const delimiter = platform === "win32" ? ";" : ":"; |
| 184 | const merged: string[] = []; |
| 185 | const seen = new Set<string>(); |
| 186 | |
| 187 | for (const pathValue of [preferredPath, inheritedPath]) { |
| 188 | if (!pathValue) continue; |
| 189 | for (const entry of pathValue.split(delimiter)) { |
| 190 | const trimmedEntry = entry.trim(); |
| 191 | if (!trimmedEntry || seen.has(trimmedEntry)) { |
| 192 | continue; |
| 193 | } |
| 194 | seen.add(trimmedEntry); |
| 195 | merged.push(trimmedEntry); |
| 196 | } |
| 197 | } |
| 198 | |
| 199 | return merged.length > 0 ? merged.join(delimiter) : undefined; |
| 200 | } |
| 201 | |
| 202 | function envCaptureStart(name: string): string { |
| 203 | return `__T3CODE_ENV_${name}_START__`; |
| 204 | } |
| 205 | |
| 206 | function envCaptureEnd(name: string): string { |
| 207 | return `__T3CODE_ENV_${name}_END__`; |
| 208 | } |
| 209 | |
| 210 | function buildEnvironmentCaptureCommand(names: ReadonlyArray<string>): string { |
| 211 | return names |
| 212 | .map((name) => { |
| 213 | if (!SHELL_ENV_NAME_PATTERN.test(name)) { |
| 214 | throw new Error(`Unsupported environment variable name: ${name}`); |
| 215 | } |
| 216 | |
| 217 | return [ |
| 218 | `printf '%s\\n' '${envCaptureStart(name)}'`, |
| 219 | `printenv ${name} || true`, |
| 220 | `printf '%s\\n' '${envCaptureEnd(name)}'`, |
| 221 | ].join("; "); |
| 222 | }) |
| 223 | .join("; "); |
| 224 | } |
| 225 | |
| 226 | function buildWindowsEnvironmentCaptureCommand(names: ReadonlyArray<string>): string { |
| 227 | return [ |
| 228 | "$ErrorActionPreference = 'Stop'", |
| 229 | ...names.flatMap((name) => { |
| 230 | if (!SHELL_ENV_NAME_PATTERN.test(name)) { |
| 231 | throw new Error(`Unsupported environment variable name: ${name}`); |
| 232 | } |
| 233 | |
| 234 | return [ |
| 235 | `Write-Output '${envCaptureStart(name)}'`, |
| 236 | `$value = [Environment]::GetEnvironmentVariable('${name}')`, |
| 237 | "if ($null -ne $value -and $value.Length -gt 0) { Write-Output $value }", |
| 238 | `Write-Output '${envCaptureEnd(name)}'`, |
| 239 | ]; |
| 240 | }), |
| 241 | ].join("; "); |
| 242 | } |
| 243 | |
| 244 | function extractEnvironmentValue(output: string, name: string): string | undefined { |
| 245 | const startMarker = envCaptureStart(name); |
| 246 | const endMarker = envCaptureEnd(name); |
| 247 | const startIndex = output.indexOf(startMarker); |
| 248 | if (startIndex === -1) return undefined; |
| 249 | |
| 250 | const valueStartIndex = startIndex + startMarker.length; |
| 251 | const endIndex = output.indexOf(endMarker, valueStartIndex); |
| 252 | if (endIndex === -1) return undefined; |
| 253 | |
| 254 | const value = output |
| 255 | .slice(valueStartIndex, endIndex) |
| 256 | .replace(/^\r?\n/, "") |
| 257 | .replace(/\r?\n$/, ""); |
| 258 | |
| 259 | return value.length > 0 ? value : undefined; |
| 260 | } |
| 261 | |
| 262 | export type ShellEnvironmentReader = ( |
| 263 | shell: string, |
| 264 | names: ReadonlyArray<string>, |
| 265 | execFile?: ExecFileSyncLike, |
| 266 | ) => Partial<Record<string, string>>; |
| 267 | |
| 268 | export const readEnvironmentFromLoginShell: ShellEnvironmentReader = ( |
| 269 | shell, |
| 270 | names, |
| 271 | execFile = NodeChildProcess.execFileSync, |
| 272 | ) => { |
| 273 | if (names.length === 0) { |
| 274 | return {}; |
| 275 | } |
| 276 | |
| 277 | const output = execFile(shell, ["-ilc", buildEnvironmentCaptureCommand(names)], { |
| 278 | encoding: "utf8", |
| 279 | timeout: 5000, |
| 280 | }); |
| 281 | |
| 282 | const environment: Partial<Record<string, string>> = {}; |
| 283 | for (const name of names) { |
| 284 | const value = extractEnvironmentValue(output, name); |
| 285 | if (value !== undefined) { |
| 286 | environment[name] = value; |
| 287 | } |
| 288 | } |
| 289 | |
| 290 | return environment; |
| 291 | }; |
| 292 | |
| 293 | export type WindowsShellEnvironmentReader = ( |
| 294 | names: ReadonlyArray<string>, |
| 295 | options?: WindowsEnvironmentProbeOptions, |
| 296 | ) => Partial<Record<string, string>>; |
| 297 | |
| 298 | export const WindowsShellEnvironment = Context.Reference<WindowsShellEnvironmentReader>( |
| 299 | "@t3tools/shared/shell/WindowsShellEnvironment", |
| 300 | { |
| 301 | defaultValue: () => readEnvironmentFromWindowsShell, |
| 302 | }, |
| 303 | ); |
| 304 | |
| 305 | export const CommandAvailability = Context.Reference<CommandAvailabilityChecker>( |
| 306 | "@t3tools/shared/shell/CommandAvailability", |
| 307 | { |
| 308 | defaultValue: () => isCommandAvailable, |
| 309 | }, |
| 310 | ); |
| 311 | |
| 312 | export function readEnvironmentFromWindowsShell( |
| 313 | names: ReadonlyArray<string>, |
| 314 | execFile?: ExecFileSyncLike, |
| 315 | ): Partial<Record<string, string>>; |
| 316 | export function readEnvironmentFromWindowsShell( |
| 317 | names: ReadonlyArray<string>, |
| 318 | options?: WindowsEnvironmentProbeOptions, |
| 319 | execFile?: ExecFileSyncLike, |
| 320 | ): Partial<Record<string, string>>; |
| 321 | export function readEnvironmentFromWindowsShell( |
| 322 | names: ReadonlyArray<string>, |
| 323 | optionsOrExecFile?: WindowsEnvironmentProbeOptions | ExecFileSyncLike, |
| 324 | maybeExecFile?: ExecFileSyncLike, |
| 325 | ): Partial<Record<string, string>> { |
| 326 | if (names.length === 0) { |
| 327 | return {}; |
| 328 | } |
| 329 | |
| 330 | const options = |
| 331 | typeof optionsOrExecFile === "function" |
| 332 | ? ({} satisfies WindowsEnvironmentProbeOptions) |
| 333 | : (optionsOrExecFile ?? {}); |
| 334 | const execFile: ExecFileSyncLike = |
| 335 | typeof optionsOrExecFile === "function" |
| 336 | ? optionsOrExecFile |
| 337 | : (maybeExecFile ?? (NodeChildProcess.execFileSync as ExecFileSyncLike)); |
| 338 | const command = buildWindowsEnvironmentCaptureCommand(names); |
| 339 | const args = [ |
| 340 | "-NoLogo", |
| 341 | ...(options.loadProfile ? ([] as const) : (["-NoProfile"] as const)), |
| 342 | "-NonInteractive", |
| 343 | "-Command", |
| 344 | command, |
| 345 | ]; |
| 346 | for (const shell of WINDOWS_SHELL_CANDIDATES) { |
| 347 | try { |
| 348 | const output = execFile(shell, args, { encoding: "utf8", timeout: 5000 }); |
| 349 | |
| 350 | const environment: Partial<Record<string, string>> = {}; |
| 351 | for (const name of names) { |
| 352 | const value = extractEnvironmentValue(output, name); |
| 353 | if (value !== undefined) { |
| 354 | environment[name] = value; |
| 355 | } |
| 356 | } |
| 357 | return environment; |
| 358 | } catch { |
| 359 | continue; |
| 360 | } |
| 361 | } |
| 362 | |
| 363 | return {}; |
| 364 | } |
| 365 | |
| 366 | function stripWrappingQuotes(value: string): string { |
| 367 | return value.replace(/^"+|"+$/g, ""); |
| 368 | } |
| 369 | |
| 370 | function pathDelimiterForPlatform(platform: NodeJS.Platform): string { |
| 371 | return platform === "win32" ? WINDOWS_PATH_DELIMITER : POSIX_PATH_DELIMITER; |
| 372 | } |
| 373 | |
| 374 | function normalizePathEntryForComparison(entry: string, platform: NodeJS.Platform): string { |
| 375 | const normalized = stripWrappingQuotes(entry.trim()); |
| 376 | return platform === "win32" ? normalized.toLowerCase() : normalized; |
| 377 | } |
| 378 | |
| 379 | function sanitizePathEntry(entry: string, platform: NodeJS.Platform): string { |
| 380 | return platform === "win32" ? entry.replaceAll('"', "") : entry; |
| 381 | } |
| 382 | |
| 383 | export function mergePathValues( |
| 384 | preferredPath: string | undefined, |
| 385 | inheritedPath: string | undefined, |
| 386 | platform: NodeJS.Platform, |
| 387 | ): string | undefined { |
| 388 | const delimiter = pathDelimiterForPlatform(platform); |
| 389 | const merged: string[] = []; |
| 390 | const seen = new Set<string>(); |
| 391 | |
| 392 | for (const rawValue of [preferredPath, inheritedPath]) { |
| 393 | if (!rawValue) continue; |
| 394 | |
| 395 | for (const entry of rawValue.split(delimiter)) { |
| 396 | const sanitized = sanitizePathEntry(entry.trim(), platform); |
| 397 | if (sanitized.length === 0) continue; |
| 398 | |
| 399 | const normalized = normalizePathEntryForComparison(sanitized, platform); |
| 400 | if (normalized.length === 0 || seen.has(normalized)) continue; |
| 401 | |
| 402 | seen.add(normalized); |
| 403 | merged.push(sanitized); |
| 404 | } |
| 405 | } |
| 406 | |
| 407 | return merged.length > 0 ? merged.join(delimiter) : undefined; |
| 408 | } |
| 409 | |
| 410 | function readEnvPath(env: NodeJS.ProcessEnv): string | undefined { |
| 411 | return env.PATH ?? env.Path ?? env.path; |
| 412 | } |
| 413 | |
| 414 | function resolvePathEnvironmentVariable(env: NodeJS.ProcessEnv): string { |
| 415 | return readEnvPath(env) ?? ""; |
| 416 | } |
| 417 | |
| 418 | function resolveWindowsPathExtensions(env: NodeJS.ProcessEnv): ReadonlyArray<string> { |
| 419 | const rawValue = env.PATHEXT; |
| 420 | const fallback = [".COM", ".EXE", ".BAT", ".CMD"]; |
| 421 | if (!rawValue) return fallback; |
| 422 | |
| 423 | const parsed: string[] = []; |
| 424 | for (const entry of rawValue.split(";")) { |
| 425 | const trimmed = entry.trim(); |
| 426 | if (trimmed.length === 0) continue; |
| 427 | parsed.push(trimmed.startsWith(".") ? trimmed.toUpperCase() : `.${trimmed.toUpperCase()}`); |
| 428 | } |
| 429 | return parsed.length > 0 ? Array.from(new Set(parsed)) : fallback; |
| 430 | } |
| 431 | |
| 432 | function resolveCommandCandidates( |
| 433 | command: string, |
| 434 | platform: NodeJS.Platform, |
| 435 | windowsPathExtensions: ReadonlyArray<string>, |
| 436 | extname: (path: string) => string, |
| 437 | ): ReadonlyArray<string> { |
| 438 | if (platform !== "win32") return [command]; |
| 439 | const extension = extname(command); |
| 440 | const normalizedExtension = extension.toUpperCase(); |
| 441 | |
| 442 | if (extension.length > 0 && windowsPathExtensions.includes(normalizedExtension)) { |
| 443 | const commandWithoutExtension = command.slice(0, -extension.length); |
| 444 | return Array.from( |
| 445 | new Set([ |
| 446 | command, |
| 447 | `${commandWithoutExtension}${normalizedExtension}`, |
| 448 | `${commandWithoutExtension}${normalizedExtension.toLowerCase()}`, |
| 449 | ]), |
| 450 | ); |
| 451 | } |
| 452 | |
| 453 | const candidates: string[] = []; |
| 454 | for (const candidateExtension of windowsPathExtensions) { |
| 455 | candidates.push(`${command}${candidateExtension}`); |
| 456 | candidates.push(`${command}${candidateExtension.toLowerCase()}`); |
| 457 | } |
| 458 | return Array.from(new Set(candidates)); |
| 459 | } |
| 460 | |
| 461 | // Session bootstrap resolves the same commands over and over, each PATH scan |
| 462 | // costing hundreds of 'shell.isExecutableFile' filesystem probes (tens of |
| 463 | // thousands per connect). Memoize the scan outcome per |
| 464 | // (platform, PATH, PATHEXT, command) for a short window: repeat scans hit the |
| 465 | // cache while any change to the search environment invalidates immediately. |
| 466 | // Explicit-path resolution is never cached - callers probe paths they have |
| 467 | // just written (e.g. managed binary installs). A "not-found" outcome is also |
| 468 | // cached for the TTL, so a just-installed binary can stay invisible for up to |
| 469 | // 30s unless resolved by explicit path. |
| 470 | // TTL expiry uses the monotonic clock (Clock.monotonicTimeNanos) so backward |
| 471 | // wall-clock adjustments cannot keep expired entries alive. |
| 472 | const COMMAND_RESOLUTION_CACHE_TTL_NANOS = 30_000_000_000n; |
| 473 | const COMMAND_RESOLUTION_CACHE_MAX_ENTRIES = 512; |
| 474 | const COMMAND_RESOLUTION_CACHE_KEY_SEPARATOR = String.fromCharCode(0); |
| 475 | |
| 476 | interface CommandResolutionCacheEntry { |
| 477 | readonly resolvedPath: string | null; |
| 478 | readonly expiresAtNanos: bigint; |
| 479 | } |
| 480 | |
| 481 | // The cache lives in the Effect environment (like HostProcess.Platform above) |
| 482 | // so tests and embedders can provide an isolated instance; the default is a |
| 483 | // single process-wide map shared by all consumers. |
| 484 | export const CommandResolutionCache = Context.Reference<Map<string, CommandResolutionCacheEntry>>( |
| 485 | "@t3tools/shared/shell/CommandResolutionCache", |
| 486 | { |
| 487 | defaultValue: () => new Map(), |
| 488 | }, |
| 489 | ); |
| 490 | |
| 491 | interface PathDirectoryListing { |
| 492 | readonly modified: number | null | undefined; |
| 493 | readonly names: ReadonlySet<string> | undefined; |
| 494 | } |
| 495 | |
| 496 | // mtime of a PATH directory; null when missing, undefined when unreadable. |
| 497 | const directoryMtime = (directory: string) => |
| 498 | FileSystem.FileSystem.use((fileSystem) => fileSystem.stat(directory)).pipe( |
| 499 | Effect.map((info) => |
| 500 | info.type === "Directory" ? Option.getOrUndefined(info.mtime)?.getTime() : undefined, |
| 501 | ), |
| 502 | Effect.catch((error) => Effect.succeed(error.reason._tag === "NotFound" ? null : undefined)), |
| 503 | ); |
| 504 | |
| 505 | // Dated before it is read, so an entry added in between changes the mtime seen |
| 506 | // on the next check. Without names, lookups probe candidates directly. |
| 507 | const listPathDirectory = Effect.fnUntraced(function* ( |
| 508 | directory: string, |
| 509 | ): Effect.fn.Return<PathDirectoryListing, never, FileSystem.FileSystem> { |
| 510 | const modified = yield* directoryMtime(directory); |
| 511 | if (modified == null) return { modified, names: modified === null ? new Set() : undefined }; |
| 512 | const entries = yield* FileSystem.FileSystem.use((fileSystem) => |
| 513 | fileSystem.readDirectory(directory), |
| 514 | ).pipe(Effect.orElseSucceed(() => undefined)); |
| 515 | return { modified, names: entries && new Set(entries.map((entry) => entry.toLowerCase())) }; |
| 516 | }); |
| 517 | |
| 518 | const PathDirectoryListings = Context.Reference< |
| 519 | Cache.Cache<string, PathDirectoryListing, never, FileSystem.FileSystem> | undefined |
| 520 | >("@t3tools/shared/shell/PathDirectoryListings", { defaultValue: () => undefined }); |
| 521 | |
| 522 | /** |
| 523 | * Run a batch of command lookups (e.g. editor discovery) that lists each PATH |
| 524 | * directory once, relisting it if its mtime changes, and probes only listed |
| 525 | * names instead of every PATH x PATHEXT candidate per command. |
| 526 | */ |
| 527 | export const withPathDirectoryListings = <A, E, R>(effect: Effect.Effect<A, E, R>) => |
| 528 | Effect.gen(function* () { |
| 529 | const listings = yield* Cache.make({ |
| 530 | capacity: 1024, |
| 531 | lookup: listPathDirectory, |
| 532 | requireServicesAt: "lookup", |
| 533 | }); |
| 534 | return yield* effect.pipe(Effect.provideService(PathDirectoryListings, listings)); |
| 535 | }); |
| 536 | |
| 537 | // An injected resolver may answer differently for the same search, so its |
| 538 | // entries are kept apart from every other resolver's. |
| 539 | let spawnResolverCacheIdCount = 0; |
| 540 | const spawnResolverCacheIds = new WeakMap<SpawnExecutableResolver, number>(); |
| 541 | function spawnResolverCacheId(resolver: SpawnExecutableResolver): number { |
| 542 | const known = spawnResolverCacheIds.get(resolver); |
| 543 | if (known !== undefined) return known; |
| 544 | const id = spawnResolverCacheIdCount++; |
| 545 | spawnResolverCacheIds.set(resolver, id); |
| 546 | return id; |
| 547 | } |
| 548 | |
| 549 | function cacheCommandResolution( |
| 550 | cache: Map<string, CommandResolutionCacheEntry>, |
| 551 | cacheKey: string, |
| 552 | resolvedPath: string | null, |
| 553 | nowNanos: bigint, |
| 554 | ): void { |
| 555 | if (cache.size >= COMMAND_RESOLUTION_CACHE_MAX_ENTRIES) { |
| 556 | const oldestKey = cache.keys().next().value; |
| 557 | if (oldestKey !== undefined) { |
| 558 | cache.delete(oldestKey); |
| 559 | } |
| 560 | } |
| 561 | cache.set(cacheKey, { |
| 562 | resolvedPath, |
| 563 | expiresAtNanos: nowNanos + COMMAND_RESOLUTION_CACHE_TTL_NANOS, |
| 564 | }); |
| 565 | } |
| 566 | |
| 567 | // Trace each command lookup, not every candidate file it probes. |
| 568 | const isExecutableFile = Effect.fnUntraced(function* ( |
| 569 | filePath: string, |
| 570 | platform: NodeJS.Platform, |
| 571 | windowsPathExtensions: ReadonlyArray<string>, |
| 572 | ): Effect.fn.Return<boolean, never, FileSystem.FileSystem | Path.Path> { |
| 573 | const fileSystem = yield* FileSystem.FileSystem; |
| 574 | const path = yield* Path.Path; |
| 575 | const stat = yield* fileSystem.stat(filePath).pipe(Effect.orElseSucceed(() => null)); |
| 576 | if (stat === null || stat.type !== "File") return false; |
| 577 | |
| 578 | if (platform === "win32") { |
| 579 | const extension = path.extname(filePath); |
| 580 | if (extension.length === 0) return false; |
| 581 | return windowsPathExtensions.includes(extension.toUpperCase()); |
| 582 | } |
| 583 | |
| 584 | return canExecuteFile(filePath); |
| 585 | }); |
| 586 | |
| 587 | const resolveCommandPathForPlatform = Effect.fn("shell.resolveCommandPathForPlatform")(function* ( |
| 588 | command: string, |
| 589 | options: CommandAvailabilityOptions & { readonly platform: NodeJS.Platform }, |
| 590 | ): Effect.fn.Return<string | null, never, FileSystem.FileSystem | Path.Path> { |
| 591 | const path = yield* Path.Path; |
| 592 | const platform = options.platform; |
| 593 | const env = options.env ?? process.env; |
| 594 | const windowsPathExtensions = platform === "win32" ? resolveWindowsPathExtensions(env) : []; |
| 595 | const commandCandidates = resolveCommandCandidates( |
| 596 | command, |
| 597 | platform, |
| 598 | windowsPathExtensions, |
| 599 | path.extname, |
| 600 | ); |
| 601 | |
| 602 | if (command.includes("/") || command.includes("\\")) { |
| 603 | for (const candidate of commandCandidates) { |
| 604 | if (yield* isExecutableFile(candidate, platform, windowsPathExtensions)) { |
| 605 | return candidate; |
| 606 | } |
| 607 | } |
| 608 | return null; |
| 609 | } |
| 610 | |
| 611 | const pathValue = resolvePathEnvironmentVariable(env); |
| 612 | if (pathValue.length === 0) { |
| 613 | return null; |
| 614 | } |
| 615 | |
| 616 | const cacheKey = [platform, pathValue, windowsPathExtensions.join(";"), command].join( |
| 617 | COMMAND_RESOLUTION_CACHE_KEY_SEPARATOR, |
| 618 | ); |
| 619 | const cache = yield* CommandResolutionCache; |
| 620 | const nowNanos = yield* Clock.monotonicTimeNanos; |
| 621 | const cached = cache.get(cacheKey); |
| 622 | if (cached !== undefined && cached.expiresAtNanos > nowNanos) { |
| 623 | return cached.resolvedPath; |
| 624 | } |
| 625 | |
| 626 | // Keep case variants: Windows can make PATH directories case-sensitive. |
| 627 | const pathEntries: string[] = []; |
| 628 | const seenPathEntries = new Set<string>(); |
| 629 | for (const entry of pathValue.split(pathDelimiterForPlatform(platform))) { |
| 630 | const pathEntry = stripWrappingQuotes(entry.trim()); |
| 631 | if (pathEntry.length === 0 || seenPathEntries.has(pathEntry)) continue; |
| 632 | |
| 633 | seenPathEntries.add(pathEntry); |
| 634 | pathEntries.push(pathEntry); |
| 635 | } |
| 636 | |
| 637 | const listings = yield* PathDirectoryListings; |
| 638 | for (const pathEntry of pathEntries) { |
| 639 | let listing = listings && (yield* Cache.get(listings, pathEntry)); |
| 640 | if (listings && listing?.names && listing.modified !== (yield* directoryMtime(pathEntry))) { |
| 641 | yield* Cache.invalidate(listings, pathEntry); |
| 642 | listing = yield* Cache.get(listings, pathEntry); |
| 643 | } |
| 644 | for (const candidate of commandCandidates) { |
| 645 | // The stat below still checks exact case and rejects non-files. |
| 646 | if (listing?.names && !listing.names.has(candidate.toLowerCase())) continue; |
| 647 | const candidatePath = path.join(pathEntry, candidate); |
| 648 | if (yield* isExecutableFile(candidatePath, platform, windowsPathExtensions)) { |
| 649 | cacheCommandResolution(cache, cacheKey, candidatePath, nowNanos); |
| 650 | return candidatePath; |
| 651 | } |
| 652 | } |
| 653 | } |
| 654 | cacheCommandResolution(cache, cacheKey, null, nowNanos); |
| 655 | return null; |
| 656 | }); |
| 657 | |
| 658 | export const resolveCommandPath = Effect.fn("shell.resolveCommandPath")(function* ( |
| 659 | command: string, |
| 660 | options: CommandAvailabilityOptions = {}, |
| 661 | ) { |
| 662 | return yield* resolveCommandPathForPlatform(command, { |
| 663 | env: options.env ?? (yield* HostProcess.Environment), |
| 664 | platform: yield* HostProcess.Platform, |
| 665 | }); |
| 666 | }); |
| 667 | |
| 668 | // Git for Windows 2.56 moved x64 builds from mingw64 to ucrt64; ARM64 builds |
| 669 | // live in clangarm64 and 32-bit ones in mingw32. |
| 670 | const GIT_FOR_WINDOWS_BUILDS = ["ucrt64", "clangarm64", "mingw64", "mingw32"] as const; |
| 671 | |
| 672 | function isFileSync(filePath: string): boolean { |
| 673 | try { |
| 674 | return NodeFS.statSync(filePath).isFile(); |
| 675 | } catch { |
| 676 | return false; |
| 677 | } |
| 678 | } |
| 679 | |
| 680 | /** |
| 681 | * Swaps Git for Windows' launcher (`<Git>\cmd\git.exe`, the only git its |
| 682 | * installer puts on PATH, or the portable build's `<Git>\bin\git.exe`) for the |
| 683 | * git.exe it starts. The launcher costs a second process on every git command, |
| 684 | * and each launch leaks a kernel token reference that slows process creation |
| 685 | * machine-wide until reboot. The real binary sets HOME itself, but adds its own |
| 686 | * folders to PATH for hooks, ssh and credential helpers only when MSYSTEM is |
| 687 | * unset, so the launcher stays when MSYSTEM is set. Anything else is returned |
| 688 | * as is. |
| 689 | */ |
| 690 | export function preferGitForWindowsBinary( |
| 691 | executable: string, |
| 692 | env: NodeJS.ProcessEnv, |
| 693 | isFile: (filePath: string) => boolean = isFileSync, |
| 694 | ): string { |
| 695 | if (env.MSYSTEM) return executable; |
| 696 | const path = NodePath.win32; |
| 697 | if (path.basename(executable).toLowerCase() !== "git.exe") return executable; |
| 698 | const launcherDirectory = path.dirname(executable); |
| 699 | const launcherFolder = path.basename(launcherDirectory).toLowerCase(); |
| 700 | if (launcherFolder !== "cmd" && launcherFolder !== "bin") return executable; |
| 701 | const installRoot = path.dirname(launcherDirectory); |
| 702 | for (const build of GIT_FOR_WINDOWS_BUILDS) { |
| 703 | const candidate = path.join(installRoot, build, "bin", "git.exe"); |
| 704 | if (isFile(candidate)) return candidate; |
| 705 | } |
| 706 | return executable; |
| 707 | } |
| 708 | |
| 709 | // Untraced because it runs before most spawns and returns at once off Windows. |
| 710 | export const resolveSpawnCommand = Effect.fnUntraced(function* ( |
| 711 | command: string, |
| 712 | args: ReadonlyArray<string>, |
| 713 | options: CommandAvailabilityOptions = {}, |
| 714 | ): Effect.fn.Return<ResolvedSpawnCommand> { |
| 715 | const platform = yield* HostProcess.Platform; |
| 716 | if (platform !== "win32") { |
| 717 | return { command, args: [...args], shell: false }; |
| 718 | } |
| 719 | |
| 720 | const hostEnvironment = yield* HostProcess.Environment; |
| 721 | const env = |
| 722 | options.env === undefined |
| 723 | ? hostEnvironment |
| 724 | : options.extendEnv |
| 725 | ? { ...hostEnvironment, ...options.env } |
| 726 | : options.env; |
| 727 | const resolveExecutable = yield* SpawnExecutableResolution; |
| 728 | // The scan is synchronous and runs before every child process, so it shares |
| 729 | // the PATH scan cache above. Explicit paths stay uncached for the same reason, |
| 730 | // and so do misses: a failed spawn is how providers report "not installed", |
| 731 | // and that has to clear the moment the binary appears. |
| 732 | const explicitPath = command.includes("/") || command.includes("\\"); |
| 733 | const cache = yield* CommandResolutionCache; |
| 734 | const cacheKey = [ |
| 735 | "spawn", |
| 736 | String(spawnResolverCacheId(resolveExecutable)), |
| 737 | platform, |
| 738 | resolvePathEnvironmentVariable(env), |
| 739 | resolveWindowsPathExtensions(env).join(";"), |
| 740 | env.MSYSTEM ? "msystem" : "", |
| 741 | command, |
| 742 | ].join(COMMAND_RESOLUTION_CACHE_KEY_SEPARATOR); |
| 743 | const nowNanos = yield* Clock.currentTimeNanos; |
| 744 | const cached = explicitPath ? undefined : cache.get(cacheKey); |
| 745 | let resolvedExecutable: string | null; |
| 746 | if (cached !== undefined && cached.expiresAtNanos > nowNanos) { |
| 747 | resolvedExecutable = cached.resolvedPath; |
| 748 | } else { |
| 749 | // Cached with the scan: its file checks would otherwise run before every |
| 750 | // git launch. A Git upgrade that moves the real binary can fail git for up |
| 751 | // to the cache lifetime. |
| 752 | const found = resolveExecutable(command, platform, env); |
| 753 | resolvedExecutable = found === undefined ? null : preferGitForWindowsBinary(found, env); |
| 754 | if (!explicitPath && resolvedExecutable !== null) { |
| 755 | cacheCommandResolution(cache, cacheKey, resolvedExecutable, nowNanos); |
| 756 | } |
| 757 | } |
| 758 | const resolvedCommand = resolvedExecutable ?? command; |
| 759 | const extension = NodePath.win32.extname(resolvedCommand).toLowerCase(); |
| 760 | if (extension !== ".cmd" && extension !== ".bat") { |
| 761 | return { command: resolvedCommand, args: [...args], shell: false }; |
| 762 | } |
| 763 | |
| 764 | // Node runs a shell spawn as `cmd.exe /d /s /c "<command> <args...>"`, but |
| 765 | // passing args with `shell: true` prints DEP0190. Build the same escaped line |
| 766 | // here and pass no args. |
| 767 | return { |
| 768 | command: [resolvedCommand, ...args].map(escapeWindowsShellArg).join(" "), |
| 769 | args: [], |
| 770 | shell: true, |
| 771 | }; |
| 772 | }); |
| 773 | |
| 774 | export const isCommandAvailable = Effect.fn("shell.isCommandAvailable")(function* ( |
| 775 | command: string, |
| 776 | options: CommandAvailabilityOptions = {}, |
| 777 | ) { |
| 778 | return (yield* resolveCommandPath(command, options)) !== null; |
| 779 | }); |
| 780 | |
| 781 | export function resolveKnownWindowsCliDirs(env: NodeJS.ProcessEnv): ReadonlyArray<string> { |
| 782 | const appData = env.APPDATA?.trim(); |
| 783 | const localAppData = env.LOCALAPPDATA?.trim(); |
| 784 | const userProfile = env.USERPROFILE?.trim(); |
| 785 | |
| 786 | return [ |
| 787 | ...(appData ? [`${appData}\\npm`] : []), |
| 788 | ...(localAppData ? [`${localAppData}\\Programs\\nodejs`, `${localAppData}\\Volta\\bin`] : []), |
| 789 | ...(localAppData ? [`${localAppData}\\pnpm`] : []), |
| 790 | ...(userProfile |
| 791 | ? [`${userProfile}\\.local\\bin`, `${userProfile}\\.bun\\bin`, `${userProfile}\\scoop\\shims`] |
| 792 | : []), |
| 793 | ]; |
| 794 | } |
| 795 | |
| 796 | function readWindowsEnvironmentSafely( |
| 797 | readEnvironment: WindowsShellEnvironmentReader, |
| 798 | names: ReadonlyArray<string>, |
| 799 | options?: WindowsEnvironmentProbeOptions, |
| 800 | ): Partial<Record<string, string>> { |
| 801 | try { |
| 802 | return readEnvironment(names, options); |
| 803 | } catch { |
| 804 | return {}; |
| 805 | } |
| 806 | } |
| 807 | |
| 808 | function mergeWindowsEnv( |
| 809 | currentEnv: NodeJS.ProcessEnv, |
| 810 | patch: Partial<Record<string, string>>, |
| 811 | ): NodeJS.ProcessEnv { |
| 812 | const nextEnv: NodeJS.ProcessEnv = { ...currentEnv }; |
| 813 | for (const [key, value] of Object.entries(patch)) { |
| 814 | if (value !== undefined) { |
| 815 | nextEnv[key] = value; |
| 816 | } |
| 817 | } |
| 818 | return nextEnv; |
| 819 | } |
| 820 | |
| 821 | export const resolveWindowsEnvironment = Effect.fn("shell.resolveWindowsEnvironment")(function* ( |
| 822 | env: NodeJS.ProcessEnv, |
| 823 | ): Effect.fn.Return<Partial<NodeJS.ProcessEnv>, never, FileSystem.FileSystem | Path.Path> { |
| 824 | const readEnvironment = yield* WindowsShellEnvironment; |
| 825 | const commandAvailable = yield* CommandAvailability; |
| 826 | const inheritedPath = readEnvPath(env); |
| 827 | const shellPath = readWindowsEnvironmentSafely(readEnvironment, ["PATH"], { |
| 828 | loadProfile: false, |
| 829 | }).PATH; |
| 830 | const mergedPath = mergePathValues(shellPath, inheritedPath, "win32"); |
| 831 | const knownCliPath = resolveKnownWindowsCliDirs(env).join(WINDOWS_PATH_DELIMITER); |
| 832 | // Preserve the order a user's shell uses. These directories fill gaps when |
| 833 | // desktop apps launch without the full interactive-shell PATH. |
| 834 | const baselinePath = mergePathValues(mergedPath, knownCliPath, "win32"); |
| 835 | const baselinePatch: Partial<NodeJS.ProcessEnv> = baselinePath ? { PATH: baselinePath } : {}; |
| 836 | const baselineEnv = mergeWindowsEnv(env, baselinePatch); |
| 837 | |
| 838 | if (yield* commandAvailable("node", { env: baselineEnv })) { |
| 839 | return baselinePatch; |
| 840 | } |
| 841 | |
| 842 | const profiledEnvironment = readWindowsEnvironmentSafely( |
| 843 | readEnvironment, |
| 844 | ["PATH", "FNM_DIR", "FNM_MULTISHELL_PATH"], |
| 845 | { loadProfile: true }, |
| 846 | ); |
| 847 | const profiledPath = mergePathValues(profiledEnvironment.PATH, baselinePath, "win32"); |
| 848 | const profiledPatch: Partial<NodeJS.ProcessEnv> = { |
| 849 | ...(profiledPath ? { PATH: profiledPath } : {}), |
| 850 | ...(profiledEnvironment.FNM_DIR ? { FNM_DIR: profiledEnvironment.FNM_DIR } : {}), |
| 851 | ...(profiledEnvironment.FNM_MULTISHELL_PATH |
| 852 | ? { FNM_MULTISHELL_PATH: profiledEnvironment.FNM_MULTISHELL_PATH } |
| 853 | : {}), |
| 854 | }; |
| 855 | return Object.keys(profiledPatch).length > 0 |
| 856 | ? { ...baselinePatch, ...profiledPatch } |
| 857 | : baselinePatch; |
| 858 | }); |