diff --git a/src/cli/doctor/checks/config.ts b/src/cli/doctor/checks/config.ts index 26852db8e..10f3f3bc5 100644 --- a/src/cli/doctor/checks/config.ts +++ b/src/cli/doctor/checks/config.ts @@ -9,7 +9,6 @@ import { loadAvailableModelsFromCache } from "./model-resolution-cache" import { getModelResolutionInfoWithOverrides } from "./model-resolution" import type { OmoConfig } from "./model-resolution-types" -const USER_CONFIG_DIR = getOpenCodeConfigDir({ binary: "opencode" }) const PROJECT_CONFIG_DIR = join(process.cwd(), ".opencode") interface ConfigValidationResult { @@ -24,7 +23,8 @@ function findConfigPath(): string | null { const projectConfig = detectPluginConfigFile(PROJECT_CONFIG_DIR) if (projectConfig.format !== "none") return projectConfig.path - const userConfig = detectPluginConfigFile(USER_CONFIG_DIR) + const userConfigDir = getOpenCodeConfigDir({ binary: "opencode" }) + const userConfig = detectPluginConfigFile(userConfigDir) if (userConfig.format !== "none") return userConfig.path return null diff --git a/src/cli/doctor/checks/model-resolution-config.ts b/src/cli/doctor/checks/model-resolution-config.ts index b9416955f..20c5c3f97 100644 --- a/src/cli/doctor/checks/model-resolution-config.ts +++ b/src/cli/doctor/checks/model-resolution-config.ts @@ -1,9 +1,8 @@ import { readFileSync } from "node:fs" import { join } from "node:path" -import { detectPluginConfigFile, getOpenCodeConfigPaths, parseJsonc } from "../../../shared" +import { detectPluginConfigFile, getOpenCodeConfigDir, parseJsonc } from "../../../shared" import type { OmoConfig } from "./model-resolution-types" -const USER_CONFIG_DIR = getOpenCodeConfigPaths({ binary: "opencode", version: null }).configDir const PROJECT_CONFIG_DIR = join(process.cwd(), ".opencode") export function loadOmoConfig(): OmoConfig | null { @@ -17,7 +16,8 @@ export function loadOmoConfig(): OmoConfig | null { } } - const userDetected = detectPluginConfigFile(USER_CONFIG_DIR) + const userConfigDir = getOpenCodeConfigDir({ binary: "opencode" }) + const userDetected = detectPluginConfigFile(userConfigDir) if (userDetected.format !== "none") { try { const content = readFileSync(userDetected.path, "utf-8") diff --git a/src/hooks/auto-update-checker/cache.ts b/src/hooks/auto-update-checker/cache.ts index 2235bbadd..e2e7a1f64 100644 --- a/src/hooks/auto-update-checker/cache.ts +++ b/src/hooks/auto-update-checker/cache.ts @@ -1,6 +1,6 @@ import * as fs from "node:fs" import * as path from "node:path" -import { CACHE_DIR, PACKAGE_NAME, USER_CONFIG_DIR } from "./constants" +import { CACHE_DIR, PACKAGE_NAME, getUserConfigDir } from "./constants" import { log } from "../../shared/logger" interface BunLockfile { @@ -61,8 +61,9 @@ function removeFromBunLock(packageName: string): boolean { export function invalidatePackage(packageName: string = PACKAGE_NAME): boolean { try { + const userConfigDir = getUserConfigDir() const pkgDirs = [ - path.join(USER_CONFIG_DIR, "node_modules", packageName), + path.join(userConfigDir, "node_modules", packageName), path.join(CACHE_DIR, "node_modules", packageName), ] diff --git a/src/hooks/auto-update-checker/checker/config-paths.ts b/src/hooks/auto-update-checker/checker/config-paths.ts index 998696b6d..a27bb5dab 100644 --- a/src/hooks/auto-update-checker/checker/config-paths.ts +++ b/src/hooks/auto-update-checker/checker/config-paths.ts @@ -1,18 +1,19 @@ import * as os from "node:os" import * as path from "node:path" import { - USER_CONFIG_DIR, - USER_OPENCODE_CONFIG, - USER_OPENCODE_CONFIG_JSONC, + getUserConfigDir, + getUserOpencodeConfig, + getUserOpencodeConfigJsonc, getWindowsAppdataDir, } from "../constants" export function getConfigPaths(directory: string): string[] { + const userConfigDir = getUserConfigDir() const paths = [ path.join(directory, ".opencode", "opencode.json"), path.join(directory, ".opencode", "opencode.jsonc"), - USER_OPENCODE_CONFIG, - USER_OPENCODE_CONFIG_JSONC, + getUserOpencodeConfig(), + getUserOpencodeConfigJsonc(), ] if (process.platform === "win32") { @@ -20,7 +21,7 @@ export function getConfigPaths(directory: string): string[] { const appdataDir = getWindowsAppdataDir() if (appdataDir) { - const alternateDir = USER_CONFIG_DIR === crossPlatformDir ? appdataDir : crossPlatformDir + const alternateDir = userConfigDir === crossPlatformDir ? appdataDir : crossPlatformDir const alternateConfig = path.join(alternateDir, "opencode", "opencode.json") const alternateConfigJsonc = path.join(alternateDir, "opencode", "opencode.jsonc") diff --git a/src/hooks/auto-update-checker/checker/plugin-entry.test.ts b/src/hooks/auto-update-checker/checker/plugin-entry.test.ts index 024ea396f..b3aaf87c4 100644 --- a/src/hooks/auto-update-checker/checker/plugin-entry.test.ts +++ b/src/hooks/auto-update-checker/checker/plugin-entry.test.ts @@ -1,15 +1,51 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test" +import { spawnSync } from "node:child_process" import * as fs from "node:fs" import * as os from "node:os" import * as path from "node:path" -import { PACKAGE_NAME } from "../constants" -import { findPluginEntry } from "./plugin-entry" + +const PACKAGE_NAME = "oh-my-openagent" + +type PluginEntryResult = { + entry: string + isPinned: boolean + pinnedVersion: string | null + configPath: string +} | null + +function runFindPluginEntry( + directory: string, + envOverrides: Record = {}, +): { status: number | null; stdout: string; stderr: string } { + const command = [ + `import { findPluginEntry } from ${JSON.stringify("./src/hooks/auto-update-checker/checker/plugin-entry")};`, + `const result = findPluginEntry(${JSON.stringify(directory)});`, + "console.log(JSON.stringify(result));", + ].join("") + + const execution = spawnSync(process.execPath, ["-e", command], { + cwd: process.cwd(), + env: { + ...process.env, + ...envOverrides, + }, + encoding: "utf-8", + }) + + return { + status: execution.status, + stdout: execution.stdout, + stderr: execution.stderr, + } +} describe("findPluginEntry", () => { let temporaryDirectory: string let configPath: string + let originalConfigDir: string | undefined beforeEach(() => { + originalConfigDir = process.env.OPENCODE_CONFIG_DIR temporaryDirectory = fs.mkdtempSync(path.join(os.tmpdir(), "omo-plugin-entry-test-")) const opencodeDirectory = path.join(temporaryDirectory, ".opencode") fs.mkdirSync(opencodeDirectory, { recursive: true }) @@ -17,58 +53,93 @@ describe("findPluginEntry", () => { }) afterEach(() => { + if (originalConfigDir === undefined) { + delete process.env.OPENCODE_CONFIG_DIR + } else { + process.env.OPENCODE_CONFIG_DIR = originalConfigDir + } fs.rmSync(temporaryDirectory, { recursive: true, force: true }) }) - test("returns unpinned for bare package name", () => { + test("returns unpinned for bare package name", async () => { // #given plugin is configured without a tag fs.writeFileSync(configPath, JSON.stringify({ plugin: [PACKAGE_NAME] })) // #when plugin entry is detected - const pluginInfo = findPluginEntry(temporaryDirectory) + const execution = runFindPluginEntry(temporaryDirectory) // #then entry is not pinned + expect(execution.status).toBe(0) + const pluginInfo = JSON.parse(execution.stdout.trim()) as PluginEntryResult expect(pluginInfo).not.toBeNull() expect(pluginInfo?.isPinned).toBe(false) expect(pluginInfo?.pinnedVersion).toBeNull() }) - test("returns unpinned for latest dist-tag", () => { + test("returns unpinned for latest dist-tag", async () => { // #given plugin is configured with latest dist-tag fs.writeFileSync(configPath, JSON.stringify({ plugin: [`${PACKAGE_NAME}@latest`] })) // #when plugin entry is detected - const pluginInfo = findPluginEntry(temporaryDirectory) + const execution = runFindPluginEntry(temporaryDirectory) // #then latest is treated as channel, not pin + expect(execution.status).toBe(0) + const pluginInfo = JSON.parse(execution.stdout.trim()) as PluginEntryResult expect(pluginInfo).not.toBeNull() expect(pluginInfo?.isPinned).toBe(false) expect(pluginInfo?.pinnedVersion).toBe("latest") }) - test("returns unpinned for beta dist-tag", () => { + test("returns unpinned for beta dist-tag", async () => { // #given plugin is configured with beta dist-tag fs.writeFileSync(configPath, JSON.stringify({ plugin: [`${PACKAGE_NAME}@beta`] })) // #when plugin entry is detected - const pluginInfo = findPluginEntry(temporaryDirectory) + const execution = runFindPluginEntry(temporaryDirectory) // #then beta is treated as channel, not pin + expect(execution.status).toBe(0) + const pluginInfo = JSON.parse(execution.stdout.trim()) as PluginEntryResult expect(pluginInfo).not.toBeNull() expect(pluginInfo?.isPinned).toBe(false) expect(pluginInfo?.pinnedVersion).toBe("beta") }) - test("returns pinned for explicit semver", () => { + test("returns pinned for explicit semver", async () => { // #given plugin is configured with explicit version fs.writeFileSync(configPath, JSON.stringify({ plugin: [`${PACKAGE_NAME}@3.5.2`] })) // #when plugin entry is detected - const pluginInfo = findPluginEntry(temporaryDirectory) + const execution = runFindPluginEntry(temporaryDirectory) // #then explicit semver is treated as pin + expect(execution.status).toBe(0) + const pluginInfo = JSON.parse(execution.stdout.trim()) as PluginEntryResult expect(pluginInfo).not.toBeNull() expect(pluginInfo?.isPinned).toBe(true) expect(pluginInfo?.pinnedVersion).toBe("3.5.2") }) + + test("reads user config from profile dir even when OPENCODE_CONFIG_DIR changes after import", async () => { + // #given profile-specific user config after module import + const profileConfigDir = path.join(temporaryDirectory, "profiles", "today") + fs.mkdirSync(profileConfigDir, { recursive: true }) + fs.writeFileSync( + path.join(profileConfigDir, "opencode.json"), + JSON.stringify({ plugin: [`${PACKAGE_NAME}@beta`] }), + ) + + // #when plugin entry is detected + const execution = runFindPluginEntry(path.join(temporaryDirectory, "workspace"), { + OPENCODE_CONFIG_DIR: profileConfigDir, + }) + + // #then profile dir is respected + expect(execution.status).toBe(0) + const pluginInfo = JSON.parse(execution.stdout.trim()) as PluginEntryResult + expect(pluginInfo).not.toBeNull() + expect(pluginInfo?.configPath).toEndWith("/profiles/today/opencode.json") + expect(pluginInfo?.pinnedVersion).toBe("beta") + }) }) diff --git a/src/hooks/auto-update-checker/constants.ts b/src/hooks/auto-update-checker/constants.ts index 18e6421ac..54eb2c7f6 100644 --- a/src/hooks/auto-update-checker/constants.ts +++ b/src/hooks/auto-update-checker/constants.ts @@ -16,9 +16,17 @@ export function getWindowsAppdataDir(): string | null { return process.env.APPDATA ?? path.join(os.homedir(), "AppData", "Roaming") } -export const USER_CONFIG_DIR = getOpenCodeConfigDir({ binary: "opencode" }) -export const USER_OPENCODE_CONFIG = path.join(USER_CONFIG_DIR, "opencode.json") -export const USER_OPENCODE_CONFIG_JSONC = path.join(USER_CONFIG_DIR, "opencode.jsonc") +export function getUserConfigDir(): string { + return getOpenCodeConfigDir({ binary: "opencode" }) +} + +export function getUserOpencodeConfig(): string { + return path.join(getUserConfigDir(), "opencode.json") +} + +export function getUserOpencodeConfigJsonc(): string { + return path.join(getUserConfigDir(), "opencode.jsonc") +} export const INSTALLED_PACKAGE_JSON = path.join( CACHE_DIR, diff --git a/src/hooks/claude-code-hooks/config-loader.test.ts b/src/hooks/claude-code-hooks/config-loader.test.ts index aaa30f6b6..d35fe13c1 100644 --- a/src/hooks/claude-code-hooks/config-loader.test.ts +++ b/src/hooks/claude-code-hooks/config-loader.test.ts @@ -94,6 +94,26 @@ describe("loadPluginExtendedConfig", () => { }, }) }) + + test("#given OPENCODE_CONFIG_DIR points at a profile dir after module import #when loading extended config #then it reads the profile config file", async () => { + //#given + const profileConfigDir = join(tempDirectory, ".config", "opencode", "profiles", "today") + const profileConfigPath = join(profileConfigDir, "opencode-cc-plugin.json") + mkdirSync(profileConfigDir, { recursive: true }) + process.env.OPENCODE_CONFIG_DIR = profileConfigDir + writeConfigFile(profileConfigPath, ["profile-stop"]) + + //#when + clearPluginExtendedConfigCache() + const result = await loadPluginExtendedConfig() + + //#then + expect(result).toEqual({ + disabledHooks: { + Stop: ["profile-stop"], + }, + }) + }) }) function writeConfigFile(filePath: string, stopPatterns: string[]): void { diff --git a/src/hooks/claude-code-hooks/config-loader.ts b/src/hooks/claude-code-hooks/config-loader.ts index 8f3375386..a01abb5eb 100644 --- a/src/hooks/claude-code-hooks/config-loader.ts +++ b/src/hooks/claude-code-hooks/config-loader.ts @@ -23,15 +23,18 @@ interface PluginExtendedConfigCacheEntry { cachedAt: number } -const USER_CONFIG_PATH = join(getOpenCodeConfigDir({ binary: "opencode" }), "opencode-cc-plugin.json") const configCache = new Map() +function getUserConfigPath(): string { + return join(getOpenCodeConfigDir({ binary: "opencode" }), "opencode-cc-plugin.json") +} + function getProjectConfigPath(): string { return join(process.cwd(), ".opencode", "opencode-cc-plugin.json") } function getCacheKey(): string { - return process.cwd() + return `${process.cwd()}::${getUserConfigPath()}` } function getCachedConfig(cacheKey: string): PluginExtendedConfig | undefined { @@ -89,7 +92,7 @@ export async function loadPluginExtendedConfig(): Promise return cachedConfig } - const userConfig = await loadConfigFromPath(USER_CONFIG_PATH) + const userConfig = await loadConfigFromPath(getUserConfigPath()) const projectConfig = await loadConfigFromPath(getProjectConfigPath()) const merged: PluginExtendedConfig = { diff --git a/src/hooks/zauc-mocks-cache/cache.test.ts b/src/hooks/zauc-mocks-cache/cache.test.ts index 56fae578d..24a32d144 100644 --- a/src/hooks/zauc-mocks-cache/cache.test.ts +++ b/src/hooks/zauc-mocks-cache/cache.test.ts @@ -15,14 +15,14 @@ const _realLogger = require("../../shared/logger") async function importFreshCacheModule(): Promise { mock.module("../auto-update-checker/constants", () => ({ CACHE_DIR: TEST_OPENCODE_CACHE_DIR, - USER_CONFIG_DIR: TEST_USER_CONFIG_DIR, PACKAGE_NAME: "oh-my-opencode", NPM_REGISTRY_URL: "https://registry.npmjs.org/-/package/oh-my-opencode/dist-tags", NPM_FETCH_TIMEOUT: 5000, VERSION_FILE: join(TEST_OPENCODE_CACHE_DIR, "version"), - USER_OPENCODE_CONFIG: join(TEST_USER_CONFIG_DIR, "opencode.json"), - USER_OPENCODE_CONFIG_JSONC: join(TEST_USER_CONFIG_DIR, "opencode.jsonc"), INSTALLED_PACKAGE_JSON: join(TEST_OPENCODE_CACHE_DIR, "node_modules", "oh-my-opencode", "package.json"), + getUserConfigDir: () => TEST_USER_CONFIG_DIR, + getUserOpencodeConfig: () => join(TEST_USER_CONFIG_DIR, "opencode.json"), + getUserOpencodeConfigJsonc: () => join(TEST_USER_CONFIG_DIR, "opencode.jsonc"), getWindowsAppdataDir: () => null, })) diff --git a/src/hooks/zauc-sync-mocks/sync-package-json.test.ts b/src/hooks/zauc-sync-mocks/sync-package-json.test.ts index 2a9ba806b..d94ce936f 100644 --- a/src/hooks/zauc-sync-mocks/sync-package-json.test.ts +++ b/src/hooks/zauc-sync-mocks/sync-package-json.test.ts @@ -19,10 +19,10 @@ async function importFreshSyncPackageJsonModule(): Promise "/tmp/opencode-config", + getUserOpencodeConfig: () => "/tmp/opencode-config/opencode.json", + getUserOpencodeConfigJsonc: () => "/tmp/opencode-config/opencode.jsonc", getWindowsAppdataDir: () => null, }))