mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-06 09:10:47 -04:00
Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4ebd1272f2 | |||
| 765b8debe3 | |||
| b54cffc3e1 | |||
| 26f64f29fb | |||
| 13822b9424 |
@@ -1,24 +1,42 @@
|
|||||||
import { Effect } from "effect"
|
import { Effect } from "effect"
|
||||||
import { Server } from "../../server/server"
|
import { Server } from "../../server/server"
|
||||||
|
import { ServerDiscovery } from "@/cli/server-discovery"
|
||||||
import { effectCmd } from "../effect-cmd"
|
import { effectCmd } from "../effect-cmd"
|
||||||
import { withNetworkOptions, resolveNetworkOptions } from "../network"
|
import { withNetworkOptions, resolveNetworkOptions } from "../network"
|
||||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||||
|
|
||||||
export const ServeCommand = effectCmd({
|
export const ServeCommand = effectCmd({
|
||||||
command: "serve",
|
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",
|
describe: "starts a headless opencode server",
|
||||||
// Server loads instances per-request via x-opencode-directory header — no
|
// Server loads instances per-request via x-opencode-directory header — no
|
||||||
// need for an ambient project InstanceContext at startup.
|
// need for an ambient project InstanceContext at startup.
|
||||||
instance: false,
|
instance: false,
|
||||||
handler: Effect.fn("Cli.serve")(function* (args) {
|
handler: (args) =>
|
||||||
if (!Flag.OPENCODE_SERVER_PASSWORD) {
|
Effect.gen(function* () {
|
||||||
console.log("Warning: OPENCODE_SERVER_PASSWORD is not set; server is unsecured.")
|
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 opts = yield* resolveNetworkOptions(args)
|
||||||
console.log(`opencode server listening on http://${server.hostname}:${server.port}`)
|
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)),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -3,12 +3,14 @@ import { fileURLToPath } from "bun"
|
|||||||
import { useTheme } from "../context/theme"
|
import { useTheme } from "../context/theme"
|
||||||
import { useDialog } from "@tui/ui/dialog"
|
import { useDialog } from "@tui/ui/dialog"
|
||||||
import { useSync } from "@tui/context/sync"
|
import { useSync } from "@tui/context/sync"
|
||||||
|
import { useSDK } from "@tui/context/sdk"
|
||||||
import { For, Match, Switch, Show, createMemo } from "solid-js"
|
import { For, Match, Switch, Show, createMemo } from "solid-js"
|
||||||
|
|
||||||
export type DialogStatusProps = {}
|
export type DialogStatusProps = {}
|
||||||
|
|
||||||
export function DialogStatus() {
|
export function DialogStatus() {
|
||||||
const sync = useSync()
|
const sync = useSync()
|
||||||
|
const sdk = useSDK()
|
||||||
const { theme } = useTheme()
|
const { theme } = useTheme()
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
|
|
||||||
@@ -50,6 +52,10 @@ export function DialogStatus() {
|
|||||||
esc
|
esc
|
||||||
</text>
|
</text>
|
||||||
</box>
|
</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>}>
|
<Show when={Object.keys(sync.data.mcp).length > 0} fallback={<text fg={theme.text}>No MCP Servers</text>}>
|
||||||
<box>
|
<box>
|
||||||
<text fg={theme.text}>{Object.keys(sync.data.mcp).length} MCP Servers</text>
|
<text fg={theme.text}>{Object.keys(sync.data.mcp).length} MCP Servers</text>
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import { errorMessage } from "@/util/error"
|
|||||||
import { withTimeout } from "@/util/timeout"
|
import { withTimeout } from "@/util/timeout"
|
||||||
import { withNetworkOptions, resolveNetworkOptionsNoConfig } from "@/cli/network"
|
import { withNetworkOptions, resolveNetworkOptionsNoConfig } from "@/cli/network"
|
||||||
import { Filesystem } from "@/util/filesystem"
|
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 { GlobalEvent } from "@opencode-ai/sdk/v2"
|
||||||
import type { EventSource } from "./context/sdk"
|
import type { EventSource } from "./context/sdk"
|
||||||
import { win32DisableProcessedInput, win32InstallCtrlCGuard } from "./win32"
|
import { win32DisableProcessedInput, win32InstallCtrlCGuard } from "./win32"
|
||||||
@@ -197,16 +199,26 @@ export const TuiThreadCommand = cmd({
|
|||||||
network.mdns ||
|
network.mdns ||
|
||||||
network.port !== 0 ||
|
network.port !== 0 ||
|
||||||
network.hostname !== "127.0.0.1"
|
network.hostname !== "127.0.0.1"
|
||||||
|
const discovered = external ? undefined : await ServerDiscovery.find()
|
||||||
|
|
||||||
const transport = external
|
const transport = external
|
||||||
? {
|
? {
|
||||||
url: (await client.call("server", network)).url,
|
url: (await client.call("server", network)).url,
|
||||||
fetch: undefined,
|
fetch: undefined,
|
||||||
|
headers: ServerAuth.headers(),
|
||||||
events: undefined,
|
events: undefined,
|
||||||
}
|
}
|
||||||
|
: discovered
|
||||||
|
? {
|
||||||
|
url: discovered,
|
||||||
|
fetch: undefined,
|
||||||
|
headers: ServerAuth.headers(),
|
||||||
|
events: undefined,
|
||||||
|
}
|
||||||
: {
|
: {
|
||||||
url: "http://opencode.internal",
|
url: "http://opencode.internal",
|
||||||
fetch: createWorkerFetch(client),
|
fetch: createWorkerFetch(client),
|
||||||
|
headers: undefined,
|
||||||
events: createEventSource(client),
|
events: createEventSource(client),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -216,6 +228,7 @@ export const TuiThreadCommand = cmd({
|
|||||||
sessionID: args.session,
|
sessionID: args.session,
|
||||||
directory: cwd,
|
directory: cwd,
|
||||||
fetch: transport.fetch,
|
fetch: transport.fetch,
|
||||||
|
headers: transport.headers,
|
||||||
})
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
UI.error(errorMessage(error))
|
UI.error(errorMessage(error))
|
||||||
@@ -241,6 +254,7 @@ export const TuiThreadCommand = cmd({
|
|||||||
config,
|
config,
|
||||||
directory: cwd,
|
directory: cwd,
|
||||||
fetch: transport.fetch,
|
fetch: transport.fetch,
|
||||||
|
headers: transport.headers,
|
||||||
events: transport.events,
|
events: transport.events,
|
||||||
args: {
|
args: {
|
||||||
continue: args.continue,
|
continue: args.continue,
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
export * as ServerDiscovery from "./server-discovery"
|
||||||
|
|
||||||
|
import { makeRuntime } from "@/effect/run-service"
|
||||||
|
import { ServerAuth } from "@/server/auth"
|
||||||
|
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||||
|
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* AppFileSystem.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(AppFileSystem.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
|
starts a headless opencode server
|
||||||
|
|
||||||
Options:
|
Options:
|
||||||
-h, --help show help [boolean]
|
-h, --help show help [boolean]
|
||||||
-v, --version show version number [boolean]
|
-v, --version show version number [boolean]
|
||||||
--print-logs print logs to stderr [boolean]
|
--print-logs print logs to stderr [boolean]
|
||||||
--log-level log level [string] [choices: "DEBUG", "INFO", "WARN", "ERROR"]
|
--log-level log level [string] [choices: "DEBUG", "INFO", "WARN", "ERROR"]
|
||||||
--pure run without external plugins [boolean]
|
--pure run without external plugins [boolean]
|
||||||
--port port to listen on [number] [default: 0]
|
--port port to listen on [number] [default: 0]
|
||||||
--hostname hostname to listen on [string] [default: "127.0.0.1"]
|
--hostname hostname to listen on [string] [default: "127.0.0.1"]
|
||||||
--mdns enable mDNS service discovery (defaults hostname to 0.0.0.0)
|
--mdns enable mDNS service discovery (defaults hostname to 0.0.0.0)
|
||||||
[boolean] [default: false]
|
[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"]
|
[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`] = `
|
exports[`opencode CLI help-text snapshots every documented command emits stable help text: opencode web --help 1`] = `
|
||||||
|
|||||||
Reference in New Issue
Block a user