fix(sdk): handle Windows opencode spawn and shutdown (#20772)
parent
e89527c9f0
commit
7b8dc8065e
10
bun.lock
10
bun.lock
|
|
@ -355,7 +355,7 @@
|
||||||
"bun-pty": "0.4.8",
|
"bun-pty": "0.4.8",
|
||||||
"chokidar": "4.0.3",
|
"chokidar": "4.0.3",
|
||||||
"clipboardy": "4.0.0",
|
"clipboardy": "4.0.0",
|
||||||
"cross-spawn": "^7.0.6",
|
"cross-spawn": "catalog:",
|
||||||
"decimal.js": "10.5.0",
|
"decimal.js": "10.5.0",
|
||||||
"diff": "catalog:",
|
"diff": "catalog:",
|
||||||
"drizzle-orm": "catalog:",
|
"drizzle-orm": "catalog:",
|
||||||
|
|
@ -410,7 +410,7 @@
|
||||||
"@tsconfig/bun": "catalog:",
|
"@tsconfig/bun": "catalog:",
|
||||||
"@types/babel__core": "7.20.5",
|
"@types/babel__core": "7.20.5",
|
||||||
"@types/bun": "catalog:",
|
"@types/bun": "catalog:",
|
||||||
"@types/cross-spawn": "6.0.6",
|
"@types/cross-spawn": "catalog:",
|
||||||
"@types/mime-types": "3.0.1",
|
"@types/mime-types": "3.0.1",
|
||||||
"@types/npmcli__arborist": "6.3.3",
|
"@types/npmcli__arborist": "6.3.3",
|
||||||
"@types/semver": "^7.5.8",
|
"@types/semver": "^7.5.8",
|
||||||
|
|
@ -463,9 +463,13 @@
|
||||||
"packages/sdk/js": {
|
"packages/sdk/js": {
|
||||||
"name": "@opencode-ai/sdk",
|
"name": "@opencode-ai/sdk",
|
||||||
"version": "1.3.13",
|
"version": "1.3.13",
|
||||||
|
"dependencies": {
|
||||||
|
"cross-spawn": "catalog:",
|
||||||
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@hey-api/openapi-ts": "0.90.10",
|
"@hey-api/openapi-ts": "0.90.10",
|
||||||
"@tsconfig/node22": "catalog:",
|
"@tsconfig/node22": "catalog:",
|
||||||
|
"@types/cross-spawn": "catalog:",
|
||||||
"@types/node": "catalog:",
|
"@types/node": "catalog:",
|
||||||
"@typescript/native-preview": "catalog:",
|
"@typescript/native-preview": "catalog:",
|
||||||
"typescript": "catalog:",
|
"typescript": "catalog:",
|
||||||
|
|
@ -634,11 +638,13 @@
|
||||||
"@tsconfig/bun": "1.0.9",
|
"@tsconfig/bun": "1.0.9",
|
||||||
"@tsconfig/node22": "22.0.2",
|
"@tsconfig/node22": "22.0.2",
|
||||||
"@types/bun": "1.3.11",
|
"@types/bun": "1.3.11",
|
||||||
|
"@types/cross-spawn": "6.0.6",
|
||||||
"@types/luxon": "3.7.1",
|
"@types/luxon": "3.7.1",
|
||||||
"@types/node": "22.13.9",
|
"@types/node": "22.13.9",
|
||||||
"@types/semver": "7.7.1",
|
"@types/semver": "7.7.1",
|
||||||
"@typescript/native-preview": "7.0.0-dev.20251207.1",
|
"@typescript/native-preview": "7.0.0-dev.20251207.1",
|
||||||
"ai": "6.0.138",
|
"ai": "6.0.138",
|
||||||
|
"cross-spawn": "7.0.6",
|
||||||
"diff": "8.0.2",
|
"diff": "8.0.2",
|
||||||
"dompurify": "3.3.1",
|
"dompurify": "3.3.1",
|
||||||
"drizzle-kit": "1.0.0-beta.19-d95b7a4",
|
"drizzle-kit": "1.0.0-beta.19-d95b7a4",
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,7 @@
|
||||||
"catalog": {
|
"catalog": {
|
||||||
"@effect/platform-node": "4.0.0-beta.43",
|
"@effect/platform-node": "4.0.0-beta.43",
|
||||||
"@types/bun": "1.3.11",
|
"@types/bun": "1.3.11",
|
||||||
|
"@types/cross-spawn": "6.0.6",
|
||||||
"@octokit/rest": "22.0.0",
|
"@octokit/rest": "22.0.0",
|
||||||
"@hono/zod-validator": "0.4.2",
|
"@hono/zod-validator": "0.4.2",
|
||||||
"ulid": "3.0.1",
|
"ulid": "3.0.1",
|
||||||
|
|
@ -47,6 +48,7 @@
|
||||||
"drizzle-orm": "1.0.0-beta.19-d95b7a4",
|
"drizzle-orm": "1.0.0-beta.19-d95b7a4",
|
||||||
"effect": "4.0.0-beta.43",
|
"effect": "4.0.0-beta.43",
|
||||||
"ai": "6.0.138",
|
"ai": "6.0.138",
|
||||||
|
"cross-spawn": "7.0.6",
|
||||||
"hono": "4.10.7",
|
"hono": "4.10.7",
|
||||||
"hono-openapi": "1.1.2",
|
"hono-openapi": "1.1.2",
|
||||||
"fuzzysort": "3.1.0",
|
"fuzzysort": "3.1.0",
|
||||||
|
|
|
||||||
|
|
@ -51,7 +51,7 @@
|
||||||
"@tsconfig/bun": "catalog:",
|
"@tsconfig/bun": "catalog:",
|
||||||
"@types/babel__core": "7.20.5",
|
"@types/babel__core": "7.20.5",
|
||||||
"@types/bun": "catalog:",
|
"@types/bun": "catalog:",
|
||||||
"@types/cross-spawn": "6.0.6",
|
"@types/cross-spawn": "catalog:",
|
||||||
"@types/mime-types": "3.0.1",
|
"@types/mime-types": "3.0.1",
|
||||||
"@types/npmcli__arborist": "6.3.3",
|
"@types/npmcli__arborist": "6.3.3",
|
||||||
"@types/semver": "^7.5.8",
|
"@types/semver": "^7.5.8",
|
||||||
|
|
@ -118,7 +118,7 @@
|
||||||
"bun-pty": "0.4.8",
|
"bun-pty": "0.4.8",
|
||||||
"chokidar": "4.0.3",
|
"chokidar": "4.0.3",
|
||||||
"clipboardy": "4.0.0",
|
"clipboardy": "4.0.0",
|
||||||
"cross-spawn": "^7.0.6",
|
"cross-spawn": "catalog:",
|
||||||
"decimal.js": "10.5.0",
|
"decimal.js": "10.5.0",
|
||||||
"diff": "catalog:",
|
"diff": "catalog:",
|
||||||
"drizzle-orm": "catalog:",
|
"drizzle-orm": "catalog:",
|
||||||
|
|
|
||||||
|
|
@ -144,7 +144,11 @@ export namespace Process {
|
||||||
throw new RunFailedError(cmd, out.code, out.stdout, out.stderr)
|
throw new RunFailedError(cmd, out.code, out.stdout, out.stderr)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Duplicated in `packages/sdk/js/src/process.ts` because the SDK cannot import
|
||||||
|
// `opencode` without creating a cycle. Keep both copies in sync.
|
||||||
export async function stop(proc: ChildProcess) {
|
export async function stop(proc: ChildProcess) {
|
||||||
|
if (proc.exitCode !== null || proc.signalCode !== null) return
|
||||||
|
|
||||||
if (process.platform !== "win32" || !proc.pid) {
|
if (process.platform !== "win32" || !proc.pid) {
|
||||||
proc.kill()
|
proc.kill()
|
||||||
return
|
return
|
||||||
|
|
|
||||||
|
|
@ -23,9 +23,12 @@
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@hey-api/openapi-ts": "0.90.10",
|
"@hey-api/openapi-ts": "0.90.10",
|
||||||
"@tsconfig/node22": "catalog:",
|
"@tsconfig/node22": "catalog:",
|
||||||
|
"@types/cross-spawn": "catalog:",
|
||||||
"@types/node": "catalog:",
|
"@types/node": "catalog:",
|
||||||
"typescript": "catalog:",
|
"@typescript/native-preview": "catalog:",
|
||||||
"@typescript/native-preview": "catalog:"
|
"typescript": "catalog:"
|
||||||
},
|
},
|
||||||
"dependencies": {}
|
"dependencies": {
|
||||||
|
"cross-spawn": "catalog:"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,31 @@
|
||||||
|
import { type ChildProcess, spawnSync } from "node:child_process"
|
||||||
|
|
||||||
|
// Duplicated from `packages/opencode/src/util/process.ts` because the SDK cannot
|
||||||
|
// import `opencode` without creating a cycle (`opencode` depends on `@opencode-ai/sdk`).
|
||||||
|
export function stop(proc: ChildProcess) {
|
||||||
|
if (proc.exitCode !== null || proc.signalCode !== null) return
|
||||||
|
if (process.platform === "win32" && proc.pid) {
|
||||||
|
const out = spawnSync("taskkill", ["/pid", String(proc.pid), "/T", "/F"], { windowsHide: true })
|
||||||
|
if (!out.error && out.status === 0) return
|
||||||
|
}
|
||||||
|
proc.kill()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function bindAbort(proc: ChildProcess, signal?: AbortSignal, onAbort?: () => void) {
|
||||||
|
if (!signal) return () => {}
|
||||||
|
const abort = () => {
|
||||||
|
clear()
|
||||||
|
stop(proc)
|
||||||
|
onAbort?.()
|
||||||
|
}
|
||||||
|
const clear = () => {
|
||||||
|
signal.removeEventListener("abort", abort)
|
||||||
|
proc.off("exit", clear)
|
||||||
|
proc.off("error", clear)
|
||||||
|
}
|
||||||
|
signal.addEventListener("abort", abort, { once: true })
|
||||||
|
proc.on("exit", clear)
|
||||||
|
proc.on("error", clear)
|
||||||
|
if (signal.aborted) abort()
|
||||||
|
return clear
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { spawn } from "node:child_process"
|
import launch from "cross-spawn"
|
||||||
import { type Config } from "./gen/types.gen.js"
|
import { type Config } from "./gen/types.gen.js"
|
||||||
|
import { stop, bindAbort } from "./process.js"
|
||||||
|
|
||||||
export type ServerOptions = {
|
export type ServerOptions = {
|
||||||
hostname?: string
|
hostname?: string
|
||||||
|
|
@ -31,29 +32,38 @@ export async function createOpencodeServer(options?: ServerOptions) {
|
||||||
const args = [`serve`, `--hostname=${options.hostname}`, `--port=${options.port}`]
|
const args = [`serve`, `--hostname=${options.hostname}`, `--port=${options.port}`]
|
||||||
if (options.config?.logLevel) args.push(`--log-level=${options.config.logLevel}`)
|
if (options.config?.logLevel) args.push(`--log-level=${options.config.logLevel}`)
|
||||||
|
|
||||||
const proc = spawn(`opencode`, args, {
|
const proc = launch(`opencode`, args, {
|
||||||
signal: options.signal,
|
|
||||||
env: {
|
env: {
|
||||||
...process.env,
|
...process.env,
|
||||||
OPENCODE_CONFIG_CONTENT: JSON.stringify(options.config ?? {}),
|
OPENCODE_CONFIG_CONTENT: JSON.stringify(options.config ?? {}),
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
let clear = () => {}
|
||||||
|
|
||||||
const url = await new Promise<string>((resolve, reject) => {
|
const url = await new Promise<string>((resolve, reject) => {
|
||||||
const id = setTimeout(() => {
|
const id = setTimeout(() => {
|
||||||
|
clear()
|
||||||
|
stop(proc)
|
||||||
reject(new Error(`Timeout waiting for server to start after ${options.timeout}ms`))
|
reject(new Error(`Timeout waiting for server to start after ${options.timeout}ms`))
|
||||||
}, options.timeout)
|
}, options.timeout)
|
||||||
let output = ""
|
let output = ""
|
||||||
|
let resolved = false
|
||||||
proc.stdout?.on("data", (chunk) => {
|
proc.stdout?.on("data", (chunk) => {
|
||||||
|
if (resolved) return
|
||||||
output += chunk.toString()
|
output += chunk.toString()
|
||||||
const lines = output.split("\n")
|
const lines = output.split("\n")
|
||||||
for (const line of lines) {
|
for (const line of lines) {
|
||||||
if (line.startsWith("opencode server listening")) {
|
if (line.startsWith("opencode server listening")) {
|
||||||
const match = line.match(/on\s+(https?:\/\/[^\s]+)/)
|
const match = line.match(/on\s+(https?:\/\/[^\s]+)/)
|
||||||
if (!match) {
|
if (!match) {
|
||||||
throw new Error(`Failed to parse server url from output: ${line}`)
|
clear()
|
||||||
|
stop(proc)
|
||||||
|
clearTimeout(id)
|
||||||
|
reject(new Error(`Failed to parse server url from output: ${line}`))
|
||||||
|
return
|
||||||
}
|
}
|
||||||
clearTimeout(id)
|
clearTimeout(id)
|
||||||
|
resolved = true
|
||||||
resolve(match[1]!)
|
resolve(match[1]!)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -74,18 +84,17 @@ export async function createOpencodeServer(options?: ServerOptions) {
|
||||||
clearTimeout(id)
|
clearTimeout(id)
|
||||||
reject(error)
|
reject(error)
|
||||||
})
|
})
|
||||||
if (options.signal) {
|
clear = bindAbort(proc, options.signal, () => {
|
||||||
options.signal.addEventListener("abort", () => {
|
clearTimeout(id)
|
||||||
clearTimeout(id)
|
reject(options.signal?.reason)
|
||||||
reject(new Error("Aborted"))
|
})
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
url,
|
url,
|
||||||
close() {
|
close() {
|
||||||
proc.kill()
|
clear()
|
||||||
|
stop(proc)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -106,8 +115,7 @@ export function createOpencodeTui(options?: TuiOptions) {
|
||||||
args.push(`--agent=${options.agent}`)
|
args.push(`--agent=${options.agent}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
const proc = spawn(`opencode`, args, {
|
const proc = launch(`opencode`, args, {
|
||||||
signal: options?.signal,
|
|
||||||
stdio: "inherit",
|
stdio: "inherit",
|
||||||
env: {
|
env: {
|
||||||
...process.env,
|
...process.env,
|
||||||
|
|
@ -115,9 +123,12 @@ export function createOpencodeTui(options?: TuiOptions) {
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const clear = bindAbort(proc, options?.signal)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
close() {
|
close() {
|
||||||
proc.kill()
|
clear()
|
||||||
|
stop(proc)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { spawn } from "node:child_process"
|
import launch from "cross-spawn"
|
||||||
import { type Config } from "./gen/types.gen.js"
|
import { type Config } from "./gen/types.gen.js"
|
||||||
|
import { stop, bindAbort } from "../process.js"
|
||||||
|
|
||||||
export type ServerOptions = {
|
export type ServerOptions = {
|
||||||
hostname?: string
|
hostname?: string
|
||||||
|
|
@ -31,29 +32,38 @@ export async function createOpencodeServer(options?: ServerOptions) {
|
||||||
const args = [`serve`, `--hostname=${options.hostname}`, `--port=${options.port}`]
|
const args = [`serve`, `--hostname=${options.hostname}`, `--port=${options.port}`]
|
||||||
if (options.config?.logLevel) args.push(`--log-level=${options.config.logLevel}`)
|
if (options.config?.logLevel) args.push(`--log-level=${options.config.logLevel}`)
|
||||||
|
|
||||||
const proc = spawn(`opencode`, args, {
|
const proc = launch(`opencode`, args, {
|
||||||
signal: options.signal,
|
|
||||||
env: {
|
env: {
|
||||||
...process.env,
|
...process.env,
|
||||||
OPENCODE_CONFIG_CONTENT: JSON.stringify(options.config ?? {}),
|
OPENCODE_CONFIG_CONTENT: JSON.stringify(options.config ?? {}),
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
let clear = () => {}
|
||||||
|
|
||||||
const url = await new Promise<string>((resolve, reject) => {
|
const url = await new Promise<string>((resolve, reject) => {
|
||||||
const id = setTimeout(() => {
|
const id = setTimeout(() => {
|
||||||
|
clear()
|
||||||
|
stop(proc)
|
||||||
reject(new Error(`Timeout waiting for server to start after ${options.timeout}ms`))
|
reject(new Error(`Timeout waiting for server to start after ${options.timeout}ms`))
|
||||||
}, options.timeout)
|
}, options.timeout)
|
||||||
let output = ""
|
let output = ""
|
||||||
|
let resolved = false
|
||||||
proc.stdout?.on("data", (chunk) => {
|
proc.stdout?.on("data", (chunk) => {
|
||||||
|
if (resolved) return
|
||||||
output += chunk.toString()
|
output += chunk.toString()
|
||||||
const lines = output.split("\n")
|
const lines = output.split("\n")
|
||||||
for (const line of lines) {
|
for (const line of lines) {
|
||||||
if (line.startsWith("opencode server listening")) {
|
if (line.startsWith("opencode server listening")) {
|
||||||
const match = line.match(/on\s+(https?:\/\/[^\s]+)/)
|
const match = line.match(/on\s+(https?:\/\/[^\s]+)/)
|
||||||
if (!match) {
|
if (!match) {
|
||||||
throw new Error(`Failed to parse server url from output: ${line}`)
|
clear()
|
||||||
|
stop(proc)
|
||||||
|
clearTimeout(id)
|
||||||
|
reject(new Error(`Failed to parse server url from output: ${line}`))
|
||||||
|
return
|
||||||
}
|
}
|
||||||
clearTimeout(id)
|
clearTimeout(id)
|
||||||
|
resolved = true
|
||||||
resolve(match[1]!)
|
resolve(match[1]!)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -74,18 +84,17 @@ export async function createOpencodeServer(options?: ServerOptions) {
|
||||||
clearTimeout(id)
|
clearTimeout(id)
|
||||||
reject(error)
|
reject(error)
|
||||||
})
|
})
|
||||||
if (options.signal) {
|
clear = bindAbort(proc, options.signal, () => {
|
||||||
options.signal.addEventListener("abort", () => {
|
clearTimeout(id)
|
||||||
clearTimeout(id)
|
reject(options.signal?.reason)
|
||||||
reject(new Error("Aborted"))
|
})
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
url,
|
url,
|
||||||
close() {
|
close() {
|
||||||
proc.kill()
|
clear()
|
||||||
|
stop(proc)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -106,8 +115,7 @@ export function createOpencodeTui(options?: TuiOptions) {
|
||||||
args.push(`--agent=${options.agent}`)
|
args.push(`--agent=${options.agent}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
const proc = spawn(`opencode`, args, {
|
const proc = launch(`opencode`, args, {
|
||||||
signal: options?.signal,
|
|
||||||
stdio: "inherit",
|
stdio: "inherit",
|
||||||
env: {
|
env: {
|
||||||
...process.env,
|
...process.env,
|
||||||
|
|
@ -115,9 +123,12 @@ export function createOpencodeTui(options?: TuiOptions) {
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const clear = bindAbort(proc, options?.signal)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
close() {
|
close() {
|
||||||
proc.kill()
|
clear()
|
||||||
|
stop(proc)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue