From 9866118bdbfe4401aaf8fb1a5724bd4ea07a5952 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 4 Jun 2026 12:02:41 -0700 Subject: [PATCH 1/3] refactor(desktop): resolve build target from Effect context --- 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 badbf98905a..0f6d691f89e 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, @@ -519,11 +520,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. @@ -575,12 +577,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); }); @@ -590,12 +594,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, @@ -620,10 +626,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 4d793194c1d..34af80d3581 100644 --- a/apps/server/src/provider/Layers/CodexProvider.ts +++ b/apps/server/src/provider/Layers/CodexProvider.ts @@ -22,6 +22,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, @@ -255,6 +256,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". @@ -266,7 +268,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 } : {}), }, }), @@ -417,12 +419,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); @@ -448,7 +452,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 f9b9c6ab4fb..4e9a9ff9e84 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"; @@ -714,8 +715,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 @@ -724,7 +727,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 8652b2cfeaf..e7d8b510c82 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, @@ -197,12 +198,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 2ebf8481957..92741a8818a 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; @@ -239,8 +238,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 } : {}), @@ -269,6 +266,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 }, @@ -1114,14 +1120,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()); @@ -1136,15 +1146,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 cd490de1e3f..6eb6416d567 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"; @@ -310,10 +311,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"; } @@ -322,7 +320,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(); @@ -358,7 +356,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(); @@ -409,8 +407,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), @@ -926,8 +924,6 @@ interface TerminalManagerOptions { historyLineLimit?: number; ptyAdapter: PtyAdapterShape; shellResolver?: () => string; - platform?: NodeJS.Platform; - env?: NodeJS.ProcessEnv; subprocessInspector?: TerminalSubprocessInspector; subprocessPollIntervalMs?: number; processKillGraceMs?: number; @@ -952,8 +948,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 d42fb07aa03..dedc42c7508 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"; @@ -46,12 +47,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; @@ -205,11 +209,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 0f2a8f790bf..1a028667808 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -739,7 +739,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 ce5d8a41411..b0f9cb92adb 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -146,6 +146,10 @@ "./relayClient": { "types": "./src/relayClient.ts", "import": "./src/relayClient.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 686a725883e..eb24496a803 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -785,6 +785,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 7808f37868497926e627696ec14f8c91bb10fd71 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 4 Jun 2026 14:06:51 -0700 Subject: [PATCH 2/3] refactor(runtime): remove host env reference --- 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 0f6d691f89e..50f61a74e19 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, @@ -524,8 +524,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. @@ -579,9 +578,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", @@ -600,8 +598,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 34af80d3581..07a2560bbb0 100644 --- a/apps/server/src/provider/Layers/CodexProvider.ts +++ b/apps/server/src/provider/Layers/CodexProvider.ts @@ -22,7 +22,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, @@ -256,7 +255,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". @@ -268,7 +266,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 } : {}), }, }), @@ -425,8 +423,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 4e9a9ff9e84..b97f1731e76 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"; @@ -715,10 +715,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 @@ -726,6 +725,7 @@ export const makeCodexSessionRuntime = ( ChildProcess.make(options.binaryPath, ["app-server"], { 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 e7d8b510c82..d8a6e1fcd9d 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, @@ -198,13 +198,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 92741a8818a..c632fafee9b 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"; @@ -266,14 +267,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))), @@ -1122,7 +1124,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", @@ -1152,7 +1154,7 @@ it.layer( shellResolver: () => "C:\\missing\\custom-shell.exe", }).pipe( Effect.provide( - withHostProcess({ + withHostRuntime({ platform: "win32", env: { ComSpec: "C:\\Windows\\System32\\cmd.exe", @@ -1181,46 +1183,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 6eb6416d567..0a4248dc92d 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"; @@ -61,6 +62,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", { @@ -517,6 +557,7 @@ function windowsInspectSubprocess( timeout: "1500 millis", maxOutputBytes: 32_768, outputMode: "truncate", + shell: true, timeoutBehavior: "timedOutResult", }); }).pipe( @@ -949,7 +990,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 dedc42c7508..94dae5dd477 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"; @@ -53,9 +53,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 5189bb4dc80..ee5cb7f1a6a 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"; export default definePlugin({ @@ -7,6 +8,7 @@ export default definePlugin({ name: "t3code", }, rules: { + "no-global-process-runtime": noGlobalProcessRuntime, "no-inline-schema-compile": noInlineSchemaCompile, }, }); 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 2ccc1fb948f..c2ee85a636b 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -90,6 +90,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", }, options: { From 646023af6cd1579367d5ec9448419c9ee8e6fad3 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 4 Jun 2026 14:38:42 -0700 Subject: [PATCH 3/3] refactor(runtime): enforce injected host platform reads --- 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 | 8 +- 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 | 18 +- 43 files changed, 1118 insertions(+), 824 deletions(-) diff --git a/apps/desktop/scripts/dev-electron.mjs b/apps/desktop/scripts/dev-electron.mjs index 2a2e52449be..310bdde3fa4 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 { desktopDir, resolveDevProtocolClient, resolveElectronPath } from "./electron-launcher.mjs"; @@ -29,6 +30,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, @@ -53,7 +56,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; } @@ -61,7 +64,7 @@ function killChildTreeByPid(pid, signal) { } function cleanupStaleDevApps() { - if (process.platform === "win32") { + if (hostPlatform === "win32") { return; } @@ -189,7 +192,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 8f20001bbb0..890a1e10f41 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 = 10; 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); @@ -313,7 +316,7 @@ export function resolveElectronPath() { const require = createRequire(import.meta.url); const electronBinaryPath = require("electron"); - if (process.platform !== "darwin") { + if (hostPlatform !== "darwin") { return electronBinaryPath; } @@ -321,7 +324,7 @@ export function resolveElectronPath() { } 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 66b809b2ac7..3c579679452 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; @@ -73,109 +74,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 9356eef441b..a0b76f3ccc4 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" }; @@ -51,11 +52,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 35145cc1d53..2c0a9756366 100644 --- a/apps/desktop/src/window/DesktopWindow.ts +++ b/apps/desktop/src/window/DesktopWindow.ts @@ -79,9 +79,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 }), @@ -103,8 +104,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 }, @@ -124,6 +128,7 @@ function getWindowTitleBarOptions(shouldUseDarkColors: boolean): WindowTitleBarO function syncWindowAppearance( window: Electron.BrowserWindow, shouldUseDarkColors: boolean, + platform: NodeJS.Platform, ): Effect.Effect { return Effect.sync(() => { if (window.isDestroyed()) { @@ -131,7 +136,7 @@ function syncWindowAppearance( } window.setBackgroundColor(getInitialWindowBackgroundColor(shouldUseDarkColors)); - const { titleBarOverlay } = getWindowTitleBarOptions(shouldUseDarkColors); + const { titleBarOverlay } = getWindowTitleBarOptions(shouldUseDarkColors, platform); if (typeof titleBarOverlay === "object") { window.setTitleBarOverlay(titleBarOverlay); } @@ -174,7 +179,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, @@ -186,7 +191,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, @@ -297,7 +302,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, () => { @@ -387,7 +392,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 c40903e1b45..aa691a5df01 100644 --- a/apps/server/src/provider/providerSnapshot.ts +++ b/apps/server/src/provider/providerSnapshot.ts @@ -74,7 +74,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 98bef90bb2e..6dc869cc3ac 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -337,7 +337,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 c632fafee9b..ebd7bee89a9 100644 --- a/apps/server/src/terminal/Layers/Manager.test.ts +++ b/apps/server/src/terminal/Layers/Manager.test.ts @@ -281,8 +281,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(); @@ -422,8 +420,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(); @@ -1084,10 +1084,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, }); @@ -1098,10 +1097,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) => @@ -1229,7 +1228,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 84ea5c51937..0b37fefad6a 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 d52d4f14663..ba808c75e60 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 77b5a163559..82615d250f4 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 7d409fce773..bc7d71993ac 100644 --- a/packages/effect-codex-app-server/src/client.ts +++ b/packages/effect-codex-app-server/src/client.ts @@ -5,6 +5,7 @@ import * as Schema from "effect/Schema"; import * as Scope from "effect/Scope"; import * as Stdio from "effect/Stdio"; 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"; @@ -277,11 +278,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 51668b5e248..a8796d15697 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,11 +36,13 @@ 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(); 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 eb24496a803..130677dc3f5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -692,6 +692,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 @@ -701,6 +704,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 c2ee85a636b..1a53bb19437 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,7 +1,19 @@ +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, + }, + ], + }, test: { environment: "node", exclude: [ @@ -10,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: [