refactor(client): split package into promise and effect entrypoints

This commit is contained in:
Dax Raad 2026-07-03 01:42:50 -04:00
commit af5eabcb26
46 changed files with 5211 additions and 2332 deletions

View file

@ -2,8 +2,9 @@ import { EOL } from "node:os"
import { Effect, Option } from "effect"
import { Commands } from "../commands"
import { Runtime } from "../../framework/runtime"
import { Service } from "../../services/service"
import type { Transport } from "@opencode-ai/client/service"
import { Service } from "@opencode-ai/client/effect"
import { ServiceConfig } from "../../services/service-config"
import type { Transport } from "@opencode-ai/client/effect"
const methods = new Set(["delete", "get", "head", "options", "patch", "post", "put"])
@ -18,7 +19,9 @@ type OpenApi = {
export default Runtime.handler(
Commands.commands.api,
Effect.fn("cli.api")(function* (input) {
const transport = yield* Service.connect()
const options = yield* ServiceConfig.options()
const found = yield* Service.discover(options)
const transport = 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)

View file

@ -3,12 +3,15 @@ import * as Effect from "effect/Effect"
import { createOpencodeClient } from "@opencode-ai/sdk/v2/client"
import { Commands } from "../../commands"
import { Runtime } from "../../../framework/runtime"
import { Service } from "../../../services/service"
import { Service } from "@opencode-ai/client/effect"
import { ServiceConfig } from "../../../services/service-config"
export default Runtime.handler(
Commands.commands.debug.commands.agents,
Effect.fn("cli.debug.agents")(function* () {
const transport = yield* Service.connect()
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 response = yield* Effect.promise(() => client.v2.agent.list({ location: { directory: process.cwd() } }))
process.stdout.write(

View file

@ -1,12 +1,12 @@
import { NodeFileSystem } from "@effect/platform-node"
import { Commands } from "../commands"
import { Runtime } from "../../framework/runtime"
import { Global } from "@opencode-ai/core/global"
import { Effect, FileSystem, Option } from "effect"
import { Service } from "../../services/service"
import { Effect, Option } from "effect"
import { Service } from "@opencode-ai/client/effect"
import type { Transport } from "@opencode-ai/client/effect"
import { ServiceConfig } from "../../services/service-config"
import { Standalone } from "../../services/standalone"
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) =>
Effect.gen(function* () {
@ -17,29 +17,34 @@ export default Runtime.handler(Commands, (input) =>
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* resolveTransport(server, input.standalone)
const transport = yield* Effect.gen(function* () {
if (server !== undefined) {
const password = process.env["OPENCODE_SERVER_PASSWORD"]
return {
url: server,
headers: password ? { authorization: "Basic " + btoa("opencode:" + password) } : undefined,
} satisfies Transport
}
if (input.standalone) return yield* Standalone.transport()
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
// retry the same address; for the managed service discovery re-reads the
// registration and may start a replacement.
const context = yield* Effect.context<FileSystem.FileSystem | Global.Service>()
const discover =
server !== undefined || input.standalone
? () => Promise.resolve(transport)
: () => Effect.runPromise(Service.connect().pipe(Effect.provide(context)))
const serviceOptions = server === undefined && !input.standalone ? yield* ServiceConfig.options() : undefined
const discover = serviceOptions
? () =>
Effect.runPromise(
Effect.gen(function* () {
const found = yield* Service.discover(serviceOptions)
return found ?? (yield* Service.start(serviceOptions))
}).pipe(Effect.provide(NodeFileSystem.layer)),
)
: () => Promise.resolve(transport)
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()
}

View file

@ -8,7 +8,8 @@ import {
} from "@opencode-ai/sdk/v2/client"
import { Commands } from "../../commands"
import { Runtime } from "../../../framework/runtime"
import { Service } from "../../../services/service"
import { Service } from "@opencode-ai/client/effect"
import { ServiceConfig } from "../../../services/service-config"
import { resolveIntegration } from "./resolve"
const location = { directory: process.cwd() }
@ -16,7 +17,9 @@ const location = { directory: process.cwd() }
export default Runtime.handler(
Commands.commands.mcp.commands.auth,
Effect.fn("cli.mcp.auth")(function* (input) {
const transport = yield* Service.connect()
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 integration = yield* resolveIntegration(client, input.name, location)

View file

@ -3,12 +3,15 @@ import * as Effect from "effect/Effect"
import { createOpencodeClient, type McpServer } from "@opencode-ai/sdk/v2/client"
import { Commands } from "../../commands"
import { Runtime } from "../../../framework/runtime"
import { Service } from "../../../services/service"
import { Service } from "@opencode-ai/client/effect"
import { ServiceConfig } from "../../../services/service-config"
export default Runtime.handler(
Commands.commands.mcp.commands.list,
Effect.fn("cli.mcp.list")(function* () {
const transport = yield* Service.connect()
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 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))

View file

@ -3,7 +3,8 @@ import { Effect } from "effect"
import { createOpencodeClient } from "@opencode-ai/sdk/v2/client"
import { Commands } from "../../commands"
import { Runtime } from "../../../framework/runtime"
import { Service } from "../../../services/service"
import { Service } from "@opencode-ai/client/effect"
import { ServiceConfig } from "../../../services/service-config"
import { resolveIntegration } from "./resolve"
const location = { directory: process.cwd() }
@ -11,7 +12,9 @@ const location = { directory: process.cwd() }
export default Runtime.handler(
Commands.commands.mcp.commands.logout,
Effect.fn("cli.mcp.logout")(function* (input) {
const transport = yield* Service.connect()
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 integration = yield* resolveIntegration(client, input.name, location)

View file

@ -4,18 +4,20 @@ import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { PermissionSaved } from "@opencode-ai/core/permission/saved"
import { Global } from "@opencode-ai/core/global"
import { Context, Layer, Option, Schedule } from "effect"
import { Context, FileSystem, Layer, Option, Schedule, Schema } from "effect"
import * as Effect from "effect/Effect"
import { HttpRouter, HttpServer } from "effect/unstable/http"
import { createServer } from "node:http"
import { createRoutes } from "@opencode-ai/server/routes"
import { ServerAuth } from "@opencode-ai/server/auth"
import { InstallationVersion } from "@opencode-ai/core/installation/version"
import { createOpencodeClient } from "@opencode-ai/sdk/v2/client"
import { Commands } from "../commands"
import { Runtime } from "../../framework/runtime"
import { Service } from "../../services/service"
import { ServiceConfig } from "../../services/service-config"
import { Updater } from "../../services/updater"
import { randomBytes } from "crypto"
import { randomBytes, randomUUID } from "crypto"
import path from "path"
export default Runtime.handler(
Commands.commands.serve,
@ -25,9 +27,9 @@ export default Runtime.handler(
Effect.gen(function* () {
const standalonePassword = process.env.OPENCODE_SERVER_PASSWORD
if (input.stdio) delete process.env.OPENCODE_SERVER_PASSWORD
const config = input.service ? yield* Service.config() : {}
const config = input.service ? yield* ServiceConfig.read() : {}
const password = input.service
? yield* Service.password()
? yield* ServiceConfig.password()
: standalonePassword || randomBytes(32).toString("base64url")
if (!password) return yield* Effect.fail(new Error("Missing server password"))
const hostname = Option.getOrUndefined(input.hostname) ?? config.hostname ?? "127.0.0.1"
@ -43,7 +45,7 @@ export default Runtime.handler(
headers: ServerAuth.headers({ password }),
}).v2.health.get({}),
)
if (input.service) yield* Service.register(address)
if (input.service) yield* register(address)
const url = HttpServer.formatAddress(address)
console.log(input.stdio ? JSON.stringify({ url }) : `server listening on ${url}`)
if (!input.service && !input.stdio && !standalonePassword) console.log(`server password ${password}`)
@ -55,6 +57,56 @@ export default Runtime.handler(
}),
)
// Server-side half of the registration protocol. The registration embeds the
// password so the file alone is enough for any client to discover and
// authenticate. The file 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.
const RegistrationId = Schema.Struct({ id: Schema.optional(Schema.String) })
const decodeRegistrationId = Schema.decodeUnknownEffect(Schema.fromJsonString(RegistrationId))
const register = Effect.fnUntraced(function* (address: HttpServer.Address) {
const fs = yield* FileSystem.FileSystem
const { file } = yield* ServiceConfig.options()
const id = randomUUID()
const secret = yield* ServiceConfig.password()
const temp = file + "." + id + ".tmp"
yield* fs.makeDirectory(path.dirname(file), { 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)
const currentID = fs.readFileString(file).pipe(
Effect.flatMap(decodeRegistrationId),
Effect.map((info) => info.id),
Effect.orElseSucceed(() => undefined),
)
yield* currentID.pipe(
Effect.flatMap((current) =>
current === 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(() =>
currentID.pipe(
Effect.flatMap((current) => (current === id ? fs.remove(file) : Effect.void)),
Effect.ignore,
),
)
})
function waitForStdinClose() {
return Effect.callback<void>((resume) => {
const close = () => resume(Effect.void)

View file

@ -3,11 +3,11 @@ import { Option } from "effect"
import * as Effect from "effect/Effect"
import { Commands } from "../../commands"
import { Runtime } from "../../../framework/runtime"
import { Service } from "../../../services/service"
import { ServiceConfig } from "../../../services/service-config"
export default Runtime.handler(
Commands.commands.service.commands.get,
Effect.fn("cli.service.get")(function* (input) {
process.stdout.write((yield* Service.get(Option.getOrUndefined(input.key))) + EOL)
process.stdout.write((yield* ServiceConfig.get(Option.getOrUndefined(input.key))) + EOL)
}),
)

View file

@ -1,13 +1,16 @@
import { EOL } from "os"
import * as Effect from "effect/Effect"
import { Service } from "@opencode-ai/client/effect"
import { Commands } from "../../commands"
import { Runtime } from "../../../framework/runtime"
import { Service } from "../../../services/service"
import { ServiceConfig } from "../../../services/service-config"
export default Runtime.handler(
Commands.commands.service.commands.restart,
Effect.fn("cli.service.restart")(function* () {
yield* Service.stop()
process.stdout.write((yield* Service.start()).url + EOL)
const options = yield* ServiceConfig.options()
yield* Service.stop(options)
const transport = yield* Service.start(options)
process.stdout.write(transport.url + EOL)
}),
)

View file

@ -1,11 +1,11 @@
import * as Effect from "effect/Effect"
import { Commands } from "../../commands"
import { Runtime } from "../../../framework/runtime"
import { Service } from "../../../services/service"
import { ServiceConfig } from "../../../services/service-config"
export default Runtime.handler(
Commands.commands.service.commands.set,
Effect.fn("cli.service.set")(function* (input) {
yield* Service.set(input.key, input.value)
yield* ServiceConfig.set(input.key, input.value)
}),
)

View file

@ -1,12 +1,14 @@
import { EOL } from "os"
import * as Effect from "effect/Effect"
import { Service } from "@opencode-ai/client/effect"
import { Commands } from "../../commands"
import { Runtime } from "../../../framework/runtime"
import { Service } from "../../../services/service"
import { ServiceConfig } from "../../../services/service-config"
export default Runtime.handler(
Commands.commands.service.commands.start,
Effect.fn("cli.service.start")(function* () {
process.stdout.write((yield* Service.start()).url + EOL)
const transport = yield* Service.start(yield* ServiceConfig.options())
process.stdout.write(transport.url + EOL)
}),
)

View file

@ -1,13 +1,14 @@
import { EOL } from "os"
import * as Effect from "effect/Effect"
import { Service } from "@opencode-ai/client/effect"
import { Commands } from "../../commands"
import { Runtime } from "../../../framework/runtime"
import { Service } from "../../../services/service"
import { ServiceConfig } from "../../../services/service-config"
export default Runtime.handler(
Commands.commands.service.commands.status,
Effect.fn("cli.service.status")(function* () {
const found = yield* Service.discover()
const found = yield* Service.discover(yield* ServiceConfig.options())
process.stdout.write((found ? found.url : "stopped") + EOL)
}),
)

View file

@ -1,11 +1,12 @@
import * as Effect from "effect/Effect"
import { Service } from "@opencode-ai/client/effect"
import { Commands } from "../../commands"
import { Runtime } from "../../../framework/runtime"
import { Service } from "../../../services/service"
import { ServiceConfig } from "../../../services/service-config"
export default Runtime.handler(
Commands.commands.service.commands.stop,
Effect.fn("cli.service.stop")(function* () {
yield* Service.stop()
yield* Service.stop(yield* ServiceConfig.options())
}),
)

View file

@ -1,11 +1,11 @@
import * as Effect from "effect/Effect"
import { Commands } from "../../commands"
import { Runtime } from "../../../framework/runtime"
import { Service } from "../../../services/service"
import { ServiceConfig } from "../../../services/service-config"
export default Runtime.handler(
Commands.commands.service.commands.unset,
Effect.fn("cli.service.unset")(function* (input) {
yield* Service.unset(input.key)
yield* ServiceConfig.unset(input.key)
}),
)

View file

@ -0,0 +1,143 @@
import { Global } from "@opencode-ai/core/global"
import { InstallationChannel, InstallationVersion } from "@opencode-ai/core/installation/version"
import { Service } from "@opencode-ai/client/effect"
import { Effect, FileSystem, Schema } from "effect"
import { randomBytes } from "crypto"
import path from "path"
// The CLI's service configuration file, plus the ServiceOptions binding that
// points the client package's service operations at this CLI: which
// registration file (by channel), which version, and how to spawn opencode.
export const Info = 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 Info = typeof Info.Type
const keys = ["hostname", "port", "password"] as const
type Key = (typeof keys)[number]
const decodeInfo = Schema.decodeUnknownEffect(Schema.fromJsonString(Info))
function configKey(key: string): Key {
if (keys.includes(key as Key)) return key as Key
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,
file: path.join(global.state, filename),
configFile: path.join(global.config, filename),
}
})
export 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 read = Effect.fn("cli.service-config.read")(function* () {
const { fs, configFile } = yield* env
return yield* fs.readFileString(configFile).pipe(
Effect.flatMap(decodeInfo),
Effect.catch(() => Effect.succeed({} as Info)),
)
})
const write = Effect.fn("cli.service-config.write")(function* (value: Info) {
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-config.password")(function* (value?: string) {
const existing = yield* read()
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* write({ ...existing, password: next })
return next
})
export const get = Effect.fn("cli.service-config.get")(function* (key?: string) {
if (key === undefined) {
const { password: _password, ...safe } = yield* read()
return JSON.stringify(safe, null, 2)
}
switch (configKey(key)) {
case "hostname": {
return (yield* read()).hostname ?? ""
}
case "port": {
const port = (yield* read()).port
return port === undefined ? "" : String(port)
}
case "password": {
return yield* password()
}
}
})
export const set = Effect.fn("cli.service-config.set")(function* (key: string, value: string) {
switch (configKey(key)) {
case "hostname": {
yield* Service.stop(yield* options())
yield* write({ ...(yield* read()), 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* Service.stop(yield* options())
yield* write({ ...(yield* read()), port })
return
}
case "password": {
yield* Service.stop(yield* options())
yield* password(value)
return
}
}
})
export const unset = Effect.fn("cli.service-config.unset")(function* (key: string) {
switch (configKey(key)) {
case "hostname": {
yield* Service.stop(yield* options())
const { hostname: _hostname, ...next } = yield* read()
yield* write(next)
return
}
case "port": {
yield* Service.stop(yield* options())
const { port: _port, ...next } = yield* read()
yield* write(next)
return
}
case "password": {
yield* Service.stop(yield* options())
const { password: _password, ...next } = yield* read()
yield* write(next)
return
}
}
})
export * as ServiceConfig from "./service-config"

View file

@ -1,203 +0,0 @@
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"

View file

@ -4,8 +4,8 @@ import { Effect } from "effect"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { Global } from "@opencode-ai/core/global"
import { loadBuiltinPlugins } from "@opencode-ai/tui/builtins"
import { OpenCode } from "@opencode-ai/client"
import type { Transport } from "@opencode-ai/client/service"
import { OpenCode } from "@opencode-ai/client/promise"
import type { Transport } from "@opencode-ai/client/effect"
import { createOpencodeClient } from "@opencode-ai/sdk/v2/client"
import type { Args } from "@opencode-ai/tui/context/args"