feat(api): add command authentication attempts
This commit is contained in:
@@ -466,6 +466,40 @@ export type IntegrationOauthCancelOperation<E = never> = (
|
|||||||
input: Endpoint10_6Input,
|
input: Endpoint10_6Input,
|
||||||
) => Effect.Effect<Endpoint10_6Output, E>
|
) => Effect.Effect<Endpoint10_6Output, E>
|
||||||
|
|
||||||
|
type Endpoint10_7Request = Parameters<RawClient["server.integration"]["integration.command.connect"]>[0]
|
||||||
|
export type Endpoint10_7Input = {
|
||||||
|
readonly integrationID: Endpoint10_7Request["params"]["integrationID"]
|
||||||
|
readonly location?: Endpoint10_7Request["query"]["location"]
|
||||||
|
readonly methodID: Endpoint10_7Request["payload"]["methodID"]
|
||||||
|
readonly label?: Endpoint10_7Request["payload"]["label"]
|
||||||
|
}
|
||||||
|
export type Endpoint10_7Output = EffectValue<ReturnType<RawClient["server.integration"]["integration.command.connect"]>>
|
||||||
|
export type IntegrationCommandConnectOperation<E = never> = (
|
||||||
|
input: Endpoint10_7Input,
|
||||||
|
) => Effect.Effect<Endpoint10_7Output, E>
|
||||||
|
|
||||||
|
type Endpoint10_8Request = Parameters<RawClient["server.integration"]["integration.command.status"]>[0]
|
||||||
|
export type Endpoint10_8Input = {
|
||||||
|
readonly integrationID: Endpoint10_8Request["params"]["integrationID"]
|
||||||
|
readonly attemptID: Endpoint10_8Request["params"]["attemptID"]
|
||||||
|
readonly location?: Endpoint10_8Request["query"]["location"]
|
||||||
|
}
|
||||||
|
export type Endpoint10_8Output = EffectValue<ReturnType<RawClient["server.integration"]["integration.command.status"]>>
|
||||||
|
export type IntegrationCommandStatusOperation<E = never> = (
|
||||||
|
input: Endpoint10_8Input,
|
||||||
|
) => Effect.Effect<Endpoint10_8Output, E>
|
||||||
|
|
||||||
|
type Endpoint10_9Request = Parameters<RawClient["server.integration"]["integration.command.cancel"]>[0]
|
||||||
|
export type Endpoint10_9Input = {
|
||||||
|
readonly integrationID: Endpoint10_9Request["params"]["integrationID"]
|
||||||
|
readonly attemptID: Endpoint10_9Request["params"]["attemptID"]
|
||||||
|
readonly location?: Endpoint10_9Request["query"]["location"]
|
||||||
|
}
|
||||||
|
export type Endpoint10_9Output = EffectValue<ReturnType<RawClient["server.integration"]["integration.command.cancel"]>>
|
||||||
|
export type IntegrationCommandCancelOperation<E = never> = (
|
||||||
|
input: Endpoint10_9Input,
|
||||||
|
) => Effect.Effect<Endpoint10_9Output, E>
|
||||||
|
|
||||||
export interface IntegrationApi<E = never> {
|
export interface IntegrationApi<E = never> {
|
||||||
readonly list: IntegrationListOperation<E>
|
readonly list: IntegrationListOperation<E>
|
||||||
readonly get: IntegrationGetOperation<E>
|
readonly get: IntegrationGetOperation<E>
|
||||||
@@ -476,6 +510,11 @@ export interface IntegrationApi<E = never> {
|
|||||||
readonly complete: IntegrationOauthCompleteOperation<E>
|
readonly complete: IntegrationOauthCompleteOperation<E>
|
||||||
readonly cancel: IntegrationOauthCancelOperation<E>
|
readonly cancel: IntegrationOauthCancelOperation<E>
|
||||||
}
|
}
|
||||||
|
readonly command: {
|
||||||
|
readonly connect: IntegrationCommandConnectOperation<E>
|
||||||
|
readonly status: IntegrationCommandStatusOperation<E>
|
||||||
|
readonly cancel: IntegrationCommandCancelOperation<E>
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type Endpoint11_0Request = Parameters<RawClient["server.mcp"]["mcp.list"]>[0]
|
type Endpoint11_0Request = Parameters<RawClient["server.mcp"]["mcp.list"]>[0]
|
||||||
|
|||||||
@@ -571,6 +571,44 @@ const Endpoint10_6 = (raw: RawClient["server.integration"]) => (input: Endpoint1
|
|||||||
query: { location: input["location"] },
|
query: { location: input["location"] },
|
||||||
}).pipe(Effect.mapError(mapClientError))
|
}).pipe(Effect.mapError(mapClientError))
|
||||||
|
|
||||||
|
type Endpoint10_7Request = Parameters<RawClient["server.integration"]["integration.command.connect"]>[0]
|
||||||
|
type Endpoint10_7Input = {
|
||||||
|
readonly integrationID: Endpoint10_7Request["params"]["integrationID"]
|
||||||
|
readonly location?: Endpoint10_7Request["query"]["location"]
|
||||||
|
readonly methodID: Endpoint10_7Request["payload"]["methodID"]
|
||||||
|
readonly label?: Endpoint10_7Request["payload"]["label"]
|
||||||
|
}
|
||||||
|
const Endpoint10_7 = (raw: RawClient["server.integration"]) => (input: Endpoint10_7Input) =>
|
||||||
|
raw["integration.command.connect"]({
|
||||||
|
params: { integrationID: input["integrationID"] },
|
||||||
|
query: { location: input["location"] },
|
||||||
|
payload: { methodID: input["methodID"], label: input["label"] },
|
||||||
|
}).pipe(Effect.mapError(mapClientError))
|
||||||
|
|
||||||
|
type Endpoint10_8Request = Parameters<RawClient["server.integration"]["integration.command.status"]>[0]
|
||||||
|
type Endpoint10_8Input = {
|
||||||
|
readonly integrationID: Endpoint10_8Request["params"]["integrationID"]
|
||||||
|
readonly attemptID: Endpoint10_8Request["params"]["attemptID"]
|
||||||
|
readonly location?: Endpoint10_8Request["query"]["location"]
|
||||||
|
}
|
||||||
|
const Endpoint10_8 = (raw: RawClient["server.integration"]) => (input: Endpoint10_8Input) =>
|
||||||
|
raw["integration.command.status"]({
|
||||||
|
params: { integrationID: input["integrationID"], attemptID: input["attemptID"] },
|
||||||
|
query: { location: input["location"] },
|
||||||
|
}).pipe(Effect.mapError(mapClientError))
|
||||||
|
|
||||||
|
type Endpoint10_9Request = Parameters<RawClient["server.integration"]["integration.command.cancel"]>[0]
|
||||||
|
type Endpoint10_9Input = {
|
||||||
|
readonly integrationID: Endpoint10_9Request["params"]["integrationID"]
|
||||||
|
readonly attemptID: Endpoint10_9Request["params"]["attemptID"]
|
||||||
|
readonly location?: Endpoint10_9Request["query"]["location"]
|
||||||
|
}
|
||||||
|
const Endpoint10_9 = (raw: RawClient["server.integration"]) => (input: Endpoint10_9Input) =>
|
||||||
|
raw["integration.command.cancel"]({
|
||||||
|
params: { integrationID: input["integrationID"], attemptID: input["attemptID"] },
|
||||||
|
query: { location: input["location"] },
|
||||||
|
}).pipe(Effect.mapError(mapClientError))
|
||||||
|
|
||||||
const adaptGroup10 = (raw: RawClient["server.integration"]) => ({
|
const adaptGroup10 = (raw: RawClient["server.integration"]) => ({
|
||||||
list: Endpoint10_0(raw),
|
list: Endpoint10_0(raw),
|
||||||
get: Endpoint10_1(raw),
|
get: Endpoint10_1(raw),
|
||||||
@@ -581,6 +619,7 @@ const adaptGroup10 = (raw: RawClient["server.integration"]) => ({
|
|||||||
complete: Endpoint10_5(raw),
|
complete: Endpoint10_5(raw),
|
||||||
cancel: Endpoint10_6(raw),
|
cancel: Endpoint10_6(raw),
|
||||||
},
|
},
|
||||||
|
command: { connect: Endpoint10_7(raw), status: Endpoint10_8(raw), cancel: Endpoint10_9(raw) },
|
||||||
})
|
})
|
||||||
|
|
||||||
type Endpoint11_0Request = Parameters<RawClient["server.mcp"]["mcp.list"]>[0]
|
type Endpoint11_0Request = Parameters<RawClient["server.mcp"]["mcp.list"]>[0]
|
||||||
|
|||||||
@@ -92,6 +92,12 @@ import type {
|
|||||||
IntegrationOauthCompleteOutput,
|
IntegrationOauthCompleteOutput,
|
||||||
IntegrationOauthCancelInput,
|
IntegrationOauthCancelInput,
|
||||||
IntegrationOauthCancelOutput,
|
IntegrationOauthCancelOutput,
|
||||||
|
IntegrationCommandConnectInput,
|
||||||
|
IntegrationCommandConnectOutput,
|
||||||
|
IntegrationCommandStatusInput,
|
||||||
|
IntegrationCommandStatusOutput,
|
||||||
|
IntegrationCommandCancelInput,
|
||||||
|
IntegrationCommandCancelOutput,
|
||||||
McpListInput,
|
McpListInput,
|
||||||
McpListOutput,
|
McpListOutput,
|
||||||
McpResourceCatalogInput,
|
McpResourceCatalogInput,
|
||||||
@@ -954,6 +960,45 @@ export function make(options: ClientOptions) {
|
|||||||
requestOptions,
|
requestOptions,
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
command: {
|
||||||
|
connect: (input: IntegrationCommandConnectInput, requestOptions?: RequestOptions) =>
|
||||||
|
request<IntegrationCommandConnectOutput>(
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
path: `/api/integration/${encodeURIComponent(input.integrationID)}/connect/command`,
|
||||||
|
query: { location: input["location"] },
|
||||||
|
body: { methodID: input["methodID"], label: input["label"] },
|
||||||
|
successStatus: 200,
|
||||||
|
declaredStatuses: [400, 401],
|
||||||
|
empty: false,
|
||||||
|
},
|
||||||
|
requestOptions,
|
||||||
|
),
|
||||||
|
status: (input: IntegrationCommandStatusInput, requestOptions?: RequestOptions) =>
|
||||||
|
request<IntegrationCommandStatusOutput>(
|
||||||
|
{
|
||||||
|
method: "GET",
|
||||||
|
path: `/api/integration/${encodeURIComponent(input.integrationID)}/connect/command/${encodeURIComponent(input.attemptID)}`,
|
||||||
|
query: { location: input["location"] },
|
||||||
|
successStatus: 200,
|
||||||
|
declaredStatuses: [401, 400],
|
||||||
|
empty: false,
|
||||||
|
},
|
||||||
|
requestOptions,
|
||||||
|
),
|
||||||
|
cancel: (input: IntegrationCommandCancelInput, requestOptions?: RequestOptions) =>
|
||||||
|
request<IntegrationCommandCancelOutput>(
|
||||||
|
{
|
||||||
|
method: "DELETE",
|
||||||
|
path: `/api/integration/${encodeURIComponent(input.integrationID)}/connect/command/${encodeURIComponent(input.attemptID)}`,
|
||||||
|
query: { location: input["location"] },
|
||||||
|
successStatus: 204,
|
||||||
|
declaredStatuses: [401, 400],
|
||||||
|
empty: true,
|
||||||
|
},
|
||||||
|
requestOptions,
|
||||||
|
),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
mcp: {
|
mcp: {
|
||||||
list: (input?: McpListInput, requestOptions?: RequestOptions) =>
|
list: (input?: McpListInput, requestOptions?: RequestOptions) =>
|
||||||
|
|||||||
@@ -187,6 +187,8 @@ export type ProviderV2Info = {
|
|||||||
|
|
||||||
export type IntegrationWhen = { key: string; op: "eq" | "neq"; value: string }
|
export type IntegrationWhen = { key: string; op: "eq" | "neq"; value: string }
|
||||||
|
|
||||||
|
export type IntegrationCommandMethod = { id: string; type: "command"; label: string; command: Array<string> }
|
||||||
|
|
||||||
export type IntegrationKeyMethod = { type: "key"; label?: string }
|
export type IntegrationKeyMethod = { type: "key"; label?: string }
|
||||||
|
|
||||||
export type IntegrationEnvMethod = { type: "env"; names: Array<string> }
|
export type IntegrationEnvMethod = { type: "env"; names: Array<string> }
|
||||||
@@ -214,6 +216,31 @@ export type IntegrationAttemptStatus =
|
|||||||
time: { created: number | "Infinity" | "-Infinity" | "NaN"; expires: number | "Infinity" | "-Infinity" | "NaN" }
|
time: { created: number | "Infinity" | "-Infinity" | "NaN"; expires: number | "Infinity" | "-Infinity" | "NaN" }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type IntegrationCommandAttempt = {
|
||||||
|
attemptID: string
|
||||||
|
time: { created: number | "Infinity" | "-Infinity" | "NaN"; expires: number | "Infinity" | "-Infinity" | "NaN" }
|
||||||
|
}
|
||||||
|
|
||||||
|
export type IntegrationCommandAttemptStatus =
|
||||||
|
| {
|
||||||
|
status: "pending"
|
||||||
|
message?: string
|
||||||
|
time: { created: number | "Infinity" | "-Infinity" | "NaN"; expires: number | "Infinity" | "-Infinity" | "NaN" }
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
status: "complete"
|
||||||
|
time: { created: number | "Infinity" | "-Infinity" | "NaN"; expires: number | "Infinity" | "-Infinity" | "NaN" }
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
status: "failed"
|
||||||
|
message: string
|
||||||
|
time: { created: number | "Infinity" | "-Infinity" | "NaN"; expires: number | "Infinity" | "-Infinity" | "NaN" }
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
status: "expired"
|
||||||
|
time: { created: number | "Infinity" | "-Infinity" | "NaN"; expires: number | "Infinity" | "-Infinity" | "NaN" }
|
||||||
|
}
|
||||||
|
|
||||||
export type McpStatusConnected = { status: "connected" }
|
export type McpStatusConnected = { status: "connected" }
|
||||||
|
|
||||||
export type McpStatusPending = { status: "pending" }
|
export type McpStatusPending = { status: "pending" }
|
||||||
@@ -1992,7 +2019,11 @@ export type SessionMessageAssistantTool = {
|
|||||||
time: { created: number; ran?: number; completed?: number }
|
time: { created: number; ran?: number; completed?: number }
|
||||||
}
|
}
|
||||||
|
|
||||||
export type IntegrationMethod = IntegrationOAuthMethod | IntegrationKeyMethod | IntegrationEnvMethod
|
export type IntegrationMethod =
|
||||||
|
| IntegrationOAuthMethod
|
||||||
|
| IntegrationCommandMethod
|
||||||
|
| IntegrationKeyMethod
|
||||||
|
| IntegrationEnvMethod
|
||||||
|
|
||||||
export type FormFields = [FormField, ...Array<FormField>]
|
export type FormFields = [FormField, ...Array<FormField>]
|
||||||
|
|
||||||
@@ -3351,6 +3382,43 @@ export type IntegrationOauthCancelInput = {
|
|||||||
|
|
||||||
export type IntegrationOauthCancelOutput = void
|
export type IntegrationOauthCancelOutput = void
|
||||||
|
|
||||||
|
export type IntegrationCommandConnectInput = {
|
||||||
|
readonly integrationID: { readonly integrationID: string }["integrationID"]
|
||||||
|
readonly location?: {
|
||||||
|
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||||
|
}["location"]
|
||||||
|
readonly methodID: { readonly methodID: string; readonly label?: string | undefined }["methodID"]
|
||||||
|
readonly label?: { readonly methodID: string; readonly label?: string | undefined }["label"]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type IntegrationCommandConnectOutput = {
|
||||||
|
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
|
||||||
|
data: IntegrationCommandAttempt
|
||||||
|
}
|
||||||
|
|
||||||
|
export type IntegrationCommandStatusInput = {
|
||||||
|
readonly integrationID: { readonly integrationID: string; readonly attemptID: string }["integrationID"]
|
||||||
|
readonly attemptID: { readonly integrationID: string; readonly attemptID: string }["attemptID"]
|
||||||
|
readonly location?: {
|
||||||
|
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||||
|
}["location"]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type IntegrationCommandStatusOutput = {
|
||||||
|
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
|
||||||
|
data: IntegrationCommandAttemptStatus
|
||||||
|
}
|
||||||
|
|
||||||
|
export type IntegrationCommandCancelInput = {
|
||||||
|
readonly integrationID: { readonly integrationID: string; readonly attemptID: string }["integrationID"]
|
||||||
|
readonly attemptID: { readonly integrationID: string; readonly attemptID: string }["attemptID"]
|
||||||
|
readonly location?: {
|
||||||
|
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||||
|
}["location"]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type IntegrationCommandCancelOutput = void
|
||||||
|
|
||||||
export type McpListInput = {
|
export type McpListInput = {
|
||||||
readonly location?: {
|
readonly location?: {
|
||||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||||
|
|||||||
@@ -36,9 +36,10 @@ test("exposes every standard HTTP API group", () => {
|
|||||||
expect(Object.keys(client.debug)).toEqual(["location"])
|
expect(Object.keys(client.debug)).toEqual(["location"])
|
||||||
expect(Object.keys(client.debug.location)).toEqual(["list", "evict"])
|
expect(Object.keys(client.debug.location)).toEqual(["list", "evict"])
|
||||||
expect(Object.keys(client.message)).toEqual(["list"])
|
expect(Object.keys(client.message)).toEqual(["list"])
|
||||||
expect(Object.keys(client.integration)).toEqual(["list", "get", "connect", "oauth"])
|
expect(Object.keys(client.integration)).toEqual(["list", "get", "connect", "oauth", "command"])
|
||||||
expect(Object.keys(client.integration.connect)).toEqual(["key"])
|
expect(Object.keys(client.integration.connect)).toEqual(["key"])
|
||||||
expect(Object.keys(client.integration.oauth)).toEqual(["connect", "status", "complete", "cancel"])
|
expect(Object.keys(client.integration.oauth)).toEqual(["connect", "status", "complete", "cancel"])
|
||||||
|
expect(Object.keys(client.integration.command)).toEqual(["connect", "status", "cancel"])
|
||||||
expect(Object.keys(client.file)).toEqual(["read", "list", "find"])
|
expect(Object.keys(client.file)).toEqual(["read", "list", "find"])
|
||||||
expect(Object.keys(client.vcs)).toEqual(["status", "diff"])
|
expect(Object.keys(client.vcs)).toEqual(["status", "diff"])
|
||||||
expect(Object.keys(client.pty)).toEqual(["list", "create", "get", "update", "remove"])
|
expect(Object.keys(client.pty)).toEqual(["list", "create", "get", "update", "remove"])
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
Schedule,
|
Schedule,
|
||||||
Schema,
|
Schema,
|
||||||
Scope,
|
Scope,
|
||||||
|
Stream,
|
||||||
SynchronizedRef,
|
SynchronizedRef,
|
||||||
Types,
|
Types,
|
||||||
} from "effect"
|
} from "effect"
|
||||||
@@ -20,6 +21,8 @@ import { Credential } from "./credential"
|
|||||||
import { State } from "./state"
|
import { State } from "./state"
|
||||||
import { EventV2 } from "./event"
|
import { EventV2 } from "./event"
|
||||||
import { IntegrationConnection } from "./integration/connection"
|
import { IntegrationConnection } from "./integration/connection"
|
||||||
|
import { AppProcess } from "./process"
|
||||||
|
import { ChildProcess } from "effect/unstable/process"
|
||||||
|
|
||||||
export const ID = Integration.ID
|
export const ID = Integration.ID
|
||||||
export type ID = Integration.ID
|
export type ID = Integration.ID
|
||||||
@@ -45,6 +48,9 @@ export type Prompt = Integration.Prompt
|
|||||||
export const OAuthMethod = Integration.OAuthMethod
|
export const OAuthMethod = Integration.OAuthMethod
|
||||||
export type OAuthMethod = Integration.OAuthMethod
|
export type OAuthMethod = Integration.OAuthMethod
|
||||||
|
|
||||||
|
export const CommandMethod = Integration.CommandMethod
|
||||||
|
export type CommandMethod = Integration.CommandMethod
|
||||||
|
|
||||||
export const KeyMethod = Integration.KeyMethod
|
export const KeyMethod = Integration.KeyMethod
|
||||||
export type KeyMethod = Integration.KeyMethod
|
export type KeyMethod = Integration.KeyMethod
|
||||||
|
|
||||||
@@ -87,12 +93,17 @@ export interface KeyImplementation {
|
|||||||
readonly method: KeyMethod
|
readonly method: KeyMethod
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface CommandImplementation {
|
||||||
|
readonly integrationID: ID
|
||||||
|
readonly method: CommandMethod
|
||||||
|
}
|
||||||
|
|
||||||
export interface EnvImplementation {
|
export interface EnvImplementation {
|
||||||
readonly integrationID: ID
|
readonly integrationID: ID
|
||||||
readonly method: EnvMethod
|
readonly method: EnvMethod
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Implementation = OAuthImplementation | KeyImplementation | EnvImplementation
|
export type Implementation = OAuthImplementation | CommandImplementation | KeyImplementation | EnvImplementation
|
||||||
|
|
||||||
export const Attempt = Integration.Attempt
|
export const Attempt = Integration.Attempt
|
||||||
export type Attempt = Integration.Attempt
|
export type Attempt = Integration.Attempt
|
||||||
@@ -100,6 +111,12 @@ export type Attempt = Integration.Attempt
|
|||||||
export const AttemptStatus = Integration.AttemptStatus
|
export const AttemptStatus = Integration.AttemptStatus
|
||||||
export type AttemptStatus = typeof AttemptStatus.Type
|
export type AttemptStatus = typeof AttemptStatus.Type
|
||||||
|
|
||||||
|
export const CommandAttempt = Integration.CommandAttempt
|
||||||
|
export type CommandAttempt = Integration.CommandAttempt
|
||||||
|
|
||||||
|
export const CommandAttemptStatus = Integration.CommandAttemptStatus
|
||||||
|
export type CommandAttemptStatus = Integration.CommandAttemptStatus
|
||||||
|
|
||||||
export class CodeRequiredError extends Schema.TaggedErrorClass<CodeRequiredError>()("Integration.CodeRequired", {
|
export class CodeRequiredError extends Schema.TaggedErrorClass<CodeRequiredError>()("Integration.CodeRequired", {
|
||||||
attemptID: AttemptID,
|
attemptID: AttemptID,
|
||||||
}) {}
|
}) {}
|
||||||
@@ -189,6 +206,18 @@ export interface Interface extends State.Transformable<Draft> {
|
|||||||
/** Cancels an attempt and releases its resources. */
|
/** Cancels an attempt and releases its resources. */
|
||||||
readonly cancel: (input: { readonly integrationID: ID; readonly attemptID: AttemptID }) => Effect.Effect<void>
|
readonly cancel: (input: { readonly integrationID: ID; readonly attemptID: AttemptID }) => Effect.Effect<void>
|
||||||
}
|
}
|
||||||
|
readonly command: {
|
||||||
|
readonly connect: (input: {
|
||||||
|
readonly integrationID: ID
|
||||||
|
readonly methodID: MethodID
|
||||||
|
readonly label?: string
|
||||||
|
}) => Effect.Effect<CommandAttempt, AuthorizationError>
|
||||||
|
readonly status: (input: {
|
||||||
|
readonly integrationID: ID
|
||||||
|
readonly attemptID: AttemptID
|
||||||
|
}) => Effect.Effect<CommandAttemptStatus>
|
||||||
|
readonly cancel: (input: { readonly integrationID: ID; readonly attemptID: AttemptID }) => Effect.Effect<void>
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Integration") {}
|
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Integration") {}
|
||||||
@@ -218,13 +247,33 @@ type TerminalAttempt = {
|
|||||||
}
|
}
|
||||||
type AttemptEntry = PendingAttempt | TerminalAttempt
|
type AttemptEntry = PendingAttempt | TerminalAttempt
|
||||||
|
|
||||||
|
type PendingCommandAttempt = {
|
||||||
|
status: "pending"
|
||||||
|
integrationID: ID
|
||||||
|
label?: string
|
||||||
|
message?: string
|
||||||
|
persisting: boolean
|
||||||
|
scope: Scope.Closeable
|
||||||
|
time: AttemptTime
|
||||||
|
}
|
||||||
|
type TerminalCommandAttempt = {
|
||||||
|
status: "complete" | "failed" | "expired"
|
||||||
|
integrationID: ID
|
||||||
|
message?: string
|
||||||
|
removeAt: number
|
||||||
|
time: AttemptTime
|
||||||
|
}
|
||||||
|
type CommandAttemptEntry = PendingCommandAttempt | TerminalCommandAttempt
|
||||||
|
|
||||||
const layer = Layer.effect(
|
const layer = Layer.effect(
|
||||||
Service,
|
Service,
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const credentials = yield* Credential.Service
|
const credentials = yield* Credential.Service
|
||||||
const events = yield* EventV2.Service
|
const events = yield* EventV2.Service
|
||||||
|
const processes = yield* AppProcess.Service
|
||||||
const scope = yield* Scope.Scope
|
const scope = yield* Scope.Scope
|
||||||
const attempts = SynchronizedRef.makeUnsafe(new Map<AttemptID, AttemptEntry>())
|
const attempts = SynchronizedRef.makeUnsafe(new Map<AttemptID, AttemptEntry>())
|
||||||
|
const commandAttempts = SynchronizedRef.makeUnsafe(new Map<AttemptID, CommandAttemptEntry>())
|
||||||
const state = State.create<Data, Draft>({
|
const state = State.create<Data, Draft>({
|
||||||
name: "integration",
|
name: "integration",
|
||||||
initial: () => ({ integrations: new Map<ID, Entry>() }),
|
initial: () => ({ integrations: new Map<ID, Entry>() }),
|
||||||
@@ -258,8 +307,11 @@ const layer = Layer.effect(
|
|||||||
}
|
}
|
||||||
const index = current.methods.findIndex((method) => {
|
const index = current.methods.findIndex((method) => {
|
||||||
if (method.type !== implementation.method.type) return false
|
if (method.type !== implementation.method.type) return false
|
||||||
if (method.type !== "oauth" || implementation.method.type !== "oauth") return true
|
if (method.type === "oauth" && implementation.method.type === "oauth")
|
||||||
return method.id === implementation.method.id
|
return method.id === implementation.method.id
|
||||||
|
if (method.type === "command" && implementation.method.type === "command")
|
||||||
|
return method.id === implementation.method.id
|
||||||
|
return true
|
||||||
})
|
})
|
||||||
if (index === -1) current.methods.push(implementation.method as Types.DeepMutable<Method>)
|
if (index === -1) current.methods.push(implementation.method as Types.DeepMutable<Method>)
|
||||||
else current.methods[index] = implementation.method as Types.DeepMutable<Method>
|
else current.methods[index] = implementation.method as Types.DeepMutable<Method>
|
||||||
@@ -275,8 +327,9 @@ const layer = Layer.effect(
|
|||||||
if (!current) return
|
if (!current) return
|
||||||
const index = current.methods.findIndex((candidate) => {
|
const index = current.methods.findIndex((candidate) => {
|
||||||
if (candidate.type !== method.type) return false
|
if (candidate.type !== method.type) return false
|
||||||
if (candidate.type !== "oauth" || method.type !== "oauth") return true
|
if (candidate.type === "oauth" && method.type === "oauth") return candidate.id === method.id
|
||||||
return candidate.id === method.id
|
if (candidate.type === "command" && method.type === "command") return candidate.id === method.id
|
||||||
|
return true
|
||||||
})
|
})
|
||||||
if (index !== -1) current.methods.splice(index, 1)
|
if (index !== -1) current.methods.splice(index, 1)
|
||||||
if (method.type === "oauth") current.implementations.delete(method.id)
|
if (method.type === "oauth") current.implementations.delete(method.id)
|
||||||
@@ -385,6 +438,61 @@ const layer = Layer.effect(
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const settleCommand = Effect.fnUntraced(function* (attemptID: AttemptID, exit: Exit.Exit<string, unknown>) {
|
||||||
|
return yield* Effect.uninterruptible(
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const now = yield* Clock.currentTimeMillis
|
||||||
|
const attempt = yield* SynchronizedRef.modify(commandAttempts, (current) => {
|
||||||
|
const match = current.get(attemptID)
|
||||||
|
if (!match || match.status !== "pending" || match.persisting) return [undefined, current]
|
||||||
|
const next = Exit.isSuccess(exit)
|
||||||
|
? { ...match, persisting: true }
|
||||||
|
: {
|
||||||
|
status: "failed" as const,
|
||||||
|
integrationID: match.integrationID,
|
||||||
|
message: message(exit.cause),
|
||||||
|
time: match.time,
|
||||||
|
removeAt: now + terminalRetention,
|
||||||
|
}
|
||||||
|
return [match, new Map(current).set(attemptID, next)]
|
||||||
|
})
|
||||||
|
if (!attempt) return
|
||||||
|
if (Exit.isFailure(exit)) {
|
||||||
|
yield* close(attempt.scope)
|
||||||
|
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 settledAt = yield* Clock.currentTimeMillis
|
||||||
|
const terminal: TerminalCommandAttempt = Exit.isSuccess(persistence)
|
||||||
|
? {
|
||||||
|
status: "complete",
|
||||||
|
integrationID: attempt.integrationID,
|
||||||
|
time: attempt.time,
|
||||||
|
removeAt: settledAt + terminalRetention,
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
status: "failed",
|
||||||
|
integrationID: attempt.integrationID,
|
||||||
|
message: message(persistence.cause),
|
||||||
|
time: attempt.time,
|
||||||
|
removeAt: settledAt + terminalRetention,
|
||||||
|
}
|
||||||
|
yield* SynchronizedRef.update(commandAttempts, (current) => new Map(current).set(attemptID, terminal))
|
||||||
|
yield* close(attempt.scope)
|
||||||
|
if (Exit.isFailure(persistence)) return
|
||||||
|
yield* events.publish(Event.ConnectionUpdated, { integrationID: attempt.integrationID })
|
||||||
|
yield* events.publish(Event.Updated, {})
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
const scrub = Effect.fnUntraced(function* () {
|
const scrub = Effect.fnUntraced(function* () {
|
||||||
const now = yield* Clock.currentTimeMillis
|
const now = yield* Clock.currentTimeMillis
|
||||||
const expired = yield* SynchronizedRef.modify(attempts, (current) => {
|
const expired = yield* SynchronizedRef.modify(attempts, (current) => {
|
||||||
@@ -408,7 +516,31 @@ const layer = Layer.effect(
|
|||||||
yield* Effect.forEach(expired, close, { discard: true })
|
yield* Effect.forEach(expired, close, { discard: true })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const scrubCommands = Effect.fnUntraced(function* () {
|
||||||
|
const now = yield* Clock.currentTimeMillis
|
||||||
|
const expired = yield* SynchronizedRef.modify(commandAttempts, (current) => {
|
||||||
|
const next = new Map(current)
|
||||||
|
const scopes: Scope.Closeable[] = []
|
||||||
|
for (const [id, attempt] of current) {
|
||||||
|
if (attempt.status === "pending" && !attempt.persisting && attempt.time.expires <= now) {
|
||||||
|
scopes.push(attempt.scope)
|
||||||
|
next.set(id, {
|
||||||
|
status: "expired",
|
||||||
|
integrationID: attempt.integrationID,
|
||||||
|
time: attempt.time,
|
||||||
|
removeAt: now + terminalRetention,
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (attempt.status !== "pending" && attempt.removeAt <= now) next.delete(id)
|
||||||
|
}
|
||||||
|
return [scopes, next]
|
||||||
|
})
|
||||||
|
yield* Effect.forEach(expired, close, { discard: true })
|
||||||
|
})
|
||||||
|
|
||||||
yield* scrub().pipe(Effect.repeat(Schedule.spaced(scrubInterval)), Effect.forkIn(scope))
|
yield* scrub().pipe(Effect.repeat(Schedule.spaced(scrubInterval)), Effect.forkIn(scope))
|
||||||
|
yield* scrubCommands().pipe(Effect.repeat(Schedule.spaced(scrubInterval)), Effect.forkIn(scope))
|
||||||
|
|
||||||
const connectOAuth = Effect.fn("Integration.oauth.connect")(function* (input: {
|
const connectOAuth = Effect.fn("Integration.oauth.connect")(function* (input: {
|
||||||
readonly integrationID: ID
|
readonly integrationID: ID
|
||||||
@@ -457,6 +589,66 @@ const layer = Layer.effect(
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const connectCommand = Effect.fn("Integration.command.connect")(function* (input: {
|
||||||
|
readonly integrationID: ID
|
||||||
|
readonly methodID: MethodID
|
||||||
|
readonly label?: string
|
||||||
|
}) {
|
||||||
|
const method = state
|
||||||
|
.get()
|
||||||
|
.integrations.get(input.integrationID)
|
||||||
|
?.methods.find((method) => method.type === "command" && method.id === input.methodID)
|
||||||
|
if (!method || method.type !== "command" || !method.command[0]) {
|
||||||
|
return yield* Effect.die(new Error(`Command method not found: ${input.integrationID}/${input.methodID}`))
|
||||||
|
}
|
||||||
|
|
||||||
|
const attemptScope = yield* Scope.fork(scope)
|
||||||
|
const attemptID = AttemptID.create()
|
||||||
|
const created = yield* Clock.currentTimeMillis
|
||||||
|
const time = { created, expires: created + attemptLifetime }
|
||||||
|
yield* SynchronizedRef.update(commandAttempts, (current) =>
|
||||||
|
new Map(current).set(attemptID, {
|
||||||
|
status: "pending",
|
||||||
|
integrationID: input.integrationID,
|
||||||
|
label: input.label,
|
||||||
|
persisting: false,
|
||||||
|
scope: attemptScope,
|
||||||
|
time,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
yield* processes
|
||||||
|
.runStream(
|
||||||
|
ChildProcess.make(method.command[0], method.command.slice(1), {
|
||||||
|
extendEnv: true,
|
||||||
|
stdin: "ignore",
|
||||||
|
}),
|
||||||
|
{ okExitCodes: [0] },
|
||||||
|
)
|
||||||
|
.pipe(
|
||||||
|
Stream.tap((line) =>
|
||||||
|
SynchronizedRef.update(commandAttempts, (current) => {
|
||||||
|
const attempt = current.get(attemptID)
|
||||||
|
if (!attempt || attempt.status !== "pending") return current
|
||||||
|
const message = attempt.message ? `${attempt.message}\n${line}` : line
|
||||||
|
return new Map(current).set(attemptID, { ...attempt, message })
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
Stream.runCollect,
|
||||||
|
Effect.flatMap((lines) => {
|
||||||
|
const credential = Array.from(lines).at(-1)
|
||||||
|
return credential
|
||||||
|
? Effect.succeed(credential)
|
||||||
|
: Effect.fail(new Error("Authentication command returned no credential"))
|
||||||
|
}),
|
||||||
|
Effect.exit,
|
||||||
|
Effect.flatMap((exit) => settleCommand(attemptID, exit)),
|
||||||
|
Effect.forkIn(attemptScope, { startImmediately: true }),
|
||||||
|
)
|
||||||
|
|
||||||
|
return CommandAttempt.make({ attemptID, time })
|
||||||
|
})
|
||||||
|
|
||||||
return Service.of({
|
return Service.of({
|
||||||
transform: state.transform,
|
transform: state.transform,
|
||||||
reload: state.reload,
|
reload: state.reload,
|
||||||
@@ -572,8 +764,42 @@ const layer = Layer.effect(
|
|||||||
if (attempt) yield* Scope.close(attempt.scope, Exit.void)
|
if (attempt) yield* Scope.close(attempt.scope, Exit.void)
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
|
command: {
|
||||||
|
connect: connectCommand,
|
||||||
|
status: Effect.fn("Integration.command.status")(function* (input) {
|
||||||
|
const attempt = (yield* SynchronizedRef.get(commandAttempts)).get(input.attemptID)
|
||||||
|
if (!attempt || attempt.integrationID !== input.integrationID)
|
||||||
|
return yield* Effect.die(new Error(`Command attempt not found: ${input.attemptID}`))
|
||||||
|
if (attempt.status === "pending") {
|
||||||
|
return {
|
||||||
|
status: attempt.status,
|
||||||
|
...(attempt.message ? { message: attempt.message } : {}),
|
||||||
|
time: attempt.time,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (attempt.status === "failed") {
|
||||||
|
return { status: attempt.status, message: attempt.message ?? "Authentication failed", time: attempt.time }
|
||||||
|
}
|
||||||
|
return { status: attempt.status, time: attempt.time }
|
||||||
|
}),
|
||||||
|
cancel: Effect.fn("Integration.command.cancel")(function* (input) {
|
||||||
|
const attempt = yield* SynchronizedRef.modify(commandAttempts, (current) => {
|
||||||
|
const match = current.get(input.attemptID)
|
||||||
|
if (!match || match.integrationID !== input.integrationID || match.status !== "pending" || match.persisting)
|
||||||
|
return [undefined, current]
|
||||||
|
const next = new Map(current)
|
||||||
|
next.delete(input.attemptID)
|
||||||
|
return [match, next]
|
||||||
|
})
|
||||||
|
if (attempt) yield* Scope.close(attempt.scope, Exit.void)
|
||||||
|
}),
|
||||||
|
},
|
||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
export const node = makeLocationNode({ service: Service, layer, deps: [Credential.node, EventV2.node] })
|
export const node = makeLocationNode({
|
||||||
|
service: Service,
|
||||||
|
layer,
|
||||||
|
deps: [Credential.node, EventV2.node, AppProcess.node],
|
||||||
|
})
|
||||||
|
|||||||
@@ -203,6 +203,28 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int
|
|||||||
attemptID: Integration.AttemptID.make(input.attemptID),
|
attemptID: Integration.AttemptID.make(input.attemptID),
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
|
command: {
|
||||||
|
connect: (input) =>
|
||||||
|
response(
|
||||||
|
integration.command.connect({
|
||||||
|
integrationID: Integration.ID.make(input.integrationID),
|
||||||
|
methodID: Integration.MethodID.make(input.methodID),
|
||||||
|
label: input.label,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
status: (input) =>
|
||||||
|
response(
|
||||||
|
integration.command.status({
|
||||||
|
integrationID: Integration.ID.make(input.integrationID),
|
||||||
|
attemptID: Integration.AttemptID.make(input.attemptID),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
cancel: (input) =>
|
||||||
|
integration.command.cancel({
|
||||||
|
integrationID: Integration.ID.make(input.integrationID),
|
||||||
|
attemptID: Integration.AttemptID.make(input.attemptID),
|
||||||
|
}),
|
||||||
|
},
|
||||||
reload: integration.reload,
|
reload: integration.reload,
|
||||||
connection: {
|
connection: {
|
||||||
active: (id) => integration.connection.active(Integration.ID.make(id)),
|
active: (id) => integration.connection.active(Integration.ID.make(id)),
|
||||||
@@ -281,6 +303,13 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int
|
|||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (input.method.type === "command") {
|
||||||
|
draft.method.update({
|
||||||
|
integrationID: Integration.ID.make(input.integrationID),
|
||||||
|
method: Schema.decodeUnknownSync(Integration.CommandMethod)(input.method),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
draft.method.update({
|
draft.method.update({
|
||||||
integrationID: Integration.ID.make(input.integrationID),
|
integrationID: Integration.ID.make(input.integrationID),
|
||||||
method: { type: "key", label: input.method.label },
|
method: { type: "key", label: input.method.label },
|
||||||
|
|||||||
@@ -135,6 +135,32 @@ export function fromPromise(plugin: Plugin) {
|
|||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
command: {
|
||||||
|
connect: (input) =>
|
||||||
|
run(
|
||||||
|
host.integration.command.connect({
|
||||||
|
...input,
|
||||||
|
integrationID: Integration.ID.make(input.integrationID),
|
||||||
|
methodID: Integration.MethodID.make(input.methodID),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
status: (input) =>
|
||||||
|
run(
|
||||||
|
host.integration.command.status({
|
||||||
|
...input,
|
||||||
|
integrationID: Integration.ID.make(input.integrationID),
|
||||||
|
attemptID: Integration.AttemptID.make(input.attemptID),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
cancel: (input) =>
|
||||||
|
run(
|
||||||
|
host.integration.command.cancel({
|
||||||
|
...input,
|
||||||
|
integrationID: Integration.ID.make(input.integrationID),
|
||||||
|
attemptID: Integration.AttemptID.make(input.attemptID),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
},
|
||||||
transform: transform(host.integration),
|
transform: transform(host.integration),
|
||||||
reload: () => run(host.integration.reload()),
|
reload: () => run(host.integration.reload()),
|
||||||
connection: {
|
connection: {
|
||||||
|
|||||||
@@ -29,6 +29,20 @@ const failingIt = testEffect(
|
|||||||
AppNodeBuilder.build(LayerNode.group([Integration.node, EventV2.node]), [[Credential.node, failingCredentialNode]]),
|
AppNodeBuilder.build(LayerNode.group([Integration.node, EventV2.node]), [[Credential.node, failingCredentialNode]]),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
function eventually<A, E, R>(
|
||||||
|
effect: Effect.Effect<A, E, R>,
|
||||||
|
predicate: (value: A) => boolean,
|
||||||
|
remaining = 1000,
|
||||||
|
): Effect.Effect<A, E | Error, R> {
|
||||||
|
return Effect.gen(function* () {
|
||||||
|
const value = yield* effect
|
||||||
|
if (predicate(value)) return value
|
||||||
|
if (remaining === 0) return yield* Effect.fail(new Error("Timed out waiting for value"))
|
||||||
|
yield* Effect.promise(() => Bun.sleep(1))
|
||||||
|
return yield* eventually(effect, predicate, remaining - 1)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
describe("Integration", () => {
|
describe("Integration", () => {
|
||||||
it.effect("registers integrations through the editor", () =>
|
it.effect("registers integrations through the editor", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
@@ -151,6 +165,51 @@ describe("Integration", () => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
it.live("runs command authentication and stores the final output line", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const integrations = yield* Integration.Service
|
||||||
|
const credentials = yield* Credential.Service
|
||||||
|
const integrationID = Integration.ID.make("company")
|
||||||
|
const methodID = Integration.MethodID.make("login")
|
||||||
|
yield* integrations.transform((editor) =>
|
||||||
|
editor.method.update({
|
||||||
|
integrationID,
|
||||||
|
method: {
|
||||||
|
id: methodID,
|
||||||
|
type: "command",
|
||||||
|
label: "Log in",
|
||||||
|
command: [
|
||||||
|
process.execPath,
|
||||||
|
"-e",
|
||||||
|
'console.log("https://example.com/login"); await Bun.sleep(50); console.log("secret")',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const attempt = yield* integrations.command.connect({ integrationID, methodID, label: "Work" })
|
||||||
|
const pending = yield* eventually(
|
||||||
|
integrations.command.status({ integrationID, attemptID: attempt.attemptID }),
|
||||||
|
(status) => status.status === "pending" && status.message?.includes("https://example.com/login") === true,
|
||||||
|
)
|
||||||
|
expect(pending).toMatchObject({ status: "pending", message: "https://example.com/login" })
|
||||||
|
|
||||||
|
expect(
|
||||||
|
yield* eventually(
|
||||||
|
integrations.command.status({ integrationID, attemptID: attempt.attemptID }),
|
||||||
|
(status) => status.status === "complete",
|
||||||
|
),
|
||||||
|
).toEqual({ status: "complete", time: attempt.time })
|
||||||
|
expect(yield* credentials.list(integrationID)).toEqual([
|
||||||
|
expect.objectContaining({
|
||||||
|
integrationID,
|
||||||
|
label: "Work",
|
||||||
|
value: Credential.Key.make({ type: "key", key: "secret" }),
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
it.effect("completes code OAuth once and stores the credential", () =>
|
it.effect("completes code OAuth once and stores the credential", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const integrations = yield* Integration.Service
|
const integrations = yield* Integration.Service
|
||||||
|
|||||||
@@ -196,6 +196,11 @@ function resourceMcpLayer(url: string, onFormCreated?: (form: Form.Info) => Effe
|
|||||||
complete: unusedIntegration,
|
complete: unusedIntegration,
|
||||||
cancel: unusedIntegration,
|
cancel: unusedIntegration,
|
||||||
},
|
},
|
||||||
|
command: {
|
||||||
|
connect: unusedIntegration,
|
||||||
|
status: unusedIntegration,
|
||||||
|
cancel: unusedIntegration,
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
Layer.mock(Credential.Service, {}),
|
Layer.mock(Credential.Service, {}),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -5,7 +5,12 @@ import { Credential } from "@opencode-ai/core/credential"
|
|||||||
import { Integration } from "@opencode-ai/core/integration"
|
import { Integration } from "@opencode-ai/core/integration"
|
||||||
import { ModelV2 } from "@opencode-ai/core/model"
|
import { ModelV2 } from "@opencode-ai/core/model"
|
||||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||||
import type { IntegrationEnvMethod, IntegrationKeyMethod, IntegrationOAuthMethod } from "@opencode-ai/sdk/v2/types"
|
import type {
|
||||||
|
IntegrationCommandMethod,
|
||||||
|
IntegrationEnvMethod,
|
||||||
|
IntegrationKeyMethod,
|
||||||
|
IntegrationOAuthMethod,
|
||||||
|
} from "@opencode-ai/sdk/v2/types"
|
||||||
import { Effect, Stream } from "effect"
|
import { Effect, Stream } from "effect"
|
||||||
|
|
||||||
type Overrides = Partial<Omit<PluginContext, "options" | "session">> & {
|
type Overrides = Partial<Omit<PluginContext, "options" | "session">> & {
|
||||||
@@ -55,6 +60,11 @@ export function host(overrides: Overrides = {}): PluginContext {
|
|||||||
complete: () => Effect.die("unused integration.oauth.complete"),
|
complete: () => Effect.die("unused integration.oauth.complete"),
|
||||||
cancel: () => Effect.die("unused integration.oauth.cancel"),
|
cancel: () => Effect.die("unused integration.oauth.cancel"),
|
||||||
},
|
},
|
||||||
|
command: {
|
||||||
|
connect: () => Effect.die("unused integration.command.connect"),
|
||||||
|
status: () => Effect.die("unused integration.command.status"),
|
||||||
|
cancel: () => Effect.die("unused integration.command.cancel"),
|
||||||
|
},
|
||||||
transform: () => Effect.die("unused integration.transform"),
|
transform: () => Effect.die("unused integration.transform"),
|
||||||
reload: () => Effect.die("unused integration.reload"),
|
reload: () => Effect.die("unused integration.reload"),
|
||||||
connection: {
|
connection: {
|
||||||
@@ -200,6 +210,11 @@ export function integrationHost(integration: Integration.Interface): PluginConte
|
|||||||
complete: () => Effect.die("unused integration.oauth.complete"),
|
complete: () => Effect.die("unused integration.oauth.complete"),
|
||||||
cancel: () => Effect.die("unused integration.oauth.cancel"),
|
cancel: () => Effect.die("unused integration.oauth.cancel"),
|
||||||
},
|
},
|
||||||
|
command: {
|
||||||
|
connect: () => Effect.die("unused integration.command.connect"),
|
||||||
|
status: () => Effect.die("unused integration.command.status"),
|
||||||
|
cancel: () => Effect.die("unused integration.command.cancel"),
|
||||||
|
},
|
||||||
reload: integration.reload,
|
reload: integration.reload,
|
||||||
connection: {
|
connection: {
|
||||||
active: (id) => integration.connection.active(Integration.ID.make(id)),
|
active: (id) => integration.connection.active(Integration.ID.make(id)),
|
||||||
@@ -281,6 +296,17 @@ export function integrationHost(integration: Integration.Interface): PluginConte
|
|||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (input.method.type === "command") {
|
||||||
|
draft.method.update({
|
||||||
|
integrationID: Integration.ID.make(input.integrationID),
|
||||||
|
method: {
|
||||||
|
...input.method,
|
||||||
|
id: Integration.MethodID.make(input.method.id),
|
||||||
|
command: [...input.method.command],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
draft.method.update({
|
draft.method.update({
|
||||||
integrationID: Integration.ID.make(input.integrationID),
|
integrationID: Integration.ID.make(input.integrationID),
|
||||||
method: input.method,
|
method: input.method,
|
||||||
@@ -296,6 +322,7 @@ export function integrationHost(integration: Integration.Interface): PluginConte
|
|||||||
function method(value: Integration.Method) {
|
function method(value: Integration.Method) {
|
||||||
if (value.type === "env") return { type: value.type, names: [...value.names] }
|
if (value.type === "env") return { type: value.type, names: [...value.names] }
|
||||||
if (value.type === "key") return { type: value.type, label: value.label }
|
if (value.type === "key") return { type: value.type, label: value.label }
|
||||||
|
if (value.type === "command") return { ...value, command: [...value.command] }
|
||||||
return {
|
return {
|
||||||
type: value.type,
|
type: value.type,
|
||||||
id: value.id,
|
id: value.id,
|
||||||
@@ -308,10 +335,17 @@ function method(value: Integration.Method) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function internalMethod(
|
function internalMethod(
|
||||||
value: IntegrationOAuthMethod | IntegrationKeyMethod | IntegrationEnvMethod,
|
value: IntegrationOAuthMethod | IntegrationCommandMethod | IntegrationKeyMethod | IntegrationEnvMethod,
|
||||||
): Integration.Method {
|
): Integration.Method {
|
||||||
if (value.type === "env") return value
|
if (value.type === "env") return value
|
||||||
if (value.type === "key") return value
|
if (value.type === "key") return value
|
||||||
|
if (value.type === "command") {
|
||||||
|
return {
|
||||||
|
...value,
|
||||||
|
id: Integration.MethodID.make(value.id),
|
||||||
|
command: [...value.command],
|
||||||
|
}
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
...value,
|
...value,
|
||||||
id: Integration.MethodID.make(value.id),
|
id: Integration.MethodID.make(value.id),
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type {
|
|||||||
ConnectionInfo,
|
ConnectionInfo,
|
||||||
CredentialOAuth,
|
CredentialOAuth,
|
||||||
CredentialValue,
|
CredentialValue,
|
||||||
|
IntegrationCommandMethod,
|
||||||
IntegrationEnvMethod,
|
IntegrationEnvMethod,
|
||||||
IntegrationInputs,
|
IntegrationInputs,
|
||||||
IntegrationKeyMethod,
|
IntegrationKeyMethod,
|
||||||
@@ -35,6 +36,10 @@ export type IntegrationOAuthMethodRegistration = {
|
|||||||
}
|
}
|
||||||
export type IntegrationMethodRegistration =
|
export type IntegrationMethodRegistration =
|
||||||
| IntegrationOAuthMethodRegistration
|
| IntegrationOAuthMethodRegistration
|
||||||
|
| {
|
||||||
|
readonly integrationID: string
|
||||||
|
readonly method: IntegrationCommandMethod
|
||||||
|
}
|
||||||
| {
|
| {
|
||||||
readonly integrationID: string
|
readonly integrationID: string
|
||||||
readonly method: IntegrationKeyMethod
|
readonly method: IntegrationKeyMethod
|
||||||
|
|||||||
@@ -129,6 +129,56 @@ export const IntegrationGroup = HttpApiGroup.make("server.integration")
|
|||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
.add(
|
||||||
|
HttpApiEndpoint.post("integration.command.connect", "/api/integration/:integrationID/connect/command", {
|
||||||
|
params: { integrationID: Integration.ID },
|
||||||
|
query: LocationQuery,
|
||||||
|
payload: Schema.Struct({
|
||||||
|
methodID: Integration.MethodID,
|
||||||
|
label: Schema.optional(Schema.String),
|
||||||
|
}),
|
||||||
|
success: Location.response(Integration.CommandAttempt),
|
||||||
|
error: InvalidRequestError,
|
||||||
|
})
|
||||||
|
.annotateMerge(locationQueryOpenApi)
|
||||||
|
.annotateMerge(
|
||||||
|
OpenApi.annotations({
|
||||||
|
identifier: "v2.integration.command.connect",
|
||||||
|
summary: "Begin command connection",
|
||||||
|
description: "Start a command authentication attempt.",
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.add(
|
||||||
|
HttpApiEndpoint.get("integration.command.status", "/api/integration/:integrationID/connect/command/:attemptID", {
|
||||||
|
params: { integrationID: Integration.ID, attemptID: Integration.AttemptID },
|
||||||
|
query: LocationQuery,
|
||||||
|
success: Location.response(Integration.CommandAttemptStatus),
|
||||||
|
})
|
||||||
|
.annotateMerge(locationQueryOpenApi)
|
||||||
|
.annotateMerge(
|
||||||
|
OpenApi.annotations({
|
||||||
|
identifier: "v2.integration.command.status",
|
||||||
|
summary: "Get command attempt status",
|
||||||
|
description: "Poll the current status and output of a command authentication attempt.",
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.add(
|
||||||
|
HttpApiEndpoint.delete("integration.command.cancel", "/api/integration/:integrationID/connect/command/:attemptID", {
|
||||||
|
params: { integrationID: Integration.ID, attemptID: Integration.AttemptID },
|
||||||
|
query: LocationQuery,
|
||||||
|
success: HttpApiSchema.NoContent,
|
||||||
|
})
|
||||||
|
.annotateMerge(locationQueryOpenApi)
|
||||||
|
.annotateMerge(
|
||||||
|
OpenApi.annotations({
|
||||||
|
identifier: "v2.integration.command.cancel",
|
||||||
|
summary: "Cancel command connection",
|
||||||
|
description: "Cancel a command authentication attempt and terminate its process.",
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
.annotateMerge(
|
.annotateMerge(
|
||||||
OpenApi.annotations({ title: "integration", description: "Integration discovery and authentication routes." }),
|
OpenApi.annotations({ title: "integration", description: "Integration discovery and authentication routes." }),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -56,6 +56,14 @@ export const OAuthMethod = Schema.Struct({
|
|||||||
prompts: optional(Schema.Array(Prompt)),
|
prompts: optional(Schema.Array(Prompt)),
|
||||||
}).annotate({ identifier: "Integration.OAuthMethod" })
|
}).annotate({ identifier: "Integration.OAuthMethod" })
|
||||||
|
|
||||||
|
export interface CommandMethod extends Schema.Schema.Type<typeof CommandMethod> {}
|
||||||
|
export const CommandMethod = Schema.Struct({
|
||||||
|
id: MethodID,
|
||||||
|
type: Schema.Literal("command"),
|
||||||
|
label: Schema.String,
|
||||||
|
command: Schema.Array(Schema.String),
|
||||||
|
}).annotate({ identifier: "Integration.CommandMethod" })
|
||||||
|
|
||||||
export interface KeyMethod extends Schema.Schema.Type<typeof KeyMethod> {}
|
export interface KeyMethod extends Schema.Schema.Type<typeof KeyMethod> {}
|
||||||
export const KeyMethod = Schema.Struct({
|
export const KeyMethod = Schema.Struct({
|
||||||
type: Schema.Literal("key"),
|
type: Schema.Literal("key"),
|
||||||
@@ -68,7 +76,7 @@ export const EnvMethod = Schema.Struct({
|
|||||||
names: Schema.Array(Schema.String),
|
names: Schema.Array(Schema.String),
|
||||||
}).annotate({ identifier: "Integration.EnvMethod" })
|
}).annotate({ identifier: "Integration.EnvMethod" })
|
||||||
|
|
||||||
export const Method = Schema.Union([OAuthMethod, KeyMethod, EnvMethod])
|
export const Method = Schema.Union([OAuthMethod, CommandMethod, KeyMethod, EnvMethod])
|
||||||
.pipe(Schema.toTaggedUnion("type"))
|
.pipe(Schema.toTaggedUnion("type"))
|
||||||
.annotate({ identifier: "Integration.Method" })
|
.annotate({ identifier: "Integration.Method" })
|
||||||
export type Method = typeof Method.Type
|
export type Method = typeof Method.Type
|
||||||
@@ -128,3 +136,19 @@ export const AttemptStatus = Schema.Union([
|
|||||||
.pipe(Schema.toTaggedUnion("status"))
|
.pipe(Schema.toTaggedUnion("status"))
|
||||||
.annotate({ identifier: "Integration.AttemptStatus" })
|
.annotate({ identifier: "Integration.AttemptStatus" })
|
||||||
export type AttemptStatus = typeof AttemptStatus.Type
|
export type AttemptStatus = typeof AttemptStatus.Type
|
||||||
|
|
||||||
|
export interface CommandAttempt extends Schema.Schema.Type<typeof CommandAttempt> {}
|
||||||
|
export const CommandAttempt = Schema.Struct({
|
||||||
|
attemptID: AttemptID,
|
||||||
|
time: AttemptTime,
|
||||||
|
}).annotate({ identifier: "Integration.CommandAttempt" })
|
||||||
|
|
||||||
|
export const CommandAttemptStatus = Schema.Union([
|
||||||
|
Schema.Struct({ status: Schema.Literal("pending"), message: optional(Schema.String), time: AttemptTime }),
|
||||||
|
Schema.Struct({ status: Schema.Literal("complete"), time: AttemptTime }),
|
||||||
|
Schema.Struct({ status: Schema.Literal("failed"), message: Schema.String, time: AttemptTime }),
|
||||||
|
Schema.Struct({ status: Schema.Literal("expired"), time: AttemptTime }),
|
||||||
|
])
|
||||||
|
.pipe(Schema.toTaggedUnion("status"))
|
||||||
|
.annotate({ identifier: "Integration.CommandAttemptStatus" })
|
||||||
|
export type CommandAttemptStatus = typeof CommandAttemptStatus.Type
|
||||||
|
|||||||
@@ -294,6 +294,12 @@ import type {
|
|||||||
V2HealthGetResponses,
|
V2HealthGetResponses,
|
||||||
V2HealthStopErrors,
|
V2HealthStopErrors,
|
||||||
V2HealthStopResponses,
|
V2HealthStopResponses,
|
||||||
|
V2IntegrationCommandCancelErrors,
|
||||||
|
V2IntegrationCommandCancelResponses,
|
||||||
|
V2IntegrationCommandConnectErrors,
|
||||||
|
V2IntegrationCommandConnectResponses,
|
||||||
|
V2IntegrationCommandStatusErrors,
|
||||||
|
V2IntegrationCommandStatusResponses,
|
||||||
V2IntegrationConnectKeyErrors,
|
V2IntegrationConnectKeyErrors,
|
||||||
V2IntegrationConnectKeyResponses,
|
V2IntegrationConnectKeyResponses,
|
||||||
V2IntegrationGetErrors,
|
V2IntegrationGetErrors,
|
||||||
@@ -7005,6 +7011,132 @@ export class Oauth2 extends HeyApiClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class Command2 extends HeyApiClient {
|
||||||
|
/**
|
||||||
|
* Begin command connection
|
||||||
|
*
|
||||||
|
* Start a command authentication attempt.
|
||||||
|
*/
|
||||||
|
public connect<ThrowOnError extends boolean = false>(
|
||||||
|
parameters: {
|
||||||
|
integrationID: string
|
||||||
|
location?: {
|
||||||
|
directory?: string | null
|
||||||
|
workspace?: string | null
|
||||||
|
} | null
|
||||||
|
methodID?: string
|
||||||
|
label?: string | null
|
||||||
|
},
|
||||||
|
options?: Options<never, ThrowOnError>,
|
||||||
|
) {
|
||||||
|
const params = buildClientParams(
|
||||||
|
[parameters],
|
||||||
|
[
|
||||||
|
{
|
||||||
|
args: [
|
||||||
|
{ in: "path", key: "integrationID" },
|
||||||
|
{ in: "query", key: "location" },
|
||||||
|
{ in: "body", key: "methodID" },
|
||||||
|
{ in: "body", key: "label" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
return (options?.client ?? this.client).post<
|
||||||
|
V2IntegrationCommandConnectResponses,
|
||||||
|
V2IntegrationCommandConnectErrors,
|
||||||
|
ThrowOnError
|
||||||
|
>({
|
||||||
|
url: "/api/integration/{integrationID}/connect/command",
|
||||||
|
...options,
|
||||||
|
...params,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
...options?.headers,
|
||||||
|
...params.headers,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cancel command connection
|
||||||
|
*
|
||||||
|
* Cancel a command authentication attempt and terminate its process.
|
||||||
|
*/
|
||||||
|
public cancel<ThrowOnError extends boolean = false>(
|
||||||
|
parameters: {
|
||||||
|
integrationID: string
|
||||||
|
attemptID: string
|
||||||
|
location?: {
|
||||||
|
directory?: string | null
|
||||||
|
workspace?: string | null
|
||||||
|
} | null
|
||||||
|
},
|
||||||
|
options?: Options<never, ThrowOnError>,
|
||||||
|
) {
|
||||||
|
const params = buildClientParams(
|
||||||
|
[parameters],
|
||||||
|
[
|
||||||
|
{
|
||||||
|
args: [
|
||||||
|
{ in: "path", key: "integrationID" },
|
||||||
|
{ in: "path", key: "attemptID" },
|
||||||
|
{ in: "query", key: "location" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
return (options?.client ?? this.client).delete<
|
||||||
|
V2IntegrationCommandCancelResponses,
|
||||||
|
V2IntegrationCommandCancelErrors,
|
||||||
|
ThrowOnError
|
||||||
|
>({
|
||||||
|
url: "/api/integration/{integrationID}/connect/command/{attemptID}",
|
||||||
|
...options,
|
||||||
|
...params,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get command attempt status
|
||||||
|
*
|
||||||
|
* Poll the current status and output of a command authentication attempt.
|
||||||
|
*/
|
||||||
|
public status<ThrowOnError extends boolean = false>(
|
||||||
|
parameters: {
|
||||||
|
integrationID: string
|
||||||
|
attemptID: string
|
||||||
|
location?: {
|
||||||
|
directory?: string | null
|
||||||
|
workspace?: string | null
|
||||||
|
} | null
|
||||||
|
},
|
||||||
|
options?: Options<never, ThrowOnError>,
|
||||||
|
) {
|
||||||
|
const params = buildClientParams(
|
||||||
|
[parameters],
|
||||||
|
[
|
||||||
|
{
|
||||||
|
args: [
|
||||||
|
{ in: "path", key: "integrationID" },
|
||||||
|
{ in: "path", key: "attemptID" },
|
||||||
|
{ in: "query", key: "location" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
return (options?.client ?? this.client).get<
|
||||||
|
V2IntegrationCommandStatusResponses,
|
||||||
|
V2IntegrationCommandStatusErrors,
|
||||||
|
ThrowOnError
|
||||||
|
>({
|
||||||
|
url: "/api/integration/{integrationID}/connect/command/{attemptID}",
|
||||||
|
...options,
|
||||||
|
...params,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export class Integration extends HeyApiClient {
|
export class Integration extends HeyApiClient {
|
||||||
/**
|
/**
|
||||||
* List integrations
|
* List integrations
|
||||||
@@ -7070,6 +7202,11 @@ export class Integration extends HeyApiClient {
|
|||||||
get oauth(): Oauth2 {
|
get oauth(): Oauth2 {
|
||||||
return (this._oauth ??= new Oauth2({ client: this.client }))
|
return (this._oauth ??= new Oauth2({ client: this.client }))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private _command?: Command2
|
||||||
|
get command(): Command2 {
|
||||||
|
return (this._command ??= new Command2({ client: this.client }))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class Resource2 extends HeyApiClient {
|
export class Resource2 extends HeyApiClient {
|
||||||
@@ -7492,7 +7629,7 @@ export class Fs extends HeyApiClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class Command2 extends HeyApiClient {
|
export class Command3 extends HeyApiClient {
|
||||||
/**
|
/**
|
||||||
* List commands
|
* List commands
|
||||||
*
|
*
|
||||||
@@ -8393,9 +8530,9 @@ export class V2 extends HeyApiClient {
|
|||||||
return (this._fs ??= new Fs({ client: this.client }))
|
return (this._fs ??= new Fs({ client: this.client }))
|
||||||
}
|
}
|
||||||
|
|
||||||
private _command?: Command2
|
private _command?: Command3
|
||||||
get command(): Command2 {
|
get command(): Command3 {
|
||||||
return (this._command ??= new Command2({ client: this.client }))
|
return (this._command ??= new Command3({ client: this.client }))
|
||||||
}
|
}
|
||||||
|
|
||||||
private _skill?: Skill
|
private _skill?: Skill
|
||||||
|
|||||||
@@ -3195,7 +3195,11 @@ export type IntegrationInputs = {
|
|||||||
[key: string]: string
|
[key: string]: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export type IntegrationMethod = IntegrationOAuthMethod | IntegrationKeyMethod | IntegrationEnvMethod
|
export type IntegrationMethod =
|
||||||
|
| IntegrationOAuthMethod
|
||||||
|
| IntegrationCommandMethod
|
||||||
|
| IntegrationKeyMethod
|
||||||
|
| IntegrationEnvMethod
|
||||||
|
|
||||||
export type IntegrationRef = {
|
export type IntegrationRef = {
|
||||||
id: string
|
id: string
|
||||||
@@ -5708,6 +5712,13 @@ export type IntegrationOAuthMethod = {
|
|||||||
prompts?: Array<IntegrationTextPrompt | IntegrationSelectPrompt>
|
prompts?: Array<IntegrationTextPrompt | IntegrationSelectPrompt>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type IntegrationCommandMethod = {
|
||||||
|
id: string
|
||||||
|
type: "command"
|
||||||
|
label: string
|
||||||
|
command: Array<string>
|
||||||
|
}
|
||||||
|
|
||||||
export type IntegrationKeyMethod = {
|
export type IntegrationKeyMethod = {
|
||||||
type: "key"
|
type: "key"
|
||||||
label?: string
|
label?: string
|
||||||
@@ -5780,6 +5791,46 @@ export type IntegrationAttemptStatus =
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type IntegrationCommandAttempt = {
|
||||||
|
attemptID: string
|
||||||
|
time: {
|
||||||
|
created: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN"
|
||||||
|
expires: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type IntegrationCommandAttemptStatus =
|
||||||
|
| {
|
||||||
|
status: "pending"
|
||||||
|
message?: string
|
||||||
|
time: {
|
||||||
|
created: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN"
|
||||||
|
expires: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
status: "complete"
|
||||||
|
time: {
|
||||||
|
created: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN"
|
||||||
|
expires: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
status: "failed"
|
||||||
|
message: string
|
||||||
|
time: {
|
||||||
|
created: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN"
|
||||||
|
expires: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
status: "expired"
|
||||||
|
time: {
|
||||||
|
created: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN"
|
||||||
|
expires: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export type McpStatusConnected2 = {
|
export type McpStatusConnected2 = {
|
||||||
status: "connected"
|
status: "connected"
|
||||||
}
|
}
|
||||||
@@ -17011,6 +17062,129 @@ export type V2IntegrationOauthCompleteResponses = {
|
|||||||
export type V2IntegrationOauthCompleteResponse =
|
export type V2IntegrationOauthCompleteResponse =
|
||||||
V2IntegrationOauthCompleteResponses[keyof V2IntegrationOauthCompleteResponses]
|
V2IntegrationOauthCompleteResponses[keyof V2IntegrationOauthCompleteResponses]
|
||||||
|
|
||||||
|
export type V2IntegrationCommandConnectData = {
|
||||||
|
body: {
|
||||||
|
methodID: string
|
||||||
|
label?: string | null
|
||||||
|
}
|
||||||
|
path: {
|
||||||
|
integrationID: string
|
||||||
|
}
|
||||||
|
query?: {
|
||||||
|
location?: {
|
||||||
|
directory?: string | null
|
||||||
|
workspace?: string | null
|
||||||
|
} | null
|
||||||
|
}
|
||||||
|
url: "/api/integration/{integrationID}/connect/command"
|
||||||
|
}
|
||||||
|
|
||||||
|
export type V2IntegrationCommandConnectErrors = {
|
||||||
|
/**
|
||||||
|
* InvalidRequestError
|
||||||
|
*/
|
||||||
|
400: InvalidRequestError1 | InvalidRequestErrorV2
|
||||||
|
/**
|
||||||
|
* UnauthorizedError
|
||||||
|
*/
|
||||||
|
401: UnauthorizedError
|
||||||
|
}
|
||||||
|
|
||||||
|
export type V2IntegrationCommandConnectError =
|
||||||
|
V2IntegrationCommandConnectErrors[keyof V2IntegrationCommandConnectErrors]
|
||||||
|
|
||||||
|
export type V2IntegrationCommandConnectResponses = {
|
||||||
|
/**
|
||||||
|
* Success
|
||||||
|
*/
|
||||||
|
200: {
|
||||||
|
location: LocationInfoV2
|
||||||
|
data: IntegrationCommandAttempt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type V2IntegrationCommandConnectResponse =
|
||||||
|
V2IntegrationCommandConnectResponses[keyof V2IntegrationCommandConnectResponses]
|
||||||
|
|
||||||
|
export type V2IntegrationCommandCancelData = {
|
||||||
|
body?: never
|
||||||
|
path: {
|
||||||
|
integrationID: string
|
||||||
|
attemptID: string
|
||||||
|
}
|
||||||
|
query?: {
|
||||||
|
location?: {
|
||||||
|
directory?: string | null
|
||||||
|
workspace?: string | null
|
||||||
|
} | null
|
||||||
|
}
|
||||||
|
url: "/api/integration/{integrationID}/connect/command/{attemptID}"
|
||||||
|
}
|
||||||
|
|
||||||
|
export type V2IntegrationCommandCancelErrors = {
|
||||||
|
/**
|
||||||
|
* InvalidRequestError
|
||||||
|
*/
|
||||||
|
400: InvalidRequestErrorV2
|
||||||
|
/**
|
||||||
|
* UnauthorizedError
|
||||||
|
*/
|
||||||
|
401: UnauthorizedError
|
||||||
|
}
|
||||||
|
|
||||||
|
export type V2IntegrationCommandCancelError = V2IntegrationCommandCancelErrors[keyof V2IntegrationCommandCancelErrors]
|
||||||
|
|
||||||
|
export type V2IntegrationCommandCancelResponses = {
|
||||||
|
/**
|
||||||
|
* <No Content>
|
||||||
|
*/
|
||||||
|
204: void
|
||||||
|
}
|
||||||
|
|
||||||
|
export type V2IntegrationCommandCancelResponse =
|
||||||
|
V2IntegrationCommandCancelResponses[keyof V2IntegrationCommandCancelResponses]
|
||||||
|
|
||||||
|
export type V2IntegrationCommandStatusData = {
|
||||||
|
body?: never
|
||||||
|
path: {
|
||||||
|
integrationID: string
|
||||||
|
attemptID: string
|
||||||
|
}
|
||||||
|
query?: {
|
||||||
|
location?: {
|
||||||
|
directory?: string | null
|
||||||
|
workspace?: string | null
|
||||||
|
} | null
|
||||||
|
}
|
||||||
|
url: "/api/integration/{integrationID}/connect/command/{attemptID}"
|
||||||
|
}
|
||||||
|
|
||||||
|
export type V2IntegrationCommandStatusErrors = {
|
||||||
|
/**
|
||||||
|
* InvalidRequestError
|
||||||
|
*/
|
||||||
|
400: InvalidRequestErrorV2
|
||||||
|
/**
|
||||||
|
* UnauthorizedError
|
||||||
|
*/
|
||||||
|
401: UnauthorizedError
|
||||||
|
}
|
||||||
|
|
||||||
|
export type V2IntegrationCommandStatusError = V2IntegrationCommandStatusErrors[keyof V2IntegrationCommandStatusErrors]
|
||||||
|
|
||||||
|
export type V2IntegrationCommandStatusResponses = {
|
||||||
|
/**
|
||||||
|
* Success
|
||||||
|
*/
|
||||||
|
200: {
|
||||||
|
location: LocationInfoV2
|
||||||
|
data: IntegrationCommandAttemptStatus
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type V2IntegrationCommandStatusResponse =
|
||||||
|
V2IntegrationCommandStatusResponses[keyof V2IntegrationCommandStatusResponses]
|
||||||
|
|
||||||
export type V2McpListData = {
|
export type V2McpListData = {
|
||||||
body?: never
|
body?: never
|
||||||
path?: never
|
path?: never
|
||||||
|
|||||||
@@ -114,5 +114,43 @@ export const IntegrationHandler = HttpApiBuilder.group(Api, "server.integration"
|
|||||||
return HttpApiSchema.NoContent.make()
|
return HttpApiSchema.NoContent.make()
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
.handle(
|
||||||
|
"integration.command.connect",
|
||||||
|
Effect.fn(function* (ctx) {
|
||||||
|
const service = yield* Integration.Service
|
||||||
|
return yield* response(
|
||||||
|
authorize(
|
||||||
|
service.command.connect({
|
||||||
|
integrationID: ctx.params.integrationID,
|
||||||
|
methodID: ctx.payload.methodID,
|
||||||
|
label: ctx.payload.label,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.handle(
|
||||||
|
"integration.command.status",
|
||||||
|
Effect.fn(function* (ctx) {
|
||||||
|
const service = yield* Integration.Service
|
||||||
|
return yield* response(
|
||||||
|
service.command.status({
|
||||||
|
integrationID: ctx.params.integrationID,
|
||||||
|
attemptID: ctx.params.attemptID,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.handle(
|
||||||
|
"integration.command.cancel",
|
||||||
|
Effect.fn(function* (ctx) {
|
||||||
|
const service = yield* Integration.Service
|
||||||
|
yield* service.command.cancel({
|
||||||
|
integrationID: ctx.params.integrationID,
|
||||||
|
attemptID: ctx.params.attemptID,
|
||||||
|
})
|
||||||
|
return HttpApiSchema.NoContent.make()
|
||||||
|
}),
|
||||||
|
)
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { TextAttributes } from "@opentui/core"
|
import { TextAttributes } from "@opentui/core"
|
||||||
import type {
|
import type {
|
||||||
ConnectionInfo,
|
ConnectionInfo,
|
||||||
|
IntegrationCommandConnectOutput,
|
||||||
IntegrationInfo,
|
IntegrationInfo,
|
||||||
IntegrationOauthConnectOutput,
|
IntegrationOauthConnectOutput,
|
||||||
IntegrationOAuthMethod,
|
IntegrationOAuthMethod,
|
||||||
@@ -28,6 +29,7 @@ const INTEGRATION_PRIORITY: Record<string, number> = {
|
|||||||
|
|
||||||
type ConnectMethod = Exclude<IntegrationInfo["methods"][number], { type: "env" }>
|
type ConnectMethod = Exclude<IntegrationInfo["methods"][number], { type: "env" }>
|
||||||
type IntegrationAttempt = IntegrationOauthConnectOutput["data"]
|
type IntegrationAttempt = IntegrationOauthConnectOutput["data"]
|
||||||
|
type CommandAttempt = IntegrationCommandConnectOutput["data"]
|
||||||
type OnIntegrationConnected = (providerID?: string) => void
|
type OnIntegrationConnected = (providerID?: string) => void
|
||||||
|
|
||||||
export function integrationOptions(list: IntegrationInfo[]) {
|
export function integrationOptions(list: IntegrationInfo[]) {
|
||||||
@@ -167,9 +169,130 @@ function openMethod(
|
|||||||
dialog.replace(() => <KeyMethod integration={integration} method={method} onConnected={onConnected} />)
|
dialog.replace(() => <KeyMethod integration={integration} method={method} onConnected={onConnected} />)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (method.type === "command") {
|
||||||
|
dialog.replace(() => <CommandStarting integration={integration} method={method} onConnected={onConnected} />)
|
||||||
|
return
|
||||||
|
}
|
||||||
void beginOAuth(integration, method, dialog, onConnected)
|
void beginOAuth(integration, method, dialog, onConnected)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function CommandStarting(props: {
|
||||||
|
integration: IntegrationInfo
|
||||||
|
method: Extract<ConnectMethod, { type: "command" }>
|
||||||
|
onConnected?: OnIntegrationConnected
|
||||||
|
}) {
|
||||||
|
const data = useData()
|
||||||
|
const dialog = useDialog()
|
||||||
|
const client = useClient()
|
||||||
|
const toast = useToast()
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
void client.api.integration.command
|
||||||
|
.connect({
|
||||||
|
integrationID: props.integration.id,
|
||||||
|
methodID: props.method.id,
|
||||||
|
location: location(data),
|
||||||
|
})
|
||||||
|
.then((result) =>
|
||||||
|
dialog.replace(() => (
|
||||||
|
<CommandPending
|
||||||
|
integration={props.integration}
|
||||||
|
title={props.method.label}
|
||||||
|
attempt={result.data}
|
||||||
|
onConnected={props.onConnected}
|
||||||
|
/>
|
||||||
|
)),
|
||||||
|
)
|
||||||
|
.catch((cause) => {
|
||||||
|
toast.show({ variant: "error", message: message(cause) })
|
||||||
|
dialog.clear()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
return <CommandView title={props.method.label} output="" message="Starting command..." />
|
||||||
|
}
|
||||||
|
|
||||||
|
function CommandPending(props: {
|
||||||
|
integration: IntegrationInfo
|
||||||
|
title: string
|
||||||
|
attempt: CommandAttempt
|
||||||
|
onConnected?: OnIntegrationConnected
|
||||||
|
}) {
|
||||||
|
const data = useData()
|
||||||
|
const dialog = useDialog()
|
||||||
|
const client = useClient()
|
||||||
|
const toast = useToast()
|
||||||
|
const [output, setOutput] = createSignal("")
|
||||||
|
let timer: ReturnType<typeof setTimeout> | undefined
|
||||||
|
let settled = false
|
||||||
|
|
||||||
|
const poll = () => {
|
||||||
|
void client.api.integration.command
|
||||||
|
.status({
|
||||||
|
integrationID: props.integration.id,
|
||||||
|
attemptID: props.attempt.attemptID,
|
||||||
|
location: location(data),
|
||||||
|
})
|
||||||
|
.then((result) => {
|
||||||
|
const status = result.data
|
||||||
|
if (status.status === "pending") {
|
||||||
|
setOutput(status.message ?? "")
|
||||||
|
timer = setTimeout(poll, 500)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
settled = true
|
||||||
|
if (status.status === "complete") {
|
||||||
|
void connected(props.integration, data, dialog, toast, props.onConnected)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
toast.show({
|
||||||
|
variant: "error",
|
||||||
|
message: status.status === "failed" ? status.message : "Authentication expired",
|
||||||
|
})
|
||||||
|
dialog.clear()
|
||||||
|
})
|
||||||
|
.catch((cause) => {
|
||||||
|
settled = true
|
||||||
|
toast.show({ variant: "error", message: message(cause) })
|
||||||
|
dialog.clear()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(poll)
|
||||||
|
onCleanup(() => {
|
||||||
|
if (timer) clearTimeout(timer)
|
||||||
|
if (settled) return
|
||||||
|
void client.api.integration.command.cancel({
|
||||||
|
integrationID: props.integration.id,
|
||||||
|
attemptID: props.attempt.attemptID,
|
||||||
|
location: location(data),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
return <CommandView title={props.title} output={output()} message="Waiting for command to finish..." />
|
||||||
|
}
|
||||||
|
|
||||||
|
function CommandView(props: { title: string; output: string; message: string }) {
|
||||||
|
const dialog = useDialog()
|
||||||
|
const { theme } = useTheme()
|
||||||
|
return (
|
||||||
|
<box paddingLeft={2} paddingRight={2} gap={1} paddingBottom={1}>
|
||||||
|
<box flexDirection="row" justifyContent="space-between">
|
||||||
|
<text attributes={TextAttributes.BOLD} fg={theme.text}>
|
||||||
|
{props.title}
|
||||||
|
</text>
|
||||||
|
<text fg={theme.textMuted} onMouseUp={() => dialog.clear()}>
|
||||||
|
esc close
|
||||||
|
</text>
|
||||||
|
</box>
|
||||||
|
<box backgroundColor={theme.backgroundElement} paddingLeft={1} paddingRight={1}>
|
||||||
|
<text fg={theme.text}>{props.output}</text>
|
||||||
|
</box>
|
||||||
|
<text fg={theme.textMuted}>{props.message}</text>
|
||||||
|
</box>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function KeyMethod(props: {
|
function KeyMethod(props: {
|
||||||
integration: IntegrationInfo
|
integration: IntegrationInfo
|
||||||
method: Extract<ConnectMethod, { type: "key" }>
|
method: Extract<ConnectMethod, { type: "key" }>
|
||||||
|
|||||||
Reference in New Issue
Block a user