From 9445b4d9402ef1968ca822e9fda5578074a48895 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Mon, 20 Jul 2026 21:05:13 -0400 Subject: [PATCH] refactor(server): inject config options --- packages/cli/src/commands/handlers/mcp/add.ts | 3 +- packages/cli/src/index.ts | 9 ++++- packages/cli/src/server-process.ts | 15 ++++++- packages/core/src/config.ts | 11 +++-- packages/core/src/effect/layer-node.ts | 21 +++++----- packages/core/src/filesystem/search.ts | 10 ++--- packages/core/src/filesystem/watcher.ts | 6 +-- packages/core/src/global.ts | 5 +-- packages/core/src/instruction-discovery.ts | 16 ++++++-- packages/core/src/models-dev.ts | 6 +-- packages/core/test/config/config.test.ts | 40 +++++++++++++++++++ .../test/effect/layer-node/layer-node.test.ts | 11 +++++ .../core/test/instruction-discovery.test.ts | 11 ++--- packages/core/test/models.test.ts | 2 +- packages/core/test/plugin/models-dev.test.ts | 2 +- packages/server/src/options.ts | 6 +++ packages/server/src/routes.ts | 12 ++++-- 17 files changed, 133 insertions(+), 53 deletions(-) diff --git a/packages/cli/src/commands/handlers/mcp/add.ts b/packages/cli/src/commands/handlers/mcp/add.ts index dc582a0e00..348bca228d 100644 --- a/packages/cli/src/commands/handlers/mcp/add.ts +++ b/packages/cli/src/commands/handlers/mcp/add.ts @@ -30,7 +30,8 @@ export default Runtime.handler( ? { type: "remote" as const, url, ...(headers ? { headers } : {}) } : { type: "local" as const, command, ...(environment ? { environment } : {}) } - const configPath = yield* Effect.promise(() => resolveConfigPath(input.global ? Global.Path.config : process.cwd())) + const global = yield* Global.Service + const configPath = yield* Effect.promise(() => resolveConfigPath(input.global ? global.config : process.cwd())) yield* Effect.promise(() => write(configPath, input.name, server)) process.stdout.write(`MCP server "${input.name}" added to ${configPath}` + EOL) }), diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 0ca2b66de1..cea7992f82 100755 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -60,7 +60,14 @@ Effect.logInfo("cli starting", { Effect.annotateLogs({ role: "cli" }), Effect.provide(Config.layer), Effect.provide(Updater.layer), - Effect.provide(LayerNode.compile(LayerNode.group([Global.node, AppProcess.node, Npm.node]))), + Effect.provide( + LayerNode.compile(LayerNode.group([Global.node, AppProcess.node, Npm.node]), [ + [ + Global.node, + Global.layerWith(process.env.OPENCODE_CONFIG_DIR ? { config: process.env.OPENCODE_CONFIG_DIR } : {}), + ], + ]), + ), Effect.provide( Observability.layer({ endpoint: process.env.OTEL_EXPORTER_OTLP_ENDPOINT, diff --git a/packages/cli/src/server-process.ts b/packages/cli/src/server-process.ts index 3c5663f56d..e9d1f27c1b 100644 --- a/packages/cli/src/server-process.ts +++ b/packages/cli/src/server-process.ts @@ -26,7 +26,14 @@ export type Options = { export const run = Effect.fnUntraced(function* (options: Options) { return yield* processEffect(options).pipe( Effect.provide(Updater.layer), - Effect.provide(LayerNode.compile(LayerNode.group([Global.node, AppProcess.node]))), + Effect.provide( + LayerNode.compile(LayerNode.group([Global.node, AppProcess.node]), [ + [ + Global.node, + Global.layerWith(process.env.OPENCODE_CONFIG_DIR ? { config: process.env.OPENCODE_CONFIG_DIR } : {}), + ], + ]), + ), Effect.provide(NodeServices.layer), ) }) @@ -77,6 +84,12 @@ const processEffect = Effect.fnUntraced(function* (options: Options) { endpoint: process.env.OTEL_EXPORTER_OTLP_ENDPOINT, headers: process.env.OTEL_EXPORTER_OTLP_HEADERS, }, + config: { + directory: process.env.OPENCODE_CONFIG_DIR, + project: !truthy( + process.env.OPENCODE_CONFIG_PROJECT_DISABLE ?? process.env.OPENCODE_DISABLE_PROJECT_CONFIG, + ), + }, fs: { filewatcher: !truthy( process.env.OPENCODE_FILEWATCHER_DISABLE ?? process.env.OPENCODE_DISABLE_FILEWATCHER, diff --git a/packages/core/src/config.ts b/packages/core/src/config.ts index b3f762b228..2d70a31973 100644 --- a/packages/core/src/config.ts +++ b/packages/core/src/config.ts @@ -153,9 +153,14 @@ export interface Interface { readonly entries: () => Effect.Effect } +export const Options = Schema.Struct({ + project: Schema.optional(Schema.Boolean), +}) +export type Options = typeof Options.Type + export class Service extends Context.Service()("@opencode/v2/Config") {} -const layer = Layer.effect( +export const layer = (options?: Options) => Layer.effect( Service, Effect.gen(function* () { const fs = yield* FSUtil.Service @@ -239,7 +244,7 @@ const layer = Layer.effect( const globalAgentsDirectory = AbsolutePath.make(path.join(global.home, ".agents")) const globalClaudeDirectory = AbsolutePath.make(path.join(global.home, ".claude")) const locationIsGlobal = path.resolve(location.directory) === path.resolve(global.config) - const discovered = locationIsGlobal + const discovered = locationIsGlobal || options?.project === false ? [] : yield* fs .up({ @@ -396,6 +401,6 @@ const layer = Layer.effect( export const node = makeLocationNode({ service: Service, - layer, + layer: layer(), deps: [Watcher.node, EventV2.node, FSUtil.node, Global.node, Location.node, Credential.node, WellKnown.node], }) diff --git a/packages/core/src/effect/layer-node.ts b/packages/core/src/effect/layer-node.ts index dc7aa972f0..6ab6c22a7b 100644 --- a/packages/core/src/effect/layer-node.ts +++ b/packages/core/src/effect/layer-node.ts @@ -121,7 +121,7 @@ type CheckReplacementErrors = [Exclude = Item extends readonly [Node, infer Replacement] ? Replacement extends Node, infer E2, T> ? CheckReplacementErrors> - : Replacement extends Layer.Layer, infer E2, never> + : Replacement extends Layer.Layer, infer E2, infer _R> ? CheckReplacementErrors> : { readonly "Invalid replacement": Replacement } : { readonly "Invalid replacement": Item } @@ -133,14 +133,16 @@ type CheckReplacements = { type ValidReplacements = Items & CheckReplacements function replacementNode(source: AnyNode, replacement: AnyNode | Layer.Any) { - const replacementNode = isNode(replacement) + const replacementNode: AnyNode = isNode(replacement) ? replacement - : make({ - ...nodeMakeIdentity(source), - layer: replacement as Layer.Layer, - deps: [], + : { + kind: "layer", + name: source.name, + service: source.service, + implementation: replacement, + dependencies: source.dependencies, tag: source.tag, - }) + } if (source.name !== replacementNode.name) { throw new Error(`Cannot replace ${source.name} with ${replacementNode.name}`) } @@ -150,11 +152,6 @@ function replacementNode(source: AnyNode, replacement: AnyNode | Layer.Any) { return replacementNode } -function nodeMakeIdentity(node: AnyNode): NodeIdentity { - if (node.service !== undefined) return { service: node.service } - return { name: node.name } -} - function isNode(input: Layer.Any | AnyNode): input is AnyNode { return "kind" in input && "dependencies" in input } diff --git a/packages/core/src/filesystem/search.ts b/packages/core/src/filesystem/search.ts index 28241ca380..6a1e36ea14 100644 --- a/packages/core/src/filesystem/search.ts +++ b/packages/core/src/filesystem/search.ts @@ -246,8 +246,8 @@ export const layer = (options?: Options) => Layer.unwrap( }), ) -export function nodeWith(options?: Options) { - return makeLocationNode({ service: Service, layer: layer(options), deps: [FSUtil.node, Location.node, Ripgrep.node] }) -} - -export const node = nodeWith() +export const node = makeLocationNode({ + service: Service, + layer: layer(), + deps: [FSUtil.node, Location.node, Ripgrep.node], +}) diff --git a/packages/core/src/filesystem/watcher.ts b/packages/core/src/filesystem/watcher.ts index 4c0c72ce30..cea9021403 100644 --- a/packages/core/src/filesystem/watcher.ts +++ b/packages/core/src/filesystem/watcher.ts @@ -144,11 +144,7 @@ export const layer = (options?: Options) => Layer.effect( }), ) -export function nodeWith(options?: Options) { - return makeGlobalNode({ service: Service, layer: layer(options), deps: [] }) -} - -export const node = nodeWith() +export const node = makeGlobalNode({ service: Service, layer: layer(), deps: [] }) function subscribeDirectory( native: typeof import("@parcel/watcher") | undefined, diff --git a/packages/core/src/global.ts b/packages/core/src/global.ts index a192a4b468..e3909880d3 100644 --- a/packages/core/src/global.ts +++ b/packages/core/src/global.ts @@ -4,7 +4,6 @@ import { xdgData, xdgCache, xdgConfig, xdgState } from "xdg-basedir" import os from "os" import { Context, Effect, Layer } from "effect" import { Flock } from "./util/flock" -import { Flag } from "./flag/flag" import { makeGlobalNode } from "./effect/app-node" const app = "opencode" @@ -61,7 +60,7 @@ export function make(input: Partial = {}): Interface { home: Path.home, data: Path.data, cache: Path.cache, - config: Flag.OPENCODE_CONFIG_DIR ?? Path.config, + config: Path.config, state: Path.state, tmp: Path.tmp, bin: Path.bin, @@ -73,7 +72,7 @@ export function make(input: Partial = {}): Interface { const layer = Layer.effect( Service, - Effect.sync(() => Service.of(make())), + Effect.sync(() => Service.of(make({ config: process.env.OPENCODE_CONFIG_DIR ?? Path.config }))), ) export const node = makeGlobalNode({ service: Service, layer: layer, deps: [] }) diff --git a/packages/core/src/instruction-discovery.ts b/packages/core/src/instruction-discovery.ts index f838083b40..3a4e9ed394 100644 --- a/packages/core/src/instruction-discovery.ts +++ b/packages/core/src/instruction-discovery.ts @@ -3,7 +3,6 @@ export * as InstructionDiscovery from "./instruction-discovery" import { Array, Context, Effect, Layer, Schema } from "effect" import { isAbsolute, join, relative, sep } from "path" import { FSUtil } from "./fs-util" -import { Flag } from "./flag/flag" import { Global } from "./global" import { Location } from "./location" import { AbsolutePath } from "./schema" @@ -22,9 +21,14 @@ export interface Interface { readonly load: () => Effect.Effect } +export const Options = Schema.Struct({ + project: Schema.optional(Schema.Boolean), +}) +export type Options = typeof Options.Type + export class Service extends Context.Service()("@opencode/v2/InstructionDiscovery") {} -const layer = Layer.effect( +export const layer = (options?: Options) => Layer.effect( Service, Effect.gen(function* () { const fs = yield* FSUtil.Service @@ -52,7 +56,7 @@ const layer = Layer.effect( fromProject === "" || (fromProject !== ".." && !fromProject.startsWith(`..${sep}`) && !isAbsolute(fromProject)) const discovered = new Set( yield* Effect.forEach( - Flag.OPENCODE_DISABLE_PROJECT_CONFIG || !insideProject + options?.project === false || !insideProject ? [] : yield* fs.up({ targets: ["AGENTS.md"], @@ -93,7 +97,11 @@ const layer = Layer.effect( }), ) -export const node = makeLocationNode({ service: Service, layer, deps: [FSUtil.node, Global.node, Location.node] }) +export const node = makeLocationNode({ + service: Service, + layer: layer(), + deps: [FSUtil.node, Global.node, Location.node], +}) function render(files: ReadonlyArray) { return files.map((file) => `Instructions from: ${file.path}\n${file.content}`).join("\n\n") diff --git a/packages/core/src/models-dev.ts b/packages/core/src/models-dev.ts index bef11207b5..670face8b0 100644 --- a/packages/core/src/models-dev.ts +++ b/packages/core/src/models-dev.ts @@ -659,10 +659,6 @@ export const layer = (options?: Options) => Layer.effect( }), ) -export function nodeWith(options?: Options) { - return makeGlobalNode({ service: Service, layer: layer(options), deps: [FSUtil.node, EventV2.node, httpClient] }) -} - -export const node = nodeWith() +export const node = makeGlobalNode({ service: Service, layer: layer(), deps: [FSUtil.node, EventV2.node, httpClient] }) export * as ModelsDev from "./models-dev" diff --git a/packages/core/test/config/config.test.ts b/packages/core/test/config/config.test.ts index fec2e80dd8..86b88e1426 100644 --- a/packages/core/test/config/config.test.ts +++ b/packages/core/test/config/config.test.ts @@ -70,6 +70,7 @@ function testLayer( watcher?: Layer.Layer, credentialNode = emptyCredentialNode, wellknownNode = emptyWellknownNode, + options?: Config.Options, ) { const locationLayer = Layer.succeed( Location.Service, @@ -81,6 +82,7 @@ function testLayer( ), ) return AppNodeBuilder.build(LayerNode.group([Config.node, EventV2.node]), [ + [Config.node, Config.layer(options)], [Location.node, locationLayer], [Global.node, Global.layerWith({ config: globalDirectory, home: path.join(globalDirectory, "home") })], [Credential.node, credentialNode], @@ -98,6 +100,44 @@ const provider = { } describe("Config", () => { + it.live("skips project configuration when project discovery is disabled", () => + Effect.acquireRelease( + Effect.promise(() => tmpdir()), + (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), + ).pipe( + Effect.flatMap((tmp) => { + const global = path.join(tmp.path, "global") + const project = path.join(tmp.path, "project") + return Effect.promise(async () => { + await fs.mkdir(global, { recursive: true }) + await fs.mkdir(project, { recursive: true }) + await fs.writeFile(path.join(global, "opencode.json"), JSON.stringify({ shell: "global" })) + await fs.writeFile(path.join(project, "opencode.json"), JSON.stringify({ shell: "project" })) + }).pipe( + Effect.andThen( + Effect.gen(function* () { + const config = yield* Config.Service + expect(Config.latest(yield* config.entries(), "shell")).toBe("global") + }).pipe( + Effect.provide( + testLayer( + project, + global, + project, + undefined, + undefined, + emptyCredentialNode, + emptyWellknownNode, + { project: false }, + ), + ), + ), + ), + ) + }), + ), + ) + it.live("reloads external config and publishes directory updates", () => Effect.acquireRelease( Effect.promise(() => tmpdir()), diff --git a/packages/core/test/effect/layer-node/layer-node.test.ts b/packages/core/test/effect/layer-node/layer-node.test.ts index b671792c59..b6d94cfd56 100644 --- a/packages/core/test/effect/layer-node/layer-node.test.ts +++ b/packages/core/test/effect/layer-node/layer-node.test.ts @@ -81,6 +81,17 @@ describe("layer node", () => { expect(await Effect.runPromise(program)).toBe("hello simulation") }) + test("preserves source dependencies when replacing a node with a layer", async () => { + const replacement = Layer.effect( + Greeting, + Effect.map(Value, (item) => Greeting.of({ value: `replaced ${item.value}` })), + ) + const program = Effect.map(Greeting, (item) => item.value).pipe( + Effect.provide(build(LayerNode.group([greeting]), [[greeting, replacement]])), + ) + expect(await Effect.runPromise(program)).toBe("replaced production") + }) + test("replaces every use of the same layer", async () => { const leftLayer = Layer.effect( Left, diff --git a/packages/core/test/instruction-discovery.test.ts b/packages/core/test/instruction-discovery.test.ts index bc8724a837..41f7d5a3ac 100644 --- a/packages/core/test/instruction-discovery.test.ts +++ b/packages/core/test/instruction-discovery.test.ts @@ -20,8 +20,10 @@ const instructionLayer = (input: { config: string locationServiceLayer: Layer.Layer filesystemLayer?: Layer.Layer + project?: boolean }) => AppNodeBuilder.build(InstructionDiscovery.node, [ + [InstructionDiscovery.node, InstructionDiscovery.layer({ project: input.project })], [Global.node, Global.layerWith({ config: input.config })], [Location.node, input.locationServiceLayer], ...(input.filesystemLayer ? [[FSUtil.node, input.filesystemLayer] as const] : []), @@ -242,15 +244,14 @@ describe("InstructionDiscovery", () => { it.effect("honors the project instruction opt-out", () => Effect.gen(function* () { - const previous = process.env.OPENCODE_DISABLE_PROJECT_CONFIG let scanned = false - process.env.OPENCODE_DISABLE_PROJECT_CONFIG = "1" yield* InstructionDiscovery.Service.pipe( Effect.flatMap((service) => service.load()), Effect.provide( instructionLayer({ config: "/global", + project: false, filesystemLayer: Layer.effect( FSUtil.Service, FSUtil.Service.pipe( @@ -263,12 +264,6 @@ describe("InstructionDiscovery", () => { ), }), ), - Effect.ensuring( - Effect.sync(() => { - if (previous === undefined) delete process.env.OPENCODE_DISABLE_PROJECT_CONFIG - else process.env.OPENCODE_DISABLE_PROJECT_CONFIG = previous - }), - ), ) expect(scanned).toBe(false) diff --git a/packages/core/test/models.test.ts b/packages/core/test/models.test.ts index 1733b9b92c..48ae970fe3 100644 --- a/packages/core/test/models.test.ts +++ b/packages/core/test/models.test.ts @@ -161,7 +161,7 @@ const buildLayer = (state: Ref.Ref, options: ModelsDev.Options = { fe // every test would reuse the cachedInvalidateWithTTL state from the first run. Layer.fresh( AppNodeBuilder.build(ModelsDev.node, [ - [ModelsDev.node, ModelsDev.nodeWith(options)], + [ModelsDev.node, ModelsDev.layer(options)], [LayerNodePlatform.httpClient, Layer.succeed(HttpClient.HttpClient, makeMockClient(state))], ]), ) diff --git a/packages/core/test/plugin/models-dev.test.ts b/packages/core/test/plugin/models-dev.test.ts index 8bc84306bf..4ae46a7236 100644 --- a/packages/core/test/plugin/models-dev.test.ts +++ b/packages/core/test/plugin/models-dev.test.ts @@ -26,7 +26,7 @@ const layer = AppNodeBuilder.build(LayerNode.group([Catalog.node, Integration.no ]) const it = testEffect(layer) const models = (file: string) => - AppNodeBuilder.build(ModelsDev.node, [[ModelsDev.node, ModelsDev.nodeWith({ file, fetch: false })]]) + AppNodeBuilder.build(ModelsDev.node, [[ModelsDev.node, ModelsDev.layer({ file, fetch: false })]]) describe("ModelsDevPlugin", () => { it.effect("projects normalized models.dev snapshots into the catalog", () => diff --git a/packages/server/src/options.ts b/packages/server/src/options.ts index 43c8e9213e..7ab33da648 100644 --- a/packages/server/src/options.ts +++ b/packages/server/src/options.ts @@ -12,6 +12,12 @@ export const ServerOptions = Schema.Struct({ database: Schema.optional(Database.Options), models: Schema.optional(ModelsDev.Options), observability: Schema.optional(Observability.Options), + config: Schema.optional( + Schema.Struct({ + directory: Schema.optional(Schema.String), + project: Schema.optional(Schema.Boolean), + }), + ), fs: Schema.optional( Schema.Struct({ filewatcher: Schema.optional(Schema.Boolean), diff --git a/packages/server/src/routes.ts b/packages/server/src/routes.ts index f7f94581be..f57d869a65 100644 --- a/packages/server/src/routes.ts +++ b/packages/server/src/routes.ts @@ -7,11 +7,14 @@ import { EventLogger } from "@opencode-ai/core/event-logger" import { FileSystemSearch } from "@opencode-ai/core/filesystem/search" import { Observability } from "@opencode-ai/core/observability" import { Credential } from "@opencode-ai/core/credential" +import { Config } from "@opencode-ai/core/config" import { PermissionSaved } from "@opencode-ai/core/permission/saved" import { PtyTicket } from "@opencode-ai/core/pty/ticket" import { Project } from "@opencode-ai/core/project" import { SessionV2 } from "@opencode-ai/core/session" import { Job } from "@opencode-ai/core/job" +import { Global } from "@opencode-ai/core/global" +import { InstructionDiscovery } from "@opencode-ai/core/instruction-discovery" import { LocationServiceMap } from "@opencode-ai/core/location-service-map" import { ModelsDev } from "@opencode-ai/core/models-dev" import { SessionRestart } from "@opencode-ai/core/session/execution/restart" @@ -77,9 +80,12 @@ function makeRoutes( const pluginRuntimeCell = PluginRuntime.makeCell() const replacements: LayerNode.Replacements = [ [Database.node, Database.layer(options.database)], - [ModelsDev.node, ModelsDev.nodeWith(options.models)], - [Watcher.node, Watcher.nodeWith({ enabled: options.fs?.filewatcher })], - [FileSystemSearch.node, FileSystemSearch.nodeWith({ fff: options.fs?.fff })], + [ModelsDev.node, ModelsDev.layer(options.models)], + [Watcher.node, Watcher.layer({ enabled: options.fs?.filewatcher })], + [FileSystemSearch.node, FileSystemSearch.layer({ fff: options.fs?.fff })], + [Global.node, Global.layerWith(options.config?.directory ? { config: options.config.directory } : {})], + [Config.node, Config.layer({ project: options.config?.project })], + [InstructionDiscovery.node, InstructionDiscovery.layer({ project: options.config?.project })], [PluginRuntime.node, PluginRuntime.layerWithCell(pluginRuntimeCell)], [PluginRuntime.providerNode, PluginRuntime.providerNodeWithCell(pluginRuntimeCell)], ]