opencode-drive 0.1.1 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/cli/index.ts CHANGED
@@ -2,71 +2,87 @@
2
2
  import { NodeRuntime, NodeServices } from "@effect/platform-node"
3
3
  import { Effect, Option } from "effect"
4
4
  import { Command, Flag } from "effect/unstable/cli"
5
- import { send } from "./send.js"
6
- import { describe } from "./describe.js"
5
+ import { api } from "./api.js"
7
6
  import { extractCommands } from "./parse.js"
7
+ import { send } from "./send.js"
8
8
  import { start } from "./start.js"
9
- import { stop } from "./stop.js"
9
+ import { restart } from "./restart.js"
10
10
  import type { DriveCommand, SendOptions, StartOptions } from "./types.js"
11
11
 
12
12
  const extracted = extract()
13
- const name = Flag.string("name").pipe(Flag.optional, Flag.withDescription("Instance name"))
14
- const driver = Flag.string("driver").pipe(Flag.optional, Flag.withDescription("TypeScript driver module"))
15
13
 
16
- const startCommand = Command.make("start", {
17
- name,
18
- driver,
19
- campaign: Flag.string("campaign").pipe(Flag.optional, Flag.withDescription("Campaign module")),
20
- seed: Flag.integer("seed").pipe(Flag.withDefault(Date.now() % 1_000_000)),
21
- caseIndex: Flag.integer("case").pipe(
22
- Flag.filter((value) => value >= 0, () => "Case must be non-negative"),
23
- Flag.optional,
24
- ),
25
- count: Flag.integer("count").pipe(
26
- Flag.filter((value) => value > 0, () => "Count must be greater than zero"),
27
- Flag.optional,
28
- ),
29
- concurrency: Flag.integer("concurrency").pipe(
30
- Flag.filter((value) => value > 0, () => "Concurrency must be greater than zero"),
31
- Flag.withDefault(1),
32
- ),
33
- visible: Flag.boolean("visible").pipe(Flag.withDescription("Show OpenCode in the terminal")),
34
- detach: Flag.boolean("detach").pipe(Flag.withDescription("Keep OpenCode running in the background (default)")),
35
- dev: Flag.string("dev").pipe(Flag.optional, Flag.withDescription("Path to an OpenCode development checkout")),
36
- state: Flag.string("state").pipe(Flag.optional, Flag.withDescription("Simulation snapshot containing files/")),
37
- anchor: Flag.string("anchor").pipe(Flag.optional),
38
- }, (config) => execute(() => start(toStartOptions(config, extracted.commands, extracted.app)))).pipe(
39
- Command.withDescription("Launch and own a local simulated OpenCode instance"),
14
+ const startCommand = Command.make(
15
+ "start",
16
+ {
17
+ script: Flag.string("script").pipe(
18
+ Flag.optional,
19
+ Flag.withDescription("JavaScript or TypeScript automation module"),
20
+ ),
21
+ visible: Flag.boolean("visible").pipe(
22
+ Flag.withDescription("Show OpenCode in the terminal"),
23
+ ),
24
+ dev: Flag.string("dev").pipe(
25
+ Flag.optional,
26
+ Flag.withDescription("Path to an OpenCode development checkout"),
27
+ ),
28
+ state: Flag.string("state").pipe(
29
+ Flag.optional,
30
+ Flag.withDescription("Simulation snapshot containing files/"),
31
+ ),
32
+ },
33
+ (config) =>
34
+ execute(() =>
35
+ start(toStartOptions(config, extracted.commands, extracted.app)),
36
+ ),
37
+ ).pipe(
38
+ Command.withDescription("Launch a local simulated OpenCode instance"),
40
39
  Command.withExamples([
41
- { command: "opencode-drive start --name demo --visible", description: "Launch a visible simulated instance" },
42
- { command: "opencode-drive start --name demo --detach", description: "Launch a background simulated instance" },
40
+ {
41
+ command: "opencode-drive start",
42
+ description: "Launch headless OpenCode on the default ports",
43
+ },
44
+ {
45
+ command: "opencode-drive start --visible",
46
+ description: "Launch visible OpenCode on the default ports",
47
+ },
48
+ {
49
+ command: "opencode-drive start --script ./drive.ts",
50
+ description: "Launch headless OpenCode and run a script",
51
+ },
43
52
  ]),
44
53
  )
45
54
 
46
- const sendCommand = Command.make("send", { name, driver }, (config) =>
47
- execute(() => send(toSendOptions(config, extracted.commands, extracted.app)))).pipe(
48
- Command.withDescription("Connect to an existing drive-enabled OpenCode instance"),
49
- Command.withExamples([
50
- {
51
- command: "opencode-drive send --name demo --command.type hello --command.press enter --command.render --command.state",
52
- description: "Execute an ordered command batch",
53
- },
54
- ]),
55
- )
55
+ const sendCommand = Command.make("send", {}, () =>
56
+ execute(() => send(toSendOptions(extracted.commands, extracted.app))),
57
+ ).pipe(
58
+ Command.withDescription("Send UI commands to OpenCode on the default port"),
59
+ Command.withExamples([
60
+ {
61
+ command:
62
+ 'opencode-drive send --command.ui.type \'{"text":"hello"}\' --command.ui.state',
63
+ description: "Execute an ordered UI command batch",
64
+ },
65
+ ]),
66
+ )
56
67
 
57
- const describeCommand = Command.make("describe", { name }, (config) =>
58
- execute(() => describe(Option.getOrUndefined(config.name)))).pipe(
59
- Command.withDescription("Describe a registered OpenCode instance"),
60
- )
68
+ const apiCommand = Command.make("api", {}, () => execute(api)).pipe(
69
+ Command.withDescription("Print the OpenCode drive UI protocol"),
70
+ )
61
71
 
62
- const stopCommand = Command.make("stop", { name }, (config) =>
63
- execute(() => stop(Option.getOrUndefined(config.name)))).pipe(
64
- Command.withDescription("Stop a registered headless OpenCode instance"),
65
- )
72
+ const restartCommand = Command.make("restart", {}, () => execute(restart)).pipe(
73
+ Command.withDescription(
74
+ "Restart the active visible OpenCode instance and rerun its script",
75
+ ),
76
+ )
66
77
 
67
78
  const root = Command.make("opencode-drive").pipe(
68
79
  Command.withDescription("Drive real and simulated OpenCode instances"),
69
- Command.withSubcommands([startCommand, sendCommand, describeCommand, stopCommand]),
80
+ Command.withSubcommands([
81
+ startCommand,
82
+ sendCommand,
83
+ restartCommand,
84
+ apiCommand,
85
+ ]),
70
86
  )
71
87
 
72
88
  Command.runWith(root, { version: "0.1.0" })(extracted.args).pipe(
@@ -76,81 +92,47 @@ Command.runWith(root, { version: "0.1.0" })(extracted.args).pipe(
76
92
 
77
93
  function toStartOptions(
78
94
  config: {
79
- readonly name: Option.Option<string>
80
- readonly driver: Option.Option<string>
81
- readonly campaign: Option.Option<string>
82
- readonly seed: number
83
- readonly caseIndex: Option.Option<number>
84
- readonly count: Option.Option<number>
85
- readonly concurrency: number
95
+ readonly script: Option.Option<string>
86
96
  readonly visible: boolean
87
- readonly detach: boolean
88
97
  readonly dev: Option.Option<string>
89
98
  readonly state: Option.Option<string>
90
- readonly anchor: Option.Option<string>
91
99
  },
92
100
  commands: ReadonlyArray<DriveCommand>,
93
101
  app: ReadonlyArray<string>,
94
102
  ): StartOptions {
95
- const driver = Option.getOrUndefined(config.driver)
96
- const campaign = Option.getOrUndefined(config.campaign)
97
- const visible = config.visible
103
+ if (commands.length > 0)
104
+ throw new Error("start does not accept command flags; use send or --script")
98
105
  const options = {
99
106
  kind: "start" as const,
100
- name: Option.getOrUndefined(config.name),
101
- driver,
102
- campaign,
103
- seed: config.seed,
104
- caseIndex: Option.getOrUndefined(config.caseIndex),
105
- count: Option.getOrUndefined(config.count),
106
- concurrency: config.concurrency,
107
- visible,
108
- detach: !visible && (config.detach || (driver === undefined && campaign === undefined && commands.length === 0)),
107
+ script: Option.getOrUndefined(config.script),
108
+ visible: config.visible,
109
109
  dev: Option.getOrUndefined(config.dev),
110
110
  state: Option.getOrUndefined(config.state),
111
- anchor: Option.getOrUndefined(config.anchor),
112
111
  command: app,
113
- commands,
114
- }
115
- assertExecutionMode(options.driver, options.campaign, commands)
116
- if (options.dev !== undefined && app.length > 0) throw new Error("--dev cannot be combined with a command after --")
117
- if (options.caseIndex !== undefined && options.campaign === undefined) throw new Error("--case requires --campaign")
118
- if (options.visible && options.campaign !== undefined && options.caseIndex === undefined) {
119
- throw new Error("visible campaign runs require --case")
120
- }
121
- if (config.detach && (options.driver !== undefined || options.campaign !== undefined || commands.length > 0)) {
122
- throw new Error("--detach cannot be combined with --driver, --campaign, or command flags")
123
112
  }
113
+ if (options.dev !== undefined && app.length > 0)
114
+ throw new Error("--dev cannot be combined with a command after --")
124
115
  return options
125
116
  }
126
117
 
127
118
  function toSendOptions(
128
- config: { readonly name: Option.Option<string>; readonly driver: Option.Option<string> },
129
119
  commands: ReadonlyArray<DriveCommand>,
130
120
  app: ReadonlyArray<string>,
131
121
  ): SendOptions {
132
122
  if (app.length > 0) throw new Error("send does not accept a command after --")
133
- const options = {
134
- kind: "send" as const,
135
- name: Option.getOrUndefined(config.name),
136
- driver: Option.getOrUndefined(config.driver),
137
- commands,
138
- }
139
- assertExecutionMode(options.driver, undefined, commands)
140
- return options
141
- }
142
-
143
- function assertExecutionMode(driver: string | undefined, campaign: string | undefined, commands: ReadonlyArray<DriveCommand>) {
144
- const count = Number(driver !== undefined) + Number(campaign !== undefined) + Number(commands.length > 0)
145
- if (count > 1) throw new Error("command flags, --driver, and --campaign are mutually exclusive")
123
+ return { kind: "send", commands }
146
124
  }
147
125
 
148
126
  function execute(task: () => Promise<void>) {
149
127
  return Effect.tryPromise({ try: task, catch: (error) => error }).pipe(
150
- Effect.catch((error) => Effect.sync(() => {
151
- console.error(`error: ${error instanceof Error ? error.message : String(error)}`)
152
- process.exitCode = 1
153
- })),
128
+ Effect.catch((error) =>
129
+ Effect.sync(() => {
130
+ console.error(
131
+ `error: ${error instanceof Error ? error.message : String(error)}`,
132
+ )
133
+ process.exitCode = 1
134
+ }),
135
+ ),
154
136
  )
155
137
  }
156
138
 
@@ -158,7 +140,9 @@ function extract() {
158
140
  try {
159
141
  return extractCommands(process.argv.slice(2))
160
142
  } catch (error) {
161
- console.error(`opencode-drive: ${error instanceof Error ? error.message : String(error)}`)
143
+ console.error(
144
+ `opencode-drive: ${error instanceof Error ? error.message : String(error)}`,
145
+ )
162
146
  return process.exit(1)
163
147
  }
164
148
  }
@@ -1,47 +1,40 @@
1
1
  import { mkdir, rm } from "node:fs/promises"
2
2
  import { tmpdir } from "node:os"
3
3
  import { join, resolve } from "node:path"
4
- import { registerInstance, registryDirectory, transferInstance, unregisterInstance } from "./registry.js"
5
- import type { InstanceManifest } from "./types.js"
4
+ import { defaultBackendPort, defaultPort } from "../client/index.js"
6
5
 
7
6
  export interface LaunchOptions {
8
- readonly name?: string
7
+ readonly name: string
9
8
  readonly command?: ReadonlyArray<string>
10
9
  readonly dev?: string
11
10
  readonly state?: string
11
+ readonly scripted?: boolean
12
12
  readonly visible?: boolean
13
13
  readonly env?: Readonly<Record<string, string>>
14
14
  }
15
15
 
16
16
  export async function launchInstance(options: LaunchOptions = {}) {
17
- const name = options.name ?? "default"
18
- const artifacts = resolve(join(tmpdir(), "opencode-drive", `${name}-${randomSuffix()}`))
19
- const cwd = artifacts
20
- const [uiPort, backendPort] = await Promise.all([freePort(), freePort()])
17
+ const artifacts = resolve(
18
+ join(tmpdir(), "opencode-drive", `run-${crypto.randomUUID().slice(0, 6)}`),
19
+ )
20
+ const logs = join(artifacts, "logs")
21
21
  const endpoints = {
22
- ui: `ws://127.0.0.1:${uiPort}`,
23
- backend: `ws://127.0.0.1:${backendPort}`,
24
- }
25
- const manifest: InstanceManifest = {
26
- version: 1,
27
- name,
28
- pid: process.pid,
29
- startedAt: new Date().toISOString(),
30
- mode: "simulated",
31
- headless: options.visible !== true,
32
- cwd,
33
- artifacts,
34
- endpoints,
22
+ ui: `ws://127.0.0.1:${await freePort()}`,
23
+ backend: `ws://127.0.0.1:${await freePort()}`,
35
24
  }
25
+ const drive = join(artifacts, "drive")
36
26
  await Promise.all([
37
- mkdir(artifacts, { recursive: true }),
38
- mkdir(cwd, { recursive: true }),
27
+ mkdir(logs, { recursive: true }),
28
+ mkdir(drive, { recursive: true }),
39
29
  mkdir(join(artifacts, "home", ".cache"), { recursive: true }),
40
30
  mkdir(join(artifacts, "home", ".config"), { recursive: true }),
41
31
  mkdir(join(artifacts, "home", ".local", "share"), { recursive: true }),
42
32
  mkdir(join(artifacts, "home", ".local", "state"), { recursive: true }),
43
33
  ])
44
- const state = options.state ? resolve(options.state) : join(artifacts, "state")
34
+ await Bun.write(join(drive, `${options.name}.json`), `${JSON.stringify({ endpoints }, undefined, 2)}\n`)
35
+ const state = options.state
36
+ ? resolve(options.state)
37
+ : join(artifacts, "state")
45
38
  if (!options.state) {
46
39
  await rm(state, { recursive: true, force: true })
47
40
  await Promise.all([
@@ -57,16 +50,17 @@ export async function launchInstance(options: LaunchOptions = {}) {
57
50
  providers: {
58
51
  simulation: {
59
52
  name: "Simulation",
53
+ package: "aisdk:@ai-sdk/openai-compatible",
54
+ settings: { baseURL: "https://api.openai.com/v1" },
60
55
  request: { body: { apiKey: "sim-key" } },
61
56
  models: {
62
57
  "sim-model": {
63
58
  name: "Simulated Model",
64
- api: {
65
- type: "aisdk",
66
- package: "@ai-sdk/openai-compatible",
67
- url: "https://api.openai.com/v1",
59
+ capabilities: {
60
+ tools: true,
61
+ input: ["text"],
62
+ output: ["text"],
68
63
  },
69
- capabilities: { tools: true, input: ["text"], output: ["text"] },
70
64
  limit: { context: 128000, output: 16000 },
71
65
  },
72
66
  },
@@ -78,121 +72,243 @@ export async function launchInstance(options: LaunchOptions = {}) {
78
72
  )}\n`,
79
73
  )
80
74
  }
81
- await registerInstance(manifest)
82
75
  const environment = cleanEnv({
83
76
  ...process.env,
84
77
  ...options.env,
85
- DRIVE_REGISTRY_DIR: registryDirectory(),
86
78
  OPENCODE_SIMULATE: "1",
87
79
  OPENCODE_SIMULATE_STATE: state,
88
- OPENCODE_DRIVE: name,
80
+ DRIVE_REGISTRY_DIR: drive,
81
+ OPENCODE_DRIVE: options.name,
89
82
  OPENCODE_DRIVE_RENDERER: options.visible ? "visible" : "headless",
90
- OPENCODE_CONFIG_DIR: join(cwd, ".opencode"),
83
+ OPENCODE_CONFIG_DIR: join(artifacts, ".opencode"),
91
84
  OPENCODE_DB: ":memory:",
85
+ OPENCODE_LOG_LEVEL: !options.visible
86
+ ? "INFO"
87
+ : process.env.OPENCODE_LOG_LEVEL,
88
+ OPENCODE_TEST_HOME: artifacts,
92
89
  XDG_CACHE_HOME: join(artifacts, "home", ".cache"),
93
90
  XDG_CONFIG_HOME: join(artifacts, "home", ".config"),
94
- XDG_DATA_HOME: join(artifacts, "home", ".local", "share"),
91
+ XDG_DATA_HOME: logs,
95
92
  XDG_STATE_HOME: join(artifacts, "home", ".local", "state"),
96
93
  })
97
94
  const command = options.dev
98
- ? await prepareDev(cwd, options.dev)
95
+ ? await prepareDev(artifacts, options.dev)
99
96
  : options.command?.length
100
97
  ? [...options.command]
101
- : ["opencode2", "--standalone"]
102
- const visible = options.visible === true
103
- const child = Bun.spawn(command, {
104
- cwd,
105
- env: environment,
106
- stdin: visible ? "inherit" : "ignore",
107
- stdout: visible ? "inherit" : Bun.file(join(artifacts, "opencode.stdout.log")),
108
- stderr: visible ? "inherit" : Bun.file(join(artifacts, "opencode.stderr.log")),
109
- })
110
- const lifecycle = { detached: false, stopping: undefined as Promise<void> | undefined }
98
+ : ["opencode2"]
99
+ const spawn = () =>
100
+ Bun.spawn(command, {
101
+ cwd: artifacts,
102
+ env: environment,
103
+ stdin: options.visible ? "inherit" : "ignore",
104
+ stdout: !options.visible
105
+ ? Bun.file(join(logs, "opencode.stdout.log"))
106
+ : "inherit",
107
+ stderr: !options.visible
108
+ ? Bun.file(join(logs, "opencode.stderr.log"))
109
+ : "inherit",
110
+ })
111
+ let child = spawn()
112
+ let stopping: Promise<void> | undefined
113
+ let restarting: Promise<void> | undefined
111
114
  return {
112
- manifest,
113
- child,
114
- async waitForDrive(requirement: "ui" | "backend" | "both" = "both", timeout = 60_000) {
115
- const urls = requirement === "both"
116
- ? [endpoints.ui, endpoints.backend]
117
- : [requirement === "ui" ? endpoints.ui : endpoints.backend]
118
- await Promise.all(urls.map((url) => waitForWebSocket(url, child.exited, timeout)))
115
+ artifacts,
116
+ logs,
117
+ endpoints,
118
+ get child() {
119
+ return child
119
120
  },
120
- async detach() {
121
- await transferInstance(manifest, child.pid)
122
- child.unref()
123
- lifecycle.detached = true
121
+ async waitForDrive(
122
+ requirement: "ui" | "backend" | "both" = "both",
123
+ timeout = 60_000,
124
+ ) {
125
+ const urls =
126
+ requirement === "both"
127
+ ? [endpoints.ui, endpoints.backend]
128
+ : [endpoints[requirement]]
129
+ await Promise.all(
130
+ urls.map((url) => waitForWebSocket(url, child.exited, timeout)),
131
+ )
124
132
  },
125
- stop(force = false) {
126
- if (lifecycle.detached) return Promise.resolve()
127
- if (lifecycle.stopping) return lifecycle.stopping
128
- lifecycle.stopping = (async () => {
129
- if (force) {
130
- if (child.exitCode === null) child.kill("SIGKILL")
131
- } else if (child.exitCode === null) {
132
- child.kill("SIGTERM")
133
- await Promise.race([child.exited, Bun.sleep(1_000)])
133
+ async restart() {
134
+ if (restarting) return restarting
135
+ restarting = (async () => {
136
+ await terminate(child)
137
+ child = spawn()
138
+ await Promise.all([
139
+ waitForWebSocket(endpoints.ui, child.exited, 60_000),
140
+ waitForWebSocket(endpoints.backend, child.exited, 60_000),
141
+ ])
142
+ })().finally(() => {
143
+ restarting = undefined
144
+ })
145
+ return restarting
146
+ },
147
+ async wait() {
148
+ while (true) {
149
+ const current = child
150
+ const status = await current.exited
151
+ if (restarting) {
152
+ await restarting
153
+ continue
134
154
  }
135
- if (child.exitCode === null) child.kill("SIGKILL")
136
- await child.exited
137
- await unregisterInstance(name, process.pid)
155
+ if (current !== child) continue
156
+ return status
157
+ }
158
+ },
159
+ stop() {
160
+ if (stopping) return stopping
161
+ stopping = (async () => {
162
+ if (restarting) await restarting.catch(() => undefined)
163
+ await terminate(child)
164
+ await stopService(join(artifacts, "home", ".local", "state"))
138
165
  })()
139
- return lifecycle.stopping
166
+ return stopping
140
167
  },
141
168
  }
142
169
  }
143
170
 
171
+ async function terminate(child: Bun.Subprocess) {
172
+ if (child.exitCode !== null) return
173
+ child.kill("SIGTERM")
174
+ await Promise.race([child.exited, Bun.sleep(1_000)])
175
+ if (child.exitCode === null) child.kill("SIGKILL")
176
+ await child.exited
177
+ }
178
+
144
179
  async function prepareDev(cwd: string, directory: string) {
145
180
  const root = resolve(directory)
146
181
  const entrypoint = join(root, "packages", "cli", "src", "index.ts")
147
- if (!(await Bun.file(entrypoint).exists())) throw new Error(`OpenCode development entrypoint not found: ${entrypoint}`)
148
- const solidPackage = join(root, "packages", "tui", "node_modules", "@opentui", "solid", "package.json")
182
+ if (!(await Bun.file(entrypoint).exists()))
183
+ throw new Error(`OpenCode development entrypoint not found: ${entrypoint}`)
184
+ const solidPackage = join(
185
+ root,
186
+ "packages",
187
+ "tui",
188
+ "node_modules",
189
+ "@opentui",
190
+ "solid",
191
+ "package.json",
192
+ )
149
193
  if (!(await Bun.file(solidPackage).exists())) {
150
- throw new Error(`OpenCode development dependency not found: ${solidPackage}; run bun install in ${root}`)
194
+ throw new Error(
195
+ `OpenCode development dependency not found: ${solidPackage}; run bun install in ${root}`,
196
+ )
151
197
  }
152
198
  const info: unknown = await Bun.file(solidPackage).json()
153
- if (!isPackageInfo(info)) throw new Error(`Invalid @opentui/solid package metadata: ${solidPackage}`)
154
- await Bun.write(join(cwd, "package.json"), `${JSON.stringify({
155
- private: true,
156
- dependencies: { "@opentui/solid": info.version },
157
- }, undefined, 2)}\n`)
199
+ if (!isPackageInfo(info))
200
+ throw new Error(`Invalid @opentui/solid package metadata: ${solidPackage}`)
201
+ await Bun.write(
202
+ join(cwd, "package.json"),
203
+ `${JSON.stringify(
204
+ {
205
+ private: true,
206
+ dependencies: { "@opentui/solid": info.version },
207
+ },
208
+ undefined,
209
+ 2,
210
+ )}\n`,
211
+ )
158
212
  const install = Bun.spawn([process.execPath, "install"], {
159
213
  cwd,
160
214
  stdin: "ignore",
161
- stdout: "inherit",
162
- stderr: "inherit",
215
+ stdout: "ignore",
216
+ stderr: "ignore",
163
217
  })
164
218
  const status = await install.exited
165
- if (status !== 0) throw new Error(`bun install failed in ${cwd} with status ${status}`)
219
+ if (status !== 0)
220
+ throw new Error(`bun install failed in ${cwd} with status ${status}`)
166
221
  return [
167
222
  process.execPath,
168
223
  "--conditions=browser",
169
224
  "--preload=@opentui/solid/preload",
170
225
  entrypoint,
171
- "--standalone",
172
226
  ]
173
227
  }
174
228
 
175
- function isPackageInfo(value: unknown): value is { readonly version: string } {
176
- return typeof value === "object" && value !== null && "version" in value && typeof value.version === "string"
177
- }
178
-
179
229
  async function freePort() {
180
- const server = Bun.serve({ port: 0, hostname: "127.0.0.1", fetch: () => new Response() })
230
+ const server = Bun.serve({
231
+ hostname: "127.0.0.1",
232
+ port: 0,
233
+ fetch: () => new Response(),
234
+ })
181
235
  const port = server.port
182
236
  await server.stop(true)
183
237
  return port
184
238
  }
185
239
 
186
- async function waitForWebSocket(url: string, exited: Promise<number>, timeout: number) {
240
+ async function stopService(state: string) {
241
+ const files = [
242
+ join(state, "opencode", "service-local.json"),
243
+ join(state, "opencode", "service.json"),
244
+ ]
245
+ const info = await Promise.all(
246
+ files.map((file) =>
247
+ Bun.file(file)
248
+ .json()
249
+ .catch(() => undefined),
250
+ ),
251
+ )
252
+ await Promise.all(
253
+ info.map(async (value) => {
254
+ if (!isServiceInfo(value)) return
255
+ try {
256
+ process.kill(value.pid, "SIGTERM")
257
+ } catch {
258
+ return
259
+ }
260
+ const deadline = Date.now() + 1_000
261
+ while (Date.now() < deadline && alive(value.pid)) await Bun.sleep(25)
262
+ if (alive(value.pid)) process.kill(value.pid, "SIGKILL")
263
+ }),
264
+ )
265
+ }
266
+
267
+ function isServiceInfo(value: unknown): value is { readonly pid: number } {
268
+ return (
269
+ typeof value === "object" &&
270
+ value !== null &&
271
+ "pid" in value &&
272
+ typeof value.pid === "number"
273
+ )
274
+ }
275
+
276
+ function alive(pid: number) {
277
+ try {
278
+ process.kill(pid, 0)
279
+ return true
280
+ } catch {
281
+ return false
282
+ }
283
+ }
284
+
285
+ function isPackageInfo(value: unknown): value is { readonly version: string } {
286
+ return (
287
+ typeof value === "object" &&
288
+ value !== null &&
289
+ "version" in value &&
290
+ typeof value.version === "string"
291
+ )
292
+ }
293
+
294
+ async function waitForWebSocket(
295
+ url: string,
296
+ exited: Promise<number>,
297
+ timeout: number,
298
+ ) {
187
299
  const deadline = Date.now() + timeout
188
300
  while (Date.now() < deadline) {
189
301
  const connected = await Promise.race([
190
- open(url).then((socket) => {
191
- socket.close()
192
- return true
193
- }).catch(() => false),
302
+ open(url)
303
+ .then((socket) => {
304
+ socket.close()
305
+ return true
306
+ })
307
+ .catch(() => false),
194
308
  exited.then((code) => {
195
- throw new Error(`OpenCode exited with status ${code} before ${url} became ready`)
309
+ throw new Error(
310
+ `OpenCode exited with status ${code} before ${url} became ready`,
311
+ )
196
312
  }),
197
313
  ])
198
314
  if (connected) return
@@ -204,15 +320,21 @@ async function waitForWebSocket(url: string, exited: Promise<number>, timeout: n
204
320
  function open(url: string) {
205
321
  return new Promise<WebSocket>((resolveSocket, reject) => {
206
322
  const socket = new WebSocket(url)
207
- socket.addEventListener("open", () => resolveSocket(socket), { once: true })
208
- socket.addEventListener("error", () => reject(new Error(`cannot connect to ${url}`)), { once: true })
323
+ socket.addEventListener("open", () => resolveSocket(socket), {
324
+ once: true,
325
+ })
326
+ socket.addEventListener(
327
+ "error",
328
+ () => reject(new Error(`cannot connect to ${url}`)),
329
+ { once: true },
330
+ )
209
331
  })
210
332
  }
211
333
 
212
- function randomSuffix() {
213
- return crypto.randomUUID().slice(0, 6)
214
- }
215
-
216
334
  function cleanEnv(env: Readonly<Record<string, string | undefined>>) {
217
- return Object.fromEntries(Object.entries(env).filter((entry): entry is [string, string] => entry[1] !== undefined))
335
+ return Object.fromEntries(
336
+ Object.entries(env).filter(
337
+ (entry): entry is [string, string] => entry[1] !== undefined,
338
+ ),
339
+ )
218
340
  }