opencode-drive 0.1.2 → 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.
@@ -1,47 +1,40 @@
1
- import { chmod, mkdir, rm } from "node:fs/promises"
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,96 +72,98 @@ 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,
92
88
  OPENCODE_TEST_HOME: artifacts,
93
89
  XDG_CACHE_HOME: join(artifacts, "home", ".cache"),
94
90
  XDG_CONFIG_HOME: join(artifacts, "home", ".config"),
95
- XDG_DATA_HOME: join(artifacts, "home", ".local", "share"),
91
+ XDG_DATA_HOME: logs,
96
92
  XDG_STATE_HOME: join(artifacts, "home", ".local", "state"),
97
93
  })
98
94
  const command = options.dev
99
- ? await prepareDev(cwd, options.dev)
95
+ ? await prepareDev(artifacts, options.dev)
100
96
  : options.command?.length
101
97
  ? [...options.command]
102
98
  : ["opencode2"]
103
- const launch = join(artifacts, "launch.json")
104
- await Bun.write(launch, `${JSON.stringify({ command, environment }, undefined, 2)}\n`)
105
- await chmod(launch, 0o600)
106
- const visible = options.visible === true
107
- let child = spawn(command, environment, manifest)
108
- const lifecycle = {
109
- detached: false,
110
- stopping: undefined as Promise<void> | undefined,
111
- restarting: undefined as Promise<void> | undefined,
112
- }
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
113
114
  return {
114
- manifest,
115
+ artifacts,
116
+ logs,
117
+ endpoints,
115
118
  get child() {
116
119
  return child
117
120
  },
118
- async waitForDrive(requirement: "ui" | "backend" | "both" = "both", timeout = 60_000) {
119
- const urls = requirement === "both"
120
- ? [endpoints.ui, endpoints.backend]
121
- : [requirement === "ui" ? endpoints.ui : endpoints.backend]
122
- await Promise.all(urls.map((url) => waitForWebSocket(url, child.exited, timeout)))
123
- },
124
- async detach() {
125
- await transferInstance(manifest, child.pid)
126
- child.unref()
127
- 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
+ )
128
132
  },
