650 lines · 18.0 KB · TypeScript
RawDownload
import {  type ChildProcess as ChildProcessHandle,  type ChildProcessWithoutNullStreams,  spawn,  spawnSync,  type StdioOptions,} from "node:child_process";import { extname, join } from "node:path"; import type { ServerRuntimeEnvironment } from "@t3tools/contracts";import { Effect, Exit, Scope } from "effect";import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import {  resolveCommandCandidates,  resolveExecutableFile,  resolvePathEnvironmentVariable,  resolveWindowsPathExtensions,  stripWrappingQuotes,} from "./commandResolution";import { detectServerRuntimeEnvironment } from "./runtimeEnvironment"; interface ProcessSpawnBaseOptions {  cwd?: string | undefined;  env?: NodeJS.ProcessEnv | undefined;  runtimeEnvironment?: ServerRuntimeEnvironment | undefined;  shell?: boolean | undefined;} interface RuntimeShellOptions {  runtimeEnvironment?: ServerRuntimeEnvironment | undefined;  shell?: boolean | string | undefined;} interface ProcessLaunchPlanOptions extends RuntimeShellOptions {  cwd?: string | undefined;  env?: NodeJS.ProcessEnv | Record<string, string | undefined> | undefined;  inheritParentEnv?: boolean | undefined;} interface ProcessSpawnOptions extends ProcessSpawnBaseOptions {  stdio?: StdioOptions | undefined;  detached?: boolean | undefined;} export interface RuntimeCommandOptions extends ChildProcess.CommandOptions {  runtimeEnvironment?: ServerRuntimeEnvironment | undefined;} interface ProcessSpawnSyncOptions extends ProcessSpawnBaseOptions {  stdio?: StdioOptions | undefined;  detached?: boolean | undefined;  encoding?: BufferEncoding | undefined;  input?: string | undefined;} export interface ProcessRunOptions {  cwd?: string | undefined;  timeoutMs?: number | undefined;  env?: NodeJS.ProcessEnv | undefined;  stdin?: string | undefined;  allowNonZeroExit?: boolean | undefined;  maxBufferBytes?: number | undefined;  outputMode?: "error" | "truncate" | undefined;  runtimeEnvironment?: ServerRuntimeEnvironment | undefined;  shell?: boolean | undefined;} export interface ProcessRunResult {  stdout: string;  stderr: string;  code: number | null;  signal: NodeJS.Signals | null;  timedOut: boolean;  stdoutTruncated?: boolean | undefined;  stderrTruncated?: boolean | undefined;} export interface ProcessLaunchPlan {  readonly command: string;  readonly args: ReadonlyArray<string>;  readonly shell: boolean | string;  readonly runtimeEnvironment: ServerRuntimeEnvironment;} interface ResolvedWindowsCommand {  readonly path: string;  readonly kind: "native" | "batch";} const WINDOWS_BATCH_EXECUTABLE_EXTENSIONS = new Set([".CMD", ".BAT"]); function commandLabel(command: string, args: readonly string[]): string {  return [command, ...args].join(" ");} function resolveRuntimeEnvironment(  runtimeEnvironment: ServerRuntimeEnvironment | undefined,): ServerRuntimeEnvironment {  return runtimeEnvironment ?? detectServerRuntimeEnvironment();} function resolveEffectiveEnvironment(options: ProcessLaunchPlanOptions): NodeJS.ProcessEnv {  const env = (options.env ?? {}) as NodeJS.ProcessEnv;  if (options.inheritParentEnv === false) {    return { ...env };  }   return {    ...process.env,    ...env,  };} function resolveWindowsCommand(  command: string,  env: NodeJS.ProcessEnv,  cwd?: string,): ResolvedWindowsCommand | null {  const windowsPathExtensions = resolveWindowsPathExtensions(env);  const candidates = resolveCommandCandidates(command, "win32", windowsPathExtensions);   const classify = (filePath: string): ResolvedWindowsCommand => {    const extension = extname(filePath).toUpperCase();    return {      path: filePath,      kind: WINDOWS_BATCH_EXECUTABLE_EXTENSIONS.has(extension) ? "batch" : "native",    };  };   if (command.includes("/") || command.includes("\\")) {    for (const candidate of candidates) {      const resolvedCandidate = resolveExecutableFile(candidate, {        platform: "win32",        windowsPathExtensions,        cwd,      });      if (resolvedCandidate) {        return classify(resolvedCandidate);      }    }    return null;  }   const pathEntries = resolvePathEnvironmentVariable(env)    .split(";")    .map((entry) => stripWrappingQuotes(entry.trim()))    .filter((entry) => entry.length > 0);   for (const pathEntry of pathEntries) {    for (const candidate of candidates) {      const candidatePath = join(pathEntry, candidate);      if (        resolveExecutableFile(candidatePath, {          platform: "win32",          windowsPathExtensions,        })      ) {        return classify(candidatePath);      }    }  }   return null;} function quoteWindowsBatchArgument(argument: string): string {  if (argument.length === 0) {    return '""';  }   const escaped = argument    .replaceAll("%", "%%")    .replace(/(\\*)"/g, (_match, slashes: string) => `${slashes}${slashes}\\"`)    .replace(/(\\+)$/g, "$1$1");  return `"${escaped}"`;} function buildWindowsBatchCommandLine(  command: string,  args: ReadonlyArray<string>,): string {  return [quoteWindowsBatchArgument(command), ...args.map(quoteWindowsBatchArgument)].join(" ");} function resolveWindowsCommandShell(env: NodeJS.ProcessEnv): string {  return env.ComSpec ?? env.COMSPEC ?? process.env.ComSpec ?? process.env.COMSPEC ?? "cmd.exe";} export function resolveProcessLaunchPlan(  command: string,  args: ReadonlyArray<string>,  options: ProcessLaunchPlanOptions = {},): ProcessLaunchPlan {  const runtimeEnvironment = resolveRuntimeEnvironment(options.runtimeEnvironment);  if (options.shell !== undefined) {    return {      command,      args: [...args],      shell: options.shell,      runtimeEnvironment,    };  }   if (runtimeEnvironment.platform !== "windows") {    return {      command,      args: [...args],      shell: false,      runtimeEnvironment,    };  }   const env = resolveEffectiveEnvironment(options);  const resolved = resolveWindowsCommand(command, env, options.cwd);  if (!resolved) {    return {      command,      args: [...args],      shell: false,      runtimeEnvironment,    };  }   if (resolved.kind === "batch") {    return {      command: resolveWindowsCommandShell(env),      args: ["/d", "/s", "/c", buildWindowsBatchCommandLine(resolved.path, args)],      shell: false,      runtimeEnvironment,    };  }   return {    command: resolved.path,    args: [...args],    shell: false,    runtimeEnvironment,  };} function toSpawnOptions(  options: ProcessSpawnOptions,  launchPlan: ProcessLaunchPlan,) {  return {    cwd: options.cwd,    env: options.env,    shell: launchPlan.shell,    ...(options.stdio !== undefined ? { stdio: options.stdio } : {}),    ...(options.detached !== undefined ? { detached: options.detached } : {}),  };} function toRuntimeCommandOptions(  options: RuntimeCommandOptions = {},  shell: boolean | string,): ChildProcess.CommandOptions {  const { runtimeEnvironment: _runtimeEnvironment, shell: _shell, ...commandOptions } = options;  return {    ...commandOptions,    shell,  };} export function makeRuntimeCommand(  command: string,  args: ReadonlyArray<string>,  options: RuntimeCommandOptions = {},): ChildProcess.StandardCommand {  const launchPlan = resolveProcessLaunchPlan(command, args, {    cwd: options.cwd,    env: options.env,    runtimeEnvironment: options.runtimeEnvironment,    shell: options.shell,    inheritParentEnv: options.extendEnv !== false,  });  return ChildProcess.make(launchPlan.command, launchPlan.args, {    ...toRuntimeCommandOptions(options, launchPlan.shell),  });} interface ManagedChildProcess {  readonly scope: Scope.Closeable;  readonly handle: ChildProcessSpawner.ChildProcessHandle;} export const spawnManagedCommand = (command: ChildProcess.Command) =>  Effect.gen(function* () {    const scope = yield* Scope.make("sequential");    const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;    const handle = yield* spawner.spawn(command).pipe(      Scope.provide(scope),      Effect.tapError(() => Scope.close(scope, Exit.void)),    );     return {      scope,      handle,    } satisfies ManagedChildProcess;  }); function spawnProcess(  command: string,  args: readonly string[],  options: ProcessSpawnOptions = {},): ChildProcessHandle {  const launchPlan = resolveProcessLaunchPlan(command, args, {    cwd: options.cwd,    env: options.env,    runtimeEnvironment: options.runtimeEnvironment,    shell: options.shell,    inheritParentEnv: options.env === undefined,  });   return spawn(launchPlan.command, launchPlan.args, toSpawnOptions(options, launchPlan));} function spawnPipedProcess(  command: string,  args: readonly string[],  options: Omit<ProcessSpawnOptions, "stdio" | "detached"> = {},): ChildProcessWithoutNullStreams {  return spawnProcess(command, args, {    ...options,    stdio: "pipe",  }) as ChildProcessWithoutNullStreams;} export function spawnProcessSync(  command: string,  args: readonly string[],  options: ProcessSpawnSyncOptions = {},) {  const launchPlan = resolveProcessLaunchPlan(command, args, {    cwd: options.cwd,    env: options.env,    runtimeEnvironment: options.runtimeEnvironment,    shell: options.shell,    inheritParentEnv: options.env === undefined,  });   return spawnSync(launchPlan.command, launchPlan.args, {    cwd: options.cwd,    env: options.env,    shell: launchPlan.shell,    encoding: options.encoding ?? "utf8",    ...(options.stdio !== undefined ? { stdio: options.stdio } : {}),    ...(options.detached !== undefined ? { detached: options.detached } : {}),    ...(options.input !== undefined ? { input: options.input } : {}),  });} export function spawnDetachedProcess(  command: string,  args: readonly string[],  options: Omit<ProcessSpawnOptions, "stdio" | "detached"> = {},): Promise<void> {  return new Promise<void>((resolve, reject) => {    const child = spawnProcess(command, args, {      ...options,      detached: true,      stdio: "ignore",    });     const handleSpawn = () => {      child.unref();      resolve();    };     child.once("spawn", handleSpawn);    child.once("error", (error) => {      reject(normalizeSpawnError(command, args, error));    });  });} function normalizeSpawnError(command: string, args: readonly string[], error: unknown): Error {  if (!(error instanceof Error)) {    return new Error(`Failed to run ${commandLabel(command, args)}.`);  }   const maybeCode = (error as NodeJS.ErrnoException).code;  if (maybeCode === "ENOENT") {    return new Error(`Command not found: ${command}`);  }   return new Error(`Failed to run ${commandLabel(command, args)}: ${error.message}`);} function isWindowsCommandNotFound(  code: number | null,  stderr: string,  runtimeEnvironment?: ServerRuntimeEnvironment,): boolean {  if (resolveRuntimeEnvironment(runtimeEnvironment).platform !== "windows") return false;  if (code === 9009) return true;  return /is not recognized as an internal or external command/i.test(stderr);} function normalizeExitError(  command: string,  args: readonly string[],  result: ProcessRunResult,  runtimeEnvironment?: ServerRuntimeEnvironment,): Error {  if (isWindowsCommandNotFound(result.code, result.stderr, runtimeEnvironment)) {    return new Error(`Command not found: ${command}`);  }   const reason = result.timedOut    ? "timed out"    : `failed (code=${result.code ?? "null"}, signal=${result.signal ?? "null"})`;  const stderr = result.stderr.trim();  const detail = stderr.length > 0 ? ` ${stderr}` : "";  return new Error(`${commandLabel(command, args)} ${reason}.${detail}`);} function normalizeStdinError(command: string, args: readonly string[], error: unknown): Error {  if (!(error instanceof Error)) {    return new Error(`Failed to write stdin for ${commandLabel(command, args)}.`);  }  return new Error(`Failed to write stdin for ${commandLabel(command, args)}: ${error.message}`);} function normalizeBufferError(  command: string,  args: readonly string[],  stream: "stdout" | "stderr",  maxBufferBytes: number,): Error {  return new Error(    `${commandLabel(command, args)} exceeded ${stream} buffer limit (${maxBufferBytes} bytes).`,  );} const DEFAULT_MAX_BUFFER_BYTES = 8 * 1024 * 1024; /** * On Windows, commands may still execute through a `cmd.exe` wrapper for * explicit shell usage or `.cmd` / `.bat` launchers. `child.kill()` only * terminates the wrapper, leaving the actual command running. Use * `taskkill /T` to kill the entire process tree instead. */function killProcessTree(  child: ChildProcessHandle,  options: {    runtimeEnvironment?: ServerRuntimeEnvironment | undefined;    signal?: NodeJS.Signals | undefined;  } = {},): void {  const signal = options.signal ?? "SIGTERM";  if (    resolveRuntimeEnvironment(options.runtimeEnvironment).platform === "windows" &&    child.pid !== undefined  ) {    try {      const result = spawnProcessSync("taskkill", ["/pid", String(child.pid), "/T", "/F"], {        stdio: "ignore",        shell: false,        runtimeEnvironment: options.runtimeEnvironment,      });      if (!result.error && result.status === 0) {        return;      }      if (result.error) {        throw result.error;      }    } catch {      // fallback to direct kill    }  }  child.kill(signal);} function appendChunkWithinLimit(  target: string,  currentBytes: number,  chunk: Buffer,  maxBytes: number,): {  next: string;  nextBytes: number;  truncated: boolean;} {  const remaining = maxBytes - currentBytes;  if (remaining <= 0) {    return { next: target, nextBytes: currentBytes, truncated: true };  }  if (chunk.length <= remaining) {    return {      next: `${target}${chunk.toString()}`,      nextBytes: currentBytes + chunk.length,      truncated: false,    };  }  return {    next: `${target}${chunk.subarray(0, remaining).toString()}`,    nextBytes: currentBytes + remaining,    truncated: true,

Lines 1–500 of 650.