From bde6c152b9a44d43cd5dc3338616c75972dfc3e7 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 4 Jun 2026 12:02:41 -0700 Subject: [PATCH 01/15] refactor(desktop): resolve build target from Effect context Co-authored-by: codex --- apps/server/src/bootstrap.ts | 7 +- .../src/diagnostics/ProcessDiagnostics.ts | 13 +- .../src/diagnostics/ProcessResourceMonitor.ts | 2 +- .../environment/Layers/ServerEnvironment.ts | 17 +- .../Layers/ServerEnvironmentLabel.test.ts | 26 ++-- .../Layers/ServerEnvironmentLabel.ts | 12 +- apps/server/src/os-jank.ts | 3 +- .../src/process/externalLauncher.test.ts | 29 ++-- apps/server/src/process/externalLauncher.ts | 105 ++++++++----- .../Layers/RepositoryIdentityResolver.ts | 5 + .../src/provider/Drivers/ClaudeDriver.ts | 4 +- .../server/src/provider/Drivers/ClaudeHome.ts | 9 +- .../src/provider/Drivers/CodexDriver.ts | 4 +- .../src/provider/Drivers/CursorDriver.ts | 4 +- .../src/provider/Drivers/OpenCodeDriver.ts | 4 +- .../src/provider/Layers/ClaudeProvider.ts | 27 ++-- .../src/provider/Layers/CodexProvider.ts | 10 +- .../provider/Layers/CodexSessionRuntime.ts | 7 +- .../src/provider/Layers/CursorProvider.ts | 27 ++-- .../src/provider/Layers/OpenCodeProvider.ts | 9 +- .../provider/ProviderInstanceEnvironment.ts | 9 ++ .../src/provider/acp/AcpSessionRuntime.ts | 7 +- apps/server/src/provider/opencodeRuntime.ts | 15 +- .../src/provider/providerMaintenance.test.ts | 16 +- .../src/provider/providerMaintenance.ts | 22 +-- .../src/telemetry/Layers/AnalyticsService.ts | 11 +- .../src/terminal/Layers/Manager.test.ts | 57 ++++--- apps/server/src/terminal/Layers/Manager.ts | 20 +-- .../textGeneration/ClaudeTextGeneration.ts | 9 +- .../src/textGeneration/CodexTextGeneration.ts | 10 +- .../textGeneration/CursorTextGeneration.ts | 7 +- .../textGeneration/OpenCodeTextGeneration.ts | 7 +- apps/server/src/ws.ts | 2 +- packages/shared/package.json | 4 + packages/shared/src/hostProcess.ts | 25 +++ packages/shared/src/shell.test.ts | 12 +- packages/shared/src/shell.ts | 41 ++++- packages/ssh/src/auth.test.ts | 15 +- packages/ssh/src/auth.ts | 15 +- packages/ssh/src/command.ts | 7 +- packages/ssh/src/config.ts | 12 +- packages/ssh/src/tunnel.ts | 3 + packages/tailscale/package.json | 1 + packages/tailscale/src/tailscale.ts | 16 +- pnpm-lock.yaml | 3 + scripts/build-desktop-artifact.test.ts | 81 ++++++++++ scripts/build-desktop-artifact.ts | 76 +++++---- scripts/dev-runner.ts | 7 +- scripts/lib/build-target-arch.test.ts | 146 +++++++++++------- scripts/lib/build-target-arch.ts | 46 ++++-- scripts/mobile-native-static-check.ts | 7 +- 51 files changed, 689 insertions(+), 344 deletions(-) create mode 100644 packages/shared/src/hostProcess.ts diff --git a/apps/server/src/bootstrap.ts b/apps/server/src/bootstrap.ts index 9ad6328798d..ec1e551a442 100644 --- a/apps/server/src/bootstrap.ts +++ b/apps/server/src/bootstrap.ts @@ -112,7 +112,7 @@ const isFdReady = (fd: number) => const makeBootstrapInputStream = (fd: number) => Effect.try({ try: () => { - const fdPath = resolveFdPath(fd); + const fdPath = resolveFdPath(fd, process.platform); if (fdPath === undefined) { return makeDirectBootstrapStream(fd); } @@ -165,10 +165,7 @@ const isBootstrapFdPathDuplicationError = Predicate.compose( (_) => _.code === "ENXIO" || _.code === "EINVAL" || _.code === "EPERM", ); -export function resolveFdPath( - fd: number, - platform: NodeJS.Platform = process.platform, -): string | undefined { +export function resolveFdPath(fd: number, platform: NodeJS.Platform): string | undefined { if (platform === "linux") { return `/proc/self/fd/${fd}`; } diff --git a/apps/server/src/diagnostics/ProcessDiagnostics.ts b/apps/server/src/diagnostics/ProcessDiagnostics.ts index ed81f021f4b..97d6ce2a6b0 100644 --- a/apps/server/src/diagnostics/ProcessDiagnostics.ts +++ b/apps/server/src/diagnostics/ProcessDiagnostics.ts @@ -4,6 +4,7 @@ import type { ServerProcessSignal, ServerSignalProcessResult, } from "@t3tools/contracts"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import * as Context from "effect/Context"; import * as DateTime from "effect/DateTime"; import * as Duration from "effect/Duration"; @@ -277,9 +278,11 @@ const runProcess = Effect.fn("runProcess")( readonly errorMessage: string; }) { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const hostPlatform = yield* HostProcessPlatform; const child = yield* spawner.spawn( ChildProcess.make(input.command, input.args, { cwd: process.cwd(), + shell: hostPlatform === "win32", }), ); const [stdout, stderr, exitCode] = yield* Effect.all( @@ -369,8 +372,10 @@ function readWindowsProcessRows(): Effect.Effect< ); } -export const readProcessRows = (platform = process.platform) => - platform === "win32" ? readWindowsProcessRows() : readPosixProcessRows(); +export const readProcessRows = Effect.gen(function* () { + const platform = yield* HostProcessPlatform; + return yield* platform === "win32" ? readWindowsProcessRows() : readPosixProcessRows(); +}); export function aggregateProcessDiagnostics(input: { readonly serverPid: number; @@ -387,7 +392,7 @@ function assertDescendantPid( return Effect.fail(toProcessDiagnosticsError("Refusing to signal the T3 server process.")); } - return readProcessRows().pipe( + return readProcessRows.pipe( Effect.flatMap((rows) => { const filteredRows = rows.filter((row) => !isDiagnosticsQueryProcess(row, process.pid)); const descendant = buildDescendantEntries(filteredRows, process.pid).some( @@ -407,7 +412,7 @@ export const make = Effect.fn("makeProcessDiagnostics")(function* () { const read: ProcessDiagnosticsShape["read"] = Effect.gen(function* () { const readAt = yield* DateTime.now; - const rows = yield* readProcessRows().pipe( + const rows = yield* readProcessRows.pipe( Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), ); return makeResult({ serverPid: process.pid, rows, readAt }); diff --git a/apps/server/src/diagnostics/ProcessResourceMonitor.ts b/apps/server/src/diagnostics/ProcessResourceMonitor.ts index 2b6dfe8d362..efeeb66256d 100644 --- a/apps/server/src/diagnostics/ProcessResourceMonitor.ts +++ b/apps/server/src/diagnostics/ProcessResourceMonitor.ts @@ -252,7 +252,7 @@ export const make = Effect.fn("makeProcessResourceMonitor")(function* () { const sampleOnce = Effect.gen(function* () { const sampledAt = yield* DateTime.now; const sampledAtMs = DateTime.toEpochMillis(sampledAt); - const rows = yield* readProcessRows().pipe( + const rows = yield* readProcessRows.pipe( Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), ); const samples = collectMonitoredSamples({ diff --git a/apps/server/src/environment/Layers/ServerEnvironment.ts b/apps/server/src/environment/Layers/ServerEnvironment.ts index cc8d803c970..fd4f6baab1a 100644 --- a/apps/server/src/environment/Layers/ServerEnvironment.ts +++ b/apps/server/src/environment/Layers/ServerEnvironment.ts @@ -1,4 +1,5 @@ import { EnvironmentId, type ExecutionEnvironmentDescriptor } from "@t3tools/contracts"; +import { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/hostProcess"; import * as Crypto from "effect/Crypto"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; @@ -11,8 +12,8 @@ import { ServerEnvironment, type ServerEnvironmentShape } from "../Services/Serv import packageJson from "../../../package.json" with { type: "json" }; import { resolveServerEnvironmentLabel } from "./ServerEnvironmentLabel.ts"; -function platformOs(): ExecutionEnvironmentDescriptor["platform"]["os"] { - switch (process.platform) { +function platformOs(platform: NodeJS.Platform): ExecutionEnvironmentDescriptor["platform"]["os"] { + switch (platform) { case "darwin": return "darwin"; case "linux": @@ -24,8 +25,10 @@ function platformOs(): ExecutionEnvironmentDescriptor["platform"]["os"] { } } -function platformArch(): ExecutionEnvironmentDescriptor["platform"]["arch"] { - switch (process.arch) { +function platformArch( + architecture: NodeJS.Architecture, +): ExecutionEnvironmentDescriptor["platform"]["arch"] { + switch (architecture) { case "arm64": return "arm64"; case "x64": @@ -40,6 +43,8 @@ export const makeServerEnvironment = Effect.fn("makeServerEnvironment")(function const path = yield* Path.Path; const serverConfig = yield* ServerConfig; const crypto = yield* Crypto.Crypto; + const hostPlatform = yield* HostProcessPlatform; + const hostArchitecture = yield* HostProcessArchitecture; const readPersistedEnvironmentId = Effect.gen(function* () { const exists = yield* fileSystem @@ -80,8 +85,8 @@ export const makeServerEnvironment = Effect.fn("makeServerEnvironment")(function environmentId, label, platform: { - os: platformOs(), - arch: platformArch(), + os: platformOs(hostPlatform), + arch: platformArch(hostArchitecture), }, serverVersion: packageJson.version, capabilities: { diff --git a/apps/server/src/environment/Layers/ServerEnvironmentLabel.test.ts b/apps/server/src/environment/Layers/ServerEnvironmentLabel.test.ts index 827f562422e..0058a3cd50d 100644 --- a/apps/server/src/environment/Layers/ServerEnvironmentLabel.test.ts +++ b/apps/server/src/environment/Layers/ServerEnvironmentLabel.test.ts @@ -2,6 +2,7 @@ import { afterEach, describe, expect, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import { vi } from "vite-plus/test"; import { ProcessRunner, ProcessSpawnError, type ProcessRunnerShape } from "../../processRunner.ts"; @@ -28,6 +29,10 @@ const LinuxMachineInfoLayer = Layer.merge( : Effect.succeed(""), }), ); +const withHostPlatform = ( + layer: Layer.Layer, + platform: NodeJS.Platform, +) => Layer.merge(layer, Layer.succeed(HostProcessPlatform, platform)); afterEach(() => { runMock.mockReset(); @@ -38,9 +43,8 @@ describe("resolveServerEnvironmentLabel", () => { Effect.gen(function* () { const result = yield* resolveServerEnvironmentLabel({ cwdBaseName: "t3code", - platform: "win32", hostname: "macbook-pro", - }).pipe(Effect.provide(TestLayer)); + }).pipe(Effect.provide(withHostPlatform(TestLayer, "win32"))); expect(result).toBe("macbook-pro"); }), @@ -61,9 +65,8 @@ describe("resolveServerEnvironmentLabel", () => { const result = yield* resolveServerEnvironmentLabel({ cwdBaseName: "t3code", - platform: "darwin", hostname: "macbook-pro", - }).pipe(Effect.provide(TestLayer)); + }).pipe(Effect.provide(withHostPlatform(TestLayer, "darwin"))); expect(result).toBe("Julius's MacBook Pro"); expect(runMock).toHaveBeenCalledWith( @@ -80,9 +83,8 @@ describe("resolveServerEnvironmentLabel", () => { Effect.gen(function* () { const result = yield* resolveServerEnvironmentLabel({ cwdBaseName: "t3code", - platform: "linux", hostname: "buildbox", - }).pipe(Effect.provide(LinuxMachineInfoLayer)); + }).pipe(Effect.provide(withHostPlatform(LinuxMachineInfoLayer, "linux"))); expect(result).toBe("Build Agent 01"); expect(runMock).not.toHaveBeenCalled(); @@ -104,9 +106,8 @@ describe("resolveServerEnvironmentLabel", () => { const result = yield* resolveServerEnvironmentLabel({ cwdBaseName: "t3code", - platform: "linux", hostname: "runner-01", - }).pipe(Effect.provide(TestLayer)); + }).pipe(Effect.provide(withHostPlatform(TestLayer, "linux"))); expect(result).toBe("CI Runner"); expect(runMock).toHaveBeenCalledWith( @@ -123,9 +124,8 @@ describe("resolveServerEnvironmentLabel", () => { Effect.gen(function* () { const result = yield* resolveServerEnvironmentLabel({ cwdBaseName: "t3code", - platform: "win32", hostname: "JULIUS-LAPTOP", - }).pipe(Effect.provide(TestLayer)); + }).pipe(Effect.provide(withHostPlatform(TestLayer, "win32"))); expect(result).toBe("JULIUS-LAPTOP"); }), @@ -145,9 +145,8 @@ describe("resolveServerEnvironmentLabel", () => { const result = yield* resolveServerEnvironmentLabel({ cwdBaseName: "t3code", - platform: "darwin", hostname: "macbook-pro", - }).pipe(Effect.provide(TestLayer)); + }).pipe(Effect.provide(withHostPlatform(TestLayer, "darwin"))); expect(result).toBe("macbook-pro"); }), @@ -168,9 +167,8 @@ describe("resolveServerEnvironmentLabel", () => { const result = yield* resolveServerEnvironmentLabel({ cwdBaseName: "t3code", - platform: "linux", hostname: " ", - }).pipe(Effect.provide(TestLayer)); + }).pipe(Effect.provide(withHostPlatform(TestLayer, "linux"))); expect(result).toBe("t3code"); }), diff --git a/apps/server/src/environment/Layers/ServerEnvironmentLabel.ts b/apps/server/src/environment/Layers/ServerEnvironmentLabel.ts index b07425b936b..5a2bec5e900 100644 --- a/apps/server/src/environment/Layers/ServerEnvironmentLabel.ts +++ b/apps/server/src/environment/Layers/ServerEnvironmentLabel.ts @@ -1,5 +1,6 @@ import * as OS from "node:os"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Option from "effect/Option"; @@ -8,7 +9,6 @@ import { ProcessRunner } from "../../processRunner.ts"; interface ResolveServerEnvironmentLabelInput { readonly cwdBaseName: string; - readonly platform?: NodeJS.Platform; readonly hostname?: string | null; } @@ -54,11 +54,13 @@ const runFriendlyLabelCommand = Effect.fn("runFriendlyLabelCommand")(function* ( args: readonly string[], ) { const processRunner = yield* ProcessRunner; + const hostPlatform = yield* HostProcessPlatform; const result = yield* processRunner .run({ command, args, timeoutBehavior: "timedOutResult", + shell: hostPlatform === "win32", }) .pipe(Effect.option); @@ -69,9 +71,8 @@ const runFriendlyLabelCommand = Effect.fn("runFriendlyLabelCommand")(function* ( return normalizeLabel(result.value.stdout); }); -const resolveFriendlyHostLabel = Effect.fn("resolveFriendlyHostLabel")(function* ( - platform: NodeJS.Platform, -) { +const resolveFriendlyHostLabel = Effect.fn("resolveFriendlyHostLabel")(function* () { + const platform = yield* HostProcessPlatform; if (platform === "darwin") { return yield* runFriendlyLabelCommand("scutil", ["--get", "ComputerName"]); } @@ -94,8 +95,7 @@ const resolveFriendlyHostLabel = Effect.fn("resolveFriendlyHostLabel")(function* export const resolveServerEnvironmentLabel = Effect.fn("resolveServerEnvironmentLabel")(function* ( input: ResolveServerEnvironmentLabelInput, ) { - const platform = input.platform ?? process.platform; - const friendlyHostLabel = yield* resolveFriendlyHostLabel(platform); + const friendlyHostLabel = yield* resolveFriendlyHostLabel(); if (friendlyHostLabel) { return friendlyHostLabel; } diff --git a/apps/server/src/os-jank.ts b/apps/server/src/os-jank.ts index 93a40ae7e19..6c3011570f4 100644 --- a/apps/server/src/os-jank.ts +++ b/apps/server/src/os-jank.ts @@ -26,7 +26,6 @@ function logPathHydrationWarning(message: string, error?: unknown): void { export function fixPath( options: { env?: NodeJS.ProcessEnv; - platform?: NodeJS.Platform; readPath?: typeof readPathFromLoginShell; readWindowsEnvironment?: WindowsShellEnvironmentReader; isWindowsCommandAvailable?: WindowsCommandAvailabilityChecker; @@ -35,7 +34,7 @@ export function fixPath( logWarning?: (message: string, error?: unknown) => void; } = {}, ): void { - const platform = options.platform ?? process.platform; + const platform = process.platform; const env = options.env ?? process.env; const logWarning = options.logWarning ?? logPathHydrationWarning; const readPath = options.readPath ?? readPathFromLoginShell; diff --git a/apps/server/src/process/externalLauncher.test.ts b/apps/server/src/process/externalLauncher.test.ts index 75e76b5e8e2..2f681d37f1d 100644 --- a/apps/server/src/process/externalLauncher.test.ts +++ b/apps/server/src/process/externalLauncher.test.ts @@ -12,12 +12,12 @@ import * as Stream from "effect/Stream"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { - isCommandAvailable, + isCommandAvailableForPlatform, launchBrowser, launchEditorProcess, resolveAvailableEditors, resolveBrowserLaunch, - resolveEditorLaunch, + resolveEditorLaunchForPlatform as resolveEditorLaunch, } from "./externalLauncher.ts"; function encodeUtf16LeBase64(input: string): string { @@ -516,9 +516,9 @@ it.layer(NodeServices.layer)("resolveEditorLaunch", (it) => { it("resolveBrowserLaunch maps default browser launchers by platform", () => { const target = "https://example.com/some path?name=o'hara"; - assert.deepEqual(resolveBrowserLaunch(target, "darwin").command, "open"); - assert.deepEqual(resolveBrowserLaunch(target, "darwin").args, [target]); - assert.deepEqual(resolveBrowserLaunch(target, "darwin").options, { + assert.deepEqual(resolveBrowserLaunch(target, "darwin", {}).command, "open"); + assert.deepEqual(resolveBrowserLaunch(target, "darwin", {}).args, [target]); + assert.deepEqual(resolveBrowserLaunch(target, "darwin", {}).options, { detached: true, stdin: "ignore", stdout: "ignore", @@ -594,7 +594,11 @@ it.layer(NodeServices.layer)("launchBrowser", (it) => { assertSuccess(result, undefined); assert.ok(spawnedCommand); - const expectedLaunch = resolveBrowserLaunch("https://example.com"); + const expectedLaunch = resolveBrowserLaunch( + "https://example.com", + process.platform, + process.env, + ); assert.equal(spawnedCommand.command, expectedLaunch.command); assert.deepEqual(spawnedCommand.args, expectedLaunch.args); assert.deepEqual(spawnedCommand.options, expectedLaunch.options); @@ -672,7 +676,7 @@ 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(isCommandAvailableForPlatform("code", { platform: "win32", env }), true); }), ); @@ -681,7 +685,10 @@ it.layer(NodeServices.layer)("isCommandAvailable", (it) => { PATH: "", PATHEXT: ".COM;.EXE;.BAT;.CMD", } satisfies NodeJS.ProcessEnv; - assert.equal(isCommandAvailable("definitely-not-installed", { platform: "win32", env }), false); + assert.equal( + isCommandAvailableForPlatform("definitely-not-installed", { platform: "win32", env }), + false, + ); }); it.effect("does not treat bare files without executable extension as available on win32", () => @@ -694,7 +701,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(isCommandAvailableForPlatform("npm", { platform: "win32", env }), false); }), ); @@ -708,7 +715,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(isCommandAvailableForPlatform("my.tool", { platform: "win32", env }), true); }), ); @@ -724,7 +731,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(isCommandAvailableForPlatform("code", { platform: "win32", env }), true); }), ); }); diff --git a/apps/server/src/process/externalLauncher.ts b/apps/server/src/process/externalLauncher.ts index da19864dcf8..e8fa8c84d0e 100644 --- a/apps/server/src/process/externalLauncher.ts +++ b/apps/server/src/process/externalLauncher.ts @@ -12,7 +12,12 @@ import { type EditorId, type LaunchEditorInput, } from "@t3tools/contracts"; -import { isCommandAvailable, type CommandAvailabilityOptions } from "@t3tools/shared/shell"; +import { HostProcessEnv, HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import { + isCommandAvailable, + isCommandAvailableForPlatform, + type PlatformCommandAvailabilityOptions, +} from "@t3tools/shared/shell"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Encoding from "effect/Encoding"; @@ -26,7 +31,7 @@ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; export { ExternalLauncherError }; export type { LaunchEditorInput }; -export { isCommandAvailable } from "@t3tools/shared/shell"; +export { isCommandAvailable, isCommandAvailableForPlatform } from "@t3tools/shared/shell"; interface EditorLaunch { readonly command: string; @@ -111,10 +116,10 @@ function resolveEditorArgs( function resolveAvailableCommand( commands: ReadonlyArray, - options: CommandAvailabilityOptions = {}, + options: PlatformCommandAvailabilityOptions, ): Option.Option { for (const command of commands) { - if (isCommandAvailable(command, options)) { + if (isCommandAvailableForPlatform(command, options)) { return Option.some(command); } } @@ -186,8 +191,8 @@ function fileManagerCommandForPlatform(platform: NodeJS.Platform): string { export function resolveBrowserLaunch( target: string, - platform: NodeJS.Platform = process.platform, - env: NodeJS.ProcessEnv = process.env, + platform: NodeJS.Platform, + env: NodeJS.ProcessEnv = {}, ): ProcessLaunch { if (platform === "darwin") { return { @@ -213,15 +218,15 @@ export function resolveBrowserLaunch( } export function resolveAvailableEditors( - platform: NodeJS.Platform = process.platform, - env: NodeJS.ProcessEnv = process.env, + platform: NodeJS.Platform, + env: NodeJS.ProcessEnv, ): ReadonlyArray { const available: EditorId[] = []; for (const editor of EDITORS) { if (editor.commands === null) { const command = fileManagerCommandForPlatform(platform); - if (isCommandAvailable(command, { platform, env })) { + if (isCommandAvailableForPlatform(command, { platform, env })) { available.push(editor.id); } continue; @@ -236,6 +241,12 @@ export function resolveAvailableEditors( return available; } +export const getAvailableEditors = Effect.fn("externalLauncher.getAvailableEditors")(function* () { + const platform = yield* HostProcessPlatform; + const env = yield* HostProcessEnv; + return resolveAvailableEditors(platform, env); +}); + /** * ExternalLauncherShape - Service API for browser and editor launch actions. */ @@ -264,37 +275,47 @@ export class ExternalLauncher extends Context.Service { - yield* Effect.annotateCurrentSpan({ - "externalLauncher.editor": input.editor, - "externalLauncher.cwd": input.cwd, - "externalLauncher.platform": platform, - }); - const editorDef = EDITORS.find((editor) => editor.id === input.editor); - if (!editorDef) { - return yield* new ExternalLauncherError({ message: `Unknown editor: ${input.editor}` }); - } +export const resolveEditorLaunchForPlatform = Effect.fn("resolveEditorLaunchForPlatform")( + function* ( + input: LaunchEditorInput, + platform: NodeJS.Platform, + env: NodeJS.ProcessEnv = {}, + ): Effect.fn.Return { + yield* Effect.annotateCurrentSpan({ + "externalLauncher.editor": input.editor, + "externalLauncher.cwd": input.cwd, + "externalLauncher.platform": platform, + }); + const editorDef = EDITORS.find((editor) => editor.id === input.editor); + if (!editorDef) { + return yield* new ExternalLauncherError({ message: `Unknown editor: ${input.editor}` }); + } - if (editorDef.commands) { - const command = Option.getOrElse( - resolveAvailableCommand(editorDef.commands, { platform, env }), - () => editorDef.commands[0], - ); - return { - command, - args: resolveEditorArgs(editorDef, input.cwd), - }; - } + if (editorDef.commands) { + const command = Option.getOrElse( + resolveAvailableCommand(editorDef.commands, { platform, env }), + () => editorDef.commands[0], + ); + return { + command, + args: resolveEditorArgs(editorDef, input.cwd), + }; + } - if (editorDef.id !== "file-manager") { - return yield* new ExternalLauncherError({ message: `Unsupported editor: ${input.editor}` }); - } + if (editorDef.id !== "file-manager") { + return yield* new ExternalLauncherError({ message: `Unsupported editor: ${input.editor}` }); + } + + return { command: fileManagerCommandForPlatform(platform), args: [input.cwd] }; + }, +); - return { command: fileManagerCommandForPlatform(platform), args: [input.cwd] }; +export const resolveEditorLaunch = Effect.fn("resolveEditorLaunch")(function* ( + input: LaunchEditorInput, +): Effect.fn.Return { + const platform = yield* HostProcessPlatform; + const env = yield* HostProcessEnv; + return yield* resolveEditorLaunchForPlatform(input, platform, env); }); const launchAndUnref = Effect.fn("externalLauncher.launchAndUnref")(function* ( @@ -315,7 +336,12 @@ const launchAndUnref = Effect.fn("externalLauncher.launchAndUnref")(function* ( export const launchBrowser = Effect.fn("externalLauncher.launchBrowser")(function* ( target: string, ): Effect.fn.Return { - return yield* launchAndUnref(resolveBrowserLaunch(target), "Browser auto-open failed"); + const platform = yield* HostProcessPlatform; + const env = yield* HostProcessEnv; + return yield* launchAndUnref( + resolveBrowserLaunch(target, platform, env), + "Browser auto-open failed", + ); }); export const launchEditorProcess = Effect.fn("externalLauncher.launchEditorProcess")(function* ( @@ -327,7 +353,8 @@ export const launchEditorProcess = Effect.fn("externalLauncher.launchEditorProce }); } - const isWin32 = process.platform === "win32"; + const platform = yield* HostProcessPlatform; + const isWin32 = platform === "win32"; yield* launchAndUnref( { command: launch.command, diff --git a/apps/server/src/project/Layers/RepositoryIdentityResolver.ts b/apps/server/src/project/Layers/RepositoryIdentityResolver.ts index 4fdaa71de22..2a767eef2e8 100644 --- a/apps/server/src/project/Layers/RepositoryIdentityResolver.ts +++ b/apps/server/src/project/Layers/RepositoryIdentityResolver.ts @@ -1,4 +1,5 @@ import type { RepositoryIdentity } from "@t3tools/contracts"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import * as Cache from "effect/Cache"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; @@ -87,6 +88,7 @@ const resolveRepositoryIdentityCacheKey = Effect.fn("resolveRepositoryIdentityCa cwd: string, ) { const processRunner = yield* ProcessRunner.ProcessRunner; + const hostPlatform = yield* HostProcessPlatform; let cacheKey = cwd; const topLevelResult = yield* processRunner @@ -94,6 +96,7 @@ const resolveRepositoryIdentityCacheKey = Effect.fn("resolveRepositoryIdentityCa command: "git", args: ["-C", cwd, "rev-parse", "--show-toplevel"], timeoutBehavior: "timedOutResult", + shell: hostPlatform === "win32", }) .pipe(Effect.option); if (topLevelResult._tag === "None" || topLevelResult.value.code !== 0) { @@ -113,11 +116,13 @@ const resolveRepositoryIdentityFromCacheKey = Effect.fn("resolveRepositoryIdenti cacheKey: string, ): Effect.fn.Return { const processRunner = yield* ProcessRunner.ProcessRunner; + const hostPlatform = yield* HostProcessPlatform; const remoteResult = yield* processRunner .run({ command: "git", args: ["-C", cacheKey, "remote", "-v"], timeoutBehavior: "timedOutResult", + shell: hostPlatform === "win32", }) .pipe(Effect.option); if (remoteResult._tag === "None" || remoteResult.value.code !== 0) { diff --git a/apps/server/src/provider/Drivers/ClaudeDriver.ts b/apps/server/src/provider/Drivers/ClaudeDriver.ts index b126028f813..f3b9a658314 100644 --- a/apps/server/src/provider/Drivers/ClaudeDriver.ts +++ b/apps/server/src/provider/Drivers/ClaudeDriver.ts @@ -41,7 +41,7 @@ import { type ProviderInstance, } from "../ProviderDriver.ts"; import type { ServerProviderDraft } from "../providerSnapshot.ts"; -import { mergeProviderInstanceEnvironment } from "../ProviderInstanceEnvironment.ts"; +import { mergeProviderInstanceEnvironmentEffect } from "../ProviderInstanceEnvironment.ts"; import { enrichProviderSnapshotWithVersionAdvisory, makePackageManagedProviderMaintenanceResolver, @@ -115,7 +115,7 @@ export const ClaudeDriver: ProviderDriver = { const path = yield* Path.Path; const httpClient = yield* HttpClient.HttpClient; const eventLoggers = yield* ProviderEventLoggers; - const processEnv = mergeProviderInstanceEnvironment(environment); + const processEnv = yield* mergeProviderInstanceEnvironmentEffect(environment); const fallbackContinuationIdentity = defaultProviderContinuationIdentity({ driverKind: DRIVER_KIND, instanceId, diff --git a/apps/server/src/provider/Drivers/ClaudeHome.ts b/apps/server/src/provider/Drivers/ClaudeHome.ts index 9a4d1ce9cdf..5fe7fdc7506 100644 --- a/apps/server/src/provider/Drivers/ClaudeHome.ts +++ b/apps/server/src/provider/Drivers/ClaudeHome.ts @@ -1,6 +1,7 @@ import * as NodeOS from "node:os"; import type { ClaudeSettings } from "@t3tools/contracts"; +import { HostProcessEnv } from "@t3tools/shared/hostProcess"; import * as Effect from "effect/Effect"; import * as Path from "effect/Path"; @@ -16,13 +17,15 @@ export const resolveClaudeHomePath = Effect.fn("resolveClaudeHomePath")(function export const makeClaudeEnvironment = Effect.fn("makeClaudeEnvironment")(function* ( config: Pick, - baseEnv: NodeJS.ProcessEnv = process.env, + baseEnv?: NodeJS.ProcessEnv, ): Effect.fn.Return { + const hostEnv = yield* HostProcessEnv; + const resolvedBaseEnv = baseEnv ?? hostEnv; const homePath = config.homePath.trim(); - if (homePath.length === 0) return baseEnv; + if (homePath.length === 0) return resolvedBaseEnv; const resolvedHomePath = yield* resolveClaudeHomePath(config); return { - ...baseEnv, + ...resolvedBaseEnv, HOME: resolvedHomePath, }; }); diff --git a/apps/server/src/provider/Drivers/CodexDriver.ts b/apps/server/src/provider/Drivers/CodexDriver.ts index 441edda479f..ec994ac1d6a 100644 --- a/apps/server/src/provider/Drivers/CodexDriver.ts +++ b/apps/server/src/provider/Drivers/CodexDriver.ts @@ -41,7 +41,7 @@ import { ProviderEventLoggers } from "../Layers/ProviderEventLoggers.ts"; import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; import type { ProviderDriver, ProviderInstance } from "../ProviderDriver.ts"; import type { ServerProviderDraft } from "../providerSnapshot.ts"; -import { mergeProviderInstanceEnvironment } from "../ProviderInstanceEnvironment.ts"; +import { mergeProviderInstanceEnvironmentEffect } from "../ProviderInstanceEnvironment.ts"; import { enrichProviderSnapshotWithVersionAdvisory, makePackageManagedProviderMaintenanceResolver, @@ -112,7 +112,7 @@ export const CodexDriver: ProviderDriver = { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const httpClient = yield* HttpClient.HttpClient; const eventLoggers = yield* ProviderEventLoggers; - const processEnv = mergeProviderInstanceEnvironment(environment); + const processEnv = yield* mergeProviderInstanceEnvironmentEffect(environment); const homeLayout = yield* resolveCodexHomeLayout(config); const continuationIdentity = codexContinuationIdentity(homeLayout); const stampIdentity = withInstanceIdentity({ diff --git a/apps/server/src/provider/Drivers/CursorDriver.ts b/apps/server/src/provider/Drivers/CursorDriver.ts index ba532864c45..32b6be2d5d3 100644 --- a/apps/server/src/provider/Drivers/CursorDriver.ts +++ b/apps/server/src/provider/Drivers/CursorDriver.ts @@ -39,7 +39,7 @@ import { type ProviderInstance, } from "../ProviderDriver.ts"; import type { ServerProviderDraft } from "../providerSnapshot.ts"; -import { mergeProviderInstanceEnvironment } from "../ProviderInstanceEnvironment.ts"; +import { mergeProviderInstanceEnvironmentEffect } from "../ProviderInstanceEnvironment.ts"; import { makeProviderMaintenanceCapabilities, makeStaticProviderMaintenanceResolver, @@ -99,7 +99,7 @@ export const CursorDriver: ProviderDriver = { const path = yield* Path.Path; const httpClient = yield* HttpClient.HttpClient; const eventLoggers = yield* ProviderEventLoggers; - const processEnv = mergeProviderInstanceEnvironment(environment); + const processEnv = yield* mergeProviderInstanceEnvironmentEffect(environment); const continuationIdentity = defaultProviderContinuationIdentity({ driverKind: DRIVER_KIND, instanceId, diff --git a/apps/server/src/provider/Drivers/OpenCodeDriver.ts b/apps/server/src/provider/Drivers/OpenCodeDriver.ts index e7216f83366..ee9076214b4 100644 --- a/apps/server/src/provider/Drivers/OpenCodeDriver.ts +++ b/apps/server/src/provider/Drivers/OpenCodeDriver.ts @@ -40,7 +40,7 @@ import { type ProviderInstance, } from "../ProviderDriver.ts"; import type { ServerProviderDraft } from "../providerSnapshot.ts"; -import { mergeProviderInstanceEnvironment } from "../ProviderInstanceEnvironment.ts"; +import { mergeProviderInstanceEnvironmentEffect } from "../ProviderInstanceEnvironment.ts"; import { enrichProviderSnapshotWithVersionAdvisory, makePackageManagedProviderMaintenanceResolver, @@ -112,7 +112,7 @@ export const OpenCodeDriver: ProviderDriver const serverConfig = yield* ServerConfig; const httpClient = yield* HttpClient.HttpClient; const eventLoggers = yield* ProviderEventLoggers; - const processEnv = mergeProviderInstanceEnvironment(environment); + const processEnv = yield* mergeProviderInstanceEnvironmentEffect(environment); const continuationIdentity = defaultProviderContinuationIdentity({ driverKind: DRIVER_KIND, instanceId, diff --git a/apps/server/src/provider/Layers/ClaudeProvider.ts b/apps/server/src/provider/Layers/ClaudeProvider.ts index d5bbc8f6572..9b4992b7601 100644 --- a/apps/server/src/provider/Layers/ClaudeProvider.ts +++ b/apps/server/src/provider/Layers/ClaudeProvider.ts @@ -18,6 +18,7 @@ import { getProviderOptionCurrentValue, getProviderOptionDescriptors, } from "@t3tools/shared/model"; +import { HostProcessEnv, HostProcessPlatform } from "@t3tools/shared/hostProcess"; import { compareSemverVersions } from "@t3tools/shared/semver"; import { query as claudeQuery, @@ -547,11 +548,12 @@ function waitForAbortSignal(signal: AbortSignal): Promise { */ const probeClaudeCapabilities = ( claudeSettings: ClaudeSettings, - environment: NodeJS.ProcessEnv = process.env, + environment?: NodeJS.ProcessEnv, ) => { const abort = new AbortController(); return Effect.gen(function* () { - const claudeEnvironment = yield* makeClaudeEnvironment(claudeSettings, environment); + const hostEnv = yield* HostProcessEnv; + const claudeEnvironment = yield* makeClaudeEnvironment(claudeSettings, environment ?? hostEnv); return yield* Effect.tryPromise(async () => { const q = claudeQuery({ // Never yield — we only need initialization data, not a conversation. @@ -603,12 +605,14 @@ const probeClaudeCapabilities = ( const runClaudeCommand = Effect.fn("runClaudeCommand")(function* ( claudeSettings: ClaudeSettings, args: ReadonlyArray, - environment: NodeJS.ProcessEnv = process.env, + environment?: NodeJS.ProcessEnv, ) { - const claudeEnvironment = yield* makeClaudeEnvironment(claudeSettings, environment); + const hostEnv = yield* HostProcessEnv; + const hostPlatform = yield* HostProcessPlatform; + const claudeEnvironment = yield* makeClaudeEnvironment(claudeSettings, environment ?? hostEnv); const command = ChildProcess.make(claudeSettings.binaryPath, [...args], { env: claudeEnvironment, - shell: process.platform === "win32", + shell: hostPlatform === "win32", }); return yield* spawnAndCollect(claudeSettings.binaryPath, command); }); @@ -618,12 +622,14 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( resolveCapabilities?: ( claudeSettings: ClaudeSettings, ) => Effect.Effect, - environment: NodeJS.ProcessEnv = process.env, + environment?: NodeJS.ProcessEnv, ): Effect.fn.Return< ServerProviderDraft, never, ChildProcessSpawner.ChildProcessSpawner | Path.Path > { + const hostEnv = yield* HostProcessEnv; + const resolvedEnvironment = environment ?? hostEnv; const checkedAt = DateTime.formatIso(yield* DateTime.now); const allModels = providerModelsFromSettings( BUILT_IN_MODELS, @@ -648,10 +654,11 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( }); } - const versionProbe = yield* runClaudeCommand(claudeSettings, ["--version"], environment).pipe( - Effect.timeoutOption(DEFAULT_TIMEOUT_MS), - Effect.result, - ); + const versionProbe = yield* runClaudeCommand( + claudeSettings, + ["--version"], + resolvedEnvironment, + ).pipe(Effect.timeoutOption(DEFAULT_TIMEOUT_MS), Effect.result); if (Result.isFailure(versionProbe)) { const error = versionProbe.failure; diff --git a/apps/server/src/provider/Layers/CodexProvider.ts b/apps/server/src/provider/Layers/CodexProvider.ts index 89d7421b232..dcbeddc34ec 100644 --- a/apps/server/src/provider/Layers/CodexProvider.ts +++ b/apps/server/src/provider/Layers/CodexProvider.ts @@ -23,6 +23,7 @@ import type { } from "@t3tools/contracts"; import { ServerSettingsError } from "@t3tools/contracts"; +import { HostProcessEnv } from "@t3tools/shared/hostProcess"; import { createModelCapabilities } from "@t3tools/shared/model"; import { AUTH_PROBE_TIMEOUT_MS, @@ -287,6 +288,7 @@ const probeCodexAppServerProvider = Effect.fn("probeCodexAppServerProvider")(fun readonly customModels?: ReadonlyArray; readonly environment?: NodeJS.ProcessEnv; }) { + const hostEnv = yield* HostProcessEnv; // `~` is not shell-expanded when env vars are set via `child_process.spawn`, // so `CODEX_HOME=~/.codex_work` would reach codex verbatim and trip // "CODEX_HOME points to '~/.codex_work', but that path does not exist". @@ -298,7 +300,7 @@ const probeCodexAppServerProvider = Effect.fn("probeCodexAppServerProvider")(fun args: ["app-server"], cwd: input.cwd, env: { - ...(input.environment ?? process.env), + ...(input.environment ?? hostEnv), ...(resolvedHomePath ? { CODEX_HOME: resolvedHomePath } : {}), }, }), @@ -449,12 +451,14 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu CodexErrors.CodexAppServerError, ChildProcessSpawner.ChildProcessSpawner | Scope.Scope > = probeCodexAppServerProvider, - environment: NodeJS.ProcessEnv = process.env, + environment?: NodeJS.ProcessEnv, ): Effect.fn.Return< ServerProviderDraft, ServerSettingsError, ChildProcessSpawner.ChildProcessSpawner > { + const hostEnv = yield* HostProcessEnv; + const resolvedEnvironment = environment ?? hostEnv; const checkedAt = DateTime.formatIso(yield* DateTime.now); const emptyModels = emptyCodexModelsFromSettings(codexSettings); @@ -480,7 +484,7 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu homePath: codexSettings.homePath, cwd: process.cwd(), customModels: codexSettings.customModels, - environment, + environment: resolvedEnvironment, }).pipe( Effect.scoped, Effect.timeoutOption(Duration.millis(AUTH_PROBE_TIMEOUT_MS)), diff --git a/apps/server/src/provider/Layers/CodexSessionRuntime.ts b/apps/server/src/provider/Layers/CodexSessionRuntime.ts index e3e20106d72..8c3c741d78e 100644 --- a/apps/server/src/provider/Layers/CodexSessionRuntime.ts +++ b/apps/server/src/provider/Layers/CodexSessionRuntime.ts @@ -16,6 +16,7 @@ import { ThreadId, TurnId, } from "@t3tools/contracts"; +import { HostProcessEnv, HostProcessPlatform } from "@t3tools/shared/hostProcess"; import { normalizeModelSlug } from "@t3tools/shared/model"; import * as Crypto from "effect/Crypto"; import * as DateTime from "effect/DateTime"; @@ -719,8 +720,10 @@ export const makeCodexSessionRuntime = ( // `child_process.spawn`; `expandHomePath` lets a configured // `CODEX_HOME=~/.codex_work` reach codex as an absolute path. const resolvedHomePath = options.homePath ? expandHomePath(options.homePath) : undefined; + const hostEnv = yield* HostProcessEnv; + const hostPlatform = yield* HostProcessPlatform; const env = { - ...(options.environment ?? process.env), + ...(options.environment ?? hostEnv), ...(resolvedHomePath ? { CODEX_HOME: resolvedHomePath } : {}), }; const child = yield* spawner @@ -729,7 +732,7 @@ export const makeCodexSessionRuntime = ( cwd: options.cwd, env, forceKillAfter: CODEX_APP_SERVER_FORCE_KILL_AFTER, - shell: process.platform === "win32", + shell: hostPlatform === "win32", }), ) .pipe( diff --git a/apps/server/src/provider/Layers/CursorProvider.ts b/apps/server/src/provider/Layers/CursorProvider.ts index facdb5a5ff1..6ff8f6ccb51 100644 --- a/apps/server/src/provider/Layers/CursorProvider.ts +++ b/apps/server/src/provider/Layers/CursorProvider.ts @@ -27,6 +27,7 @@ import { getProviderOptionBooleanSelectionValue, getProviderOptionStringSelectionValue, } from "@t3tools/shared/model"; +import { HostProcessEnv, HostProcessPlatform } from "@t3tools/shared/hostProcess"; import { buildBooleanOptionDescriptor, @@ -394,10 +395,11 @@ function buildCursorDiscoveredModelsFromAvailableModelsResponse( const makeCursorAcpProbeRuntime = ( cursorSettings: CursorSettings, - environment: NodeJS.ProcessEnv = process.env, + environment?: NodeJS.ProcessEnv, ) => Effect.gen(function* () { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const hostEnv = yield* HostProcessEnv; const acpContext = yield* Layer.build( AcpSessionRuntime.layer({ spawn: { @@ -407,7 +409,7 @@ const makeCursorAcpProbeRuntime = ( "acp", ], cwd: process.cwd(), - env: environment, + env: environment ?? hostEnv, }, cwd: process.cwd(), clientInfo: { name: "t3-code-provider-probe", version: "0.0.0" }, @@ -421,7 +423,7 @@ const makeCursorAcpProbeRuntime = ( const withCursorAcpProbeRuntime = ( cursorSettings: CursorSettings, useRuntime: (acp: AcpSessionRuntime["Service"]) => Effect.Effect, - environment: NodeJS.ProcessEnv = process.env, + environment?: NodeJS.ProcessEnv, ) => makeCursorAcpProbeRuntime(cursorSettings, environment).pipe( Effect.flatMap(useRuntime), @@ -542,7 +544,7 @@ export function resolveCursorAcpConfigUpdates( const discoverCursorModelsViaListAvailableModels = ( cursorSettings: CursorSettings, - environment: NodeJS.ProcessEnv = process.env, + environment?: NodeJS.ProcessEnv, ) => withCursorAcpProbeRuntime( cursorSettings, @@ -558,7 +560,7 @@ const discoverCursorModelsViaListAvailableModels = ( export const discoverCursorModelsViaAcp = ( cursorSettings: CursorSettings, - environment: NodeJS.ProcessEnv = process.env, + environment?: NodeJS.ProcessEnv, ) => discoverCursorModelsViaListAvailableModels(cursorSettings, environment); export function getCursorFallbackModels( @@ -927,13 +929,15 @@ export function parseCursorAboutOutput(result: CommandResult): CursorAboutResult const runCursorCommand = ( cursorSettings: CursorSettings, args: ReadonlyArray, - environment: NodeJS.ProcessEnv = process.env, + environment?: NodeJS.ProcessEnv, ) => Effect.gen(function* () { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const hostEnv = yield* HostProcessEnv; + const hostPlatform = yield* HostProcessPlatform; const command = ChildProcess.make(cursorSettings.binaryPath, [...args], { - env: environment, - shell: process.platform === "win32", + env: environment ?? hostEnv, + shell: hostPlatform === "win32", }); const child = yield* spawner.spawn(command); @@ -949,10 +953,7 @@ const runCursorCommand = ( return { stdout, stderr, code: exitCode } satisfies CommandResult; }).pipe(Effect.scoped); -const runCursorAboutCommand = ( - cursorSettings: CursorSettings, - environment: NodeJS.ProcessEnv = process.env, -) => +const runCursorAboutCommand = (cursorSettings: CursorSettings, environment?: NodeJS.ProcessEnv) => Effect.gen(function* () { const jsonResult = yield* runCursorCommand( cursorSettings, @@ -967,7 +968,7 @@ const runCursorAboutCommand = ( export const checkCursorProviderStatus = Effect.fn("checkCursorProviderStatus")(function* ( cursorSettings: CursorSettings, - environment: NodeJS.ProcessEnv = process.env, + environment?: NodeJS.ProcessEnv, ): Effect.fn.Return< ServerProviderDraft, never, diff --git a/apps/server/src/provider/Layers/OpenCodeProvider.ts b/apps/server/src/provider/Layers/OpenCodeProvider.ts index 8842b1da5ce..0c7b0f4b308 100644 --- a/apps/server/src/provider/Layers/OpenCodeProvider.ts +++ b/apps/server/src/provider/Layers/OpenCodeProvider.ts @@ -9,6 +9,7 @@ import * as Data from "effect/Data"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; +import { HostProcessEnv } from "@t3tools/shared/hostProcess"; import { createModelCapabilities } from "@t3tools/shared/model"; import { compareSemverVersions } from "@t3tools/shared/semver"; import { @@ -301,9 +302,11 @@ export const makePendingOpenCodeProvider = ( export const checkOpenCodeProviderStatus = Effect.fn("checkOpenCodeProviderStatus")(function* ( openCodeSettings: OpenCodeSettings, cwd: string, - environment: NodeJS.ProcessEnv = process.env, + environment?: NodeJS.ProcessEnv, ): Effect.fn.Return { const openCodeRuntime = yield* OpenCodeRuntime; + const hostEnv = yield* HostProcessEnv; + const resolvedEnvironment = environment ?? hostEnv; const checkedAt = DateTime.formatIso(yield* DateTime.now); const customModels = openCodeSettings.customModels; const isExternalServer = openCodeSettings.serverUrl.trim().length > 0; @@ -364,7 +367,7 @@ export const checkOpenCodeProviderStatus = Effect.fn("checkOpenCodeProviderStatu .runOpenCodeCommand({ binaryPath: openCodeSettings.binaryPath, args: ["--version"], - environment, + environment: resolvedEnvironment, }) .pipe( Effect.mapError( @@ -413,7 +416,7 @@ export const checkOpenCodeProviderStatus = Effect.fn("checkOpenCodeProviderStatu const server = yield* openCodeRuntime.connectToOpenCodeServer({ binaryPath: openCodeSettings.binaryPath, serverUrl: openCodeSettings.serverUrl, - environment, + environment: resolvedEnvironment, }); return yield* openCodeRuntime.loadOpenCodeInventory( openCodeRuntime.createOpenCodeSdkClient({ diff --git a/apps/server/src/provider/ProviderInstanceEnvironment.ts b/apps/server/src/provider/ProviderInstanceEnvironment.ts index e469253604e..3d0c24d51ae 100644 --- a/apps/server/src/provider/ProviderInstanceEnvironment.ts +++ b/apps/server/src/provider/ProviderInstanceEnvironment.ts @@ -1,4 +1,6 @@ import type { ProviderInstanceEnvironment } from "@t3tools/contracts"; +import { HostProcessEnv } from "@t3tools/shared/hostProcess"; +import * as Effect from "effect/Effect"; export function mergeProviderInstanceEnvironment( environment: ProviderInstanceEnvironment | undefined, @@ -14,3 +16,10 @@ export function mergeProviderInstanceEnvironment( } return next; } + +export const mergeProviderInstanceEnvironmentEffect = Effect.fn( + "mergeProviderInstanceEnvironmentEffect", +)(function* (environment: ProviderInstanceEnvironment | undefined) { + const hostEnv = yield* HostProcessEnv; + return mergeProviderInstanceEnvironment(environment, hostEnv); +}); diff --git a/apps/server/src/provider/acp/AcpSessionRuntime.ts b/apps/server/src/provider/acp/AcpSessionRuntime.ts index 47a8c845e56..dee8d1d1dca 100644 --- a/apps/server/src/provider/acp/AcpSessionRuntime.ts +++ b/apps/server/src/provider/acp/AcpSessionRuntime.ts @@ -13,6 +13,7 @@ import * as EffectAcpClient from "effect-acp/client"; import * as EffectAcpErrors from "effect-acp/errors"; import type * as EffectAcpSchema from "effect-acp/schema"; import type * as EffectAcpProtocol from "effect-acp/protocol"; +import { HostProcessEnv, HostProcessPlatform } from "@t3tools/shared/hostProcess"; import { collectSessionConfigOptionValues, @@ -201,12 +202,14 @@ const makeAcpSessionRuntime = ( ), ); + const hostEnv = yield* HostProcessEnv; + const hostPlatform = yield* HostProcessPlatform; const child = yield* spawner .spawn( ChildProcess.make(options.spawn.command, [...options.spawn.args], { ...(options.spawn.cwd ? { cwd: options.spawn.cwd } : {}), - ...(options.spawn.env ? { env: { ...process.env, ...options.spawn.env } } : {}), - shell: process.platform === "win32", + ...(options.spawn.env ? { env: { ...hostEnv, ...options.spawn.env } } : {}), + shell: hostPlatform === "win32", }), ) .pipe( diff --git a/apps/server/src/provider/opencodeRuntime.ts b/apps/server/src/provider/opencodeRuntime.ts index 9c48e441032..60caaca07af 100644 --- a/apps/server/src/provider/opencodeRuntime.ts +++ b/apps/server/src/provider/opencodeRuntime.ts @@ -31,6 +31,7 @@ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { isWindowsCommandNotFound } from "../processRunner.ts"; import { collectStreamAsString } from "./providerSnapshot.ts"; import * as NetService from "@t3tools/shared/Net"; +import { HostProcessEnv, HostProcessPlatform } from "@t3tools/shared/hostProcess"; const encodeUnknownJsonStringExit = Schema.encodeUnknownExit(Schema.UnknownFromJsonString); const OPENCODE_EMPTY_CONFIG_CONTENT = "{}"; @@ -276,13 +277,15 @@ function ensureRuntimeError( const makeOpenCodeRuntime = Effect.gen(function* () { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const netService = yield* NetService.NetService; + const hostEnv = yield* HostProcessEnv; + const hostPlatform = yield* HostProcessPlatform; const runOpenCodeCommand: OpenCodeRuntimeShape["runOpenCodeCommand"] = (input) => Effect.gen(function* () { const child = yield* spawner.spawn( ChildProcess.make(input.binaryPath, [...input.args], { - shell: process.platform === "win32", - env: input.environment ?? process.env, + shell: hostPlatform === "win32", + env: input.environment ?? hostEnv, }), ); const [stdout, stderr, code] = yield* Effect.all( @@ -338,10 +341,10 @@ const makeOpenCodeRuntime = Effect.gen(function* () { const child = yield* spawner .spawn( ChildProcess.make(input.binaryPath, args, { - detached: process.platform !== "win32", - shell: process.platform === "win32", + detached: hostPlatform !== "win32", + shell: hostPlatform === "win32", env: { - ...(input.environment ?? process.env), + ...(input.environment ?? hostEnv), OPENCODE_CONFIG_CONTENT: OPENCODE_EMPTY_CONFIG_CONTENT, }, }), @@ -359,7 +362,7 @@ const makeOpenCodeRuntime = Effect.gen(function* () { ); const killOpenCodeProcessGroup = (signal: NodeJS.Signals) => - process.platform === "win32" + hostPlatform === "win32" ? child.kill({ killSignal: signal, forceKillAfter: "1 second" }).pipe(Effect.asVoid) : Effect.sync(() => { try { diff --git a/apps/server/src/provider/providerMaintenance.test.ts b/apps/server/src/provider/providerMaintenance.test.ts index 73428f0a445..080448dc828 100644 --- a/apps/server/src/provider/providerMaintenance.test.ts +++ b/apps/server/src/provider/providerMaintenance.test.ts @@ -147,7 +147,6 @@ it.layer(NodeServices.layer)("providerMaintenance", (it) => { expect( packageToolUpdate.resolve({ binaryPath: "package-tool", - platform: "darwin", env: { PATH: vitePlusBinDir, }, @@ -180,11 +179,7 @@ it.layer(NodeServices.layer)("providerMaintenance", (it) => { expect( nativePackageToolUpdate.resolve({ binaryPath: "native-package-tool", - platform: "win32", - env: { - PATH: bunBinDir, - PATHEXT: ".COM;.EXE;.BAT;.CMD", - }, + resolvedCommandPath: path.join(bunBinDir, "native-package-tool.exe"), }), ).toEqual({ provider: driver("nativePackageTool"), @@ -216,7 +211,6 @@ it.layer(NodeServices.layer)("providerMaintenance", (it) => { expect( scopedPackageToolUpdate.resolve({ binaryPath: "scoped-package-tool", - platform: "darwin", env: { PATH: pnpmHomeDir, }, @@ -241,7 +235,6 @@ it.layer(NodeServices.layer)("providerMaintenance", (it) => { expect( packageToolUpdate.resolve({ binaryPath: "/opt/homebrew/bin/package-tool", - platform: "darwin", env: { PATH: "", }, @@ -275,7 +268,6 @@ it.layer(NodeServices.layer)("providerMaintenance", (it) => { expect( nativePackageToolUpdate.resolve({ binaryPath: "native-package-tool", - platform: "darwin", env: { PATH: nativeBinDir, }, @@ -310,7 +302,6 @@ it.layer(NodeServices.layer)("providerMaintenance", (it) => { expect( scopedPackageToolUpdate.resolve({ binaryPath: "scoped-package-tool", - platform: "darwin", env: { PATH: nativeBinDir, }, @@ -335,7 +326,6 @@ it.layer(NodeServices.layer)("providerMaintenance", (it) => { expect( nativePackageToolUpdate.resolve({ binaryPath: "/opt/homebrew/bin/native-package-tool", - platform: "darwin", env: { PATH: "", }, @@ -359,7 +349,6 @@ it.layer(NodeServices.layer)("providerMaintenance", (it) => { expect( scopedPackageToolUpdate.resolve({ binaryPath: "/opt/homebrew/bin/scoped-package-tool", - platform: "darwin", env: { PATH: "", }, @@ -401,7 +390,6 @@ it.layer(NodeServices.layer)("providerMaintenance", (it) => { const capabilities = yield* resolveProviderMaintenanceCapabilitiesEffect(packageToolUpdate, { binaryPath: symlinkPath, - platform: "darwin", env: { PATH: "", }, @@ -449,7 +437,6 @@ it.layer(NodeServices.layer)("providerMaintenance", (it) => { const capabilities = yield* resolveProviderMaintenanceCapabilitiesEffect(packageToolUpdate, { binaryPath: symlinkPath, - platform: "darwin", env: { PATH: "", }, @@ -475,7 +462,6 @@ it.layer(NodeServices.layer)("providerMaintenance", (it) => { expect( packageToolUpdate.resolve({ binaryPath: "C:\\Tools\\package-tool\\package-tool.exe", - platform: "win32", env: { PATH: "", PATHEXT: ".COM;.EXE;.BAT;.CMD", diff --git a/apps/server/src/provider/providerMaintenance.ts b/apps/server/src/provider/providerMaintenance.ts index 3b0fabf6a99..8b466b77523 100644 --- a/apps/server/src/provider/providerMaintenance.ts +++ b/apps/server/src/provider/providerMaintenance.ts @@ -3,8 +3,9 @@ import { type ServerProvider, type ServerProviderVersionAdvisory, } from "@t3tools/contracts"; +import { HostProcessEnv, HostProcessPlatform } from "@t3tools/shared/hostProcess"; import { compareSemverVersions } from "@t3tools/shared/semver"; -import { resolveCommandPath } from "@t3tools/shared/shell"; +import { resolveCommandPath, resolveCommandPathForPlatform } from "@t3tools/shared/shell"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; @@ -32,7 +33,7 @@ export interface ProviderMaintenanceCommandAction { export interface ProviderMaintenanceCapabilityResolutionOptions { readonly binaryPath?: string | null; readonly env?: NodeJS.ProcessEnv; - readonly platform?: NodeJS.Platform; + readonly resolvedCommandPath?: string | null; readonly realCommandPath?: string | null; } @@ -251,10 +252,9 @@ export function resolvePackageManagedProviderMaintenance( } const resolvedCommandPath = - resolveCommandPath(binaryPath, { - ...(options?.platform ? { platform: options.platform } : {}), - ...(options?.env ? { env: options.env } : {}), - }) ?? (hasPathSeparator(binaryPath) ? binaryPath : null); + options?.resolvedCommandPath ?? + resolveCommandPath(binaryPath, options?.env ? { env: options.env } : {}) ?? + (hasPathSeparator(binaryPath) ? binaryPath : null); if (resolvedCommandPath) { const commandPaths = [ @@ -335,10 +335,12 @@ export const resolveProviderMaintenanceCapabilitiesEffect = Effect.fn( return resolver.resolve(options); } + const platform = yield* HostProcessPlatform; + const env = options?.env ?? (yield* HostProcessEnv); const resolvedCommandPath = - resolveCommandPath(binaryPath, { - ...(options?.platform ? { platform: options.platform } : {}), - ...(options?.env ? { env: options.env } : {}), + resolveCommandPathForPlatform(binaryPath, { + platform, + env, }) ?? (hasPathSeparator(binaryPath) ? binaryPath : null); if (!resolvedCommandPath) { return resolver.resolve(options); @@ -350,6 +352,8 @@ export const resolveProviderMaintenanceCapabilitiesEffect = Effect.fn( .pipe(Effect.orElseSucceed(() => resolvedCommandPath)); return resolver.resolve({ ...options, + env, + resolvedCommandPath, realCommandPath, }); }); diff --git a/apps/server/src/telemetry/Layers/AnalyticsService.ts b/apps/server/src/telemetry/Layers/AnalyticsService.ts index 27bf64c7be0..0d51d7c66b1 100644 --- a/apps/server/src/telemetry/Layers/AnalyticsService.ts +++ b/apps/server/src/telemetry/Layers/AnalyticsService.ts @@ -7,10 +7,12 @@ * @module AnalyticsServiceLive */ +import { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/hostProcess"; import * as Config from "effect/Config"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; import * as Ref from "effect/Ref"; import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"; @@ -37,6 +39,7 @@ const TelemetryEnvConfig = Config.all({ maxBufferedEvents: Config.number("T3CODE_TELEMETRY_MAX_BUFFERED_EVENTS").pipe( Config.withDefault(1_000), ), + wslDistroName: Config.string("WSL_DISTRO_NAME").pipe(Config.option), }); const makeAnalyticsService = Effect.gen(function* () { @@ -46,6 +49,8 @@ const makeAnalyticsService = Effect.gen(function* () { const identifier = yield* getTelemetryIdentifier; const bufferRef = yield* Ref.make>([]); const clientType = serverConfig.mode === "desktop" ? "desktop-app" : "cli-web-client"; + const hostPlatform = yield* HostProcessPlatform; + const hostArchitecture = yield* HostProcessArchitecture; const enqueueBufferedEvent = (event: string, properties?: Readonly>) => Effect.flatMap(DateTime.now, (now) => @@ -87,9 +92,9 @@ const makeAnalyticsService = Effect.gen(function* () { properties: { ...event.properties, $process_person_profile: false, - platform: process.platform, - wsl: process.env.WSL_DISTRO_NAME, - arch: process.arch, + platform: hostPlatform, + wsl: Option.getOrUndefined(telemetryConfig.wslDistroName), + arch: hostArchitecture, t3CodeVersion: packageJson.version, clientType, }, diff --git a/apps/server/src/terminal/Layers/Manager.test.ts b/apps/server/src/terminal/Layers/Manager.test.ts index 30515ca2c47..fd6c8a65b11 100644 --- a/apps/server/src/terminal/Layers/Manager.test.ts +++ b/apps/server/src/terminal/Layers/Manager.test.ts @@ -8,6 +8,7 @@ import { type TerminalOpenInput, type TerminalRestartInput, } from "@t3tools/contracts"; +import { HostProcessEnv, HostProcessPlatform } from "@t3tools/shared/hostProcess"; import * as Data from "effect/Data"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; @@ -199,8 +200,6 @@ const multiTerminalHistoryLogPath = ( interface CreateManagerOptions { shellResolver?: () => string; - platform?: NodeJS.Platform; - env?: NodeJS.ProcessEnv; subprocessInspector?: (terminalPid: number) => Effect.Effect<{ readonly hasRunningSubprocess: boolean; readonly childCommand: string | null; @@ -240,8 +239,6 @@ const createManager = ( historyLineLimit, ptyAdapter, ...(options.shellResolver !== undefined ? { shellResolver: options.shellResolver } : {}), - ...(options.platform !== undefined ? { platform: options.platform } : {}), - ...(options.env !== undefined ? { env: options.env } : {}), ...(options.subprocessInspector !== undefined ? { subprocessInspector: options.subprocessInspector } : {}), @@ -270,6 +267,15 @@ const createManager = ( }), ); +const withHostProcess = (input: { + readonly platform: NodeJS.Platform; + readonly env?: NodeJS.ProcessEnv; +}) => + Layer.mergeAll( + Layer.succeed(HostProcessPlatform, input.platform), + Layer.succeed(HostProcessEnv, input.env ?? {}), + ); + it.layer( Layer.merge(NodeServices.layer, ProcessRunner.layer.pipe(Layer.provide(NodeServices.layer))), { excludeTestServices: true }, @@ -1120,14 +1126,18 @@ it.layer( it.effect("prefers PowerShell over ComSpec for Windows terminals", () => Effect.gen(function* () { - const { manager, ptyAdapter } = yield* createManager(5, { - platform: "win32", - env: { - ComSpec: "C:\\Windows\\System32\\cmd.exe", - PATH: "C:\\Windows\\System32", - SystemRoot: "C:\\Windows", - }, - }); + const { manager, ptyAdapter } = yield* createManager(5).pipe( + Effect.provide( + withHostProcess({ + platform: "win32", + env: { + ComSpec: "C:\\Windows\\System32\\cmd.exe", + PATH: "C:\\Windows\\System32", + SystemRoot: "C:\\Windows", + }, + }), + ), + ); yield* manager.open(openInput()); @@ -1142,15 +1152,22 @@ it.layer( it.effect("falls back to built-in PowerShell by absolute path on Windows", () => Effect.gen(function* () { - const { manager, ptyAdapter } = yield* createManager(5, { - platform: "win32", - env: { - ComSpec: "C:\\Windows\\System32\\cmd.exe", - PATH: "C:\\Windows\\System32", - SystemRoot: "C:\\Windows", - }, + const ptyAdapter = new FakePtyAdapter(); + const { manager } = yield* createManager(5, { + ptyAdapter, shellResolver: () => "C:\\missing\\custom-shell.exe", - }); + }).pipe( + Effect.provide( + withHostProcess({ + platform: "win32", + env: { + ComSpec: "C:\\Windows\\System32\\cmd.exe", + PATH: "C:\\Windows\\System32", + SystemRoot: "C:\\Windows", + }, + }), + ), + ); ptyAdapter.spawnFailures.push( new Error("spawn custom-shell.exe ENOENT"), new Error("spawn pwsh.exe ENOENT"), diff --git a/apps/server/src/terminal/Layers/Manager.ts b/apps/server/src/terminal/Layers/Manager.ts index f2b466a4390..d7c70b47d3b 100644 --- a/apps/server/src/terminal/Layers/Manager.ts +++ b/apps/server/src/terminal/Layers/Manager.ts @@ -10,6 +10,7 @@ import { type TerminalSummary, } from "@t3tools/contracts"; import { makeKeyedCoalescingWorker } from "@t3tools/shared/KeyedCoalescingWorker"; +import { HostProcessEnv, HostProcessPlatform } from "@t3tools/shared/hostProcess"; import { getTerminalLabel } from "@t3tools/shared/terminalLabels"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; @@ -312,10 +313,7 @@ function enqueueProcessEvent( return true; } -function defaultShellResolver( - platform: NodeJS.Platform = process.platform, - env: NodeJS.ProcessEnv = process.env, -): string { +function defaultShellResolver(platform: NodeJS.Platform, env: NodeJS.ProcessEnv): string { if (platform === "win32") { return "pwsh.exe"; } @@ -324,7 +322,7 @@ function defaultShellResolver( function normalizeShellCommand( value: string | undefined, - platform: NodeJS.Platform = process.platform, + platform: NodeJS.Platform, ): string | null { if (!value) return null; const trimmed = value.trim(); @@ -360,7 +358,7 @@ function joinWindowsPath(...parts: ReadonlyArray): string { function shellCandidateFromCommand( command: string | null, - platform: NodeJS.Platform = process.platform, + platform: NodeJS.Platform, ): ShellCandidate | null { if (!command || command.length === 0) return null; const shellName = basenameForPlatform(command, platform).toLowerCase(); @@ -411,8 +409,8 @@ function uniqueShellCandidates(candidates: Array): ShellC function resolveShellCandidates( shellResolver: () => string, - platform: NodeJS.Platform = process.platform, - env: NodeJS.ProcessEnv = process.env, + platform: NodeJS.Platform, + env: NodeJS.ProcessEnv, ): ShellCandidate[] { const requested = shellCandidateFromCommand( normalizeShellCommand(shellResolver(), platform), @@ -976,8 +974,6 @@ interface TerminalManagerOptions { historyLineLimit?: number; ptyAdapter: PtyAdapterShape; shellResolver?: () => string; - platform?: NodeJS.Platform; - env?: NodeJS.ProcessEnv; subprocessInspector?: TerminalSubprocessInspector; subprocessPollIntervalMs?: number; processKillGraceMs?: number; @@ -1014,8 +1010,8 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith const logsDir = options.logsDir; const historyLineLimit = options.historyLineLimit ?? DEFAULT_HISTORY_LINE_LIMIT; - const platform = options.platform ?? process.platform; - const baseEnv = options.env ?? process.env; + const platform = yield* HostProcessPlatform; + const baseEnv = yield* HostProcessEnv; const shellResolver = options.shellResolver ?? (() => defaultShellResolver(platform, baseEnv)); const processRunner = yield* ProcessRunner.ProcessRunner; const subprocessInspector = diff --git a/apps/server/src/textGeneration/ClaudeTextGeneration.ts b/apps/server/src/textGeneration/ClaudeTextGeneration.ts index c06a0bfc560..a6c88e916a4 100644 --- a/apps/server/src/textGeneration/ClaudeTextGeneration.ts +++ b/apps/server/src/textGeneration/ClaudeTextGeneration.ts @@ -15,6 +15,7 @@ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { type ClaudeSettings, type ModelSelection } from "@t3tools/contracts"; import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git"; +import { HostProcessEnv, HostProcessPlatform } from "@t3tools/shared/hostProcess"; import { TextGenerationError } from "@t3tools/contracts"; import { type TextGenerationShape } from "./TextGeneration.ts"; @@ -59,10 +60,12 @@ const decodeClaudeOutputEnvelope = Schema.decodeEffect(Schema.fromJsonString(Cla export const makeClaudeTextGeneration = Effect.fn("makeClaudeTextGeneration")(function* ( claudeSettings: ClaudeSettings, - environment: NodeJS.ProcessEnv = process.env, + environment?: NodeJS.ProcessEnv, ) { const commandSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const claudeEnvironment = yield* makeClaudeEnvironment(claudeSettings, environment); + const hostEnv = yield* HostProcessEnv; + const hostPlatform = yield* HostProcessPlatform; + const claudeEnvironment = yield* makeClaudeEnvironment(claudeSettings, environment ?? hostEnv); const readStreamAsString = ( operation: string, @@ -173,7 +176,7 @@ export const makeClaudeTextGeneration = Effect.fn("makeClaudeTextGeneration")(fu { env: claudeEnvironment, cwd, - shell: process.platform === "win32", + shell: hostPlatform === "win32", stdin: { stream: Stream.encodeText(Stream.make(prompt)), }, diff --git a/apps/server/src/textGeneration/CodexTextGeneration.ts b/apps/server/src/textGeneration/CodexTextGeneration.ts index bebd0acf800..3531f92f188 100644 --- a/apps/server/src/textGeneration/CodexTextGeneration.ts +++ b/apps/server/src/textGeneration/CodexTextGeneration.ts @@ -9,6 +9,7 @@ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { type CodexSettings, type ModelSelection } from "@t3tools/contracts"; import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git"; +import { HostProcessEnv, HostProcessPlatform } from "@t3tools/shared/hostProcess"; import { resolveAttachmentPath } from "../attachmentStore.ts"; import { ServerConfig } from "../config.ts"; @@ -44,12 +45,15 @@ const encodeJsonString = Schema.encodeEffect(Schema.UnknownFromJsonString); */ export const makeCodexTextGeneration = Effect.fn("makeCodexTextGeneration")(function* ( codexConfig: CodexSettings, - environment: NodeJS.ProcessEnv = process.env, + environment?: NodeJS.ProcessEnv, ) { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; const commandSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; const serverConfig = yield* Effect.service(ServerConfig); + const hostEnv = yield* HostProcessEnv; + const hostPlatform = yield* HostProcessPlatform; + const resolvedEnvironment = environment ?? hostEnv; type MaterializedImageAttachments = { readonly imagePaths: ReadonlyArray; @@ -202,11 +206,11 @@ export const makeCodexTextGeneration = Effect.fn("makeCodexTextGeneration")(func ], { env: { - ...environment, + ...resolvedEnvironment, ...(codexConfig.homePath ? { CODEX_HOME: expandHomePath(codexConfig.homePath) } : {}), }, cwd, - shell: process.platform === "win32", + shell: hostPlatform === "win32", stdin: { stream: Stream.encodeText(Stream.make(prompt)), }, diff --git a/apps/server/src/textGeneration/CursorTextGeneration.ts b/apps/server/src/textGeneration/CursorTextGeneration.ts index c4ef1af21d1..df3d4d7cc7b 100644 --- a/apps/server/src/textGeneration/CursorTextGeneration.ts +++ b/apps/server/src/textGeneration/CursorTextGeneration.ts @@ -6,6 +6,7 @@ import { ChildProcessSpawner } from "effect/unstable/process"; import { type CursorSettings, type ModelSelection } from "@t3tools/contracts"; import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git"; +import { HostProcessEnv } from "@t3tools/shared/hostProcess"; import { extractJsonObject } from "@t3tools/shared/schemaJson"; import { TextGenerationError } from "@t3tools/contracts"; @@ -59,9 +60,11 @@ function isTextGenerationError(error: unknown): error is TextGenerationError { */ export const makeCursorTextGeneration = Effect.fn("makeCursorTextGeneration")(function* ( cursorSettings: CursorSettings, - environment: NodeJS.ProcessEnv = process.env, + environment?: NodeJS.ProcessEnv, ) { const commandSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const hostEnv = yield* HostProcessEnv; + const resolvedEnvironment = environment ?? hostEnv; const runCursorJson = ({ operation, @@ -84,7 +87,7 @@ export const makeCursorTextGeneration = Effect.fn("makeCursorTextGeneration")(fu const outputRef = yield* Ref.make(""); const runtime = yield* makeCursorAcpRuntime({ cursorSettings, - environment, + environment: resolvedEnvironment, childProcessSpawner: commandSpawner, cwd, clientInfo: { name: "t3-code-git-text", version: "0.0.0" }, diff --git a/apps/server/src/textGeneration/OpenCodeTextGeneration.ts b/apps/server/src/textGeneration/OpenCodeTextGeneration.ts index b865b2e5ef5..1c60fec9413 100644 --- a/apps/server/src/textGeneration/OpenCodeTextGeneration.ts +++ b/apps/server/src/textGeneration/OpenCodeTextGeneration.ts @@ -12,6 +12,7 @@ import { type OpenCodeSettings, } from "@t3tools/contracts"; import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git"; +import { HostProcessEnv } from "@t3tools/shared/hostProcess"; import { getModelSelectionStringOptionValue } from "@t3tools/shared/model"; import { extractJsonObject } from "@t3tools/shared/schemaJson"; @@ -99,10 +100,12 @@ interface SharedOpenCodeTextGenerationServerState { export const makeOpenCodeTextGeneration = Effect.fn("makeOpenCodeTextGeneration")(function* ( openCodeSettings: OpenCodeSettings, - environment: NodeJS.ProcessEnv = process.env, + environment?: NodeJS.ProcessEnv, ) { const serverConfig = yield* ServerConfig; const openCodeRuntime = yield* OpenCodeRuntime; + const hostEnv = yield* HostProcessEnv; + const resolvedEnvironment = environment ?? hostEnv; const idleFiberScope = yield* Effect.acquireRelease(Scope.make(), (scope) => Scope.close(scope, Exit.void), ); @@ -208,7 +211,7 @@ export const makeOpenCodeTextGeneration = Effect.fn("makeOpenCodeTextGeneration" openCodeRuntime .startOpenCodeServerProcess({ binaryPath: input.binaryPath, - environment, + environment: resolvedEnvironment, }) .pipe( Effect.provideService(Scope.Scope, serverScope), diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 2823923e033..403f6eb92b9 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -761,7 +761,7 @@ const makeWsRpcLayer = (currentSession: AuthenticatedSession) => keybindings: keybindingsConfig.keybindings, issues: keybindingsConfig.issues, providers, - availableEditors: ExternalLauncher.resolveAvailableEditors(), + availableEditors: yield* ExternalLauncher.getAvailableEditors(), observability: { logsDirectoryPath: config.logsDir, localTracingEnabled: true, diff --git a/packages/shared/package.json b/packages/shared/package.json index 791e66951bd..ad3db08eb2e 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -154,6 +154,10 @@ "./preview": { "types": "./src/preview.ts", "import": "./src/preview.ts" + }, + "./hostProcess": { + "types": "./src/hostProcess.ts", + "import": "./src/hostProcess.ts" } }, "scripts": { diff --git a/packages/shared/src/hostProcess.ts b/packages/shared/src/hostProcess.ts new file mode 100644 index 00000000000..7d7cd09c29c --- /dev/null +++ b/packages/shared/src/hostProcess.ts @@ -0,0 +1,25 @@ +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; + +export const HostProcessPlatform = Context.Reference( + "@t3tools/shared/hostProcess/HostProcessPlatform", + { + defaultValue: () => process.platform, + }, +); + +export const HostProcessArchitecture = Context.Reference( + "@t3tools/shared/hostProcess/HostProcessArchitecture", + { + defaultValue: () => process.arch, + }, +); + +export const HostProcessEnv = Context.Reference( + "@t3tools/shared/hostProcess/HostProcessEnv", + { + defaultValue: () => process.env, + }, +); + +export const isHostWindows = Effect.map(HostProcessPlatform, (platform) => platform === "win32"); diff --git a/packages/shared/src/shell.test.ts b/packages/shared/src/shell.test.ts index a9e2dff6943..ec16792bb60 100644 --- a/packages/shared/src/shell.test.ts +++ b/packages/shared/src/shell.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it, vi } from "vite-plus/test"; import { extractPathFromShellOutput, - isCommandAvailable, + isCommandAvailableForPlatform, listLoginShellCandidates, mergePathEntries, mergePathValues, @@ -10,7 +10,7 @@ import { readEnvironmentFromWindowsShell, readPathFromLaunchctl, readPathFromLoginShell, - resolveCommandPath, + resolveCommandPathForPlatform, resolveKnownWindowsCliDirs, resolveWindowsEnvironment, } from "./shell.ts"; @@ -325,7 +325,7 @@ describe("resolveKnownWindowsCliDirs", () => { describe("isCommandAvailable", () => { it("returns false when PATH is empty", () => { expect( - isCommandAvailable("definitely-not-installed", { + isCommandAvailableForPlatform("definitely-not-installed", { platform: "win32", env: { PATH: "", PATHEXT: ".COM;.EXE;.BAT;.CMD" }, }), @@ -336,7 +336,7 @@ describe("isCommandAvailable", () => { describe("resolveCommandPath", () => { it("returns the first executable resolved from PATH", () => { expect( - resolveCommandPath("definitely-not-installed", { + resolveCommandPathForPlatform("definitely-not-installed", { platform: "win32", env: { PATH: "", PATHEXT: ".COM;.EXE;.BAT;.CMD" }, }), @@ -383,9 +383,7 @@ describe("resolveWindowsEnvironment", () => { expect(readEnvironment).toHaveBeenCalledWith(["PATH"], { loadProfile: false }); expect(commandAvailable).toHaveBeenCalledWith( "node", - expect.objectContaining({ - platform: "win32", - }), + expect.objectContaining({ env: expect.any(Object) }), ); }); diff --git a/packages/shared/src/shell.ts b/packages/shared/src/shell.ts index c88ccc10d2a..7043b516368 100644 --- a/packages/shared/src/shell.ts +++ b/packages/shared/src/shell.ts @@ -18,10 +18,18 @@ type ExecFileSyncLike = ( ) => string; export interface CommandAvailabilityOptions { - readonly platform?: NodeJS.Platform; readonly env?: NodeJS.ProcessEnv; } +export interface PlatformCommandAvailabilityOptions extends CommandAvailabilityOptions { + readonly platform: NodeJS.Platform; +} + +export type CommandAvailabilityChecker = ( + command: string, + options?: CommandAvailabilityOptions, +) => boolean; + export interface WindowsEnvironmentProbeOptions { readonly loadProfile?: boolean; } @@ -382,7 +390,17 @@ export function resolveCommandPath( command: string, options: CommandAvailabilityOptions = {}, ): string | null { - const platform = options.platform ?? process.platform; + return resolveCommandPathForPlatform(command, { + platform: process.platform, + ...(options.env ? { env: options.env } : {}), + }); +} + +export function resolveCommandPathForPlatform( + command: string, + options: PlatformCommandAvailabilityOptions, +): string | null { + const platform = options.platform; const env = options.env ?? process.env; const windowsPathExtensions = platform === "win32" ? resolveWindowsPathExtensions(env) : []; const commandCandidates = resolveCommandCandidates(command, platform, windowsPathExtensions); @@ -424,6 +442,13 @@ export function isCommandAvailable( return resolveCommandPath(command, options) !== null; } +export function isCommandAvailableForPlatform( + command: string, + options: PlatformCommandAvailabilityOptions, +): boolean { + return resolveCommandPathForPlatform(command, options) !== null; +} + export function resolveKnownWindowsCliDirs(env: NodeJS.ProcessEnv): ReadonlyArray { const appData = env.APPDATA?.trim(); const localAppData = env.LOCALAPPDATA?.trim(); @@ -439,7 +464,7 @@ export function resolveKnownWindowsCliDirs(env: NodeJS.ProcessEnv): ReadonlyArra export interface WindowsEnvironmentResolverOptions { readonly readEnvironment?: WindowsShellEnvironmentReader; - readonly commandAvailable?: typeof isCommandAvailable; + readonly commandAvailable?: CommandAvailabilityChecker; } function readWindowsEnvironmentSafely( @@ -472,7 +497,13 @@ export function resolveWindowsEnvironment( options: WindowsEnvironmentResolverOptions = {}, ): Partial { const readEnvironment = options.readEnvironment ?? readEnvironmentFromWindowsShell; - const commandAvailable = options.commandAvailable ?? isCommandAvailable; + const commandAvailable = + options.commandAvailable ?? + ((command, commandOptions) => + isCommandAvailableForPlatform(command, { + platform: "win32", + ...(commandOptions?.env ? { env: commandOptions.env } : {}), + })); const inheritedPath = readEnvPath(env); const shellPath = readWindowsEnvironmentSafely(readEnvironment, ["PATH"], { loadProfile: false, @@ -483,7 +514,7 @@ export function resolveWindowsEnvironment( const baselinePatch: Partial = baselinePath ? { PATH: baselinePath } : {}; const baselineEnv = mergeWindowsEnv(env, baselinePatch); - if (commandAvailable("node", { platform: "win32", env: baselineEnv })) { + if (commandAvailable("node", { env: baselineEnv })) { return baselinePatch; } diff --git a/packages/ssh/src/auth.test.ts b/packages/ssh/src/auth.test.ts index e59707207a2..3cd42ad4382 100644 --- a/packages/ssh/src/auth.test.ts +++ b/packages/ssh/src/auth.test.ts @@ -3,7 +3,9 @@ import { assert, describe, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import { buildSshAskpassHelperDescriptor, @@ -37,7 +39,6 @@ describe("ssh auth", () => { authSecret: "super-secret", interactiveAuth: true, askpassDirectory: directory, - platform: "linux", baseEnv: {}, }); @@ -48,15 +49,21 @@ describe("ssh auth", () => { assert.equal(env.DISPLAY, "t3code"); assert.equal(yield* fs.exists(askpassPath), true); assert.include(yield* fs.readFileString(askpassPath), 'printf "%s\\n" "$T3_SSH_AUTH_SECRET"'); - }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), + }).pipe( + Effect.provide(Layer.merge(NodeServices.layer, Layer.succeed(HostProcessPlatform, "linux"))), + Effect.scoped, + ), ); it.effect("builds a windows askpass launcher pair", () => Effect.gen(function* () { const descriptor = yield* buildSshAskpassHelperDescriptor({ directory: "C:\\temp\\t3code-ssh-askpass", - platform: "win32", - }).pipe(Effect.provide(NodeServices.layer)); + }).pipe( + Effect.provide( + Layer.merge(NodeServices.layer, Layer.succeed(HostProcessPlatform, "win32")), + ), + ); assert.equal(descriptor.launcherPath, "C:\\temp\\t3code-ssh-askpass\\ssh-askpass.cmd"); assert.deepEqual( diff --git a/packages/ssh/src/auth.ts b/packages/ssh/src/auth.ts index f11512cbb75..213d960d222 100644 --- a/packages/ssh/src/auth.ts +++ b/packages/ssh/src/auth.ts @@ -1,3 +1,4 @@ +import { HostProcessEnv, HostProcessPlatform } from "@t3tools/shared/hostProcess"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; @@ -55,7 +56,6 @@ export interface SshChildEnvironmentOptions { readonly baseEnv?: NodeJS.ProcessEnv; readonly askpassDirectory?: string; readonly authSecret?: string | null; - readonly platform?: NodeJS.Platform; } const SSH_ASKPASS_DIR_NAME = "t3code-ssh-askpass"; @@ -110,9 +110,8 @@ export const buildSshAskpassHelperDescriptor = Effect.fn( "ssh/auth.buildSshAskpassHelperDescriptor", )(function* (input: { readonly directory: string; - readonly platform?: NodeJS.Platform; }): Effect.fn.Return { - const platform = input.platform ?? process.platform; + const platform = yield* HostProcessPlatform; const path = yield* Path.Path; const directory = input.directory; @@ -148,12 +147,11 @@ export const buildSshAskpassHelperDescriptor = Effect.fn( export const ensureSshAskpassHelpers = Effect.fn("ssh/auth.ensureSshAskpassHelpers")( function* (input: { readonly directory: string; - readonly platform?: NodeJS.Platform; }): Effect.fn.Return { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const descriptor = yield* buildSshAskpassHelperDescriptor(input); - const platform = input.platform ?? process.platform; + const platform = yield* HostProcessPlatform; yield* fs.makeDirectory(path.dirname(descriptor.launcherPath), { recursive: true }); @@ -179,14 +177,15 @@ export const buildSshChildEnvironment = Effect.fn("ssh/auth.buildSshChildEnviron PlatformError.PlatformError, FileSystem.FileSystem | Path.Path > { - const baseEnv = { ...(input.baseEnv ?? process.env) }; + const hostEnv = yield* HostProcessEnv; + const baseEnv = { ...(input.baseEnv ?? hostEnv) }; if (!input.interactiveAuth) { return baseEnv; } - const platform = input.platform ?? process.platform; + const platform = yield* HostProcessPlatform; const directory = input.askpassDirectory ?? (yield* getDefaultSshAskpassDirectory()); - const sshAskpass = yield* ensureSshAskpassHelpers({ directory, platform }); + const sshAskpass = yield* ensureSshAskpassHelpers({ directory }); return { ...baseEnv, diff --git a/packages/ssh/src/command.ts b/packages/ssh/src/command.ts index 44cc047aae1..eb9c8cfbd31 100644 --- a/packages/ssh/src/command.ts +++ b/packages/ssh/src/command.ts @@ -1,6 +1,7 @@ import * as Crypto from "node:crypto"; import type { DesktopSshEnvironmentTarget, DesktopUpdateChannel } from "@t3tools/contracts"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; @@ -16,7 +17,7 @@ import { SshCommandError, SshInvalidTargetError } from "./errors.ts"; const PUBLISHABLE_T3_VERSION_PATTERN = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/u; const DEFAULT_SSH_COMMAND_TIMEOUT_MS = 60_000; const MAX_SSH_ERROR_OUTPUT_LENGTH = 4_000; -export const SSH_COMMAND = process.platform === "win32" ? "ssh.exe" : "ssh"; +export const SSH_COMMAND = "ssh"; const encoder = new TextEncoder(); @@ -191,6 +192,7 @@ const runSshCommandInScope = Effect.fn("ssh/command.runSshCommand.inScope")(func ...(input.remoteCommandArgs ?? []), ]; const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const hostPlatform = yield* HostProcessPlatform; yield* Effect.logDebug("ssh.command.start", { ...sshTargetLogFields(target), command: [SSH_COMMAND, ...args], @@ -199,8 +201,9 @@ const runSshCommandInScope = Effect.fn("ssh/command.runSshCommand.inScope")(func }); const child = yield* spawner .spawn( - ChildProcess.make(SSH_COMMAND, args, { + ChildProcess.make("ssh", args, { env: environment, + shell: hostPlatform === "win32", stdin: { stream: stdinStream(input.stdin), endOnDone: true, diff --git a/packages/ssh/src/config.ts b/packages/ssh/src/config.ts index 3de430c093b..bb702515a31 100644 --- a/packages/ssh/src/config.ts +++ b/packages/ssh/src/config.ts @@ -1,7 +1,9 @@ import type { DesktopDiscoveredSshHost } from "@t3tools/contracts"; +import * as Config from "effect/Config"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; +import * as Option from "effect/Option"; import * as Path from "effect/Path"; import * as PlatformError from "effect/PlatformError"; @@ -208,7 +210,15 @@ const readKnownHostsHostnames = Effect.fnUntraced(function* (filePath: string) { export const discoverSshHosts = Effect.fnUntraced( function* (input: { readonly homeDir?: string }) { const path = yield* Path.Path; - const homeDir = input?.homeDir ?? process.env.HOME ?? process.env.USERPROFILE ?? ""; + const env = yield* Config.all({ + home: Config.string("HOME").pipe(Config.option), + userProfile: Config.string("USERPROFILE").pipe(Config.option), + }); + const homeDir = + input?.homeDir ?? + Option.getOrUndefined(env.home) ?? + Option.getOrUndefined(env.userProfile) ?? + ""; if (homeDir.trim().length === 0) { return []; } diff --git a/packages/ssh/src/tunnel.ts b/packages/ssh/src/tunnel.ts index 029b7644897..5edd5680816 100644 --- a/packages/ssh/src/tunnel.ts +++ b/packages/ssh/src/tunnel.ts @@ -3,6 +3,7 @@ import type { DesktopSshEnvironmentTarget, } from "@t3tools/contracts"; import * as NetService from "@t3tools/shared/Net"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import { extractJsonObject, fromLenientJson } from "@t3tools/shared/schemaJson"; import { satisfiesSemverRange } from "@t3tools/shared/semver"; import * as Context from "effect/Context"; @@ -1071,6 +1072,7 @@ const startSshTunnel = Effect.fn("ssh/tunnel.startSshTunnel")(function* (input: ]; const tunnelCommand = [SSH_COMMAND, ...args]; const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const hostPlatform = yield* HostProcessPlatform; const scope = yield* Scope.Scope; yield* Effect.logDebug("ssh.tunnel.spawn.start", { ...sshTargetLogFields(input.resolvedTarget), @@ -1084,6 +1086,7 @@ const startSshTunnel = Effect.fn("ssh/tunnel.startSshTunnel")(function* (input: .spawn( ChildProcess.make(SSH_COMMAND, args, { env: childEnvironment, + shell: hostPlatform === "win32", stdin: { stream: Stream.empty, endOnDone: true, diff --git a/packages/tailscale/package.json b/packages/tailscale/package.json index f7c358799a3..ce020dc8ef5 100644 --- a/packages/tailscale/package.json +++ b/packages/tailscale/package.json @@ -14,6 +14,7 @@ }, "dependencies": { "@effect/platform-node": "catalog:", + "@t3tools/shared": "workspace:*", "effect": "catalog:" }, "devDependencies": { diff --git a/packages/tailscale/src/tailscale.ts b/packages/tailscale/src/tailscale.ts index 45fdbc6d0d1..6f74b2f6db2 100644 --- a/packages/tailscale/src/tailscale.ts +++ b/packages/tailscale/src/tailscale.ts @@ -1,3 +1,4 @@ +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import * as Data from "effect/Data"; import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; @@ -10,7 +11,6 @@ export const DEFAULT_TAILSCALE_SERVE_PORT = 443; export const TAILSCALE_STATUS_TIMEOUT_MS = 1_500; export const TAILSCALE_SERVE_TIMEOUT_MS = 10_000; export const TAILSCALE_PROBE_TIMEOUT_MS = 2_500; -const TAILSCALE_COMMAND = process.platform === "win32" ? "tailscale.exe" : "tailscale"; export class TailscaleCommandError extends Data.TaggedError("TailscaleCommandError")<{ readonly command: readonly string[]; @@ -136,8 +136,13 @@ export const readTailscaleStatus: Effect.Effect< > = Effect.gen(function* () { const args = ["status", "--json"]; const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const hostPlatform = yield* HostProcessPlatform; const child = yield* spawner - .spawn(ChildProcess.make(TAILSCALE_COMMAND, args)) + .spawn( + ChildProcess.make("tailscale", args, { + shell: hostPlatform === "win32", + }), + ) .pipe( Effect.mapError((cause) => tailscaleCommandError( @@ -211,8 +216,13 @@ const runTailscaleCommand = ( ): Effect.Effect => Effect.gen(function* () { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const hostPlatform = yield* HostProcessPlatform; const child = yield* spawner - .spawn(ChildProcess.make(TAILSCALE_COMMAND, args)) + .spawn( + ChildProcess.make("tailscale", args, { + shell: hostPlatform === "win32", + }), + ) .pipe( Effect.mapError((cause) => tailscaleCommandError( diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f1325b3c44f..501422ec25d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -810,6 +810,9 @@ importers: '@effect/platform-node': specifier: 4.0.0-beta.78 version: 4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754))(ioredis@5.11.0)(utf-8-validate@6.0.6) + '@t3tools/shared': + specifier: workspace:* + version: link:../shared effect: specifier: 4.0.0-beta.78 version: 4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754) diff --git a/scripts/build-desktop-artifact.test.ts b/scripts/build-desktop-artifact.test.ts index 8dc23484ab6..e08755183e2 100644 --- a/scripts/build-desktop-artifact.test.ts +++ b/scripts/build-desktop-artifact.test.ts @@ -2,6 +2,7 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, it } from "@effect/vitest"; import * as ConfigProvider from "effect/ConfigProvider"; import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import { @@ -11,10 +12,12 @@ import { resolveDesktopBuildIconAssets, resolveDesktopProductName, resolveDesktopUpdateChannel, + resolveGitHubPublishConfig, resolveMockUpdateServerPort, resolveMockUpdateServerUrl, } from "./build-desktop-artifact.ts"; import { BRAND_ASSET_PATHS } from "./lib/brand-assets.ts"; +import { HostProcessArchitecture, HostProcessPlatform } from "./lib/build-target-arch.ts"; it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { it("resolves the dedicated nightly updater channel from nightly versions", () => { @@ -41,6 +44,47 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { }); }); + it.effect("resolves GitHub desktop publish config from Effect config", () => + Effect.gen(function* () { + const latestConfig = yield* resolveGitHubPublishConfig("latest").pipe( + Effect.provide( + ConfigProvider.layer( + ConfigProvider.fromEnv({ + env: { + T3CODE_DESKTOP_UPDATE_REPOSITORY: "pingdotgg/t3code", + }, + }), + ), + ), + ); + const nightlyConfig = yield* resolveGitHubPublishConfig("nightly").pipe( + Effect.provide( + ConfigProvider.layer( + ConfigProvider.fromEnv({ + env: { + GITHUB_REPOSITORY: "pingdotgg/t3code", + }, + }), + ), + ), + ); + + assert.deepStrictEqual(latestConfig, { + provider: "github", + owner: "pingdotgg", + repo: "t3code", + releaseType: "release", + }); + assert.deepStrictEqual(nightlyConfig, { + provider: "github", + owner: "pingdotgg", + repo: "t3code", + releaseType: "prerelease", + channel: "nightly", + }); + }), + ); + it("omits bundled workspace packages from staged desktop dependencies", () => { assert.deepStrictEqual( resolveDesktopRuntimeDependencies( @@ -122,6 +166,43 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { }), ); + it.effect("resolves default platform and architecture from host references", () => + Effect.gen(function* () { + const resolved = yield* resolveBuildOptions({ + platform: Option.none(), + target: Option.none(), + arch: Option.none(), + buildVersion: Option.none(), + outputDir: Option.none(), + skipBuild: Option.none(), + keepStage: Option.none(), + signed: Option.none(), + verbose: Option.none(), + mockUpdates: Option.none(), + mockUpdateServerPort: Option.none(), + }).pipe( + Effect.provide( + Layer.mergeAll( + Layer.succeed(HostProcessPlatform, "win32"), + Layer.succeed(HostProcessArchitecture, "x64"), + ConfigProvider.layer( + ConfigProvider.fromEnv({ + env: { + PROCESSOR_ARCHITECTURE: "AMD64", + PROCESSOR_ARCHITEW6432: "ARM64", + }, + }), + ), + ), + ), + ); + + assert.equal(resolved.platform, "win"); + assert.equal(resolved.target, "nsis"); + assert.equal(resolved.arch, "arm64"); + }), + ); + it.effect("preserves explicit false boolean flags over true env defaults", () => Effect.gen(function* () { const resolved = yield* resolveBuildOptions({ diff --git a/scripts/build-desktop-artifact.ts b/scripts/build-desktop-artifact.ts index 4d63a11dbb0..9e7b8fbe5c8 100644 --- a/scripts/build-desktop-artifact.ts +++ b/scripts/build-desktop-artifact.ts @@ -6,7 +6,11 @@ import desktopPackageJson from "../apps/desktop/package.json" with { type: "json import serverPackageJson from "../apps/server/package.json" with { type: "json" }; import { BRAND_ASSET_PATHS } from "./lib/brand-assets.ts"; -import { getDefaultBuildArch } from "./lib/build-target-arch.ts"; +import { + getDefaultBuildArch, + HostProcessEnv, + HostProcessPlatform, +} from "./lib/build-target-arch.ts"; import { resolveCatalogDependencies } from "./lib/resolve-catalog.ts"; import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; @@ -101,14 +105,14 @@ function detectHostBuildPlatform(hostPlatform: string): typeof BuildPlatform.Typ return undefined; } -function getDefaultArch(platform: typeof BuildPlatform.Type): typeof BuildArch.Type { +const getDefaultArch = Effect.fn("getDefaultArch")(function* (platform: typeof BuildPlatform.Type) { const config = PLATFORM_CONFIG[platform]; if (!config) { return "x64"; } - return getDefaultBuildArch(platform, process.arch, process.env, config); -} + return yield* getDefaultBuildArch(platform, config); +}); class BuildScriptError extends Data.TaggedError("BuildScriptError")<{ readonly message: string; @@ -198,13 +202,21 @@ const resolveGitCommitHash = Effect.fn("resolveGitCommitHash")(function* (repoRo const resolvePythonForNodeGyp = Effect.fn("resolvePythonForNodeGyp")(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; - const configured = process.env.npm_config_python ?? process.env.PYTHON; + const hostPlatform = yield* HostProcessPlatform; + const env = yield* Config.all({ + configuredPython: Config.string("npm_config_python").pipe( + Config.orElse(() => Config.string("PYTHON")), + Config.option, + ), + localAppData: Config.string("LOCALAPPDATA").pipe(Config.option), + }); + const configured = Option.getOrUndefined(env.configuredPython); if (configured && (yield* fs.exists(configured))) { return configured; } - if (process.platform === "win32") { - const localAppData = process.env.LOCALAPPDATA; + if (hostPlatform === "win32") { + const localAppData = Option.getOrUndefined(env.localAppData); if (localAppData) { for (const version of ["Python313", "Python312", "Python311", "Python310"]) { const candidate = path.join(localAppData, "Programs", "Python", version, "python.exe"); @@ -348,21 +360,23 @@ export const resolveBuildOptions = Effect.fn("resolveBuildOptions")(function* ( const path = yield* Path.Path; const repoRoot = yield* RepoRoot; const env = yield* BuildEnvConfig; + const hostPlatform = yield* HostProcessPlatform; const platform = mergeOptions( input.platform, env.platform, - detectHostBuildPlatform(process.platform), + detectHostBuildPlatform(hostPlatform), ); if (!platform) { return yield* new BuildScriptError({ - message: `Unsupported host platform '${process.platform}'.`, + message: `Unsupported host platform '${hostPlatform}'.`, }); } const target = mergeOptions(input.target, env.target, PLATFORM_CONFIG[platform].defaultTarget); - const arch = mergeOptions(input.arch, env.arch, getDefaultArch(platform)); + const defaultArch = yield* getDefaultArch(platform); + const arch = mergeOptions(input.arch, env.arch, defaultArch); const version = mergeOptions(input.buildVersion, env.version, undefined); const releaseDir = resolveBooleanFlag(input.mockUpdates, env.mockUpdates) ? "release-mock" @@ -622,19 +636,18 @@ export function resolveDesktopRuntimeDependencies( return resolveCatalogDependencies(runtimeDependencies, catalog, "apps/desktop"); } -function resolveGitHubPublishConfig(updateChannel: "latest" | "nightly"): - | { - readonly provider: "github"; - readonly owner: string; - readonly repo: string; - readonly releaseType: "release" | "prerelease"; - readonly channel?: "nightly"; - } - | undefined { - const rawRepo = - process.env.T3CODE_DESKTOP_UPDATE_REPOSITORY?.trim() || - process.env.GITHUB_REPOSITORY?.trim() || - ""; +export const resolveGitHubPublishConfig = Effect.fn("resolveGitHubPublishConfig")(function* ( + updateChannel: "latest" | "nightly", +) { + const env = yield* Config.all({ + updateRepository: Config.string("T3CODE_DESKTOP_UPDATE_REPOSITORY").pipe(Config.option), + githubRepository: Config.string("GITHUB_REPOSITORY").pipe(Config.option), + }); + const rawRepo = ( + Option.getOrUndefined(env.updateRepository)?.trim() || + Option.getOrUndefined(env.githubRepository)?.trim() || + "" + ).trim(); if (!rawRepo) return undefined; const [owner, repo, ...rest] = rawRepo.split("/"); @@ -647,7 +660,7 @@ function resolveGitHubPublishConfig(updateChannel: "latest" | "nightly"): releaseType: updateChannel === "nightly" ? "prerelease" : "release", ...(updateChannel === "nightly" ? { channel: "nightly" as const } : {}), }; -} +}); export function resolveDesktopUpdateChannel(version: string): "latest" | "nightly" { return /-nightly\.\d{8}\.\d+$/.test(version) ? "nightly" : "latest"; @@ -696,7 +709,7 @@ const createBuildConfig = Effect.fn("createBuildConfig")(function* ( }, }; const updateChannel = resolveDesktopUpdateChannel(version); - const publishConfig = resolveGitHubPublishConfig(updateChannel); + const publishConfig = yield* resolveGitHubPublishConfig(updateChannel); if (publishConfig) { buildConfig.publish = [publishConfig]; } else if (mockUpdates) { @@ -780,6 +793,9 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( const repoRoot = yield* RepoRoot; const path = yield* Path.Path; const fs = yield* FileSystem.FileSystem; + const hostPlatform = yield* HostProcessPlatform; + const hostEnv = yield* HostProcessEnv; + const useWindowsShell = hostPlatform === "win32"; const workspaceConfig = yield* readWorkspaceConfig(); const workspaceCatalog = workspaceConfig.catalog ?? {}; const workspaceOverrides = workspaceConfig.overrides ?? {}; @@ -850,7 +866,7 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( ChildProcess.make({ cwd: repoRoot, // Windows needs shell mode to resolve .cmd shims (e.g. vp.cmd). - shell: process.platform === "win32", + shell: useWindowsShell, })`vp run build:desktop`, { label: "vp run build:desktop", verbose: options.verbose }, ); @@ -937,13 +953,13 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( ChildProcess.make({ cwd: stageAppDir, // Windows needs shell mode to resolve .cmd shims (e.g. vp.cmd). - shell: process.platform === "win32", + shell: useWindowsShell, })`vp install --prod --no-optional`, { label: "vp install --prod --no-optional", verbose: options.verbose }, ); const buildEnv: NodeJS.ProcessEnv = { - ...process.env, + ...hostEnv, }; for (const [key, value] of Object.entries(buildEnv)) { if (value === "") { @@ -959,7 +975,7 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( delete buildEnv.APPLE_API_ISSUER; } - if (process.platform === "win32") { + if (hostPlatform === "win32") { const python = yield* resolvePythonForNodeGyp(); if (python) { buildEnv.PYTHON = python; @@ -983,7 +999,7 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( cwd: repoRoot, env: buildEnv, // Windows needs shell mode to resolve .cmd shims. - shell: process.platform === "win32", + shell: useWindowsShell, })`vp exec --filter @t3tools/desktop -- electron-builder --projectDir ${stageAppDir} ${platformConfig.cliFlag} --${options.arch} --publish never`, { label: `vp exec --filter @t3tools/desktop -- electron-builder --projectDir ${stageAppDir} ${platformConfig.cliFlag} --${options.arch} --publish never`, diff --git a/scripts/dev-runner.ts b/scripts/dev-runner.ts index 3c53d45dcdc..e5848548949 100644 --- a/scripts/dev-runner.ts +++ b/scripts/dev-runner.ts @@ -5,6 +5,7 @@ import * as NodeOS from "node:os"; import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; import * as NodeServices from "@effect/platform-node/NodeServices"; import * as NetService from "@t3tools/shared/Net"; +import { HostProcessEnv, HostProcessPlatform } from "@t3tools/shared/hostProcess"; import * as Config from "effect/Config"; import * as Data from "effect/Data"; import * as Effect from "effect/Effect"; @@ -418,9 +419,11 @@ export function runDevRunnerWithInput(input: DevRunnerCliInput) { hasExplicitDevUrl: input.devUrl !== undefined, }); + const hostEnv = yield* HostProcessEnv; + const hostPlatform = yield* HostProcessPlatform; const env = yield* createDevRunnerEnv({ mode: input.mode, - baseEnv: process.env, + baseEnv: hostEnv, serverOffset, webOffset, t3Home: input.t3Home, @@ -452,7 +455,7 @@ export function runDevRunnerWithInput(input: DevRunnerCliInput) { env, extendEnv: false, // Windows needs shell mode to resolve .cmd shims (e.g. vp.cmd). - shell: process.platform === "win32", + shell: hostPlatform === "win32", // Keep Vite+ in the same process group so terminal signals (Ctrl+C) // reach it directly. Effect defaults to detached: true on non-Windows, // which would put the runner in a new group and require manual forwarding. diff --git a/scripts/lib/build-target-arch.test.ts b/scripts/lib/build-target-arch.test.ts index 56251d3ffd1..7ff114a5b89 100644 --- a/scripts/lib/build-target-arch.test.ts +++ b/scripts/lib/build-target-arch.test.ts @@ -1,61 +1,95 @@ import { assert, describe, it } from "@effect/vitest"; +import * as ConfigProvider from "effect/ConfigProvider"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; -import { getDefaultBuildArch, resolveHostProcessArch } from "./build-target-arch.ts"; +import { + getDefaultBuildArch, + HostProcessArchitecture, + HostProcessPlatform, + resolveHostProcessArch, +} from "./build-target-arch.ts"; + +const compactEnv = (env: Readonly>): Record => + Object.fromEntries( + Object.entries(env).filter((entry): entry is [string, string] => entry[1] !== undefined), + ); + +const withHostRuntime = ( + platform: NodeJS.Platform, + arch: NodeJS.Architecture, + env: Readonly> = {}, +) => + Effect.provide( + Layer.mergeAll( + Layer.succeed(HostProcessPlatform, platform), + Layer.succeed(HostProcessArchitecture, arch), + ConfigProvider.layer(ConfigProvider.fromEnv({ env: compactEnv(env) })), + ), + ); describe("build-target-arch", () => { - it("prefers arm64 for Windows-on-Arm hosts running x64 emulation", () => { - // Windows-on-Arm can run an x64 Node process under emulation while still - // exposing the real host CPU via PROCESSOR_ARCHITEW6432. - const hostArch = resolveHostProcessArch("win32", "x64", { - PROCESSOR_ARCHITECTURE: "AMD64", // The currently running Node process is x64. - PROCESSOR_ARCHITEW6432: "ARM64", // Windows exposes the real host CPU here when x64 runs under ARM emulation. - }); - - assert.equal(hostArch, "arm64"); - }); - - it("falls back to x64 for native x64 Windows hosts", () => { - const hostArch = resolveHostProcessArch("win32", "x64", { - PROCESSOR_ARCHITECTURE: "AMD64", // Both the process and the Windows host are native x64. - }); - - assert.equal(hostArch, "x64"); - }); - - it("keeps arm64 when the current process is already native arm64", () => { - const hostArch = resolveHostProcessArch("win32", "arm64", {}); - - assert.equal(hostArch, "arm64"); - }); - - it("uses the resolved host arch when selecting the default Windows build arch", () => { - // This mirrors the packaging script's default-path behavior: the current - // process is x64, but the machine itself is ARM64, so the default build - // target should be win-arm64 rather than win-x64. - const arch = getDefaultBuildArch( - "win", - "x64", - { - PROCESSOR_ARCHITECTURE: "AMD64", // The currently running Node process is x64. - PROCESSOR_ARCHITEW6432: "ARM64", // The process is x64, but the actual Windows host is ARM64. - }, - { archChoices: ["x64", "arm64"] }, - ); - - assert.equal(arch, "arm64"); - }); - - it("does not apply Windows host env heuristics for non-Windows targets", () => { - const arch = getDefaultBuildArch( - "linux", - "x64", - { - PROCESSOR_ARCHITECTURE: "AMD64", - PROCESSOR_ARCHITEW6432: "ARM64", - }, - { archChoices: ["x64", "arm64"] }, - ); - - assert.equal(arch, "x64"); - }); + it.effect("prefers arm64 for Windows-on-Arm hosts running x64 emulation", () => + Effect.gen(function* () { + // Windows-on-Arm can run an x64 Node process under emulation while still + // exposing the real host CPU via PROCESSOR_ARCHITEW6432. + const hostArch = yield* resolveHostProcessArch().pipe( + withHostRuntime("win32", "x64", { + PROCESSOR_ARCHITECTURE: "AMD64", // The currently running Node process is x64. + PROCESSOR_ARCHITEW6432: "ARM64", // Windows exposes the real host CPU here when x64 runs under ARM emulation. + }), + ); + + assert.equal(hostArch, "arm64"); + }), + ); + + it.effect("falls back to x64 for native x64 Windows hosts", () => + Effect.gen(function* () { + const hostArch = yield* resolveHostProcessArch().pipe( + withHostRuntime("win32", "x64", { + PROCESSOR_ARCHITECTURE: "AMD64", // Both the process and the Windows host are native x64. + }), + ); + + assert.equal(hostArch, "x64"); + }), + ); + + it.effect("keeps arm64 when the current process is already native arm64", () => + Effect.gen(function* () { + const hostArch = yield* resolveHostProcessArch().pipe(withHostRuntime("win32", "arm64")); + + assert.equal(hostArch, "arm64"); + }), + ); + + it.effect("uses the resolved host arch when selecting the default Windows build arch", () => + Effect.gen(function* () { + // This mirrors the packaging script's default-path behavior: the current + // process is x64, but the machine itself is ARM64, so the default build + // target should be win-arm64 rather than win-x64. + const arch = yield* getDefaultBuildArch("win", { archChoices: ["x64", "arm64"] }).pipe( + withHostRuntime("win32", "x64", { + PROCESSOR_ARCHITECTURE: "AMD64", // The currently running Node process is x64. + PROCESSOR_ARCHITEW6432: "ARM64", // The process is x64, but the actual Windows host is ARM64. + }), + ); + + assert.equal(arch, "arm64"); + }), + ); + + it.effect("does not apply Windows host env heuristics for non-Windows targets", () => + Effect.gen(function* () { + const arch = yield* getDefaultBuildArch("linux", { archChoices: ["x64", "arm64"] }).pipe( + withHostRuntime("linux", "x64", { + PROCESSOR_ARCHITECTURE: "AMD64", + PROCESSOR_ARCHITEW6432: "ARM64", + }), + ); + + assert.equal(arch, "x64"); + }), + ); }); diff --git a/scripts/lib/build-target-arch.ts b/scripts/lib/build-target-arch.ts index 8c39648414a..33884f04a30 100644 --- a/scripts/lib/build-target-arch.ts +++ b/scripts/lib/build-target-arch.ts @@ -1,3 +1,12 @@ +import { + HostProcessArchitecture, + HostProcessEnv, + HostProcessPlatform, +} from "@t3tools/shared/hostProcess"; +import * as Config from "effect/Config"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; + export type BuildArch = "arm64" | "x64" | "universal"; export type BuildPlatform = "mac" | "linux" | "win"; @@ -5,6 +14,13 @@ interface PlatformConfig { readonly archChoices: ReadonlyArray; } +export { HostProcessArchitecture, HostProcessEnv, HostProcessPlatform }; + +const WindowsProcessorArchitectureConfig = Config.all({ + processorArchitecture: Config.string("PROCESSOR_ARCHITECTURE").pipe(Config.option), + processorArchitectureW6432: Config.string("PROCESSOR_ARCHITEW6432").pipe(Config.option), +}); + function normalizeWindowsArch(value: string | undefined): BuildArch | undefined { const normalized = value?.trim().toLowerCase(); if (!normalized) return undefined; @@ -13,38 +29,36 @@ function normalizeWindowsArch(value: string | undefined): BuildArch | undefined return undefined; } -export function resolveHostProcessArch( - platform: NodeJS.Platform, - processArch: NodeJS.Architecture, - env: NodeJS.ProcessEnv, -): BuildArch | undefined { +const optionToUndefined = (value: Option.Option): A | undefined => + Option.getOrUndefined(value); + +export const resolveHostProcessArch = Effect.fn("resolveHostProcessArch")(function* () { + const platform = yield* HostProcessPlatform; + const processArch = yield* HostProcessArchitecture; if (processArch === "arm64") return "arm64"; if (processArch === "x64") { if (platform !== "win32") return "x64"; // On Windows-on-Arm, x64 Node/Bun can run under emulation while the host // still reports ARM64 via the processor environment variables. + const env = yield* WindowsProcessorArchitectureConfig; return ( - normalizeWindowsArch(env.PROCESSOR_ARCHITEW6432) ?? - normalizeWindowsArch(env.PROCESSOR_ARCHITECTURE) ?? + normalizeWindowsArch(optionToUndefined(env.processorArchitectureW6432)) ?? + normalizeWindowsArch(optionToUndefined(env.processorArchitecture)) ?? "x64" ); } return undefined; -} +}); -export function getDefaultBuildArch( +export const getDefaultBuildArch = Effect.fn("getDefaultBuildArch")(function* ( platform: BuildPlatform, - processArch: NodeJS.Architecture, - env: NodeJS.ProcessEnv, platformConfig: PlatformConfig, -): BuildArch { - const hostPlatform: NodeJS.Platform = - platform === "win" ? "win32" : platform === "mac" ? "darwin" : "linux"; - const hostArch = resolveHostProcessArch(hostPlatform, processArch, env); +) { + const hostArch = yield* resolveHostProcessArch(); if (hostArch && platformConfig.archChoices.includes(hostArch)) { return hostArch; } return platformConfig.archChoices[0] ?? "x64"; -} +}); diff --git a/scripts/mobile-native-static-check.ts b/scripts/mobile-native-static-check.ts index 2f29034b42c..d08e3f954e0 100644 --- a/scripts/mobile-native-static-check.ts +++ b/scripts/mobile-native-static-check.ts @@ -2,6 +2,7 @@ import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; import * as NodeServices from "@effect/platform-node/NodeServices"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import * as Console from "effect/Console"; import * as Data from "effect/Data"; import * as Effect from "effect/Effect"; @@ -59,8 +60,9 @@ const commandOutputOptions = { const commandExists = Effect.fn("commandExists")(function* (command: string) { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const hostPlatform = yield* HostProcessPlatform; const lookupCommand = - process.platform === "win32" + hostPlatform === "win32" ? ChildProcess.make("where", [command], { stdout: "ignore", stderr: "ignore", @@ -89,11 +91,12 @@ const runCommand = Effect.fn("runCommand")(function* ( ) { yield* Console.log(`$ ${[command, ...args].join(" ")}`); const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const hostPlatform = yield* HostProcessPlatform; const child = yield* spawner.spawn( ChildProcess.make(command, [...args], { cwd, ...commandOutputOptions, - shell: process.platform === "win32", + shell: hostPlatform === "win32", }), ); const exitCode = Number(yield* child.exitCode); From 8e3ba92a41c1a98c54e232faa9c3cccdc295bb99 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 4 Jun 2026 14:06:51 -0700 Subject: [PATCH 02/15] refactor(runtime): remove host env reference Co-authored-by: codex --- apps/server/src/process/externalLauncher.ts | 51 +++++++++--- .../server/src/provider/Drivers/ClaudeHome.ts | 4 +- .../src/provider/Layers/ClaudeProvider.ts | 11 +-- .../src/provider/Layers/CodexProvider.ts | 7 +- .../provider/Layers/CodexSessionRuntime.ts | 6 +- .../src/provider/Layers/CursorProvider.ts | 8 +- .../src/provider/Layers/OpenCodeProvider.ts | 4 +- .../provider/ProviderInstanceEnvironment.ts | 10 +-- .../src/provider/acp/AcpSessionRuntime.ts | 5 +- apps/server/src/provider/opencodeRuntime.ts | 8 +- .../src/provider/providerMaintenance.ts | 24 +++++- .../src/terminal/Layers/Manager.test.ts | 79 +++++++----------- apps/server/src/terminal/Layers/Manager.ts | 45 ++++++++++- .../textGeneration/ClaudeTextGeneration.ts | 5 +- .../src/textGeneration/CodexTextGeneration.ts | 5 +- .../textGeneration/CursorTextGeneration.ts | 4 +- .../textGeneration/OpenCodeTextGeneration.ts | 4 +- oxlint-plugin-t3code/index.ts | 2 + .../rules/no-global-process-runtime.test.ts | 52 ++++++++++++ .../rules/no-global-process-runtime.ts | 81 +++++++++++++++++++ packages/shared/src/hostProcess.ts | 7 -- packages/ssh/src/auth.ts | 16 +++- packages/ssh/src/command.ts | 1 + packages/ssh/src/tunnel.ts | 1 + scripts/build-desktop-artifact.ts | 44 +++++----- scripts/dev-runner.ts | 7 +- scripts/lib/build-target-arch.ts | 8 +- vite.config.ts | 1 + 28 files changed, 341 insertions(+), 159 deletions(-) create mode 100644 oxlint-plugin-t3code/rules/no-global-process-runtime.test.ts create mode 100644 oxlint-plugin-t3code/rules/no-global-process-runtime.ts diff --git a/apps/server/src/process/externalLauncher.ts b/apps/server/src/process/externalLauncher.ts index e8fa8c84d0e..d20bc0bbffe 100644 --- a/apps/server/src/process/externalLauncher.ts +++ b/apps/server/src/process/externalLauncher.ts @@ -12,12 +12,12 @@ import { type EditorId, type LaunchEditorInput, } from "@t3tools/contracts"; -import { HostProcessEnv, HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import { - isCommandAvailable, isCommandAvailableForPlatform, type PlatformCommandAvailabilityOptions, } from "@t3tools/shared/shell"; +import * as Config from "effect/Config"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Encoding from "effect/Encoding"; @@ -31,7 +31,7 @@ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; export { ExternalLauncherError }; export type { LaunchEditorInput }; -export { isCommandAvailable, isCommandAvailableForPlatform } from "@t3tools/shared/shell"; +export { isCommandAvailableForPlatform } from "@t3tools/shared/shell"; interface EditorLaunch { readonly command: string; @@ -66,6 +66,36 @@ const DETACHED_IGNORE_STDIO_OPTIONS = { stderr: "ignore", } as const satisfies ChildProcess.CommandOptions; +const compactEnv = (input: Record>): NodeJS.ProcessEnv => + Object.fromEntries( + Object.entries(input).flatMap(([key, value]) => + Option.match(value, { + onNone: () => [], + onSome: (resolved) => [[key, resolved]], + }), + ), + ); + +const BrowserLaunchEnvConfig = Config.all({ + SYSTEMROOT: Config.string("SYSTEMROOT").pipe(Config.option), + windir: Config.string("windir").pipe(Config.option), + WSL_DISTRO_NAME: Config.string("WSL_DISTRO_NAME").pipe(Config.option), + WSL_INTEROP: Config.string("WSL_INTEROP").pipe(Config.option), + SSH_CONNECTION: Config.string("SSH_CONNECTION").pipe(Config.option), + SSH_TTY: Config.string("SSH_TTY").pipe(Config.option), + container: Config.string("container").pipe(Config.option), +}).pipe(Config.map(compactEnv)); + +const CommandLookupEnvConfig = Config.all({ + PATH: Config.string("PATH").pipe(Config.option), + Path: Config.string("Path").pipe(Config.option), + path: Config.string("path").pipe(Config.option), + PATHEXT: Config.string("PATHEXT").pipe(Config.option), +}).pipe(Config.map(compactEnv)); + +const readBrowserLaunchEnv = BrowserLaunchEnvConfig.pipe(Effect.orElseSucceed(() => ({}))); +const readCommandLookupEnv = CommandLookupEnvConfig.pipe(Effect.orElseSucceed(() => ({}))); + function parseTargetPathAndPosition(target: string): Option.Option { const match = TARGET_WITH_POSITION_PATTERN.exec(target); if (!match?.[1] || !match[2]) { @@ -140,7 +170,7 @@ function escapePowerShellStringLiteral(input: string): string { return `'${input.replaceAll("'", "''")}'`; } -function resolvePowerShellPath(env: NodeJS.ProcessEnv = process.env): string { +function resolvePowerShellPath(env: NodeJS.ProcessEnv = {}): string { return `${env.SYSTEMROOT || env.windir || String.raw`C:\Windows`}\\System32\\WindowsPowerShell\\v1.0\\powershell.exe`; } @@ -150,7 +180,7 @@ function resolveWslPowerShellPath(): string { function shouldUseWindowsBrowserFromWsl( platform: NodeJS.Platform, - env: NodeJS.ProcessEnv = process.env, + env: NodeJS.ProcessEnv = {}, ): boolean { return ( platform === "linux" && @@ -243,7 +273,7 @@ export function resolveAvailableEditors( export const getAvailableEditors = Effect.fn("externalLauncher.getAvailableEditors")(function* () { const platform = yield* HostProcessPlatform; - const env = yield* HostProcessEnv; + const env = yield* readCommandLookupEnv; return resolveAvailableEditors(platform, env); }); @@ -314,7 +344,7 @@ export const resolveEditorLaunch = Effect.fn("resolveEditorLaunch")(function* ( input: LaunchEditorInput, ): Effect.fn.Return { const platform = yield* HostProcessPlatform; - const env = yield* HostProcessEnv; + const env = yield* readCommandLookupEnv; return yield* resolveEditorLaunchForPlatform(input, platform, env); }); @@ -337,7 +367,7 @@ export const launchBrowser = Effect.fn("externalLauncher.launchBrowser")(functio target: string, ): Effect.fn.Return { const platform = yield* HostProcessPlatform; - const env = yield* HostProcessEnv; + const env = yield* readBrowserLaunchEnv; return yield* launchAndUnref( resolveBrowserLaunch(target, platform, env), "Browser auto-open failed", @@ -347,13 +377,14 @@ 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)) { + const platform = yield* HostProcessPlatform; + const env = yield* readCommandLookupEnv; + if (!isCommandAvailableForPlatform(launch.command, { platform, env })) { return yield* new ExternalLauncherError({ message: `Editor command not found: ${launch.command}`, }); } - const platform = yield* HostProcessPlatform; const isWin32 = platform === "win32"; yield* launchAndUnref( { diff --git a/apps/server/src/provider/Drivers/ClaudeHome.ts b/apps/server/src/provider/Drivers/ClaudeHome.ts index 5fe7fdc7506..65c74f9764a 100644 --- a/apps/server/src/provider/Drivers/ClaudeHome.ts +++ b/apps/server/src/provider/Drivers/ClaudeHome.ts @@ -1,7 +1,6 @@ import * as NodeOS from "node:os"; import type { ClaudeSettings } from "@t3tools/contracts"; -import { HostProcessEnv } from "@t3tools/shared/hostProcess"; import * as Effect from "effect/Effect"; import * as Path from "effect/Path"; @@ -19,8 +18,7 @@ export const makeClaudeEnvironment = Effect.fn("makeClaudeEnvironment")(function config: Pick, baseEnv?: NodeJS.ProcessEnv, ): Effect.fn.Return { - const hostEnv = yield* HostProcessEnv; - const resolvedBaseEnv = baseEnv ?? hostEnv; + const resolvedBaseEnv = baseEnv ?? process.env; const homePath = config.homePath.trim(); if (homePath.length === 0) return resolvedBaseEnv; const resolvedHomePath = yield* resolveClaudeHomePath(config); diff --git a/apps/server/src/provider/Layers/ClaudeProvider.ts b/apps/server/src/provider/Layers/ClaudeProvider.ts index 9b4992b7601..f9770b1d1ec 100644 --- a/apps/server/src/provider/Layers/ClaudeProvider.ts +++ b/apps/server/src/provider/Layers/ClaudeProvider.ts @@ -18,7 +18,7 @@ import { getProviderOptionCurrentValue, getProviderOptionDescriptors, } from "@t3tools/shared/model"; -import { HostProcessEnv, HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import { compareSemverVersions } from "@t3tools/shared/semver"; import { query as claudeQuery, @@ -552,8 +552,7 @@ const probeClaudeCapabilities = ( ) => { const abort = new AbortController(); return Effect.gen(function* () { - const hostEnv = yield* HostProcessEnv; - const claudeEnvironment = yield* makeClaudeEnvironment(claudeSettings, environment ?? hostEnv); + const claudeEnvironment = yield* makeClaudeEnvironment(claudeSettings, environment); return yield* Effect.tryPromise(async () => { const q = claudeQuery({ // Never yield — we only need initialization data, not a conversation. @@ -607,9 +606,8 @@ const runClaudeCommand = Effect.fn("runClaudeCommand")(function* ( args: ReadonlyArray, environment?: NodeJS.ProcessEnv, ) { - const hostEnv = yield* HostProcessEnv; const hostPlatform = yield* HostProcessPlatform; - const claudeEnvironment = yield* makeClaudeEnvironment(claudeSettings, environment ?? hostEnv); + const claudeEnvironment = yield* makeClaudeEnvironment(claudeSettings, environment); const command = ChildProcess.make(claudeSettings.binaryPath, [...args], { env: claudeEnvironment, shell: hostPlatform === "win32", @@ -628,8 +626,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( never, ChildProcessSpawner.ChildProcessSpawner | Path.Path > { - const hostEnv = yield* HostProcessEnv; - const resolvedEnvironment = environment ?? hostEnv; + const resolvedEnvironment = environment ?? process.env; const checkedAt = DateTime.formatIso(yield* DateTime.now); const allModels = providerModelsFromSettings( BUILT_IN_MODELS, diff --git a/apps/server/src/provider/Layers/CodexProvider.ts b/apps/server/src/provider/Layers/CodexProvider.ts index dcbeddc34ec..74eeea5c53a 100644 --- a/apps/server/src/provider/Layers/CodexProvider.ts +++ b/apps/server/src/provider/Layers/CodexProvider.ts @@ -23,7 +23,6 @@ import type { } from "@t3tools/contracts"; import { ServerSettingsError } from "@t3tools/contracts"; -import { HostProcessEnv } from "@t3tools/shared/hostProcess"; import { createModelCapabilities } from "@t3tools/shared/model"; import { AUTH_PROBE_TIMEOUT_MS, @@ -288,7 +287,6 @@ const probeCodexAppServerProvider = Effect.fn("probeCodexAppServerProvider")(fun readonly customModels?: ReadonlyArray; readonly environment?: NodeJS.ProcessEnv; }) { - const hostEnv = yield* HostProcessEnv; // `~` is not shell-expanded when env vars are set via `child_process.spawn`, // so `CODEX_HOME=~/.codex_work` would reach codex verbatim and trip // "CODEX_HOME points to '~/.codex_work', but that path does not exist". @@ -300,7 +298,7 @@ const probeCodexAppServerProvider = Effect.fn("probeCodexAppServerProvider")(fun args: ["app-server"], cwd: input.cwd, env: { - ...(input.environment ?? hostEnv), + ...(input.environment ?? process.env), ...(resolvedHomePath ? { CODEX_HOME: resolvedHomePath } : {}), }, }), @@ -457,8 +455,7 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu ServerSettingsError, ChildProcessSpawner.ChildProcessSpawner > { - const hostEnv = yield* HostProcessEnv; - const resolvedEnvironment = environment ?? hostEnv; + const resolvedEnvironment = environment ?? process.env; const checkedAt = DateTime.formatIso(yield* DateTime.now); const emptyModels = emptyCodexModelsFromSettings(codexSettings); diff --git a/apps/server/src/provider/Layers/CodexSessionRuntime.ts b/apps/server/src/provider/Layers/CodexSessionRuntime.ts index 8c3c741d78e..1d81fc7f831 100644 --- a/apps/server/src/provider/Layers/CodexSessionRuntime.ts +++ b/apps/server/src/provider/Layers/CodexSessionRuntime.ts @@ -16,7 +16,7 @@ import { ThreadId, TurnId, } from "@t3tools/contracts"; -import { HostProcessEnv, HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import { normalizeModelSlug } from "@t3tools/shared/model"; import * as Crypto from "effect/Crypto"; import * as DateTime from "effect/DateTime"; @@ -720,10 +720,9 @@ export const makeCodexSessionRuntime = ( // `child_process.spawn`; `expandHomePath` lets a configured // `CODEX_HOME=~/.codex_work` reach codex as an absolute path. const resolvedHomePath = options.homePath ? expandHomePath(options.homePath) : undefined; - const hostEnv = yield* HostProcessEnv; const hostPlatform = yield* HostProcessPlatform; const env = { - ...(options.environment ?? hostEnv), + ...options.environment, ...(resolvedHomePath ? { CODEX_HOME: resolvedHomePath } : {}), }; const child = yield* spawner @@ -731,6 +730,7 @@ export const makeCodexSessionRuntime = ( ChildProcess.make(options.binaryPath, ["app-server", ...(options.appServerArgs ?? [])], { cwd: options.cwd, env, + extendEnv: options.environment === undefined, forceKillAfter: CODEX_APP_SERVER_FORCE_KILL_AFTER, shell: hostPlatform === "win32", }), diff --git a/apps/server/src/provider/Layers/CursorProvider.ts b/apps/server/src/provider/Layers/CursorProvider.ts index 6ff8f6ccb51..f8bd81519a8 100644 --- a/apps/server/src/provider/Layers/CursorProvider.ts +++ b/apps/server/src/provider/Layers/CursorProvider.ts @@ -27,7 +27,7 @@ import { getProviderOptionBooleanSelectionValue, getProviderOptionStringSelectionValue, } from "@t3tools/shared/model"; -import { HostProcessEnv, HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import { buildBooleanOptionDescriptor, @@ -399,7 +399,6 @@ const makeCursorAcpProbeRuntime = ( ) => Effect.gen(function* () { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const hostEnv = yield* HostProcessEnv; const acpContext = yield* Layer.build( AcpSessionRuntime.layer({ spawn: { @@ -409,7 +408,7 @@ const makeCursorAcpProbeRuntime = ( "acp", ], cwd: process.cwd(), - env: environment ?? hostEnv, + ...(environment ? { env: environment } : {}), }, cwd: process.cwd(), clientInfo: { name: "t3-code-provider-probe", version: "0.0.0" }, @@ -933,10 +932,9 @@ const runCursorCommand = ( ) => Effect.gen(function* () { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const hostEnv = yield* HostProcessEnv; const hostPlatform = yield* HostProcessPlatform; const command = ChildProcess.make(cursorSettings.binaryPath, [...args], { - env: environment ?? hostEnv, + ...(environment ? { env: environment } : { extendEnv: true }), shell: hostPlatform === "win32", }); diff --git a/apps/server/src/provider/Layers/OpenCodeProvider.ts b/apps/server/src/provider/Layers/OpenCodeProvider.ts index 0c7b0f4b308..a8285e960fc 100644 --- a/apps/server/src/provider/Layers/OpenCodeProvider.ts +++ b/apps/server/src/provider/Layers/OpenCodeProvider.ts @@ -9,7 +9,6 @@ import * as Data from "effect/Data"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; -import { HostProcessEnv } from "@t3tools/shared/hostProcess"; import { createModelCapabilities } from "@t3tools/shared/model"; import { compareSemverVersions } from "@t3tools/shared/semver"; import { @@ -305,8 +304,7 @@ export const checkOpenCodeProviderStatus = Effect.fn("checkOpenCodeProviderStatu environment?: NodeJS.ProcessEnv, ): Effect.fn.Return { const openCodeRuntime = yield* OpenCodeRuntime; - const hostEnv = yield* HostProcessEnv; - const resolvedEnvironment = environment ?? hostEnv; + const resolvedEnvironment = environment ?? process.env; const checkedAt = DateTime.formatIso(yield* DateTime.now); const customModels = openCodeSettings.customModels; const isExternalServer = openCodeSettings.serverUrl.trim().length > 0; diff --git a/apps/server/src/provider/ProviderInstanceEnvironment.ts b/apps/server/src/provider/ProviderInstanceEnvironment.ts index 3d0c24d51ae..057713048f7 100644 --- a/apps/server/src/provider/ProviderInstanceEnvironment.ts +++ b/apps/server/src/provider/ProviderInstanceEnvironment.ts @@ -1,5 +1,4 @@ import type { ProviderInstanceEnvironment } from "@t3tools/contracts"; -import { HostProcessEnv } from "@t3tools/shared/hostProcess"; import * as Effect from "effect/Effect"; export function mergeProviderInstanceEnvironment( @@ -17,9 +16,6 @@ export function mergeProviderInstanceEnvironment( return next; } -export const mergeProviderInstanceEnvironmentEffect = Effect.fn( - "mergeProviderInstanceEnvironmentEffect", -)(function* (environment: ProviderInstanceEnvironment | undefined) { - const hostEnv = yield* HostProcessEnv; - return mergeProviderInstanceEnvironment(environment, hostEnv); -}); +export const mergeProviderInstanceEnvironmentEffect = ( + environment: ProviderInstanceEnvironment | undefined, +) => Effect.sync(() => mergeProviderInstanceEnvironment(environment)); diff --git a/apps/server/src/provider/acp/AcpSessionRuntime.ts b/apps/server/src/provider/acp/AcpSessionRuntime.ts index dee8d1d1dca..6db2cdcd0ef 100644 --- a/apps/server/src/provider/acp/AcpSessionRuntime.ts +++ b/apps/server/src/provider/acp/AcpSessionRuntime.ts @@ -13,7 +13,7 @@ import * as EffectAcpClient from "effect-acp/client"; import * as EffectAcpErrors from "effect-acp/errors"; import type * as EffectAcpSchema from "effect-acp/schema"; import type * as EffectAcpProtocol from "effect-acp/protocol"; -import { HostProcessEnv, HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import { collectSessionConfigOptionValues, @@ -202,13 +202,12 @@ const makeAcpSessionRuntime = ( ), ); - const hostEnv = yield* HostProcessEnv; const hostPlatform = yield* HostProcessPlatform; const child = yield* spawner .spawn( ChildProcess.make(options.spawn.command, [...options.spawn.args], { ...(options.spawn.cwd ? { cwd: options.spawn.cwd } : {}), - ...(options.spawn.env ? { env: { ...hostEnv, ...options.spawn.env } } : {}), + ...(options.spawn.env ? { env: options.spawn.env, extendEnv: true } : {}), shell: hostPlatform === "win32", }), ) diff --git a/apps/server/src/provider/opencodeRuntime.ts b/apps/server/src/provider/opencodeRuntime.ts index 60caaca07af..dafdcd4e1bc 100644 --- a/apps/server/src/provider/opencodeRuntime.ts +++ b/apps/server/src/provider/opencodeRuntime.ts @@ -31,7 +31,7 @@ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { isWindowsCommandNotFound } from "../processRunner.ts"; import { collectStreamAsString } from "./providerSnapshot.ts"; import * as NetService from "@t3tools/shared/Net"; -import { HostProcessEnv, HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; const encodeUnknownJsonStringExit = Schema.encodeUnknownExit(Schema.UnknownFromJsonString); const OPENCODE_EMPTY_CONFIG_CONTENT = "{}"; @@ -277,7 +277,6 @@ function ensureRuntimeError( const makeOpenCodeRuntime = Effect.gen(function* () { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const netService = yield* NetService.NetService; - const hostEnv = yield* HostProcessEnv; const hostPlatform = yield* HostProcessPlatform; const runOpenCodeCommand: OpenCodeRuntimeShape["runOpenCodeCommand"] = (input) => @@ -285,7 +284,7 @@ const makeOpenCodeRuntime = Effect.gen(function* () { const child = yield* spawner.spawn( ChildProcess.make(input.binaryPath, [...input.args], { shell: hostPlatform === "win32", - env: input.environment ?? hostEnv, + ...(input.environment ? { env: input.environment } : { extendEnv: true }), }), ); const [stdout, stderr, code] = yield* Effect.all( @@ -344,9 +343,10 @@ const makeOpenCodeRuntime = Effect.gen(function* () { detached: hostPlatform !== "win32", shell: hostPlatform === "win32", env: { - ...(input.environment ?? hostEnv), + ...input.environment, OPENCODE_CONFIG_CONTENT: OPENCODE_EMPTY_CONFIG_CONTENT, }, + extendEnv: input.environment === undefined, }), ) .pipe( diff --git a/apps/server/src/provider/providerMaintenance.ts b/apps/server/src/provider/providerMaintenance.ts index 8b466b77523..6a867c5a0e0 100644 --- a/apps/server/src/provider/providerMaintenance.ts +++ b/apps/server/src/provider/providerMaintenance.ts @@ -3,9 +3,10 @@ import { type ServerProvider, type ServerProviderVersionAdvisory, } from "@t3tools/contracts"; -import { HostProcessEnv, HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import { compareSemverVersions } from "@t3tools/shared/semver"; import { resolveCommandPath, resolveCommandPathForPlatform } from "@t3tools/shared/shell"; +import * as Config from "effect/Config"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; @@ -17,6 +18,25 @@ const LATEST_VERSION_CACHE_TTL_MS = 60 * 60 * 1_000; const LATEST_VERSION_TIMEOUT_MS = 4_000; const PROVIDER_UPDATE_ACTION_TOAST_MESSAGE = "Install the update now or review provider settings."; +const compactEnv = (input: Record>): NodeJS.ProcessEnv => + Object.fromEntries( + Object.entries(input).flatMap(([key, value]) => + Option.match(value, { + onNone: () => [], + onSome: (resolved) => [[key, resolved]], + }), + ), + ); + +const CommandLookupEnvConfig = Config.all({ + PATH: Config.string("PATH").pipe(Config.option), + Path: Config.string("Path").pipe(Config.option), + path: Config.string("path").pipe(Config.option), + PATHEXT: Config.string("PATHEXT").pipe(Config.option), +}).pipe(Config.map(compactEnv)); + +const readCommandLookupEnv = CommandLookupEnvConfig.pipe(Effect.orElseSucceed(() => ({}))); + export interface ProviderMaintenanceCapabilities { readonly provider: ProviderDriverKind; readonly packageName: string | null; @@ -336,7 +356,7 @@ export const resolveProviderMaintenanceCapabilitiesEffect = Effect.fn( } const platform = yield* HostProcessPlatform; - const env = options?.env ?? (yield* HostProcessEnv); + const env = options?.env ?? (yield* readCommandLookupEnv); const resolvedCommandPath = resolveCommandPathForPlatform(binaryPath, { platform, diff --git a/apps/server/src/terminal/Layers/Manager.test.ts b/apps/server/src/terminal/Layers/Manager.test.ts index fd6c8a65b11..fa5c44e1016 100644 --- a/apps/server/src/terminal/Layers/Manager.test.ts +++ b/apps/server/src/terminal/Layers/Manager.test.ts @@ -8,7 +8,8 @@ import { type TerminalOpenInput, type TerminalRestartInput, } from "@t3tools/contracts"; -import { HostProcessEnv, HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import * as ConfigProvider from "effect/ConfigProvider"; import * as Data from "effect/Data"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; @@ -267,14 +268,15 @@ const createManager = ( }), ); -const withHostProcess = (input: { +const withHostProcess = (platform: NodeJS.Platform) => Layer.succeed(HostProcessPlatform, platform); + +const withConfigEnv = (env: Record) => + ConfigProvider.layer(ConfigProvider.fromEnv({ env })); + +const withHostRuntime = (input: { readonly platform: NodeJS.Platform; - readonly env?: NodeJS.ProcessEnv; -}) => - Layer.mergeAll( - Layer.succeed(HostProcessPlatform, input.platform), - Layer.succeed(HostProcessEnv, input.env ?? {}), - ); + readonly env?: Record; +}) => Layer.merge(withHostProcess(input.platform), withConfigEnv(input.env ?? {})); it.layer( Layer.merge(NodeServices.layer, ProcessRunner.layer.pipe(Layer.provide(NodeServices.layer))), @@ -1128,7 +1130,7 @@ it.layer( Effect.gen(function* () { const { manager, ptyAdapter } = yield* createManager(5).pipe( Effect.provide( - withHostProcess({ + withHostRuntime({ platform: "win32", env: { ComSpec: "C:\\Windows\\System32\\cmd.exe", @@ -1158,7 +1160,7 @@ it.layer( shellResolver: () => "C:\\missing\\custom-shell.exe", }).pipe( Effect.provide( - withHostProcess({ + withHostRuntime({ platform: "win32", env: { ComSpec: "C:\\Windows\\System32\\cmd.exe", @@ -1187,46 +1189,25 @@ it.layer( it.effect("filters app runtime env variables from terminal sessions", () => Effect.gen(function* () { - const originalValues = new Map(); - const setEnv = (key: string, value: string | undefined) => { - if (!originalValues.has(key)) { - originalValues.set(key, process.env[key]); - } - if (value === undefined) { - delete process.env[key]; - return; - } - process.env[key] = value; - }; - const restoreEnv = () => { - for (const [key, value] of originalValues) { - if (value === undefined) { - delete process.env[key]; - } else { - process.env[key] = value; - } - } - }; - - setEnv("PORT", "5173"); - setEnv("T3CODE_PORT", "3773"); - setEnv("VITE_DEV_SERVER_URL", "http://localhost:5173"); - setEnv("TEST_TERMINAL_KEEP", "keep-me"); + const { manager, ptyAdapter } = yield* createManager().pipe( + Effect.provide( + withConfigEnv({ + PORT: "5173", + T3CODE_PORT: "3773", + VITE_DEV_SERVER_URL: "http://localhost:5173", + LANG: "en_US.UTF-8", + }), + ), + ); + yield* manager.open(openInput()); + const spawnInput = ptyAdapter.spawnInputs[0]; + expect(spawnInput).toBeDefined(); + if (!spawnInput) return; - try { - const { manager, ptyAdapter } = yield* createManager(); - yield* manager.open(openInput()); - const spawnInput = ptyAdapter.spawnInputs[0]; - expect(spawnInput).toBeDefined(); - if (!spawnInput) return; - - expect(spawnInput.env.PORT).toBeUndefined(); - expect(spawnInput.env.T3CODE_PORT).toBeUndefined(); - expect(spawnInput.env.VITE_DEV_SERVER_URL).toBeUndefined(); - expect(spawnInput.env.TEST_TERMINAL_KEEP).toBe("keep-me"); - } finally { - restoreEnv(); - } + expect(spawnInput.env.PORT).toBeUndefined(); + expect(spawnInput.env.T3CODE_PORT).toBeUndefined(); + expect(spawnInput.env.VITE_DEV_SERVER_URL).toBeUndefined(); + expect(spawnInput.env.LANG).toBe("en_US.UTF-8"); }), ); diff --git a/apps/server/src/terminal/Layers/Manager.ts b/apps/server/src/terminal/Layers/Manager.ts index d7c70b47d3b..fa61798c91c 100644 --- a/apps/server/src/terminal/Layers/Manager.ts +++ b/apps/server/src/terminal/Layers/Manager.ts @@ -10,8 +10,9 @@ import { type TerminalSummary, } from "@t3tools/contracts"; import { makeKeyedCoalescingWorker } from "@t3tools/shared/KeyedCoalescingWorker"; -import { HostProcessEnv, HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import { getTerminalLabel } from "@t3tools/shared/terminalLabels"; +import * as Config from "effect/Config"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as Encoding from "effect/Encoding"; @@ -62,6 +63,45 @@ const TERMINAL_ENV_BLOCKLIST = new Set(["PORT", "ELECTRON_RENDERER_PORT", "ELECT const nowIso = Effect.map(DateTime.now, DateTime.formatIso); const MAX_TERMINAL_LABEL_LENGTH = 128; +const compactEnv = (input: Record>): NodeJS.ProcessEnv => + Object.fromEntries( + Object.entries(input).flatMap(([key, value]) => + Option.match(value, { + onNone: () => [], + onSome: (resolved) => [[key, resolved]], + }), + ), + ); + +const TerminalHostEnvConfig = Config.all({ + COLORTERM: Config.string("COLORTERM").pipe(Config.option), + ComSpec: Config.string("ComSpec").pipe(Config.option), + ELECTRON_RENDERER_PORT: Config.string("ELECTRON_RENDERER_PORT").pipe(Config.option), + ELECTRON_RUN_AS_NODE: Config.string("ELECTRON_RUN_AS_NODE").pipe(Config.option), + HOME: Config.string("HOME").pipe(Config.option), + LANG: Config.string("LANG").pipe(Config.option), + LC_ALL: Config.string("LC_ALL").pipe(Config.option), + PATH: Config.string("PATH").pipe(Config.option), + PATHEXT: Config.string("PATHEXT").pipe(Config.option), + PORT: Config.string("PORT").pipe(Config.option), + Path: Config.string("Path").pipe(Config.option), + SHELL: Config.string("SHELL").pipe(Config.option), + SSH_AUTH_SOCK: Config.string("SSH_AUTH_SOCK").pipe(Config.option), + SystemRoot: Config.string("SystemRoot").pipe(Config.option), + T3CODE_PORT: Config.string("T3CODE_PORT").pipe(Config.option), + TEMP: Config.string("TEMP").pipe(Config.option), + TERM: Config.string("TERM").pipe(Config.option), + TMP: Config.string("TMP").pipe(Config.option), + TMPDIR: Config.string("TMPDIR").pipe(Config.option), + USER: Config.string("USER").pipe(Config.option), + USERNAME: Config.string("USERNAME").pipe(Config.option), + VITE_DEV_SERVER_URL: Config.string("VITE_DEV_SERVER_URL").pipe(Config.option), + path: Config.string("path").pipe(Config.option), + windir: Config.string("windir").pipe(Config.option), +}).pipe(Config.map(compactEnv)); + +const readTerminalHostEnv = TerminalHostEnvConfig.pipe(Effect.orElseSucceed(() => ({}))); + class TerminalSubprocessCheckError extends Schema.TaggedErrorClass()( "TerminalSubprocessCheckError", { @@ -515,6 +555,7 @@ function windowsInspectSubprocess( timeout: "1500 millis", maxOutputBytes: 32_768, outputMode: "truncate", + shell: true, timeoutBehavior: "timedOutResult", }); }).pipe( @@ -1011,7 +1052,7 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith const logsDir = options.logsDir; const historyLineLimit = options.historyLineLimit ?? DEFAULT_HISTORY_LINE_LIMIT; const platform = yield* HostProcessPlatform; - const baseEnv = yield* HostProcessEnv; + const baseEnv = yield* readTerminalHostEnv; const shellResolver = options.shellResolver ?? (() => defaultShellResolver(platform, baseEnv)); const processRunner = yield* ProcessRunner.ProcessRunner; const subprocessInspector = diff --git a/apps/server/src/textGeneration/ClaudeTextGeneration.ts b/apps/server/src/textGeneration/ClaudeTextGeneration.ts index a6c88e916a4..4a7a4190312 100644 --- a/apps/server/src/textGeneration/ClaudeTextGeneration.ts +++ b/apps/server/src/textGeneration/ClaudeTextGeneration.ts @@ -15,7 +15,7 @@ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { type ClaudeSettings, type ModelSelection } from "@t3tools/contracts"; import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git"; -import { HostProcessEnv, HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import { TextGenerationError } from "@t3tools/contracts"; import { type TextGenerationShape } from "./TextGeneration.ts"; @@ -63,9 +63,8 @@ export const makeClaudeTextGeneration = Effect.fn("makeClaudeTextGeneration")(fu environment?: NodeJS.ProcessEnv, ) { const commandSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const hostEnv = yield* HostProcessEnv; const hostPlatform = yield* HostProcessPlatform; - const claudeEnvironment = yield* makeClaudeEnvironment(claudeSettings, environment ?? hostEnv); + const claudeEnvironment = yield* makeClaudeEnvironment(claudeSettings, environment); const readStreamAsString = ( operation: string, diff --git a/apps/server/src/textGeneration/CodexTextGeneration.ts b/apps/server/src/textGeneration/CodexTextGeneration.ts index 3531f92f188..e28bdb0ca69 100644 --- a/apps/server/src/textGeneration/CodexTextGeneration.ts +++ b/apps/server/src/textGeneration/CodexTextGeneration.ts @@ -9,7 +9,7 @@ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { type CodexSettings, type ModelSelection } from "@t3tools/contracts"; import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git"; -import { HostProcessEnv, HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import { resolveAttachmentPath } from "../attachmentStore.ts"; import { ServerConfig } from "../config.ts"; @@ -51,9 +51,8 @@ export const makeCodexTextGeneration = Effect.fn("makeCodexTextGeneration")(func const path = yield* Path.Path; const commandSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; const serverConfig = yield* Effect.service(ServerConfig); - const hostEnv = yield* HostProcessEnv; const hostPlatform = yield* HostProcessPlatform; - const resolvedEnvironment = environment ?? hostEnv; + const resolvedEnvironment = environment ?? process.env; type MaterializedImageAttachments = { readonly imagePaths: ReadonlyArray; diff --git a/apps/server/src/textGeneration/CursorTextGeneration.ts b/apps/server/src/textGeneration/CursorTextGeneration.ts index df3d4d7cc7b..6d72178b8ae 100644 --- a/apps/server/src/textGeneration/CursorTextGeneration.ts +++ b/apps/server/src/textGeneration/CursorTextGeneration.ts @@ -6,7 +6,6 @@ import { ChildProcessSpawner } from "effect/unstable/process"; import { type CursorSettings, type ModelSelection } from "@t3tools/contracts"; import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git"; -import { HostProcessEnv } from "@t3tools/shared/hostProcess"; import { extractJsonObject } from "@t3tools/shared/schemaJson"; import { TextGenerationError } from "@t3tools/contracts"; @@ -63,8 +62,7 @@ export const makeCursorTextGeneration = Effect.fn("makeCursorTextGeneration")(fu environment?: NodeJS.ProcessEnv, ) { const commandSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const hostEnv = yield* HostProcessEnv; - const resolvedEnvironment = environment ?? hostEnv; + const resolvedEnvironment = environment ?? process.env; const runCursorJson = ({ operation, diff --git a/apps/server/src/textGeneration/OpenCodeTextGeneration.ts b/apps/server/src/textGeneration/OpenCodeTextGeneration.ts index 1c60fec9413..65d3854e945 100644 --- a/apps/server/src/textGeneration/OpenCodeTextGeneration.ts +++ b/apps/server/src/textGeneration/OpenCodeTextGeneration.ts @@ -12,7 +12,6 @@ import { type OpenCodeSettings, } from "@t3tools/contracts"; import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git"; -import { HostProcessEnv } from "@t3tools/shared/hostProcess"; import { getModelSelectionStringOptionValue } from "@t3tools/shared/model"; import { extractJsonObject } from "@t3tools/shared/schemaJson"; @@ -104,8 +103,7 @@ export const makeOpenCodeTextGeneration = Effect.fn("makeOpenCodeTextGeneration" ) { const serverConfig = yield* ServerConfig; const openCodeRuntime = yield* OpenCodeRuntime; - const hostEnv = yield* HostProcessEnv; - const resolvedEnvironment = environment ?? hostEnv; + const resolvedEnvironment = environment ?? process.env; const idleFiberScope = yield* Effect.acquireRelease(Scope.make(), (scope) => Scope.close(scope, Exit.void), ); diff --git a/oxlint-plugin-t3code/index.ts b/oxlint-plugin-t3code/index.ts index 7fd0565cf1a..b8db9e16a36 100644 --- a/oxlint-plugin-t3code/index.ts +++ b/oxlint-plugin-t3code/index.ts @@ -1,5 +1,6 @@ import { definePlugin } from "@oxlint/plugins"; +import noGlobalProcessRuntime from "./rules/no-global-process-runtime.ts"; import noInlineSchemaCompile from "./rules/no-inline-schema-compile.ts"; import noManualEffectRuntimeInTests from "./rules/no-manual-effect-runtime-in-tests.ts"; @@ -8,6 +9,7 @@ export default definePlugin({ name: "t3code", }, rules: { + "no-global-process-runtime": noGlobalProcessRuntime, "no-inline-schema-compile": noInlineSchemaCompile, "no-manual-effect-runtime-in-tests": noManualEffectRuntimeInTests, }, diff --git a/oxlint-plugin-t3code/rules/no-global-process-runtime.test.ts b/oxlint-plugin-t3code/rules/no-global-process-runtime.test.ts new file mode 100644 index 00000000000..5b091815fde --- /dev/null +++ b/oxlint-plugin-t3code/rules/no-global-process-runtime.test.ts @@ -0,0 +1,52 @@ +import { assert, describe } from "@effect/vitest"; + +import { createOxlintRuleHarness } from "../test/utils.ts"; + +const rule = createOxlintRuleHarness("t3code/no-global-process-runtime"); + +describe("t3code/no-global-process-runtime", () => { + rule.valid( + "allows injected host process references", + ` + import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; + import * as Effect from "effect/Effect"; + + export const isWindows = Effect.map(HostProcessPlatform, (platform) => platform === "win32"); + `, + ); + + rule.valid( + "allows unrelated process members", + ` + process.exitCode = 1; + const nodeEnv = process.env.NODE_ENV; + `, + ); + + rule.invalid( + "reports direct platform reads", + ` + export const isWindows = process.platform === "win32"; + `, + (output) => { + assert.match(output, /Use HostProcessPlatform/); + }, + ); + + rule.invalid( + "reports direct architecture reads", + ` + export const isArm = process.arch === "arm64"; + `, + (output) => { + assert.match(output, /Use HostProcessArchitecture/); + }, + ); + + rule.invalid( + "reports globalThis process platform reads", + ` + export const terminalName = globalThis.process.platform === "win32" ? "xterm-color" : "xterm-256color"; + `, + ); +}); diff --git a/oxlint-plugin-t3code/rules/no-global-process-runtime.ts b/oxlint-plugin-t3code/rules/no-global-process-runtime.ts new file mode 100644 index 00000000000..e147f0c4ceb --- /dev/null +++ b/oxlint-plugin-t3code/rules/no-global-process-runtime.ts @@ -0,0 +1,81 @@ +import { defineRule } from "@oxlint/plugins"; +import * as Option from "effect/Option"; + +import { getPropertyName, isIdentifier, unwrapExpression } from "../utils.ts"; + +const RUNTIME_PROPERTIES = new Set(["platform", "arch"]); +const HOST_PROCESS_REFERENCE_FILE = "packages/shared/src/hostProcess.ts"; +const SCOPED_RUNTIME_MODULE_PREFIXES = [ + "apps/server/src/process/externalLauncher.ts", + "apps/server/src/provider/", + "apps/server/src/textGeneration/", + "packages/ssh/src/", + "scripts/build-desktop-artifact.ts", + "scripts/dev-runner.ts", + "scripts/lib/build-target-arch.ts", +] as const; + +const normalizePath = (path: string) => path.replaceAll("\\", "/"); + +const toRepoPath = (filename: string, cwd: string) => { + const normalizedFilename = normalizePath(filename); + const normalizedCwd = normalizePath(cwd).replace(/\/+$/u, ""); + const prefix = `${normalizedCwd}/`; + return normalizedFilename.startsWith(prefix) + ? normalizedFilename.slice(prefix.length) + : normalizedFilename; +}; + +const isHostProcessReferenceFile = (filename: string, cwd: string) => + toRepoPath(filename, cwd) === HOST_PROCESS_REFERENCE_FILE; + +const shouldCheckFile = (filename: string, cwd: string) => { + if (normalizePath(filename).endsWith("/fixture.ts")) return true; + + const repoPath = toRepoPath(filename, cwd); + if (repoPath.endsWith(".test.ts") || repoPath.includes("/test/")) return false; + + return SCOPED_RUNTIME_MODULE_PREFIXES.some((prefix) => repoPath.startsWith(prefix)); +}; + +const isGlobalProcessObject = (node: unknown): boolean => { + const expression = unwrapExpression(node); + if (isIdentifier(expression, "process")) return true; + if (Option.isNone(expression) || expression.value.type !== "MemberExpression") return false; + + const object = unwrapExpression(expression.value.object); + const property = getPropertyName(expression.value.property); + return ( + isIdentifier(object, "globalThis") && Option.isSome(property) && property.value === "process" + ); +}; + +const message = (property: string) => + `Use HostProcess${property === "arch" ? "Architecture" : "Platform"} instead of process.${property}; inject the runtime reference in Effect code and provide it explicitly in tests.`; + +export default defineRule({ + meta: { + type: "problem", + docs: { + description: + "Disallow direct host runtime platform/architecture reads outside the shared host process references.", + }, + }, + createOnce(context) { + return { + MemberExpression(node) { + if (isHostProcessReferenceFile(context.filename, context.cwd)) return; + if (!shouldCheckFile(context.filename, context.cwd)) return; + + const property = getPropertyName(node.property); + if (Option.isNone(property) || !RUNTIME_PROPERTIES.has(property.value)) return; + if (!isGlobalProcessObject(node.object)) return; + + context.report({ + node, + message: message(property.value), + }); + }, + }; + }, +}); diff --git a/packages/shared/src/hostProcess.ts b/packages/shared/src/hostProcess.ts index 7d7cd09c29c..bece9d79410 100644 --- a/packages/shared/src/hostProcess.ts +++ b/packages/shared/src/hostProcess.ts @@ -15,11 +15,4 @@ export const HostProcessArchitecture = Context.Reference( }, ); -export const HostProcessEnv = Context.Reference( - "@t3tools/shared/hostProcess/HostProcessEnv", - { - defaultValue: () => process.env, - }, -); - export const isHostWindows = Effect.map(HostProcessPlatform, (platform) => platform === "win32"); diff --git a/packages/ssh/src/auth.ts b/packages/ssh/src/auth.ts index 213d960d222..ef78b2f24fe 100644 --- a/packages/ssh/src/auth.ts +++ b/packages/ssh/src/auth.ts @@ -1,8 +1,10 @@ -import { HostProcessEnv, HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import * as Config from "effect/Config"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; 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 * as PlatformError from "effect/PlatformError"; @@ -177,13 +179,19 @@ export const buildSshChildEnvironment = Effect.fn("ssh/auth.buildSshChildEnviron PlatformError.PlatformError, FileSystem.FileSystem | Path.Path > { - const hostEnv = yield* HostProcessEnv; - const baseEnv = { ...(input.baseEnv ?? hostEnv) }; + const baseEnv = { ...input.baseEnv }; if (!input.interactiveAuth) { return baseEnv; } const platform = yield* HostProcessPlatform; + const hostDisplay = input.baseEnv + ? input.baseEnv.DISPLAY + : yield* Config.string("DISPLAY").pipe( + Config.option, + Effect.orElseSucceed(() => Option.none()), + Effect.map(Option.getOrUndefined), + ); const directory = input.askpassDirectory ?? (yield* getDefaultSshAskpassDirectory()); const sshAskpass = yield* ensureSshAskpassHelpers({ directory }); @@ -192,7 +200,7 @@ export const buildSshChildEnvironment = Effect.fn("ssh/auth.buildSshChildEnviron SSH_ASKPASS: sshAskpass, SSH_ASKPASS_REQUIRE: "force", ...(input.authSecret === undefined ? {} : { T3_SSH_AUTH_SECRET: input.authSecret ?? "" }), - ...(platform === "win32" || baseEnv.DISPLAY ? {} : { DISPLAY: "t3code" }), + ...(platform === "win32" || baseEnv.DISPLAY || hostDisplay ? {} : { DISPLAY: "t3code" }), }; }); diff --git a/packages/ssh/src/command.ts b/packages/ssh/src/command.ts index eb9c8cfbd31..343326ccd39 100644 --- a/packages/ssh/src/command.ts +++ b/packages/ssh/src/command.ts @@ -203,6 +203,7 @@ const runSshCommandInScope = Effect.fn("ssh/command.runSshCommand.inScope")(func .spawn( ChildProcess.make("ssh", args, { env: environment, + extendEnv: true, shell: hostPlatform === "win32", stdin: { stream: stdinStream(input.stdin), diff --git a/packages/ssh/src/tunnel.ts b/packages/ssh/src/tunnel.ts index 5edd5680816..6320c9eaaa3 100644 --- a/packages/ssh/src/tunnel.ts +++ b/packages/ssh/src/tunnel.ts @@ -1086,6 +1086,7 @@ const startSshTunnel = Effect.fn("ssh/tunnel.startSshTunnel")(function* (input: .spawn( ChildProcess.make(SSH_COMMAND, args, { env: childEnvironment, + extendEnv: true, shell: hostPlatform === "win32", stdin: { stream: Stream.empty, diff --git a/scripts/build-desktop-artifact.ts b/scripts/build-desktop-artifact.ts index 9e7b8fbe5c8..7b389b9074f 100644 --- a/scripts/build-desktop-artifact.ts +++ b/scripts/build-desktop-artifact.ts @@ -6,11 +6,7 @@ import desktopPackageJson from "../apps/desktop/package.json" with { type: "json import serverPackageJson from "../apps/server/package.json" with { type: "json" }; import { BRAND_ASSET_PATHS } from "./lib/brand-assets.ts"; -import { - getDefaultBuildArch, - HostProcessEnv, - HostProcessPlatform, -} from "./lib/build-target-arch.ts"; +import { getDefaultBuildArch, HostProcessPlatform } from "./lib/build-target-arch.ts"; import { resolveCatalogDependencies } from "./lib/resolve-catalog.ts"; import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; @@ -332,6 +328,12 @@ const BuildEnvConfig = Config.all({ mockUpdateServerPort: Config.string("T3CODE_DESKTOP_MOCK_UPDATE_SERVER_PORT").pipe(Config.option), }); +const ElectronBuilderEnvConfig = Config.all({ + debug: Config.string("DEBUG").pipe(Config.option), + npmConfigMsvsVersion: Config.string("npm_config_msvs_version").pipe(Config.option), + gypMsvsVersion: Config.string("GYP_MSVS_VERSION").pipe(Config.option), +}); + const MockUpdateServerPortSchema = Schema.NumberFromString.check( Schema.isInt(), Schema.isBetween({ minimum: 1, maximum: 65535 }), @@ -794,7 +796,6 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( const path = yield* Path.Path; const fs = yield* FileSystem.FileSystem; const hostPlatform = yield* HostProcessPlatform; - const hostEnv = yield* HostProcessEnv; const useWindowsShell = hostPlatform === "win32"; const workspaceConfig = yield* readWorkspaceConfig(); const workspaceCatalog = workspaceConfig.catalog ?? {}; @@ -958,21 +959,15 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( { label: "vp install --prod --no-optional", verbose: options.verbose }, ); - const buildEnv: NodeJS.ProcessEnv = { - ...hostEnv, - }; - for (const [key, value] of Object.entries(buildEnv)) { - if (value === "") { - delete buildEnv[key]; - } - } + const currentBuildEnv = yield* ElectronBuilderEnvConfig; + const buildEnv: NodeJS.ProcessEnv = {}; if (!options.signed) { buildEnv.CSC_IDENTITY_AUTO_DISCOVERY = "false"; - delete buildEnv.CSC_LINK; - delete buildEnv.CSC_KEY_PASSWORD; - delete buildEnv.APPLE_API_KEY; - delete buildEnv.APPLE_API_KEY_ID; - delete buildEnv.APPLE_API_ISSUER; + buildEnv.CSC_LINK = undefined; + buildEnv.CSC_KEY_PASSWORD = undefined; + buildEnv.APPLE_API_KEY = undefined; + buildEnv.APPLE_API_KEY_ID = undefined; + buildEnv.APPLE_API_ISSUER = undefined; } if (hostPlatform === "win32") { @@ -981,14 +976,16 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( buildEnv.PYTHON = python; buildEnv.npm_config_python = python; } - buildEnv.npm_config_msvs_version = buildEnv.npm_config_msvs_version ?? "2022"; - buildEnv.GYP_MSVS_VERSION = buildEnv.GYP_MSVS_VERSION ?? "2022"; + buildEnv.npm_config_msvs_version = + Option.getOrUndefined(currentBuildEnv.npmConfigMsvsVersion) ?? "2022"; + buildEnv.GYP_MSVS_VERSION = Option.getOrUndefined(currentBuildEnv.gypMsvsVersion) ?? "2022"; } if (options.verbose) { + const debug = Option.getOrUndefined(currentBuildEnv.debug); buildEnv.DEBUG = - buildEnv.DEBUG === undefined || buildEnv.DEBUG === "" + debug === undefined || debug === "" ? "electron-builder,electron-builder:*" - : `${buildEnv.DEBUG},electron-builder,electron-builder:*`; + : `${debug},electron-builder,electron-builder:*`; } yield* Effect.log( @@ -998,6 +995,7 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( ChildProcess.make({ cwd: repoRoot, env: buildEnv, + extendEnv: true, // Windows needs shell mode to resolve .cmd shims. shell: useWindowsShell, })`vp exec --filter @t3tools/desktop -- electron-builder --projectDir ${stageAppDir} ${platformConfig.cliFlag} --${options.arch} --publish never`, diff --git a/scripts/dev-runner.ts b/scripts/dev-runner.ts index e5848548949..c5a11c49260 100644 --- a/scripts/dev-runner.ts +++ b/scripts/dev-runner.ts @@ -5,7 +5,7 @@ import * as NodeOS from "node:os"; import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; import * as NodeServices from "@effect/platform-node/NodeServices"; import * as NetService from "@t3tools/shared/Net"; -import { HostProcessEnv, HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import * as Config from "effect/Config"; import * as Data from "effect/Data"; import * as Effect from "effect/Effect"; @@ -419,11 +419,10 @@ export function runDevRunnerWithInput(input: DevRunnerCliInput) { hasExplicitDevUrl: input.devUrl !== undefined, }); - const hostEnv = yield* HostProcessEnv; const hostPlatform = yield* HostProcessPlatform; const env = yield* createDevRunnerEnv({ mode: input.mode, - baseEnv: hostEnv, + baseEnv: {}, serverOffset, webOffset, t3Home: input.t3Home, @@ -453,7 +452,7 @@ export function runDevRunnerWithInput(input: DevRunnerCliInput) { stdout: "inherit", stderr: "inherit", env, - extendEnv: false, + extendEnv: true, // Windows needs shell mode to resolve .cmd shims (e.g. vp.cmd). shell: hostPlatform === "win32", // Keep Vite+ in the same process group so terminal signals (Ctrl+C) diff --git a/scripts/lib/build-target-arch.ts b/scripts/lib/build-target-arch.ts index 33884f04a30..b6ba94f52b4 100644 --- a/scripts/lib/build-target-arch.ts +++ b/scripts/lib/build-target-arch.ts @@ -1,8 +1,4 @@ -import { - HostProcessArchitecture, - HostProcessEnv, - HostProcessPlatform, -} from "@t3tools/shared/hostProcess"; +import { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/hostProcess"; import * as Config from "effect/Config"; import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; @@ -14,7 +10,7 @@ interface PlatformConfig { readonly archChoices: ReadonlyArray; } -export { HostProcessArchitecture, HostProcessEnv, HostProcessPlatform }; +export { HostProcessArchitecture, HostProcessPlatform }; const WindowsProcessorArchitectureConfig = Config.all({ processorArchitecture: Config.string("PROCESSOR_ARCHITECTURE").pipe(Config.option), diff --git a/vite.config.ts b/vite.config.ts index d235efd60be..f07bf044a85 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -96,6 +96,7 @@ export default defineConfig({ "typescript/require-array-sort-compare": "off", "typescript/restrict-template-expressions": "off", "typescript/unbound-method": "off", + "t3code/no-global-process-runtime": "error", "t3code/no-inline-schema-compile": "warn", "t3code/no-manual-effect-runtime-in-tests": "error", }, From d795e0fc55c7b869e9710f48e1a1d1df55ea0ad2 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 4 Jun 2026 14:38:42 -0700 Subject: [PATCH 03/15] refactor(runtime): enforce injected host platform reads Co-authored-by: codex --- apps/desktop/scripts/dev-electron.mjs | 9 +- apps/desktop/scripts/electron-launcher.mjs | 7 +- .../scripts/ensure-electron-runtime.mjs | 24 +- apps/desktop/src/app/DesktopAssets.ts | 2 +- apps/desktop/src/electron/ElectronMenu.ts | 193 +++--- apps/desktop/src/electron/ElectronWindow.ts | 4 +- apps/desktop/src/main.ts | 11 +- apps/desktop/src/window/DesktopWindow.ts | 23 +- apps/server/scripts/cli.ts | 7 +- .../cursor-acp-model-mismatch-probe.ts | 4 +- apps/server/src/bootstrap.ts | 58 +- apps/server/src/os-jank.ts | 22 +- .../src/process/externalLauncher.test.ts | 605 +++++++++--------- apps/server/src/process/externalLauncher.ts | 138 ++-- apps/server/src/processRunner.test.ts | 25 +- apps/server/src/processRunner.ts | 14 +- .../Layers/RepositoryIdentityResolver.test.ts | 3 + apps/server/src/provider/opencodeRuntime.ts | 2 +- .../src/provider/providerMaintenance.test.ts | 65 +- .../src/provider/providerMaintenance.ts | 11 +- apps/server/src/provider/providerSnapshot.ts | 2 +- apps/server/src/server.ts | 2 +- apps/server/src/terminal/Layers/BunPTY.ts | 4 +- .../src/terminal/Layers/Manager.test.ts | 17 +- .../src/terminal/Layers/NodePTY.test.ts | 13 +- apps/server/src/terminal/Layers/NodePTY.ts | 11 +- .../workspace/Layers/WorkspaceEntries.test.ts | 14 +- .../src/workspace/Layers/WorkspaceEntries.ts | 4 +- .../rules/no-global-process-runtime.test.ts | 42 ++ .../rules/no-global-process-runtime.ts | 101 ++- packages/effect-acp/package.json | 1 + packages/effect-acp/src/client.test.ts | 3 + packages/effect-acp/src/protocol.test.ts | 3 + .../examples/cursor-acp-client.example.ts | 4 +- packages/effect-codex-app-server/package.json | 1 + .../src/client.test.ts | 3 + .../effect-codex-app-server/src/client.ts | 4 +- .../fixtures/codex-app-server-mock-peer.ts | 12 +- packages/shared/src/relayClient.ts | 5 +- packages/shared/src/shell.test.ts | 287 +++++---- packages/shared/src/shell.ts | 162 ++--- pnpm-lock.yaml | 6 + vite.config.ts | 20 +- 43 files changed, 1118 insertions(+), 830 deletions(-) diff --git a/apps/desktop/scripts/dev-electron.mjs b/apps/desktop/scripts/dev-electron.mjs index 6c8b94188a2..58ccfe90eb9 100644 --- a/apps/desktop/scripts/dev-electron.mjs +++ b/apps/desktop/scripts/dev-electron.mjs @@ -1,5 +1,6 @@ import { spawn, spawnSync } from "node:child_process"; import { watch } from "node:fs"; +import * as NodeOS from "node:os"; import { join } from "node:path"; import { @@ -33,6 +34,8 @@ const forcedShutdownTimeoutMs = 1_500; const restartDebounceMs = 120; const childTreeGracePeriodMs = 1_200; const remoteDebuggingPort = process.env.T3CODE_DESKTOP_REMOTE_DEBUGGING_PORT?.trim(); +// oxlint-disable-next-line t3code/no-global-process-runtime -- Standalone dev script has no Effect runtime. +const hostPlatform = NodeOS.platform(); await waitForResources({ baseDir: desktopDir, @@ -57,7 +60,7 @@ const expectedExits = new WeakSet(); const watchers = []; function killChildTreeByPid(pid, signal) { - if (process.platform === "win32" || typeof pid !== "number") { + if (hostPlatform === "win32" || typeof pid !== "number") { return; } @@ -65,7 +68,7 @@ function killChildTreeByPid(pid, signal) { } function cleanupStaleDevApps() { - if (process.platform === "win32") { + if (hostPlatform === "win32") { return; } @@ -194,7 +197,7 @@ function startWatchers() { } function killChildTree(signal) { - if (process.platform === "win32") { + if (hostPlatform === "win32") { return; } diff --git a/apps/desktop/scripts/electron-launcher.mjs b/apps/desktop/scripts/electron-launcher.mjs index bc1fabae960..1114a442c6e 100644 --- a/apps/desktop/scripts/electron-launcher.mjs +++ b/apps/desktop/scripts/electron-launcher.mjs @@ -14,6 +14,7 @@ import { writeFileSync, } from "node:fs"; import { createRequire } from "node:module"; +import * as NodeOS from "node:os"; import { basename, dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { ensureElectronRuntime } from "./ensure-electron-runtime.mjs"; @@ -33,6 +34,8 @@ const APP_PROTOCOL_SCHEMES = isDevelopment ? ["t3code-dev"] : ["t3code"]; const LAUNCHER_VERSION = 11; const defaultIconPath = join(desktopDir, "resources", "icon.icns"); const developmentMacIconPngPath = join(repoRoot, "assets", "dev", "blueprint-macos-1024.png"); +// oxlint-disable-next-line t3code/no-global-process-runtime -- Standalone launcher script has no Effect runtime. +const hostPlatform = NodeOS.platform(); function resolveDevelopmentProtocolCallbackPort() { const configuredPort = Number.parseInt(process.env.T3CODE_PORT ?? "", 10); @@ -342,7 +345,7 @@ export function resolveElectronPath() { const require = createRequire(import.meta.url); const electronBinaryPath = require("electron"); - if (process.platform !== "darwin") { + if (hostPlatform !== "darwin") { return electronBinaryPath; } @@ -358,7 +361,7 @@ export function resolveElectronLaunchCommand(args = []) { } export function resolveDevProtocolClient() { - if (process.platform !== "darwin" || !isDevelopment) { + if (hostPlatform !== "darwin" || !isDevelopment) { return null; } diff --git a/apps/desktop/scripts/ensure-electron-runtime.mjs b/apps/desktop/scripts/ensure-electron-runtime.mjs index 2df47d3c62b..0a13506d341 100644 --- a/apps/desktop/scripts/ensure-electron-runtime.mjs +++ b/apps/desktop/scripts/ensure-electron-runtime.mjs @@ -1,13 +1,17 @@ import { chmodSync, existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { createRequire } from "node:module"; -import { tmpdir } from "node:os"; +import { arch, platform, tmpdir } from "node:os"; import { dirname, join } from "node:path"; import { spawnSync } from "node:child_process"; const require = createRequire(import.meta.url); +// oxlint-disable-next-line t3code/no-global-process-runtime -- Standalone repair script has no Effect runtime. +const hostPlatform = platform(); +// oxlint-disable-next-line t3code/no-global-process-runtime -- Standalone repair script has no Effect runtime. +const hostArch = arch(); function getPlatformPath() { - switch (process.platform) { + switch (hostPlatform) { case "darwin": return "Electron.app/Contents/MacOS/Electron"; case "freebsd": @@ -17,12 +21,12 @@ function getPlatformPath() { case "win32": return "electron.exe"; default: - throw new Error(`Electron builds are not available on platform: ${process.platform}`); + throw new Error(`Electron builds are not available on platform: ${hostPlatform}`); } } function ensureExecutable(filePath) { - if (process.platform !== "win32") { + if (hostPlatform !== "win32") { chmodSync(filePath, 0o755); } } @@ -39,7 +43,7 @@ function repairPathFile(electronDir, platformPath) { function getRequiredRuntimePaths(electronDir, platformPath) { const paths = [join(electronDir, "dist", platformPath)]; - if (process.platform === "darwin") { + if (hostPlatform === "darwin") { paths.push( join(electronDir, "dist", "Electron.app", "Contents", "Info.plist"), join( @@ -58,7 +62,7 @@ function getRequiredRuntimePaths(electronDir, platformPath) { } function isMachO(filePath) { - if (process.platform !== "darwin") { + if (hostPlatform !== "darwin") { return true; } @@ -76,7 +80,7 @@ function missingRuntimePaths(electronDir, platformPath) { } function invalidRuntimePaths(electronDir, platformPath) { - if (process.platform !== "darwin") { + if (hostPlatform !== "darwin") { return []; } @@ -111,16 +115,16 @@ function runChecked(command, args) { function installElectronRuntime(electronDir, version) { const tempDir = mkdtempSync(join(tmpdir(), "t3-electron-")); - const zipPath = join(tempDir, `electron-v${version}-${process.platform}-${process.arch}.zip`); + const zipPath = join(tempDir, `electron-v${version}-${hostPlatform}-${hostArch}.zip`); try { runChecked("curl", [ "-fsSL", - `https://github.com/electron/electron/releases/download/v${version}/electron-v${version}-${process.platform}-${process.arch}.zip`, + `https://github.com/electron/electron/releases/download/v${version}/electron-v${version}-${hostPlatform}-${hostArch}.zip`, "-o", zipPath, ]); - if (process.platform === "darwin") { + if (hostPlatform === "darwin") { runChecked("ditto", ["-x", "-k", zipPath, join(electronDir, "dist")]); } else { runChecked("python3", [ diff --git a/apps/desktop/src/app/DesktopAssets.ts b/apps/desktop/src/app/DesktopAssets.ts index a9c1d62e685..3b5a15e435f 100644 --- a/apps/desktop/src/app/DesktopAssets.ts +++ b/apps/desktop/src/app/DesktopAssets.ts @@ -49,7 +49,7 @@ const resolveIconPath = Effect.fn("desktop.assets.resolveIconPath")(function* ( > { const fileSystem = yield* FileSystem.FileSystem; const environment = yield* DesktopEnvironment.DesktopEnvironment; - if (environment.isDevelopment && process.platform === "darwin" && ext === "png") { + if (environment.isDevelopment && environment.platform === "darwin" && ext === "png") { const developmentDockIconPath = environment.developmentDockIconPath; const developmentDockIconExists = yield* fileSystem .exists(developmentDockIconPath) diff --git a/apps/desktop/src/electron/ElectronMenu.ts b/apps/desktop/src/electron/ElectronMenu.ts index 005c86e0868..cb25043ff44 100644 --- a/apps/desktop/src/electron/ElectronMenu.ts +++ b/apps/desktop/src/electron/ElectronMenu.ts @@ -5,6 +5,7 @@ import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Electron from "electron"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; export interface ElectronMenuPosition { readonly x: number; @@ -79,109 +80,113 @@ const normalizePosition = ( ({ x, y }) => Number.isFinite(x) && Number.isFinite(y) && x >= 0 && y >= 0, ).pipe(Option.map(({ x, y }) => ({ x: Math.floor(x), y: Math.floor(y) }))); -export const layer = Layer.sync(ElectronMenu, () => { - let destructiveMenuIconCache: Option.Option | undefined; +export const layer = Layer.effect( + ElectronMenu, + Effect.gen(function* () { + const platform = yield* HostProcessPlatform; + let destructiveMenuIconCache: Option.Option | undefined; - const getDestructiveMenuIcon = (): Option.Option => { - if (process.platform !== "darwin") { - return Option.none(); - } - if (destructiveMenuIconCache !== undefined) { - return destructiveMenuIconCache; - } - - try { - const icon = Electron.nativeImage.createFromNamedImage("trash").resize({ - width: 12, - height: 12, - }); - destructiveMenuIconCache = icon.isEmpty() ? Option.none() : Option.some(icon); - } catch { - destructiveMenuIconCache = Option.none(); - } - - return destructiveMenuIconCache; - }; - - const buildTemplate = ( - entries: readonly ContextMenuItem[], - complete: (selectedItemId: Option.Option) => void, - ): Electron.MenuItemConstructorOptions[] => { - const template: Electron.MenuItemConstructorOptions[] = []; - let hasInsertedDestructiveSeparator = false; - - for (const item of entries) { - if (item.destructive && !hasInsertedDestructiveSeparator && template.length > 0) { - template.push({ type: "separator" }); - hasInsertedDestructiveSeparator = true; + const getDestructiveMenuIcon = (): Option.Option => { + if (platform !== "darwin") { + return Option.none(); } - - const itemOption: Electron.MenuItemConstructorOptions = { - label: item.label, - enabled: !item.disabled, - }; - if (item.children && item.children.length > 0) { - itemOption.submenu = buildTemplate(item.children, complete); - } else { - itemOption.click = () => complete(Option.some(item.id)); + if (destructiveMenuIconCache !== undefined) { + return destructiveMenuIconCache; } - if (item.destructive && (!item.children || item.children.length === 0)) { - const destructiveIcon = getDestructiveMenuIcon(); - if (Option.isSome(destructiveIcon)) { - itemOption.icon = destructiveIcon.value; - } + + try { + const icon = Electron.nativeImage.createFromNamedImage("trash").resize({ + width: 12, + height: 12, + }); + destructiveMenuIconCache = icon.isEmpty() ? Option.none() : Option.some(icon); + } catch { + destructiveMenuIconCache = Option.none(); } - template.push(itemOption); - } + return destructiveMenuIconCache; + }; - return template; - }; - - return ElectronMenu.of({ - setApplicationMenu: (template) => - Effect.sync(() => { - Electron.Menu.setApplicationMenu(Electron.Menu.buildFromTemplate([...template])); - }), - popupTemplate: (input) => - Effect.sync(() => { - if (input.template.length === 0) { - return; + const buildTemplate = ( + entries: readonly ContextMenuItem[], + complete: (selectedItemId: Option.Option) => void, + ): Electron.MenuItemConstructorOptions[] => { + const template: Electron.MenuItemConstructorOptions[] = []; + let hasInsertedDestructiveSeparator = false; + + for (const item of entries) { + if (item.destructive && !hasInsertedDestructiveSeparator && template.length > 0) { + template.push({ type: "separator" }); + hasInsertedDestructiveSeparator = true; } - Electron.Menu.buildFromTemplate([...input.template]).popup({ window: input.window }); - }), - showContextMenu: (input) => - Effect.callback>((resume) => { - const normalizedItems = normalizeContextMenuItems(input.items); - if (normalizedItems.length === 0) { - resume(Effect.succeed(Option.none())); - return; + + const itemOption: Electron.MenuItemConstructorOptions = { + label: item.label, + enabled: !item.disabled, + }; + if (item.children && item.children.length > 0) { + itemOption.submenu = buildTemplate(item.children, complete); + } else { + itemOption.click = () => complete(Option.some(item.id)); } + if (item.destructive && (!item.children || item.children.length === 0)) { + const destructiveIcon = getDestructiveMenuIcon(); + if (Option.isSome(destructiveIcon)) { + itemOption.icon = destructiveIcon.value; + } + } + + template.push(itemOption); + } - let completed = false; - const complete = (selectedItemId: Option.Option) => { - if (completed) { + return template; + }; + + return ElectronMenu.of({ + setApplicationMenu: (template) => + Effect.sync(() => { + Electron.Menu.setApplicationMenu(Electron.Menu.buildFromTemplate([...template])); + }), + popupTemplate: (input) => + Effect.sync(() => { + if (input.template.length === 0) { + return; + } + Electron.Menu.buildFromTemplate([...input.template]).popup({ window: input.window }); + }), + showContextMenu: (input) => + Effect.callback>((resume) => { + const normalizedItems = normalizeContextMenuItems(input.items); + if (normalizedItems.length === 0) { + resume(Effect.succeed(Option.none())); return; } - completed = true; - resume(Effect.succeed(selectedItemId)); - }; - const menu = Electron.Menu.buildFromTemplate(buildTemplate(normalizedItems, complete)); - const popupPosition = normalizePosition(input.position); - const popupOptions = Option.match(popupPosition, { - onNone: (): Electron.PopupOptions => ({ - window: input.window, - callback: () => complete(Option.none()), - }), - onSome: (position): Electron.PopupOptions => ({ - window: input.window, - x: position.x, - y: position.y, - callback: () => complete(Option.none()), - }), - }); - menu.popup(popupOptions); - }), - }); -}); + let completed = false; + const complete = (selectedItemId: Option.Option) => { + if (completed) { + return; + } + completed = true; + resume(Effect.succeed(selectedItemId)); + }; + + const menu = Electron.Menu.buildFromTemplate(buildTemplate(normalizedItems, complete)); + const popupPosition = normalizePosition(input.position); + const popupOptions = Option.match(popupPosition, { + onNone: (): Electron.PopupOptions => ({ + window: input.window, + callback: () => complete(Option.none()), + }), + onSome: (position): Electron.PopupOptions => ({ + window: input.window, + x: position.x, + y: position.y, + callback: () => complete(Option.none()), + }), + }); + menu.popup(popupOptions); + }), + }); + }), +); diff --git a/apps/desktop/src/electron/ElectronWindow.ts b/apps/desktop/src/electron/ElectronWindow.ts index d41a8326e63..35c1fbc5faa 100644 --- a/apps/desktop/src/electron/ElectronWindow.ts +++ b/apps/desktop/src/electron/ElectronWindow.ts @@ -6,6 +6,7 @@ import * as Option from "effect/Option"; import * as Ref from "effect/Ref"; import * as Electron from "electron"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; export class ElectronWindowCreateError extends Data.TaggedError("ElectronWindowCreateError")<{ readonly cause: unknown; @@ -37,6 +38,7 @@ export class ElectronWindow extends Context.Service>(Option.none()); const liveMain = Ref.get(mainWindowRef).pipe( @@ -98,7 +100,7 @@ const make = Effect.gen(function* () { window.show(); } - if (process.platform === "darwin") { + if (platform === "darwin") { Electron.app.focus({ steal: true }); } diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index c7a16a5c7f5..a5cdba9bf0c 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -1,7 +1,7 @@ import * as NodeHttpClient from "@effect/platform-node/NodeHttpClient"; import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; import * as NodeServices from "@effect/platform-node/NodeServices"; -import * as NodeOS from "node:os"; +import { homedir } from "node:os"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; @@ -9,6 +9,7 @@ import * as Option from "effect/Option"; import * as Electron from "electron"; import * as NetService from "@t3tools/shared/Net"; +import { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/hostProcess"; import { resolveRemoteT3CliPackageSpec } from "@t3tools/ssh/command"; import type { RemoteT3RunnerOptions } from "@t3tools/ssh/tunnel"; import serverPackageJson from "../../server/package.json" with { type: "json" }; @@ -53,11 +54,13 @@ const desktopEnvironmentLayer = Layer.unwrap( const metadata = yield* Effect.service(ElectronApp.ElectronApp).pipe( Effect.flatMap((app) => app.metadata), ); + const platform = yield* HostProcessPlatform; + const processArch = yield* HostProcessArchitecture; return DesktopEnvironment.layer({ dirname: __dirname, - homeDirectory: NodeOS.homedir(), - platform: process.platform, - processArch: process.arch, + homeDirectory: homedir(), + platform, + processArch, ...metadata, }); }), diff --git a/apps/desktop/src/window/DesktopWindow.ts b/apps/desktop/src/window/DesktopWindow.ts index f24485fd879..6f045eb80b6 100644 --- a/apps/desktop/src/window/DesktopWindow.ts +++ b/apps/desktop/src/window/DesktopWindow.ts @@ -82,9 +82,10 @@ function resolveDesktopDevServerUrl( function getIconOption( iconPaths: DesktopAssets.DesktopIconPaths, + platform: NodeJS.Platform, ): { icon: string } | Record { - if (process.platform === "darwin") return {}; // macOS uses .icns from app bundle - const ext = process.platform === "win32" ? "ico" : "png"; + if (platform === "darwin") return {}; // macOS uses .icns from app bundle + const ext = platform === "win32" ? "ico" : "png"; return Option.match(iconPaths[ext], { onNone: () => ({}), onSome: (icon) => ({ icon }), @@ -106,8 +107,11 @@ export function isSameOriginRendererNavigation(input: { } } -function getWindowTitleBarOptions(shouldUseDarkColors: boolean): WindowTitleBarOptions { - if (process.platform === "darwin") { +function getWindowTitleBarOptions( + shouldUseDarkColors: boolean, + platform: NodeJS.Platform, +): WindowTitleBarOptions { + if (platform === "darwin") { return { titleBarStyle: "hiddenInset", trafficLightPosition: { x: 16, y: 18 }, @@ -127,6 +131,7 @@ function getWindowTitleBarOptions(shouldUseDarkColors: boolean): WindowTitleBarO function syncWindowAppearance( window: Electron.BrowserWindow, shouldUseDarkColors: boolean, + platform: NodeJS.Platform, ): Effect.Effect { return Effect.sync(() => { if (window.isDestroyed()) { @@ -134,7 +139,7 @@ function syncWindowAppearance( } window.setBackgroundColor(getInitialWindowBackgroundColor(shouldUseDarkColors)); - const { titleBarOverlay } = getWindowTitleBarOptions(shouldUseDarkColors); + const { titleBarOverlay } = getWindowTitleBarOptions(shouldUseDarkColors, platform); if (typeof titleBarOverlay === "object") { window.setTitleBarOverlay(titleBarOverlay); } @@ -179,7 +184,7 @@ const make = Effect.gen(function* () { ? yield* resolveDesktopDevServerUrl(environment) : backendHttpUrl.href; const iconPaths = yield* assets.iconPaths; - const iconOption = getIconOption(iconPaths); + const iconOption = getIconOption(iconPaths, environment.platform); const shouldUseDarkColors = yield* electronTheme.shouldUseDarkColors; const window = yield* electronWindow.create({ width: 1100, @@ -191,7 +196,7 @@ const make = Effect.gen(function* () { backgroundColor: getInitialWindowBackgroundColor(shouldUseDarkColors), ...iconOption, title: environment.displayName, - ...getWindowTitleBarOptions(shouldUseDarkColors), + ...getWindowTitleBarOptions(shouldUseDarkColors, environment.platform), webPreferences: { preload: environment.preloadPath, contextIsolation: true, @@ -318,7 +323,7 @@ const make = Effect.gen(function* () { }); const revealSubscribers: RevealSubscription[] = [(fire) => window.once("ready-to-show", fire)]; - if (process.platform === "linux") { + if (environment.platform === "linux") { revealSubscribers.push((fire) => window.webContents.once("did-finish-load", fire)); } bindFirstRevealTrigger(revealSubscribers, () => { @@ -408,7 +413,7 @@ const make = Effect.gen(function* () { syncAppearance: Effect.gen(function* () { const shouldUseDarkColors = yield* electronTheme.shouldUseDarkColors; yield* electronWindow.syncAllAppearance((window) => - syncWindowAppearance(window, shouldUseDarkColors), + syncWindowAppearance(window, shouldUseDarkColors, environment.platform), ); }).pipe(Effect.withSpan("desktop.window.syncAppearance")), }); diff --git a/apps/server/scripts/cli.ts b/apps/server/scripts/cli.ts index aced9266733..0474e6e2acb 100644 --- a/apps/server/scripts/cli.ts +++ b/apps/server/scripts/cli.ts @@ -18,6 +18,7 @@ import { import { resolveCatalogDependencies } from "../../../scripts/lib/resolve-catalog.ts"; import { fromJsonStringPretty } from "@t3tools/shared/schemaJson"; import { fromYaml } from "@t3tools/shared/schemaYaml"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import serverPackageJson from "../package.json" with { type: "json" }; interface PackageJson { @@ -167,6 +168,7 @@ const buildCmd = Command.make( const path = yield* Path.Path; const fs = yield* FileSystem.FileSystem; const repoRoot = yield* RepoRoot; + const platform = yield* HostProcessPlatform; const serverDir = path.join(repoRoot, "apps/server"); yield* Effect.log("[cli] Running tsdown..."); @@ -175,6 +177,8 @@ const buildCmd = Command.make( cwd: serverDir, stdout: config.verbose ? "inherit" : "ignore", stderr: "inherit", + // Windows needs shell mode to resolve `.cmd` shims on PATH. + shell: platform === "win32", }), ); @@ -290,6 +294,7 @@ const publishCmd = Command.make( () => Effect.gen(function* () { const args = createVpPmPublishArgs(config); + const platform = yield* HostProcessPlatform; yield* Effect.log(`[cli] Running: vp pm ${args.join(" ")}`); yield* runCommand( @@ -298,7 +303,7 @@ const publishCmd = Command.make( stdout: config.verbose ? "inherit" : "ignore", stderr: "inherit", // Windows needs shell mode to resolve .cmd shims. - shell: process.platform === "win32", + shell: platform === "win32", }), ); }), diff --git a/apps/server/scripts/cursor-acp-model-mismatch-probe.ts b/apps/server/scripts/cursor-acp-model-mismatch-probe.ts index 04c2321870e..c840aeaaf8c 100644 --- a/apps/server/scripts/cursor-acp-model-mismatch-probe.ts +++ b/apps/server/scripts/cursor-acp-model-mismatch-probe.ts @@ -1,5 +1,6 @@ // @effect-diagnostics nodeBuiltinImport:off import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; +import * as NodeOS from "node:os"; import process from "node:process"; import readline from "node:readline"; import * as NodeTimers from "node:timers"; @@ -130,7 +131,8 @@ class JsonRpcChild { constructor(bin: string, args: string[], cwd: string) { this.child = spawn(bin, args, { cwd, - shell: process.platform === "win32", + // oxlint-disable-next-line t3code/no-global-process-runtime -- Standalone Node probe script has no Effect runtime. + shell: NodeOS.platform() === "win32", stdio: ["pipe", "pipe", "pipe"], env: process.env, }); diff --git a/apps/server/src/bootstrap.ts b/apps/server/src/bootstrap.ts index ec1e551a442..48022c289e3 100644 --- a/apps/server/src/bootstrap.ts +++ b/apps/server/src/bootstrap.ts @@ -11,6 +11,7 @@ import * as Predicate from "effect/Predicate"; import * as Result from "effect/Result"; import * as Schema from "effect/Schema"; import { decodeJsonResult } from "@t3tools/shared/schemaJson"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; class BootstrapError extends Data.TaggedError("BootstrapError")<{ readonly message: string; @@ -110,36 +111,39 @@ const isFdReady = (fd: number) => ); const makeBootstrapInputStream = (fd: number) => - Effect.try({ - try: () => { - const fdPath = resolveFdPath(fd, process.platform); - if (fdPath === undefined) { - return makeDirectBootstrapStream(fd); - } + Effect.gen(function* () { + const platform = yield* HostProcessPlatform; + return yield* Effect.try({ + try: () => { + const fdPath = resolveFdPath(fd, platform); + if (fdPath === undefined) { + return makeDirectBootstrapStream(fd); + } - let streamFd: number | undefined; - try { - streamFd = NFS.openSync(fdPath, "r"); - return NFS.createReadStream("", { - fd: streamFd, - encoding: "utf8", - autoClose: true, - }); - } catch (error) { - if (isBootstrapFdPathDuplicationError(error)) { - if (streamFd !== undefined) { - NFS.closeSync(streamFd); + let streamFd: number | undefined; + try { + streamFd = NFS.openSync(fdPath, "r"); + return NFS.createReadStream("", { + fd: streamFd, + encoding: "utf8", + autoClose: true, + }); + } catch (error) { + if (isBootstrapFdPathDuplicationError(error)) { + if (streamFd !== undefined) { + NFS.closeSync(streamFd); + } + return makeDirectBootstrapStream(fd); } - return makeDirectBootstrapStream(fd); + throw error; } - throw error; - } - }, - catch: (error) => - new BootstrapError({ - message: "Failed to duplicate bootstrap fd.", - cause: error, - }), + }, + catch: (error) => + new BootstrapError({ + message: "Failed to duplicate bootstrap fd.", + cause: error, + }), + }); }); const makeDirectBootstrapStream = (fd: number): Readable => { diff --git a/apps/server/src/os-jank.ts b/apps/server/src/os-jank.ts index 6c3011570f4..76be335b51f 100644 --- a/apps/server/src/os-jank.ts +++ b/apps/server/src/os-jank.ts @@ -1,11 +1,13 @@ import * as NodeOS from "node:os"; import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import { readPathFromLoginShell, readEnvironmentFromWindowsShell, resolveWindowsEnvironment, - type CommandAvailabilityOptions, + type PlatformCommandAvailabilityOptions, type WindowsShellEnvironmentReader, listLoginShellCandidates, mergePathEntries, @@ -14,8 +16,8 @@ import { type WindowsCommandAvailabilityChecker = ( command: string, - options?: CommandAvailabilityOptions, -) => boolean; + options: PlatformCommandAvailabilityOptions, +) => Effect.Effect; function logPathHydrationWarning(message: string, error?: unknown): void { process.stderr.write( @@ -23,7 +25,7 @@ function logPathHydrationWarning(message: string, error?: unknown): void { ); } -export function fixPath( +export const fixPath = Effect.fn("fixPath")(function* ( options: { env?: NodeJS.ProcessEnv; readPath?: typeof readPathFromLoginShell; @@ -33,15 +35,15 @@ export function fixPath( userShell?: string; logWarning?: (message: string, error?: unknown) => void; } = {}, -): void { - const platform = process.platform; +): Effect.fn.Return { + const platform = yield* HostProcessPlatform; const env = options.env ?? process.env; const logWarning = options.logWarning ?? logPathHydrationWarning; const readPath = options.readPath ?? readPathFromLoginShell; try { if (platform === "win32") { - const repairedEnvironment = resolveWindowsEnvironment(env, { + const repairedEnvironment = yield* resolveWindowsEnvironment(env, { readEnvironment: options.readWindowsEnvironment ?? readEnvironmentFromWindowsShell, ...(options.isWindowsCommandAvailable ? { commandAvailable: options.isWindowsCommandAvailable } @@ -79,9 +81,11 @@ export function fixPath( env.PATH = mergedPath; } } catch (error) { - logWarning("Failed to hydrate PATH from the user environment.", error); + yield* Effect.sync(() => { + 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 2f681d37f1d..58914b17f7b 100644 --- a/apps/server/src/process/externalLauncher.test.ts +++ b/apps/server/src/process/externalLauncher.test.ts @@ -2,6 +2,7 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, it } from "@effect/vitest"; import { assertSuccess } from "@effect/vitest/utils"; import * as Crypto from "effect/Crypto"; +import * as ConfigProvider from "effect/ConfigProvider"; import * as Effect from "effect/Effect"; import * as Encoding from "effect/Encoding"; import * as FileSystem from "effect/FileSystem"; @@ -11,13 +12,14 @@ import * as Sink from "effect/Sink"; import * as Stream from "effect/Stream"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import { isCommandAvailableForPlatform, launchBrowser, launchEditorProcess, resolveAvailableEditors, resolveBrowserLaunch, - resolveEditorLaunchForPlatform as resolveEditorLaunch, + resolveEditorLaunch, } from "./externalLauncher.ts"; function encodeUtf16LeBase64(input: string): string { @@ -49,189 +51,189 @@ function makeMockDetachedHandle(onUnref: () => void = () => undefined) { }); } +const withHostRuntime = (input: { + readonly platform: NodeJS.Platform; + readonly env?: Record; +}) => + Layer.merge( + Layer.succeed(HostProcessPlatform, input.platform), + ConfigProvider.layer(ConfigProvider.fromEnv({ env: input.env ?? {} })), + ); + it.layer(NodeServices.layer)("resolveEditorLaunch", (it) => { it.effect("returns commands for command-based editors", () => Effect.gen(function* () { - const antigravityLaunch = yield* resolveEditorLaunch( - { cwd: "/tmp/workspace", editor: "antigravity" }, - "darwin", - { PATH: "" }, - ); + const darwinRuntime = withHostRuntime({ platform: "darwin", env: { PATH: "" } }); + const antigravityLaunch = yield* resolveEditorLaunch({ + cwd: "/tmp/workspace", + editor: "antigravity", + }).pipe(Effect.provide(darwinRuntime)); assert.deepEqual(antigravityLaunch, { command: "agy", args: ["/tmp/workspace"], }); - const cursorLaunch = yield* resolveEditorLaunch( - { cwd: "/tmp/workspace", editor: "cursor" }, - "darwin", - { PATH: "" }, - ); + const cursorLaunch = yield* resolveEditorLaunch({ + cwd: "/tmp/workspace", + editor: "cursor", + }).pipe(Effect.provide(darwinRuntime)); assert.deepEqual(cursorLaunch, { command: "cursor", args: ["/tmp/workspace"], }); - const traeLaunch = yield* resolveEditorLaunch( - { cwd: "/tmp/workspace", editor: "trae" }, - "darwin", + const traeLaunch = yield* resolveEditorLaunch({ cwd: "/tmp/workspace", editor: "trae" }).pipe( + Effect.provide(darwinRuntime), ); assert.deepEqual(traeLaunch, { command: "trae", args: ["/tmp/workspace"], }); - const kiroLaunch = yield* resolveEditorLaunch( - { cwd: "/tmp/workspace", editor: "kiro" }, - "darwin", - { PATH: "" }, + const kiroLaunch = yield* resolveEditorLaunch({ cwd: "/tmp/workspace", editor: "kiro" }).pipe( + Effect.provide(darwinRuntime), ); assert.deepEqual(kiroLaunch, { command: "kiro", args: ["ide", "/tmp/workspace"], }); - const vscodeLaunch = yield* resolveEditorLaunch( - { cwd: "/tmp/workspace", editor: "vscode" }, - "darwin", - { PATH: "" }, - ); + const vscodeLaunch = yield* resolveEditorLaunch({ + cwd: "/tmp/workspace", + editor: "vscode", + }).pipe(Effect.provide(darwinRuntime)); assert.deepEqual(vscodeLaunch, { command: "code", args: ["/tmp/workspace"], }); - const vscodeInsidersLaunch = yield* resolveEditorLaunch( - { cwd: "/tmp/workspace", editor: "vscode-insiders" }, - "darwin", - ); + const vscodeInsidersLaunch = yield* resolveEditorLaunch({ + cwd: "/tmp/workspace", + editor: "vscode-insiders", + }).pipe(Effect.provide(darwinRuntime)); assert.deepEqual(vscodeInsidersLaunch, { command: "code-insiders", args: ["/tmp/workspace"], }); - const vscodiumLaunch = yield* resolveEditorLaunch( - { cwd: "/tmp/workspace", editor: "vscodium" }, - "darwin", - ); + const vscodiumLaunch = yield* resolveEditorLaunch({ + cwd: "/tmp/workspace", + editor: "vscodium", + }).pipe(Effect.provide(darwinRuntime)); assert.deepEqual(vscodiumLaunch, { command: "codium", args: ["/tmp/workspace"], }); - const zedLaunch = yield* resolveEditorLaunch( - { cwd: "/tmp/workspace", editor: "zed" }, - "darwin", - { PATH: "" }, + const zedLaunch = yield* resolveEditorLaunch({ cwd: "/tmp/workspace", editor: "zed" }).pipe( + Effect.provide(darwinRuntime), ); assert.deepEqual(zedLaunch, { command: "zed", args: ["/tmp/workspace"], }); - const ideaLaunch = yield* resolveEditorLaunch( - { cwd: "/tmp/workspace", editor: "idea" }, - "darwin", + const ideaLaunch = yield* resolveEditorLaunch({ cwd: "/tmp/workspace", editor: "idea" }).pipe( + Effect.provide(darwinRuntime), ); assert.deepEqual(ideaLaunch, { command: "idea", args: ["/tmp/workspace"], }); - const aquaLaunch = yield* resolveEditorLaunch( - { cwd: "/tmp/workspace", editor: "aqua" }, - "darwin", + const aquaLaunch = yield* resolveEditorLaunch({ cwd: "/tmp/workspace", editor: "aqua" }).pipe( + Effect.provide(darwinRuntime), ); assert.deepEqual(aquaLaunch, { command: "aqua", args: ["/tmp/workspace"], }); - const clionLaunch = yield* resolveEditorLaunch( - { cwd: "/tmp/workspace", editor: "clion" }, - "darwin", - ); + const clionLaunch = yield* resolveEditorLaunch({ + cwd: "/tmp/workspace", + editor: "clion", + }).pipe(Effect.provide(darwinRuntime)); assert.deepEqual(clionLaunch, { command: "clion", args: ["/tmp/workspace"], }); - const datagripLaunch = yield* resolveEditorLaunch( - { cwd: "/tmp/workspace", editor: "datagrip" }, - "darwin", - ); + const datagripLaunch = yield* resolveEditorLaunch({ + cwd: "/tmp/workspace", + editor: "datagrip", + }).pipe(Effect.provide(darwinRuntime)); assert.deepEqual(datagripLaunch, { command: "datagrip", args: ["/tmp/workspace"], }); - const dataspellLaunch = yield* resolveEditorLaunch( - { cwd: "/tmp/workspace", editor: "dataspell" }, - "darwin", - ); + const dataspellLaunch = yield* resolveEditorLaunch({ + cwd: "/tmp/workspace", + editor: "dataspell", + }).pipe(Effect.provide(darwinRuntime)); assert.deepEqual(dataspellLaunch, { command: "dataspell", args: ["/tmp/workspace"], }); - const golandLaunch = yield* resolveEditorLaunch( - { cwd: "/tmp/workspace", editor: "goland" }, - "darwin", - ); + const golandLaunch = yield* resolveEditorLaunch({ + cwd: "/tmp/workspace", + editor: "goland", + }).pipe(Effect.provide(darwinRuntime)); assert.deepEqual(golandLaunch, { command: "goland", args: ["/tmp/workspace"], }); - const phpstormLaunch = yield* resolveEditorLaunch( - { cwd: "/tmp/workspace", editor: "phpstorm" }, - "darwin", - ); + const phpstormLaunch = yield* resolveEditorLaunch({ + cwd: "/tmp/workspace", + editor: "phpstorm", + }).pipe(Effect.provide(darwinRuntime)); assert.deepEqual(phpstormLaunch, { command: "phpstorm", args: ["/tmp/workspace"], }); - const pycharmLaunch = yield* resolveEditorLaunch( - { cwd: "/tmp/workspace", editor: "pycharm" }, - "darwin", - ); + const pycharmLaunch = yield* resolveEditorLaunch({ + cwd: "/tmp/workspace", + editor: "pycharm", + }).pipe(Effect.provide(darwinRuntime)); assert.deepEqual(pycharmLaunch, { command: "pycharm", args: ["/tmp/workspace"], }); - const riderLaunch = yield* resolveEditorLaunch( - { cwd: "/tmp/workspace", editor: "rider" }, - "darwin", - ); + const riderLaunch = yield* resolveEditorLaunch({ + cwd: "/tmp/workspace", + editor: "rider", + }).pipe(Effect.provide(darwinRuntime)); assert.deepEqual(riderLaunch, { command: "rider", args: ["/tmp/workspace"], }); - const rubymineLaunch = yield* resolveEditorLaunch( - { cwd: "/tmp/workspace", editor: "rubymine" }, - "darwin", - ); + const rubymineLaunch = yield* resolveEditorLaunch({ + cwd: "/tmp/workspace", + editor: "rubymine", + }).pipe(Effect.provide(darwinRuntime)); assert.deepEqual(rubymineLaunch, { command: "rubymine", args: ["/tmp/workspace"], }); - const rustroverLaunch = yield* resolveEditorLaunch( - { cwd: "/tmp/workspace", editor: "rustrover" }, - "darwin", - ); + const rustroverLaunch = yield* resolveEditorLaunch({ + cwd: "/tmp/workspace", + editor: "rustrover", + }).pipe(Effect.provide(darwinRuntime)); assert.deepEqual(rustroverLaunch, { command: "rustrover", args: ["/tmp/workspace"], }); - const webstormLaunch = yield* resolveEditorLaunch( - { cwd: "/tmp/workspace", editor: "webstorm" }, - "darwin", - ); + const webstormLaunch = yield* resolveEditorLaunch({ + cwd: "/tmp/workspace", + editor: "webstorm", + }).pipe(Effect.provide(darwinRuntime)); assert.deepEqual(webstormLaunch, { command: "webstorm", args: ["/tmp/workspace"], @@ -241,205 +243,200 @@ it.layer(NodeServices.layer)("resolveEditorLaunch", (it) => { it.effect("applies launch-style-specific navigation arguments", () => Effect.gen(function* () { - const lineOnly = yield* resolveEditorLaunch( - { cwd: "/tmp/workspace/AGENTS.md:48", editor: "cursor" }, - "darwin", - { PATH: "" }, - ); + const darwinRuntime = withHostRuntime({ platform: "darwin", env: { PATH: "" } }); + const lineOnly = yield* resolveEditorLaunch({ + cwd: "/tmp/workspace/AGENTS.md:48", + editor: "cursor", + }).pipe(Effect.provide(darwinRuntime)); assert.deepEqual(lineOnly, { command: "cursor", args: ["--goto", "/tmp/workspace/AGENTS.md:48"], }); - const lineAndColumn = yield* resolveEditorLaunch( - { cwd: "/tmp/workspace/src/process/externalLauncher.ts:71:5", editor: "cursor" }, - "darwin", - { PATH: "" }, - ); + const lineAndColumn = yield* resolveEditorLaunch({ + cwd: "/tmp/workspace/src/process/externalLauncher.ts:71:5", + editor: "cursor", + }).pipe(Effect.provide(darwinRuntime)); assert.deepEqual(lineAndColumn, { command: "cursor", args: ["--goto", "/tmp/workspace/src/process/externalLauncher.ts:71:5"], }); - const traeLineAndColumn = yield* resolveEditorLaunch( - { cwd: "/tmp/workspace/src/process/externalLauncher.ts:71:5", editor: "trae" }, - "darwin", - ); + const traeLineAndColumn = yield* resolveEditorLaunch({ + cwd: "/tmp/workspace/src/process/externalLauncher.ts:71:5", + editor: "trae", + }).pipe(Effect.provide(darwinRuntime)); assert.deepEqual(traeLineAndColumn, { command: "trae", args: ["--goto", "/tmp/workspace/src/process/externalLauncher.ts:71:5"], }); - const kiroLineAndColumn = yield* resolveEditorLaunch( - { cwd: "/tmp/workspace/src/process/externalLauncher.ts:71:5", editor: "kiro" }, - "darwin", - { PATH: "" }, - ); + const kiroLineAndColumn = yield* resolveEditorLaunch({ + cwd: "/tmp/workspace/src/process/externalLauncher.ts:71:5", + editor: "kiro", + }).pipe(Effect.provide(darwinRuntime)); assert.deepEqual(kiroLineAndColumn, { command: "kiro", args: ["ide", "--goto", "/tmp/workspace/src/process/externalLauncher.ts:71:5"], }); - const vscodeLineAndColumn = yield* resolveEditorLaunch( - { cwd: "/tmp/workspace/src/process/externalLauncher.ts:71:5", editor: "vscode" }, - "darwin", - { PATH: "" }, - ); + const vscodeLineAndColumn = yield* resolveEditorLaunch({ + cwd: "/tmp/workspace/src/process/externalLauncher.ts:71:5", + editor: "vscode", + }).pipe(Effect.provide(darwinRuntime)); assert.deepEqual(vscodeLineAndColumn, { command: "code", args: ["--goto", "/tmp/workspace/src/process/externalLauncher.ts:71:5"], }); - const vscodeInsidersLineAndColumn = yield* resolveEditorLaunch( - { cwd: "/tmp/workspace/src/process/externalLauncher.ts:71:5", editor: "vscode-insiders" }, - "darwin", - ); + const vscodeInsidersLineAndColumn = yield* resolveEditorLaunch({ + cwd: "/tmp/workspace/src/process/externalLauncher.ts:71:5", + editor: "vscode-insiders", + }).pipe(Effect.provide(darwinRuntime)); assert.deepEqual(vscodeInsidersLineAndColumn, { command: "code-insiders", args: ["--goto", "/tmp/workspace/src/process/externalLauncher.ts:71:5"], }); - const vscodiumLineAndColumn = yield* resolveEditorLaunch( - { cwd: "/tmp/workspace/src/process/externalLauncher.ts:71:5", editor: "vscodium" }, - "darwin", - ); + const vscodiumLineAndColumn = yield* resolveEditorLaunch({ + cwd: "/tmp/workspace/src/process/externalLauncher.ts:71:5", + editor: "vscodium", + }).pipe(Effect.provide(darwinRuntime)); assert.deepEqual(vscodiumLineAndColumn, { command: "codium", args: ["--goto", "/tmp/workspace/src/process/externalLauncher.ts:71:5"], }); - const zedLineAndColumn = yield* resolveEditorLaunch( - { cwd: "/tmp/workspace/src/process/externalLauncher.ts:71:5", editor: "zed" }, - "darwin", - { PATH: "" }, - ); + const zedLineAndColumn = yield* resolveEditorLaunch({ + cwd: "/tmp/workspace/src/process/externalLauncher.ts:71:5", + editor: "zed", + }).pipe(Effect.provide(darwinRuntime)); assert.deepEqual(zedLineAndColumn, { command: "zed", args: ["/tmp/workspace/src/process/externalLauncher.ts:71:5"], }); - const zedLineOnly = yield* resolveEditorLaunch( - { cwd: "/tmp/workspace/AGENTS.md:48", editor: "zed" }, - "darwin", - { PATH: "" }, - ); + const zedLineOnly = yield* resolveEditorLaunch({ + cwd: "/tmp/workspace/AGENTS.md:48", + editor: "zed", + }).pipe(Effect.provide(darwinRuntime)); assert.deepEqual(zedLineOnly, { command: "zed", args: ["/tmp/workspace/AGENTS.md:48"], }); - const ideaLineOnly = yield* resolveEditorLaunch( - { cwd: "/tmp/workspace/AGENTS.md:48", editor: "idea" }, - "darwin", - ); + const ideaLineOnly = yield* resolveEditorLaunch({ + cwd: "/tmp/workspace/AGENTS.md:48", + editor: "idea", + }).pipe(Effect.provide(darwinRuntime)); assert.deepEqual(ideaLineOnly, { command: "idea", args: ["--line", "48", "/tmp/workspace/AGENTS.md"], }); - const ideaLineAndColumn = yield* resolveEditorLaunch( - { cwd: "/tmp/workspace/src/process/externalLauncher.ts:71:5", editor: "idea" }, - "darwin", - ); + const ideaLineAndColumn = yield* resolveEditorLaunch({ + cwd: "/tmp/workspace/src/process/externalLauncher.ts:71:5", + editor: "idea", + }).pipe(Effect.provide(darwinRuntime)); assert.deepEqual(ideaLineAndColumn, { command: "idea", args: ["--line", "71", "--column", "5", "/tmp/workspace/src/process/externalLauncher.ts"], }); - const aquaLineAndColumn = yield* resolveEditorLaunch( - { cwd: "/tmp/workspace/src/process/externalLauncher.ts:71:5", editor: "aqua" }, - "darwin", - ); + const aquaLineAndColumn = yield* resolveEditorLaunch({ + cwd: "/tmp/workspace/src/process/externalLauncher.ts:71:5", + editor: "aqua", + }).pipe(Effect.provide(darwinRuntime)); assert.deepEqual(aquaLineAndColumn, { command: "aqua", args: ["--line", "71", "--column", "5", "/tmp/workspace/src/process/externalLauncher.ts"], }); - const clionLineAndColumn = yield* resolveEditorLaunch( - { cwd: "/tmp/workspace/src/process/externalLauncher.ts:71:5", editor: "clion" }, - "darwin", - ); + const clionLineAndColumn = yield* resolveEditorLaunch({ + cwd: "/tmp/workspace/src/process/externalLauncher.ts:71:5", + editor: "clion", + }).pipe(Effect.provide(darwinRuntime)); assert.deepEqual(clionLineAndColumn, { command: "clion", args: ["--line", "71", "--column", "5", "/tmp/workspace/src/process/externalLauncher.ts"], }); - const datagripLineAndColumn = yield* resolveEditorLaunch( - { cwd: "/tmp/workspace/src/process/externalLauncher.ts:71:5", editor: "datagrip" }, - "darwin", - ); + const datagripLineAndColumn = yield* resolveEditorLaunch({ + cwd: "/tmp/workspace/src/process/externalLauncher.ts:71:5", + editor: "datagrip", + }).pipe(Effect.provide(darwinRuntime)); assert.deepEqual(datagripLineAndColumn, { command: "datagrip", args: ["--line", "71", "--column", "5", "/tmp/workspace/src/process/externalLauncher.ts"], }); - const dataspellLineAndColumn = yield* resolveEditorLaunch( - { cwd: "/tmp/workspace/src/process/externalLauncher.ts:71:5", editor: "dataspell" }, - "darwin", - ); + const dataspellLineAndColumn = yield* resolveEditorLaunch({ + cwd: "/tmp/workspace/src/process/externalLauncher.ts:71:5", + editor: "dataspell", + }).pipe(Effect.provide(darwinRuntime)); assert.deepEqual(dataspellLineAndColumn, { command: "dataspell", args: ["--line", "71", "--column", "5", "/tmp/workspace/src/process/externalLauncher.ts"], }); - const golandLineAndColumn = yield* resolveEditorLaunch( - { cwd: "/tmp/workspace/src/process/externalLauncher.ts:71:5", editor: "goland" }, - "darwin", - ); + const golandLineAndColumn = yield* resolveEditorLaunch({ + cwd: "/tmp/workspace/src/process/externalLauncher.ts:71:5", + editor: "goland", + }).pipe(Effect.provide(darwinRuntime)); assert.deepEqual(golandLineAndColumn, { command: "goland", args: ["--line", "71", "--column", "5", "/tmp/workspace/src/process/externalLauncher.ts"], }); - const phpstormLineAndColumn = yield* resolveEditorLaunch( - { cwd: "/tmp/workspace/src/process/externalLauncher.ts:71:5", editor: "phpstorm" }, - "darwin", - ); + const phpstormLineAndColumn = yield* resolveEditorLaunch({ + cwd: "/tmp/workspace/src/process/externalLauncher.ts:71:5", + editor: "phpstorm", + }).pipe(Effect.provide(darwinRuntime)); assert.deepEqual(phpstormLineAndColumn, { command: "phpstorm", args: ["--line", "71", "--column", "5", "/tmp/workspace/src/process/externalLauncher.ts"], }); - const pycharmLineAndColumn = yield* resolveEditorLaunch( - { cwd: "/tmp/workspace/src/process/externalLauncher.ts:71:5", editor: "pycharm" }, - "darwin", - ); + const pycharmLineAndColumn = yield* resolveEditorLaunch({ + cwd: "/tmp/workspace/src/process/externalLauncher.ts:71:5", + editor: "pycharm", + }).pipe(Effect.provide(darwinRuntime)); assert.deepEqual(pycharmLineAndColumn, { command: "pycharm", args: ["--line", "71", "--column", "5", "/tmp/workspace/src/process/externalLauncher.ts"], }); - const riderLineAndColumn = yield* resolveEditorLaunch( - { cwd: "/tmp/workspace/src/process/externalLauncher.ts:71:5", editor: "rider" }, - "darwin", - ); + const riderLineAndColumn = yield* resolveEditorLaunch({ + cwd: "/tmp/workspace/src/process/externalLauncher.ts:71:5", + editor: "rider", + }).pipe(Effect.provide(darwinRuntime)); assert.deepEqual(riderLineAndColumn, { command: "rider", args: ["--line", "71", "--column", "5", "/tmp/workspace/src/process/externalLauncher.ts"], }); - const rubymineLineAndColumn = yield* resolveEditorLaunch( - { cwd: "/tmp/workspace/src/process/externalLauncher.ts:71:5", editor: "rubymine" }, - "darwin", - ); + const rubymineLineAndColumn = yield* resolveEditorLaunch({ + cwd: "/tmp/workspace/src/process/externalLauncher.ts:71:5", + editor: "rubymine", + }).pipe(Effect.provide(darwinRuntime)); assert.deepEqual(rubymineLineAndColumn, { command: "rubymine", args: ["--line", "71", "--column", "5", "/tmp/workspace/src/process/externalLauncher.ts"], }); - const rustroverLineAndColumn = yield* resolveEditorLaunch( - { cwd: "/tmp/workspace/src/process/externalLauncher.ts:71:5", editor: "rustrover" }, - "darwin", - ); + const rustroverLineAndColumn = yield* resolveEditorLaunch({ + cwd: "/tmp/workspace/src/process/externalLauncher.ts:71:5", + editor: "rustrover", + }).pipe(Effect.provide(darwinRuntime)); assert.deepEqual(rustroverLineAndColumn, { command: "rustrover", args: ["--line", "71", "--column", "5", "/tmp/workspace/src/process/externalLauncher.ts"], }); - const webstormLineOnly = yield* resolveEditorLaunch( - { cwd: "/tmp/workspace/AGENTS.md:48", editor: "webstorm" }, - "darwin", - ); + const webstormLineOnly = yield* resolveEditorLaunch({ + cwd: "/tmp/workspace/AGENTS.md:48", + editor: "webstorm", + }).pipe(Effect.provide(darwinRuntime)); assert.deepEqual(webstormLineOnly, { command: "webstorm", args: ["--line", "48", "/tmp/workspace/AGENTS.md"], @@ -455,9 +452,9 @@ it.layer(NodeServices.layer)("resolveEditorLaunch", (it) => { yield* fs.writeFileString(path.join(dir, "zeditor"), "#!/bin/sh\nexit 0\n"); yield* fs.chmod(path.join(dir, "zeditor"), 0o755); - const result = yield* resolveEditorLaunch({ cwd: "/tmp/workspace", editor: "zed" }, "linux", { - PATH: dir, - }); + const result = yield* resolveEditorLaunch({ cwd: "/tmp/workspace", editor: "zed" }).pipe( + Effect.provide(withHostRuntime({ platform: "linux", env: { PATH: dir } })), + ); assert.deepEqual(result, { command: "zeditor", @@ -468,9 +465,9 @@ it.layer(NodeServices.layer)("resolveEditorLaunch", (it) => { it.effect("falls back to the primary command when no alias is installed", () => Effect.gen(function* () { - const result = yield* resolveEditorLaunch({ cwd: "/tmp/workspace", editor: "zed" }, "linux", { - PATH: "", - }); + const result = yield* resolveEditorLaunch({ cwd: "/tmp/workspace", editor: "zed" }).pipe( + Effect.provide(withHostRuntime({ platform: "linux", env: { PATH: "" } })), + ); assert.deepEqual(result, { command: "zed", args: ["/tmp/workspace"], @@ -480,31 +477,32 @@ it.layer(NodeServices.layer)("resolveEditorLaunch", (it) => { it.effect("maps file-manager editor to OS open commands", () => Effect.gen(function* () { - const launch1 = yield* resolveEditorLaunch( - { cwd: "/tmp/workspace", editor: "file-manager" }, - "darwin", - { PATH: "" }, - ); + const darwinRuntime = withHostRuntime({ platform: "darwin", env: { PATH: "" } }); + const windowsRuntime = withHostRuntime({ platform: "win32", env: { PATH: "" } }); + const linuxRuntime = withHostRuntime({ platform: "linux", env: { PATH: "" } }); + + const launch1 = yield* resolveEditorLaunch({ + cwd: "/tmp/workspace", + editor: "file-manager", + }).pipe(Effect.provide(darwinRuntime)); assert.deepEqual(launch1, { command: "open", args: ["/tmp/workspace"], }); - const launch2 = yield* resolveEditorLaunch( - { cwd: "C:\\workspace", editor: "file-manager" }, - "win32", - { PATH: "" }, - ); + const launch2 = yield* resolveEditorLaunch({ + cwd: "C:\\workspace", + editor: "file-manager", + }).pipe(Effect.provide(windowsRuntime)); assert.deepEqual(launch2, { command: "explorer", args: ["C:\\workspace"], }); - const launch3 = yield* resolveEditorLaunch( - { cwd: "/tmp/workspace", editor: "file-manager" }, - "linux", - { PATH: "" }, - ); + const launch3 = yield* resolveEditorLaunch({ + cwd: "/tmp/workspace", + editor: "file-manager", + }).pipe(Effect.provide(linuxRuntime)); assert.deepEqual(launch3, { command: "xdg-open", args: ["/tmp/workspace"], @@ -513,65 +511,83 @@ it.layer(NodeServices.layer)("resolveEditorLaunch", (it) => { ); }); -it("resolveBrowserLaunch maps default browser launchers by platform", () => { - const target = "https://example.com/some path?name=o'hara"; - - assert.deepEqual(resolveBrowserLaunch(target, "darwin", {}).command, "open"); - assert.deepEqual(resolveBrowserLaunch(target, "darwin", {}).args, [target]); - assert.deepEqual(resolveBrowserLaunch(target, "darwin", {}).options, { - detached: true, - stdin: "ignore", - stdout: "ignore", - stderr: "ignore", - }); +it.effect("resolveBrowserLaunch maps default browser launchers by platform", () => + Effect.gen(function* () { + const target = "https://example.com/some path?name=o'hara"; - assert.deepEqual(resolveBrowserLaunch(target, "linux", {}).command, "xdg-open"); - assert.deepEqual(resolveBrowserLaunch(target, "linux", {}).args, [target]); + const darwin = yield* resolveBrowserLaunch(target).pipe( + Effect.provide(withHostRuntime({ platform: "darwin" })), + ); + assert.deepEqual(darwin.command, "open"); + assert.deepEqual(darwin.args, [target]); + assert.deepEqual(darwin.options, { + detached: true, + stdin: "ignore", + stdout: "ignore", + stderr: "ignore", + }); - const windows = resolveBrowserLaunch(target, "win32", { - SYSTEMROOT: "C:\\Windows", - }); - assert.equal(windows.command, "C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe"); - assert.deepEqual(windows.args, [ - "-NoProfile", - "-NonInteractive", - "-ExecutionPolicy", - "Bypass", - "-EncodedCommand", - encodeUtf16LeBase64( - "$ProgressPreference = 'SilentlyContinue'; Start 'https://example.com/some path?name=o''hara'", - ), - ]); - assert.deepEqual(windows.options, { - detached: true, - shell: false, - stdin: "ignore", - stdout: "ignore", - stderr: "ignore", - }); -}); + const linux = yield* resolveBrowserLaunch(target).pipe( + Effect.provide(withHostRuntime({ platform: "linux" })), + ); + assert.deepEqual(linux.command, "xdg-open"); + assert.deepEqual(linux.args, [target]); -it("resolveBrowserLaunch opens through Windows from WSL when not remote", () => { - const launch = resolveBrowserLaunch("https://example.com", "linux", { - WSL_DISTRO_NAME: "Ubuntu", - }); - assert.equal(launch.command, "/mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe"); - assert.equal(launch.options.detached, true); -}); + const windows = yield* resolveBrowserLaunch(target).pipe( + Effect.provide(withHostRuntime({ platform: "win32", env: { SYSTEMROOT: "C:\\Windows" } })), + ); + assert.equal(windows.command, "C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe"); + assert.deepEqual(windows.args, [ + "-NoProfile", + "-NonInteractive", + "-ExecutionPolicy", + "Bypass", + "-EncodedCommand", + encodeUtf16LeBase64( + "$ProgressPreference = 'SilentlyContinue'; Start 'https://example.com/some path?name=o''hara'", + ), + ]); + assert.deepEqual(windows.options, { + detached: true, + shell: false, + stdin: "ignore", + stdout: "ignore", + stderr: "ignore", + }); + }), +); -it("resolveBrowserLaunch keeps xdg-open for WSL over SSH", () => { - const launch = resolveBrowserLaunch("https://example.com", "linux", { - WSL_DISTRO_NAME: "Ubuntu", - SSH_CONNECTION: "client server", - }); - assert.equal(launch.command, "xdg-open"); -}); +it.effect("resolveBrowserLaunch opens through Windows from WSL when not remote", () => + Effect.gen(function* () { + const launch = yield* resolveBrowserLaunch("https://example.com").pipe( + Effect.provide(withHostRuntime({ platform: "linux", env: { WSL_DISTRO_NAME: "Ubuntu" } })), + ); + assert.equal(launch.command, "/mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe"); + assert.equal(launch.options.detached, true); + }), +); + +it.effect("resolveBrowserLaunch keeps xdg-open for WSL over SSH", () => + Effect.gen(function* () { + const launch = yield* resolveBrowserLaunch("https://example.com").pipe( + Effect.provide( + withHostRuntime({ + platform: "linux", + env: { WSL_DISTRO_NAME: "Ubuntu", SSH_CONNECTION: "client server" }, + }), + ), + ); + assert.equal(launch.command, "xdg-open"); + }), +); it.layer(NodeServices.layer)("launchBrowser", (it) => { it.effect("spawns through the ChildProcessSpawner service and unrefs the handle", () => Effect.gen(function* () { let spawnedCommand: ChildProcess.StandardCommand | undefined; let didUnref = false; + const platform = "linux" satisfies NodeJS.Platform; + const env = {}; const spawnerLayer = Layer.mock(ChildProcessSpawner.ChildProcessSpawner, { spawn: (command) => @@ -588,20 +604,20 @@ it.layer(NodeServices.layer)("launchBrowser", (it) => { }); const result = yield* launchBrowser("https://example.com").pipe( - Effect.provide(spawnerLayer), + Effect.provide(Layer.merge(spawnerLayer, withHostRuntime({ platform, env }))), Effect.result, ); assertSuccess(result, undefined); assert.ok(spawnedCommand); - const expectedLaunch = resolveBrowserLaunch( - "https://example.com", - process.platform, - process.env, - ); - assert.equal(spawnedCommand.command, expectedLaunch.command); - assert.deepEqual(spawnedCommand.args, expectedLaunch.args); - assert.deepEqual(spawnedCommand.options, expectedLaunch.options); + assert.equal(spawnedCommand.command, "xdg-open"); + assert.deepEqual(spawnedCommand.args, ["https://example.com"]); + assert.deepEqual(spawnedCommand.options, { + detached: true, + stdin: "ignore", + stdout: "ignore", + stderr: "ignore", + }); assert.equal(didUnref, true); }), ); @@ -613,6 +629,7 @@ it.layer(NodeServices.layer)("launchEditorProcess", (it) => { let spawnedCommand: ChildProcess.StandardCommand | undefined; let didUnref = false; const expectedArgs = ["-e", "process.exit(0)"]; + const platform = "linux" satisfies NodeJS.Platform; const spawnerLayer = Layer.mock(ChildProcessSpawner.ChildProcessSpawner, { spawn: (command) => @@ -631,18 +648,18 @@ it.layer(NodeServices.layer)("launchEditorProcess", (it) => { const result = yield* launchEditorProcess({ command: process.execPath, args: expectedArgs, - }).pipe(Effect.provide(spawnerLayer), Effect.result); + }).pipe( + Effect.provide(Layer.merge(spawnerLayer, withHostRuntime({ platform }))), + Effect.result, + ); assertSuccess(result, undefined); assert.ok(spawnedCommand); assert.equal(spawnedCommand.command, process.execPath); - assert.deepEqual( - spawnedCommand.args, - process.platform === "win32" ? expectedArgs.map((arg) => `"${arg}"`) : expectedArgs, - ); + assert.deepEqual(spawnedCommand.args, expectedArgs); assert.deepEqual(spawnedCommand.options, { detached: true, - shell: process.platform === "win32", + shell: false, stdin: "ignore", stdout: "ignore", stderr: "ignore", @@ -676,20 +693,25 @@ it.layer(NodeServices.layer)("isCommandAvailable", (it) => { PATH: dir, PATHEXT: ".COM;.EXE;.BAT;.CMD", } satisfies NodeJS.ProcessEnv; - assert.equal(isCommandAvailableForPlatform("code", { platform: "win32", env }), true); + assert.equal(yield* isCommandAvailableForPlatform("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( - isCommandAvailableForPlatform("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* isCommandAvailableForPlatform("definitely-not-installed", { + platform: "win32", + env, + }), + false, + ); + }), + ); it.effect("does not treat bare files without executable extension as available on win32", () => Effect.gen(function* () { @@ -701,7 +723,7 @@ it.layer(NodeServices.layer)("isCommandAvailable", (it) => { PATH: dir, PATHEXT: ".COM;.EXE;.BAT;.CMD", } satisfies NodeJS.ProcessEnv; - assert.equal(isCommandAvailableForPlatform("npm", { platform: "win32", env }), false); + assert.equal(yield* isCommandAvailableForPlatform("npm", { platform: "win32", env }), false); }), ); @@ -715,7 +737,10 @@ it.layer(NodeServices.layer)("isCommandAvailable", (it) => { PATH: dir, PATHEXT: ".COM;.EXE;.BAT;.CMD", } satisfies NodeJS.ProcessEnv; - assert.equal(isCommandAvailableForPlatform("my.tool", { platform: "win32", env }), true); + assert.equal( + yield* isCommandAvailableForPlatform("my.tool", { platform: "win32", env }), + true, + ); }), ); @@ -731,7 +756,7 @@ it.layer(NodeServices.layer)("isCommandAvailable", (it) => { PATH: `${firstDir};${secondDir}`, PATHEXT: ".COM;.EXE;.BAT;.CMD", } satisfies NodeJS.ProcessEnv; - assert.equal(isCommandAvailableForPlatform("code", { platform: "win32", env }), true); + assert.equal(yield* isCommandAvailableForPlatform("code", { platform: "win32", env }), true); }), ); }); @@ -759,10 +784,14 @@ 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", { - PATH: dir, - PATHEXT: ".COM;.EXE;.BAT;.CMD", - }); + const editors = yield* resolveAvailableEditors().pipe( + Effect.provide( + withHostRuntime({ + platform: "win32", + env: { PATH: dir, PATHEXT: ".COM;.EXE;.BAT;.CMD" }, + }), + ), + ); assert.deepEqual(editors, [ "trae", "kiro", @@ -795,17 +824,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", { - PATH: dir, - }); + const editors = yield* resolveAvailableEditors().pipe( + Effect.provide(withHostRuntime({ platform: "linux", env: { 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().pipe( + Effect.provide(withHostRuntime({ platform: "linux", env: { PATH: "" } })), + ); + assert.deepEqual(editors, []); + }), + ); }); diff --git a/apps/server/src/process/externalLauncher.ts b/apps/server/src/process/externalLauncher.ts index d20bc0bbffe..4a632817596 100644 --- a/apps/server/src/process/externalLauncher.ts +++ b/apps/server/src/process/externalLauncher.ts @@ -21,8 +21,10 @@ import * as Config from "effect/Config"; 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"; // ============================== @@ -144,17 +146,17 @@ function resolveEditorArgs( return [...baseArgs, ...resolveCommandEditorArgs(editor, target)]; } -function resolveAvailableCommand( +const resolveAvailableCommand = Effect.fn("externalLauncher.resolveAvailableCommand")(function* ( commands: ReadonlyArray, options: PlatformCommandAvailabilityOptions, -): Option.Option { +): Effect.fn.Return, never, FileSystem.FileSystem | Path.Path> { for (const command of commands) { - if (isCommandAvailableForPlatform(command, options)) { + if (yield* isCommandAvailableForPlatform(command, options)) { return Option.some(command); } } return Option.none(); -} +}); function encodeUtf16LeBase64(input: string): string { const bytes = new Uint8Array(input.length * 2); @@ -219,7 +221,7 @@ function fileManagerCommandForPlatform(platform: NodeJS.Platform): string { } } -export function resolveBrowserLaunch( +function buildBrowserLaunch( target: string, platform: NodeJS.Platform, env: NodeJS.ProcessEnv = {}, @@ -247,36 +249,48 @@ export function resolveBrowserLaunch( }; } -export function resolveAvailableEditors( +const buildAvailableEditors = Effect.fn("externalLauncher.buildAvailableEditors")(function* ( platform: NodeJS.Platform, env: NodeJS.ProcessEnv, -): ReadonlyArray { +): 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 (isCommandAvailableForPlatform(command, { platform, env })) { + if (yield* isCommandAvailableForPlatform(command, { platform, env })) { available.push(editor.id); } continue; } - const command = resolveAvailableCommand(editor.commands, { platform, env }); + const command = yield* resolveAvailableCommand(editor.commands, { platform, env }); if (Option.isSome(command)) { available.push(editor.id); } } return available; -} +}); -export const getAvailableEditors = Effect.fn("externalLauncher.getAvailableEditors")(function* () { +export const resolveBrowserLaunch = Effect.fn("externalLauncher.resolveBrowserLaunch")(function* ( + target: string, +) { const platform = yield* HostProcessPlatform; - const env = yield* readCommandLookupEnv; - return resolveAvailableEditors(platform, env); + const env = yield* readBrowserLaunchEnv; + return buildBrowserLaunch(target, platform, env); }); +export const resolveAvailableEditors = Effect.fn("externalLauncher.resolveAvailableEditors")( + function* () { + const platform = yield* HostProcessPlatform; + const env = yield* readCommandLookupEnv; + return yield* buildAvailableEditors(platform, env); + }, +); + +export const getAvailableEditors = resolveAvailableEditors; + /** * ExternalLauncherShape - Service API for browser and editor launch actions. */ @@ -305,47 +319,37 @@ export class ExternalLauncher extends Context.Service { - yield* Effect.annotateCurrentSpan({ - "externalLauncher.editor": input.editor, - "externalLauncher.cwd": input.cwd, - "externalLauncher.platform": platform, - }); - const editorDef = EDITORS.find((editor) => editor.id === input.editor); - if (!editorDef) { - return yield* new ExternalLauncherError({ message: `Unknown editor: ${input.editor}` }); - } - - if (editorDef.commands) { - const command = Option.getOrElse( - resolveAvailableCommand(editorDef.commands, { platform, env }), - () => editorDef.commands[0], - ); - return { - command, - args: resolveEditorArgs(editorDef, input.cwd), - }; - } - - if (editorDef.id !== "file-manager") { - return yield* new ExternalLauncherError({ message: `Unsupported editor: ${input.editor}` }); - } - - return { command: fileManagerCommandForPlatform(platform), args: [input.cwd] }; - }, -); - export const resolveEditorLaunch = Effect.fn("resolveEditorLaunch")(function* ( input: LaunchEditorInput, -): Effect.fn.Return { +): Effect.fn.Return { const platform = yield* HostProcessPlatform; const env = yield* readCommandLookupEnv; - return yield* resolveEditorLaunchForPlatform(input, platform, env); + yield* Effect.annotateCurrentSpan({ + "externalLauncher.editor": input.editor, + "externalLauncher.cwd": input.cwd, + "externalLauncher.platform": platform, + }); + const editorDef = EDITORS.find((editor) => editor.id === input.editor); + if (!editorDef) { + return yield* new ExternalLauncherError({ message: `Unknown editor: ${input.editor}` }); + } + + if (editorDef.commands) { + const command = Option.getOrElse( + yield* resolveAvailableCommand(editorDef.commands, { platform, env }), + () => editorDef.commands[0], + ); + return { + command, + args: resolveEditorArgs(editorDef, input.cwd), + }; + } + + if (editorDef.id !== "file-manager") { + return yield* new ExternalLauncherError({ message: `Unsupported editor: ${input.editor}` }); + } + + return { command: fileManagerCommandForPlatform(platform), args: [input.cwd] }; }); const launchAndUnref = Effect.fn("externalLauncher.launchAndUnref")(function* ( @@ -366,20 +370,20 @@ const launchAndUnref = Effect.fn("externalLauncher.launchAndUnref")(function* ( export const launchBrowser = Effect.fn("externalLauncher.launchBrowser")(function* ( target: string, ): Effect.fn.Return { - const platform = yield* HostProcessPlatform; - const env = yield* readBrowserLaunchEnv; - return yield* launchAndUnref( - resolveBrowserLaunch(target, platform, env), - "Browser auto-open failed", - ); + const launch = yield* resolveBrowserLaunch(target); + return yield* launchAndUnref(launch, "Browser auto-open failed"); }); export const launchEditorProcess = Effect.fn("externalLauncher.launchEditorProcess")(function* ( launch: EditorLaunch, -): Effect.fn.Return { +): Effect.fn.Return< + void, + ExternalLauncherError, + ChildProcessSpawner.ChildProcessSpawner | FileSystem.FileSystem | Path.Path +> { const platform = yield* HostProcessPlatform; const env = yield* readCommandLookupEnv; - if (!isCommandAvailableForPlatform(launch.command, { platform, env })) { + if (!(yield* isCommandAvailableForPlatform(launch.command, { platform, env }))) { return yield* new ExternalLauncherError({ message: `Editor command not found: ${launch.command}`, }); @@ -404,6 +408,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) => @@ -411,9 +425,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/processRunner.test.ts b/apps/server/src/processRunner.test.ts index fae9ad574cf..bacb3e369d4 100644 --- a/apps/server/src/processRunner.test.ts +++ b/apps/server/src/processRunner.test.ts @@ -8,6 +8,7 @@ import * as Sink from "effect/Sink"; import * as Stream from "effect/Stream"; import { TestClock } from "effect/testing"; import { ChildProcessSpawner } from "effect/unstable/process"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import { isWindowsCommandNotFound, @@ -280,19 +281,13 @@ describe("runProcess", () => { }); describe("isWindowsCommandNotFound", () => { - it("matches the localized German cmd.exe error text", () => { - const originalPlatform = process.platform; - Object.defineProperty(process, "platform", { value: "win32", configurable: true }); - - try { - expect( - isWindowsCommandNotFound( - 1, - "wird nicht als interner oder externer Befehl, betriebsfahiges Programm oder Batch-Datei erkannt", - ), - ).toBe(true); - } finally { - Object.defineProperty(process, "platform", { value: originalPlatform, configurable: true }); - } - }); + it.effect("matches the localized German cmd.exe error text", () => + Effect.gen(function* () { + const isCommandNotFound = yield* isWindowsCommandNotFound( + 1, + "wird nicht als interner oder externer Befehl, betriebsfahiges Programm oder Batch-Datei erkannt", + ).pipe(Effect.provideService(HostProcessPlatform, "win32")); + expect(isCommandNotFound).toBe(true); + }), + ); }); diff --git a/apps/server/src/processRunner.ts b/apps/server/src/processRunner.ts index 45135bf9d2a..9247a99339d 100644 --- a/apps/server/src/processRunner.ts +++ b/apps/server/src/processRunner.ts @@ -8,6 +8,7 @@ import * as PlatformError from "effect/PlatformError"; import * as Scope from "effect/Scope"; import * as Stream from "effect/Stream"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import { collectUint8StreamText, type CollectedUint8StreamText, @@ -109,11 +110,14 @@ function hasWindowsCommandNotFoundMessage(output: string): boolean { return WINDOWS_COMMAND_NOT_FOUND_PATTERNS.some((pattern) => pattern.test(output)); } -export function isWindowsCommandNotFound(code: number | null, stderr: string): boolean { - if (process.platform !== "win32") return false; - if (code === 9009) return true; - return hasWindowsCommandNotFoundMessage(stderr); -} +export const isWindowsCommandNotFound = Effect.fn("processRunner.isWindowsCommandNotFound")( + function* (code: number | null, stderr: string) { + const platform = yield* HostProcessPlatform; + if (platform !== "win32") return false; + if (code === 9009) return true; + return hasWindowsCommandNotFoundMessage(stderr); + }, +); const collectText = Effect.fn("processRunner.collectText")(function* (input: { readonly command: string; diff --git a/apps/server/src/project/Layers/RepositoryIdentityResolver.test.ts b/apps/server/src/project/Layers/RepositoryIdentityResolver.test.ts index 1c985cd8592..1a3f125ee63 100644 --- a/apps/server/src/project/Layers/RepositoryIdentityResolver.test.ts +++ b/apps/server/src/project/Layers/RepositoryIdentityResolver.test.ts @@ -6,6 +6,7 @@ import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; import { TestClock } from "effect/testing"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import * as ProcessRunner from "../../processRunner.ts"; import { RepositoryIdentityResolver } from "../Services/RepositoryIdentityResolver.ts"; @@ -20,9 +21,11 @@ const normalizeResolvedPath = (value: string) => normalizePathSeparators(value); const git = (cwd: string, args: ReadonlyArray) => Effect.gen(function* () { const processRunner = yield* ProcessRunner.ProcessRunner; + const platform = yield* HostProcessPlatform; return yield* processRunner.run({ command: "git", args: ["-C", cwd, ...args], + shell: platform === "win32", }); }).pipe(Effect.provide(ProcessRunner.layer)); diff --git a/apps/server/src/provider/opencodeRuntime.ts b/apps/server/src/provider/opencodeRuntime.ts index dafdcd4e1bc..3b64eb84b25 100644 --- a/apps/server/src/provider/opencodeRuntime.ts +++ b/apps/server/src/provider/opencodeRuntime.ts @@ -292,7 +292,7 @@ const makeOpenCodeRuntime = Effect.gen(function* () { { concurrency: "unbounded" }, ); const exitCode = Number(code); - if (isWindowsCommandNotFound(exitCode, stderr)) { + if (yield* isWindowsCommandNotFound(exitCode, stderr)) { return yield* new OpenCodeRuntimeError({ operation: "runOpenCodeCommand", detail: `spawn ${input.binaryPath} ENOENT`, diff --git a/apps/server/src/provider/providerMaintenance.test.ts b/apps/server/src/provider/providerMaintenance.test.ts index 080448dc828..5ae04eb416a 100644 --- a/apps/server/src/provider/providerMaintenance.test.ts +++ b/apps/server/src/provider/providerMaintenance.test.ts @@ -2,9 +2,10 @@ import { afterEach, expect, it } from "@effect/vitest"; import { chmodSync, mkdirSync, symlinkSync, writeFileSync } from "node:fs"; import * as NodeServices from "@effect/platform-node/NodeServices"; -import os from "node:os"; +import * as NodeOS from "node:os"; import path from "node:path"; import { ProviderDriverKind } from "@t3tools/contracts"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import * as Crypto from "effect/Crypto"; import * as Effect from "effect/Effect"; import { @@ -21,7 +22,7 @@ const driver = (value: string) => ProviderDriverKind.make(value); const makeTempDir = (name: string) => Crypto.Crypto.pipe( Effect.flatMap((crypto) => crypto.randomUUIDv4), - Effect.map((id) => path.join(os.tmpdir(), `${name}-${id}`)), + Effect.map((id) => path.join(NodeOS.tmpdir(), `${name}-${id}`)), ); const isNativeTestCommandPath = (expectedPathSegment: string) => @@ -144,14 +145,17 @@ it.layer(NodeServices.layer)("providerMaintenance", (it) => { writeFileSync(packageToolPath, "#!/bin/sh\n"); chmodSync(packageToolPath, 0o755); - expect( - packageToolUpdate.resolve({ + const capabilities = yield* resolveProviderMaintenanceCapabilitiesEffect( + packageToolUpdate, + { binaryPath: "package-tool", env: { PATH: vitePlusBinDir, }, - }), - ).toEqual({ + }, + ).pipe(Effect.provideService(HostProcessPlatform, "darwin")); + + expect(capabilities).toEqual({ provider: driver("packageTool"), packageName: "@example/package-tool", update: { @@ -176,12 +180,18 @@ it.layer(NodeServices.layer)("providerMaintenance", (it) => { mkdirSync(bunBinDir, { recursive: true }); writeFileSync(path.join(bunBinDir, "native-package-tool.exe"), "MZ"); - expect( - nativePackageToolUpdate.resolve({ + const capabilities = yield* resolveProviderMaintenanceCapabilitiesEffect( + nativePackageToolUpdate, + { binaryPath: "native-package-tool", - resolvedCommandPath: path.join(bunBinDir, "native-package-tool.exe"), - }), - ).toEqual({ + env: { + PATH: bunBinDir, + PATHEXT: ".COM;.EXE;.BAT;.CMD", + }, + }, + ).pipe(Effect.provideService(HostProcessPlatform, "win32")); + + expect(capabilities).toEqual({ provider: driver("nativePackageTool"), packageName: "@example/native-package-tool", update: { @@ -208,14 +218,17 @@ it.layer(NodeServices.layer)("providerMaintenance", (it) => { writeFileSync(scopedPackageToolPath, "#!/bin/sh\n"); chmodSync(scopedPackageToolPath, 0o755); - expect( - scopedPackageToolUpdate.resolve({ + const capabilities = yield* resolveProviderMaintenanceCapabilitiesEffect( + scopedPackageToolUpdate, + { binaryPath: "scoped-package-tool", env: { PATH: pnpmHomeDir, }, - }), - ).toEqual({ + }, + ).pipe(Effect.provideService(HostProcessPlatform, "darwin")); + + expect(capabilities).toEqual({ provider: driver("scopedPackageTool"), packageName: "@example/scoped-package-tool", update: { @@ -265,14 +278,17 @@ it.layer(NodeServices.layer)("providerMaintenance", (it) => { writeFileSync(nativePackageToolPath, "#!/bin/sh\n"); chmodSync(nativePackageToolPath, 0o755); - expect( - nativePackageToolUpdate.resolve({ + const capabilities = yield* resolveProviderMaintenanceCapabilitiesEffect( + nativePackageToolUpdate, + { binaryPath: "native-package-tool", env: { PATH: nativeBinDir, }, - }), - ).toEqual({ + }, + ).pipe(Effect.provideService(HostProcessPlatform, "darwin")); + + expect(capabilities).toEqual({ provider: driver("nativePackageTool"), packageName: "@example/native-package-tool", update: { @@ -299,14 +315,17 @@ it.layer(NodeServices.layer)("providerMaintenance", (it) => { writeFileSync(scopedPackageToolPath, "#!/bin/sh\n"); chmodSync(scopedPackageToolPath, 0o755); - expect( - scopedPackageToolUpdate.resolve({ + const capabilities = yield* resolveProviderMaintenanceCapabilitiesEffect( + scopedPackageToolUpdate, + { binaryPath: "scoped-package-tool", env: { PATH: nativeBinDir, }, - }), - ).toEqual({ + }, + ).pipe(Effect.provideService(HostProcessPlatform, "darwin")); + + expect(capabilities).toEqual({ provider: driver("scopedPackageTool"), packageName: "@example/scoped-package-tool", update: { diff --git a/apps/server/src/provider/providerMaintenance.ts b/apps/server/src/provider/providerMaintenance.ts index 6a867c5a0e0..4e062d47787 100644 --- a/apps/server/src/provider/providerMaintenance.ts +++ b/apps/server/src/provider/providerMaintenance.ts @@ -5,7 +5,7 @@ import { } from "@t3tools/contracts"; import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import { compareSemverVersions } from "@t3tools/shared/semver"; -import { resolveCommandPath, resolveCommandPathForPlatform } from "@t3tools/shared/shell"; +import { resolveCommandPathForPlatform } from "@t3tools/shared/shell"; import * as Config from "effect/Config"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; @@ -272,9 +272,7 @@ export function resolvePackageManagedProviderMaintenance( } const resolvedCommandPath = - options?.resolvedCommandPath ?? - resolveCommandPath(binaryPath, options?.env ? { env: options.env } : {}) ?? - (hasPathSeparator(binaryPath) ? binaryPath : null); + options?.resolvedCommandPath ?? (hasPathSeparator(binaryPath) ? binaryPath : null); if (resolvedCommandPath) { const commandPaths = [ @@ -358,10 +356,11 @@ export const resolveProviderMaintenanceCapabilitiesEffect = Effect.fn( const platform = yield* HostProcessPlatform; const env = options?.env ?? (yield* readCommandLookupEnv); const resolvedCommandPath = - resolveCommandPathForPlatform(binaryPath, { + (yield* resolveCommandPathForPlatform(binaryPath, { platform, 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/provider/providerSnapshot.ts b/apps/server/src/provider/providerSnapshot.ts index ce43c5e6eab..2ecb3220773 100644 --- a/apps/server/src/provider/providerSnapshot.ts +++ b/apps/server/src/provider/providerSnapshot.ts @@ -75,7 +75,7 @@ export const spawnAndCollect = (binaryPath: string, command: ChildProcess.Comman ); const result: CommandResult = { stdout, stderr, code: exitCode }; - if (isWindowsCommandNotFound(exitCode, stderr)) { + if (yield* isWindowsCommandNotFound(exitCode, stderr)) { return yield* new ProviderCommandExecutionError({ message: `spawn ${binaryPath} ENOENT` }); } return result; diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 3a95f906866..cd4e114879c 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -365,7 +365,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/terminal/Layers/BunPTY.ts b/apps/server/src/terminal/Layers/BunPTY.ts index 5fde1469193..82ea1dcb9b9 100644 --- a/apps/server/src/terminal/Layers/BunPTY.ts +++ b/apps/server/src/terminal/Layers/BunPTY.ts @@ -2,6 +2,7 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import { PtyAdapter } from "../Services/PTY.ts"; import type { PtyAdapterShape, PtyExitEvent, PtyProcess } from "../Services/PTY.ts"; @@ -95,7 +96,8 @@ class BunPtyProcess implements PtyProcess { export const layer = Layer.effect( PtyAdapter, Effect.gen(function* () { - if (process.platform === "win32") { + const platform = yield* HostProcessPlatform; + if (platform === "win32") { return yield* Effect.die( "Bun PTY terminal support is unavailable on Windows. Please use Node.js (e.g. by running `npx t3`) instead.", ); diff --git a/apps/server/src/terminal/Layers/Manager.test.ts b/apps/server/src/terminal/Layers/Manager.test.ts index fa5c44e1016..21e67af8368 100644 --- a/apps/server/src/terminal/Layers/Manager.test.ts +++ b/apps/server/src/terminal/Layers/Manager.test.ts @@ -282,8 +282,6 @@ it.layer( Layer.merge(NodeServices.layer, ProcessRunner.layer.pipe(Layer.provide(NodeServices.layer))), { excludeTestServices: true }, )("TerminalManager", (it) => { - const itEffectSkipOnWindows = process.platform === "win32" ? it.effect.skip : it.effect; - it.effect("spawns lazily and reuses running terminal per thread", () => Effect.gen(function* () { const { manager, ptyAdapter } = yield* createManager(); @@ -423,8 +421,10 @@ it.layer( fs.writeFileString(filePath, contents), ); - itEffectSkipOnWindows("preserves non-notFound cwd stat failures", () => + it.effect("preserves non-notFound cwd stat failures", () => Effect.gen(function* () { + if ((yield* HostProcessPlatform) === "win32") return; + const path = yield* Path.Path; const { manager, baseDir } = yield* createManager(); @@ -1090,10 +1090,9 @@ it.layer( it.effect("retries with fallback shells when preferred shell spawn fails", () => Effect.gen(function* () { + const platform = yield* HostProcessPlatform; const missingShell = - process.platform === "win32" - ? "C:\\definitely\\missing-shell.exe" - : "/definitely/missing-shell -l"; + platform === "win32" ? "C:\\definitely\\missing-shell.exe" : "/definitely/missing-shell -l"; const { manager, ptyAdapter } = yield* createManager(5, { shellResolver: () => missingShell, }); @@ -1104,10 +1103,10 @@ it.layer( assert.equal(snapshot.status, "running"); expect(ptyAdapter.spawnInputs.length).toBeGreaterThanOrEqual(2); expect(ptyAdapter.spawnInputs[0]?.shell).toBe( - process.platform === "win32" ? missingShell : "/definitely/missing-shell", + platform === "win32" ? missingShell : "/definitely/missing-shell", ); - if (process.platform === "win32") { + if (platform === "win32") { expect( ptyAdapter.spawnInputs.some( (input) => @@ -1235,7 +1234,7 @@ it.layer( it.effect("starts zsh with prompt spacer disabled to avoid `%` end markers", () => Effect.gen(function* () { - if (process.platform === "win32") return; + if ((yield* HostProcessPlatform) === "win32") return; const { manager, ptyAdapter } = yield* createManager(5, { shellResolver: () => "/bin/zsh", }); diff --git a/apps/server/src/terminal/Layers/NodePTY.test.ts b/apps/server/src/terminal/Layers/NodePTY.test.ts index 15d24360f7e..2730f47c8aa 100644 --- a/apps/server/src/terminal/Layers/NodePTY.test.ts +++ b/apps/server/src/terminal/Layers/NodePTY.test.ts @@ -5,11 +5,12 @@ import { assert, it } from "@effect/vitest"; import { ensureNodePtySpawnHelperExecutable } from "./NodePTY.ts"; import * as NodeServices from "@effect/platform-node/NodeServices"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; it.layer(NodeServices.layer)("ensureNodePtySpawnHelperExecutable", (it) => { it.effect("adds executable bits when helper exists but is not executable", () => Effect.gen(function* () { - if (process.platform === "win32") return; + if ((yield* HostProcessPlatform) === "win32") return; const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -19,7 +20,9 @@ it.layer(NodeServices.layer)("ensureNodePtySpawnHelperExecutable", (it) => { yield* fs.writeFileString(helperPath, "#!/bin/sh\nexit 0\n"); yield* fs.chmod(helperPath, 0o644); - yield* ensureNodePtySpawnHelperExecutable(helperPath); + yield* ensureNodePtySpawnHelperExecutable(helperPath).pipe( + Effect.provideService(HostProcessPlatform, "linux"), + ); const mode = (yield* fs.stat(helperPath)).mode & 0o777; assert.equal(mode & 0o111, 0o111); @@ -28,7 +31,7 @@ it.layer(NodeServices.layer)("ensureNodePtySpawnHelperExecutable", (it) => { it.effect("keeps executable helper as executable", () => Effect.gen(function* () { - if (process.platform === "win32") return; + if ((yield* HostProcessPlatform) === "win32") return; const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -38,7 +41,9 @@ it.layer(NodeServices.layer)("ensureNodePtySpawnHelperExecutable", (it) => { yield* fs.writeFileString(helperPath, "#!/bin/sh\nexit 0\n"); yield* fs.chmod(helperPath, 0o755); - yield* ensureNodePtySpawnHelperExecutable(helperPath); + yield* ensureNodePtySpawnHelperExecutable(helperPath).pipe( + Effect.provideService(HostProcessPlatform, "linux"), + ); const mode = (yield* fs.stat(helperPath)).mode & 0o777; assert.equal(mode & 0o111, 0o111); diff --git a/apps/server/src/terminal/Layers/NodePTY.ts b/apps/server/src/terminal/Layers/NodePTY.ts index c81d76f5d1e..a684db6b4a2 100644 --- a/apps/server/src/terminal/Layers/NodePTY.ts +++ b/apps/server/src/terminal/Layers/NodePTY.ts @@ -4,6 +4,7 @@ import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; +import { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/hostProcess"; import { PtyAdapter } from "../Services/PTY.ts"; import { PtySpawnError, @@ -18,13 +19,15 @@ const resolveNodePtySpawnHelperPath = Effect.gen(function* () { const requireForNodePty = createRequire(import.meta.url); const path = yield* Path.Path; const fs = yield* FileSystem.FileSystem; + const platform = yield* HostProcessPlatform; + const architecture = yield* HostProcessArchitecture; const packageJsonPath = requireForNodePty.resolve("node-pty/package.json"); const packageDir = path.dirname(packageJsonPath); const candidates = [ path.join(packageDir, "build", "Release", "spawn-helper"), path.join(packageDir, "build", "Debug", "spawn-helper"), - path.join(packageDir, "prebuilds", `${process.platform}-${process.arch}`, "spawn-helper"), + path.join(packageDir, "prebuilds", `${platform}-${architecture}`, "spawn-helper"), ]; for (const candidate of candidates) { @@ -37,7 +40,8 @@ const resolveNodePtySpawnHelperPath = Effect.gen(function* () { export const ensureNodePtySpawnHelperExecutable = Effect.fn(function* (explicitPath?: string) { const fs = yield* FileSystem.FileSystem; - if (process.platform === "win32") return; + const platform = yield* HostProcessPlatform; + if (platform === "win32") return; if (!explicitPath && didEnsureSpawnHelperExecutable) return; const helperPath = explicitPath ?? (yield* resolveNodePtySpawnHelperPath); @@ -102,6 +106,7 @@ export const layer = Layer.effect( Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; + const platform = yield* HostProcessPlatform; const nodePty = yield* Effect.promise(() => import("node-pty")); @@ -123,7 +128,7 @@ export const layer = Layer.effect( cols: input.cols, rows: input.rows, env: input.env, - name: globalThis.process.platform === "win32" ? "xterm-color" : "xterm-256color", + name: platform === "win32" ? "xterm-color" : "xterm-256color", }), catch: (cause) => new PtySpawnError({ diff --git a/apps/server/src/workspace/Layers/WorkspaceEntries.test.ts b/apps/server/src/workspace/Layers/WorkspaceEntries.test.ts index ffee4d56a52..1a4fa43a589 100644 --- a/apps/server/src/workspace/Layers/WorkspaceEntries.test.ts +++ b/apps/server/src/workspace/Layers/WorkspaceEntries.test.ts @@ -10,6 +10,7 @@ import * as Path from "effect/Path"; import * as PlatformError from "effect/PlatformError"; import { ServerConfig } from "../../config.ts"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import * as VcsDriverRegistry from "../../vcs/VcsDriverRegistry.ts"; import * as VcsProcess from "../../vcs/VcsProcess.ts"; import { WorkspaceEntries } from "../Services/WorkspaceEntries.ts"; @@ -75,9 +76,11 @@ const searchWorkspaceEntries = (input: { cwd: string; query: string; limit: numb }); const appendSeparator = (input: string) => - input.endsWith("/") || input.endsWith("\\") - ? input - : `${input}${process.platform === "win32" ? "\\" : "/"}`; + Effect.map(HostProcessPlatform, (platform) => + input.endsWith("/") || input.endsWith("\\") + ? input + : `${input}${platform === "win32" ? "\\" : "/"}`, + ); it.layer(TestLayer)("WorkspaceEntriesLive", (it) => { afterEach(() => { @@ -344,12 +347,13 @@ it.layer(TestLayer)("WorkspaceEntriesLive", (it) => { const cwd = yield* makeTempDir({ prefix: "t3code-workspace-browse-hidden-" }); yield* writeTextFile(cwd, ".config/settings.json", "{}"); yield* writeTextFile(cwd, "config/settings.json", "{}"); + const cwdWithSeparator = yield* appendSeparator(cwd); const directoryResult = yield* workspaceEntries.browse({ - partialPath: appendSeparator(cwd), + partialPath: cwdWithSeparator, }); const hiddenPrefixResult = yield* workspaceEntries.browse({ - partialPath: `${appendSeparator(cwd)}.c`, + partialPath: `${cwdWithSeparator}.c`, }); expect(directoryResult.entries.map((entry) => entry.name)).toEqual([".config", "config"]); diff --git a/apps/server/src/workspace/Layers/WorkspaceEntries.ts b/apps/server/src/workspace/Layers/WorkspaceEntries.ts index 95d957136b7..605c0bb76e9 100644 --- a/apps/server/src/workspace/Layers/WorkspaceEntries.ts +++ b/apps/server/src/workspace/Layers/WorkspaceEntries.ts @@ -12,6 +12,7 @@ import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; import { type FilesystemBrowseInput, type ProjectEntry } from "@t3tools/contracts"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import { isExplicitRelativePath, isWindowsAbsolutePath } from "@t3tools/shared/path"; import { insertRankedSearchResult, @@ -155,7 +156,8 @@ const resolveBrowseTarget = ( pathService: Path.Path, ): Effect.Effect => Effect.gen(function* () { - if (process.platform !== "win32" && isWindowsAbsolutePath(input.partialPath)) { + const platform = yield* HostProcessPlatform; + if (platform !== "win32" && isWindowsAbsolutePath(input.partialPath)) { return yield* new WorkspaceEntriesBrowseError({ cwd: input.cwd, partialPath: input.partialPath, diff --git a/oxlint-plugin-t3code/rules/no-global-process-runtime.test.ts b/oxlint-plugin-t3code/rules/no-global-process-runtime.test.ts index 5b091815fde..dc9cf6979a7 100644 --- a/oxlint-plugin-t3code/rules/no-global-process-runtime.test.ts +++ b/oxlint-plugin-t3code/rules/no-global-process-runtime.test.ts @@ -23,6 +23,15 @@ describe("t3code/no-global-process-runtime", () => { `, ); + rule.valid( + "allows unrelated node os imports", + ` + import { tmpdir } from "node:os"; + + export const tempDirectory = tmpdir(); + `, + ); + rule.invalid( "reports direct platform reads", ` @@ -49,4 +58,37 @@ describe("t3code/no-global-process-runtime", () => { export const terminalName = globalThis.process.platform === "win32" ? "xterm-color" : "xterm-256color"; `, ); + + rule.invalid( + "reports node os namespace platform reads", + ` + import * as NodeOS from "node:os"; + + export const isWindows = NodeOS.platform() === "win32"; + `, + (output) => { + assert.match(output, /Use HostProcessPlatform/); + }, + ); + + rule.invalid( + "reports renamed node os architecture imports", + ` + import { arch as hostArch } from "node:os"; + + export const isArm = hostArch() === "arm64"; + `, + (output) => { + assert.match(output, /Use HostProcessArchitecture/); + }, + ); + + rule.invalid( + "reports default node os platform reads", + ` + import os from "node:os"; + + export const isWindows = os.platform() === "win32"; + `, + ); }); diff --git a/oxlint-plugin-t3code/rules/no-global-process-runtime.ts b/oxlint-plugin-t3code/rules/no-global-process-runtime.ts index e147f0c4ceb..e364d29040f 100644 --- a/oxlint-plugin-t3code/rules/no-global-process-runtime.ts +++ b/oxlint-plugin-t3code/rules/no-global-process-runtime.ts @@ -5,15 +5,7 @@ import { getPropertyName, isIdentifier, unwrapExpression } from "../utils.ts"; const RUNTIME_PROPERTIES = new Set(["platform", "arch"]); const HOST_PROCESS_REFERENCE_FILE = "packages/shared/src/hostProcess.ts"; -const SCOPED_RUNTIME_MODULE_PREFIXES = [ - "apps/server/src/process/externalLauncher.ts", - "apps/server/src/provider/", - "apps/server/src/textGeneration/", - "packages/ssh/src/", - "scripts/build-desktop-artifact.ts", - "scripts/dev-runner.ts", - "scripts/lib/build-target-arch.ts", -] as const; +const NODE_OS_MODULES = new Set(["node:os", "os"]); const normalizePath = (path: string) => path.replaceAll("\\", "/"); @@ -29,15 +21,6 @@ const toRepoPath = (filename: string, cwd: string) => { const isHostProcessReferenceFile = (filename: string, cwd: string) => toRepoPath(filename, cwd) === HOST_PROCESS_REFERENCE_FILE; -const shouldCheckFile = (filename: string, cwd: string) => { - if (normalizePath(filename).endsWith("/fixture.ts")) return true; - - const repoPath = toRepoPath(filename, cwd); - if (repoPath.endsWith(".test.ts") || repoPath.includes("/test/")) return false; - - return SCOPED_RUNTIME_MODULE_PREFIXES.some((prefix) => repoPath.startsWith(prefix)); -}; - const isGlobalProcessObject = (node: unknown): boolean => { const expression = unwrapExpression(node); if (isIdentifier(expression, "process")) return true; @@ -53,6 +36,13 @@ const isGlobalProcessObject = (node: unknown): boolean => { const message = (property: string) => `Use HostProcess${property === "arch" ? "Architecture" : "Platform"} instead of process.${property}; inject the runtime reference in Effect code and provide it explicitly in tests.`; +const getLiteralStringValue = (node: unknown): Option.Option => { + if (typeof node !== "object" || node === null) return Option.none(); + if (!("type" in node) || node.type !== "Literal") return Option.none(); + if (!("value" in node) || typeof node.value !== "string") return Option.none(); + return Option.some(node.value); +}; + export default defineRule({ meta: { type: "problem", @@ -62,15 +52,88 @@ export default defineRule({ }, }, createOnce(context) { + const nodeOsNamespaces = new Set(); + const nodeOsRuntimeImports = new Map(); + + const resetBindings = () => { + nodeOsNamespaces.clear(); + nodeOsRuntimeImports.clear(); + }; + + const trackImportDeclaration = (node: unknown) => { + if (typeof node !== "object" || node === null) return; + if (!("source" in node)) return; + + const source = getLiteralStringValue(node.source); + if (Option.isNone(source) || !NODE_OS_MODULES.has(source.value)) return; + if (!("specifiers" in node) || !Array.isArray(node.specifiers)) return; + + for (const specifier of node.specifiers) { + if (typeof specifier !== "object" || specifier === null) continue; + if (!("local" in specifier)) continue; + + const local = unwrapExpression(specifier.local); + if (Option.isNone(local) || local.value.type !== "Identifier") continue; + const localName = local.value.name; + + if ( + specifier.type === "ImportNamespaceSpecifier" || + specifier.type === "ImportDefaultSpecifier" + ) { + nodeOsNamespaces.add(localName); + continue; + } + + if (specifier.type !== "ImportSpecifier" || !("imported" in specifier)) continue; + + const imported = getPropertyName(specifier.imported); + if (Option.isSome(imported) && RUNTIME_PROPERTIES.has(imported.value)) { + nodeOsRuntimeImports.set(localName, imported.value); + } + } + }; + + const getNodeOsRuntimeCall = (callee: unknown): Option.Option => { + const expression = unwrapExpression(callee); + if (Option.isNone(expression)) return Option.none(); + + if (expression.value.type === "Identifier") { + const property = nodeOsRuntimeImports.get(expression.value.name); + return property === undefined ? Option.none() : Option.some(property); + } + + if (expression.value.type !== "MemberExpression") return Option.none(); + + const object = unwrapExpression(expression.value.object); + if (Option.isNone(object) || object.value.type !== "Identifier") return Option.none(); + if (!nodeOsNamespaces.has(object.value.name)) return Option.none(); + + return Option.filter(getPropertyName(expression.value.property), (property) => + RUNTIME_PROPERTIES.has(property), + ); + }; + return { + before: resetBindings, + ImportDeclaration: trackImportDeclaration, MemberExpression(node) { if (isHostProcessReferenceFile(context.filename, context.cwd)) return; - if (!shouldCheckFile(context.filename, context.cwd)) return; const property = getPropertyName(node.property); if (Option.isNone(property) || !RUNTIME_PROPERTIES.has(property.value)) return; if (!isGlobalProcessObject(node.object)) return; + context.report({ + node, + message: message(property.value), + }); + }, + CallExpression(node) { + if (isHostProcessReferenceFile(context.filename, context.cwd)) return; + + const property = getNodeOsRuntimeCall(node.callee); + if (Option.isNone(property)) return; + context.report({ node, message: message(property.value), diff --git a/packages/effect-acp/package.json b/packages/effect-acp/package.json index 4455dd460e7..82c54ffc48f 100644 --- a/packages/effect-acp/package.json +++ b/packages/effect-acp/package.json @@ -44,6 +44,7 @@ "@effect/openapi-generator": "catalog:", "@effect/platform-node": "catalog:", "@effect/vitest": "catalog:", + "@t3tools/shared": "workspace:*", "@types/node": "catalog:", "vite-plus": "catalog:" } diff --git a/packages/effect-acp/src/client.test.ts b/packages/effect-acp/src/client.test.ts index aca87d45c62..30ff39b9171 100644 --- a/packages/effect-acp/src/client.test.ts +++ b/packages/effect-acp/src/client.test.ts @@ -13,6 +13,7 @@ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { it, assert } from "@effect/vitest"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import * as AcpClient from "./client.ts"; import * as AcpSchema from "./_generated/schema.gen.ts"; @@ -34,8 +35,10 @@ it.layer(NodeServices.layer)("effect-acp client", (it) => { Effect.gen(function* () { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const path = yield* Path.Path; + const platform = yield* HostProcessPlatform; const command = ChildProcess.make(process.execPath, mockPeerArgs(yield* mockPeerPath), { cwd: path.join(import.meta.dirname, ".."), + shell: platform === "win32", ...(env ? { env: { ...process.env, ...env } } : {}), }); return yield* spawner.spawn(command); diff --git a/packages/effect-acp/src/protocol.test.ts b/packages/effect-acp/src/protocol.test.ts index 093d4acfcfa..ce50304e7d9 100644 --- a/packages/effect-acp/src/protocol.test.ts +++ b/packages/effect-acp/src/protocol.test.ts @@ -11,6 +11,7 @@ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { it, assert } from "@effect/vitest"; import * as NodeServices from "@effect/platform-node/NodeServices"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import * as AcpSchema from "./_generated/schema.gen.ts"; import * as AcpProtocol from "./protocol.ts"; @@ -57,8 +58,10 @@ const makeHandle = (env?: Record) => Effect.gen(function* () { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const path = yield* Path.Path; + const platform = yield* HostProcessPlatform; const command = ChildProcess.make(process.execPath, mockPeerArgs(yield* mockPeerPath), { cwd: path.join(import.meta.dirname, ".."), + shell: platform === "win32", ...(env ? { env: { ...process.env, ...env } } : {}), }); return yield* spawner.spawn(command); diff --git a/packages/effect-acp/test/examples/cursor-acp-client.example.ts b/packages/effect-acp/test/examples/cursor-acp-client.example.ts index f730c3dbde0..5ef80333d53 100644 --- a/packages/effect-acp/test/examples/cursor-acp-client.example.ts +++ b/packages/effect-acp/test/examples/cursor-acp-client.example.ts @@ -4,14 +4,16 @@ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import * as NodeServices from "@effect/platform-node/NodeServices"; import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import * as AcpClient from "../../src/client.ts"; const program = Effect.gen(function* () { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const platform = yield* HostProcessPlatform; const command = ChildProcess.make("cursor-agent", ["acp"], { cwd: process.cwd(), - shell: process.platform === "win32", + shell: platform === "win32", }); const handle = yield* spawner.spawn(command); const acpLayer = AcpClient.layerChildProcess(handle, { diff --git a/packages/effect-codex-app-server/package.json b/packages/effect-codex-app-server/package.json index a067976c616..ea88887e7de 100644 --- a/packages/effect-codex-app-server/package.json +++ b/packages/effect-codex-app-server/package.json @@ -31,6 +31,7 @@ "probe": "node test/examples/codex-app-server-probe.ts" }, "dependencies": { + "@t3tools/shared": "workspace:*", "effect": "catalog:" }, "devDependencies": { diff --git a/packages/effect-codex-app-server/src/client.test.ts b/packages/effect-codex-app-server/src/client.test.ts index 8d301742a50..4aaf267da4c 100644 --- a/packages/effect-codex-app-server/src/client.test.ts +++ b/packages/effect-codex-app-server/src/client.test.ts @@ -8,6 +8,7 @@ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, it } from "@effect/vitest"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import * as CodexClient from "./client.ts"; @@ -21,9 +22,11 @@ it.layer(NodeServices.layer)("effect-codex-app-server client", (it) => { Effect.gen(function* () { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const path = yield* Path.Path; + const platform = yield* HostProcessPlatform; const peerCwd = path.join(import.meta.dirname, ".."); const command = ChildProcess.make(process.execPath, mockPeerArgs(yield* mockPeerPath), { cwd: peerCwd, + shell: platform === "win32", }); return yield* spawner.spawn(command); }); diff --git a/packages/effect-codex-app-server/src/client.ts b/packages/effect-codex-app-server/src/client.ts index edc7d8b6cf7..70f678d2dd0 100644 --- a/packages/effect-codex-app-server/src/client.ts +++ b/packages/effect-codex-app-server/src/client.ts @@ -6,6 +6,7 @@ import * as Scope from "effect/Scope"; import * as Stdio from "effect/Stdio"; import * as Stream from "effect/Stream"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import * as CodexRpc from "./_generated/meta.gen.ts"; import * as CodexError from "./errors.ts"; @@ -282,11 +283,12 @@ export const layerCommand = ( CodexAppServerClient, Effect.gen(function* () { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const platform = yield* HostProcessPlatform; const command = ChildProcess.make(options.command, [...(options.args ?? [])], { ...(options.cwd ? { cwd: options.cwd } : {}), ...(options.env ? { env: { ...process.env, ...options.env } } : {}), forceKillAfter: DEFAULT_APP_SERVER_FORCE_KILL_AFTER, - shell: process.platform === "win32", + shell: platform === "win32", }); return yield* spawner.spawn(command).pipe( Effect.mapError( diff --git a/packages/effect-codex-app-server/test/fixtures/codex-app-server-mock-peer.ts b/packages/effect-codex-app-server/test/fixtures/codex-app-server-mock-peer.ts index f04f43dd70f..3f2a213d38c 100644 --- a/packages/effect-codex-app-server/test/fixtures/codex-app-server-mock-peer.ts +++ b/packages/effect-codex-app-server/test/fixtures/codex-app-server-mock-peer.ts @@ -1,3 +1,5 @@ +import * as NodeOS from "node:os"; + let nextServerRequestId = 10_000; let pendingSkillsListRequestId: number | string | null = null; let pendingUserInputRequestId: number | null = null; @@ -34,14 +36,16 @@ const handleMethod = (message: Record) => { switch (method) { case "initialize": { + // oxlint-disable-next-line t3code/no-global-process-runtime -- Standalone mock peer process has no Effect runtime. + const platform = NodeOS.platform(); const stderrBytes = Number(process.env.CODEX_APP_SERVER_TEST_STDERR_BYTES ?? 0); if (Number.isFinite(stderrBytes) && stderrBytes > 0) { process.stderr.write("x".repeat(stderrBytes), () => { respond(message.id as number | string, { userAgent: "mock-codex-app-server", codexHome: process.cwd(), - platformFamily: process.platform === "win32" ? "windows" : "unix", - platformOs: process.platform === "darwin" ? "macos" : process.platform, + platformFamily: platform === "win32" ? "windows" : "unix", + platformOs: platform === "darwin" ? "macos" : platform, }); }); return; @@ -49,8 +53,8 @@ const handleMethod = (message: Record) => { respond(message.id as number | string, { userAgent: "mock-codex-app-server", codexHome: process.cwd(), - platformFamily: process.platform === "win32" ? "windows" : "unix", - platformOs: process.platform === "darwin" ? "macos" : process.platform, + platformFamily: platform === "win32" ? "windows" : "unix", + platformOs: platform === "darwin" ? "macos" : platform, }); return; } diff --git a/packages/shared/src/relayClient.ts b/packages/shared/src/relayClient.ts index 35d002466e9..82d003eb776 100644 --- a/packages/shared/src/relayClient.ts +++ b/packages/shared/src/relayClient.ts @@ -18,6 +18,7 @@ import * as PlatformError from "effect/PlatformError"; import * as Semaphore from "effect/Semaphore"; import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; +import { HostProcessArchitecture, HostProcessPlatform } from "./hostProcess.ts"; export const CLOUDFLARED_VERSION = "2026.5.2"; export const CLOUDFLARED_PATH_ENV_NAME = "T3CODE_CLOUDFLARED_PATH"; @@ -205,8 +206,8 @@ export const makeCloudflaredRelayClient = Effect.fn("cloudflared.make")(function const path = yield* Path.Path; const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const installSemaphore = yield* Semaphore.make(1); - const platform = options.platform ?? process.platform; - const arch = options.arch ?? process.arch; + const platform = options.platform ?? (yield* HostProcessPlatform); + const arch = options.arch ?? (yield* HostProcessArchitecture); const releaseAsset = options.releaseAsset ?? resolveReleaseAsset(platform, arch); const loadCloudflaredConfig = Effect.suspend(() => CloudflaredConfig.pipe( diff --git a/packages/shared/src/shell.test.ts b/packages/shared/src/shell.test.ts index ec16792bb60..0b3164cb23e 100644 --- a/packages/shared/src/shell.test.ts +++ b/packages/shared/src/shell.test.ts @@ -1,3 +1,6 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { it as effectIt } from "@effect/vitest"; +import * as Effect from "effect/Effect"; import { describe, expect, it, vi } from "vite-plus/test"; import { @@ -322,147 +325,161 @@ describe("resolveKnownWindowsCliDirs", () => { }); }); -describe("isCommandAvailable", () => { - it("returns false when PATH is empty", () => { - expect( - isCommandAvailableForPlatform("definitely-not-installed", { - platform: "win32", - env: { PATH: "", PATHEXT: ".COM;.EXE;.BAT;.CMD" }, - }), - ).toBe(false); - }); +effectIt.layer(NodeServices.layer)("isCommandAvailable", (it) => { + it.effect("returns false when PATH is empty", () => + Effect.gen(function* () { + expect( + yield* isCommandAvailableForPlatform("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( - resolveCommandPathForPlatform("definitely-not-installed", { +effectIt.layer(NodeServices.layer)("resolveCommandPath", (it) => { + it.effect("fails when PATH is empty", () => + Effect.gen(function* () { + const result = yield* resolveCommandPathForPlatform("definitely-not-installed", { platform: "win32", env: { PATH: "", PATHEXT: ".COM;.EXE;.BAT;.CMD" }, - }), - ).toBeNull(); - }); -}); - -describe("resolveWindowsEnvironment", () => { - it("returns the baseline no-profile PATH patch when node is already available", () => { - 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); + }).pipe(Effect.result); - expect( - 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: [ - "C:\\Users\\testuser\\AppData\\Roaming\\npm", - "C:\\Users\\testuser\\AppData\\Local\\Programs\\nodejs", - "C:\\Users\\testuser\\AppData\\Local\\Volta\\bin", - "C:\\Users\\testuser\\AppData\\Local\\pnpm", - "C:\\Users\\testuser\\.bun\\bin", - "C:\\Users\\testuser\\scoop\\shims", - "C:\\Shell\\Bin", - "C:\\Windows\\System32", - ].join(";"), - }); - expect(readEnvironment).toHaveBeenCalledTimes(1); - expect(readEnvironment).toHaveBeenCalledWith(["PATH"], { loadProfile: false }); - expect(commandAvailable).toHaveBeenCalledWith( - "node", - expect.objectContaining({ env: expect.any(Object) }), - ); - }); - - it("loads the PowerShell profile when baseline env cannot resolve node", () => { - const readEnvironment = vi.fn( - (_names: ReadonlyArray, options?: { loadProfile?: boolean }) => - options?.loadProfile - ? { - PATH: "C:\\Profile\\Node;C:\\Windows\\System32", - FNM_DIR: "C:\\Users\\testuser\\AppData\\Roaming\\fnm", - FNM_MULTISHELL_PATH: "C:\\Users\\testuser\\AppData\\Local\\fnm_multishells\\123", - } - : { PATH: "C:\\Shell\\Bin;C:\\Windows\\System32" }, - ); - const commandAvailable = vi.fn(() => 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, - }, - ), - ).toEqual({ - PATH: [ - "C:\\Profile\\Node", - "C:\\Windows\\System32", - "C:\\Users\\testuser\\AppData\\Roaming\\npm", - "C:\\Users\\testuser\\AppData\\Local\\Programs\\nodejs", - "C:\\Users\\testuser\\AppData\\Local\\Volta\\bin", - "C:\\Users\\testuser\\AppData\\Local\\pnpm", - "C:\\Users\\testuser\\.bun\\bin", - "C:\\Users\\testuser\\scoop\\shims", - "C:\\Shell\\Bin", - ].join(";"), - FNM_DIR: "C:\\Users\\testuser\\AppData\\Roaming\\fnm", - FNM_MULTISHELL_PATH: "C:\\Users\\testuser\\AppData\\Local\\fnm_multishells\\123", - }); - expect(readEnvironment).toHaveBeenNthCalledWith(1, ["PATH"], { loadProfile: false }); - expect(readEnvironment).toHaveBeenNthCalledWith(2, ["PATH", "FNM_DIR", "FNM_MULTISHELL_PATH"], { - loadProfile: true, - }); - expect(commandAvailable).toHaveBeenCalledTimes(1); - }); - - it("keeps the baseline env when profiled probe still does not resolve node", () => { - const readEnvironment = vi.fn( - (_names: ReadonlyArray, options?: { loadProfile?: boolean }) => - options?.loadProfile ? { FNM_DIR: "C:\\Users\\testuser\\AppData\\Roaming\\fnm" } : {}, - ); - const commandAvailable = vi.fn(() => false); + expect(result._tag).toBe("Failure"); + }), + ); +}); - expect( - resolveWindowsEnvironment( - { - PATH: "C:\\Windows\\System32", - APPDATA: "C:\\Users\\testuser\\AppData\\Roaming", - USERPROFILE: "C:\\Users\\testuser", - }, +effectIt.layer(NodeServices.layer)("resolveWindowsEnvironment", (it) => { + it.effect("returns the baseline no-profile PATH patch when node is already available", () => + Effect.gen(function* () { + 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(() => Effect.succeed(true)); + + expect( + yield* 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: [ + "C:\\Users\\testuser\\AppData\\Roaming\\npm", + "C:\\Users\\testuser\\AppData\\Local\\Programs\\nodejs", + "C:\\Users\\testuser\\AppData\\Local\\Volta\\bin", + "C:\\Users\\testuser\\AppData\\Local\\pnpm", + "C:\\Users\\testuser\\.bun\\bin", + "C:\\Users\\testuser\\scoop\\shims", + "C:\\Shell\\Bin", + "C:\\Windows\\System32", + ].join(";"), + }); + expect(readEnvironment).toHaveBeenCalledTimes(1); + expect(readEnvironment).toHaveBeenCalledWith(["PATH"], { loadProfile: false }); + expect(commandAvailable).toHaveBeenCalledWith( + "node", + expect.objectContaining({ platform: "win32", env: expect.any(Object) }), + ); + }), + ); + + it.effect("loads the PowerShell profile when baseline env cannot resolve node", () => + Effect.gen(function* () { + const readEnvironment = vi.fn( + (_names: ReadonlyArray, options?: { loadProfile?: boolean }) => + options?.loadProfile + ? { + PATH: "C:\\Profile\\Node;C:\\Windows\\System32", + FNM_DIR: "C:\\Users\\testuser\\AppData\\Roaming\\fnm", + FNM_MULTISHELL_PATH: "C:\\Users\\testuser\\AppData\\Local\\fnm_multishells\\123", + } + : { PATH: "C:\\Shell\\Bin;C:\\Windows\\System32" }, + ); + const commandAvailable = vi.fn(() => Effect.succeed(false)); + + expect( + yield* 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: [ + "C:\\Profile\\Node", + "C:\\Windows\\System32", + "C:\\Users\\testuser\\AppData\\Roaming\\npm", + "C:\\Users\\testuser\\AppData\\Local\\Programs\\nodejs", + "C:\\Users\\testuser\\AppData\\Local\\Volta\\bin", + "C:\\Users\\testuser\\AppData\\Local\\pnpm", + "C:\\Users\\testuser\\.bun\\bin", + "C:\\Users\\testuser\\scoop\\shims", + "C:\\Shell\\Bin", + ].join(";"), + FNM_DIR: "C:\\Users\\testuser\\AppData\\Roaming\\fnm", + FNM_MULTISHELL_PATH: "C:\\Users\\testuser\\AppData\\Local\\fnm_multishells\\123", + }); + expect(readEnvironment).toHaveBeenNthCalledWith(1, ["PATH"], { loadProfile: false }); + expect(readEnvironment).toHaveBeenNthCalledWith( + 2, + ["PATH", "FNM_DIR", "FNM_MULTISHELL_PATH"], { - readEnvironment, - commandAvailable, + loadProfile: true, }, - ), - ).toEqual({ - PATH: [ - "C:\\Users\\testuser\\AppData\\Roaming\\npm", - "C:\\Users\\testuser\\.bun\\bin", - "C:\\Users\\testuser\\scoop\\shims", - "C:\\Windows\\System32", - ].join(";"), - FNM_DIR: "C:\\Users\\testuser\\AppData\\Roaming\\fnm", - }); - expect(commandAvailable).toHaveBeenCalledTimes(1); - }); + ); + expect(commandAvailable).toHaveBeenCalledTimes(1); + }), + ); + + it.effect("keeps the baseline env when profiled probe still does not resolve node", () => + Effect.gen(function* () { + const readEnvironment = vi.fn( + (_names: ReadonlyArray, options?: { loadProfile?: boolean }) => + options?.loadProfile ? { FNM_DIR: "C:\\Users\\testuser\\AppData\\Roaming\\fnm" } : {}, + ); + const commandAvailable = vi.fn(() => Effect.succeed(false)); + + expect( + yield* resolveWindowsEnvironment( + { + PATH: "C:\\Windows\\System32", + APPDATA: "C:\\Users\\testuser\\AppData\\Roaming", + USERPROFILE: "C:\\Users\\testuser", + }, + { + readEnvironment, + commandAvailable, + }, + ), + ).toEqual({ + PATH: [ + "C:\\Users\\testuser\\AppData\\Roaming\\npm", + "C:\\Users\\testuser\\.bun\\bin", + "C:\\Users\\testuser\\scoop\\shims", + "C:\\Windows\\System32", + ].join(";"), + FNM_DIR: "C:\\Users\\testuser\\AppData\\Roaming\\fnm", + }); + expect(commandAvailable).toHaveBeenCalledTimes(1); + }), + ); }); diff --git a/packages/shared/src/shell.ts b/packages/shared/src/shell.ts index 7043b516368..ea3095d4249 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__"; @@ -27,8 +29,13 @@ export interface PlatformCommandAvailabilityOptions extends CommandAvailabilityO export type CommandAvailabilityChecker = ( command: string, - options?: CommandAvailabilityOptions, -) => boolean; + options: PlatformCommandAvailabilityOptions, +) => Effect.Effect; + +export class CommandResolutionError extends Data.TaggedError("CommandResolutionError")<{ + readonly command: string; + readonly reason: "not-found"; +}> {} export interface WindowsEnvironmentProbeOptions { readonly loadProfile?: boolean; @@ -342,6 +349,7 @@ function resolveCommandCandidates( command: string, platform: NodeJS.Platform, windowsPathExtensions: ReadonlyArray, + extname: (path: string) => string, ): ReadonlyArray { if (platform !== "win32") return [command]; const extension = extname(command); @@ -366,88 +374,88 @@ 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 { - return false; +): 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; + + if (platform === "win32") { + const extension = path.extname(filePath); + if (extension.length === 0) return false; + return windowsPathExtensions.includes(extension.toUpperCase()); } -} -export function resolveCommandPath( - command: string, - options: CommandAvailabilityOptions = {}, -): string | null { - return resolveCommandPathForPlatform(command, { - platform: process.platform, - ...(options.env ? { env: options.env } : {}), - }); -} + if (stat.mode === undefined) { + return true; + } + return (stat.mode & 0o111) !== 0; +}); + +export const resolveCommandPathForPlatform = Effect.fn("shell.resolveCommandPathForPlatform")( + function* ( + command: string, + options: PlatformCommandAvailabilityOptions, + ): Effect.fn.Return { + const path = yield* Path.Path; + const platform = options.platform; + const env = options.env ?? process.env; + const windowsPathExtensions = platform === "win32" ? resolveWindowsPathExtensions(env) : []; + const commandCandidates = resolveCommandCandidates( + command, + platform, + windowsPathExtensions, + path.extname, + ); -export function resolveCommandPathForPlatform( - command: string, - options: PlatformCommandAvailabilityOptions, -): string | null { - const platform = options.platform; - const env = options.env ?? process.env; - const windowsPathExtensions = platform === "win32" ? resolveWindowsPathExtensions(env) : []; - const commandCandidates = resolveCommandCandidates(command, platform, windowsPathExtensions); - - if (command.includes("/") || command.includes("\\")) { - for (const candidate of commandCandidates) { - if (isExecutableFile(candidate, platform, windowsPathExtensions)) { - return candidate; + if (command.includes("/") || command.includes("\\")) { + for (const candidate of commandCandidates) { + if (yield* isExecutableFile(candidate, platform, windowsPathExtensions)) { + return candidate; + } } + return yield* new CommandResolutionError({ command, reason: "not-found" }); } - return null; - } - const pathValue = resolvePathEnvironmentVariable(env); - if (pathValue.length === 0) return null; - const pathEntries: string[] = []; - for (const entry of pathValue.split(pathDelimiterForPlatform(platform))) { - const pathEntry = stripWrappingQuotes(entry.trim()); - if (pathEntry.length > 0) { - pathEntries.push(pathEntry); + const pathValue = resolvePathEnvironmentVariable(env); + if (pathValue.length === 0) { + return yield* new CommandResolutionError({ command, reason: "not-found" }); } - } - - for (const pathEntry of pathEntries) { - for (const candidate of commandCandidates) { - const candidatePath = join(pathEntry, candidate); - if (isExecutableFile(candidatePath, platform, windowsPathExtensions)) { - return candidatePath; + const pathEntries: string[] = []; + for (const entry of pathValue.split(pathDelimiterForPlatform(platform))) { + const pathEntry = stripWrappingQuotes(entry.trim()); + if (pathEntry.length > 0) { + pathEntries.push(pathEntry); } } - } - return null; -} - -export function isCommandAvailable( - command: string, - options: CommandAvailabilityOptions = {}, -): boolean { - return resolveCommandPath(command, options) !== null; -} -export function isCommandAvailableForPlatform( - command: string, - options: PlatformCommandAvailabilityOptions, -): boolean { - return resolveCommandPathForPlatform(command, options) !== null; -} + for (const pathEntry of pathEntries) { + for (const candidate of commandCandidates) { + const candidatePath = path.join(pathEntry, candidate); + if (yield* isExecutableFile(candidatePath, platform, windowsPathExtensions)) { + return candidatePath; + } + } + } + return yield* new CommandResolutionError({ command, reason: "not-found" }); + }, +); + +export const isCommandAvailableForPlatform = Effect.fn("shell.isCommandAvailableForPlatform")( + function* ( + command: string, + options: PlatformCommandAvailabilityOptions, + ): Effect.fn.Return { + return yield* resolveCommandPathForPlatform(command, options).pipe( + Effect.as(true), + Effect.catchTag("CommandResolutionError", () => Effect.succeed(false)), + ); + }, +); export function resolveKnownWindowsCliDirs(env: NodeJS.ProcessEnv): ReadonlyArray { const appData = env.APPDATA?.trim(); @@ -492,10 +500,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 ?? @@ -514,7 +522,7 @@ export function resolveWindowsEnvironment( const baselinePatch: Partial = baselinePath ? { PATH: baselinePath } : {}; const baselineEnv = mergeWindowsEnv(env, baselinePatch); - if (commandAvailable("node", { env: baselineEnv })) { + if (yield* commandAvailable("node", { platform: "win32", env: baselineEnv })) { return baselinePatch; } @@ -534,4 +542,4 @@ export function resolveWindowsEnvironment( return Object.keys(profiledPatch).length > 0 ? { ...baselinePatch, ...profiledPatch } : baselinePatch; -} +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 501422ec25d..17ace409a34 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -717,6 +717,9 @@ importers: '@effect/vitest': specifier: 4.0.0-beta.78 version: 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(effect@4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754)) + '@t3tools/shared': + specifier: workspace:* + version: link:../shared '@types/node': specifier: 24.12.4 version: 24.12.4 @@ -726,6 +729,9 @@ importers: packages/effect-codex-app-server: dependencies: + '@t3tools/shared': + specifier: workspace:* + version: link:../shared effect: specifier: 4.0.0-beta.78 version: 4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754) diff --git a/vite.config.ts b/vite.config.ts index f07bf044a85..b5812dfb37f 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,12 +1,18 @@ +import { fileURLToPath } from "node:url"; + import "vite-plus/test/config"; import { defineConfig } from "vite-plus"; -import { fileURLToPath } from "node:url"; + +const webSrcPath = fileURLToPath(new URL("./apps/web/src", import.meta.url)); export default defineConfig({ resolve: { - alias: { - "~": fileURLToPath(new URL("./apps/web/src", import.meta.url)), - }, + alias: [ + { + find: "~", + replacement: webSrcPath, + }, + ], }, test: { environment: "node", @@ -16,9 +22,11 @@ export default defineConfig({ "**/dist/**", "**/dist-electron/**", "**/.{idea,git,cache,output,temp}/**", + "**/routeTree.gen.ts", ], - hookTimeout: 60_000, - testTimeout: 60_000, + fileParallelism: false, + hookTimeout: 120_000, + testTimeout: 120_000, }, fmt: { ignorePatterns: [ From b7e6679a1b73e4556929b0e2d0500ff751fdc7c5 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 9 Jun 2026 16:07:57 -0700 Subject: [PATCH 04/15] fix(runtime): spawn real executables directly and sanitize shell-mode args MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review follow-ups for the host-process refactor: - terminals inherit the full host env again (blocklist semantics) instead of a Config-backed allowlist; `options.env` is the test seam - drop cmd.exe shell mode for real executables (git, ssh, tailscale, powershell.exe, node test peers) — shell-mode arg joining re-tokenizes pipes and paths with spaces; resolve `.exe` names per platform instead - add escapeWindowsShellArg/sanitizeShellModeArgs (cross-spawn style) and apply them at every spawn that keeps shell mode for npm `.cmd` shims (provider binaries, ACP agents, codex app-server, editor launches) - restore the electron-builder env copy + empty-string scrub in build-desktop-artifact (set-but-empty CSC_* vars are treated as enabled) - downgrade fixPath defects to warnings on the win32 branch so PATH hydration can never fail server startup - collapse mergeProviderInstanceEnvironmentEffect back to the sync helper and drop the duplicate getAvailableEditors alias - migrate GrokProvider (new on main) to HostProcessPlatform + sanitizer Co-Authored-By: Claude Opus 4.8 Co-authored-by: codex --- .../src/diagnostics/ProcessDiagnostics.ts | 5 +- .../Layers/ServerEnvironmentLabel.ts | 2 - apps/server/src/os-jank.ts | 12 ++- apps/server/src/process/externalLauncher.ts | 5 +- .../Layers/RepositoryIdentityResolver.test.ts | 3 - .../Layers/RepositoryIdentityResolver.ts | 7 +- .../src/provider/Drivers/ClaudeDriver.ts | 4 +- .../src/provider/Drivers/CodexDriver.ts | 4 +- .../src/provider/Drivers/CursorDriver.ts | 4 +- .../src/provider/Drivers/OpenCodeDriver.ts | 4 +- .../src/provider/Layers/ClaudeProvider.ts | 15 +++- .../provider/Layers/CodexSessionRuntime.ts | 16 ++-- .../src/provider/Layers/CursorProvider.ts | 15 +++- .../src/provider/Layers/GrokProvider.ts | 26 ++++--- .../provider/ProviderInstanceEnvironment.ts | 5 -- .../src/provider/acp/AcpSessionRuntime.ts | 17 +++-- apps/server/src/provider/opencodeRuntime.ts | 7 +- .../src/terminal/Layers/Manager.test.ts | 73 +++++++------------ apps/server/src/terminal/Layers/Manager.ts | 51 +++---------- .../textGeneration/ClaudeTextGeneration.ts | 30 +++++--- .../src/textGeneration/CodexTextGeneration.ts | 42 ++++++----- .../workspace/Layers/WorkspaceEntries.test.ts | 2 +- apps/server/src/ws.ts | 2 +- packages/effect-acp/src/client.test.ts | 3 - packages/effect-acp/src/protocol.test.ts | 3 - .../src/client.test.ts | 3 - .../effect-codex-app-server/src/client.ts | 19 +++-- packages/shared/src/shell.test.ts | 41 +++++++++++ packages/shared/src/shell.ts | 38 ++++++++++ packages/ssh/src/command.ts | 11 ++- packages/ssh/src/tunnel.ts | 4 +- packages/tailscale/src/tailscale.ts | 17 ++--- scripts/build-desktop-artifact.ts | 40 +++++----- 33 files changed, 302 insertions(+), 228 deletions(-) diff --git a/apps/server/src/diagnostics/ProcessDiagnostics.ts b/apps/server/src/diagnostics/ProcessDiagnostics.ts index 97d6ce2a6b0..f5f746134f2 100644 --- a/apps/server/src/diagnostics/ProcessDiagnostics.ts +++ b/apps/server/src/diagnostics/ProcessDiagnostics.ts @@ -278,11 +278,12 @@ const runProcess = Effect.fn("runProcess")( readonly errorMessage: string; }) { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const hostPlatform = yield* HostProcessPlatform; + // `ps` and `powershell.exe` are real executables; spawning through cmd.exe + // shell mode would re-tokenize the PowerShell `-Command` payload (which + // contains pipes) before PowerShell ever sees it. const child = yield* spawner.spawn( ChildProcess.make(input.command, input.args, { cwd: process.cwd(), - shell: hostPlatform === "win32", }), ); const [stdout, stderr, exitCode] = yield* Effect.all( diff --git a/apps/server/src/environment/Layers/ServerEnvironmentLabel.ts b/apps/server/src/environment/Layers/ServerEnvironmentLabel.ts index 5a2bec5e900..af87da95e24 100644 --- a/apps/server/src/environment/Layers/ServerEnvironmentLabel.ts +++ b/apps/server/src/environment/Layers/ServerEnvironmentLabel.ts @@ -54,13 +54,11 @@ const runFriendlyLabelCommand = Effect.fn("runFriendlyLabelCommand")(function* ( args: readonly string[], ) { const processRunner = yield* ProcessRunner; - const hostPlatform = yield* HostProcessPlatform; const result = yield* processRunner .run({ command, args, timeoutBehavior: "timedOutResult", - shell: hostPlatform === "win32", }) .pipe(Effect.option); diff --git a/apps/server/src/os-jank.ts b/apps/server/src/os-jank.ts index 76be335b51f..88764946f4d 100644 --- a/apps/server/src/os-jank.ts +++ b/apps/server/src/os-jank.ts @@ -43,12 +43,22 @@ export const fixPath = Effect.fn("fixPath")(function* ( try { if (platform === "win32") { + // PATH hydration must never take down server startup: a JS `catch` + // around `yield*` does not see Effect defects, so downgrade them to a + // warning explicitly. const repairedEnvironment = yield* resolveWindowsEnvironment(env, { readEnvironment: options.readWindowsEnvironment ?? readEnvironmentFromWindowsShell, ...(options.isWindowsCommandAvailable ? { commandAvailable: options.isWindowsCommandAvailable } : {}), - }); + }).pipe( + Effect.catchDefect((defect) => + Effect.sync(() => { + logWarning("Failed to hydrate PATH from the user environment.", defect); + return {} as Partial; + }), + ), + ); for (const [key, value] of Object.entries(repairedEnvironment)) { if (value !== undefined) { env[key] = value; diff --git a/apps/server/src/process/externalLauncher.ts b/apps/server/src/process/externalLauncher.ts index 4a632817596..c4647dd5f75 100644 --- a/apps/server/src/process/externalLauncher.ts +++ b/apps/server/src/process/externalLauncher.ts @@ -15,6 +15,7 @@ import { import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import { isCommandAvailableForPlatform, + sanitizeShellModeArgs, type PlatformCommandAvailabilityOptions, } from "@t3tools/shared/shell"; import * as Config from "effect/Config"; @@ -289,8 +290,6 @@ export const resolveAvailableEditors = Effect.fn("externalLauncher.resolveAvaila }, ); -export const getAvailableEditors = resolveAvailableEditors; - /** * ExternalLauncherShape - Service API for browser and editor launch actions. */ @@ -393,7 +392,7 @@ export const launchEditorProcess = Effect.fn("externalLauncher.launchEditorProce yield* launchAndUnref( { command: launch.command, - args: isWin32 ? launch.args.map((arg) => `"${arg}"`) : [...launch.args], + args: sanitizeShellModeArgs(launch.args, platform), options: { detached: true, shell: isWin32, diff --git a/apps/server/src/project/Layers/RepositoryIdentityResolver.test.ts b/apps/server/src/project/Layers/RepositoryIdentityResolver.test.ts index 1a3f125ee63..1c985cd8592 100644 --- a/apps/server/src/project/Layers/RepositoryIdentityResolver.test.ts +++ b/apps/server/src/project/Layers/RepositoryIdentityResolver.test.ts @@ -6,7 +6,6 @@ import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; import { TestClock } from "effect/testing"; -import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import * as ProcessRunner from "../../processRunner.ts"; import { RepositoryIdentityResolver } from "../Services/RepositoryIdentityResolver.ts"; @@ -21,11 +20,9 @@ const normalizeResolvedPath = (value: string) => normalizePathSeparators(value); const git = (cwd: string, args: ReadonlyArray) => Effect.gen(function* () { const processRunner = yield* ProcessRunner.ProcessRunner; - const platform = yield* HostProcessPlatform; return yield* processRunner.run({ command: "git", args: ["-C", cwd, ...args], - shell: platform === "win32", }); }).pipe(Effect.provide(ProcessRunner.layer)); diff --git a/apps/server/src/project/Layers/RepositoryIdentityResolver.ts b/apps/server/src/project/Layers/RepositoryIdentityResolver.ts index 2a767eef2e8..d4ae073b953 100644 --- a/apps/server/src/project/Layers/RepositoryIdentityResolver.ts +++ b/apps/server/src/project/Layers/RepositoryIdentityResolver.ts @@ -1,5 +1,4 @@ import type { RepositoryIdentity } from "@t3tools/contracts"; -import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import * as Cache from "effect/Cache"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; @@ -88,15 +87,15 @@ const resolveRepositoryIdentityCacheKey = Effect.fn("resolveRepositoryIdentityCa cwd: string, ) { const processRunner = yield* ProcessRunner.ProcessRunner; - const hostPlatform = yield* HostProcessPlatform; let cacheKey = cwd; + // git is a real executable on every platform — no cmd.exe shell mode, which + // would split paths containing spaces during cmd's re-tokenization. const topLevelResult = yield* processRunner .run({ command: "git", args: ["-C", cwd, "rev-parse", "--show-toplevel"], timeoutBehavior: "timedOutResult", - shell: hostPlatform === "win32", }) .pipe(Effect.option); if (topLevelResult._tag === "None" || topLevelResult.value.code !== 0) { @@ -116,13 +115,11 @@ const resolveRepositoryIdentityFromCacheKey = Effect.fn("resolveRepositoryIdenti cacheKey: string, ): Effect.fn.Return { const processRunner = yield* ProcessRunner.ProcessRunner; - const hostPlatform = yield* HostProcessPlatform; const remoteResult = yield* processRunner .run({ command: "git", args: ["-C", cacheKey, "remote", "-v"], timeoutBehavior: "timedOutResult", - shell: hostPlatform === "win32", }) .pipe(Effect.option); if (remoteResult._tag === "None" || remoteResult.value.code !== 0) { diff --git a/apps/server/src/provider/Drivers/ClaudeDriver.ts b/apps/server/src/provider/Drivers/ClaudeDriver.ts index f3b9a658314..b126028f813 100644 --- a/apps/server/src/provider/Drivers/ClaudeDriver.ts +++ b/apps/server/src/provider/Drivers/ClaudeDriver.ts @@ -41,7 +41,7 @@ import { type ProviderInstance, } from "../ProviderDriver.ts"; import type { ServerProviderDraft } from "../providerSnapshot.ts"; -import { mergeProviderInstanceEnvironmentEffect } from "../ProviderInstanceEnvironment.ts"; +import { mergeProviderInstanceEnvironment } from "../ProviderInstanceEnvironment.ts"; import { enrichProviderSnapshotWithVersionAdvisory, makePackageManagedProviderMaintenanceResolver, @@ -115,7 +115,7 @@ export const ClaudeDriver: ProviderDriver = { const path = yield* Path.Path; const httpClient = yield* HttpClient.HttpClient; const eventLoggers = yield* ProviderEventLoggers; - const processEnv = yield* mergeProviderInstanceEnvironmentEffect(environment); + const processEnv = mergeProviderInstanceEnvironment(environment); const fallbackContinuationIdentity = defaultProviderContinuationIdentity({ driverKind: DRIVER_KIND, instanceId, diff --git a/apps/server/src/provider/Drivers/CodexDriver.ts b/apps/server/src/provider/Drivers/CodexDriver.ts index ec994ac1d6a..441edda479f 100644 --- a/apps/server/src/provider/Drivers/CodexDriver.ts +++ b/apps/server/src/provider/Drivers/CodexDriver.ts @@ -41,7 +41,7 @@ import { ProviderEventLoggers } from "../Layers/ProviderEventLoggers.ts"; import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; import type { ProviderDriver, ProviderInstance } from "../ProviderDriver.ts"; import type { ServerProviderDraft } from "../providerSnapshot.ts"; -import { mergeProviderInstanceEnvironmentEffect } from "../ProviderInstanceEnvironment.ts"; +import { mergeProviderInstanceEnvironment } from "../ProviderInstanceEnvironment.ts"; import { enrichProviderSnapshotWithVersionAdvisory, makePackageManagedProviderMaintenanceResolver, @@ -112,7 +112,7 @@ export const CodexDriver: ProviderDriver = { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const httpClient = yield* HttpClient.HttpClient; const eventLoggers = yield* ProviderEventLoggers; - const processEnv = yield* mergeProviderInstanceEnvironmentEffect(environment); + const processEnv = mergeProviderInstanceEnvironment(environment); const homeLayout = yield* resolveCodexHomeLayout(config); const continuationIdentity = codexContinuationIdentity(homeLayout); const stampIdentity = withInstanceIdentity({ diff --git a/apps/server/src/provider/Drivers/CursorDriver.ts b/apps/server/src/provider/Drivers/CursorDriver.ts index 32b6be2d5d3..ba532864c45 100644 --- a/apps/server/src/provider/Drivers/CursorDriver.ts +++ b/apps/server/src/provider/Drivers/CursorDriver.ts @@ -39,7 +39,7 @@ import { type ProviderInstance, } from "../ProviderDriver.ts"; import type { ServerProviderDraft } from "../providerSnapshot.ts"; -import { mergeProviderInstanceEnvironmentEffect } from "../ProviderInstanceEnvironment.ts"; +import { mergeProviderInstanceEnvironment } from "../ProviderInstanceEnvironment.ts"; import { makeProviderMaintenanceCapabilities, makeStaticProviderMaintenanceResolver, @@ -99,7 +99,7 @@ export const CursorDriver: ProviderDriver = { const path = yield* Path.Path; const httpClient = yield* HttpClient.HttpClient; const eventLoggers = yield* ProviderEventLoggers; - const processEnv = yield* mergeProviderInstanceEnvironmentEffect(environment); + const processEnv = mergeProviderInstanceEnvironment(environment); const continuationIdentity = defaultProviderContinuationIdentity({ driverKind: DRIVER_KIND, instanceId, diff --git a/apps/server/src/provider/Drivers/OpenCodeDriver.ts b/apps/server/src/provider/Drivers/OpenCodeDriver.ts index ee9076214b4..e7216f83366 100644 --- a/apps/server/src/provider/Drivers/OpenCodeDriver.ts +++ b/apps/server/src/provider/Drivers/OpenCodeDriver.ts @@ -40,7 +40,7 @@ import { type ProviderInstance, } from "../ProviderDriver.ts"; import type { ServerProviderDraft } from "../providerSnapshot.ts"; -import { mergeProviderInstanceEnvironmentEffect } from "../ProviderInstanceEnvironment.ts"; +import { mergeProviderInstanceEnvironment } from "../ProviderInstanceEnvironment.ts"; import { enrichProviderSnapshotWithVersionAdvisory, makePackageManagedProviderMaintenanceResolver, @@ -112,7 +112,7 @@ export const OpenCodeDriver: ProviderDriver const serverConfig = yield* ServerConfig; const httpClient = yield* HttpClient.HttpClient; const eventLoggers = yield* ProviderEventLoggers; - const processEnv = yield* mergeProviderInstanceEnvironmentEffect(environment); + const processEnv = mergeProviderInstanceEnvironment(environment); const continuationIdentity = defaultProviderContinuationIdentity({ driverKind: DRIVER_KIND, instanceId, diff --git a/apps/server/src/provider/Layers/ClaudeProvider.ts b/apps/server/src/provider/Layers/ClaudeProvider.ts index f9770b1d1ec..e9ccceb7157 100644 --- a/apps/server/src/provider/Layers/ClaudeProvider.ts +++ b/apps/server/src/provider/Layers/ClaudeProvider.ts @@ -19,6 +19,7 @@ import { getProviderOptionDescriptors, } from "@t3tools/shared/model"; import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import { sanitizeShellModeArgs } from "@t3tools/shared/shell"; import { compareSemverVersions } from "@t3tools/shared/semver"; import { query as claudeQuery, @@ -608,10 +609,16 @@ const runClaudeCommand = Effect.fn("runClaudeCommand")(function* ( ) { const hostPlatform = yield* HostProcessPlatform; const claudeEnvironment = yield* makeClaudeEnvironment(claudeSettings, environment); - const command = ChildProcess.make(claudeSettings.binaryPath, [...args], { - env: claudeEnvironment, - shell: hostPlatform === "win32", - }); + // The provider binary may be an npm-installed `.cmd` shim, so Windows spawns + // through cmd.exe shell mode with explicitly sanitized arguments. + const command = ChildProcess.make( + claudeSettings.binaryPath, + sanitizeShellModeArgs(args, hostPlatform), + { + env: claudeEnvironment, + shell: hostPlatform === "win32", + }, + ); return yield* spawnAndCollect(claudeSettings.binaryPath, command); }); diff --git a/apps/server/src/provider/Layers/CodexSessionRuntime.ts b/apps/server/src/provider/Layers/CodexSessionRuntime.ts index 1d81fc7f831..5e3c3f7fe25 100644 --- a/apps/server/src/provider/Layers/CodexSessionRuntime.ts +++ b/apps/server/src/provider/Layers/CodexSessionRuntime.ts @@ -17,6 +17,7 @@ import { TurnId, } from "@t3tools/contracts"; import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import { sanitizeShellModeArgs } from "@t3tools/shared/shell"; import { normalizeModelSlug } from "@t3tools/shared/model"; import * as Crypto from "effect/Crypto"; import * as DateTime from "effect/DateTime"; @@ -725,12 +726,15 @@ export const makeCodexSessionRuntime = ( ...options.environment, ...(resolvedHomePath ? { CODEX_HOME: resolvedHomePath } : {}), }; - const child = yield* spawner - .spawn( - ChildProcess.make(options.binaryPath, ["app-server", ...(options.appServerArgs ?? [])], { - cwd: options.cwd, - env, - extendEnv: options.environment === undefined, + const child = yield* spawner + .spawn( + ChildProcess.make( + options.binaryPath, + sanitizeShellModeArgs(["app-server", ...(options.appServerArgs ?? [])], hostPlatform), + { + cwd: options.cwd, + env, + extendEnv: options.environment === undefined, forceKillAfter: CODEX_APP_SERVER_FORCE_KILL_AFTER, shell: hostPlatform === "win32", }), diff --git a/apps/server/src/provider/Layers/CursorProvider.ts b/apps/server/src/provider/Layers/CursorProvider.ts index f8bd81519a8..8e4239c0c0a 100644 --- a/apps/server/src/provider/Layers/CursorProvider.ts +++ b/apps/server/src/provider/Layers/CursorProvider.ts @@ -28,6 +28,7 @@ import { getProviderOptionStringSelectionValue, } from "@t3tools/shared/model"; import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import { sanitizeShellModeArgs } from "@t3tools/shared/shell"; import { buildBooleanOptionDescriptor, @@ -933,10 +934,16 @@ const runCursorCommand = ( Effect.gen(function* () { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const hostPlatform = yield* HostProcessPlatform; - const command = ChildProcess.make(cursorSettings.binaryPath, [...args], { - ...(environment ? { env: environment } : { extendEnv: true }), - shell: hostPlatform === "win32", - }); + // The provider binary may be an npm-installed `.cmd` shim, so Windows spawns + // through cmd.exe shell mode with explicitly sanitized arguments. + const command = ChildProcess.make( + cursorSettings.binaryPath, + sanitizeShellModeArgs(args, hostPlatform), + { + ...(environment ? { env: environment } : { extendEnv: true }), + shell: hostPlatform === "win32", + }, + ); const child = yield* spawner.spawn(command); const [stdout, stderr, exitCode] = yield* Effect.all( diff --git a/apps/server/src/provider/Layers/GrokProvider.ts b/apps/server/src/provider/Layers/GrokProvider.ts index bead8b1a407..d71cb02d362 100644 --- a/apps/server/src/provider/Layers/GrokProvider.ts +++ b/apps/server/src/provider/Layers/GrokProvider.ts @@ -14,7 +14,9 @@ import * as Option from "effect/Option"; import * as Result from "effect/Result"; import { HttpClient } from "effect/unstable/http"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import { createModelCapabilities } from "@t3tools/shared/model"; +import { sanitizeShellModeArgs } from "@t3tools/shared/shell"; import { buildServerProvider, @@ -149,16 +151,20 @@ const discoverGrokModelsViaAcp = ( const runGrokVersionCommand = ( grokSettings: GrokSettings, environment: NodeJS.ProcessEnv = process.env, -) => { - const command = grokSettings.binaryPath || "grok"; - return spawnAndCollect( - command, - ChildProcess.make(command, ["--version"], { - env: environment, - shell: process.platform === "win32", - }), - ); -}; +) => + Effect.gen(function* () { + const hostPlatform = yield* HostProcessPlatform; + const command = grokSettings.binaryPath || "grok"; + // The provider binary may be an npm-installed `.cmd` shim, so Windows spawns + // through cmd.exe shell mode with explicitly sanitized arguments. + return yield* spawnAndCollect( + command, + ChildProcess.make(command, sanitizeShellModeArgs(["--version"], hostPlatform), { + env: environment, + shell: hostPlatform === "win32", + }), + ); + }); export const checkGrokProviderStatus = Effect.fn("checkGrokProviderStatus")(function* ( grokSettings: GrokSettings, diff --git a/apps/server/src/provider/ProviderInstanceEnvironment.ts b/apps/server/src/provider/ProviderInstanceEnvironment.ts index 057713048f7..e469253604e 100644 --- a/apps/server/src/provider/ProviderInstanceEnvironment.ts +++ b/apps/server/src/provider/ProviderInstanceEnvironment.ts @@ -1,5 +1,4 @@ import type { ProviderInstanceEnvironment } from "@t3tools/contracts"; -import * as Effect from "effect/Effect"; export function mergeProviderInstanceEnvironment( environment: ProviderInstanceEnvironment | undefined, @@ -15,7 +14,3 @@ export function mergeProviderInstanceEnvironment( } return next; } - -export const mergeProviderInstanceEnvironmentEffect = ( - environment: ProviderInstanceEnvironment | undefined, -) => Effect.sync(() => mergeProviderInstanceEnvironment(environment)); diff --git a/apps/server/src/provider/acp/AcpSessionRuntime.ts b/apps/server/src/provider/acp/AcpSessionRuntime.ts index 6db2cdcd0ef..17900f97c2d 100644 --- a/apps/server/src/provider/acp/AcpSessionRuntime.ts +++ b/apps/server/src/provider/acp/AcpSessionRuntime.ts @@ -14,6 +14,7 @@ import * as EffectAcpErrors from "effect-acp/errors"; import type * as EffectAcpSchema from "effect-acp/schema"; import type * as EffectAcpProtocol from "effect-acp/protocol"; import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import { sanitizeShellModeArgs } from "@t3tools/shared/shell"; import { collectSessionConfigOptionValues, @@ -205,11 +206,17 @@ const makeAcpSessionRuntime = ( const hostPlatform = yield* HostProcessPlatform; const child = yield* spawner .spawn( - ChildProcess.make(options.spawn.command, [...options.spawn.args], { - ...(options.spawn.cwd ? { cwd: options.spawn.cwd } : {}), - ...(options.spawn.env ? { env: options.spawn.env, extendEnv: true } : {}), - shell: hostPlatform === "win32", - }), + // The agent binary may be an npm-installed `.cmd` shim, so Windows spawns + // through cmd.exe shell mode with explicitly sanitized arguments. + ChildProcess.make( + options.spawn.command, + sanitizeShellModeArgs(options.spawn.args, hostPlatform), + { + ...(options.spawn.cwd ? { cwd: options.spawn.cwd } : {}), + ...(options.spawn.env ? { env: options.spawn.env, extendEnv: true } : {}), + shell: hostPlatform === "win32", + }, + ), ) .pipe( Effect.provideService(Scope.Scope, runtimeScope), diff --git a/apps/server/src/provider/opencodeRuntime.ts b/apps/server/src/provider/opencodeRuntime.ts index 3b64eb84b25..340e5eb04b0 100644 --- a/apps/server/src/provider/opencodeRuntime.ts +++ b/apps/server/src/provider/opencodeRuntime.ts @@ -32,6 +32,7 @@ import { isWindowsCommandNotFound } from "../processRunner.ts"; import { collectStreamAsString } from "./providerSnapshot.ts"; import * as NetService from "@t3tools/shared/Net"; import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import { sanitizeShellModeArgs } from "@t3tools/shared/shell"; const encodeUnknownJsonStringExit = Schema.encodeUnknownExit(Schema.UnknownFromJsonString); const OPENCODE_EMPTY_CONFIG_CONTENT = "{}"; @@ -281,8 +282,10 @@ const makeOpenCodeRuntime = Effect.gen(function* () { const runOpenCodeCommand: OpenCodeRuntimeShape["runOpenCodeCommand"] = (input) => Effect.gen(function* () { + // The opencode binary may be an npm-installed `.cmd` shim, so Windows + // spawns through cmd.exe shell mode with explicitly sanitized arguments. const child = yield* spawner.spawn( - ChildProcess.make(input.binaryPath, [...input.args], { + ChildProcess.make(input.binaryPath, sanitizeShellModeArgs(input.args, hostPlatform), { shell: hostPlatform === "win32", ...(input.environment ? { env: input.environment } : { extendEnv: true }), }), @@ -339,7 +342,7 @@ const makeOpenCodeRuntime = Effect.gen(function* () { const child = yield* spawner .spawn( - ChildProcess.make(input.binaryPath, args, { + ChildProcess.make(input.binaryPath, sanitizeShellModeArgs(args, hostPlatform), { detached: hostPlatform !== "win32", shell: hostPlatform === "win32", env: { diff --git a/apps/server/src/terminal/Layers/Manager.test.ts b/apps/server/src/terminal/Layers/Manager.test.ts index 21e67af8368..8b5aa3adbcd 100644 --- a/apps/server/src/terminal/Layers/Manager.test.ts +++ b/apps/server/src/terminal/Layers/Manager.test.ts @@ -9,7 +9,6 @@ import { type TerminalRestartInput, } from "@t3tools/contracts"; import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; -import * as ConfigProvider from "effect/ConfigProvider"; import * as Data from "effect/Data"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; @@ -201,6 +200,7 @@ const multiTerminalHistoryLogPath = ( interface CreateManagerOptions { shellResolver?: () => string; + env?: NodeJS.ProcessEnv; subprocessInspector?: (terminalPid: number) => Effect.Effect<{ readonly hasRunningSubprocess: boolean; readonly childCommand: string | null; @@ -240,6 +240,7 @@ const createManager = ( historyLineLimit, ptyAdapter, ...(options.shellResolver !== undefined ? { shellResolver: options.shellResolver } : {}), + ...(options.env !== undefined ? { env: options.env } : {}), ...(options.subprocessInspector !== undefined ? { subprocessInspector: options.subprocessInspector } : {}), @@ -268,15 +269,8 @@ const createManager = ( }), ); -const withHostProcess = (platform: NodeJS.Platform) => Layer.succeed(HostProcessPlatform, platform); - -const withConfigEnv = (env: Record) => - ConfigProvider.layer(ConfigProvider.fromEnv({ env })); - -const withHostRuntime = (input: { - readonly platform: NodeJS.Platform; - readonly env?: Record; -}) => Layer.merge(withHostProcess(input.platform), withConfigEnv(input.env ?? {})); +const withHostPlatform = (platform: NodeJS.Platform) => + Layer.succeed(HostProcessPlatform, platform); it.layer( Layer.merge(NodeServices.layer, ProcessRunner.layer.pipe(Layer.provide(NodeServices.layer))), @@ -1127,18 +1121,13 @@ it.layer( it.effect("prefers PowerShell over ComSpec for Windows terminals", () => Effect.gen(function* () { - const { manager, ptyAdapter } = yield* createManager(5).pipe( - Effect.provide( - withHostRuntime({ - platform: "win32", - env: { - ComSpec: "C:\\Windows\\System32\\cmd.exe", - PATH: "C:\\Windows\\System32", - SystemRoot: "C:\\Windows", - }, - }), - ), - ); + const { manager, ptyAdapter } = yield* createManager(5, { + env: { + ComSpec: "C:\\Windows\\System32\\cmd.exe", + PATH: "C:\\Windows\\System32", + SystemRoot: "C:\\Windows", + }, + }).pipe(Effect.provide(withHostPlatform("win32"))); yield* manager.open(openInput()); @@ -1157,18 +1146,12 @@ it.layer( const { manager } = yield* createManager(5, { ptyAdapter, shellResolver: () => "C:\\missing\\custom-shell.exe", - }).pipe( - Effect.provide( - withHostRuntime({ - platform: "win32", - env: { - ComSpec: "C:\\Windows\\System32\\cmd.exe", - PATH: "C:\\Windows\\System32", - SystemRoot: "C:\\Windows", - }, - }), - ), - ); + env: { + ComSpec: "C:\\Windows\\System32\\cmd.exe", + PATH: "C:\\Windows\\System32", + SystemRoot: "C:\\Windows", + }, + }).pipe(Effect.provide(withHostPlatform("win32"))); ptyAdapter.spawnFailures.push( new Error("spawn custom-shell.exe ENOENT"), new Error("spawn pwsh.exe ENOENT"), @@ -1188,16 +1171,14 @@ it.layer( it.effect("filters app runtime env variables from terminal sessions", () => Effect.gen(function* () { - const { manager, ptyAdapter } = yield* createManager().pipe( - Effect.provide( - withConfigEnv({ - PORT: "5173", - T3CODE_PORT: "3773", - VITE_DEV_SERVER_URL: "http://localhost:5173", - LANG: "en_US.UTF-8", - }), - ), - ); + const { manager, ptyAdapter } = yield* createManager(5, { + env: { + PORT: "5173", + T3CODE_PORT: "3773", + VITE_DEV_SERVER_URL: "http://localhost:5173", + TEST_TERMINAL_KEEP: "keep-me", + }, + }); yield* manager.open(openInput()); const spawnInput = ptyAdapter.spawnInputs[0]; expect(spawnInput).toBeDefined(); @@ -1206,7 +1187,9 @@ it.layer( expect(spawnInput.env.PORT).toBeUndefined(); expect(spawnInput.env.T3CODE_PORT).toBeUndefined(); expect(spawnInput.env.VITE_DEV_SERVER_URL).toBeUndefined(); - expect(spawnInput.env.LANG).toBe("en_US.UTF-8"); + // Arbitrary host env vars must pass through — terminals inherit the + // user's environment apart from the explicit blocklist. + expect(spawnInput.env.TEST_TERMINAL_KEEP).toBe("keep-me"); }), ); diff --git a/apps/server/src/terminal/Layers/Manager.ts b/apps/server/src/terminal/Layers/Manager.ts index fa61798c91c..e33d9b4b290 100644 --- a/apps/server/src/terminal/Layers/Manager.ts +++ b/apps/server/src/terminal/Layers/Manager.ts @@ -12,7 +12,6 @@ import { import { makeKeyedCoalescingWorker } from "@t3tools/shared/KeyedCoalescingWorker"; import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import { getTerminalLabel } from "@t3tools/shared/terminalLabels"; -import * as Config from "effect/Config"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as Encoding from "effect/Encoding"; @@ -63,45 +62,6 @@ const TERMINAL_ENV_BLOCKLIST = new Set(["PORT", "ELECTRON_RENDERER_PORT", "ELECT const nowIso = Effect.map(DateTime.now, DateTime.formatIso); const MAX_TERMINAL_LABEL_LENGTH = 128; -const compactEnv = (input: Record>): NodeJS.ProcessEnv => - Object.fromEntries( - Object.entries(input).flatMap(([key, value]) => - Option.match(value, { - onNone: () => [], - onSome: (resolved) => [[key, resolved]], - }), - ), - ); - -const TerminalHostEnvConfig = Config.all({ - COLORTERM: Config.string("COLORTERM").pipe(Config.option), - ComSpec: Config.string("ComSpec").pipe(Config.option), - ELECTRON_RENDERER_PORT: Config.string("ELECTRON_RENDERER_PORT").pipe(Config.option), - ELECTRON_RUN_AS_NODE: Config.string("ELECTRON_RUN_AS_NODE").pipe(Config.option), - HOME: Config.string("HOME").pipe(Config.option), - LANG: Config.string("LANG").pipe(Config.option), - LC_ALL: Config.string("LC_ALL").pipe(Config.option), - PATH: Config.string("PATH").pipe(Config.option), - PATHEXT: Config.string("PATHEXT").pipe(Config.option), - PORT: Config.string("PORT").pipe(Config.option), - Path: Config.string("Path").pipe(Config.option), - SHELL: Config.string("SHELL").pipe(Config.option), - SSH_AUTH_SOCK: Config.string("SSH_AUTH_SOCK").pipe(Config.option), - SystemRoot: Config.string("SystemRoot").pipe(Config.option), - T3CODE_PORT: Config.string("T3CODE_PORT").pipe(Config.option), - TEMP: Config.string("TEMP").pipe(Config.option), - TERM: Config.string("TERM").pipe(Config.option), - TMP: Config.string("TMP").pipe(Config.option), - TMPDIR: Config.string("TMPDIR").pipe(Config.option), - USER: Config.string("USER").pipe(Config.option), - USERNAME: Config.string("USERNAME").pipe(Config.option), - VITE_DEV_SERVER_URL: Config.string("VITE_DEV_SERVER_URL").pipe(Config.option), - path: Config.string("path").pipe(Config.option), - windir: Config.string("windir").pipe(Config.option), -}).pipe(Config.map(compactEnv)); - -const readTerminalHostEnv = TerminalHostEnvConfig.pipe(Effect.orElseSucceed(() => ({}))); - class TerminalSubprocessCheckError extends Schema.TaggedErrorClass()( "TerminalSubprocessCheckError", { @@ -550,12 +510,14 @@ function windowsInspectSubprocess( return Effect.gen(function* () { const processRunner = yield* ProcessRunner.ProcessRunner; return yield* processRunner.run({ + // powershell.exe is a real executable — never spawn it through cmd.exe + // shell mode, which would re-tokenize the `-Command` payload (pipes, + // semicolons) before PowerShell ever sees it. command: "powershell.exe", args: ["-NoProfile", "-NonInteractive", "-Command", command], timeout: "1500 millis", maxOutputBytes: 32_768, outputMode: "truncate", - shell: true, timeoutBehavior: "timedOutResult", }); }).pipe( @@ -1015,6 +977,7 @@ interface TerminalManagerOptions { historyLineLimit?: number; ptyAdapter: PtyAdapterShape; shellResolver?: () => string; + env?: NodeJS.ProcessEnv; subprocessInspector?: TerminalSubprocessInspector; subprocessPollIntervalMs?: number; processKillGraceMs?: number; @@ -1052,7 +1015,11 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith const logsDir = options.logsDir; const historyLineLimit = options.historyLineLimit ?? DEFAULT_HISTORY_LINE_LIMIT; const platform = yield* HostProcessPlatform; - const baseEnv = yield* readTerminalHostEnv; + // Terminals must inherit the user's full environment (minus the blocklist + // applied in createTerminalSpawnEnv) — an allowlist here silently strips + // things like PSModulePath, DISPLAY, proxies, and toolchain variables. + // `options.env` is the test seam. + const baseEnv = options.env ?? process.env; const shellResolver = options.shellResolver ?? (() => defaultShellResolver(platform, baseEnv)); const processRunner = yield* ProcessRunner.ProcessRunner; const subprocessInspector = diff --git a/apps/server/src/textGeneration/ClaudeTextGeneration.ts b/apps/server/src/textGeneration/ClaudeTextGeneration.ts index 4a7a4190312..f2fa175f4a5 100644 --- a/apps/server/src/textGeneration/ClaudeTextGeneration.ts +++ b/apps/server/src/textGeneration/ClaudeTextGeneration.ts @@ -16,6 +16,7 @@ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { type ClaudeSettings, type ModelSelection } from "@t3tools/contracts"; import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git"; import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import { sanitizeShellModeArgs } from "@t3tools/shared/shell"; import { TextGenerationError } from "@t3tools/contracts"; import { type TextGenerationShape } from "./TextGeneration.ts"; @@ -160,18 +161,23 @@ export const makeClaudeTextGeneration = Effect.fn("makeClaudeTextGeneration")(fu const runClaudeCommand = Effect.fn("runClaudeJson.runClaudeCommand")(function* () { const command = ChildProcess.make( claudeSettings.binaryPath || "claude", - [ - "-p", - "--output-format", - "json", - "--json-schema", - jsonSchemaStr, - "--model", - resolveClaudeApiModelId(modelSelection), - ...(cliEffort ? ["--effort", cliEffort] : []), - ...(settingsJson ? ["--settings", settingsJson] : []), - "--dangerously-skip-permissions", - ], + // The provider binary may be an npm-installed `.cmd` shim, so Windows + // spawns through cmd.exe shell mode with explicitly sanitized arguments. + sanitizeShellModeArgs( + [ + "-p", + "--output-format", + "json", + "--json-schema", + jsonSchemaStr, + "--model", + resolveClaudeApiModelId(modelSelection), + ...(cliEffort ? ["--effort", cliEffort] : []), + ...(settingsJson ? ["--settings", settingsJson] : []), + "--dangerously-skip-permissions", + ], + hostPlatform, + ), { env: claudeEnvironment, cwd, diff --git a/apps/server/src/textGeneration/CodexTextGeneration.ts b/apps/server/src/textGeneration/CodexTextGeneration.ts index e28bdb0ca69..9512cfdda9c 100644 --- a/apps/server/src/textGeneration/CodexTextGeneration.ts +++ b/apps/server/src/textGeneration/CodexTextGeneration.ts @@ -10,6 +10,7 @@ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { type CodexSettings, type ModelSelection } from "@t3tools/contracts"; import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git"; import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import { sanitizeShellModeArgs } from "@t3tools/shared/shell"; import { resolveAttachmentPath } from "../attachmentStore.ts"; import { ServerConfig } from "../config.ts"; @@ -185,24 +186,29 @@ export const makeCodexTextGeneration = Effect.fn("makeCodexTextGeneration")(func const serviceTier = getCodexServiceTierOptionValue(modelSelection); const command = ChildProcess.make( codexConfig.binaryPath || "codex", - [ - "exec", - "--ephemeral", - "--skip-git-repo-check", - "-s", - "read-only", - "--model", - modelSelection.model, - "--config", - `model_reasoning_effort="${reasoningEffort}"`, - ...(serviceTier ? ["--config", `service_tier="${serviceTier}"`] : []), - "--output-schema", - schemaPath, - "--output-last-message", - outputPath, - ...imagePaths.flatMap((imagePath) => ["--image", imagePath]), - "-", - ], + // The provider binary may be an npm-installed `.cmd` shim, so Windows + // spawns through cmd.exe shell mode with explicitly sanitized arguments. + sanitizeShellModeArgs( + [ + "exec", + "--ephemeral", + "--skip-git-repo-check", + "-s", + "read-only", + "--model", + modelSelection.model, + "--config", + `model_reasoning_effort="${reasoningEffort}"`, + ...(serviceTier ? ["--config", `service_tier="${serviceTier}"`] : []), + "--output-schema", + schemaPath, + "--output-last-message", + outputPath, + ...imagePaths.flatMap((imagePath) => ["--image", imagePath]), + "-", + ], + hostPlatform, + ), { env: { ...resolvedEnvironment, diff --git a/apps/server/src/workspace/Layers/WorkspaceEntries.test.ts b/apps/server/src/workspace/Layers/WorkspaceEntries.test.ts index 1a4fa43a589..7b25f169e72 100644 --- a/apps/server/src/workspace/Layers/WorkspaceEntries.test.ts +++ b/apps/server/src/workspace/Layers/WorkspaceEntries.test.ts @@ -406,7 +406,7 @@ it.layer(TestLayer)("WorkspaceEntriesLive", (it) => { vi.spyOn(fsPromises, "readdir").mockRejectedValueOnce(denied); const result = yield* workspaceEntries.browse({ - partialPath: appendSeparator(cwd), + partialPath: yield* appendSeparator(cwd), }); expect(result).toEqual({ parentPath: cwd, entries: [] }); }), diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 403f6eb92b9..d4d04b30380 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -761,7 +761,7 @@ const makeWsRpcLayer = (currentSession: AuthenticatedSession) => keybindings: keybindingsConfig.keybindings, issues: keybindingsConfig.issues, providers, - availableEditors: yield* ExternalLauncher.getAvailableEditors(), + availableEditors: yield* ExternalLauncher.resolveAvailableEditors(), observability: { logsDirectoryPath: config.logsDir, localTracingEnabled: true, diff --git a/packages/effect-acp/src/client.test.ts b/packages/effect-acp/src/client.test.ts index 30ff39b9171..aca87d45c62 100644 --- a/packages/effect-acp/src/client.test.ts +++ b/packages/effect-acp/src/client.test.ts @@ -13,7 +13,6 @@ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { it, assert } from "@effect/vitest"; -import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import * as AcpClient from "./client.ts"; import * as AcpSchema from "./_generated/schema.gen.ts"; @@ -35,10 +34,8 @@ it.layer(NodeServices.layer)("effect-acp client", (it) => { Effect.gen(function* () { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const path = yield* Path.Path; - const platform = yield* HostProcessPlatform; const command = ChildProcess.make(process.execPath, mockPeerArgs(yield* mockPeerPath), { cwd: path.join(import.meta.dirname, ".."), - shell: platform === "win32", ...(env ? { env: { ...process.env, ...env } } : {}), }); return yield* spawner.spawn(command); diff --git a/packages/effect-acp/src/protocol.test.ts b/packages/effect-acp/src/protocol.test.ts index ce50304e7d9..093d4acfcfa 100644 --- a/packages/effect-acp/src/protocol.test.ts +++ b/packages/effect-acp/src/protocol.test.ts @@ -11,7 +11,6 @@ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { it, assert } from "@effect/vitest"; import * as NodeServices from "@effect/platform-node/NodeServices"; -import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import * as AcpSchema from "./_generated/schema.gen.ts"; import * as AcpProtocol from "./protocol.ts"; @@ -58,10 +57,8 @@ const makeHandle = (env?: Record) => Effect.gen(function* () { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const path = yield* Path.Path; - const platform = yield* HostProcessPlatform; const command = ChildProcess.make(process.execPath, mockPeerArgs(yield* mockPeerPath), { cwd: path.join(import.meta.dirname, ".."), - shell: platform === "win32", ...(env ? { env: { ...process.env, ...env } } : {}), }); return yield* spawner.spawn(command); diff --git a/packages/effect-codex-app-server/src/client.test.ts b/packages/effect-codex-app-server/src/client.test.ts index 4aaf267da4c..8d301742a50 100644 --- a/packages/effect-codex-app-server/src/client.test.ts +++ b/packages/effect-codex-app-server/src/client.test.ts @@ -8,7 +8,6 @@ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, it } from "@effect/vitest"; -import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import * as CodexClient from "./client.ts"; @@ -22,11 +21,9 @@ it.layer(NodeServices.layer)("effect-codex-app-server client", (it) => { Effect.gen(function* () { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const path = yield* Path.Path; - const platform = yield* HostProcessPlatform; const peerCwd = path.join(import.meta.dirname, ".."); const command = ChildProcess.make(process.execPath, mockPeerArgs(yield* mockPeerPath), { cwd: peerCwd, - shell: platform === "win32", }); return yield* spawner.spawn(command); }); diff --git a/packages/effect-codex-app-server/src/client.ts b/packages/effect-codex-app-server/src/client.ts index 70f678d2dd0..a7b0a265ee3 100644 --- a/packages/effect-codex-app-server/src/client.ts +++ b/packages/effect-codex-app-server/src/client.ts @@ -7,6 +7,7 @@ import * as Stdio from "effect/Stdio"; import * as Stream from "effect/Stream"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import { sanitizeShellModeArgs } from "@t3tools/shared/shell"; import * as CodexRpc from "./_generated/meta.gen.ts"; import * as CodexError from "./errors.ts"; @@ -284,12 +285,18 @@ export const layerCommand = ( Effect.gen(function* () { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const platform = yield* HostProcessPlatform; - const command = ChildProcess.make(options.command, [...(options.args ?? [])], { - ...(options.cwd ? { cwd: options.cwd } : {}), - ...(options.env ? { env: { ...process.env, ...options.env } } : {}), - forceKillAfter: DEFAULT_APP_SERVER_FORCE_KILL_AFTER, - shell: platform === "win32", - }); + // The codex binary may be an npm-installed `.cmd` shim, so Windows spawns + // through cmd.exe shell mode with explicitly sanitized arguments. + const command = ChildProcess.make( + options.command, + sanitizeShellModeArgs(options.args ?? [], platform), + { + ...(options.cwd ? { cwd: options.cwd } : {}), + ...(options.env ? { env: { ...process.env, ...options.env } } : {}), + forceKillAfter: DEFAULT_APP_SERVER_FORCE_KILL_AFTER, + shell: platform === "win32", + }, + ); return yield* spawner.spawn(command).pipe( Effect.mapError( (cause) => diff --git a/packages/shared/src/shell.test.ts b/packages/shared/src/shell.test.ts index 0b3164cb23e..dfb293668d2 100644 --- a/packages/shared/src/shell.test.ts +++ b/packages/shared/src/shell.test.ts @@ -4,6 +4,7 @@ import * as Effect from "effect/Effect"; import { describe, expect, it, vi } from "vite-plus/test"; import { + escapeWindowsShellArg, extractPathFromShellOutput, isCommandAvailableForPlatform, listLoginShellCandidates, @@ -16,8 +17,48 @@ import { resolveCommandPathForPlatform, resolveKnownWindowsCliDirs, resolveWindowsEnvironment, + sanitizeShellModeArgs, } from "./shell.ts"; +describe("escapeWindowsShellArg", () => { + it("quotes plain arguments", () => { + expect(escapeWindowsShellArg("--version")).toBe('^"--version^"'); + }); + + it("preserves embedded whitespace through quoting", () => { + expect(escapeWindowsShellArg("C:\\Users\\John Doe\\project")).toBe( + '^"C:\\Users\\John^ Doe\\project^"', + ); + }); + + it("escapes embedded double quotes for CommandLineToArgvW", () => { + expect(escapeWindowsShellArg('say "hi"')).toBe('^"say^ \\^"hi\\^"^"'); + }); + + it("doubles trailing backslashes so the closing quote survives", () => { + expect(escapeWindowsShellArg("C:\\dir\\")).toBe('^"C:\\dir\\\\^"'); + }); + + it("escapes cmd.exe metacharacters", () => { + expect(escapeWindowsShellArg("a|b&c>d")).toBe('^"a^|b^&c^>d^"'); + expect(escapeWindowsShellArg("100%")).toBe('^"100^%^"'); + }); + + it("handles empty arguments", () => { + expect(escapeWindowsShellArg("")).toBe('^"^"'); + }); +}); + +describe("sanitizeShellModeArgs", () => { + it("escapes arguments on win32", () => { + expect(sanitizeShellModeArgs(["--goto", "a b"], "win32")).toEqual(['^"--goto^"', '^"a^ b^"']); + }); + + it("returns arguments untouched on other platforms", () => { + expect(sanitizeShellModeArgs(["--goto", "a b"], "darwin")).toEqual(["--goto", "a b"]); + }); +}); + describe("extractPathFromShellOutput", () => { it("extracts the path between capture markers", () => { expect( diff --git a/packages/shared/src/shell.ts b/packages/shared/src/shell.ts index ea3095d4249..209c67d4958 100644 --- a/packages/shared/src/shell.ts +++ b/packages/shared/src/shell.ts @@ -37,6 +37,39 @@ export class CommandResolutionError extends Data.TaggedError("CommandResolutionE readonly reason: "not-found"; }> {} +const WINDOWS_SHELL_META_CHARS = /([()\][%!^"`<>&|;, *?])/g; + +/** + * Escapes a single argument for `cmd.exe` shell mode (`spawn(..., { shell: true })` + * on Windows). Node joins the command and arguments with spaces and hands the + * resulting string to `cmd.exe` without any quoting, so every dynamic argument + * must be escaped to survive both cmd.exe parsing and the target program's + * `CommandLineToArgvW` parsing. Mirrors cross-spawn's argument escaping. + */ +export function escapeWindowsShellArg(arg: string): string { + // Double up backslashes that precede a double quote, then escape the quote + // itself so it survives CommandLineToArgvW. + let escaped = arg.replace(/(\\*)"/g, '$1$1\\"'); + // Double up trailing backslashes so the closing quote is not escaped away. + escaped = escaped.replace(/(\\*)$/, "$1$1"); + // Quote the whole argument so embedded whitespace is preserved. + escaped = `"${escaped}"`; + // Escape cmd.exe metacharacters so cmd passes them through verbatim. + return escaped.replace(WINDOWS_SHELL_META_CHARS, "^$1"); +} + +/** + * Escapes arguments for shell-mode spawns: applies {@link escapeWindowsShellArg} + * when the platform is `win32` (where `shell: true` routes through `cmd.exe`) + * and returns the arguments untouched everywhere else. + */ +export function sanitizeShellModeArgs( + args: ReadonlyArray, + platform: NodeJS.Platform, +): Array { + return platform === "win32" ? args.map(escapeWindowsShellArg) : [...args]; +} + export interface WindowsEnvironmentProbeOptions { readonly loadProfile?: boolean; } @@ -393,6 +426,11 @@ const isExecutableFile = Effect.fn("shell.isExecutableFile")(function* ( if (stat.mode === undefined) { return true; } + // Note: this checks for any execute bit rather than the effective-uid-aware + // `access(X_OK)` the previous sync implementation used. `FileSystem.access` + // exposes no executable probe, and the difference only matters for files + // that are executable solely by a different user — close enough for PATH + // candidate filtering. return (stat.mode & 0o111) !== 0; }); diff --git a/packages/ssh/src/command.ts b/packages/ssh/src/command.ts index 343326ccd39..0c615b70c28 100644 --- a/packages/ssh/src/command.ts +++ b/packages/ssh/src/command.ts @@ -19,6 +19,14 @@ const DEFAULT_SSH_COMMAND_TIMEOUT_MS = 60_000; const MAX_SSH_ERROR_OUTPUT_LENGTH = 4_000; export const SSH_COMMAND = "ssh"; +/** + * ssh is a real executable everywhere (`ssh.exe` on Windows), so it is always + * spawned directly — cmd.exe shell mode would re-tokenize arguments such as + * identity-file paths containing spaces. + */ +export const sshCommandForPlatform = (platform: NodeJS.Platform): string => + platform === "win32" ? "ssh.exe" : "ssh"; + const encoder = new TextEncoder(); export interface SshCommandResult { @@ -201,10 +209,9 @@ const runSshCommandInScope = Effect.fn("ssh/command.runSshCommand.inScope")(func }); const child = yield* spawner .spawn( - ChildProcess.make("ssh", args, { + ChildProcess.make(sshCommandForPlatform(hostPlatform), args, { env: environment, extendEnv: true, - shell: hostPlatform === "win32", stdin: { stream: stdinStream(input.stdin), endOnDone: true, diff --git a/packages/ssh/src/tunnel.ts b/packages/ssh/src/tunnel.ts index 6320c9eaaa3..cfb3974a53b 100644 --- a/packages/ssh/src/tunnel.ts +++ b/packages/ssh/src/tunnel.ts @@ -38,6 +38,7 @@ import { resolveSshTarget, runSshCommand, SSH_COMMAND, + sshCommandForPlatform, targetConnectionKey, } from "./command.ts"; import { @@ -1084,10 +1085,9 @@ const startSshTunnel = Effect.fn("ssh/tunnel.startSshTunnel")(function* (input: }); const child = yield* spawner .spawn( - ChildProcess.make(SSH_COMMAND, args, { + ChildProcess.make(sshCommandForPlatform(hostPlatform), args, { env: childEnvironment, extendEnv: true, - shell: hostPlatform === "win32", stdin: { stream: Stream.empty, endOnDone: true, diff --git a/packages/tailscale/src/tailscale.ts b/packages/tailscale/src/tailscale.ts index 6f74b2f6db2..e0cca8fde56 100644 --- a/packages/tailscale/src/tailscale.ts +++ b/packages/tailscale/src/tailscale.ts @@ -12,6 +12,11 @@ export const TAILSCALE_STATUS_TIMEOUT_MS = 1_500; export const TAILSCALE_SERVE_TIMEOUT_MS = 10_000; export const TAILSCALE_PROBE_TIMEOUT_MS = 2_500; +// tailscale is a real executable everywhere (`tailscale.exe` on Windows), so +// it is always spawned directly rather than through cmd.exe shell mode. +const tailscaleCommandForPlatform = (platform: NodeJS.Platform): string => + platform === "win32" ? "tailscale.exe" : "tailscale"; + export class TailscaleCommandError extends Data.TaggedError("TailscaleCommandError")<{ readonly command: readonly string[]; readonly message: string; @@ -138,11 +143,7 @@ export const readTailscaleStatus: Effect.Effect< const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const hostPlatform = yield* HostProcessPlatform; const child = yield* spawner - .spawn( - ChildProcess.make("tailscale", args, { - shell: hostPlatform === "win32", - }), - ) + .spawn(ChildProcess.make(tailscaleCommandForPlatform(hostPlatform), args)) .pipe( Effect.mapError((cause) => tailscaleCommandError( @@ -218,11 +219,7 @@ const runTailscaleCommand = ( const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const hostPlatform = yield* HostProcessPlatform; const child = yield* spawner - .spawn( - ChildProcess.make("tailscale", args, { - shell: hostPlatform === "win32", - }), - ) + .spawn(ChildProcess.make(tailscaleCommandForPlatform(hostPlatform), args)) .pipe( Effect.mapError((cause) => tailscaleCommandError( diff --git a/scripts/build-desktop-artifact.ts b/scripts/build-desktop-artifact.ts index 7b389b9074f..f5ce01dd18f 100644 --- a/scripts/build-desktop-artifact.ts +++ b/scripts/build-desktop-artifact.ts @@ -328,12 +328,6 @@ const BuildEnvConfig = Config.all({ mockUpdateServerPort: Config.string("T3CODE_DESKTOP_MOCK_UPDATE_SERVER_PORT").pipe(Config.option), }); -const ElectronBuilderEnvConfig = Config.all({ - debug: Config.string("DEBUG").pipe(Config.option), - npmConfigMsvsVersion: Config.string("npm_config_msvs_version").pipe(Config.option), - gypMsvsVersion: Config.string("GYP_MSVS_VERSION").pipe(Config.option), -}); - const MockUpdateServerPortSchema = Schema.NumberFromString.check( Schema.isInt(), Schema.isBetween({ minimum: 1, maximum: 65535 }), @@ -959,15 +953,24 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( { label: "vp install --prod --no-optional", verbose: options.verbose }, ); - const currentBuildEnv = yield* ElectronBuilderEnvConfig; - const buildEnv: NodeJS.ProcessEnv = {}; + // electron-builder treats several set-but-empty variables (e.g. CSC_LINK="") + // as enabled, so copy the host env and scrub empty values instead of relying + // on `extendEnv` merging. + const buildEnv: NodeJS.ProcessEnv = { + ...process.env, + }; + for (const [key, value] of Object.entries(buildEnv)) { + if (value === "") { + delete buildEnv[key]; + } + } if (!options.signed) { buildEnv.CSC_IDENTITY_AUTO_DISCOVERY = "false"; - buildEnv.CSC_LINK = undefined; - buildEnv.CSC_KEY_PASSWORD = undefined; - buildEnv.APPLE_API_KEY = undefined; - buildEnv.APPLE_API_KEY_ID = undefined; - buildEnv.APPLE_API_ISSUER = undefined; + delete buildEnv.CSC_LINK; + delete buildEnv.CSC_KEY_PASSWORD; + delete buildEnv.APPLE_API_KEY; + delete buildEnv.APPLE_API_KEY_ID; + delete buildEnv.APPLE_API_ISSUER; } if (hostPlatform === "win32") { @@ -976,16 +979,14 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( buildEnv.PYTHON = python; buildEnv.npm_config_python = python; } - buildEnv.npm_config_msvs_version = - Option.getOrUndefined(currentBuildEnv.npmConfigMsvsVersion) ?? "2022"; - buildEnv.GYP_MSVS_VERSION = Option.getOrUndefined(currentBuildEnv.gypMsvsVersion) ?? "2022"; + buildEnv.npm_config_msvs_version = buildEnv.npm_config_msvs_version ?? "2022"; + buildEnv.GYP_MSVS_VERSION = buildEnv.GYP_MSVS_VERSION ?? "2022"; } if (options.verbose) { - const debug = Option.getOrUndefined(currentBuildEnv.debug); buildEnv.DEBUG = - debug === undefined || debug === "" + buildEnv.DEBUG === undefined ? "electron-builder,electron-builder:*" - : `${debug},electron-builder,electron-builder:*`; + : `${buildEnv.DEBUG},electron-builder,electron-builder:*`; } yield* Effect.log( @@ -995,7 +996,6 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( ChildProcess.make({ cwd: repoRoot, env: buildEnv, - extendEnv: true, // Windows needs shell mode to resolve .cmd shims. shell: useWindowsShell, })`vp exec --filter @t3tools/desktop -- electron-builder --projectDir ${stageAppDir} ${platformConfig.cliFlag} --${options.arch} --publish never`, From 56ef23d48b2891f3796fed6ea04f894f329c0a63 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 9 Jun 2026 16:45:43 -0700 Subject: [PATCH 05/15] refactor(effect-codex-app-server): drop layerCommand, keep packages standalone MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit effect-acp and effect-codex-app-server should not depend on @t3tools/shared. Remove layerCommand (the only consumer of the platform/sanitizer helpers) and have callers spawn the child themselves and hand a handle to layerChildProcess — the same shape effect-acp already uses. - CodexProvider's probe now spawns `codex app-server` itself (host platform + sanitized shell-mode args + spawn-error mapping) and builds the client via layerChildProcess - probe/example scripts read the platform from node:os like other standalone scripts - both package manifests no longer reference @t3tools/shared; lockfile again only records the deliberate @t3tools/tailscale -> shared link Co-Authored-By: Claude Opus 4.8 Co-authored-by: codex --- .../src/provider/Layers/CodexProvider.ts | 44 ++++++++++++----- .../provider/Layers/ProviderRegistry.test.ts | 2 +- packages/effect-acp/package.json | 1 - .../examples/cursor-acp-client.example.ts | 9 ++-- packages/effect-codex-app-server/package.json | 1 - .../src/client.test.ts | 47 +++--------------- .../effect-codex-app-server/src/client.ts | 49 +------------------ .../test/examples/codex-app-server-probe.ts | 18 +++++-- pnpm-lock.yaml | 6 --- 9 files changed, 60 insertions(+), 117 deletions(-) diff --git a/apps/server/src/provider/Layers/CodexProvider.ts b/apps/server/src/provider/Layers/CodexProvider.ts index 74eeea5c53a..5003cd58ccf 100644 --- a/apps/server/src/provider/Layers/CodexProvider.ts +++ b/apps/server/src/provider/Layers/CodexProvider.ts @@ -7,7 +7,7 @@ import * as Result from "effect/Result"; import * as Schema from "effect/Schema"; import * as Scope from "effect/Scope"; import * as Types from "effect/Types"; -import { ChildProcessSpawner } from "effect/unstable/process"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import * as CodexClient from "effect-codex-app-server/client"; import * as CodexSchema from "effect-codex-app-server/schema"; import * as CodexErrors from "effect-codex-app-server/errors"; @@ -23,7 +23,9 @@ import type { } from "@t3tools/contracts"; import { ServerSettingsError } from "@t3tools/contracts"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import { createModelCapabilities } from "@t3tools/shared/model"; +import { sanitizeShellModeArgs } from "@t3tools/shared/shell"; import { AUTH_PROBE_TIMEOUT_MS, buildServerProvider, @@ -33,6 +35,8 @@ import { expandHomePath } from "../../pathExpansion.ts"; import packageJson from "../../../package.json" with { type: "json" }; const isCodexAppServerSpawnError = Schema.is(CodexErrors.CodexAppServerSpawnError); +const CODEX_APP_SERVER_PROBE_FORCE_KILL_AFTER = "2 seconds" as const; + const CODEX_PRESENTATION = { displayName: "Codex", showInteractionModeToggle: true, @@ -292,17 +296,33 @@ const probeCodexAppServerProvider = Effect.fn("probeCodexAppServerProvider")(fun // "CODEX_HOME points to '~/.codex_work', but that path does not exist". // Expand here for parity with `CodexTextGeneration`/`CodexSessionRuntime`. const resolvedHomePath = input.homePath ? expandHomePath(input.homePath) : undefined; - const clientContext = yield* Layer.build( - CodexClient.layerCommand({ - command: input.binaryPath, - args: ["app-server"], - cwd: input.cwd, - env: { - ...(input.environment ?? process.env), - ...(resolvedHomePath ? { CODEX_HOME: resolvedHomePath } : {}), - }, - }), - ); + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const hostPlatform = yield* HostProcessPlatform; + // The codex binary may be an npm-installed `.cmd` shim, so Windows spawns + // through cmd.exe shell mode with explicitly sanitized arguments. + const child = yield* spawner + .spawn( + ChildProcess.make(input.binaryPath, sanitizeShellModeArgs(["app-server"], hostPlatform), { + cwd: input.cwd, + env: { + ...input.environment, + ...(resolvedHomePath ? { CODEX_HOME: resolvedHomePath } : {}), + }, + extendEnv: true, + forceKillAfter: CODEX_APP_SERVER_PROBE_FORCE_KILL_AFTER, + shell: hostPlatform === "win32", + }), + ) + .pipe( + Effect.mapError( + (cause) => + new CodexErrors.CodexAppServerSpawnError({ + command: `${input.binaryPath} app-server`, + cause, + }), + ), + ); + const clientContext = yield* Layer.build(CodexClient.layerChildProcess(child)); const client = yield* Effect.service(CodexClient.CodexAppServerClient).pipe( Effect.provide(clientContext), ); diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts index 56b80f6c4a2..e2488f728d7 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -1039,7 +1039,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest(), T // This test intentionally avoids `mockCommandSpawnerLayer` so the real // `probeCodexAppServerProvider` path runs — including the full - // `codex app-server` RPC handshake via `CodexClient.layerCommand`. + // `codex app-server` RPC handshake via `CodexClient.layerChildProcess`. // We point `binaryPath` at a name that cannot exist on any machine so // the real `ChildProcessSpawner` deterministically returns ENOENT; the // probe wraps that as `CodexAppServerSpawnError` and diff --git a/packages/effect-acp/package.json b/packages/effect-acp/package.json index 82c54ffc48f..4455dd460e7 100644 --- a/packages/effect-acp/package.json +++ b/packages/effect-acp/package.json @@ -44,7 +44,6 @@ "@effect/openapi-generator": "catalog:", "@effect/platform-node": "catalog:", "@effect/vitest": "catalog:", - "@t3tools/shared": "workspace:*", "@types/node": "catalog:", "vite-plus": "catalog:" } diff --git a/packages/effect-acp/test/examples/cursor-acp-client.example.ts b/packages/effect-acp/test/examples/cursor-acp-client.example.ts index 5ef80333d53..4a0788743f7 100644 --- a/packages/effect-acp/test/examples/cursor-acp-client.example.ts +++ b/packages/effect-acp/test/examples/cursor-acp-client.example.ts @@ -2,18 +2,21 @@ import * as Effect from "effect/Effect"; import * as Console from "effect/Console"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; +import * as NodeOS from "node:os"; + import * as NodeServices from "@effect/platform-node/NodeServices"; import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; -import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import * as AcpClient from "../../src/client.ts"; +// oxlint-disable-next-line t3code/no-global-process-runtime -- Standalone example script has no Effect runtime wiring. +const hostPlatform = NodeOS.platform(); + const program = Effect.gen(function* () { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const platform = yield* HostProcessPlatform; const command = ChildProcess.make("cursor-agent", ["acp"], { cwd: process.cwd(), - shell: platform === "win32", + shell: hostPlatform === "win32", }); const handle = yield* spawner.spawn(command); const acpLayer = AcpClient.layerChildProcess(handle, { diff --git a/packages/effect-codex-app-server/package.json b/packages/effect-codex-app-server/package.json index ea88887e7de..a067976c616 100644 --- a/packages/effect-codex-app-server/package.json +++ b/packages/effect-codex-app-server/package.json @@ -31,7 +31,6 @@ "probe": "node test/examples/codex-app-server-probe.ts" }, "dependencies": { - "@t3tools/shared": "workspace:*", "effect": "catalog:" }, "devDependencies": { diff --git a/packages/effect-codex-app-server/src/client.test.ts b/packages/effect-codex-app-server/src/client.test.ts index 8d301742a50..3830c5fc5f6 100644 --- a/packages/effect-codex-app-server/src/client.test.ts +++ b/packages/effect-codex-app-server/src/client.test.ts @@ -17,13 +17,14 @@ const mockPeerPath = Effect.map(Effect.service(Path.Path), (path) => const mockPeerArgs = (path: string) => [path]; it.layer(NodeServices.layer)("effect-codex-app-server client", (it) => { - const makeHandle = () => + const makeHandle = (env?: Record) => Effect.gen(function* () { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const path = yield* Path.Path; const peerCwd = path.join(import.meta.dirname, ".."); const command = ChildProcess.make(process.execPath, mockPeerArgs(yield* mockPeerPath), { cwd: peerCwd, + ...(env ? { env: { ...process.env, ...env } } : {}), }); return yield* spawner.spawn(command); }); @@ -122,49 +123,13 @@ it.layer(NodeServices.layer)("effect-codex-app-server client", (it) => { ]); }), ); - - it.effect("initializes a command-backed app-server client", () => + it.effect("drains child stderr so large diagnostics cannot block protocol responses", () => Effect.gen(function* () { - const path = yield* Path.Path; - const scope = yield* Scope.make(); - const clientLayer = CodexClient.layerCommand({ - command: "node", - args: mockPeerArgs(yield* mockPeerPath), - cwd: path.join(import.meta.dirname, ".."), + const handle = yield* makeHandle({ + CODEX_APP_SERVER_TEST_STDERR_BYTES: String(512 * 1024), }); - const context = yield* Layer.buildWithScope(clientLayer, scope); - - const initialized = yield* Effect.gen(function* () { - const client = yield* CodexClient.CodexAppServerClient; - return yield* client.request("initialize", { - clientInfo: { - name: "effect-codex-app-server-test", - title: "Effect Codex App Server Test", - version: "0.0.0", - }, - capabilities: { - experimentalApi: true, - optOutNotificationMethods: null, - }, - }); - }).pipe(Effect.provide(context), Effect.ensuring(Scope.close(scope, Exit.void))); - - assert.equal(initialized.userAgent, "mock-codex-app-server"); - }), - ); - - it.effect("drains command stderr so large diagnostics cannot block protocol responses", () => - Effect.gen(function* () { - const path = yield* Path.Path; const scope = yield* Scope.make(); - const clientLayer = CodexClient.layerCommand({ - command: "node", - args: mockPeerArgs(yield* mockPeerPath), - cwd: path.join(import.meta.dirname, ".."), - env: { - CODEX_APP_SERVER_TEST_STDERR_BYTES: String(512 * 1024), - }, - }); + const clientLayer = CodexClient.layerChildProcess(handle); const context = yield* Layer.buildWithScope(clientLayer, scope); const initialized = yield* Effect.gen(function* () { diff --git a/packages/effect-codex-app-server/src/client.ts b/packages/effect-codex-app-server/src/client.ts index a7b0a265ee3..f031b48d19c 100644 --- a/packages/effect-codex-app-server/src/client.ts +++ b/packages/effect-codex-app-server/src/client.ts @@ -5,9 +5,7 @@ import * as Schema from "effect/Schema"; import * as Scope from "effect/Scope"; import * as Stdio from "effect/Stdio"; import * as Stream from "effect/Stream"; -import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; -import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; -import { sanitizeShellModeArgs } from "@t3tools/shared/shell"; +import { ChildProcessSpawner } from "effect/unstable/process"; import * as CodexRpc from "./_generated/meta.gen.ts"; import * as CodexError from "./errors.ts"; @@ -20,8 +18,6 @@ import { } from "./_internal/shared.ts"; import { makeChildStdio, makeTerminationError } from "./_internal/stdio.ts"; -const DEFAULT_APP_SERVER_FORCE_KILL_AFTER = "2 seconds" as const; - export interface CodexAppServerClientOptions { readonly logIncoming?: boolean; readonly logOutgoing?: boolean; @@ -265,46 +261,3 @@ const makeChildProcessClient = Effect.fn( yield* Stream.runDrain(handle.stderr).pipe(Effect.ignore, Effect.forkScoped); return yield* make(makeChildStdio(handle), options, makeTerminationError(handle)); }); - -export interface CodexAppServerCommandLayerOptions extends CodexAppServerClientOptions { - readonly command: string; - readonly args?: ReadonlyArray; - readonly cwd?: string; - readonly env?: Record; -} - -export const layerCommand = ( - options: CodexAppServerCommandLayerOptions, -): Layer.Layer< - CodexAppServerClient, - CodexError.CodexAppServerSpawnError, - ChildProcessSpawner.ChildProcessSpawner -> => - Layer.effect( - CodexAppServerClient, - Effect.gen(function* () { - const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const platform = yield* HostProcessPlatform; - // The codex binary may be an npm-installed `.cmd` shim, so Windows spawns - // through cmd.exe shell mode with explicitly sanitized arguments. - const command = ChildProcess.make( - options.command, - sanitizeShellModeArgs(options.args ?? [], platform), - { - ...(options.cwd ? { cwd: options.cwd } : {}), - ...(options.env ? { env: { ...process.env, ...options.env } } : {}), - forceKillAfter: DEFAULT_APP_SERVER_FORCE_KILL_AFTER, - shell: platform === "win32", - }, - ); - return yield* spawner.spawn(command).pipe( - Effect.mapError( - (cause) => - new CodexError.CodexAppServerSpawnError({ - command: [options.command, ...(options.args ?? [])].join(" "), - cause, - }), - ), - ); - }).pipe(Effect.flatMap((handle) => makeChildProcessClient(handle, options))), - ); diff --git a/packages/effect-codex-app-server/test/examples/codex-app-server-probe.ts b/packages/effect-codex-app-server/test/examples/codex-app-server-probe.ts index b6383c0ab7d..8fa4f29965b 100644 --- a/packages/effect-codex-app-server/test/examples/codex-app-server-probe.ts +++ b/packages/effect-codex-app-server/test/examples/codex-app-server-probe.ts @@ -1,16 +1,26 @@ +import * as NodeOS from "node:os"; + import * as Console from "effect/Console"; import * as Effect from "effect/Effect"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; import * as NodeServices from "@effect/platform-node/NodeServices"; import * as CodexClient from "../../src/client.ts"; +// oxlint-disable-next-line t3code/no-global-process-runtime -- Standalone example script has no Effect runtime wiring. +const hostPlatform = NodeOS.platform(); + const program = Effect.gen(function* () { - const codexLayer = CodexClient.layerCommand({ - command: process.env.CODEX_BIN ?? "codex", - args: ["app-server"], - cwd: process.cwd(), + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const handle = yield* spawner.spawn( + ChildProcess.make(process.env.CODEX_BIN ?? "codex", ["app-server"], { + cwd: process.cwd(), + shell: hostPlatform === "win32", + }), + ); + const codexLayer = CodexClient.layerChildProcess(handle, { logIncoming: true, logOutgoing: true, }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 17ace409a34..501422ec25d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -717,9 +717,6 @@ importers: '@effect/vitest': specifier: 4.0.0-beta.78 version: 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(effect@4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754)) - '@t3tools/shared': - specifier: workspace:* - version: link:../shared '@types/node': specifier: 24.12.4 version: 24.12.4 @@ -729,9 +726,6 @@ importers: packages/effect-codex-app-server: dependencies: - '@t3tools/shared': - specifier: workspace:* - version: link:../shared effect: specifier: 4.0.0-beta.78 version: 4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754) From d9f35a9ba96c6e128fac028007a060b20f55c668 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 14 Jun 2026 12:00:35 -0700 Subject: [PATCH 06/15] refactor(runtime): align incoming host platform usage - inject the host platform into preview port discovery\n- avoid global platform reads in preview tests and desktop launcher\n- retain formatted Codex app-server argument handling\n\nCo-authored-by: codex --- apps/desktop/scripts/electron-launcher.mjs | 2 +- apps/server/src/preview/PortScanner.test.ts | 28 +++++-------------- apps/server/src/preview/PortScanner.ts | 4 ++- .../provider/Layers/CodexSessionRuntime.ts | 11 ++++---- 4 files changed, 17 insertions(+), 28 deletions(-) diff --git a/apps/desktop/scripts/electron-launcher.mjs b/apps/desktop/scripts/electron-launcher.mjs index 1114a442c6e..52b6dd5cc6e 100644 --- a/apps/desktop/scripts/electron-launcher.mjs +++ b/apps/desktop/scripts/electron-launcher.mjs @@ -315,7 +315,7 @@ function buildMacLauncher(electronBinaryPath) { } function isLinuxSetuidSandboxConfigured(electronBinaryPath) { - if (process.platform !== "linux") { + if (hostPlatform !== "linux") { return true; } diff --git a/apps/server/src/preview/PortScanner.test.ts b/apps/server/src/preview/PortScanner.test.ts index 8b37e86d8a9..455a67b347b 100644 --- a/apps/server/src/preview/PortScanner.test.ts +++ b/apps/server/src/preview/PortScanner.test.ts @@ -2,6 +2,7 @@ import * as net from "node:net"; import { it as effectIt } from "@effect/vitest"; import { ThreadId } from "@t3tools/contracts"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import * as Net from "@t3tools/shared/Net"; import { Effect, Layer } from "effect"; import { describe, expect, it } from "vite-plus/test"; @@ -15,7 +16,9 @@ const TestProcessRunner = Layer.succeed(ProcessRunner, { run: () => Effect.die("ProcessRunner should not be used by Windows TCP probe tests"), }); const TestPortDiscoveryLive = PortScanner.layer.pipe( - Layer.provide(Layer.mergeAll(TestProcessRunner, Net.layer)), + Layer.provide( + Layer.mergeAll(TestProcessRunner, Net.layer, Layer.succeed(HostProcessPlatform, "win32")), + ), ); const openServer = (port: number): Effect.Effect => @@ -37,21 +40,6 @@ const closeServer = (server: net.Server): Effect.Effect => server.close(() => resume(Effect.void)); }); -const windowsPlatform = Effect.acquireRelease( - Effect.sync(() => { - const originalPlatform = process.platform; - Object.defineProperty(process, "platform", { value: "win32", configurable: true }); - return originalPlatform; - }), - (originalPlatform) => - Effect.sync(() => { - Object.defineProperty(process, "platform", { - value: originalPlatform, - configurable: true, - }); - }), -); - const openCommonDevServer = Effect.fn("PortScannerTest.openCommonDevServer")(function* ( ports: ReadonlyArray, ) { @@ -230,15 +218,14 @@ describe("parseWindowsListenerOutput", () => { }); /** - * Integration tests against a real TCP listener. We force the Windows code - * path (TCP-probe fallback) by monkey-patching `process.platform` for the - * duration of the test so we don't depend on `lsof` being installed. + * Integration tests against a real TCP listener. We provide the Windows host + * platform so the tests exercise the TCP-probe fallback without depending on + * `lsof` being installed. */ effectIt.layer(TestPortDiscoveryLive)("PortDiscovery integration (TCP probe fallback)", (it) => { it.effect( "scan() returns a server we just opened on a curated dev port", Effect.fn("PortScannerTest.scanFindsCommonDevServer")(function* () { - yield* windowsPlatform; const { port } = yield* commonDevServer; const scanner = yield* PortScanner.PortDiscovery; const result = yield* scanner.scan(); @@ -251,7 +238,6 @@ effectIt.layer(TestPortDiscoveryLive)("PortDiscovery integration (TCP probe fall it.effect( "retain drives an immediate broadcast to subscribers", Effect.fn("PortScannerTest.retainBroadcastsImmediately")(function* () { - yield* windowsPlatform; const { port } = yield* commonDevServer; const received: number[] = []; const scanner = yield* PortScanner.PortDiscovery; diff --git a/apps/server/src/preview/PortScanner.ts b/apps/server/src/preview/PortScanner.ts index c8d9a051ed6..d3413adcd98 100644 --- a/apps/server/src/preview/PortScanner.ts +++ b/apps/server/src/preview/PortScanner.ts @@ -12,6 +12,7 @@ * polls forever, but each tick is a no-op when the retain count is zero. */ import { ThreadId, type DiscoveredLocalServer } from "@t3tools/contracts"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import * as Net from "@t3tools/shared/Net"; import { LSOF_LOCAL_HOST_TOKENS } from "@t3tools/shared/preview"; import { Cause, Context, Duration, Effect, Layer, Ref, Schedule, Scope } from "effect"; @@ -182,6 +183,7 @@ const serversEqual = ( const make = Effect.gen(function* PortDiscoveryMake() { const net = yield* Net.NetService; const processRunner = yield* ProcessRunner; + const hostPlatform = yield* HostProcessPlatform; const stateRef = yield* Ref.make({ lastSnapshot: [], listeners: new Set(), @@ -221,7 +223,7 @@ const make = Effect.gen(function* PortDiscoveryMake() { terminalByProcessId.set(processId, registration.owner); } } - if (process.platform === "win32") { + if (hostPlatform === "win32") { const command = 'Get-NetTCPConnection -State Listen -ErrorAction Stop | ForEach-Object { $processName = (Get-Process -Id $_.OwningProcess -ErrorAction SilentlyContinue).ProcessName; Write-Output "$($_.LocalAddress)|$($_.LocalPort)|$($_.OwningProcess)|$processName" }'; const listeners = yield* processRunner diff --git a/apps/server/src/provider/Layers/CodexSessionRuntime.ts b/apps/server/src/provider/Layers/CodexSessionRuntime.ts index 5e3c3f7fe25..a0d2a73471c 100644 --- a/apps/server/src/provider/Layers/CodexSessionRuntime.ts +++ b/apps/server/src/provider/Layers/CodexSessionRuntime.ts @@ -726,8 +726,8 @@ export const makeCodexSessionRuntime = ( ...options.environment, ...(resolvedHomePath ? { CODEX_HOME: resolvedHomePath } : {}), }; - const child = yield* spawner - .spawn( + const child = yield* spawner + .spawn( ChildProcess.make( options.binaryPath, sanitizeShellModeArgs(["app-server", ...(options.appServerArgs ?? [])], hostPlatform), @@ -735,9 +735,10 @@ export const makeCodexSessionRuntime = ( cwd: options.cwd, env, extendEnv: options.environment === undefined, - forceKillAfter: CODEX_APP_SERVER_FORCE_KILL_AFTER, - shell: hostPlatform === "win32", - }), + forceKillAfter: CODEX_APP_SERVER_FORCE_KILL_AFTER, + shell: hostPlatform === "win32", + }, + ), ) .pipe( Effect.provideService(Scope.Scope, runtimeScope), From 5303484695a8f3b7a91f45cb70607ed3dd8fcf07 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 14 Jun 2026 12:28:54 -0700 Subject: [PATCH 07/15] refactor: align host runtime tests with Effect references Co-authored-by: codex --- apps/server/src/bootstrap.test.ts | 17 +- apps/server/src/bootstrap.ts | 2 +- .../Layers/ServerEnvironmentLabel.test.ts | 31 +- .../Layers/ServerEnvironmentLabel.ts | 7 +- apps/server/src/os-jank.ts | 131 ++- apps/server/src/preview/PortScanner.test.ts | 166 +--- apps/server/src/preview/PortScanner.ts | 8 - .../src/process/externalLauncher.test.ts | 894 ++---------------- apps/server/src/process/externalLauncher.ts | 44 +- .../src/provider/Layers/ClaudeProvider.ts | 12 +- .../src/provider/Layers/CodexProvider.ts | 2 +- .../provider/Layers/CodexSessionRuntime.ts | 2 +- .../src/provider/Layers/CursorProvider.ts | 2 +- .../src/provider/Layers/GrokProvider.ts | 2 +- .../src/provider/acp/AcpSessionRuntime.ts | 14 +- apps/server/src/provider/opencodeRuntime.ts | 4 +- .../src/provider/providerMaintenance.test.ts | 36 +- .../src/provider/providerMaintenance.ts | 27 +- .../providerMaintenanceRunner.test.ts | 15 +- .../src/terminal/Layers/NodePTY.test.ts | 52 - apps/server/src/terminal/Layers/NodePTY.ts | 10 +- .../textGeneration/ClaudeTextGeneration.ts | 27 +- .../src/textGeneration/CodexTextGeneration.ts | 39 +- apps/server/src/ws.ts | 2 +- packages/shared/src/hostProcess.ts | 15 + packages/shared/src/relayClient.test.ts | 70 +- packages/shared/src/relayClient.ts | 33 +- packages/shared/src/shell.test.ts | 106 +-- packages/shared/src/shell.ts | 161 ++-- packages/ssh/src/command.ts | 7 +- packages/ssh/src/tunnel.ts | 6 +- scripts/build-desktop-artifact.test.ts | 2 +- scripts/build-desktop-artifact.ts | 3 +- scripts/lib/build-target-arch.test.ts | 43 +- scripts/lib/build-target-arch.ts | 4 +- 35 files changed, 490 insertions(+), 1506 deletions(-) delete mode 100644 apps/server/src/terminal/Layers/NodePTY.test.ts diff --git a/apps/server/src/bootstrap.test.ts b/apps/server/src/bootstrap.test.ts index 422d880d7f1..a3bbcc66d34 100644 --- a/apps/server/src/bootstrap.test.ts +++ b/apps/server/src/bootstrap.test.ts @@ -3,7 +3,7 @@ import * as NFS from "node:fs"; import * as path from "node:path"; import { execFileSync, spawn } from "node:child_process"; import * as NodeServices from "@effect/platform-node/NodeServices"; -import { assert, it } from "@effect/vitest"; +import { it } from "@effect/vitest"; import * as FileSystem from "effect/FileSystem"; import * as Schema from "effect/Schema"; import * as Duration from "effect/Duration"; @@ -11,8 +11,9 @@ import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; import * as TestClock from "effect/testing/TestClock"; import { vi } from "vite-plus/test"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; -import { readBootstrapEnvelope, resolveFdPath } from "./bootstrap.ts"; +import { readBootstrapEnvelope } from "./bootstrap.ts"; import { assertNone, assertSome } from "@effect/vitest/utils"; const openSyncInterceptor = vi.hoisted(() => ({ failPath: null as string | null })); @@ -41,14 +42,6 @@ const TestEnvelopeSchema = Schema.Struct({ mode: Schema.String }); const encodeTestEnvelopeSchema = Schema.encodeEffect(Schema.fromJsonString(TestEnvelopeSchema)); it.layer(NodeServices.layer)("readBootstrapEnvelope", (it) => { - it.effect("uses platform-specific fd paths", () => - Effect.sync(() => { - assert.equal(resolveFdPath(3, "linux"), "/proc/self/fd/3"); - assert.equal(resolveFdPath(3, "darwin"), "/dev/fd/3"); - assert.equal(resolveFdPath(3, "win32"), undefined); - }), - ); - it.effect("reads a bootstrap envelope from a provided fd", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; @@ -89,7 +82,9 @@ it.layer(NodeServices.layer)("readBootstrapEnvelope", (it) => { openSyncInterceptor.failPath = `/proc/self/fd/${fd}`; try { - const payload = yield* readBootstrapEnvelope(TestEnvelopeSchema, fd, { timeoutMs: 100 }); + const payload = yield* readBootstrapEnvelope(TestEnvelopeSchema, fd, { + timeoutMs: 100, + }).pipe(Effect.provideService(HostProcessPlatform, "linux")); assertSome(payload, { mode: "desktop", }); diff --git a/apps/server/src/bootstrap.ts b/apps/server/src/bootstrap.ts index 48022c289e3..83d1d337888 100644 --- a/apps/server/src/bootstrap.ts +++ b/apps/server/src/bootstrap.ts @@ -169,7 +169,7 @@ const isBootstrapFdPathDuplicationError = Predicate.compose( (_) => _.code === "ENXIO" || _.code === "EINVAL" || _.code === "EPERM", ); -export function resolveFdPath(fd: number, platform: NodeJS.Platform): string | undefined { +function resolveFdPath(fd: number, platform: NodeJS.Platform): string | undefined { if (platform === "linux") { return `/proc/self/fd/${fd}`; } diff --git a/apps/server/src/environment/Layers/ServerEnvironmentLabel.test.ts b/apps/server/src/environment/Layers/ServerEnvironmentLabel.test.ts index 0058a3cd50d..3a4dce1627c 100644 --- a/apps/server/src/environment/Layers/ServerEnvironmentLabel.test.ts +++ b/apps/server/src/environment/Layers/ServerEnvironmentLabel.test.ts @@ -2,7 +2,7 @@ import { afterEach, describe, expect, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; -import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import { HostProcessHostname, HostProcessPlatform } from "@t3tools/shared/hostProcess"; import { vi } from "vite-plus/test"; import { ProcessRunner, ProcessSpawnError, type ProcessRunnerShape } from "../../processRunner.ts"; @@ -32,7 +32,13 @@ const LinuxMachineInfoLayer = Layer.merge( const withHostPlatform = ( layer: Layer.Layer, platform: NodeJS.Platform, -) => Layer.merge(layer, Layer.succeed(HostProcessPlatform, platform)); + hostname: string, +) => + Layer.mergeAll( + layer, + Layer.succeed(HostProcessPlatform, platform), + Layer.succeed(HostProcessHostname, hostname), + ); afterEach(() => { runMock.mockReset(); @@ -43,8 +49,7 @@ describe("resolveServerEnvironmentLabel", () => { Effect.gen(function* () { const result = yield* resolveServerEnvironmentLabel({ cwdBaseName: "t3code", - hostname: "macbook-pro", - }).pipe(Effect.provide(withHostPlatform(TestLayer, "win32"))); + }).pipe(Effect.provide(withHostPlatform(TestLayer, "win32", "macbook-pro"))); expect(result).toBe("macbook-pro"); }), @@ -65,8 +70,7 @@ describe("resolveServerEnvironmentLabel", () => { const result = yield* resolveServerEnvironmentLabel({ cwdBaseName: "t3code", - hostname: "macbook-pro", - }).pipe(Effect.provide(withHostPlatform(TestLayer, "darwin"))); + }).pipe(Effect.provide(withHostPlatform(TestLayer, "darwin", "macbook-pro"))); expect(result).toBe("Julius's MacBook Pro"); expect(runMock).toHaveBeenCalledWith( @@ -83,8 +87,7 @@ describe("resolveServerEnvironmentLabel", () => { Effect.gen(function* () { const result = yield* resolveServerEnvironmentLabel({ cwdBaseName: "t3code", - hostname: "buildbox", - }).pipe(Effect.provide(withHostPlatform(LinuxMachineInfoLayer, "linux"))); + }).pipe(Effect.provide(withHostPlatform(LinuxMachineInfoLayer, "linux", "buildbox"))); expect(result).toBe("Build Agent 01"); expect(runMock).not.toHaveBeenCalled(); @@ -106,8 +109,7 @@ describe("resolveServerEnvironmentLabel", () => { const result = yield* resolveServerEnvironmentLabel({ cwdBaseName: "t3code", - hostname: "runner-01", - }).pipe(Effect.provide(withHostPlatform(TestLayer, "linux"))); + }).pipe(Effect.provide(withHostPlatform(TestLayer, "linux", "runner-01"))); expect(result).toBe("CI Runner"); expect(runMock).toHaveBeenCalledWith( @@ -124,8 +126,7 @@ describe("resolveServerEnvironmentLabel", () => { Effect.gen(function* () { const result = yield* resolveServerEnvironmentLabel({ cwdBaseName: "t3code", - hostname: "JULIUS-LAPTOP", - }).pipe(Effect.provide(withHostPlatform(TestLayer, "win32"))); + }).pipe(Effect.provide(withHostPlatform(TestLayer, "win32", "JULIUS-LAPTOP"))); expect(result).toBe("JULIUS-LAPTOP"); }), @@ -145,8 +146,7 @@ describe("resolveServerEnvironmentLabel", () => { const result = yield* resolveServerEnvironmentLabel({ cwdBaseName: "t3code", - hostname: "macbook-pro", - }).pipe(Effect.provide(withHostPlatform(TestLayer, "darwin"))); + }).pipe(Effect.provide(withHostPlatform(TestLayer, "darwin", "macbook-pro"))); expect(result).toBe("macbook-pro"); }), @@ -167,8 +167,7 @@ describe("resolveServerEnvironmentLabel", () => { const result = yield* resolveServerEnvironmentLabel({ cwdBaseName: "t3code", - hostname: " ", - }).pipe(Effect.provide(withHostPlatform(TestLayer, "linux"))); + }).pipe(Effect.provide(withHostPlatform(TestLayer, "linux", " "))); expect(result).toBe("t3code"); }), diff --git a/apps/server/src/environment/Layers/ServerEnvironmentLabel.ts b/apps/server/src/environment/Layers/ServerEnvironmentLabel.ts index af87da95e24..73a3b9526c4 100644 --- a/apps/server/src/environment/Layers/ServerEnvironmentLabel.ts +++ b/apps/server/src/environment/Layers/ServerEnvironmentLabel.ts @@ -1,6 +1,4 @@ -import * as OS from "node:os"; - -import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import { HostProcessHostname, HostProcessPlatform } from "@t3tools/shared/hostProcess"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Option from "effect/Option"; @@ -9,7 +7,6 @@ import { ProcessRunner } from "../../processRunner.ts"; interface ResolveServerEnvironmentLabelInput { readonly cwdBaseName: string; - readonly hostname?: string | null; } function normalizeLabel(value: string | null | undefined): string | null { @@ -98,7 +95,7 @@ export const resolveServerEnvironmentLabel = Effect.fn("resolveServerEnvironment return friendlyHostLabel; } - const hostname = normalizeLabel(input.hostname ?? OS.hostname()); + const hostname = normalizeLabel(yield* HostProcessHostname); if (hostname) { return hostname; } diff --git a/apps/server/src/os-jank.ts b/apps/server/src/os-jank.ts index 88764946f4d..bc72758bc71 100644 --- a/apps/server/src/os-jank.ts +++ b/apps/server/src/os-jank.ts @@ -1,23 +1,15 @@ -import * as NodeOS from "node:os"; -import * as Effect from "effect/Effect"; -import * as FileSystem from "effect/FileSystem"; -import * as Path from "effect/Path"; -import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import { HostProcessEnvironment, HostProcessPlatform } from "@t3tools/shared/hostProcess"; import { - readPathFromLoginShell, - readEnvironmentFromWindowsShell, - resolveWindowsEnvironment, - type PlatformCommandAvailabilityOptions, - type WindowsShellEnvironmentReader, listLoginShellCandidates, mergePathEntries, + readPathFromLoginShell, readPathFromLaunchctl, + resolveWindowsEnvironment, } from "@t3tools/shared/shell"; - -type WindowsCommandAvailabilityChecker = ( - command: string, - options: PlatformCommandAvailabilityOptions, -) => Effect.Effect; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import * as NodeOS from "node:os"; function logPathHydrationWarning(message: string, error?: unknown): void { process.stderr.write( @@ -25,76 +17,59 @@ function logPathHydrationWarning(message: string, error?: unknown): void { ); } -export const fixPath = Effect.fn("fixPath")(function* ( - options: { - env?: NodeJS.ProcessEnv; - readPath?: typeof readPathFromLoginShell; - readWindowsEnvironment?: WindowsShellEnvironmentReader; - isWindowsCommandAvailable?: WindowsCommandAvailabilityChecker; - readLaunchctlPath?: typeof readPathFromLaunchctl; - userShell?: string; - logWarning?: (message: string, error?: unknown) => void; - } = {}, -): Effect.fn.Return { - const platform = yield* HostProcessPlatform; - const env = options.env ?? process.env; - const logWarning = options.logWarning ?? logPathHydrationWarning; - const readPath = options.readPath ?? readPathFromLoginShell; - - try { - if (platform === "win32") { - // PATH hydration must never take down server startup: a JS `catch` - // around `yield*` does not see Effect defects, so downgrade them to a - // warning explicitly. - const repairedEnvironment = yield* resolveWindowsEnvironment(env, { - readEnvironment: options.readWindowsEnvironment ?? readEnvironmentFromWindowsShell, - ...(options.isWindowsCommandAvailable - ? { commandAvailable: options.isWindowsCommandAvailable } - : {}), - }).pipe( - Effect.catchDefect((defect) => - Effect.sync(() => { - logWarning("Failed to hydrate PATH from the user environment.", defect); - return {} as Partial; - }), - ), - ); - for (const [key, value] of Object.entries(repairedEnvironment)) { - if (value !== undefined) { - env[key] = value; - } - } - return; +function hydratePosixPath(env: NodeJS.ProcessEnv, platform: NodeJS.Platform): void { + let shellPath: string | undefined; + for (const shell of listLoginShellCandidates(platform, env.SHELL)) { + try { + shellPath = readPathFromLoginShell(shell); + } catch (error) { + logPathHydrationWarning(`Failed to read PATH from login shell ${shell}.`, error); } - if (platform !== "darwin" && platform !== "linux") return; + if (shellPath) break; + } - 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); - } + const launchctlPath = platform === "darwin" && !shellPath ? readPathFromLaunchctl() : undefined; + const mergedPath = mergePathEntries(shellPath ?? launchctlPath, env.PATH, platform); + if (mergedPath) { + env.PATH = mergedPath; + } +} - if (shellPath) { - break; - } - } +export const fixPath = Effect.fn("fixPath")(function* (): Effect.fn.Return< + void, + never, + FileSystem.FileSystem | Path.Path +> { + const platform = yield* HostProcessPlatform; + const env = yield* HostProcessEnvironment; - const launchctlPath = - platform === "darwin" && !shellPath - ? (options.readLaunchctlPath ?? readPathFromLaunchctl)() - : undefined; - const mergedPath = mergePathEntries(shellPath ?? launchctlPath, env.PATH, platform); - if (mergedPath) { - env.PATH = mergedPath; + if (platform === "win32") { + const repairedEnvironment = yield* resolveWindowsEnvironment(env).pipe( + Effect.catchDefect((defect) => + Effect.sync(() => { + logPathHydrationWarning("Failed to hydrate PATH from the user environment.", defect); + return {} as Partial; + }), + ), + ); + for (const [key, value] of Object.entries(repairedEnvironment)) { + if (value !== undefined) { + env[key] = value; + } } - } catch (error) { - yield* Effect.sync(() => { - logWarning("Failed to hydrate PATH from the user environment.", error); - }); + return; } + + if (platform !== "darwin" && platform !== "linux") return; + + yield* Effect.sync(() => hydratePosixPath(env, platform)).pipe( + Effect.catchDefect((defect) => + Effect.sync(() => { + logPathHydrationWarning("Failed to hydrate PATH from the user environment.", defect); + }), + ), + ); }); export const expandHomePath = Effect.fn(function* (input: string) { diff --git a/apps/server/src/preview/PortScanner.test.ts b/apps/server/src/preview/PortScanner.test.ts index 455a67b347b..481d28d782f 100644 --- a/apps/server/src/preview/PortScanner.test.ts +++ b/apps/server/src/preview/PortScanner.test.ts @@ -1,17 +1,13 @@ import * as net from "node:net"; import { it as effectIt } from "@effect/vitest"; -import { ThreadId } from "@t3tools/contracts"; import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import * as Net from "@t3tools/shared/Net"; import { Effect, Layer } from "effect"; -import { describe, expect, it } from "vite-plus/test"; +import { expect } from "vite-plus/test"; import { ProcessRunner } from "../processRunner.ts"; import * as PortScanner from "./PortScanner.ts"; - -const { parseLsofOutput, parsePortFromLsofName, parseWindowsListenerOutput, serversEqual } = - PortScanner.__testing; const TestProcessRunner = Layer.succeed(ProcessRunner, { run: () => Effect.die("ProcessRunner should not be used by Windows TCP probe tests"), }); @@ -57,166 +53,6 @@ const commonDevServer = Effect.acquireRelease( ({ server }) => closeServer(server), ); -describe("parsePortFromLsofName", () => { - it("parses *:port", () => { - expect(parsePortFromLsofName("*:5173")).toBe(5173); - }); - - it("parses 127.0.0.1:port", () => { - expect(parsePortFromLsofName("127.0.0.1:5173")).toBe(5173); - }); - - it("parses localhost:port", () => { - expect(parsePortFromLsofName("localhost:5173")).toBe(5173); - }); - - it("parses [::1]:port", () => { - expect(parsePortFromLsofName("[::1]:5173")).toBe(5173); - }); - - it("ignores non-local hosts", () => { - expect(parsePortFromLsofName("192.168.1.10:5173")).toBeNull(); - }); - - it("strips trailing description", () => { - expect(parsePortFromLsofName("*:5173 (LISTEN)")).toBe(5173); - }); - - it("rejects garbage", () => { - expect(parsePortFromLsofName("")).toBeNull(); - expect(parsePortFromLsofName("not-a-port")).toBeNull(); - expect(parsePortFromLsofName("*:0")).toBeNull(); - expect(parsePortFromLsofName("*:99999")).toBeNull(); - }); -}); - -describe("parseLsofOutput", () => { - it("parses a typical lsof -F pcn output", () => { - const sample = [ - "p12345", - "cnode", - "n*:5173", - "p67890", - "cnext-server", - "n127.0.0.1:3000", - "n127.0.0.1:9229", // node debug port too — same process - "p13579", - "cChrome", - "n192.168.1.10:443", // not local — ignored - ].join("\n"); - - const servers = parseLsofOutput(sample); - expect(servers).toEqual([ - { - host: "localhost", - port: 3000, - url: "http://localhost:3000", - processName: "next-server", - pid: 67890, - terminal: null, - }, - { - host: "localhost", - port: 5173, - url: "http://localhost:5173", - processName: "node", - pid: 12345, - terminal: null, - }, - { - host: "localhost", - port: 9229, - url: "http://localhost:9229", - processName: "next-server", - pid: 67890, - terminal: null, - }, - ]); - }); - - it("handles empty input", () => { - expect(parseLsofOutput("")).toEqual([]); - }); - - it("dedupes by host:port", () => { - const sample = ["p1", "cnode", "n*:5173", "n127.0.0.1:5173"].join("\n"); - const servers = parseLsofOutput(sample); - expect(servers).toHaveLength(1); - expect(servers[0]?.port).toBe(5173); - }); - - it("attributes listeners to a registered terminal process", () => { - const servers = parseLsofOutput( - ["p12345", "cnode", "n*:5173"].join("\n"), - new Map([ - [ - 12345, - { - threadId: ThreadId.make("thread-1"), - terminalId: "terminal-1", - }, - ], - ]), - ); - - expect(servers[0]?.terminal).toEqual({ - threadId: "thread-1", - terminalId: "terminal-1", - }); - }); -}); - -describe("serversEqual", () => { - const a = { - host: "localhost", - port: 5173, - url: "http://localhost:5173", - processName: "node", - pid: 1, - terminal: null, - }; - it("returns true for identical lists", () => { - expect(serversEqual([a], [{ ...a }])).toBe(true); - }); - it("returns false for different lengths", () => { - expect(serversEqual([a], [])).toBe(false); - }); - it("returns false for different processName", () => { - expect(serversEqual([a], [{ ...a, processName: "other" }])).toBe(false); - }); -}); - -describe("parseWindowsListenerOutput", () => { - it("parses and attributes PowerShell listener records", () => { - const servers = parseWindowsListenerOutput( - "0.0.0.0|5173|12345|node", - new Map([ - [ - 12345, - { - threadId: ThreadId.make("thread-1"), - terminalId: "terminal-1", - }, - ], - ]), - ); - - expect(servers).toEqual([ - { - host: "localhost", - port: 5173, - url: "http://localhost:5173", - processName: "node", - pid: 12345, - terminal: { - threadId: "thread-1", - terminalId: "terminal-1", - }, - }, - ]); - }); -}); - /** * Integration tests against a real TCP listener. We provide the Windows host * platform so the tests exercise the TCP-probe fallback without depending on diff --git a/apps/server/src/preview/PortScanner.ts b/apps/server/src/preview/PortScanner.ts index d3413adcd98..183d5d4f009 100644 --- a/apps/server/src/preview/PortScanner.ts +++ b/apps/server/src/preview/PortScanner.ts @@ -361,11 +361,3 @@ const make = Effect.gen(function* PortDiscoveryMake() { }).pipe(Effect.withSpan("PortDiscovery.make")); export const layer = Layer.effect(PortDiscovery, make); - -/** Exposed for tests. */ -export const __testing = { - parseLsofOutput, - parsePortFromLsofName, - parseWindowsListenerOutput, - serversEqual, -}; diff --git a/apps/server/src/process/externalLauncher.test.ts b/apps/server/src/process/externalLauncher.test.ts index 58914b17f7b..ca155e9306e 100644 --- a/apps/server/src/process/externalLauncher.test.ts +++ b/apps/server/src/process/externalLauncher.test.ts @@ -1,10 +1,7 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, it } from "@effect/vitest"; -import { assertSuccess } from "@effect/vitest/utils"; -import * as Crypto from "effect/Crypto"; import * as ConfigProvider from "effect/ConfigProvider"; 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 Path from "effect/Path"; @@ -13,24 +10,7 @@ import * as Stream from "effect/Stream"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; -import { - isCommandAvailableForPlatform, - launchBrowser, - launchEditorProcess, - resolveAvailableEditors, - resolveBrowserLaunch, - resolveEditorLaunch, -} from "./externalLauncher.ts"; - -function encodeUtf16LeBase64(input: string): string { - const bytes = new Uint8Array(input.length * 2); - for (let index = 0; index < input.length; index += 1) { - const code = input.charCodeAt(index); - bytes[index * 2] = code & 0xff; - bytes[index * 2 + 1] = code >>> 8; - } - return Encoding.encodeBase64(bytes); -} +import { ExternalLauncher, layer as ExternalLauncherLive } from "./externalLauncher.ts"; function makeMockDetachedHandle(onUnref: () => void = () => undefined) { return ChildProcessSpawner.makeHandle({ @@ -51,792 +31,128 @@ function makeMockDetachedHandle(onUnref: () => void = () => undefined) { }); } -const withHostRuntime = (input: { +const testLayer = (input: { readonly platform: NodeJS.Platform; readonly env?: Record; -}) => - Layer.merge( + readonly onSpawn?: (command: ChildProcess.StandardCommand) => void; + readonly onUnref?: () => void; +}) => { + const spawnerLayer = Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make((command) => + Effect.sync(() => { + assert.equal(ChildProcess.isStandardCommand(command), true); + if (!ChildProcess.isStandardCommand(command)) { + throw new Error("Expected a standard command"); + } + input.onSpawn?.(command); + return makeMockDetachedHandle(input.onUnref); + }), + ), + ); + + return Layer.mergeAll( + ExternalLauncherLive.pipe(Layer.provide(Layer.merge(NodeServices.layer, spawnerLayer))), Layer.succeed(HostProcessPlatform, input.platform), ConfigProvider.layer(ConfigProvider.fromEnv({ env: input.env ?? {} })), ); - -it.layer(NodeServices.layer)("resolveEditorLaunch", (it) => { - it.effect("returns commands for command-based editors", () => - Effect.gen(function* () { - const darwinRuntime = withHostRuntime({ platform: "darwin", env: { PATH: "" } }); - const antigravityLaunch = yield* resolveEditorLaunch({ - cwd: "/tmp/workspace", - editor: "antigravity", - }).pipe(Effect.provide(darwinRuntime)); - assert.deepEqual(antigravityLaunch, { - command: "agy", - args: ["/tmp/workspace"], - }); - - const cursorLaunch = yield* resolveEditorLaunch({ - cwd: "/tmp/workspace", - editor: "cursor", - }).pipe(Effect.provide(darwinRuntime)); - assert.deepEqual(cursorLaunch, { - command: "cursor", - args: ["/tmp/workspace"], - }); - - const traeLaunch = yield* resolveEditorLaunch({ cwd: "/tmp/workspace", editor: "trae" }).pipe( - Effect.provide(darwinRuntime), - ); - assert.deepEqual(traeLaunch, { - command: "trae", - args: ["/tmp/workspace"], - }); - - const kiroLaunch = yield* resolveEditorLaunch({ cwd: "/tmp/workspace", editor: "kiro" }).pipe( - Effect.provide(darwinRuntime), - ); - assert.deepEqual(kiroLaunch, { - command: "kiro", - args: ["ide", "/tmp/workspace"], - }); - - const vscodeLaunch = yield* resolveEditorLaunch({ - cwd: "/tmp/workspace", - editor: "vscode", - }).pipe(Effect.provide(darwinRuntime)); - assert.deepEqual(vscodeLaunch, { - command: "code", - args: ["/tmp/workspace"], - }); - - const vscodeInsidersLaunch = yield* resolveEditorLaunch({ - cwd: "/tmp/workspace", - editor: "vscode-insiders", - }).pipe(Effect.provide(darwinRuntime)); - assert.deepEqual(vscodeInsidersLaunch, { - command: "code-insiders", - args: ["/tmp/workspace"], - }); - - const vscodiumLaunch = yield* resolveEditorLaunch({ - cwd: "/tmp/workspace", - editor: "vscodium", - }).pipe(Effect.provide(darwinRuntime)); - assert.deepEqual(vscodiumLaunch, { - command: "codium", - args: ["/tmp/workspace"], - }); - - const zedLaunch = yield* resolveEditorLaunch({ cwd: "/tmp/workspace", editor: "zed" }).pipe( - Effect.provide(darwinRuntime), - ); - assert.deepEqual(zedLaunch, { - command: "zed", - args: ["/tmp/workspace"], - }); - - const ideaLaunch = yield* resolveEditorLaunch({ cwd: "/tmp/workspace", editor: "idea" }).pipe( - Effect.provide(darwinRuntime), - ); - assert.deepEqual(ideaLaunch, { - command: "idea", - args: ["/tmp/workspace"], - }); - - const aquaLaunch = yield* resolveEditorLaunch({ cwd: "/tmp/workspace", editor: "aqua" }).pipe( - Effect.provide(darwinRuntime), - ); - assert.deepEqual(aquaLaunch, { - command: "aqua", - args: ["/tmp/workspace"], - }); - - const clionLaunch = yield* resolveEditorLaunch({ - cwd: "/tmp/workspace", - editor: "clion", - }).pipe(Effect.provide(darwinRuntime)); - assert.deepEqual(clionLaunch, { - command: "clion", - args: ["/tmp/workspace"], - }); - - const datagripLaunch = yield* resolveEditorLaunch({ - cwd: "/tmp/workspace", - editor: "datagrip", - }).pipe(Effect.provide(darwinRuntime)); - assert.deepEqual(datagripLaunch, { - command: "datagrip", - args: ["/tmp/workspace"], - }); - - const dataspellLaunch = yield* resolveEditorLaunch({ - cwd: "/tmp/workspace", - editor: "dataspell", - }).pipe(Effect.provide(darwinRuntime)); - assert.deepEqual(dataspellLaunch, { - command: "dataspell", - args: ["/tmp/workspace"], - }); - - const golandLaunch = yield* resolveEditorLaunch({ - cwd: "/tmp/workspace", - editor: "goland", - }).pipe(Effect.provide(darwinRuntime)); - assert.deepEqual(golandLaunch, { - command: "goland", - args: ["/tmp/workspace"], - }); - - const phpstormLaunch = yield* resolveEditorLaunch({ - cwd: "/tmp/workspace", - editor: "phpstorm", - }).pipe(Effect.provide(darwinRuntime)); - assert.deepEqual(phpstormLaunch, { - command: "phpstorm", - args: ["/tmp/workspace"], - }); - - const pycharmLaunch = yield* resolveEditorLaunch({ - cwd: "/tmp/workspace", - editor: "pycharm", - }).pipe(Effect.provide(darwinRuntime)); - assert.deepEqual(pycharmLaunch, { - command: "pycharm", - args: ["/tmp/workspace"], - }); - - const riderLaunch = yield* resolveEditorLaunch({ - cwd: "/tmp/workspace", - editor: "rider", - }).pipe(Effect.provide(darwinRuntime)); - assert.deepEqual(riderLaunch, { - command: "rider", - args: ["/tmp/workspace"], - }); - - const rubymineLaunch = yield* resolveEditorLaunch({ - cwd: "/tmp/workspace", - editor: "rubymine", - }).pipe(Effect.provide(darwinRuntime)); - assert.deepEqual(rubymineLaunch, { - command: "rubymine", - args: ["/tmp/workspace"], - }); - - const rustroverLaunch = yield* resolveEditorLaunch({ - cwd: "/tmp/workspace", - editor: "rustrover", - }).pipe(Effect.provide(darwinRuntime)); - assert.deepEqual(rustroverLaunch, { - command: "rustrover", - args: ["/tmp/workspace"], - }); - - const webstormLaunch = yield* resolveEditorLaunch({ - cwd: "/tmp/workspace", - editor: "webstorm", - }).pipe(Effect.provide(darwinRuntime)); - assert.deepEqual(webstormLaunch, { - command: "webstorm", - args: ["/tmp/workspace"], - }); - }), - ); - - it.effect("applies launch-style-specific navigation arguments", () => - Effect.gen(function* () { - const darwinRuntime = withHostRuntime({ platform: "darwin", env: { PATH: "" } }); - const lineOnly = yield* resolveEditorLaunch({ - cwd: "/tmp/workspace/AGENTS.md:48", - editor: "cursor", - }).pipe(Effect.provide(darwinRuntime)); - assert.deepEqual(lineOnly, { - command: "cursor", - args: ["--goto", "/tmp/workspace/AGENTS.md:48"], - }); - - const lineAndColumn = yield* resolveEditorLaunch({ - cwd: "/tmp/workspace/src/process/externalLauncher.ts:71:5", - editor: "cursor", - }).pipe(Effect.provide(darwinRuntime)); - assert.deepEqual(lineAndColumn, { - command: "cursor", - args: ["--goto", "/tmp/workspace/src/process/externalLauncher.ts:71:5"], - }); - - const traeLineAndColumn = yield* resolveEditorLaunch({ - cwd: "/tmp/workspace/src/process/externalLauncher.ts:71:5", - editor: "trae", - }).pipe(Effect.provide(darwinRuntime)); - assert.deepEqual(traeLineAndColumn, { - command: "trae", - args: ["--goto", "/tmp/workspace/src/process/externalLauncher.ts:71:5"], - }); - - const kiroLineAndColumn = yield* resolveEditorLaunch({ - cwd: "/tmp/workspace/src/process/externalLauncher.ts:71:5", - editor: "kiro", - }).pipe(Effect.provide(darwinRuntime)); - assert.deepEqual(kiroLineAndColumn, { - command: "kiro", - args: ["ide", "--goto", "/tmp/workspace/src/process/externalLauncher.ts:71:5"], - }); - - const vscodeLineAndColumn = yield* resolveEditorLaunch({ - cwd: "/tmp/workspace/src/process/externalLauncher.ts:71:5", - editor: "vscode", - }).pipe(Effect.provide(darwinRuntime)); - assert.deepEqual(vscodeLineAndColumn, { - command: "code", - args: ["--goto", "/tmp/workspace/src/process/externalLauncher.ts:71:5"], - }); - - const vscodeInsidersLineAndColumn = yield* resolveEditorLaunch({ - cwd: "/tmp/workspace/src/process/externalLauncher.ts:71:5", - editor: "vscode-insiders", - }).pipe(Effect.provide(darwinRuntime)); - assert.deepEqual(vscodeInsidersLineAndColumn, { - command: "code-insiders", - args: ["--goto", "/tmp/workspace/src/process/externalLauncher.ts:71:5"], - }); - - const vscodiumLineAndColumn = yield* resolveEditorLaunch({ - cwd: "/tmp/workspace/src/process/externalLauncher.ts:71:5", - editor: "vscodium", - }).pipe(Effect.provide(darwinRuntime)); - assert.deepEqual(vscodiumLineAndColumn, { - command: "codium", - args: ["--goto", "/tmp/workspace/src/process/externalLauncher.ts:71:5"], - }); - - const zedLineAndColumn = yield* resolveEditorLaunch({ - cwd: "/tmp/workspace/src/process/externalLauncher.ts:71:5", - editor: "zed", - }).pipe(Effect.provide(darwinRuntime)); - assert.deepEqual(zedLineAndColumn, { - command: "zed", - args: ["/tmp/workspace/src/process/externalLauncher.ts:71:5"], - }); - - const zedLineOnly = yield* resolveEditorLaunch({ - cwd: "/tmp/workspace/AGENTS.md:48", - editor: "zed", - }).pipe(Effect.provide(darwinRuntime)); - assert.deepEqual(zedLineOnly, { - command: "zed", - args: ["/tmp/workspace/AGENTS.md:48"], - }); - - const ideaLineOnly = yield* resolveEditorLaunch({ - cwd: "/tmp/workspace/AGENTS.md:48", - editor: "idea", - }).pipe(Effect.provide(darwinRuntime)); - assert.deepEqual(ideaLineOnly, { - command: "idea", - args: ["--line", "48", "/tmp/workspace/AGENTS.md"], - }); - - const ideaLineAndColumn = yield* resolveEditorLaunch({ - cwd: "/tmp/workspace/src/process/externalLauncher.ts:71:5", - editor: "idea", - }).pipe(Effect.provide(darwinRuntime)); - assert.deepEqual(ideaLineAndColumn, { - command: "idea", - args: ["--line", "71", "--column", "5", "/tmp/workspace/src/process/externalLauncher.ts"], - }); - - const aquaLineAndColumn = yield* resolveEditorLaunch({ - cwd: "/tmp/workspace/src/process/externalLauncher.ts:71:5", - editor: "aqua", - }).pipe(Effect.provide(darwinRuntime)); - assert.deepEqual(aquaLineAndColumn, { - command: "aqua", - args: ["--line", "71", "--column", "5", "/tmp/workspace/src/process/externalLauncher.ts"], - }); - - const clionLineAndColumn = yield* resolveEditorLaunch({ - cwd: "/tmp/workspace/src/process/externalLauncher.ts:71:5", - editor: "clion", - }).pipe(Effect.provide(darwinRuntime)); - assert.deepEqual(clionLineAndColumn, { - command: "clion", - args: ["--line", "71", "--column", "5", "/tmp/workspace/src/process/externalLauncher.ts"], - }); - - const datagripLineAndColumn = yield* resolveEditorLaunch({ - cwd: "/tmp/workspace/src/process/externalLauncher.ts:71:5", - editor: "datagrip", - }).pipe(Effect.provide(darwinRuntime)); - assert.deepEqual(datagripLineAndColumn, { - command: "datagrip", - args: ["--line", "71", "--column", "5", "/tmp/workspace/src/process/externalLauncher.ts"], - }); - - const dataspellLineAndColumn = yield* resolveEditorLaunch({ - cwd: "/tmp/workspace/src/process/externalLauncher.ts:71:5", - editor: "dataspell", - }).pipe(Effect.provide(darwinRuntime)); - assert.deepEqual(dataspellLineAndColumn, { - command: "dataspell", - args: ["--line", "71", "--column", "5", "/tmp/workspace/src/process/externalLauncher.ts"], - }); - - const golandLineAndColumn = yield* resolveEditorLaunch({ - cwd: "/tmp/workspace/src/process/externalLauncher.ts:71:5", - editor: "goland", - }).pipe(Effect.provide(darwinRuntime)); - assert.deepEqual(golandLineAndColumn, { - command: "goland", - args: ["--line", "71", "--column", "5", "/tmp/workspace/src/process/externalLauncher.ts"], - }); - - const phpstormLineAndColumn = yield* resolveEditorLaunch({ - cwd: "/tmp/workspace/src/process/externalLauncher.ts:71:5", - editor: "phpstorm", - }).pipe(Effect.provide(darwinRuntime)); - assert.deepEqual(phpstormLineAndColumn, { - command: "phpstorm", - args: ["--line", "71", "--column", "5", "/tmp/workspace/src/process/externalLauncher.ts"], - }); - - const pycharmLineAndColumn = yield* resolveEditorLaunch({ - cwd: "/tmp/workspace/src/process/externalLauncher.ts:71:5", - editor: "pycharm", - }).pipe(Effect.provide(darwinRuntime)); - assert.deepEqual(pycharmLineAndColumn, { - command: "pycharm", - args: ["--line", "71", "--column", "5", "/tmp/workspace/src/process/externalLauncher.ts"], - }); - - const riderLineAndColumn = yield* resolveEditorLaunch({ - cwd: "/tmp/workspace/src/process/externalLauncher.ts:71:5", - editor: "rider", - }).pipe(Effect.provide(darwinRuntime)); - assert.deepEqual(riderLineAndColumn, { - command: "rider", - args: ["--line", "71", "--column", "5", "/tmp/workspace/src/process/externalLauncher.ts"], - }); - - const rubymineLineAndColumn = yield* resolveEditorLaunch({ - cwd: "/tmp/workspace/src/process/externalLauncher.ts:71:5", - editor: "rubymine", - }).pipe(Effect.provide(darwinRuntime)); - assert.deepEqual(rubymineLineAndColumn, { - command: "rubymine", - args: ["--line", "71", "--column", "5", "/tmp/workspace/src/process/externalLauncher.ts"], - }); - - const rustroverLineAndColumn = yield* resolveEditorLaunch({ - cwd: "/tmp/workspace/src/process/externalLauncher.ts:71:5", - editor: "rustrover", - }).pipe(Effect.provide(darwinRuntime)); - assert.deepEqual(rustroverLineAndColumn, { - command: "rustrover", - args: ["--line", "71", "--column", "5", "/tmp/workspace/src/process/externalLauncher.ts"], - }); - - const webstormLineOnly = yield* resolveEditorLaunch({ - cwd: "/tmp/workspace/AGENTS.md:48", - editor: "webstorm", - }).pipe(Effect.provide(darwinRuntime)); - assert.deepEqual(webstormLineOnly, { - command: "webstorm", - args: ["--line", "48", "/tmp/workspace/AGENTS.md"], - }); - }), - ); - - it.effect("falls back to zeditor when zed is not installed", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-external-launcher-test-" }); - yield* fs.writeFileString(path.join(dir, "zeditor"), "#!/bin/sh\nexit 0\n"); - yield* fs.chmod(path.join(dir, "zeditor"), 0o755); - - const result = yield* resolveEditorLaunch({ cwd: "/tmp/workspace", editor: "zed" }).pipe( - Effect.provide(withHostRuntime({ platform: "linux", env: { PATH: dir } })), - ); - - assert.deepEqual(result, { - command: "zeditor", - args: ["/tmp/workspace"], - }); - }), - ); - - it.effect("falls back to the primary command when no alias is installed", () => - Effect.gen(function* () { - const result = yield* resolveEditorLaunch({ cwd: "/tmp/workspace", editor: "zed" }).pipe( - Effect.provide(withHostRuntime({ platform: "linux", env: { PATH: "" } })), - ); - assert.deepEqual(result, { - command: "zed", - args: ["/tmp/workspace"], - }); - }), - ); - - it.effect("maps file-manager editor to OS open commands", () => - Effect.gen(function* () { - const darwinRuntime = withHostRuntime({ platform: "darwin", env: { PATH: "" } }); - const windowsRuntime = withHostRuntime({ platform: "win32", env: { PATH: "" } }); - const linuxRuntime = withHostRuntime({ platform: "linux", env: { PATH: "" } }); - - const launch1 = yield* resolveEditorLaunch({ - cwd: "/tmp/workspace", - editor: "file-manager", - }).pipe(Effect.provide(darwinRuntime)); - assert.deepEqual(launch1, { - command: "open", - args: ["/tmp/workspace"], - }); - - const launch2 = yield* resolveEditorLaunch({ - cwd: "C:\\workspace", - editor: "file-manager", - }).pipe(Effect.provide(windowsRuntime)); - assert.deepEqual(launch2, { - command: "explorer", - args: ["C:\\workspace"], - }); - - const launch3 = yield* resolveEditorLaunch({ - cwd: "/tmp/workspace", - editor: "file-manager", - }).pipe(Effect.provide(linuxRuntime)); - assert.deepEqual(launch3, { - command: "xdg-open", - args: ["/tmp/workspace"], - }); - }), +}; + +it.effect("launches the default browser through the platform command", () => { + let spawned: ChildProcess.StandardCommand | undefined; + let didUnref = false; + return Effect.gen(function* () { + const launcher = yield* ExternalLauncher; + + yield* launcher.launchBrowser("https://example.com/some path"); + + assert.ok(spawned); + assert.equal(spawned.command, "xdg-open"); + assert.deepEqual(spawned.args, ["https://example.com/some path"]); + assert.equal(spawned.options.detached, true); + assert.equal(didUnref, true); + }).pipe( + Effect.provide( + testLayer({ + platform: "linux", + onSpawn: (command) => { + spawned = command; + }, + onUnref: () => { + didUnref = true; + }, + }), + ), ); }); -it.effect("resolveBrowserLaunch maps default browser launchers by platform", () => +it.effect("launches an installed editor with platform-safe arguments", () => Effect.gen(function* () { - const target = "https://example.com/some path?name=o'hara"; - - const darwin = yield* resolveBrowserLaunch(target).pipe( - Effect.provide(withHostRuntime({ platform: "darwin" })), - ); - assert.deepEqual(darwin.command, "open"); - assert.deepEqual(darwin.args, [target]); - assert.deepEqual(darwin.options, { - detached: true, - stdin: "ignore", - stdout: "ignore", - stderr: "ignore", - }); - - const linux = yield* resolveBrowserLaunch(target).pipe( - Effect.provide(withHostRuntime({ platform: "linux" })), + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const binDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-editors-" }); + yield* fileSystem.writeFileString(path.join(binDir, "code.CMD"), "@echo off\r\n"); + + let spawned: ChildProcess.StandardCommand | undefined; + yield* Effect.gen(function* () { + const launcher = yield* ExternalLauncher; + yield* launcher.launchEditor({ + editor: "vscode", + cwd: "C:\\workspace with spaces\\src\\index.ts:12:4", + }); + }).pipe( + Effect.provide( + testLayer({ + platform: "win32", + env: { PATH: binDir, PATHEXT: ".COM;.EXE;.BAT;.CMD" }, + onSpawn: (command) => { + spawned = command; + }, + }), + ), ); - assert.deepEqual(linux.command, "xdg-open"); - assert.deepEqual(linux.args, [target]); - const windows = yield* resolveBrowserLaunch(target).pipe( - Effect.provide(withHostRuntime({ platform: "win32", env: { SYSTEMROOT: "C:\\Windows" } })), - ); - assert.equal(windows.command, "C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe"); - assert.deepEqual(windows.args, [ - "-NoProfile", - "-NonInteractive", - "-ExecutionPolicy", - "Bypass", - "-EncodedCommand", - encodeUtf16LeBase64( - "$ProgressPreference = 'SilentlyContinue'; Start 'https://example.com/some path?name=o''hara'", - ), + assert.ok(spawned); + assert.equal(spawned.command, "code"); + assert.deepEqual(spawned.args, [ + '^"--goto^"', + '^"C:\\workspace^ with^ spaces\\src\\index.ts:12:4^"', ]); - assert.deepEqual(windows.options, { - detached: true, - shell: false, - stdin: "ignore", - stdout: "ignore", - stderr: "ignore", - }); - }), -); - -it.effect("resolveBrowserLaunch opens through Windows from WSL when not remote", () => - Effect.gen(function* () { - const launch = yield* resolveBrowserLaunch("https://example.com").pipe( - Effect.provide(withHostRuntime({ platform: "linux", env: { WSL_DISTRO_NAME: "Ubuntu" } })), - ); - assert.equal(launch.command, "/mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe"); - assert.equal(launch.options.detached, true); - }), + assert.equal(spawned.options.shell, true); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); -it.effect("resolveBrowserLaunch keeps xdg-open for WSL over SSH", () => +it.effect("discovers editors through the service API", () => Effect.gen(function* () { - const launch = yield* resolveBrowserLaunch("https://example.com").pipe( + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const binDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-editors-" }); + yield* fileSystem.writeFileString(path.join(binDir, "code.CMD"), "@echo off\r\n"); + yield* fileSystem.writeFileString(path.join(binDir, "explorer.CMD"), "@echo off\r\n"); + + const editors = yield* Effect.gen(function* () { + const launcher = yield* ExternalLauncher; + return yield* launcher.resolveAvailableEditors(); + }).pipe( Effect.provide( - withHostRuntime({ - platform: "linux", - env: { WSL_DISTRO_NAME: "Ubuntu", SSH_CONNECTION: "client server" }, + testLayer({ + platform: "win32", + env: { PATH: binDir, PATHEXT: ".COM;.EXE;.BAT;.CMD" }, }), ), ); - assert.equal(launch.command, "xdg-open"); - }), -); - -it.layer(NodeServices.layer)("launchBrowser", (it) => { - it.effect("spawns through the ChildProcessSpawner service and unrefs the handle", () => - Effect.gen(function* () { - let spawnedCommand: ChildProcess.StandardCommand | undefined; - let didUnref = false; - const platform = "linux" satisfies NodeJS.Platform; - const env = {}; - - const spawnerLayer = Layer.mock(ChildProcessSpawner.ChildProcessSpawner, { - spawn: (command) => - Effect.sync(() => { - assert.equal(ChildProcess.isStandardCommand(command), true); - if (!ChildProcess.isStandardCommand(command)) { - throw new Error("Expected a standard command"); - } - spawnedCommand = command; - return makeMockDetachedHandle(() => { - didUnref = true; - }); - }), - }); - - const result = yield* launchBrowser("https://example.com").pipe( - Effect.provide(Layer.merge(spawnerLayer, withHostRuntime({ platform, env }))), - Effect.result, - ); - - assertSuccess(result, undefined); - assert.ok(spawnedCommand); - assert.equal(spawnedCommand.command, "xdg-open"); - assert.deepEqual(spawnedCommand.args, ["https://example.com"]); - assert.deepEqual(spawnedCommand.options, { - detached: true, - stdin: "ignore", - stdout: "ignore", - stderr: "ignore", - }); - assert.equal(didUnref, true); - }), - ); -}); - -it.layer(NodeServices.layer)("launchEditorProcess", (it) => { - it.effect("spawns through the ChildProcessSpawner service and unrefs the handle", () => - Effect.gen(function* () { - let spawnedCommand: ChildProcess.StandardCommand | undefined; - let didUnref = false; - const expectedArgs = ["-e", "process.exit(0)"]; - const platform = "linux" satisfies NodeJS.Platform; - - const spawnerLayer = Layer.mock(ChildProcessSpawner.ChildProcessSpawner, { - spawn: (command) => - Effect.sync(() => { - assert.equal(ChildProcess.isStandardCommand(command), true); - if (!ChildProcess.isStandardCommand(command)) { - throw new Error("Expected a standard command"); - } - spawnedCommand = command; - return makeMockDetachedHandle(() => { - didUnref = true; - }); - }), - }); - - const result = yield* launchEditorProcess({ - command: process.execPath, - args: expectedArgs, - }).pipe( - Effect.provide(Layer.merge(spawnerLayer, withHostRuntime({ platform }))), - Effect.result, - ); - - assertSuccess(result, undefined); - assert.ok(spawnedCommand); - assert.equal(spawnedCommand.command, process.execPath); - assert.deepEqual(spawnedCommand.args, expectedArgs); - assert.deepEqual(spawnedCommand.options, { - detached: true, - shell: false, - stdin: "ignore", - stdout: "ignore", - stderr: "ignore", - }); - assert.equal(didUnref, true); - }), - ); - it.effect("rejects when command does not exist", () => - Effect.gen(function* () { - const spawnerLayer = Layer.mock(ChildProcessSpawner.ChildProcessSpawner, {}); - const result = yield* launchEditorProcess({ - command: `t3code-no-such-command-${yield* Crypto.Crypto.pipe( - Effect.flatMap((crypto) => crypto.randomUUIDv4), - )}`, - args: [], - }).pipe(Effect.provide(spawnerLayer), Effect.result); - assert.equal(result._tag, "Failure"); - }), - ); -}); - -it.layer(NodeServices.layer)("isCommandAvailable", (it) => { - it.effect("resolves win32 commands with PATHEXT", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-external-launcher-test-" }); - yield* fs.writeFileString(path.join(dir, "code.CMD"), "@echo off\r\n"); - const env = { - PATH: dir, - PATHEXT: ".COM;.EXE;.BAT;.CMD", - } satisfies NodeJS.ProcessEnv; - assert.equal(yield* isCommandAvailableForPlatform("code", { platform: "win32", env }), true); - }), - ); - - 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* isCommandAvailableForPlatform("definitely-not-installed", { - platform: "win32", - env, - }), - false, - ); - }), - ); - - it.effect("does not treat bare files without executable extension as available on win32", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-external-launcher-test-" }); - yield* fs.writeFileString(path.join(dir, "npm"), "echo nope\r\n"); - const env = { - PATH: dir, - PATHEXT: ".COM;.EXE;.BAT;.CMD", - } satisfies NodeJS.ProcessEnv; - assert.equal(yield* isCommandAvailableForPlatform("npm", { platform: "win32", env }), false); - }), - ); - - it.effect("appends PATHEXT for commands with non-executable extensions on win32", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-external-launcher-test-" }); - yield* fs.writeFileString(path.join(dir, "my.tool.CMD"), "@echo off\r\n"); - const env = { - PATH: dir, - PATHEXT: ".COM;.EXE;.BAT;.CMD", - } satisfies NodeJS.ProcessEnv; - assert.equal( - yield* isCommandAvailableForPlatform("my.tool", { platform: "win32", env }), - true, - ); - }), - ); - - it.effect("uses platform-specific PATH delimiter for platform overrides", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const firstDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-external-launcher-test-" }); - const secondDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-external-launcher-test-" }); - yield* fs.writeFileString(path.join(firstDir, "code.CMD"), "@echo off\r\n"); - yield* fs.writeFileString(path.join(secondDir, "code.CMD"), "MZ"); - const env = { - PATH: `${firstDir};${secondDir}`, - PATHEXT: ".COM;.EXE;.BAT;.CMD", - } satisfies NodeJS.ProcessEnv; - assert.equal(yield* isCommandAvailableForPlatform("code", { platform: "win32", env }), true); - }), - ); -}); - -it.layer(NodeServices.layer)("resolveAvailableEditors", (it) => { - it.effect("returns installed editors for command launches", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-editors-" }); - - yield* fs.writeFileString(path.join(dir, "trae.CMD"), "@echo off\r\n"); - yield* fs.writeFileString(path.join(dir, "kiro.CMD"), "@echo off\r\n"); - yield* fs.writeFileString(path.join(dir, "code-insiders.CMD"), "@echo off\r\n"); - yield* fs.writeFileString(path.join(dir, "codium.CMD"), "@echo off\r\n"); - yield* fs.writeFileString(path.join(dir, "aqua.CMD"), "@echo off\r\n"); - yield* fs.writeFileString(path.join(dir, "clion.CMD"), "@echo off\r\n"); - yield* fs.writeFileString(path.join(dir, "datagrip.CMD"), "@echo off\r\n"); - yield* fs.writeFileString(path.join(dir, "dataspell.CMD"), "@echo off\r\n"); - yield* fs.writeFileString(path.join(dir, "goland.CMD"), "@echo off\r\n"); - yield* fs.writeFileString(path.join(dir, "phpstorm.CMD"), "@echo off\r\n"); - yield* fs.writeFileString(path.join(dir, "pycharm.CMD"), "@echo off\r\n"); - yield* fs.writeFileString(path.join(dir, "rider.CMD"), "@echo off\r\n"); - yield* fs.writeFileString(path.join(dir, "rubymine.CMD"), "@echo off\r\n"); - 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 = yield* resolveAvailableEditors().pipe( - Effect.provide( - withHostRuntime({ - platform: "win32", - env: { PATH: dir, PATHEXT: ".COM;.EXE;.BAT;.CMD" }, - }), - ), - ); - assert.deepEqual(editors, [ - "trae", - "kiro", - "vscode-insiders", - "vscodium", - "aqua", - "clion", - "datagrip", - "dataspell", - "goland", - "phpstorm", - "pycharm", - "rider", - "rubymine", - "rustrover", - "webstorm", - "file-manager", - ]); - }), - ); - - it.effect("includes zed when only the zeditor command is installed", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-editors-" }); - - yield* fs.writeFileString(path.join(dir, "zeditor"), "#!/bin/sh\nexit 0\n"); - yield* fs.writeFileString(path.join(dir, "xdg-open"), "#!/bin/sh\nexit 0\n"); - yield* fs.chmod(path.join(dir, "zeditor"), 0o755); - yield* fs.chmod(path.join(dir, "xdg-open"), 0o755); - - const editors = yield* resolveAvailableEditors().pipe( - Effect.provide(withHostRuntime({ platform: "linux", env: { PATH: dir } })), - ); - assert.deepEqual(editors, ["zed", "file-manager"]); - }), - ); + assert.equal(editors.includes("vscode"), true); + assert.equal(editors.includes("file-manager"), true); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); - it.effect("omits file-manager when the platform opener is unavailable", () => - Effect.gen(function* () { - const editors = yield* resolveAvailableEditors().pipe( - Effect.provide(withHostRuntime({ platform: "linux", env: { PATH: "" } })), - ); - assert.deepEqual(editors, []); - }), - ); -}); +it.effect("rejects unknown editors through the service API", () => + Effect.gen(function* () { + const launcher = yield* ExternalLauncher; + const result = yield* launcher + .launchEditor({ editor: "missing-editor" as never, cwd: "/tmp/workspace" }) + .pipe(Effect.result); + assert.equal(result._tag, "Failure"); + }).pipe(Effect.provide(testLayer({ platform: "linux", env: { PATH: "" } }))), +); diff --git a/apps/server/src/process/externalLauncher.ts b/apps/server/src/process/externalLauncher.ts index c4647dd5f75..20dfc1b6d92 100644 --- a/apps/server/src/process/externalLauncher.ts +++ b/apps/server/src/process/externalLauncher.ts @@ -13,11 +13,7 @@ import { type LaunchEditorInput, } from "@t3tools/contracts"; import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; -import { - isCommandAvailableForPlatform, - sanitizeShellModeArgs, - type PlatformCommandAvailabilityOptions, -} from "@t3tools/shared/shell"; +import { isCommandAvailable, sanitizeShellModeArgs } from "@t3tools/shared/shell"; import * as Config from "effect/Config"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; @@ -34,8 +30,6 @@ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; export { ExternalLauncherError }; export type { LaunchEditorInput }; -export { isCommandAvailableForPlatform } from "@t3tools/shared/shell"; - interface EditorLaunch { readonly command: string; readonly args: ReadonlyArray; @@ -149,10 +143,10 @@ function resolveEditorArgs( const resolveAvailableCommand = Effect.fn("externalLauncher.resolveAvailableCommand")(function* ( commands: ReadonlyArray, - options: PlatformCommandAvailabilityOptions, + env: NodeJS.ProcessEnv, ): Effect.fn.Return, never, FileSystem.FileSystem | Path.Path> { for (const command of commands) { - if (yield* isCommandAvailableForPlatform(command, options)) { + if (yield* isCommandAvailable(command, { env })) { return Option.some(command); } } @@ -259,13 +253,13 @@ const buildAvailableEditors = Effect.fn("externalLauncher.buildAvailableEditors" for (const editor of EDITORS) { if (editor.commands === null) { const command = fileManagerCommandForPlatform(platform); - if (yield* isCommandAvailableForPlatform(command, { platform, env })) { + if (yield* isCommandAvailable(command, { env })) { available.push(editor.id); } continue; } - const command = yield* resolveAvailableCommand(editor.commands, { platform, env }); + const command = yield* resolveAvailableCommand(editor.commands, env); if (Option.isSome(command)) { available.push(editor.id); } @@ -274,7 +268,7 @@ const buildAvailableEditors = Effect.fn("externalLauncher.buildAvailableEditors" return available; }); -export const resolveBrowserLaunch = Effect.fn("externalLauncher.resolveBrowserLaunch")(function* ( +const resolveBrowserLaunch = Effect.fn("externalLauncher.resolveBrowserLaunch")(function* ( target: string, ) { const platform = yield* HostProcessPlatform; @@ -282,18 +276,17 @@ export const resolveBrowserLaunch = Effect.fn("externalLauncher.resolveBrowserLa return buildBrowserLaunch(target, platform, env); }); -export const resolveAvailableEditors = Effect.fn("externalLauncher.resolveAvailableEditors")( - function* () { - const platform = yield* HostProcessPlatform; - const env = yield* readCommandLookupEnv; - return yield* buildAvailableEditors(platform, env); - }, -); +const resolveAvailableEditors = Effect.fn("externalLauncher.resolveAvailableEditors")(function* () { + const platform = yield* HostProcessPlatform; + const env = yield* readCommandLookupEnv; + return yield* buildAvailableEditors(platform, env); +}); /** * ExternalLauncherShape - Service API for browser and editor launch actions. */ export interface ExternalLauncherShape { + readonly resolveAvailableEditors: () => Effect.Effect>; /** * Launch a URL target in the default browser. */ @@ -318,7 +311,7 @@ export class ExternalLauncher extends Context.Service { const platform = yield* HostProcessPlatform; @@ -335,7 +328,7 @@ export const resolveEditorLaunch = Effect.fn("resolveEditorLaunch")(function* ( if (editorDef.commands) { const command = Option.getOrElse( - yield* resolveAvailableCommand(editorDef.commands, { platform, env }), + yield* resolveAvailableCommand(editorDef.commands, env), () => editorDef.commands[0], ); return { @@ -366,14 +359,14 @@ const launchAndUnref = Effect.fn("externalLauncher.launchAndUnref")(function* ( ); }); -export const launchBrowser = Effect.fn("externalLauncher.launchBrowser")(function* ( +const launchBrowser = Effect.fn("externalLauncher.launchBrowser")(function* ( target: string, ): Effect.fn.Return { const launch = yield* resolveBrowserLaunch(target); return yield* launchAndUnref(launch, "Browser auto-open failed"); }); -export const launchEditorProcess = Effect.fn("externalLauncher.launchEditorProcess")(function* ( +const launchEditorProcess = Effect.fn("externalLauncher.launchEditorProcess")(function* ( launch: EditorLaunch, ): Effect.fn.Return< void, @@ -382,7 +375,7 @@ export const launchEditorProcess = Effect.fn("externalLauncher.launchEditorProce > { const platform = yield* HostProcessPlatform; const env = yield* readCommandLookupEnv; - if (!(yield* isCommandAvailableForPlatform(launch.command, { platform, env }))) { + if (!(yield* isCommandAvailable(launch.command, { env }))) { return yield* new ExternalLauncherError({ message: `Editor command not found: ${launch.command}`, }); @@ -392,7 +385,7 @@ export const launchEditorProcess = Effect.fn("externalLauncher.launchEditorProce yield* launchAndUnref( { command: launch.command, - args: sanitizeShellModeArgs(launch.args, platform), + args: yield* sanitizeShellModeArgs(launch.args), options: { detached: true, shell: isWin32, @@ -419,6 +412,7 @@ const make = Effect.gen(function* () { ); return { + resolveAvailableEditors: () => provideCommandResolutionServices(resolveAvailableEditors()), launchBrowser: (target) => launchBrowser(target).pipe( Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), diff --git a/apps/server/src/provider/Layers/ClaudeProvider.ts b/apps/server/src/provider/Layers/ClaudeProvider.ts index e9ccceb7157..af25fa23c62 100644 --- a/apps/server/src/provider/Layers/ClaudeProvider.ts +++ b/apps/server/src/provider/Layers/ClaudeProvider.ts @@ -611,14 +611,10 @@ const runClaudeCommand = Effect.fn("runClaudeCommand")(function* ( const claudeEnvironment = yield* makeClaudeEnvironment(claudeSettings, environment); // The provider binary may be an npm-installed `.cmd` shim, so Windows spawns // through cmd.exe shell mode with explicitly sanitized arguments. - const command = ChildProcess.make( - claudeSettings.binaryPath, - sanitizeShellModeArgs(args, hostPlatform), - { - env: claudeEnvironment, - shell: hostPlatform === "win32", - }, - ); + const command = ChildProcess.make(claudeSettings.binaryPath, yield* sanitizeShellModeArgs(args), { + env: claudeEnvironment, + shell: hostPlatform === "win32", + }); return yield* spawnAndCollect(claudeSettings.binaryPath, command); }); diff --git a/apps/server/src/provider/Layers/CodexProvider.ts b/apps/server/src/provider/Layers/CodexProvider.ts index 5003cd58ccf..058954b31d4 100644 --- a/apps/server/src/provider/Layers/CodexProvider.ts +++ b/apps/server/src/provider/Layers/CodexProvider.ts @@ -302,7 +302,7 @@ const probeCodexAppServerProvider = Effect.fn("probeCodexAppServerProvider")(fun // through cmd.exe shell mode with explicitly sanitized arguments. const child = yield* spawner .spawn( - ChildProcess.make(input.binaryPath, sanitizeShellModeArgs(["app-server"], hostPlatform), { + ChildProcess.make(input.binaryPath, yield* sanitizeShellModeArgs(["app-server"]), { cwd: input.cwd, env: { ...input.environment, diff --git a/apps/server/src/provider/Layers/CodexSessionRuntime.ts b/apps/server/src/provider/Layers/CodexSessionRuntime.ts index a0d2a73471c..9759a33b913 100644 --- a/apps/server/src/provider/Layers/CodexSessionRuntime.ts +++ b/apps/server/src/provider/Layers/CodexSessionRuntime.ts @@ -730,7 +730,7 @@ export const makeCodexSessionRuntime = ( .spawn( ChildProcess.make( options.binaryPath, - sanitizeShellModeArgs(["app-server", ...(options.appServerArgs ?? [])], hostPlatform), + yield* sanitizeShellModeArgs(["app-server", ...(options.appServerArgs ?? [])]), { cwd: options.cwd, env, diff --git a/apps/server/src/provider/Layers/CursorProvider.ts b/apps/server/src/provider/Layers/CursorProvider.ts index 8e4239c0c0a..65d0c18dec0 100644 --- a/apps/server/src/provider/Layers/CursorProvider.ts +++ b/apps/server/src/provider/Layers/CursorProvider.ts @@ -938,7 +938,7 @@ const runCursorCommand = ( // through cmd.exe shell mode with explicitly sanitized arguments. const command = ChildProcess.make( cursorSettings.binaryPath, - sanitizeShellModeArgs(args, hostPlatform), + yield* sanitizeShellModeArgs(args), { ...(environment ? { env: environment } : { extendEnv: true }), shell: hostPlatform === "win32", diff --git a/apps/server/src/provider/Layers/GrokProvider.ts b/apps/server/src/provider/Layers/GrokProvider.ts index d71cb02d362..417ada2b6d1 100644 --- a/apps/server/src/provider/Layers/GrokProvider.ts +++ b/apps/server/src/provider/Layers/GrokProvider.ts @@ -159,7 +159,7 @@ const runGrokVersionCommand = ( // through cmd.exe shell mode with explicitly sanitized arguments. return yield* spawnAndCollect( command, - ChildProcess.make(command, sanitizeShellModeArgs(["--version"], hostPlatform), { + ChildProcess.make(command, yield* sanitizeShellModeArgs(["--version"]), { env: environment, shell: hostPlatform === "win32", }), diff --git a/apps/server/src/provider/acp/AcpSessionRuntime.ts b/apps/server/src/provider/acp/AcpSessionRuntime.ts index 17900f97c2d..817d3b8029f 100644 --- a/apps/server/src/provider/acp/AcpSessionRuntime.ts +++ b/apps/server/src/provider/acp/AcpSessionRuntime.ts @@ -208,15 +208,11 @@ const makeAcpSessionRuntime = ( .spawn( // The agent binary may be an npm-installed `.cmd` shim, so Windows spawns // through cmd.exe shell mode with explicitly sanitized arguments. - ChildProcess.make( - options.spawn.command, - sanitizeShellModeArgs(options.spawn.args, hostPlatform), - { - ...(options.spawn.cwd ? { cwd: options.spawn.cwd } : {}), - ...(options.spawn.env ? { env: options.spawn.env, extendEnv: true } : {}), - shell: hostPlatform === "win32", - }, - ), + ChildProcess.make(options.spawn.command, yield* sanitizeShellModeArgs(options.spawn.args), { + ...(options.spawn.cwd ? { cwd: options.spawn.cwd } : {}), + ...(options.spawn.env ? { env: options.spawn.env, extendEnv: true } : {}), + shell: hostPlatform === "win32", + }), ) .pipe( Effect.provideService(Scope.Scope, runtimeScope), diff --git a/apps/server/src/provider/opencodeRuntime.ts b/apps/server/src/provider/opencodeRuntime.ts index 340e5eb04b0..c5bcf9039da 100644 --- a/apps/server/src/provider/opencodeRuntime.ts +++ b/apps/server/src/provider/opencodeRuntime.ts @@ -285,7 +285,7 @@ const makeOpenCodeRuntime = Effect.gen(function* () { // The opencode binary may be an npm-installed `.cmd` shim, so Windows // spawns through cmd.exe shell mode with explicitly sanitized arguments. const child = yield* spawner.spawn( - ChildProcess.make(input.binaryPath, sanitizeShellModeArgs(input.args, hostPlatform), { + ChildProcess.make(input.binaryPath, yield* sanitizeShellModeArgs(input.args), { shell: hostPlatform === "win32", ...(input.environment ? { env: input.environment } : { extendEnv: true }), }), @@ -342,7 +342,7 @@ const makeOpenCodeRuntime = Effect.gen(function* () { const child = yield* spawner .spawn( - ChildProcess.make(input.binaryPath, sanitizeShellModeArgs(args, hostPlatform), { + ChildProcess.make(input.binaryPath, yield* sanitizeShellModeArgs(args), { detached: hostPlatform !== "win32", shell: hostPlatform === "win32", env: { diff --git a/apps/server/src/provider/providerMaintenance.test.ts b/apps/server/src/provider/providerMaintenance.test.ts index 5ae04eb416a..c4ad2fa7509 100644 --- a/apps/server/src/provider/providerMaintenance.test.ts +++ b/apps/server/src/provider/providerMaintenance.test.ts @@ -1,5 +1,5 @@ // @effect-diagnostics nodeBuiltinImport:off -import { afterEach, expect, it } from "@effect/vitest"; +import { expect, it } from "@effect/vitest"; import { chmodSync, mkdirSync, symlinkSync, writeFileSync } from "node:fs"; import * as NodeServices from "@effect/platform-node/NodeServices"; import * as NodeOS from "node:os"; @@ -8,13 +8,15 @@ import { ProviderDriverKind } from "@t3tools/contracts"; import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import * as Crypto from "effect/Crypto"; import * as Effect from "effect/Effect"; +import { HttpClient } from "effect/unstable/http"; import { - clearLatestProviderVersionCacheForTests, createProviderVersionAdvisory, makePackageManagedProviderMaintenanceResolver, makeProviderMaintenanceCapabilities, makeStaticProviderMaintenanceResolver, normalizeCommandPath, + ProviderVersionCache, + resolveLatestProviderVersion, resolveProviderMaintenanceCapabilitiesEffect, } from "./providerMaintenance.ts"; @@ -66,11 +68,33 @@ const staticToolUpdate = makeStaticProviderMaintenanceResolver( }), ); -afterEach(() => { - clearLatestProviderVersionCacheForTests(); -}); - it.layer(NodeServices.layer)("providerMaintenance", (it) => { + it.effect("reads cached versions through the injectable cache reference", () => + resolveLatestProviderVersion(packageToolUpdate.resolve()).pipe( + Effect.provideService( + ProviderVersionCache, + new Map([ + [ + "@example/package-tool", + { + expiresAt: Number.MAX_SAFE_INTEGER, + version: "9.9.9", + }, + ], + ]), + ), + Effect.provideService( + HttpClient.HttpClient, + HttpClient.make(() => + Effect.die("cached provider version should not make an HTTP request"), + ), + ), + Effect.map((version) => { + expect(version).toBe("9.9.9"); + }), + ), + ); + it("marks providers with unknown current versions as unknown", () => { expect( createProviderVersionAdvisory({ diff --git a/apps/server/src/provider/providerMaintenance.ts b/apps/server/src/provider/providerMaintenance.ts index 4e062d47787..d1c4a7d6a71 100644 --- a/apps/server/src/provider/providerMaintenance.ts +++ b/apps/server/src/provider/providerMaintenance.ts @@ -3,10 +3,10 @@ import { type ServerProvider, type ServerProviderVersionAdvisory, } from "@t3tools/contracts"; -import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import { compareSemverVersions } from "@t3tools/shared/semver"; -import { resolveCommandPathForPlatform } from "@t3tools/shared/shell"; +import { resolveCommandPath } from "@t3tools/shared/shell"; import * as Config from "effect/Config"; +import * as Context from "effect/Context"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; @@ -75,20 +75,21 @@ export interface PackageManagedProviderMaintenanceDefinition { } | null; } -interface LatestVersionCacheEntry { +export interface ProviderVersionCacheEntry { readonly expiresAt: number; readonly version: string | null; } -const latestVersionCache = new Map(); +export const ProviderVersionCache = Context.Reference>( + "@t3tools/server/providerMaintenance/ProviderVersionCache", + { + defaultValue: () => new Map(), + }, +); const NpmLatestVersionResponse = Schema.Struct({ version: Schema.optional(Schema.String), }); -export function clearLatestProviderVersionCacheForTests(): void { - latestVersionCache.clear(); -} - function nonEmptyString(value: unknown): string | null { return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; } @@ -353,14 +354,11 @@ export const resolveProviderMaintenanceCapabilitiesEffect = Effect.fn( return resolver.resolve(options); } - const platform = yield* HostProcessPlatform; const env = options?.env ?? (yield* readCommandLookupEnv); const resolvedCommandPath = - (yield* resolveCommandPathForPlatform(binaryPath, { - platform, - env, - }).pipe(Effect.catchTag("CommandResolutionError", () => Effect.succeed(null)))) ?? - (hasPathSeparator(binaryPath) ? binaryPath : null); + (yield* resolveCommandPath(binaryPath, { env }).pipe( + Effect.catchTag("CommandResolutionError", () => Effect.succeed(null)), + )) ?? (hasPathSeparator(binaryPath) ? binaryPath : null); if (!resolvedCommandPath) { return resolver.resolve(options); } @@ -453,6 +451,7 @@ export const resolveLatestProviderVersion = Effect.fn("resolveLatestProviderVers return null; } + const latestVersionCache = yield* ProviderVersionCache; const cached = latestVersionCache.get(packageName); const now = DateTime.toEpochMillis(yield* DateTime.now); if (cached && cached.expiresAt > now) { diff --git a/apps/server/src/provider/providerMaintenanceRunner.test.ts b/apps/server/src/provider/providerMaintenanceRunner.test.ts index 5f5f975a4e3..5ffb69cd5f7 100644 --- a/apps/server/src/provider/providerMaintenanceRunner.test.ts +++ b/apps/server/src/provider/providerMaintenanceRunner.test.ts @@ -1,4 +1,4 @@ -import { afterEach, describe, it, assert } from "@effect/vitest"; +import { describe, it, assert } from "@effect/vitest"; import { ProviderDriverKind, ProviderInstanceId, @@ -21,8 +21,8 @@ import { ChildProcessSpawner } from "effect/unstable/process"; import { ProviderRegistry, type ProviderRegistryShape } from "./Services/ProviderRegistry.ts"; import * as ProviderMaintenanceRunner from "./providerMaintenanceRunner.ts"; import { - clearLatestProviderVersionCacheForTests, makeProviderMaintenanceCapabilities, + ProviderVersionCache, type ProviderMaintenanceCapabilities, } from "./providerMaintenance.ts"; const isServerProviderUpdateError = Schema.is(ServerProviderUpdateError); @@ -35,10 +35,6 @@ const CURSOR_INSTANCE_ID = ProviderInstanceId.make("cursor"); const OPENCODE_INSTANCE_ID = ProviderInstanceId.make("opencode"); const encoder = new TextEncoder(); -afterEach(() => { - clearLatestProviderVersionCacheForTests(); -}); - function lifecycleFor(provider: ProviderDriverKind): ProviderMaintenanceCapabilities { if (provider === CURSOR_DRIVER) { return makeProviderMaintenanceCapabilities({ @@ -202,7 +198,12 @@ const makeTestRunner = (registry: ProviderRegistryShape) => Effect.service(ProviderMaintenanceRunner.ProviderMaintenanceRunner).pipe( Effect.provide( ProviderMaintenanceRunner.layer.pipe( - Layer.provide(Layer.succeed(ProviderRegistry, registry)), + Layer.provide( + Layer.mergeAll( + Layer.succeed(ProviderRegistry, registry), + Layer.succeed(ProviderVersionCache, new Map()), + ), + ), ), ), ); diff --git a/apps/server/src/terminal/Layers/NodePTY.test.ts b/apps/server/src/terminal/Layers/NodePTY.test.ts deleted file mode 100644 index 2730f47c8aa..00000000000 --- a/apps/server/src/terminal/Layers/NodePTY.test.ts +++ /dev/null @@ -1,52 +0,0 @@ -import * as FileSystem from "effect/FileSystem"; -import * as Path from "effect/Path"; -import * as Effect from "effect/Effect"; -import { assert, it } from "@effect/vitest"; - -import { ensureNodePtySpawnHelperExecutable } from "./NodePTY.ts"; -import * as NodeServices from "@effect/platform-node/NodeServices"; -import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; - -it.layer(NodeServices.layer)("ensureNodePtySpawnHelperExecutable", (it) => { - it.effect("adds executable bits when helper exists but is not executable", () => - Effect.gen(function* () { - if ((yield* HostProcessPlatform) === "win32") return; - - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - - const dir = yield* fs.makeTempDirectoryScoped({ prefix: "pty-helper-test-" }); - const helperPath = path.join(dir, "spawn-helper"); - yield* fs.writeFileString(helperPath, "#!/bin/sh\nexit 0\n"); - yield* fs.chmod(helperPath, 0o644); - - yield* ensureNodePtySpawnHelperExecutable(helperPath).pipe( - Effect.provideService(HostProcessPlatform, "linux"), - ); - - const mode = (yield* fs.stat(helperPath)).mode & 0o777; - assert.equal(mode & 0o111, 0o111); - }), - ); - - it.effect("keeps executable helper as executable", () => - Effect.gen(function* () { - if ((yield* HostProcessPlatform) === "win32") return; - - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - - const dir = yield* fs.makeTempDirectoryScoped({ prefix: "pty-helper-test-" }); - const helperPath = path.join(dir, "spawn-helper"); - yield* fs.writeFileString(helperPath, "#!/bin/sh\nexit 0\n"); - yield* fs.chmod(helperPath, 0o755); - - yield* ensureNodePtySpawnHelperExecutable(helperPath).pipe( - Effect.provideService(HostProcessPlatform, "linux"), - ); - - const mode = (yield* fs.stat(helperPath)).mode & 0o777; - assert.equal(mode & 0o111, 0o111); - }), - ); -}); diff --git a/apps/server/src/terminal/Layers/NodePTY.ts b/apps/server/src/terminal/Layers/NodePTY.ts index a684db6b4a2..3968195b51e 100644 --- a/apps/server/src/terminal/Layers/NodePTY.ts +++ b/apps/server/src/terminal/Layers/NodePTY.ts @@ -38,17 +38,15 @@ const resolveNodePtySpawnHelperPath = Effect.gen(function* () { return null; }).pipe(Effect.orElseSucceed(() => null)); -export const ensureNodePtySpawnHelperExecutable = Effect.fn(function* (explicitPath?: string) { +const ensureNodePtySpawnHelperExecutable = Effect.fn(function* () { const fs = yield* FileSystem.FileSystem; const platform = yield* HostProcessPlatform; if (platform === "win32") return; - if (!explicitPath && didEnsureSpawnHelperExecutable) return; + if (didEnsureSpawnHelperExecutable) return; - const helperPath = explicitPath ?? (yield* resolveNodePtySpawnHelperPath); + const helperPath = yield* resolveNodePtySpawnHelperPath; if (!helperPath) return; - if (!explicitPath) { - didEnsureSpawnHelperExecutable = true; - } + didEnsureSpawnHelperExecutable = true; if (!(yield* fs.exists(helperPath))) { return; diff --git a/apps/server/src/textGeneration/ClaudeTextGeneration.ts b/apps/server/src/textGeneration/ClaudeTextGeneration.ts index f2fa175f4a5..541d673de9a 100644 --- a/apps/server/src/textGeneration/ClaudeTextGeneration.ts +++ b/apps/server/src/textGeneration/ClaudeTextGeneration.ts @@ -163,21 +163,18 @@ export const makeClaudeTextGeneration = Effect.fn("makeClaudeTextGeneration")(fu claudeSettings.binaryPath || "claude", // The provider binary may be an npm-installed `.cmd` shim, so Windows // spawns through cmd.exe shell mode with explicitly sanitized arguments. - sanitizeShellModeArgs( - [ - "-p", - "--output-format", - "json", - "--json-schema", - jsonSchemaStr, - "--model", - resolveClaudeApiModelId(modelSelection), - ...(cliEffort ? ["--effort", cliEffort] : []), - ...(settingsJson ? ["--settings", settingsJson] : []), - "--dangerously-skip-permissions", - ], - hostPlatform, - ), + yield* sanitizeShellModeArgs([ + "-p", + "--output-format", + "json", + "--json-schema", + jsonSchemaStr, + "--model", + resolveClaudeApiModelId(modelSelection), + ...(cliEffort ? ["--effort", cliEffort] : []), + ...(settingsJson ? ["--settings", settingsJson] : []), + "--dangerously-skip-permissions", + ]), { env: claudeEnvironment, cwd, diff --git a/apps/server/src/textGeneration/CodexTextGeneration.ts b/apps/server/src/textGeneration/CodexTextGeneration.ts index 9512cfdda9c..f6ff7e06001 100644 --- a/apps/server/src/textGeneration/CodexTextGeneration.ts +++ b/apps/server/src/textGeneration/CodexTextGeneration.ts @@ -188,27 +188,24 @@ export const makeCodexTextGeneration = Effect.fn("makeCodexTextGeneration")(func codexConfig.binaryPath || "codex", // The provider binary may be an npm-installed `.cmd` shim, so Windows // spawns through cmd.exe shell mode with explicitly sanitized arguments. - sanitizeShellModeArgs( - [ - "exec", - "--ephemeral", - "--skip-git-repo-check", - "-s", - "read-only", - "--model", - modelSelection.model, - "--config", - `model_reasoning_effort="${reasoningEffort}"`, - ...(serviceTier ? ["--config", `service_tier="${serviceTier}"`] : []), - "--output-schema", - schemaPath, - "--output-last-message", - outputPath, - ...imagePaths.flatMap((imagePath) => ["--image", imagePath]), - "-", - ], - hostPlatform, - ), + yield* sanitizeShellModeArgs([ + "exec", + "--ephemeral", + "--skip-git-repo-check", + "-s", + "read-only", + "--model", + modelSelection.model, + "--config", + `model_reasoning_effort="${reasoningEffort}"`, + ...(serviceTier ? ["--config", `service_tier="${serviceTier}"`] : []), + "--output-schema", + schemaPath, + "--output-last-message", + outputPath, + ...imagePaths.flatMap((imagePath) => ["--image", imagePath]), + "-", + ]), { env: { ...resolvedEnvironment, diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index d4d04b30380..175bf3248b8 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -761,7 +761,7 @@ const makeWsRpcLayer = (currentSession: AuthenticatedSession) => keybindings: keybindingsConfig.keybindings, issues: keybindingsConfig.issues, providers, - availableEditors: yield* ExternalLauncher.resolveAvailableEditors(), + availableEditors: yield* externalLauncher.resolveAvailableEditors(), observability: { logsDirectoryPath: config.logsDir, localTracingEnabled: true, diff --git a/packages/shared/src/hostProcess.ts b/packages/shared/src/hostProcess.ts index bece9d79410..1e5b69749cf 100644 --- a/packages/shared/src/hostProcess.ts +++ b/packages/shared/src/hostProcess.ts @@ -1,5 +1,6 @@ import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; +import * as NodeOS from "node:os"; export const HostProcessPlatform = Context.Reference( "@t3tools/shared/hostProcess/HostProcessPlatform", @@ -15,4 +16,18 @@ export const HostProcessArchitecture = Context.Reference( }, ); +export const HostProcessHostname = Context.Reference( + "@t3tools/shared/hostProcess/HostProcessHostname", + { + defaultValue: () => NodeOS.hostname(), + }, +); + +export const HostProcessEnvironment = Context.Reference( + "@t3tools/shared/hostProcess/HostProcessEnvironment", + { + defaultValue: () => process.env, + }, +); + export const isHostWindows = Effect.map(HostProcessPlatform, (platform) => platform === "win32"); diff --git a/packages/shared/src/relayClient.test.ts b/packages/shared/src/relayClient.test.ts index 7e552194dae..df39ef0b161 100644 --- a/packages/shared/src/relayClient.test.ts +++ b/packages/shared/src/relayClient.test.ts @@ -10,15 +10,20 @@ import * as Sink from "effect/Sink"; import * as Stream from "effect/Stream"; import { HttpClient, HttpClientResponse } from "effect/unstable/http"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; +import { HostProcessArchitecture, HostProcessPlatform } from "./hostProcess.ts"; import { RelayClientInstallError, CLOUDFLARED_VERSION, makeCloudflaredRelayClient, - resolveManagedCloudflaredPath, } from "./relayClient.ts"; -const emptyConfigProvider = () => ConfigProvider.fromEnv({ env: {} }); +const hostRuntimeLayer = (env: Record = {}) => + Layer.mergeAll( + Layer.succeed(HostProcessPlatform, "linux"), + Layer.succeed(HostProcessArchitecture, "x64"), + ConfigProvider.layer(ConfigProvider.fromEnv({ env })), + ); function makeHandle(exitCode = 0) { return ChildProcessSpawner.makeHandle({ @@ -69,18 +74,18 @@ describe("RelayClient", () => { yield* fileSystem.chmod(overridePath, 0o755); const manager = yield* makeCloudflaredRelayClient({ baseDir, - platform: "linux", - arch: "x64", - configProvider: () => - ConfigProvider.fromEnv({ - env: { - PATH: "", - T3CODE_CLOUDFLARED_PATH: overridePath, - }, - }), }); - expect(yield* manager.resolve).toEqual({ + expect( + yield* manager.resolve.pipe( + Effect.provideService( + ConfigProvider.ConfigProvider, + ConfigProvider.fromEnv({ + env: { PATH: "", T3CODE_CLOUDFLARED_PATH: overridePath }, + }), + ), + ), + ).toEqual({ status: "available", executablePath: overridePath, source: "override", @@ -93,6 +98,7 @@ describe("RelayClient", () => { NodeServices.layer, makeHttpClientLayer(new Uint8Array()), makeSpawnerLayer([]), + hostRuntimeLayer(), ), ), ), @@ -107,14 +113,11 @@ describe("RelayClient", () => { const bytes = new TextEncoder().encode("test-cloudflared-binary"); const manager = yield* makeCloudflaredRelayClient({ baseDir, - platform: "linux", - arch: "x64", releaseAsset: { url: "https://example.test/cloudflared", sha256: Encoding.encodeHex(sha256(bytes)), archive: "binary", }, - configProvider: emptyConfigProvider, }); const progress: Array = []; @@ -125,11 +128,7 @@ describe("RelayClient", () => { } }), ); - const managedPath = resolveManagedCloudflaredPath({ - baseDir, - platform: "linux", - arch: "x64", - }); + const managedPath = `${baseDir}/tools/cloudflared/${CLOUDFLARED_VERSION}/linux-x64/cloudflared`; expect(installed).toEqual({ status: "available", executablePath: managedPath, @@ -156,6 +155,7 @@ describe("RelayClient", () => { NodeServices.layer, makeHttpClientLayer(new TextEncoder().encode("test-cloudflared-binary")), makeSpawnerLayer([]), + hostRuntimeLayer(), ), ), ), @@ -169,14 +169,11 @@ describe("RelayClient", () => { }); const manager = yield* makeCloudflaredRelayClient({ baseDir, - platform: "linux", - arch: "x64", releaseAsset: { url: "https://example.test/cloudflared", sha256: Encoding.encodeHex(sha256(new TextEncoder().encode("expected"))), archive: "binary", }, - configProvider: emptyConfigProvider, }); const error = yield* manager.install.pipe(Effect.flip); @@ -189,6 +186,7 @@ describe("RelayClient", () => { NodeServices.layer, makeHttpClientLayer(new TextEncoder().encode("tampered")), makeSpawnerLayer([]), + hostRuntimeLayer(), ), ), ), @@ -204,14 +202,11 @@ describe("RelayClient", () => { }); const manager = yield* makeCloudflaredRelayClient({ baseDir, - platform: "linux", - arch: "x64", releaseAsset: { url: "https://example.test/cloudflared", sha256: Encoding.encodeHex(sha256(bytes)), archive: "binary", }, - configProvider: emptyConfigProvider, }); const [first, second] = yield* Effect.all([manager.install, manager.install], { @@ -222,25 +217,27 @@ describe("RelayClient", () => { }).pipe( Effect.scoped, Effect.provide( - Layer.mergeAll(NodeServices.layer, makeHttpClientLayer(bytes), makeSpawnerLayer(commands)), + Layer.mergeAll( + NodeServices.layer, + makeHttpClientLayer(bytes), + makeSpawnerLayer(commands), + hostRuntimeLayer(), + ), ), ); }); - it.effect("observes PATH changes after the manager has been constructed", () => - Effect.gen(function* () { + it.effect("observes PATH changes after the manager has been constructed", () => { + const env = { PATH: "" }; + return Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const baseDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-cloudflared-test-", }); const binDir = `${baseDir}/bin`; const executablePath = `${binDir}/cloudflared`; - let path = ""; const manager = yield* makeCloudflaredRelayClient({ baseDir, - platform: "linux", - arch: "x64", - configProvider: () => ConfigProvider.fromEnv({ env: { PATH: path } }), }); expect(yield* manager.resolve).toEqual({ @@ -251,7 +248,7 @@ describe("RelayClient", () => { yield* fileSystem.makeDirectory(binDir); yield* fileSystem.writeFileString(executablePath, "cloudflared"); yield* fileSystem.chmod(executablePath, 0o755); - path = binDir; + env.PATH = binDir; expect(yield* manager.resolve).toEqual({ status: "available", @@ -266,8 +263,9 @@ describe("RelayClient", () => { NodeServices.layer, makeHttpClientLayer(new Uint8Array()), makeSpawnerLayer([]), + hostRuntimeLayer(env), ), ), - ), - ); + ); + }); }); diff --git a/packages/shared/src/relayClient.ts b/packages/shared/src/relayClient.ts index 82d003eb776..0a56e45191c 100644 --- a/packages/shared/src/relayClient.ts +++ b/packages/shared/src/relayClient.ts @@ -4,7 +4,6 @@ import type { RelayClientInstallProgressStage, } from "@t3tools/contracts"; import * as Config from "effect/Config"; -import * as ConfigProvider from "effect/ConfigProvider"; import * as Context from "effect/Context"; import * as Crypto from "effect/Crypto"; import * as Data from "effect/Data"; @@ -121,10 +120,7 @@ const CloudflaredConfig = Config.all({ export interface CloudflaredRelayClientOptions { readonly baseDir: string; - readonly platform?: NodeJS.Platform; - readonly arch?: string; readonly releaseAsset?: CloudflaredReleaseAsset; - readonly configProvider?: () => ConfigProvider.ConfigProvider; } export interface RelayClientShape { @@ -143,22 +139,6 @@ function executableFileName(platform: NodeJS.Platform): string { return platform === "win32" ? "cloudflared.exe" : "cloudflared"; } -export function resolveManagedCloudflaredPath(input: { - readonly baseDir: string; - readonly platform: NodeJS.Platform; - readonly arch: string; -}): string { - const separator = input.platform === "win32" ? "\\" : "/"; - return [ - input.baseDir.replace(/[\\/]+$/u, ""), - "tools", - "cloudflared", - CLOUDFLARED_VERSION, - `${input.platform}-${input.arch}`, - executableFileName(input.platform), - ].join(separator); -} - function resolveReleaseAsset( platform: NodeJS.Platform, arch: string, @@ -206,17 +186,10 @@ export const makeCloudflaredRelayClient = Effect.fn("cloudflared.make")(function const path = yield* Path.Path; const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const installSemaphore = yield* Semaphore.make(1); - const platform = options.platform ?? (yield* HostProcessPlatform); - const arch = options.arch ?? (yield* HostProcessArchitecture); + const platform = yield* HostProcessPlatform; + const arch = yield* HostProcessArchitecture; const releaseAsset = options.releaseAsset ?? resolveReleaseAsset(platform, arch); - const loadCloudflaredConfig = Effect.suspend(() => - CloudflaredConfig.pipe( - Effect.provideService( - ConfigProvider.ConfigProvider, - options.configProvider?.() ?? ConfigProvider.fromEnv(), - ), - ), - ).pipe(Effect.orDie); + const loadCloudflaredConfig = Effect.suspend(() => CloudflaredConfig).pipe(Effect.orDie); const managedPath = path.join( options.baseDir, "tools", diff --git a/packages/shared/src/shell.test.ts b/packages/shared/src/shell.test.ts index dfb293668d2..4d3bbfc9bec 100644 --- a/packages/shared/src/shell.test.ts +++ b/packages/shared/src/shell.test.ts @@ -1,12 +1,14 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { it as effectIt } from "@effect/vitest"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import * as Effect from "effect/Effect"; import { describe, expect, it, vi } from "vite-plus/test"; import { - escapeWindowsShellArg, extractPathFromShellOutput, - isCommandAvailableForPlatform, + CommandAvailability, + type CommandAvailabilityChecker, + isCommandAvailable, listLoginShellCandidates, mergePathEntries, mergePathValues, @@ -14,50 +16,22 @@ import { readEnvironmentFromWindowsShell, readPathFromLaunchctl, readPathFromLoginShell, - resolveCommandPathForPlatform, + resolveCommandPath, resolveKnownWindowsCliDirs, resolveWindowsEnvironment, - sanitizeShellModeArgs, + WindowsShellEnvironment, + type WindowsShellEnvironmentReader, } from "./shell.ts"; -describe("escapeWindowsShellArg", () => { - it("quotes plain arguments", () => { - expect(escapeWindowsShellArg("--version")).toBe('^"--version^"'); - }); - - it("preserves embedded whitespace through quoting", () => { - expect(escapeWindowsShellArg("C:\\Users\\John Doe\\project")).toBe( - '^"C:\\Users\\John^ Doe\\project^"', - ); - }); - - it("escapes embedded double quotes for CommandLineToArgvW", () => { - expect(escapeWindowsShellArg('say "hi"')).toBe('^"say^ \\^"hi\\^"^"'); - }); - - it("doubles trailing backslashes so the closing quote survives", () => { - expect(escapeWindowsShellArg("C:\\dir\\")).toBe('^"C:\\dir\\\\^"'); - }); - - it("escapes cmd.exe metacharacters", () => { - expect(escapeWindowsShellArg("a|b&c>d")).toBe('^"a^|b^&c^>d^"'); - expect(escapeWindowsShellArg("100%")).toBe('^"100^%^"'); - }); - - it("handles empty arguments", () => { - expect(escapeWindowsShellArg("")).toBe('^"^"'); - }); -}); - -describe("sanitizeShellModeArgs", () => { - it("escapes arguments on win32", () => { - expect(sanitizeShellModeArgs(["--goto", "a b"], "win32")).toEqual(['^"--goto^"', '^"a^ b^"']); - }); - - it("returns arguments untouched on other platforms", () => { - expect(sanitizeShellModeArgs(["--goto", "a b"], "darwin")).toEqual(["--goto", "a b"]); - }); -}); +const withWindowsEnvironmentMocks = ( + effect: Effect.Effect, + readEnvironment: WindowsShellEnvironmentReader, + commandAvailable: CommandAvailabilityChecker, +) => + effect.pipe( + Effect.provideService(WindowsShellEnvironment, readEnvironment), + Effect.provideService(CommandAvailability, commandAvailable), + ); describe("extractPathFromShellOutput", () => { it("extracts the path between capture markers", () => { @@ -370,10 +344,9 @@ effectIt.layer(NodeServices.layer)("isCommandAvailable", (it) => { it.effect("returns false when PATH is empty", () => Effect.gen(function* () { expect( - yield* isCommandAvailableForPlatform("definitely-not-installed", { - platform: "win32", + yield* isCommandAvailable("definitely-not-installed", { env: { PATH: "", PATHEXT: ".COM;.EXE;.BAT;.CMD" }, - }), + }).pipe(Effect.provideService(HostProcessPlatform, "win32")), ).toBe(false); }), ); @@ -382,10 +355,9 @@ effectIt.layer(NodeServices.layer)("isCommandAvailable", (it) => { effectIt.layer(NodeServices.layer)("resolveCommandPath", (it) => { it.effect("fails when PATH is empty", () => Effect.gen(function* () { - const result = yield* resolveCommandPathForPlatform("definitely-not-installed", { - platform: "win32", + const result = yield* resolveCommandPath("definitely-not-installed", { env: { PATH: "", PATHEXT: ".COM;.EXE;.BAT;.CMD" }, - }).pipe(Effect.result); + }).pipe(Effect.provideService(HostProcessPlatform, "win32"), Effect.result); expect(result._tag).toBe("Failure"); }), @@ -404,17 +376,15 @@ effectIt.layer(NodeServices.layer)("resolveWindowsEnvironment", (it) => { const commandAvailable = vi.fn(() => Effect.succeed(true)); expect( - yield* resolveWindowsEnvironment( - { + yield* withWindowsEnvironmentMocks( + resolveWindowsEnvironment({ PATH: "C:\\Windows\\System32", APPDATA: "C:\\Users\\testuser\\AppData\\Roaming", LOCALAPPDATA: "C:\\Users\\testuser\\AppData\\Local", USERPROFILE: "C:\\Users\\testuser", - }, - { - readEnvironment, - commandAvailable, - }, + }), + readEnvironment, + commandAvailable, ), ).toEqual({ PATH: [ @@ -432,7 +402,7 @@ effectIt.layer(NodeServices.layer)("resolveWindowsEnvironment", (it) => { expect(readEnvironment).toHaveBeenCalledWith(["PATH"], { loadProfile: false }); expect(commandAvailable).toHaveBeenCalledWith( "node", - expect.objectContaining({ platform: "win32", env: expect.any(Object) }), + expect.objectContaining({ env: expect.any(Object) }), ); }), ); @@ -452,17 +422,15 @@ effectIt.layer(NodeServices.layer)("resolveWindowsEnvironment", (it) => { const commandAvailable = vi.fn(() => Effect.succeed(false)); expect( - yield* resolveWindowsEnvironment( - { + yield* withWindowsEnvironmentMocks( + resolveWindowsEnvironment({ PATH: "C:\\Windows\\System32", APPDATA: "C:\\Users\\testuser\\AppData\\Roaming", LOCALAPPDATA: "C:\\Users\\testuser\\AppData\\Local", USERPROFILE: "C:\\Users\\testuser", - }, - { - readEnvironment, - commandAvailable, - }, + }), + readEnvironment, + commandAvailable, ), ).toEqual({ PATH: [ @@ -500,16 +468,14 @@ effectIt.layer(NodeServices.layer)("resolveWindowsEnvironment", (it) => { const commandAvailable = vi.fn(() => Effect.succeed(false)); expect( - yield* resolveWindowsEnvironment( - { + yield* withWindowsEnvironmentMocks( + resolveWindowsEnvironment({ PATH: "C:\\Windows\\System32", APPDATA: "C:\\Users\\testuser\\AppData\\Roaming", USERPROFILE: "C:\\Users\\testuser", - }, - { - readEnvironment, - commandAvailable, - }, + }), + readEnvironment, + commandAvailable, ), ).toEqual({ PATH: [ diff --git a/packages/shared/src/shell.ts b/packages/shared/src/shell.ts index 209c67d4958..4db346af28a 100644 --- a/packages/shared/src/shell.ts +++ b/packages/shared/src/shell.ts @@ -6,6 +6,9 @@ import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; +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_]+$/; @@ -23,13 +26,9 @@ export interface CommandAvailabilityOptions { readonly env?: NodeJS.ProcessEnv; } -export interface PlatformCommandAvailabilityOptions extends CommandAvailabilityOptions { - readonly platform: NodeJS.Platform; -} - export type CommandAvailabilityChecker = ( command: string, - options: PlatformCommandAvailabilityOptions, + options?: CommandAvailabilityOptions, ) => Effect.Effect; export class CommandResolutionError extends Data.TaggedError("CommandResolutionError")<{ @@ -46,7 +45,7 @@ const WINDOWS_SHELL_META_CHARS = /([()\][%!^"`<>&|;, *?])/g; * must be escaped to survive both cmd.exe parsing and the target program's * `CommandLineToArgvW` parsing. Mirrors cross-spawn's argument escaping. */ -export function escapeWindowsShellArg(arg: string): string { +function escapeWindowsShellArg(arg: string): string { // Double up backslashes that precede a double quote, then escape the quote // itself so it survives CommandLineToArgvW. let escaped = arg.replace(/(\\*)"/g, '$1$1\\"'); @@ -63,13 +62,19 @@ export function escapeWindowsShellArg(arg: string): string { * when the platform is `win32` (where `shell: true` routes through `cmd.exe`) * and returns the arguments untouched everywhere else. */ -export function sanitizeShellModeArgs( +function sanitizeShellModeArgsForPlatform( args: ReadonlyArray, platform: NodeJS.Platform, ): Array { return platform === "win32" ? args.map(escapeWindowsShellArg) : [...args]; } +export const sanitizeShellModeArgs = Effect.fn("shell.sanitizeShellModeArgs")(function* ( + args: ReadonlyArray, +) { + return sanitizeShellModeArgsForPlatform(args, yield* HostProcessPlatform); +}); + export interface WindowsEnvironmentProbeOptions { readonly loadProfile?: boolean; } @@ -262,6 +267,20 @@ export type WindowsShellEnvironmentReader = ( options?: WindowsEnvironmentProbeOptions, ) => Partial>; +export const WindowsShellEnvironment = Context.Reference( + "@t3tools/shared/shell/WindowsShellEnvironment", + { + defaultValue: () => readEnvironmentFromWindowsShell, + }, +); + +export const CommandAvailability = Context.Reference( + "@t3tools/shared/shell/CommandAvailability", + { + defaultValue: () => isCommandAvailable, + }, +); + export function readEnvironmentFromWindowsShell( names: ReadonlyArray, execFile?: ExecFileSyncLike, @@ -414,7 +433,7 @@ const isExecutableFile = Effect.fn("shell.isExecutableFile")(function* ( ): 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))); + const stat = yield* fileSystem.stat(filePath).pipe(Effect.orElseSucceed(() => null)); if (stat === null || stat.type !== "File") return false; if (platform === "win32") { @@ -434,66 +453,72 @@ const isExecutableFile = Effect.fn("shell.isExecutableFile")(function* ( return (stat.mode & 0o111) !== 0; }); -export const resolveCommandPathForPlatform = Effect.fn("shell.resolveCommandPathForPlatform")( - function* ( - command: string, - options: PlatformCommandAvailabilityOptions, - ): Effect.fn.Return { - const path = yield* Path.Path; - const platform = options.platform; - const env = options.env ?? process.env; - const windowsPathExtensions = platform === "win32" ? resolveWindowsPathExtensions(env) : []; - const commandCandidates = resolveCommandCandidates( - command, - platform, - windowsPathExtensions, - path.extname, - ); +const resolveCommandPathForPlatform = Effect.fn("shell.resolveCommandPathForPlatform")(function* ( + command: string, + options: CommandAvailabilityOptions & { readonly platform: NodeJS.Platform }, +): Effect.fn.Return { + const path = yield* Path.Path; + const platform = options.platform; + const env = options.env ?? process.env; + const windowsPathExtensions = platform === "win32" ? resolveWindowsPathExtensions(env) : []; + const commandCandidates = resolveCommandCandidates( + command, + platform, + windowsPathExtensions, + path.extname, + ); - if (command.includes("/") || command.includes("\\")) { - for (const candidate of commandCandidates) { - if (yield* isExecutableFile(candidate, platform, windowsPathExtensions)) { - return candidate; - } + if (command.includes("/") || command.includes("\\")) { + for (const candidate of commandCandidates) { + if (yield* isExecutableFile(candidate, platform, windowsPathExtensions)) { + return candidate; } - return yield* new CommandResolutionError({ command, reason: "not-found" }); } + return yield* new CommandResolutionError({ command, reason: "not-found" }); + } - const pathValue = resolvePathEnvironmentVariable(env); - 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()); - if (pathEntry.length > 0) { - pathEntries.push(pathEntry); - } + const pathValue = resolvePathEnvironmentVariable(env); + 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()); + if (pathEntry.length > 0) { + pathEntries.push(pathEntry); } + } - for (const pathEntry of pathEntries) { - for (const candidate of commandCandidates) { - const candidatePath = path.join(pathEntry, candidate); - if (yield* isExecutableFile(candidatePath, platform, windowsPathExtensions)) { - return candidatePath; - } + for (const pathEntry of pathEntries) { + for (const candidate of commandCandidates) { + const candidatePath = path.join(pathEntry, candidate); + if (yield* isExecutableFile(candidatePath, platform, windowsPathExtensions)) { + return candidatePath; } } - return yield* new CommandResolutionError({ command, reason: "not-found" }); - }, -); + } + return yield* new CommandResolutionError({ command, reason: "not-found" }); +}); -export const isCommandAvailableForPlatform = Effect.fn("shell.isCommandAvailableForPlatform")( - function* ( - command: string, - options: PlatformCommandAvailabilityOptions, - ): Effect.fn.Return { - return yield* resolveCommandPathForPlatform(command, options).pipe( - Effect.as(true), - Effect.catchTag("CommandResolutionError", () => Effect.succeed(false)), - ); - }, -); +export const resolveCommandPath = Effect.fn("shell.resolveCommandPath")(function* ( + command: string, + options: CommandAvailabilityOptions = {}, +) { + return yield* resolveCommandPathForPlatform(command, { + env: options.env ?? (yield* HostProcessEnvironment), + platform: yield* HostProcessPlatform, + }); +}); + +export const isCommandAvailable = Effect.fn("shell.isCommandAvailable")(function* ( + command: string, + options: CommandAvailabilityOptions = {}, +) { + 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(); @@ -508,11 +533,6 @@ export function resolveKnownWindowsCliDirs(env: NodeJS.ProcessEnv): ReadonlyArra ]; } -export interface WindowsEnvironmentResolverOptions { - readonly readEnvironment?: WindowsShellEnvironmentReader; - readonly commandAvailable?: CommandAvailabilityChecker; -} - function readWindowsEnvironmentSafely( readEnvironment: WindowsShellEnvironmentReader, names: ReadonlyArray, @@ -540,16 +560,9 @@ function mergeWindowsEnv( export const resolveWindowsEnvironment = Effect.fn("shell.resolveWindowsEnvironment")(function* ( env: NodeJS.ProcessEnv, - options: WindowsEnvironmentResolverOptions = {}, ): Effect.fn.Return, never, FileSystem.FileSystem | Path.Path> { - const readEnvironment = options.readEnvironment ?? readEnvironmentFromWindowsShell; - const commandAvailable = - options.commandAvailable ?? - ((command, commandOptions) => - isCommandAvailableForPlatform(command, { - platform: "win32", - ...(commandOptions?.env ? { env: commandOptions.env } : {}), - })); + const readEnvironment = yield* WindowsShellEnvironment; + const commandAvailable = yield* CommandAvailability; const inheritedPath = readEnvPath(env); const shellPath = readWindowsEnvironmentSafely(readEnvironment, ["PATH"], { loadProfile: false, @@ -560,7 +573,7 @@ export const resolveWindowsEnvironment = Effect.fn("shell.resolveWindowsEnvironm const baselinePatch: Partial = baselinePath ? { PATH: baselinePath } : {}; const baselineEnv = mergeWindowsEnv(env, baselinePatch); - if (yield* commandAvailable("node", { platform: "win32", env: baselineEnv })) { + if (yield* commandAvailable("node", { env: baselineEnv })) { return baselinePatch; } diff --git a/packages/ssh/src/command.ts b/packages/ssh/src/command.ts index 0c615b70c28..09a52225f50 100644 --- a/packages/ssh/src/command.ts +++ b/packages/ssh/src/command.ts @@ -24,9 +24,11 @@ export const SSH_COMMAND = "ssh"; * spawned directly — cmd.exe shell mode would re-tokenize arguments such as * identity-file paths containing spaces. */ -export const sshCommandForPlatform = (platform: NodeJS.Platform): string => +const sshCommandForPlatform = (platform: NodeJS.Platform): string => platform === "win32" ? "ssh.exe" : "ssh"; +export const resolveSshCommand = Effect.map(HostProcessPlatform, sshCommandForPlatform); + const encoder = new TextEncoder(); export interface SshCommandResult { @@ -200,7 +202,6 @@ const runSshCommandInScope = Effect.fn("ssh/command.runSshCommand.inScope")(func ...(input.remoteCommandArgs ?? []), ]; const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const hostPlatform = yield* HostProcessPlatform; yield* Effect.logDebug("ssh.command.start", { ...sshTargetLogFields(target), command: [SSH_COMMAND, ...args], @@ -209,7 +210,7 @@ const runSshCommandInScope = Effect.fn("ssh/command.runSshCommand.inScope")(func }); const child = yield* spawner .spawn( - ChildProcess.make(sshCommandForPlatform(hostPlatform), args, { + ChildProcess.make(yield* resolveSshCommand, args, { env: environment, extendEnv: true, stdin: { diff --git a/packages/ssh/src/tunnel.ts b/packages/ssh/src/tunnel.ts index cfb3974a53b..78e9fc5b5e6 100644 --- a/packages/ssh/src/tunnel.ts +++ b/packages/ssh/src/tunnel.ts @@ -3,7 +3,6 @@ import type { DesktopSshEnvironmentTarget, } from "@t3tools/contracts"; import * as NetService from "@t3tools/shared/Net"; -import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import { extractJsonObject, fromLenientJson } from "@t3tools/shared/schemaJson"; import { satisfiesSemverRange } from "@t3tools/shared/semver"; import * as Context from "effect/Context"; @@ -35,10 +34,10 @@ import { collectProcessOutput, getLastNonEmptyOutputLine, remoteStateKey, + resolveSshCommand, resolveSshTarget, runSshCommand, SSH_COMMAND, - sshCommandForPlatform, targetConnectionKey, } from "./command.ts"; import { @@ -1073,7 +1072,6 @@ const startSshTunnel = Effect.fn("ssh/tunnel.startSshTunnel")(function* (input: ]; const tunnelCommand = [SSH_COMMAND, ...args]; const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const hostPlatform = yield* HostProcessPlatform; const scope = yield* Scope.Scope; yield* Effect.logDebug("ssh.tunnel.spawn.start", { ...sshTargetLogFields(input.resolvedTarget), @@ -1085,7 +1083,7 @@ const startSshTunnel = Effect.fn("ssh/tunnel.startSshTunnel")(function* (input: }); const child = yield* spawner .spawn( - ChildProcess.make(sshCommandForPlatform(hostPlatform), args, { + ChildProcess.make(yield* resolveSshCommand, args, { env: childEnvironment, extendEnv: true, stdin: { diff --git a/scripts/build-desktop-artifact.test.ts b/scripts/build-desktop-artifact.test.ts index e08755183e2..974f3d036f0 100644 --- a/scripts/build-desktop-artifact.test.ts +++ b/scripts/build-desktop-artifact.test.ts @@ -17,7 +17,7 @@ import { resolveMockUpdateServerUrl, } from "./build-desktop-artifact.ts"; import { BRAND_ASSET_PATHS } from "./lib/brand-assets.ts"; -import { HostProcessArchitecture, HostProcessPlatform } from "./lib/build-target-arch.ts"; +import { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/hostProcess"; it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { it("resolves the dedicated nightly updater channel from nightly versions", () => { diff --git a/scripts/build-desktop-artifact.ts b/scripts/build-desktop-artifact.ts index f5ce01dd18f..4e043828326 100644 --- a/scripts/build-desktop-artifact.ts +++ b/scripts/build-desktop-artifact.ts @@ -1,12 +1,13 @@ #!/usr/bin/env node import { fromYaml } from "@t3tools/shared/schemaYaml"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import rootPackageJson from "../package.json" with { type: "json" }; import desktopPackageJson from "../apps/desktop/package.json" with { type: "json" }; import serverPackageJson from "../apps/server/package.json" with { type: "json" }; import { BRAND_ASSET_PATHS } from "./lib/brand-assets.ts"; -import { getDefaultBuildArch, HostProcessPlatform } from "./lib/build-target-arch.ts"; +import { getDefaultBuildArch } from "./lib/build-target-arch.ts"; import { resolveCatalogDependencies } from "./lib/resolve-catalog.ts"; import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; diff --git a/scripts/lib/build-target-arch.test.ts b/scripts/lib/build-target-arch.test.ts index 7ff114a5b89..5da97047570 100644 --- a/scripts/lib/build-target-arch.test.ts +++ b/scripts/lib/build-target-arch.test.ts @@ -2,13 +2,9 @@ import { assert, describe, it } from "@effect/vitest"; import * as ConfigProvider from "effect/ConfigProvider"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/hostProcess"; -import { - getDefaultBuildArch, - HostProcessArchitecture, - HostProcessPlatform, - resolveHostProcessArch, -} from "./build-target-arch.ts"; +import { getDefaultBuildArch } from "./build-target-arch.ts"; const compactEnv = (env: Readonly>): Record => Object.fromEntries( @@ -29,41 +25,6 @@ const withHostRuntime = ( ); describe("build-target-arch", () => { - it.effect("prefers arm64 for Windows-on-Arm hosts running x64 emulation", () => - Effect.gen(function* () { - // Windows-on-Arm can run an x64 Node process under emulation while still - // exposing the real host CPU via PROCESSOR_ARCHITEW6432. - const hostArch = yield* resolveHostProcessArch().pipe( - withHostRuntime("win32", "x64", { - PROCESSOR_ARCHITECTURE: "AMD64", // The currently running Node process is x64. - PROCESSOR_ARCHITEW6432: "ARM64", // Windows exposes the real host CPU here when x64 runs under ARM emulation. - }), - ); - - assert.equal(hostArch, "arm64"); - }), - ); - - it.effect("falls back to x64 for native x64 Windows hosts", () => - Effect.gen(function* () { - const hostArch = yield* resolveHostProcessArch().pipe( - withHostRuntime("win32", "x64", { - PROCESSOR_ARCHITECTURE: "AMD64", // Both the process and the Windows host are native x64. - }), - ); - - assert.equal(hostArch, "x64"); - }), - ); - - it.effect("keeps arm64 when the current process is already native arm64", () => - Effect.gen(function* () { - const hostArch = yield* resolveHostProcessArch().pipe(withHostRuntime("win32", "arm64")); - - assert.equal(hostArch, "arm64"); - }), - ); - it.effect("uses the resolved host arch when selecting the default Windows build arch", () => Effect.gen(function* () { // This mirrors the packaging script's default-path behavior: the current diff --git a/scripts/lib/build-target-arch.ts b/scripts/lib/build-target-arch.ts index b6ba94f52b4..eb804b226ea 100644 --- a/scripts/lib/build-target-arch.ts +++ b/scripts/lib/build-target-arch.ts @@ -10,8 +10,6 @@ interface PlatformConfig { readonly archChoices: ReadonlyArray; } -export { HostProcessArchitecture, HostProcessPlatform }; - const WindowsProcessorArchitectureConfig = Config.all({ processorArchitecture: Config.string("PROCESSOR_ARCHITECTURE").pipe(Config.option), processorArchitectureW6432: Config.string("PROCESSOR_ARCHITEW6432").pipe(Config.option), @@ -28,7 +26,7 @@ function normalizeWindowsArch(value: string | undefined): BuildArch | undefined const optionToUndefined = (value: Option.Option): A | undefined => Option.getOrUndefined(value); -export const resolveHostProcessArch = Effect.fn("resolveHostProcessArch")(function* () { +const resolveHostProcessArch = Effect.fn("resolveHostProcessArch")(function* () { const platform = yield* HostProcessPlatform; const processArch = yield* HostProcessArchitecture; if (processArch === "arm64") return "arm64"; From b4cac17151e0b60aae391eb287d8d9ea2d833cfd Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 14 Jun 2026 12:30:57 -0700 Subject: [PATCH 08/15] fix: address host runtime review findings Co-authored-by: codex --- .../src/terminal/Layers/NodePTY.test.ts | 58 +++++++++++++++++++ apps/server/src/terminal/Layers/NodePTY.ts | 3 + packages/shared/src/shell.ts | 20 ++++--- packages/ssh/src/command.ts | 8 +-- packages/ssh/src/tunnel.ts | 6 +- scripts/dev-runner.ts | 7 ++- 6 files changed, 83 insertions(+), 19 deletions(-) create mode 100644 apps/server/src/terminal/Layers/NodePTY.test.ts diff --git a/apps/server/src/terminal/Layers/NodePTY.test.ts b/apps/server/src/terminal/Layers/NodePTY.test.ts new file mode 100644 index 00000000000..46840214b66 --- /dev/null +++ b/apps/server/src/terminal/Layers/NodePTY.test.ts @@ -0,0 +1,58 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import { vi } from "vite-plus/test"; + +import { PtyAdapter } from "../Services/PTY.ts"; +import { layer } from "./NodePTY.ts"; + +const spawn = vi.fn(() => ({ + pid: 42, + write: vi.fn(), + resize: vi.fn(), + kill: vi.fn(), + onData: vi.fn(() => ({ dispose: vi.fn() })), + onExit: vi.fn(() => ({ dispose: vi.fn() })), +})); + +vi.mock("node-pty", () => ({ spawn })); + +const testLayer = layer.pipe( + Layer.provide( + Layer.mergeAll( + NodeServices.layer, + Layer.succeed(HostProcessPlatform, "win32"), + Layer.succeed(HostProcessArchitecture, "x64"), + ), + ), +); + +it.effect("spawns through the public adapter with the provided host references", () => + Effect.gen(function* () { + const adapter = yield* PtyAdapter; + const process = yield* adapter.spawn({ + shell: "powershell.exe", + args: ["-NoLogo"], + cwd: "C:\\workspace", + cols: 120, + rows: 40, + env: {}, + }); + + assert.equal(process.pid, 42); + assert.equal(spawn.mock.calls.length, 1); + assert.deepEqual(spawn.mock.calls[0], [ + "powershell.exe", + ["-NoLogo"], + { + cwd: "C:\\workspace", + cols: 120, + rows: 40, + env: {}, + name: "xterm-color", + }, + ]); + }).pipe(Effect.provide(testLayer)), +); diff --git a/apps/server/src/terminal/Layers/NodePTY.ts b/apps/server/src/terminal/Layers/NodePTY.ts index 3968195b51e..2b19fe4ac51 100644 --- a/apps/server/src/terminal/Layers/NodePTY.ts +++ b/apps/server/src/terminal/Layers/NodePTY.ts @@ -105,6 +105,7 @@ export const layer = Layer.effect( const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const platform = yield* HostProcessPlatform; + const architecture = yield* HostProcessArchitecture; const nodePty = yield* Effect.promise(() => import("node-pty")); @@ -112,6 +113,8 @@ export const layer = Layer.effect( ensureNodePtySpawnHelperExecutable().pipe( Effect.provideService(FileSystem.FileSystem, fs), Effect.provideService(Path.Path, path), + Effect.provideService(HostProcessPlatform, platform), + Effect.provideService(HostProcessArchitecture, architecture), Effect.orElseSucceed(() => undefined), ), ); diff --git a/packages/shared/src/shell.ts b/packages/shared/src/shell.ts index 4db346af28a..11536819f06 100644 --- a/packages/shared/src/shell.ts +++ b/packages/shared/src/shell.ts @@ -1,6 +1,7 @@ // @effect-diagnostics nodeBuiltinImport:off import * as NodeOS from "node:os"; import { execFileSync } from "node:child_process"; +import { accessSync, constants as fileSystemConstants } from "node:fs"; import * as Data from "effect/Data"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; @@ -22,6 +23,15 @@ type ExecFileSyncLike = ( options: { encoding: "utf8"; timeout: number }, ) => string; +function canExecuteFile(filePath: string): boolean { + try { + accessSync(filePath, fileSystemConstants.X_OK); + return true; + } catch { + return false; + } +} + export interface CommandAvailabilityOptions { readonly env?: NodeJS.ProcessEnv; } @@ -442,15 +452,7 @@ const isExecutableFile = Effect.fn("shell.isExecutableFile")(function* ( return windowsPathExtensions.includes(extension.toUpperCase()); } - if (stat.mode === undefined) { - return true; - } - // Note: this checks for any execute bit rather than the effective-uid-aware - // `access(X_OK)` the previous sync implementation used. `FileSystem.access` - // exposes no executable probe, and the difference only matters for files - // that are executable solely by a different user — close enough for PATH - // candidate filtering. - return (stat.mode & 0o111) !== 0; + return canExecuteFile(filePath); }); const resolveCommandPathForPlatform = Effect.fn("shell.resolveCommandPathForPlatform")(function* ( diff --git a/packages/ssh/src/command.ts b/packages/ssh/src/command.ts index 09a52225f50..aa48a1b357e 100644 --- a/packages/ssh/src/command.ts +++ b/packages/ssh/src/command.ts @@ -17,7 +17,6 @@ import { SshCommandError, SshInvalidTargetError } from "./errors.ts"; const PUBLISHABLE_T3_VERSION_PATTERN = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/u; const DEFAULT_SSH_COMMAND_TIMEOUT_MS = 60_000; const MAX_SSH_ERROR_OUTPUT_LENGTH = 4_000; -export const SSH_COMMAND = "ssh"; /** * ssh is a real executable everywhere (`ssh.exe` on Windows), so it is always @@ -202,15 +201,16 @@ const runSshCommandInScope = Effect.fn("ssh/command.runSshCommand.inScope")(func ...(input.remoteCommandArgs ?? []), ]; const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const sshCommand = yield* resolveSshCommand; yield* Effect.logDebug("ssh.command.start", { ...sshTargetLogFields(target), - command: [SSH_COMMAND, ...args], + command: [sshCommand, ...args], hasStdin: input.stdin !== undefined, timeoutMs: input.timeoutMs ?? DEFAULT_SSH_COMMAND_TIMEOUT_MS, }); const child = yield* spawner .spawn( - ChildProcess.make(yield* resolveSshCommand, args, { + ChildProcess.make(sshCommand, args, { env: environment, extendEnv: true, stdin: { @@ -224,7 +224,7 @@ const runSshCommandInScope = Effect.fn("ssh/command.runSshCommand.inScope")(func Effect.mapError( (cause) => new SshCommandError({ - command: [SSH_COMMAND, ...args], + command: [sshCommand, ...args], exitCode: null, stderr: "", message: diff --git a/packages/ssh/src/tunnel.ts b/packages/ssh/src/tunnel.ts index 78e9fc5b5e6..a7f0d68c2a3 100644 --- a/packages/ssh/src/tunnel.ts +++ b/packages/ssh/src/tunnel.ts @@ -37,7 +37,6 @@ import { resolveSshCommand, resolveSshTarget, runSshCommand, - SSH_COMMAND, targetConnectionKey, } from "./command.ts"; import { @@ -1070,7 +1069,8 @@ const startSshTunnel = Effect.fn("ssh/tunnel.startSshTunnel")(function* (input: `${input.localPort}:127.0.0.1:${input.remotePort}`, hostSpec, ]; - const tunnelCommand = [SSH_COMMAND, ...args]; + const sshCommand = yield* resolveSshCommand; + const tunnelCommand = [sshCommand, ...args]; const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const scope = yield* Scope.Scope; yield* Effect.logDebug("ssh.tunnel.spawn.start", { @@ -1083,7 +1083,7 @@ const startSshTunnel = Effect.fn("ssh/tunnel.startSshTunnel")(function* (input: }); const child = yield* spawner .spawn( - ChildProcess.make(yield* resolveSshCommand, args, { + ChildProcess.make(sshCommand, args, { env: childEnvironment, extendEnv: true, stdin: { diff --git a/scripts/dev-runner.ts b/scripts/dev-runner.ts index c5a11c49260..db6d374c372 100644 --- a/scripts/dev-runner.ts +++ b/scripts/dev-runner.ts @@ -5,7 +5,7 @@ import * as NodeOS from "node:os"; import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; import * as NodeServices from "@effect/platform-node/NodeServices"; import * as NetService from "@t3tools/shared/Net"; -import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import { HostProcessEnvironment, HostProcessPlatform } from "@t3tools/shared/hostProcess"; import * as Config from "effect/Config"; import * as Data from "effect/Data"; import * as Effect from "effect/Effect"; @@ -420,9 +420,10 @@ export function runDevRunnerWithInput(input: DevRunnerCliInput) { }); const hostPlatform = yield* HostProcessPlatform; + const hostEnvironment = yield* HostProcessEnvironment; const env = yield* createDevRunnerEnv({ mode: input.mode, - baseEnv: {}, + baseEnv: hostEnvironment, serverOffset, webOffset, t3Home: input.t3Home, @@ -452,7 +453,7 @@ export function runDevRunnerWithInput(input: DevRunnerCliInput) { stdout: "inherit", stderr: "inherit", env, - extendEnv: true, + extendEnv: false, // Windows needs shell mode to resolve .cmd shims (e.g. vp.cmd). shell: hostPlatform === "win32", // Keep Vite+ in the same process group so terminal signals (Ctrl+C) From 2d5b4b984d6c551e8c9fccbfa46bf39115351440 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 14 Jun 2026 12:34:28 -0700 Subject: [PATCH 09/15] test: complete external launcher server mock Co-authored-by: codex --- apps/server/src/server.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 40c9c7cd9a8..095e82f948e 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -579,6 +579,7 @@ const buildAppUnderTest = (options?: { ), Layer.provide( Layer.mock(ExternalLauncher.ExternalLauncher)({ + resolveAvailableEditors: () => Effect.succeed([]), ...options?.layers?.externalLauncher, }), ), From 86eab5b61061639001737b82852a9a8fdcc306e6 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 14 Jun 2026 12:38:36 -0700 Subject: [PATCH 10/15] test: assert provider reprobe executable Co-authored-by: codex --- .../provider/Layers/ProviderRegistry.test.ts | 37 +++++++------------ 1 file changed, 14 insertions(+), 23 deletions(-) diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts index e2488f728d7..5fe0f903686 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -1159,6 +1159,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest(), T Effect.gen(function* () { const firstMissing = `t3code_codex_first_`; const secondMissing = `t3code_codex_second_`; + const spawnedCommands: Array = []; const serverSettings = yield* makeMutableServerSettingsService( decodeServerSettings( deepMerge(encodedDefaultServerSettings, { @@ -1185,10 +1186,12 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest(), T Layer.provideMerge(TestHttpClientLive), Layer.provideMerge(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)), Layer.provideMerge(OpenCodeRuntimeLive), - // `it.live` does not inherit layers from the outer `it.layer` - // wrapper, so provide `NodeServices.layer` inline. This is the - // same real `ChildProcessSpawner` + `FileSystem` + `Path` - // services that production uses. + Layer.updateService(ChildProcessSpawner.ChildProcessSpawner, (spawner) => + ChildProcessSpawner.make((command) => { + spawnedCommands.push((command as { readonly command: string }).command); + return spawner.spawn(command); + }), + ), Layer.provideMerge(NodeServices.layer), ); const runtimeServices = yield* Layer.build(providerRegistryLayer).pipe( @@ -1199,10 +1202,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest(), T const registry = yield* ProviderRegistry; // Boot-time probe: the default codex instance is enabled with // `firstMissing`, so the real spawner yields ENOENT and the - // snapshot should be `status: "error"`. What *distinguishes* - // the two probe runs is `checkedAt` — each probe stamps a - // fresh DateTime, so we capture it and assert it advances - // after the settings mutation. + // snapshot should be `status: "error"`. let initialProviders = yield* registry.getProviders; for ( let attempts = 0; @@ -1220,13 +1220,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest(), T ); assert.strictEqual(initialCodex?.status, "error"); assert.strictEqual(initialCodex?.installed, false); - const initialCheckedAt = initialCodex?.checkedAt; - assert.notStrictEqual(initialCheckedAt, undefined); - - // The rebuilt instance may re-probe synchronously during the - // settings update. Advance the TestClock first so `checkedAt` - // can safely act as the fresh-probe marker this assertion uses. - yield* TestClock.adjust("1 second"); + assert.deepStrictEqual(spawnedCommands, [firstMissing]); // Drive a settings change. The Hydration layer's // `SettingsWatcherLive` consumes this via `streamChanges`, @@ -1242,8 +1236,9 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest(), T }, }); - // Poll with TestClock until `checkedAt` advances or we hit a - // generous virtual 3-second ceiling. + // Poll until the injected process boundary observes the new + // executable. This verifies the public settings-to-probe behavior + // without depending on timestamps assigned by TestClock. const refreshed = yield* Effect.gen(function* () { for (let attempts = 0; attempts < 60; attempts += 1) { const providers = yield* registry.getProviders; @@ -1251,7 +1246,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest(), T if ( codex !== undefined && codex.status === "error" && - codex.checkedAt !== initialCheckedAt + spawnedCommands.includes(secondMissing) ) { return providers; } @@ -1262,11 +1257,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest(), T }); const reprobedCodex = refreshed.find((provider) => provider.instanceId === "codex"); - assert.notStrictEqual( - reprobedCodex?.checkedAt, - initialCheckedAt, - "Expected a fresh probe after settings change, got the stale snapshot", - ); + assert.deepStrictEqual(spawnedCommands, [firstMissing, secondMissing]); assert.strictEqual(reprobedCodex?.status, "error"); assert.strictEqual(reprobedCodex?.installed, false); }).pipe(Effect.provide(runtimeServices)); From 1329832fa51973ad229c2fcc5f7bee9e4f79a1d6 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 14 Jun 2026 12:45:44 -0700 Subject: [PATCH 11/15] chore: simplify Vite test configuration Co-authored-by: codex --- vite.config.ts | 17 +++-------------- 1 file changed, 3 insertions(+), 14 deletions(-) diff --git a/vite.config.ts b/vite.config.ts index b5812dfb37f..314ebf01e2e 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,18 +1,9 @@ -import { fileURLToPath } from "node:url"; - import "vite-plus/test/config"; import { defineConfig } from "vite-plus"; -const webSrcPath = fileURLToPath(new URL("./apps/web/src", import.meta.url)); - export default defineConfig({ resolve: { - alias: [ - { - find: "~", - replacement: webSrcPath, - }, - ], + tsconfigPaths: true, }, test: { environment: "node", @@ -22,11 +13,9 @@ export default defineConfig({ "**/dist/**", "**/dist-electron/**", "**/.{idea,git,cache,output,temp}/**", - "**/routeTree.gen.ts", ], - fileParallelism: false, - hookTimeout: 120_000, - testTimeout: 120_000, + hookTimeout: 60_000, + testTimeout: 60_000, }, fmt: { ignorePatterns: [ From 14538b6191753df43df709c52cc842e2c59e5074 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 14 Jun 2026 12:50:23 -0700 Subject: [PATCH 12/15] nit --- apps/desktop/src/main.ts | 4 ++-- apps/server/src/cli/config.test.ts | 2 +- apps/server/src/startupAccess.ts | 8 ++++---- apps/server/src/telemetry/Identify.ts | 7 ++++--- apps/server/src/workspace/Layers/WorkspaceEntries.ts | 6 +++--- apps/server/src/workspace/Layers/WorkspacePaths.ts | 6 +++--- 6 files changed, 17 insertions(+), 16 deletions(-) diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index a5cdba9bf0c..3ed0b9b5cf0 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -1,7 +1,7 @@ import * as NodeHttpClient from "@effect/platform-node/NodeHttpClient"; import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; import * as NodeServices from "@effect/platform-node/NodeServices"; -import { homedir } from "node:os"; +import * as NodeOS from "node:os"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; @@ -58,7 +58,7 @@ const desktopEnvironmentLayer = Layer.unwrap( const processArch = yield* HostProcessArchitecture; return DesktopEnvironment.layer({ dirname: __dirname, - homeDirectory: homedir(), + homeDirectory: NodeOS.homedir(), platform, processArch, ...metadata, diff --git a/apps/server/src/cli/config.test.ts b/apps/server/src/cli/config.test.ts index 9e73773d5a5..d4d9d378557 100644 --- a/apps/server/src/cli/config.test.ts +++ b/apps/server/src/cli/config.test.ts @@ -1,4 +1,4 @@ -import NodeOS from "node:os"; +import * as NodeOS from "node:os"; import { assert, expect, it } from "@effect/vitest"; import * as ConfigProvider from "effect/ConfigProvider"; diff --git a/apps/server/src/startupAccess.ts b/apps/server/src/startupAccess.ts index 7a03dafaefe..7df131669ba 100644 --- a/apps/server/src/startupAccess.ts +++ b/apps/server/src/startupAccess.ts @@ -1,4 +1,4 @@ -import { networkInterfaces } from "node:os"; +import * as NodeOS from "node:os"; import { QrCode } from "@t3tools/shared/qrCode"; import * as Effect from "effect/Effect"; @@ -13,7 +13,7 @@ export interface HeadlessServeAccessInfo { readonly pairingUrl: string; } -type NetworkInterfacesMap = ReturnType; +type NetworkInterfacesMap = ReturnType; export const isLoopbackHost = (host: string | undefined): boolean => { if (!host || host.length === 0) { @@ -44,7 +44,7 @@ const isIpv6Family = (family: string | number): boolean => family === "IPv6" || export const resolveHeadlessConnectionHost = ( host: string | undefined, - interfaces: NetworkInterfacesMap = networkInterfaces(), + interfaces: NetworkInterfacesMap = NodeOS.networkInterfaces(), ): string => { if (!host) { return "localhost"; @@ -71,7 +71,7 @@ export const resolveHeadlessConnectionHost = ( export const resolveHeadlessConnectionString = ( host: string | undefined, port: number, - interfaces: NetworkInterfacesMap = networkInterfaces(), + interfaces: NetworkInterfacesMap = NodeOS.networkInterfaces(), ): string => { const connectionHost = resolveHeadlessConnectionHost(host, interfaces); return `http://${formatHostForUrl(connectionHost)}:${port}`; diff --git a/apps/server/src/telemetry/Identify.ts b/apps/server/src/telemetry/Identify.ts index da04bd0b266..364273a9e1d 100644 --- a/apps/server/src/telemetry/Identify.ts +++ b/apps/server/src/telemetry/Identify.ts @@ -1,10 +1,11 @@ +import * as NodeOS from "node:os"; import * as Crypto from "effect/Crypto"; import * as Effect from "effect/Effect"; import * as Encoding from "effect/Encoding"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; -import { homedir } from "node:os"; + import { ServerConfig } from "../config.ts"; const CodexAuthJsonSchema = Schema.Struct({ @@ -39,7 +40,7 @@ const getCodexAccountId = Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; - const authJsonPath = path.join(homedir(), ".codex", "auth.json"); + const authJsonPath = path.join(NodeOS.homedir(), ".codex", "auth.json"); const authJson = yield* Effect.flatMap( fileSystem.readFileString(authJsonPath), Schema.decodeEffect(Schema.fromJsonString(CodexAuthJsonSchema)), @@ -52,7 +53,7 @@ const getClaudeUserId = Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; - const claudeJsonPath = path.join(homedir(), ".claude.json"); + const claudeJsonPath = path.join(NodeOS.homedir(), ".claude.json"); const claudeJson = yield* Effect.flatMap( fileSystem.readFileString(claudeJsonPath), Schema.decodeEffect(Schema.fromJsonString(ClaudeJsonSchema)), diff --git a/apps/server/src/workspace/Layers/WorkspaceEntries.ts b/apps/server/src/workspace/Layers/WorkspaceEntries.ts index 605c0bb76e9..2903fd3a8e4 100644 --- a/apps/server/src/workspace/Layers/WorkspaceEntries.ts +++ b/apps/server/src/workspace/Layers/WorkspaceEntries.ts @@ -1,5 +1,5 @@ // @effect-diagnostics nodeBuiltinImport:off -import * as OS from "node:os"; +import * as NodeOS from "node:os"; import fsPromises from "node:fs/promises"; import type { Dirent } from "node:fs"; @@ -66,10 +66,10 @@ function toPosixPath(input: string): string { function expandHomePath(input: string, path: Path.Path): string { if (input === "~") { - return OS.homedir(); + return NodeOS.homedir(); } if (input.startsWith("~/") || input.startsWith("~\\")) { - return path.join(OS.homedir(), input.slice(2)); + return path.join(NodeOS.homedir(), input.slice(2)); } return input; } diff --git a/apps/server/src/workspace/Layers/WorkspacePaths.ts b/apps/server/src/workspace/Layers/WorkspacePaths.ts index f994aa875ef..dfe02e8f67c 100644 --- a/apps/server/src/workspace/Layers/WorkspacePaths.ts +++ b/apps/server/src/workspace/Layers/WorkspacePaths.ts @@ -1,4 +1,4 @@ -import * as OS from "node:os"; +import * as NodeOS from "node:os"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; @@ -19,10 +19,10 @@ function toPosixRelativePath(input: string): string { function expandHomePath(input: string, path: Path.Path): string { if (input === "~") { - return OS.homedir(); + return NodeOS.homedir(); } if (input.startsWith("~/") || input.startsWith("~\\")) { - return path.join(OS.homedir(), input.slice(2)); + return path.join(NodeOS.homedir(), input.slice(2)); } return input; } From df7c07d6eb4b4b79636ac77ad3fe065e605d4ac2 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 14 Jun 2026 13:02:30 -0700 Subject: [PATCH 13/15] security: constrain Windows shell process spawning Co-authored-by: codex --- apps/server/scripts/cli.ts | 13 +-- .../cursor-acp-model-mismatch-probe.ts | 9 +- .../src/process/externalLauncher.test.ts | 10 +- apps/server/src/process/externalLauncher.ts | 11 +-- apps/server/src/processRunner.test.ts | 1 - apps/server/src/processRunner.ts | 2 - .../src/provider/Layers/ClaudeProvider.ts | 13 ++- .../src/provider/Layers/CodexProvider.ts | 24 ++--- .../provider/Layers/CodexSessionRuntime.ts | 27 +++--- .../src/provider/Layers/CursorProvider.ts | 19 ++-- .../src/provider/Layers/GrokProvider.ts | 13 ++- .../src/provider/acp/AcpSessionRuntime.ts | 15 +-- apps/server/src/provider/opencodeRuntime.ts | 16 ++-- .../textGeneration/ClaudeTextGeneration.ts | 29 +++--- .../src/textGeneration/CodexTextGeneration.ts | 35 ++++--- .../examples/cursor-acp-client.example.ts | 7 +- .../test/examples/codex-app-server-probe.ts | 7 +- packages/shared/src/shell.test.ts | 58 ++++++++++++ packages/shared/src/shell.ts | 91 +++++++++++++++++-- scripts/build-desktop-artifact.ts | 39 +++++--- scripts/dev-runner.ts | 14 ++- scripts/mobile-native-static-check.ts | 27 +----- 22 files changed, 301 insertions(+), 179 deletions(-) diff --git a/apps/server/scripts/cli.ts b/apps/server/scripts/cli.ts index 0474e6e2acb..a158eaa068d 100644 --- a/apps/server/scripts/cli.ts +++ b/apps/server/scripts/cli.ts @@ -18,7 +18,7 @@ import { import { resolveCatalogDependencies } from "../../../scripts/lib/resolve-catalog.ts"; import { fromJsonStringPretty } from "@t3tools/shared/schemaJson"; import { fromYaml } from "@t3tools/shared/schemaYaml"; -import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import { resolveSpawnCommand } from "@t3tools/shared/shell"; import serverPackageJson from "../package.json" with { type: "json" }; interface PackageJson { @@ -168,7 +168,6 @@ const buildCmd = Command.make( const path = yield* Path.Path; const fs = yield* FileSystem.FileSystem; const repoRoot = yield* RepoRoot; - const platform = yield* HostProcessPlatform; const serverDir = path.join(repoRoot, "apps/server"); yield* Effect.log("[cli] Running tsdown..."); @@ -177,8 +176,7 @@ const buildCmd = Command.make( cwd: serverDir, stdout: config.verbose ? "inherit" : "ignore", stderr: "inherit", - // Windows needs shell mode to resolve `.cmd` shims on PATH. - shell: platform === "win32", + shell: false, }), ); @@ -294,16 +292,15 @@ const publishCmd = Command.make( () => Effect.gen(function* () { const args = createVpPmPublishArgs(config); - const platform = yield* HostProcessPlatform; + const spawnCommand = yield* resolveSpawnCommand("vp", ["pm", ...args]); yield* Effect.log(`[cli] Running: vp pm ${args.join(" ")}`); yield* runCommand( - ChildProcess.make("vp", ["pm", ...args], { + ChildProcess.make(spawnCommand.command, spawnCommand.args, { cwd: repoRoot, stdout: config.verbose ? "inherit" : "ignore", stderr: "inherit", - // Windows needs shell mode to resolve .cmd shims. - shell: platform === "win32", + shell: spawnCommand.shell, }), ); }), diff --git a/apps/server/scripts/cursor-acp-model-mismatch-probe.ts b/apps/server/scripts/cursor-acp-model-mismatch-probe.ts index c840aeaaf8c..31f2ef6f1f7 100644 --- a/apps/server/scripts/cursor-acp-model-mismatch-probe.ts +++ b/apps/server/scripts/cursor-acp-model-mismatch-probe.ts @@ -1,9 +1,10 @@ // @effect-diagnostics nodeBuiltinImport:off import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; -import * as NodeOS from "node:os"; import process from "node:process"; import readline from "node:readline"; import * as NodeTimers from "node:timers"; +import { resolveSpawnCommand } from "@t3tools/shared/shell"; +import * as Effect from "effect/Effect"; type JsonPrimitive = null | boolean | number | string; type JsonValue = JsonPrimitive | JsonValue[] | { [key: string]: JsonValue }; @@ -129,10 +130,10 @@ class JsonRpcChild { closed = false; constructor(bin: string, args: string[], cwd: string) { - this.child = spawn(bin, args, { + const spawnCommand = Effect.runSync(resolveSpawnCommand(bin, args)); + this.child = spawn(spawnCommand.command, spawnCommand.args, { cwd, - // oxlint-disable-next-line t3code/no-global-process-runtime -- Standalone Node probe script has no Effect runtime. - shell: NodeOS.platform() === "win32", + shell: spawnCommand.shell, stdio: ["pipe", "pipe", "pipe"], env: process.env, }); diff --git a/apps/server/src/process/externalLauncher.test.ts b/apps/server/src/process/externalLauncher.test.ts index ca155e9306e..0a157e301c4 100644 --- a/apps/server/src/process/externalLauncher.test.ts +++ b/apps/server/src/process/externalLauncher.test.ts @@ -10,6 +10,7 @@ import * as Stream from "effect/Stream"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import { SpawnExecutableResolution } from "@t3tools/shared/shell"; import { ExternalLauncher, layer as ExternalLauncherLive } from "./externalLauncher.ts"; function makeMockDetachedHandle(onUnref: () => void = () => undefined) { @@ -34,6 +35,7 @@ function makeMockDetachedHandle(onUnref: () => void = () => undefined) { const testLayer = (input: { readonly platform: NodeJS.Platform; readonly env?: Record; + readonly resolveExecutable?: (command: string) => string | undefined; readonly onSpawn?: (command: ChildProcess.StandardCommand) => void; readonly onUnref?: () => void; }) => { @@ -54,6 +56,10 @@ const testLayer = (input: { return Layer.mergeAll( ExternalLauncherLive.pipe(Layer.provide(Layer.merge(NodeServices.layer, spawnerLayer))), Layer.succeed(HostProcessPlatform, input.platform), + Layer.succeed( + SpawnExecutableResolution, + (command) => input.resolveExecutable?.(command) ?? command, + ), ConfigProvider.layer(ConfigProvider.fromEnv({ env: input.env ?? {} })), ); }; @@ -105,6 +111,8 @@ it.effect("launches an installed editor with platform-safe arguments", () => testLayer({ platform: "win32", env: { PATH: binDir, PATHEXT: ".COM;.EXE;.BAT;.CMD" }, + resolveExecutable: (command) => + command === "code" ? "C:\\Program Files\\Microsoft VS Code\\bin\\code.CMD" : command, onSpawn: (command) => { spawned = command; }, @@ -113,7 +121,7 @@ it.effect("launches an installed editor with platform-safe arguments", () => ); assert.ok(spawned); - assert.equal(spawned.command, "code"); + assert.equal(spawned.command, '^"C:\\Program^ Files\\Microsoft^ VS^ Code\\bin\\code.CMD^"'); assert.deepEqual(spawned.args, [ '^"--goto^"', '^"C:\\workspace^ with^ spaces\\src\\index.ts:12:4^"', diff --git a/apps/server/src/process/externalLauncher.ts b/apps/server/src/process/externalLauncher.ts index 20dfc1b6d92..0b40acef5c0 100644 --- a/apps/server/src/process/externalLauncher.ts +++ b/apps/server/src/process/externalLauncher.ts @@ -13,7 +13,7 @@ import { type LaunchEditorInput, } from "@t3tools/contracts"; import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; -import { isCommandAvailable, sanitizeShellModeArgs } from "@t3tools/shared/shell"; +import { isCommandAvailable, resolveSpawnCommand } from "@t3tools/shared/shell"; import * as Config from "effect/Config"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; @@ -373,7 +373,6 @@ const launchEditorProcess = Effect.fn("externalLauncher.launchEditorProcess")(fu ExternalLauncherError, ChildProcessSpawner.ChildProcessSpawner | FileSystem.FileSystem | Path.Path > { - const platform = yield* HostProcessPlatform; const env = yield* readCommandLookupEnv; if (!(yield* isCommandAvailable(launch.command, { env }))) { return yield* new ExternalLauncherError({ @@ -381,14 +380,14 @@ const launchEditorProcess = Effect.fn("externalLauncher.launchEditorProcess")(fu }); } - const isWin32 = platform === "win32"; + const spawnCommand = yield* resolveSpawnCommand(launch.command, launch.args, { env }); yield* launchAndUnref( { - command: launch.command, - args: yield* sanitizeShellModeArgs(launch.args), + command: spawnCommand.command, + args: spawnCommand.args, options: { detached: true, - shell: isWin32, + shell: spawnCommand.shell, stdin: "ignore", stdout: "ignore", stderr: "ignore", diff --git a/apps/server/src/processRunner.test.ts b/apps/server/src/processRunner.test.ts index bacb3e369d4..29983555a54 100644 --- a/apps/server/src/processRunner.test.ts +++ b/apps/server/src/processRunner.test.ts @@ -69,7 +69,6 @@ const runWith = Effect.flatMap((runner) => runner.run({ ...input, - shell: input.shell ?? false, }), ), Effect.provide( diff --git a/apps/server/src/processRunner.ts b/apps/server/src/processRunner.ts index 9247a99339d..ec8b78f121e 100644 --- a/apps/server/src/processRunner.ts +++ b/apps/server/src/processRunner.ts @@ -25,7 +25,6 @@ export interface ProcessRunInput { readonly maxOutputBytes?: number | undefined; readonly outputMode?: "error" | "truncate" | undefined; readonly truncatedMarker?: string | undefined; - readonly shell?: boolean | string | undefined; /** * On timeout, return a synthetic timedOut result. * Partial stdout/stderr are not preserved. @@ -246,7 +245,6 @@ const runProcessCore = Effect.fn("processRunner.runProcessCore")(function* ( extendEnv: true, } : {}), - ...(input.shell !== undefined ? { shell: input.shell } : {}), }), ) .pipe( diff --git a/apps/server/src/provider/Layers/ClaudeProvider.ts b/apps/server/src/provider/Layers/ClaudeProvider.ts index af25fa23c62..d677de7a313 100644 --- a/apps/server/src/provider/Layers/ClaudeProvider.ts +++ b/apps/server/src/provider/Layers/ClaudeProvider.ts @@ -18,8 +18,7 @@ import { getProviderOptionCurrentValue, getProviderOptionDescriptors, } from "@t3tools/shared/model"; -import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; -import { sanitizeShellModeArgs } from "@t3tools/shared/shell"; +import { resolveSpawnCommand } from "@t3tools/shared/shell"; import { compareSemverVersions } from "@t3tools/shared/semver"; import { query as claudeQuery, @@ -607,13 +606,13 @@ const runClaudeCommand = Effect.fn("runClaudeCommand")(function* ( args: ReadonlyArray, environment?: NodeJS.ProcessEnv, ) { - const hostPlatform = yield* HostProcessPlatform; const claudeEnvironment = yield* makeClaudeEnvironment(claudeSettings, environment); - // The provider binary may be an npm-installed `.cmd` shim, so Windows spawns - // through cmd.exe shell mode with explicitly sanitized arguments. - const command = ChildProcess.make(claudeSettings.binaryPath, yield* sanitizeShellModeArgs(args), { + const spawnCommand = yield* resolveSpawnCommand(claudeSettings.binaryPath, args, { env: claudeEnvironment, - shell: hostPlatform === "win32", + }); + const command = ChildProcess.make(spawnCommand.command, spawnCommand.args, { + env: claudeEnvironment, + shell: spawnCommand.shell, }); return yield* spawnAndCollect(claudeSettings.binaryPath, command); }); diff --git a/apps/server/src/provider/Layers/CodexProvider.ts b/apps/server/src/provider/Layers/CodexProvider.ts index 058954b31d4..ceaa05b56a1 100644 --- a/apps/server/src/provider/Layers/CodexProvider.ts +++ b/apps/server/src/provider/Layers/CodexProvider.ts @@ -23,9 +23,9 @@ import type { } from "@t3tools/contracts"; import { ServerSettingsError } from "@t3tools/contracts"; -import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import { createModelCapabilities } from "@t3tools/shared/model"; -import { sanitizeShellModeArgs } from "@t3tools/shared/shell"; +import { HostProcessEnvironment } from "@t3tools/shared/hostProcess"; +import { resolveSpawnCommand } from "@t3tools/shared/shell"; import { AUTH_PROBE_TIMEOUT_MS, buildServerProvider, @@ -297,20 +297,22 @@ const probeCodexAppServerProvider = Effect.fn("probeCodexAppServerProvider")(fun // Expand here for parity with `CodexTextGeneration`/`CodexSessionRuntime`. const resolvedHomePath = input.homePath ? expandHomePath(input.homePath) : undefined; const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const hostPlatform = yield* HostProcessPlatform; - // The codex binary may be an npm-installed `.cmd` shim, so Windows spawns - // through cmd.exe shell mode with explicitly sanitized arguments. + const environment = { + ...input.environment, + ...(resolvedHomePath ? { CODEX_HOME: resolvedHomePath } : {}), + }; + const hostEnvironment = yield* HostProcessEnvironment; + const spawnCommand = yield* resolveSpawnCommand(input.binaryPath, ["app-server"], { + env: { ...hostEnvironment, ...environment }, + }); const child = yield* spawner .spawn( - ChildProcess.make(input.binaryPath, yield* sanitizeShellModeArgs(["app-server"]), { + ChildProcess.make(spawnCommand.command, spawnCommand.args, { cwd: input.cwd, - env: { - ...input.environment, - ...(resolvedHomePath ? { CODEX_HOME: resolvedHomePath } : {}), - }, + env: environment, extendEnv: true, forceKillAfter: CODEX_APP_SERVER_PROBE_FORCE_KILL_AFTER, - shell: hostPlatform === "win32", + shell: spawnCommand.shell, }), ) .pipe( diff --git a/apps/server/src/provider/Layers/CodexSessionRuntime.ts b/apps/server/src/provider/Layers/CodexSessionRuntime.ts index 9759a33b913..dd85a5c0d33 100644 --- a/apps/server/src/provider/Layers/CodexSessionRuntime.ts +++ b/apps/server/src/provider/Layers/CodexSessionRuntime.ts @@ -16,8 +16,7 @@ import { ThreadId, TurnId, } from "@t3tools/contracts"; -import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; -import { sanitizeShellModeArgs } from "@t3tools/shared/shell"; +import { resolveSpawnCommand } from "@t3tools/shared/shell"; import { normalizeModelSlug } from "@t3tools/shared/model"; import * as Crypto from "effect/Crypto"; import * as DateTime from "effect/DateTime"; @@ -721,24 +720,24 @@ export const makeCodexSessionRuntime = ( // `child_process.spawn`; `expandHomePath` lets a configured // `CODEX_HOME=~/.codex_work` reach codex as an absolute path. const resolvedHomePath = options.homePath ? expandHomePath(options.homePath) : undefined; - const hostPlatform = yield* HostProcessPlatform; const env = { ...options.environment, ...(resolvedHomePath ? { CODEX_HOME: resolvedHomePath } : {}), }; + const spawnCommand = yield* resolveSpawnCommand( + options.binaryPath, + ["app-server", ...(options.appServerArgs ?? [])], + { env }, + ); const child = yield* spawner .spawn( - ChildProcess.make( - options.binaryPath, - yield* sanitizeShellModeArgs(["app-server", ...(options.appServerArgs ?? [])]), - { - cwd: options.cwd, - env, - extendEnv: options.environment === undefined, - forceKillAfter: CODEX_APP_SERVER_FORCE_KILL_AFTER, - shell: hostPlatform === "win32", - }, - ), + ChildProcess.make(spawnCommand.command, spawnCommand.args, { + cwd: options.cwd, + env, + extendEnv: options.environment === undefined, + forceKillAfter: CODEX_APP_SERVER_FORCE_KILL_AFTER, + shell: spawnCommand.shell, + }), ) .pipe( Effect.provideService(Scope.Scope, runtimeScope), diff --git a/apps/server/src/provider/Layers/CursorProvider.ts b/apps/server/src/provider/Layers/CursorProvider.ts index 65d0c18dec0..35d5413714c 100644 --- a/apps/server/src/provider/Layers/CursorProvider.ts +++ b/apps/server/src/provider/Layers/CursorProvider.ts @@ -27,8 +27,7 @@ import { getProviderOptionBooleanSelectionValue, getProviderOptionStringSelectionValue, } from "@t3tools/shared/model"; -import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; -import { sanitizeShellModeArgs } from "@t3tools/shared/shell"; +import { resolveSpawnCommand } from "@t3tools/shared/shell"; import { buildBooleanOptionDescriptor, @@ -933,17 +932,15 @@ const runCursorCommand = ( ) => Effect.gen(function* () { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const hostPlatform = yield* HostProcessPlatform; - // The provider binary may be an npm-installed `.cmd` shim, so Windows spawns - // through cmd.exe shell mode with explicitly sanitized arguments. - const command = ChildProcess.make( + const spawnCommand = yield* resolveSpawnCommand( cursorSettings.binaryPath, - yield* sanitizeShellModeArgs(args), - { - ...(environment ? { env: environment } : { extendEnv: true }), - shell: hostPlatform === "win32", - }, + args, + environment ? { env: environment } : {}, ); + const command = ChildProcess.make(spawnCommand.command, spawnCommand.args, { + ...(environment ? { env: environment } : { extendEnv: true }), + shell: spawnCommand.shell, + }); const child = yield* spawner.spawn(command); const [stdout, stderr, exitCode] = yield* Effect.all( diff --git a/apps/server/src/provider/Layers/GrokProvider.ts b/apps/server/src/provider/Layers/GrokProvider.ts index 417ada2b6d1..35611398b4b 100644 --- a/apps/server/src/provider/Layers/GrokProvider.ts +++ b/apps/server/src/provider/Layers/GrokProvider.ts @@ -14,9 +14,8 @@ import * as Option from "effect/Option"; import * as Result from "effect/Result"; import { HttpClient } from "effect/unstable/http"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; -import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import { createModelCapabilities } from "@t3tools/shared/model"; -import { sanitizeShellModeArgs } from "@t3tools/shared/shell"; +import { resolveSpawnCommand } from "@t3tools/shared/shell"; import { buildServerProvider, @@ -153,15 +152,15 @@ const runGrokVersionCommand = ( environment: NodeJS.ProcessEnv = process.env, ) => Effect.gen(function* () { - const hostPlatform = yield* HostProcessPlatform; const command = grokSettings.binaryPath || "grok"; - // The provider binary may be an npm-installed `.cmd` shim, so Windows spawns - // through cmd.exe shell mode with explicitly sanitized arguments. + const spawnCommand = yield* resolveSpawnCommand(command, ["--version"], { + env: environment, + }); return yield* spawnAndCollect( command, - ChildProcess.make(command, yield* sanitizeShellModeArgs(["--version"]), { + ChildProcess.make(spawnCommand.command, spawnCommand.args, { env: environment, - shell: hostPlatform === "win32", + shell: spawnCommand.shell, }), ); }); diff --git a/apps/server/src/provider/acp/AcpSessionRuntime.ts b/apps/server/src/provider/acp/AcpSessionRuntime.ts index 817d3b8029f..c167dc2cba6 100644 --- a/apps/server/src/provider/acp/AcpSessionRuntime.ts +++ b/apps/server/src/provider/acp/AcpSessionRuntime.ts @@ -13,8 +13,8 @@ import * as EffectAcpClient from "effect-acp/client"; import * as EffectAcpErrors from "effect-acp/errors"; import type * as EffectAcpSchema from "effect-acp/schema"; import type * as EffectAcpProtocol from "effect-acp/protocol"; -import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; -import { sanitizeShellModeArgs } from "@t3tools/shared/shell"; +import { HostProcessEnvironment } from "@t3tools/shared/hostProcess"; +import { resolveSpawnCommand } from "@t3tools/shared/shell"; import { collectSessionConfigOptionValues, @@ -203,15 +203,16 @@ const makeAcpSessionRuntime = ( ), ); - const hostPlatform = yield* HostProcessPlatform; + const hostEnvironment = yield* HostProcessEnvironment; + const spawnCommand = yield* resolveSpawnCommand(options.spawn.command, options.spawn.args, { + env: { ...hostEnvironment, ...options.spawn.env }, + }); const child = yield* spawner .spawn( - // The agent binary may be an npm-installed `.cmd` shim, so Windows spawns - // through cmd.exe shell mode with explicitly sanitized arguments. - ChildProcess.make(options.spawn.command, yield* sanitizeShellModeArgs(options.spawn.args), { + ChildProcess.make(spawnCommand.command, spawnCommand.args, { ...(options.spawn.cwd ? { cwd: options.spawn.cwd } : {}), ...(options.spawn.env ? { env: options.spawn.env, extendEnv: true } : {}), - shell: hostPlatform === "win32", + shell: spawnCommand.shell, }), ) .pipe( diff --git a/apps/server/src/provider/opencodeRuntime.ts b/apps/server/src/provider/opencodeRuntime.ts index c5bcf9039da..365884da85d 100644 --- a/apps/server/src/provider/opencodeRuntime.ts +++ b/apps/server/src/provider/opencodeRuntime.ts @@ -32,7 +32,7 @@ import { isWindowsCommandNotFound } from "../processRunner.ts"; import { collectStreamAsString } from "./providerSnapshot.ts"; import * as NetService from "@t3tools/shared/Net"; import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; -import { sanitizeShellModeArgs } from "@t3tools/shared/shell"; +import { resolveSpawnCommand } from "@t3tools/shared/shell"; const encodeUnknownJsonStringExit = Schema.encodeUnknownExit(Schema.UnknownFromJsonString); const OPENCODE_EMPTY_CONFIG_CONTENT = "{}"; @@ -279,14 +279,15 @@ const makeOpenCodeRuntime = Effect.gen(function* () { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const netService = yield* NetService.NetService; const hostPlatform = yield* HostProcessPlatform; + const resolveCommand = (command: string, args: ReadonlyArray, env?: NodeJS.ProcessEnv) => + resolveSpawnCommand(command, args, env ? { env } : {}); const runOpenCodeCommand: OpenCodeRuntimeShape["runOpenCodeCommand"] = (input) => Effect.gen(function* () { - // The opencode binary may be an npm-installed `.cmd` shim, so Windows - // spawns through cmd.exe shell mode with explicitly sanitized arguments. + const spawnCommand = yield* resolveCommand(input.binaryPath, input.args, input.environment); const child = yield* spawner.spawn( - ChildProcess.make(input.binaryPath, yield* sanitizeShellModeArgs(input.args), { - shell: hostPlatform === "win32", + ChildProcess.make(spawnCommand.command, spawnCommand.args, { + shell: spawnCommand.shell, ...(input.environment ? { env: input.environment } : { extendEnv: true }), }), ); @@ -339,12 +340,13 @@ const makeOpenCodeRuntime = Effect.gen(function* () { )); const timeoutMs = input.timeoutMs ?? DEFAULT_OPENCODE_SERVER_TIMEOUT_MS; const args = ["serve", `--hostname=${hostname}`, `--port=${port}`]; + const spawnCommand = yield* resolveCommand(input.binaryPath, args, input.environment); const child = yield* spawner .spawn( - ChildProcess.make(input.binaryPath, yield* sanitizeShellModeArgs(args), { + ChildProcess.make(spawnCommand.command, spawnCommand.args, { detached: hostPlatform !== "win32", - shell: hostPlatform === "win32", + shell: spawnCommand.shell, env: { ...input.environment, OPENCODE_CONFIG_CONTENT: OPENCODE_EMPTY_CONFIG_CONTENT, diff --git a/apps/server/src/textGeneration/ClaudeTextGeneration.ts b/apps/server/src/textGeneration/ClaudeTextGeneration.ts index 541d673de9a..91ad90b786e 100644 --- a/apps/server/src/textGeneration/ClaudeTextGeneration.ts +++ b/apps/server/src/textGeneration/ClaudeTextGeneration.ts @@ -15,8 +15,7 @@ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { type ClaudeSettings, type ModelSelection } from "@t3tools/contracts"; import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git"; -import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; -import { sanitizeShellModeArgs } from "@t3tools/shared/shell"; +import { resolveSpawnCommand } from "@t3tools/shared/shell"; import { TextGenerationError } from "@t3tools/contracts"; import { type TextGenerationShape } from "./TextGeneration.ts"; @@ -64,7 +63,6 @@ export const makeClaudeTextGeneration = Effect.fn("makeClaudeTextGeneration")(fu environment?: NodeJS.ProcessEnv, ) { const commandSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const hostPlatform = yield* HostProcessPlatform; const claudeEnvironment = yield* makeClaudeEnvironment(claudeSettings, environment); const readStreamAsString = ( @@ -159,11 +157,9 @@ export const makeClaudeTextGeneration = Effect.fn("makeClaudeTextGeneration")(fu : undefined; const runClaudeCommand = Effect.fn("runClaudeJson.runClaudeCommand")(function* () { - const command = ChildProcess.make( + const spawnCommand = yield* resolveSpawnCommand( claudeSettings.binaryPath || "claude", - // The provider binary may be an npm-installed `.cmd` shim, so Windows - // spawns through cmd.exe shell mode with explicitly sanitized arguments. - yield* sanitizeShellModeArgs([ + [ "-p", "--output-format", "json", @@ -174,16 +170,17 @@ export const makeClaudeTextGeneration = Effect.fn("makeClaudeTextGeneration")(fu ...(cliEffort ? ["--effort", cliEffort] : []), ...(settingsJson ? ["--settings", settingsJson] : []), "--dangerously-skip-permissions", - ]), - { - env: claudeEnvironment, - cwd, - shell: hostPlatform === "win32", - stdin: { - stream: Stream.encodeText(Stream.make(prompt)), - }, - }, + ], + { env: claudeEnvironment }, ); + const command = ChildProcess.make(spawnCommand.command, spawnCommand.args, { + env: claudeEnvironment, + cwd, + shell: spawnCommand.shell, + stdin: { + stream: Stream.encodeText(Stream.make(prompt)), + }, + }); const child = yield* commandSpawner .spawn(command) diff --git a/apps/server/src/textGeneration/CodexTextGeneration.ts b/apps/server/src/textGeneration/CodexTextGeneration.ts index f6ff7e06001..80b39af2584 100644 --- a/apps/server/src/textGeneration/CodexTextGeneration.ts +++ b/apps/server/src/textGeneration/CodexTextGeneration.ts @@ -9,8 +9,7 @@ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { type CodexSettings, type ModelSelection } from "@t3tools/contracts"; import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git"; -import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; -import { sanitizeShellModeArgs } from "@t3tools/shared/shell"; +import { resolveSpawnCommand } from "@t3tools/shared/shell"; import { resolveAttachmentPath } from "../attachmentStore.ts"; import { ServerConfig } from "../config.ts"; @@ -52,7 +51,6 @@ export const makeCodexTextGeneration = Effect.fn("makeCodexTextGeneration")(func const path = yield* Path.Path; const commandSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; const serverConfig = yield* Effect.service(ServerConfig); - const hostPlatform = yield* HostProcessPlatform; const resolvedEnvironment = environment ?? process.env; type MaterializedImageAttachments = { @@ -184,11 +182,9 @@ export const makeCodexTextGeneration = Effect.fn("makeCodexTextGeneration")(func getModelSelectionStringOptionValue(modelSelection, "reasoningEffort") ?? CODEX_GIT_TEXT_GENERATION_REASONING_EFFORT; const serviceTier = getCodexServiceTierOptionValue(modelSelection); - const command = ChildProcess.make( + const spawnCommand = yield* resolveSpawnCommand( codexConfig.binaryPath || "codex", - // The provider binary may be an npm-installed `.cmd` shim, so Windows - // spawns through cmd.exe shell mode with explicitly sanitized arguments. - yield* sanitizeShellModeArgs([ + [ "exec", "--ephemeral", "--skip-git-repo-check", @@ -205,19 +201,20 @@ export const makeCodexTextGeneration = Effect.fn("makeCodexTextGeneration")(func outputPath, ...imagePaths.flatMap((imagePath) => ["--image", imagePath]), "-", - ]), - { - env: { - ...resolvedEnvironment, - ...(codexConfig.homePath ? { CODEX_HOME: expandHomePath(codexConfig.homePath) } : {}), - }, - cwd, - shell: hostPlatform === "win32", - stdin: { - stream: Stream.encodeText(Stream.make(prompt)), - }, - }, + ], + { env: resolvedEnvironment }, ); + const command = ChildProcess.make(spawnCommand.command, spawnCommand.args, { + env: { + ...resolvedEnvironment, + ...(codexConfig.homePath ? { CODEX_HOME: expandHomePath(codexConfig.homePath) } : {}), + }, + cwd, + shell: spawnCommand.shell, + stdin: { + stream: Stream.encodeText(Stream.make(prompt)), + }, + }); const child = yield* commandSpawner .spawn(command) diff --git a/packages/effect-acp/test/examples/cursor-acp-client.example.ts b/packages/effect-acp/test/examples/cursor-acp-client.example.ts index 4a0788743f7..b7a146cf5c2 100644 --- a/packages/effect-acp/test/examples/cursor-acp-client.example.ts +++ b/packages/effect-acp/test/examples/cursor-acp-client.example.ts @@ -2,21 +2,16 @@ import * as Effect from "effect/Effect"; import * as Console from "effect/Console"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; -import * as NodeOS from "node:os"; - import * as NodeServices from "@effect/platform-node/NodeServices"; import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; import * as AcpClient from "../../src/client.ts"; -// oxlint-disable-next-line t3code/no-global-process-runtime -- Standalone example script has no Effect runtime wiring. -const hostPlatform = NodeOS.platform(); - const program = Effect.gen(function* () { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const command = ChildProcess.make("cursor-agent", ["acp"], { cwd: process.cwd(), - shell: hostPlatform === "win32", + shell: false, }); const handle = yield* spawner.spawn(command); const acpLayer = AcpClient.layerChildProcess(handle, { diff --git a/packages/effect-codex-app-server/test/examples/codex-app-server-probe.ts b/packages/effect-codex-app-server/test/examples/codex-app-server-probe.ts index 8fa4f29965b..fff994dc2ca 100644 --- a/packages/effect-codex-app-server/test/examples/codex-app-server-probe.ts +++ b/packages/effect-codex-app-server/test/examples/codex-app-server-probe.ts @@ -1,5 +1,3 @@ -import * as NodeOS from "node:os"; - import * as Console from "effect/Console"; import * as Effect from "effect/Effect"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; @@ -9,15 +7,12 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import * as CodexClient from "../../src/client.ts"; -// oxlint-disable-next-line t3code/no-global-process-runtime -- Standalone example script has no Effect runtime wiring. -const hostPlatform = NodeOS.platform(); - const program = Effect.gen(function* () { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const handle = yield* spawner.spawn( ChildProcess.make(process.env.CODEX_BIN ?? "codex", ["app-server"], { cwd: process.cwd(), - shell: hostPlatform === "win32", + shell: false, }), ); const codexLayer = CodexClient.layerChildProcess(handle, { diff --git a/packages/shared/src/shell.test.ts b/packages/shared/src/shell.test.ts index 4d3bbfc9bec..2b456f92962 100644 --- a/packages/shared/src/shell.test.ts +++ b/packages/shared/src/shell.test.ts @@ -18,7 +18,9 @@ import { readPathFromLoginShell, resolveCommandPath, resolveKnownWindowsCliDirs, + resolveSpawnCommand, resolveWindowsEnvironment, + SpawnExecutableResolution, WindowsShellEnvironment, type WindowsShellEnvironmentReader, } from "./shell.ts"; @@ -364,6 +366,62 @@ effectIt.layer(NodeServices.layer)("resolveCommandPath", (it) => { ); }); +effectIt.layer(NodeServices.layer)("resolveSpawnCommand", (it) => { + it.effect("runs Windows executables directly without a shell", () => + Effect.gen(function* () { + const command = yield* resolveSpawnCommand("node.exe", ["script.js", "hello & goodbye"], { + env: { PATH: "", PATHEXT: ".COM;.EXE;.BAT;.CMD" }, + }).pipe(Effect.provideService(HostProcessPlatform, "win32")); + + expect(command).toEqual({ + command: "node.exe", + args: ["script.js", "hello & goodbye"], + shell: false, + }); + }), + ); + + it.effect("escapes the executable and arguments for Windows command shims", () => + Effect.gen(function* () { + const command = yield* resolveSpawnCommand( + "vp", + ["run", "value & calc", "%PATH%", 'quote"value'], + { env: { PATH: "", PATHEXT: ".COM;.EXE;.BAT;.CMD" } }, + ).pipe( + Effect.provideService(HostProcessPlatform, "win32"), + Effect.provideService( + SpawnExecutableResolution, + () => "C:\\Program Files\\npm & tools\\vp.cmd", + ), + ); + + expect(command.shell).toBe(true); + expect(command.command).not.toContain(" & "); + expect(command.command).toContain("^&"); + expect(command.args).toEqual([ + '^"run^"', + '^"value^ ^&^ calc^"', + '^"^%PATH^%^"', + '^"quote\\^"value^"', + ]); + }), + ); + + it.effect("does not fall back to a shell for unresolved Windows commands", () => + Effect.gen(function* () { + const command = yield* resolveSpawnCommand("missing & calc", ["unsafe & value"], { + env: { PATH: "", PATHEXT: ".COM;.EXE;.BAT;.CMD" }, + }).pipe(Effect.provideService(HostProcessPlatform, "win32")); + + expect(command).toEqual({ + command: "missing & calc", + args: ["unsafe & value"], + shell: false, + }); + }), + ); +}); + effectIt.layer(NodeServices.layer)("resolveWindowsEnvironment", (it) => { it.effect("returns the baseline no-profile PATH patch when node is already available", () => Effect.gen(function* () { diff --git a/packages/shared/src/shell.ts b/packages/shared/src/shell.ts index 11536819f06..88bf41145b7 100644 --- a/packages/shared/src/shell.ts +++ b/packages/shared/src/shell.ts @@ -1,7 +1,8 @@ // @effect-diagnostics nodeBuiltinImport:off import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; import { execFileSync } from "node:child_process"; -import { accessSync, constants as fileSystemConstants } from "node:fs"; +import { accessSync, constants as fileSystemConstants, statSync } from "node:fs"; import * as Data from "effect/Data"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; @@ -79,11 +80,64 @@ function sanitizeShellModeArgsForPlatform( return platform === "win32" ? args.map(escapeWindowsShellArg) : [...args]; } -export const sanitizeShellModeArgs = Effect.fn("shell.sanitizeShellModeArgs")(function* ( - args: ReadonlyArray, -) { - return sanitizeShellModeArgsForPlatform(args, yield* HostProcessPlatform); -}); +export interface ResolvedSpawnCommand { + readonly command: string; + readonly args: ReadonlyArray; + readonly shell: boolean; +} + +export type SpawnExecutableResolver = ( + command: string, + platform: NodeJS.Platform, + env: NodeJS.ProcessEnv, +) => string | undefined; + +function resolveSpawnExecutableWithNode( + command: string, + platform: NodeJS.Platform, + env: NodeJS.ProcessEnv, +): string | undefined { + const path = platform === "win32" ? NodePath.win32 : NodePath.posix; + const windowsPathExtensions = platform === "win32" ? resolveWindowsPathExtensions(env) : []; + const candidates = resolveCommandCandidates( + command, + platform, + windowsPathExtensions, + path.extname, + ); + const isExecutable = (candidate: string) => { + try { + if (!statSync(candidate).isFile()) return false; + if (platform === "win32") { + return windowsPathExtensions.includes(path.extname(candidate).toUpperCase()); + } + return canExecuteFile(candidate); + } catch { + return false; + } + }; + + if (command.includes("/") || command.includes("\\")) { + return candidates.find(isExecutable); + } + + for (const pathEntry of (readEnvPath(env) ?? "").split(pathDelimiterForPlatform(platform))) { + const normalizedPathEntry = stripWrappingQuotes(pathEntry.trim()); + if (normalizedPathEntry.length === 0) continue; + for (const candidate of candidates) { + const candidatePath = path.join(normalizedPathEntry, candidate); + if (isExecutable(candidatePath)) return candidatePath; + } + } + return undefined; +} + +export const SpawnExecutableResolution = Context.Reference( + "@t3tools/shared/shell/SpawnExecutableResolution", + { + defaultValue: () => resolveSpawnExecutableWithNode, + }, +); export interface WindowsEnvironmentProbeOptions { readonly loadProfile?: boolean; @@ -512,6 +566,31 @@ export const resolveCommandPath = Effect.fn("shell.resolveCommandPath")(function }); }); +export const resolveSpawnCommand = Effect.fn("shell.resolveSpawnCommand")(function* ( + command: string, + args: ReadonlyArray, + options: CommandAvailabilityOptions = {}, +): Effect.fn.Return { + const platform = yield* HostProcessPlatform; + if (platform !== "win32") { + return { command, args: [...args], shell: false }; + } + + const env = options.env ?? (yield* HostProcessEnvironment); + const resolveExecutable = yield* SpawnExecutableResolution; + const resolvedCommand = resolveExecutable(command, platform, env) ?? command; + const extension = NodePath.win32.extname(resolvedCommand).toLowerCase(); + if (extension !== ".cmd" && extension !== ".bat") { + return { command: resolvedCommand, args: [...args], shell: false }; + } + + return { + command: escapeWindowsShellArg(resolvedCommand), + args: sanitizeShellModeArgsForPlatform(args, platform), + shell: true, + }; +}); + export const isCommandAvailable = Effect.fn("shell.isCommandAvailable")(function* ( command: string, options: CommandAvailabilityOptions = {}, diff --git a/scripts/build-desktop-artifact.ts b/scripts/build-desktop-artifact.ts index 4e043828326..f5785f904aa 100644 --- a/scripts/build-desktop-artifact.ts +++ b/scripts/build-desktop-artifact.ts @@ -2,6 +2,7 @@ import { fromYaml } from "@t3tools/shared/schemaYaml"; import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import { resolveSpawnCommand } from "@t3tools/shared/shell"; import rootPackageJson from "../package.json" with { type: "json" }; import desktopPackageJson from "../apps/desktop/package.json" with { type: "json" }; import serverPackageJson from "../apps/server/package.json" with { type: "json" }; @@ -791,7 +792,6 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( const path = yield* Path.Path; const fs = yield* FileSystem.FileSystem; const hostPlatform = yield* HostProcessPlatform; - const useWindowsShell = hostPlatform === "win32"; const workspaceConfig = yield* readWorkspaceConfig(); const workspaceCatalog = workspaceConfig.catalog ?? {}; const workspaceOverrides = workspaceConfig.overrides ?? {}; @@ -858,12 +858,12 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( if (!options.skipBuild) { yield* Effect.log("[desktop-artifact] Building desktop/server/web artifacts..."); + const spawnCommand = yield* resolveSpawnCommand("vp", ["run", "build:desktop"]); yield* runCommand( - ChildProcess.make({ + ChildProcess.make(spawnCommand.command, spawnCommand.args, { cwd: repoRoot, - // Windows needs shell mode to resolve .cmd shims (e.g. vp.cmd). - shell: useWindowsShell, - })`vp run build:desktop`, + shell: spawnCommand.shell, + }), { label: "vp run build:desktop", verbose: options.verbose }, ); } @@ -945,12 +945,12 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( } yield* Effect.log("[desktop-artifact] Installing staged production dependencies..."); + const installCommand = yield* resolveSpawnCommand("vp", ["install", "--prod", "--no-optional"]); yield* runCommand( - ChildProcess.make({ + ChildProcess.make(installCommand.command, installCommand.args, { cwd: stageAppDir, - // Windows needs shell mode to resolve .cmd shims (e.g. vp.cmd). - shell: useWindowsShell, - })`vp install --prod --no-optional`, + shell: installCommand.shell, + }), { label: "vp install --prod --no-optional", verbose: options.verbose }, ); @@ -993,13 +993,26 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( yield* Effect.log( `[desktop-artifact] Building ${options.platform}/${options.target} (arch=${options.arch}, version=${appVersion})...`, ); + const builderArgs = [ + "exec", + "--filter", + "@t3tools/desktop", + "--", + "electron-builder", + "--projectDir", + stageAppDir, + platformConfig.cliFlag, + `--${options.arch}`, + "--publish", + "never", + ]; + const builderCommand = yield* resolveSpawnCommand("vp", builderArgs, { env: buildEnv }); yield* runCommand( - ChildProcess.make({ + ChildProcess.make(builderCommand.command, builderCommand.args, { cwd: repoRoot, env: buildEnv, - // Windows needs shell mode to resolve .cmd shims. - shell: useWindowsShell, - })`vp exec --filter @t3tools/desktop -- electron-builder --projectDir ${stageAppDir} ${platformConfig.cliFlag} --${options.arch} --publish never`, + shell: builderCommand.shell, + }), { label: `vp exec --filter @t3tools/desktop -- electron-builder --projectDir ${stageAppDir} ${platformConfig.cliFlag} --${options.arch} --publish never`, verbose: options.verbose, diff --git a/scripts/dev-runner.ts b/scripts/dev-runner.ts index db6d374c372..36c5aa41852 100644 --- a/scripts/dev-runner.ts +++ b/scripts/dev-runner.ts @@ -5,7 +5,8 @@ import * as NodeOS from "node:os"; import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; import * as NodeServices from "@effect/platform-node/NodeServices"; import * as NetService from "@t3tools/shared/Net"; -import { HostProcessEnvironment, HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import { HostProcessEnvironment } from "@t3tools/shared/hostProcess"; +import { resolveSpawnCommand } from "@t3tools/shared/shell"; import * as Config from "effect/Config"; import * as Data from "effect/Data"; import * as Effect from "effect/Effect"; @@ -419,7 +420,6 @@ export function runDevRunnerWithInput(input: DevRunnerCliInput) { hasExplicitDevUrl: input.devUrl !== undefined, }); - const hostPlatform = yield* HostProcessPlatform; const hostEnvironment = yield* HostProcessEnvironment; const env = yield* createDevRunnerEnv({ mode: input.mode, @@ -448,14 +448,18 @@ export function runDevRunnerWithInput(input: DevRunnerCliInput) { return; } - const child = yield* ChildProcess.make("vp", [...MODE_ARGS[input.mode], ...input.runArgs], { + const spawnCommand = yield* resolveSpawnCommand( + "vp", + [...MODE_ARGS[input.mode], ...input.runArgs], + { env }, + ); + const child = yield* ChildProcess.make(spawnCommand.command, spawnCommand.args, { stdin: "inherit", stdout: "inherit", stderr: "inherit", env, extendEnv: false, - // Windows needs shell mode to resolve .cmd shims (e.g. vp.cmd). - shell: hostPlatform === "win32", + shell: spawnCommand.shell, // Keep Vite+ in the same process group so terminal signals (Ctrl+C) // reach it directly. Effect defaults to detached: true on non-Windows, // which would put the runner in a new group and require manual forwarding. diff --git a/scripts/mobile-native-static-check.ts b/scripts/mobile-native-static-check.ts index d08e3f954e0..4b43788a9ef 100644 --- a/scripts/mobile-native-static-check.ts +++ b/scripts/mobile-native-static-check.ts @@ -2,7 +2,7 @@ import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; import * as NodeServices from "@effect/platform-node/NodeServices"; -import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import { isCommandAvailable, resolveSpawnCommand } from "@t3tools/shared/shell"; import * as Console from "effect/Console"; import * as Data from "effect/Data"; import * as Effect from "effect/Effect"; @@ -59,24 +59,7 @@ const commandOutputOptions = { } as const; const commandExists = Effect.fn("commandExists")(function* (command: string) { - const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const hostPlatform = yield* HostProcessPlatform; - const lookupCommand = - hostPlatform === "win32" - ? ChildProcess.make("where", [command], { - stdout: "ignore", - stderr: "ignore", - }) - : ChildProcess.make("/bin/sh", ["-c", `command -v ${command}`], { - stdout: "ignore", - stderr: "ignore", - }); - - return yield* spawner.spawn(lookupCommand).pipe( - Effect.flatMap((child) => child.exitCode), - Effect.map((exitCode) => exitCode === 0), - Effect.orElseSucceed(() => false), - ); + return yield* isCommandAvailable(command); }); const warnMissingTool = (tool: NativeStaticTool, checkName: string) => @@ -91,12 +74,12 @@ const runCommand = Effect.fn("runCommand")(function* ( ) { yield* Console.log(`$ ${[command, ...args].join(" ")}`); const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const hostPlatform = yield* HostProcessPlatform; + const spawnCommand = yield* resolveSpawnCommand(command, args); const child = yield* spawner.spawn( - ChildProcess.make(command, [...args], { + ChildProcess.make(spawnCommand.command, spawnCommand.args, { cwd, ...commandOutputOptions, - shell: hostPlatform === "win32", + shell: spawnCommand.shell, }), ); const exitCode = Number(yield* child.exitCode); From 4f11202ad2e70df83dc38dc9d6943715b66ec724 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 14 Jun 2026 13:09:33 -0700 Subject: [PATCH 14/15] fix: resolve spawns against effective environment Co-authored-by: codex --- .../src/provider/Layers/CodexProvider.ts | 5 ++-- .../provider/Layers/CodexSessionRuntime.ts | 5 ++-- .../src/provider/acp/AcpSessionRuntime.ts | 10 +++---- packages/shared/src/shell.test.ts | 28 ++++++++++++++++++- packages/shared/src/shell.ts | 9 +++++- 5 files changed, 45 insertions(+), 12 deletions(-) diff --git a/apps/server/src/provider/Layers/CodexProvider.ts b/apps/server/src/provider/Layers/CodexProvider.ts index ceaa05b56a1..fb2f36f6438 100644 --- a/apps/server/src/provider/Layers/CodexProvider.ts +++ b/apps/server/src/provider/Layers/CodexProvider.ts @@ -24,7 +24,6 @@ import type { import { ServerSettingsError } from "@t3tools/contracts"; import { createModelCapabilities } from "@t3tools/shared/model"; -import { HostProcessEnvironment } from "@t3tools/shared/hostProcess"; import { resolveSpawnCommand } from "@t3tools/shared/shell"; import { AUTH_PROBE_TIMEOUT_MS, @@ -301,9 +300,9 @@ const probeCodexAppServerProvider = Effect.fn("probeCodexAppServerProvider")(fun ...input.environment, ...(resolvedHomePath ? { CODEX_HOME: resolvedHomePath } : {}), }; - const hostEnvironment = yield* HostProcessEnvironment; const spawnCommand = yield* resolveSpawnCommand(input.binaryPath, ["app-server"], { - env: { ...hostEnvironment, ...environment }, + env: environment, + extendEnv: true, }); const child = yield* spawner .spawn( diff --git a/apps/server/src/provider/Layers/CodexSessionRuntime.ts b/apps/server/src/provider/Layers/CodexSessionRuntime.ts index dd85a5c0d33..03957081ded 100644 --- a/apps/server/src/provider/Layers/CodexSessionRuntime.ts +++ b/apps/server/src/provider/Layers/CodexSessionRuntime.ts @@ -724,17 +724,18 @@ export const makeCodexSessionRuntime = ( ...options.environment, ...(resolvedHomePath ? { CODEX_HOME: resolvedHomePath } : {}), }; + const extendEnv = options.environment === undefined; const spawnCommand = yield* resolveSpawnCommand( options.binaryPath, ["app-server", ...(options.appServerArgs ?? [])], - { env }, + { env, extendEnv }, ); const child = yield* spawner .spawn( ChildProcess.make(spawnCommand.command, spawnCommand.args, { cwd: options.cwd, env, - extendEnv: options.environment === undefined, + extendEnv, forceKillAfter: CODEX_APP_SERVER_FORCE_KILL_AFTER, shell: spawnCommand.shell, }), diff --git a/apps/server/src/provider/acp/AcpSessionRuntime.ts b/apps/server/src/provider/acp/AcpSessionRuntime.ts index c167dc2cba6..b8097f10b75 100644 --- a/apps/server/src/provider/acp/AcpSessionRuntime.ts +++ b/apps/server/src/provider/acp/AcpSessionRuntime.ts @@ -13,7 +13,6 @@ import * as EffectAcpClient from "effect-acp/client"; import * as EffectAcpErrors from "effect-acp/errors"; import type * as EffectAcpSchema from "effect-acp/schema"; import type * as EffectAcpProtocol from "effect-acp/protocol"; -import { HostProcessEnvironment } from "@t3tools/shared/hostProcess"; import { resolveSpawnCommand } from "@t3tools/shared/shell"; import { @@ -203,10 +202,11 @@ const makeAcpSessionRuntime = ( ), ); - const hostEnvironment = yield* HostProcessEnvironment; - const spawnCommand = yield* resolveSpawnCommand(options.spawn.command, options.spawn.args, { - env: { ...hostEnvironment, ...options.spawn.env }, - }); + const spawnCommand = yield* resolveSpawnCommand( + options.spawn.command, + options.spawn.args, + options.spawn.env ? { env: options.spawn.env, extendEnv: true } : {}, + ); const child = yield* spawner .spawn( ChildProcess.make(spawnCommand.command, spawnCommand.args, { diff --git a/packages/shared/src/shell.test.ts b/packages/shared/src/shell.test.ts index 2b456f92962..e8b2c41cb77 100644 --- a/packages/shared/src/shell.test.ts +++ b/packages/shared/src/shell.test.ts @@ -1,6 +1,6 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { it as effectIt } from "@effect/vitest"; -import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import { HostProcessEnvironment, HostProcessPlatform } from "@t3tools/shared/hostProcess"; import * as Effect from "effect/Effect"; import { describe, expect, it, vi } from "vite-plus/test"; @@ -407,6 +407,32 @@ effectIt.layer(NodeServices.layer)("resolveSpawnCommand", (it) => { }), ); + it.effect("resolves against the effective environment when extending host env", () => + Effect.gen(function* () { + let resolvedEnvironment: NodeJS.ProcessEnv | undefined; + yield* resolveSpawnCommand("codex", ["app-server"], { + env: { CODEX_HOME: "C:\\Users\\tester\\.codex" }, + extendEnv: true, + }).pipe( + Effect.provideService(HostProcessPlatform, "win32"), + Effect.provideService(HostProcessEnvironment, { + PATH: "C:\\Users\\tester\\AppData\\Roaming\\npm", + PATHEXT: ".COM;.EXE;.BAT;.CMD", + }), + Effect.provideService(SpawnExecutableResolution, (_command, _platform, env) => { + resolvedEnvironment = env; + return "C:\\Users\\tester\\AppData\\Roaming\\npm\\codex.cmd"; + }), + ); + + expect(resolvedEnvironment).toEqual({ + PATH: "C:\\Users\\tester\\AppData\\Roaming\\npm", + PATHEXT: ".COM;.EXE;.BAT;.CMD", + CODEX_HOME: "C:\\Users\\tester\\.codex", + }); + }), + ); + it.effect("does not fall back to a shell for unresolved Windows commands", () => Effect.gen(function* () { const command = yield* resolveSpawnCommand("missing & calc", ["unsafe & value"], { diff --git a/packages/shared/src/shell.ts b/packages/shared/src/shell.ts index 88bf41145b7..5eab78b83d5 100644 --- a/packages/shared/src/shell.ts +++ b/packages/shared/src/shell.ts @@ -35,6 +35,7 @@ function canExecuteFile(filePath: string): boolean { export interface CommandAvailabilityOptions { readonly env?: NodeJS.ProcessEnv; + readonly extendEnv?: boolean; } export type CommandAvailabilityChecker = ( @@ -576,7 +577,13 @@ export const resolveSpawnCommand = Effect.fn("shell.resolveSpawnCommand")(functi return { command, args: [...args], shell: false }; } - const env = options.env ?? (yield* HostProcessEnvironment); + const hostEnvironment = yield* HostProcessEnvironment; + const env = + options.env === undefined + ? hostEnvironment + : options.extendEnv + ? { ...hostEnvironment, ...options.env } + : options.env; const resolveExecutable = yield* SpawnExecutableResolution; const resolvedCommand = resolveExecutable(command, platform, env) ?? command; const extension = NodePath.win32.extname(resolvedCommand).toLowerCase(); From 361b3d4ada8242f22af9d8940f30ac6b7fc788be Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 14 Jun 2026 16:44:35 -0700 Subject: [PATCH 15/15] fix: resolve Windows VCS command shims Co-authored-by: codex --- apps/server/src/processRunner.test.ts | 44 ++++++++++++++++++++++++++- apps/server/src/processRunner.ts | 12 ++++++-- 2 files changed, 53 insertions(+), 3 deletions(-) diff --git a/apps/server/src/processRunner.test.ts b/apps/server/src/processRunner.test.ts index 29983555a54..f914c667a1c 100644 --- a/apps/server/src/processRunner.test.ts +++ b/apps/server/src/processRunner.test.ts @@ -8,7 +8,8 @@ import * as Sink from "effect/Sink"; import * as Stream from "effect/Stream"; import { TestClock } from "effect/testing"; import { ChildProcessSpawner } from "effect/unstable/process"; -import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import { HostProcessEnvironment, HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import { SpawnExecutableResolution } from "@t3tools/shared/shell"; import { isWindowsCommandNotFound, @@ -22,6 +23,9 @@ import { type ChildProcessCommand = { readonly command: string; readonly args: ReadonlyArray; + readonly options: { + readonly shell?: boolean | string; + }; }; // Accesses private properties of ChildProcessCommand for testing purposes @@ -123,6 +127,44 @@ describe("runProcess", () => { }).pipe(Effect.provide(layer)); }); + it.effect("resolves and escapes Windows command shims before spawning", () => { + const spawner = makeSpawner((command) => + Effect.sync(() => { + expect(command.command).toBe('^"C:\\Users\\tester\\AppData\\Roaming\\npm\\az.cmd^"'); + expect(command.args).toEqual([ + '^"repos^"', + '^"pr^"', + '^"list^"', + '^"--source-branch^"', + '^"feature^ ^&^ release^"', + ]); + expect(command.options.shell).toBe(true); + return makeHandle({ stdout: "[]" }); + }), + ); + + return runWith(spawner)({ + command: "az", + args: ["repos", "pr", "list", "--source-branch", "feature & release"], + env: { AZURE_CONFIG_DIR: "C:\\Users\\tester\\.azure" }, + }).pipe( + Effect.provideService(HostProcessPlatform, "win32"), + Effect.provideService(HostProcessEnvironment, { + PATH: "C:\\Users\\tester\\AppData\\Roaming\\npm", + PATHEXT: ".COM;.EXE;.BAT;.CMD", + }), + Effect.provideService(SpawnExecutableResolution, (_command, _platform, env) => + env.PATH === "C:\\Users\\tester\\AppData\\Roaming\\npm" && + env.AZURE_CONFIG_DIR === "C:\\Users\\tester\\.azure" + ? "C:\\Users\\tester\\AppData\\Roaming\\npm\\az.cmd" + : undefined, + ), + Effect.map((result) => { + expect(result.stdout).toBe("[]"); + }), + ); + }); + it.effect("fails when output exceeds max buffer in default mode", () => Effect.gen(function* () { const spawner = makeSpawner(() => Effect.succeed(makeHandle({ stdout: "x".repeat(2048) }))); diff --git a/apps/server/src/processRunner.ts b/apps/server/src/processRunner.ts index ec8b78f121e..4cfb764c557 100644 --- a/apps/server/src/processRunner.ts +++ b/apps/server/src/processRunner.ts @@ -9,6 +9,7 @@ import * as Scope from "effect/Scope"; import * as Stream from "effect/Stream"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import { resolveSpawnCommand } from "@t3tools/shared/shell"; import { collectUint8StreamText, type CollectedUint8StreamText, @@ -234,17 +235,24 @@ const runProcessCore = Effect.fn("processRunner.runProcessCore")(function* ( const maxOutputBytes = input.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES; const outputMode = input.outputMode ?? "error"; const truncatedMarker = input.truncatedMarker ?? ""; + const extendEnv = input.env !== undefined; + const spawnCommand = yield* resolveSpawnCommand( + input.command, + input.args, + input.env === undefined ? {} : { env: input.env, extendEnv }, + ); const child = yield* spawner .spawn( - ChildProcess.make(input.command, [...input.args], { + ChildProcess.make(spawnCommand.command, spawnCommand.args, { ...((input.spawnCwd ?? input.cwd) ? { cwd: input.spawnCwd ?? input.cwd } : {}), ...(input.env !== undefined ? { env: input.env, - extendEnv: true, + extendEnv, } : {}), + shell: spawnCommand.shell, }), ) .pipe(