129
- restart() {
130
- if (lifecycle.restarting) return lifecycle.restarting
131
- lifecycle.restarting = (async () => {
133
+ async restart() {
134
+ if (restarting) return restarting
135
+ restarting = (async () => {
132
136
  await terminate(child)
133
- child = spawn(command, environment, manifest)
137
+ child = spawn()
134
138
  await Promise.all([
135
139
  waitForWebSocket(endpoints.ui, child.exited, 60_000),
136
140
  waitForWebSocket(endpoints.backend, child.exited, 60_000),
137
141
  ])
138
142
  })().finally(() => {
139
- lifecycle.restarting = undefined
143
+ restarting = undefined
140
144
  })
141
- return lifecycle.restarting
145
+ return restarting
142
146
  },
143
147
  async wait() {
144
148
  while (true) {
145
149
  const current = child
146
150
  const status = await current.exited
147
- if (lifecycle.restarting) {
148
- await lifecycle.restarting
151
+ if (restarting) {
152
+ await restarting
149
153
  continue
150
154
  }
151
- if (child !== current) continue
155
+ if (current !== child) continue
152
156
  return status
153
157
  }
154
158
  },
155
- stop(force = false) {
156
- if (lifecycle.detached) return Promise.resolve()
157
- if (lifecycle.stopping) return lifecycle.stopping
158
- lifecycle.stopping = (async () => {
159
- if (lifecycle.restarting) await lifecycle.restarting.catch(() => undefined)
160
- if (force) {
161
- if (child.exitCode === null) child.kill("SIGKILL")
162
- } else if (child.exitCode === null) {
163
- child.kill("SIGTERM")
164
- await Promise.race([child.exited, Bun.sleep(1_000)])
165
- }
166
- if (child.exitCode === null) child.kill("SIGKILL")
167
- await child.exited
168
- await unregisterInstance(name, process.pid)
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"))
169
165
  })()
170
- return lifecycle.stopping
166
+ return stopping
171
167
  },
172
168
  }
173
169
  }
@@ -180,67 +176,48 @@ async function terminate(child: Bun.Subprocess) {
180
176
  await child.exited
181
177
  }
182
178
 
183
- export async function restartInstance(manifest: InstanceManifest) {
184
- const value: unknown = await Bun.file(join(manifest.artifacts, "launch.json")).json()
185
- if (!isLaunchInfo(value)) throw new Error(`drive instance "${manifest.name}" has no valid restart metadata`)
186
- const child = spawn(value.command, value.environment, manifest)
187
- await transferInstance(manifest, child.pid)
188
- try {
189
- await Promise.all([
190
- waitForWebSocket(manifest.endpoints.ui, child.exited, 60_000),
191
- waitForWebSocket(manifest.endpoints.backend, child.exited, 60_000),
192
- ])
193
- child.unref()
194
- return child.pid
195
- } catch (error) {
196
- if (child.exitCode === null) child.kill("SIGKILL")
197
- await child.exited
198
- throw error
199
- }
200
- }
201
-
202
- function spawn(command: ReadonlyArray<string>, environment: Readonly<Record<string, string>>, manifest: InstanceManifest) {
203
- return Bun.spawn([...command], {
204
- cwd: manifest.cwd,
205
- env: environment,
206
- stdin: manifest.headless ? "ignore" : "inherit",
207
- stdout: manifest.headless ? Bun.file(join(manifest.artifacts, "opencode.stdout.log")) : "inherit",
208
- stderr: manifest.headless ? Bun.file(join(manifest.artifacts, "opencode.stderr.log")) : "inherit",
209
- })
210
- }
211
-
212
- function isLaunchInfo(value: unknown): value is {
213
- readonly command: ReadonlyArray<string>
214
- readonly environment: Readonly<Record<string, string>>
215
- } {
216
- if (typeof value !== "object" || value === null) return false
217
- if (!("command" in value) || !Array.isArray(value.command) || !value.command.every((item) => typeof item === "string")) return false
218
- if (!("environment" in value) || typeof value.environment !== "object" || value.environment === null || Array.isArray(value.environment)) return false
219
- return Object.values(value.environment).every((item) => typeof item === "string")
220
- }
221
-
222
179
  async function prepareDev(cwd: string, directory: string) {
223
180
  const root = resolve(directory)
224
181
  const entrypoint = join(root, "packages", "cli", "src", "index.ts")
225
- if (!(await Bun.file(entrypoint).exists())) throw new Error(`OpenCode development entrypoint not found: ${entrypoint}`)
226
- 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
+ )
227
193
  if (!(await Bun.file(solidPackage).exists())) {
228
- 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
+ )
229
197
  }
230
198
  const info: unknown = await Bun.file(solidPackage).json()
231
- if (!isPackageInfo(info)) throw new Error(`Invalid @opentui/solid package metadata: ${solidPackage}`)
232
- await Bun.write(join(cwd, "package.json"), `${JSON.stringify({
233
- private: true,
234
- dependencies: { "@opentui/solid": info.version },
235
- }, 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
+ )
236
212
  const install = Bun.spawn([process.execPath, "install"], {
237
213
  cwd,
238
214
  stdin: "ignore",
239
- stdout: "inherit",
240
- stderr: "inherit",
215
+ stdout: "ignore",
216
+ stderr: "ignore",
241
217
  })
242
218
  const status = await install.exited
243
- 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}`)
244
221
  return [
245
222
  process.execPath,
246
223
  "--conditions=browser",
@@ -249,27 +226,89 @@ async function prepareDev(cwd: string, directory: string) {
249
226
  ]
250
227
  }
251
228
 
252
- function isPackageInfo(value: unknown): value is { readonly version: string } {
253
- return typeof value === "object" && value !== null && "version" in value && typeof value.version === "string"
254
- }
255
-
256
229
  async function freePort() {
257
- 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
+ })
258
235
  const port = server.port
259
236
  await server.stop(true)
260
237
  return port
261
238
  }
262
239
 
263
- 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
+ ) {
264
299
  const deadline = Date.now() + timeout
265
300
  while (Date.now() < deadline) {
266
301
  const connected = await Promise.race([
267
- open(url).then((socket) => {
268
- socket.close()
269
- return true
270
- }).catch(() => false),
302
+ open(url)
303
+ .then((socket) => {
304
+ socket.close()
305
+ return true
306
+ })
307
+ .catch(() => false),
271
308
  exited.then((code) => {
272
- 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
+ )
273
312
  }),
274
313
  ])
275
314
  if (connected) return
@@ -281,15 +320,21 @@ async function waitForWebSocket(url: string, exited: Promise<number>, timeout: n
281
320
  function open(url: string) {
282
321
  return new Promise<WebSocket>((resolveSocket, reject) => {
283
322
  const socket = new WebSocket(url)
284
- socket.addEventListener("open", () => resolveSocket(socket), { once: true })
285
- 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
+ )
286
331
  })
287
332
  }
288
333
 
289
- function randomSuffix() {
290
- return crypto.randomUUID().slice(0, 6)
291
- }
292
-
293
334
  function cleanEnv(env: Readonly<Record<string, string | undefined>>) {
294
- 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
+ )
295
340
  }
@@ -0,0 +1,25 @@
1
+ import { connectBackendSimulation } from "../client/index.js"
2
+
3
+ const response = "This is a sample response from opencode-drive."
4
+
5
+ export async function connectMockBackend(endpoint: string) {
6
+ const backend = await connectBackendSimulation({ url: endpoint })
7
+ let closing = false
8
+ await backend.attach((request) => {
9
+ void backend
10
+ .chunk(request.id, [{ type: "textDelta", text: response }])
11
+ .then(() => backend.finish(request.id))
12
+ .catch((error) => {
13
+ if (!closing)
14
+ console.error(
15
+ `error: ${error instanceof Error ? error.message : String(error)}`,
16
+ )
17
+ })
18
+ })
19
+ return {
20
+ close() {
21
+ closing = true
22
+ backend.close()
23
+ },
24
+ }
25
+ }
@@ -1,92 +1,54 @@
1
+ import { mkdir, rm } from "node:fs/promises"
1
2
  import { homedir } from "node:os"
2
- import { basename, join } from "node:path"
3
- import { mkdir, open, readdir, rename, rm } from "node:fs/promises"
4
- import type { InstanceManifest } from "./types.js"
3
+ import { join } from "node:path"
4
+
5
+ export interface InstanceManifest {
6
+ readonly version: 1
7
+ readonly name: string
8
+ readonly pid: number
9
+ readonly artifacts: string
10
+ readonly visible: boolean
11
+ readonly endpoints: { readonly ui: string; readonly backend: string }
12
+ readonly control: string
13
+ }
5
14
 
6
15
  export function registryDirectory() {
7
- if (process.env.DRIVE_REGISTRY_DIR) return process.env.DRIVE_REGISTRY_DIR
8
- return join(process.env.XDG_STATE_HOME ?? join(homedir(), ".local", "state"), "opencode-drive", "instances")
16
+ return process.env.DRIVE_REGISTRY_DIR ?? join(homedir(), ".local", "state", "opencode-drive", "instances")
9
17
  }
10
18
 
11
19
  export function manifestPath(name: string) {
12
20
  return join(registryDirectory(), `${validateName(name)}.json`)
13
21
  }
14
22
 
