diff --git a/src/management/plugin-path-normalizer.ts b/src/management/plugin-path-normalizer.ts new file mode 100644 index 00000000..92193fd7 --- /dev/null +++ b/src/management/plugin-path-normalizer.ts @@ -0,0 +1,81 @@ +import * as os from 'os'; +import * as path from 'path'; + +function getPluginPathModule( + targetConfigDir: string, + input: string +): typeof path.posix | typeof path.win32 { + return targetConfigDir.includes('\\') || input.includes('\\') ? path.win32 : path.posix; +} + +function normalizeTargetConfigDir(targetConfigDir: string, input: string): string { + const pathModule = getPluginPathModule(targetConfigDir, input); + return pathModule.normalize( + pathModule === path.win32 + ? targetConfigDir.replace(/\//g, '\\') + : targetConfigDir.replace(/\\/g, '/') + ); +} + +export function normalizePluginMetadataPathString( + input: string, + targetConfigDir = path.join(os.homedir(), '.claude') +): string { + const match = input.match( + /^(.*?)([\\/])(?:\.claude|\.ccs\2shared|\.ccs\2instances\2[^\\/]+)\2plugins(?:(\2.*))?$/ + ); + + if (!match) { + return input; + } + + const pathModule = getPluginPathModule(targetConfigDir, input); + const normalizedTargetConfigDir = normalizeTargetConfigDir(targetConfigDir, input); + const suffix = match[3] ?? ''; + const suffixSegments = suffix.split(/[\\/]+/).filter(Boolean); + + return pathModule.join(normalizedTargetConfigDir, 'plugins', ...suffixSegments); +} + +export function normalizePluginMetadataValue( + value: unknown, + targetConfigDir: string +): { normalized: unknown; changed: boolean } { + if (typeof value === 'string') { + const normalized = normalizePluginMetadataPathString(value, targetConfigDir); + return { normalized, changed: normalized !== value }; + } + + if (Array.isArray(value)) { + let changed = false; + const normalized = value.map((item) => { + const result = normalizePluginMetadataValue(item, targetConfigDir); + changed = changed || result.changed; + return result.normalized; + }); + return { normalized, changed }; + } + + if (value && typeof value === 'object') { + let changed = false; + const normalized = Object.fromEntries( + Object.entries(value as Record).map(([key, item]) => { + const result = normalizePluginMetadataValue(item, targetConfigDir); + changed = changed || result.changed; + return [key, result.normalized]; + }) + ); + return { normalized, changed }; + } + + return { normalized: value, changed: false }; +} + +export function normalizePluginMetadataContent( + original: string, + targetConfigDir = path.join(os.homedir(), '.claude') +): string { + const parsed = JSON.parse(original) as unknown; + const result = normalizePluginMetadataValue(parsed, targetConfigDir); + return result.changed ? JSON.stringify(result.normalized, null, 2) : original; +} diff --git a/src/management/shared-manager.ts b/src/management/shared-manager.ts index 730f1ded..c2f79d2c 100644 --- a/src/management/shared-manager.ts +++ b/src/management/shared-manager.ts @@ -14,6 +14,14 @@ import { ok, info, warn } from '../utils/ui'; import { DEFAULT_ACCOUNT_CONTEXT_GROUP } from '../auth/account-context'; import type { AccountContextPolicy } from '../auth/account-context'; import { getCcsDir } from '../utils/config-manager'; +import { + normalizePluginMetadataContent, + normalizePluginMetadataValue, +} from './plugin-path-normalizer'; +export { + normalizePluginMetadataContent, + normalizePluginMetadataPathString, +} from './plugin-path-normalizer'; interface SharedItem { name: string; @@ -29,85 +37,6 @@ const DEFAULT_INSTALLED_PLUGIN_REGISTRY = JSON.stringify( 2 ); -function getPluginPathModule( - targetConfigDir: string, - input: string -): typeof path.posix | typeof path.win32 { - return targetConfigDir.includes('\\') || input.includes('\\') ? path.win32 : path.posix; -} - -function normalizeTargetConfigDir(targetConfigDir: string, input: string): string { - const pathModule = getPluginPathModule(targetConfigDir, input); - return pathModule.normalize( - pathModule === path.win32 - ? targetConfigDir.replace(/\//g, '\\') - : targetConfigDir.replace(/\\/g, '/') - ); -} - -export function normalizePluginMetadataPathString( - input: string, - targetConfigDir = path.join(os.homedir(), '.claude') -): string { - const match = input.match( - /^(.*?)([\\/])(?:\.claude|\.ccs\2shared|\.ccs\2instances\2[^\\/]+)\2plugins(?:(\2.*))?$/ - ); - - if (!match) { - return input; - } - - const pathModule = getPluginPathModule(targetConfigDir, input); - const normalizedTargetConfigDir = normalizeTargetConfigDir(targetConfigDir, input); - const suffix = match[3] ?? ''; - const suffixSegments = suffix.split(/[\\/]+/).filter(Boolean); - - return pathModule.join(normalizedTargetConfigDir, 'plugins', ...suffixSegments); -} - -function normalizePluginMetadataValue( - value: unknown, - targetConfigDir: string -): { normalized: unknown; changed: boolean } { - if (typeof value === 'string') { - const normalized = normalizePluginMetadataPathString(value, targetConfigDir); - return { normalized, changed: normalized !== value }; - } - - if (Array.isArray(value)) { - let changed = false; - const normalized = value.map((item) => { - const result = normalizePluginMetadataValue(item, targetConfigDir); - changed = changed || result.changed; - return result.normalized; - }); - return { normalized, changed }; - } - - if (value && typeof value === 'object') { - let changed = false; - const normalized = Object.fromEntries( - Object.entries(value as Record).map(([key, item]) => { - const result = normalizePluginMetadataValue(item, targetConfigDir); - changed = changed || result.changed; - return [key, result.normalized]; - }) - ); - return { normalized, changed }; - } - - return { normalized: value, changed: false }; -} - -export function normalizePluginMetadataContent( - original: string, - targetConfigDir = path.join(os.homedir(), '.claude') -): string { - const parsed = JSON.parse(original) as unknown; - const result = normalizePluginMetadataValue(parsed, targetConfigDir); - return result.changed ? JSON.stringify(result.normalized, null, 2) : original; -} - /** * SharedManager Class */ diff --git a/tests/unit/shared-manager.test.ts b/tests/unit/shared-manager.test.ts index 730d2f57..063ae9a4 100644 --- a/tests/unit/shared-manager.test.ts +++ b/tests/unit/shared-manager.test.ts @@ -2,9 +2,13 @@ import { afterEach, beforeEach, describe, expect, it, mock, spyOn } from 'bun:te import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; -import SharedManager, { +import { normalizePluginMetadataContent, normalizePluginMetadataPathString, +} from '../../src/management/plugin-path-normalizer'; +import SharedManager, { + normalizePluginMetadataContent as normalizeSharedManagerPluginMetadataContent, + normalizePluginMetadataPathString as normalizeSharedManagerPluginMetadataPathString, } from '../../src/management/shared-manager'; describe('SharedManager', () => { @@ -144,6 +148,12 @@ describe('SharedManager', () => { expect(normalizePluginMetadataPathString(input, targetConfigDir)).toBe(input); }); + it('preserves original JSON content when no plugin path changes are needed', () => { + const original = JSON.stringify({ plugins: { 'plugin-a': { enabled: true } } }, null, 4); + + expect(normalizePluginMetadataContent(original, instanceDir('work'))).toBe(original); + }); + it('handles Windows path separators', () => { const targetConfigDir = 'C:\\Users\\user\\.claude'; const input = 'C:\\Users\\user\\.ccs\\instances\\work\\plugins\\marketplaces\\official'; @@ -152,6 +162,27 @@ describe('SharedManager', () => { 'C:\\Users\\user\\.claude\\plugins\\marketplaces\\official' ); }); + + it('uses the current home directory when target config dir is omitted', () => { + const input = path.join(tempRoot, '.ccs', 'shared', 'plugins', 'cache', 'plugin-a'); + + expect(normalizePluginMetadataPathString(input)).toBe( + path.join(tempRoot, '.claude', 'plugins', 'cache', 'plugin-a') + ); + }); + + it('keeps the legacy shared-manager helper export compatible', () => { + const targetConfigDir = instanceDir('work'); + const input = path.join(tempRoot, '.ccs', 'shared', 'plugins', 'cache', 'plugin-a'); + const content = JSON.stringify({ installPath: input }, null, 2); + + expect(normalizeSharedManagerPluginMetadataPathString(input, targetConfigDir)).toBe( + normalizePluginMetadataPathString(input, targetConfigDir) + ); + expect(normalizeSharedManagerPluginMetadataContent(content, targetConfigDir)).toBe( + normalizePluginMetadataContent(content, targetConfigDir) + ); + }); }); describe('shared symlink lifecycle', () => {