diff --git a/packages/shared/src/shell.test.ts b/packages/shared/src/shell.test.ts index e8b2c41cb77..910c94dd6c1 100644 --- a/packages/shared/src/shell.test.ts +++ b/packages/shared/src/shell.test.ts @@ -2,12 +2,16 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { it as effectIt } from "@effect/vitest"; import { HostProcessEnvironment, HostProcessPlatform } from "@t3tools/shared/hostProcess"; import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import * as PlatformError from "effect/PlatformError"; import { describe, expect, it, vi } from "vite-plus/test"; import { extractPathFromShellOutput, CommandAvailability, type CommandAvailabilityChecker, + CommandResolutionError, isCommandAvailable, listLoginShellCandidates, mergePathEntries, @@ -355,13 +359,84 @@ effectIt.layer(NodeServices.layer)("isCommandAvailable", (it) => { }); effectIt.layer(NodeServices.layer)("resolveCommandPath", (it) => { - it.effect("fails when PATH is empty", () => + it.effect("reports the unresolved command and platform when PATH is empty", () => Effect.gen(function* () { - const result = yield* resolveCommandPath("definitely-not-installed", { + const error = yield* resolveCommandPath("definitely-not-installed", { env: { PATH: "", PATHEXT: ".COM;.EXE;.BAT;.CMD" }, - }).pipe(Effect.provideService(HostProcessPlatform, "win32"), Effect.result); + }).pipe(Effect.provideService(HostProcessPlatform, "win32"), Effect.flip); - expect(result._tag).toBe("Failure"); + expect(error).toBeInstanceOf(CommandResolutionError); + expect(error.command).toBe("definitely-not-installed"); + expect(error.platform).toBe("win32"); + expect(error.message).toBe('Could not resolve command "definitely-not-installed" on win32.'); + }), + ); + + it.effect("continues past a failed PATH probe when a later executable exists", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const baseDirectory = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-command-resolution-", + }); + const blockedDirectory = path.join(baseDirectory, "blocked"); + const workingDirectory = path.join(baseDirectory, "working"); + const blockedCandidate = path.join(blockedDirectory, "available-command"); + const workingCandidate = path.join(workingDirectory, "available-command"); + yield* fileSystem.makeDirectory(blockedDirectory); + yield* fileSystem.makeDirectory(workingDirectory); + yield* fileSystem.writeFileString(workingCandidate, "#!/bin/sh\nexit 0\n"); + yield* fileSystem.chmod(workingCandidate, 0o755); + + const cause = PlatformError.systemError({ + _tag: "PermissionDenied", + module: "FileSystem", + method: "stat", + pathOrDescriptor: blockedCandidate, + }); + const failingFileSystem = FileSystem.FileSystem.of({ + ...fileSystem, + stat: (candidate) => + candidate === blockedCandidate ? Effect.fail(cause) : fileSystem.stat(candidate), + }); + + const resolved = yield* resolveCommandPath("available-command", { + env: { PATH: `${blockedDirectory}:${workingDirectory}` }, + }).pipe( + Effect.provideService(HostProcessPlatform, "linux"), + Effect.provideService(FileSystem.FileSystem, failingFileSystem), + ); + + expect(resolved).toBe(workingCandidate); + }), + ); + + it.effect("preserves filesystem failures after exhausting PATH candidates", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const cause = PlatformError.systemError({ + _tag: "PermissionDenied", + module: "FileSystem", + method: "stat", + pathOrDescriptor: "/bin/blocked-command", + }); + const failingFileSystem = FileSystem.FileSystem.of({ + ...fileSystem, + stat: () => Effect.fail(cause), + }); + + const error = yield* resolveCommandPath("blocked-command", { + env: { PATH: "/bin" }, + }).pipe( + Effect.provideService(HostProcessPlatform, "linux"), + Effect.provideService(FileSystem.FileSystem, failingFileSystem), + Effect.flip, + ); + + expect(error).toBeInstanceOf(CommandResolutionError); + expect(error.command).toBe("blocked-command"); + expect(error.platform).toBe("linux"); + expect(error.cause).toBe(cause); }), ); }); diff --git a/packages/shared/src/shell.ts b/packages/shared/src/shell.ts index d0aaa821680..246473da586 100644 --- a/packages/shared/src/shell.ts +++ b/packages/shared/src/shell.ts @@ -3,13 +3,14 @@ import * 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 Data from "effect/Data"; +import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; +import * as PlatformError from "effect/PlatformError"; +import * as Schema from "effect/Schema"; 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__"; @@ -17,6 +18,19 @@ 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; +const ProcessPlatform = Schema.Literals([ + "aix", + "android", + "darwin", + "freebsd", + "haiku", + "linux", + "openbsd", + "sunos", + "win32", + "cygwin", + "netbsd", +]); type ExecFileSyncLike = ( file: string, @@ -43,10 +57,18 @@ export type CommandAvailabilityChecker = ( options?: CommandAvailabilityOptions, ) => Effect.Effect; -export class CommandResolutionError extends Data.TaggedError("CommandResolutionError")<{ - readonly command: string; - readonly reason: "not-found"; -}> {} +export class CommandResolutionError extends Schema.TaggedErrorClass()( + "CommandResolutionError", + { + command: Schema.String, + platform: ProcessPlatform, + cause: Schema.optional(Schema.Defect()), + }, +) { + override get message(): string { + return `Could not resolve command ${JSON.stringify(this.command)} on ${this.platform}.`; + } +} const WINDOWS_SHELL_META_CHARS = /([()\][%!^"`<>&|;, *?])/g; @@ -495,10 +517,15 @@ const isExecutableFile = Effect.fn("shell.isExecutableFile")(function* ( filePath: string, platform: NodeJS.Platform, windowsPathExtensions: ReadonlyArray, -): Effect.fn.Return { +): Effect.fn.Return { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; - const stat = yield* fileSystem.stat(filePath).pipe(Effect.orElseSucceed(() => null)); + const stat = yield* fileSystem.stat(filePath).pipe( + Effect.catchIf( + (error) => error.reason._tag === "NotFound", + () => Effect.succeed(null), + ), + ); if (stat === null || stat.type !== "File") return false; if (platform === "win32") { @@ -524,19 +551,31 @@ const resolveCommandPathForPlatform = Effect.fn("shell.resolveCommandPathForPlat windowsPathExtensions, path.extname, ); + let firstProbeFailure: PlatformError.PlatformError | undefined; + const probeCandidate = (candidatePath: string) => + isExecutableFile(candidatePath, platform, windowsPathExtensions).pipe( + Effect.catch((cause) => { + firstProbeFailure ??= cause; + return Effect.succeed(false); + }), + ); if (command.includes("/") || command.includes("\\")) { for (const candidate of commandCandidates) { - if (yield* isExecutableFile(candidate, platform, windowsPathExtensions)) { + if (yield* probeCandidate(candidate)) { return candidate; } } - return yield* new CommandResolutionError({ command, reason: "not-found" }); + return yield* new CommandResolutionError({ + command, + platform, + ...(firstProbeFailure === undefined ? {} : { cause: firstProbeFailure }), + }); } const pathValue = resolvePathEnvironmentVariable(env); if (pathValue.length === 0) { - return yield* new CommandResolutionError({ command, reason: "not-found" }); + return yield* new CommandResolutionError({ command, platform }); } const pathEntries: string[] = []; for (const entry of pathValue.split(pathDelimiterForPlatform(platform))) { @@ -549,12 +588,16 @@ const resolveCommandPathForPlatform = Effect.fn("shell.resolveCommandPathForPlat for (const pathEntry of pathEntries) { for (const candidate of commandCandidates) { const candidatePath = path.join(pathEntry, candidate); - if (yield* isExecutableFile(candidatePath, platform, windowsPathExtensions)) { + if (yield* probeCandidate(candidatePath)) { return candidatePath; } } } - return yield* new CommandResolutionError({ command, reason: "not-found" }); + return yield* new CommandResolutionError({ + command, + platform, + ...(firstProbeFailure === undefined ? {} : { cause: firstProbeFailure }), + }); }); export const resolveCommandPath = Effect.fn("shell.resolveCommandPath")(function* (