opencode-telegram-mirror 0.3.0
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/AGENTS.md +196 -0
- package/README.md +230 -0
- package/bun.lock +67 -0
- package/package.json +27 -0
- package/src/config.ts +99 -0
- package/src/database.ts +120 -0
- package/src/diff-service.ts +176 -0
- package/src/log.ts +23 -0
- package/src/main.ts +1182 -0
- package/src/message-formatting.ts +202 -0
- package/src/opencode.ts +306 -0
- package/src/permission-handler.ts +242 -0
- package/src/question-handler.ts +391 -0
- package/src/system-message.ts +73 -0
- package/src/telegram.ts +705 -0
- package/test/fixtures/commands-test.json +157 -0
- package/test/fixtures/sample-updates.json +9098 -0
- package/test/mock-server.ts +271 -0
- package/test/run-test.ts +160 -0
- package/tsconfig.json +26 -0
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Message formatting for OpenCode parts
|
|
3
|
+
* Matches kimaki's Discord formatting style
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { Part } from "@opencode-ai/sdk/v2"
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Escapes Telegram markdown special characters
|
|
10
|
+
*/
|
|
11
|
+
function escapeMarkdown(text: string): string {
|
|
12
|
+
return text.replace(/([*_`\[\]])/g, "\\$1")
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Get tool summary text (file names, patterns, etc.)
|
|
17
|
+
*/
|
|
18
|
+
function getToolSummaryText(part: Part): string {
|
|
19
|
+
if (part.type !== "tool") return ""
|
|
20
|
+
|
|
21
|
+
const input = part.state.input ?? {}
|
|
22
|
+
|
|
23
|
+
if (part.tool === "edit") {
|
|
24
|
+
const filePath = (input.filePath as string) || ""
|
|
25
|
+
const newString = (input.newString as string) || ""
|
|
26
|
+
const oldString = (input.oldString as string) || ""
|
|
27
|
+
const added = newString.split("\n").length
|
|
28
|
+
const removed = oldString.split("\n").length
|
|
29
|
+
const fileName = filePath.split("/").pop() || ""
|
|
30
|
+
return fileName ? `*${escapeMarkdown(fileName)}* (+${added}-${removed})` : `(+${added}-${removed})`
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (part.tool === "write") {
|
|
34
|
+
const filePath = (input.filePath as string) || ""
|
|
35
|
+
const content = (input.content as string) || ""
|
|
36
|
+
const lines = content.split("\n").length
|
|
37
|
+
const fileName = filePath.split("/").pop() || ""
|
|
38
|
+
return fileName ? `*${escapeMarkdown(fileName)}* (${lines} line${lines === 1 ? "" : "s"})` : `(${lines} line${lines === 1 ? "" : "s"})`
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (part.tool === "webfetch") {
|
|
42
|
+
const url = (input.url as string) || ""
|
|
43
|
+
const urlWithoutProtocol = url.replace(/^https?:\/\//, "")
|
|
44
|
+
return urlWithoutProtocol ? `*${escapeMarkdown(urlWithoutProtocol)}*` : ""
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (part.tool === "read") {
|
|
48
|
+
const filePath = (input.filePath as string) || ""
|
|
49
|
+
const fileName = filePath.split("/").pop() || ""
|
|
50
|
+
return fileName ? `*${escapeMarkdown(fileName)}*` : ""
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (part.tool === "glob") {
|
|
54
|
+
const pattern = (input.pattern as string) || ""
|
|
55
|
+
return pattern ? `*${escapeMarkdown(pattern)}*` : ""
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (part.tool === "grep") {
|
|
59
|
+
const pattern = (input.pattern as string) || ""
|
|
60
|
+
return pattern ? `*${escapeMarkdown(pattern)}*` : ""
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (part.tool === "bash" || part.tool === "todoread" || part.tool === "todowrite") {
|
|
64
|
+
return ""
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (part.tool === "task") {
|
|
68
|
+
const description = (input.description as string) || ""
|
|
69
|
+
return description ? `_${escapeMarkdown(description)}_` : ""
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return ""
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Status indicators for todo items
|
|
77
|
+
*/
|
|
78
|
+
const TODO_STATUS_ICONS: Record<string, string> = {
|
|
79
|
+
pending: "○",
|
|
80
|
+
in_progress: "◉",
|
|
81
|
+
completed: "✓",
|
|
82
|
+
cancelled: "✗",
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Format todo list from todowrite tool
|
|
87
|
+
* Shows all todos with status indicators
|
|
88
|
+
*/
|
|
89
|
+
function formatTodoList(part: Part): string {
|
|
90
|
+
if (part.type !== "tool" || part.tool !== "todowrite") return ""
|
|
91
|
+
|
|
92
|
+
const todos = (part.state.input?.todos as Array<{
|
|
93
|
+
content: string
|
|
94
|
+
status: "pending" | "in_progress" | "completed" | "cancelled"
|
|
95
|
+
priority?: "high" | "medium" | "low"
|
|
96
|
+
}>) ?? []
|
|
97
|
+
|
|
98
|
+
if (todos.length === 0) return ""
|
|
99
|
+
|
|
100
|
+
const lines: string[] = []
|
|
101
|
+
|
|
102
|
+
for (const todo of todos) {
|
|
103
|
+
const icon = TODO_STATUS_ICONS[todo.status] || "○"
|
|
104
|
+
const content = todo.content
|
|
105
|
+
|
|
106
|
+
// Format based on status
|
|
107
|
+
let formatted: string
|
|
108
|
+
if (todo.status === "in_progress") {
|
|
109
|
+
// Active item: bold
|
|
110
|
+
formatted = `${icon} *${escapeMarkdown(content)}*`
|
|
111
|
+
} else if (todo.status === "completed") {
|
|
112
|
+
// Completed: strikethrough
|
|
113
|
+
formatted = `${icon} ~${escapeMarkdown(content)}~`
|
|
114
|
+
} else if (todo.status === "cancelled") {
|
|
115
|
+
// Cancelled: strikethrough + italic
|
|
116
|
+
formatted = `${icon} ~_${escapeMarkdown(content)}_~`
|
|
117
|
+
} else {
|
|
118
|
+
// Pending: plain
|
|
119
|
+
formatted = `${icon} ${escapeMarkdown(content)}`
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
lines.push(formatted)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return lines.join("\n")
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Format a single part for Telegram display
|
|
130
|
+
* Matches kimaki's formatting style
|
|
131
|
+
*/
|
|
132
|
+
export function formatPart(part: Part): string {
|
|
133
|
+
if (part.type === "text") {
|
|
134
|
+
if (!part.text?.trim()) return ""
|
|
135
|
+
return part.text
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (part.type === "reasoning") {
|
|
139
|
+
if (!part.text?.trim()) return ""
|
|
140
|
+
return "> thinking"
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (part.type === "file") {
|
|
144
|
+
return `[file] ${part.filename || "File"}`
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
if (part.type === "step-start" || part.type === "step-finish" || part.type === "patch") {
|
|
148
|
+
return ""
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (part.type === "agent") {
|
|
152
|
+
return `> agent ${part.id}`
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
if (part.type === "tool") {
|
|
156
|
+
if (part.tool === "todowrite") {
|
|
157
|
+
return formatTodoList(part)
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Question tool is handled via buttons, not text
|
|
161
|
+
if (part.tool === "question") {
|
|
162
|
+
return ""
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (part.state.status === "pending") {
|
|
166
|
+
return ""
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const summaryText = getToolSummaryText(part)
|
|
170
|
+
const stateTitle = "title" in part.state ? part.state.title : undefined
|
|
171
|
+
|
|
172
|
+
let toolTitle = ""
|
|
173
|
+
if (part.state.status === "error") {
|
|
174
|
+
toolTitle = part.state.error || "error"
|
|
175
|
+
} else if (part.tool === "bash") {
|
|
176
|
+
const command = (part.state.input?.command as string) || ""
|
|
177
|
+
const description = (part.state.input?.description as string) || ""
|
|
178
|
+
const isSingleLine = !command.includes("\n")
|
|
179
|
+
if (isSingleLine && command.length <= 50) {
|
|
180
|
+
toolTitle = `_${escapeMarkdown(command)}_`
|
|
181
|
+
} else if (description) {
|
|
182
|
+
toolTitle = `_${escapeMarkdown(description)}_`
|
|
183
|
+
} else if (stateTitle) {
|
|
184
|
+
toolTitle = `_${escapeMarkdown(stateTitle as string)}_`
|
|
185
|
+
}
|
|
186
|
+
} else if (stateTitle) {
|
|
187
|
+
toolTitle = `_${escapeMarkdown(stateTitle as string)}_`
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const icon = (() => {
|
|
191
|
+
if (part.state.status === "error") return "X"
|
|
192
|
+
if (part.tool === "edit" || part.tool === "write") return ">"
|
|
193
|
+
return ">"
|
|
194
|
+
})()
|
|
195
|
+
|
|
196
|
+
return `${icon} ${part.tool} ${toolTitle} ${summaryText}`.trim()
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
return ""
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export type { Part }
|
package/src/opencode.ts
ADDED
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OpenCode server process manager.
|
|
3
|
+
* Spawns and maintains a single OpenCode API server.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { spawn, type ChildProcess } from "node:child_process"
|
|
7
|
+
import fs from "node:fs"
|
|
8
|
+
import net from "node:net"
|
|
9
|
+
import {
|
|
10
|
+
createOpencodeClient,
|
|
11
|
+
type OpencodeClient,
|
|
12
|
+
type Config,
|
|
13
|
+
} from "@opencode-ai/sdk"
|
|
14
|
+
import {
|
|
15
|
+
createOpencodeClient as createOpencodeClientV2,
|
|
16
|
+
type OpencodeClient as OpencodeClientV2,
|
|
17
|
+
} from "@opencode-ai/sdk/v2"
|
|
18
|
+
import { Result, TaggedError } from "better-result"
|
|
19
|
+
import { createLogger } from "./log"
|
|
20
|
+
|
|
21
|
+
const log = createLogger()
|
|
22
|
+
|
|
23
|
+
export interface OpenCodeServer {
|
|
24
|
+
process: ChildProcess | null // null when connecting to external server
|
|
25
|
+
client: OpencodeClient
|
|
26
|
+
clientV2: OpencodeClientV2
|
|
27
|
+
port: number
|
|
28
|
+
directory: string
|
|
29
|
+
baseUrl: string
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export class PortLookupError extends TaggedError("PortLookupError")<{
|
|
33
|
+
message: string
|
|
34
|
+
cause: unknown
|
|
35
|
+
}>() {
|
|
36
|
+
constructor(args: { cause: unknown }) {
|
|
37
|
+
const causeMessage = args.cause instanceof Error ? args.cause.message : String(args.cause)
|
|
38
|
+
super({ ...args, message: `Failed to get open port: ${causeMessage}` })
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export class ServerStartError extends TaggedError("ServerStartError")<{
|
|
43
|
+
message: string
|
|
44
|
+
cause: unknown
|
|
45
|
+
}>() {
|
|
46
|
+
constructor(args: { cause: unknown }) {
|
|
47
|
+
const causeMessage = args.cause instanceof Error ? args.cause.message : String(args.cause)
|
|
48
|
+
super({ ...args, message: `Server failed to start: ${causeMessage}` })
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export class DirectoryAccessError extends TaggedError("DirectoryAccessError")<{
|
|
53
|
+
message: string
|
|
54
|
+
directory: string
|
|
55
|
+
cause: unknown
|
|
56
|
+
}>() {
|
|
57
|
+
constructor(args: { directory: string; cause: unknown }) {
|
|
58
|
+
const causeMessage = args.cause instanceof Error ? args.cause.message : String(args.cause)
|
|
59
|
+
super({ ...args, message: `Directory not accessible: ${args.directory} (${causeMessage})` })
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
let server: OpenCodeServer | null = null
|
|
64
|
+
|
|
65
|
+
async function getOpenPort(): Promise<Result<number, PortLookupError>> {
|
|
66
|
+
return Result.tryPromise({
|
|
67
|
+
try: () =>
|
|
68
|
+
new Promise<number>((resolve, reject) => {
|
|
69
|
+
const srv = net.createServer()
|
|
70
|
+
srv.listen(0, () => {
|
|
71
|
+
const address = srv.address()
|
|
72
|
+
if (address && typeof address === "object") {
|
|
73
|
+
const port = address.port
|
|
74
|
+
srv.close(() => resolve(port))
|
|
75
|
+
} else {
|
|
76
|
+
reject(new Error("Failed to get port"))
|
|
77
|
+
}
|
|
78
|
+
})
|
|
79
|
+
srv.on("error", reject)
|
|
80
|
+
}),
|
|
81
|
+
catch: (error) => new PortLookupError({ cause: error }),
|
|
82
|
+
})
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async function waitForServer(
|
|
86
|
+
port: number,
|
|
87
|
+
maxAttempts = 30,
|
|
88
|
+
baseUrl?: string
|
|
89
|
+
): Promise<Result<boolean, ServerStartError>> {
|
|
90
|
+
const url = baseUrl || `http://127.0.0.1:${port}`
|
|
91
|
+
|
|
92
|
+
for (let i = 0; i < maxAttempts; i++) {
|
|
93
|
+
const responseResult = await Result.tryPromise({
|
|
94
|
+
try: () =>
|
|
95
|
+
fetch(`${url}/session`, {
|
|
96
|
+
signal: AbortSignal.timeout(2000),
|
|
97
|
+
}),
|
|
98
|
+
catch: (error) => new ServerStartError({ cause: error }),
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
if (responseResult.status === "ok") {
|
|
102
|
+
if (responseResult.value.status < 500) {
|
|
103
|
+
return Result.ok(true)
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
await new Promise((r) => setTimeout(r, 1000))
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return Result.err(
|
|
111
|
+
new ServerStartError({
|
|
112
|
+
cause: new Error(`Server did not start at ${url} after ${maxAttempts} seconds`),
|
|
113
|
+
})
|
|
114
|
+
)
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Connect to an already-running OpenCode server
|
|
119
|
+
*/
|
|
120
|
+
export async function connectToServer(
|
|
121
|
+
baseUrl: string,
|
|
122
|
+
directory: string
|
|
123
|
+
): Promise<Result<OpenCodeServer, ServerStartError>> {
|
|
124
|
+
// Reuse existing server if connected to same URL
|
|
125
|
+
if (server && server.baseUrl === baseUrl) {
|
|
126
|
+
log("info", "Reusing existing connection", { baseUrl })
|
|
127
|
+
return Result.ok(server)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
log("info", "Connecting to external OpenCode server", { baseUrl })
|
|
131
|
+
|
|
132
|
+
// Extract port from URL
|
|
133
|
+
const url = new URL(baseUrl)
|
|
134
|
+
const port = Number(url.port) || (url.protocol === "https:" ? 443 : 80)
|
|
135
|
+
|
|
136
|
+
// Wait for server to be ready
|
|
137
|
+
const readyResult = await waitForServer(port, 30, baseUrl)
|
|
138
|
+
if (readyResult.status === "error") {
|
|
139
|
+
return Result.err(readyResult.error)
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
log("info", "External server ready", { baseUrl })
|
|
143
|
+
|
|
144
|
+
const fetchWithTimeout = (request: Request) =>
|
|
145
|
+
fetch(request, {
|
|
146
|
+
// @ts-ignore - bun supports timeout
|
|
147
|
+
timeout: false,
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
const client = createOpencodeClient({
|
|
151
|
+
baseUrl,
|
|
152
|
+
fetch: fetchWithTimeout,
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
const clientV2 = createOpencodeClientV2({
|
|
156
|
+
baseUrl,
|
|
157
|
+
fetch: fetchWithTimeout as typeof fetch,
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
server = {
|
|
161
|
+
process: null, // No process - external server
|
|
162
|
+
client,
|
|
163
|
+
clientV2,
|
|
164
|
+
port,
|
|
165
|
+
directory,
|
|
166
|
+
baseUrl,
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return Result.ok(server)
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export async function startServer(
|
|
173
|
+
directory: string
|
|
174
|
+
): Promise<Result<OpenCodeServer, DirectoryAccessError | PortLookupError | ServerStartError>> {
|
|
175
|
+
// Reuse existing server if running
|
|
176
|
+
if (server?.process && !server.process.killed) {
|
|
177
|
+
log("info", "Reusing existing server", { directory, port: server.port })
|
|
178
|
+
return Result.ok(server)
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Verify directory exists
|
|
182
|
+
const accessResult = Result.try({
|
|
183
|
+
try: () => fs.accessSync(directory, fs.constants.R_OK | fs.constants.X_OK),
|
|
184
|
+
catch: (error) => new DirectoryAccessError({ directory, cause: error }),
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
if (accessResult.status === "error") {
|
|
188
|
+
return Result.err(accessResult.error)
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const envPort = process.env.OPENCODE_PORT
|
|
192
|
+
const parsedPort = envPort ? Number(envPort) : null
|
|
193
|
+
const portResult = parsedPort && !Number.isNaN(parsedPort) ? Result.ok(parsedPort) : await getOpenPort()
|
|
194
|
+
|
|
195
|
+
if (portResult.status === "error") {
|
|
196
|
+
return Result.err(portResult.error)
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const port = portResult.value
|
|
200
|
+
const opencodePath = process.env.OPENCODE_PATH || `${process.env.HOME}/.opencode/bin/opencode`
|
|
201
|
+
|
|
202
|
+
log("info", "Starting opencode serve", { directory, port })
|
|
203
|
+
|
|
204
|
+
const serverProcess = spawn(opencodePath, ["serve", "--port", port.toString()], {
|
|
205
|
+
stdio: "pipe",
|
|
206
|
+
detached: false,
|
|
207
|
+
cwd: directory,
|
|
208
|
+
env: {
|
|
209
|
+
...process.env,
|
|
210
|
+
OPENCODE_CONFIG_CONTENT: JSON.stringify({
|
|
211
|
+
$schema: "https://opencode.ai/config.json",
|
|
212
|
+
lsp: false,
|
|
213
|
+
formatter: false,
|
|
214
|
+
permission: {
|
|
215
|
+
edit: "allow",
|
|
216
|
+
bash: "allow",
|
|
217
|
+
webfetch: "allow",
|
|
218
|
+
},
|
|
219
|
+
} satisfies Config),
|
|
220
|
+
},
|
|
221
|
+
})
|
|
222
|
+
|
|
223
|
+
serverProcess.stdout?.on("data", (data) => {
|
|
224
|
+
log("debug", "opencode stdout", { data: data.toString().trim().slice(0, 200) })
|
|
225
|
+
})
|
|
226
|
+
|
|
227
|
+
serverProcess.stderr?.on("data", (data) => {
|
|
228
|
+
log("debug", "opencode stderr", { data: data.toString().trim().slice(0, 200) })
|
|
229
|
+
})
|
|
230
|
+
|
|
231
|
+
serverProcess.on("error", (error) => {
|
|
232
|
+
log("error", "Server process error", { directory, error: String(error) })
|
|
233
|
+
})
|
|
234
|
+
|
|
235
|
+
serverProcess.on("exit", (code) => {
|
|
236
|
+
log("info", "Server exited", { directory, code })
|
|
237
|
+
server = null
|
|
238
|
+
|
|
239
|
+
if (code !== 0) {
|
|
240
|
+
log("info", "Restarting server", { directory })
|
|
241
|
+
startServer(directory).then((result) => {
|
|
242
|
+
if (result.status === "error") {
|
|
243
|
+
log("error", "Failed to restart server", { error: result.error.message })
|
|
244
|
+
}
|
|
245
|
+
})
|
|
246
|
+
}
|
|
247
|
+
})
|
|
248
|
+
|
|
249
|
+
const readyResult = await waitForServer(port)
|
|
250
|
+
if (readyResult.status === "error") {
|
|
251
|
+
return Result.err(readyResult.error)
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
log("info", "Server ready", { directory, port })
|
|
255
|
+
|
|
256
|
+
const baseUrl = `http://127.0.0.1:${port}`
|
|
257
|
+
const fetchWithTimeout = (request: Request) =>
|
|
258
|
+
fetch(request, {
|
|
259
|
+
// @ts-ignore - bun supports timeout
|
|
260
|
+
timeout: false,
|
|
261
|
+
})
|
|
262
|
+
|
|
263
|
+
const client = createOpencodeClient({
|
|
264
|
+
baseUrl,
|
|
265
|
+
fetch: fetchWithTimeout,
|
|
266
|
+
})
|
|
267
|
+
|
|
268
|
+
const clientV2 = createOpencodeClientV2({
|
|
269
|
+
baseUrl,
|
|
270
|
+
fetch: fetchWithTimeout as typeof fetch,
|
|
271
|
+
})
|
|
272
|
+
|
|
273
|
+
server = {
|
|
274
|
+
process: serverProcess,
|
|
275
|
+
client,
|
|
276
|
+
clientV2,
|
|
277
|
+
port,
|
|
278
|
+
directory,
|
|
279
|
+
baseUrl,
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
return Result.ok(server)
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
export function getServer(): OpenCodeServer | null {
|
|
286
|
+
return server
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
export async function stopServer(): Promise<Result<void, ServerStartError>> {
|
|
290
|
+
if (!server) {
|
|
291
|
+
return Result.ok(undefined)
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
const serverToStop = server
|
|
295
|
+
|
|
296
|
+
const stopResult = Result.try({
|
|
297
|
+
try: () => {
|
|
298
|
+
serverToStop.process?.kill()
|
|
299
|
+
log("info", "Server stopped", { directory: serverToStop.directory })
|
|
300
|
+
server = null
|
|
301
|
+
},
|
|
302
|
+
catch: (error) => new ServerStartError({ cause: error }),
|
|
303
|
+
})
|
|
304
|
+
|
|
305
|
+
return stopResult.map(() => undefined)
|
|
306
|
+
}
|