diff --git a/src/cli/doctor/checks/system-loaded-version.test.ts b/src/cli/doctor/checks/system-loaded-version.test.ts index 3a89ee82d..b35e5a638 100644 --- a/src/cli/doctor/checks/system-loaded-version.test.ts +++ b/src/cli/doctor/checks/system-loaded-version.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it } from "bun:test" -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs" +import { mkdirSync, mkdtempSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "node:fs" import { tmpdir } from "node:os" import { dirname, join } from "node:path" @@ -104,6 +104,31 @@ describe("system loaded version", () => { expect(loadedVersion.expectedVersion).toBe("2.3.4") expect(loadedVersion.loadedVersion).toBe("2.3.4") }) + + it("resolves symlinked config directories before selecting install path", () => { + //#given + const realConfigDir = createTemporaryDirectory("omo-real-config-") + const symlinkBaseDir = createTemporaryDirectory("omo-symlink-base-") + const symlinkConfigDir = join(symlinkBaseDir, "config-link") + + symlinkSync(realConfigDir, symlinkConfigDir, process.platform === "win32" ? "junction" : "dir") + process.env.OPENCODE_CONFIG_DIR = symlinkConfigDir + + writeJson(join(realConfigDir, "package.json"), { + dependencies: { [PACKAGE_NAME]: "4.5.6" }, + }) + writeJson(join(realConfigDir, "node_modules", PACKAGE_NAME, "package.json"), { + version: "4.5.6", + }) + + //#when + const loadedVersion = getLoadedPluginVersion() + + //#then + expect(loadedVersion.cacheDir).toBe(realpathSync(symlinkConfigDir)) + expect(loadedVersion.expectedVersion).toBe("4.5.6") + expect(loadedVersion.loadedVersion).toBe("4.5.6") + }) }) describe("getSuggestedInstallTag", () => { diff --git a/src/cli/doctor/checks/system-loaded-version.ts b/src/cli/doctor/checks/system-loaded-version.ts index a62c0f97a..7693b2d7a 100644 --- a/src/cli/doctor/checks/system-loaded-version.ts +++ b/src/cli/doctor/checks/system-loaded-version.ts @@ -1,4 +1,4 @@ -import { existsSync, readFileSync } from "node:fs" +import { existsSync, readFileSync, realpathSync } from "node:fs" import { homedir } from "node:os" import { join } from "node:path" @@ -36,6 +36,16 @@ function resolveOpenCodeCacheDir(): string { return platformDefault } +function resolveExistingDir(dirPath: string): string { + if (!existsSync(dirPath)) return dirPath + + try { + return realpathSync(dirPath) + } catch { + return dirPath + } +} + function readPackageJson(filePath: string): PackageJsonShape | null { if (!existsSync(filePath)) return null @@ -55,12 +65,13 @@ function normalizeVersion(value: string | undefined): string | null { export function getLoadedPluginVersion(): LoadedVersionInfo { const configPaths = getOpenCodeConfigPaths({ binary: "opencode" }) - const cacheDir = resolveOpenCodeCacheDir() + const configDir = resolveExistingDir(configPaths.configDir) + const cacheDir = resolveExistingDir(resolveOpenCodeCacheDir()) const candidates = [ { - cacheDir: configPaths.configDir, - cachePackagePath: configPaths.packageJson, - installedPackagePath: join(configPaths.configDir, "node_modules", PACKAGE_NAME, "package.json"), + cacheDir: configDir, + cachePackagePath: join(configDir, "package.json"), + installedPackagePath: join(configDir, "node_modules", PACKAGE_NAME, "package.json"), }, { cacheDir, diff --git a/src/features/claude-code-plugin-loader/discovery.test.ts b/src/features/claude-code-plugin-loader/discovery.test.ts new file mode 100644 index 000000000..63e2340a6 --- /dev/null +++ b/src/features/claude-code-plugin-loader/discovery.test.ts @@ -0,0 +1,104 @@ +import { afterEach, beforeEach, describe, expect, it } from "bun:test" +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs" +import { tmpdir } from "node:os" +import { join } from "node:path" + +import { discoverInstalledPlugins } from "./discovery" + +const originalClaudePluginsHome = process.env.CLAUDE_PLUGINS_HOME +const temporaryDirectories: string[] = [] + +function createTemporaryDirectory(prefix: string): string { + const directory = mkdtempSync(join(tmpdir(), prefix)) + temporaryDirectories.push(directory) + return directory +} + +describe("discoverInstalledPlugins", () => { + beforeEach(() => { + const pluginsHome = createTemporaryDirectory("omo-claude-plugins-") + process.env.CLAUDE_PLUGINS_HOME = pluginsHome + }) + + afterEach(() => { + if (originalClaudePluginsHome === undefined) { + delete process.env.CLAUDE_PLUGINS_HOME + } else { + process.env.CLAUDE_PLUGINS_HOME = originalClaudePluginsHome + } + + for (const directory of temporaryDirectories.splice(0)) { + rmSync(directory, { recursive: true, force: true }) + } + }) + + it("preserves scoped package name from npm plugin keys", () => { + //#given + const pluginsHome = process.env.CLAUDE_PLUGINS_HOME as string + const installPath = join(createTemporaryDirectory("omo-plugin-install-"), "@myorg", "my-plugin") + mkdirSync(installPath, { recursive: true }) + + const databasePath = join(pluginsHome, "installed_plugins.json") + writeFileSync( + databasePath, + JSON.stringify({ + version: 2, + plugins: { + "@myorg/my-plugin@1.0.0": [ + { + scope: "user", + installPath, + version: "1.0.0", + installedAt: "2026-03-25T00:00:00Z", + lastUpdated: "2026-03-25T00:00:00Z", + }, + ], + }, + }), + "utf-8", + ) + + //#when + const discovered = discoverInstalledPlugins() + + //#then + expect(discovered.errors).toHaveLength(0) + expect(discovered.plugins).toHaveLength(1) + expect(discovered.plugins[0]?.name).toBe("@myorg/my-plugin") + }) + + it("derives package name from file URL plugin keys", () => { + //#given + const pluginsHome = process.env.CLAUDE_PLUGINS_HOME as string + const installPath = join(createTemporaryDirectory("omo-plugin-install-"), "oh-my-opencode") + mkdirSync(installPath, { recursive: true }) + + const databasePath = join(pluginsHome, "installed_plugins.json") + writeFileSync( + databasePath, + JSON.stringify({ + version: 2, + plugins: { + "file:///D:/configs/user-configs/.config/opencode/node_modules/oh-my-opencode@latest": [ + { + scope: "user", + installPath, + version: "3.10.0", + installedAt: "2026-03-20T00:00:00Z", + lastUpdated: "2026-03-20T00:00:00Z", + }, + ], + }, + }), + "utf-8", + ) + + //#when + const discovered = discoverInstalledPlugins() + + //#then + expect(discovered.errors).toHaveLength(0) + expect(discovered.plugins).toHaveLength(1) + expect(discovered.plugins[0]?.name).toBe("oh-my-opencode") + }) +}) diff --git a/src/features/claude-code-plugin-loader/discovery.ts b/src/features/claude-code-plugin-loader/discovery.ts index f2ec3d0be..d4d9bb577 100644 --- a/src/features/claude-code-plugin-loader/discovery.ts +++ b/src/features/claude-code-plugin-loader/discovery.ts @@ -1,6 +1,7 @@ import { existsSync, readFileSync } from "fs" import { homedir } from "os" -import { join } from "path" +import { basename, join } from "path" +import { fileURLToPath } from "url" import { log } from "../../shared/logger" import type { InstalledPluginsDatabase, @@ -79,8 +80,34 @@ function loadPluginManifest(installPath: string): PluginManifest | null { } function derivePluginNameFromKey(pluginKey: string): string { - const atIndex = pluginKey.indexOf("@") - return atIndex > 0 ? pluginKey.substring(0, atIndex) : pluginKey + const keyWithoutSource = pluginKey.startsWith("npm:") ? pluginKey.slice(4) : pluginKey + + let versionSeparator: number + if (keyWithoutSource.startsWith("@")) { + const scopeEnd = keyWithoutSource.indexOf("/") + versionSeparator = scopeEnd > 0 ? keyWithoutSource.indexOf("@", scopeEnd) : -1 + } else { + versionSeparator = keyWithoutSource.lastIndexOf("@") + } + const keyWithoutVersion = versionSeparator > 0 ? keyWithoutSource.slice(0, versionSeparator) : keyWithoutSource + + if (keyWithoutVersion.startsWith("file://")) { + try { + return basename(fileURLToPath(keyWithoutVersion)) + } catch { + return basename(keyWithoutVersion) + } + } + + if (keyWithoutVersion.startsWith("@") && keyWithoutVersion.includes("/")) { + return keyWithoutVersion + } + + if (keyWithoutVersion.includes("/") || keyWithoutVersion.includes("\\")) { + return basename(keyWithoutVersion) + } + + return keyWithoutVersion } function isPluginEnabled( diff --git a/src/shared/opencode-config-dir.ts b/src/shared/opencode-config-dir.ts index 620d561dd..cf4fc28da 100644 --- a/src/shared/opencode-config-dir.ts +++ b/src/shared/opencode-config-dir.ts @@ -1,4 +1,4 @@ -import { existsSync } from "node:fs" +import { existsSync, realpathSync } from "node:fs" import { homedir } from "node:os" import { join, resolve } from "node:path" @@ -42,14 +42,25 @@ function getTauriConfigDir(identifier: string): string { } } +function resolveConfigPath(pathValue: string): string { + const resolvedPath = resolve(pathValue) + if (!existsSync(resolvedPath)) return resolvedPath + + try { + return realpathSync(resolvedPath) + } catch { + return resolvedPath + } +} + function getCliConfigDir(): string { const envConfigDir = process.env.OPENCODE_CONFIG_DIR?.trim() if (envConfigDir) { - return resolve(envConfigDir) + return resolveConfigPath(envConfigDir) } const xdgConfig = process.env.XDG_CONFIG_HOME || join(homedir(), ".config") - return join(xdgConfig, "opencode") + return resolveConfigPath(join(xdgConfig, "opencode")) } export function getOpenCodeConfigDir(options: OpenCodeConfigDirOptions): string { @@ -60,7 +71,7 @@ export function getOpenCodeConfigDir(options: OpenCodeConfigDirOptions): string } const identifier = isDevBuild(version) ? TAURI_APP_IDENTIFIER_DEV : TAURI_APP_IDENTIFIER - const tauriDir = getTauriConfigDir(identifier) + const tauriDir = resolveConfigPath(getTauriConfigDir(identifier)) if (checkExisting) { const legacyDir = getCliConfigDir() @@ -92,7 +103,7 @@ export function detectExistingConfigDir(binary: OpenCodeBinaryType, version?: st const envConfigDir = process.env.OPENCODE_CONFIG_DIR?.trim() if (envConfigDir) { - locations.push(resolve(envConfigDir)) + locations.push(resolveConfigPath(envConfigDir)) } if (binary === "opencode-desktop") {