fix(plugin-config): merge user config from default and custom opencode dirs
This commit is contained in:
@@ -1,4 +1,4 @@
|
|||||||
import { afterEach, describe, expect, it, mock } from "bun:test";
|
import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test";
|
||||||
import { chmodSync, existsSync, mkdtempSync, mkdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs"
|
import { chmodSync, existsSync, mkdtempSync, mkdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs"
|
||||||
import { tmpdir } from "node:os"
|
import { tmpdir } from "node:os"
|
||||||
import { join } from "node:path"
|
import { join } from "node:path"
|
||||||
@@ -23,6 +23,7 @@ afterEach(() => {
|
|||||||
mock.restore()
|
mock.restore()
|
||||||
clearConfigLoadErrors()
|
clearConfigLoadErrors()
|
||||||
delete process.env.OPENCODE_CONFIG_DIR
|
delete process.env.OPENCODE_CONFIG_DIR
|
||||||
|
delete process.env.XDG_CONFIG_HOME
|
||||||
|
|
||||||
for (const dir of tempDirs.splice(0)) {
|
for (const dir of tempDirs.splice(0)) {
|
||||||
rmSync(dir, { recursive: true, force: true })
|
rmSync(dir, { recursive: true, force: true })
|
||||||
@@ -426,6 +427,12 @@ describe("loadConfigFromPath agent_order warnings", () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
describe("loadPluginConfig", () => {
|
describe("loadPluginConfig", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
const isolatedXdgRoot = mkdtempSync(join(tmpdir(), "omo-plugin-config-xdg-"))
|
||||||
|
tempDirs.push(isolatedXdgRoot)
|
||||||
|
process.env.XDG_CONFIG_HOME = isolatedXdgRoot
|
||||||
|
})
|
||||||
|
|
||||||
it("should only honor mcp_env_allowlist from user config", async () => {
|
it("should only honor mcp_env_allowlist from user config", async () => {
|
||||||
// given
|
// given
|
||||||
const rootDir = mkdtempSync(join(tmpdir(), "omo-plugin-config-"))
|
const rootDir = mkdtempSync(join(tmpdir(), "omo-plugin-config-"))
|
||||||
@@ -757,6 +764,39 @@ describe("loadPluginConfig", () => {
|
|||||||
expect(config.agents?.oracle?.model).toBe("project/model")
|
expect(config.agents?.oracle?.model).toBe("project/model")
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it("should load user config from the default global directory even when OPENCODE_CONFIG_DIR is set", async () => {
|
||||||
|
// given
|
||||||
|
const rootDir = mkdtempSync(join(tmpdir(), "omo-plugin-config-additive-user-"))
|
||||||
|
const defaultGlobalConfigDir = join(rootDir, "xdg", "opencode")
|
||||||
|
const customConfigDir = join(rootDir, "custom-opencode")
|
||||||
|
const projectDir = join(rootDir, "project")
|
||||||
|
|
||||||
|
tempDirs.push(rootDir)
|
||||||
|
mkdirSync(defaultGlobalConfigDir, { recursive: true })
|
||||||
|
mkdirSync(customConfigDir, { recursive: true })
|
||||||
|
mkdirSync(join(projectDir, ".opencode"), { recursive: true })
|
||||||
|
|
||||||
|
writeFileSync(
|
||||||
|
join(defaultGlobalConfigDir, "oh-my-openagent.jsonc"),
|
||||||
|
JSON.stringify({ agents: { oracle: { model: "default/oracle" } } }),
|
||||||
|
)
|
||||||
|
writeFileSync(
|
||||||
|
join(customConfigDir, "oh-my-openagent.jsonc"),
|
||||||
|
JSON.stringify({ agents: { hephaestus: { model: "custom/hephaestus" } } }),
|
||||||
|
)
|
||||||
|
|
||||||
|
process.env.XDG_CONFIG_HOME = join(rootDir, "xdg")
|
||||||
|
process.env.OPENCODE_CONFIG_DIR = customConfigDir
|
||||||
|
|
||||||
|
// when
|
||||||
|
const { loadPluginConfig } = await importFreshPluginConfigModule()
|
||||||
|
const config = loadPluginConfig(projectDir, {})
|
||||||
|
|
||||||
|
// then
|
||||||
|
expect(config.agents?.oracle?.model).toBe("default/oracle")
|
||||||
|
expect(config.agents?.hephaestus?.model).toBe("custom/hephaestus")
|
||||||
|
})
|
||||||
|
|
||||||
it("should layer ancestor configs so each contributes fields not overridden by closer ones", async () => {
|
it("should layer ancestor configs so each contributes fields not overridden by closer ones", async () => {
|
||||||
// given
|
// given
|
||||||
const rootDir = mkdtempSync(join(tmpdir(), "omo-plugin-config-walk-layer-"))
|
const rootDir = mkdtempSync(join(tmpdir(), "omo-plugin-config-walk-layer-"))
|
||||||
|
|||||||
+46
-32
@@ -6,7 +6,7 @@ import {
|
|||||||
log,
|
log,
|
||||||
containsPath,
|
containsPath,
|
||||||
deepMerge,
|
deepMerge,
|
||||||
getOpenCodeConfigDir,
|
getOpenCodeConfigDirs,
|
||||||
addConfigLoadError,
|
addConfigLoadError,
|
||||||
parseJsonc,
|
parseJsonc,
|
||||||
detectPluginConfigFile,
|
detectPluginConfigFile,
|
||||||
@@ -275,25 +275,23 @@ export function loadPluginConfig(
|
|||||||
directory: string,
|
directory: string,
|
||||||
ctx: unknown
|
ctx: unknown
|
||||||
): OhMyOpenCodeConfig {
|
): OhMyOpenCodeConfig {
|
||||||
// User-level config path - prefer .jsonc over .json
|
const userConfigDirs = [...getOpenCodeConfigDirs({ binary: "opencode" })].reverse()
|
||||||
const configDir = getOpenCodeConfigDir({ binary: "opencode" });
|
const userConfigLayers = userConfigDirs.map((configDir) => {
|
||||||
const userDetected = detectPluginConfigFile(configDir);
|
const detected = detectPluginConfigFile(configDir)
|
||||||
let userConfigPath =
|
|
||||||
userDetected.format !== "none"
|
|
||||||
? userDetected.path
|
|
||||||
: path.join(configDir, `${CONFIG_BASENAME}.json`);
|
|
||||||
|
|
||||||
if (userDetected.legacyPath) {
|
if (detected.legacyPath) {
|
||||||
log("Canonical plugin config detected alongside legacy config. Remove the legacy file to avoid confusion.", {
|
log("Canonical plugin config detected alongside legacy config. Remove the legacy file to avoid confusion.", {
|
||||||
canonicalPath: userDetected.path,
|
canonicalPath: detected.path,
|
||||||
legacyPath: userDetected.legacyPath,
|
legacyPath: detected.legacyPath,
|
||||||
});
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Auto-copy legacy config file to canonical name if needed
|
const configPath = detected.format !== "none"
|
||||||
if (userDetected.format !== "none") {
|
? resolveConfigPathAfterLegacyMigration(detected.path)
|
||||||
userConfigPath = resolveConfigPathAfterLegacyMigration(userConfigPath)
|
: null
|
||||||
}
|
|
||||||
|
return { configDir, configPath }
|
||||||
|
})
|
||||||
|
|
||||||
// Pin the walk to $HOME only when the start directory is inside it. Outside
|
// Pin the walk to $HOME only when the start directory is inside it. Outside
|
||||||
// $HOME the walker would otherwise reach FS root and surface unrelated configs
|
// $HOME the walker would otherwise reach FS root and surface unrelated configs
|
||||||
@@ -325,20 +323,36 @@ export function loadPluginConfig(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
// Load user config first (base). Parse empty config through Zod to apply field defaults.
|
let config: OhMyOpenCodeConfig = OhMyOpenCodeConfigSchema.parse({})
|
||||||
const userConfig = loadConfigFromPath(userConfigPath, ctx)
|
let mergedUserGitMasterOverrides: Record<string, unknown> | null = null
|
||||||
const userGitMasterOverrides = loadExplicitGitMasterOverrides(userConfigPath)
|
|
||||||
|
|
||||||
if (userConfig?.agent_definitions) {
|
for (const userLayer of userConfigLayers) {
|
||||||
userConfig.agent_definitions = resolveAgentDefinitionPaths(
|
if (!userLayer.configPath) continue
|
||||||
userConfig.agent_definitions,
|
|
||||||
configDir,
|
const userConfig = loadConfigFromPath(userLayer.configPath, ctx)
|
||||||
null
|
const userGitMasterOverrides = loadExplicitGitMasterOverrides(userLayer.configPath)
|
||||||
)
|
|
||||||
|
if (userConfig?.agent_definitions) {
|
||||||
|
userConfig.agent_definitions = resolveAgentDefinitionPaths(
|
||||||
|
userConfig.agent_definitions,
|
||||||
|
userLayer.configDir,
|
||||||
|
null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (userConfig) {
|
||||||
|
config = mergeConfigs(config, userConfig)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (userGitMasterOverrides) {
|
||||||
|
mergedUserGitMasterOverrides = {
|
||||||
|
...(mergedUserGitMasterOverrides ?? {}),
|
||||||
|
...userGitMasterOverrides,
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let config: OhMyOpenCodeConfig =
|
const userMcpEnvAllowlist = config.mcp_env_allowlist ?? []
|
||||||
userConfig ?? OhMyOpenCodeConfigSchema.parse({});
|
|
||||||
|
|
||||||
const canonicalAncestorPathsFarthestFirst = [...canonicalAncestorPathsNearestFirst].reverse()
|
const canonicalAncestorPathsFarthestFirst = [...canonicalAncestorPathsNearestFirst].reverse()
|
||||||
const defaultGitMaster = OhMyOpenCodeConfigSchema.parse({}).git_master
|
const defaultGitMaster = OhMyOpenCodeConfigSchema.parse({}).git_master
|
||||||
@@ -368,7 +382,7 @@ export function loadPluginConfig(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (userGitMasterOverrides || ancestorGitMasterOverridesFarthestFirst.length > 0) {
|
if (mergedUserGitMasterOverrides || ancestorGitMasterOverridesFarthestFirst.length > 0) {
|
||||||
const mergedAncestorGitMaster: Record<string, unknown> = {}
|
const mergedAncestorGitMaster: Record<string, unknown> = {}
|
||||||
for (const override of ancestorGitMasterOverridesFarthestFirst) {
|
for (const override of ancestorGitMasterOverridesFarthestFirst) {
|
||||||
Object.assign(mergedAncestorGitMaster, override)
|
Object.assign(mergedAncestorGitMaster, override)
|
||||||
@@ -377,7 +391,7 @@ export function loadPluginConfig(
|
|||||||
...config,
|
...config,
|
||||||
git_master: {
|
git_master: {
|
||||||
...defaultGitMaster,
|
...defaultGitMaster,
|
||||||
...(userGitMasterOverrides ?? {}),
|
...(mergedUserGitMasterOverrides ?? {}),
|
||||||
...mergedAncestorGitMaster,
|
...mergedAncestorGitMaster,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -389,7 +403,7 @@ export function loadPluginConfig(
|
|||||||
// expansion in .mcp.json files. See commit 316d2504 for context.
|
// expansion in .mcp.json files. See commit 316d2504 for context.
|
||||||
config = {
|
config = {
|
||||||
...config,
|
...config,
|
||||||
mcp_env_allowlist: userConfig?.mcp_env_allowlist ?? [],
|
mcp_env_allowlist: userMcpEnvAllowlist,
|
||||||
};
|
};
|
||||||
|
|
||||||
log("Final merged config", {
|
log("Final merged config", {
|
||||||
|
|||||||
Reference in New Issue
Block a user