From 19f42f7102ec7fd6ec33d593584ced3dd36ef55d Mon Sep 17 00:00:00 2001 From: Simon Klee Date: Thu, 9 Jul 2026 00:00:09 +0200 Subject: [PATCH] feat(v2/cli): add console login (#35969) --- bun.lock | 1 + packages/cli/package.json | 1 + packages/cli/src/commands/commands.ts | 11 + .../src/commands/handlers/console/login.ts | 116 +++++++++ packages/cli/src/index.ts | 3 + packages/cli/src/ui/timeline.tsx | 242 ++++++++++++++++++ packages/core/src/integration.ts | 82 ++++-- packages/core/src/plugin/provider/opencode.ts | 27 +- packages/core/test/integration.test.ts | 62 ++++- .../test/plugin/provider-opencode.test.ts | 67 +++++ 10 files changed, 584 insertions(+), 28 deletions(-) create mode 100644 packages/cli/src/commands/handlers/console/login.ts create mode 100644 packages/cli/src/ui/timeline.tsx diff --git a/bun.lock b/bun.lock index df33000ba53..6e8f024ea2d 100644 --- a/bun.lock +++ b/bun.lock @@ -114,6 +114,7 @@ "effect": "catalog:", "fuzzysort": "catalog:", "jsonc-parser": "3.3.1", + "open": "10.1.2", "opentui-spinner": "catalog:", "semver": "catalog:", "solid-js": "catalog:", diff --git a/packages/cli/package.json b/packages/cli/package.json index e11986e5b2e..d924dd6a3a2 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -46,6 +46,7 @@ "effect": "catalog:", "fuzzysort": "catalog:", "jsonc-parser": "3.3.1", + "open": "10.1.2", "opentui-spinner": "catalog:", "semver": "catalog:", "solid-js": "catalog:", diff --git a/packages/cli/src/commands/commands.ts b/packages/cli/src/commands/commands.ts index a3b49b3d06f..d3a5e0b8136 100644 --- a/packages/cli/src/commands/commands.ts +++ b/packages/cli/src/commands/commands.ts @@ -75,6 +75,17 @@ export const Commands = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCO description: "Debugging and troubleshooting tools", commands: [Spec.make("agents", { description: "List all agents" })], }), + Spec.make("console", { + description: "Manage OpenCode Console access", + commands: [ + Spec.make("login", { + description: "Log in to OpenCode Console", + params: { + url: Argument.string("url").pipe(Argument.withDescription("Console server URL"), Argument.optional), + }, + }), + ], + }), Spec.make("mcp", { description: "Manage MCP (Model Context Protocol) servers", commands: [ diff --git a/packages/cli/src/commands/handlers/console/login.ts b/packages/cli/src/commands/handlers/console/login.ts new file mode 100644 index 00000000000..959263e89db --- /dev/null +++ b/packages/cli/src/commands/handlers/console/login.ts @@ -0,0 +1,116 @@ +import { Cause, Effect, Exit, Option } from "effect" +import { OpenCode, type OpenCodeClient } from "@opencode-ai/client/promise" +import { AppProcess } from "@opencode-ai/core/process" +import { Commands } from "../../commands" +import { Runtime } from "../../../framework/runtime" +import { Daemon } from "../../../daemon" +import { createTimelineHost, type TimelineHost } from "../../../ui/timeline" + +const integrationID = "opencode" +const location = { directory: process.cwd() } + +export default Runtime.handler( + Commands.commands.console.commands.login, + Effect.fn("cli.console.login")(function* (input) { + const timeline = yield* Effect.acquireRelease( + Effect.promise(() => createTimelineHost()), + (value) => request(() => value.close()).pipe(Effect.ignore), + ) + const exit = yield* login(timeline, Option.getOrUndefined(input.url)).pipe( + Effect.raceFirst(AppProcess.waitForAbort(timeline.signal)), + Effect.exit, + ) + if (Exit.isSuccess(exit)) return + + const cancelled = timeline.signal.aborted + yield* request(() => timeline.failure(cancelled ? "Authorization cancelled" : errorMessage(exit.cause))).pipe( + Effect.ignore, + ) + process.exitCode = cancelled ? 130 : 1 + }), +) + +const login = Effect.fn("cli.console.login.run")(function* (timeline: TimelineHost, server?: string) { + yield* request(() => timeline.intro("Log in")) + yield* request(() => timeline.pending("Connecting to OpenCode...")) + + const transport = yield* Daemon.transport({ mode: "shared" }) + const client = OpenCode.make({ baseUrl: transport.url, headers: transport.headers }) + const found = yield* request((signal) => client.integration.get({ integrationID, location }, { signal })) + const integration = yield* required(found.data, "OpenCode Console integration is unavailable") + const method = yield* required( + integration.methods.find((candidate) => candidate.type === "oauth"), + "OpenCode Console login is unavailable", + ) + + yield* request(() => timeline.pending("Starting authorization...")) + const started = yield* request((signal) => + client.integration.connect.oauth( + { + integrationID, + methodID: method.id, + inputs: server ? { server } : {}, + location, + }, + { signal }, + ), + ) + const attempt = started.data + yield* Effect.addFinalizer(() => + request(() => + client.integration.attempt.cancel( + { attemptID: attempt.attemptID, location }, + { signal: AbortSignal.timeout(5_000) }, + ), + ).pipe(Effect.ignore), + ) + if (attempt.mode !== "auto") yield* Effect.fail(new Error("OpenCode Console requires a device login")) + + yield* request(() => timeline.item(`Go to: ${attempt.url}`)) + yield* request(() => timeline.item(attempt.instructions)) + yield* request(async () => { + const { default: open } = await import("open") + await open(attempt.url) + }).pipe(Effect.ignore) + yield* request(() => timeline.pending("Waiting for authorization...")) + + const status = yield* waitForConsoleLogin(client, attempt.attemptID) + if (status.status === "failed") yield* Effect.fail(new Error(status.message)) + if (status.status === "expired") yield* Effect.fail(new Error("Device code expired")) + + yield* request(() => timeline.success("Connected to OpenCode Console")) + yield* request(() => timeline.outro("Done")) +}) + +const waitForConsoleLogin = Effect.fn("cli.console.login.wait")(function* ( + client: OpenCodeClient, + attemptID: string, +) { + while (true) { + const response = yield* request((signal) => + client.integration.attempt.status({ attemptID, location }, { signal }), + ) + if (response.data.status !== "pending") return response.data + yield* Effect.sleep(500) + } +}) + +function request(task: (signal: AbortSignal) => Promise) { + return Effect.tryPromise({ + try: task, + catch: (cause) => cause, + }) +} + +function required(value: A | null | undefined, message: string) { + return value === null || value === undefined ? Effect.fail(new Error(message)) : Effect.succeed(value) +} + +function errorMessage(cause: Cause.Cause) { + const error = Cause.squash(cause) + if (error instanceof Error) return error.message + if (typeof error === "object" && error !== null && "message" in error && typeof error.message === "string") { + return error.message + } + return String(error) +} diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 5b851d7f497..d6d758c95de 100755 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -24,6 +24,9 @@ const Handlers = Runtime.handlers(Commands, { debug: { agents: () => import("./commands/handlers/debug/agents"), }, + console: { + login: () => import("./commands/handlers/console/login"), + }, mcp: { list: () => import("./commands/handlers/mcp/list"), add: () => import("./commands/handlers/mcp/add"), diff --git a/packages/cli/src/ui/timeline.tsx b/packages/cli/src/ui/timeline.tsx new file mode 100644 index 00000000000..8c7e6612c67 --- /dev/null +++ b/packages/cli/src/ui/timeline.tsx @@ -0,0 +1,242 @@ +/** @jsxImportSource @opentui/solid */ +import { createCliRenderer, RGBA, type CliRenderer, type ColorInput, type ScrollbackWriter } from "@opentui/core" +import { createScrollbackWriter, render, useKeyboard } from "@opentui/solid" +import { registerOpencodeSpinner } from "@opencode-ai/tui/component/register-spinner" +import { Show, createSignal } from "solid-js" + +registerOpencodeSpinner() + +export type TimelineHost = { + readonly signal: AbortSignal + intro(text: string): Promise + item(text: string): Promise + pending(text: string): Promise + success(text: string): Promise + failure(text: string): Promise + outro(text: string): Promise + close(): Promise +} + +type RowKind = "intro" | "item" | "success" | "failure" | "outro" + +const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"] +const IDLE_TIMEOUT = 1_000 +const COLORS = { + accent: RGBA.fromIndex(6), + error: RGBA.fromIndex(1), + foreground: RGBA.defaultForeground(), + muted: RGBA.fromIndex(8), + success: RGBA.fromIndex(2), +} +const ROWS: Record = { + intro: { marker: "┌", color: COLORS.muted, connector: true }, + item: { marker: "●", color: COLORS.accent, connector: true }, + success: { marker: "◇", color: COLORS.success, connector: true }, + failure: { marker: "■", color: COLORS.error, connector: false }, + outro: { marker: "└", color: COLORS.muted, connector: false }, +} + +function row(kind: RowKind, value: string): ScrollbackWriter { + const style = ROWS[kind] + return createScrollbackWriter( + () => ( + + + + {style.marker} + + + {value} + + + + + + + ), + { startOnNewLine: true, trailingNewline: !style.connector }, + ) +} + +function TimelineFooter(props: { pending: () => string | undefined; cancel: () => void }) { + useKeyboard((event) => { + if (event.name !== "escape" && !(event.ctrl && event.name === "c")) return + event.preventDefault() + props.cancel() + }) + + return ( + + + {(text) => ( + <> + + + {text()} + + + )} + + + ) +} + +function bounded(task: Promise) { + return new Promise((resolve) => { + const timer = setTimeout(resolve, IDLE_TIMEOUT) + timer.unref() + const finish = () => { + clearTimeout(timer) + resolve() + } + void task.then(finish, finish) + }) +} + +async function shutdown(renderer: CliRenderer): Promise { + await bounded(renderer.idle()) + try { + renderer.externalOutputMode = "passthrough" + } finally { + try { + renderer.screenMode = "main-screen" + } finally { + if (!renderer.isDestroyed) renderer.destroy() + } + } +} + +export async function createTimelineHost(): Promise { + const stdout = process.stdout + const controller = new AbortController() + const signals: NodeJS.Signals[] = ["SIGINT", "SIGHUP", "SIGQUIT"] + const cancel = () => { + if (!controller.signal.aborted) controller.abort() + } + signals.forEach((signal) => process.on(signal, cancel)) + + if (!stdout.isTTY || !process.stdin.isTTY) { + let closed = false + let writing = false + let active: Promise | undefined + let closeTask: Promise | undefined + const write = async (kind: RowKind | "pending", text: string) => { + if (closed) throw new Error("timeline closed") + if (writing) throw new Error("timeline write already in progress") + writing = true + try { + const style = kind === "pending" ? undefined : ROWS[kind] + const marker = kind === "pending" ? "." : ROWS[kind].marker + const connector = style?.connector ? "│\n" : "" + active = new Promise((resolve, reject) => { + stdout.write(`${marker} ${text}\n${connector}`, (error) => (error ? reject(error) : resolve())) + }) + await active + } finally { + writing = false + active = undefined + } + } + const close = () => { + if (closeTask) return closeTask + closed = true + closeTask = (async () => { + await active?.catch(() => { }) + signals.forEach((signal) => process.off(signal, cancel)) + })() + return closeTask + } + return { + signal: controller.signal, + intro: (text) => write("intro", text), + item: (text) => write("item", text), + pending: (text) => write("pending", text), + success: (text) => write("success", text), + failure: (text) => write("failure", text), + outro: (text) => write("outro", text), + close, + } + } + + let renderer: CliRenderer | undefined + + try { + // Start on a fresh row so delayed SSH cursor reports cannot make + // split-footer overwrite the shell command. + process.stdout.write("\n") + renderer = await createCliRenderer({ + stdin: process.stdin, + useMouse: false, + autoFocus: false, + openConsoleOnError: false, + exitOnCtrlC: false, + exitSignals: [], + screenMode: "split-footer", + footerHeight: 1, + externalOutputMode: "capture-stdout", + consoleMode: "disabled", + clearOnShutdown: false, + }) + const activeRenderer = renderer + const [pending, setPending] = createSignal() + const renderTask = render(() => , activeRenderer) + void renderTask.catch(cancel) + await bounded(activeRenderer.idle()) + + let closed = false + let writing = false + let active: Promise | undefined + let closeTask: Promise | undefined + const write = (kind: RowKind | "pending", text: string) => { + if (closed) return Promise.reject(new Error("timeline closed")) + if (writing) return Promise.reject(new Error("timeline write already in progress")) + writing = true + active = (async () => { + if (kind === "pending") { + setPending(text) + activeRenderer.requestRender() + } else { + if (kind === "success" || kind === "failure" || kind === "outro") setPending(undefined) + activeRenderer.writeToScrollback(row(kind, text)) + activeRenderer.requestRender() + } + await bounded(activeRenderer.idle()) + })().finally(() => { + writing = false + active = undefined + }) + return active + } + const close = () => { + if (closeTask) return closeTask + closed = true + closeTask = (async () => { + await active?.catch(() => { }) + try { + await shutdown(activeRenderer) + await bounded(renderTask) + } finally { + signals.forEach((signal) => process.off(signal, cancel)) + } + })() + return closeTask + } + return { + signal: controller.signal, + intro: (text) => write("intro", text), + item: (text) => write("item", text), + pending: (text) => write("pending", text), + success: (text) => write("success", text), + failure: (text) => write("failure", text), + outro: (text) => write("outro", text), + close, + } + } catch (error) { + try { + if (renderer) await shutdown(renderer) + } finally { + signals.forEach((signal) => process.off(signal, cancel)) + } + throw error + } +} diff --git a/packages/core/src/integration.ts b/packages/core/src/integration.ts index 12dad07dd34..c4f7c3b8f2d 100644 --- a/packages/core/src/integration.ts +++ b/packages/core/src/integration.ts @@ -203,6 +203,7 @@ type AttemptTime = { created: number; expires: number } type PendingAttempt = { status: "pending" completing: boolean + persisting: boolean authorization: OAuthAuthorization integrationID: ID methodID: MethodID @@ -320,27 +321,61 @@ const layer = Layer.effect( } const settle = Effect.fnUntraced(function* (attemptID: AttemptID, exit: Exit.Exit) { - const now = yield* Clock.currentTimeMillis - const result = yield* SynchronizedRef.modify(attempts, (current) => { - const attempt = current.get(attemptID) - if (!attempt || attempt.status !== "pending") return [undefined, current] - const terminal: TerminalAttempt = Exit.isSuccess(exit) - ? { status: "complete", time: attempt.time, removeAt: now + terminalRetention } - : { status: "failed", message: message(exit.cause), time: attempt.time, removeAt: now + terminalRetention } - return [attempt, new Map(current).set(attemptID, terminal)] - }) - if (!result) return - if (Exit.isSuccess(exit)) { - const implementation = state.get().integrations.get(result.integrationID)?.implementations.get(result.methodID) - yield* credentials.create({ - integrationID: result.integrationID, - label: result.label ?? implementation?.label?.(exit.value), - value: exit.value, - }) - yield* events.publish(Event.ConnectionUpdated, { integrationID: result.integrationID }) - yield* events.publish(Event.Updated, {}) - } - yield* close(result.scope) + return yield* Effect.uninterruptible( + Effect.gen(function* () { + const now = yield* Clock.currentTimeMillis + const attempt = yield* SynchronizedRef.modify(attempts, (current) => { + const match = current.get(attemptID) + if (!match || match.status !== "pending" || match.persisting) return [undefined, current] + const next = Exit.isSuccess(exit) + ? { ...match, persisting: true } + : { + status: "failed" as const, + message: message(exit.cause), + time: match.time, + removeAt: now + terminalRetention, + } + return [match, new Map(current).set(attemptID, next)] + }) + if (!attempt) return + if (Exit.isFailure(exit)) { + yield* close(attempt.scope) + return + } + + yield* Effect.gen(function* () { + const implementation = state + .get() + .integrations.get(attempt.integrationID) + ?.implementations.get(attempt.methodID) + const persistence = yield* Effect.sync(() => attempt.label ?? implementation?.label?.(exit.value)).pipe( + Effect.flatMap((label) => + credentials.create({ + integrationID: attempt.integrationID, + label, + value: exit.value, + }), + ), + Effect.asVoid, + Effect.exit, + ) + const settledAt = yield* Clock.currentTimeMillis + const terminal: TerminalAttempt = Exit.isSuccess(persistence) + ? { status: "complete", time: attempt.time, removeAt: settledAt + terminalRetention } + : { + status: "failed", + message: message(persistence.cause), + time: attempt.time, + removeAt: settledAt + terminalRetention, + } + // Persisting attempts cannot be cancelled, expired, or claimed again. + yield* SynchronizedRef.update(attempts, (current) => new Map(current).set(attemptID, terminal)) + if (Exit.isFailure(persistence)) yield* Effect.failCause(persistence.cause) + yield* events.publish(Event.ConnectionUpdated, { integrationID: attempt.integrationID }) + yield* events.publish(Event.Updated, {}) + }).pipe(Effect.ensuring(close(attempt.scope))) + }), + ) }) const scrub = Effect.fnUntraced(function* () { @@ -349,7 +384,7 @@ const layer = Layer.effect( const next = new Map(current) const scopes: Scope.Closeable[] = [] for (const [id, attempt] of current) { - if (attempt.status === "pending" && attempt.time.expires <= now) { + if (attempt.status === "pending" && !attempt.persisting && attempt.time.expires <= now) { scopes.push(attempt.scope) next.set(id, { status: "expired", time: attempt.time, removeAt: now + terminalRetention }) continue @@ -432,6 +467,7 @@ const layer = Layer.effect( new Map(current).set(id, { status: "pending", completing: authorization.mode === "auto", + persisting: false, authorization, integrationID: input.integrationID, methodID: input.methodID, @@ -506,7 +542,7 @@ const layer = Layer.effect( cancel: Effect.fn("Integration.attempt.cancel")(function* (attemptID) { const attempt = yield* SynchronizedRef.modify(attempts, (current) => { const match = current.get(attemptID) - if (!match || match.status !== "pending") return [undefined, current] + if (!match || match.status !== "pending" || match.persisting) return [undefined, current] const next = new Map(current) next.delete(attemptID) return [match, next] diff --git a/packages/core/src/plugin/provider/opencode.ts b/packages/core/src/plugin/provider/opencode.ts index cca6a2dfa73..6092903b41a 100644 --- a/packages/core/src/plugin/provider/opencode.ts +++ b/packages/core/src/plugin/provider/opencode.ts @@ -43,14 +43,21 @@ function oauth(http: HttpClient.HttpClient) { type: "oauth", label: "OpenCode Console account", }, - authorize: () => + authorize: (inputs) => Effect.gen(function* () { - const device = yield* post(http, `${defaultServer}/auth/device/code`, { client_id: clientID }, Device) + const server = yield* normalizeServer(inputs.server ?? defaultServer) + const device = yield* post(http, `${server}/auth/device/code`, { client_id: clientID }, Device) + const verification = URL.canParse(device.verification_uri_complete) + ? new URL(device.verification_uri_complete) + : undefined + if (verification && verification.protocol !== "http:" && verification.protocol !== "https:") { + return yield* Effect.fail(new Error("Invalid device verification URL: expected HTTP(S)")) + } return { mode: "auto" as const, - url: `${defaultServer}${device.verification_uri_complete}`, + url: verification?.href ?? `${server}/${device.verification_uri_complete.replace(/^\/+/, "")}`, instructions: `Enter code: ${device.user_code}`, - callback: poll(http, defaultServer, device.device_code, Duration.seconds(device.interval)), + callback: poll(http, server, device.device_code, Duration.seconds(device.interval)), } }), refresh: (credential) => @@ -219,6 +226,18 @@ function withoutCredentials(body: Readonly> | undefined) return Object.fromEntries(Object.entries(body ?? {}).filter(([key]) => key !== "apiKey" && key !== "headers")) } +function normalizeServer(input: string) { + return Effect.try({ + try: () => { + const url = new URL(input) + if (url.protocol !== "http:" && url.protocol !== "https:") throw new Error("expected HTTP(S)") + return `${url.origin}${url.pathname.replace(/\/+$/, "")}` + }, + catch: (cause) => + new Error(`Invalid OpenCode server URL: ${cause instanceof Error ? cause.message : String(cause)}`), + }) +} + function remoteCost(input: NonNullable<(typeof ConfigProviderV1.Model.Type)["cost"]>) { const base = { input: Money.USDPerMillionTokens.make(input.input), diff --git a/packages/core/test/integration.test.ts b/packages/core/test/integration.test.ts index 376d2f9523b..dc20303f001 100644 --- a/packages/core/test/integration.test.ts +++ b/packages/core/test/integration.test.ts @@ -1,14 +1,33 @@ import { describe, expect } from "bun:test" -import { Duration, Effect, Exit, Fiber, Scope, Stream } from "effect" +import { Cause, Duration, Effect, Exit, Fiber, Layer, Scope, Stream } from "effect" import * as TestClock from "effect/testing/TestClock" import { Credential } from "@opencode-ai/core/credential" 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 { EventV2 } from "@opencode-ai/core/event" import { Integration } from "@opencode-ai/core/integration" import { testEffect } from "./lib/effect" const it = testEffect(AppNodeBuilder.build(LayerNode.group([Integration.node, Credential.node, EventV2.node]))) +const failingCredentialNode = 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(new Error("credential persistence failed")), + update: () => Effect.void, + remove: () => Effect.void, + }), + ), + deps: [], +}) +const failingIt = testEffect( + AppNodeBuilder.build(LayerNode.group([Integration.node, EventV2.node]), [[Credential.node, failingCredentialNode]]), +) describe("Integration", () => { it.effect("registers integrations through the editor", () => @@ -254,6 +273,47 @@ describe("Integration", () => { }), ) + failingIt.effect("fails the attempt when credential persistence fails", () => + Effect.gen(function* () { + const integrations = yield* Integration.Service + const integrationID = Integration.ID.make("openai") + const methodID = Integration.MethodID.make("chatgpt") + yield* integrations.transform((editor) => + editor.method.update({ + integrationID, + method: { id: methodID, type: "oauth", label: "ChatGPT" }, + authorize: () => + Effect.succeed({ + mode: "code" as const, + url: "https://example.com/authorize", + instructions: "Paste the code", + callback: () => + Effect.succeed( + Credential.OAuth.make({ + type: "oauth", + methodID, + access: "access", + refresh: "refresh", + expires: 1, + }), + ), + }), + }), + ) + + const attempt = yield* integrations.connection.oauth({ integrationID, methodID, inputs: {} }) + const exit = yield* integrations.attempt + .complete({ attemptID: attempt.attemptID, code: "1234" }) + .pipe(Effect.exit) + expect(Exit.isFailure(exit) && Cause.hasDies(exit.cause)).toBe(true) + expect(yield* integrations.attempt.status(attempt.attemptID)).toEqual({ + status: "failed", + message: "credential persistence failed", + time: attempt.time, + }) + }), + ) + it.effect("expires abandoned OAuth attempts", () => Effect.gen(function* () { const integrations = yield* Integration.Service diff --git a/packages/core/test/plugin/provider-opencode.test.ts b/packages/core/test/plugin/provider-opencode.test.ts index f8334026f90..0b3e30cd319 100644 --- a/packages/core/test/plugin/provider-opencode.test.ts +++ b/packages/core/test/plugin/provider-opencode.test.ts @@ -92,6 +92,73 @@ describe("OpencodePlugin", () => { }), ) + it.live("uses a canonical custom server throughout device authorization", () => + Effect.acquireUseRelease( + Effect.sync(() => { + const requests: string[] = [] + const server = Bun.serve({ + port: 0, + fetch: (request) => { + const url = new URL(request.url) + requests.push(`${request.method} ${url.pathname}`) + if (url.pathname.endsWith("/auth/device/code")) { + return Response.json({ + device_code: "device", + user_code: "user", + verification_uri_complete: `${url.origin}/verify`, + expires_in: 60, + interval: 0, + }) + } + if (url.pathname.endsWith("/auth/device/token")) { + return Response.json({ access_token: "access", refresh_token: "refresh", expires_in: 600 }) + } + if (url.pathname.endsWith("/api/user")) return Response.json({ id: "user", email: "user@example.com" }) + if (url.pathname.endsWith("/api/orgs")) return Response.json([{ id: "org", name: "Org" }]) + return new Response("Not found", { status: 404 }) + }, + }) + return { requests, server } + }), + ({ requests, server }) => + Effect.gen(function* () { + yield* addPlugin() + const integrations = yield* Integration.Service + const attempt = yield* integrations.connection.oauth({ + integrationID: Integration.ID.make("opencode"), + methodID: Integration.MethodID.make("device"), + inputs: { server: `${server.url.origin}/console///?ignored=true#ignored` }, + }) + expect(attempt.url).toBe(`${server.url.origin}/verify`) + yield* eventually(integrations.attempt.status(attempt.attemptID), (status) => status.status === "complete") + + expect(requests).toContain("POST /console/auth/device/code") + expect(requests).toContain("POST /console/auth/device/token") + expect(requests).toContain("GET /console/api/user") + expect(requests).toContain("GET /console/api/orgs") + expect((yield* (yield* Credential.Service).list(Integration.ID.make("opencode")))[0]?.value).toMatchObject({ + metadata: { server: `${server.url.origin}/console` }, + }) + }), + ({ server }) => Effect.promise(() => server.stop(true)), + ), + ) + + it.effect("rejects non-HTTP OpenCode servers", () => + Effect.gen(function* () { + yield* addPlugin() + const error = yield* (yield* Integration.Service).connection + .oauth({ + integrationID: Integration.ID.make("opencode"), + methodID: Integration.MethodID.make("device"), + inputs: { server: "ftp://console.example.com" }, + }) + .pipe(Effect.flip) + expect(error).toBeInstanceOf(Integration.AuthorizationError) + expect(String(error.cause)).toContain("Invalid OpenCode server URL: expected HTTP(S)") + }), + ) + it.live("loads providers and models from the connected OpenCode server", () => Effect.acquireUseRelease( Effect.sync(() => {