This commit is contained in:
Sebastian Herrlinger
2026-03-24 21:29:42 +01:00
parent b7a06e1939
commit e89f5084d3
73 changed files with 7968 additions and 641 deletions
+83 -42
View File
@@ -30,20 +30,26 @@ import { GlobalBus } from "@/bus/global"
import { Event } from "../server/event"
import { Glob } from "../util/glob"
import { PackageRegistry } from "@/bun/registry"
import { proxied } from "@/util/proxied"
import { online, proxied } from "@/util/network"
import { iife } from "@/util/iife"
import { Account } from "@/account"
import { isRecord } from "@/util/record"
import { ConfigPaths } from "./paths"
import { Filesystem } from "@/util/filesystem"
import { Process } from "@/util/process"
import { Lock } from "@/util/lock"
import { AppFileSystem } from "@/filesystem"
import { InstanceState } from "@/effect/instance-state"
import { makeRuntime } from "@/effect/run-service"
import { Duration, Effect, Layer, ServiceMap } from "effect"
import { Flock } from "@/util/flock"
export namespace Config {
const ModelId = z.string().meta({ $ref: "https://models.dev/model-schema.json#/$defs/Model" })
const PluginOptions = z.record(z.string(), z.unknown())
export const PluginSpec = z.union([z.string(), z.tuple([z.string(), PluginOptions])])
export type PluginOptions = z.infer<typeof PluginOptions>
export type PluginSpec = z.infer<typeof PluginSpec>
const log = Log.create({ service: "config" })
@@ -78,34 +84,56 @@ export namespace Config {
return merged
}
export async function installDependencies(dir: string) {
export type InstallInput = {
signal?: AbortSignal
waitTick?: (input: { dir: string; attempt: number; delay: number; waited: number }) => void | Promise<void>
}
export async function installDependencies(dir: string, input?: InstallInput) {
if (!(await needsInstall(dir))) return
await using _ = await Flock.acquire(`config-install:${Filesystem.resolve(dir)}`, {
signal: input?.signal,
onWait: (tick) =>
input?.waitTick?.({
dir,
attempt: tick.attempt,
delay: tick.delay,
waited: tick.waited,
}),
})
input?.signal?.throwIfAborted()
if (!(await needsInstall(dir))) return
const pkg = path.join(dir, "package.json")
const targetVersion = Installation.isLocal() ? "*" : Installation.VERSION
const target = Installation.isLocal() ? "*" : Installation.VERSION
const json = await Filesystem.readJson<{ dependencies?: Record<string, string> }>(pkg).catch(() => ({
dependencies: {},
}))
json.dependencies = {
...json.dependencies,
"@opencode-ai/plugin": targetVersion,
"@opencode-ai/plugin": target,
}
await Filesystem.writeJson(pkg, json)
const gitignore = path.join(dir, ".gitignore")
const hasGitIgnore = await Filesystem.exists(gitignore)
if (!hasGitIgnore)
const ignore = await Filesystem.exists(gitignore)
if (!ignore) {
await Filesystem.write(gitignore, ["node_modules", "package.json", "bun.lock", ".gitignore"].join("\n"))
}
// Install any additional dependencies defined in the package.json
// This allows local plugins and custom tools to use external packages
using _ = await Lock.write("bun-install")
await BunProc.run(
[
"install",
// TODO: get rid of this case (see: https://github.com/oven-sh/bun/issues/19936)
...(proxied() || process.env.CI ? ["--no-cache"] : []),
],
{ cwd: dir },
{
cwd: dir,
abort: input?.signal,
},
).catch((err) => {
if (err instanceof Process.RunFailedError) {
const detail = {
@@ -149,8 +177,8 @@ export namespace Config {
return false
}
const nodeModules = path.join(dir, "node_modules")
if (!existsSync(nodeModules)) return true
const mod = path.join(dir, "node_modules", "@opencode-ai", "plugin")
if (!existsSync(mod)) return true
const pkg = path.join(dir, "package.json")
const pkgExists = await Filesystem.exists(pkg)
@@ -163,8 +191,9 @@ export namespace Config {
const targetVersion = Installation.isLocal() ? "latest" : Installation.VERSION
if (targetVersion === "latest") {
const isOutdated = await PackageRegistry.isOutdated("@opencode-ai/plugin", depVersion, dir)
if (!isOutdated) return false
if (!online()) return false
const stale = await PackageRegistry.isOutdated("@opencode-ai/plugin", depVersion, dir)
if (!stale) return false
log.info("Cached version is outdated, proceeding with install", {
pkg: "@opencode-ai/plugin",
cachedVersion: depVersion,
@@ -303,7 +332,7 @@ export namespace Config {
}
async function loadPlugin(dir: string) {
const plugins: string[] = []
const plugins: PluginSpec[] = []
for (const item of await Glob.scan("{plugin,plugins}/*.{ts,js}", {
cwd: dir,
@@ -316,6 +345,32 @@ export namespace Config {
return plugins
}
export function pluginSpecifier(plugin: PluginSpec): string {
return Array.isArray(plugin) ? plugin[0] : plugin
}
export function pluginOptions(plugin: PluginSpec): PluginOptions | undefined {
return Array.isArray(plugin) ? plugin[1] : undefined
}
export function resolvePluginSpec(plugin: PluginSpec, configFilepath: string): PluginSpec {
const spec = pluginSpecifier(plugin)
try {
const resolved = import.meta.resolve!(spec, configFilepath)
if (Array.isArray(plugin)) return [resolved, plugin[1]]
return resolved
} catch {
try {
const require = createRequire(configFilepath)
const resolved = pathToFileURL(require.resolve(spec)).href
if (Array.isArray(plugin)) return [resolved, plugin[1]]
return resolved
} catch {
return plugin
}
}
}
/**
* Extracts a canonical plugin name from a plugin specifier.
* - For file:// URLs: extracts filename without extension
@@ -326,15 +381,16 @@ export namespace Config {
* getPluginName("oh-my-opencode@2.4.3") // "oh-my-opencode"
* getPluginName("@scope/pkg@1.0.0") // "@scope/pkg"
*/
export function getPluginName(plugin: string): string {
if (plugin.startsWith("file://")) {
return path.parse(new URL(plugin).pathname).name
export function getPluginName(plugin: PluginSpec): string {
const spec = pluginSpecifier(plugin)
if (spec.startsWith("file://")) {
return path.parse(new URL(spec).pathname).name
}
const lastAt = plugin.lastIndexOf("@")
const lastAt = spec.lastIndexOf("@")
if (lastAt > 0) {
return plugin.substring(0, lastAt)
return spec.substring(0, lastAt)
}
return plugin
return spec
}
/**
@@ -348,14 +404,14 @@ export namespace Config {
* Since plugins are added in low-to-high priority order,
* we reverse, deduplicate (keeping first occurrence), then restore order.
*/
export function deduplicatePlugins(plugins: string[]): string[] {
export function deduplicatePlugins(plugins: PluginSpec[]): PluginSpec[] {
// seenNames: canonical plugin names for duplicate detection
// e.g., "oh-my-opencode", "@scope/pkg"
const seenNames = new Set<string>()
// uniqueSpecifiers: full plugin specifiers to return
// e.g., "oh-my-opencode@2.4.3", "file:///path/to/plugin.js"
const uniqueSpecifiers: string[] = []
// e.g., "oh-my-opencode@2.4.3", ["file:///path/to/plugin.js", { ... }]
const uniqueSpecifiers: PluginSpec[] = []
for (const specifier of plugins.toReversed()) {
const name = getPluginName(specifier)
@@ -858,13 +914,13 @@ export namespace Config {
ignore: z.array(z.string()).optional(),
})
.optional(),
plugin: z.string().array().optional(),
snapshot: z
.boolean()
.optional()
.describe(
"Enable or disable snapshot tracking. When false, filesystem snapshots are not recorded and undoing or reverting will not undo/redo file changes. Defaults to true.",
),
plugin: PluginSpec.array().optional(),
share: z
.enum(["manual", "auto", "disabled"])
.optional()
@@ -1070,10 +1126,6 @@ export namespace Config {
return candidates[0]
}
function isRecord(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === "object" && !Array.isArray(value)
}
function patchJsonc(input: string, patch: unknown, path: string[] = []): string {
if (!isRecord(patch)) {
const edits = modify(input, path, patch, {
@@ -1184,18 +1236,7 @@ export namespace Config {
const data = parsed.data
if (data.plugin && isFile) {
for (let i = 0; i < data.plugin.length; i++) {
const plugin = data.plugin[i]
try {
data.plugin[i] = import.meta.resolve!(plugin, options.path)
} catch (e) {
try {
const require = createRequire(options.path)
const resolvedPath = require.resolve(plugin)
data.plugin[i] = pathToFileURL(resolvedPath).href
} catch {
// Ignore, plugin might be a generic string identifier like "mcp-server"
}
}
data.plugin[i] = resolvePluginSpec(data.plugin[i], options.path)
}
}
return data
@@ -29,6 +29,7 @@ export const TuiInfo = z
$schema: z.string().optional(),
theme: z.string().optional(),
keybinds: KeybindOverride.optional(),
plugin: Config.PluginSpec.array().optional(),
})
.extend(TuiOptions.shape)
.strict()
+119 -27
View File
@@ -8,6 +8,7 @@ import { TuiInfo } from "./tui-schema"
import { Instance } from "@/project/instance"
import { Flag } from "@/flag/flag"
import { Log } from "@/util/log"
import { isRecord } from "@/util/record"
import { Global } from "@/global"
export namespace TuiConfig {
@@ -15,16 +16,91 @@ export namespace TuiConfig {
export const Info = TuiInfo
export type Info = z.output<typeof Info>
export type PluginMeta = {
scope: "global" | "local"
source: string
}
type PluginEntry = {
item: Config.PluginSpec
meta: PluginMeta
}
type Acc = {
result: Info
entries: PluginEntry[]
}
export type Info = z.output<typeof Info> & {
plugin_meta?: Record<string, PluginMeta>
}
function pluginScope(file: string): PluginMeta["scope"] {
if (Instance.containsPath(file)) return "local"
return "global"
}
function dedupePlugins(list: PluginEntry[]) {
const seen = new Set<string>()
const result: PluginEntry[] = []
for (const item of list.toReversed()) {
const name = Config.getPluginName(item.item)
if (seen.has(name)) continue
seen.add(name)
result.push(item)
}
return result.toReversed()
}
function mergeInfo(target: Info, source: Info): Info {
return mergeDeep(target, source)
const merged = mergeDeep(target, source)
if (target.plugin && source.plugin) {
merged.plugin = [...target.plugin, ...source.plugin]
}
return merged
}
function customPath() {
return Flag.OPENCODE_TUI_CONFIG
}
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 installDeps(dir: string): Promise<void> {
return Config.installDependencies(dir)
}
async function mergeFile(acc: Acc, file: string) {
const data = await loadFile(file)
acc.result = mergeInfo(acc.result, data)
if (!data.plugin?.length) return
const scope = pluginScope(file)
for (const item of data.plugin) {
acc.entries.push({
item,
meta: {
scope,
source: file,
},
})
}
}
const state = Instance.state(async () => {
let projectFiles = Flag.OPENCODE_DISABLE_PROJECT_CONFIG
? []
@@ -38,38 +114,55 @@ export namespace TuiConfig {
? []
: await ConfigPaths.projectFiles("tui", Instance.directory, Instance.worktree)
let result: Info = {}
const acc: Acc = {
result: {},
entries: [],
}
for (const file of ConfigPaths.fileInDirectory(Global.Path.config, "tui")) {
result = mergeInfo(result, await loadFile(file))
await mergeFile(acc, file)
}
if (custom) {
result = mergeInfo(result, await loadFile(custom))
await mergeFile(acc, custom)
log.debug("loaded custom tui config", { path: custom })
}
for (const file of projectFiles) {
result = mergeInfo(result, await loadFile(file))
await mergeFile(acc, file)
}
for (const dir of unique(directories)) {
if (!dir.endsWith(".opencode") && dir !== Flag.OPENCODE_CONFIG_DIR) continue
for (const file of ConfigPaths.fileInDirectory(dir, "tui")) {
result = mergeInfo(result, await loadFile(file))
await mergeFile(acc, file)
}
}
if (existsSync(managed)) {
for (const file of ConfigPaths.fileInDirectory(managed, "tui")) {
result = mergeInfo(result, await loadFile(file))
await mergeFile(acc, file)
}
}
result.keybinds = Config.Keybinds.parse(result.keybinds ?? {})
const merged = dedupePlugins(acc.entries)
acc.result.keybinds = Config.Keybinds.parse(acc.result.keybinds ?? {})
acc.result.plugin = merged.map((item) => item.item)
acc.result.plugin_meta = merged.length
? Object.fromEntries(merged.map((item) => [Config.getPluginName(item.item), item.meta]))
: undefined
const deps: Promise<void>[] = []
if (acc.result.plugin?.length) {
for (const dir of unique(directories)) {
if (!dir.endsWith(".opencode") && dir !== Flag.OPENCODE_CONFIG_DIR) continue
deps.push(installDeps(dir))
}
}
return {
config: result,
config: acc.result,
deps,
}
})
@@ -77,6 +170,11 @@ export namespace TuiConfig {
return state().then((x) => x.config)
}
export async function waitForDependencies() {
const deps = await state().then((x) => x.deps)
await Promise.all(deps)
}
async function loadFile(filepath: string): Promise<Info> {
const text = await ConfigPaths.readFile(filepath)
if (!text) return {}
@@ -87,25 +185,12 @@ export namespace TuiConfig {
}
async function load(text: string, configFilepath: string): Promise<Info> {
const data = await ConfigPaths.parseText(text, configFilepath, "empty")
if (!data || typeof data !== "object" || Array.isArray(data)) return {}
const raw = await ConfigPaths.parseText(text, configFilepath, "empty")
if (!isRecord(raw)) return {}
// 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 = (() => {
const copy = { ...(data as Record<string, unknown>) }
if (!("tui" in copy)) return copy
if (!copy.tui || typeof copy.tui !== "object" || Array.isArray(copy.tui)) {
delete copy.tui
return copy
}
const tui = copy.tui as Record<string, unknown>
delete copy.tui
return {
...tui,
...copy,
}
})()
const normalized = normalize(raw)
const parsed = Info.safeParse(normalized)
if (!parsed.success) {
@@ -113,6 +198,13 @@ export namespace TuiConfig {
return {}
}
return parsed.data
const data = parsed.data
if (data.plugin) {
for (let i = 0; i < data.plugin.length; i++) {
data.plugin[i] = Config.resolvePluginSpec(data.plugin[i], configFilepath)
}
}
return data
}
}