From 91fe4db27c3ca0f9de8b06e9e508de6b6365956b Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 13 Apr 2026 17:06:03 +0000 Subject: [PATCH 1/6] Update VOUCHED list https://github.com/anomalyco/opencode/issues/22239#issuecomment-4238224546 --- .github/VOUCHED.td | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/VOUCHED.td b/.github/VOUCHED.td index 733748462..117285077 100644 --- a/.github/VOUCHED.td +++ b/.github/VOUCHED.td @@ -25,6 +25,7 @@ kommander -opencodeengineer bot that spams issues r44vc0rp rekram1-node +-ricardo-m-l -robinmordasiewicz simonklee -spider-yamet clawdbot/llm psychosis, spam pinging the team From f7c6943817e91189c44e1dab9271501c02c9e36f Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Mon, 13 Apr 2026 13:11:05 -0400 Subject: [PATCH 2/6] refactor(config): remove async facade exports (#22325) --- packages/opencode/script/seed-e2e.ts | 2 +- packages/opencode/src/cli/cmd/debug/config.ts | 3 +- packages/opencode/src/cli/cmd/mcp.ts | 9 +- packages/opencode/src/cli/cmd/providers.ts | 2 +- packages/opencode/src/cli/cmd/tui/worker.ts | 2 +- packages/opencode/src/cli/network.ts | 3 +- packages/opencode/src/cli/upgrade.ts | 2 +- packages/opencode/src/config/config.ts | 39 ----- packages/opencode/src/config/tui.ts | 3 +- .../opencode/src/server/instance/config.ts | 4 +- .../opencode/src/server/instance/global.ts | 4 +- .../opencode/src/server/instance/provider.ts | 3 +- .../opencode/test/config/agent-color.test.ts | 5 +- packages/opencode/test/config/config.test.ts | 144 ++++++++++-------- packages/opencode/test/config/tui.test.ts | 9 +- .../opencode/test/permission-task.test.ts | 15 +- 16 files changed, 117 insertions(+), 132 deletions(-) diff --git a/packages/opencode/script/seed-e2e.ts b/packages/opencode/script/seed-e2e.ts index fe83b8ec0..7010f2d96 100644 --- a/packages/opencode/script/seed-e2e.ts +++ b/packages/opencode/script/seed-e2e.ts @@ -25,7 +25,7 @@ const seed = async () => { directory: dir, init: () => AppRuntime.runPromise(InstanceBootstrap), fn: async () => { - await Config.waitForDependencies() + await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.waitForDependencies())) await AppRuntime.runPromise( Effect.gen(function* () { const registry = yield* ToolRegistry.Service diff --git a/packages/opencode/src/cli/cmd/debug/config.ts b/packages/opencode/src/cli/cmd/debug/config.ts index af536f93c..59e29c4a3 100644 --- a/packages/opencode/src/cli/cmd/debug/config.ts +++ b/packages/opencode/src/cli/cmd/debug/config.ts @@ -1,5 +1,6 @@ import { EOL } from "os" import { Config } from "../../../config/config" +import { AppRuntime } from "@/effect/app-runtime" import { bootstrap } from "../../bootstrap" import { cmd } from "../cmd" @@ -9,7 +10,7 @@ export const ConfigCommand = cmd({ builder: (yargs) => yargs, async handler() { await bootstrap(process.cwd(), async () => { - const config = await Config.get() + const config = await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.get())) process.stdout.write(JSON.stringify(config, null, 2) + EOL) }) }, diff --git a/packages/opencode/src/cli/cmd/mcp.ts b/packages/opencode/src/cli/cmd/mcp.ts index 41e498102..e33ab6a35 100644 --- a/packages/opencode/src/cli/cmd/mcp.ts +++ b/packages/opencode/src/cli/cmd/mcp.ts @@ -15,6 +15,7 @@ import { Global } from "../../global" import { modify, applyEdits } from "jsonc-parser" import { Filesystem } from "../../util/filesystem" import { Bus } from "../../bus" +import { AppRuntime } from "@/effect/app-runtime" function getAuthStatusIcon(status: MCP.AuthStatus): string { switch (status) { @@ -75,7 +76,7 @@ export const McpListCommand = cmd({ UI.empty() prompts.intro("MCP Servers") - const config = await Config.get() + const config = await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.get())) const mcpServers = config.mcp ?? {} const statuses = await MCP.status() @@ -152,7 +153,7 @@ export const McpAuthCommand = cmd({ UI.empty() prompts.intro("MCP OAuth Authentication") - const config = await Config.get() + const config = await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.get())) const mcpServers = config.mcp ?? {} // Get OAuth-capable servers (remote servers with oauth not explicitly disabled) @@ -289,7 +290,7 @@ export const McpAuthListCommand = cmd({ UI.empty() prompts.intro("MCP OAuth Status") - const config = await Config.get() + const config = await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.get())) const mcpServers = config.mcp ?? {} // Get OAuth-capable servers @@ -595,7 +596,7 @@ export const McpDebugCommand = cmd({ UI.empty() prompts.intro("MCP OAuth Debug") - const config = await Config.get() + const config = await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.get())) const mcpServers = config.mcp ?? {} const serverName = args.name diff --git a/packages/opencode/src/cli/cmd/providers.ts b/packages/opencode/src/cli/cmd/providers.ts index 829e4e1b4..8d6159c9a 100644 --- a/packages/opencode/src/cli/cmd/providers.ts +++ b/packages/opencode/src/cli/cmd/providers.ts @@ -326,7 +326,7 @@ export const ProvidersLoginCommand = cmd({ } await ModelsDev.refresh(true).catch(() => {}) - const config = await Config.get() + const config = await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.get())) const disabled = new Set(config.disabled_providers ?? []) const enabled = config.enabled_providers ? new Set(config.enabled_providers) : undefined diff --git a/packages/opencode/src/cli/cmd/tui/worker.ts b/packages/opencode/src/cli/cmd/tui/worker.ts index 5e9bc62c1..4e1bdabcd 100644 --- a/packages/opencode/src/cli/cmd/tui/worker.ts +++ b/packages/opencode/src/cli/cmd/tui/worker.ts @@ -81,7 +81,7 @@ export const rpc = { }) }, async reload() { - await Config.invalidate(true) + await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.invalidate(true))) }, async shutdown() { Log.Default.info("worker shutting down") diff --git a/packages/opencode/src/cli/network.ts b/packages/opencode/src/cli/network.ts index 84268e267..cea49affa 100644 --- a/packages/opencode/src/cli/network.ts +++ b/packages/opencode/src/cli/network.ts @@ -1,5 +1,6 @@ import type { Argv, InferredOptionTypes } from "yargs" import { Config } from "../config/config" +import { AppRuntime } from "@/effect/app-runtime" const options = { port: { @@ -37,7 +38,7 @@ export function withNetworkOptions(yargs: Argv) { } export async function resolveNetworkOptions(args: NetworkOptions) { - const config = await Config.getGlobal() + const config = await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.getGlobal())) const portExplicitlySet = process.argv.includes("--port") const hostnameExplicitlySet = process.argv.includes("--hostname") const mdnsExplicitlySet = process.argv.includes("--mdns") diff --git a/packages/opencode/src/cli/upgrade.ts b/packages/opencode/src/cli/upgrade.ts index e902dcb92..f67b66245 100644 --- a/packages/opencode/src/cli/upgrade.ts +++ b/packages/opencode/src/cli/upgrade.ts @@ -5,7 +5,7 @@ import { Flag } from "@/flag/flag" import { Installation } from "@/installation" export async function upgrade() { - const config = await Config.getGlobal() + const config = await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.getGlobal())) const method = await AppRuntime.runPromise(Installation.Service.use((svc) => svc.method())) const latest = await AppRuntime.runPromise(Installation.Service.use((svc) => svc.latest(method))).catch(() => {}) if (!latest) return diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index ab3abaf94..6aa79e309 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -33,7 +33,6 @@ import { ConfigPaths } from "./paths" import type { ConsoleState } from "./console-state" import { AppFileSystem } from "@/filesystem" import { InstanceState } from "@/effect/instance-state" -import { makeRuntime } from "@/effect/run-service" import { Context, Duration, Effect, Exit, Fiber, Layer, Option } from "effect" import { Flock } from "@/util/flock" import { isPathPluginSpec, parsePluginSpecifier, resolvePathPluginTarget } from "@/plugin/shared" @@ -1661,42 +1660,4 @@ export namespace Config { Layer.provide(Auth.defaultLayer), Layer.provide(Account.defaultLayer), ) - - const { runPromise } = makeRuntime(Service, defaultLayer) - - export async function get() { - return runPromise((svc) => svc.get()) - } - - export async function getGlobal() { - return runPromise((svc) => svc.getGlobal()) - } - - export async function getConsoleState() { - return runPromise((svc) => svc.getConsoleState()) - } - - export async function installDependencies(dir: string, input?: InstallInput) { - return runPromise((svc) => svc.installDependencies(dir, input)) - } - - export async function update(config: Info) { - return runPromise((svc) => svc.update(config)) - } - - export async function updateGlobal(config: Info) { - return runPromise((svc) => svc.updateGlobal(config)) - } - - export async function invalidate(wait = false) { - return runPromise((svc) => svc.invalidate(wait)) - } - - export async function directories() { - return runPromise((svc) => svc.directories()) - } - - export async function waitForDependencies() { - return runPromise((svc) => svc.waitForDependencies()) - } } diff --git a/packages/opencode/src/config/tui.ts b/packages/opencode/src/config/tui.ts index fa2022482..ed9bb5b8d 100644 --- a/packages/opencode/src/config/tui.ts +++ b/packages/opencode/src/config/tui.ts @@ -10,6 +10,7 @@ import { Flag } from "@/flag/flag" import { Log } from "@/util/log" import { isRecord } from "@/util/record" import { Global } from "@/global" +import { AppRuntime } from "@/effect/app-runtime" export namespace TuiConfig { const log = Log.create({ service: "tui.config" }) @@ -51,7 +52,7 @@ export namespace TuiConfig { } function installDeps(dir: string): Promise { - return Config.installDependencies(dir) + return AppRuntime.runPromise(Config.Service.use((cfg) => cfg.installDependencies(dir))) } async function mergeFile(acc: Acc, file: string) { diff --git a/packages/opencode/src/server/instance/config.ts b/packages/opencode/src/server/instance/config.ts index 2b28ba450..41d5872c9 100644 --- a/packages/opencode/src/server/instance/config.ts +++ b/packages/opencode/src/server/instance/config.ts @@ -32,7 +32,7 @@ export const ConfigRoutes = lazy(() => }, }), async (c) => { - return c.json(await Config.get()) + return c.json(await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.get()))) }, ) .patch( @@ -56,7 +56,7 @@ export const ConfigRoutes = lazy(() => validator("json", Config.Info), async (c) => { const config = c.req.valid("json") - await Config.update(config) + await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.update(config))) return c.json(config) }, ) diff --git a/packages/opencode/src/server/instance/global.ts b/packages/opencode/src/server/instance/global.ts index 6b0a9a164..daecb6bd3 100644 --- a/packages/opencode/src/server/instance/global.ts +++ b/packages/opencode/src/server/instance/global.ts @@ -199,7 +199,7 @@ export const GlobalRoutes = lazy(() => }, }), async (c) => { - return c.json(await Config.getGlobal()) + return c.json(await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.getGlobal()))) }, ) .patch( @@ -223,7 +223,7 @@ export const GlobalRoutes = lazy(() => validator("json", Config.Info), async (c) => { const config = c.req.valid("json") - const next = await Config.updateGlobal(config) + const next = await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.updateGlobal(config))) return c.json(next) }, ) diff --git a/packages/opencode/src/server/instance/provider.ts b/packages/opencode/src/server/instance/provider.ts index ca203d6a4..6988d56e4 100644 --- a/packages/opencode/src/server/instance/provider.ts +++ b/packages/opencode/src/server/instance/provider.ts @@ -44,7 +44,8 @@ export const ProviderRoutes = lazy(() => const result = await AppRuntime.runPromise( Effect.gen(function* () { const svc = yield* Provider.Service - const config = yield* Effect.promise(() => Config.get()) + const cfg = yield* Config.Service + const config = yield* cfg.get() const all = yield* Effect.promise(() => ModelsDev.get()) const disabled = new Set(config.disabled_providers ?? []) const enabled = config.enabled_providers ? new Set(config.enabled_providers) : undefined diff --git a/packages/opencode/test/config/agent-color.test.ts b/packages/opencode/test/config/agent-color.test.ts index b9c7cccc4..0dc2653c2 100644 --- a/packages/opencode/test/config/agent-color.test.ts +++ b/packages/opencode/test/config/agent-color.test.ts @@ -5,6 +5,9 @@ import { Instance } from "../../src/project/instance" import { Config } from "../../src/config/config" import { Agent as AgentSvc } from "../../src/agent/agent" import { Color } from "../../src/util/color" +import { AppRuntime } from "../../src/effect/app-runtime" + +const load = () => AppRuntime.runPromise(Config.Service.use((svc) => svc.get())) test("agent color parsed from project config", async () => { await using tmp = await tmpdir({ @@ -24,7 +27,7 @@ test("agent color parsed from project config", async () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const cfg = await Config.get() + const cfg = await load() expect(cfg.agent?.["build"]?.color).toBe("#FFA500") expect(cfg.agent?.["plan"]?.color).toBe("primary") }, diff --git a/packages/opencode/test/config/config.test.ts b/packages/opencode/test/config/config.test.ts index d6931975c..ce3566a0c 100644 --- a/packages/opencode/test/config/config.test.ts +++ b/packages/opencode/test/config/config.test.ts @@ -33,15 +33,25 @@ const emptyAuth = Layer.mock(Auth.Service)({ all: () => Effect.succeed({}), }) -const it = testEffect( - Config.layer.pipe( - Layer.provide(AppFileSystem.defaultLayer), - Layer.provide(emptyAuth), - Layer.provide(emptyAccount), - Layer.provideMerge(infra), - ), +const layer = Config.layer.pipe( + Layer.provide(AppFileSystem.defaultLayer), + Layer.provide(emptyAuth), + Layer.provide(emptyAccount), + Layer.provideMerge(infra), ) +const it = testEffect(layer) + +const load = () => Effect.runPromise(Config.Service.use((svc) => svc.get()).pipe(Effect.scoped, Effect.provide(layer))) +const save = (config: Config.Info) => + Effect.runPromise(Config.Service.use((svc) => svc.update(config)).pipe(Effect.scoped, Effect.provide(layer))) +const clear = (wait = false) => + Effect.runPromise(Config.Service.use((svc) => svc.invalidate(wait)).pipe(Effect.scoped, Effect.provide(layer))) +const listDirs = () => + Effect.runPromise(Config.Service.use((svc) => svc.directories()).pipe(Effect.scoped, Effect.provide(layer))) +const ready = () => + Effect.runPromise(Config.Service.use((svc) => svc.waitForDependencies()).pipe(Effect.scoped, Effect.provide(layer))) + const installDeps = (dir: string, input?: Config.InstallInput) => Config.Service.use((svc) => svc.installDependencies(dir, input)) @@ -49,12 +59,12 @@ const installDeps = (dir: string, input?: Config.InstallInput) => const managedConfigDir = process.env.OPENCODE_TEST_MANAGED_CONFIG_DIR! beforeEach(async () => { - await Config.invalidate(true) + await clear(true) }) afterEach(async () => { await fs.rm(managedConfigDir, { force: true, recursive: true }).catch(() => {}) - await Config.invalidate(true) + await clear(true) }) async function writeManagedSettings(settings: object, filename = "opencode.json") { @@ -72,7 +82,7 @@ async function check(map: (dir: string) => string) { await using tmp = await tmpdir({ git: true, config: { snapshot: true } }) const prev = Global.Path.config ;(Global.Path as { config: string }).config = globalTmp.path - await Config.invalidate() + await clear() try { await writeConfig(globalTmp.path, { $schema: "https://opencode.ai/config.json", @@ -81,7 +91,7 @@ async function check(map: (dir: string) => string) { await Instance.provide({ directory: map(tmp.path), fn: async () => { - const cfg = await Config.get() + const cfg = await load() expect(cfg.snapshot).toBe(true) expect(Instance.directory).toBe(Filesystem.resolve(tmp.path)) expect(Instance.project.id).not.toBe(ProjectID.global) @@ -90,7 +100,7 @@ async function check(map: (dir: string) => string) { } finally { await Instance.disposeAll() ;(Global.Path as { config: string }).config = prev - await Config.invalidate() + await clear() } } @@ -99,7 +109,7 @@ test("loads config with defaults when no files exist", async () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const config = await Config.get() + const config = await load() expect(config.username).toBeDefined() }, }) @@ -118,7 +128,7 @@ test("loads JSON config file", async () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const config = await Config.get() + const config = await load() expect(config.model).toBe("test/model") expect(config.username).toBe("testuser") }, @@ -156,7 +166,7 @@ test("ignores legacy tui keys in opencode config", async () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const config = await Config.get() + const config = await load() expect(config.model).toBe("test/model") expect((config as Record).theme).toBeUndefined() expect((config as Record).tui).toBeUndefined() @@ -181,7 +191,7 @@ test("loads JSONC config file", async () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const config = await Config.get() + const config = await load() expect(config.model).toBe("test/model") expect(config.username).toBe("testuser") }, @@ -209,7 +219,7 @@ test("jsonc overrides json in the same directory", async () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const config = await Config.get() + const config = await load() expect(config.model).toBe("base") expect(config.username).toBe("base") }, @@ -232,7 +242,7 @@ test("handles environment variable substitution", async () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const config = await Config.get() + const config = await load() expect(config.username).toBe("test-user") }, }) @@ -264,7 +274,7 @@ test("preserves env variables when adding $schema to config", async () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const config = await Config.get() + const config = await load() expect(config.username).toBe("secret_value") // Read the file to verify the env variable was preserved @@ -358,7 +368,7 @@ test("handles file inclusion substitution", async () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const config = await Config.get() + const config = await load() expect(config.username).toBe("test-user") }, }) @@ -377,7 +387,7 @@ test("handles file inclusion with replacement tokens", async () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const config = await Config.get() + const config = await load() expect(config.username).toBe("const out = await Bun.$`echo hi`") }, }) @@ -396,7 +406,7 @@ test("validates config schema and throws on invalid fields", async () => { directory: tmp.path, fn: async () => { // Strict schema should throw an error for invalid fields - await expect(Config.get()).rejects.toThrow() + await expect(load()).rejects.toThrow() }, }) }) @@ -410,7 +420,7 @@ test("throws error for invalid JSON", async () => { await Instance.provide({ directory: tmp.path, fn: async () => { - await expect(Config.get()).rejects.toThrow() + await expect(load()).rejects.toThrow() }, }) }) @@ -433,7 +443,7 @@ test("handles agent configuration", async () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const config = await Config.get() + const config = await load() expect(config.agent?.["test_agent"]).toEqual( expect.objectContaining({ model: "test/model", @@ -464,7 +474,7 @@ test("treats agent variant as model-scoped setting (not provider option)", async await Instance.provide({ directory: tmp.path, fn: async () => { - const config = await Config.get() + const config = await load() const agent = config.agent?.["test_agent"] expect(agent?.variant).toBe("xhigh") @@ -494,7 +504,7 @@ test("handles command configuration", async () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const config = await Config.get() + const config = await load() expect(config.command?.["test_command"]).toEqual({ template: "test template", description: "test command", @@ -519,7 +529,7 @@ test("migrates autoshare to share field", async () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const config = await Config.get() + const config = await load() expect(config.share).toBe("auto") expect(config.autoshare).toBe(true) }, @@ -546,7 +556,7 @@ test("migrates mode field to agent field", async () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const config = await Config.get() + const config = await load() expect(config.agent?.["test_mode"]).toEqual({ model: "test/model", temperature: 0.5, @@ -578,7 +588,7 @@ Test agent prompt`, await Instance.provide({ directory: tmp.path, fn: async () => { - const config = await Config.get() + const config = await load() expect(config.agent?.["test"]).toEqual( expect.objectContaining({ name: "test", @@ -622,7 +632,7 @@ Nested agent prompt`, await Instance.provide({ directory: tmp.path, fn: async () => { - const config = await Config.get() + const config = await load() expect(config.agent?.["helper"]).toMatchObject({ name: "helper", @@ -671,7 +681,7 @@ Nested command template`, await Instance.provide({ directory: tmp.path, fn: async () => { - const config = await Config.get() + const config = await load() expect(config.command?.["hello"]).toEqual({ description: "Test command", @@ -716,7 +726,7 @@ Nested command template`, await Instance.provide({ directory: tmp.path, fn: async () => { - const config = await Config.get() + const config = await load() expect(config.command?.["hello"]).toEqual({ description: "Test command", @@ -737,7 +747,7 @@ test("updates config and writes to file", async () => { directory: tmp.path, fn: async () => { const newConfig = { model: "updated/model" } - await Config.update(newConfig as any) + await save(newConfig as any) const writtenConfig = await Filesystem.readJson(path.join(tmp.path, "config.json")) expect(writtenConfig.model).toBe("updated/model") @@ -750,7 +760,7 @@ test("gets config directories", async () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const dirs = await Config.directories() + const dirs = await listDirs() expect(dirs.length).toBeGreaterThanOrEqual(1) }, }) @@ -780,7 +790,7 @@ test("does not try to install dependencies in read-only OPENCODE_CONFIG_DIR", as await Instance.provide({ directory: tmp.path, fn: async () => { - await Config.get() + await load() }, }) } finally { @@ -814,8 +824,8 @@ test("installs dependencies in writable OPENCODE_CONFIG_DIR", async () => { await Instance.provide({ directory: tmp.path, fn: async () => { - await Config.get() - await Config.waitForDependencies() + await load() + await ready() }, }) @@ -996,7 +1006,7 @@ test("resolves scoped npm plugins in config", async () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const config = await Config.get() + const config = await load() const pluginEntries = config.plugin ?? [] expect(pluginEntries).toContain("@scope/plugin") }, @@ -1034,7 +1044,7 @@ test("merges plugin arrays from global and local configs", async () => { await Instance.provide({ directory: path.join(tmp.path, "project"), fn: async () => { - const config = await Config.get() + const config = await load() const plugins = config.plugin ?? [] // Should contain both global and local plugins @@ -1070,7 +1080,7 @@ Helper subagent prompt`, await Instance.provide({ directory: tmp.path, fn: async () => { - const config = await Config.get() + const config = await load() expect(config.agent?.["helper"]).toMatchObject({ name: "helper", model: "test/model", @@ -1109,7 +1119,7 @@ test("merges instructions arrays from global and local configs", async () => { await Instance.provide({ directory: path.join(tmp.path, "project"), fn: async () => { - const config = await Config.get() + const config = await load() const instructions = config.instructions ?? [] expect(instructions).toContain("global-instructions.md") @@ -1148,7 +1158,7 @@ test("deduplicates duplicate instructions from global and local configs", async await Instance.provide({ directory: path.join(tmp.path, "project"), fn: async () => { - const config = await Config.get() + const config = await load() const instructions = config.instructions ?? [] expect(instructions).toContain("global-only.md") @@ -1193,7 +1203,7 @@ test("deduplicates duplicate plugins from global and local configs", async () => await Instance.provide({ directory: path.join(tmp.path, "project"), fn: async () => { - const config = await Config.get() + const config = await load() const plugins = config.plugin ?? [] // Should contain all unique plugins @@ -1242,7 +1252,7 @@ test("keeps plugin origins aligned with merged plugin list", async () => { await Instance.provide({ directory: path.join(tmp.path, "project"), fn: async () => { - const cfg = await Config.get() + const cfg = await load() const plugins = cfg.plugin ?? [] const origins = cfg.plugin_origins ?? [] const names = plugins.map((item) => Config.pluginSpecifier(item)) @@ -1283,7 +1293,7 @@ test("migrates legacy tools config to permissions - allow", async () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const config = await Config.get() + const config = await load() expect(config.agent?.["test"]?.permission).toEqual({ bash: "allow", read: "allow", @@ -1314,7 +1324,7 @@ test("migrates legacy tools config to permissions - deny", async () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const config = await Config.get() + const config = await load() expect(config.agent?.["test"]?.permission).toEqual({ bash: "deny", webfetch: "deny", @@ -1344,7 +1354,7 @@ test("migrates legacy write tool to edit permission", async () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const config = await Config.get() + const config = await load() expect(config.agent?.["test"]?.permission).toEqual({ edit: "allow", }) @@ -1376,7 +1386,7 @@ test("managed settings override user settings", async () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const config = await Config.get() + const config = await load() expect(config.model).toBe("managed/model") expect(config.share).toBe("disabled") expect(config.username).toBe("testuser") @@ -1404,7 +1414,7 @@ test("managed settings override project settings", async () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const config = await Config.get() + const config = await load() expect(config.autoupdate).toBe(false) expect(config.disabled_providers).toEqual(["openai"]) }, @@ -1424,7 +1434,7 @@ test("missing managed settings file is not an error", async () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const config = await Config.get() + const config = await load() expect(config.model).toBe("user/model") }, }) @@ -1451,7 +1461,7 @@ test("migrates legacy edit tool to edit permission", async () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const config = await Config.get() + const config = await load() expect(config.agent?.["test"]?.permission).toEqual({ edit: "deny", }) @@ -1480,7 +1490,7 @@ test("migrates legacy patch tool to edit permission", async () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const config = await Config.get() + const config = await load() expect(config.agent?.["test"]?.permission).toEqual({ edit: "allow", }) @@ -1509,7 +1519,7 @@ test("migrates legacy multiedit tool to edit permission", async () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const config = await Config.get() + const config = await load() expect(config.agent?.["test"]?.permission).toEqual({ edit: "deny", }) @@ -1541,7 +1551,7 @@ test("migrates mixed legacy tools config", async () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const config = await Config.get() + const config = await load() expect(config.agent?.["test"]?.permission).toEqual({ bash: "allow", edit: "allow", @@ -1576,7 +1586,7 @@ test("merges legacy tools with existing permission config", async () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const config = await Config.get() + const config = await load() expect(config.agent?.["test"]?.permission).toEqual({ glob: "allow", bash: "allow", @@ -1611,7 +1621,7 @@ test("permission config preserves key order", async () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const config = await Config.get() + const config = await load() expect(Object.keys(config.permission!)).toEqual([ "*", "edit", @@ -1671,7 +1681,7 @@ test("project config can override MCP server enabled status", async () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const config = await Config.get() + const config = await load() // jira should be enabled (overridden by project config) expect(config.mcp?.jira).toEqual({ type: "remote", @@ -1727,7 +1737,7 @@ test("MCP config deep merges preserving base config properties", async () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const config = await Config.get() + const config = await load() expect(config.mcp?.myserver).toEqual({ type: "remote", url: "https://myserver.example.com/mcp", @@ -1778,7 +1788,7 @@ test("local .opencode config can override MCP from project config", async () => await Instance.provide({ directory: tmp.path, fn: async () => { - const config = await Config.get() + const config = await load() expect(config.mcp?.docs?.enabled).toBe(true) }, }) @@ -2029,7 +2039,7 @@ describe("deduplicatePluginOrigins", () => { await Instance.provide({ directory: path.join(tmp.path, "project"), fn: async () => { - const config = await Config.get() + const config = await load() const plugins = config.plugin ?? [] expect(plugins.some((p) => Config.pluginSpecifier(p) === "my-plugin@1.0.0")).toBe(true) @@ -2061,7 +2071,7 @@ describe("OPENCODE_DISABLE_PROJECT_CONFIG", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const config = await Config.get() + const config = await load() // Project config should NOT be loaded - model should be default, not "project/model" expect(config.model).not.toBe("project/model") expect(config.username).not.toBe("project-user") @@ -2092,7 +2102,7 @@ describe("OPENCODE_DISABLE_PROJECT_CONFIG", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const directories = await Config.directories() + const directories = await listDirs() // Project .opencode should NOT be in directories list const hasProjectOpencode = directories.some((d) => d.startsWith(tmp.path)) expect(hasProjectOpencode).toBe(false) @@ -2117,7 +2127,7 @@ describe("OPENCODE_DISABLE_PROJECT_CONFIG", () => { directory: tmp.path, fn: async () => { // Should still get default config (from global or defaults) - const config = await Config.get() + const config = await load() expect(config).toBeDefined() expect(config.username).toBeDefined() }, @@ -2160,7 +2170,7 @@ describe("OPENCODE_DISABLE_PROJECT_CONFIG", () => { fn: async () => { // The relative instruction should be skipped without error // We're mainly verifying this doesn't throw and the config loads - const config = await Config.get() + const config = await load() expect(config).toBeDefined() // The instruction should have been skipped (warning logged) // We can't easily test the warning was logged, but we verify @@ -2218,7 +2228,7 @@ describe("OPENCODE_DISABLE_PROJECT_CONFIG", () => { await Instance.provide({ directory: projectTmp.path, fn: async () => { - const config = await Config.get() + const config = await load() // Should load from OPENCODE_CONFIG_DIR, not project expect(config.model).toBe("configdir/model") }, @@ -2253,7 +2263,7 @@ describe("OPENCODE_CONFIG_CONTENT token substitution", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const config = await Config.get() + const config = await load() expect(config.username).toBe("test_api_key_12345") }, }) @@ -2287,7 +2297,7 @@ describe("OPENCODE_CONFIG_CONTENT token substitution", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const config = await Config.get() + const config = await load() expect(config.username).toBe("secret_key_from_file") }, }) diff --git a/packages/opencode/test/config/tui.test.ts b/packages/opencode/test/config/tui.test.ts index b761d59ea..529d88bce 100644 --- a/packages/opencode/test/config/tui.test.ts +++ b/packages/opencode/test/config/tui.test.ts @@ -7,12 +7,15 @@ import { Config } from "../../src/config/config" import { TuiConfig } from "../../src/config/tui" import { Global } from "../../src/global" import { Filesystem } from "../../src/util/filesystem" +import { AppRuntime } from "../../src/effect/app-runtime" const managedConfigDir = process.env.OPENCODE_TEST_MANAGED_CONFIG_DIR! const wintest = process.platform === "win32" ? test : test.skip +const clear = (wait = false) => AppRuntime.runPromise(Config.Service.use((svc) => svc.invalidate(wait))) +const load = () => AppRuntime.runPromise(Config.Service.use((svc) => svc.get())) beforeEach(async () => { - await Config.invalidate(true) + await clear(true) }) afterEach(async () => { @@ -23,7 +26,7 @@ afterEach(async () => { await fs.rm(path.join(Global.Path.config, "tui.json"), { force: true }).catch(() => {}) await fs.rm(path.join(Global.Path.config, "tui.jsonc"), { force: true }).catch(() => {}) await fs.rm(managedConfigDir, { force: true, recursive: true }).catch(() => {}) - await Config.invalidate(true) + await clear(true) }) test("keeps server and tui plugin merge semantics aligned", async () => { @@ -79,7 +82,7 @@ test("keeps server and tui plugin merge semantics aligned", async () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const server = await Config.get() + const server = await load() const tui = await TuiConfig.get() const serverPlugins = (server.plugin ?? []).map((item) => Config.pluginSpecifier(item)) const tuiPlugins = (tui.plugin ?? []).map((item) => Config.pluginSpecifier(item)) diff --git a/packages/opencode/test/permission-task.test.ts b/packages/opencode/test/permission-task.test.ts index 3ca32bf41..d415d23eb 100644 --- a/packages/opencode/test/permission-task.test.ts +++ b/packages/opencode/test/permission-task.test.ts @@ -3,6 +3,9 @@ import { Permission } from "../src/permission" import { Config } from "../src/config/config" import { Instance } from "../src/project/instance" import { tmpdir } from "./fixture/fixture" +import { AppRuntime } from "../src/effect/app-runtime" + +const load = () => AppRuntime.runPromise(Config.Service.use((svc) => svc.get())) afterEach(async () => { await Instance.disposeAll() @@ -158,7 +161,7 @@ describe("permission.task with real config files", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const config = await Config.get() + const config = await load() const ruleset = Permission.fromConfig(config.permission ?? {}) // general and orchestrator-fast should be allowed, code-reviewer denied expect(Permission.evaluate("task", "general", ruleset).action).toBe("allow") @@ -183,7 +186,7 @@ describe("permission.task with real config files", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const config = await Config.get() + const config = await load() const ruleset = Permission.fromConfig(config.permission ?? {}) // general and code-reviewer should be ask, orchestrator-* denied expect(Permission.evaluate("task", "general", ruleset).action).toBe("ask") @@ -208,7 +211,7 @@ describe("permission.task with real config files", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const config = await Config.get() + const config = await load() const ruleset = Permission.fromConfig(config.permission ?? {}) expect(Permission.evaluate("task", "general", ruleset).action).toBe("allow") expect(Permission.evaluate("task", "code-reviewer", ruleset).action).toBe("deny") @@ -235,7 +238,7 @@ describe("permission.task with real config files", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const config = await Config.get() + const config = await load() const ruleset = Permission.fromConfig(config.permission ?? {}) // Verify task permissions @@ -273,7 +276,7 @@ describe("permission.task with real config files", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const config = await Config.get() + const config = await load() const ruleset = Permission.fromConfig(config.permission ?? {}) // Last matching rule wins - "*" deny is last, so all agents are denied @@ -304,7 +307,7 @@ describe("permission.task with real config files", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const config = await Config.get() + const config = await load() const ruleset = Permission.fromConfig(config.permission ?? {}) // Evaluate uses findLast - "general" allow comes after "*" deny From b8801dbd22e561e3ddaf83744726d8d98744f255 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Mon, 13 Apr 2026 13:12:02 -0400 Subject: [PATCH 3/6] refactor(file): remove async facade exports (#22322) --- packages/opencode/src/cli/cmd/debug/file.ts | 26 ++- packages/opencode/src/file/index.ts | 23 --- packages/opencode/src/server/instance/file.ts | 37 +++- packages/opencode/test/file/fsmonitor.test.ts | 12 +- packages/opencode/test/file/index.test.ts | 166 ++++++++++-------- .../opencode/test/file/path-traversal.test.ts | 22 ++- 6 files changed, 161 insertions(+), 125 deletions(-) diff --git a/packages/opencode/src/cli/cmd/debug/file.ts b/packages/opencode/src/cli/cmd/debug/file.ts index 6faaf399a..d5e24a0cf 100644 --- a/packages/opencode/src/cli/cmd/debug/file.ts +++ b/packages/opencode/src/cli/cmd/debug/file.ts @@ -1,4 +1,6 @@ import { EOL } from "os" +import { Effect } from "effect" +import { AppRuntime } from "@/effect/app-runtime" import { File } from "../../../file" import { bootstrap } from "../../bootstrap" import { cmd } from "../cmd" @@ -15,7 +17,11 @@ const FileSearchCommand = cmd({ }), async handler(args) { await bootstrap(process.cwd(), async () => { - const results = await File.search({ query: args.query }) + const results = await AppRuntime.runPromise( + Effect.gen(function* () { + return yield* File.Service.use((svc) => svc.search({ query: args.query })) + }), + ) process.stdout.write(results.join(EOL) + EOL) }) }, @@ -32,7 +38,11 @@ const FileReadCommand = cmd({ }), async handler(args) { await bootstrap(process.cwd(), async () => { - const content = await File.read(args.path) + const content = await AppRuntime.runPromise( + Effect.gen(function* () { + return yield* File.Service.use((svc) => svc.read(args.path)) + }), + ) process.stdout.write(JSON.stringify(content, null, 2) + EOL) }) }, @@ -44,7 +54,11 @@ const FileStatusCommand = cmd({ builder: (yargs) => yargs, async handler() { await bootstrap(process.cwd(), async () => { - const status = await File.status() + const status = await AppRuntime.runPromise( + Effect.gen(function* () { + return yield* File.Service.use((svc) => svc.status()) + }), + ) process.stdout.write(JSON.stringify(status, null, 2) + EOL) }) }, @@ -61,7 +75,11 @@ const FileListCommand = cmd({ }), async handler(args) { await bootstrap(process.cwd(), async () => { - const files = await File.list(args.path) + const files = await AppRuntime.runPromise( + Effect.gen(function* () { + return yield* File.Service.use((svc) => svc.list(args.path)) + }), + ) process.stdout.write(JSON.stringify(files, null, 2) + EOL) }) }, diff --git a/packages/opencode/src/file/index.ts b/packages/opencode/src/file/index.ts index 80ed2b7ef..8dc851634 100644 --- a/packages/opencode/src/file/index.ts +++ b/packages/opencode/src/file/index.ts @@ -1,6 +1,5 @@ import { BusEvent } from "@/bus/bus-event" import { InstanceState } from "@/effect/instance-state" -import { makeRuntime } from "@/effect/run-service" import { AppFileSystem } from "@/filesystem" import { Git } from "@/git" import { Effect, Layer, Context } from "effect" @@ -644,26 +643,4 @@ export namespace File { ) export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer), Layer.provide(Git.defaultLayer)) - - const { runPromise } = makeRuntime(Service, defaultLayer) - - export function init() { - return runPromise((svc) => svc.init()) - } - - export async function status() { - return runPromise((svc) => svc.status()) - } - - export async function read(file: string): Promise { - return runPromise((svc) => svc.read(file)) - } - - export async function list(dir?: string) { - return runPromise((svc) => svc.list(dir)) - } - - export async function search(input: { query: string; limit?: number; dirs?: boolean; type?: "file" | "directory" }) { - return runPromise((svc) => svc.search(input)) - } } diff --git a/packages/opencode/src/server/instance/file.ts b/packages/opencode/src/server/instance/file.ts index a869cf367..db5e22777 100644 --- a/packages/opencode/src/server/instance/file.ts +++ b/packages/opencode/src/server/instance/file.ts @@ -1,5 +1,6 @@ import { Hono } from "hono" import { describeRoute, validator, resolver } from "hono-openapi" +import { Effect } from "effect" import z from "zod" import { AppRuntime } from "../../effect/app-runtime" import { File } from "../../file" @@ -72,12 +73,18 @@ export const FileRoutes = lazy(() => const dirs = c.req.valid("query").dirs const type = c.req.valid("query").type const limit = c.req.valid("query").limit - const results = await File.search({ - query, - limit: limit ?? 10, - dirs: dirs !== "false", - type, - }) + const results = await AppRuntime.runPromise( + Effect.gen(function* () { + return yield* File.Service.use((svc) => + svc.search({ + query, + limit: limit ?? 10, + dirs: dirs !== "false", + type, + }), + ) + }), + ) return c.json(results) }, ) @@ -133,7 +140,11 @@ export const FileRoutes = lazy(() => ), async (c) => { const path = c.req.valid("query").path - const content = await File.list(path) + const content = await AppRuntime.runPromise( + Effect.gen(function* () { + return yield* File.Service.use((svc) => svc.list(path)) + }), + ) return c.json(content) }, ) @@ -162,7 +173,11 @@ export const FileRoutes = lazy(() => ), async (c) => { const path = c.req.valid("query").path - const content = await File.read(path) + const content = await AppRuntime.runPromise( + Effect.gen(function* () { + return yield* File.Service.use((svc) => svc.read(path)) + }), + ) return c.json(content) }, ) @@ -184,7 +199,11 @@ export const FileRoutes = lazy(() => }, }), async (c) => { - const content = await File.status() + const content = await AppRuntime.runPromise( + Effect.gen(function* () { + return yield* File.Service.use((svc) => svc.status()) + }), + ) return c.json(content) }, ), diff --git a/packages/opencode/test/file/fsmonitor.test.ts b/packages/opencode/test/file/fsmonitor.test.ts index 8cdde014d..699e713c2 100644 --- a/packages/opencode/test/file/fsmonitor.test.ts +++ b/packages/opencode/test/file/fsmonitor.test.ts @@ -1,10 +1,16 @@ import { $ } from "bun" import { describe, expect, test } from "bun:test" +import { Effect } from "effect" import fs from "fs/promises" import path from "path" import { File } from "../../src/file" import { Instance } from "../../src/project/instance" -import { tmpdir } from "../fixture/fixture" +import { provideInstance, tmpdir } from "../fixture/fixture" + +const run = (eff: Effect.Effect) => + Effect.runPromise(provideInstance(Instance.directory)(eff.pipe(Effect.provide(File.defaultLayer)))) +const status = () => run(File.Service.use((svc) => svc.status())) +const read = (file: string) => run(File.Service.use((svc) => svc.read(file))) const wintest = process.platform === "win32" ? test : test.skip @@ -27,7 +33,7 @@ describe("file fsmonitor", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - await File.status() + await status() }, }) @@ -52,7 +58,7 @@ describe("file fsmonitor", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - await File.read("tracked.txt") + await read("tracked.txt") }, }) diff --git a/packages/opencode/test/file/index.test.ts b/packages/opencode/test/file/index.test.ts index cd4f023cc..d8203ac12 100644 --- a/packages/opencode/test/file/index.test.ts +++ b/packages/opencode/test/file/index.test.ts @@ -1,18 +1,28 @@ import { afterEach, describe, test, expect } from "bun:test" import { $ } from "bun" +import { Effect } from "effect" import path from "path" import fs from "fs/promises" import { File } from "../../src/file" import { Instance } from "../../src/project/instance" import { Filesystem } from "../../src/util/filesystem" -import { tmpdir } from "../fixture/fixture" +import { provideInstance, tmpdir } from "../fixture/fixture" afterEach(async () => { await Instance.disposeAll() }) +const init = () => run(File.Service.use((svc) => svc.init())) +const run = (eff: Effect.Effect) => + Effect.runPromise(provideInstance(Instance.directory)(eff.pipe(Effect.provide(File.defaultLayer)))) +const status = () => run(File.Service.use((svc) => svc.status())) +const read = (file: string) => run(File.Service.use((svc) => svc.read(file))) +const list = (dir?: string) => run(File.Service.use((svc) => svc.list(dir))) +const search = (input: { query: string; limit?: number; dirs?: boolean; type?: "file" | "directory" }) => + run(File.Service.use((svc) => svc.search(input))) + describe("file/index Filesystem patterns", () => { - describe("File.read() - text content", () => { + describe("read() - text content", () => { test("reads text file via Filesystem.readText()", async () => { await using tmp = await tmpdir() const filepath = path.join(tmp.path, "test.txt") @@ -21,7 +31,7 @@ describe("file/index Filesystem patterns", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const result = await File.read("test.txt") + const result = await read("test.txt") expect(result.type).toBe("text") expect(result.content).toBe("Hello World") }, @@ -35,7 +45,7 @@ describe("file/index Filesystem patterns", () => { directory: tmp.path, fn: async () => { // Non-existent file should return empty content - const result = await File.read("nonexistent.txt") + const result = await read("nonexistent.txt") expect(result.type).toBe("text") expect(result.content).toBe("") }, @@ -50,7 +60,7 @@ describe("file/index Filesystem patterns", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const result = await File.read("test.txt") + const result = await read("test.txt") expect(result.content).toBe("content with spaces") }, }) @@ -64,7 +74,7 @@ describe("file/index Filesystem patterns", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const result = await File.read("empty.txt") + const result = await read("empty.txt") expect(result.type).toBe("text") expect(result.content).toBe("") }, @@ -79,14 +89,14 @@ describe("file/index Filesystem patterns", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const result = await File.read("multiline.txt") + const result = await read("multiline.txt") expect(result.content).toBe("line1\nline2\nline3") }, }) }) }) - describe("File.read() - binary content", () => { + describe("read() - binary content", () => { test("reads binary file via Filesystem.readArrayBuffer()", async () => { await using tmp = await tmpdir() const filepath = path.join(tmp.path, "image.png") @@ -96,7 +106,7 @@ describe("file/index Filesystem patterns", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const result = await File.read("image.png") + const result = await read("image.png") expect(result.type).toBe("text") // Images return as text with base64 encoding expect(result.encoding).toBe("base64") expect(result.mimeType).toBe("image/png") @@ -113,7 +123,7 @@ describe("file/index Filesystem patterns", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const result = await File.read("binary.so") + const result = await read("binary.so") expect(result.type).toBe("binary") expect(result.content).toBe("") }, @@ -121,7 +131,7 @@ describe("file/index Filesystem patterns", () => { }) }) - describe("File.read() - Filesystem.mimeType()", () => { + describe("read() - Filesystem.mimeType()", () => { test("detects MIME type via Filesystem.mimeType()", async () => { await using tmp = await tmpdir() const filepath = path.join(tmp.path, "test.json") @@ -132,7 +142,7 @@ describe("file/index Filesystem patterns", () => { fn: async () => { expect(Filesystem.mimeType(filepath)).toContain("application/json") - const result = await File.read("test.json") + const result = await read("test.json") expect(result.type).toBe("text") }, }) @@ -161,7 +171,7 @@ describe("file/index Filesystem patterns", () => { }) }) - describe("File.list() - Filesystem.exists() and readText()", () => { + describe("list() - Filesystem.exists() and readText()", () => { test("reads .gitignore via Filesystem.exists() and readText()", async () => { await using tmp = await tmpdir({ git: true }) @@ -171,7 +181,7 @@ describe("file/index Filesystem patterns", () => { const gitignorePath = path.join(tmp.path, ".gitignore") await fs.writeFile(gitignorePath, "node_modules\ndist\n", "utf-8") - // This is used internally in File.list() + // This is used internally in list() expect(await Filesystem.exists(gitignorePath)).toBe(true) const content = await Filesystem.readText(gitignorePath) @@ -204,8 +214,8 @@ describe("file/index Filesystem patterns", () => { const gitignorePath = path.join(tmp.path, ".gitignore") expect(await Filesystem.exists(gitignorePath)).toBe(false) - // File.list() should still work - const nodes = await File.list() + // list() should still work + const nodes = await list() expect(Array.isArray(nodes)).toBe(true) }, }) @@ -244,8 +254,8 @@ describe("file/index Filesystem patterns", () => { // Filesystem.readText() on non-existent file throws await expect(Filesystem.readText(nonExistentPath)).rejects.toThrow() - // But File.read() handles this gracefully - const result = await File.read("does-not-exist.txt") + // But read() handles this gracefully + const result = await read("does-not-exist.txt") expect(result.content).toBe("") }, }) @@ -272,8 +282,8 @@ describe("file/index Filesystem patterns", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - // File.read() handles missing images gracefully - const result = await File.read("broken.png") + // read() handles missing images gracefully + const result = await read("broken.png") expect(result.type).toBe("text") expect(result.content).toBe("") }, @@ -290,7 +300,7 @@ describe("file/index Filesystem patterns", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const result = await File.read("test.ts") + const result = await read("test.ts") expect(result.type).toBe("text") expect(result.content).toBe("export const value = 1") }, @@ -305,7 +315,7 @@ describe("file/index Filesystem patterns", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const result = await File.read("test.mts") + const result = await read("test.mts") expect(result.type).toBe("text") expect(result.content).toBe("export const value = 1") }, @@ -320,7 +330,7 @@ describe("file/index Filesystem patterns", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const result = await File.read("test.sh") + const result = await read("test.sh") expect(result.type).toBe("text") expect(result.content).toBe("#!/usr/bin/env bash\necho hello") }, @@ -335,7 +345,7 @@ describe("file/index Filesystem patterns", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const result = await File.read("Dockerfile") + const result = await read("Dockerfile") expect(result.type).toBe("text") expect(result.content).toBe("FROM alpine:3.20") }, @@ -350,7 +360,7 @@ describe("file/index Filesystem patterns", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const result = await File.read("test.txt") + const result = await read("test.txt") expect(result.encoding).toBeUndefined() expect(result.type).toBe("text") }, @@ -365,7 +375,7 @@ describe("file/index Filesystem patterns", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const result = await File.read("test.jpg") + const result = await read("test.jpg") expect(result.encoding).toBe("base64") expect(result.mimeType).toBe("image/jpeg") }, @@ -380,7 +390,7 @@ describe("file/index Filesystem patterns", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - await expect(File.read("../outside.txt")).rejects.toThrow("Access denied") + await expect(read("../outside.txt")).rejects.toThrow("Access denied") }, }) }) @@ -391,13 +401,13 @@ describe("file/index Filesystem patterns", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - await expect(File.read("../outside.txt")).rejects.toThrow("Access denied") + await expect(read("../outside.txt")).rejects.toThrow("Access denied") }, }) }) }) - describe("File.status()", () => { + describe("status()", () => { test("detects modified file", async () => { await using tmp = await tmpdir({ git: true }) const filepath = path.join(tmp.path, "file.txt") @@ -409,7 +419,7 @@ describe("file/index Filesystem patterns", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const result = await File.status() + const result = await status() const entry = result.find((f) => f.path === "file.txt") expect(entry).toBeDefined() expect(entry!.status).toBe("modified") @@ -426,7 +436,7 @@ describe("file/index Filesystem patterns", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const result = await File.status() + const result = await status() const entry = result.find((f) => f.path === "new.txt") expect(entry).toBeDefined() expect(entry!.status).toBe("added") @@ -447,7 +457,7 @@ describe("file/index Filesystem patterns", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const result = await File.status() + const result = await status() // Deleted files appear in both numstat (as "modified") and diff-filter=D (as "deleted") const entries = result.filter((f) => f.path === "gone.txt") expect(entries.some((e) => e.status === "deleted")).toBe(true) @@ -470,7 +480,7 @@ describe("file/index Filesystem patterns", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const result = await File.status() + const result = await status() expect(result.some((f) => f.path === "keep.txt" && f.status === "modified")).toBe(true) expect(result.some((f) => f.path === "remove.txt" && f.status === "deleted")).toBe(true) expect(result.some((f) => f.path === "brand-new.txt" && f.status === "added")).toBe(true) @@ -484,7 +494,7 @@ describe("file/index Filesystem patterns", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const result = await File.status() + const result = await status() expect(result).toEqual([]) }, }) @@ -496,7 +506,7 @@ describe("file/index Filesystem patterns", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const result = await File.status() + const result = await status() expect(result).toEqual([]) }, }) @@ -519,7 +529,7 @@ describe("file/index Filesystem patterns", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const result = await File.status() + const result = await status() const entry = result.find((f) => f.path === "data.bin") expect(entry).toBeDefined() expect(entry!.status).toBe("modified") @@ -530,7 +540,7 @@ describe("file/index Filesystem patterns", () => { }) }) - describe("File.list()", () => { + describe("list()", () => { test("returns files and directories with correct shape", async () => { await using tmp = await tmpdir({ git: true }) await fs.mkdir(path.join(tmp.path, "subdir")) @@ -540,7 +550,7 @@ describe("file/index Filesystem patterns", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const nodes = await File.list() + const nodes = await list() expect(nodes.length).toBeGreaterThanOrEqual(2) for (const node of nodes) { expect(node).toHaveProperty("name") @@ -564,7 +574,7 @@ describe("file/index Filesystem patterns", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const nodes = await File.list() + const nodes = await list() const dirs = nodes.filter((n) => n.type === "directory") const files = nodes.filter((n) => n.type === "file") // Dirs come first @@ -589,7 +599,7 @@ describe("file/index Filesystem patterns", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const nodes = await File.list() + const nodes = await list() const names = nodes.map((n) => n.name) expect(names).not.toContain(".git") expect(names).not.toContain(".DS_Store") @@ -608,7 +618,7 @@ describe("file/index Filesystem patterns", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const nodes = await File.list() + const nodes = await list() const logNode = nodes.find((n) => n.name === "app.log") const tsNode = nodes.find((n) => n.name === "main.ts") const buildNode = nodes.find((n) => n.name === "build") @@ -628,7 +638,7 @@ describe("file/index Filesystem patterns", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const nodes = await File.list("sub") + const nodes = await list("sub") expect(nodes.length).toBe(2) expect(nodes.map((n) => n.name).sort()).toEqual(["a.txt", "b.txt"]) // Paths should be relative to project root (normalize for Windows) @@ -643,7 +653,7 @@ describe("file/index Filesystem patterns", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - await expect(File.list("../outside")).rejects.toThrow("Access denied") + await expect(list("../outside")).rejects.toThrow("Access denied") }, }) }) @@ -655,7 +665,7 @@ describe("file/index Filesystem patterns", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const nodes = await File.list() + const nodes = await list() expect(nodes.length).toBeGreaterThanOrEqual(1) // Without git, ignored should be false for all for (const node of nodes) { @@ -666,7 +676,7 @@ describe("file/index Filesystem patterns", () => { }) }) - describe("File.search()", () => { + describe("search()", () => { async function setupSearchableRepo() { const tmp = await tmpdir({ git: true }) await fs.writeFile(path.join(tmp.path, "index.ts"), "code", "utf-8") @@ -685,9 +695,9 @@ describe("file/index Filesystem patterns", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - await File.init() + await init() - const result = await File.search({ query: "", type: "file" }) + const result = await search({ query: "", type: "file" }) expect(result.length).toBeGreaterThan(0) }, }) @@ -699,7 +709,7 @@ describe("file/index Filesystem patterns", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const result = await File.search({ query: "main", type: "file" }) + const result = await search({ query: "main", type: "file" }) expect(result.some((f) => f.includes("main"))).toBe(true) }, }) @@ -711,9 +721,9 @@ describe("file/index Filesystem patterns", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - await File.init() + await init() - const result = await File.search({ query: "", type: "directory" }) + const result = await search({ query: "", type: "directory" }) expect(result.length).toBeGreaterThan(0) // Find first hidden dir index const firstHidden = result.findIndex((d) => d.split("/").some((p) => p.startsWith(".") && p.length > 1)) @@ -731,9 +741,9 @@ describe("file/index Filesystem patterns", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - await File.init() + await init() - const result = await File.search({ query: "main", type: "file" }) + const result = await search({ query: "main", type: "file" }) expect(result.some((f) => f.includes("main"))).toBe(true) }, }) @@ -745,9 +755,9 @@ describe("file/index Filesystem patterns", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - await File.init() + await init() - const result = await File.search({ query: "", type: "file" }) + const result = await search({ query: "", type: "file" }) // Files don't end with / for (const f of result) { expect(f.endsWith("/")).toBe(false) @@ -762,9 +772,9 @@ describe("file/index Filesystem patterns", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - await File.init() + await init() - const result = await File.search({ query: "", type: "directory" }) + const result = await search({ query: "", type: "directory" }) // Directories end with / for (const d of result) { expect(d.endsWith("/")).toBe(true) @@ -779,9 +789,9 @@ describe("file/index Filesystem patterns", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - await File.init() + await init() - const result = await File.search({ query: "", type: "file", limit: 2 }) + const result = await search({ query: "", type: "file", limit: 2 }) expect(result.length).toBeLessThanOrEqual(2) }, }) @@ -793,9 +803,9 @@ describe("file/index Filesystem patterns", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - await File.init() + await init() - const result = await File.search({ query: ".hidden", type: "directory" }) + const result = await search({ query: ".hidden", type: "directory" }) expect(result.length).toBeGreaterThan(0) expect(result[0]).toContain(".hidden") }, @@ -808,19 +818,19 @@ describe("file/index Filesystem patterns", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - await File.init() - expect(await File.search({ query: "fresh", type: "file" })).toEqual([]) + await init() + expect(await search({ query: "fresh", type: "file" })).toEqual([]) await fs.writeFile(path.join(tmp.path, "fresh.ts"), "fresh", "utf-8") - const result = await File.search({ query: "fresh", type: "file" }) + const result = await search({ query: "fresh", type: "file" }) expect(result).toContain("fresh.ts") }, }) }) }) - describe("File.read() - diff/patch", () => { + describe("read() - diff/patch", () => { test("returns diff and patch for modified tracked file", async () => { await using tmp = await tmpdir({ git: true }) const filepath = path.join(tmp.path, "file.txt") @@ -832,7 +842,7 @@ describe("file/index Filesystem patterns", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const result = await File.read("file.txt") + const result = await read("file.txt") expect(result.type).toBe("text") expect(result.content).toBe("modified content") expect(result.diff).toBeDefined() @@ -856,7 +866,7 @@ describe("file/index Filesystem patterns", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const result = await File.read("staged.txt") + const result = await read("staged.txt") expect(result.diff).toBeDefined() expect(result.patch).toBeDefined() }, @@ -873,7 +883,7 @@ describe("file/index Filesystem patterns", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const result = await File.read("clean.txt") + const result = await read("clean.txt") expect(result.type).toBe("text") expect(result.content).toBe("unchanged") expect(result.diff).toBeUndefined() @@ -893,10 +903,10 @@ describe("file/index Filesystem patterns", () => { await Instance.provide({ directory: one.path, fn: async () => { - await File.init() - const results = await File.search({ query: "a.ts", type: "file" }) + await init() + const results = await search({ query: "a.ts", type: "file" }) expect(results).toContain("a.ts") - const results2 = await File.search({ query: "b.ts", type: "file" }) + const results2 = await search({ query: "b.ts", type: "file" }) expect(results2).not.toContain("b.ts") }, }) @@ -904,10 +914,10 @@ describe("file/index Filesystem patterns", () => { await Instance.provide({ directory: two.path, fn: async () => { - await File.init() - const results = await File.search({ query: "b.ts", type: "file" }) + await init() + const results = await search({ query: "b.ts", type: "file" }) expect(results).toContain("b.ts") - const results2 = await File.search({ query: "a.ts", type: "file" }) + const results2 = await search({ query: "a.ts", type: "file" }) expect(results2).not.toContain("a.ts") }, }) @@ -920,8 +930,8 @@ describe("file/index Filesystem patterns", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - await File.init() - const results = await File.search({ query: "before", type: "file" }) + await init() + const results = await search({ query: "before", type: "file" }) expect(results).toContain("before.ts") }, }) @@ -934,10 +944,10 @@ describe("file/index Filesystem patterns", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - await File.init() - const results = await File.search({ query: "after", type: "file" }) + await init() + const results = await search({ query: "after", type: "file" }) expect(results).toContain("after.ts") - const stale = await File.search({ query: "before", type: "file" }) + const stale = await search({ query: "before", type: "file" }) expect(stale).not.toContain("before.ts") }, }) diff --git a/packages/opencode/test/file/path-traversal.test.ts b/packages/opencode/test/file/path-traversal.test.ts index 44ae8f154..1f2e45a6a 100644 --- a/packages/opencode/test/file/path-traversal.test.ts +++ b/packages/opencode/test/file/path-traversal.test.ts @@ -1,10 +1,16 @@ import { test, expect, describe } from "bun:test" +import { Effect } from "effect" import path from "path" import fs from "fs/promises" import { Filesystem } from "../../src/util/filesystem" import { File } from "../../src/file" import { Instance } from "../../src/project/instance" -import { tmpdir } from "../fixture/fixture" +import { provideInstance, tmpdir } from "../fixture/fixture" + +const run = (eff: Effect.Effect) => + Effect.runPromise(provideInstance(Instance.directory)(eff.pipe(Effect.provide(File.defaultLayer)))) +const read = (file: string) => run(File.Service.use((svc) => svc.read(file))) +const list = (dir?: string) => run(File.Service.use((svc) => svc.list(dir))) describe("Filesystem.contains", () => { test("allows paths within project", () => { @@ -32,10 +38,10 @@ describe("Filesystem.contains", () => { }) /* - * Integration tests for File.read() and File.list() path traversal protection. + * Integration tests for read() and list() path traversal protection. * * These tests verify the HTTP API code path is protected. The HTTP endpoints - * in server.ts (GET /file/content, GET /file) call File.read()/File.list() + * in server.ts (GET /file/content, GET /file) call read()/list() * directly - they do NOT go through ReadTool or the agent permission layer. * * This is a SEPARATE code path from ReadTool, which has its own checks. @@ -51,7 +57,7 @@ describe("File.read path traversal protection", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - await expect(File.read("../../../etc/passwd")).rejects.toThrow("Access denied: path escapes project directory") + await expect(read("../../../etc/passwd")).rejects.toThrow("Access denied: path escapes project directory") }, }) }) @@ -62,7 +68,7 @@ describe("File.read path traversal protection", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - await expect(File.read("src/nested/../../../../../../../etc/passwd")).rejects.toThrow( + await expect(read("src/nested/../../../../../../../etc/passwd")).rejects.toThrow( "Access denied: path escapes project directory", ) }, @@ -79,7 +85,7 @@ describe("File.read path traversal protection", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const result = await File.read("valid.txt") + const result = await read("valid.txt") expect(result.content).toBe("valid content") }, }) @@ -93,7 +99,7 @@ describe("File.list path traversal protection", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - await expect(File.list("../../../etc")).rejects.toThrow("Access denied: path escapes project directory") + await expect(list("../../../etc")).rejects.toThrow("Access denied: path escapes project directory") }, }) }) @@ -108,7 +114,7 @@ describe("File.list path traversal protection", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const result = await File.list("subdir") + const result = await list("subdir") expect(Array.isArray(result)).toBe(true) }, }) From bf50d1c028e973ccc0beffdf568fca417b62f020 Mon Sep 17 00:00:00 2001 From: James Long Date: Mon, 13 Apr 2026 13:33:13 -0400 Subject: [PATCH 4/6] feat(core): expose workspace adaptors to plugins (#21927) --- .../migration.sql | 16 + .../snapshot.json | 1337 +++++++++++++++++ .../tui/component/dialog-workspace-create.tsx | 48 +- .../src/control-plane/adaptors/index.ts | 54 +- .../src/control-plane/adaptors/worktree.ts | 20 +- packages/opencode/src/control-plane/types.ts | 14 +- .../src/control-plane/workspace.sql.ts | 2 +- .../opencode/src/control-plane/workspace.ts | 9 +- packages/opencode/src/plugin/index.ts | 15 +- .../src/server/instance/middleware.ts | 2 +- .../opencode/src/server/instance/workspace.ts | 28 + .../test/control-plane/adaptors.test.ts | 71 + .../test/plugin/github-copilot-models.test.ts | 3 + .../test/plugin/workspace-adaptor.test.ts | 99 ++ packages/plugin/src/example-workspace.ts | 34 + packages/plugin/src/index.ts | 33 + packages/plugin/tsconfig.json | 1 + 17 files changed, 1744 insertions(+), 42 deletions(-) create mode 100644 packages/opencode/migration/20260410174513_workspace-name/migration.sql create mode 100644 packages/opencode/migration/20260410174513_workspace-name/snapshot.json create mode 100644 packages/opencode/test/control-plane/adaptors.test.ts create mode 100644 packages/opencode/test/plugin/workspace-adaptor.test.ts create mode 100644 packages/plugin/src/example-workspace.ts diff --git a/packages/opencode/migration/20260410174513_workspace-name/migration.sql b/packages/opencode/migration/20260410174513_workspace-name/migration.sql new file mode 100644 index 000000000..2a27248e4 --- /dev/null +++ b/packages/opencode/migration/20260410174513_workspace-name/migration.sql @@ -0,0 +1,16 @@ +PRAGMA foreign_keys=OFF;--> statement-breakpoint +CREATE TABLE `__new_workspace` ( + `id` text PRIMARY KEY, + `type` text NOT NULL, + `name` text DEFAULT '' NOT NULL, + `branch` text, + `directory` text, + `extra` text, + `project_id` text NOT NULL, + CONSTRAINT `fk_workspace_project_id_project_id_fk` FOREIGN KEY (`project_id`) REFERENCES `project`(`id`) ON DELETE CASCADE +); +--> statement-breakpoint +INSERT INTO `__new_workspace`(`id`, `type`, `branch`, `name`, `directory`, `extra`, `project_id`) SELECT `id`, `type`, `branch`, `name`, `directory`, `extra`, `project_id` FROM `workspace`;--> statement-breakpoint +DROP TABLE `workspace`;--> statement-breakpoint +ALTER TABLE `__new_workspace` RENAME TO `workspace`;--> statement-breakpoint +PRAGMA foreign_keys=ON; \ No newline at end of file diff --git a/packages/opencode/migration/20260410174513_workspace-name/snapshot.json b/packages/opencode/migration/20260410174513_workspace-name/snapshot.json new file mode 100644 index 000000000..ab7028008 --- /dev/null +++ b/packages/opencode/migration/20260410174513_workspace-name/snapshot.json @@ -0,0 +1,1337 @@ +{ + "version": "7", + "dialect": "sqlite", + "id": "b61476b8-3b92-49ae-9fa5-6eef586ed64b", + "prevIds": [ + "f13dfa58-7fb4-47a2-8f6b-dc70258e14ed" + ], + "ddl": [ + { + "name": "account_state", + "entityType": "tables" + }, + { + "name": "account", + "entityType": "tables" + }, + { + "name": "control_account", + "entityType": "tables" + }, + { + "name": "workspace", + "entityType": "tables" + }, + { + "name": "project", + "entityType": "tables" + }, + { + "name": "message", + "entityType": "tables" + }, + { + "name": "part", + "entityType": "tables" + }, + { + "name": "permission", + "entityType": "tables" + }, + { + "name": "session", + "entityType": "tables" + }, + { + "name": "todo", + "entityType": "tables" + }, + { + "name": "session_share", + "entityType": "tables" + }, + { + "name": "event_sequence", + "entityType": "tables" + }, + { + "name": "event", + "entityType": "tables" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "account_state" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "active_account_id", + "entityType": "columns", + "table": "account_state" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "active_org_id", + "entityType": "columns", + "table": "account_state" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "email", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "url", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "access_token", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "refresh_token", + "entityType": "columns", + "table": "account" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "token_expiry", + "entityType": "columns", + "table": "account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "email", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "url", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "access_token", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "refresh_token", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "token_expiry", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "active", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "type", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": "''", + "generated": null, + "name": "name", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "branch", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "directory", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "extra", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "project_id", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "worktree", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "vcs", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "name", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "icon_url", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "icon_color", + "entityType": "columns", + "table": "project" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "project" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "project" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_initialized", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "sandboxes", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "commands", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "message" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "message" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "message" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "message" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "data", + "entityType": "columns", + "table": "message" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "part" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "message_id", + "entityType": "columns", + "table": "part" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "part" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "part" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "part" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "data", + "entityType": "columns", + "table": "part" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "project_id", + "entityType": "columns", + "table": "permission" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "permission" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "permission" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "data", + "entityType": "columns", + "table": "permission" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "project_id", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "workspace_id", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "parent_id", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "slug", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "directory", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "title", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "version", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "share_url", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "summary_additions", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "summary_deletions", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "summary_files", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "summary_diffs", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "revert", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "permission", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_compacting", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_archived", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "todo" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "content", + "entityType": "columns", + "table": "todo" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "status", + "entityType": "columns", + "table": "todo" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "priority", + "entityType": "columns", + "table": "todo" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "position", + "entityType": "columns", + "table": "todo" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "todo" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "todo" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "secret", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "url", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "aggregate_id", + "entityType": "columns", + "table": "event_sequence" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "seq", + "entityType": "columns", + "table": "event_sequence" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "event" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "aggregate_id", + "entityType": "columns", + "table": "event" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "seq", + "entityType": "columns", + "table": "event" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "type", + "entityType": "columns", + "table": "event" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "data", + "entityType": "columns", + "table": "event" + }, + { + "columns": [ + "active_account_id" + ], + "tableTo": "account", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "nameExplicit": false, + "name": "fk_account_state_active_account_id_account_id_fk", + "entityType": "fks", + "table": "account_state" + }, + { + "columns": [ + "project_id" + ], + "tableTo": "project", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_workspace_project_id_project_id_fk", + "entityType": "fks", + "table": "workspace" + }, + { + "columns": [ + "session_id" + ], + "tableTo": "session", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_message_session_id_session_id_fk", + "entityType": "fks", + "table": "message" + }, + { + "columns": [ + "message_id" + ], + "tableTo": "message", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_part_message_id_message_id_fk", + "entityType": "fks", + "table": "part" + }, + { + "columns": [ + "project_id" + ], + "tableTo": "project", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_permission_project_id_project_id_fk", + "entityType": "fks", + "table": "permission" + }, + { + "columns": [ + "project_id" + ], + "tableTo": "project", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_session_project_id_project_id_fk", + "entityType": "fks", + "table": "session" + }, + { + "columns": [ + "session_id" + ], + "tableTo": "session", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_todo_session_id_session_id_fk", + "entityType": "fks", + "table": "todo" + }, + { + "columns": [ + "session_id" + ], + "tableTo": "session", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_session_share_session_id_session_id_fk", + "entityType": "fks", + "table": "session_share" + }, + { + "columns": [ + "aggregate_id" + ], + "tableTo": "event_sequence", + "columnsTo": [ + "aggregate_id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_event_aggregate_id_event_sequence_aggregate_id_fk", + "entityType": "fks", + "table": "event" + }, + { + "columns": [ + "email", + "url" + ], + "nameExplicit": false, + "name": "control_account_pk", + "entityType": "pks", + "table": "control_account" + }, + { + "columns": [ + "session_id", + "position" + ], + "nameExplicit": false, + "name": "todo_pk", + "entityType": "pks", + "table": "todo" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "account_state_pk", + "table": "account_state", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "account_pk", + "table": "account", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "workspace_pk", + "table": "workspace", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "project_pk", + "table": "project", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "message_pk", + "table": "message", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "part_pk", + "table": "part", + "entityType": "pks" + }, + { + "columns": [ + "project_id" + ], + "nameExplicit": false, + "name": "permission_pk", + "table": "permission", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "session_pk", + "table": "session", + "entityType": "pks" + }, + { + "columns": [ + "session_id" + ], + "nameExplicit": false, + "name": "session_share_pk", + "table": "session_share", + "entityType": "pks" + }, + { + "columns": [ + "aggregate_id" + ], + "nameExplicit": false, + "name": "event_sequence_pk", + "table": "event_sequence", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "event_pk", + "table": "event", + "entityType": "pks" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + }, + { + "value": "time_created", + "isExpression": false + }, + { + "value": "id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "message_session_time_created_id_idx", + "entityType": "indexes", + "table": "message" + }, + { + "columns": [ + { + "value": "message_id", + "isExpression": false + }, + { + "value": "id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "part_message_id_id_idx", + "entityType": "indexes", + "table": "part" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "part_session_idx", + "entityType": "indexes", + "table": "part" + }, + { + "columns": [ + { + "value": "project_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_project_idx", + "entityType": "indexes", + "table": "session" + }, + { + "columns": [ + { + "value": "workspace_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_workspace_idx", + "entityType": "indexes", + "table": "session" + }, + { + "columns": [ + { + "value": "parent_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_parent_idx", + "entityType": "indexes", + "table": "session" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "todo_session_idx", + "entityType": "indexes", + "table": "todo" + } + ], + "renames": [] +} \ No newline at end of file diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-workspace-create.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-workspace-create.tsx index 40cc1013e..447a1c325 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-workspace-create.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-workspace-create.tsx @@ -9,6 +9,12 @@ import { setTimeout as sleep } from "node:timers/promises" import { useSDK } from "../context/sdk" import { useToast } from "../ui/toast" +type Adaptor = { + type: string + name: string + description: string +} + function scoped(sdk: ReturnType, sync: ReturnType, workspaceID: string) { return createOpencodeClient({ baseUrl: sdk.url, @@ -63,9 +69,27 @@ export function DialogWorkspaceCreate(props: { onSelect: (workspaceID: string) = const sdk = useSDK() const toast = useToast() const [creating, setCreating] = createSignal() + const [adaptors, setAdaptors] = createSignal() onMount(() => { dialog.setSize("medium") + void (async () => { + const dir = sync.path.directory || sdk.directory + const url = new URL("/experimental/workspace/adaptor", sdk.url) + if (dir) url.searchParams.set("directory", dir) + const res = await sdk + .fetch(url) + .then((x) => x.json() as Promise) + .catch(() => undefined) + if (!res) { + toast.show({ + message: "Failed to load workspace adaptors", + variant: "error", + }) + return + } + setAdaptors(res) + })() }) const options = createMemo(() => { @@ -79,13 +103,21 @@ export function DialogWorkspaceCreate(props: { onSelect: (workspaceID: string) = }, ] } - return [ - { - title: "Worktree", - value: "worktree" as const, - description: "Create a local git worktree", - }, - ] + const list = adaptors() + if (!list) { + return [ + { + title: "Loading workspaces...", + value: "loading" as const, + description: "Fetching available workspace adaptors", + }, + ] + } + return list.map((item) => ({ + title: item.name, + value: item.type, + description: item.description, + })) }) const create = async (type: string) => { @@ -113,7 +145,7 @@ export function DialogWorkspaceCreate(props: { onSelect: (workspaceID: string) = skipFilter={true} options={options()} onSelect={(option) => { - if (option.value === "creating") return + if (option.value === "creating" || option.value === "loading") return void create(option.value) }} /> diff --git a/packages/opencode/src/control-plane/adaptors/index.ts b/packages/opencode/src/control-plane/adaptors/index.ts index a43fce248..291e392ea 100644 --- a/packages/opencode/src/control-plane/adaptors/index.ts +++ b/packages/opencode/src/control-plane/adaptors/index.ts @@ -1,20 +1,52 @@ import { lazy } from "@/util/lazy" -import type { Adaptor } from "../types" +import type { ProjectID } from "@/project/schema" +import type { WorkspaceAdaptor } from "../types" -const ADAPTORS: Record Promise> = { +export type WorkspaceAdaptorEntry = { + type: string + name: string + description: string +} + +const BUILTIN: Record Promise> = { worktree: lazy(async () => (await import("./worktree")).WorktreeAdaptor), } -export function getAdaptor(type: string): Promise { - return ADAPTORS[type]() +const state = new Map>() + +export async function getAdaptor(projectID: ProjectID, type: string): Promise { + const custom = state.get(projectID)?.get(type) + if (custom) return custom + + const builtin = BUILTIN[type] + if (builtin) return builtin() + + throw new Error(`Unknown workspace adaptor: ${type}`) } -export function installAdaptor(type: string, adaptor: Adaptor) { - // This is experimental: mostly used for testing right now, but we - // will likely allow this in the future. Need to figure out the - // TypeScript story +export async function listAdaptors(projectID: ProjectID): Promise { + const builtin = await Promise.all( + Object.entries(BUILTIN).map(async ([type, init]) => { + const adaptor = await init() + return { + type, + name: adaptor.name, + description: adaptor.description, + } + }), + ) + const custom = [...(state.get(projectID)?.entries() ?? [])].map(([type, adaptor]) => ({ + type, + name: adaptor.name, + description: adaptor.description, + })) + return [...builtin, ...custom] +} - // @ts-expect-error we force the builtin types right now, but we - // will implement a way to extend the types for custom adaptors - ADAPTORS[type] = () => adaptor +// Plugins can be loaded per-project so we need to scope them. If you +// want to install a global one pass `ProjectID.global` +export function registerAdaptor(projectID: ProjectID, type: string, adaptor: WorkspaceAdaptor) { + const adaptors = state.get(projectID) ?? new Map() + adaptors.set(type, adaptor) + state.set(projectID, adaptors) } diff --git a/packages/opencode/src/control-plane/adaptors/worktree.ts b/packages/opencode/src/control-plane/adaptors/worktree.ts index 9fb6c7479..6cc4c20a4 100644 --- a/packages/opencode/src/control-plane/adaptors/worktree.ts +++ b/packages/opencode/src/control-plane/adaptors/worktree.ts @@ -1,18 +1,18 @@ import z from "zod" import { Worktree } from "@/worktree" -import { type Adaptor, WorkspaceInfo } from "../types" +import { type WorkspaceAdaptor, WorkspaceInfo } from "../types" -const Config = WorkspaceInfo.extend({ - name: WorkspaceInfo.shape.name.unwrap(), +const WorktreeConfig = z.object({ + name: WorkspaceInfo.shape.name, branch: WorkspaceInfo.shape.branch.unwrap(), directory: WorkspaceInfo.shape.directory.unwrap(), }) -type Config = z.infer - -export const WorktreeAdaptor: Adaptor = { +export const WorktreeAdaptor: WorkspaceAdaptor = { + name: "Worktree", + description: "Create a git worktree", async configure(info) { - const worktree = await Worktree.makeWorktreeInfo(info.name ?? undefined) + const worktree = await Worktree.makeWorktreeInfo(undefined) return { ...info, name: worktree.name, @@ -21,7 +21,7 @@ export const WorktreeAdaptor: Adaptor = { } }, async create(info) { - const config = Config.parse(info) + const config = WorktreeConfig.parse(info) await Worktree.createFromInfo({ name: config.name, directory: config.directory, @@ -29,11 +29,11 @@ export const WorktreeAdaptor: Adaptor = { }) }, async remove(info) { - const config = Config.parse(info) + const config = WorktreeConfig.parse(info) await Worktree.remove({ directory: config.directory }) }, target(info) { - const config = Config.parse(info) + const config = WorktreeConfig.parse(info) return { type: "local", directory: config.directory, diff --git a/packages/opencode/src/control-plane/types.ts b/packages/opencode/src/control-plane/types.ts index dd17c56d9..4e499e45e 100644 --- a/packages/opencode/src/control-plane/types.ts +++ b/packages/opencode/src/control-plane/types.ts @@ -5,8 +5,8 @@ import { WorkspaceID } from "./schema" export const WorkspaceInfo = z.object({ id: WorkspaceID.zod, type: z.string(), + name: z.string(), branch: z.string().nullable(), - name: z.string().nullable(), directory: z.string().nullable(), extra: z.unknown().nullable(), projectID: ProjectID.zod, @@ -24,9 +24,11 @@ export type Target = headers?: HeadersInit } -export type Adaptor = { - configure(input: WorkspaceInfo): WorkspaceInfo | Promise - create(config: WorkspaceInfo, from?: WorkspaceInfo): Promise - remove(config: WorkspaceInfo): Promise - target(config: WorkspaceInfo): Target | Promise +export type WorkspaceAdaptor = { + name: string + description: string + configure(info: WorkspaceInfo): WorkspaceInfo | Promise + create(info: WorkspaceInfo, from?: WorkspaceInfo): Promise + remove(info: WorkspaceInfo): Promise + target(info: WorkspaceInfo): Target | Promise } diff --git a/packages/opencode/src/control-plane/workspace.sql.ts b/packages/opencode/src/control-plane/workspace.sql.ts index 272907da1..a6a4ce2c8 100644 --- a/packages/opencode/src/control-plane/workspace.sql.ts +++ b/packages/opencode/src/control-plane/workspace.sql.ts @@ -6,8 +6,8 @@ import type { WorkspaceID } from "./schema" export const WorkspaceTable = sqliteTable("workspace", { id: text().$type().primaryKey(), type: text().notNull(), + name: text().notNull().default(""), branch: text(), - name: text(), directory: text(), extra: text({ mode: "json" }), project_id: text() diff --git a/packages/opencode/src/control-plane/workspace.ts b/packages/opencode/src/control-plane/workspace.ts index bbf79620c..f330e07b7 100644 --- a/packages/opencode/src/control-plane/workspace.ts +++ b/packages/opencode/src/control-plane/workspace.ts @@ -9,6 +9,7 @@ import { SyncEvent } from "@/sync" import { Log } from "@/util/log" import { Filesystem } from "@/util/filesystem" import { ProjectID } from "@/project/schema" +import { Slug } from "@opencode-ai/util/slug" import { WorkspaceTable } from "./workspace.sql" import { getAdaptor } from "./adaptors" import { WorkspaceInfo } from "./types" @@ -66,9 +67,9 @@ export namespace Workspace { export const create = fn(CreateInput, async (input) => { const id = WorkspaceID.ascending(input.id) - const adaptor = await getAdaptor(input.type) + const adaptor = await getAdaptor(input.projectID, input.type) - const config = await adaptor.configure({ ...input, id, name: null, directory: null }) + const config = await adaptor.configure({ ...input, id, name: Slug.create(), directory: null }) const info: Info = { id, @@ -124,7 +125,7 @@ export namespace Workspace { stopSync(id) const info = fromRow(row) - const adaptor = await getAdaptor(row.type) + const adaptor = await getAdaptor(info.projectID, row.type) adaptor.remove(info) Database.use((db) => db.delete(WorkspaceTable).where(eq(WorkspaceTable.id, id)).run()) return info @@ -162,7 +163,7 @@ export namespace Workspace { log.info("connecting to sync: " + space.id) setStatus(space.id, "connecting") - const adaptor = await getAdaptor(space.type) + const adaptor = await getAdaptor(space.projectID, space.type) const target = await adaptor.target(space) if (target.type === "local") return diff --git a/packages/opencode/src/plugin/index.ts b/packages/opencode/src/plugin/index.ts index e0478e0b3..60942915a 100644 --- a/packages/opencode/src/plugin/index.ts +++ b/packages/opencode/src/plugin/index.ts @@ -1,4 +1,10 @@ -import type { Hooks, PluginInput, Plugin as PluginInstance, PluginModule } from "@opencode-ai/plugin" +import type { + Hooks, + PluginInput, + Plugin as PluginInstance, + PluginModule, + WorkspaceAdaptor as PluginWorkspaceAdaptor, +} from "@opencode-ai/plugin" import { Config } from "../config/config" import { Bus } from "../bus" import { Log } from "../util/log" @@ -18,6 +24,8 @@ import { makeRuntime } from "@/effect/run-service" import { errorMessage } from "@/util/error" import { PluginLoader } from "./loader" import { parsePluginSpecifier, readPluginId, readV1Plugin, resolvePluginId } from "./shared" +import { registerAdaptor } from "@/control-plane/adaptors" +import type { WorkspaceAdaptor } from "@/control-plane/types" export namespace Plugin { const log = Log.create({ service: "plugin" }) @@ -132,6 +140,11 @@ export namespace Plugin { project: ctx.project, worktree: ctx.worktree, directory: ctx.directory, + experimental_workspace: { + register(type: string, adaptor: PluginWorkspaceAdaptor) { + registerAdaptor(ctx.project.id, type, adaptor as WorkspaceAdaptor) + }, + }, get serverUrl(): URL { return Server.url ?? new URL("http://localhost:4096") }, diff --git a/packages/opencode/src/server/instance/middleware.ts b/packages/opencode/src/server/instance/middleware.ts index 19bd26535..868131eb8 100644 --- a/packages/opencode/src/server/instance/middleware.ts +++ b/packages/opencode/src/server/instance/middleware.ts @@ -95,7 +95,7 @@ export function WorkspaceRouterMiddleware(upgrade: UpgradeWebSocket): Middleware }) } - const adaptor = await getAdaptor(workspace.type) + const adaptor = await getAdaptor(workspace.projectID, workspace.type) const target = await adaptor.target(workspace) if (target.type === "local") { diff --git a/packages/opencode/src/server/instance/workspace.ts b/packages/opencode/src/server/instance/workspace.ts index 419321654..7cee03197 100644 --- a/packages/opencode/src/server/instance/workspace.ts +++ b/packages/opencode/src/server/instance/workspace.ts @@ -1,13 +1,41 @@ import { Hono } from "hono" import { describeRoute, resolver, validator } from "hono-openapi" import z from "zod" +import { listAdaptors } from "../../control-plane/adaptors" import { Workspace } from "../../control-plane/workspace" import { Instance } from "../../project/instance" import { errors } from "../error" import { lazy } from "../../util/lazy" +const WorkspaceAdaptor = z.object({ + type: z.string(), + name: z.string(), + description: z.string(), +}) + export const WorkspaceRoutes = lazy(() => new Hono() + .get( + "/adaptor", + describeRoute({ + summary: "List workspace adaptors", + description: "List all available workspace adaptors for the current project.", + operationId: "experimental.workspace.adaptor.list", + responses: { + 200: { + description: "Workspace adaptors", + content: { + "application/json": { + schema: resolver(z.array(WorkspaceAdaptor)), + }, + }, + }, + }, + }), + async (c) => { + return c.json(await listAdaptors(Instance.project.id)) + }, + ) .post( "/", describeRoute({ diff --git a/packages/opencode/test/control-plane/adaptors.test.ts b/packages/opencode/test/control-plane/adaptors.test.ts new file mode 100644 index 000000000..a8e490226 --- /dev/null +++ b/packages/opencode/test/control-plane/adaptors.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, test } from "bun:test" +import { getAdaptor, registerAdaptor } from "../../src/control-plane/adaptors" +import { ProjectID } from "../../src/project/schema" +import type { WorkspaceInfo } from "../../src/control-plane/types" + +function info(projectID: WorkspaceInfo["projectID"], type: string): WorkspaceInfo { + return { + id: "workspace-test" as WorkspaceInfo["id"], + type, + name: "workspace-test", + branch: null, + directory: null, + extra: null, + projectID, + } +} + +function adaptor(dir: string) { + return { + name: dir, + description: dir, + configure(input: WorkspaceInfo) { + return input + }, + async create() {}, + async remove() {}, + target() { + return { + type: "local" as const, + directory: dir, + } + }, + } +} + +describe("control-plane/adaptors", () => { + test("isolates custom adaptors by project", async () => { + const type = `demo-${Math.random().toString(36).slice(2)}` + const one = ProjectID.make(`project-${Math.random().toString(36).slice(2)}`) + const two = ProjectID.make(`project-${Math.random().toString(36).slice(2)}`) + registerAdaptor(one, type, adaptor("/one")) + registerAdaptor(two, type, adaptor("/two")) + + expect(await (await getAdaptor(one, type)).target(info(one, type))).toEqual({ + type: "local", + directory: "/one", + }) + expect(await (await getAdaptor(two, type)).target(info(two, type))).toEqual({ + type: "local", + directory: "/two", + }) + }) + + test("latest install wins within a project", async () => { + const type = `demo-${Math.random().toString(36).slice(2)}` + const id = ProjectID.make(`project-${Math.random().toString(36).slice(2)}`) + registerAdaptor(id, type, adaptor("/one")) + + expect(await (await getAdaptor(id, type)).target(info(id, type))).toEqual({ + type: "local", + directory: "/one", + }) + + registerAdaptor(id, type, adaptor("/two")) + + expect(await (await getAdaptor(id, type)).target(info(id, type))).toEqual({ + type: "local", + directory: "/two", + }) + }) +}) diff --git a/packages/opencode/test/plugin/github-copilot-models.test.ts b/packages/opencode/test/plugin/github-copilot-models.test.ts index 0b67588a7..33ddef5dd 100644 --- a/packages/opencode/test/plugin/github-copilot-models.test.ts +++ b/packages/opencode/test/plugin/github-copilot-models.test.ts @@ -125,6 +125,9 @@ test("remaps fallback oauth model urls to the enterprise host", async () => { project: {} as never, directory: "", worktree: "", + experimental_workspace: { + register() {}, + }, serverUrl: new URL("https://example.com"), $: {} as never, }) diff --git a/packages/opencode/test/plugin/workspace-adaptor.test.ts b/packages/opencode/test/plugin/workspace-adaptor.test.ts new file mode 100644 index 000000000..cc098b124 --- /dev/null +++ b/packages/opencode/test/plugin/workspace-adaptor.test.ts @@ -0,0 +1,99 @@ +import { afterAll, afterEach, describe, expect, test } from "bun:test" +import path from "path" +import { pathToFileURL } from "url" +import { tmpdir } from "../fixture/fixture" + +const disableDefault = process.env.OPENCODE_DISABLE_DEFAULT_PLUGINS +process.env.OPENCODE_DISABLE_DEFAULT_PLUGINS = "1" + +const { Plugin } = await import("../../src/plugin/index") +const { Workspace } = await import("../../src/control-plane/workspace") +const { Instance } = await import("../../src/project/instance") + +afterEach(async () => { + await Instance.disposeAll() +}) + +afterAll(() => { + if (disableDefault === undefined) { + delete process.env.OPENCODE_DISABLE_DEFAULT_PLUGINS + return + } + process.env.OPENCODE_DISABLE_DEFAULT_PLUGINS = disableDefault +}) + +describe("plugin.workspace", () => { + test("plugin can install a workspace adaptor", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + const type = `plug-${Math.random().toString(36).slice(2)}` + const file = path.join(dir, "plugin.ts") + const mark = path.join(dir, "created.json") + const space = path.join(dir, "space") + await Bun.write( + file, + [ + "export default async ({ experimental_workspace }) => {", + ` experimental_workspace.register(${JSON.stringify(type)}, {`, + ' name: "plug",', + ' description: "plugin workspace adaptor",', + " configure(input) {", + ` return { ...input, name: \"plug\", branch: \"plug/main\", directory: ${JSON.stringify(space)} }`, + " },", + " async create(input) {", + ` await Bun.write(${JSON.stringify(mark)}, JSON.stringify(input))`, + " },", + " async remove() {},", + " target(input) {", + ' return { type: "local", directory: input.directory }', + " },", + " })", + " return {}", + "}", + "", + ].join("\n"), + ) + + await Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify( + { + $schema: "https://opencode.ai/config.json", + plugin: [pathToFileURL(file).href], + }, + null, + 2, + ), + ) + + return { mark, space, type } + }, + }) + + const info = await Instance.provide({ + directory: tmp.path, + fn: async () => { + await Plugin.init() + return Workspace.create({ + type: tmp.extra.type, + branch: null, + extra: { key: "value" }, + projectID: Instance.project.id, + }) + }, + }) + + expect(info.type).toBe(tmp.extra.type) + expect(info.name).toBe("plug") + expect(info.branch).toBe("plug/main") + expect(info.directory).toBe(tmp.extra.space) + expect(info.extra).toEqual({ key: "value" }) + expect(JSON.parse(await Bun.file(tmp.extra.mark).text())).toMatchObject({ + type: tmp.extra.type, + name: "plug", + branch: "plug/main", + directory: tmp.extra.space, + extra: { key: "value" }, + }) + }) +}) diff --git a/packages/plugin/src/example-workspace.ts b/packages/plugin/src/example-workspace.ts new file mode 100644 index 000000000..925328450 --- /dev/null +++ b/packages/plugin/src/example-workspace.ts @@ -0,0 +1,34 @@ +import type { Plugin } from "@opencode-ai/plugin" +import { mkdir, rm } from "node:fs/promises" + +export const FolderWorkspacePlugin: Plugin = async ({ experimental_workspace }) => { + experimental_workspace.register("folder", { + name: "Folder", + description: "Create a blank folder", + configure(config) { + const rand = "" + Math.random() + + return { + ...config, + directory: `/tmp/folder/folder-${rand}`, + } + }, + async create(config) { + if (!config.directory) return + await mkdir(config.directory, { recursive: true }) + }, + async remove(config) { + await rm(config.directory!, { recursive: true, force: true }) + }, + target(config) { + return { + type: "local", + directory: config.directory!, + } + }, + }) + + return {} +} + +export default FolderWorkspacePlugin diff --git a/packages/plugin/src/index.ts b/packages/plugin/src/index.ts index 1afb55daa..49d995c6f 100644 --- a/packages/plugin/src/index.ts +++ b/packages/plugin/src/index.ts @@ -24,11 +24,44 @@ export type ProviderContext = { options: Record } +export type WorkspaceInfo = { + id: string + type: string + name: string + branch: string | null + directory: string | null + extra: unknown | null + projectID: string +} + +export type WorkspaceTarget = + | { + type: "local" + directory: string + } + | { + type: "remote" + url: string | URL + headers?: HeadersInit + } + +export type WorkspaceAdaptor = { + name: string + description: string + configure(config: WorkspaceInfo): WorkspaceInfo | Promise + create(config: WorkspaceInfo, from?: WorkspaceInfo): Promise + remove(config: WorkspaceInfo): Promise + target(config: WorkspaceInfo): WorkspaceTarget | Promise +} + export type PluginInput = { client: ReturnType project: Project directory: string worktree: string + experimental_workspace: { + register(type: string, adaptor: WorkspaceAdaptor): void + } serverUrl: URL $: BunShell } diff --git a/packages/plugin/tsconfig.json b/packages/plugin/tsconfig.json index 117381878..f8e9370d8 100644 --- a/packages/plugin/tsconfig.json +++ b/packages/plugin/tsconfig.json @@ -2,6 +2,7 @@ "$schema": "https://json.schemastore.org/tsconfig.json", "extends": "@tsconfig/node22/tsconfig.json", "compilerOptions": { + "rootDir": "src", "outDir": "dist", "module": "nodenext", "declaration": true, From 329fcb040be17a5779c3b88cab9367127e491638 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Mon, 13 Apr 2026 17:37:41 +0000 Subject: [PATCH 5/6] chore: generate --- .../snapshot.json | 132 +++++------------- packages/sdk/js/src/v2/gen/sdk.gen.ts | 38 +++++ packages/sdk/js/src/v2/gen/types.gen.ts | 26 +++- packages/sdk/openapi.json | 71 ++++++++-- 4 files changed, 156 insertions(+), 111 deletions(-) diff --git a/packages/opencode/migration/20260410174513_workspace-name/snapshot.json b/packages/opencode/migration/20260410174513_workspace-name/snapshot.json index ab7028008..9adeeecbe 100644 --- a/packages/opencode/migration/20260410174513_workspace-name/snapshot.json +++ b/packages/opencode/migration/20260410174513_workspace-name/snapshot.json @@ -2,9 +2,7 @@ "version": "7", "dialect": "sqlite", "id": "b61476b8-3b92-49ae-9fa5-6eef586ed64b", - "prevIds": [ - "f13dfa58-7fb4-47a2-8f6b-dc70258e14ed" - ], + "prevIds": ["f13dfa58-7fb4-47a2-8f6b-dc70258e14ed"], "ddl": [ { "name": "account_state", @@ -969,13 +967,9 @@ "table": "event" }, { - "columns": [ - "active_account_id" - ], + "columns": ["active_account_id"], "tableTo": "account", - "columnsTo": [ - "id" - ], + "columnsTo": ["id"], "onUpdate": "NO ACTION", "onDelete": "SET NULL", "nameExplicit": false, @@ -984,13 +978,9 @@ "table": "account_state" }, { - "columns": [ - "project_id" - ], + "columns": ["project_id"], "tableTo": "project", - "columnsTo": [ - "id" - ], + "columnsTo": ["id"], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -999,13 +989,9 @@ "table": "workspace" }, { - "columns": [ - "session_id" - ], + "columns": ["session_id"], "tableTo": "session", - "columnsTo": [ - "id" - ], + "columnsTo": ["id"], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1014,13 +1000,9 @@ "table": "message" }, { - "columns": [ - "message_id" - ], + "columns": ["message_id"], "tableTo": "message", - "columnsTo": [ - "id" - ], + "columnsTo": ["id"], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1029,13 +1011,9 @@ "table": "part" }, { - "columns": [ - "project_id" - ], + "columns": ["project_id"], "tableTo": "project", - "columnsTo": [ - "id" - ], + "columnsTo": ["id"], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1044,13 +1022,9 @@ "table": "permission" }, { - "columns": [ - "project_id" - ], + "columns": ["project_id"], "tableTo": "project", - "columnsTo": [ - "id" - ], + "columnsTo": ["id"], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1059,13 +1033,9 @@ "table": "session" }, { - "columns": [ - "session_id" - ], + "columns": ["session_id"], "tableTo": "session", - "columnsTo": [ - "id" - ], + "columnsTo": ["id"], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1074,13 +1044,9 @@ "table": "todo" }, { - "columns": [ - "session_id" - ], + "columns": ["session_id"], "tableTo": "session", - "columnsTo": [ - "id" - ], + "columnsTo": ["id"], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1089,13 +1055,9 @@ "table": "session_share" }, { - "columns": [ - "aggregate_id" - ], + "columns": ["aggregate_id"], "tableTo": "event_sequence", - "columnsTo": [ - "aggregate_id" - ], + "columnsTo": ["aggregate_id"], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1104,119 +1066,91 @@ "table": "event" }, { - "columns": [ - "email", - "url" - ], + "columns": ["email", "url"], "nameExplicit": false, "name": "control_account_pk", "entityType": "pks", "table": "control_account" }, { - "columns": [ - "session_id", - "position" - ], + "columns": ["session_id", "position"], "nameExplicit": false, "name": "todo_pk", "entityType": "pks", "table": "todo" }, { - "columns": [ - "id" - ], + "columns": ["id"], "nameExplicit": false, "name": "account_state_pk", "table": "account_state", "entityType": "pks" }, { - "columns": [ - "id" - ], + "columns": ["id"], "nameExplicit": false, "name": "account_pk", "table": "account", "entityType": "pks" }, { - "columns": [ - "id" - ], + "columns": ["id"], "nameExplicit": false, "name": "workspace_pk", "table": "workspace", "entityType": "pks" }, { - "columns": [ - "id" - ], + "columns": ["id"], "nameExplicit": false, "name": "project_pk", "table": "project", "entityType": "pks" }, { - "columns": [ - "id" - ], + "columns": ["id"], "nameExplicit": false, "name": "message_pk", "table": "message", "entityType": "pks" }, { - "columns": [ - "id" - ], + "columns": ["id"], "nameExplicit": false, "name": "part_pk", "table": "part", "entityType": "pks" }, { - "columns": [ - "project_id" - ], + "columns": ["project_id"], "nameExplicit": false, "name": "permission_pk", "table": "permission", "entityType": "pks" }, { - "columns": [ - "id" - ], + "columns": ["id"], "nameExplicit": false, "name": "session_pk", "table": "session", "entityType": "pks" }, { - "columns": [ - "session_id" - ], + "columns": ["session_id"], "nameExplicit": false, "name": "session_share_pk", "table": "session_share", "entityType": "pks" }, { - "columns": [ - "aggregate_id" - ], + "columns": ["aggregate_id"], "nameExplicit": false, "name": "event_sequence_pk", "table": "event_sequence", "entityType": "pks" }, { - "columns": [ - "id" - ], + "columns": ["id"], "nameExplicit": false, "name": "event_pk", "table": "event", @@ -1334,4 +1268,4 @@ } ], "renames": [] -} \ No newline at end of file +} diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index 7fba60fce..f297cb18e 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -29,6 +29,7 @@ import type { ExperimentalConsoleSwitchOrgResponses, ExperimentalResourceListResponses, ExperimentalSessionListResponses, + ExperimentalWorkspaceAdaptorListResponses, ExperimentalWorkspaceCreateErrors, ExperimentalWorkspaceCreateResponses, ExperimentalWorkspaceListResponses, @@ -1086,6 +1087,38 @@ export class Console extends HeyApiClient { } } +export class Adaptor extends HeyApiClient { + /** + * List workspace adaptors + * + * List all available workspace adaptors for the current project. + */ + public list( + parameters?: { + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).get({ + url: "/experimental/workspace/adaptor", + ...options, + ...params, + }) + } +} + export class Workspace extends HeyApiClient { /** * List workspaces @@ -1229,6 +1262,11 @@ export class Workspace extends HeyApiClient { ...params, }) } + + private _adaptor?: Adaptor + get adaptor(): Adaptor { + return (this._adaptor ??= new Adaptor({ client: this.client })) + } } export class Session extends HeyApiClient { diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 6512f40a2..83ec3d751 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -1772,8 +1772,8 @@ export type ToolList = Array export type Workspace = { id: string type: string + name: string branch: string | null - name: string | null directory: string | null extra: unknown | null projectID: string @@ -2812,6 +2812,30 @@ export type ToolListResponses = { export type ToolListResponse = ToolListResponses[keyof ToolListResponses] +export type ExperimentalWorkspaceAdaptorListData = { + body?: never + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/experimental/workspace/adaptor" +} + +export type ExperimentalWorkspaceAdaptorListResponses = { + /** + * Workspace adaptors + */ + 200: Array<{ + type: string + name: string + description: string + }> +} + +export type ExperimentalWorkspaceAdaptorListResponse = + ExperimentalWorkspaceAdaptorListResponses[keyof ExperimentalWorkspaceAdaptorListResponses] + export type ExperimentalWorkspaceListData = { body?: never path?: never diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index 5369d1c2e..85443726f 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -1526,6 +1526,62 @@ ] } }, + "/experimental/workspace/adaptor": { + "get": { + "operationId": "experimental.workspace.adaptor.list", + "parameters": [ + { + "in": "query", + "name": "directory", + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "workspace", + "schema": { + "type": "string" + } + } + ], + "summary": "List workspace adaptors", + "description": "List all available workspace adaptors for the current project.", + "responses": { + "200": { + "description": "Workspace adaptors", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "name": { + "type": "string" + }, + "description": { + "type": "string" + } + }, + "required": ["type", "name", "description"] + } + } + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.experimental.workspace.adaptor.list({\n ...\n})" + } + ] + } + }, "/experimental/workspace": { "post": { "operationId": "experimental.workspace.create", @@ -11885,17 +11941,10 @@ "type": { "type": "string" }, - "branch": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] - }, "name": { + "type": "string" + }, + "branch": { "anyOf": [ { "type": "string" @@ -11927,7 +11976,7 @@ "type": "string" } }, - "required": ["id", "type", "branch", "name", "directory", "extra", "projectID"] + "required": ["id", "type", "name", "branch", "directory", "extra", "projectID"] }, "Worktree": { "type": "object", From 1dc69359d5351c4fb4dfbefef9e87a34c7c15b12 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Mon, 13 Apr 2026 13:45:34 -0400 Subject: [PATCH 6/6] refactor(mcp): remove async facade exports (#22324) --- packages/opencode/src/cli/cmd/mcp.ts | 127 ++-- packages/opencode/src/mcp/index.ts | 34 -- .../src/server/instance/experimental.ts | 9 +- packages/opencode/src/server/instance/mcp.ts | 48 +- packages/opencode/test/mcp/headers.test.ts | 65 +- packages/opencode/test/mcp/lifecycle.test.ts | 568 ++++++++++-------- .../test/mcp/oauth-auto-connect.test.ts | 14 +- .../opencode/test/mcp/oauth-browser.test.ts | 25 +- 8 files changed, 501 insertions(+), 389 deletions(-) diff --git a/packages/opencode/src/cli/cmd/mcp.ts b/packages/opencode/src/cli/cmd/mcp.ts index e33ab6a35..4904db1c3 100644 --- a/packages/opencode/src/cli/cmd/mcp.ts +++ b/packages/opencode/src/cli/cmd/mcp.ts @@ -15,7 +15,8 @@ import { Global } from "../../global" import { modify, applyEdits } from "jsonc-parser" import { Filesystem } from "../../util/filesystem" import { Bus } from "../../bus" -import { AppRuntime } from "@/effect/app-runtime" +import { AppRuntime } from "../../effect/app-runtime" +import { Effect } from "effect" function getAuthStatusIcon(status: MCP.AuthStatus): string { switch (status) { @@ -51,6 +52,47 @@ function isMcpRemote(config: McpEntry): config is McpRemote { return isMcpConfigured(config) && config.type === "remote" } +function configuredServers(config: Config.Info) { + return Object.entries(config.mcp ?? {}).filter((entry): entry is [string, McpConfigured] => isMcpConfigured(entry[1])) +} + +function oauthServers(config: Config.Info) { + return configuredServers(config).filter( + (entry): entry is [string, McpRemote] => isMcpRemote(entry[1]) && entry[1].oauth !== false, + ) +} + +async function listState() { + return AppRuntime.runPromise( + Effect.gen(function* () { + const cfg = yield* Config.Service + const mcp = yield* MCP.Service + const config = yield* cfg.get() + const statuses = yield* mcp.status() + const stored = yield* Effect.all( + Object.fromEntries(configuredServers(config).map(([name]) => [name, mcp.hasStoredTokens(name)])), + { concurrency: "unbounded" }, + ) + return { config, statuses, stored } + }), + ) +} + +async function authState() { + return AppRuntime.runPromise( + Effect.gen(function* () { + const cfg = yield* Config.Service + const mcp = yield* MCP.Service + const config = yield* cfg.get() + const auth = yield* Effect.all( + Object.fromEntries(oauthServers(config).map(([name]) => [name, mcp.getAuthStatus(name)])), + { concurrency: "unbounded" }, + ) + return { config, auth } + }), + ) +} + export const McpCommand = cmd({ command: "mcp", describe: "manage MCP (Model Context Protocol) servers", @@ -76,13 +118,8 @@ export const McpListCommand = cmd({ UI.empty() prompts.intro("MCP Servers") - const config = await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.get())) - const mcpServers = config.mcp ?? {} - const statuses = await MCP.status() - - const servers = Object.entries(mcpServers).filter((entry): entry is [string, McpConfigured] => - isMcpConfigured(entry[1]), - ) + const { config, statuses, stored } = await listState() + const servers = configuredServers(config) if (servers.length === 0) { prompts.log.warn("No MCP servers configured") @@ -93,7 +130,7 @@ export const McpListCommand = cmd({ for (const [name, serverConfig] of servers) { const status = statuses[name] const hasOAuth = isMcpRemote(serverConfig) && !!serverConfig.oauth - const hasStoredTokens = await MCP.hasStoredTokens(name) + const hasStoredTokens = stored[name] let statusIcon: string let statusText: string @@ -153,15 +190,11 @@ export const McpAuthCommand = cmd({ UI.empty() prompts.intro("MCP OAuth Authentication") - const config = await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.get())) + const { config, auth } = await authState() const mcpServers = config.mcp ?? {} + const servers = oauthServers(config) - // Get OAuth-capable servers (remote servers with oauth not explicitly disabled) - const oauthServers = Object.entries(mcpServers).filter( - (entry): entry is [string, McpRemote] => isMcpRemote(entry[1]) && entry[1].oauth !== false, - ) - - if (oauthServers.length === 0) { + if (servers.length === 0) { prompts.log.warn("No OAuth-capable MCP servers configured") prompts.log.info("Remote MCP servers support OAuth by default. Add a remote server in opencode.json:") prompts.log.info(` @@ -178,19 +211,17 @@ export const McpAuthCommand = cmd({ let serverName = args.name if (!serverName) { // Build options with auth status - const options = await Promise.all( - oauthServers.map(async ([name, cfg]) => { - const authStatus = await MCP.getAuthStatus(name) - const icon = getAuthStatusIcon(authStatus) - const statusText = getAuthStatusText(authStatus) - const url = cfg.url - return { - label: `${icon} ${name} (${statusText})`, - value: name, - hint: url, - } - }), - ) + const options = servers.map(([name, cfg]) => { + const authStatus = auth[name] + const icon = getAuthStatusIcon(authStatus) + const statusText = getAuthStatusText(authStatus) + const url = cfg.url + return { + label: `${icon} ${name} (${statusText})`, + value: name, + hint: url, + } + }) const selected = await prompts.select({ message: "Select MCP server to authenticate", @@ -214,7 +245,8 @@ export const McpAuthCommand = cmd({ } // Check if already authenticated - const authStatus = await MCP.getAuthStatus(serverName) + const authStatus = + auth[serverName] ?? (await AppRuntime.runPromise(MCP.Service.use((mcp) => mcp.getAuthStatus(serverName)))) if (authStatus === "authenticated") { const confirm = await prompts.confirm({ message: `${serverName} already has valid credentials. Re-authenticate?`, @@ -241,7 +273,7 @@ export const McpAuthCommand = cmd({ }) try { - const status = await MCP.authenticate(serverName) + const status = await AppRuntime.runPromise(MCP.Service.use((mcp) => mcp.authenticate(serverName))) if (status.status === "connected") { spinner.stop("Authentication successful!") @@ -290,22 +322,17 @@ export const McpAuthListCommand = cmd({ UI.empty() prompts.intro("MCP OAuth Status") - const config = await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.get())) - const mcpServers = config.mcp ?? {} + const { config, auth } = await authState() + const servers = oauthServers(config) - // Get OAuth-capable servers - const oauthServers = Object.entries(mcpServers).filter( - (entry): entry is [string, McpRemote] => isMcpRemote(entry[1]) && entry[1].oauth !== false, - ) - - if (oauthServers.length === 0) { + if (servers.length === 0) { prompts.log.warn("No OAuth-capable MCP servers configured") prompts.outro("Done") return } - for (const [name, serverConfig] of oauthServers) { - const authStatus = await MCP.getAuthStatus(name) + for (const [name, serverConfig] of servers) { + const authStatus = auth[name] const icon = getAuthStatusIcon(authStatus) const statusText = getAuthStatusText(authStatus) const url = serverConfig.url @@ -313,7 +340,7 @@ export const McpAuthListCommand = cmd({ prompts.log.info(`${icon} ${name} ${UI.Style.TEXT_DIM}${statusText}\n ${UI.Style.TEXT_DIM}${url}`) } - prompts.outro(`${oauthServers.length} OAuth-capable server(s)`) + prompts.outro(`${servers.length} OAuth-capable server(s)`) }, }) }, @@ -335,7 +362,7 @@ export const McpLogoutCommand = cmd({ prompts.intro("MCP OAuth Logout") const authPath = path.join(Global.Path.data, "mcp-auth.json") - const credentials = await McpAuth.all() + const credentials = await AppRuntime.runPromise(McpAuth.Service.use((auth) => auth.all())) const serverNames = Object.keys(credentials) if (serverNames.length === 0) { @@ -373,7 +400,7 @@ export const McpLogoutCommand = cmd({ return } - await MCP.removeAuth(serverName) + await AppRuntime.runPromise(MCP.Service.use((mcp) => mcp.removeAuth(serverName))) prompts.log.success(`Removed OAuth credentials for ${serverName}`) prompts.outro("Done") }, @@ -623,10 +650,18 @@ export const McpDebugCommand = cmd({ prompts.log.info(`URL: ${serverConfig.url}`) // Check stored auth status - const authStatus = await MCP.getAuthStatus(serverName) + const { authStatus, entry } = await AppRuntime.runPromise( + Effect.gen(function* () { + const mcp = yield* MCP.Service + const auth = yield* McpAuth.Service + return { + authStatus: yield* mcp.getAuthStatus(serverName), + entry: yield* auth.get(serverName), + } + }), + ) prompts.log.info(`Auth status: ${getAuthStatusIcon(authStatus)} ${getAuthStatusText(authStatus)}`) - const entry = await McpAuth.get(serverName) if (entry?.tokens) { prompts.log.info(` Access token: ${entry.tokens.accessToken.substring(0, 20)}...`) if (entry.tokens.expiresAt) { diff --git a/packages/opencode/src/mcp/index.ts b/packages/opencode/src/mcp/index.ts index 696a662c1..fc34143a2 100644 --- a/packages/opencode/src/mcp/index.ts +++ b/packages/opencode/src/mcp/index.ts @@ -27,7 +27,6 @@ import open from "open" import { Effect, Exit, Layer, Option, Context, Stream } from "effect" import { EffectLogger } from "@/effect/logger" import { InstanceState } from "@/effect/instance-state" -import { makeRuntime } from "@/effect/run-service" import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process" import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner" @@ -890,37 +889,4 @@ export namespace MCP { Layer.provide(CrossSpawnSpawner.defaultLayer), Layer.provide(AppFileSystem.defaultLayer), ) - - const { runPromise } = makeRuntime(Service, defaultLayer) - - // --- Async facade functions --- - - export const status = async () => runPromise((svc) => svc.status()) - - export const tools = async () => runPromise((svc) => svc.tools()) - - export const prompts = async () => runPromise((svc) => svc.prompts()) - - export const resources = async () => runPromise((svc) => svc.resources()) - - export const add = async (name: string, mcp: Config.Mcp) => runPromise((svc) => svc.add(name, mcp)) - - export const connect = async (name: string) => runPromise((svc) => svc.connect(name)) - - export const disconnect = async (name: string) => runPromise((svc) => svc.disconnect(name)) - - export const startAuth = async (mcpName: string) => runPromise((svc) => svc.startAuth(mcpName)) - - export const authenticate = async (mcpName: string) => runPromise((svc) => svc.authenticate(mcpName)) - - export const finishAuth = async (mcpName: string, authorizationCode: string) => - runPromise((svc) => svc.finishAuth(mcpName, authorizationCode)) - - export const removeAuth = async (mcpName: string) => runPromise((svc) => svc.removeAuth(mcpName)) - - export const supportsOAuth = async (mcpName: string) => runPromise((svc) => svc.supportsOAuth(mcpName)) - - export const hasStoredTokens = async (mcpName: string) => runPromise((svc) => svc.hasStoredTokens(mcpName)) - - export const getAuthStatus = async (mcpName: string) => runPromise((svc) => svc.getAuthStatus(mcpName)) } diff --git a/packages/opencode/src/server/instance/experimental.ts b/packages/opencode/src/server/instance/experimental.ts index 978aa03a9..ca8b89fa6 100644 --- a/packages/opencode/src/server/instance/experimental.ts +++ b/packages/opencode/src/server/instance/experimental.ts @@ -408,7 +408,14 @@ export const ExperimentalRoutes = lazy(() => }, }), async (c) => { - return c.json(await MCP.resources()) + return c.json( + await AppRuntime.runPromise( + Effect.gen(function* () { + const mcp = yield* MCP.Service + return yield* mcp.resources() + }), + ), + ) }, ), ) diff --git a/packages/opencode/src/server/instance/mcp.ts b/packages/opencode/src/server/instance/mcp.ts index 1e604c991..f1c8701c4 100644 --- a/packages/opencode/src/server/instance/mcp.ts +++ b/packages/opencode/src/server/instance/mcp.ts @@ -3,8 +3,10 @@ import { describeRoute, validator, resolver } from "hono-openapi" import z from "zod" import { MCP } from "../../mcp" import { Config } from "../../config/config" +import { AppRuntime } from "../../effect/app-runtime" import { errors } from "../error" import { lazy } from "../../util/lazy" +import { Effect } from "effect" export const McpRoutes = lazy(() => new Hono() @@ -26,7 +28,7 @@ export const McpRoutes = lazy(() => }, }), async (c) => { - return c.json(await MCP.status()) + return c.json(await AppRuntime.runPromise(MCP.Service.use((mcp) => mcp.status()))) }, ) .post( @@ -56,7 +58,7 @@ export const McpRoutes = lazy(() => ), async (c) => { const { name, config } = c.req.valid("json") - const result = await MCP.add(name, config) + const result = await AppRuntime.runPromise(MCP.Service.use((mcp) => mcp.add(name, config))) return c.json(result.status) }, ) @@ -84,12 +86,21 @@ export const McpRoutes = lazy(() => }), async (c) => { const name = c.req.param("name") - const supportsOAuth = await MCP.supportsOAuth(name) - if (!supportsOAuth) { + const result = await AppRuntime.runPromise( + Effect.gen(function* () { + const mcp = yield* MCP.Service + const supports = yield* mcp.supportsOAuth(name) + if (!supports) return { supports } + return { + supports, + auth: yield* mcp.startAuth(name), + } + }), + ) + if (!result.supports) { return c.json({ error: `MCP server ${name} does not support OAuth` }, 400) } - const result = await MCP.startAuth(name) - return c.json(result) + return c.json(result.auth) }, ) .post( @@ -120,7 +131,7 @@ export const McpRoutes = lazy(() => async (c) => { const name = c.req.param("name") const { code } = c.req.valid("json") - const status = await MCP.finishAuth(name, code) + const status = await AppRuntime.runPromise(MCP.Service.use((mcp) => mcp.finishAuth(name, code))) return c.json(status) }, ) @@ -144,12 +155,21 @@ export const McpRoutes = lazy(() => }), async (c) => { const name = c.req.param("name") - const supportsOAuth = await MCP.supportsOAuth(name) - if (!supportsOAuth) { + const result = await AppRuntime.runPromise( + Effect.gen(function* () { + const mcp = yield* MCP.Service + const supports = yield* mcp.supportsOAuth(name) + if (!supports) return { supports } + return { + supports, + status: yield* mcp.authenticate(name), + } + }), + ) + if (!result.supports) { return c.json({ error: `MCP server ${name} does not support OAuth` }, 400) } - const status = await MCP.authenticate(name) - return c.json(status) + return c.json(result.status) }, ) .delete( @@ -172,7 +192,7 @@ export const McpRoutes = lazy(() => }), async (c) => { const name = c.req.param("name") - await MCP.removeAuth(name) + await AppRuntime.runPromise(MCP.Service.use((mcp) => mcp.removeAuth(name))) return c.json({ success: true as const }) }, ) @@ -195,7 +215,7 @@ export const McpRoutes = lazy(() => validator("param", z.object({ name: z.string() })), async (c) => { const { name } = c.req.valid("param") - await MCP.connect(name) + await AppRuntime.runPromise(MCP.Service.use((mcp) => mcp.connect(name))) return c.json(true) }, ) @@ -218,7 +238,7 @@ export const McpRoutes = lazy(() => validator("param", z.object({ name: z.string() })), async (c) => { const { name } = c.req.valid("param") - await MCP.disconnect(name) + await AppRuntime.runPromise(MCP.Service.use((mcp) => mcp.disconnect(name))) return c.json(true) }, ), diff --git a/packages/opencode/test/mcp/headers.test.ts b/packages/opencode/test/mcp/headers.test.ts index 69998aaaa..14c08e303 100644 --- a/packages/opencode/test/mcp/headers.test.ts +++ b/packages/opencode/test/mcp/headers.test.ts @@ -1,4 +1,6 @@ import { test, expect, mock, beforeEach } from "bun:test" +import { Effect } from "effect" +import type { MCP as MCPNS } from "../../src/mcp/index" // Track what options were passed to each transport constructor const transportCalls: Array<{ @@ -44,8 +46,10 @@ beforeEach(() => { // Import MCP after mocking const { MCP } = await import("../../src/mcp/index") +const { AppRuntime } = await import("../../src/effect/app-runtime") const { Instance } = await import("../../src/project/instance") const { tmpdir } = await import("../fixture/fixture") +const service = MCP.Service as unknown as Effect.Effect test("headers are passed to transports when oauth is enabled (default)", async () => { await using tmp = await tmpdir({ @@ -73,14 +77,21 @@ test("headers are passed to transports when oauth is enabled (default)", async ( directory: tmp.path, fn: async () => { // Trigger MCP initialization - it will fail to connect but we can check the transport options - await MCP.add("test-server", { - type: "remote", - url: "https://example.com/mcp", - headers: { - Authorization: "Bearer test-token", - "X-Custom-Header": "custom-value", - }, - }).catch(() => {}) + await AppRuntime.runPromise( + Effect.gen(function* () { + const mcp = yield* service + yield* mcp + .add("test-server", { + type: "remote", + url: "https://example.com/mcp", + headers: { + Authorization: "Bearer test-token", + "X-Custom-Header": "custom-value", + }, + }) + .pipe(Effect.catch(() => Effect.void)) + }), + ) // Both transports should have been created with headers expect(transportCalls.length).toBeGreaterThanOrEqual(1) @@ -106,14 +117,21 @@ test("headers are passed to transports when oauth is explicitly disabled", async fn: async () => { transportCalls.length = 0 - await MCP.add("test-server-no-oauth", { - type: "remote", - url: "https://example.com/mcp", - oauth: false, - headers: { - Authorization: "Bearer test-token", - }, - }).catch(() => {}) + await AppRuntime.runPromise( + Effect.gen(function* () { + const mcp = yield* service + yield* mcp + .add("test-server-no-oauth", { + type: "remote", + url: "https://example.com/mcp", + oauth: false, + headers: { + Authorization: "Bearer test-token", + }, + }) + .pipe(Effect.catch(() => Effect.void)) + }), + ) expect(transportCalls.length).toBeGreaterThanOrEqual(1) @@ -137,10 +155,17 @@ test("no requestInit when headers are not provided", async () => { fn: async () => { transportCalls.length = 0 - await MCP.add("test-server-no-headers", { - type: "remote", - url: "https://example.com/mcp", - }).catch(() => {}) + await AppRuntime.runPromise( + Effect.gen(function* () { + const mcp = yield* service + yield* mcp + .add("test-server-no-headers", { + type: "remote", + url: "https://example.com/mcp", + }) + .pipe(Effect.catch(() => Effect.void)) + }), + ) expect(transportCalls.length).toBeGreaterThanOrEqual(1) diff --git a/packages/opencode/test/mcp/lifecycle.test.ts b/packages/opencode/test/mcp/lifecycle.test.ts index 3d17d3a0d..09caa1cd8 100644 --- a/packages/opencode/test/mcp/lifecycle.test.ts +++ b/packages/opencode/test/mcp/lifecycle.test.ts @@ -1,4 +1,6 @@ import { test, expect, mock, beforeEach } from "bun:test" +import { Effect } from "effect" +import type { MCP as MCPNS } from "../../src/mcp/index" // --- Mock infrastructure --- @@ -170,7 +172,10 @@ const { tmpdir } = await import("../fixture/fixture") // --- Helper --- -function withInstance(config: Record, fn: () => Promise) { +function withInstance( + config: Record, + fn: (mcp: MCPNS.Interface) => Effect.Effect, +) { return async () => { await using tmp = await tmpdir({ init: async (dir) => { @@ -187,7 +192,7 @@ function withInstance(config: Record, fn: () => Promise) { await Instance.provide({ directory: tmp.path, fn: async () => { - await fn() + await Effect.runPromise(MCP.Service.use(fn).pipe(Effect.provide(MCP.defaultLayer))) // dispose instance to clean up state between tests await Instance.dispose() }, @@ -201,28 +206,30 @@ function withInstance(config: Record, fn: () => Promise) { test( "tools() reuses cached tool definitions after connect", - withInstance({}, async () => { - lastCreatedClientName = "my-server" - const serverState = getOrCreateClientState("my-server") - serverState.tools = [ - { name: "do_thing", description: "does a thing", inputSchema: { type: "object", properties: {} } }, - ] + withInstance({}, (mcp) => + Effect.gen(function* () { + lastCreatedClientName = "my-server" + const serverState = getOrCreateClientState("my-server") + serverState.tools = [ + { name: "do_thing", description: "does a thing", inputSchema: { type: "object", properties: {} } }, + ] - // First: add the server successfully - const addResult = await MCP.add("my-server", { - type: "local", - command: ["echo", "test"], - }) - expect((addResult.status as any)["my-server"]?.status ?? (addResult.status as any).status).toBe("connected") + // First: add the server successfully + const addResult = yield* mcp.add("my-server", { + type: "local", + command: ["echo", "test"], + }) + expect((addResult.status as any)["my-server"]?.status ?? (addResult.status as any).status).toBe("connected") - expect(serverState.listToolsCalls).toBe(1) + expect(serverState.listToolsCalls).toBe(1) - const toolsA = await MCP.tools() - const toolsB = await MCP.tools() - expect(Object.keys(toolsA).length).toBeGreaterThan(0) - expect(Object.keys(toolsB).length).toBeGreaterThan(0) - expect(serverState.listToolsCalls).toBe(1) - }), + const toolsA = yield* mcp.tools() + const toolsB = yield* mcp.tools() + expect(Object.keys(toolsA).length).toBeGreaterThan(0) + expect(Object.keys(toolsB).length).toBeGreaterThan(0) + expect(serverState.listToolsCalls).toBe(1) + }), + ), ) // ======================================================================== @@ -231,30 +238,32 @@ test( test( "tool change notifications refresh cached tool definitions", - withInstance({}, async () => { - lastCreatedClientName = "status-server" - const serverState = getOrCreateClientState("status-server") + withInstance({}, (mcp) => + Effect.gen(function* () { + lastCreatedClientName = "status-server" + const serverState = getOrCreateClientState("status-server") - await MCP.add("status-server", { - type: "local", - command: ["echo", "test"], - }) + yield* mcp.add("status-server", { + type: "local", + command: ["echo", "test"], + }) - const before = await MCP.tools() - expect(Object.keys(before).some((key) => key.includes("test_tool"))).toBe(true) - expect(serverState.listToolsCalls).toBe(1) + const before = yield* mcp.tools() + expect(Object.keys(before).some((key) => key.includes("test_tool"))).toBe(true) + expect(serverState.listToolsCalls).toBe(1) - serverState.tools = [{ name: "next_tool", description: "next", inputSchema: { type: "object", properties: {} } }] + serverState.tools = [{ name: "next_tool", description: "next", inputSchema: { type: "object", properties: {} } }] - const handler = Array.from(serverState.notificationHandlers.values())[0] - expect(handler).toBeDefined() - await handler?.() + const handler = Array.from(serverState.notificationHandlers.values())[0] + expect(handler).toBeDefined() + yield* Effect.promise(() => handler?.()) - const after = await MCP.tools() - expect(Object.keys(after).some((key) => key.includes("next_tool"))).toBe(true) - expect(Object.keys(after).some((key) => key.includes("test_tool"))).toBe(false) - expect(serverState.listToolsCalls).toBe(2) - }), + const after = yield* mcp.tools() + expect(Object.keys(after).some((key) => key.includes("next_tool"))).toBe(true) + expect(Object.keys(after).some((key) => key.includes("test_tool"))).toBe(false) + expect(serverState.listToolsCalls).toBe(2) + }), + ), ) // ======================================================================== @@ -270,28 +279,29 @@ test( command: ["echo", "test"], }, }, - async () => { - lastCreatedClientName = "disc-server" - getOrCreateClientState("disc-server") + (mcp) => + Effect.gen(function* () { + lastCreatedClientName = "disc-server" + getOrCreateClientState("disc-server") - await MCP.add("disc-server", { - type: "local", - command: ["echo", "test"], - }) + yield* mcp.add("disc-server", { + type: "local", + command: ["echo", "test"], + }) - const statusBefore = await MCP.status() - expect(statusBefore["disc-server"]?.status).toBe("connected") + const statusBefore = yield* mcp.status() + expect(statusBefore["disc-server"]?.status).toBe("connected") - await MCP.disconnect("disc-server") + yield* mcp.disconnect("disc-server") - const statusAfter = await MCP.status() - expect(statusAfter["disc-server"]?.status).toBe("disabled") + const statusAfter = yield* mcp.status() + expect(statusAfter["disc-server"]?.status).toBe("disabled") - // Tools should be empty after disconnect - const tools = await MCP.tools() - const serverTools = Object.keys(tools).filter((k) => k.startsWith("disc-server")) - expect(serverTools.length).toBe(0) - }, + // Tools should be empty after disconnect + const tools = yield* mcp.tools() + const serverTools = Object.keys(tools).filter((k) => k.startsWith("disc-server")) + expect(serverTools.length).toBe(0) + }), ), ) @@ -304,26 +314,29 @@ test( command: ["echo", "test"], }, }, - async () => { - lastCreatedClientName = "reconn-server" - const serverState = getOrCreateClientState("reconn-server") - serverState.tools = [{ name: "my_tool", description: "a tool", inputSchema: { type: "object", properties: {} } }] + (mcp) => + Effect.gen(function* () { + lastCreatedClientName = "reconn-server" + const serverState = getOrCreateClientState("reconn-server") + serverState.tools = [ + { name: "my_tool", description: "a tool", inputSchema: { type: "object", properties: {} } }, + ] - await MCP.add("reconn-server", { - type: "local", - command: ["echo", "test"], - }) + yield* mcp.add("reconn-server", { + type: "local", + command: ["echo", "test"], + }) - await MCP.disconnect("reconn-server") - expect((await MCP.status())["reconn-server"]?.status).toBe("disabled") + yield* mcp.disconnect("reconn-server") + expect((yield* mcp.status())["reconn-server"]?.status).toBe("disabled") - // Reconnect - await MCP.connect("reconn-server") - expect((await MCP.status())["reconn-server"]?.status).toBe("connected") + // Reconnect + yield* mcp.connect("reconn-server") + expect((yield* mcp.status())["reconn-server"]?.status).toBe("connected") - const tools = await MCP.tools() - expect(Object.keys(tools).some((k) => k.includes("my_tool"))).toBe(true) - }, + const tools = yield* mcp.tools() + expect(Object.keys(tools).some((k) => k.includes("my_tool"))).toBe(true) + }), ), ) @@ -335,30 +348,32 @@ test( "add() closes the old client when replacing a server", // Don't put the server in config — add it dynamically so we control // exactly which client instance is "first" vs "second". - withInstance({}, async () => { - lastCreatedClientName = "replace-server" - const firstState = getOrCreateClientState("replace-server") + withInstance({}, (mcp) => + Effect.gen(function* () { + lastCreatedClientName = "replace-server" + const firstState = getOrCreateClientState("replace-server") - await MCP.add("replace-server", { - type: "local", - command: ["echo", "test"], - }) + yield* mcp.add("replace-server", { + type: "local", + command: ["echo", "test"], + }) - expect(firstState.closed).toBe(false) + expect(firstState.closed).toBe(false) - // Create new state for second client - clientStates.delete("replace-server") - const secondState = getOrCreateClientState("replace-server") + // Create new state for second client + clientStates.delete("replace-server") + const secondState = getOrCreateClientState("replace-server") - // Re-add should close the first client - await MCP.add("replace-server", { - type: "local", - command: ["echo", "test"], - }) + // Re-add should close the first client + yield* mcp.add("replace-server", { + type: "local", + command: ["echo", "test"], + }) - expect(firstState.closed).toBe(true) - expect(secondState.closed).toBe(false) - }), + expect(firstState.closed).toBe(true) + expect(secondState.closed).toBe(false) + }), + ), ) // ======================================================================== @@ -378,37 +393,38 @@ test( command: ["echo", "bad"], }, }, - async () => { - // Set up good server - const goodState = getOrCreateClientState("good-server") - goodState.tools = [{ name: "good_tool", description: "works", inputSchema: { type: "object", properties: {} } }] + (mcp) => + Effect.gen(function* () { + // Set up good server + const goodState = getOrCreateClientState("good-server") + goodState.tools = [{ name: "good_tool", description: "works", inputSchema: { type: "object", properties: {} } }] - // Set up bad server - will fail on listTools during create() - const badState = getOrCreateClientState("bad-server") - badState.listToolsShouldFail = true + // Set up bad server - will fail on listTools during create() + const badState = getOrCreateClientState("bad-server") + badState.listToolsShouldFail = true - // Add good server first - lastCreatedClientName = "good-server" - await MCP.add("good-server", { - type: "local", - command: ["echo", "good"], - }) + // Add good server first + lastCreatedClientName = "good-server" + yield* mcp.add("good-server", { + type: "local", + command: ["echo", "good"], + }) - // Add bad server - should fail but not affect good server - lastCreatedClientName = "bad-server" - await MCP.add("bad-server", { - type: "local", - command: ["echo", "bad"], - }) + // Add bad server - should fail but not affect good server + lastCreatedClientName = "bad-server" + yield* mcp.add("bad-server", { + type: "local", + command: ["echo", "bad"], + }) - const status = await MCP.status() - expect(status["good-server"]?.status).toBe("connected") - expect(status["bad-server"]?.status).toBe("failed") + const status = yield* mcp.status() + expect(status["good-server"]?.status).toBe("connected") + expect(status["bad-server"]?.status).toBe("failed") - // Good server's tools should still be available - const tools = await MCP.tools() - expect(Object.keys(tools).some((k) => k.includes("good_tool"))).toBe(true) - }, + // Good server's tools should still be available + const tools = yield* mcp.tools() + expect(Object.keys(tools).some((k) => k.includes("good_tool"))).toBe(true) + }), ), ) @@ -426,21 +442,22 @@ test( enabled: false, }, }, - async () => { - const countBefore = clientCreateCount + (mcp) => + Effect.gen(function* () { + const countBefore = clientCreateCount - await MCP.add("disabled-server", { - type: "local", - command: ["echo", "test"], - enabled: false, - } as any) + yield* mcp.add("disabled-server", { + type: "local", + command: ["echo", "test"], + enabled: false, + } as any) - // No client should have been created - expect(clientCreateCount).toBe(countBefore) + // No client should have been created + expect(clientCreateCount).toBe(countBefore) - const status = await MCP.status() - expect(status["disabled-server"]?.status).toBe("disabled") - }, + const status = yield* mcp.status() + expect(status["disabled-server"]?.status).toBe("disabled") + }), ), ) @@ -457,22 +474,23 @@ test( command: ["echo", "test"], }, }, - async () => { - lastCreatedClientName = "prompt-server" - const serverState = getOrCreateClientState("prompt-server") - serverState.prompts = [{ name: "my-prompt", description: "A test prompt" }] + (mcp) => + Effect.gen(function* () { + lastCreatedClientName = "prompt-server" + const serverState = getOrCreateClientState("prompt-server") + serverState.prompts = [{ name: "my-prompt", description: "A test prompt" }] - await MCP.add("prompt-server", { - type: "local", - command: ["echo", "test"], - }) + yield* mcp.add("prompt-server", { + type: "local", + command: ["echo", "test"], + }) - const prompts = await MCP.prompts() - expect(Object.keys(prompts).length).toBe(1) - const key = Object.keys(prompts)[0] - expect(key).toContain("prompt-server") - expect(key).toContain("my-prompt") - }, + const prompts = yield* mcp.prompts() + expect(Object.keys(prompts).length).toBe(1) + const key = Object.keys(prompts)[0] + expect(key).toContain("prompt-server") + expect(key).toContain("my-prompt") + }), ), ) @@ -485,22 +503,23 @@ test( command: ["echo", "test"], }, }, - async () => { - lastCreatedClientName = "resource-server" - const serverState = getOrCreateClientState("resource-server") - serverState.resources = [{ name: "my-resource", uri: "file:///test.txt", description: "A test resource" }] + (mcp) => + Effect.gen(function* () { + lastCreatedClientName = "resource-server" + const serverState = getOrCreateClientState("resource-server") + serverState.resources = [{ name: "my-resource", uri: "file:///test.txt", description: "A test resource" }] - await MCP.add("resource-server", { - type: "local", - command: ["echo", "test"], - }) + yield* mcp.add("resource-server", { + type: "local", + command: ["echo", "test"], + }) - const resources = await MCP.resources() - expect(Object.keys(resources).length).toBe(1) - const key = Object.keys(resources)[0] - expect(key).toContain("resource-server") - expect(key).toContain("my-resource") - }, + const resources = yield* mcp.resources() + expect(Object.keys(resources).length).toBe(1) + const key = Object.keys(resources)[0] + expect(key).toContain("resource-server") + expect(key).toContain("my-resource") + }), ), ) @@ -513,21 +532,22 @@ test( command: ["echo", "test"], }, }, - async () => { - lastCreatedClientName = "prompt-disc-server" - const serverState = getOrCreateClientState("prompt-disc-server") - serverState.prompts = [{ name: "hidden-prompt", description: "Should not appear" }] + (mcp) => + Effect.gen(function* () { + lastCreatedClientName = "prompt-disc-server" + const serverState = getOrCreateClientState("prompt-disc-server") + serverState.prompts = [{ name: "hidden-prompt", description: "Should not appear" }] - await MCP.add("prompt-disc-server", { - type: "local", - command: ["echo", "test"], - }) + yield* mcp.add("prompt-disc-server", { + type: "local", + command: ["echo", "test"], + }) - await MCP.disconnect("prompt-disc-server") + yield* mcp.disconnect("prompt-disc-server") - const prompts = await MCP.prompts() - expect(Object.keys(prompts).length).toBe(0) - }, + const prompts = yield* mcp.prompts() + expect(Object.keys(prompts).length).toBe(0) + }), ), ) @@ -537,12 +557,14 @@ test( test( "connect() on nonexistent server does not throw", - withInstance({}, async () => { - // Should not throw - await MCP.connect("nonexistent") - const status = await MCP.status() - expect(status["nonexistent"]).toBeUndefined() - }), + withInstance({}, (mcp) => + Effect.gen(function* () { + // Should not throw + yield* mcp.connect("nonexistent") + const status = yield* mcp.status() + expect(status["nonexistent"]).toBeUndefined() + }), + ), ) // ======================================================================== @@ -551,10 +573,12 @@ test( test( "disconnect() on nonexistent server does not throw", - withInstance({}, async () => { - await MCP.disconnect("nonexistent") - // Should complete without error - }), + withInstance({}, (mcp) => + Effect.gen(function* () { + yield* mcp.disconnect("nonexistent") + // Should complete without error + }), + ), ) // ======================================================================== @@ -563,10 +587,12 @@ test( test( "tools() returns empty when no MCP servers are configured", - withInstance({}, async () => { - const tools = await MCP.tools() - expect(Object.keys(tools).length).toBe(0) - }), + withInstance({}, (mcp) => + Effect.gen(function* () { + const tools = yield* mcp.tools() + expect(Object.keys(tools).length).toBe(0) + }), + ), ) // ======================================================================== @@ -582,27 +608,28 @@ test( command: ["echo", "test"], }, }, - async () => { - lastCreatedClientName = "fail-connect" - getOrCreateClientState("fail-connect") - connectShouldFail = true - connectError = "Connection refused" + (mcp) => + Effect.gen(function* () { + lastCreatedClientName = "fail-connect" + getOrCreateClientState("fail-connect") + connectShouldFail = true + connectError = "Connection refused" - await MCP.add("fail-connect", { - type: "local", - command: ["echo", "test"], - }) + yield* mcp.add("fail-connect", { + type: "local", + command: ["echo", "test"], + }) - const status = await MCP.status() - expect(status["fail-connect"]?.status).toBe("failed") - if (status["fail-connect"]?.status === "failed") { - expect(status["fail-connect"].error).toContain("Connection refused") - } + const status = yield* mcp.status() + expect(status["fail-connect"]?.status).toBe("failed") + if (status["fail-connect"]?.status === "failed") { + expect(status["fail-connect"].error).toContain("Connection refused") + } - // No tools should be available - const tools = await MCP.tools() - expect(Object.keys(tools).length).toBe(0) - }, + // No tools should be available + const tools = yield* mcp.tools() + expect(Object.keys(tools).length).toBe(0) + }), ), ) @@ -648,28 +675,29 @@ test( command: ["echo", "test"], }, }, - async () => { - lastCreatedClientName = "my.special-server" - const serverState = getOrCreateClientState("my.special-server") - serverState.tools = [ - { name: "tool-a", description: "Tool A", inputSchema: { type: "object", properties: {} } }, - { name: "tool.b", description: "Tool B", inputSchema: { type: "object", properties: {} } }, - ] + (mcp) => + Effect.gen(function* () { + lastCreatedClientName = "my.special-server" + const serverState = getOrCreateClientState("my.special-server") + serverState.tools = [ + { name: "tool-a", description: "Tool A", inputSchema: { type: "object", properties: {} } }, + { name: "tool.b", description: "Tool B", inputSchema: { type: "object", properties: {} } }, + ] - await MCP.add("my.special-server", { - type: "local", - command: ["echo", "test"], - }) + yield* mcp.add("my.special-server", { + type: "local", + command: ["echo", "test"], + }) - const tools = await MCP.tools() - const keys = Object.keys(tools) + const tools = yield* mcp.tools() + const keys = Object.keys(tools) - // Server name dots should be replaced with underscores - expect(keys.some((k) => k.startsWith("my_special-server_"))).toBe(true) - // Tool name dots should be replaced with underscores - expect(keys.some((k) => k.endsWith("tool_b"))).toBe(true) - expect(keys.length).toBe(2) - }, + // Server name dots should be replaced with underscores + expect(keys.some((k) => k.startsWith("my_special-server_"))).toBe(true) + // Tool name dots should be replaced with underscores + expect(keys.some((k) => k.endsWith("tool_b"))).toBe(true) + expect(keys.length).toBe(2) + }), ), ) @@ -679,23 +707,25 @@ test( test( "local stdio transport is closed when connect times out (no process leak)", - withInstance({}, async () => { - lastCreatedClientName = "hanging-server" - getOrCreateClientState("hanging-server") - connectShouldHang = true + withInstance({}, (mcp) => + Effect.gen(function* () { + lastCreatedClientName = "hanging-server" + getOrCreateClientState("hanging-server") + connectShouldHang = true - const addResult = await MCP.add("hanging-server", { - type: "local", - command: ["node", "fake.js"], - timeout: 100, - }) + const addResult = yield* mcp.add("hanging-server", { + type: "local", + command: ["node", "fake.js"], + timeout: 100, + }) - const serverStatus = (addResult.status as any)["hanging-server"] ?? addResult.status - expect(serverStatus.status).toBe("failed") - expect(serverStatus.error).toContain("timed out") - // Transport must be closed to avoid orphaned child process - expect(transportCloseCount).toBeGreaterThanOrEqual(1) - }), + const serverStatus = (addResult.status as any)["hanging-server"] ?? addResult.status + expect(serverStatus.status).toBe("failed") + expect(serverStatus.error).toContain("timed out") + // Transport must be closed to avoid orphaned child process + expect(transportCloseCount).toBeGreaterThanOrEqual(1) + }), + ), ) // ======================================================================== @@ -704,23 +734,25 @@ test( test( "remote transport is closed when connect times out", - withInstance({}, async () => { - lastCreatedClientName = "hanging-remote" - getOrCreateClientState("hanging-remote") - connectShouldHang = true + withInstance({}, (mcp) => + Effect.gen(function* () { + lastCreatedClientName = "hanging-remote" + getOrCreateClientState("hanging-remote") + connectShouldHang = true - const addResult = await MCP.add("hanging-remote", { - type: "remote", - url: "http://localhost:9999/mcp", - timeout: 100, - oauth: false, - }) + const addResult = yield* mcp.add("hanging-remote", { + type: "remote", + url: "http://localhost:9999/mcp", + timeout: 100, + oauth: false, + }) - const serverStatus = (addResult.status as any)["hanging-remote"] ?? addResult.status - expect(serverStatus.status).toBe("failed") - // Transport must be closed to avoid leaked HTTP connections - expect(transportCloseCount).toBeGreaterThanOrEqual(1) - }), + const serverStatus = (addResult.status as any)["hanging-remote"] ?? addResult.status + expect(serverStatus.status).toBe("failed") + // Transport must be closed to avoid leaked HTTP connections + expect(transportCloseCount).toBeGreaterThanOrEqual(1) + }), + ), ) // ======================================================================== @@ -729,22 +761,24 @@ test( test( "failed remote transport is closed before trying next transport", - withInstance({}, async () => { - lastCreatedClientName = "fail-remote" - getOrCreateClientState("fail-remote") - connectShouldFail = true - connectError = "Connection refused" + withInstance({}, (mcp) => + Effect.gen(function* () { + lastCreatedClientName = "fail-remote" + getOrCreateClientState("fail-remote") + connectShouldFail = true + connectError = "Connection refused" - const addResult = await MCP.add("fail-remote", { - type: "remote", - url: "http://localhost:9999/mcp", - timeout: 5000, - oauth: false, - }) + const addResult = yield* mcp.add("fail-remote", { + type: "remote", + url: "http://localhost:9999/mcp", + timeout: 5000, + oauth: false, + }) - const serverStatus = (addResult.status as any)["fail-remote"] ?? addResult.status - expect(serverStatus.status).toBe("failed") - // Both StreamableHTTP and SSE transports should be closed - expect(transportCloseCount).toBeGreaterThanOrEqual(2) - }), + const serverStatus = (addResult.status as any)["fail-remote"] ?? addResult.status + expect(serverStatus.status).toBe("failed") + // Both StreamableHTTP and SSE transports should be closed + expect(transportCloseCount).toBeGreaterThanOrEqual(2) + }), + ), ) diff --git a/packages/opencode/test/mcp/oauth-auto-connect.test.ts b/packages/opencode/test/mcp/oauth-auto-connect.test.ts index 76f825247..786f1fb46 100644 --- a/packages/opencode/test/mcp/oauth-auto-connect.test.ts +++ b/packages/opencode/test/mcp/oauth-auto-connect.test.ts @@ -1,4 +1,6 @@ import { test, expect, mock, beforeEach } from "bun:test" +import { Effect } from "effect" +import type { MCP as MCPNS } from "../../src/mcp/index" // Mock UnauthorizedError to match the SDK's class class MockUnauthorizedError extends Error { @@ -122,10 +124,14 @@ test("first connect to OAuth server shows needs_auth instead of failed", async ( await Instance.provide({ directory: tmp.path, fn: async () => { - const result = await MCP.add("test-oauth", { - type: "remote", - url: "https://example.com/mcp", - }) + const result = await Effect.runPromise( + MCP.Service.use((mcp) => + mcp.add("test-oauth", { + type: "remote", + url: "https://example.com/mcp", + }), + ).pipe(Effect.provide(MCP.defaultLayer)), + ) const serverStatus = result.status as Record diff --git a/packages/opencode/test/mcp/oauth-browser.test.ts b/packages/opencode/test/mcp/oauth-browser.test.ts index ee4429be7..b6a32b1e1 100644 --- a/packages/opencode/test/mcp/oauth-browser.test.ts +++ b/packages/opencode/test/mcp/oauth-browser.test.ts @@ -1,5 +1,7 @@ import { test, expect, mock, beforeEach } from "bun:test" import { EventEmitter } from "events" +import { Effect } from "effect" +import type { MCP as MCPNS } from "../../src/mcp/index" // Track open() calls and control failure behavior let openShouldFail = false @@ -100,10 +102,12 @@ beforeEach(() => { // Import modules after mocking const { MCP } = await import("../../src/mcp/index") +const { AppRuntime } = await import("../../src/effect/app-runtime") const { Bus } = await import("../../src/bus") const { McpOAuthCallback } = await import("../../src/mcp/oauth-callback") const { Instance } = await import("../../src/project/instance") const { tmpdir } = await import("../fixture/fixture") +const service = MCP.Service as unknown as Effect.Effect test("BrowserOpenFailed event is published when open() throws", async () => { await using tmp = await tmpdir({ @@ -136,7 +140,12 @@ test("BrowserOpenFailed event is published when open() throws", async () => { // Run authenticate with a timeout to avoid waiting forever for the callback // Attach a handler immediately so callback shutdown rejections // don't show up as unhandled between tests. - const authPromise = MCP.authenticate("test-oauth-server").catch(() => undefined) + const authPromise = AppRuntime.runPromise( + Effect.gen(function* () { + const mcp = yield* service + return yield* mcp.authenticate("test-oauth-server") + }), + ).catch(() => undefined) // Config.get() can be slow in tests, so give it plenty of time. await new Promise((resolve) => setTimeout(resolve, 2_000)) @@ -185,7 +194,12 @@ test("BrowserOpenFailed event is NOT published when open() succeeds", async () = }) // Run authenticate with a timeout to avoid waiting forever for the callback - const authPromise = MCP.authenticate("test-oauth-server-2").catch(() => undefined) + const authPromise = AppRuntime.runPromise( + Effect.gen(function* () { + const mcp = yield* service + return yield* mcp.authenticate("test-oauth-server-2") + }), + ).catch(() => undefined) // Config.get() can be slow in tests; also covers the ~500ms open() error-detection window. await new Promise((resolve) => setTimeout(resolve, 2_000)) @@ -230,7 +244,12 @@ test("open() is called with the authorization URL", async () => { openCalledWith = undefined // Run authenticate with a timeout to avoid waiting forever for the callback - const authPromise = MCP.authenticate("test-oauth-server-3").catch(() => undefined) + const authPromise = AppRuntime.runPromise( + Effect.gen(function* () { + const mcp = yield* service + return yield* mcp.authenticate("test-oauth-server-3") + }), + ).catch(() => undefined) // Config.get() can be slow in tests; also covers the ~500ms open() error-detection window. await new Promise((resolve) => setTimeout(resolve, 2_000))