mirror of
https://github.com/anomalyco/opencode.git
synced 2026-07-21 08:23:32 +00:00
feat(v2/cli): add console login (#35969)
This commit is contained in:
parent
212c9f99ee
commit
19f42f7102
10 changed files with 584 additions and 28 deletions
1
bun.lock
1
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:",
|
||||
|
|
|
|||
|
|
@ -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:",
|
||||
|
|
|
|||
|
|
@ -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: [
|
||||
|
|
|
|||
116
packages/cli/src/commands/handlers/console/login.ts
Normal file
116
packages/cli/src/commands/handlers/console/login.ts
Normal file
|
|
@ -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<A>(task: (signal: AbortSignal) => Promise<A>) {
|
||||
return Effect.tryPromise({
|
||||
try: task,
|
||||
catch: (cause) => cause,
|
||||
})
|
||||
}
|
||||
|
||||
function required<A>(value: A | null | undefined, message: string) {
|
||||
return value === null || value === undefined ? Effect.fail(new Error(message)) : Effect.succeed(value)
|
||||
}
|
||||
|
||||
function errorMessage(cause: Cause.Cause<unknown>) {
|
||||
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)
|
||||
}
|
||||
|
|
@ -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"),
|
||||
|
|
|
|||
242
packages/cli/src/ui/timeline.tsx
Normal file
242
packages/cli/src/ui/timeline.tsx
Normal file
|
|
@ -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<void>
|
||||
item(text: string): Promise<void>
|
||||
pending(text: string): Promise<void>
|
||||
success(text: string): Promise<void>
|
||||
failure(text: string): Promise<void>
|
||||
outro(text: string): Promise<void>
|
||||
close(): Promise<void>
|
||||
}
|
||||
|
||||
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<RowKind, { marker: string; color: ColorInput; connector: boolean }> = {
|
||||
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(
|
||||
() => (
|
||||
<box width="100%" minHeight={1} flexDirection="column">
|
||||
<box width="100%" minHeight={1} flexDirection="row" gap={1}>
|
||||
<text fg={style.color} flexShrink={0}>
|
||||
{style.marker}
|
||||
</text>
|
||||
<text fg={COLORS.foreground} wrapMode="word">
|
||||
{value}
|
||||
</text>
|
||||
</box>
|
||||
<Show when={style.connector}>
|
||||
<text fg={COLORS.muted}>│</text>
|
||||
</Show>
|
||||
</box>
|
||||
),
|
||||
{ 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 (
|
||||
<box width="100%" height={1} flexDirection="row" gap={1}>
|
||||
<Show when={props.pending()}>
|
||||
{(text) => (
|
||||
<>
|
||||
<spinner frames={SPINNER_FRAMES} interval={80} color={COLORS.accent} />
|
||||
<text fg={COLORS.foreground} wrapMode="none" truncate>
|
||||
{text()}
|
||||
</text>
|
||||
</>
|
||||
)}
|
||||
</Show>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
||||
function bounded(task: Promise<unknown>) {
|
||||
return new Promise<void>((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<void> {
|
||||
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<TimelineHost> {
|
||||
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<void> | undefined
|
||||
let closeTask: Promise<void> | 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<void>((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<string>()
|
||||
const renderTask = render(() => <TimelineFooter pending={pending} cancel={cancel} />, activeRenderer)
|
||||
void renderTask.catch(cancel)
|
||||
await bounded(activeRenderer.idle())
|
||||
|
||||
let closed = false
|
||||
let writing = false
|
||||
let active: Promise<void> | undefined
|
||||
let closeTask: Promise<void> | 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
|
||||
}
|
||||
}
|
||||
|
|
@ -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<Credential.OAuth, unknown>) {
|
||||
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]
|
||||
|
|
|
|||
|
|
@ -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<Record<string, unknown>> | 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),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(() => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue