diff --git a/packages/cli/src/commands/commands.ts b/packages/cli/src/commands/commands.ts index f5622d215fa..6adc263a692 100644 --- a/packages/cli/src/commands/commands.ts +++ b/packages/cli/src/commands/commands.ts @@ -183,6 +183,26 @@ const Root = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME ), }, }), + Spec.make("export", { + description: "print stored credentials, including secrets, as JSON", + params: { + ...ServerParams, + target: Argument.string("target").pipe( + Argument.withDescription("Integration ID or name (exports every integration when omitted)"), + Argument.optional, + ), + }, + }), + Spec.make("import", { + description: "import credentials exported by auth export", + params: { + ...ServerParams, + file: Argument.string("file").pipe( + Argument.withDescription("JSON file to import (reads stdin when omitted)"), + Argument.optional, + ), + }, + }), Spec.make("switch", { description: "switch the active account for an integration", params: { diff --git a/packages/cli/src/commands/handlers/auth/export.ts b/packages/cli/src/commands/handlers/auth/export.ts new file mode 100644 index 00000000000..d95336496c9 --- /dev/null +++ b/packages/cli/src/commands/handlers/auth/export.ts @@ -0,0 +1,31 @@ +import { EOL } from "node:os" +import { Effect, Option } from "effect" +import { Commands } from "../../commands" +import { Runtime } from "../../../framework/runtime" +import { createClient, loadIntegrations, request, resolveIntegration } from "./shared" +import { errorMessage } from "../../../util/error" + +export default Runtime.handler( + Commands.commands.auth.commands.export, + Effect.fn("cli.auth.export")( + function* (input) { + const client = yield* createClient({ server: Option.getOrUndefined(input.server), standalone: input.standalone }) + const target = Option.getOrUndefined(input.target) + const integrationID = target ? (yield* resolveIntegration(yield* loadIntegrations(client), target)).id : undefined + const credentials = (yield* request((signal) => client.credential.list({ signal }))).filter( + (credential) => !integrationID || credential.integrationID === integrationID, + ) + if (process.stdout.isTTY) + process.stderr.write( + "Warning: the output contains secrets; redirect it to a file or pipe it to auth import" + EOL, + ) + process.stdout.write(JSON.stringify(credentials, null, 2) + EOL) + }, + Effect.catch((error) => + Effect.sync(() => { + process.stderr.write(errorMessage(error) + EOL) + process.exitCode = 1 + }), + ), + ), +) diff --git a/packages/cli/src/commands/handlers/auth/import.ts b/packages/cli/src/commands/handlers/auth/import.ts new file mode 100644 index 00000000000..e1434a24bb0 --- /dev/null +++ b/packages/cli/src/commands/handlers/auth/import.ts @@ -0,0 +1,58 @@ +import { EOL } from "node:os" +import { isConflictError, type CredentialCreateInput } from "@opencode/client" +import { Credential } from "@opencode/schema/credential" +import { Effect, Option, Schema } from "effect" +import { Commands } from "../../commands" +import { Runtime } from "../../../framework/runtime" +import { createClient, request } from "./shared" +import { errorMessage } from "../../../util/error" +import { readStdin } from "../../../util/io" + +export default Runtime.handler( + Commands.commands.auth.commands.import, + Effect.fn("cli.auth.import")( + function* (input) { + const file = Option.getOrUndefined(input.file) + if (!file && process.stdin.isTTY) + return yield* Effect.fail(new Error("Pipe auth export output into stdin or pass a file to import")) + const text = yield* request(() => (file ? Bun.file(file).text() : readStdin())) + const credentials = yield* Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.Array(Credential.Entry)))(text) + const client = yield* createClient({ server: Option.getOrUndefined(input.server), standalone: input.standalone }) + const existing = yield* request((signal) => client.credential.list({ signal })) + const ids = new Set(existing.map((credential) => credential.id)) + const integrations = new Set(existing.map((credential) => credential.integrationID)) + const results = yield* Effect.forEach(credentials, (credential) => { + if (ids.has(credential.id)) return Effect.succeed(false) + return request((signal) => + client.credential.create( + { + id: credential.id, + integrationID: credential.integrationID, + label: credential.label, + // Stored metadata is JSON, but the credential schema types it as unknown while the generated client expects JSON. + value: credential.value as CredentialCreateInput["value"], + // Keep the destination's current selections; only integrations new to it adopt the exported selection. + activate: credential.active && !integrations.has(credential.integrationID), + }, + { signal }, + ), + ).pipe( + Effect.as(true), + Effect.catchIf(isConflictError, () => Effect.succeed(false)), + ) + }) + const imported = results.filter(Boolean).length + process.stderr.write( + `Imported ${imported} ${imported === 1 ? "credential" : "credentials"}` + + (results.length > imported ? `, skipped ${results.length - imported} already present` : "") + + EOL, + ) + }, + Effect.catch((error) => + Effect.sync(() => { + process.stderr.write(errorMessage(error) + EOL) + process.exitCode = 1 + }), + ), + ), +) diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 9bdd0dcb32b..0592f3c94d7 100755 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -31,6 +31,8 @@ const Handlers = Runtime.handlers(Commands, { list: () => import("./commands/handlers/auth/list"), login: () => import("./commands/handlers/auth/login"), logout: () => import("./commands/handlers/auth/logout"), + export: () => import("./commands/handlers/auth/export"), + import: () => import("./commands/handlers/auth/import"), switch: () => import("./commands/handlers/auth/switch"), }, debug: { diff --git a/packages/client/src/effect/api/api.ts b/packages/client/src/effect/api/api.ts index d3cf01ed138..cc721aeb956 100644 --- a/packages/client/src/effect/api/api.ts +++ b/packages/client/src/effect/api/api.ts @@ -1743,6 +1743,21 @@ export interface McpApi { readonly resource: { readonly catalog: McpResourceCatalogOperation } } +export type CredentialListOutput = ReadonlyArray +export type CredentialListOperation = () => Effect.Effect + +export type CredentialCreateInput = { + readonly id?: Credential.ID | undefined + readonly integrationID: Integration.ID + readonly label?: string | undefined + readonly value: Credential.Value + readonly activate?: boolean | undefined +} +export type CredentialCreateOutput = Credential.Entry +export type CredentialCreateOperation = ( + input: CredentialCreateInput, +) => Effect.Effect + export type CredentialUpdateInput = { readonly credentialID: Credential.ID; readonly label: string } export type CredentialUpdateOutput = void export type CredentialUpdateOperation = ( @@ -1762,6 +1777,8 @@ export type CredentialRemoveOperation = ( ) => Effect.Effect export interface CredentialApi { + readonly list: CredentialListOperation + readonly create: CredentialCreateOperation readonly update: CredentialUpdateOperation readonly activate: CredentialActivateOperation readonly remove: CredentialRemoveOperation diff --git a/packages/client/src/effect/generated/client.ts b/packages/client/src/effect/generated/client.ts index 94f8c30f422..9b553dfbd9c 100644 --- a/packages/client/src/effect/generated/client.ts +++ b/packages/client/src/effect/generated/client.ts @@ -153,6 +153,9 @@ import type { McpDisconnectOutput, McpResourceCatalogInput, McpResourceCatalogOutput, + CredentialListOutput, + CredentialCreateInput, + CredentialCreateOutput, CredentialUpdateInput, CredentialUpdateOutput, CredentialActivateInput, @@ -1021,6 +1024,30 @@ const adaptGroupMcp = (raw: RawClient["server.mcp"]) => ({ resource: { catalog: EndpointMcpResourceCatalog(raw) }, }) +const EndpointCredentialList = (raw: RawClient["server.credential"]) => () => + preserveEffect()( + raw["credential.list"]({}).pipe( + Effect.mapError(mapClientError), + Effect.map((value) => value.data), + ), + ) + +const EndpointCredentialCreate = (raw: RawClient["server.credential"]) => (input: CredentialCreateInput) => + preserveEffect()( + raw["credential.create"]({ + payload: { + id: input["id"], + integrationID: input["integrationID"], + label: input["label"], + value: input["value"], + activate: input["activate"], + }, + }).pipe( + Effect.mapError(mapClientError), + Effect.map((value) => value.data), + ), + ) + const EndpointCredentialUpdate = (raw: RawClient["server.credential"]) => (input: CredentialUpdateInput) => preserveEffect()( raw["credential.update"]({ @@ -1042,6 +1069,8 @@ const EndpointCredentialRemove = (raw: RawClient["server.credential"]) => (input ) const adaptGroupCredential = (raw: RawClient["server.credential"]) => ({ + list: EndpointCredentialList(raw), + create: EndpointCredentialCreate(raw), update: EndpointCredentialUpdate(raw), activate: EndpointCredentialActivate(raw), remove: EndpointCredentialRemove(raw), diff --git a/packages/client/src/promise/generated/client.ts b/packages/client/src/promise/generated/client.ts index 60653cc7333..e78020b7442 100644 --- a/packages/client/src/promise/generated/client.ts +++ b/packages/client/src/promise/generated/client.ts @@ -147,6 +147,9 @@ import type { McpDisconnectOutput, McpResourceCatalogInput, McpResourceCatalogOutput, + CredentialListOutput, + CredentialCreateInput, + CredentialCreateOutput, CredentialUpdateInput, CredentialUpdateOutput, CredentialActivateInput, @@ -1392,6 +1395,29 @@ export function make(options: ClientOptions) { }, }, credential: { + list: (requestOptions?: RequestOptions) => + request<{ readonly data: CredentialListOutput }>( + { method: "GET", path: `/api/credential`, successStatus: 200, declaredStatuses: [400, 401], empty: false }, + requestOptions, + ).then((value) => value.data), + create: (input: CredentialCreateInput, requestOptions?: RequestOptions) => + request<{ readonly data: CredentialCreateOutput }>( + { + method: "POST", + path: `/api/credential`, + body: { + id: input["id"], + integrationID: input["integrationID"], + label: input["label"], + value: input["value"], + activate: input["activate"], + }, + successStatus: 200, + declaredStatuses: [400, 401, 409], + empty: false, + }, + requestOptions, + ).then((value) => value.data), update: (input: CredentialUpdateInput, requestOptions?: RequestOptions) => request( { diff --git a/packages/client/src/promise/generated/types.ts b/packages/client/src/promise/generated/types.ts index 1704e549c4d..217cc570e5b 100644 --- a/packages/client/src/promise/generated/types.ts +++ b/packages/client/src/promise/generated/types.ts @@ -293,6 +293,15 @@ export type McpResourceTemplate = { mimeType?: string } +export type CredentialOAuth = { + type: "oauth" + methodID: string + refresh: string + access: string + expires: number + metadata?: { [x: string]: JsonValue } +} + export type ProjectVcs = string export type ProjectIcon = { url?: string; override?: string; color?: string } @@ -1656,7 +1665,7 @@ export type FormMultiselectField1 = { default?: Array } -export type FormAnswer1 = { [x: string]: FormValue1 } +export type FormAnswer2 = { [x: string]: FormValue1 } export type SessionStatusUpdated = { id: string @@ -1890,6 +1899,13 @@ export type FormField = export type FormState = { status: "pending" } | { status: "answered"; answer: FormAnswer } | { status: "cancelled" } +export type CredentialKey = { + type: "key" + key: string + metadata?: { [x: string]: JsonValue } + configuration?: FormAnswer +} + export type ConnectionInfo = ConnectionCredentialInfo | ConnectionEnvInfo export type FormField1 = @@ -1906,7 +1922,7 @@ export type FormReplied = { metadata?: { [x: string]: any } type: "form.replied" location?: LocationRef - data: { id: string; sessionID: string; answer: FormAnswer1 } + data: { id: string; sessionID: string; answer: FormAnswer2 } } export type ReferenceInfo = { @@ -2225,6 +2241,8 @@ export type SessionMessageAssistantTool1 = { export type FormFields = [FormField, ...Array] +export type CredentialValue = CredentialOAuth | CredentialKey + export type FormFields2 = [FormField1, ...Array] export type SessionsResponse = { data: Array; cursor: { previous?: string | null; next?: string | null } } @@ -2279,6 +2297,14 @@ export type IntegrationOAuthMethod = { id: string; type: "oauth"; label: string; export type IntegrationKeyMethod = { type: "key"; label?: string; form?: FormFields } +export type CredentialEntry = { + id: string + integrationID: string + label: string + active: boolean + value: CredentialValue +} + export type FormInfo1 = { id: string; sessionID: string; title: string; metadata?: FormMetadata1; fields: FormFields2 } export type SessionMessageInfo = @@ -5750,6 +5776,128 @@ export type McpResourceCatalogInput = { export type McpResourceCatalogOutput = { location: LocationPublicRef; data: McpResourceCatalog } +export type CredentialListOutput = { data: Array }["data"] + +export type CredentialCreateInput = { + readonly id?: { + readonly id?: string + readonly integrationID: string + readonly label?: string + readonly value: + | { + readonly type: "oauth" + readonly methodID: string + readonly refresh: string + readonly access: string + readonly expires: number + readonly metadata?: { readonly [x: string]: JsonValue } + } + | { + readonly type: "key" + readonly key: string + readonly metadata?: { readonly [x: string]: JsonValue } + readonly configuration?: { + readonly [x: string]: string | number | "Infinity" | "-Infinity" | "NaN" | boolean | ReadonlyArray + } + } + readonly activate?: boolean + }["id"] + readonly integrationID: { + readonly id?: string + readonly integrationID: string + readonly label?: string + readonly value: + | { + readonly type: "oauth" + readonly methodID: string + readonly refresh: string + readonly access: string + readonly expires: number + readonly metadata?: { readonly [x: string]: JsonValue } + } + | { + readonly type: "key" + readonly key: string + readonly metadata?: { readonly [x: string]: JsonValue } + readonly configuration?: { + readonly [x: string]: string | number | "Infinity" | "-Infinity" | "NaN" | boolean | ReadonlyArray + } + } + readonly activate?: boolean + }["integrationID"] + readonly label?: { + readonly id?: string + readonly integrationID: string + readonly label?: string + readonly value: + | { + readonly type: "oauth" + readonly methodID: string + readonly refresh: string + readonly access: string + readonly expires: number + readonly metadata?: { readonly [x: string]: JsonValue } + } + | { + readonly type: "key" + readonly key: string + readonly metadata?: { readonly [x: string]: JsonValue } + readonly configuration?: { + readonly [x: string]: string | number | "Infinity" | "-Infinity" | "NaN" | boolean | ReadonlyArray + } + } + readonly activate?: boolean + }["label"] + readonly value: { + readonly id?: string + readonly integrationID: string + readonly label?: string + readonly value: + | { + readonly type: "oauth" + readonly methodID: string + readonly refresh: string + readonly access: string + readonly expires: number + readonly metadata?: { readonly [x: string]: JsonValue } + } + | { + readonly type: "key" + readonly key: string + readonly metadata?: { readonly [x: string]: JsonValue } + readonly configuration?: { + readonly [x: string]: string | number | "Infinity" | "-Infinity" | "NaN" | boolean | ReadonlyArray + } + } + readonly activate?: boolean + }["value"] + readonly activate?: { + readonly id?: string + readonly integrationID: string + readonly label?: string + readonly value: + | { + readonly type: "oauth" + readonly methodID: string + readonly refresh: string + readonly access: string + readonly expires: number + readonly metadata?: { readonly [x: string]: JsonValue } + } + | { + readonly type: "key" + readonly key: string + readonly metadata?: { readonly [x: string]: JsonValue } + readonly configuration?: { + readonly [x: string]: string | number | "Infinity" | "-Infinity" | "NaN" | boolean | ReadonlyArray + } + } + readonly activate?: boolean + }["activate"] +} + +export type CredentialCreateOutput = { data: CredentialEntry }["data"] + export type CredentialUpdateInput = { readonly credentialID: { readonly credentialID: string }["credentialID"] readonly label: { readonly label: string }["label"] diff --git a/packages/core/src/credential.ts b/packages/core/src/credential.ts index 4ea4e985abb..c6323614b6f 100644 --- a/packages/core/src/credential.ts +++ b/packages/core/src/credential.ts @@ -38,11 +38,16 @@ export interface Interface { readonly list: (integrationID: Integration.ID) => Effect.Effect /** Returns one stored credential by ID. */ readonly get: (id: ID) => Effect.Effect - /** Creates a credential for an integration and returns the new record. */ + /** + * Creates a credential for an integration and returns the new record. The new credential becomes the + * integration's selection unless `activate` is false and the integration already has a credential. + */ readonly create: (input: { + readonly id?: ID readonly integrationID: Integration.ID readonly value: Value readonly label?: string + readonly activate?: boolean }) => Effect.Effect /** Selects a stored credential for its integration. */ readonly activate: (id: ID) => Effect.Effect @@ -99,19 +104,30 @@ const layer = Layer.effect( }), create: Effect.fn("Credential.create")(function* (input) { const credential = new Info({ - id: ID.create(), + id: input.id ?? ID.create(), integrationID: input.integrationID, label: input.label ?? "default", value: input.value, }) - yield* db + const activated = yield* db .transaction((tx) => Effect.gen(function* () { - yield* tx - .update(CredentialTable) - .set({ active: false }) + const current = yield* tx + .select({ id: CredentialTable.id, active: CredentialTable.active }) + .from(CredentialTable) .where(eq(CredentialTable.integration_id, credential.integrationID)) - .run() + .orderBy(desc(CredentialTable.active), desc(CredentialTable.time_created), desc(CredentialTable.id)) + .get() + const activate = input.activate !== false || !current + if (activate) + yield* tx + .update(CredentialTable) + .set({ active: false }) + .where(eq(CredentialTable.integration_id, credential.integrationID)) + .run() + // Legacy rows have no active flag and the newest one is selected, so pin it before inserting a newer row. + if (!activate && current && !current.active) + yield* tx.update(CredentialTable).set({ active: true }).where(eq(CredentialTable.id, current.id)).run() yield* tx .insert(CredentialTable) .values({ @@ -119,9 +135,10 @@ const layer = Layer.effect( integration_id: credential.integrationID, label: credential.label, value: credential.value, - active: true, + active: activate, }) .run() + return activate }), ) .pipe( @@ -138,13 +155,15 @@ const layer = Layer.effect( credentialID: credential.id, integrationID: credential.integrationID, type: credential.value.type, + active: activated, }) yield* bus.publish(Event.Updated, {}, { global: true }) - yield* bus.publish( - Event.Switched, - { integrationID: credential.integrationID, credentialID: credential.id }, - { global: true }, - ) + if (activated) + yield* bus.publish( + Event.Switched, + { integrationID: credential.integrationID, credentialID: credential.id }, + { global: true }, + ) return credential }), activate: Effect.fn("Credential.activate")(function* (id) { diff --git a/packages/core/test/credential.test.ts b/packages/core/test/credential.test.ts index f68e635c4da..ed970d0fc84 100644 --- a/packages/core/test/credential.test.ts +++ b/packages/core/test/credential.test.ts @@ -199,4 +199,49 @@ describe("Credential", () => { expect((yield* credentials.list(otherIntegrationID)).at(-1)).toEqual(otherOlder) }), ) + + it.effect("creates credentials with a requested ID without taking over the current selection", () => + Effect.gen(function* () { + const credentials = yield* Credential.Service + const bus = yield* Bus.Service + const database = yield* Database.Service + const integrationID = Integration.ID.make("openai") + const events = new Array() + yield* bus.listen((event) => Effect.sync(() => events.push(event))) + + const first = yield* credentials.create({ + id: Credential.ID.make("cred_0000imported"), + integrationID, + value: Credential.Key.make({ type: "key", key: "first" }), + activate: false, + }) + expect(first.id).toBe(Credential.ID.make("cred_0000imported")) + expect((yield* credentials.list(integrationID)).at(-1)).toEqual(first) + + const legacy = yield* credentials.create({ + integrationID, + value: Credential.Key.make({ type: "key", key: "legacy" }), + }) + yield* database.db + .update(CredentialTable) + .set({ active: null }) + .where(eq(CredentialTable.integration_id, integrationID)) + .run() + .pipe(Effect.orDie) + const imported = yield* credentials.create({ + integrationID, + value: Credential.Key.make({ type: "key", key: "imported" }), + activate: false, + }) + + expect(yield* credentials.list(integrationID)).toEqual([first, imported, legacy]) + expect(events.map((event) => ({ type: event.type, data: event.data }))).toEqual([ + { type: Credential.Event.Updated.type, data: {} }, + { type: Credential.Event.Switched.type, data: { integrationID, credentialID: first.id } }, + { type: Credential.Event.Updated.type, data: {} }, + { type: Credential.Event.Switched.type, data: { integrationID, credentialID: legacy.id } }, + { type: Credential.Event.Updated.type, data: {} }, + ]) + }), + ) }) diff --git a/packages/protocol/src/groups/credential.ts b/packages/protocol/src/groups/credential.ts index bda39c33902..7832916326e 100644 --- a/packages/protocol/src/groups/credential.ts +++ b/packages/protocol/src/groups/credential.ts @@ -1,8 +1,34 @@ import { Credential } from "@opencode/schema/credential" import { Schema } from "effect" import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi" +import { ConflictError } from "../errors.js" export const CredentialGroup = HttpApiGroup.make("server.credential") + .add( + HttpApiEndpoint.get("credential.list", "/api/credential", { + success: Schema.Struct({ data: Schema.Array(Credential.Entry) }), + }).annotateMerge( + OpenApi.annotations({ + identifier: "credential.list", + summary: "List credentials", + description: "List every stored integration credential, including its secret value.", + }), + ), + ) + .add( + HttpApiEndpoint.post("credential.create", "/api/credential", { + payload: Credential.CreateInput, + success: Schema.Struct({ data: Credential.Entry }), + error: ConflictError, + }).annotateMerge( + OpenApi.annotations({ + identifier: "credential.create", + summary: "Create credential", + description: + "Store an integration credential. It becomes the integration's active credential unless activate is false and the integration already has one. Fails with a conflict when the requested ID already exists.", + }), + ), + ) .add( HttpApiEndpoint.patch("credential.update", "/api/credential/:credentialID", { params: { credentialID: Credential.ID }, diff --git a/packages/schema/src/credential.ts b/packages/schema/src/credential.ts index 93a16fd0151..408ca68e8ac 100644 --- a/packages/schema/src/credential.ts +++ b/packages/schema/src/credential.ts @@ -50,3 +50,21 @@ export const Value = Schema.Union([OAuth, Key]) .pipe(Schema.toTaggedUnion("type")) .annotate({ identifier: "Credential.Value" }) export type Value = Schema.Schema.Type + +export interface Entry extends Schema.Schema.Type {} +export const Entry = Schema.Struct({ + id: ID, + integrationID: IntegrationID, + label: Schema.String, + active: Schema.Boolean, + value: Value, +}).annotate({ identifier: "Credential.Entry" }) + +export interface CreateInput extends Schema.Schema.Type {} +export const CreateInput = Schema.Struct({ + id: optional(ID), + integrationID: IntegrationID, + label: optional(Schema.String), + value: Value, + activate: optional(Schema.Boolean), +}).annotate({ identifier: "Credential.CreateInput" }) diff --git a/packages/server/src/handlers/credential.ts b/packages/server/src/handlers/credential.ts index 225f72eb087..a2cf7158181 100644 --- a/packages/server/src/handlers/credential.ts +++ b/packages/server/src/handlers/credential.ts @@ -1,10 +1,32 @@ import { Credential } from "@opencode/core/credential" +import { ConflictError } from "@opencode/protocol/errors" import { Effect } from "effect" import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi" import { Api } from "../api" export const CredentialHandler = HttpApiBuilder.group(Api, "server.credential", (handlers) => handlers + .handle( + "credential.list", + Effect.fn(function* () { + const credential = yield* Credential.Service + return { data: entries(yield* credential.all()) } + }), + ) + .handle( + "credential.create", + Effect.fn(function* (ctx) { + const credential = yield* Credential.Service + if (ctx.payload.id && (yield* credential.get(ctx.payload.id))) + return yield* new ConflictError({ + resource: ctx.payload.id, + message: `Credential already exists: ${ctx.payload.id}`, + }) + const created = yield* credential.create(ctx.payload) + const entry = entries(yield* credential.list(created.integrationID)).find((item) => item.id === created.id) + return { data: entry ?? { ...created, active: false } } + }), + ) .handle( "credential.update", Effect.fn(function* (ctx) { @@ -30,3 +52,15 @@ export const CredentialHandler = HttpApiBuilder.group(Api, "server.credential", }), ), ) + +// Credential listings order each integration's selected credential last. +function entries(credentials: Credential.Info[]) { + const selected = new Map(credentials.map((item) => [item.integrationID, item.id])) + return credentials.map((item) => ({ + id: item.id, + integrationID: item.integrationID, + label: item.label, + active: selected.get(item.integrationID) === item.id, + value: item.value, + })) +}