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..3c412f3fc414 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) { 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..12c531e96175 100644 --- a/packages/core/src/formatter/builtins.ts +++ b/packages/core/src/formatter/builtins.ts @@ -18,8 +18,10 @@ 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 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[]) => @@ -37,7 +39,7 @@ export function make(input: { name: "gofmt", extensions: [".go"], enabled: Effect.sync(() => { - const match = which("gofmt") + const match = findExecutable("gofmt") return match ? [match, "-w", "$FILE"] : disabled }), } @@ -46,7 +48,7 @@ export function make(input: { name: "mix", extensions: [".ex", ".exs", ".eex", ".heex", ".leex", ".neex", ".sface"], enabled: Effect.sync(() => { - const match = which("mix") + const match = findExecutable("mix") return match ? [match, "format", "$FILE"] : disabled }), } @@ -149,7 +151,7 @@ export function make(input: { name: "zig", extensions: [".zig", ".zon"], enabled: Effect.sync(() => { - const match = which("zig") + const match = findExecutable("zig") return match ? [match, "fmt", "$FILE"] : disabled }), } @@ -159,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") + const match = findExecutable("clang-format") return match ? [match, "-i", "$FILE"] : disabled }).pipe(Effect.orElseSucceed(() => disabled)), } @@ -168,7 +170,7 @@ export function make(input: { name: "ktlint", extensions: [".kt", ".kts"], enabled: Effect.sync(() => { - const match = which("ktlint") + const match = findExecutable("ktlint") return match ? [match, "-F", "$FILE"] : disabled }), } @@ -177,17 +179,18 @@ export function make(input: { name: "ruff", extensions: [".py", ".pyi"], enabled: Effect.gen(function* () { - if (!which("ruff")) return disabled + const bin = findExecutable("ruff") + 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 +200,7 @@ export function make(input: { name: "air", extensions: [".R"], enabled: Effect.gen(function* () { - const bin = which("air") + const bin = findExecutable("air") if (!bin) return disabled const output = yield* commandOutput([bin, "--help"]) if (output._tag === "None" || output.value.exitCode !== 0) return disabled @@ -210,34 +213,34 @@ export function make(input: { name: "uv", extensions: [".py", ".pyi"], enabled: Effect.gen(function* () { - const bin = which("uv") + 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"]) - 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"], 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") + const match = findExecutable("ocamlformat") 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"], 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", @@ -253,9 +256,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"], findExecutable) + const cljfmt = executable("cljfmt", [".clj", ".cljs", ".cljc", ".edn"], ["fix", "--quiet", "$FILE"], findExecutable) + const dfmt = executable("dfmt", [".d"], ["-i", "$FILE"], findExecutable) return [ gofmt, @@ -287,12 +290,17 @@ 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[], + findExecutable: (name: string) => string | null, +): Info { return { name, extensions, enabled: Effect.sync(() => { - const match = which(name) + const match = findExecutable(name) 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..9225d2a861cd 100644 --- a/packages/core/src/ripgrep/binary.ts +++ b/packages/core/src/ripgrep/binary.ts @@ -34,6 +34,8 @@ 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 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" })) @@ -53,10 +55,12 @@ 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(() => findExecutable("powershell.exe") ?? findExecutable("pwsh.exe"))) ?? + "powershell.exe" const result = yield* run(shell, [ "-NoProfile", "-NonInteractive", @@ -91,10 +95,10 @@ 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(() => findExecutable(process.platform === "win32" ? "rg.exe" : "rg")) 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 +107,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 +131,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..2ef5ed667402 100644 --- a/packages/core/src/shell/select.ts +++ b/packages/core/src/shell/select.ts @@ -34,15 +34,19 @@ function stat(file: string) { return statSync(file, { throwIfNoEntry: false }) ?? undefined } -function full(file: string, options?: Options) { +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) 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) || findExecutable(shell, bin) || shell + return findExecutable(shell, bin) || shell } function meta(file: string) { @@ -57,21 +61,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 findExecutable(shell, 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"] + [ + findExecutable("pwsh", bin), + findExecutable("powershell", 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 +91,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 = findExecutable("git", 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 = findExecutable("bash", bin) if (bash) return bash return "/bin/sh" } @@ -120,12 +129,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 +148,36 @@ export function args(file: string, command: string) { return ["-c", command] } -let defaultPreferred: string | undefined -let defaultAcceptable: string | undefined +let defaultPreferred: { bin?: string; value: string } | undefined +let defaultAcceptable: { bin?: string; value: string } | undefined -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 cached = defaultPreferred + if (cached && cached.bin === bin) return cached.value + const value = select(process.env.SHELL, undefined, undefined, bin) ?? fallback(bin) + defaultPreferred = { bin, value } + return value } preferred.reset = () => { defaultPreferred = undefined } -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 cached = defaultAcceptable + if (cached && cached.bin === bin) return cached.value + const value = select(process.env.SHELL, undefined, { acceptable: true }, bin) ?? fallback(bin) + defaultAcceptable = { bin, value } + return value } acceptable.reset = () => { defaultAcceptable = undefined } -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..7f48c496cb7d 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 = ( @@ -772,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 @@ -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/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/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/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 b2716d05598b..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: { > - + 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({}) }}> +