mirror of
https://fastgit.cc/https://github.com/anomalyco/opencode
synced 2026-04-22 05:42:35 +08:00
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:
@@ -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 })
|
||||
}
|
||||
|
||||
76
packages/opencode/src/config/command.ts
Normal file
76
packages/opencode/src/config/command.ts
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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 ?? {}, {
|
||||
|
||||
@@ -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"
|
||||
|
||||
71
packages/opencode/src/config/managed.ts
Normal file
71
packages/opencode/src/config/managed.ts
Normal 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,
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user