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.
- package/README.md +19 -80
- package/package.json +1 -1
- package/src/cli/commands.ts +104 -68
- package/src/cli/control.ts +143 -0
- package/src/cli/index.ts +149 -106
- package/src/cli/instance.ts +222 -168
- package/src/cli/list.ts +10 -0
- package/src/cli/logs.ts +9 -0
- package/src/cli/mock-backend.ts +37 -0
- package/src/cli/registry.ts +224 -71
- package/src/cli/response-generator.ts +334 -0
- package/src/cli/responses.ts +20 -0
- package/src/cli/restart.ts +2 -65
- package/src/cli/script.ts +77 -0
- package/src/cli/send.ts +22 -39
- package/src/cli/start.ts +202 -65
- package/src/cli/stop.ts +19 -26
- package/src/cli/types.ts +7 -30
- package/src/client/backend.ts +95 -33
- package/src/client/client.ts +86 -29
- package/src/client/protocol.ts +116 -29
- package/src/client/protocol.types.ts +17 -23
- package/src/experimental/driver.ts +29 -18
- package/src/experimental/flow-driver.ts +130 -43
- package/src/experimental/flows/properties.ts +19 -13
- package/src/experimental/hello-driver.ts +11 -4
- package/src/experimental/stale-running-driver.ts +45 -13
- package/src/index.ts +2 -0
- package/src/cli/describe.ts +0 -14
- package/src/cli/driver-runner.ts +0 -39
- package/src/cli/driver.ts +0 -28
- package/src/experimental/cli-campaign.ts +0 -179
package/README.md
CHANGED
|
@@ -1,100 +1,39 @@
|
|
|
1
1
|
# opencode-drive
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
This project gives your agents control over OpenCode:
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
* Run it during development and let your agents see and poke at the running instance
|
|
6
|
+
* Allow your agents to run it in headless mode and drive it to test things
|
|
6
7
|
|
|
7
|
-
|
|
8
|
+
## Skill
|
|
8
9
|
|
|
9
|
-
```
|
|
10
|
-
|
|
11
|
-
```
|
|
12
|
-
|
|
13
|
-
**Start a named instance, then address it with `--name`:**
|
|
14
|
-
|
|
15
|
-
```bash
|
|
16
|
-
bunx opencode-drive start --name demo
|
|
17
|
-
```
|
|
18
|
-
|
|
19
|
-
**Type, submit a prompt, and take a screenshot:**
|
|
20
|
-
|
|
21
|
-
```bash
|
|
22
|
-
bunx opencode-drive send --name demo \
|
|
23
|
-
--command.type "Explain this project in one sentence" \
|
|
24
|
-
--command.enter \
|
|
25
|
-
--command.screenshot
|
|
26
|
-
```
|
|
27
|
-
|
|
28
|
-
**List the full command API for agents:**
|
|
29
|
-
|
|
30
|
-
```bash
|
|
31
|
-
bunx opencode-drive api
|
|
32
|
-
```
|
|
33
|
-
|
|
34
|
-
**Run OpenCode visibly in the foreground:**
|
|
35
|
-
|
|
36
|
-
```bash
|
|
37
|
-
bunx opencode-drive start --visible
|
|
38
|
-
```
|
|
39
|
-
|
|
40
|
-
**Run a local OpenCode development checkout:**
|
|
41
|
-
|
|
42
|
-
```bash
|
|
43
|
-
cd ~/projects/opencode
|
|
44
|
-
bunx opencode-drive start --visible --dev .
|
|
45
|
-
```
|
|
46
|
-
|
|
47
|
-
**Run a custom OpenCode command after `--`:**
|
|
48
|
-
|
|
49
|
-
```bash
|
|
50
|
-
bunx opencode-drive start --name demo -- opencode2 --standalone
|
|
10
|
+
```sh
|
|
11
|
+
npx skills add jlongster/opencode-drive --agent opencode --skill opencode-drive
|
|
51
12
|
```
|
|
13
|
+
## OpenCode development
|
|
52
14
|
|
|
53
|
-
|
|
15
|
+
Run this:
|
|
54
16
|
|
|
55
|
-
```
|
|
56
|
-
|
|
17
|
+
```sh
|
|
18
|
+
OPENCODE_DRIVE=1 bun run dev
|
|
57
19
|
```
|
|
58
20
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
There are two different modes OpenCode can run in:
|
|
62
|
-
|
|
63
|
-
* Simulated: core layers like networking are swapped out in the backend so you can control them
|
|
64
|
-
* Driven: starts websocket servers internally to expose commands to drive the app
|
|
65
|
-
|
|
66
|
-
You can choose one or the other, or both! This allows the following use cases:
|
|
67
|
-
|
|
68
|
-
1. You want to develop opencode
|
|
21
|
+
If you installed the skill file, OpenCode will be able to see and interact with the running instance.
|
|
69
22
|
|
|
70
|
-
|
|
23
|
+
## Using with agents
|
|
71
24
|
|
|
72
|
-
|
|
25
|
+
Install the skill file above and ask the agent to test various flows with the app. You also ask it to make a recording and it will generate a video file for you to watch anything it did.
|
|
73
26
|
|
|
74
|
-
|
|
27
|
+
## UI development
|
|
75
28
|
|
|
76
|
-
|
|
77
|
-
bunx opencode-drive start --dev . --visible
|
|
78
|
-
```
|
|
29
|
+
If you are doing UI development in OpenCode, you might want to run it in a simulated mode. This allows `opencode-drive` to drive it and always put it into a state that you want to see.
|
|
79
30
|
|
|
80
|
-
|
|
31
|
+
Run it in visible mode:
|
|
81
32
|
|
|
82
33
|
```sh
|
|
83
|
-
|
|
34
|
+
opencode-drive start --visible --dev ~/projects/opencode
|
|
84
35
|
```
|
|
85
36
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
2. You want opencode to develop itself
|
|
89
|
-
|
|
90
|
-
OpenCode can spawn it's own instances of OpenCode in headless mode. Use the skill in this repo to teach it about it.
|
|
91
|
-
|
|
92
|
-
It will spawn it in both drive and simulated mode, allowing it explore the app. It will use the `screenshot` command to capture screenshots to see what's happening, providing a full feedback loop.
|
|
93
|
-
|
|
94
|
-
3. You want to share reproducible steps
|
|
95
|
-
|
|
96
|
-
Ask opencode to find a bug, and list the commands it used to get there. You can then share this list with someone else who can use `opencode-drive` in visible mode to visually inspect it and develop a fix. (Or just share the steps with your own local OpenCode to find a fix)
|
|
97
|
-
|
|
98
|
-
4. You want to run it a billion times and assert properties
|
|
37
|
+
While developing, you can run `opencode-drive restart` to restart only the UI (the server will persist as a separate process). Do this with agents, and they will always restart and get the UI where you want it to be automatically.
|
|
99
38
|
|
|
100
|
-
|
|
39
|
+
View the [skills file](https://github.com/jlongster/opencode-drive/blob/main/skills/opencode-drive/SKILL.md) for more details about the CLI.
|
package/package.json
CHANGED
package/src/cli/commands.ts
CHANGED
|
@@ -1,25 +1,45 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import type { DriveCommand
|
|
1
|
+
import { connectSimulation, Frontend } from "../client/index.js"
|
|
2
|
+
import type { DriveCommand } from "./types.js"
|
|
3
3
|
|
|
4
4
|
export const commandInfo = {
|
|
5
5
|
"ui.type": { value: true, description: "Type text using JSON params" },
|
|
6
6
|
"ui.press": { value: true, description: "Press a key using JSON params" },
|
|
7
7
|
"ui.enter": { value: false, description: "Press Enter" },
|
|
8
|
-
"ui.arrow": {
|
|
9
|
-
|
|
8
|
+
"ui.arrow": {
|
|
9
|
+
value: true,
|
|
10
|
+
description: "Press an arrow key using JSON params",
|
|
11
|
+
},
|
|
12
|
+
"ui.focus": {
|
|
13
|
+
value: true,
|
|
14
|
+
description: "Focus an element using JSON params",
|
|
15
|
+
},
|
|
10
16
|
"ui.click": { value: true, description: "Click using JSON params" },
|
|
11
|
-
"ui.screenshot": {
|
|
12
|
-
|
|
17
|
+
"ui.screenshot": {
|
|
18
|
+
value: false,
|
|
19
|
+
description: "Take a screenshot and return its path",
|
|
20
|
+
},
|
|
21
|
+
"ui.state": {
|
|
22
|
+
value: false,
|
|
23
|
+
description: "Return focus, elements, and available UI actions",
|
|
24
|
+
},
|
|
13
25
|
"ui.start-record": { value: false, description: "Start recording the UI" },
|
|
14
|
-
"ui.end-record": {
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
"llm.disconnect": { value: true, description: "Disconnect a simulated LLM exchange" },
|
|
26
|
+
"ui.end-record": {
|
|
27
|
+
value: false,
|
|
28
|
+
description: "Stop recording and return the recording path",
|
|
29
|
+
},
|
|
19
30
|
} as const
|
|
20
31
|
|
|
21
32
|
export function commandAcceptsValue(operation: string) {
|
|
22
|
-
if (operation
|
|
33
|
+
if (operation === "ui.type") return commandInfo[operation].value
|
|
34
|
+
if (operation === "ui.press") return commandInfo[operation].value
|
|
35
|
+
if (operation === "ui.enter") return commandInfo[operation].value
|
|
36
|
+
if (operation === "ui.arrow") return commandInfo[operation].value
|
|
37
|
+
if (operation === "ui.focus") return commandInfo[operation].value
|
|
38
|
+
if (operation === "ui.click") return commandInfo[operation].value
|
|
39
|
+
if (operation === "ui.screenshot") return commandInfo[operation].value
|
|
40
|
+
if (operation === "ui.state") return commandInfo[operation].value
|
|
41
|
+
if (operation === "ui.start-record") return commandInfo[operation].value
|
|
42
|
+
if (operation === "ui.end-record") return commandInfo[operation].value
|
|
23
43
|
throw new Error(`unknown drive command "${operation}"`)
|
|
24
44
|
}
|
|
25
45
|
|
|
@@ -27,31 +47,33 @@ export function commandNames() {
|
|
|
27
47
|
return Object.keys(commandInfo).sort()
|
|
28
48
|
}
|
|
29
49
|
|
|
30
|
-
export async function executeCommands(
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
const
|
|
36
|
-
|
|
37
|
-
const results: Array<{ readonly command: string; readonly result: unknown }> = []
|
|
50
|
+
export async function executeCommands(
|
|
51
|
+
endpoint: string,
|
|
52
|
+
commands: ReadonlyArray<DriveCommand>,
|
|
53
|
+
) {
|
|
54
|
+
const ui = await connectSimulation({ url: endpoint })
|
|
55
|
+
const results: Array<{ readonly command: string; readonly result: unknown }> =
|
|
56
|
+
[]
|
|
38
57
|
try {
|
|
39
|
-
for (const command of commands)
|
|
40
|
-
results.push({
|
|
41
|
-
|
|
42
|
-
|
|
58
|
+
for (const command of commands)
|
|
59
|
+
results.push({
|
|
60
|
+
command: command.operation,
|
|
61
|
+
result: await execute(command, ui),
|
|
62
|
+
})
|
|
63
|
+
return { results }
|
|
43
64
|
} catch (error) {
|
|
44
|
-
throw new CommandBatchError(
|
|
65
|
+
throw new CommandBatchError(results, error)
|
|
45
66
|
} finally {
|
|
46
|
-
|
|
47
|
-
clients.backend?.close()
|
|
67
|
+
ui.close()
|
|
48
68
|
}
|
|
49
69
|
}
|
|
50
70
|
|
|
51
71
|
export class CommandBatchError extends Error {
|
|
52
72
|
constructor(
|
|
53
|
-
readonly
|
|
54
|
-
|
|
73
|
+
readonly results: ReadonlyArray<{
|
|
74
|
+
readonly command: string
|
|
75
|
+
readonly result: unknown
|
|
76
|
+
}>,
|
|
55
77
|
readonly reason: unknown,
|
|
56
78
|
) {
|
|
57
79
|
super(reason instanceof Error ? reason.message : String(reason))
|
|
@@ -61,62 +83,76 @@ export class CommandBatchError extends Error {
|
|
|
61
83
|
|
|
62
84
|
async function execute(
|
|
63
85
|
command: DriveCommand,
|
|
64
|
-
ui:
|
|
65
|
-
backend: () => Promise<Awaited<ReturnType<typeof connectBackendSimulation>>>,
|
|
86
|
+
ui: Awaited<ReturnType<typeof connectSimulation>>,
|
|
66
87
|
) {
|
|
67
88
|
switch (command.operation) {
|
|
68
89
|
case "ui.type": {
|
|
69
|
-
const request = Frontend.decodeRequest({
|
|
70
|
-
|
|
71
|
-
|
|
90
|
+
const request = Frontend.decodeRequest({
|
|
91
|
+
jsonrpc: "2.0",
|
|
92
|
+
method: "ui.type",
|
|
93
|
+
params: json(required(command)),
|
|
94
|
+
})
|
|
95
|
+
if (request.method !== "ui.type")
|
|
96
|
+
throw new Error("invalid ui.type params")
|
|
97
|
+
return ui.typeText(request.params.text)
|
|
72
98
|
}
|
|
73
99
|
case "ui.press": {
|
|
74
|
-
const request = Frontend.decodeRequest({
|
|
75
|
-
|
|
76
|
-
|
|
100
|
+
const request = Frontend.decodeRequest({
|
|
101
|
+
jsonrpc: "2.0",
|
|
102
|
+
method: "ui.press",
|
|
103
|
+
params: json(required(command)),
|
|
104
|
+
})
|
|
105
|
+
if (request.method !== "ui.press")
|
|
106
|
+
throw new Error("invalid ui.press params")
|
|
107
|
+
return ui.pressKey(request.params.key, request.params.modifiers)
|
|
77
108
|
}
|
|
78
|
-
case "ui.enter":
|
|
109
|
+
case "ui.enter":
|
|
110
|
+
return ui.pressEnter()
|
|
79
111
|
case "ui.arrow": {
|
|
80
|
-
const request = Frontend.decodeRequest({
|
|
81
|
-
|
|
82
|
-
|
|
112
|
+
const request = Frontend.decodeRequest({
|
|
113
|
+
jsonrpc: "2.0",
|
|
114
|
+
method: "ui.arrow",
|
|
115
|
+
params: json(required(command)),
|
|
116
|
+
})
|
|
117
|
+
if (request.method !== "ui.arrow")
|
|
118
|
+
throw new Error("invalid ui.arrow params")
|
|
119
|
+
return ui.pressArrow(request.params.direction)
|
|
83
120
|
}
|
|
84
121
|
case "ui.focus": {
|
|
85
|
-
const request = Frontend.decodeRequest({
|
|
86
|
-
|
|
87
|
-
|
|
122
|
+
const request = Frontend.decodeRequest({
|
|
123
|
+
jsonrpc: "2.0",
|
|
124
|
+
method: "ui.focus",
|
|
125
|
+
params: json(required(command)),
|
|
126
|
+
})
|
|
127
|
+
if (request.method !== "ui.focus")
|
|
128
|
+
throw new Error("invalid ui.focus params")
|
|
129
|
+
return ui.focus(request.params.target)
|
|
88
130
|
}
|
|
89
131
|
case "ui.click": {
|
|
90
|
-
const request = Frontend.decodeRequest({
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
case "llm.pending": return (await backend()).pendingExchanges()
|
|
99
|
-
case "llm.chunk": {
|
|
100
|
-
const request = Backend.decodeRequest({ jsonrpc: "2.0", method: "llm.chunk", params: json(required(command)) })
|
|
101
|
-
if (request.method !== "llm.chunk") throw new Error("invalid llm.chunk params")
|
|
102
|
-
return (await backend()).chunk(request.params.id, request.params.items)
|
|
103
|
-
}
|
|
104
|
-
case "llm.finish": {
|
|
105
|
-
const request = Backend.decodeRequest({ jsonrpc: "2.0", method: "llm.finish", params: json(required(command)) })
|
|
106
|
-
if (request.method !== "llm.finish") throw new Error("invalid llm.finish params")
|
|
107
|
-
return (await backend()).finish(request.params.id, request.params.reason)
|
|
108
|
-
}
|
|
109
|
-
case "llm.disconnect": {
|
|
110
|
-
const request = Backend.decodeRequest({ jsonrpc: "2.0", method: "llm.disconnect", params: json(required(command)) })
|
|
111
|
-
if (request.method !== "llm.disconnect") throw new Error("invalid llm.disconnect params")
|
|
112
|
-
return (await backend()).disconnect(request.params.id)
|
|
132
|
+
const request = Frontend.decodeRequest({
|
|
133
|
+
jsonrpc: "2.0",
|
|
134
|
+
method: "ui.click",
|
|
135
|
+
params: json(required(command)),
|
|
136
|
+
})
|
|
137
|
+
if (request.method !== "ui.click")
|
|
138
|
+
throw new Error("invalid ui.click params")
|
|
139
|
+
return ui.click(request.params.target, request.params.x, request.params.y)
|
|
113
140
|
}
|
|
141
|
+
case "ui.screenshot":
|
|
142
|
+
return ui.screenshot()
|
|
143
|
+
case "ui.state":
|
|
144
|
+
return ui.state()
|
|
145
|
+
case "ui.start-record":
|
|
146
|
+
return ui.startRecord()
|
|
147
|
+
case "ui.end-record":
|
|
148
|
+
return ui.endRecord()
|
|
114
149
|
}
|
|
115
150
|
throw new Error(`unknown drive command "${command.operation}"`)
|
|
116
151
|
}
|
|
117
152
|
|
|
118
153
|
function required(command: DriveCommand) {
|
|
119
|
-
if (command.value === undefined)
|
|
154
|
+
if (command.value === undefined)
|
|
155
|
+
throw new Error(`${command.operation} requires a value`)
|
|
120
156
|
return command.value
|
|
121
157
|
}
|
|
122
158
|
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { rm } from "node:fs/promises"
|
|
2
|
+
import { connect, createServer } from "node:net"
|
|
3
|
+
import type {
|
|
4
|
+
ResponseConfiguration,
|
|
5
|
+
ResponseUpdate,
|
|
6
|
+
} from "./response-generator.js"
|
|
7
|
+
|
|
8
|
+
export async function listenControl(
|
|
9
|
+
path: string,
|
|
10
|
+
handlers: {
|
|
11
|
+
readonly restart: () => Promise<void>
|
|
12
|
+
readonly stop: () => Promise<void>
|
|
13
|
+
readonly responses: (
|
|
14
|
+
input: ResponseUpdate,
|
|
15
|
+
) => Promise<ResponseConfiguration>
|
|
16
|
+
},
|
|
17
|
+
) {
|
|
18
|
+
const server = createServer((socket) => {
|
|
19
|
+
let buffer = ""
|
|
20
|
+
socket.setEncoding("utf8")
|
|
21
|
+
socket.on("data", (data) => {
|
|
22
|
+
buffer += data
|
|
23
|
+
if (buffer.length > 64 * 1024) {
|
|
24
|
+
socket.removeAllListeners("data")
|
|
25
|
+
socket.end("error: control request exceeds 64 KiB\n")
|
|
26
|
+
return
|
|
27
|
+
}
|
|
28
|
+
if (!buffer.includes("\n")) return
|
|
29
|
+
socket.removeAllListeners("data")
|
|
30
|
+
void handle(buffer.slice(0, buffer.indexOf("\n"))).then(
|
|
31
|
+
(result) =>
|
|
32
|
+
socket.end(
|
|
33
|
+
`success${result === undefined ? "" : ` ${JSON.stringify(result)}`}\n`,
|
|
34
|
+
),
|
|
35
|
+
(error) =>
|
|
36
|
+
socket.end(
|
|
37
|
+
`error: ${error instanceof Error ? error.message : String(error)}\n`,
|
|
38
|
+
),
|
|
39
|
+
)
|
|
40
|
+
})
|
|
41
|
+
})
|
|
42
|
+
const handle = async (input: string) => {
|
|
43
|
+
if (input === "restart") return handlers.restart()
|
|
44
|
+
if (input === "stop") return handlers.stop()
|
|
45
|
+
if (input === "responses") return handlers.responses({})
|
|
46
|
+
if (input.startsWith("responses "))
|
|
47
|
+
return handlers.responses(parseResponseUpdate(input.slice("responses ".length)))
|
|
48
|
+
throw new Error("unknown control command")
|
|
49
|
+
}
|
|
50
|
+
await listen(server, path)
|
|
51
|
+
return async () => {
|
|
52
|
+
await new Promise<void>((resolve) => server.close(() => resolve()))
|
|
53
|
+
await rm(path, { force: true })
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export async function request(path: string, command: "restart" | "stop") {
|
|
58
|
+
const response = await send(path, command)
|
|
59
|
+
if (response !== "success") throw responseError(response)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export async function requestResponses(path: string, input: ResponseUpdate) {
|
|
63
|
+
const response = await send(
|
|
64
|
+
path,
|
|
65
|
+
Object.keys(input).length === 0
|
|
66
|
+
? "responses"
|
|
67
|
+
: `responses ${JSON.stringify(input)}`,
|
|
68
|
+
)
|
|
69
|
+
if (!response.startsWith("success ")) throw responseError(response)
|
|
70
|
+
const value: unknown = JSON.parse(response.slice("success ".length))
|
|
71
|
+
if (!isResponseConfiguration(value))
|
|
72
|
+
throw new Error("instance returned an invalid response configuration")
|
|
73
|
+
return value
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function send(path: string, command: string) {
|
|
77
|
+
return new Promise<string>((resolve, reject) => {
|
|
78
|
+
const socket = connect(path)
|
|
79
|
+
let response = ""
|
|
80
|
+
const timer = setTimeout(() => {
|
|
81
|
+
socket.destroy()
|
|
82
|
+
reject(new Error("instance control request timed out"))
|
|
83
|
+
}, 10_000)
|
|
84
|
+
socket.setEncoding("utf8")
|
|
85
|
+
socket.on("connect", () => socket.write(`${command}\n`))
|
|
86
|
+
socket.on("data", (data) => {
|
|
87
|
+
response += data
|
|
88
|
+
})
|
|
89
|
+
socket.on("end", () => {
|
|
90
|
+
clearTimeout(timer)
|
|
91
|
+
resolve(response.trim())
|
|
92
|
+
})
|
|
93
|
+
socket.on("error", () => {
|
|
94
|
+
clearTimeout(timer)
|
|
95
|
+
reject(new Error("instance control socket is unavailable"))
|
|
96
|
+
})
|
|
97
|
+
})
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function parseResponseUpdate(input: string): ResponseUpdate {
|
|
101
|
+
const value: unknown = JSON.parse(input)
|
|
102
|
+
if (typeof value !== "object" || value === null || Array.isArray(value))
|
|
103
|
+
throw new Error("invalid responses configuration")
|
|
104
|
+
const types = "types" in value ? stringArray(value.types) : undefined
|
|
105
|
+
const tools = "tools" in value ? stringArray(value.tools) : undefined
|
|
106
|
+
return {
|
|
107
|
+
...(types === undefined ? {} : { types }),
|
|
108
|
+
...(tools === undefined ? {} : { tools }),
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function stringArray(value: unknown) {
|
|
113
|
+
if (!Array.isArray(value) || !value.every((item) => typeof item === "string"))
|
|
114
|
+
throw new Error("response types and tools must be string arrays")
|
|
115
|
+
return value
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function isResponseConfiguration(
|
|
119
|
+
value: unknown,
|
|
120
|
+
): value is ResponseConfiguration {
|
|
121
|
+
if (typeof value !== "object" || value === null) return false
|
|
122
|
+
if (!("types" in value) || !stringArrayValue(value.types)) return false
|
|
123
|
+
return "tools" in value && stringArrayValue(value.tools)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function stringArrayValue(value: unknown) {
|
|
127
|
+
return Array.isArray(value) && value.every((item) => typeof item === "string")
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function responseError(response: string) {
|
|
131
|
+
return new Error(response.replace(/^error:\s*/, "") || "empty control response")
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async function listen(server: ReturnType<typeof createServer>, path: string) {
|
|
135
|
+
await rm(path, { force: true })
|
|
136
|
+
await new Promise<void>((resolve, reject) => {
|
|
137
|
+
server.once("error", reject)
|
|
138
|
+
server.listen(path, () => {
|
|
139
|
+
server.off("error", reject)
|
|
140
|
+
resolve()
|
|
141
|
+
})
|
|
142
|
+
})
|
|
143
|
+
}
|