tui: ensure TUI plugins load with proper project context when multiple directories are open

Fixes potential plugin resolution issues when switching between projects by wrapping
plugin loading in Instance.provide(). This ensures each plugin resolves dependencies
relative to its correct project directory instead of inheriting context from whatever
instance happened to be active.

Also reorganizes config loading code into focused modules (command.ts, managed.ts,
plugin.ts) to make the codebase easier to maintain and test.
This commit is contained in:
Dax Raad
2026-04-16 12:40:24 -04:00
parent b28956f0db
commit bfffc3c2c6
8 changed files with 262 additions and 267 deletions
@@ -16,6 +16,7 @@ import { TuiConfig } from "@/cli/cmd/tui/config/tui"
import { Log } from "@/util" import { Log } from "@/util"
import { errorData, errorMessage } from "@/util/error" import { errorData, errorMessage } from "@/util/error"
import { isRecord } from "@/util/record" import { isRecord } from "@/util/record"
import { Instance } from "@/project/instance"
import { import {
readPackageThemes, readPackageThemes,
readPluginId, readPluginId,
@@ -789,7 +790,13 @@ async function addPluginBySpec(state: RuntimeState | undefined, raw: string) {
state.pending.delete(spec) state.pending.delete(spec)
return true return true
} }
const ready = await resolveExternalPlugins([cfg], () => TuiConfig.waitForDependencies()) const ready = await Instance.provide({
directory: state.directory,
fn: () => resolveExternalPlugins([cfg], () => TuiConfig.waitForDependencies()),
}).catch((error) => {
fail("failed to add tui plugin", { path: next, error })
return [] as PluginLoad[]
})
if (!ready.length) { if (!ready.length) {
return false return false
} }
@@ -980,37 +987,42 @@ export namespace TuiPluginRuntime {
} }
runtime = next runtime = next
try { try {
const records = Flag.OPENCODE_PURE ? [] : (config.plugin_origins ?? []) await Instance.provide({
if (Flag.OPENCODE_PURE && config.plugin_origins?.length) { directory: cwd,
log.info("skipping external tui plugins in pure mode", { count: config.plugin_origins.length }) fn: async () => {
} const records = Flag.OPENCODE_PURE ? [] : (config.plugin_origins ?? [])
if (Flag.OPENCODE_PURE && config.plugin_origins?.length) {
log.info("skipping external tui plugins in pure mode", { count: config.plugin_origins.length })
}
for (const item of INTERNAL_TUI_PLUGINS) { for (const item of INTERNAL_TUI_PLUGINS) {
log.info("loading internal tui plugin", { id: item.id }) log.info("loading internal tui plugin", { id: item.id })
const entry = loadInternalPlugin(item) const entry = loadInternalPlugin(item)
const meta = createMeta(entry.source, entry.spec, entry.target, undefined, entry.id) const meta = createMeta(entry.source, entry.spec, entry.target, undefined, entry.id)
addPluginEntry(next, { addPluginEntry(next, {
id: entry.id, id: entry.id,
load: entry, load: entry,
meta, meta,
themes: {}, themes: {},
plugin: entry.module.tui, plugin: entry.module.tui,
enabled: true, enabled: true,
}) })
} }
const ready = await resolveExternalPlugins(records, () => TuiConfig.waitForDependencies()) const ready = await resolveExternalPlugins(records, () => TuiConfig.waitForDependencies())
await addExternalPluginEntries(next, ready) await addExternalPluginEntries(next, ready)
applyInitialPluginEnabledState(next, config) applyInitialPluginEnabledState(next, config)
for (const plugin of next.plugins) { for (const plugin of next.plugins) {
if (!plugin.enabled) continue if (!plugin.enabled) continue
// Keep plugin execution sequential for deterministic side effects: // Keep plugin execution sequential for deterministic side effects:
// command registration order affects keybind/command precedence, // command registration order affects keybind/command precedence,
// route registration is last-wins when ids collide, // route registration is last-wins when ids collide,
// and hook chains rely on stable plugin ordering. // and hook chains rely on stable plugin ordering.
await activatePluginEntry(next, plugin, false) await activatePluginEntry(next, plugin, false)
} }
},
})
} catch (error) { } catch (error) {
fail("failed to load tui plugins", { directory: cwd, error }) fail("failed to load tui plugins", { directory: cwd, error })
} }
+76
View File
@@ -0,0 +1,76 @@
import { Log } from "../util"
import path from "path"
import z from "zod"
import { NamedError } from "@opencode-ai/shared/util/error"
import { Glob } from "@opencode-ai/shared/util/glob"
import { Bus } from "@/bus"
import * as ConfigMarkdown from "./markdown"
import { InvalidError } from "./paths"
const ModelId = z.string().meta({ $ref: "https://models.dev/model-schema.json#/$defs/Model" })
const log = Log.create({ service: "config" })
function rel(item: string, patterns: string[]) {
const normalizedItem = item.replaceAll("\\", "/")
for (const pattern of patterns) {
const index = normalizedItem.indexOf(pattern)
if (index === -1) continue
return normalizedItem.slice(index + pattern.length)
}
}
function trim(file: string) {
const ext = path.extname(file)
return ext.length ? file.slice(0, -ext.length) : file
}
export namespace ConfigCommand {
export const Info = z.object({
template: z.string(),
description: z.string().optional(),
agent: z.string().optional(),
model: ModelId.optional(),
subtask: z.boolean().optional(),
})
export type Info = z.infer<typeof Info>
export async function load(dir: string) {
const result: Record<string, Info> = {}
for (const item of await Glob.scan("{command,commands}/**/*.md", {
cwd: dir,
absolute: true,
dot: true,
symlink: true,
})) {
const md = await ConfigMarkdown.parse(item).catch(async (err) => {
const message = ConfigMarkdown.FrontmatterError.isInstance(err)
? err.data.message
: `Failed to parse command ${item}`
const { Session } = await import("@/session")
void Bus.publish(Session.Event.Error, { error: new NamedError.Unknown({ message }).toObject() })
log.error("failed to load command", { command: item, err })
return undefined
})
if (!md) continue
const patterns = ["/.opencode/command/", "/.opencode/commands/", "/command/", "/commands/"]
const file = rel(item, patterns) ?? path.basename(item)
const name = trim(file)
const config = {
name,
...md.data,
template: md.content.trim(),
}
const parsed = Info.safeParse(config)
if (parsed.success) {
result[config.name] = parsed.data
continue
}
throw new InvalidError({ path: item, issues: parsed.error.issues }, { cause: parsed.error })
}
return result
}
}
+32 -201
View File
@@ -2,9 +2,8 @@ import { Log } from "../util"
import path from "path" import path from "path"
import { pathToFileURL } from "url" import { pathToFileURL } from "url"
import os from "os" import os from "os"
import { Process } from "../util"
import z from "zod" import z from "zod"
import { mergeDeep, pipe, unique } from "remeda" import { mergeDeep, pipe } from "remeda"
import { Global } from "../global" import { Global } from "../global"
import fsNode from "fs/promises" import fsNode from "fs/promises"
import { NamedError } from "@opencode-ai/shared/util/error" import { NamedError } from "@opencode-ai/shared/util/error"
@@ -35,10 +34,11 @@ import { AppFileSystem } from "@opencode-ai/shared/filesystem"
import { InstanceState } from "@/effect" import { InstanceState } from "@/effect"
import { Context, Duration, Effect, Exit, Fiber, Layer, Option } from "effect" import { Context, Duration, Effect, Exit, Fiber, Layer, Option } from "effect"
import { EffectFlock } from "@opencode-ai/shared/util/effect-flock" import { EffectFlock } from "@opencode-ai/shared/util/effect-flock"
import { isPathPluginSpec, parsePluginSpecifier, resolvePathPluginTarget } from "@/plugin/shared"
import { InstanceRef } from "@/effect/instance-ref" import { InstanceRef } from "@/effect/instance-ref"
import { Npm } from "@opencode-ai/shared/npm" import { Npm } from "@opencode-ai/shared/npm"
import { ConfigPlugin } from "./plugin"
import { ConfigManaged } from "./managed"
import { ConfigCommand } from "./command"
const ModelId = z.string().meta({ $ref: "https://models.dev/model-schema.json#/$defs/Model" }) const ModelId = z.string().meta({ $ref: "https://models.dev/model-schema.json#/$defs/Model" })
const PluginOptions = z.record(z.string(), z.unknown()) const PluginOptions = z.record(z.string(), z.unknown())
@@ -55,78 +55,6 @@ export type PluginOrigin = {
const log = Log.create({ service: "config" }) const log = Log.create({ service: "config" })
// Managed settings directory for enterprise deployments (highest priority, admin-controlled)
// These settings override all user and project settings
function systemManagedConfigDir(): string {
switch (process.platform) {
case "darwin":
return "/Library/Application Support/opencode"
case "win32":
return path.join(process.env.ProgramData || "C:\\ProgramData", "opencode")
default:
return "/etc/opencode"
}
}
export function managedConfigDir() {
return process.env.OPENCODE_TEST_MANAGED_CONFIG_DIR || systemManagedConfigDir()
}
const managedDir = managedConfigDir()
const MANAGED_PLIST_DOMAIN = "ai.opencode.managed"
// Keys injected by macOS/MDM into the managed plist that are not OpenCode config
const PLIST_META = new Set([
"PayloadDisplayName",
"PayloadIdentifier",
"PayloadType",
"PayloadUUID",
"PayloadVersion",
"_manualProfile",
])
/**
* Parse raw JSON (from plutil conversion of a managed plist) into OpenCode config.
* Strips MDM metadata keys before parsing through the config schema.
* Pure function — no OS interaction, safe to unit test directly.
*/
export function parseManagedPlist(json: string, source: string): Info {
const raw = JSON.parse(json)
for (const key of Object.keys(raw)) {
if (PLIST_META.has(key)) delete raw[key]
}
return parseConfig(JSON.stringify(raw), source)
}
/**
* Read macOS managed preferences deployed via .mobileconfig / MDM (Jamf, Kandji, etc).
* MDM-installed profiles write to /Library/Managed Preferences/ which is only writable by root.
* User-scoped plists are checked first, then machine-scoped.
*/
async function readManagedPreferences(): Promise<Info> {
if (process.platform !== "darwin") return {}
const domain = MANAGED_PLIST_DOMAIN
const user = os.userInfo().username
const paths = [
path.join("/Library/Managed Preferences", user, `${domain}.plist`),
path.join("/Library/Managed Preferences", `${domain}.plist`),
]
for (const plist of paths) {
if (!existsSync(plist)) continue
log.info("reading macOS managed preferences", { path: plist })
const result = await Process.run(["plutil", "-convert", "json", "-o", "-", plist], { nothrow: true })
if (result.code !== 0) {
log.warn("failed to convert managed preferences plist", { path: plist })
continue
}
return parseManagedPlist(result.stdout.toString(), `mobileconfig:${plist}`)
}
return {}
}
// Custom merge function that concatenates array fields instead of replacing them // Custom merge function that concatenates array fields instead of replacing them
function mergeConfigConcatArrays(target: Info, source: Info): Info { function mergeConfigConcatArrays(target: Info, source: Info): Info {
const merged = mergeDeep(target, source) const merged = mergeDeep(target, source)
@@ -154,44 +82,6 @@ function trim(file: string) {
return ext.length ? file.slice(0, -ext.length) : file return ext.length ? file.slice(0, -ext.length) : file
} }
async function loadCommand(dir: string) {
const result: Record<string, Command> = {}
for (const item of await Glob.scan("{command,commands}/**/*.md", {
cwd: dir,
absolute: true,
dot: true,
symlink: true,
})) {
const md = await ConfigMarkdown.parse(item).catch(async (err) => {
const message = ConfigMarkdown.FrontmatterError.isInstance(err)
? err.data.message
: `Failed to parse command ${item}`
const { Session } = await import("@/session")
void Bus.publish(Session.Event.Error, { error: new NamedError.Unknown({ message }).toObject() })
log.error("failed to load command", { command: item, err })
return undefined
})
if (!md) continue
const patterns = ["/.opencode/command/", "/.opencode/commands/", "/command/", "/commands/"]
const file = rel(item, patterns) ?? path.basename(item)
const name = trim(file)
const config = {
name,
...md.data,
template: md.content.trim(),
}
const parsed = Command.safeParse(config)
if (parsed.success) {
result[config.name] = parsed.data
continue
}
throw new InvalidError({ path: item, issues: parsed.error.issues }, { cause: parsed.error })
}
return result
}
async function loadAgent(dir: string) { async function loadAgent(dir: string) {
const result: Record<string, Agent> = {} const result: Record<string, Agent> = {}
@@ -267,60 +157,6 @@ async function loadMode(dir: string) {
return result return result
} }
async function loadPlugin(dir: string) {
const plugins: PluginSpec[] = []
for (const item of await Glob.scan("{plugin,plugins}/*.{ts,js}", {
cwd: dir,
absolute: true,
dot: true,
symlink: true,
})) {
plugins.push(pathToFileURL(item).href)
}
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 async function resolvePluginSpec(plugin: PluginSpec, configFilepath: string): Promise<PluginSpec> {
const spec = pluginSpecifier(plugin)
if (!isPathPluginSpec(spec)) return plugin
const base = path.dirname(configFilepath)
const file = (() => {
if (spec.startsWith("file://")) return spec
if (path.isAbsolute(spec) || /^[A-Za-z]:[\\/]/.test(spec)) return pathToFileURL(spec).href
return pathToFileURL(path.resolve(base, spec)).href
})()
const resolved = await resolvePathPluginTarget(file).catch(() => file)
if (Array.isArray(plugin)) return [resolved, plugin[1]]
return resolved
}
export function deduplicatePluginOrigins(plugins: PluginOrigin[]): PluginOrigin[] {
const seen = new Set<string>()
const list: PluginOrigin[] = []
for (const plugin of plugins.toReversed()) {
const spec = pluginSpecifier(plugin.spec)
const name = spec.startsWith("file://") ? spec : parsePluginSpecifier(spec).pkg
if (seen.has(name)) continue
seen.add(name)
list.push(plugin)
}
return list.toReversed()
}
export const McpLocal = z export const McpLocal = z
.object({ .object({
type: z.literal("local").describe("Type of MCP server connection"), type: z.literal("local").describe("Type of MCP server connection"),
@@ -453,15 +289,6 @@ export const Permission = z
}) })
export type Permission = z.infer<typeof Permission> export type Permission = z.infer<typeof Permission>
export const Command = z.object({
template: z.string(),
description: z.string().optional(),
agent: z.string().optional(),
model: ModelId.optional(),
subtask: z.boolean().optional(),
})
export type Command = z.infer<typeof Command>
export const Skills = z.object({ export const Skills = z.object({
paths: z.array(z.string()).optional().describe("Additional paths to skill folders"), paths: z.array(z.string()).optional().describe("Additional paths to skill folders"),
urls: z urls: z
@@ -854,7 +681,7 @@ export const Info = z
logLevel: Log.Level.optional().describe("Log level"), logLevel: Log.Level.optional().describe("Log level"),
server: Server.optional().describe("Server configuration for opencode serve and web commands"), server: Server.optional().describe("Server configuration for opencode serve and web commands"),
command: z command: z
.record(z.string(), Command) .record(z.string(), ConfigCommand.Info)
.optional() .optional()
.describe("Command configuration, see https://opencode.ai/docs/commands"), .describe("Command configuration, see https://opencode.ai/docs/commands"),
skills: Skills.optional().describe("Additional skill folder paths"), skills: Skills.optional().describe("Additional skill folder paths"),
@@ -1095,7 +922,7 @@ function writable(info: Info) {
return next return next
} }
function parseConfig(text: string, filepath: string): Info { export function parseConfig(text: string, filepath: string): Info {
const errors: JsoncParseError[] = [] const errors: JsoncParseError[] = []
const data = parseJsonc(text, errors, { allowTrailingComma: true }) const data = parseJsonc(text, errors, { allowTrailingComma: true })
if (errors.length) { if (errors.length) {
@@ -1193,7 +1020,7 @@ export const layer = Layer.effect(
if (data.plugin && isFile) { if (data.plugin && isFile) {
const list = data.plugin const list = data.plugin
for (let i = 0; i < list.length; i++) { for (let i = 0; i < list.length; i++) {
list[i] = yield* Effect.promise(() => resolvePluginSpec(list[i], options.path)) list[i] = yield* Effect.promise(() => ConfigPlugin.resolvePluginSpec(list[i], options.path))
} }
} }
return data return data
@@ -1253,7 +1080,7 @@ export const layer = Layer.effect(
return yield* cachedGlobal return yield* cachedGlobal
}) })
const setupConfigDir = Effect.fnUntraced(function* (dir: string) { const ensureGitignore = Effect.fn("Config.ensureGitignore")(function* (dir: string) {
const gitignore = path.join(dir, ".gitignore") const gitignore = path.join(dir, ".gitignore")
const hasIgnore = yield* fs.existsSafe(gitignore) const hasIgnore = yield* fs.existsSafe(gitignore)
if (!hasIgnore) { if (!hasIgnore) {
@@ -1262,9 +1089,6 @@ export const layer = Layer.effect(
["node_modules", "package.json", "package-lock.json", "bun.lock", ".gitignore"].join("\n"), ["node_modules", "package.json", "package-lock.json", "bun.lock", ".gitignore"].join("\n"),
) )
} }
yield* npmSvc.install(dir, {
add: ["@opencode-ai/plugin" + (InstallationLocal ? "" : "@" + InstallationVersion)],
})
}) })
const loadInstanceState = Effect.fn("Config.loadInstanceState")(function* (ctx: InstanceContext) { const loadInstanceState = Effect.fn("Config.loadInstanceState")(function* (ctx: InstanceContext) {
@@ -1284,7 +1108,7 @@ export const layer = Layer.effect(
const track = Effect.fnUntraced(function* (source: string, list: PluginSpec[] | undefined, kind?: PluginScope) { const track = Effect.fnUntraced(function* (source: string, list: PluginSpec[] | undefined, kind?: PluginScope) {
if (!list?.length) return if (!list?.length) return
const hit = kind ?? (yield* scope(source)) const hit = kind ?? (yield* scope(source))
const plugins = deduplicatePluginOrigins([ const plugins = ConfigPlugin.deduplicatePluginOrigins([
...(result.plugin_origins ?? []), ...(result.plugin_origins ?? []),
...list.map((spec) => ({ spec, source, scope: hit })), ...list.map((spec) => ({ spec, source, scope: hit })),
]) ])
@@ -1347,7 +1171,7 @@ export const layer = Layer.effect(
const deps: Fiber.Fiber<void, never>[] = [] const deps: Fiber.Fiber<void, never>[] = []
for (const dir of unique(directories)) { for (const dir of directories) {
if (dir.endsWith(".opencode") || dir === Flag.OPENCODE_CONFIG_DIR) { if (dir.endsWith(".opencode") || dir === Flag.OPENCODE_CONFIG_DIR) {
for (const file of ["opencode.json", "opencode.jsonc"]) { for (const file of ["opencode.json", "opencode.jsonc"]) {
const source = path.join(dir, file) const source = path.join(dir, file)
@@ -1359,24 +1183,30 @@ export const layer = Layer.effect(
} }
} }
const dep = yield* setupConfigDir(dir).pipe( yield* ensureGitignore(dir).pipe(Effect.forkScoped)
Effect.exit,
Effect.tap((exit) => const dep = yield* npmSvc
Exit.isFailure(exit) .install(dir, {
? Effect.sync(() => { add: ["@opencode-ai/plugin" + (InstallationLocal ? "" : "@" + InstallationVersion)],
log.warn("background dependency install failed", { dir, error: String(exit.cause) }) })
}) .pipe(
: Effect.void, Effect.exit,
), Effect.tap((exit) =>
Effect.asVoid, Exit.isFailure(exit)
Effect.forkScoped, ? Effect.sync(() => {
) log.warn("background dependency install failed", { dir, error: String(exit.cause) })
})
: Effect.void,
),
Effect.asVoid,
Effect.forkDetach,
)
deps.push(dep) deps.push(dep)
result.command = mergeDeep(result.command ?? {}, yield* Effect.promise(() => loadCommand(dir))) result.command = mergeDeep(result.command ?? {}, yield* Effect.promise(() => ConfigCommand.load(dir)))
result.agent = mergeDeep(result.agent, yield* Effect.promise(() => loadAgent(dir))) result.agent = mergeDeep(result.agent, yield* Effect.promise(() => loadAgent(dir)))
result.agent = mergeDeep(result.agent, yield* Effect.promise(() => loadMode(dir))) result.agent = mergeDeep(result.agent, yield* Effect.promise(() => loadMode(dir)))
const list = yield* Effect.promise(() => loadPlugin(dir)) const list = yield* Effect.promise(() => ConfigPlugin.load(dir))
yield* track(dir, list) yield* track(dir, list)
} }
@@ -1429,6 +1259,7 @@ export const layer = Layer.effect(
) )
} }
const managedDir = ConfigManaged.managedConfigDir()
if (existsSync(managedDir)) { if (existsSync(managedDir)) {
for (const file of ["opencode.json", "opencode.jsonc"]) { for (const file of ["opencode.json", "opencode.jsonc"]) {
const source = path.join(managedDir, file) const source = path.join(managedDir, file)
@@ -1437,7 +1268,7 @@ export const layer = Layer.effect(
} }
// macOS managed preferences (.mobileconfig deployed via MDM) override everything // macOS managed preferences (.mobileconfig deployed via MDM) override everything
result = mergeConfigConcatArrays(result, yield* Effect.promise(() => readManagedPreferences())) result = mergeConfigConcatArrays(result, yield* Effect.promise(() => ConfigManaged.readManagedPreferences()))
for (const [name, mode] of Object.entries(result.mode ?? {})) { for (const [name, mode] of Object.entries(result.mode ?? {})) {
result.agent = mergeDeep(result.agent ?? {}, { result.agent = mergeDeep(result.agent ?? {}, {
+2
View File
@@ -1,3 +1,5 @@
export * as Config from "./config" export * as Config from "./config"
export * as ConfigCommand from "./command"
export { ConfigManaged } from "./managed"
export * as ConfigMarkdown from "./markdown" export * as ConfigMarkdown from "./markdown"
export * as ConfigPaths from "./paths" export * as ConfigPaths from "./paths"
+71
View File
@@ -0,0 +1,71 @@
import { existsSync } from "fs"
import os from "os"
import path from "path"
import { type Info, parseConfig } from "./config"
import { Log, Process } from "../util"
const log = Log.create({ service: "config" })
const MANAGED_PLIST_DOMAIN = "ai.opencode.managed"
// Keys injected by macOS/MDM into the managed plist that are not OpenCode config
const PLIST_META = new Set([
"PayloadDisplayName",
"PayloadIdentifier",
"PayloadType",
"PayloadUUID",
"PayloadVersion",
"_manualProfile",
])
function systemManagedConfigDir(): string {
switch (process.platform) {
case "darwin":
return "/Library/Application Support/opencode"
case "win32":
return path.join(process.env.ProgramData || "C:\\ProgramData", "opencode")
default:
return "/etc/opencode"
}
}
function managedConfigDir() {
return process.env.OPENCODE_TEST_MANAGED_CONFIG_DIR || systemManagedConfigDir()
}
function parseManagedPlist(json: string, source: string): Info {
const raw = JSON.parse(json)
for (const key of Object.keys(raw)) {
if (PLIST_META.has(key)) delete raw[key]
}
return parseConfig(JSON.stringify(raw), source)
}
async function readManagedPreferences(): Promise<Info> {
if (process.platform !== "darwin") return {}
const user = os.userInfo().username
const paths = [
path.join("/Library/Managed Preferences", user, `${MANAGED_PLIST_DOMAIN}.plist`),
path.join("/Library/Managed Preferences", `${MANAGED_PLIST_DOMAIN}.plist`),
]
for (const plist of paths) {
if (!existsSync(plist)) continue
log.info("reading macOS managed preferences", { path: plist })
const result = await Process.run(["plutil", "-convert", "json", "-o", "-", plist], { nothrow: true })
if (result.code !== 0) {
log.warn("failed to convert managed preferences plist", { path: plist })
continue
}
return parseManagedPlist(result.stdout.toString(), `mobileconfig:${plist}`)
}
return {}
}
export const ConfigManaged = {
managedConfigDir,
parseManagedPlist,
readManagedPreferences,
}
+3 -2
View File
@@ -6,13 +6,14 @@ import { NamedError } from "@opencode-ai/shared/util/error"
import { Filesystem } from "@/util" import { Filesystem } from "@/util"
import { Flag } from "@/flag/flag" import { Flag } from "@/flag/flag"
import { Global } from "@/global" import { Global } from "@/global"
import { unique } from "remeda"
export async function projectFiles(name: string, directory: string, worktree?: string) { export async function projectFiles(name: string, directory: string, worktree?: string) {
return Filesystem.findUp([`${name}.json`, `${name}.jsonc`], directory, worktree, { rootFirst: true }) return Filesystem.findUp([`${name}.json`, `${name}.jsonc`], directory, worktree, { rootFirst: true })
} }
export async function directories(directory: string, worktree?: string) { export async function directories(directory: string, worktree?: string) {
return [ return unique([
Global.Path.config, Global.Path.config,
...(!Flag.OPENCODE_DISABLE_PROJECT_CONFIG ...(!Flag.OPENCODE_DISABLE_PROJECT_CONFIG
? await Array.fromAsync( ? await Array.fromAsync(
@@ -31,7 +32,7 @@ export async function directories(directory: string, worktree?: string) {
}), }),
)), )),
...(Flag.OPENCODE_CONFIG_DIR ? [Flag.OPENCODE_CONFIG_DIR] : []), ...(Flag.OPENCODE_CONFIG_DIR ? [Flag.OPENCODE_CONFIG_DIR] : []),
] ])
} }
export function fileInDirectory(dir: string, name: string) { export function fileInDirectory(dir: string, name: string) {
+14 -15
View File
@@ -1,7 +1,7 @@
import { test, expect, describe, mock, afterEach, beforeEach, spyOn } from "bun:test" import { test, expect, describe, mock, afterEach, beforeEach } from "bun:test"
import { Deferred, Effect, Fiber, Layer, Option } from "effect" import { Effect, Layer, Option } from "effect"
import { NodeFileSystem, NodePath } from "@effect/platform-node" import { NodeFileSystem, NodePath } from "@effect/platform-node"
import { Config } from "../../src/config" import { Config, ConfigManaged } from "../../src/config"
import { EffectFlock } from "@opencode-ai/shared/util/effect-flock" import { EffectFlock } from "@opencode-ai/shared/util/effect-flock"
import { Instance } from "../../src/project/instance" import { Instance } from "../../src/project/instance"
@@ -10,7 +10,7 @@ import { AccessToken, Account, AccountID, OrgID } from "../../src/account"
import { AppFileSystem } from "@opencode-ai/shared/filesystem" import { AppFileSystem } from "@opencode-ai/shared/filesystem"
import { Env } from "../../src/env" import { Env } from "../../src/env"
import { provideTmpdirInstance } from "../fixture/fixture" import { provideTmpdirInstance } from "../fixture/fixture"
import { tmpdir, tmpdirScoped } from "../fixture/fixture" import { tmpdir } from "../fixture/fixture"
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner" import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
import { testEffect } from "../lib/effect" import { testEffect } from "../lib/effect"
@@ -24,7 +24,6 @@ import { pathToFileURL } from "url"
import { Global } from "../../src/global" import { Global } from "../../src/global"
import { ProjectID } from "../../src/project/schema" import { ProjectID } from "../../src/project/schema"
import { Filesystem } from "../../src/util" import { Filesystem } from "../../src/util"
import * as Network from "../../src/util/network"
import { ConfigPlugin } from "@/config/plugin" import { ConfigPlugin } from "@/config/plugin"
import { Npm } from "@opencode-ai/shared/npm" import { Npm } from "@opencode-ai/shared/npm"
@@ -1860,14 +1859,14 @@ describe("resolvePluginSpec", () => {
}) })
const file = path.join(tmp.path, "opencode.json") const file = path.join(tmp.path, "opencode.json")
const hit = await Config.resolvePluginSpec("./plugin", file) const hit = await ConfigPlugin.resolvePluginSpec("./plugin", file)
expect(Config.pluginSpecifier(hit)).toBe(pathToFileURL(path.join(tmp.path, "plugin", "index.ts")).href) expect(ConfigPlugin.pluginSpecifier(hit)).toBe(pathToFileURL(path.join(tmp.path, "plugin", "index.ts")).href)
}) })
}) })
describe("deduplicatePluginOrigins", () => { describe("deduplicatePluginOrigins", () => {
const dedupe = (plugins: Config.PluginSpec[]) => const dedupe = (plugins: Config.PluginSpec[]) =>
Config.deduplicatePluginOrigins( ConfigPlugin.deduplicatePluginOrigins(
plugins.map((spec) => ({ plugins.map((spec) => ({
spec, spec,
source: "", source: "",
@@ -1937,8 +1936,8 @@ describe("deduplicatePluginOrigins", () => {
const config = await load() const config = await load()
const plugins = config.plugin ?? [] const plugins = config.plugin ?? []
expect(plugins.some((p) => Config.pluginSpecifier(p) === "my-plugin@1.0.0")).toBe(true) expect(plugins.some((p) => ConfigPlugin.pluginSpecifier(p) === "my-plugin@1.0.0")).toBe(true)
expect(plugins.some((p) => Config.pluginSpecifier(p).startsWith("file://"))).toBe(true) expect(plugins.some((p) => ConfigPlugin.pluginSpecifier(p).startsWith("file://"))).toBe(true)
}, },
}) })
}) })
@@ -2209,7 +2208,7 @@ describe("OPENCODE_CONFIG_CONTENT token substitution", () => {
// parseManagedPlist unit tests — pure function, no OS interaction // parseManagedPlist unit tests — pure function, no OS interaction
test("parseManagedPlist strips MDM metadata keys", async () => { test("parseManagedPlist strips MDM metadata keys", async () => {
const config = await Config.parseManagedPlist( const config = await ConfigManaged.parseManagedPlist(
JSON.stringify({ JSON.stringify({
PayloadDisplayName: "OpenCode Managed", PayloadDisplayName: "OpenCode Managed",
PayloadIdentifier: "ai.opencode.managed.test", PayloadIdentifier: "ai.opencode.managed.test",
@@ -2231,7 +2230,7 @@ test("parseManagedPlist strips MDM metadata keys", async () => {
}) })
test("parseManagedPlist parses server settings", async () => { test("parseManagedPlist parses server settings", async () => {
const config = await Config.parseManagedPlist( const config = await ConfigManaged.parseManagedPlist(
JSON.stringify({ JSON.stringify({
$schema: "https://opencode.ai/config.json", $schema: "https://opencode.ai/config.json",
server: { hostname: "127.0.0.1", mdns: false }, server: { hostname: "127.0.0.1", mdns: false },
@@ -2245,7 +2244,7 @@ test("parseManagedPlist parses server settings", async () => {
}) })
test("parseManagedPlist parses permission rules", async () => { test("parseManagedPlist parses permission rules", async () => {
const config = await Config.parseManagedPlist( const config = await ConfigManaged.parseManagedPlist(
JSON.stringify({ JSON.stringify({
$schema: "https://opencode.ai/config.json", $schema: "https://opencode.ai/config.json",
permission: { permission: {
@@ -2269,7 +2268,7 @@ test("parseManagedPlist parses permission rules", async () => {
}) })
test("parseManagedPlist parses enabled_providers", async () => { test("parseManagedPlist parses enabled_providers", async () => {
const config = await Config.parseManagedPlist( const config = await ConfigManaged.parseManagedPlist(
JSON.stringify({ JSON.stringify({
$schema: "https://opencode.ai/config.json", $schema: "https://opencode.ai/config.json",
enabled_providers: ["anthropic", "google"], enabled_providers: ["anthropic", "google"],
@@ -2280,7 +2279,7 @@ test("parseManagedPlist parses enabled_providers", async () => {
}) })
test("parseManagedPlist handles empty config", async () => { test("parseManagedPlist handles empty config", async () => {
const config = await Config.parseManagedPlist( const config = await ConfigManaged.parseManagedPlist(
JSON.stringify({ $schema: "https://opencode.ai/config.json" }), JSON.stringify({ $schema: "https://opencode.ai/config.json" }),
"test:mobileconfig", "test:mobileconfig",
) )
+23 -20
View File
@@ -142,7 +142,7 @@ export namespace Npm {
yield* flock.acquire(`npm-install:${dir}`) yield* flock.acquire(`npm-install:${dir}`)
const reify = Effect.fnUntraced(function* () { const reify = Effect.fn("Npm.reify")(function* () {
const { Arborist } = yield* Effect.promise(() => import("@npmcli/arborist")) const { Arborist } = yield* Effect.promise(() => import("@npmcli/arborist"))
const arb = new Arborist({ const arb = new Arborist({
path: dir, path: dir,
@@ -176,28 +176,31 @@ export namespace Npm {
const pkgAny = pkg as any const pkgAny = pkg as any
const lockAny = lock as any const lockAny = lock as any
const declared = new Set([ yield* Effect.gen(function* () {
...Object.keys(pkgAny?.dependencies || {}), const declared = new Set([
...Object.keys(pkgAny?.devDependencies || {}), ...Object.keys(pkgAny?.dependencies || {}),
...Object.keys(pkgAny?.peerDependencies || {}), ...Object.keys(pkgAny?.devDependencies || {}),
...Object.keys(pkgAny?.optionalDependencies || {}), ...Object.keys(pkgAny?.peerDependencies || {}),
...(input?.add || []), ...Object.keys(pkgAny?.optionalDependencies || {}),
]) ...(input?.add || []),
])
const root = lockAny?.packages?.[""] || {} const root = lockAny?.packages?.[""] || {}
const locked = new Set([ const locked = new Set([
...Object.keys(root?.dependencies || {}), ...Object.keys(root?.dependencies || {}),
...Object.keys(root?.devDependencies || {}), ...Object.keys(root?.devDependencies || {}),
...Object.keys(root?.peerDependencies || {}), ...Object.keys(root?.peerDependencies || {}),
...Object.keys(root?.optionalDependencies || {}), ...Object.keys(root?.optionalDependencies || {}),
]) ])
for (const name of declared) { for (const name of declared) {
if (!locked.has(name)) { if (!locked.has(name)) {
yield* reify() yield* reify()
return return
}
} }
} }).pipe(Effect.withSpan("Npm.checkDirty"))
return
}, Effect.scoped) }, Effect.scoped)
const which = Effect.fn("Npm.which")(function* (pkg: string) { const which = Effect.fn("Npm.which")(function* (pkg: string) {