diff --git a/apps/server/src/device/AgentDeviceShim.test.ts b/apps/server/src/device/AgentDeviceShim.test.ts new file mode 100644 index 000000000000..7ee999c7c31f --- /dev/null +++ b/apps/server/src/device/AgentDeviceShim.test.ts @@ -0,0 +1,68 @@ +// @effect-diagnostics nodeBuiltinImport:off - Executes the emitted Node launcher with a local spawn fixture. +import * as NodeChildProcess from "node:child_process"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import { + HostProcessExecutablePath, + HostProcessIsExecutable, + HostProcessPlatform, +} from "@t3tools/shared/hostProcess"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; + +import { ensureAgentDeviceShim } from "./AgentDeviceShim.ts"; + +it.effect( + "sets windowsHide and forwards arguments and exit status in the emitted device launcher", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-device-shim-" }); + const entryPath = path.join(root, "entry with spaces.mjs"); + const shimDir = yield* ensureAgentDeviceShim({ entryPath, stateDir: root }); + const preload = path.join(root, "spawn-fixture.cjs"); + yield* fs.writeFileString( + preload, + ` +const { EventEmitter } = require("node:events"); +require("node:child_process").spawn = (command, args, options) => { + console.log(JSON.stringify({ command, args, windowsHide: options.windowsHide, stdio: options.stdio, + inheritedConfig: options.env.AGENT_DEVICE_CONFIG })); + const child = new EventEmitter(); + queueMicrotask(() => child.emit("exit", 23)); + return child; +}; +require("node:module").syncBuiltinESMExports(); +`, + ); + const result = NodeChildProcess.spawnSync( + process.execPath, + ["--require", preload, path.join(shimDir, "agent-device-launcher.mjs"), "--help"], + { + encoding: "utf8", + windowsHide: true, + env: { ...process.env, AGENT_DEVICE_CONFIG: "fixture-config" }, + }, + ); + assert.isUndefined(result.error); + assert.equal(result.status, 23); + assert.deepEqual(JSON.parse(result.stdout), { + command: process.execPath, + args: [entryPath, "--help"], + windowsHide: true, + stdio: "inherit", + }); + assert.include( + yield* fs.readFileString(path.join(shimDir, "agent-device.cmd")), + "agent-device-launcher.mjs", + ); + }).pipe( + Effect.scoped, + Effect.provideService(HostProcessPlatform, "win32"), + Effect.provideService(HostProcessExecutablePath, process.execPath), + Effect.provideService(HostProcessIsExecutable, false), + Effect.provide(NodeServices.layer), + ), +); diff --git a/apps/server/src/device/AgentDeviceShim.ts b/apps/server/src/device/AgentDeviceShim.ts index a5a669d1d8ba..b9303e34e10e 100644 --- a/apps/server/src/device/AgentDeviceShim.ts +++ b/apps/server/src/device/AgentDeviceShim.ts @@ -39,7 +39,7 @@ const env = { ...process.env }; delete env.AGENT_DEVICE_DAEMON_BASE_URL; delete env.AGENT_DEVICE_DAEMON_AUTH_TOKEN; delete env.AGENT_DEVICE_CONFIG; -const child = spawn(${JSON.stringify(node)}, [${JSON.stringify(entryPath)}, ...args], { stdio: "inherit", env }); +const child = spawn(${JSON.stringify(node)}, [${JSON.stringify(entryPath)}, ...args], { stdio: "inherit", env, windowsHide: true }); child.on("error", error => { console.error(error.message); process.exitCode = 1; }); child.on("exit", code => { process.exitCode = code ?? 1; }); `, diff --git a/apps/server/src/process/externalLauncher.test.ts b/apps/server/src/process/externalLauncher.test.ts index aab4d78f4d71..9794c1c51592 100644 --- a/apps/server/src/process/externalLauncher.test.ts +++ b/apps/server/src/process/externalLauncher.test.ts @@ -121,6 +121,31 @@ it.effect("launches the default browser through the platform command", () => { ); }); +it.effect("sets windowsHide for the Windows browser helper", () => { + let spawned: ChildProcess.StandardCommand | undefined; + return Effect.gen(function* () { + const launcher = yield* ExternalLauncher.ExternalLauncher; + yield* launcher.launchBrowser("https://example.com"); + assert.ok(spawned); + assert.equal(spawned.options.windowsHide, true); + assert.equal(spawned.options.detached, true); + assert.equal(spawned.options.shell, false); + assert.include( + Buffer.from(spawned.args.at(-1) ?? "", "base64").toString("utf16le"), + "https://example.com", + ); + }).pipe( + Effect.provide( + testLayer({ + platform: "win32", + onSpawn: (command) => { + spawned = command; + }, + }), + ), + ); +}); + it.effect("launches an installed editor with platform-safe arguments", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; @@ -338,6 +363,7 @@ it.effect("reveals a file in File Explorer through PowerShell on Windows", () => "$ProgressPreference = 'SilentlyContinue'; Start-Process 'explorer.exe' -ArgumentList ('/select,\"' + 'C:\\workspace with spaces\\media\\author''s clip.mp4' + '\"')", ); assert.equal(spawned.options.shell, false); + assert.equal(spawned.options.windowsHide, true); }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); diff --git a/apps/server/src/process/externalLauncher.ts b/apps/server/src/process/externalLauncher.ts index ab287e4d78ee..ce469bebd2a1 100644 --- a/apps/server/src/process/externalLauncher.ts +++ b/apps/server/src/process/externalLauncher.ts @@ -57,6 +57,7 @@ interface EditorLaunch { readonly target: string; readonly command: string; readonly args: ReadonlyArray; + readonly hideConsole?: true; } interface ProcessLaunch { @@ -215,6 +216,7 @@ function resolveWindowsBrowserLaunch(target: string, command: string): ProcessLa args: [...POWERSHELL_ARGUMENTS_PREFIX, encodedCommand], options: { detached: true, + windowsHide: true, shell: false, stdin: "ignore", stdout: "ignore", @@ -599,6 +601,7 @@ function fileExplorerRevealLaunch( ...POWERSHELL_ARGUMENTS_PREFIX, encodeUtf16LeBase64(buildFileExplorerRevealPowerShellSource("explorer.exe", explorerTarget)), ], + hideConsole: true, }; } @@ -730,6 +733,7 @@ const launchEditorProcess = Effect.fn("externalLauncher.launchEditorProcess")(fu options: { detached: true, shell: spawnCommand.shell, + ...(launch.hideConsole === true ? { windowsHide: true } : {}), stdin: "ignore", stdout: "ignore", stderr: "ignore", diff --git a/packages/shared/src/legacyCliLauncher.test.ts b/packages/shared/src/legacyCliLauncher.test.ts index b6630f468a83..12905e6cc397 100644 --- a/packages/shared/src/legacyCliLauncher.test.ts +++ b/packages/shared/src/legacyCliLauncher.test.ts @@ -13,6 +13,50 @@ const hostPlatform = NodeOS.platform(); // oxlint-disable-next-line t3code/no-global-process-runtime -- Match the real executable used by the subprocess. const hostArch = NodeOS.arch(); +it("sets windowsHide and forwards arguments and exit status in the generated launcher", async () => { + const root = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-legacy-launcher-")); + try { + const entry = NodePath.join(root, "node_modules/t3/dist/bin.mjs"); + const packageDir = NodePath.join(root, `node_modules/@t3code/t3-${hostPlatform}-${hostArch}`); + const preload = NodePath.join(root, "spawn-fixture.cjs"); + await NodeFSP.mkdir(NodePath.dirname(entry), { recursive: true }); + await NodeFSP.mkdir(packageDir, { recursive: true }); + await NodeFSP.writeFile(NodePath.join(packageDir, "package.json"), "{}"); + await NodeFSP.writeFile(entry, legacyCliLauncherScript()); + await NodeFSP.writeFile( + preload, + ` +const { EventEmitter } = require("node:events"); +require("node:child_process").spawn = (command, args, options) => { + const record = { command, args, options }; + const child = new EventEmitter(); + console.log(JSON.stringify(record)); + queueMicrotask(() => child.emit("exit", 23)); + return child; +}; +require("node:module").syncBuiltinESMExports(); +`, + ); + const result = NodeChildProcess.spawnSync( + process.execPath, + ["--require", preload, entry, "serve", "a path with spaces"], + { + encoding: "utf8", + windowsHide: true, + }, + ); + expect(result.error).toBeUndefined(); + expect(result.status).toBe(23); + expect(JSON.parse(result.stdout)).toEqual({ + command: NodePath.join(packageDir, hostPlatform === "win32" ? "t3.exe" : "t3"), + args: ["serve", "a path with spaces"], + options: { stdio: "inherit", windowsHide: true }, + }); + } finally { + await NodeFSP.rm(root, { recursive: true, force: true }); + } +}); + // The fixture executable uses a POSIX shebang. The wrapper itself also runs on Windows. it.skipIf(hostPlatform === "win32")( "keeps service IPC, arguments, and termination connected", diff --git a/packages/shared/src/legacyCliLauncher.ts b/packages/shared/src/legacyCliLauncher.ts index b852e2310dba..8c527ca14c81 100644 --- a/packages/shared/src/legacyCliLauncher.ts +++ b/packages/shared/src/legacyCliLauncher.ts @@ -21,6 +21,7 @@ const executable = join(dirname(require.resolve("@t3code/t3-" + process.platform const ipc = process.send !== undefined; const child = spawn(executable, process.argv.slice(2), { stdio: ipc ? ["inherit", "inherit", "inherit", "ipc"] : "inherit", + windowsHide: true, }); const fail = (error) => { if (!error) return; diff --git a/packages/shared/src/shell.test.ts b/packages/shared/src/shell.test.ts index 6c89e49d017f..2270e08e00f3 100644 --- a/packages/shared/src/shell.test.ts +++ b/packages/shared/src/shell.test.ts @@ -216,7 +216,7 @@ describe("readEnvironmentFromWindowsShell", () => { expect(execFile).toHaveBeenCalledWith( "pwsh.exe", expect.arrayContaining(["-NoLogo", "-NoProfile", "-NonInteractive", "-Command"]), - { encoding: "utf8", timeout: 5000 }, + { encoding: "utf8", timeout: 5000, windowsHide: true }, ); }); @@ -252,7 +252,7 @@ describe("readEnvironmentFromWindowsShell", () => { expect(execFile).toHaveBeenCalledWith( "pwsh.exe", expect.arrayContaining(["-NoLogo", "-NonInteractive", "-Command"]), - { encoding: "utf8", timeout: 5000 }, + { encoding: "utf8", timeout: 5000, windowsHide: true }, ); expect(execFile.mock.calls[0]?.[1]).not.toContain("-NoProfile"); }); @@ -277,10 +277,12 @@ describe("readEnvironmentFromWindowsShell", () => { expect(execFile).toHaveBeenNthCalledWith(1, "pwsh.exe", expect.any(Array), { encoding: "utf8", timeout: 5000, + windowsHide: true, }); expect(execFile).toHaveBeenNthCalledWith(2, "powershell.exe", expect.any(Array), { encoding: "utf8", timeout: 5000, + windowsHide: true, }); }); }); diff --git a/packages/shared/src/shell.ts b/packages/shared/src/shell.ts index 6cb08d2890be..01ef4d244f56 100644 --- a/packages/shared/src/shell.ts +++ b/packages/shared/src/shell.ts @@ -21,7 +21,7 @@ const WINDOWS_SHELL_CANDIDATES = ["pwsh.exe", "powershell.exe"] as const; type ExecFileSyncLike = ( file: string, args: ReadonlyArray, - options: { encoding: "utf8"; timeout: number }, + options: { encoding: "utf8"; timeout: number; windowsHide?: true }, ) => string; function canExecuteFile(filePath: string): boolean { @@ -370,7 +370,7 @@ export function readEnvironmentFromWindowsShell( ]; for (const shell of WINDOWS_SHELL_CANDIDATES) { try { - const output = execFile(shell, args, { encoding: "utf8", timeout: 5000 }); + const output = execFile(shell, args, { encoding: "utf8", timeout: 5000, windowsHide: true }); const environment: Partial> = {}; for (const name of names) {