mirror of
https://gitee.com/zhangsken/opencode.git
synced 2026-10-16 10:30:01 +08:00
feat(tui): support multiple integration accounts (#45072)
This commit is contained in:
@@ -1333,6 +1333,15 @@ export type CredentialUpdateOperation<E = never> = (
|
||||
input: CredentialUpdateInput,
|
||||
) => Effect.Effect<CredentialUpdateOutput, E>
|
||||
|
||||
export type CredentialActivateInput = {
|
||||
readonly credentialID: Credential.ID
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
}
|
||||
export type CredentialActivateOutput = void
|
||||
export type CredentialActivateOperation<E = never> = (
|
||||
input: CredentialActivateInput,
|
||||
) => Effect.Effect<CredentialActivateOutput, E>
|
||||
|
||||
export type CredentialRemoveInput = {
|
||||
readonly credentialID: Credential.ID
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
@@ -1344,6 +1353,7 @@ export type CredentialRemoveOperation<E = never> = (
|
||||
|
||||
export interface CredentialApi<E = never> {
|
||||
readonly update: CredentialUpdateOperation<E>
|
||||
readonly activate: CredentialActivateOperation<E>
|
||||
readonly remove: CredentialRemoveOperation<E>
|
||||
}
|
||||
|
||||
|
||||
@@ -138,6 +138,8 @@ import type {
|
||||
McpResourceCatalogOutput,
|
||||
CredentialUpdateInput,
|
||||
CredentialUpdateOutput,
|
||||
CredentialActivateInput,
|
||||
CredentialActivateOutput,
|
||||
CredentialRemoveInput,
|
||||
CredentialRemoveOutput,
|
||||
ProjectListOutput,
|
||||
@@ -935,6 +937,14 @@ const EndpointCredentialUpdate = (raw: RawClient["server.credential"]) => (input
|
||||
}).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
const EndpointCredentialActivate = (raw: RawClient["server.credential"]) => (input: CredentialActivateInput) =>
|
||||
preserveEffect<CredentialActivateOutput>()(
|
||||
raw["credential.activate"]({
|
||||
params: { credentialID: input["credentialID"] },
|
||||
query: { location: input["location"] },
|
||||
}).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
const EndpointCredentialRemove = (raw: RawClient["server.credential"]) => (input: CredentialRemoveInput) =>
|
||||
preserveEffect<CredentialRemoveOutput>()(
|
||||
raw["credential.remove"]({
|
||||
@@ -945,6 +955,7 @@ const EndpointCredentialRemove = (raw: RawClient["server.credential"]) => (input
|
||||
|
||||
const adaptGroupCredential = (raw: RawClient["server.credential"]) => ({
|
||||
update: EndpointCredentialUpdate(raw),
|
||||
activate: EndpointCredentialActivate(raw),
|
||||
remove: EndpointCredentialRemove(raw),
|
||||
})
|
||||
|
||||
|
||||
@@ -132,6 +132,8 @@ import type {
|
||||
McpResourceCatalogOutput,
|
||||
CredentialUpdateInput,
|
||||
CredentialUpdateOutput,
|
||||
CredentialActivateInput,
|
||||
CredentialActivateOutput,
|
||||
CredentialRemoveInput,
|
||||
CredentialRemoveOutput,
|
||||
ProjectListOutput,
|
||||
@@ -1270,6 +1272,18 @@ export function make(options: ClientOptions) {
|
||||
},
|
||||
requestOptions,
|
||||
),
|
||||
activate: (input: CredentialActivateInput, requestOptions?: RequestOptions) =>
|
||||
request<CredentialActivateOutput>(
|
||||
{
|
||||
method: "POST",
|
||||
path: `/api/credential/${encodeURIComponent(input.credentialID)}/activate`,
|
||||
query: { location: input["location"] },
|
||||
successStatus: 204,
|
||||
declaredStatuses: [401, 400],
|
||||
empty: true,
|
||||
},
|
||||
requestOptions,
|
||||
),
|
||||
remove: (input: CredentialRemoveInput, requestOptions?: RequestOptions) =>
|
||||
request<CredentialRemoveOutput>(
|
||||
{
|
||||
|
||||
@@ -823,6 +823,24 @@ export type ModelsDevRefreshed = {
|
||||
data: {}
|
||||
}
|
||||
|
||||
export type CredentialUpdated = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
type: "credential.updated"
|
||||
location?: LocationRef
|
||||
data: {}
|
||||
}
|
||||
|
||||
export type CredentialSwitched = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
type: "credential.switched"
|
||||
location?: LocationRef
|
||||
data: { integrationID: string; credentialID: string | null }
|
||||
}
|
||||
|
||||
export type IntegrationUpdated = {
|
||||
id: string
|
||||
created: number
|
||||
@@ -832,15 +850,6 @@ export type IntegrationUpdated = {
|
||||
data: {}
|
||||
}
|
||||
|
||||
export type IntegrationConnectionUpdated = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
type: "integration.connection.updated"
|
||||
location?: LocationRef
|
||||
data: { integrationID: string }
|
||||
}
|
||||
|
||||
export type CatalogUpdated = {
|
||||
id: string
|
||||
created: number
|
||||
@@ -2129,8 +2138,9 @@ export type IntegrationInfo = {
|
||||
|
||||
export type V2Event =
|
||||
| ModelsDevRefreshed
|
||||
| CredentialUpdated
|
||||
| CredentialSwitched
|
||||
| IntegrationUpdated
|
||||
| IntegrationConnectionUpdated
|
||||
| CatalogUpdated
|
||||
| AgentUpdated
|
||||
| SessionCreated
|
||||
@@ -4371,6 +4381,15 @@ export type CredentialUpdateInput = {
|
||||
|
||||
export type CredentialUpdateOutput = void
|
||||
|
||||
export type CredentialActivateInput = {
|
||||
readonly credentialID: { readonly credentialID: string }["credentialID"]
|
||||
readonly location?: {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
}["location"]
|
||||
}
|
||||
|
||||
export type CredentialActivateOutput = void
|
||||
|
||||
export type CredentialRemoveInput = {
|
||||
readonly credentialID: { readonly credentialID: string }["credentialID"]
|
||||
readonly location?: {
|
||||
|
||||
@@ -1048,6 +1048,36 @@ export function createData(config: CreateDataInput) {
|
||||
return
|
||||
}
|
||||
|
||||
if (event.type === "credential.updated" || event.type === "credential.switched") {
|
||||
Object.keys(store.location).forEach((key) => {
|
||||
const ref = JSON.parse(key) as [string, string | null]
|
||||
const location = { directory: ref[0], workspaceID: ref[1] ?? undefined }
|
||||
if (event.type === "credential.updated") {
|
||||
result.location.integration.invalidate(location)
|
||||
void result.location.integration.sync(location)
|
||||
return
|
||||
}
|
||||
setStore("location", key, (data) => ({
|
||||
...data,
|
||||
integration: data?.integration?.map((integration) => {
|
||||
if (integration.id !== event.data.integrationID) return integration
|
||||
const active = integration.connections.find(
|
||||
(connection) => connection.type === "credential" && connection.id === event.data.credentialID,
|
||||
)
|
||||
if (!active) return integration
|
||||
return {
|
||||
...integration,
|
||||
connections: [active, ...integration.connections.filter((connection) => connection !== active)],
|
||||
}
|
||||
}),
|
||||
}))
|
||||
result.location.model.invalidate(location)
|
||||
result.location.provider.invalidate(location)
|
||||
void Promise.all([result.location.model.sync(location), result.location.provider.sync(location)])
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (!event.location) return
|
||||
const location = event.location
|
||||
switch (event.type) {
|
||||
|
||||
@@ -26,6 +26,7 @@ test("exposes every standard HTTP API group", () => {
|
||||
"skill",
|
||||
"event",
|
||||
"pty",
|
||||
"experimental",
|
||||
"shell",
|
||||
"reference",
|
||||
"worktree",
|
||||
@@ -187,6 +188,24 @@ test("experimental wellknown integration add uses the public HTTP contract", asy
|
||||
expect(await request?.json()).toEqual({ url: "https://example.com" })
|
||||
})
|
||||
|
||||
test("credential.activate uses the public HTTP contract", async () => {
|
||||
let request: Request | undefined
|
||||
const client = OpenCode.make({
|
||||
baseUrl: "http://localhost:3000",
|
||||
fetch: async (input, init) => {
|
||||
request = input instanceof Request ? input : new Request(input, init)
|
||||
return new Response(null, { status: 204 })
|
||||
},
|
||||
})
|
||||
|
||||
await client.credential.activate({ credentialID: "cred_work", location: { directory: "/tmp/project" } })
|
||||
|
||||
expect(request?.method).toBe("POST")
|
||||
expect(request?.url).toBe(
|
||||
"http://localhost:3000/api/credential/cred_work/activate?location%5Bdirectory%5D=%2Ftmp%2Fproject",
|
||||
)
|
||||
})
|
||||
|
||||
test("integration connections optionally submit a form answer", async () => {
|
||||
const requests: Request[] = []
|
||||
const client = OpenCode.make({
|
||||
|
||||
@@ -154,6 +154,103 @@ test("updates authoritative cached project metadata from live events", async ()
|
||||
}
|
||||
})
|
||||
|
||||
test("refreshes global credential events across every loaded location and workspace", async () => {
|
||||
const listeners = new Set<Parameters<CreateDataInput["event"]["listen"]>[0]>()
|
||||
const requests: URL[] = []
|
||||
const api = OpenCode.make({
|
||||
baseUrl: "http://opencode.local",
|
||||
fetch: async (input, init) => {
|
||||
const request = input instanceof Request ? input : new Request(input, init)
|
||||
const url = new URL(request.url)
|
||||
requests.push(url)
|
||||
const directory = url.searchParams.get("location[directory]") ?? "/project"
|
||||
return Response.json({
|
||||
location: {
|
||||
directory,
|
||||
workspaceID: url.searchParams.get("location[workspace]") ?? undefined,
|
||||
project: { id: "project", directory, canonical: directory },
|
||||
},
|
||||
data: [],
|
||||
})
|
||||
},
|
||||
})
|
||||
const setup = createRoot((dispose) => ({
|
||||
data: createData({
|
||||
api: () => api,
|
||||
directory: "/project",
|
||||
event: {
|
||||
on: () => () => {},
|
||||
listen(handler) {
|
||||
listeners.add(handler)
|
||||
return () => listeners.delete(handler)
|
||||
},
|
||||
},
|
||||
connection: { status: () => "connected" },
|
||||
}),
|
||||
dispose,
|
||||
}))
|
||||
const locations = [{ directory: "/project" }, { directory: "/other", workspaceID: "workspace-other" }]
|
||||
|
||||
try {
|
||||
await Promise.all(
|
||||
locations.flatMap((location) => [
|
||||
setup.data.location.integration.sync(location),
|
||||
setup.data.location.model.sync(location),
|
||||
setup.data.location.provider.sync(location),
|
||||
]),
|
||||
)
|
||||
requests.length = 0
|
||||
|
||||
const updated: OpenCodeEvent = {
|
||||
id: "evt_credential.updated",
|
||||
created: 1,
|
||||
type: "credential.updated",
|
||||
data: {},
|
||||
}
|
||||
listeners.forEach((listener) => listener({ name: updated.type, details: updated }))
|
||||
await wait(() => requests.length === 2)
|
||||
expect(
|
||||
requests.map((url) => [
|
||||
url.pathname,
|
||||
url.searchParams.get("location[directory]"),
|
||||
url.searchParams.get("location[workspace]"),
|
||||
]),
|
||||
).toEqual([
|
||||
["/api/integration", "/project", null],
|
||||
["/api/integration", "/other", "workspace-other"],
|
||||
])
|
||||
requests.length = 0
|
||||
|
||||
for (const credentialID of ["credential", null]) {
|
||||
const switched: OpenCodeEvent = {
|
||||
id: `evt_credential.switched.${credentialID}`,
|
||||
created: 2,
|
||||
type: "credential.switched",
|
||||
data: { credentialID, integrationID: "integration" },
|
||||
}
|
||||
listeners.forEach((listener) => listener({ name: switched.type, details: switched }))
|
||||
await wait(() => requests.length === 4)
|
||||
expect(
|
||||
requests.map((url) => [
|
||||
url.pathname,
|
||||
url.searchParams.get("location[directory]"),
|
||||
url.searchParams.get("location[workspace]"),
|
||||
]),
|
||||
).toEqual(
|
||||
expect.arrayContaining([
|
||||
["/api/model", "/project", null],
|
||||
["/api/provider", "/project", null],
|
||||
["/api/model", "/other", "workspace-other"],
|
||||
["/api/provider", "/other", "workspace-other"],
|
||||
]),
|
||||
)
|
||||
requests.length = 0
|
||||
}
|
||||
} finally {
|
||||
setup.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
test("reports optimistic sessions as creating until the request settles", async () => {
|
||||
const release = Promise.withResolvers<void>()
|
||||
const api = OpenCode.make({
|
||||
|
||||
+13
-10
@@ -89,6 +89,7 @@ export interface PublishOptions {
|
||||
readonly id?: Event.ID
|
||||
readonly metadata?: Record<string, unknown>
|
||||
readonly location?: Location.Ref
|
||||
readonly global?: boolean
|
||||
/** Local operational projection committed atomically with a new durable event. Not replayed or serialized. */
|
||||
readonly commit?: (seq: number) => Effect.Effect<void>
|
||||
}
|
||||
@@ -446,11 +447,12 @@ export function configured(options?: Options) {
|
||||
function publish<D extends Event.Definition>(definition: D, data: Event.Data<D>, options?: PublishOptions) {
|
||||
return Effect.gen(function* () {
|
||||
const serviceLocation = Option.getOrUndefined(yield* Effect.serviceOption(Location.Service))
|
||||
const location =
|
||||
options?.location ??
|
||||
(serviceLocation
|
||||
? { directory: serviceLocation.directory, workspaceID: serviceLocation.workspaceID }
|
||||
: undefined)
|
||||
const location = options?.global
|
||||
? undefined
|
||||
: (options?.location ??
|
||||
(serviceLocation
|
||||
? { directory: serviceLocation.directory, workspaceID: serviceLocation.workspaceID }
|
||||
: undefined))
|
||||
return yield* publishEvent(
|
||||
definition,
|
||||
{
|
||||
@@ -480,11 +482,12 @@ export function configured(options?: Options) {
|
||||
}),
|
||||
)
|
||||
}
|
||||
const location =
|
||||
options?.location ??
|
||||
(serviceLocation
|
||||
? { directory: serviceLocation.directory, workspaceID: serviceLocation.workspaceID }
|
||||
: undefined)
|
||||
const location = options?.global
|
||||
? undefined
|
||||
: (options?.location ??
|
||||
(serviceLocation
|
||||
? { directory: serviceLocation.directory, workspaceID: serviceLocation.workspaceID }
|
||||
: undefined))
|
||||
return {
|
||||
definition,
|
||||
aggregateID,
|
||||
|
||||
@@ -16,7 +16,6 @@ import {
|
||||
type Entry,
|
||||
Event,
|
||||
} from "@opencode-ai/schema/config"
|
||||
import { Integration } from "@opencode-ai/schema/integration"
|
||||
import { isRecord } from "@opencode-ai/ai/utils/record"
|
||||
import { Credential } from "./credential.js"
|
||||
import { Bus } from "./bus.js"
|
||||
@@ -159,9 +158,7 @@ export const layer = (options?: Options) =>
|
||||
const loadWellknownEntry = Effect.fnUntraced(function* (entry: WellKnown.Entry) {
|
||||
const auth = entry.manifest.auth
|
||||
if (!auth) return []
|
||||
const credential = (yield* credentials.list(entry.integrationID)).findLast(
|
||||
(credential) => credential.value.type === "key",
|
||||
)
|
||||
const credential = (yield* credentials.list(entry.integrationID)).at(-1)
|
||||
if (!credential || credential.value.type !== "key") return []
|
||||
const variables = { [auth.env]: credential.value.key }
|
||||
const configs = yield* wellknown
|
||||
@@ -343,7 +340,7 @@ export const layer = (options?: Options) =>
|
||||
),
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
yield* bus.subscribe(Integration.Event.ConnectionUpdated).pipe(
|
||||
yield* bus.subscribe(Credential.Event.Switched).pipe(
|
||||
Stream.filterEffect((event) =>
|
||||
wellknown.entries().pipe(
|
||||
Effect.map((entries) => entries.some((entry) => entry.integrationID === event.data.integrationID)),
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
export * as Credential from "./credential.js"
|
||||
|
||||
import { asc, eq } from "drizzle-orm"
|
||||
import { asc, desc, eq } from "drizzle-orm"
|
||||
import { Context, Effect, Layer, Schema } from "effect"
|
||||
import { Credential } from "@opencode-ai/schema/credential"
|
||||
import { Integration } from "@opencode-ai/schema/integration"
|
||||
import { Database } from "./database/database.js"
|
||||
import { Bus } from "./bus.js"
|
||||
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { CredentialTable } from "./credential/sql.js"
|
||||
|
||||
@@ -20,6 +21,8 @@ export type Key = Credential.Key
|
||||
export const Value = Credential.Value
|
||||
export type Value = Credential.Value
|
||||
|
||||
export const Event = Credential.Event
|
||||
|
||||
export class Info extends Schema.Class<Info>("Credential.Info")({
|
||||
id: ID,
|
||||
integrationID: Integration.ID,
|
||||
@@ -34,12 +37,14 @@ export interface Interface {
|
||||
readonly list: (integrationID: Integration.ID) => Effect.Effect<Info[]>
|
||||
/** Returns one stored credential by ID. */
|
||||
readonly get: (id: ID) => Effect.Effect<Info | undefined>
|
||||
/** Replaces any credential for an integration and returns the new record. */
|
||||
/** Creates a credential for an integration and returns the new record. */
|
||||
readonly create: (input: {
|
||||
readonly integrationID: Integration.ID
|
||||
readonly value: Value
|
||||
readonly label?: string
|
||||
}) => Effect.Effect<Info>
|
||||
/** Selects a stored credential for its integration. */
|
||||
readonly activate: (id: ID) => Effect.Effect<void>
|
||||
/** Updates the label or secret value of a stored credential. */
|
||||
readonly update: (id: ID, updates: Partial<Pick<Info, "label" | "value">>) => Effect.Effect<void>
|
||||
/** Removes a stored credential. */
|
||||
@@ -52,6 +57,7 @@ const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const db = (yield* Database.Service).db
|
||||
const bus = yield* Bus.Service
|
||||
const decode = Schema.decodeUnknownSync(Value)
|
||||
const stored = (row: typeof CredentialTable.$inferSelect) => {
|
||||
if (!row.integration_id) return
|
||||
@@ -73,7 +79,7 @@ const layer = Layer.effect(
|
||||
db
|
||||
.select()
|
||||
.from(CredentialTable)
|
||||
.orderBy(asc(CredentialTable.time_created))
|
||||
.orderBy(asc(CredentialTable.active), asc(CredentialTable.time_created), asc(CredentialTable.id))
|
||||
.all()
|
||||
.pipe(Effect.orDie, Effect.map(storedRows)),
|
||||
),
|
||||
@@ -82,7 +88,7 @@ const layer = Layer.effect(
|
||||
.select()
|
||||
.from(CredentialTable)
|
||||
.where(eq(CredentialTable.integration_id, integrationID))
|
||||
.orderBy(asc(CredentialTable.time_created))
|
||||
.orderBy(asc(CredentialTable.active), asc(CredentialTable.time_created), asc(CredentialTable.id))
|
||||
.all()
|
||||
.pipe(Effect.orDie, Effect.map(storedRows)),
|
||||
),
|
||||
@@ -101,7 +107,8 @@ const layer = Layer.effect(
|
||||
.transaction((tx) =>
|
||||
Effect.gen(function* () {
|
||||
yield* tx
|
||||
.delete(CredentialTable)
|
||||
.update(CredentialTable)
|
||||
.set({ active: false })
|
||||
.where(eq(CredentialTable.integration_id, credential.integrationID))
|
||||
.run()
|
||||
yield* tx
|
||||
@@ -111,27 +118,117 @@ const layer = Layer.effect(
|
||||
integration_id: credential.integrationID,
|
||||
label: credential.label,
|
||||
value: credential.value,
|
||||
active: true,
|
||||
})
|
||||
.run()
|
||||
}),
|
||||
)
|
||||
.pipe(Effect.orDie)
|
||||
yield* bus.publish(Event.Updated, {}, { global: true })
|
||||
yield* bus.publish(
|
||||
Event.Switched,
|
||||
{ integrationID: credential.integrationID, credentialID: credential.id },
|
||||
{ global: true },
|
||||
)
|
||||
return credential
|
||||
}),
|
||||
activate: Effect.fn("Credential.activate")(function* (id) {
|
||||
const integrationID = yield* db
|
||||
.transaction((tx) =>
|
||||
Effect.gen(function* () {
|
||||
const credential = yield* tx.select().from(CredentialTable).where(eq(CredentialTable.id, id)).get()
|
||||
if (!credential?.integration_id) return
|
||||
const active = yield* tx
|
||||
.select({ id: CredentialTable.id })
|
||||
.from(CredentialTable)
|
||||
.where(eq(CredentialTable.integration_id, credential.integration_id))
|
||||
.orderBy(desc(CredentialTable.active), desc(CredentialTable.time_created), desc(CredentialTable.id))
|
||||
.get()
|
||||
if (active?.id === id) return
|
||||
yield* tx
|
||||
.update(CredentialTable)
|
||||
.set({ active: false })
|
||||
.where(eq(CredentialTable.integration_id, credential.integration_id))
|
||||
.run()
|
||||
yield* tx.update(CredentialTable).set({ active: true }).where(eq(CredentialTable.id, id)).run()
|
||||
return credential.integration_id
|
||||
}),
|
||||
)
|
||||
.pipe(Effect.orDie)
|
||||
if (integrationID) yield* bus.publish(Event.Switched, { integrationID, credentialID: id }, { global: true })
|
||||
}),
|
||||
update: Effect.fn("Credential.update")(function* (id, updates) {
|
||||
if (!updates.label && !updates.value) return
|
||||
if (updates.label === undefined && updates.value === undefined) return
|
||||
const credential = yield* db
|
||||
.select({ integrationID: CredentialTable.integration_id, label: CredentialTable.label })
|
||||
.from(CredentialTable)
|
||||
.where(eq(CredentialTable.id, id))
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
if (!credential?.integrationID) return
|
||||
if (updates.label === credential.label && updates.value === undefined) return
|
||||
yield* db
|
||||
.update(CredentialTable)
|
||||
.set({ label: updates.label, value: updates.value })
|
||||
.where(eq(CredentialTable.id, id))
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
if (updates.label !== undefined && updates.label !== credential.label)
|
||||
yield* bus.publish(Event.Updated, {}, { global: true })
|
||||
}),
|
||||
remove: Effect.fn("Credential.remove")(function* (id) {
|
||||
yield* db.delete(CredentialTable).where(eq(CredentialTable.id, id)).run().pipe(Effect.orDie)
|
||||
const removed = yield* db
|
||||
.transaction((tx) =>
|
||||
Effect.gen(function* () {
|
||||
const credential = yield* tx.select().from(CredentialTable).where(eq(CredentialTable.id, id)).get()
|
||||
if (!credential) return
|
||||
const active = credential.integration_id
|
||||
? yield* tx
|
||||
.select({ id: CredentialTable.id })
|
||||
.from(CredentialTable)
|
||||
.where(eq(CredentialTable.integration_id, credential.integration_id))
|
||||
.orderBy(desc(CredentialTable.active), desc(CredentialTable.time_created), desc(CredentialTable.id))
|
||||
.get()
|
||||
: undefined
|
||||
yield* tx.delete(CredentialTable).where(eq(CredentialTable.id, id)).run()
|
||||
if (!credential.integration_id || active?.id !== id) return { switched: false as const }
|
||||
const replacement = yield* tx
|
||||
.select({ id: CredentialTable.id })
|
||||
.from(CredentialTable)
|
||||
.where(eq(CredentialTable.integration_id, credential.integration_id))
|
||||
.orderBy(desc(CredentialTable.time_created), desc(CredentialTable.id))
|
||||
.get()
|
||||
if (replacement) {
|
||||
yield* tx
|
||||
.update(CredentialTable)
|
||||
.set({ active: false })
|
||||
.where(eq(CredentialTable.integration_id, credential.integration_id))
|
||||
.run()
|
||||
yield* tx
|
||||
.update(CredentialTable)
|
||||
.set({ active: true })
|
||||
.where(eq(CredentialTable.id, replacement.id))
|
||||
.run()
|
||||
}
|
||||
return {
|
||||
switched: true as const,
|
||||
integrationID: credential.integration_id,
|
||||
credentialID: replacement?.id ?? null,
|
||||
}
|
||||
}),
|
||||
)
|
||||
.pipe(Effect.orDie)
|
||||
if (!removed) return
|
||||
yield* bus.publish(Event.Updated, {}, { global: true })
|
||||
if (removed.switched)
|
||||
yield* bus.publish(
|
||||
Event.Switched,
|
||||
{ integrationID: removed.integrationID, credentialID: removed.credentialID },
|
||||
{ global: true },
|
||||
)
|
||||
}),
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
export const node = makeGlobalNode({ service: Service, layer, deps: [Database.node] })
|
||||
export const node = makeGlobalNode({ service: Service, layer, deps: [Database.node, Bus.node] })
|
||||
|
||||
@@ -166,6 +166,8 @@ export interface Interface extends State.Transformable<Draft> {
|
||||
/** User-facing label for the stored credential. */
|
||||
readonly label?: string
|
||||
}) => Effect.Effect<void, AuthorizationError>
|
||||
/** Selects a stored credential as the active integration connection. */
|
||||
readonly activate: (credentialID: Credential.ID) => Effect.Effect<void>
|
||||
/** Updates a stored credential exposed as a connection. */
|
||||
readonly update: (
|
||||
credentialID: Credential.ID,
|
||||
@@ -329,6 +331,17 @@ const layer = Layer.effect(
|
||||
finalize: () => bus.publish(Integration.Event.Updated, {}).pipe(Effect.asVoid),
|
||||
})
|
||||
|
||||
const createCredential = Effect.fnUntraced(function* (input: Parameters<Credential.Interface["create"]>[0]) {
|
||||
if (input.label !== undefined) return yield* credentials.create(input)
|
||||
const name = state.get().integrations.get(input.integrationID)?.ref.name ?? input.integrationID
|
||||
const labels = new Set((yield* credentials.list(input.integrationID)).map((credential) => credential.label))
|
||||
const label =
|
||||
Array.from({ length: labels.size + 1 }, (_, index) => (index === 0 ? name : `${name} ${index + 1}`)).find(
|
||||
(candidate) => !labels.has(candidate),
|
||||
) ?? name
|
||||
return yield* credentials.create({ ...input, label })
|
||||
})
|
||||
|
||||
const resolveConnections = (entry: Entry | undefined, saved: readonly Credential.Info[]) => {
|
||||
const credentials = saved
|
||||
.map((credential) => ({
|
||||
@@ -394,7 +407,7 @@ const layer = Layer.effect(
|
||||
?.implementations.get(attempt.methodID)
|
||||
const persistence = yield* Effect.sync(() => attempt.label ?? implementation?.label?.(exit.value)).pipe(
|
||||
Effect.flatMap((label) =>
|
||||
credentials.create({
|
||||
createCredential({
|
||||
integrationID: attempt.integrationID,
|
||||
label,
|
||||
value: exit.value,
|
||||
@@ -421,8 +434,6 @@ const layer = Layer.effect(
|
||||
// Persisting attempts cannot be cancelled, expired, or claimed again.
|
||||
yield* SynchronizedRef.update(attempts, (current) => new Map(current).set(attemptID, terminal))
|
||||
if (Exit.isFailure(persistence)) yield* Effect.failCause(persistence.cause)
|
||||
yield* bus.publish(Integration.Event.ConnectionUpdated, { integrationID: attempt.integrationID })
|
||||
yield* bus.publish(Integration.Event.Updated, {})
|
||||
}).pipe(Effect.ensuring(close(attempt.scope)))
|
||||
}),
|
||||
)
|
||||
@@ -452,13 +463,11 @@ const layer = Layer.effect(
|
||||
return
|
||||
}
|
||||
|
||||
const persistence = yield* credentials
|
||||
.create({
|
||||
integrationID: attempt.integrationID,
|
||||
label: attempt.label,
|
||||
value: Credential.Key.make({ type: "key", key: exit.value }),
|
||||
})
|
||||
.pipe(Effect.asVoid, Effect.exit)
|
||||
const persistence = yield* createCredential({
|
||||
integrationID: attempt.integrationID,
|
||||
label: attempt.label,
|
||||
value: Credential.Key.make({ type: "key", key: exit.value }),
|
||||
}).pipe(Effect.asVoid, Effect.exit)
|
||||
const settledAt = yield* Clock.currentTimeMillis
|
||||
const terminal: TerminalCommandAttempt = Exit.isSuccess(persistence)
|
||||
? {
|
||||
@@ -476,9 +485,6 @@ const layer = Layer.effect(
|
||||
}
|
||||
yield* SynchronizedRef.update(commandAttempts, (current) => new Map(current).set(attemptID, terminal))
|
||||
yield* close(attempt.scope)
|
||||
if (Exit.isFailure(persistence)) return
|
||||
yield* bus.publish(Integration.Event.ConnectionUpdated, { integrationID: attempt.integrationID })
|
||||
yield* bus.publish(Integration.Event.Updated, {})
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -702,7 +708,7 @@ const layer = Layer.effect(
|
||||
if (!method.form && Object.keys(answer).length > 0) {
|
||||
return yield* new AuthorizationError({ cause: new Error("Key method does not accept a form answer") })
|
||||
}
|
||||
yield* credentials.create({
|
||||
yield* createCredential({
|
||||
integrationID: input.integrationID,
|
||||
label: input.label,
|
||||
value: Credential.Key.make({
|
||||
@@ -711,25 +717,21 @@ const layer = Layer.effect(
|
||||
...(Object.keys(answer).length > 0 ? { configuration: answer } : {}),
|
||||
}),
|
||||
})
|
||||
yield* bus.publish(Integration.Event.ConnectionUpdated, { integrationID: input.integrationID })
|
||||
yield* bus.publish(Integration.Event.Updated, {})
|
||||
}),
|
||||
update: Effect.fn("Integration.connection.update")(function* (credentialID, updates) {
|
||||
activate: Effect.fn("Integration.connection.activate")(function* (credentialID) {
|
||||
const credential = yield* credentials.get(credentialID)
|
||||
yield* credentials.update(credentialID, updates)
|
||||
if (credential) {
|
||||
yield* bus.publish(Integration.Event.ConnectionUpdated, { integrationID: credential.integrationID })
|
||||
}
|
||||
yield* bus.publish(Integration.Event.Updated, {})
|
||||
}),
|
||||
remove: Effect.fn("Integration.connection.remove")(function* (credentialID) {
|
||||
const credential = yield* credentials.get(credentialID)
|
||||
yield* credentials.remove(credentialID)
|
||||
if (credential) {
|
||||
yield* bus.publish(Integration.Event.ConnectionUpdated, { integrationID: credential.integrationID })
|
||||
}
|
||||
yield* bus.publish(Integration.Event.Updated, {})
|
||||
if (!credential) return
|
||||
const active = resolveConnections(
|
||||
state.get().integrations.get(credential.integrationID),
|
||||
yield* credentials.list(credential.integrationID),
|
||||
)[0]
|
||||
if (active?.type === "credential" && active.id === credentialID) return
|
||||
yield* credentials.activate(credentialID)
|
||||
}),
|
||||
update: Effect.fn("Integration.connection.update")((credentialID, updates) =>
|
||||
credentials.update(credentialID, updates),
|
||||
),
|
||||
remove: Effect.fn("Integration.connection.remove")((credentialID) => credentials.remove(credentialID)),
|
||||
},
|
||||
oauth: {
|
||||
connect: connectOAuth,
|
||||
|
||||
@@ -140,8 +140,7 @@ export type Draft = {
|
||||
remove: (server: ServerName | string) => void
|
||||
}
|
||||
|
||||
const cloneConfig = (config: Mcp.ServerConfig) =>
|
||||
structuredClone(config) as Types.DeepMutable<Mcp.ServerConfig>
|
||||
const cloneConfig = (config: Mcp.ServerConfig) => structuredClone(config) as Types.DeepMutable<Mcp.ServerConfig>
|
||||
|
||||
export interface Interface extends State.Transformable<Draft> {
|
||||
readonly servers: () => Effect.Effect<ServerInfo[]>
|
||||
@@ -264,8 +263,7 @@ export const layer = (options?: Options) =>
|
||||
// No browser during connect: an auth-gated server surfaces needs_auth instead of opening a browser.
|
||||
onRedirect: () => {},
|
||||
}
|
||||
const stored = yield* credentials.list(entry.integrationID)
|
||||
const found = stored.find((credential) => credential.value.type === "oauth")
|
||||
const found = (yield* credentials.list(entry.integrationID)).at(-1)
|
||||
if (!found || found.value.type !== "oauth")
|
||||
// No stored credential yet: an empty in-memory store still lets the SDK run the auth handshake, which
|
||||
// ends in UnauthorizedError -> needs_auth. Returning no provider instead would let the transport throw
|
||||
@@ -288,8 +286,8 @@ export const layer = (options?: Options) =>
|
||||
// Drop a credential the SDK rejected so the next connect cleanly reports needs_auth — but only if it is
|
||||
// still the stored one. Rotating servers hand out a fresh refresh token per use, so a concurrent
|
||||
// connection may have already replaced ours; deleting then would discard the newer valid credential and
|
||||
// strand every connection in needs_auth until a manual re-auth. Uses the raw credential service (no
|
||||
// integration event) to avoid re-triggering the reconnect subscriber mid-connect.
|
||||
// strand every connection in needs_auth until a manual re-auth. Credential deletion notifies all locations;
|
||||
// reconnects remain serialized by the server lock.
|
||||
invalidate: async (scope) => {
|
||||
if (scope === "verifier" || scope === "discovery") return
|
||||
const oauth = await readOAuthCredential()
|
||||
@@ -673,7 +671,7 @@ export const layer = (options?: Options) =>
|
||||
}).pipe(locks.withLock(name))
|
||||
})
|
||||
fork(
|
||||
bus.subscribe(Integration.Event.ConnectionUpdated).pipe(
|
||||
bus.subscribe(Credential.Event.Switched).pipe(
|
||||
Stream.filter((event) => owned.has(event.data.integrationID)),
|
||||
Stream.runForEach((event) => Effect.sync(() => fork(reconnect(event.data.integrationID)))),
|
||||
Effect.ignore,
|
||||
@@ -687,9 +685,7 @@ export const layer = (options?: Options) =>
|
||||
config === false ? [] : [[name, cloneConfig(config)] as const],
|
||||
),
|
||||
),
|
||||
removed: new Set(
|
||||
Array.from(overrides).flatMap(([name, config]) => (config === false ? [name] : [])),
|
||||
),
|
||||
removed: new Set(Array.from(overrides).flatMap(([name, config]) => (config === false ? [name] : []))),
|
||||
}),
|
||||
draft: (draft) => ({
|
||||
list: () => Array.from(draft.servers),
|
||||
|
||||
@@ -221,7 +221,7 @@ export const GithubCopilotPlugin = define({
|
||||
}
|
||||
})
|
||||
const refresh = () => loading.withPermit(load().pipe(Effect.andThen(ctx.catalog.reload())))
|
||||
yield* bus.subscribe(Integration.Event.ConnectionUpdated).pipe(
|
||||
yield* bus.subscribe(Credential.Event.Switched).pipe(
|
||||
Stream.filter((event) => event.data.integrationID === Integration.ID.make("github-copilot")),
|
||||
Stream.runForEach(refresh),
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
|
||||
@@ -301,7 +301,7 @@ export const OpenAIPlugin = define({
|
||||
{ providerID: Provider.ID.openai },
|
||||
)
|
||||
const refresh = () => loading.withPermit(load().pipe(Effect.andThen(ctx.catalog.reload())))
|
||||
yield* bus.subscribe(Integration.Event.ConnectionUpdated).pipe(
|
||||
yield* bus.subscribe(Credential.Event.Switched).pipe(
|
||||
Stream.filter((event) => event.data.integrationID === Integration.ID.make("openai")),
|
||||
Stream.runForEach(refresh),
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
|
||||
@@ -193,7 +193,7 @@ export const OpencodePlugin = define<HttpClient.HttpClient | Bus.Service | Scope
|
||||
})
|
||||
|
||||
const refresh = () => loading.withPermit(load().pipe(Effect.andThen(ctx.catalog.reload())))
|
||||
yield* bus.subscribe(Integration.Event.ConnectionUpdated).pipe(
|
||||
yield* bus.subscribe(Credential.Event.Switched).pipe(
|
||||
Stream.filter((event) => event.data.integrationID === Integration.ID.make("opencode")),
|
||||
Stream.runForEach(refresh),
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
|
||||
@@ -149,6 +149,23 @@ describe("Bus", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("omits ambient and explicit locations for global events", () =>
|
||||
Effect.gen(function* () {
|
||||
const bus = yield* Bus.Service
|
||||
const event = yield* bus.publish(
|
||||
GlobalMessage,
|
||||
{ text: "hello" },
|
||||
{
|
||||
global: true,
|
||||
location: { directory: AbsolutePath.make("explicit"), workspaceID: Workspace.ID.make("wrk_explicit") },
|
||||
},
|
||||
)
|
||||
|
||||
expect(event).not.toHaveProperty("location")
|
||||
expect(event.type).toBe("test.global")
|
||||
}),
|
||||
)
|
||||
|
||||
itWithoutLocation.effect("omits location when no location is available", () =>
|
||||
Effect.gen(function* () {
|
||||
const bus = yield* Bus.Service
|
||||
|
||||
@@ -393,6 +393,7 @@ describe("Config", () => {
|
||||
]),
|
||||
get: () => Effect.die("unused Credential.get"),
|
||||
create: () => Effect.die("unused Credential.create"),
|
||||
activate: () => Effect.die("unused Credential.activate"),
|
||||
update: () => Effect.die("unused Credential.update"),
|
||||
remove: () => Effect.die("unused Credential.remove"),
|
||||
}),
|
||||
@@ -437,7 +438,11 @@ describe("Config", () => {
|
||||
yield* Effect.yieldNow
|
||||
available = true
|
||||
key = "next"
|
||||
yield* bus.publish(Integration.Event.ConnectionUpdated, { integrationID })
|
||||
yield* bus.publish(
|
||||
Credential.Event.Switched,
|
||||
{ credentialID: Credential.ID.create(), integrationID },
|
||||
{ global: true },
|
||||
)
|
||||
expect(yield* Fiber.join(updated)).toHaveLength(1)
|
||||
const refreshed = yield* config.entries()
|
||||
expect(Config.latest(refreshed, "shell")).toBe("project")
|
||||
@@ -496,6 +501,7 @@ describe("Config", () => {
|
||||
]),
|
||||
get: () => Effect.die("unused Credential.get"),
|
||||
create: () => Effect.die("unused Credential.create"),
|
||||
activate: () => Effect.die("unused Credential.activate"),
|
||||
update: () => Effect.die("unused Credential.update"),
|
||||
remove: () => Effect.die("unused Credential.remove"),
|
||||
}),
|
||||
|
||||
@@ -1,11 +1,20 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { eq } from "drizzle-orm"
|
||||
import { Effect } from "effect"
|
||||
import { Bus } from "@opencode-ai/core/bus"
|
||||
import { Credential } from "@opencode-ai/core/credential"
|
||||
import { CredentialTable } from "@opencode-ai/core/credential/sql"
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { Workspace } from "@opencode-ai/core/workspace"
|
||||
import { Event } from "@opencode-ai/schema/event"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { Integration } from "@opencode-ai/core/integration"
|
||||
import { location } from "./fixture/location"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
const it = testEffect(LayerNode.compile(Credential.node))
|
||||
const it = testEffect(LayerNode.compile(LayerNode.group([Credential.node, Bus.node, Database.node])))
|
||||
|
||||
describe("Credential", () => {
|
||||
it.effect("stores, updates, lists, and removes credentials", () =>
|
||||
@@ -22,15 +31,172 @@ describe("Credential", () => {
|
||||
yield* credentials.update(created.id, { label: "Personal" })
|
||||
expect((yield* credentials.list(integrationID))[0]?.label).toBe("Personal")
|
||||
|
||||
const additional = yield* credentials.create({
|
||||
integrationID,
|
||||
label: "Additional",
|
||||
value: Credential.Key.make({ type: "key", key: "additional" }),
|
||||
})
|
||||
expect(yield* credentials.list(integrationID)).toEqual([
|
||||
expect.objectContaining({ id: created.id, label: "Personal" }),
|
||||
additional,
|
||||
])
|
||||
|
||||
yield* credentials.remove(additional.id)
|
||||
expect(yield* credentials.list(integrationID)).toEqual([
|
||||
expect.objectContaining({ id: created.id, label: "Personal" }),
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("publishes global events only for observable credential mutations", () =>
|
||||
Effect.gen(function* () {
|
||||
const credentials = yield* Credential.Service
|
||||
const bus = yield* Bus.Service
|
||||
const integrationID = Integration.ID.make("openai")
|
||||
const events = new Array<Event.Payload>()
|
||||
yield* bus.listen((event) => Effect.sync(() => events.push(event)))
|
||||
|
||||
const older = yield* credentials
|
||||
.create({ integrationID, value: Credential.Key.make({ type: "key", key: "older" }) })
|
||||
.pipe(
|
||||
Effect.provideService(
|
||||
Location.Service,
|
||||
Location.Service.of(
|
||||
location({ directory: AbsolutePath.make("project"), workspaceID: Workspace.ID.make("wrk_test") }),
|
||||
),
|
||||
),
|
||||
)
|
||||
const newer = yield* credentials.create({
|
||||
integrationID,
|
||||
value: Credential.Key.make({ type: "key", key: "newer" }),
|
||||
})
|
||||
|
||||
yield* credentials.activate(newer.id)
|
||||
yield* credentials.activate(Credential.ID.create())
|
||||
yield* credentials.activate(older.id)
|
||||
yield* credentials.activate(older.id)
|
||||
yield* credentials.update(older.id, {})
|
||||
yield* credentials.update(Credential.ID.create(), { label: "Missing" })
|
||||
yield* credentials.update(older.id, { label: "default" })
|
||||
yield* credentials.update(older.id, { value: Credential.Key.make({ type: "key", key: "refreshed" }) })
|
||||
yield* credentials.update(older.id, { label: "Renamed" })
|
||||
yield* credentials.remove(Credential.ID.create())
|
||||
yield* credentials.remove(newer.id)
|
||||
yield* credentials.remove(newer.id)
|
||||
expect((yield* credentials.list(integrationID)).at(-1)?.id).toBe(older.id)
|
||||
|
||||
const replacement = yield* credentials.create({
|
||||
integrationID,
|
||||
label: "Replacement",
|
||||
value: Credential.Key.make({ type: "key", key: "replacement" }),
|
||||
})
|
||||
expect(yield* credentials.list(integrationID)).toEqual([replacement])
|
||||
|
||||
yield* credentials.remove(replacement.id)
|
||||
expect((yield* credentials.list(integrationID)).at(-1)?.id).toBe(older.id)
|
||||
yield* credentials.remove(older.id)
|
||||
expect(yield* credentials.list(integrationID)).toEqual([])
|
||||
|
||||
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: older.id } },
|
||||
{ type: Credential.Event.Updated.type, data: {} },
|
||||
{ type: Credential.Event.Switched.type, data: { integrationID, credentialID: newer.id } },
|
||||
{ type: Credential.Event.Switched.type, data: { integrationID, credentialID: older.id } },
|
||||
{ type: Credential.Event.Updated.type, data: {} },
|
||||
{ type: Credential.Event.Updated.type, data: {} },
|
||||
{ type: Credential.Event.Updated.type, data: {} },
|
||||
{ type: Credential.Event.Switched.type, data: { integrationID, credentialID: replacement.id } },
|
||||
{ type: Credential.Event.Updated.type, data: {} },
|
||||
{ type: Credential.Event.Switched.type, data: { integrationID, credentialID: older.id } },
|
||||
{ type: Credential.Event.Updated.type, data: {} },
|
||||
{ type: Credential.Event.Switched.type, data: { integrationID, credentialID: null } },
|
||||
])
|
||||
expect(events.every((event) => !("location" in event))).toBeTrue()
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("promotes the newest remaining legacy credential when the effective selection is removed", () =>
|
||||
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 oldest = yield* credentials.create({
|
||||
integrationID,
|
||||
value: Credential.Key.make({ type: "key", key: "oldest" }),
|
||||
})
|
||||
const newer = yield* credentials.create({
|
||||
integrationID,
|
||||
value: Credential.Key.make({ type: "key", key: "newer" }),
|
||||
})
|
||||
const newest = yield* credentials.create({
|
||||
integrationID,
|
||||
value: Credential.Key.make({ type: "key", key: "newest" }),
|
||||
})
|
||||
yield* database.db
|
||||
.update(CredentialTable)
|
||||
.set({ active: null })
|
||||
.where(eq(CredentialTable.integration_id, integrationID))
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
|
||||
const events = new Array<Event.Payload>()
|
||||
yield* bus.listen((event) => Effect.sync(() => events.push(event)))
|
||||
yield* credentials.activate(newest.id)
|
||||
yield* credentials.remove(oldest.id)
|
||||
yield* credentials.remove(newest.id)
|
||||
|
||||
expect(yield* credentials.list(integrationID)).toEqual([newer])
|
||||
expect(
|
||||
yield* database.db
|
||||
.select({ active: CredentialTable.active })
|
||||
.from(CredentialTable)
|
||||
.where(eq(CredentialTable.id, newer.id))
|
||||
.get()
|
||||
.pipe(Effect.orDie),
|
||||
).toEqual({ active: true })
|
||||
expect(events.map((event) => ({ type: event.type, data: event.data }))).toEqual([
|
||||
{ type: Credential.Event.Updated.type, data: {} },
|
||||
{ type: Credential.Event.Updated.type, data: {} },
|
||||
{ type: Credential.Event.Switched.type, data: { integrationID, credentialID: newer.id } },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("activates older credentials without affecting other integrations", () =>
|
||||
Effect.gen(function* () {
|
||||
const credentials = yield* Credential.Service
|
||||
const integrationID = Integration.ID.make("openai")
|
||||
const otherIntegrationID = Integration.ID.make("anthropic")
|
||||
const older = yield* credentials.create({
|
||||
integrationID,
|
||||
value: Credential.Key.make({ type: "key", key: "older" }),
|
||||
})
|
||||
const newer = yield* credentials.create({
|
||||
integrationID,
|
||||
value: Credential.Key.make({ type: "key", key: "newer" }),
|
||||
})
|
||||
const otherOlder = yield* credentials.create({
|
||||
integrationID: otherIntegrationID,
|
||||
value: Credential.Key.make({ type: "key", key: "other-older" }),
|
||||
})
|
||||
const otherNewer = yield* credentials.create({
|
||||
integrationID: otherIntegrationID,
|
||||
value: Credential.Key.make({ type: "key", key: "other-newer" }),
|
||||
})
|
||||
|
||||
yield* credentials.activate(older.id)
|
||||
expect(yield* credentials.list(integrationID)).toEqual([newer, older])
|
||||
expect(yield* credentials.list(otherIntegrationID)).toEqual([otherOlder, otherNewer])
|
||||
|
||||
yield* credentials.activate(Credential.ID.create())
|
||||
expect((yield* credentials.list(integrationID)).at(-1)).toEqual(older)
|
||||
|
||||
yield* credentials.activate(otherOlder.id)
|
||||
expect(yield* credentials.list(otherIntegrationID)).toEqual([otherNewer, otherOlder])
|
||||
expect((yield* credentials.list(integrationID)).at(-1)).toEqual(older)
|
||||
|
||||
yield* credentials.remove(older.id)
|
||||
expect((yield* credentials.list(integrationID)).at(-1)).toEqual(newer)
|
||||
expect((yield* credentials.list(otherIntegrationID)).at(-1)).toEqual(otherOlder)
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -12,6 +12,7 @@ export const emptyCredentialNode = makeGlobalNode({
|
||||
list: () => Effect.succeed([]),
|
||||
get: () => Effect.undefined,
|
||||
create: () => Effect.die("unused Credential.create"),
|
||||
activate: () => Effect.die("unused Credential.activate"),
|
||||
update: () => Effect.die("unused Credential.update"),
|
||||
remove: () => Effect.die("unused Credential.remove"),
|
||||
}),
|
||||
|
||||
@@ -38,6 +38,7 @@ const integrations = Layer.mock(Integration.Service, {
|
||||
active: () => Effect.undefined,
|
||||
resolve: () => Effect.die("unused"),
|
||||
key: () => Effect.die("unused"),
|
||||
activate: () => Effect.die("unused"),
|
||||
update: () => Effect.die("unused"),
|
||||
remove: () => Effect.die("unused"),
|
||||
},
|
||||
|
||||
@@ -19,6 +19,7 @@ const failingCredentialNode = makeGlobalNode({
|
||||
list: () => Effect.succeed([]),
|
||||
get: () => Effect.undefined,
|
||||
create: () => Effect.die(new Error("credential persistence failed")),
|
||||
activate: () => Effect.void,
|
||||
update: () => Effect.void,
|
||||
remove: () => Effect.void,
|
||||
}),
|
||||
@@ -147,9 +148,9 @@ describe("Integration", () => {
|
||||
},
|
||||
}),
|
||||
)
|
||||
const updated = yield* bus
|
||||
.subscribe(Integration.Event.Updated)
|
||||
.pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
|
||||
const created = yield* bus
|
||||
.subscribe([Credential.Event.Updated, Credential.Event.Switched])
|
||||
.pipe(Stream.take(2), Stream.runCollect, Effect.forkScoped)
|
||||
yield* Effect.yieldNow
|
||||
|
||||
expect(
|
||||
@@ -166,14 +167,42 @@ describe("Integration", () => {
|
||||
label: "Work",
|
||||
})
|
||||
|
||||
expect(yield* credentials.list(integrationID)).toEqual([
|
||||
const stored = yield* credentials.list(integrationID)
|
||||
expect(stored).toEqual([
|
||||
expect.objectContaining({
|
||||
integrationID,
|
||||
label: "Work",
|
||||
value: Credential.Key.make({ type: "key", key: "secret", configuration: { accountId: "account" } }),
|
||||
}),
|
||||
])
|
||||
expect((yield* Fiber.join(updated)).length).toBe(1)
|
||||
expect(Array.from(yield* Fiber.join(created), (event) => ({ type: event.type, data: event.data }))).toEqual([
|
||||
{ type: Credential.Event.Updated.type, data: {} },
|
||||
{ type: Credential.Event.Switched.type, data: { credentialID: stored[0]?.id, integrationID } },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("names unlabeled credentials after the integration", () =>
|
||||
Effect.gen(function* () {
|
||||
const integrations = yield* Integration.Service
|
||||
const credentials = yield* Credential.Service
|
||||
const integrationID = Integration.ID.make("openai")
|
||||
yield* integrations.transform((editor) => {
|
||||
editor.update(integrationID, (integration) => (integration.name = "OpenAI"))
|
||||
editor.method.update({ integrationID, method: { type: "key", label: "API key" } })
|
||||
})
|
||||
|
||||
yield* integrations.connection.key({ integrationID, key: "first" })
|
||||
yield* integrations.connection.key({ integrationID, key: "second" })
|
||||
yield* integrations.connection.key({ integrationID, key: "work", label: "Work" })
|
||||
yield* integrations.connection.key({ integrationID, key: "third" })
|
||||
|
||||
expect((yield* credentials.list(integrationID)).map((credential) => credential.label)).toEqual([
|
||||
"OpenAI",
|
||||
"OpenAI 2",
|
||||
"Work",
|
||||
"OpenAI 3",
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -482,6 +511,11 @@ describe("Integration", () => {
|
||||
},
|
||||
}),
|
||||
)
|
||||
const archived = yield* credentials.create({
|
||||
integrationID,
|
||||
label: "Archived",
|
||||
value: Credential.Key.make({ type: "key", key: "c" }),
|
||||
})
|
||||
const work = yield* credentials.create({
|
||||
integrationID,
|
||||
label: "Work",
|
||||
@@ -500,6 +534,16 @@ describe("Integration", () => {
|
||||
id: personal.id,
|
||||
label: "Personal",
|
||||
},
|
||||
{
|
||||
type: "credential",
|
||||
id: work.id,
|
||||
label: "Work",
|
||||
},
|
||||
{
|
||||
type: "credential",
|
||||
id: archived.id,
|
||||
label: "Archived",
|
||||
},
|
||||
{ type: "env", name: "INTEGRATION_TEST_ACME_KEY" },
|
||||
])
|
||||
expect(yield* integrations.connection.active(integrationID)).toEqual({
|
||||
@@ -507,7 +551,52 @@ describe("Integration", () => {
|
||||
id: personal.id,
|
||||
label: "Personal",
|
||||
})
|
||||
expect(work.id).not.toBe(personal.id)
|
||||
|
||||
const bus = yield* Bus.Service
|
||||
const events = new Array<{ type: string; data: unknown }>()
|
||||
yield* bus.listen((event) => Effect.sync(() => events.push({ type: event.type, data: event.data })))
|
||||
yield* integrations.connection.activate(work.id)
|
||||
|
||||
expect(yield* integrations.connection.active(integrationID)).toEqual({
|
||||
type: "credential",
|
||||
id: work.id,
|
||||
label: "Work",
|
||||
})
|
||||
expect((yield* integrations.get(integrationID))?.connections.map((connection) => connection.type)).toEqual([
|
||||
"credential",
|
||||
"credential",
|
||||
"credential",
|
||||
"env",
|
||||
])
|
||||
expect(events).toEqual([
|
||||
{ type: Credential.Event.Switched.type, data: { credentialID: work.id, integrationID } },
|
||||
])
|
||||
|
||||
yield* integrations.connection.remove(archived.id)
|
||||
expect(events).toEqual([
|
||||
{ type: Credential.Event.Switched.type, data: { credentialID: work.id, integrationID } },
|
||||
{ type: Credential.Event.Updated.type, data: {} },
|
||||
])
|
||||
|
||||
yield* integrations.connection.remove(work.id)
|
||||
expect(yield* integrations.connection.active(integrationID)).toEqual({
|
||||
type: "credential",
|
||||
id: personal.id,
|
||||
label: "Personal",
|
||||
})
|
||||
yield* integrations.connection.remove(personal.id)
|
||||
expect(yield* integrations.connection.active(integrationID)).toEqual({
|
||||
type: "env",
|
||||
name: "INTEGRATION_TEST_ACME_KEY",
|
||||
})
|
||||
expect(events).toEqual([
|
||||
{ type: Credential.Event.Switched.type, data: { credentialID: work.id, integrationID } },
|
||||
{ type: Credential.Event.Updated.type, data: {} },
|
||||
{ type: Credential.Event.Updated.type, data: {} },
|
||||
{ type: Credential.Event.Switched.type, data: { credentialID: personal.id, integrationID } },
|
||||
{ type: Credential.Event.Updated.type, data: {} },
|
||||
{ type: Credential.Event.Switched.type, data: { credentialID: null, integrationID } },
|
||||
])
|
||||
}),
|
||||
(previous) =>
|
||||
Effect.sync(() => {
|
||||
|
||||
@@ -234,6 +234,7 @@ function resourceMcpLayer(
|
||||
active: unusedIntegration,
|
||||
resolve: unusedIntegration,
|
||||
key: unusedIntegration,
|
||||
activate: unusedIntegration,
|
||||
update: unusedIntegration,
|
||||
remove: unusedIntegration,
|
||||
},
|
||||
@@ -1024,9 +1025,7 @@ test("restores runtime MCP config when a transform is disposed", async () => {
|
||||
yield* transformed.dispose
|
||||
expect(observed).toBe("original")
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
resourceMcpLayer(new ConfigMCP.Local({ type: "local", command: ["unused"], disabled: true })),
|
||||
),
|
||||
Effect.provide(resourceMcpLayer(new ConfigMCP.Local({ type: "local", command: ["unused"], disabled: true }))),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -312,6 +312,7 @@ describe("ModelResolver", () => {
|
||||
active: () => Effect.undefined,
|
||||
resolve: () => Effect.die("unused"),
|
||||
key: () => Effect.die("unused"),
|
||||
activate: () => Effect.die("unused"),
|
||||
update: () => Effect.die("unused"),
|
||||
remove: () => Effect.die("unused"),
|
||||
},
|
||||
@@ -885,12 +886,7 @@ describe("ModelResolver", () => {
|
||||
{ reasoning: { effort: "high" } },
|
||||
{ reasoning: { effort: "high" } },
|
||||
],
|
||||
[
|
||||
"@ai-sdk/xai",
|
||||
"@opencode-ai/ai/providers/xai",
|
||||
{ reasoningEffort: "high" },
|
||||
{ reasoningEffort: "high" },
|
||||
],
|
||||
["@ai-sdk/xai", "@opencode-ai/ai/providers/xai", { reasoningEffort: "high" }, { reasoningEffort: "high" }],
|
||||
] as const
|
||||
|
||||
yield* Effect.forEach(packages, ([catalogPackage, nativePackage, sourceOptions, providerOptions]) =>
|
||||
@@ -939,11 +935,7 @@ describe("ModelResolver", () => {
|
||||
["@ai-sdk/azure", "@opencode-ai/ai/providers/azure/responses", "api-model"],
|
||||
["@ai-sdk/google", "@opencode-ai/ai/providers/google", "api-model"],
|
||||
["@ai-sdk/google-vertex", "@opencode-ai/ai/providers/google-vertex", "api-model"],
|
||||
[
|
||||
"@ai-sdk/google-vertex/anthropic",
|
||||
"@opencode-ai/ai/providers/google-vertex/messages",
|
||||
"claude-sonnet-4-6",
|
||||
],
|
||||
["@ai-sdk/google-vertex/anthropic", "@opencode-ai/ai/providers/google-vertex/messages", "claude-sonnet-4-6"],
|
||||
["@ai-sdk/openai", "@opencode-ai/ai/providers/openai", "api-model"],
|
||||
["@ai-sdk/openai-compatible", "@opencode-ai/ai/providers/openai-compatible", "api-model"],
|
||||
["@openrouter/ai-sdk-provider", "@opencode-ai/ai/providers/openrouter", "api-model"],
|
||||
|
||||
@@ -279,7 +279,7 @@ describe("OpencodePlugin", () => {
|
||||
})
|
||||
draft.model.update(Provider.ID.make("remote"), Model.ID.make("stale"), () => {})
|
||||
})
|
||||
yield* credentials.create({
|
||||
const initial = yield* credentials.create({
|
||||
integrationID: Integration.ID.make("opencode"),
|
||||
value: Credential.Key.make({
|
||||
type: "key",
|
||||
@@ -332,6 +332,29 @@ describe("OpencodePlugin", () => {
|
||||
required(yield* catalog.model.get(Provider.ID.make("remote"), Model.ID.make("disabled"))).enabled,
|
||||
).toBe(false)
|
||||
expect(yield* catalog.model.get(Provider.ID.make("remote"), Model.ID.make("stale"))).toBeDefined()
|
||||
|
||||
yield* credentials.update(initial.id, { label: "Renamed" })
|
||||
yield* Effect.yieldNow
|
||||
expect(authorization).toEqual(["Bearer secret"])
|
||||
|
||||
const replacement = yield* credentials.create({
|
||||
integrationID: Integration.ID.make("opencode"),
|
||||
value: Credential.Key.make({
|
||||
type: "key",
|
||||
key: "replacement",
|
||||
metadata: { server: server.url.origin },
|
||||
}),
|
||||
})
|
||||
yield* eventually(
|
||||
Effect.sync(() => authorization.length),
|
||||
(count) => count === 2,
|
||||
)
|
||||
expect(authorization).toEqual(["Bearer secret", "Bearer replacement"])
|
||||
|
||||
yield* credentials.remove(initial.id)
|
||||
yield* Effect.yieldNow
|
||||
expect(authorization).toEqual(["Bearer secret", "Bearer replacement"])
|
||||
expect((yield* credentials.list(Integration.ID.make("opencode"))).at(-1)?.id).toBe(replacement.id)
|
||||
}),
|
||||
({ server }) => Effect.promise(() => server.stop(true)),
|
||||
),
|
||||
|
||||
@@ -6749,6 +6749,90 @@
|
||||
"summary": "Remove credential"
|
||||
}
|
||||
},
|
||||
"/api/credential/{credentialID}/activate": {
|
||||
"post": {
|
||||
"tags": ["credential"],
|
||||
"operationId": "v2.credential.activate",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "credentialID",
|
||||
"in": "path",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"name": "location",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"directory": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"workspace": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"required": false,
|
||||
"style": "deepObject",
|
||||
"explode": true
|
||||
}
|
||||
],
|
||||
"security": [],
|
||||
"responses": {
|
||||
"204": {
|
||||
"description": "<No Content>"
|
||||
},
|
||||
"400": {
|
||||
"description": "InvalidRequestError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "UnauthorizedError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/UnauthorizedErrorEncoded"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Activate a stored integration credential.",
|
||||
"summary": "Activate credential"
|
||||
}
|
||||
},
|
||||
"/api/project": {
|
||||
"get": {
|
||||
"tags": ["project"],
|
||||
@@ -6794,6 +6878,89 @@
|
||||
"summary": "List projects"
|
||||
}
|
||||
},
|
||||
"/api/project/{projectID}": {
|
||||
"patch": {
|
||||
"tags": ["project"],
|
||||
"operationId": "v2.project.update",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "projectID",
|
||||
"in": "path",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
},
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"security": [],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Project",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Project"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "InvalidRequestError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "UnauthorizedError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/UnauthorizedErrorEncoded"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "ProjectNotFoundError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProjectNotFoundErrorEncoded"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Update project display metadata and workspace commands.",
|
||||
"summary": "Update project",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"icon": {
|
||||
"$ref": "#/components/schemas/Project.Icon"
|
||||
},
|
||||
"commands": {
|
||||
"$ref": "#/components/schemas/Project.Commands"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/project/current": {
|
||||
"get": {
|
||||
"tags": ["project"],
|
||||
@@ -11193,7 +11360,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Create a worktree for a project.",
|
||||
"description": "Create a worktree for a project and run its configured setup script.",
|
||||
"summary": "Create worktree",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
@@ -15194,6 +15361,9 @@
|
||||
},
|
||||
"requireFinishReason": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"requireAssistantAfterTool": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
@@ -15436,6 +15606,9 @@
|
||||
},
|
||||
"source": {
|
||||
"$ref": "#/components/schemas/Permission.Source"
|
||||
},
|
||||
"message": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["id", "sessionID", "action", "resources"],
|
||||
@@ -15914,6 +16087,23 @@
|
||||
"type": "string",
|
||||
"enum": ["git", "hg"]
|
||||
},
|
||||
"ProjectNotFoundErrorEncoded": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"_tag": {
|
||||
"type": "string",
|
||||
"enum": ["ProjectNotFoundError"]
|
||||
},
|
||||
"projectID": {
|
||||
"type": "string"
|
||||
},
|
||||
"message": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["_tag", "projectID", "message"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"Prompt.AgentAttachment": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
||||
@@ -21,6 +21,21 @@ export const CredentialGroup = HttpApiGroup.make("server.credential")
|
||||
),
|
||||
)
|
||||
.annotateMerge(OpenApi.annotations({ title: "credential" }))
|
||||
.add(
|
||||
HttpApiEndpoint.post("credential.activate", "/api/credential/:credentialID/activate", {
|
||||
params: { credentialID: Credential.ID },
|
||||
query: LocationQuery,
|
||||
success: HttpApiSchema.NoContent,
|
||||
})
|
||||
.annotateMerge(locationQueryOpenApi)
|
||||
.annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "v2.credential.activate",
|
||||
summary: "Activate credential",
|
||||
description: "Activate a stored integration credential.",
|
||||
}),
|
||||
),
|
||||
)
|
||||
.add(
|
||||
HttpApiEndpoint.delete("credential.remove", "/api/credential/:credentialID", {
|
||||
params: { credentialID: Credential.ID },
|
||||
|
||||
@@ -2,7 +2,8 @@ export * as Credential from "./credential.js"
|
||||
|
||||
import { Schema } from "effect"
|
||||
import { optional } from "./schema.js"
|
||||
import { IntegrationMethodID } from "./integration-id.js"
|
||||
import { ephemeral, inventory } from "./event.js"
|
||||
import { IntegrationID, IntegrationMethodID } from "./integration-id.js"
|
||||
import { ascending } from "./identifier.js"
|
||||
import { NonNegativeInt, statics } from "./schema.js"
|
||||
import { Form } from "./form.js"
|
||||
@@ -13,6 +14,20 @@ export const ID = Schema.String.pipe(
|
||||
)
|
||||
export type ID = typeof ID.Type
|
||||
|
||||
const Updated = ephemeral({
|
||||
type: "credential.updated",
|
||||
schema: {},
|
||||
})
|
||||
const Switched = ephemeral({
|
||||
type: "credential.switched",
|
||||
schema: { integrationID: IntegrationID, credentialID: Schema.NullOr(ID) },
|
||||
})
|
||||
export const Event = {
|
||||
Updated,
|
||||
Switched,
|
||||
Definitions: inventory(Updated, Switched),
|
||||
}
|
||||
|
||||
export interface OAuth extends Schema.Schema.Type<typeof OAuth> {}
|
||||
export const OAuth = Schema.Struct({
|
||||
type: Schema.Literal("oauth"),
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Agent } from "./agent.js"
|
||||
import { Catalog } from "./catalog.js"
|
||||
import { Command } from "./command.js"
|
||||
import { Config } from "./config.js"
|
||||
import { Credential } from "./credential.js"
|
||||
import { Durable } from "./durable-event-manifest.js"
|
||||
import { Event } from "./event.js"
|
||||
import { FileSystem } from "./filesystem.js"
|
||||
@@ -39,6 +40,7 @@ const coreDefinitions = Event.inventory(...SessionEvent.Definitions)
|
||||
|
||||
const foundationDefinitions = Event.inventory(
|
||||
...ModelsDev.Event.Definitions,
|
||||
...Credential.Event.Definitions,
|
||||
...Integration.Event.Definitions,
|
||||
...Catalog.Event.Definitions,
|
||||
...Agent.Event.Definitions,
|
||||
|
||||
@@ -53,11 +53,7 @@ const Updated = ephemeral({
|
||||
type: "integration.updated",
|
||||
schema: {},
|
||||
})
|
||||
const ConnectionUpdated = ephemeral({
|
||||
type: "integration.connection.updated",
|
||||
schema: { integrationID: ID },
|
||||
})
|
||||
export const Event = { Updated, ConnectionUpdated, Definitions: inventory(Updated, ConnectionUpdated) }
|
||||
export const Event = { Updated, Definitions: inventory(Updated) }
|
||||
|
||||
export interface Ref extends Schema.Schema.Type<typeof Ref> {}
|
||||
export const Ref = Schema.Struct({
|
||||
|
||||
@@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test"
|
||||
import {
|
||||
Agent,
|
||||
Config,
|
||||
Credential,
|
||||
FileSystem,
|
||||
Form,
|
||||
Integration,
|
||||
@@ -59,11 +60,12 @@ describe("public event manifest", () => {
|
||||
expect(EventManifest.Latest.get("agent.updated")).toBe(Agent.Event.Updated)
|
||||
expect(EventManifest.Latest.get("project.updated")).toBe(Project.Event.Updated)
|
||||
expect(Agent.Event.Definitions).toEqual([Agent.Event.Updated])
|
||||
expect(Credential.Event.Definitions).toEqual([Credential.Event.Updated, Credential.Event.Switched])
|
||||
expect(Project.Event.Definitions).toEqual([Project.Event.Updated])
|
||||
expect(Config.Event.Definitions).toEqual([Config.Event.Updated])
|
||||
expect(FileSystem.Event.Definitions).toEqual([FileSystem.Event.Changed])
|
||||
expect(FileSystemV1.Event.Definitions).toEqual([FileSystemV1.Event.Edited])
|
||||
expect(Integration.Event.Definitions).toEqual([Integration.Event.Updated, Integration.Event.ConnectionUpdated])
|
||||
expect(Integration.Event.Definitions).toEqual([Integration.Event.Updated])
|
||||
expect(Permission.Event.Definitions).toEqual([Permission.Event.Asked, Permission.Event.Replied])
|
||||
expect(PersistentPty.Event.Definitions).toEqual([PersistentPty.Event.Added, PersistentPty.Event.Removed])
|
||||
expect(Form.Event.Definitions).toEqual([Form.Event.Created, Form.Event.Replied, Form.Event.Cancelled])
|
||||
@@ -77,6 +79,35 @@ describe("public event manifest", () => {
|
||||
expect(EventManifest.Durable.has("session.step.ended.2")).toBe(false)
|
||||
})
|
||||
|
||||
test("keeps credential events public, canonical, and ephemeral", () => {
|
||||
const credentialID = Credential.ID.make("cred_test")
|
||||
const integrationID = Integration.ID.make("integration_test")
|
||||
|
||||
for (const definition of Credential.Event.Definitions) {
|
||||
expect(EventManifest.ServerDefinitions).toContain(definition)
|
||||
expect(EventManifest.Definitions).toContain(definition)
|
||||
expect(EventManifest.Server.get(definition.type)).toBe(definition)
|
||||
expect(EventManifest.Latest.get(definition.type)).toBe(definition)
|
||||
expect(definition.durability).toBe("ephemeral")
|
||||
expect(EventManifest.Durable.has(definition.type)).toBe(false)
|
||||
}
|
||||
|
||||
expect(Credential.Event.Updated.data.make({})).toEqual({})
|
||||
expect(Credential.Event.Switched.data.make({ integrationID, credentialID })).toEqual({
|
||||
integrationID,
|
||||
credentialID,
|
||||
})
|
||||
expect(Credential.Event.Switched.data.make({ integrationID, credentialID: null })).toEqual({
|
||||
integrationID,
|
||||
credentialID: null,
|
||||
})
|
||||
expect(EventManifest.Server.has("credential.created")).toBe(false)
|
||||
expect(EventManifest.Server.has("credential.activated")).toBe(false)
|
||||
expect(EventManifest.Server.has("credential.deleted")).toBe(false)
|
||||
expect(EventManifest.Server.has("integration.connection.updated")).toBe(false)
|
||||
expect(EventManifest.Latest.has("integration.connection.updated")).toBe(false)
|
||||
})
|
||||
|
||||
test("derives durable definitions from explicit definition durability", () => {
|
||||
expect(Array.from(EventManifest.Durable.keys()).toSorted()).toEqual(
|
||||
[
|
||||
|
||||
@@ -13,6 +13,14 @@ export const CredentialHandler = HttpApiBuilder.group(Api, "server.credential",
|
||||
return HttpApiSchema.NoContent.make()
|
||||
}),
|
||||
)
|
||||
.handle(
|
||||
"credential.activate",
|
||||
Effect.fn(function* (ctx) {
|
||||
const integration = yield* Integration.Service
|
||||
yield* integration.connection.activate(ctx.params.credentialID)
|
||||
return HttpApiSchema.NoContent.make()
|
||||
}),
|
||||
)
|
||||
.handle(
|
||||
"credential.remove",
|
||||
Effect.fn(function* (ctx) {
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Agent } from "@opencode-ai/core/agent"
|
||||
import { Bus } from "@opencode-ai/core/bus"
|
||||
import { Credential } from "@opencode-ai/schema/credential"
|
||||
import { Event } from "@opencode-ai/schema/event"
|
||||
import { IntegrationID } from "@opencode-ai/schema/integration-id"
|
||||
import { Deferred, Effect, Exit, Fiber, Option, Schema, Stream } from "effect"
|
||||
import { it } from "../../core/test/lib/effect"
|
||||
import { EventFeed } from "../src/event-feed"
|
||||
@@ -111,6 +113,27 @@ describe("EventFeed", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("delivers global credential events to public subscribers", () =>
|
||||
Effect.gen(function* () {
|
||||
const source = makeSource()
|
||||
const feed = yield* EventFeed.make(source.observe, { encode: (event) => event.type })
|
||||
const stream = yield* feed.subscribe
|
||||
const received = yield* stream.pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
|
||||
|
||||
yield* source.publish({
|
||||
id: Event.ID.create(),
|
||||
created: Date.now(),
|
||||
type: Credential.Event.Switched.type,
|
||||
data: {
|
||||
credentialID: Credential.ID.make("cred_test"),
|
||||
integrationID: IntegrationID.make("openai"),
|
||||
},
|
||||
})
|
||||
|
||||
expect(Array.from(yield* Fiber.join(received))).toEqual([Credential.Event.Switched.type])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("filters internal events before they consume subscriber capacity", () =>
|
||||
Effect.gen(function* () {
|
||||
const source = makeSource()
|
||||
|
||||
@@ -64,6 +64,16 @@ it.live("serves the HttpApi and enforces Basic auth like the Node server", () =>
|
||||
}).pipe(Effect.scoped),
|
||||
)
|
||||
|
||||
it.live("activates credentials through the HttpApi", () =>
|
||||
Effect.gen(function* () {
|
||||
const handler = yield* ServerFetch.make(options)
|
||||
const response = yield* Effect.promise(() =>
|
||||
handler(new Request("http://opencode.local/api/credential/cred_missing/activate", { method: "POST" })),
|
||||
)
|
||||
expect(response.status).toBe(204)
|
||||
}).pipe(Effect.scoped),
|
||||
)
|
||||
|
||||
it.live("serves unauthenticated and answers CORS preflight when no password is configured", () =>
|
||||
Effect.gen(function* () {
|
||||
const handler = yield* ServerFetch.make(options)
|
||||
|
||||
@@ -150,29 +150,101 @@ function manageConnections(
|
||||
const data = useData()
|
||||
const client = useClient()
|
||||
const toast = useToast()
|
||||
const theme = useTheme("elevated")
|
||||
const shortcuts = Keymap.useShortcuts()
|
||||
const [deleting, setDeleting] = createSignal<string>()
|
||||
const [selected, setSelected] = createSignal(methods.length ? "add" : credentialConnections(integration)[0]?.id)
|
||||
const current = createMemo(() => data.location.integration.list()?.find((item) => item.id === integration.id))
|
||||
|
||||
return (
|
||||
<DialogSelect
|
||||
title={integration.name}
|
||||
current={credentialConnections(current() ?? integration)[0]?.id}
|
||||
focusCurrent={false}
|
||||
preserveSelection
|
||||
onMove={(option) => {
|
||||
setSelected(option.value)
|
||||
setDeleting(undefined)
|
||||
}}
|
||||
options={[
|
||||
...(methods.length
|
||||
? [
|
||||
{
|
||||
title: "Add connection",
|
||||
title: "Add account",
|
||||
value: "add",
|
||||
onSelect: () => selectMethod(integration, methods, dialog, onConnected),
|
||||
onSelect: () => selectMethod(current() ?? integration, methods, dialog, onConnected),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...credentialConnections(integration).map((connection) => ({
|
||||
title: `Disconnect ${connection.label}`,
|
||||
value: connection.id,
|
||||
onSelect: () => {
|
||||
void client.api.credential
|
||||
.remove({ credentialID: connection.id, location: location(data) })
|
||||
.then(() => disconnected(integration.name, data, dialog, toast))
|
||||
.catch(toast.error)
|
||||
...credentialConnections(current() ?? integration)
|
||||
.toSorted((a, b) => a.label.localeCompare(b.label) || a.id.localeCompare(b.id))
|
||||
.map((connection) => {
|
||||
const confirming = deleting() === connection.id
|
||||
return {
|
||||
title: confirming
|
||||
? `Press ${shortcuts.get("dialog.integration.delete")} again to confirm`
|
||||
: connection.label,
|
||||
value: connection.id,
|
||||
category: "Connected accounts",
|
||||
bg: confirming ? theme.background.action.destructive.focused : undefined,
|
||||
fg: confirming ? theme.text.action.destructive.focused : undefined,
|
||||
onSelect: () => {
|
||||
if (credentialConnections(current() ?? integration)[0]?.id === connection.id) return
|
||||
void client.api.credential
|
||||
.activate({ credentialID: connection.id, location: location(data) })
|
||||
.catch(toast.error)
|
||||
},
|
||||
}
|
||||
}),
|
||||
]}
|
||||
actions={[
|
||||
{
|
||||
command: "dialog.integration.rename",
|
||||
title: "rename",
|
||||
hidden: selected() === "add",
|
||||
disabled: (option) => !option || option.value === "add",
|
||||
onTrigger: (option) => {
|
||||
dialog.replace(() => (
|
||||
<DialogPrompt
|
||||
title="Rename account"
|
||||
placeholder="Account name"
|
||||
value={
|
||||
credentialConnections(current() ?? integration).find((item) => item.id === option.value)?.label
|
||||
}
|
||||
onConfirm={(value) => {
|
||||
const label = value.trim()
|
||||
if (!label) return
|
||||
void client.api.credential
|
||||
.update({ credentialID: option.value, label, location: location(data) })
|
||||
.then(() => manageConnections(integration, methods, dialog, onConnected))
|
||||
.catch(toast.error)
|
||||
}}
|
||||
/>
|
||||
))
|
||||
},
|
||||
})),
|
||||
},
|
||||
{
|
||||
command: "dialog.integration.delete",
|
||||
title: "delete",
|
||||
hidden: selected() === "add",
|
||||
disabled: (option) => !option || option.value === "add",
|
||||
onTrigger: (option) => {
|
||||
if (deleting() !== option.value) return setDeleting(option.value)
|
||||
const final = credentialConnections(current() ?? integration).length === 1
|
||||
void client.api.credential
|
||||
.remove({ credentialID: option.value, location: location(data) })
|
||||
.then(() => {
|
||||
setDeleting(undefined)
|
||||
if (!final) return
|
||||
toast.show({ variant: "success", message: `Disconnected ${integration.name}` })
|
||||
dialog.clear()
|
||||
})
|
||||
.catch((error) => {
|
||||
setDeleting(undefined)
|
||||
toast.error(error)
|
||||
})
|
||||
},
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)
|
||||
@@ -923,20 +995,6 @@ function providerID(data: ReturnType<typeof useData>, integrationID: string) {
|
||||
)
|
||||
}
|
||||
|
||||
async function disconnected(
|
||||
name: string,
|
||||
data: ReturnType<typeof useData>,
|
||||
dialog: ReturnType<typeof useDialog>,
|
||||
toast: ReturnType<typeof useToast>,
|
||||
) {
|
||||
data.location.integration.invalidate()
|
||||
data.location.model.invalidate()
|
||||
data.location.provider.invalidate()
|
||||
await Promise.all([data.location.integration.sync(), data.location.model.sync(), data.location.provider.sync()])
|
||||
toast.show({ variant: "success", message: `Disconnected ${name}` })
|
||||
dialog.clear()
|
||||
}
|
||||
|
||||
function location(data: ReturnType<typeof useData>) {
|
||||
const current = data.location.default()
|
||||
return { directory: current.directory, workspace: current.workspaceID }
|
||||
|
||||
@@ -243,6 +243,8 @@ export const Definitions = {
|
||||
"dialog.select.end": keybind("end", "Move to last dialog item"),
|
||||
"dialog.select.submit": keybind("return", "Submit selected dialog item"),
|
||||
"dialog.prompt.submit": keybind("return", "Submit dialog prompt"),
|
||||
"dialog.integration.rename": keybind("ctrl+r", "Rename integration account"),
|
||||
"dialog.integration.delete": keybind("ctrl+d", "Delete integration account"),
|
||||
"dialog.worktree.generate": keybind("tab", "Generate worktree name"),
|
||||
"dialog.move_session.new": keybind("ctrl+m", "New worktree"),
|
||||
"dialog.move_session.delete": keybind("ctrl+d", "Delete worktree"),
|
||||
|
||||
@@ -365,6 +365,7 @@ function messageIDFromEvent(id: string) {
|
||||
const catalogEvents = new Set([
|
||||
"catalog.updated",
|
||||
"integration.updated",
|
||||
"credential.switched",
|
||||
"agent.updated",
|
||||
"command.updated",
|
||||
"skill.updated",
|
||||
|
||||
@@ -1728,6 +1728,7 @@ test("refreshes integrations after integration updates", async () => {
|
||||
id: "openai",
|
||||
name: "OpenAI",
|
||||
methods: [{ type: "key" }],
|
||||
connections: [{ type: "credential", id: "cred_openai", label: "OpenAI" }],
|
||||
},
|
||||
],
|
||||
})
|
||||
@@ -1766,6 +1767,16 @@ test("refreshes integrations after integration updates", async () => {
|
||||
await wait(() => data.location.integration.list()?.length === 1)
|
||||
await wait(() => requests.model > before.model && requests.provider > before.provider)
|
||||
expect(data.location.integration.list()?.[0]).toMatchObject({ id: "openai", name: "OpenAI" })
|
||||
|
||||
const previous = { ...requests }
|
||||
events.emit({
|
||||
id: "evt_credential",
|
||||
created: 0,
|
||||
type: "credential.switched",
|
||||
data: { credentialID: "cred_openai", integrationID: "openai" },
|
||||
})
|
||||
await wait(() => requests.model > previous.model && requests.provider > previous.provider)
|
||||
expect(requests.integration).toBe(previous.integration)
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,339 @@
|
||||
/** @jsxImportSource @opentui/solid */
|
||||
import { InputRenderable, TextareaRenderable } from "@opentui/core"
|
||||
import { testRender } from "@opentui/solid"
|
||||
import { expect, test } from "bun:test"
|
||||
import { onMount } from "solid-js"
|
||||
import { DialogIntegration } from "../../../src/component/dialog-integration"
|
||||
import { ConfigProvider } from "../../../src/config"
|
||||
import { ClientProvider } from "../../../src/context/client"
|
||||
import { DataProvider, useData } from "../../../src/context/data"
|
||||
import { Keymap } from "../../../src/context/keymap"
|
||||
import { LocationProvider } from "../../../src/context/location"
|
||||
import { ThemeProvider } from "../../../src/context/theme"
|
||||
import { DialogProvider, useDialog } from "../../../src/ui/dialog"
|
||||
import { ToastProvider } from "../../../src/ui/toast"
|
||||
import { emptyThemeSource } from "../../fixture/fixture"
|
||||
import { createApi, createEventStream, createFetch, json } from "../../fixture/tui-client"
|
||||
import { TestTuiContexts } from "../../fixture/tui-environment"
|
||||
import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
|
||||
|
||||
test("renders account management with an uncategorized add row and marks the active credential", async () => {
|
||||
const fixture = await renderIntegration()
|
||||
|
||||
try {
|
||||
const frame = fixture.app.captureCharFrame()
|
||||
const lines = frame.split("\n")
|
||||
|
||||
expect(frame.indexOf("Add account")).toBeLessThan(frame.indexOf("Connected accounts"))
|
||||
expect(frame.indexOf("Connected accounts")).toBeLessThan(frame.indexOf("Personal"))
|
||||
expect(frame.indexOf("Personal")).toBeLessThan(frame.indexOf("Work"))
|
||||
expect(lines.find((line) => line.includes("Personal"))).toContain("\u25cf")
|
||||
expect(lines.find((line) => line.includes("Work"))).not.toContain("\u25cf")
|
||||
expect(frame).not.toContain("OPENAI_API_KEY")
|
||||
} finally {
|
||||
fixture.app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("opens the key connection prompt from the initially focused add account row", async () => {
|
||||
const fixture = await renderIntegration()
|
||||
|
||||
try {
|
||||
fixture.app.mockInput.pressEnter()
|
||||
await fixture.app.waitForFrame((frame) => frame.includes("API key") && !frame.includes("Connected accounts"))
|
||||
|
||||
expect(fixture.requests).toEqual([])
|
||||
} finally {
|
||||
fixture.app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("switches the selected account with enter and keeps the reactive account manager open", async () => {
|
||||
const fixture = await renderIntegration()
|
||||
|
||||
try {
|
||||
fixture.app.mockInput.pressArrow("down")
|
||||
fixture.app.mockInput.pressArrow("down")
|
||||
fixture.app.mockInput.pressEnter()
|
||||
|
||||
await fixture.app.waitForFrame((frame) => {
|
||||
const line = frame.split("\n").find((entry) => entry.includes("Work"))
|
||||
return frame.includes("Connected accounts") && line?.includes("\u25cf") === true
|
||||
})
|
||||
|
||||
expect(fixture.requests).toEqual([{ method: "POST", path: "/api/credential/cred_work/activate" }])
|
||||
expect(fixture.accounts.map((account) => account.id)).toEqual(["cred_work", "cred_personal"])
|
||||
const frame = fixture.app.captureCharFrame()
|
||||
expect(frame.indexOf("Personal")).toBeLessThan(frame.indexOf("Work"))
|
||||
expect(fixture.reads.integration).toBe(1)
|
||||
expect(fixture.reads.model).toBeGreaterThan(0)
|
||||
expect(fixture.reads.provider).toBeGreaterThan(0)
|
||||
} finally {
|
||||
fixture.app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("does not refetch when selecting the already-active account", async () => {
|
||||
const fixture = await renderIntegration()
|
||||
|
||||
try {
|
||||
fixture.app.mockInput.pressArrow("down")
|
||||
fixture.app.mockInput.pressEnter()
|
||||
|
||||
expect(fixture.requests).toEqual([])
|
||||
expect(fixture.reads.model).toBe(0)
|
||||
expect(fixture.reads.provider).toBe(0)
|
||||
} finally {
|
||||
fixture.app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("renames the selected account through a prefilled prompt and reopens the account manager", async () => {
|
||||
const fixture = await renderIntegration()
|
||||
|
||||
try {
|
||||
fixture.app.mockInput.pressArrow("down")
|
||||
fixture.app.mockInput.pressArrow("down")
|
||||
fixture.app.mockInput.pressKey("r", { ctrl: true })
|
||||
await fixture.app.waitFor(() => fixture.app.renderer.currentFocusedEditor instanceof TextareaRenderable)
|
||||
|
||||
const textarea = fixture.app.renderer.currentFocusedEditor
|
||||
if (!(textarea instanceof TextareaRenderable)) throw new Error("expected focused rename prompt")
|
||||
expect(textarea.plainText).toBe("Work")
|
||||
|
||||
await fixture.app.mockInput.typeText(" Account")
|
||||
fixture.app.mockInput.pressEnter()
|
||||
await fixture.app.waitForFrame((frame) => frame.includes("Connected accounts") && frame.includes("Work Account"))
|
||||
|
||||
expect(fixture.requests).toEqual([
|
||||
{ method: "PATCH", path: "/api/credential/cred_work", body: { label: "Work Account" } },
|
||||
])
|
||||
expect(fixture.accounts.find((account) => account.id === "cred_work")?.label).toBe("Work Account")
|
||||
} finally {
|
||||
fixture.app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("requires delete confirmation and preserves the account manager when another credential remains", async () => {
|
||||
const fixture = await renderIntegration()
|
||||
|
||||
try {
|
||||
fixture.app.mockInput.pressArrow("down")
|
||||
fixture.app.mockInput.pressArrow("down")
|
||||
fixture.app.mockInput.pressKey("d", { ctrl: true })
|
||||
|
||||
expect(fixture.requests).toEqual([])
|
||||
|
||||
fixture.app.mockInput.pressKey("d", { ctrl: true })
|
||||
await fixture.app.waitForFrame((frame) => frame.includes("Connected accounts") && !frame.includes("Work"))
|
||||
|
||||
expect(fixture.requests).toEqual([{ method: "DELETE", path: "/api/credential/cred_work" }])
|
||||
expect(fixture.accounts).toEqual([{ type: "credential", id: "cred_personal", label: "Personal" }])
|
||||
expect(fixture.reads.model).toBe(0)
|
||||
expect(fixture.reads.provider).toBe(0)
|
||||
expect(fixture.app.captureCharFrame()).toContain("Add account")
|
||||
expect(fixture.app.captureCharFrame()).toContain("Personal")
|
||||
} finally {
|
||||
fixture.app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("renames the account label rather than its delete-confirmation message", async () => {
|
||||
const fixture = await renderIntegration()
|
||||
|
||||
try {
|
||||
fixture.app.mockInput.pressArrow("down")
|
||||
fixture.app.mockInput.pressArrow("down")
|
||||
fixture.app.mockInput.pressKey("d", { ctrl: true })
|
||||
await fixture.app.waitForFrame((frame) => frame.includes("again to confirm"))
|
||||
|
||||
fixture.app.mockInput.pressKey("r", { ctrl: true })
|
||||
await fixture.app.waitFor(() => fixture.app.renderer.currentFocusedEditor instanceof TextareaRenderable)
|
||||
|
||||
const textarea = fixture.app.renderer.currentFocusedEditor
|
||||
if (!(textarea instanceof TextareaRenderable)) throw new Error("expected focused rename prompt")
|
||||
expect(textarea.plainText).toBe("Work")
|
||||
} finally {
|
||||
fixture.app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("marks the remaining account active after deleting the active credential", async () => {
|
||||
const fixture = await renderIntegration()
|
||||
|
||||
try {
|
||||
fixture.app.mockInput.pressArrow("down")
|
||||
fixture.app.mockInput.pressKey("d", { ctrl: true })
|
||||
fixture.app.mockInput.pressKey("d", { ctrl: true })
|
||||
|
||||
await fixture.app.waitForFrame((frame) => {
|
||||
const line = frame.split("\n").find((entry) => entry.includes("Work"))
|
||||
return !frame.includes("Personal") && line?.includes("\u25cf") === true
|
||||
})
|
||||
|
||||
expect(fixture.requests).toEqual([{ method: "DELETE", path: "/api/credential/cred_personal" }])
|
||||
expect(fixture.accounts).toEqual([{ type: "credential", id: "cred_work", label: "Work" }])
|
||||
} finally {
|
||||
fixture.app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("hides account rename and delete actions while the add account row is selected", async () => {
|
||||
const fixture = await renderIntegration()
|
||||
|
||||
try {
|
||||
expect(fixture.app.captureCharFrame()).not.toContain("rename")
|
||||
expect(fixture.app.captureCharFrame()).not.toContain("delete")
|
||||
|
||||
fixture.app.mockInput.pressKey("r", { ctrl: true })
|
||||
fixture.app.mockInput.pressKey("d", { ctrl: true })
|
||||
fixture.app.mockInput.pressKey("d", { ctrl: true })
|
||||
|
||||
expect(fixture.requests).toEqual([])
|
||||
expect(fixture.app.renderer.currentFocusedEditor).toBeInstanceOf(InputRenderable)
|
||||
expect(fixture.app.captureCharFrame()).toContain("Connected accounts")
|
||||
|
||||
fixture.app.mockInput.pressArrow("down")
|
||||
await fixture.app.waitForFrame((frame) => frame.includes("rename") && frame.includes("delete"))
|
||||
|
||||
fixture.app.mockInput.pressArrow("up")
|
||||
await fixture.app.waitForFrame((frame) => !frame.includes("rename") && !frame.includes("delete"))
|
||||
} finally {
|
||||
fixture.app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
async function renderIntegration() {
|
||||
const events = createEventStream()
|
||||
const requests: Array<{ method: string; path: string; body?: { label: string } }> = []
|
||||
const reads = { integration: 0, model: 0, provider: 0 }
|
||||
let accounts = [
|
||||
{ type: "credential" as const, id: "cred_personal", label: "Personal" },
|
||||
{ type: "credential" as const, id: "cred_work", label: "Work" },
|
||||
]
|
||||
|
||||
const calls = createFetch(async (url, request) => {
|
||||
const location = {
|
||||
directory: process.cwd(),
|
||||
project: { id: "proj_test", directory: process.cwd(), canonical: process.cwd() },
|
||||
}
|
||||
|
||||
if (url.pathname === "/api/integration") {
|
||||
reads.integration++
|
||||
return json({
|
||||
location,
|
||||
data: [
|
||||
{
|
||||
id: "openai",
|
||||
name: "OpenAI",
|
||||
methods: [{ type: "key", label: "API key" }],
|
||||
connections: [...accounts, { type: "env", name: "OPENAI_API_KEY" }],
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
if (url.pathname === "/api/model") {
|
||||
reads.model++
|
||||
return json({ location, data: [] })
|
||||
}
|
||||
|
||||
if (url.pathname === "/api/provider") {
|
||||
reads.provider++
|
||||
return json({ location, data: [] })
|
||||
}
|
||||
|
||||
if (request.method === "POST" && /^\/api\/credential\/[^/]+\/activate$/.test(url.pathname)) {
|
||||
const id = url.pathname.split("/")[3]
|
||||
const active = accounts.find((account) => account.id === id)
|
||||
if (!active) throw new Error(`unknown credential: ${id}`)
|
||||
accounts = [active, ...accounts.filter((account) => account.id !== id)]
|
||||
requests.push({ method: request.method, path: url.pathname })
|
||||
events.emit({
|
||||
id: `evt_switched_${id}`,
|
||||
created: Date.now(),
|
||||
type: "credential.switched",
|
||||
data: { integrationID: "openai", credentialID: id },
|
||||
})
|
||||
return new Response(null, { status: 204 })
|
||||
}
|
||||
|
||||
if (request.method === "PATCH" && /^\/api\/credential\/[^/]+$/.test(url.pathname)) {
|
||||
const id = url.pathname.split("/")[3]
|
||||
const body = (await request.json()) as { label: string }
|
||||
accounts = accounts.map((account) => (account.id === id ? { ...account, label: body.label } : account))
|
||||
requests.push({ method: request.method, path: url.pathname, body })
|
||||
events.emit({ id: `evt_updated_${id}`, created: Date.now(), type: "credential.updated", data: {} })
|
||||
return new Response(null, { status: 204 })
|
||||
}
|
||||
|
||||
if (request.method === "DELETE" && /^\/api\/credential\/[^/]+$/.test(url.pathname)) {
|
||||
const id = url.pathname.split("/")[3]
|
||||
const active = accounts[0]?.id === id
|
||||
accounts = accounts.filter((account) => account.id !== id)
|
||||
requests.push({ method: request.method, path: url.pathname })
|
||||
events.emit({ id: `evt_deleted_${id}`, created: Date.now(), type: "credential.updated", data: {} })
|
||||
if (active)
|
||||
events.emit({
|
||||
id: `evt_switched_${id}`,
|
||||
created: Date.now(),
|
||||
type: "credential.switched",
|
||||
data: { integrationID: "openai", credentialID: accounts[0]?.id ?? null },
|
||||
})
|
||||
return new Response(null, { status: 204 })
|
||||
}
|
||||
|
||||
return undefined
|
||||
}, events)
|
||||
|
||||
function Probe() {
|
||||
const data = useData()
|
||||
const dialog = useDialog()
|
||||
onMount(() => {
|
||||
void data.location.integration
|
||||
.sync()
|
||||
.then(() => dialog.replace(() => <DialogIntegration integrationID="openai" autoConnect />))
|
||||
})
|
||||
return null
|
||||
}
|
||||
|
||||
const app = await testRender(
|
||||
() => (
|
||||
<TestTuiContexts>
|
||||
<ConfigProvider config={createTuiResolvedConfig()}>
|
||||
<Keymap.Provider>
|
||||
<ToastProvider>
|
||||
<ClientProvider api={createApi(calls.fetch)}>
|
||||
<DataProvider>
|
||||
<LocationProvider>
|
||||
<ThemeProvider mode="dark" source={emptyThemeSource}>
|
||||
<DialogProvider>
|
||||
<Probe />
|
||||
</DialogProvider>
|
||||
</ThemeProvider>
|
||||
</LocationProvider>
|
||||
</DataProvider>
|
||||
</ClientProvider>
|
||||
</ToastProvider>
|
||||
</Keymap.Provider>
|
||||
</ConfigProvider>
|
||||
</TestTuiContexts>
|
||||
),
|
||||
{ width: 100, height: 30, kittyKeyboard: true },
|
||||
)
|
||||
|
||||
app.renderer.start()
|
||||
await app.waitForFrame(
|
||||
(frame) => frame.includes("Add account") && frame.includes("Personal") && frame.includes("Work"),
|
||||
)
|
||||
await app.waitFor(() => app.renderer.currentFocusedEditor instanceof InputRenderable)
|
||||
|
||||
return {
|
||||
app,
|
||||
reads,
|
||||
requests,
|
||||
get accounts() {
|
||||
return accounts
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -2996,7 +2996,7 @@ describe("V2 mini transport", () => {
|
||||
await transport.close()
|
||||
})
|
||||
|
||||
test("refreshes catalogs on connection and location-scoped invalidations", async () => {
|
||||
test("refreshes catalogs on connection, location-scoped invalidations, and global credential switches", async () => {
|
||||
const events = feed()
|
||||
events.push(connected())
|
||||
const client = sdk({ streams: [events] })
|
||||
@@ -3030,6 +3030,19 @@ describe("V2 mini transport", () => {
|
||||
location: { directory: "/project", workspaceID: "work-1" },
|
||||
data: {},
|
||||
})
|
||||
events.push({
|
||||
id: "evt_credential.updated",
|
||||
created: 0,
|
||||
type: "credential.updated",
|
||||
data: {},
|
||||
})
|
||||
for (const credentialID of ["credential", null])
|
||||
events.push({
|
||||
id: `evt_credential.switched.${credentialID}`,
|
||||
created: 0,
|
||||
type: "credential.switched",
|
||||
data: { credentialID, integrationID: "integration" },
|
||||
})
|
||||
events.push({
|
||||
id: "evt_foreign_catalog",
|
||||
created: 0,
|
||||
@@ -3044,10 +3057,10 @@ describe("V2 mini transport", () => {
|
||||
location: { directory: "/project", workspaceID: "work-2" },
|
||||
data: {},
|
||||
})
|
||||
while (refreshes < 7) await Bun.sleep(0)
|
||||
while (refreshes < 9) await Bun.sleep(0)
|
||||
await Bun.sleep(0)
|
||||
|
||||
expect(refreshes).toBe(7)
|
||||
expect(refreshes).toBe(9)
|
||||
await transport.close()
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user