From 40ce5240757a4bb42713727f1501e2360fc0fe7a Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Mon, 10 Aug 2026 21:03:29 -0400 Subject: [PATCH 1/5] refactor: consume Global through the service, not the static path table --- packages/cli/src/commands/handlers/mini.ts | 3 + packages/cli/src/mini-host.ts | 9 +- packages/cli/src/mini.ts | 3 +- packages/cli/src/server-process.ts | 3 +- packages/core/src/command.ts | 13 ++- packages/core/src/database/database.ts | 17 ++-- packages/core/src/database/migration.ts | 6 +- ...0260805200742_import_legacy_credentials.ts | 5 +- packages/core/src/database/v1-migration.ts | 13 +-- packages/core/src/formatter.ts | 5 +- packages/core/src/formatter/builtins.ts | 54 +++++------ packages/core/src/models-dev.ts | 5 +- packages/core/src/pty.ts | 11 ++- packages/core/src/ripgrep/binary.ts | 20 +++-- packages/core/src/shell.ts | 4 +- packages/core/src/shell/select.ts | 89 +++++++++++-------- packages/core/src/util/which.ts | 5 +- packages/core/test/database-migration.test.ts | 24 +++-- packages/core/test/session-create.test.ts | 3 +- packages/core/test/v1-migration.test.ts | 12 ++- packages/tui/src/app.tsx | 7 +- packages/tui/src/context/theme.tsx | 11 ++- .../tui/test/cli/tui/dialog-prompt.test.tsx | 2 +- .../cli/tui/diff-viewer-file-tree.test.tsx | 2 +- .../tui/test/cli/tui/diff-viewer.test.tsx | 2 +- 25 files changed, 199 insertions(+), 129 deletions(-) diff --git a/packages/cli/src/commands/handlers/mini.ts b/packages/cli/src/commands/handlers/mini.ts index 541d3409ed43..596c17a1b798 100644 --- a/packages/cli/src/commands/handlers/mini.ts +++ b/packages/cli/src/commands/handlers/mini.ts @@ -4,6 +4,7 @@ import { Runtime } from "../../framework/runtime" import { ServerConnection } from "../../services/server-connection" import { Config } from "../../config" import { resolve } from "@opencode-ai/tui/config" +import { Global } from "@opencode-ai/util/global" export default Runtime.handler(Commands.commands.mini, (input) => Effect.gen(function* () { @@ -16,6 +17,7 @@ export default Runtime.handler(Commands.commands.mini, (input) => mismatch: "replace", }) const config = yield* Config.Service + const global = yield* Global.Service const resolved = resolve(yield* config.get(), { terminalSuspend: process.platform !== "win32" }) const fileSystem = yield* FileSystem.FileSystem const runServicePromise = Effect.runPromiseWith(Context.make(FileSystem.FileSystem, fileSystem)) @@ -39,6 +41,7 @@ export default Runtime.handler(Commands.commands.mini, (input) => config: { update: (update) => runServicePromise(config.update(update)), }, + paths: { home: global.home, state: global.state, log: global.log }, }), ) }), diff --git a/packages/cli/src/mini-host.ts b/packages/cli/src/mini-host.ts index 49078695c18e..6376db74b82c 100644 --- a/packages/cli/src/mini-host.ts +++ b/packages/cli/src/mini-host.ts @@ -1,6 +1,5 @@ import type { MiniFrontendInput } from "@opencode-ai/tui/mini" import { createModelPreferenceRepository } from "@opencode-ai/tui/model-preference" -import { Global } from "@opencode-ai/util/global" import fs from "node:fs" import { readFile } from "node:fs/promises" import path from "node:path" @@ -129,13 +128,9 @@ export async function usingInteractiveStdin( export function createMiniHost(input: { terminal: InteractiveStdin directory: string - paths?: { home: string; state: string; log: string } + paths: { home: string; state: string; log: string } }): MiniHost { - const paths = input.paths ?? { - home: Global.Path.home, - state: Global.Path.state, - log: Global.Path.log, - } + const paths = input.paths const diagnostics = { pid: process.pid, cwd: input.directory, diff --git a/packages/cli/src/mini.ts b/packages/cli/src/mini.ts index 049f08a24ea7..2ebcee6fd0a3 100644 --- a/packages/cli/src/mini.ts +++ b/packages/cli/src/mini.ts @@ -22,6 +22,7 @@ export type MiniCommandInput = { demo?: boolean tuiConfig?: MiniFrontendInput["tuiConfig"] config?: MiniFrontendInput["config"] + paths: { home: string; state: string; log: string } } type Model = MiniFrontendInput["model"] @@ -104,7 +105,7 @@ export async function runMini(input: MiniCommandInput) { })) const frontend = await frontendTask return frontend.runMiniFrontend({ - host: createMiniHost({ terminal, directory }), + host: createMiniHost({ terminal, directory, paths: input.paths }), sdk, directory, target: resolveTarget, diff --git a/packages/cli/src/server-process.ts b/packages/cli/src/server-process.ts index 9eb1a7ff0b02..46db79cffac0 100644 --- a/packages/cli/src/server-process.ts +++ b/packages/cli/src/server-process.ts @@ -39,7 +39,8 @@ export const run = Effect.fnUntraced(function* (options: Options) { }) const processEffect = Effect.fnUntraced(function* (options: Options) { - if (options.mode === "service") yield* Effect.sync(() => process.chdir(Global.Path.home)) + const global = yield* Global.Service + if (options.mode === "service") yield* Effect.sync(() => process.chdir(global.home)) return yield* Effect.scoped( Effect.gen(function* () { const serviceOptions = options.mode === "service" ? yield* ServiceConfig.options() : undefined diff --git a/packages/core/src/command.ts b/packages/core/src/command.ts index d635d9ebb6b7..ae6498c45429 100644 --- a/packages/core/src/command.ts +++ b/packages/core/src/command.ts @@ -11,6 +11,7 @@ import { ChildProcess } from "effect/unstable/process" import { Config } from "./config" import { Location } from "./location" import { ShellSelect } from "./shell/select" +import { Global } from "@opencode-ai/util/global" export const Info = Command.Info export type Info = Command.Info @@ -61,6 +62,7 @@ export const layer = (options?: ShellSelect.Options) => const processes = yield* AppProcess.Service const config = yield* Config.Service const location = yield* Location.Service + const global = yield* Global.Service const state = State.create({ name: "command", initial: () => ({ commands: new Map() }), @@ -111,6 +113,7 @@ export const layer = (options?: ShellSelect.Options) => location, processes, shell: options, + bin: global.bin, }) const prompt = (yield* mcp.prompts()).find( @@ -164,6 +167,7 @@ function evaluateTemplate( readonly location: Location.Info readonly processes: AppProcess.Interface readonly shell?: ShellSelect.Options + readonly bin: string }, ) { return Effect.gen(function* () { @@ -197,11 +201,16 @@ const evaluateShell = Effect.fnUntraced(function* ( readonly location: Location.Info readonly processes: AppProcess.Interface readonly shell?: ShellSelect.Options + readonly bin: string }, ) { const matches = Array.from(text.matchAll(shellRegex)) if (matches.length === 0) return text - const shell = ShellSelect.preferred(Config.latest(yield* services.config.entries(), "shell"), services.shell) + const shell = ShellSelect.preferred( + Config.latest(yield* services.config.entries(), "shell"), + services.shell, + services.bin, + ) const outputs = yield* Effect.forEach( matches, (match) => { @@ -262,7 +271,7 @@ export function configured(options?: ShellSelect.Options) { return makeLocationNode({ service: Service, layer: layer(options), - deps: [MCP.node, Bus.node, AppProcess.node, Config.node, Location.node], + deps: [MCP.node, Bus.node, AppProcess.node, Config.node, Location.node, Global.node], }) } diff --git a/packages/core/src/database/database.ts b/packages/core/src/database/database.ts index 3150011717b1..82d98c6efc26 100644 --- a/packages/core/src/database/database.ts +++ b/packages/core/src/database/database.ts @@ -40,16 +40,19 @@ const databaseLayer = Layer.effect( ) export function layer(options: Options = { path: ":memory:" }) { - return Layer.suspend(() => { - const provide = (filename: string) => databaseLayer.pipe(Layer.provide(sqliteLayer({ filename }))) - const filename = options.path ?? ":memory:" - if (filename === ":memory:" || isAbsolute(filename)) return provide(filename) - return provide(join(Global.Path.data, filename)) - }) + return Layer.unwrap( + Effect.gen(function* () { + const global = yield* Global.Service + const provide = (filename: string) => databaseLayer.pipe(Layer.provide(sqliteLayer({ filename }))) + const filename = options.path ?? ":memory:" + if (filename === ":memory:" || isAbsolute(filename)) return provide(filename) + return provide(join(global.data, filename)) + }), + ) } export function configured(options?: Options) { - return makeGlobalNode({ service: Service, layer: layer(options), deps: [] }) + return makeGlobalNode({ service: Service, layer: layer(options), deps: [Global.node] }) } export const node = configured({ path: ":memory:" }) diff --git a/packages/core/src/database/migration.ts b/packages/core/src/database/migration.ts index 4dd79f7f3530..09710b5a32be 100644 --- a/packages/core/src/database/migration.ts +++ b/packages/core/src/database/migration.ts @@ -5,6 +5,7 @@ import { Effect, Semaphore } from "effect" import type { EffectDrizzleSqlite } from "@opencode-ai/effect-drizzle-sqlite" import { migrations } from "./migration.gen" import schema from "./schema.gen" +import { Global } from "@opencode-ai/util/global" type Database = EffectDrizzleSqlite.EffectSQLiteDatabase type Transaction = Parameters[0]>[0] @@ -13,7 +14,7 @@ const lock = Semaphore.makeUnsafe(1) export type Migration = { id: string foreignKeys?: boolean - up: (tx: Transaction) => Effect.Effect + up: (tx: Transaction) => Effect.Effect } export function apply(db: Database) { @@ -50,6 +51,7 @@ export function apply(db: Database) { export function applyOnly(db: Database, input: Migration[]) { return Effect.gen(function* () { + const global = yield* Global.Service yield* db.run( sql`CREATE TABLE IF NOT EXISTS ${sql.identifier("migration")} (id TEXT PRIMARY KEY, time_completed INTEGER NOT NULL)`, ) @@ -80,7 +82,7 @@ export function applyOnly(db: Database, input: Migration[]) { yield* Effect.logInfo("database migration started", { migration: migration.id }) const apply = db.transaction((tx) => Effect.gen(function* () { - yield* migration.up(tx) + yield* migration.up(tx).pipe(Effect.provideService(Global.Service, global)) yield* tx.run( sql`INSERT INTO ${sql.identifier("migration")} (id, time_completed) VALUES (${migration.id}, ${Date.now()})`, ) diff --git a/packages/core/src/database/migration/20260805200742_import_legacy_credentials.ts b/packages/core/src/database/migration/20260805200742_import_legacy_credentials.ts index 8dd33be7ea74..a393146a50d3 100644 --- a/packages/core/src/database/migration/20260805200742_import_legacy_credentials.ts +++ b/packages/core/src/database/migration/20260805200742_import_legacy_credentials.ts @@ -34,7 +34,10 @@ const wellKnownSourcesKey = "wellknown:sources" const migration: DatabaseMigration.Migration = { id: "20260805200742_import_legacy_credentials", up(tx) { - return importLegacyCredentials(tx, path.join(Global.Path.data, "auth.json")) + return Effect.gen(function* () { + const global = yield* Global.Service + return yield* importLegacyCredentials(tx, path.join(global.data, "auth.json")) + }) }, } diff --git a/packages/core/src/database/v1-migration.ts b/packages/core/src/database/v1-migration.ts index 4aedc2a1fb7b..14eafa1c941f 100644 --- a/packages/core/src/database/v1-migration.ts +++ b/packages/core/src/database/v1-migration.ts @@ -467,10 +467,11 @@ function updateProgress(progress: Progress) { if (runtimeState.status === "running") runtimeState = { status: "running", progress } } -export function run(options: Options = {}): Effect.Effect { +export function run(options: Options = {}): Effect.Effect { return lock.withPermit( Effect.gen(function* () { const { db } = yield* Database.Service + const global = yield* Global.Service const state = yield* readState(db) if (state?.phase === "completed") return { status: "completed" as const } if (!(yield* hasLegacySessions(db))) return { status: "completed" as const } @@ -478,7 +479,7 @@ export function run(options: Options = {}): Effect.Effect(sql`SELECT COUNT(*) AS value FROM session`))?.value ?? 0 const cursor = state?.phase === "sessions" ? state.cursor : undefined const migrated = @@ -502,7 +503,7 @@ export function run(options: Options = {}): Effect.Effect { + yield* importNextDatabase(db, nextPath(options, global.data), (completed) => { updateProgress({ label: "Migrating sessions", numerator: migrated + completed, denominator }) }) updateProgress({ label: "Migrating sessions", numerator: migrated + sourceTotal, denominator }) @@ -621,10 +622,10 @@ export function run(options: Options = {}): Effect.Effect() let formatters: Info[] = [] @@ -42,6 +44,7 @@ const layer = Layer.effect( fs, npm, processes, + bin: global.bin, }) formatters = builtIns if (configured === true) return @@ -122,5 +125,5 @@ const layer = Layer.effect( export const node = makeLocationNode({ service: Service, layer, - deps: [Config.node, FSUtil.node, Location.node, Npm.node, AppProcess.node], + deps: [Config.node, FSUtil.node, Location.node, Npm.node, AppProcess.node, Global.node], }) diff --git a/packages/core/src/formatter/builtins.ts b/packages/core/src/formatter/builtins.ts index f2e7a27e56e1..a0aea0d9acdd 100644 --- a/packages/core/src/formatter/builtins.ts +++ b/packages/core/src/formatter/builtins.ts @@ -18,6 +18,7 @@ export function make(input: { readonly fs: FSUtil.Interface readonly npm: Npm.Interface readonly processes: AppProcess.Interface + readonly bin: string }) { const disabled = false as const const findUp = (target: string) => input.fs.findUp(target, input.directory, input.worktree) @@ -37,7 +38,7 @@ export function make(input: { name: "gofmt", extensions: [".go"], enabled: Effect.sync(() => { - const match = which("gofmt") + const match = which("gofmt", undefined, input.bin) return match ? [match, "-w", "$FILE"] : disabled }), } @@ -46,7 +47,7 @@ export function make(input: { name: "mix", extensions: [".ex", ".exs", ".eex", ".heex", ".leex", ".neex", ".sface"], enabled: Effect.sync(() => { - const match = which("mix") + const match = which("mix", undefined, input.bin) return match ? [match, "format", "$FILE"] : disabled }), } @@ -149,7 +150,7 @@ export function make(input: { name: "zig", extensions: [".zig", ".zon"], enabled: Effect.sync(() => { - const match = which("zig") + const match = which("zig", undefined, input.bin) return match ? [match, "fmt", "$FILE"] : disabled }), } @@ -159,7 +160,7 @@ export function make(input: { extensions: [".c", ".cc", ".cpp", ".cxx", ".c++", ".h", ".hh", ".hpp", ".hxx", ".h++", ".ino", ".C", ".H"], enabled: Effect.gen(function* () { if (!(yield* findUp(".clang-format")).length) return disabled - const match = which("clang-format") + const match = which("clang-format", undefined, input.bin) return match ? [match, "-i", "$FILE"] : disabled }).pipe(Effect.orElseSucceed(() => disabled)), } @@ -168,7 +169,7 @@ export function make(input: { name: "ktlint", extensions: [".kt", ".kts"], enabled: Effect.sync(() => { - const match = which("ktlint") + const match = which("ktlint", undefined, input.bin) return match ? [match, "-F", "$FILE"] : disabled }), } @@ -177,17 +178,18 @@ export function make(input: { name: "ruff", extensions: [".py", ".pyi"], enabled: Effect.gen(function* () { - if (!which("ruff")) return disabled + const bin = which("ruff", undefined, input.bin) + if (!bin) return disabled for (const config of ["pyproject.toml", "ruff.toml", ".ruff.toml"]) { const found = yield* findUp(config) if (!found.length) continue if (config !== "pyproject.toml" || (yield* readText(found[0])).includes("[tool.ruff]")) { - return ["ruff", "format", "$FILE"] + return [bin, "format", "$FILE"] } } for (const dependency of ["requirements.txt", "pyproject.toml", "Pipfile"]) { const found = yield* findUp(dependency) - if (found.length && (yield* readText(found[0])).includes("ruff")) return ["ruff", "format", "$FILE"] + if (found.length && (yield* readText(found[0])).includes("ruff")) return [bin, "format", "$FILE"] } return disabled }).pipe(Effect.orElseSucceed(() => disabled)), @@ -197,7 +199,7 @@ export function make(input: { name: "air", extensions: [".R"], enabled: Effect.gen(function* () { - const bin = which("air") + const bin = which("air", undefined, input.bin) if (!bin) return disabled const output = yield* commandOutput([bin, "--help"]) if (output._tag === "None" || output.value.exitCode !== 0) return disabled @@ -210,34 +212,34 @@ export function make(input: { name: "uv", extensions: [".py", ".pyi"], enabled: Effect.gen(function* () { - const bin = which("uv") + const bin = which("uv", undefined, input.bin) if (!bin) return disabled const output = yield* commandOutput([bin, "format", "--help"]) return output._tag === "Some" && output.value.exitCode === 0 ? [bin, "format", "--", "$FILE"] : disabled }), } - const rubocop = executable("rubocop", [".rb", ".rake", ".gemspec", ".ru"], ["--autocorrect", "$FILE"]) - const standardrb = executable("standardrb", [".rb", ".rake", ".gemspec", ".ru"], ["--fix", "$FILE"]) - const htmlbeautifier = executable("htmlbeautifier", [".erb", ".html.erb"], ["$FILE"]) - const dart = executable("dart", [".dart"], ["format", "$FILE"]) + const rubocop = executable("rubocop", [".rb", ".rake", ".gemspec", ".ru"], ["--autocorrect", "$FILE"], input.bin) + const standardrb = executable("standardrb", [".rb", ".rake", ".gemspec", ".ru"], ["--fix", "$FILE"], input.bin) + const htmlbeautifier = executable("htmlbeautifier", [".erb", ".html.erb"], ["$FILE"], input.bin) + const dart = executable("dart", [".dart"], ["format", "$FILE"], input.bin) const ocamlformat: Info = { name: "ocamlformat", extensions: [".ml", ".mli"], enabled: Effect.gen(function* () { if (!(yield* findUp(".ocamlformat")).length) return disabled - const match = which("ocamlformat") + const match = which("ocamlformat", undefined, input.bin) return match ? [match, "-i", "$FILE"] : disabled }).pipe(Effect.orElseSucceed(() => disabled)), } - const terraform = executable("terraform", [".tf", ".tfvars"], ["fmt", "$FILE"]) - const latexindent = executable("latexindent", [".tex"], ["-w", "-s", "$FILE"]) - const gleam = executable("gleam", [".gleam"], ["format", "$FILE"]) - const shfmt = executable("shfmt", [".sh", ".bash"], ["-w", "$FILE"]) - const nixfmt = executable("nixfmt", [".nix"], ["$FILE"]) - const rustfmt = executable("rustfmt", [".rs"], ["$FILE"]) + const terraform = executable("terraform", [".tf", ".tfvars"], ["fmt", "$FILE"], input.bin) + const latexindent = executable("latexindent", [".tex"], ["-w", "-s", "$FILE"], input.bin) + const gleam = executable("gleam", [".gleam"], ["format", "$FILE"], input.bin) + const shfmt = executable("shfmt", [".sh", ".bash"], ["-w", "$FILE"], input.bin) + const nixfmt = executable("nixfmt", [".nix"], ["$FILE"], input.bin) + const rustfmt = executable("rustfmt", [".rs"], ["$FILE"], input.bin) const pint: Info = { name: "pint", @@ -253,9 +255,9 @@ export function make(input: { }).pipe(Effect.orElseSucceed(() => disabled)), } - const ormolu = executable("ormolu", [".hs"], ["-i", "$FILE"]) - const cljfmt = executable("cljfmt", [".clj", ".cljs", ".cljc", ".edn"], ["fix", "--quiet", "$FILE"]) - const dfmt = executable("dfmt", [".d"], ["-i", "$FILE"]) + const ormolu = executable("ormolu", [".hs"], ["-i", "$FILE"], input.bin) + const cljfmt = executable("cljfmt", [".clj", ".cljs", ".cljc", ".edn"], ["fix", "--quiet", "$FILE"], input.bin) + const dfmt = executable("dfmt", [".d"], ["-i", "$FILE"], input.bin) return [ gofmt, @@ -287,12 +289,12 @@ export function make(input: { ] satisfies Info[] } -function executable(name: string, extensions: readonly string[], args: string[]): Info { +function executable(name: string, extensions: readonly string[], args: string[], bin: string): Info { return { name, extensions, enabled: Effect.sync(() => { - const match = which(name) + const match = which(name, undefined, bin) return match ? [match, ...args] : false }), } diff --git a/packages/core/src/models-dev.ts b/packages/core/src/models-dev.ts index 697e0a615f22..b7a41ff111bf 100644 --- a/packages/core/src/models-dev.ts +++ b/packages/core/src/models-dev.ts @@ -544,6 +544,7 @@ export const layer = (options?: Options) => const fs = yield* FSUtil.Service const bus = yield* Bus.Service const app = yield* App.Metadata + const global = yield* Global.Service const http = HttpClient.filterStatusOk( (yield* HttpClient.HttpClient).pipe( HttpClient.retryTransient({ @@ -558,7 +559,7 @@ export const layer = (options?: Options) => const fetch = options?.fetch ?? true const userAgent = App.useragent(app) const filepath = path.join( - Global.Path.cache, + global.cache, source === "https://models.opencode.ai" ? "models.json" : `models-${Hash.fast(source)}.json`, ) const ttl = Duration.minutes(5) @@ -660,7 +661,7 @@ export function configured(options?: Options) { return makeGlobalNode({ service: Service, layer: layer(options), - deps: [FSUtil.node, Bus.node, App.node, httpClient], + deps: [FSUtil.node, Bus.node, App.node, Global.node, httpClient], }) } diff --git a/packages/core/src/pty.ts b/packages/core/src/pty.ts index ffd9fdbaeca5..69190c963364 100644 --- a/packages/core/src/pty.ts +++ b/packages/core/src/pty.ts @@ -9,6 +9,7 @@ import { Bus } from "./bus" import { Location } from "./location" import { PtyID } from "./pty/schema" import { ShellSelect } from "./shell/select" +import { Global } from "@opencode-ai/util/global" import { lazy } from "./util/lazy" const BUFFER_LIMIT = 1024 * 1024 * 2 @@ -96,6 +97,7 @@ export const layer = (options?: ShellSelect.Options) => const bus = yield* Bus.Service const location = yield* Location.Service const config = yield* Config.Service + const global = yield* Global.Service const context = yield* Effect.context() const runFork = Effect.runForkWith(context) const sessions = new Map() @@ -165,7 +167,8 @@ export const layer = (options?: ShellSelect.Options) => 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"), options) + const command = + input.command || ShellSelect.preferred(Config.latest(yield* config.entries(), "shell"), options, global.bin) const args = ShellSelect.login(command) ? [...(input.args ?? []), "-l"] : [...(input.args ?? [])] const cwd = input.cwd || location.directory const env = { @@ -315,7 +318,11 @@ export const layer = (options?: ShellSelect.Options) => ) export function configured(options?: ShellSelect.Options) { - return makeLocationNode({ service: Service, layer: layer(options), deps: [Bus.node, Location.node, Config.node] }) + return makeLocationNode({ + service: Service, + layer: layer(options), + deps: [Bus.node, Location.node, Config.node, Global.node], + }) } export const node = configured() diff --git a/packages/core/src/ripgrep/binary.ts b/packages/core/src/ripgrep/binary.ts index 3f876f901de6..2cd835f332b0 100644 --- a/packages/core/src/ripgrep/binary.ts +++ b/packages/core/src/ripgrep/binary.ts @@ -34,6 +34,7 @@ export namespace RipgrepBinary { const fs = yield* FSUtil.Service const http = HttpClient.filterStatusOk(yield* HttpClient.HttpClient) const spawner = yield* ChildProcessSpawner + const global = yield* Global.Service const run = Effect.fnUntraced(function* (command: string, args: string[]) { const handle = yield* spawner.spawn(ChildProcess.make(command, args, { extendEnv: true, stdin: "ignore" })) @@ -53,10 +54,13 @@ export namespace RipgrepBinary { config: (typeof PLATFORM)[keyof typeof PLATFORM], target: string, ) { - const dir = yield* fs.makeTempDirectoryScoped({ directory: Global.Path.bin, prefix: "ripgrep-" }) + const dir = yield* fs.makeTempDirectoryScoped({ directory: global.bin, prefix: "ripgrep-" }) if (config.extension === "zip") { - const shell = (yield* Effect.sync(() => which("powershell.exe") ?? which("pwsh.exe"))) ?? "powershell.exe" + const shell = + (yield* Effect.sync( + () => which("powershell.exe", undefined, global.bin) ?? which("pwsh.exe", undefined, global.bin), + )) ?? "powershell.exe" const result = yield* run(shell, [ "-NoProfile", "-NonInteractive", @@ -91,10 +95,12 @@ export namespace RipgrepBinary { return Service.of({ filepath: yield* Effect.cached( Effect.gen(function* () { - const system = yield* Effect.sync(() => which(process.platform === "win32" ? "rg.exe" : "rg")) + const system = yield* Effect.sync(() => + which(process.platform === "win32" ? "rg.exe" : "rg", undefined, global.bin), + ) if (system && (yield* fs.isFile(system).pipe(Effect.orDie))) return system - const target = path.join(Global.Path.bin, `rg${process.platform === "win32" ? ".exe" : ""}`) + const target = path.join(global.bin, `rg${process.platform === "win32" ? ".exe" : ""}`) if (yield* fs.isFile(target).pipe(Effect.orDie)) return target const platformKey = `${process.arch}-${process.platform}` as keyof typeof PLATFORM @@ -103,10 +109,10 @@ export namespace RipgrepBinary { const filename = `ripgrep-${VERSION}-${config.platform}.${config.extension}` const url = `https://github.com/BurntSushi/ripgrep/releases/download/${VERSION}/${filename}` - const archive = path.join(Global.Path.bin, filename) + const archive = path.join(global.bin, filename) yield* Effect.logInfo("downloading ripgrep", { url }) - yield* fs.ensureDir(Global.Path.bin).pipe(Effect.orDie) + yield* fs.ensureDir(global.bin).pipe(Effect.orDie) const bytes = yield* HttpClientRequest.get(url).pipe( http.execute, Effect.flatMap((response) => response.arrayBuffer), @@ -127,6 +133,6 @@ export namespace RipgrepBinary { export const node = makeGlobalNode({ service: Service, layer: layer, - deps: [FSUtil.node, httpClient, CrossSpawnSpawner.node], + deps: [FSUtil.node, Global.node, httpClient, CrossSpawnSpawner.node], }) } diff --git a/packages/core/src/shell.ts b/packages/core/src/shell.ts index e43c8aff2e1b..b60049c65501 100644 --- a/packages/core/src/shell.ts +++ b/packages/core/src/shell.ts @@ -143,7 +143,9 @@ export const layer = (options?: ShellSelect.Options) => }) const resolve = () => - config.entries().pipe(Effect.map((entries) => ShellSelect.preferred(Config.latest(entries, "shell"), options))) + config + .entries() + .pipe(Effect.map((entries) => ShellSelect.preferred(Config.latest(entries, "shell"), options, global.bin))) const name = () => resolve().pipe(Effect.map(ShellSelect.name)) diff --git a/packages/core/src/shell/select.ts b/packages/core/src/shell/select.ts index 0e5b631b1b10..80abd2a7cff9 100644 --- a/packages/core/src/shell/select.ts +++ b/packages/core/src/shell/select.ts @@ -34,15 +34,15 @@ function stat(file: string) { return statSync(file, { throwIfNoEntry: false }) ?? undefined } -function full(file: string, options?: Options) { +function full(file: string, options?: Options, bin?: string) { if (process.platform !== "win32") return file const shell = FSUtil.windowsPath(file) if (path.win32.dirname(shell) !== ".") { - if (shell.startsWith("/") && name(shell) === "bash") return gitbash(options) || shell + if (shell.startsWith("/") && name(shell) === "bash") return gitbash(options, bin) || shell return shell } - if (name(shell) === "bash") return gitbash(options) || which(shell) || shell - return which(shell) || shell + if (name(shell) === "bash") return gitbash(options, bin) || which(shell, undefined, bin) || shell + return which(shell, undefined, bin) || shell } function meta(file: string) { @@ -57,21 +57,26 @@ function rooted(file: string) { return path.isAbsolute(FSUtil.windowsPath(file)) } -function resolve(file: string, options?: Options) { - const shell = full(file, options) +function resolve(file: string, options?: Options, bin?: string) { + const shell = full(file, options, bin) if (rooted(shell)) { if (stat(shell)?.isFile()) return shell return } - return which(shell) ?? undefined + return which(shell, undefined, bin) ?? undefined } -function win(options?: Options) { +function win(options?: Options, bin?: string) { return Array.from( new Set( - [which("pwsh"), which("powershell"), gitbash(options), process.env.COMSPEC || "cmd.exe"] + [ + which("pwsh", undefined, bin), + which("powershell", undefined, bin), + gitbash(options, bin), + process.env.COMSPEC || "cmd.exe", + ] .filter((item): item is string => Boolean(item)) - .map((file) => full(file, options)), + .map((file) => full(file, options, bin)), ), ) } @@ -82,27 +87,27 @@ async function unix() { return ["/bin/bash", "/bin/zsh", "/bin/sh"] } -function select(file: string | undefined, options?: Options, opts?: { acceptable?: boolean }) { +function select(file: string | undefined, options?: Options, opts?: { acceptable?: boolean }, bin?: string) { if (file && (!opts?.acceptable || ok(file))) { - const shell = resolve(file, options) + const shell = resolve(file, options, bin) if (shell) return shell } - if (process.platform === "win32") return win(options)[0] - return fallback() + if (process.platform === "win32") return win(options, bin)[0] + return fallback(bin) } -export function gitbash(options?: Options) { +export function gitbash(options?: Options, bin?: string) { if (process.platform !== "win32") return if (options?.gitbash) return options.gitbash - const git = which("git") + const git = which("git", undefined, bin) if (!git) return const file = path.join(git, "..", "..", "bin", "bash.exe") if (stat(file)?.size) return file } -function fallback() { +function fallback(bin?: string) { if (process.platform === "darwin") return "/bin/zsh" - const bash = which("bash") + const bash = which("bash", undefined, bin) if (bash) return bash return "/bin/sh" } @@ -120,12 +125,12 @@ export function ps(file: string) { return meta(file)?.ps === true } -function info(file: string, options?: Options): Item { - const item = full(file, options) +function info(file: string, options?: Options, bin?: string): Item { + const item = full(file, options, bin) const n = name(item) return { path: item, - name: resolve(n, options) ? n : item, + name: resolve(n, options, bin) ? n : item, acceptable: ok(item), } } @@ -139,30 +144,38 @@ export function args(file: string, command: string) { return ["-c", command] } -let defaultPreferred: string | undefined -let defaultAcceptable: string | undefined +const defaultPreferred = new Map() +const defaultAcceptable = new Map() -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 +export function preferred(configShell?: string, options?: Options, bin?: string) { + if (configShell) return select(configShell, options, undefined, bin) + if (options?.gitbash) return select(process.env.SHELL, options, undefined, bin) + const key = bin ?? "" + const cached = defaultPreferred.get(key) + if (cached) return cached + const value = select(process.env.SHELL, undefined, undefined, bin) ?? fallback(bin) + defaultPreferred.set(key, value) + return value } preferred.reset = () => { - defaultPreferred = undefined + defaultPreferred.clear() } -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 +export function acceptable(configShell?: string, options?: Options, bin?: string) { + if (configShell) return select(configShell, options, { acceptable: true }, bin) + if (options?.gitbash) return select(process.env.SHELL, options, { acceptable: true }, bin) + const key = bin ?? "" + const cached = defaultAcceptable.get(key) + if (cached) return cached + const value = select(process.env.SHELL, undefined, { acceptable: true }, bin) ?? fallback(bin) + defaultAcceptable.set(key, value) + return value } acceptable.reset = () => { - defaultAcceptable = undefined + defaultAcceptable.clear() } -export async function list(options?: Options): Promise { - const shells = process.platform === "win32" ? win(options) : await unix() - return shells.filter((shell) => resolve(shell, options)).map((shell) => info(shell, options)) +export async function list(options?: Options, bin?: string): Promise { + const shells = process.platform === "win32" ? win(options, bin) : await unix() + return shells.filter((shell) => resolve(shell, options, bin)).map((shell) => info(shell, options, bin)) } diff --git a/packages/core/src/util/which.ts b/packages/core/src/util/which.ts index 7e7bd97acfb6..7c4767535735 100644 --- a/packages/core/src/util/which.ts +++ b/packages/core/src/util/which.ts @@ -1,10 +1,9 @@ import whichPkg from "which" import path from "path" -import { Global } from "@opencode-ai/util/global" -export function which(cmd: string, env?: NodeJS.ProcessEnv) { +export function which(cmd: string, env?: NodeJS.ProcessEnv, bin?: string) { const base = env?.PATH ?? env?.Path ?? process.env.PATH ?? process.env.Path ?? "" - const full = base ? base + path.delimiter + Global.Path.bin : Global.Path.bin + const full = base && bin ? base + path.delimiter + bin : base || bin const result = whichPkg.sync(cmd, { nothrow: true, path: full, diff --git a/packages/core/test/database-migration.test.ts b/packages/core/test/database-migration.test.ts index d9e8e8f975be..f23cf0e8bbc3 100644 --- a/packages/core/test/database-migration.test.ts +++ b/packages/core/test/database-migration.test.ts @@ -11,11 +11,19 @@ import { migrations } from "@opencode-ai/core/database/migration.gen" import { Database } from "@opencode-ai/core/database/database" import { tmpdir } from "./fixture/tmpdir" import type { SqlClient } from "effect/unstable/sql/SqlClient" -import { importLegacyCredentials } from "@opencode-ai/core/database/migration/20260805200742_import_legacy_credentials" +import legacyCredentialsMigration from "@opencode-ai/core/database/migration/20260805200742_import_legacy_credentials" +import { Global } from "@opencode-ai/util/global" -const run = (effect: Effect.Effect) => +const run = ( + effect: Effect.Effect, + global = Global.make({ data: path.join(process.cwd(), ".test-data") }), +) => Effect.runPromise( - effect.pipe(Effect.provide(SqliteClient.layer({ filename: ":memory:", disableWAL: true })), Effect.scoped), + effect.pipe( + Effect.provideService(Global.Service, global), + Effect.provide(SqliteClient.layer({ filename: ":memory:", disableWAL: true })), + Effect.scoped, + ), ) const makeDb = EffectDrizzleSqlite.makeWithDefaults() @@ -31,7 +39,7 @@ describe("DatabaseMigration", () => { Effect.scoped(Layer.build(layer)), ), { concurrency: "unbounded" }, - ), + ).pipe(Effect.provideService(Global.Service, Global.make({ data: tmp.path }))), ) }) @@ -127,7 +135,8 @@ describe("DatabaseMigration", () => { VALUES ('existing', 'anthropic', 'Existing', ${JSON.stringify({ type: "key", key: "current-key" })}, ${now}, ${now}) `) - yield* db.transaction((tx) => importLegacyCredentials(tx, source)) + yield* db.run(sql`DELETE FROM migration WHERE id = ${legacyCredentialsMigration.id}`) + yield* DatabaseMigration.applyOnly(db, [legacyCredentialsMigration]) expect(yield* db.all(sql`SELECT integration_id, label, value FROM credential ORDER BY integration_id`)).toEqual( [ @@ -159,6 +168,7 @@ describe("DatabaseMigration", () => { value: JSON.stringify(["https://example.com"]), }) }), + Global.make({ data: tmp.path }), ) expect(await Bun.file(source).text()).toBe(content) @@ -171,10 +181,12 @@ describe("DatabaseMigration", () => { Effect.gen(function* () { const db = yield* makeDb yield* DatabaseMigration.apply(db) - yield* db.transaction((tx) => importLegacyCredentials(tx, path.join(tmp.path, "missing-auth.json"))) + yield* db.run(sql`DELETE FROM migration WHERE id = ${legacyCredentialsMigration.id}`) + yield* DatabaseMigration.applyOnly(db, [legacyCredentialsMigration]) expect(yield* db.all(sql`SELECT id FROM credential`)).toEqual([]) }), + Global.make({ data: tmp.path }), ) }) diff --git a/packages/core/test/session-create.test.ts b/packages/core/test/session-create.test.ts index 56db03a2c00b..0b7cca413d3b 100644 --- a/packages/core/test/session-create.test.ts +++ b/packages/core/test/session-create.test.ts @@ -543,11 +543,10 @@ describe("Session.create", () => { Effect.promise(() => tmpdir()), (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), ) - const targetDatabase = Database.layer({ path: path.join(tmp.path, "target.sqlite") }) const targetLayer = AppNodeBuilder.build( LayerNode.group([Database.node, Bus.node, SessionProjector.node, SessionStore.node]), [ - [Database.node, targetDatabase], + [Database.node, Database.configured({ path: path.join(tmp.path, "target.sqlite") })], [Bus.node, Bus.configured({ persist: true })], ], ) diff --git a/packages/core/test/v1-migration.test.ts b/packages/core/test/v1-migration.test.ts index 457a87ddd3d5..eac310140dab 100644 --- a/packages/core/test/v1-migration.test.ts +++ b/packages/core/test/v1-migration.test.ts @@ -18,9 +18,14 @@ import { tmpdir } from "./fixture/tmpdir" import path from "path" const makeDb = EffectDrizzleSqlite.makeWithDefaults() -const run = (effect: Effect.Effect) => +const run = (effect: Effect.Effect) => Effect.runPromise( - Effect.scoped(effect.pipe(Effect.provide(SqliteClient.layer({ filename: ":memory:", disableWAL: true })))), + Effect.scoped( + effect.pipe( + Effect.provideService(Global.Service, Global.make({ data: path.join(process.cwd(), ".test-data") })), + Effect.provide(SqliteClient.layer({ filename: ":memory:", disableWAL: true })), + ), + ), ) const session = ( @@ -935,6 +940,7 @@ describe("V1Migration database workflow", () => { await database( Effect.gen(function* () { const { db } = yield* Database.Service + const global = yield* Global.Service yield* db.run( sql`INSERT INTO session (id, project_id, slug, directory, title, version, time_created, time_updated) VALUES ('ses_orphan', 'missing-project', 'orphan', '/tmp/orphan', 'Orphan', '1', 1, 2)`, ) @@ -944,7 +950,7 @@ describe("V1Migration database workflow", () => { project_id: "global", }) expect(yield* db.get(sql`SELECT worktree FROM project WHERE id = 'global'`)).toEqual({ - worktree: path.parse(Global.Path.data).root, + worktree: path.parse(global.data).root, }) expect(yield* db.get(sql`SELECT value FROM kv WHERE key = 'migration.v1-v2'`)).toEqual({ value: '{"phase":"completed"}', diff --git a/packages/tui/src/app.tsx b/packages/tui/src/app.tsx index 94aa8edddf10..b5713bf34400 100644 --- a/packages/tui/src/app.tsx +++ b/packages/tui/src/app.tsx @@ -72,7 +72,7 @@ import { DialogOpen } from "./component/dialog-open" import { SessionTabs } from "./component/session-tabs" import { sessionTabsFitVertically } from "./ui/layout" import { ThemeErrorToast } from "./component/theme-error-toast" -import { ThemeProvider, useTheme, useThemes } from "./context/theme" +import { createThemeSource, ThemeProvider, useTheme, useThemes } from "./context/theme" import { Home } from "./routes/home" import { Session } from "./routes/session" import { PromptHistoryProvider } from "./prompt/history" @@ -372,7 +372,10 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) { - + diff --git a/packages/tui/src/context/theme.tsx b/packages/tui/src/context/theme.tsx index 5b9e26355f0d..677ff9245c2c 100644 --- a/packages/tui/src/context/theme.tsx +++ b/packages/tui/src/context/theme.tsx @@ -28,7 +28,6 @@ import { createEffect, createMemo, onCleanup, onMount, type Accessor, type Paren import { createStore, produce } from "solid-js/store" import { createSimpleContext } from "./helper" import { useConfig } from "../config" -import { Global } from "@opencode-ai/util/global" import { DevTools } from "../devtools" import { configDirectories } from "../util/config-directories" @@ -69,15 +68,15 @@ export type ThemeSource = Readonly<{ subscribeRefresh?(refresh: () => void): () => void }> -const themeSource: ThemeSource = { +export const createThemeSource = (config: string): ThemeSource => ({ async discover() { - return discoverThemes(configDirectories(Global.Path.config, process.cwd())) + return discoverThemes(configDirectories(config, process.cwd())) }, subscribeRefresh(refresh) { process.on("SIGUSR2", refresh) return () => process.off("SIGUSR2", refresh) }, -} +}) export { discoverThemes } from "../theme/discovery" @@ -139,11 +138,11 @@ subscribeThemes((themes) => setStore("themes", themes)) const themeContext = createSimpleContext({ name: "Theme", - init: (props: { mode: "dark" | "light"; source?: ThemeSource }): ThemeContextValue => { + init: (props: { mode: "dark" | "light"; source: ThemeSource }): ThemeContextValue => { const renderer = useRenderer() const configState = useConfig() const config = configState.data - const themes = props.source ?? themeSource + const themes = props.source const pick = (value: unknown) => { if (value === "dark" || value === "light") return value return diff --git a/packages/tui/test/cli/tui/dialog-prompt.test.tsx b/packages/tui/test/cli/tui/dialog-prompt.test.tsx index b2716d05598b..ba7c802a7811 100644 --- a/packages/tui/test/cli/tui/dialog-prompt.test.tsx +++ b/packages/tui/test/cli/tui/dialog-prompt.test.tsx @@ -58,7 +58,7 @@ async function mountPrompt(input: { > - + Promise.resolve({}) }}> diff --git a/packages/tui/test/cli/tui/diff-viewer-file-tree.test.tsx b/packages/tui/test/cli/tui/diff-viewer-file-tree.test.tsx index bc2a51cb6b13..264a23c0646a 100644 --- a/packages/tui/test/cli/tui/diff-viewer-file-tree.test.tsx +++ b/packages/tui/test/cli/tui/diff-viewer-file-tree.test.tsx @@ -149,7 +149,7 @@ function withTheme(component: () => JSX.Element, onReady = () => {}) { return ( - + Promise.resolve({}) }}> {component()} diff --git a/packages/tui/test/cli/tui/diff-viewer.test.tsx b/packages/tui/test/cli/tui/diff-viewer.test.tsx index d40b2d448ace..4541311ab7fd 100644 --- a/packages/tui/test/cli/tui/diff-viewer.test.tsx +++ b/packages/tui/test/cli/tui/diff-viewer.test.tsx @@ -224,7 +224,7 @@ async function renderDiffViewer(vcsDiff: unknown[], height = 20, initialRoute?: - + Promise.resolve({}) }}> From 3208caa3499ded8c404d4eb9af608759890efeb0 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Mon, 10 Aug 2026 21:04:09 -0400 Subject: [PATCH 2/5] test(core): provide Global to V1 migration workflows --- packages/core/test/v1-migration.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/test/v1-migration.test.ts b/packages/core/test/v1-migration.test.ts index eac310140dab..7f48c496cb7d 100644 --- a/packages/core/test/v1-migration.test.ts +++ b/packages/core/test/v1-migration.test.ts @@ -777,7 +777,7 @@ describe("V1Migration database workflow", () => { `) }) - const database = (effect: Effect.Effect) => + const database = (effect: Effect.Effect) => run( Effect.gen(function* () { const db = yield* makeDb From 08b0a9d58a765174ce8df3efd2614ecb1f775398 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Mon, 10 Aug 2026 21:05:03 -0400 Subject: [PATCH 3/5] test(server): provide Global to process fixtures --- packages/server/test/config.test.ts | 3 ++- packages/server/test/process.test.ts | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/server/test/config.test.ts b/packages/server/test/config.test.ts index 2f899741b642..eca833a043aa 100644 --- a/packages/server/test/config.test.ts +++ b/packages/server/test/config.test.ts @@ -7,6 +7,7 @@ import { HttpServer } from "effect/unstable/http" import { tmpdir } from "../../core/test/fixture/tmpdir" import { it } from "../../core/test/lib/effect" import { ServerProcess } from "../src/process" +import { Global } from "@opencode-ai/util/global" it.live("returns ordered config entries for the requested directory", () => Effect.acquireUseRelease( @@ -70,7 +71,7 @@ it.live("returns ordered config entries for the requested directory", () => expect(mcp["servers"]["docs"]).not.toHaveProperty("oauth") }), (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), - ), + ).pipe(Effect.provide(Global.layerWith({}))), ) function isRecord(value: unknown): value is Record { diff --git a/packages/server/test/process.test.ts b/packages/server/test/process.test.ts index b462720bd37c..0e25a131f1d5 100644 --- a/packages/server/test/process.test.ts +++ b/packages/server/test/process.test.ts @@ -3,6 +3,7 @@ import { Effect } from "effect" import { HttpServer } from "effect/unstable/http" import { it } from "../../core/test/lib/effect" import { ServerProcess } from "../src/process" +import { Global } from "@opencode-ai/util/global" it.live("allows browser preflight requests without credentials", () => Effect.gen(function* () { @@ -40,5 +41,5 @@ it.live("allows browser preflight requests without credentials", () => expect(health.status).toBe(200) expect(health.headers.get("access-control-allow-origin")).toBe("http://localhost:3000") expect(yield* Effect.promise(() => health.json())).toMatchObject({ version: "test-version" }) - }), + }).pipe(Effect.provide(Global.layerWith({}))), ) From ba52d2939e84b9aaec45d57c1aec85dd3bd8e8c4 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Mon, 10 Aug 2026 21:06:56 -0400 Subject: [PATCH 4/5] refactor(sdk): acquire Global for embedded runtimes --- packages/sdk-next/package.json | 1 + packages/sdk-next/src/opencode.ts | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/sdk-next/package.json b/packages/sdk-next/package.json index 604eb22b144c..5f785457862c 100644 --- a/packages/sdk-next/package.json +++ b/packages/sdk-next/package.json @@ -17,6 +17,7 @@ "@opencode-ai/plugin": "workspace:*", "@opencode-ai/schema": "workspace:*", "@opencode-ai/server": "workspace:*", + "@opencode-ai/util": "workspace:*", "effect": "catalog:" }, "devDependencies": { diff --git a/packages/sdk-next/src/opencode.ts b/packages/sdk-next/src/opencode.ts index 90495c964a12..14e9b57c9162 100644 --- a/packages/sdk-next/src/opencode.ts +++ b/packages/sdk-next/src/opencode.ts @@ -4,6 +4,7 @@ import { createEmbeddedRoutes } from "@opencode-ai/server/routes" import type { ServerOptions } from "@opencode-ai/server/options" import { Context, Effect, Layer, ManagedRuntime } from "effect" import { FetchHttpClient, HttpEffect, HttpRouter, HttpServer } from "effect/unstable/http" +import { Global } from "@opencode-ai/util/global" export const create = Effect.fn("OpenCode.create")(function* (options: ServerOptions = {}) { const runtime = yield* Effect.acquireRelease( @@ -13,7 +14,7 @@ export const create = Effect.fn("OpenCode.create")(function* (options: ServerOpt ...options, app: { ...options.app, name: options.app?.name ?? "sdk" }, database: { path: ":memory:", ...options.database }, - }).pipe(Layer.provide(HttpServer.layerServices)), + }).pipe(Layer.provide(HttpServer.layerServices), Layer.provide(Global.layerWith({}))), ), ), (runtime) => runtime.disposeEffect, From 2cb261a6aedaa1df5c83c99c958444c33b6e5d76 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Mon, 10 Aug 2026 21:26:09 -0400 Subject: [PATCH 5/5] refactor: simplify Global service wiring --- packages/core/src/database/migration.ts | 3 +- packages/core/src/formatter/builtins.ts | 54 ++++++++++--------- packages/core/src/ripgrep/binary.ts | 10 ++-- packages/core/src/shell/select.ts | 40 +++++++------- packages/sdk-next/package.json | 1 - packages/sdk-next/src/opencode.ts | 3 +- packages/server/src/routes.ts | 1 + packages/server/test/config.test.ts | 3 +- packages/server/test/process.test.ts | 3 +- .../tui/test/cli/tui/command-palette.test.tsx | 3 +- packages/tui/test/cli/tui/data.test.tsx | 3 +- .../tui/test/cli/tui/dialog-open.test.tsx | 3 +- .../tui/test/cli/tui/dialog-prompt.test.tsx | 4 +- .../tui/test/cli/tui/dialog-select.test.tsx | 6 +-- .../cli/tui/diff-viewer-file-tree.test.tsx | 3 +- .../tui/test/cli/tui/diff-viewer.test.tsx | 3 +- packages/tui/test/cli/tui/form.test.tsx | 4 +- packages/tui/test/fixture/fixture.ts | 3 ++ 18 files changed, 80 insertions(+), 70 deletions(-) diff --git a/packages/core/src/database/migration.ts b/packages/core/src/database/migration.ts index 09710b5a32be..3c412f3fc414 100644 --- a/packages/core/src/database/migration.ts +++ b/packages/core/src/database/migration.ts @@ -51,7 +51,6 @@ export function apply(db: Database) { export function applyOnly(db: Database, input: Migration[]) { return Effect.gen(function* () { - const global = yield* Global.Service yield* db.run( sql`CREATE TABLE IF NOT EXISTS ${sql.identifier("migration")} (id TEXT PRIMARY KEY, time_completed INTEGER NOT NULL)`, ) @@ -82,7 +81,7 @@ export function applyOnly(db: Database, input: Migration[]) { yield* Effect.logInfo("database migration started", { migration: migration.id }) const apply = db.transaction((tx) => Effect.gen(function* () { - yield* migration.up(tx).pipe(Effect.provideService(Global.Service, global)) + yield* migration.up(tx) yield* tx.run( sql`INSERT INTO ${sql.identifier("migration")} (id, time_completed) VALUES (${migration.id}, ${Date.now()})`, ) diff --git a/packages/core/src/formatter/builtins.ts b/packages/core/src/formatter/builtins.ts index a0aea0d9acdd..12c531e96175 100644 --- a/packages/core/src/formatter/builtins.ts +++ b/packages/core/src/formatter/builtins.ts @@ -21,6 +21,7 @@ export function make(input: { readonly bin: string }) { const disabled = false as const + const findExecutable = (name: string) => which(name, undefined, input.bin) const findUp = (target: string) => input.fs.findUp(target, input.directory, input.worktree) const readText = (file: string) => input.fs.readFileString(file).pipe(Effect.orElseSucceed(() => "")) const commandOutput = (command: string[]) => @@ -38,7 +39,7 @@ export function make(input: { name: "gofmt", extensions: [".go"], enabled: Effect.sync(() => { - const match = which("gofmt", undefined, input.bin) + const match = findExecutable("gofmt") return match ? [match, "-w", "$FILE"] : disabled }), } @@ -47,7 +48,7 @@ export function make(input: { name: "mix", extensions: [".ex", ".exs", ".eex", ".heex", ".leex", ".neex", ".sface"], enabled: Effect.sync(() => { - const match = which("mix", undefined, input.bin) + const match = findExecutable("mix") return match ? [match, "format", "$FILE"] : disabled }), } @@ -150,7 +151,7 @@ export function make(input: { name: "zig", extensions: [".zig", ".zon"], enabled: Effect.sync(() => { - const match = which("zig", undefined, input.bin) + const match = findExecutable("zig") return match ? [match, "fmt", "$FILE"] : disabled }), } @@ -160,7 +161,7 @@ export function make(input: { extensions: [".c", ".cc", ".cpp", ".cxx", ".c++", ".h", ".hh", ".hpp", ".hxx", ".h++", ".ino", ".C", ".H"], enabled: Effect.gen(function* () { if (!(yield* findUp(".clang-format")).length) return disabled - const match = which("clang-format", undefined, input.bin) + const match = findExecutable("clang-format") return match ? [match, "-i", "$FILE"] : disabled }).pipe(Effect.orElseSucceed(() => disabled)), } @@ -169,7 +170,7 @@ export function make(input: { name: "ktlint", extensions: [".kt", ".kts"], enabled: Effect.sync(() => { - const match = which("ktlint", undefined, input.bin) + const match = findExecutable("ktlint") return match ? [match, "-F", "$FILE"] : disabled }), } @@ -178,7 +179,7 @@ export function make(input: { name: "ruff", extensions: [".py", ".pyi"], enabled: Effect.gen(function* () { - const bin = which("ruff", undefined, input.bin) + const bin = findExecutable("ruff") if (!bin) return disabled for (const config of ["pyproject.toml", "ruff.toml", ".ruff.toml"]) { const found = yield* findUp(config) @@ -199,7 +200,7 @@ export function make(input: { name: "air", extensions: [".R"], enabled: Effect.gen(function* () { - const bin = which("air", undefined, input.bin) + const bin = findExecutable("air") if (!bin) return disabled const output = yield* commandOutput([bin, "--help"]) if (output._tag === "None" || output.value.exitCode !== 0) return disabled @@ -212,34 +213,34 @@ export function make(input: { name: "uv", extensions: [".py", ".pyi"], enabled: Effect.gen(function* () { - const bin = which("uv", undefined, input.bin) + const bin = findExecutable("uv") if (!bin) return disabled const output = yield* commandOutput([bin, "format", "--help"]) return output._tag === "Some" && output.value.exitCode === 0 ? [bin, "format", "--", "$FILE"] : disabled }), } - const rubocop = executable("rubocop", [".rb", ".rake", ".gemspec", ".ru"], ["--autocorrect", "$FILE"], input.bin) - const standardrb = executable("standardrb", [".rb", ".rake", ".gemspec", ".ru"], ["--fix", "$FILE"], input.bin) - const htmlbeautifier = executable("htmlbeautifier", [".erb", ".html.erb"], ["$FILE"], input.bin) - const dart = executable("dart", [".dart"], ["format", "$FILE"], input.bin) + const rubocop = executable("rubocop", [".rb", ".rake", ".gemspec", ".ru"], ["--autocorrect", "$FILE"], findExecutable) + const standardrb = executable("standardrb", [".rb", ".rake", ".gemspec", ".ru"], ["--fix", "$FILE"], findExecutable) + const htmlbeautifier = executable("htmlbeautifier", [".erb", ".html.erb"], ["$FILE"], findExecutable) + const dart = executable("dart", [".dart"], ["format", "$FILE"], findExecutable) const ocamlformat: Info = { name: "ocamlformat", extensions: [".ml", ".mli"], enabled: Effect.gen(function* () { if (!(yield* findUp(".ocamlformat")).length) return disabled - const match = which("ocamlformat", undefined, input.bin) + const match = findExecutable("ocamlformat") return match ? [match, "-i", "$FILE"] : disabled }).pipe(Effect.orElseSucceed(() => disabled)), } - const terraform = executable("terraform", [".tf", ".tfvars"], ["fmt", "$FILE"], input.bin) - const latexindent = executable("latexindent", [".tex"], ["-w", "-s", "$FILE"], input.bin) - const gleam = executable("gleam", [".gleam"], ["format", "$FILE"], input.bin) - const shfmt = executable("shfmt", [".sh", ".bash"], ["-w", "$FILE"], input.bin) - const nixfmt = executable("nixfmt", [".nix"], ["$FILE"], input.bin) - const rustfmt = executable("rustfmt", [".rs"], ["$FILE"], input.bin) + const terraform = executable("terraform", [".tf", ".tfvars"], ["fmt", "$FILE"], findExecutable) + const latexindent = executable("latexindent", [".tex"], ["-w", "-s", "$FILE"], findExecutable) + const gleam = executable("gleam", [".gleam"], ["format", "$FILE"], findExecutable) + const shfmt = executable("shfmt", [".sh", ".bash"], ["-w", "$FILE"], findExecutable) + const nixfmt = executable("nixfmt", [".nix"], ["$FILE"], findExecutable) + const rustfmt = executable("rustfmt", [".rs"], ["$FILE"], findExecutable) const pint: Info = { name: "pint", @@ -255,9 +256,9 @@ export function make(input: { }).pipe(Effect.orElseSucceed(() => disabled)), } - const ormolu = executable("ormolu", [".hs"], ["-i", "$FILE"], input.bin) - const cljfmt = executable("cljfmt", [".clj", ".cljs", ".cljc", ".edn"], ["fix", "--quiet", "$FILE"], input.bin) - const dfmt = executable("dfmt", [".d"], ["-i", "$FILE"], input.bin) + const ormolu = executable("ormolu", [".hs"], ["-i", "$FILE"], findExecutable) + const cljfmt = executable("cljfmt", [".clj", ".cljs", ".cljc", ".edn"], ["fix", "--quiet", "$FILE"], findExecutable) + const dfmt = executable("dfmt", [".d"], ["-i", "$FILE"], findExecutable) return [ gofmt, @@ -289,12 +290,17 @@ export function make(input: { ] satisfies Info[] } -function executable(name: string, extensions: readonly string[], args: string[], bin: string): Info { +function executable( + name: string, + extensions: readonly string[], + args: string[], + findExecutable: (name: string) => string | null, +): Info { return { name, extensions, enabled: Effect.sync(() => { - const match = which(name, undefined, bin) + const match = findExecutable(name) return match ? [match, ...args] : false }), } diff --git a/packages/core/src/ripgrep/binary.ts b/packages/core/src/ripgrep/binary.ts index 2cd835f332b0..9225d2a861cd 100644 --- a/packages/core/src/ripgrep/binary.ts +++ b/packages/core/src/ripgrep/binary.ts @@ -35,6 +35,7 @@ export namespace RipgrepBinary { const http = HttpClient.filterStatusOk(yield* HttpClient.HttpClient) const spawner = yield* ChildProcessSpawner const global = yield* Global.Service + const findExecutable = (name: string) => which(name, undefined, global.bin) const run = Effect.fnUntraced(function* (command: string, args: string[]) { const handle = yield* spawner.spawn(ChildProcess.make(command, args, { extendEnv: true, stdin: "ignore" })) @@ -58,9 +59,8 @@ export namespace RipgrepBinary { if (config.extension === "zip") { const shell = - (yield* Effect.sync( - () => which("powershell.exe", undefined, global.bin) ?? which("pwsh.exe", undefined, global.bin), - )) ?? "powershell.exe" + (yield* Effect.sync(() => findExecutable("powershell.exe") ?? findExecutable("pwsh.exe"))) ?? + "powershell.exe" const result = yield* run(shell, [ "-NoProfile", "-NonInteractive", @@ -95,9 +95,7 @@ export namespace RipgrepBinary { return Service.of({ filepath: yield* Effect.cached( Effect.gen(function* () { - const system = yield* Effect.sync(() => - which(process.platform === "win32" ? "rg.exe" : "rg", undefined, global.bin), - ) + const system = yield* Effect.sync(() => findExecutable(process.platform === "win32" ? "rg.exe" : "rg")) if (system && (yield* fs.isFile(system).pipe(Effect.orDie))) return system const target = path.join(global.bin, `rg${process.platform === "win32" ? ".exe" : ""}`) diff --git a/packages/core/src/shell/select.ts b/packages/core/src/shell/select.ts index 80abd2a7cff9..2ef5ed667402 100644 --- a/packages/core/src/shell/select.ts +++ b/packages/core/src/shell/select.ts @@ -34,6 +34,10 @@ function stat(file: string) { return statSync(file, { throwIfNoEntry: false }) ?? undefined } +function findExecutable(name: string, bin?: string) { + return which(name, undefined, bin) +} + function full(file: string, options?: Options, bin?: string) { if (process.platform !== "win32") return file const shell = FSUtil.windowsPath(file) @@ -41,8 +45,8 @@ function full(file: string, options?: Options, bin?: string) { if (shell.startsWith("/") && name(shell) === "bash") return gitbash(options, bin) || shell return shell } - if (name(shell) === "bash") return gitbash(options, bin) || which(shell, undefined, bin) || shell - return which(shell, undefined, bin) || shell + if (name(shell) === "bash") return gitbash(options, bin) || findExecutable(shell, bin) || shell + return findExecutable(shell, bin) || shell } function meta(file: string) { @@ -63,15 +67,15 @@ function resolve(file: string, options?: Options, bin?: string) { if (stat(shell)?.isFile()) return shell return } - return which(shell, undefined, bin) ?? undefined + return findExecutable(shell, bin) ?? undefined } function win(options?: Options, bin?: string) { return Array.from( new Set( [ - which("pwsh", undefined, bin), - which("powershell", undefined, bin), + findExecutable("pwsh", bin), + findExecutable("powershell", bin), gitbash(options, bin), process.env.COMSPEC || "cmd.exe", ] @@ -99,7 +103,7 @@ function select(file: string | undefined, options?: Options, opts?: { acceptable export function gitbash(options?: Options, bin?: string) { if (process.platform !== "win32") return if (options?.gitbash) return options.gitbash - const git = which("git", undefined, bin) + const git = findExecutable("git", bin) if (!git) return const file = path.join(git, "..", "..", "bin", "bash.exe") if (stat(file)?.size) return file @@ -107,7 +111,7 @@ export function gitbash(options?: Options, bin?: string) { function fallback(bin?: string) { if (process.platform === "darwin") return "/bin/zsh" - const bash = which("bash", undefined, bin) + const bash = findExecutable("bash", bin) if (bash) return bash return "/bin/sh" } @@ -144,35 +148,33 @@ export function args(file: string, command: string) { return ["-c", command] } -const defaultPreferred = new Map() -const defaultAcceptable = new Map() +let defaultPreferred: { bin?: string; value: string } | undefined +let defaultAcceptable: { bin?: string; value: string } | undefined export function preferred(configShell?: string, options?: Options, bin?: string) { if (configShell) return select(configShell, options, undefined, bin) if (options?.gitbash) return select(process.env.SHELL, options, undefined, bin) - const key = bin ?? "" - const cached = defaultPreferred.get(key) - if (cached) return cached + const cached = defaultPreferred + if (cached && cached.bin === bin) return cached.value const value = select(process.env.SHELL, undefined, undefined, bin) ?? fallback(bin) - defaultPreferred.set(key, value) + defaultPreferred = { bin, value } return value } preferred.reset = () => { - defaultPreferred.clear() + defaultPreferred = undefined } export function acceptable(configShell?: string, options?: Options, bin?: string) { if (configShell) return select(configShell, options, { acceptable: true }, bin) if (options?.gitbash) return select(process.env.SHELL, options, { acceptable: true }, bin) - const key = bin ?? "" - const cached = defaultAcceptable.get(key) - if (cached) return cached + const cached = defaultAcceptable + if (cached && cached.bin === bin) return cached.value const value = select(process.env.SHELL, undefined, { acceptable: true }, bin) ?? fallback(bin) - defaultAcceptable.set(key, value) + defaultAcceptable = { bin, value } return value } acceptable.reset = () => { - defaultAcceptable.clear() + defaultAcceptable = undefined } export async function list(options?: Options, bin?: string): Promise { diff --git a/packages/sdk-next/package.json b/packages/sdk-next/package.json index 5f785457862c..604eb22b144c 100644 --- a/packages/sdk-next/package.json +++ b/packages/sdk-next/package.json @@ -17,7 +17,6 @@ "@opencode-ai/plugin": "workspace:*", "@opencode-ai/schema": "workspace:*", "@opencode-ai/server": "workspace:*", - "@opencode-ai/util": "workspace:*", "effect": "catalog:" }, "devDependencies": { diff --git a/packages/sdk-next/src/opencode.ts b/packages/sdk-next/src/opencode.ts index 14e9b57c9162..90495c964a12 100644 --- a/packages/sdk-next/src/opencode.ts +++ b/packages/sdk-next/src/opencode.ts @@ -4,7 +4,6 @@ import { createEmbeddedRoutes } from "@opencode-ai/server/routes" import type { ServerOptions } from "@opencode-ai/server/options" import { Context, Effect, Layer, ManagedRuntime } from "effect" import { FetchHttpClient, HttpEffect, HttpRouter, HttpServer } from "effect/unstable/http" -import { Global } from "@opencode-ai/util/global" export const create = Effect.fn("OpenCode.create")(function* (options: ServerOptions = {}) { const runtime = yield* Effect.acquireRelease( @@ -14,7 +13,7 @@ export const create = Effect.fn("OpenCode.create")(function* (options: ServerOpt ...options, app: { ...options.app, name: options.app?.name ?? "sdk" }, database: { path: ":memory:", ...options.database }, - }).pipe(Layer.provide(HttpServer.layerServices), Layer.provide(Global.layerWith({}))), + }).pipe(Layer.provide(HttpServer.layerServices)), ), ), (runtime) => runtime.disposeEffect, diff --git a/packages/server/src/routes.ts b/packages/server/src/routes.ts index 1a94619ef5f1..965522466c1c 100644 --- a/packages/server/src/routes.ts +++ b/packages/server/src/routes.ts @@ -46,6 +46,7 @@ import type { ServerOptions } from "./options" import { modalWorkspaceDriver, provider as modalProvider } from "./workspace/modal-workspace" const applicationServices = LayerNode.group([ + Global.node, Database.node, Bus.node, EventLogger.node, diff --git a/packages/server/test/config.test.ts b/packages/server/test/config.test.ts index eca833a043aa..2f899741b642 100644 --- a/packages/server/test/config.test.ts +++ b/packages/server/test/config.test.ts @@ -7,7 +7,6 @@ import { HttpServer } from "effect/unstable/http" import { tmpdir } from "../../core/test/fixture/tmpdir" import { it } from "../../core/test/lib/effect" import { ServerProcess } from "../src/process" -import { Global } from "@opencode-ai/util/global" it.live("returns ordered config entries for the requested directory", () => Effect.acquireUseRelease( @@ -71,7 +70,7 @@ it.live("returns ordered config entries for the requested directory", () => expect(mcp["servers"]["docs"]).not.toHaveProperty("oauth") }), (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), - ).pipe(Effect.provide(Global.layerWith({}))), + ), ) function isRecord(value: unknown): value is Record { diff --git a/packages/server/test/process.test.ts b/packages/server/test/process.test.ts index 0e25a131f1d5..b462720bd37c 100644 --- a/packages/server/test/process.test.ts +++ b/packages/server/test/process.test.ts @@ -3,7 +3,6 @@ import { Effect } from "effect" import { HttpServer } from "effect/unstable/http" import { it } from "../../core/test/lib/effect" import { ServerProcess } from "../src/process" -import { Global } from "@opencode-ai/util/global" it.live("allows browser preflight requests without credentials", () => Effect.gen(function* () { @@ -41,5 +40,5 @@ it.live("allows browser preflight requests without credentials", () => expect(health.status).toBe(200) expect(health.headers.get("access-control-allow-origin")).toBe("http://localhost:3000") expect(yield* Effect.promise(() => health.json())).toMatchObject({ version: "test-version" }) - }).pipe(Effect.provide(Global.layerWith({}))), + }), ) diff --git a/packages/tui/test/cli/tui/command-palette.test.tsx b/packages/tui/test/cli/tui/command-palette.test.tsx index 3e3fa0e2a195..cdd65500a84a 100644 --- a/packages/tui/test/cli/tui/command-palette.test.tsx +++ b/packages/tui/test/cli/tui/command-palette.test.tsx @@ -10,6 +10,7 @@ import { ThemeProvider } from "../../../src/context/theme" import { DialogProvider, useDialog } from "../../../src/ui/dialog" import { ToastProvider } from "../../../src/ui/toast" import { TestTuiContexts } from "../../fixture/tui-environment" +import { emptyThemeSource } from "../../fixture/fixture" test("searches settings globally and opens the matching setting", async () => { let current: Info = {} @@ -53,7 +54,7 @@ test("searches settings globally and opens the matching setting", async () => { - Promise.resolve({}) }}> + diff --git a/packages/tui/test/cli/tui/data.test.tsx b/packages/tui/test/cli/tui/data.test.tsx index d4c385d44e24..c88f17819e80 100644 --- a/packages/tui/test/cli/tui/data.test.tsx +++ b/packages/tui/test/cli/tui/data.test.tsx @@ -16,6 +16,7 @@ import { ThemeProvider } from "../../../src/context/theme" import { Composer } from "../../../src/routes/session/composer" import { createSessionRows, type SessionRow } from "../../../src/routes/session/rows" import { createApi, createEventStream, createFetch, directory, json, worktree } from "../../fixture/tui-client" +import { emptyThemeSource } from "../../fixture/fixture" import { TestTuiContexts } from "../../fixture/tui-environment" import { createTuiResolvedConfig } from "../../fixture/tui-runtime" @@ -1979,7 +1980,7 @@ test("keeps shell state scoped to location", async () => { return ( - Promise.resolve({}) }}> + diff --git a/packages/tui/test/cli/tui/dialog-open.test.tsx b/packages/tui/test/cli/tui/dialog-open.test.tsx index cdd1c1f5c6b2..a3e76726ff22 100644 --- a/packages/tui/test/cli/tui/dialog-open.test.tsx +++ b/packages/tui/test/cli/tui/dialog-open.test.tsx @@ -16,6 +16,7 @@ import { ThemeProvider } from "../../../src/context/theme" import { DialogProvider, useDialog } from "../../../src/ui/dialog" import { ToastProvider } from "../../../src/ui/toast" import { createApi, createEventStream, createFetch, json, type FetchHandler } from "../../fixture/tui-client" +import { emptyThemeSource } from "../../fixture/fixture" import { TestTuiContexts } from "../../fixture/tui-environment" import { tmpdir } from "../../fixture/fixture" import { createTuiResolvedConfig } from "../../fixture/tui-runtime" @@ -226,7 +227,7 @@ async function renderOpen( - Promise.resolve({}) }}> + diff --git a/packages/tui/test/cli/tui/dialog-prompt.test.tsx b/packages/tui/test/cli/tui/dialog-prompt.test.tsx index ba7c802a7811..b2257860c9c7 100644 --- a/packages/tui/test/cli/tui/dialog-prompt.test.tsx +++ b/packages/tui/test/cli/tui/dialog-prompt.test.tsx @@ -5,7 +5,7 @@ import { expect, test } from "bun:test" import { mkdir } from "node:fs/promises" import path from "node:path" import { onCleanup } from "solid-js" -import { tmpdir } from "../../fixture/fixture" +import { emptyThemeSource, tmpdir } from "../../fixture/fixture" import { createTuiResolvedConfig } from "../../fixture/tui-runtime" import type { TuiKeybind } from "../../../src/config/keybind" import { TestTuiContexts } from "../../fixture/tui-environment" @@ -58,7 +58,7 @@ async function mountPrompt(input: { > - Promise.resolve({}) }}> + diff --git a/packages/tui/test/cli/tui/dialog-select.test.tsx b/packages/tui/test/cli/tui/dialog-select.test.tsx index b0c96d05c870..c3cb7113687a 100644 --- a/packages/tui/test/cli/tui/dialog-select.test.tsx +++ b/packages/tui/test/cli/tui/dialog-select.test.tsx @@ -9,7 +9,7 @@ import { dialogWidth } from "../../../src/ui/dialog" import { dialogSelectContentWidth, type DialogSelectOption } from "../../../src/ui/dialog-select" import { truncateFilePath } from "../../../src/ui/file-path" import { stringWidth } from "../../../src/util/string-width" -import { tmpdir } from "../../fixture/fixture" +import { emptyThemeSource, tmpdir } from "../../fixture/fixture" import { TestTuiContexts } from "../../fixture/tui-environment" import { createTuiResolvedConfig } from "../../fixture/tui-runtime" @@ -62,7 +62,7 @@ async function renderSelect( - Promise.resolve({}) }}> +