authorgravatar for saiansh2525@gmail.comYash Singh <saiansh2525@gmail.com> 2026-10-12 20:04:34-05:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-12 20:04:34-05:00
loge2e95a39d309e1ffec901682cc88ea1ba276c868
tree46a92e2c520f3b269945d26fa85d253317bd47d2
parent8110edfddd3fb4d7fd2e0c9d29256c3a1b9da5c8
signature Signed by PGP key B5690EEEBB952194

fix(server): return null for unresolved optional commands (#18871)


9 files changed, 140 insertions(+), 52 deletions(-)

apps/server/src/process/agentScope.ts+8-9
...@@ -106,7 +106,6 @@ export const make = Effect.gen(function* () {...@@ -106,7 +106,6 @@ export const make = Effect.gen(function* () {
106 Effect.provideService(FileSystem.FileSystem, fileSystem),106 Effect.provideService(FileSystem.FileSystem, fileSystem),
107 Effect.provideService(Path.Path, path),107 Effect.provideService(Path.Path, path),
108 Effect.provideService(HostProcess.Platform, platform),108 Effect.provideService(HostProcess.Platform, platform),
109 Effect.option,
110 );109 );
111110
112 const run = (command: string, args: ReadonlyArray<string>) =>111 const run = (command: string, args: ReadonlyArray<string>) =>
...@@ -120,9 +119,9 @@ export const make = Effect.gen(function* () {...@@ -120,9 +119,9 @@ export const make = Effect.gen(function* () {
120 if (runtimeDir === undefined || runtimeDir.length === 0) return undefined;119 if (runtimeDir === undefined || runtimeDir.length === 0) return undefined;
121 const systemdRun = yield* resolve("systemd-run", hostEnvironment);120 const systemdRun = yield* resolve("systemd-run", hostEnvironment);
122 const systemctl = yield* resolve("systemctl", hostEnvironment);121 const systemctl = yield* resolve("systemctl", hostEnvironment);
123 if (systemdRun._tag === "None" || systemctl._tag === "None") return undefined;122 if (systemdRun === null || systemctl === null) return undefined;
124 const probe = (options: ReadonlyArray<string>) =>123 const probe = (options: ReadonlyArray<string>) =>
125 run(systemdRun.value, [124 run(systemdRun, [
126 "--user",125 "--user",
127 "--scope",126 "--scope",
128 "--quiet",127 "--quiet",
...@@ -146,7 +145,7 @@ export const make = Effect.gen(function* () {...@@ -146,7 +145,7 @@ export const make = Effect.gen(function* () {
146 return undefined;145 return undefined;
147 }146 }
148 const limits = agentSliceMemoryLimits(NodeOS.totalmem());147 const limits = agentSliceMemoryLimits(NodeOS.totalmem());
149 const limited = yield* run(systemctl.value, [148 const limited = yield* run(systemctl, [
150 "--user",149 "--user",
151 "set-property",150 "set-property",
152 "--runtime",151 "--runtime",
...@@ -165,8 +164,8 @@ export const make = Effect.gen(function* () {...@@ -165,8 +164,8 @@ export const make = Effect.gen(function* () {
165 oomPolicy: scopeOptions.length > 0,164 oomPolicy: scopeOptions.length > 0,
166 });165 });
167 return {166 return {
168 systemdRun: systemdRun.value,167 systemdRun,
169 systemctl: systemctl.value,168 systemctl,
170 runtimeDir,169 runtimeDir,
171 scopeOptions,170 scopeOptions,
172 };171 };
...@@ -294,9 +293,9 @@ export const make = Effect.gen(function* () {...@@ -294,9 +293,9 @@ export const make = Effect.gen(function* () {
294 wrap: Effect.fn("AgentScope.wrap")(function* ({ command, args, name, threadId, env }) {293 wrap: Effect.fn("AgentScope.wrap")(function* ({ command, args, name, threadId, env }) {
295 const resolved = yield* resolve(command, env ?? hostEnvironment);294 const resolved = yield* resolve(command, env ?? hostEnvironment);
296 // Leave unresolved commands alone so the spawn reports the missing binary.295 // Leave unresolved commands alone so the spawn reports the missing binary.
297 if (resolved._tag === "None") return { command, args };296 if (resolved === null) return { command, args };
298 const scope = yield* systemd;297 const scope = yield* systemd;
299 if (scope === undefined) return agentScopeCommand({ command: resolved.value, args });298 if (scope === undefined) return agentScopeCommand({ command: resolved, args });
300 const unit = `t3code-${name}-${NodeCrypto.randomUUID().slice(0, 8)}.scope`;299 const unit = `t3code-${name}-${NodeCrypto.randomUUID().slice(0, 8)}.scope`;
301 if (threadId !== undefined) yield* track(scope.systemctl, threadId, unit);300 if (threadId !== undefined) yield* track(scope.systemctl, threadId, unit);
302 const systemdScope: AgentSystemdScope = {301 const systemdScope: AgentSystemdScope = {
...@@ -308,7 +307,7 @@ export const make = Effect.gen(function* () {...@@ -308,7 +307,7 @@ export const make = Effect.gen(function* () {
308 // the unit even when it fails.307 // the unit even when it fails.
309 options: threadId === undefined ? [...scope.scopeOptions, "--collect"] : scope.scopeOptions,308 options: threadId === undefined ? [...scope.scopeOptions, "--collect"] : scope.scopeOptions,
310 };309 };
311 return agentScopeCommand({ command: resolved.value, args, scope: systemdScope });310 return agentScopeCommand({ command: resolved, args, scope: systemdScope });
312 }),311 }),
313 oomKilled,312 oomKilled,
314 clear,313 clear,
apps/server/src/provider/CodexInstallation.ts+1
...@@ -335,6 +335,7 @@ export const makeCodexInstallation = Effect.fn("makeCodexInstallation")(function...@@ -335,6 +335,7 @@ export const makeCodexInstallation = Effect.fn("makeCodexInstallation")(function
335 Effect.provideService(FileSystem.FileSystem, fs),335 Effect.provideService(FileSystem.FileSystem, fs),
336 Effect.provideService(Path.Path, path),336 Effect.provideService(Path.Path, path),
337 );337 );
338 if (executablePath === null) return null;
338 // Keep launcher symlinks intact: version-manager shims dispatch by their invoked name.339 // Keep launcher symlinks intact: version-manager shims dispatch by their invoked name.
339 const realExecutablePath = yield* fs.realPath(executablePath);340 const realExecutablePath = yield* fs.realPath(executablePath);
340 // A PATH entry pointing into T3's download remains a managed installation.341 // A PATH entry pointing into T3's download remains a managed installation.
apps/server/src/provider/antigravityAuthSupport.test.ts-1
...@@ -592,7 +592,6 @@ it.layer(NodeServices.layer)("Antigravity profile preparation", (it) => {...@@ -592,7 +592,6 @@ it.layer(NodeServices.layer)("Antigravity profile preparation", (it) => {
592 detail: expect.stringContaining("Install Node.js"),592 detail: expect.stringContaining("Install Node.js"),
593 cause: {593 cause: {
594 _tag: "NodeRuntimeUnavailableError",594 _tag: "NodeRuntimeUnavailableError",
595 cause: { _tag: "CommandResolutionError" },
596 },595 },
597 });596 });
598 }597 }
apps/server/src/provider/providerInstallation.test.ts+21
...@@ -238,4 +238,25 @@ describe("provider installation routing", () => {...@@ -238,4 +238,25 @@ describe("provider installation routing", () => {
238 assert.deepEqual(harness.calls, ["remove", "refresh"]);238 assert.deepEqual(harness.calls, ["remove", "refresh"]);
239 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),239 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
240 );240 );
241
242 it.effect("keeps only the configured path when another instance's binary is missing", () =>
243 Effect.gen(function* () {
244 const fs = yield* FileSystem.FileSystem;
245 const path = yield* Path.Path;
246 const directory = yield* fs.makeTempDirectoryScoped({ prefix: "t3-provider-install-route-" });
247 const binaryPath = path.join(directory, "missing");
248 const harness = yield* makeHarness({
249 settings: {
250 providerInstances: {
251 [ProviderInstanceId.make("antigravity-work")]: {
252 driver,
253 config: { binaryPath },
254 },
255 },
256 },
257 });
258 yield* harness.router.remove({ instanceId });
259 assert.deepEqual(harness.protectedPaths(), [binaryPath]);
260 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
261 );
241});262});
apps/server/src/provider/providerInstallation.ts+3-5
...@@ -141,12 +141,10 @@ export const makeProviderInstallation = Effect.fn("makeProviderInstallation")(fu...@@ -141,12 +141,10 @@ export const makeProviderInstallation = Effect.fn("makeProviderInstallation")(fu
141 return [];141 return [];
142 }142 }
143 const binaryPath = entry.config.binaryPath.trim();143 const binaryPath = entry.config.binaryPath.trim();
144 return yield* resolveCommandPath(binaryPath, {144 const resolved = yield* resolveCommandPath(binaryPath, {
145 env: yield* mergeProviderInstanceEnvironment(entry.environment),145 env: yield* mergeProviderInstanceEnvironment(entry.environment),
146 }).pipe(146 });
147 Effect.map((resolved) => [binaryPath, resolved]),147 return resolved === null ? [binaryPath] : [binaryPath, resolved];
148 Effect.orElseSucceed(() => [binaryPath]),
149 );
150 }),148 }),
151 );149 );
152 yield* installation150 yield* installation
packages/provider-core/src/server/maintenanceResolver.ts+2-6
...@@ -498,9 +498,7 @@ export const resolvePackageManagedProviderMaintenance = Effect.fn(...@@ -498,9 +498,7 @@ export const resolvePackageManagedProviderMaintenance = Effect.fn(
498 const kegBrewPath = path.join(homebrew.prefix, "bin", "brew");498 const kegBrewPath = path.join(homebrew.prefix, "bin", "brew");
499 const brewPath = (yield* fileSystem.exists(kegBrewPath).pipe(Effect.orElseSucceed(() => false)))499 const brewPath = (yield* fileSystem.exists(kegBrewPath).pipe(Effect.orElseSucceed(() => false)))
500 ? kegBrewPath500 ? kegBrewPath
501 : yield* resolveCommandPath("brew", { env: context.env }).pipe(501 : yield* resolveCommandPath("brew", { env: context.env });
502 Effect.catchTags({ CommandResolutionError: () => Effect.succeed(null) }),
503 );
504 if (!brewPath) {502 if (!brewPath) {
505 return fallback;503 return fallback;
506 }504 }
...@@ -648,9 +646,7 @@ export const resolveProviderMaintenanceCapabilitiesEffect = Effect.fn(...@@ -648,9 +646,7 @@ export const resolveProviderMaintenanceCapabilitiesEffect = Effect.fn(
648 // resolveCommandPath checks explicit paths for existence too, so a missing646 // resolveCommandPath checks explicit paths for existence too, so a missing
649 // binary always lands in the no-context branch and never gets an update647 // binary always lands in the no-context branch and never gets an update
650 // command it cannot run.648 // command it cannot run.
651 const resolvedCommandPath = yield* resolveCommandPath(binaryPath, { env }).pipe(649 const resolvedCommandPath = yield* resolveCommandPath(binaryPath, { env });
652 Effect.catchTags({ CommandResolutionError: () => Effect.succeed(null) }),
653 );
654 if (!resolvedCommandPath) {650 if (!resolvedCommandPath) {
655 return yield* resolver.resolve(null);651 return yield* resolver.resolve(null);
656 }652 }
packages/shared/src/nodeRuntime.ts+3-3
...@@ -76,15 +76,15 @@ export const resolveNodeExecutable = Effect.fn("nodeRuntime.resolveNodeExecutabl...@@ -76,15 +76,15 @@ export const resolveNodeExecutable = Effect.fn("nodeRuntime.resolveNodeExecutabl
76 const path = yield* Path.Path;76 const path = yield* Path.Path;
77 const platform = yield* HostProcess.Platform;77 const platform = yield* HostProcess.Platform;
78 const env = environment ?? (yield* HostProcess.Environment);78 const env = environment ?? (yield* HostProcess.Environment);
79 const nodePath = yield* resolveCommandPath(platform === "win32" ? "node.exe" : "node", {79 const resolvedNodePath = yield* resolveCommandPath(platform === "win32" ? "node.exe" : "node", {
80 // Batch wrappers require a shell; helper callers launch the runtime directly.80 // Batch wrappers require a shell; helper callers launch the runtime directly.
81 env: platform === "win32" ? { ...env, PATHEXT: ".EXE" } : env,81 env: platform === "win32" ? { ...env, PATHEXT: ".EXE" } : env,
82 }).pipe(82 }).pipe(
83 // Refresh immediately after the user installs Node and retries setup.83 // Refresh immediately after the user installs Node and retries setup.
84 Effect.provideService(CommandResolutionCache, new Map()),84 Effect.provideService(CommandResolutionCache, new Map()),
85 Effect.map((commandPath) => path.resolve(commandPath)),
86 Effect.mapError((cause) => new NodeRuntimeUnavailableError({ feature, cause })),
87 );85 );
86 if (resolvedNodePath === null) return yield* new NodeRuntimeUnavailableError({ feature });
87 const nodePath = path.resolve(resolvedNodePath);
88 // A launcher or symlink named node must not point back at the standalone app.88 // A launcher or symlink named node must not point back at the standalone app.
89 const resolvedPath = yield* fs89 const resolvedPath = yield* fs
90 .realPath(nodePath)90 .realPath(nodePath)
packages/shared/src/shell.test.ts+97-11
...@@ -2,9 +2,12 @@ import * as NodeServices from "@effect/platform-node/NodeServices";...@@ -2,9 +2,12 @@ import * as NodeServices from "@effect/platform-node/NodeServices";
2import { it as effectIt } from "@effect/vitest";2import { it as effectIt } from "@effect/vitest";
3import * as HostProcess from "@t3tools/shared/HostProcess";3import * as HostProcess from "@t3tools/shared/HostProcess";
4import * as Effect from "effect/Effect";4import * as Effect from "effect/Effect";
5import * as Exit from "effect/Exit";
5import * as FileSystem from "effect/FileSystem";6import * as FileSystem from "effect/FileSystem";
6import * as Path from "effect/Path";7import * as Path from "effect/Path";
8import * as Result from "effect/Result";
7import * as TestClock from "effect/testing/TestClock";9import * as TestClock from "effect/testing/TestClock";
10import * as Tracer from "effect/Tracer";
8import { describe, expect, it, vi } from "vite-plus/test";11import { describe, expect, it, vi } from "vite-plus/test";
912
10import {13import {
...@@ -41,6 +44,19 @@ const withWindowsEnvironmentMocks = <A, E, R>(...@@ -41,6 +44,19 @@ const withWindowsEnvironmentMocks = <A, E, R>(
41 Effect.provideService(CommandAvailability, commandAvailable),44 Effect.provideService(CommandAvailability, commandAvailable),
42 );45 );
4346
47const recordCommandTrace = Effect.fnUntraced(function* <A, E, R>(effect: Effect.Effect<A, E, R>) {
48 const spans: Array<Tracer.NativeSpan> = [];
49 const tracer = Tracer.make({
50 span: (options) => {
51 const span = new Tracer.NativeSpan(options);
52 spans.push(span);
53 return span;
54 },
55 });
56 const result = yield* effect.pipe(Effect.withTracer(tracer), Effect.result);
57 return { result, spans };
58});
59
44describe("readPathFromLoginShell", () => {60describe("readPathFromLoginShell", () => {
45 it("uses a shell-agnostic printenv PATH probe", () => {61 it("uses a shell-agnostic printenv PATH probe", () => {
46 const execFile = vi.fn<62 const execFile = vi.fn<
...@@ -338,6 +354,65 @@ describe("resolveKnownWindowsCliDirs", () => {...@@ -338,6 +354,65 @@ describe("resolveKnownWindowsCliDirs", () => {
338});354});
339355
340effectIt.layer(NodeServices.layer)("isCommandAvailable", (it) => {356effectIt.layer(NodeServices.layer)("isCommandAvailable", (it) => {
357 it.effect.each(["win32", "linux"] as const)(
358 "records missing optional commands as successful lookups on %s",
359 (platform) =>
360 Effect.gen(function* () {
361 const fs = yield* FileSystem.FileSystem;
362 const path = yield* Path.Path;
363 const cwd = yield* fs.makeTempDirectoryScoped({ prefix: "t3-command-trace-" });
364 const env = { PATH: cwd, PATHEXT: ".CMD" };
365 const checks = [
366 isCommandAvailable("missing", { env }),
367 isCommandAvailable("missing", { env }),
368 isCommandAvailable("missing", { env: { ...env, PATH: "" } }),
369 isCommandAvailable(path.join(cwd, "missing.cmd"), { env }),
370 ];
371 for (const check of checks) {
372 const { result, spans } = yield* recordCommandTrace(check);
373 expect(Result.getOrThrow(result)).toBe(false);
374 expect(spans.length).toBeGreaterThan(0);
375 for (const span of spans) {
376 expect(span.status._tag).toBe("Ended");
377 if (span.status._tag === "Ended") {
378 expect(Exit.isSuccess(span.status.exit), span.name).toBe(true);
379 }
380 }
381 }
382 }).pipe(
383 Effect.provideService(HostProcess.Platform, platform),
384 Effect.provideService(CommandResolutionCache, new Map()),
385 ),
386 );
387
388 it.effect.each(["win32", "linux"] as const)(
389 "records available commands as successful lookups on %s",
390 (platform) =>
391 Effect.gen(function* () {
392 const fs = yield* FileSystem.FileSystem;
393 const path = yield* Path.Path;
394 const cwd = yield* fs.makeTempDirectoryScoped({ prefix: "t3-command-trace-" });
395 const executable = path.join(cwd, platform === "win32" ? "available.cmd" : "available");
396 yield* fs.writeFileString(executable, "");
397 yield* fs.chmod(executable, 0o700);
398 const env = { PATH: cwd, PATHEXT: ".CMD" };
399 for (const command of ["available", executable]) {
400 const { result, spans } = yield* recordCommandTrace(isCommandAvailable(command, { env }));
401 expect(Result.getOrThrow(result)).toBe(true);
402 expect(spans.length).toBeGreaterThan(0);
403 for (const span of spans) {
404 expect(span.status._tag).toBe("Ended");
405 if (span.status._tag === "Ended") {
406 expect(Exit.isSuccess(span.status.exit), span.name).toBe(true);
407 }
408 }
409 }
410 }).pipe(
411 Effect.provideService(HostProcess.Platform, platform),
412 Effect.provideService(CommandResolutionCache, new Map()),
413 ),
414 );
415
341 it.effect("returns false when PATH is empty", () =>416 it.effect("returns false when PATH is empty", () =>
342 Effect.gen(function* () {417 Effect.gen(function* () {
343 expect(418 expect(
...@@ -350,13 +425,29 @@ effectIt.layer(NodeServices.layer)("isCommandAvailable", (it) => {...@@ -350,13 +425,29 @@ effectIt.layer(NodeServices.layer)("isCommandAvailable", (it) => {
350});425});
351426
352effectIt.layer(NodeServices.layer)("resolveCommandPath", (it) => {427effectIt.layer(NodeServices.layer)("resolveCommandPath", (it) => {
353 it.effect("fails when PATH is empty", () =>428 it.effect("records a successful lookup when a command is missing", () =>
429 Effect.gen(function* () {
430 const { result, spans } = yield* recordCommandTrace(
431 resolveCommandPath("missing", { env: { PATH: "" } }),
432 );
433 expect(Result.getOrThrow(result)).toBeNull();
434 expect(spans.length).toBeGreaterThan(0);
435 for (const span of spans) {
436 expect(span.status._tag).toBe("Ended");
437 if (span.status._tag === "Ended") {
438 expect(Exit.isSuccess(span.status.exit), span.name).toBe(true);
439 }
440 }
441 }),
442 );
443
444 it.effect("returns null when PATH is empty", () =>
354 Effect.gen(function* () {445 Effect.gen(function* () {
355 const result = yield* resolveCommandPath("definitely-not-installed", {446 const result = yield* resolveCommandPath("definitely-not-installed", {
356 env: { PATH: "", PATHEXT: ".COM;.EXE;.BAT;.CMD" },447 env: { PATH: "", PATHEXT: ".COM;.EXE;.BAT;.CMD" },
357 }).pipe(Effect.provideService(HostProcess.Platform, "win32"), Effect.result);448 }).pipe(Effect.provideService(HostProcess.Platform, "win32"));
358449
359 expect(result._tag).toBe("Failure");450 expect(result).toBeNull();
360 }),451 }),
361 );452 );
362453
...@@ -379,10 +470,9 @@ effectIt.layer(NodeServices.layer)("resolveCommandPath", (it) => {...@@ -379,10 +470,9 @@ effectIt.layer(NodeServices.layer)("resolveCommandPath", (it) => {
379 }),470 }),
380 }),471 }),
381 ),472 ),
382 Effect.result,
383 );473 );
384474
385 expect(result._tag).toBe("Failure");475 expect(result).toBeNull();
386 return probed;476 return probed;
387 });477 });
388478
...@@ -448,13 +538,9 @@ effectIt.layer(NodeServices.layer)("resolveCommandPath", (it) => {...@@ -448,13 +538,9 @@ effectIt.layer(NodeServices.layer)("resolveCommandPath", (it) => {
448 const executable = path.join(cwd, "appeared.CMD");538 const executable = path.join(cwd, "appeared.CMD");
449 const options = { env: { PATH: `${cwd};${cwd}`, PATHEXT: ".CMD" } };539 const options = { env: { PATH: `${cwd};${cwd}`, PATHEXT: ".CMD" } };
450540
451 expect((yield* resolveCommandPath("appeared", options).pipe(Effect.result))._tag).toBe(541 expect(yield* resolveCommandPath("appeared", options)).toBeNull();
452 "Failure",
453 );
454 yield* fs.writeFileString(executable, "@echo off\n");542 yield* fs.writeFileString(executable, "@echo off\n");
455 expect((yield* resolveCommandPath("appeared", options).pipe(Effect.result))._tag).toBe(543 expect(yield* resolveCommandPath("appeared", options)).toBeNull();
456 "Failure",
457 );
458 expect(yield* resolveCommandPath(executable, options)).toBe(executable);544 expect(yield* resolveCommandPath(executable, options)).toBe(executable);
459 yield* TestClock.adjust("30 seconds");545 yield* TestClock.adjust("30 seconds");
460 expect(yield* resolveCommandPath("appeared", options)).toBe(executable);546 expect(yield* resolveCommandPath("appeared", options)).toBe(executable);
packages/shared/src/shell.ts+5-17
...@@ -5,7 +5,6 @@ import * as NodeChildProcess from "node:child_process";...@@ -5,7 +5,6 @@ import * as NodeChildProcess from "node:child_process";
5import * as NodeFS from "node:fs";5import * as NodeFS from "node:fs";
6import * as Cache from "effect/Cache";6import * as Cache from "effect/Cache";
7import * as Clock from "effect/Clock";7import * as Clock from "effect/Clock";
8import * as Data from "effect/Data";
9import * as Effect from "effect/Effect";8import * as Effect from "effect/Effect";
10import * as FileSystem from "effect/FileSystem";9import * as FileSystem from "effect/FileSystem";
11import * as Option from "effect/Option";10import * as Option from "effect/Option";
...@@ -43,11 +42,6 @@ export type CommandAvailabilityChecker = (...@@ -43,11 +42,6 @@ export type CommandAvailabilityChecker = (
43 options?: CommandAvailabilityOptions,42 options?: CommandAvailabilityOptions,
44) => Effect.Effect<boolean, never, FileSystem.FileSystem | Path.Path>;43) => Effect.Effect<boolean, never, FileSystem.FileSystem | Path.Path>;
4544
46export class CommandResolutionError extends Data.TaggedError("CommandResolutionError")<{
47 readonly command: string;
48 readonly reason: "not-found";
49}> {}
50
51const WINDOWS_SHELL_META_CHARS = /([()\][%!^"`<>&|;, *?])/g;45const WINDOWS_SHELL_META_CHARS = /([()\][%!^"`<>&|;, *?])/g;
5246
53/**47/**
...@@ -612,7 +606,7 @@ const isExecutableFile = Effect.fnUntraced(function* (...@@ -612,7 +606,7 @@ const isExecutableFile = Effect.fnUntraced(function* (
612const resolveCommandPathForPlatform = Effect.fn("shell.resolveCommandPathForPlatform")(function* (606const resolveCommandPathForPlatform = Effect.fn("shell.resolveCommandPathForPlatform")(function* (
613 command: string,607 command: string,
614 options: CommandAvailabilityOptions & { readonly platform: NodeJS.Platform },608 options: CommandAvailabilityOptions & { readonly platform: NodeJS.Platform },
615): Effect.fn.Return<string, CommandResolutionError, FileSystem.FileSystem | Path.Path> {609): Effect.fn.Return<string | null, never, FileSystem.FileSystem | Path.Path> {
616 const path = yield* Path.Path;610 const path = yield* Path.Path;
617 const platform = options.platform;611 const platform = options.platform;
618 const env = options.env ?? process.env;612 const env = options.env ?? process.env;
...@@ -630,12 +624,12 @@ const resolveCommandPathForPlatform = Effect.fn("shell.resolveCommandPathForPlat...@@ -630,12 +624,12 @@ const resolveCommandPathForPlatform = Effect.fn("shell.resolveCommandPathForPlat
630 return candidate;624 return candidate;
631 }625 }
632 }626 }
633 return yield* new CommandResolutionError({ command, reason: "not-found" });627 return null;
634 }628 }
635629
636 const pathValue = resolvePathEnvironmentVariable(env);630 const pathValue = resolvePathEnvironmentVariable(env);
637 if (pathValue.length === 0) {631 if (pathValue.length === 0) {
638 return yield* new CommandResolutionError({ command, reason: "not-found" });632 return null;
639 }633 }
640634
641 const cacheKey = [platform, pathValue, windowsPathExtensions.join(";"), command].join(635 const cacheKey = [platform, pathValue, windowsPathExtensions.join(";"), command].join(
...@@ -645,9 +639,6 @@ const resolveCommandPathForPlatform = Effect.fn("shell.resolveCommandPathForPlat...@@ -645,9 +639,6 @@ const resolveCommandPathForPlatform = Effect.fn("shell.resolveCommandPathForPlat
645 const nowNanos = yield* Clock.monotonicTimeNanos;639 const nowNanos = yield* Clock.monotonicTimeNanos;
646 const cached = cache.get(cacheKey);640 const cached = cache.get(cacheKey);
647 if (cached !== undefined && cached.expiresAtNanos > nowNanos) {641 if (cached !== undefined && cached.expiresAtNanos > nowNanos) {
648 if (cached.resolvedPath === null) {
649 return yield* new CommandResolutionError({ command, reason: "not-found" });
650 }
651 return cached.resolvedPath;642 return cached.resolvedPath;
652 }643 }
653644
...@@ -680,7 +671,7 @@ const resolveCommandPathForPlatform = Effect.fn("shell.resolveCommandPathForPlat...@@ -680,7 +671,7 @@ const resolveCommandPathForPlatform = Effect.fn("shell.resolveCommandPathForPlat
680 }671 }
681 }672 }
682 cacheCommandResolution(cache, cacheKey, null, nowNanos);673 cacheCommandResolution(cache, cacheKey, null, nowNanos);
683 return yield* new CommandResolutionError({ command, reason: "not-found" });674 return null;
684});675});
685676
686export const resolveCommandPath = Effect.fn("shell.resolveCommandPath")(function* (677export const resolveCommandPath = Effect.fn("shell.resolveCommandPath")(function* (
...@@ -800,10 +791,7 @@ export const isCommandAvailable = Effect.fn("shell.isCommandAvailable")(function...@@ -800,10 +791,7 @@ export const isCommandAvailable = Effect.fn("shell.isCommandAvailable")(function
800 command: string,791 command: string,
801 options: CommandAvailabilityOptions = {},792 options: CommandAvailabilityOptions = {},
802) {793) {
803 return yield* resolveCommandPath(command, options).pipe(794 return (yield* resolveCommandPath(command, options)) !== null;
804 Effect.as(true),
805 Effect.catchTags({ CommandResolutionError: () => Effect.succeed(false) }),
806 );
807});795});
808796
809export function resolveKnownWindowsCliDirs(env: NodeJS.ProcessEnv): ReadonlyArray<string> {797export function resolveKnownWindowsCliDirs(env: NodeJS.ProcessEnv): ReadonlyArray<string> {