1// @effect-diagnostics nodeBuiltinImport:off
2import * as NodeOS from "node:os";
3import * as NodePath from "node:path";
4import * as NodeChildProcess from "node:child_process";
5import * as NodeFS from "node:fs";
6import * as Cache from "effect/Cache";
7import * as Clock from "effect/Clock";
8import * as Effect from "effect/Effect";
9import * as FileSystem from "effect/FileSystem";
10import * as Option from "effect/Option";
11import * as Path from "effect/Path";
12
13import * as HostProcess from "./HostProcess.ts";
14import * as Context from "effect/Context";
15const SHELL_ENV_NAME_PATTERN = /^[A-Z0-9_]+$/;
16const WINDOWS_PATH_DELIMITER = ";";
17const POSIX_PATH_DELIMITER = ":";
18const WINDOWS_SHELL_CANDIDATES = ["pwsh.exe", "powershell.exe"] as const;
19
20type ExecFileSyncLike = (
21 file: string,
22 args: ReadonlyArray<string>,
23 options: { encoding: "utf8"; timeout: number },
24) => string;
25
26function 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
35export interface CommandAvailabilityOptions {
36 readonly env?: NodeJS.ProcessEnv;
37 readonly extendEnv?: boolean;
38}
39
40export type CommandAvailabilityChecker = (
41 command: string,
42 options?: CommandAvailabilityOptions,
43) => Effect.Effect<boolean, never, FileSystem.FileSystem | Path.Path>;
44
45const 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 */
54function 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
66export interface ResolvedSpawnCommand {
67 readonly command: string;
68 readonly args: ReadonlyArray<string>;
69 readonly shell: boolean;
70}
71
72export type SpawnExecutableResolver = (
73 command: string,
74 platform: NodeJS.Platform,
75 env: NodeJS.ProcessEnv,
76) => string | undefined;
77
78function 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
118export const SpawnExecutableResolution = Context.Reference<SpawnExecutableResolver>(
119 "@t3tools/shared/shell/SpawnExecutableResolution",
120 {
121 defaultValue: () => resolveSpawnExecutableWithNode,
122 },
123);
124
125export interface WindowsEnvironmentProbeOptions {
126 readonly loadProfile?: boolean;
127}
128
129function trimNonEmpty(value: string | null | undefined): string | undefined {
130 const trimmed = value?.trim();
131 return trimmed && trimmed.length > 0 ? trimmed : undefined;
132}
133
134function readUserLoginShell(): string | undefined {
135 try {
136 return trimNonEmpty(NodeOS.userInfo().shell);
137 } catch {
138 return undefined;
139 }
140}
141
142export 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
163export 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
178export 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
202function envCaptureStart(name: string): string {
203 return `__T3CODE_ENV_${name}_START__`;
204}
205
206function envCaptureEnd(name: string): string {
207 return `__T3CODE_ENV_${name}_END__`;
208}
209
210function 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
226function 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
244function 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
262export type ShellEnvironmentReader = (
263 shell: string,
264 names: ReadonlyArray<string>,
265 execFile?: ExecFileSyncLike,
266) => Partial<Record<string, string>>;
267
268export 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
293export type WindowsShellEnvironmentReader = (
294 names: ReadonlyArray<string>,
295 options?: WindowsEnvironmentProbeOptions,
296) => Partial<Record<string, string>>;
297
298export const WindowsShellEnvironment = Context.Reference<WindowsShellEnvironmentReader>(
299 "@t3tools/shared/shell/WindowsShellEnvironment",
300 {
301 defaultValue: () => readEnvironmentFromWindowsShell,
302 },
303);
304
305export const CommandAvailability = Context.Reference<CommandAvailabilityChecker>(
306 "@t3tools/shared/shell/CommandAvailability",
307 {
308 defaultValue: () => isCommandAvailable,
309 },
310);
311
312export function readEnvironmentFromWindowsShell(
313 names: ReadonlyArray<string>,
314 execFile?: ExecFileSyncLike,
315): Partial<Record<string, string>>;
316export function readEnvironmentFromWindowsShell(
317 names: ReadonlyArray<string>,
318 options?: WindowsEnvironmentProbeOptions,
319 execFile?: ExecFileSyncLike,
320): Partial<Record<string, string>>;
321export 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
366function stripWrappingQuotes(value: string): string {
367 return value.replace(/^"+|"+$/g, "");
368}
369
370function pathDelimiterForPlatform(platform: NodeJS.Platform): string {
371 return platform === "win32" ? WINDOWS_PATH_DELIMITER : POSIX_PATH_DELIMITER;
372}
373
374function normalizePathEntryForComparison(entry: string, platform: NodeJS.Platform): string {
375 const normalized = stripWrappingQuotes(entry.trim());
376 return platform === "win32" ? normalized.toLowerCase() : normalized;
377}
378
379function sanitizePathEntry(entry: string, platform: NodeJS.Platform): string {
380 return platform === "win32" ? entry.replaceAll('"', "") : entry;
381}
382
383export 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
410function readEnvPath(env: NodeJS.ProcessEnv): string | undefined {
411 return env.PATH ?? env.Path ?? env.path;
412}
413
414function resolvePathEnvironmentVariable(env: NodeJS.ProcessEnv): string {
415 return readEnvPath(env) ?? "";
416}
417
418function 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
432function 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.
472const COMMAND_RESOLUTION_CACHE_TTL_NANOS = 30_000_000_000n;
473const COMMAND_RESOLUTION_CACHE_MAX_ENTRIES = 512;
474const COMMAND_RESOLUTION_CACHE_KEY_SEPARATOR = String.fromCharCode(0);
475
476interface 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.
484export const CommandResolutionCache = Context.Reference<Map<string, CommandResolutionCacheEntry>>(
485 "@t3tools/shared/shell/CommandResolutionCache",
486 {
487 defaultValue: () => new Map(),
488 },
489);
490
491interface 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.
497const 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.
507const 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
518const 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 */
527export 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.
539let spawnResolverCacheIdCount = 0;
540const spawnResolverCacheIds = new WeakMap<SpawnExecutableResolver, number>();
541function 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
549function 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.
568const 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
587const 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
658export 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.
670const GIT_FOR_WINDOWS_BUILDS = ["ucrt64", "clangarm64", "mingw64", "mingw32"] as const;
671
672function 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 */
690export 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.
710export 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
774export const isCommandAvailable = Effect.fn("shell.isCommandAvailable")(function* (
775 command: string,
776 options: CommandAvailabilityOptions = {},
777) {
778 return (yield* resolveCommandPath(command, options)) !== null;
779});
780
781export 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
796function 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
808function 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
821export 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});