diff --git a/apps/desktop/scripts/dev-electron.mjs b/apps/desktop/scripts/dev-electron.mjs index 6c8b94188a2..58ccfe90eb9 100644 --- a/apps/desktop/scripts/dev-electron.mjs +++ b/apps/desktop/scripts/dev-electron.mjs @@ -1,5 +1,6 @@ import { spawn, spawnSync } from "node:child_process"; import { watch } from "node:fs"; +import * as NodeOS from "node:os"; import { join } from "node:path"; import { @@ -33,6 +34,8 @@ const forcedShutdownTimeoutMs = 1_500; const restartDebounceMs = 120; const childTreeGracePeriodMs = 1_200; const remoteDebuggingPort = process.env.T3CODE_DESKTOP_REMOTE_DEBUGGING_PORT?.trim(); +// oxlint-disable-next-line t3code/no-global-process-runtime -- Standalone dev script has no Effect runtime. +const hostPlatform = NodeOS.platform(); await waitForResources({ baseDir: desktopDir, @@ -57,7 +60,7 @@ const expectedExits = new WeakSet(); const watchers = []; function killChildTreeByPid(pid, signal) { - if (process.platform === "win32" || typeof pid !== "number") { + if (hostPlatform === "win32" || typeof pid !== "number") { return; } @@ -65,7 +68,7 @@ function killChildTreeByPid(pid, signal) { } function cleanupStaleDevApps() { - if (process.platform === "win32") { + if (hostPlatform === "win32") { return; } @@ -194,7 +197,7 @@ function startWatchers() { } function killChildTree(signal) { - if (process.platform === "win32") { + if (hostPlatform === "win32") { return; } diff --git a/apps/desktop/scripts/electron-launcher.mjs b/apps/desktop/scripts/electron-launcher.mjs index bc1fabae960..52b6dd5cc6e 100644 --- a/apps/desktop/scripts/electron-launcher.mjs +++ b/apps/desktop/scripts/electron-launcher.mjs @@ -14,6 +14,7 @@ import { writeFileSync, } from "node:fs"; import { createRequire } from "node:module"; +import * as NodeOS from "node:os"; import { basename, dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { ensureElectronRuntime } from "./ensure-electron-runtime.mjs"; @@ -33,6 +34,8 @@ const APP_PROTOCOL_SCHEMES = isDevelopment ? ["t3code-dev"] : ["t3code"]; const LAUNCHER_VERSION = 11; const defaultIconPath = join(desktopDir, "resources", "icon.icns"); const developmentMacIconPngPath = join(repoRoot, "assets", "dev", "blueprint-macos-1024.png"); +// oxlint-disable-next-line t3code/no-global-process-runtime -- Standalone launcher script has no Effect runtime. +const hostPlatform = NodeOS.platform(); function resolveDevelopmentProtocolCallbackPort() { const configuredPort = Number.parseInt(process.env.T3CODE_PORT ?? "", 10); @@ -312,7 +315,7 @@ function buildMacLauncher(electronBinaryPath) { } function isLinuxSetuidSandboxConfigured(electronBinaryPath) { - if (process.platform !== "linux") { + if (hostPlatform !== "linux") { return true; } @@ -342,7 +345,7 @@ export function resolveElectronPath() { const require = createRequire(import.meta.url); const electronBinaryPath = require("electron"); - if (process.platform !== "darwin") { + if (hostPlatform !== "darwin") { return electronBinaryPath; } @@ -358,7 +361,7 @@ export function resolveElectronLaunchCommand(args = []) { } export function resolveDevProtocolClient() { - if (process.platform !== "darwin" || !isDevelopment) { + if (hostPlatform !== "darwin" || !isDevelopment) { return null; } diff --git a/apps/desktop/scripts/ensure-electron-runtime.mjs b/apps/desktop/scripts/ensure-electron-runtime.mjs index 2df47d3c62b..0a13506d341 100644 --- a/apps/desktop/scripts/ensure-electron-runtime.mjs +++ b/apps/desktop/scripts/ensure-electron-runtime.mjs @@ -1,13 +1,17 @@ import { chmodSync, existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { createRequire } from "node:module"; -import { tmpdir } from "node:os"; +import { arch, platform, tmpdir } from "node:os"; import { dirname, join } from "node:path"; import { spawnSync } from "node:child_process"; const require = createRequire(import.meta.url); +// oxlint-disable-next-line t3code/no-global-process-runtime -- Standalone repair script has no Effect runtime. +const hostPlatform = platform(); +// oxlint-disable-next-line t3code/no-global-process-runtime -- Standalone repair script has no Effect runtime. +const hostArch = arch(); function getPlatformPath() { - switch (process.platform) { + switch (hostPlatform) { case "darwin": return "Electron.app/Contents/MacOS/Electron"; case "freebsd": @@ -17,12 +21,12 @@ function getPlatformPath() { case "win32": return "electron.exe"; default: - throw new Error(`Electron builds are not available on platform: ${process.platform}`); + throw new Error(`Electron builds are not available on platform: ${hostPlatform}`); } } function ensureExecutable(filePath) { - if (process.platform !== "win32") { + if (hostPlatform !== "win32") { chmodSync(filePath, 0o755); } } @@ -39,7 +43,7 @@ function repairPathFile(electronDir, platformPath) { function getRequiredRuntimePaths(electronDir, platformPath) { const paths = [join(electronDir, "dist", platformPath)]; - if (process.platform === "darwin") { + if (hostPlatform === "darwin") { paths.push( join(electronDir, "dist", "Electron.app", "Contents", "Info.plist"), join( @@ -58,7 +62,7 @@ function getRequiredRuntimePaths(electronDir, platformPath) { } function isMachO(filePath) { - if (process.platform !== "darwin") { + if (hostPlatform !== "darwin") { return true; } @@ -76,7 +80,7 @@ function missingRuntimePaths(electronDir, platformPath) { } function invalidRuntimePaths(electronDir, platformPath) { - if (process.platform !== "darwin") { + if (hostPlatform !== "darwin") { return []; } @@ -111,16 +115,16 @@ function runChecked(command, args) { function installElectronRuntime(electronDir, version) { const tempDir = mkdtempSync(join(tmpdir(), "t3-electron-")); - const zipPath = join(tempDir, `electron-v${version}-${process.platform}-${process.arch}.zip`); + const zipPath = join(tempDir, `electron-v${version}-${hostPlatform}-${hostArch}.zip`); try { runChecked("curl", [ "-fsSL", - `https://github.com/electron/electron/releases/download/v${version}/electron-v${version}-${process.platform}-${process.arch}.zip`, + `https://github.com/electron/electron/releases/download/v${version}/electron-v${version}-${hostPlatform}-${hostArch}.zip`, "-o", zipPath, ]); - if (process.platform === "darwin") { + if (hostPlatform === "darwin") { runChecked("ditto", ["-x", "-k", zipPath, join(electronDir, "dist")]); } else { runChecked("python3", [ diff --git a/apps/desktop/src/app/DesktopAssets.ts b/apps/desktop/src/app/DesktopAssets.ts index a9c1d62e685..3b5a15e435f 100644 --- a/apps/desktop/src/app/DesktopAssets.ts +++ b/apps/desktop/src/app/DesktopAssets.ts @@ -49,7 +49,7 @@ const resolveIconPath = Effect.fn("desktop.assets.resolveIconPath")(function* ( > { const fileSystem = yield* FileSystem.FileSystem; const environment = yield* DesktopEnvironment.DesktopEnvironment; - if (environment.isDevelopment && process.platform === "darwin" && ext === "png") { + if (environment.isDevelopment && environment.platform === "darwin" && ext === "png") { const developmentDockIconPath = environment.developmentDockIconPath; const developmentDockIconExists = yield* fileSystem .exists(developmentDockIconPath) diff --git a/apps/desktop/src/electron/ElectronMenu.ts b/apps/desktop/src/electron/ElectronMenu.ts index 005c86e0868..cb25043ff44 100644 --- a/apps/desktop/src/electron/ElectronMenu.ts +++ b/apps/desktop/src/electron/ElectronMenu.ts @@ -5,6 +5,7 @@ import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Electron from "electron"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; export interface ElectronMenuPosition { readonly x: number; @@ -79,109 +80,113 @@ const normalizePosition = ( ({ x, y }) => Number.isFinite(x) && Number.isFinite(y) && x >= 0 && y >= 0, ).pipe(Option.map(({ x, y }) => ({ x: Math.floor(x), y: Math.floor(y) }))); -export const layer = Layer.sync(ElectronMenu, () => { - let destructiveMenuIconCache: Option.Option | undefined; +export const layer = Layer.effect( + ElectronMenu, + Effect.gen(function* () { + const platform = yield* HostProcessPlatform; + let destructiveMenuIconCache: Option.Option | undefined; - const getDestructiveMenuIcon = (): Option.Option => { - if (process.platform !== "darwin") { - return Option.none(); - } - if (destructiveMenuIconCache !== undefined) { - return destructiveMenuIconCache; - } - - try { - const icon = Electron.nativeImage.createFromNamedImage("trash").resize({ - width: 12, - height: 12, - }); - destructiveMenuIconCache = icon.isEmpty() ? Option.none() : Option.some(icon); - } catch { - destructiveMenuIconCache = Option.none(); - } - - return destructiveMenuIconCache; - }; - - const buildTemplate = ( - entries: readonly ContextMenuItem[], - complete: (selectedItemId: Option.Option) => void, - ): Electron.MenuItemConstructorOptions[] => { - const template: Electron.MenuItemConstructorOptions[] = []; - let hasInsertedDestructiveSeparator = false; - - for (const item of entries) { - if (item.destructive && !hasInsertedDestructiveSeparator && template.length > 0) { - template.push({ type: "separator" }); - hasInsertedDestructiveSeparator = true; + const getDestructiveMenuIcon = (): Option.Option => { + if (platform !== "darwin") { + return Option.none(); } - - const itemOption: Electron.MenuItemConstructorOptions = { - label: item.label, - enabled: !item.disabled, - }; - if (item.children && item.children.length > 0) { - itemOption.submenu = buildTemplate(item.children, complete); - } else { - itemOption.click = () => complete(Option.some(item.id)); + if (destructiveMenuIconCache !== undefined) { + return destructiveMenuIconCache; } - if (item.destructive && (!item.children || item.children.length === 0)) { - const destructiveIcon = getDestructiveMenuIcon(); - if (Option.isSome(destructiveIcon)) { - itemOption.icon = destructiveIcon.value; - } + + try { + const icon = Electron.nativeImage.createFromNamedImage("trash").resize({ + width: 12, + height: 12, + }); + destructiveMenuIconCache = icon.isEmpty() ? Option.none() : Option.some(icon); + } catch { + destructiveMenuIconCache = Option.none(); } - template.push(itemOption); - } + return destructiveMenuIconCache; + }; - return template; - }; - - return ElectronMenu.of({ - setApplicationMenu: (template) => - Effect.sync(() => { - Electron.Menu.setApplicationMenu(Electron.Menu.buildFromTemplate([...template])); - }), - popupTemplate: (input) => - Effect.sync(() => { - if (input.template.length === 0) { - return; + const buildTemplate = ( + entries: readonly ContextMenuItem[], + complete: (selectedItemId: Option.Option) => void, + ): Electron.MenuItemConstructorOptions[] => { + const template: Electron.MenuItemConstructorOptions[] = []; + let hasInsertedDestructiveSeparator = false; + + for (const item of entries) { + if (item.destructive && !hasInsertedDestructiveSeparator && template.length > 0) { + template.push({ type: "separator" }); + hasInsertedDestructiveSeparator = true; } - Electron.Menu.buildFromTemplate([...input.template]).popup({ window: input.window }); - }), - showContextMenu: (input) => - Effect.callback>((resume) => { - const normalizedItems = normalizeContextMenuItems(input.items); - if (normalizedItems.length === 0) { - resume(Effect.succeed(Option.none())); - return; + + const itemOption: Electron.MenuItemConstructorOptions = { + label: item.label, + enabled: !item.disabled, + }; + if (item.children && item.children.length > 0) { + itemOption.submenu = buildTemplate(item.children, complete); + } else { + itemOption.click = () => complete(Option.some(item.id)); } + if (item.destructive && (!item.children || item.children.length === 0)) { + const destructiveIcon = getDestructiveMenuIcon(); + if (Option.isSome(destructiveIcon)) { + itemOption.icon = destructiveIcon.value; + } + } + + template.push(itemOption); + } - let completed = false; - const complete = (selectedItemId: Option.Option) => { - if (completed) { + return template; + }; + + return ElectronMenu.of({ + setApplicationMenu: (template) => + Effect.sync(() => { + Electron.Menu.setApplicationMenu(Electron.Menu.buildFromTemplate([...template])); + }), + popupTemplate: (input) => + Effect.sync(() => { + if (input.template.length === 0) { + return; + } + Electron.Menu.buildFromTemplate([...input.template]).popup({ window: input.window }); + }), + showContextMenu: (input) => + Effect.callback>((resume) => { + const normalizedItems = normalizeContextMenuItems(input.items); + if (normalizedItems.length === 0) { + resume(Effect.succeed(Option.none())); return; } - completed = true; - resume(Effect.succeed(selectedItemId)); - }; - const menu = Electron.Menu.buildFromTemplate(buildTemplate(normalizedItems, complete)); - const popupPosition = normalizePosition(input.position); - const popupOptions = Option.match(popupPosition, { - onNone: (): Electron.PopupOptions => ({ - window: input.window, - callback: () => complete(Option.none()), - }), - onSome: (position): Electron.PopupOptions => ({ - window: input.window, - x: position.x, - y: position.y, - callback: () => complete(Option.none()), - }), - }); - menu.popup(popupOptions); - }), - }); -}); + let completed = false; + const complete = (selectedItemId: Option.Option) => { + if (completed) { + return; + } + completed = true; + resume(Effect.succeed(selectedItemId)); + }; + + const menu = Electron.Menu.buildFromTemplate(buildTemplate(normalizedItems, complete)); + const popupPosition = normalizePosition(input.position); + const popupOptions = Option.match(popupPosition, { + onNone: (): Electron.PopupOptions => ({ + window: input.window, + callback: () => complete(Option.none()), + }), + onSome: (position): Electron.PopupOptions => ({ + window: input.window, + x: position.x, + y: position.y, + callback: () => complete(Option.none()), + }), + }); + menu.popup(popupOptions); + }), + }); + }), +); diff --git a/apps/desktop/src/electron/ElectronWindow.ts b/apps/desktop/src/electron/ElectronWindow.ts index d41a8326e63..35c1fbc5faa 100644 --- a/apps/desktop/src/electron/ElectronWindow.ts +++ b/apps/desktop/src/electron/ElectronWindow.ts @@ -6,6 +6,7 @@ import * as Option from "effect/Option"; import * as Ref from "effect/Ref"; import * as Electron from "electron"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; export class ElectronWindowCreateError extends Data.TaggedError("ElectronWindowCreateError")<{ readonly cause: unknown; @@ -37,6 +38,7 @@ export class ElectronWindow extends Context.Service>(Option.none()); const liveMain = Ref.get(mainWindowRef).pipe( @@ -98,7 +100,7 @@ const make = Effect.gen(function* () { window.show(); } - if (process.platform === "darwin") { + if (platform === "darwin") { Electron.app.focus({ steal: true }); } diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index c7a16a5c7f5..3ed0b9b5cf0 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -9,6 +9,7 @@ import * as Option from "effect/Option"; import * as Electron from "electron"; import * as NetService from "@t3tools/shared/Net"; +import { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/hostProcess"; import { resolveRemoteT3CliPackageSpec } from "@t3tools/ssh/command"; import type { RemoteT3RunnerOptions } from "@t3tools/ssh/tunnel"; import serverPackageJson from "../../server/package.json" with { type: "json" }; @@ -53,11 +54,13 @@ const desktopEnvironmentLayer = Layer.unwrap( const metadata = yield* Effect.service(ElectronApp.ElectronApp).pipe( Effect.flatMap((app) => app.metadata), ); + const platform = yield* HostProcessPlatform; + const processArch = yield* HostProcessArchitecture; return DesktopEnvironment.layer({ dirname: __dirname, homeDirectory: NodeOS.homedir(), - platform: process.platform, - processArch: process.arch, + platform, + processArch, ...metadata, }); }), diff --git a/apps/desktop/src/window/DesktopWindow.ts b/apps/desktop/src/window/DesktopWindow.ts index f24485fd879..6f045eb80b6 100644 --- a/apps/desktop/src/window/DesktopWindow.ts +++ b/apps/desktop/src/window/DesktopWindow.ts @@ -82,9 +82,10 @@ function resolveDesktopDevServerUrl( function getIconOption( iconPaths: DesktopAssets.DesktopIconPaths, + platform: NodeJS.Platform, ): { icon: string } | Record { - if (process.platform === "darwin") return {}; // macOS uses .icns from app bundle - const ext = process.platform === "win32" ? "ico" : "png"; + if (platform === "darwin") return {}; // macOS uses .icns from app bundle + const ext = platform === "win32" ? "ico" : "png"; return Option.match(iconPaths[ext], { onNone: () => ({}), onSome: (icon) => ({ icon }), @@ -106,8 +107,11 @@ export function isSameOriginRendererNavigation(input: { } } -function getWindowTitleBarOptions(shouldUseDarkColors: boolean): WindowTitleBarOptions { - if (process.platform === "darwin") { +function getWindowTitleBarOptions( + shouldUseDarkColors: boolean, + platform: NodeJS.Platform, +): WindowTitleBarOptions { + if (platform === "darwin") { return { titleBarStyle: "hiddenInset", trafficLightPosition: { x: 16, y: 18 }, @@ -127,6 +131,7 @@ function getWindowTitleBarOptions(shouldUseDarkColors: boolean): WindowTitleBarO function syncWindowAppearance( window: Electron.BrowserWindow, shouldUseDarkColors: boolean, + platform: NodeJS.Platform, ): Effect.Effect { return Effect.sync(() => { if (window.isDestroyed()) { @@ -134,7 +139,7 @@ function syncWindowAppearance( } window.setBackgroundColor(getInitialWindowBackgroundColor(shouldUseDarkColors)); - const { titleBarOverlay } = getWindowTitleBarOptions(shouldUseDarkColors); + const { titleBarOverlay } = getWindowTitleBarOptions(shouldUseDarkColors, platform); if (typeof titleBarOverlay === "object") { window.setTitleBarOverlay(titleBarOverlay); } @@ -179,7 +184,7 @@ const make = Effect.gen(function* () { ? yield* resolveDesktopDevServerUrl(environment) : backendHttpUrl.href; const iconPaths = yield* assets.iconPaths; - const iconOption = getIconOption(iconPaths); + const iconOption = getIconOption(iconPaths, environment.platform); const shouldUseDarkColors = yield* electronTheme.shouldUseDarkColors; const window = yield* electronWindow.create({ width: 1100, @@ -191,7 +196,7 @@ const make = Effect.gen(function* () { backgroundColor: getInitialWindowBackgroundColor(shouldUseDarkColors), ...iconOption, title: environment.displayName, - ...getWindowTitleBarOptions(shouldUseDarkColors), + ...getWindowTitleBarOptions(shouldUseDarkColors, environment.platform), webPreferences: { preload: environment.preloadPath, contextIsolation: true, @@ -318,7 +323,7 @@ const make = Effect.gen(function* () { }); const revealSubscribers: RevealSubscription[] = [(fire) => window.once("ready-to-show", fire)]; - if (process.platform === "linux") { + if (environment.platform === "linux") { revealSubscribers.push((fire) => window.webContents.once("did-finish-load", fire)); } bindFirstRevealTrigger(revealSubscribers, () => { @@ -408,7 +413,7 @@ const make = Effect.gen(function* () { syncAppearance: Effect.gen(function* () { const shouldUseDarkColors = yield* electronTheme.shouldUseDarkColors; yield* electronWindow.syncAllAppearance((window) => - syncWindowAppearance(window, shouldUseDarkColors), + syncWindowAppearance(window, shouldUseDarkColors, environment.platform), ); }).pipe(Effect.withSpan("desktop.window.syncAppearance")), }); diff --git a/apps/server/scripts/cli.ts b/apps/server/scripts/cli.ts index aced9266733..a158eaa068d 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 { resolveSpawnCommand } from "@t3tools/shared/shell"; import serverPackageJson from "../package.json" with { type: "json" }; interface PackageJson { @@ -175,6 +176,7 @@ const buildCmd = Command.make( cwd: serverDir, stdout: config.verbose ? "inherit" : "ignore", stderr: "inherit", + shell: false, }), ); @@ -290,15 +292,15 @@ const publishCmd = Command.make( () => Effect.gen(function* () { const args = createVpPmPublishArgs(config); + const spawnCommand = yield* resolveSpawnCommand("vp", ["pm", ...args]); yield* Effect.log(`[cli] Running: vp pm ${args.join(" ")}`); yield* runCommand( - ChildProcess.make("vp", ["pm", ...args], { + ChildProcess.make(spawnCommand.command, spawnCommand.args, { cwd: repoRoot, stdout: config.verbose ? "inherit" : "ignore", stderr: "inherit", - // Windows needs shell mode to resolve .cmd shims. - shell: process.platform === "win32", + shell: spawnCommand.shell, }), ); }), diff --git a/apps/server/scripts/cursor-acp-model-mismatch-probe.ts b/apps/server/scripts/cursor-acp-model-mismatch-probe.ts index 04c2321870e..31f2ef6f1f7 100644 --- a/apps/server/scripts/cursor-acp-model-mismatch-probe.ts +++ b/apps/server/scripts/cursor-acp-model-mismatch-probe.ts @@ -3,6 +3,8 @@ import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; import process from "node:process"; import readline from "node:readline"; import * as NodeTimers from "node:timers"; +import { resolveSpawnCommand } from "@t3tools/shared/shell"; +import * as Effect from "effect/Effect"; type JsonPrimitive = null | boolean | number | string; type JsonValue = JsonPrimitive | JsonValue[] | { [key: string]: JsonValue }; @@ -128,9 +130,10 @@ class JsonRpcChild { closed = false; constructor(bin: string, args: string[], cwd: string) { - this.child = spawn(bin, args, { + const spawnCommand = Effect.runSync(resolveSpawnCommand(bin, args)); + this.child = spawn(spawnCommand.command, spawnCommand.args, { cwd, - shell: process.platform === "win32", + shell: spawnCommand.shell, stdio: ["pipe", "pipe", "pipe"], env: process.env, }); diff --git a/apps/server/src/bootstrap.test.ts b/apps/server/src/bootstrap.test.ts index 422d880d7f1..a3bbcc66d34 100644 --- a/apps/server/src/bootstrap.test.ts +++ b/apps/server/src/bootstrap.test.ts @@ -3,7 +3,7 @@ import * as NFS from "node:fs"; import * as path from "node:path"; import { execFileSync, spawn } from "node:child_process"; import * as NodeServices from "@effect/platform-node/NodeServices"; -import { assert, it } from "@effect/vitest"; +import { it } from "@effect/vitest"; import * as FileSystem from "effect/FileSystem"; import * as Schema from "effect/Schema"; import * as Duration from "effect/Duration"; @@ -11,8 +11,9 @@ import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; import * as TestClock from "effect/testing/TestClock"; import { vi } from "vite-plus/test"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; -import { readBootstrapEnvelope, resolveFdPath } from "./bootstrap.ts"; +import { readBootstrapEnvelope } from "./bootstrap.ts"; import { assertNone, assertSome } from "@effect/vitest/utils"; const openSyncInterceptor = vi.hoisted(() => ({ failPath: null as string | null })); @@ -41,14 +42,6 @@ const TestEnvelopeSchema = Schema.Struct({ mode: Schema.String }); const encodeTestEnvelopeSchema = Schema.encodeEffect(Schema.fromJsonString(TestEnvelopeSchema)); it.layer(NodeServices.layer)("readBootstrapEnvelope", (it) => { - it.effect("uses platform-specific fd paths", () => - Effect.sync(() => { - assert.equal(resolveFdPath(3, "linux"), "/proc/self/fd/3"); - assert.equal(resolveFdPath(3, "darwin"), "/dev/fd/3"); - assert.equal(resolveFdPath(3, "win32"), undefined); - }), - ); - it.effect("reads a bootstrap envelope from a provided fd", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; @@ -89,7 +82,9 @@ it.layer(NodeServices.layer)("readBootstrapEnvelope", (it) => { openSyncInterceptor.failPath = `/proc/self/fd/${fd}`; try { - const payload = yield* readBootstrapEnvelope(TestEnvelopeSchema, fd, { timeoutMs: 100 }); + const payload = yield* readBootstrapEnvelope(TestEnvelopeSchema, fd, { + timeoutMs: 100, + }).pipe(Effect.provideService(HostProcessPlatform, "linux")); assertSome(payload, { mode: "desktop", }); diff --git a/apps/server/src/bootstrap.ts b/apps/server/src/bootstrap.ts index 9ad6328798d..83d1d337888 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); - 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 => { @@ -165,10 +169,7 @@ const isBootstrapFdPathDuplicationError = Predicate.compose( (_) => _.code === "ENXIO" || _.code === "EINVAL" || _.code === "EPERM", ); -export function resolveFdPath( - fd: number, - platform: NodeJS.Platform = process.platform, -): string | undefined { +function resolveFdPath(fd: number, platform: NodeJS.Platform): string | undefined { if (platform === "linux") { return `/proc/self/fd/${fd}`; } diff --git a/apps/server/src/cli/config.test.ts b/apps/server/src/cli/config.test.ts index 9e73773d5a5..d4d9d378557 100644 --- a/apps/server/src/cli/config.test.ts +++ b/apps/server/src/cli/config.test.ts @@ -1,4 +1,4 @@ -import NodeOS from "node:os"; +import * as NodeOS from "node:os"; import { assert, expect, it } from "@effect/vitest"; import * as ConfigProvider from "effect/ConfigProvider"; diff --git a/apps/server/src/diagnostics/ProcessDiagnostics.ts b/apps/server/src/diagnostics/ProcessDiagnostics.ts index ed81f021f4b..f5f746134f2 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,6 +278,9 @@ const runProcess = Effect.fn("runProcess")( readonly errorMessage: string; }) { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + // `ps` and `powershell.exe` are real executables; spawning through cmd.exe + // shell mode would re-tokenize the PowerShell `-Command` payload (which + // contains pipes) before PowerShell ever sees it. const child = yield* spawner.spawn( ChildProcess.make(input.command, input.args, { cwd: process.cwd(), @@ -369,8 +373,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 +393,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 +413,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..3a4dce1627c 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 { HostProcessHostname, HostProcessPlatform } from "@t3tools/shared/hostProcess"; import { vi } from "vite-plus/test"; import { ProcessRunner, ProcessSpawnError, type ProcessRunnerShape } from "../../processRunner.ts"; @@ -28,6 +29,16 @@ const LinuxMachineInfoLayer = Layer.merge( : Effect.succeed(""), }), ); +const withHostPlatform = ( + layer: Layer.Layer, + platform: NodeJS.Platform, + hostname: string, +) => + Layer.mergeAll( + layer, + Layer.succeed(HostProcessPlatform, platform), + Layer.succeed(HostProcessHostname, hostname), + ); afterEach(() => { runMock.mockReset(); @@ -38,9 +49,7 @@ 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", "macbook-pro"))); expect(result).toBe("macbook-pro"); }), @@ -61,9 +70,7 @@ describe("resolveServerEnvironmentLabel", () => { const result = yield* resolveServerEnvironmentLabel({ cwdBaseName: "t3code", - platform: "darwin", - hostname: "macbook-pro", - }).pipe(Effect.provide(TestLayer)); + }).pipe(Effect.provide(withHostPlatform(TestLayer, "darwin", "macbook-pro"))); expect(result).toBe("Julius's MacBook Pro"); expect(runMock).toHaveBeenCalledWith( @@ -80,9 +87,7 @@ 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", "buildbox"))); expect(result).toBe("Build Agent 01"); expect(runMock).not.toHaveBeenCalled(); @@ -104,9 +109,7 @@ describe("resolveServerEnvironmentLabel", () => { const result = yield* resolveServerEnvironmentLabel({ cwdBaseName: "t3code", - platform: "linux", - hostname: "runner-01", - }).pipe(Effect.provide(TestLayer)); + }).pipe(Effect.provide(withHostPlatform(TestLayer, "linux", "runner-01"))); expect(result).toBe("CI Runner"); expect(runMock).toHaveBeenCalledWith( @@ -123,9 +126,7 @@ 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", "JULIUS-LAPTOP"))); expect(result).toBe("JULIUS-LAPTOP"); }), @@ -145,9 +146,7 @@ describe("resolveServerEnvironmentLabel", () => { const result = yield* resolveServerEnvironmentLabel({ cwdBaseName: "t3code", - platform: "darwin", - hostname: "macbook-pro", - }).pipe(Effect.provide(TestLayer)); + }).pipe(Effect.provide(withHostPlatform(TestLayer, "darwin", "macbook-pro"))); expect(result).toBe("macbook-pro"); }), @@ -168,9 +167,7 @@ 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..73a3b9526c4 100644 --- a/apps/server/src/environment/Layers/ServerEnvironmentLabel.ts +++ b/apps/server/src/environment/Layers/ServerEnvironmentLabel.ts @@ -1,5 +1,4 @@ -import * as OS from "node:os"; - +import { HostProcessHostname, HostProcessPlatform } from "@t3tools/shared/hostProcess"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Option from "effect/Option"; @@ -8,8 +7,6 @@ import { ProcessRunner } from "../../processRunner.ts"; interface ResolveServerEnvironmentLabelInput { readonly cwdBaseName: string; - readonly platform?: NodeJS.Platform; - readonly hostname?: string | null; } function normalizeLabel(value: string | null | undefined): string | null { @@ -69,9 +66,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,13 +90,12 @@ 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; } - const hostname = normalizeLabel(input.hostname ?? OS.hostname()); + const hostname = normalizeLabel(yield* HostProcessHostname); if (hostname) { return hostname; } diff --git a/apps/server/src/os-jank.ts b/apps/server/src/os-jank.ts index 93a40ae7e19..bc72758bc71 100644 --- a/apps/server/src/os-jank.ts +++ b/apps/server/src/os-jank.ts @@ -1,21 +1,15 @@ -import * as NodeOS from "node:os"; -import * as Effect from "effect/Effect"; -import * as Path from "effect/Path"; +import { HostProcessEnvironment, HostProcessPlatform } from "@t3tools/shared/hostProcess"; import { - readPathFromLoginShell, - readEnvironmentFromWindowsShell, - resolveWindowsEnvironment, - type CommandAvailabilityOptions, - type WindowsShellEnvironmentReader, listLoginShellCandidates, mergePathEntries, + readPathFromLoginShell, readPathFromLaunchctl, + resolveWindowsEnvironment, } from "@t3tools/shared/shell"; - -type WindowsCommandAvailabilityChecker = ( - command: string, - options?: CommandAvailabilityOptions, -) => boolean; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import * as NodeOS from "node:os"; function logPathHydrationWarning(message: string, error?: unknown): void { process.stderr.write( @@ -23,66 +17,60 @@ function logPathHydrationWarning(message: string, error?: unknown): void { ); } -export function fixPath( - options: { - env?: NodeJS.ProcessEnv; - platform?: NodeJS.Platform; - readPath?: typeof readPathFromLoginShell; - readWindowsEnvironment?: WindowsShellEnvironmentReader; - isWindowsCommandAvailable?: WindowsCommandAvailabilityChecker; - readLaunchctlPath?: typeof readPathFromLaunchctl; - userShell?: string; - logWarning?: (message: string, error?: unknown) => void; - } = {}, -): void { - const platform = options.platform ?? process.platform; - const env = options.env ?? process.env; - const logWarning = options.logWarning ?? logPathHydrationWarning; - const readPath = options.readPath ?? readPathFromLoginShell; - - try { - if (platform === "win32") { - const repairedEnvironment = resolveWindowsEnvironment(env, { - readEnvironment: options.readWindowsEnvironment ?? readEnvironmentFromWindowsShell, - ...(options.isWindowsCommandAvailable - ? { commandAvailable: options.isWindowsCommandAvailable } - : {}), - }); - for (const [key, value] of Object.entries(repairedEnvironment)) { - if (value !== undefined) { - env[key] = value; - } - } - return; +function hydratePosixPath(env: NodeJS.ProcessEnv, platform: NodeJS.Platform): void { + let shellPath: string | undefined; + for (const shell of listLoginShellCandidates(platform, env.SHELL)) { + try { + shellPath = readPathFromLoginShell(shell); + } catch (error) { + logPathHydrationWarning(`Failed to read PATH from login shell ${shell}.`, error); } - if (platform !== "darwin" && platform !== "linux") return; + if (shellPath) break; + } - let shellPath: string | undefined; - for (const shell of listLoginShellCandidates(platform, env.SHELL, options.userShell)) { - try { - shellPath = readPath(shell); - } catch (error) { - logWarning(`Failed to read PATH from login shell ${shell}.`, error); - } + const launchctlPath = platform === "darwin" && !shellPath ? readPathFromLaunchctl() : undefined; + const mergedPath = mergePathEntries(shellPath ?? launchctlPath, env.PATH, platform); + if (mergedPath) { + env.PATH = mergedPath; + } +} - if (shellPath) { - break; - } - } +export const fixPath = Effect.fn("fixPath")(function* (): Effect.fn.Return< + void, + never, + FileSystem.FileSystem | Path.Path +> { + const platform = yield* HostProcessPlatform; + const env = yield* HostProcessEnvironment; - const launchctlPath = - platform === "darwin" && !shellPath - ? (options.readLaunchctlPath ?? readPathFromLaunchctl)() - : undefined; - const mergedPath = mergePathEntries(shellPath ?? launchctlPath, env.PATH, platform); - if (mergedPath) { - env.PATH = mergedPath; + if (platform === "win32") { + const repairedEnvironment = yield* resolveWindowsEnvironment(env).pipe( + Effect.catchDefect((defect) => + Effect.sync(() => { + logPathHydrationWarning("Failed to hydrate PATH from the user environment.", defect); + return {} as Partial; + }), + ), + ); + for (const [key, value] of Object.entries(repairedEnvironment)) { + if (value !== undefined) { + env[key] = value; + } } - } catch (error) { - logWarning("Failed to hydrate PATH from the user environment.", error); + return; } -} + + if (platform !== "darwin" && platform !== "linux") return; + + yield* Effect.sync(() => hydratePosixPath(env, platform)).pipe( + Effect.catchDefect((defect) => + Effect.sync(() => { + logPathHydrationWarning("Failed to hydrate PATH from the user environment.", defect); + }), + ), + ); +}); export const expandHomePath = Effect.fn(function* (input: string) { const { join } = yield* Path.Path; diff --git a/apps/server/src/preview/PortScanner.test.ts b/apps/server/src/preview/PortScanner.test.ts index 8b37e86d8a9..481d28d782f 100644 --- a/apps/server/src/preview/PortScanner.test.ts +++ b/apps/server/src/preview/PortScanner.test.ts @@ -1,21 +1,20 @@ import * as net from "node:net"; import { it as effectIt } from "@effect/vitest"; -import { ThreadId } from "@t3tools/contracts"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import * as Net from "@t3tools/shared/Net"; import { Effect, Layer } from "effect"; -import { describe, expect, it } from "vite-plus/test"; +import { expect } from "vite-plus/test"; import { ProcessRunner } from "../processRunner.ts"; import * as PortScanner from "./PortScanner.ts"; - -const { parseLsofOutput, parsePortFromLsofName, parseWindowsListenerOutput, serversEqual } = - PortScanner.__testing; const TestProcessRunner = Layer.succeed(ProcessRunner, { run: () => Effect.die("ProcessRunner should not be used by Windows TCP probe tests"), }); const TestPortDiscoveryLive = PortScanner.layer.pipe( - Layer.provide(Layer.mergeAll(TestProcessRunner, Net.layer)), + Layer.provide( + Layer.mergeAll(TestProcessRunner, Net.layer, Layer.succeed(HostProcessPlatform, "win32")), + ), ); const openServer = (port: number): Effect.Effect => @@ -37,21 +36,6 @@ const closeServer = (server: net.Server): Effect.Effect => server.close(() => resume(Effect.void)); }); -const windowsPlatform = Effect.acquireRelease( - Effect.sync(() => { - const originalPlatform = process.platform; - Object.defineProperty(process, "platform", { value: "win32", configurable: true }); - return originalPlatform; - }), - (originalPlatform) => - Effect.sync(() => { - Object.defineProperty(process, "platform", { - value: originalPlatform, - configurable: true, - }); - }), -); - const openCommonDevServer = Effect.fn("PortScannerTest.openCommonDevServer")(function* ( ports: ReadonlyArray, ) { @@ -69,176 +53,15 @@ const commonDevServer = Effect.acquireRelease( ({ server }) => closeServer(server), ); -describe("parsePortFromLsofName", () => { - it("parses *:port", () => { - expect(parsePortFromLsofName("*:5173")).toBe(5173); - }); - - it("parses 127.0.0.1:port", () => { - expect(parsePortFromLsofName("127.0.0.1:5173")).toBe(5173); - }); - - it("parses localhost:port", () => { - expect(parsePortFromLsofName("localhost:5173")).toBe(5173); - }); - - it("parses [::1]:port", () => { - expect(parsePortFromLsofName("[::1]:5173")).toBe(5173); - }); - - it("ignores non-local hosts", () => { - expect(parsePortFromLsofName("192.168.1.10:5173")).toBeNull(); - }); - - it("strips trailing description", () => { - expect(parsePortFromLsofName("*:5173 (LISTEN)")).toBe(5173); - }); - - it("rejects garbage", () => { - expect(parsePortFromLsofName("")).toBeNull(); - expect(parsePortFromLsofName("not-a-port")).toBeNull(); - expect(parsePortFromLsofName("*:0")).toBeNull(); - expect(parsePortFromLsofName("*:99999")).toBeNull(); - }); -}); - -describe("parseLsofOutput", () => { - it("parses a typical lsof -F pcn output", () => { - const sample = [ - "p12345", - "cnode", - "n*:5173", - "p67890", - "cnext-server", - "n127.0.0.1:3000", - "n127.0.0.1:9229", // node debug port too — same process - "p13579", - "cChrome", - "n192.168.1.10:443", // not local — ignored - ].join("\n"); - - const servers = parseLsofOutput(sample); - expect(servers).toEqual([ - { - host: "localhost", - port: 3000, - url: "http://localhost:3000", - processName: "next-server", - pid: 67890, - terminal: null, - }, - { - host: "localhost", - port: 5173, - url: "http://localhost:5173", - processName: "node", - pid: 12345, - terminal: null, - }, - { - host: "localhost", - port: 9229, - url: "http://localhost:9229", - processName: "next-server", - pid: 67890, - terminal: null, - }, - ]); - }); - - it("handles empty input", () => { - expect(parseLsofOutput("")).toEqual([]); - }); - - it("dedupes by host:port", () => { - const sample = ["p1", "cnode", "n*:5173", "n127.0.0.1:5173"].join("\n"); - const servers = parseLsofOutput(sample); - expect(servers).toHaveLength(1); - expect(servers[0]?.port).toBe(5173); - }); - - it("attributes listeners to a registered terminal process", () => { - const servers = parseLsofOutput( - ["p12345", "cnode", "n*:5173"].join("\n"), - new Map([ - [ - 12345, - { - threadId: ThreadId.make("thread-1"), - terminalId: "terminal-1", - }, - ], - ]), - ); - - expect(servers[0]?.terminal).toEqual({ - threadId: "thread-1", - terminalId: "terminal-1", - }); - }); -}); - -describe("serversEqual", () => { - const a = { - host: "localhost", - port: 5173, - url: "http://localhost:5173", - processName: "node", - pid: 1, - terminal: null, - }; - it("returns true for identical lists", () => { - expect(serversEqual([a], [{ ...a }])).toBe(true); - }); - it("returns false for different lengths", () => { - expect(serversEqual([a], [])).toBe(false); - }); - it("returns false for different processName", () => { - expect(serversEqual([a], [{ ...a, processName: "other" }])).toBe(false); - }); -}); - -describe("parseWindowsListenerOutput", () => { - it("parses and attributes PowerShell listener records", () => { - const servers = parseWindowsListenerOutput( - "0.0.0.0|5173|12345|node", - new Map([ - [ - 12345, - { - threadId: ThreadId.make("thread-1"), - terminalId: "terminal-1", - }, - ], - ]), - ); - - expect(servers).toEqual([ - { - host: "localhost", - port: 5173, - url: "http://localhost:5173", - processName: "node", - pid: 12345, - terminal: { - threadId: "thread-1", - terminalId: "terminal-1", - }, - }, - ]); - }); -}); - /** - * Integration tests against a real TCP listener. We force the Windows code - * path (TCP-probe fallback) by monkey-patching `process.platform` for the - * duration of the test so we don't depend on `lsof` being installed. + * Integration tests against a real TCP listener. We provide the Windows host + * platform so the tests exercise the TCP-probe fallback without depending on + * `lsof` being installed. */ effectIt.layer(TestPortDiscoveryLive)("PortDiscovery integration (TCP probe fallback)", (it) => { it.effect( "scan() returns a server we just opened on a curated dev port", Effect.fn("PortScannerTest.scanFindsCommonDevServer")(function* () { - yield* windowsPlatform; const { port } = yield* commonDevServer; const scanner = yield* PortScanner.PortDiscovery; const result = yield* scanner.scan(); @@ -251,7 +74,6 @@ effectIt.layer(TestPortDiscoveryLive)("PortDiscovery integration (TCP probe fall it.effect( "retain drives an immediate broadcast to subscribers", Effect.fn("PortScannerTest.retainBroadcastsImmediately")(function* () { - yield* windowsPlatform; const { port } = yield* commonDevServer; const received: number[] = []; const scanner = yield* PortScanner.PortDiscovery; diff --git a/apps/server/src/preview/PortScanner.ts b/apps/server/src/preview/PortScanner.ts index c8d9a051ed6..183d5d4f009 100644 --- a/apps/server/src/preview/PortScanner.ts +++ b/apps/server/src/preview/PortScanner.ts @@ -12,6 +12,7 @@ * polls forever, but each tick is a no-op when the retain count is zero. */ import { ThreadId, type DiscoveredLocalServer } from "@t3tools/contracts"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import * as Net from "@t3tools/shared/Net"; import { LSOF_LOCAL_HOST_TOKENS } from "@t3tools/shared/preview"; import { Cause, Context, Duration, Effect, Layer, Ref, Schedule, Scope } from "effect"; @@ -182,6 +183,7 @@ const serversEqual = ( const make = Effect.gen(function* PortDiscoveryMake() { const net = yield* Net.NetService; const processRunner = yield* ProcessRunner; + const hostPlatform = yield* HostProcessPlatform; const stateRef = yield* Ref.make({ lastSnapshot: [], listeners: new Set(), @@ -221,7 +223,7 @@ const make = Effect.gen(function* PortDiscoveryMake() { terminalByProcessId.set(processId, registration.owner); } } - if (process.platform === "win32") { + if (hostPlatform === "win32") { const command = 'Get-NetTCPConnection -State Listen -ErrorAction Stop | ForEach-Object { $processName = (Get-Process -Id $_.OwningProcess -ErrorAction SilentlyContinue).ProcessName; Write-Output "$($_.LocalAddress)|$($_.LocalPort)|$($_.OwningProcess)|$processName" }'; const listeners = yield* processRunner @@ -359,11 +361,3 @@ const make = Effect.gen(function* PortDiscoveryMake() { }).pipe(Effect.withSpan("PortDiscovery.make")); export const layer = Layer.effect(PortDiscovery, make); - -/** Exposed for tests. */ -export const __testing = { - parseLsofOutput, - parsePortFromLsofName, - parseWindowsListenerOutput, - serversEqual, -}; diff --git a/apps/server/src/process/externalLauncher.test.ts b/apps/server/src/process/externalLauncher.test.ts index 75e76b5e8e2..0a157e301c4 100644 --- a/apps/server/src/process/externalLauncher.test.ts +++ b/apps/server/src/process/externalLauncher.test.ts @@ -1,9 +1,7 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, it } from "@effect/vitest"; -import { assertSuccess } from "@effect/vitest/utils"; -import * as Crypto from "effect/Crypto"; +import * as ConfigProvider from "effect/ConfigProvider"; import * as Effect from "effect/Effect"; -import * as Encoding from "effect/Encoding"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; @@ -11,24 +9,9 @@ import * as Sink from "effect/Sink"; import * as Stream from "effect/Stream"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; -import { - isCommandAvailable, - launchBrowser, - launchEditorProcess, - resolveAvailableEditors, - resolveBrowserLaunch, - resolveEditorLaunch, -} from "./externalLauncher.ts"; - -function encodeUtf16LeBase64(input: string): string { - const bytes = new Uint8Array(input.length * 2); - for (let index = 0; index < input.length; index += 1) { - const code = input.charCodeAt(index); - bytes[index * 2] = code & 0xff; - bytes[index * 2 + 1] = code >>> 8; - } - return Encoding.encodeBase64(bytes); -} +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import { SpawnExecutableResolution } from "@t3tools/shared/shell"; +import { ExternalLauncher, layer as ExternalLauncherLive } from "./externalLauncher.ts"; function makeMockDetachedHandle(onUnref: () => void = () => undefined) { return ChildProcessSpawner.makeHandle({ @@ -49,756 +32,135 @@ function makeMockDetachedHandle(onUnref: () => void = () => undefined) { }); } -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: "" }, - ); - assert.deepEqual(antigravityLaunch, { - command: "agy", - args: ["/tmp/workspace"], - }); - - const cursorLaunch = yield* resolveEditorLaunch( - { cwd: "/tmp/workspace", editor: "cursor" }, - "darwin", - { PATH: "" }, - ); - assert.deepEqual(cursorLaunch, { - command: "cursor", - args: ["/tmp/workspace"], - }); - - const traeLaunch = yield* resolveEditorLaunch( - { cwd: "/tmp/workspace", editor: "trae" }, - "darwin", - ); - assert.deepEqual(traeLaunch, { - command: "trae", - args: ["/tmp/workspace"], - }); - - const kiroLaunch = yield* resolveEditorLaunch( - { cwd: "/tmp/workspace", editor: "kiro" }, - "darwin", - { PATH: "" }, - ); - assert.deepEqual(kiroLaunch, { - command: "kiro", - args: ["ide", "/tmp/workspace"], - }); - - const vscodeLaunch = yield* resolveEditorLaunch( - { cwd: "/tmp/workspace", editor: "vscode" }, - "darwin", - { PATH: "" }, - ); - assert.deepEqual(vscodeLaunch, { - command: "code", - args: ["/tmp/workspace"], - }); - - const vscodeInsidersLaunch = yield* resolveEditorLaunch( - { cwd: "/tmp/workspace", editor: "vscode-insiders" }, - "darwin", - ); - assert.deepEqual(vscodeInsidersLaunch, { - command: "code-insiders", - args: ["/tmp/workspace"], - }); - - const vscodiumLaunch = yield* resolveEditorLaunch( - { cwd: "/tmp/workspace", editor: "vscodium" }, - "darwin", - ); - assert.deepEqual(vscodiumLaunch, { - command: "codium", - args: ["/tmp/workspace"], - }); - - const zedLaunch = yield* resolveEditorLaunch( - { cwd: "/tmp/workspace", editor: "zed" }, - "darwin", - { PATH: "" }, - ); - assert.deepEqual(zedLaunch, { - command: "zed", - args: ["/tmp/workspace"], - }); - - const ideaLaunch = yield* resolveEditorLaunch( - { cwd: "/tmp/workspace", editor: "idea" }, - "darwin", - ); - assert.deepEqual(ideaLaunch, { - command: "idea", - args: ["/tmp/workspace"], - }); - - const aquaLaunch = yield* resolveEditorLaunch( - { cwd: "/tmp/workspace", editor: "aqua" }, - "darwin", - ); - assert.deepEqual(aquaLaunch, { - command: "aqua", - args: ["/tmp/workspace"], - }); - - const clionLaunch = yield* resolveEditorLaunch( - { cwd: "/tmp/workspace", editor: "clion" }, - "darwin", - ); - assert.deepEqual(clionLaunch, { - command: "clion", - args: ["/tmp/workspace"], - }); - - const datagripLaunch = yield* resolveEditorLaunch( - { cwd: "/tmp/workspace", editor: "datagrip" }, - "darwin", - ); - assert.deepEqual(datagripLaunch, { - command: "datagrip", - args: ["/tmp/workspace"], - }); - - const dataspellLaunch = yield* resolveEditorLaunch( - { cwd: "/tmp/workspace", editor: "dataspell" }, - "darwin", - ); - assert.deepEqual(dataspellLaunch, { - command: "dataspell", - args: ["/tmp/workspace"], - }); - - const golandLaunch = yield* resolveEditorLaunch( - { cwd: "/tmp/workspace", editor: "goland" }, - "darwin", - ); - assert.deepEqual(golandLaunch, { - command: "goland", - args: ["/tmp/workspace"], - }); - - const phpstormLaunch = yield* resolveEditorLaunch( - { cwd: "/tmp/workspace", editor: "phpstorm" }, - "darwin", - ); - assert.deepEqual(phpstormLaunch, { - command: "phpstorm", - args: ["/tmp/workspace"], - }); - - const pycharmLaunch = yield* resolveEditorLaunch( - { cwd: "/tmp/workspace", editor: "pycharm" }, - "darwin", - ); - assert.deepEqual(pycharmLaunch, { - command: "pycharm", - args: ["/tmp/workspace"], - }); - - const riderLaunch = yield* resolveEditorLaunch( - { cwd: "/tmp/workspace", editor: "rider" }, - "darwin", - ); - assert.deepEqual(riderLaunch, { - command: "rider", - args: ["/tmp/workspace"], - }); - - const rubymineLaunch = yield* resolveEditorLaunch( - { cwd: "/tmp/workspace", editor: "rubymine" }, - "darwin", - ); - assert.deepEqual(rubymineLaunch, { - command: "rubymine", - args: ["/tmp/workspace"], - }); - - const rustroverLaunch = yield* resolveEditorLaunch( - { cwd: "/tmp/workspace", editor: "rustrover" }, - "darwin", - ); - assert.deepEqual(rustroverLaunch, { - command: "rustrover", - args: ["/tmp/workspace"], - }); - - const webstormLaunch = yield* resolveEditorLaunch( - { cwd: "/tmp/workspace", editor: "webstorm" }, - "darwin", - ); - assert.deepEqual(webstormLaunch, { - command: "webstorm", - args: ["/tmp/workspace"], - }); - }), - ); - - 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: "" }, - ); - 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: "" }, - ); - 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", - ); - 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: "" }, - ); - 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: "" }, - ); - 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", - ); - 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", - ); - 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: "" }, - ); - 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: "" }, - ); - assert.deepEqual(zedLineOnly, { - command: "zed", - args: ["/tmp/workspace/AGENTS.md:48"], - }); - - const ideaLineOnly = yield* resolveEditorLaunch( - { cwd: "/tmp/workspace/AGENTS.md:48", editor: "idea" }, - "darwin", - ); - 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", - ); - 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", - ); - 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", - ); - 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", - ); - 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", - ); - 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", - ); - 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", - ); - 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", - ); - 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", - ); - 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", - ); - 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", - ); - 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", - ); - assert.deepEqual(webstormLineOnly, { - command: "webstorm", - args: ["--line", "48", "/tmp/workspace/AGENTS.md"], - }); - }), - ); - - it.effect("falls back to zeditor when zed is not installed", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-external-launcher-test-" }); - yield* fs.writeFileString(path.join(dir, "zeditor"), "#!/bin/sh\nexit 0\n"); - yield* fs.chmod(path.join(dir, "zeditor"), 0o755); - - const result = yield* resolveEditorLaunch({ cwd: "/tmp/workspace", editor: "zed" }, "linux", { - PATH: dir, - }); - - assert.deepEqual(result, { - command: "zeditor", - args: ["/tmp/workspace"], - }); - }), - ); - - it.effect("falls back to the primary command when no alias is installed", () => - Effect.gen(function* () { - const result = yield* resolveEditorLaunch({ cwd: "/tmp/workspace", editor: "zed" }, "linux", { - PATH: "", - }); - assert.deepEqual(result, { - command: "zed", - args: ["/tmp/workspace"], - }); - }), - ); - - 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: "" }, - ); - assert.deepEqual(launch1, { - command: "open", - args: ["/tmp/workspace"], - }); - - const launch2 = yield* resolveEditorLaunch( - { cwd: "C:\\workspace", editor: "file-manager" }, - "win32", - { PATH: "" }, - ); - assert.deepEqual(launch2, { - command: "explorer", - args: ["C:\\workspace"], - }); - - const launch3 = yield* resolveEditorLaunch( - { cwd: "/tmp/workspace", editor: "file-manager" }, - "linux", - { PATH: "" }, - ); - assert.deepEqual(launch3, { - command: "xdg-open", - args: ["/tmp/workspace"], - }); - }), - ); -}); - -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", - }); - - assert.deepEqual(resolveBrowserLaunch(target, "linux", {}).command, "xdg-open"); - assert.deepEqual(resolveBrowserLaunch(target, "linux", {}).args, [target]); - - 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'", +const testLayer = (input: { + readonly platform: NodeJS.Platform; + readonly env?: Record; + readonly resolveExecutable?: (command: string) => string | undefined; + readonly onSpawn?: (command: ChildProcess.StandardCommand) => void; + readonly onUnref?: () => void; +}) => { + const spawnerLayer = Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make((command) => + Effect.sync(() => { + assert.equal(ChildProcess.isStandardCommand(command), true); + if (!ChildProcess.isStandardCommand(command)) { + throw new Error("Expected a standard command"); + } + input.onSpawn?.(command); + return makeMockDetachedHandle(input.onUnref); + }), ), - ]); - assert.deepEqual(windows.options, { - detached: true, - shell: false, - stdin: "ignore", - stdout: "ignore", - stderr: "ignore", - }); -}); - -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); -}); - -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.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 spawnerLayer = Layer.mock(ChildProcessSpawner.ChildProcessSpawner, { - spawn: (command) => - Effect.sync(() => { - assert.equal(ChildProcess.isStandardCommand(command), true); - if (!ChildProcess.isStandardCommand(command)) { - throw new Error("Expected a standard command"); - } - spawnedCommand = command; - return makeMockDetachedHandle(() => { - didUnref = true; - }); - }), - }); - - const result = yield* launchBrowser("https://example.com").pipe( - Effect.provide(spawnerLayer), - Effect.result, - ); - - assertSuccess(result, undefined); - assert.ok(spawnedCommand); - const expectedLaunch = resolveBrowserLaunch("https://example.com"); - assert.equal(spawnedCommand.command, expectedLaunch.command); - assert.deepEqual(spawnedCommand.args, expectedLaunch.args); - assert.deepEqual(spawnedCommand.options, expectedLaunch.options); - assert.equal(didUnref, true); - }), - ); -}); - -it.layer(NodeServices.layer)("launchEditorProcess", (it) => { - it.effect("spawns through the ChildProcessSpawner service and unrefs the handle", () => - Effect.gen(function* () { - let spawnedCommand: ChildProcess.StandardCommand | undefined; - let didUnref = false; - const expectedArgs = ["-e", "process.exit(0)"]; - - const spawnerLayer = Layer.mock(ChildProcessSpawner.ChildProcessSpawner, { - spawn: (command) => - Effect.sync(() => { - assert.equal(ChildProcess.isStandardCommand(command), true); - if (!ChildProcess.isStandardCommand(command)) { - throw new Error("Expected a standard command"); - } - spawnedCommand = command; - return makeMockDetachedHandle(() => { - didUnref = true; - }); - }), - }); - - const result = yield* launchEditorProcess({ - command: process.execPath, - args: expectedArgs, - }).pipe(Effect.provide(spawnerLayer), 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.options, { - detached: true, - shell: process.platform === "win32", - stdin: "ignore", - stdout: "ignore", - stderr: "ignore", - }); - assert.equal(didUnref, true); - }), - ); - - it.effect("rejects when command does not exist", () => - Effect.gen(function* () { - const spawnerLayer = Layer.mock(ChildProcessSpawner.ChildProcessSpawner, {}); - const result = yield* launchEditorProcess({ - command: `t3code-no-such-command-${yield* Crypto.Crypto.pipe( - Effect.flatMap((crypto) => crypto.randomUUIDv4), - )}`, - args: [], - }).pipe(Effect.provide(spawnerLayer), Effect.result); - assert.equal(result._tag, "Failure"); - }), - ); -}); - -it.layer(NodeServices.layer)("isCommandAvailable", (it) => { - it.effect("resolves win32 commands with PATHEXT", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-external-launcher-test-" }); - yield* fs.writeFileString(path.join(dir, "code.CMD"), "@echo off\r\n"); - const env = { - PATH: dir, - PATHEXT: ".COM;.EXE;.BAT;.CMD", - } satisfies NodeJS.ProcessEnv; - assert.equal(isCommandAvailable("code", { platform: "win32", env }), true); - }), - ); - - it("returns false when a command is not on PATH", () => { - const env = { - PATH: "", - PATHEXT: ".COM;.EXE;.BAT;.CMD", - } satisfies NodeJS.ProcessEnv; - assert.equal(isCommandAvailable("definitely-not-installed", { platform: "win32", env }), false); - }); - - it.effect("does not treat bare files without executable extension as available on win32", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-external-launcher-test-" }); - yield* fs.writeFileString(path.join(dir, "npm"), "echo nope\r\n"); - const env = { - PATH: dir, - PATHEXT: ".COM;.EXE;.BAT;.CMD", - } satisfies NodeJS.ProcessEnv; - assert.equal(isCommandAvailable("npm", { platform: "win32", env }), false); - }), ); - it.effect("appends PATHEXT for commands with non-executable extensions on win32", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-external-launcher-test-" }); - yield* fs.writeFileString(path.join(dir, "my.tool.CMD"), "@echo off\r\n"); - const env = { - PATH: dir, - PATHEXT: ".COM;.EXE;.BAT;.CMD", - } satisfies NodeJS.ProcessEnv; - assert.equal(isCommandAvailable("my.tool", { platform: "win32", env }), true); - }), + return Layer.mergeAll( + ExternalLauncherLive.pipe(Layer.provide(Layer.merge(NodeServices.layer, spawnerLayer))), + Layer.succeed(HostProcessPlatform, input.platform), + Layer.succeed( + SpawnExecutableResolution, + (command) => input.resolveExecutable?.(command) ?? command, + ), + ConfigProvider.layer(ConfigProvider.fromEnv({ env: input.env ?? {} })), ); - - it.effect("uses platform-specific PATH delimiter for platform overrides", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const firstDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-external-launcher-test-" }); - const secondDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-external-launcher-test-" }); - yield* fs.writeFileString(path.join(firstDir, "code.CMD"), "@echo off\r\n"); - yield* fs.writeFileString(path.join(secondDir, "code.CMD"), "MZ"); - const env = { - PATH: `${firstDir};${secondDir}`, - PATHEXT: ".COM;.EXE;.BAT;.CMD", - } satisfies NodeJS.ProcessEnv; - assert.equal(isCommandAvailable("code", { platform: "win32", env }), true); - }), +}; + +it.effect("launches the default browser through the platform command", () => { + let spawned: ChildProcess.StandardCommand | undefined; + let didUnref = false; + return Effect.gen(function* () { + const launcher = yield* ExternalLauncher; + + yield* launcher.launchBrowser("https://example.com/some path"); + + assert.ok(spawned); + assert.equal(spawned.command, "xdg-open"); + assert.deepEqual(spawned.args, ["https://example.com/some path"]); + assert.equal(spawned.options.detached, true); + assert.equal(didUnref, true); + }).pipe( + Effect.provide( + testLayer({ + platform: "linux", + onSpawn: (command) => { + spawned = command; + }, + onUnref: () => { + didUnref = true; + }, + }), + ), ); }); -it.layer(NodeServices.layer)("resolveAvailableEditors", (it) => { - it.effect("returns installed editors for command launches", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-editors-" }); - - yield* fs.writeFileString(path.join(dir, "trae.CMD"), "@echo off\r\n"); - yield* fs.writeFileString(path.join(dir, "kiro.CMD"), "@echo off\r\n"); - yield* fs.writeFileString(path.join(dir, "code-insiders.CMD"), "@echo off\r\n"); - yield* fs.writeFileString(path.join(dir, "codium.CMD"), "@echo off\r\n"); - yield* fs.writeFileString(path.join(dir, "aqua.CMD"), "@echo off\r\n"); - yield* fs.writeFileString(path.join(dir, "clion.CMD"), "@echo off\r\n"); - yield* fs.writeFileString(path.join(dir, "datagrip.CMD"), "@echo off\r\n"); - yield* fs.writeFileString(path.join(dir, "dataspell.CMD"), "@echo off\r\n"); - yield* fs.writeFileString(path.join(dir, "goland.CMD"), "@echo off\r\n"); - yield* fs.writeFileString(path.join(dir, "phpstorm.CMD"), "@echo off\r\n"); - yield* fs.writeFileString(path.join(dir, "pycharm.CMD"), "@echo off\r\n"); - yield* fs.writeFileString(path.join(dir, "rider.CMD"), "@echo off\r\n"); - yield* fs.writeFileString(path.join(dir, "rubymine.CMD"), "@echo off\r\n"); - yield* fs.writeFileString(path.join(dir, "rustrover.CMD"), "@echo off\r\n"); - yield* fs.writeFileString(path.join(dir, "webstorm.CMD"), "@echo off\r\n"); - yield* fs.writeFileString(path.join(dir, "explorer.CMD"), "MZ"); - const editors = resolveAvailableEditors("win32", { - PATH: dir, - PATHEXT: ".COM;.EXE;.BAT;.CMD", - }); - assert.deepEqual(editors, [ - "trae", - "kiro", - "vscode-insiders", - "vscodium", - "aqua", - "clion", - "datagrip", - "dataspell", - "goland", - "phpstorm", - "pycharm", - "rider", - "rubymine", - "rustrover", - "webstorm", - "file-manager", - ]); - }), - ); - - it.effect("includes zed when only the zeditor command is installed", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-editors-" }); - - yield* fs.writeFileString(path.join(dir, "zeditor"), "#!/bin/sh\nexit 0\n"); - yield* fs.writeFileString(path.join(dir, "xdg-open"), "#!/bin/sh\nexit 0\n"); - yield* fs.chmod(path.join(dir, "zeditor"), 0o755); - yield* fs.chmod(path.join(dir, "xdg-open"), 0o755); - - const editors = resolveAvailableEditors("linux", { - PATH: dir, - }); - assert.deepEqual(editors, ["zed", "file-manager"]); - }), - ); - - it("omits file-manager when the platform opener is unavailable", () => { - const editors = resolveAvailableEditors("linux", { - PATH: "", - }); - assert.deepEqual(editors, []); - }); -}); +it.effect("launches an installed editor with platform-safe arguments", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const binDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-editors-" }); + yield* fileSystem.writeFileString(path.join(binDir, "code.CMD"), "@echo off\r\n"); + + let spawned: ChildProcess.StandardCommand | undefined; + yield* Effect.gen(function* () { + const launcher = yield* ExternalLauncher; + yield* launcher.launchEditor({ + editor: "vscode", + cwd: "C:\\workspace with spaces\\src\\index.ts:12:4", + }); + }).pipe( + Effect.provide( + testLayer({ + platform: "win32", + env: { PATH: binDir, PATHEXT: ".COM;.EXE;.BAT;.CMD" }, + resolveExecutable: (command) => + command === "code" ? "C:\\Program Files\\Microsoft VS Code\\bin\\code.CMD" : command, + onSpawn: (command) => { + spawned = command; + }, + }), + ), + ); + + assert.ok(spawned); + assert.equal(spawned.command, '^"C:\\Program^ Files\\Microsoft^ VS^ Code\\bin\\code.CMD^"'); + assert.deepEqual(spawned.args, [ + '^"--goto^"', + '^"C:\\workspace^ with^ spaces\\src\\index.ts:12:4^"', + ]); + assert.equal(spawned.options.shell, true); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +it.effect("discovers editors through the service API", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const binDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-editors-" }); + yield* fileSystem.writeFileString(path.join(binDir, "code.CMD"), "@echo off\r\n"); + yield* fileSystem.writeFileString(path.join(binDir, "explorer.CMD"), "@echo off\r\n"); + + const editors = yield* Effect.gen(function* () { + const launcher = yield* ExternalLauncher; + return yield* launcher.resolveAvailableEditors(); + }).pipe( + Effect.provide( + testLayer({ + platform: "win32", + env: { PATH: binDir, PATHEXT: ".COM;.EXE;.BAT;.CMD" }, + }), + ), + ); + + assert.equal(editors.includes("vscode"), true); + assert.equal(editors.includes("file-manager"), true); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +it.effect("rejects unknown editors through the service API", () => + Effect.gen(function* () { + const launcher = yield* ExternalLauncher; + const result = yield* launcher + .launchEditor({ editor: "missing-editor" as never, cwd: "/tmp/workspace" }) + .pipe(Effect.result); + assert.equal(result._tag, "Failure"); + }).pipe(Effect.provide(testLayer({ platform: "linux", env: { PATH: "" } }))), +); diff --git a/apps/server/src/process/externalLauncher.ts b/apps/server/src/process/externalLauncher.ts index da19864dcf8..0b40acef5c0 100644 --- a/apps/server/src/process/externalLauncher.ts +++ b/apps/server/src/process/externalLauncher.ts @@ -12,12 +12,16 @@ import { type EditorId, type LaunchEditorInput, } from "@t3tools/contracts"; -import { isCommandAvailable, type CommandAvailabilityOptions } from "@t3tools/shared/shell"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import { isCommandAvailable, resolveSpawnCommand } from "@t3tools/shared/shell"; +import * as Config from "effect/Config"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; 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"; // ============================== @@ -26,8 +30,6 @@ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; export { ExternalLauncherError }; export type { LaunchEditorInput }; -export { isCommandAvailable } from "@t3tools/shared/shell"; - interface EditorLaunch { readonly command: string; readonly args: ReadonlyArray; @@ -61,6 +63,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]) { @@ -109,17 +141,17 @@ function resolveEditorArgs( return [...baseArgs, ...resolveCommandEditorArgs(editor, target)]; } -function resolveAvailableCommand( +const resolveAvailableCommand = Effect.fn("externalLauncher.resolveAvailableCommand")(function* ( commands: ReadonlyArray, - options: CommandAvailabilityOptions = {}, -): Option.Option { + env: NodeJS.ProcessEnv, +): Effect.fn.Return, never, FileSystem.FileSystem | Path.Path> { for (const command of commands) { - if (isCommandAvailable(command, options)) { + if (yield* isCommandAvailable(command, { env })) { return Option.some(command); } } return Option.none(); -} +}); function encodeUtf16LeBase64(input: string): string { const bytes = new Uint8Array(input.length * 2); @@ -135,7 +167,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`; } @@ -145,7 +177,7 @@ function resolveWslPowerShellPath(): string { function shouldUseWindowsBrowserFromWsl( platform: NodeJS.Platform, - env: NodeJS.ProcessEnv = process.env, + env: NodeJS.ProcessEnv = {}, ): boolean { return ( platform === "linux" && @@ -184,10 +216,10 @@ function fileManagerCommandForPlatform(platform: NodeJS.Platform): string { } } -export function resolveBrowserLaunch( +function buildBrowserLaunch( target: string, - platform: NodeJS.Platform = process.platform, - env: NodeJS.ProcessEnv = process.env, + platform: NodeJS.Platform, + env: NodeJS.ProcessEnv = {}, ): ProcessLaunch { if (platform === "darwin") { return { @@ -212,34 +244,49 @@ export function resolveBrowserLaunch( }; } -export function resolveAvailableEditors( - platform: NodeJS.Platform = process.platform, - env: NodeJS.ProcessEnv = process.env, -): ReadonlyArray { +const buildAvailableEditors = Effect.fn("externalLauncher.buildAvailableEditors")(function* ( + platform: NodeJS.Platform, + env: NodeJS.ProcessEnv, +): 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 (isCommandAvailable(command, { platform, env })) { + if (yield* isCommandAvailable(command, { env })) { available.push(editor.id); } continue; } - const command = resolveAvailableCommand(editor.commands, { platform, env }); + const command = yield* resolveAvailableCommand(editor.commands, env); if (Option.isSome(command)) { available.push(editor.id); } } return available; -} +}); + +const resolveBrowserLaunch = Effect.fn("externalLauncher.resolveBrowserLaunch")(function* ( + target: string, +) { + const platform = yield* HostProcessPlatform; + const env = yield* readBrowserLaunchEnv; + return buildBrowserLaunch(target, platform, env); +}); + +const resolveAvailableEditors = Effect.fn("externalLauncher.resolveAvailableEditors")(function* () { + const platform = yield* HostProcessPlatform; + const env = yield* readCommandLookupEnv; + return yield* buildAvailableEditors(platform, env); +}); /** * ExternalLauncherShape - Service API for browser and editor launch actions. */ export interface ExternalLauncherShape { + readonly resolveAvailableEditors: () => Effect.Effect>; /** * Launch a URL target in the default browser. */ @@ -264,11 +311,11 @@ export class ExternalLauncher extends Context.Service { +): Effect.fn.Return { + const platform = yield* HostProcessPlatform; + const env = yield* readCommandLookupEnv; yield* Effect.annotateCurrentSpan({ "externalLauncher.editor": input.editor, "externalLauncher.cwd": input.cwd, @@ -281,7 +328,7 @@ export const resolveEditorLaunch = Effect.fn("resolveEditorLaunch")(function* ( if (editorDef.commands) { const command = Option.getOrElse( - resolveAvailableCommand(editorDef.commands, { platform, env }), + yield* resolveAvailableCommand(editorDef.commands, env), () => editorDef.commands[0], ); return { @@ -312,29 +359,35 @@ const launchAndUnref = Effect.fn("externalLauncher.launchAndUnref")(function* ( ); }); -export const launchBrowser = Effect.fn("externalLauncher.launchBrowser")(function* ( +const launchBrowser = Effect.fn("externalLauncher.launchBrowser")(function* ( target: string, ): Effect.fn.Return { - return yield* launchAndUnref(resolveBrowserLaunch(target), "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* ( +const launchEditorProcess = Effect.fn("externalLauncher.launchEditorProcess")(function* ( launch: EditorLaunch, -): Effect.fn.Return { - if (!isCommandAvailable(launch.command)) { +): Effect.fn.Return< + void, + ExternalLauncherError, + ChildProcessSpawner.ChildProcessSpawner | FileSystem.FileSystem | Path.Path +> { + const env = yield* readCommandLookupEnv; + if (!(yield* isCommandAvailable(launch.command, { env }))) { return yield* new ExternalLauncherError({ message: `Editor command not found: ${launch.command}`, }); } - const isWin32 = process.platform === "win32"; + const spawnCommand = yield* resolveSpawnCommand(launch.command, launch.args, { env }); yield* launchAndUnref( { - command: launch.command, - args: isWin32 ? launch.args.map((arg) => `"${arg}"`) : [...launch.args], + command: spawnCommand.command, + args: spawnCommand.args, options: { detached: true, - shell: isWin32, + shell: spawnCommand.shell, stdin: "ignore", stdout: "ignore", stderr: "ignore", @@ -346,16 +399,29 @@ 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 { + resolveAvailableEditors: () => provideCommandResolutionServices(resolveAvailableEditors()), launchBrowser: (target) => launchBrowser(target).pipe( 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..f914c667a1c 100644 --- a/apps/server/src/processRunner.test.ts +++ b/apps/server/src/processRunner.test.ts @@ -8,6 +8,8 @@ import * as Sink from "effect/Sink"; import * as Stream from "effect/Stream"; import { TestClock } from "effect/testing"; import { ChildProcessSpawner } from "effect/unstable/process"; +import { HostProcessEnvironment, HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import { SpawnExecutableResolution } from "@t3tools/shared/shell"; import { isWindowsCommandNotFound, @@ -21,6 +23,9 @@ import { type ChildProcessCommand = { readonly command: string; readonly args: ReadonlyArray; + readonly options: { + readonly shell?: boolean | string; + }; }; // Accesses private properties of ChildProcessCommand for testing purposes @@ -68,7 +73,6 @@ const runWith = Effect.flatMap((runner) => runner.run({ ...input, - shell: input.shell ?? false, }), ), Effect.provide( @@ -123,6 +127,44 @@ describe("runProcess", () => { }).pipe(Effect.provide(layer)); }); + it.effect("resolves and escapes Windows command shims before spawning", () => { + const spawner = makeSpawner((command) => + Effect.sync(() => { + expect(command.command).toBe('^"C:\\Users\\tester\\AppData\\Roaming\\npm\\az.cmd^"'); + expect(command.args).toEqual([ + '^"repos^"', + '^"pr^"', + '^"list^"', + '^"--source-branch^"', + '^"feature^ ^&^ release^"', + ]); + expect(command.options.shell).toBe(true); + return makeHandle({ stdout: "[]" }); + }), + ); + + return runWith(spawner)({ + command: "az", + args: ["repos", "pr", "list", "--source-branch", "feature & release"], + env: { AZURE_CONFIG_DIR: "C:\\Users\\tester\\.azure" }, + }).pipe( + Effect.provideService(HostProcessPlatform, "win32"), + Effect.provideService(HostProcessEnvironment, { + PATH: "C:\\Users\\tester\\AppData\\Roaming\\npm", + PATHEXT: ".COM;.EXE;.BAT;.CMD", + }), + Effect.provideService(SpawnExecutableResolution, (_command, _platform, env) => + env.PATH === "C:\\Users\\tester\\AppData\\Roaming\\npm" && + env.AZURE_CONFIG_DIR === "C:\\Users\\tester\\.azure" + ? "C:\\Users\\tester\\AppData\\Roaming\\npm\\az.cmd" + : undefined, + ), + Effect.map((result) => { + expect(result.stdout).toBe("[]"); + }), + ); + }); + it.effect("fails when output exceeds max buffer in default mode", () => Effect.gen(function* () { const spawner = makeSpawner(() => Effect.succeed(makeHandle({ stdout: "x".repeat(2048) }))); @@ -280,19 +322,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..4cfb764c557 100644 --- a/apps/server/src/processRunner.ts +++ b/apps/server/src/processRunner.ts @@ -8,6 +8,8 @@ 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 { resolveSpawnCommand } from "@t3tools/shared/shell"; import { collectUint8StreamText, type CollectedUint8StreamText, @@ -24,7 +26,6 @@ export interface ProcessRunInput { readonly maxOutputBytes?: number | undefined; readonly outputMode?: "error" | "truncate" | undefined; readonly truncatedMarker?: string | undefined; - readonly shell?: boolean | string | undefined; /** * On timeout, return a synthetic timedOut result. * Partial stdout/stderr are not preserved. @@ -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; @@ -231,18 +235,24 @@ const runProcessCore = Effect.fn("processRunner.runProcessCore")(function* ( const maxOutputBytes = input.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES; const outputMode = input.outputMode ?? "error"; const truncatedMarker = input.truncatedMarker ?? ""; + const extendEnv = input.env !== undefined; + const spawnCommand = yield* resolveSpawnCommand( + input.command, + input.args, + input.env === undefined ? {} : { env: input.env, extendEnv }, + ); const child = yield* spawner .spawn( - ChildProcess.make(input.command, [...input.args], { + ChildProcess.make(spawnCommand.command, spawnCommand.args, { ...((input.spawnCwd ?? input.cwd) ? { cwd: input.spawnCwd ?? input.cwd } : {}), ...(input.env !== undefined ? { env: input.env, - extendEnv: true, + extendEnv, } : {}), - ...(input.shell !== undefined ? { shell: input.shell } : {}), + shell: spawnCommand.shell, }), ) .pipe( diff --git a/apps/server/src/project/Layers/RepositoryIdentityResolver.ts b/apps/server/src/project/Layers/RepositoryIdentityResolver.ts index 4fdaa71de22..d4ae073b953 100644 --- a/apps/server/src/project/Layers/RepositoryIdentityResolver.ts +++ b/apps/server/src/project/Layers/RepositoryIdentityResolver.ts @@ -89,6 +89,8 @@ const resolveRepositoryIdentityCacheKey = Effect.fn("resolveRepositoryIdentityCa const processRunner = yield* ProcessRunner.ProcessRunner; let cacheKey = cwd; + // git is a real executable on every platform — no cmd.exe shell mode, which + // would split paths containing spaces during cmd's re-tokenization. const topLevelResult = yield* processRunner .run({ command: "git", diff --git a/apps/server/src/provider/Drivers/ClaudeHome.ts b/apps/server/src/provider/Drivers/ClaudeHome.ts index 9a4d1ce9cdf..65c74f9764a 100644 --- a/apps/server/src/provider/Drivers/ClaudeHome.ts +++ b/apps/server/src/provider/Drivers/ClaudeHome.ts @@ -16,13 +16,14 @@ 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 resolvedBaseEnv = baseEnv ?? process.env; 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/Layers/ClaudeProvider.ts b/apps/server/src/provider/Layers/ClaudeProvider.ts index d5bbc8f6572..d677de7a313 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 { resolveSpawnCommand } from "@t3tools/shared/shell"; import { compareSemverVersions } from "@t3tools/shared/semver"; import { query as claudeQuery, @@ -547,7 +548,7 @@ 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* () { @@ -603,12 +604,15 @@ 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 command = ChildProcess.make(claudeSettings.binaryPath, [...args], { + const spawnCommand = yield* resolveSpawnCommand(claudeSettings.binaryPath, args, { env: claudeEnvironment, - shell: process.platform === "win32", + }); + const command = ChildProcess.make(spawnCommand.command, spawnCommand.args, { + env: claudeEnvironment, + shell: spawnCommand.shell, }); return yield* spawnAndCollect(claudeSettings.binaryPath, command); }); @@ -618,12 +622,13 @@ 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 resolvedEnvironment = environment ?? process.env; const checkedAt = DateTime.formatIso(yield* DateTime.now); const allModels = providerModelsFromSettings( BUILT_IN_MODELS, @@ -648,10 +653,11 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( }); } - const versionProbe = yield* runClaudeCommand(claudeSettings, ["--version"], environment).pipe( - Effect.timeoutOption(DEFAULT_TIMEOUT_MS), - Effect.result, - ); + const versionProbe = yield* runClaudeCommand( + claudeSettings, + ["--version"], + resolvedEnvironment, + ).pipe(Effect.timeoutOption(DEFAULT_TIMEOUT_MS), Effect.result); if (Result.isFailure(versionProbe)) { const error = versionProbe.failure; diff --git a/apps/server/src/provider/Layers/CodexProvider.ts b/apps/server/src/provider/Layers/CodexProvider.ts index 89d7421b232..fb2f36f6438 100644 --- a/apps/server/src/provider/Layers/CodexProvider.ts +++ b/apps/server/src/provider/Layers/CodexProvider.ts @@ -7,7 +7,7 @@ import * as Result from "effect/Result"; import * as Schema from "effect/Schema"; import * as Scope from "effect/Scope"; import * as Types from "effect/Types"; -import { ChildProcessSpawner } from "effect/unstable/process"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import * as CodexClient from "effect-codex-app-server/client"; import * as CodexSchema from "effect-codex-app-server/schema"; import * as CodexErrors from "effect-codex-app-server/errors"; @@ -24,6 +24,7 @@ import type { import { ServerSettingsError } from "@t3tools/contracts"; import { createModelCapabilities } from "@t3tools/shared/model"; +import { resolveSpawnCommand } from "@t3tools/shared/shell"; import { AUTH_PROBE_TIMEOUT_MS, buildServerProvider, @@ -33,6 +34,8 @@ import { expandHomePath } from "../../pathExpansion.ts"; import packageJson from "../../../package.json" with { type: "json" }; const isCodexAppServerSpawnError = Schema.is(CodexErrors.CodexAppServerSpawnError); +const CODEX_APP_SERVER_PROBE_FORCE_KILL_AFTER = "2 seconds" as const; + const CODEX_PRESENTATION = { displayName: "Codex", showInteractionModeToggle: true, @@ -292,17 +295,35 @@ const probeCodexAppServerProvider = Effect.fn("probeCodexAppServerProvider")(fun // "CODEX_HOME points to '~/.codex_work', but that path does not exist". // Expand here for parity with `CodexTextGeneration`/`CodexSessionRuntime`. const resolvedHomePath = input.homePath ? expandHomePath(input.homePath) : undefined; - const clientContext = yield* Layer.build( - CodexClient.layerCommand({ - command: input.binaryPath, - args: ["app-server"], - cwd: input.cwd, - env: { - ...(input.environment ?? process.env), - ...(resolvedHomePath ? { CODEX_HOME: resolvedHomePath } : {}), - }, - }), - ); + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const environment = { + ...input.environment, + ...(resolvedHomePath ? { CODEX_HOME: resolvedHomePath } : {}), + }; + const spawnCommand = yield* resolveSpawnCommand(input.binaryPath, ["app-server"], { + env: environment, + extendEnv: true, + }); + const child = yield* spawner + .spawn( + ChildProcess.make(spawnCommand.command, spawnCommand.args, { + cwd: input.cwd, + env: environment, + extendEnv: true, + forceKillAfter: CODEX_APP_SERVER_PROBE_FORCE_KILL_AFTER, + shell: spawnCommand.shell, + }), + ) + .pipe( + Effect.mapError( + (cause) => + new CodexErrors.CodexAppServerSpawnError({ + command: `${input.binaryPath} app-server`, + cause, + }), + ), + ); + const clientContext = yield* Layer.build(CodexClient.layerChildProcess(child)); const client = yield* Effect.service(CodexClient.CodexAppServerClient).pipe( Effect.provide(clientContext), ); @@ -449,12 +470,13 @@ 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 resolvedEnvironment = environment ?? process.env; const checkedAt = DateTime.formatIso(yield* DateTime.now); const emptyModels = emptyCodexModelsFromSettings(codexSettings); @@ -480,7 +502,7 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu homePath: codexSettings.homePath, cwd: process.cwd(), customModels: codexSettings.customModels, - environment, + environment: resolvedEnvironment, }).pipe( Effect.scoped, Effect.timeoutOption(Duration.millis(AUTH_PROBE_TIMEOUT_MS)), diff --git a/apps/server/src/provider/Layers/CodexSessionRuntime.ts b/apps/server/src/provider/Layers/CodexSessionRuntime.ts index e3e20106d72..03957081ded 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 { resolveSpawnCommand } from "@t3tools/shared/shell"; import { normalizeModelSlug } from "@t3tools/shared/model"; import * as Crypto from "effect/Crypto"; import * as DateTime from "effect/DateTime"; @@ -720,16 +721,23 @@ export const makeCodexSessionRuntime = ( // `CODEX_HOME=~/.codex_work` reach codex as an absolute path. const resolvedHomePath = options.homePath ? expandHomePath(options.homePath) : undefined; const env = { - ...(options.environment ?? process.env), + ...options.environment, ...(resolvedHomePath ? { CODEX_HOME: resolvedHomePath } : {}), }; + const extendEnv = options.environment === undefined; + const spawnCommand = yield* resolveSpawnCommand( + options.binaryPath, + ["app-server", ...(options.appServerArgs ?? [])], + { env, extendEnv }, + ); const child = yield* spawner .spawn( - ChildProcess.make(options.binaryPath, ["app-server", ...(options.appServerArgs ?? [])], { + ChildProcess.make(spawnCommand.command, spawnCommand.args, { cwd: options.cwd, env, + extendEnv, forceKillAfter: CODEX_APP_SERVER_FORCE_KILL_AFTER, - shell: process.platform === "win32", + shell: spawnCommand.shell, }), ) .pipe( diff --git a/apps/server/src/provider/Layers/CursorProvider.ts b/apps/server/src/provider/Layers/CursorProvider.ts index facdb5a5ff1..35d5413714c 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 { resolveSpawnCommand } from "@t3tools/shared/shell"; import { buildBooleanOptionDescriptor, @@ -394,7 +395,7 @@ function buildCursorDiscoveredModelsFromAvailableModelsResponse( const makeCursorAcpProbeRuntime = ( cursorSettings: CursorSettings, - environment: NodeJS.ProcessEnv = process.env, + environment?: NodeJS.ProcessEnv, ) => Effect.gen(function* () { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; @@ -407,7 +408,7 @@ const makeCursorAcpProbeRuntime = ( "acp", ], cwd: process.cwd(), - env: environment, + ...(environment ? { env: environment } : {}), }, cwd: process.cwd(), clientInfo: { name: "t3-code-provider-probe", version: "0.0.0" }, @@ -421,7 +422,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 +543,7 @@ export function resolveCursorAcpConfigUpdates( const discoverCursorModelsViaListAvailableModels = ( cursorSettings: CursorSettings, - environment: NodeJS.ProcessEnv = process.env, + environment?: NodeJS.ProcessEnv, ) => withCursorAcpProbeRuntime( cursorSettings, @@ -558,7 +559,7 @@ const discoverCursorModelsViaListAvailableModels = ( export const discoverCursorModelsViaAcp = ( cursorSettings: CursorSettings, - environment: NodeJS.ProcessEnv = process.env, + environment?: NodeJS.ProcessEnv, ) => discoverCursorModelsViaListAvailableModels(cursorSettings, environment); export function getCursorFallbackModels( @@ -927,13 +928,18 @@ 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 command = ChildProcess.make(cursorSettings.binaryPath, [...args], { - env: environment, - shell: process.platform === "win32", + const spawnCommand = yield* resolveSpawnCommand( + cursorSettings.binaryPath, + args, + environment ? { env: environment } : {}, + ); + const command = ChildProcess.make(spawnCommand.command, spawnCommand.args, { + ...(environment ? { env: environment } : { extendEnv: true }), + shell: spawnCommand.shell, }); const child = yield* spawner.spawn(command); @@ -949,10 +955,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 +970,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/GrokProvider.ts b/apps/server/src/provider/Layers/GrokProvider.ts index bead8b1a407..35611398b4b 100644 --- a/apps/server/src/provider/Layers/GrokProvider.ts +++ b/apps/server/src/provider/Layers/GrokProvider.ts @@ -15,6 +15,7 @@ import * as Result from "effect/Result"; import { HttpClient } from "effect/unstable/http"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { createModelCapabilities } from "@t3tools/shared/model"; +import { resolveSpawnCommand } from "@t3tools/shared/shell"; import { buildServerProvider, @@ -149,16 +150,20 @@ const discoverGrokModelsViaAcp = ( const runGrokVersionCommand = ( grokSettings: GrokSettings, environment: NodeJS.ProcessEnv = process.env, -) => { - const command = grokSettings.binaryPath || "grok"; - return spawnAndCollect( - command, - ChildProcess.make(command, ["--version"], { +) => + Effect.gen(function* () { + const command = grokSettings.binaryPath || "grok"; + const spawnCommand = yield* resolveSpawnCommand(command, ["--version"], { env: environment, - shell: process.platform === "win32", - }), - ); -}; + }); + return yield* spawnAndCollect( + command, + ChildProcess.make(spawnCommand.command, spawnCommand.args, { + env: environment, + shell: spawnCommand.shell, + }), + ); + }); export const checkGrokProviderStatus = Effect.fn("checkGrokProviderStatus")(function* ( grokSettings: GrokSettings, diff --git a/apps/server/src/provider/Layers/OpenCodeProvider.ts b/apps/server/src/provider/Layers/OpenCodeProvider.ts index 8842b1da5ce..a8285e960fc 100644 --- a/apps/server/src/provider/Layers/OpenCodeProvider.ts +++ b/apps/server/src/provider/Layers/OpenCodeProvider.ts @@ -301,9 +301,10 @@ 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 resolvedEnvironment = environment ?? process.env; const checkedAt = DateTime.formatIso(yield* DateTime.now); const customModels = openCodeSettings.customModels; const isExternalServer = openCodeSettings.serverUrl.trim().length > 0; @@ -364,7 +365,7 @@ export const checkOpenCodeProviderStatus = Effect.fn("checkOpenCodeProviderStatu .runOpenCodeCommand({ binaryPath: openCodeSettings.binaryPath, args: ["--version"], - environment, + environment: resolvedEnvironment, }) .pipe( Effect.mapError( @@ -413,7 +414,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/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts index 56b80f6c4a2..5fe0f903686 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -1039,7 +1039,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest(), T // This test intentionally avoids `mockCommandSpawnerLayer` so the real // `probeCodexAppServerProvider` path runs — including the full - // `codex app-server` RPC handshake via `CodexClient.layerCommand`. + // `codex app-server` RPC handshake via `CodexClient.layerChildProcess`. // We point `binaryPath` at a name that cannot exist on any machine so // the real `ChildProcessSpawner` deterministically returns ENOENT; the // probe wraps that as `CodexAppServerSpawnError` and @@ -1159,6 +1159,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest(), T Effect.gen(function* () { const firstMissing = `t3code_codex_first_`; const secondMissing = `t3code_codex_second_`; + const spawnedCommands: Array = []; const serverSettings = yield* makeMutableServerSettingsService( decodeServerSettings( deepMerge(encodedDefaultServerSettings, { @@ -1185,10 +1186,12 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest(), T Layer.provideMerge(TestHttpClientLive), Layer.provideMerge(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)), Layer.provideMerge(OpenCodeRuntimeLive), - // `it.live` does not inherit layers from the outer `it.layer` - // wrapper, so provide `NodeServices.layer` inline. This is the - // same real `ChildProcessSpawner` + `FileSystem` + `Path` - // services that production uses. + Layer.updateService(ChildProcessSpawner.ChildProcessSpawner, (spawner) => + ChildProcessSpawner.make((command) => { + spawnedCommands.push((command as { readonly command: string }).command); + return spawner.spawn(command); + }), + ), Layer.provideMerge(NodeServices.layer), ); const runtimeServices = yield* Layer.build(providerRegistryLayer).pipe( @@ -1199,10 +1202,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest(), T const registry = yield* ProviderRegistry; // Boot-time probe: the default codex instance is enabled with // `firstMissing`, so the real spawner yields ENOENT and the - // snapshot should be `status: "error"`. What *distinguishes* - // the two probe runs is `checkedAt` — each probe stamps a - // fresh DateTime, so we capture it and assert it advances - // after the settings mutation. + // snapshot should be `status: "error"`. let initialProviders = yield* registry.getProviders; for ( let attempts = 0; @@ -1220,13 +1220,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest(), T ); assert.strictEqual(initialCodex?.status, "error"); assert.strictEqual(initialCodex?.installed, false); - const initialCheckedAt = initialCodex?.checkedAt; - assert.notStrictEqual(initialCheckedAt, undefined); - - // The rebuilt instance may re-probe synchronously during the - // settings update. Advance the TestClock first so `checkedAt` - // can safely act as the fresh-probe marker this assertion uses. - yield* TestClock.adjust("1 second"); + assert.deepStrictEqual(spawnedCommands, [firstMissing]); // Drive a settings change. The Hydration layer's // `SettingsWatcherLive` consumes this via `streamChanges`, @@ -1242,8 +1236,9 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest(), T }, }); - // Poll with TestClock until `checkedAt` advances or we hit a - // generous virtual 3-second ceiling. + // Poll until the injected process boundary observes the new + // executable. This verifies the public settings-to-probe behavior + // without depending on timestamps assigned by TestClock. const refreshed = yield* Effect.gen(function* () { for (let attempts = 0; attempts < 60; attempts += 1) { const providers = yield* registry.getProviders; @@ -1251,7 +1246,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest(), T if ( codex !== undefined && codex.status === "error" && - codex.checkedAt !== initialCheckedAt + spawnedCommands.includes(secondMissing) ) { return providers; } @@ -1262,11 +1257,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest(), T }); const reprobedCodex = refreshed.find((provider) => provider.instanceId === "codex"); - assert.notStrictEqual( - reprobedCodex?.checkedAt, - initialCheckedAt, - "Expected a fresh probe after settings change, got the stale snapshot", - ); + assert.deepStrictEqual(spawnedCommands, [firstMissing, secondMissing]); assert.strictEqual(reprobedCodex?.status, "error"); assert.strictEqual(reprobedCodex?.installed, false); }).pipe(Effect.provide(runtimeServices)); diff --git a/apps/server/src/provider/acp/AcpSessionRuntime.ts b/apps/server/src/provider/acp/AcpSessionRuntime.ts index 47a8c845e56..b8097f10b75 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 { resolveSpawnCommand } from "@t3tools/shared/shell"; import { collectSessionConfigOptionValues, @@ -201,12 +202,17 @@ const makeAcpSessionRuntime = ( ), ); + const spawnCommand = yield* resolveSpawnCommand( + options.spawn.command, + options.spawn.args, + options.spawn.env ? { env: options.spawn.env, extendEnv: true } : {}, + ); const child = yield* spawner .spawn( - ChildProcess.make(options.spawn.command, [...options.spawn.args], { + ChildProcess.make(spawnCommand.command, spawnCommand.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: options.spawn.env, extendEnv: true } : {}), + shell: spawnCommand.shell, }), ) .pipe( diff --git a/apps/server/src/provider/opencodeRuntime.ts b/apps/server/src/provider/opencodeRuntime.ts index 9c48e441032..365884da85d 100644 --- a/apps/server/src/provider/opencodeRuntime.ts +++ b/apps/server/src/provider/opencodeRuntime.ts @@ -31,6 +31,8 @@ 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 { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import { resolveSpawnCommand } from "@t3tools/shared/shell"; const encodeUnknownJsonStringExit = Schema.encodeUnknownExit(Schema.UnknownFromJsonString); const OPENCODE_EMPTY_CONFIG_CONTENT = "{}"; @@ -276,13 +278,17 @@ function ensureRuntimeError( const makeOpenCodeRuntime = Effect.gen(function* () { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const netService = yield* NetService.NetService; + const hostPlatform = yield* HostProcessPlatform; + const resolveCommand = (command: string, args: ReadonlyArray, env?: NodeJS.ProcessEnv) => + resolveSpawnCommand(command, args, env ? { env } : {}); const runOpenCodeCommand: OpenCodeRuntimeShape["runOpenCodeCommand"] = (input) => Effect.gen(function* () { + const spawnCommand = yield* resolveCommand(input.binaryPath, input.args, input.environment); const child = yield* spawner.spawn( - ChildProcess.make(input.binaryPath, [...input.args], { - shell: process.platform === "win32", - env: input.environment ?? process.env, + ChildProcess.make(spawnCommand.command, spawnCommand.args, { + shell: spawnCommand.shell, + ...(input.environment ? { env: input.environment } : { extendEnv: true }), }), ); const [stdout, stderr, code] = yield* Effect.all( @@ -290,7 +296,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`, @@ -334,16 +340,18 @@ const makeOpenCodeRuntime = Effect.gen(function* () { )); const timeoutMs = input.timeoutMs ?? DEFAULT_OPENCODE_SERVER_TIMEOUT_MS; const args = ["serve", `--hostname=${hostname}`, `--port=${port}`]; + const spawnCommand = yield* resolveCommand(input.binaryPath, args, input.environment); const child = yield* spawner .spawn( - ChildProcess.make(input.binaryPath, args, { - detached: process.platform !== "win32", - shell: process.platform === "win32", + ChildProcess.make(spawnCommand.command, spawnCommand.args, { + detached: hostPlatform !== "win32", + shell: spawnCommand.shell, env: { - ...(input.environment ?? process.env), + ...input.environment, OPENCODE_CONFIG_CONTENT: OPENCODE_EMPTY_CONFIG_CONTENT, }, + extendEnv: input.environment === undefined, }), ) .pipe( @@ -359,7 +367,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..c4ad2fa7509 100644 --- a/apps/server/src/provider/providerMaintenance.test.ts +++ b/apps/server/src/provider/providerMaintenance.test.ts @@ -1,19 +1,22 @@ // @effect-diagnostics nodeBuiltinImport:off -import { afterEach, expect, it } from "@effect/vitest"; +import { expect, it } from "@effect/vitest"; import { chmodSync, mkdirSync, symlinkSync, writeFileSync } from "node:fs"; import * as NodeServices from "@effect/platform-node/NodeServices"; -import 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 { HttpClient } from "effect/unstable/http"; import { - clearLatestProviderVersionCacheForTests, createProviderVersionAdvisory, makePackageManagedProviderMaintenanceResolver, makeProviderMaintenanceCapabilities, makeStaticProviderMaintenanceResolver, normalizeCommandPath, + ProviderVersionCache, + resolveLatestProviderVersion, resolveProviderMaintenanceCapabilitiesEffect, } from "./providerMaintenance.ts"; @@ -21,7 +24,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) => @@ -65,11 +68,33 @@ const staticToolUpdate = makeStaticProviderMaintenanceResolver( }), ); -afterEach(() => { - clearLatestProviderVersionCacheForTests(); -}); - it.layer(NodeServices.layer)("providerMaintenance", (it) => { + it.effect("reads cached versions through the injectable cache reference", () => + resolveLatestProviderVersion(packageToolUpdate.resolve()).pipe( + Effect.provideService( + ProviderVersionCache, + new Map([ + [ + "@example/package-tool", + { + expiresAt: Number.MAX_SAFE_INTEGER, + version: "9.9.9", + }, + ], + ]), + ), + Effect.provideService( + HttpClient.HttpClient, + HttpClient.make(() => + Effect.die("cached provider version should not make an HTTP request"), + ), + ), + Effect.map((version) => { + expect(version).toBe("9.9.9"); + }), + ), + ); + it("marks providers with unknown current versions as unknown", () => { expect( createProviderVersionAdvisory({ @@ -144,15 +169,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", - platform: "darwin", env: { PATH: vitePlusBinDir, }, - }), - ).toEqual({ + }, + ).pipe(Effect.provideService(HostProcessPlatform, "darwin")); + + expect(capabilities).toEqual({ provider: driver("packageTool"), packageName: "@example/package-tool", update: { @@ -177,16 +204,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", - platform: "win32", env: { PATH: bunBinDir, PATHEXT: ".COM;.EXE;.BAT;.CMD", }, - }), - ).toEqual({ + }, + ).pipe(Effect.provideService(HostProcessPlatform, "win32")); + + expect(capabilities).toEqual({ provider: driver("nativePackageTool"), packageName: "@example/native-package-tool", update: { @@ -213,15 +242,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", - platform: "darwin", env: { PATH: pnpmHomeDir, }, - }), - ).toEqual({ + }, + ).pipe(Effect.provideService(HostProcessPlatform, "darwin")); + + expect(capabilities).toEqual({ provider: driver("scopedPackageTool"), packageName: "@example/scoped-package-tool", update: { @@ -241,7 +272,6 @@ it.layer(NodeServices.layer)("providerMaintenance", (it) => { expect( packageToolUpdate.resolve({ binaryPath: "/opt/homebrew/bin/package-tool", - platform: "darwin", env: { PATH: "", }, @@ -272,15 +302,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", - platform: "darwin", env: { PATH: nativeBinDir, }, - }), - ).toEqual({ + }, + ).pipe(Effect.provideService(HostProcessPlatform, "darwin")); + + expect(capabilities).toEqual({ provider: driver("nativePackageTool"), packageName: "@example/native-package-tool", update: { @@ -307,15 +339,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", - platform: "darwin", env: { PATH: nativeBinDir, }, - }), - ).toEqual({ + }, + ).pipe(Effect.provideService(HostProcessPlatform, "darwin")); + + expect(capabilities).toEqual({ provider: driver("scopedPackageTool"), packageName: "@example/scoped-package-tool", update: { @@ -335,7 +369,6 @@ it.layer(NodeServices.layer)("providerMaintenance", (it) => { expect( nativePackageToolUpdate.resolve({ binaryPath: "/opt/homebrew/bin/native-package-tool", - platform: "darwin", env: { PATH: "", }, @@ -359,7 +392,6 @@ it.layer(NodeServices.layer)("providerMaintenance", (it) => { expect( scopedPackageToolUpdate.resolve({ binaryPath: "/opt/homebrew/bin/scoped-package-tool", - platform: "darwin", env: { PATH: "", }, @@ -401,7 +433,6 @@ it.layer(NodeServices.layer)("providerMaintenance", (it) => { const capabilities = yield* resolveProviderMaintenanceCapabilitiesEffect(packageToolUpdate, { binaryPath: symlinkPath, - platform: "darwin", env: { PATH: "", }, @@ -449,7 +480,6 @@ it.layer(NodeServices.layer)("providerMaintenance", (it) => { const capabilities = yield* resolveProviderMaintenanceCapabilitiesEffect(packageToolUpdate, { binaryPath: symlinkPath, - platform: "darwin", env: { PATH: "", }, @@ -475,7 +505,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..d1c4a7d6a71 100644 --- a/apps/server/src/provider/providerMaintenance.ts +++ b/apps/server/src/provider/providerMaintenance.ts @@ -5,6 +5,8 @@ import { } from "@t3tools/contracts"; import { compareSemverVersions } from "@t3tools/shared/semver"; import { resolveCommandPath } from "@t3tools/shared/shell"; +import * as Config from "effect/Config"; +import * as Context from "effect/Context"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; @@ -16,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; @@ -32,7 +53,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; } @@ -54,20 +75,21 @@ export interface PackageManagedProviderMaintenanceDefinition { } | null; } -interface LatestVersionCacheEntry { +export interface ProviderVersionCacheEntry { readonly expiresAt: number; readonly version: string | null; } -const latestVersionCache = new Map(); +export const ProviderVersionCache = Context.Reference>( + "@t3tools/server/providerMaintenance/ProviderVersionCache", + { + defaultValue: () => new Map(), + }, +); const NpmLatestVersionResponse = Schema.Struct({ version: Schema.optional(Schema.String), }); -export function clearLatestProviderVersionCacheForTests(): void { - latestVersionCache.clear(); -} - function nonEmptyString(value: unknown): string | null { return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; } @@ -251,10 +273,7 @@ export function resolvePackageManagedProviderMaintenance( } const resolvedCommandPath = - resolveCommandPath(binaryPath, { - ...(options?.platform ? { platform: options.platform } : {}), - ...(options?.env ? { env: options.env } : {}), - }) ?? (hasPathSeparator(binaryPath) ? binaryPath : null); + options?.resolvedCommandPath ?? (hasPathSeparator(binaryPath) ? binaryPath : null); if (resolvedCommandPath) { const commandPaths = [ @@ -335,11 +354,11 @@ export const resolveProviderMaintenanceCapabilitiesEffect = Effect.fn( return resolver.resolve(options); } + const env = options?.env ?? (yield* readCommandLookupEnv); const resolvedCommandPath = - resolveCommandPath(binaryPath, { - ...(options?.platform ? { platform: options.platform } : {}), - ...(options?.env ? { env: options.env } : {}), - }) ?? (hasPathSeparator(binaryPath) ? binaryPath : null); + (yield* resolveCommandPath(binaryPath, { env }).pipe( + Effect.catchTag("CommandResolutionError", () => Effect.succeed(null)), + )) ?? (hasPathSeparator(binaryPath) ? binaryPath : null); if (!resolvedCommandPath) { return resolver.resolve(options); } @@ -350,6 +369,8 @@ export const resolveProviderMaintenanceCapabilitiesEffect = Effect.fn( .pipe(Effect.orElseSucceed(() => resolvedCommandPath)); return resolver.resolve({ ...options, + env, + resolvedCommandPath, realCommandPath, }); }); @@ -430,6 +451,7 @@ export const resolveLatestProviderVersion = Effect.fn("resolveLatestProviderVers return null; } + const latestVersionCache = yield* ProviderVersionCache; const cached = latestVersionCache.get(packageName); const now = DateTime.toEpochMillis(yield* DateTime.now); if (cached && cached.expiresAt > now) { diff --git a/apps/server/src/provider/providerMaintenanceRunner.test.ts b/apps/server/src/provider/providerMaintenanceRunner.test.ts index 5f5f975a4e3..5ffb69cd5f7 100644 --- a/apps/server/src/provider/providerMaintenanceRunner.test.ts +++ b/apps/server/src/provider/providerMaintenanceRunner.test.ts @@ -1,4 +1,4 @@ -import { afterEach, describe, it, assert } from "@effect/vitest"; +import { describe, it, assert } from "@effect/vitest"; import { ProviderDriverKind, ProviderInstanceId, @@ -21,8 +21,8 @@ import { ChildProcessSpawner } from "effect/unstable/process"; import { ProviderRegistry, type ProviderRegistryShape } from "./Services/ProviderRegistry.ts"; import * as ProviderMaintenanceRunner from "./providerMaintenanceRunner.ts"; import { - clearLatestProviderVersionCacheForTests, makeProviderMaintenanceCapabilities, + ProviderVersionCache, type ProviderMaintenanceCapabilities, } from "./providerMaintenance.ts"; const isServerProviderUpdateError = Schema.is(ServerProviderUpdateError); @@ -35,10 +35,6 @@ const CURSOR_INSTANCE_ID = ProviderInstanceId.make("cursor"); const OPENCODE_INSTANCE_ID = ProviderInstanceId.make("opencode"); const encoder = new TextEncoder(); -afterEach(() => { - clearLatestProviderVersionCacheForTests(); -}); - function lifecycleFor(provider: ProviderDriverKind): ProviderMaintenanceCapabilities { if (provider === CURSOR_DRIVER) { return makeProviderMaintenanceCapabilities({ @@ -202,7 +198,12 @@ const makeTestRunner = (registry: ProviderRegistryShape) => Effect.service(ProviderMaintenanceRunner.ProviderMaintenanceRunner).pipe( Effect.provide( ProviderMaintenanceRunner.layer.pipe( - Layer.provide(Layer.succeed(ProviderRegistry, registry)), + Layer.provide( + Layer.mergeAll( + Layer.succeed(ProviderRegistry, registry), + Layer.succeed(ProviderVersionCache, new Map()), + ), + ), ), ), ); diff --git a/apps/server/src/provider/providerSnapshot.ts b/apps/server/src/provider/providerSnapshot.ts index ce43c5e6eab..2ecb3220773 100644 --- a/apps/server/src/provider/providerSnapshot.ts +++ b/apps/server/src/provider/providerSnapshot.ts @@ -75,7 +75,7 @@ export const spawnAndCollect = (binaryPath: string, command: ChildProcess.Comman ); const result: CommandResult = { stdout, stderr, code: exitCode }; - if (isWindowsCommandNotFound(exitCode, stderr)) { + if (yield* isWindowsCommandNotFound(exitCode, stderr)) { return yield* new ProviderCommandExecutionError({ message: `spawn ${binaryPath} ENOENT` }); } return result; diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 40c9c7cd9a8..095e82f948e 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -579,6 +579,7 @@ const buildAppUnderTest = (options?: { ), Layer.provide( Layer.mock(ExternalLauncher.ExternalLauncher)({ + resolveAvailableEditors: () => Effect.succeed([]), ...options?.layers?.externalLauncher, }), ), diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 3a95f906866..cd4e114879c 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -365,7 +365,7 @@ export const makeServerLayer = Layer.unwrap( Effect.gen(function* () { const config = yield* ServerConfig; - fixPath(); + yield* fixPath(); const httpListeningLayer = Layer.effectDiscard( Effect.gen(function* () { diff --git a/apps/server/src/startupAccess.ts b/apps/server/src/startupAccess.ts index 7a03dafaefe..7df131669ba 100644 --- a/apps/server/src/startupAccess.ts +++ b/apps/server/src/startupAccess.ts @@ -1,4 +1,4 @@ -import { networkInterfaces } from "node:os"; +import * as NodeOS from "node:os"; import { QrCode } from "@t3tools/shared/qrCode"; import * as Effect from "effect/Effect"; @@ -13,7 +13,7 @@ export interface HeadlessServeAccessInfo { readonly pairingUrl: string; } -type NetworkInterfacesMap = ReturnType; +type NetworkInterfacesMap = ReturnType; export const isLoopbackHost = (host: string | undefined): boolean => { if (!host || host.length === 0) { @@ -44,7 +44,7 @@ const isIpv6Family = (family: string | number): boolean => family === "IPv6" || export const resolveHeadlessConnectionHost = ( host: string | undefined, - interfaces: NetworkInterfacesMap = networkInterfaces(), + interfaces: NetworkInterfacesMap = NodeOS.networkInterfaces(), ): string => { if (!host) { return "localhost"; @@ -71,7 +71,7 @@ export const resolveHeadlessConnectionHost = ( export const resolveHeadlessConnectionString = ( host: string | undefined, port: number, - interfaces: NetworkInterfacesMap = networkInterfaces(), + interfaces: NetworkInterfacesMap = NodeOS.networkInterfaces(), ): string => { const connectionHost = resolveHeadlessConnectionHost(host, interfaces); return `http://${formatHostForUrl(connectionHost)}:${port}`; diff --git a/apps/server/src/telemetry/Identify.ts b/apps/server/src/telemetry/Identify.ts index da04bd0b266..364273a9e1d 100644 --- a/apps/server/src/telemetry/Identify.ts +++ b/apps/server/src/telemetry/Identify.ts @@ -1,10 +1,11 @@ +import * as NodeOS from "node:os"; import * as Crypto from "effect/Crypto"; import * as Effect from "effect/Effect"; import * as Encoding from "effect/Encoding"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; -import { homedir } from "node:os"; + import { ServerConfig } from "../config.ts"; const CodexAuthJsonSchema = Schema.Struct({ @@ -39,7 +40,7 @@ const getCodexAccountId = Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; - const authJsonPath = path.join(homedir(), ".codex", "auth.json"); + const authJsonPath = path.join(NodeOS.homedir(), ".codex", "auth.json"); const authJson = yield* Effect.flatMap( fileSystem.readFileString(authJsonPath), Schema.decodeEffect(Schema.fromJsonString(CodexAuthJsonSchema)), @@ -52,7 +53,7 @@ const getClaudeUserId = Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; - const claudeJsonPath = path.join(homedir(), ".claude.json"); + const claudeJsonPath = path.join(NodeOS.homedir(), ".claude.json"); const claudeJson = yield* Effect.flatMap( fileSystem.readFileString(claudeJsonPath), Schema.decodeEffect(Schema.fromJsonString(ClaudeJsonSchema)), diff --git a/apps/server/src/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/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 30515ca2c47..8b5aa3adbcd 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 { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import * as Data from "effect/Data"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; @@ -199,7 +200,6 @@ const multiTerminalHistoryLogPath = ( interface CreateManagerOptions { shellResolver?: () => string; - platform?: NodeJS.Platform; env?: NodeJS.ProcessEnv; subprocessInspector?: (terminalPid: number) => Effect.Effect<{ readonly hasRunningSubprocess: boolean; @@ -240,7 +240,6 @@ const createManager = ( historyLineLimit, ptyAdapter, ...(options.shellResolver !== undefined ? { shellResolver: options.shellResolver } : {}), - ...(options.platform !== undefined ? { platform: options.platform } : {}), ...(options.env !== undefined ? { env: options.env } : {}), ...(options.subprocessInspector !== undefined ? { subprocessInspector: options.subprocessInspector } @@ -270,12 +269,13 @@ const createManager = ( }), ); +const withHostPlatform = (platform: NodeJS.Platform) => + Layer.succeed(HostProcessPlatform, platform); + 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(); @@ -415,8 +415,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(); @@ -1082,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, }); @@ -1096,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) => @@ -1121,13 +1122,12 @@ 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", }, - }); + }).pipe(Effect.provide(withHostPlatform("win32"))); yield* manager.open(openInput()); @@ -1142,15 +1142,16 @@ 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", + const ptyAdapter = new FakePtyAdapter(); + const { manager } = yield* createManager(5, { + ptyAdapter, + shellResolver: () => "C:\\missing\\custom-shell.exe", env: { ComSpec: "C:\\Windows\\System32\\cmd.exe", PATH: "C:\\Windows\\System32", SystemRoot: "C:\\Windows", }, - shellResolver: () => "C:\\missing\\custom-shell.exe", - }); + }).pipe(Effect.provide(withHostPlatform("win32"))); ptyAdapter.spawnFailures.push( new Error("spawn custom-shell.exe ENOENT"), new Error("spawn pwsh.exe ENOENT"), @@ -1170,46 +1171,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(5, { + env: { + PORT: "5173", + T3CODE_PORT: "3773", + VITE_DEV_SERVER_URL: "http://localhost:5173", + TEST_TERMINAL_KEEP: "keep-me", + }, + }); + yield* manager.open(openInput()); + const spawnInput = ptyAdapter.spawnInputs[0]; + expect(spawnInput).toBeDefined(); + 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(); + // Arbitrary host env vars must pass through — terminals inherit the + // user's environment apart from the explicit blocklist. + expect(spawnInput.env.TEST_TERMINAL_KEEP).toBe("keep-me"); }), ); @@ -1237,7 +1217,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/Manager.ts b/apps/server/src/terminal/Layers/Manager.ts index f2b466a4390..e33d9b4b290 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 { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import { getTerminalLabel } from "@t3tools/shared/terminalLabels"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; @@ -312,10 +313,7 @@ function enqueueProcessEvent( return true; } -function defaultShellResolver( - platform: NodeJS.Platform = process.platform, - env: NodeJS.ProcessEnv = process.env, -): string { +function defaultShellResolver(platform: NodeJS.Platform, env: NodeJS.ProcessEnv): string { if (platform === "win32") { return "pwsh.exe"; } @@ -324,7 +322,7 @@ function defaultShellResolver( function normalizeShellCommand( value: string | undefined, - platform: NodeJS.Platform = process.platform, + platform: NodeJS.Platform, ): string | null { if (!value) return null; const trimmed = value.trim(); @@ -360,7 +358,7 @@ function joinWindowsPath(...parts: ReadonlyArray): string { function shellCandidateFromCommand( command: string | null, - platform: NodeJS.Platform = process.platform, + platform: NodeJS.Platform, ): ShellCandidate | null { if (!command || command.length === 0) return null; const shellName = basenameForPlatform(command, platform).toLowerCase(); @@ -411,8 +409,8 @@ function uniqueShellCandidates(candidates: Array): ShellC function resolveShellCandidates( shellResolver: () => string, - platform: NodeJS.Platform = process.platform, - env: NodeJS.ProcessEnv = process.env, + platform: NodeJS.Platform, + env: NodeJS.ProcessEnv, ): ShellCandidate[] { const requested = shellCandidateFromCommand( normalizeShellCommand(shellResolver(), platform), @@ -512,6 +510,9 @@ function windowsInspectSubprocess( return Effect.gen(function* () { const processRunner = yield* ProcessRunner.ProcessRunner; return yield* processRunner.run({ + // powershell.exe is a real executable — never spawn it through cmd.exe + // shell mode, which would re-tokenize the `-Command` payload (pipes, + // semicolons) before PowerShell ever sees it. command: "powershell.exe", args: ["-NoProfile", "-NonInteractive", "-Command", command], timeout: "1500 millis", @@ -976,7 +977,6 @@ interface TerminalManagerOptions { historyLineLimit?: number; ptyAdapter: PtyAdapterShape; shellResolver?: () => string; - platform?: NodeJS.Platform; env?: NodeJS.ProcessEnv; subprocessInspector?: TerminalSubprocessInspector; subprocessPollIntervalMs?: number; @@ -1014,7 +1014,11 @@ 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 platform = yield* HostProcessPlatform; + // Terminals must inherit the user's full environment (minus the blocklist + // applied in createTerminalSpawnEnv) — an allowlist here silently strips + // things like PSModulePath, DISPLAY, proxies, and toolchain variables. + // `options.env` is the test seam. const baseEnv = options.env ?? process.env; const shellResolver = options.shellResolver ?? (() => defaultShellResolver(platform, baseEnv)); const processRunner = yield* ProcessRunner.ProcessRunner; diff --git a/apps/server/src/terminal/Layers/NodePTY.test.ts b/apps/server/src/terminal/Layers/NodePTY.test.ts index 15d24360f7e..46840214b66 100644 --- a/apps/server/src/terminal/Layers/NodePTY.test.ts +++ b/apps/server/src/terminal/Layers/NodePTY.test.ts @@ -1,47 +1,58 @@ -import * as FileSystem from "effect/FileSystem"; -import * as Path from "effect/Path"; -import * as Effect from "effect/Effect"; -import { assert, it } from "@effect/vitest"; - -import { ensureNodePtySpawnHelperExecutable } from "./NodePTY.ts"; import * as NodeServices from "@effect/platform-node/NodeServices"; - -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; - - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - - const dir = yield* fs.makeTempDirectoryScoped({ prefix: "pty-helper-test-" }); - const helperPath = path.join(dir, "spawn-helper"); - yield* fs.writeFileString(helperPath, "#!/bin/sh\nexit 0\n"); - yield* fs.chmod(helperPath, 0o644); - - yield* ensureNodePtySpawnHelperExecutable(helperPath); - - const mode = (yield* fs.stat(helperPath)).mode & 0o777; - assert.equal(mode & 0o111, 0o111); - }), - ); - - it.effect("keeps executable helper as executable", () => - Effect.gen(function* () { - if (process.platform === "win32") return; - - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - - const dir = yield* fs.makeTempDirectoryScoped({ prefix: "pty-helper-test-" }); - const helperPath = path.join(dir, "spawn-helper"); - yield* fs.writeFileString(helperPath, "#!/bin/sh\nexit 0\n"); - yield* fs.chmod(helperPath, 0o755); - - yield* ensureNodePtySpawnHelperExecutable(helperPath); - - const mode = (yield* fs.stat(helperPath)).mode & 0o777; - assert.equal(mode & 0o111, 0o111); - }), - ); -}); +import { assert, it } from "@effect/vitest"; +import { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import { vi } from "vite-plus/test"; + +import { PtyAdapter } from "../Services/PTY.ts"; +import { layer } from "./NodePTY.ts"; + +const spawn = vi.fn(() => ({ + pid: 42, + write: vi.fn(), + resize: vi.fn(), + kill: vi.fn(), + onData: vi.fn(() => ({ dispose: vi.fn() })), + onExit: vi.fn(() => ({ dispose: vi.fn() })), +})); + +vi.mock("node-pty", () => ({ spawn })); + +const testLayer = layer.pipe( + Layer.provide( + Layer.mergeAll( + NodeServices.layer, + Layer.succeed(HostProcessPlatform, "win32"), + Layer.succeed(HostProcessArchitecture, "x64"), + ), + ), +); + +it.effect("spawns through the public adapter with the provided host references", () => + Effect.gen(function* () { + const adapter = yield* PtyAdapter; + const process = yield* adapter.spawn({ + shell: "powershell.exe", + args: ["-NoLogo"], + cwd: "C:\\workspace", + cols: 120, + rows: 40, + env: {}, + }); + + assert.equal(process.pid, 42); + assert.equal(spawn.mock.calls.length, 1); + assert.deepEqual(spawn.mock.calls[0], [ + "powershell.exe", + ["-NoLogo"], + { + cwd: "C:\\workspace", + cols: 120, + rows: 40, + env: {}, + name: "xterm-color", + }, + ]); + }).pipe(Effect.provide(testLayer)), +); diff --git a/apps/server/src/terminal/Layers/NodePTY.ts b/apps/server/src/terminal/Layers/NodePTY.ts index c81d76f5d1e..2b19fe4ac51 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) { @@ -35,16 +38,15 @@ const resolveNodePtySpawnHelperPath = Effect.gen(function* () { return null; }).pipe(Effect.orElseSucceed(() => null)); -export const ensureNodePtySpawnHelperExecutable = Effect.fn(function* (explicitPath?: string) { +const ensureNodePtySpawnHelperExecutable = Effect.fn(function* () { const fs = yield* FileSystem.FileSystem; - if (process.platform === "win32") return; - if (!explicitPath && didEnsureSpawnHelperExecutable) return; + const platform = yield* HostProcessPlatform; + if (platform === "win32") return; + if (didEnsureSpawnHelperExecutable) return; - const helperPath = explicitPath ?? (yield* resolveNodePtySpawnHelperPath); + const helperPath = yield* resolveNodePtySpawnHelperPath; if (!helperPath) return; - if (!explicitPath) { - didEnsureSpawnHelperExecutable = true; - } + didEnsureSpawnHelperExecutable = true; if (!(yield* fs.exists(helperPath))) { return; @@ -102,6 +104,8 @@ export const layer = Layer.effect( Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; + const platform = yield* HostProcessPlatform; + const architecture = yield* HostProcessArchitecture; const nodePty = yield* Effect.promise(() => import("node-pty")); @@ -109,6 +113,8 @@ export const layer = Layer.effect( ensureNodePtySpawnHelperExecutable().pipe( Effect.provideService(FileSystem.FileSystem, fs), Effect.provideService(Path.Path, path), + Effect.provideService(HostProcessPlatform, platform), + Effect.provideService(HostProcessArchitecture, architecture), Effect.orElseSucceed(() => undefined), ), ); @@ -123,7 +129,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/textGeneration/ClaudeTextGeneration.ts b/apps/server/src/textGeneration/ClaudeTextGeneration.ts index c06a0bfc560..91ad90b786e 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 { resolveSpawnCommand } from "@t3tools/shared/shell"; import { TextGenerationError } from "@t3tools/contracts"; import { type TextGenerationShape } from "./TextGeneration.ts"; @@ -59,7 +60,7 @@ 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); @@ -156,7 +157,7 @@ export const makeClaudeTextGeneration = Effect.fn("makeClaudeTextGeneration")(fu : undefined; const runClaudeCommand = Effect.fn("runClaudeJson.runClaudeCommand")(function* () { - const command = ChildProcess.make( + const spawnCommand = yield* resolveSpawnCommand( claudeSettings.binaryPath || "claude", [ "-p", @@ -170,15 +171,16 @@ export const makeClaudeTextGeneration = Effect.fn("makeClaudeTextGeneration")(fu ...(settingsJson ? ["--settings", settingsJson] : []), "--dangerously-skip-permissions", ], - { - env: claudeEnvironment, - cwd, - shell: process.platform === "win32", - stdin: { - stream: Stream.encodeText(Stream.make(prompt)), - }, - }, + { env: claudeEnvironment }, ); + const command = ChildProcess.make(spawnCommand.command, spawnCommand.args, { + env: claudeEnvironment, + cwd, + shell: spawnCommand.shell, + stdin: { + stream: Stream.encodeText(Stream.make(prompt)), + }, + }); const child = yield* commandSpawner .spawn(command) diff --git a/apps/server/src/textGeneration/CodexTextGeneration.ts b/apps/server/src/textGeneration/CodexTextGeneration.ts index bebd0acf800..80b39af2584 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 { resolveSpawnCommand } from "@t3tools/shared/shell"; import { resolveAttachmentPath } from "../attachmentStore.ts"; import { ServerConfig } from "../config.ts"; @@ -44,12 +45,13 @@ 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 resolvedEnvironment = environment ?? process.env; type MaterializedImageAttachments = { readonly imagePaths: ReadonlyArray; @@ -180,7 +182,7 @@ export const makeCodexTextGeneration = Effect.fn("makeCodexTextGeneration")(func getModelSelectionStringOptionValue(modelSelection, "reasoningEffort") ?? CODEX_GIT_TEXT_GENERATION_REASONING_EFFORT; const serviceTier = getCodexServiceTierOptionValue(modelSelection); - const command = ChildProcess.make( + const spawnCommand = yield* resolveSpawnCommand( codexConfig.binaryPath || "codex", [ "exec", @@ -200,18 +202,19 @@ export const makeCodexTextGeneration = Effect.fn("makeCodexTextGeneration")(func ...imagePaths.flatMap((imagePath) => ["--image", imagePath]), "-", ], - { - env: { - ...environment, - ...(codexConfig.homePath ? { CODEX_HOME: expandHomePath(codexConfig.homePath) } : {}), - }, - cwd, - shell: process.platform === "win32", - stdin: { - stream: Stream.encodeText(Stream.make(prompt)), - }, - }, + { env: resolvedEnvironment }, ); + const command = ChildProcess.make(spawnCommand.command, spawnCommand.args, { + env: { + ...resolvedEnvironment, + ...(codexConfig.homePath ? { CODEX_HOME: expandHomePath(codexConfig.homePath) } : {}), + }, + cwd, + shell: spawnCommand.shell, + stdin: { + stream: Stream.encodeText(Stream.make(prompt)), + }, + }); const child = yield* commandSpawner .spawn(command) diff --git a/apps/server/src/textGeneration/CursorTextGeneration.ts b/apps/server/src/textGeneration/CursorTextGeneration.ts index c4ef1af21d1..6d72178b8ae 100644 --- a/apps/server/src/textGeneration/CursorTextGeneration.ts +++ b/apps/server/src/textGeneration/CursorTextGeneration.ts @@ -59,9 +59,10 @@ 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 resolvedEnvironment = environment ?? process.env; const runCursorJson = ({ operation, @@ -84,7 +85,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..65d3854e945 100644 --- a/apps/server/src/textGeneration/OpenCodeTextGeneration.ts +++ b/apps/server/src/textGeneration/OpenCodeTextGeneration.ts @@ -99,10 +99,11 @@ 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 resolvedEnvironment = environment ?? process.env; const idleFiberScope = yield* Effect.acquireRelease(Scope.make(), (scope) => Scope.close(scope, Exit.void), ); @@ -208,7 +209,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/workspace/Layers/WorkspaceEntries.test.ts b/apps/server/src/workspace/Layers/WorkspaceEntries.test.ts index ffee4d56a52..7b25f169e72 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"]); @@ -402,7 +406,7 @@ it.layer(TestLayer)("WorkspaceEntriesLive", (it) => { vi.spyOn(fsPromises, "readdir").mockRejectedValueOnce(denied); const result = yield* workspaceEntries.browse({ - partialPath: appendSeparator(cwd), + partialPath: yield* appendSeparator(cwd), }); expect(result).toEqual({ parentPath: cwd, entries: [] }); }), diff --git a/apps/server/src/workspace/Layers/WorkspaceEntries.ts b/apps/server/src/workspace/Layers/WorkspaceEntries.ts index 95d957136b7..2903fd3a8e4 100644 --- a/apps/server/src/workspace/Layers/WorkspaceEntries.ts +++ b/apps/server/src/workspace/Layers/WorkspaceEntries.ts @@ -1,5 +1,5 @@ // @effect-diagnostics nodeBuiltinImport:off -import * as OS from "node:os"; +import * as NodeOS from "node:os"; import fsPromises from "node:fs/promises"; import type { Dirent } from "node:fs"; @@ -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, @@ -65,10 +66,10 @@ function toPosixPath(input: string): string { function expandHomePath(input: string, path: Path.Path): string { if (input === "~") { - return OS.homedir(); + return NodeOS.homedir(); } if (input.startsWith("~/") || input.startsWith("~\\")) { - return path.join(OS.homedir(), input.slice(2)); + return path.join(NodeOS.homedir(), input.slice(2)); } return input; } @@ -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/apps/server/src/workspace/Layers/WorkspacePaths.ts b/apps/server/src/workspace/Layers/WorkspacePaths.ts index f994aa875ef..dfe02e8f67c 100644 --- a/apps/server/src/workspace/Layers/WorkspacePaths.ts +++ b/apps/server/src/workspace/Layers/WorkspacePaths.ts @@ -1,4 +1,4 @@ -import * as OS from "node:os"; +import * as NodeOS from "node:os"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; @@ -19,10 +19,10 @@ function toPosixRelativePath(input: string): string { function expandHomePath(input: string, path: Path.Path): string { if (input === "~") { - return OS.homedir(); + return NodeOS.homedir(); } if (input.startsWith("~/") || input.startsWith("~\\")) { - return path.join(OS.homedir(), input.slice(2)); + return path.join(NodeOS.homedir(), input.slice(2)); } return input; } diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 2823923e033..175bf3248b8 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -761,7 +761,7 @@ const makeWsRpcLayer = (currentSession: AuthenticatedSession) => keybindings: keybindingsConfig.keybindings, issues: keybindingsConfig.issues, providers, - availableEditors: ExternalLauncher.resolveAvailableEditors(), + availableEditors: yield* externalLauncher.resolveAvailableEditors(), observability: { logsDirectoryPath: config.logsDir, localTracingEnabled: true, diff --git a/oxlint-plugin-t3code/index.ts b/oxlint-plugin-t3code/index.ts index 7fd0565cf1a..b8db9e16a36 100644 --- a/oxlint-plugin-t3code/index.ts +++ b/oxlint-plugin-t3code/index.ts @@ -1,5 +1,6 @@ import { definePlugin } from "@oxlint/plugins"; +import noGlobalProcessRuntime from "./rules/no-global-process-runtime.ts"; import noInlineSchemaCompile from "./rules/no-inline-schema-compile.ts"; import noManualEffectRuntimeInTests from "./rules/no-manual-effect-runtime-in-tests.ts"; @@ -8,6 +9,7 @@ export default definePlugin({ name: "t3code", }, rules: { + "no-global-process-runtime": noGlobalProcessRuntime, "no-inline-schema-compile": noInlineSchemaCompile, "no-manual-effect-runtime-in-tests": noManualEffectRuntimeInTests, }, diff --git a/oxlint-plugin-t3code/rules/no-global-process-runtime.test.ts b/oxlint-plugin-t3code/rules/no-global-process-runtime.test.ts new file mode 100644 index 00000000000..dc9cf6979a7 --- /dev/null +++ b/oxlint-plugin-t3code/rules/no-global-process-runtime.test.ts @@ -0,0 +1,94 @@ +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.valid( + "allows unrelated node os imports", + ` + import { tmpdir } from "node:os"; + + export const tempDirectory = tmpdir(); + `, + ); + + 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"; + `, + ); + + 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 new file mode 100644 index 00000000000..e364d29040f --- /dev/null +++ b/oxlint-plugin-t3code/rules/no-global-process-runtime.ts @@ -0,0 +1,144 @@ +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 NODE_OS_MODULES = new Set(["node:os", "os"]); + +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 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.`; + +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", + docs: { + description: + "Disallow direct host runtime platform/architecture reads outside the shared host process references.", + }, + }, + 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; + + 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/test/examples/cursor-acp-client.example.ts b/packages/effect-acp/test/examples/cursor-acp-client.example.ts index f730c3dbde0..b7a146cf5c2 100644 --- a/packages/effect-acp/test/examples/cursor-acp-client.example.ts +++ b/packages/effect-acp/test/examples/cursor-acp-client.example.ts @@ -11,7 +11,7 @@ const program = Effect.gen(function* () { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const command = ChildProcess.make("cursor-agent", ["acp"], { cwd: process.cwd(), - shell: process.platform === "win32", + shell: false, }); const handle = yield* spawner.spawn(command); const acpLayer = AcpClient.layerChildProcess(handle, { diff --git a/packages/effect-codex-app-server/src/client.test.ts b/packages/effect-codex-app-server/src/client.test.ts index 8d301742a50..3830c5fc5f6 100644 --- a/packages/effect-codex-app-server/src/client.test.ts +++ b/packages/effect-codex-app-server/src/client.test.ts @@ -17,13 +17,14 @@ const mockPeerPath = Effect.map(Effect.service(Path.Path), (path) => const mockPeerArgs = (path: string) => [path]; it.layer(NodeServices.layer)("effect-codex-app-server client", (it) => { - const makeHandle = () => + const makeHandle = (env?: Record) => Effect.gen(function* () { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const path = yield* Path.Path; const peerCwd = path.join(import.meta.dirname, ".."); const command = ChildProcess.make(process.execPath, mockPeerArgs(yield* mockPeerPath), { cwd: peerCwd, + ...(env ? { env: { ...process.env, ...env } } : {}), }); return yield* spawner.spawn(command); }); @@ -122,49 +123,13 @@ it.layer(NodeServices.layer)("effect-codex-app-server client", (it) => { ]); }), ); - - it.effect("initializes a command-backed app-server client", () => + it.effect("drains child stderr so large diagnostics cannot block protocol responses", () => Effect.gen(function* () { - const path = yield* Path.Path; - const scope = yield* Scope.make(); - const clientLayer = CodexClient.layerCommand({ - command: "node", - args: mockPeerArgs(yield* mockPeerPath), - cwd: path.join(import.meta.dirname, ".."), + const handle = yield* makeHandle({ + CODEX_APP_SERVER_TEST_STDERR_BYTES: String(512 * 1024), }); - const context = yield* Layer.buildWithScope(clientLayer, scope); - - const initialized = yield* Effect.gen(function* () { - const client = yield* CodexClient.CodexAppServerClient; - return yield* client.request("initialize", { - clientInfo: { - name: "effect-codex-app-server-test", - title: "Effect Codex App Server Test", - version: "0.0.0", - }, - capabilities: { - experimentalApi: true, - optOutNotificationMethods: null, - }, - }); - }).pipe(Effect.provide(context), Effect.ensuring(Scope.close(scope, Exit.void))); - - assert.equal(initialized.userAgent, "mock-codex-app-server"); - }), - ); - - it.effect("drains command stderr so large diagnostics cannot block protocol responses", () => - Effect.gen(function* () { - const path = yield* Path.Path; const scope = yield* Scope.make(); - const clientLayer = CodexClient.layerCommand({ - command: "node", - args: mockPeerArgs(yield* mockPeerPath), - cwd: path.join(import.meta.dirname, ".."), - env: { - CODEX_APP_SERVER_TEST_STDERR_BYTES: String(512 * 1024), - }, - }); + const clientLayer = CodexClient.layerChildProcess(handle); const context = yield* Layer.buildWithScope(clientLayer, scope); const initialized = yield* Effect.gen(function* () { diff --git a/packages/effect-codex-app-server/src/client.ts b/packages/effect-codex-app-server/src/client.ts index edc7d8b6cf7..f031b48d19c 100644 --- a/packages/effect-codex-app-server/src/client.ts +++ b/packages/effect-codex-app-server/src/client.ts @@ -5,7 +5,7 @@ import * as Schema from "effect/Schema"; import * as Scope from "effect/Scope"; import * as Stdio from "effect/Stdio"; import * as Stream from "effect/Stream"; -import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; +import { ChildProcessSpawner } from "effect/unstable/process"; import * as CodexRpc from "./_generated/meta.gen.ts"; import * as CodexError from "./errors.ts"; @@ -18,8 +18,6 @@ import { } from "./_internal/shared.ts"; import { makeChildStdio, makeTerminationError } from "./_internal/stdio.ts"; -const DEFAULT_APP_SERVER_FORCE_KILL_AFTER = "2 seconds" as const; - export interface CodexAppServerClientOptions { readonly logIncoming?: boolean; readonly logOutgoing?: boolean; @@ -263,39 +261,3 @@ const makeChildProcessClient = Effect.fn( yield* Stream.runDrain(handle.stderr).pipe(Effect.ignore, Effect.forkScoped); return yield* make(makeChildStdio(handle), options, makeTerminationError(handle)); }); - -export interface CodexAppServerCommandLayerOptions extends CodexAppServerClientOptions { - readonly command: string; - readonly args?: ReadonlyArray; - readonly cwd?: string; - readonly env?: Record; -} - -export const layerCommand = ( - options: CodexAppServerCommandLayerOptions, -): Layer.Layer< - CodexAppServerClient, - CodexError.CodexAppServerSpawnError, - ChildProcessSpawner.ChildProcessSpawner -> => - Layer.effect( - CodexAppServerClient, - Effect.gen(function* () { - const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const 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", - }); - return yield* spawner.spawn(command).pipe( - Effect.mapError( - (cause) => - new CodexError.CodexAppServerSpawnError({ - command: [options.command, ...(options.args ?? [])].join(" "), - cause, - }), - ), - ); - }).pipe(Effect.flatMap((handle) => makeChildProcessClient(handle, options))), - ); diff --git a/packages/effect-codex-app-server/test/examples/codex-app-server-probe.ts b/packages/effect-codex-app-server/test/examples/codex-app-server-probe.ts index b6383c0ab7d..fff994dc2ca 100644 --- a/packages/effect-codex-app-server/test/examples/codex-app-server-probe.ts +++ b/packages/effect-codex-app-server/test/examples/codex-app-server-probe.ts @@ -1,5 +1,6 @@ import * as Console from "effect/Console"; import * as Effect from "effect/Effect"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; import * as NodeServices from "@effect/platform-node/NodeServices"; @@ -7,10 +8,14 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import * as CodexClient from "../../src/client.ts"; const program = Effect.gen(function* () { - const codexLayer = CodexClient.layerCommand({ - command: process.env.CODEX_BIN ?? "codex", - args: ["app-server"], - cwd: process.cwd(), + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const handle = yield* spawner.spawn( + ChildProcess.make(process.env.CODEX_BIN ?? "codex", ["app-server"], { + cwd: process.cwd(), + shell: false, + }), + ); + const codexLayer = CodexClient.layerChildProcess(handle, { logIncoming: true, logOutgoing: true, }); diff --git a/packages/effect-codex-app-server/test/fixtures/codex-app-server-mock-peer.ts b/packages/effect-codex-app-server/test/fixtures/codex-app-server-mock-peer.ts index f04f43dd70f..3f2a213d38c 100644 --- a/packages/effect-codex-app-server/test/fixtures/codex-app-server-mock-peer.ts +++ b/packages/effect-codex-app-server/test/fixtures/codex-app-server-mock-peer.ts @@ -1,3 +1,5 @@ +import * as NodeOS from "node:os"; + let nextServerRequestId = 10_000; let pendingSkillsListRequestId: number | string | null = null; let pendingUserInputRequestId: number | null = null; @@ -34,14 +36,16 @@ const handleMethod = (message: Record) => { switch (method) { case "initialize": { + // oxlint-disable-next-line t3code/no-global-process-runtime -- Standalone mock peer process has no Effect runtime. + const platform = NodeOS.platform(); const stderrBytes = Number(process.env.CODEX_APP_SERVER_TEST_STDERR_BYTES ?? 0); if (Number.isFinite(stderrBytes) && stderrBytes > 0) { process.stderr.write("x".repeat(stderrBytes), () => { respond(message.id as number | string, { userAgent: "mock-codex-app-server", codexHome: process.cwd(), - platformFamily: process.platform === "win32" ? "windows" : "unix", - platformOs: process.platform === "darwin" ? "macos" : process.platform, + platformFamily: platform === "win32" ? "windows" : "unix", + platformOs: platform === "darwin" ? "macos" : platform, }); }); return; @@ -49,8 +53,8 @@ const handleMethod = (message: Record) => { respond(message.id as number | string, { userAgent: "mock-codex-app-server", codexHome: process.cwd(), - platformFamily: process.platform === "win32" ? "windows" : "unix", - platformOs: process.platform === "darwin" ? "macos" : process.platform, + platformFamily: platform === "win32" ? "windows" : "unix", + platformOs: platform === "darwin" ? "macos" : platform, }); return; } diff --git a/packages/shared/package.json b/packages/shared/package.json index 791e66951bd..ad3db08eb2e 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -154,6 +154,10 @@ "./preview": { "types": "./src/preview.ts", "import": "./src/preview.ts" + }, + "./hostProcess": { + "types": "./src/hostProcess.ts", + "import": "./src/hostProcess.ts" } }, "scripts": { diff --git a/packages/shared/src/hostProcess.ts b/packages/shared/src/hostProcess.ts new file mode 100644 index 00000000000..1e5b69749cf --- /dev/null +++ b/packages/shared/src/hostProcess.ts @@ -0,0 +1,33 @@ +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as NodeOS from "node:os"; + +export const HostProcessPlatform = Context.Reference( + "@t3tools/shared/hostProcess/HostProcessPlatform", + { + defaultValue: () => process.platform, + }, +); + +export const HostProcessArchitecture = Context.Reference( + "@t3tools/shared/hostProcess/HostProcessArchitecture", + { + defaultValue: () => process.arch, + }, +); + +export const HostProcessHostname = Context.Reference( + "@t3tools/shared/hostProcess/HostProcessHostname", + { + defaultValue: () => NodeOS.hostname(), + }, +); + +export const HostProcessEnvironment = Context.Reference( + "@t3tools/shared/hostProcess/HostProcessEnvironment", + { + defaultValue: () => process.env, + }, +); + +export const isHostWindows = Effect.map(HostProcessPlatform, (platform) => platform === "win32"); diff --git a/packages/shared/src/relayClient.test.ts b/packages/shared/src/relayClient.test.ts index 7e552194dae..df39ef0b161 100644 --- a/packages/shared/src/relayClient.test.ts +++ b/packages/shared/src/relayClient.test.ts @@ -10,15 +10,20 @@ import * as Sink from "effect/Sink"; import * as Stream from "effect/Stream"; import { HttpClient, HttpClientResponse } from "effect/unstable/http"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; +import { HostProcessArchitecture, HostProcessPlatform } from "./hostProcess.ts"; import { RelayClientInstallError, CLOUDFLARED_VERSION, makeCloudflaredRelayClient, - resolveManagedCloudflaredPath, } from "./relayClient.ts"; -const emptyConfigProvider = () => ConfigProvider.fromEnv({ env: {} }); +const hostRuntimeLayer = (env: Record = {}) => + Layer.mergeAll( + Layer.succeed(HostProcessPlatform, "linux"), + Layer.succeed(HostProcessArchitecture, "x64"), + ConfigProvider.layer(ConfigProvider.fromEnv({ env })), + ); function makeHandle(exitCode = 0) { return ChildProcessSpawner.makeHandle({ @@ -69,18 +74,18 @@ describe("RelayClient", () => { yield* fileSystem.chmod(overridePath, 0o755); const manager = yield* makeCloudflaredRelayClient({ baseDir, - platform: "linux", - arch: "x64", - configProvider: () => - ConfigProvider.fromEnv({ - env: { - PATH: "", - T3CODE_CLOUDFLARED_PATH: overridePath, - }, - }), }); - expect(yield* manager.resolve).toEqual({ + expect( + yield* manager.resolve.pipe( + Effect.provideService( + ConfigProvider.ConfigProvider, + ConfigProvider.fromEnv({ + env: { PATH: "", T3CODE_CLOUDFLARED_PATH: overridePath }, + }), + ), + ), + ).toEqual({ status: "available", executablePath: overridePath, source: "override", @@ -93,6 +98,7 @@ describe("RelayClient", () => { NodeServices.layer, makeHttpClientLayer(new Uint8Array()), makeSpawnerLayer([]), + hostRuntimeLayer(), ), ), ), @@ -107,14 +113,11 @@ describe("RelayClient", () => { const bytes = new TextEncoder().encode("test-cloudflared-binary"); const manager = yield* makeCloudflaredRelayClient({ baseDir, - platform: "linux", - arch: "x64", releaseAsset: { url: "https://example.test/cloudflared", sha256: Encoding.encodeHex(sha256(bytes)), archive: "binary", }, - configProvider: emptyConfigProvider, }); const progress: Array = []; @@ -125,11 +128,7 @@ describe("RelayClient", () => { } }), ); - const managedPath = resolveManagedCloudflaredPath({ - baseDir, - platform: "linux", - arch: "x64", - }); + const managedPath = `${baseDir}/tools/cloudflared/${CLOUDFLARED_VERSION}/linux-x64/cloudflared`; expect(installed).toEqual({ status: "available", executablePath: managedPath, @@ -156,6 +155,7 @@ describe("RelayClient", () => { NodeServices.layer, makeHttpClientLayer(new TextEncoder().encode("test-cloudflared-binary")), makeSpawnerLayer([]), + hostRuntimeLayer(), ), ), ), @@ -169,14 +169,11 @@ describe("RelayClient", () => { }); const manager = yield* makeCloudflaredRelayClient({ baseDir, - platform: "linux", - arch: "x64", releaseAsset: { url: "https://example.test/cloudflared", sha256: Encoding.encodeHex(sha256(new TextEncoder().encode("expected"))), archive: "binary", }, - configProvider: emptyConfigProvider, }); const error = yield* manager.install.pipe(Effect.flip); @@ -189,6 +186,7 @@ describe("RelayClient", () => { NodeServices.layer, makeHttpClientLayer(new TextEncoder().encode("tampered")), makeSpawnerLayer([]), + hostRuntimeLayer(), ), ), ), @@ -204,14 +202,11 @@ describe("RelayClient", () => { }); const manager = yield* makeCloudflaredRelayClient({ baseDir, - platform: "linux", - arch: "x64", releaseAsset: { url: "https://example.test/cloudflared", sha256: Encoding.encodeHex(sha256(bytes)), archive: "binary", }, - configProvider: emptyConfigProvider, }); const [first, second] = yield* Effect.all([manager.install, manager.install], { @@ -222,25 +217,27 @@ describe("RelayClient", () => { }).pipe( Effect.scoped, Effect.provide( - Layer.mergeAll(NodeServices.layer, makeHttpClientLayer(bytes), makeSpawnerLayer(commands)), + Layer.mergeAll( + NodeServices.layer, + makeHttpClientLayer(bytes), + makeSpawnerLayer(commands), + hostRuntimeLayer(), + ), ), ); }); - it.effect("observes PATH changes after the manager has been constructed", () => - Effect.gen(function* () { + it.effect("observes PATH changes after the manager has been constructed", () => { + const env = { PATH: "" }; + return Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const baseDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-cloudflared-test-", }); const binDir = `${baseDir}/bin`; const executablePath = `${binDir}/cloudflared`; - let path = ""; const manager = yield* makeCloudflaredRelayClient({ baseDir, - platform: "linux", - arch: "x64", - configProvider: () => ConfigProvider.fromEnv({ env: { PATH: path } }), }); expect(yield* manager.resolve).toEqual({ @@ -251,7 +248,7 @@ describe("RelayClient", () => { yield* fileSystem.makeDirectory(binDir); yield* fileSystem.writeFileString(executablePath, "cloudflared"); yield* fileSystem.chmod(executablePath, 0o755); - path = binDir; + env.PATH = binDir; expect(yield* manager.resolve).toEqual({ status: "available", @@ -266,8 +263,9 @@ describe("RelayClient", () => { NodeServices.layer, makeHttpClientLayer(new Uint8Array()), makeSpawnerLayer([]), + hostRuntimeLayer(env), ), ), - ), - ); + ); + }); }); diff --git a/packages/shared/src/relayClient.ts b/packages/shared/src/relayClient.ts index 35d002466e9..0a56e45191c 100644 --- a/packages/shared/src/relayClient.ts +++ b/packages/shared/src/relayClient.ts @@ -4,7 +4,6 @@ import type { RelayClientInstallProgressStage, } from "@t3tools/contracts"; import * as Config from "effect/Config"; -import * as ConfigProvider from "effect/ConfigProvider"; import * as Context from "effect/Context"; import * as Crypto from "effect/Crypto"; import * as Data from "effect/Data"; @@ -18,6 +17,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"; @@ -120,10 +120,7 @@ const CloudflaredConfig = Config.all({ export interface CloudflaredRelayClientOptions { readonly baseDir: string; - readonly platform?: NodeJS.Platform; - readonly arch?: string; readonly releaseAsset?: CloudflaredReleaseAsset; - readonly configProvider?: () => ConfigProvider.ConfigProvider; } export interface RelayClientShape { @@ -142,22 +139,6 @@ function executableFileName(platform: NodeJS.Platform): string { return platform === "win32" ? "cloudflared.exe" : "cloudflared"; } -export function resolveManagedCloudflaredPath(input: { - readonly baseDir: string; - readonly platform: NodeJS.Platform; - readonly arch: string; -}): string { - const separator = input.platform === "win32" ? "\\" : "/"; - return [ - input.baseDir.replace(/[\\/]+$/u, ""), - "tools", - "cloudflared", - CLOUDFLARED_VERSION, - `${input.platform}-${input.arch}`, - executableFileName(input.platform), - ].join(separator); -} - function resolveReleaseAsset( platform: NodeJS.Platform, arch: string, @@ -205,17 +186,10 @@ export const makeCloudflaredRelayClient = Effect.fn("cloudflared.make")(function const path = yield* Path.Path; const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const installSemaphore = yield* Semaphore.make(1); - const platform = options.platform ?? process.platform; - const arch = options.arch ?? process.arch; + const platform = yield* HostProcessPlatform; + const arch = yield* HostProcessArchitecture; const releaseAsset = options.releaseAsset ?? resolveReleaseAsset(platform, arch); - const loadCloudflaredConfig = Effect.suspend(() => - CloudflaredConfig.pipe( - Effect.provideService( - ConfigProvider.ConfigProvider, - options.configProvider?.() ?? ConfigProvider.fromEnv(), - ), - ), - ).pipe(Effect.orDie); + const loadCloudflaredConfig = Effect.suspend(() => CloudflaredConfig).pipe(Effect.orDie); const managedPath = path.join( options.baseDir, "tools", diff --git a/packages/shared/src/shell.test.ts b/packages/shared/src/shell.test.ts index a9e2dff6943..e8b2c41cb77 100644 --- a/packages/shared/src/shell.test.ts +++ b/packages/shared/src/shell.test.ts @@ -1,7 +1,13 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { it as effectIt } from "@effect/vitest"; +import { HostProcessEnvironment, HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import * as Effect from "effect/Effect"; import { describe, expect, it, vi } from "vite-plus/test"; import { extractPathFromShellOutput, + CommandAvailability, + type CommandAvailabilityChecker, isCommandAvailable, listLoginShellCandidates, mergePathEntries, @@ -12,9 +18,23 @@ import { readPathFromLoginShell, resolveCommandPath, resolveKnownWindowsCliDirs, + resolveSpawnCommand, resolveWindowsEnvironment, + SpawnExecutableResolution, + WindowsShellEnvironment, + type WindowsShellEnvironmentReader, } from "./shell.ts"; +const withWindowsEnvironmentMocks = ( + effect: Effect.Effect, + readEnvironment: WindowsShellEnvironmentReader, + commandAvailable: CommandAvailabilityChecker, +) => + effect.pipe( + Effect.provideService(WindowsShellEnvironment, readEnvironment), + Effect.provideService(CommandAvailability, commandAvailable), + ); + describe("extractPathFromShellOutput", () => { it("extracts the path between capture markers", () => { expect( @@ -322,149 +342,235 @@ describe("resolveKnownWindowsCliDirs", () => { }); }); -describe("isCommandAvailable", () => { - it("returns false when PATH is empty", () => { - expect( - isCommandAvailable("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* isCommandAvailable("definitely-not-installed", { + env: { PATH: "", PATHEXT: ".COM;.EXE;.BAT;.CMD" }, + }).pipe(Effect.provideService(HostProcessPlatform, "win32")), + ).toBe(false); + }), + ); }); -describe("resolveCommandPath", () => { - it("returns the first executable resolved from PATH", () => { - expect( - resolveCommandPath("definitely-not-installed", { - platform: "win32", +effectIt.layer(NodeServices.layer)("resolveCommandPath", (it) => { + it.effect("fails when PATH is empty", () => + Effect.gen(function* () { + const result = yield* resolveCommandPath("definitely-not-installed", { env: { PATH: "", PATHEXT: ".COM;.EXE;.BAT;.CMD" }, - }), - ).toBeNull(); - }); + }).pipe(Effect.provideService(HostProcessPlatform, "win32"), Effect.result); + + expect(result._tag).toBe("Failure"); + }), + ); }); -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); +effectIt.layer(NodeServices.layer)("resolveSpawnCommand", (it) => { + it.effect("runs Windows executables directly without a shell", () => + Effect.gen(function* () { + const command = yield* resolveSpawnCommand("node.exe", ["script.js", "hello & goodbye"], { + env: { PATH: "", PATHEXT: ".COM;.EXE;.BAT;.CMD" }, + }).pipe(Effect.provideService(HostProcessPlatform, "win32")); + + expect(command).toEqual({ + command: "node.exe", + args: ["script.js", "hello & goodbye"], + shell: false, + }); + }), + ); + + it.effect("escapes the executable and arguments for Windows command shims", () => + Effect.gen(function* () { + const command = yield* resolveSpawnCommand( + "vp", + ["run", "value & calc", "%PATH%", 'quote"value'], + { env: { PATH: "", PATHEXT: ".COM;.EXE;.BAT;.CMD" } }, + ).pipe( + Effect.provideService(HostProcessPlatform, "win32"), + Effect.provideService( + SpawnExecutableResolution, + () => "C:\\Program Files\\npm & tools\\vp.cmd", + ), + ); + + expect(command.shell).toBe(true); + expect(command.command).not.toContain(" & "); + expect(command.command).toContain("^&"); + expect(command.args).toEqual([ + '^"run^"', + '^"value^ ^&^ calc^"', + '^"^%PATH^%^"', + '^"quote\\^"value^"', + ]); + }), + ); + + it.effect("resolves against the effective environment when extending host env", () => + Effect.gen(function* () { + let resolvedEnvironment: NodeJS.ProcessEnv | undefined; + yield* resolveSpawnCommand("codex", ["app-server"], { + env: { CODEX_HOME: "C:\\Users\\tester\\.codex" }, + extendEnv: true, + }).pipe( + Effect.provideService(HostProcessPlatform, "win32"), + Effect.provideService(HostProcessEnvironment, { + PATH: "C:\\Users\\tester\\AppData\\Roaming\\npm", + PATHEXT: ".COM;.EXE;.BAT;.CMD", + }), + Effect.provideService(SpawnExecutableResolution, (_command, _platform, env) => { + resolvedEnvironment = env; + return "C:\\Users\\tester\\AppData\\Roaming\\npm\\codex.cmd"; + }), + ); + + expect(resolvedEnvironment).toEqual({ + PATH: "C:\\Users\\tester\\AppData\\Roaming\\npm", + PATHEXT: ".COM;.EXE;.BAT;.CMD", + CODEX_HOME: "C:\\Users\\tester\\.codex", + }); + }), + ); + + it.effect("does not fall back to a shell for unresolved Windows commands", () => + Effect.gen(function* () { + const command = yield* resolveSpawnCommand("missing & calc", ["unsafe & value"], { + env: { PATH: "", PATHEXT: ".COM;.EXE;.BAT;.CMD" }, + }).pipe(Effect.provideService(HostProcessPlatform, "win32")); + + expect(command).toEqual({ + command: "missing & calc", + args: ["unsafe & value"], + shell: false, + }); + }), + ); +}); - expect( - resolveWindowsEnvironment( - { - PATH: "C:\\Windows\\System32", - APPDATA: "C:\\Users\\testuser\\AppData\\Roaming", - LOCALAPPDATA: "C:\\Users\\testuser\\AppData\\Local", - 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* withWindowsEnvironmentMocks( + 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", - }), - ); - }); - - 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", - }, - { + ), + ).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.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* withWindowsEnvironmentMocks( + 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( - resolveWindowsEnvironment( + ), + ).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"], { - PATH: "C:\\Windows\\System32", - APPDATA: "C:\\Users\\testuser\\AppData\\Roaming", - USERPROFILE: "C:\\Users\\testuser", + loadProfile: true, }, - { + ); + 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* withWindowsEnvironmentMocks( + 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); - }); + ), + ).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 c88ccc10d2a..5eab78b83d5 100644 --- a/packages/shared/src/shell.ts +++ b/packages/shared/src/shell.ts @@ -1,8 +1,15 @@ // @effect-diagnostics nodeBuiltinImport:off import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; import { execFileSync } from "node:child_process"; -import { accessSync, constants, statSync } from "node:fs"; -import { extname, join } from "node:path"; +import { accessSync, constants as fileSystemConstants, statSync } from "node:fs"; +import * as Data from "effect/Data"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; + +import { HostProcessEnvironment, HostProcessPlatform } from "./hostProcess.ts"; +import * as Context from "effect/Context"; const PATH_CAPTURE_START = "__T3CODE_PATH_START__"; const PATH_CAPTURE_END = "__T3CODE_PATH_END__"; @@ -17,11 +24,122 @@ type ExecFileSyncLike = ( options: { encoding: "utf8"; timeout: number }, ) => string; +function canExecuteFile(filePath: string): boolean { + try { + accessSync(filePath, fileSystemConstants.X_OK); + return true; + } catch { + return false; + } +} + export interface CommandAvailabilityOptions { - readonly platform?: NodeJS.Platform; readonly env?: NodeJS.ProcessEnv; + readonly extendEnv?: boolean; +} + +export type CommandAvailabilityChecker = ( + command: string, + options?: CommandAvailabilityOptions, +) => Effect.Effect; + +export class CommandResolutionError extends Data.TaggedError("CommandResolutionError")<{ + readonly command: string; + readonly reason: "not-found"; +}> {} + +const WINDOWS_SHELL_META_CHARS = /([()\][%!^"`<>&|;, *?])/g; + +/** + * Escapes a single argument for `cmd.exe` shell mode (`spawn(..., { shell: true })` + * on Windows). Node joins the command and arguments with spaces and hands the + * resulting string to `cmd.exe` without any quoting, so every dynamic argument + * must be escaped to survive both cmd.exe parsing and the target program's + * `CommandLineToArgvW` parsing. Mirrors cross-spawn's argument escaping. + */ +function escapeWindowsShellArg(arg: string): string { + // Double up backslashes that precede a double quote, then escape the quote + // itself so it survives CommandLineToArgvW. + let escaped = arg.replace(/(\\*)"/g, '$1$1\\"'); + // Double up trailing backslashes so the closing quote is not escaped away. + escaped = escaped.replace(/(\\*)$/, "$1$1"); + // Quote the whole argument so embedded whitespace is preserved. + escaped = `"${escaped}"`; + // Escape cmd.exe metacharacters so cmd passes them through verbatim. + return escaped.replace(WINDOWS_SHELL_META_CHARS, "^$1"); +} + +/** + * Escapes arguments for shell-mode spawns: applies {@link escapeWindowsShellArg} + * when the platform is `win32` (where `shell: true` routes through `cmd.exe`) + * and returns the arguments untouched everywhere else. + */ +function sanitizeShellModeArgsForPlatform( + args: ReadonlyArray, + platform: NodeJS.Platform, +): Array { + return platform === "win32" ? args.map(escapeWindowsShellArg) : [...args]; +} + +export interface ResolvedSpawnCommand { + readonly command: string; + readonly args: ReadonlyArray; + readonly shell: boolean; +} + +export type SpawnExecutableResolver = ( + command: string, + platform: NodeJS.Platform, + env: NodeJS.ProcessEnv, +) => string | undefined; + +function resolveSpawnExecutableWithNode( + command: string, + platform: NodeJS.Platform, + env: NodeJS.ProcessEnv, +): string | undefined { + const path = platform === "win32" ? NodePath.win32 : NodePath.posix; + const windowsPathExtensions = platform === "win32" ? resolveWindowsPathExtensions(env) : []; + const candidates = resolveCommandCandidates( + command, + platform, + windowsPathExtensions, + path.extname, + ); + const isExecutable = (candidate: string) => { + try { + if (!statSync(candidate).isFile()) return false; + if (platform === "win32") { + return windowsPathExtensions.includes(path.extname(candidate).toUpperCase()); + } + return canExecuteFile(candidate); + } catch { + return false; + } + }; + + if (command.includes("/") || command.includes("\\")) { + return candidates.find(isExecutable); + } + + for (const pathEntry of (readEnvPath(env) ?? "").split(pathDelimiterForPlatform(platform))) { + const normalizedPathEntry = stripWrappingQuotes(pathEntry.trim()); + if (normalizedPathEntry.length === 0) continue; + for (const candidate of candidates) { + const candidatePath = path.join(normalizedPathEntry, candidate); + if (isExecutable(candidatePath)) return candidatePath; + } + } + return undefined; } +export const SpawnExecutableResolution = Context.Reference( + "@t3tools/shared/shell/SpawnExecutableResolution", + { + defaultValue: () => resolveSpawnExecutableWithNode, + }, +); + export interface WindowsEnvironmentProbeOptions { readonly loadProfile?: boolean; } @@ -214,6 +332,20 @@ export type WindowsShellEnvironmentReader = ( options?: WindowsEnvironmentProbeOptions, ) => Partial>; +export const WindowsShellEnvironment = Context.Reference( + "@t3tools/shared/shell/WindowsShellEnvironment", + { + defaultValue: () => readEnvironmentFromWindowsShell, + }, +); + +export const CommandAvailability = Context.Reference( + "@t3tools/shared/shell/CommandAvailability", + { + defaultValue: () => isCommandAvailable, + }, +); + export function readEnvironmentFromWindowsShell( names: ReadonlyArray, execFile?: ExecFileSyncLike, @@ -334,6 +466,7 @@ function resolveCommandCandidates( command: string, platform: NodeJS.Platform, windowsPathExtensions: ReadonlyArray, + extname: (path: string) => string, ): ReadonlyArray { if (platform !== "win32") return [command]; const extension = extname(command); @@ -358,46 +491,53 @@ 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.orElseSucceed(() => 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( + return canExecuteFile(filePath); +}); + +const resolveCommandPathForPlatform = Effect.fn("shell.resolveCommandPathForPlatform")(function* ( command: string, - options: CommandAvailabilityOptions = {}, -): string | null { - const platform = options.platform ?? process.platform; + options: CommandAvailabilityOptions & { readonly platform: NodeJS.Platform }, +): Effect.fn.Return { + const path = yield* Path.Path; + const platform = options.platform; const env = options.env ?? process.env; const windowsPathExtensions = platform === "win32" ? resolveWindowsPathExtensions(env) : []; - const commandCandidates = resolveCommandCandidates(command, platform, windowsPathExtensions); + const commandCandidates = resolveCommandCandidates( + command, + platform, + windowsPathExtensions, + path.extname, + ); if (command.includes("/") || command.includes("\\")) { for (const candidate of commandCandidates) { - if (isExecutableFile(candidate, platform, windowsPathExtensions)) { + if (yield* isExecutableFile(candidate, platform, windowsPathExtensions)) { return candidate; } } - return null; + return yield* new CommandResolutionError({ command, reason: "not-found" }); } const pathValue = resolvePathEnvironmentVariable(env); - if (pathValue.length === 0) return null; + if (pathValue.length === 0) { + return yield* new CommandResolutionError({ command, reason: "not-found" }); + } const pathEntries: string[] = []; for (const entry of pathValue.split(pathDelimiterForPlatform(platform))) { const pathEntry = stripWrappingQuotes(entry.trim()); @@ -408,21 +548,65 @@ export function resolveCommandPath( for (const pathEntry of pathEntries) { for (const candidate of commandCandidates) { - const candidatePath = join(pathEntry, candidate); - if (isExecutableFile(candidatePath, platform, windowsPathExtensions)) { + const candidatePath = path.join(pathEntry, candidate); + if (yield* isExecutableFile(candidatePath, platform, windowsPathExtensions)) { return candidatePath; } } } - return null; -} + return yield* new CommandResolutionError({ command, reason: "not-found" }); +}); -export function isCommandAvailable( +export const resolveCommandPath = Effect.fn("shell.resolveCommandPath")(function* ( command: string, options: CommandAvailabilityOptions = {}, -): boolean { - return resolveCommandPath(command, options) !== null; -} +) { + return yield* resolveCommandPathForPlatform(command, { + env: options.env ?? (yield* HostProcessEnvironment), + platform: yield* HostProcessPlatform, + }); +}); + +export const resolveSpawnCommand = Effect.fn("shell.resolveSpawnCommand")(function* ( + command: string, + args: ReadonlyArray, + options: CommandAvailabilityOptions = {}, +): Effect.fn.Return { + const platform = yield* HostProcessPlatform; + if (platform !== "win32") { + return { command, args: [...args], shell: false }; + } + + const hostEnvironment = yield* HostProcessEnvironment; + const env = + options.env === undefined + ? hostEnvironment + : options.extendEnv + ? { ...hostEnvironment, ...options.env } + : options.env; + const resolveExecutable = yield* SpawnExecutableResolution; + const resolvedCommand = resolveExecutable(command, platform, env) ?? command; + const extension = NodePath.win32.extname(resolvedCommand).toLowerCase(); + if (extension !== ".cmd" && extension !== ".bat") { + return { command: resolvedCommand, args: [...args], shell: false }; + } + + return { + command: escapeWindowsShellArg(resolvedCommand), + args: sanitizeShellModeArgsForPlatform(args, platform), + shell: true, + }; +}); + +export const isCommandAvailable = Effect.fn("shell.isCommandAvailable")(function* ( + command: string, + options: CommandAvailabilityOptions = {}, +) { + return yield* resolveCommandPath(command, options).pipe( + Effect.as(true), + Effect.catchTag("CommandResolutionError", () => Effect.succeed(false)), + ); +}); export function resolveKnownWindowsCliDirs(env: NodeJS.ProcessEnv): ReadonlyArray { const appData = env.APPDATA?.trim(); @@ -437,11 +621,6 @@ export function resolveKnownWindowsCliDirs(env: NodeJS.ProcessEnv): ReadonlyArra ]; } -export interface WindowsEnvironmentResolverOptions { - readonly readEnvironment?: WindowsShellEnvironmentReader; - readonly commandAvailable?: typeof isCommandAvailable; -} - function readWindowsEnvironmentSafely( readEnvironment: WindowsShellEnvironmentReader, names: ReadonlyArray, @@ -467,12 +646,11 @@ function mergeWindowsEnv( return nextEnv; } -export function resolveWindowsEnvironment( +export const resolveWindowsEnvironment = Effect.fn("shell.resolveWindowsEnvironment")(function* ( env: NodeJS.ProcessEnv, - options: WindowsEnvironmentResolverOptions = {}, -): Partial { - const readEnvironment = options.readEnvironment ?? readEnvironmentFromWindowsShell; - const commandAvailable = options.commandAvailable ?? isCommandAvailable; +): Effect.fn.Return, never, FileSystem.FileSystem | Path.Path> { + const readEnvironment = yield* WindowsShellEnvironment; + const commandAvailable = yield* CommandAvailability; const inheritedPath = readEnvPath(env); const shellPath = readWindowsEnvironmentSafely(readEnvironment, ["PATH"], { loadProfile: false, @@ -483,7 +661,7 @@ export function resolveWindowsEnvironment( const baselinePatch: Partial = baselinePath ? { PATH: baselinePath } : {}; const baselineEnv = mergeWindowsEnv(env, baselinePatch); - if (commandAvailable("node", { platform: "win32", env: baselineEnv })) { + if (yield* commandAvailable("node", { env: baselineEnv })) { return baselinePatch; } @@ -503,4 +681,4 @@ export function resolveWindowsEnvironment( return Object.keys(profiledPatch).length > 0 ? { ...baselinePatch, ...profiledPatch } : 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..ef78b2f24fe 100644 --- a/packages/ssh/src/auth.ts +++ b/packages/ssh/src/auth.ts @@ -1,7 +1,10 @@ +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"; @@ -55,7 +58,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 +112,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 +149,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,21 +179,28 @@ export const buildSshChildEnvironment = Effect.fn("ssh/auth.buildSshChildEnviron PlatformError.PlatformError, FileSystem.FileSystem | Path.Path > { - const baseEnv = { ...(input.baseEnv ?? process.env) }; + const baseEnv = { ...input.baseEnv }; if (!input.interactiveAuth) { return baseEnv; } - const platform = input.platform ?? process.platform; + 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, platform }); + const sshAskpass = yield* ensureSshAskpassHelpers({ directory }); return { ...baseEnv, 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 44cc047aae1..aa48a1b357e 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,16 @@ 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"; + +/** + * ssh is a real executable everywhere (`ssh.exe` on Windows), so it is always + * spawned directly — cmd.exe shell mode would re-tokenize arguments such as + * identity-file paths containing spaces. + */ +const sshCommandForPlatform = (platform: NodeJS.Platform): string => + platform === "win32" ? "ssh.exe" : "ssh"; + +export const resolveSshCommand = Effect.map(HostProcessPlatform, sshCommandForPlatform); const encoder = new TextEncoder(); @@ -191,16 +201,18 @@ const runSshCommandInScope = Effect.fn("ssh/command.runSshCommand.inScope")(func ...(input.remoteCommandArgs ?? []), ]; const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const sshCommand = yield* resolveSshCommand; yield* Effect.logDebug("ssh.command.start", { ...sshTargetLogFields(target), - command: [SSH_COMMAND, ...args], + command: [sshCommand, ...args], hasStdin: input.stdin !== undefined, timeoutMs: input.timeoutMs ?? DEFAULT_SSH_COMMAND_TIMEOUT_MS, }); const child = yield* spawner .spawn( - ChildProcess.make(SSH_COMMAND, args, { + ChildProcess.make(sshCommand, args, { env: environment, + extendEnv: true, stdin: { stream: stdinStream(input.stdin), endOnDone: true, @@ -212,7 +224,7 @@ const runSshCommandInScope = Effect.fn("ssh/command.runSshCommand.inScope")(func Effect.mapError( (cause) => new SshCommandError({ - command: [SSH_COMMAND, ...args], + command: [sshCommand, ...args], exitCode: null, stderr: "", message: diff --git a/packages/ssh/src/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..a7f0d68c2a3 100644 --- a/packages/ssh/src/tunnel.ts +++ b/packages/ssh/src/tunnel.ts @@ -34,9 +34,9 @@ import { collectProcessOutput, getLastNonEmptyOutputLine, remoteStateKey, + resolveSshCommand, resolveSshTarget, runSshCommand, - SSH_COMMAND, targetConnectionKey, } from "./command.ts"; import { @@ -1069,7 +1069,8 @@ const startSshTunnel = Effect.fn("ssh/tunnel.startSshTunnel")(function* (input: `${input.localPort}:127.0.0.1:${input.remotePort}`, hostSpec, ]; - const tunnelCommand = [SSH_COMMAND, ...args]; + const sshCommand = yield* resolveSshCommand; + const tunnelCommand = [sshCommand, ...args]; const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const scope = yield* Scope.Scope; yield* Effect.logDebug("ssh.tunnel.spawn.start", { @@ -1082,8 +1083,9 @@ const startSshTunnel = Effect.fn("ssh/tunnel.startSshTunnel")(function* (input: }); const child = yield* spawner .spawn( - ChildProcess.make(SSH_COMMAND, args, { + ChildProcess.make(sshCommand, args, { env: childEnvironment, + extendEnv: true, 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..e0cca8fde56 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,11 @@ 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"; + +// tailscale is a real executable everywhere (`tailscale.exe` on Windows), so +// it is always spawned directly rather than through cmd.exe shell mode. +const tailscaleCommandForPlatform = (platform: NodeJS.Platform): string => + platform === "win32" ? "tailscale.exe" : "tailscale"; export class TailscaleCommandError extends Data.TaggedError("TailscaleCommandError")<{ readonly command: readonly string[]; @@ -136,8 +141,9 @@ 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(tailscaleCommandForPlatform(hostPlatform), args)) .pipe( Effect.mapError((cause) => tailscaleCommandError( @@ -211,8 +217,9 @@ 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(tailscaleCommandForPlatform(hostPlatform), args)) .pipe( Effect.mapError((cause) => tailscaleCommandError( diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f1325b3c44f..501422ec25d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -810,6 +810,9 @@ importers: '@effect/platform-node': specifier: 4.0.0-beta.78 version: 4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754))(ioredis@5.11.0)(utf-8-validate@6.0.6) + '@t3tools/shared': + specifier: workspace:* + version: link:../shared effect: specifier: 4.0.0-beta.78 version: 4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754) diff --git a/scripts/build-desktop-artifact.test.ts b/scripts/build-desktop-artifact.test.ts index 8dc23484ab6..974f3d036f0 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 "@t3tools/shared/hostProcess"; 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..f5785f904aa 100644 --- a/scripts/build-desktop-artifact.ts +++ b/scripts/build-desktop-artifact.ts @@ -1,6 +1,8 @@ #!/usr/bin/env node import { fromYaml } from "@t3tools/shared/schemaYaml"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import { resolveSpawnCommand } from "@t3tools/shared/shell"; import rootPackageJson from "../package.json" with { type: "json" }; import desktopPackageJson from "../apps/desktop/package.json" with { type: "json" }; import serverPackageJson from "../apps/server/package.json" with { type: "json" }; @@ -101,14 +103,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 +200,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 +358,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 +634,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 +658,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 +707,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 +791,7 @@ 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 workspaceConfig = yield* readWorkspaceConfig(); const workspaceCatalog = workspaceConfig.catalog ?? {}; const workspaceOverrides = workspaceConfig.overrides ?? {}; @@ -846,12 +858,12 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( if (!options.skipBuild) { yield* Effect.log("[desktop-artifact] Building desktop/server/web artifacts..."); + const spawnCommand = yield* resolveSpawnCommand("vp", ["run", "build:desktop"]); yield* runCommand( - ChildProcess.make({ + ChildProcess.make(spawnCommand.command, spawnCommand.args, { cwd: repoRoot, - // Windows needs shell mode to resolve .cmd shims (e.g. vp.cmd). - shell: process.platform === "win32", - })`vp run build:desktop`, + shell: spawnCommand.shell, + }), { label: "vp run build:desktop", verbose: options.verbose }, ); } @@ -933,15 +945,18 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( } yield* Effect.log("[desktop-artifact] Installing staged production dependencies..."); + const installCommand = yield* resolveSpawnCommand("vp", ["install", "--prod", "--no-optional"]); yield* runCommand( - ChildProcess.make({ + ChildProcess.make(installCommand.command, installCommand.args, { cwd: stageAppDir, - // Windows needs shell mode to resolve .cmd shims (e.g. vp.cmd). - shell: process.platform === "win32", - })`vp install --prod --no-optional`, + shell: installCommand.shell, + }), { label: "vp install --prod --no-optional", verbose: options.verbose }, ); + // electron-builder treats several set-but-empty variables (e.g. CSC_LINK="") + // as enabled, so copy the host env and scrub empty values instead of relying + // on `extendEnv` merging. const buildEnv: NodeJS.ProcessEnv = { ...process.env, }; @@ -959,7 +974,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; @@ -970,7 +985,7 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( } if (options.verbose) { buildEnv.DEBUG = - buildEnv.DEBUG === undefined || buildEnv.DEBUG === "" + buildEnv.DEBUG === undefined ? "electron-builder,electron-builder:*" : `${buildEnv.DEBUG},electron-builder,electron-builder:*`; } @@ -978,13 +993,26 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( yield* Effect.log( `[desktop-artifact] Building ${options.platform}/${options.target} (arch=${options.arch}, version=${appVersion})...`, ); + const builderArgs = [ + "exec", + "--filter", + "@t3tools/desktop", + "--", + "electron-builder", + "--projectDir", + stageAppDir, + platformConfig.cliFlag, + `--${options.arch}`, + "--publish", + "never", + ]; + const builderCommand = yield* resolveSpawnCommand("vp", builderArgs, { env: buildEnv }); yield* runCommand( - ChildProcess.make({ + ChildProcess.make(builderCommand.command, builderCommand.args, { cwd: repoRoot, env: buildEnv, - // Windows needs shell mode to resolve .cmd shims. - shell: process.platform === "win32", - })`vp exec --filter @t3tools/desktop -- electron-builder --projectDir ${stageAppDir} ${platformConfig.cliFlag} --${options.arch} --publish never`, + shell: builderCommand.shell, + }), { label: `vp exec --filter @t3tools/desktop -- electron-builder --projectDir ${stageAppDir} ${platformConfig.cliFlag} --${options.arch} --publish never`, verbose: options.verbose, diff --git a/scripts/dev-runner.ts b/scripts/dev-runner.ts index 3c53d45dcdc..36c5aa41852 100644 --- a/scripts/dev-runner.ts +++ b/scripts/dev-runner.ts @@ -5,6 +5,8 @@ import * as NodeOS from "node:os"; import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; import * as NodeServices from "@effect/platform-node/NodeServices"; import * as NetService from "@t3tools/shared/Net"; +import { HostProcessEnvironment } from "@t3tools/shared/hostProcess"; +import { resolveSpawnCommand } from "@t3tools/shared/shell"; import * as Config from "effect/Config"; import * as Data from "effect/Data"; import * as Effect from "effect/Effect"; @@ -418,9 +420,10 @@ export function runDevRunnerWithInput(input: DevRunnerCliInput) { hasExplicitDevUrl: input.devUrl !== undefined, }); + const hostEnvironment = yield* HostProcessEnvironment; const env = yield* createDevRunnerEnv({ mode: input.mode, - baseEnv: process.env, + baseEnv: hostEnvironment, serverOffset, webOffset, t3Home: input.t3Home, @@ -445,14 +448,18 @@ export function runDevRunnerWithInput(input: DevRunnerCliInput) { return; } - const child = yield* ChildProcess.make("vp", [...MODE_ARGS[input.mode], ...input.runArgs], { + const spawnCommand = yield* resolveSpawnCommand( + "vp", + [...MODE_ARGS[input.mode], ...input.runArgs], + { env }, + ); + const child = yield* ChildProcess.make(spawnCommand.command, spawnCommand.args, { stdin: "inherit", stdout: "inherit", stderr: "inherit", env, extendEnv: false, - // Windows needs shell mode to resolve .cmd shims (e.g. vp.cmd). - shell: process.platform === "win32", + shell: spawnCommand.shell, // Keep Vite+ in the same process group so terminal signals (Ctrl+C) // reach it directly. Effect defaults to detached: true on non-Windows, // which would put the runner in a new group and require manual forwarding. diff --git a/scripts/lib/build-target-arch.test.ts b/scripts/lib/build-target-arch.test.ts index 56251d3ffd1..5da97047570 100644 --- a/scripts/lib/build-target-arch.test.ts +++ b/scripts/lib/build-target-arch.test.ts @@ -1,61 +1,56 @@ import { assert, describe, it } from "@effect/vitest"; - -import { getDefaultBuildArch, resolveHostProcessArch } from "./build-target-arch.ts"; +import * as ConfigProvider from "effect/ConfigProvider"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/hostProcess"; + +import { getDefaultBuildArch } 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("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..eb804b226ea 100644 --- a/scripts/lib/build-target-arch.ts +++ b/scripts/lib/build-target-arch.ts @@ -1,3 +1,8 @@ +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"; + export type BuildArch = "arm64" | "x64" | "universal"; export type BuildPlatform = "mac" | "linux" | "win"; @@ -5,6 +10,11 @@ interface PlatformConfig { readonly archChoices: ReadonlyArray; } +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 +23,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); + +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..4b43788a9ef 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 { isCommandAvailable, resolveSpawnCommand } from "@t3tools/shared/shell"; import * as Console from "effect/Console"; import * as Data from "effect/Data"; import * as Effect from "effect/Effect"; @@ -58,23 +59,7 @@ const commandOutputOptions = { } as const; const commandExists = Effect.fn("commandExists")(function* (command: string) { - const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const lookupCommand = - process.platform === "win32" - ? ChildProcess.make("where", [command], { - stdout: "ignore", - stderr: "ignore", - }) - : ChildProcess.make("/bin/sh", ["-c", `command -v ${command}`], { - stdout: "ignore", - stderr: "ignore", - }); - - return yield* spawner.spawn(lookupCommand).pipe( - Effect.flatMap((child) => child.exitCode), - Effect.map((exitCode) => exitCode === 0), - Effect.orElseSucceed(() => false), - ); + return yield* isCommandAvailable(command); }); const warnMissingTool = (tool: NativeStaticTool, checkName: string) => @@ -89,11 +74,12 @@ const runCommand = Effect.fn("runCommand")(function* ( ) { yield* Console.log(`$ ${[command, ...args].join(" ")}`); const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const spawnCommand = yield* resolveSpawnCommand(command, args); const child = yield* spawner.spawn( - ChildProcess.make(command, [...args], { + ChildProcess.make(spawnCommand.command, spawnCommand.args, { cwd, ...commandOutputOptions, - shell: process.platform === "win32", + shell: spawnCommand.shell, }), ); const exitCode = Number(yield* child.exitCode); diff --git a/vite.config.ts b/vite.config.ts index d235efd60be..314ebf01e2e 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,12 +1,9 @@ import "vite-plus/test/config"; import { defineConfig } from "vite-plus"; -import { fileURLToPath } from "node:url"; export default defineConfig({ resolve: { - alias: { - "~": fileURLToPath(new URL("./apps/web/src", import.meta.url)), - }, + tsconfigPaths: true, }, test: { environment: "node", @@ -96,6 +93,7 @@ export default defineConfig({ "typescript/require-array-sort-compare": "off", "typescript/restrict-template-expressions": "off", "typescript/unbound-method": "off", + "t3code/no-global-process-runtime": "error", "t3code/no-inline-schema-compile": "warn", "t3code/no-manual-effect-runtime-in-tests": "error", },