From 2ade03788bf6cb1dd0887f4e452fd7d9ec938b77 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 31 Mar 2026 00:31:41 -0700 Subject: [PATCH 01/11] Refactor open service onto Effect-backed process spawning - Add external opener support alongside browser/editor launches - Remove the `open` dependency and cover platform behavior in tests --- apps/server/package.json | 1 - apps/server/src/main.test.ts | 2 + apps/server/src/open.test.ts | 777 +++++++++++++++++++++---------- apps/server/src/open.ts | 743 ++++++++++++++++++++++------- apps/server/src/wsServer.test.ts | 6 + apps/server/src/wsServer.ts | 9 +- bun.lock | 15 - 7 files changed, 1125 insertions(+), 428 deletions(-) diff --git a/apps/server/package.json b/apps/server/package.json index e473e21fd1..b85e1c1d46 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -29,7 +29,6 @@ "@pierre/diffs": "^1.1.0-beta.16", "effect": "catalog:", "node-pty": "^1.1.0", - "open": "^10.1.0", "ws": "^8.18.0" }, "devDependencies": { diff --git a/apps/server/src/main.test.ts b/apps/server/src/main.test.ts index 0d990a81c8..1fa0a3752d 100644 --- a/apps/server/src/main.test.ts +++ b/apps/server/src/main.test.ts @@ -51,6 +51,8 @@ const testLayer = Layer.mergeAll( stopSignal: Effect.void, } satisfies ServerShape), Layer.succeed(Open, { + getAvailableEditors: Effect.succeed([]), + openExternal: () => Effect.void, openBrowser: (_target: string) => Effect.void, openInEditor: () => Effect.void, } satisfies OpenShape), diff --git a/apps/server/src/open.test.ts b/apps/server/src/open.test.ts index 947a60ac2a..41ed33eeb4 100644 --- a/apps/server/src/open.test.ts +++ b/apps/server/src/open.test.ts @@ -1,269 +1,558 @@ -import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as NodeFileSystem from "@effect/platform-node/NodeFileSystem"; +import * as NodePath from "@effect/platform-node/NodePath"; import { assert, it } from "@effect/vitest"; -import { assertSuccess } from "@effect/vitest/utils"; -import { FileSystem, Path, Effect } from "effect"; - import { - isCommandAvailable, - launchDetached, - resolveAvailableEditors, - resolveEditorLaunch, -} from "./open"; - -it.layer(NodeServices.layer)("resolveEditorLaunch", (it) => { - it.effect("returns commands for command-based editors", () => - Effect.gen(function* () { - const antigravityLaunch = yield* resolveEditorLaunch( - { cwd: "/tmp/workspace", editor: "antigravity" }, - "darwin", - ); - assert.deepEqual(antigravityLaunch, { - command: "agy", - args: ["/tmp/workspace"], - }); + Deferred, + Effect, + Exit, + FileSystem, + Layer, + PlatformError, + Scope, + Sink, + Stream, +} from "effect"; +import { ChildProcessSpawner } from "effect/unstable/process"; - const cursorLaunch = yield* resolveEditorLaunch( - { cwd: "/tmp/workspace", editor: "cursor" }, - "darwin", - ); - assert.deepEqual(cursorLaunch, { - command: "cursor", - args: ["/tmp/workspace"], - }); +import { makeOpenLayer, Open } from "./open"; - const vscodeLaunch = yield* resolveEditorLaunch( - { cwd: "/tmp/workspace", editor: "vscode" }, - "darwin", - ); - assert.deepEqual(vscodeLaunch, { - command: "code", - args: ["/tmp/workspace"], - }); +const encoder = new TextEncoder(); - const vscodeInsidersLaunch = yield* resolveEditorLaunch( - { cwd: "/tmp/workspace", editor: "vscode-insiders" }, - "darwin", - ); - assert.deepEqual(vscodeInsidersLaunch, { - command: "code-insiders", - args: ["/tmp/workspace"], - }); +interface SpawnCall { + readonly command: string; + readonly args: ReadonlyArray; + readonly detached?: boolean | undefined; + readonly shell?: boolean | string | undefined; + readonly stdin?: unknown; + readonly stdout?: unknown; + readonly stderr?: unknown; +} - const vscodiumLaunch = yield* resolveEditorLaunch( - { cwd: "/tmp/workspace", editor: "vscodium" }, - "darwin", - ); - assert.deepEqual(vscodiumLaunch, { - command: "codium", - args: ["/tmp/workspace"], - }); +function decodePowerShellCommand(encoded: string): string { + return Buffer.from(encoded, "base64").toString("utf16le"); +} - const zedLaunch = yield* resolveEditorLaunch( - { cwd: "/tmp/workspace", editor: "zed" }, - "darwin", +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, + }), ); - assert.deepEqual(zedLaunch, { - command: "zed", - args: ["/tmp/workspace"], - }); }), ); +} - it.effect("uses --goto when editor supports line/column suffixes", () => - Effect.gen(function* () { - const lineOnly = yield* resolveEditorLaunch( - { cwd: "/tmp/workspace/AGENTS.md:48", editor: "cursor" }, - "darwin", - ); - assert.deepEqual(lineOnly, { - command: "cursor", - args: ["--goto", "/tmp/workspace/AGENTS.md:48"], - }); - - const lineAndColumn = yield* resolveEditorLaunch( - { cwd: "/tmp/workspace/src/open.ts:71:5", editor: "cursor" }, - "darwin", - ); - assert.deepEqual(lineAndColumn, { - command: "cursor", - args: ["--goto", "/tmp/workspace/src/open.ts:71:5"], - }); +const provideOpen = ( + options: Parameters[0], + spawnLayer: Layer.Layer, +) => + makeOpenLayer(options).pipe( + Layer.provide(Layer.mergeAll(NodeFileSystem.layer, NodePath.layer, spawnLayer)), + ); - const vscodeLineAndColumn = yield* resolveEditorLaunch( - { cwd: "/tmp/workspace/src/open.ts:71:5", editor: "vscode" }, - "darwin", - ); - assert.deepEqual(vscodeLineAndColumn, { - command: "code", - args: ["--goto", "/tmp/workspace/src/open.ts:71:5"], - }); +const runOpen = ( + options: Parameters[0], + spawnLayer: Layer.Layer, + effect: Effect.Effect, +) => effect.pipe(Effect.provide(provideOpen(options, spawnLayer))); - const vscodeInsidersLineAndColumn = yield* resolveEditorLaunch( - { cwd: "/tmp/workspace/src/open.ts:71:5", editor: "vscode-insiders" }, - "darwin", - ); - assert.deepEqual(vscodeInsidersLineAndColumn, { - command: "code-insiders", - args: ["--goto", "/tmp/workspace/src/open.ts:71:5"], - }); +const readOpen = Effect.service(Open); - const vscodiumLineAndColumn = yield* resolveEditorLaunch( - { cwd: "/tmp/workspace/src/open.ts:71:5", editor: "vscodium" }, - "darwin", - ); - assert.deepEqual(vscodiumLineAndColumn, { - command: "codium", +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", + }, + }, + spawnerLayer(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`, + }, + spawnerLayer(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 }, + }, + spawnerLayer(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)), +); - const zedLineAndColumn = yield* resolveEditorLaunch( - { cwd: "/tmp/workspace/src/open.ts:71:5", editor: "zed" }, - "darwin", - ); - assert.deepEqual(zedLineAndColumn, { - command: "zed", - args: ["/tmp/workspace/src/open.ts:71:5"], - }); - }), - ); +it.effect("openInEditor launches Windows batch shims through cmd.exe without shell mode", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-open-vscode-win32-" }); + yield* fs.writeFileString(`${dir}/code.cmd`, "@echo off\r\n"); - it.effect("maps file-manager editor to OS open commands", () => - Effect.gen(function* () { - const launch1 = yield* resolveEditorLaunch( - { cwd: "/tmp/workspace", editor: "file-manager" }, - "darwin", - ); - assert.deepEqual(launch1, { - command: "open", + const calls: Array = []; + const open = yield* runOpen( + { + platform: "win32", + env: { + PATH: dir, + PATHEXT: ".COM;.EXE;.BAT;.CMD", + }, + }, + spawnerLayer(calls), + readOpen, + ); + + yield* open.openInEditor({ + cwd: "C:\\work\\100% real\\file.ts:12:4", + editor: "vscode", + }); + + assert.equal(calls.length, 1); + assert.equal(calls[0]?.command, "cmd.exe"); + assert.deepEqual(calls[0]?.args.slice(0, 4), ["/d", "/v:off", "/s", "/c"]); + assert.equal(calls[0]?.args[4]?.toLowerCase().includes(`${dir}/code.cmd`.toLowerCase()), true); + assert.equal(calls[0]?.args[4]?.includes('"--goto"'), true); + assert.equal(calls[0]?.args[4]?.includes("100%% real"), true); + 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("openInEditor detached launches survive service scope shutdown", () => + 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 exitDeferred = yield* Deferred.make(); + let killCount = 0; + + yield* Effect.acquireUseRelease( + Scope.make("sequential"), + (scope) => + Effect.gen(function* () { + const runtimeServices = yield* Layer.build( + provideOpen( + { + platform: "darwin", + env: { PATH: dir }, + }, + spawnerLayer(calls, () => ({ + awaitExit: exitDeferred, + onKill: () => { + killCount += 1; + }, + })), + ), + ).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.equal(killCount, 0); + assert.deepEqual(calls, [ + { + command: `${dir}/zed`, args: ["/tmp/workspace"], - }); + detached: true, + shell: false, + stdin: "ignore", + stdout: "ignore", + stderr: "ignore", + }, + ]); - const launch2 = yield* resolveEditorLaunch( - { cwd: "C:\\workspace", editor: "file-manager" }, - "win32", - ); - assert.deepEqual(launch2, { - command: "explorer", - args: ["C:\\workspace"], - }); - - const launch3 = yield* resolveEditorLaunch( - { cwd: "/tmp/workspace", editor: "file-manager" }, - "linux", - ); - assert.deepEqual(launch3, { - command: "xdg-open", + yield* Deferred.succeed(exitDeferred, void 0); + yield* Effect.yieldNow; + assert.equal(killCount, 0); + }).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 }, + }, + spawnerLayer(calls), + readOpen, + ); + + yield* open.openInEditor({ + cwd: "/tmp/workspace", + editor: "file-manager", + }); + + assert.deepEqual(calls, [ + { + command: `${dir}/open`, args: ["/tmp/workspace"], - }); - }), - ); -}); - -it.layer(NodeServices.layer)("launchDetached", (it) => { - it.effect("resolves when command can be spawned", () => - Effect.gen(function* () { - const result = yield* launchDetached({ - command: process.execPath, - args: ["-e", "process.exit(0)"], - }).pipe(Effect.result); - assertSuccess(result, undefined); - }), - ); + detached: undefined, + shell: false, + stdin: undefined, + stdout: undefined, + stderr: undefined, + }, + ]); + }).pipe(Effect.provide(NodeFileSystem.layer)), +); - it.effect("rejects when command does not exist", () => - Effect.gen(function* () { - const result = yield* launchDetached({ - command: `t3code-no-such-command-${Date.now()}`, - args: [], - }).pipe(Effect.result); - assert.equal(result._tag, "Failure"); - }), - ); -}); - -it.layer(NodeServices.layer)("isCommandAvailable", (it) => { - it.effect("resolves win32 commands with PATHEXT", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-open-test-" }); - yield* fs.writeFileString(path.join(dir, "code.CMD"), "@echo off\r\n"); - const env = { - PATH: dir, - PATHEXT: ".COM;.EXE;.BAT;.CMD", - } satisfies NodeJS.ProcessEnv; - assert.equal(isCommandAvailable("code", { platform: "win32", env }), true); - }), - ); +it.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`); - it("returns false when a command is not on PATH", () => { - const env = { - PATH: "", - PATHEXT: ".COM;.EXE;.BAT;.CMD", - } satisfies NodeJS.ProcessEnv; - assert.equal(isCommandAvailable("definitely-not-installed", { platform: "win32", env }), false); - }); + const calls: Array = []; + const open = yield* runOpen( + { + platform: "darwin", + env: { PATH: dir }, + }, + spawnerLayer(calls), + readOpen, + ); - it.effect("does not treat bare files without executable extension as available on win32", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-open-test-" }); - yield* fs.writeFileString(path.join(dir, "npm"), "echo nope\r\n"); - const env = { - PATH: dir, - PATHEXT: ".COM;.EXE;.BAT;.CMD", - } satisfies NodeJS.ProcessEnv; - assert.equal(isCommandAvailable("npm", { platform: "win32", env }), false); - }), - ); + yield* open.openBrowser("https://example.com", { + wait: true, + background: true, + newInstance: true, + app: { + name: "google chrome", + arguments: ["--profile-directory=Work"], + }, + }); - it.effect("appends PATHEXT for commands with non-executable extensions on win32", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-open-test-" }); - yield* fs.writeFileString(path.join(dir, "my.tool.CMD"), "@echo off\r\n"); - const env = { - PATH: dir, - PATHEXT: ".COM;.EXE;.BAT;.CMD", - } satisfies NodeJS.ProcessEnv; - assert.equal(isCommandAvailable("my.tool", { platform: "win32", env }), true); - }), - ); + 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("uses platform-specific PATH delimiter for platform overrides", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const firstDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-open-test-" }); - const secondDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-open-test-" }); - yield* fs.writeFileString(path.join(firstDir, "code.CMD"), "@echo off\r\n"); - yield* fs.writeFileString(path.join(secondDir, "code.CMD"), "MZ"); - const env = { - PATH: `${firstDir};${secondDir}`, - PATHEXT: ".COM;.EXE;.BAT;.CMD", - } satisfies NodeJS.ProcessEnv; - assert.equal(isCommandAvailable("code", { platform: "win32", env }), true); - }), - ); -}); - -it.layer(NodeServices.layer)("resolveAvailableEditors", (it) => { - it.effect("returns installed editors for command launches", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-editors-" }); - - yield* fs.writeFileString(path.join(dir, "code-insiders.CMD"), "@echo off\r\n"); - yield* fs.writeFileString(path.join(dir, "codium.CMD"), "@echo off\r\n"); - yield* fs.writeFileString(path.join(dir, "explorer.CMD"), "MZ"); - const editors = resolveAvailableEditors("win32", { - PATH: dir, - PATHEXT: ".COM;.EXE;.BAT;.CMD", - }); - assert.deepEqual(editors, ["vscode-insiders", "vscodium", "file-manager"]); - }), - ); -}); +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", + }, + }, + spawnerLayer(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 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`, + }, + spawnerLayer(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("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`, + }, + spawnerLayer(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: "" }, + }, + spawnerLayer(calls), + readOpen, + ); + + const error = yield* open + .openInEditor({ + cwd: "/tmp/workspace", + editor: "cursor", + }) + .pipe(Effect.flip); + + assert.equal(error._tag, "OpenError"); + assert.equal(error.message, "Command not found: cursor"); + assert.deepEqual(calls, []); + }), +); diff --git a/apps/server/src/open.ts b/apps/server/src/open.ts index 3fbfd1653d..97d45b86bf 100644 --- a/apps/server/src/open.ts +++ b/apps/server/src/open.ts @@ -1,21 +1,17 @@ /** * Open - Browser/editor launch service interface. * - * Owns process launch helpers for opening URLs in a browser and workspace - * paths in a configured editor. + * 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 { spawn } from "node:child_process"; -import { accessSync, constants, statSync } from "node:fs"; -import { extname, join } from "node:path"; +import os from "node:os"; import { EDITORS, type EditorId } from "@t3tools/contracts"; -import { ServiceMap, Schema, Effect, Layer } from "effect"; - -// ============================== -// Definitions -// ============================== +import { Effect, Exit, FileSystem, Layer, Option, Path, Schema, Scope, ServiceMap } from "effect"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; export class OpenError extends Schema.TaggedErrorClass()("OpenError", { message: Schema.String, @@ -27,33 +23,66 @@ export interface OpenInEditorInput { readonly editor: EditorId; } -interface EditorLaunch { - readonly command: string; - readonly args: ReadonlyArray; +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; } -interface CommandAvailabilityOptions { +export interface OpenRuntimeOptions { readonly platform?: NodeJS.Platform; readonly env?: NodeJS.ProcessEnv; + readonly isWsl?: boolean; + readonly isInsideContainer?: boolean; + readonly powerShellCommand?: string; +} + +interface OpenRuntime { + 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; } 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_BATCH_EXTENSIONS = [".CMD", ".BAT"] as const; function shouldUseGotoFlag(editor: (typeof EDITORS)[number], target: string): boolean { return editor.supportsGoto && LINE_COLUMN_SUFFIX_PATTERN.test(target); } -function fileManagerCommandForPlatform(platform: NodeJS.Platform): string { - switch (platform) { - case "darwin": - return "open"; - case "win32": - return "explorer"; - default: - return "xdg-open"; - } -} - function stripWrappingQuotes(value: string): string { return value.replace(/^"+|"+$/g, ""); } @@ -72,19 +101,24 @@ function resolveWindowsPathExtensions(env: NodeJS.ProcessEnv): ReadonlyArray entry.trim()) .filter((entry) => entry.length > 0) .map((entry) => (entry.startsWith(".") ? entry.toUpperCase() : `.${entry.toUpperCase()}`)); + return parsed.length > 0 ? Array.from(new Set(parsed)) : fallback; } +function resolveWindowsCommandShell(env: NodeJS.ProcessEnv): string { + return env.ComSpec ?? env.COMSPEC ?? "cmd.exe"; +} + function resolveCommandCandidates( command: string, - platform: NodeJS.Platform, - windowsPathExtensions: ReadonlyArray, + runtime: OpenRuntime, + pathService: Path.Path, ): ReadonlyArray { - if (platform !== "win32") return [command]; - const extension = extname(command); + if (runtime.platform !== "win32") return [command]; + const extension = pathService.extname(command); const normalizedExtension = extension.toUpperCase(); - if (extension.length > 0 && windowsPathExtensions.includes(normalizedExtension)) { + if (extension.length > 0 && runtime.windowsPathExtensions.includes(normalizedExtension)) { const commandWithoutExtension = command.slice(0, -extension.length); return Array.from( new Set([ @@ -96,179 +130,558 @@ function resolveCommandCandidates( } const candidates: string[] = []; - for (const extension of windowsPathExtensions) { - candidates.push(`${command}${extension}`); - candidates.push(`${command}${extension.toLowerCase()}`); + for (const extensionName of runtime.windowsPathExtensions) { + candidates.push(`${command}${extensionName}`); + candidates.push(`${command}${extensionName.toLowerCase()}`); } return Array.from(new Set(candidates)); } -function isExecutableFile( - filePath: string, - platform: NodeJS.Platform, - windowsPathExtensions: ReadonlyArray, -): boolean { - try { - const stat = statSync(filePath); - if (!stat.isFile()) return false; - if (platform === "win32") { - const extension = extname(filePath); - if (extension.length === 0) return false; - return windowsPathExtensions.includes(extension.toUpperCase()); - } - accessSync(filePath, constants.X_OK); +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; - } catch { - return false; } + return os.release().toLowerCase().includes("microsoft"); } -function resolvePathDelimiter(platform: NodeJS.Platform): string { - return platform === "win32" ? ";" : ":"; +function quotePowerShellValue(value: string): string { + return `'${value.replaceAll("'", "''")}'`; } -export function isCommandAvailable( +function encodePowerShellCommand(command: string): string { + return Buffer.from(command, "utf16le").toString("base64"); +} + +function normalizeAppCandidates( + app: OpenExternalInput["app"], +): ReadonlyArray<{ readonly name: string; readonly arguments: ReadonlyArray } | undefined> { + if (!app) return [undefined]; + + const apps = Array.isArray(app) ? app : [app]; + const candidates: Array<{ readonly name: string; readonly arguments: ReadonlyArray }> = + []; + + for (const appDef of apps) { + const names = Array.isArray(appDef.name) ? appDef.name : [appDef.name]; + for (const name of names) { + candidates.push({ name, arguments: appDef.arguments ?? [] }); + } + } + + return candidates; +} + +function isUriLikeTarget(target: string): boolean { + return /^[A-Za-z][A-Za-z\d+.-]*:/.test(target); +} + +function shouldPreferWindowsOpenerOnWsl(input: OpenExternalInput, runtime: OpenRuntime): boolean { + return runtime.isWsl && !runtime.isInsideContainer && isUriLikeTarget(input.target); +} + +function makeLaunchPlan( + runtime: OpenRuntime, command: string, - options: CommandAvailabilityOptions = {}, -): boolean { - const platform = options.platform ?? process.platform; - const env = options.env ?? process.env; - const windowsPathExtensions = platform === "win32" ? resolveWindowsPathExtensions(env) : []; - const commandCandidates = resolveCommandCandidates(command, platform, windowsPathExtensions); - - if (command.includes("/") || command.includes("\\")) { - return commandCandidates.some((candidate) => - isExecutableFile(candidate, platform, windowsPathExtensions), - ); + 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, runtime: OpenRuntime): 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(runtime, "open", args, { + wait, + allowNonzeroExitCode: input.allowNonzeroExitCode ?? false, + shell: false, + }); +} + +function makeDarwinApplicationPlan( + input: OpenExternalInput, + app: { readonly name: string; readonly arguments: ReadonlyArray }, + runtime: OpenRuntime, +): 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); } - const pathValue = resolvePathEnvironmentVariable(env); - if (pathValue.length === 0) return false; - const pathEntries = pathValue - .split(resolvePathDelimiter(platform)) - .map((entry) => stripWrappingQuotes(entry.trim())) - .filter((entry) => entry.length > 0); - - for (const pathEntry of pathEntries) { - for (const candidate of commandCandidates) { - if (isExecutableFile(join(pathEntry, candidate), platform, windowsPathExtensions)) { - return true; + return makeLaunchPlan(runtime, "open", args, { + wait, + allowNonzeroExitCode: input.allowNonzeroExitCode ?? false, + shell: false, + }); +} + +function makePowerShellPlan( + input: OpenExternalInput, + runtime: OpenRuntime, + powerShellCommand: string, +): LaunchPlan { + const encodedParts = ["Start"]; + const wait = input.wait ?? false; + + if (wait) encodedParts.push("-Wait"); + encodedParts.push(quotePowerShellValue(input.target)); + + return makeLaunchPlan( + runtime, + powerShellCommand, + [ + "-NoProfile", + "-NonInteractive", + "-ExecutionPolicy", + "Bypass", + "-EncodedCommand", + encodePowerShellCommand(encodedParts.join(" ")), + ], + { + wait, + allowNonzeroExitCode: input.allowNonzeroExitCode ?? false, + shell: false, + }, + ); +} + +function makeLinuxDefaultPlan(input: OpenExternalInput, runtime: OpenRuntime): LaunchPlan { + const wait = input.wait ?? false; + return makeLaunchPlan(runtime, "xdg-open", [input.target], { + wait, + allowNonzeroExitCode: input.allowNonzeroExitCode ?? false, + detached: !wait, + ...(wait ? {} : { stdio: "ignore" as const }), + shell: false, + }); +} + +function makeWindowsExplorerPlan(input: OpenExternalInput, runtime: OpenRuntime): LaunchPlan { + return makeLaunchPlan(runtime, "explorer", [input.target], { + wait: false, + allowNonzeroExitCode: false, + shell: false, + }); +} + +function makeDirectApplicationPlan( + input: OpenExternalInput, + app: { readonly name: string; readonly arguments: ReadonlyArray }, + runtime: OpenRuntime, +): LaunchPlan { + return makeLaunchPlan(runtime, app.name, [...app.arguments, input.target], { + wait: input.wait ?? false, + allowNonzeroExitCode: input.allowNonzeroExitCode ?? false, + shell: false, + }); +} + +function resolveExternalPlans( + input: OpenExternalInput, + runtime: OpenRuntime, +): 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, runtime)); + } else { + plans.push(makeDirectApplicationPlan(input, app, runtime)); } + continue; } - } - return false; -} -export function resolveAvailableEditors( - platform: NodeJS.Platform = process.platform, - env: NodeJS.ProcessEnv = process.env, -): ReadonlyArray { - const available: EditorId[] = []; + if (runtime.platform === "darwin") { + plans.push(makeDarwinDefaultPlan(input, runtime)); + continue; + } - for (const editor of EDITORS) { - const command = editor.command ?? fileManagerCommandForPlatform(platform); - if (isCommandAvailable(command, { platform, env })) { - available.push(editor.id); + if (runtime.platform === "win32" || preferWindowsOpenerOnWsl) { + for (const powerShellCommand of runtime.powerShellCandidates) { + plans.push(makePowerShellPlan(input, runtime, powerShellCommand)); + } } + + if (runtime.platform === "win32") { + if (!(input.wait ?? false)) { + plans.push(makeWindowsExplorerPlan(input, runtime)); + } + continue; + } + + plans.push(makeLinuxDefaultPlan(input, runtime)); } - return available; + return plans; +} + +function toOpenError(message: string, cause: unknown): OpenError { + return new OpenError({ message, cause }); +} + +function isWindowsBatchShim(pathService: Path.Path, filePath: string): boolean { + return WINDOWS_BATCH_EXTENSIONS.includes(pathService.extname(filePath).toUpperCase() as never); +} + +function quoteForWindowsCmd(value: string): string { + return `"${value.replaceAll("%", "%%").replaceAll('"', '""')}"`; +} + +function makeWindowsCmdCommandLine(commandPath: string, args: ReadonlyArray): string { + return `"${[commandPath, ...args].map(quoteForWindowsCmd).join(" ")}"`; } -/** - * OpenShape - Service API for browser and editor launch actions. - */ export interface OpenShape { - /** - * Open a URL target in the default browser. - */ - readonly openBrowser: (target: string) => Effect.Effect; - - /** - * Open a workspace path in a selected editor integration. - * - * Launches the editor as a detached process so server startup is not blocked. - */ + readonly getAvailableEditors: Effect.Effect, OpenError>; + readonly openExternal: (input: OpenExternalInput) => Effect.Effect; + readonly openBrowser: ( + target: string, + options?: Omit, + ) => Effect.Effect; readonly openInEditor: (input: OpenInEditorInput) => Effect.Effect; } -/** - * Open - Service tag for browser/editor launch operations. - */ export class Open extends ServiceMap.Service()("t3/open") {} -// ============================== -// Implementations -// ============================== - -export const resolveEditorLaunch = Effect.fnUntraced(function* ( - input: OpenInEditorInput, - platform: NodeJS.Platform = process.platform, -): Effect.fn.Return { - const editorDef = EDITORS.find((editor) => editor.id === input.editor); - if (!editorDef) { - return yield* new OpenError({ message: `Unknown editor: ${input.editor}` }); - } +const makeOpen = (options: OpenRuntimeOptions = {}) => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const pathService = yield* Path.Path; + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + + const isInsideContainer = + options.isInsideContainer ?? + (typeof options.env?.CONTAINER === "string" || + typeof options.env?.container === "string" || + typeof options.env?.KUBERNETES_SERVICE_HOST === "string" + ? true + : yield* fileSystem.exists("/.dockerenv").pipe(Effect.catch(() => Effect.succeed(false)))); + + const runtime: OpenRuntime = { + platform: options.platform ?? process.platform, + env: options.env ?? process.env, + isWsl: + options.isWsl ?? + detectWsl(options.platform ?? process.platform, options.env ?? process.env), + isInsideContainer, + powerShellCandidates: + options.powerShellCommand !== undefined + ? [options.powerShellCommand] + : (options.isWsl ?? + detectWsl(options.platform ?? process.platform, options.env ?? process.env)) + ? WSL_POWERSHELL_CANDIDATES + : WINDOWS_POWERSHELL_CANDIDATES, + windowsPathExtensions: resolveWindowsPathExtensions(options.env ?? process.env), + }; + + const resolveCommand = Effect.fn(function* ( + command: string, + ): Effect.fn.Return, never> { + const candidates = resolveCommandCandidates(command, runtime, pathService); + + const resolveExecutableFile = Effect.fn(function* ( + filePath: string, + ): Effect.fn.Return, never> { + 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(pathService, 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(); + } - if (editorDef.command) { - return shouldUseGotoFlag(editorDef, input.cwd) - ? { command: editorDef.command, args: ["--goto", input.cwd] } - : { command: editorDef.command, args: [input.cwd] }; - } + 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; + } + } + } - if (editorDef.id !== "file-manager") { - return yield* new OpenError({ message: `Unsupported editor: ${input.editor}` }); - } + return Option.none(); + }); - return { command: fileManagerCommandForPlatform(platform), args: [input.cwd] }; -}); + const commandAvailable = (command: string) => + resolveCommand(command).pipe(Effect.map(Option.isSome)); + + 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; + } + } -export const launchDetached = (launch: EditorLaunch) => - Effect.gen(function* () { - if (!isCommandAvailable(launch.command)) { - return yield* new OpenError({ message: `Editor command not found: ${launch.command}` }); - } + return false; + }); + + const 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 as ReadonlyArray; + }).pipe(Effect.mapError((cause) => toOpenError("Failed to resolve available editors", cause))); + + const spawnPlan = ( + plan: LaunchPlan, + resolvedCommand: ResolvedCommand, + failureMessage: string, + ) => + spawner + .spawn( + ChildProcess.make( + resolvedCommand.usesCmdWrapper + ? resolveWindowsCommandShell(runtime.env) + : resolvedCommand.path, + resolvedCommand.usesCmdWrapper + ? [ + "/d", + "/v:off", + "/s", + "/c", + makeWindowsCmdCommandLine(resolvedCommand.path, plan.args), + ] + : [...plan.args], + { + detached: plan.detached, + shell: plan.shell, + ...(plan.stdio === "ignore" + ? { + stdin: "ignore", + stdout: "ignore", + stderr: "ignore", + } + : {}), + }, + ), + ) + .pipe(Effect.mapError((cause) => toOpenError(failureMessage, cause))); + + const waitForExit = ( + plan: LaunchPlan, + failureMessage: string, + handle: ChildProcessSpawner.ChildProcessHandle, + ) => + handle.exitCode.pipe( + Effect.flatMap((exitCode) => + !plan.allowNonzeroExitCode && (exitCode as number) !== 0 + ? Effect.fail( + new OpenError({ + message: `${failureMessage} (code=${exitCode as number})`, + }), + ) + : Effect.void, + ), + Effect.mapError((cause) => toOpenError(failureMessage, cause)), + ); + + const runWaitedPlan = ( + plan: LaunchPlan, + resolvedCommand: ResolvedCommand, + failureMessage: string, + ) => + Effect.acquireUseRelease( + Scope.make("sequential"), + (scope) => + Effect.gen(function* () { + const handle = yield* spawnPlan(plan, resolvedCommand, failureMessage).pipe( + Scope.provide(scope), + ); + yield* waitForExit(plan, failureMessage, handle); + }), + (scope) => Scope.close(scope, Exit.void), + ); + + const runDetachedPlan = ( + plan: LaunchPlan, + resolvedCommand: ResolvedCommand, + failureMessage: string, + ) => + Effect.gen(function* () { + const childScope = yield* Scope.make("sequential"); + const handle = yield* spawnPlan(plan, resolvedCommand, failureMessage).pipe( + Scope.provide(childScope), + Effect.catch((error) => + Scope.close(childScope, Exit.void).pipe(Effect.andThen(Effect.fail(error))), + ), + ); - yield* Effect.callback((resume) => { - let child; - try { - child = spawn(launch.command, [...launch.args], { - detached: true, - stdio: "ignore", - shell: process.platform === "win32", - }); - } catch (error) { - return resume( - Effect.fail(new OpenError({ message: "failed to spawn detached process", cause: error })), + const releaseOnExit: Effect.Effect = handle.exitCode.pipe( + Effect.ignoreCause, + Effect.ensuring(Scope.close(childScope, Exit.void)), ); + + yield* Effect.forkDetach(releaseOnExit); + }); + + const runPlan = (plan: LaunchPlan, resolvedCommand: ResolvedCommand, failureMessage: string) => + plan.wait + ? runWaitedPlan(plan, resolvedCommand, failureMessage) + : runDetachedPlan(plan, resolvedCommand, failureMessage); + + const runFirstAvailablePlan = ( + plans: ReadonlyArray, + failureMessage: string, + ): Effect.Effect => { + const [first, ...rest] = plans; + if (!first) { + return Effect.fail(new OpenError({ message: failureMessage })); } - const handleSpawn = () => { - child.unref(); - resume(Effect.void); - }; + return resolveCommand(first.command).pipe( + Effect.flatMap((resolvedCommand) => { + if (Option.isNone(resolvedCommand)) { + return rest.length === 0 + ? Effect.fail(new OpenError({ message: `Command not found: ${first.command}` })) + : runFirstAvailablePlan(rest, failureMessage); + } + + return runPlan(first, resolvedCommand.value, failureMessage).pipe( + Effect.catch((error) => + rest.length === 0 ? Effect.fail(error) : runFirstAvailablePlan(rest, failureMessage), + ), + ); + }), + ); + }; + + const openExternal = (input: OpenExternalInput) => { + if (input.target.trim().length === 0) { + return Effect.fail(new OpenError({ message: "Open target must not be empty" })); + } - child.once("spawn", handleSpawn); - child.once("error", (cause) => - resume(Effect.fail(new OpenError({ message: "failed to spawn detached process", cause }))), + return runFirstAvailablePlan( + resolveExternalPlans(input, runtime), + `Failed to open ${input.target}`, ); - }); - }); + }; + + const openInEditor = (input: OpenInEditorInput) => { + const editor = EDITORS.find((candidate) => candidate.id === input.editor); + if (!editor) { + return Effect.fail(new OpenError({ message: `Unknown editor: ${input.editor}` })); + } + + if (editor.command) { + return runFirstAvailablePlan( + [ + makeLaunchPlan( + runtime, + editor.command, + shouldUseGotoFlag(editor, input.cwd) ? ["--goto", input.cwd] : [input.cwd], + { + wait: false, + allowNonzeroExitCode: false, + detached: true, + stdio: "ignore", + shell: false, + }, + ), + ], + `Failed to open ${input.cwd} in ${input.editor}`, + ); + } -const make = Effect.gen(function* () { - const open = yield* Effect.tryPromise({ - try: () => import("open"), - catch: (cause) => new OpenError({ message: "failed to load browser opener", cause }), + return openExternal({ target: input.cwd }); + }; + + return { + getAvailableEditors, + openExternal, + openBrowser: (target, openOptions = {}) => openExternal({ ...openOptions, target }), + openInEditor, + } satisfies OpenShape; }); - return { - openBrowser: (target) => - Effect.tryPromise({ - try: () => open.default(target), - catch: (cause) => new OpenError({ message: "Browser auto-open failed", cause }), - }), - openInEditor: (input) => Effect.flatMap(resolveEditorLaunch(input), launchDetached), - } satisfies OpenShape; -}); - -export const OpenLive = Layer.effect(Open, make); +export const makeOpenLayer = (options: OpenRuntimeOptions = {}) => + Layer.effect(Open, makeOpen(options)); + +export const OpenLive = makeOpenLayer(); diff --git a/apps/server/src/wsServer.test.ts b/apps/server/src/wsServer.test.ts index 0e06650fe6..ba5eddc030 100644 --- a/apps/server/src/wsServer.test.ts +++ b/apps/server/src/wsServer.test.ts @@ -63,6 +63,8 @@ const asThreadId = (value: string): ThreadId => ThreadId.makeUnsafe(value); const asTurnId = (value: string): TurnId => TurnId.makeUnsafe(value); const defaultOpenService: OpenShape = { + getAvailableEditors: Effect.succeed([]), + openExternal: () => Effect.void, openBrowser: () => Effect.void, openInEditor: () => Effect.void, }; @@ -1030,6 +1032,8 @@ describe("WebSocket Server", () => { it("routes shell.openInEditor through the injected open service", async () => { const openCalls: Array<{ cwd: string; editor: string }> = []; const openService: OpenShape = { + getAvailableEditors: Effect.succeed([]), + openExternal: () => Effect.void, openBrowser: () => Effect.void, openInEditor: (input) => { openCalls.push({ cwd: input.cwd, editor: input.editor }); @@ -1531,6 +1535,8 @@ describe("WebSocket Server", () => { process.on("unhandledRejection", onUnhandledRejection); const brokenOpenService: OpenShape = { + getAvailableEditors: Effect.succeed([]), + openExternal: () => Effect.void, openBrowser: () => Effect.void, openInEditor: () => Effect.sync(() => BigInt(1)).pipe(Effect.map((result) => result as unknown as void)), diff --git a/apps/server/src/wsServer.ts b/apps/server/src/wsServer.ts index 25f8158926..9983e0209b 100644 --- a/apps/server/src/wsServer.ts +++ b/apps/server/src/wsServer.ts @@ -58,7 +58,7 @@ import { ProviderService } from "./provider/Services/ProviderService"; import { ProviderRegistry } from "./provider/Services/ProviderRegistry"; import { CheckpointDiffQuery } from "./checkpointing/Services/CheckpointDiffQuery"; import { clamp } from "effect/Number"; -import { Open, resolveAvailableEditors } from "./open"; +import { Open } from "./open"; import { ServerConfig } from "./config"; import { GitCore } from "./git/Services/GitCore.ts"; import { tryHandleProjectFaviconRequest } from "./projectFaviconRoute"; @@ -250,7 +250,10 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< logWebSocketEvents, autoBootstrapProjectFromCwd, } = serverConfig; - const availableEditors = resolveAvailableEditors(); + const open = yield* Open; + const availableEditors = yield* open.getAvailableEditors.pipe( + Effect.mapError((cause) => new ServerLifecycleError({ operation: "availableEditors", cause })), + ); const runtimeServices = yield* Effect.services< ServerRuntimeServices | ServerConfig | FileSystem.FileSystem | Path.Path @@ -615,7 +618,7 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< const projectionReadModelQuery = yield* ProjectionSnapshotQuery; const checkpointDiffQuery = yield* CheckpointDiffQuery; const orchestrationReactor = yield* OrchestrationReactor; - const { openInEditor } = yield* Open; + const { openInEditor } = open; const subscriptionsScope = yield* Scope.make("sequential"); yield* Effect.addFinalizer(() => Scope.close(subscriptionsScope, Exit.void)); diff --git a/bun.lock b/bun.lock index 91cdc8ac97..d597fcc61a 100644 --- a/bun.lock +++ b/bun.lock @@ -54,7 +54,6 @@ "@pierre/diffs": "^1.1.0-beta.16", "effect": "catalog:", "node-pty": "^1.1.0", - "open": "^10.1.0", "ws": "^8.18.0", }, "devDependencies": { @@ -896,8 +895,6 @@ "bun-types": ["bun-types@1.3.10", "", { "dependencies": { "@types/node": "*" } }, "sha512-tcpfCCl6XWo6nCVnpcVrxQ+9AYN1iqMIzgrSKYMB/fjLtV2eyAVEg7AxQJuCq/26R6HpKWykQXuSOq/21RYcbg=="], - "bundle-name": ["bundle-name@4.1.0", "", { "dependencies": { "run-applescript": "^7.0.0" } }, "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q=="], - "cac": ["cac@6.7.14", "", {}, "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ=="], "cacheable-lookup": ["cacheable-lookup@5.0.4", "", {}, "sha512-2/kNscPhpcxrOigMZzbiWF7dz8ilhb/nIHU3EyZiXWXpeq/au8qJ8VhdftMkty3n7Gj6HIGalQG8oiBNB3AJgA=="], @@ -972,16 +969,10 @@ "decompress-response": ["decompress-response@6.0.0", "", { "dependencies": { "mimic-response": "^3.1.0" } }, "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ=="], - "default-browser": ["default-browser@5.5.0", "", { "dependencies": { "bundle-name": "^4.1.0", "default-browser-id": "^5.0.0" } }, "sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw=="], - - "default-browser-id": ["default-browser-id@5.0.1", "", {}, "sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q=="], - "defer-to-connect": ["defer-to-connect@2.0.1", "", {}, "sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg=="], "define-data-property": ["define-data-property@1.1.4", "", { "dependencies": { "es-define-property": "^1.0.0", "es-errors": "^1.3.0", "gopd": "^1.0.1" } }, "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A=="], - "define-lazy-prop": ["define-lazy-prop@3.0.0", "", {}, "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg=="], - "define-properties": ["define-properties@1.2.1", "", { "dependencies": { "define-data-property": "^1.0.1", "has-property-descriptors": "^1.0.0", "object-keys": "^1.1.1" } }, "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg=="], "defu": ["defu@6.1.4", "", {}, "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg=="], @@ -1462,8 +1453,6 @@ "oniguruma-to-es": ["oniguruma-to-es@4.3.5", "", { "dependencies": { "oniguruma-parser": "^0.12.1", "regex": "^6.1.0", "regex-recursion": "^6.0.2" } }, "sha512-Zjygswjpsewa0NLTsiizVuMQZbp0MDyM6lIt66OxsF21npUDlzpHi1Mgb/qhQdkb+dWFTzJmFbEWdvZgRho8eQ=="], - "open": ["open@10.2.0", "", { "dependencies": { "default-browser": "^5.2.1", "define-lazy-prop": "^3.0.0", "is-inside-container": "^1.0.0", "wsl-utils": "^0.1.0" } }, "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA=="], - "outvariant": ["outvariant@1.4.3", "", {}, "sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA=="], "oxfmt": ["oxfmt@0.40.0", "", { "dependencies": { "tinypool": "2.1.0" }, "optionalDependencies": { "@oxfmt/binding-android-arm-eabi": "0.40.0", "@oxfmt/binding-android-arm64": "0.40.0", "@oxfmt/binding-darwin-arm64": "0.40.0", "@oxfmt/binding-darwin-x64": "0.40.0", "@oxfmt/binding-freebsd-x64": "0.40.0", "@oxfmt/binding-linux-arm-gnueabihf": "0.40.0", "@oxfmt/binding-linux-arm-musleabihf": "0.40.0", "@oxfmt/binding-linux-arm64-gnu": "0.40.0", "@oxfmt/binding-linux-arm64-musl": "0.40.0", "@oxfmt/binding-linux-ppc64-gnu": "0.40.0", "@oxfmt/binding-linux-riscv64-gnu": "0.40.0", "@oxfmt/binding-linux-riscv64-musl": "0.40.0", "@oxfmt/binding-linux-s390x-gnu": "0.40.0", "@oxfmt/binding-linux-x64-gnu": "0.40.0", "@oxfmt/binding-linux-x64-musl": "0.40.0", "@oxfmt/binding-openharmony-arm64": "0.40.0", "@oxfmt/binding-win32-arm64-msvc": "0.40.0", "@oxfmt/binding-win32-ia32-msvc": "0.40.0", "@oxfmt/binding-win32-x64-msvc": "0.40.0" }, "bin": { "oxfmt": "bin/oxfmt" } }, "sha512-g0C3I7xUj4b4DcagevM9kgH6+pUHytikxUcn3/VUkvzTNaaXBeyZqb7IBsHwojeXm4mTBEC/aBjBTMVUkZwWUQ=="], @@ -1598,8 +1587,6 @@ "rolldown-plugin-dts": ["rolldown-plugin-dts@0.22.5", "", { "dependencies": { "@babel/generator": "8.0.0-rc.2", "@babel/helper-validator-identifier": "8.0.0-rc.2", "@babel/parser": "8.0.0-rc.2", "@babel/types": "8.0.0-rc.2", "ast-kit": "^3.0.0-beta.1", "birpc": "^4.0.0", "dts-resolver": "^2.1.3", "get-tsconfig": "^4.13.6", "obug": "^2.1.1" }, "peerDependencies": { "@ts-macro/tsc": "^0.3.6", "@typescript/native-preview": ">=7.0.0-dev.20250601.1", "rolldown": "^1.0.0-rc.3", "typescript": "^5.0.0 || ^6.0.0-beta", "vue-tsc": "~3.2.0" }, "optionalPeers": ["@ts-macro/tsc", "@typescript/native-preview", "typescript", "vue-tsc"] }, "sha512-M/HXfM4cboo+jONx9Z0X+CUf3B5tCi7ni+kR5fUW50Fp9AlZk0oVLesibGWgCXDKFp5lpgQ9yhKoImUFjl3VZw=="], - "run-applescript": ["run-applescript@7.1.0", "", {}, "sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q=="], - "rxjs": ["rxjs@7.8.2", "", { "dependencies": { "tslib": "^2.1.0" } }, "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA=="], "sax": ["sax@1.5.0", "", {}, "sha512-21IYA3Q5cQf089Z6tgaUTr7lDAyzoTPx5HRtbhsME8Udispad8dC/+sziTNugOEx54ilvatQ9YCzl4KQLPcRHA=="], @@ -1856,8 +1843,6 @@ "ws": ["ws@8.19.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg=="], - "wsl-utils": ["wsl-utils@0.1.0", "", { "dependencies": { "is-wsl": "^3.1.0" } }, "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw=="], - "xxhash-wasm": ["xxhash-wasm@1.1.0", "", {}, "sha512-147y/6YNh+tlp6nd/2pWq38i9h6mz/EuQ6njIrmW8D1BS5nCqs0P6DG+m6zTGnNz5I+uhZ0SHxBs9BsPrwcKDA=="], "y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="], From d7257df2ae572a8e3d7be3094821607bc2ae6b4d Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 31 Mar 2026 10:54:26 -0700 Subject: [PATCH 02/11] Extract desktop launcher service layer - Move browser/editor launch logic out of `open.ts` - Wire server startup and WebSocket tests to `DesktopLauncher` - Add validation and fallback error coverage for launch attempts --- apps/server/src/index.ts | 4 +- apps/server/src/main.test.ts | 6 +- apps/server/src/main.ts | 6 +- apps/server/src/open.ts | 687 -------------- .../Layers/DesktopLauncher.test.ts} | 209 ++++- .../src/process/Layers/DesktopLauncher.ts | 857 ++++++++++++++++++ .../src/process/Services/DesktopLauncher.ts | 200 ++++ apps/server/src/wsServer.test.ts | 29 +- apps/server/src/wsServer.ts | 9 +- 9 files changed, 1260 insertions(+), 747 deletions(-) delete mode 100644 apps/server/src/open.ts rename apps/server/src/{open.test.ts => process/Layers/DesktopLauncher.test.ts} (73%) create mode 100644 apps/server/src/process/Layers/DesktopLauncher.ts create mode 100644 apps/server/src/process/Services/DesktopLauncher.ts diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index 363a07ee38..542d750c77 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -4,7 +4,7 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import { CliConfig, t3Cli } from "./main"; -import { OpenLive } from "./open"; +import * as DesktopLauncher from "./process/Layers/DesktopLauncher"; import { Command } from "effect/unstable/cli"; import { version } from "../package.json" with { type: "json" }; import { ServerLive } from "./wsServer"; @@ -14,7 +14,7 @@ import { FetchHttpClient } from "effect/unstable/http"; const RuntimeLayer = Layer.empty.pipe( Layer.provideMerge(CliConfig.layer), Layer.provideMerge(ServerLive), - Layer.provideMerge(OpenLive), + Layer.provideMerge(DesktopLauncher.layer), Layer.provideMerge(NetService.layer), Layer.provideMerge(NodeServices.layer), Layer.provideMerge(FetchHttpClient.layer), diff --git a/apps/server/src/main.test.ts b/apps/server/src/main.test.ts index 1fa0a3752d..8fa75b4ac5 100644 --- a/apps/server/src/main.test.ts +++ b/apps/server/src/main.test.ts @@ -13,7 +13,7 @@ import { NetService } from "@t3tools/shared/Net"; import { CliConfig, recordStartupHeartbeat, t3Cli, type CliConfigShape } from "./main"; import { ServerConfig, type ServerConfigShape } from "./config"; -import { Open, type OpenShape } from "./open"; +import { DesktopLauncher, type DesktopLauncherShape } from "./process/Services/DesktopLauncher"; import { ProjectionSnapshotQuery } from "./orchestration/Services/ProjectionSnapshotQuery"; import { AnalyticsService } from "./telemetry/Services/AnalyticsService"; import { Server, type ServerShape } from "./wsServer"; @@ -50,12 +50,12 @@ const testLayer = Layer.mergeAll( start: serverStart, stopSignal: Effect.void, } satisfies ServerShape), - Layer.succeed(Open, { + Layer.succeed(DesktopLauncher, { getAvailableEditors: Effect.succeed([]), openExternal: () => Effect.void, openBrowser: (_target: string) => Effect.void, openInEditor: () => Effect.void, - } satisfies OpenShape), + } satisfies DesktopLauncherShape), ServerSettingsService.layerTest(), AnalyticsService.layerTest, FetchHttpClient.layer, diff --git a/apps/server/src/main.ts b/apps/server/src/main.ts index 019783c253..8e7b4c8cb7 100644 --- a/apps/server/src/main.ts +++ b/apps/server/src/main.ts @@ -18,7 +18,7 @@ import { type ServerConfigShape, } from "./config"; import { fixPath, resolveBaseDir } from "./os-jank"; -import { Open } from "./open"; +import { DesktopLauncher } from "./process/Services/DesktopLauncher"; import * as SqlitePersistence from "./persistence/Layers/Sqlite"; import { makeServerProviderLayer, makeServerRuntimeServicesLayer } from "./serverLayers"; import { ProjectionSnapshotQuery } from "./orchestration/Services/ProjectionSnapshotQuery"; @@ -336,7 +336,7 @@ export const recordStartupHeartbeat = Effect.gen(function* () { const makeServerRuntimeProgram = (input: CliInput) => Effect.gen(function* () { const { start, stopSignal } = yield* Server; - const openDeps = yield* Open; + const desktopLauncher = yield* DesktopLauncher; const config = yield* ServerConfig; @@ -366,7 +366,7 @@ const makeServerRuntimeProgram = (input: CliInput) => if (!config.noBrowser) { const target = config.devUrl?.toString() ?? bindUrl; - yield* openDeps.openBrowser(target).pipe( + yield* desktopLauncher.openBrowser(target).pipe( Effect.catch(() => Effect.logInfo("browser auto-open unavailable", { hint: `Open ${target} in your browser.`, diff --git a/apps/server/src/open.ts b/apps/server/src/open.ts deleted file mode 100644 index 97d45b86bf..0000000000 --- a/apps/server/src/open.ts +++ /dev/null @@ -1,687 +0,0 @@ -/** - * 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 { EDITORS, type EditorId } from "@t3tools/contracts"; -import { Effect, Exit, FileSystem, Layer, Option, Path, Schema, Scope, ServiceMap } from "effect"; -import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; - -export class OpenError extends Schema.TaggedErrorClass()("OpenError", { - message: Schema.String, - cause: Schema.optional(Schema.Defect), -}) {} - -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 OpenRuntimeOptions { - readonly platform?: NodeJS.Platform; - readonly env?: NodeJS.ProcessEnv; - readonly isWsl?: boolean; - readonly isInsideContainer?: boolean; - readonly powerShellCommand?: string; -} - -interface OpenRuntime { - 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; -} - -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_BATCH_EXTENSIONS = [".CMD", ".BAT"] as const; - -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 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.from(new Set(parsed)) : fallback; -} - -function resolveWindowsCommandShell(env: NodeJS.ProcessEnv): string { - return env.ComSpec ?? env.COMSPEC ?? "cmd.exe"; -} - -function resolveCommandCandidates( - command: string, - runtime: OpenRuntime, - 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.from( - new Set([ - 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.from(new Set(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 encodePowerShellCommand(command: string): string { - return Buffer.from(command, "utf16le").toString("base64"); -} - -function normalizeAppCandidates( - app: OpenExternalInput["app"], -): ReadonlyArray<{ readonly name: string; readonly arguments: ReadonlyArray } | undefined> { - if (!app) return [undefined]; - - const apps = Array.isArray(app) ? app : [app]; - const candidates: Array<{ readonly name: string; readonly arguments: ReadonlyArray }> = - []; - - for (const appDef of apps) { - const names = Array.isArray(appDef.name) ? appDef.name : [appDef.name]; - for (const name of names) { - candidates.push({ name, arguments: appDef.arguments ?? [] }); - } - } - - return candidates; -} - -function isUriLikeTarget(target: string): boolean { - return /^[A-Za-z][A-Za-z\d+.-]*:/.test(target); -} - -function shouldPreferWindowsOpenerOnWsl(input: OpenExternalInput, runtime: OpenRuntime): boolean { - return runtime.isWsl && !runtime.isInsideContainer && isUriLikeTarget(input.target); -} - -function makeLaunchPlan( - runtime: OpenRuntime, - 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, runtime: OpenRuntime): 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(runtime, "open", args, { - wait, - allowNonzeroExitCode: input.allowNonzeroExitCode ?? false, - shell: false, - }); -} - -function makeDarwinApplicationPlan( - input: OpenExternalInput, - app: { readonly name: string; readonly arguments: ReadonlyArray }, - runtime: OpenRuntime, -): 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(runtime, "open", args, { - wait, - allowNonzeroExitCode: input.allowNonzeroExitCode ?? false, - shell: false, - }); -} - -function makePowerShellPlan( - input: OpenExternalInput, - runtime: OpenRuntime, - powerShellCommand: string, -): LaunchPlan { - const encodedParts = ["Start"]; - const wait = input.wait ?? false; - - if (wait) encodedParts.push("-Wait"); - encodedParts.push(quotePowerShellValue(input.target)); - - return makeLaunchPlan( - runtime, - powerShellCommand, - [ - "-NoProfile", - "-NonInteractive", - "-ExecutionPolicy", - "Bypass", - "-EncodedCommand", - encodePowerShellCommand(encodedParts.join(" ")), - ], - { - wait, - allowNonzeroExitCode: input.allowNonzeroExitCode ?? false, - shell: false, - }, - ); -} - -function makeLinuxDefaultPlan(input: OpenExternalInput, runtime: OpenRuntime): LaunchPlan { - const wait = input.wait ?? false; - return makeLaunchPlan(runtime, "xdg-open", [input.target], { - wait, - allowNonzeroExitCode: input.allowNonzeroExitCode ?? false, - detached: !wait, - ...(wait ? {} : { stdio: "ignore" as const }), - shell: false, - }); -} - -function makeWindowsExplorerPlan(input: OpenExternalInput, runtime: OpenRuntime): LaunchPlan { - return makeLaunchPlan(runtime, "explorer", [input.target], { - wait: false, - allowNonzeroExitCode: false, - shell: false, - }); -} - -function makeDirectApplicationPlan( - input: OpenExternalInput, - app: { readonly name: string; readonly arguments: ReadonlyArray }, - runtime: OpenRuntime, -): LaunchPlan { - return makeLaunchPlan(runtime, app.name, [...app.arguments, input.target], { - wait: input.wait ?? false, - allowNonzeroExitCode: input.allowNonzeroExitCode ?? false, - shell: false, - }); -} - -function resolveExternalPlans( - input: OpenExternalInput, - runtime: OpenRuntime, -): 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, runtime)); - } else { - plans.push(makeDirectApplicationPlan(input, app, runtime)); - } - continue; - } - - if (runtime.platform === "darwin") { - plans.push(makeDarwinDefaultPlan(input, runtime)); - continue; - } - - if (runtime.platform === "win32" || preferWindowsOpenerOnWsl) { - for (const powerShellCommand of runtime.powerShellCandidates) { - plans.push(makePowerShellPlan(input, runtime, powerShellCommand)); - } - } - - if (runtime.platform === "win32") { - if (!(input.wait ?? false)) { - plans.push(makeWindowsExplorerPlan(input, runtime)); - } - continue; - } - - plans.push(makeLinuxDefaultPlan(input, runtime)); - } - - return plans; -} - -function toOpenError(message: string, cause: unknown): OpenError { - return new OpenError({ message, cause }); -} - -function isWindowsBatchShim(pathService: Path.Path, filePath: string): boolean { - return WINDOWS_BATCH_EXTENSIONS.includes(pathService.extname(filePath).toUpperCase() as never); -} - -function quoteForWindowsCmd(value: string): string { - return `"${value.replaceAll("%", "%%").replaceAll('"', '""')}"`; -} - -function makeWindowsCmdCommandLine(commandPath: string, args: ReadonlyArray): string { - return `"${[commandPath, ...args].map(quoteForWindowsCmd).join(" ")}"`; -} - -export interface OpenShape { - readonly getAvailableEditors: Effect.Effect, OpenError>; - readonly openExternal: (input: OpenExternalInput) => Effect.Effect; - readonly openBrowser: ( - target: string, - options?: Omit, - ) => Effect.Effect; - readonly openInEditor: (input: OpenInEditorInput) => Effect.Effect; -} - -export class Open extends ServiceMap.Service()("t3/open") {} - -const makeOpen = (options: OpenRuntimeOptions = {}) => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const pathService = yield* Path.Path; - const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; - - const isInsideContainer = - options.isInsideContainer ?? - (typeof options.env?.CONTAINER === "string" || - typeof options.env?.container === "string" || - typeof options.env?.KUBERNETES_SERVICE_HOST === "string" - ? true - : yield* fileSystem.exists("/.dockerenv").pipe(Effect.catch(() => Effect.succeed(false)))); - - const runtime: OpenRuntime = { - platform: options.platform ?? process.platform, - env: options.env ?? process.env, - isWsl: - options.isWsl ?? - detectWsl(options.platform ?? process.platform, options.env ?? process.env), - isInsideContainer, - powerShellCandidates: - options.powerShellCommand !== undefined - ? [options.powerShellCommand] - : (options.isWsl ?? - detectWsl(options.platform ?? process.platform, options.env ?? process.env)) - ? WSL_POWERSHELL_CANDIDATES - : WINDOWS_POWERSHELL_CANDIDATES, - windowsPathExtensions: resolveWindowsPathExtensions(options.env ?? process.env), - }; - - const resolveCommand = Effect.fn(function* ( - command: string, - ): Effect.fn.Return, never> { - const candidates = resolveCommandCandidates(command, runtime, pathService); - - const resolveExecutableFile = Effect.fn(function* ( - filePath: string, - ): Effect.fn.Return, never> { - 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(pathService, 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 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 = 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 as ReadonlyArray; - }).pipe(Effect.mapError((cause) => toOpenError("Failed to resolve available editors", cause))); - - const spawnPlan = ( - plan: LaunchPlan, - resolvedCommand: ResolvedCommand, - failureMessage: string, - ) => - spawner - .spawn( - ChildProcess.make( - resolvedCommand.usesCmdWrapper - ? resolveWindowsCommandShell(runtime.env) - : resolvedCommand.path, - resolvedCommand.usesCmdWrapper - ? [ - "/d", - "/v:off", - "/s", - "/c", - makeWindowsCmdCommandLine(resolvedCommand.path, plan.args), - ] - : [...plan.args], - { - detached: plan.detached, - shell: plan.shell, - ...(plan.stdio === "ignore" - ? { - stdin: "ignore", - stdout: "ignore", - stderr: "ignore", - } - : {}), - }, - ), - ) - .pipe(Effect.mapError((cause) => toOpenError(failureMessage, cause))); - - const waitForExit = ( - plan: LaunchPlan, - failureMessage: string, - handle: ChildProcessSpawner.ChildProcessHandle, - ) => - handle.exitCode.pipe( - Effect.flatMap((exitCode) => - !plan.allowNonzeroExitCode && (exitCode as number) !== 0 - ? Effect.fail( - new OpenError({ - message: `${failureMessage} (code=${exitCode as number})`, - }), - ) - : Effect.void, - ), - Effect.mapError((cause) => toOpenError(failureMessage, cause)), - ); - - const runWaitedPlan = ( - plan: LaunchPlan, - resolvedCommand: ResolvedCommand, - failureMessage: string, - ) => - Effect.acquireUseRelease( - Scope.make("sequential"), - (scope) => - Effect.gen(function* () { - const handle = yield* spawnPlan(plan, resolvedCommand, failureMessage).pipe( - Scope.provide(scope), - ); - yield* waitForExit(plan, failureMessage, handle); - }), - (scope) => Scope.close(scope, Exit.void), - ); - - const runDetachedPlan = ( - plan: LaunchPlan, - resolvedCommand: ResolvedCommand, - failureMessage: string, - ) => - Effect.gen(function* () { - const childScope = yield* Scope.make("sequential"); - const handle = yield* spawnPlan(plan, resolvedCommand, failureMessage).pipe( - Scope.provide(childScope), - Effect.catch((error) => - Scope.close(childScope, Exit.void).pipe(Effect.andThen(Effect.fail(error))), - ), - ); - - const releaseOnExit: Effect.Effect = handle.exitCode.pipe( - Effect.ignoreCause, - Effect.ensuring(Scope.close(childScope, Exit.void)), - ); - - yield* Effect.forkDetach(releaseOnExit); - }); - - const runPlan = (plan: LaunchPlan, resolvedCommand: ResolvedCommand, failureMessage: string) => - plan.wait - ? runWaitedPlan(plan, resolvedCommand, failureMessage) - : runDetachedPlan(plan, resolvedCommand, failureMessage); - - const runFirstAvailablePlan = ( - plans: ReadonlyArray, - failureMessage: string, - ): Effect.Effect => { - const [first, ...rest] = plans; - if (!first) { - return Effect.fail(new OpenError({ message: failureMessage })); - } - - return resolveCommand(first.command).pipe( - Effect.flatMap((resolvedCommand) => { - if (Option.isNone(resolvedCommand)) { - return rest.length === 0 - ? Effect.fail(new OpenError({ message: `Command not found: ${first.command}` })) - : runFirstAvailablePlan(rest, failureMessage); - } - - return runPlan(first, resolvedCommand.value, failureMessage).pipe( - Effect.catch((error) => - rest.length === 0 ? Effect.fail(error) : runFirstAvailablePlan(rest, failureMessage), - ), - ); - }), - ); - }; - - const openExternal = (input: OpenExternalInput) => { - if (input.target.trim().length === 0) { - return Effect.fail(new OpenError({ message: "Open target must not be empty" })); - } - - return runFirstAvailablePlan( - resolveExternalPlans(input, runtime), - `Failed to open ${input.target}`, - ); - }; - - const openInEditor = (input: OpenInEditorInput) => { - const editor = EDITORS.find((candidate) => candidate.id === input.editor); - if (!editor) { - return Effect.fail(new OpenError({ message: `Unknown editor: ${input.editor}` })); - } - - if (editor.command) { - return runFirstAvailablePlan( - [ - makeLaunchPlan( - runtime, - editor.command, - shouldUseGotoFlag(editor, input.cwd) ? ["--goto", input.cwd] : [input.cwd], - { - wait: false, - allowNonzeroExitCode: false, - detached: true, - stdio: "ignore", - shell: false, - }, - ), - ], - `Failed to open ${input.cwd} in ${input.editor}`, - ); - } - - return openExternal({ target: input.cwd }); - }; - - return { - getAvailableEditors, - openExternal, - openBrowser: (target, openOptions = {}) => openExternal({ ...openOptions, target }), - openInEditor, - } satisfies OpenShape; - }); - -export const makeOpenLayer = (options: OpenRuntimeOptions = {}) => - Layer.effect(Open, makeOpen(options)); - -export const OpenLive = makeOpenLayer(); diff --git a/apps/server/src/open.test.ts b/apps/server/src/process/Layers/DesktopLauncher.test.ts similarity index 73% rename from apps/server/src/open.test.ts rename to apps/server/src/process/Layers/DesktopLauncher.test.ts index 41ed33eeb4..e869b1fbea 100644 --- a/apps/server/src/open.test.ts +++ b/apps/server/src/process/Layers/DesktopLauncher.test.ts @@ -14,7 +14,12 @@ import { } from "effect"; import { ChildProcessSpawner } from "effect/unstable/process"; -import { makeOpenLayer, Open } from "./open"; +import { + make as makeDesktopLauncher, + type DetachedSpawnInput, + type LaunchRuntimeOptions, +} from "./DesktopLauncher"; +import { DesktopLauncher, DesktopLauncherSpawnError } from "../Services/DesktopLauncher"; const encoder = new TextEncoder(); @@ -121,21 +126,90 @@ function spawnerLayer( ); } +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, + 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: Parameters[0], - spawnLayer: Layer.Layer, + options: LaunchRuntimeOptions, + harness: { + readonly layer: Layer.Layer; + readonly spawnDetached: ( + input: DetachedSpawnInput, + context: { + readonly operation: string; + readonly target?: string; + readonly editor?: string; + }, + ) => Effect.Effect; + }, ) => - makeOpenLayer(options).pipe( - Layer.provide(Layer.mergeAll(NodeFileSystem.layer, NodePath.layer, spawnLayer)), - ); + Layer.effect( + DesktopLauncher, + makeDesktopLauncher({ ...options, spawnDetached: harness.spawnDetached }), + ).pipe(Layer.provide(Layer.mergeAll(NodeFileSystem.layer, NodePath.layer, harness.layer))); const runOpen = ( - options: Parameters[0], - spawnLayer: Layer.Layer, - effect: Effect.Effect, -) => effect.pipe(Effect.provide(provideOpen(options, spawnLayer))); + 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(Open); +const readOpen = Effect.service(DesktopLauncher); const writeExecutable = (filePath: string) => Effect.gen(function* () { @@ -161,7 +235,7 @@ it.effect("getAvailableEditors detects installed editors through the service", ( PATHEXT: ".COM;.EXE;.BAT;.CMD", }, }, - spawnerLayer(calls), + spawnHarness(calls), readOpen, ); @@ -192,7 +266,7 @@ it.effect("getAvailableEditors does not advertise WSL file-manager from PowerShe isInsideContainer: false, powerShellCommand: `${dir}/mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe`, }, - spawnerLayer(calls), + spawnHarness(calls), readOpen, ); @@ -214,7 +288,7 @@ it.effect("openInEditor uses --goto for editors that support it", () => platform: "darwin", env: { PATH: dir }, }, - spawnerLayer(calls), + spawnHarness(calls), readOpen, ); @@ -252,7 +326,7 @@ it.effect("openInEditor launches Windows batch shims through cmd.exe without she PATHEXT: ".COM;.EXE;.BAT;.CMD", }, }, - spawnerLayer(calls), + spawnHarness(calls), readOpen, ); @@ -275,15 +349,14 @@ it.effect("openInEditor launches Windows batch shims through cmd.exe without she }).pipe(Effect.provide(NodeFileSystem.layer)), ); -it.effect("openInEditor detached launches survive service scope shutdown", () => +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 exitDeferred = yield* Deferred.make(); - let killCount = 0; + const harness = spawnHarness(calls); yield* Effect.acquireUseRelease( Scope.make("sequential"), @@ -295,12 +368,7 @@ it.effect("openInEditor detached launches survive service scope shutdown", () => platform: "darwin", env: { PATH: dir }, }, - spawnerLayer(calls, () => ({ - awaitExit: exitDeferred, - onKill: () => { - killCount += 1; - }, - })), + harness, ), ).pipe(Scope.provide(scope)); @@ -313,7 +381,6 @@ it.effect("openInEditor detached launches survive service scope shutdown", () => (scope) => Scope.close(scope, Exit.void), ); - assert.equal(killCount, 0); assert.deepEqual(calls, [ { command: `${dir}/zed`, @@ -325,10 +392,6 @@ it.effect("openInEditor detached launches survive service scope shutdown", () => stderr: "ignore", }, ]); - - yield* Deferred.succeed(exitDeferred, void 0); - yield* Effect.yieldNow; - assert.equal(killCount, 0); }).pipe(Effect.provide(NodeFileSystem.layer)), ); @@ -344,7 +407,7 @@ it.effect("openInEditor uses the default opener for file-manager on macOS", () = platform: "darwin", env: { PATH: dir }, }, - spawnerLayer(calls), + spawnHarness(calls), readOpen, ); @@ -379,7 +442,7 @@ it.effect("openBrowser uses macOS open flags and app arguments", () => platform: "darwin", env: { PATH: dir }, }, - spawnerLayer(calls), + spawnHarness(calls), readOpen, ); @@ -431,7 +494,7 @@ it.effect("openBrowser uses PowerShell on win32", () => PATHEXT: ".COM;.EXE;.BAT;.CMD", }, }, - spawnerLayer(calls), + spawnHarness(calls), readOpen, ); @@ -469,7 +532,7 @@ it.effect("openBrowser falls back from WSL PowerShell to xdg-open", () => isInsideContainer: false, powerShellCommand: `${dir}/mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe`, }, - spawnerLayer(calls, (call) => + spawnHarness(calls, (call) => call.command.includes("powershell") ? { fail: "powershell unavailable" } : { @@ -509,7 +572,7 @@ it.effect("openInEditor uses xdg-open first for WSL file-manager paths", () => isInsideContainer: false, powerShellCommand: `${dir}/mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe`, }, - spawnerLayer(calls), + spawnHarness(calls), readOpen, ); @@ -540,7 +603,7 @@ it.effect("openInEditor fails when the editor command is unavailable", () => platform: "darwin", env: { PATH: "" }, }, - spawnerLayer(calls), + spawnHarness(calls), readOpen, ); @@ -551,8 +614,80 @@ it.effect("openInEditor fails when the editor command is unavailable", () => }) .pipe(Effect.flip); - assert.equal(error._tag, "OpenError"); - assert.equal(error.message, "Command not found: cursor"); + 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..c8e19b2bdc --- /dev/null +++ b/apps/server/src/process/Layers/DesktopLauncher.ts @@ -0,0 +1,857 @@ +/** + * 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 { EDITORS, type EditorId } from "@t3tools/contracts"; +import { Array, Effect, FileSystem, Layer, Option, Path, Scope } from "effect"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; +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 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_BATCH_EXTENSIONS = [".CMD", ".BAT"] as const; + +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 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 resolveWindowsCommandShell(env: NodeJS.ProcessEnv): string { + return env.ComSpec ?? env.COMSPEC ?? "cmd.exe"; +} + +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 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; +} + +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 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 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, + shell: false, + }); +} + +function makeDirectApplicationPlan( + input: OpenExternalInput, + app: OpenApplicationCandidate, +): LaunchPlan { + return makeLaunchPlan(app.name, [...app.arguments, input.target], { + wait: input.wait ?? false, + allowNonzeroExitCode: input.allowNonzeroExitCode ?? false, + 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 isWindowsBatchShim(pathService: Path.Path, filePath: string): boolean { + return WINDOWS_BATCH_EXTENSIONS.includes(pathService.extname(filePath).toUpperCase() as never); +} + +function quoteForWindowsCmd(value: string): string { + return `"${value.replaceAll("%", "%%").replaceAll('"', '""')}"`; +} + +function makeWindowsCmdCommandLine(commandPath: string, args: ReadonlyArray): string { + return `"${[commandPath, ...args].map(quoteForWindowsCmd).join(" ")}"`; +} + +function resolveSpawnInput( + runtime: LaunchRuntime, + plan: LaunchPlan, + resolvedCommand: ResolvedCommand, +): DetachedSpawnInput { + return { + command: resolvedCommand.usesCmdWrapper + ? resolveWindowsCommandShell(runtime.env) + : resolvedCommand.path, + args: resolvedCommand.usesCmdWrapper + ? ["/d", "/v:off", "/s", "/c", makeWindowsCmdCommandLine(resolvedCommand.path, plan.args)] + : [...plan.args], + ...(plan.detached !== undefined ? { detached: plan.detached } : {}), + ...(plan.shell !== undefined ? { shell: plan.shell } : {}), + ...(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. + */ +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.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) => { + 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.unref(); + resume(Effect.void); + }; + + childProcess.once("error", onError); + childProcess.once("spawn", onSpawn); + + return Effect.sync(() => { + childProcess.off("error", onError); + childProcess.off("spawn", onSpawn); + }); + }), + ), + ); +} + +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 isInsideContainer = + options.isInsideContainer ?? + (typeof options.env?.CONTAINER === "string" || + typeof options.env?.container === "string" || + typeof options.env?.KUBERNETES_SERVICE_HOST === "string" + ? true + : yield* fileSystem.exists("/.dockerenv").pipe(Effect.catch(() => Effect.succeed(false)))); + + const runtime: LaunchRuntime = { + platform: options.platform ?? process.platform, + env: options.env ?? process.env, + isWsl: + options.isWsl ?? detectWsl(options.platform ?? process.platform, options.env ?? process.env), + isInsideContainer, + powerShellCandidates: + options.powerShellCommand !== undefined + ? [options.powerShellCommand] + : (options.isWsl ?? + detectWsl(options.platform ?? process.platform, options.env ?? process.env)) + ? WSL_POWERSHELL_CANDIDATES + : WINDOWS_POWERSHELL_CANDIDATES, + windowsPathExtensions: resolveWindowsPathExtensions(options.env ?? process.env), + }; + + 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(pathService, 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 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); + 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.flatMap((exitCode) => + !plan.allowNonzeroExitCode && exitCode !== 0 + ? Effect.fail( + makeNonZeroExitError(context, spawnInput.command, spawnInput.args, exitCode), + ) + : Effect.void, + ), + Effect.mapError((cause) => + makeSpawnError(context, spawnInput.command, spawnInput.args, cause), + ), + ); + + const runWaitedPlan = ( + plan: LaunchPlan, + resolvedCommand: ResolvedCommand, + context: LaunchContext, + ) => + Effect.acquireUseRelease( + Scope.make("sequential"), + (scope) => + Effect.gen(function* () { + const spawnInput = resolveSpawnInput(runtime, plan, resolvedCommand); + 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), 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 }); + } + + 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/wsServer.test.ts b/apps/server/src/wsServer.test.ts index ba5eddc030..90a76bbd9b 100644 --- a/apps/server/src/wsServer.test.ts +++ b/apps/server/src/wsServer.test.ts @@ -48,7 +48,7 @@ import { makeSqlitePersistenceLive, SqlitePersistenceMemory } from "./persistenc import { SqlClient, SqlError } from "effect/unstable/sql"; import { ProviderService, type ProviderServiceShape } from "./provider/Services/ProviderService"; import { ProviderRegistry, type ProviderRegistryShape } from "./provider/Services/ProviderRegistry"; -import { Open, type OpenShape } from "./open"; +import { DesktopLauncher, type DesktopLauncherShape } from "./process/Services/DesktopLauncher"; import { GitManager, type GitManagerShape } from "./git/Services/GitManager.ts"; import type { GitCoreShape } from "./git/Services/GitCore.ts"; import { GitCore } from "./git/Services/GitCore.ts"; @@ -62,10 +62,10 @@ const asProviderItemId = (value: string): ProviderItemId => ProviderItemId.makeU const asThreadId = (value: string): ThreadId => ThreadId.makeUnsafe(value); const asTurnId = (value: string): TurnId => TurnId.makeUnsafe(value); -const defaultOpenService: OpenShape = { +const defaultDesktopLauncherService: DesktopLauncherShape = { getAvailableEditors: Effect.succeed([]), openExternal: () => Effect.void, - openBrowser: () => Effect.void, + openBrowser: (_target) => Effect.void, openInEditor: () => Effect.void, }; @@ -501,7 +501,7 @@ describe("WebSocket Server", () => { staticDir?: string; providerLayer?: Layer.Layer; providerRegistry?: ProviderRegistryShape; - open?: OpenShape; + desktopLauncher?: DesktopLauncherShape; gitManager?: GitManagerShape; gitCore?: Pick; terminalManager?: TerminalManagerShape; @@ -522,7 +522,10 @@ describe("WebSocket Server", () => { ProviderRegistry, options.providerRegistry ?? defaultProviderRegistryService, ); - const openLayer = Layer.succeed(Open, options.open ?? defaultOpenService); + const desktopLauncherLayer = Layer.succeed( + DesktopLauncher, + options.desktopLauncher ?? defaultDesktopLauncherService, + ); const serverConfigLayer = Layer.succeed(ServerConfig, { mode: "web", port: 0, @@ -558,7 +561,7 @@ describe("WebSocket Server", () => { const dependenciesLayer = Layer.empty.pipe( Layer.provideMerge(runtimeLayer), Layer.provideMerge(providerRegistryLayer), - Layer.provideMerge(openLayer), + Layer.provideMerge(desktopLauncherLayer), Layer.provideMerge(ServerSettingsService.layerTest(options.serverSettings)), Layer.provideMerge(serverConfigLayer), Layer.provideMerge(AnalyticsService.layerTest), @@ -1031,7 +1034,7 @@ describe("WebSocket Server", () => { it("routes shell.openInEditor through the injected open service", async () => { const openCalls: Array<{ cwd: string; editor: string }> = []; - const openService: OpenShape = { + const desktopLauncherService: DesktopLauncherShape = { getAvailableEditors: Effect.succeed([]), openExternal: () => Effect.void, openBrowser: () => Effect.void, @@ -1041,7 +1044,10 @@ describe("WebSocket Server", () => { }, }; - server = await createTestServer({ cwd: "/my/workspace", open: openService }); + server = await createTestServer({ + cwd: "/my/workspace", + desktopLauncher: desktopLauncherService, + }); const addr = server.address(); const port = typeof addr === "object" && addr !== null ? addr.port : 0; @@ -1534,7 +1540,7 @@ describe("WebSocket Server", () => { }; process.on("unhandledRejection", onUnhandledRejection); - const brokenOpenService: OpenShape = { + const brokenDesktopLauncherService: DesktopLauncherShape = { getAvailableEditors: Effect.succeed([]), openExternal: () => Effect.void, openBrowser: () => Effect.void, @@ -1543,7 +1549,10 @@ describe("WebSocket Server", () => { }; try { - server = await createTestServer({ cwd: "/test", open: brokenOpenService }); + server = await createTestServer({ + cwd: "/test", + desktopLauncher: brokenDesktopLauncherService, + }); const addr = server.address(); const port = typeof addr === "object" && addr !== null ? addr.port : 0; diff --git a/apps/server/src/wsServer.ts b/apps/server/src/wsServer.ts index 9983e0209b..7a87add734 100644 --- a/apps/server/src/wsServer.ts +++ b/apps/server/src/wsServer.ts @@ -58,7 +58,7 @@ import { ProviderService } from "./provider/Services/ProviderService"; import { ProviderRegistry } from "./provider/Services/ProviderRegistry"; import { CheckpointDiffQuery } from "./checkpointing/Services/CheckpointDiffQuery"; import { clamp } from "effect/Number"; -import { Open } from "./open"; +import { DesktopLauncher } from "./process/Services/DesktopLauncher"; import { ServerConfig } from "./config"; import { GitCore } from "./git/Services/GitCore.ts"; import { tryHandleProjectFaviconRequest } from "./projectFaviconRoute"; @@ -218,7 +218,7 @@ export type ServerRuntimeServices = | TerminalManager | Keybindings | ServerSettingsService - | Open + | DesktopLauncher | AnalyticsService; export class ServerLifecycleError extends Schema.TaggedErrorClass()( @@ -250,7 +250,7 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< logWebSocketEvents, autoBootstrapProjectFromCwd, } = serverConfig; - const open = yield* Open; + const open = yield* DesktopLauncher; const availableEditors = yield* open.getAvailableEditors.pipe( Effect.mapError((cause) => new ServerLifecycleError({ operation: "availableEditors", cause })), ); @@ -618,7 +618,6 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< const projectionReadModelQuery = yield* ProjectionSnapshotQuery; const checkpointDiffQuery = yield* CheckpointDiffQuery; const orchestrationReactor = yield* OrchestrationReactor; - const { openInEditor } = open; const subscriptionsScope = yield* Scope.make("sequential"); yield* Effect.addFinalizer(() => Scope.close(subscriptionsScope, Exit.void)); @@ -810,7 +809,7 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< case WS_METHODS.shellOpenInEditor: { const body = stripRequestTag(request.body); - return yield* openInEditor(body); + return yield* open.openInEditor(body); } case WS_METHODS.gitStatus: { From 7fa5304e874b514d493c88f66ee4c7e125ca581e Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 31 Mar 2026 11:15:19 -0700 Subject: [PATCH 03/11] Detach direct app launches on Windows when not waiting - Preserve app launch arguments while passing the target URL - Run non-waited direct launches detached with ignored stdio --- .../process/Layers/DesktopLauncher.test.ts | 37 +++++++++++++++++++ .../src/process/Layers/DesktopLauncher.ts | 5 ++- 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/apps/server/src/process/Layers/DesktopLauncher.test.ts b/apps/server/src/process/Layers/DesktopLauncher.test.ts index e869b1fbea..7eb43b02a6 100644 --- a/apps/server/src/process/Layers/DesktopLauncher.test.ts +++ b/apps/server/src/process/Layers/DesktopLauncher.test.ts @@ -510,6 +510,43 @@ it.effect("openBrowser uses PowerShell on win32", () => }).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 falls back from WSL PowerShell to xdg-open", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; diff --git a/apps/server/src/process/Layers/DesktopLauncher.ts b/apps/server/src/process/Layers/DesktopLauncher.ts index c8e19b2bdc..9e43e0fa6b 100644 --- a/apps/server/src/process/Layers/DesktopLauncher.ts +++ b/apps/server/src/process/Layers/DesktopLauncher.ts @@ -326,9 +326,12 @@ function makeDirectApplicationPlan( input: OpenExternalInput, app: OpenApplicationCandidate, ): LaunchPlan { + const wait = input.wait ?? false; return makeLaunchPlan(app.name, [...app.arguments, input.target], { - wait: input.wait ?? false, + wait, allowNonzeroExitCode: input.allowNonzeroExitCode ?? false, + detached: !wait, + ...(wait ? {} : { stdio: "ignore" as const }), shell: false, }); } From 439e8bcd42992ac25b103d217ca44031f3c19466 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 31 Mar 2026 11:34:21 -0700 Subject: [PATCH 04/11] Preserve waited app launch exit errors - Map spawn failures before nonzero-exit checks in desktop launcher - Add regression coverage for waited direct app launches on Linux --- .../process/Layers/DesktopLauncher.test.ts | 33 +++++++++++++++++++ .../src/process/Layers/DesktopLauncher.ts | 6 ++-- 2 files changed, 36 insertions(+), 3 deletions(-) diff --git a/apps/server/src/process/Layers/DesktopLauncher.test.ts b/apps/server/src/process/Layers/DesktopLauncher.test.ts index 7eb43b02a6..e6cc724987 100644 --- a/apps/server/src/process/Layers/DesktopLauncher.test.ts +++ b/apps/server/src/process/Layers/DesktopLauncher.test.ts @@ -547,6 +547,39 @@ it.effect("openBrowser detaches direct app launches on win32 when not waiting", }).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; diff --git a/apps/server/src/process/Layers/DesktopLauncher.ts b/apps/server/src/process/Layers/DesktopLauncher.ts index 9e43e0fa6b..a89bb9c70c 100644 --- a/apps/server/src/process/Layers/DesktopLauncher.ts +++ b/apps/server/src/process/Layers/DesktopLauncher.ts @@ -703,6 +703,9 @@ export const make = Effect.fn("makeDesktopLauncher")(function* ( 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( @@ -710,9 +713,6 @@ export const make = Effect.fn("makeDesktopLauncher")(function* ( ) : Effect.void, ), - Effect.mapError((cause) => - makeSpawnError(context, spawnInput.command, spawnInput.args, cause), - ), ); const runWaitedPlan = ( From 935e7c6ceae0a909bcaabd36a2fbdd4b0061bb41 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 31 Mar 2026 12:15:04 -0700 Subject: [PATCH 05/11] Refactor process helpers for Windows handling and output limits - Extract shared Windows command/process utilities - Add process tree kill and probe process wrappers - Cover output truncation and Windows command behavior with tests --- apps/server/src/git/Layers/GitCore.ts | 14 ++-- .../src/process/Layers/DesktopLauncher.ts | 26 ++----- apps/server/src/process/killTree.test.ts | 47 ++++++++++++ apps/server/src/process/killTree.ts | 32 ++++++++ apps/server/src/process/outputBuffer.test.ts | 34 +++++++++ apps/server/src/process/outputBuffer.ts | 57 ++++++++++++++ .../src/process/runProbeProcess.test.ts | 35 +++++++++ apps/server/src/process/runProbeProcess.ts | 17 +++++ .../server/src/process/windowsCommand.test.ts | 65 ++++++++++++++++ apps/server/src/process/windowsCommand.ts | 35 +++++++++ apps/server/src/processRunner.ts | 76 +++++-------------- apps/server/src/provider/codexAppServer.ts | 14 +--- apps/server/src/provider/providerSnapshot.ts | 2 +- apps/server/src/terminal/Layers/Manager.ts | 16 ++-- 14 files changed, 365 insertions(+), 105 deletions(-) create mode 100644 apps/server/src/process/killTree.test.ts create mode 100644 apps/server/src/process/killTree.ts create mode 100644 apps/server/src/process/outputBuffer.test.ts create mode 100644 apps/server/src/process/outputBuffer.ts create mode 100644 apps/server/src/process/runProbeProcess.test.ts create mode 100644 apps/server/src/process/runProbeProcess.ts create mode 100644 apps/server/src/process/windowsCommand.test.ts create mode 100644 apps/server/src/process/windowsCommand.ts diff --git a/apps/server/src/git/Layers/GitCore.ts b/apps/server/src/git/Layers/GitCore.ts index 81d6cbb549..b94278095a 100644 --- a/apps/server/src/git/Layers/GitCore.ts +++ b/apps/server/src/git/Layers/GitCore.ts @@ -29,6 +29,7 @@ import { } from "../Services/GitCore.ts"; import { ServerConfig } from "../../config.ts"; import { decodeJsonResult } from "@t3tools/shared/schemaJson"; +import { limitChunkToByteLimit } from "../../process/outputBuffer"; const DEFAULT_TIMEOUT_MS = 30_000; const DEFAULT_MAX_OUTPUT_BYTES = 1_000_000; @@ -520,8 +521,8 @@ const collectOutput = Effect.fn("collectOutput")(function* ( 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), @@ -530,12 +531,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/process/Layers/DesktopLauncher.ts b/apps/server/src/process/Layers/DesktopLauncher.ts index a89bb9c70c..e276abad06 100644 --- a/apps/server/src/process/Layers/DesktopLauncher.ts +++ b/apps/server/src/process/Layers/DesktopLauncher.ts @@ -13,6 +13,11 @@ import { spawn as spawnNodeChildProcess } from "node:child_process"; 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, @@ -113,7 +118,6 @@ const WSL_POWERSHELL_CANDIDATES = [ "powershell.exe", "pwsh.exe", ] as const; -const WINDOWS_BATCH_EXTENSIONS = [".CMD", ".BAT"] as const; function shouldUseGotoFlag(editor: (typeof EDITORS)[number], target: string): boolean { return editor.supportsGoto && LINE_COLUMN_SUFFIX_PATTERN.test(target); @@ -141,10 +145,6 @@ function resolveWindowsPathExtensions(env: NodeJS.ProcessEnv): ReadonlyArray 0 ? Array.dedupe(parsed) : fallback; } -function resolveWindowsCommandShell(env: NodeJS.ProcessEnv): string { - return env.ComSpec ?? env.COMSPEC ?? "cmd.exe"; -} - function resolveCommandCandidates( command: string, runtime: LaunchRuntime, @@ -378,18 +378,6 @@ function resolveExternalPlans( return plans; } -function isWindowsBatchShim(pathService: Path.Path, filePath: string): boolean { - return WINDOWS_BATCH_EXTENSIONS.includes(pathService.extname(filePath).toUpperCase() as never); -} - -function quoteForWindowsCmd(value: string): string { - return `"${value.replaceAll("%", "%%").replaceAll('"', '""')}"`; -} - -function makeWindowsCmdCommandLine(commandPath: string, args: ReadonlyArray): string { - return `"${[commandPath, ...args].map(quoteForWindowsCmd).join(" ")}"`; -} - function resolveSpawnInput( runtime: LaunchRuntime, plan: LaunchPlan, @@ -400,7 +388,7 @@ function resolveSpawnInput( ? resolveWindowsCommandShell(runtime.env) : resolvedCommand.path, args: resolvedCommand.usesCmdWrapper - ? ["/d", "/v:off", "/s", "/c", makeWindowsCmdCommandLine(resolvedCommand.path, plan.args)] + ? makeWindowsCmdSpawnArguments(resolvedCommand.path, plan.args) : [...plan.args], ...(plan.detached !== undefined ? { detached: plan.detached } : {}), ...(plan.shell !== undefined ? { shell: plan.shell } : {}), @@ -583,7 +571,7 @@ export const make = Effect.fn("makeDesktopLauncher")(function* ( return Option.some({ path: filePath, - usesCmdWrapper: isWindowsBatchShim(pathService, filePath), + usesCmdWrapper: isWindowsBatchShim(filePath), } satisfies ResolvedCommand); } diff --git a/apps/server/src/process/killTree.test.ts b/apps/server/src/process/killTree.test.ts new file mode 100644 index 0000000000..9e48e106f4 --- /dev/null +++ b/apps/server/src/process/killTree.test.ts @@ -0,0 +1,47 @@ +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(); + + 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(() => { + throw new Error("taskkill unavailable"); + }); + + killChildProcessTree({ pid: 456, kill } as never, "SIGKILL", { + platform: "win32", + spawnSyncImpl, + }); + + expect(kill).toHaveBeenCalledWith("SIGKILL"); + }); + + 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..dd78574718 --- /dev/null +++ b/apps/server/src/process/killTree.ts @@ -0,0 +1,32 @@ +import { spawnSync, type ChildProcess as NodeChildProcess } from "node:child_process"; + +type KillableChildProcess = Pick; + +interface KillChildProcessTreeOptions { + readonly platform?: NodeJS.Platform; + readonly spawnSyncImpl?: typeof spawnSync; +} + +/** + * 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 { + (options.spawnSyncImpl ?? spawnSync)("taskkill", ["/pid", String(child.pid), "/T", "/F"], { + stdio: "ignore", + }); + 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/codexAppServer.ts b/apps/server/src/provider/codexAppServer.ts index d25fc3533e..6b1439bc26 100644 --- a/apps/server/src/provider/codexAppServer.ts +++ b/apps/server/src/provider/codexAppServer.ts @@ -1,6 +1,7 @@ -import { spawn, spawnSync, type ChildProcessWithoutNullStreams } from "node:child_process"; +import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; import readline from "node:readline"; import { readCodexAccountSnapshot, type CodexAccountSnapshot } from "./codexAccount"; +import { killChildProcessTree } from "../process/killTree"; interface JsonRpcProbeResponse { readonly id?: unknown; @@ -28,16 +29,7 @@ export function buildCodexInitializeParams() { } export function killCodexChildProcess(child: ChildProcessWithoutNullStreams): void { - if (process.platform === "win32" && child.pid !== undefined) { - try { - spawnSync("taskkill", ["/pid", String(child.pid), "/T", "/F"], { stdio: "ignore" }); - return; - } catch { - // Fall through to direct kill when taskkill is unavailable. - } - } - - child.kill(); + killChildProcessTree(child); } export async function probeCodexAccount(input: { diff --git a/apps/server/src/provider/providerSnapshot.ts b/apps/server/src/provider/providerSnapshot.ts index e1243c4bd0..3a6a0b7a93 100644 --- a/apps/server/src/provider/providerSnapshot.ts +++ b/apps/server/src/provider/providerSnapshot.ts @@ -7,7 +7,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 7ef18b1655..bcb9864502 100644 --- a/apps/server/src/terminal/Layers/Manager.ts +++ b/apps/server/src/terminal/Layers/Manager.ts @@ -23,7 +23,8 @@ import { } from "effect"; import { ServerConfig } from "../../config"; -import { runProcess } from "../../processRunner"; +import { runProbeProcess } from "../../process/runProbeProcess"; +import { resolveWindowsCommandShell } from "../../process/windowsCommand"; import { TerminalCwdError, TerminalHistoryError, @@ -174,7 +175,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"; } @@ -226,7 +227,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"), ]); @@ -301,11 +302,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({ @@ -322,11 +322,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({ @@ -339,11 +338,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({ From 0cdb35e73b8485286444158d29207651eb3c4ba5 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 31 Mar 2026 12:56:35 -0700 Subject: [PATCH 06/11] Skip WSL browser launch in containers - Treat container env detection consistently when resolving desktop launch options - Fall back to direct kill if `taskkill` exits nonzero or cannot spawn - Add coverage for the WSL container and taskkill fallback cases --- .../process/Layers/DesktopLauncher.test.ts | 68 +++++++++++++++++++ .../src/process/Layers/DesktopLauncher.ts | 21 +++--- apps/server/src/process/killTree.test.ts | 18 +++-- apps/server/src/process/killTree.ts | 25 +++++-- 4 files changed, 113 insertions(+), 19 deletions(-) diff --git a/apps/server/src/process/Layers/DesktopLauncher.test.ts b/apps/server/src/process/Layers/DesktopLauncher.test.ts index e6cc724987..8fb2950be9 100644 --- a/apps/server/src/process/Layers/DesktopLauncher.test.ts +++ b/apps/server/src/process/Layers/DesktopLauncher.test.ts @@ -620,6 +620,74 @@ it.effect("openBrowser falls back from WSL PowerShell to xdg-open", () => }).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; diff --git a/apps/server/src/process/Layers/DesktopLauncher.ts b/apps/server/src/process/Layers/DesktopLauncher.ts index e276abad06..57061a4b14 100644 --- a/apps/server/src/process/Layers/DesktopLauncher.ts +++ b/apps/server/src/process/Layers/DesktopLauncher.ts @@ -524,29 +524,30 @@ export const make = Effect.fn("makeDesktopLauncher")(function* ( 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 options.env?.CONTAINER === "string" || - typeof options.env?.container === "string" || - typeof options.env?.KUBERNETES_SERVICE_HOST === "string" + (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: options.platform ?? process.platform, - env: options.env ?? process.env, - isWsl: - options.isWsl ?? detectWsl(options.platform ?? process.platform, options.env ?? process.env), + platform: runtimePlatform, + env: runtimeEnv, + isWsl, isInsideContainer, powerShellCandidates: options.powerShellCommand !== undefined ? [options.powerShellCommand] - : (options.isWsl ?? - detectWsl(options.platform ?? process.platform, options.env ?? process.env)) + : isWsl ? WSL_POWERSHELL_CANDIDATES : WINDOWS_POWERSHELL_CANDIDATES, - windowsPathExtensions: resolveWindowsPathExtensions(options.env ?? process.env), + windowsPathExtensions: resolveWindowsPathExtensions(runtimeEnv), }; const resolveCommand = Effect.fn("resolveCommand")(function* ( diff --git a/apps/server/src/process/killTree.test.ts b/apps/server/src/process/killTree.test.ts index 9e48e106f4..cae8823638 100644 --- a/apps/server/src/process/killTree.test.ts +++ b/apps/server/src/process/killTree.test.ts @@ -5,7 +5,7 @@ import { killChildProcessTree } from "./killTree"; describe("killChildProcessTree", () => { it("uses taskkill on Windows when a pid is available", () => { const kill = vi.fn(); - const spawnSyncImpl = vi.fn(); + const spawnSyncImpl = vi.fn(() => ({ status: 0 })); killChildProcessTree({ pid: 123, kill } as never, "SIGTERM", { platform: "win32", @@ -20,9 +20,7 @@ describe("killChildProcessTree", () => { it("falls back to direct kill when taskkill fails", () => { const kill = vi.fn(); - const spawnSyncImpl = vi.fn(() => { - throw new Error("taskkill unavailable"); - }); + const spawnSyncImpl = vi.fn(() => ({ status: 1 })); killChildProcessTree({ pid: 456, kill } as never, "SIGKILL", { platform: "win32", @@ -32,6 +30,18 @@ describe("killChildProcessTree", () => { 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(); diff --git a/apps/server/src/process/killTree.ts b/apps/server/src/process/killTree.ts index dd78574718..44e017dc48 100644 --- a/apps/server/src/process/killTree.ts +++ b/apps/server/src/process/killTree.ts @@ -1,10 +1,19 @@ 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?: typeof spawnSync; + readonly spawnSyncImpl?: TaskkillRunner; } /** @@ -19,10 +28,16 @@ export function killChildProcessTree( const platform = options.platform ?? process.platform; if (platform === "win32" && child.pid !== undefined) { try { - (options.spawnSyncImpl ?? spawnSync)("taskkill", ["/pid", String(child.pid), "/T", "/F"], { - stdio: "ignore", - }); - return; + 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. } From c5472479b62a57184805c1422880f8d02294422a Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 31 Mar 2026 21:15:09 +0000 Subject: [PATCH 07/11] fix: detect immediate exit failures in detached launches and handle empty app lists - defaultSpawnDetached now waits a grace period after spawn to catch processes that exit non-zero immediately, allowing runFirstAvailablePlan to fall through to the next strategy instead of silently succeeding. - normalizeAppCandidates returns [undefined] (the platform-default branch) when the resolved candidate list is empty, so an empty app array behaves the same as omitting app entirely. Applied via @cursor push command --- .../src/process/Layers/DesktopLauncher.ts | 36 +++++++++++++++++-- 1 file changed, 33 insertions(+), 3 deletions(-) diff --git a/apps/server/src/process/Layers/DesktopLauncher.ts b/apps/server/src/process/Layers/DesktopLauncher.ts index 57061a4b14..00e6f410c9 100644 --- a/apps/server/src/process/Layers/DesktopLauncher.ts +++ b/apps/server/src/process/Layers/DesktopLauncher.ts @@ -206,7 +206,7 @@ function normalizeAppCandidates( } } - return candidates; + return candidates.length > 0 ? candidates : [undefined]; } function isUriLikeTarget(target: string): boolean { @@ -479,6 +479,8 @@ function toLaunchAttemptFailure(error: LaunchAttemptError): LaunchAttemptFailure * 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, @@ -496,22 +498,50 @@ function defaultSpawnDetached( }).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.unref(); - resume(Effect.void); + 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); }); }), ), From 9b3394c1d6784d773e76db5579ce80eb819a8829 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 31 Mar 2026 14:30:04 -0700 Subject: [PATCH 08/11] rm aliases --- apps/server/src/codexAppServerManager.ts | 14 +++----------- apps/server/src/provider/codexAppServer.ts | 8 ++------ 2 files changed, 5 insertions(+), 17 deletions(-) diff --git a/apps/server/src/codexAppServerManager.ts b/apps/server/src/codexAppServerManager.ts index 3145038647..c06a3d2614 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"; @@ -306,15 +307,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, @@ -908,7 +900,7 @@ export class CodexAppServerManager extends EventEmitter Date: Tue, 31 Mar 2026 14:31:01 -0700 Subject: [PATCH 09/11] naming --- apps/server/src/wsServer.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/server/src/wsServer.ts b/apps/server/src/wsServer.ts index f401192f38..abe7956e18 100644 --- a/apps/server/src/wsServer.ts +++ b/apps/server/src/wsServer.ts @@ -214,8 +214,8 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< logWebSocketEvents, autoBootstrapProjectFromCwd, } = serverConfig; - const open = yield* DesktopLauncher; - const availableEditors = yield* open.getAvailableEditors.pipe( + const desktopLauncher = yield* DesktopLauncher; + const availableEditors = yield* desktopLauncher.getAvailableEditors.pipe( Effect.mapError((cause) => new ServerLifecycleError({ operation: "availableEditors", cause })), ); @@ -747,7 +747,7 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< case WS_METHODS.shellOpenInEditor: { const body = stripRequestTag(request.body); - return yield* open.openInEditor(body); + return yield* desktopLauncher.openInEditor(body); } case WS_METHODS.gitStatus: { From 1218eff09995c1ba33caa1fcc18557844bc27cc4 Mon Sep 17 00:00:00 2001 From: julius Date: Tue, 31 Mar 2026 07:51:50 -0700 Subject: [PATCH 10/11] Launch Windows shims through PowerShell Start-Process - Prefer PowerShell for detached Windows batch shim launches - Preserve literal args in the encoded Start-Process command - Update launcher tests for the new spawn path --- CLAUDE.md | 2 +- .../process/Layers/DesktopLauncher.test.ts | 23 +++++-- .../src/process/Layers/DesktopLauncher.ts | 60 ++++++++++++++++++- 3 files changed, 76 insertions(+), 9 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 47dc3e3d86..c317064255 120000 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1 +1 @@ -AGENTS.md \ No newline at end of file +AGENTS.md diff --git a/apps/server/src/process/Layers/DesktopLauncher.test.ts b/apps/server/src/process/Layers/DesktopLauncher.test.ts index 8fb2950be9..f8978d43f0 100644 --- a/apps/server/src/process/Layers/DesktopLauncher.test.ts +++ b/apps/server/src/process/Layers/DesktopLauncher.test.ts @@ -28,6 +28,7 @@ interface SpawnCall { readonly args: ReadonlyArray; readonly detached?: boolean | undefined; readonly shell?: boolean | string | undefined; + readonly windowsVerbatimArguments?: boolean | undefined; readonly stdin?: unknown; readonly stdout?: unknown; readonly stderr?: unknown; @@ -152,6 +153,7 @@ function spawnHarness( args: [...input.args], detached: input.detached, shell: input.shell, + windowsVerbatimArguments: input.windowsVerbatimArguments, stdin: input.stdin, stdout: input.stdout, stderr: input.stderr, @@ -336,12 +338,23 @@ it.effect("openInEditor launches Windows batch shims through cmd.exe without she }); assert.equal(calls.length, 1); - assert.equal(calls[0]?.command, "cmd.exe"); - assert.deepEqual(calls[0]?.args.slice(0, 4), ["/d", "/v:off", "/s", "/c"]); - assert.equal(calls[0]?.args[4]?.toLowerCase().includes(`${dir}/code.cmd`.toLowerCase()), true); - assert.equal(calls[0]?.args[4]?.includes('"--goto"'), true); - assert.equal(calls[0]?.args[4]?.includes("100%% real"), true); + assert.equal(calls[0]?.command.toLowerCase().includes("powershell"), true); + assert.deepEqual(calls[0]?.args.slice(0, 5), [ + "-NoProfile", + "-NonInteractive", + "-ExecutionPolicy", + "Bypass", + "-EncodedCommand", + ]); + const encoded = calls[0]?.args[5]; + assert.isString(encoded); + const decoded = decodePowerShellCommand(encoded!); + assert.equal(decoded.includes("Start-Process"), true); + assert.equal(decoded.toLowerCase().includes(`${dir}/code.cmd`.toLowerCase()), true); + assert.equal(decoded.includes("--goto"), true); + assert.equal(decoded.includes("100% real"), true); assert.equal(calls[0]?.detached, true); + assert.equal(calls[0]?.windowsVerbatimArguments, undefined); assert.equal(calls[0]?.shell, false); assert.equal(calls[0]?.stdin, "ignore"); assert.equal(calls[0]?.stdout, "ignore"); diff --git a/apps/server/src/process/Layers/DesktopLauncher.ts b/apps/server/src/process/Layers/DesktopLauncher.ts index 00e6f410c9..85302c2335 100644 --- a/apps/server/src/process/Layers/DesktopLauncher.ts +++ b/apps/server/src/process/Layers/DesktopLauncher.ts @@ -37,6 +37,7 @@ export interface DetachedSpawnInput { readonly args: ReadonlyArray; readonly detached?: boolean; readonly shell?: boolean; + readonly windowsVerbatimArguments?: boolean; readonly stdin?: "ignore"; readonly stdout?: "ignore"; readonly stderr?: "ignore"; @@ -303,6 +304,23 @@ function makePowerShellPlan(input: OpenExternalInput, powerShellCommand: string) ); } +function makePowerShellStartProcessArgs( + commandPath: string, + args: ReadonlyArray, +): ReadonlyArray { + const argumentList = + args.length > 0 ? ` -ArgumentList ${args.map(quotePowerShellValue).join(", ")}` : ""; + const command = `Start-Process -FilePath ${quotePowerShellValue(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], { @@ -382,7 +400,23 @@ 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 } : {}), + ...(plan.stdio === "ignore" + ? { + stdin: "ignore" as const, + stdout: "ignore" as const, + stderr: "ignore" as const, + } + : {}), + }; + } return { command: resolvedCommand.usesCmdWrapper ? resolveWindowsCommandShell(runtime.env) @@ -392,6 +426,7 @@ function resolveSpawnInput( : [...plan.args], ...(plan.detached !== undefined ? { detached: plan.detached } : {}), ...(plan.shell !== undefined ? { shell: plan.shell } : {}), + ...(resolvedCommand.usesCmdWrapper ? { windowsVerbatimArguments: true } : {}), ...(plan.stdio === "ignore" ? { stdin: "ignore" as const, @@ -490,6 +525,9 @@ function defaultSpawnDetached( spawnNodeChildProcess(input.command, [...input.args], { ...(input.detached !== undefined ? { detached: input.detached } : {}), ...(input.shell !== undefined ? { shell: input.shell } : {}), + ...(input.windowsVerbatimArguments !== undefined + ? { windowsVerbatimArguments: input.windowsVerbatimArguments } + : {}), ...(input.stdin === "ignore" && input.stdout === "ignore" && input.stderr === "ignore" ? { stdio: "ignore" as const } : {}), @@ -645,6 +683,16 @@ export const make = Effect.fn("makeDesktopLauncher")(function* ( 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" @@ -695,7 +743,7 @@ export const make = Effect.fn("makeDesktopLauncher")(function* ( resolvedCommand: ResolvedCommand, context: LaunchContext, ) => { - const input = resolveSpawnInput(runtime, plan, resolvedCommand); + const input = resolveSpawnInput(runtime, plan, resolvedCommand, startProcessLauncher); return spawner .spawn( ChildProcess.make(input.command, [...input.args], { @@ -743,7 +791,12 @@ export const make = Effect.fn("makeDesktopLauncher")(function* ( Scope.make("sequential"), (scope) => Effect.gen(function* () { - const spawnInput = resolveSpawnInput(runtime, plan, resolvedCommand); + const spawnInput = resolveSpawnInput( + runtime, + plan, + resolvedCommand, + startProcessLauncher, + ); const handle = yield* spawnPlan(plan, resolvedCommand, context).pipe( Scope.provide(scope), ); @@ -756,7 +809,8 @@ export const make = Effect.fn("makeDesktopLauncher")(function* ( plan: LaunchPlan, resolvedCommand: ResolvedCommand, context: LaunchContext, - ) => spawnDetached(resolveSpawnInput(runtime, plan, resolvedCommand), context); + ) => + spawnDetached(resolveSpawnInput(runtime, plan, resolvedCommand, startProcessLauncher), context); const runPlan = (plan: LaunchPlan, resolvedCommand: ResolvedCommand, context: LaunchContext) => plan.wait From 8d257e2c2eb424ae7bfbece5edda5feee052213a Mon Sep 17 00:00:00 2001 From: julius Date: Fri, 10 Apr 2026 22:57:57 -0700 Subject: [PATCH 11/11] Open VS Code on Windows via protocol URI - Launch `explorer.exe` with a `vscode://file/...` target for Windows editor opens - Improve Windows spawn handling with `windowsHide` and protocol URI coverage --- apps/server/spawned2.txt | 2 + .../process/Layers/DesktopLauncher.test.ts | 37 ++++------ .../src/process/Layers/DesktopLauncher.ts | 69 ++++++++++++++++++- apps/server/tmp-test.cmd | 3 + 4 files changed, 86 insertions(+), 25 deletions(-) create mode 100644 apps/server/spawned2.txt create mode 100644 apps/server/tmp-test.cmd 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/process/Layers/DesktopLauncher.test.ts b/apps/server/src/process/Layers/DesktopLauncher.test.ts index f8978d43f0..8db3a7eef9 100644 --- a/apps/server/src/process/Layers/DesktopLauncher.test.ts +++ b/apps/server/src/process/Layers/DesktopLauncher.test.ts @@ -29,6 +29,7 @@ interface SpawnCall { 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; @@ -154,6 +155,7 @@ function spawnHarness( detached: input.detached, shell: input.shell, windowsVerbatimArguments: input.windowsVerbatimArguments, + windowsHide: input.windowsHide, stdin: input.stdin, stdout: input.stdout, stderr: input.stderr, @@ -313,11 +315,11 @@ it.effect("openInEditor uses --goto for editors that support it", () => }).pipe(Effect.provide(NodeFileSystem.layer)), ); -it.effect("openInEditor launches Windows batch shims through cmd.exe without shell mode", () => +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}/code.cmd`, "@echo off\r\n"); + yield* fs.writeFileString(`${dir}/explorer.exe`, "MZ"); const calls: Array = []; const open = yield* runOpen( @@ -337,28 +339,17 @@ it.effect("openInEditor launches Windows batch shims through cmd.exe without she editor: "vscode", }); - assert.equal(calls.length, 1); - assert.equal(calls[0]?.command.toLowerCase().includes("powershell"), true); - assert.deepEqual(calls[0]?.args.slice(0, 5), [ - "-NoProfile", - "-NonInteractive", - "-ExecutionPolicy", - "Bypass", - "-EncodedCommand", + 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", + }, ]); - const encoded = calls[0]?.args[5]; - assert.isString(encoded); - const decoded = decodePowerShellCommand(encoded!); - assert.equal(decoded.includes("Start-Process"), true); - assert.equal(decoded.toLowerCase().includes(`${dir}/code.cmd`.toLowerCase()), true); - assert.equal(decoded.includes("--goto"), true); - assert.equal(decoded.includes("100% real"), true); - assert.equal(calls[0]?.detached, true); - assert.equal(calls[0]?.windowsVerbatimArguments, undefined); - 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)), ); diff --git a/apps/server/src/process/Layers/DesktopLauncher.ts b/apps/server/src/process/Layers/DesktopLauncher.ts index 85302c2335..64b32d60b2 100644 --- a/apps/server/src/process/Layers/DesktopLauncher.ts +++ b/apps/server/src/process/Layers/DesktopLauncher.ts @@ -9,6 +9,7 @@ */ 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"; @@ -38,6 +39,7 @@ export interface DetachedSpawnInput { readonly detached?: boolean; readonly shell?: boolean; readonly windowsVerbatimArguments?: boolean; + readonly windowsHide?: boolean; readonly stdin?: "ignore"; readonly stdout?: "ignore"; readonly stderr?: "ignore"; @@ -119,6 +121,11 @@ const WSL_POWERSHELL_CANDIDATES = [ "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); @@ -128,6 +135,24 @@ 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 ?? ""; } @@ -188,6 +213,10 @@ 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"); } @@ -218,6 +247,19 @@ function shouldPreferWindowsOpenerOnWsl(input: OpenExternalInput, runtime: Launc 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, @@ -309,8 +351,8 @@ function makePowerShellStartProcessArgs( args: ReadonlyArray, ): ReadonlyArray { const argumentList = - args.length > 0 ? ` -ArgumentList ${args.map(quotePowerShellValue).join(", ")}` : ""; - const command = `Start-Process -FilePath ${quotePowerShellValue(commandPath)}${argumentList} -WindowStyle Hidden`; + args.length > 0 ? ` -ArgumentList ${args.map(quotePowerShellArgument).join(",")}` : ""; + const command = `Start ${quotePowerShellArgument(commandPath)}${argumentList} -WindowStyle Hidden`; return [ "-NoProfile", "-NonInteractive", @@ -336,6 +378,8 @@ function makeWindowsExplorerPlan(input: OpenExternalInput): LaunchPlan { return makeLaunchPlan("explorer", [input.target], { wait: false, allowNonzeroExitCode: false, + detached: true, + stdio: "ignore", shell: false, }); } @@ -408,6 +452,9 @@ function resolveSpawnInput( 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, @@ -417,6 +464,7 @@ function resolveSpawnInput( : {}), }; } + const windowsHide = runtime.platform === "win32" && plan.detached ? true : undefined; return { command: resolvedCommand.usesCmdWrapper ? resolveWindowsCommandShell(runtime.env) @@ -426,6 +474,7 @@ function resolveSpawnInput( : [...plan.args], ...(plan.detached !== undefined ? { detached: plan.detached } : {}), ...(plan.shell !== undefined ? { shell: plan.shell } : {}), + ...(windowsHide ? { windowsHide } : {}), ...(resolvedCommand.usesCmdWrapper ? { windowsVerbatimArguments: true } : {}), ...(plan.stdio === "ignore" ? { @@ -525,6 +574,7 @@ function defaultSpawnDetached( 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 } : {}), @@ -891,6 +941,21 @@ export const make = Effect.fn("makeDesktopLauncher")(function* ( 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( [ 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 +