feat(api): add experimental wellknown connections
This commit is contained in:
parent
6bf92aa7de
commit
5fb0470b44
33 changed files with 1070 additions and 138 deletions
|
|
@ -6,6 +6,8 @@ import { type ParseError, parse } from "jsonc-parser"
|
|||
import { Context, Effect, Fiber, Layer, Option, PubSub, Schema, Stream } from "effect"
|
||||
import { Permission } from "@opencode-ai/schema/permission"
|
||||
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 { Watcher } from "./filesystem/watcher"
|
||||
import { FSUtil } from "./fs-util"
|
||||
|
|
@ -28,6 +30,7 @@ import { ConfigVariable } from "./config/variable"
|
|||
import { ConfigWatcher } from "./config/watcher"
|
||||
import { ConfigV1 } from "./v1/config/config"
|
||||
import { ConfigMigrateV1 } from "./v1/config/migrate"
|
||||
import { WellKnown } from "./wellknown"
|
||||
|
||||
export class Info extends Schema.Class<Info>("Config.Info")({
|
||||
$schema: Schema.optional(Schema.String).annotate({
|
||||
|
|
@ -157,29 +160,67 @@ const layer = Layer.effect(
|
|||
const location = yield* Location.Service
|
||||
const watcher = yield* Watcher.Service
|
||||
const events = yield* EventV2.Service
|
||||
const credentials = yield* Credential.Service
|
||||
const wellknown = yield* WellKnown.Service
|
||||
const names = ["opencode.json", "opencode.jsonc"]
|
||||
const decodeOptions = { errors: "all", onExcessProperty: "ignore", propertyOrder: "original" } as const
|
||||
const decodeInfo = Schema.decodeUnknownOption(Info, decodeOptions)
|
||||
const decodeV1Info = Schema.decodeUnknownOption(ConfigV1.Info, decodeOptions)
|
||||
|
||||
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 parseInfo = (text: string) => {
|
||||
const errors: ParseError[] = []
|
||||
const input: unknown = parse(substituted, errors, { allowTrailingComma: true })
|
||||
const input: unknown = parse(text, errors, { allowTrailingComma: true })
|
||||
if (errors.length) return
|
||||
|
||||
const info = Option.getOrUndefined(
|
||||
return Option.getOrUndefined(
|
||||
ConfigMigrateV1.isV1(input)
|
||||
? decodeV1Info(input).pipe(Option.map(ConfigMigrateV1.migrate), Option.flatMap(decodeInfo))
|
||||
: 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
|
||||
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) {
|
||||
return [
|
||||
...(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()],
|
||||
start: location.directory,
|
||||
})
|
||||
.pipe(Effect.orDie)
|
||||
.pipe(Effect.orDie)
|
||||
|
||||
// We load certain files from a few other folders in the ecosystem
|
||||
const claude = [
|
||||
|
|
@ -244,7 +285,14 @@ const layer = Layer.effect(
|
|||
)
|
||||
|
||||
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()
|
||||
|
|
@ -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(
|
||||
Stream.debounce("100 millis"),
|
||||
Stream.runForEach((update) =>
|
||||
Effect.gen(function* () {
|
||||
const next = yield* discover()
|
||||
configs = next
|
||||
yield* reconcile(next)
|
||||
yield* events.publish(ConfigSchema.Event.Updated, {})
|
||||
}).pipe(Effect.catchCause((cause) => Effect.logError("failed to reload config", { path: update.path, cause }))),
|
||||
reload().pipe(
|
||||
Effect.catchCause((cause) => Effect.logError("failed to reload config", { path: update.path, cause })),
|
||||
),
|
||||
),
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
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 }),
|
||||
)
|
||||
|
|
@ -301,5 +371,5 @@ const layer = Layer.effect(
|
|||
export const node = makeLocationNode({
|
||||
service: Service,
|
||||
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
packages/core/src/database/migration.gen.ts
generated
1
packages/core/src/database/migration.gen.ts
generated
|
|
@ -54,5 +54,6 @@ export const migrations = (
|
|||
import("./migration/20260709163752_time_suspended"),
|
||||
import("./migration/20260709190621_session_pending_table"),
|
||||
import("./migration/20260710025429_instruction_sync"),
|
||||
import("./migration/20260716020354_kv"),
|
||||
])
|
||||
).map((module) => module.default) satisfies DatabaseMigration.Migration[]
|
||||
|
|
|
|||
18
packages/core/src/database/migration/20260716020354_kv.ts
Normal file
18
packages/core/src/database/migration/20260716020354_kv.ts
Normal file
|
|
@ -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
|
||||
);
|
||||
`)
|
||||
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(`
|
||||
CREATE TABLE \`permission\` (
|
||||
\`id\` text PRIMARY KEY,
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import {
|
|||
Duration,
|
||||
Effect,
|
||||
Exit,
|
||||
Fiber,
|
||||
Layer,
|
||||
Schedule,
|
||||
Schema,
|
||||
|
|
@ -617,34 +618,40 @@ const layer = Layer.effect(
|
|||
}),
|
||||
)
|
||||
|
||||
yield* processes
|
||||
.runStream(
|
||||
yield* Effect.gen(function* () {
|
||||
const handle = yield* processes.spawn(
|
||||
ChildProcess.make(method.command[0], method.command.slice(1), {
|
||||
extendEnv: true,
|
||||
stdin: "ignore",
|
||||
}),
|
||||
{ okExitCodes: [0] },
|
||||
)
|
||||
.pipe(
|
||||
Stream.tap((line) =>
|
||||
const stdout = yield* AppProcess.collectStream(handle.stdout, undefined).pipe(Effect.forkScoped)
|
||||
yield* handle.stderr.pipe(
|
||||
Stream.decodeText,
|
||||
Stream.tap((chunk) =>
|
||||
SynchronizedRef.update(commandAttempts, (current) => {
|
||||
const attempt = current.get(attemptID)
|
||||
if (!attempt || attempt.status !== "pending") return current
|
||||
const message = attempt.message ? `${attempt.message}\n${line}` : line
|
||||
const message = (attempt.message ?? "") + chunk
|
||||
return new Map(current).set(attemptID, { ...attempt, message })
|
||||
}),
|
||||
),
|
||||
Stream.runCollect,
|
||||
Effect.flatMap((lines) => {
|
||||
const credential = Array.from(lines).at(-1)
|
||||
return credential
|
||||
? Effect.succeed(credential)
|
||||
: Effect.fail(new Error("Authentication command returned no credential"))
|
||||
}),
|
||||
Effect.exit,
|
||||
Effect.flatMap((exit) => settleCommand(attemptID, exit)),
|
||||
Effect.forkIn(attemptScope, { startImmediately: true }),
|
||||
Stream.runDrain,
|
||||
)
|
||||
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 })
|
||||
})
|
||||
|
|
|
|||
47
packages/core/src/kv.ts
Normal file
47
packages/core/src/kv.ts
Normal file
|
|
@ -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] })
|
||||
9
packages/core/src/kv/sql.ts
Normal file
9
packages/core/src/kv/sql.ts
Normal file
|
|
@ -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 { WebFetchTool } from "../tool/webfetch"
|
||||
import { WebSearchTool } from "../tool/websearch"
|
||||
import { WellKnown } from "../wellknown"
|
||||
import { WriteTool } from "../tool/write"
|
||||
import { AgentPlugin } from "./agent"
|
||||
import { CommandPlugin } from "./command"
|
||||
|
|
@ -52,6 +53,7 @@ import { PluginRuntime } from "./runtime"
|
|||
import { SkillPlugin } from "./skill"
|
||||
import { SystemPromptPlugin } from "./system-prompt"
|
||||
import { VariantPlugin } from "./variant"
|
||||
import { WellKnownPlugin } from "../wellknown/plugin"
|
||||
|
||||
const services = Effect.fn("PluginInternal.services")(function* () {
|
||||
const agent = yield* AgentV2.Service
|
||||
|
|
@ -81,6 +83,7 @@ const services = Effect.fn("PluginInternal.services")(function* () {
|
|||
const skill = yield* SkillV2.Service
|
||||
const tools = yield* Tools.Service
|
||||
const websearch = yield* WebSearchTool.ConfigService
|
||||
const wellknown = yield* WellKnown.Service
|
||||
return Context.mergeAll(
|
||||
Context.make(AgentV2.Service, agent),
|
||||
Context.make(Catalog.Service, catalog),
|
||||
|
|
@ -109,6 +112,7 @@ const services = Effect.fn("PluginInternal.services")(function* () {
|
|||
Context.make(SkillV2.Service, skill),
|
||||
Context.make(Tools.Service, tools),
|
||||
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>
|
||||
|
||||
const pre = [
|
||||
WellKnownPlugin.Plugin,
|
||||
AgentPlugin.Plugin,
|
||||
CommandPlugin.Plugin,
|
||||
SkillPlugin.Plugin,
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ import { SkillV2 } from "../skill"
|
|||
import { ReadToolFileSystem } from "../tool/read-filesystem"
|
||||
import { ToolRegistry } from "../tool/registry"
|
||||
import { WebSearchTool } from "../tool/websearch"
|
||||
import { WellKnown } from "../wellknown"
|
||||
import { PluginInternal } from "./internal"
|
||||
import { PluginRuntime } from "./runtime"
|
||||
import { SdkPlugins } from "./sdk"
|
||||
|
|
@ -163,9 +164,7 @@ const load = Effect.fn("PluginSupervisor.load")(function* (operation: Extract<Op
|
|||
if (!entrypoint) return
|
||||
// Bun currently ignores query parameters when caching file:// imports.
|
||||
const source =
|
||||
operation.mtime === undefined
|
||||
? entrypoint
|
||||
: `${operation.target.replaceAll("\\", "/")}?mtime=${operation.mtime}`
|
||||
operation.mtime === undefined ? entrypoint : `${operation.target.replaceAll("\\", "/")}?mtime=${operation.mtime}`
|
||||
yield* Effect.log({ msg: "loading plugin", id: operation.target, entrypoint: source })
|
||||
const mod = yield* Effect.promise(() => import(source))
|
||||
const value = (yield* Schema.decodeUnknownEffect(PluginModule)(mod)).default
|
||||
|
|
@ -296,6 +295,7 @@ export const node = makeLocationNode({
|
|||
SkillV2.node,
|
||||
ToolRegistry.toolsNode,
|
||||
WebSearchTool.configNode,
|
||||
WellKnown.node,
|
||||
],
|
||||
})
|
||||
|
||||
|
|
|
|||
166
packages/core/src/wellknown.ts
Normal file
166
packages/core/src/wellknown.ts
Normal file
|
|
@ -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] })
|
||||
34
packages/core/src/wellknown/plugin.ts
Normal file
34
packages/core/src/wellknown/plugin.ts
Normal file
|
|
@ -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 }),
|
||||
)
|
||||
}),
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue