Compare commits

...

3 Commits

Author SHA1 Message Date
Kit Langton
591e197c46 test(config): avoid app runtime in config tests 2026-04-13 13:14:57 -04:00
Kit Langton
aef81b3cea Merge branch 'dev' into facade/config 2026-04-13 13:00:43 -04:00
Kit Langton
80566f0def refactor(config): remove async facade exports 2026-04-13 12:20:33 -04:00
16 changed files with 118 additions and 132 deletions

View File

@@ -25,7 +25,7 @@ const seed = async () => {
directory: dir, directory: dir,
init: () => AppRuntime.runPromise(InstanceBootstrap), init: () => AppRuntime.runPromise(InstanceBootstrap),
fn: async () => { fn: async () => {
await Config.waitForDependencies() await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.waitForDependencies()))
await AppRuntime.runPromise( await AppRuntime.runPromise(
Effect.gen(function* () { Effect.gen(function* () {
const registry = yield* ToolRegistry.Service const registry = yield* ToolRegistry.Service

View File

@@ -1,5 +1,6 @@
import { EOL } from "os" import { EOL } from "os"
import { Config } from "../../../config/config" import { Config } from "../../../config/config"
import { AppRuntime } from "@/effect/app-runtime"
import { bootstrap } from "../../bootstrap" import { bootstrap } from "../../bootstrap"
import { cmd } from "../cmd" import { cmd } from "../cmd"
@@ -9,7 +10,7 @@ export const ConfigCommand = cmd({
builder: (yargs) => yargs, builder: (yargs) => yargs,
async handler() { async handler() {
await bootstrap(process.cwd(), async () => { 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) process.stdout.write(JSON.stringify(config, null, 2) + EOL)
}) })
}, },

View File

@@ -15,6 +15,7 @@ import { Global } from "../../global"
import { modify, applyEdits } from "jsonc-parser" import { modify, applyEdits } from "jsonc-parser"
import { Filesystem } from "../../util/filesystem" import { Filesystem } from "../../util/filesystem"
import { Bus } from "../../bus" import { Bus } from "../../bus"
import { AppRuntime } from "@/effect/app-runtime"
function getAuthStatusIcon(status: MCP.AuthStatus): string { function getAuthStatusIcon(status: MCP.AuthStatus): string {
switch (status) { switch (status) {
@@ -75,7 +76,7 @@ export const McpListCommand = cmd({
UI.empty() UI.empty()
prompts.intro("MCP Servers") 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 mcpServers = config.mcp ?? {}
const statuses = await MCP.status() const statuses = await MCP.status()
@@ -152,7 +153,7 @@ export const McpAuthCommand = cmd({
UI.empty() UI.empty()
prompts.intro("MCP OAuth Authentication") 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 ?? {} const mcpServers = config.mcp ?? {}
// Get OAuth-capable servers (remote servers with oauth not explicitly disabled) // Get OAuth-capable servers (remote servers with oauth not explicitly disabled)
@@ -289,7 +290,7 @@ export const McpAuthListCommand = cmd({
UI.empty() UI.empty()
prompts.intro("MCP OAuth Status") 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 ?? {} const mcpServers = config.mcp ?? {}
// Get OAuth-capable servers // Get OAuth-capable servers
@@ -595,7 +596,7 @@ export const McpDebugCommand = cmd({
UI.empty() UI.empty()
prompts.intro("MCP OAuth Debug") 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 mcpServers = config.mcp ?? {}
const serverName = args.name const serverName = args.name

View File

@@ -326,7 +326,7 @@ export const ProvidersLoginCommand = cmd({
} }
await ModelsDev.refresh(true).catch(() => {}) 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 disabled = new Set(config.disabled_providers ?? [])
const enabled = config.enabled_providers ? new Set(config.enabled_providers) : undefined const enabled = config.enabled_providers ? new Set(config.enabled_providers) : undefined

View File

@@ -81,7 +81,7 @@ export const rpc = {
}) })
}, },
async reload() { async reload() {
await Config.invalidate(true) await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.invalidate(true)))
}, },
async shutdown() { async shutdown() {
Log.Default.info("worker shutting down") Log.Default.info("worker shutting down")

View File

@@ -1,5 +1,6 @@
import type { Argv, InferredOptionTypes } from "yargs" import type { Argv, InferredOptionTypes } from "yargs"
import { Config } from "../config/config" import { Config } from "../config/config"
import { AppRuntime } from "@/effect/app-runtime"
const options = { const options = {
port: { port: {
@@ -37,7 +38,7 @@ export function withNetworkOptions<T>(yargs: Argv<T>) {
} }
export async function resolveNetworkOptions(args: NetworkOptions) { 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 portExplicitlySet = process.argv.includes("--port")
const hostnameExplicitlySet = process.argv.includes("--hostname") const hostnameExplicitlySet = process.argv.includes("--hostname")
const mdnsExplicitlySet = process.argv.includes("--mdns") const mdnsExplicitlySet = process.argv.includes("--mdns")

View File

@@ -5,7 +5,7 @@ import { Flag } from "@/flag/flag"
import { Installation } from "@/installation" import { Installation } from "@/installation"
export async function upgrade() { 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 method = await AppRuntime.runPromise(Installation.Service.use((svc) => svc.method()))
const latest = await AppRuntime.runPromise(Installation.Service.use((svc) => svc.latest(method))).catch(() => {}) const latest = await AppRuntime.runPromise(Installation.Service.use((svc) => svc.latest(method))).catch(() => {})
if (!latest) return if (!latest) return

View File

@@ -33,7 +33,6 @@ import { ConfigPaths } from "./paths"
import type { ConsoleState } from "./console-state" import type { ConsoleState } from "./console-state"
import { AppFileSystem } from "@/filesystem" import { AppFileSystem } from "@/filesystem"
import { InstanceState } from "@/effect/instance-state" import { InstanceState } from "@/effect/instance-state"
import { makeRuntime } from "@/effect/run-service"
import { Context, Duration, Effect, Exit, Fiber, Layer, Option } from "effect" import { Context, Duration, Effect, Exit, Fiber, Layer, Option } from "effect"
import { Flock } from "@/util/flock" import { Flock } from "@/util/flock"
import { isPathPluginSpec, parsePluginSpecifier, resolvePathPluginTarget } from "@/plugin/shared" import { isPathPluginSpec, parsePluginSpecifier, resolvePathPluginTarget } from "@/plugin/shared"
@@ -1661,42 +1660,4 @@ export namespace Config {
Layer.provide(Auth.defaultLayer), Layer.provide(Auth.defaultLayer),
Layer.provide(Account.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())
}
} }

View File

@@ -10,6 +10,7 @@ import { Flag } from "@/flag/flag"
import { Log } from "@/util/log" import { Log } from "@/util/log"
import { isRecord } from "@/util/record" import { isRecord } from "@/util/record"
import { Global } from "@/global" import { Global } from "@/global"
import { AppRuntime } from "@/effect/app-runtime"
export namespace TuiConfig { export namespace TuiConfig {
const log = Log.create({ service: "tui.config" }) const log = Log.create({ service: "tui.config" })
@@ -51,7 +52,7 @@ export namespace TuiConfig {
} }
function installDeps(dir: string): Promise<void> { function installDeps(dir: string): Promise<void> {
return Config.installDependencies(dir) return AppRuntime.runPromise(Config.Service.use((cfg) => cfg.installDependencies(dir)))
} }
async function mergeFile(acc: Acc, file: string) { async function mergeFile(acc: Acc, file: string) {

View File

@@ -32,7 +32,7 @@ export const ConfigRoutes = lazy(() =>
}, },
}), }),
async (c) => { async (c) => {
return c.json(await Config.get()) return c.json(await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.get())))
}, },
) )
.patch( .patch(
@@ -56,7 +56,7 @@ export const ConfigRoutes = lazy(() =>
validator("json", Config.Info), validator("json", Config.Info),
async (c) => { async (c) => {
const config = c.req.valid("json") const config = c.req.valid("json")
await Config.update(config) await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.update(config)))
return c.json(config) return c.json(config)
}, },
) )

View File

@@ -199,7 +199,7 @@ export const GlobalRoutes = lazy(() =>
}, },
}), }),
async (c) => { async (c) => {
return c.json(await Config.getGlobal()) return c.json(await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.getGlobal())))
}, },
) )
.patch( .patch(
@@ -223,7 +223,7 @@ export const GlobalRoutes = lazy(() =>
validator("json", Config.Info), validator("json", Config.Info),
async (c) => { async (c) => {
const config = c.req.valid("json") 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) return c.json(next)
}, },
) )

