refactor(cli): unify server endpoint handling
This commit is contained in:
@@ -20,10 +20,10 @@ export default Runtime.handler(
|
|||||||
Effect.fn("cli.api")(function* (input) {
|
Effect.fn("cli.api")(function* (input) {
|
||||||
const options = yield* ServiceConfig.options()
|
const options = yield* ServiceConfig.options()
|
||||||
const found = yield* Service.discover(options)
|
const found = yield* Service.discover(options)
|
||||||
const transport = found ?? (yield* Service.start(options))
|
const endpoint = found ?? (yield* Service.start(options))
|
||||||
const params = Option.getOrElse(input.param, () => ({}))
|
const params = Option.getOrElse(input.param, () => ({}))
|
||||||
const request = yield* resolveRequest(transport, input.request, params)
|
const request = yield* resolveRequest(endpoint, input.request, params)
|
||||||
const headers = new Headers(transport.headers)
|
const headers = new Headers(Service.headers(endpoint))
|
||||||
for (const header of input.header) {
|
for (const header of input.header) {
|
||||||
const index = header.indexOf(":")
|
const index = header.indexOf(":")
|
||||||
if (index < 1) return yield* Effect.fail(new Error(`Invalid header, expected name:value: ${header}`))
|
if (index < 1) return yield* Effect.fail(new Error(`Invalid header, expected name:value: ${header}`))
|
||||||
@@ -33,7 +33,7 @@ export default Runtime.handler(
|
|||||||
if (body !== undefined && !headers.has("content-type")) headers.set("content-type", "application/json")
|
if (body !== undefined && !headers.has("content-type")) headers.set("content-type", "application/json")
|
||||||
|
|
||||||
const response = yield* Effect.tryPromise(() =>
|
const response = yield* Effect.tryPromise(() =>
|
||||||
fetch(new URL(request.path, transport.url), {
|
fetch(new URL(request.path, endpoint.url), {
|
||||||
method: request.method,
|
method: request.method,
|
||||||
headers,
|
headers,
|
||||||
body,
|
body,
|
||||||
@@ -60,7 +60,7 @@ export function rawRequest(input: readonly string[]) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function resolveRequest(
|
function resolveRequest(
|
||||||
transport: Service.Transport,
|
endpoint: Service.Endpoint,
|
||||||
input: readonly string[],
|
input: readonly string[],
|
||||||
params: Record<string, string>,
|
params: Record<string, string>,
|
||||||
) {
|
) {
|
||||||
@@ -68,7 +68,7 @@ function resolveRequest(
|
|||||||
if (raw) return Effect.succeed(raw)
|
if (raw) return Effect.succeed(raw)
|
||||||
if (input.length !== 1) return Effect.fail(new Error("Expected an operation name or an HTTP method and path"))
|
if (input.length !== 1) return Effect.fail(new Error("Expected an operation name or an HTTP method and path"))
|
||||||
return Effect.tryPromise(async () => {
|
return Effect.tryPromise(async () => {
|
||||||
const response = await fetch(new URL("/openapi.json", transport.url), { headers: transport.headers })
|
const response = await fetch(new URL("/openapi.json", endpoint.url), { headers: Service.headers(endpoint) })
|
||||||
if (!response.ok) throw new Error(`Failed to load OpenAPI document: HTTP ${response.status}`)
|
if (!response.ok) throw new Error(`Failed to load OpenAPI document: HTTP ${response.status}`)
|
||||||
return resolveOperation((await response.json()) as OpenApi, input[0], params)
|
return resolveOperation((await response.json()) as OpenApi, input[0], params)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { Cause, Effect, Exit, Option } from "effect"
|
import { Cause, Effect, Exit, Option } from "effect"
|
||||||
|
import { Service } from "@opencode-ai/client/effect"
|
||||||
import { OpenCode, type OpenCodeClient } from "@opencode-ai/client/promise"
|
import { OpenCode, type OpenCodeClient } from "@opencode-ai/client/promise"
|
||||||
import { AppProcess } from "@opencode-ai/core/process"
|
import { AppProcess } from "@opencode-ai/core/process"
|
||||||
import { Commands } from "../../commands"
|
import { Commands } from "../../commands"
|
||||||
import { Runtime } from "../../../framework/runtime"
|
import { Runtime } from "../../../framework/runtime"
|
||||||
import { Daemon } from "../../../daemon"
|
import { ServiceConfig } from "../../../services/service-config"
|
||||||
import { createTimelineHost, type TimelineHost } from "../../../ui/timeline"
|
import { createTimelineHost, type TimelineHost } from "../../../ui/timeline"
|
||||||
|
|
||||||
const integrationID = "opencode"
|
const integrationID = "opencode"
|
||||||
@@ -34,8 +35,8 @@ const login = Effect.fn("cli.console.login.run")(function* (timeline: TimelineHo
|
|||||||
yield* request(() => timeline.intro("Log in"))
|
yield* request(() => timeline.intro("Log in"))
|
||||||
yield* request(() => timeline.pending("Connecting to OpenCode..."))
|
yield* request(() => timeline.pending("Connecting to OpenCode..."))
|
||||||
|
|
||||||
const transport = yield* Daemon.transport({ mode: "shared" })
|
const endpoint = yield* Service.start(yield* ServiceConfig.options())
|
||||||
const client = OpenCode.make({ baseUrl: transport.url, headers: transport.headers })
|
const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) })
|
||||||
const found = yield* request((signal) => client.integration.get({ integrationID, location }, { signal }))
|
const found = yield* request((signal) => client.integration.get({ integrationID, location }, { signal }))
|
||||||
const integration = yield* required(found.data, "OpenCode Console integration is unavailable")
|
const integration = yield* required(found.data, "OpenCode Console integration is unavailable")
|
||||||
const method = yield* required(
|
const method = yield* required(
|
||||||
|
|||||||
@@ -11,8 +11,8 @@ export default Runtime.handler(
|
|||||||
Effect.fn("cli.debug.agents")(function* () {
|
Effect.fn("cli.debug.agents")(function* () {
|
||||||
const options = yield* ServiceConfig.options()
|
const options = yield* ServiceConfig.options()
|
||||||
const found = yield* Service.discover(options)
|
const found = yield* Service.discover(options)
|
||||||
const transport = found ?? (yield* Service.start(options))
|
const endpoint = found ?? (yield* Service.start(options))
|
||||||
const client = createOpencodeClient({ baseUrl: transport.url, headers: transport.headers })
|
const client = createOpencodeClient({ baseUrl: endpoint.url, headers: Service.headers(endpoint) })
|
||||||
const response = yield* Effect.promise(() => client.v2.agent.list({ location: { directory: process.cwd() } }))
|
const response = yield* Effect.promise(() => client.v2.agent.list({ location: { directory: process.cwd() } }))
|
||||||
process.stdout.write(
|
process.stdout.write(
|
||||||
JSON.stringify(
|
JSON.stringify(
|
||||||
|
|||||||
@@ -1,4 +1,9 @@
|
|||||||
import { NodeFileSystem } from "@effect/platform-node"
|
import { NodeFileSystem } from "@effect/platform-node"
|
||||||
|
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||||
|
import { Global } from "@opencode-ai/core/global"
|
||||||
|
import { run } from "@opencode-ai/tui"
|
||||||
|
import { loadBuiltinPlugins } from "@opencode-ai/tui/builtins"
|
||||||
|
import { TuiConfig } from "@opencode-ai/tui/config"
|
||||||
import { Commands } from "../commands"
|
import { Commands } from "../commands"
|
||||||
import { Runtime } from "../../framework/runtime"
|
import { Runtime } from "../../framework/runtime"
|
||||||
import { Effect, Option, Redacted } from "effect"
|
import { Effect, Option, Redacted } from "effect"
|
||||||
@@ -10,26 +15,29 @@ import { Updater } from "../../services/updater"
|
|||||||
|
|
||||||
export default Runtime.handler(Commands, (input) =>
|
export default Runtime.handler(Commands, (input) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const directory = Option.getOrUndefined(input.directory)
|
const requestedDirectory = Option.getOrUndefined(input.directory)
|
||||||
if (directory !== undefined) process.chdir(directory)
|
if (requestedDirectory !== undefined) process.chdir(requestedDirectory)
|
||||||
const updater = yield* Updater.Service
|
const updater = yield* Updater.Service
|
||||||
yield* updater.check().pipe(Effect.forkScoped)
|
yield* updater.check().pipe(Effect.forkScoped)
|
||||||
const server = Option.getOrUndefined(input.server)
|
const server = Option.getOrUndefined(input.server)
|
||||||
if (server !== undefined && input.standalone)
|
if (server !== undefined && input.standalone)
|
||||||
return yield* Effect.fail(new Error("--server and --standalone cannot be combined"))
|
return yield* Effect.fail(new Error("--server and --standalone cannot be combined"))
|
||||||
const transport = yield* Effect.gen(function* () {
|
const endpoint = yield* Effect.gen(function* () {
|
||||||
if (server !== undefined) {
|
if (server !== undefined) {
|
||||||
const password = yield* Env.password
|
const password = yield* Env.password
|
||||||
const explicit = {
|
const explicit = {
|
||||||
url: server,
|
url: server,
|
||||||
headers: password
|
auth: password
|
||||||
? { authorization: "Basic " + btoa("opencode:" + Redacted.value(password)) }
|
? { type: "basic" as const, username: "opencode", password: Redacted.value(password) }
|
||||||
: undefined,
|
: undefined,
|
||||||
} satisfies Service.Transport
|
} satisfies Service.Endpoint
|
||||||
// Fail loudly before entering the TUI: an explicit server that is
|
// Fail loudly before entering the TUI: an explicit server that is
|
||||||
// unreachable or rejects auth should not present as reconnect churn.
|
// unreachable or rejects auth should not present as reconnect churn.
|
||||||
const response = yield* Effect.tryPromise(() =>
|
const response = yield* Effect.tryPromise(() =>
|
||||||
fetch(new URL("/api/health", server), { headers: explicit.headers, signal: AbortSignal.timeout(5_000) }),
|
fetch(new URL("/api/health", server), {
|
||||||
|
headers: Service.headers(explicit),
|
||||||
|
signal: AbortSignal.timeout(5_000),
|
||||||
|
}),
|
||||||
).pipe(Effect.mapError((cause) => new Error(`Could not reach server at ${server}`, { cause })))
|
).pipe(Effect.mapError((cause) => new Error(`Could not reach server at ${server}`, { cause })))
|
||||||
if (response.status === 401)
|
if (response.status === 401)
|
||||||
return yield* Effect.fail(
|
return yield* Effect.fail(
|
||||||
@@ -43,14 +51,13 @@ export default Runtime.handler(Commands, (input) =>
|
|||||||
return yield* Effect.fail(new Error(`Server at ${server} responded with status ${response.status}`))
|
return yield* Effect.fail(new Error(`Server at ${server} responded with status ${response.status}`))
|
||||||
return explicit
|
return explicit
|
||||||
}
|
}
|
||||||
if (input.standalone) return yield* Standalone.transport()
|
if (input.standalone) return yield* Standalone.start()
|
||||||
const options = yield* ServiceConfig.options()
|
const options = yield* ServiceConfig.options()
|
||||||
const found = yield* Service.discover(options)
|
const found = yield* Service.discover(options)
|
||||||
return found ?? (yield* Service.start(options))
|
return found ?? (yield* Service.start(options))
|
||||||
})
|
})
|
||||||
const { runTui } = yield* Effect.promise(() => import("../../tui"))
|
|
||||||
// The TUI re-runs discover whenever its event stream drops. For an explicit
|
// The TUI re-runs discover whenever its event stream drops. For an explicit
|
||||||
// --server or a standalone child the transport is fixed, so reconnects
|
// --server or a standalone child the endpoint is fixed, so reconnects
|
||||||
// retry the same address; for the managed service discovery re-reads the
|
// retry the same address; for the managed service discovery re-reads the
|
||||||
// registration and may start a replacement.
|
// registration and may start a replacement.
|
||||||
const serviceOptions = server === undefined && !input.standalone ? yield* ServiceConfig.options() : undefined
|
const serviceOptions = server === undefined && !input.standalone ? yield* ServiceConfig.options() : undefined
|
||||||
@@ -65,7 +72,7 @@ export default Runtime.handler(Commands, (input) =>
|
|||||||
return found ?? (yield* Service.start(reconnectOptions))
|
return found ?? (yield* Service.start(reconnectOptions))
|
||||||
}).pipe(Effect.provide(NodeFileSystem.layer)),
|
}).pipe(Effect.provide(NodeFileSystem.layer)),
|
||||||
)
|
)
|
||||||
: () => Promise.resolve(transport)
|
: undefined
|
||||||
// Restart the managed service in place; start() resolves once the
|
// Restart the managed service in place; start() resolves once the
|
||||||
// replacement is healthy and the reconnect loop reattaches on its own.
|
// replacement is healthy and the reconnect loop reattaches on its own.
|
||||||
// Only meaningful in service mode: --server is not ours to restart and a
|
// Only meaningful in service mode: --server is not ours to restart and a
|
||||||
@@ -79,11 +86,36 @@ export default Runtime.handler(Commands, (input) =>
|
|||||||
}).pipe(Effect.provide(NodeFileSystem.layer)),
|
}).pipe(Effect.provide(NodeFileSystem.layer)),
|
||||||
)
|
)
|
||||||
: undefined
|
: undefined
|
||||||
yield* runTui(
|
const config = TuiConfig.resolve({}, { terminalSuspend: false })
|
||||||
transport,
|
let disposeSlots: (() => void) | undefined
|
||||||
{ continue: input.continue, sessionID: Option.getOrUndefined(input.session) },
|
const runFork = Effect.runForkWith(yield* Effect.context())
|
||||||
discover,
|
yield* run({
|
||||||
reload,
|
server: {
|
||||||
)
|
endpoint,
|
||||||
|
discover,
|
||||||
|
reload,
|
||||||
|
},
|
||||||
|
args: { continue: input.continue, sessionID: Option.getOrUndefined(input.session) },
|
||||||
|
config,
|
||||||
|
log: (level, message, tags) => {
|
||||||
|
const effect =
|
||||||
|
level === "debug"
|
||||||
|
? Effect.logDebug(message, tags)
|
||||||
|
: level === "warn"
|
||||||
|
? Effect.logWarning(message, tags)
|
||||||
|
: level === "error"
|
||||||
|
? Effect.logError(message, tags)
|
||||||
|
: Effect.logInfo(message, tags)
|
||||||
|
runFork(effect)
|
||||||
|
},
|
||||||
|
pluginHost: {
|
||||||
|
async start(pluginInput) {
|
||||||
|
disposeSlots = await loadBuiltinPlugins(pluginInput.api, pluginInput.runtime)
|
||||||
|
},
|
||||||
|
async dispose() {
|
||||||
|
disposeSlots?.()
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}).pipe(Effect.provide(AppNodeBuilder.build(Global.node)))
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -10,10 +10,10 @@ import { ServiceConfig } from "../../services/service-config"
|
|||||||
export default Runtime.handler(
|
export default Runtime.handler(
|
||||||
Commands.commands.link,
|
Commands.commands.link,
|
||||||
Effect.fn("cli.link")(function* () {
|
Effect.fn("cli.link")(function* () {
|
||||||
const transport = yield* Service.start(yield* ServiceConfig.options())
|
const endpoint = yield* Service.start(yield* ServiceConfig.options())
|
||||||
const password = yield* ServiceConfig.password()
|
const password = yield* ServiceConfig.password()
|
||||||
const server = yield* Effect.tryPromise(() =>
|
const server = yield* Effect.tryPromise(() =>
|
||||||
OpenCode.make({ baseUrl: transport.url, headers: transport.headers }).server.get(),
|
OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) }).server.get(),
|
||||||
)
|
)
|
||||||
const info = { urls: server.urls, username: "opencode", password }
|
const info = { urls: server.urls, username: "opencode", password }
|
||||||
process.stdout.write(
|
process.stdout.write(
|
||||||
@@ -34,7 +34,7 @@ export default Runtime.handler(
|
|||||||
].join(EOL) + EOL,
|
].join(EOL) + EOL,
|
||||||
)
|
)
|
||||||
|
|
||||||
const hostname = new URL(transport.url).hostname
|
const hostname = new URL(endpoint.url).hostname
|
||||||
if (!["localhost", "127.0.0.1", "[::1]"].includes(hostname)) return
|
if (!["localhost", "127.0.0.1", "[::1]"].includes(hostname)) return
|
||||||
process.stderr.write(
|
process.stderr.write(
|
||||||
` Run \`opencode service set hostname 0.0.0.0\` to access the service remotely.${EOL}${EOL}`,
|
` Run \`opencode service set hostname 0.0.0.0\` to access the service remotely.${EOL}${EOL}`,
|
||||||
|
|||||||
@@ -19,8 +19,8 @@ export default Runtime.handler(
|
|||||||
Effect.fn("cli.mcp.auth")(function* (input) {
|
Effect.fn("cli.mcp.auth")(function* (input) {
|
||||||
const options = yield* ServiceConfig.options()
|
const options = yield* ServiceConfig.options()
|
||||||
const found = yield* Service.discover(options)
|
const found = yield* Service.discover(options)
|
||||||
const transport = found ?? (yield* Service.start(options))
|
const endpoint = found ?? (yield* Service.start(options))
|
||||||
const client = createOpencodeClient({ baseUrl: transport.url, headers: transport.headers })
|
const client = createOpencodeClient({ baseUrl: endpoint.url, headers: Service.headers(endpoint) })
|
||||||
|
|
||||||
const integration = yield* resolveIntegration(client, input.name, location)
|
const integration = yield* resolveIntegration(client, input.name, location)
|
||||||
if (!integration)
|
if (!integration)
|
||||||
|
|||||||
@@ -11,8 +11,8 @@ export default Runtime.handler(
|
|||||||
Effect.fn("cli.mcp.list")(function* () {
|
Effect.fn("cli.mcp.list")(function* () {
|
||||||
const options = yield* ServiceConfig.options()
|
const options = yield* ServiceConfig.options()
|
||||||
const found = yield* Service.discover(options)
|
const found = yield* Service.discover(options)
|
||||||
const transport = found ?? (yield* Service.start(options))
|
const endpoint = found ?? (yield* Service.start(options))
|
||||||
const client = createOpencodeClient({ baseUrl: transport.url, headers: transport.headers })
|
const client = createOpencodeClient({ baseUrl: endpoint.url, headers: Service.headers(endpoint) })
|
||||||
const response = yield* Effect.promise(() => client.v2.mcp.list({ location: { directory: process.cwd() } }))
|
const response = yield* Effect.promise(() => client.v2.mcp.list({ location: { directory: process.cwd() } }))
|
||||||
const servers = (response.data?.data ?? []).toSorted((a, b) => a.name.localeCompare(b.name))
|
const servers = (response.data?.data ?? []).toSorted((a, b) => a.name.localeCompare(b.name))
|
||||||
if (servers.length === 0) {
|
if (servers.length === 0) {
|
||||||
|
|||||||
@@ -14,8 +14,8 @@ export default Runtime.handler(
|
|||||||
Effect.fn("cli.mcp.logout")(function* (input) {
|
Effect.fn("cli.mcp.logout")(function* (input) {
|
||||||
const options = yield* ServiceConfig.options()
|
const options = yield* ServiceConfig.options()
|
||||||
const found = yield* Service.discover(options)
|
const found = yield* Service.discover(options)
|
||||||
const transport = found ?? (yield* Service.start(options))
|
const endpoint = found ?? (yield* Service.start(options))
|
||||||
const client = createOpencodeClient({ baseUrl: transport.url, headers: transport.headers })
|
const client = createOpencodeClient({ baseUrl: endpoint.url, headers: Service.headers(endpoint) })
|
||||||
|
|
||||||
const integration = yield* resolveIntegration(client, input.name, location)
|
const integration = yield* resolveIntegration(client, input.name, location)
|
||||||
if (!integration) {
|
if (!integration) {
|
||||||
|
|||||||
+10
-34
@@ -1,59 +1,35 @@
|
|||||||
import { Service } from "@opencode-ai/client/effect"
|
import { Service } from "@opencode-ai/client/effect"
|
||||||
import { ClientError, isUnauthorizedError, OpenCode } from "@opencode-ai/client/promise"
|
import { ClientError, isUnauthorizedError, OpenCode } from "@opencode-ai/client/promise"
|
||||||
import { InstallationVersion } from "@opencode-ai/core/installation/version"
|
import { InstallationVersion } from "@opencode-ai/core/installation/version"
|
||||||
import { ServerAuth } from "@opencode-ai/server/auth"
|
|
||||||
import { Effect } from "effect"
|
import { Effect } from "effect"
|
||||||
import { ServiceConfig } from "./services/service-config"
|
|
||||||
|
|
||||||
export type SharedOptions = {
|
type ConnectOptions = {
|
||||||
readonly mode: "shared"
|
|
||||||
readonly command?: ReadonlyArray<string>
|
|
||||||
}
|
|
||||||
export type AttachOptions = {
|
|
||||||
readonly mode: "attach"
|
|
||||||
readonly url: string
|
readonly url: string
|
||||||
readonly username?: string
|
readonly username?: string
|
||||||
readonly password?: string
|
readonly password?: string
|
||||||
}
|
}
|
||||||
export type Options = SharedOptions | AttachOptions
|
|
||||||
|
|
||||||
const attach = Effect.fn("cli.daemon.attach")(function* (options: AttachOptions) {
|
export const connect = Effect.fn("cli.daemon.connect")(function* (options: ConnectOptions) {
|
||||||
const transport = {
|
const endpoint = {
|
||||||
url: options.url,
|
url: options.url,
|
||||||
headers:
|
auth:
|
||||||
options.password === undefined
|
options.password === undefined
|
||||||
? undefined
|
? undefined
|
||||||
: ServerAuth.headers({ password: options.password, username: options.username }),
|
: { type: "basic" as const, username: options.username ?? "opencode", password: options.password },
|
||||||
} satisfies Service.Transport
|
} satisfies Service.Endpoint
|
||||||
const client = OpenCode.make({ baseUrl: transport.url, headers: transport.headers })
|
const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) })
|
||||||
const health = yield* Effect.tryPromise({
|
const health = yield* Effect.tryPromise({
|
||||||
try: () => client.health.get({ signal: AbortSignal.timeout(5_000) }),
|
try: () => client.health.get({ signal: AbortSignal.timeout(5_000) }),
|
||||||
catch: (cause) => attachError(options, cause),
|
catch: (cause) => connectError(options, cause),
|
||||||
})
|
})
|
||||||
if (health.version !== InstallationVersion)
|
if (health.version !== InstallationVersion)
|
||||||
process.stderr.write(
|
process.stderr.write(
|
||||||
`Warning: Server at ${options.url} has version ${health.version}; this client is ${InstallationVersion}. Continuing anyway.\n`,
|
`Warning: Server at ${options.url} has version ${health.version}; this client is ${InstallationVersion}. Continuing anyway.\n`,
|
||||||
)
|
)
|
||||||
return transport
|
return endpoint
|
||||||
})
|
})
|
||||||
|
|
||||||
const shared = Effect.fn("cli.daemon.shared")(function* (options: SharedOptions) {
|
function connectError(options: ConnectOptions, cause: unknown) {
|
||||||
const config = yield* ServiceConfig.options()
|
|
||||||
const service = options.command === undefined ? config : { ...config, command: options.command }
|
|
||||||
const found = yield* Service.discover(service)
|
|
||||||
if (found) return found
|
|
||||||
return yield* Service.start(service)
|
|
||||||
})
|
|
||||||
|
|
||||||
export function transport(options: AttachOptions): ReturnType<typeof attach>
|
|
||||||
export function transport(options: SharedOptions): ReturnType<typeof shared>
|
|
||||||
export function transport(options: Options): ReturnType<typeof attach> | ReturnType<typeof shared>
|
|
||||||
export function transport(options: Options) {
|
|
||||||
if (options.mode === "attach") return attach(options)
|
|
||||||
return shared(options)
|
|
||||||
}
|
|
||||||
|
|
||||||
function attachError(options: AttachOptions, cause: unknown) {
|
|
||||||
if (isUnauthorizedError(cause)) {
|
if (isUnauthorizedError(cause)) {
|
||||||
return new Error(
|
return new Error(
|
||||||
options.password === undefined
|
options.password === undefined
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import { NodeFileSystem } from "@effect/platform-node"
|
import { NodeFileSystem } from "@effect/platform-node"
|
||||||
|
import { Service } from "@opencode-ai/client/effect"
|
||||||
import { OpenCode, type OpenCodeClient } from "@opencode-ai/client/promise"
|
import { OpenCode, type OpenCodeClient } from "@opencode-ai/client/promise"
|
||||||
import { Global } from "@opencode-ai/core/global"
|
import { Global } from "@opencode-ai/core/global"
|
||||||
import { Effect } from "effect"
|
import { Effect } from "effect"
|
||||||
import path from "node:path"
|
import path from "node:path"
|
||||||
import { Daemon } from "../daemon"
|
import { Daemon } from "../daemon"
|
||||||
|
import { ServiceConfig } from "../services/service-config"
|
||||||
import { waitForCatalogReady } from "./catalog.shared"
|
import { waitForCatalogReady } from "./catalog.shared"
|
||||||
import { INTERACTIVE_INPUT_ERROR, resolveInteractiveStdin } from "./runtime.stdin"
|
import { INTERACTIVE_INPUT_ERROR, resolveInteractiveStdin } from "./runtime.stdin"
|
||||||
import type { RunInput, RunTuiConfig } from "./types"
|
import type { RunInput, RunTuiConfig } from "./types"
|
||||||
@@ -27,28 +29,26 @@ export type MiniCommandInput = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type Session = Awaited<ReturnType<OpenCodeClient["session"]["get"]>>
|
type Session = Awaited<ReturnType<OpenCodeClient["session"]["get"]>>
|
||||||
type Transport = { readonly url: string; readonly headers?: HeadersInit }
|
|
||||||
|
|
||||||
export async function runMini(input: MiniCommandInput) {
|
export async function runMini(input: MiniCommandInput) {
|
||||||
validate(input)
|
validate(input)
|
||||||
const initialInput = mergeInput(process.stdin.isTTY ? undefined : await Bun.stdin.text(), input.prompt)
|
const initialInput = mergeInput(process.stdin.isTTY ? undefined : await Bun.stdin.text(), input.prompt)
|
||||||
const runtimeTask = import("./runtime")
|
const runtimeTask = import("./runtime")
|
||||||
const directory = input.attach ? input.directory : localDirectory(input.directory)
|
const directory = input.attach ? input.directory : localDirectory(input.directory)
|
||||||
const transportTask = startTransport(input)
|
const endpointTask = startEndpoint(input)
|
||||||
void transportTask.catch(() => {})
|
void endpointTask.catch(() => {})
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (input.attach) await transportTask
|
if (input.attach) await endpointTask
|
||||||
const sdk = OpenCode.make({
|
const sdk = OpenCode.make({
|
||||||
baseUrl: "http://opencode.pending",
|
baseUrl: "http://opencode.pending",
|
||||||
fetch: deferredFetch(transportTask),
|
fetch: deferredFetch(endpointTask),
|
||||||
})
|
})
|
||||||
const attachedSession =
|
const attachedSession =
|
||||||
input.attach && input.session && !input.directory
|
input.attach && input.session && !input.directory
|
||||||
? await sdk.session.get({ sessionID: input.session }).catch(() => fail("Session not found"))
|
? await sdk.session.get({ sessionID: input.session }).catch(() => fail("Session not found"))
|
||||||
: undefined
|
: undefined
|
||||||
const resolvedDirectory =
|
const resolvedDirectory =
|
||||||
directory ?? attachedSession?.location.directory ?? (await remoteDirectory(await transportTask, sdk))
|
directory ?? attachedSession?.location.directory ?? (await remoteDirectory(await endpointTask, sdk))
|
||||||
const model = parseModel(input.model)
|
const model = parseModel(input.model)
|
||||||
let agentTask: Promise<string | undefined> | undefined
|
let agentTask: Promise<string | undefined> | undefined
|
||||||
const resolveAgent = () => {
|
const resolveAgent = () => {
|
||||||
@@ -120,11 +120,10 @@ function localDirectory(directory?: string): string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function startTransport(input: MiniCommandInput): Promise<Transport> {
|
function startEndpoint(input: MiniCommandInput): Promise<Service.Endpoint> {
|
||||||
if (input.attach) {
|
if (input.attach) {
|
||||||
return Effect.runPromise(
|
return Effect.runPromise(
|
||||||
Daemon.transport({
|
Daemon.connect({
|
||||||
mode: "attach",
|
|
||||||
url: input.attach,
|
url: input.attach,
|
||||||
password: input.password ?? process.env.OPENCODE_SERVER_PASSWORD,
|
password: input.password ?? process.env.OPENCODE_SERVER_PASSWORD,
|
||||||
username: input.username ?? process.env.OPENCODE_SERVER_USERNAME,
|
username: input.username ?? process.env.OPENCODE_SERVER_USERNAME,
|
||||||
@@ -132,31 +131,36 @@ function startTransport(input: MiniCommandInput): Promise<Transport> {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
return Effect.runPromise(
|
return Effect.runPromise(
|
||||||
Daemon.transport({ mode: "shared", command: input.serverCommand }).pipe(
|
Effect.gen(function* () {
|
||||||
|
const options = yield* ServiceConfig.options()
|
||||||
|
return yield* Service.start(
|
||||||
|
input.serverCommand === undefined ? options : { ...options, command: input.serverCommand },
|
||||||
|
)
|
||||||
|
}).pipe(
|
||||||
Effect.provide(NodeFileSystem.layer),
|
Effect.provide(NodeFileSystem.layer),
|
||||||
Effect.provide(Global.layerWith({})),
|
Effect.provide(Global.layerWith({})),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function deferredFetch(transportTask: Promise<{ url: string; headers?: HeadersInit }>): typeof globalThis.fetch {
|
function deferredFetch(endpointTask: Promise<Service.Endpoint>): typeof globalThis.fetch {
|
||||||
const fetch = async (input: RequestInfo | URL, init?: RequestInit) => {
|
const fetch = async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||||
const transport = await transportTask
|
const endpoint = await endpointTask
|
||||||
const request = new Request(input, init)
|
const request = new Request(input, init)
|
||||||
const source = new URL(request.url)
|
const source = new URL(request.url)
|
||||||
const headers = new Headers(request.headers)
|
const headers = new Headers(request.headers)
|
||||||
for (const [key, value] of new Headers(transport.headers)) headers.set(key, value)
|
for (const [key, value] of new Headers(Service.headers(endpoint))) headers.set(key, value)
|
||||||
return globalThis.fetch(new Request(new URL(source.pathname + source.search, transport.url), request), { headers })
|
return globalThis.fetch(new Request(new URL(source.pathname + source.search, endpoint.url), request), { headers })
|
||||||
}
|
}
|
||||||
return fetch as typeof globalThis.fetch
|
return fetch as typeof globalThis.fetch
|
||||||
}
|
}
|
||||||
|
|
||||||
async function remoteDirectory(
|
async function remoteDirectory(
|
||||||
transport: { url: string; headers?: HeadersInit },
|
endpoint: Service.Endpoint,
|
||||||
sdk: OpenCodeClient,
|
sdk: OpenCodeClient,
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
const location = await sdk.location.get()
|
const location = await sdk.location.get()
|
||||||
if (!location.directory) throw new Error(`Failed to resolve remote directory from ${transport.url}`)
|
if (!location.directory) throw new Error(`Failed to resolve remote directory from ${endpoint.url}`)
|
||||||
return location.directory
|
return location.directory
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { NodeFileSystem } from "@effect/platform-node"
|
import { NodeFileSystem } from "@effect/platform-node"
|
||||||
|
import { Service } from "@opencode-ai/client/effect"
|
||||||
import { OpenCode, type OpenCodeClient } from "@opencode-ai/client/promise"
|
import { OpenCode, type OpenCodeClient } from "@opencode-ai/client/promise"
|
||||||
import { Global } from "@opencode-ai/core/global"
|
import { Global } from "@opencode-ai/core/global"
|
||||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||||
@@ -7,6 +8,7 @@ import { Effect } from "effect"
|
|||||||
import { open } from "node:fs/promises"
|
import { open } from "node:fs/promises"
|
||||||
import path from "node:path"
|
import path from "node:path"
|
||||||
import { Daemon } from "../daemon"
|
import { Daemon } from "../daemon"
|
||||||
|
import { ServiceConfig } from "../services/service-config"
|
||||||
import { Standalone } from "../services/standalone"
|
import { Standalone } from "../services/standalone"
|
||||||
import { loadRunAgents, waitForCatalogReady } from "./catalog.shared"
|
import { loadRunAgents, waitForCatalogReady } from "./catalog.shared"
|
||||||
import { runNonInteractivePrompt } from "./noninteractive"
|
import { runNonInteractivePrompt } from "./noninteractive"
|
||||||
@@ -39,8 +41,6 @@ type FilePart = {
|
|||||||
mime: string
|
mime: string
|
||||||
}
|
}
|
||||||
|
|
||||||
type Transport = { readonly url: string; readonly headers?: HeadersInit }
|
|
||||||
|
|
||||||
type Prepared = {
|
type Prepared = {
|
||||||
directory?: string
|
directory?: string
|
||||||
message: string
|
message: string
|
||||||
@@ -67,17 +67,17 @@ async function run(input: RunCommandInput) {
|
|||||||
return Effect.runPromise(
|
return Effect.runPromise(
|
||||||
Effect.scoped(
|
Effect.scoped(
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const transport = yield* Standalone.transport({ command: input.standaloneCommand })
|
const endpoint = yield* Standalone.start({ command: input.standaloneCommand })
|
||||||
yield* Effect.promise(() => execute(input, prepared, transport))
|
yield* Effect.promise(() => execute(input, prepared, endpoint))
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
const transport = await startTransport(input)
|
const endpoint = await startEndpoint(input)
|
||||||
return execute(input, prepared, transport)
|
return execute(input, prepared, endpoint)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function execute(input: RunCommandInput, prepared: Prepared, transport: Transport) {
|
async function execute(input: RunCommandInput, prepared: Prepared, endpoint: Service.Endpoint) {
|
||||||
const client = OpenCode.make({ baseUrl: transport.url, headers: transport.headers })
|
const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) })
|
||||||
const requestedDirectory = prepared.directory ?? (await client.location.get()).directory
|
const requestedDirectory = prepared.directory ?? (await client.location.get()).directory
|
||||||
if (!requestedDirectory) fail("Failed to resolve server directory")
|
if (!requestedDirectory) fail("Failed to resolve server directory")
|
||||||
const session = await selectSession(client, requestedDirectory, input)
|
const session = await selectSession(client, requestedDirectory, input)
|
||||||
@@ -163,11 +163,10 @@ function localDirectory(directory: string | undefined, root: string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function startTransport(input: RunCommandInput) {
|
function startEndpoint(input: RunCommandInput) {
|
||||||
if (input.server) {
|
if (input.server) {
|
||||||
return Effect.runPromise(
|
return Effect.runPromise(
|
||||||
Daemon.transport({
|
Daemon.connect({
|
||||||
mode: "attach",
|
|
||||||
url: input.server,
|
url: input.server,
|
||||||
password: input.password,
|
password: input.password,
|
||||||
username: input.username,
|
username: input.username,
|
||||||
@@ -175,7 +174,9 @@ function startTransport(input: RunCommandInput) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
return Effect.runPromise(
|
return Effect.runPromise(
|
||||||
Daemon.transport({ mode: "shared" }).pipe(
|
Effect.gen(function* () {
|
||||||
|
return yield* Service.start(yield* ServiceConfig.options())
|
||||||
|
}).pipe(
|
||||||
Effect.provide(NodeFileSystem.layer),
|
Effect.provide(NodeFileSystem.layer),
|
||||||
Effect.provide(Global.layerWith({})),
|
Effect.provide(Global.layerWith({})),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { ServerAuth } from "@opencode-ai/server/auth"
|
import { Service } from "@opencode-ai/client/effect"
|
||||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||||
import { Effect, Schema, Stream } from "effect"
|
import { Effect, Schema, Stream } from "effect"
|
||||||
@@ -34,7 +34,7 @@ function command(password: string, options: Options) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const makeTransport = Effect.fn("cli.standalone.transport")(
|
const makeEndpoint = Effect.fn("cli.standalone.endpoint")(
|
||||||
function* (options: Options) {
|
function* (options: Options) {
|
||||||
const password = randomBytes(32).toString("base64url")
|
const password = randomBytes(32).toString("base64url")
|
||||||
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
|
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
|
||||||
@@ -42,13 +42,17 @@ const makeTransport = Effect.fn("cli.standalone.transport")(
|
|||||||
const output = yield* proc.stdout.pipe(Stream.decodeText(), Stream.splitLines, Stream.take(1), Stream.mkString)
|
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"))
|
if (!output) return yield* Effect.fail(new Error("Standalone server exited before reporting readiness"))
|
||||||
const ready = yield* Effect.tryPromise(() => decodeReady(output))
|
const ready = yield* Effect.tryPromise(() => decodeReady(output))
|
||||||
return { url: ready.url, headers: ServerAuth.headers({ password, username: "opencode" }), pid: proc.pid }
|
return {
|
||||||
|
url: ready.url,
|
||||||
|
auth: { type: "basic" as const, username: "opencode", password },
|
||||||
|
pid: proc.pid,
|
||||||
|
} satisfies Service.Endpoint & { readonly pid: number }
|
||||||
},
|
},
|
||||||
Effect.provide(AppNodeBuilder.build(CrossSpawnSpawner.node)),
|
Effect.provide(AppNodeBuilder.build(CrossSpawnSpawner.node)),
|
||||||
)
|
)
|
||||||
|
|
||||||
export function transport(options: Options = {}) {
|
export function start(options: Options = {}) {
|
||||||
return makeTransport(options)
|
return makeEndpoint(options)
|
||||||
}
|
}
|
||||||
|
|
||||||
export * as Standalone from "./standalone"
|
export * as Standalone from "./standalone"
|
||||||
|
|||||||
@@ -1,76 +0,0 @@
|
|||||||
import { run } from "@opencode-ai/tui"
|
|
||||||
import { TuiConfig } from "@opencode-ai/tui/config"
|
|
||||||
import { Effect } from "effect"
|
|
||||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
|
||||||
import { Global } from "@opencode-ai/core/global"
|
|
||||||
import { loadBuiltinPlugins } from "@opencode-ai/tui/builtins"
|
|
||||||
import { OpenCode } from "@opencode-ai/client/promise"
|
|
||||||
import type { Service } from "@opencode-ai/client/effect"
|
|
||||||
import { createOpencodeClient } from "@opencode-ai/sdk/v2/client"
|
|
||||||
import type { Args } from "@opencode-ai/tui/context/args"
|
|
||||||
|
|
||||||
export function runTui(
|
|
||||||
transport: Service.Transport,
|
|
||||||
args: Args,
|
|
||||||
discover?: () => Promise<Service.Transport>,
|
|
||||||
reload?: () => Promise<void>,
|
|
||||||
) {
|
|
||||||
const config = TuiConfig.resolve({}, { terminalSuspend: false })
|
|
||||||
let disposeSlots: (() => void) | undefined
|
|
||||||
return Effect.gen(function* () {
|
|
||||||
const runFork = Effect.runForkWith(yield* Effect.context())
|
|
||||||
const options = { baseUrl: transport.url, headers: transport.headers }
|
|
||||||
const api = OpenCode.make(options)
|
|
||||||
const directory = yield* Effect.tryPromise(() => api.file.list({ location: { directory: process.cwd() } })).pipe(
|
|
||||||
Effect.map((response) => response.location.directory),
|
|
||||||
Effect.catch(() =>
|
|
||||||
Effect.tryPromise(() => api.location.get()).pipe(Effect.map((response) => response.directory)),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
return yield* run({
|
|
||||||
client: createOpencodeClient({ ...options, directory }),
|
|
||||||
api,
|
|
||||||
link: linkCredentials(transport),
|
|
||||||
discover: discover
|
|
||||||
? async () => {
|
|
||||||
const next = await discover()
|
|
||||||
return {
|
|
||||||
client: createOpencodeClient({ baseUrl: next.url, headers: next.headers, directory }),
|
|
||||||
api: OpenCode.make({ baseUrl: next.url, headers: next.headers }),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
: undefined,
|
|
||||||
reload,
|
|
||||||
args,
|
|
||||||
config,
|
|
||||||
log: (level, message, tags) => {
|
|
||||||
const effect =
|
|
||||||
level === "debug"
|
|
||||||
? Effect.logDebug(message, tags)
|
|
||||||
: level === "warn"
|
|
||||||
? Effect.logWarning(message, tags)
|
|
||||||
: level === "error"
|
|
||||||
? Effect.logError(message, tags)
|
|
||||||
: Effect.logInfo(message, tags)
|
|
||||||
runFork(effect)
|
|
||||||
},
|
|
||||||
pluginHost: {
|
|
||||||
async start(input) {
|
|
||||||
disposeSlots = await loadBuiltinPlugins(input.api, input.runtime)
|
|
||||||
},
|
|
||||||
async dispose() {
|
|
||||||
disposeSlots?.()
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}).pipe(Effect.provide(AppNodeBuilder.build(Global.node)))
|
|
||||||
}
|
|
||||||
|
|
||||||
function linkCredentials(transport: Service.Transport) {
|
|
||||||
const authorization = new Headers(transport.headers).get("authorization")
|
|
||||||
if (!authorization?.startsWith("Basic ")) return { username: "opencode", password: "" }
|
|
||||||
const value = atob(authorization.slice("Basic ".length))
|
|
||||||
const separator = value.indexOf(":")
|
|
||||||
if (separator === -1) return { username: "opencode", password: "" }
|
|
||||||
return { username: value.slice(0, separator), password: value.slice(separator + 1) }
|
|
||||||
}
|
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Effect } from "effect"
|
import { Effect } from "effect"
|
||||||
|
import { Service } from "@opencode-ai/client/effect"
|
||||||
import path from "node:path"
|
import path from "node:path"
|
||||||
import { Standalone } from "../../src/services/standalone"
|
import { Standalone } from "../../src/services/standalone"
|
||||||
|
|
||||||
@@ -7,9 +8,11 @@ process.argv[1] = path.join(import.meta.dir, "../../src/index.ts")
|
|||||||
await Effect.runPromise(
|
await Effect.runPromise(
|
||||||
Effect.scoped(
|
Effect.scoped(
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const transport = yield* Standalone.transport()
|
const endpoint = yield* Standalone.start()
|
||||||
const response = yield* Effect.promise(() => fetch(new URL("/api/health", transport.url), { headers: transport.headers }))
|
const response = yield* Effect.promise(() =>
|
||||||
console.log(`${transport.pid} ${transport.url} ${response.status}`)
|
fetch(new URL("/api/health", endpoint.url), { headers: Service.headers(endpoint) }),
|
||||||
|
)
|
||||||
|
console.log(`${endpoint.pid} ${endpoint.url} ${response.status}`)
|
||||||
return yield* Effect.never
|
return yield* Effect.never
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -11,9 +11,13 @@ import { join } from "node:path"
|
|||||||
// is all a client needs to connect. The daemon's own configuration (port,
|
// is all a client needs to connect. The daemon's own configuration (port,
|
||||||
// persisted password) is CLI-owned and never read here.
|
// persisted password) is CLI-owned and never read here.
|
||||||
|
|
||||||
export type Transport = {
|
export type Endpoint = {
|
||||||
readonly url: string
|
readonly url: string
|
||||||
readonly headers?: RequestInit["headers"]
|
readonly auth?: {
|
||||||
|
readonly type: "basic"
|
||||||
|
readonly username: string
|
||||||
|
readonly password: string
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Options = {
|
export type Options = {
|
||||||
@@ -31,7 +35,7 @@ export type Options = {
|
|||||||
// Read-only lookup: registration file plus health check and version gate.
|
// Read-only lookup: registration file plus health check and version gate.
|
||||||
// Never spawns; escalation to start() is the caller's policy.
|
// Never spawns; escalation to start() is the caller's policy.
|
||||||
export const discover = Effect.fn("service.discover")(function* (options: Options = {}) {
|
export const discover = Effect.fn("service.discover")(function* (options: Options = {}) {
|
||||||
return (yield* discoverLocal(options))?.transport
|
return (yield* discoverLocal(options))?.endpoint
|
||||||
})
|
})
|
||||||
|
|
||||||
const discoverLocal = Effect.fnUntraced(function* (options: Options) {
|
const discoverLocal = Effect.fnUntraced(function* (options: Options) {
|
||||||
@@ -72,7 +76,7 @@ export const start = Effect.fn("service.start")(function* (options: Options = {}
|
|||||||
child.kill("SIGTERM")
|
child.kill("SIGTERM")
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
Effect.map((found) => found.transport),
|
Effect.map((found) => found.endpoint),
|
||||||
Effect.tapError(() => Effect.try({ try: () => child.kill("SIGTERM"), catch: () => undefined }).pipe(Effect.ignore)),
|
Effect.tapError(() => Effect.try({ try: () => child.kill("SIGTERM"), catch: () => undefined }).pipe(Effect.ignore)),
|
||||||
Effect.mapError(() => new Error("Failed to start server")),
|
Effect.mapError(() => new Error("Failed to start server")),
|
||||||
)
|
)
|
||||||
@@ -90,8 +94,9 @@ function fallback() {
|
|||||||
return join(state, "opencode", "service.json")
|
return join(state, "opencode", "service.json")
|
||||||
}
|
}
|
||||||
|
|
||||||
function auth(password: string): RequestInit["headers"] {
|
export function headers(endpoint: Endpoint): RequestInit["headers"] {
|
||||||
return { authorization: "Basic " + btoa("opencode:" + password) }
|
if (endpoint.auth === undefined) return undefined
|
||||||
|
return { authorization: "Basic " + btoa(endpoint.auth.username + ":" + endpoint.auth.password) }
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Info = Schema.Struct({
|
export const Info = Schema.Struct({
|
||||||
@@ -120,14 +125,20 @@ const read = Effect.fnUntraced(function* (file?: string) {
|
|||||||
|
|
||||||
type LocalService = {
|
type LocalService = {
|
||||||
readonly info: Info
|
readonly info: Info
|
||||||
readonly transport: Transport
|
readonly endpoint: Endpoint
|
||||||
}
|
}
|
||||||
|
|
||||||
const probe = Effect.fnUntraced(function* (info: Info, version?: string, allowLegacy = false) {
|
const probe = Effect.fnUntraced(function* (info: Info, version?: string, allowLegacy = false) {
|
||||||
const headers = info.password === undefined ? undefined : auth(info.password)
|
const endpoint = {
|
||||||
|
url: info.url,
|
||||||
|
auth:
|
||||||
|
info.password === undefined
|
||||||
|
? undefined
|
||||||
|
: { type: "basic" as const, username: "opencode", password: info.password },
|
||||||
|
} satisfies Endpoint
|
||||||
const response = yield* Effect.tryPromise(() =>
|
const response = yield* Effect.tryPromise(() =>
|
||||||
fetch(new URL("/api/health", info.url), {
|
fetch(new URL("/api/health", info.url), {
|
||||||
headers,
|
headers: headers(endpoint),
|
||||||
signal: AbortSignal.timeout(2_000),
|
signal: AbortSignal.timeout(2_000),
|
||||||
}),
|
}),
|
||||||
).pipe(Effect.option, Effect.map(Option.getOrUndefined))
|
).pipe(Effect.option, Effect.map(Option.getOrUndefined))
|
||||||
@@ -138,7 +149,7 @@ const probe = Effect.fnUntraced(function* (info: Info, version?: string, allowLe
|
|||||||
if (health.value.pid !== info.pid) return undefined
|
if (health.value.pid !== info.pid) return undefined
|
||||||
if (info.version !== undefined && health.value.version !== info.version) return undefined
|
if (info.version !== undefined && health.value.version !== info.version) return undefined
|
||||||
if (version !== undefined && health.value.version !== version) return undefined
|
if (version !== undefined && health.value.version !== version) return undefined
|
||||||
return { info, transport: { url: info.url, headers } } satisfies LocalService
|
return { info, endpoint } satisfies LocalService
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
!allowLegacy ||
|
!allowLegacy ||
|
||||||
@@ -146,7 +157,7 @@ const probe = Effect.fnUntraced(function* (info: Info, version?: string, allowLe
|
|||||||
(typeof body === "object" && body !== null && ("version" in body || "pid" in body))
|
(typeof body === "object" && body !== null && ("version" in body || "pid" in body))
|
||||||
)
|
)
|
||||||
return undefined
|
return undefined
|
||||||
return { info, transport: { url: info.url, headers } } satisfies LocalService
|
return { info, endpoint } satisfies LocalService
|
||||||
})
|
})
|
||||||
|
|
||||||
// Health-checked lookup without the version gate: lifecycle operations must be
|
// Health-checked lookup without the version gate: lifecycle operations must be
|
||||||
|
|||||||
+39
-12
@@ -2,6 +2,8 @@ import { render, TimeToFirstDraw, useRenderer, useTerminalDimensions } from "@op
|
|||||||
import { registerOpencodeSpinner } from "./component/register-spinner"
|
import { registerOpencodeSpinner } from "./component/register-spinner"
|
||||||
import { createDefaultOpenTuiKeymap } from "@opentui/keymap/opentui"
|
import { createDefaultOpenTuiKeymap } from "@opentui/keymap/opentui"
|
||||||
import { Deferred, Effect } from "effect"
|
import { Deferred, Effect } from "effect"
|
||||||
|
import { Service } from "@opencode-ai/client/effect"
|
||||||
|
import { OpenCode } from "@opencode-ai/client/promise"
|
||||||
import { Global } from "@opencode-ai/core/global"
|
import { Global } from "@opencode-ai/core/global"
|
||||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||||
import { InstallationVersion } from "@opencode-ai/core/installation/version"
|
import { InstallationVersion } from "@opencode-ai/core/installation/version"
|
||||||
@@ -46,6 +48,7 @@ import { DialogMcp } from "./component/dialog-mcp"
|
|||||||
import { DialogStatus } from "./component/dialog-status"
|
import { DialogStatus } from "./component/dialog-status"
|
||||||
import { DialogDebug } from "./component/dialog-debug"
|
import { DialogDebug } from "./component/dialog-debug"
|
||||||
import { DialogLink, type DialogLinkCredentials } from "./component/dialog-link"
|
import { DialogLink, type DialogLinkCredentials } from "./component/dialog-link"
|
||||||
|
import { createOpencodeClient } from "@opencode-ai/sdk/v2/client"
|
||||||
import { DialogThemeList } from "./component/dialog-theme-list"
|
import { DialogThemeList } from "./component/dialog-theme-list"
|
||||||
import { DialogHelp } from "./ui/dialog-help"
|
import { DialogHelp } from "./ui/dialog-help"
|
||||||
import { DialogAgent } from "./component/dialog-agent"
|
import { DialogAgent } from "./component/dialog-agent"
|
||||||
@@ -81,8 +84,6 @@ import {
|
|||||||
useOpencodeKeymap,
|
useOpencodeKeymap,
|
||||||
} from "./keymap"
|
} from "./keymap"
|
||||||
|
|
||||||
import type { OpenCodeClient } from "@opencode-ai/client/promise"
|
|
||||||
import type { OpencodeClient } from "@opencode-ai/sdk/v2"
|
|
||||||
import { DialogVariant } from "./component/dialog-variant"
|
import { DialogVariant } from "./component/dialog-variant"
|
||||||
import { createTuiAttention } from "./attention"
|
import { createTuiAttention } from "./attention"
|
||||||
import * as TuiAudio from "./audio"
|
import * as TuiAudio from "./audio"
|
||||||
@@ -144,11 +145,11 @@ const appBindingCommands = [
|
|||||||
] as const
|
] as const
|
||||||
|
|
||||||
export type TuiInput = {
|
export type TuiInput = {
|
||||||
client: OpencodeClient
|
server: {
|
||||||
api: OpenCodeClient
|
endpoint: Service.Endpoint
|
||||||
discover?: () => Promise<{ client: OpencodeClient; api: OpenCodeClient }>
|
discover?: () => Promise<Service.Endpoint>
|
||||||
reload?: () => Promise<void>
|
reload?: () => Promise<void>
|
||||||
link?: DialogLinkCredentials
|
}
|
||||||
args: Args
|
args: Args
|
||||||
config: TuiConfig.Resolved
|
config: TuiConfig.Resolved
|
||||||
onSnapshot?: () => Promise<string[]>
|
onSnapshot?: () => Promise<string[]>
|
||||||
@@ -191,6 +192,25 @@ function isVersionGreater(left: string, right: string) {
|
|||||||
export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
|
export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
|
||||||
const log = input.log ?? (() => {})
|
const log = input.log ?? (() => {})
|
||||||
const global = yield* Global.Service
|
const global = yield* Global.Service
|
||||||
|
const options = { baseUrl: input.server.endpoint.url, headers: Service.headers(input.server.endpoint) }
|
||||||
|
const api = OpenCode.make(options)
|
||||||
|
const directory = yield* Effect.tryPromise(() => api.file.list({ location: { directory: process.cwd() } })).pipe(
|
||||||
|
Effect.map((response) => response.location.directory),
|
||||||
|
Effect.catch(() =>
|
||||||
|
Effect.tryPromise(() => api.location.get()).pipe(Effect.map((response) => response.directory)),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
const discover = input.server.discover
|
||||||
|
const reconnect = discover
|
||||||
|
? async () => {
|
||||||
|
const endpoint = await discover()
|
||||||
|
const next = { baseUrl: endpoint.url, headers: Service.headers(endpoint) }
|
||||||
|
return {
|
||||||
|
client: createOpencodeClient({ ...next, directory }),
|
||||||
|
api: OpenCode.make(next),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
const exit = { epilogue: undefined as string | undefined, reason: undefined as unknown }
|
const exit = { epilogue: undefined as string | undefined, reason: undefined as unknown }
|
||||||
const result = yield* Effect.scoped(
|
const result = yield* Effect.scoped(
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
@@ -317,10 +337,10 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
|
|||||||
<TuiConfigProvider config={input.config}>
|
<TuiConfigProvider config={input.config}>
|
||||||
<PluginRuntimeProvider value={pluginRuntime}>
|
<PluginRuntimeProvider value={pluginRuntime}>
|
||||||
<SDKProvider
|
<SDKProvider
|
||||||
client={input.client}
|
client={createOpencodeClient({ ...options, directory })}
|
||||||
api={input.api}
|
api={api}
|
||||||
discover={input.discover}
|
discover={reconnect}
|
||||||
reload={input.reload}
|
reload={input.server.reload}
|
||||||
>
|
>
|
||||||
<PermissionProvider>
|
<PermissionProvider>
|
||||||
<ProjectProvider>
|
<ProjectProvider>
|
||||||
@@ -338,7 +358,14 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
|
|||||||
<App
|
<App
|
||||||
onSnapshot={input.onSnapshot}
|
onSnapshot={input.onSnapshot}
|
||||||
pluginHost={input.pluginHost}
|
pluginHost={input.pluginHost}
|
||||||
link={input.link}
|
link={
|
||||||
|
input.server.endpoint.auth
|
||||||
|
? input.server.endpoint.auth
|
||||||
|
: {
|
||||||
|
username: "opencode",
|
||||||
|
password: "",
|
||||||
|
}
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</LocationProvider>
|
</LocationProvider>
|
||||||
</EditorContextProvider>
|
</EditorContextProvider>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { Effect } from "effect"
|
|||||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||||
import { Global } from "@opencode-ai/core/global"
|
import { Global } from "@opencode-ai/core/global"
|
||||||
import { createTuiResolvedConfig } from "./fixture/tui-runtime"
|
import { createTuiResolvedConfig } from "./fixture/tui-runtime"
|
||||||
import { createApi, createClient, createEventStream, createFetch, directory, json } from "./fixture/tui-sdk"
|
import { createEventStream, createFetch, directory, json } from "./fixture/tui-sdk"
|
||||||
|
|
||||||
test("SIGHUP clears title and disposes scoped resources once", async () => {
|
test("SIGHUP clears title and disposes scoped resources once", async () => {
|
||||||
const setup = await createTestRenderer({ width: 80, height: 24, useThread: false })
|
const setup = await createTestRenderer({ width: 80, height: 24, useThread: false })
|
||||||
@@ -20,6 +20,7 @@ test("SIGHUP clears title and disposes scoped resources once", async () => {
|
|||||||
const listeners = new Set(process.listeners("SIGHUP"))
|
const listeners = new Set(process.listeners("SIGHUP"))
|
||||||
const events = createEventStream()
|
const events = createEventStream()
|
||||||
const calls = createFetch(undefined, events)
|
const calls = createFetch(undefined, events)
|
||||||
|
const server = Bun.serve({ port: 0, fetch: (request) => calls.fetch(request) })
|
||||||
let started!: () => void
|
let started!: () => void
|
||||||
const ready = new Promise<void>((resolve) => {
|
const ready = new Promise<void>((resolve) => {
|
||||||
started = resolve
|
started = resolve
|
||||||
@@ -30,8 +31,7 @@ test("SIGHUP clears title and disposes scoped resources once", async () => {
|
|||||||
const { run } = await import("../src/app")
|
const { run } = await import("../src/app")
|
||||||
const task = Effect.runPromise(
|
const task = Effect.runPromise(
|
||||||
run({
|
run({
|
||||||
client: createClient(calls.fetch),
|
server: { endpoint: { url: server.url.toString() } },
|
||||||
api: createApi(calls.fetch),
|
|
||||||
config: createTuiResolvedConfig({ plugin_enabled: {} }),
|
config: createTuiResolvedConfig({ plugin_enabled: {} }),
|
||||||
args: {},
|
args: {},
|
||||||
log: () => {},
|
log: () => {},
|
||||||
@@ -55,6 +55,7 @@ test("SIGHUP clears title and disposes scoped resources once", async () => {
|
|||||||
expect(process.listeners("SIGHUP").every((listener) => listeners.has(listener))).toBe(true)
|
expect(process.listeners("SIGHUP").every((listener) => listeners.has(listener))).toBe(true)
|
||||||
} finally {
|
} finally {
|
||||||
if (!setup.renderer.isDestroyed) setup.renderer.destroy()
|
if (!setup.renderer.isDestroyed) setup.renderer.destroy()
|
||||||
|
await server.stop()
|
||||||
mock.restore()
|
mock.restore()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -79,22 +80,25 @@ test("session lifecycle updates the terminal title and prints the epilogue after
|
|||||||
}
|
}
|
||||||
const events = createEventStream()
|
const events = createEventStream()
|
||||||
const calls = createFetch((url) => {
|
const calls = createFetch((url) => {
|
||||||
|
const session = {
|
||||||
|
id: "dummy",
|
||||||
|
title: "Demo session",
|
||||||
|
projectID: "project",
|
||||||
|
location: { directory },
|
||||||
|
cost: 0,
|
||||||
|
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||||
|
time: { created: 0, updated: 0 },
|
||||||
|
}
|
||||||
if (url.pathname === "/api/session")
|
if (url.pathname === "/api/session")
|
||||||
return json({
|
return json({
|
||||||
data: [
|
data: [session],
|
||||||
{
|
|
||||||
id: "dummy",
|
|
||||||
title: "Demo session",
|
|
||||||
projectID: "project",
|
|
||||||
location: { directory },
|
|
||||||
cost: 0,
|
|
||||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
|
||||||
time: { created: 0, updated: 0 },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
cursor: {},
|
cursor: {},
|
||||||
})
|
})
|
||||||
|
if (url.pathname === "/api/session/dummy") return json({ data: session })
|
||||||
|
if (url.pathname === "/api/session/dummy/message") return json({ data: [], cursor: {} })
|
||||||
|
if (url.pathname === "/api/session/dummy/permission") return json({ data: [] })
|
||||||
}, events)
|
}, events)
|
||||||
|
const server = Bun.serve({ port: 0, fetch: (request) => calls.fetch(request) })
|
||||||
const originalWrite = process.stdout.write.bind(process.stdout)
|
const originalWrite = process.stdout.write.bind(process.stdout)
|
||||||
let stdout = ""
|
let stdout = ""
|
||||||
let api: TuiPluginApi | undefined
|
let api: TuiPluginApi | undefined
|
||||||
@@ -112,8 +116,7 @@ test("session lifecycle updates the terminal title and prints the epilogue after
|
|||||||
const { run } = await import("../src/app")
|
const { run } = await import("../src/app")
|
||||||
const task = Effect.runPromise(
|
const task = Effect.runPromise(
|
||||||
run({
|
run({
|
||||||
client: createClient(calls.fetch),
|
server: { endpoint: { url: server.url.toString() } },
|
||||||
api: createApi(calls.fetch),
|
|
||||||
config: createTuiResolvedConfig({ plugin_enabled: {} }),
|
config: createTuiResolvedConfig({ plugin_enabled: {} }),
|
||||||
args: { sessionID: "dummy" },
|
args: { sessionID: "dummy" },
|
||||||
log: () => {},
|
log: () => {},
|
||||||
@@ -145,6 +148,7 @@ test("session lifecycle updates the terminal title and prints the epilogue after
|
|||||||
} finally {
|
} finally {
|
||||||
process.stdout.write = originalWrite
|
process.stdout.write = originalWrite
|
||||||
if (!setup.renderer.isDestroyed) setup.renderer.destroy()
|
if (!setup.renderer.isDestroyed) setup.renderer.destroy()
|
||||||
|
await server.stop()
|
||||||
mock.restore()
|
mock.restore()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -93,6 +93,8 @@ export function createFetch(override?: FetchHandler, events?: ReturnType<typeof
|
|||||||
if (url.pathname === "/experimental/capabilities") return json({ backgroundSubagents: true })
|
if (url.pathname === "/experimental/capabilities") return json({ backgroundSubagents: true })
|
||||||
if (url.pathname === "/path") return json({ home: "", state: "", config: "", worktree, directory })
|
if (url.pathname === "/path") return json({ home: "", state: "", config: "", worktree, directory })
|
||||||
if (url.pathname === "/api/location") return json({ directory, project: { id: "proj_test", directory: worktree } })
|
if (url.pathname === "/api/location") return json({ directory, project: { id: "proj_test", directory: worktree } })
|
||||||
|
if (url.pathname === "/api/fs/list")
|
||||||
|
return json({ location: { directory, project: { id: "proj_test", directory: worktree } }, data: [] })
|
||||||
if (url.pathname === "/api/project/current") return json({ id: "proj_test", directory: worktree })
|
if (url.pathname === "/api/project/current") return json({ id: "proj_test", directory: worktree })
|
||||||
if (url.pathname === "/api/project/proj_test/directories") return json([{ directory: worktree }])
|
if (url.pathname === "/api/project/proj_test/directories") return json([{ directory: worktree }])
|
||||||
if (url.pathname === "/api/shell")
|
if (url.pathname === "/api/shell")
|
||||||
|
|||||||
Reference in New Issue
Block a user