fix(integration): restore tailored auth flows
This commit is contained in:
@@ -191,6 +191,7 @@ export const GithubCopilotPlugin = define({
|
||||
})
|
||||
|
||||
yield* ctx.integration.transform((draft) => {
|
||||
draft.method.remove("github-copilot", { type: "key" })
|
||||
draft.method.update(oauth(ctx.app))
|
||||
})
|
||||
yield* ctx.catalog.transform((evt) => {
|
||||
|
||||
@@ -62,6 +62,32 @@ describe("GithubCopilotPlugin", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("removes the generic key method", () =>
|
||||
Effect.gen(function* () {
|
||||
const integrations = yield* Integration.Service
|
||||
yield* integrations.transform((draft) => {
|
||||
draft.method.update({
|
||||
integrationID: Integration.ID.make("github-copilot"),
|
||||
method: { type: "key" },
|
||||
})
|
||||
draft.method.update({
|
||||
integrationID: Integration.ID.make("github-copilot"),
|
||||
method: { type: "env", names: ["GITHUB_TOKEN"] },
|
||||
})
|
||||
})
|
||||
yield* addPlugin()
|
||||
expect((yield* integrations.get(Integration.ID.make("github-copilot")))?.methods).toEqual([
|
||||
{ type: "env", names: ["GITHUB_TOKEN"] },
|
||||
{
|
||||
id: Integration.MethodID.make("device"),
|
||||
type: "oauth",
|
||||
label: "Login with GitHub Copilot",
|
||||
form: expect.any(Array),
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("adds Copilot authentication and request metadata headers", () =>
|
||||
Effect.gen(function* () {
|
||||
const requests: Headers[] = []
|
||||
|
||||
@@ -6,7 +6,9 @@ import type {
|
||||
IntegrationOauthConnectOutput,
|
||||
IntegrationOAuthMethod,
|
||||
FormAnswer,
|
||||
FormField,
|
||||
FormFields,
|
||||
FormValue,
|
||||
} from "@opencode-ai/client"
|
||||
import open from "open"
|
||||
import { createMemo, createSignal, onCleanup, onMount, Show } from "solid-js"
|
||||
@@ -20,7 +22,7 @@ import { DialogPrompt } from "../ui/dialog-prompt"
|
||||
import { DialogSelect } from "../ui/dialog-select"
|
||||
import { Link } from "../ui/link"
|
||||
import { useToast } from "../ui/toast"
|
||||
import { FormInput } from "../routes/session/form"
|
||||
import { formLabel, formToggleMultiselect, formValidateValue, type FormAnswerField } from "../util/form"
|
||||
|
||||
const INTEGRATION_PRIORITY: Record<string, number> = {
|
||||
opencode: 0,
|
||||
@@ -35,6 +37,10 @@ type ConnectMethod = Exclude<IntegrationInfo["methods"][number], { type: "env" }
|
||||
type IntegrationAttempt = IntegrationOauthConnectOutput["data"]
|
||||
type CommandAttempt = IntegrationCommandConnectOutput["data"]
|
||||
type OnIntegrationConnected = (providerID?: string) => void
|
||||
const CANCELLED = Symbol("cancelled")
|
||||
const CUSTOM = Symbol("custom")
|
||||
const OPEN = Symbol("open")
|
||||
const SUBMIT = Symbol("submit")
|
||||
|
||||
export function integrationOptions(list: IntegrationInfo[]) {
|
||||
return list.toSorted(
|
||||
@@ -642,24 +648,235 @@ function OAuthView(props: {
|
||||
}
|
||||
|
||||
async function formAnswer(dialog: ReturnType<typeof useDialog>, title: string, fields: FormFields) {
|
||||
return new Promise<FormAnswer | null>((resolve) => {
|
||||
const answer: FormAnswer = {}
|
||||
for (const field of fields) {
|
||||
if (!active(field, answer)) continue
|
||||
const value = await fieldAnswer(dialog, title, field)
|
||||
if (value === CANCELLED) return null
|
||||
if (value !== undefined) answer[field.key] = value
|
||||
}
|
||||
return answer
|
||||
}
|
||||
|
||||
function active(field: FormField, answer: FormAnswer) {
|
||||
if (field.type === "external" || !field.when) return true
|
||||
return field.when.every((when) => {
|
||||
const value = answer[when.key]
|
||||
if (value === undefined) return false
|
||||
const hit = Array.isArray(value) ? value.includes(String(when.value)) : value === when.value
|
||||
return when.op === "eq" ? hit : !hit
|
||||
})
|
||||
}
|
||||
|
||||
function fieldAnswer(
|
||||
dialog: ReturnType<typeof useDialog>,
|
||||
title: string,
|
||||
field: FormField,
|
||||
): Promise<FormValue | undefined | typeof CANCELLED> {
|
||||
if (field.type === "external") return externalAnswer(dialog, title, field)
|
||||
if (field.type === "multiselect") return multiselectAnswer(dialog, title, field)
|
||||
if (field.type === "boolean" || (field.type === "string" && field.options)) {
|
||||
return selectAnswer(dialog, title, field)
|
||||
}
|
||||
return textAnswer(dialog, title, field)
|
||||
}
|
||||
|
||||
async function selectAnswer(
|
||||
dialog: ReturnType<typeof useDialog>,
|
||||
title: string,
|
||||
field: Extract<FormAnswerField, { type: "boolean" | "string" }>,
|
||||
): Promise<FormValue | undefined | typeof CANCELLED> {
|
||||
const options =
|
||||
field.type === "boolean"
|
||||
? field.default === false
|
||||
? [
|
||||
{ title: "No", value: false as FormValue },
|
||||
{ title: "Yes", value: true as FormValue },
|
||||
]
|
||||
: [
|
||||
{ title: "Yes", value: true as FormValue },
|
||||
{ title: "No", value: false as FormValue },
|
||||
]
|
||||
: (field.options ?? []).map((option) => ({
|
||||
title: option.label,
|
||||
value: option.value as FormValue,
|
||||
description: option.description,
|
||||
}))
|
||||
const choice = await new Promise<FormValue | typeof CUSTOM | undefined | typeof CANCELLED>((resolve) => {
|
||||
dialog.replace(
|
||||
() => (
|
||||
<FormInput
|
||||
form={{ title, fields }}
|
||||
onSubmit={resolve}
|
||||
onCancel={() => {
|
||||
dialog.clear()
|
||||
resolve(null)
|
||||
<DialogSelect<FormValue | typeof CUSTOM | 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 }] : []),
|
||||
]}
|
||||
current={field.type === "string" ? field.default : undefined}
|
||||
onSelect={(option) => resolve(option.value)}
|
||||
/>
|
||||
),
|
||||
() => resolve(CANCELLED),
|
||||
)
|
||||
})
|
||||
if (choice === CUSTOM) {
|
||||
if (field.type !== "string") return CANCELLED
|
||||
return textAnswer(dialog, title, field, "")
|
||||
}
|
||||
return choice
|
||||
}
|
||||
|
||||
function textAnswer(
|
||||
dialog: ReturnType<typeof useDialog>,
|
||||
title: string,
|
||||
field: Extract<FormAnswerField, { type: "string" | "number" | "integer" }>,
|
||||
initial = field.default === undefined ? undefined : String(field.default),
|
||||
): Promise<FormValue | undefined | typeof CANCELLED> {
|
||||
return new Promise<FormValue | undefined | typeof CANCELLED>((resolve) => {
|
||||
dialog.replace(
|
||||
() => {
|
||||
const theme = useTheme("elevated")
|
||||
const [error, setError] = createSignal<string>()
|
||||
return (
|
||||
<DialogPrompt
|
||||
title={formLabel(field) || title}
|
||||
placeholder={field.type === "string" ? field.placeholder : undefined}
|
||||
value={initial}
|
||||
onConfirm={(input) => {
|
||||
const text = input.trim()
|
||||
const value = text === "" && !field.required ? undefined : field.type === "string" ? text : Number(text)
|
||||
const invalid = formValidateValue(field, value)
|
||||
if (invalid) {
|
||||
setError(invalid)
|
||||
return
|
||||
}
|
||||
resolve(value)
|
||||
}}
|
||||
description={() => (
|
||||
<box gap={1}>
|
||||
<Show when={field.description}>
|
||||
{(description) => <text fg={theme.text.subdued}>{description()}</text>}
|
||||
</Show>
|
||||
<Show when={error()}>{(value) => <text fg={theme.text.feedback.error.default}>{value()}</text>}</Show>
|
||||
</box>
|
||||
)}
|
||||
/>
|
||||
)
|
||||
},
|
||||
() => resolve(CANCELLED),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
async function multiselectAnswer(
|
||||
dialog: ReturnType<typeof useDialog>,
|
||||
title: string,
|
||||
field: Extract<FormAnswerField, { type: "multiselect" }>,
|
||||
): Promise<FormValue | typeof CANCELLED> {
|
||||
const selected = field.default ? [...field.default] : []
|
||||
while (true) {
|
||||
const invalid = formValidateValue(field, selected)
|
||||
const choice = await new Promise<string | typeof CUSTOM | typeof SUBMIT | typeof CANCELLED>((resolve) => {
|
||||
dialog.replace(
|
||||
() => (
|
||||
<DialogSelect<string | typeof CUSTOM | typeof SUBMIT>
|
||||
title={formLabel(field) || title}
|
||||
options={[
|
||||
...field.options.map((option) => ({
|
||||
title: `[${selected.includes(option.value) ? "x" : " "}] ${option.label}`,
|
||||
value: option.value,
|
||||
description: option.description,
|
||||
disabled:
|
||||
!selected.includes(option.value) && field.maxItems !== undefined && selected.length >= field.maxItems,
|
||||
})),
|
||||
...(field.custom ? [{ title: "Type your own answer", value: CUSTOM as typeof CUSTOM }] : []),
|
||||
{
|
||||
title: "Continue",
|
||||
value: SUBMIT as typeof SUBMIT,
|
||||
description: invalid,
|
||||
disabled: invalid !== undefined,
|
||||
},
|
||||
]}
|
||||
onSelect={(option) => resolve(option.value)}
|
||||
/>
|
||||
),
|
||||
() => resolve(CANCELLED),
|
||||
)
|
||||
})
|
||||
if (choice === CANCELLED) return CANCELLED
|
||||
if (choice === SUBMIT) return selected
|
||||
if (choice === CUSTOM) {
|
||||
const value = await customAnswer(dialog, title, field)
|
||||
if (value === CANCELLED) return CANCELLED
|
||||
if (value && !selected.includes(value)) selected.push(value)
|
||||
continue
|
||||
}
|
||||
selected.splice(0, selected.length, ...formToggleMultiselect(selected, choice))
|
||||
}
|
||||
}
|
||||
|
||||
function customAnswer(
|
||||
dialog: ReturnType<typeof useDialog>,
|
||||
title: string,
|
||||
field: Extract<FormAnswerField, { type: "multiselect" }>,
|
||||
): Promise<string | typeof CANCELLED> {
|
||||
return new Promise<string | typeof CANCELLED>((resolve) => {
|
||||
dialog.replace(
|
||||
() => (
|
||||
<DialogPrompt
|
||||
title={formLabel(field) || title}
|
||||
placeholder="Type your own answer"
|
||||
onConfirm={(value) => {
|
||||
if (value) resolve(value)
|
||||
}}
|
||||
/>
|
||||
),
|
||||
() => resolve(null),
|
||||
() => resolve(CANCELLED),
|
||||
)
|
||||
dialog.setSize("large")
|
||||
})
|
||||
}
|
||||
|
||||
async function externalAnswer(
|
||||
dialog: ReturnType<typeof useDialog>,
|
||||
title: string,
|
||||
field: Extract<FormField, { type: "external" }>,
|
||||
): Promise<true | typeof CANCELLED> {
|
||||
let opened = false
|
||||
while (true) {
|
||||
const choice = await new Promise<true | typeof OPEN | typeof CANCELLED>((resolve) => {
|
||||
dialog.replace(
|
||||
() => (
|
||||
<DialogSelect<true | typeof OPEN>
|
||||
title={formLabel(field) || title}
|
||||
options={[
|
||||
{ title: opened ? "Open link again" : "Open link", value: OPEN as typeof OPEN, description: field.url },
|
||||
{ title: "I finished", value: true as const, description: field.description, disabled: !opened },
|
||||
]}
|
||||
onSelect={(option) => resolve(option.value)}
|
||||
/>
|
||||
),
|
||||
() => resolve(CANCELLED),
|
||||
)
|
||||
})
|
||||
if (choice === CANCELLED) return CANCELLED
|
||||
if (choice === true) return true
|
||||
const result = await new Promise<boolean | typeof CANCELLED>((resolve) => {
|
||||
dialog.replace(
|
||||
() => <OAuthView title={formLabel(field) || title} message="Opening link..." />,
|
||||
() => resolve(CANCELLED),
|
||||
)
|
||||
void open(field.url).then(
|
||||
() => resolve(true),
|
||||
() => resolve(false),
|
||||
)
|
||||
})
|
||||
if (result === CANCELLED) return CANCELLED
|
||||
opened ||= result
|
||||
}
|
||||
}
|
||||
|
||||
async function connected(
|
||||
integration: IntegrationInfo,
|
||||
data: ReturnType<typeof useData>,
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useRenderer, useTerminalDimensions } from "@opentui/solid"
|
||||
import type { ScrollBoxRenderable, TextareaRenderable } from "@opentui/core"
|
||||
import open from "open"
|
||||
import { useTheme, useThemes } from "../../context/theme"
|
||||
import type { FormAnswer, FormField, FormValue } from "@opencode-ai/client"
|
||||
import type { FormField, FormValue } from "@opencode-ai/client"
|
||||
import type { FormWithLocation } from "../../context/data"
|
||||
import { useClient } from "../../context/client"
|
||||
import { useClipboard } from "../../context/clipboard"
|
||||
@@ -44,27 +44,6 @@ function requestOptions(form: FormWithLocation) {
|
||||
|
||||
export function FormPrompt(props: { form: FormWithLocation }) {
|
||||
const client = useClient()
|
||||
return (
|
||||
<FormInput
|
||||
form={props.form}
|
||||
onSubmit={(answer) =>
|
||||
client.api.form.reply(
|
||||
{ sessionID: props.form.sessionID, formID: props.form.id, answer },
|
||||
requestOptions(props.form),
|
||||
)
|
||||
}
|
||||
onCancel={() =>
|
||||
client.api.form.cancel({ sessionID: props.form.sessionID, formID: props.form.id }, requestOptions(props.form))
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function FormInput(props: {
|
||||
form: Pick<FormWithLocation, "title" | "fields" | "metadata">
|
||||
onSubmit: (answer: FormAnswer) => Promise<unknown> | void
|
||||
onCancel: () => Promise<unknown> | void
|
||||
}) {
|
||||
const themes = useThemes()
|
||||
const theme = useTheme("elevated")
|
||||
const themeMode = themes.mode
|
||||
@@ -202,14 +181,23 @@ export function FormInput(props: {
|
||||
}
|
||||
|
||||
function replySingle(field: FormAnswerField, value: FormValue) {
|
||||
Promise.resolve(props.onSubmit({ [field.key]: value })).catch((error: unknown) => {
|
||||
setStore(
|
||||
"error",
|
||||
typeof error === "object" && error !== null && "message" in error && typeof error.message === "string"
|
||||
? error.message
|
||||
: "Invalid answer",
|
||||
client.api.form
|
||||
.reply(
|
||||
{
|
||||
sessionID: props.form.sessionID,
|
||||
formID: props.form.id,
|
||||
answer: { [field.key]: value },
|
||||
},
|
||||
requestOptions(props.form),
|
||||
)
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
setStore(
|
||||
"error",
|
||||
typeof error === "object" && error !== null && "message" in error && typeof error.message === "string"
|
||||
? error.message
|
||||
: "Invalid answer",
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
function pick(value: FormValue, customValue?: string) {
|
||||
@@ -362,7 +350,7 @@ export function FormInput(props: {
|
||||
}
|
||||
|
||||
function cancel() {
|
||||
void props.onCancel()
|
||||
void client.api.form.cancel({ sessionID: props.form.sessionID, formID: props.form.id }, requestOptions(props.form))
|
||||
}
|
||||
|
||||
function openExternal() {
|
||||
@@ -414,23 +402,28 @@ export function FormInput(props: {
|
||||
setStore("error", formValidateValue(invalid, store.answers[invalid.key]) ?? "Invalid answer")
|
||||
return
|
||||
}
|
||||
Promise.resolve(
|
||||
props.onSubmit(
|
||||
Object.fromEntries(
|
||||
fields().flatMap((field) => {
|
||||
const value = store.answers[field.key]
|
||||
return value === undefined ? [] : [[field.key, value] as const]
|
||||
}),
|
||||
),
|
||||
),
|
||||
).catch((error: unknown) => {
|
||||
setStore(
|
||||
"error",
|
||||
typeof error === "object" && error !== null && "message" in error && typeof error.message === "string"
|
||||
? error.message
|
||||
: "Invalid answer",
|
||||
client.api.form
|
||||
.reply(
|
||||
{
|
||||
sessionID: props.form.sessionID,
|
||||
formID: props.form.id,
|
||||
answer: Object.fromEntries(
|
||||
fields().flatMap((field) => {
|
||||
const value = store.answers[field.key]
|
||||
return value === undefined ? [] : [[field.key, value] as const]
|
||||
}),
|
||||
),
|
||||
},
|
||||
requestOptions(props.form),
|
||||
)
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
setStore(
|
||||
"error",
|
||||
typeof error === "object" && error !== null && "message" in error && typeof error.message === "string"
|
||||
? error.message
|
||||
: "Invalid answer",
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
onMount(() => onCleanup(keymap.mode.push(FORM_MODE)))
|
||||
@@ -458,7 +451,10 @@ export function FormInput(props: {
|
||||
group: "Form",
|
||||
run: () => {
|
||||
if (textual()) {
|
||||
void props.onCancel()
|
||||
void client.api.form.cancel(
|
||||
{ sessionID: props.form.sessionID, formID: props.form.id },
|
||||
requestOptions(props.form),
|
||||
)
|
||||
return
|
||||
}
|
||||
setStore("editing", false)
|
||||
|
||||
Reference in New Issue
Block a user