diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 349b7e6a074e..18ba8d04ec0c 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 { ConfigLoaderEnv } from "./loader-env" import { ConfigFormatter } from "./formatter" import { ConfigLayout } from "./layout" import { ConfigLSP } from "./lsp" @@ -385,6 +386,7 @@ export const layer = Layer.effect( const authSvc = yield* Auth.Service const accountSvc = yield* Account.Service const env = yield* Env.Service + const loaderEnv = yield* ConfigLoaderEnv.Service const npmSvc = yield* Npm.Service const http = yield* HttpClient.HttpClient @@ -444,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 (!Flag.OPENCODE_CONFIG && !Flag.OPENCODE_CONFIG_DIR && !Flag.OPENCODE_CONFIG_CONTENT) { + if ( + Option.isNone(loaderEnv.config) && + Option.isNone(loaderEnv.configDir) && + Option.isNone(loaderEnv.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 (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 (!Flag.OPENCODE_DISABLE_PROJECT_CONFIG) { + 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") } @@ -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: loaderEnv.configDir, + disableProjectConfig: loaderEnv.disableProjectConfig, + }) - if (Flag.OPENCODE_CONFIG_DIR) { - log.debug("loading config from OPENCODE_CONFIG_DIR", { path: Flag.OPENCODE_CONFIG_DIR }) + 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") || dir === Flag.OPENCODE_CONFIG_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}`) @@ -663,12 +672,9 @@ export const layer = Layer.effect( yield* mergePluginOrigins(dir, list) } - if (process.env.OPENCODE_CONFIG_CONTENT) { + if (Option.isSome(loaderEnv.inlineConfigContent)) { const source = "OPENCODE_CONFIG_CONTENT" - const next = yield* loadConfig(process.env.OPENCODE_CONFIG_CONTENT, { - dir: ctx.directory, - source, - }) + 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") } @@ -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 (Option.isSome(loaderEnv.permission)) { try { - result.permission = mergeDeep(result.permission ?? {}, JSON.parse(Flag.OPENCODE_PERMISSION)) + result.permission = mergeDeep(result.permission ?? {}, JSON.parse(loaderEnv.permission.value)) } 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(ConfigLoaderEnv.defaultLayer), Layer.provide(Auth.defaultLayer), Layer.provide(Account.defaultLayer), Layer.provide(Npm.defaultLayer), diff --git a/packages/opencode/src/config/loader-env.ts b/packages/opencode/src/config/loader-env.ts new file mode 100644 index 000000000000..d5ff7a596bc0 --- /dev/null +++ b/packages/opencode/src/config/loader-env.ts @@ -0,0 +1,28 @@ +export * as ConfigLoaderEnv from "./loader-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/ConfigLoaderEnv", 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..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* ( @@ -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?: Option.Option; disableProjectConfig?: boolean } = {}, +) { const afs = yield* AppFileSystem.Service + const configDir = options.configDir ?? Option.fromUndefinedOr(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] : []), + ...Option.toArray(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..51d1e08b1b3b 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 { ConfigLoaderEnv } from "../../src/config/loader-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(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 04dcde32e11d..d4d497c8797b 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 { 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" @@ -56,6 +57,16 @@ const json = (request: Parameters[0], body: u }), ) +const loaderEnvLayer = (input: Partial = {}) => + ConfigLoaderEnv.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 + loaderEnv?: Layer.Layer } = {}, ) => Config.layer.pipe( Layer.provide(testFlock), Layer.provide(AppFileSystem.defaultLayer), Layer.provide(Env.defaultLayer), + Layer.provide(options.loaderEnv ?? ConfigLoaderEnv.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({ loaderEnv: loaderEnvLayer({ 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(ConfigLoaderEnv.defaultLayer), Layer.provide(wellKnownAuth(server.url.origin)), Layer.provide(AccountTest.empty), Layer.provideMerge(infra), @@ -1976,136 +1981,203 @@ 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({ loaderEnv: loaderEnvLayer({ 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({ loaderEnv: loaderEnvLayer({ 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({ loaderEnv: loaderEnvLayer({ 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({ loaderEnv: loaderEnvLayer({ 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({ + loaderEnv: loaderEnvLayer({ + 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({ loaderEnv: loaderEnvLayer({ 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 ConfigLoaderEnv 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") }), ), - ), - ) + ).pipe( + Effect.scoped, + Effect.provide( + configLayer({ + loaderEnv: loaderEnvLayer({ + inlineConfigContent: Option.some( + JSON.stringify({ $schema: "https://opencode.ai/config.json", username: "inline-config-user" }), + ), + }), + }), + ), + Effect.runPromise, + ) + }) - 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}", + test("substitutes {env:} tokens in OPENCODE_CONFIG_CONTENT", async () => { + 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({ + loaderEnv: loaderEnvLayer({ + inlineConfigContent: Option.some( + JSON.stringify({ + $schema: "https://opencode.ai/config.json", + username: "{env:TEST_CONFIG_VAR}", + }), + ), + }), }), + ), + Effect.runPromise, + ) + }) + + 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({ + loaderEnv: loaderEnvLayer({ + 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..a2b22f71fcbd 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 { ConfigLoaderEnv } from "../../src/config/loader-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(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 79964d3deeb7..9f3811eebf2e 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 { 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" @@ -31,6 +32,7 @@ const configLayer = Config.layer.pipe( Layer.provide(EffectFlock.defaultLayer), Layer.provide(AppFileSystem.defaultLayer), Layer.provide(Env.defaultLayer), + Layer.provide(ConfigLoaderEnv.defaultLayer), Layer.provide(AuthTest.empty), Layer.provide(AccountTest.empty), Layer.provide(NpmTest.noop),