54 lines
2.5 KiB
TypeScript
54 lines
2.5 KiB
TypeScript
import { ServerAuth } from "@opencode-ai/server/auth"
|
|
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
|
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
|
import { Effect, Schema, Stream } from "effect"
|
|
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
|
|
import { randomBytes } from "node:crypto"
|
|
import path from "node:path"
|
|
|
|
const Ready = Schema.Struct({ url: Schema.String })
|
|
const decodeReady = Schema.decodeUnknownPromise(Schema.fromJsonString(Ready))
|
|
|
|
type Options = {
|
|
readonly command?: ReadonlyArray<string>
|
|
}
|
|
|
|
function command(password: string, options: Options) {
|
|
const compiled = path.basename(process.execPath).replace(/\.exe$/, "") !== "bun"
|
|
const entrypoint = compiled ? [] : process.argv[1] ? [process.argv[1]] : []
|
|
if (!compiled && entrypoint.length === 0) throw new Error("Failed to resolve CLI entrypoint")
|
|
const [executable, ...args] = options.command ?? [process.execPath, ...entrypoint, "serve"]
|
|
if (!executable) throw new Error("Failed to resolve standalone server command")
|
|
return ChildProcess.make(executable, [...args, "--stdio", "--port", "0"], {
|
|
cwd: process.cwd(),
|
|
// 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 makeTransport = Effect.fn("cli.standalone.transport")(
|
|
function* (options: Options) {
|
|
const password = randomBytes(32).toString("base64url")
|
|
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
|
|
const proc = yield* spawner.spawn(command(password, options))
|
|
const output = yield* proc.stdout.pipe(Stream.decodeText(), Stream.splitLines, Stream.take(1), Stream.mkString)
|
|
if (!output) return yield* Effect.fail(new Error("Standalone server exited before reporting readiness"))
|
|
const ready = yield* Effect.tryPromise(() => decodeReady(output))
|
|
return { url: ready.url, headers: ServerAuth.headers({ password, username: "opencode" }), pid: proc.pid }
|
|
},
|
|
Effect.provide(AppNodeBuilder.build(CrossSpawnSpawner.node)),
|
|
)
|
|
|
|
export function transport(options: Options = {}) {
|
|
return makeTransport(options)
|
|
}
|
|
|
|
export * as Standalone from "./standalone"
|