From a474a0fa129c162a35e0150763126d15637a2ad8 Mon Sep 17 00:00:00 2001 From: lorenzozanee Date: Wed, 16 Sep 2026 01:59:45 +0800 Subject: [PATCH] fix(cli): hide Windows console for remaining child-process spawns --- packages/cli/src/server/portUtils.ts | 5 +- .../src/server/portUtils.windowsHide.test.ts | 101 ++++++++++++++++++ packages/cli/src/telemetry/transport.ts | 2 +- .../telemetry/transport.windowsHide.test.ts | 24 +++++ packages/cli/src/utils/clipboard.ts | 3 +- .../src/utils/clipboard.windowsHide.test.ts | 36 +++++++ 6 files changed, 168 insertions(+), 3 deletions(-) create mode 100644 packages/cli/src/server/portUtils.windowsHide.test.ts create mode 100644 packages/cli/src/telemetry/transport.windowsHide.test.ts create mode 100644 packages/cli/src/utils/clipboard.windowsHide.test.ts diff --git a/packages/cli/src/server/portUtils.ts b/packages/cli/src/server/portUtils.ts index 44de5a60c4..6c0af7ea49 100644 --- a/packages/cli/src/server/portUtils.ts +++ b/packages/cli/src/server/portUtils.ts @@ -217,7 +217,10 @@ async function getProcessOnPort(port: number): Promise { async function windowsListenerPid(port: number): Promise { try { - const { stdout } = await execFileAsync("netstat", ["-ano", "-p", "tcp"], { timeout: 4000 }); + const { stdout } = await execFileAsync("netstat", ["-ano", "-p", "tcp"], { + timeout: 4000, + windowsHide: true, + }); for (const line of stdout.split(/\r?\n/)) { const columns = line.trim().split(/\s+/); if (columns.length < 5 || columns[3] !== "LISTENING") continue; diff --git a/packages/cli/src/server/portUtils.windowsHide.test.ts b/packages/cli/src/server/portUtils.windowsHide.test.ts new file mode 100644 index 0000000000..407aa91bc6 --- /dev/null +++ b/packages/cli/src/server/portUtils.windowsHide.test.ts @@ -0,0 +1,101 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { createServer as createHttpServer, type Server as HttpServer } from "node:http"; +import { promisify } from "node:util"; + +const { execFileMock } = vi.hoisted(() => ({ execFileMock: vi.fn() })); + +vi.mock("node:child_process", () => ({ execFile: execFileMock })); + +// portUtils promisifies execFile at import time. The mock has no +// promisify.custom implementation, so teach it to resolve { stdout, stderr } +// like the real child_process.execFile does. +type ExecCallback = (error: unknown, stdout: string, stderr: string) => void; +( + execFileMock as unknown as Record< + symbol, + (command: string, args: string[], options: unknown) => Promise + > +)[promisify.custom] = (command: string, args: string[], options: unknown) => + new Promise((resolve, reject) => { + (execFileMock as unknown as (...call: unknown[]) => void)(command, args, options, (( + error: unknown, + stdout: string, + stderr: string, + ) => (error ? reject(error) : resolve({ stdout, stderr }))) as unknown); + }); + +const { activeServerOnPort } = await import("./portUtils.js"); + +const openHttpServers: HttpServer[] = []; + +async function startConfigProbeServer(port: number): Promise { + const server = createHttpServer((_req, res) => { + res.setHeader("Content-Type", "application/json"); + res.end( + JSON.stringify({ + isHyperframes: true, + projectName: "demo-project", + projectDir: "/tmp/demo-project", + serverBuildSignature: null, + version: "0.6.42", + pid: 4242, + }), + ); + }); + openHttpServers.push(server); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(port, "127.0.0.1", () => resolve()); + }); +} + +describe("port listener lookup child-process options", () => { + const originalPlatform = process.platform; + + beforeEach(() => { + Object.defineProperty(process, "platform", { value: "win32", configurable: true }); + execFileMock.mockImplementation( + (_command: string, _args: string[], _options: unknown, callback: ExecCallback) => { + callback(null, "", ""); + }, + ); + }); + + afterEach(async () => { + Object.defineProperty(process, "platform", { value: originalPlatform, configurable: true }); + vi.clearAllMocks(); + await Promise.all( + openHttpServers + .splice(0) + .map((s) => new Promise((resolve) => s.close(() => resolve()))), + ); + }); + + it("hides the netstat console window used for Windows listener lookup", async () => { + // Occupy an ephemeral port with a HyperFrames config responder so the + // default OS listener lookup runs instead of the injected stub. + const ephemeral = createHttpServer(); + await new Promise((resolve) => ephemeral.listen(0, "127.0.0.1", () => resolve())); + const port = (ephemeral.address() as import("node:net").AddressInfo).port; + await new Promise((resolve) => ephemeral.close(() => resolve())); + await startConfigProbeServer(port); + + execFileMock.mockImplementation( + (_command: string, _args: string[], _options: unknown, callback: ExecCallback) => { + callback( + null, + ` TCP 127.0.0.1:${port} 0.0.0.0:0 LISTENING 1234\r\n`, + "", + ); + }, + ); + + const server = await activeServerOnPort(port); + + expect(execFileMock).toHaveBeenCalledTimes(1); + expect(execFileMock.mock.calls[0]?.[0]).toBe("netstat"); + expect(execFileMock.mock.calls[0]?.[2]).toEqual(expect.objectContaining({ windowsHide: true })); + expect(server?.pid).toBe("1234"); + expect(server?.pidSource).toBe("os"); + }); +}); diff --git a/packages/cli/src/telemetry/transport.ts b/packages/cli/src/telemetry/transport.ts index 0605280a06..f9b782f54e 100644 --- a/packages/cli/src/telemetry/transport.ts +++ b/packages/cli/src/telemetry/transport.ts @@ -155,7 +155,7 @@ export function flushSync(): void { "-e", `fetch(${JSON.stringify(`${POSTHOG_HOST}/batch/`)},{method:"POST",headers:{"Content-Type":"application/json"},body:${JSON.stringify(payload)},signal:AbortSignal.timeout(${FLUSH_TIMEOUT_MS})}).catch(()=>{})`, ], - { detached: true, stdio: "ignore" }, + { detached: true, stdio: "ignore", windowsHide: true }, ); // Let the parent exit without waiting for the child child.unref(); diff --git a/packages/cli/src/telemetry/transport.windowsHide.test.ts b/packages/cli/src/telemetry/transport.windowsHide.test.ts new file mode 100644 index 0000000000..ff3d40c7e1 --- /dev/null +++ b/packages/cli/src/telemetry/transport.windowsHide.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it, vi } from "vitest"; + +const { spawnMock } = vi.hoisted(() => ({ spawnMock: vi.fn() })); + +vi.mock("node:child_process", () => ({ spawn: spawnMock })); +vi.mock("./config.js", () => ({ + readConfig: () => ({ anonymousId: "anon-test-123", telemetryEnabled: true }), +})); + +import { enqueue, flushSync } from "./transport.js"; + +describe("telemetry flushSync child-process options", () => { + it("hides the detached Node console window on Windows", () => { + spawnMock.mockReturnValue({ unref: vi.fn() }); + + enqueue("test_event", {}); + flushSync(); + + expect(spawnMock).toHaveBeenCalledTimes(1); + expect(spawnMock.mock.calls[0]?.[0]).toBe(process.execPath); + expect(spawnMock.mock.calls[0]?.[2]).toEqual(expect.objectContaining({ detached: true })); + expect(spawnMock.mock.calls[0]?.[2]).toEqual(expect.objectContaining({ windowsHide: true })); + }); +}); diff --git a/packages/cli/src/utils/clipboard.ts b/packages/cli/src/utils/clipboard.ts index f20dad7252..754632b930 100644 --- a/packages/cli/src/utils/clipboard.ts +++ b/packages/cli/src/utils/clipboard.ts @@ -33,7 +33,7 @@ function detectProvider(): ClipboardProvider | undefined { ]; const cmd = process.platform === "win32" ? "where" : "which"; for (const p of candidates) { - const result = spawnSync(cmd, [p.cmd], { stdio: "ignore" }); + const result = spawnSync(cmd, [p.cmd], { stdio: "ignore", windowsHide: true }); if (result.status === 0) return p; } return undefined; @@ -49,6 +49,7 @@ export function copyToClipboard(text: string): boolean { const res = spawnSync(provider.cmd, provider.args, { input: text, encoding: "utf-8", + windowsHide: true, }); return res.status === 0; } catch { diff --git a/packages/cli/src/utils/clipboard.windowsHide.test.ts b/packages/cli/src/utils/clipboard.windowsHide.test.ts new file mode 100644 index 0000000000..d49349eb76 --- /dev/null +++ b/packages/cli/src/utils/clipboard.windowsHide.test.ts @@ -0,0 +1,36 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const { spawnSyncMock } = vi.hoisted(() => ({ spawnSyncMock: vi.fn() })); + +vi.mock("node:child_process", () => ({ spawnSync: spawnSyncMock })); +vi.mock("node:os", () => ({ platform: () => "linux" })); + +import { copyToClipboard } from "./clipboard.js"; + +describe("clipboard child-process options", () => { + const originalPlatform = process.platform; + + beforeEach(() => { + // Exercise the `where`-based provider probe: os.platform() reports a + // non-Windows OS (so the candidate loop runs) while the probe itself + // resolves `where`, the branch taken on Windows. + Object.defineProperty(process, "platform", { value: "win32", configurable: true }); + spawnSyncMock.mockReturnValue({ status: 0 }); + }); + + afterEach(() => { + Object.defineProperty(process, "platform", { value: originalPlatform, configurable: true }); + vi.clearAllMocks(); + }); + + it("hides console windows for provider probing and copying", () => { + expect(copyToClipboard("snippet")).toBe(true); + + // One `where` probe plus the provider copy itself. + expect(spawnSyncMock.mock.calls.length).toBeGreaterThanOrEqual(2); + expect(spawnSyncMock.mock.calls[0]?.[0]).toBe("where"); + for (const call of spawnSyncMock.mock.calls) { + expect(call[2]).toEqual(expect.objectContaining({ windowsHide: true })); + } + }); +});