mirror of
https://github.com/anomalyco/opencode.git
synced 2026-07-10 10:08:28 +00:00
Apply PR #27805: Discover running serve instances from TUI
This commit is contained in:
commit
645df43b0b
5 changed files with 173 additions and 21 deletions
|
|
@ -1,24 +1,42 @@
|
|||
import { Effect } from "effect"
|
||||
import { ServerDiscovery } from "@/cli/server-discovery"
|
||||
import { effectCmd } from "../effect-cmd"
|
||||
import { withNetworkOptions, resolveNetworkOptions } from "../network"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
|
||||
export const ServeCommand = effectCmd({
|
||||
command: "serve",
|
||||
builder: (yargs) => withNetworkOptions(yargs),
|
||||
builder: (yargs) =>
|
||||
withNetworkOptions(yargs).option("discoverable", {
|
||||
type: "boolean",
|
||||
describe: "write this server to the local discovery file for default TUI startup",
|
||||
default: false,
|
||||
}),
|
||||
describe: "starts a headless opencode server",
|
||||
// Server loads instances per-request via x-opencode-directory header — no
|
||||
// need for an ambient project InstanceContext at startup.
|
||||
instance: false,
|
||||
handler: Effect.fn("Cli.serve")(function* (args) {
|
||||
const { Server } = yield* Effect.promise(() => import("../../server/server"))
|
||||
if (!Flag.OPENCODE_SERVER_PASSWORD) {
|
||||
console.log("Warning: OPENCODE_SERVER_PASSWORD is not set; server is unsecured.")
|
||||
}
|
||||
const opts = yield* resolveNetworkOptions(args)
|
||||
const server = yield* Effect.promise(() => Server.listen(opts))
|
||||
console.log(`opencode server listening on http://${server.hostname}:${server.port}`)
|
||||
handler: (args) =>
|
||||
Effect.gen(function* () {
|
||||
const { Server } = yield* Effect.promise(() => import("../../server/server"))
|
||||
if (!Flag.OPENCODE_SERVER_PASSWORD) {
|
||||
console.log("Warning: OPENCODE_SERVER_PASSWORD is not set; server is unsecured.")
|
||||
}
|
||||
const opts = yield* resolveNetworkOptions(args)
|
||||
const server = yield* Effect.promise(() => Server.listen(opts))
|
||||
const discovery = args.discoverable ? yield* ServerDiscovery.Service : undefined
|
||||
if (discovery) {
|
||||
yield* discovery.write(server.url)
|
||||
process.on("exit", ServerDiscovery.removeSync)
|
||||
}
|
||||
console.log(`opencode server listening on http://${server.hostname}:${server.port}`)
|
||||
|
||||
yield* Effect.never
|
||||
}),
|
||||
yield* Effect.never.pipe(
|
||||
Effect.ensuring(
|
||||
discovery
|
||||
? discovery.remove().pipe(Effect.ensuring(Effect.sync(() => process.off("exit", ServerDiscovery.removeSync))))
|
||||
: Effect.void,
|
||||
),
|
||||
)
|
||||
}).pipe(Effect.provide(ServerDiscovery.defaultLayer)),
|
||||
})
|
||||
|
|
|
|||
|
|
@ -8,6 +8,8 @@ import { errorMessage } from "@opencode-ai/tui/util/error"
|
|||
import { withTimeout } from "@/util/timeout"
|
||||
import { withNetworkOptions, resolveNetworkOptionsNoConfig } from "@/cli/network"
|
||||
import { Filesystem } from "@/util/filesystem"
|
||||
import { ServerAuth } from "@/server/auth"
|
||||
import { ServerDiscovery } from "@/cli/server-discovery"
|
||||
import type { GlobalEvent } from "@opencode-ai/sdk/v2"
|
||||
import type { EventSource } from "@opencode-ai/tui/context/sdk"
|
||||
import { writeHeapSnapshot } from "v8"
|
||||
|
|
@ -153,16 +155,26 @@ export const TuiThreadCommand = cmd({
|
|||
network.mdns ||
|
||||
network.port !== 0 ||
|
||||
network.hostname !== "127.0.0.1"
|
||||
const discovered = external ? undefined : await ServerDiscovery.find()
|
||||
|
||||
const transport = external
|
||||
? {
|
||||
url: (await client.call("server", network)).url,
|
||||
fetch: undefined,
|
||||
headers: ServerAuth.headers(),
|
||||
events: undefined,
|
||||
}
|
||||
: discovered
|
||||
? {
|
||||
url: discovered,
|
||||
fetch: undefined,
|
||||
headers: ServerAuth.headers(),
|
||||
events: undefined,
|
||||
}
|
||||
: {
|
||||
url: "http://opencode.internal",
|
||||
fetch: createWorkerFetch(client),
|
||||
headers: undefined,
|
||||
events: createEventSource(client),
|
||||
}
|
||||
|
||||
|
|
@ -172,6 +184,7 @@ export const TuiThreadCommand = cmd({
|
|||
sessionID: args.session,
|
||||
directory: cwd,
|
||||
fetch: transport.fetch,
|
||||
headers: transport.headers,
|
||||
})
|
||||
} catch (error) {
|
||||
UI.error(errorMessage(error))
|
||||
|
|
@ -199,6 +212,7 @@ export const TuiThreadCommand = cmd({
|
|||
pluginHost: createLegacyTuiPluginHost(),
|
||||
directory: cwd,
|
||||
fetch: transport.fetch,
|
||||
headers: transport.headers,
|
||||
events: transport.events,
|
||||
args: {
|
||||
continue: args.continue,
|
||||
|
|
|
|||
112
packages/opencode/src/cli/server-discovery.ts
Normal file
112
packages/opencode/src/cli/server-discovery.ts
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
export * as ServerDiscovery from "./server-discovery"
|
||||
|
||||
import { makeRuntime } from "@/effect/run-service"
|
||||
import { ServerAuth } from "@/server/auth"
|
||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { Context, Effect, Layer, Option, Schema } from "effect"
|
||||
import { readFileSync, unlinkSync } from "fs"
|
||||
import path from "path"
|
||||
|
||||
export const file = path.join(Global.Path.state, "server.json")
|
||||
|
||||
const Entry = Schema.Struct({
|
||||
url: Schema.String,
|
||||
pid: Schema.Number,
|
||||
})
|
||||
type Entry = typeof Entry.Type
|
||||
const decodeEntry = Schema.decodeUnknownOption(Entry)
|
||||
|
||||
export interface Interface {
|
||||
readonly write: (url: URL) => Effect.Effect<void>
|
||||
readonly remove: () => Effect.Effect<void>
|
||||
readonly find: () => Effect.Effect<string | undefined>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/CliServerDiscovery") {}
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* FSUtil.Service
|
||||
|
||||
const read = Effect.fn("CliServerDiscovery.read")(function* () {
|
||||
const entry = yield* fs.readJson(file).pipe(Effect.catch(() => Effect.succeed(undefined)))
|
||||
return Option.getOrUndefined(decodeEntry(entry))
|
||||
})
|
||||
|
||||
const remove = Effect.fn("CliServerDiscovery.remove")(function* () {
|
||||
const entry = yield* read()
|
||||
if (entry?.pid !== process.pid) return
|
||||
yield* fs.remove(file).pipe(Effect.ignore)
|
||||
})
|
||||
|
||||
const removeStale = Effect.fn("CliServerDiscovery.removeStale")(function* (entry: Entry) {
|
||||
const current = yield* read()
|
||||
if (current?.pid !== entry.pid || current.url !== entry.url) return
|
||||
yield* fs.remove(file).pipe(Effect.ignore)
|
||||
})
|
||||
|
||||
return Service.of({
|
||||
write: Effect.fn("CliServerDiscovery.write")(function* (url) {
|
||||
yield* fs.writeJson(file, { url: localURL(url).toString(), pid: process.pid }, 0o600).pipe(Effect.orDie)
|
||||
}),
|
||||
remove,
|
||||
find: Effect.fn("CliServerDiscovery.find")(function* () {
|
||||
const entry = yield* read()
|
||||
if (!entry) return undefined
|
||||
const url = yield* healthy(entry.url)
|
||||
if (url) return url
|
||||
yield* removeStale(entry)
|
||||
}),
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
export const defaultLayer = layer.pipe(Layer.provide(FSUtil.defaultLayer))
|
||||
|
||||
const { runPromise } = makeRuntime(Service, defaultLayer)
|
||||
|
||||
export const find = () => runPromise((discovery) => discovery.find())
|
||||
|
||||
export function removeSync() {
|
||||
const entry = readSync()
|
||||
if (entry?.pid !== process.pid) return
|
||||
try {
|
||||
unlinkSync(file)
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function readSync() {
|
||||
try {
|
||||
return Option.getOrUndefined(decodeEntry(JSON.parse(readFileSync(file, "utf8"))))
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
function healthy(input: string) {
|
||||
return Effect.tryPromise({
|
||||
try: async () => {
|
||||
const url = new URL(input)
|
||||
if (url.protocol !== "http:" && url.protocol !== "https:") return undefined
|
||||
const response = await fetch(new URL("/global/health", url), {
|
||||
headers: ServerAuth.headers(),
|
||||
signal: AbortSignal.timeout(1000),
|
||||
})
|
||||
if (!response.ok) return undefined
|
||||
const body = (await response.json()) as unknown
|
||||
if (typeof body === "object" && body !== null && "healthy" in body && body.healthy === true) {
|
||||
return url.toString()
|
||||
}
|
||||
},
|
||||
catch: () => undefined,
|
||||
}).pipe(Effect.catch(() => Effect.succeed(undefined)))
|
||||
}
|
||||
|
||||
function localURL(url: URL) {
|
||||
const result = new URL(url)
|
||||
if (result.hostname === "0.0.0.0") result.hostname = "127.0.0.1"
|
||||
if (result.hostname === "::") result.hostname = "::1"
|
||||
return result
|
||||
}
|
||||
|
|
@ -219,18 +219,20 @@ exports[`opencode CLI help-text snapshots every documented command emits stable
|
|||
starts a headless opencode server
|
||||
|
||||
Options:
|
||||
-h, --help show help [boolean]
|
||||
-v, --version show version number [boolean]
|
||||
--print-logs print logs to stderr [boolean]
|
||||
--log-level log level [string] [choices: "DEBUG", "INFO", "WARN", "ERROR"]
|
||||
--pure run without external plugins [boolean]
|
||||
--port port to listen on [number] [default: 0]
|
||||
--hostname hostname to listen on [string] [default: "127.0.0.1"]
|
||||
--mdns enable mDNS service discovery (defaults hostname to 0.0.0.0)
|
||||
-h, --help show help [boolean]
|
||||
-v, --version show version number [boolean]
|
||||
--print-logs print logs to stderr [boolean]
|
||||
--log-level log level [string] [choices: "DEBUG", "INFO", "WARN", "ERROR"]
|
||||
--pure run without external plugins [boolean]
|
||||
--port port to listen on [number] [default: 0]
|
||||
--hostname hostname to listen on [string] [default: "127.0.0.1"]
|
||||
--mdns enable mDNS service discovery (defaults hostname to 0.0.0.0)
|
||||
[boolean] [default: false]
|
||||
--mdns-domain custom domain name for mDNS service (default: opencode.local)
|
||||
--mdns-domain custom domain name for mDNS service (default: opencode.local)
|
||||
[string] [default: "opencode.local"]
|
||||
--cors additional domains to allow for CORS [array] [default: []]"
|
||||
--cors additional domains to allow for CORS [array] [default: []]
|
||||
--discoverable write this server to the local discovery file for default TUI startup
|
||||
[boolean] [default: false]"
|
||||
`;
|
||||
|
||||
exports[`opencode CLI help-text snapshots every documented command emits stable help text: opencode web --help 1`] = `
|
||||
|
|
|
|||
|
|
@ -3,12 +3,14 @@ import { fileURLToPath } from "bun"
|
|||
import { useTheme } from "../context/theme"
|
||||
import { useDialog } from "../ui/dialog"
|
||||
import { useSync } from "../context/sync"
|
||||
import { useSDK } from "../context/sdk"
|
||||
import { For, Match, Switch, Show, createMemo } from "solid-js"
|
||||
|
||||
export type DialogStatusProps = {}
|
||||
|
||||
export function DialogStatus() {
|
||||
const sync = useSync()
|
||||
const sdk = useSDK()
|
||||
const { theme } = useTheme()
|
||||
const dialog = useDialog()
|
||||
|
||||
|
|
@ -50,6 +52,10 @@ export function DialogStatus() {
|
|||
esc
|
||||
</text>
|
||||
</box>
|
||||
<text fg={theme.text}>
|
||||
Server{" "}
|
||||
<span style={{ fg: theme.textMuted }}>{sdk.url === "http://opencode.internal" ? "Embedded" : sdk.url}</span>
|
||||
</text>
|
||||
<Show when={Object.keys(sync.data.mcp).length > 0} fallback={<text fg={theme.text}>No MCP Servers</text>}>
|
||||
<box>
|
||||
<text fg={theme.text}>{Object.keys(sync.data.mcp).length} MCP Servers</text>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue