Skip to content

Commit 594635b

Browse files
authored
feat(cli): add auth account switching and targeted logout (#48050)
1 parent 18b05e8 commit 594635b

5 files changed

Lines changed: 149 additions & 53 deletions

File tree

‎packages/cli/src/commands/commands.ts‎

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -123,13 +123,31 @@ const Root = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME
123123
},
124124
}),
125125
Spec.make("logout", {
126-
description: "log out from a configured provider",
126+
description: "log out of a saved account",
127127
params: {
128128
...ServerParams,
129129
target: Argument.string("target").pipe(
130130
Argument.withDescription("Integration ID or name"),
131131
Argument.optional,
132132
),
133+
credential: Argument.string("credential").pipe(
134+
Argument.withDescription("Credential ID or label (opens an account picker when omitted)"),
135+
Argument.optional,
136+
),
137+
},
138+
}),
139+
Spec.make("switch", {
140+
description: "switch the active account for an integration",
141+
params: {
142+
...ServerParams,
143+
target: Argument.string("target").pipe(
144+
Argument.withDescription("Integration ID or name"),
145+
Argument.optional,
146+
),
147+
credential: Argument.string("credential").pipe(
148+
Argument.withDescription("Credential ID or label (opens an account picker when omitted)"),
149+
Argument.optional,
150+
),
133151
},
134152
}),
135153
],
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import { autocomplete } from "@clack/prompts"
2+
import { Effect } from "effect"
3+
import type { IntegrationInfo } from "@opencode/client"
4+
import { prompt } from "../../../ui/prompt"
5+
import { resolveIntegration } from "./shared"
6+
7+
export const chooseIntegration = Effect.fn("cli.auth.account.integration")(function* (
8+
integrations: IntegrationInfo[],
9+
target?: string,
10+
) {
11+
if (target) return yield* resolveIntegration(integrations, target)
12+
const configured = integrations.filter((integration) =>
13+
integration.connections.some((connection) => connection.type === "credential"),
14+
)
15+
if (configured.length === 0) return yield* Effect.fail(new Error("No stored credentials found"))
16+
const id = yield* prompt<string>(() =>
17+
autocomplete({
18+
message: "Select integration",
19+
maxItems: 8,
20+
options: configured.map((integration) => ({
21+
value: integration.id,
22+
label: integration.name,
23+
hint: integration.connections
24+
.filter((connection) => connection.type === "credential")
25+
.map((connection) => connection.label)
26+
.join(", "),
27+
})),
28+
}),
29+
)
30+
return yield* resolveIntegration(configured, id)
31+
})
32+
33+
export const chooseCredential = Effect.fn("cli.auth.account.credential")(function* (
34+
integration: IntegrationInfo,
35+
action: "log out" | "switch to",
36+
target?: string,
37+
) {
38+
const credentials = integration.connections.filter((connection) => connection.type === "credential")
39+
if (credentials.length === 0) {
40+
const environment = integration.connections
41+
.filter((connection) => connection.type === "env")
42+
.map((connection) => connection.name)
43+
if (environment.length && action === "log out") {
44+
yield* Effect.fail(
45+
new Error(
46+
`${integration.name} is authenticated through ${environment.join(", ")}; unset the environment variable to disconnect`,
47+
),
48+
)
49+
}
50+
yield* Effect.fail(new Error(`No stored credentials for ${integration.name}`))
51+
}
52+
if (target) {
53+
const byID = credentials.find((credential) => credential.id === target)
54+
if (byID) return byID.id
55+
const matches = credentials.filter((credential) => credential.label.toLowerCase() === target.toLowerCase())
56+
if (matches.length === 1) return matches[0].id
57+
if (matches.length > 1)
58+
return yield* Effect.fail(
59+
new Error(`Credential label "${target}" is ambiguous. Use an ID: ${matches.map((item) => item.id).join(", ")}`),
60+
)
61+
return yield* Effect.fail(new Error(`Credential not found for ${integration.name}: ${target}`))
62+
}
63+
return yield* prompt<string>(() =>
64+
autocomplete({
65+
message: `Select ${integration.name} account to ${action}`,
66+
maxItems: 8,
67+
options: credentials.map((credential, index) => ({
68+
value: credential.id,
69+
label: index === 0 ? `${credential.label} (active)` : credential.label,
70+
hint: credential.id,
71+
})),
72+
}),
73+
)
74+
})
Lines changed: 12 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,17 @@
1-
import { autocomplete, intro, outro, spinner } from "@clack/prompts"
1+
import { intro, outro, spinner } from "@clack/prompts"
22
import { Effect, Option } from "effect"
3-
import type { IntegrationInfo } from "@opencode/client"
43
import { Commands } from "../../commands"
54
import { Runtime } from "../../../framework/runtime"
6-
import { handlePromptErrors, prompt, requireInteractive } from "../../../ui/prompt"
7-
import { createClient, loadIntegrations, location, request, resolveIntegration } from "./shared"
5+
import { handlePromptErrors, requireInteractive } from "../../../ui/prompt"
6+
import { createClient, loadIntegrations, location, request } from "./shared"
7+
import { chooseCredential, chooseIntegration } from "./account"
88

