refactor(cli): centralize server resolution
This commit is contained in:
parent
be7a684791
commit
cccd013801
13 changed files with 152 additions and 292 deletions
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue