Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 79 additions & 4 deletions packages/shared/src/shell.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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);
}),
);
});
Expand Down
69 changes: 56 additions & 13 deletions packages/shared/src/shell.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,20 +3,34 @@ 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__";
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,
Expand All @@ -43,10 +57,18 @@ export type CommandAvailabilityChecker = (
options?: CommandAvailabilityOptions,
) => Effect.Effect<boolean, never, FileSystem.FileSystem | Path.Path>;

export class CommandResolutionError extends Data.TaggedError("CommandResolutionError")<{
readonly command: string;
readonly reason: "not-found";
}> {}
export class CommandResolutionError extends Schema.TaggedErrorClass<CommandResolutionError>()(
"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;

Expand Down Expand Up @@ -495,10 +517,15 @@ const isExecutableFile = Effect.fn("shell.isExecutableFile")(function* (
filePath: string,
platform: NodeJS.Platform,
windowsPathExtensions: ReadonlyArray<string>,
): Effect.fn.Return<boolean, never, FileSystem.FileSystem | Path.Path> {
): Effect.fn.Return<boolean, PlatformError.PlatformError, FileSystem.FileSystem | Path.Path> {
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") {
Expand All @@ -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))) {
Expand All @@ -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* (
Expand Down
Loading