feat(api): add experimental wellknown connections
This commit is contained in:
@@ -66,6 +66,17 @@ export const Commands = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCO
|
|||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
|
Spec.make("auth", {
|
||||||
|
description: "Manage authentication",
|
||||||
|
commands: [
|
||||||
|
Spec.make("connect", {
|
||||||
|
description: "Connect to a wellknown authentication provider",
|
||||||
|
params: {
|
||||||
|
url: Argument.string("url").pipe(Argument.withDescription("Wellknown provider URL")),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
}),
|
||||||
Spec.make("mcp", {
|
Spec.make("mcp", {
|
||||||
description: "Manage MCP (Model Context Protocol) servers",
|
description: "Manage MCP (Model Context Protocol) servers",
|
||||||
commands: [
|
commands: [
|
||||||
@@ -178,10 +189,7 @@ export const Commands = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCO
|
|||||||
Flag.atMost(100),
|
Flag.atMost(100),
|
||||||
),
|
),
|
||||||
title: Flag.string("title").pipe(Flag.withDescription("Session title"), Flag.optional),
|
title: Flag.string("title").pipe(Flag.withDescription("Session title"), Flag.optional),
|
||||||
thinking: Flag.boolean("thinking").pipe(
|
thinking: Flag.boolean("thinking").pipe(Flag.withDescription("Show thinking blocks"), Flag.withDefault(false)),
|
||||||
Flag.withDescription("Show thinking blocks"),
|
|
||||||
Flag.withDefault(false),
|
|
||||||
),
|
|
||||||
auto: Flag.boolean("auto").pipe(
|
auto: Flag.boolean("auto").pipe(
|
||||||
Flag.withDescription("Auto-approve permissions that are not explicitly denied"),
|
Flag.withDescription("Auto-approve permissions that are not explicitly denied"),
|
||||||
Flag.withDefault(false),
|
Flag.withDefault(false),
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { EOL } from "node:os"
|
||||||
|
import { Effect } from "effect"
|
||||||
|
import { Service } from "@opencode-ai/client/effect/service"
|
||||||
|
import { OpenCode, type IntegrationCommandStatusOutput, type OpenCodeClient } from "@opencode-ai/client/promise"
|
||||||
|
import { Commands } from "../../commands"
|
||||||
|
import { Runtime } from "../../../framework/runtime"
|
||||||
|
import { ServiceConfig } from "../../../services/service-config"
|
||||||
|
|
||||||
|
const location = { directory: process.cwd() }
|
||||||
|
|
||||||
|
export default Runtime.handler(
|
||||||
|
Commands.commands.auth.commands.connect,
|
||||||
|
Effect.fn("cli.auth.connect")(function* (input) {
|
||||||
|
process.stdout.write("Connecting..." + EOL + EOL)
|
||||||
|
const endpoint = yield* Service.ensure(yield* ServiceConfig.options())
|
||||||
|
const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) })
|
||||||
|
yield* request(() => client.integration.wellknown.add({ url: input.url, location }))
|
||||||
|
const integrationID = input.url.replace(/\/+$/, "")
|
||||||
|
const started = yield* request(() =>
|
||||||
|
client.integration.command.connect({ integrationID, methodID: "login", location }),
|
||||||
|
)
|
||||||
|
yield* Effect.addFinalizer(() =>
|
||||||
|
request(() =>
|
||||||
|
client.integration.command.cancel({ integrationID, attemptID: started.data.attemptID, location }),
|
||||||
|
).pipe(Effect.ignore),
|
||||||
|
)
|
||||||
|
|
||||||
|
const status = yield* wait(client, integrationID, started.data.attemptID)
|
||||||
|
if (status.status === "failed") return yield* Effect.fail(new Error(status.message))
|
||||||
|
if (status.status === "expired") return yield* Effect.fail(new Error("Authentication expired"))
|
||||||
|
process.stdout.write("Connected" + EOL)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const wait = (
|
||||||
|
client: OpenCodeClient,
|
||||||
|
integrationID: string,
|
||||||
|
attemptID: string,
|
||||||
|
shown = false,
|
||||||
|
): Effect.Effect<Exclude<IntegrationCommandStatusOutput["data"], { status: "pending" }>, unknown> =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const response = yield* request(() => client.integration.command.status({ integrationID, attemptID, location }))
|
||||||
|
if (response.data.status !== "pending") return response.data
|
||||||
|
const output = response.data.message?.trim()
|
||||||
|
if (!shown && output) process.stdout.write(output + EOL + EOL)
|
||||||
|
yield* Effect.sleep(500)
|
||||||
|
return yield* wait(client, integrationID, attemptID, shown || !!output)
|
||||||
|
})
|
||||||
|
|
||||||
|
function request<A>(task: () => Promise<A>) {
|
||||||
|
return Effect.tryPromise({ try: task, catch: (cause) => cause })
|
||||||
|
}
|
||||||
@@ -17,6 +17,9 @@ import { Npm } from "@opencode-ai/core/npm"
|
|||||||
const Handlers = Runtime.handlers(Commands, {
|
const Handlers = Runtime.handlers(Commands, {
|
||||||
$: () => import("./commands/handlers/default"),
|
$: () => import("./commands/handlers/default"),
|
||||||
api: () => import("./commands/handlers/api"),
|
api: () => import("./commands/handlers/api"),
|
||||||
|
auth: {
|
||||||
|
connect: () => import("./commands/handlers/auth/connect"),
|
||||||
|
},
|
||||||
debug: {
|
debug: {
|
||||||
agents: () => import("./commands/handlers/debug/agents"),
|
agents: () => import("./commands/handlers/debug/agents"),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -407,102 +407,113 @@ export type Endpoint10_1Input = {
|
|||||||
export type Endpoint10_1Output = EffectValue<ReturnType<RawClient["server.integration"]["integration.get"]>>
|
export type Endpoint10_1Output = EffectValue<ReturnType<RawClient["server.integration"]["integration.get"]>>
|
||||||
export type IntegrationGetOperation<E = never> = (input: Endpoint10_1Input) => Effect.Effect<Endpoint10_1Output, E>
|
export type IntegrationGetOperation<E = never> = (input: Endpoint10_1Input) => Effect.Effect<Endpoint10_1Output, E>
|
||||||
|
|
||||||
type Endpoint10_2Request = Parameters<RawClient["server.integration"]["integration.connect.key"]>[0]
|
type Endpoint10_2Request = Parameters<RawClient["server.integration"]["integration.wellknown.add"]>[0]
|
||||||
export type Endpoint10_2Input = {
|
export type Endpoint10_2Input = {
|
||||||
readonly integrationID: Endpoint10_2Request["params"]["integrationID"]
|
|
||||||
readonly location?: Endpoint10_2Request["query"]["location"]
|
readonly location?: Endpoint10_2Request["query"]["location"]
|
||||||
readonly key: Endpoint10_2Request["payload"]["key"]
|
readonly url: Endpoint10_2Request["payload"]["url"]
|
||||||
readonly label?: Endpoint10_2Request["payload"]["label"]
|
|
||||||
}
|
}
|
||||||
export type Endpoint10_2Output = EffectValue<ReturnType<RawClient["server.integration"]["integration.connect.key"]>>
|
export type Endpoint10_2Output = EffectValue<ReturnType<RawClient["server.integration"]["integration.wellknown.add"]>>
|
||||||
export type IntegrationConnectKeyOperation<E = never> = (
|
export type IntegrationWellknownAddOperation<E = never> = (
|
||||||
input: Endpoint10_2Input,
|
input: Endpoint10_2Input,
|
||||||
) => Effect.Effect<Endpoint10_2Output, E>
|
) => Effect.Effect<Endpoint10_2Output, E>
|
||||||
|
|
||||||
type Endpoint10_3Request = Parameters<RawClient["server.integration"]["integration.oauth.connect"]>[0]
|
type Endpoint10_3Request = Parameters<RawClient["server.integration"]["integration.connect.key"]>[0]
|
||||||
export type Endpoint10_3Input = {
|
export type Endpoint10_3Input = {
|
||||||
readonly integrationID: Endpoint10_3Request["params"]["integrationID"]
|
readonly integrationID: Endpoint10_3Request["params"]["integrationID"]
|
||||||
readonly location?: Endpoint10_3Request["query"]["location"]
|
readonly location?: Endpoint10_3Request["query"]["location"]
|
||||||
readonly methodID: Endpoint10_3Request["payload"]["methodID"]
|
readonly key: Endpoint10_3Request["payload"]["key"]
|
||||||
readonly inputs: Endpoint10_3Request["payload"]["inputs"]
|
|
||||||
readonly label?: Endpoint10_3Request["payload"]["label"]
|
readonly label?: Endpoint10_3Request["payload"]["label"]
|
||||||
}
|
}
|
||||||
export type Endpoint10_3Output = EffectValue<ReturnType<RawClient["server.integration"]["integration.oauth.connect"]>>
|
export type Endpoint10_3Output = EffectValue<ReturnType<RawClient["server.integration"]["integration.connect.key"]>>
|
||||||
export type IntegrationOauthConnectOperation<E = never> = (
|
export type IntegrationConnectKeyOperation<E = never> = (
|
||||||
input: Endpoint10_3Input,
|
input: Endpoint10_3Input,
|
||||||
) => Effect.Effect<Endpoint10_3Output, E>
|
) => Effect.Effect<Endpoint10_3Output, E>
|
||||||
|
|
||||||
type Endpoint10_4Request = Parameters<RawClient["server.integration"]["integration.oauth.status"]>[0]
|
type Endpoint10_4Request = Parameters<RawClient["server.integration"]["integration.oauth.connect"]>[0]
|
||||||
export type Endpoint10_4Input = {
|
export type Endpoint10_4Input = {
|
||||||
readonly integrationID: Endpoint10_4Request["params"]["integrationID"]
|
readonly integrationID: Endpoint10_4Request["params"]["integrationID"]
|
||||||
readonly attemptID: Endpoint10_4Request["params"]["attemptID"]
|
|
||||||
readonly location?: Endpoint10_4Request["query"]["location"]
|
readonly location?: Endpoint10_4Request["query"]["location"]
|
||||||
|
readonly methodID: Endpoint10_4Request["payload"]["methodID"]
|
||||||
|
readonly inputs: Endpoint10_4Request["payload"]["inputs"]
|
||||||
|
readonly label?: Endpoint10_4Request["payload"]["label"]
|
||||||
}
|
}
|
||||||
export type Endpoint10_4Output = EffectValue<ReturnType<RawClient["server.integration"]["integration.oauth.status"]>>
|
export type Endpoint10_4Output = EffectValue<ReturnType<RawClient["server.integration"]["integration.oauth.connect"]>>
|
||||||
export type IntegrationOauthStatusOperation<E = never> = (
|
export type IntegrationOauthConnectOperation<E = never> = (
|
||||||
input: Endpoint10_4Input,
|
input: Endpoint10_4Input,
|
||||||
) => Effect.Effect<Endpoint10_4Output, E>
|
) => Effect.Effect<Endpoint10_4Output, E>
|
||||||
|
|
||||||
type Endpoint10_5Request = Parameters<RawClient["server.integration"]["integration.oauth.complete"]>[0]
|
type Endpoint10_5Request = Parameters<RawClient["server.integration"]["integration.oauth.status"]>[0]
|
||||||
export type Endpoint10_5Input = {
|
export type Endpoint10_5Input = {
|
||||||
readonly integrationID: Endpoint10_5Request["params"]["integrationID"]
|
readonly integrationID: Endpoint10_5Request["params"]["integrationID"]
|
||||||
readonly attemptID: Endpoint10_5Request["params"]["attemptID"]
|
readonly attemptID: Endpoint10_5Request["params"]["attemptID"]
|
||||||
readonly location?: Endpoint10_5Request["query"]["location"]
|
readonly location?: Endpoint10_5Request["query"]["location"]
|
||||||
readonly code?: Endpoint10_5Request["payload"]["code"]
|
|
||||||
}
|
}
|
||||||
export type Endpoint10_5Output = EffectValue<ReturnType<RawClient["server.integration"]["integration.oauth.complete"]>>
|
export type Endpoint10_5Output = EffectValue<ReturnType<RawClient["server.integration"]["integration.oauth.status"]>>
|
||||||
export type IntegrationOauthCompleteOperation<E = never> = (
|
export type IntegrationOauthStatusOperation<E = never> = (
|
||||||
input: Endpoint10_5Input,
|
input: Endpoint10_5Input,
|
||||||
) => Effect.Effect<Endpoint10_5Output, E>
|
) => Effect.Effect<Endpoint10_5Output, E>
|
||||||
|
|
||||||
type Endpoint10_6Request = Parameters<RawClient["server.integration"]["integration.oauth.cancel"]>[0]
|
type Endpoint10_6Request = Parameters<RawClient["server.integration"]["integration.oauth.complete"]>[0]
|
||||||
export type Endpoint10_6Input = {
|
export type Endpoint10_6Input = {
|
||||||
readonly integrationID: Endpoint10_6Request["params"]["integrationID"]
|
readonly integrationID: Endpoint10_6Request["params"]["integrationID"]
|
||||||
readonly attemptID: Endpoint10_6Request["params"]["attemptID"]
|
readonly attemptID: Endpoint10_6Request["params"]["attemptID"]
|
||||||
readonly location?: Endpoint10_6Request["query"]["location"]
|
readonly location?: Endpoint10_6Request["query"]["location"]
|
||||||
|
readonly code?: Endpoint10_6Request["payload"]["code"]
|
||||||
}
|
}
|
||||||
export type Endpoint10_6Output = EffectValue<ReturnType<RawClient["server.integration"]["integration.oauth.cancel"]>>
|
export type Endpoint10_6Output = EffectValue<ReturnType<RawClient["server.integration"]["integration.oauth.complete"]>>
|
||||||
export type IntegrationOauthCancelOperation<E = never> = (
|
export type IntegrationOauthCompleteOperation<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]
|
type Endpoint10_7Request = Parameters<RawClient["server.integration"]["integration.oauth.cancel"]>[0]
|
||||||
export type Endpoint10_7Input = {
|
export type Endpoint10_7Input = {
|
||||||
readonly integrationID: Endpoint10_7Request["params"]["integrationID"]
|
readonly integrationID: Endpoint10_7Request["params"]["integrationID"]
|
||||||
|
readonly attemptID: Endpoint10_7Request["params"]["attemptID"]
|
||||||
readonly location?: Endpoint10_7Request["query"]["location"]
|
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 Endpoint10_7Output = EffectValue<ReturnType<RawClient["server.integration"]["integration.oauth.cancel"]>>
|
||||||
export type IntegrationCommandConnectOperation<E = never> = (
|
export type IntegrationOauthCancelOperation<E = never> = (
|
||||||
input: Endpoint10_7Input,
|
input: Endpoint10_7Input,
|
||||||
) => Effect.Effect<Endpoint10_7Output, E>
|
) => Effect.Effect<Endpoint10_7Output, E>
|
||||||
|
|
||||||
type Endpoint10_8Request = Parameters<RawClient["server.integration"]["integration.command.status"]>[0]
|
type Endpoint10_8Request = Parameters<RawClient["server.integration"]["integration.command.connect"]>[0]
|
||||||
export type Endpoint10_8Input = {
|
export type Endpoint10_8Input = {
|
||||||
readonly integrationID: Endpoint10_8Request["params"]["integrationID"]
|
readonly integrationID: Endpoint10_8Request["params"]["integrationID"]
|
||||||
readonly attemptID: Endpoint10_8Request["params"]["attemptID"]
|
|
||||||
readonly location?: Endpoint10_8Request["query"]["location"]
|
readonly location?: Endpoint10_8Request["query"]["location"]
|
||||||
|
readonly methodID: Endpoint10_8Request["payload"]["methodID"]
|
||||||
|
readonly label?: Endpoint10_8Request["payload"]["label"]
|
||||||
}
|
}
|
||||||
export type Endpoint10_8Output = EffectValue<ReturnType<RawClient["server.integration"]["integration.command.status"]>>
|
export type Endpoint10_8Output = EffectValue<ReturnType<RawClient["server.integration"]["integration.command.connect"]>>
|
||||||
export type IntegrationCommandStatusOperation<E = never> = (
|
export type IntegrationCommandConnectOperation<E = never> = (
|
||||||
input: Endpoint10_8Input,
|
input: Endpoint10_8Input,
|
||||||
) => Effect.Effect<Endpoint10_8Output, E>
|
) => Effect.Effect<Endpoint10_8Output, E>
|
||||||
|
|
||||||
type Endpoint10_9Request = Parameters<RawClient["server.integration"]["integration.command.cancel"]>[0]
|
type Endpoint10_9Request = Parameters<RawClient["server.integration"]["integration.command.status"]>[0]
|
||||||
export type Endpoint10_9Input = {
|
export type Endpoint10_9Input = {
|
||||||
readonly integrationID: Endpoint10_9Request["params"]["integrationID"]
|
readonly integrationID: Endpoint10_9Request["params"]["integrationID"]
|
||||||
readonly attemptID: Endpoint10_9Request["params"]["attemptID"]
|
readonly attemptID: Endpoint10_9Request["params"]["attemptID"]
|
||||||
readonly location?: Endpoint10_9Request["query"]["location"]
|
readonly location?: Endpoint10_9Request["query"]["location"]
|
||||||
}
|
}
|
||||||
export type Endpoint10_9Output = EffectValue<ReturnType<RawClient["server.integration"]["integration.command.cancel"]>>
|
export type Endpoint10_9Output = EffectValue<ReturnType<RawClient["server.integration"]["integration.command.status"]>>
|
||||||
export type IntegrationCommandCancelOperation<E = never> = (
|
export type IntegrationCommandStatusOperation<E = never> = (
|
||||||
input: Endpoint10_9Input,
|
input: Endpoint10_9Input,
|
||||||
) => Effect.Effect<Endpoint10_9Output, E>
|
) => Effect.Effect<Endpoint10_9Output, E>
|
||||||
|
|
||||||
|
type Endpoint10_10Request = Parameters<RawClient["server.integration"]["integration.command.cancel"]>[0]
|
||||||
|
export type Endpoint10_10Input = {
|
||||||
|
readonly integrationID: Endpoint10_10Request["params"]["integrationID"]
|
||||||
|
readonly attemptID: Endpoint10_10Request["params"]["attemptID"]
|
||||||
|
readonly location?: Endpoint10_10Request["query"]["location"]
|
||||||
|
}
|
||||||
|
export type Endpoint10_10Output = EffectValue<ReturnType<RawClient["server.integration"]["integration.command.cancel"]>>
|
||||||
|
export type IntegrationCommandCancelOperation<E = never> = (
|
||||||
|
input: Endpoint10_10Input,
|
||||||
|
) => Effect.Effect<Endpoint10_10Output, 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>
|
||||||
|
readonly wellknown: { readonly add: IntegrationWellknownAddOperation<E> }
|
||||||
readonly connect: { readonly key: IntegrationConnectKeyOperation<E> }
|
readonly connect: { readonly key: IntegrationConnectKeyOperation<E> }
|
||||||
readonly oauth: {
|
readonly oauth: {
|
||||||
readonly connect: IntegrationOauthConnectOperation<E>
|
readonly connect: IntegrationOauthConnectOperation<E>
|
||||||
|
|||||||
@@ -504,106 +504,116 @@ const Endpoint10_1 = (raw: RawClient["server.integration"]) => (input: Endpoint1
|
|||||||
query: { location: input["location"] },
|
query: { location: input["location"] },
|
||||||
}).pipe(Effect.mapError(mapClientError))
|
}).pipe(Effect.mapError(mapClientError))
|
||||||
|
|
||||||
type Endpoint10_2Request = Parameters<RawClient["server.integration"]["integration.connect.key"]>[0]
|
type Endpoint10_2Request = Parameters<RawClient["server.integration"]["integration.wellknown.add"]>[0]
|
||||||
type Endpoint10_2Input = {
|
type Endpoint10_2Input = {
|
||||||
readonly integrationID: Endpoint10_2Request["params"]["integrationID"]
|
|
||||||
readonly location?: Endpoint10_2Request["query"]["location"]
|
readonly location?: Endpoint10_2Request["query"]["location"]
|
||||||
readonly key: Endpoint10_2Request["payload"]["key"]
|
readonly url: Endpoint10_2Request["payload"]["url"]
|
||||||
readonly label?: Endpoint10_2Request["payload"]["label"]
|
|
||||||
}
|
}
|
||||||
const Endpoint10_2 = (raw: RawClient["server.integration"]) => (input: Endpoint10_2Input) =>
|
const Endpoint10_2 = (raw: RawClient["server.integration"]) => (input: Endpoint10_2Input) =>
|
||||||
|
raw["integration.wellknown.add"]({ query: { location: input["location"] }, payload: { url: input["url"] } }).pipe(
|
||||||
|
Effect.mapError(mapClientError),
|
||||||
|
)
|
||||||
|
|
||||||
|
type Endpoint10_3Request = Parameters<RawClient["server.integration"]["integration.connect.key"]>[0]
|
||||||
|
type Endpoint10_3Input = {
|
||||||
|
readonly integrationID: Endpoint10_3Request["params"]["integrationID"]
|
||||||
|
readonly location?: Endpoint10_3Request["query"]["location"]
|
||||||
|
readonly key: Endpoint10_3Request["payload"]["key"]
|
||||||
|
readonly label?: Endpoint10_3Request["payload"]["label"]
|
||||||
|
}
|
||||||
|
const Endpoint10_3 = (raw: RawClient["server.integration"]) => (input: Endpoint10_3Input) =>
|
||||||
raw["integration.connect.key"]({
|
raw["integration.connect.key"]({
|
||||||
params: { integrationID: input["integrationID"] },
|
params: { integrationID: input["integrationID"] },
|
||||||
query: { location: input["location"] },
|
query: { location: input["location"] },
|
||||||
payload: { key: input["key"], label: input["label"] },
|
payload: { key: input["key"], label: input["label"] },
|
||||||
}).pipe(Effect.mapError(mapClientError))
|
}).pipe(Effect.mapError(mapClientError))
|
||||||
|
|
||||||
type Endpoint10_3Request = Parameters<RawClient["server.integration"]["integration.oauth.connect"]>[0]
|
type Endpoint10_4Request = Parameters<RawClient["server.integration"]["integration.oauth.connect"]>[0]
|
||||||
type Endpoint10_3Input = {
|
type Endpoint10_4Input = {
|
||||||
readonly integrationID: Endpoint10_3Request["params"]["integrationID"]
|
readonly integrationID: Endpoint10_4Request["params"]["integrationID"]
|
||||||
readonly location?: Endpoint10_3Request["query"]["location"]
|
readonly location?: Endpoint10_4Request["query"]["location"]
|
||||||
readonly methodID: Endpoint10_3Request["payload"]["methodID"]
|
readonly methodID: Endpoint10_4Request["payload"]["methodID"]
|
||||||
readonly inputs: Endpoint10_3Request["payload"]["inputs"]
|
readonly inputs: Endpoint10_4Request["payload"]["inputs"]
|
||||||
readonly label?: Endpoint10_3Request["payload"]["label"]
|
readonly label?: Endpoint10_4Request["payload"]["label"]
|
||||||
}
|
}
|
||||||
const Endpoint10_3 = (raw: RawClient["server.integration"]) => (input: Endpoint10_3Input) =>
|
const Endpoint10_4 = (raw: RawClient["server.integration"]) => (input: Endpoint10_4Input) =>
|
||||||
raw["integration.oauth.connect"]({
|
raw["integration.oauth.connect"]({
|
||||||
params: { integrationID: input["integrationID"] },
|
params: { integrationID: input["integrationID"] },
|
||||||
query: { location: input["location"] },
|
query: { location: input["location"] },
|
||||||
payload: { methodID: input["methodID"], inputs: input["inputs"], label: input["label"] },
|
payload: { methodID: input["methodID"], inputs: input["inputs"], label: input["label"] },
|
||||||
}).pipe(Effect.mapError(mapClientError))
|
}).pipe(Effect.mapError(mapClientError))
|
||||||
|
|
||||||
type Endpoint10_4Request = Parameters<RawClient["server.integration"]["integration.oauth.status"]>[0]
|
type Endpoint10_5Request = Parameters<RawClient["server.integration"]["integration.oauth.status"]>[0]
|
||||||
type Endpoint10_4Input = {
|
type Endpoint10_5Input = {
|
||||||
readonly integrationID: Endpoint10_4Request["params"]["integrationID"]
|
readonly integrationID: Endpoint10_5Request["params"]["integrationID"]
|
||||||
readonly attemptID: Endpoint10_4Request["params"]["attemptID"]
|
readonly attemptID: Endpoint10_5Request["params"]["attemptID"]
|
||||||
readonly location?: Endpoint10_4Request["query"]["location"]
|
readonly location?: Endpoint10_5Request["query"]["location"]
|
||||||
}
|
}
|
||||||
const Endpoint10_4 = (raw: RawClient["server.integration"]) => (input: Endpoint10_4Input) =>
|
const Endpoint10_5 = (raw: RawClient["server.integration"]) => (input: Endpoint10_5Input) =>
|
||||||
raw["integration.oauth.status"]({
|
raw["integration.oauth.status"]({
|
||||||
params: { integrationID: input["integrationID"], attemptID: input["attemptID"] },
|
params: { integrationID: input["integrationID"], attemptID: input["attemptID"] },
|
||||||
query: { location: input["location"] },
|
query: { location: input["location"] },
|
||||||
}).pipe(Effect.mapError(mapClientError))
|
}).pipe(Effect.mapError(mapClientError))
|
||||||
|
|
||||||
type Endpoint10_5Request = Parameters<RawClient["server.integration"]["integration.oauth.complete"]>[0]
|
type Endpoint10_6Request = Parameters<RawClient["server.integration"]["integration.oauth.complete"]>[0]
|
||||||
type Endpoint10_5Input = {
|
type Endpoint10_6Input = {
|
||||||
readonly integrationID: Endpoint10_5Request["params"]["integrationID"]
|
readonly integrationID: Endpoint10_6Request["params"]["integrationID"]
|
||||||
readonly attemptID: Endpoint10_5Request["params"]["attemptID"]
|
readonly attemptID: Endpoint10_6Request["params"]["attemptID"]
|
||||||
readonly location?: Endpoint10_5Request["query"]["location"]
|
readonly location?: Endpoint10_6Request["query"]["location"]
|
||||||
readonly code?: Endpoint10_5Request["payload"]["code"]
|
readonly code?: Endpoint10_6Request["payload"]["code"]
|
||||||
}
|
}
|
||||||
const Endpoint10_5 = (raw: RawClient["server.integration"]) => (input: Endpoint10_5Input) =>
|
const Endpoint10_6 = (raw: RawClient["server.integration"]) => (input: Endpoint10_6Input) =>
|
||||||
raw["integration.oauth.complete"]({
|
raw["integration.oauth.complete"]({
|
||||||
params: { integrationID: input["integrationID"], attemptID: input["attemptID"] },
|
params: { integrationID: input["integrationID"], attemptID: input["attemptID"] },
|
||||||
query: { location: input["location"] },
|
query: { location: input["location"] },
|
||||||
payload: { code: input["code"] },
|
payload: { code: input["code"] },
|
||||||
}).pipe(Effect.mapError(mapClientError))
|
}).pipe(Effect.mapError(mapClientError))
|
||||||
|
|
||||||
type Endpoint10_6Request = Parameters<RawClient["server.integration"]["integration.oauth.cancel"]>[0]
|
type Endpoint10_7Request = Parameters<RawClient["server.integration"]["integration.oauth.cancel"]>[0]
|
||||||
type Endpoint10_6Input = {
|
type Endpoint10_7Input = {
|
||||||
readonly integrationID: Endpoint10_6Request["params"]["integrationID"]
|
readonly integrationID: Endpoint10_7Request["params"]["integrationID"]
|
||||||
readonly attemptID: Endpoint10_6Request["params"]["attemptID"]
|
readonly attemptID: Endpoint10_7Request["params"]["attemptID"]
|
||||||
readonly location?: Endpoint10_6Request["query"]["location"]
|
readonly location?: Endpoint10_7Request["query"]["location"]
|
||||||
}
|
}
|
||||||
const Endpoint10_6 = (raw: RawClient["server.integration"]) => (input: Endpoint10_6Input) =>
|
const Endpoint10_7 = (raw: RawClient["server.integration"]) => (input: Endpoint10_7Input) =>
|
||||||
raw["integration.oauth.cancel"]({
|
raw["integration.oauth.cancel"]({
|
||||||
params: { integrationID: input["integrationID"], attemptID: input["attemptID"] },
|
params: { integrationID: input["integrationID"], attemptID: input["attemptID"] },
|
||||||
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_8Request = Parameters<RawClient["server.integration"]["integration.command.connect"]>[0]
|
||||||
type Endpoint10_7Input = {
|
type Endpoint10_8Input = {
|
||||||
readonly integrationID: Endpoint10_7Request["params"]["integrationID"]
|
readonly integrationID: Endpoint10_8Request["params"]["integrationID"]
|
||||||
readonly location?: Endpoint10_7Request["query"]["location"]
|
readonly location?: Endpoint10_8Request["query"]["location"]
|
||||||
readonly methodID: Endpoint10_7Request["payload"]["methodID"]
|
readonly methodID: Endpoint10_8Request["payload"]["methodID"]
|
||||||
readonly label?: Endpoint10_7Request["payload"]["label"]
|
readonly label?: Endpoint10_8Request["payload"]["label"]
|
||||||
}
|
}
|
||||||
const Endpoint10_7 = (raw: RawClient["server.integration"]) => (input: Endpoint10_7Input) =>
|
const Endpoint10_8 = (raw: RawClient["server.integration"]) => (input: Endpoint10_8Input) =>
|
||||||
raw["integration.command.connect"]({
|
raw["integration.command.connect"]({
|
||||||
params: { integrationID: input["integrationID"] },
|
params: { integrationID: input["integrationID"] },
|
||||||
query: { location: input["location"] },
|
query: { location: input["location"] },
|
||||||
payload: { methodID: input["methodID"], label: input["label"] },
|
payload: { methodID: input["methodID"], label: input["label"] },
|
||||||
}).pipe(Effect.mapError(mapClientError))
|
}).pipe(Effect.mapError(mapClientError))
|
||||||
|
|
||||||
type Endpoint10_8Request = Parameters<RawClient["server.integration"]["integration.command.status"]>[0]
|
type Endpoint10_9Request = 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 = {
|
type Endpoint10_9Input = {
|
||||||
readonly integrationID: Endpoint10_9Request["params"]["integrationID"]
|
readonly integrationID: Endpoint10_9Request["params"]["integrationID"]
|
||||||
readonly attemptID: Endpoint10_9Request["params"]["attemptID"]
|
readonly attemptID: Endpoint10_9Request["params"]["attemptID"]
|
||||||
readonly location?: Endpoint10_9Request["query"]["location"]
|
readonly location?: Endpoint10_9Request["query"]["location"]
|
||||||
}
|
}
|
||||||
const Endpoint10_9 = (raw: RawClient["server.integration"]) => (input: Endpoint10_9Input) =>
|
const Endpoint10_9 = (raw: RawClient["server.integration"]) => (input: Endpoint10_9Input) =>
|
||||||
|
raw["integration.command.status"]({
|
||||||
|
params: { integrationID: input["integrationID"], attemptID: input["attemptID"] },
|
||||||
|
query: { location: input["location"] },
|
||||||
|
}).pipe(Effect.mapError(mapClientError))
|
||||||
|
|
||||||
|
type Endpoint10_10Request = Parameters<RawClient["server.integration"]["integration.command.cancel"]>[0]
|
||||||
|
type Endpoint10_10Input = {
|
||||||
|
readonly integrationID: Endpoint10_10Request["params"]["integrationID"]
|
||||||
|
readonly attemptID: Endpoint10_10Request["params"]["attemptID"]
|
||||||
|
readonly location?: Endpoint10_10Request["query"]["location"]
|
||||||
|
}
|
||||||
|
const Endpoint10_10 = (raw: RawClient["server.integration"]) => (input: Endpoint10_10Input) =>
|
||||||
raw["integration.command.cancel"]({
|
raw["integration.command.cancel"]({
|
||||||
params: { integrationID: input["integrationID"], attemptID: input["attemptID"] },
|
params: { integrationID: input["integrationID"], attemptID: input["attemptID"] },
|
||||||
query: { location: input["location"] },
|
query: { location: input["location"] },
|
||||||
@@ -612,14 +622,15 @@ const Endpoint10_9 = (raw: RawClient["server.integration"]) => (input: Endpoint1
|
|||||||
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),
|
||||||
connect: { key: Endpoint10_2(raw) },
|
wellknown: { add: Endpoint10_2(raw) },
|
||||||
|
connect: { key: Endpoint10_3(raw) },
|
||||||
oauth: {
|
oauth: {
|
||||||
connect: Endpoint10_3(raw),
|
connect: Endpoint10_4(raw),
|
||||||
status: Endpoint10_4(raw),
|
status: Endpoint10_5(raw),
|
||||||
complete: Endpoint10_5(raw),
|
complete: Endpoint10_6(raw),
|
||||||
cancel: Endpoint10_6(raw),
|
cancel: Endpoint10_7(raw),
|
||||||
},
|
},
|
||||||
command: { connect: Endpoint10_7(raw), status: Endpoint10_8(raw), cancel: Endpoint10_9(raw) },
|
command: { connect: Endpoint10_8(raw), status: Endpoint10_9(raw), cancel: Endpoint10_10(raw) },
|
||||||
})
|
})
|
||||||
|
|
||||||
type Endpoint11_0Request = Parameters<RawClient["server.mcp"]["mcp.list"]>[0]
|
type Endpoint11_0Request = Parameters<RawClient["server.mcp"]["mcp.list"]>[0]
|
||||||
|
|||||||
@@ -82,6 +82,8 @@ import type {
|
|||||||
IntegrationListOutput,
|
IntegrationListOutput,
|
||||||
IntegrationGetInput,
|
IntegrationGetInput,
|
||||||
IntegrationGetOutput,
|
IntegrationGetOutput,
|
||||||
|
IntegrationWellknownAddInput,
|
||||||
|
IntegrationWellknownAddOutput,
|
||||||
IntegrationConnectKeyInput,
|
IntegrationConnectKeyInput,
|
||||||
IntegrationConnectKeyOutput,
|
IntegrationConnectKeyOutput,
|
||||||
IntegrationOauthConnectInput,
|
IntegrationOauthConnectInput,
|
||||||
@@ -893,6 +895,21 @@ export function make(options: ClientOptions) {
|
|||||||
},
|
},
|
||||||
requestOptions,
|
requestOptions,
|
||||||
),
|
),
|
||||||
|
wellknown: {
|
||||||
|
add: (input: IntegrationWellknownAddInput, requestOptions?: RequestOptions) =>
|
||||||
|
request<IntegrationWellknownAddOutput>(
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
path: `/api/experimental/integration/wellknown`,
|
||||||
|
query: { location: input["location"] },
|
||||||
|
body: { url: input["url"] },
|
||||||
|
successStatus: 204,
|
||||||
|
declaredStatuses: [400, 401],
|
||||||
|
empty: true,
|
||||||
|
},
|
||||||
|
requestOptions,
|
||||||
|
),
|
||||||
|
},
|
||||||
connect: {
|
connect: {
|
||||||
key: (input: IntegrationConnectKeyInput, requestOptions?: RequestOptions) =>
|
key: (input: IntegrationConnectKeyInput, requestOptions?: RequestOptions) =>
|
||||||
request<IntegrationConnectKeyOutput>(
|
request<IntegrationConnectKeyOutput>(
|
||||||
|
|||||||
@@ -3304,6 +3304,15 @@ export type IntegrationGetOutput = {
|
|||||||
data: IntegrationInfo | null
|
data: IntegrationInfo | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type IntegrationWellknownAddInput = {
|
||||||
|
readonly location?: {
|
||||||
|
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||||
|
}["location"]
|
||||||
|
readonly url: { readonly url: string }["url"]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type IntegrationWellknownAddOutput = void
|
||||||
|
|
||||||
export type IntegrationConnectKeyInput = {
|
export type IntegrationConnectKeyInput = {
|
||||||
readonly integrationID: { readonly integrationID: string }["integrationID"]
|
readonly integrationID: { readonly integrationID: string }["integrationID"]
|
||||||
readonly location?: {
|
readonly location?: {
|
||||||
|
|||||||
@@ -36,7 +36,8 @@ 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", "command"])
|
expect(Object.keys(client.integration)).toEqual(["list", "get", "wellknown", "connect", "oauth", "command"])
|
||||||
|
expect(Object.keys(client.integration.wellknown)).toEqual(["add"])
|
||||||
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.integration.command)).toEqual(["connect", "status", "cancel"])
|
||||||
@@ -62,6 +63,28 @@ test("server.get uses the public HTTP contract", async () => {
|
|||||||
expect(request?.url).toBe("http://localhost:3000/api/server")
|
expect(request?.url).toBe("http://localhost:3000/api/server")
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("experimental wellknown integration add uses the public HTTP contract", async () => {
|
||||||
|
let request: Request | undefined
|
||||||
|
const client = OpenCode.make({
|
||||||
|
baseUrl: "http://localhost:3000",
|
||||||
|
fetch: async (input, init) => {
|
||||||
|
request = input instanceof Request ? input : new Request(input, init)
|
||||||
|
return new Response(null, { status: 204 })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
await client.integration.wellknown.add({
|
||||||
|
url: "https://example.com",
|
||||||
|
location: { directory: "/tmp/project" },
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(request?.method).toBe("POST")
|
||||||
|
expect(request?.url).toBe(
|
||||||
|
"http://localhost:3000/api/experimental/integration/wellknown?location%5Bdirectory%5D=%2Ftmp%2Fproject",
|
||||||
|
)
|
||||||
|
expect(await request?.json()).toEqual({ url: "https://example.com" })
|
||||||
|
})
|
||||||
|
|
||||||
test("health.stop sends exact replacement identity", async () => {
|
test("health.stop sends exact replacement identity", async () => {
|
||||||
let request: Request | undefined
|
let request: Request | undefined
|
||||||
const client = OpenCode.make({
|
const client = OpenCode.make({
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
{
|
{
|
||||||
"version": "7",
|
"version": "7",
|
||||||
"dialect": "sqlite",
|
"dialect": "sqlite",
|
||||||
"id": "5f0a1db8-d4bf-42c3-becb-96b46fe66bed",
|
"id": "a4ba73b4-21bc-41ab-a415-94e2ca38d798",
|
||||||
"prevIds": [
|
"prevIds": [
|
||||||
"666138ef-82cb-4a9a-a765-e6669a436ff3"
|
"5f0a1db8-d4bf-42c3-becb-96b46fe66bed"
|
||||||
],
|
],
|
||||||
"ddl": [
|
"ddl": [
|
||||||
{
|
{
|
||||||
@@ -38,6 +38,10 @@
|
|||||||
"name": "event",
|
"name": "event",
|
||||||
"entityType": "tables"
|
"entityType": "tables"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "kv",
|
||||||
|
"entityType": "tables"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "permission",
|
"name": "permission",
|
||||||
"entityType": "tables"
|
"entityType": "tables"
|
||||||
@@ -556,6 +560,46 @@
|
|||||||
"entityType": "columns",
|
"entityType": "columns",
|
||||||
"table": "event"
|
"table": "event"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": null,
|
||||||
|
"generated": null,
|
||||||
|
"name": "key",
|
||||||
|
"entityType": "columns",
|
||||||
|
"table": "kv"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": null,
|
||||||
|
"generated": null,
|
||||||
|
"name": "value",
|
||||||
|
"entityType": "columns",
|
||||||
|
"table": "kv"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "integer",
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": null,
|
||||||
|
"generated": null,
|
||||||
|
"name": "time_created",
|
||||||
|
"entityType": "columns",
|
||||||
|
"table": "kv"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "integer",
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": null,
|
||||||
|
"generated": null,
|
||||||
|
"name": "time_updated",
|
||||||
|
"entityType": "columns",
|
||||||
|
"table": "kv"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"type": "text",
|
"type": "text",
|
||||||
"notNull": false,
|
"notNull": false,
|
||||||
@@ -1844,6 +1888,15 @@
|
|||||||
"table": "event",
|
"table": "event",
|
||||||
"entityType": "pks"
|
"entityType": "pks"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"columns": [
|
||||||
|
"key"
|
||||||
|
],
|
||||||
|
"nameExplicit": false,
|
||||||
|
"name": "kv_pk",
|
||||||
|
"table": "kv",
|
||||||
|
"entityType": "pks"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"columns": [
|
"columns": [
|
||||||
"id"
|
"id"
|
||||||
|
|||||||
+87
-17
@@ -6,6 +6,8 @@ import { type ParseError, parse } from "jsonc-parser"
|
|||||||
import { Context, Effect, Fiber, Layer, Option, PubSub, Schema, Stream } from "effect"
|
import { Context, Effect, Fiber, Layer, Option, PubSub, Schema, Stream } from "effect"
|
||||||
import { Permission } from "@opencode-ai/schema/permission"
|
import { Permission } from "@opencode-ai/schema/permission"
|
||||||
import { Config as ConfigSchema } from "@opencode-ai/schema/config"
|
import { Config as ConfigSchema } from "@opencode-ai/schema/config"
|
||||||
|
import { Integration } from "@opencode-ai/schema/integration"
|
||||||
|
import { Credential } from "./credential"
|
||||||
import { EventV2 } from "./event"
|
import { EventV2 } from "./event"
|
||||||
import { Watcher } from "./filesystem/watcher"
|
import { Watcher } from "./filesystem/watcher"
|
||||||
import { FSUtil } from "./fs-util"
|
import { FSUtil } from "./fs-util"
|
||||||
@@ -28,6 +30,7 @@ import { ConfigVariable } from "./config/variable"
|
|||||||
import { ConfigWatcher } from "./config/watcher"
|
import { ConfigWatcher } from "./config/watcher"
|
||||||
import { ConfigV1 } from "./v1/config/config"
|
import { ConfigV1 } from "./v1/config/config"
|
||||||
import { ConfigMigrateV1 } from "./v1/config/migrate"
|
import { ConfigMigrateV1 } from "./v1/config/migrate"
|
||||||
|
import { WellKnown } from "./wellknown"
|
||||||
|
|
||||||
export class Info extends Schema.Class<Info>("Config.Info")({
|
export class Info extends Schema.Class<Info>("Config.Info")({
|
||||||
$schema: Schema.optional(Schema.String).annotate({
|
$schema: Schema.optional(Schema.String).annotate({
|
||||||
@@ -157,29 +160,67 @@ const layer = Layer.effect(
|
|||||||
const location = yield* Location.Service
|
const location = yield* Location.Service
|
||||||
const watcher = yield* Watcher.Service
|
const watcher = yield* Watcher.Service
|
||||||
const events = yield* EventV2.Service
|
const events = yield* EventV2.Service
|
||||||
|
const credentials = yield* Credential.Service
|
||||||
|
const wellknown = yield* WellKnown.Service
|
||||||
const names = ["opencode.json", "opencode.jsonc"]
|
const names = ["opencode.json", "opencode.jsonc"]
|
||||||
const decodeOptions = { errors: "all", onExcessProperty: "ignore", propertyOrder: "original" } as const
|
const decodeOptions = { errors: "all", onExcessProperty: "ignore", propertyOrder: "original" } as const
|
||||||
const decodeInfo = Schema.decodeUnknownOption(Info, decodeOptions)
|
const decodeInfo = Schema.decodeUnknownOption(Info, decodeOptions)
|
||||||
const decodeV1Info = Schema.decodeUnknownOption(ConfigV1.Info, decodeOptions)
|
const decodeV1Info = Schema.decodeUnknownOption(ConfigV1.Info, decodeOptions)
|
||||||
|
|
||||||
const loadFile = Effect.fnUntraced(function* (filepath: string) {
|
const parseInfo = (text: string) => {
|
||||||
const text = yield* fs.readFileStringSafe(filepath)
|
|
||||||
if (!text) return
|
|
||||||
const substituted = yield* ConfigVariable.substitute({ type: "path", path: filepath, text })
|
|
||||||
|
|
||||||
const errors: ParseError[] = []
|
const errors: ParseError[] = []
|
||||||
const input: unknown = parse(substituted, errors, { allowTrailingComma: true })
|
const input: unknown = parse(text, errors, { allowTrailingComma: true })
|
||||||
if (errors.length) return
|
if (errors.length) return
|
||||||
|
return Option.getOrUndefined(
|
||||||
const info = Option.getOrUndefined(
|
|
||||||
ConfigMigrateV1.isV1(input)
|
ConfigMigrateV1.isV1(input)
|
||||||
? decodeV1Info(input).pipe(Option.map(ConfigMigrateV1.migrate), Option.flatMap(decodeInfo))
|
? decodeV1Info(input).pipe(Option.map(ConfigMigrateV1.migrate), Option.flatMap(decodeInfo))
|
||||||
: decodeInfo(input),
|
: decodeInfo(input),
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadFile = Effect.fnUntraced(function* (filepath: string) {
|
||||||
|
const text = yield* fs.readFileStringSafe(filepath)
|
||||||
|
if (!text) return
|
||||||
|
const substituted = yield* ConfigVariable.substitute({ type: "path", path: filepath, text })
|
||||||
|
const info = parseInfo(substituted)
|
||||||
if (!info) return
|
if (!info) return
|
||||||
return new Document({ type: "document", path: filepath, info })
|
return new Document({ type: "document", path: filepath, info })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const loadWellknown = Effect.fn("Config.loadWellknown")(function* () {
|
||||||
|
const entries = yield* wellknown
|
||||||
|
.entries()
|
||||||
|
.pipe(
|
||||||
|
Effect.catch((error) =>
|
||||||
|
Effect.logWarning("failed to discover wellknown config", { error }).pipe(Effect.as([] as const)),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return yield* Effect.forEach(entries, (entry) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const auth = entry.manifest.auth
|
||||||
|
if (!auth) return []
|
||||||
|
const credential = (yield* credentials.list(entry.integrationID)).findLast(
|
||||||
|
(credential) => credential.value.type === "key",
|
||||||
|
)
|
||||||
|
if (!credential || credential.value.type !== "key") return []
|
||||||
|
const variables = { [auth.env]: credential.value.key }
|
||||||
|
const configs = yield* wellknown.resolve(entry, variables).pipe(Effect.orDie)
|
||||||
|
return yield* Effect.forEach(configs, (config) =>
|
||||||
|
ConfigVariable.substitute({
|
||||||
|
type: "virtual",
|
||||||
|
source: entry.origin,
|
||||||
|
dir: entry.origin,
|
||||||
|
text: JSON.stringify(config),
|
||||||
|
env: variables,
|
||||||
|
}).pipe(
|
||||||
|
Effect.map(parseInfo),
|
||||||
|
Effect.map((info) => (info ? new Document({ type: "document", info }) : undefined)),
|
||||||
|
),
|
||||||
|
).pipe(Effect.map((documents) => documents.filter((document) => document !== undefined)))
|
||||||
|
}),
|
||||||
|
).pipe(Effect.map((documents) => documents.flat()))
|
||||||
|
})
|
||||||
|
|
||||||
const loadDirectory = Effect.fnUntraced(function* (directory: AbsolutePath) {
|
const loadDirectory = Effect.fnUntraced(function* (directory: AbsolutePath) {
|
||||||
return [
|
return [
|
||||||
...(yield* Effect.forEach(names, (file) => loadFile(path.join(directory, file))).pipe(
|
...(yield* Effect.forEach(names, (file) => loadFile(path.join(directory, file))).pipe(
|
||||||
@@ -201,7 +242,7 @@ const layer = Layer.effect(
|
|||||||
targets: [".opencode", ".claude", ".agents", ...names.toReversed()],
|
targets: [".opencode", ".claude", ".agents", ...names.toReversed()],
|
||||||
start: location.directory,
|
start: location.directory,
|
||||||
})
|
})
|
||||||
.pipe(Effect.orDie)
|
.pipe(Effect.orDie)
|
||||||
|
|
||||||
// We load certain files from a few other folders in the ecosystem
|
// We load certain files from a few other folders in the ecosystem
|
||||||
const claude = [
|
const claude = [
|
||||||
@@ -244,7 +285,14 @@ const layer = Layer.effect(
|
|||||||
)
|
)
|
||||||
|
|
||||||
const supplementary = yield* Effect.forEach(directories, loadDirectory).pipe(Effect.orDie)
|
const supplementary = yield* Effect.forEach(directories, loadDirectory).pipe(Effect.orDie)
|
||||||
return [...claude, ...agents, ...(supplementary[0] ?? []), ...direct, ...supplementary.slice(1).flat()]
|
return [
|
||||||
|
...claude,
|
||||||
|
...agents,
|
||||||
|
...(supplementary[0] ?? []),
|
||||||
|
...direct,
|
||||||
|
...supplementary.slice(1).flat(),
|
||||||
|
...(yield* loadWellknown().pipe(Effect.orDie)),
|
||||||
|
]
|
||||||
})
|
})
|
||||||
|
|
||||||
const initial = yield* discover()
|
const initial = yield* discover()
|
||||||
@@ -276,15 +324,37 @@ const layer = Layer.effect(
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const reload = Effect.fn("Config.reload")(function* () {
|
||||||
|
const next = yield* discover()
|
||||||
|
configs = next
|
||||||
|
yield* reconcile(next)
|
||||||
|
yield* events.publish(ConfigSchema.Event.Updated, {})
|
||||||
|
})
|
||||||
|
|
||||||
yield* Stream.fromPubSub(updates).pipe(
|
yield* Stream.fromPubSub(updates).pipe(
|
||||||
Stream.debounce("100 millis"),
|
Stream.debounce("100 millis"),
|
||||||
Stream.runForEach((update) =>
|
Stream.runForEach((update) =>
|
||||||
Effect.gen(function* () {
|
reload().pipe(
|
||||||
const next = yield* discover()
|
Effect.catchCause((cause) => Effect.logError("failed to reload config", { path: update.path, cause })),
|
||||||
configs = next
|
),
|
||||||
yield* reconcile(next)
|
),
|
||||||
yield* events.publish(ConfigSchema.Event.Updated, {})
|
Effect.forkScoped({ startImmediately: true }),
|
||||||
}).pipe(Effect.catchCause((cause) => Effect.logError("failed to reload config", { path: update.path, cause }))),
|
)
|
||||||
|
yield* events.subscribe(Integration.Event.ConnectionUpdated).pipe(
|
||||||
|
Stream.filterEffect((event) =>
|
||||||
|
wellknown.entries().pipe(
|
||||||
|
Effect.map((entries) => entries.some((entry) => entry.integrationID === event.data.integrationID)),
|
||||||
|
Effect.catch(() => Effect.succeed(false)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Stream.runForEach(() =>
|
||||||
|
reload().pipe(Effect.catchCause((cause) => Effect.logError("failed to reload wellknown config", { cause }))),
|
||||||
|
),
|
||||||
|
Effect.forkScoped({ startImmediately: true }),
|
||||||
|
)
|
||||||
|
yield* wellknown.changes.pipe(
|
||||||
|
Stream.runForEach(() =>
|
||||||
|
reload().pipe(Effect.catchCause((cause) => Effect.logError("failed to reload wellknown sources", { cause }))),
|
||||||
),
|
),
|
||||||
Effect.forkScoped({ startImmediately: true }),
|
Effect.forkScoped({ startImmediately: true }),
|
||||||
)
|
)
|
||||||
@@ -301,5 +371,5 @@ const layer = Layer.effect(
|
|||||||
export const node = makeLocationNode({
|
export const node = makeLocationNode({
|
||||||
service: Service,
|
service: Service,
|
||||||
layer,
|
layer,
|
||||||
deps: [Watcher.node, EventV2.node, FSUtil.node, Global.node, Location.node],
|
deps: [Watcher.node, EventV2.node, FSUtil.node, Global.node, Location.node, Credential.node, WellKnown.node],
|
||||||
})
|
})
|
||||||
|
|||||||
+1
@@ -54,5 +54,6 @@ export const migrations = (
|
|||||||
import("./migration/20260709163752_time_suspended"),
|
import("./migration/20260709163752_time_suspended"),
|
||||||
import("./migration/20260709190621_session_pending_table"),
|
import("./migration/20260709190621_session_pending_table"),
|
||||||
import("./migration/20260710025429_instruction_sync"),
|
import("./migration/20260710025429_instruction_sync"),
|
||||||
|
import("./migration/20260716020354_kv"),
|
||||||
])
|
])
|
||||||
).map((module) => module.default) satisfies DatabaseMigration.Migration[]
|
).map((module) => module.default) satisfies DatabaseMigration.Migration[]
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { Effect } from "effect"
|
||||||
|
import type { DatabaseMigration } from "../migration"
|
||||||
|
|
||||||
|
export default {
|
||||||
|
id: "20260716020354_kv",
|
||||||
|
up(tx) {
|
||||||
|
return Effect.gen(function* () {
|
||||||
|
yield* tx.run(`
|
||||||
|
CREATE TABLE \`kv\` (
|
||||||
|
\`key\` text PRIMARY KEY,
|
||||||
|
\`value\` text NOT NULL,
|
||||||
|
\`time_created\` integer NOT NULL,
|
||||||
|
\`time_updated\` integer NOT NULL
|
||||||
|
);
|
||||||
|
`)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
} satisfies DatabaseMigration.Migration
|
||||||
@@ -87,6 +87,14 @@ export default {
|
|||||||
CONSTRAINT \`fk_event_aggregate_id_event_sequence_aggregate_id_fk\` FOREIGN KEY (\`aggregate_id\`) REFERENCES \`event_sequence\`(\`aggregate_id\`) ON DELETE CASCADE
|
CONSTRAINT \`fk_event_aggregate_id_event_sequence_aggregate_id_fk\` FOREIGN KEY (\`aggregate_id\`) REFERENCES \`event_sequence\`(\`aggregate_id\`) ON DELETE CASCADE
|
||||||
);
|
);
|
||||||
`)
|
`)
|
||||||
|
yield* tx.run(`
|
||||||
|
CREATE TABLE \`kv\` (
|
||||||
|
\`key\` text PRIMARY KEY,
|
||||||
|
\`value\` text NOT NULL,
|
||||||
|
\`time_created\` integer NOT NULL,
|
||||||
|
\`time_updated\` integer NOT NULL
|
||||||
|
);
|
||||||
|
`)
|
||||||
yield* tx.run(`
|
yield* tx.run(`
|
||||||
CREATE TABLE \`permission\` (
|
CREATE TABLE \`permission\` (
|
||||||
\`id\` text PRIMARY KEY,
|
\`id\` text PRIMARY KEY,
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
Duration,
|
Duration,
|
||||||
Effect,
|
Effect,
|
||||||
Exit,
|
Exit,
|
||||||
|
Fiber,
|
||||||
Layer,
|
Layer,
|
||||||
Schedule,
|
Schedule,
|
||||||
Schema,
|
Schema,
|
||||||
@@ -617,34 +618,40 @@ const layer = Layer.effect(
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
yield* processes
|
yield* Effect.gen(function* () {
|
||||||
.runStream(
|
const handle = yield* processes.spawn(
|
||||||
ChildProcess.make(method.command[0], method.command.slice(1), {
|
ChildProcess.make(method.command[0], method.command.slice(1), {
|
||||||
extendEnv: true,
|
extendEnv: true,
|
||||||
stdin: "ignore",
|
stdin: "ignore",
|
||||||
}),
|
}),
|
||||||
{ okExitCodes: [0] },
|
|
||||||
)
|
)
|
||||||
.pipe(
|
const stdout = yield* AppProcess.collectStream(handle.stdout, undefined).pipe(Effect.forkScoped)
|
||||||
Stream.tap((line) =>
|
yield* handle.stderr.pipe(
|
||||||
|
Stream.decodeText,
|
||||||
|
Stream.tap((chunk) =>
|
||||||
SynchronizedRef.update(commandAttempts, (current) => {
|
SynchronizedRef.update(commandAttempts, (current) => {
|
||||||
const attempt = current.get(attemptID)
|
const attempt = current.get(attemptID)
|
||||||
if (!attempt || attempt.status !== "pending") return current
|
if (!attempt || attempt.status !== "pending") return current
|
||||||
const message = attempt.message ? `${attempt.message}\n${line}` : line
|
const message = (attempt.message ?? "") + chunk
|
||||||
return new Map(current).set(attemptID, { ...attempt, message })
|
return new Map(current).set(attemptID, { ...attempt, message })
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
Stream.runCollect,
|
Stream.runDrain,
|
||||||
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 }),
|
|
||||||
)
|
)
|
||||||
|
const exitCode = yield* handle.exitCode
|
||||||
|
if (exitCode !== 0) {
|
||||||
|
const attempt = (yield* SynchronizedRef.get(commandAttempts)).get(attemptID)
|
||||||
|
return yield* Effect.fail(new Error(attempt?.message?.trim() || `Authentication command exited ${exitCode}`))
|
||||||
|
}
|
||||||
|
const credential = (yield* Fiber.join(stdout)).buffer.toString("utf8").trim()
|
||||||
|
if (!credential) return yield* Effect.fail(new Error("Authentication command returned no credential"))
|
||||||
|
return credential
|
||||||
|
}).pipe(
|
||||||
|
Scope.provide(attemptScope),
|
||||||
|
Effect.exit,
|
||||||
|
Effect.flatMap((exit) => settleCommand(attemptID, exit)),
|
||||||
|
Effect.forkIn(attemptScope, { startImmediately: true }),
|
||||||
|
)
|
||||||
|
|
||||||
return CommandAttempt.make({ attemptID, time })
|
return CommandAttempt.make({ attemptID, time })
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
export * as KV from "./kv"
|
||||||
|
|
||||||
|
import { eq } from "drizzle-orm"
|
||||||
|
import { Context, Effect, Layer, Schema } from "effect"
|
||||||
|
import { Database } from "./database/database"
|
||||||
|
import { makeGlobalNode } from "./effect/app-node"
|
||||||
|
import { KVTable } from "./kv/sql"
|
||||||
|
|
||||||
|
export type Value = Schema.Json
|
||||||
|
|
||||||
|
export interface Interface {
|
||||||
|
readonly get: (key: string) => Effect.Effect<Value | undefined>
|
||||||
|
readonly set: (key: string, value: Value) => Effect.Effect<void>
|
||||||
|
readonly remove: (key: string) => Effect.Effect<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/KV") {}
|
||||||
|
|
||||||
|
const layer = Layer.effect(
|
||||||
|
Service,
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const { db } = yield* Database.Service
|
||||||
|
return Service.of({
|
||||||
|
get: Effect.fn("KV.get")(function* (key) {
|
||||||
|
return (yield* db
|
||||||
|
.select({ value: KVTable.value })
|
||||||
|
.from(KVTable)
|
||||||
|
.where(eq(KVTable.key, key))
|
||||||
|
.get()
|
||||||
|
.pipe(Effect.orDie))?.value
|
||||||
|
}),
|
||||||
|
set: Effect.fn("KV.set")(function* (key, value) {
|
||||||
|
yield* db
|
||||||
|
.insert(KVTable)
|
||||||
|
.values({ key, value })
|
||||||
|
.onConflictDoUpdate({ target: KVTable.key, set: { value, time_updated: Date.now() } })
|
||||||
|
.run()
|
||||||
|
.pipe(Effect.orDie)
|
||||||
|
}),
|
||||||
|
remove: Effect.fn("KV.remove")(function* (key) {
|
||||||
|
yield* db.delete(KVTable).where(eq(KVTable.key, key)).run().pipe(Effect.orDie)
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
export const node = makeGlobalNode({ service: Service, layer, deps: [Database.node] })
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { sqliteTable, text } from "drizzle-orm/sqlite-core"
|
||||||
|
import { Timestamps } from "../database/schema.sql"
|
||||||
|
import type { KV } from "../kv"
|
||||||
|
|
||||||
|
export const KVTable = sqliteTable("kv", {
|
||||||
|
key: text().primaryKey(),
|
||||||
|
value: text({ mode: "json" }).$type<KV.Value>().notNull(),
|
||||||
|
...Timestamps,
|
||||||
|
})
|
||||||
@@ -43,6 +43,7 @@ import { SubagentTool } from "../tool/subagent"
|
|||||||
import { Tools } from "../tool/tools"
|
import { Tools } from "../tool/tools"
|
||||||
import { WebFetchTool } from "../tool/webfetch"
|
import { WebFetchTool } from "../tool/webfetch"
|
||||||
import { WebSearchTool } from "../tool/websearch"
|
import { WebSearchTool } from "../tool/websearch"
|
||||||
|
import { WellKnown } from "../wellknown"
|
||||||
import { WriteTool } from "../tool/write"
|
import { WriteTool } from "../tool/write"
|
||||||
import { AgentPlugin } from "./agent"
|
import { AgentPlugin } from "./agent"
|
||||||
import { CommandPlugin } from "./command"
|
import { CommandPlugin } from "./command"
|
||||||
@@ -52,6 +53,7 @@ import { PluginRuntime } from "./runtime"
|
|||||||
import { SkillPlugin } from "./skill"
|
import { SkillPlugin } from "./skill"
|
||||||
import { SystemPromptPlugin } from "./system-prompt"
|
import { SystemPromptPlugin } from "./system-prompt"
|
||||||
import { VariantPlugin } from "./variant"
|
import { VariantPlugin } from "./variant"
|
||||||
|
import { WellKnownPlugin } from "../wellknown/plugin"
|
||||||
|
|
||||||
const services = Effect.fn("PluginInternal.services")(function* () {
|
const services = Effect.fn("PluginInternal.services")(function* () {
|
||||||
const agent = yield* AgentV2.Service
|
const agent = yield* AgentV2.Service
|
||||||
@@ -81,6 +83,7 @@ const services = Effect.fn("PluginInternal.services")(function* () {
|
|||||||
const skill = yield* SkillV2.Service
|
const skill = yield* SkillV2.Service
|
||||||
const tools = yield* Tools.Service
|
const tools = yield* Tools.Service
|
||||||
const websearch = yield* WebSearchTool.ConfigService
|
const websearch = yield* WebSearchTool.ConfigService
|
||||||
|
const wellknown = yield* WellKnown.Service
|
||||||
return Context.mergeAll(
|
return Context.mergeAll(
|
||||||
Context.make(AgentV2.Service, agent),
|
Context.make(AgentV2.Service, agent),
|
||||||
Context.make(Catalog.Service, catalog),
|
Context.make(Catalog.Service, catalog),
|
||||||
@@ -109,6 +112,7 @@ const services = Effect.fn("PluginInternal.services")(function* () {
|
|||||||
Context.make(SkillV2.Service, skill),
|
Context.make(SkillV2.Service, skill),
|
||||||
Context.make(Tools.Service, tools),
|
Context.make(Tools.Service, tools),
|
||||||
Context.make(WebSearchTool.ConfigService, websearch),
|
Context.make(WebSearchTool.ConfigService, websearch),
|
||||||
|
Context.make(WellKnown.Service, wellknown),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -119,6 +123,7 @@ export type Requirements = ContextServices<Effect.Success<ReturnType<typeof serv
|
|||||||
export type InternalPlugin = Plugin<Requirements | Scope.Scope>
|
export type InternalPlugin = Plugin<Requirements | Scope.Scope>
|
||||||
|
|
||||||
const pre = [
|
const pre = [
|
||||||
|
WellKnownPlugin.Plugin,
|
||||||
AgentPlugin.Plugin,
|
AgentPlugin.Plugin,
|
||||||
CommandPlugin.Plugin,
|
CommandPlugin.Plugin,
|
||||||
SkillPlugin.Plugin,
|
SkillPlugin.Plugin,
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ import { SkillV2 } from "../skill"
|
|||||||
import { ReadToolFileSystem } from "../tool/read-filesystem"
|
import { ReadToolFileSystem } from "../tool/read-filesystem"
|
||||||
import { ToolRegistry } from "../tool/registry"
|
import { ToolRegistry } from "../tool/registry"
|
||||||
import { WebSearchTool } from "../tool/websearch"
|
import { WebSearchTool } from "../tool/websearch"
|
||||||
|
import { WellKnown } from "../wellknown"
|
||||||
import { PluginInternal } from "./internal"
|
import { PluginInternal } from "./internal"
|
||||||
import { PluginRuntime } from "./runtime"
|
import { PluginRuntime } from "./runtime"
|
||||||
import { SdkPlugins } from "./sdk"
|
import { SdkPlugins } from "./sdk"
|
||||||
@@ -163,9 +164,7 @@ const load = Effect.fn("PluginSupervisor.load")(function* (operation: Extract<Op
|
|||||||
if (!entrypoint) return
|
if (!entrypoint) return
|
||||||
// Bun currently ignores query parameters when caching file:// imports.
|
// Bun currently ignores query parameters when caching file:// imports.
|
||||||
const source =
|
const source =
|
||||||
operation.mtime === undefined
|
operation.mtime === undefined ? entrypoint : `${operation.target.replaceAll("\\", "/")}?mtime=${operation.mtime}`
|
||||||
? entrypoint
|
|
||||||
: `${operation.target.replaceAll("\\", "/")}?mtime=${operation.mtime}`
|
|
||||||
yield* Effect.log({ msg: "loading plugin", id: operation.target, entrypoint: source })
|
yield* Effect.log({ msg: "loading plugin", id: operation.target, entrypoint: source })
|
||||||
const mod = yield* Effect.promise(() => import(source))
|
const mod = yield* Effect.promise(() => import(source))
|
||||||
const value = (yield* Schema.decodeUnknownEffect(PluginModule)(mod)).default
|
const value = (yield* Schema.decodeUnknownEffect(PluginModule)(mod)).default
|
||||||
@@ -296,6 +295,7 @@ export const node = makeLocationNode({
|
|||||||
SkillV2.node,
|
SkillV2.node,
|
||||||
ToolRegistry.toolsNode,
|
ToolRegistry.toolsNode,
|
||||||
WebSearchTool.configNode,
|
WebSearchTool.configNode,
|
||||||
|
WellKnown.node,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,166 @@
|
|||||||
|
export * as WellKnown from "./wellknown"
|
||||||
|
|
||||||
|
import { Integration } from "@opencode-ai/schema/integration"
|
||||||
|
import { Context, Effect, Layer, PubSub, Ref, Schema, Semaphore, Stream } from "effect"
|
||||||
|
import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
|
||||||
|
import { makeGlobalNode } from "./effect/app-node"
|
||||||
|
import { httpClient } from "./effect/app-node-platform"
|
||||||
|
import { KV } from "./kv"
|
||||||
|
|
||||||
|
export interface Auth extends Schema.Schema.Type<typeof Auth> {}
|
||||||
|
export const Auth = Schema.Struct({
|
||||||
|
command: Schema.Array(Schema.String),
|
||||||
|
env: Schema.String,
|
||||||
|
}).annotate({ identifier: "WellKnown.Auth" })
|
||||||
|
|
||||||
|
export interface RemoteConfig extends Schema.Schema.Type<typeof RemoteConfig> {}
|
||||||
|
export const RemoteConfig = Schema.Struct({
|
||||||
|
url: Schema.String,
|
||||||
|
headers: Schema.optional(Schema.Record(Schema.String, Schema.String)),
|
||||||
|
}).annotate({ identifier: "WellKnown.RemoteConfig" })
|
||||||
|
|
||||||
|
export interface Config extends Schema.Schema.Type<typeof Config> {}
|
||||||
|
export const Config = Schema.Record(Schema.String, Schema.Json).annotate({ identifier: "WellKnown.Config" })
|
||||||
|
|
||||||
|
export interface Manifest extends Schema.Schema.Type<typeof Manifest> {}
|
||||||
|
export const Manifest = Schema.Struct({
|
||||||
|
auth: Schema.optional(Auth),
|
||||||
|
config: Schema.optional(Schema.NullOr(Config)),
|
||||||
|
remote_config: Schema.optional(RemoteConfig),
|
||||||
|
}).annotate({ identifier: "WellKnown.Manifest" })
|
||||||
|
|
||||||
|
export interface ResolveInput {
|
||||||
|
readonly origin: string
|
||||||
|
readonly variables?: Readonly<Record<string, string>>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Entry {
|
||||||
|
readonly origin: string
|
||||||
|
readonly integrationID: Integration.ID
|
||||||
|
readonly manifest: Manifest
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Interface {
|
||||||
|
readonly entries: () => Effect.Effect<readonly Entry[], Error>
|
||||||
|
readonly snapshot: () => readonly Entry[]
|
||||||
|
readonly add: (origin: string) => Effect.Effect<Entry, Error>
|
||||||
|
readonly remove: (origin: string) => Effect.Effect<void>
|
||||||
|
readonly changes: Stream.Stream<void>
|
||||||
|
readonly resolve: (entry: Entry, variables: Readonly<Record<string, string>>) => Effect.Effect<Config[], Error>
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/WellKnown") {}
|
||||||
|
|
||||||
|
export const inspect = Effect.fn("WellKnown.inspect")(function* (origin: string) {
|
||||||
|
const url = `${origin.replace(/\/+$/, "")}/.well-known/opencode`
|
||||||
|
const http = HttpClient.filterStatusOk(yield* HttpClient.HttpClient)
|
||||||
|
return yield* http.execute(HttpClientRequest.get(url).pipe(HttpClientRequest.acceptJson)).pipe(
|
||||||
|
Effect.flatMap(HttpClientResponse.schemaBodyJson(Manifest)),
|
||||||
|
Effect.mapError((cause) => new Error(`Failed to load wellknown manifest from ${url}`, { cause })),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
export const resolve = Effect.fn("WellKnown.resolve")(function* (input: ResolveInput) {
|
||||||
|
const manifest = yield* inspect(input.origin)
|
||||||
|
return yield* resolveEntry(
|
||||||
|
{ origin: input.origin, integrationID: Integration.ID.make(input.origin.replace(/\/+$/, "")), manifest },
|
||||||
|
input.variables ?? {},
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
const resolveEntry = Effect.fnUntraced(function* (entry: Entry, variables: Readonly<Record<string, string>>) {
|
||||||
|
const configs = entry.manifest.config ? [entry.manifest.config] : []
|
||||||
|
if (!entry.manifest.remote_config) return configs
|
||||||
|
|
||||||
|
const substitute = (value: string) =>
|
||||||
|
value.replace(/\{env:([^}]+)\}/g, (_, name: string) => variables[name] ?? process.env[name] ?? "")
|
||||||
|
const url = substitute(entry.manifest.remote_config.url)
|
||||||
|
const headers = Object.fromEntries(
|
||||||
|
Object.entries(entry.manifest.remote_config.headers ?? {}).map(([key, value]) => [key, substitute(value)]),
|
||||||
|
)
|
||||||
|
const http = HttpClient.filterStatusOk(yield* HttpClient.HttpClient)
|
||||||
|
const remote = yield* http
|
||||||
|
.execute(HttpClientRequest.get(url).pipe(HttpClientRequest.acceptJson, HttpClientRequest.setHeaders(headers)))
|
||||||
|
.pipe(
|
||||||
|
Effect.flatMap(HttpClientResponse.schemaBodyJson(Config)),
|
||||||
|
Effect.mapError((cause) => new Error(`Failed to load wellknown remote config from ${url}`, { cause })),
|
||||||
|
)
|
||||||
|
if (Schema.is(Config)(remote.config)) return [...configs, remote.config]
|
||||||
|
return [...configs, remote]
|
||||||
|
})
|
||||||
|
|
||||||
|
const sourcesKey = "wellknown:sources"
|
||||||
|
const Sources = Schema.Array(Schema.String)
|
||||||
|
|
||||||
|
const layer = Layer.effect(
|
||||||
|
Service,
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const http = yield* HttpClient.HttpClient
|
||||||
|
const kv = yield* KV.Service
|
||||||
|
const cache = yield* Ref.make(new Map<string, Entry>())
|
||||||
|
const changes = yield* PubSub.unbounded<void>()
|
||||||
|
const lock = Semaphore.makeUnsafe(1)
|
||||||
|
|
||||||
|
const load = Effect.fn("WellKnown.load")(function* () {
|
||||||
|
const value = yield* kv.get(sourcesKey)
|
||||||
|
const origins = Schema.is(Sources)(value) ? value : []
|
||||||
|
const current = yield* Ref.get(cache)
|
||||||
|
const entries = yield* Effect.forEach(origins, (origin) => {
|
||||||
|
const cached = current.get(origin)
|
||||||
|
if (cached) return Effect.succeed(cached)
|
||||||
|
return inspect(origin).pipe(
|
||||||
|
Effect.provideService(HttpClient.HttpClient, http),
|
||||||
|
Effect.map((manifest) => ({ origin, integrationID: Integration.ID.make(origin), manifest })),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
yield* Ref.set(cache, new Map(entries.map((entry) => [entry.origin, entry])))
|
||||||
|
return entries
|
||||||
|
})
|
||||||
|
|
||||||
|
return Service.of({
|
||||||
|
entries: load,
|
||||||
|
snapshot: () => Array.from(Ref.getUnsafe(cache).values()),
|
||||||
|
add: Effect.fn("WellKnown.add")(function* (value) {
|
||||||
|
return yield* lock.withPermit(
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const origin = value.replace(/\/+$/, "")
|
||||||
|
const manifest = yield* inspect(origin).pipe(Effect.provideService(HttpClient.HttpClient, http))
|
||||||
|
if (!manifest.auth) return yield* Effect.fail(new Error(`No authentication method found at ${origin}`))
|
||||||
|
const entry = { origin, integrationID: Integration.ID.make(origin), manifest }
|
||||||
|
const sources = yield* kv.get(sourcesKey)
|
||||||
|
const origins = Schema.is(Sources)(sources) ? sources : []
|
||||||
|
yield* kv.set(sourcesKey, Array.from(new Set([...origins, origin])))
|
||||||
|
yield* Ref.update(cache, (current) => new Map(current).set(origin, entry))
|
||||||
|
yield* PubSub.publish(changes, undefined)
|
||||||
|
return entry
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
remove: Effect.fn("WellKnown.remove")(function* (value) {
|
||||||
|
yield* lock.withPermit(
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const origin = value.replace(/\/+$/, "")
|
||||||
|
const sources = yield* kv.get(sourcesKey)
|
||||||
|
const origins = Schema.is(Sources)(sources) ? sources : []
|
||||||
|
yield* kv.set(
|
||||||
|
sourcesKey,
|
||||||
|
origins.filter((item) => item !== origin),
|
||||||
|
)
|
||||||
|
yield* Ref.update(cache, (current) => {
|
||||||
|
const next = new Map(current)
|
||||||
|
next.delete(origin)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
yield* PubSub.publish(changes, undefined)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
changes: Stream.fromPubSub(changes),
|
||||||
|
resolve: Effect.fn("WellKnown.resolveEntry")(function* (entry, variables) {
|
||||||
|
return yield* resolveEntry(entry, variables).pipe(Effect.provideService(HttpClient.HttpClient, http))
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
export const node = makeGlobalNode({ service: Service, layer, deps: [httpClient, KV.node] })
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
export * as WellKnownPlugin from "./plugin"
|
||||||
|
|
||||||
|
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
|
||||||
|
import { Effect, Stream } from "effect"
|
||||||
|
import { WellKnown } from "../wellknown"
|
||||||
|
|
||||||
|
export const Plugin = define({
|
||||||
|
id: "opencode.wellknown",
|
||||||
|
effect: Effect.fn(function* (ctx) {
|
||||||
|
const wellknown = yield* WellKnown.Service
|
||||||
|
yield* wellknown.entries().pipe(Effect.orDie)
|
||||||
|
yield* ctx.integration.transform((draft) => {
|
||||||
|
wellknown.snapshot().forEach((entry) => {
|
||||||
|
if (!entry.manifest.auth) return
|
||||||
|
draft.update(entry.integrationID, (integration) => {
|
||||||
|
integration.name = new URL(entry.origin).hostname
|
||||||
|
})
|
||||||
|
draft.method.update({
|
||||||
|
integrationID: entry.integrationID,
|
||||||
|
method: {
|
||||||
|
id: "login",
|
||||||
|
type: "command",
|
||||||
|
label: "Log in",
|
||||||
|
command: [...entry.manifest.auth.command],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
yield* wellknown.changes.pipe(
|
||||||
|
Stream.runForEach(() => ctx.integration.reload()),
|
||||||
|
Effect.forkScoped({ startImmediately: true }),
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
})
|
||||||
@@ -8,7 +8,9 @@ import { ConfigModel } from "@opencode-ai/core/config/model"
|
|||||||
import { Config as ConfigSchema } from "@opencode-ai/schema/config"
|
import { Config as ConfigSchema } from "@opencode-ai/schema/config"
|
||||||
import { ConfigProvider } from "@opencode-ai/core/config/provider"
|
import { ConfigProvider } from "@opencode-ai/core/config/provider"
|
||||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||||
|
import { makeGlobalNode } from "@opencode-ai/core/effect/app-node"
|
||||||
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||||
|
import { Credential } from "@opencode-ai/core/credential"
|
||||||
import { ConfigMigrateV1 } from "@opencode-ai/core/v1/config/migrate"
|
import { ConfigMigrateV1 } from "@opencode-ai/core/v1/config/migrate"
|
||||||
import { ConfigV1 } from "@opencode-ai/core/v1/config/config"
|
import { ConfigV1 } from "@opencode-ai/core/v1/config/config"
|
||||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||||
@@ -19,6 +21,8 @@ import { Location } from "@opencode-ai/core/location"
|
|||||||
import { Project } from "@opencode-ai/core/project"
|
import { Project } from "@opencode-ai/core/project"
|
||||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||||
|
import { WellKnown } from "@opencode-ai/core/wellknown"
|
||||||
|
import { Integration } from "@opencode-ai/schema/integration"
|
||||||
import { location } from "../fixture/location"
|
import { location } from "../fixture/location"
|
||||||
import { tmpdir } from "../fixture/tmpdir"
|
import { tmpdir } from "../fixture/tmpdir"
|
||||||
import { testEffect } from "../lib/effect"
|
import { testEffect } from "../lib/effect"
|
||||||
@@ -26,12 +30,46 @@ import { testEffect } from "../lib/effect"
|
|||||||
const it = testEffect(Layer.empty)
|
const it = testEffect(Layer.empty)
|
||||||
const selection = Schema.decodeUnknownSync(ConfigModel.Selection)
|
const selection = Schema.decodeUnknownSync(ConfigModel.Selection)
|
||||||
|
|
||||||
|
const emptyCredentialNode = makeGlobalNode({
|
||||||
|
service: Credential.Service,
|
||||||
|
layer: Layer.succeed(
|
||||||
|
Credential.Service,
|
||||||
|
Credential.Service.of({
|
||||||
|
all: () => Effect.succeed([]),
|
||||||
|
list: () => Effect.succeed([]),
|
||||||
|
get: () => Effect.succeed(undefined),
|
||||||
|
create: () => Effect.die("unused Credential.create"),
|
||||||
|
update: () => Effect.die("unused Credential.update"),
|
||||||
|
remove: () => Effect.die("unused Credential.remove"),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
deps: [],
|
||||||
|
})
|
||||||
|
|
||||||
|
const emptyWellknownNode = makeGlobalNode({
|
||||||
|
service: WellKnown.Service,
|
||||||
|
layer: Layer.succeed(
|
||||||
|
WellKnown.Service,
|
||||||
|
WellKnown.Service.of({
|
||||||
|
entries: () => Effect.succeed([]),
|
||||||
|
snapshot: () => [],
|
||||||
|
add: () => Effect.die("unused Wellknown.add"),
|
||||||
|
remove: () => Effect.die("unused Wellknown.remove"),
|
||||||
|
changes: Stream.empty,
|
||||||
|
resolve: () => Effect.die("unused Wellknown.resolve"),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
deps: [],
|
||||||
|
})
|
||||||
|
|
||||||
function testLayer(
|
function testLayer(
|
||||||
directory: string,
|
directory: string,
|
||||||
globalDirectory = path.join(directory, "global"),
|
globalDirectory = path.join(directory, "global"),
|
||||||
projectDirectory = directory,
|
projectDirectory = directory,
|
||||||
vcs?: Project.Vcs,
|
vcs?: Project.Vcs,
|
||||||
watcher?: Layer.Layer<Watcher.Service>,
|
watcher?: Layer.Layer<Watcher.Service>,
|
||||||
|
credentialNode = emptyCredentialNode,
|
||||||
|
wellknownNode = emptyWellknownNode,
|
||||||
) {
|
) {
|
||||||
const locationLayer = Layer.succeed(
|
const locationLayer = Layer.succeed(
|
||||||
Location.Service,
|
Location.Service,
|
||||||
@@ -45,6 +83,8 @@ function testLayer(
|
|||||||
return AppNodeBuilder.build(LayerNode.group([Config.node, EventV2.node]), [
|
return AppNodeBuilder.build(LayerNode.group([Config.node, EventV2.node]), [
|
||||||
[Location.node, locationLayer],
|
[Location.node, locationLayer],
|
||||||
[Global.node, Global.layerWith({ config: globalDirectory, home: path.join(globalDirectory, "home") })],
|
[Global.node, Global.layerWith({ config: globalDirectory, home: path.join(globalDirectory, "home") })],
|
||||||
|
[Credential.node, credentialNode],
|
||||||
|
[WellKnown.node, wellknownNode],
|
||||||
...(watcher ? ([[Watcher.node, watcher]] as const) : []),
|
...(watcher ? ([[Watcher.node, watcher]] as const) : []),
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
@@ -125,6 +165,86 @@ describe("Config", () => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
it.live("loads authenticated wellknown config at highest priority", () =>
|
||||||
|
Effect.acquireUseRelease(
|
||||||
|
Effect.promise(() => tmpdir()),
|
||||||
|
(tmp) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const global = path.join(tmp.path, "global")
|
||||||
|
const project = path.join(tmp.path, "project")
|
||||||
|
yield* Effect.promise(async () => {
|
||||||
|
await fs.mkdir(global, { recursive: true })
|
||||||
|
await fs.mkdir(project, { recursive: true })
|
||||||
|
await fs.writeFile(path.join(global, "opencode.json"), JSON.stringify({ shell: "global" }))
|
||||||
|
await fs.writeFile(path.join(project, "opencode.json"), JSON.stringify({ shell: "project" }))
|
||||||
|
})
|
||||||
|
|
||||||
|
const integrationID = Integration.ID.make("https://example.com")
|
||||||
|
let key = "secret"
|
||||||
|
const credentialNode = makeGlobalNode({
|
||||||
|
service: Credential.Service,
|
||||||
|
layer: Layer.succeed(
|
||||||
|
Credential.Service,
|
||||||
|
Credential.Service.of({
|
||||||
|
all: () => Effect.die("unused Credential.all"),
|
||||||
|
list: () =>
|
||||||
|
Effect.succeed([
|
||||||
|
new Credential.Info({
|
||||||
|
id: Credential.ID.create(),
|
||||||
|
integrationID,
|
||||||
|
label: "default",
|
||||||
|
value: Credential.Key.make({ type: "key", key }),
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
get: () => Effect.die("unused Credential.get"),
|
||||||
|
create: () => Effect.die("unused Credential.create"),
|
||||||
|
update: () => Effect.die("unused Credential.update"),
|
||||||
|
remove: () => Effect.die("unused Credential.remove"),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
deps: [],
|
||||||
|
})
|
||||||
|
const entry: WellKnown.Entry = {
|
||||||
|
origin: "https://example.com",
|
||||||
|
integrationID,
|
||||||
|
manifest: { auth: { command: ["login"], env: "TOKEN" } },
|
||||||
|
}
|
||||||
|
const wellknownNode = makeGlobalNode({
|
||||||
|
service: WellKnown.Service,
|
||||||
|
layer: Layer.succeed(
|
||||||
|
WellKnown.Service,
|
||||||
|
WellKnown.Service.of({
|
||||||
|
entries: () => Effect.succeed([entry]),
|
||||||
|
snapshot: () => [entry],
|
||||||
|
add: () => Effect.die("unused Wellknown.add"),
|
||||||
|
remove: () => Effect.die("unused Wellknown.remove"),
|
||||||
|
changes: Stream.empty,
|
||||||
|
resolve: (_entry, variables) => Effect.succeed([{ shell: variables.TOKEN }]),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
deps: [],
|
||||||
|
})
|
||||||
|
|
||||||
|
return yield* Effect.gen(function* () {
|
||||||
|
const config = yield* Config.Service
|
||||||
|
const events = yield* EventV2.Service
|
||||||
|
expect(Config.latest(yield* config.entries(), "shell")).toBe("secret")
|
||||||
|
const updated = yield* events
|
||||||
|
.subscribe(ConfigSchema.Event.Updated)
|
||||||
|
.pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
|
||||||
|
yield* Effect.yieldNow
|
||||||
|
key = "next"
|
||||||
|
yield* events.publish(Integration.Event.ConnectionUpdated, { integrationID })
|
||||||
|
expect(yield* Fiber.join(updated)).toHaveLength(1)
|
||||||
|
expect(Config.latest(yield* config.entries(), "shell")).toBe("next")
|
||||||
|
}).pipe(
|
||||||
|
Effect.provide(testLayer(project, global, project, undefined, undefined, credentialNode, wellknownNode)),
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
it.effect("detects v1 configuration from any v1-only top-level key", () =>
|
it.effect("detects v1 configuration from any v1-only top-level key", () =>
|
||||||
Effect.sync(() => {
|
Effect.sync(() => {
|
||||||
expect(ConfigMigrateV1.isV1({ snapshot: false })).toBe(true)
|
expect(ConfigMigrateV1.isV1({ snapshot: false })).toBe(true)
|
||||||
@@ -282,9 +402,7 @@ describe("Config", () => {
|
|||||||
const config = yield* Config.Service
|
const config = yield* Config.Service
|
||||||
yield* config.entries()
|
yield* config.entries()
|
||||||
|
|
||||||
expect(targets).toEqual([
|
expect(targets).toEqual([{ type: "directory", path: AbsolutePath.make(path.join(tmp.path, "global")) }])
|
||||||
{ type: "directory", path: AbsolutePath.make(path.join(tmp.path, "global")) },
|
|
||||||
])
|
|
||||||
}).pipe(Effect.provide(testLayer(tmp.path, undefined, undefined, undefined, watcher)))
|
}).pipe(Effect.provide(testLayer(tmp.path, undefined, undefined, undefined, watcher)))
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -181,7 +181,7 @@ describe("Integration", () => {
|
|||||||
command: [
|
command: [
|
||||||
process.execPath,
|
process.execPath,
|
||||||
"-e",
|
"-e",
|
||||||
'console.log("https://example.com/login"); await Bun.sleep(50); console.log("secret")',
|
'console.error("https://example.com/login"); await Bun.sleep(50); console.log("secret")',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
@@ -192,7 +192,7 @@ describe("Integration", () => {
|
|||||||
integrations.command.status({ integrationID, attemptID: attempt.attemptID }),
|
integrations.command.status({ integrationID, attemptID: attempt.attemptID }),
|
||||||
(status) => status.status === "pending" && status.message?.includes("https://example.com/login") === true,
|
(status) => status.status === "pending" && status.message?.includes("https://example.com/login") === true,
|
||||||
)
|
)
|
||||||
expect(pending).toMatchObject({ status: "pending", message: "https://example.com/login" })
|
expect(pending).toMatchObject({ status: "pending", message: "https://example.com/login\n" })
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
yield* eventually(
|
yield* eventually(
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { describe, expect } from "bun:test"
|
||||||
|
import { Effect } from "effect"
|
||||||
|
import { KV } from "@opencode-ai/core/kv"
|
||||||
|
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||||
|
import { testEffect } from "./lib/effect"
|
||||||
|
|
||||||
|
const it = testEffect(LayerNode.compile(KV.node))
|
||||||
|
|
||||||
|
describe("KV", () => {
|
||||||
|
it.effect("stores, replaces, and removes JSON values", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const kv = yield* KV.Service
|
||||||
|
expect(yield* kv.get("wellknown:sources")).toBeUndefined()
|
||||||
|
|
||||||
|
yield* kv.set("wellknown:sources", ["https://example.com"])
|
||||||
|
expect(yield* kv.get("wellknown:sources")).toEqual(["https://example.com"])
|
||||||
|
|
||||||
|
yield* kv.set("wellknown:sources", ["https://example.com", "https://example.org"])
|
||||||
|
expect(yield* kv.get("wellknown:sources")).toEqual(["https://example.com", "https://example.org"])
|
||||||
|
|
||||||
|
yield* kv.remove("wellknown:sources")
|
||||||
|
expect(yield* kv.get("wellknown:sources")).toBeUndefined()
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import { expect } from "bun:test"
|
||||||
|
import { Effect, Fiber, Stream } from "effect"
|
||||||
|
import { FetchHttpClient } from "effect/unstable/http"
|
||||||
|
import { KV } from "@opencode-ai/core/kv"
|
||||||
|
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||||
|
import { WellKnown } from "@opencode-ai/core/wellknown"
|
||||||
|
import { testEffect } from "./lib/effect"
|
||||||
|
|
||||||
|
const it = testEffect(FetchHttpClient.layer)
|
||||||
|
const serviceIt = testEffect(LayerNode.compile(LayerNode.group([WellKnown.node, KV.node])))
|
||||||
|
|
||||||
|
it.live("loads embedded and remote configuration", () =>
|
||||||
|
Effect.acquireUseRelease(
|
||||||
|
Effect.sync(() =>
|
||||||
|
Bun.serve({
|
||||||
|
port: 0,
|
||||||
|
fetch(request) {
|
||||||
|
const url = new URL(request.url)
|
||||||
|
if (url.pathname === "/.well-known/opencode") {
|
||||||
|
return Response.json({
|
||||||
|
auth: { command: ["login"], env: "TOKEN" },
|
||||||
|
config: { model: "embedded/model" },
|
||||||
|
remote_config: {
|
||||||
|
url: `${url.origin}/config/{env:TOKEN}`,
|
||||||
|
headers: { authorization: "Bearer {env:TOKEN}" },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (url.pathname === "/config/secret" && request.headers.get("authorization") === "Bearer secret") {
|
||||||
|
return Response.json({ config: { model: "remote/model" } })
|
||||||
|
}
|
||||||
|
return new Response("Not found", { status: 404 })
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
(server) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const origin = server.url.origin
|
||||||
|
expect(yield* WellKnown.inspect(`${origin}/`)).toEqual({
|
||||||
|
auth: { command: ["login"], env: "TOKEN" },
|
||||||
|
config: { model: "embedded/model" },
|
||||||
|
remote_config: {
|
||||||
|
url: `${origin}/config/{env:TOKEN}`,
|
||||||
|
headers: { authorization: "Bearer {env:TOKEN}" },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
expect(yield* WellKnown.resolve({ origin, variables: { TOKEN: "secret" } })).toEqual([
|
||||||
|
{ model: "embedded/model" },
|
||||||
|
{ model: "remote/model" },
|
||||||
|
])
|
||||||
|
}),
|
||||||
|
(server) => Effect.promise(() => server.stop(true)),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
serviceIt.live("persists sources in one KV value", () =>
|
||||||
|
Effect.acquireUseRelease(
|
||||||
|
Effect.sync(() =>
|
||||||
|
Bun.serve({
|
||||||
|
port: 0,
|
||||||
|
fetch: () => Response.json({ auth: { command: ["login"], env: "TOKEN" } }),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
(server) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const wellknown = yield* WellKnown.Service
|
||||||
|
const kv = yield* KV.Service
|
||||||
|
const changed = yield* wellknown.changes.pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
|
||||||
|
const entry = yield* wellknown.add(`${server.url.origin}/`)
|
||||||
|
|
||||||
|
expect(entry.origin).toBe(server.url.origin)
|
||||||
|
expect(yield* kv.get("wellknown:sources")).toEqual([server.url.origin])
|
||||||
|
expect(yield* wellknown.entries()).toEqual([entry])
|
||||||
|
expect(yield* Fiber.join(changed)).toHaveLength(1)
|
||||||
|
|
||||||
|
yield* wellknown.remove(server.url.origin)
|
||||||
|
expect(yield* kv.get("wellknown:sources")).toEqual([])
|
||||||
|
expect(yield* wellknown.entries()).toEqual([])
|
||||||
|
}),
|
||||||
|
(server) => Effect.promise(() => server.stop(true)),
|
||||||
|
),
|
||||||
|
)
|
||||||
@@ -1528,7 +1528,7 @@ describe("session.message-v2.fromError", () => {
|
|||||||
|
|
||||||
test("classifies ZlibError from fetch as retryable APIError", () => {
|
test("classifies ZlibError from fetch as retryable APIError", () => {
|
||||||
const zlibError = new Error(
|
const zlibError = new Error(
|
||||||
'ZlibError fetching "https://opencode.cloudflare.dev/anthropic/messages". For more information, pass `verbose: true` in the second argument to fetch()',
|
'ZlibError fetching "https://example.com/anthropic/messages". For more information, pass `verbose: true` in the second argument to fetch()',
|
||||||
)
|
)
|
||||||
;(zlibError as any).code = "ZlibError"
|
;(zlibError as any).code = "ZlibError"
|
||||||
;(zlibError as any).errno = 0
|
;(zlibError as any).errno = 0
|
||||||
@@ -1543,7 +1543,7 @@ describe("session.message-v2.fromError", () => {
|
|||||||
|
|
||||||
test("classifies ZlibError as AbortedError when abort context is provided", () => {
|
test("classifies ZlibError as AbortedError when abort context is provided", () => {
|
||||||
const zlibError = new Error(
|
const zlibError = new Error(
|
||||||
'ZlibError fetching "https://opencode.cloudflare.dev/anthropic/messages". For more information, pass `verbose: true` in the second argument to fetch()',
|
'ZlibError fetching "https://example.com/anthropic/messages". For more information, pass `verbose: true` in the second argument to fetch()',
|
||||||
)
|
)
|
||||||
;(zlibError as any).code = "ZlibError"
|
;(zlibError as any).code = "ZlibError"
|
||||||
;(zlibError as any).errno = 0
|
;(zlibError as any).errno = 0
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ export interface IntegrationDraft {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IntegrationDomain extends IntegrationApi<unknown> {
|
export interface IntegrationDomain extends Omit<IntegrationApi<unknown>, "wellknown"> {
|
||||||
readonly transform: Transform<IntegrationDraft>
|
readonly transform: Transform<IntegrationDraft>
|
||||||
readonly reload: () => Effect.Effect<void>
|
readonly reload: () => Effect.Effect<void>
|
||||||
readonly connection: {
|
readonly connection: {
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import type { Transform } from "./registration.js"
|
|||||||
|
|
||||||
export type { IntegrationDraft, IntegrationMethodRegistration }
|
export type { IntegrationDraft, IntegrationMethodRegistration }
|
||||||
|
|
||||||
export interface IntegrationDomain extends IntegrationApi {
|
export interface IntegrationDomain extends Omit<IntegrationApi, "wellknown"> {
|
||||||
readonly transform: Transform<IntegrationDraft>
|
readonly transform: Transform<IntegrationDraft>
|
||||||
readonly reload: () => Promise<void>
|
readonly reload: () => Promise<void>
|
||||||
readonly connection: {
|
readonly connection: {
|
||||||
|
|||||||
@@ -37,6 +37,22 @@ export const IntegrationGroup = HttpApiGroup.make("server.integration")
|
|||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
.add(
|
||||||
|
HttpApiEndpoint.post("integration.wellknown.add", "/api/experimental/integration/wellknown", {
|
||||||
|
query: LocationQuery,
|
||||||
|
payload: Schema.Struct({ url: Schema.String }),
|
||||||
|
success: HttpApiSchema.NoContent,
|
||||||
|
error: InvalidRequestError,
|
||||||
|
})
|
||||||
|
.annotateMerge(locationQueryOpenApi)
|
||||||
|
.annotateMerge(
|
||||||
|
OpenApi.annotations({
|
||||||
|
identifier: "v2.experimental.integration.wellknown.add",
|
||||||
|
summary: "Add wellknown integration",
|
||||||
|
description: "Discover and persist an experimental wellknown integration source.",
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
.add(
|
.add(
|
||||||
HttpApiEndpoint.post("integration.connect.key", "/api/integration/:integrationID/connect/key", {
|
HttpApiEndpoint.post("integration.connect.key", "/api/integration/:integrationID/connect/key", {
|
||||||
params: { integrationID: Integration.ID },
|
params: { integrationID: Integration.ID },
|
||||||
|
|||||||
@@ -280,6 +280,8 @@ import type {
|
|||||||
V2DebugLocationListResponses,
|
V2DebugLocationListResponses,
|
||||||
V2EventSubscribeErrors,
|
V2EventSubscribeErrors,
|
||||||
V2EventSubscribeResponses,
|
V2EventSubscribeResponses,
|
||||||
|
V2ExperimentalIntegrationWellknownAddErrors,
|
||||||
|
V2ExperimentalIntegrationWellknownAddResponses,
|
||||||
V2FormRequestListErrors,
|
V2FormRequestListErrors,
|
||||||
V2FormRequestListResponses,
|
V2FormRequestListResponses,
|
||||||
V2FsFindErrors,
|
V2FsFindErrors,
|
||||||
@@ -7209,6 +7211,64 @@ export class Integration extends HeyApiClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class Wellknown extends HeyApiClient {
|
||||||
|
/**
|
||||||
|
* Add wellknown integration
|
||||||
|
*
|
||||||
|
* Discover and persist an experimental wellknown integration source.
|
||||||
|
*/
|
||||||
|
public add<ThrowOnError extends boolean = false>(
|
||||||
|
parameters?: {
|
||||||
|
location?: {
|
||||||
|
directory?: string | null
|
||||||
|
workspace?: string | null
|
||||||
|
} | null
|
||||||
|
url?: string
|
||||||
|
},
|
||||||
|
options?: Options<never, ThrowOnError>,
|
||||||
|
) {
|
||||||
|
const params = buildClientParams(
|
||||||
|
[parameters],
|
||||||
|
[
|
||||||
|
{
|
||||||
|
args: [
|
||||||
|
{ in: "query", key: "location" },
|
||||||
|
{ in: "body", key: "url" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
return (options?.client ?? this.client).post<
|
||||||
|
V2ExperimentalIntegrationWellknownAddResponses,
|
||||||
|
V2ExperimentalIntegrationWellknownAddErrors,
|
||||||
|
ThrowOnError
|
||||||
|
>({
|
||||||
|
url: "/api/experimental/integration/wellknown",
|
||||||
|
...options,
|
||||||
|
...params,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
...options?.headers,
|
||||||
|
...params.headers,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Integration2 extends HeyApiClient {
|
||||||
|
private _wellknown?: Wellknown
|
||||||
|
get wellknown(): Wellknown {
|
||||||
|
return (this._wellknown ??= new Wellknown({ client: this.client }))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Experimental2 extends HeyApiClient {
|
||||||
|
private _integration?: Integration2
|
||||||
|
get integration(): Integration2 {
|
||||||
|
return (this._integration ??= new Integration2({ client: this.client }))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export class Resource2 extends HeyApiClient {
|
export class Resource2 extends HeyApiClient {
|
||||||
/**
|
/**
|
||||||
* List MCP resources
|
* List MCP resources
|
||||||
@@ -8500,6 +8560,11 @@ export class V2 extends HeyApiClient {
|
|||||||
return (this._integration ??= new Integration({ client: this.client }))
|
return (this._integration ??= new Integration({ client: this.client }))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private _experimental?: Experimental2
|
||||||
|
get experimental(): Experimental2 {
|
||||||
|
return (this._experimental ??= new Experimental2({ client: this.client }))
|
||||||
|
}
|
||||||
|
|
||||||
private _mcp?: Mcp2
|
private _mcp?: Mcp2
|
||||||
get mcp(): Mcp2 {
|
get mcp(): Mcp2 {
|
||||||
return (this._mcp ??= new Mcp2({ client: this.client }))
|
return (this._mcp ??= new Mcp2({ client: this.client }))
|
||||||
|
|||||||
@@ -16859,6 +16859,44 @@ export type V2IntegrationGetResponses = {
|
|||||||
|
|
||||||
export type V2IntegrationGetResponse = V2IntegrationGetResponses[keyof V2IntegrationGetResponses]
|
export type V2IntegrationGetResponse = V2IntegrationGetResponses[keyof V2IntegrationGetResponses]
|
||||||
|
|
||||||
|
export type V2ExperimentalIntegrationWellknownAddData = {
|
||||||
|
body: {
|
||||||
|
url: string
|
||||||
|
}
|
||||||
|
path?: never
|
||||||
|
query?: {
|
||||||
|
location?: {
|
||||||
|
directory?: string | null
|
||||||
|
workspace?: string | null
|
||||||
|
} | null
|
||||||
|
}
|
||||||
|
url: "/api/experimental/integration/wellknown"
|
||||||
|
}
|
||||||
|
|
||||||
|
export type V2ExperimentalIntegrationWellknownAddErrors = {
|
||||||
|
/**
|
||||||
|
* InvalidRequestError
|
||||||
|
*/
|
||||||
|
400: InvalidRequestError1 | InvalidRequestErrorV2
|
||||||
|
/**
|
||||||
|
* UnauthorizedError
|
||||||
|
*/
|
||||||
|
401: UnauthorizedError
|
||||||
|
}
|
||||||
|
|
||||||
|
export type V2ExperimentalIntegrationWellknownAddError =
|
||||||
|
V2ExperimentalIntegrationWellknownAddErrors[keyof V2ExperimentalIntegrationWellknownAddErrors]
|
||||||
|
|
||||||
|
export type V2ExperimentalIntegrationWellknownAddResponses = {
|
||||||
|
/**
|
||||||
|
* <No Content>
|
||||||
|
*/
|
||||||
|
204: void
|
||||||
|
}
|
||||||
|
|
||||||
|
export type V2ExperimentalIntegrationWellknownAddResponse =
|
||||||
|
V2ExperimentalIntegrationWellknownAddResponses[keyof V2ExperimentalIntegrationWellknownAddResponses]
|
||||||
|
|
||||||
export type V2IntegrationConnectKeyData = {
|
export type V2IntegrationConnectKeyData = {
|
||||||
body: {
|
body: {
|
||||||
key: string
|
key: string
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi"
|
|||||||
import { Api } from "../api"
|
import { Api } from "../api"
|
||||||
import { InvalidRequestError } from "@opencode-ai/protocol/errors"
|
import { InvalidRequestError } from "@opencode-ai/protocol/errors"
|
||||||
import { response } from "../location"
|
import { response } from "../location"
|
||||||
|
import { WellKnown } from "@opencode-ai/core/wellknown"
|
||||||
|
|
||||||
const authorize = <A, R>(effect: Effect.Effect<A, Integration.AuthorizationError, R>) =>
|
const authorize = <A, R>(effect: Effect.Effect<A, Integration.AuthorizationError, R>) =>
|
||||||
effect.pipe(
|
effect.pipe(
|
||||||
@@ -33,6 +34,22 @@ export const IntegrationHandler = HttpApiBuilder.group(Api, "server.integration"
|
|||||||
return yield* response(service.get(ctx.params.integrationID))
|
return yield* response(service.get(ctx.params.integrationID))
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
.handle(
|
||||||
|
"integration.wellknown.add",
|
||||||
|
Effect.fn(function* (ctx) {
|
||||||
|
const wellknown = yield* WellKnown.Service
|
||||||
|
const integration = yield* Integration.Service
|
||||||
|
yield* wellknown
|
||||||
|
.add(ctx.payload.url)
|
||||||
|
.pipe(
|
||||||
|
Effect.mapError(
|
||||||
|
(error) => new InvalidRequestError({ message: error.message, kind: "well_known_discovery" }),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
yield* integration.reload()
|
||||||
|
return HttpApiSchema.NoContent.make()
|
||||||
|
}),
|
||||||
|
)
|
||||||
.handle(
|
.handle(
|
||||||
"integration.connect.key",
|
"integration.connect.key",
|
||||||
Effect.fn(function* (ctx) {
|
Effect.fn(function* (ctx) {
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import { SessionRestart } from "@opencode-ai/core/session/execution/restart"
|
|||||||
import { PluginRuntime } from "@opencode-ai/core/plugin/runtime"
|
import { PluginRuntime } from "@opencode-ai/core/plugin/runtime"
|
||||||
import { SdkPlugins } from "@opencode-ai/core/plugin/sdk"
|
import { SdkPlugins } from "@opencode-ai/core/plugin/sdk"
|
||||||
import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
|
import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
|
||||||
|
import { WellKnown } from "@opencode-ai/core/wellknown"
|
||||||
import { HttpRouter, HttpServer } from "effect/unstable/http"
|
import { HttpRouter, HttpServer } from "effect/unstable/http"
|
||||||
import { HttpApiBuilder } from "effect/unstable/httpapi"
|
import { HttpApiBuilder } from "effect/unstable/httpapi"
|
||||||
import { Context, Effect, Layer, Option } from "effect"
|
import { Context, Effect, Layer, Option } from "effect"
|
||||||
@@ -44,6 +45,7 @@ const applicationServices = LayerNode.group([
|
|||||||
PermissionSaved.node,
|
PermissionSaved.node,
|
||||||
PtyTicket.node,
|
PtyTicket.node,
|
||||||
Credential.node,
|
Credential.node,
|
||||||
|
WellKnown.node,
|
||||||
PtyEnvironment.node,
|
PtyEnvironment.node,
|
||||||
LocationServiceMap.node,
|
LocationServiceMap.node,
|
||||||
SessionRestart.node,
|
SessionRestart.node,
|
||||||
@@ -85,7 +87,7 @@ function makeRoutes<AuthError, AuthServices>(
|
|||||||
Layer.flatMap((context) => {
|
Layer.flatMap((context) => {
|
||||||
const services = Layer.succeedContext(context)
|
const services = Layer.succeedContext(context)
|
||||||
const requestServices = Layer.merge(
|
const requestServices = Layer.merge(
|
||||||
Layer.succeedContext(Context.pick(PermissionSaved.Service, Project.Service)(context)),
|
Layer.succeedContext(Context.pick(PermissionSaved.Service, Project.Service, WellKnown.Service)(context)),
|
||||||
ServerInfo.layer(serviceURLs),
|
ServerInfo.layer(serviceURLs),
|
||||||
)
|
)
|
||||||
return HttpApiBuilder.layer(Api, { openapiPath: "/openapi.json" }).pipe(
|
return HttpApiBuilder.layer(Api, { openapiPath: "/openapi.json" }).pipe(
|
||||||
@@ -109,5 +111,4 @@ function simulateEnabled() {
|
|||||||
return !!process.env.OPENCODE_SIMULATE
|
return !!process.env.OPENCODE_SIMULATE
|
||||||
}
|
}
|
||||||
|
|
||||||
export const webHandler = () =>
|
export const webHandler = () => HttpRouter.toWebHandler(createRoutes().pipe(Layer.provide(HttpServer.layerServices)))
|
||||||
HttpRouter.toWebHandler(createRoutes().pipe(Layer.provide(HttpServer.layerServices)))
|
|
||||||
|
|||||||
@@ -185,6 +185,8 @@ function CommandStarting(props: {
|
|||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
const client = useClient()
|
const client = useClient()
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
|
let closed = false
|
||||||
|
let handedOff = false
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
void client.api.integration.command
|
void client.api.integration.command
|
||||||
@@ -193,7 +195,16 @@ function CommandStarting(props: {
|
|||||||
methodID: props.method.id,
|
methodID: props.method.id,
|
||||||
location: location(data),
|
location: location(data),
|
||||||
})
|
})
|
||||||
.then((result) =>
|
.then((result) => {
|
||||||
|
if (closed) {
|
||||||
|
void client.api.integration.command.cancel({
|
||||||
|
integrationID: props.integration.id,
|
||||||
|
attemptID: result.data.attemptID,
|
||||||
|
location: location(data),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
handedOff = true
|
||||||
dialog.replace(() => (
|
dialog.replace(() => (
|
||||||
<CommandPending
|
<CommandPending
|
||||||
integration={props.integration}
|
integration={props.integration}
|
||||||
@@ -201,13 +212,17 @@ function CommandStarting(props: {
|
|||||||
attempt={result.data}
|
attempt={result.data}
|
||||||
onConnected={props.onConnected}
|
onConnected={props.onConnected}
|
||||||
/>
|
/>
|
||||||
)),
|
))
|
||||||
)
|
})
|
||||||
.catch((cause) => {
|
.catch((cause) => {
|
||||||
|
if (closed) return
|
||||||
toast.show({ variant: "error", message: message(cause) })
|
toast.show({ variant: "error", message: message(cause) })
|
||||||
dialog.clear()
|
dialog.clear()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
onCleanup(() => {
|
||||||
|
if (!handedOff) closed = true
|
||||||
|
})
|
||||||
|
|
||||||
return <CommandView title={props.method.label} output="" message="Starting command..." />
|
return <CommandView title={props.method.label} output="" message="Starting command..." />
|
||||||
}
|
}
|
||||||
@@ -275,9 +290,10 @@ function CommandPending(props: {
|
|||||||
function CommandView(props: { title: string; output: string; message: string }) {
|
function CommandView(props: { title: string; output: string; message: string }) {
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
const { theme } = useTheme()
|
const { theme } = useTheme()
|
||||||
|
onMount(() => dialog.setSize("large"))
|
||||||
return (
|
return (
|
||||||
<box paddingLeft={2} paddingRight={2} gap={1} paddingBottom={1}>
|
<box gap={1} paddingBottom={1}>
|
||||||
<box flexDirection="row" justifyContent="space-between">
|
<box flexDirection="row" justifyContent="space-between" paddingLeft={2} paddingRight={2}>
|
||||||
<text attributes={TextAttributes.BOLD} fg={theme.text}>
|
<text attributes={TextAttributes.BOLD} fg={theme.text}>
|
||||||
{props.title}
|
{props.title}
|
||||||
</text>
|
</text>
|
||||||
@@ -285,10 +301,12 @@ function CommandView(props: { title: string; output: string; message: string })
|
|||||||
esc close
|
esc close
|
||||||
</text>
|
</text>
|
||||||
</box>
|
</box>
|
||||||
<box backgroundColor={theme.backgroundElement} paddingLeft={1} paddingRight={1}>
|
<box backgroundColor={theme.backgroundElement} paddingLeft={2} paddingRight={2} paddingTop={1} paddingBottom={1}>
|
||||||
<text fg={theme.text}>{props.output}</text>
|
<text fg={theme.text}>{props.output.trim()}</text>
|
||||||
|
</box>
|
||||||
|
<box paddingLeft={2} paddingRight={2}>
|
||||||
|
<text fg={theme.textMuted}>{props.message}</text>
|
||||||
</box>
|
</box>
|
||||||
<text fg={theme.textMuted}>{props.message}</text>
|
|
||||||
</box>
|
</box>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user