diff --git a/packages/opencode/src/server/routes/instance/httpapi/control.ts b/packages/opencode/src/server/routes/instance/httpapi/control.ts new file mode 100644 index 0000000000..14cbdf7c45 --- /dev/null +++ b/packages/opencode/src/server/routes/instance/httpapi/control.ts @@ -0,0 +1,72 @@ +import { Auth } from "@/auth" +import { ProviderID } from "@/provider/schema" +import { Schema } from "effect" +import { HttpApi, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" + +const AuthParams = Schema.Struct({ + providerID: ProviderID, +}) + +const LogQuery = Schema.Struct({ + directory: Schema.optional(Schema.String), + workspace: Schema.optional(Schema.String), +}) + +const LogInput = Schema.Struct({ + service: Schema.String.annotate({ description: "Service name for the log entry" }), + level: Schema.Union([ + Schema.Literal("debug"), + Schema.Literal("info"), + Schema.Literal("error"), + Schema.Literal("warn"), + ]).annotate({ description: "Log level" }), + message: Schema.String.annotate({ description: "Log message" }), + extra: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)).annotate({ + description: "Additional metadata for the log entry", + }), +}).annotate({ identifier: "AppLogInput" }) + +export const ControlPaths = { + auth: "/auth/:providerID", + log: "/log", +} as const + +export const ControlApi = HttpApi.make("control") + .add( + HttpApiGroup.make("control") + .add( + HttpApiEndpoint.put("authSet", ControlPaths.auth, { + params: AuthParams, + payload: Auth.Info, + success: Schema.Boolean, + }).annotateMerge( + OpenApi.annotations({ + identifier: "auth.set", + summary: "Set auth credentials", + description: "Set authentication credentials", + }), + ), + HttpApiEndpoint.delete("authRemove", ControlPaths.auth, { + params: AuthParams, + success: Schema.Boolean, + }).annotateMerge( + OpenApi.annotations({ + identifier: "auth.remove", + summary: "Remove auth credentials", + description: "Remove authentication credentials", + }), + ), + HttpApiEndpoint.post("log", ControlPaths.log, { + query: LogQuery, + payload: LogInput, + success: Schema.Boolean, + }).annotateMerge( + OpenApi.annotations({ + identifier: "app.log", + summary: "Write log", + description: "Write a log entry to the server logs with specified level and metadata.", + }), + ), + ) + .annotateMerge(OpenApi.annotations({ title: "control", description: "Control plane routes." })), + ) diff --git a/packages/opencode/src/server/routes/instance/httpapi/event.ts b/packages/opencode/src/server/routes/instance/httpapi/event.ts index 78113e976d..3194210cee 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/event.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/event.ts @@ -1,8 +1,9 @@ import { Bus } from "@/bus" import * as Log from "@opencode-ai/core/util/log" -import { Effect } from "effect" +import { Effect, Schema } from "effect" import * as Stream from "effect/Stream" import { HttpRouter, HttpServerResponse } from "effect/unstable/http" +import { HttpApi, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" const log = Log.create({ service: "server" }) @@ -10,6 +11,23 @@ export const EventPaths = { event: "/event", } as const +export const EventApi = HttpApi.make("event") + .add( + HttpApiGroup.make("event") + .add( + HttpApiEndpoint.get("subscribe", EventPaths.event, { + success: Schema.Unknown, + }).annotateMerge( + OpenApi.annotations({ + identifier: "event.subscribe", + summary: "Subscribe to events", + description: "Get events", + }), + ), + ) + .annotateMerge(OpenApi.annotations({ title: "event", description: "Instance event stream route." })), + ) + function eventData(data: unknown) { return `data: ${JSON.stringify(data)}\n\n` } diff --git a/packages/opencode/src/server/routes/instance/httpapi/global.ts b/packages/opencode/src/server/routes/instance/httpapi/global.ts new file mode 100644 index 0000000000..44789b12fb --- /dev/null +++ b/packages/opencode/src/server/routes/instance/httpapi/global.ts @@ -0,0 +1,102 @@ +import { Config } from "@/config/config" +import { Schema } from "effect" +import { HttpApi, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" + +const GlobalHealth = Schema.Struct({ + healthy: Schema.Literal(true), + version: Schema.String, +}).annotate({ identifier: "GlobalHealth" }) + +const GlobalEvent = Schema.Struct({ + directory: Schema.String, + project: Schema.optional(Schema.String), + workspace: Schema.optional(Schema.String), + payload: Schema.Unknown, +}).annotate({ identifier: "GlobalEvent" }) + +const GlobalUpgradeInput = Schema.Struct({ + target: Schema.optional(Schema.String), +}).annotate({ identifier: "GlobalUpgradeInput" }) + +const GlobalUpgradeResult = Schema.Union([ + Schema.Struct({ + success: Schema.Literal(true), + version: Schema.String, + }), + Schema.Struct({ + success: Schema.Literal(false), + error: Schema.String, + }), +]).annotate({ identifier: "GlobalUpgradeResult" }) + +export const GlobalPaths = { + health: "/global/health", + event: "/global/event", + config: "/global/config", + dispose: "/global/dispose", + upgrade: "/global/upgrade", +} as const + +export const GlobalApi = HttpApi.make("global") + .add( + HttpApiGroup.make("global") + .add( + HttpApiEndpoint.get("health", GlobalPaths.health, { + success: GlobalHealth, + }).annotateMerge( + OpenApi.annotations({ + identifier: "global.health", + summary: "Get health", + description: "Get health information about the OpenCode server.", + }), + ), + HttpApiEndpoint.get("event", GlobalPaths.event, { + success: GlobalEvent, + }).annotateMerge( + OpenApi.annotations({ + identifier: "global.event", + summary: "Get global events", + description: "Subscribe to global events from the OpenCode system using server-sent events.", + }), + ), + HttpApiEndpoint.get("configGet", GlobalPaths.config, { + success: Config.Info, + }).annotateMerge( + OpenApi.annotations({ + identifier: "global.config.get", + summary: "Get global configuration", + description: "Retrieve the current global OpenCode configuration settings and preferences.", + }), + ), + HttpApiEndpoint.patch("configUpdate", GlobalPaths.config, { + payload: Config.Info, + success: Config.Info, + }).annotateMerge( + OpenApi.annotations({ + identifier: "global.config.update", + summary: "Update global configuration", + description: "Update global OpenCode configuration settings and preferences.", + }), + ), + HttpApiEndpoint.post("dispose", GlobalPaths.dispose, { + success: Schema.Boolean, + }).annotateMerge( + OpenApi.annotations({ + identifier: "global.dispose", + summary: "Dispose instance", + description: "Clean up and dispose all OpenCode instances, releasing all resources.", + }), + ), + HttpApiEndpoint.post("upgrade", GlobalPaths.upgrade, { + payload: GlobalUpgradeInput, + success: GlobalUpgradeResult, + }).annotateMerge( + OpenApi.annotations({ + identifier: "global.upgrade", + summary: "Upgrade opencode", + description: "Upgrade opencode to the specified version or latest if not specified.", + }), + ), + ) + .annotateMerge(OpenApi.annotations({ title: "global", description: "Global server routes." })), + ) diff --git a/packages/opencode/src/server/routes/instance/httpapi/pty.ts b/packages/opencode/src/server/routes/instance/httpapi/pty.ts index 4e46f30df7..21a2dec5ce 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/pty.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/pty.ts @@ -113,6 +113,25 @@ export const PtyApi = HttpApi.make("pty") }), ) +export const PtyConnectApi = HttpApi.make("pty-connect") + .add( + HttpApiGroup.make("pty-connect") + .add( + HttpApiEndpoint.get("connect", PtyPaths.connect, { + params: Params, + query: CursorQuery, + success: Schema.Boolean, + }).annotateMerge( + OpenApi.annotations({ + identifier: "pty.connect", + summary: "Connect to PTY session", + description: "Establish a WebSocket connection to interact with a pseudo-terminal (PTY) session in real-time.", + }), + ), + ) + .annotateMerge(OpenApi.annotations({ title: "pty", description: "PTY websocket route." })), + ) + export const ptyHandlers = Layer.unwrap( Effect.gen(function* () { const pty = yield* Pty.Service diff --git a/packages/opencode/src/server/routes/instance/httpapi/public.ts b/packages/opencode/src/server/routes/instance/httpapi/public.ts new file mode 100644 index 0000000000..1a7f675b3f --- /dev/null +++ b/packages/opencode/src/server/routes/instance/httpapi/public.ts @@ -0,0 +1,45 @@ +import { HttpApi, OpenApi } from "effect/unstable/httpapi" +import { ConfigApi } from "./config" +import { ControlApi } from "./control" +import { EventApi } from "./event" +import { ExperimentalApi } from "./experimental" +import { FileApi } from "./file" +import { GlobalApi } from "./global" +import { InstanceApi } from "./instance" +import { McpApi } from "./mcp" +import { PermissionApi } from "./permission" +import { ProjectApi } from "./project" +import { ProviderApi } from "./provider" +import { PtyApi, PtyConnectApi } from "./pty" +import { QuestionApi } from "./question" +import { SessionApi } from "./session" +import { SyncApi } from "./sync" +import { TuiApi } from "./tui" +import { WorkspaceApi } from "./workspace" + +export const PublicApi = HttpApi.make("opencode") + .addHttpApi(ControlApi) + .addHttpApi(GlobalApi) + .addHttpApi(EventApi) + .addHttpApi(ConfigApi) + .addHttpApi(ExperimentalApi) + .addHttpApi(FileApi) + .addHttpApi(InstanceApi) + .addHttpApi(McpApi) + .addHttpApi(PermissionApi) + .addHttpApi(ProjectApi) + .addHttpApi(ProviderApi) + .addHttpApi(PtyApi) + .addHttpApi(PtyConnectApi) + .addHttpApi(QuestionApi) + .addHttpApi(SessionApi) + .addHttpApi(SyncApi) + .addHttpApi(TuiApi) + .addHttpApi(WorkspaceApi) + .annotateMerge( + OpenApi.annotations({ + title: "opencode", + version: "1.0.0", + description: "opencode api", + }), + ) diff --git a/packages/opencode/test/server/httpapi-bridge.test.ts b/packages/opencode/test/server/httpapi-bridge.test.ts index 8f9170d660..d185dee3b2 100644 --- a/packages/opencode/test/server/httpapi-bridge.test.ts +++ b/packages/opencode/test/server/httpapi-bridge.test.ts @@ -19,8 +19,10 @@ import { SessionApi } from "../../src/server/routes/instance/httpapi/session" import { SyncApi } from "../../src/server/routes/instance/httpapi/sync" import { TuiApi } from "../../src/server/routes/instance/httpapi/tui" import { WorkspaceApi } from "../../src/server/routes/instance/httpapi/workspace" +import { PublicApi } from "../../src/server/routes/instance/httpapi/public" +import { Server } from "../../src/server/server" import * as Log from "@opencode-ai/core/util/log" -import { HttpApi, HttpApiGroup } from "effect/unstable/httpapi" +import { HttpApi, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" import { resetDatabase } from "../fixture/db" import { tmpdir } from "../fixture/fixture" @@ -33,6 +35,7 @@ const original = { } const websocket = (() => () => new Response(null, { status: 501 })) as unknown as UpgradeWebSocket +const methods = ["get", "post", "put", "delete", "patch"] as const function app(input?: { password?: string; username?: string }) { Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true @@ -75,6 +78,12 @@ function reflectedHttpApiRoutes() { return [...new Set(routes)] } +function openApiRouteKeys(spec: { paths: Record>> }) { + return Object.entries(spec.paths) + .flatMap(([path, item]) => methods.filter((method) => item[method]).map((method) => `${method.toUpperCase()} ${path}`)) + .sort() +} + function authorization(username: string, password: string) { return `Basic ${Buffer.from(`${username}:${password}`).toString("base64")}` } @@ -129,6 +138,14 @@ describe("HttpApi Hono bridge", () => { expect([...bridgeRoutes].filter((route) => !httpApiRoutes.includes(route)).sort()).toEqual([]) }) + test("covers every generated OpenAPI route with Effect HttpApi contracts", async () => { + const honoRoutes = openApiRouteKeys(await Server.openapi()) + const effectRoutes = openApiRouteKeys(OpenApi.fromApi(PublicApi)) + + expect(honoRoutes.filter((route) => !effectRoutes.includes(route))).toEqual([]) + expect(effectRoutes.filter((route) => !honoRoutes.includes(route))).toEqual([]) + }) + test("allows requests when auth is disabled", async () => { await using tmp = await tmpdir({ git: true }) await Bun.write(`${tmp.path}/hello.txt`, "hello")