86 lines
3.9 KiB
TypeScript
86 lines
3.9 KiB
TypeScript
import { NodeFileSystem } from "@effect/platform-node"
|
|
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 { 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 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* () {
|
|
if (server !== undefined) {
|
|
const password = yield* Env.password
|
|
const explicit = {
|
|
url: server,
|
|
headers: password
|
|
? { authorization: "Basic " + btoa("opencode:" + Redacted.value(password)) }
|
|
: undefined,
|
|
} satisfies Service.Transport
|
|
// 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) }),
|
|
).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.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 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)
|
|
// 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
|
|
yield* runTui(
|
|
transport,
|
|
{ continue: input.continue, sessionID: Option.getOrUndefined(input.session) },
|
|
discover,
|
|
reload,
|
|
)
|
|
}),
|
|
)
|