15
- export async function registerInstance(manifest: InstanceManifest) {
16
- await mkdir(registryDirectory(), { recursive: true })
17
- const file = manifestPath(manifest.name)
18
- const lock = `${file}.lock`
19
- const existing = await readManifest(file)
20
- if (existing && processAlive(existing.pid)) throw new Error(`drive instance "${manifest.name}" is already running`)
21
- if (existing) await rm(file, { force: true })
22
- const handle = await open(lock, "wx").catch(() => undefined)
23
- if (!handle) throw new Error(`drive instance "${manifest.name}" is already starting`)
24
- try {
25
- const current = await readManifest(file)
26
- if (current && processAlive(current.pid)) throw new Error(`drive instance "${manifest.name}" is already running`)
27
- const temporary = `${file}.${process.pid}.tmp`
28
- await Bun.write(temporary, `${JSON.stringify(manifest, undefined, 2)}\n`)
29
- await rename(temporary, file)
30
- return file
31
- } finally {
32
- await handle.close()
33
- await rm(lock, { force: true })
34
- }
23
+ export function controlPath(name: string) {
24
+ return join(registryDirectory(), `${validateName(name)}.sock`)
35
25
  }
36
26
 
37
- export async function unregisterInstance(name: string, pid: number) {
38
- const file = manifestPath(name)
39
- const manifest = await readManifest(file)
40
- if (manifest?.pid !== pid) return
41
- await rm(file, { force: true })
42
- }
43
-
44
- export async function transferInstance(manifest: InstanceManifest, pid: number) {
45
- const file = manifestPath(manifest.name)
46
- const current = await readManifest(file)
47
- if (current?.pid !== manifest.pid) throw new Error(`drive instance "${manifest.name}" changed ownership`)
48
- const temporary = `${file}.${process.pid}.tmp`
49
- await Bun.write(temporary, `${JSON.stringify({ ...manifest, pid }, undefined, 2)}\n`)
50
- await rename(temporary, file)
27
+ export async function register(manifest: InstanceManifest) {
28
+ await mkdir(registryDirectory(), { recursive: true })
29
+ const existing = await read(manifest.name).catch(() => undefined)
30
+ if (existing && alive(existing.pid)) throw new Error(`drive instance "${manifest.name}" is already running`)
31
+ await rm(manifest.control, { force: true })
32
+ await Bun.write(manifestPath(manifest.name), `${JSON.stringify(manifest, undefined, 2)}\n`)
51
33
  }
52
34
 
53
- export async function resolveInstance(name?: string) {
54
- const instances = await listInstances()
55
- if (name) {
56
- const instance = instances.find((item) => item.name === name)
57
- if (!instance) throw new Error(`drive instance "${name}" was not found`)
58
- return instance
35
+ export async function resolveInstance(name = "default") {
36
+ const manifest = await read(name)
37
+ if (!alive(manifest.pid)) {
38
+ await unregister(name)
39
+ throw new Error(`drive instance "${name}" is not running`)
59
40
  }
60
- if (instances.length === 1) return instances[0]!
61
- if (instances.length === 0) throw new Error("no drive instances are running")
62
- throw new Error(`multiple drive instances are running; pass --name (${instances.map((item) => item.name).join(", ")})`)
41
+ return manifest
63
42
  }
64
43
 
65
- export async function listInstances() {
66
- await mkdir(registryDirectory(), { recursive: true })
67
- const directory = registryDirectory()
68
- const files = (await readdir(directory)).filter((file) => file.endsWith(".json"))
69
- const entries = await Promise.all(files.map((file) => readManifest(join(directory, file))))
70
- const active = entries.filter((entry): entry is InstanceManifest => entry !== undefined && processAlive(entry.pid))
71
- const activeNames = new Set(active.map((entry) => entry.name))
72
- await Promise.all(
73
- files
74
- .filter((file) => !activeNames.has(basename(file, ".json")))
75
- .map((file) => rm(join(directory, file), { force: true })),
76
- )
77
- return active.sort((a, b) => a.name.localeCompare(b.name))
44
+ export async function unregister(name: string) {
45
+ await Promise.all([rm(manifestPath(name), { force: true }), rm(controlPath(name), { force: true })])
78
46
  }
79
47
 
80
- async function readManifest(file: string): Promise<InstanceManifest | undefined> {
81
- if (!(await Bun.file(file).exists())) return undefined
82
- try {
83
- const value = await Bun.file(file).json()
84
- if (!isManifest(value)) return undefined
85
- return value
86
- } catch {
87
- await rm(file, { force: true })
88
- return undefined
89
- }
48
+ async function read(name: string): Promise<InstanceManifest> {
49
+ const value: unknown = await Bun.file(manifestPath(name)).json().catch(() => undefined)
50
+ if (!isManifest(value)) throw new Error(`drive instance "${name}" was not found`)
51
+ return value
90
52
  }
91
53
 
92
54
  function isManifest(value: unknown): value is InstanceManifest {
@@ -94,16 +56,19 @@ function isManifest(value: unknown): value is InstanceManifest {
94
56
  if (!("version" in value) || value.version !== 1) return false
95
57
  if (!("name" in value) || typeof value.name !== "string") return false
96
58
  if (!("pid" in value) || typeof value.pid !== "number") return false
97
- if (!("startedAt" in value) || typeof value.startedAt !== "string") return false
98
- if (!("mode" in value) || (value.mode !== "simulated" && value.mode !== "real")) return false
99
- if (!("headless" in value) || typeof value.headless !== "boolean") return false
100
- if (!("cwd" in value) || typeof value.cwd !== "string") return false
101
59
  if (!("artifacts" in value) || typeof value.artifacts !== "string") return false
60
+ if (!("visible" in value) || typeof value.visible !== "boolean") return false
61
+ if (!("control" in value) || typeof value.control !== "string") return false
102
62
  if (!("endpoints" in value) || typeof value.endpoints !== "object" || value.endpoints === null) return false
103
63
  return "ui" in value.endpoints && typeof value.endpoints.ui === "string" && "backend" in value.endpoints && typeof value.endpoints.backend === "string"
104
64
  }
105
65
 
106
- function processAlive(pid: number) {
66
+ function validateName(name: string) {
67
+ if (!/^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$/.test(name)) throw new Error(`invalid instance name "${name}"`)
68
+ return name
69
+ }
70
+
71
+ function alive(pid: number) {
107
72
  try {
108
73
  process.kill(pid, 0)
109
74
  return true
@@ -111,10 +76,3 @@ function processAlive(pid: number) {
111
76
  return false
112
77
  }
113
78
  }
114
-
115
- function validateName(name: string) {
116
- if (!/^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$/.test(name)) {
117
- throw new Error("instance names must contain 1-64 letters, numbers, dots, underscores, or dashes")
118
- }
119
- return name
120
- }