mirror of
https://github.com/anomalyco/opencode.git
synced 2026-07-21 10:16:03 +00:00
refactor(server): inject config options
This commit is contained in:
@@ -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)
|
||||
}),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -153,9 +153,14 @@ export interface Interface {
|
||||
readonly entries: () => Effect.Effect<Entry[]>
|
||||
}
|
||||
|
||||
export const Options = Schema.Struct({
|
||||
project: Schema.optional(Schema.Boolean),
|
||||
})
|
||||
export type Options = typeof Options.Type
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@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],
|
||||
})
|
||||
|
||||
@@ -121,7 +121,7 @@ type CheckReplacementErrors<SourceError, ReplacementError> = [Exclude<Replacemen
|
||||
type CheckReplacement<Item> = Item extends readonly [Node<infer A, infer E, infer T>, infer Replacement]
|
||||
? Replacement extends Node<NoInfer<A>, infer E2, T>
|
||||
? CheckReplacementErrors<E, NoInfer<E2>>
|
||||
: Replacement extends Layer.Layer<NoInfer<A>, infer E2, never>
|
||||
: Replacement extends Layer.Layer<NoInfer<A>, infer E2, infer _R>
|
||||
? CheckReplacementErrors<E, NoInfer<E2>>
|
||||
: { readonly "Invalid replacement": Replacement }
|
||||
: { readonly "Invalid replacement": Item }
|
||||
@@ -133,14 +133,16 @@ type CheckReplacements<Items extends Replacements> = {
|
||||
type ValidReplacements<Items extends Replacements> = Items & CheckReplacements<Items>
|
||||
|
||||
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<unknown, unknown>,
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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],
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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> = {}): 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> = {}): 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: [] })
|
||||
|
||||
@@ -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<Instructions.Instructions>
|
||||
}
|
||||
|
||||
export const Options = Schema.Struct({
|
||||
project: Schema.optional(Schema.Boolean),
|
||||
})
|
||||
export type Options = typeof Options.Type
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@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<File>) {
|
||||
return files.map((file) => `Instructions from: ${file.path}\n${file.content}`).join("\n\n")
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -70,6 +70,7 @@ function testLayer(
|
||||
watcher?: Layer.Layer<Watcher.Service>,
|
||||
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()),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -20,8 +20,10 @@ const instructionLayer = (input: {
|
||||
config: string
|
||||
locationServiceLayer: Layer.Layer<Location.Service>
|
||||
filesystemLayer?: Layer.Layer<FSUtil.Service>
|
||||
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)
|
||||
|
||||
@@ -161,7 +161,7 @@ const buildLayer = (state: Ref.Ref<MockState>, 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))],
|
||||
]),
|
||||
)
|
||||
|
||||
@@ -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", () =>
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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<AuthError, AuthServices>(
|
||||
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)],
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user