summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
-rw-r--r--packages/opencode/src/cli/cmd/tui/plugin/runtime.ts72
-rw-r--r--packages/opencode/src/config/command.ts76
-rw-r--r--packages/opencode/src/config/config.ts233
-rw-r--r--packages/opencode/src/config/index.ts2
-rw-r--r--packages/opencode/src/config/managed.ts71
-rw-r--r--packages/opencode/src/config/paths.ts5
-rw-r--r--packages/opencode/test/config/config.test.ts29
-rw-r--r--packages/shared/src/npm.ts47
8 files changed, 265 insertions, 270 deletions
diff --git a/packages/opencode/src/cli/cmd/tui/plugin/runtime.ts b/packages/opencode/src/cli/cmd/tui/plugin/runtime.ts
index af37ffbd7..ac1c0fc3b 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)
-
- 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)
- }
+ 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)
+ }
+ },
+ })
} 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 000000000..4b2d58f3f
--- /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<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
+ }
+}
diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts
index 97e7a662d..3922357f2 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<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
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<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) {
const result: Record<string, Agent> = {}
@@ -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<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
.object({
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 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({
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<void, never>[] = []
- 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 fbcca1aa9..8380d370d 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 000000000..61c535185
--- /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<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,
+}
diff --git a/packages/opencode/src/config/paths.ts b/packages/opencode/src/config/paths.ts
index eeb9d62d3..fabd3fd5f 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 1f3631244..303fa8ba0 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) === "[email protected]")).toBe(true)
- expect(plugins.some((p) => Config.pluginSpecifier(p).startsWith("file://"))).toBe(true)
+ expect(plugins.some((p) => ConfigPlugin.pluginSpecifier(p) === "[email protected]")).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 955cafa19..e4f42227d 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 || []),
- ])
-
- 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
+ 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 || {}),
+ ])
+
+ 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) {