99
export default Runtime.handler(
1010
Commands.commands.auth.commands.logout,
1111
Effect.fn("cli.auth.logout")((input) =>
1212
logout({
1313
target: Option.getOrUndefined(input.target),
14+
credential: Option.getOrUndefined(input.credential),
1415
server: Option.getOrUndefined(input.server),
1516
standalone: input.standalone,
1617
}).pipe(handlePromptErrors),
@@ -19,65 +20,24 @@ export default Runtime.handler(
1920

2021
const logout = Effect.fn("cli.auth.logout.run")(function* (input: {
2122
target?: string
23+
credential?: string
2224
server?: string
2325
standalone: boolean
2426
}) {
2527
if (!input.target)
2628
yield* requireInteractive("Pass an integration ID or name when running without an interactive terminal")
27-
intro("Remove credential")
29+
if (!input.credential)
30+
yield* requireInteractive("Pass a credential ID or label when running without an interactive terminal")
31+
intro("Log out of an account")
2832
const client = yield* createClient({ server: input.server, standalone: input.standalone })
2933
const integrations = yield* loadIntegrations(client)
3034
const integration = yield* chooseIntegration(integrations, input.target)
31-
const credentials = integration.connections.filter((connection) => connection.type === "credential")
32-
if (credentials.length === 0) {
33-
const environment = integration.connections
34-
.filter((connection) => connection.type === "env")
35-
.map((connection) => connection.name)
36-
if (environment.length) {
37-
yield* Effect.fail(
38-
new Error(
39-
`${integration.name} is authenticated through ${environment.join(", ")}; unset the environment variable to disconnect`,
40-
),
41-
)
42-
}
43-
yield* Effect.fail(new Error(`No stored credentials for ${integration.name}`))
44-
}
35+
const credentialID = yield* chooseCredential(integration, "log out", input.credential)
4536
const progress = spinner()
4637
progress.start("Removing credential...")
47-
yield* Effect.forEach(
48-
credentials,
49-
(connection) =>
50-
request((signal) => client.credential.remove({ credentialID: connection.id, location }, { signal })),
51-
{ concurrency: "unbounded", discard: true },
52-
).pipe(
53-
Effect.tap(() => Effect.sync(() => progress.stop(`Disconnected from ${integration.name}`))),
38+
yield* request((signal) => client.credential.remove({ credentialID, location }, { signal })).pipe(
39+
Effect.tap(() => Effect.sync(() => progress.stop(`Removed account from ${integration.name}`))),
5440
Effect.tapCause(() => Effect.sync(() => progress.stop("Failed to remove credential", 1))),
5541
)
5642
outro("Done")
5743
})
58-
59-
const chooseIntegration = Effect.fn("cli.auth.logout.integration")(function* (
60-
integrations: IntegrationInfo[],
61-
target?: string,
62-
) {
63-
if (target) return yield* resolveIntegration(integrations, target)
64-
const configured = integrations.filter((integration) =>
65-
integration.connections.some((connection) => connection.type === "credential"),
66-
)
67-
if (configured.length === 0) return yield* Effect.fail(new Error("No stored credentials found"))
68-
const id = yield* prompt<string>(() =>
69-
autocomplete({
70-
message: "Select integration",
71-
maxItems: 8,
72-
options: configured.map((integration) => ({
73-
value: integration.id,
74-
label: integration.name,
75-
hint: integration.connections
76-
.filter((connection) => connection.type === "credential")
77-
.map((connection) => connection.label)
78-
.join(", "),
79-
})),
80-
}),
81-
)
82-
return yield* resolveIntegration(configured, id)
83-
})
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import { intro, outro, spinner } from "@clack/prompts"
2+
import { Effect, Option } from "effect"
3+
import { Commands } from "../../commands"
4+
import { Runtime } from "../../../framework/runtime"
5+
import { handlePromptErrors, requireInteractive } from "../../../ui/prompt"
6+
import { createClient, loadIntegrations, location, request } from "./shared"
7+
import { chooseCredential, chooseIntegration } from "./account"
8+
9+
export default Runtime.handler(
10+
Commands.commands.auth.commands.switch,
11+
Effect.fn("cli.auth.switch")((input) =>
12+
switchAccount({
13+
target: Option.getOrUndefined(input.target),
14+
credential: Option.getOrUndefined(input.credential),
15+
server: Option.getOrUndefined(input.server),
16+
standalone: input.standalone,
17+
}).pipe(handlePromptErrors),
18+
),
19+
)
20+
21+
const switchAccount = Effect.fn("cli.auth.switch.run")(function* (input: {
22+
target?: string
23+
credential?: string
24+
server?: string
25+
standalone: boolean
26+
}) {
27+
if (!input.target)
28+
yield* requireInteractive("Pass an integration ID or name when running without an interactive terminal")
29+
if (!input.credential)
30+
yield* requireInteractive("Pass a credential ID or label when running without an interactive terminal")
31+
intro("Switch account")
32+
const client = yield* createClient({ server: input.server, standalone: input.standalone })
33+
const integrations = yield* loadIntegrations(client)
34+
const integration = yield* chooseIntegration(integrations, input.target)
35+
const credentialID = yield* chooseCredential(integration, "switch to", input.credential)
36+
const progress = spinner()
37+
progress.start("Switching account...")
38+
yield* request((signal) => client.credential.activate({ credentialID, location }, { signal })).pipe(
39+
Effect.tap(() => Effect.sync(() => progress.stop(`Switched account for ${integration.name}`))),
40+
Effect.tapCause(() => Effect.sync(() => progress.stop("Failed to switch account", 1))),
41+
)
42+
outro("Done")
43+
})

‎packages/cli/src/index.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ const Handlers = Runtime.handlers(Commands, {
2424
list: () => import("./commands/handlers/auth/list"),
2525
login: () => import("./commands/handlers/auth/login"),
2626
logout: () => import("./commands/handlers/auth/logout"),
27+
switch: () => import("./commands/handlers/auth/switch"),
2728
},
2829
debug: {
2930
agents: () => import("./commands/handlers/debug/agents"),

0 commit comments

Comments
 (0)