From 5fb0470b44abd2aeba846d7c369a5f382bd0b5a7 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Wed, 15 Jul 2026 22:52:55 -0400 Subject: [PATCH] feat(api): add experimental wellknown connections --- packages/cli/src/commands/commands.ts | 16 +- .../cli/src/commands/handlers/auth/connect.ts | 52 ++++++ packages/cli/src/index.ts | 3 + packages/client/src/effect/api/api.ts | 79 +++++---- .../client/src/effect/generated/client.ts | 125 +++++++------ .../client/src/promise/generated/client.ts | 17 ++ .../client/src/promise/generated/types.ts | 9 + packages/client/test/promise.test.ts | 25 ++- packages/core/schema.json | 57 +++++- packages/core/src/config.ts | 104 +++++++++-- packages/core/src/database/migration.gen.ts | 1 + .../database/migration/20260716020354_kv.ts | 18 ++ packages/core/src/database/schema.gen.ts | 8 + packages/core/src/integration.ts | 39 ++-- packages/core/src/kv.ts | 47 +++++ packages/core/src/kv/sql.ts | 9 + packages/core/src/plugin/internal.ts | 5 + packages/core/src/plugin/supervisor.ts | 6 +- packages/core/src/wellknown.ts | 166 ++++++++++++++++++ packages/core/src/wellknown/plugin.ts | 34 ++++ packages/core/test/config/config.test.ts | 124 ++++++++++++- packages/core/test/integration.test.ts | 4 +- packages/core/test/kv.test.ts | 25 +++ packages/core/test/wellknown.test.ts | 82 +++++++++ .../opencode/test/session/message-v2.test.ts | 4 +- packages/plugin/src/v2/effect/integration.ts | 2 +- packages/plugin/src/v2/promise/integration.ts | 2 +- packages/protocol/src/groups/integration.ts | 16 ++ packages/sdk/js/src/v2/gen/sdk.gen.ts | 65 +++++++ packages/sdk/js/src/v2/gen/types.gen.ts | 38 ++++ packages/server/src/handlers/integration.ts | 17 ++ packages/server/src/routes.ts | 7 +- .../tui/src/component/dialog-integration.tsx | 34 +++- 33 files changed, 1086 insertions(+), 154 deletions(-) create mode 100644 packages/cli/src/commands/handlers/auth/connect.ts create mode 100644 packages/core/src/database/migration/20260716020354_kv.ts create mode 100644 packages/core/src/kv.ts create mode 100644 packages/core/src/kv/sql.ts create mode 100644 packages/core/src/wellknown.ts create mode 100644 packages/core/src/wellknown/plugin.ts create mode 100644 packages/core/test/kv.test.ts create mode 100644 packages/core/test/wellknown.test.ts diff --git a/packages/cli/src/commands/commands.ts b/packages/cli/src/commands/commands.ts index a80c5ba108..89b109c961 100644 --- a/packages/cli/src/commands/commands.ts +++ b/packages/cli/src/commands/commands.ts @@ -66,6 +66,17 @@ export const Commands = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCO }), ], }), + Spec.make("auth", { + description: "Manage authentication", + commands: [ + Spec.make("connect", { + description: "Connect to a wellknown authentication provider", + params: { + url: Argument.string("url").pipe(Argument.withDescription("Wellknown provider URL")), + }, + }), + ], + }), Spec.make("mcp", { description: "Manage MCP (Model Context Protocol) servers", commands: [ @@ -178,10 +189,7 @@ export const Commands = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCO Flag.atMost(100), ), title: Flag.string("title").pipe(Flag.withDescription("Session title"), Flag.optional), - thinking: Flag.boolean("thinking").pipe( - Flag.withDescription("Show thinking blocks"), - Flag.withDefault(false), - ), + thinking: Flag.boolean("thinking").pipe(Flag.withDescription("Show thinking blocks"), Flag.withDefault(false)), auto: Flag.boolean("auto").pipe( Flag.withDescription("Auto-approve permissions that are not explicitly denied"), Flag.withDefault(false), diff --git a/packages/cli/src/commands/handlers/auth/connect.ts b/packages/cli/src/commands/handlers/auth/connect.ts new file mode 100644 index 0000000000..f135145568 --- /dev/null +++ b/packages/cli/src/commands/handlers/auth/connect.ts @@ -0,0 +1,52 @@ +import { EOL } from "node:os" +import { Effect } from "effect" +import { Service } from "@opencode-ai/client/effect/service" +import { OpenCode, type IntegrationCommandStatusOutput, type OpenCodeClient } from "@opencode-ai/client/promise" +import { Commands } from "../../commands" +import { Runtime } from "../../../framework/runtime" +import { ServiceConfig } from "../../../services/service-config" + +const location = { directory: process.cwd() } + +export default Runtime.handler( + Commands.commands.auth.commands.connect, + Effect.fn("cli.auth.connect")(function* (input) { + process.stdout.write("Connecting..." + EOL + EOL) + const endpoint = yield* Service.ensure(yield* ServiceConfig.options()) + const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) }) + yield* request(() => client.integration.wellknown.add({ url: input.url, location })) + const integrationID = input.url.replace(/\/+$/, "") + const started = yield* request(() => + client.integration.command.connect({ integrationID, methodID: "login", location }), + ) + yield* Effect.addFinalizer(() => + request(() => + client.integration.command.cancel({ integrationID, attemptID: started.data.attemptID, location }), + ).pipe(Effect.ignore), + ) + + const status = yield* wait(client, integrationID, started.data.attemptID) + if (status.status === "failed") return yield* Effect.fail(new Error(status.message)) + if (status.status === "expired") return yield* Effect.fail(new Error("Authentication expired")) + process.stdout.write("Connected" + EOL) + }), +) + +const wait = ( + client: OpenCodeClient, + integrationID: string, + attemptID: string, + shown = false, +): Effect.Effect, unknown> => + Effect.gen(function* () { + const response = yield* request(() => client.integration.command.status({ integrationID, attemptID, location })) + if (response.data.status !== "pending") return response.data + const output = response.data.message?.trim() + if (!shown && output) process.stdout.write(output + EOL + EOL) + yield* Effect.sleep(500) + return yield* wait(client, integrationID, attemptID, shown || !!output) + }) + +function request(task: () => Promise) { + return Effect.tryPromise({ try: task, catch: (cause) => cause }) +} diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index fd59472a86..9346dbe35f 100755 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -17,6 +17,9 @@ import { Npm } from "@opencode-ai/core/npm" const Handlers = Runtime.handlers(Commands, { $: () => import("./commands/handlers/default"), api: () => import("./commands/handlers/api"), + auth: { + connect: () => import("./commands/handlers/auth/connect"), + }, debug: { agents: () => import("./commands/handlers/debug/agents"), }, diff --git a/packages/client/src/effect/api/api.ts b/packages/client/src/effect/api/api.ts index 853e98d548..27da772894 100644 --- a/packages/client/src/effect/api/api.ts +++ b/packages/client/src/effect/api/api.ts @@ -407,102 +407,113 @@ export type Endpoint10_1Input = { export type Endpoint10_1Output = EffectValue> export type IntegrationGetOperation = (input: Endpoint10_1Input) => Effect.Effect -type Endpoint10_2Request = Parameters[0] +type Endpoint10_2Request = Parameters[0] export type Endpoint10_2Input = { - readonly integrationID: Endpoint10_2Request["params"]["integrationID"] readonly location?: Endpoint10_2Request["query"]["location"] - readonly key: Endpoint10_2Request["payload"]["key"] - readonly label?: Endpoint10_2Request["payload"]["label"] + readonly url: Endpoint10_2Request["payload"]["url"] } -export type Endpoint10_2Output = EffectValue> -export type IntegrationConnectKeyOperation = ( +export type Endpoint10_2Output = EffectValue> +export type IntegrationWellknownAddOperation = ( input: Endpoint10_2Input, ) => Effect.Effect -type Endpoint10_3Request = Parameters[0] +type Endpoint10_3Request = Parameters[0] export type Endpoint10_3Input = { readonly integrationID: Endpoint10_3Request["params"]["integrationID"] readonly location?: Endpoint10_3Request["query"]["location"] - readonly methodID: Endpoint10_3Request["payload"]["methodID"] - readonly inputs: Endpoint10_3Request["payload"]["inputs"] + readonly key: Endpoint10_3Request["payload"]["key"] readonly label?: Endpoint10_3Request["payload"]["label"] } -export type Endpoint10_3Output = EffectValue> -export type IntegrationOauthConnectOperation = ( +export type Endpoint10_3Output = EffectValue> +export type IntegrationConnectKeyOperation = ( input: Endpoint10_3Input, ) => Effect.Effect -type Endpoint10_4Request = Parameters[0] +type Endpoint10_4Request = Parameters[0] export type Endpoint10_4Input = { readonly integrationID: Endpoint10_4Request["params"]["integrationID"] - readonly attemptID: Endpoint10_4Request["params"]["attemptID"] readonly location?: Endpoint10_4Request["query"]["location"] + readonly methodID: Endpoint10_4Request["payload"]["methodID"] + readonly inputs: Endpoint10_4Request["payload"]["inputs"] + readonly label?: Endpoint10_4Request["payload"]["label"] } -export type Endpoint10_4Output = EffectValue> -export type IntegrationOauthStatusOperation = ( +export type Endpoint10_4Output = EffectValue> +export type IntegrationOauthConnectOperation = ( input: Endpoint10_4Input, ) => Effect.Effect -type Endpoint10_5Request = Parameters[0] +type Endpoint10_5Request = Parameters[0] export type Endpoint10_5Input = { readonly integrationID: Endpoint10_5Request["params"]["integrationID"] readonly attemptID: Endpoint10_5Request["params"]["attemptID"] readonly location?: Endpoint10_5Request["query"]["location"] - readonly code?: Endpoint10_5Request["payload"]["code"] } -export type Endpoint10_5Output = EffectValue> -export type IntegrationOauthCompleteOperation = ( +export type Endpoint10_5Output = EffectValue> +export type IntegrationOauthStatusOperation = ( input: Endpoint10_5Input, ) => Effect.Effect -type Endpoint10_6Request = Parameters[0] +type Endpoint10_6Request = Parameters[0] export type Endpoint10_6Input = { readonly integrationID: Endpoint10_6Request["params"]["integrationID"] readonly attemptID: Endpoint10_6Request["params"]["attemptID"] readonly location?: Endpoint10_6Request["query"]["location"] + readonly code?: Endpoint10_6Request["payload"]["code"] } -export type Endpoint10_6Output = EffectValue> -export type IntegrationOauthCancelOperation = ( +export type Endpoint10_6Output = EffectValue> +export type IntegrationOauthCompleteOperation = ( input: Endpoint10_6Input, ) => Effect.Effect -type Endpoint10_7Request = Parameters[0] +type Endpoint10_7Request = Parameters[0] export type Endpoint10_7Input = { readonly integrationID: Endpoint10_7Request["params"]["integrationID"] + readonly attemptID: Endpoint10_7Request["params"]["attemptID"] readonly location?: Endpoint10_7Request["query"]["location"] - readonly methodID: Endpoint10_7Request["payload"]["methodID"] - readonly label?: Endpoint10_7Request["payload"]["label"] } -export type Endpoint10_7Output = EffectValue> -export type IntegrationCommandConnectOperation = ( +export type Endpoint10_7Output = EffectValue> +export type IntegrationOauthCancelOperation = ( input: Endpoint10_7Input, ) => Effect.Effect -type Endpoint10_8Request = Parameters[0] +type Endpoint10_8Request = Parameters[0] export type Endpoint10_8Input = { readonly integrationID: Endpoint10_8Request["params"]["integrationID"] - readonly attemptID: Endpoint10_8Request["params"]["attemptID"] readonly location?: Endpoint10_8Request["query"]["location"] + readonly methodID: Endpoint10_8Request["payload"]["methodID"] + readonly label?: Endpoint10_8Request["payload"]["label"] } -export type Endpoint10_8Output = EffectValue> -export type IntegrationCommandStatusOperation = ( +export type Endpoint10_8Output = EffectValue> +export type IntegrationCommandConnectOperation = ( input: Endpoint10_8Input, ) => Effect.Effect -type Endpoint10_9Request = Parameters[0] +type Endpoint10_9Request = Parameters[0] export type Endpoint10_9Input = { readonly integrationID: Endpoint10_9Request["params"]["integrationID"] readonly attemptID: Endpoint10_9Request["params"]["attemptID"] readonly location?: Endpoint10_9Request["query"]["location"] } -export type Endpoint10_9Output = EffectValue> -export type IntegrationCommandCancelOperation = ( +export type Endpoint10_9Output = EffectValue> +export type IntegrationCommandStatusOperation = ( input: Endpoint10_9Input, ) => Effect.Effect +type Endpoint10_10Request = Parameters[0] +export type Endpoint10_10Input = { + readonly integrationID: Endpoint10_10Request["params"]["integrationID"] + readonly attemptID: Endpoint10_10Request["params"]["attemptID"] + readonly location?: Endpoint10_10Request["query"]["location"] +} +export type Endpoint10_10Output = EffectValue> +export type IntegrationCommandCancelOperation = ( + input: Endpoint10_10Input, +) => Effect.Effect + export interface IntegrationApi { readonly list: IntegrationListOperation readonly get: IntegrationGetOperation + readonly wellknown: { readonly add: IntegrationWellknownAddOperation } readonly connect: { readonly key: IntegrationConnectKeyOperation } readonly oauth: { readonly connect: IntegrationOauthConnectOperation diff --git a/packages/client/src/effect/generated/client.ts b/packages/client/src/effect/generated/client.ts index 22bebf6e19..c7a2f86035 100644 --- a/packages/client/src/effect/generated/client.ts +++ b/packages/client/src/effect/generated/client.ts @@ -504,106 +504,116 @@ const Endpoint10_1 = (raw: RawClient["server.integration"]) => (input: Endpoint1 query: { location: input["location"] }, }).pipe(Effect.mapError(mapClientError)) -type Endpoint10_2Request = Parameters[0] +type Endpoint10_2Request = Parameters[0] type Endpoint10_2Input = { - readonly integrationID: Endpoint10_2Request["params"]["integrationID"] readonly location?: Endpoint10_2Request["query"]["location"] - readonly key: Endpoint10_2Request["payload"]["key"] - readonly label?: Endpoint10_2Request["payload"]["label"] + readonly url: Endpoint10_2Request["payload"]["url"] } const Endpoint10_2 = (raw: RawClient["server.integration"]) => (input: Endpoint10_2Input) => + raw["integration.wellknown.add"]({ query: { location: input["location"] }, payload: { url: input["url"] } }).pipe( + Effect.mapError(mapClientError), + ) + +type Endpoint10_3Request = Parameters[0] +type Endpoint10_3Input = { + readonly integrationID: Endpoint10_3Request["params"]["integrationID"] + readonly location?: Endpoint10_3Request["query"]["location"] + readonly key: Endpoint10_3Request["payload"]["key"] + readonly label?: Endpoint10_3Request["payload"]["label"] +} +const Endpoint10_3 = (raw: RawClient["server.integration"]) => (input: Endpoint10_3Input) => raw["integration.connect.key"]({ params: { integrationID: input["integrationID"] }, query: { location: input["location"] }, payload: { key: input["key"], label: input["label"] }, }).pipe(Effect.mapError(mapClientError)) -type Endpoint10_3Request = Parameters[0] -type Endpoint10_3Input = { - readonly integrationID: Endpoint10_3Request["params"]["integrationID"] - readonly location?: Endpoint10_3Request["query"]["location"] - readonly methodID: Endpoint10_3Request["payload"]["methodID"] - readonly inputs: Endpoint10_3Request["payload"]["inputs"] - readonly label?: Endpoint10_3Request["payload"]["label"] +type Endpoint10_4Request = Parameters[0] +type Endpoint10_4Input = { + readonly integrationID: Endpoint10_4Request["params"]["integrationID"] + readonly location?: Endpoint10_4Request["query"]["location"] + readonly methodID: Endpoint10_4Request["payload"]["methodID"] + readonly inputs: Endpoint10_4Request["payload"]["inputs"] + readonly label?: Endpoint10_4Request["payload"]["label"] } -const Endpoint10_3 = (raw: RawClient["server.integration"]) => (input: Endpoint10_3Input) => +const Endpoint10_4 = (raw: RawClient["server.integration"]) => (input: Endpoint10_4Input) => raw["integration.oauth.connect"]({ params: { integrationID: input["integrationID"] }, query: { location: input["location"] }, payload: { methodID: input["methodID"], inputs: input["inputs"], label: input["label"] }, }).pipe(Effect.mapError(mapClientError)) -type Endpoint10_4Request = Parameters[0] -type Endpoint10_4Input = { - readonly integrationID: Endpoint10_4Request["params"]["integrationID"] - readonly attemptID: Endpoint10_4Request["params"]["attemptID"] - readonly location?: Endpoint10_4Request["query"]["location"] +type Endpoint10_5Request = Parameters[0] +type Endpoint10_5Input = { + readonly integrationID: Endpoint10_5Request["params"]["integrationID"] + readonly attemptID: Endpoint10_5Request["params"]["attemptID"] + readonly location?: Endpoint10_5Request["query"]["location"] } -const Endpoint10_4 = (raw: RawClient["server.integration"]) => (input: Endpoint10_4Input) => +const Endpoint10_5 = (raw: RawClient["server.integration"]) => (input: Endpoint10_5Input) => raw["integration.oauth.status"]({ params: { integrationID: input["integrationID"], attemptID: input["attemptID"] }, query: { location: input["location"] }, }).pipe(Effect.mapError(mapClientError)) -type Endpoint10_5Request = Parameters[0] -type Endpoint10_5Input = { - readonly integrationID: Endpoint10_5Request["params"]["integrationID"] - readonly attemptID: Endpoint10_5Request["params"]["attemptID"] - readonly location?: Endpoint10_5Request["query"]["location"] - readonly code?: Endpoint10_5Request["payload"]["code"] +type Endpoint10_6Request = Parameters[0] +type Endpoint10_6Input = { + readonly integrationID: Endpoint10_6Request["params"]["integrationID"] + readonly attemptID: Endpoint10_6Request["params"]["attemptID"] + readonly location?: Endpoint10_6Request["query"]["location"] + readonly code?: Endpoint10_6Request["payload"]["code"] } -const Endpoint10_5 = (raw: RawClient["server.integration"]) => (input: Endpoint10_5Input) => +const Endpoint10_6 = (raw: RawClient["server.integration"]) => (input: Endpoint10_6Input) => raw["integration.oauth.complete"]({ params: { integrationID: input["integrationID"], attemptID: input["attemptID"] }, query: { location: input["location"] }, payload: { code: input["code"] }, }).pipe(Effect.mapError(mapClientError)) -type Endpoint10_6Request = Parameters[0] -type Endpoint10_6Input = { - readonly integrationID: Endpoint10_6Request["params"]["integrationID"] - readonly attemptID: Endpoint10_6Request["params"]["attemptID"] - readonly location?: Endpoint10_6Request["query"]["location"] +type Endpoint10_7Request = Parameters[0] +type Endpoint10_7Input = { + readonly integrationID: Endpoint10_7Request["params"]["integrationID"] + readonly attemptID: Endpoint10_7Request["params"]["attemptID"] + readonly location?: Endpoint10_7Request["query"]["location"] } -const Endpoint10_6 = (raw: RawClient["server.integration"]) => (input: Endpoint10_6Input) => +const Endpoint10_7 = (raw: RawClient["server.integration"]) => (input: Endpoint10_7Input) => raw["integration.oauth.cancel"]({ params: { integrationID: input["integrationID"], attemptID: input["attemptID"] }, query: { location: input["location"] }, }).pipe(Effect.mapError(mapClientError)) -type Endpoint10_7Request = Parameters[0] -type Endpoint10_7Input = { - readonly integrationID: Endpoint10_7Request["params"]["integrationID"] - readonly location?: Endpoint10_7Request["query"]["location"] - readonly methodID: Endpoint10_7Request["payload"]["methodID"] - readonly label?: Endpoint10_7Request["payload"]["label"] +type Endpoint10_8Request = Parameters[0] +type Endpoint10_8Input = { + readonly integrationID: Endpoint10_8Request["params"]["integrationID"] + readonly location?: Endpoint10_8Request["query"]["location"] + readonly methodID: Endpoint10_8Request["payload"]["methodID"] + readonly label?: Endpoint10_8Request["payload"]["label"] } -const Endpoint10_7 = (raw: RawClient["server.integration"]) => (input: Endpoint10_7Input) => +const Endpoint10_8 = (raw: RawClient["server.integration"]) => (input: Endpoint10_8Input) => raw["integration.command.connect"]({ params: { integrationID: input["integrationID"] }, query: { location: input["location"] }, payload: { methodID: input["methodID"], label: input["label"] }, }).pipe(Effect.mapError(mapClientError)) -type Endpoint10_8Request = Parameters[0] -type Endpoint10_8Input = { - readonly integrationID: Endpoint10_8Request["params"]["integrationID"] - readonly attemptID: Endpoint10_8Request["params"]["attemptID"] - readonly location?: Endpoint10_8Request["query"]["location"] -} -const Endpoint10_8 = (raw: RawClient["server.integration"]) => (input: Endpoint10_8Input) => - raw["integration.command.status"]({ - params: { integrationID: input["integrationID"], attemptID: input["attemptID"] }, - query: { location: input["location"] }, - }).pipe(Effect.mapError(mapClientError)) - -type Endpoint10_9Request = Parameters[0] +type Endpoint10_9Request = Parameters[0] type Endpoint10_9Input = { readonly integrationID: Endpoint10_9Request["params"]["integrationID"] readonly attemptID: Endpoint10_9Request["params"]["attemptID"] readonly location?: Endpoint10_9Request["query"]["location"] } const Endpoint10_9 = (raw: RawClient["server.integration"]) => (input: Endpoint10_9Input) => + raw["integration.command.status"]({ + params: { integrationID: input["integrationID"], attemptID: input["attemptID"] }, + query: { location: input["location"] }, + }).pipe(Effect.mapError(mapClientError)) + +type Endpoint10_10Request = Parameters[0] +type Endpoint10_10Input = { + readonly integrationID: Endpoint10_10Request["params"]["integrationID"] + readonly attemptID: Endpoint10_10Request["params"]["attemptID"] + readonly location?: Endpoint10_10Request["query"]["location"] +} +const Endpoint10_10 = (raw: RawClient["server.integration"]) => (input: Endpoint10_10Input) => raw["integration.command.cancel"]({ params: { integrationID: input["integrationID"], attemptID: input["attemptID"] }, query: { location: input["location"] }, @@ -612,14 +622,15 @@ const Endpoint10_9 = (raw: RawClient["server.integration"]) => (input: Endpoint1 const adaptGroup10 = (raw: RawClient["server.integration"]) => ({ list: Endpoint10_0(raw), get: Endpoint10_1(raw), - connect: { key: Endpoint10_2(raw) }, + wellknown: { add: Endpoint10_2(raw) }, + connect: { key: Endpoint10_3(raw) }, oauth: { - connect: Endpoint10_3(raw), - status: Endpoint10_4(raw), - complete: Endpoint10_5(raw), - cancel: Endpoint10_6(raw), + connect: Endpoint10_4(raw), + status: Endpoint10_5(raw), + complete: Endpoint10_6(raw), + cancel: Endpoint10_7(raw), }, - command: { connect: Endpoint10_7(raw), status: Endpoint10_8(raw), cancel: Endpoint10_9(raw) }, + command: { connect: Endpoint10_8(raw), status: Endpoint10_9(raw), cancel: Endpoint10_10(raw) }, }) type Endpoint11_0Request = Parameters[0] diff --git a/packages/client/src/promise/generated/client.ts b/packages/client/src/promise/generated/client.ts index 6523b19848..3783a5e5c0 100644 --- a/packages/client/src/promise/generated/client.ts +++ b/packages/client/src/promise/generated/client.ts @@ -82,6 +82,8 @@ import type { IntegrationListOutput, IntegrationGetInput, IntegrationGetOutput, + IntegrationWellknownAddInput, + IntegrationWellknownAddOutput, IntegrationConnectKeyInput, IntegrationConnectKeyOutput, IntegrationOauthConnectInput, @@ -893,6 +895,21 @@ export function make(options: ClientOptions) { }, requestOptions, ), + wellknown: { + add: (input: IntegrationWellknownAddInput, requestOptions?: RequestOptions) => + request( + { + method: "POST", + path: `/api/experimental/integration/wellknown`, + query: { location: input["location"] }, + body: { url: input["url"] }, + successStatus: 204, + declaredStatuses: [400, 401], + empty: true, + }, + requestOptions, + ), + }, connect: { key: (input: IntegrationConnectKeyInput, requestOptions?: RequestOptions) => request( diff --git a/packages/client/src/promise/generated/types.ts b/packages/client/src/promise/generated/types.ts index dce1302906..25241fb908 100644 --- a/packages/client/src/promise/generated/types.ts +++ b/packages/client/src/promise/generated/types.ts @@ -3304,6 +3304,15 @@ export type IntegrationGetOutput = { data: IntegrationInfo | null } +export type IntegrationWellknownAddInput = { + readonly location?: { + readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined + }["location"] + readonly url: { readonly url: string }["url"] +} + +export type IntegrationWellknownAddOutput = void + export type IntegrationConnectKeyInput = { readonly integrationID: { readonly integrationID: string }["integrationID"] readonly location?: { diff --git a/packages/client/test/promise.test.ts b/packages/client/test/promise.test.ts index 6a22178f35..fd836272b0 100644 --- a/packages/client/test/promise.test.ts +++ b/packages/client/test/promise.test.ts @@ -36,7 +36,8 @@ test("exposes every standard HTTP API group", () => { expect(Object.keys(client.debug)).toEqual(["location"]) expect(Object.keys(client.debug.location)).toEqual(["list", "evict"]) expect(Object.keys(client.message)).toEqual(["list"]) - expect(Object.keys(client.integration)).toEqual(["list", "get", "connect", "oauth", "command"]) + expect(Object.keys(client.integration)).toEqual(["list", "get", "wellknown", "connect", "oauth", "command"]) + expect(Object.keys(client.integration.wellknown)).toEqual(["add"]) expect(Object.keys(client.integration.connect)).toEqual(["key"]) expect(Object.keys(client.integration.oauth)).toEqual(["connect", "status", "complete", "cancel"]) expect(Object.keys(client.integration.command)).toEqual(["connect", "status", "cancel"]) @@ -62,6 +63,28 @@ test("server.get uses the public HTTP contract", async () => { expect(request?.url).toBe("http://localhost:3000/api/server") }) +test("experimental wellknown integration add uses the public HTTP contract", async () => { + let request: Request | undefined + const client = OpenCode.make({ + baseUrl: "http://localhost:3000", + fetch: async (input, init) => { + request = input instanceof Request ? input : new Request(input, init) + return new Response(null, { status: 204 }) + }, + }) + + await client.integration.wellknown.add({ + url: "https://example.com", + location: { directory: "/tmp/project" }, + }) + + expect(request?.method).toBe("POST") + expect(request?.url).toBe( + "http://localhost:3000/api/experimental/integration/wellknown?location%5Bdirectory%5D=%2Ftmp%2Fproject", + ) + expect(await request?.json()).toEqual({ url: "https://example.com" }) +}) + test("health.stop sends exact replacement identity", async () => { let request: Request | undefined const client = OpenCode.make({ diff --git a/packages/core/schema.json b/packages/core/schema.json index 82a395b4e4..2ef980ade3 100644 --- a/packages/core/schema.json +++ b/packages/core/schema.json @@ -1,9 +1,9 @@ { "version": "7", "dialect": "sqlite", - "id": "5f0a1db8-d4bf-42c3-becb-96b46fe66bed", + "id": "a4ba73b4-21bc-41ab-a415-94e2ca38d798", "prevIds": [ - "666138ef-82cb-4a9a-a765-e6669a436ff3" + "5f0a1db8-d4bf-42c3-becb-96b46fe66bed" ], "ddl": [ { @@ -38,6 +38,10 @@ "name": "event", "entityType": "tables" }, + { + "name": "kv", + "entityType": "tables" + }, { "name": "permission", "entityType": "tables" @@ -556,6 +560,46 @@ "entityType": "columns", "table": "event" }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "key", + "entityType": "columns", + "table": "kv" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "value", + "entityType": "columns", + "table": "kv" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "kv" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "kv" + }, { "type": "text", "notNull": false, @@ -1844,6 +1888,15 @@ "table": "event", "entityType": "pks" }, + { + "columns": [ + "key" + ], + "nameExplicit": false, + "name": "kv_pk", + "table": "kv", + "entityType": "pks" + }, { "columns": [ "id" diff --git a/packages/core/src/config.ts b/packages/core/src/config.ts index 55a1641f1c..adf6e9912b 100644 --- a/packages/core/src/config.ts +++ b/packages/core/src/config.ts @@ -6,6 +6,8 @@ import { type ParseError, parse } from "jsonc-parser" import { Context, Effect, Fiber, Layer, Option, PubSub, Schema, Stream } from "effect" import { Permission } from "@opencode-ai/schema/permission" import { Config as ConfigSchema } from "@opencode-ai/schema/config" +import { Integration } from "@opencode-ai/schema/integration" +import { Credential } from "./credential" import { EventV2 } from "./event" import { Watcher } from "./filesystem/watcher" import { FSUtil } from "./fs-util" @@ -28,6 +30,7 @@ import { ConfigVariable } from "./config/variable" import { ConfigWatcher } from "./config/watcher" import { ConfigV1 } from "./v1/config/config" import { ConfigMigrateV1 } from "./v1/config/migrate" +import { WellKnown } from "./wellknown" export class Info extends Schema.Class("Config.Info")({ $schema: Schema.optional(Schema.String).annotate({ @@ -157,29 +160,67 @@ const layer = Layer.effect( const location = yield* Location.Service const watcher = yield* Watcher.Service const events = yield* EventV2.Service + const credentials = yield* Credential.Service + const wellknown = yield* WellKnown.Service const names = ["opencode.json", "opencode.jsonc"] const decodeOptions = { errors: "all", onExcessProperty: "ignore", propertyOrder: "original" } as const const decodeInfo = Schema.decodeUnknownOption(Info, decodeOptions) const decodeV1Info = Schema.decodeUnknownOption(ConfigV1.Info, decodeOptions) - const loadFile = Effect.fnUntraced(function* (filepath: string) { - const text = yield* fs.readFileStringSafe(filepath) - if (!text) return - const substituted = yield* ConfigVariable.substitute({ type: "path", path: filepath, text }) - + const parseInfo = (text: string) => { const errors: ParseError[] = [] - const input: unknown = parse(substituted, errors, { allowTrailingComma: true }) + const input: unknown = parse(text, errors, { allowTrailingComma: true }) if (errors.length) return - - const info = Option.getOrUndefined( + return Option.getOrUndefined( ConfigMigrateV1.isV1(input) ? decodeV1Info(input).pipe(Option.map(ConfigMigrateV1.migrate), Option.flatMap(decodeInfo)) : decodeInfo(input), ) + } + + const loadFile = Effect.fnUntraced(function* (filepath: string) { + const text = yield* fs.readFileStringSafe(filepath) + if (!text) return + const substituted = yield* ConfigVariable.substitute({ type: "path", path: filepath, text }) + const info = parseInfo(substituted) if (!info) return return new Document({ type: "document", path: filepath, info }) }) + const loadWellknown = Effect.fn("Config.loadWellknown")(function* () { + const entries = yield* wellknown + .entries() + .pipe( + Effect.catch((error) => + Effect.logWarning("failed to discover wellknown config", { error }).pipe(Effect.as([] as const)), + ), + ) + return yield* Effect.forEach(entries, (entry) => + Effect.gen(function* () { + const auth = entry.manifest.auth + if (!auth) return [] + const credential = (yield* credentials.list(entry.integrationID)).findLast( + (credential) => credential.value.type === "key", + ) + if (!credential || credential.value.type !== "key") return [] + const variables = { [auth.env]: credential.value.key } + const configs = yield* wellknown.resolve(entry, variables).pipe(Effect.orDie) + return yield* Effect.forEach(configs, (config) => + ConfigVariable.substitute({ + type: "virtual", + source: entry.origin, + dir: entry.origin, + text: JSON.stringify(config), + env: variables, + }).pipe( + Effect.map(parseInfo), + Effect.map((info) => (info ? new Document({ type: "document", info }) : undefined)), + ), + ).pipe(Effect.map((documents) => documents.filter((document) => document !== undefined))) + }), + ).pipe(Effect.map((documents) => documents.flat())) + }) + const loadDirectory = Effect.fnUntraced(function* (directory: AbsolutePath) { return [ ...(yield* Effect.forEach(names, (file) => loadFile(path.join(directory, file))).pipe( @@ -201,7 +242,7 @@ const layer = Layer.effect( targets: [".opencode", ".claude", ".agents", ...names.toReversed()], start: location.directory, }) - .pipe(Effect.orDie) + .pipe(Effect.orDie) // We load certain files from a few other folders in the ecosystem const claude = [ @@ -244,7 +285,14 @@ const layer = Layer.effect( ) const supplementary = yield* Effect.forEach(directories, loadDirectory).pipe(Effect.orDie) - return [...claude, ...agents, ...(supplementary[0] ?? []), ...direct, ...supplementary.slice(1).flat()] + return [ + ...claude, + ...agents, + ...(supplementary[0] ?? []), + ...direct, + ...supplementary.slice(1).flat(), + ...(yield* loadWellknown().pipe(Effect.orDie)), + ] }) const initial = yield* discover() @@ -276,15 +324,37 @@ const layer = Layer.effect( } }) + const reload = Effect.fn("Config.reload")(function* () { + const next = yield* discover() + configs = next + yield* reconcile(next) + yield* events.publish(ConfigSchema.Event.Updated, {}) + }) + yield* Stream.fromPubSub(updates).pipe( Stream.debounce("100 millis"), Stream.runForEach((update) => - Effect.gen(function* () { - const next = yield* discover() - configs = next - yield* reconcile(next) - yield* events.publish(ConfigSchema.Event.Updated, {}) - }).pipe(Effect.catchCause((cause) => Effect.logError("failed to reload config", { path: update.path, cause }))), + reload().pipe( + Effect.catchCause((cause) => Effect.logError("failed to reload config", { path: update.path, cause })), + ), + ), + Effect.forkScoped({ startImmediately: true }), + ) + yield* events.subscribe(Integration.Event.ConnectionUpdated).pipe( + Stream.filterEffect((event) => + wellknown.entries().pipe( + Effect.map((entries) => entries.some((entry) => entry.integrationID === event.data.integrationID)), + Effect.catch(() => Effect.succeed(false)), + ), + ), + Stream.runForEach(() => + reload().pipe(Effect.catchCause((cause) => Effect.logError("failed to reload wellknown config", { cause }))), + ), + Effect.forkScoped({ startImmediately: true }), + ) + yield* wellknown.changes.pipe( + Stream.runForEach(() => + reload().pipe(Effect.catchCause((cause) => Effect.logError("failed to reload wellknown sources", { cause }))), ), Effect.forkScoped({ startImmediately: true }), ) @@ -301,5 +371,5 @@ const layer = Layer.effect( export const node = makeLocationNode({ service: Service, layer, - deps: [Watcher.node, EventV2.node, FSUtil.node, Global.node, Location.node], + deps: [Watcher.node, EventV2.node, FSUtil.node, Global.node, Location.node, Credential.node, WellKnown.node], }) diff --git a/packages/core/src/database/migration.gen.ts b/packages/core/src/database/migration.gen.ts index 2039393030..c4067c4765 100644 --- a/packages/core/src/database/migration.gen.ts +++ b/packages/core/src/database/migration.gen.ts @@ -54,5 +54,6 @@ export const migrations = ( import("./migration/20260709163752_time_suspended"), import("./migration/20260709190621_session_pending_table"), import("./migration/20260710025429_instruction_sync"), + import("./migration/20260716020354_kv"), ]) ).map((module) => module.default) satisfies DatabaseMigration.Migration[] diff --git a/packages/core/src/database/migration/20260716020354_kv.ts b/packages/core/src/database/migration/20260716020354_kv.ts new file mode 100644 index 0000000000..5a0ac8bdd5 --- /dev/null +++ b/packages/core/src/database/migration/20260716020354_kv.ts @@ -0,0 +1,18 @@ +import { Effect } from "effect" +import type { DatabaseMigration } from "../migration" + +export default { + id: "20260716020354_kv", + up(tx) { + return Effect.gen(function* () { + yield* tx.run(` + CREATE TABLE \`kv\` ( + \`key\` text PRIMARY KEY, + \`value\` text NOT NULL, + \`time_created\` integer NOT NULL, + \`time_updated\` integer NOT NULL + ); + `) + }) + }, +} satisfies DatabaseMigration.Migration diff --git a/packages/core/src/database/schema.gen.ts b/packages/core/src/database/schema.gen.ts index 7d9fc7937b..b92b75e47b 100644 --- a/packages/core/src/database/schema.gen.ts +++ b/packages/core/src/database/schema.gen.ts @@ -87,6 +87,14 @@ export default { CONSTRAINT \`fk_event_aggregate_id_event_sequence_aggregate_id_fk\` FOREIGN KEY (\`aggregate_id\`) REFERENCES \`event_sequence\`(\`aggregate_id\`) ON DELETE CASCADE ); `) + yield* tx.run(` + CREATE TABLE \`kv\` ( + \`key\` text PRIMARY KEY, + \`value\` text NOT NULL, + \`time_created\` integer NOT NULL, + \`time_updated\` integer NOT NULL + ); + `) yield* tx.run(` CREATE TABLE \`permission\` ( \`id\` text PRIMARY KEY, diff --git a/packages/core/src/integration.ts b/packages/core/src/integration.ts index 9537b435d6..62d6376702 100644 --- a/packages/core/src/integration.ts +++ b/packages/core/src/integration.ts @@ -8,6 +8,7 @@ import { Duration, Effect, Exit, + Fiber, Layer, Schedule, Schema, @@ -617,34 +618,40 @@ const layer = Layer.effect( }), ) - yield* processes - .runStream( + yield* Effect.gen(function* () { + const handle = yield* processes.spawn( ChildProcess.make(method.command[0], method.command.slice(1), { extendEnv: true, stdin: "ignore", }), - { okExitCodes: [0] }, ) - .pipe( - Stream.tap((line) => + const stdout = yield* AppProcess.collectStream(handle.stdout, undefined).pipe(Effect.forkScoped) + yield* handle.stderr.pipe( + Stream.decodeText, + Stream.tap((chunk) => SynchronizedRef.update(commandAttempts, (current) => { const attempt = current.get(attemptID) if (!attempt || attempt.status !== "pending") return current - const message = attempt.message ? `${attempt.message}\n${line}` : line + const message = (attempt.message ?? "") + chunk return new Map(current).set(attemptID, { ...attempt, message }) }), ), - Stream.runCollect, - Effect.flatMap((lines) => { - const credential = Array.from(lines).at(-1) - return credential - ? Effect.succeed(credential) - : Effect.fail(new Error("Authentication command returned no credential")) - }), - Effect.exit, - Effect.flatMap((exit) => settleCommand(attemptID, exit)), - Effect.forkIn(attemptScope, { startImmediately: true }), + Stream.runDrain, ) + const exitCode = yield* handle.exitCode + if (exitCode !== 0) { + const attempt = (yield* SynchronizedRef.get(commandAttempts)).get(attemptID) + return yield* Effect.fail(new Error(attempt?.message?.trim() || `Authentication command exited ${exitCode}`)) + } + const credential = (yield* Fiber.join(stdout)).buffer.toString("utf8").trim() + if (!credential) return yield* Effect.fail(new Error("Authentication command returned no credential")) + return credential + }).pipe( + Scope.provide(attemptScope), + Effect.exit, + Effect.flatMap((exit) => settleCommand(attemptID, exit)), + Effect.forkIn(attemptScope, { startImmediately: true }), + ) return CommandAttempt.make({ attemptID, time }) }) diff --git a/packages/core/src/kv.ts b/packages/core/src/kv.ts new file mode 100644 index 0000000000..9bc0d10094 --- /dev/null +++ b/packages/core/src/kv.ts @@ -0,0 +1,47 @@ +export * as KV from "./kv" + +import { eq } from "drizzle-orm" +import { Context, Effect, Layer, Schema } from "effect" +import { Database } from "./database/database" +import { makeGlobalNode } from "./effect/app-node" +import { KVTable } from "./kv/sql" + +export type Value = Schema.Json + +export interface Interface { + readonly get: (key: string) => Effect.Effect + readonly set: (key: string, value: Value) => Effect.Effect + readonly remove: (key: string) => Effect.Effect +} + +export class Service extends Context.Service()("@opencode/v2/KV") {} + +const layer = Layer.effect( + Service, + Effect.gen(function* () { + const { db } = yield* Database.Service + return Service.of({ + get: Effect.fn("KV.get")(function* (key) { + return (yield* db + .select({ value: KVTable.value }) + .from(KVTable) + .where(eq(KVTable.key, key)) + .get() + .pipe(Effect.orDie))?.value + }), + set: Effect.fn("KV.set")(function* (key, value) { + yield* db + .insert(KVTable) + .values({ key, value }) + .onConflictDoUpdate({ target: KVTable.key, set: { value, time_updated: Date.now() } }) + .run() + .pipe(Effect.orDie) + }), + remove: Effect.fn("KV.remove")(function* (key) { + yield* db.delete(KVTable).where(eq(KVTable.key, key)).run().pipe(Effect.orDie) + }), + }) + }), +) + +export const node = makeGlobalNode({ service: Service, layer, deps: [Database.node] }) diff --git a/packages/core/src/kv/sql.ts b/packages/core/src/kv/sql.ts new file mode 100644 index 0000000000..2344c30c28 --- /dev/null +++ b/packages/core/src/kv/sql.ts @@ -0,0 +1,9 @@ +import { sqliteTable, text } from "drizzle-orm/sqlite-core" +import { Timestamps } from "../database/schema.sql" +import type { KV } from "../kv" + +export const KVTable = sqliteTable("kv", { + key: text().primaryKey(), + value: text({ mode: "json" }).$type().notNull(), + ...Timestamps, +}) diff --git a/packages/core/src/plugin/internal.ts b/packages/core/src/plugin/internal.ts index 1daf052ace..634ad46bb8 100644 --- a/packages/core/src/plugin/internal.ts +++ b/packages/core/src/plugin/internal.ts @@ -43,6 +43,7 @@ import { SubagentTool } from "../tool/subagent" import { Tools } from "../tool/tools" import { WebFetchTool } from "../tool/webfetch" import { WebSearchTool } from "../tool/websearch" +import { WellKnown } from "../wellknown" import { WriteTool } from "../tool/write" import { AgentPlugin } from "./agent" import { CommandPlugin } from "./command" @@ -52,6 +53,7 @@ import { PluginRuntime } from "./runtime" import { SkillPlugin } from "./skill" import { SystemPromptPlugin } from "./system-prompt" import { VariantPlugin } from "./variant" +import { WellKnownPlugin } from "../wellknown/plugin" const services = Effect.fn("PluginInternal.services")(function* () { const agent = yield* AgentV2.Service @@ -81,6 +83,7 @@ const services = Effect.fn("PluginInternal.services")(function* () { const skill = yield* SkillV2.Service const tools = yield* Tools.Service const websearch = yield* WebSearchTool.ConfigService + const wellknown = yield* WellKnown.Service return Context.mergeAll( Context.make(AgentV2.Service, agent), Context.make(Catalog.Service, catalog), @@ -109,6 +112,7 @@ const services = Effect.fn("PluginInternal.services")(function* () { Context.make(SkillV2.Service, skill), Context.make(Tools.Service, tools), Context.make(WebSearchTool.ConfigService, websearch), + Context.make(WellKnown.Service, wellknown), ) }) @@ -119,6 +123,7 @@ export type Requirements = ContextServices const pre = [ + WellKnownPlugin.Plugin, AgentPlugin.Plugin, CommandPlugin.Plugin, SkillPlugin.Plugin, diff --git a/packages/core/src/plugin/supervisor.ts b/packages/core/src/plugin/supervisor.ts index f41be2ad7c..fce294998e 100644 --- a/packages/core/src/plugin/supervisor.ts +++ b/packages/core/src/plugin/supervisor.ts @@ -35,6 +35,7 @@ import { SkillV2 } from "../skill" import { ReadToolFileSystem } from "../tool/read-filesystem" import { ToolRegistry } from "../tool/registry" import { WebSearchTool } from "../tool/websearch" +import { WellKnown } from "../wellknown" import { PluginInternal } from "./internal" import { PluginRuntime } from "./runtime" import { SdkPlugins } from "./sdk" @@ -163,9 +164,7 @@ const load = Effect.fn("PluginSupervisor.load")(function* (operation: Extract import(source)) const value = (yield* Schema.decodeUnknownEffect(PluginModule)(mod)).default @@ -296,6 +295,7 @@ export const node = makeLocationNode({ SkillV2.node, ToolRegistry.toolsNode, WebSearchTool.configNode, + WellKnown.node, ], }) diff --git a/packages/core/src/wellknown.ts b/packages/core/src/wellknown.ts new file mode 100644 index 0000000000..b259c4a953 --- /dev/null +++ b/packages/core/src/wellknown.ts @@ -0,0 +1,166 @@ +export * as WellKnown from "./wellknown" + +import { Integration } from "@opencode-ai/schema/integration" +import { Context, Effect, Layer, PubSub, Ref, Schema, Semaphore, Stream } from "effect" +import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http" +import { makeGlobalNode } from "./effect/app-node" +import { httpClient } from "./effect/app-node-platform" +import { KV } from "./kv" + +export interface Auth extends Schema.Schema.Type {} +export const Auth = Schema.Struct({ + command: Schema.Array(Schema.String), + env: Schema.String, +}).annotate({ identifier: "WellKnown.Auth" }) + +export interface RemoteConfig extends Schema.Schema.Type {} +export const RemoteConfig = Schema.Struct({ + url: Schema.String, + headers: Schema.optional(Schema.Record(Schema.String, Schema.String)), +}).annotate({ identifier: "WellKnown.RemoteConfig" }) + +export interface Config extends Schema.Schema.Type {} +export const Config = Schema.Record(Schema.String, Schema.Json).annotate({ identifier: "WellKnown.Config" }) + +export interface Manifest extends Schema.Schema.Type {} +export const Manifest = Schema.Struct({ + auth: Schema.optional(Auth), + config: Schema.optional(Schema.NullOr(Config)), + remote_config: Schema.optional(RemoteConfig), +}).annotate({ identifier: "WellKnown.Manifest" }) + +export interface ResolveInput { + readonly origin: string + readonly variables?: Readonly> +} + +export interface Entry { + readonly origin: string + readonly integrationID: Integration.ID + readonly manifest: Manifest +} + +export interface Interface { + readonly entries: () => Effect.Effect + readonly snapshot: () => readonly Entry[] + readonly add: (origin: string) => Effect.Effect + readonly remove: (origin: string) => Effect.Effect + readonly changes: Stream.Stream + readonly resolve: (entry: Entry, variables: Readonly>) => Effect.Effect +} + +export class Service extends Context.Service()("@opencode/v2/WellKnown") {} + +export const inspect = Effect.fn("WellKnown.inspect")(function* (origin: string) { + const url = `${origin.replace(/\/+$/, "")}/.well-known/opencode` + const http = HttpClient.filterStatusOk(yield* HttpClient.HttpClient) + return yield* http.execute(HttpClientRequest.get(url).pipe(HttpClientRequest.acceptJson)).pipe( + Effect.flatMap(HttpClientResponse.schemaBodyJson(Manifest)), + Effect.mapError((cause) => new Error(`Failed to load wellknown manifest from ${url}`, { cause })), + ) +}) + +export const resolve = Effect.fn("WellKnown.resolve")(function* (input: ResolveInput) { + const manifest = yield* inspect(input.origin) + return yield* resolveEntry( + { origin: input.origin, integrationID: Integration.ID.make(input.origin.replace(/\/+$/, "")), manifest }, + input.variables ?? {}, + ) +}) + +const resolveEntry = Effect.fnUntraced(function* (entry: Entry, variables: Readonly>) { + const configs = entry.manifest.config ? [entry.manifest.config] : [] + if (!entry.manifest.remote_config) return configs + + const substitute = (value: string) => + value.replace(/\{env:([^}]+)\}/g, (_, name: string) => variables[name] ?? process.env[name] ?? "") + const url = substitute(entry.manifest.remote_config.url) + const headers = Object.fromEntries( + Object.entries(entry.manifest.remote_config.headers ?? {}).map(([key, value]) => [key, substitute(value)]), + ) + const http = HttpClient.filterStatusOk(yield* HttpClient.HttpClient) + const remote = yield* http + .execute(HttpClientRequest.get(url).pipe(HttpClientRequest.acceptJson, HttpClientRequest.setHeaders(headers))) + .pipe( + Effect.flatMap(HttpClientResponse.schemaBodyJson(Config)), + Effect.mapError((cause) => new Error(`Failed to load wellknown remote config from ${url}`, { cause })), + ) + if (Schema.is(Config)(remote.config)) return [...configs, remote.config] + return [...configs, remote] +}) + +const sourcesKey = "wellknown:sources" +const Sources = Schema.Array(Schema.String) + +const layer = Layer.effect( + Service, + Effect.gen(function* () { + const http = yield* HttpClient.HttpClient + const kv = yield* KV.Service + const cache = yield* Ref.make(new Map()) + const changes = yield* PubSub.unbounded() + const lock = Semaphore.makeUnsafe(1) + + const load = Effect.fn("WellKnown.load")(function* () { + const value = yield* kv.get(sourcesKey) + const origins = Schema.is(Sources)(value) ? value : [] + const current = yield* Ref.get(cache) + const entries = yield* Effect.forEach(origins, (origin) => { + const cached = current.get(origin) + if (cached) return Effect.succeed(cached) + return inspect(origin).pipe( + Effect.provideService(HttpClient.HttpClient, http), + Effect.map((manifest) => ({ origin, integrationID: Integration.ID.make(origin), manifest })), + ) + }) + yield* Ref.set(cache, new Map(entries.map((entry) => [entry.origin, entry]))) + return entries + }) + + return Service.of({ + entries: load, + snapshot: () => Array.from(Ref.getUnsafe(cache).values()), + add: Effect.fn("WellKnown.add")(function* (value) { + return yield* lock.withPermit( + Effect.gen(function* () { + const origin = value.replace(/\/+$/, "") + const manifest = yield* inspect(origin).pipe(Effect.provideService(HttpClient.HttpClient, http)) + if (!manifest.auth) return yield* Effect.fail(new Error(`No authentication method found at ${origin}`)) + const entry = { origin, integrationID: Integration.ID.make(origin), manifest } + const sources = yield* kv.get(sourcesKey) + const origins = Schema.is(Sources)(sources) ? sources : [] + yield* kv.set(sourcesKey, Array.from(new Set([...origins, origin]))) + yield* Ref.update(cache, (current) => new Map(current).set(origin, entry)) + yield* PubSub.publish(changes, undefined) + return entry + }), + ) + }), + remove: Effect.fn("WellKnown.remove")(function* (value) { + yield* lock.withPermit( + Effect.gen(function* () { + const origin = value.replace(/\/+$/, "") + const sources = yield* kv.get(sourcesKey) + const origins = Schema.is(Sources)(sources) ? sources : [] + yield* kv.set( + sourcesKey, + origins.filter((item) => item !== origin), + ) + yield* Ref.update(cache, (current) => { + const next = new Map(current) + next.delete(origin) + return next + }) + yield* PubSub.publish(changes, undefined) + }), + ) + }), + changes: Stream.fromPubSub(changes), + resolve: Effect.fn("WellKnown.resolveEntry")(function* (entry, variables) { + return yield* resolveEntry(entry, variables).pipe(Effect.provideService(HttpClient.HttpClient, http)) + }), + }) + }), +) + +export const node = makeGlobalNode({ service: Service, layer, deps: [httpClient, KV.node] }) diff --git a/packages/core/src/wellknown/plugin.ts b/packages/core/src/wellknown/plugin.ts new file mode 100644 index 0000000000..e9f4f13576 --- /dev/null +++ b/packages/core/src/wellknown/plugin.ts @@ -0,0 +1,34 @@ +export * as WellKnownPlugin from "./plugin" + +import { define } from "@opencode-ai/plugin/v2/effect/plugin" +import { Effect, Stream } from "effect" +import { WellKnown } from "../wellknown" + +export const Plugin = define({ + id: "opencode.wellknown", + effect: Effect.fn(function* (ctx) { + const wellknown = yield* WellKnown.Service + yield* wellknown.entries().pipe(Effect.orDie) + yield* ctx.integration.transform((draft) => { + wellknown.snapshot().forEach((entry) => { + if (!entry.manifest.auth) return + draft.update(entry.integrationID, (integration) => { + integration.name = new URL(entry.origin).hostname + }) + draft.method.update({ + integrationID: entry.integrationID, + method: { + id: "login", + type: "command", + label: "Log in", + command: [...entry.manifest.auth.command], + }, + }) + }) + }) + yield* wellknown.changes.pipe( + Stream.runForEach(() => ctx.integration.reload()), + Effect.forkScoped({ startImmediately: true }), + ) + }), +}) diff --git a/packages/core/test/config/config.test.ts b/packages/core/test/config/config.test.ts index 94c84c3b02..65267ec41b 100644 --- a/packages/core/test/config/config.test.ts +++ b/packages/core/test/config/config.test.ts @@ -8,7 +8,9 @@ import { ConfigModel } from "@opencode-ai/core/config/model" import { Config as ConfigSchema } from "@opencode-ai/schema/config" import { ConfigProvider } from "@opencode-ai/core/config/provider" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { makeGlobalNode } from "@opencode-ai/core/effect/app-node" import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { Credential } from "@opencode-ai/core/credential" import { ConfigMigrateV1 } from "@opencode-ai/core/v1/config/migrate" import { ConfigV1 } from "@opencode-ai/core/v1/config/config" import { FSUtil } from "@opencode-ai/core/fs-util" @@ -19,6 +21,8 @@ import { Location } from "@opencode-ai/core/location" import { Project } from "@opencode-ai/core/project" import { ProviderV2 } from "@opencode-ai/core/provider" import { AbsolutePath } from "@opencode-ai/core/schema" +import { WellKnown } from "@opencode-ai/core/wellknown" +import { Integration } from "@opencode-ai/schema/integration" import { location } from "../fixture/location" import { tmpdir } from "../fixture/tmpdir" import { testEffect } from "../lib/effect" @@ -26,12 +30,46 @@ import { testEffect } from "../lib/effect" const it = testEffect(Layer.empty) const selection = Schema.decodeUnknownSync(ConfigModel.Selection) +const emptyCredentialNode = makeGlobalNode({ + service: Credential.Service, + layer: Layer.succeed( + Credential.Service, + Credential.Service.of({ + all: () => Effect.succeed([]), + list: () => Effect.succeed([]), + get: () => Effect.succeed(undefined), + create: () => Effect.die("unused Credential.create"), + update: () => Effect.die("unused Credential.update"), + remove: () => Effect.die("unused Credential.remove"), + }), + ), + deps: [], +}) + +const emptyWellknownNode = makeGlobalNode({ + service: WellKnown.Service, + layer: Layer.succeed( + WellKnown.Service, + WellKnown.Service.of({ + entries: () => Effect.succeed([]), + snapshot: () => [], + add: () => Effect.die("unused Wellknown.add"), + remove: () => Effect.die("unused Wellknown.remove"), + changes: Stream.empty, + resolve: () => Effect.die("unused Wellknown.resolve"), + }), + ), + deps: [], +}) + function testLayer( directory: string, globalDirectory = path.join(directory, "global"), projectDirectory = directory, vcs?: Project.Vcs, watcher?: Layer.Layer, + credentialNode = emptyCredentialNode, + wellknownNode = emptyWellknownNode, ) { const locationLayer = Layer.succeed( Location.Service, @@ -45,6 +83,8 @@ function testLayer( return AppNodeBuilder.build(LayerNode.group([Config.node, EventV2.node]), [ [Location.node, locationLayer], [Global.node, Global.layerWith({ config: globalDirectory, home: path.join(globalDirectory, "home") })], + [Credential.node, credentialNode], + [WellKnown.node, wellknownNode], ...(watcher ? ([[Watcher.node, watcher]] as const) : []), ]) } @@ -125,6 +165,86 @@ describe("Config", () => { }), ) + it.live("loads authenticated wellknown config at highest priority", () => + Effect.acquireUseRelease( + Effect.promise(() => tmpdir()), + (tmp) => + Effect.gen(function* () { + const global = path.join(tmp.path, "global") + const project = path.join(tmp.path, "project") + yield* Effect.promise(async () => { + await fs.mkdir(global, { recursive: true }) + await fs.mkdir(project, { recursive: true }) + await fs.writeFile(path.join(global, "opencode.json"), JSON.stringify({ shell: "global" })) + await fs.writeFile(path.join(project, "opencode.json"), JSON.stringify({ shell: "project" })) + }) + + const integrationID = Integration.ID.make("https://example.com") + let key = "secret" + const credentialNode = makeGlobalNode({ + service: Credential.Service, + layer: Layer.succeed( + Credential.Service, + Credential.Service.of({ + all: () => Effect.die("unused Credential.all"), + list: () => + Effect.succeed([ + new Credential.Info({ + id: Credential.ID.create(), + integrationID, + label: "default", + value: Credential.Key.make({ type: "key", key }), + }), + ]), + get: () => Effect.die("unused Credential.get"), + create: () => Effect.die("unused Credential.create"), + update: () => Effect.die("unused Credential.update"), + remove: () => Effect.die("unused Credential.remove"), + }), + ), + deps: [], + }) + const entry: WellKnown.Entry = { + origin: "https://example.com", + integrationID, + manifest: { auth: { command: ["login"], env: "TOKEN" } }, + } + const wellknownNode = makeGlobalNode({ + service: WellKnown.Service, + layer: Layer.succeed( + WellKnown.Service, + WellKnown.Service.of({ + entries: () => Effect.succeed([entry]), + snapshot: () => [entry], + add: () => Effect.die("unused Wellknown.add"), + remove: () => Effect.die("unused Wellknown.remove"), + changes: Stream.empty, + resolve: (_entry, variables) => Effect.succeed([{ shell: variables.TOKEN }]), + }), + ), + deps: [], + }) + + return yield* Effect.gen(function* () { + const config = yield* Config.Service + const events = yield* EventV2.Service + expect(Config.latest(yield* config.entries(), "shell")).toBe("secret") + const updated = yield* events + .subscribe(ConfigSchema.Event.Updated) + .pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped) + yield* Effect.yieldNow + key = "next" + yield* events.publish(Integration.Event.ConnectionUpdated, { integrationID }) + expect(yield* Fiber.join(updated)).toHaveLength(1) + expect(Config.latest(yield* config.entries(), "shell")).toBe("next") + }).pipe( + Effect.provide(testLayer(project, global, project, undefined, undefined, credentialNode, wellknownNode)), + ) + }), + (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), + ), + ) + it.effect("detects v1 configuration from any v1-only top-level key", () => Effect.sync(() => { expect(ConfigMigrateV1.isV1({ snapshot: false })).toBe(true) @@ -282,9 +402,7 @@ describe("Config", () => { const config = yield* Config.Service yield* config.entries() - expect(targets).toEqual([ - { type: "directory", path: AbsolutePath.make(path.join(tmp.path, "global")) }, - ]) + expect(targets).toEqual([{ type: "directory", path: AbsolutePath.make(path.join(tmp.path, "global")) }]) }).pipe(Effect.provide(testLayer(tmp.path, undefined, undefined, undefined, watcher))) }), ), diff --git a/packages/core/test/integration.test.ts b/packages/core/test/integration.test.ts index a591e018a0..42d1ab50d3 100644 --- a/packages/core/test/integration.test.ts +++ b/packages/core/test/integration.test.ts @@ -181,7 +181,7 @@ describe("Integration", () => { command: [ process.execPath, "-e", - 'console.log("https://example.com/login"); await Bun.sleep(50); console.log("secret")', + 'console.error("https://example.com/login"); await Bun.sleep(50); console.log("secret")', ], }, }), @@ -192,7 +192,7 @@ describe("Integration", () => { integrations.command.status({ integrationID, attemptID: attempt.attemptID }), (status) => status.status === "pending" && status.message?.includes("https://example.com/login") === true, ) - expect(pending).toMatchObject({ status: "pending", message: "https://example.com/login" }) + expect(pending).toMatchObject({ status: "pending", message: "https://example.com/login\n" }) expect( yield* eventually( diff --git a/packages/core/test/kv.test.ts b/packages/core/test/kv.test.ts new file mode 100644 index 0000000000..3fd3797dde --- /dev/null +++ b/packages/core/test/kv.test.ts @@ -0,0 +1,25 @@ +import { describe, expect } from "bun:test" +import { Effect } from "effect" +import { KV } from "@opencode-ai/core/kv" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { testEffect } from "./lib/effect" + +const it = testEffect(LayerNode.compile(KV.node)) + +describe("KV", () => { + it.effect("stores, replaces, and removes JSON values", () => + Effect.gen(function* () { + const kv = yield* KV.Service + expect(yield* kv.get("wellknown:sources")).toBeUndefined() + + yield* kv.set("wellknown:sources", ["https://example.com"]) + expect(yield* kv.get("wellknown:sources")).toEqual(["https://example.com"]) + + yield* kv.set("wellknown:sources", ["https://example.com", "https://example.org"]) + expect(yield* kv.get("wellknown:sources")).toEqual(["https://example.com", "https://example.org"]) + + yield* kv.remove("wellknown:sources") + expect(yield* kv.get("wellknown:sources")).toBeUndefined() + }), + ) +}) diff --git a/packages/core/test/wellknown.test.ts b/packages/core/test/wellknown.test.ts new file mode 100644 index 0000000000..57ded34cdf --- /dev/null +++ b/packages/core/test/wellknown.test.ts @@ -0,0 +1,82 @@ +import { expect } from "bun:test" +import { Effect, Fiber, Stream } from "effect" +import { FetchHttpClient } from "effect/unstable/http" +import { KV } from "@opencode-ai/core/kv" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { WellKnown } from "@opencode-ai/core/wellknown" +import { testEffect } from "./lib/effect" + +const it = testEffect(FetchHttpClient.layer) +const serviceIt = testEffect(LayerNode.compile(LayerNode.group([WellKnown.node, KV.node]))) + +it.live("loads embedded and remote configuration", () => + Effect.acquireUseRelease( + Effect.sync(() => + Bun.serve({ + port: 0, + fetch(request) { + const url = new URL(request.url) + if (url.pathname === "/.well-known/opencode") { + return Response.json({ + auth: { command: ["login"], env: "TOKEN" }, + config: { model: "embedded/model" }, + remote_config: { + url: `${url.origin}/config/{env:TOKEN}`, + headers: { authorization: "Bearer {env:TOKEN}" }, + }, + }) + } + if (url.pathname === "/config/secret" && request.headers.get("authorization") === "Bearer secret") { + return Response.json({ config: { model: "remote/model" } }) + } + return new Response("Not found", { status: 404 }) + }, + }), + ), + (server) => + Effect.gen(function* () { + const origin = server.url.origin + expect(yield* WellKnown.inspect(`${origin}/`)).toEqual({ + auth: { command: ["login"], env: "TOKEN" }, + config: { model: "embedded/model" }, + remote_config: { + url: `${origin}/config/{env:TOKEN}`, + headers: { authorization: "Bearer {env:TOKEN}" }, + }, + }) + expect(yield* WellKnown.resolve({ origin, variables: { TOKEN: "secret" } })).toEqual([ + { model: "embedded/model" }, + { model: "remote/model" }, + ]) + }), + (server) => Effect.promise(() => server.stop(true)), + ), +) + +serviceIt.live("persists sources in one KV value", () => + Effect.acquireUseRelease( + Effect.sync(() => + Bun.serve({ + port: 0, + fetch: () => Response.json({ auth: { command: ["login"], env: "TOKEN" } }), + }), + ), + (server) => + Effect.gen(function* () { + const wellknown = yield* WellKnown.Service + const kv = yield* KV.Service + const changed = yield* wellknown.changes.pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped) + const entry = yield* wellknown.add(`${server.url.origin}/`) + + expect(entry.origin).toBe(server.url.origin) + expect(yield* kv.get("wellknown:sources")).toEqual([server.url.origin]) + expect(yield* wellknown.entries()).toEqual([entry]) + expect(yield* Fiber.join(changed)).toHaveLength(1) + + yield* wellknown.remove(server.url.origin) + expect(yield* kv.get("wellknown:sources")).toEqual([]) + expect(yield* wellknown.entries()).toEqual([]) + }), + (server) => Effect.promise(() => server.stop(true)), + ), +) diff --git a/packages/opencode/test/session/message-v2.test.ts b/packages/opencode/test/session/message-v2.test.ts index 9bb688aedd..af38a3d94f 100644 --- a/packages/opencode/test/session/message-v2.test.ts +++ b/packages/opencode/test/session/message-v2.test.ts @@ -1528,7 +1528,7 @@ describe("session.message-v2.fromError", () => { test("classifies ZlibError from fetch as retryable APIError", () => { const zlibError = new Error( - 'ZlibError fetching "https://opencode.cloudflare.dev/anthropic/messages". For more information, pass `verbose: true` in the second argument to fetch()', + 'ZlibError fetching "https://example.com/anthropic/messages". For more information, pass `verbose: true` in the second argument to fetch()', ) ;(zlibError as any).code = "ZlibError" ;(zlibError as any).errno = 0 @@ -1543,7 +1543,7 @@ describe("session.message-v2.fromError", () => { test("classifies ZlibError as AbortedError when abort context is provided", () => { const zlibError = new Error( - 'ZlibError fetching "https://opencode.cloudflare.dev/anthropic/messages". For more information, pass `verbose: true` in the second argument to fetch()', + 'ZlibError fetching "https://example.com/anthropic/messages". For more information, pass `verbose: true` in the second argument to fetch()', ) ;(zlibError as any).code = "ZlibError" ;(zlibError as any).errno = 0 diff --git a/packages/plugin/src/v2/effect/integration.ts b/packages/plugin/src/v2/effect/integration.ts index d34b42d97d..12bb71945e 100644 --- a/packages/plugin/src/v2/effect/integration.ts +++ b/packages/plugin/src/v2/effect/integration.ts @@ -61,7 +61,7 @@ export interface IntegrationDraft { } } -export interface IntegrationDomain extends IntegrationApi { +export interface IntegrationDomain extends Omit, "wellknown"> { readonly transform: Transform readonly reload: () => Effect.Effect readonly connection: { diff --git a/packages/plugin/src/v2/promise/integration.ts b/packages/plugin/src/v2/promise/integration.ts index 76e5a99eb5..e666b83b8a 100644 --- a/packages/plugin/src/v2/promise/integration.ts +++ b/packages/plugin/src/v2/promise/integration.ts @@ -5,7 +5,7 @@ import type { Transform } from "./registration.js" export type { IntegrationDraft, IntegrationMethodRegistration } -export interface IntegrationDomain extends IntegrationApi { +export interface IntegrationDomain extends Omit { readonly transform: Transform readonly reload: () => Promise readonly connection: { diff --git a/packages/protocol/src/groups/integration.ts b/packages/protocol/src/groups/integration.ts index 0584bb0de6..9d88e002da 100644 --- a/packages/protocol/src/groups/integration.ts +++ b/packages/protocol/src/groups/integration.ts @@ -37,6 +37,22 @@ export const IntegrationGroup = HttpApiGroup.make("server.integration") }), ), ) + .add( + HttpApiEndpoint.post("integration.wellknown.add", "/api/experimental/integration/wellknown", { + query: LocationQuery, + payload: Schema.Struct({ url: Schema.String }), + success: HttpApiSchema.NoContent, + error: InvalidRequestError, + }) + .annotateMerge(locationQueryOpenApi) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.experimental.integration.wellknown.add", + summary: "Add wellknown integration", + description: "Discover and persist an experimental wellknown integration source.", + }), + ), + ) .add( HttpApiEndpoint.post("integration.connect.key", "/api/integration/:integrationID/connect/key", { params: { integrationID: Integration.ID }, diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index bf0190f94a..a7c84c3815 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -280,6 +280,8 @@ import type { V2DebugLocationListResponses, V2EventSubscribeErrors, V2EventSubscribeResponses, + V2ExperimentalIntegrationWellknownAddErrors, + V2ExperimentalIntegrationWellknownAddResponses, V2FormRequestListErrors, V2FormRequestListResponses, V2FsFindErrors, @@ -7209,6 +7211,64 @@ export class Integration extends HeyApiClient { } } +export class Wellknown extends HeyApiClient { + /** + * Add wellknown integration + * + * Discover and persist an experimental wellknown integration source. + */ + public add( + parameters?: { + location?: { + directory?: string | null + workspace?: string | null + } | null + url?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "location" }, + { in: "body", key: "url" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post< + V2ExperimentalIntegrationWellknownAddResponses, + V2ExperimentalIntegrationWellknownAddErrors, + ThrowOnError + >({ + url: "/api/experimental/integration/wellknown", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } +} + +export class Integration2 extends HeyApiClient { + private _wellknown?: Wellknown + get wellknown(): Wellknown { + return (this._wellknown ??= new Wellknown({ client: this.client })) + } +} + +export class Experimental2 extends HeyApiClient { + private _integration?: Integration2 + get integration(): Integration2 { + return (this._integration ??= new Integration2({ client: this.client })) + } +} + export class Resource2 extends HeyApiClient { /** * List MCP resources @@ -8500,6 +8560,11 @@ export class V2 extends HeyApiClient { return (this._integration ??= new Integration({ client: this.client })) } + private _experimental?: Experimental2 + get experimental(): Experimental2 { + return (this._experimental ??= new Experimental2({ client: this.client })) + } + private _mcp?: Mcp2 get mcp(): Mcp2 { return (this._mcp ??= new Mcp2({ client: this.client })) diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 6b59c2fe84..b24a95f5d4 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -16859,6 +16859,44 @@ export type V2IntegrationGetResponses = { export type V2IntegrationGetResponse = V2IntegrationGetResponses[keyof V2IntegrationGetResponses] +export type V2ExperimentalIntegrationWellknownAddData = { + body: { + url: string + } + path?: never + query?: { + location?: { + directory?: string | null + workspace?: string | null + } | null + } + url: "/api/experimental/integration/wellknown" +} + +export type V2ExperimentalIntegrationWellknownAddErrors = { + /** + * InvalidRequestError + */ + 400: InvalidRequestError1 | InvalidRequestErrorV2 + /** + * UnauthorizedError + */ + 401: UnauthorizedError +} + +export type V2ExperimentalIntegrationWellknownAddError = + V2ExperimentalIntegrationWellknownAddErrors[keyof V2ExperimentalIntegrationWellknownAddErrors] + +export type V2ExperimentalIntegrationWellknownAddResponses = { + /** + * + */ + 204: void +} + +export type V2ExperimentalIntegrationWellknownAddResponse = + V2ExperimentalIntegrationWellknownAddResponses[keyof V2ExperimentalIntegrationWellknownAddResponses] + export type V2IntegrationConnectKeyData = { body: { key: string diff --git a/packages/server/src/handlers/integration.ts b/packages/server/src/handlers/integration.ts index 90ff7c5cf6..a9cd73fa83 100644 --- a/packages/server/src/handlers/integration.ts +++ b/packages/server/src/handlers/integration.ts @@ -4,6 +4,7 @@ import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi" import { Api } from "../api" import { InvalidRequestError } from "@opencode-ai/protocol/errors" import { response } from "../location" +import { WellKnown } from "@opencode-ai/core/wellknown" const authorize = (effect: Effect.Effect) => effect.pipe( @@ -33,6 +34,22 @@ export const IntegrationHandler = HttpApiBuilder.group(Api, "server.integration" return yield* response(service.get(ctx.params.integrationID)) }), ) + .handle( + "integration.wellknown.add", + Effect.fn(function* (ctx) { + const wellknown = yield* WellKnown.Service + const integration = yield* Integration.Service + yield* wellknown + .add(ctx.payload.url) + .pipe( + Effect.mapError( + (error) => new InvalidRequestError({ message: error.message, kind: "well_known_discovery" }), + ), + ) + yield* integration.reload() + return HttpApiSchema.NoContent.make() + }), + ) .handle( "integration.connect.key", Effect.fn(function* (ctx) { diff --git a/packages/server/src/routes.ts b/packages/server/src/routes.ts index 05e76f0f97..7a1a1d82be 100644 --- a/packages/server/src/routes.ts +++ b/packages/server/src/routes.ts @@ -16,6 +16,7 @@ import { SessionRestart } from "@opencode-ai/core/session/execution/restart" import { PluginRuntime } from "@opencode-ai/core/plugin/runtime" import { SdkPlugins } from "@opencode-ai/core/plugin/sdk" import { ToolOutputStore } from "@opencode-ai/core/tool-output-store" +import { WellKnown } from "@opencode-ai/core/wellknown" import { HttpRouter, HttpServer } from "effect/unstable/http" import { HttpApiBuilder } from "effect/unstable/httpapi" import { Context, Effect, Layer, Option } from "effect" @@ -44,6 +45,7 @@ const applicationServices = LayerNode.group([ PermissionSaved.node, PtyTicket.node, Credential.node, + WellKnown.node, PtyEnvironment.node, LocationServiceMap.node, SessionRestart.node, @@ -85,7 +87,7 @@ function makeRoutes( Layer.flatMap((context) => { const services = Layer.succeedContext(context) const requestServices = Layer.merge( - Layer.succeedContext(Context.pick(PermissionSaved.Service, Project.Service)(context)), + Layer.succeedContext(Context.pick(PermissionSaved.Service, Project.Service, WellKnown.Service)(context)), ServerInfo.layer(serviceURLs), ) return HttpApiBuilder.layer(Api, { openapiPath: "/openapi.json" }).pipe( @@ -109,5 +111,4 @@ function simulateEnabled() { return !!process.env.OPENCODE_SIMULATE } -export const webHandler = () => - HttpRouter.toWebHandler(createRoutes().pipe(Layer.provide(HttpServer.layerServices))) +export const webHandler = () => HttpRouter.toWebHandler(createRoutes().pipe(Layer.provide(HttpServer.layerServices))) diff --git a/packages/tui/src/component/dialog-integration.tsx b/packages/tui/src/component/dialog-integration.tsx index 1b80ddbe02..2cc9c58584 100644 --- a/packages/tui/src/component/dialog-integration.tsx +++ b/packages/tui/src/component/dialog-integration.tsx @@ -185,6 +185,8 @@ function CommandStarting(props: { const dialog = useDialog() const client = useClient() const toast = useToast() + let closed = false + let handedOff = false onMount(() => { void client.api.integration.command @@ -193,7 +195,16 @@ function CommandStarting(props: { methodID: props.method.id, location: location(data), }) - .then((result) => + .then((result) => { + if (closed) { + void client.api.integration.command.cancel({ + integrationID: props.integration.id, + attemptID: result.data.attemptID, + location: location(data), + }) + return + } + handedOff = true dialog.replace(() => ( - )), - ) + )) + }) .catch((cause) => { + if (closed) return toast.show({ variant: "error", message: message(cause) }) dialog.clear() }) }) + onCleanup(() => { + if (!handedOff) closed = true + }) return } @@ -275,9 +290,10 @@ function CommandPending(props: { function CommandView(props: { title: string; output: string; message: string }) { const dialog = useDialog() const { theme } = useTheme() + onMount(() => dialog.setSize("large")) return ( - - + + {props.title} @@ -285,10 +301,12 @@ function CommandView(props: { title: string; output: string; message: string }) esc close - - {props.output} + + {props.output.trim()} + + + {props.message} - {props.message} ) }