feat(client): self-contained local service discovery and lifecycle
This commit is contained in:
parent
b6c3a8ea7b
commit
1de3c6e4a6
27 changed files with 535 additions and 480 deletions
|
|
@ -14,6 +14,10 @@ export const Commands = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCO
|
||||||
Flag.withDescription("Run with a private server instead of the background service"),
|
Flag.withDescription("Run with a private server instead of the background service"),
|
||||||
Flag.withDefault(false),
|
Flag.withDefault(false),
|
||||||
),
|
),
|
||||||
|
server: Flag.string("server").pipe(
|
||||||
|
Flag.withDescription("Connect to a server URL instead of the background service"),
|
||||||
|
Flag.optional,
|
||||||
|
),
|
||||||
continue: Flag.boolean("continue").pipe(
|
continue: Flag.boolean("continue").pipe(
|
||||||
Flag.withAlias("c"),
|
Flag.withAlias("c"),
|
||||||
Flag.withDescription("Continue the last session"),
|
Flag.withDescription("Continue the last session"),
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,8 @@ import { EOL } from "node:os"
|
||||||
import { Effect, Option } from "effect"
|
import { Effect, Option } from "effect"
|
||||||
import { Commands } from "../commands"
|
import { Commands } from "../commands"
|
||||||
import { Runtime } from "../../framework/runtime"
|
import { Runtime } from "../../framework/runtime"
|
||||||
import { Daemon } from "../../services/daemon"
|
import { Service } from "../../services/service"
|
||||||
|
import type { Transport } from "@opencode-ai/client/service"
|
||||||
|
|
||||||
const methods = new Set(["delete", "get", "head", "options", "patch", "post", "put"])
|
const methods = new Set(["delete", "get", "head", "options", "patch", "post", "put"])
|
||||||
|
|
||||||
|
|
@ -17,8 +18,7 @@ type OpenApi = {
|
||||||
export default Runtime.handler(
|
export default Runtime.handler(
|
||||||
Commands.commands.api,
|
Commands.commands.api,
|
||||||
Effect.fn("cli.api")(function* (input) {
|
Effect.fn("cli.api")(function* (input) {
|
||||||
const daemon = yield* Daemon.Service
|
const transport = yield* Service.connect()
|
||||||
const transport = yield* daemon.transport()
|
|
||||||
const params = Option.getOrElse(input.param, () => ({}))
|
const params = Option.getOrElse(input.param, () => ({}))
|
||||||
const request = yield* resolveRequest(transport, input.request, params)
|
const request = yield* resolveRequest(transport, input.request, params)
|
||||||
const headers = new Headers(transport.headers)
|
const headers = new Headers(transport.headers)
|
||||||
|
|
@ -58,7 +58,7 @@ export function rawRequest(input: readonly string[]) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveRequest(
|
function resolveRequest(
|
||||||
transport: { url: string; headers: RequestInit["headers"] },
|
transport: Transport,
|
||||||
input: readonly string[],
|
input: readonly string[],
|
||||||
params: Record<string, string>,
|
params: Record<string, string>,
|
||||||
) {
|
) {
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,15 @@
|
||||||
import { EOL } from "os"
|
import { EOL } from "os"
|
||||||
import * as Effect from "effect/Effect"
|
import * as Effect from "effect/Effect"
|
||||||
|
import { createOpencodeClient } from "@opencode-ai/sdk/v2/client"
|
||||||
import { Commands } from "../../commands"
|
import { Commands } from "../../commands"
|
||||||
import { Runtime } from "../../../framework/runtime"
|
import { Runtime } from "../../../framework/runtime"
|
||||||
import { Daemon } from "../../../services/daemon"
|
import { Service } from "../../../services/service"
|
||||||
|
|
||||||
export default Runtime.handler(
|
export default Runtime.handler(
|
||||||
Commands.commands.debug.commands.agents,
|
Commands.commands.debug.commands.agents,
|
||||||
Effect.fn("cli.debug.agents")(function* () {
|
Effect.fn("cli.debug.agents")(function* () {
|
||||||
const daemon = yield* Daemon.Service
|
const transport = yield* Service.connect()
|
||||||
const client = yield* daemon.client()
|
const client = createOpencodeClient({ baseUrl: transport.url, headers: transport.headers })
|
||||||
const response = yield* Effect.promise(() => client.v2.agent.list({ location: { directory: process.cwd() } }))
|
const response = yield* Effect.promise(() => client.v2.agent.list({ location: { directory: process.cwd() } }))
|
||||||
process.stdout.write(
|
process.stdout.write(
|
||||||
JSON.stringify(
|
JSON.stringify(
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,12 @@
|
||||||
import { Commands } from "../commands"
|
import { Commands } from "../commands"
|
||||||
import { Runtime } from "../../framework/runtime"
|
import { Runtime } from "../../framework/runtime"
|
||||||
import { Effect, Option } from "effect"
|
import { Global } from "@opencode-ai/core/global"
|
||||||
import { Daemon } from "../../services/daemon"
|
import { Effect, FileSystem, Option } from "effect"
|
||||||
|
import { Service } from "../../services/service"
|
||||||
import { Standalone } from "../../services/standalone"
|
import { Standalone } from "../../services/standalone"
|
||||||
import { Updater } from "../../services/updater"
|
import { Updater } from "../../services/updater"
|
||||||
|
import { basicAuth } from "@opencode-ai/client/service"
|
||||||
|
import type { Transport } from "@opencode-ai/client/service"
|
||||||
|
|
||||||
export default Runtime.handler(Commands, (input) =>
|
export default Runtime.handler(Commands, (input) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
|
|
@ -11,18 +14,32 @@ export default Runtime.handler(Commands, (input) =>
|
||||||
if (directory !== undefined) process.chdir(directory)
|
if (directory !== undefined) process.chdir(directory)
|
||||||
const updater = yield* Updater.Service
|
const updater = yield* Updater.Service
|
||||||
yield* updater.check().pipe(Effect.forkScoped)
|
yield* updater.check().pipe(Effect.forkScoped)
|
||||||
const daemon = yield* Daemon.Service
|
const server = Option.getOrUndefined(input.server)
|
||||||
const transport = yield* (input.standalone ? Standalone.transport() : daemon.transport())
|
if (server !== undefined && input.standalone)
|
||||||
|
return yield* Effect.fail(new Error("--server and --standalone cannot be combined"))
|
||||||
|
const transport = yield* resolveTransport(server, input.standalone)
|
||||||
const { runTui } = yield* Effect.promise(() => import("../../tui"))
|
const { runTui } = yield* Effect.promise(() => import("../../tui"))
|
||||||
yield* runTui(
|
// The TUI re-runs discover whenever its event stream drops. For an explicit
|
||||||
transport,
|
// --server or a standalone child the transport is fixed, so reconnects
|
||||||
{ continue: input.continue, sessionID: Option.getOrUndefined(input.session) },
|
// retry the same address; for the managed service discovery re-reads the
|
||||||
input.standalone
|
// registration and may start a replacement.
|
||||||
? undefined
|
const context = yield* Effect.context<FileSystem.FileSystem | Global.Service>()
|
||||||
: async () => {
|
const discover =
|
||||||
await Effect.runPromise(daemon.stop())
|
server !== undefined || input.standalone
|
||||||
return Effect.runPromise(daemon.transport())
|
? () => Promise.resolve(transport)
|
||||||
},
|
: () => Effect.runPromise(Service.connect().pipe(Effect.provide(context)))
|
||||||
)
|
yield* runTui(transport, { continue: input.continue, sessionID: Option.getOrUndefined(input.session) }, discover)
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
function resolveTransport(server: string | undefined, standalone: boolean) {
|
||||||
|
if (server !== undefined) {
|
||||||
|
const password = process.env["OPENCODE_SERVER_PASSWORD"]
|
||||||
|
return Effect.succeed({
|
||||||
|
url: server,
|
||||||
|
headers: password ? basicAuth(password) : undefined,
|
||||||
|
} satisfies Transport)
|
||||||
|
}
|
||||||
|
if (standalone) return Standalone.transport()
|
||||||
|
return Service.connect()
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,14 @@
|
||||||
import { EOL } from "node:os"
|
import { EOL } from "node:os"
|
||||||
import { Effect } from "effect"
|
import { Effect } from "effect"
|
||||||
import type { IntegrationAttemptStatus, IntegrationOAuthMethod, OpencodeClient } from "@opencode-ai/sdk/v2/client"
|
import {
|
||||||
|
createOpencodeClient,
|
||||||
|
type IntegrationAttemptStatus,
|
||||||
|
type IntegrationOAuthMethod,
|
||||||
|
type OpencodeClient,
|
||||||
|
} from "@opencode-ai/sdk/v2/client"
|
||||||
import { Commands } from "../../commands"
|
import { Commands } from "../../commands"
|
||||||
import { Runtime } from "../../../framework/runtime"
|
import { Runtime } from "../../../framework/runtime"
|
||||||
import { Daemon } from "../../../services/daemon"
|
import { Service } from "../../../services/service"
|
||||||
import { resolveIntegration } from "./resolve"
|
import { resolveIntegration } from "./resolve"
|
||||||
|
|
||||||
const location = { directory: process.cwd() }
|
const location = { directory: process.cwd() }
|
||||||
|
|
@ -11,8 +16,8 @@ const location = { directory: process.cwd() }
|
||||||
export default Runtime.handler(
|
export default Runtime.handler(
|
||||||
Commands.commands.mcp.commands.auth,
|
Commands.commands.mcp.commands.auth,
|
||||||
Effect.fn("cli.mcp.auth")(function* (input) {
|
Effect.fn("cli.mcp.auth")(function* (input) {
|
||||||
const daemon = yield* Daemon.Service
|
const transport = yield* Service.connect()
|
||||||
const client = yield* daemon.client()
|
const client = createOpencodeClient({ baseUrl: transport.url, headers: transport.headers })
|
||||||
|
|
||||||
const integration = yield* resolveIntegration(client, input.name, location)
|
const integration = yield* resolveIntegration(client, input.name, location)
|
||||||
if (!integration)
|
if (!integration)
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,15 @@
|
||||||
import { EOL } from "node:os"
|
import { EOL } from "node:os"
|
||||||
import * as Effect from "effect/Effect"
|
import * as Effect from "effect/Effect"
|
||||||
import type { McpServer } from "@opencode-ai/sdk/v2/client"
|
import { createOpencodeClient, type McpServer } from "@opencode-ai/sdk/v2/client"
|
||||||
import { Commands } from "../../commands"
|
import { Commands } from "../../commands"
|
||||||
import { Runtime } from "../../../framework/runtime"
|
import { Runtime } from "../../../framework/runtime"
|
||||||
import { Daemon } from "../../../services/daemon"
|
import { Service } from "../../../services/service"
|
||||||
|
|
||||||
export default Runtime.handler(
|
export default Runtime.handler(
|
||||||
Commands.commands.mcp.commands.list,
|
Commands.commands.mcp.commands.list,
|
||||||
Effect.fn("cli.mcp.list")(function* () {
|
Effect.fn("cli.mcp.list")(function* () {
|
||||||
const daemon = yield* Daemon.Service
|
const transport = yield* Service.connect()
|
||||||
const client = yield* daemon.client()
|
const client = createOpencodeClient({ baseUrl: transport.url, headers: transport.headers })
|
||||||
const response = yield* Effect.promise(() => client.v2.mcp.list({ location: { directory: process.cwd() } }))
|
const response = yield* Effect.promise(() => client.v2.mcp.list({ location: { directory: process.cwd() } }))
|
||||||
const servers = (response.data?.data ?? []).toSorted((a, b) => a.name.localeCompare(b.name))
|
const servers = (response.data?.data ?? []).toSorted((a, b) => a.name.localeCompare(b.name))
|
||||||
if (servers.length === 0) {
|
if (servers.length === 0) {
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,9 @@
|
||||||
import { EOL } from "node:os"
|
import { EOL } from "node:os"
|
||||||
import { Effect } from "effect"
|
import { Effect } from "effect"
|
||||||
|
import { createOpencodeClient } from "@opencode-ai/sdk/v2/client"
|
||||||
import { Commands } from "../../commands"
|
import { Commands } from "../../commands"
|
||||||
import { Runtime } from "../../../framework/runtime"
|
import { Runtime } from "../../../framework/runtime"
|
||||||
import { Daemon } from "../../../services/daemon"
|
import { Service } from "../../../services/service"
|
||||||
import { resolveIntegration } from "./resolve"
|
import { resolveIntegration } from "./resolve"
|
||||||
|
|
||||||
const location = { directory: process.cwd() }
|
const location = { directory: process.cwd() }
|
||||||
|
|
@ -10,8 +11,8 @@ const location = { directory: process.cwd() }
|
||||||
export default Runtime.handler(
|
export default Runtime.handler(
|
||||||
Commands.commands.mcp.commands.logout,
|
Commands.commands.mcp.commands.logout,
|
||||||
Effect.fn("cli.mcp.logout")(function* (input) {
|
Effect.fn("cli.mcp.logout")(function* (input) {
|
||||||
const daemon = yield* Daemon.Service
|
const transport = yield* Service.connect()
|
||||||
const client = yield* daemon.client()
|
const client = createOpencodeClient({ baseUrl: transport.url, headers: transport.headers })
|
||||||
|
|
||||||
const integration = yield* resolveIntegration(client, input.name, location)
|
const integration = yield* resolveIntegration(client, input.name, location)
|
||||||
if (!integration) {
|
if (!integration) {
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ import { ServerAuth } from "@opencode-ai/server/auth"
|
||||||
import { createOpencodeClient } from "@opencode-ai/sdk/v2/client"
|
import { createOpencodeClient } from "@opencode-ai/sdk/v2/client"
|
||||||
import { Commands } from "../commands"
|
import { Commands } from "../commands"
|
||||||
import { Runtime } from "../../framework/runtime"
|
import { Runtime } from "../../framework/runtime"
|
||||||
import { Daemon } from "../../services/daemon"
|
import { Service } from "../../services/service"
|
||||||
import { Updater } from "../../services/updater"
|
import { Updater } from "../../services/updater"
|
||||||
import { randomBytes } from "crypto"
|
import { randomBytes } from "crypto"
|
||||||
|
|
||||||
|
|
@ -23,12 +23,11 @@ export default Runtime.handler(
|
||||||
if (input.service) yield* Effect.sync(() => process.chdir(Global.Path.home))
|
if (input.service) yield* Effect.sync(() => process.chdir(Global.Path.home))
|
||||||
return yield* Effect.scoped(
|
return yield* Effect.scoped(
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const daemon = yield* Daemon.Service
|
|
||||||
const standalonePassword = process.env.OPENCODE_SERVER_PASSWORD
|
const standalonePassword = process.env.OPENCODE_SERVER_PASSWORD
|
||||||
if (input.stdio) delete process.env.OPENCODE_SERVER_PASSWORD
|
if (input.stdio) delete process.env.OPENCODE_SERVER_PASSWORD
|
||||||
const config = input.service ? yield* daemon.config() : {}
|
const config = input.service ? yield* Service.config() : {}
|
||||||
const password = input.service
|
const password = input.service
|
||||||
? yield* daemon.password()
|
? yield* Service.password()
|
||||||
: standalonePassword || randomBytes(32).toString("base64url")
|
: standalonePassword || randomBytes(32).toString("base64url")
|
||||||
if (!password) return yield* Effect.fail(new Error("Missing server password"))
|
if (!password) return yield* Effect.fail(new Error("Missing server password"))
|
||||||
const hostname = Option.getOrUndefined(input.hostname) ?? config.hostname ?? "127.0.0.1"
|
const hostname = Option.getOrUndefined(input.hostname) ?? config.hostname ?? "127.0.0.1"
|
||||||
|
|
@ -44,7 +43,7 @@ export default Runtime.handler(
|
||||||
headers: ServerAuth.headers({ password }),
|
headers: ServerAuth.headers({ password }),
|
||||||
}).v2.health.get({}),
|
}).v2.health.get({}),
|
||||||
)
|
)
|
||||||
if (input.service) yield* daemon.register(address)
|
if (input.service) yield* Service.register(address)
|
||||||
const url = HttpServer.formatAddress(address)
|
const url = HttpServer.formatAddress(address)
|
||||||
console.log(input.stdio ? JSON.stringify({ url }) : `server listening on ${url}`)
|
console.log(input.stdio ? JSON.stringify({ url }) : `server listening on ${url}`)
|
||||||
if (!input.service && !input.stdio && !standalonePassword) console.log(`server password ${password}`)
|
if (!input.service && !input.stdio && !standalonePassword) console.log(`server password ${password}`)
|
||||||
|
|
|
||||||
|
|
@ -3,12 +3,11 @@ import { Option } from "effect"
|
||||||
import * as Effect from "effect/Effect"
|
import * as Effect from "effect/Effect"
|
||||||
import { Commands } from "../../commands"
|
import { Commands } from "../../commands"
|
||||||
import { Runtime } from "../../../framework/runtime"
|
import { Runtime } from "../../../framework/runtime"
|
||||||
import { Daemon } from "../../../services/daemon"
|
import { Service } from "../../../services/service"
|
||||||
|
|
||||||
export default Runtime.handler(
|
export default Runtime.handler(
|
||||||
Commands.commands.service.commands.get,
|
Commands.commands.service.commands.get,
|
||||||
Effect.fn("cli.service.get")(function* (input) {
|
Effect.fn("cli.service.get")(function* (input) {
|
||||||
const daemon = yield* Daemon.Service
|
process.stdout.write((yield* Service.get(Option.getOrUndefined(input.key))) + EOL)
|
||||||
process.stdout.write((yield* daemon.get(Option.getOrUndefined(input.key))) + EOL)
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -2,13 +2,12 @@ import { EOL } from "os"
|
||||||
import * as Effect from "effect/Effect"
|
import * as Effect from "effect/Effect"
|
||||||
import { Commands } from "../../commands"
|
import { Commands } from "../../commands"
|
||||||
import { Runtime } from "../../../framework/runtime"
|
import { Runtime } from "../../../framework/runtime"
|
||||||
import { Daemon } from "../../../services/daemon"
|
import { Service } from "../../../services/service"
|
||||||
|
|
||||||
export default Runtime.handler(
|
export default Runtime.handler(
|
||||||
Commands.commands.service.commands.restart,
|
Commands.commands.service.commands.restart,
|
||||||
Effect.fn("cli.service.restart")(function* () {
|
Effect.fn("cli.service.restart")(function* () {
|
||||||
const daemon = yield* Daemon.Service
|
yield* Service.stop()
|
||||||
yield* daemon.stop()
|
process.stdout.write((yield* Service.start()).url + EOL)
|
||||||
process.stdout.write((yield* daemon.start()) + EOL)
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,11 @@
|
||||||
import * as Effect from "effect/Effect"
|
import * as Effect from "effect/Effect"
|
||||||
import { Commands } from "../../commands"
|
import { Commands } from "../../commands"
|
||||||
import { Runtime } from "../../../framework/runtime"
|
import { Runtime } from "../../../framework/runtime"
|
||||||
import { Daemon } from "../../../services/daemon"
|
import { Service } from "../../../services/service"
|
||||||
|
|
||||||
export default Runtime.handler(
|
export default Runtime.handler(
|
||||||
Commands.commands.service.commands.set,
|
Commands.commands.service.commands.set,
|
||||||
Effect.fn("cli.service.set")(function* (input) {
|
Effect.fn("cli.service.set")(function* (input) {
|
||||||
yield* (yield* Daemon.Service).set(input.key, input.value)
|
yield* Service.set(input.key, input.value)
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -2,11 +2,11 @@ import { EOL } from "os"
|
||||||
import * as Effect from "effect/Effect"
|
import * as Effect from "effect/Effect"
|
||||||
import { Commands } from "../../commands"
|
import { Commands } from "../../commands"
|
||||||
import { Runtime } from "../../../framework/runtime"
|
import { Runtime } from "../../../framework/runtime"
|
||||||
import { Daemon } from "../../../services/daemon"
|
import { Service } from "../../../services/service"
|
||||||
|
|
||||||
export default Runtime.handler(
|
export default Runtime.handler(
|
||||||
Commands.commands.service.commands.start,
|
Commands.commands.service.commands.start,
|
||||||
Effect.fn("cli.service.start")(function* () {
|
Effect.fn("cli.service.start")(function* () {
|
||||||
process.stdout.write((yield* (yield* Daemon.Service).start()) + EOL)
|
process.stdout.write((yield* Service.start()).url + EOL)
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -2,12 +2,12 @@ import { EOL } from "os"
|
||||||
import * as Effect from "effect/Effect"
|
import * as Effect from "effect/Effect"
|
||||||
import { Commands } from "../../commands"
|
import { Commands } from "../../commands"
|
||||||
import { Runtime } from "../../../framework/runtime"
|
import { Runtime } from "../../../framework/runtime"
|
||||||
import { Daemon } from "../../../services/daemon"
|
import { Service } from "../../../services/service"
|
||||||
|
|
||||||
export default Runtime.handler(
|
export default Runtime.handler(
|
||||||
Commands.commands.service.commands.status,
|
Commands.commands.service.commands.status,
|
||||||
Effect.fn("cli.service.status")(function* () {
|
Effect.fn("cli.service.status")(function* () {
|
||||||
const url = yield* (yield* Daemon.Service).status()
|
const found = yield* Service.discover()
|
||||||
process.stdout.write((url ? url : "stopped") + EOL)
|
process.stdout.write((found ? found.url : "stopped") + EOL)
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,11 @@
|
||||||
import * as Effect from "effect/Effect"
|
import * as Effect from "effect/Effect"
|
||||||
import { Commands } from "../../commands"
|
import { Commands } from "../../commands"
|
||||||
import { Runtime } from "../../../framework/runtime"
|
import { Runtime } from "../../../framework/runtime"
|
||||||
import { Daemon } from "../../../services/daemon"
|
import { Service } from "../../../services/service"
|
||||||
|
|
||||||
export default Runtime.handler(
|
export default Runtime.handler(
|
||||||
Commands.commands.service.commands.stop,
|
Commands.commands.service.commands.stop,
|
||||||
Effect.fn("cli.service.stop")(function* () {
|
Effect.fn("cli.service.stop")(function* () {
|
||||||
yield* (yield* Daemon.Service).stop()
|
yield* Service.stop()
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,11 @@
|
||||||
import * as Effect from "effect/Effect"
|
import * as Effect from "effect/Effect"
|
||||||
import { Commands } from "../../commands"
|
import { Commands } from "../../commands"
|
||||||
import { Runtime } from "../../../framework/runtime"
|
import { Runtime } from "../../../framework/runtime"
|
||||||
import { Daemon } from "../../../services/daemon"
|
import { Service } from "../../../services/service"
|
||||||
|
|
||||||
export default Runtime.handler(
|
export default Runtime.handler(
|
||||||
Commands.commands.service.commands.unset,
|
Commands.commands.service.commands.unset,
|
||||||
Effect.fn("cli.service.unset")(function* (input) {
|
Effect.fn("cli.service.unset")(function* (input) {
|
||||||
yield* (yield* Daemon.Service).unset(input.key)
|
yield* Service.unset(input.key)
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
import * as Effect from "effect/Effect"
|
import * as Effect from "effect/Effect"
|
||||||
import * as Command from "effect/unstable/cli/Command"
|
import * as Command from "effect/unstable/cli/Command"
|
||||||
import { Spec } from "./spec"
|
import { Spec } from "./spec"
|
||||||
import { Daemon } from "../services/daemon"
|
import { Global } from "@opencode-ai/core/global"
|
||||||
import { Updater } from "../services/updater"
|
import { Updater } from "../services/updater"
|
||||||
import { Scope } from "effect"
|
import { FileSystem, Scope } from "effect"
|
||||||
|
|
||||||
export type Input<Value> =
|
export type Input<Value> =
|
||||||
Value extends Spec.Node<infer _Name, infer Command, infer _Commands>
|
Value extends Spec.Node<infer _Name, infer Command, infer _Commands>
|
||||||
|
|
@ -12,11 +12,11 @@ export type Input<Value> =
|
||||||
? Input
|
? Input
|
||||||
: never
|
: never
|
||||||
|
|
||||||
type RuntimeHandler = (input: unknown) => Effect.Effect<void, unknown, Daemon.Service | Updater.Service | Scope.Scope>
|
type RuntimeHandler = (input: unknown) => Effect.Effect<void, unknown, FileSystem.FileSystem | Global.Service | Updater.Service | Scope.Scope>
|
||||||
type Loader<Node extends Spec.Any> = () => Promise<{
|
type Loader<Node extends Spec.Any> = () => Promise<{
|
||||||
default: (input: Input<Node>) => Effect.Effect<void, any, Daemon.Service | Updater.Service | Scope.Scope>
|
default: (input: Input<Node>) => Effect.Effect<void, any, FileSystem.FileSystem | Global.Service | Updater.Service | Scope.Scope>
|
||||||
}>
|
}>
|
||||||
type ProvidedCommand = Command.Command<string, unknown, unknown, unknown, Daemon.Service | Updater.Service | Scope.Scope>
|
type ProvidedCommand = Command.Command<string, unknown, unknown, unknown, FileSystem.FileSystem | Global.Service | Updater.Service | Scope.Scope>
|
||||||
|
|
||||||
export type Handlers<Node extends Spec.Any> = keyof Node["commands"] extends never
|
export type Handlers<Node extends Spec.Any> = keyof Node["commands"] extends never
|
||||||
? Loader<Node>
|
? Loader<Node>
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,6 @@ import * as Effect from "effect/Effect"
|
||||||
import { Layer, Logger, References } from "effect"
|
import { Layer, Logger, References } from "effect"
|
||||||
import { Commands } from "./commands/commands"
|
import { Commands } from "./commands/commands"
|
||||||
import { Runtime } from "./framework/runtime"
|
import { Runtime } from "./framework/runtime"
|
||||||
import { Daemon } from "./services/daemon"
|
|
||||||
import { Logging } from "@opencode-ai/core/observability/logging"
|
import { Logging } from "@opencode-ai/core/observability/logging"
|
||||||
import { Updater } from "./services/updater"
|
import { Updater } from "./services/updater"
|
||||||
import { InstallationChannel, InstallationVersion, InstallationLocal } from "@opencode-ai/core/installation/version"
|
import { InstallationChannel, InstallationVersion, InstallationLocal } from "@opencode-ai/core/installation/version"
|
||||||
|
|
@ -50,7 +49,6 @@ const Handlers = Runtime.handlers(Commands, {
|
||||||
Effect.logInfo("cli starting", { version: InstallationVersion, channel: InstallationChannel, local: InstallationLocal }).pipe(
|
Effect.logInfo("cli starting", { version: InstallationVersion, channel: InstallationChannel, local: InstallationLocal }).pipe(
|
||||||
Effect.flatMap(() => Runtime.run(Commands, Handlers, { version: InstallationVersion })),
|
Effect.flatMap(() => Runtime.run(Commands, Handlers, { version: InstallationVersion })),
|
||||||
Effect.annotateLogs({ role: "cli" }),
|
Effect.annotateLogs({ role: "cli" }),
|
||||||
Effect.provide(Daemon.layer),
|
|
||||||
Effect.provide(Updater.layer),
|
Effect.provide(Updater.layer),
|
||||||
Effect.provide(AppNodeBuilder.build(LayerNode.group([Global.node, AppProcess.node]))),
|
Effect.provide(AppNodeBuilder.build(LayerNode.group([Global.node, AppProcess.node]))),
|
||||||
Effect.provide(LoggingLayer),
|
Effect.provide(LoggingLayer),
|
||||||
|
|
|
||||||
|
|
@ -1,323 +0,0 @@
|
||||||
import { Global } from "@opencode-ai/core/global"
|
|
||||||
import { InstallationChannel, InstallationVersion } from "@opencode-ai/core/installation/version"
|
|
||||||
import { createOpencodeClient } from "@opencode-ai/sdk/v2/client"
|
|
||||||
import { ServerAuth } from "@opencode-ai/server/auth"
|
|
||||||
import { Context, Effect, FileSystem, Layer, Option, Schedule, Schema, Scope } from "effect"
|
|
||||||
import { HttpServer } from "effect/unstable/http"
|
|
||||||
import { randomBytes, randomUUID } from "crypto"
|
|
||||||
import { spawn } from "node:child_process"
|
|
||||||
import path from "path"
|
|
||||||
|
|
||||||
export interface Interface {
|
|
||||||
readonly client: () => Effect.Effect<ReturnType<typeof createOpencodeClient>, unknown>
|
|
||||||
readonly transport: () => Effect.Effect<{ url: string; headers: RequestInit["headers"] }, unknown>
|
|
||||||
readonly start: () => Effect.Effect<string, Error>
|
|
||||||
readonly status: () => Effect.Effect<string | undefined>
|
|
||||||
readonly stop: () => Effect.Effect<void, unknown>
|
|
||||||
readonly password: (value?: string) => Effect.Effect<string, unknown>
|
|
||||||
readonly config: () => Effect.Effect<ServiceConfig, unknown>
|
|
||||||
readonly get: (key?: string) => Effect.Effect<string, unknown>
|
|
||||||
readonly set: (key: string, value: string) => Effect.Effect<void, unknown>
|
|
||||||
readonly unset: (key: string) => Effect.Effect<void, unknown>
|
|
||||||
readonly register: (address: HttpServer.Address) => Effect.Effect<void, unknown, Scope.Scope>
|
|
||||||
}
|
|
||||||
|
|
||||||
export class Service extends Context.Service<Service, Interface>()("@opencode/cli/Daemon") {}
|
|
||||||
|
|
||||||
const Registration = Schema.Struct({
|
|
||||||
id: Schema.optional(Schema.String),
|
|
||||||
version: Schema.optional(Schema.String),
|
|
||||||
url: Schema.String,
|
|
||||||
pid: Schema.Int.check(Schema.isGreaterThan(0)),
|
|
||||||
})
|
|
||||||
type Registration = typeof Registration.Type
|
|
||||||
|
|
||||||
const ServiceConfig = Schema.Struct({
|
|
||||||
hostname: Schema.optional(Schema.String),
|
|
||||||
port: Schema.optional(Schema.Int.check(Schema.isGreaterThanOrEqualTo(1), Schema.isLessThanOrEqualTo(65_535))),
|
|
||||||
password: Schema.optional(Schema.String),
|
|
||||||
autostart: Schema.optional(Schema.Boolean),
|
|
||||||
})
|
|
||||||
export type ServiceConfig = typeof ServiceConfig.Type
|
|
||||||
|
|
||||||
const serviceConfigKeys = ["hostname", "port", "password", "autostart"] as const
|
|
||||||
type ServiceConfigKey = (typeof serviceConfigKeys)[number]
|
|
||||||
|
|
||||||
function serviceConfigKey(key: string): ServiceConfigKey {
|
|
||||||
if (serviceConfigKeys.includes(key as ServiceConfigKey)) return key as ServiceConfigKey
|
|
||||||
throw new Error(`Unknown service config key: ${key}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
function sameRegistration(left: Registration, right: Registration) {
|
|
||||||
return left.id === right.id && left.version === right.version && left.url === right.url && left.pid === right.pid
|
|
||||||
}
|
|
||||||
|
|
||||||
export const layer = Layer.effect(
|
|
||||||
Service,
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const fs = yield* FileSystem.FileSystem
|
|
||||||
const global = yield* Global.Service
|
|
||||||
const directory = global.state
|
|
||||||
const filename = InstallationChannel === "local" ? "service-local.json" : "service.json"
|
|
||||||
const file = path.join(directory, filename)
|
|
||||||
const configFile = path.join(global.config, filename)
|
|
||||||
const decodeRegistration = Schema.decodeUnknownEffect(Schema.fromJsonString(Registration))
|
|
||||||
const decodeServiceConfig = Schema.decodeUnknownEffect(Schema.fromJsonString(ServiceConfig))
|
|
||||||
|
|
||||||
const config = Effect.fn("cli.daemon.config")(function* () {
|
|
||||||
return yield* fs.readFileString(configFile).pipe(
|
|
||||||
Effect.flatMap(decodeServiceConfig),
|
|
||||||
Effect.catch(() => Effect.succeed({} as ServiceConfig)),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
const writeConfig = Effect.fn("cli.daemon.writeConfig")(function* (value: ServiceConfig) {
|
|
||||||
const temp = configFile + ".tmp"
|
|
||||||
yield* fs.makeDirectory(path.dirname(configFile), { recursive: true })
|
|
||||||
yield* fs.writeFileString(temp, JSON.stringify(value, null, 2) + "\n", { mode: 0o600 })
|
|
||||||
yield* fs.rename(temp, configFile)
|
|
||||||
})
|
|
||||||
|
|
||||||
const password = Effect.fn("cli.daemon.password")(function* (value?: string) {
|
|
||||||
const existing = yield* config()
|
|
||||||
if (value === undefined && existing.password) return existing.password
|
|
||||||
const next = value ?? randomBytes(32).toString("base64url")
|
|
||||||
|
|
||||||
// Keep one private credential across server restarts so discovered clients
|
|
||||||
// can reconnect without exposing a password flag or environment variable.
|
|
||||||
yield* writeConfig({ ...existing, password: next })
|
|
||||||
return next
|
|
||||||
})
|
|
||||||
|
|
||||||
const get = Effect.fn("cli.daemon.get")(function* (key?: string) {
|
|
||||||
if (key === undefined) {
|
|
||||||
const { password: _password, ...safe } = yield* config()
|
|
||||||
return JSON.stringify(safe, null, 2)
|
|
||||||
}
|
|
||||||
switch (serviceConfigKey(key)) {
|
|
||||||
case "hostname": {
|
|
||||||
return (yield* config()).hostname ?? ""
|
|
||||||
}
|
|
||||||
case "port": {
|
|
||||||
const port = (yield* config()).port
|
|
||||||
return port === undefined ? "" : String(port)
|
|
||||||
}
|
|
||||||
case "password": {
|
|
||||||
return yield* password()
|
|
||||||
}
|
|
||||||
case "autostart": {
|
|
||||||
const autostart = (yield* config()).autostart
|
|
||||||
return autostart === undefined ? "" : String(autostart)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const set = Effect.fn("cli.daemon.set")(function* (key: string, value: string) {
|
|
||||||
switch (serviceConfigKey(key)) {
|
|
||||||
case "hostname": {
|
|
||||||
yield* stop()
|
|
||||||
yield* writeConfig({ ...(yield* config()), hostname: value })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
case "port": {
|
|
||||||
const port = Number(value)
|
|
||||||
if (!Number.isInteger(port) || port < 1 || port > 65_535) throw new Error("Port must be between 1 and 65535")
|
|
||||||
yield* stop()
|
|
||||||
yield* writeConfig({ ...(yield* config()), port })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
case "password": {
|
|
||||||
yield* stop()
|
|
||||||
yield* password(value)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
case "autostart": {
|
|
||||||
if (value !== "true" && value !== "false") throw new Error("Autostart must be true or false")
|
|
||||||
yield* writeConfig({ ...(yield* config()), autostart: value === "true" })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const unset = Effect.fn("cli.daemon.unset")(function* (key: string) {
|
|
||||||
switch (serviceConfigKey(key)) {
|
|
||||||
case "hostname": {
|
|
||||||
yield* stop()
|
|
||||||
const { hostname: _hostname, ...next } = yield* config()
|
|
||||||
yield* writeConfig(next)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
case "port": {
|
|
||||||
yield* stop()
|
|
||||||
const { port: _port, ...next } = yield* config()
|
|
||||||
yield* writeConfig(next)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
case "password": {
|
|
||||||
yield* stop()
|
|
||||||
const { password: _password, ...next } = yield* config()
|
|
||||||
yield* writeConfig(next)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
case "autostart": {
|
|
||||||
const { autostart: _autostart, ...next } = yield* config()
|
|
||||||
yield* writeConfig(next)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const registration = Effect.fnUntraced(function* () {
|
|
||||||
return yield* fs.readFileString(file).pipe(Effect.flatMap(decodeRegistration))
|
|
||||||
})
|
|
||||||
|
|
||||||
const createClient = Effect.fnUntraced(function* (url: string) {
|
|
||||||
return createOpencodeClient({ baseUrl: url, headers: ServerAuth.headers({ password: yield* password() }) })
|
|
||||||
})
|
|
||||||
|
|
||||||
const healthy = Effect.fnUntraced(function* () {
|
|
||||||
const info = yield* registration()
|
|
||||||
const client = yield* createClient(info.url)
|
|
||||||
const response = yield* Effect.tryPromise(() => client.v2.health.get({ signal: AbortSignal.timeout(2_000) }))
|
|
||||||
if (response.data?.healthy === true) return info
|
|
||||||
return yield* Effect.fail(new Error("Registered server is not healthy"))
|
|
||||||
})
|
|
||||||
|
|
||||||
const remoteTransport = Effect.fn("cli.daemon.remoteTransport")(function* (input: ServiceConfig) {
|
|
||||||
const url = serviceURL(input)
|
|
||||||
const headers = ServerAuth.headers({ password: input.password })
|
|
||||||
const response = yield* Effect.tryPromise(() =>
|
|
||||||
createOpencodeClient({ baseUrl: url, headers }).v2.health.get({ signal: AbortSignal.timeout(2_000) }),
|
|
||||||
)
|
|
||||||
if (response.data?.healthy === true) return { url, headers }
|
|
||||||
return yield* Effect.fail(new Error(`Server is not healthy: ${url}`))
|
|
||||||
})
|
|
||||||
|
|
||||||
const compatible = Effect.fnUntraced(function* () {
|
|
||||||
const info = yield* healthy()
|
|
||||||
if (info.version === InstallationVersion) return info
|
|
||||||
return yield* Effect.fail(new Error("Registered server version does not match the client"))
|
|
||||||
})
|
|
||||||
|
|
||||||
const signal = (pid: number, signal: NodeJS.Signals) =>
|
|
||||||
Effect.try({ try: () => process.kill(pid, signal), catch: (cause) => cause }).pipe(Effect.ignore)
|
|
||||||
|
|
||||||
const awaitStopped = Effect.fnUntraced(function* (pid: number) {
|
|
||||||
const running = yield* Effect.try({ try: () => process.kill(pid, 0), catch: () => false }).pipe(
|
|
||||||
Effect.orElseSucceed(() => false),
|
|
||||||
)
|
|
||||||
if (!running) return true
|
|
||||||
return yield* Effect.fail(new Error(`Server process ${pid} is still running`))
|
|
||||||
})
|
|
||||||
|
|
||||||
const stopProcess = Effect.fnUntraced(function* (info: Registration) {
|
|
||||||
const current = yield* healthy().pipe(Effect.option)
|
|
||||||
if (Option.isNone(current) || !sameRegistration(current.value, info)) return
|
|
||||||
|
|
||||||
yield* signal(info.pid, "SIGTERM")
|
|
||||||
const stopped = yield* awaitStopped(info.pid).pipe(
|
|
||||||
Effect.retry(Schedule.spaced("50 millis").pipe(Schedule.both(Schedule.recurs(100)))),
|
|
||||||
Effect.option,
|
|
||||||
)
|
|
||||||
if (Option.isSome(stopped)) return
|
|
||||||
|
|
||||||
const latest = yield* healthy().pipe(Effect.option)
|
|
||||||
if (Option.isNone(latest) || !sameRegistration(latest.value, info)) return
|
|
||||||
yield* signal(info.pid, "SIGKILL")
|
|
||||||
yield* awaitStopped(info.pid).pipe(
|
|
||||||
Effect.retry(Schedule.spaced("50 millis").pipe(Schedule.both(Schedule.recurs(100)))),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
const start = Effect.fn("cli.daemon.start")(function* () {
|
|
||||||
const existing = yield* healthy().pipe(Effect.option)
|
|
||||||
const found = Option.getOrUndefined(existing)
|
|
||||||
const compiled = path.basename(process.execPath).replace(/\.exe$/, "") !== "bun"
|
|
||||||
if (found?.version === InstallationVersion) return found.url
|
|
||||||
if (found) yield* stopProcess(found).pipe(Effect.ignore)
|
|
||||||
|
|
||||||
const entrypoint = compiled ? undefined : process.argv[1]
|
|
||||||
if (!compiled && entrypoint === undefined)
|
|
||||||
return yield* Effect.fail(new Error("Failed to resolve CLI entrypoint"))
|
|
||||||
yield* Effect.try({
|
|
||||||
try: () => {
|
|
||||||
spawn(process.execPath, [...(entrypoint ? [entrypoint] : []), "serve", "--service"], {
|
|
||||||
detached: true,
|
|
||||||
stdio: "ignore",
|
|
||||||
}).unref()
|
|
||||||
},
|
|
||||||
catch: (cause) => new Error("Failed to start server", { cause }),
|
|
||||||
})
|
|
||||||
|
|
||||||
return yield* compatible().pipe(
|
|
||||||
Effect.retry(Schedule.spaced("50 millis").pipe(Schedule.both(Schedule.recurs(100)))),
|
|
||||||
Effect.map((info) => info.url),
|
|
||||||
Effect.mapError(() => new Error("Failed to start server")),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
const transport = Effect.fn("cli.daemon.transport")(function* () {
|
|
||||||
const current = yield* config()
|
|
||||||
if (current.autostart === false) return yield* remoteTransport(current)
|
|
||||||
return { url: yield* start(), headers: ServerAuth.headers({ password: yield* password() }) }
|
|
||||||
})
|
|
||||||
|
|
||||||
const client = Effect.fn("cli.daemon.client")(function* () {
|
|
||||||
const connection = yield* transport()
|
|
||||||
return createOpencodeClient({ baseUrl: connection.url, headers: connection.headers })
|
|
||||||
})
|
|
||||||
|
|
||||||
const status = Effect.fn("cli.daemon.status")(function* () {
|
|
||||||
const existing = yield* healthy().pipe(Effect.option)
|
|
||||||
const found = Option.getOrUndefined(existing)
|
|
||||||
if (found?.version === InstallationVersion) return found.url
|
|
||||||
if (found) return undefined
|
|
||||||
yield* fs.remove(file).pipe(Effect.ignore)
|
|
||||||
return undefined
|
|
||||||
})
|
|
||||||
|
|
||||||
const stop = Effect.fn("cli.daemon.stop")(function* () {
|
|
||||||
const existing = yield* healthy().pipe(Effect.option)
|
|
||||||
// A stale registration may point at a PID that has since been reused by
|
|
||||||
// another process. Only signal the PID after authenticating the server.
|
|
||||||
if (Option.isNone(existing)) return yield* fs.remove(file).pipe(Effect.ignore)
|
|
||||||
yield* stopProcess(existing.value)
|
|
||||||
yield* fs.remove(file).pipe(Effect.ignore)
|
|
||||||
})
|
|
||||||
|
|
||||||
const register = Effect.fn("cli.daemon.register")(function* (address: HttpServer.Address) {
|
|
||||||
const id = randomUUID()
|
|
||||||
const temp = file + "." + id + ".tmp"
|
|
||||||
yield* fs.makeDirectory(directory, { recursive: true })
|
|
||||||
yield* fs.writeFileString(
|
|
||||||
temp,
|
|
||||||
JSON.stringify({ id, version: InstallationVersion, url: HttpServer.formatAddress(address), pid: process.pid }),
|
|
||||||
{ mode: 0o600 },
|
|
||||||
)
|
|
||||||
yield* fs.rename(temp, file)
|
|
||||||
yield* registration().pipe(
|
|
||||||
Effect.flatMap((info) => (info.id === id ? Effect.void : signal(process.pid, "SIGTERM"))),
|
|
||||||
Effect.catch(() => signal(process.pid, "SIGTERM")),
|
|
||||||
Effect.repeat(Schedule.spaced("10 seconds")),
|
|
||||||
Effect.forkScoped,
|
|
||||||
)
|
|
||||||
yield* Effect.addFinalizer(() =>
|
|
||||||
registration().pipe(
|
|
||||||
Effect.flatMap((info) => (info.id === id ? fs.remove(file) : Effect.void)),
|
|
||||||
Effect.ignore,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
return Service.of({ client, transport, start, status, stop, password, config, get, set, unset, register })
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
function serviceURL(config: ServiceConfig) {
|
|
||||||
const hostname = config.hostname ?? "127.0.0.1"
|
|
||||||
const result = new URL(`http://${hostname.includes(":") && !hostname.startsWith("[") ? `[${hostname}]` : hostname}`)
|
|
||||||
result.port = String(config.port ?? 4096)
|
|
||||||
return result.toString()
|
|
||||||
}
|
|
||||||
|
|
||||||
export * as Daemon from "./daemon"
|
|
||||||
203
packages/cli/src/services/service.ts
Normal file
203
packages/cli/src/services/service.ts
Normal file
|
|
@ -0,0 +1,203 @@
|
||||||
|
import { Global } from "@opencode-ai/core/global"
|
||||||
|
import { InstallationChannel, InstallationVersion } from "@opencode-ai/core/installation/version"
|
||||||
|
import { ServiceEffect } from "@opencode-ai/client/service/effect"
|
||||||
|
import { Effect, FileSystem, Schedule, Schema } from "effect"
|
||||||
|
import { HttpServer } from "effect/unstable/http"
|
||||||
|
import { randomBytes, randomUUID } from "crypto"
|
||||||
|
import path from "path"
|
||||||
|
|
||||||
|
// Binds the client package's service operations to this CLI: which
|
||||||
|
// registration file (by channel), which version, and how to spawn opencode.
|
||||||
|
// Also owns the service config file and the server-side registration write.
|
||||||
|
|
||||||
|
const ServiceConfig = Schema.Struct({
|
||||||
|
hostname: Schema.optional(Schema.String),
|
||||||
|
port: Schema.optional(Schema.Int.check(Schema.isGreaterThanOrEqualTo(1), Schema.isLessThanOrEqualTo(65_535))),
|
||||||
|
password: Schema.optional(Schema.String),
|
||||||
|
})
|
||||||
|
export type ServiceConfig = typeof ServiceConfig.Type
|
||||||
|
|
||||||
|
const serviceConfigKeys = ["hostname", "port", "password"] as const
|
||||||
|
type ServiceConfigKey = (typeof serviceConfigKeys)[number]
|
||||||
|
|
||||||
|
const decodeServiceConfig = Schema.decodeUnknownEffect(Schema.fromJsonString(ServiceConfig))
|
||||||
|
|
||||||
|
function serviceConfigKey(key: string): ServiceConfigKey {
|
||||||
|
if (serviceConfigKeys.includes(key as ServiceConfigKey)) return key as ServiceConfigKey
|
||||||
|
throw new Error(`Unknown service config key: ${key}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const env = Effect.gen(function* () {
|
||||||
|
const fs = yield* FileSystem.FileSystem
|
||||||
|
const global = yield* Global.Service
|
||||||
|
const filename = InstallationChannel === "local" ? "service-local.json" : "service.json"
|
||||||
|
return {
|
||||||
|
fs,
|
||||||
|
stateDir: global.state,
|
||||||
|
file: path.join(global.state, filename),
|
||||||
|
configFile: path.join(global.config, filename),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const options = Effect.fnUntraced(function* () {
|
||||||
|
const { file } = yield* env
|
||||||
|
const compiled = path.basename(process.execPath).replace(/\.exe$/, "") !== "bun"
|
||||||
|
const entrypoint = compiled ? undefined : process.argv[1]
|
||||||
|
if (!compiled && entrypoint === undefined) return yield* Effect.fail(new Error("Failed to resolve CLI entrypoint"))
|
||||||
|
return {
|
||||||
|
file,
|
||||||
|
version: InstallationVersion,
|
||||||
|
command: [process.execPath, ...(entrypoint ? [entrypoint] : []), "serve", "--service"],
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
export const discover = Effect.fn("cli.service.discover")(function* () {
|
||||||
|
const found = yield* ServiceEffect.discover(yield* options())
|
||||||
|
return found?.transport
|
||||||
|
})
|
||||||
|
|
||||||
|
export const start = Effect.fn("cli.service.start")(function* () {
|
||||||
|
return yield* ServiceEffect.start(yield* options())
|
||||||
|
})
|
||||||
|
|
||||||
|
export const connect = Effect.fn("cli.service.connect")(function* () {
|
||||||
|
return yield* ServiceEffect.connect(yield* options())
|
||||||
|
})
|
||||||
|
|
||||||
|
export const stop = Effect.fn("cli.service.stop")(function* () {
|
||||||
|
return yield* ServiceEffect.stop(yield* options())
|
||||||
|
})
|
||||||
|
|
||||||
|
export const config = Effect.fn("cli.service.config")(function* () {
|
||||||
|
const { fs, configFile } = yield* env
|
||||||
|
return yield* fs.readFileString(configFile).pipe(
|
||||||
|
Effect.flatMap(decodeServiceConfig),
|
||||||
|
Effect.catch(() => Effect.succeed({} as ServiceConfig)),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
const writeConfig = Effect.fn("cli.service.writeConfig")(function* (value: ServiceConfig) {
|
||||||
|
const { fs, configFile } = yield* env
|
||||||
|
const temp = configFile + ".tmp"
|
||||||
|
yield* fs.makeDirectory(path.dirname(configFile), { recursive: true })
|
||||||
|
yield* fs.writeFileString(temp, JSON.stringify(value, null, 2) + "\n", { mode: 0o600 })
|
||||||
|
yield* fs.rename(temp, configFile)
|
||||||
|
})
|
||||||
|
|
||||||
|
export const password = Effect.fn("cli.service.password")(function* (value?: string) {
|
||||||
|
const existing = yield* config()
|
||||||
|
if (value === undefined && existing.password) return existing.password
|
||||||
|
const next = value ?? randomBytes(32).toString("base64url")
|
||||||
|
|
||||||
|
// Keep one private credential across server restarts so discovered clients
|
||||||
|
// can reconnect without exposing a password flag or environment variable.
|
||||||
|
yield* writeConfig({ ...existing, password: next })
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
|
||||||
|
export const get = Effect.fn("cli.service.get")(function* (key?: string) {
|
||||||
|
if (key === undefined) {
|
||||||
|
const { password: _password, ...safe } = yield* config()
|
||||||
|
return JSON.stringify(safe, null, 2)
|
||||||
|
}
|
||||||
|
switch (serviceConfigKey(key)) {
|
||||||
|
case "hostname": {
|
||||||
|
return (yield* config()).hostname ?? ""
|
||||||
|
}
|
||||||
|
case "port": {
|
||||||
|
const port = (yield* config()).port
|
||||||
|
return port === undefined ? "" : String(port)
|
||||||
|
}
|
||||||
|
case "password": {
|
||||||
|
return yield* password()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
export const set = Effect.fn("cli.service.set")(function* (key: string, value: string) {
|
||||||
|
switch (serviceConfigKey(key)) {
|
||||||
|
case "hostname": {
|
||||||
|
yield* stop()
|
||||||
|
yield* writeConfig({ ...(yield* config()), hostname: value })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
case "port": {
|
||||||
|
const port = Number(value)
|
||||||
|
if (!Number.isInteger(port) || port < 1 || port > 65_535) throw new Error("Port must be between 1 and 65535")
|
||||||
|
yield* stop()
|
||||||
|
yield* writeConfig({ ...(yield* config()), port })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
case "password": {
|
||||||
|
yield* stop()
|
||||||
|
yield* password(value)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
export const unset = Effect.fn("cli.service.unset")(function* (key: string) {
|
||||||
|
switch (serviceConfigKey(key)) {
|
||||||
|
case "hostname": {
|
||||||
|
yield* stop()
|
||||||
|
const { hostname: _hostname, ...next } = yield* config()
|
||||||
|
yield* writeConfig(next)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
case "port": {
|
||||||
|
yield* stop()
|
||||||
|
const { port: _port, ...next } = yield* config()
|
||||||
|
yield* writeConfig(next)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
case "password": {
|
||||||
|
yield* stop()
|
||||||
|
const { password: _password, ...next } = yield* config()
|
||||||
|
yield* writeConfig(next)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Server-side half of the registration protocol, run by `serve --service` at
|
||||||
|
// boot. The registration embeds the password so the file alone is enough for
|
||||||
|
// any client to discover and authenticate. service.json arbitrates ownership
|
||||||
|
// after concurrent starts; it is not a startup lock: the atomic rename elects
|
||||||
|
// the latest writer, the watcher self-evicts losers, and the finalizer
|
||||||
|
// id-guard keeps an exiting server from deleting its successor's registration.
|
||||||
|
export const register = Effect.fn("cli.service.register")(function* (address: HttpServer.Address) {
|
||||||
|
const { fs, stateDir, file } = yield* env
|
||||||
|
const id = randomUUID()
|
||||||
|
const secret = yield* password()
|
||||||
|
const temp = file + "." + id + ".tmp"
|
||||||
|
yield* fs.makeDirectory(stateDir, { recursive: true })
|
||||||
|
yield* fs.writeFileString(
|
||||||
|
temp,
|
||||||
|
JSON.stringify({
|
||||||
|
id,
|
||||||
|
version: InstallationVersion,
|
||||||
|
url: HttpServer.formatAddress(address),
|
||||||
|
pid: process.pid,
|
||||||
|
password: secret,
|
||||||
|
}),
|
||||||
|
{ mode: 0o600 },
|
||||||
|
)
|
||||||
|
yield* fs.rename(temp, file)
|
||||||
|
yield* ServiceEffect.readRegistration(file).pipe(
|
||||||
|
Effect.flatMap((info) =>
|
||||||
|
info?.id === id
|
||||||
|
? Effect.void
|
||||||
|
: Effect.try({ try: () => process.kill(process.pid, "SIGTERM"), catch: (cause) => cause }).pipe(Effect.ignore),
|
||||||
|
),
|
||||||
|
Effect.repeat(Schedule.spaced("10 seconds")),
|
||||||
|
Effect.forkScoped,
|
||||||
|
)
|
||||||
|
yield* Effect.addFinalizer(() =>
|
||||||
|
ServiceEffect.readRegistration(file).pipe(
|
||||||
|
Effect.flatMap((info) => (info?.id === id ? fs.remove(file) : Effect.void)),
|
||||||
|
Effect.ignore,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
export * as Service from "./service"
|
||||||
|
|
@ -5,12 +5,11 @@ import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||||
import { Global } from "@opencode-ai/core/global"
|
import { Global } from "@opencode-ai/core/global"
|
||||||
import { loadBuiltinPlugins } from "@opencode-ai/tui/builtins"
|
import { loadBuiltinPlugins } from "@opencode-ai/tui/builtins"
|
||||||
import { OpenCode } from "@opencode-ai/client"
|
import { OpenCode } from "@opencode-ai/client"
|
||||||
|
import type { Transport } from "@opencode-ai/client/service"
|
||||||
import { createOpencodeClient } from "@opencode-ai/sdk/v2/client"
|
import { createOpencodeClient } from "@opencode-ai/sdk/v2/client"
|
||||||
import type { Args } from "@opencode-ai/tui/context/args"
|
import type { Args } from "@opencode-ai/tui/context/args"
|
||||||
|
|
||||||
type Transport = { url: string; headers: RequestInit["headers"] }
|
export function runTui(transport: Transport, args: Args, discover?: () => Promise<Transport>) {
|
||||||
|
|
||||||
export function runTui(transport: Transport, args: Args, reload?: () => Promise<Transport>) {
|
|
||||||
const config = TuiConfig.resolve({}, { terminalSuspend: false })
|
const config = TuiConfig.resolve({}, { terminalSuspend: false })
|
||||||
let disposeSlots: (() => void) | undefined
|
let disposeSlots: (() => void) | undefined
|
||||||
return Effect.gen(function* () {
|
return Effect.gen(function* () {
|
||||||
|
|
@ -25,9 +24,9 @@ export function runTui(transport: Transport, args: Args, reload?: () => Promise<
|
||||||
return yield* run({
|
return yield* run({
|
||||||
client: createOpencodeClient({ ...options, directory }),
|
client: createOpencodeClient({ ...options, directory }),
|
||||||
api,
|
api,
|
||||||
reload: reload
|
discover: discover
|
||||||
? async () => {
|
? async () => {
|
||||||
const next = await reload()
|
const next = await discover()
|
||||||
return {
|
return {
|
||||||
client: createOpencodeClient({ baseUrl: next.url, headers: next.headers, directory }),
|
client: createOpencodeClient({ baseUrl: next.url, headers: next.headers, directory }),
|
||||||
api: OpenCode.make({ baseUrl: next.url, headers: next.headers }),
|
api: OpenCode.make({ baseUrl: next.url, headers: next.headers }),
|
||||||
|
|
|
||||||
|
|
@ -5,23 +5,19 @@ import { Effect } from "effect"
|
||||||
import fs from "node:fs/promises"
|
import fs from "node:fs/promises"
|
||||||
import os from "node:os"
|
import os from "node:os"
|
||||||
import path from "node:path"
|
import path from "node:path"
|
||||||
import { Daemon } from "../src/services/daemon"
|
import { Service } from "../src/services/service"
|
||||||
|
|
||||||
test("local channel stores service config with the local service filename", async () => {
|
test("local channel stores service config with the local service filename", async () => {
|
||||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-daemon-"))
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-service-"))
|
||||||
try {
|
try {
|
||||||
await Effect.runPromise(
|
await Effect.runPromise(
|
||||||
Effect.gen(function* () {
|
Service.set("hostname", "127.0.0.2").pipe(
|
||||||
const daemon = yield* Daemon.Service
|
|
||||||
yield* daemon.set("autostart", "false")
|
|
||||||
}).pipe(
|
|
||||||
Effect.provide(Daemon.layer),
|
|
||||||
Effect.provide(Global.layerWith({ config: path.join(root, "config"), state: path.join(root, "state") })),
|
Effect.provide(Global.layerWith({ config: path.join(root, "config"), state: path.join(root, "state") })),
|
||||||
Effect.provide(NodeFileSystem.layer),
|
Effect.provide(NodeFileSystem.layer),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
expect(await Bun.file(path.join(root, "config", "service-local.json")).json()).toEqual({
|
expect(await Bun.file(path.join(root, "config", "service-local.json")).json()).toEqual({
|
||||||
autostart: false,
|
hostname: "127.0.0.2",
|
||||||
})
|
})
|
||||||
expect(await Bun.file(path.join(root, "config", "service.json")).exists()).toBe(false)
|
expect(await Bun.file(path.join(root, "config", "service.json")).exists()).toBe(false)
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -6,7 +6,9 @@
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"exports": {
|
"exports": {
|
||||||
".": "./src/index.ts",
|
".": "./src/index.ts",
|
||||||
"./effect": "./src/effect.ts"
|
"./effect": "./src/effect.ts",
|
||||||
|
"./service": "./src/service/index.ts",
|
||||||
|
"./service/effect": "./src/service/effect.ts"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"generate": "bun run script/build.ts",
|
"generate": "bun run script/build.ts",
|
||||||
|
|
|
||||||
22
packages/client/src/service/effect.ts
Normal file
22
packages/client/src/service/effect.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
import { Effect } from "effect"
|
||||||
|
import * as service from "./index.js"
|
||||||
|
|
||||||
|
export type { Transport, Discover, Registration, LocalService, ServiceOptions } from "./index.js"
|
||||||
|
|
||||||
|
export { basicAuth } from "./index.js"
|
||||||
|
|
||||||
|
export const readRegistration = (file?: string) => Effect.promise(() => service.readRegistration(file))
|
||||||
|
export const discover = (options?: service.ServiceOptions) => Effect.promise(() => service.discover(options))
|
||||||
|
export const stop = (options?: service.ServiceOptions) => Effect.promise(() => service.stop(options))
|
||||||
|
export const start = (options?: service.ServiceOptions) =>
|
||||||
|
Effect.tryPromise({
|
||||||
|
try: () => service.start(options),
|
||||||
|
catch: (cause) => new Error("Failed to start server", { cause }),
|
||||||
|
})
|
||||||
|
export const connect = (options?: service.ServiceOptions) =>
|
||||||
|
Effect.tryPromise({
|
||||||
|
try: () => service.connect(options),
|
||||||
|
catch: (cause) => new Error("Failed to connect to server", { cause }),
|
||||||
|
})
|
||||||
|
|
||||||
|
export * as ServiceEffect from "./effect.js"
|
||||||
172
packages/client/src/service/index.ts
Normal file
172
packages/client/src/service/index.ts
Normal file
|
|
@ -0,0 +1,172 @@
|
||||||
|
import { spawn } from "node:child_process"
|
||||||
|
import { readFile, rm } from "node:fs/promises"
|
||||||
|
import { homedir } from "node:os"
|
||||||
|
import { join } from "node:path"
|
||||||
|
|
||||||
|
// Everything a client needs to connect to an opencode server and to find or
|
||||||
|
// start the local background service.
|
||||||
|
//
|
||||||
|
// The service daemon advertises itself through a registration file in the
|
||||||
|
// user's state directory: url, pid, version, and the private password, with
|
||||||
|
// 0600 permissions. That file is the complete discovery contract — reading it
|
||||||
|
// is all a client needs to connect. The daemon's own configuration (port,
|
||||||
|
// persisted password) is CLI-owned and never read here.
|
||||||
|
|
||||||
|
export type Transport = {
|
||||||
|
readonly url: string
|
||||||
|
readonly headers?: RequestInit["headers"]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Discover = () => Promise<Transport | undefined>
|
||||||
|
|
||||||
|
export function basicAuth(password: string): RequestInit["headers"] {
|
||||||
|
return { authorization: "Basic " + btoa("opencode:" + password) }
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Registration = {
|
||||||
|
readonly id?: string
|
||||||
|
readonly version?: string
|
||||||
|
readonly url: string
|
||||||
|
readonly pid: number
|
||||||
|
readonly password?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type LocalService = {
|
||||||
|
readonly registration: Registration
|
||||||
|
readonly transport: Transport
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ServiceOptions = {
|
||||||
|
// Absolute path to the service registration file. Defaults to
|
||||||
|
// opencode/service.json in the XDG state directory.
|
||||||
|
readonly file?: string
|
||||||
|
// When set, discovery only returns a server reporting this exact version,
|
||||||
|
// and start() replaces a healthy server whose version differs.
|
||||||
|
readonly version?: string
|
||||||
|
// Argv used to spawn the service. Defaults to ["opencode", "serve",
|
||||||
|
// "--service"] resolved from PATH.
|
||||||
|
readonly command?: ReadonlyArray<string>
|
||||||
|
readonly timeout?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function defaultRegistrationFile(): string {
|
||||||
|
const state = process.env["XDG_STATE_HOME"] ?? join(homedir(), ".local", "state")
|
||||||
|
return join(state, "opencode", "service.json")
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function readRegistration(file?: string): Promise<Registration | undefined> {
|
||||||
|
const text = await readFile(file ?? defaultRegistrationFile(), "utf8").catch(() => undefined)
|
||||||
|
if (text === undefined) return undefined
|
||||||
|
const value: unknown = JSON.parse(text)
|
||||||
|
if (typeof value !== "object" || value === null) return undefined
|
||||||
|
const record = value as Record<string, unknown>
|
||||||
|
if (typeof record.url !== "string") return undefined
|
||||||
|
if (typeof record.pid !== "number" || !Number.isInteger(record.pid) || record.pid <= 0) return undefined
|
||||||
|
return {
|
||||||
|
id: typeof record.id === "string" ? record.id : undefined,
|
||||||
|
version: typeof record.version === "string" ? record.version : undefined,
|
||||||
|
url: record.url,
|
||||||
|
pid: record.pid,
|
||||||
|
password: typeof record.password === "string" ? record.password : undefined,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read-only lookup: registration file plus health check and version gate.
|
||||||
|
// Never spawns; escalation to start() is the caller's policy.
|
||||||
|
export async function discover(options: ServiceOptions = {}): Promise<LocalService | undefined> {
|
||||||
|
const registration = await readRegistration(options.file).catch(() => undefined)
|
||||||
|
if (registration === undefined) return undefined
|
||||||
|
if (options.version !== undefined && registration.version !== options.version) return undefined
|
||||||
|
return await probe(registration, options)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function probe(registration: Registration, options: ServiceOptions): Promise<LocalService | undefined> {
|
||||||
|
const headers = registration.password === undefined ? undefined : basicAuth(registration.password)
|
||||||
|
const healthy = await fetch(new URL("/api/health", registration.url), {
|
||||||
|
headers,
|
||||||
|
signal: AbortSignal.timeout(options.timeout ?? 2_000),
|
||||||
|
})
|
||||||
|
.then((response) => response.ok)
|
||||||
|
.catch(() => false)
|
||||||
|
if (!healthy) return undefined
|
||||||
|
return { registration, transport: { url: registration.url, headers } }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Health-checked lookup without the version gate: lifecycle operations must be
|
||||||
|
// able to see (and replace or stop) a server from a different version.
|
||||||
|
async function anyService(options: ServiceOptions): Promise<LocalService | undefined> {
|
||||||
|
const registration = await readRegistration(options.file).catch(() => undefined)
|
||||||
|
if (registration === undefined) return undefined
|
||||||
|
return await probe(registration, options)
|
||||||
|
}
|
||||||
|
|
||||||
|
function signal(pid: number, name: "SIGTERM" | "SIGKILL" | 0): boolean {
|
||||||
|
try {
|
||||||
|
process.kill(pid, name)
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function awaitStopped(pid: number, timeout: number): Promise<boolean> {
|
||||||
|
const deadline = Date.now() + timeout
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
if (!signal(pid, 0)) return true
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||||
|
}
|
||||||
|
return !signal(pid, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
function sameRegistration(left: Registration, right: Registration) {
|
||||||
|
return left.id === right.id && left.version === right.version && left.url === right.url && left.pid === right.pid
|
||||||
|
}
|
||||||
|
|
||||||
|
async function stopProcess(info: Registration, options: ServiceOptions): Promise<void> {
|
||||||
|
// A stale registration may point at a PID that has since been reused by
|
||||||
|
// another process. Only signal the PID after authenticating the server.
|
||||||
|
const current = await anyService(options)
|
||||||
|
if (current === undefined || !sameRegistration(current.registration, info)) return
|
||||||
|
|
||||||
|
signal(info.pid, "SIGTERM")
|
||||||
|
if (await awaitStopped(info.pid, 5_000)) return
|
||||||
|
|
||||||
|
const latest = await anyService(options)
|
||||||
|
if (latest === undefined || !sameRegistration(latest.registration, info)) return
|
||||||
|
signal(info.pid, "SIGKILL")
|
||||||
|
await awaitStopped(info.pid, 5_000)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function stop(options: ServiceOptions = {}): Promise<void> {
|
||||||
|
const existing = await anyService(options)
|
||||||
|
if (existing !== undefined) await stopProcess(existing.registration, options)
|
||||||
|
await rm(options.file ?? defaultRegistrationFile(), { force: true }).catch(() => undefined)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Idempotent ensure-running: reuses a healthy compatible server, replaces a
|
||||||
|
// version-mismatched one, and otherwise spawns the service command detached.
|
||||||
|
export async function start(options: ServiceOptions = {}): Promise<Transport> {
|
||||||
|
const compatible = await discover(options)
|
||||||
|
if (compatible !== undefined) return compatible.transport
|
||||||
|
const mismatched = await anyService(options)
|
||||||
|
if (mismatched !== undefined) await stopProcess(mismatched.registration, options).catch(() => undefined)
|
||||||
|
|
||||||
|
const [command, ...args] = options.command ?? ["opencode", "serve", "--service"]
|
||||||
|
if (command === undefined) throw new Error("Missing service command")
|
||||||
|
spawn(command, args, { detached: true, stdio: "ignore" }).unref()
|
||||||
|
|
||||||
|
const deadline = Date.now() + 5_000
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
const found = await discover(options)
|
||||||
|
if (found !== undefined) return found.transport
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||||
|
}
|
||||||
|
throw new Error("Failed to start server")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default connection policy for the local service: discover, else start.
|
||||||
|
export async function connect(options: ServiceOptions = {}): Promise<Transport> {
|
||||||
|
const found = await discover(options)
|
||||||
|
if (found !== undefined) return found.transport
|
||||||
|
return await start(options)
|
||||||
|
}
|
||||||
|
|
@ -138,7 +138,7 @@ const appBindingCommands = [
|
||||||
export type TuiInput = {
|
export type TuiInput = {
|
||||||
client: OpencodeClient
|
client: OpencodeClient
|
||||||
api: OpenCodeClient
|
api: OpenCodeClient
|
||||||
reload?: () => Promise<{ client: OpencodeClient; api: OpenCodeClient }>
|
discover?: () => Promise<{ client: OpencodeClient; api: OpenCodeClient }>
|
||||||
args: Args
|
args: Args
|
||||||
config: TuiConfig.Resolved
|
config: TuiConfig.Resolved
|
||||||
onSnapshot?: () => Promise<string[]>
|
onSnapshot?: () => Promise<string[]>
|
||||||
|
|
@ -301,7 +301,7 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
|
||||||
>
|
>
|
||||||
<TuiConfigProvider config={input.config}>
|
<TuiConfigProvider config={input.config}>
|
||||||
<PluginRuntimeProvider value={pluginRuntime}>
|
<PluginRuntimeProvider value={pluginRuntime}>
|
||||||
<SDKProvider client={input.client} api={input.api} reload={input.reload}>
|
<SDKProvider client={input.client} api={input.api} discover={input.discover}>
|
||||||
<PermissionProvider>
|
<PermissionProvider>
|
||||||
<ProjectProvider>
|
<ProjectProvider>
|
||||||
<SyncProvider>
|
<SyncProvider>
|
||||||
|
|
@ -374,7 +374,6 @@ function App(props: { onSnapshot?: () => Promise<string[]>; pluginHost: TuiPlugi
|
||||||
const keymap = useOpencodeKeymap()
|
const keymap = useOpencodeKeymap()
|
||||||
const event = useEvent()
|
const event = useEvent()
|
||||||
const sdk = useSDK()
|
const sdk = useSDK()
|
||||||
const reload = sdk.reload
|
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
const themeState = useTheme()
|
const themeState = useTheme()
|
||||||
const { theme, mode, setMode, locked, lock, unlock } = themeState
|
const { theme, mode, setMode, locked, lock, unlock } = themeState
|
||||||
|
|
@ -801,33 +800,6 @@ function App(props: { onSnapshot?: () => Promise<string[]>; pluginHost: TuiPlugi
|
||||||
},
|
},
|
||||||
category: "System",
|
category: "System",
|
||||||
},
|
},
|
||||||
...(reload
|
|
||||||
? [
|
|
||||||
{
|
|
||||||
name: "server.reload",
|
|
||||||
title: "Reload server",
|
|
||||||
slashName: "reload",
|
|
||||||
slashAliases: ["restart"],
|
|
||||||
run: async () => {
|
|
||||||
dialog.clear()
|
|
||||||
toast.show({
|
|
||||||
variant: "info",
|
|
||||||
message: "Reloading server...",
|
|
||||||
duration: 30000,
|
|
||||||
})
|
|
||||||
await reload()
|
|
||||||
.then(() =>
|
|
||||||
toast.show({
|
|
||||||
variant: "success",
|
|
||||||
message: "Server reloaded",
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.catch(toast.error)
|
|
||||||
},
|
|
||||||
category: "System",
|
|
||||||
},
|
|
||||||
]
|
|
||||||
: []),
|
|
||||||
{
|
{
|
||||||
name: "theme.switch",
|
name: "theme.switch",
|
||||||
title: "Switch theme",
|
title: "Switch theme",
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
|
||||||
init: (props: {
|
init: (props: {
|
||||||
client: OpencodeClient
|
client: OpencodeClient
|
||||||
api: OpenCodeClient
|
api: OpenCodeClient
|
||||||
reload?: () => Promise<{ client: OpencodeClient; api: OpenCodeClient }>
|
discover?: () => Promise<{ client: OpencodeClient; api: OpenCodeClient }>
|
||||||
}) => {
|
}) => {
|
||||||
const abort = new AbortController()
|
const abort = new AbortController()
|
||||||
let client = props.client
|
let client = props.client
|
||||||
|
|
@ -32,12 +32,10 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
|
||||||
connectedOnce: false,
|
connectedOnce: false,
|
||||||
})
|
})
|
||||||
let stream: AbortController | undefined
|
let stream: AbortController | undefined
|
||||||
let pending: Promise<void> | undefined
|
|
||||||
|
|
||||||
function start() {
|
function start() {
|
||||||
stream?.abort()
|
stream?.abort()
|
||||||
const controller = new AbortController()
|
const controller = new AbortController()
|
||||||
const current = client
|
|
||||||
let connected!: () => void
|
let connected!: () => void
|
||||||
const ready = new Promise<void>((resolve) => {
|
const ready = new Promise<void>((resolve) => {
|
||||||
connected = resolve
|
connected = resolve
|
||||||
|
|
@ -54,7 +52,7 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
|
||||||
)
|
)
|
||||||
controller.signal.addEventListener("abort", cancel, { once: true })
|
controller.signal.addEventListener("abort", cancel, { once: true })
|
||||||
const error = await (async () => {
|
const error = await (async () => {
|
||||||
const response = await current.v2.event.subscribe({
|
const response = await client.v2.event.subscribe({
|
||||||
signal: connection.signal,
|
signal: connection.signal,
|
||||||
sseMaxRetryAttempts: 0,
|
sseMaxRetryAttempts: 0,
|
||||||
throwOnError: true,
|
throwOnError: true,
|
||||||
|
|
@ -86,6 +84,17 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
|
||||||
})
|
})
|
||||||
if (abort.signal.aborted || controller.signal.aborted) return
|
if (abort.signal.aborted || controller.signal.aborted) return
|
||||||
attempt += 1
|
attempt += 1
|
||||||
|
// Re-resolve the transport before retrying: the server may have
|
||||||
|
// moved (service restarted on a new port) or need starting. Static
|
||||||
|
// transports (--server, standalone) resolve to the same address.
|
||||||
|
if (props.discover) {
|
||||||
|
const next = await props.discover().catch(() => undefined)
|
||||||
|
if (abort.signal.aborted || controller.signal.aborted) return
|
||||||
|
if (next) {
|
||||||
|
client = next.client
|
||||||
|
api = next.api
|
||||||
|
}
|
||||||
|
}
|
||||||
setConnection({
|
setConnection({
|
||||||
status: "connecting",
|
status: "connecting",
|
||||||
attempt,
|
attempt,
|
||||||
|
|
@ -97,23 +106,6 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
|
||||||
return ready
|
return ready
|
||||||
}
|
}
|
||||||
|
|
||||||
const reload = props.reload
|
|
||||||
? () => {
|
|
||||||
if (pending) return pending
|
|
||||||
pending = Promise.resolve()
|
|
||||||
.then(props.reload)
|
|
||||||
.then(async (next) => {
|
|
||||||
client = next.client
|
|
||||||
api = next.api
|
|
||||||
if (!abort.signal.aborted) await start()
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
pending = undefined
|
|
||||||
})
|
|
||||||
return pending
|
|
||||||
}
|
|
||||||
: undefined
|
|
||||||
|
|
||||||
onMount(() => void start())
|
onMount(() => void start())
|
||||||
onCleanup(() => {
|
onCleanup(() => {
|
||||||
abort.abort()
|
abort.abort()
|
||||||
|
|
@ -146,7 +138,6 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
|
||||||
return connection.connectedOnce
|
return connection.connectedOnce
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
reload,
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -47,7 +47,7 @@ function update(version: string): V2Event {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function mount(reload?: () => Promise<{ client: OpencodeClient; api: OpenCodeClient }>) {
|
async function mount(discover?: () => Promise<{ client: OpencodeClient; api: OpenCodeClient }>) {
|
||||||
const events = createEventStream()
|
const events = createEventStream()
|
||||||
const calls = createFetch(undefined, events)
|
const calls = createFetch(undefined, events)
|
||||||
const seen: V2Event[] = []
|
const seen: V2Event[] = []
|
||||||
|
|
@ -61,7 +61,7 @@ async function mount(reload?: () => Promise<{ client: OpencodeClient; api: OpenC
|
||||||
|
|
||||||
const app = await testRender(() => (
|
const app = await testRender(() => (
|
||||||
<TestTuiContexts>
|
<TestTuiContexts>
|
||||||
<SDKProvider client={createClient(calls.fetch)} api={createApi(calls.fetch)} reload={reload}>
|
<SDKProvider client={createClient(calls.fetch)} api={createApi(calls.fetch)} discover={discover}>
|
||||||
<ProjectProvider>
|
<ProjectProvider>
|
||||||
<Probe
|
<Probe
|
||||||
onReady={async (ctx) => {
|
onReady={async (ctx) => {
|
||||||
|
|
@ -79,7 +79,7 @@ async function mount(reload?: () => Promise<{ client: OpencodeClient; api: OpenC
|
||||||
))
|
))
|
||||||
|
|
||||||
await ready
|
await ready
|
||||||
return { app, emit: events.emit, project, sdk, seen, workspaces }
|
return { app, events, emit: events.emit, project, sdk, seen, workspaces }
|
||||||
}
|
}
|
||||||
|
|
||||||
function Probe(props: {
|
function Probe(props: {
|
||||||
|
|
@ -148,24 +148,25 @@ describe("useEvent", () => {
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
test("reloads the host and reconnects the event stream", async () => {
|
test("rediscovers the server after the event stream drops", async () => {
|
||||||
let calls = 0
|
let calls = 0
|
||||||
const events = createEventStream()
|
const replacementEvents = createEventStream()
|
||||||
const replacementCalls = createFetch(undefined, events)
|
const replacementCalls = createFetch(undefined, replacementEvents)
|
||||||
const replacement = { client: createClient(replacementCalls.fetch), api: createApi(replacementCalls.fetch) }
|
const replacement = { client: createClient(replacementCalls.fetch), api: createApi(replacementCalls.fetch) }
|
||||||
const { app, sdk, seen } = await mount(async () => {
|
const { app, events, sdk, seen } = await mount(async () => {
|
||||||
calls += 1
|
calls += 1
|
||||||
return replacement
|
return replacement
|
||||||
})
|
})
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await wait(() => sdk.connection.status() === "connected")
|
await wait(() => sdk.connection.status() === "connected")
|
||||||
await sdk.reload?.()
|
// Discovery only runs when the stream is down, never while connected.
|
||||||
await wait(() => sdk.connection.status() === "connected")
|
expect(calls).toBe(0)
|
||||||
events.emit(event(vcs("reloaded"), { directory: "/tmp/reloaded" }))
|
events.disconnect()
|
||||||
await wait(() => seen.some((item) => item.type === "vcs.branch.updated" && item.data.branch === "reloaded"))
|
await wait(() => sdk.connection.status() === "connected" && calls > 0)
|
||||||
|
replacementEvents.emit(event(vcs("rediscovered"), { directory: "/tmp/rediscovered" }))
|
||||||
|
await wait(() => seen.some((item) => item.type === "vcs.branch.updated" && item.data.branch === "rediscovered"))
|
||||||
|
|
||||||
expect(calls).toBe(1)
|
|
||||||
expect(sdk.client).toBe(replacement.client)
|
expect(sdk.client).toBe(replacement.client)
|
||||||
expect(sdk.api).toBe(replacement.api)
|
expect(sdk.api).toBe(replacement.api)
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -173,27 +174,24 @@ describe("useEvent", () => {
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
test("keeps the current event stream alive while the host reload is pending", async () => {
|
test("keeps the current client when discovery fails", async () => {
|
||||||
let complete!: (client: { client: OpencodeClient; api: OpenCodeClient }) => void
|
let calls = 0
|
||||||
const pending = new Promise<{ client: OpencodeClient; api: OpenCodeClient }>((resolve) => {
|
const { app, events, sdk, seen } = await mount(async () => {
|
||||||
complete = resolve
|
calls += 1
|
||||||
|
throw new Error("no server")
|
||||||
})
|
})
|
||||||
const replacementEvents = createEventStream()
|
|
||||||
const replacementCalls = createFetch(undefined, replacementEvents)
|
|
||||||
const replacement = { client: createClient(replacementCalls.fetch), api: createApi(replacementCalls.fetch) }
|
|
||||||
const { app, emit, sdk, seen } = await mount(() => pending)
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await wait(() => sdk.connection.status() === "connected")
|
await wait(() => sdk.connection.status() === "connected")
|
||||||
const reload = sdk.reload?.()
|
const original = sdk.client
|
||||||
emit(event(vcs("during-reload"), { directory: "/tmp/reload" }))
|
events.disconnect()
|
||||||
await wait(() => seen.some((item) => item.type === "vcs.branch.updated" && item.data.branch === "during-reload"))
|
// Discovery rejects; the loop retries against the last known transport,
|
||||||
|
// which succeeds once the fixture accepts the reconnect.
|
||||||
|
await wait(() => calls > 0 && sdk.connection.status() === "connected")
|
||||||
|
events.emit(event(vcs("recovered"), { directory: "/tmp/recovered" }))
|
||||||
|
await wait(() => seen.some((item) => item.type === "vcs.branch.updated" && item.data.branch === "recovered"))
|
||||||
|
|
||||||
expect(sdk.connection.status()).toBe("connected")
|
expect(sdk.client).toBe(original)
|
||||||
complete(replacement)
|
|
||||||
await reload
|
|
||||||
expect(sdk.client).toBe(replacement.client)
|
|
||||||
expect(sdk.api).toBe(replacement.api)
|
|
||||||
} finally {
|
} finally {
|
||||||
app.renderer.destroy()
|
app.renderer.destroy()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue