From 0bda2717dfa7997805f4f5c6f203be3756505e76 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Thu, 21 May 2026 19:58:02 -0400 Subject: [PATCH 1/3] refactor(opencode): type config loader env --- packages/opencode/src/config/config.ts | 56 ++-- packages/opencode/src/config/env.ts | 28 ++ packages/opencode/src/config/paths.ts | 11 +- .../agent/plugin-agent-regression.test.ts | 2 + packages/opencode/test/config/config.test.ts | 307 +++++++++++------- packages/opencode/test/plugin/trigger.test.ts | 2 + .../test/plugin/workspace-adapter.test.ts | 2 + 7 files changed, 264 insertions(+), 144 deletions(-) create mode 100644 packages/opencode/src/config/env.ts diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 349b7e6a074e..6f1255aa7a4f 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -26,6 +26,7 @@ import { NonNegativeInt, PositiveInt, type DeepMutable } from "@opencode-ai/core import { ConfigAgent } from "./agent" import { ConfigAttachment } from "./attachment" import { ConfigCommand } from "./command" +import { ConfigEnv } from "./env" import { ConfigFormatter } from "./formatter" import { ConfigLayout } from "./layout" import { ConfigLSP } from "./lsp" @@ -385,8 +386,13 @@ export const layer = Layer.effect( const authSvc = yield* Auth.Service const accountSvc = yield* Account.Service const env = yield* Env.Service + const configEnv = yield* ConfigEnv.Service const npmSvc = yield* Npm.Service const http = yield* HttpClient.HttpClient + const configPath = Option.getOrUndefined(configEnv.config) + const configDir = Option.getOrUndefined(configEnv.configDir) + const inlineConfigContent = Option.getOrUndefined(configEnv.inlineConfigContent) + const permission = Option.getOrUndefined(configEnv.permission) const readConfigFile = (filepath: string) => fs.readFileStringSafe(filepath).pipe(Effect.orDie) @@ -444,7 +450,7 @@ export const layer = Layer.effect( let result: Info = {} // Seed the default global config with the schema for editor completion, but avoid writing when the user // explicitly routes config through env-provided paths or content. - if (!Flag.OPENCODE_CONFIG && !Flag.OPENCODE_CONFIG_DIR && !Flag.OPENCODE_CONFIG_CONTENT) { + if (!configPath && !configDir && !inlineConfigContent) { const file = globalConfigFile() if (!existsSync(file)) { yield* fs @@ -594,12 +600,12 @@ export const layer = Layer.effect( const global = Object.keys(authEnv).length ? yield* loadGlobal(authEnv) : yield* getGlobal() yield* merge(Global.Path.config, global, "global") - if (Flag.OPENCODE_CONFIG) { - yield* merge(Flag.OPENCODE_CONFIG, yield* loadFile(Flag.OPENCODE_CONFIG, authEnv)) - log.debug("loaded custom config", { path: Flag.OPENCODE_CONFIG }) + if (configPath) { + yield* merge(configPath, yield* loadFile(configPath, authEnv)) + log.debug("loaded custom config", { path: configPath }) } - if (!Flag.OPENCODE_DISABLE_PROJECT_CONFIG) { + if (!configEnv.disableProjectConfig) { for (const file of yield* ConfigPaths.files("opencode", ctx.directory, ctx.worktree).pipe(Effect.orDie)) { yield* merge(file, yield* loadFile(file, authEnv), "local") } @@ -609,16 +615,19 @@ export const layer = Layer.effect( result.mode = result.mode || {} result.plugin = result.plugin || [] - const directories = yield* ConfigPaths.directories(ctx.directory, ctx.worktree) + const directories = yield* ConfigPaths.directories(ctx.directory, ctx.worktree, { + configDir, + disableProjectConfig: configEnv.disableProjectConfig, + }) - if (Flag.OPENCODE_CONFIG_DIR) { - log.debug("loading config from OPENCODE_CONFIG_DIR", { path: Flag.OPENCODE_CONFIG_DIR }) + if (configDir) { + log.debug("loading config from OPENCODE_CONFIG_DIR", { path: configDir }) } const deps: Fiber.Fiber[] = [] for (const dir of directories) { - if (dir.endsWith(".opencode") || dir === Flag.OPENCODE_CONFIG_DIR) { + if (dir.endsWith(".opencode") || dir === configDir) { for (const file of ["opencode.json", "opencode.jsonc"]) { const source = path.join(dir, file) log.debug(`loading config from ${source}`) @@ -663,12 +672,9 @@ export const layer = Layer.effect( yield* mergePluginOrigins(dir, list) } - if (process.env.OPENCODE_CONFIG_CONTENT) { + if (inlineConfigContent) { const source = "OPENCODE_CONFIG_CONTENT" - const next = yield* loadConfig(process.env.OPENCODE_CONFIG_CONTENT, { - dir: ctx.directory, - source, - }) + const next = yield* loadConfig(inlineConfigContent, { dir: ctx.directory, source }, authEnv) yield* merge(source, next, "local") log.debug("loaded custom config from OPENCODE_CONFIG_CONTENT") } @@ -685,17 +691,18 @@ export const layer = Layer.effect( [accountSvc.config(accountID, orgID), accountSvc.token(accountID)], { concurrency: 2 }, ) - if (Option.isSome(tokenOpt)) { - process.env["OPENCODE_CONSOLE_TOKEN"] = tokenOpt.value - yield* env.set("OPENCODE_CONSOLE_TOKEN", tokenOpt.value) - } + const accountEnv = Option.isSome(tokenOpt) + ? { ...authEnv, OPENCODE_CONSOLE_TOKEN: tokenOpt.value } + : authEnv + if (Option.isSome(tokenOpt)) yield* env.set("OPENCODE_CONSOLE_TOKEN", tokenOpt.value) if (Option.isSome(configOpt)) { const source = `${url}/api/config` - const next = yield* loadConfig(JSON.stringify(configOpt.value), { - dir: path.dirname(source), - source, - }) + const next = yield* loadConfig( + JSON.stringify(configOpt.value), + { dir: path.dirname(source), source }, + accountEnv, + ) for (const providerID of Object.keys(next.provider ?? {})) { consoleManagedProviders.add(providerID) } @@ -741,9 +748,9 @@ export const layer = Layer.effect( }) } - if (Flag.OPENCODE_PERMISSION) { + if (permission) { try { - result.permission = mergeDeep(result.permission ?? {}, JSON.parse(Flag.OPENCODE_PERMISSION)) + result.permission = mergeDeep(result.permission ?? {}, JSON.parse(permission)) } catch (err) { log.warn("OPENCODE_PERMISSION contains invalid JSON, skipping", { err }) } @@ -868,6 +875,7 @@ export const defaultLayer = layer.pipe( Layer.provide(EffectFlock.defaultLayer), Layer.provide(AppFileSystem.defaultLayer), Layer.provide(Env.defaultLayer), + Layer.provide(ConfigEnv.defaultLayer), Layer.provide(Auth.defaultLayer), Layer.provide(Account.defaultLayer), Layer.provide(Npm.defaultLayer), diff --git a/packages/opencode/src/config/env.ts b/packages/opencode/src/config/env.ts new file mode 100644 index 000000000000..420c7d4ddd1c --- /dev/null +++ b/packages/opencode/src/config/env.ts @@ -0,0 +1,28 @@ +export * as ConfigEnv from "./env" + +import { Config as EffectConfig, ConfigProvider, Context, Effect, Layer } from "effect" +import { ConfigService } from "@/effect/config-service" + +const fields = { + config: EffectConfig.string("OPENCODE_CONFIG").pipe(EffectConfig.option), + configDir: EffectConfig.string("OPENCODE_CONFIG_DIR").pipe(EffectConfig.option), + inlineConfigContent: EffectConfig.string("OPENCODE_CONFIG_CONTENT").pipe(EffectConfig.option), + disableProjectConfig: EffectConfig.boolean("OPENCODE_DISABLE_PROJECT_CONFIG").pipe(EffectConfig.withDefault(false)), + permission: EffectConfig.string("OPENCODE_PERMISSION").pipe(EffectConfig.option), +} + +export class Service extends ConfigService.Service()("@opencode/ConfigEnv", fields) {} + +export type Info = Context.Service.Shape + +export const layer = (input: Info) => Service.layer(input) +// Build the env-backed provider inside the layer so each layer construction sees +// the current process env instead of a module-load snapshot. +export const defaultLayer = Layer.effect( + Service, + Effect.gen(function* () { + const config = yield* EffectConfig.all(fields).pipe(Effect.provide(ConfigProvider.layer(ConfigProvider.fromEnv()))) + // oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- Config.all preserves the declared field shape. + return Service.of(config as Info) + }), +).pipe(Layer.orDie) diff --git a/packages/opencode/src/config/paths.ts b/packages/opencode/src/config/paths.ts index 82fca570f4cf..ae09902ce5ad 100644 --- a/packages/opencode/src/config/paths.ts +++ b/packages/opencode/src/config/paths.ts @@ -20,11 +20,16 @@ export const files = Effect.fn("ConfigPaths.projectFiles")(function* ( })).toReversed() }) -export const directories = Effect.fn("ConfigPaths.directories")(function* (directory: string, worktree?: string) { +export const directories = Effect.fn("ConfigPaths.directories")(function* ( + directory: string, + worktree?: string, + options: { configDir?: string; disableProjectConfig?: boolean } = {}, +) { const afs = yield* AppFileSystem.Service + const configDir = options.configDir ?? Flag.OPENCODE_CONFIG_DIR return unique([ Global.Path.config, - ...(!Flag.OPENCODE_DISABLE_PROJECT_CONFIG + ...(!(options.disableProjectConfig ?? Flag.OPENCODE_DISABLE_PROJECT_CONFIG) ? yield* afs.up({ targets: [".opencode"], start: directory, @@ -36,7 +41,7 @@ export const directories = Effect.fn("ConfigPaths.directories")(function* (direc start: Global.Path.home, stop: Global.Path.home, })), - ...(Flag.OPENCODE_CONFIG_DIR ? [Flag.OPENCODE_CONFIG_DIR] : []), + ...(configDir ? [configDir] : []), ]) }) diff --git a/packages/opencode/test/agent/plugin-agent-regression.test.ts b/packages/opencode/test/agent/plugin-agent-regression.test.ts index d79e01c78867..0441360337c4 100644 --- a/packages/opencode/test/agent/plugin-agent-regression.test.ts +++ b/packages/opencode/test/agent/plugin-agent-regression.test.ts @@ -7,6 +7,7 @@ import { pathToFileURL } from "url" import { Agent } from "../../src/agent/agent" import { Bus } from "../../src/bus" import { Config } from "../../src/config/config" +import { ConfigEnv } from "../../src/config/env" import { Env } from "../../src/env" import { RuntimeFlags } from "../../src/effect/runtime-flags" import { Plugin } from "../../src/plugin" @@ -27,6 +28,7 @@ const provider = ProviderTest.fake() const configLayer = Config.layer.pipe( Layer.provide(AppFileSystem.defaultLayer), Layer.provide(Env.defaultLayer), + Layer.provide(ConfigEnv.defaultLayer), Layer.provide(AuthTest.empty), Layer.provide(AccountTest.empty), Layer.provide(NpmTest.noop), diff --git a/packages/opencode/test/config/config.test.ts b/packages/opencode/test/config/config.test.ts index 04dcde32e11d..22ce1df6eac1 100644 --- a/packages/opencode/test/config/config.test.ts +++ b/packages/opencode/test/config/config.test.ts @@ -3,6 +3,7 @@ import { Effect, Exit, Layer, Option } from "effect" import { FetchHttpClient, HttpClient, HttpClientResponse } from "effect/unstable/http" import { NodeFileSystem, NodePath } from "@effect/platform-node" import { Config } from "@/config/config" +import { ConfigEnv } from "@/config/env" import { ConfigManaged } from "@/config/managed" import { ConfigParse } from "../../src/config/parse" import { EffectFlock } from "@opencode-ai/core/util/effect-flock" @@ -56,6 +57,16 @@ const json = (request: Parameters[0], body: u }), ) +const configEnvLayer = (input: Partial = {}) => + ConfigEnv.layer({ + config: Option.none(), + configDir: Option.none(), + inlineConfigContent: Option.none(), + disableProjectConfig: false, + permission: Option.none(), + ...input, + }) + const wellKnownAuth = (url: string) => Layer.mock(Auth.Service)({ all: () => @@ -88,12 +99,14 @@ const configLayer = ( auth?: Layer.Layer account?: Layer.Layer client?: HttpClient.HttpClient + configEnv?: Layer.Layer } = {}, ) => Config.layer.pipe( Layer.provide(testFlock), Layer.provide(AppFileSystem.defaultLayer), Layer.provide(Env.defaultLayer), + Layer.provide(options.configEnv ?? ConfigEnv.defaultLayer), Layer.provide(options.auth ?? AuthTest.empty), Layer.provide(options.account ?? AccountTest.empty), Layer.provideMerge(infra), @@ -108,10 +121,11 @@ const it = testEffect(layer) const provideCurrentInstance = (effect: Effect.Effect, ctx: InstanceContext) => effect.pipe(Effect.provideService(InstanceRef, ctx)) -const load = (ctx: InstanceContext) => +const loadWith = (ctx: InstanceContext, nextLayer = layer) => Effect.runPromise( - Config.Service.use((svc) => provideCurrentInstance(svc.get(), ctx)).pipe(Effect.scoped, Effect.provide(layer)), + Config.Service.use((svc) => provideCurrentInstance(svc.get(), ctx)).pipe(Effect.scoped, Effect.provide(nextLayer)), ) +const load = (ctx: InstanceContext) => loadWith(ctx) const saveGlobal = (config: Config.Info) => Effect.runPromise( Config.use.updateGlobal(config).pipe( @@ -124,13 +138,6 @@ const clear = async (wait = false) => { await Effect.runPromise(Config.use.invalidate().pipe(Effect.scoped, Effect.provide(layer))) if (wait) await InstanceRuntime.disposeAllInstances() } -const listDirs = (ctx: InstanceContext) => - Effect.runPromise( - Config.Service.use((svc) => provideCurrentInstance(svc.directories(), ctx)).pipe( - Effect.scoped, - Effect.provide(layer), - ), - ) // Get managed config directory from environment (set in preload.ts) const managedConfigDir = process.env.OPENCODE_TEST_MANAGED_CONFIG_DIR! const originalTestToken = process.env.TEST_TOKEN @@ -250,24 +257,20 @@ test("does not create global config when OPENCODE_CONFIG_DIR is set", async () = await using tmp = await tmpdir() await using custom = await tmpdir() const prevConfig = Global.Path.config - const prevEnv = process.env.OPENCODE_CONFIG_DIR ;(Global.Path as { config: string }).config = tmp.path - process.env.OPENCODE_CONFIG_DIR = custom.path await clear(true) try { await withTestInstance({ directory: tmp.path, fn: async (ctx) => { - await load(ctx) + await loadWith(ctx, configLayer({ configEnv: configEnvLayer({ configDir: Option.some(custom.path) }) })) }, }) expect(await Filesystem.exists(path.join(tmp.path, "opencode.jsonc"))).toBe(false) } finally { ;(Global.Path as { config: string }).config = prevConfig - if (prevEnv === undefined) delete process.env.OPENCODE_CONFIG_DIR - else process.env.OPENCODE_CONFIG_DIR = prevEnv await clear(true) } }) @@ -577,6 +580,7 @@ test("resolves env templates in account config with account token", async () => Effect.gen(function* () { const config = yield* svc.get() expect(config.provider?.["opencode"]?.options?.apiKey).toBe("st_test_token") + expect(process.env["OPENCODE_CONSOLE_TOKEN"]).toBe(originalControlToken) }), ), ).pipe(Effect.scoped, Effect.provide(layer), Effect.runPromise) @@ -1676,6 +1680,7 @@ test("remote well-known config can use FetchHttpClient layer", async () => { Layer.provide(testFlock), Layer.provide(AppFileSystem.defaultLayer), Layer.provide(Env.defaultLayer), + Layer.provide(ConfigEnv.defaultLayer), Layer.provide(wellKnownAuth(server.url.origin)), Layer.provide(AccountTest.empty), Layer.provideMerge(infra), @@ -1976,136 +1981,204 @@ describe("deduplicatePluginOrigins", () => { }) describe("OPENCODE_DISABLE_PROJECT_CONFIG", () => { - it.instance( - "skips project config files when flag is set", - () => - withProcessEnv( - "OPENCODE_DISABLE_PROJECT_CONFIG", - "true", + test("skips project config files when flag is set", async () => { + await provideTmpdirInstance( + () => + Config.Service.use((svc) => + Effect.gen(function* () { + const config = yield* svc.get() + expect(config.model).not.toBe("project/model") + expect(config.username).not.toBe("project-user") + }), + ), + { config: { model: "project/model", username: "project-user" } }, + ).pipe( + Effect.scoped, + Effect.provide(configLayer({ configEnv: configEnvLayer({ disableProjectConfig: true }) })), + Effect.runPromise, + ) + }) + + test("skips project .opencode/ directories when flag is set", async () => { + await provideTmpdirInstance((dir) => + Config.Service.use((svc) => Effect.gen(function* () { - const config = yield* Config.use.get() - expect(config.model).not.toBe("project/model") - expect(config.username).not.toBe("project-user") + yield* mkdirEffect(path.join(dir, ".opencode", "command")) + yield* writeTextEffect( + path.join(dir, ".opencode", "command", "test-cmd.md"), + "# Test Command\nThis is a test command.", + ) + yield* svc.get() + const directories = yield* svc.directories() + expect(directories.some((item) => item.startsWith(dir))).toBe(false) }), ), - { config: { model: "project/model", username: "project-user" } }, - ) - - it.instance("skips project .opencode/ directories when flag is set", () => - withProcessEnv( - "OPENCODE_DISABLE_PROJECT_CONFIG", - "true", - Effect.gen(function* () { - const test = yield* TestInstance - yield* mkdirEffect(path.join(test.directory, ".opencode", "command")) - yield* writeTextEffect( - path.join(test.directory, ".opencode", "command", "test-cmd.md"), - "# Test Command\nThis is a test command.", - ) - const directories = yield* Config.use.directories() - expect(directories.some((d) => d.startsWith(test.directory))).toBe(false) - }), - ), - ) - - it.instance("still loads global config when flag is set", () => - withProcessEnv( - "OPENCODE_DISABLE_PROJECT_CONFIG", - "true", - Effect.gen(function* () { - const config = yield* Config.use.get() - expect(config).toBeDefined() - expect(config.username).toBeDefined() - }), - ), - ) + ).pipe( + Effect.scoped, + Effect.provide(configLayer({ configEnv: configEnvLayer({ disableProjectConfig: true }) })), + Effect.runPromise, + ) + }) - it.instance( - "skips relative instructions with warning when flag is set but no config dir", - () => - withProcessEnvs( - { OPENCODE_CONFIG_DIR: undefined, OPENCODE_DISABLE_PROJECT_CONFIG: "true" }, + test("still loads global config when flag is set", async () => { + await provideTmpdirInstance(() => + Config.Service.use((svc) => Effect.gen(function* () { - const test = yield* TestInstance - yield* writeTextEffect(path.join(test.directory, "CUSTOM.md"), "# Custom Instructions") - // The relative instruction should be skipped without error - const config = yield* Config.use.get() + const config = yield* svc.get() expect(config).toBeDefined() + expect(config.username).toBeDefined() }), ), - { config: { instructions: ["./CUSTOM.md"] } }, - ) + ).pipe( + Effect.scoped, + Effect.provide(configLayer({ configEnv: configEnvLayer({ disableProjectConfig: true }) })), + Effect.runPromise, + ) + }) - it.instance( - "OPENCODE_CONFIG_DIR still works when flag is set", - () => - Effect.gen(function* () { - const configDir = yield* tmpdirScoped({ config: { model: "configdir/model" } }) - yield* withProcessEnvs( - { OPENCODE_DISABLE_PROJECT_CONFIG: "true", OPENCODE_CONFIG_DIR: configDir }, + test("skips relative instructions with warning when flag is set but no config dir", async () => { + await provideTmpdirInstance( + (dir) => + Config.Service.use((svc) => Effect.gen(function* () { - const config = yield* Config.use.get() - expect(config.model).toBe("configdir/model") + yield* writeTextEffect(path.join(dir, "CUSTOM.md"), "# Custom Instructions") + const config = yield* svc.get() + expect(config).toBeDefined() }), - ) - }), - { config: { model: "project/model" } }, - ) + ), + { config: { instructions: ["./CUSTOM.md"] } }, + ).pipe( + Effect.scoped, + Effect.provide(configLayer({ configEnv: configEnvLayer({ disableProjectConfig: true }) })), + Effect.runPromise, + ) + }) + + test("OPENCODE_CONFIG_DIR still works when flag is set", async () => { + await provideTmpdirInstance( + () => + Effect.gen(function* () { + const configDir = yield* tmpdirScoped({ config: { model: "configdir/model" } }) + const config = yield* Config.Service.use((svc) => svc.get()).pipe( + Effect.provide( + configLayer({ + configEnv: configEnvLayer({ configDir: Option.some(configDir), disableProjectConfig: true }), + }), + ), + ) + expect(config).toBeDefined() + expect(config.model).toBe("configdir/model") + }), + { config: { model: "project/model" } }, + ).pipe(Effect.provide(infra), Effect.scoped, Effect.runPromise) + }) }) // Regression for #28206: malformed OPENCODE_PERMISSION JSON used to crash // the app on startup with an unhandled SyntaxError. Loading the config with // an invalid JSON value in this env var should not throw. describe("OPENCODE_PERMISSION env var", () => { - it.instance("does not crash when OPENCODE_PERMISSION contains invalid JSON", () => - withProcessEnv( - "OPENCODE_PERMISSION", - "{invalid", - Effect.gen(function* () { - const config = yield* Config.use.get() - // Regression: load() used to throw before returning anything. - expect(config).toBeDefined() - }), - ), - ) + test("does not crash when OPENCODE_PERMISSION contains invalid JSON", async () => { + await provideTmpdirInstance(() => + Config.Service.use((svc) => + Effect.gen(function* () { + const config = yield* svc.get() + // Regression: load() used to throw before returning anything. + expect(config).toBeDefined() + }), + ), + ).pipe( + Effect.scoped, + Effect.provide(configLayer({ configEnv: configEnvLayer({ permission: Option.some("{invalid") }) })), + Effect.runPromise, + ) + }) }) describe("OPENCODE_CONFIG_CONTENT token substitution", () => { - it.instance("substitutes {env:} tokens in OPENCODE_CONFIG_CONTENT", () => - withProcessEnv( - "TEST_CONFIG_VAR", - "test_api_key_12345", - withProcessEnv( - "OPENCODE_CONFIG_CONTENT", - JSON.stringify({ - $schema: "https://opencode.ai/config.json", - username: "{env:TEST_CONFIG_VAR}", - }), + test("loads inline config from ConfigEnv service", async () => { + await provideTmpdirInstance(() => + Config.Service.use((svc) => Effect.gen(function* () { - const config = yield* Config.use.get() - expect(config.username).toBe("test_api_key_12345") + const config = yield* svc.get() + expect(config.username).toBe("inline-config-user") }), ), - ), - ) - - it.instance("substitutes {file:} tokens in OPENCODE_CONFIG_CONTENT", () => - Effect.gen(function* () { - const test = yield* TestInstance - yield* writeTextEffect(path.join(test.directory, "api_key.txt"), "secret_key_from_file") - yield* withProcessEnv( - "OPENCODE_CONFIG_CONTENT", - JSON.stringify({ - $schema: "https://opencode.ai/config.json", - username: "{file:./api_key.txt}", + ).pipe( + Effect.scoped, + Effect.provide( + configLayer({ + configEnv: configEnvLayer({ + inlineConfigContent: Option.some( + JSON.stringify({ $schema: "https://opencode.ai/config.json", username: "inline-config-user" }), + ), + }), }), + ), + Effect.runPromise, + ) + }) + + test("substitutes {env:} tokens in OPENCODE_CONFIG_CONTENT", async () => { + const originalTestVar = process.env["TEST_CONFIG_VAR"] + process.env["TEST_CONFIG_VAR"] = "test_api_key_12345" + + try { + await provideTmpdirInstance(() => + Config.Service.use((svc) => + Effect.gen(function* () { + const config = yield* svc.get() + expect(config.username).toBe("test_api_key_12345") + }), + ), + ).pipe( + Effect.scoped, + Effect.provide( + configLayer({ + configEnv: configEnvLayer({ + inlineConfigContent: Option.some( + JSON.stringify({ + $schema: "https://opencode.ai/config.json", + username: "{env:TEST_CONFIG_VAR}", + }), + ), + }), + }), + ), + Effect.runPromise, + ) + } finally { + if (originalTestVar !== undefined) process.env["TEST_CONFIG_VAR"] = originalTestVar + else delete process.env["TEST_CONFIG_VAR"] + } + }) + + test("substitutes {file:} tokens in OPENCODE_CONFIG_CONTENT", async () => { + await provideTmpdirInstance((dir) => + Config.Service.use((svc) => Effect.gen(function* () { - const config = yield* Config.use.get() + yield* writeTextEffect(path.join(dir, "api_key.txt"), "secret_key_from_file") + const config = yield* svc.get() expect(config.username).toBe("secret_key_from_file") }), - ) - }), - ) + ), + ).pipe( + Effect.scoped, + Effect.provide( + configLayer({ + configEnv: configEnvLayer({ + inlineConfigContent: Option.some( + JSON.stringify({ + $schema: "https://opencode.ai/config.json", + username: "{file:./api_key.txt}", + }), + ), + }), + }), + ), + Effect.runPromise, + ) + }) }) // parseManagedPlist unit tests — pure function, no OS interaction diff --git a/packages/opencode/test/plugin/trigger.test.ts b/packages/opencode/test/plugin/trigger.test.ts index 3716bc3aca5e..d1667e8ba003 100644 --- a/packages/opencode/test/plugin/trigger.test.ts +++ b/packages/opencode/test/plugin/trigger.test.ts @@ -8,6 +8,7 @@ import path from "path" import { pathToFileURL } from "url" import { Bus } from "../../src/bus" import { Config } from "../../src/config/config" +import { ConfigEnv } from "../../src/config/env" import { Env } from "../../src/env" import { RuntimeFlags } from "../../src/effect/runtime-flags" import { Plugin } from "../../src/plugin/index" @@ -22,6 +23,7 @@ const configLayer = Config.layer.pipe( Layer.provide(EffectFlock.defaultLayer), Layer.provide(AppFileSystem.defaultLayer), Layer.provide(Env.defaultLayer), + Layer.provide(ConfigEnv.defaultLayer), Layer.provide(AuthTest.empty), Layer.provide(AccountTest.empty), Layer.provide(NpmTest.noop), diff --git a/packages/opencode/test/plugin/workspace-adapter.test.ts b/packages/opencode/test/plugin/workspace-adapter.test.ts index 79964d3deeb7..499bd5bffd0e 100644 --- a/packages/opencode/test/plugin/workspace-adapter.test.ts +++ b/packages/opencode/test/plugin/workspace-adapter.test.ts @@ -9,6 +9,7 @@ import { pathToFileURL } from "url" import { Auth } from "../../src/auth" import { Bus } from "../../src/bus" import { Config } from "../../src/config/config" +import { ConfigEnv } from "../../src/config/env" import { Env } from "../../src/env" import { RuntimeFlags } from "../../src/effect/runtime-flags" import { Workspace } from "../../src/control-plane/workspace" @@ -31,6 +32,7 @@ const configLayer = Config.layer.pipe( Layer.provide(EffectFlock.defaultLayer), Layer.provide(AppFileSystem.defaultLayer), Layer.provide(Env.defaultLayer), + Layer.provide(ConfigEnv.defaultLayer), Layer.provide(AuthTest.empty), Layer.provide(AccountTest.empty), Layer.provide(NpmTest.noop), From 2b056cfcc0251136cac489b5ffca4d271046106a Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Fri, 22 May 2026 11:36:01 -0400 Subject: [PATCH 2/3] refactor(opencode): preserve config env options --- packages/opencode/src/config/config.ts | 73 +++++++++++++++----------- packages/opencode/src/config/paths.ts | 8 +-- 2 files changed, 47 insertions(+), 34 deletions(-) diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 6f1255aa7a4f..73f60f23491b 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -389,10 +389,6 @@ export const layer = Layer.effect( const configEnv = yield* ConfigEnv.Service const npmSvc = yield* Npm.Service const http = yield* HttpClient.HttpClient - const configPath = Option.getOrUndefined(configEnv.config) - const configDir = Option.getOrUndefined(configEnv.configDir) - const inlineConfigContent = Option.getOrUndefined(configEnv.inlineConfigContent) - const permission = Option.getOrUndefined(configEnv.permission) const readConfigFile = (filepath: string) => fs.readFileStringSafe(filepath).pipe(Effect.orDie) @@ -450,7 +446,11 @@ export const layer = Layer.effect( let result: Info = {} // Seed the default global config with the schema for editor completion, but avoid writing when the user // explicitly routes config through env-provided paths or content. - if (!configPath && !configDir && !inlineConfigContent) { + if ( + Option.isNone(configEnv.config) && + Option.isNone(configEnv.configDir) && + Option.isNone(configEnv.inlineConfigContent) + ) { const file = globalConfigFile() if (!existsSync(file)) { yield* fs @@ -600,10 +600,14 @@ export const layer = Layer.effect( const global = Object.keys(authEnv).length ? yield* loadGlobal(authEnv) : yield* getGlobal() yield* merge(Global.Path.config, global, "global") - if (configPath) { - yield* merge(configPath, yield* loadFile(configPath, authEnv)) - log.debug("loaded custom config", { path: configPath }) - } + yield* Option.match(configEnv.config, { + onNone: () => Effect.void, + onSome: (configPath) => + Effect.gen(function* () { + yield* merge(configPath, yield* loadFile(configPath, authEnv)) + log.debug("loaded custom config", { path: configPath }) + }), + }) if (!configEnv.disableProjectConfig) { for (const file of yield* ConfigPaths.files("opencode", ctx.directory, ctx.worktree).pipe(Effect.orDie)) { @@ -616,18 +620,19 @@ export const layer = Layer.effect( result.plugin = result.plugin || [] const directories = yield* ConfigPaths.directories(ctx.directory, ctx.worktree, { - configDir, + configDir: configEnv.configDir, disableProjectConfig: configEnv.disableProjectConfig, }) - if (configDir) { - log.debug("loading config from OPENCODE_CONFIG_DIR", { path: configDir }) - } + Option.match(configEnv.configDir, { + onNone: () => undefined, + onSome: (configDir) => log.debug("loading config from OPENCODE_CONFIG_DIR", { path: configDir }), + }) const deps: Fiber.Fiber[] = [] for (const dir of directories) { - if (dir.endsWith(".opencode") || dir === configDir) { + if (dir.endsWith(".opencode") || Option.contains(configEnv.configDir, dir)) { for (const file of ["opencode.json", "opencode.jsonc"]) { const source = path.join(dir, file) log.debug(`loading config from ${source}`) @@ -672,12 +677,16 @@ export const layer = Layer.effect( yield* mergePluginOrigins(dir, list) } - if (inlineConfigContent) { - const source = "OPENCODE_CONFIG_CONTENT" - const next = yield* loadConfig(inlineConfigContent, { dir: ctx.directory, source }, authEnv) - yield* merge(source, next, "local") - log.debug("loaded custom config from OPENCODE_CONFIG_CONTENT") - } + yield* Option.match(configEnv.inlineConfigContent, { + onNone: () => Effect.void, + onSome: (inlineConfigContent) => + Effect.gen(function* () { + const source = "OPENCODE_CONFIG_CONTENT" + const next = yield* loadConfig(inlineConfigContent, { dir: ctx.directory, source }, authEnv) + yield* merge(source, next, "local") + log.debug("loaded custom config from OPENCODE_CONFIG_CONTENT") + }), + }) const activeAccount = Option.getOrUndefined( yield* accountSvc.active().pipe(Effect.catch(() => Effect.succeed(Option.none()))), @@ -691,9 +700,10 @@ export const layer = Layer.effect( [accountSvc.config(accountID, orgID), accountSvc.token(accountID)], { concurrency: 2 }, ) - const accountEnv = Option.isSome(tokenOpt) - ? { ...authEnv, OPENCODE_CONSOLE_TOKEN: tokenOpt.value } - : authEnv + const accountEnv = Option.match(tokenOpt, { + onNone: () => authEnv, + onSome: (token) => ({ ...authEnv, OPENCODE_CONSOLE_TOKEN: token }), + }) if (Option.isSome(tokenOpt)) yield* env.set("OPENCODE_CONSOLE_TOKEN", tokenOpt.value) if (Option.isSome(configOpt)) { @@ -748,13 +758,16 @@ export const layer = Layer.effect( }) } - if (permission) { - try { - result.permission = mergeDeep(result.permission ?? {}, JSON.parse(permission)) - } catch (err) { - log.warn("OPENCODE_PERMISSION contains invalid JSON, skipping", { err }) - } - } + Option.match(configEnv.permission, { + onNone: () => undefined, + onSome: (permission) => { + try { + result.permission = mergeDeep(result.permission ?? {}, JSON.parse(permission)) + } catch (err) { + log.warn("OPENCODE_PERMISSION contains invalid JSON, skipping", { err }) + } + }, + }) if (result.tools) { const perms: Record = {} diff --git a/packages/opencode/src/config/paths.ts b/packages/opencode/src/config/paths.ts index ae09902ce5ad..18ca6756a706 100644 --- a/packages/opencode/src/config/paths.ts +++ b/packages/opencode/src/config/paths.ts @@ -4,7 +4,7 @@ import path from "path" import { Flag } from "@opencode-ai/core/flag/flag" import { Global } from "@opencode-ai/core/global" import { unique } from "remeda" -import * as Effect from "effect/Effect" +import { Effect, Option } from "effect" import { AppFileSystem } from "@opencode-ai/core/filesystem" export const files = Effect.fn("ConfigPaths.projectFiles")(function* ( @@ -23,10 +23,10 @@ export const files = Effect.fn("ConfigPaths.projectFiles")(function* ( export const directories = Effect.fn("ConfigPaths.directories")(function* ( directory: string, worktree?: string, - options: { configDir?: string; disableProjectConfig?: boolean } = {}, + options: { configDir?: Option.Option; disableProjectConfig?: boolean } = {}, ) { const afs = yield* AppFileSystem.Service - const configDir = options.configDir ?? Flag.OPENCODE_CONFIG_DIR + const configDir = options.configDir ?? Option.fromUndefinedOr(Flag.OPENCODE_CONFIG_DIR) return unique([ Global.Path.config, ...(!(options.disableProjectConfig ?? Flag.OPENCODE_DISABLE_PROJECT_CONFIG) @@ -41,7 +41,7 @@ export const directories = Effect.fn("ConfigPaths.directories")(function* ( start: Global.Path.home, stop: Global.Path.home, })), - ...(configDir ? [configDir] : []), + ...Option.toArray(configDir), ]) }) From c85fa88fd2b8c2a45a0de82e75342e42cab52083 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Fri, 22 May 2026 13:21:11 -0400 Subject: [PATCH 3/3] refactor(opencode): simplify config loader env --- packages/opencode/src/config/config.ts | 79 ++++++++----------- .../src/config/{env.ts => loader-env.ts} | 4 +- .../agent/plugin-agent-regression.test.ts | 4 +- packages/opencode/test/config/config.test.ts | 79 +++++++++---------- packages/opencode/test/plugin/trigger.test.ts | 4 +- .../test/plugin/workspace-adapter.test.ts | 4 +- 6 files changed, 80 insertions(+), 94 deletions(-) rename packages/opencode/src/config/{env.ts => loader-env.ts} (93%) diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 73f60f23491b..18ba8d04ec0c 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -26,7 +26,7 @@ import { NonNegativeInt, PositiveInt, type DeepMutable } from "@opencode-ai/core import { ConfigAgent } from "./agent" import { ConfigAttachment } from "./attachment" import { ConfigCommand } from "./command" -import { ConfigEnv } from "./env" +import { ConfigLoaderEnv } from "./loader-env" import { ConfigFormatter } from "./formatter" import { ConfigLayout } from "./layout" import { ConfigLSP } from "./lsp" @@ -386,7 +386,7 @@ export const layer = Layer.effect( const authSvc = yield* Auth.Service const accountSvc = yield* Account.Service const env = yield* Env.Service - const configEnv = yield* ConfigEnv.Service + const loaderEnv = yield* ConfigLoaderEnv.Service const npmSvc = yield* Npm.Service const http = yield* HttpClient.HttpClient @@ -447,9 +447,9 @@ export const layer = Layer.effect( // Seed the default global config with the schema for editor completion, but avoid writing when the user // explicitly routes config through env-provided paths or content. if ( - Option.isNone(configEnv.config) && - Option.isNone(configEnv.configDir) && - Option.isNone(configEnv.inlineConfigContent) + Option.isNone(loaderEnv.config) && + Option.isNone(loaderEnv.configDir) && + Option.isNone(loaderEnv.inlineConfigContent) ) { const file = globalConfigFile() if (!existsSync(file)) { @@ -600,16 +600,12 @@ export const layer = Layer.effect( const global = Object.keys(authEnv).length ? yield* loadGlobal(authEnv) : yield* getGlobal() yield* merge(Global.Path.config, global, "global") - yield* Option.match(configEnv.config, { - onNone: () => Effect.void, - onSome: (configPath) => - Effect.gen(function* () { - yield* merge(configPath, yield* loadFile(configPath, authEnv)) - log.debug("loaded custom config", { path: configPath }) - }), - }) + if (Option.isSome(loaderEnv.config)) { + yield* merge(loaderEnv.config.value, yield* loadFile(loaderEnv.config.value, authEnv)) + log.debug("loaded custom config", { path: loaderEnv.config.value }) + } - if (!configEnv.disableProjectConfig) { + if (!loaderEnv.disableProjectConfig) { for (const file of yield* ConfigPaths.files("opencode", ctx.directory, ctx.worktree).pipe(Effect.orDie)) { yield* merge(file, yield* loadFile(file, authEnv), "local") } @@ -620,19 +616,18 @@ export const layer = Layer.effect( result.plugin = result.plugin || [] const directories = yield* ConfigPaths.directories(ctx.directory, ctx.worktree, { - configDir: configEnv.configDir, - disableProjectConfig: configEnv.disableProjectConfig, + configDir: loaderEnv.configDir, + disableProjectConfig: loaderEnv.disableProjectConfig, }) - Option.match(configEnv.configDir, { - onNone: () => undefined, - onSome: (configDir) => log.debug("loading config from OPENCODE_CONFIG_DIR", { path: configDir }), - }) + if (Option.isSome(loaderEnv.configDir)) { + log.debug("loading config from OPENCODE_CONFIG_DIR", { path: loaderEnv.configDir.value }) + } const deps: Fiber.Fiber[] = [] for (const dir of directories) { - if (dir.endsWith(".opencode") || Option.contains(configEnv.configDir, dir)) { + if (dir.endsWith(".opencode") || Option.contains(loaderEnv.configDir, dir)) { for (const file of ["opencode.json", "opencode.jsonc"]) { const source = path.join(dir, file) log.debug(`loading config from ${source}`) @@ -677,16 +672,12 @@ export const layer = Layer.effect( yield* mergePluginOrigins(dir, list) } - yield* Option.match(configEnv.inlineConfigContent, { - onNone: () => Effect.void, - onSome: (inlineConfigContent) => - Effect.gen(function* () { - const source = "OPENCODE_CONFIG_CONTENT" - const next = yield* loadConfig(inlineConfigContent, { dir: ctx.directory, source }, authEnv) - yield* merge(source, next, "local") - log.debug("loaded custom config from OPENCODE_CONFIG_CONTENT") - }), - }) + if (Option.isSome(loaderEnv.inlineConfigContent)) { + const source = "OPENCODE_CONFIG_CONTENT" + const next = yield* loadConfig(loaderEnv.inlineConfigContent.value, { dir: ctx.directory, source }, authEnv) + yield* merge(source, next, "local") + log.debug("loaded custom config from OPENCODE_CONFIG_CONTENT") + } const activeAccount = Option.getOrUndefined( yield* accountSvc.active().pipe(Effect.catch(() => Effect.succeed(Option.none()))), @@ -700,10 +691,9 @@ export const layer = Layer.effect( [accountSvc.config(accountID, orgID), accountSvc.token(accountID)], { concurrency: 2 }, ) - const accountEnv = Option.match(tokenOpt, { - onNone: () => authEnv, - onSome: (token) => ({ ...authEnv, OPENCODE_CONSOLE_TOKEN: token }), - }) + const accountEnv = Option.isSome(tokenOpt) + ? { ...authEnv, OPENCODE_CONSOLE_TOKEN: tokenOpt.value } + : authEnv if (Option.isSome(tokenOpt)) yield* env.set("OPENCODE_CONSOLE_TOKEN", tokenOpt.value) if (Option.isSome(configOpt)) { @@ -758,16 +748,13 @@ export const layer = Layer.effect( }) } - Option.match(configEnv.permission, { - onNone: () => undefined, - onSome: (permission) => { - try { - result.permission = mergeDeep(result.permission ?? {}, JSON.parse(permission)) - } catch (err) { - log.warn("OPENCODE_PERMISSION contains invalid JSON, skipping", { err }) - } - }, - }) + if (Option.isSome(loaderEnv.permission)) { + try { + result.permission = mergeDeep(result.permission ?? {}, JSON.parse(loaderEnv.permission.value)) + } catch (err) { + log.warn("OPENCODE_PERMISSION contains invalid JSON, skipping", { err }) + } + } if (result.tools) { const perms: Record = {} @@ -888,7 +875,7 @@ export const defaultLayer = layer.pipe( Layer.provide(EffectFlock.defaultLayer), Layer.provide(AppFileSystem.defaultLayer), Layer.provide(Env.defaultLayer), - Layer.provide(ConfigEnv.defaultLayer), + Layer.provide(ConfigLoaderEnv.defaultLayer), Layer.provide(Auth.defaultLayer), Layer.provide(Account.defaultLayer), Layer.provide(Npm.defaultLayer), diff --git a/packages/opencode/src/config/env.ts b/packages/opencode/src/config/loader-env.ts similarity index 93% rename from packages/opencode/src/config/env.ts rename to packages/opencode/src/config/loader-env.ts index 420c7d4ddd1c..d5ff7a596bc0 100644 --- a/packages/opencode/src/config/env.ts +++ b/packages/opencode/src/config/loader-env.ts @@ -1,4 +1,4 @@ -export * as ConfigEnv from "./env" +export * as ConfigLoaderEnv from "./loader-env" import { Config as EffectConfig, ConfigProvider, Context, Effect, Layer } from "effect" import { ConfigService } from "@/effect/config-service" @@ -11,7 +11,7 @@ const fields = { permission: EffectConfig.string("OPENCODE_PERMISSION").pipe(EffectConfig.option), } -export class Service extends ConfigService.Service()("@opencode/ConfigEnv", fields) {} +export class Service extends ConfigService.Service()("@opencode/ConfigLoaderEnv", fields) {} export type Info = Context.Service.Shape diff --git a/packages/opencode/test/agent/plugin-agent-regression.test.ts b/packages/opencode/test/agent/plugin-agent-regression.test.ts index 0441360337c4..51d1e08b1b3b 100644 --- a/packages/opencode/test/agent/plugin-agent-regression.test.ts +++ b/packages/opencode/test/agent/plugin-agent-regression.test.ts @@ -7,7 +7,7 @@ import { pathToFileURL } from "url" import { Agent } from "../../src/agent/agent" import { Bus } from "../../src/bus" import { Config } from "../../src/config/config" -import { ConfigEnv } from "../../src/config/env" +import { ConfigLoaderEnv } from "../../src/config/loader-env" import { Env } from "../../src/env" import { RuntimeFlags } from "../../src/effect/runtime-flags" import { Plugin } from "../../src/plugin" @@ -28,7 +28,7 @@ const provider = ProviderTest.fake() const configLayer = Config.layer.pipe( Layer.provide(AppFileSystem.defaultLayer), Layer.provide(Env.defaultLayer), - Layer.provide(ConfigEnv.defaultLayer), + Layer.provide(ConfigLoaderEnv.defaultLayer), Layer.provide(AuthTest.empty), Layer.provide(AccountTest.empty), Layer.provide(NpmTest.noop), diff --git a/packages/opencode/test/config/config.test.ts b/packages/opencode/test/config/config.test.ts index 22ce1df6eac1..d4d497c8797b 100644 --- a/packages/opencode/test/config/config.test.ts +++ b/packages/opencode/test/config/config.test.ts @@ -3,7 +3,7 @@ import { Effect, Exit, Layer, Option } from "effect" import { FetchHttpClient, HttpClient, HttpClientResponse } from "effect/unstable/http" import { NodeFileSystem, NodePath } from "@effect/platform-node" import { Config } from "@/config/config" -import { ConfigEnv } from "@/config/env" +import { ConfigLoaderEnv } from "@/config/loader-env" import { ConfigManaged } from "@/config/managed" import { ConfigParse } from "../../src/config/parse" import { EffectFlock } from "@opencode-ai/core/util/effect-flock" @@ -57,8 +57,8 @@ const json = (request: Parameters[0], body: u }), ) -const configEnvLayer = (input: Partial = {}) => - ConfigEnv.layer({ +const loaderEnvLayer = (input: Partial = {}) => + ConfigLoaderEnv.layer({ config: Option.none(), configDir: Option.none(), inlineConfigContent: Option.none(), @@ -99,14 +99,14 @@ const configLayer = ( auth?: Layer.Layer account?: Layer.Layer client?: HttpClient.HttpClient - configEnv?: Layer.Layer + loaderEnv?: Layer.Layer } = {}, ) => Config.layer.pipe( Layer.provide(testFlock), Layer.provide(AppFileSystem.defaultLayer), Layer.provide(Env.defaultLayer), - Layer.provide(options.configEnv ?? ConfigEnv.defaultLayer), + Layer.provide(options.loaderEnv ?? ConfigLoaderEnv.defaultLayer), Layer.provide(options.auth ?? AuthTest.empty), Layer.provide(options.account ?? AccountTest.empty), Layer.provideMerge(infra), @@ -264,7 +264,7 @@ test("does not create global config when OPENCODE_CONFIG_DIR is set", async () = await withTestInstance({ directory: tmp.path, fn: async (ctx) => { - await loadWith(ctx, configLayer({ configEnv: configEnvLayer({ configDir: Option.some(custom.path) }) })) + await loadWith(ctx, configLayer({ loaderEnv: loaderEnvLayer({ configDir: Option.some(custom.path) }) })) }, }) @@ -1680,7 +1680,7 @@ test("remote well-known config can use FetchHttpClient layer", async () => { Layer.provide(testFlock), Layer.provide(AppFileSystem.defaultLayer), Layer.provide(Env.defaultLayer), - Layer.provide(ConfigEnv.defaultLayer), + Layer.provide(ConfigLoaderEnv.defaultLayer), Layer.provide(wellKnownAuth(server.url.origin)), Layer.provide(AccountTest.empty), Layer.provideMerge(infra), @@ -1994,7 +1994,7 @@ describe("OPENCODE_DISABLE_PROJECT_CONFIG", () => { { config: { model: "project/model", username: "project-user" } }, ).pipe( Effect.scoped, - Effect.provide(configLayer({ configEnv: configEnvLayer({ disableProjectConfig: true }) })), + Effect.provide(configLayer({ loaderEnv: loaderEnvLayer({ disableProjectConfig: true }) })), Effect.runPromise, ) }) @@ -2015,7 +2015,7 @@ describe("OPENCODE_DISABLE_PROJECT_CONFIG", () => { ), ).pipe( Effect.scoped, - Effect.provide(configLayer({ configEnv: configEnvLayer({ disableProjectConfig: true }) })), + Effect.provide(configLayer({ loaderEnv: loaderEnvLayer({ disableProjectConfig: true }) })), Effect.runPromise, ) }) @@ -2031,7 +2031,7 @@ describe("OPENCODE_DISABLE_PROJECT_CONFIG", () => { ), ).pipe( Effect.scoped, - Effect.provide(configLayer({ configEnv: configEnvLayer({ disableProjectConfig: true }) })), + Effect.provide(configLayer({ loaderEnv: loaderEnvLayer({ disableProjectConfig: true }) })), Effect.runPromise, ) }) @@ -2049,7 +2049,7 @@ describe("OPENCODE_DISABLE_PROJECT_CONFIG", () => { { config: { instructions: ["./CUSTOM.md"] } }, ).pipe( Effect.scoped, - Effect.provide(configLayer({ configEnv: configEnvLayer({ disableProjectConfig: true }) })), + Effect.provide(configLayer({ loaderEnv: loaderEnvLayer({ disableProjectConfig: true }) })), Effect.runPromise, ) }) @@ -2062,7 +2062,10 @@ describe("OPENCODE_DISABLE_PROJECT_CONFIG", () => { const config = yield* Config.Service.use((svc) => svc.get()).pipe( Effect.provide( configLayer({ - configEnv: configEnvLayer({ configDir: Option.some(configDir), disableProjectConfig: true }), + loaderEnv: loaderEnvLayer({ + configDir: Option.some(configDir), + disableProjectConfig: true, + }), }), ), ) @@ -2089,14 +2092,14 @@ describe("OPENCODE_PERMISSION env var", () => { ), ).pipe( Effect.scoped, - Effect.provide(configLayer({ configEnv: configEnvLayer({ permission: Option.some("{invalid") }) })), + Effect.provide(configLayer({ loaderEnv: loaderEnvLayer({ permission: Option.some("{invalid") }) })), Effect.runPromise, ) }) }) describe("OPENCODE_CONFIG_CONTENT token substitution", () => { - test("loads inline config from ConfigEnv service", async () => { + test("loads inline config from ConfigLoaderEnv service", async () => { await provideTmpdirInstance(() => Config.Service.use((svc) => Effect.gen(function* () { @@ -2108,7 +2111,7 @@ describe("OPENCODE_CONFIG_CONTENT token substitution", () => { Effect.scoped, Effect.provide( configLayer({ - configEnv: configEnvLayer({ + loaderEnv: loaderEnvLayer({ inlineConfigContent: Option.some( JSON.stringify({ $schema: "https://opencode.ai/config.json", username: "inline-config-user" }), ), @@ -2120,37 +2123,33 @@ describe("OPENCODE_CONFIG_CONTENT token substitution", () => { }) test("substitutes {env:} tokens in OPENCODE_CONFIG_CONTENT", async () => { - const originalTestVar = process.env["TEST_CONFIG_VAR"] - process.env["TEST_CONFIG_VAR"] = "test_api_key_12345" - - try { - await provideTmpdirInstance(() => + await withProcessEnv( + "TEST_CONFIG_VAR", + "test_api_key_12345", + provideTmpdirInstance(() => Config.Service.use((svc) => Effect.gen(function* () { const config = yield* svc.get() expect(config.username).toBe("test_api_key_12345") }), ), - ).pipe( - Effect.scoped, - Effect.provide( - configLayer({ - configEnv: configEnvLayer({ - inlineConfigContent: Option.some( - JSON.stringify({ - $schema: "https://opencode.ai/config.json", - username: "{env:TEST_CONFIG_VAR}", - }), - ), - }), + ), + ).pipe( + Effect.scoped, + Effect.provide( + configLayer({ + loaderEnv: loaderEnvLayer({ + inlineConfigContent: Option.some( + JSON.stringify({ + $schema: "https://opencode.ai/config.json", + username: "{env:TEST_CONFIG_VAR}", + }), + ), }), - ), - Effect.runPromise, - ) - } finally { - if (originalTestVar !== undefined) process.env["TEST_CONFIG_VAR"] = originalTestVar - else delete process.env["TEST_CONFIG_VAR"] - } + }), + ), + Effect.runPromise, + ) }) test("substitutes {file:} tokens in OPENCODE_CONFIG_CONTENT", async () => { @@ -2166,7 +2165,7 @@ describe("OPENCODE_CONFIG_CONTENT token substitution", () => { Effect.scoped, Effect.provide( configLayer({ - configEnv: configEnvLayer({ + loaderEnv: loaderEnvLayer({ inlineConfigContent: Option.some( JSON.stringify({ $schema: "https://opencode.ai/config.json", diff --git a/packages/opencode/test/plugin/trigger.test.ts b/packages/opencode/test/plugin/trigger.test.ts index d1667e8ba003..a2b22f71fcbd 100644 --- a/packages/opencode/test/plugin/trigger.test.ts +++ b/packages/opencode/test/plugin/trigger.test.ts @@ -8,7 +8,7 @@ import path from "path" import { pathToFileURL } from "url" import { Bus } from "../../src/bus" import { Config } from "../../src/config/config" -import { ConfigEnv } from "../../src/config/env" +import { ConfigLoaderEnv } from "../../src/config/loader-env" import { Env } from "../../src/env" import { RuntimeFlags } from "../../src/effect/runtime-flags" import { Plugin } from "../../src/plugin/index" @@ -23,7 +23,7 @@ const configLayer = Config.layer.pipe( Layer.provide(EffectFlock.defaultLayer), Layer.provide(AppFileSystem.defaultLayer), Layer.provide(Env.defaultLayer), - Layer.provide(ConfigEnv.defaultLayer), + Layer.provide(ConfigLoaderEnv.defaultLayer), Layer.provide(AuthTest.empty), Layer.provide(AccountTest.empty), Layer.provide(NpmTest.noop), diff --git a/packages/opencode/test/plugin/workspace-adapter.test.ts b/packages/opencode/test/plugin/workspace-adapter.test.ts index 499bd5bffd0e..9f3811eebf2e 100644 --- a/packages/opencode/test/plugin/workspace-adapter.test.ts +++ b/packages/opencode/test/plugin/workspace-adapter.test.ts @@ -9,7 +9,7 @@ import { pathToFileURL } from "url" import { Auth } from "../../src/auth" import { Bus } from "../../src/bus" import { Config } from "../../src/config/config" -import { ConfigEnv } from "../../src/config/env" +import { ConfigLoaderEnv } from "../../src/config/loader-env" import { Env } from "../../src/env" import { RuntimeFlags } from "../../src/effect/runtime-flags" import { Workspace } from "../../src/control-plane/workspace" @@ -32,7 +32,7 @@ const configLayer = Config.layer.pipe( Layer.provide(EffectFlock.defaultLayer), Layer.provide(AppFileSystem.defaultLayer), Layer.provide(Env.defaultLayer), - Layer.provide(ConfigEnv.defaultLayer), + Layer.provide(ConfigLoaderEnv.defaultLayer), Layer.provide(AuthTest.empty), Layer.provide(AccountTest.empty), Layer.provide(NpmTest.noop),