opencode-drive 0.1.2 → 0.1.4

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,173 +1,178 @@
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"
6
4
 
7
5
  export interface LaunchOptions {
8
- readonly name?: string
6
+ readonly name: string
9
7
  readonly command?: ReadonlyArray<string>
10
8
  readonly dev?: string
11
9
  readonly state?: string
10
+ readonly scripted?: boolean
12
11
  readonly visible?: boolean
13
12
  readonly env?: Readonly<Record<string, string>>
14
13
  }
15
14
 
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()])
15
+ export async function launchInstance(options: LaunchOptions) {
16
+ const artifacts = resolve(
17
+ join(tmpdir(), "opencode-drive", `run-${crypto.randomUUID().slice(0, 6)}`),
18
+ )
19
+ const logs = join(artifacts, "logs")
21
20
  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,
21
+ ui: `ws://127.0.0.1:${await freePort()}`,
22
+ backend: `ws://127.0.0.1:${await freePort()}`,
35
23
  }
24
+ const drive = join(artifacts, "drive")
36
25
  await Promise.all([
37
- mkdir(artifacts, { recursive: true }),
38
- mkdir(cwd, { recursive: true }),
26
+ mkdir(logs, { recursive: true }),
27
+ mkdir(drive, { recursive: true }),
39
28
  mkdir(join(artifacts, "home", ".cache"), { recursive: true }),
40
29
  mkdir(join(artifacts, "home", ".config"), { recursive: true }),
41
30
  mkdir(join(artifacts, "home", ".local", "share"), { recursive: true }),
42
31
  mkdir(join(artifacts, "home", ".local", "state"), { recursive: true }),
43
32
  ])
44
- const state = options.state ? resolve(options.state) : join(artifacts, "state")
33
+ await Bun.write(
34
+ join(drive, `${options.name}.json`),
35
+ `${JSON.stringify({ endpoints }, undefined, 2)}\n`,
36
+ )
37
+ const state = options.state
38
+ ? resolve(options.state)
39
+ : join(artifacts, "state")
45
40
  if (!options.state) {
46
41
  await rm(state, { recursive: true, force: true })
47
42
  await Promise.all([
48
43
  mkdir(join(state, "files", ".git"), { recursive: true }),
49
44
  mkdir(join(state, "files", ".opencode"), { recursive: true }),
45
+ mkdir(join(state, "files", "src"), { recursive: true }),
50
46
  ])
51
- await Bun.write(
52
- join(state, "files", ".opencode", "opencode.jsonc"),
53
- `${JSON.stringify(
54
- {
55
- model: "simulation/sim-model",
56
- permissions: [{ action: "*", resource: "*", effect: "allow" }],
57
- providers: {
58
- simulation: {
59
- name: "Simulation",
60
- request: { body: { apiKey: "sim-key" } },
61
- models: {
62
- "sim-model": {
63
- name: "Simulated Model",
64
- api: {
65
- type: "aisdk",
66
- package: "@ai-sdk/openai-compatible",
67
- url: "https://api.openai.com/v1",
47
+ await Promise.all([
48
+ Bun.write(
49
+ join(state, "files", ".opencode", "opencode.jsonc"),
50
+ `${JSON.stringify(
51
+ {
52
+ model: "simulation/gpt-sim-model",
53
+ permissions: [{ action: "*", resource: "*", effect: "allow" }],
54
+ providers: {
55
+ simulation: {
56
+ name: "Simulation",
57
+ package: "aisdk:@ai-sdk/openai-compatible",
58
+ settings: { baseURL: "https://api.openai.com/v1" },
59
+ request: { body: { apiKey: "sim-key" } },
60
+ models: {
61
+ "gpt-sim-model": {
62
+ name: "Simulated Model",
63
+ capabilities: {
64
+ tools: true,
65
+ input: ["text"],
66
+ output: ["text"],
67
+ },
68
+ limit: { context: 128000, output: 16000 },
68
69
  },
69
- capabilities: { tools: true, input: ["text"], output: ["text"] },
70
- limit: { context: 128000, output: 16000 },
71
70
  },
72
71
  },
73
72
  },
74
73
  },
75
- },
76
- undefined,
77
- 2,
78
- )}\n`,
79
- )
74
+ undefined,
75
+ 2,
76
+ )}\n`,
77
+ ),
78
+ Bun.write(
79
+ join(state, "files", "src", "garden.js"),
80
+ 'export function greet(name) {\n return `Hello, ${name}.`\n}\n',
81
+ ),
82
+ ])
80
83
  }
