mirror of
https://gitee.com/zhangsken/opencode.git
synced 2026-10-16 10:30:01 +08:00
feat(cli): add auth account switching and targeted logout (#48050)
This commit is contained in:
@@ -123,13 +123,31 @@ const Root = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME
|
||||
},
|
||||
}),
|
||||
Spec.make("logout", {
|
||||
description: "log out from a configured provider",
|
||||
description: "log out of a saved account",
|
||||
params: {
|
||||
...ServerParams,
|
||||
target: Argument.string("target").pipe(
|
||||
Argument.withDescription("Integration ID or name"),
|
||||
Argument.optional,
|
||||
),
|
||||
credential: Argument.string("credential").pipe(
|
||||
Argument.withDescription("Credential ID or label (opens an account picker when omitted)"),
|
||||
Argument.optional,
|
||||
),
|
||||
},
|
||||
}),
|
||||
Spec.make("switch", {
|
||||
description: "switch the active account for an integration",
|
||||
params: {
|
||||
...ServerParams,
|
||||
target: Argument.string("target").pipe(
|
||||
Argument.withDescription("Integration ID or name"),
|
||||
Argument.optional,
|
||||
),
|
||||
credential: Argument.string("credential").pipe(
|
||||
Argument.withDescription("Credential ID or label (opens an account picker when omitted)"),
|
||||
Argument.optional,
|
||||
),
|
||||
},
|
||||
}),
|
||||
],
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { autocomplete } from "@clack/prompts"
|
||||
import { Effect } from "effect"
|
||||
import type { IntegrationInfo } from "@opencode/client"
|
||||
import { prompt } from "../../../ui/prompt"
|
||||
import { resolveIntegration } from "./shared"
|
||||
|
||||
export const chooseIntegration = Effect.fn("cli.auth.account.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<string>(() =>
|
||||
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)
|
||||
})
|
||||
|
||||
export const chooseCredential = Effect.fn("cli.auth.account.credential")(function* (
|
||||
integration: IntegrationInfo,
|
||||
action: "log out" | "switch to",
|
||||
target?: string,
|
||||
) {
|
||||
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 && action === "log out") {
|
||||
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}`))
|
||||
}
|
||||
if (target) {
|
||||
const byID = credentials.find((credential) => credential.id === target)
|
||||
if (byID) return byID.id
|
||||
const matches = credentials.filter((credential) => credential.label.toLowerCase() === target.toLowerCase())
|
||||
if (matches.length === 1) return matches[0].id
|
||||
if (matches.length > 1)
|
||||
return yield* Effect.fail(
|
||||
new Error(`Credential label "${target}" is ambiguous. Use an ID: ${matches.map((item) => item.id).join(", ")}`),
|
||||
)
|
||||
return yield* Effect.fail(new Error(`Credential not found for ${integration.name}: ${target}`))
|
||||
}
|
||||
return yield* prompt<string>(() =>
|
||||
autocomplete({
|
||||
message: `Select ${integration.name} account to ${action}`,
|
||||
maxItems: 8,
|
||||
options: credentials.map((credential, index) => ({
|
||||
value: credential.id,
|
||||
label: index === 0 ? `${credential.label} (active)` : credential.label,
|
||||
hint: credential.id,
|
||||
})),
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -1,16 +1,17 @@
|
||||
import { autocomplete, intro, outro, spinner } from "@clack/prompts"
|
||||
import { intro, outro, spinner } from "@clack/prompts"
|
||||
import { Effect, Option } from "effect"
|
||||
import type { IntegrationInfo } from "@opencode/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"
|
||||
import { handlePromptErrors, requireInteractive } from "../../../ui/prompt"
|
||||
import { createClient, loadIntegrations, location, request } from "./shared"
|
||||
import { chooseCredential, chooseIntegration } from "./account"
|
||||
|
||||
export default Runtime.handler(
|
||||
Commands.commands.auth.commands.logout,
|
||||
Effect.fn("cli.auth.logout")((input) =>
|
||||
logout({
|
||||
target: Option.getOrUndefined(input.target),
|
||||
credential: Option.getOrUndefined(input.credential),
|
||||
server: Option.getOrUndefined(input.server),
|
||||
standalone: input.standalone,
|
||||
}).pipe(handlePromptErrors),
|
||||
@@ -19,65 +20,24 @@ export default Runtime.handler(
|
||||
|
||||
const logout = Effect.fn("cli.auth.logout.run")(function* (input: {
|
||||
target?: string
|
||||
credential?: 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")
|
||||
if (!input.credential)
|
||||
yield* requireInteractive("Pass a credential ID or label when running without an interactive terminal")
|
||||
intro("Log out of an account")
|
||||
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 credentialID = yield* chooseCredential(integration, "log out", input.credential)
|
||||
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}`))),
|
||||
yield* request((signal) => client.credential.remove({ credentialID, location }, { signal })).pipe(
|
||||
Effect.tap(() => Effect.sync(() => progress.stop(`Removed account 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<string>(() =>
|
||||
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)
|
||||
})
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { intro, outro, spinner } from "@clack/prompts"
|
||||
import { Effect, Option } from "effect"
|
||||
import { Commands } from "../../commands"
|
||||
import { Runtime } from "../../../framework/runtime"
|
||||
import { handlePromptErrors, requireInteractive } from "../../../ui/prompt"
|
||||
import { createClient, loadIntegrations, location, request } from "./shared"
|
||||
import { chooseCredential, chooseIntegration } from "./account"
|
||||
|
||||
export default Runtime.handler(
|
||||
Commands.commands.auth.commands.switch,
|
||||
Effect.fn("cli.auth.switch")((input) =>
|
||||
switchAccount({
|
||||
target: Option.getOrUndefined(input.target),
|
||||
credential: Option.getOrUndefined(input.credential),
|
||||
server: Option.getOrUndefined(input.server),
|
||||
standalone: input.standalone,
|
||||
}).pipe(handlePromptErrors),
|
||||
),
|
||||
)
|
||||
|
||||
const switchAccount = Effect.fn("cli.auth.switch.run")(function* (input: {
|
||||
target?: string
|
||||
credential?: string
|
||||
server?: string
|
||||
standalone: boolean
|
||||
}) {
|
||||
if (!input.target)
|
||||
yield* requireInteractive("Pass an integration ID or name when running without an interactive terminal")
|
||||
if (!input.credential)
|
||||
yield* requireInteractive("Pass a credential ID or label when running without an interactive terminal")
|
||||
intro("Switch account")
|
||||
const client = yield* createClient({ server: input.server, standalone: input.standalone })
|
||||
const integrations = yield* loadIntegrations(client)
|
||||
const integration = yield* chooseIntegration(integrations, input.target)
|
||||
const credentialID = yield* chooseCredential(integration, "switch to", input.credential)
|
||||
const progress = spinner()
|
||||
progress.start("Switching account...")
|
||||
yield* request((signal) => client.credential.activate({ credentialID, location }, { signal })).pipe(
|
||||
Effect.tap(() => Effect.sync(() => progress.stop(`Switched account for ${integration.name}`))),
|
||||
Effect.tapCause(() => Effect.sync(() => progress.stop("Failed to switch account", 1))),
|
||||
)
|
||||
outro("Done")
|
||||
})
|
||||
@@ -24,6 +24,7 @@ const Handlers = Runtime.handlers(Commands, {
|
||||
list: () => import("./commands/handlers/auth/list"),
|
||||
login: () => import("./commands/handlers/auth/login"),
|
||||
logout: () => import("./commands/handlers/auth/logout"),
|
||||
switch: () => import("./commands/handlers/auth/switch"),
|
||||
},
|
||||
debug: {
|
||||
agents: () => import("./commands/handlers/debug/agents"),
|
||||
|
||||
Reference in New Issue
Block a user