refactor(server): canonicalize service API (#31049)
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
import { Context } from "effect"
|
||||
|
||||
export const CurrentWorkingDirectory = Context.Reference<string>("CurrentWorkingDirectory", {
|
||||
defaultValue: () => process.cwd(),
|
||||
})
|
||||
@@ -0,0 +1,21 @@
|
||||
import { TuiConfig } from "@opencode-ai/tui/config"
|
||||
import { isRecord } from "@opencode-ai/tui/util/record"
|
||||
import { Filesystem } from "@/util/filesystem"
|
||||
import { Schema } from "effect"
|
||||
|
||||
export function resolveHostAttentionSoundPaths(
|
||||
root: string,
|
||||
sounds: unknown,
|
||||
options?: { trim?: boolean },
|
||||
): TuiConfig.AttentionSoundPaths {
|
||||
if (!isRecord(sounds)) return {}
|
||||
return Object.fromEntries(
|
||||
Object.entries(sounds).flatMap(([name, file]) => {
|
||||
if (!Schema.is(TuiConfig.AttentionSoundName)(name)) return []
|
||||
if (typeof file !== "string") return []
|
||||
const value = options?.trim ? file.trim() : file
|
||||
if (!value) return []
|
||||
return [[name, Filesystem.resolveFilePath(root, value)]]
|
||||
}),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
import path from "path"
|
||||
import { type ParseError as JsoncParseError, applyEdits, modify, parse as parseJsonc } from "jsonc-parser"
|
||||
import { unique } from "remeda"
|
||||
import { Option, Schema } from "effect"
|
||||
import { TuiConfig } from "@opencode-ai/tui/config"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { Filesystem } from "@/util/filesystem"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import * as ConfigPaths from "@/config/paths"
|
||||
|
||||
const log = Log.create({ service: "tui.migrate" })
|
||||
|
||||
const TUI_SCHEMA_URL = "https://opencode.ai/tui.json"
|
||||
|
||||
const decodeTheme = Schema.decodeUnknownOption(Schema.String)
|
||||
const decodeRecord = Schema.decodeUnknownOption(Schema.Record(Schema.String, Schema.Unknown))
|
||||
const decodeScrollSpeed = Schema.decodeUnknownOption(TuiConfig.ScrollSpeed)
|
||||
const decodeScrollAcceleration = Schema.decodeUnknownOption(TuiConfig.ScrollAcceleration)
|
||||
const decodeDiffStyle = Schema.decodeUnknownOption(TuiConfig.DiffStyle)
|
||||
|
||||
interface MigrateInput {
|
||||
cwd: string
|
||||
directories: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Migrates tui-specific keys (theme, keybinds, tui) from opencode.json files
|
||||
* into dedicated tui.json files. Migration is performed per-directory and
|
||||
* skips only locations where a tui.json already exists.
|
||||
*/
|
||||
export async function migrateTuiConfig(input: MigrateInput) {
|
||||
const opencode = await opencodeFiles(input)
|
||||
for (const file of opencode) {
|
||||
const source = await Filesystem.readText(file).catch((error) => {
|
||||
log.warn("failed to read config for tui migration", { path: file, error })
|
||||
return undefined
|
||||
})
|
||||
if (!source) continue
|
||||
const errors: JsoncParseError[] = []
|
||||
const data = parseJsonc(source, errors, { allowTrailingComma: true })
|
||||
if (errors.length || !data || typeof data !== "object" || Array.isArray(data)) continue
|
||||
|
||||
const theme = decodeTheme("theme" in data ? data.theme : undefined)
|
||||
const keybinds = decodeRecord("keybinds" in data ? data.keybinds : undefined)
|
||||
const legacyTui = decodeRecord("tui" in data ? data.tui : undefined)
|
||||
const extracted = {
|
||||
theme: Option.getOrUndefined(theme),
|
||||
keybinds: Option.getOrUndefined(keybinds),
|
||||
tui: Option.getOrUndefined(legacyTui),
|
||||
}
|
||||
const tui = extracted.tui ? normalizeTui(extracted.tui) : undefined
|
||||
if (extracted.theme === undefined && extracted.keybinds === undefined && !tui) continue
|
||||
|
||||
const target = path.join(path.dirname(file), "tui.json")
|
||||
const targetExists = await Filesystem.exists(target)
|
||||
if (targetExists) continue
|
||||
|
||||
const payload: Record<string, unknown> = {
|
||||
$schema: TUI_SCHEMA_URL,
|
||||
}
|
||||
if (extracted.theme !== undefined) payload.theme = extracted.theme
|
||||
if (extracted.keybinds !== undefined) payload.keybinds = extracted.keybinds
|
||||
if (tui) Object.assign(payload, tui)
|
||||
|
||||
const wrote = await Filesystem.write(target, JSON.stringify(payload, null, 2))
|
||||
.then(() => true)
|
||||
.catch((error) => {
|
||||
log.warn("failed to write tui migration target", { from: file, to: target, error })
|
||||
return false
|
||||
})
|
||||
if (!wrote) continue
|
||||
|
||||
const stripped = await backupAndStripLegacy(file, source)
|
||||
if (!stripped) {
|
||||
log.warn("tui config migrated but source file was not stripped", { from: file, to: target })
|
||||
continue
|
||||
}
|
||||
log.info("migrated tui config", { from: file, to: target })
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeTui(data: Record<string, unknown>):
|
||||
| {
|
||||
scroll_speed: number | undefined
|
||||
scroll_acceleration: { enabled: boolean } | undefined
|
||||
diff_style: "auto" | "stacked" | undefined
|
||||
}
|
||||
| undefined {
|
||||
const parsed = {
|
||||
scroll_speed: Option.getOrUndefined(decodeScrollSpeed(data.scroll_speed)),
|
||||
scroll_acceleration: Option.getOrUndefined(decodeScrollAcceleration(data.scroll_acceleration)),
|
||||
diff_style: Option.getOrUndefined(decodeDiffStyle(data.diff_style)),
|
||||
}
|
||||
return parsed.scroll_speed === undefined &&
|
||||
parsed.diff_style === undefined &&
|
||||
parsed.scroll_acceleration === undefined
|
||||
? undefined
|
||||
: parsed
|
||||
}
|
||||
|
||||
async function backupAndStripLegacy(file: string, source: string) {
|
||||
const backup = file + ".tui-migration.bak"
|
||||
const hasBackup = await Filesystem.exists(backup)
|
||||
const backed = hasBackup
|
||||
? true
|
||||
: await Filesystem.write(backup, source)
|
||||
.then(() => true)
|
||||
.catch((error) => {
|
||||
log.warn("failed to backup source config during tui migration", { path: file, backup, error })
|
||||
return false
|
||||
})
|
||||
if (!backed) return false
|
||||
|
||||
const text = ["theme", "keybinds", "tui"].reduce((acc, key) => {
|
||||
const edits = modify(acc, [key], undefined, {
|
||||
formattingOptions: {
|
||||
insertSpaces: true,
|
||||
tabSize: 2,
|
||||
},
|
||||
})
|
||||
if (!edits.length) return acc
|
||||
return applyEdits(acc, edits)
|
||||
}, source)
|
||||
|
||||
return Filesystem.write(file, text)
|
||||
.then(() => {
|
||||
log.info("stripped tui keys from server config", { path: file, backup })
|
||||
return true
|
||||
})
|
||||
.catch((error) => {
|
||||
log.warn("failed to strip legacy tui keys from server config", { path: file, backup, error })
|
||||
return false
|
||||
})
|
||||
}
|
||||
|
||||
async function opencodeFiles(input: { directories: string[]; cwd: string }) {
|
||||
const files = [
|
||||
...ConfigPaths.fileInDirectory(Global.Path.config, "opencode"),
|
||||
...(await Filesystem.findUp(["opencode.json", "opencode.jsonc"], input.cwd, undefined, { rootFirst: true })),
|
||||
]
|
||||
for (const dir of unique(input.directories)) {
|
||||
files.push(...ConfigPaths.fileInDirectory(dir, "opencode"))
|
||||
}
|
||||
if (Flag.OPENCODE_CONFIG) files.push(Flag.OPENCODE_CONFIG)
|
||||
|
||||
const existing = await Promise.all(
|
||||
unique(files).map(async (file) => {
|
||||
const ok = await Filesystem.exists(file)
|
||||
return ok ? file : undefined
|
||||
}),
|
||||
)
|
||||
return existing.filter((file): file is string => !!file)
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
export * as TuiConfig from "./tui"
|
||||
|
||||
import path from "path"
|
||||
import { mergeDeep, unique } from "remeda"
|
||||
import { Cause, Context, Effect, Fiber, Layer } from "effect"
|
||||
import { ConfigParse } from "@/config/parse"
|
||||
import * as ConfigPaths from "@/config/paths"
|
||||
import { migrateTuiConfig } from "./tui-migrate"
|
||||
import { resolveHostAttentionSoundPaths } from "./tui-host-attention"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import { isRecord } from "@opencode-ai/tui/util/record"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||
import { CurrentWorkingDirectory } from "./tui-cwd"
|
||||
import { ConfigPlugin } from "@/config/plugin"
|
||||
import { TuiKeybind } from "@opencode-ai/tui/config/keybind"
|
||||
import { InstallationLocal, InstallationVersion } from "@opencode-ai/core/installation/version"
|
||||
import { makeRuntime } from "@opencode-ai/core/effect/runtime"
|
||||
import { Filesystem } from "@/util/filesystem"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { ConfigVariable } from "@/config/variable"
|
||||
import { Npm } from "@opencode-ai/core/npm"
|
||||
import { FormatError, FormatUnknownError } from "@/cli/error"
|
||||
import { TuiConfig } from "@opencode-ai/tui/config"
|
||||
|
||||
const log = Log.create({ service: "tui.config" })
|
||||
|
||||
export const Info = TuiConfig.Info
|
||||
export type Info = TuiConfig.Info
|
||||
|
||||
type Acc = {
|
||||
result: Info
|
||||
plugin_origins: ConfigPlugin.Origin[]
|
||||
}
|
||||
|
||||
export type Resolved = TuiConfig.Resolved
|
||||
|
||||
export type HostMetadata = {
|
||||
plugin_origins?: ConfigPlugin.Origin[]
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
readonly get: () => Effect.Effect<Resolved>
|
||||
readonly pluginOrigins: () => Effect.Effect<ConfigPlugin.Origin[]>
|
||||
readonly waitForDependencies: () => Effect.Effect<void>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/TuiConfig") {}
|
||||
|
||||
function pluginScope(file: string, ctx: { directory: string }): ConfigPlugin.Scope {
|
||||
if (Filesystem.contains(ctx.directory, file)) return "local"
|
||||
// if (ctx.worktree !== "/" && Filesystem.contains(ctx.worktree, file)) return "local"
|
||||
return "global"
|
||||
}
|
||||
|
||||
function normalize(raw: Record<string, unknown>) {
|
||||
const data = { ...raw }
|
||||
if (!("tui" in data)) return data
|
||||
if (!isRecord(data.tui)) {
|
||||
delete data.tui
|
||||
return data
|
||||
}
|
||||
|
||||
const tui = data.tui
|
||||
delete data.tui
|
||||
return {
|
||||
...tui,
|
||||
...data,
|
||||
}
|
||||
}
|
||||
|
||||
function dropUnknownKeybinds(input: Record<string, unknown>, configFilepath: string) {
|
||||
if (!isRecord(input.keybinds)) return input
|
||||
|
||||
const invalid = TuiKeybind.unknownKeys(input.keybinds)
|
||||
if (!invalid.length) return input
|
||||
|
||||
log.warn("ignored unknown tui keybinds", {
|
||||
path: configFilepath,
|
||||
keybinds: invalid,
|
||||
hint: "Remove these entries or rename them to keys from the tui.json schema.",
|
||||
})
|
||||
return {
|
||||
...input,
|
||||
keybinds: Object.fromEntries(Object.entries(input.keybinds).filter(([key]) => !invalid.includes(key))),
|
||||
}
|
||||
}
|
||||
|
||||
const loadState = Effect.fn("TuiConfig.loadState")(function* (ctx: { directory: string }) {
|
||||
const afs = yield* FSUtil.Service
|
||||
let appliedOrder = 0
|
||||
|
||||
const resolvePlugins = (config: Info, configFilepath: string): Effect.Effect<Info> =>
|
||||
Effect.gen(function* () {
|
||||
const plugins = config.plugin
|
||||
if (!plugins) return config
|
||||
return {
|
||||
...config,
|
||||
plugin: yield* Effect.forEach(plugins, (plugin) =>
|
||||
Effect.promise(() => ConfigPlugin.resolvePluginSpec(plugin as ConfigPlugin.Origin["spec"], configFilepath)),
|
||||
),
|
||||
}
|
||||
})
|
||||
|
||||
const load = (text: string, configFilepath: string): Effect.Effect<Info> =>
|
||||
Effect.gen(function* () {
|
||||
const expanded = yield* Effect.promise(() =>
|
||||
ConfigVariable.substitute({ text, type: "path", path: configFilepath, missing: "empty" }),
|
||||
)
|
||||
const data = ConfigParse.jsonc(expanded, configFilepath)
|
||||
if (!isRecord(data)) return {} as Info
|
||||
// Flatten a nested "tui" key so users who wrote `{ "tui": { ... } }` inside tui.json
|
||||
// (mirroring the old opencode.json shape) still get their settings applied.
|
||||
const normalized = dropUnknownKeybinds(normalize(data), configFilepath)
|
||||
const parsed = ConfigParse.schema(Info, normalized, configFilepath)
|
||||
const validated = parsed.attention?.sounds
|
||||
? {
|
||||
...parsed,
|
||||
attention: {
|
||||
...parsed.attention,
|
||||
sounds: resolveHostAttentionSoundPaths(path.dirname(configFilepath), parsed.attention.sounds),
|
||||
},
|
||||
}
|
||||
: parsed
|
||||
return yield* resolvePlugins(validated, configFilepath)
|
||||
}).pipe(
|
||||
// catchCause (not tapErrorCause + orElseSucceed) because JSONC parsing and validation
|
||||
// can sync-throw — those become defects, which orElseSucceed wouldn't catch.
|
||||
Effect.catchCause((cause) =>
|
||||
Effect.sync(() => {
|
||||
const error = Cause.squash(cause)
|
||||
const reason = FormatError(error) ?? FormatUnknownError(error)
|
||||
log.warn("skipping invalid tui config", {
|
||||
path: configFilepath,
|
||||
reason,
|
||||
})
|
||||
return {} as Info
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
const loadFile = (filepath: string): Effect.Effect<Info> =>
|
||||
Effect.gen(function* () {
|
||||
// Silent-swallow non-NotFound read errors (perms, EISDIR, IO) → log + skip.
|
||||
// Matches how parse/schema/plugin failures in load() are handled — every
|
||||
// broken-config path degrades gracefully rather than crashing TUI startup.
|
||||
const text = yield* afs.readFileStringSafe(filepath).pipe(
|
||||
Effect.catchCause((cause) =>
|
||||
Effect.sync(() => {
|
||||
const error = Cause.squash(cause)
|
||||
const reason = FormatError(error) ?? FormatUnknownError(error)
|
||||
log.warn("failed to read tui config", {
|
||||
path: filepath,
|
||||
reason,
|
||||
})
|
||||
return undefined
|
||||
}),
|
||||
),
|
||||
)
|
||||
if (!text) return {} as Info
|
||||
log.info("loading tui config", { path: filepath })
|
||||
return yield* load(text, filepath)
|
||||
})
|
||||
|
||||
const mergeFile = (acc: Acc, file: string) =>
|
||||
Effect.gen(function* () {
|
||||
const data = yield* loadFile(file)
|
||||
if (Object.keys(data).length) {
|
||||
appliedOrder += 1
|
||||
log.info("applying tui config", { path: file, order: appliedOrder })
|
||||
}
|
||||
acc.result = mergeDeep(acc.result, data)
|
||||
if (!data.plugin?.length) return
|
||||
|
||||
const scope = pluginScope(file, ctx)
|
||||
const plugins = ConfigPlugin.deduplicatePluginOrigins([
|
||||
...acc.plugin_origins,
|
||||
...data.plugin.map((spec) => ({ spec: spec as ConfigPlugin.Origin["spec"], scope, source: file })),
|
||||
])
|
||||
acc.result = {
|
||||
...acc.result,
|
||||
plugin: plugins.map((item) => item.spec),
|
||||
}
|
||||
acc.plugin_origins = plugins
|
||||
})
|
||||
|
||||
// Every config dir we may read from: global config dir, any `.opencode`
|
||||
// folders between cwd and home, and OPENCODE_CONFIG_DIR.
|
||||
const directories = yield* ConfigPaths.directories(ctx.directory)
|
||||
yield* Effect.promise(() => migrateTuiConfig({ directories, cwd: ctx.directory }))
|
||||
|
||||
const projectFiles = Flag.OPENCODE_DISABLE_PROJECT_CONFIG ? [] : yield* ConfigPaths.files("tui", ctx.directory)
|
||||
|
||||
const acc: Acc = {
|
||||
result: {},
|
||||
plugin_origins: [],
|
||||
}
|
||||
|
||||
// 1. Global tui config (lowest precedence).
|
||||
for (const file of ConfigPaths.fileInDirectory(Global.Path.config, "tui")) {
|
||||
yield* mergeFile(acc, file)
|
||||
}
|
||||
|
||||
// 2. Explicit OPENCODE_TUI_CONFIG override, if set.
|
||||
if (Flag.OPENCODE_TUI_CONFIG) {
|
||||
const configFile = Flag.OPENCODE_TUI_CONFIG
|
||||
yield* mergeFile(acc, configFile)
|
||||
log.debug("loaded custom tui config", { path: configFile })
|
||||
}
|
||||
|
||||
// 3. Project tui files, applied root-first so the closest file wins.
|
||||
for (const file of projectFiles) {
|
||||
yield* mergeFile(acc, file)
|
||||
}
|
||||
|
||||
// 4. `.opencode` directories (and OPENCODE_CONFIG_DIR) discovered while
|
||||
// walking up the tree. Also returned below so callers can install plugin
|
||||
// dependencies from each location.
|
||||
const dirs = unique(directories).filter((dir) => dir.endsWith(".opencode") || dir === Flag.OPENCODE_CONFIG_DIR)
|
||||
|
||||
for (const dir of dirs) {
|
||||
if (!dir.endsWith(".opencode") && dir !== Flag.OPENCODE_CONFIG_DIR) continue
|
||||
for (const file of ConfigPaths.fileInDirectory(dir, "tui")) {
|
||||
yield* mergeFile(acc, file)
|
||||
}
|
||||
}
|
||||
|
||||
const result = TuiConfig.resolve(
|
||||
{
|
||||
...acc.result,
|
||||
},
|
||||
{
|
||||
terminalSuspend: process.platform !== "win32",
|
||||
},
|
||||
)
|
||||
|
||||
return {
|
||||
config: result,
|
||||
pluginOrigins: acc.plugin_origins,
|
||||
dirs: result.plugin?.length ? dirs : [],
|
||||
}
|
||||
})
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const directory = yield* CurrentWorkingDirectory
|
||||
const npm = yield* Npm.Service
|
||||
const data = yield* loadState({ directory })
|
||||
const deps = yield* Effect.forEach(
|
||||
data.dirs,
|
||||
(dir) =>
|
||||
npm
|
||||
.install(dir, {
|
||||
add: [
|
||||
{
|
||||
name: "@opencode-ai/plugin",
|
||||
version: InstallationLocal ? undefined : InstallationVersion,
|
||||
},
|
||||
],
|
||||
})
|
||||
.pipe(Effect.forkScoped),
|
||||
{
|
||||
concurrency: "unbounded",
|
||||
},
|
||||
)
|
||||
|
||||
const get = Effect.fn("TuiConfig.get")(() => Effect.succeed(data.config))
|
||||
const pluginOrigins = Effect.fn("TuiConfig.pluginOrigins")(() => Effect.succeed(data.pluginOrigins))
|
||||
|
||||
const waitForDependencies = Effect.fn("TuiConfig.waitForDependencies")(() =>
|
||||
Effect.forEach(deps, Fiber.join, { concurrency: "unbounded" }).pipe(Effect.ignore(), Effect.asVoid),
|
||||
)
|
||||
return Service.of({ get, pluginOrigins, waitForDependencies })
|
||||
}).pipe(Effect.withSpan("TuiConfig.layer")),
|
||||
)
|
||||
|
||||
export const defaultLayer = layer.pipe(Layer.provide(Npm.defaultLayer), Layer.provide(FSUtil.defaultLayer))
|
||||
|
||||
const { runPromise } = makeRuntime(Service, defaultLayer)
|
||||
|
||||
export async function waitForDependencies() {
|
||||
await runPromise((svc) => svc.waitForDependencies())
|
||||
}
|
||||
|
||||
export async function get() {
|
||||
return runPromise((svc) => svc.get())
|
||||
}
|
||||
|
||||
export async function pluginOrigins() {
|
||||
return runPromise((svc) => svc.pluginOrigins())
|
||||
}
|
||||
Reference in New Issue
Block a user