mirror of
https://github.com/anomalyco/opencode.git
synced 2026-11-14 12:10:45 +03:00
feat(app): sign in to OpenCode Go and Console through the browser (#50267)
This commit is contained in:
committed by
GitHub
Unverified
parent
9c18abce47
commit
fbacf6a126
@@ -5,6 +5,7 @@ import { useDialog } from "@opencode/ui/context/dialog"
|
||||
import { getCursorPosition, setCursorPosition } from "./editor/dom"
|
||||
import { useSessionLayout } from "@/session/session-layout"
|
||||
import { createSessionOwnership } from "@/session/session-ownership"
|
||||
import { useWorkspaceLocation } from "@/workspaces/location"
|
||||
|
||||
const withCategory = (category: string) => {
|
||||
return (option: Omit<CommandOption, "category">): CommandOption => ({
|
||||
@@ -18,11 +19,19 @@ export const useComposerCommands = (input: { model?: ModelSelection } = {}) => {
|
||||
const dialog = useDialog()
|
||||
const language = useLanguage()
|
||||
const local = useLocal()
|
||||
const workspace = useWorkspaceLocation()
|
||||
const { sessionKey } = useSessionLayout()
|
||||
const sessionOwnership = createSessionOwnership(sessionKey)
|
||||
const model = input.model ?? local.model
|
||||
const modelCommand = withCategory(language.t("command.category.model"))
|
||||
const agentCommand = withCategory(language.t("command.category.agent"))
|
||||
const providerCommand = withCategory(language.t("command.category.provider"))
|
||||
|
||||
// Mirrors the TUI's `/connect`, which the Console's setup steps tell people to run.
|
||||
const connectProvider = async () => {
|
||||
const { DialogConnectProvider } = await import("@/providers/connect/dialog")
|
||||
void dialog.show(() => <DialogConnectProvider directory={workspace().directory} />)
|
||||
}
|
||||
|
||||
const chooseModel = async () => {
|
||||
const owner = sessionOwnership.capture()
|
||||
@@ -62,6 +71,13 @@ export const useComposerCommands = (input: { model?: ModelSelection } = {}) => {
|
||||
keybind: "shift+mod+d",
|
||||
onSelect: () => model.variant.cycle(),
|
||||
}),
|
||||
providerCommand({
|
||||
id: "provider.connect",
|
||||
title: language.t("command.provider.connect"),
|
||||
description: language.t("command.provider.connect.description"),
|
||||
slash: "connect",
|
||||
onSelect: connectProvider,
|
||||
}),
|
||||
agentCommand({
|
||||
id: "agent.cycle",
|
||||
title: language.t("command.agent.cycle"),
|
||||
|
||||
@@ -1,18 +1,44 @@
|
||||
import type { FormAnswer, IntegrationMethod, IntegrationOauthConnectOutput } from "@opencode/client/promise"
|
||||
import type {
|
||||
FormAnswer,
|
||||
IntegrationInfo,
|
||||
IntegrationMethod,
|
||||
IntegrationOauthConnectOutput,
|
||||
} from "@opencode/client/promise"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { usePlatform } from "@/runtime/platform/platform"
|
||||
import { useServerSDK } from "@/runtime/server/client"
|
||||
import { useData } from "@/runtime/server/current"
|
||||
import { createEffect, createMemo, createResource, onCleanup } from "solid-js"
|
||||
import { createEffect, createMemo, on, onCleanup } from "solid-js"
|
||||
import { createStore, produce } from "solid-js/store"
|
||||
|
||||
export type ProviderConnectMethod = Extract<IntegrationMethod, { type: "key" | "oauth" }>
|
||||
type Authorization = IntegrationOauthConnectOutput["data"]
|
||||
|
||||
// OpenCode Go and OpenCode Zen both bill through the OpenCode Console, so the
|
||||
// Console sign-in is the connection method for both providers.
|
||||
export const CONSOLE_INTEGRATION = "opencode"
|
||||
export const CONSOLE_PROVIDERS = new Set(["opencode", "opencode-go"])
|
||||
|
||||
export function consoleIntegration(provider: string) {
|
||||
return CONSOLE_PROVIDERS.has(provider) ? CONSOLE_INTEGRATION : provider
|
||||
}
|
||||
|
||||
function hiddenDefaults(method: ProviderConnectMethod | undefined) {
|
||||
return Object.fromEntries(
|
||||
(method?.form ?? []).flatMap((field) =>
|
||||
field.type !== "external" && field.hidden && field.default !== undefined ? [[field.key, field.default]] : [],
|
||||
),
|
||||
) as FormAnswer
|
||||
}
|
||||
|
||||
export function createProviderConnectionController(options: {
|
||||
provider: () => string
|
||||
/** Integration that stores API keys when it differs from the one that lists methods and runs OAuth. */
|
||||
keyProvider?: () => string
|
||||
directory: () => string | undefined
|
||||
onComplete: () => void
|
||||
/** Picks the method to start without asking when the integration exposes several. */
|
||||
autoSelect?: (methods: ProviderConnectMethod[]) => number | undefined
|
||||
pollInterval?: number
|
||||
}) {
|
||||
const language = useLanguage()
|
||||
@@ -23,12 +49,28 @@ export function createProviderConnectionController(options: {
|
||||
const directory = options.directory()
|
||||
return directory ? { directory } : undefined
|
||||
}
|
||||
const [integration] = createResource(
|
||||
() => ({ provider: options.provider(), directory: options.directory() }),
|
||||
(input) =>
|
||||
serverSDK.api.integration
|
||||
.get({ integrationID: input.provider, location: location() })
|
||||
.then((result) => result.data),
|
||||
// Not createResource: the dialog is owned by whichever page opened it, so reading a pending
|
||||
// resource here would suspend that page's <Suspense> and blank the screen behind the dialog.
|
||||
const [integration, setIntegration] = createStore({
|
||||
loading: true,
|
||||
latest: undefined as IntegrationInfo | undefined,
|
||||
})
|
||||
createEffect(
|
||||
on(
|
||||
() => ({ provider: options.provider(), directory: options.directory() }),
|
||||
(input) => {
|
||||
setIntegration({ loading: true, latest: undefined })
|
||||
serverSDK.api.integration
|
||||
.get({ integrationID: input.provider, location: location() })
|
||||
.then((result) => result.data)
|
||||
.catch(() => undefined)
|
||||
.then((latest) => {
|
||||
if (polling.disposed) return
|
||||
if (input.provider !== options.provider() || input.directory !== options.directory()) return
|
||||
setIntegration({ loading: false, latest })
|
||||
})
|
||||
},
|
||||
),
|
||||
)
|
||||
const methods = createMemo<ProviderConnectMethod[]>(() => {
|
||||
const values = integration.latest?.methods.filter(
|
||||
@@ -41,17 +83,28 @@ export function createProviderConnectionController(options: {
|
||||
methodIndex: undefined as number | undefined,
|
||||
authorization: undefined as Authorization | undefined,
|
||||
formAnswer: undefined as FormAnswer | undefined,
|
||||
state: "pending" as "pending" | "complete" | "error" | "form" | undefined,
|
||||
// Nothing is in flight until a method is selected; `busy()` reads this, so a truthy initial
|
||||
// value would keep multi-method providers on the spinner instead of the method list.
|
||||
state: undefined as "pending" | "complete" | "error" | "form" | undefined,
|
||||
error: undefined as string | undefined,
|
||||
auto: false,
|
||||
})
|
||||
const polling = {
|
||||
generation: 0,
|
||||
timer: undefined as ReturnType<typeof setTimeout> | undefined,
|
||||
disposed: false,
|
||||
// An attempt the server still considers open; cancelled when the dialog goes away.
|
||||
attempt: undefined as Authorization | undefined,
|
||||
}
|
||||
const currentMethod = createMemo(() =>
|
||||
store.methodIndex === undefined ? undefined : methods().at(store.methodIndex),
|
||||
)
|
||||
const autoIndex = createMemo(() => {
|
||||
if (integration.loading) return undefined
|
||||
const values = methods()
|
||||
if (values.length === 1) return 0
|
||||
return options.autoSelect?.(values)
|
||||
})
|
||||
|
||||
type Action =
|
||||
| { type: "method.select"; index: number }
|
||||
@@ -109,6 +162,14 @@ export function createProviderConnectionController(options: {
|
||||
)
|
||||
}
|
||||
|
||||
const cancelAttempt = () => {
|
||||
const attempt = polling.attempt
|
||||
polling.attempt = undefined
|
||||
if (!attempt) return
|
||||
void serverSDK.api.integration.oauth
|
||||
.cancel({ integrationID: options.provider(), attemptID: attempt.attemptID, location: location() })
|
||||
.catch(() => undefined)
|
||||
}
|
||||
const cancelPolling = () => {
|
||||
polling.generation++
|
||||
if (polling.timer === undefined) return
|
||||
@@ -117,6 +178,7 @@ export function createProviderConnectionController(options: {
|
||||
}
|
||||
const finish = async () => {
|
||||
cancelPolling()
|
||||
polling.attempt = undefined
|
||||
const ref = location()
|
||||
data.location.integration.invalidate(ref)
|
||||
data.location.provider.invalidate(ref)
|
||||
@@ -140,6 +202,7 @@ export function createProviderConnectionController(options: {
|
||||
.catch((error) => ({ ok: false as const, error }))
|
||||
if (polling.disposed || generation !== polling.generation) return
|
||||
if (!result.ok) {
|
||||
polling.attempt = undefined
|
||||
dispatch({
|
||||
type: "auth.error",
|
||||
error: result.error instanceof Error ? result.error.message : String(result.error),
|
||||
@@ -151,26 +214,35 @@ export function createProviderConnectionController(options: {
|
||||
return
|
||||
}
|
||||
if (result.status.status === "failed") {
|
||||
polling.attempt = undefined
|
||||
dispatch({ type: "auth.error", error: result.status.message })
|
||||
return
|
||||
}
|
||||
if (result.status.status === "expired") {
|
||||
dispatch({ type: "auth.error", error: language.t("common.requestFailed") })
|
||||
polling.attempt = undefined
|
||||
dispatch({ type: "auth.error", error: language.t("provider.connect.oauth.expired") })
|
||||
return
|
||||
}
|
||||
polling.timer = setTimeout(() => void poll(authorization, generation), options.pollInterval ?? 1_000)
|
||||
}
|
||||
const open = () => {
|
||||
const url = store.authorization?.url
|
||||
if (url) platform.openExternal(url)
|
||||
}
|
||||
const select = async (index: number, answer?: FormAnswer) => {
|
||||
cancelPolling()
|
||||
cancelAttempt()
|
||||
const generation = polling.generation
|
||||
const selected = methods()[index]
|
||||
dispatch({ type: "method.select", index })
|
||||
if (selected.form?.length && !answer) {
|
||||
const visible = (selected.form ?? []).some((field) => field.type === "external" || !field.hidden)
|
||||
if (visible && !answer) {
|
||||
dispatch({ type: "auth.form" })
|
||||
return
|
||||
}
|
||||
const merged = { ...hiddenDefaults(selected), ...answer }
|
||||
if (selected.type === "key") {
|
||||
dispatch({ type: "auth.answer", answer })
|
||||
dispatch({ type: "auth.answer", answer: Object.keys(merged).length ? merged : undefined })
|
||||
return
|
||||
}
|
||||
if (selected.type !== "oauth") return
|
||||
@@ -183,11 +255,11 @@ export function createProviderConnectionController(options: {
|
||||
.connect({
|
||||
integrationID: options.provider(),
|
||||
methodID: selected.id,
|
||||
...(answer ? { answer } : {}),
|
||||
...(Object.keys(merged).length ? { answer: merged } : {}),
|
||||
location: location(),
|
||||
})
|
||||
.then((response) => {
|
||||
if (options.provider() === "opencode" && platform.platform === "desktop") {
|
||||
if (options.provider() === CONSOLE_INTEGRATION && platform.platform === "desktop") {
|
||||
const url = new URL(response.data.url)
|
||||
url.searchParams.set("client_id", "opencode-desktop")
|
||||
response.data.url = url.href
|
||||
@@ -195,21 +267,44 @@ export function createProviderConnectionController(options: {
|
||||
return { ok: true as const, authorization: response.data }
|
||||
})
|
||||
.catch((error) => ({ ok: false as const, error }))
|
||||
if (polling.disposed || generation !== polling.generation) return
|
||||
if (!result.ok) {
|
||||
dispatch({ type: "auth.error", error: String(result.error) })
|
||||
if (polling.disposed || generation !== polling.generation) {
|
||||
if (result.ok)
|
||||
void serverSDK.api.integration.oauth
|
||||
.cancel({
|
||||
integrationID: options.provider(),
|
||||
attemptID: result.authorization.attemptID,
|
||||
location: location(),
|
||||
})
|
||||
.catch(() => undefined)
|
||||
return
|
||||
}
|
||||
if (!result.ok) {
|
||||
dispatch({
|
||||
type: "auth.error",
|
||||
error: result.error instanceof Error ? result.error.message : String(result.error),
|
||||
})
|
||||
return
|
||||
}
|
||||
polling.attempt = result.authorization
|
||||
dispatch({ type: "auth.complete", authorization: result.authorization })
|
||||
// Same as `opencode auth login`: hand the user straight to the browser instead of
|
||||
// asking them to click a link and retype a code.
|
||||
platform.openExternal(result.authorization.url)
|
||||
if (result.authorization.mode === "auto") void poll(result.authorization, generation)
|
||||
}
|
||||
const retry = () => {
|
||||
const index = store.methodIndex
|
||||
if (index === undefined) return
|
||||
void select(index, store.formAnswer)
|
||||
}
|
||||
const reset = () => {
|
||||
cancelPolling()
|
||||
cancelAttempt()
|
||||
dispatch({ type: "method.reset" })
|
||||
}
|
||||
const connectKey = async (key: string) => {
|
||||
await serverSDK.api.integration.connect.key({
|
||||
integrationID: options.provider(),
|
||||
integrationID: options.keyProvider?.() ?? options.provider(),
|
||||
location: location(),
|
||||
key,
|
||||
...(store.formAnswer ? { answer: store.formAnswer } : {}),
|
||||
@@ -236,15 +331,16 @@ export function createProviderConnectionController(options: {
|
||||
return undefined
|
||||
}
|
||||
|
||||
let auto = false
|
||||
createEffect(() => {
|
||||
if (auto || integration.loading || methods().length !== 1) return
|
||||
auto = true
|
||||
void select(0)
|
||||
const index = autoIndex()
|
||||
if (store.auto || index === undefined) return
|
||||
setStore("auto", true)
|
||||
void select(index)
|
||||
})
|
||||
onCleanup(() => {
|
||||
polling.disposed = true
|
||||
cancelPolling()
|
||||
cancelAttempt()
|
||||
})
|
||||
|
||||
return {
|
||||
@@ -254,11 +350,19 @@ export function createProviderConnectionController(options: {
|
||||
currentMethod,
|
||||
methodIndex: () => store.methodIndex,
|
||||
authorization: () => store.authorization,
|
||||
// True while nothing useful can be shown yet: the integration is loading, a method is
|
||||
// about to be picked automatically, or the authorization request is in flight.
|
||||
busy: () =>
|
||||
integration.loading ||
|
||||
(store.methodIndex === undefined && !store.auto && autoIndex() !== undefined) ||
|
||||
store.state === "pending",
|
||||
auth: {
|
||||
state: () => store.state,
|
||||
error: () => store.error,
|
||||
select,
|
||||
reset,
|
||||
retry,
|
||||
open,
|
||||
connectKey,
|
||||
completeCode,
|
||||
},
|
||||
|
||||
@@ -8,16 +8,23 @@ import { TextField } from "@opencode/ui/text-field"
|
||||
import { DialogBody, DialogHeader, DialogTitle, Dialog } from "@opencode/ui/dialog"
|
||||
import { TextInput } from "@opencode/ui/text-input"
|
||||
import { showToast } from "@/shell/notifications/toast"
|
||||
import { type Component, createMemo, createUniqueId, For, Match, onMount, Show, Switch } from "solid-js"
|
||||
import { type Component, createMemo, createUniqueId, For, type JSX, Match, onMount, Show, Switch } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { useParams } from "@solidjs/router"
|
||||
import { ExternalLink } from "@/runtime/platform/external-link"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { useData } from "@/runtime/server/current"
|
||||
import { useGlobal } from "@/runtime/server/runtime"
|
||||
import { useProviders } from "@/providers/catalog/providers"
|
||||
import { useIntegrations } from "@/providers/catalog/integrations"
|
||||
import { CustomProviderForm } from "@/providers/credentials/dialog"
|
||||
import { decode64 } from "@/runtime/persistence/base64"
|
||||
import { createProviderConnectionController, type ProviderConnectMethod } from "./controller"
|
||||
import {
|
||||
CONSOLE_PROVIDERS,
|
||||
consoleIntegration,
|
||||
createProviderConnectionController,
|
||||
type ProviderConnectMethod,
|
||||
} from "./controller"
|
||||
|
||||
const CUSTOM_ID = "_custom"
|
||||
type IntegrationForm = NonNullable<ProviderConnectMethod["form"]>[number]
|
||||
@@ -258,13 +265,42 @@ function ProviderConnection(props: {
|
||||
const dialog = useDialog()
|
||||
const params = useParams()
|
||||
const language = useLanguage()
|
||||
const data = useData()
|
||||
const global = useGlobal()
|
||||
const providers = useProviders(() => props.directory)
|
||||
const integrations = useIntegrations(() => props.directory)
|
||||
const directory = () => props.directory ?? decode64(params.dir)
|
||||
const location = () => {
|
||||
const value = directory()
|
||||
return value ? { directory: value } : undefined
|
||||
}
|
||||
const integrationID = () => consoleIntegration(props.provider)
|
||||
const isConsole = () => CONSOLE_PROVIDERS.has(props.provider)
|
||||
|
||||
const controller = createProviderConnectionController({
|
||||
provider: () => props.provider,
|
||||
provider: integrationID,
|
||||
// A Go service-account key still belongs to the `opencode-go` integration (zen/go/v1),
|
||||
// exactly as before; only the sign-in is shared with the Console.
|
||||
keyProvider: () => props.provider,
|
||||
directory,
|
||||
autoSelect: (methods) => {
|
||||
if (!isConsole()) return undefined
|
||||
const index = methods.findIndex((method) => method.type === "oauth")
|
||||
return index === -1 ? undefined : index
|
||||
},
|
||||
onComplete: () => {
|
||||
// The picker only lists the newest model per family by default, which hides most of
|
||||
// what a new connection just unlocked. Show everything the connected integration offers.
|
||||
// Console sign-in returns Go models inside the `opencode` provider, so this covers Go too.
|
||||
const linked = (data.location.provider.list(location()) ?? []).filter(
|
||||
(item) => item.id === props.provider || item.integrationID === integrationID(),
|
||||
)
|
||||
const ids = new Set(linked.map((item) => item.id))
|
||||
global.models.show(
|
||||
(data.location.model.list(location()) ?? [])
|
||||
.filter((model) => ids.has(model.providerID) && model.status !== "deprecated")
|
||||
.map((model) => ({ providerID: model.providerID, modelID: model.id })),
|
||||
)
|
||||
dialog.close()
|
||||
showToast({
|
||||
variant: "success",
|
||||
@@ -276,7 +312,11 @@ function ProviderConnection(props: {
|
||||
})
|
||||
const provider = createMemo(() => ({
|
||||
id: props.provider,
|
||||
name: providers.all().get(props.provider)?.name ?? controller.integration()?.name ?? props.provider,
|
||||
name:
|
||||
integrations.list().find((item) => item.id === props.provider)?.name ??
|
||||
providers.all().get(props.provider)?.name ??
|
||||
controller.integration()?.name ??
|
||||
props.provider,
|
||||
}))
|
||||
const methodLabel = (value?: { type?: string; label?: string }) => {
|
||||
if (!value) return ""
|
||||
@@ -298,6 +338,13 @@ function ProviderConnection(props: {
|
||||
: undefined,
|
||||
}
|
||||
}
|
||||
const code = createMemo(() => {
|
||||
const instructions = controller.authorization()?.instructions
|
||||
if (instructions?.includes(":")) return instructions.split(":").pop()?.trim()
|
||||
return instructions
|
||||
})
|
||||
const keyIndex = () => controller.methods().findIndex((method) => method.type === "key")
|
||||
const oauthIndex = () => controller.methods().findIndex((method) => method.type === "oauth")
|
||||
|
||||
function AuthFormView() {
|
||||
const [formStore, setFormStore] = createStore({
|
||||
@@ -307,7 +354,7 @@ function ProviderConnection(props: {
|
||||
|
||||
const fields = createMemo<StringForm[]>(() => {
|
||||
const value = controller.currentMethod()
|
||||
return (value?.form ?? []).flatMap((field) => (field.type === "string" ? [field] : []))
|
||||
return (value?.form ?? []).flatMap((field) => (field.type === "string" && !field.hidden ? [field] : []))
|
||||
})
|
||||
const matches = (field: StringForm, value: Record<string, string>) => {
|
||||
return (field.when ?? []).every((condition) => {
|
||||
@@ -364,7 +411,7 @@ function ProviderConnection(props: {
|
||||
})
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} class="flex flex-col items-start gap-4">
|
||||
<form onSubmit={handleSubmit} class="flex flex-col items-start gap-4 px-3">
|
||||
<Switch>
|
||||
<Match when={item()?.field.options === undefined}>
|
||||
<TextField
|
||||
@@ -422,7 +469,13 @@ function ProviderConnection(props: {
|
||||
}
|
||||
|
||||
function goBack() {
|
||||
if (controller.methods().length > 1 && controller.methodIndex() !== undefined) {
|
||||
// The API key path for the Console is an escape hatch below the sign-in flow, so
|
||||
// "back" returns to the sign-in rather than leaving the provider.
|
||||
if (isConsole() && controller.currentMethod()?.type === "key" && oauthIndex() !== -1) {
|
||||
void controller.auth.select(oauthIndex())
|
||||
return
|
||||
}
|
||||
if (!isConsole() && controller.methods().length > 1 && controller.methodIndex() !== undefined) {
|
||||
controller.auth.reset()
|
||||
return
|
||||
}
|
||||
@@ -463,6 +516,31 @@ function ProviderConnection(props: {
|
||||
)
|
||||
}
|
||||
|
||||
function StatusRow(input: { children: JSX.Element }) {
|
||||
return (
|
||||
<div class="flex items-center gap-2 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-muted">
|
||||
<Spinner class="size-4 shrink-0 text-v2-icon-icon-muted" />
|
||||
<span>{input.children}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ErrorRow() {
|
||||
return (
|
||||
<div class="flex flex-col items-start gap-3">
|
||||
<div class="flex items-start gap-2 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-base">
|
||||
<Icon name="circle-ban-sign" size="small" class="mt-0.5 shrink-0 text-v2-state-fg-danger" />
|
||||
<span role="alert">
|
||||
{language.t("provider.connect.status.failed", { error: controller.auth.error() ?? "" })}
|
||||
</span>
|
||||
</div>
|
||||
<Button variant="neutral" onClick={() => controller.auth.retry()}>
|
||||
{language.t("common.retry")}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ApiAuthView() {
|
||||
let apiKey: HTMLInputElement | undefined
|
||||
const errorID = createUniqueId()
|
||||
@@ -494,22 +572,17 @@ function ProviderConnection(props: {
|
||||
return (
|
||||
<div class="flex flex-col gap-5 px-3 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-muted">
|
||||
<Show
|
||||
when={provider().id === "opencode"}
|
||||
when={isConsole()}
|
||||
fallback={language.t("provider.connect.apiKey.description", { provider: provider().name })}
|
||||
>
|
||||
<div class="flex flex-col gap-5">
|
||||
<div>{language.t("provider.connect.opencodeZen.line1")}</div>
|
||||
<div>{language.t("provider.connect.opencodeZen.line2")}</div>
|
||||
<div>
|
||||
{language.t("provider.connect.opencodeZen.visit.prefix")}
|
||||
<ExternalLink
|
||||
href="https://opencode.ai/zen"
|
||||
class="text-v2-text-text-base focus-visible:rounded-xs focus-visible:outline-2 focus-visible:outline-v2-border-border-focus"
|
||||
>
|
||||
{language.t("provider.connect.opencodeZen.visit.link")}
|
||||
</ExternalLink>
|
||||
{language.t("provider.connect.opencodeZen.visit.suffix")}
|
||||
</div>
|
||||
<div>
|
||||
{language.t("provider.connect.console.apiKey.description")}{" "}
|
||||
<ExternalLink
|
||||
href="https://opencode.ai/console"
|
||||
class="text-v2-text-text-base focus-visible:rounded-xs focus-visible:outline-2 focus-visible:outline-v2-border-border-focus"
|
||||
>
|
||||
{language.t("provider.connect.console.apiKey.link")}
|
||||
</ExternalLink>
|
||||
</div>
|
||||
</Show>
|
||||
<form onSubmit={handleSubmit} class="flex flex-col items-start gap-5 self-stretch">
|
||||
@@ -574,13 +647,10 @@ function ProviderConnection(props: {
|
||||
|
||||
return (
|
||||
<div class="flex flex-col gap-5 px-3 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-muted">
|
||||
<div>
|
||||
{language.t("provider.connect.oauth.code.visit.prefix")}
|
||||
<ExternalLink href={controller.authorization()!.url} class="text-v2-text-text-base">
|
||||
{language.t("provider.connect.oauth.code.visit.link")}
|
||||
</ExternalLink>
|
||||
{language.t("provider.connect.oauth.code.visit.suffix", { provider: provider().name })}
|
||||
</div>
|
||||
<div>{language.t("provider.connect.oauth.code.description", { provider: provider().name })}</div>
|
||||
<Button variant="neutral" icon="arrow-up-right" onClick={() => controller.auth.open()}>
|
||||
{language.t("provider.connect.oauth.openBrowser")}
|
||||
</Button>
|
||||
<form onSubmit={handleSubmit} class="flex flex-col items-start gap-5 self-stretch">
|
||||
<label class="flex w-full flex-col gap-1 font-[530] leading-4 text-v2-text-text-base">
|
||||
{language.t("provider.connect.oauth.code.label", { method: controller.currentMethod()?.label ?? "" })}
|
||||
@@ -613,34 +683,71 @@ function ProviderConnection(props: {
|
||||
}
|
||||
|
||||
function OAuthAutoView() {
|
||||
const code = createMemo(() => {
|
||||
const instructions = controller.authorization()?.instructions
|
||||
if (instructions?.includes(":")) {
|
||||
return instructions.split(":").pop()?.trim()
|
||||
}
|
||||
return instructions
|
||||
})
|
||||
|
||||
return (
|
||||
<div class="flex flex-col gap-6">
|
||||
<div class="text-14-regular text-text-base">
|
||||
{language.t("provider.connect.oauth.auto.visit.prefix")}
|
||||
<ExternalLink href={controller.authorization()!.url}>
|
||||
{language.t("provider.connect.oauth.auto.visit.link")}
|
||||
</ExternalLink>
|
||||
{language.t("provider.connect.oauth.auto.visit.suffix", { provider: provider().name })}
|
||||
<div class="flex flex-col gap-5 px-3 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-muted">
|
||||
<div>{language.t("provider.connect.oauth.auto.description", { provider: provider().name })}</div>
|
||||
<StatusRow>{language.t("provider.connect.status.waiting")}</StatusRow>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<Button variant="neutral" icon="arrow-up-right" onClick={() => controller.auth.open()}>
|
||||
{language.t("provider.connect.oauth.openBrowser")}
|
||||
</Button>
|
||||
</div>
|
||||
<Show when={code()}>
|
||||
{(value) => (
|
||||
<TextField
|
||||
label={language.t("provider.connect.oauth.auto.confirmationCode")}
|
||||
description={language.t("provider.connect.oauth.auto.confirmationCode.description")}
|
||||
class="font-mono"
|
||||
value={value()}
|
||||
readOnly
|
||||
copyable
|
||||
/>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Rendered from the first frame of the Console sign-in, before the attempt exists. Only the
|
||||
// status line changes once the browser opens, so nothing around it moves.
|
||||
function ConsoleSignInView() {
|
||||
const ready = () => controller.authorization()?.url !== undefined
|
||||
return (
|
||||
<div class="flex flex-col gap-5 px-3 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-muted">
|
||||
<div>{language.t("provider.connect.console.description", { provider: provider().name })}</div>
|
||||
<StatusRow>
|
||||
{language.t(ready() ? "provider.connect.status.waiting" : "provider.connect.console.opening")}
|
||||
</StatusRow>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<Button variant="neutral" icon="arrow-up-right" disabled={!ready()} onClick={() => controller.auth.open()}>
|
||||
{language.t("provider.connect.oauth.openBrowser")}
|
||||
</Button>
|
||||
</div>
|
||||
<TextField
|
||||
label={language.t("provider.connect.oauth.auto.confirmationCode")}
|
||||
description={language.t("provider.connect.oauth.auto.confirmationCode.description")}
|
||||
class="font-mono"
|
||||
value={code()}
|
||||
value={code() ?? ""}
|
||||
placeholder={language.t("provider.connect.console.code.placeholder")}
|
||||
readOnly
|
||||
copyable
|
||||
copyable={code() !== undefined}
|
||||
/>
|
||||
<div class="text-14-regular text-text-base flex items-center gap-4">
|
||||
<Spinner />
|
||||
<span>{language.t("provider.connect.status.waiting")}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Deliberately quiet: most people should never need a key, so this stays small and at the bottom.
|
||||
function ConsoleApiKeySwitch() {
|
||||
return (
|
||||
<div class="mt-auto flex justify-end px-3 pt-4 text-[11px] leading-4 text-v2-text-text-muted">
|
||||
<button
|
||||
type="button"
|
||||
data-action="provider-connect-api-key"
|
||||
class="rounded-xs px-1 underline decoration-v2-border-border-base underline-offset-2 hover:text-v2-text-text-base focus-visible:outline-2 focus-visible:outline-v2-border-border-focus"
|
||||
onClick={() => void controller.auth.select(keyIndex())}
|
||||
>
|
||||
{language.t("provider.connect.console.apiKey.switch")}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -661,53 +768,48 @@ function ProviderConnection(props: {
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex min-h-0 flex-1 flex-col">
|
||||
<div>
|
||||
<Switch>
|
||||
<Match when={controller.loading()}>
|
||||
<div class="text-14-regular text-text-base">
|
||||
<div class="flex items-center gap-x-2">
|
||||
<Spinner />
|
||||
<span>{language.t("provider.connect.status.inProgress")}</span>
|
||||
</div>
|
||||
</div>
|
||||
</Match>
|
||||
<Match when={controller.methodIndex() === undefined}>
|
||||
<MethodSelection />
|
||||
</Match>
|
||||
<Match when={controller.auth.state() === "pending"}>
|
||||
<div class="text-14-regular text-text-base">
|
||||
<div class="flex items-center gap-x-2">
|
||||
<Spinner />
|
||||
<span>{language.t("provider.connect.status.inProgress")}</span>
|
||||
</div>
|
||||
</div>
|
||||
</Match>
|
||||
<Match when={controller.auth.state() === "form"}>
|
||||
<AuthFormView />
|
||||
</Match>
|
||||
<Match when={controller.auth.state() === "error"}>
|
||||
<div class="text-14-regular text-text-base">
|
||||
<div class="flex items-center gap-x-2">
|
||||
<Icon name="circle-ban-sign" class="text-icon-critical-base" />
|
||||
<span>{language.t("provider.connect.status.failed", { error: controller.auth.error() ?? "" })}</span>
|
||||
</div>
|
||||
</div>
|
||||
</Match>
|
||||
<Match when={controller.currentMethod()?.type === "key"}>
|
||||
<ApiAuthView />
|
||||
</Match>
|
||||
<Match when={controller.currentMethod()?.type === "oauth"}>
|
||||
<Switch>
|
||||
<Match when={controller.authorization()?.mode === "code"}>
|
||||
<OAuthCodeView />
|
||||
</Match>
|
||||
<Match when={controller.authorization()?.mode === "auto"}>
|
||||
<OAuthAutoView />
|
||||
</Match>
|
||||
</Switch>
|
||||
</Match>
|
||||
</Switch>
|
||||
</div>
|
||||
<Switch>
|
||||
<Match
|
||||
when={
|
||||
isConsole() &&
|
||||
controller.currentMethod()?.type !== "key" &&
|
||||
controller.auth.state() !== "error" &&
|
||||
(controller.busy() || controller.authorization()?.mode === "auto")
|
||||
}
|
||||
>
|
||||
<ConsoleSignInView />
|
||||
</Match>
|
||||
<Match when={controller.busy()}>
|
||||
<div class="px-3">
|
||||
<StatusRow>{language.t("provider.connect.status.inProgress")}</StatusRow>
|
||||
</div>
|
||||
</Match>
|
||||
<Match when={controller.methodIndex() === undefined}>
|
||||
<MethodSelection />
|
||||
</Match>
|
||||
<Match when={controller.auth.state() === "form"}>
|
||||
<AuthFormView />
|
||||
</Match>
|
||||
<Match when={controller.auth.state() === "error"}>
|
||||
<div class="px-3">
|
||||
<ErrorRow />
|
||||
</div>
|
||||
</Match>
|
||||
<Match when={controller.currentMethod()?.type === "key"}>
|
||||
<ApiAuthView />
|
||||
</Match>
|
||||
<Match when={controller.authorization()?.mode === "code"}>
|
||||
<OAuthCodeView />
|
||||
</Match>
|
||||
<Match when={controller.authorization()?.mode === "auto"}>
|
||||
<OAuthAutoView />
|
||||
</Match>
|
||||
</Switch>
|
||||
<Show
|
||||
when={isConsole() && !controller.loading() && controller.currentMethod()?.type !== "key" && keyIndex() !== -1}
|
||||
>
|
||||
<ConsoleApiKeySwitch />
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -87,6 +87,7 @@ export const dict = {
|
||||
"command.project.index": "Switch to project {{index}}",
|
||||
"command.project.copyID": "Copy Project ID",
|
||||
"command.provider.connect": "Connect provider",
|
||||
"command.provider.connect.description": "Sign in to OpenCode Go, OpenCode Console, or another model provider",
|
||||
"command.server.switch": "Switch server",
|
||||
"command.settings.open": "Open settings",
|
||||
"command.session.previous": "Previous session",
|
||||
@@ -227,6 +228,22 @@ export const dict = {
|
||||
"provider.connect.oauth.auto.visit.suffix":
|
||||
" and enter the code below to connect your account and use {{provider}} models in OpenCode.",
|
||||
"provider.connect.oauth.auto.confirmationCode": "Confirmation code",
|
||||
"provider.connect.oauth.auto.confirmationCode.description":
|
||||
"Check that your browser shows the same code before you authorize.",
|
||||
"provider.connect.oauth.auto.description":
|
||||
"Your browser opens so you can sign in to {{provider}}. Come back here when you are done.",
|
||||
"provider.connect.oauth.code.description":
|
||||
"Your browser opens so you can sign in to {{provider}}. Paste the authorization code it gives you below.",
|
||||
"provider.connect.oauth.openBrowser": "Open browser",
|
||||
"provider.connect.oauth.expired": "Authorization expired",
|
||||
"provider.connect.console.description":
|
||||
"Sign in with your OpenCode Console account. Your browser opens to the Console, where you pick a workspace and select Authorize.",
|
||||
"provider.connect.console.opening": "Opening your browser…",
|
||||
"provider.connect.console.code.placeholder": "Requesting a code…",
|
||||
"provider.connect.console.apiKey.switch": "Connect with an API key or service account instead",
|
||||
"provider.connect.console.apiKey.description":
|
||||
"Paste an API key for a service account. You create service accounts in the OpenCode Console under Keys.",
|
||||
"provider.connect.console.apiKey.link": "Open the Console",
|
||||
"provider.connect.toast.connected.title": "{{provider}} connected",
|
||||
"provider.connect.toast.connected.description": "{{provider}} models are now available to use.",
|
||||
|
||||
@@ -304,6 +321,7 @@ export const dict = {
|
||||
"common.connect": "Connect",
|
||||
"common.disconnect": "Disconnect",
|
||||
"common.continue": "Continue",
|
||||
"common.retry": "Try again",
|
||||
"common.submit": "Submit",
|
||||
"common.save": "Save",
|
||||
"common.saving": "Saving…",
|
||||
@@ -1369,6 +1387,7 @@ export const dict = {
|
||||
"settings.providers.tag.environment": "Environment",
|
||||
"settings.providers.tag.config": "Config",
|
||||
"settings.providers.tag.custom": "Custom",
|
||||
"settings.providers.tag.account": "Account",
|
||||
"settings.providers.tag.other": "Other",
|
||||
"settings.models.title": "Models",
|
||||
"settings.models.description": "Choose which models appear in model picker",
|
||||
|
||||
@@ -51,6 +51,7 @@ export function normalizeProviderList(
|
||||
for (const provider of providers) {
|
||||
all.set(provider.id, {
|
||||
id: provider.id,
|
||||
integrationID: provider.integrationID,
|
||||
name: provider.name,
|
||||
source: "custom",
|
||||
env: [],
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createSimpleContext } from "@opencode/ui/context"
|
||||
import { Accessor, createEffect, createMemo, createResource, createRoot, getOwner } from "solid-js"
|
||||
import { Accessor, batch, createEffect, createMemo, createResource, createRoot, getOwner } from "solid-js"
|
||||
import { createServerProjects, RECENTLY_CLOSED_DISPLAY_LIMIT, ServerConnection, useServers } from "./registry"
|
||||
import { pathKey } from "@/workspaces/path-key"
|
||||
import { useServerHealth } from "@/runtime/server/health"
|
||||
@@ -98,6 +98,25 @@ function createGlobalModels() {
|
||||
set: setStore,
|
||||
ready,
|
||||
recent: () => recent()!,
|
||||
// Marks models visible in the picker regardless of the "latest per family" default.
|
||||
show(models: ReadonlyArray<{ providerID: string; modelID: string }>) {
|
||||
const seen = new Map(store.user.map((item, index) => [`${item.providerID}:${item.modelID}`, index]))
|
||||
batch(() => {
|
||||
for (const model of models) {
|
||||
const index = seen.get(`${model.providerID}:${model.modelID}`)
|
||||
if (index !== undefined) {
|
||||
setStore("user", index, "visibility", "show")
|
||||
continue
|
||||
}
|
||||
seen.set(`${model.providerID}:${model.modelID}`, store.user.length)
|
||||
setStore("user", store.user.length, {
|
||||
providerID: model.providerID,
|
||||
modelID: model.modelID,
|
||||
visibility: "show",
|
||||
})
|
||||
}
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -129,6 +129,8 @@ export type Model = {
|
||||
|
||||
export type Provider = {
|
||||
id: string
|
||||
/** Integration that connects this provider; differs from `id` for Console-managed providers. */
|
||||
integrationID?: string
|
||||
name: string
|
||||
source: "env" | "config" | "custom" | "api"
|
||||
env: string[]
|
||||
|
||||
@@ -12,7 +12,7 @@ import { DialogConnectProvider, useProviderConnectController } from "@/providers
|
||||
import { SettingsList } from "@/settings/list"
|
||||
import "@/settings/settings.css"
|
||||
|
||||
type ProviderSource = "env" | "api" | "config" | "custom"
|
||||
type ProviderSource = "env" | "api" | "account" | "config" | "custom"
|
||||
type ProviderItem = ReturnType<ReturnType<typeof useProviders>["connected"]>[number]
|
||||
|
||||
const PROVIDER_NOTES = [
|
||||
@@ -38,7 +38,12 @@ export const SettingsProviders: Component<{
|
||||
const providers = useProviders(() => props.directory)
|
||||
const integrations = useIntegrations(() => props.directory)
|
||||
const providerConnect = useProviderConnectController({ onBack: props.onBack })
|
||||
const integration = (providerID: string) => integrations.list().find((item) => item.id === providerID)
|
||||
// Console-managed providers (`opencode-go`, `console-*`) connect through the `opencode`
|
||||
// integration, so the lookup must follow `integrationID` rather than the provider id.
|
||||
const integration = (item: ProviderItem) => {
|
||||
const id = item.integrationID ?? item.id
|
||||
return integrations.list().find((entry) => entry.id === id)
|
||||
}
|
||||
|
||||
const connect = (provider?: string) => {
|
||||
providerConnect.select(provider)
|
||||
@@ -69,8 +74,9 @@ export const SettingsProviders: Component<{
|
||||
// connections mean an API key or OAuth grant, env connections mean detected
|
||||
// environment variables, and a connectionless integration is config-provided.
|
||||
const source = (item: ProviderItem): ProviderSource | undefined => {
|
||||
const current = integration(item.id)
|
||||
if (current?.connections.some((connection) => connection.type === "credential")) return "api"
|
||||
const current = integration(item)
|
||||
const credential = current?.connections.find((connection) => connection.type === "credential")
|
||||
if (credential) return credential.method === "oauth" ? "account" : "api"
|
||||
if (current?.connections.some((connection) => connection.type === "env")) return "env"
|
||||
if (current) return "config"
|
||||
if (!("source" in item)) return
|
||||
@@ -83,13 +89,14 @@ export const SettingsProviders: Component<{
|
||||
const current = source(item)
|
||||
if (current === "env") return language.t("settings.providers.tag.environment")
|
||||
if (current === "api") return language.t("provider.connect.method.apiKey")
|
||||
if (current === "account") return language.t("settings.providers.tag.account")
|
||||
if (current === "config") return language.t("settings.providers.tag.config")
|
||||
if (current === "custom") return language.t("settings.providers.tag.custom")
|
||||
return language.t("settings.providers.tag.other")
|
||||
}
|
||||
|
||||
const canDisconnect = (item: ProviderItem) => {
|
||||
const current = integration(item.id)
|
||||
const current = integration(item)
|
||||
if (current) return current.connections.some((connection) => connection.type === "credential")
|
||||
const currentSource = source(item)
|
||||
return currentSource !== "env" && currentSource !== "config"
|
||||
@@ -97,10 +104,11 @@ export const SettingsProviders: Component<{
|
||||
|
||||
const note = (id: string) => PROVIDER_NOTES.find((item) => item.match(id))?.key
|
||||
|
||||
const disconnect = async (providerID: string, name: string) => {
|
||||
const disconnect = async (item: ProviderItem) => {
|
||||
const name = item.name
|
||||
const location = props.directory ? { directory: props.directory } : undefined
|
||||
await serverSdk.api.integration
|
||||
.get({ integrationID: providerID, location })
|
||||
.get({ integrationID: item.integrationID ?? item.id, location })
|
||||
.then(async (integration) => {
|
||||
const credentials = integration.data?.connections.filter((item) => item.type === "credential") ?? []
|
||||
if (credentials.length === 0) throw new Error(`No removable credentials found for ${name}`)
|
||||
@@ -162,7 +170,7 @@ export const SettingsProviders: Component<{
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<Button size="normal" variant="ghost-muted" onClick={() => void disconnect(item.id, item.name)}>
|
||||
<Button size="normal" variant="ghost-muted" onClick={() => void disconnect(item)}>
|
||||
{language.t("common.disconnect")}
|
||||
</Button>
|
||||
</Show>
|
||||
|
||||
@@ -49,7 +49,7 @@ describe("auth command", () => {
|
||||
name: "Anthropic",
|
||||
methods: [],
|
||||
connections: [
|
||||
{ type: "credential", id: "cred_test", label: "default" },
|
||||
{ type: "credential", method: "key", id: "cred_test", label: "default" },
|
||||
{ type: "env", name: "ANTHROPIC_API_KEY" },
|
||||
],
|
||||
},
|
||||
@@ -67,7 +67,7 @@ describe("auth command", () => {
|
||||
id: "anthropic",
|
||||
name: "Anthropic",
|
||||
connections: [
|
||||
{ type: "credential", id: "cred_test", label: "default" },
|
||||
{ type: "credential", method: "key", id: "cred_test", label: "default" },
|
||||
{ type: "env", name: "ANTHROPIC_API_KEY" },
|
||||
],
|
||||
},
|
||||
@@ -267,7 +267,7 @@ describe("auth command", () => {
|
||||
id: "anthropic",
|
||||
name: "Anthropic",
|
||||
methods: [{ type: "key" }],
|
||||
connections: [{ type: "credential", id: "cred_test", label: "default" }],
|
||||
connections: [{ type: "credential", method: "key", id: "cred_test", label: "default" }],
|
||||
},
|
||||
]),
|
||||
)
|
||||
|
||||
@@ -230,7 +230,7 @@ export type IntegrationCommandMethod = { id: string; type: "command"; label: str
|
||||
|
||||
export type IntegrationEnvMethod = { type: "env"; names: Array<string> }
|
||||
|
||||
export type ConnectionCredentialInfo = { type: "credential"; id: string; label: string }
|
||||
export type ConnectionCredentialInfo = { type: "credential"; id: string; label: string; method: "key" | "oauth" }
|
||||
|
||||
export type ConnectionEnvInfo = { type: "env"; name: string }
|
||||
|
||||
|
||||
@@ -361,6 +361,7 @@ const layer = Layer.effect(
|
||||
type: "credential" as const,
|
||||
id: credential.id,
|
||||
label: credential.label,
|
||||
method: credential.value.type,
|
||||
}))
|
||||
.toReversed()
|
||||
const env = (entry?.methods ?? [])
|
||||
@@ -731,8 +732,7 @@ const layer = Layer.effect(
|
||||
connect: connectOAuth,
|
||||
status: Effect.fn("Integration.oauth.status")(function* (input) {
|
||||
const attempt = (yield* SynchronizedRef.get(attempts)).get(input.attemptID)
|
||||
if (!attempt || attempt.integrationID !== input.integrationID)
|
||||
return yield* new AttemptNotFoundError(input)
|
||||
if (!attempt || attempt.integrationID !== input.integrationID) return yield* new AttemptNotFoundError(input)
|
||||
if (attempt.status === "failed") {
|
||||
return { status: attempt.status, message: attempt.message ?? "Authorization failed", time: attempt.time }
|
||||
}
|
||||
@@ -777,8 +777,7 @@ const layer = Layer.effect(
|
||||
connect: connectCommand,
|
||||
status: Effect.fn("Integration.command.status")(function* (input) {
|
||||
const attempt = (yield* SynchronizedRef.get(commandAttempts)).get(input.attemptID)
|
||||
if (!attempt || attempt.integrationID !== input.integrationID)
|
||||
return yield* new AttemptNotFoundError(input)
|
||||
if (!attempt || attempt.integrationID !== input.integrationID) return yield* new AttemptNotFoundError(input)
|
||||
if (attempt.status === "pending") {
|
||||
return {
|
||||
status: attempt.status,
|
||||
|
||||
@@ -165,7 +165,7 @@ export const OpencodePlugin = define<HttpClient.HttpClient | Bus.Service | Manag
|
||||
|
||||
yield* ctx.integration.transform((editor) => {
|
||||
editor.update("opencode", (integration) => {
|
||||
integration.name = "OpenCode"
|
||||
integration.name = "OpenCode Console"
|
||||
})
|
||||
editor.method.update(oauth(http))
|
||||
editor.method.update({ integrationID: "opencode", method: { type: "key", label: "API key (service account)" } })
|
||||
|
||||
@@ -599,16 +599,19 @@ describe("Integration", () => {
|
||||
expect((yield* integrations.get(integrationID))?.connections).toEqual([
|
||||
{
|
||||
type: "credential",
|
||||
method: "key",
|
||||
id: personal.id,
|
||||
label: "Personal",
|
||||
},
|
||||
{
|
||||
type: "credential",
|
||||
method: "key",
|
||||
id: work.id,
|
||||
label: "Work",
|
||||
},
|
||||
{
|
||||
type: "credential",
|
||||
method: "key",
|
||||
id: archived.id,
|
||||
label: "Archived",
|
||||
},
|
||||
@@ -616,6 +619,7 @@ describe("Integration", () => {
|
||||
])
|
||||
expect(yield* integrations.connection.active(integrationID)).toEqual({
|
||||
type: "credential",
|
||||
method: "key",
|
||||
id: personal.id,
|
||||
label: "Personal",
|
||||
})
|
||||
@@ -627,6 +631,7 @@ describe("Integration", () => {
|
||||
|
||||
expect(yield* integrations.connection.active(integrationID)).toEqual({
|
||||
type: "credential",
|
||||
method: "key",
|
||||
id: work.id,
|
||||
label: "Work",
|
||||
})
|
||||
@@ -649,6 +654,7 @@ describe("Integration", () => {
|
||||
yield* integrations.connection.remove(work.id)
|
||||
expect(yield* integrations.connection.active(integrationID)).toEqual({
|
||||
type: "credential",
|
||||
method: "key",
|
||||
id: personal.id,
|
||||
label: "Personal",
|
||||
})
|
||||
|
||||
@@ -568,9 +568,14 @@ it.effect("refreshes expired OAuth credentials through the context during activa
|
||||
expect(yield* plugins.list()).toMatchObject([{ id: "oauth-refresh", state: { status: "active" } }])
|
||||
expect(resolved).toEqual([refreshed])
|
||||
expect((yield* credentials.get(stored.id))?.value).toEqual(refreshed)
|
||||
expect(yield* integrations.connection.resolve({ type: "credential", id: stored.id, label: stored.label })).toEqual(
|
||||
refreshed,
|
||||
)
|
||||
expect(
|
||||
yield* integrations.connection.resolve({
|
||||
type: "credential",
|
||||
method: "oauth",
|
||||
id: stored.id,
|
||||
label: stored.label,
|
||||
}),
|
||||
).toEqual(refreshed)
|
||||
expect(refreshes).toEqual([expired])
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -320,7 +320,12 @@ describe("OpencodePlugin", () => {
|
||||
expect(config).toEqual([{ authorization: "Bearer access", orgID: scenario.orgID ?? "org-a" }])
|
||||
const integrations = yield* Integration.Service
|
||||
expect(
|
||||
yield* integrations.connection.resolve({ type: "credential", id: initial.id, label: initial.label }),
|
||||
yield* integrations.connection.resolve({
|
||||
type: "credential",
|
||||
method: "oauth",
|
||||
id: initial.id,
|
||||
label: initial.label,
|
||||
}),
|
||||
).toEqual(stored.value)
|
||||
expect(requests).toEqual(["/auth/device/token", "/api/v2/config"])
|
||||
}),
|
||||
|
||||
@@ -319,7 +319,7 @@ it.effect("keeps near-expiry keys usable and requires a new login after expiry",
|
||||
expires: (yield* Clock.currentTimeMillis) + 120_000,
|
||||
}),
|
||||
})
|
||||
const connection = { type: "credential" as const, id: saved.id, label: saved.label }
|
||||
const connection = { type: "credential" as const, method: "oauth" as const, id: saved.id, label: saved.label }
|
||||
expect(yield* test.integrations.connection.resolve(connection)).toEqual(saved.value)
|
||||
yield* TestClock.adjust("2 minutes")
|
||||
const error = yield* test.integrations.connection.resolve(connection).pipe(Effect.flip)
|
||||
|
||||
@@ -13602,9 +13602,13 @@
|
||||
},
|
||||
"label": {
|
||||
"type": "string"
|
||||
},
|
||||
"method": {
|
||||
"type": "string",
|
||||
"enum": ["key", "oauth"]
|
||||
}
|
||||
},
|
||||
"required": ["type", "id", "label"],
|
||||
"required": ["type", "id", "label", "method"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"Connection.EnvInfo": {
|
||||
|
||||
@@ -8,6 +8,8 @@ export const CredentialInfo = Schema.Struct({
|
||||
type: Schema.Literal("credential"),
|
||||
id: Credential.ID,
|
||||
label: Schema.String,
|
||||
/** How the credential was obtained: a stored key or an OAuth grant. */
|
||||
method: Schema.Literals(["key", "oauth"]),
|
||||
}).annotate({ identifier: "Connection.CredentialInfo" })
|
||||
|
||||
export interface EnvInfo extends Schema.Schema.Type<typeof EnvInfo> {}
|
||||
|
||||
@@ -67,11 +67,11 @@ describe("credentialConnections", () => {
|
||||
name: "Example",
|
||||
connections: [
|
||||
{ type: "env", name: "EXAMPLE_KEY" },
|
||||
{ type: "credential", id: "cred_1", label: "Work" },
|
||||
{ type: "credential", method: "key", id: "cred_1", label: "Work" },
|
||||
],
|
||||
}),
|
||||
),
|
||||
).toEqual([{ type: "credential", id: "cred_1", label: "Work" }])
|
||||
).toEqual([{ type: "credential", method: "key", id: "cred_1", label: "Work" }])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -83,7 +83,7 @@ describe("connectionSummary", () => {
|
||||
id: "example",
|
||||
name: "Example",
|
||||
connections: [
|
||||
{ type: "credential", id: "cred_1", label: "Work" },
|
||||
{ type: "credential", method: "key", id: "cred_1", label: "Work" },
|
||||
{ type: "env", name: "EXAMPLE_KEY" },
|
||||
],
|
||||
}),
|
||||
|
||||
@@ -1826,7 +1826,7 @@ test("refreshes integrations after integration updates", async () => {
|
||||
id: "openai",
|
||||
name: "OpenAI",
|
||||
methods: [{ type: "key" }],
|
||||
connections: [{ type: "credential", id: "cred_openai", label: "OpenAI" }],
|
||||
connections: [{ type: "credential", method: "key", id: "cred_openai", label: "OpenAI" }],
|
||||
},
|
||||
],
|
||||
})
|
||||
@@ -2176,9 +2176,7 @@ test("keeps shell state scoped to location", async () => {
|
||||
},
|
||||
},
|
||||
})
|
||||
await wait(() =>
|
||||
data.shell.list({ directory: other }).some((shell) => shell.id === "sh_live_other"),
|
||||
)
|
||||
await wait(() => data.shell.list({ directory: other }).some((shell) => shell.id === "sh_live_other"))
|
||||
expect(data.shell.list().map((shell) => shell.id)).toEqual(["sh_default"])
|
||||
expect(
|
||||
data.shell.listBySession("ses_shared").find((shell) => shell.id === "sh_live_other")?.location.directory,
|
||||
@@ -2638,9 +2636,7 @@ test("resyncs global forms only for the active location after reconnect", async
|
||||
await wait(() => data.session.form.list("global", home)?.[0]?.id === "frm_default_2", 4000)
|
||||
expect(data.session.form.list("global", other)?.[0]?.id).toBe("frm_other_1")
|
||||
expect(requests).toHaveLength(1)
|
||||
expect(requests.map((url) => url.searchParams.get("location[directory]") ?? directory)).toEqual([
|
||||
home.directory,
|
||||
])
|
||||
expect(requests.map((url) => url.searchParams.get("location[directory]") ?? directory)).toEqual([home.directory])
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
|
||||
@@ -154,7 +154,7 @@ test("requires delete confirmation and preserves the account manager when anothe
|
||||
await fixture.app.waitForFrame((frame) => frame.includes("Connected accounts") && !frame.includes("Work"))
|
||||
|
||||
expect(fixture.requests).toEqual([{ method: "DELETE", path: "/api/credential/cred_work" }])
|
||||
expect(fixture.accounts).toEqual([{ type: "credential", id: "cred_personal", label: "Personal" }])
|
||||
expect(fixture.accounts).toEqual([{ type: "credential", method: "key", id: "cred_personal", label: "Personal" }])
|
||||
expect(fixture.reads.model).toBe(0)
|
||||
expect(fixture.reads.provider).toBe(0)
|
||||
expect(fixture.app.captureCharFrame()).toContain("Add account")
|
||||
@@ -198,7 +198,7 @@ test("marks the remaining account active after deleting the active credential",
|
||||
})
|
||||
|
||||
expect(fixture.requests).toEqual([{ method: "DELETE", path: "/api/credential/cred_personal" }])
|
||||
expect(fixture.accounts).toEqual([{ type: "credential", id: "cred_work", label: "Work" }])
|
||||
expect(fixture.accounts).toEqual([{ type: "credential", method: "key", id: "cred_work", label: "Work" }])
|
||||
} finally {
|
||||
fixture.app.renderer.destroy()
|
||||
}
|
||||
@@ -254,8 +254,8 @@ async function renderIntegration(activeLocation?: LocationRef, form?: FormFields
|
||||
const credentialQueries: string[] = []
|
||||
const reads = { integration: 0, model: 0, provider: 0 }
|
||||
let accounts = [
|
||||
{ type: "credential" as const, id: "cred_personal", label: "Personal" },
|
||||
{ type: "credential" as const, id: "cred_work", label: "Work" },
|
||||
{ type: "credential" as const, method: "key" as const, id: "cred_personal", label: "Personal" },
|
||||
{ type: "credential" as const, method: "key" as const, id: "cred_work", label: "Work" },
|
||||
]
|
||||
|
||||
const calls = createFetch(async (url, request) => {
|
||||
|
||||
@@ -8,7 +8,9 @@ describe("hasConnectedProvider", () => {
|
||||
})
|
||||
|
||||
test("is true after any provider integration is connected", () => {
|
||||
expect(hasConnectedProvider([{ connections: [{ type: "credential", id: "cred_1", label: "Work" }] }])).toBe(true)
|
||||
expect(
|
||||
hasConnectedProvider([{ connections: [{ type: "credential", method: "key", id: "cred_1", label: "Work" }] }]),
|
||||
).toBe(true)
|
||||
expect(hasConnectedProvider([{ connections: [{ type: "env", name: "OPENAI_API_KEY" }] }])).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -372,10 +372,8 @@
|
||||
></path>
|
||||
</symbol>
|
||||
<symbol viewBox="0 0 24 24" fill="none" id="opencode">
|
||||
<path
|
||||
d="M8.40005 17.4H19.2001V21H4.80005V13.8H8.40005V17.4ZM15.6001 10.2V13.8H8.40005V10.2H15.6001ZM19.2001 10.2H15.6001V6.6H4.80005V3H19.2001V10.2Z"
|
||||
fill="currentColor"
|
||||
></path>
|
||||
<path d="M15.6 17.4H8.4V10.2H15.6V17.4Z" fill="currentColor" fill-opacity="0.4"></path>
|
||||
<path d="M15.6 6.6H8.4V17.4H15.6V6.6ZM19.2 21H4.8V3H19.2V21Z" fill="currentColor"></path>
|
||||
</symbol>
|
||||
<symbol viewBox="0 0 24 24" fill="none" id="opencode-go">
|
||||
<path
|
||||
|
||||
|
Before Width: | Height: | Size: 275 KiB After Width: | Height: | Size: 275 KiB |
@@ -13602,9 +13602,13 @@
|
||||
},
|
||||
"label": {
|
||||
"type": "string"
|
||||
},
|
||||
"method": {
|
||||
"type": "string",
|
||||
"enum": ["key", "oauth"]
|
||||
}
|
||||
},
|
||||
"required": ["type", "id", "label"],
|
||||
"required": ["type", "id", "label", "method"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"Connection.EnvInfo": {
|
||||
|
||||
@@ -13602,9 +13602,13 @@
|
||||
},
|
||||
"label": {
|
||||
"type": "string"
|
||||
},
|
||||
"method": {
|
||||
"type": "string",
|
||||
"enum": ["key", "oauth"]
|
||||
}
|
||||
},
|
||||
"required": ["type", "id", "label"],
|
||||
"required": ["type", "id", "label", "method"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"Connection.EnvInfo": {
|
||||
|
||||
Reference in New Issue
Block a user