View File

@@ -44,7 +44,8 @@ export const ProviderRoutes = lazy(() =>
const result = await AppRuntime.runPromise( const result = await AppRuntime.runPromise(
Effect.gen(function* () { Effect.gen(function* () {
const svc = yield* Provider.Service 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 all = yield* Effect.promise(() => ModelsDev.get())
const disabled = new Set(config.disabled_providers ?? []) const disabled = new Set(config.disabled_providers ?? [])
const enabled = config.enabled_providers ? new Set(config.enabled_providers) : undefined const enabled = config.enabled_providers ? new Set(config.enabled_providers) : undefined

View File

@@ -5,6 +5,9 @@ import { Instance } from "../../src/project/instance"
import { Config } from "../../src/config/config" import { Config } from "../../src/config/config"
import { Agent as AgentSvc } from "../../src/agent/agent" import { Agent as AgentSvc } from "../../src/agent/agent"
import { Color } from "../../src/util/color" 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 () => { test("agent color parsed from project config", async () => {
await using tmp = await tmpdir({ await using tmp = await tmpdir({
@@ -24,7 +27,7 @@ test("agent color parsed from project config", async () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const cfg = await Config.get() const cfg = await load()
expect(cfg.agent?.["build"]?.color).toBe("#FFA500") expect(cfg.agent?.["build"]?.color).toBe("#FFA500")
expect(cfg.agent?.["plan"]?.color).toBe("primary") expect(cfg.agent?.["plan"]?.color).toBe("primary")
}, },

View File

@@ -33,15 +33,25 @@ const emptyAuth = Layer.mock(Auth.Service)({
all: () => Effect.succeed({}), all: () => Effect.succeed({}),
}) })
const it = testEffect( const layer = Config.layer.pipe(
Config.layer.pipe( Layer.provide(AppFileSystem.defaultLayer),
Layer.provide(AppFileSystem.defaultLayer), Layer.provide(emptyAuth),
Layer.provide(emptyAuth), Layer.provide(emptyAccount),
Layer.provide(emptyAccount), Layer.provideMerge(infra),
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) => const installDeps = (dir: string, input?: Config.InstallInput) =>
Config.Service.use((svc) => svc.installDependencies(dir, input)) 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! const managedConfigDir = process.env.OPENCODE_TEST_MANAGED_CONFIG_DIR!
beforeEach(async () => { beforeEach(async () => {
await Config.invalidate(true) await clear(true)
}) })
afterEach(async () => { afterEach(async () => {
await fs.rm(managedConfigDir, { force: true, recursive: true }).catch(() => {}) await fs.rm(managedConfigDir, { force: true, recursive: true }).catch(() => {})
await Config.invalidate(true) await clear(true)
}) })
async function writeManagedSettings(settings: object, filename = "opencode.json") { 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 } }) await using tmp = await tmpdir({ git: true, config: { snapshot: true } })
const prev = Global.Path.config const prev = Global.Path.config
;(Global.Path as { config: string }).config = globalTmp.path ;(Global.Path as { config: string }).config = globalTmp.path
await Config.invalidate() await clear()
try { try {
await writeConfig(globalTmp.path, { await writeConfig(globalTmp.path, {
$schema: "https://opencode.ai/config.json", $schema: "https://opencode.ai/config.json",
@@ -81,7 +91,7 @@ async function check(map: (dir: string) => string) {
await Instance.provide({ await Instance.provide({
directory: map(tmp.path), directory: map(tmp.path),
fn: async () => { fn: async () => {
const cfg = await Config.get() const cfg = await load()
expect(cfg.snapshot).toBe(true) expect(cfg.snapshot).toBe(true)
expect(Instance.directory).toBe(Filesystem.resolve(tmp.path)) expect(Instance.directory).toBe(Filesystem.resolve(tmp.path))
expect(Instance.project.id).not.toBe(ProjectID.global) expect(Instance.project.id).not.toBe(ProjectID.global)
@@ -90,7 +100,7 @@ async function check(map: (dir: string) => string) {
} finally { } finally {
await Instance.disposeAll() await Instance.disposeAll()
;(Global.Path as { config: string }).config = prev ;(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({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const config = await Config.get() const config = await load()
expect(config.username).toBeDefined() expect(config.username).toBeDefined()
}, },
}) })
@@ -118,7 +128,7 @@ test("loads JSON config file", async () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const config = await Config.get() const config = await load()
expect(config.model).toBe("test/model") expect(config.model).toBe("test/model")
expect(config.username).toBe("testuser") expect(config.username).toBe("testuser")
}, },
@@ -156,7 +166,7 @@ test("ignores legacy tui keys in opencode config", async () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const config = await Config.get() const config = await load()
expect(config.model).toBe("test/model") expect(config.model).toBe("test/model")
expect((config as Record<string, unknown>).theme).toBeUndefined() expect((config as Record<string, unknown>).theme).toBeUndefined()
expect((config as Record<string, unknown>).tui).toBeUndefined() expect((config as Record<string, unknown>).tui).toBeUndefined()
@@ -181,7 +191,7 @@ test("loads JSONC config file", async () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const config = await Config.get() const config = await load()
expect(config.model).toBe("test/model") expect(config.model).toBe("test/model")
expect(config.username).toBe("testuser") expect(config.username).toBe("testuser")
}, },
@@ -209,7 +219,7 @@ test("jsonc overrides json in the same directory", async () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const config = await Config.get() const config = await load()
expect(config.model).toBe("base") expect(config.model).toBe("base")
expect(config.username).toBe("base") expect(config.username).toBe("base")
}, },
@@ -232,7 +242,7 @@ test("handles environment variable substitution", async () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const config = await Config.get() const config = await load()
expect(config.username).toBe("test-user") expect(config.username).toBe("test-user")
}, },
}) })
@@ -264,7 +274,7 @@ test("preserves env variables when adding $schema to config", async () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const config = await Config.get() const config = await load()
expect(config.username).toBe("secret_value") expect(config.username).toBe("secret_value")
// Read the file to verify the env variable was preserved // Read the file to verify the env variable was preserved
@@ -358,7 +368,7 @@ test("handles file inclusion substitution", async () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const config = await Config.get() const config = await load()
expect(config.username).toBe("test-user") expect(config.username).toBe("test-user")
}, },
}) })
@@ -377,7 +387,7 @@ test("handles file inclusion with replacement tokens", async () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const config = await Config.get() const config = await load()
expect(config.username).toBe("const out = await Bun.$`echo hi`") 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, directory: tmp.path,
fn: async () => { fn: async () => {
// Strict schema should throw an error for invalid fields // 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({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { 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({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const config = await Config.get() const config = await load()
expect(config.agent?.["test_agent"]).toEqual( expect(config.agent?.["test_agent"]).toEqual(
expect.objectContaining({ expect.objectContaining({
model: "test/model", model: "test/model",
@@ -464,7 +474,7 @@ test("treats agent variant as model-scoped setting (not provider option)", async
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const config = await Config.get() const config = await load()
const agent = config.agent?.["test_agent"] const agent = config.agent?.["test_agent"]
expect(agent?.variant).toBe("xhigh") expect(agent?.variant).toBe("xhigh")
@@ -494,7 +504,7 @@ test("handles command configuration", async () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const config = await Config.get() const config = await load()
expect(config.command?.["test_command"]).toEqual({ expect(config.command?.["test_command"]).toEqual({
template: "test template", template: "test template",
description: "test command", description: "test command",
@@ -519,7 +529,7 @@ test("migrates autoshare to share field", async () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const config = await Config.get() const config = await load()
expect(config.share).toBe("auto") expect(config.share).toBe("auto")
expect(config.autoshare).toBe(true) expect(config.autoshare).toBe(true)
}, },
@@ -546,7 +556,7 @@ test("migrates mode field to agent field", async () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const config = await Config.get() const config = await load()
expect(config.agent?.["test_mode"]).toEqual({ expect(config.agent?.["test_mode"]).toEqual({
model: "test/model", model: "test/model",
temperature: 0.5, temperature: 0.5,
@@ -578,7 +588,7 @@ Test agent prompt`,
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const config = await Config.get() const config = await load()
expect(config.agent?.["test"]).toEqual( expect(config.agent?.["test"]).toEqual(
expect.objectContaining({ expect.objectContaining({
name: "test", name: "test",
@@ -622,7 +632,7 @@ Nested agent prompt`,
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const config = await Config.get() const config = await load()
expect(config.agent?.["helper"]).toMatchObject({ expect(config.agent?.["helper"]).toMatchObject({
name: "helper", name: "helper",
@@ -671,7 +681,7 @@ Nested command template`,
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const config = await Config.get() const config = await load()
expect(config.command?.["hello"]).toEqual({ expect(config.command?.["hello"]).toEqual({
description: "Test command", description: "Test command",
@@ -716,7 +726,7 @@ Nested command template`,
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const config = await Config.get() const config = await load()
expect(config.command?.["hello"]).toEqual({ expect(config.command?.["hello"]).toEqual({
description: "Test command", description: "Test command",
@@ -737,7 +747,7 @@ test("updates config and writes to file", async () => {
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const newConfig = { model: "updated/model" } 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")) const writtenConfig = await Filesystem.readJson(path.join(tmp.path, "config.json"))
expect(writtenConfig.model).toBe("updated/model") expect(writtenConfig.model).toBe("updated/model")
@@ -750,7 +760,7 @@ test("gets config directories", async () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const dirs = await Config.directories() const dirs = await listDirs()
expect(dirs.length).toBeGreaterThanOrEqual(1) 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({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
await Config.get() await load()
}, },
}) })
} finally { } finally {
@@ -814,8 +824,8 @@ test("installs dependencies in writable OPENCODE_CONFIG_DIR", async () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
await Config.get() await load()
await Config.waitForDependencies() await ready()
}, },
}) })
@@ -996,7 +1006,7 @@ test("resolves scoped npm plugins in config", async () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const config = await Config.get() const config = await load()
const pluginEntries = config.plugin ?? [] const pluginEntries = config.plugin ?? []
expect(pluginEntries).toContain("@scope/plugin") expect(pluginEntries).toContain("@scope/plugin")
}, },
@@ -1034,7 +1044,7 @@ test("merges plugin arrays from global and local configs", async () => {
await Instance.provide({ await Instance.provide({
directory: path.join(tmp.path, "project"), directory: path.join(tmp.path, "project"),
fn: async () => { fn: async () => {
const config = await Config.get() const config = await load()
const plugins = config.plugin ?? [] const plugins = config.plugin ?? []
// Should contain both global and local plugins // Should contain both global and local plugins
@@ -1070,7 +1080,7 @@ Helper subagent prompt`,
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const config = await Config.get() const config = await load()
expect(config.agent?.["helper"]).toMatchObject({ expect(config.agent?.["helper"]).toMatchObject({
name: "helper", name: "helper",
model: "test/model", model: "test/model",
@@ -1109,7 +1119,7 @@ test("merges instructions arrays from global and local configs", async () => {
await Instance.provide({ await Instance.provide({
directory: path.join(tmp.path, "project"), directory: path.join(tmp.path, "project"),
fn: async () => { fn: async () => {
const config = await Config.get() const config = await load()
const instructions = config.instructions ?? [] const instructions = config.instructions ?? []
expect(instructions).toContain("global-instructions.md") expect(instructions).toContain("global-instructions.md")
@@ -1148,7 +1158,7 @@ test("deduplicates duplicate instructions from global and local configs", async
await Instance.provide({ await Instance.provide({
directory: path.join(tmp.path, "project"), directory: path.join(tmp.path, "project"),
fn: async () => { fn: async () => {
const config = await Config.get() const config = await load()
const instructions = config.instructions ?? [] const instructions = config.instructions ?? []
expect(instructions).toContain("global-only.md") expect(instructions).toContain("global-only.md")
@@ -1193,7 +1203,7 @@ test("deduplicates duplicate plugins from global and local configs", async () =>
await Instance.provide({ await Instance.provide({
directory: path.join(tmp.path, "project"), directory: path.join(tmp.path, "project"),
fn: async () => { fn: async () => {
const config = await Config.get() const config = await load()
const plugins = config.plugin ?? [] const plugins = config.plugin ?? []
// Should contain all unique plugins // Should contain all unique plugins
@@ -1242,7 +1252,7 @@ test("keeps plugin origins aligned with merged plugin list", async () => {
await Instance.provide({ await Instance.provide({
directory: path.join(tmp.path, "project"), directory: path.join(tmp.path, "project"),
fn: async () => { fn: async () => {
const cfg = await Config.get() const cfg = await load()
const plugins = cfg.plugin ?? [] const plugins = cfg.plugin ?? []
const origins = cfg.plugin_origins ?? [] const origins = cfg.plugin_origins ?? []
const names = plugins.map((item) => Config.pluginSpecifier(item)) const names = plugins.map((item) => Config.pluginSpecifier(item))
@@ -1283,7 +1293,7 @@ test("migrates legacy tools config to permissions - allow", async () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const config = await Config.get() const config = await load()
expect(config.agent?.["test"]?.permission).toEqual({ expect(config.agent?.["test"]?.permission).toEqual({
bash: "allow", bash: "allow",
read: "allow", read: "allow",
@@ -1314,7 +1324,7 @@ test("migrates legacy tools config to permissions - deny", async () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const config = await Config.get() const config = await load()
expect(config.agent?.["test"]?.permission).toEqual({ expect(config.agent?.["test"]?.permission).toEqual({
bash: "deny", bash: "deny",
webfetch: "deny", webfetch: "deny",
@@ -1344,7 +1354,7 @@ test("migrates legacy write tool to edit permission", async () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const config = await Config.get() const config = await load()
expect(config.agent?.["test"]?.permission).toEqual({ expect(config.agent?.["test"]?.permission).toEqual({
edit: "allow", edit: "allow",
}) })
@@ -1376,7 +1386,7 @@ test("managed settings override user settings", async () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const config = await Config.get() const config = await load()
expect(config.model).toBe("managed/model") expect(config.model).toBe("managed/model")
expect(config.share).toBe("disabled") expect(config.share).toBe("disabled")
expect(config.username).toBe("testuser") expect(config.username).toBe("testuser")
@@ -1404,7 +1414,7 @@ test("managed settings override project settings", async () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const config = await Config.get() const config = await load()
expect(config.autoupdate).toBe(false) expect(config.autoupdate).toBe(false)
expect(config.disabled_providers).toEqual(["openai"]) expect(config.disabled_providers).toEqual(["openai"])
}, },
@@ -1424,7 +1434,7 @@ test("missing managed settings file is not an error", async () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const config = await Config.get() const config = await load()
expect(config.model).toBe("user/model") expect(config.model).toBe("user/model")
}, },
}) })
@@ -1451,7 +1461,7 @@ test("migrates legacy edit tool to edit permission", async () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const config = await Config.get() const config = await load()
expect(config.agent?.["test"]?.permission).toEqual({ expect(config.agent?.["test"]?.permission).toEqual({
edit: "deny", edit: "deny",
}) })
@@ -1480,7 +1490,7 @@ test("migrates legacy patch tool to edit permission", async () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const config = await Config.get() const config = await load()
expect(config.agent?.["test"]?.permission).toEqual({ expect(config.agent?.["test"]?.permission).toEqual({
edit: "allow", edit: "allow",
}) })
@@ -1509,7 +1519,7 @@ test("migrates legacy multiedit tool to edit permission", async () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const config = await Config.get() const config = await load()
expect(config.agent?.["test"]?.permission).toEqual({ expect(config.agent?.["test"]?.permission).toEqual({
edit: "deny", edit: "deny",
}) })
@@ -1541,7 +1551,7 @@ test("migrates mixed legacy tools config", async () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const config = await Config.get() const config = await load()
expect(config.agent?.["test"]?.permission).toEqual({ expect(config.agent?.["test"]?.permission).toEqual({
bash: "allow", bash: "allow",
edit: "allow", edit: "allow",
@@ -1576,7 +1586,7 @@ test("merges legacy tools with existing permission config", async () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const config = await Config.get() const config = await load()
expect(config.agent?.["test"]?.permission).toEqual({ expect(config.agent?.["test"]?.permission).toEqual({
glob: "allow", glob: "allow",
bash: "allow", bash: "allow",
@@ -1611,7 +1621,7 @@ test("permission config preserves key order", async () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const config = await Config.get() const config = await load()
expect(Object.keys(config.permission!)).toEqual([ expect(Object.keys(config.permission!)).toEqual([
"*", "*",
"edit", "edit",
@@ -1671,7 +1681,7 @@ test("project config can override MCP server enabled status", async () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const config = await Config.get() const config = await load()
// jira should be enabled (overridden by project config) // jira should be enabled (overridden by project config)
expect(config.mcp?.jira).toEqual({ expect(config.mcp?.jira).toEqual({
type: "remote", type: "remote",
@@ -1727,7 +1737,7 @@ test("MCP config deep merges preserving base config properties", async () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const config = await Config.get() const config = await load()
expect(config.mcp?.myserver).toEqual({ expect(config.mcp?.myserver).toEqual({
type: "remote", type: "remote",
url: "https://myserver.example.com/mcp", url: "https://myserver.example.com/mcp",
@@ -1778,7 +1788,7 @@ test("local .opencode config can override MCP from project config", async () =>
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const config = await Config.get() const config = await load()
expect(config.mcp?.docs?.enabled).toBe(true) expect(config.mcp?.docs?.enabled).toBe(true)
}, },
}) })
@@ -2029,7 +2039,7 @@ describe("deduplicatePluginOrigins", () => {
await Instance.provide({ await Instance.provide({
directory: path.join(tmp.path, "project"), directory: path.join(tmp.path, "project"),
fn: async () => { fn: async () => {
const config = await Config.get() const config = await load()
const plugins = config.plugin ?? [] const plugins = config.plugin ?? []
expect(plugins.some((p) => Config.pluginSpecifier(p) === "my-plugin@1.0.0")).toBe(true) 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({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const config = await Config.get() const config = await load()
// Project config should NOT be loaded - model should be default, not "project/model" // Project config should NOT be loaded - model should be default, not "project/model"
expect(config.model).not.toBe("project/model") expect(config.model).not.toBe("project/model")
expect(config.username).not.toBe("project-user") expect(config.username).not.toBe("project-user")
@@ -2092,7 +2102,7 @@ describe("OPENCODE_DISABLE_PROJECT_CONFIG", () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const directories = await Config.directories() const directories = await listDirs()
// Project .opencode should NOT be in directories list // Project .opencode should NOT be in directories list
const hasProjectOpencode = directories.some((d) => d.startsWith(tmp.path)) const hasProjectOpencode = directories.some((d) => d.startsWith(tmp.path))
expect(hasProjectOpencode).toBe(false) expect(hasProjectOpencode).toBe(false)
@@ -2117,7 +2127,7 @@ describe("OPENCODE_DISABLE_PROJECT_CONFIG", () => {
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
// Should still get default config (from global or defaults) // Should still get default config (from global or defaults)
const config = await Config.get() const config = await load()
expect(config).toBeDefined() expect(config).toBeDefined()
expect(config.username).toBeDefined() expect(config.username).toBeDefined()
}, },
@@ -2160,7 +2170,7 @@ describe("OPENCODE_DISABLE_PROJECT_CONFIG", () => {
fn: async () => { fn: async () => {
// The relative instruction should be skipped without error // The relative instruction should be skipped without error
// We're mainly verifying this doesn't throw and the config loads // We're mainly verifying this doesn't throw and the config loads
const config = await Config.get() const config = await load()
expect(config).toBeDefined() expect(config).toBeDefined()
// The instruction should have been skipped (warning logged) // The instruction should have been skipped (warning logged)
// We can't easily test the warning was logged, but we verify // We can't easily test the warning was logged, but we verify
@@ -2218,7 +2228,7 @@ describe("OPENCODE_DISABLE_PROJECT_CONFIG", () => {
await Instance.provide({ await Instance.provide({
directory: projectTmp.path, directory: projectTmp.path,
fn: async () => { fn: async () => {
const config = await Config.get() const config = await load()
// Should load from OPENCODE_CONFIG_DIR, not project // Should load from OPENCODE_CONFIG_DIR, not project
expect(config.model).toBe("configdir/model") expect(config.model).toBe("configdir/model")
}, },
@@ -2253,7 +2263,7 @@ describe("OPENCODE_CONFIG_CONTENT token substitution", () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const config = await Config.get() const config = await load()
expect(config.username).toBe("test_api_key_12345") expect(config.username).toBe("test_api_key_12345")
}, },
}) })
@@ -2287,7 +2297,7 @@ describe("OPENCODE_CONFIG_CONTENT token substitution", () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const config = await Config.get() const config = await load()
expect(config.username).toBe("secret_key_from_file") expect(config.username).toBe("secret_key_from_file")
}, },
}) })

View File

@@ -1,4 +1,5 @@
import { afterEach, beforeEach, expect, test } from "bun:test" import { afterEach, beforeEach, expect, test } from "bun:test"
import { Effect } from "effect"
import path from "path" import path from "path"
import fs from "fs/promises" import fs from "fs/promises"
import { tmpdir } from "../fixture/fixture" import { tmpdir } from "../fixture/fixture"
@@ -10,9 +11,12 @@ import { Filesystem } from "../../src/util/filesystem"
const managedConfigDir = process.env.OPENCODE_TEST_MANAGED_CONFIG_DIR! const managedConfigDir = process.env.OPENCODE_TEST_MANAGED_CONFIG_DIR!
const wintest = process.platform === "win32" ? test : test.skip const wintest = process.platform === "win32" ? test : test.skip
const clear = (wait = false) =>
Effect.runPromise(Config.Service.use((svc) => svc.invalidate(wait)).pipe(Effect.provide(Config.defaultLayer)))
const load = () => Effect.runPromise(Config.Service.use((svc) => svc.get()).pipe(Effect.provide(Config.defaultLayer)))
beforeEach(async () => { beforeEach(async () => {
await Config.invalidate(true) await clear(true)
}) })
afterEach(async () => { afterEach(async () => {
@@ -23,7 +27,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.json"), { force: true }).catch(() => {})
await fs.rm(path.join(Global.Path.config, "tui.jsonc"), { 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 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 () => { test("keeps server and tui plugin merge semantics aligned", async () => {
@@ -79,7 +83,7 @@ test("keeps server and tui plugin merge semantics aligned", async () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const server = await Config.get() const server = await load()
const tui = await TuiConfig.get() const tui = await TuiConfig.get()
const serverPlugins = (server.plugin ?? []).map((item) => Config.pluginSpecifier(item)) const serverPlugins = (server.plugin ?? []).map((item) => Config.pluginSpecifier(item))
const tuiPlugins = (tui.plugin ?? []).map((item) => Config.pluginSpecifier(item)) const tuiPlugins = (tui.plugin ?? []).map((item) => Config.pluginSpecifier(item))

View File

@@ -3,6 +3,9 @@ import { Permission } from "../src/permission"
import { Config } from "../src/config/config" import { Config } from "../src/config/config"
import { Instance } from "../src/project/instance" import { Instance } from "../src/project/instance"
import { tmpdir } from "./fixture/fixture" import { tmpdir } from "./fixture/fixture"
import { Effect } from "effect"
const load = () => Effect.runPromise(Config.Service.use((svc) => svc.get()).pipe(Effect.provide(Config.defaultLayer)))
afterEach(async () => { afterEach(async () => {
await Instance.disposeAll() await Instance.disposeAll()
@@ -158,7 +161,7 @@ describe("permission.task with real config files", () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const config = await Config.get() const config = await load()
const ruleset = Permission.fromConfig(config.permission ?? {}) const ruleset = Permission.fromConfig(config.permission ?? {})
// general and orchestrator-fast should be allowed, code-reviewer denied // general and orchestrator-fast should be allowed, code-reviewer denied
expect(Permission.evaluate("task", "general", ruleset).action).toBe("allow") expect(Permission.evaluate("task", "general", ruleset).action).toBe("allow")
@@ -183,7 +186,7 @@ describe("permission.task with real config files", () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const config = await Config.get() const config = await load()
const ruleset = Permission.fromConfig(config.permission ?? {}) const ruleset = Permission.fromConfig(config.permission ?? {})
// general and code-reviewer should be ask, orchestrator-* denied // general and code-reviewer should be ask, orchestrator-* denied
expect(Permission.evaluate("task", "general", ruleset).action).toBe("ask") expect(Permission.evaluate("task", "general", ruleset).action).toBe("ask")
@@ -208,7 +211,7 @@ describe("permission.task with real config files", () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const config = await Config.get() const config = await load()
const ruleset = Permission.fromConfig(config.permission ?? {}) const ruleset = Permission.fromConfig(config.permission ?? {})
expect(Permission.evaluate("task", "general", ruleset).action).toBe("allow") expect(Permission.evaluate("task", "general", ruleset).action).toBe("allow")
expect(Permission.evaluate("task", "code-reviewer", ruleset).action).toBe("deny") expect(Permission.evaluate("task", "code-reviewer", ruleset).action).toBe("deny")
@@ -235,7 +238,7 @@ describe("permission.task with real config files", () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const config = await Config.get() const config = await load()
const ruleset = Permission.fromConfig(config.permission ?? {}) const ruleset = Permission.fromConfig(config.permission ?? {})
// Verify task permissions // Verify task permissions
@@ -273,7 +276,7 @@ describe("permission.task with real config files", () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const config = await Config.get() const config = await load()
const ruleset = Permission.fromConfig(config.permission ?? {}) const ruleset = Permission.fromConfig(config.permission ?? {})
// Last matching rule wins - "*" deny is last, so all agents are denied // 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({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const config = await Config.get() const config = await load()
const ruleset = Permission.fromConfig(config.permission ?? {}) const ruleset = Permission.fromConfig(config.permission ?? {})
// Evaluate uses findLast - "general" allow comes after "*" deny // Evaluate uses findLast - "general" allow comes after "*" deny