81
- await registerInstance(manifest)
82
84
  const environment = cleanEnv({
83
85
  ...process.env,
84
86
  ...options.env,
85
- DRIVE_REGISTRY_DIR: registryDirectory(),
86
87
  OPENCODE_SIMULATE: "1",
87
88
  OPENCODE_SIMULATE_STATE: state,
88
- OPENCODE_DRIVE: name,
89
+ DRIVE_REGISTRY_DIR: drive,
90
+ OPENCODE_DRIVE: options.name,
89
91
  OPENCODE_DRIVE_RENDERER: options.visible ? "visible" : "headless",
90
- OPENCODE_CONFIG_DIR: join(cwd, ".opencode"),
92
+ OPENCODE_CONFIG_DIR: join(artifacts, ".opencode"),
91
93
  OPENCODE_DB: ":memory:",
94
+ OPENCODE_LOG_LEVEL: !options.visible
95
+ ? "INFO"
96
+ : process.env.OPENCODE_LOG_LEVEL,
92
97
  OPENCODE_TEST_HOME: artifacts,
93
98
  XDG_CACHE_HOME: join(artifacts, "home", ".cache"),
94
99
  XDG_CONFIG_HOME: join(artifacts, "home", ".config"),
95
- XDG_DATA_HOME: join(artifacts, "home", ".local", "share"),
100
+ XDG_DATA_HOME: logs,
96
101
  XDG_STATE_HOME: join(artifacts, "home", ".local", "state"),
97
102
  })
98
103
  const command = options.dev
99
- ? await prepareDev(cwd, options.dev)
104
+ ? await prepareDev(artifacts, options.dev)
100
105
  : options.command?.length
101
106
  ? [...options.command]
