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/README.md
CHANGED
|
@@ -1,90 +1,119 @@
|
|
|
1
1
|
# opencode-drive
|
|
2
2
|
|
|
3
|
-
Drive
|
|
3
|
+
Drive real and simulated OpenCode instances through fixed local WebSocket ports.
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
bunx opencode-drive start --name demo
|
|
5
|
+
## Skill
|
|
7
6
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
--command.press enter \
|
|
11
|
-
--command.render \
|
|
12
|
-
--command.state
|
|
7
|
+
```sh
|
|
8
|
+
npx skills add jlongster/opencode-drive --agent opencode --skill opencode-drive
|
|
13
9
|
```
|
|
14
10
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
11
|
+
# Usage
|
|
12
|
+
|
|
13
|
+
**Start the default headless instance:**
|
|
18
14
|
|
|
19
15
|
```bash
|
|
20
|
-
bunx opencode-drive start
|
|
21
|
-
opencode2 --standalone
|
|
16
|
+
bunx opencode-drive start
|
|
22
17
|
```
|
|
23
18
|
|
|
24
|
-
|
|
25
|
-
checkout's `@opentui/solid` runtime in its isolated working directory and configures
|
|
26
|
-
Bun automatically:
|
|
19
|
+
There is no "detached" version. If you want to drive an isolated app, use the scripted version below.
|
|
27
20
|
|
|
28
|
-
|
|
29
|
-
|
|
21
|
+
Run a local OpenCode checkout:
|
|
22
|
+
|
|
23
|
+
```sh
|
|
24
|
+
bunx opencode-drive start --dev ~/projects/opencode
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Send UI Commands
|
|
28
|
+
|
|
29
|
+
`send` always connects to UI port `40900`:
|
|
30
|
+
|
|
31
|
+
```sh
|
|
32
|
+
bunx opencode-drive send \
|
|
33
|
+
--command.ui.type '{"text":"Explain this project"}' \
|
|
34
|
+
--command.ui.enter
|
|
35
|
+
|
|
36
|
+
bunx opencode-drive send --command.ui.state
|
|
37
|
+
bunx opencode-drive send --command.ui.screenshot
|
|
30
38
|
```
|
|
31
39
|
|
|
32
|
-
|
|
33
|
-
|
|
40
|
+
Run `bunx opencode-drive api` for the full UI command contract.
|
|
41
|
+
|
|
42
|
+
## Scripted Mode
|
|
43
|
+
|
|
44
|
+
Scripted mode launches OpenCode, runs the script, and stops OpenCode when the script exits.
|
|
45
|
+
|
|
46
|
+
```sh
|
|
47
|
+
bunx opencode-drive start --script ./drive.ts
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Add `--visible` to watch the TUI while the script runs. Visible starts remain active after a script finishes. Restart the visible OpenCode child and rerun its script with `bunx opencode-drive restart`.
|
|
51
|
+
|
|
52
|
+
Only one visible instance can be controlled this way. Headless runs exit when their script completes and do not support restart.
|
|
53
|
+
|
|
54
|
+
The module must default-export a function receiving connected clients and the run's artifact directory:
|
|
34
55
|
|
|
35
56
|
```ts
|
|
36
|
-
import {
|
|
57
|
+
import { defineScript } from "opencode-drive"
|
|
37
58
|
|
|
38
|
-
export default
|
|
39
|
-
await
|
|
59
|
+
export default defineScript(async ({ ui, backend }) => {
|
|
60
|
+
await backend.attach(async (request) => {
|
|
61
|
+
await backend.chunk(request.id, [{ type: "textDelta", text: "Hello" }])
|
|
62
|
+
await backend.finish(request.id)
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
await ui.typeText("Say hello")
|
|
40
66
|
await ui.pressEnter()
|
|
41
|
-
|
|
42
|
-
if (!state.focused.editor) throw new Error("prompt editor is not focused")
|
|
67
|
+
await ui.screenshot()
|
|
43
68
|
})
|
|
44
69
|
```
|
|
45
70
|
|
|
46
|
-
|
|
47
|
-
headless OpenCode process. One deterministic case can use the same runner in a
|
|
48
|
-
visible terminal:
|
|
71
|
+
# Use cases
|
|
49
72
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
73
|
+
There are two different modes OpenCode can run in:
|
|
74
|
+
|
|
75
|
+
* Simulated: core layers like networking are swapped out in the backend so you can control them
|
|
76
|
+
* Driven: starts websocket servers internally to expose commands to drive the app
|
|
77
|
+
|
|
78
|
+
You can choose one or the other, or both! This allows the following use cases:
|
|
79
|
+
|
|
80
|
+
## You want to develop opencode
|
|
81
|
+
|
|
82
|
+
You are running a development version of OpenCode to work on it. You have another OpenCode instance making changes to the app.
|
|
54
83
|
|
|
55
|
-
OpenCode
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
"version": 1,
|
|
63
|
-
"name": "demo",
|
|
64
|
-
"pid": 1234,
|
|
65
|
-
"startedAt": "2026-07-06T00:00:00.000Z",
|
|
66
|
-
"mode": "simulated",
|
|
67
|
-
"cwd": "/workspace",
|
|
68
|
-
"artifacts": "/tmp/opencode-drive/demo",
|
|
69
|
-
"endpoints": {
|
|
70
|
-
"ui": "ws://127.0.0.1:41000",
|
|
71
|
-
"backend": "ws://127.0.0.1:41001"
|
|
72
|
-
}
|
|
73
|
-
}
|
|
84
|
+
In this case, you don't care about simulation. But you still want OpenCode to see and drive your development version! This _closes the loop_ and gives direct feedback to AI.
|
|
85
|
+
|
|
86
|
+
To do this, all you have to do is pass `OPENCODE_DRIVE=1` when running opencode:
|
|
87
|
+
|
|
88
|
+
```sh
|
|
89
|
+
# In the opencode repo
|
|
90
|
+
OPENCODE_DRIVE=1 bun run dev
|
|
74
91
|
```
|
|
75
92
|
|
|
76
|
-
|
|
93
|
+
#### UI work in simulated mode
|
|
77
94
|
|
|
78
|
-
|
|
79
|
-
and server. The probe controls the real application through simulation-only
|
|
80
|
-
WebSocket interfaces while external model and filesystem state remain isolated.
|
|
95
|
+
If you are doing UI work, you may want to run it in simulated mode. You can do that with this:
|
|
81
96
|
|
|
82
|
-
|
|
97
|
+
```
|
|
98
|
+
bunx opencode-drive start --dev . --visible
|
|
99
|
+
```
|
|
83
100
|
|
|
84
|
-
|
|
101
|
+
The nice thing about this is OpenCode can drive it into the states you are interested in. Additionally, its able to restart the UI itself. The server will still be running in the background so this only restarts the UI:
|
|
85
102
|
|
|
86
|
-
```
|
|
87
|
-
|
|
88
|
-
bun install
|
|
89
|
-
bun run check
|
|
103
|
+
```sh
|
|
104
|
+
bunx opencode-drive restart
|
|
90
105
|
```
|
|
106
|
+
|
|
107
|
+
## You want opencode to develop itself
|
|
108
|
+
|
|
109
|
+
OpenCode can spawn it's own instances of OpenCode in headless mode. Use the skill in this repo to teach it about it.
|
|
110
|
+
|
|
111
|
+
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.
|
|
112
|
+
|
|
113
|
+
## You want to share reproducible steps
|
|
114
|
+
|
|
115
|
+
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)
|
|
116
|
+
|
|
117
|
+
## You want to run it a billion times and assert properties
|
|
118
|
+
|
|
119
|
+
This isn't fully implemented right now, but in the future `opencode-drive` will provide a way to specify properties that you want to assert, and run the app a billion times in many different states to make sure those properties hold. This will work with a new CLI command that takes different flags.
|
package/package.json
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "https://json.schemastore.org/package.json",
|
|
3
3
|
"name": "opencode-drive",
|
|
4
|
-
"version": "0.1.
|
|
4
|
+
"version": "0.1.3",
|
|
5
5
|
"description": "Drive real and simulated OpenCode instances",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"type": "module",
|
|
8
8
|
"packageManager": "bun@1.3.14",
|
|
9
9
|
"bin": {
|
|
10
|
-
"opencode-drive": "
|
|
10
|
+
"opencode-drive": "bin/opencode-drive"
|
|
11
11
|
},
|
|
12
12
|
"exports": {
|
|
13
13
|
".": "./src/index.ts",
|
package/src/cli/api.ts
ADDED
package/src/cli/commands.ts
CHANGED
|
@@ -1,64 +1,76 @@
|
|
|
1
|
-
import
|
|
2
|
-
import {
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
"
|
|
7
|
-
"
|
|
8
|
-
"
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
"
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
"
|
|
17
|
-
"
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
"
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
1
|
+
import { connectSimulation, Frontend } from "../client/index.js"
|
|
2
|
+
import type { DriveCommand } from "./types.js"
|
|
3
|
+
|
|
4
|
+
export const commandInfo = {
|
|
5
|
+
"ui.type": { value: true, description: "Type text using JSON params" },
|
|
6
|
+
"ui.press": { value: true, description: "Press a key using JSON params" },
|
|
7
|
+
"ui.enter": { value: false, description: "Press Enter" },
|
|
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
|
+
},
|
|
16
|
+
"ui.click": { value: true, description: "Click using JSON params" },
|
|
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
|
+
},
|
|
25
|
+
"ui.start-record": { value: false, description: "Start recording the UI" },
|
|
26
|
+
"ui.end-record": {
|
|
27
|
+
value: false,
|
|
28
|
+
description: "Stop recording and return the recording path",
|
|
29
|
+
},
|
|
30
|
+
} as const
|
|
26
31
|
|
|
27
32
|
export function commandAcceptsValue(operation: string) {
|
|
28
|
-
if (
|
|
29
|
-
if (
|
|
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
|
|
30
43
|
throw new Error(`unknown drive command "${operation}"`)
|
|
31
44
|
}
|
|
32
45
|
|
|
33
46
|
export function commandNames() {
|
|
34
|
-
return
|
|
47
|
+
return Object.keys(commandInfo).sort()
|
|
35
48
|
}
|
|
36
49
|
|
|
37
|
-
export async function executeCommands(
|
|
38
|
-
const
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
} = {}
|
|
42
|
-
const ui = async () => (clients.ui ??= await connectSimulation({ url: manifest.endpoints.ui }))
|
|
43
|
-
const backend = async () => (clients.backend ??= await connectBackendSimulation({ url: manifest.endpoints.backend }))
|
|
44
|
-
const results: Array<{ readonly command: string; readonly result: unknown }> = []
|
|
50
|
+
export async function executeCommands(endpoint: string, commands: ReadonlyArray<DriveCommand>) {
|
|
51
|
+
const ui = await connectSimulation({ url: endpoint })
|
|
52
|
+
const results: Array<{ readonly command: string; readonly result: unknown }> =
|
|
53
|
+
[]
|
|
45
54
|
try {
|
|
46
|
-
for (const command of commands)
|
|
47
|
-
results.push({
|
|
48
|
-
|
|
49
|
-
|
|
55
|
+
for (const command of commands)
|
|
56
|
+
results.push({
|
|
57
|
+
command: command.operation,
|
|
58
|
+
result: await execute(command, ui),
|
|
59
|
+
})
|
|
60
|
+
return { results }
|
|
50
61
|
} catch (error) {
|
|
51
|
-
throw new CommandBatchError(
|
|
62
|
+
throw new CommandBatchError(results, error)
|
|
52
63
|
} finally {
|
|
53
|
-
|
|
54
|
-
clients.backend?.close()
|
|
64
|
+
ui.close()
|
|
55
65
|
}
|
|
56
66
|
}
|
|
57
67
|
|
|
58
68
|
export class CommandBatchError extends Error {
|
|
59
69
|
constructor(
|
|
60
|
-
readonly
|
|
61
|
-
|
|
70
|
+
readonly results: ReadonlyArray<{
|
|
71
|
+
readonly command: string
|
|
72
|
+
readonly result: unknown
|
|
73
|
+
}>,
|
|
62
74
|
readonly reason: unknown,
|
|
63
75
|
) {
|
|
64
76
|
super(reason instanceof Error ? reason.message : String(reason))
|
|
@@ -68,108 +80,79 @@ export class CommandBatchError extends Error {
|
|
|
68
80
|
|
|
69
81
|
async function execute(
|
|
70
82
|
command: DriveCommand,
|
|
71
|
-
ui:
|
|
72
|
-
backend: () => Promise<Awaited<ReturnType<typeof connectBackendSimulation>>>,
|
|
83
|
+
ui: Awaited<ReturnType<typeof connectSimulation>>,
|
|
73
84
|
) {
|
|
74
85
|
switch (command.operation) {
|
|
75
|
-
case "
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
return (await ui()).pressKey(string(input, "key"), modifiers(input.modifiers))
|
|
86
|
+
case "ui.type": {
|
|
87
|
+
const request = Frontend.decodeRequest({
|
|
88
|
+
jsonrpc: "2.0",
|
|
89
|
+
method: "ui.type",
|
|
90
|
+
params: json(required(command)),
|
|
91
|
+
})
|
|
92
|
+
if (request.method !== "ui.type")
|
|
93
|
+
throw new Error("invalid ui.type params")
|
|
94
|
+
return ui.typeText(request.params.text)
|
|
85
95
|
}
|
|
86
|
-
case "
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
}
|
|
92
|
-
|
|
96
|
+
case "ui.press": {
|
|
97
|
+
const request = Frontend.decodeRequest({
|
|
98
|
+
jsonrpc: "2.0",
|
|
99
|
+
method: "ui.press",
|
|
100
|
+
params: json(required(command)),
|
|
101
|
+
})
|
|
102
|
+
if (request.method !== "ui.press")
|
|
103
|
+
throw new Error("invalid ui.press params")
|
|
104
|
+
return ui.pressKey(request.params.key, request.params.modifiers)
|
|
93
105
|
}
|
|
94
|
-
case "
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
106
|
+
case "ui.enter":
|
|
107
|
+
return ui.pressEnter()
|
|
108
|
+
case "ui.arrow": {
|
|
109
|
+
const request = Frontend.decodeRequest({
|
|
110
|
+
jsonrpc: "2.0",
|
|
111
|
+
method: "ui.arrow",
|
|
112
|
+
params: json(required(command)),
|
|
113
|
+
})
|
|
114
|
+
if (request.method !== "ui.arrow")
|
|
115
|
+
throw new Error("invalid ui.arrow params")
|
|
116
|
+
return ui.pressArrow(request.params.direction)
|
|
98
117
|
}
|
|
99
|
-
case "
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
118
|
+
case "ui.focus": {
|
|
119
|
+
const request = Frontend.decodeRequest({
|
|
120
|
+
jsonrpc: "2.0",
|
|
121
|
+
method: "ui.focus",
|
|
122
|
+
params: json(required(command)),
|
|
123
|
+
})
|
|
124
|
+
if (request.method !== "ui.focus")
|
|
125
|
+
throw new Error("invalid ui.focus params")
|
|
126
|
+
return ui.focus(request.params.target)
|
|
106
127
|
}
|
|
107
|
-
case "
|
|
108
|
-
const
|
|
109
|
-
|
|
110
|
-
|
|
128
|
+
case "ui.click": {
|
|
129
|
+
const request = Frontend.decodeRequest({
|
|
130
|
+
jsonrpc: "2.0",
|
|
131
|
+
method: "ui.click",
|
|
132
|
+
params: json(required(command)),
|
|
133
|
+
})
|
|
134
|
+
if (request.method !== "ui.click")
|
|
135
|
+
throw new Error("invalid ui.click params")
|
|
136
|
+
return ui.click(request.params.target, request.params.x, request.params.y)
|
|
111
137
|
}
|
|
112
|
-
case "
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
case "
|
|
117
|
-
|
|
138
|
+
case "ui.screenshot":
|
|
139
|
+
return ui.screenshot()
|
|
140
|
+
case "ui.state":
|
|
141
|
+
return ui.state()
|
|
142
|
+
case "ui.start-record":
|
|
143
|
+
return ui.startRecord()
|
|
144
|
+
case "ui.end-record":
|
|
145
|
+
return ui.endRecord()
|
|
118
146
|
}
|
|
119
147
|
throw new Error(`unknown drive command "${command.operation}"`)
|
|
120
148
|
}
|
|
121
149
|
|
|
122
150
|
function required(command: DriveCommand) {
|
|
123
|
-
if (command.value === undefined)
|
|
151
|
+
if (command.value === undefined)
|
|
152
|
+
throw new Error(`${command.operation} requires a value`)
|
|
124
153
|
return command.value
|
|
125
154
|
}
|
|
126
155
|
|
|
127
|
-
function
|
|
128
|
-
|
|
129
|
-
if (!isRecord(parsed)) throw new Error("command value must be a JSON object")
|
|
130
|
-
return parsed
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
function scalarOrObject(value: string, key: string) {
|
|
134
|
-
return value.trim().startsWith("{") ? object(value) : { [key]: value }
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
function string(input: Record<string, unknown>, key: string) {
|
|
138
|
-
const value = input[key]
|
|
139
|
-
if (typeof value !== "string") throw new Error(`${key} must be a string`)
|
|
140
|
-
return value
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
function number(value: unknown, name: string) {
|
|
144
|
-
const parsed = typeof value === "number" ? value : Number(value)
|
|
145
|
-
if (!Number.isFinite(parsed)) throw new Error(`${name} must be a number`)
|
|
146
|
-
return parsed
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
function modifiers(value: unknown): KeyModifiers | undefined {
|
|
150
|
-
if (value === undefined) return undefined
|
|
151
|
-
if (!isRecord(value)) throw new Error("modifiers must be an object")
|
|
152
|
-
return {
|
|
153
|
-
ctrl: optionalBoolean(value.ctrl, "ctrl"),
|
|
154
|
-
shift: optionalBoolean(value.shift, "shift"),
|
|
155
|
-
meta: optionalBoolean(value.meta, "meta"),
|
|
156
|
-
super: optionalBoolean(value.super, "super"),
|
|
157
|
-
hyper: optionalBoolean(value.hyper, "hyper"),
|
|
158
|
-
}
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
function finishReason(value: unknown): BackendFinishReason | undefined {
|
|
162
|
-
if (value === undefined) return undefined
|
|
163
|
-
if (value === "stop" || value === "tool-calls" || value === "length" || value === "content-filter") return value
|
|
164
|
-
throw new Error("reason must be stop, tool-calls, length, or content-filter")
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
168
|
-
return typeof value === "object" && value !== null && !Array.isArray(value)
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
function optionalBoolean(value: unknown, name: string) {
|
|
172
|
-
if (value === undefined) return undefined
|
|
173
|
-
if (typeof value !== "boolean") throw new Error(`${name} must be a boolean`)
|
|
174
|
-
return value
|
|
156
|
+
function json(value: string): unknown {
|
|
157
|
+
return JSON.parse(value)
|
|
175
158
|
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { rm } from "node:fs/promises"
|
|
2
|
+
import { connect, createServer } from "node:net"
|
|
3
|
+
|
|
4
|
+
export async function listenControl(
|
|
5
|
+
path: string,
|
|
6
|
+
handlers: { readonly restart: () => Promise<void>; readonly stop: () => Promise<void> },
|
|
7
|
+
) {
|
|
8
|
+
const server = createServer((socket) => {
|
|
9
|
+
socket.setEncoding("utf8")
|
|
10
|
+
socket.once("data", (data) => {
|
|
11
|
+
const handler = handlers[String(data).trim() as keyof typeof handlers]
|
|
12
|
+
socket.destroy()
|
|
13
|
+
if (!handler) return
|
|
14
|
+
void handler().catch((error) => {
|
|
15
|
+
console.error(`error: ${error instanceof Error ? error.message : String(error)}`)
|
|
16
|
+
})
|
|
17
|
+
})
|
|
18
|
+
})
|
|
19
|
+
await listen(server, path)
|
|
20
|
+
return async () => {
|
|
21
|
+
await new Promise<void>((resolve) => server.close(() => resolve()))
|
|
22
|
+
await rm(path, { force: true })
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function request(path: string, command: "restart" | "stop") {
|
|
27
|
+
return new Promise<void>((resolve, reject) => {
|
|
28
|
+
const socket = connect(path)
|
|
29
|
+
socket.on("connect", () => socket.end(`${command}\n`, resolve))
|
|
30
|
+
socket.on("error", () => reject(new Error("instance control socket is unavailable")))
|
|
31
|
+
})
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async function listen(server: ReturnType<typeof createServer>, path: string) {
|
|
35
|
+
await rm(path, { force: true })
|
|
36
|
+
await new Promise<void>((resolve, reject) => {
|
|
37
|
+
server.once("error", reject)
|
|
38
|
+
server.listen(path, () => {
|
|
39
|
+
server.off("error", reject)
|
|
40
|
+
resolve()
|
|
41
|
+
})
|
|
42
|
+
})
|
|
43
|
+
}
|
package/src/cli/describe.ts
CHANGED
|
@@ -1,14 +1,13 @@
|
|
|
1
|
-
import { join } from "node:path"
|
|
2
1
|
import { resolveInstance } from "./registry.js"
|
|
3
2
|
|
|
4
|
-
export async function describe(name
|
|
5
|
-
const manifest = await resolveInstance(name
|
|
3
|
+
export async function describe(name: string) {
|
|
4
|
+
const manifest = await resolveInstance(name)
|
|
6
5
|
console.log([
|
|
7
6
|
`PID: ${manifest.pid}`,
|
|
8
|
-
`
|
|
7
|
+
`Visible: ${manifest.visible}`,
|
|
9
8
|
`Artifacts: ${manifest.artifacts}`,
|
|
10
9
|
`UI: ${manifest.endpoints.ui}`,
|
|
11
10
|
`Backend: ${manifest.endpoints.backend}`,
|
|
12
|
-
`Logs: ${
|
|
11
|
+
`Logs: ${manifest.artifacts}/logs`,
|
|
13
12
|
].join("\n"))
|
|
14
13
|
}
|