751 lines · 24.3 KB · TypeScript
RawDownload
// @effect-diagnostics nodeBuiltinImport:offimport * as NodeOS from "node:os";import * as NodePath from "node:path";import * as NodeChildProcess from "node:child_process";import * as NodeFS from "node:fs";import * as Clock from "effect/Clock";import * as Data from "effect/Data";import * as Effect from "effect/Effect";import * as FileSystem from "effect/FileSystem";import * as Path from "effect/Path"; import { HostProcessEnvironment, HostProcessPlatform } from "./hostProcess.ts";import * as Context from "effect/Context"; const PATH_CAPTURE_START = "__T3CODE_PATH_START__";const PATH_CAPTURE_END = "__T3CODE_PATH_END__";const SHELL_ENV_NAME_PATTERN = /^[A-Z0-9_]+$/;const WINDOWS_PATH_DELIMITER = ";";const POSIX_PATH_DELIMITER = ":";const WINDOWS_SHELL_CANDIDATES = ["pwsh.exe", "powershell.exe"] as const; type ExecFileSyncLike = (  file: string,  args: ReadonlyArray<string>,  options: { encoding: "utf8"; timeout: number },) => string; function canExecuteFile(filePath: string): boolean {  try {    NodeFS.accessSync(filePath, NodeFS.constants.X_OK);    return true;  } catch {    return false;  }} export interface CommandAvailabilityOptions {  readonly env?: NodeJS.ProcessEnv;  readonly extendEnv?: boolean;} export type CommandAvailabilityChecker = (  command: string,  options?: CommandAvailabilityOptions,) => Effect.Effect<boolean, never, FileSystem.FileSystem | Path.Path>; export class CommandResolutionError extends Data.TaggedError("CommandResolutionError")<{  readonly command: string;  readonly reason: "not-found";}> {} const WINDOWS_SHELL_META_CHARS = /([()\][%!^"`<>&|;, *?])/g; /** * Escapes a single argument for `cmd.exe` shell mode (`spawn(..., { shell: true })` * on Windows). Node joins the command and arguments with spaces and hands the * resulting string to `cmd.exe` without any quoting, so every dynamic argument * must be escaped to survive both cmd.exe parsing and the target program's * `CommandLineToArgvW` parsing. Mirrors cross-spawn's argument escaping. */function escapeWindowsShellArg(arg: string): string {  // Double up backslashes that precede a double quote, then escape the quote  // itself so it survives CommandLineToArgvW.  let escaped = arg.replace(/(\\*)"/g, '$1$1\\"');  // Double up trailing backslashes so the closing quote is not escaped away.  escaped = escaped.replace(/(\\*)$/, "$1$1");  // Quote the whole argument so embedded whitespace is preserved.  escaped = `"${escaped}"`;  // Escape cmd.exe metacharacters so cmd passes them through verbatim.  return escaped.replace(WINDOWS_SHELL_META_CHARS, "^$1");} /** * Escapes arguments for shell-mode spawns: applies {@link escapeWindowsShellArg} * when the platform is `win32` (where `shell: true` routes through `cmd.exe`) * and returns the arguments untouched everywhere else. */function sanitizeShellModeArgsForPlatform(  args: ReadonlyArray<string>,  platform: NodeJS.Platform,): Array<string> {  return platform === "win32" ? args.map(escapeWindowsShellArg) : [...args];} export interface ResolvedSpawnCommand {  readonly command: string;  readonly args: ReadonlyArray<string>;  readonly shell: boolean;} export type SpawnExecutableResolver = (  command: string,  platform: NodeJS.Platform,  env: NodeJS.ProcessEnv,) => string | undefined; function resolveSpawnExecutableWithNode(  command: string,  platform: NodeJS.Platform,  env: NodeJS.ProcessEnv,): string | undefined {  const path = platform === "win32" ? NodePath.win32 : NodePath.posix;  const windowsPathExtensions = platform === "win32" ? resolveWindowsPathExtensions(env) : [];  const candidates = resolveCommandCandidates(    command,    platform,    windowsPathExtensions,    path.extname,  );  const isExecutable = (candidate: string) => {    try {      if (!NodeFS.statSync(candidate).isFile()) return false;      if (platform === "win32") {        return windowsPathExtensions.includes(path.extname(candidate).toUpperCase());      }      return canExecuteFile(candidate);    } catch {      return false;    }  };   if (command.includes("/") || command.includes("\\")) {    return candidates.find(isExecutable);  }   for (const pathEntry of (readEnvPath(env) ?? "").split(pathDelimiterForPlatform(platform))) {    const normalizedPathEntry = stripWrappingQuotes(pathEntry.trim());    if (normalizedPathEntry.length === 0) continue;    for (const candidate of candidates) {      const candidatePath = path.join(normalizedPathEntry, candidate);      if (isExecutable(candidatePath)) return candidatePath;    }  }  return undefined;} export const SpawnExecutableResolution = Context.Reference<SpawnExecutableResolver>(  "@t3tools/shared/shell/SpawnExecutableResolution",  {    defaultValue: () => resolveSpawnExecutableWithNode,  },); export interface WindowsEnvironmentProbeOptions {  readonly loadProfile?: boolean;} function trimNonEmpty(value: string | null | undefined): string | undefined {  const trimmed = value?.trim();  return trimmed && trimmed.length > 0 ? trimmed : undefined;} function readUserLoginShell(): string | undefined {  try {    return trimNonEmpty(NodeOS.userInfo().shell);  } catch {    return undefined;  }} export function listLoginShellCandidates(  platform: NodeJS.Platform,  shell: string | undefined,  userShell = readUserLoginShell(),): ReadonlyArray<string> {  const fallbackShell =    platform === "darwin" ? "/bin/zsh" : platform === "linux" ? "/bin/bash" : undefined;  const seen = new Set<string>();  const candidates: string[] = [];   for (const candidate of [trimNonEmpty(shell), trimNonEmpty(userShell), fallbackShell]) {    if (!candidate || seen.has(candidate)) {      continue;    }    seen.add(candidate);    candidates.push(candidate);  }   return candidates;} export function extractPathFromShellOutput(output: string): string | null {  const startIndex = output.indexOf(PATH_CAPTURE_START);  if (startIndex === -1) return null;   const valueStartIndex = startIndex + PATH_CAPTURE_START.length;  const endIndex = output.indexOf(PATH_CAPTURE_END, valueStartIndex);  if (endIndex === -1) return null;   const pathValue = output.slice(valueStartIndex, endIndex).trim();  return pathValue.length > 0 ? pathValue : null;} export function readPathFromLoginShell(  shell: string,  execFile: ExecFileSyncLike = NodeChildProcess.execFileSync,): string | undefined {  return readEnvironmentFromLoginShell(shell, ["PATH"], execFile).PATH;} export function readPathFromLaunchctl(  execFile: ExecFileSyncLike = NodeChildProcess.execFileSync,): string | undefined {  try {    return trimNonEmpty(      execFile("/bin/launchctl", ["getenv", "PATH"], {        encoding: "utf8",        timeout: 2000,      }),    );  } catch {    return undefined;  }} export function mergePathEntries(  preferredPath: string | undefined,  inheritedPath: string | undefined,  platform: NodeJS.Platform,): string | undefined {  const delimiter = platform === "win32" ? ";" : ":";  const merged: string[] = [];  const seen = new Set<string>();   for (const pathValue of [preferredPath, inheritedPath]) {    if (!pathValue) continue;    for (const entry of pathValue.split(delimiter)) {      const trimmedEntry = entry.trim();      if (!trimmedEntry || seen.has(trimmedEntry)) {        continue;      }      seen.add(trimmedEntry);      merged.push(trimmedEntry);    }  }   return merged.length > 0 ? merged.join(delimiter) : undefined;} function envCaptureStart(name: string): string {  return `__T3CODE_ENV_${name}_START__`;} function envCaptureEnd(name: string): string {  return `__T3CODE_ENV_${name}_END__`;} function buildEnvironmentCaptureCommand(names: ReadonlyArray<string>): string {  return names    .map((name) => {      if (!SHELL_ENV_NAME_PATTERN.test(name)) {        throw new Error(`Unsupported environment variable name: ${name}`);      }       return [        `printf '%s\\n' '${envCaptureStart(name)}'`,        `printenv ${name} || true`,        `printf '%s\\n' '${envCaptureEnd(name)}'`,      ].join("; ");    })    .join("; ");} function buildWindowsEnvironmentCaptureCommand(names: ReadonlyArray<string>): string {  return [    "$ErrorActionPreference = 'Stop'",    ...names.flatMap((name) => {      if (!SHELL_ENV_NAME_PATTERN.test(name)) {        throw new Error(`Unsupported environment variable name: ${name}`);      }       return [        `Write-Output '${envCaptureStart(name)}'`,        `$value = [Environment]::GetEnvironmentVariable('${name}')`,        "if ($null -ne $value -and $value.Length -gt 0) { Write-Output $value }",        `Write-Output '${envCaptureEnd(name)}'`,      ];    }),  ].join("; ");} function extractEnvironmentValue(output: string, name: string): string | undefined {  const startMarker = envCaptureStart(name);  const endMarker = envCaptureEnd(name);  const startIndex = output.indexOf(startMarker);  if (startIndex === -1) return undefined;   const valueStartIndex = startIndex + startMarker.length;  const endIndex = output.indexOf(endMarker, valueStartIndex);  if (endIndex === -1) return undefined;   const value = output    .slice(valueStartIndex, endIndex)    .replace(/^\r?\n/, "")    .replace(/\r?\n$/, "");   return value.length > 0 ? value : undefined;} export type ShellEnvironmentReader = (  shell: string,  names: ReadonlyArray<string>,  execFile?: ExecFileSyncLike,) => Partial<Record<string, string>>; export const readEnvironmentFromLoginShell: ShellEnvironmentReader = (  shell,  names,  execFile = NodeChildProcess.execFileSync,) => {  if (names.length === 0) {    return {};  }   const output = execFile(shell, ["-ilc", buildEnvironmentCaptureCommand(names)], {    encoding: "utf8",    timeout: 5000,  });   const environment: Partial<Record<string, string>> = {};  for (const name of names) {    const value = extractEnvironmentValue(output, name);    if (value !== undefined) {      environment[name] = value;    }  }   return environment;}; export type WindowsShellEnvironmentReader = (  names: ReadonlyArray<string>,  options?: WindowsEnvironmentProbeOptions,) => Partial<Record<string, string>>; export const WindowsShellEnvironment = Context.Reference<WindowsShellEnvironmentReader>(  "@t3tools/shared/shell/WindowsShellEnvironment",  {    defaultValue: () => readEnvironmentFromWindowsShell,  },); export const CommandAvailability = Context.Reference<CommandAvailabilityChecker>(  "@t3tools/shared/shell/CommandAvailability",  {    defaultValue: () => isCommandAvailable,  },); export function readEnvironmentFromWindowsShell(  names: ReadonlyArray<string>,  execFile?: ExecFileSyncLike,): Partial<Record<string, string>>;export function readEnvironmentFromWindowsShell(  names: ReadonlyArray<string>,  options?: WindowsEnvironmentProbeOptions,  execFile?: ExecFileSyncLike,): Partial<Record<string, string>>;export function readEnvironmentFromWindowsShell(  names: ReadonlyArray<string>,  optionsOrExecFile?: WindowsEnvironmentProbeOptions | ExecFileSyncLike,  maybeExecFile?: ExecFileSyncLike,): Partial<Record<string, string>> {  if (names.length === 0) {    return {};  }   const options =    typeof optionsOrExecFile === "function"      ? ({} satisfies WindowsEnvironmentProbeOptions)      : (optionsOrExecFile ?? {});  const execFile: ExecFileSyncLike =    typeof optionsOrExecFile === "function"      ? optionsOrExecFile      : (maybeExecFile ?? (NodeChildProcess.execFileSync as ExecFileSyncLike));  const command = buildWindowsEnvironmentCaptureCommand(names);  const args = [    "-NoLogo",    ...(options.loadProfile ? ([] as const) : (["-NoProfile"] as const)),    "-NonInteractive",    "-Command",    command,  ];  for (const shell of WINDOWS_SHELL_CANDIDATES) {    try {      const output = execFile(shell, args, { encoding: "utf8", timeout: 5000 });       const environment: Partial<Record<string, string>> = {};      for (const name of names) {        const value = extractEnvironmentValue(output, name);        if (value !== undefined) {          environment[name] = value;        }      }      return environment;    } catch {      continue;    }  }   return {};} function stripWrappingQuotes(value: string): string {  return value.replace(/^"+|"+$/g, "");} function pathDelimiterForPlatform(platform: NodeJS.Platform): string {  return platform === "win32" ? WINDOWS_PATH_DELIMITER : POSIX_PATH_DELIMITER;} function normalizePathEntryForComparison(entry: string, platform: NodeJS.Platform): string {  const normalized = stripWrappingQuotes(entry.trim());  return platform === "win32" ? normalized.toLowerCase() : normalized;} export function mergePathValues(  preferredPath: string | undefined,  inheritedPath: string | undefined,  platform: NodeJS.Platform,): string | undefined {  const delimiter = pathDelimiterForPlatform(platform);  const merged: string[] = [];  const seen = new Set<string>();   for (const rawValue of [preferredPath, inheritedPath]) {    if (!rawValue) continue;     for (const entry of rawValue.split(delimiter)) {      const trimmed = entry.trim();      if (trimmed.length === 0) continue;       const normalized = normalizePathEntryForComparison(trimmed, platform);      if (normalized.length === 0 || seen.has(normalized)) continue;       seen.add(normalized);      merged.push(trimmed);    }  }   return merged.length > 0 ? merged.join(delimiter) : undefined;} function readEnvPath(env: NodeJS.ProcessEnv): string | undefined {  return env.PATH ?? env.Path ?? env.path;} function resolvePathEnvironmentVariable(env: NodeJS.ProcessEnv): string {  return readEnvPath(env) ?? "";} function resolveWindowsPathExtensions(env: NodeJS.ProcessEnv): ReadonlyArray<string> {  const rawValue = env.PATHEXT;  const fallback = [".COM", ".EXE", ".BAT", ".CMD"];  if (!rawValue) return fallback;   const parsed: string[] = [];  for (const entry of rawValue.split(";")) {    const trimmed = entry.trim();    if (trimmed.length === 0) continue;    parsed.push(trimmed.startsWith(".") ? trimmed.toUpperCase() : `.${trimmed.toUpperCase()}`);  }  return parsed.length > 0 ? Array.from(new Set(parsed)) : fallback;} function resolveCommandCandidates(  command: string,  platform: NodeJS.Platform,  windowsPathExtensions: ReadonlyArray<string>,  extname: (path: string) => string,): ReadonlyArray<string> {  if (platform !== "win32") return [command];  const extension = extname(command);  const normalizedExtension = extension.toUpperCase();   if (extension.length > 0 && windowsPathExtensions.includes(normalizedExtension)) {    const commandWithoutExtension = command.slice(0, -extension.length);    return Array.from(      new Set([        command,        `${commandWithoutExtension}${normalizedExtension}`,        `${commandWithoutExtension}${normalizedExtension.toLowerCase()}`,      ]),    );  }   const candidates: string[] = [];  for (const candidateExtension of windowsPathExtensions) {    candidates.push(`${command}${candidateExtension}`);    candidates.push(`${command}${candidateExtension.toLowerCase()}`);  }  return Array.from(new Set(candidates));} // Session bootstrap resolves the same commands over and over, each PATH scan// costing hundreds of 'shell.isExecutableFile' filesystem probes (tens of// thousands per connect). Memoize the scan outcome per// (platform, PATH, PATHEXT, command) for a short window: repeat scans hit the// cache while any change to the search environment invalidates immediately.// Explicit-path resolution is never cached - callers probe paths they have

Lines 1–500 of 751.