Compare commits

...
3 Commits
28 changed files with 832 additions and 110 deletions

No files matched your search

@@ -37,7 +37,10 @@ export const defaultChain = (options: DefaultChainOptions): Effect.Effect<Creden
Effect.tryPromise({
try: async () => {
const { fromNodeProviderChain } = await import("@aws-sdk/credential-providers")
const identity = await fromNodeProviderChain(options.profile === undefined ? {} : { profile: options.profile })()
const identity = await fromNodeProviderChain({
ignoreCache: true,
...(options.profile === undefined ? {} : { profile: options.profile }),
})()
return {
region: options.region,
accessKeyId: identity.accessKeyId,
@@ -11,8 +11,7 @@ import { useData } from "@/runtime/server/current"
import { createEffect, createMemo, on, onCleanup } from "solid-js"
import { createStore, produce } from "solid-js/store"
export type ProviderConnectMethod = Extract<IntegrationMethod, { type: "key" | "oauth" }>
export type ProviderConnectMethod = Extract<IntegrationMethod, { type: "key" | "oauth" | "form" }>
type Authorization = IntegrationOauthConnectOutput["data"]
// OpenCode Go and OpenCode Zen both bill through the OpenCode Console, so the
@@ -100,11 +99,11 @@ export function createProviderConnectionController(options: {
const methods = createMemo<ProviderConnectMethod[]>(() => {
const values = integration.latest?.methods.filter(
(method): method is ProviderConnectMethod => method.type === "key" || method.type === "oauth",
(method): method is ProviderConnectMethod =>
method.type === "key" || method.type === "oauth" || method.type === "form",
)
if (values?.length) return [...values]
if (values?.length)
return values.toSorted((a, b) => ("order" in a ? (a.order ?? 0) : 0) - ("order" in b ? (b.order ?? 0) : 0))
return [{ type: "key", label: language.t("provider.connect.method.apiKey") }]
})
@@ -357,7 +356,26 @@ export function createProviderConnectionController(options: {
return
}
if (selected.type === "form") {
dispatch({ type: "auth.answer", answer: merged })
dispatch({ type: "auth.pending" })
const result = await serverSDK.api.integration.connect
.form({
integrationID: options.provider(),
methodID: selected.id,
answer: merged,
location: location(),
})
.then(() => ({ ok: true as const }))
.catch((error) => ({ ok: false as const, error }))
if (polling.disposed || generation !== polling.generation) return
if (!result.ok) {
dispatch({ type: "auth.error", error: errorMessage(result.error) })
return
}
await finish()
return
}
if (selected.type !== "oauth") return
if (selected.form?.some((field) => field.type !== "string")) {
+114 -48
View File
@@ -3,6 +3,7 @@ import { Badge } from "@opencode/ui/badge"
import { useDialog } from "@opencode/ui/context/dialog"
import { Icon } from "@opencode/ui/icon"
import { List } from "@opencode/ui/list"
import { RadioGroup, RadioItem } from "@opencode/ui/radio"
import { Spinner } from "@opencode/ui/spinner"
import { TextField } from "@opencode/ui/text-field"
import { DialogBody, DialogHeader, DialogTitle, Dialog } from "@opencode/ui/dialog"
@@ -132,8 +133,7 @@ export const DialogConnectProvider: Component<{
onConnected={(methodID) => {
props.onConnected?.(provider)
if (provider === "openai" && methodID === "chatgpt-token-sharing")
setState("chatgptWelcome", true)
if (provider === "openai" && methodID === "chatgpt-token-sharing") setState("chatgptWelcome", true)
}}
onFirstConnection={(provider) => setState("modelProvider", provider)}
onAuthorization={(authorization) => setState("authorization", authorization)}
@@ -172,7 +172,8 @@ export const DialogConnectProvider: Component<{
}}
class="[font-family:var(--v2-font-family-sans)] [&_[data-slot=dialog-header]]:!px-5 [&_[data-slot=dialog-header-title]]:!text-[15px] [&_[data-slot=dialog-header-title]]:!tracking-[-0.13px]"
classList={{
"[&_[data-slot=dialog-header]]:!pt-4 [&_[data-slot=dialog-header]]:!pb-3": consoleSelected() && !state.modelProvider,
"[&_[data-slot=dialog-header]]:!pt-4 [&_[data-slot=dialog-header]]:!pb-3":
consoleSelected() && !state.modelProvider,
"[&_[data-slot=dialog-header]]:!pt-5": !!state.modelProvider,
}}
>
@@ -452,9 +453,7 @@ function ProviderConnection(props: {
props.onConnected?.(method?.type === "oauth" ? method.id : undefined)
// 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.
global.models.show(
connectionModels().map((model) => ({ providerID: model.providerID, modelID: model.id })),
)
global.models.show(connectionModels().map((model) => ({ providerID: model.providerID, modelID: model.id })))
if (state.catalogPending) {
setState("noModels", true)
@@ -578,7 +577,9 @@ function ProviderConnection(props: {
})
createEffect(() => {
const current = controller.auth.state()
props.onAuthorization(controller.authorization() !== undefined && (current === "waiting" || current === "refreshing"))
props.onAuthorization(
controller.authorization() !== undefined && (current === "waiting" || current === "refreshing"),
)
})
const provider = createMemo(() => ({
@@ -592,9 +593,7 @@ function ProviderConnection(props: {
const methodLabel = (value?: { type?: string; label?: string }) => {
if (!value) return ""
if (value.type === "key") return language.t("provider.connect.method.apiKey")
if (value.type === "key") return value.label ?? language.t("provider.connect.method.apiKey")
return value.label ?? ""
}
@@ -647,6 +646,8 @@ function ProviderConnection(props: {
Object.entries(defaults).flatMap(([key, value]) => (typeof value === "string" ? [[key, value]] : [])),
) as Record<string, string>,
index: 0,
customKey: "",
customText: "",
})
const fields = createMemo<StringForm[]>(() => {
@@ -679,9 +680,7 @@ function ProviderConnection(props: {
const valid = createMemo(() => {
const item = current()
if (!item || item.field.options) return false
if (!item || (item.field.options && !item.field.custom)) return false
if (!item.field.required) return true
return (formStore.value[item.field.key] ?? "").trim().length > 0
@@ -694,8 +693,7 @@ function ProviderConnection(props: {
const next = fields().findIndex((field, i) => i > index && matches(field, value))
if (next !== -1) {
setFormStore("index", next)
setFormStore({ index: next, customKey: "", customText: "" })
return
}
@@ -705,9 +703,7 @@ function ProviderConnection(props: {
async function handleSubmit(e: SubmitEvent) {
e.preventDefault()
const item = current()
if (!item || item.field.options) return
if (!item || (item.field.options && !item.field.custom)) return
if (!valid()) return
await next(item.index, formStore.value)
}
@@ -729,13 +725,22 @@ function ProviderConnection(props: {
return field
})
const custom = () => {
const field = select()
if (!field?.custom) return false
const value = formStore.value[field.key]
return (
formStore.customKey === field.key ||
(value !== undefined && !field.options?.some((option) => option.value === value))
)
}
return (
<form onSubmit={handleSubmit} class="flex flex-col items-start gap-4 px-3">
<Switch>
<Match when={item()?.field.options === undefined}>
<TextField
type="text"
type={text()?.format === "password" ? "password" : "text"}
label={text()?.title ?? ""}
placeholder={text()?.placeholder}
value={text() ? (formStore.value[text()!.key] ?? "") : ""}
@@ -754,36 +759,97 @@ function ProviderConnection(props: {
<div class="w-full flex flex-col gap-1.5">
<div class="text-14-regular text-text-base">{select()?.title}</div>
<div>
<List
class="px-3"
items={select()?.options ?? []}
key={(x) => x.value}
current={select()?.options?.find((x) => x.value === formStore.value[select()!.key])}
onSelect={(value) => {
if (!value) return
const field = select()
if (!field) return
const nextValue = {
...formStore.value,
[field.key]: value.value,
}
setFormStore("value", field.key, value.value)
void next(item()!.index, nextValue)
}}
<Show
when={select()?.custom}
fallback={
<List
class="px-3"
items={select()?.options ?? []}
key={(x) => x.value}
current={select()?.options?.find((x) => x.value === formStore.value[select()!.key])}
onSelect={(value) => {
if (!value) return
const field = select()
if (!field) return
const nextValue = {
...formStore.value,
[field.key]: value.value,
}
setFormStore("value", field.key, value.value)
void next(item()!.index, nextValue)
}}
>
{(option) => (
<div class="w-full flex items-center gap-x-2">
<div class="w-4 h-2 rounded-[1px] bg-input-base shadow-xs-border-base flex items-center justify-center">
<div class="w-2.5 h-0.5 ml-0 bg-icon-strong-base hidden" data-slot="list-item-extra-icon" />
</div>
<span>{option.label}</span>
<span class="text-14-regular text-text-weak">{option.description}</span>
</div>
)}
</List>
}
>
{(option) => (
<div class="w-full flex items-center gap-x-2">
<div class="w-4 h-2 rounded-[1px] bg-input-base shadow-xs-border-base flex items-center justify-center">
<div class="w-2.5 h-0.5 ml-0 bg-icon-strong-base hidden" data-slot="list-item-extra-icon" />
</div>
<span>{option.label}</span>
<span class="text-14-regular text-text-weak">{option.description}</span>
</div>
)}
</List>
<RadioGroup
label={select()?.title}
hideLabel
description={select()?.description}
value={
custom()
? String(select()?.options?.length ?? 0)
: String(
select()?.options?.findIndex((option) => option.value === formStore.value[select()!.key]) ??
-1,
)
}
onChange={(value) => {
const field = select()
if (!field) return
const option = field.options?.[Number(value)]
if (option) {
if (custom()) setFormStore("customText", formStore.value[field.key] ?? "")
setFormStore({ customKey: "" })
setFormStore("value", field.key, option.value)
return
}
setFormStore({ customKey: field.key })
setFormStore("value", field.key, formStore.customText)
}}
>
<For each={select()?.options}>
{(option, index) => (
<RadioItem value={String(index())} label={option.label} description={option.description} />
)}
</For>
<RadioItem
value={String(select()?.options?.length ?? 0)}
label={language.t("ui.messagePart.option.typeOwnAnswer")}
/>
<Show when={custom()}>
<TextField
ref={(input: HTMLInputElement) => queueMicrotask(() => input.focus())}
type={select()?.format === "password" ? "password" : "text"}
label={language.t("ui.messagePart.option.typeOwnAnswer")}
hideLabel
placeholder={select()?.placeholder ?? language.t("ui.question.custom.placeholder")}
value={formStore.value[select()!.key] ?? ""}
onChange={(value) => {
const field = select()
if (!field) return
setFormStore({ customText: value })
setFormStore("value", field.key, value)
}}
onKeyDown={(event: KeyboardEvent) => {
if (event.key !== "Escape") event.stopPropagation()
}}
/>
</Show>
</RadioGroup>
<Button class="w-auto" type="submit" size="large" variant="contrast" disabled={!valid()}>
{language.t("common.continue")}
</Button>
</Show>
</div>
</div>
</Match>
@@ -137,12 +137,14 @@ const answerField = Effect.fn("cli.auth.form.field")(function* (field: FormField
if (value !== custom) return value
}
const value = yield* prompt<string>(() =>
text({
message,
placeholder: field.type === "string" ? field.placeholder : undefined,
initialValue: field.default === undefined ? undefined : String(field.default),
validate: (input) => validateText(field, input),
}),
field.type === "string" && field.format === "password"
? password({ message, validate: (input) => validateText(field, input) })
: text({
message,
placeholder: field.type === "string" ? field.placeholder : undefined,
initialValue: field.default === undefined ? undefined : String(field.default),
validate: (input) => validateText(field, input),
}),
)
if (!value && !field.required) return undefined
if (field.type === "string") return value
@@ -127,6 +127,13 @@ const authenticate = Effect.fn("cli.auth.login.authenticate")(function* (
) {
if (method.type === "key") return yield* keyLogin(client, integration, method, answer)
if (method.type === "command") return yield* commandLogin(client, integration, method)
if (method.type === "form")
return yield* request((signal) =>
client.integration.connect.form(
{ integrationID: integration.id, methodID: method.id, answer, location },
{ signal },
),
)
return yield* oauthLogin(client, integration, method, answer)
})
@@ -48,7 +48,11 @@ export type ConnectMethod = Exclude<IntegrationMethod, { type: "env" }>
export function connectMethods(integration: IntegrationInfo) {
return integration.methods
.filter((method): method is ConnectMethod => method.type !== "env")
.toSorted((a, b) => Number(a.type === "key") - Number(b.type === "key"))
.toSorted(
(a, b) =>
("order" in a && a.order !== undefined ? a.order : Number(a.type === "key")) -
("order" in b && b.order !== undefined ? b.order : Number(b.type === "key")),
)
}
export const resolveMethod = Effect.fn("cli.auth.resolve-method")(function* (methods: ConnectMethod[], target: string) {
+16 -1
View File
@@ -1597,6 +1597,18 @@ export type IntegrationConnectKeyOperation<E = never> = (
input: IntegrationConnectKeyInput,
) => Effect.Effect<IntegrationConnectKeyOutput, E>
export type IntegrationConnectFormInput = {
readonly integrationID: Integration.ID
readonly location?: { readonly directory?: string | undefined } | undefined
readonly methodID: Integration.MethodID
readonly answer?: Form.Answer | undefined
readonly label?: string | undefined
}
export type IntegrationConnectFormOutput = void
export type IntegrationConnectFormOperation<E = never> = (
input: IntegrationConnectFormInput,
) => Effect.Effect<IntegrationConnectFormOutput, E>
export type IntegrationOauthConnectInput = {
readonly integrationID: Integration.ID
readonly location?: { readonly directory?: string | undefined } | undefined
@@ -1687,7 +1699,10 @@ export interface IntegrationApi<E = never> {
readonly list: IntegrationListOperation<E>
readonly get: IntegrationGetOperation<E>
readonly wellknown: { readonly add: IntegrationWellknownAddOperation<E> }
readonly connect: { readonly key: IntegrationConnectKeyOperation<E> }
readonly connect: {
readonly key: IntegrationConnectKeyOperation<E>
readonly form: IntegrationConnectFormOperation<E>
}
readonly oauth: {
readonly connect: IntegrationOauthConnectOperation<E>
readonly status: IntegrationOauthStatusOperation<E>
+12 -1
View File
@@ -127,6 +127,8 @@ import type {
IntegrationWellknownAddOutput,
IntegrationConnectKeyInput,
IntegrationConnectKeyOutput,
IntegrationConnectFormInput,
IntegrationConnectFormOutput,
IntegrationOauthConnectInput,
IntegrationOauthConnectOutput,
IntegrationOauthStatusInput,
@@ -897,6 +899,15 @@ const EndpointIntegrationConnectKey = (raw: RawClient["server.integration"]) =>
}).pipe(Effect.mapError(mapClientError)),
)
const EndpointIntegrationConnectForm = (raw: RawClient["server.integration"]) => (input: IntegrationConnectFormInput) =>
preserveEffect<IntegrationConnectFormOutput>()(
raw["integration.connect.form"]({
params: { integrationID: input["integrationID"] },
query: { location: input["location"] },
payload: { methodID: input["methodID"], answer: input["answer"], label: input["label"] },
}).pipe(Effect.mapError(mapClientError)),
)
const EndpointIntegrationOauthConnect =
(raw: RawClient["server.integration"]) => (input: IntegrationOauthConnectInput) =>
preserveEffect<IntegrationOauthConnectOutput>()(
@@ -965,7 +976,7 @@ const adaptGroupIntegration = (raw: RawClient["server.integration"]) => ({
list: EndpointIntegrationList(raw),
get: EndpointIntegrationGet(raw),
wellknown: { add: EndpointIntegrationWellknownAdd(raw) },
connect: { key: EndpointIntegrationConnectKey(raw) },
connect: { key: EndpointIntegrationConnectKey(raw), form: EndpointIntegrationConnectForm(raw) },
oauth: {
connect: EndpointIntegrationOauthConnect(raw),
status: EndpointIntegrationOauthStatus(raw),
@@ -121,6 +121,8 @@ import type {
IntegrationWellknownAddOutput,
IntegrationConnectKeyInput,
IntegrationConnectKeyOutput,
IntegrationConnectFormInput,
IntegrationConnectFormOutput,
IntegrationOauthConnectInput,
IntegrationOauthConnectOutput,
IntegrationOauthStatusInput,
@@ -1228,6 +1230,19 @@ export function make(options: ClientOptions) {
},
requestOptions,
),
form: (input: IntegrationConnectFormInput, requestOptions?: RequestOptions) =>
request<IntegrationConnectFormOutput>(
{
method: "POST",
path: `/api/integration/${encodeURIComponent(input.integrationID)}/connect/form`,
query: { location: input["location"] },
body: { methodID: input["methodID"], answer: input["answer"], label: input["label"] },
successStatus: 204,
declaredStatuses: [400, 401, 404],
empty: true,
},
requestOptions,
),
},
oauth: {
connect: (input: IntegrationOauthConnectInput, requestOptions?: RequestOptions) =>
+36 -11
View File
@@ -1445,7 +1445,7 @@ export type FormStringField = {
hidden?: boolean
when?: Array<FormWhen>
type: "string"
format?: "email" | "uri" | "date" | "date-time"
format?: "email" | "uri" | "date" | "date-time" | "password"
minLength?: number
maxLength?: number
pattern?: string
@@ -1617,7 +1617,7 @@ export type FormStringField1 = {
hidden?: boolean
when?: Array<FormWhen1>
type: "string"
format?: "email" | "uri" | "date" | "date-time"
format?: "email" | "uri" | "date" | "date-time" | "password"
minLength?: number
maxLength?: number
pattern?: string
@@ -2312,7 +2312,9 @@ export type FormDetail = {
export type IntegrationOAuthMethod = { id: string; type: "oauth"; label: string; form?: FormFields }
export type IntegrationKeyMethod = { type: "key"; label?: string; form?: FormFields }
export type IntegrationKeyMethod = { type: "key"; label?: string; order?: number; form?: FormFields }
export type IntegrationFormMethod = { id: string; type: "form"; label: string; order?: number; form?: FormFields }
export type CredentialEntry = {
id: string
@@ -2352,6 +2354,7 @@ export type IntegrationMethod =
| IntegrationCommandMethod
| IntegrationKeyMethod
| IntegrationEnvMethod
| IntegrationFormMethod
export type FormCreated = {
id: string
@@ -4665,7 +4668,7 @@ export type SessionFormCreateInput = {
readonly value: string | number | "Infinity" | "-Infinity" | "NaN" | boolean
}>
readonly type: "string"
readonly format?: "email" | "uri" | "date" | "date-time"
readonly format?: "email" | "uri" | "date" | "date-time" | "password"
readonly minLength?: number
readonly maxLength?: number
readonly pattern?: string
@@ -4767,7 +4770,7 @@ export type SessionFormCreateInput = {
readonly value: string | number | "Infinity" | "-Infinity" | "NaN" | boolean
}>
readonly type: "string"
readonly format?: "email" | "uri" | "date" | "date-time"
readonly format?: "email" | "uri" | "date" | "date-time" | "password"
readonly minLength?: number
readonly maxLength?: number
readonly pattern?: string
@@ -4876,7 +4879,7 @@ export type SessionFormCreateInput = {
readonly value: string | number | "Infinity" | "-Infinity" | "NaN" | boolean
}>
readonly type: "string"
readonly format?: "email" | "uri" | "date" | "date-time"
readonly format?: "email" | "uri" | "date" | "date-time" | "password"
readonly minLength?: number
readonly maxLength?: number
readonly pattern?: string
@@ -4978,7 +4981,7 @@ export type SessionFormCreateInput = {
readonly value: string | number | "Infinity" | "-Infinity" | "NaN" | boolean
}>
readonly type: "string"
readonly format?: "email" | "uri" | "date" | "date-time"
readonly format?: "email" | "uri" | "date" | "date-time" | "password"
readonly minLength?: number
readonly maxLength?: number
readonly pattern?: string
@@ -5087,7 +5090,7 @@ export type SessionFormCreateInput = {
readonly value: string | number | "Infinity" | "-Infinity" | "NaN" | boolean
}>
readonly type: "string"
readonly format?: "email" | "uri" | "date" | "date-time"
readonly format?: "email" | "uri" | "date" | "date-time" | "password"
readonly minLength?: number
readonly maxLength?: number
readonly pattern?: string
@@ -5189,7 +5192,7 @@ export type SessionFormCreateInput = {
readonly value: string | number | "Infinity" | "-Infinity" | "NaN" | boolean
}>
readonly type: "string"
readonly format?: "email" | "uri" | "date" | "date-time"
readonly format?: "email" | "uri" | "date" | "date-time" | "password"
readonly minLength?: number
readonly maxLength?: number
readonly pattern?: string
@@ -5298,7 +5301,7 @@ export type SessionFormCreateInput = {
readonly value: string | number | "Infinity" | "-Infinity" | "NaN" | boolean
}>
readonly type: "string"
readonly format?: "email" | "uri" | "date" | "date-time"
readonly format?: "email" | "uri" | "date" | "date-time" | "password"
readonly minLength?: number
readonly maxLength?: number
readonly pattern?: string
@@ -5400,7 +5403,7 @@ export type SessionFormCreateInput = {
readonly value: string | number | "Infinity" | "-Infinity" | "NaN" | boolean
}>
readonly type: "string"
readonly format?: "email" | "uri" | "date" | "date-time"
readonly format?: "email" | "uri" | "date" | "date-time" | "password"
readonly minLength?: number
readonly maxLength?: number
readonly pattern?: string
@@ -5688,6 +5691,28 @@ export type IntegrationConnectKeyInput = {
export type IntegrationConnectKeyOutput = void
export type IntegrationConnectFormInput = {
readonly integrationID: { readonly integrationID: string }["integrationID"]
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"]
readonly methodID: {
readonly methodID: string
readonly answer?: { readonly [x: string]: string | number | boolean | ReadonlyArray<string> } | undefined
readonly label?: string | undefined
}["methodID"]
readonly answer?: {
readonly methodID: string
readonly answer?: { readonly [x: string]: string | number | boolean | ReadonlyArray<string> } | undefined
readonly label?: string | undefined
}["answer"]
readonly label?: {
readonly methodID: string
readonly answer?: { readonly [x: string]: string | number | boolean | ReadonlyArray<string> } | undefined
readonly label?: string | undefined
}["label"]
}
export type IntegrationConnectFormOutput = void
export type IntegrationOauthConnectInput = {
readonly integrationID: { readonly integrationID: string }["integrationID"]
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"]
+50 -1
View File
@@ -45,6 +45,9 @@ export type CommandMethod = Integration.CommandMethod
export const KeyMethod = Integration.KeyMethod
export type KeyMethod = Integration.KeyMethod
export const FormMethod = Integration.FormMethod
export type FormMethod = Integration.FormMethod
export const EnvMethod = Integration.EnvMethod
export type EnvMethod = Integration.EnvMethod
@@ -82,6 +85,12 @@ export interface KeyImplementation {
readonly method: KeyMethod
}
export interface FormImplementation {
readonly integrationID: ID
readonly method: FormMethod
readonly connect: (answer: Form.Answer) => Effect.Effect<Credential.Key | Credential.External, unknown>
}
export interface CommandImplementation {
readonly integrationID: ID
readonly method: CommandMethod
@@ -92,7 +101,12 @@ export interface EnvImplementation {
readonly method: EnvMethod
}
export type Implementation = OAuthImplementation | CommandImplementation | KeyImplementation | EnvImplementation
export type Implementation =
| OAuthImplementation
| CommandImplementation
| KeyImplementation
| EnvImplementation
| FormImplementation
export const Attempt = Integration.Attempt
export type Attempt = Integration.Attempt
@@ -136,6 +150,7 @@ type Entry = {
ref: Types.DeepMutable<Ref>
methods: Types.DeepMutable<Method>[]
implementations: Map<MethodID, Types.DeepMutable<OAuthImplementation>>
forms: Map<MethodID, Types.DeepMutable<FormImplementation>>
}
type Data = {
@@ -180,6 +195,13 @@ export interface Interface extends State.Transformable<Editor> {
/** User-facing label for the stored credential. */
readonly label?: string
}) => Effect.Effect<void, AuthorizationError>
/** Validates a setup form and stores the credential returned by its implementation. */
readonly form: (input: {
readonly integrationID: ID
readonly methodID: MethodID
readonly answer?: Form.Answer
readonly label?: string
}) => Effect.Effect<void, AuthorizationError>
/** Selects a stored credential as the active integration connection. */
readonly activate: (credentialID: Credential.ID) => Effect.Effect<void>
/** Updates a stored credential exposed as a connection. */
@@ -301,6 +323,7 @@ const layer = Layer.effect(
ref: { id, name: id },
methods: [],
implementations: new Map(),
forms: new Map(),
}
if (!editor.integrations.has(id)) editor.integrations.set(id, current)
update(current.ref)
@@ -317,6 +340,7 @@ const layer = Layer.effect(
},
methods: [],
implementations: new Map<MethodID, Types.DeepMutable<OAuthImplementation>>(),
forms: new Map<MethodID, Types.DeepMutable<FormImplementation>>(),
}
if (!editor.integrations.has(implementation.integrationID)) {
editor.integrations.set(implementation.integrationID, current)
@@ -327,6 +351,8 @@ const layer = Layer.effect(
return method.id === implementation.method.id
if (method.type === "command" && implementation.method.type === "command")
return method.id === implementation.method.id
if (method.type === "form" && implementation.method.type === "form")
return method.id === implementation.method.id
return true
})
if (index === -1) current.methods.push(implementation.method as Types.DeepMutable<Method>)
@@ -337,6 +363,8 @@ const layer = Layer.effect(
implementation as Types.DeepMutable<OAuthImplementation>,
)
}
if ("connect" in implementation)
current.forms.set(implementation.method.id, implementation as Types.DeepMutable<FormImplementation>)
},
remove: (integrationID, method) => {
const current = editor.integrations.get(integrationID)
@@ -345,10 +373,12 @@ const layer = Layer.effect(
if (candidate.type !== method.type) return false
if (candidate.type === "oauth" && method.type === "oauth") return candidate.id === method.id
if (candidate.type === "command" && method.type === "command") return candidate.id === method.id
if (candidate.type === "form" && method.type === "form") return candidate.id === method.id
return true
})
if (index !== -1) current.methods.splice(index, 1)
if (method.type === "oauth") current.implementations.delete(method.id)
if (method.type === "form") current.forms.delete(method.id)
},
},
}),
@@ -737,6 +767,25 @@ const layer = Layer.effect(
})
}),
activate: Effect.fn("Integration.connection.activate")((credentialID) => credentials.activate(credentialID)),
form: Effect.fn("Integration.connection.form")(function* (input) {
const implementation = state.get().integrations.get(input.integrationID)?.forms.get(input.methodID)
if (!implementation) return yield* new AuthorizationError({ cause: new Error("Connection method not found") })
const answer = input.answer ?? {}
if (implementation.method.form) {
const invalid =
Form.validateFields(implementation.method.form) ?? Form.validateAnswer(implementation.method.form, answer)
if (invalid) return yield* new AuthorizationError({ cause: new Error(invalid) })
}
if (!implementation.method.form && Object.keys(answer).length > 0)
return yield* new AuthorizationError({
cause: new Error("Connection method does not accept a form answer"),
})
yield* createCredential({
integrationID: input.integrationID,
label: input.label,
value: yield* authorize(implementation.connect(answer)),
})
}),
update: Effect.fn("Integration.connection.update")((credentialID, updates) =>
credentials.update(credentialID, updates),
),
+37 -5
View File
@@ -6,6 +6,7 @@ import { Auth } from "@opencode/ai/route"
import { Context, Effect, Layer, Schema, Struct } from "effect"
import { AISDK } from "./aisdk.js"
import { Credential } from "./credential.js"
import { BedrockAuth } from "@opencode/ai/protocols/utils/bedrock-auth"
import { Integration } from "./integration.js"
import { Capabilities, ID, Info, Model, Ref, VariantID } from "./model.js"
import type { RuntimeInfo } from "./model.js"
@@ -199,7 +200,13 @@ const resolveCatalogModel = Effect.fn("ModelResolver.resolveCatalogModel")(funct
dependencies?: Dependencies,
) {
const resolved = prepareRuntimeModel(model, credential)
const configuration = credential?.type === "key" ? credential.configuration : undefined
const configuration =
credential?.type === "key"
? credential.configuration?.auth === "sigv4" &&
resolved.package?.startsWith("@opencode/ai/providers/amazon-bedrock")
? Struct.omit(credential.configuration, ["accessKeyId", "sessionToken"])
: credential.configuration
: undefined
const configured = { ...resolved.settings, ...credential?.metadata, ...configuration }
if (Provider.isAISDK(resolved.package)) {
const loadAISDK = dependencies?.loadAISDK
@@ -207,7 +214,7 @@ const resolveCatalogModel = Effect.fn("ModelResolver.resolveCatalogModel")(funct
const settings = yield* prepareProviderSettings(
resolved,
Provider.mergeOverlay(resolved.settings, {
...nativeCredentialSettings(resolved.package ?? "", credential),
...nativeCredentialSettings(resolved.package ?? "", credential, configured),
...credential?.metadata,
...configuration,
}) ?? {},
@@ -225,7 +232,7 @@ const resolveCatalogModel = Effect.fn("ModelResolver.resolveCatalogModel")(funct
const settings = {
...(credential ? Struct.omit(mapped, ["accessToken", "apiKey", "authToken"]) : mapped),
...(resolved.canonical === undefined ? {} : { provider: resolved.canonical }),
...nativeCredentialSettings(specifier, credential),
...nativeCredentialSettings(specifier, credential, configured),
headers: resolved.headers,
body: resolved.body,
}
@@ -305,8 +312,33 @@ function unresolvedProviderVariables(model: RuntimeInfo, baseURL: string) {
})
}
const nativeCredentialSettings = (specifier: string, credential: Credential.Value | undefined) => {
if (!credential || credential.type === "external") return {}
const nativeCredentialSettings = (
specifier: string,
credential: Credential.Value | undefined,
settings: Readonly<Record<string, unknown>>,
) => {
if (!credential) return {}
if (credential.type === "external") {
if (specifier.startsWith("@opencode/ai/providers/amazon-bedrock") && credential.methodID === "aws-credentials")
return { auth: "sigv4", profile: credential.metadata?.profile, credentials: undefined }
return {}
}
if (
credential.type === "key" &&
specifier.startsWith("@opencode/ai/providers/amazon-bedrock") &&
credential.configuration?.auth === "sigv4"
)
return {
auth: "sigv4",
credentials: {
region: BedrockAuth.resolveRegion({
region: typeof settings.region === "string" ? settings.region : undefined,
}),
accessKeyId: credential.configuration.accessKeyId,
secretAccessKey: credential.key,
...(credential.configuration.sessionToken ? { sessionToken: credential.configuration.sessionToken } : {}),
},
}
if (credential.type === "key") return { apiKey: credential.key }
if (specifier === "@opencode/ai/providers/anthropic" || specifier === "@opencode/ai/providers/anthropic-compatible")
return { authToken: credential.access }
+13
View File
@@ -279,6 +279,13 @@ export const make = Effect.fn("PluginHost.make")(function* (
return yield* response(Effect.succeed(item))
}),
connect: {
form: (input) =>
integration.connection.form({
integrationID: Integration.ID.make(input.integrationID),
methodID: Integration.MethodID.make(input.methodID),
answer: input.answer,
label: input.label,
}),
key: (input) =>
integration.connection.key({
integrationID: Integration.ID.make(input.integrationID),
@@ -629,6 +636,12 @@ export function storage(kv: KV.Interface, pluginID: string): Plugin.Context["sto
}
function methodImplementation(input: IntegrationMethodRegistration): Integration.Implementation {
if ("connect" in input)
return {
integrationID: Integration.ID.make(input.integrationID),
method: { ...input.method, id: Integration.MethodID.make(input.method.id) },
connect: input.connect,
}
if ("authorize" in input) {
const refresh = input.refresh
return {
@@ -1,5 +1,10 @@
import { Effect } from "effect"
import path from "node:path"
import { define } from "@opencode/plugin/effect/plugin"
import { FSUtil } from "@opencode/util/fs-util"
import { Global } from "@opencode/util/global"
import { Credential } from "../../credential.js"
import { Integration } from "../../integration.js"
import { Provider } from "../../provider.js"
// Ambient inputs the AWS default credential chain can turn into credentials
@@ -19,6 +24,35 @@ const isBedrock = (item: { readonly package: string }) =>
export const AmazonBedrockPlugin = define({
id: "opencode.provider.amazon.bedrock",
effect: Effect.fn(function* (ctx) {
const fs = yield* FSUtil.Service
const paths = [
process.env.AWS_CONFIG_FILE ?? path.join(Global.Path.home, ".aws", "config"),
process.env.AWS_SHARED_CREDENTIALS_FILE ?? path.join(Global.Path.home, ".aws", "credentials"),
]
const files = yield* Effect.all(
paths.map((file) => fs.readFileStringSafe(file).pipe(Effect.orElseSucceed(() => undefined))),
)
// Discover names only. Resolving every profile here could run credential helpers or contact AWS.
const profiles = Array.from(
new Set(
files.flatMap((content, index) =>
Array.from((content ?? "").matchAll(/^\s*\[([^\]\r\n]+)\]/gm)).flatMap((match) => {
const section = match[1].trim()
if (index === 1) return [section]
if (section === "default") return [section]
return section.startsWith("profile ") ? [section.slice(8).trim()] : []
}),
),
),
)
.filter(Boolean)
.toSorted()
const sources = paths
.filter((_, index) => profiles.length === 0 || files[index] !== undefined)
.map((file) =>
file.startsWith(Global.Path.home + path.sep) ? `~${file.slice(Global.Path.home.length)}` : file,
)
.join(" and ")
yield* ctx.integration.transform((editor) => {
// models.dev advertises AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, and
// AWS_REGION alongside the bearer token. Only the bearer token is a key;
@@ -27,6 +61,76 @@ export const AmazonBedrockPlugin = define({
integrationID: Provider.ID.amazonBedrock,
method: { type: "env", names: ["AWS_BEARER_TOKEN_BEDROCK"] },
})
editor.method.update({
integrationID: Provider.ID.amazonBedrock,
method: { type: "key", label: "Bedrock API key", order: -3 },
})
editor.method.update({
integrationID: Provider.ID.amazonBedrock,
method: {
id: "aws-credentials",
type: "form",
label: "AWS profile (SSO or named profile)",
order: -2,
form: [
{
key: "profile",
type: "string",
title: "AWS profile",
description: profiles.length
? `Found ${profiles.length} profile${profiles.length === 1 ? "" : "s"} in ${sources} on the server.`
: `No AWS profiles found in ${sources} on the server.`,
required: true,
minLength: 1,
pattern: "\\S",
placeholder: "Profile name",
custom: true,
options: profiles.map((profile) => ({ value: profile, label: profile })),
},
],
},
connect: (answer) =>
Effect.succeed(
Credential.External.make({
type: "external",
methodID: Integration.MethodID.make("aws-credentials"),
metadata: { auth: "sigv4", profile: String(answer.profile).trim() },
}),
),
})
editor.method.update({
integrationID: Provider.ID.amazonBedrock,
method: {
id: "aws-access-keys",
type: "form",
label: "AWS access key + secret",
order: -1,
form: [
{ key: "accessKeyId", type: "string", title: "Access key ID", required: true },
{ key: "secretAccessKey", type: "string", format: "password", title: "Secret access key", required: true },
{
key: "sessionToken",
type: "string",
format: "password",
title: "Session token (optional)",
description:
"Required for temporary credentials. These pasted credentials cannot renew themselves when they expire.",
},
],
},
connect: (answer) =>
Effect.succeed(
Credential.Key.make({
type: "key",
key: String(answer.secretAccessKey).trim(),
configuration: {
auth: "sigv4",
accessKeyId: String(answer.accessKeyId).trim(),
...(answer.sessionToken ? { sessionToken: String(answer.sessionToken).trim() } : {}),
},
}),
),
})
})
yield* ctx.provider.transform((evt) => {
for (const item of evt.list()) {
+1
View File
@@ -32,6 +32,7 @@ const integrations = Layer.mock(Integration.Service, {
active: () => Effect.undefined,
resolve: () => Effect.die("unused"),
key: () => Effect.die("unused"),
form: () => Effect.die("unused"),
activate: () => Effect.die("unused"),
update: () => Effect.die("unused"),
remove: () => Effect.die("unused"),
+1
View File
@@ -330,6 +330,7 @@ function resourceMcpLayer(
active: unusedIntegration,
resolve: unusedIntegration,
key: unusedIntegration,
form: unusedIntegration,
activate: unusedIntegration,
update: unusedIntegration,
remove: unusedIntegration,
@@ -372,6 +372,7 @@ describe("ModelResolver", () => {
},
resolve: () => Effect.die("unused"),
key: () => Effect.die("unused"),
form: () => Effect.die("unused"),
activate: () => Effect.die("unused"),
update: () => Effect.die("unused"),
remove: () => Effect.die("unused"),
+11 -1
View File
@@ -78,6 +78,7 @@ export function host(overrides: Overrides = {}): Plugin.Context {
get: () => Effect.die("unused integration.get"),
connect: {
key: () => Effect.die("unused integration.connect.key"),
form: () => Effect.die("unused integration.connect.form"),
},
oauth: {
connect: () => Effect.die("unused integration.oauth.connect"),
@@ -297,6 +298,7 @@ export function integrationHost(integration: Integration.Interface): Plugin.Cont
get: () => Effect.die("unused integration.get"),
connect: {
key: () => Effect.die("unused integration.connect.key"),
form: () => Effect.die("unused integration.connect.form"),
},
oauth: {
connect: () => Effect.die("unused integration.oauth.connect"),
@@ -339,6 +341,14 @@ export function integrationHost(integration: Integration.Interface): Plugin.Cont
method: {
list: (id) => editor.method.list(Integration.ID.make(id)),
update: (input) => {
if ("connect" in input) {
editor.method.update({
integrationID: Integration.ID.make(input.integrationID),
method: { ...input.method, id: Integration.MethodID.make(input.method.id) },
connect: input.connect,
})
return
}
if ("authorize" in input) {
const methodID = Integration.MethodID.make(input.method.id)
const refresh = input.refresh
@@ -459,7 +469,7 @@ export function webSearchHost(websearch: WebSearch.Interface): Plugin.Context["w
}
function internalMethod(value: IntegrationMethod): Integration.Method {
if (value.type === "oauth" || value.type === "command") {
if (value.type === "oauth" || value.type === "command" || value.type === "form") {
return { ...value, id: Integration.MethodID.make(value.id) }
}
return value
@@ -104,8 +104,7 @@ describe("AmazonBedrockPlugin", () => {
})
})
yield* addPlugin()
expect((yield* integrations.get(integrationID))?.methods).toEqual([
{ type: "key" },
expect((yield* integrations.get(integrationID))?.methods.filter((method) => method.type === "env")).toEqual([
{ type: "env", names: ["AWS_BEARER_TOKEN_BEDROCK"] },
])
}),
+15
View File
@@ -25,6 +25,15 @@ export interface IntegrationCommandMethod {
export interface IntegrationKeyMethod {
readonly type: "key"
readonly label?: string
readonly order?: number
readonly form?: Form.Fields
}
export interface IntegrationFormMethod {
readonly id: string
readonly type: "form"
readonly label: string
readonly order?: number
readonly form?: Form.Fields
}
@@ -38,6 +47,7 @@ export type IntegrationMethod =
| IntegrationCommandMethod
| IntegrationKeyMethod
| IntegrationEnvMethod
| IntegrationFormMethod
export type IntegrationOAuthAuthorization = {
readonly url: string
@@ -62,6 +72,11 @@ export type IntegrationOAuthMethodRegistration = {
}
export type IntegrationMethodRegistration =
| IntegrationOAuthMethodRegistration
| {
readonly integrationID: string
readonly method: IntegrationFormMethod
readonly connect: (answer: Form.Answer) => Effect.Effect<Credential.Key | Credential.External, unknown>
}
| {
readonly integrationID: string
readonly method: IntegrationCommandMethod
+6
View File
@@ -355,6 +355,7 @@ export function fromPromise(plugin: Plugin) {
list: adaptApiMethod(IntegrationEndpoints["integration.list"], host.integration.list),
get: adaptApiMethod(IntegrationEndpoints["integration.get"], host.integration.get),
connect: {
form: adaptApiMethod(IntegrationEndpoints["integration.connect.form"], host.integration.connect.form),
key: adaptApiMethod(IntegrationEndpoints["integration.connect.key"], host.integration.connect.key),
},
oauth: {
@@ -394,6 +395,11 @@ export function fromPromise(plugin: Plugin) {
method: {
list: editor.method.list,
update: (input) => {
if ("connect" in input)
return editor.method.update({
...input,
connect: (answer) => Effect.tryPromise(() => input.connect(answer)),
})
if (!("authorize" in input)) return editor.method.update(input)
const refresh = input.refresh
editor.method.update({
@@ -24,6 +24,15 @@ export interface IntegrationCommandMethod {
export interface IntegrationKeyMethod {
readonly type: "key"
readonly label?: string
readonly order?: number
readonly form?: Form.Fields
}
export interface IntegrationFormMethod {
readonly id: string
readonly type: "form"
readonly label: string
readonly order?: number
readonly form?: Form.Fields
}
@@ -37,6 +46,7 @@ export type IntegrationMethod =
| IntegrationCommandMethod
| IntegrationKeyMethod
| IntegrationEnvMethod
| IntegrationFormMethod
export type IntegrationOAuthAuthorization = {
readonly url: string
@@ -63,6 +73,11 @@ export type IntegrationOAuthMethodRegistration = {
export type IntegrationMethodRegistration =
| IntegrationOAuthMethodRegistration
| {
readonly integrationID: string
readonly method: IntegrationFormMethod
readonly connect: (answer: Form.Answer) => Promise<Credential.Key | Credential.External>
}
| { readonly integrationID: string; readonly method: IntegrationCommandMethod }
| {
readonly integrationID: string
@@ -79,6 +79,27 @@ export const IntegrationGroup = HttpApiGroup.make("server.integration")
}),
),
)
.add(
HttpApiEndpoint.post("integration.connect.form", "/api/integration/:integrationID/connect/form", {
params: { integrationID: Integration.ID },
query: LocationQuery,
payload: Schema.Struct({
methodID: Integration.MethodID,
answer: Schema.optional(Form.Answer),
label: Schema.optional(Schema.String),
}),
success: HttpApiSchema.NoContent,
error: [IntegrationNotFoundError, InvalidRequestError],
})
.annotateMerge(locationQueryOpenApi)
.annotateMerge(
OpenApi.annotations({
identifier: "integration.connect.form",
summary: "Connect with setup form",
description: "Run a setup form method and store its key or external credential-source reference.",
}),
),
)
.add(
HttpApiEndpoint.post("integration.oauth.connect", "/api/integration/:integrationID/connect/oauth", {
params: { integrationID: Integration.ID },
+1 -1
View File
@@ -50,7 +50,7 @@ const FieldBase = {
export const StringField = Schema.Struct({
...FieldBase,
type: Schema.Literal("string"),
format: Schema.Literals(["email", "uri", "date", "date-time"]).pipe(optional),
format: Schema.Literals(["email", "uri", "date", "date-time", "password"]).pipe(optional),
minLength: NonNegativeInt.pipe(optional),
maxLength: NonNegativeInt.pipe(optional),
pattern: Schema.String.pipe(optional),
+12 -1
View File
@@ -35,16 +35,27 @@ export interface KeyMethod extends Schema.Schema.Type<typeof KeyMethod> {}
export const KeyMethod = Schema.Struct({
type: Schema.Literal("key"),
label: optional(Schema.String),
order: optional(Schema.Finite),
form: optional(Form.Fields),
}).annotate({ identifier: "Integration.KeyMethod" })
/** A setup form that saves a credential without an OAuth or command attempt. */
export interface FormMethod extends Schema.Schema.Type<typeof FormMethod> {}
export const FormMethod = Schema.Struct({
id: MethodID,
type: Schema.Literal("form"),
label: Schema.String,
order: optional(Schema.Finite),
form: optional(Form.Fields),
}).annotate({ identifier: "Integration.FormMethod" })
export interface EnvMethod extends Schema.Schema.Type<typeof EnvMethod> {}
export const EnvMethod = Schema.Struct({
type: Schema.Literal("env"),
names: Schema.Array(Schema.String),
}).annotate({ identifier: "Integration.EnvMethod" })
export const Method = Schema.Union([OAuthMethod, CommandMethod, KeyMethod, EnvMethod])
export const Method = Schema.Union([OAuthMethod, CommandMethod, KeyMethod, EnvMethod, FormMethod])
.pipe(Schema.toTaggedUnion("type"))
.annotate({ identifier: "Integration.Method" })
export type Method = typeof Method.Type
@@ -84,6 +84,26 @@ export const IntegrationHandler = HttpApiBuilder.group(Api, "server.integration"
return HttpApiSchema.NoContent.make()
}),
)
.handle(
"integration.connect.form",
Effect.fn(function* (ctx) {
const service = yield* Integration.Service
if (!(yield* service.get(ctx.params.integrationID)))
return yield* new IntegrationNotFoundError({
integrationID: ctx.params.integrationID,
message: `Integration not found: ${ctx.params.integrationID}`,
})
yield* authorize(
service.connection.form({
integrationID: ctx.params.integrationID,
methodID: ctx.payload.methodID,
answer: ctx.payload.answer,
label: ctx.payload.label,
}),
)
return HttpApiSchema.NoContent.make()
}),
)
.handle(
"integration.oauth.connect",
Effect.fn(function* (ctx) {
+278 -23
View File
@@ -1,4 +1,5 @@
import { TextAttributes } from "@opentui/core"
import { ScrollBoxRenderable, TextareaRenderable, TextAttributes } from "@opentui/core"
import { useTerminalDimensions } from "@opentui/solid"
import type {
ConnectionInfo,
IntegrationCommandConnectOutput,
@@ -12,7 +13,8 @@ import type {
LocationRef,
} from "@opencode/client"
import { openUrl } from "@opencode/util/open"
import { createEffect, createMemo, createSignal, onCleanup, onMount, Show } from "solid-js"
import { createEffect, createMemo, createSignal, For, onCleanup, onMount, Show } from "solid-js"
import { createStore } from "solid-js/store"
import { useClipboard } from "../context/clipboard"
import { useData } from "../context/data"
import { useClient } from "../context/client"
@@ -24,8 +26,17 @@ import { DialogPrompt } from "../ui/dialog-prompt"
import { DialogSelect } from "../ui/dialog-select"
import { Link } from "../ui/link"
import { useToast } from "../ui/toast"
import { Spinner } from "./spinner"
import { errorMessage } from "../util/error"
import { formLabel, formToggleMultiselect, formValidateValue, type FormAnswerField } from "../util/form"
import {
formInitialValues,
formLabel,
formRows,
formSelected,
formToggleMultiselect,
formValidateValue,
type FormAnswerField,
} from "../util/form"
const INTEGRATION_PRIORITY: Record<string, number> = {
"opencode-go": 0,
@@ -40,6 +51,11 @@ type ConnectMethod = Exclude<IntegrationInfo["methods"][number], { type: "env" }
type IntegrationAttempt = IntegrationOauthConnectOutput["data"]
type CommandAttempt = IntegrationCommandConnectOutput["data"]
type OnIntegrationConnected = (providerID?: string) => void
type ConnectionContext = {
client: ReturnType<typeof useClient>
data: ReturnType<typeof useData>
toast: ReturnType<typeof useToast>
}
const CANCELLED = Symbol("cancelled")
const CUSTOM = Symbol("custom")
const OPEN = Symbol("open")
@@ -58,7 +74,11 @@ export function integrationOptions(list: IntegrationInfo[]) {
export function connectMethods(integration: IntegrationInfo): ConnectMethod[] {
return integration.methods
.filter((method): method is ConnectMethod => method.type !== "env")
.toSorted((a, b) => Number(a.type === "key") - Number(b.type === "key"))
.toSorted(
(a, b) =>
("order" in a && a.order !== undefined ? a.order : Number(a.type === "key")) -
("order" in b && b.order !== undefined ? b.order : Number(b.type === "key")),
)
}
export function credentialConnections(integration: IntegrationInfo) {
@@ -77,6 +97,7 @@ export function DialogIntegration(
props: { onConnected?: OnIntegrationConnected; integrationID?: string; autoConnect?: boolean } = {},
) {
const data = useData()
const context = { client: useClient(), data, toast: useToast() }
const currentLocation = useLocation()
const dialog = useDialog()
const theme = useTheme().surface("dialog")
@@ -96,7 +117,7 @@ export function DialogIntegration(
manageConnections(integration, methods, location, dialog, props.onConnected)
return
}
selectMethod(integration, methods, location, dialog, props.onConnected)
selectMethod(integration, methods, location, dialog, context, props.onConnected)
})
const options = createMemo(() => {
@@ -121,7 +142,7 @@ export function DialogIntegration(
: undefined,
onSelect: () => {
if (credentials.length) return manageConnections(integration, methods, location, dialog, props.onConnected)
return selectMethod(integration, methods, location, dialog, props.onConnected)
return selectMethod(integration, methods, location, dialog, context, props.onConnected)
},
}
})
@@ -156,6 +177,7 @@ function manageConnections(
const data = useData()
const client = useClient()
const toast = useToast()
const context = { client, data, toast }
const theme = useTheme().surface("dialog")
const shortcuts = Keymap.useShortcuts()
const [deleting, setDeleting] = createSignal<string>()
@@ -180,7 +202,8 @@ function manageConnections(
{
title: "Add account",
value: "add",
onSelect: () => selectMethod(current() ?? integration, methods, location, dialog, onConnected),
onSelect: () =>
selectMethod(current() ?? integration, methods, location, dialog, context, onConnected),
},
]
: []),
@@ -201,7 +224,7 @@ function manageConnections(
onSelect: () => {
if (connection.status?.url) return void openUrl(connection.status.url).catch(toast.error)
if (connection.status)
return selectMethod(current() ?? integration, methods, location, dialog, onConnected)
return selectMethod(current() ?? integration, methods, location, dialog, context, onConnected)
if (credentialConnections(current() ?? integration)[0]?.id === connection.id) return
void client.api.credential.activate({ credentialID: connection.id }).catch(toast.error)
},
@@ -267,16 +290,17 @@ function selectMethod(
methods: ConnectMethod[],
location: LocationRef,
dialog: ReturnType<typeof useDialog>,
context: ConnectionContext,
onConnected?: OnIntegrationConnected,
) {
if (methods.length === 1) return openMethod(integration, methods[0], location, dialog, onConnected)
if (methods.length === 1) return openMethod(integration, methods[0], location, dialog, context, onConnected)
dialog.replace(() => (
<DialogSelect
title={`Connect ${integration.name}`}
options={methods.map((method) => ({
title: method.type === "key" ? (method.label ?? "API key") : method.label,
value: method.type === "key" ? "key" : method.id,
onSelect: () => openMethod(integration, method, location, dialog, onConnected),
onSelect: () => openMethod(integration, method, location, dialog, context, onConnected),
}))}
/>
))
@@ -287,6 +311,7 @@ function openMethod(
method: ConnectMethod,
location: LocationRef,
dialog: ReturnType<typeof useDialog>,
context: ConnectionContext,
onConnected?: OnIntegrationConnected,
) {
if (method.type === "key") {
@@ -299,9 +324,37 @@ function openMethod(
))
return
}
if (method.type === "form") {
void beginForm(integration, method, location, dialog, context, onConnected)
return
}
void beginOAuth(integration, method, location, dialog, onConnected)
}
async function beginForm(
integration: IntegrationInfo,
method: Extract<ConnectMethod, { type: "form" }>,
location: LocationRef,
dialog: ReturnType<typeof useDialog>,
context: ConnectionContext,
onConnected?: OnIntegrationConnected,
) {
const answer = method.form ? await formAnswer(dialog, method.label, method.form) : undefined
if (answer === null) return
await context.client.api.integration.connect
.form({
integrationID: integration.id,
methodID: method.id,
location: locationQuery(location),
answer,
})
.then(() => connected(integration, location, context.data, dialog, context.toast, onConnected))
.catch((cause) => {
context.toast.show({ variant: "error", message: errorMessage(cause) })
dialog.clear()
})
}
async function beginKey(
integration: IntegrationInfo,
method: Extract<ConnectMethod, { type: "key" }>,
@@ -813,6 +866,13 @@ async function selectAnswer(
title: string,
field: Extract<FormAnswerField, { type: "boolean" | "string" }>,
): Promise<FormValue | undefined | typeof CANCELLED> {
if (field.type === "string" && field.custom)
return new Promise((resolve) => {
dialog.replace(
() => <StringChoiceField field={field} onSubmit={resolve} onCancel={() => resolve(CANCELLED)} />,
() => resolve(CANCELLED),
)
})
const options =
field.type === "boolean"
? field.default === false
@@ -829,18 +889,12 @@ async function selectAnswer(
value: option.value as FormValue,
description: option.description,
}))
const choice = await new Promise<FormValue | typeof CUSTOM | undefined | typeof CANCELLED>((resolve) => {
return new Promise<FormValue | undefined | typeof CANCELLED>((resolve) => {
dialog.replace(
() => (
<DialogSelect<FormValue | typeof CUSTOM | undefined>
<DialogSelect<FormValue | undefined>
title={formLabel(field) || title}
options={[
...options,
...(field.type === "string" && field.custom
? [{ title: "Type your own answer", value: CUSTOM as typeof CUSTOM }]
: []),
...(!field.required ? [{ title: "Skip", value: undefined }] : []),
]}
options={[...options, ...(!field.required ? [{ title: "Skip", value: undefined }] : [])]}
current={field.type === "string" ? field.default : undefined}
onSelect={(option) => resolve(option.value)}
/>
@@ -848,11 +902,208 @@ async function selectAnswer(
() => resolve(CANCELLED),
)
})
if (choice === CUSTOM) {
if (field.type !== "string") return CANCELLED
return textAnswer(dialog, title, field, "")
}
function StringChoiceField(props: {
field: Extract<FormAnswerField, { type: "string" }>
onSubmit: (value: string | undefined) => void
onCancel: () => void
}) {
const theme = useTheme().surface("dialog")
const dimensions = useTerminalDimensions()
const rows = formRows(props.field)
const count = rows.length + (props.field.required ? 1 : 2)
const [store, setStore] = createStore({
selected: formSelected(props.field, props.field.default),
editing: false,
submitted: false,
text: formInitialValues([props.field]).custom[props.field.key] ?? "",
error: "",
})
const [inputTarget, setInputTarget] = createSignal<TextareaRenderable>()
let textarea: TextareaRenderable | undefined
let scroll: ScrollBoxRenderable | undefined
createEffect(() => {
const input = inputTarget()
if (!input || !store.submitted) return
input.traits = { suspend: true, status: "BUSY" }
input.blur()
})
createEffect(() => {
const selected = store.selected
const row = scroll?.getChildren()[selected]
if (row) scroll?.scrollChildIntoView(row.id)
})
const submit = (value: string | undefined) => {
if (store.submitted) return
const invalid = formValidateValue(props.field, value)
if (invalid) return setStore("error", invalid)
setStore("submitted", true)
props.onSubmit(value)
}
return choice
const select = (index: number) => {
if (store.submitted) return
setStore({ selected: index, error: "" })
if (!props.field.required && index === rows.length + 1) {
submit(undefined)
return
}
if (index === rows.length) {
setStore("editing", true)
return
}
submit(String(rows[index].value))
}
const cancel = () => {
if (store.editing) {
setStore({ editing: false, text: textarea?.plainText ?? store.text, error: "" })
return
}
props.onCancel()
}
Keymap.createLayer(() => ({
mode: "modal",
enabled: !store.editing && !store.submitted,
commands: [
{
id: "dialog.select.prev",
title: "Previous answer",
group: "Form",
run: () => setStore("selected", (store.selected + count - 1) % count),
},
{
id: "dialog.select.next",
title: "Next answer",
group: "Form",
run: () => setStore("selected", (store.selected + 1) % count),
},
{ id: "dialog.select.submit", title: "Select answer", group: "Form", run: () => select(store.selected) },
{ bind: "escape", title: "Cancel form", group: "Form", run: cancel },
],
}))
Keymap.createLayer(() => ({
mode: "modal",
priority: 1,
target: inputTarget,
enabled: store.editing && !store.submitted,
commands: [
{
id: "dialog.prompt.submit",
title: "Submit answer",
group: "Form",
run: () => submit(textarea?.plainText.trim() || undefined),
},
{ bind: "escape", title: "Back to answers", group: "Form", run: cancel },
],
}))
return (
<box paddingLeft={4} paddingRight={4} paddingBottom={1} gap={1}>
<box flexDirection="row" justifyContent="space-between">
<text fg={theme.text.base} attributes={TextAttributes.BOLD}>
{formLabel(props.field)}
</text>
<text fg={theme.text.muted} onMouseUp={cancel}>
esc
</text>
</box>
<Show when={props.field.description}>{(description) => <text fg={theme.text.muted}>{description()}</text>}</Show>
<scrollbox
gap={1}
height={Math.min(
count + rows.filter((row) => row.description).length,
Math.max(3, Math.floor(dimensions().height / 2) - 6),
)}
scrollbarOptions={{ visible: false }}
ref={(value: ScrollBoxRenderable) => {
scroll = value
}}
>
<For each={rows}>
{(row, index) => (
<box onMouseUp={() => select(index())}>
<box flexDirection="row" gap={1}>
<text fg={store.selected === index() ? theme.text.formfield.focused : theme.text.formfield.base}>
{index() + 1}.
</text>
<text fg={store.selected === index() ? theme.text.formfield.focused : theme.text.formfield.base}>
{row.label}
</text>
</box>
<Show when={row.description}>
{(description) => (
<text paddingLeft={3} fg={theme.text.muted}>
{description()}
</text>
)}
</Show>
</box>
)}
</For>
<box flexDirection="row" gap={1} onMouseUp={() => select(rows.length)}>
<text fg={store.selected === rows.length ? theme.text.formfield.focused : theme.text.formfield.base}>
{rows.length + 1}.
</text>
<Show
when={store.editing}
fallback={
<text fg={store.selected === rows.length ? theme.text.formfield.focused : theme.text.formfield.base}>
{store.text || "Type your own answer"}
</text>
}
>
<textarea
height={1}
flexGrow={1}
wrapMode="none"
initialValue={store.text}
placeholder={props.field.placeholder ?? "Type your own answer"}
placeholderColor={theme.text.muted}
textColor={theme.text.formfield.focused}
focusedTextColor={theme.text.formfield.focused}
cursorColor={theme.text.formfield.focused}
ref={(value: TextareaRenderable) => {
textarea = value
setInputTarget(value)
value.traits = { status: "ANSWER" }
queueMicrotask(() => {
if (value.isDestroyed) return
value.focus()
value.gotoLineEnd()
})
}}
onContentChange={() => {
if (!textarea) return
const text = textarea.plainText
setStore("text", text)
if (store.error && !formValidateValue(props.field, text.trim() || undefined)) setStore("error", "")
}}
/>
</Show>
</box>
<Show when={!props.field.required}>
<box flexDirection="row" gap={1} onMouseUp={() => select(rows.length + 1)}>
<text fg={store.selected === rows.length + 1 ? theme.text.formfield.focused : theme.text.formfield.base}>
{rows.length + 2}.
</text>
<text fg={store.selected === rows.length + 1 ? theme.text.formfield.focused : theme.text.formfield.base}>
Skip
</text>
</box>
</Show>
</scrollbox>
<Show when={store.error}>{(error) => <text fg={theme.text.feedback.error.base}>{error()}</text>}</Show>
<Show
when={store.submitted}
fallback={
<text fg={theme.text.muted}>
{store.editing ? "enter submit · esc back" : "↑/↓ select · enter confirm · esc cancel"}
</text>
}
>
<Spinner color={theme.text.muted}>Connecting…</Spinner>
</Show>
</box>
)
}
function textAnswer(
@@ -866,11 +1117,14 @@ function textAnswer(
() => {
const theme = useTheme().surface("dialog")
const [error, setError] = createSignal<string>()
const [busy, setBusy] = createSignal(false)
return (
<DialogPrompt
title={formLabel(field) || title}
placeholder={field.type === "string" ? field.placeholder : undefined}
secret={field.type === "string" && field.format === "password"}
value={initial}
busy={busy()}
onConfirm={(input) => {
const text = input.trim()
const value = text === "" && !field.required ? undefined : field.type === "string" ? text : Number(text)
@@ -879,6 +1133,7 @@ function textAnswer(
setError(invalid)
return
}
setBusy(true)
resolve(value)
}}
description={() => (
+3
View File
@@ -13,6 +13,7 @@ export type DialogPromptProps = {
description?: () => JSX.Element
placeholder?: string
value?: string
secret?: boolean
busy?: boolean
busyText?: string
onConfirm?: (value: string) => void
@@ -113,6 +114,8 @@ export function DialogPrompt(props: DialogPromptProps) {
<box gap={1}>
{props.description?.()}
<textarea
attributes={props.secret ? TextAttributes.HIDDEN : TextAttributes.NONE}
selectable={!props.secret}
height={1}
wrapMode="none"
ref={(val: TextareaRenderable) => {