| author | |
| committer | |
| log | e2e95a39d309e1ffec901682cc88ea1ba276c868 |
| tree | 46a92e2c520f3b269945d26fa85d253317bd47d2 |
| parent | 8110edfddd3fb4d7fd2e0c9d29256c3a1b9da5c8 |
| signature | Signed by PGP key B5690EEEBB952194 |
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 | ); |
| 111 | 110 | ||
| 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* installation | 150 | 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 | ? kegBrewPath | 500 | ? 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 missing | 646 | // resolveCommandPath checks explicit paths for existence too, so a missing |
| 649 | // binary always lands in the no-context branch and never gets an update | 647 | // 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* fs | 89 | 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"; |
| 2 | import { it as effectIt } from "@effect/vitest"; | 2 | import { it as effectIt } from "@effect/vitest"; |
| 3 | import * as HostProcess from "@t3tools/shared/HostProcess"; | 3 | import * as HostProcess from "@t3tools/shared/HostProcess"; |
| 4 | import * as Effect from "effect/Effect"; | 4 | import * as Effect from "effect/Effect"; |
| 5 | import * as Exit from "effect/Exit"; | ||
| 5 | import * as FileSystem from "effect/FileSystem"; | 6 | import * as FileSystem from "effect/FileSystem"; |
| 6 | import * as Path from "effect/Path"; | 7 | import * as Path from "effect/Path"; |
| 8 | import * as Result from "effect/Result"; | ||
| 7 | import * as TestClock from "effect/testing/TestClock"; | 9 | import * as TestClock from "effect/testing/TestClock"; |
| 10 | import * as Tracer from "effect/Tracer"; | ||
| 8 | import { describe, expect, it, vi } from "vite-plus/test"; | 11 | import { describe, expect, it, vi } from "vite-plus/test"; |
| 9 | 12 | ||
| 10 | import { | 13 | import { |
| ... | @@ -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 | ); |
| 43 | 46 | ||
| 47 | const 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 | |||
| 44 | describe("readPathFromLoginShell", () => { | 60 | describe("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 | }); |
| 339 | 355 | ||
| 340 | effectIt.layer(NodeServices.layer)("isCommandAvailable", (it) => { | 356 | effectIt.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 | }); |
| 351 | 426 | ||
| 352 | effectIt.layer(NodeServices.layer)("resolveCommandPath", (it) => { | 427 | effectIt.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")); |
| 358 | 449 | ||
| 359 | expect(result._tag).toBe("Failure"); | 450 | expect(result).toBeNull(); |
| 360 | }), | 451 | }), |
| 361 | ); | 452 | ); |
| 362 | 453 | ||
| ... | @@ -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 | ); |
| 384 | 474 | ||
| 385 | expect(result._tag).toBe("Failure"); | 475 | expect(result).toBeNull(); |
| 386 | return probed; | 476 | return probed; |
| 387 | }); | 477 | }); |
| 388 | 478 | ||
| ... | @@ -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" } }; |
| 450 | 540 | ||
| 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"; |
| 5 | import * as NodeFS from "node:fs"; | 5 | import * as NodeFS from "node:fs"; |
| 6 | import * as Cache from "effect/Cache"; | 6 | import * as Cache from "effect/Cache"; |
| 7 | import * as Clock from "effect/Clock"; | 7 | import * as Clock from "effect/Clock"; |
| 8 | import * as Data from "effect/Data"; | ||
| 9 | import * as Effect from "effect/Effect"; | 8 | import * as Effect from "effect/Effect"; |
| 10 | import * as FileSystem from "effect/FileSystem"; | 9 | import * as FileSystem from "effect/FileSystem"; |
| 11 | import * as Option from "effect/Option"; | 10 | import * 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>; |
| 45 | 44 | ||
| 46 | export class CommandResolutionError extends Data.TaggedError("CommandResolutionError")<{ | ||
| 47 | readonly command: string; | ||
| 48 | readonly reason: "not-found"; | ||
| 49 | }> {} | ||
| 50 | |||
| 51 | const WINDOWS_SHELL_META_CHARS = /([()\][%!^"`<>&|;, *?])/g; | 45 | const WINDOWS_SHELL_META_CHARS = /([()\][%!^"`<>&|;, *?])/g; |
| 52 | 46 | ||
| 53 | /** | 47 | /** |
| ... | @@ -612,7 +606,7 @@ const isExecutableFile = Effect.fnUntraced(function* ( | ... | @@ -612,7 +606,7 @@ const isExecutableFile = Effect.fnUntraced(function* ( |
| 612 | const resolveCommandPathForPlatform = Effect.fn("shell.resolveCommandPathForPlatform")(function* ( | 606 | const 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 | } |
| 635 | 629 | ||
| 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 | } |
| 640 | 634 | ||
| 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 | } |
| 653 | 644 | ||
| ... | @@ -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 | }); |
| 685 | 676 | ||
| 686 | export const resolveCommandPath = Effect.fn("shell.resolveCommandPath")(function* ( | 677 | export 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 | }); |
| 808 | 796 | ||
| 809 | export function resolveKnownWindowsCliDirs(env: NodeJS.ProcessEnv): ReadonlyArray<string> { | 797 | export function resolveKnownWindowsCliDirs(env: NodeJS.ProcessEnv): ReadonlyArray<string> { |