diff --git a/apps/server/src/os-jank.ts b/apps/server/src/os-jank.ts index 93a40ae7e19..761f8dc5177 100644 --- a/apps/server/src/os-jank.ts +++ b/apps/server/src/os-jank.ts @@ -1,4 +1,5 @@ import * as NodeOS from "node:os"; +import * as Data from "effect/Data"; import * as Effect from "effect/Effect"; import * as Path from "effect/Path"; import { @@ -15,7 +16,11 @@ import { type WindowsCommandAvailabilityChecker = ( command: string, options?: CommandAvailabilityOptions, -) => boolean; +) => Effect.Effect; + +class ShellPathReadError extends Data.TaggedError("ShellPathReadError")<{ + readonly cause: unknown; +}> {} function logPathHydrationWarning(message: string, error?: unknown): void { process.stderr.write( @@ -23,7 +28,7 @@ function logPathHydrationWarning(message: string, error?: unknown): void { ); } -export function fixPath( +export const fixPath = Effect.fn("fixPath")(function* ( options: { env?: NodeJS.ProcessEnv; platform?: NodeJS.Platform; @@ -34,15 +39,15 @@ export function fixPath( userShell?: string; logWarning?: (message: string, error?: unknown) => void; } = {}, -): void { +) { const platform = options.platform ?? process.platform; const env = options.env ?? process.env; const logWarning = options.logWarning ?? logPathHydrationWarning; const readPath = options.readPath ?? readPathFromLoginShell; - try { + yield* Effect.gen(function* () { if (platform === "win32") { - const repairedEnvironment = resolveWindowsEnvironment(env, { + const repairedEnvironment = yield* resolveWindowsEnvironment(env, { readEnvironment: options.readWindowsEnvironment ?? readEnvironmentFromWindowsShell, ...(options.isWindowsCommandAvailable ? { commandAvailable: options.isWindowsCommandAvailable } @@ -60,11 +65,17 @@ export function fixPath( let shellPath: string | undefined; for (const shell of listLoginShellCandidates(platform, env.SHELL, options.userShell)) { - try { - shellPath = readPath(shell); - } catch (error) { - logWarning(`Failed to read PATH from login shell ${shell}.`, error); - } + shellPath = yield* Effect.try({ + try: () => readPath(shell), + catch: (error) => new ShellPathReadError({ cause: error }), + }).pipe( + Effect.catchTag("ShellPathReadError", (error) => + Effect.sync(() => { + logWarning(`Failed to read PATH from login shell ${shell}.`, error.cause); + return undefined; + }), + ), + ); if (shellPath) { break; @@ -79,10 +90,8 @@ export function fixPath( if (mergedPath) { env.PATH = mergedPath; } - } catch (error) { - logWarning("Failed to hydrate PATH from the user environment.", error); - } -} + }); +}); export const expandHomePath = Effect.fn(function* (input: string) { const { join } = yield* Path.Path; diff --git a/apps/server/src/process/externalLauncher.test.ts b/apps/server/src/process/externalLauncher.test.ts index 75e76b5e8e2..c59dbdae14c 100644 --- a/apps/server/src/process/externalLauncher.test.ts +++ b/apps/server/src/process/externalLauncher.test.ts @@ -672,17 +672,22 @@ it.layer(NodeServices.layer)("isCommandAvailable", (it) => { PATH: dir, PATHEXT: ".COM;.EXE;.BAT;.CMD", } satisfies NodeJS.ProcessEnv; - assert.equal(isCommandAvailable("code", { platform: "win32", env }), true); + assert.equal(yield* isCommandAvailable("code", { platform: "win32", env }), true); }), ); - it("returns false when a command is not on PATH", () => { - const env = { - PATH: "", - PATHEXT: ".COM;.EXE;.BAT;.CMD", - } satisfies NodeJS.ProcessEnv; - assert.equal(isCommandAvailable("definitely-not-installed", { platform: "win32", env }), false); - }); + it.effect("returns false when a command is not on PATH", () => + Effect.gen(function* () { + const env = { + PATH: "", + PATHEXT: ".COM;.EXE;.BAT;.CMD", + } satisfies NodeJS.ProcessEnv; + assert.equal( + yield* isCommandAvailable("definitely-not-installed", { platform: "win32", env }), + false, + ); + }), + ); it.effect("does not treat bare files without executable extension as available on win32", () => Effect.gen(function* () { @@ -694,7 +699,7 @@ it.layer(NodeServices.layer)("isCommandAvailable", (it) => { PATH: dir, PATHEXT: ".COM;.EXE;.BAT;.CMD", } satisfies NodeJS.ProcessEnv; - assert.equal(isCommandAvailable("npm", { platform: "win32", env }), false); + assert.equal(yield* isCommandAvailable("npm", { platform: "win32", env }), false); }), ); @@ -708,7 +713,7 @@ it.layer(NodeServices.layer)("isCommandAvailable", (it) => { PATH: dir, PATHEXT: ".COM;.EXE;.BAT;.CMD", } satisfies NodeJS.ProcessEnv; - assert.equal(isCommandAvailable("my.tool", { platform: "win32", env }), true); + assert.equal(yield* isCommandAvailable("my.tool", { platform: "win32", env }), true); }), ); @@ -724,7 +729,7 @@ it.layer(NodeServices.layer)("isCommandAvailable", (it) => { PATH: `${firstDir};${secondDir}`, PATHEXT: ".COM;.EXE;.BAT;.CMD", } satisfies NodeJS.ProcessEnv; - assert.equal(isCommandAvailable("code", { platform: "win32", env }), true); + assert.equal(yield* isCommandAvailable("code", { platform: "win32", env }), true); }), ); }); @@ -752,7 +757,7 @@ it.layer(NodeServices.layer)("resolveAvailableEditors", (it) => { yield* fs.writeFileString(path.join(dir, "rustrover.CMD"), "@echo off\r\n"); yield* fs.writeFileString(path.join(dir, "webstorm.CMD"), "@echo off\r\n"); yield* fs.writeFileString(path.join(dir, "explorer.CMD"), "MZ"); - const editors = resolveAvailableEditors("win32", { + const editors = yield* resolveAvailableEditors("win32", { PATH: dir, PATHEXT: ".COM;.EXE;.BAT;.CMD", }); @@ -788,17 +793,19 @@ it.layer(NodeServices.layer)("resolveAvailableEditors", (it) => { yield* fs.chmod(path.join(dir, "zeditor"), 0o755); yield* fs.chmod(path.join(dir, "xdg-open"), 0o755); - const editors = resolveAvailableEditors("linux", { + const editors = yield* resolveAvailableEditors("linux", { PATH: dir, }); assert.deepEqual(editors, ["zed", "file-manager"]); }), ); - it("omits file-manager when the platform opener is unavailable", () => { - const editors = resolveAvailableEditors("linux", { - PATH: "", - }); - assert.deepEqual(editors, []); - }); + it.effect("omits file-manager when the platform opener is unavailable", () => + Effect.gen(function* () { + const editors = yield* resolveAvailableEditors("linux", { + PATH: "", + }); + assert.deepEqual(editors, []); + }), + ); }); diff --git a/apps/server/src/process/externalLauncher.ts b/apps/server/src/process/externalLauncher.ts index da19864dcf8..92de49c0bd4 100644 --- a/apps/server/src/process/externalLauncher.ts +++ b/apps/server/src/process/externalLauncher.ts @@ -16,8 +16,10 @@ import { isCommandAvailable, type CommandAvailabilityOptions } from "@t3tools/sh import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Encoding from "effect/Encoding"; +import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; +import * as Path from "effect/Path"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; // ============================== @@ -109,17 +111,17 @@ function resolveEditorArgs( return [...baseArgs, ...resolveCommandEditorArgs(editor, target)]; } -function resolveAvailableCommand( +const resolveAvailableCommand = Effect.fn("externalLauncher.resolveAvailableCommand")(function* ( commands: ReadonlyArray, options: CommandAvailabilityOptions = {}, -): Option.Option { +): Effect.fn.Return, never, FileSystem.FileSystem | Path.Path> { for (const command of commands) { - if (isCommandAvailable(command, options)) { + if (yield* isCommandAvailable(command, options)) { return Option.some(command); } } return Option.none(); -} +}); function encodeUtf16LeBase64(input: string): string { const bytes = new Uint8Array(input.length * 2); @@ -212,29 +214,31 @@ export function resolveBrowserLaunch( }; } -export function resolveAvailableEditors( - platform: NodeJS.Platform = process.platform, - env: NodeJS.ProcessEnv = process.env, -): ReadonlyArray { - const available: EditorId[] = []; +export const resolveAvailableEditors = Effect.fn("externalLauncher.resolveAvailableEditors")( + function* ( + platform: NodeJS.Platform = process.platform, + env: NodeJS.ProcessEnv = process.env, + ): Effect.fn.Return, never, FileSystem.FileSystem | Path.Path> { + const available: EditorId[] = []; + + for (const editor of EDITORS) { + if (editor.commands === null) { + const command = fileManagerCommandForPlatform(platform); + if (yield* isCommandAvailable(command, { platform, env })) { + available.push(editor.id); + } + continue; + } - for (const editor of EDITORS) { - if (editor.commands === null) { - const command = fileManagerCommandForPlatform(platform); - if (isCommandAvailable(command, { platform, env })) { + const command = yield* resolveAvailableCommand(editor.commands, { platform, env }); + if (Option.isSome(command)) { available.push(editor.id); } - continue; - } - - const command = resolveAvailableCommand(editor.commands, { platform, env }); - if (Option.isSome(command)) { - available.push(editor.id); } - } - return available; -} + return available; + }, +); /** * ExternalLauncherShape - Service API for browser and editor launch actions. @@ -268,7 +272,7 @@ export const resolveEditorLaunch = Effect.fn("resolveEditorLaunch")(function* ( input: LaunchEditorInput, platform: NodeJS.Platform = process.platform, env: NodeJS.ProcessEnv = process.env, -): Effect.fn.Return { +): Effect.fn.Return { yield* Effect.annotateCurrentSpan({ "externalLauncher.editor": input.editor, "externalLauncher.cwd": input.cwd, @@ -281,7 +285,7 @@ export const resolveEditorLaunch = Effect.fn("resolveEditorLaunch")(function* ( if (editorDef.commands) { const command = Option.getOrElse( - resolveAvailableCommand(editorDef.commands, { platform, env }), + yield* resolveAvailableCommand(editorDef.commands, { platform, env }), () => editorDef.commands[0], ); return { @@ -320,8 +324,12 @@ export const launchBrowser = Effect.fn("externalLauncher.launchBrowser")(functio export const launchEditorProcess = Effect.fn("externalLauncher.launchEditorProcess")(function* ( launch: EditorLaunch, -): Effect.fn.Return { - if (!isCommandAvailable(launch.command)) { +): Effect.fn.Return< + void, + ExternalLauncherError, + ChildProcessSpawner.ChildProcessSpawner | FileSystem.FileSystem | Path.Path +> { + if (!(yield* isCommandAvailable(launch.command))) { return yield* new ExternalLauncherError({ message: `Editor command not found: ${launch.command}`, }); @@ -346,6 +354,16 @@ export const launchEditorProcess = Effect.fn("externalLauncher.launchEditorProce const make = Effect.gen(function* () { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + + const provideCommandResolutionServices = ( + effect: Effect.Effect, + ) => + effect.pipe( + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, path), + ); return { launchBrowser: (target) => @@ -353,9 +371,11 @@ const make = Effect.gen(function* () { Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), ), launchEditor: (input) => - Effect.flatMap(resolveEditorLaunch(input), (launch) => - launchEditorProcess(launch).pipe( - Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + provideCommandResolutionServices( + Effect.flatMap(resolveEditorLaunch(input), (launch) => + launchEditorProcess(launch).pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + ), ), ), } satisfies ExternalLauncherShape; diff --git a/apps/server/src/provider/providerMaintenance.test.ts b/apps/server/src/provider/providerMaintenance.test.ts index 73428f0a445..bd9894dbaf3 100644 --- a/apps/server/src/provider/providerMaintenance.test.ts +++ b/apps/server/src/provider/providerMaintenance.test.ts @@ -145,7 +145,7 @@ it.layer(NodeServices.layer)("providerMaintenance", (it) => { chmodSync(packageToolPath, 0o755); expect( - packageToolUpdate.resolve({ + yield* resolveProviderMaintenanceCapabilitiesEffect(packageToolUpdate, { binaryPath: "package-tool", platform: "darwin", env: { @@ -178,7 +178,7 @@ it.layer(NodeServices.layer)("providerMaintenance", (it) => { writeFileSync(path.join(bunBinDir, "native-package-tool.exe"), "MZ"); expect( - nativePackageToolUpdate.resolve({ + yield* resolveProviderMaintenanceCapabilitiesEffect(nativePackageToolUpdate, { binaryPath: "native-package-tool", platform: "win32", env: { @@ -214,7 +214,7 @@ it.layer(NodeServices.layer)("providerMaintenance", (it) => { chmodSync(scopedPackageToolPath, 0o755); expect( - scopedPackageToolUpdate.resolve({ + yield* resolveProviderMaintenanceCapabilitiesEffect(scopedPackageToolUpdate, { binaryPath: "scoped-package-tool", platform: "darwin", env: { @@ -273,7 +273,7 @@ it.layer(NodeServices.layer)("providerMaintenance", (it) => { chmodSync(nativePackageToolPath, 0o755); expect( - nativePackageToolUpdate.resolve({ + yield* resolveProviderMaintenanceCapabilitiesEffect(nativePackageToolUpdate, { binaryPath: "native-package-tool", platform: "darwin", env: { @@ -308,7 +308,7 @@ it.layer(NodeServices.layer)("providerMaintenance", (it) => { chmodSync(scopedPackageToolPath, 0o755); expect( - scopedPackageToolUpdate.resolve({ + yield* resolveProviderMaintenanceCapabilitiesEffect(scopedPackageToolUpdate, { binaryPath: "scoped-package-tool", platform: "darwin", env: { diff --git a/apps/server/src/provider/providerMaintenance.ts b/apps/server/src/provider/providerMaintenance.ts index 7f5e9d94dc5..5f50264c3ba 100644 --- a/apps/server/src/provider/providerMaintenance.ts +++ b/apps/server/src/provider/providerMaintenance.ts @@ -251,10 +251,7 @@ export function resolvePackageManagedProviderMaintenance( } const resolvedCommandPath = - resolveCommandPath(binaryPath, { - ...(options?.platform ? { platform: options.platform } : {}), - ...(options?.env ? { env: options.env } : {}), - }) ?? (hasPathSeparator(binaryPath) ? binaryPath : null); + options?.realCommandPath ?? (hasPathSeparator(binaryPath) ? binaryPath : null); if (resolvedCommandPath) { const commandPaths = [ @@ -336,10 +333,11 @@ export const resolveProviderMaintenanceCapabilitiesEffect = Effect.fn( } const resolvedCommandPath = - resolveCommandPath(binaryPath, { + (yield* resolveCommandPath(binaryPath, { ...(options?.platform ? { platform: options.platform } : {}), ...(options?.env ? { env: options.env } : {}), - }) ?? (hasPathSeparator(binaryPath) ? binaryPath : null); + }).pipe(Effect.catchTag("CommandResolutionError", () => Effect.succeed(null)))) ?? + (hasPathSeparator(binaryPath) ? binaryPath : null); if (!resolvedCommandPath) { return resolver.resolve(options); } diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 5c0bbb8425a..6f6fb4e0250 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -306,7 +306,7 @@ export const makeServerLayer = Layer.unwrap( Effect.gen(function* () { const config = yield* ServerConfig; - fixPath(); + yield* fixPath(); const httpListeningLayer = Layer.effectDiscard( Effect.gen(function* () { diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index da8e3f694db..3b5e231d467 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -732,7 +732,7 @@ const makeWsRpcLayer = (currentSession: AuthenticatedSession) => keybindings: keybindingsConfig.keybindings, issues: keybindingsConfig.issues, providers, - availableEditors: ExternalLauncher.resolveAvailableEditors(), + availableEditors: yield* ExternalLauncher.resolveAvailableEditors(), observability: { logsDirectoryPath: config.logsDir, localTracingEnabled: true, diff --git a/packages/shared/src/shell.test.ts b/packages/shared/src/shell.test.ts index a9e2dff6943..a5b86a7fa49 100644 --- a/packages/shared/src/shell.test.ts +++ b/packages/shared/src/shell.test.ts @@ -1,6 +1,11 @@ import { describe, expect, it, vi } from "vite-plus/test"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; import { + CommandResolutionError, extractPathFromShellOutput, isCommandAvailable, listLoginShellCandidates, @@ -15,6 +20,9 @@ import { resolveWindowsEnvironment, } from "./shell.ts"; +const runShellEffect = (effect: Effect.Effect) => + Effect.runPromise(effect.pipe(Effect.provide(NodeServices.layer))); + describe("extractPathFromShellOutput", () => { it("extracts the path between capture markers", () => { expect( @@ -323,49 +331,55 @@ describe("resolveKnownWindowsCliDirs", () => { }); describe("isCommandAvailable", () => { - it("returns false when PATH is empty", () => { + it("returns false when PATH is empty", async () => { expect( - isCommandAvailable("definitely-not-installed", { - platform: "win32", - env: { PATH: "", PATHEXT: ".COM;.EXE;.BAT;.CMD" }, - }), + await runShellEffect( + isCommandAvailable("definitely-not-installed", { + platform: "win32", + env: { PATH: "", PATHEXT: ".COM;.EXE;.BAT;.CMD" }, + }), + ), ).toBe(false); }); }); describe("resolveCommandPath", () => { - it("returns the first executable resolved from PATH", () => { - expect( - resolveCommandPath("definitely-not-installed", { - platform: "win32", - env: { PATH: "", PATHEXT: ".COM;.EXE;.BAT;.CMD" }, - }), - ).toBeNull(); + it("fails with a typed error when PATH is empty", async () => { + await expect( + runShellEffect( + resolveCommandPath("definitely-not-installed", { + platform: "win32", + env: { PATH: "", PATHEXT: ".COM;.EXE;.BAT;.CMD" }, + }), + ), + ).rejects.toBeInstanceOf(CommandResolutionError); }); }); describe("resolveWindowsEnvironment", () => { - it("returns the baseline no-profile PATH patch when node is already available", () => { + it("returns the baseline no-profile PATH patch when node is already available", async () => { const readEnvironment = vi.fn( (_names: ReadonlyArray, options?: { loadProfile?: boolean }) => options?.loadProfile ? { PATH: "C:\\Profile\\Bin" } : { PATH: "C:\\Shell\\Bin;C:\\Windows\\System32" }, ); - const commandAvailable = vi.fn(() => true); + const commandAvailable = vi.fn(() => Effect.succeed(true)); expect( - resolveWindowsEnvironment( - { - PATH: "C:\\Windows\\System32", - APPDATA: "C:\\Users\\testuser\\AppData\\Roaming", - LOCALAPPDATA: "C:\\Users\\testuser\\AppData\\Local", - USERPROFILE: "C:\\Users\\testuser", - }, - { - readEnvironment, - commandAvailable, - }, + await runShellEffect( + resolveWindowsEnvironment( + { + PATH: "C:\\Windows\\System32", + APPDATA: "C:\\Users\\testuser\\AppData\\Roaming", + LOCALAPPDATA: "C:\\Users\\testuser\\AppData\\Local", + USERPROFILE: "C:\\Users\\testuser", + }, + { + readEnvironment, + commandAvailable, + }, + ), ), ).toEqual({ PATH: [ @@ -389,7 +403,7 @@ describe("resolveWindowsEnvironment", () => { ); }); - it("loads the PowerShell profile when baseline env cannot resolve node", () => { + it("loads the PowerShell profile when baseline env cannot resolve node", async () => { const readEnvironment = vi.fn( (_names: ReadonlyArray, options?: { loadProfile?: boolean }) => options?.loadProfile @@ -400,20 +414,22 @@ describe("resolveWindowsEnvironment", () => { } : { PATH: "C:\\Shell\\Bin;C:\\Windows\\System32" }, ); - const commandAvailable = vi.fn(() => false); + const commandAvailable = vi.fn(() => Effect.succeed(false)); expect( - resolveWindowsEnvironment( - { - PATH: "C:\\Windows\\System32", - APPDATA: "C:\\Users\\testuser\\AppData\\Roaming", - LOCALAPPDATA: "C:\\Users\\testuser\\AppData\\Local", - USERPROFILE: "C:\\Users\\testuser", - }, - { - readEnvironment, - commandAvailable, - }, + await runShellEffect( + resolveWindowsEnvironment( + { + PATH: "C:\\Windows\\System32", + APPDATA: "C:\\Users\\testuser\\AppData\\Roaming", + LOCALAPPDATA: "C:\\Users\\testuser\\AppData\\Local", + USERPROFILE: "C:\\Users\\testuser", + }, + { + readEnvironment, + commandAvailable, + }, + ), ), ).toEqual({ PATH: [ @@ -437,24 +453,26 @@ describe("resolveWindowsEnvironment", () => { expect(commandAvailable).toHaveBeenCalledTimes(1); }); - it("keeps the baseline env when profiled probe still does not resolve node", () => { + it("keeps the baseline env when profiled probe still does not resolve node", async () => { const readEnvironment = vi.fn( (_names: ReadonlyArray, options?: { loadProfile?: boolean }) => options?.loadProfile ? { FNM_DIR: "C:\\Users\\testuser\\AppData\\Roaming\\fnm" } : {}, ); - const commandAvailable = vi.fn(() => false); + const commandAvailable = vi.fn(() => Effect.succeed(false)); expect( - resolveWindowsEnvironment( - { - PATH: "C:\\Windows\\System32", - APPDATA: "C:\\Users\\testuser\\AppData\\Roaming", - USERPROFILE: "C:\\Users\\testuser", - }, - { - readEnvironment, - commandAvailable, - }, + await runShellEffect( + resolveWindowsEnvironment( + { + PATH: "C:\\Windows\\System32", + APPDATA: "C:\\Users\\testuser\\AppData\\Roaming", + USERPROFILE: "C:\\Users\\testuser", + }, + { + readEnvironment, + commandAvailable, + }, + ), ), ).toEqual({ PATH: [ diff --git a/packages/shared/src/shell.ts b/packages/shared/src/shell.ts index c88ccc10d2a..2b4caab676d 100644 --- a/packages/shared/src/shell.ts +++ b/packages/shared/src/shell.ts @@ -1,8 +1,10 @@ // @effect-diagnostics nodeBuiltinImport:off import * as NodeOS from "node:os"; import { execFileSync } from "node:child_process"; -import { accessSync, constants, statSync } from "node:fs"; -import { extname, join } from "node:path"; +import * as Data from "effect/Data"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; const PATH_CAPTURE_START = "__T3CODE_PATH_START__"; const PATH_CAPTURE_END = "__T3CODE_PATH_END__"; @@ -22,6 +24,11 @@ export interface CommandAvailabilityOptions { readonly env?: NodeJS.ProcessEnv; } +export class CommandResolutionError extends Data.TaggedError("CommandResolutionError")<{ + readonly command: string; + readonly reason: "not-found"; +}> {} + export interface WindowsEnvironmentProbeOptions { readonly loadProfile?: boolean; } @@ -334,6 +341,7 @@ function resolveCommandCandidates( command: string, platform: NodeJS.Platform, windowsPathExtensions: ReadonlyArray, + extname: (path: string) => string, ): ReadonlyArray { if (platform !== "win32") return [command]; const extension = extname(command); @@ -358,46 +366,55 @@ function resolveCommandCandidates( return Array.from(new Set(candidates)); } -function isExecutableFile( +const isExecutableFile = Effect.fn("shell.isExecutableFile")(function* ( filePath: string, platform: NodeJS.Platform, windowsPathExtensions: ReadonlyArray, -): boolean { - try { - const stat = statSync(filePath); - if (!stat.isFile()) return false; - if (platform === "win32") { - const extension = extname(filePath); - if (extension.length === 0) return false; - return windowsPathExtensions.includes(extension.toUpperCase()); - } - accessSync(filePath, constants.X_OK); - return true; - } catch { +): Effect.fn.Return { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const stat = yield* fileSystem.stat(filePath).pipe(Effect.catch(() => Effect.succeed(null))); + if (stat === null || stat.type !== "File") { return false; } -} -export function resolveCommandPath( + if (platform === "win32") { + const extension = path.extname(filePath); + if (extension.length === 0) return false; + return windowsPathExtensions.includes(extension.toUpperCase()); + } + + return (stat.mode & 0o111) !== 0; +}); + +export const resolveCommandPath = Effect.fn("shell.resolveCommandPath")(function* ( command: string, options: CommandAvailabilityOptions = {}, -): string | null { +): Effect.fn.Return { + const path = yield* Path.Path; const platform = options.platform ?? process.platform; const env = options.env ?? process.env; const windowsPathExtensions = platform === "win32" ? resolveWindowsPathExtensions(env) : []; - const commandCandidates = resolveCommandCandidates(command, platform, windowsPathExtensions); + const commandCandidates = resolveCommandCandidates( + command, + platform, + windowsPathExtensions, + path.extname, + ); if (command.includes("/") || command.includes("\\")) { for (const candidate of commandCandidates) { - if (isExecutableFile(candidate, platform, windowsPathExtensions)) { + if (yield* isExecutableFile(candidate, platform, windowsPathExtensions)) { return candidate; } } - return null; + return yield* new CommandResolutionError({ command, reason: "not-found" }); } const pathValue = resolvePathEnvironmentVariable(env); - if (pathValue.length === 0) return null; + if (pathValue.length === 0) { + return yield* new CommandResolutionError({ command, reason: "not-found" }); + } const pathEntries: string[] = []; for (const entry of pathValue.split(pathDelimiterForPlatform(platform))) { const pathEntry = stripWrappingQuotes(entry.trim()); @@ -408,21 +425,24 @@ export function resolveCommandPath( for (const pathEntry of pathEntries) { for (const candidate of commandCandidates) { - const candidatePath = join(pathEntry, candidate); - if (isExecutableFile(candidatePath, platform, windowsPathExtensions)) { + const candidatePath = path.join(pathEntry, candidate); + if (yield* isExecutableFile(candidatePath, platform, windowsPathExtensions)) { return candidatePath; } } } - return null; -} + return yield* new CommandResolutionError({ command, reason: "not-found" }); +}); -export function isCommandAvailable( +export const isCommandAvailable = Effect.fn("shell.isCommandAvailable")(function* ( command: string, options: CommandAvailabilityOptions = {}, -): boolean { - return resolveCommandPath(command, options) !== null; -} +): Effect.fn.Return { + return yield* resolveCommandPath(command, options).pipe( + Effect.as(true), + Effect.catchTag("CommandResolutionError", () => Effect.succeed(false)), + ); +}); export function resolveKnownWindowsCliDirs(env: NodeJS.ProcessEnv): ReadonlyArray { const appData = env.APPDATA?.trim(); @@ -439,7 +459,10 @@ export function resolveKnownWindowsCliDirs(env: NodeJS.ProcessEnv): ReadonlyArra export interface WindowsEnvironmentResolverOptions { readonly readEnvironment?: WindowsShellEnvironmentReader; - readonly commandAvailable?: typeof isCommandAvailable; + readonly commandAvailable?: ( + command: string, + options?: CommandAvailabilityOptions, + ) => Effect.Effect; } function readWindowsEnvironmentSafely( @@ -467,10 +490,10 @@ function mergeWindowsEnv( return nextEnv; } -export function resolveWindowsEnvironment( +export const resolveWindowsEnvironment = Effect.fn("shell.resolveWindowsEnvironment")(function* ( env: NodeJS.ProcessEnv, options: WindowsEnvironmentResolverOptions = {}, -): Partial { +): Effect.fn.Return, never, FileSystem.FileSystem | Path.Path> { const readEnvironment = options.readEnvironment ?? readEnvironmentFromWindowsShell; const commandAvailable = options.commandAvailable ?? isCommandAvailable; const inheritedPath = readEnvPath(env); @@ -483,7 +506,7 @@ export function resolveWindowsEnvironment( const baselinePatch: Partial = baselinePath ? { PATH: baselinePath } : {}; const baselineEnv = mergeWindowsEnv(env, baselinePatch); - if (commandAvailable("node", { platform: "win32", env: baselineEnv })) { + if (yield* commandAvailable("node", { platform: "win32", env: baselineEnv })) { return baselinePatch; } @@ -503,4 +526,4 @@ export function resolveWindowsEnvironment( return Object.keys(profiledPatch).length > 0 ? { ...baselinePatch, ...profiledPatch } : baselinePatch; -} +});