refactor(cli): centralize server resolution

This commit is contained in:
Dax Raad 2026-07-08 22:09:59 -04:00
parent be7a684791
commit cccd013801
13 changed files with 152 additions and 292 deletions

View file

@ -28,21 +28,25 @@ const MiniParams = {
),
}
const ServerParams = {
standalone: Flag.boolean("standalone").pipe(
Flag.withDescription("Run with a private server instead of the background service"),
Flag.withDefault(false),
),
server: Flag.string("server").pipe(
Flag.withDescription("Connect to a server URL instead of the background service"),
Flag.optional,
),
}
export const Commands = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME : "opencode", {
description: "OpenCode 2.0 preview command line interface",
params: {
...ServerParams,
directory: Argument.string("directory").pipe(
Argument.withDescription("Directory to start OpenCode in"),
Argument.optional,
),
standalone: Flag.boolean("standalone").pipe(
Flag.withDescription("Run with a private server instead of the background service"),
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(
Flag.withAlias("c"),
Flag.withDescription("Continue the last session"),
@ -128,6 +132,7 @@ export const Commands = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCO
description: "Start the minimal interactive interface",
params: {
...MiniParams,
...ServerParams,
project: Argument.string("project").pipe(
Argument.withDescription("Path to start OpenCode in"),
Argument.optional,
@ -139,16 +144,13 @@ export const Commands = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCO
),
agent: Flag.string("agent").pipe(Flag.withDescription("Agent to use"), Flag.optional),
prompt: Flag.string("prompt").pipe(Flag.withDescription("Prompt to use"), Flag.optional),
server: Flag.string("server").pipe(
Flag.withDescription("Connect to a server URL instead of the background service"),
Flag.optional,
),
demo: Flag.boolean("demo").pipe(Flag.withDefault(false), Flag.withHidden),
},
}),
Spec.make("run", {
description: "Run OpenCode with a message",
params: {
...ServerParams,
message: Argument.string("message").pipe(
Argument.withDescription("Message to send"),
Argument.variadic({ min: 0 }),
@ -183,11 +185,6 @@ export const Commands = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCO
Flag.atMost(100),
),
title: Flag.string("title").pipe(Flag.withDescription("Session title"), Flag.optional),
server: Flag.string("server").pipe(
Flag.withDescription("Connect to a server URL instead of the background service"),
Flag.optional,
),
dir: Flag.string("dir").pipe(Flag.withDescription("Directory to run in"), Flag.optional),
variant: Flag.string("variant").pipe(Flag.withDescription("Model variant"), Flag.optional),
thinking: Flag.boolean("thinking").pipe(
Flag.withDescription("Show thinking blocks"),

View file

@ -1,4 +1,3 @@
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"
@ -6,11 +5,8 @@ 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"
import { Service } from "@opencode-ai/client/effect"
import { Env } from "../../env"
import { ServiceConfig } from "../../services/service-config"
import { Standalone } from "../../services/standalone"
import { Effect, Option } from "effect"
import { Server } from "../../services/server"
import { Updater } from "../../services/updater"
export default Runtime.handler(Commands, (input) =>
@ -19,82 +15,15 @@ export default Runtime.handler(Commands, (input) =>
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 endpoint = yield* Effect.gen(function* () {
if (server !== undefined) {
const password = yield* Env.password
const explicit = {
url: server,
auth: password
? { type: "basic" as const, username: "opencode", password: Redacted.value(password) }
: undefined,
} 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: 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(
new Error(
password
? `Server at ${server} rejected the password`
: `Server at ${server} requires a password; set OPENCODE_PASSWORD`,
),
)
if (!response.ok)
return yield* Effect.fail(new Error(`Server at ${server} responded with status ${response.status}`))
return explicit
}
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 server = yield* Server.resolve({
server: Option.getOrUndefined(input.server),
standalone: input.standalone,
})
// The TUI re-runs discover whenever its event stream drops. For an explicit
// --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
// Only startup enforces the CLI version. A reconnect must accept a server
// replaced by another client or the two clients will restart it forever.
const reconnectOptions = serviceOptions ? { ...serviceOptions, version: undefined } : undefined
const discover = reconnectOptions
? () =>
Effect.runPromise(
Effect.gen(function* () {
const found = yield* Service.discover(reconnectOptions)
return found ?? (yield* Service.start(reconnectOptions))
}).pipe(Effect.provide(NodeFileSystem.layer)),
)
: 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
// standalone child cannot be respawned.
const reload = serviceOptions
? () =>
Effect.runPromise(
Effect.gen(function* () {
yield* Service.stop(serviceOptions)
yield* Service.start(serviceOptions)
}).pipe(Effect.provide(NodeFileSystem.layer)),
)
: undefined
const config = TuiConfig.resolve({}, { terminalSuspend: false })
let disposeSlots: (() => void) | undefined
const runFork = Effect.runForkWith(yield* Effect.context())
yield* run({
server: {
endpoint,
discover,
reload,
},
server,
args: { continue: input.continue, sessionID: Option.getOrUndefined(input.session) },
config,
log: (level, message, tags) => {

View file

@ -1,25 +1,21 @@
import { Effect, Option, Redacted } from "effect"
import { Effect, Option } from "effect"
import path from "node:path"
import { Commands } from "../commands"
import { Env } from "../../env"
import { Runtime } from "../../framework/runtime"
import { Server } from "../../services/server"
export default Runtime.handler(Commands.commands.mini, (input) =>
Effect.gen(function* () {
const { runMini } = yield* Effect.promise(() => import("../../mini"))
const { runMini, validateMiniTerminal } = yield* Effect.promise(() => import("../../mini"))
yield* Effect.promise(async () => validateMiniTerminal())
const project = Option.getOrUndefined(input.project)
const server = Option.getOrUndefined(input.server)
const password = yield* Env.password
const serverURL = Option.getOrUndefined(input.server)
const server = yield* Server.resolve({ server: serverURL, standalone: input.standalone })
yield* Effect.promise(() =>
runMini({
attach: server,
password: password ? Redacted.value(password) : undefined,
server,
directory:
server !== undefined
? project
: project === undefined
? process.cwd()
: path.resolve(process.env.PWD ?? process.cwd(), project),
project === undefined ? process.cwd() : path.resolve(process.env.PWD ?? process.cwd(), project),
continue: input.continue,
session: Option.getOrUndefined(input.session),
fork: input.fork,

View file

@ -1,15 +1,19 @@
import { Effect, Option, Redacted } from "effect"
import { Effect, Option } from "effect"
import { Commands } from "../commands"
import { Env } from "../../env"
import { Runtime } from "../../framework/runtime"
import { Server } from "../../services/server"
export default Runtime.handler(Commands.commands.run, (input) =>
Effect.gen(function* () {
const { runNonInteractive } = yield* Effect.promise(() => import("../../mini"))
const password = yield* Env.password
const separator = process.argv.indexOf("--", 2)
const server = yield* Server.resolve({
server: Option.getOrUndefined(input.server),
standalone: input.standalone,
})
yield* Effect.promise(() =>
runNonInteractive({
server,
message: [...input.message, ...(separator === -1 ? [] : process.argv.slice(separator + 1))],
continue: input.continue,
session: Option.getOrUndefined(input.session),
@ -19,9 +23,6 @@ export default Runtime.handler(Commands.commands.run, (input) =>
format: input.format,
file: [...input.file],
title: Option.getOrUndefined(input.title),
server: Option.getOrUndefined(input.server),
password: password ? Redacted.value(password) : undefined,
directory: Option.getOrUndefined(input.dir),
variant: Option.getOrUndefined(input.variant),
thinking: input.thinking,
dangerouslySkipPermissions: input.auto || input.yolo || input.dangerouslySkipPermissions,

View file

@ -1,46 +0,0 @@
import { Service } from "@opencode-ai/client/effect"
import { ClientError, isUnauthorizedError, OpenCode } from "@opencode-ai/client/promise"
import { InstallationVersion } from "@opencode-ai/core/installation/version"
import { Effect } from "effect"
type ConnectOptions = {
readonly url: string
readonly username?: string
readonly password?: string
}
export const connect = Effect.fn("cli.daemon.connect")(function* (options: ConnectOptions) {
const endpoint = {
url: options.url,
auth:
options.password === undefined
? undefined
: { type: "basic" as const, username: options.username ?? "opencode", password: options.password },
} satisfies Service.Endpoint
const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) })
const health = yield* Effect.tryPromise({
try: () => client.health.get({ signal: AbortSignal.timeout(5_000) }),
catch: (cause) => connectError(options, cause),
})
if (health.version !== InstallationVersion)
process.stderr.write(
`Warning: Server at ${options.url} has version ${health.version}; this client is ${InstallationVersion}. Continuing anyway.\n`,
)
return endpoint
})
function connectError(options: ConnectOptions, cause: unknown) {
if (isUnauthorizedError(cause)) {
return new Error(
options.password === undefined
? `Server at ${options.url} requires authentication; provide a password`
: `Server at ${options.url} rejected the supplied credentials`,
{ cause },
)
}
if (cause instanceof ClientError && cause.reason === "Transport")
return new Error(`Could not reach server at ${options.url}`, { cause })
return new Error(`Server at ${options.url} did not provide a compatible V2 health response`, { cause })
}
export * as Daemon from "./daemon"

View file

@ -1,4 +1,4 @@
export { runMini, mergeInput as mergeInteractiveInput, type MiniCommandInput } from "./mini"
export { runMini, validateMiniTerminal, mergeInput as mergeInteractiveInput, type MiniCommandInput } from "./mini"
export {
runNonInteractive,
mergeInput as mergeNonInteractiveInput,

View file

@ -1,20 +1,14 @@
import { NodeFileSystem } from "@effect/platform-node"
import { Service } from "@opencode-ai/client/effect"
import { OpenCode, type OpenCodeClient } from "@opencode-ai/client/promise"
import { Global } from "@opencode-ai/core/global"
import { Effect } from "effect"
import path from "node:path"
import { Daemon } from "../daemon"
import { ServiceConfig } from "../services/service-config"
import { Server } from "../services/server"
import { waitForCatalogReady } from "./catalog.shared"
import { INTERACTIVE_INPUT_ERROR, resolveInteractiveStdin } from "./runtime.stdin"
import type { RunInput, RunTuiConfig } from "./types"
export type MiniCommandInput = {
server: Server.Resolved
directory?: string
attach?: string
password?: string
username?: string
continue?: boolean
session?: string
fork?: boolean
@ -24,7 +18,6 @@ export type MiniCommandInput = {
replay?: boolean
replayLimit?: number
demo?: boolean
serverCommand?: ReadonlyArray<string>
tuiConfig?: RunTuiConfig | Promise<RunTuiConfig>
}
@ -33,47 +26,38 @@ export async function runMini(input: MiniCommandInput) {
validate(input)
const initialInput = mergeInput(process.stdin.isTTY ? undefined : await Bun.stdin.text(), input.prompt)
const runtimeTask = import("./runtime")
const directory = input.attach ? input.directory : localDirectory(input.directory)
const endpointTask = startEndpoint(input)
void endpointTask.catch(() => {})
const directory = localDirectory(input.directory)
try {
if (input.attach) await endpointTask
const sdk = OpenCode.make({
baseUrl: "http://opencode.pending",
fetch: deferredFetch(endpointTask),
baseUrl: input.server.endpoint.url,
headers: Service.headers(input.server.endpoint),
})
const attachedSession =
input.attach && input.session && !input.directory
? await sdk.session.get({ sessionID: input.session }).catch(() => fail("Session not found"))
: undefined
const resolvedDirectory =
directory ?? attachedSession?.location.directory ?? (await remoteDirectory(await endpointTask, sdk))
const model = parseModel(input.model)
let agentTask: Promise<string | undefined> | undefined
const resolveAgent = () => {
agentTask ??= validateAgent(sdk, resolvedDirectory, input.agent, input.attach)
agentTask ??= validateAgent(sdk, directory, input.agent)
return agentTask
}
const resolveSession = async () => {
const [agent, selected] = await Promise.all([
resolveAgent(),
selectSession(sdk, resolvedDirectory, input, attachedSession),
selectSession(sdk, directory, input),
])
const readyModel =
model ?? (selected?.model ? { providerID: selected.model.providerID, modelID: selected.model.id } : undefined)
if (readyModel) await waitForCatalogReady({ sdk, directory: resolvedDirectory, model: readyModel })
const session = selected ?? (await createSession(sdk, resolvedDirectory, agent, model))
if (readyModel) await waitForCatalogReady({ sdk, directory, model: readyModel })
const session = selected ?? (await createSession(sdk, directory, agent, model))
return { id: session.id, title: session.title, resume: selected !== undefined }
}
const create = (
_sdk: OpenCodeClient,
next: { agent: string | undefined; model: RunInput["model"]; variant: string | undefined },
) => createSession(sdk, resolvedDirectory, next.agent, next.model, next.variant)
) => createSession(sdk, directory, next.agent, next.model, next.variant)
const runtime = await runtimeTask
await runtime.runInteractiveDeferredMode({
sdk,
directory: resolvedDirectory,
directory,
resolveAgent,
session: resolveSession,
createSession: create,
@ -94,6 +78,10 @@ export async function runMini(input: MiniCommandInput) {
}
}
export function validateMiniTerminal() {
if (!process.stdout.isTTY) fail("opencode mini requires a TTY stdout")
}
/** @internal Exported for testing. */
export function mergeInput(piped: string | undefined, prompt: string | undefined) {
if (!prompt) return piped || undefined
@ -102,7 +90,7 @@ export function mergeInput(piped: string | undefined, prompt: string | undefined
}
function validate(input: MiniCommandInput) {
if (!process.stdout.isTTY) fail("opencode mini requires a TTY stdout")
validateMiniTerminal()
if (input.replayLimit !== undefined && (!Number.isInteger(input.replayLimit) || input.replayLimit <= 0)) {
fail("--replay-limit must be a positive integer")
}
@ -120,50 +108,6 @@ function localDirectory(directory?: string): string {
}
}
function startEndpoint(input: MiniCommandInput): Promise<Service.Endpoint> {
if (input.attach) {
return Effect.runPromise(
Daemon.connect({
url: input.attach,
password: input.password ?? process.env.OPENCODE_SERVER_PASSWORD,
username: input.username ?? process.env.OPENCODE_SERVER_USERNAME,
}),
)
}
return Effect.runPromise(
Effect.gen(function* () {
const options = yield* ServiceConfig.options()
return yield* Service.start(
input.serverCommand === undefined ? options : { ...options, command: input.serverCommand },
)
}).pipe(
Effect.provide(NodeFileSystem.layer),
Effect.provide(Global.layerWith({})),
),
)
}
function deferredFetch(endpointTask: Promise<Service.Endpoint>): typeof globalThis.fetch {
const fetch = async (input: RequestInfo | URL, init?: RequestInit) => {
const endpoint = await endpointTask
const request = new Request(input, init)
const source = new URL(request.url)
const headers = new Headers(request.headers)
for (const [key, value] of new Headers(Service.headers(endpoint))) headers.set(key, value)
return globalThis.fetch(new Request(new URL(source.pathname + source.search, endpoint.url), request), { headers })
}
return fetch as typeof globalThis.fetch
}
async function remoteDirectory(
endpoint: Service.Endpoint,
sdk: OpenCodeClient,
): Promise<string> {
const location = await sdk.location.get()
if (!location.directory) throw new Error(`Failed to resolve remote directory from ${endpoint.url}`)
return location.directory
}
function parseModel(value?: string): RunInput["model"] {
if (!value) return
const [providerID, ...rest] = value.split("/")
@ -172,7 +116,7 @@ function parseModel(value?: string): RunInput["model"] {
return { providerID, modelID }
}
async function validateAgent(sdk: OpenCodeClient, directory: string, name?: string, attach?: string) {
async function validateAgent(sdk: OpenCodeClient, directory: string, name?: string) {
if (!name) return
const deadline = Date.now() + 5_000
let agents: Awaited<ReturnType<OpenCodeClient["agent"]["list"]>> | undefined
@ -187,7 +131,7 @@ async function validateAgent(sdk: OpenCodeClient, directory: string, name?: stri
await Bun.sleep(25)
}
if (!agents) {
warning(`failed to list agents${attach ? ` from ${attach}` : ""}. Falling back to default agent`)
warning("failed to list agents. Falling back to default agent")
return
}
warning(`agent "${name}" not found. Falling back to default agent`)

View file

@ -1,21 +1,17 @@
import { NodeFileSystem } from "@effect/platform-node"
import { Service } from "@opencode-ai/client/effect"
import { OpenCode, type OpenCodeClient } from "@opencode-ai/client/promise"
import { Global } from "@opencode-ai/core/global"
import { FSUtil } from "@opencode-ai/core/fs-util"
import type { ToolPart } from "@opencode-ai/sdk/v2"
import { Effect } from "effect"
import { open } from "node:fs/promises"
import path from "node:path"
import { Daemon } from "../daemon"
import { ServiceConfig } from "../services/service-config"
import { Standalone } from "../services/standalone"
import { Server } from "../services/server"
import { loadRunAgents, waitForCatalogReady } from "./catalog.shared"
import { runNonInteractivePrompt } from "./noninteractive"
import { toolInlineInfo } from "./tool"
import { UI } from "./ui"
export type RunCommandInput = {
server: Server.Resolved
message: string[]
continue?: boolean
session?: string
@ -25,14 +21,9 @@ export type RunCommandInput = {
format: "default" | "json"
file: string[]
title?: string
server?: string
password?: string
username?: string
directory?: string
variant?: string
thinking?: boolean
dangerouslySkipPermissions?: boolean
standaloneCommand?: ReadonlyArray<string>
}
type FilePart = {
@ -56,24 +47,12 @@ export function runNonInteractive(input: RunCommandInput) {
async function run(input: RunCommandInput) {
if (input.fork && !input.continue && !input.session) fail("--fork requires --continue or --session")
const root = process.env.PWD ?? process.cwd()
const directory = input.server ? input.directory : localDirectory(input.directory, root)
const directory = localDirectory(root)
const message = mergeInput(formatMessage(input.message), process.stdin.isTTY ? undefined : await Bun.stdin.text())
if (!message?.trim()) fail("You must provide a message")
const files = await Promise.all(
input.file.map((file) => prepareFile(file, input.server ? root : (directory ?? root), input.server !== undefined)),
)
const files = await Promise.all(input.file.map((file) => prepareFile(file, root)))
const prepared = { directory, message, files }
if (input.standaloneCommand)
return Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
const endpoint = yield* Standalone.start({ command: input.standaloneCommand })
yield* Effect.promise(() => execute(input, prepared, endpoint))
}),
),
)
const endpoint = await startEndpoint(input)
return execute(input, prepared, endpoint)
return execute(input, prepared, input.server.endpoint)
}
async function execute(input: RunCommandInput, prepared: Prepared, endpoint: Service.Endpoint) {
@ -100,7 +79,7 @@ async function execute(input: RunCommandInput, prepared: Prepared, endpoint: Ser
if (!available.data.some((item) => item.providerID === model.providerID && item.id === model.modelID))
return reportError(input, `Model unavailable: ${model.providerID}/${model.modelID}`, session?.id)
}
const agent = await validateAgent(client, cwd, input.agent, input.server)
const agent = await validateAgent(client, cwd, input.agent)
const selected =
session ??
(await client.session.create({
@ -126,7 +105,7 @@ async function execute(input: RunCommandInput, prepared: Prepared, endpoint: Ser
thinking: input.thinking ?? false,
format: input.format,
dangerouslySkipPermissions: input.dangerouslySkipPermissions ?? false,
attached: !input.standaloneCommand,
attached: true,
renderTool,
renderToolError,
}).catch((error) => reportError(input, error instanceof Error ? error.message : String(error), selected.id))
@ -154,35 +133,15 @@ function formatMessage(message: string[]) {
return value || undefined
}
function localDirectory(directory: string | undefined, root: string) {
function localDirectory(root: string) {
try {
process.chdir(directory ? (path.isAbsolute(directory) ? directory : path.join(root, directory)) : root)
process.chdir(root)
return process.cwd()
} catch {
fail(`Failed to change directory to ${directory}`)
fail(`Failed to change directory to ${root}`)
}
}
function startEndpoint(input: RunCommandInput) {
if (input.server) {
return Effect.runPromise(
Daemon.connect({
url: input.server,
password: input.password,
username: input.username,
}),
)
}
return Effect.runPromise(
Effect.gen(function* () {
return yield* Service.start(yield* ServiceConfig.options())
}).pipe(
Effect.provide(NodeFileSystem.layer),
Effect.provide(Global.layerWith({})),
),
)
}
function parseModel(value?: string) {
if (!value) return
const [providerID, ...rest] = value.split("/")
@ -191,11 +150,11 @@ function parseModel(value?: string) {
return { providerID, modelID }
}
async function validateAgent(client: OpenCodeClient, directory: string, name?: string, server?: string) {
async function validateAgent(client: OpenCodeClient, directory: string, name?: string) {
if (!name) return
const agents = await loadRunAgents(client, directory).catch(() => undefined)
if (!agents) {
warning(`failed to list agents${server ? ` from ${server}` : ""}. Falling back to default agent`)
warning("failed to list agents. Falling back to default agent")
return
}
const agent = agents.find((item) => item.id === name)
@ -223,12 +182,11 @@ async function selectSession(client: OpenCodeClient, directory: string, input: R
return client.session.fork({ sessionID: selected.id })
}
async function prepareFile(input: string, directory: string, remote: boolean): Promise<FilePart> {
async function prepareFile(input: string, directory: string): Promise<FilePart> {
const file = path.resolve(directory, input)
const handle = await open(file, "r").catch(() => fail(`File not found: ${input}`))
try {
const stat = await handle.stat()
if (remote && stat.isDirectory()) fail(`Cannot attach local directory without a shared filesystem: ${input}`)
if (!stat.isFile() || stat.size > ATTACH_FILE_MAX_BYTES)
fail(`Cannot attach a directory, special file, or file larger than 10 MiB: ${input}`)
const content = Buffer.alloc(Number(stat.size))

View file

@ -0,0 +1,77 @@
import { NodeFileSystem } from "@effect/platform-node"
import { Service } from "@opencode-ai/client/effect"
import { ClientError, isUnauthorizedError, OpenCode } from "@opencode-ai/client/promise"
import { InstallationVersion } from "@opencode-ai/core/installation/version"
import { Effect, Redacted } from "effect"
import { Env } from "../env"
import { ServiceConfig } from "./service-config"
import { Standalone } from "./standalone"
export type Args = {
readonly server?: string
readonly standalone?: boolean
}
export type Resolved = {
readonly endpoint: Service.Endpoint
readonly discover?: () => Promise<Service.Endpoint>
readonly reload?: () => Promise<void>
}
export const resolve = Effect.fn("cli.server.resolve")(function* (args: Args) {
if (args.server !== undefined && args.standalone)
return yield* Effect.fail(new Error("--server and --standalone cannot be combined"))
if (args.server !== undefined) {
const password = yield* Env.password
const endpoint = {
url: args.server,
auth: password
? { type: "basic" as const, username: "opencode", password: Redacted.value(password) }
: undefined,
} satisfies Service.Endpoint
const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) })
const health = yield* Effect.tryPromise({
try: () => client.health.get({ signal: AbortSignal.timeout(5_000) }),
catch: (cause) => connectError(endpoint, cause),
})
if (health.version !== InstallationVersion)
process.stderr.write(
`Warning: Server at ${endpoint.url} has version ${health.version}; this client is ${InstallationVersion}. Continuing anyway.\n`,
)
return { endpoint } satisfies Resolved
}
if (args.standalone) {
return { endpoint: yield* Standalone.start() } satisfies Resolved
}
const options = yield* ServiceConfig.options()
const endpoint = yield* Service.start(options)
const reconnectOptions = { ...options, version: undefined }
return {
endpoint,
discover: () => Effect.runPromise(Service.start(reconnectOptions).pipe(Effect.provide(NodeFileSystem.layer))),
reload: () =>
Effect.runPromise(
Effect.gen(function* () {
yield* Service.stop(options)
yield* Service.start(options)
}).pipe(Effect.provide(NodeFileSystem.layer)),
),
} satisfies Resolved
})
function connectError(endpoint: Service.Endpoint, cause: unknown) {
if (isUnauthorizedError(cause)) {
return new Error(
endpoint.auth === undefined
? `Server at ${endpoint.url} requires a password; set OPENCODE_PASSWORD`
: `Server at ${endpoint.url} rejected the password`,
{ cause },
)
}
if (cause instanceof ClientError && cause.reason === "Transport")
return new Error(`Could not reach server at ${endpoint.url}`, { cause })
return new Error(`Server at ${endpoint.url} did not provide a compatible V2 health response`, { cause })
}
export * as Server from "./server"

View file

@ -71,6 +71,8 @@ describe("mini command", () => {
const url = new URL(request.url)
if (url.pathname === "/api/health")
return Response.json({ healthy: true, version: InstallationVersion, pid: process.pid })
if (url.pathname === "/api/location")
return Response.json({ directory: process.cwd(), project: { id: "global", directory: process.cwd() } })
if (url.pathname === "/api/model") {
modelRequests++
return Response.json({
@ -87,8 +89,6 @@ describe("mini command", () => {
"run",
"--server",
server.url.toString(),
"--dir",
process.cwd(),
"--model",
"definitely/missing",
"hi",
@ -104,7 +104,8 @@ describe("mini command", () => {
test("reports pre-admission errors as JSON", async () => {
const server = Bun.serve({
port: 0,
fetch() {
fetch(request) {
if (new URL(request.url).pathname === "/api/session") return new Response("boom", { status: 500 })
return Response.json({ healthy: true, version: "incompatible", pid: process.pid })
},
})
@ -116,7 +117,7 @@ describe("mini command", () => {
expect(JSON.parse(result.stdout)).toMatchObject({
type: "error",
sessionID: "",
error: { type: "unknown", message: "Failed to resolve server directory" },
error: { type: "unknown", message: "UnexpectedStatus" },
})
} finally {
server.stop(true)

View file

@ -84,6 +84,7 @@ export const AttachCommand = cmd({
const { createLegacyTuiPluginHost } = await import("@/plugin/tui/runtime")
await Effect.runPromise(
run({
// @ts-expect-error V1 does not consume the V2-only server input.
client: createOpencodeClient({ baseUrl: args.url, headers, directory }),
api: OpenCode.make({ baseUrl: args.url, headers }),
config,

View file

@ -112,6 +112,7 @@ export const RunCommand = effectCmd({
file: args.file ?? [],
title: args.title,
server: args.server ?? args.attach,
// @ts-expect-error V1 does not consume the V2-only resolved server input.
password: args.password ?? process.env.OPENCODE_PASSWORD ?? process.env.OPENCODE_SERVER_PASSWORD,
username: args.username ?? process.env.OPENCODE_SERVER_USERNAME,
directory: args.dir,

View file

@ -159,6 +159,7 @@ export const TuiThreadCommand = cmd({
const { createLegacyTuiPluginHost } = await import("@/plugin/tui/runtime")
await Effect.runPromise(
run({
// @ts-expect-error V1 does not consume the V2-only server input.
client: createOpencodeClient({ baseUrl: url, headers, directory: cwd }),
api: OpenCode.make({ baseUrl: url, headers }),
async onSnapshot() {