import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Match from "effect/Match"; import { ChildProcessSpawner } from "effect/unstable/process"; import { type VcsError, VcsProcessExitError, type VcsProcessExitFailureKind, VcsProcessMissingExitCodeError, VcsProcessOutputLimitError, VcsProcessOutputReadError, VcsProcessSpawnError, VcsProcessStdinWriteError, VcsProcessTimeoutError, } from "@t3tools/contracts"; import * as ProcessRunner from "../processRunner.ts"; export interface VcsProcessInput { readonly operation: string; readonly command: string; readonly args: ReadonlyArray; readonly cwd: string; readonly spawnCwd?: string; readonly stdin?: string; readonly env?: NodeJS.ProcessEnv; readonly allowNonZeroExit?: boolean; readonly timeoutMs?: number; readonly maxOutputBytes?: number; readonly appendTruncationMarker?: boolean; } export interface VcsProcessOutput { readonly exitCode: ChildProcessSpawner.ExitCode; readonly stdout: string; readonly stderr: string; readonly stdoutTruncated: boolean; readonly stderrTruncated: boolean; /** Present on real process output; optional so narrow test doubles remain lightweight. */ readonly stdoutInvalidUtf8?: boolean; readonly stderrInvalidUtf8?: boolean; } export class VcsProcess extends Context.Service< VcsProcess, { readonly run: (input: VcsProcessInput) => Effect.Effect; } >()("t3/vcs/VcsProcess") {} const DEFAULT_TIMEOUT_MS = 30_000; const DEFAULT_MAX_OUTPUT_BYTES = 1_000_000; const OUTPUT_TRUNCATED_MARKER = "\n\n[truncated]"; const classifyNonZeroExit = (command: string, stderr: string): VcsProcessExitFailureKind => { const normalized = stderr.toLowerCase(); if ( normalized.includes("authentication failed") || normalized.includes("not logged in") || normalized.includes("gh auth login") || normalized.includes("glab auth login") || normalized.includes("az devops login") || normalized.includes("please run az login") || normalized.includes("no oauth token") || normalized.includes("unauthorized") ) { return "authentication"; } if ( normalized.includes("api rate limit") || normalized.includes("rate limit exceeded") || normalized.includes("secondary rate limit") || normalized.includes("too many requests") || normalized.includes("http 429") ) { return "rate-limited"; } if ( (command === "gh" && (normalized.includes("could not resolve to a pullrequest") || normalized.includes("repository.pullrequest") || normalized.includes("no pull requests found for branch") || normalized.includes("pull request not found"))) || (command === "glab" && (normalized.includes("merge request not found") || normalized.includes("not found") || normalized.includes("404"))) || (command === "az" && normalized.includes("pull request") && (normalized.includes("not found") || normalized.includes("does not exist"))) ) { return "not-found"; } return "command-failed"; }; export const make = Effect.gen(function* () { const processRunner = yield* ProcessRunner.ProcessRunner; const run = Effect.fn("VcsProcess.run")(function* (input: VcsProcessInput) { const baseError = { operation: input.operation, command: input.command, cwd: input.cwd, argumentCount: input.args.length, }; const result = yield* processRunner .run({ command: input.command, args: input.args, cwd: input.cwd, ...(input.spawnCwd !== undefined ? { spawnCwd: input.spawnCwd } : {}), ...(input.stdin !== undefined ? { stdin: input.stdin } : {}), ...(input.env !== undefined ? { env: input.env } : {}), timeout: input.timeoutMs ?? DEFAULT_TIMEOUT_MS, maxOutputBytes: input.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES, outputMode: "truncate", truncatedMarker: input.appendTruncationMarker ? OUTPUT_TRUNCATED_MARKER : "", timeoutBehavior: "error", }) .pipe( Effect.mapError( Match.valueTags({ ProcessSpawnError: (error) => VcsProcessSpawnError.fromProcessSpawnError(baseError, error), ProcessOutputLimitError: (error) => new VcsProcessOutputLimitError({ ...baseError, stream: error.stream, maxBytes: error.maxBytes, observedBytes: error.observedBytes, }), ProcessTimeoutError: (error) => VcsProcessTimeoutError.fromProcessTimeoutError(baseError, error), ProcessStdinError: (error) => new VcsProcessStdinWriteError({ ...baseError, stdinBytes: error.stdinBytes, cause: error.cause, }), ProcessReadError: (error) => new VcsProcessOutputReadError({ ...baseError, stream: error.stream, cause: error.cause, }), }), ), ); if (result.code === null) { return yield* new VcsProcessMissingExitCodeError(baseError); } if (!input.allowNonZeroExit && result.code !== 0) { return yield* VcsProcessExitError.fromProcessExit( baseError, { exitCode: result.code, stderr: result.stderr, stderrTruncated: result.stderrTruncated, }, classifyNonZeroExit(input.command, result.stderr), ); } return { exitCode: result.code, stdout: result.stdout, stderr: result.stderr, stdoutTruncated: result.stdoutTruncated, stderrTruncated: result.stderrTruncated, stdoutInvalidUtf8: result.stdoutInvalidUtf8 ?? false, stderrInvalidUtf8: result.stderrInvalidUtf8 ?? false, } satisfies VcsProcessOutput; }); return VcsProcess.of({ run }); }); export const layer = Layer.effect(VcsProcess, make).pipe(Layer.provide(ProcessRunner.layer));