Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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)),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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))
|
||||||
@@ -239,6 +252,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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user