fix(windows): add windowsHide to Bun.spawn calls to prevent stray terminal windows
Closes #1915
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import { getConfigDir } from "./config-context"
|
||||
import { spawnWithWindowsHide } from "../../shared/spawn-with-windows-hide"
|
||||
|
||||
const BUN_INSTALL_TIMEOUT_SECONDS = 60
|
||||
const BUN_INSTALL_TIMEOUT_MS = BUN_INSTALL_TIMEOUT_SECONDS * 1000
|
||||
@@ -16,7 +17,7 @@ export async function runBunInstall(): Promise<boolean> {
|
||||
|
||||
export async function runBunInstallWithDetails(): Promise<BunInstallResult> {
|
||||
try {
|
||||
const proc = Bun.spawn(["bun", "install"], {
|
||||
const proc = spawnWithWindowsHide(["bun", "install"], {
|
||||
cwd: getConfigDir(),
|
||||
stdout: "inherit",
|
||||
stderr: "inherit",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { OpenCodeBinaryType } from "../../shared/opencode-config-dir-types"
|
||||
import { spawnWithWindowsHide } from "../../shared/spawn-with-windows-hide"
|
||||
import { initConfigContext } from "./config-context"
|
||||
|
||||
const OPENCODE_BINARIES = ["opencode", "opencode-desktop"] as const
|
||||
@@ -11,7 +12,7 @@ interface OpenCodeBinaryResult {
|
||||
async function findOpenCodeBinaryWithVersion(): Promise<OpenCodeBinaryResult | null> {
|
||||
for (const binary of OPENCODE_BINARIES) {
|
||||
try {
|
||||
const proc = Bun.spawn([binary, "--version"], {
|
||||
const proc = spawnWithWindowsHide([binary, "--version"], {
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
})
|
||||
|
||||
@@ -3,6 +3,7 @@ import { createRequire } from "node:module"
|
||||
import { dirname, join } from "node:path"
|
||||
|
||||
import type { DependencyInfo } from "../types"
|
||||
import { spawnWithWindowsHide } from "../../../shared/spawn-with-windows-hide"
|
||||
|
||||
async function checkBinaryExists(binary: string): Promise<{ exists: boolean; path: string | null }> {
|
||||
try {
|
||||
@@ -18,7 +19,7 @@ async function checkBinaryExists(binary: string): Promise<{ exists: boolean; pat
|
||||
|
||||
async function getBinaryVersion(binary: string): Promise<string | null> {
|
||||
try {
|
||||
const proc = Bun.spawn([binary, "--version"], { stdout: "pipe", stderr: "pipe" })
|
||||
const proc = spawnWithWindowsHide([binary, "--version"], { stdout: "pipe", stderr: "pipe" })
|
||||
const output = await new Response(proc.stdout).text()
|
||||
await proc.exited
|
||||
if (proc.exitCode === 0) {
|
||||
@@ -140,4 +141,3 @@ export async function checkCommentChecker(): Promise<DependencyInfo> {
|
||||
path: resolvedPath,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { existsSync } from "node:fs"
|
||||
import { homedir } from "node:os"
|
||||
import { join } from "node:path"
|
||||
import { spawnWithWindowsHide } from "../../../shared/spawn-with-windows-hide"
|
||||
|
||||
import { OPENCODE_BINARIES } from "../constants"
|
||||
|
||||
@@ -110,7 +111,7 @@ export async function getOpenCodeVersion(
|
||||
): Promise<string | null> {
|
||||
try {
|
||||
const command = buildVersionCommand(binaryPath, platform)
|
||||
const processResult = Bun.spawn(command, { stdout: "pipe", stderr: "pipe" })
|
||||
const processResult = spawnWithWindowsHide(command, { stdout: "pipe", stderr: "pipe" })
|
||||
const output = await new Response(processResult.stdout).text()
|
||||
await processResult.exited
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { spawnWithWindowsHide } from "../../../shared/spawn-with-windows-hide"
|
||||
|
||||
export interface GhCliInfo {
|
||||
installed: boolean
|
||||
version: string | null
|
||||
@@ -19,7 +21,7 @@ async function checkBinaryExists(binary: string): Promise<{ exists: boolean; pat
|
||||
|
||||
async function getGhVersion(): Promise<string | null> {
|
||||
try {
|
||||
const processResult = Bun.spawn(["gh", "--version"], { stdout: "pipe", stderr: "pipe" })
|
||||
const processResult = spawnWithWindowsHide(["gh", "--version"], { stdout: "pipe", stderr: "pipe" })
|
||||
const output = await new Response(processResult.stdout).text()
|
||||
await processResult.exited
|
||||
if (processResult.exitCode !== 0) return null
|
||||
@@ -38,7 +40,7 @@ async function getGhAuthStatus(): Promise<{
|
||||
error: string | null
|
||||
}> {
|
||||
try {
|
||||
const processResult = Bun.spawn(["gh", "auth", "status"], {
|
||||
const processResult = spawnWithWindowsHide(["gh", "auth", "status"], {
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
env: { ...process.env, GH_NO_UPDATE_NOTIFIER: "1" },
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { RunResult } from "./types"
|
||||
import { createJsonOutputManager } from "./json-output"
|
||||
import { resolveSession } from "./session-resolver"
|
||||
import { executeOnCompleteHook } from "./on-complete-hook"
|
||||
import * as spawnWithWindowsHideModule from "../../shared/spawn-with-windows-hide"
|
||||
import type { OpencodeClient } from "./types"
|
||||
import * as originalSdk from "@opencode-ai/sdk"
|
||||
import * as originalPortUtils from "../../shared/port-utils"
|
||||
@@ -147,7 +148,7 @@ describe("integration: --session-id", () => {
|
||||
const result = resolveSession({ client: mockClient, sessionId, directory: "/test" })
|
||||
|
||||
// then
|
||||
await expect(result).rejects.toThrow(`Session not found: ${sessionId}`)
|
||||
expect(result).rejects.toThrow(`Session not found: ${sessionId}`)
|
||||
expect(mockClient.session.get).toHaveBeenCalledWith({
|
||||
path: { id: sessionId },
|
||||
query: { directory: "/test" },
|
||||
@@ -161,10 +162,13 @@ describe("integration: --on-complete", () => {
|
||||
|
||||
beforeEach(() => {
|
||||
spyOn(console, "error").mockImplementation(() => {})
|
||||
spawnSpy = spyOn(Bun, "spawn").mockReturnValue({
|
||||
spawnSpy = spyOn(spawnWithWindowsHideModule, "spawnWithWindowsHide").mockReturnValue({
|
||||
exited: Promise.resolve(0),
|
||||
exitCode: 0,
|
||||
} as unknown as ReturnType<typeof Bun.spawn>)
|
||||
stdout: undefined,
|
||||
stderr: undefined,
|
||||
kill: () => {},
|
||||
} satisfies ReturnType<typeof spawnWithWindowsHideModule.spawnWithWindowsHide>)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
@@ -186,7 +190,7 @@ describe("integration: --on-complete", () => {
|
||||
|
||||
// then
|
||||
expect(spawnSpy).toHaveBeenCalledTimes(1)
|
||||
const [_, options] = spawnSpy.mock.calls[0] as Parameters<typeof Bun.spawn>
|
||||
const [_, options] = spawnSpy.mock.calls[0] as Parameters<typeof spawnWithWindowsHideModule.spawnWithWindowsHide>
|
||||
expect(options?.env?.SESSION_ID).toBe("session-123")
|
||||
expect(options?.env?.EXIT_CODE).toBe("0")
|
||||
expect(options?.env?.DURATION_MS).toBe("5000")
|
||||
@@ -208,10 +212,13 @@ describe("integration: option combinations", () => {
|
||||
spyOn(console, "error").mockImplementation(() => {})
|
||||
mockStdout = createMockWriteStream()
|
||||
mockStderr = createMockWriteStream()
|
||||
spawnSpy = spyOn(Bun, "spawn").mockReturnValue({
|
||||
spawnSpy = spyOn(spawnWithWindowsHideModule, "spawnWithWindowsHide").mockReturnValue({
|
||||
exited: Promise.resolve(0),
|
||||
exitCode: 0,
|
||||
} as unknown as ReturnType<typeof Bun.spawn>)
|
||||
stdout: undefined,
|
||||
stderr: undefined,
|
||||
kill: () => {},
|
||||
} satisfies ReturnType<typeof spawnWithWindowsHideModule.spawnWithWindowsHide>)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
@@ -249,9 +256,9 @@ describe("integration: option combinations", () => {
|
||||
const emitted = mockStdout.writes[0]!
|
||||
expect(() => JSON.parse(emitted)).not.toThrow()
|
||||
expect(spawnSpy).toHaveBeenCalledTimes(1)
|
||||
const [args] = spawnSpy.mock.calls[0] as Parameters<typeof Bun.spawn>
|
||||
const [args] = spawnSpy.mock.calls[0] as Parameters<typeof spawnWithWindowsHideModule.spawnWithWindowsHide>
|
||||
expect(args).toEqual(["sh", "-c", "echo done"])
|
||||
const [_, options] = spawnSpy.mock.calls[0] as Parameters<typeof Bun.spawn>
|
||||
const [_, options] = spawnSpy.mock.calls[0] as Parameters<typeof spawnWithWindowsHideModule.spawnWithWindowsHide>
|
||||
expect(options?.env?.SESSION_ID).toBe("session-123")
|
||||
expect(options?.env?.EXIT_CODE).toBe("0")
|
||||
expect(options?.env?.DURATION_MS).toBe("5000")
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { describe, it, expect, spyOn, beforeEach, afterEach } from "bun:test"
|
||||
import * as spawnWithWindowsHideModule from "../../shared/spawn-with-windows-hide"
|
||||
import { executeOnCompleteHook } from "./on-complete-hook"
|
||||
|
||||
describe("executeOnCompleteHook", () => {
|
||||
@@ -6,7 +7,10 @@ describe("executeOnCompleteHook", () => {
|
||||
return {
|
||||
exited: Promise.resolve(exitCode),
|
||||
exitCode,
|
||||
} as unknown as ReturnType<typeof Bun.spawn>
|
||||
stdout: undefined,
|
||||
stderr: undefined,
|
||||
kill: () => {},
|
||||
} satisfies ReturnType<typeof spawnWithWindowsHideModule.spawnWithWindowsHide>
|
||||
}
|
||||
|
||||
let consoleErrorSpy: ReturnType<typeof spyOn<typeof console, "error">>
|
||||
@@ -21,7 +25,7 @@ describe("executeOnCompleteHook", () => {
|
||||
|
||||
it("executes command with correct env vars", async () => {
|
||||
// given
|
||||
const spawnSpy = spyOn(Bun, "spawn").mockReturnValue(createProc(0))
|
||||
const spawnSpy = spyOn(spawnWithWindowsHideModule, "spawnWithWindowsHide").mockReturnValue(createProc(0))
|
||||
|
||||
try {
|
||||
// when
|
||||
@@ -35,7 +39,7 @@ describe("executeOnCompleteHook", () => {
|
||||
|
||||
// then
|
||||
expect(spawnSpy).toHaveBeenCalledTimes(1)
|
||||
const [args, options] = spawnSpy.mock.calls[0] as Parameters<typeof Bun.spawn>
|
||||
const [args, options] = spawnSpy.mock.calls[0] as Parameters<typeof spawnWithWindowsHideModule.spawnWithWindowsHide>
|
||||
|
||||
expect(args).toEqual(["sh", "-c", "echo test"])
|
||||
expect(options?.env?.SESSION_ID).toBe("session-123")
|
||||
@@ -51,7 +55,7 @@ describe("executeOnCompleteHook", () => {
|
||||
|
||||
it("env var values are strings", async () => {
|
||||
// given
|
||||
const spawnSpy = spyOn(Bun, "spawn").mockReturnValue(createProc(0))
|
||||
const spawnSpy = spyOn(spawnWithWindowsHideModule, "spawnWithWindowsHide").mockReturnValue(createProc(0))
|
||||
|
||||
try {
|
||||
// when
|
||||
@@ -64,7 +68,7 @@ describe("executeOnCompleteHook", () => {
|
||||
})
|
||||
|
||||
// then
|
||||
const [_, options] = spawnSpy.mock.calls[0] as Parameters<typeof Bun.spawn>
|
||||
const [_, options] = spawnSpy.mock.calls[0] as Parameters<typeof spawnWithWindowsHideModule.spawnWithWindowsHide>
|
||||
|
||||
expect(options?.env?.EXIT_CODE).toBe("1")
|
||||
expect(options?.env?.EXIT_CODE).toBeTypeOf("string")
|
||||
@@ -79,7 +83,7 @@ describe("executeOnCompleteHook", () => {
|
||||
|
||||
it("empty command string is no-op", async () => {
|
||||
// given
|
||||
const spawnSpy = spyOn(Bun, "spawn").mockReturnValue(createProc(0))
|
||||
const spawnSpy = spyOn(spawnWithWindowsHideModule, "spawnWithWindowsHide").mockReturnValue(createProc(0))
|
||||
|
||||
try {
|
||||
// when
|
||||
@@ -100,7 +104,7 @@ describe("executeOnCompleteHook", () => {
|
||||
|
||||
it("whitespace-only command is no-op", async () => {
|
||||
// given
|
||||
const spawnSpy = spyOn(Bun, "spawn").mockReturnValue(createProc(0))
|
||||
const spawnSpy = spyOn(spawnWithWindowsHideModule, "spawnWithWindowsHide").mockReturnValue(createProc(0))
|
||||
|
||||
try {
|
||||
// when
|
||||
@@ -121,11 +125,11 @@ describe("executeOnCompleteHook", () => {
|
||||
|
||||
it("command failure logs warning but does not throw", async () => {
|
||||
// given
|
||||
const spawnSpy = spyOn(Bun, "spawn").mockReturnValue(createProc(1))
|
||||
const spawnSpy = spyOn(spawnWithWindowsHideModule, "spawnWithWindowsHide").mockReturnValue(createProc(1))
|
||||
|
||||
try {
|
||||
// when
|
||||
await expect(
|
||||
expect(
|
||||
executeOnCompleteHook({
|
||||
command: "false",
|
||||
sessionId: "session-123",
|
||||
@@ -149,13 +153,13 @@ describe("executeOnCompleteHook", () => {
|
||||
it("spawn error logs warning but does not throw", async () => {
|
||||
// given
|
||||
const spawnError = new Error("Command not found")
|
||||
const spawnSpy = spyOn(Bun, "spawn").mockImplementation(() => {
|
||||
const spawnSpy = spyOn(spawnWithWindowsHideModule, "spawnWithWindowsHide").mockImplementation(() => {
|
||||
throw spawnError
|
||||
})
|
||||
|
||||
try {
|
||||
// when
|
||||
await expect(
|
||||
expect(
|
||||
executeOnCompleteHook({
|
||||
command: "nonexistent-command",
|
||||
sessionId: "session-123",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import pc from "picocolors"
|
||||
import { spawnWithWindowsHide } from "../../shared/spawn-with-windows-hide"
|
||||
|
||||
export async function executeOnCompleteHook(options: {
|
||||
command: string
|
||||
@@ -17,7 +18,7 @@ export async function executeOnCompleteHook(options: {
|
||||
console.error(pc.dim(`Running on-complete hook: ${trimmedCommand}`))
|
||||
|
||||
try {
|
||||
const proc = Bun.spawn(["sh", "-c", trimmedCommand], {
|
||||
const proc = spawnWithWindowsHide(["sh", "-c", trimmedCommand], {
|
||||
env: {
|
||||
...process.env,
|
||||
SESSION_ID: sessionId,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { delimiter, dirname, join } from "node:path"
|
||||
import { spawnWithWindowsHide } from "../../shared/spawn-with-windows-hide"
|
||||
|
||||
const OPENCODE_COMMANDS = ["opencode", "opencode-desktop"] as const
|
||||
const WINDOWS_SUFFIXES = ["", ".exe", ".cmd", ".bat", ".ps1"] as const
|
||||
@@ -41,7 +42,7 @@ export function collectCandidateBinaryPaths(
|
||||
|
||||
export async function canExecuteBinary(binaryPath: string): Promise<boolean> {
|
||||
try {
|
||||
const proc = Bun.spawn([binaryPath, "--version"], {
|
||||
const proc = spawnWithWindowsHide([binaryPath, "--version"], {
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
})
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
import { OMO_SESSION_PREFIX, buildSessionReminderMessage } from "./constants";
|
||||
import type { InteractiveBashSessionState } from "./types";
|
||||
import { subagentSessions } from "../../features/claude-code-session-state";
|
||||
import { spawnWithWindowsHide } from "../../shared/spawn-with-windows-hide";
|
||||
|
||||
type AbortSession = (args: { path: { id: string } }) => Promise<unknown>
|
||||
|
||||
@@ -19,7 +20,7 @@ async function killAllTrackedSessions(
|
||||
): Promise<void> {
|
||||
for (const sessionName of state.tmuxSessions) {
|
||||
try {
|
||||
const proc = Bun.spawn(["tmux", "kill-session", "-t", sessionName], {
|
||||
const proc = spawnWithWindowsHide(["tmux", "kill-session", "-t", sessionName], {
|
||||
stdout: "ignore",
|
||||
stderr: "ignore",
|
||||
})
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { InteractiveBashSessionState } from "./types";
|
||||
import { loadInteractiveBashSessionState } from "./storage";
|
||||
import { OMO_SESSION_PREFIX } from "./constants";
|
||||
import { spawnWithWindowsHide } from "../../shared/spawn-with-windows-hide";
|
||||
|
||||
export function getOrCreateState(sessionID: string, sessionStates: Map<string, InteractiveBashSessionState>): InteractiveBashSessionState {
|
||||
if (!sessionStates.has(sessionID)) {
|
||||
@@ -24,7 +25,7 @@ export async function killAllTrackedSessions(
|
||||
): Promise<void> {
|
||||
for (const sessionName of state.tmuxSessions) {
|
||||
try {
|
||||
const proc = Bun.spawn(["tmux", "kill-session", "-t", sessionName], {
|
||||
const proc = spawnWithWindowsHide(["tmux", "kill-session", "-t", sessionName], {
|
||||
stdout: "ignore",
|
||||
stderr: "ignore",
|
||||
});
|
||||
|
||||
84
src/shared/spawn-with-windows-hide.ts
Normal file
84
src/shared/spawn-with-windows-hide.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import { spawn as bunSpawn } from "bun"
|
||||
import { spawn as nodeSpawn, type ChildProcess } from "node:child_process"
|
||||
import { Readable } from "node:stream"
|
||||
|
||||
export interface SpawnOptions {
|
||||
cwd?: string
|
||||
env?: Record<string, string | undefined>
|
||||
stdin?: "pipe" | "inherit" | "ignore"
|
||||
stdout?: "pipe" | "inherit" | "ignore"
|
||||
stderr?: "pipe" | "inherit" | "ignore"
|
||||
}
|
||||
|
||||
export interface SpawnedProcess {
|
||||
readonly exitCode: number | null
|
||||
readonly exited: Promise<number>
|
||||
readonly stdout: ReadableStream<Uint8Array> | undefined
|
||||
readonly stderr: ReadableStream<Uint8Array> | undefined
|
||||
kill(signal?: NodeJS.Signals): void
|
||||
}
|
||||
|
||||
function toReadableStream(stream: NodeJS.ReadableStream | null): ReadableStream<Uint8Array> | undefined {
|
||||
if (!stream) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return Readable.toWeb(stream as Readable) as ReadableStream<Uint8Array>
|
||||
}
|
||||
|
||||
function wrapNodeProcess(proc: ChildProcess): SpawnedProcess {
|
||||
let resolveExited: (exitCode: number) => void
|
||||
let exitCode: number | null = null
|
||||
|
||||
const exited = new Promise<number>((resolve) => {
|
||||
resolveExited = resolve
|
||||
})
|
||||
|
||||
proc.on("exit", (code) => {
|
||||
exitCode = code ?? 1
|
||||
resolveExited(exitCode)
|
||||
})
|
||||
|
||||
proc.on("error", () => {
|
||||
if (exitCode === null) {
|
||||
exitCode = 1
|
||||
resolveExited(1)
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
get exitCode() {
|
||||
return exitCode
|
||||
},
|
||||
exited,
|
||||
stdout: toReadableStream(proc.stdout),
|
||||
stderr: toReadableStream(proc.stderr),
|
||||
kill(signal?: NodeJS.Signals): void {
|
||||
try {
|
||||
if (!signal) {
|
||||
proc.kill()
|
||||
return
|
||||
}
|
||||
|
||||
proc.kill(signal)
|
||||
} catch {}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function spawnWithWindowsHide(command: string[], options: SpawnOptions): SpawnedProcess {
|
||||
if (process.platform !== "win32") {
|
||||
return bunSpawn(command, options)
|
||||
}
|
||||
|
||||
const [cmd, ...args] = command
|
||||
const proc = nodeSpawn(cmd, args, {
|
||||
cwd: options.cwd,
|
||||
env: options.env,
|
||||
stdio: [options.stdin ?? "pipe", options.stdout ?? "pipe", options.stderr ?? "pipe"],
|
||||
windowsHide: true,
|
||||
shell: true,
|
||||
})
|
||||
|
||||
return wrapNodeProcess(proc)
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { tool, type ToolDefinition } from "@opencode-ai/plugin/tool"
|
||||
import { spawnWithWindowsHide } from "../../shared/spawn-with-windows-hide"
|
||||
import { BLOCKED_TMUX_SUBCOMMANDS, DEFAULT_TIMEOUT_MS, INTERACTIVE_BASH_DESCRIPTION } from "./constants"
|
||||
import { getCachedTmuxPath } from "./tmux-path-resolver"
|
||||
|
||||
@@ -89,7 +90,7 @@ tmux capture-pane -p -t ${sessionName} -S -1000
|
||||
The Bash tool can execute these commands directly. Do NOT retry with interactive_bash.`
|
||||
}
|
||||
|
||||
const proc = Bun.spawn([tmuxPath, ...parts], {
|
||||
const proc = spawnWithWindowsHide([tmuxPath, ...parts], {
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user