Skip to content
Source
Sign inSign up
Open History to see the last commit

shell.ts

1,109 lines38.2 KB

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 Duration from "effect/Duration";
9import * as Effect from "effect/Effect";
10import * as FileSystem from "effect/FileSystem";
11import * as Option from "effect/Option";
12import * as Path from "effect/Path";
13​
14import type { ShellEnvironmentPatch } from "@t3tools/contracts";
15import * as HostProcess from "./HostProcess.ts";
16import * as Context from "effect/Context";
17const SHELL_ENV_NAME_PATTERN = /^[A-Z0-9_]+$/;
18const WINDOWS_PATH_DELIMITER = ";";
19const POSIX_PATH_DELIMITER = ":";
20const WINDOWS_SHELL_CANDIDATES = ["pwsh.exe", "powershell.exe"] as const;
21const WINDOWS_SHELL_PROBE_TIMEOUT_MS = 5000;
22// A shell stuck in the kernel survives the kill and holds stdout, so execFile's callback never fires.
23const WINDOWS_SHELL_PROBE_DEADLINE = Duration.millis(WINDOWS_SHELL_PROBE_TIMEOUT_MS + 1000);
24​
25type ExecFileSyncLike = (
26 file: string,
27 args: ReadonlyArray<string>,
28 options: {
29 encoding: "utf8";
30 timeout: number;
31 killSignal?: NodeJS.Signals | number;
32 detached?: true;
33 stdio?: ["ignore", "pipe", "ignore"];
34 },
35) => string;
36​
37function canExecuteFile(filePath: string): boolean {
38 try {
39 NodeFS.accessSync(filePath, NodeFS.constants.X_OK);
40 return true;
41 } catch {
42 return false;
43 }
45​
46export interface CommandAvailabilityOptions {
47 readonly env?: NodeJS.ProcessEnv;
48 readonly extendEnv?: boolean;
50​
51export type CommandAvailabilityChecker = (
52 command: string,
53 options?: CommandAvailabilityOptions,
54) => Effect.Effect<boolean, never, FileSystem.FileSystem | Path.Path>;
55​
56const WINDOWS_SHELL_META_CHARS = /([()\][%!^"`<>&|;, *?])/g;
57​
58// Escape dynamic arguments for both cmd.exe and the target's CommandLineToArgvW parsing.
59function escapeWindowsShellArg(arg: string): string {
60 let escaped = arg.replace(/(\\*)"/g, '$1$1\\"');
61 // Double up trailing backslashes so the closing quote is not escaped away.
62 escaped = escaped.replace(/(\\*)$/, "$1$1");
63 escaped = `"${escaped}"`;
64 return escaped.replace(WINDOWS_SHELL_META_CHARS, "^$1");
66​
67export interface ResolvedSpawnCommand {
68 readonly command: string;
69 readonly args: ReadonlyArray<string>;
70 readonly shell: boolean;
72​
73export type SpawnExecutableResolver = (
74 command: string,
75 platform: NodeJS.Platform,
76 env: NodeJS.ProcessEnv,
77) => string | undefined;
78​
79function resolveSpawnExecutableWithNode(
80 command: string,
81 platform: NodeJS.Platform,
82 env: NodeJS.ProcessEnv,
83): string | undefined {
84 const path = platform === "win32" ? NodePath.win32 : NodePath.posix;
85 const windowsPathExtensions = platform === "win32" ? resolveWindowsPathExtensions(env) : [];
86 const candidates = resolveCommandCandidates(
87 command,
88 platform,
89 windowsPathExtensions,
90 path.extname,
91 );
92 const isExecutable = (candidate: string) => {
93 try {
94 if (!NodeFS.statSync(candidate).isFile()) return false;
95 if (platform === "win32") {
96 return windowsPathExtensions.includes(path.extname(candidate).toUpperCase());
97 }
98 return canExecuteFile(candidate);
99 } catch {
100 return false;
101 }
102 };
103​
104 if (command.includes("/") || command.includes("\\")) {
105 return candidates.find(isExecutable);
106 }
107​
108 for (const pathEntry of (readEnvPath(env) ?? "").split(pathDelimiterForPlatform(platform))) {
109 const normalizedPathEntry = stripWrappingQuotes(pathEntry.trim());
110 if (normalizedPathEntry.length === 0) continue;
111 for (const candidate of candidates) {
112 const candidatePath = path.join(normalizedPathEntry, candidate);
113 if (isExecutable(candidatePath)) return candidatePath;
114 }
115 }
116 return undefined;
118​
119export const SpawnExecutableResolution = Context.Reference<SpawnExecutableResolver>(
120 "@t3tools/shared/shell/SpawnExecutableResolution",
121 {
122 defaultValue: () => resolveSpawnExecutableWithNode,
123 },
124);
125​
126export interface WindowsEnvironmentProbeOptions {
127 readonly loadProfile?: boolean;
129​
130function trimNonEmpty(value: string | null | undefined): string | undefined {
131 const trimmed = value?.trim();
132 return trimmed && trimmed.length > 0 ? trimmed : undefined;
134​
135export function readUserLoginShell(): string | undefined {
136 try {
137 return trimNonEmpty(NodeOS.userInfo().shell);
138 } catch {
139 return undefined;
140 }
142​
143export function listLoginShellCandidates(
144 platform: NodeJS.Platform,
145 shell: string | undefined,
146 userShell = readUserLoginShell(),
147): ReadonlyArray<string> {
148 const fallbackShell =
149 platform === "darwin" ? "/bin/zsh" : platform === "linux" ? "/bin/bash" : undefined;
150 const seen = new Set<string>();
151 const candidates: string[] = [];
152​
153 for (const candidate of [trimNonEmpty(shell), trimNonEmpty(userShell), fallbackShell]) {
154 if (!candidate || seen.has(candidate)) {
155 continue;
156 }
157 seen.add(candidate);
158 candidates.push(candidate);
159 }
160​
161 return candidates;
163​
164export function readPathFromLaunchctl(
165 execFile: ExecFileSyncLike = NodeChildProcess.execFileSync,
166): string | undefined {
167 try {
168 return trimNonEmpty(
169 execFile("/bin/launchctl", ["getenv", "PATH"], {
170 encoding: "utf8",
171 timeout: 2000,
172 }),
173 );
174 } catch {
175 return undefined;
176 }
178​
179export function mergePathEntries(
180 preferredPath: string | undefined,
181 inheritedPath: string | undefined,
182 platform: NodeJS.Platform,
183): string | undefined {
184 const delimiter = platform === "win32" ? ";" : ":";
185 const merged: string[] = [];
186 const seen = new Set<string>();
187​
188 for (const pathValue of [preferredPath, inheritedPath]) {
189 if (!pathValue) continue;
190 for (const entry of pathValue.split(delimiter)) {
191 const trimmedEntry = entry.trim();
192 if (!trimmedEntry || seen.has(trimmedEntry)) {
193 continue;
194 }
195 seen.add(trimmedEntry);
196 merged.push(trimmedEntry);
197 }
198 }
199​
200 return merged.length > 0 ? merged.join(delimiter) : undefined;
202​
203function envCaptureStart(name: string): string {
204 return `__T3CODE_ENV_${name}_START__`;
206​
207function envCaptureEnd(name: string): string {
208 return `__T3CODE_ENV_${name}_END__`;
210​
211const DEBUG_TRAP_SHELLS = new Set(["bash", "zsh", "sh", "ksh", "ksh93"]);
212​
213export function buildEnvironmentCaptureCommand(
214 shell: string,
215 names: ReadonlyArray<string>,
216): string {
217 const executableName = NodePath.basename(shell).toLowerCase();
218 const isNushell = executableName.startsWith("nu");
219​
220 const capture = names
221 .map((name) => {
222 if (!SHELL_ENV_NAME_PATTERN.test(name)) {
223 throw new Error(`Unsupported environment variable name: ${name}`);
224 }
225​
226 if (isNushell) {
227 // Without a configured conversion, Nu expands home paths only in a list PATH.
228 const defaultValue =
229 name === "PATH"
230 ? "if ($env.PATH? | describe | str starts-with 'list') { " +
231 "$env.PATH | each {|p| if ($p | str starts-with '~') { " +
232 "$p | path expand --no-symlink } else { $p } } | str join (char esep) " +
233 "} else { $env.PATH? | default '' }"
234 : `$env.${name}? | default ''`;
235 const value =
236 `if $env.${name}? == null { '' } else if ` +
237 `$env.ENV_CONVERSIONS?.${name}?.to_string? != null { ` +
238 `$env.${name} | do $env.ENV_CONVERSIONS.${name}.to_string $env.${name} ` +
239 `} else { ${defaultValue} }`;
240 return [
241 `print '${envCaptureStart(name)}'`,
242 `print (${value})`,
243 `print '${envCaptureEnd(name)}'`,
244 ].join("; ");
245 }
246​
247 return [
248 `printf '%s\\n' '${envCaptureStart(name)}'`,
249 `printenv ${name} || true`,
250 `printf '%s\\n' '${envCaptureEnd(name)}'`,
251 ].join("; ");
252 })
253 .join("; ");
254​
255 // A DEBUG trap that echoes each command (e.g. into the terminal title) would print the markers.
256 // OpenBSD ksh (also its /bin/sh) exits on `trap - DEBUG`, so try it in a subshell first.
257 return DEBUG_TRAP_SHELLS.has(executableName)
258 ? `(trap - DEBUG) 2>/dev/null && trap - DEBUG; ${capture}`
259 : capture;
261​
262function buildWindowsEnvironmentCaptureCommand(names: ReadonlyArray<string>): string {
263 return [
264 "$ErrorActionPreference = 'Stop'",
265 ...names.flatMap((name) => {
266 if (!SHELL_ENV_NAME_PATTERN.test(name)) {
267 throw new Error(`Unsupported environment variable name: ${name}`);
268 }
269​
270 return [
271 `Write-Output '${envCaptureStart(name)}'`,
272 `$value = [Environment]::GetEnvironmentVariable('${name}')`,
273 "if ($null -ne $value -and $value.Length -gt 0) { Write-Output $value }",
274 `Write-Output '${envCaptureEnd(name)}'`,
275 ];
276 }),
277 ].join("; ");
279​
280function extractEnvironmentValue(output: string, name: string): string | undefined {
281 const startMarker = envCaptureStart(name);
282 const endMarker = envCaptureEnd(name);
283 const startIndex = output.indexOf(startMarker);
284 if (startIndex === -1) return undefined;
285​
286 const valueStartIndex = startIndex + startMarker.length;
287 const endIndex = output.indexOf(endMarker, valueStartIndex);
288 if (endIndex === -1) return undefined;
289​
290 const value = output
291 .slice(valueStartIndex, endIndex)
292 .replace(/^\r?\n/, "")
293 .replace(/\r?\n$/, "");
294​
295 return value.length > 0 ? value : undefined;
297​
298/** Reads the values `buildEnvironmentCaptureCommand` printed; unset or empty names are omitted. */
299export function extractEnvironmentValues(
300 output: string,
301 names: ReadonlyArray<string>,
302): Partial<Record<string, string>> {
303 const environment: Partial<Record<string, string>> = {};
304 for (const name of names) {
305 const value = extractEnvironmentValue(output, name);
306 if (value !== undefined) {
307 environment[name] = value;
308 }
309 }
310 return environment;
312​
313export type ShellEnvironmentReader = (
314 shell: string,
315 names: ReadonlyArray<string>,
316 execFile?: ExecFileSyncLike,
317) => Partial<Record<string, string>>;
318​
319export const readEnvironmentFromLoginShell: ShellEnvironmentReader = (
320 shell,
321 names,
322 execFile = NodeChildProcess.execFileSync,
323) => {
324 if (names.length === 0) {
325 return {};
326 }
327​
328 // Interactive startup loads PATH entries; detaching prevents job control on an
329 // inherited controlling TTY from stopping the probe while the parent waits.
330 const output = execFile(shell, ["-ilc", buildEnvironmentCaptureCommand(shell, names)], {
331 encoding: "utf8",
332 timeout: 5000,
333 killSignal: "SIGKILL",
334 detached: true,
335 stdio: ["ignore", "pipe", "ignore"],
336 });
337​
338 return extractEnvironmentValues(output, names);
339};
340​
341export type WindowsShellEnvironmentReader = (
342 names: ReadonlyArray<string>,
343 options?: WindowsEnvironmentProbeOptions,
344) => Effect.Effect<Partial<Record<string, string>>>;
345​
346export const WindowsShellEnvironment = Context.Reference<WindowsShellEnvironmentReader>(
347 "@t3tools/shared/shell/WindowsShellEnvironment",
348 {
349 defaultValue: () => readEnvironmentFromWindowsShell,
350 },
351);
352​
353export const CommandAvailability = Context.Reference<CommandAvailabilityChecker>(
354 "@t3tools/shared/shell/CommandAvailability",
355 {
356 defaultValue: () => isCommandAvailable,
357 },
358);
359​
360const execWindowsEnvironmentProbe = (
361 file: string,
362 args: ReadonlyArray<string>,
363 options: { encoding: "utf8"; timeout: number },
364) =>
365 Effect.callback<Option.Option<string>>((resume) => {
366 let child: NodeChildProcess.ChildProcess;
367 try {
368 child = NodeChildProcess.execFile(
369 file,
370 args,
371 { ...options, windowsHide: true },
372 (error, stdout) => {
373 resume(Effect.succeed(error ? Option.none() : Option.some(stdout)));
374 },
375 );
376 } catch {
377 resume(Effect.succeedNone);
378 return;
379 }
380 return Effect.sync(() => {
381 child.kill();
382 child.stdout?.destroy();
383 child.stderr?.destroy();
384 child.unref();
385 });
386 });
387​
388export const readEnvironmentFromWindowsShell = Effect.fn("shell.readEnvironmentFromWindowsShell")(
389 function* (
390 names: ReadonlyArray<string>,
391 options: WindowsEnvironmentProbeOptions = {},
392 execFile: typeof execWindowsEnvironmentProbe = execWindowsEnvironmentProbe,
393 ) {
394 if (names.length === 0) return {};
395​
396 const command = buildWindowsEnvironmentCaptureCommand(names);
397 const args = [
398 "-NoLogo",
399 ...(options.loadProfile ? ([] as const) : (["-NoProfile"] as const)),
400 "-NonInteractive",
401 "-Command",
402 command,
403 ];
404 for (const shell of WINDOWS_SHELL_CANDIDATES) {
405 const output = yield* execFile(shell, args, {
406 encoding: "utf8",
407 timeout: WINDOWS_SHELL_PROBE_TIMEOUT_MS,
408 }).pipe(Effect.timeoutOption(WINDOWS_SHELL_PROBE_DEADLINE), Effect.map(Option.flatten));
409 if (Option.isNone(output)) continue;
410​
411 const environment: Partial<Record<string, string>> = {};
412 for (const name of names) {
413 const value = extractEnvironmentValue(output.value, name);
414 if (value !== undefined) environment[name] = value;
415 }
416 return environment;
417 }
418​
419 return {};
420 },
421);
422​
423function stripWrappingQuotes(value: string): string {
424 return value.replace(/^"+|"+$/g, "");
426​
427function pathDelimiterForPlatform(platform: NodeJS.Platform): string {
428 return platform === "win32" ? WINDOWS_PATH_DELIMITER : POSIX_PATH_DELIMITER;
430​
431function normalizePathEntryForComparison(entry: string, platform: NodeJS.Platform): string {
432 const normalized = stripWrappingQuotes(entry.trim());
433 return platform === "win32" ? normalized.toLowerCase() : normalized;
435​
436function sanitizePathEntry(entry: string, platform: NodeJS.Platform): string {
437 return platform === "win32" ? entry.replaceAll('"', "") : entry;
439​
440export function mergePathValues(
441 preferredPath: string | undefined,
442 inheritedPath: string | undefined,
443 platform: NodeJS.Platform,
444): string | undefined {
445 const delimiter = pathDelimiterForPlatform(platform);
446 const merged: string[] = [];
447 const seen = new Set<string>();
448​
449 for (const rawValue of [preferredPath, inheritedPath]) {
450 if (!rawValue) continue;
451​
452 for (const entry of rawValue.split(delimiter)) {
453 const sanitized = sanitizePathEntry(entry.trim(), platform);
454 if (sanitized.length === 0) continue;
455​
456 const normalized = normalizePathEntryForComparison(sanitized, platform);
457 if (normalized.length === 0 || seen.has(normalized)) continue;
458​
459 seen.add(normalized);
460 merged.push(sanitized);
461 }
462 }
463​
464 return merged.length > 0 ? merged.join(delimiter) : undefined;
466​
467function readEnvPath(env: NodeJS.ProcessEnv): string | undefined {
468 return env.PATH ?? env.Path ?? env.path;
470​
471function resolvePathEnvironmentVariable(env: NodeJS.ProcessEnv): string {
472 return readEnvPath(env) ?? "";
474​
475function resolveWindowsPathExtensions(env: NodeJS.ProcessEnv): ReadonlyArray<string> {
476 const rawValue = env.PATHEXT;
477 const fallback = [".COM", ".EXE", ".BAT", ".CMD"];
478 if (!rawValue) return fallback;
479​
480 const parsed: string[] = [];
481 for (const entry of rawValue.split(";")) {
482 const trimmed = entry.trim();
483 if (trimmed.length === 0) continue;
484 parsed.push(trimmed.startsWith(".") ? trimmed.toUpperCase() : `.${trimmed.toUpperCase()}`);
485 }
486 return parsed.length > 0 ? Array.from(new Set(parsed)) : fallback;
488​
489function resolveCommandCandidates(
490 command: string,
491 platform: NodeJS.Platform,
492 windowsPathExtensions: ReadonlyArray<string>,
493 extname: (path: string) => string,
494): ReadonlyArray<string> {
495 if (platform !== "win32") return [command];
496 const extension = extname(command);
497 const normalizedExtension = extension.toUpperCase();
498​
499 if (extension.length > 0 && windowsPathExtensions.includes(normalizedExtension)) {
500 const commandWithoutExtension = command.slice(0, -extension.length);
501 return Array.from(
502 new Set([
503 command,
504 `${commandWithoutExtension}${normalizedExtension}`,
505 `${commandWithoutExtension}${normalizedExtension.toLowerCase()}`,
506 ]),
507 );
508 }
509​
510 const candidates: string[] = [];
511 for (const candidateExtension of windowsPathExtensions) {
512 candidates.push(`${command}${candidateExtension}`);
513 candidates.push(`${command}${candidateExtension.toLowerCase()}`);
514 }
515 return Array.from(new Set(candidates));
517​
518// Explicit paths bypass the cache for new installs; cached misses can otherwise hide binaries for 30 seconds.
519const COMMAND_RESOLUTION_CACHE_TTL_NANOS = 30_000_000_000n;
520const COMMAND_RESOLUTION_CACHE_MAX_ENTRIES = 512;
521const COMMAND_RESOLUTION_CACHE_KEY_SEPARATOR = String.fromCharCode(0);
522​
523interface CommandResolutionCacheEntry {
524 readonly resolvedPath: string | null;
525 readonly expiresAtNanos: bigint;
527​
528export const CommandResolutionCache = Context.Reference<Map<string, CommandResolutionCacheEntry>>(
529 "@t3tools/shared/shell/CommandResolutionCache",
530 {
531 defaultValue: () => new Map(),
532 },
533);
534​
535interface PathDirectoryListing {
536 readonly modified: number | null | undefined;
537 readonly names: ReadonlySet<string> | undefined;
539​
540const directoryMtime = (directory: string) =>
541 FileSystem.FileSystem.use((fileSystem) => fileSystem.stat(directory)).pipe(
542 Effect.map((info) =>
543 info.type === "Directory" ? Option.getOrUndefined(info.mtime)?.getTime() : undefined,
544 ),
545 Effect.catch((error) => Effect.succeed(error.reason._tag === "NotFound" ? null : undefined)),
546 );
547​
548// Timestamp before reading so concurrent additions change the next mtime check; missing listings fall back to direct probes.
549const listPathDirectory = Effect.fnUntraced(function* (
550 directory: string,
551): Effect.fn.Return<PathDirectoryListing, never, FileSystem.FileSystem> {
552 const modified = yield* directoryMtime(directory);
553 if (modified == null) return { modified, names: modified === null ? new Set() : undefined };
554 const entries = yield* FileSystem.FileSystem.use((fileSystem) =>
555 fileSystem.readDirectory(directory),
556 ).pipe(Effect.orElseSucceed(() => undefined));
557 return { modified, names: entries && new Set(entries.map((entry) => entry.toLowerCase())) };
558});
559​
560const PathDirectoryListings = Context.Reference<
561 Cache.Cache<string, PathDirectoryListing, never, FileSystem.FileSystem> | undefined
562>("@t3tools/shared/shell/PathDirectoryListings", { defaultValue: () => undefined });
563​
564export const withPathDirectoryListings =
565 /* Directory reads are cached inside one shell operation; a span for each lookup adds noise. */
566 Effect.fnUntraced(function* <A, E, R>(effect: Effect.Effect<A, E, R>) {
567 const listings = yield* Cache.make({
568 capacity: 1024,
569 lookup: listPathDirectory,
570 requireServicesAt: "lookup",
571 });
572 return yield* effect.pipe(Effect.provideService(PathDirectoryListings, listings));
573 });
574​
575// An injected resolver or locator may answer differently for the same search, so its
576// entries are kept apart from every other resolver's.
577let spawnResolverCacheIdCount = 0;
578const spawnResolverCacheIds = new WeakMap<SpawnExecutableResolver | XcodeGitLocator, number>();
579function spawnResolverCacheId(resolver: SpawnExecutableResolver | XcodeGitLocator): number {
580 const known = spawnResolverCacheIds.get(resolver);
581 if (known !== undefined) return known;
582 const id = spawnResolverCacheIdCount++;
583 spawnResolverCacheIds.set(resolver, id);
584 return id;
586​
587function cacheCommandResolution(
588 cache: Map<string, CommandResolutionCacheEntry>,
589 cacheKey: string,
590 resolvedPath: string | null,
591 nowNanos: bigint,
592): void {
593 if (cache.size >= COMMAND_RESOLUTION_CACHE_MAX_ENTRIES) {
594 const oldestKey = cache.keys().next().value;
595 if (oldestKey !== undefined) {
596 cache.delete(oldestKey);
597 }
598 }
599 cache.set(cacheKey, {
600 resolvedPath,
601 expiresAtNanos: nowNanos + COMMAND_RESOLUTION_CACHE_TTL_NANOS,
602 });
604​
605const isExecutableFile = Effect.fnUntraced(function* (
606 filePath: string,
607 platform: NodeJS.Platform,
608 windowsPathExtensions: ReadonlyArray<string>,
609): Effect.fn.Return<boolean, never, FileSystem.FileSystem | Path.Path> {
610 const fileSystem = yield* FileSystem.FileSystem;
611 const path = yield* Path.Path;
612 const stat = yield* fileSystem.stat(filePath).pipe(Effect.orElseSucceed(() => null));
613 if (stat === null || stat.type !== "File") return false;
614​
615 if (platform === "win32") {
616 const extension = path.extname(filePath);
617 if (extension.length === 0) return false;
618 return windowsPathExtensions.includes(extension.toUpperCase());
619 }
620​
621 return canExecuteFile(filePath);
622});
623​
624const resolveCommandPathForPlatform = Effect.fn("shell.resolveCommandPathForPlatform")(function* (
625 command: string,
626 options: CommandAvailabilityOptions & { readonly platform: NodeJS.Platform },
627): Effect.fn.Return<string | null, never, FileSystem.FileSystem | Path.Path> {
628 const path = yield* Path.Path;
629 const platform = options.platform;
630 const env = options.env ?? process.env;
631 const windowsPathExtensions = platform === "win32" ? resolveWindowsPathExtensions(env) : [];
632 const commandCandidates = resolveCommandCandidates(
633 command,
634 platform,
635 windowsPathExtensions,
636 path.extname,
637 );
638​
639 if (command.includes("/") || command.includes("\\")) {
640 for (const candidate of commandCandidates) {
641 if (yield* isExecutableFile(candidate, platform, windowsPathExtensions)) {
642 return candidate;
643 }
644 }
645 return null;
646 }
647​
648 const pathValue = resolvePathEnvironmentVariable(env);
649 if (pathValue.length === 0) {
650 return null;
651 }
652​
653 const cacheKey = [platform, pathValue, windowsPathExtensions.join(";"), command].join(
654 COMMAND_RESOLUTION_CACHE_KEY_SEPARATOR,
655 );
656 const cache = yield* CommandResolutionCache;
657 const nowNanos = yield* Clock.monotonicTimeNanos;
658 const cached = cache.get(cacheKey);
659 if (cached !== undefined && cached.expiresAtNanos > nowNanos) {
660 return cached.resolvedPath;
661 }
662​
663 // Keep case variants: Windows can make PATH directories case-sensitive.
664 const pathEntries: string[] = [];
665 const seenPathEntries = new Set<string>();
666 for (const entry of pathValue.split(pathDelimiterForPlatform(platform))) {
667 const pathEntry = stripWrappingQuotes(entry.trim());
668 if (pathEntry.length === 0 || seenPathEntries.has(pathEntry)) continue;
669​
670 seenPathEntries.add(pathEntry);
671 pathEntries.push(pathEntry);
672 }
673​
674 const listings = yield* PathDirectoryListings;
675 for (const pathEntry of pathEntries) {
676 let listing = listings && (yield* Cache.get(listings, pathEntry));
677 if (listings && listing?.names && listing.modified !== (yield* directoryMtime(pathEntry))) {
678 yield* Cache.invalidate(listings, pathEntry);
679 listing = yield* Cache.get(listings, pathEntry);
680 }
681 for (const candidate of commandCandidates) {
682 if (listing?.names && !listing.names.has(candidate.toLowerCase())) continue;
683 const candidatePath = path.join(pathEntry, candidate);
684 if (yield* isExecutableFile(candidatePath, platform, windowsPathExtensions)) {
685 cacheCommandResolution(cache, cacheKey, candidatePath, nowNanos);
686 return candidatePath;
687 }
688 }
689 }
690 cacheCommandResolution(cache, cacheKey, null, nowNanos);
691 return null;
692});
693​
694export const resolveCommandPath = Effect.fn("shell.resolveCommandPath")(function* (
695 command: string,
696 options: CommandAvailabilityOptions = {},
697) {
698 return yield* resolveCommandPathForPlatform(command, {
699 env: options.env ?? (yield* HostProcess.Environment),
700 platform: yield* HostProcess.Platform,
701 });
702});
703​
704// Git 2.56 x64 uses ucrt64; ARM64 uses clangarm64; older x64/32-bit builds use mingw64/mingw32.
705const GIT_FOR_WINDOWS_BUILDS = ["ucrt64", "clangarm64", "mingw64", "mingw32"] as const;
706​
707function isFileSync(filePath: string): boolean {
708 try {
709 return NodeFS.statSync(filePath).isFile();
710 } catch {
711 return false;
712 }
714​
715/** Bypass the launcher's extra process and kernel-token leak. Retain it when MSYSTEM is set, or git.exe omits hook/helper PATH entries. */
716export function preferGitForWindowsBinary(
717 executable: string,
718 env: NodeJS.ProcessEnv,
719 isFile: (filePath: string) => boolean = isFileSync,
720): string {
721 if (env.MSYSTEM) return executable;
722 const path = NodePath.win32;
723 if (path.basename(executable).toLowerCase() !== "git.exe") return executable;
724 const launcherDirectory = path.dirname(executable);
725 const launcherFolder = path.basename(launcherDirectory).toLowerCase();
726 if (launcherFolder !== "cmd" && launcherFolder !== "bin") return executable;
727 const installRoot = path.dirname(launcherDirectory);
728 for (const build of GIT_FOR_WINDOWS_BUILDS) {
729 const candidate = path.join(installRoot, build, "bin", "git.exe");
730 if (isFile(candidate)) return candidate;
731 }
732 return executable;
734​
735// npm cmd-shim format for native executables.
736const NPM_EXECUTABLE_SHIM_PREFIX = [
737 "@ECHO off",
738 "GOTO start",
739 ":find_dp0",
740 "SET dp0=%~dp0",
741 "EXIT /b",
742 ":start",
743 "SETLOCAL",
744 "CALL :find_dp0",
745];
746const NPM_EXECUTABLE_SHIM_RUN = /^"%dp0%\\([^"%]+\.exe)" +%\*$/;
747​
748function readFileSyncOrUndefined(filePath: string): string | undefined {
749 try {
750 return NodeFS.readFileSync(filePath, "utf8");
751 } catch {
752 return undefined;
753 }
755​
756/** Resolve shims that only launch native executables so direct spawning keeps them in libuv's kill-on-close job. */
757export function npmShimExecutable(
758 shimPath: string,
759 readFile: (filePath: string) => string | undefined = readFileSyncOrUndefined,
760 isFile: (filePath: string) => boolean = isFileSync,
761): string | undefined {
762 const path = NodePath.win32;
763 if (path.extname(shimPath).toLowerCase() !== ".cmd") return undefined;
764 // Match exactly because preserved SET whitespace can select a different file.
765 const lines = readFile(shimPath)
766 ?.split(/\r?\n/)
767 .filter((line) => line.length > 0);
768 if (lines?.length !== NPM_EXECUTABLE_SHIM_PREFIX.length + 1) return undefined;
769 if (NPM_EXECUTABLE_SHIM_PREFIX.some((expected, index) => lines[index] !== expected)) {
770 return undefined;
771 }
772 const target = NPM_EXECUTABLE_SHIM_RUN.exec(lines.at(-1)!)?.[1];
773 if (target === undefined) return undefined;
774 const executable = path.join(path.dirname(shimPath), target);
775 return isFile(executable) ? executable : undefined;
777​
778export type XcodeGitLocator = (env: NodeJS.ProcessEnv) => string | undefined;
779​
780// xcrun honors DEVELOPER_DIR and the `xcode-select` choice, as the shim does.
781function locateXcodeGitWithXcrun(env: NodeJS.ProcessEnv): string | undefined {
782 try {
783 const located = NodeChildProcess.execFileSync("/usr/bin/xcrun", ["--find", "git"], {
784 encoding: "utf8",
785 env,
786 timeout: 5_000,
787 stdio: ["ignore", "pipe", "ignore"],
788 }).trim();
789 return NodePath.isAbsolute(located) ? located : undefined;
790 } catch {
791 return undefined;
792 }
794​
795export const XcodeGitLocation = Context.Reference<XcodeGitLocator>(
796 "@t3tools/shared/shell/XcodeGitLocation",
797 { defaultValue: () => locateXcodeGitWithXcrun },
798);
799​
800const MACOS_GIT_SHIM = "/usr/bin/git";
801​
802/**
803 * Skip the xcrun shim's per-spawn lookup only when git resolves to /usr/bin/git.
804 * User-selected git on PATH still wins, children keep PATH, and lookup failures use `git`.
805 */
806const resolveMacosGit = Effect.fnUntraced(function* (
807 env: NodeJS.ProcessEnv,
808 resolveExecutable: SpawnExecutableResolver,
809) {
810 const locateXcodeGit = yield* XcodeGitLocation;
811 const cache = yield* CommandResolutionCache;
812 const cacheKey = [
813 "xcode-git",
814 String(spawnResolverCacheId(resolveExecutable)),
815 String(spawnResolverCacheId(locateXcodeGit)),
816 resolvePathEnvironmentVariable(env),
817 env.DEVELOPER_DIR ?? "",
818 ].join(COMMAND_RESOLUTION_CACHE_KEY_SEPARATOR);
819 const nowNanos = yield* Clock.currentTimeNanos;
820 const cached = cache.get(cacheKey);
821 // An Xcode update or removal deletes the binary; look again instead of failing the spawn.
822 if (
823 cached !== undefined &&
824 cached.expiresAtNanos > nowNanos &&
825 (cached.resolvedPath === null ||
826 resolveExecutable(cached.resolvedPath, "darwin", env) !== undefined)
827 ) {
828 return cached.resolvedPath ?? "git";
829 }
830 const located =
831 resolveExecutable("git", "darwin", env) === MACOS_GIT_SHIM ? locateXcodeGit(env) : undefined;
832 const realGit =
833 located !== undefined && resolveExecutable(located, "darwin", env) !== undefined
834 ? located
835 : null;
836 cacheCommandResolution(cache, cacheKey, realGit, nowNanos);
837 return realGit ?? "git";
838});
839​
840export interface SpawnCommandOptions extends CommandAvailabilityOptions {
841 /** Bypass npm cmd-shims; see npmShimExecutable for process-lifetime semantics. */
842 readonly bypassNpmShim?: boolean;
844​
845// `*` is one argument that is not an option. These forms read refs or config
846// only, so git starts no hook, helper, pager, editor or lazy fetch for them.
847const CONSOLE_FREE_GIT_READS = [
848 "rev-parse --git-path index",
849 "rev-parse --git-common-dir",
850 "rev-parse --show-toplevel",
851 "rev-parse --symbolic-full-name HEAD",
852 "rev-parse --abbrev-ref --symbolic-full-name @{upstream}",
853 "symbolic-ref --quiet HEAD",
854 "symbolic-ref *",
855 "remote",
856 "remote get-url *",
857 "remote get-url -- *",
858 "config --get *",
859].map((form) => form.split(" "));
860​
861export function runsGitWithoutConsole(
862 platform: NodeJS.Platform,
863 executable: string,
864 args: ReadonlyArray<string>,
865): boolean {
866 if (platform !== "win32") return false;
867 const { basename, dirname } = NodePath.win32;
868 const bin = dirname(executable);
869 const build = basename(dirname(bin)).toLowerCase();
870 if (basename(executable).toLowerCase() !== "git.exe" || basename(bin).toLowerCase() !== "bin") {
871 return false;
872 }
873 if (!GIT_FOR_WINDOWS_BUILDS.some((candidate) => candidate === build)) return false;
874 const rest =
875 args[0] === "--git-dir" && args[1] !== undefined && !args[1].startsWith("-")
876 ? args.slice(2)
877 : args;
878 return CONSOLE_FREE_GIT_READS.some(
879 (form) =>
880 form.length === rest.length &&
881 form.every((part, i) => part === rest[i] || (part === "*" && !rest[i]!.startsWith("-"))),
882 );
884​
885export const resolveSpawnCommand = Effect.fnUntraced(function* (
886 command: string,
887 args: ReadonlyArray<string>,
888 options: SpawnCommandOptions = {},
889): Effect.fn.Return<ResolvedSpawnCommand> {
890 const platform = yield* HostProcess.Platform;
891 const isMacosGit = platform === "darwin" && command === "git";
892 if (platform !== "win32" && !isMacosGit) {
893 return { command, args: [...args], shell: false };
894 }
895​
896 const hostEnvironment = yield* HostProcess.Environment;
897 const env =
898 options.env === undefined
899 ? hostEnvironment
900 : options.extendEnv
901 ? { ...hostEnvironment, ...options.env }
902 : options.env;
903 const resolveExecutable = yield* SpawnExecutableResolution;
904 if (isMacosGit) {
905 return {
906 command: yield* resolveMacosGit(env, resolveExecutable),
907 args: [...args],
908 shell: false,
909 };
910 }
911 // The scan is synchronous and runs before every child process, so it shares
912 // the PATH scan cache above. Explicit paths stay uncached for the same reason,
913 // and so do misses: a failed spawn is how providers report "not installed",
914 // and that has to clear the moment the binary appears.
915 const explicitPath = command.includes("/") || command.includes("\\");
916 const cache = yield* CommandResolutionCache;
917 const cacheKey = [
918 "spawn",
919 String(spawnResolverCacheId(resolveExecutable)),
920 platform,
921 resolvePathEnvironmentVariable(env),
922 resolveWindowsPathExtensions(env).join(";"),
923 env.MSYSTEM ? "msystem" : "",
924 command,
925 ].join(COMMAND_RESOLUTION_CACHE_KEY_SEPARATOR);
926 const nowNanos = yield* Clock.currentTimeNanos;
927 const cached = explicitPath ? undefined : cache.get(cacheKey);
928 let resolvedExecutable: string | null;
929 if (cached !== undefined && cached.expiresAtNanos > nowNanos) {
930 resolvedExecutable = cached.resolvedPath;
931 } else {
932 const found = resolveExecutable(command, platform, env);
933 resolvedExecutable = found === undefined ? null : preferGitForWindowsBinary(found, env);
934 if (!explicitPath && resolvedExecutable !== null) {
935 cacheCommandResolution(cache, cacheKey, resolvedExecutable, nowNanos);
936 }
937 }
938 const resolvedCommand = resolvedExecutable ?? command;
939 const extension = NodePath.win32.extname(resolvedCommand).toLowerCase();
940 if (extension !== ".cmd" && extension !== ".bat") {
941 return { command: resolvedCommand, args: [...args], shell: false };
942 }
943 const shimExecutable = options.bypassNpmShim ? npmShimExecutable(resolvedCommand) : undefined;
944 if (shimExecutable !== undefined) {
945 return { command: shimExecutable, args: [...args], shell: false };
946 }
947​
948 // Build the escaped command line without separate args to avoid Node DEP0190 with shell:true.
949 return {
950 command: [resolvedCommand, ...args].map(escapeWindowsShellArg).join(" "),
951 args: [],
952 shell: true,
953 };
954});
955​
956export const isCommandAvailable = Effect.fn("shell.isCommandAvailable")(function* (
957 command: string,
958 options: CommandAvailabilityOptions = {},
959) {
960 return (yield* resolveCommandPath(command, options)) !== null;
961});
962​
963function resolveKnownWindowsCliDirs(env: NodeJS.ProcessEnv): ReadonlyArray<string> {
964 const appData = env.APPDATA?.trim();
965 const localAppData = env.LOCALAPPDATA?.trim();
966 const userProfile = env.USERPROFILE?.trim();
967​
968 return [
969 ...(appData ? [`${appData}\\npm`] : []),
970 ...(localAppData ? [`${localAppData}\\Programs\\nodejs`, `${localAppData}\\Volta\\bin`] : []),
971 ...(localAppData ? [`${localAppData}\\pnpm`] : []),
972 ...(userProfile
973 ? [`${userProfile}\\.local\\bin`, `${userProfile}\\.bun\\bin`, `${userProfile}\\scoop\\shims`]
974 : []),
975 ];
977​
978/** Keep one PATH spelling in copied Windows environment records. */
979export function setWindowsPath(env: NodeJS.ProcessEnv, value: string | null): void {
980 for (const key of Object.keys(env)) {
981 if (key.toUpperCase() === "PATH") {
982 delete env[key];
983 }
984 }
985 if (value !== null) env.PATH = value;
987​
988function mergeWindowsEnv(
989 currentEnv: NodeJS.ProcessEnv,
990 patch: Partial<Record<string, string>>,
991): NodeJS.ProcessEnv {
992 const nextEnv: NodeJS.ProcessEnv = { ...currentEnv };
993 for (const [key, value] of Object.entries(patch)) {
994 if (value === undefined) continue;
995 if (key === "PATH") {
996 setWindowsPath(nextEnv, value);
997 } else {
998 nextEnv[key] = value;
999 }
1001 return nextEnv;
1003​
1004export const resolveWindowsEnvironment = Effect.fn("shell.resolveWindowsEnvironment")(function* (
1005 env: NodeJS.ProcessEnv,
1006): Effect.fn.Return<Partial<NodeJS.ProcessEnv>, never, FileSystem.FileSystem | Path.Path> {
1007 const readEnvironment = yield* WindowsShellEnvironment;
1008 const commandAvailable = yield* CommandAvailability;
1009 const inheritedPath = readEnvPath(env);
1010 const shellPath = (yield* readEnvironment(["PATH"], { loadProfile: false })).PATH;
1011 const mergedPath = mergePathValues(shellPath, inheritedPath, "win32");
1012 const knownCliPath = resolveKnownWindowsCliDirs(env).join(WINDOWS_PATH_DELIMITER);
1013 const baselinePath = mergePathValues(mergedPath, knownCliPath, "win32");
1014 const baselinePatch: Partial<NodeJS.ProcessEnv> = baselinePath ? { PATH: baselinePath } : {};
1015 const baselineEnv = mergeWindowsEnv(env, baselinePatch);
1016​
1017 if (yield* commandAvailable("node", { env: baselineEnv })) {
1018 return baselinePatch;
1020​
1021 const profiledEnvironment = yield* readEnvironment(["PATH", "FNM_DIR", "FNM_MULTISHELL_PATH"], {
1022 loadProfile: true,
1023 });
1024 const profiledPath = mergePathValues(profiledEnvironment.PATH, baselinePath, "win32");
1025 const profiledPatch: Partial<NodeJS.ProcessEnv> = {
1026 ...(profiledPath ? { PATH: profiledPath } : {}),
1027 ...(profiledEnvironment.FNM_DIR ? { FNM_DIR: profiledEnvironment.FNM_DIR } : {}),
1028 ...(profiledEnvironment.FNM_MULTISHELL_PATH
1029 ? { FNM_MULTISHELL_PATH: profiledEnvironment.FNM_MULTISHELL_PATH }
1030 : {}),
1031 };
1032 return Object.keys(profiledPatch).length > 0
1033 ? { ...baselinePatch, ...profiledPatch }
1034 : baselinePatch;
1035});
1036​
1037export function applyShellEnvironment(
1038 env: NodeJS.ProcessEnv,
1039 patch: ShellEnvironmentPatch,
1040 platform: NodeJS.Platform,
1041): void {
1042 for (const [key, value] of Object.entries(patch)) {
1043 if (key === "PATH" && platform === "win32") setWindowsPath(env, value);
1044 else if (value === null) delete env[key];
1045 else env[key] = value;
1048​
1049interface PathSetupInput {
1050 readonly directory: string;
1051 readonly home: string;
1052 readonly shell: string | undefined;
1053 readonly platform: NodeJS.Platform;
1054 /** `XDG_CONFIG_HOME`, where fish reads its startup file when set. */
1055 readonly configHome?: string | undefined;
1056 /** `ZDOTDIR`, where zsh reads `.zshrc` when set. */
1057 readonly zdotdir?: string | undefined;
1059​
1060/**
1061 * The startup file (`~/...`, or absolute under `configHome`) and line that put `directory` last on
1062 * PATH for new zsh, bash, or fish terminals, so it never shadows another `t3`. Undefined on Windows (writing PATH there would flatten the registry's
1063 * `%VAR%` entries), for other shells, macOS bash, or unsafe paths.
1064 */
1065export function pathSetup(
1066 input: PathSetupInput,
1067): { readonly file: string; readonly line: string } | undefined {
1068 // A relative folder on PATH would follow the working directory into any checkout.
1069 if (input.platform === "win32" || !input.directory.startsWith("/")) return undefined;
1070 const underHome = input.directory.startsWith(`${input.home}/`);
1071 const rest = underHome ? input.directory.slice(input.home.length) : input.directory;
1072 if (!/^[\w./-]+$/.test(rest)) return undefined;
1073 const directory = underHome ? `$HOME${rest}` : rest;
1074 const shell = input.shell?.split("/").pop();
1075 // A set folder the shell reads from replaces the default; one unsafe to write means no setup,
1076 // since the default file would never be read.
1077 const readFrom = (value: string | undefined, fallback: string) =>
1078 !value ? fallback : value.startsWith("/") && /^[\w./-]+$/.test(value) ? value : undefined;
1079 if (shell === "fish") {
1080 const configHome = readFrom(input.configHome, "~/.config");
1081 if (configHome === undefined) return undefined;
1082 // --path changes $PATH itself; fish_user_paths would go ahead of the system folders.
1083 return {
1084 file: `${configHome}/fish/config.fish`,
1085 line: `fish_add_path --append --path ${directory}`,
1086 };
1088 // macOS bash reads only the first of .bash_profile, .bash_login, and .profile; no file is safe.
1089 const zdotdir = readFrom(input.zdotdir, "~");
1090 const file =
1091 shell === "zsh"
1092 ? zdotdir && `${zdotdir}/.zshrc`
1093 : shell === "bash" && input.platform !== "darwin"
1094 ? "~/.bashrc"
1095 : undefined;
1096 return file === undefined ? undefined : { file, line: `export PATH="$PATH:${directory}"` };
1098​
1099/** A command to paste that does what {@link pathSetup} describes. */
1100export function pathSetupCommand(input: PathSetupInput): string | undefined {
1101 const setup = pathSetup(input);
1102 if (!setup) return undefined;
1103 // The leading newline keeps the line out of a final comment with no newline after it.
1104 const append = `printf '\\n%s\\n' '${setup.line}' >> ${setup.file}`;
1105 // fish's config folder may not exist yet; `&&` works in fish 3 too.
1106 return setup.file.endsWith(".fish")
1107 ? `mkdir -p ${setup.file.slice(0, setup.file.lastIndexOf("/"))} && ${append}`
1108 : append;