feat(api): add command authentication attempts
This commit is contained in:
parent
a772767e9b
commit
b5177adb5b
18 changed files with 1138 additions and 16 deletions
|
|
@ -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<CodeRequiredError>()("Integration.CodeRequired", {
|
||||
attemptID: AttemptID,
|
||||
}) {}
|
||||
|
|
@ -189,6 +206,18 @@ export interface Interface extends State.Transformable<Draft> {
|
|||
/** Cancels an attempt and releases its resources. */
|
||||
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") {}
|
||||
|
|
@ -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<AttemptID, AttemptEntry>())
|
||||
const commandAttempts = SynchronizedRef.makeUnsafe(new Map<AttemptID, CommandAttemptEntry>())
|
||||
const state = State.create<Data, Draft>({
|
||||
name: "integration",
|
||||
initial: () => ({ integrations: new Map<ID, Entry>() }),
|
||||
|
|
@ -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<Method>)
|
||||
else current.methods[index] = implementation.method as Types.DeepMutable<Method>
|
||||
|
|
@ -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<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 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],
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -29,6 +29,20 @@ const failingIt = testEffect(
|
|||
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", () => {
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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, {}),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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<Omit<PluginContext, "options" | "session">> & {
|
||||
|
|
@ -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),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue