diff --git a/apps/server/spawned2.txt b/apps/server/spawned2.txt new file mode 100644 index 0000000000..3b31732c2d --- /dev/null +++ b/apps/server/spawned2.txt @@ -0,0 +1,2 @@ +started +started diff --git a/apps/server/src/codexAppServerManager.ts b/apps/server/src/codexAppServerManager.ts index 230ba8e364..43ffdb4a4c 100644 --- a/apps/server/src/codexAppServerManager.ts +++ b/apps/server/src/codexAppServerManager.ts @@ -31,7 +31,8 @@ import { resolveCodexModelForAccount, type CodexAccountSnapshot, } from "./provider/codexAccount"; -import { buildCodexInitializeParams, killCodexChildProcess } from "./provider/codexAppServer"; +import { buildCodexInitializeParams } from "./provider/codexAppServer"; +import { killChildProcessTree } from "./process/killTree"; export { buildCodexInitializeParams } from "./provider/codexAppServer"; export { readCodexAccountSnapshot, resolveCodexModelForAccount } from "./provider/codexAccount"; @@ -312,15 +313,6 @@ function mapCodexRuntimeMode(runtimeMode: RuntimeMode): { } } -/** - * On Windows with `shell: true`, `child.kill()` only terminates the `cmd.exe` - * wrapper, leaving the actual command running. Use `taskkill /T` to kill the - * entire process tree instead. - */ -function killChildTree(child: ChildProcessWithoutNullStreams): void { - killCodexChildProcess(child); -} - export function normalizeCodexModelSlug( model: string | undefined | null, preferredId?: string, @@ -914,7 +906,7 @@ export class CodexAppServerManager extends EventEmitter( if (truncateOutputAtMaxBytes && truncated) { return; } - const nextBytes = bytes + chunk.byteLength; - if (!truncateOutputAtMaxBytes && nextBytes > maxOutputBytes) { + const limitedChunk = limitChunkToByteLimit(chunk, bytes, maxOutputBytes); + if (!truncateOutputAtMaxBytes && limitedChunk.overflow) { return yield* new GitCommandError({ operation: input.operation, command: quoteGitCommand(input.args), @@ -628,12 +629,9 @@ const collectOutput = Effect.fn("collectOutput")(function* ( }); } - const chunkToDecode = - truncateOutputAtMaxBytes && nextBytes > maxOutputBytes - ? chunk.subarray(0, Math.max(0, maxOutputBytes - bytes)) - : chunk; - bytes += chunkToDecode.byteLength; - truncated = truncateOutputAtMaxBytes && nextBytes > maxOutputBytes; + const chunkToDecode = truncateOutputAtMaxBytes ? limitedChunk.chunk : chunk; + bytes = truncateOutputAtMaxBytes ? limitedChunk.nextBytes : bytes + chunk.byteLength; + truncated = truncateOutputAtMaxBytes && limitedChunk.truncated; const decoded = decoder.decode(chunkToDecode, { stream: !truncated }); text += decoded; diff --git a/apps/server/src/open.test.ts b/apps/server/src/open.test.ts index 59b0239c96..6711e9da81 100644 --- a/apps/server/src/open.test.ts +++ b/apps/server/src/open.test.ts @@ -254,6 +254,20 @@ it.layer(NodeServices.layer)("resolveEditorLaunch", (it) => { }); }), ); + + it.effect("uses explorer with a VS Code protocol target on Windows", () => + Effect.gen(function* () { + const launch = yield* resolveEditorLaunch( + { cwd: "C:\\work\\100% real\\file.ts:12:4", editor: "vscode" }, + "win32", + { PATH: "" }, + ); + assert.deepEqual(launch, { + command: "explorer", + args: ["vscode://file/C:/work/100%25%20real/file.ts:12:4"], + }); + }), + ); }); it.layer(NodeServices.layer)("launchDetached", (it) => { diff --git a/apps/server/src/open.ts b/apps/server/src/open.ts index dd7d8c3849..945974d03a 100644 --- a/apps/server/src/open.ts +++ b/apps/server/src/open.ts @@ -9,6 +9,7 @@ import { spawn } from "node:child_process"; import { accessSync, constants, statSync } from "node:fs"; import { extname, join } from "node:path"; +import { pathToFileURL } from "node:url"; import { EDITORS, OpenError, type EditorId } from "@t3tools/contracts"; import { Context, Effect, Layer } from "effect"; @@ -35,6 +36,11 @@ interface CommandAvailabilityOptions { } const TARGET_WITH_POSITION_PATTERN = /^(.*?):(\d+)(?::(\d+))?$/; +const WINDOWS_EDITOR_URI_SCHEMES: Partial> = { + vscode: "vscode", + "vscode-insiders": "vscode-insiders", + vscodium: "vscodium", +}; function parseTargetPathAndPosition(target: string): { path: string; @@ -53,6 +59,36 @@ function parseTargetPathAndPosition(target: string): { }; } +function splitTargetPathAndSuffix(target: string): { + path: string; + suffix: string; +} { + const parsedTarget = parseTargetPathAndPosition(target); + if (!parsedTarget) { + return { path: target, suffix: "" }; + } + + return { + path: parsedTarget.path, + suffix: parsedTarget.column + ? `:${parsedTarget.line}:${parsedTarget.column}` + : `:${parsedTarget.line}`, + }; +} + +function makeWindowsEditorProtocolTarget(editor: EditorId, target: string): string | undefined { + const scheme = WINDOWS_EDITOR_URI_SCHEMES[editor]; + if (!scheme) return undefined; + + const { path, suffix } = splitTargetPathAndSuffix(target); + const fileUrl = pathToFileURL(path).href; + const fileTarget = fileUrl.startsWith("file:///") + ? fileUrl.slice("file:///".length) + : fileUrl.replace(/^file:\/\//, ""); + + return `${scheme}://file/${fileTarget}${suffix}`; +} + function resolveCommandEditorArgs( editor: (typeof EDITORS)[number], target: string, @@ -268,6 +304,16 @@ export const resolveEditorLaunch = Effect.fn("resolveEditorLaunch")(function* ( return yield* new OpenError({ message: `Unknown editor: ${input.editor}` }); } + if (platform === "win32") { + const protocolTarget = makeWindowsEditorProtocolTarget(input.editor, input.cwd); + if (protocolTarget) { + return { + command: fileManagerCommandForPlatform(platform), + args: [protocolTarget], + }; + } + } + if (editorDef.commands) { const command = resolveAvailableCommand(editorDef.commands, { platform, env }) ?? editorDef.commands[0]; @@ -296,7 +342,12 @@ export const launchDetached = (launch: EditorLaunch) => child = spawn(launch.command, [...launch.args], { detached: true, stdio: "ignore", - shell: process.platform === "win32", + shell: + process.platform === "win32" && + launch.command.toLowerCase() !== "explorer" && + !launch.command.toLowerCase().endsWith("\\explorer.exe") && + !launch.command.toLowerCase().endsWith("/explorer.exe"), + ...(process.platform === "win32" ? { windowsHide: true } : {}), }); } catch (error) { return resume( diff --git a/apps/server/src/process/Layers/DesktopLauncher.test.ts b/apps/server/src/process/Layers/DesktopLauncher.test.ts new file mode 100644 index 0000000000..8db3a7eef9 --- /dev/null +++ b/apps/server/src/process/Layers/DesktopLauncher.test.ts @@ -0,0 +1,835 @@ +import * as NodeFileSystem from "@effect/platform-node/NodeFileSystem"; +import * as NodePath from "@effect/platform-node/NodePath"; +import { assert, it } from "@effect/vitest"; +import { + Deferred, + Effect, + Exit, + FileSystem, + Layer, + PlatformError, + Scope, + Sink, + Stream, +} from "effect"; +import { ChildProcessSpawner } from "effect/unstable/process"; + +import { + make as makeDesktopLauncher, + type DetachedSpawnInput, + type LaunchRuntimeOptions, +} from "./DesktopLauncher"; +import { DesktopLauncher, DesktopLauncherSpawnError } from "../Services/DesktopLauncher"; + +const encoder = new TextEncoder(); + +interface SpawnCall { + readonly command: string; + readonly args: ReadonlyArray; + readonly detached?: boolean | undefined; + readonly shell?: boolean | string | undefined; + readonly windowsVerbatimArguments?: boolean | undefined; + readonly windowsHide?: boolean | undefined; + readonly stdin?: unknown; + readonly stdout?: unknown; + readonly stderr?: unknown; +} + +function decodePowerShellCommand(encoded: string): string { + return Buffer.from(encoded, "base64").toString("utf16le"); +} + +function platformSpawnError(message: string) { + return PlatformError.systemError({ + _tag: "Unknown", + module: "ChildProcess", + method: "spawn", + description: message, + }); +} + +function spawnerLayer( + calls: Array, + handler: (call: SpawnCall) => { + readonly code?: number; + readonly fail?: string; + readonly awaitExit?: Deferred.Deferred; + readonly onKill?: () => void; + } = () => ({ + code: 0, + }), +) { + return Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make((command) => { + const standardCommand = command as unknown as { + readonly command: string; + readonly args: ReadonlyArray; + readonly options: { + readonly detached?: boolean; + readonly shell?: boolean | string; + readonly stdin?: unknown; + readonly stdout?: unknown; + readonly stderr?: unknown; + }; + }; + + const call: SpawnCall = { + command: standardCommand.command, + args: [...standardCommand.args], + detached: standardCommand.options.detached, + shell: standardCommand.options.shell, + stdin: standardCommand.options.stdin, + stdout: standardCommand.options.stdout, + stderr: standardCommand.options.stderr, + }; + calls.push(call); + + const result = handler(call); + if (result.fail) { + return Effect.fail(platformSpawnError(result.fail)); + } + + let exited = false; + const exitCode = result.awaitExit + ? Deferred.await(result.awaitExit).pipe( + Effect.tap(() => + Effect.sync(() => { + exited = true; + }), + ), + Effect.andThen(Effect.succeed(ChildProcessSpawner.ExitCode(result.code ?? 0))), + ) + : Effect.sync(() => { + exited = true; + return ChildProcessSpawner.ExitCode(result.code ?? 0); + }); + + return Effect.succeed( + ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(calls.length), + exitCode, + isRunning: Effect.succeed(false), + kill: () => + Effect.sync(() => { + if (!exited) { + result.onKill?.(); + } + }), + stdin: Sink.drain, + stdout: Stream.make(encoder.encode("")), + stderr: Stream.make(encoder.encode("")), + all: Stream.empty, + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + }), + ); + }), + ); +} + +function spawnHarness( + calls: Array, + handler: (call: SpawnCall) => { + readonly code?: number; + readonly fail?: string; + readonly awaitExit?: Deferred.Deferred; + readonly onKill?: () => void; + } = () => ({ + code: 0, + }), +) { + return { + layer: spawnerLayer(calls, handler), + spawnDetached: ( + input: DetachedSpawnInput, + context: { + readonly operation: string; + readonly target?: string; + readonly editor?: string; + }, + ) => { + const call: SpawnCall = { + command: input.command, + args: [...input.args], + detached: input.detached, + shell: input.shell, + windowsVerbatimArguments: input.windowsVerbatimArguments, + windowsHide: input.windowsHide, + stdin: input.stdin, + stdout: input.stdout, + stderr: input.stderr, + }; + calls.push(call); + + const result = handler(call); + return result.fail + ? Effect.fail( + new DesktopLauncherSpawnError({ + operation: context.operation, + command: input.command, + args: [...input.args], + ...(context.target !== undefined ? { target: context.target } : {}), + ...(context.editor !== undefined ? { editor: context.editor } : {}), + }), + ) + : Effect.void; + }, + } as const; +} + +const provideOpen = ( + options: LaunchRuntimeOptions, + harness: { + readonly layer: Layer.Layer; + readonly spawnDetached: ( + input: DetachedSpawnInput, + context: { + readonly operation: string; + readonly target?: string; + readonly editor?: string; + }, + ) => Effect.Effect; + }, +) => + Layer.effect( + DesktopLauncher, + makeDesktopLauncher({ ...options, spawnDetached: harness.spawnDetached }), + ).pipe(Layer.provide(Layer.mergeAll(NodeFileSystem.layer, NodePath.layer, harness.layer))); + +const runOpen = ( + options: LaunchRuntimeOptions, + harness: { + readonly layer: Layer.Layer; + readonly spawnDetached: ( + input: DetachedSpawnInput, + context: { + readonly operation: string; + readonly target?: string; + readonly editor?: string; + }, + ) => Effect.Effect; + }, + effect: Effect.Effect, +) => effect.pipe(Effect.provide(provideOpen(options, harness))); + +const readOpen = Effect.service(DesktopLauncher); + +const writeExecutable = (filePath: string) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + yield* fs.writeFileString(filePath, "#!/bin/sh\nexit 0\n"); + yield* fs.chmod(filePath, 0o755); + }); + +it.effect("getAvailableEditors detects installed editors through the service", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-open-editors-" }); + yield* fs.writeFileString(`${dir}/code-insiders.CMD`, "@echo off\r\n"); + yield* fs.writeFileString(`${dir}/codium.CMD`, "@echo off\r\n"); + yield* fs.writeFileString(`${dir}/explorer.CMD`, "@echo off\r\n"); + + const calls: Array = []; + const open = yield* runOpen( + { + platform: "win32", + env: { + PATH: dir, + PATHEXT: ".COM;.EXE;.BAT;.CMD", + }, + }, + spawnHarness(calls), + readOpen, + ); + + const editors = yield* open.getAvailableEditors; + assert.deepEqual(editors, ["vscode-insiders", "vscodium", "file-manager"]); + assert.deepEqual(calls, []); + }).pipe(Effect.provide(NodeFileSystem.layer)), +); + +it.effect("getAvailableEditors does not advertise WSL file-manager from PowerShell alone", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-open-wsl-editors-" }); + yield* fs.makeDirectory(`${dir}/mnt/c/Windows/System32/WindowsPowerShell/v1.0`, { + recursive: true, + }); + yield* writeExecutable(`${dir}/mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe`); + + const calls: Array = []; + const open = yield* runOpen( + { + platform: "linux", + env: { + PATH: `${dir}:${dir}/mnt/c/Windows/System32/WindowsPowerShell/v1.0`, + WSL_DISTRO_NAME: "Ubuntu", + }, + isWsl: true, + isInsideContainer: false, + powerShellCommand: `${dir}/mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe`, + }, + spawnHarness(calls), + readOpen, + ); + + const editors = yield* open.getAvailableEditors; + assert.equal(editors.includes("file-manager"), false); + assert.deepEqual(calls, []); + }).pipe(Effect.provide(NodeFileSystem.layer)), +); + +it.effect("openInEditor uses --goto for editors that support it", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-open-cursor-" }); + yield* writeExecutable(`${dir}/cursor`); + + const calls: Array = []; + const open = yield* runOpen( + { + platform: "darwin", + env: { PATH: dir }, + }, + spawnHarness(calls), + readOpen, + ); + + yield* open.openInEditor({ + cwd: "/tmp/workspace/src/open.ts:71:5", + editor: "cursor", + }); + + assert.deepEqual(calls, [ + { + command: `${dir}/cursor`, + args: ["--goto", "/tmp/workspace/src/open.ts:71:5"], + detached: true, + shell: false, + stdin: "ignore", + stdout: "ignore", + stderr: "ignore", + }, + ]); + }).pipe(Effect.provide(NodeFileSystem.layer)), +); + +it.effect("openInEditor uses explorer with a VS Code protocol target on Windows", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-open-vscode-win32-" }); + yield* fs.writeFileString(`${dir}/explorer.exe`, "MZ"); + + const calls: Array = []; + const open = yield* runOpen( + { + platform: "win32", + env: { + PATH: dir, + PATHEXT: ".COM;.EXE;.BAT;.CMD", + }, + }, + spawnHarness(calls), + readOpen, + ); + + yield* open.openInEditor({ + cwd: "C:\\work\\100% real\\file.ts:12:4", + editor: "vscode", + }); + + assert.deepEqual(calls, [ + { + command: `${dir}/explorer.exe`, + args: ["vscode://file/C:/work/100%25%20real/file.ts:12:4"], + detached: true, + shell: false, + stdin: "ignore", + stdout: "ignore", + stderr: "ignore", + }, + ]); + }).pipe(Effect.provide(NodeFileSystem.layer)), +); + +it.effect("openInEditor detached launches bypass the scoped child-process spawner", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-open-zed-detached-" }); + yield* writeExecutable(`${dir}/zed`); + + const calls: Array = []; + const harness = spawnHarness(calls); + + yield* Effect.acquireUseRelease( + Scope.make("sequential"), + (scope) => + Effect.gen(function* () { + const runtimeServices = yield* Layer.build( + provideOpen( + { + platform: "darwin", + env: { PATH: dir }, + }, + harness, + ), + ).pipe(Scope.provide(scope)); + + const open = yield* readOpen.pipe(Effect.provide(runtimeServices), Scope.provide(scope)); + yield* open.openInEditor({ + cwd: "/tmp/workspace", + editor: "zed", + }); + }), + (scope) => Scope.close(scope, Exit.void), + ); + + assert.deepEqual(calls, [ + { + command: `${dir}/zed`, + args: ["/tmp/workspace"], + detached: true, + shell: false, + stdin: "ignore", + stdout: "ignore", + stderr: "ignore", + }, + ]); + }).pipe(Effect.provide(NodeFileSystem.layer)), +); + +it.effect("openInEditor uses the default opener for file-manager on macOS", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-open-macos-" }); + yield* writeExecutable(`${dir}/open`); + + const calls: Array = []; + const open = yield* runOpen( + { + platform: "darwin", + env: { PATH: dir }, + }, + spawnHarness(calls), + readOpen, + ); + + yield* open.openInEditor({ + cwd: "/tmp/workspace", + editor: "file-manager", + }); + + assert.deepEqual(calls, [ + { + command: `${dir}/open`, + args: ["/tmp/workspace"], + detached: undefined, + shell: false, + stdin: undefined, + stdout: undefined, + stderr: undefined, + }, + ]); + }).pipe(Effect.provide(NodeFileSystem.layer)), +); + +it.effect("openBrowser uses macOS open flags and app arguments", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-open-browser-macos-" }); + yield* writeExecutable(`${dir}/open`); + + const calls: Array = []; + const open = yield* runOpen( + { + platform: "darwin", + env: { PATH: dir }, + }, + spawnHarness(calls), + readOpen, + ); + + yield* open.openBrowser("https://example.com", { + wait: true, + background: true, + newInstance: true, + app: { + name: "google chrome", + arguments: ["--profile-directory=Work"], + }, + }); + + assert.deepEqual(calls, [ + { + command: `${dir}/open`, + args: [ + "--wait-apps", + "--background", + "--new", + "-a", + "google chrome", + "https://example.com", + "--args", + "--profile-directory=Work", + ], + detached: undefined, + shell: false, + stdin: undefined, + stdout: undefined, + stderr: undefined, + }, + ]); + }).pipe(Effect.provide(NodeFileSystem.layer)), +); + +it.effect("openBrowser uses PowerShell on win32", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-open-browser-win32-" }); + yield* fs.writeFileString(`${dir}/powershell.exe`, "MZ"); + + const calls: Array = []; + const open = yield* runOpen( + { + platform: "win32", + env: { + PATH: dir, + PATHEXT: ".COM;.EXE;.BAT;.CMD", + }, + }, + spawnHarness(calls), + readOpen, + ); + + yield* open.openBrowser("https://example.com"); + + assert.equal(calls.length, 1); + assert.equal(calls[0]?.command, `${dir}/powershell.exe`); + assert.equal(calls[0]?.args.at(-2), "-EncodedCommand"); + assert.equal( + decodePowerShellCommand(calls[0]?.args.at(-1) ?? ""), + "Start 'https://example.com'", + ); + }).pipe(Effect.provide(NodeFileSystem.layer)), +); + +it.effect("openBrowser detaches direct app launches on win32 when not waiting", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-open-browser-win32-app-" }); + yield* fs.writeFileString(`${dir}/chrome.exe`, "MZ"); + + const calls: Array = []; + const open = yield* runOpen( + { + platform: "win32", + env: { + PATH: dir, + PATHEXT: ".COM;.EXE;.BAT;.CMD", + }, + }, + spawnHarness(calls), + readOpen, + ); + + yield* open.openBrowser("https://example.com", { + app: { + name: "chrome", + arguments: ["--profile-directory=Work"], + }, + }); + + assert.equal(calls.length, 1); + assert.equal(calls[0]?.command.toLowerCase(), `${dir}/chrome.exe`.toLowerCase()); + assert.deepEqual(calls[0]?.args, ["--profile-directory=Work", "https://example.com"]); + assert.equal(calls[0]?.detached, true); + assert.equal(calls[0]?.shell, false); + assert.equal(calls[0]?.stdin, "ignore"); + assert.equal(calls[0]?.stdout, "ignore"); + assert.equal(calls[0]?.stderr, "ignore"); + }).pipe(Effect.provide(NodeFileSystem.layer)), +); + +it.effect("openBrowser preserves non-zero exit errors for waited direct app launches", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-open-browser-linux-app-wait-" }); + yield* writeExecutable(`${dir}/firefox`); + + const calls: Array = []; + const open = yield* runOpen( + { + platform: "linux", + env: { PATH: dir }, + }, + spawnHarness(calls, () => ({ code: 23 })), + readOpen, + ); + + const error = yield* open + .openBrowser("https://example.com", { + wait: true, + app: { name: "firefox" }, + }) + .pipe(Effect.flip); + + assert.equal(error._tag, "DesktopLauncherNonZeroExitError"); + if (error._tag !== "DesktopLauncherNonZeroExitError") { + throw new Error(`Unexpected error tag: ${error._tag}`); + } + assert.equal(error.exitCode, 23); + assert.equal(error.command, `${dir}/firefox`); + assert.deepEqual(error.args, ["https://example.com"]); + }).pipe(Effect.provide(NodeFileSystem.layer)), +); + +it.effect("openBrowser falls back from WSL PowerShell to xdg-open", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-open-browser-wsl-" }); + yield* fs.makeDirectory(`${dir}/mnt/c/Windows/System32/WindowsPowerShell/v1.0`, { + recursive: true, + }); + yield* writeExecutable(`${dir}/mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe`); + yield* writeExecutable(`${dir}/xdg-open`); + + const calls: Array = []; + const open = yield* runOpen( + { + platform: "linux", + env: { + PATH: `${dir}:${dir}/mnt/c/Windows/System32/WindowsPowerShell/v1.0`, + WSL_DISTRO_NAME: "Ubuntu", + }, + isWsl: true, + isInsideContainer: false, + powerShellCommand: `${dir}/mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe`, + }, + spawnHarness(calls, (call) => + call.command.includes("powershell") + ? { fail: "powershell unavailable" } + : { + code: 0, + }, + ), + readOpen, + ); + + yield* open.openBrowser("https://example.com"); + + assert.equal(calls[0]?.command.includes("powershell"), true); + assert.equal(calls[1]?.command, `${dir}/xdg-open`); + assert.deepEqual(calls[1]?.args, ["https://example.com"]); + }).pipe(Effect.provide(NodeFileSystem.layer)), +); + +it.effect("openBrowser skips WSL PowerShell when process env marks a container", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-open-browser-wsl-container-" }); + yield* fs.makeDirectory(`${dir}/mnt/c/Windows/System32/WindowsPowerShell/v1.0`, { + recursive: true, + }); + yield* writeExecutable(`${dir}/mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe`); + yield* writeExecutable(`${dir}/xdg-open`); + + const calls: Array = []; + 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; + } + } + }; + + try { + setEnv("PATH", `${dir}:${dir}/mnt/c/Windows/System32/WindowsPowerShell/v1.0`); + setEnv("WSL_DISTRO_NAME", "Ubuntu"); + setEnv("CONTAINER", "docker"); + + const open = yield* runOpen( + { + platform: "linux", + isWsl: true, + powerShellCommand: `${dir}/mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe`, + }, + spawnHarness(calls), + readOpen, + ); + + yield* open.openBrowser("https://example.com"); + } finally { + restoreEnv(); + } + + assert.deepEqual(calls, [ + { + command: `${dir}/xdg-open`, + args: ["https://example.com"], + detached: true, + shell: false, + stdin: "ignore", + stdout: "ignore", + stderr: "ignore", + }, + ]); + }).pipe(Effect.provide(NodeFileSystem.layer)), +); + +it.effect("openInEditor uses xdg-open first for WSL file-manager paths", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-open-wsl-file-manager-" }); + yield* fs.makeDirectory(`${dir}/mnt/c/Windows/System32/WindowsPowerShell/v1.0`, { + recursive: true, + }); + yield* writeExecutable(`${dir}/mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe`); + yield* writeExecutable(`${dir}/xdg-open`); + + const calls: Array = []; + const open = yield* runOpen( + { + platform: "linux", + env: { + PATH: `${dir}:${dir}/mnt/c/Windows/System32/WindowsPowerShell/v1.0`, + WSL_DISTRO_NAME: "Ubuntu", + }, + isWsl: true, + isInsideContainer: false, + powerShellCommand: `${dir}/mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe`, + }, + spawnHarness(calls), + readOpen, + ); + + yield* open.openInEditor({ + cwd: "/home/julius/workspace", + editor: "file-manager", + }); + + assert.deepEqual(calls, [ + { + command: `${dir}/xdg-open`, + args: ["/home/julius/workspace"], + detached: true, + shell: false, + stdin: "ignore", + stdout: "ignore", + stderr: "ignore", + }, + ]); + }).pipe(Effect.provide(NodeFileSystem.layer)), +); + +it.effect("openInEditor fails when the editor command is unavailable", () => + Effect.gen(function* () { + const calls: Array = []; + const open = yield* runOpen( + { + platform: "darwin", + env: { PATH: "" }, + }, + spawnHarness(calls), + readOpen, + ); + + const error = yield* open + .openInEditor({ + cwd: "/tmp/workspace", + editor: "cursor", + }) + .pipe(Effect.flip); + + assert.equal(error._tag, "DesktopLauncherCommandNotFoundError"); + assert.equal( + error.message, + "Desktop launcher command not found in openInEditor: cursor for editor cursor", + ); + assert.deepEqual(calls, []); + }), +); + +it.effect("openBrowser rejects an empty target with a validation error", () => + Effect.gen(function* () { + const calls: Array = []; + const open = yield* runOpen( + { + platform: "darwin", + env: { PATH: "" }, + }, + spawnHarness(calls), + readOpen, + ); + + const error = yield* open.openBrowser(" ").pipe(Effect.flip); + + assert.equal(error._tag, "DesktopLauncherValidationError"); + assert.equal( + error.message, + "Desktop launcher validation failed in openBrowser: target must not be empty", + ); + assert.deepEqual(calls, []); + }), +); + +it.effect("openBrowser reports exhausted fallback attempts when multiple launchers fail", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-open-browser-fallback-errors-" }); + yield* fs.makeDirectory(`${dir}/mnt/c/Windows/System32/WindowsPowerShell/v1.0`, { + recursive: true, + }); + yield* writeExecutable(`${dir}/mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe`); + yield* writeExecutable(`${dir}/xdg-open`); + + const calls: Array = []; + const open = yield* runOpen( + { + platform: "linux", + env: { + PATH: `${dir}:${dir}/mnt/c/Windows/System32/WindowsPowerShell/v1.0`, + WSL_DISTRO_NAME: "Ubuntu", + }, + isWsl: true, + isInsideContainer: false, + powerShellCommand: `${dir}/mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe`, + }, + spawnHarness(calls, () => ({ fail: "launcher unavailable" })), + readOpen, + ); + + const error = yield* open.openBrowser("https://example.com").pipe(Effect.flip); + assert.equal(error._tag, "DesktopLauncherLaunchAttemptsExhaustedError"); + if (error._tag !== "DesktopLauncherLaunchAttemptsExhaustedError") { + throw new Error(`Unexpected error tag: ${error._tag}`); + } + assert.deepEqual( + error.attempts.map((attempt) => attempt.reason), + ["spawnFailed", "spawnFailed"], + ); + assert.deepEqual( + error.attempts.map((attempt) => attempt.command.includes("powershell")), + [true, false], + ); + assert.deepEqual( + calls.map((call) => call.command.includes("powershell")), + [true, false], + ); + }).pipe(Effect.provide(NodeFileSystem.layer)), +); diff --git a/apps/server/src/process/Layers/DesktopLauncher.ts b/apps/server/src/process/Layers/DesktopLauncher.ts new file mode 100644 index 0000000000..64b32d60b2 --- /dev/null +++ b/apps/server/src/process/Layers/DesktopLauncher.ts @@ -0,0 +1,998 @@ +/** + * Open - Browser/editor launch service interface. + * + * Owns process launch helpers for opening URLs in a browser, workspace paths in + * a configured editor, and generic external targets through the platform's + * default opener. + * + * @module Open + */ +import OS from "node:os"; +import { spawn as spawnNodeChildProcess } from "node:child_process"; +import { pathToFileURL } from "node:url"; + +import { EDITORS, type EditorId } from "@t3tools/contracts"; +import { Array, Effect, FileSystem, Layer, Option, Path, Scope } from "effect"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; +import { + isWindowsBatchShim, + makeWindowsCmdSpawnArguments, + resolveWindowsCommandShell, +} from "../windowsCommand"; +import { + DesktopLauncherCommandNotFoundError, + DesktopLauncherDiscoveryError, + DesktopLauncherLaunchAttemptsExhaustedError, + DesktopLauncherNonZeroExitError, + DesktopLauncherSpawnError, + DesktopLauncherUnknownEditorError, + DesktopLauncherValidationError, + DesktopLauncher, + type DesktopLauncherShape, + type OpenApplicationInput, + type OpenExternalInput, +} from "../Services/DesktopLauncher"; + +export interface DetachedSpawnInput { + readonly command: string; + readonly args: ReadonlyArray; + readonly detached?: boolean; + readonly shell?: boolean; + readonly windowsVerbatimArguments?: boolean; + readonly windowsHide?: boolean; + readonly stdin?: "ignore"; + readonly stdout?: "ignore"; + readonly stderr?: "ignore"; +} + +type DesktopLauncherOperation = + | "getAvailableEditors" + | "openBrowser" + | "openExternal" + | "openInEditor"; + +interface LaunchContext { + readonly operation: DesktopLauncherOperation; + readonly target?: string; + readonly editor?: EditorId; +} + +export interface LaunchRuntimeOptions { + readonly platform?: NodeJS.Platform; + readonly env?: NodeJS.ProcessEnv; + readonly isWsl?: boolean; + readonly isInsideContainer?: boolean; + readonly powerShellCommand?: string; + readonly spawnDetached?: ( + input: DetachedSpawnInput, + context: LaunchContext, + ) => Effect.Effect; +} + +interface LaunchRuntime { + readonly platform: NodeJS.Platform; + readonly env: NodeJS.ProcessEnv; + readonly isWsl: boolean; + readonly isInsideContainer: boolean; + readonly powerShellCandidates: ReadonlyArray; + readonly windowsPathExtensions: ReadonlyArray; +} + +interface LaunchPlan { + readonly command: string; + readonly args: ReadonlyArray; + readonly wait: boolean; + readonly allowNonzeroExitCode: boolean; + readonly detached?: boolean; + readonly shell?: boolean; + readonly stdio?: "ignore"; +} + +interface ResolvedCommand { + readonly path: string; + readonly usesCmdWrapper: boolean; +} + +interface OpenApplicationCandidate { + readonly name: string; + readonly arguments: ReadonlyArray; +} + +interface LaunchAttemptFailure { + readonly command: string; + readonly args: ReadonlyArray; + readonly reason: "commandNotFound" | "spawnFailed" | "nonZeroExit"; + readonly detail: string; + readonly exitCode?: number; +} + +type LaunchAttemptError = + | DesktopLauncherCommandNotFoundError + | DesktopLauncherNonZeroExitError + | DesktopLauncherSpawnError; + +type LaunchPlanError = LaunchAttemptError | DesktopLauncherLaunchAttemptsExhaustedError; + +const LINE_COLUMN_SUFFIX_PATTERN = /:\d+(?::\d+)?$/; +const WINDOWS_POWERSHELL_CANDIDATES = ["powershell.exe", "powershell", "pwsh.exe", "pwsh"] as const; +const WSL_POWERSHELL_CANDIDATES = [ + "/mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe", + "/mnt/c/Program Files/PowerShell/7/pwsh.exe", + "powershell.exe", + "pwsh.exe", +] as const; +const WINDOWS_EDITOR_URI_SCHEMES: Partial> = { + vscode: "vscode", + "vscode-insiders": "vscode-insiders", + vscodium: "vscodium", +}; + +function shouldUseGotoFlag(editor: (typeof EDITORS)[number], target: string): boolean { + return editor.supportsGoto && LINE_COLUMN_SUFFIX_PATTERN.test(target); +} + +function stripWrappingQuotes(value: string): string { + return value.replace(/^"+|"+$/g, ""); +} + +function splitLineColumnSuffix(target: string): { + readonly filePath: string; + readonly suffix: string; +} { + const match = target.match(LINE_COLUMN_SUFFIX_PATTERN); + if (!match) { + return { + filePath: target, + suffix: "", + }; + } + + return { + filePath: target.slice(0, -match[0].length), + suffix: match[0], + }; +} + +function resolvePathEnvironmentVariable(env: NodeJS.ProcessEnv): string { + return env.PATH ?? env.Path ?? env.path ?? ""; +} + +function resolveWindowsPathExtensions(env: NodeJS.ProcessEnv): ReadonlyArray { + const rawValue = env.PATHEXT; + const fallback = [".COM", ".EXE", ".BAT", ".CMD"]; + if (!rawValue) return fallback; + + const parsed = rawValue + .split(";") + .map((entry) => entry.trim()) + .filter((entry) => entry.length > 0) + .map((entry) => (entry.startsWith(".") ? entry.toUpperCase() : `.${entry.toUpperCase()}`)); + + return parsed.length > 0 ? Array.dedupe(parsed) : fallback; +} + +function resolveCommandCandidates( + command: string, + runtime: LaunchRuntime, + pathService: Path.Path, +): ReadonlyArray { + if (runtime.platform !== "win32") return [command]; + const extension = pathService.extname(command); + const normalizedExtension = extension.toUpperCase(); + + if (extension.length > 0 && runtime.windowsPathExtensions.includes(normalizedExtension)) { + const commandWithoutExtension = command.slice(0, -extension.length); + return Array.dedupe([ + command, + `${commandWithoutExtension}${normalizedExtension}`, + `${commandWithoutExtension}${normalizedExtension.toLowerCase()}`, + ]); + } + + const candidates: string[] = []; + for (const extensionName of runtime.windowsPathExtensions) { + candidates.push(`${command}${extensionName}`); + candidates.push(`${command}${extensionName.toLowerCase()}`); + } + return Array.dedupe(candidates); +} + +function resolvePathDelimiter(platform: NodeJS.Platform): string { + return platform === "win32" ? ";" : ":"; +} + +function detectWsl(platform: NodeJS.Platform, env: NodeJS.ProcessEnv): boolean { + if (platform !== "linux") return false; + if (typeof env.WSL_DISTRO_NAME === "string" || typeof env.WSL_INTEROP === "string") { + return true; + } + return OS.release().toLowerCase().includes("microsoft"); +} + +function quotePowerShellValue(value: string): string { + return `'${value.replaceAll("'", "''")}'`; +} + +function quotePowerShellArgument(value: string): string { + return `"\`"${value.replaceAll("`", "``").replaceAll('"', '`"')}\`""`; +} + +function encodePowerShellCommand(command: string): string { + return Buffer.from(command, "utf16le").toString("base64"); +} + +function normalizeAppCandidates( + app: OpenApplicationInput | ReadonlyArray | undefined, +): ReadonlyArray { + if (!app) return [undefined]; + + const apps = Array.ensure(app); + const candidates: Array = []; + + for (const appDef of apps) { + const names = Array.ensure(appDef.name); + for (const name of names) { + candidates.push({ name, arguments: appDef.arguments ?? [] }); + } + } + + return candidates.length > 0 ? candidates : [undefined]; +} + +function isUriLikeTarget(target: string): boolean { + return /^[A-Za-z][A-Za-z\d+.-]*:/.test(target); +} + +function shouldPreferWindowsOpenerOnWsl(input: OpenExternalInput, runtime: LaunchRuntime): boolean { + return runtime.isWsl && !runtime.isInsideContainer && isUriLikeTarget(input.target); +} + +function makeWindowsEditorProtocolTarget(editor: EditorId, target: string): string | undefined { + const scheme = WINDOWS_EDITOR_URI_SCHEMES[editor]; + if (!scheme) return undefined; + + const { filePath, suffix } = splitLineColumnSuffix(target); + const fileUrl = pathToFileURL(filePath).href; + const fileTarget = fileUrl.startsWith("file:///") + ? fileUrl.slice("file:///".length) + : fileUrl.replace(/^file:\/\//, ""); + + return `${scheme}://file/${fileTarget}${suffix}`; +} + +function makeLaunchPlan( + command: string, + args: ReadonlyArray, + options: { + readonly wait: boolean; + readonly allowNonzeroExitCode: boolean; + readonly detached?: boolean; + readonly shell?: boolean; + readonly stdio?: "ignore"; + }, +): LaunchPlan { + return { + command, + args, + wait: options.wait, + allowNonzeroExitCode: options.allowNonzeroExitCode, + shell: options.shell ?? false, + ...(options.detached !== undefined ? { detached: options.detached } : {}), + ...(options.stdio !== undefined ? { stdio: options.stdio } : {}), + }; +} + +function makeDarwinDefaultPlan(input: OpenExternalInput): LaunchPlan { + const args: string[] = []; + const wait = input.wait ?? false; + + if (wait) args.push("--wait-apps"); + if (input.background) args.push("--background"); + if (input.newInstance) args.push("--new"); + args.push(input.target); + + return makeLaunchPlan("open", args, { + wait, + allowNonzeroExitCode: input.allowNonzeroExitCode ?? false, + shell: false, + }); +} + +function makeDarwinApplicationPlan( + input: OpenExternalInput, + app: OpenApplicationCandidate, +): LaunchPlan { + const args: string[] = []; + const wait = input.wait ?? false; + + if (wait) args.push("--wait-apps"); + if (input.background) args.push("--background"); + if (input.newInstance) args.push("--new"); + args.push("-a", app.name); + args.push(input.target); + if (app.arguments.length > 0) { + args.push("--args", ...app.arguments); + } + + return makeLaunchPlan("open", args, { + wait, + allowNonzeroExitCode: input.allowNonzeroExitCode ?? false, + shell: false, + }); +} + +function makePowerShellPlan(input: OpenExternalInput, powerShellCommand: string): LaunchPlan { + const encodedParts = ["Start"]; + const wait = input.wait ?? false; + + if (wait) encodedParts.push("-Wait"); + encodedParts.push(quotePowerShellValue(input.target)); + + return makeLaunchPlan( + powerShellCommand, + [ + "-NoProfile", + "-NonInteractive", + "-ExecutionPolicy", + "Bypass", + "-EncodedCommand", + encodePowerShellCommand(encodedParts.join(" ")), + ], + { + wait, + allowNonzeroExitCode: input.allowNonzeroExitCode ?? false, + shell: false, + }, + ); +} + +function makePowerShellStartProcessArgs( + commandPath: string, + args: ReadonlyArray, +): ReadonlyArray { + const argumentList = + args.length > 0 ? ` -ArgumentList ${args.map(quotePowerShellArgument).join(",")}` : ""; + const command = `Start ${quotePowerShellArgument(commandPath)}${argumentList} -WindowStyle Hidden`; + return [ + "-NoProfile", + "-NonInteractive", + "-ExecutionPolicy", + "Bypass", + "-EncodedCommand", + encodePowerShellCommand(command), + ]; +} + +function makeLinuxDefaultPlan(input: OpenExternalInput): LaunchPlan { + const wait = input.wait ?? false; + return makeLaunchPlan("xdg-open", [input.target], { + wait, + allowNonzeroExitCode: input.allowNonzeroExitCode ?? false, + detached: !wait, + ...(wait ? {} : { stdio: "ignore" as const }), + shell: false, + }); +} + +function makeWindowsExplorerPlan(input: OpenExternalInput): LaunchPlan { + return makeLaunchPlan("explorer", [input.target], { + wait: false, + allowNonzeroExitCode: false, + detached: true, + stdio: "ignore", + shell: false, + }); +} + +function makeDirectApplicationPlan( + input: OpenExternalInput, + app: OpenApplicationCandidate, +): LaunchPlan { + const wait = input.wait ?? false; + return makeLaunchPlan(app.name, [...app.arguments, input.target], { + wait, + allowNonzeroExitCode: input.allowNonzeroExitCode ?? false, + detached: !wait, + ...(wait ? {} : { stdio: "ignore" as const }), + shell: false, + }); +} + +function resolveExternalPlans( + input: OpenExternalInput, + runtime: LaunchRuntime, +): ReadonlyArray { + const appCandidates = normalizeAppCandidates(input.app); + const plans: LaunchPlan[] = []; + const preferWindowsOpenerOnWsl = shouldPreferWindowsOpenerOnWsl(input, runtime); + + for (const app of appCandidates) { + if (app) { + if (runtime.platform === "darwin") { + plans.push(makeDarwinApplicationPlan(input, app)); + } else { + plans.push(makeDirectApplicationPlan(input, app)); + } + continue; + } + + if (runtime.platform === "darwin") { + plans.push(makeDarwinDefaultPlan(input)); + continue; + } + + if (runtime.platform === "win32" || preferWindowsOpenerOnWsl) { + for (const powerShellCommand of runtime.powerShellCandidates) { + plans.push(makePowerShellPlan(input, powerShellCommand)); + } + } + + if (runtime.platform === "win32") { + if (!(input.wait ?? false)) { + plans.push(makeWindowsExplorerPlan(input)); + } + continue; + } + + plans.push(makeLinuxDefaultPlan(input)); + } + + return plans; +} + +function resolveSpawnInput( + runtime: LaunchRuntime, + plan: LaunchPlan, + resolvedCommand: ResolvedCommand, + startProcessLauncher: string | undefined, +): DetachedSpawnInput { + if (plan.detached && resolvedCommand.usesCmdWrapper && startProcessLauncher !== undefined) { + return { + command: startProcessLauncher, + args: makePowerShellStartProcessArgs(resolvedCommand.path, plan.args), + ...(plan.detached !== undefined ? { detached: plan.detached } : {}), + ...(plan.shell !== undefined ? { shell: plan.shell } : {}), + ...(runtime.platform === "win32" + ? { windowsVerbatimArguments: true, windowsHide: true } + : {}), + ...(plan.stdio === "ignore" + ? { + stdin: "ignore" as const, + stdout: "ignore" as const, + stderr: "ignore" as const, + } + : {}), + }; + } + const windowsHide = runtime.platform === "win32" && plan.detached ? true : undefined; + return { + command: resolvedCommand.usesCmdWrapper + ? resolveWindowsCommandShell(runtime.env) + : resolvedCommand.path, + args: resolvedCommand.usesCmdWrapper + ? makeWindowsCmdSpawnArguments(resolvedCommand.path, plan.args) + : [...plan.args], + ...(plan.detached !== undefined ? { detached: plan.detached } : {}), + ...(plan.shell !== undefined ? { shell: plan.shell } : {}), + ...(windowsHide ? { windowsHide } : {}), + ...(resolvedCommand.usesCmdWrapper ? { windowsVerbatimArguments: true } : {}), + ...(plan.stdio === "ignore" + ? { + stdin: "ignore" as const, + stdout: "ignore" as const, + stderr: "ignore" as const, + } + : {}), + }; +} + +function makeCommandNotFoundError( + context: LaunchContext, + command: string, +): DesktopLauncherCommandNotFoundError { + return new DesktopLauncherCommandNotFoundError({ + operation: context.operation, + command, + ...(context.target !== undefined ? { target: context.target } : {}), + ...(context.editor !== undefined ? { editor: context.editor } : {}), + }); +} + +function makeSpawnError( + context: LaunchContext, + command: string, + args: ReadonlyArray, + cause?: unknown, +): DesktopLauncherSpawnError { + return new DesktopLauncherSpawnError({ + operation: context.operation, + command, + args: [...args], + ...(context.target !== undefined ? { target: context.target } : {}), + ...(context.editor !== undefined ? { editor: context.editor } : {}), + ...(cause !== undefined ? { cause } : {}), + }); +} + +function makeNonZeroExitError( + context: LaunchContext, + command: string, + args: ReadonlyArray, + exitCode: number, +): DesktopLauncherNonZeroExitError { + return new DesktopLauncherNonZeroExitError({ + operation: context.operation, + command, + args: [...args], + exitCode, + ...(context.target !== undefined ? { target: context.target } : {}), + ...(context.editor !== undefined ? { editor: context.editor } : {}), + }); +} + +function toLaunchAttemptFailure(error: LaunchAttemptError): LaunchAttemptFailure { + switch (error._tag) { + case "DesktopLauncherCommandNotFoundError": + return { + command: error.command, + args: [], + reason: "commandNotFound", + detail: error.message, + }; + case "DesktopLauncherSpawnError": + return { + command: error.command, + args: error.args, + reason: "spawnFailed", + detail: error.message, + }; + case "DesktopLauncherNonZeroExitError": + return { + command: error.command, + args: error.args, + reason: "nonZeroExit", + detail: error.message, + exitCode: error.exitCode, + }; + } +} + +/** + * Detached GUI launches must call `unref()` after spawn. The Effect + * `ChildProcessSpawner` owns spawned handles through scope finalizers and does + * not expose `unref()`, which means it cannot provide true fire-and-forget + * behavior for editor / file-manager launches. + */ +const DETACHED_SPAWN_GRACE_MS = 500; + +function defaultSpawnDetached( + input: DetachedSpawnInput, + context: LaunchContext, +): Effect.Effect { + return Effect.try({ + try: () => + spawnNodeChildProcess(input.command, [...input.args], { + ...(input.detached !== undefined ? { detached: input.detached } : {}), + ...(input.shell !== undefined ? { shell: input.shell } : {}), + ...(input.windowsHide !== undefined ? { windowsHide: input.windowsHide } : {}), + ...(input.windowsVerbatimArguments !== undefined + ? { windowsVerbatimArguments: input.windowsVerbatimArguments } + : {}), + ...(input.stdin === "ignore" && input.stdout === "ignore" && input.stderr === "ignore" + ? { stdio: "ignore" as const } + : {}), + }), + catch: (cause) => makeSpawnError(context, input.command, input.args, cause), + }).pipe( + Effect.flatMap((childProcess) => + Effect.callback((resume) => { + let graceTimer: ReturnType | undefined; + + const onEarlyExit = (code: number | null) => { + if (graceTimer !== undefined) clearTimeout(graceTimer); + if (code !== null && code !== 0) { + resume( + Effect.fail( + makeSpawnError( + context, + input.command, + input.args, + new Error(`Process exited immediately with code ${code}`), + ), + ), + ); + } else { + childProcess.unref(); + resume(Effect.void); + } + }; + + const onError = (error: Error) => { + childProcess.off("spawn", onSpawn); + resume(Effect.fail(makeSpawnError(context, input.command, input.args, error))); + }; + + const onSpawn = () => { + childProcess.off("error", onError); + childProcess.once("exit", onEarlyExit); + graceTimer = setTimeout(() => { + childProcess.off("exit", onEarlyExit); + childProcess.unref(); + resume(Effect.void); + }, DETACHED_SPAWN_GRACE_MS); + }; + + childProcess.once("error", onError); + childProcess.once("spawn", onSpawn); + + return Effect.sync(() => { + if (graceTimer !== undefined) clearTimeout(graceTimer); + childProcess.off("error", onError); + childProcess.off("spawn", onSpawn); + childProcess.off("exit", onEarlyExit); + }); + }), + ), + ); +} + +export const make = Effect.fn("makeDesktopLauncher")(function* ( + options: LaunchRuntimeOptions = {}, +) { + const fileSystem = yield* FileSystem.FileSystem; + const pathService = yield* Path.Path; + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const runtimeEnv = options.env ?? process.env; + const runtimePlatform = options.platform ?? process.platform; + const isWsl = options.isWsl ?? detectWsl(runtimePlatform, runtimeEnv); + + const isInsideContainer = + options.isInsideContainer ?? + (typeof runtimeEnv.CONTAINER === "string" || + typeof runtimeEnv.container === "string" || + typeof runtimeEnv.KUBERNETES_SERVICE_HOST === "string" + ? true + : yield* fileSystem.exists("/.dockerenv").pipe(Effect.catch(() => Effect.succeed(false)))); + + const runtime: LaunchRuntime = { + platform: runtimePlatform, + env: runtimeEnv, + isWsl, + isInsideContainer, + powerShellCandidates: + options.powerShellCommand !== undefined + ? [options.powerShellCommand] + : isWsl + ? WSL_POWERSHELL_CANDIDATES + : WINDOWS_POWERSHELL_CANDIDATES, + windowsPathExtensions: resolveWindowsPathExtensions(runtimeEnv), + }; + + const resolveCommand = Effect.fn("resolveCommand")(function* ( + command: string, + ): Effect.fn.Return> { + const candidates = resolveCommandCandidates(command, runtime, pathService); + + const resolveExecutableFile = Effect.fn("resolveExecutableFile")(function* ( + filePath: string, + ): Effect.fn.Return> { + const info = yield* fileSystem.stat(filePath).pipe(Effect.option); + if (Option.isNone(info) || info.value.type !== "File") return Option.none(); + + if (runtime.platform === "win32") { + const extension = pathService.extname(filePath); + if ( + extension.length === 0 || + !runtime.windowsPathExtensions.includes(extension.toUpperCase()) + ) { + return Option.none(); + } + + return Option.some({ + path: filePath, + usesCmdWrapper: isWindowsBatchShim(filePath), + } satisfies ResolvedCommand); + } + + return (info.value.mode & 0o111) !== 0 + ? Option.some({ + path: filePath, + usesCmdWrapper: false, + } satisfies ResolvedCommand) + : Option.none(); + }); + + if (command.includes("/") || command.includes("\\")) { + for (const candidate of candidates) { + const resolved = yield* resolveExecutableFile(candidate); + if (Option.isSome(resolved)) return resolved; + } + return Option.none(); + } + + const pathValue = resolvePathEnvironmentVariable(runtime.env); + if (pathValue.length === 0) return Option.none(); + + const pathEntries = pathValue + .split(resolvePathDelimiter(runtime.platform)) + .map((entry) => stripWrappingQuotes(entry.trim())) + .filter((entry) => entry.length > 0); + + for (const pathEntry of pathEntries) { + for (const candidate of candidates) { + const resolved = yield* resolveExecutableFile(pathService.join(pathEntry, candidate)); + if (Option.isSome(resolved)) { + return resolved; + } + } + } + + return Option.none(); + }); + + const commandAvailable = (command: string) => + resolveCommand(command).pipe(Effect.map(Option.isSome)); + + const startProcessLauncher = yield* Effect.gen(function* () { + for (const candidate of runtime.powerShellCandidates) { + const resolved = yield* resolveCommand(candidate); + if (Option.isSome(resolved)) { + return resolved.value.path; + } + } + return undefined; + }); + + const fileManagerAvailable = Effect.gen(function* () { + const candidates = + runtime.platform === "darwin" + ? ["open"] + : runtime.platform === "win32" + ? [...runtime.powerShellCandidates, "explorer"] + : ["xdg-open"]; + + for (const candidate of candidates) { + if (yield* commandAvailable(candidate)) { + return true; + } + } + + return false; + }); + + const getAvailableEditors: DesktopLauncherShape["getAvailableEditors"] = Effect.gen(function* () { + const available: EditorId[] = []; + + for (const editor of EDITORS) { + if (editor.id === "file-manager") { + if (yield* fileManagerAvailable) { + available.push(editor.id); + } + continue; + } + + if (editor.command && (yield* commandAvailable(editor.command))) { + available.push(editor.id); + } + } + + return available; + }).pipe( + Effect.mapError( + (cause) => + new DesktopLauncherDiscoveryError({ + operation: "getAvailableEditors", + detail: "failed to resolve available editors", + cause, + }), + ), + ); + + const spawnPlan = ( + plan: LaunchPlan, + resolvedCommand: ResolvedCommand, + context: LaunchContext, + ) => { + const input = resolveSpawnInput(runtime, plan, resolvedCommand, startProcessLauncher); + return spawner + .spawn( + ChildProcess.make(input.command, [...input.args], { + detached: plan.detached, + shell: plan.shell, + ...(plan.stdio === "ignore" + ? { + stdin: "ignore", + stdout: "ignore", + stderr: "ignore", + } + : {}), + }), + ) + .pipe(Effect.mapError((cause) => makeSpawnError(context, input.command, input.args, cause))); + }; + + const spawnDetached = options.spawnDetached ?? defaultSpawnDetached; + + const waitForExit = ( + plan: LaunchPlan, + context: LaunchContext, + spawnInput: DetachedSpawnInput, + handle: ChildProcessSpawner.ChildProcessHandle, + ) => + handle.exitCode.pipe( + Effect.mapError((cause) => + makeSpawnError(context, spawnInput.command, spawnInput.args, cause), + ), + Effect.flatMap((exitCode) => + !plan.allowNonzeroExitCode && exitCode !== 0 + ? Effect.fail( + makeNonZeroExitError(context, spawnInput.command, spawnInput.args, exitCode), + ) + : Effect.void, + ), + ); + + const runWaitedPlan = ( + plan: LaunchPlan, + resolvedCommand: ResolvedCommand, + context: LaunchContext, + ) => + Effect.acquireUseRelease( + Scope.make("sequential"), + (scope) => + Effect.gen(function* () { + const spawnInput = resolveSpawnInput( + runtime, + plan, + resolvedCommand, + startProcessLauncher, + ); + const handle = yield* spawnPlan(plan, resolvedCommand, context).pipe( + Scope.provide(scope), + ); + yield* waitForExit(plan, context, spawnInput, handle); + }), + (scope, exit) => Scope.close(scope, exit), + ); + + const runDetachedPlan = ( + plan: LaunchPlan, + resolvedCommand: ResolvedCommand, + context: LaunchContext, + ) => + spawnDetached(resolveSpawnInput(runtime, plan, resolvedCommand, startProcessLauncher), context); + + const runPlan = (plan: LaunchPlan, resolvedCommand: ResolvedCommand, context: LaunchContext) => + plan.wait + ? runWaitedPlan(plan, resolvedCommand, context) + : runDetachedPlan(plan, resolvedCommand, context); + + const runFirstAvailablePlan = Effect.fn("runFirstAvailablePlan")(function* ( + plans: ReadonlyArray, + context: LaunchContext, + ): Effect.fn.Return { + const failures: LaunchAttemptError[] = []; + + for (const plan of plans) { + const resolvedCommand = yield* resolveCommand(plan.command); + if (Option.isNone(resolvedCommand)) { + failures.push(makeCommandNotFoundError(context, plan.command)); + continue; + } + + const attempt = yield* Effect.result(runPlan(plan, resolvedCommand.value, context)); + if (attempt._tag === "Success") { + return; + } + + failures.push(attempt.failure); + } + + const [firstFailure] = failures; + if (failures.length === 1 && firstFailure) { + return yield* firstFailure; + } + + return yield* new DesktopLauncherLaunchAttemptsExhaustedError({ + operation: context.operation, + ...(context.target !== undefined ? { target: context.target } : {}), + ...(context.editor !== undefined ? { editor: context.editor } : {}), + attempts: failures.map(toLaunchAttemptFailure), + }); + }); + + const openTarget = Effect.fn("openTarget")(function* ( + operation: Extract, + input: OpenExternalInput, + context: Omit = {}, + ) { + if (input.target.trim().length === 0) { + return yield* new DesktopLauncherValidationError({ + operation, + detail: "target must not be empty", + target: input.target, + }); + } + + return yield* runFirstAvailablePlan(resolveExternalPlans(input, runtime), { + operation, + target: input.target, + ...(context.editor !== undefined ? { editor: context.editor } : {}), + }); + }); + + const openExternal: DesktopLauncherShape["openExternal"] = Effect.fn("openExternal")( + function* (input) { + return yield* openTarget("openExternal", input); + }, + ); + + const openBrowser: DesktopLauncherShape["openBrowser"] = Effect.fn("openBrowser")(function* ( + target, + openOptions = {}, + ) { + return yield* openTarget("openBrowser", { ...openOptions, target }); + }); + + const openInEditor: DesktopLauncherShape["openInEditor"] = Effect.fn("openInEditor")( + function* (input) { + const editor = EDITORS.find((candidate) => candidate.id === input.editor); + if (!editor) { + return yield* new DesktopLauncherUnknownEditorError({ editor: input.editor }); + } + + const windowsEditorProtocolTarget = + runtime.platform === "win32" + ? makeWindowsEditorProtocolTarget(input.editor, input.cwd) + : undefined; + if (windowsEditorProtocolTarget) { + return yield* runFirstAvailablePlan( + [makeWindowsExplorerPlan({ target: windowsEditorProtocolTarget })], + { + operation: "openInEditor", + target: input.cwd, + editor: input.editor, + }, + ); + } + + if (editor.command) { + return yield* runFirstAvailablePlan( + [ + makeLaunchPlan( + editor.command, + shouldUseGotoFlag(editor, input.cwd) ? ["--goto", input.cwd] : [input.cwd], + { + wait: false, + allowNonzeroExitCode: false, + detached: true, + stdio: "ignore", + shell: false, + }, + ), + ], + { + operation: "openInEditor", + target: input.cwd, + editor: input.editor, + }, + ); + } + + return yield* openTarget( + "openInEditor", + { target: input.cwd }, + { target: input.cwd, editor: input.editor }, + ); + }, + ); + + return { + getAvailableEditors, + openExternal, + openBrowser, + openInEditor, + } satisfies DesktopLauncherShape; +}); + +export const layer = Layer.effect(DesktopLauncher, make()); diff --git a/apps/server/src/process/Services/DesktopLauncher.ts b/apps/server/src/process/Services/DesktopLauncher.ts new file mode 100644 index 0000000000..5d29bfb0b7 --- /dev/null +++ b/apps/server/src/process/Services/DesktopLauncher.ts @@ -0,0 +1,200 @@ +/** + * Open - Browser/editor launch service interface. + * + * Owns process launch helpers for opening URLs in a browser, workspace paths in + * a configured editor, and generic external targets through the platform's + * default opener. + * + * @module Open + */ + +import { type EditorId } from "@t3tools/contracts"; +import { Effect, Schema, ServiceMap } from "effect"; + +const DesktopLauncherLaunchAttemptSchema = Schema.Struct({ + command: Schema.String, + args: Schema.Array(Schema.String), + reason: Schema.Literals(["commandNotFound", "spawnFailed", "nonZeroExit"]), + detail: Schema.String, + exitCode: Schema.optional(Schema.Number), +}); + +export class DesktopLauncherDiscoveryError extends Schema.TaggedErrorClass()( + "DesktopLauncherDiscoveryError", + { + operation: Schema.String, + detail: Schema.String, + cause: Schema.optional(Schema.Defect), + }, +) { + override get message(): string { + return `Desktop launcher discovery failed in ${this.operation}: ${this.detail}`; + } +} + +export class DesktopLauncherValidationError extends Schema.TaggedErrorClass()( + "DesktopLauncherValidationError", + { + operation: Schema.String, + detail: Schema.String, + target: Schema.optional(Schema.String), + cause: Schema.optional(Schema.Defect), + }, +) { + override get message(): string { + return `Desktop launcher validation failed in ${this.operation}: ${this.detail}`; + } +} + +export class DesktopLauncherUnknownEditorError extends Schema.TaggedErrorClass()( + "DesktopLauncherUnknownEditorError", + { + editor: Schema.String, + cause: Schema.optional(Schema.Defect), + }, +) { + override get message(): string { + return `Unknown desktop editor: ${this.editor}`; + } +} + +export class DesktopLauncherCommandNotFoundError extends Schema.TaggedErrorClass()( + "DesktopLauncherCommandNotFoundError", + { + operation: Schema.String, + command: Schema.String, + target: Schema.optional(Schema.String), + editor: Schema.optional(Schema.String), + cause: Schema.optional(Schema.Defect), + }, +) { + override get message(): string { + const target = this.editor + ? ` for editor ${this.editor}` + : this.target + ? ` for ${this.target}` + : ""; + return `Desktop launcher command not found in ${this.operation}: ${this.command}${target}`; + } +} + +export class DesktopLauncherSpawnError extends Schema.TaggedErrorClass()( + "DesktopLauncherSpawnError", + { + operation: Schema.String, + command: Schema.String, + args: Schema.Array(Schema.String), + target: Schema.optional(Schema.String), + editor: Schema.optional(Schema.String), + cause: Schema.optional(Schema.Defect), + }, +) { + override get message(): string { + return `Desktop launcher failed to spawn ${this.command} in ${this.operation}`; + } +} + +export class DesktopLauncherNonZeroExitError extends Schema.TaggedErrorClass()( + "DesktopLauncherNonZeroExitError", + { + operation: Schema.String, + command: Schema.String, + args: Schema.Array(Schema.String), + exitCode: Schema.Number, + target: Schema.optional(Schema.String), + editor: Schema.optional(Schema.String), + cause: Schema.optional(Schema.Defect), + }, +) { + override get message(): string { + return `Desktop launcher command exited non-zero in ${this.operation}: ${this.command} (code=${this.exitCode})`; + } +} + +export class DesktopLauncherLaunchAttemptsExhaustedError extends Schema.TaggedErrorClass()( + "DesktopLauncherLaunchAttemptsExhaustedError", + { + operation: Schema.String, + target: Schema.optional(Schema.String), + editor: Schema.optional(Schema.String), + attempts: Schema.Array(DesktopLauncherLaunchAttemptSchema), + cause: Schema.optional(Schema.Defect), + }, +) { + override get message(): string { + const subject = this.editor ? `editor ${this.editor}` : this.target ? this.target : "target"; + return `Desktop launcher exhausted all launch attempts in ${this.operation} for ${subject}`; + } +} + +export const DesktopLauncherError = Schema.Union([ + DesktopLauncherCommandNotFoundError, + DesktopLauncherDiscoveryError, + DesktopLauncherLaunchAttemptsExhaustedError, + DesktopLauncherNonZeroExitError, + DesktopLauncherSpawnError, + DesktopLauncherUnknownEditorError, + DesktopLauncherValidationError, +]); + +export interface OpenInEditorInput { + readonly cwd: string; + readonly editor: EditorId; +} + +export interface OpenApplicationInput { + readonly name: string | ReadonlyArray; + readonly arguments?: ReadonlyArray; +} + +export interface OpenExternalInput { + readonly target: string; + readonly wait?: boolean; + readonly background?: boolean; + readonly newInstance?: boolean; + readonly allowNonzeroExitCode?: boolean; + readonly app?: OpenApplicationInput | ReadonlyArray; +} + +export interface DesktopLauncherShape { + readonly getAvailableEditors: Effect.Effect< + ReadonlyArray, + DesktopLauncherDiscoveryError + >; + readonly openExternal: ( + input: OpenExternalInput, + ) => Effect.Effect< + void, + | DesktopLauncherCommandNotFoundError + | DesktopLauncherLaunchAttemptsExhaustedError + | DesktopLauncherNonZeroExitError + | DesktopLauncherSpawnError + | DesktopLauncherValidationError + >; + readonly openBrowser: ( + target: string, + options?: Omit, + ) => Effect.Effect< + void, + | DesktopLauncherCommandNotFoundError + | DesktopLauncherLaunchAttemptsExhaustedError + | DesktopLauncherNonZeroExitError + | DesktopLauncherSpawnError + | DesktopLauncherValidationError + >; + readonly openInEditor: ( + input: OpenInEditorInput, + ) => Effect.Effect< + void, + | DesktopLauncherCommandNotFoundError + | DesktopLauncherLaunchAttemptsExhaustedError + | DesktopLauncherNonZeroExitError + | DesktopLauncherSpawnError + | DesktopLauncherUnknownEditorError + | DesktopLauncherValidationError + >; +} + +export class DesktopLauncher extends ServiceMap.Service()( + "t3/desktop-launcher", +) {} diff --git a/apps/server/src/process/killTree.test.ts b/apps/server/src/process/killTree.test.ts new file mode 100644 index 0000000000..cae8823638 --- /dev/null +++ b/apps/server/src/process/killTree.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it, vi } from "vitest"; + +import { killChildProcessTree } from "./killTree"; + +describe("killChildProcessTree", () => { + it("uses taskkill on Windows when a pid is available", () => { + const kill = vi.fn(); + const spawnSyncImpl = vi.fn(() => ({ status: 0 })); + + killChildProcessTree({ pid: 123, kill } as never, "SIGTERM", { + platform: "win32", + spawnSyncImpl, + }); + + expect(spawnSyncImpl).toHaveBeenCalledWith("taskkill", ["/pid", "123", "/T", "/F"], { + stdio: "ignore", + }); + expect(kill).not.toHaveBeenCalled(); + }); + + it("falls back to direct kill when taskkill fails", () => { + const kill = vi.fn(); + const spawnSyncImpl = vi.fn(() => ({ status: 1 })); + + killChildProcessTree({ pid: 456, kill } as never, "SIGKILL", { + platform: "win32", + spawnSyncImpl, + }); + + expect(kill).toHaveBeenCalledWith("SIGKILL"); + }); + + it("falls back to direct kill when taskkill cannot be spawned", () => { + const kill = vi.fn(); + const spawnSyncImpl = vi.fn(() => ({ status: null, error: new Error("ENOENT") })); + + killChildProcessTree({ pid: 654, kill } as never, "SIGTERM", { + platform: "win32", + spawnSyncImpl, + }); + + expect(kill).toHaveBeenCalledWith("SIGTERM"); + }); + + it("kills directly on non-Windows platforms", () => { + const kill = vi.fn(); + const spawnSyncImpl = vi.fn(); + + killChildProcessTree({ pid: 789, kill } as never, "SIGTERM", { + platform: "darwin", + spawnSyncImpl, + }); + + expect(spawnSyncImpl).not.toHaveBeenCalled(); + expect(kill).toHaveBeenCalledWith("SIGTERM"); + }); +}); diff --git a/apps/server/src/process/killTree.ts b/apps/server/src/process/killTree.ts new file mode 100644 index 0000000000..44e017dc48 --- /dev/null +++ b/apps/server/src/process/killTree.ts @@ -0,0 +1,47 @@ +import { spawnSync, type ChildProcess as NodeChildProcess } from "node:child_process"; + +type KillableChildProcess = Pick; +type TaskkillResult = { + readonly status: number | null; + readonly error?: Error; +}; +type TaskkillRunner = ( + command: string, + args: ReadonlyArray, + options: { readonly stdio: "ignore" }, +) => TaskkillResult; + +interface KillChildProcessTreeOptions { + readonly platform?: NodeJS.Platform; + readonly spawnSyncImpl?: TaskkillRunner; +} + +/** + * On Windows with shell-backed processes, direct kill only terminates the + * wrapper process. Use `taskkill /T` first so the spawned process tree exits. + */ +export function killChildProcessTree( + child: KillableChildProcess, + signal: NodeJS.Signals = "SIGTERM", + options: KillChildProcessTreeOptions = {}, +): void { + const platform = options.platform ?? process.platform; + if (platform === "win32" && child.pid !== undefined) { + try { + const result = (options.spawnSyncImpl ?? spawnSync)( + "taskkill", + ["/pid", String(child.pid), "/T", "/F"], + { + stdio: "ignore", + }, + ); + if (!result.error && result.status === 0) { + return; + } + } catch { + // Fall through to direct kill when taskkill is unavailable. + } + } + + child.kill(signal); +} diff --git a/apps/server/src/process/outputBuffer.test.ts b/apps/server/src/process/outputBuffer.test.ts new file mode 100644 index 0000000000..4c9a727a0b --- /dev/null +++ b/apps/server/src/process/outputBuffer.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from "vitest"; + +import { appendTextChunkWithinByteLimit, limitChunkToByteLimit } from "./outputBuffer"; + +describe("outputBuffer", () => { + it("keeps a full chunk when it fits within the byte limit", () => { + const chunk = Buffer.from("hello"); + + expect(limitChunkToByteLimit(chunk, 2, 10)).toEqual({ + chunk, + nextBytes: 7, + truncated: false, + overflow: false, + }); + }); + + it("truncates a chunk to the remaining byte budget", () => { + const chunk = Buffer.from("abcdef"); + const limited = limitChunkToByteLimit(chunk, 3, 5); + + expect(Buffer.from(limited.chunk).toString()).toBe("ab"); + expect(limited.nextBytes).toBe(5); + expect(limited.truncated).toBe(true); + expect(limited.overflow).toBe(true); + }); + + it("appends only the bytes that fit", () => { + expect(appendTextChunkWithinByteLimit("pre", 3, Buffer.from("abcdef"), 7)).toEqual({ + next: "preabcd", + nextBytes: 7, + truncated: true, + }); + }); +}); diff --git a/apps/server/src/process/outputBuffer.ts b/apps/server/src/process/outputBuffer.ts new file mode 100644 index 0000000000..0258f06e69 --- /dev/null +++ b/apps/server/src/process/outputBuffer.ts @@ -0,0 +1,57 @@ +export interface LimitedChunk { + readonly chunk: Uint8Array; + readonly nextBytes: number; + readonly truncated: boolean; + readonly overflow: boolean; +} + +export function limitChunkToByteLimit( + chunk: Uint8Array, + currentBytes: number, + maxBytes: number, +): LimitedChunk { + const remaining = maxBytes - currentBytes; + if (remaining <= 0) { + return { + chunk: new Uint8Array(), + nextBytes: currentBytes, + truncated: true, + overflow: true, + }; + } + + if (chunk.byteLength <= remaining) { + return { + chunk, + nextBytes: currentBytes + chunk.byteLength, + truncated: false, + overflow: false, + }; + } + + return { + chunk: chunk.subarray(0, remaining), + nextBytes: currentBytes + remaining, + truncated: true, + overflow: true, + }; +} + +export function appendTextChunkWithinByteLimit( + target: string, + currentBytes: number, + chunk: Uint8Array, + maxBytes: number, +): { + readonly next: string; + readonly nextBytes: number; + readonly truncated: boolean; +} { + const limited = limitChunkToByteLimit(chunk, currentBytes, maxBytes); + + return { + next: `${target}${Buffer.from(limited.chunk).toString()}`, + nextBytes: limited.nextBytes, + truncated: limited.truncated, + }; +} diff --git a/apps/server/src/process/runProbeProcess.test.ts b/apps/server/src/process/runProbeProcess.test.ts new file mode 100644 index 0000000000..5eff1e525e --- /dev/null +++ b/apps/server/src/process/runProbeProcess.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it, vi } from "vitest"; + +import { runProbeProcess } from "./runProbeProcess"; + +describe("runProbeProcess", () => { + it("forces truncated output mode while preserving other options", async () => { + const runner = vi.fn().mockResolvedValue({ + stdout: "", + stderr: "", + code: 0, + signal: null, + timedOut: false, + }); + + await runProbeProcess( + "ps", + ["-eo", "pid=,ppid="], + { + timeoutMs: 500, + allowNonZeroExit: true, + maxBufferBytes: 1024, + cwd: "/tmp", + }, + runner, + ); + + expect(runner).toHaveBeenCalledWith("ps", ["-eo", "pid=,ppid="], { + timeoutMs: 500, + allowNonZeroExit: true, + maxBufferBytes: 1024, + cwd: "/tmp", + outputMode: "truncate", + }); + }); +}); diff --git a/apps/server/src/process/runProbeProcess.ts b/apps/server/src/process/runProbeProcess.ts new file mode 100644 index 0000000000..cd0b4bb0ce --- /dev/null +++ b/apps/server/src/process/runProbeProcess.ts @@ -0,0 +1,17 @@ +import { runProcess, type ProcessRunOptions, type ProcessRunResult } from "../processRunner"; + +type ProcessRunner = typeof runProcess; + +export type ProbeProcessOptions = Omit; + +export function runProbeProcess( + command: string, + args: readonly string[], + options: ProbeProcessOptions = {}, + runner: ProcessRunner = runProcess, +): Promise { + return runner(command, args, { + ...options, + outputMode: "truncate", + }); +} diff --git a/apps/server/src/process/windowsCommand.test.ts b/apps/server/src/process/windowsCommand.test.ts new file mode 100644 index 0000000000..6edcfbcd40 --- /dev/null +++ b/apps/server/src/process/windowsCommand.test.ts @@ -0,0 +1,65 @@ +import { assert, it } from "@effect/vitest"; + +import { + isWindowsBatchShim, + isWindowsCommandNotFound, + makeWindowsCmdCommandLine, + makeWindowsCmdSpawnArguments, + quoteForWindowsCmd, + resolveWindowsCommandShell, +} from "./windowsCommand"; + +it("resolves cmd.exe from Windows command shell environment variables", () => { + assert.equal(resolveWindowsCommandShell({}), "cmd.exe"); + assert.equal( + resolveWindowsCommandShell({ COMSPEC: "C:\\Windows\\System32\\cmd.exe" }), + "C:\\Windows\\System32\\cmd.exe", + ); + assert.equal( + resolveWindowsCommandShell({ ComSpec: "C:\\custom\\cmd.exe" }), + "C:\\custom\\cmd.exe", + ); +}); + +it("detects Windows batch shims case-insensitively", () => { + assert.equal(isWindowsBatchShim("code.cmd"), true); + assert.equal(isWindowsBatchShim("C:\\tools\\launcher.BAT"), true); + assert.equal(isWindowsBatchShim("/tmp/code.exe"), false); +}); + +it("detects Windows command-not-found exits", () => { + assert.equal(isWindowsCommandNotFound(9009, "", "win32"), true); + assert.equal( + isWindowsCommandNotFound( + 1, + "'foo' is not recognized as an internal or external command", + "win32", + ), + true, + ); + assert.equal(isWindowsCommandNotFound(9009, "", "darwin"), false); +}); + +it("quotes Windows cmd values safely", () => { + assert.equal( + quoteForWindowsCmd('C:\\work\\100% real\\"quoted".ts:12:4'), + '"C:\\work\\100%% real\\""quoted"".ts:12:4"', + ); +}); + +it("builds Windows cmd command lines safely", () => { + assert.equal( + makeWindowsCmdCommandLine("code.cmd", ["--goto", "file.ts:12:4"]), + '""code.cmd" "--goto" "file.ts:12:4""', + ); +}); + +it("builds Windows cmd spawn arguments with cmd control flags", () => { + assert.deepEqual(makeWindowsCmdSpawnArguments("code.cmd", ["--goto", "file.ts:1:1"]), [ + "/d", + "/v:off", + "/s", + "/c", + '""code.cmd" "--goto" "file.ts:1:1""', + ]); +}); diff --git a/apps/server/src/process/windowsCommand.ts b/apps/server/src/process/windowsCommand.ts new file mode 100644 index 0000000000..5f0d35b511 --- /dev/null +++ b/apps/server/src/process/windowsCommand.ts @@ -0,0 +1,35 @@ +export function resolveWindowsCommandShell(env: NodeJS.ProcessEnv): string { + return env.ComSpec ?? env.COMSPEC ?? "cmd.exe"; +} + +export function isWindowsCommandNotFound( + code: number | null, + stderr: string, + platform: NodeJS.Platform = process.platform, +): boolean { + if (platform !== "win32") return false; + if (code === 9009) return true; + return /is not recognized as an internal or external command/i.test(stderr); +} + +export function isWindowsBatchShim(filePath: string): boolean { + return /\.(cmd|bat)$/i.test(filePath); +} + +export function quoteForWindowsCmd(value: string): string { + return `"${value.replaceAll("%", "%%").replaceAll('"', '""')}"`; +} + +export function makeWindowsCmdCommandLine( + commandPath: string, + args: ReadonlyArray, +): string { + return `"${[commandPath, ...args].map(quoteForWindowsCmd).join(" ")}"`; +} + +export function makeWindowsCmdSpawnArguments( + commandPath: string, + args: ReadonlyArray, +): ReadonlyArray { + return ["/d", "/v:off", "/s", "/c", makeWindowsCmdCommandLine(commandPath, args)]; +} diff --git a/apps/server/src/processRunner.ts b/apps/server/src/processRunner.ts index 5402612887..63c83bf2e4 100644 --- a/apps/server/src/processRunner.ts +++ b/apps/server/src/processRunner.ts @@ -1,4 +1,7 @@ -import { type ChildProcess as ChildProcessHandle, spawn, spawnSync } from "node:child_process"; +import { spawn } from "node:child_process"; +import { killChildProcessTree } from "./process/killTree"; +import { appendTextChunkWithinByteLimit } from "./process/outputBuffer"; +import { isWindowsCommandNotFound } from "./process/windowsCommand"; export interface ProcessRunOptions { cwd?: string | undefined; @@ -37,12 +40,6 @@ function normalizeSpawnError(command: string, args: readonly string[], error: un return new Error(`Failed to run ${commandLabel(command, args)}: ${error.message}`); } -export function isWindowsCommandNotFound(code: number | null, stderr: string): boolean { - if (process.platform !== "win32") return false; - if (code === 9009) return true; - return /is not recognized as an internal or external command/i.test(stderr); -} - function normalizeExitError( command: string, args: readonly string[], @@ -80,51 +77,6 @@ function normalizeBufferError( const DEFAULT_MAX_BUFFER_BYTES = 8 * 1024 * 1024; -/** - * On Windows with `shell: true`, `child.kill()` only terminates the `cmd.exe` - * wrapper, leaving the actual command running. Use `taskkill /T` to kill the - * entire process tree instead. - */ -function killChild(child: ChildProcessHandle, signal: NodeJS.Signals = "SIGTERM"): void { - if (process.platform === "win32" && child.pid !== undefined) { - try { - spawnSync("taskkill", ["/pid", String(child.pid), "/T", "/F"], { stdio: "ignore" }); - return; - } catch { - // fallback to direct kill - } - } - child.kill(signal); -} - -function appendChunkWithinLimit( - target: string, - currentBytes: number, - chunk: Buffer, - maxBytes: number, -): { - next: string; - nextBytes: number; - truncated: boolean; -} { - const remaining = maxBytes - currentBytes; - if (remaining <= 0) { - return { next: target, nextBytes: currentBytes, truncated: true }; - } - if (chunk.length <= remaining) { - return { - next: `${target}${chunk.toString()}`, - nextBytes: currentBytes + chunk.length, - truncated: false, - }; - } - return { - next: `${target}${chunk.subarray(0, remaining).toString()}`, - nextBytes: currentBytes + remaining, - truncated: true, - }; -} - export async function runProcess( command: string, args: readonly string[], @@ -154,9 +106,9 @@ export async function runProcess( const timeoutTimer = setTimeout(() => { timedOut = true; - killChild(child, "SIGTERM"); + killChildProcessTree(child, "SIGTERM"); forceKillTimer = setTimeout(() => { - killChild(child, "SIGKILL"); + killChildProcessTree(child, "SIGKILL"); }, 1_000); }, timeoutMs); @@ -171,7 +123,7 @@ export async function runProcess( }; const fail = (error: Error): void => { - killChild(child, "SIGTERM"); + killChildProcessTree(child, "SIGTERM"); finalize(() => { reject(error); }); @@ -183,7 +135,12 @@ export async function runProcess( const byteLength = chunkBuffer.length; if (stream === "stdout") { if (outputMode === "truncate") { - const appended = appendChunkWithinLimit(stdout, stdoutBytes, chunkBuffer, maxBufferBytes); + const appended = appendTextChunkWithinByteLimit( + stdout, + stdoutBytes, + chunkBuffer, + maxBufferBytes, + ); stdout = appended.next; stdoutBytes = appended.nextBytes; stdoutTruncated = stdoutTruncated || appended.truncated; @@ -196,7 +153,12 @@ export async function runProcess( } } else { if (outputMode === "truncate") { - const appended = appendChunkWithinLimit(stderr, stderrBytes, chunkBuffer, maxBufferBytes); + const appended = appendTextChunkWithinByteLimit( + stderr, + stderrBytes, + chunkBuffer, + maxBufferBytes, + ); stderr = appended.next; stderrBytes = appended.nextBytes; stderrTruncated = stderrTruncated || appended.truncated; diff --git a/apps/server/src/provider/providerSnapshot.ts b/apps/server/src/provider/providerSnapshot.ts index 40246563ae..849d9f0bd3 100644 --- a/apps/server/src/provider/providerSnapshot.ts +++ b/apps/server/src/provider/providerSnapshot.ts @@ -10,7 +10,7 @@ import type { import { Effect, Stream } from "effect"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { normalizeModelSlug } from "@t3tools/shared/model"; -import { isWindowsCommandNotFound } from "../processRunner"; +import { isWindowsCommandNotFound } from "../process/windowsCommand"; export const DEFAULT_TIMEOUT_MS = 4_000; diff --git a/apps/server/src/terminal/Layers/Manager.ts b/apps/server/src/terminal/Layers/Manager.ts index 4bdeba68e1..7a1e716870 100644 --- a/apps/server/src/terminal/Layers/Manager.ts +++ b/apps/server/src/terminal/Layers/Manager.ts @@ -23,12 +23,13 @@ import { } from "effect"; import { ServerConfig } from "../../config"; +import { runProbeProcess } from "../../process/runProbeProcess"; +import { resolveWindowsCommandShell } from "../../process/windowsCommand"; import { increment, terminalRestartsTotal, terminalSessionsTotal, } from "../../observability/Metrics"; -import { runProcess } from "../../processRunner"; import { TerminalCwdError, TerminalHistoryError, @@ -188,7 +189,7 @@ function enqueueProcessEvent( function defaultShellResolver(): string { if (process.platform === "win32") { - return process.env.ComSpec ?? "cmd.exe"; + return resolveWindowsCommandShell(process.env); } return process.env.SHELL ?? "bash"; } @@ -240,7 +241,7 @@ function resolveShellCandidates(shellResolver: () => string): ShellCandidate[] { if (process.platform === "win32") { return uniqueShellCandidates([ requested, - shellCandidateFromCommand(process.env.ComSpec ?? null), + shellCandidateFromCommand(resolveWindowsCommandShell(process.env)), shellCandidateFromCommand("powershell.exe"), shellCandidateFromCommand("cmd.exe"), ]); @@ -314,11 +315,10 @@ function checkWindowsSubprocessActivity( ].join("; "); return Effect.tryPromise({ try: () => - runProcess("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", command], { + runProbeProcess("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", command], { timeoutMs: 1_500, allowNonZeroExit: true, maxBufferBytes: 32_768, - outputMode: "truncate", }), catch: (cause) => new TerminalSubprocessCheckError({ @@ -335,11 +335,10 @@ const checkPosixSubprocessActivity = Effect.fn("terminal.checkPosixSubprocessAct ): Effect.fn.Return { const runPgrep = Effect.tryPromise({ try: () => - runProcess("pgrep", ["-P", String(terminalPid)], { + runProbeProcess("pgrep", ["-P", String(terminalPid)], { timeoutMs: 1_000, allowNonZeroExit: true, maxBufferBytes: 32_768, - outputMode: "truncate", }), catch: (cause) => new TerminalSubprocessCheckError({ @@ -352,11 +351,10 @@ const checkPosixSubprocessActivity = Effect.fn("terminal.checkPosixSubprocessAct const runPs = Effect.tryPromise({ try: () => - runProcess("ps", ["-eo", "pid=,ppid="], { + runProbeProcess("ps", ["-eo", "pid=,ppid="], { timeoutMs: 1_000, allowNonZeroExit: true, maxBufferBytes: 262_144, - outputMode: "truncate", }), catch: (cause) => new TerminalSubprocessCheckError({ diff --git a/apps/server/tmp-test.cmd b/apps/server/tmp-test.cmd new file mode 100644 index 0000000000..b095f6e5ac --- /dev/null +++ b/apps/server/tmp-test.cmd @@ -0,0 +1,3 @@ +@echo off +echo started >> spawned2.txt + diff --git a/packages/contracts/src/environment.ts b/packages/contracts/src/environment.ts index bc3b5459cd..9e97be83ea 100644 --- a/packages/contracts/src/environment.ts +++ b/packages/contracts/src/environment.ts @@ -1,4 +1,4 @@ -import { Effect, Schema } from "effect"; +import { Schema } from "effect"; import { EnvironmentId, ProjectId, ThreadId, TrimmedNonEmptyString } from "./baseSchemas"; @@ -20,7 +20,7 @@ export const ExecutionEnvironmentPlatform = Schema.Struct({ export type ExecutionEnvironmentPlatform = typeof ExecutionEnvironmentPlatform.Type; export const ExecutionEnvironmentCapabilities = Schema.Struct({ - repositoryIdentity: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), + repositoryIdentity: Schema.Boolean.pipe(Schema.withDecodingDefault(() => false)), }); export type ExecutionEnvironmentCapabilities = typeof ExecutionEnvironmentCapabilities.Type; diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index c80367d158..7848be0850 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -1,4 +1,4 @@ -import { Effect, Option, Schema, SchemaIssue, Struct } from "effect"; +import { Option, Schema, SchemaIssue, Struct } from "effect"; import { ClaudeModelOptions, CodexModelOptions } from "./model"; import { RepositoryIdentity } from "./environment"; import { @@ -177,9 +177,9 @@ export const OrchestrationProposedPlan = Schema.Struct({ id: OrchestrationProposedPlanId, turnId: Schema.NullOr(TurnId), planMarkdown: TrimmedNonEmptyString, - implementedAt: Schema.NullOr(IsoDateTime).pipe(Schema.withDecodingDefault(Effect.succeed(null))), + implementedAt: Schema.NullOr(IsoDateTime).pipe(Schema.withDecodingDefault(() => null)), implementationThreadId: Schema.NullOr(ThreadId).pipe( - Schema.withDecodingDefault(Effect.succeed(null)), + Schema.withDecodingDefault(() => null), ), createdAt: IsoDateTime, updatedAt: IsoDateTime, @@ -206,7 +206,7 @@ export const OrchestrationSession = Schema.Struct({ threadId: ThreadId, status: OrchestrationSessionStatus, providerName: Schema.NullOr(TrimmedNonEmptyString), - runtimeMode: RuntimeMode.pipe(Schema.withDecodingDefault(Effect.succeed(DEFAULT_RUNTIME_MODE))), + runtimeMode: RuntimeMode.pipe(Schema.withDecodingDefault(() => DEFAULT_RUNTIME_MODE)), activeTurnId: Schema.NullOr(TurnId), lastError: Schema.NullOr(TrimmedNonEmptyString), updatedAt: IsoDateTime, @@ -281,18 +281,18 @@ export const OrchestrationThread = Schema.Struct({ modelSelection: ModelSelection, runtimeMode: RuntimeMode, interactionMode: ProviderInteractionMode.pipe( - Schema.withDecodingDefault(Effect.succeed(DEFAULT_PROVIDER_INTERACTION_MODE)), + Schema.withDecodingDefault(() => DEFAULT_PROVIDER_INTERACTION_MODE), ), branch: Schema.NullOr(TrimmedNonEmptyString), worktreePath: Schema.NullOr(TrimmedNonEmptyString), latestTurn: Schema.NullOr(OrchestrationLatestTurn), createdAt: IsoDateTime, updatedAt: IsoDateTime, - archivedAt: Schema.NullOr(IsoDateTime).pipe(Schema.withDecodingDefault(Effect.succeed(null))), + archivedAt: Schema.NullOr(IsoDateTime).pipe(Schema.withDecodingDefault(() => null)), deletedAt: Schema.NullOr(IsoDateTime), messages: Schema.Array(OrchestrationMessage), proposedPlans: Schema.Array(OrchestrationProposedPlan).pipe( - Schema.withDecodingDefault(Effect.succeed([])), + Schema.withDecodingDefault(() => []), ), activities: Schema.Array(OrchestrationThreadActivity), checkpoints: Schema.Array(OrchestrationCheckpointSummary), @@ -343,7 +343,7 @@ const ThreadCreateCommand = Schema.Struct({ modelSelection: ModelSelection, runtimeMode: RuntimeMode, interactionMode: ProviderInteractionMode.pipe( - Schema.withDecodingDefault(Effect.succeed(DEFAULT_PROVIDER_INTERACTION_MODE)), + Schema.withDecodingDefault(() => DEFAULT_PROVIDER_INTERACTION_MODE), ), branch: Schema.NullOr(TrimmedNonEmptyString), worktreePath: Schema.NullOr(TrimmedNonEmptyString), @@ -431,9 +431,9 @@ export const ThreadTurnStartCommand = Schema.Struct({ }), modelSelection: Schema.optional(ModelSelection), titleSeed: Schema.optional(TrimmedNonEmptyString), - runtimeMode: RuntimeMode.pipe(Schema.withDecodingDefault(Effect.succeed(DEFAULT_RUNTIME_MODE))), + runtimeMode: RuntimeMode.pipe(Schema.withDecodingDefault(() => DEFAULT_RUNTIME_MODE)), interactionMode: ProviderInteractionMode.pipe( - Schema.withDecodingDefault(Effect.succeed(DEFAULT_PROVIDER_INTERACTION_MODE)), + Schema.withDecodingDefault(() => DEFAULT_PROVIDER_INTERACTION_MODE), ), bootstrap: Schema.optional(ThreadTurnStartBootstrap), sourceProposedPlan: Schema.optional(SourceProposedPlanReference), @@ -684,9 +684,9 @@ export const ThreadCreatedPayload = Schema.Struct({ projectId: ProjectId, title: TrimmedNonEmptyString, modelSelection: ModelSelection, - runtimeMode: RuntimeMode.pipe(Schema.withDecodingDefault(Effect.succeed(DEFAULT_RUNTIME_MODE))), + runtimeMode: RuntimeMode.pipe(Schema.withDecodingDefault(() => DEFAULT_RUNTIME_MODE)), interactionMode: ProviderInteractionMode.pipe( - Schema.withDecodingDefault(Effect.succeed(DEFAULT_PROVIDER_INTERACTION_MODE)), + Schema.withDecodingDefault(() => DEFAULT_PROVIDER_INTERACTION_MODE), ), branch: Schema.NullOr(TrimmedNonEmptyString), worktreePath: Schema.NullOr(TrimmedNonEmptyString), @@ -728,7 +728,7 @@ export const ThreadRuntimeModeSetPayload = Schema.Struct({ export const ThreadInteractionModeSetPayload = Schema.Struct({ threadId: ThreadId, interactionMode: ProviderInteractionMode.pipe( - Schema.withDecodingDefault(Effect.succeed(DEFAULT_PROVIDER_INTERACTION_MODE)), + Schema.withDecodingDefault(() => DEFAULT_PROVIDER_INTERACTION_MODE), ), updatedAt: IsoDateTime, }); @@ -750,9 +750,9 @@ export const ThreadTurnStartRequestedPayload = Schema.Struct({ messageId: MessageId, modelSelection: Schema.optional(ModelSelection), titleSeed: Schema.optional(TrimmedNonEmptyString), - runtimeMode: RuntimeMode.pipe(Schema.withDecodingDefault(Effect.succeed(DEFAULT_RUNTIME_MODE))), + runtimeMode: RuntimeMode.pipe(Schema.withDecodingDefault(() => DEFAULT_RUNTIME_MODE)), interactionMode: ProviderInteractionMode.pipe( - Schema.withDecodingDefault(Effect.succeed(DEFAULT_PROVIDER_INTERACTION_MODE)), + Schema.withDecodingDefault(() => DEFAULT_PROVIDER_INTERACTION_MODE), ), sourceProposedPlan: Schema.optional(SourceProposedPlanReference), createdAt: IsoDateTime, diff --git a/packages/contracts/src/providerRuntime.ts b/packages/contracts/src/providerRuntime.ts index 6b03d70d56..81231d88f6 100644 --- a/packages/contracts/src/providerRuntime.ts +++ b/packages/contracts/src/providerRuntime.ts @@ -1,4 +1,4 @@ -import { Effect, Schema } from "effect"; +import { Option, Schema } from "effect"; import { EventId, IsoDateTime, @@ -435,7 +435,7 @@ export const UserInputQuestion = Schema.Struct({ question: TrimmedNonEmptyStringSchema, options: Schema.Array(UserInputQuestionOption), multiSelect: Schema.optional(Schema.Boolean).pipe( - Schema.withConstructorDefault(Effect.succeed(false)), + Schema.withConstructorDefault(() => Option.some(false)), ), }); export type UserInputQuestion = typeof UserInputQuestion.Type; diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index 50db737c6a..56a4ccd80c 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -1,4 +1,4 @@ -import { Effect, Schema } from "effect"; +import { Schema } from "effect"; import { ExecutionEnvironmentDescriptor } from "./environment"; import { ServerAuthDescriptor } from "./auth"; import { @@ -92,9 +92,9 @@ export const ServerProvider = Schema.Struct({ message: Schema.optional(TrimmedNonEmptyString), models: Schema.Array(ServerProviderModel), slashCommands: Schema.Array(ServerProviderSlashCommand).pipe( - Schema.withDecodingDefault(Effect.succeed([])), + Schema.withDecodingDefault(() => []), ), - skills: Schema.Array(ServerProviderSkill).pipe(Schema.withDecodingDefault(Effect.succeed([]))), + skills: Schema.Array(ServerProviderSkill).pipe(Schema.withDecodingDefault(() => [])), }); export type ServerProvider = typeof ServerProvider.Type; diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 426f8bee56..5a18527414 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -24,17 +24,17 @@ export type SidebarThreadSortOrder = typeof SidebarThreadSortOrder.Type; export const DEFAULT_SIDEBAR_THREAD_SORT_ORDER: SidebarThreadSortOrder = "updated_at"; export const ClientSettingsSchema = Schema.Struct({ - confirmThreadArchive: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), - confirmThreadDelete: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), - diffWordWrap: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), + confirmThreadArchive: Schema.Boolean.pipe(Schema.withDecodingDefault(() => false)), + confirmThreadDelete: Schema.Boolean.pipe(Schema.withDecodingDefault(() => true)), + diffWordWrap: Schema.Boolean.pipe(Schema.withDecodingDefault(() => false)), sidebarProjectSortOrder: SidebarProjectSortOrder.pipe( - Schema.withDecodingDefault(Effect.succeed(DEFAULT_SIDEBAR_PROJECT_SORT_ORDER)), + Schema.withDecodingDefault(() => DEFAULT_SIDEBAR_PROJECT_SORT_ORDER), ), sidebarThreadSortOrder: SidebarThreadSortOrder.pipe( - Schema.withDecodingDefault(Effect.succeed(DEFAULT_SIDEBAR_THREAD_SORT_ORDER)), + Schema.withDecodingDefault(() => DEFAULT_SIDEBAR_THREAD_SORT_ORDER), ), timestampFormat: TimestampFormat.pipe( - Schema.withDecodingDefault(Effect.succeed(DEFAULT_TIMESTAMP_FORMAT)), + Schema.withDecodingDefault(() => DEFAULT_TIMESTAMP_FORMAT), ), }); export type ClientSettings = typeof ClientSettingsSchema.Type; @@ -55,50 +55,48 @@ const makeBinaryPathSetting = (fallback: string) => encode: (value) => Effect.succeed(value), }), ), - Schema.withDecodingDefault(Effect.succeed(fallback)), + Schema.withDecodingDefault(() => fallback), ); export const CodexSettings = Schema.Struct({ - enabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), + enabled: Schema.Boolean.pipe(Schema.withDecodingDefault(() => true)), binaryPath: makeBinaryPathSetting("codex"), - homePath: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))), - customModels: Schema.Array(Schema.String).pipe(Schema.withDecodingDefault(Effect.succeed([]))), + homePath: TrimmedString.pipe(Schema.withDecodingDefault(() => "")), + customModels: Schema.Array(Schema.String).pipe(Schema.withDecodingDefault(() => [])), }); export type CodexSettings = typeof CodexSettings.Type; export const ClaudeSettings = Schema.Struct({ - enabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), + enabled: Schema.Boolean.pipe(Schema.withDecodingDefault(() => true)), binaryPath: makeBinaryPathSetting("claude"), - customModels: Schema.Array(Schema.String).pipe(Schema.withDecodingDefault(Effect.succeed([]))), + customModels: Schema.Array(Schema.String).pipe(Schema.withDecodingDefault(() => [])), }); export type ClaudeSettings = typeof ClaudeSettings.Type; export const ObservabilitySettings = Schema.Struct({ - otlpTracesUrl: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))), - otlpMetricsUrl: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))), + otlpTracesUrl: TrimmedString.pipe(Schema.withDecodingDefault(() => "")), + otlpMetricsUrl: TrimmedString.pipe(Schema.withDecodingDefault(() => "")), }); export type ObservabilitySettings = typeof ObservabilitySettings.Type; export const ServerSettings = Schema.Struct({ - enableAssistantStreaming: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), + enableAssistantStreaming: Schema.Boolean.pipe(Schema.withDecodingDefault(() => false)), defaultThreadEnvMode: ThreadEnvMode.pipe( - Schema.withDecodingDefault(Effect.succeed("local" as const satisfies ThreadEnvMode)), + Schema.withDecodingDefault(() => "local" as const satisfies ThreadEnvMode), ), textGenerationModelSelection: ModelSelection.pipe( - Schema.withDecodingDefault( - Effect.succeed({ - provider: "codex" as const, - model: DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER.codex, - }), - ), + Schema.withDecodingDefault(() => ({ + provider: "codex" as const, + model: DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER.codex, + })), ), // Provider specific settings providers: Schema.Struct({ - codex: CodexSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), - claudeAgent: ClaudeSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), - }).pipe(Schema.withDecodingDefault(Effect.succeed({}))), - observability: ObservabilitySettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), + codex: CodexSettings.pipe(Schema.withDecodingDefault(() => ({}))), + claudeAgent: ClaudeSettings.pipe(Schema.withDecodingDefault(() => ({}))), + }).pipe(Schema.withDecodingDefault(() => ({}))), + observability: ObservabilitySettings.pipe(Schema.withDecodingDefault(() => ({}))), }); export type ServerSettings = typeof ServerSettings.Type; diff --git a/packages/contracts/src/terminal.ts b/packages/contracts/src/terminal.ts index 3fe883b442..8b20254def 100644 --- a/packages/contracts/src/terminal.ts +++ b/packages/contracts/src/terminal.ts @@ -1,4 +1,4 @@ -import { Effect, Schema } from "effect"; +import { Schema } from "effect"; import { TrimmedNonEmptyString } from "./baseSchemas"; export const DEFAULT_TERMINAL_ID = "default"; @@ -20,7 +20,7 @@ const TerminalEnvSchema = Schema.Record(TerminalEnvKeySchema, TerminalEnvValueSc ); const TerminalIdWithDefaultSchema = TerminalIdSchema.pipe( - Schema.withDecodingDefault(Effect.succeed(DEFAULT_TERMINAL_ID)), + Schema.withDecodingDefault(() => DEFAULT_TERMINAL_ID), ); export const TerminalThreadInput = Schema.Struct({