102
107
  : ["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
- }
108
+ const spawn = () =>
109
+ Bun.spawn(command, {
110
+ cwd: artifacts,
111
+ env: environment,
112
+ stdin: options.visible ? "inherit" : "ignore",
113
+ stdout: !options.visible
114
+ ? Bun.file(join(logs, "opencode.stdout.log"))
115
+ : "inherit",
116
+ stderr: !options.visible
117
+ ? Bun.file(join(logs, "opencode.stderr.log"))
118
+ : "inherit",
119
+ })
120
+ let child = spawn()
121
+ let stopping: Promise<void> | undefined
122
+ let restarting: Promise<void> | undefined
113
123
  return {
114
- manifest,
124
+ artifacts,
125
+ logs,
126
+ endpoints,
115
127
  get child() {
116
128
  return child
117
129
  },
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
130
+ async waitForDrive(
131
+ requirement: "ui" | "backend" | "both" = "both",
132
+ timeout = 60_000,
133
+ ) {
134
+ const urls =
135
+ requirement === "both"
136
+ ? [endpoints.ui, endpoints.backend]
137
+ : [endpoints[requirement]]
138
+ await Promise.all(
139
+ urls.map((url) => waitForWebSocket(url, child.exited, timeout)),
140
+ )
128
141
  },
129
- restart() {
130
- if (lifecycle.restarting) return lifecycle.restarting
131
- lifecycle.restarting = (async () => {
142
+ async restart() {
143
+ if (restarting) return restarting
144
+ restarting = (async () => {
132
145
  await terminate(child)
133
- child = spawn(command, environment, manifest)
146
+ child = spawn()
134
147
  await Promise.all([
135
148
  waitForWebSocket(endpoints.ui, child.exited, 60_000),
136
149
  waitForWebSocket(endpoints.backend, child.exited, 60_000),
137
150
  ])
138
151
  })().finally(() => {
139
- lifecycle.restarting = undefined
152
+ restarting = undefined
140
153
  })
141
- return lifecycle.restarting
154
+ return restarting
142
155
  },
143
156
  async wait() {
144
157
  while (true) {
145
158
  const current = child
146
159
  const status = await current.exited
147
- if (lifecycle.restarting) {
148
- await lifecycle.restarting
160
+ if (restarting) {
161
+ await restarting
149
162
  continue
150
163
  }
151
- if (child !== current) continue
164
+ if (current !== child) continue
152
165
  return status
153
166
  }
154
167
  },
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)
168
+ stop() {
169
+ if (stopping) return stopping
170
+ stopping = (async () => {
171
+ if (restarting) await restarting.catch(() => undefined)
172
+ await terminate(child)
173
+ await stopService(join(artifacts, "home", ".local", "state"))
169
174
  })()
170
- return lifecycle.stopping
175
+ return stopping
171
176
  },
172
177
  }
173
178
  }
@@ -180,67 +185,48 @@ async function terminate(child: Bun.Subprocess) {
180
185
  await child.exited
181
186
  }
182
187
 
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
188
  async function prepareDev(cwd: string, directory: string) {
223
189
  const root = resolve(directory)
224
190
  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")
191
+ if (!(await Bun.file(entrypoint).exists()))
192
+ throw new Error(`OpenCode development entrypoint not found: ${entrypoint}`)
193
+ const solidPackage = join(
194
+ root,
195
+ "packages",
196
+ "tui",
197
+ "node_modules",
198
+ "@opentui",
199
+ "solid",
200
+ "package.json",
201
+ )
227
202
  if (!(await Bun.file(solidPackage).exists())) {
228
- throw new Error(`OpenCode development dependency not found: ${solidPackage}; run bun install in ${root}`)
203
+ throw new Error(
204
+ `OpenCode development dependency not found: ${solidPackage}; run bun install in ${root}`,
205
+ )
229
206
  }
230
207
  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`)
208
+ if (!isPackageInfo(info))
209
+ throw new Error(`Invalid @opentui/solid package metadata: ${solidPackage}`)
210
+ await Bun.write(
211
+ join(cwd, "package.json"),
212
+ `${JSON.stringify(
213
+ {
214
+ private: true,
215
+ dependencies: { "@opentui/solid": info.version },
216
+ },
217
+ undefined,
218
+ 2,
219
+ )}\n`,
220
+ )
236
221
  const install = Bun.spawn([process.execPath, "install"], {
237
222
  cwd,
238
223
  stdin: "ignore",
239
- stdout: "inherit",
240
- stderr: "inherit",
224
+ stdout: "ignore",
225
+ stderr: "ignore",
241
226
  })
242
227
  const status = await install.exited
243
- if (status !== 0) throw new Error(`bun install failed in ${cwd} with status ${status}`)
228
+ if (status !== 0)
229
+ throw new Error(`bun install failed in ${cwd} with status ${status}`)
244
230
  return [
245
231
  process.execPath,
246
232
  "--conditions=browser",
@@ -249,27 +235,89 @@ async function prepareDev(cwd: string, directory: string) {
249
235
  ]
250
236
  }
251
237
 
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
238
  async function freePort() {
257
- const server = Bun.serve({ port: 0, hostname: "127.0.0.1", fetch: () => new Response() })
239
+ const server = Bun.serve({
240
+ hostname: "127.0.0.1",
241
+ port: 0,
242
+ fetch: () => new Response(),
243
+ })
258
244
  const port = server.port
259
245
  await server.stop(true)
260
246
  return port
261
247
  }
262
248
 
263
- async function waitForWebSocket(url: string, exited: Promise<number>, timeout: number) {
249
+ async function stopService(state: string) {
250
+ const files = [
251
+ join(state, "opencode", "service-local.json"),
252
+ join(state, "opencode", "service.json"),
253
+ ]
254
+ const info = await Promise.all(
255
+ files.map((file) =>
256
+ Bun.file(file)
257
+ .json()
258
+ .catch(() => undefined),
259
+ ),
260
+ )
261
+ await Promise.all(
262
+ info.map(async (value) => {
263
+ if (!isServiceInfo(value)) return
264
+ try {
265
+ process.kill(value.pid, "SIGTERM")
266
+ } catch {
267
+ return
268
+ }
269
+ const deadline = Date.now() + 1_000
270
+ while (Date.now() < deadline && alive(value.pid)) await Bun.sleep(25)
271
+ if (alive(value.pid)) process.kill(value.pid, "SIGKILL")
272
+ }),
273
+ )
274
+ }
275
+
276
+ function isServiceInfo(value: unknown): value is { readonly pid: number } {
277
+ return (
278
+ typeof value === "object" &&
279
+ value !== null &&
280
+ "pid" in value &&
281
+ typeof value.pid === "number"
282
+ )
283
+ }
284
+
285
+ function alive(pid: number) {
286
+ try {
287
+ process.kill(pid, 0)
288
+ return true
289
+ } catch {
290
+ return false
291
+ }
292
+ }
293
+
294
+ function isPackageInfo(value: unknown): value is { readonly version: string } {
295
+ return (
296
+ typeof value === "object" &&
297
+ value !== null &&
298
+ "version" in value &&
299
+ typeof value.version === "string"
300
+ )
301
+ }
302
+
303
+ async function waitForWebSocket(
304
+ url: string,
305
+ exited: Promise<number>,
306
+ timeout: number,
307
+ ) {
264
308
  const deadline = Date.now() + timeout
265
309
  while (Date.now() < deadline) {
266
310
  const connected = await Promise.race([
267
- open(url).then((socket) => {
268
- socket.close()
269
- return true
270
- }).catch(() => false),
311
+ open(url)
312
+ .then((socket) => {
313
+ socket.terminate()
314
+ return true
315
+ })
316
+ .catch(() => false),
271
317
  exited.then((code) => {
272
- throw new Error(`OpenCode exited with status ${code} before ${url} became ready`)
318
+ throw new Error(
319
+ `OpenCode exited with status ${code} before ${url} became ready`,
320
+ )
273
321
  }),
274
322
  ])
275
323
  if (connected) return
@@ -281,15 +329,21 @@ async function waitForWebSocket(url: string, exited: Promise<number>, timeout: n
281
329
  function open(url: string) {
282
330
  return new Promise<WebSocket>((resolveSocket, reject) => {
283
331
  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 })
332
+ socket.addEventListener("open", () => resolveSocket(socket), {
333
+ once: true,
334
+ })
335
+ socket.addEventListener(
336
+ "error",
337
+ () => reject(new Error(`cannot connect to ${url}`)),
338
+ { once: true },
339
+ )
286
340
  })
287
341
  }
288
342
 
289
- function randomSuffix() {
290
- return crypto.randomUUID().slice(0, 6)
291
- }
292
-
293
343
  function cleanEnv(env: Readonly<Record<string, string | undefined>>) {
294
- return Object.fromEntries(Object.entries(env).filter((entry): entry is [string, string] => entry[1] !== undefined))
344
+ return Object.fromEntries(
345
+ Object.entries(env).filter(
346
+ (entry): entry is [string, string] => entry[1] !== undefined,
347
+ ),
348
+ )
295
349
  }
@@ -0,0 +1,10 @@
1
+ import { listInstances, manifestPath } from "./registry.js"
2
+
3
+ export async function list() {
4
+ const instances = await listInstances()
5
+ console.log(
6
+ instances
7
+ .map((instance) => `${instance.name}: ${manifestPath(instance.name)}`)
8
+ .join("\n"),
9
+ )
10
+ }
@@ -0,0 +1,9 @@
1
+ import { join } from "node:path"
2
+ import { resolveInstance } from "./registry.js"
3
+
4
+ export async function logs(name?: string) {
5
+ const manifest = await resolveInstance(name)
6
+ console.log(
7
+ join(manifest.artifacts, "logs", "opencode", "log", "opencode*.log"),
8
+ )
9
+ }
@@ -0,0 +1,37 @@
1
+ import { connectBackendSimulation } from "../client/index.js"
2
+ import { generateResponse } from "./response-generator.js"
3
+ import type { createResponseSettings } from "./response-generator.js"
4
+
5
+ export async function connectMockBackend(
6
+ endpoint: string,
7
+ responses: ReturnType<typeof createResponseSettings>,
8
+ ) {
9
+ const backend = await connectBackendSimulation({ url: endpoint })
10
+ await backend.attach(async (request) => {
11
+ const response = generateResponse(responses.current(), request)
12
+ for (const item of response.items) {
13
+ if (item.type !== "textDelta" && item.type !== "reasoningDelta") {
14
+ await backend.chunk(request.id, [item])
15
+ continue
16
+ }
17
+ for (const text of splitText(item.text)) {
18
+ await backend.chunk(request.id, [{ ...item, text }])
19
+ await Bun.sleep(45 + Math.floor(Math.random() * 35))
20
+ }
21
+ }
22
+ await backend.finish(request.id, response.finish)
23
+ })
24
+ return {
25
+ close() {
26
+ backend.close()
27
+ },
28
+ }
29
+ }
30
+
31
+ export function splitText(text: string) {
32
+ const words = text.match(/\S+\s*/g) ?? [text]
33
+ return Array.from(
34
+ { length: Math.ceil(words.length / 3) },
35
+ (_, index) => words.slice(index * 3, index * 3 + 3).join(""),
36
+ )
37
+ }