opencode/packages/cli/src/services/standalone.ts
2026-07-26 20:16:58 -04:00

65 lines
2.6 KiB
TypeScript

import { Service, type Endpoint } from "@opencode-ai/client/effect/service"
import { CrossSpawnSpawner } from "@opencode-ai/util/cross-spawn-spawner"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Deferred, Effect, Schema, Stream } from "effect"
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
import { randomBytes } from "node:crypto"
import { selfCommand } from "../util/process"
const Ready = Schema.Struct({ url: Schema.String })
const decodeReady = Schema.decodeUnknownPromise(Schema.fromJsonString(Ready))
type Options = {
readonly command?: ReadonlyArray<string>
}
const startupDirectory = process.cwd()
function command(password: string, options: Options) {
const [executable, ...args] = options.command ?? [...selfCommand(), "serve"]
if (!executable) throw new Error("Failed to resolve standalone server command")
return ChildProcess.make(executable, [...args, "--stdio", "--port", "0"], {
cwd: startupDirectory,
// Explicit entry wins over anything inherited, so a user-exported
// OPENCODE_PASSWORD cannot shadow the child's lease credential.
env: { OPENCODE_PASSWORD: password },
extendEnv: true,
// The server treats EOF on this pipe as the end of its ownership lease.
// The OS closes it even when the TUI is killed before Effect finalizers run.
stdin: "pipe",
stderr: "ignore",
killSignal: "SIGTERM",
forceKillAfter: "3 seconds",
})
}
const makeEndpoint = Effect.fn("cli.standalone.endpoint")(
function* (options: Options) {
const password = randomBytes(32).toString("base64url")
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
const proc = yield* spawner.spawn(command(password, options))
const readyLine = yield* Deferred.make<string, Error>()
// Keep draining stdout after readiness so later server writes cannot hit EPIPE.
yield* proc.stdout.pipe(
Stream.decodeText(),
Stream.splitLines,
Stream.runForEach((line) => Deferred.succeed(readyLine, line)),
Effect.ensuring(Deferred.fail(readyLine, new Error("Standalone server exited before reporting readiness"))),
Effect.forkScoped,
)
const output = yield* Deferred.await(readyLine)
const ready = yield* Effect.tryPromise(() => decodeReady(output))
return {
url: ready.url,
auth: { type: "basic" as const, username: "opencode", password },
pid: proc.pid,
} satisfies Endpoint & { readonly pid: number }
},
Effect.provide(LayerNode.compile(CrossSpawnSpawner.node)),
)
export function start(options: Options = {}) {
return makeEndpoint(options)
}
export * as Standalone from "./standalone"