From bfffc3c2c6349d9199dd1a73260612b5ec2da88d Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Thu, 16 Apr 2026 12:19:43 -0400 Subject: [PATCH] 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. --- .../src/cli/cmd/tui/plugin/runtime.ts | 70 +++--- packages/opencode/src/config/command.ts | 76 ++++++ packages/opencode/src/config/config.ts | 233 +++--------------- packages/opencode/src/config/index.ts | 2 + packages/opencode/src/config/managed.ts | 71 ++++++ packages/opencode/src/config/paths.ts | 5 +- packages/opencode/test/config/config.test.ts | 29 ++- packages/shared/src/npm.ts | 43 ++-- 8 files changed, 262 insertions(+), 267 deletions(-) create mode 100644 packages/opencode/src/config/command.ts create mode 100644 packages/opencode/src/config/managed.ts diff --git a/packages/opencode/src/cli/cmd/tui/plugin/runtime.ts b/packages/opencode/src/cli/cmd/tui/plugin/runtime.ts index af37ffbd76..ac1c0fc3b8 100644 --- a/packages/opencode/src/cli/cmd/tui/plugin/runtime.ts +++ b/packages/opencode/src/cli/cmd/tui/plugin/runtime.ts @@ -16,6 +16,7 @@ import { TuiConfig } from "@/cli/cmd/tui/config/tui" import { Log } from "@/util" import { errorData, errorMessage } from "@/util/error" import { isRecord } from "@/util/record" +import { Instance } from "@/project/instance" import { readPackageThemes, readPluginId, @@ -789,7 +790,13 @@ async function addPluginBySpec(state: RuntimeState | undefined, raw: string) { state.pending.delete(spec) 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) { return false } @@ -980,37 +987,42 @@ export namespace TuiPluginRuntime { } runtime = next try { - 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 }) - } + await Instance.provide({ + directory: cwd, + 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) { - log.info("loading internal tui plugin", { id: item.id }) - const entry = loadInternalPlugin(item) - const meta = createMeta(entry.source, entry.spec, entry.target, undefined, entry.id) - addPluginEntry(next, { - id: entry.id, - load: entry, - meta, - themes: {}, - plugin: entry.module.tui, - enabled: true, - }) - } + for (const item of INTERNAL_TUI_PLUGINS) { + log.info("loading internal tui plugin", { id: item.id }) + const entry = loadInternalPlugin(item) + const meta = createMeta(entry.source, entry.spec, entry.target, undefined, entry.id) + addPluginEntry(next, { + id: entry.id, + load: entry, + meta, + themes: {}, + plugin: entry.module.tui, + enabled: true, + }) + } - const ready = await resolveExternalPlugins(records, () => TuiConfig.waitForDependencies()) - await addExternalPluginEntries(next, ready) + const ready = await resolveExternalPlugins(records, () => TuiConfig.waitForDependencies()) + await addExternalPluginEntries(next, ready) - applyInitialPluginEnabledState(next, config) - for (const plugin of next.plugins) { - if (!plugin.enabled) continue - // Keep plugin execution sequential for deterministic side effects: - // command registration order affects keybind/command precedence, - // route registration is last-wins when ids collide, - // and hook chains rely on stable plugin ordering. - await activatePluginEntry(next, plugin, false) - } + applyInitialPluginEnabledState(next, config) + for (const plugin of next.plugins) { + if (!plugin.enabled) continue + // Keep plugin execution sequential for deterministic side effects: + // command registration order affects keybind/command precedence, + // route registration is last-wins when ids collide, + // and hook chains rely on stable plugin ordering. + await activatePluginEntry(next, plugin, false) + } + }, + }) } catch (error) { fail("failed to load tui plugins", { directory: cwd, error }) } diff --git a/packages/opencode/src/config/command.ts b/packages/opencode/src/config/command.ts new file mode 100644 index 0000000000..4b2d58f3ff --- /dev/null +++ b/packages/opencode/src/config/command.ts @@ -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 + + export async function load(dir: string) { + const result: Record = {} + 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 + } +} diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 97e7a662d0..3922357f2e 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -2,9 +2,8 @@ import { Log } from "../util" import path from "path" import { pathToFileURL } from "url" import os from "os" -import { Process } from "../util" import z from "zod" -import { mergeDeep, pipe, unique } from "remeda" +import { mergeDeep, pipe } from "remeda" import { Global } from "../global" import fsNode from "fs/promises" import { NamedError } from "@opencode-ai/shared/util/error" @@ -35,10 +34,11 @@ import { AppFileSystem } from "@opencode-ai/shared/filesystem" import { InstanceState } from "@/effect" import { Context, Duration, Effect, Exit, Fiber, Layer, Option } from "effect" import { EffectFlock } from "@opencode-ai/shared/util/effect-flock" - -import { isPathPluginSpec, parsePluginSpecifier, resolvePathPluginTarget } from "@/plugin/shared" import { InstanceRef } from "@/effect/instance-ref" 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 PluginOptions = z.record(z.string(), z.unknown()) @@ -55,78 +55,6 @@ export type PluginOrigin = { 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 { - 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 function mergeConfigConcatArrays(target: Info, source: Info): Info { const merged = mergeDeep(target, source) @@ -154,44 +82,6 @@ function trim(file: string) { return ext.length ? file.slice(0, -ext.length) : file } -async function loadCommand(dir: string) { - const result: Record = {} - 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) { const result: Record = {} @@ -267,60 +157,6 @@ async function loadMode(dir: string) { 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 { - 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() - 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 .object({ type: z.literal("local").describe("Type of MCP server connection"), @@ -453,15 +289,6 @@ export const Permission = z }) export type Permission = z.infer -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 - export const Skills = z.object({ paths: z.array(z.string()).optional().describe("Additional paths to skill folders"), urls: z @@ -854,7 +681,7 @@ export const Info = z logLevel: Log.Level.optional().describe("Log level"), server: Server.optional().describe("Server configuration for opencode serve and web commands"), command: z - .record(z.string(), Command) + .record(z.string(), ConfigCommand.Info) .optional() .describe("Command configuration, see https://opencode.ai/docs/commands"), skills: Skills.optional().describe("Additional skill folder paths"), @@ -1095,7 +922,7 @@ function writable(info: Info) { return next } -function parseConfig(text: string, filepath: string): Info { +export function parseConfig(text: string, filepath: string): Info { const errors: JsoncParseError[] = [] const data = parseJsonc(text, errors, { allowTrailingComma: true }) if (errors.length) { @@ -1193,7 +1020,7 @@ export const layer = Layer.effect( if (data.plugin && isFile) { const list = data.plugin 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 @@ -1253,7 +1080,7 @@ export const layer = Layer.effect( 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 hasIgnore = yield* fs.existsSafe(gitignore) if (!hasIgnore) { @@ -1262,9 +1089,6 @@ export const layer = Layer.effect( ["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) { @@ -1284,7 +1108,7 @@ export const layer = Layer.effect( const track = Effect.fnUntraced(function* (source: string, list: PluginSpec[] | undefined, kind?: PluginScope) { if (!list?.length) return const hit = kind ?? (yield* scope(source)) - const plugins = deduplicatePluginOrigins([ + const plugins = ConfigPlugin.deduplicatePluginOrigins([ ...(result.plugin_origins ?? []), ...list.map((spec) => ({ spec, source, scope: hit })), ]) @@ -1347,7 +1171,7 @@ export const layer = Layer.effect( const deps: Fiber.Fiber[] = [] - for (const dir of unique(directories)) { + for (const dir of directories) { if (dir.endsWith(".opencode") || dir === Flag.OPENCODE_CONFIG_DIR) { for (const file of ["opencode.json", "opencode.jsonc"]) { const source = path.join(dir, file) @@ -1359,24 +1183,30 @@ export const layer = Layer.effect( } } - const dep = yield* setupConfigDir(dir).pipe( - Effect.exit, - Effect.tap((exit) => - Exit.isFailure(exit) - ? Effect.sync(() => { - log.warn("background dependency install failed", { dir, error: String(exit.cause) }) - }) - : Effect.void, - ), - Effect.asVoid, - Effect.forkScoped, - ) + yield* ensureGitignore(dir).pipe(Effect.forkScoped) + + const dep = yield* npmSvc + .install(dir, { + add: ["@opencode-ai/plugin" + (InstallationLocal ? "" : "@" + InstallationVersion)], + }) + .pipe( + Effect.exit, + Effect.tap((exit) => + Exit.isFailure(exit) + ? Effect.sync(() => { + log.warn("background dependency install failed", { dir, error: String(exit.cause) }) + }) + : Effect.void, + ), + Effect.asVoid, + Effect.forkDetach, + ) 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(() => loadMode(dir))) - const list = yield* Effect.promise(() => loadPlugin(dir)) + const list = yield* Effect.promise(() => ConfigPlugin.load(dir)) yield* track(dir, list) } @@ -1429,6 +1259,7 @@ export const layer = Layer.effect( ) } + const managedDir = ConfigManaged.managedConfigDir() if (existsSync(managedDir)) { for (const file of ["opencode.json", "opencode.jsonc"]) { const source = path.join(managedDir, file) @@ -1437,7 +1268,7 @@ export const layer = Layer.effect( } // 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 ?? {})) { result.agent = mergeDeep(result.agent ?? {}, { diff --git a/packages/opencode/src/config/index.ts b/packages/opencode/src/config/index.ts index fbcca1aa9a..8380d370d8 100644 --- a/packages/opencode/src/config/index.ts +++ b/packages/opencode/src/config/index.ts @@ -1,3 +1,5 @@ export * as Config from "./config" +export * as ConfigCommand from "./command" +export { ConfigManaged } from "./managed" export * as ConfigMarkdown from "./markdown" export * as ConfigPaths from "./paths" diff --git a/packages/opencode/src/config/managed.ts b/packages/opencode/src/config/managed.ts new file mode 100644 index 0000000000..61c535185f --- /dev/null +++ b/packages/opencode/src/config/managed.ts @@ -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 { + 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, +} diff --git a/packages/opencode/src/config/paths.ts b/packages/opencode/src/config/paths.ts index eeb9d62d3f..fabd3fd5f8 100644 --- a/packages/opencode/src/config/paths.ts +++ b/packages/opencode/src/config/paths.ts @@ -6,13 +6,14 @@ import { NamedError } from "@opencode-ai/shared/util/error" import { Filesystem } from "@/util" import { Flag } from "@/flag/flag" import { Global } from "@/global" +import { unique } from "remeda" export async function projectFiles(name: string, directory: string, worktree?: string) { return Filesystem.findUp([`${name}.json`, `${name}.jsonc`], directory, worktree, { rootFirst: true }) } export async function directories(directory: string, worktree?: string) { - return [ + return unique([ Global.Path.config, ...(!Flag.OPENCODE_DISABLE_PROJECT_CONFIG ? await Array.fromAsync( @@ -31,7 +32,7 @@ export async function directories(directory: string, worktree?: string) { }), )), ...(Flag.OPENCODE_CONFIG_DIR ? [Flag.OPENCODE_CONFIG_DIR] : []), - ] + ]) } export function fileInDirectory(dir: string, name: string) { diff --git a/packages/opencode/test/config/config.test.ts b/packages/opencode/test/config/config.test.ts index 1f36312447..303fa8ba08 100644 --- a/packages/opencode/test/config/config.test.ts +++ b/packages/opencode/test/config/config.test.ts @@ -1,7 +1,7 @@ -import { test, expect, describe, mock, afterEach, beforeEach, spyOn } from "bun:test" -import { Deferred, Effect, Fiber, Layer, Option } from "effect" +import { test, expect, describe, mock, afterEach, beforeEach } from "bun:test" +import { Effect, Layer, Option } from "effect" 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 { 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 { Env } from "../../src/env" 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 { testEffect } from "../lib/effect" @@ -24,7 +24,6 @@ import { pathToFileURL } from "url" import { Global } from "../../src/global" import { ProjectID } from "../../src/project/schema" import { Filesystem } from "../../src/util" -import * as Network from "../../src/util/network" import { ConfigPlugin } from "@/config/plugin" import { Npm } from "@opencode-ai/shared/npm" @@ -1860,14 +1859,14 @@ describe("resolvePluginSpec", () => { }) const file = path.join(tmp.path, "opencode.json") - const hit = await Config.resolvePluginSpec("./plugin", file) - expect(Config.pluginSpecifier(hit)).toBe(pathToFileURL(path.join(tmp.path, "plugin", "index.ts")).href) + const hit = await ConfigPlugin.resolvePluginSpec("./plugin", file) + expect(ConfigPlugin.pluginSpecifier(hit)).toBe(pathToFileURL(path.join(tmp.path, "plugin", "index.ts")).href) }) }) describe("deduplicatePluginOrigins", () => { const dedupe = (plugins: Config.PluginSpec[]) => - Config.deduplicatePluginOrigins( + ConfigPlugin.deduplicatePluginOrigins( plugins.map((spec) => ({ spec, source: "", @@ -1937,8 +1936,8 @@ describe("deduplicatePluginOrigins", () => { const config = await load() const plugins = config.plugin ?? [] - expect(plugins.some((p) => Config.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) === "my-plugin@1.0.0")).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 test("parseManagedPlist strips MDM metadata keys", async () => { - const config = await Config.parseManagedPlist( + const config = await ConfigManaged.parseManagedPlist( JSON.stringify({ PayloadDisplayName: "OpenCode Managed", PayloadIdentifier: "ai.opencode.managed.test", @@ -2231,7 +2230,7 @@ test("parseManagedPlist strips MDM metadata keys", async () => { }) test("parseManagedPlist parses server settings", async () => { - const config = await Config.parseManagedPlist( + const config = await ConfigManaged.parseManagedPlist( JSON.stringify({ $schema: "https://opencode.ai/config.json", server: { hostname: "127.0.0.1", mdns: false }, @@ -2245,7 +2244,7 @@ test("parseManagedPlist parses server settings", async () => { }) test("parseManagedPlist parses permission rules", async () => { - const config = await Config.parseManagedPlist( + const config = await ConfigManaged.parseManagedPlist( JSON.stringify({ $schema: "https://opencode.ai/config.json", permission: { @@ -2269,7 +2268,7 @@ test("parseManagedPlist parses permission rules", async () => { }) test("parseManagedPlist parses enabled_providers", async () => { - const config = await Config.parseManagedPlist( + const config = await ConfigManaged.parseManagedPlist( JSON.stringify({ $schema: "https://opencode.ai/config.json", enabled_providers: ["anthropic", "google"], @@ -2280,7 +2279,7 @@ test("parseManagedPlist parses enabled_providers", 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" }), "test:mobileconfig", ) diff --git a/packages/shared/src/npm.ts b/packages/shared/src/npm.ts index 955cafa190..e4f42227de 100644 --- a/packages/shared/src/npm.ts +++ b/packages/shared/src/npm.ts @@ -142,7 +142,7 @@ export namespace Npm { 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 arb = new Arborist({ path: dir, @@ -176,28 +176,31 @@ export namespace Npm { const pkgAny = pkg as any const lockAny = lock as any - const declared = new Set([ - ...Object.keys(pkgAny?.dependencies || {}), - ...Object.keys(pkgAny?.devDependencies || {}), - ...Object.keys(pkgAny?.peerDependencies || {}), - ...Object.keys(pkgAny?.optionalDependencies || {}), - ...(input?.add || []), - ]) + yield* Effect.gen(function* () { + const declared = new Set([ + ...Object.keys(pkgAny?.dependencies || {}), + ...Object.keys(pkgAny?.devDependencies || {}), + ...Object.keys(pkgAny?.peerDependencies || {}), + ...Object.keys(pkgAny?.optionalDependencies || {}), + ...(input?.add || []), + ]) - const root = lockAny?.packages?.[""] || {} - const locked = new Set([ - ...Object.keys(root?.dependencies || {}), - ...Object.keys(root?.devDependencies || {}), - ...Object.keys(root?.peerDependencies || {}), - ...Object.keys(root?.optionalDependencies || {}), - ]) + const root = lockAny?.packages?.[""] || {} + const locked = new Set([ + ...Object.keys(root?.dependencies || {}), + ...Object.keys(root?.devDependencies || {}), + ...Object.keys(root?.peerDependencies || {}), + ...Object.keys(root?.optionalDependencies || {}), + ]) - for (const name of declared) { - if (!locked.has(name)) { - yield* reify() - return + for (const name of declared) { + if (!locked.has(name)) { + yield* reify() + return + } } - } + }).pipe(Effect.withSpan("Npm.checkDirty")) + return }, Effect.scoped) const which = Effect.fn("Npm.which")(function* (pkg: string) {