From b5177adb5b2a5e7a4ed48e81359ada60da6931a8 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Wed, 15 Jul 2026 19:08:14 -0400 Subject: [PATCH] feat(api): add command authentication attempts --- packages/client/src/effect/api/api.ts | 39 +++ .../client/src/effect/generated/client.ts | 39 +++ .../client/src/promise/generated/client.ts | 45 ++++ .../client/src/promise/generated/types.ts | 70 +++++- packages/client/test/promise.test.ts | 3 +- packages/core/src/integration.ts | 238 +++++++++++++++++- packages/core/src/plugin/host.ts | 29 +++ packages/core/src/plugin/promise.ts | 26 ++ packages/core/test/integration.test.ts | 59 +++++ packages/core/test/mcp.test.ts | 5 + packages/core/test/plugin/host.ts | 38 ++- packages/plugin/src/v2/effect/integration.ts | 5 + packages/protocol/src/groups/integration.ts | 50 ++++ packages/schema/src/integration.ts | 26 +- packages/sdk/js/src/v2/gen/sdk.gen.ts | 145 ++++++++++- packages/sdk/js/src/v2/gen/types.gen.ts | 176 ++++++++++++- packages/server/src/handlers/integration.ts | 38 +++ .../tui/src/component/dialog-integration.tsx | 123 +++++++++ 18 files changed, 1138 insertions(+), 16 deletions(-) diff --git a/packages/client/src/effect/api/api.ts b/packages/client/src/effect/api/api.ts index 8028af9925..853e98d548 100644 --- a/packages/client/src/effect/api/api.ts +++ b/packages/client/src/effect/api/api.ts @@ -466,6 +466,40 @@ export type IntegrationOauthCancelOperation = ( input: Endpoint10_6Input, ) => Effect.Effect +type Endpoint10_7Request = Parameters[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> +export type IntegrationCommandConnectOperation = ( + input: Endpoint10_7Input, +) => Effect.Effect + +type Endpoint10_8Request = Parameters[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> +export type IntegrationCommandStatusOperation = ( + input: Endpoint10_8Input, +) => Effect.Effect + +type Endpoint10_9Request = Parameters[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> +export type IntegrationCommandCancelOperation = ( + input: Endpoint10_9Input, +) => Effect.Effect + export interface IntegrationApi { readonly list: IntegrationListOperation readonly get: IntegrationGetOperation @@ -476,6 +510,11 @@ export interface IntegrationApi { readonly complete: IntegrationOauthCompleteOperation readonly cancel: IntegrationOauthCancelOperation } + readonly command: { + readonly connect: IntegrationCommandConnectOperation + readonly status: IntegrationCommandStatusOperation + readonly cancel: IntegrationCommandCancelOperation + } } type Endpoint11_0Request = Parameters[0] diff --git a/packages/client/src/effect/generated/client.ts b/packages/client/src/effect/generated/client.ts index c85469d3b3..22bebf6e19 100644 --- a/packages/client/src/effect/generated/client.ts +++ b/packages/client/src/effect/generated/client.ts @@ -571,6 +571,44 @@ const Endpoint10_6 = (raw: RawClient["server.integration"]) => (input: Endpoint1 query: { location: input["location"] }, }).pipe(Effect.mapError(mapClientError)) +type Endpoint10_7Request = Parameters[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[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[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"]) => ({ list: Endpoint10_0(raw), get: Endpoint10_1(raw), @@ -581,6 +619,7 @@ const adaptGroup10 = (raw: RawClient["server.integration"]) => ({ complete: Endpoint10_5(raw), cancel: Endpoint10_6(raw), }, + command: { connect: Endpoint10_7(raw), status: Endpoint10_8(raw), cancel: Endpoint10_9(raw) }, }) type Endpoint11_0Request = Parameters[0] diff --git a/packages/client/src/promise/generated/client.ts b/packages/client/src/promise/generated/client.ts index 864b158004..6523b19848 100644 --- a/packages/client/src/promise/generated/client.ts +++ b/packages/client/src/promise/generated/client.ts @@ -92,6 +92,12 @@ import type { IntegrationOauthCompleteOutput, IntegrationOauthCancelInput, IntegrationOauthCancelOutput, + IntegrationCommandConnectInput, + IntegrationCommandConnectOutput, + IntegrationCommandStatusInput, + IntegrationCommandStatusOutput, + IntegrationCommandCancelInput, + IntegrationCommandCancelOutput, McpListInput, McpListOutput, McpResourceCatalogInput, @@ -954,6 +960,45 @@ export function make(options: ClientOptions) { requestOptions, ), }, + command: { + connect: (input: IntegrationCommandConnectInput, requestOptions?: RequestOptions) => + request( + { + 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( + { + 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( + { + 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: { list: (input?: McpListInput, requestOptions?: RequestOptions) => diff --git a/packages/client/src/promise/generated/types.ts b/packages/client/src/promise/generated/types.ts index 882dadc086..dce1302906 100644 --- a/packages/client/src/promise/generated/types.ts +++ b/packages/client/src/promise/generated/types.ts @@ -187,6 +187,8 @@ export type ProviderV2Info = { export type IntegrationWhen = { key: string; op: "eq" | "neq"; value: string } +export type IntegrationCommandMethod = { id: string; type: "command"; label: string; command: Array } + export type IntegrationKeyMethod = { type: "key"; label?: string } export type IntegrationEnvMethod = { type: "env"; names: Array } @@ -214,6 +216,31 @@ export type IntegrationAttemptStatus = 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 McpStatusPending = { status: "pending" } @@ -1992,7 +2019,11 @@ export type SessionMessageAssistantTool = { 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] @@ -3351,6 +3382,43 @@ export type IntegrationOauthCancelInput = { 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 = { readonly location?: { readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined diff --git a/packages/client/test/promise.test.ts b/packages/client/test/promise.test.ts index a0166f6f79..6a22178f35 100644 --- a/packages/client/test/promise.test.ts +++ b/packages/client/test/promise.test.ts @@ -36,9 +36,10 @@ test("exposes every standard HTTP API group", () => { expect(Object.keys(client.debug)).toEqual(["location"]) expect(Object.keys(client.debug.location)).toEqual(["list", "evict"]) 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.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.vcs)).toEqual(["status", "diff"]) expect(Object.keys(client.pty)).toEqual(["list", "create", "get", "update", "remove"]) diff --git a/packages/core/src/integration.ts b/packages/core/src/integration.ts index f987d87eda..9537b435d6 100644 --- a/packages/core/src/integration.ts +++ b/packages/core/src/integration.ts @@ -12,6 +12,7 @@ import { Schedule, Schema, Scope, + Stream, SynchronizedRef, Types, } from "effect" @@ -20,6 +21,8 @@ import { Credential } from "./credential" import { State } from "./state" import { EventV2 } from "./event" import { IntegrationConnection } from "./integration/connection" +import { AppProcess } from "./process" +import { ChildProcess } from "effect/unstable/process" export const ID = Integration.ID export type ID = Integration.ID @@ -45,6 +48,9 @@ export type Prompt = Integration.Prompt export const OAuthMethod = Integration.OAuthMethod export type OAuthMethod = Integration.OAuthMethod +export const CommandMethod = Integration.CommandMethod +export type CommandMethod = Integration.CommandMethod + export const KeyMethod = Integration.KeyMethod export type KeyMethod = Integration.KeyMethod @@ -87,12 +93,17 @@ export interface KeyImplementation { readonly method: KeyMethod } +export interface CommandImplementation { + readonly integrationID: ID + readonly method: CommandMethod +} + export interface EnvImplementation { readonly integrationID: ID readonly method: EnvMethod } -export type Implementation = OAuthImplementation | KeyImplementation | EnvImplementation +export type Implementation = OAuthImplementation | CommandImplementation | KeyImplementation | EnvImplementation export const Attempt = Integration.Attempt export type Attempt = Integration.Attempt @@ -100,6 +111,12 @@ export type Attempt = Integration.Attempt export const AttemptStatus = Integration.AttemptStatus 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()("Integration.CodeRequired", { attemptID: AttemptID, }) {} @@ -189,6 +206,18 @@ export interface Interface extends State.Transformable { /** Cancels an attempt and releases its resources. */ readonly cancel: (input: { readonly integrationID: ID; readonly attemptID: AttemptID }) => Effect.Effect } + readonly command: { + readonly connect: (input: { + readonly integrationID: ID + readonly methodID: MethodID + readonly label?: string + }) => Effect.Effect + readonly status: (input: { + readonly integrationID: ID + readonly attemptID: AttemptID + }) => Effect.Effect + readonly cancel: (input: { readonly integrationID: ID; readonly attemptID: AttemptID }) => Effect.Effect + } } export class Service extends Context.Service()("@opencode/v2/Integration") {} @@ -218,13 +247,33 @@ type 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( Service, Effect.gen(function* () { const credentials = yield* Credential.Service const events = yield* EventV2.Service + const processes = yield* AppProcess.Service const scope = yield* Scope.Scope const attempts = SynchronizedRef.makeUnsafe(new Map()) + const commandAttempts = SynchronizedRef.makeUnsafe(new Map()) const state = State.create({ name: "integration", initial: () => ({ integrations: new Map() }), @@ -258,8 +307,11 @@ const layer = Layer.effect( } const index = current.methods.findIndex((method) => { if (method.type !== implementation.method.type) return false - if (method.type !== "oauth" || implementation.method.type !== "oauth") return true - return method.id === implementation.method.id + if (method.type === "oauth" && implementation.method.type === "oauth") + 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) else current.methods[index] = implementation.method as Types.DeepMutable @@ -275,8 +327,9 @@ const layer = Layer.effect( if (!current) return const index = current.methods.findIndex((candidate) => { if (candidate.type !== method.type) return false - if (candidate.type !== "oauth" || method.type !== "oauth") return true - return candidate.id === method.id + if (candidate.type === "oauth" && method.type === "oauth") 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 (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) { + 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 now = yield* Clock.currentTimeMillis const expired = yield* SynchronizedRef.modify(attempts, (current) => { @@ -408,7 +516,31 @@ const layer = Layer.effect( 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* scrubCommands().pipe(Effect.repeat(Schedule.spaced(scrubInterval)), Effect.forkIn(scope)) const connectOAuth = Effect.fn("Integration.oauth.connect")(function* (input: { 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({ transform: state.transform, reload: state.reload, @@ -572,8 +764,42 @@ const layer = Layer.effect( 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], +}) diff --git a/packages/core/src/plugin/host.ts b/packages/core/src/plugin/host.ts index f6d0470a0f..d11cd2997b 100644 --- a/packages/core/src/plugin/host.ts +++ b/packages/core/src/plugin/host.ts @@ -203,6 +203,28 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int 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, connection: { 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 } + 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({ integrationID: Integration.ID.make(input.integrationID), method: { type: "key", label: input.method.label }, diff --git a/packages/core/src/plugin/promise.ts b/packages/core/src/plugin/promise.ts index 04fb8aee96..ebff6ec71d 100644 --- a/packages/core/src/plugin/promise.ts +++ b/packages/core/src/plugin/promise.ts @@ -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), reload: () => run(host.integration.reload()), connection: { diff --git a/packages/core/test/integration.test.ts b/packages/core/test/integration.test.ts index 6fd4fa5c01..a591e018a0 100644 --- a/packages/core/test/integration.test.ts +++ b/packages/core/test/integration.test.ts @@ -29,6 +29,20 @@ const failingIt = testEffect( AppNodeBuilder.build(LayerNode.group([Integration.node, EventV2.node]), [[Credential.node, failingCredentialNode]]), ) +function eventually( + effect: Effect.Effect, + predicate: (value: A) => boolean, + remaining = 1000, +): Effect.Effect { + 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", () => { it.effect("registers integrations through the editor", () => 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", () => Effect.gen(function* () { const integrations = yield* Integration.Service diff --git a/packages/core/test/mcp.test.ts b/packages/core/test/mcp.test.ts index e057cf9c3d..22390b6316 100644 --- a/packages/core/test/mcp.test.ts +++ b/packages/core/test/mcp.test.ts @@ -196,6 +196,11 @@ function resourceMcpLayer(url: string, onFormCreated?: (form: Form.Info) => Effe complete: unusedIntegration, cancel: unusedIntegration, }, + command: { + connect: unusedIntegration, + status: unusedIntegration, + cancel: unusedIntegration, + }, }), Layer.mock(Credential.Service, {}), ), diff --git a/packages/core/test/plugin/host.ts b/packages/core/test/plugin/host.ts index e5e7cf271c..3274ddf261 100644 --- a/packages/core/test/plugin/host.ts +++ b/packages/core/test/plugin/host.ts @@ -5,7 +5,12 @@ import { Credential } from "@opencode-ai/core/credential" import { Integration } from "@opencode-ai/core/integration" import { ModelV2 } from "@opencode-ai/core/model" 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" type Overrides = Partial> & { @@ -55,6 +60,11 @@ export function host(overrides: Overrides = {}): PluginContext { complete: () => Effect.die("unused integration.oauth.complete"), 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"), reload: () => Effect.die("unused integration.reload"), connection: { @@ -200,6 +210,11 @@ export function integrationHost(integration: Integration.Interface): PluginConte complete: () => Effect.die("unused integration.oauth.complete"), 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, connection: { active: (id) => integration.connection.active(Integration.ID.make(id)), @@ -281,6 +296,17 @@ export function integrationHost(integration: Integration.Interface): PluginConte }) 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({ integrationID: Integration.ID.make(input.integrationID), method: input.method, @@ -296,6 +322,7 @@ export function integrationHost(integration: Integration.Interface): PluginConte function method(value: Integration.Method) { 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 === "command") return { ...value, command: [...value.command] } return { type: value.type, id: value.id, @@ -308,10 +335,17 @@ function method(value: Integration.Method) { } function internalMethod( - value: IntegrationOAuthMethod | IntegrationKeyMethod | IntegrationEnvMethod, + value: IntegrationOAuthMethod | IntegrationCommandMethod | IntegrationKeyMethod | IntegrationEnvMethod, ): Integration.Method { if (value.type === "env") return value if (value.type === "key") return value + if (value.type === "command") { + return { + ...value, + id: Integration.MethodID.make(value.id), + command: [...value.command], + } + } return { ...value, id: Integration.MethodID.make(value.id), diff --git a/packages/plugin/src/v2/effect/integration.ts b/packages/plugin/src/v2/effect/integration.ts index 7af3542b6c..d34b42d97d 100644 --- a/packages/plugin/src/v2/effect/integration.ts +++ b/packages/plugin/src/v2/effect/integration.ts @@ -2,6 +2,7 @@ import type { ConnectionInfo, CredentialOAuth, CredentialValue, + IntegrationCommandMethod, IntegrationEnvMethod, IntegrationInputs, IntegrationKeyMethod, @@ -35,6 +36,10 @@ export type IntegrationOAuthMethodRegistration = { } export type IntegrationMethodRegistration = | IntegrationOAuthMethodRegistration + | { + readonly integrationID: string + readonly method: IntegrationCommandMethod + } | { readonly integrationID: string readonly method: IntegrationKeyMethod diff --git a/packages/protocol/src/groups/integration.ts b/packages/protocol/src/groups/integration.ts index f0cc2b4b77..0584bb0de6 100644 --- a/packages/protocol/src/groups/integration.ts +++ b/packages/protocol/src/groups/integration.ts @@ -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( OpenApi.annotations({ title: "integration", description: "Integration discovery and authentication routes." }), ) diff --git a/packages/schema/src/integration.ts b/packages/schema/src/integration.ts index 6caa559710..0e573c4313 100644 --- a/packages/schema/src/integration.ts +++ b/packages/schema/src/integration.ts @@ -56,6 +56,14 @@ export const OAuthMethod = Schema.Struct({ prompts: optional(Schema.Array(Prompt)), }).annotate({ identifier: "Integration.OAuthMethod" }) +export interface CommandMethod extends Schema.Schema.Type {} +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 {} export const KeyMethod = Schema.Struct({ type: Schema.Literal("key"), @@ -68,7 +76,7 @@ export const EnvMethod = Schema.Struct({ names: Schema.Array(Schema.String), }).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")) .annotate({ identifier: "Integration.Method" }) export type Method = typeof Method.Type @@ -128,3 +136,19 @@ export const AttemptStatus = Schema.Union([ .pipe(Schema.toTaggedUnion("status")) .annotate({ identifier: "Integration.AttemptStatus" }) export type AttemptStatus = typeof AttemptStatus.Type + +export interface CommandAttempt extends Schema.Schema.Type {} +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 diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index 43154a176f..bf0190f94a 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -294,6 +294,12 @@ import type { V2HealthGetResponses, V2HealthStopErrors, V2HealthStopResponses, + V2IntegrationCommandCancelErrors, + V2IntegrationCommandCancelResponses, + V2IntegrationCommandConnectErrors, + V2IntegrationCommandConnectResponses, + V2IntegrationCommandStatusErrors, + V2IntegrationCommandStatusResponses, V2IntegrationConnectKeyErrors, V2IntegrationConnectKeyResponses, 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( + parameters: { + integrationID: string + location?: { + directory?: string | null + workspace?: string | null + } | null + methodID?: string + label?: string | null + }, + options?: Options, + ) { + 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( + parameters: { + integrationID: string + attemptID: string + location?: { + directory?: string | null + workspace?: string | null + } | null + }, + options?: Options, + ) { + 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( + parameters: { + integrationID: string + attemptID: string + location?: { + directory?: string | null + workspace?: string | null + } | null + }, + options?: Options, + ) { + 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 { /** * List integrations @@ -7070,6 +7202,11 @@ export class Integration extends HeyApiClient { get oauth(): Oauth2 { 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 { @@ -7492,7 +7629,7 @@ export class Fs extends HeyApiClient { } } -export class Command2 extends HeyApiClient { +export class Command3 extends HeyApiClient { /** * List commands * @@ -8393,9 +8530,9 @@ export class V2 extends HeyApiClient { return (this._fs ??= new Fs({ client: this.client })) } - private _command?: Command2 - get command(): Command2 { - return (this._command ??= new Command2({ client: this.client })) + private _command?: Command3 + get command(): Command3 { + return (this._command ??= new Command3({ client: this.client })) } private _skill?: Skill diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 051aff88a6..343a3a4c56 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -3195,7 +3195,11 @@ export type IntegrationInputs = { [key: string]: string } -export type IntegrationMethod = IntegrationOAuthMethod | IntegrationKeyMethod | IntegrationEnvMethod +export type IntegrationMethod = + | IntegrationOAuthMethod + | IntegrationCommandMethod + | IntegrationKeyMethod + | IntegrationEnvMethod export type IntegrationRef = { id: string @@ -5708,6 +5712,13 @@ export type IntegrationOAuthMethod = { prompts?: Array } +export type IntegrationCommandMethod = { + id: string + type: "command" + label: string + command: Array +} + export type IntegrationKeyMethod = { type: "key" 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 = { status: "connected" } @@ -17011,6 +17062,129 @@ export type V2IntegrationOauthCompleteResponses = { export type V2IntegrationOauthCompleteResponse = 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 = { + /** + * + */ + 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 = { body?: never path?: never diff --git a/packages/server/src/handlers/integration.ts b/packages/server/src/handlers/integration.ts index 826eee9eb4..90ff7c5cf6 100644 --- a/packages/server/src/handlers/integration.ts +++ b/packages/server/src/handlers/integration.ts @@ -114,5 +114,43 @@ export const IntegrationHandler = HttpApiBuilder.group(Api, "server.integration" 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() + }), + ) }), ) diff --git a/packages/tui/src/component/dialog-integration.tsx b/packages/tui/src/component/dialog-integration.tsx index ad4240f0c0..1b80ddbe02 100644 --- a/packages/tui/src/component/dialog-integration.tsx +++ b/packages/tui/src/component/dialog-integration.tsx @@ -1,6 +1,7 @@ import { TextAttributes } from "@opentui/core" import type { ConnectionInfo, + IntegrationCommandConnectOutput, IntegrationInfo, IntegrationOauthConnectOutput, IntegrationOAuthMethod, @@ -28,6 +29,7 @@ const INTEGRATION_PRIORITY: Record = { type ConnectMethod = Exclude type IntegrationAttempt = IntegrationOauthConnectOutput["data"] +type CommandAttempt = IntegrationCommandConnectOutput["data"] type OnIntegrationConnected = (providerID?: string) => void export function integrationOptions(list: IntegrationInfo[]) { @@ -167,9 +169,130 @@ function openMethod( dialog.replace(() => ) return } + if (method.type === "command") { + dialog.replace(() => ) + return + } void beginOAuth(integration, method, dialog, onConnected) } +function CommandStarting(props: { + integration: IntegrationInfo + method: Extract + 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(() => ( + + )), + ) + .catch((cause) => { + toast.show({ variant: "error", message: message(cause) }) + dialog.clear() + }) + }) + + return +} + +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 | 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 +} + +function CommandView(props: { title: string; output: string; message: string }) { + const dialog = useDialog() + const { theme } = useTheme() + return ( + + + + {props.title} + + dialog.clear()}> + esc close + + + + {props.output} + + {props.message} + + ) +} + function KeyMethod(props: { integration: IntegrationInfo method: Extract