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/README.md +90 -61
- package/package.json +2 -2
- package/src/cli/api.ts +5 -0
- package/src/cli/commands.ts +117 -134
- package/src/cli/control.ts +43 -0
- package/src/cli/describe.ts +4 -5
- package/src/cli/index.ts +85 -101
- package/src/cli/instance.ts +220 -98
- package/src/cli/mock-backend.ts +25 -0
- package/src/cli/registry.ts +41 -83
- package/src/cli/restart.ts +7 -0
- package/src/cli/script.ts +66 -0
- package/src/cli/send.ts +19 -39
- package/src/cli/start.ts +100 -33
- package/src/cli/stop.ts +4 -28
- package/src/cli/types.ts +7 -30
- package/src/client/backend.ts +94 -37
- package/src/client/client.ts +15 -31
- package/src/client/index.ts +0 -3
- package/src/client/protocol.ts +133 -44
- package/src/client/protocol.types.ts +62 -0
- package/src/experimental/campaign.ts +2 -3
- package/src/experimental/driver.ts +29 -18
- package/src/experimental/flow-driver.ts +130 -45
- package/src/experimental/flows/properties.ts +19 -13
- package/src/experimental/flows/types.ts +0 -1
- package/src/experimental/hello-driver.ts +11 -4
- package/src/experimental/reproduce-stale-running-visible.ts +1 -1
- package/src/experimental/stale-running-driver.ts +45 -13
- package/src/index.ts +2 -0
- package/src/cli/driver-runner.ts +0 -39
- package/src/cli/driver.ts +0 -28
- package/src/experimental/cli-campaign.ts +0 -179
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 {
|
|
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 {
|
|
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(
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
Flag.
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
Flag.
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
Flag.
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
-
{
|
|
42
|
-
|
|
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", {
|
|
47
|
-
execute(() => send(toSendOptions(
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
)
|
|
68
|
+
const apiCommand = Command.make("api", {}, () => execute(api)).pipe(
|
|
69
|
+
Command.withDescription("Print the OpenCode drive UI protocol"),
|
|
70
|
+
)
|
|
61
71
|
|
|
62
|
-
const
|
|
63
|
-
|
|
64
|
-
|
|
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([
|
|
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
|
|
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
|
-
|
|
96
|
-
|
|
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
|
-
|
|
101
|
-
|
|
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
|
-
|
|
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) =>
|
|
151
|
-
|
|
152
|
-
|
|
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(
|
|
143
|
+
console.error(
|
|
144
|
+
`opencode-drive: ${error instanceof Error ? error.message : String(error)}`,
|
|
145
|
+
)
|
|
162
146
|
return process.exit(1)
|
|
163
147
|
}
|
|
164
148
|
}
|
package/src/cli/instance.ts
CHANGED
|
@@ -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 {
|
|
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
|
|
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
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
const
|
|
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:${
|
|
23
|
-
backend: `ws://127.0.0.1:${
|
|
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(
|
|
38
|
-
mkdir(
|
|
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
|
-
|
|
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
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
-
|
|
80
|
+
DRIVE_REGISTRY_DIR: drive,
|
|
81
|
+
OPENCODE_DRIVE: options.name,
|
|
89
82
|
OPENCODE_DRIVE_RENDERER: options.visible ? "visible" : "headless",
|
|
90
|
-
OPENCODE_CONFIG_DIR: join(
|
|
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:
|
|
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(
|
|
95
|
+
? await prepareDev(artifacts, options.dev)
|
|
99
96
|
: options.command?.length
|
|
100
97
|
? [...options.command]
|
|
101
|
-
: ["opencode2"
|
|
102
|
-
const
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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
|
-
|
|
126
|
-
if (
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
child.
|
|
133
|
-
|
|
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 (
|
|
136
|
-
|
|
137
|
-
|
|
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
|
|
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()))
|
|
148
|
-
|
|
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(
|
|
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))
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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: "
|
|
162
|
-
stderr: "
|
|
215
|
+
stdout: "ignore",
|
|
216
|
+
stderr: "ignore",
|
|
163
217
|
})
|
|
164
218
|
const status = await install.exited
|
|
165
|
-
if (status !== 0)
|
|
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({
|
|
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
|
|
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)
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
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(
|
|
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), {
|
|
208
|
-
|
|
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(
|
|
335
|
+
return Object.fromEntries(
|
|
336
|
+
Object.entries(env).filter(
|
|
337
|
+
(entry): entry is [string, string] => entry[1] !== undefined,
|
|
338
|
+
),
|
|
339
|
+
)
|
|
218
340
|
}
|