From dfb60e37a31536106420cb0395aece0c41873b23 Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Tue, 18 Aug 2026 16:37:56 +0530 Subject: [PATCH] feat(cli): add interactive auth commands (#43127) --- bun.lock | 1 + packages/cli/package.json | 1 + packages/cli/src/commands/commands.ts | 33 +- .../cli/src/commands/handlers/auth/form.ts | 153 +++++++++ .../cli/src/commands/handlers/auth/list.ts | 51 +++ .../cli/src/commands/handlers/auth/login.ts | 291 +++++++++++++++--- .../cli/src/commands/handlers/auth/logout.ts | 83 +++++ .../cli/src/commands/handlers/auth/shared.ts | 64 ++++ packages/cli/src/index.ts | 2 + packages/cli/src/ui/prompt.ts | 48 +++ packages/cli/test/auth.test.ts | 257 +++++++++++++++- 11 files changed, 937 insertions(+), 47 deletions(-) create mode 100644 packages/cli/src/commands/handlers/auth/form.ts create mode 100644 packages/cli/src/commands/handlers/auth/list.ts create mode 100644 packages/cli/src/commands/handlers/auth/logout.ts create mode 100644 packages/cli/src/commands/handlers/auth/shared.ts create mode 100644 packages/cli/src/ui/prompt.ts diff --git a/bun.lock b/bun.lock index 04c79c171b7..f8982fae499 100644 --- a/bun.lock +++ b/bun.lock @@ -119,6 +119,7 @@ }, "dependencies": { "@agentclientprotocol/sdk": "1.2.1", + "@clack/prompts": "1.0.0-alpha.1", "@effect/platform-node": "catalog:", "@opencode-ai/client": "workspace:*", "@opencode-ai/plugin": "workspace:*", diff --git a/packages/cli/package.json b/packages/cli/package.json index e255181f63b..1ef1576ded6 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -23,6 +23,7 @@ }, "dependencies": { "@agentclientprotocol/sdk": "1.2.1", + "@clack/prompts": "1.0.0-alpha.1", "@effect/platform-node": "catalog:", "@opencode-ai/client": "workspace:*", "@opencode-ai/plugin": "workspace:*", diff --git a/packages/cli/src/commands/commands.ts b/packages/cli/src/commands/commands.ts index a721cdcc594..1ce8df43c07 100644 --- a/packages/cli/src/commands/commands.ts +++ b/packages/cli/src/commands/commands.ts @@ -86,12 +86,37 @@ const Root = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME ], }), Spec.make("auth", { - description: "Manage authentication", + description: "manage AI providers and credentials", commands: [ - Spec.make("login", { - description: "Log in to a well-known authentication provider", + Spec.make("list", { + description: "list providers and credentials", params: { - url: Argument.string("url").pipe(Argument.withDescription("Well-known provider URL")), + ...ServerParams, + format: Flag.choice("format", ["default", "json"]).pipe( + Flag.withDescription("Output format"), + Flag.withDefault("default"), + ), + }, + }), + Spec.make("login", { + description: "log in to a provider", + params: { + ...ServerParams, + target: Argument.string("target").pipe( + Argument.withDescription("Integration ID, name, or well-known provider URL"), + Argument.optional, + ), + method: Flag.string("method").pipe(Flag.withDescription("Authentication method ID"), Flag.optional), + }, + }), + Spec.make("logout", { + description: "log out from a configured provider", + params: { + ...ServerParams, + target: Argument.string("target").pipe( + Argument.withDescription("Integration ID or name"), + Argument.optional, + ), }, }), ], diff --git a/packages/cli/src/commands/handlers/auth/form.ts b/packages/cli/src/commands/handlers/auth/form.ts new file mode 100644 index 00000000000..d1b931db978 --- /dev/null +++ b/packages/cli/src/commands/handlers/auth/form.ts @@ -0,0 +1,153 @@ +import { confirm, log, multiselect, password, select, text, type Option } from "@clack/prompts" +import { Effect } from "effect" +import type { FormAnswer, FormField, FormFields } from "@opencode-ai/client" +import { openUrl, prompt, requireInteractive } from "../../../ui/prompt" + +const skip = Symbol("skip") +const custom = Symbol("custom") + +export const answerForm = Effect.fn("cli.auth.form")(function* (fields: FormFields | undefined) { + if (!fields) return undefined + yield* requireInteractive("Authentication form input requires an interactive terminal") + const answer: FormAnswer = {} + for (const field of fields) { + if (!active(field, answer)) continue + const value = yield* answerField(field) + if (value !== undefined) answer[field.key] = value + } + return answer +}) + +export const secret = Effect.fn("cli.auth.secret")(function* (message: string) { + yield* requireInteractive("API key input requires an interactive terminal") + return yield* prompt(() => password({ message, validate: (value) => (!value ? "Required" : undefined) })) +}) + +const answerField = Effect.fn("cli.auth.form.field")(function* (field: FormField) { + const message = field.title ?? field.key + if (field.description) log.info(field.description) + if (field.type === "external") { + log.info(field.url) + yield* openUrl(field.url) + const acknowledged = yield* prompt(() => + confirm({ message: message || "Continue after completing this step?", initialValue: true }), + ) + if (!acknowledged) return yield* Effect.fail(new Error(`${message || "External step"} is required`)) + return true + } + if (field.type === "boolean") { + if (field.required) return yield* prompt(() => confirm({ message, initialValue: field.default ?? true })) + const options: Array> = [ + { value: true, label: "Yes" }, + { value: false, label: "No" }, + { value: skip, label: "Skip" }, + ] + const value = yield* prompt(() => + select({ + message, + options, + initialValue: field.default ?? skip, + }), + ) + if (value === skip) return undefined + return value + } + if (field.type === "multiselect") { + const options: Array> = field.options.map((option) => ({ + value: option.value, + label: option.label, + hint: option.description, + })) + if (field.custom) options.push({ value: custom, label: "Type another value" }) + const values = yield* prompt>(() => + multiselect({ + message, + options, + initialValues: field.default, + required: field.required || (field.minItems ?? 0) > 0, + }), + ) + const selected = values.filter((value): value is string => value !== custom) + if (values.includes(custom)) { + selected.push(yield* prompt(() => text({ message: "Enter value", validate: required }))) + } + if (field.minItems !== undefined && selected.length < field.minItems) { + return yield* Effect.fail(new Error(`Select at least ${field.minItems}`)) + } + if (field.maxItems !== undefined && selected.length > field.maxItems) { + return yield* Effect.fail(new Error(`Select at most ${field.maxItems}`)) + } + return selected + } + if (field.type === "string" && field.options) { + const options: Array> = field.options.map((option) => ({ + value: option.value, + label: option.label, + hint: option.description, + })) + if (field.custom) options.push({ value: custom, label: "Type your own answer" }) + if (!field.required) options.push({ value: skip, label: "Skip" }) + const value = yield* prompt(() => + select({ message, options, initialValue: field.default }), + ) + if (value === skip) return undefined + if (value !== custom) return value + } + const value = yield* prompt(() => + 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 + return Number(value) +}) + +function active(field: FormField, answer: FormAnswer) { + if (field.type === "external" || !field.when) return true + return field.when.every((condition) => { + const value = answer[condition.key] + if (value === undefined) return false + const matches = Array.isArray(value) ? value.includes(String(condition.value)) : value === condition.value + return condition.op === "eq" ? matches : !matches + }) +} + +function required(value: string | undefined) { + return value ? undefined : "Required" +} + +function validateText(field: Exclude, value?: string) { + if (!value) return field.required ? "Required" : undefined + if (field.type === "number" || field.type === "integer") { + const number = Number(value) + if (!Number.isFinite(number)) return "Expected a number" + if (field.type === "integer" && !Number.isInteger(number)) return "Expected an integer" + if (typeof field.minimum === "number" && number < field.minimum) return `Must be at least ${field.minimum}` + if (typeof field.maximum === "number" && number > field.maximum) return `Must be at most ${field.maximum}` + return undefined + } + if (field.minLength !== undefined && value.length < field.minLength) + return `Must be at least ${field.minLength} characters` + if (field.maxLength !== undefined && value.length > field.maxLength) + return `Must be at most ${field.maxLength} characters` + if (field.pattern) { + try { + if (!new RegExp(field.pattern).test(value)) return "Invalid format" + } catch { + return "Invalid format" + } + } + if (field.format === "uri" && !URL.canParse(value)) return "Expected a URL" + if (field.format === "email" && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) return "Expected an email address" + if (field.format === "date") { + if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) return "Expected a date" + const date = new Date(`${value}T00:00:00.000Z`) + if (Number.isNaN(date.getTime()) || date.toISOString().slice(0, 10) !== value) return "Expected a date" + } + if (field.format === "date-time" && Number.isNaN(Date.parse(value))) return "Expected a date and time" + return undefined +} diff --git a/packages/cli/src/commands/handlers/auth/list.ts b/packages/cli/src/commands/handlers/auth/list.ts new file mode 100644 index 00000000000..7441b772328 --- /dev/null +++ b/packages/cli/src/commands/handlers/auth/list.ts @@ -0,0 +1,51 @@ +import { EOL } from "node:os" +import { Effect, Option } from "effect" +import { Commands } from "../../commands" +import { Runtime } from "../../../framework/runtime" +import { createClient, loadIntegrations } from "./shared" +import { errorMessage } from "../../../ui/prompt" + +export default Runtime.handler(Commands.commands.auth.commands.list, (input) => + list(input).pipe( + Effect.catch((error) => + Effect.sync(() => { + process.stderr.write(errorMessage(error) + EOL) + process.exitCode = 1 + }), + ), + ), +) + +const list = Effect.fn("cli.auth.list")(function* (input) { + const client = yield* createClient({ server: Option.getOrUndefined(input.server), standalone: input.standalone }) + const integrations = (yield* loadIntegrations(client)).filter((integration) => integration.connections.length > 0) + if (input.format === "json") { + process.stdout.write( + JSON.stringify( + integrations.map((integration) => ({ + id: integration.id, + name: integration.name, + connections: integration.connections, + })), + null, + 2, + ) + EOL, + ) + return + } + const rows = integrations.flatMap((integration) => + integration.connections.map((connection) => ({ + integration: integration.name, + source: connection.type === "credential" ? connection.label : connection.name, + type: connection.type === "credential" ? "stored" : "environment", + })), + ) + if (rows.length === 0) { + process.stdout.write("No authenticated integrations" + EOL) + return + } + const width = Math.max(...rows.map((row) => row.integration.length)) + 2 + process.stdout.write( + rows.map((row) => row.integration.padEnd(width) + row.source.padEnd(28) + row.type).join(EOL) + EOL, + ) +}) diff --git a/packages/cli/src/commands/handlers/auth/login.ts b/packages/cli/src/commands/handlers/auth/login.ts index c349777592c..ee3acf9c66f 100644 --- a/packages/cli/src/commands/handlers/auth/login.ts +++ b/packages/cli/src/commands/handlers/auth/login.ts @@ -1,52 +1,267 @@ -import { EOL } from "node:os" -import { Effect } from "effect" -import { Service } from "@opencode-ai/client/effect/service" -import { OpenCode, type IntegrationCommandStatusOutput, type OpenCodeClient } from "@opencode-ai/client/promise" +import { autocomplete, intro, log, outro, select, spinner, text } from "@clack/prompts" +import { Effect, Option } from "effect" +import type { FormAnswer, IntegrationInfo, OpenCodeClient } from "@opencode-ai/client" import { Commands } from "../../commands" import { Runtime } from "../../../framework/runtime" -import { ServiceConfig } from "../../../services/service-config" +import { handlePromptErrors, openUrl, prompt, requireInteractive } from "../../../ui/prompt" +import { answerForm, secret } from "./form" +import { + createClient, + connectMethods, + loadIntegrations, + location, + request, + resolveIntegration, + resolveMethod, + type ConnectMethod, +} from "./shared" -const location = { directory: process.cwd() } +const integrationPriority = new Map([ + ["opencode", 0], + ["opencode-go", 1], + ["openai", 2], + ["github-copilot", 3], + ["google", 4], + ["anthropic", 5], + ["openrouter", 6], + ["vercel", 7], +]) export default Runtime.handler( Commands.commands.auth.commands.login, - Effect.fn("cli.auth.login")(function* (input) { - process.stdout.write("Logging in..." + EOL + EOL) - const endpoint = yield* Service.ensure(yield* ServiceConfig.options()) - const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) }) - yield* request(() => client.integration.wellknown.add({ url: input.url, location })) - const integrationID = input.url.replace(/\/+$/, "") - const started = yield* request(() => - client.integration.command.connect({ integrationID, methodID: "login", location }), - ) - yield* Effect.addFinalizer(() => - request(() => - client.integration.command.cancel({ integrationID, attemptID: started.data.attemptID, location }), - ).pipe(Effect.ignore), - ) - - const status = yield* wait(client, integrationID, started.data.attemptID) - if (status.status === "failed") return yield* Effect.fail(new Error(status.message)) - if (status.status === "expired") return yield* Effect.fail(new Error("Authentication expired")) - process.stdout.write("Logged in" + EOL) - }), + Effect.fn("cli.auth.login")((input) => + login({ + target: Option.getOrUndefined(input.target), + method: Option.getOrUndefined(input.method), + server: Option.getOrUndefined(input.server), + standalone: input.standalone, + }).pipe(handlePromptErrors), + ), ) -const wait = ( +const login = Effect.fn("cli.auth.login.run")(function* (input: { + target?: string + method?: string + server?: string + standalone: boolean +}) { + if (!input.target) + yield* requireInteractive("Pass an integration ID or name when running without an interactive terminal") + intro("Connect an integration") + const client = yield* createClient({ server: input.server, standalone: input.standalone }) + const integration = yield* findIntegration(client, input.target) + const methods = connectMethods(integration) + if (methods.length === 0) yield* Effect.fail(new Error(`${integration.name} has no interactive login methods`)) + const method = yield* chooseMethod(methods, input.method) + const answer = method.type === "command" ? undefined : yield* answerForm(method.form) + yield* authenticate(client, integration, method, answer) + outro("Done") +}) + +const findIntegration = Effect.fn("cli.auth.login.integration")(function* (client: OpenCodeClient, target?: string) { + if (target && URL.canParse(target)) { + const protocol = new URL(target).protocol + if (protocol === "http:" || protocol === "https:") { + const progress = spinner() + progress.start("Discovering authentication provider...") + yield* request((signal) => client.integration.wellknown.add({ url: target, location }, { signal })).pipe( + Effect.tap(() => Effect.sync(() => progress.stop("Authentication provider discovered"))), + Effect.tapCause(() => Effect.sync(() => progress.stop("Discovery failed", 1))), + ) + } + } + const integrations = yield* loadIntegrations(client) + if (target) return yield* resolveIntegration(integrations, target) + const available = integrations + .filter((integration) => connectMethods(integration).length > 0) + .toSorted( + (a, b) => + (integrationPriority.get(a.id) ?? integrationPriority.size) - + (integrationPriority.get(b.id) ?? integrationPriority.size) || + a.name.localeCompare(b.name) || + a.id.localeCompare(b.id), + ) + if (available.length === 0) return yield* Effect.fail(new Error("No authentication integrations are available")) + const id = yield* prompt(() => + autocomplete({ + message: "Select integration", + maxItems: 8, + options: available.map((integration) => { + const option = { value: integration.id, label: integration.name, hint: integration.id } + if (integration.connections.length > 0) return { ...option, hint: "connected" } + if (integration.id === "opencode") return { ...option, hint: "recommended" } + return option + }), + }), + ) + return yield* resolveIntegration(available, id) +}) + +const chooseMethod = Effect.fn("cli.auth.login.method")(function* (methods: ConnectMethod[], target?: string) { + if (target) return yield* resolveMethod(methods, target) + if (methods.length === 1) return methods[0] + yield* requireInteractive("Pass --method when running without an interactive terminal") + const id = yield* prompt(() => + select({ + message: "Select login method", + options: methods.map((method) => { + if (method.type === "key") return { value: "key", label: method.label ?? "API key" } + return { value: method.id, label: method.label } + }), + }), + ) + return yield* resolveMethod(methods, id) +}) + +const authenticate = Effect.fn("cli.auth.login.authenticate")(function* ( + client: OpenCodeClient, + integration: IntegrationInfo, + method: ConnectMethod, + answer?: FormAnswer, +) { + if (method.type === "key") return yield* keyLogin(client, integration, method, answer) + if (method.type === "command") return yield* commandLogin(client, integration, method) + return yield* oauthLogin(client, integration, method, answer) +}) + +const keyLogin = Effect.fn("cli.auth.login.key")(function* ( + client: OpenCodeClient, + integration: IntegrationInfo, + method: Extract, + answer?: FormAnswer, +) { + const key = yield* secret(method.label ?? `Enter your ${integration.name} API key`) + const progress = spinner() + progress.start("Saving credential...") + yield* request((signal) => + client.integration.connect.key({ integrationID: integration.id, key, answer, location }, { signal }), + ).pipe( + Effect.tap(() => Effect.sync(() => progress.stop(`Connected to ${integration.name}`))), + Effect.tapCause(() => Effect.sync(() => progress.stop("Authentication failed", 1))), + ) +}) + +const oauthLogin = Effect.fn("cli.auth.login.oauth")(function* ( + client: OpenCodeClient, + integration: IntegrationInfo, + method: Extract, + answer?: FormAnswer, +) { + const progress = spinner() + progress.start("Starting authorization...") + const started = yield* request((signal) => + client.integration.oauth.connect( + { integrationID: integration.id, methodID: method.id, answer, location }, + { signal }, + ), + ).pipe(Effect.tapCause(() => Effect.sync(() => progress.stop("Authentication failed", 1)))) + const attempt = started.data + yield* Effect.addFinalizer(() => + request(() => + client.integration.oauth.cancel( + { integrationID: integration.id, attemptID: attempt.attemptID, location }, + { signal: AbortSignal.timeout(5_000) }, + ), + ).pipe(Effect.ignore), + ) + progress.stop("Authorization started") + log.info(attempt.instructions) + log.info(attempt.url) + if (process.stdin.isTTY && process.stdout.isTTY) yield* openUrl(attempt.url) + + if (attempt.mode === "code") { + yield* requireInteractive("This login requires an interactive terminal to enter the authorization code") + const code = yield* prompt(() => + text({ message: "Paste the authorization code", validate: (value) => (!value ? "Required" : undefined) }), + ) + const completing = spinner() + completing.start("Completing authorization...") + yield* request((signal) => + client.integration.oauth.complete( + { integrationID: integration.id, attemptID: attempt.attemptID, code, location }, + { signal }, + ), + ).pipe( + Effect.tap(() => Effect.sync(() => completing.stop(`Connected to ${integration.name}`))), + Effect.tapCause(() => Effect.sync(() => completing.stop("Authentication failed", 1))), + ) + return + } + + const waiting = spinner() + waiting.start("Waiting for authorization...") + const status = yield* waitForOAuth(client, integration.id, attempt.attemptID).pipe( + Effect.tapCause(() => Effect.sync(() => waiting.stop("Authentication failed", 1))), + ) + if (status.status === "complete") { + waiting.stop(`Connected to ${integration.name}`) + return + } + waiting.stop("Authentication failed", 1) + if (status.status === "failed") yield* Effect.fail(new Error(status.message)) + yield* Effect.fail(new Error("Authorization expired")) +}) + +const commandLogin = Effect.fn("cli.auth.login.command")(function* ( + client: OpenCodeClient, + integration: IntegrationInfo, + method: Extract, +) { + const progress = spinner() + progress.start("Starting authentication command...") + const started = yield* request((signal) => + client.integration.command.connect({ integrationID: integration.id, methodID: method.id, location }, { signal }), + ).pipe(Effect.tapCause(() => Effect.sync(() => progress.stop("Authentication failed", 1)))) + yield* Effect.addFinalizer(() => + request(() => + client.integration.command.cancel( + { + integrationID: integration.id, + attemptID: started.data.attemptID, + location, + }, + { signal: AbortSignal.timeout(5_000) }, + ), + ).pipe(Effect.ignore), + ) + const status = yield* waitForCommand(client, integration.id, started.data.attemptID, (message) => + progress.message(message.trim() || "Waiting for authentication command..."), + ).pipe(Effect.tapCause(() => Effect.sync(() => progress.stop("Authentication failed", 1)))) + if (status.status === "complete") { + progress.stop(`Connected to ${integration.name}`) + return + } + progress.stop("Authentication failed", 1) + if (status.status === "failed") yield* Effect.fail(new Error(status.message)) + yield* Effect.fail(new Error("Authentication expired")) +}) + +const waitForOAuth = Effect.fn("cli.auth.login.oauth.wait")(function* ( client: OpenCodeClient, integrationID: string, attemptID: string, - shown = false, -): Effect.Effect, unknown> => - Effect.gen(function* () { - const response = yield* request(() => client.integration.command.status({ integrationID, attemptID, location })) +) { + while (true) { + const response = yield* request((signal) => + client.integration.oauth.status({ integrationID, attemptID, location }, { signal }), + ) if (response.data.status !== "pending") return response.data - const output = response.data.message?.trim() - if (!shown && output) process.stdout.write(output + EOL + EOL) yield* Effect.sleep(500) - return yield* wait(client, integrationID, attemptID, shown || !!output) - }) + } +}) -function request(task: () => Promise) { - return Effect.tryPromise({ try: task, catch: (cause) => cause }) -} +const waitForCommand = Effect.fn("cli.auth.login.command.wait")(function* ( + client: OpenCodeClient, + integrationID: string, + attemptID: string, + update: (message: string) => void, +) { + while (true) { + const response = yield* request((signal) => + client.integration.command.status({ integrationID, attemptID, location }, { signal }), + ) + if (response.data.status !== "pending") return response.data + if (response.data.message) update(response.data.message) + yield* Effect.sleep(500) + } +}) diff --git a/packages/cli/src/commands/handlers/auth/logout.ts b/packages/cli/src/commands/handlers/auth/logout.ts new file mode 100644 index 00000000000..e9ac770d36f --- /dev/null +++ b/packages/cli/src/commands/handlers/auth/logout.ts @@ -0,0 +1,83 @@ +import { autocomplete, intro, outro, spinner } from "@clack/prompts" +import { Effect, Option } from "effect" +import type { IntegrationInfo } from "@opencode-ai/client" +import { Commands } from "../../commands" +import { Runtime } from "../../../framework/runtime" +import { handlePromptErrors, prompt, requireInteractive } from "../../../ui/prompt" +import { createClient, loadIntegrations, location, request, resolveIntegration } from "./shared" + +export default Runtime.handler( + Commands.commands.auth.commands.logout, + Effect.fn("cli.auth.logout")((input) => + logout({ + target: Option.getOrUndefined(input.target), + server: Option.getOrUndefined(input.server), + standalone: input.standalone, + }).pipe(handlePromptErrors), + ), +) + +const logout = Effect.fn("cli.auth.logout.run")(function* (input: { + target?: string + server?: string + standalone: boolean +}) { + if (!input.target) + yield* requireInteractive("Pass an integration ID or name when running without an interactive terminal") + intro("Remove credential") + const client = yield* createClient({ server: input.server, standalone: input.standalone }) + const integrations = yield* loadIntegrations(client) + const integration = yield* chooseIntegration(integrations, input.target) + const credentials = integration.connections.filter((connection) => connection.type === "credential") + if (credentials.length === 0) { + const environment = integration.connections + .filter((connection) => connection.type === "env") + .map((connection) => connection.name) + if (environment.length) { + yield* Effect.fail( + new Error( + `${integration.name} is authenticated through ${environment.join(", ")}; unset the environment variable to disconnect`, + ), + ) + } + yield* Effect.fail(new Error(`No stored credentials for ${integration.name}`)) + } + const progress = spinner() + progress.start("Removing credential...") + yield* Effect.forEach( + credentials, + (connection) => + request((signal) => client.credential.remove({ credentialID: connection.id, location }, { signal })), + { concurrency: "unbounded", discard: true }, + ).pipe( + Effect.tap(() => Effect.sync(() => progress.stop(`Disconnected from ${integration.name}`))), + Effect.tapCause(() => Effect.sync(() => progress.stop("Failed to remove credential", 1))), + ) + outro("Done") +}) + +const chooseIntegration = Effect.fn("cli.auth.logout.integration")(function* ( + integrations: IntegrationInfo[], + target?: string, +) { + if (target) return yield* resolveIntegration(integrations, target) + const configured = integrations.filter((integration) => + integration.connections.some((connection) => connection.type === "credential"), + ) + if (configured.length === 0) return yield* Effect.fail(new Error("No stored credentials found")) + const id = yield* prompt(() => + autocomplete({ + message: "Select integration", + maxItems: 8, + options: configured.map((integration) => ({ + value: integration.id, + label: integration.name, + hint: integration.connections + .filter((connection) => connection.type === "credential") + .map((connection) => connection.label) + .join(", "), + })), + }), + ) + return yield* resolveIntegration(configured, id) +}) diff --git a/packages/cli/src/commands/handlers/auth/shared.ts b/packages/cli/src/commands/handlers/auth/shared.ts new file mode 100644 index 00000000000..753d307e9b8 --- /dev/null +++ b/packages/cli/src/commands/handlers/auth/shared.ts @@ -0,0 +1,64 @@ +import { Effect } from "effect" +import { OpenCode, type IntegrationInfo, type IntegrationMethod, type OpenCodeClient } from "@opencode-ai/client" +import { Service } from "@opencode-ai/client/effect/service" +import { ServerConnection } from "../../../services/server-connection" + +export const location = { directory: process.cwd() } + +export const createClient = Effect.fn("cli.auth.client")(function* (input: ServerConnection.Args) { + const server = yield* ServerConnection.resolve(input) + return OpenCode.make({ baseUrl: server.endpoint.url, headers: Service.headers(server.endpoint) }) +}) + +export function request(run: (signal: AbortSignal) => Promise) { + return Effect.tryPromise({ try: run, catch: (cause) => cause }) +} + +export const loadIntegrations = Effect.fn("cli.auth.integrations")(function* (client: OpenCodeClient) { + // The model endpoint is the existing public readiness boundary for the initial plugin generation. + yield* request((signal) => client.model.default({ location }, { signal })) + return yield* request((signal) => client.integration.list({ location }, { signal })).pipe( + Effect.map((response) => response.data), + ) +}) + +export const resolveIntegration = Effect.fn("cli.auth.resolve-integration")(function* ( + integrations: IntegrationInfo[], + target: string, +) { + const normalized = target.replace(/\/+$/, "") + const byID = integrations.find((integration) => integration.id === normalized) + if (byID) return byID + const matches = integrations.filter((integration) => integration.name.toLowerCase() === normalized.toLowerCase()) + if (matches.length === 1) return matches[0] + if (matches.length > 1) { + return yield* Effect.fail( + new Error( + `Integration name "${target}" is ambiguous: ${matches.map((integration) => integration.id).join(", ")}`, + ), + ) + } + return yield* Effect.fail(new Error(`Integration not found: ${target}`)) +}) + +export type ConnectMethod = Exclude + +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")) +} + +export const resolveMethod = Effect.fn("cli.auth.resolve-method")(function* (methods: ConnectMethod[], target: string) { + const normalized = target.toLowerCase() + const matches = methods.filter((method) => { + if (method.type === "key") return normalized === "key" || method.label?.toLowerCase() === normalized + return method.id === target || method.label.toLowerCase() === normalized + }) + if (matches.length === 1) return matches[0] + if (matches.length > 1) return yield* Effect.fail(new Error(`Authentication method "${target}" is ambiguous`)) + const available = methods.map((method) => (method.type === "key" ? "key" : method.id)).join(", ") + return yield* Effect.fail( + new Error(`Authentication method not found: ${target}${available ? `. Available: ${available}` : ""}`), + ) +}) diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index dd386f58ec2..d25df16d1d9 100755 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -19,7 +19,9 @@ const Handlers = Runtime.handlers(Commands, { acp: () => import("./commands/handlers/acp"), api: () => import("./commands/handlers/api"), auth: { + list: () => import("./commands/handlers/auth/list"), login: () => import("./commands/handlers/auth/login"), + logout: () => import("./commands/handlers/auth/logout"), }, debug: { agents: () => import("./commands/handlers/debug/agents"), diff --git a/packages/cli/src/ui/prompt.ts b/packages/cli/src/ui/prompt.ts new file mode 100644 index 00000000000..8482c16d99d --- /dev/null +++ b/packages/cli/src/ui/prompt.ts @@ -0,0 +1,48 @@ +import { cancel, isCancel, log, outro } from "@clack/prompts" +import { Effect } from "effect" + +const cancelled = Symbol("cancelled") + +export function prompt(run: () => Promise) { + return Effect.tryPromise({ try: run, catch: (cause) => cause }).pipe( + Effect.flatMap((value) => (isCancel(value) ? Effect.fail(cancelled) : Effect.succeed(value))), + ) +} + +export function requireInteractive(message: string) { + if (process.stdin.isTTY && process.stdout.isTTY) return Effect.void + return Effect.fail(new Error(message)) +} + +export const openUrl = Effect.fn("cli.prompt.open-url")(function* (url: string) { + const { default: open } = yield* Effect.promise(() => import("open")) + yield* Effect.promise(() => open(url)).pipe(Effect.ignore) +}) + +export function handlePromptErrors(effect: Effect.Effect) { + return effect.pipe( + Effect.catchIf( + (error) => error === cancelled, + () => + Effect.sync(() => { + cancel("Cancelled") + process.exitCode = 130 + }), + ), + Effect.catch((error) => + Effect.sync(() => { + log.error(errorMessage(error)) + outro("Failed") + process.exitCode = 1 + }), + ), + ) +} + +export function errorMessage(error: unknown) { + if (error instanceof Error) return error.message + if (typeof error === "object" && error !== null && "message" in error && typeof error.message === "string") { + return error.message + } + return String(error) +} diff --git a/packages/cli/test/auth.test.ts b/packages/cli/test/auth.test.ts index 739bcb3dd40..ab777516c2c 100644 --- a/packages/cli/test/auth.test.ts +++ b/packages/cli/test/auth.test.ts @@ -1,20 +1,267 @@ import { describe, expect, test } from "bun:test" import path from "node:path" +import { OPENCODE_VERSION } from "../src/version" describe("auth command", () => { - test("registers login", async () => { - const [auth, login] = await Promise.all([cli(["auth", "--help"]), cli(["auth", "login", "--help"])]) + test("registers authentication commands", async () => { + const [auth, list, login, logout] = await Promise.all([ + cli(["auth", "--help"]), + cli(["auth", "list", "--help"]), + cli(["auth", "login", "--help"]), + cli(["auth", "logout", "--help"]), + ]) expect(auth.exitCode).toBe(0) + expect(auth.stdout).toContain("list") expect(auth.stdout).toContain("login") - expect(auth.stdout).toContain("Log in to a well-known authentication provider") + expect(auth.stdout).toContain("logout") + expect(auth.stdout).toContain("manage AI providers and credentials") + expect(auth.stdout).toContain("list providers and credentials") + expect(auth.stdout).toContain("log in to a provider") + expect(auth.stdout).toContain("log out from a configured provider") expect(auth.stdout).not.toContain("connect") + expect(list.exitCode).toBe(0) + expect(list.stdout).toContain("opencode auth list [flags]") + expect(list.stdout).toContain("--format") expect(login.exitCode).toBe(0) - expect(login.stdout).toContain("opencode auth login [flags] ") - expect(login.stdout).toContain("Well-known provider URL") + expect(login.stdout).toContain("opencode auth login [flags] []") + expect(login.stdout).toContain("Integration ID, name, or well-known provider URL") + expect(login.stdout).toContain("--method") + expect(logout.exitCode).toBe(0) + expect(logout.stdout).toContain("opencode auth logout [flags] []") + }) + + test("lists stored and environment connections", async () => { + const requests: string[] = [] + using server = authServer((request, url) => { + if (url.pathname === "/api/integration") { + return Response.json( + located([ + { + id: "anthropic", + name: "Anthropic", + methods: [], + connections: [ + { type: "credential", id: "cred_test", label: "default" }, + { type: "env", name: "ANTHROPIC_API_KEY" }, + ], + }, + { id: "openai", name: "OpenAI", methods: [], connections: [] }, + ]), + ) + } + return new Response("Not found", { status: 404 }) + }, requests) + + const result = await cli(["auth", "list", "--format", "json", "--server", server.url.toString()]) + expect({ exitCode: result.exitCode, stderr: result.stderr }).toEqual({ exitCode: 0, stderr: "" }) + expect(JSON.parse(result.stdout)).toEqual([ + { + id: "anthropic", + name: "Anthropic", + connections: [ + { type: "credential", id: "cred_test", label: "default" }, + { type: "env", name: "ANTHROPIC_API_KEY" }, + ], + }, + ]) + expect(requests.indexOf("/api/model/default")).toBeLessThan(requests.indexOf("/api/integration")) + }) + + test("runs command authentication without interactive input", async () => { + const requests: Array<{ method: string; path: string }> = [] + using server = authServer((request, url) => { + requests.push({ method: request.method, path: url.pathname }) + if (url.pathname === "/api/integration") { + return Response.json( + located([ + { + id: "company", + name: "Company", + methods: [{ id: "login", type: "command", label: "Company login", command: ["company", "login"] }], + connections: [], + }, + ]), + ) + } + if (url.pathname === "/api/integration/company/connect/command" && request.method === "POST") { + return Response.json(located({ attemptID: "con_test", time: { created: 1, expires: 2 } })) + } + if (url.pathname === "/api/integration/company/connect/command/con_test" && request.method === "GET") { + return Response.json(located({ status: "complete", time: { created: 1, expires: 2 } })) + } + if (url.pathname === "/api/integration/company/connect/command/con_test" && request.method === "DELETE") { + return new Response(null, { status: 204 }) + } + return new Response("Not found", { status: 404 }) + }) + + const result = await cli(["auth", "login", "company", "--server", server.url.toString()]) + expect({ exitCode: result.exitCode, stderr: result.stderr }).toEqual({ exitCode: 0, stderr: "" }) + expect(result.stdout).toContain("Connected to Company") + expect(requests).toContainEqual({ method: "POST", path: "/api/integration/company/connect/command" }) + expect(requests).toContainEqual({ method: "GET", path: "/api/integration/company/connect/command/con_test" }) + expect(requests).toContainEqual({ method: "DELETE", path: "/api/integration/company/connect/command/con_test" }) + }) + + test("completes automatic OAuth authentication", async () => { + const requests: Array<{ method: string; path: string }> = [] + using server = authServer((request, url) => { + requests.push({ method: request.method, path: url.pathname }) + if (url.pathname === "/api/integration") { + return Response.json( + located([ + { + id: "openai", + name: "OpenAI", + methods: [{ id: "browser", type: "oauth", label: "Browser" }], + connections: [], + }, + ]), + ) + } + if (url.pathname === "/api/integration/openai/connect/oauth" && request.method === "POST") { + return Response.json( + located({ + attemptID: "con_oauth", + url: "https://example.com/authorize", + instructions: "Authorize OpenAI", + mode: "auto", + time: { created: 1, expires: 2 }, + }), + ) + } + if (url.pathname === "/api/integration/openai/connect/oauth/con_oauth" && request.method === "GET") { + return Response.json(located({ status: "complete", time: { created: 1, expires: 2 } })) + } + if (url.pathname === "/api/integration/openai/connect/oauth/con_oauth" && request.method === "DELETE") { + return new Response(null, { status: 204 }) + } + return new Response("Not found", { status: 404 }) + }) + + const result = await cli(["auth", "login", "openai", "--server", server.url.toString()]) + expect({ exitCode: result.exitCode, stderr: result.stderr }).toEqual({ exitCode: 0, stderr: "" }) + expect(result.stdout).toContain("https://example.com/authorize") + expect(result.stdout).toContain("Connected to OpenAI") + expect(requests).toContainEqual({ method: "POST", path: "/api/integration/openai/connect/oauth" }) + expect(requests).toContainEqual({ method: "GET", path: "/api/integration/openai/connect/oauth/con_oauth" }) + expect(requests).toContainEqual({ method: "DELETE", path: "/api/integration/openai/connect/oauth/con_oauth" }) + }) + + test("settles the OAuth spinner when status polling fails", async () => { + using server = authServer((request, url) => { + if (url.pathname === "/api/integration") { + return Response.json( + located([ + { + id: "openai", + name: "OpenAI", + methods: [{ id: "browser", type: "oauth", label: "Browser" }], + connections: [], + }, + ]), + ) + } + if (url.pathname === "/api/integration/openai/connect/oauth" && request.method === "POST") { + return Response.json( + located({ + attemptID: "con_oauth", + url: "https://example.com/authorize", + instructions: "Authorize OpenAI", + mode: "auto", + time: { created: 1, expires: 2 }, + }), + ) + } + if (url.pathname === "/api/integration/openai/connect/oauth/con_oauth" && request.method === "GET") { + return new Response("Unavailable", { status: 500 }) + } + if (url.pathname === "/api/integration/openai/connect/oauth/con_oauth" && request.method === "DELETE") { + return new Response(null, { status: 204 }) + } + return new Response("Not found", { status: 404 }) + }) + + const result = await cli(["auth", "login", "openai", "--server", server.url.toString()]) + expect(result.exitCode).toBe(1) + expect(result.stdout).toContain("Authentication failed") + expect(result.stdout).toContain("Failed") + expect(result.stdout).not.toContain("\n at ") + }) + + test("removes stored credentials", async () => { + const removed: string[] = [] + using server = authServer((request, url) => { + if (url.pathname === "/api/integration") { + return Response.json( + located([ + { + id: "anthropic", + name: "Anthropic", + methods: [{ type: "key" }], + connections: [{ type: "credential", id: "cred_test", label: "default" }], + }, + ]), + ) + } + if (url.pathname === "/api/credential/cred_test" && request.method === "DELETE") { + removed.push("cred_test") + return new Response(null, { status: 204 }) + } + return new Response("Not found", { status: 404 }) + }) + + const result = await cli(["auth", "logout", "anthropic", "--server", server.url.toString()]) + expect({ exitCode: result.exitCode, stderr: result.stderr }).toEqual({ exitCode: 0, stderr: "" }) + expect(result.stdout).toContain("Disconnected from Anthropic") + expect(removed).toEqual(["cred_test"]) + }) + + test("requires a target outside an interactive terminal", async () => { + const result = await cli(["auth", "login"]) + expect(result.exitCode).toBe(1) + expect(result.stdout).toContain("Pass an integration ID or name") + expect(result.stdout).not.toContain("Background service failed to start") + }) + + test("reports list connection failures without a stack trace", async () => { + using server = Bun.serve({ port: 0, fetch: () => new Response("Unavailable", { status: 503 }) }) + const result = await cli(["auth", "list", "--server", server.url.toString()]) + expect(result.exitCode).toBe(1) + expect(result.stdout).toBe("") + expect(result.stderr).toContain("did not provide a compatible V2 health response") + expect(result.stderr).not.toContain("\n at ") }) }) +function authServer(fetch: (request: Request, url: URL) => Response | Promise, requests?: string[]) { + return Bun.serve({ + port: 0, + fetch(request) { + const url = new URL(request.url) + requests?.push(url.pathname) + if (url.pathname === "/api/health") return health() + if (url.pathname === "/api/model/default") return Response.json(located(null)) + return fetch(request, url) + }, + }) +} + +function health() { + return Response.json({ healthy: true, version: OPENCODE_VERSION, pid: process.pid }) +} + +function located(data: T) { + return { + location: { + directory: process.cwd(), + project: { id: "project", directory: process.cwd(), canonical: process.cwd() }, + }, + data, + } +} + async function cli(args: string[]) { const child = Bun.spawn([process.execPath, "run", "src/index.ts", ...args], { cwd: path.join(import.meta.dir, ".."),