refactor(server): migrate host options

This commit is contained in:
Dax Raad 2026-07-20 21:21:36 -04:00
commit 407ac2d8aa
20 changed files with 149 additions and 94 deletions

View file

@ -90,6 +90,9 @@ const processEffect = Effect.fnUntraced(function* (options: Options) {
process.env.OPENCODE_CONFIG_PROJECT_DISABLE ?? process.env.OPENCODE_DISABLE_PROJECT_CONFIG,
),
},
windows: {
gitbash: process.env.OPENCODE_GIT_BASH_PATH,
},
fs: {
filewatcher: !truthy(
process.env.OPENCODE_FILEWATCHER_DISABLE ?? process.env.OPENCODE_DISABLE_FILEWATCHER,

View file

@ -52,7 +52,7 @@ export interface Interface extends State.Transformable<Draft> {
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Command") {}
const layer = Layer.effect(
export const layer = (options?: ShellSelect.Options) => Layer.effect(
Service,
Effect.gen(function* () {
const mcp = yield* MCP.Service
@ -111,6 +111,7 @@ const layer = Layer.effect(
config,
location,
processes,
shell: options,
})
const prompt = (yield* mcp.prompts()).find((prompt) => mcpCommandName(prompt.server, prompt.name) === input.name)
@ -157,6 +158,7 @@ function evaluateTemplate(
readonly config: Config.Interface
readonly location: Location.Info
readonly processes: AppProcess.Interface
readonly shell?: ShellSelect.Options
},
) {
return Effect.gen(function* () {
@ -188,11 +190,12 @@ const evaluateShell = Effect.fnUntraced(function* (
readonly config: Config.Interface
readonly location: Location.Info
readonly processes: AppProcess.Interface
readonly shell?: ShellSelect.Options
},
) {
const matches = Array.from(text.matchAll(shellRegex))
if (matches.length === 0) return text
const shell = ShellSelect.preferred(Config.latest(yield* services.config.entries(), "shell"))
const shell = ShellSelect.preferred(Config.latest(yield* services.config.entries(), "shell"), services.shell)
const outputs = yield* Effect.forEach(
matches,
(match) => {
@ -240,8 +243,12 @@ const placeholderRegex = /\$(\d+)/g
const quoteTrimRegex = /^["']|["']$/g
const shellRegex = /!`([^`]+)`/g
export const node = makeLocationNode({
service: Service,
layer,
deps: [MCP.node, EventV2.node, AppProcess.node, Config.node, Location.node],
})
export function configured(options?: ShellSelect.Options) {
return makeLocationNode({
service: Service,
layer: layer(options),
deps: [MCP.node, EventV2.node, AppProcess.node, Config.node, Location.node],
})
}
export const node = configured()

View file

@ -399,8 +399,12 @@ export const layer = (options?: Options) => Layer.effect(
}),
)
export const node = makeLocationNode({
service: Service,
layer: layer(),
deps: [Watcher.node, EventV2.node, FSUtil.node, Global.node, Location.node, Credential.node, WellKnown.node],
})
export function configured(options?: Options) {
return makeLocationNode({
service: Service,
layer: layer(options),
deps: [Watcher.node, EventV2.node, FSUtil.node, Global.node, Location.node, Credential.node, WellKnown.node],
})
}
export const node = configured()

View file

@ -57,8 +57,8 @@ export function layer(options?: Options) {
})
}
export const node = makeGlobalNode({
service: Service,
layer: layer({ path: ":memory:" }),
deps: [],
})
export function configured(options?: Options) {
return makeGlobalNode({ service: Service, layer: layer(options), deps: [] })
}
export const node = configured({ path: ":memory:" })

View file

@ -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, infer _R>
: Replacement extends Layer.Layer<NoInfer<A>, infer E2, never>
? CheckReplacementErrors<E, NoInfer<E2>>
: { readonly "Invalid replacement": Replacement }
: { readonly "Invalid replacement": Item }
@ -133,16 +133,14 @@ type CheckReplacements<Items extends Replacements> = {
type ValidReplacements<Items extends Replacements> = Items & CheckReplacements<Items>
function replacementNode(source: AnyNode, replacement: AnyNode | Layer.Any) {
const replacementNode: AnyNode = isNode(replacement)
const replacementNode = isNode(replacement)
? replacement
: {
kind: "layer",
name: source.name,
service: source.service,
implementation: replacement,
dependencies: source.dependencies,
: make({
...nodeMakeIdentity(source),
layer: replacement as Layer.Layer<unknown, unknown>,
deps: [],
tag: source.tag,
}
})
if (source.name !== replacementNode.name) {
throw new Error(`Cannot replace ${source.name} with ${replacementNode.name}`)
}
@ -152,6 +150,11 @@ 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
}

View file

@ -246,8 +246,8 @@ export const layer = (options?: Options) => Layer.unwrap(
}),
)
export const node = makeLocationNode({
service: Service,
layer: layer(),
deps: [FSUtil.node, Location.node, Ripgrep.node],
})
export function configured(options?: Options) {
return makeLocationNode({ service: Service, layer: layer(options), deps: [FSUtil.node, Location.node, Ripgrep.node] })
}
export const node = configured()

View file

@ -144,7 +144,11 @@ export const layer = (options?: Options) => Layer.effect(
}),
)
export const node = makeGlobalNode({ service: Service, layer: layer(), deps: [] })
export function configured(options?: Options) {
return makeGlobalNode({ service: Service, layer: layer(options), deps: [] })
}
export const node = configured()
function subscribeDirectory(
native: typeof import("@parcel/watcher") | undefined,

View file

@ -13,10 +13,13 @@ function enabledByExperimental(key: string) {
}
export const Flag = {
// V2: ServerOptions.observability.endpoint
OTEL_EXPORTER_OTLP_ENDPOINT: process.env["OTEL_EXPORTER_OTLP_ENDPOINT"],
// V2: ServerOptions.observability.headers
OTEL_EXPORTER_OTLP_HEADERS: process.env["OTEL_EXPORTER_OTLP_HEADERS"],
OPENCODE_AUTO_HEAP_SNAPSHOT: truthy("OPENCODE_AUTO_HEAP_SNAPSHOT"),
// V2: ServerOptions.windows.gitbash
OPENCODE_GIT_BASH_PATH: process.env["OPENCODE_GIT_BASH_PATH"],
OPENCODE_CONFIG: process.env["OPENCODE_CONFIG"],
OPENCODE_CONFIG_CONTENT: process.env["OPENCODE_CONFIG_CONTENT"],
@ -26,19 +29,25 @@ export const Flag = {
OPENCODE_DISABLE_TERMINAL_TITLE: truthy("OPENCODE_DISABLE_TERMINAL_TITLE"),
OPENCODE_SHOW_TTFD: truthy("OPENCODE_SHOW_TTFD"),
OPENCODE_DISABLE_AUTOCOMPACT: truthy("OPENCODE_DISABLE_AUTOCOMPACT"),
// V2: ServerOptions.models.fetch
OPENCODE_DISABLE_MODELS_FETCH: truthy("OPENCODE_DISABLE_MODELS_FETCH"),
OPENCODE_DISABLE_MOUSE: truthy("OPENCODE_DISABLE_MOUSE"),
OPENCODE_FAKE_VCS: process.env["OPENCODE_FAKE_VCS"],
OPENCODE_SERVER_PASSWORD: process.env["OPENCODE_SERVER_PASSWORD"],
OPENCODE_SERVER_USERNAME: process.env["OPENCODE_SERVER_USERNAME"],
// V2: ServerOptions.fs.fff
OPENCODE_DISABLE_FFF: fff === undefined ? process.platform === "win32" : truthy("OPENCODE_DISABLE_FFF"),
// V2: ServerOptions.fs.filewatcher
OPENCODE_DISABLE_FILEWATCHER: truthy("OPENCODE_DISABLE_FILEWATCHER"),
// Experimental
OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT:
copy === undefined ? process.platform === "win32" : truthy("OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT"),
// V2: ServerOptions.models.url
OPENCODE_MODELS_URL: process.env["OPENCODE_MODELS_URL"],
// V2: ServerOptions.models.file
OPENCODE_MODELS_PATH: process.env["OPENCODE_MODELS_PATH"],
// V2: ServerOptions.database.path
OPENCODE_DB: process.env["OPENCODE_DB"],
OPENCODE_WORKSPACE_ID: process.env["OPENCODE_WORKSPACE_ID"],
@ -46,6 +55,7 @@ export const Flag = {
// Evaluated at access time (not module load) because tests, the CLI, and
// external tooling set these env vars at runtime.
// V2: ServerOptions.config.project
get OPENCODE_DISABLE_PROJECT_CONFIG() {
return truthy("OPENCODE_DISABLE_PROJECT_CONFIG")
},
@ -55,6 +65,7 @@ export const Flag = {
get OPENCODE_TUI_CONFIG() {
return process.env["OPENCODE_TUI_CONFIG"]
},
// V2: ServerOptions.config.directory
get OPENCODE_CONFIG_DIR() {
return process.env["OPENCODE_CONFIG_DIR"]
},

View file

@ -97,11 +97,15 @@ export const layer = (options?: Options) => Layer.effect(
}),
)
export const node = makeLocationNode({
service: Service,
layer: layer(),
deps: [FSUtil.node, Global.node, Location.node],
})
export function configured(options?: Options) {
return makeLocationNode({
service: Service,
layer: layer(options),
deps: [FSUtil.node, Global.node, Location.node],
})
}
export const node = configured()
function render(files: ReadonlyArray<File>) {
return files.map((file) => `Instructions from: ${file.path}\n${file.content}`).join("\n\n")

View file

@ -659,6 +659,10 @@ export const layer = (options?: Options) => Layer.effect(
}),
)
export const node = makeGlobalNode({ service: Service, layer: layer(), deps: [FSUtil.node, EventV2.node, httpClient] })
export function configured(options?: Options) {
return makeGlobalNode({ service: Service, layer: layer(options), deps: [FSUtil.node, EventV2.node, httpClient] })
}
export const node = configured()
export * as ModelsDev from "./models-dev"

View file

@ -89,7 +89,7 @@ export interface Interface {
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Pty") {}
const layer = Layer.effect(
export const layer = (options?: ShellSelect.Options) => Layer.effect(
Service,
Effect.gen(function* () {
const events = yield* EventV2.Service
@ -164,7 +164,7 @@ const layer = Layer.effect(
const create = Effect.fn("Pty.create")(function* (input: CreateInput) {
const id = PtyID.ascending()
const command = input.command || ShellSelect.preferred(Config.latest(yield* config.entries(), "shell"))
const command = input.command || ShellSelect.preferred(Config.latest(yield* config.entries(), "shell"), options)
const args = ShellSelect.login(command) ? [...(input.args ?? []), "-l"] : [...(input.args ?? [])]
const cwd = input.cwd || location.directory
const env = {
@ -313,4 +313,8 @@ const layer = Layer.effect(
}),
)
export const node = makeLocationNode({ service: Service, layer, deps: [EventV2.node, Location.node, Config.node] })
export function configured(options?: ShellSelect.Options) {
return makeLocationNode({ service: Service, layer: layer(options), deps: [EventV2.node, Location.node, Config.node] })
}
export const node = configured()

View file

@ -59,7 +59,7 @@ export interface Interface {
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Shell") {}
export const layer = Layer.effect(
export const layer = (options?: ShellSelect.Options) => Layer.effect(
Service,
Effect.gen(function* () {
const events = yield* EventV2.Service
@ -168,7 +168,7 @@ export const layer = Layer.effect(
const id = Shell.ID.ascending()
const cwd = input.cwd ?? location.directory
const configShell = Config.latest(yield* config.entries(), "shell")
const shell = ShellSelect.preferred(configShell)
const shell = ShellSelect.preferred(configShell, options)
const args = ShellSelect.args(shell, input.command)
const file = path.join(outputDir, `${id}.out`)
const env = {
@ -316,8 +316,12 @@ export const layer = Layer.effect(
}),
)
export const node = makeLocationNode({
service: Service,
layer,
deps: [EventV2.node, Location.node, Config.node, Global.node, AppProcess.node],
})
export function configured(options?: ShellSelect.Options) {
return makeLocationNode({
service: Service,
layer: layer(options),
deps: [EventV2.node, Location.node, Config.node, Global.node, AppProcess.node],
})
}
export const node = configured()

View file

@ -5,7 +5,7 @@ import { spawn, type ChildProcess } from "child_process"
import { readFile } from "fs/promises"
import { statSync } from "fs"
import { setTimeout } from "node:timers/promises"
import { Flag } from "../flag/flag"
import { Schema } from "effect"
import { FSUtil } from "../fs-util"
import { which } from "../util/which"
@ -28,6 +28,11 @@ export type Item = {
acceptable: boolean
}
export const Options = Schema.Struct({
gitbash: Schema.optional(Schema.String),
})
export type Options = typeof Options.Type
export async function killTree(proc: ChildProcess, opts?: { exited?: () => boolean }): Promise<void> {
const pid = proc.pid
if (!pid || opts?.exited?.()) return
@ -63,14 +68,14 @@ function stat(file: string) {
return statSync(file, { throwIfNoEntry: false }) ?? undefined
}
function full(file: string) {
function full(file: string, options?: Options) {
if (process.platform !== "win32") return file
const shell = FSUtil.windowsPath(file)
if (path.win32.dirname(shell) !== ".") {
if (shell.startsWith("/") && name(shell) === "bash") return gitbash() || shell
if (shell.startsWith("/") && name(shell) === "bash") return gitbash(options) || shell
return shell
}
if (name(shell) === "bash") return gitbash() || which(shell) || shell
if (name(shell) === "bash") return gitbash(options) || which(shell) || shell
return which(shell) || shell
}
@ -86,8 +91,8 @@ function rooted(file: string) {
return path.isAbsolute(FSUtil.windowsPath(file))
}
function resolve(file: string) {
const shell = full(file)
function resolve(file: string, options?: Options) {
const shell = full(file, options)
if (rooted(shell)) {
if (stat(shell)?.isFile()) return shell
return
@ -95,12 +100,12 @@ function resolve(file: string) {
return which(shell) ?? undefined
}
function win() {
function win(options?: Options) {
return Array.from(
new Set(
[which("pwsh"), which("powershell"), gitbash(), process.env.COMSPEC || "cmd.exe"]
[which("pwsh"), which("powershell"), gitbash(options), process.env.COMSPEC || "cmd.exe"]
.filter((item): item is string => Boolean(item))
.map(full),
.map((file) => full(file, options)),
),
)
}
@ -111,18 +116,18 @@ async function unix() {
return ["/bin/bash", "/bin/zsh", "/bin/sh"]
}
function select(file: string | undefined, opts?: { acceptable?: boolean }) {
function select(file: string | undefined, options?: Options, opts?: { acceptable?: boolean }) {
if (file && (!opts?.acceptable || ok(file))) {
const shell = resolve(file)
const shell = resolve(file, options)
if (shell) return shell
}
if (process.platform === "win32") return win()[0]
if (process.platform === "win32") return win(options)[0]
return fallback()
}
export function gitbash() {
export function gitbash(options?: Options) {
if (process.platform !== "win32") return
if (Flag.OPENCODE_GIT_BASH_PATH) return Flag.OPENCODE_GIT_BASH_PATH
if (options?.gitbash) return options.gitbash
const git = which("git")
if (!git) return
const file = path.join(git, "..", "..", "bin", "bash.exe")
@ -153,12 +158,12 @@ export function ps(file: string) {
return meta(file)?.ps === true
}
function info(file: string): Item {
const item = full(file)
function info(file: string, options?: Options): Item {
const item = full(file, options)
const n = name(item)
return {
path: item,
name: resolve(n) ? n : item,
name: resolve(n, options) ? n : item,
acceptable: ok(item),
}
}
@ -175,8 +180,9 @@ export function args(file: string, command: string) {
let defaultPreferred: string | undefined
let defaultAcceptable: string | undefined
export function preferred(configShell?: string) {
if (configShell) return select(configShell)
export function preferred(configShell?: string, options?: Options) {
if (configShell) return select(configShell, options)
if (options?.gitbash) return select(process.env.SHELL, options)
defaultPreferred ??= select(process.env.SHELL)
return defaultPreferred
}
@ -184,16 +190,17 @@ preferred.reset = () => {
defaultPreferred = undefined
}
export function acceptable(configShell?: string) {
if (configShell) return select(configShell, { acceptable: true })
defaultAcceptable ??= select(process.env.SHELL, { acceptable: true })
export function acceptable(configShell?: string, options?: Options) {
if (configShell) return select(configShell, options, { acceptable: true })
if (options?.gitbash) return select(process.env.SHELL, options, { acceptable: true })
defaultAcceptable ??= select(process.env.SHELL, undefined, { acceptable: true })
return defaultAcceptable
}
acceptable.reset = () => {
defaultAcceptable = undefined
}
export async function list(): Promise<Item[]> {
const shells = process.platform === "win32" ? win() : await unix()
return shells.filter((s) => resolve(s)).map(info)
export async function list(options?: Options): Promise<Item[]> {
const shells = process.platform === "win32" ? win(options) : await unix()
return shells.filter((shell) => resolve(shell, options)).map((shell) => info(shell, options))
}

View file

@ -82,7 +82,7 @@ function testLayer(
),
)
return AppNodeBuilder.build(LayerNode.group([Config.node, EventV2.node]), [
[Config.node, Config.layer(options)],
[Config.node, Config.configured(options)],
[Location.node, locationLayer],
[Global.node, Global.layerWith({ config: globalDirectory, home: path.join(globalDirectory, "home") })],
[Credential.node, credentialNode],

View file

@ -81,17 +81,6 @@ 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,

View file

@ -23,7 +23,7 @@ const instructionLayer = (input: {
project?: boolean
}) =>
AppNodeBuilder.build(InstructionDiscovery.node, [
[InstructionDiscovery.node, InstructionDiscovery.layer({ project: input.project })],
[InstructionDiscovery.node, InstructionDiscovery.configured({ project: input.project })],
[Global.node, Global.layerWith({ config: input.config })],
[Location.node, input.locationServiceLayer],
...(input.filesystemLayer ? [[FSUtil.node, input.filesystemLayer] as const] : []),

View file

@ -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.layer(options)],
[ModelsDev.node, ModelsDev.configured(options)],
[LayerNodePlatform.httpClient, Layer.succeed(HttpClient.HttpClient, makeMockClient(state))],
]),
)

View file

@ -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.layer({ file, fetch: false })]])
AppNodeBuilder.build(ModelsDev.node, [[ModelsDev.node, ModelsDev.configured({ file, fetch: false })]])
describe("ModelsDevPlugin", () => {
it.effect("projects normalized models.dev snapshots into the catalog", () =>

View file

@ -18,6 +18,11 @@ export const ServerOptions = Schema.Struct({
project: Schema.optional(Schema.Boolean),
}),
),
windows: Schema.optional(
Schema.Struct({
gitbash: Schema.optional(Schema.String),
}),
),
fs: Schema.optional(
Schema.Struct({
filewatcher: Schema.optional(Schema.Boolean),

View file

@ -8,10 +8,13 @@ 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 { CommandV2 } from "@opencode-ai/core/command"
import { PermissionSaved } from "@opencode-ai/core/permission/saved"
import { PtyTicket } from "@opencode-ai/core/pty/ticket"
import { Pty } from "@opencode-ai/core/pty"
import { Project } from "@opencode-ai/core/project"
import { SessionV2 } from "@opencode-ai/core/session"
import { Shell } from "@opencode-ai/core/shell"
import { Job } from "@opencode-ai/core/job"
import { Global } from "@opencode-ai/core/global"
import { InstructionDiscovery } from "@opencode-ai/core/instruction-discovery"
@ -79,13 +82,16 @@ function makeRoutes<AuthError, AuthServices>(
) {
const pluginRuntimeCell = PluginRuntime.makeCell()
const replacements: LayerNode.Replacements = [
[Database.node, Database.layer(options.database)],
[ModelsDev.node, ModelsDev.layer(options.models)],
[Watcher.node, Watcher.layer({ enabled: options.fs?.filewatcher })],
[FileSystemSearch.node, FileSystemSearch.layer({ fff: options.fs?.fff })],
[Database.node, Database.configured(options.database)],
[ModelsDev.node, ModelsDev.configured(options.models)],
[Watcher.node, Watcher.configured({ enabled: options.fs?.filewatcher })],
[FileSystemSearch.node, FileSystemSearch.configured({ 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 })],
[Config.node, Config.configured({ project: options.config?.project })],
[InstructionDiscovery.node, InstructionDiscovery.configured({ project: options.config?.project })],
[CommandV2.node, CommandV2.configured({ gitbash: options.windows?.gitbash })],
[Pty.node, Pty.configured({ gitbash: options.windows?.gitbash })],
[Shell.node, Shell.configured({ gitbash: options.windows?.gitbash })],
[PluginRuntime.node, PluginRuntime.layerWithCell(pluginRuntimeCell)],
[PluginRuntime.providerNode, PluginRuntime.providerNodeWithCell(pluginRuntimeCell)],
]