Skip to content

Commit 54ed87d

Browse files
authored
fix(windows): use cross-spawn for shim-backed commands (#18010)
1 parent 8ee939c commit 54ed87d

11 files changed

Lines changed: 126 additions & 38 deletions

File tree

bun.lock

Lines changed: 4 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/opencode/package.json

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,11 +42,12 @@
4242
"@tsconfig/bun": "catalog:",
4343
"@types/babel__core": "7.20.5",
4444
"@types/bun": "catalog:",
45+
"@types/cross-spawn": "6.0.6",
4546
"@types/mime-types": "3.0.1",
4647
"@types/semver": "^7.5.8",
4748
"@types/turndown": "5.0.5",
48-
"@types/yargs": "17.0.33",
4949
"@types/which": "3.0.4",
50+
"@types/yargs": "17.0.33",
5051
"@typescript/native-preview": "catalog:",
5152
"drizzle-kit": "1.0.0-beta.16-ea816b6",
5253
"drizzle-orm": "1.0.0-beta.16-ea816b6",
@@ -80,6 +81,7 @@
8081
"@ai-sdk/xai": "2.0.51",
8182
"@aws-sdk/credential-providers": "3.993.0",
8283
"@clack/prompts": "1.0.0-alpha.1",
84+
"@effect/platform-node": "4.0.0-beta.31",
8385
"@gitlab/gitlab-ai-provider": "3.6.0",
8486
"@gitlab/opencode-gitlab-auth": "1.3.3",
8587
"@hono/standard-validator": "0.1.5",
@@ -95,7 +97,6 @@
9597
"@openrouter/ai-sdk-provider": "1.5.4",
9698
"@opentui/core": "0.1.87",
9799
"@opentui/solid": "0.1.87",
98-
"@effect/platform-node": "4.0.0-beta.31",
99100
"@parcel/watcher": "2.5.1",
100101
"@pierre/diffs": "catalog:",
101102
"@solid-primitives/event-bus": "1.1.2",
@@ -108,6 +109,7 @@
108109
"bun-pty": "0.4.8",
109110
"chokidar": "4.0.3",
110111
"clipboardy": "4.0.0",
112+
"cross-spawn": "^7.0.6",
111113
"decimal.js": "10.5.0",
112114
"diff": "catalog:",
113115
"drizzle-orm": "1.0.0-beta.16-ea816b6",

packages/opencode/src/cli/cmd/pr.ts

Lines changed: 6 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -112,21 +112,15 @@ export const PrCommand = cmd({
112112
UI.println("Starting opencode...")
113113
UI.println()
114114

115-
// Launch opencode TUI with session ID if available
116-
const { spawn } = await import("child_process")
117115
const opencodeArgs = sessionId ? ["-s", sessionId] : []
118-
const opencodeProcess = spawn("opencode", opencodeArgs, {
119-
stdio: "inherit",
116+
const opencodeProcess = Process.spawn(["opencode", ...opencodeArgs], {
117+
stdin: "inherit",
118+
stdout: "inherit",
119+
stderr: "inherit",
120120
cwd: process.cwd(),
121121
})
122-
123-
await new Promise<void>((resolve, reject) => {
124-
opencodeProcess.on("exit", (code) => {
125-
if (code === 0) resolve()
126-
else reject(new Error(`opencode exited with code ${code}`))
127-
})
128-
opencodeProcess.on("error", reject)
129-
})
122+
const code = await opencodeProcess.exited
123+
if (code !== 0) throw new Error(`opencode exited with code ${code}`)
130124
},
131125
})
132126
},

packages/opencode/src/ide/index.ts

Lines changed: 6 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
import { BusEvent } from "@/bus/bus-event"
22
import { Bus } from "@/bus"
3-
import { spawn } from "bun"
43
import z from "zod"
54
import { NamedError } from "@opencode-ai/util/error"
65
import { Log } from "../util/log"
6+
import { Process } from "@/util/process"
77

88
const SUPPORTED_IDES = [
99
{ name: "Windsurf" as const, cmd: "windsurf" },
@@ -52,21 +52,19 @@ export namespace Ide {
5252
const cmd = SUPPORTED_IDES.find((i) => i.name === ide)?.cmd
5353
if (!cmd) throw new Error(`Unknown IDE: ${ide}`)
5454

55-
const p = spawn([cmd, "--install-extension", "sst-dev.opencode"], {
56-
stdout: "pipe",
57-
stderr: "pipe",
55+
const p = await Process.run([cmd, "--install-extension", "sst-dev.opencode"], {
56+
nothrow: true,
5857
})
59-
await p.exited
60-
const stdout = await new Response(p.stdout).text()
61-
const stderr = await new Response(p.stderr).text()
58+
const stdout = p.stdout.toString()
59+
const stderr = p.stderr.toString()
6260

6361
log.info("installed", {
6462
ide,
6563
stdout,
6664
stderr,
6765
})
6866

69-
if (p.exitCode !== 0) {
67+
if (p.code !== 0) {
7068
throw new InstallFailedError({ stderr })
7169
}
7270
if (stdout.includes("already installed")) {

packages/opencode/src/lsp/client.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { pathToFileURL, fileURLToPath } from "url"
55
import { createMessageConnection, StreamMessageReader, StreamMessageWriter } from "vscode-jsonrpc/node"
66
import type { Diagnostic as VSCodeDiagnostic } from "vscode-languageserver-types"
77
import { Log } from "../util/log"
8+
import { Process } from "../util/process"
89
import { LANGUAGE_EXTENSIONS } from "./language"
910
import z from "zod"
1011
import type { LSPServer } from "./server"
@@ -239,7 +240,7 @@ export namespace LSPClient {
239240
l.info("shutting down")
240241
connection.end()
241242
connection.dispose()
242-
input.server.process.kill()
243+
await Process.stop(input.server.process)
243244
l.info("shutdown")
244245
},
245246
}

packages/opencode/src/lsp/index.ts

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,10 @@ import { pathToFileURL, fileURLToPath } from "url"
77
import { LSPServer } from "./server"
88
import z from "zod"
99
import { Config } from "../config/config"
10-
import { spawn } from "child_process"
1110
import { Instance } from "../project/instance"
1211
import { Flag } from "@/flag/flag"
12+
import { Process } from "../util/process"
13+
import { spawn as lspspawn } from "./launch"
1314

1415
export namespace LSP {
1516
const log = Log.create({ service: "lsp" })
@@ -112,9 +113,8 @@ export namespace LSP {
112113
extensions: item.extensions ?? existing?.extensions ?? [],
113114
spawn: async (root) => {
114115
return {
115-
process: spawn(item.command[0], item.command.slice(1), {
116+
process: lspspawn(item.command[0], item.command.slice(1), {
116117
cwd: root,
117-
windowsHide: true,
118118
env: {
119119
...process.env,
120120
...item.env,
@@ -200,21 +200,20 @@ export namespace LSP {
200200
serverID: server.id,
201201
server: handle,
202202
root,
203-
}).catch((err) => {
203+
}).catch(async (err) => {
204204
s.broken.add(key)
205-
handle.process.kill()
205+
await Process.stop(handle.process)
206206
log.error(`Failed to initialize LSP client ${server.id}`, { error: err })
207207
return undefined
208208
})
209209

210210
if (!client) {
211-
handle.process.kill()
212211
return undefined
213212
}
214213

215214
const existing = s.clients.find((x) => x.root === root && x.serverID === server.id)
216215
if (existing) {
217-
handle.process.kill()
216+
await Process.stop(handle.process)
218217
return existing
219218
}
220219

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import type { ChildProcessWithoutNullStreams } from "child_process"
2+
import { Process } from "../util/process"
3+
4+
type Child = Process.Child & ChildProcessWithoutNullStreams
5+
6+
export function spawn(cmd: string, args: string[], opts?: Process.Options): Child
7+
export function spawn(cmd: string, opts?: Process.Options): Child
8+
export function spawn(cmd: string, argsOrOpts?: string[] | Process.Options, opts?: Process.Options) {
9+
const args = Array.isArray(argsOrOpts) ? [...argsOrOpts] : []
10+
const cfg = Array.isArray(argsOrOpts) ? opts : argsOrOpts
11+
const proc = Process.spawn([cmd, ...args], {
12+
...(cfg ?? {}),
13+
stdin: "pipe",
14+
stdout: "pipe",
15+
stderr: "pipe",
16+
}) as Child
17+
18+
if (!proc.stdin || !proc.stdout || !proc.stderr) throw new Error("Process output not available")
19+
20+
return proc
21+
}

packages/opencode/src/lsp/server.ts

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { spawn as launch, type ChildProcessWithoutNullStreams } from "child_process"
1+
import type { ChildProcessWithoutNullStreams } from "child_process"
22
import path from "path"
33
import os from "os"
44
import { Global } from "../global"
@@ -13,11 +13,7 @@ import { Archive } from "../util/archive"
1313
import { Process } from "../util/process"
1414
import { which } from "../util/which"
1515
import { Module } from "@opencode-ai/util/module"
16-
17-
const spawn = ((cmd, args, opts) => {
18-
if (Array.isArray(args)) return launch(cmd, [...args], { ...(opts ?? {}), windowsHide: true })
19-
return launch(cmd, { ...(args ?? {}), windowsHide: true })
20-
}) as typeof launch
16+
import { spawn } from "./launch"
2117

2218
export namespace LSPServer {
2319
const log = Log.create({ service: "lsp.server" })
@@ -273,7 +269,7 @@ export namespace LSPServer {
273269
}
274270

275271
if (lintBin) {
276-
const proc = Process.spawn([lintBin, "--help"], { stdout: "pipe" })
272+
const proc = spawn(lintBin, ["--help"])
277273
await proc.exited
278274
if (proc.stdout) {
279275
const help = await text(proc.stdout)

packages/opencode/src/util/process.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
import { spawn as launch, type ChildProcess } from "child_process"
1+
import { type ChildProcess } from "child_process"
2+
import launch from "cross-spawn"
23
import { buffer } from "node:stream/consumers"
34

45
export namespace Process {
@@ -113,6 +114,7 @@ export namespace Process {
113114
cwd: opts.cwd,
114115
env: opts.env,
115116
stdin: opts.stdin,
117+
shell: opts.shell,
116118
abort: opts.abort,
117119
kill: opts.kill,
118120
timeout: opts.timeout,
@@ -140,6 +142,20 @@ export namespace Process {
140142
throw new RunFailedError(cmd, out.code, out.stdout, out.stderr)
141143
}
142144

145+
export async function stop(proc: ChildProcess) {
146+
if (process.platform !== "win32" || !proc.pid) {
147+
proc.kill()
148+
return
149+
}
150+
151+
const out = await run(["taskkill", "/pid", String(proc.pid), "/T", "/F"], {
152+
nothrow: true,
153+
})
154+
155+
if (out.code === 0) return
156+
proc.kill()
157+
}
158+
143159
export async function text(cmd: string[], opts: RunOptions = {}): Promise<TextResult> {
144160
const out = await run(cmd, opts)
145161
return {
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { describe, expect, test } from "bun:test"
2+
import fs from "fs/promises"
3+
import path from "path"
4+
import { spawn } from "../../src/lsp/launch"
5+
import { tmpdir } from "../fixture/fixture"
6+
7+
describe("lsp.launch", () => {
8+
test("spawns cmd scripts with spaces on Windows", async () => {
9+
if (process.platform !== "win32") return
10+
11+
await using tmp = await tmpdir()
12+
const dir = path.join(tmp.path, "with space")
13+
const file = path.join(dir, "echo cmd.cmd")
14+
15+
await fs.mkdir(dir, { recursive: true })
16+
await Bun.write(file, "@echo off\r\nif %~1==--stdio exit /b 0\r\nexit /b 7\r\n")
17+
18+
const proc = spawn(file, ["--stdio"])
19+
20+
expect(await proc.exited).toBe(0)
21+
})
22+
})

0 commit comments

Comments
 (0)