refactor(cli): unify server endpoint handling

This commit is contained in:
Dax Raad 2026-07-08 21:51:37 -04:00
commit be7a684791
18 changed files with 212 additions and 223 deletions

View file

@ -20,10 +20,10 @@ export default Runtime.handler(
Effect.fn("cli.api")(function* (input) {
const options = yield* ServiceConfig.options()
const found = yield* Service.discover(options)
const transport = found ?? (yield* Service.start(options))
const endpoint = found ?? (yield* Service.start(options))
const params = Option.getOrElse(input.param, () => ({}))
const request = yield* resolveRequest(transport, input.request, params)
const headers = new Headers(transport.headers)
const request = yield* resolveRequest(endpoint, input.request, params)
const headers = new Headers(Service.headers(endpoint))
for (const header of input.header) {
const index = header.indexOf(":")
if (index < 1) return yield* Effect.fail(new Error(`Invalid header, expected name:value: ${header}`))
@ -33,7 +33,7 @@ export default Runtime.handler(
if (body !== undefined && !headers.has("content-type")) headers.set("content-type", "application/json")
const response = yield* Effect.tryPromise(() =>
fetch(new URL(request.path, transport.url), {
fetch(new URL(request.path, endpoint.url), {
method: request.method,
headers,
body,
@ -60,7 +60,7 @@ export function rawRequest(input: readonly string[]) {
}
function resolveRequest(
transport: Service.Transport,
endpoint: Service.Endpoint,
input: readonly string[],
params: Record<string, string>,
) {
@ -68,7 +68,7 @@ function resolveRequest(
if (raw) return Effect.succeed(raw)
if (input.length !== 1) return Effect.fail(new Error("Expected an operation name or an HTTP method and path"))
return Effect.tryPromise(async () => {
const response = await fetch(new URL("/openapi.json", transport.url), { headers: transport.headers })
const response = await fetch(new URL("/openapi.json", endpoint.url), { headers: Service.headers(endpoint) })
if (!response.ok) throw new Error(`Failed to load OpenAPI document: HTTP ${response.status}`)
return resolveOperation((await response.json()) as OpenApi, input[0], params)
})

View file

@ -1,9 +1,10 @@
import { Cause, Effect, Exit, Option } from "effect"
import { Service } from "@opencode-ai/client/effect"
import { OpenCode, type OpenCodeClient } from "@opencode-ai/client/promise"
import { AppProcess } from "@opencode-ai/core/process"
import { Commands } from "../../commands"
import { Runtime } from "../../../framework/runtime"
import { Daemon } from "../../../daemon"
import { ServiceConfig } from "../../../services/service-config"
import { createTimelineHost, type TimelineHost } from "../../../ui/timeline"
const integrationID = "opencode"
@ -34,8 +35,8 @@ const login = Effect.fn("cli.console.login.run")(function* (timeline: TimelineHo
yield* request(() => timeline.intro("Log in"))
yield* request(() => timeline.pending("Connecting to OpenCode..."))
const transport = yield* Daemon.transport({ mode: "shared" })
const client = OpenCode.make({ baseUrl: transport.url, headers: transport.headers })
const endpoint = yield* Service.start(yield* ServiceConfig.options())
const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) })
const found = yield* request((signal) => client.integration.get({ integrationID, location }, { signal }))
const integration = yield* required(found.data, "OpenCode Console integration is unavailable")
const method = yield* required(

View file

@ -11,8 +11,8 @@ export default Runtime.handler(
Effect.fn("cli.debug.agents")(function* () {
const options = yield* ServiceConfig.options()
const found = yield* Service.discover(options)
const transport = found ?? (yield* Service.start(options))
const client = createOpencodeClient({ baseUrl: transport.url, headers: transport.headers })
const endpoint = found ?? (yield* Service.start(options))
const client = createOpencodeClient({ baseUrl: endpoint.url, headers: Service.headers(endpoint) })
const response = yield* Effect.promise(() => client.v2.agent.list({ location: { directory: process.cwd() } }))
process.stdout.write(
JSON.stringify(

View file

@ -1,4 +1,9 @@
import { NodeFileSystem } from "@effect/platform-node"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { Global } from "@opencode-ai/core/global"
import { run } from "@opencode-ai/tui"
import { loadBuiltinPlugins } from "@opencode-ai/tui/builtins"
import { TuiConfig } from "@opencode-ai/tui/config"
import { Commands } from "../commands"
import { Runtime } from "../../framework/runtime"
import { Effect, Option, Redacted } from "effect"
@ -10,26 +15,29 @@ import { Updater } from "../../services/updater"
export default Runtime.handler(Commands, (input) =>
Effect.gen(function* () {
const directory = Option.getOrUndefined(input.directory)
if (directory !== undefined) process.chdir(directory)
const requestedDirectory = Option.getOrUndefined(input.directory)
if (requestedDirectory !== undefined) process.chdir(requestedDirectory)
const updater = yield* Updater.Service
yield* updater.check().pipe(Effect.forkScoped)
const server = Option.getOrUndefined(input.server)
if (server !== undefined && input.standalone)
return yield* Effect.fail(new Error("--server and --standalone cannot be combined"))
const transport = yield* Effect.gen(function* () {
const endpoint = yield* Effect.gen(function* () {
if (server !== undefined) {
const password = yield* Env.password
const explicit = {
url: server,
headers: password
? { authorization: "Basic " + btoa("opencode:" + Redacted.value(password)) }
auth: password
? { type: "basic" as const, username: "opencode", password: Redacted.value(password) }
: undefined,
} satisfies Service.Transport
} satisfies Service.Endpoint
// Fail loudly before entering the TUI: an explicit server that is
// unreachable or rejects auth should not present as reconnect churn.
const response = yield* Effect.tryPromise(() =>
fetch(new URL("/api/health", server), { headers: explicit.headers, signal: AbortSignal.timeout(5_000) }),
fetch(new URL("/api/health", server), {
headers: Service.headers(explicit),
signal: AbortSignal.timeout(5_000),
}),
).pipe(Effect.mapError((cause) => new Error(`Could not reach server at ${server}`, { cause })))
if (response.status === 401)
return yield* Effect.fail(
@ -43,14 +51,13 @@ export default Runtime.handler(Commands, (input) =>
return yield* Effect.fail(new Error(`Server at ${server} responded with status ${response.status}`))
return explicit
}
if (input.standalone) return yield* Standalone.transport()
if (input.standalone) return yield* Standalone.start()
const options = yield* ServiceConfig.options()
const found = yield* Service.discover(options)
return found ?? (yield* Service.start(options))
})
const { runTui } = yield* Effect.promise(() => import("../../tui"))
// The TUI re-runs discover whenever its event stream drops. For an explicit
// --server or a standalone child the transport is fixed, so reconnects
// --server or a standalone child the endpoint is fixed, so reconnects
// retry the same address; for the managed service discovery re-reads the
// registration and may start a replacement.
const serviceOptions = server === undefined && !input.standalone ? yield* ServiceConfig.options() : undefined
@ -65,7 +72,7 @@ export default Runtime.handler(Commands, (input) =>
return found ?? (yield* Service.start(reconnectOptions))
}).pipe(Effect.provide(NodeFileSystem.layer)),
)
: () => Promise.resolve(transport)
: undefined
// Restart the managed service in place; start() resolves once the
// replacement is healthy and the reconnect loop reattaches on its own.
// Only meaningful in service mode: --server is not ours to restart and a
@ -79,11 +86,36 @@ export default Runtime.handler(Commands, (input) =>
}).pipe(Effect.provide(NodeFileSystem.layer)),
)
: undefined
yield* runTui(
transport,
{ continue: input.continue, sessionID: Option.getOrUndefined(input.session) },
discover,
reload,
)
const config = TuiConfig.resolve({}, { terminalSuspend: false })
let disposeSlots: (() => void) | undefined
const runFork = Effect.runForkWith(yield* Effect.context())
yield* run({
server: {
endpoint,
discover,
reload,
},
args: { continue: input.continue, sessionID: Option.getOrUndefined(input.session) },
config,
log: (level, message, tags) => {
const effect =
level === "debug"
? Effect.logDebug(message, tags)
: level === "warn"
? Effect.logWarning(message, tags)
: level === "error"
? Effect.logError(message, tags)
: Effect.logInfo(message, tags)
runFork(effect)
},
pluginHost: {
async start(pluginInput) {
disposeSlots = await loadBuiltinPlugins(pluginInput.api, pluginInput.runtime)
},
async dispose() {
disposeSlots?.()
},
},
}).pipe(Effect.provide(AppNodeBuilder.build(Global.node)))
}),
)

View file

@ -10,10 +10,10 @@ import { ServiceConfig } from "../../services/service-config"
export default Runtime.handler(
Commands.commands.link,
Effect.fn("cli.link")(function* () {
const transport = yield* Service.start(yield* ServiceConfig.options())
const endpoint = yield* Service.start(yield* ServiceConfig.options())
const password = yield* ServiceConfig.password()
const server = yield* Effect.tryPromise(() =>
OpenCode.make({ baseUrl: transport.url, headers: transport.headers }).server.get(),
OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) }).server.get(),
)
const info = { urls: server.urls, username: "opencode", password }
process.stdout.write(
@ -34,7 +34,7 @@ export default Runtime.handler(
].join(EOL) + EOL,
)
const hostname = new URL(transport.url).hostname
const hostname = new URL(endpoint.url).hostname
if (!["localhost", "127.0.0.1", "[::1]"].includes(hostname)) return
process.stderr.write(
` Run \`opencode service set hostname 0.0.0.0\` to access the service remotely.${EOL}${EOL}`,

View file

@ -19,8 +19,8 @@ export default Runtime.handler(
Effect.fn("cli.mcp.auth")(function* (input) {
const options = yield* ServiceConfig.options()
const found = yield* Service.discover(options)
const transport = found ?? (yield* Service.start(options))
const client = createOpencodeClient({ baseUrl: transport.url, headers: transport.headers })
const endpoint = found ?? (yield* Service.start(options))
const client = createOpencodeClient({ baseUrl: endpoint.url, headers: Service.headers(endpoint) })
const integration = yield* resolveIntegration(client, input.name, location)
if (!integration)

View file

@ -11,8 +11,8 @@ export default Runtime.handler(
Effect.fn("cli.mcp.list")(function* () {
const options = yield* ServiceConfig.options()
const found = yield* Service.discover(options)
const transport = found ?? (yield* Service.start(options))
const client = createOpencodeClient({ baseUrl: transport.url, headers: transport.headers })
const endpoint = found ?? (yield* Service.start(options))
const client = createOpencodeClient({ baseUrl: endpoint.url, headers: Service.headers(endpoint) })
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))
if (servers.length === 0) {

View file

@ -14,8 +14,8 @@ export default Runtime.handler(
Effect.fn("cli.mcp.logout")(function* (input) {
const options = yield* ServiceConfig.options()
const found = yield* Service.discover(options)
const transport = found ?? (yield* Service.start(options))
const client = createOpencodeClient({ baseUrl: transport.url, headers: transport.headers })
const endpoint = found ?? (yield* Service.start(options))
const client = createOpencodeClient({ baseUrl: endpoint.url, headers: Service.headers(endpoint) })
const integration = yield* resolveIntegration(client, input.name, location)
if (!integration) {