From f2c5d52bdc73a5403850eb0c67a3ad6b8504e67d Mon Sep 17 00:00:00 2001 From: 777genius Date: Thu, 16 Apr 2026 21:55:50 +0300 Subject: [PATCH] fix(extensions): scope plugin install state to active project --- .../install/PluginInstallService.ts | 14 ++ .../state/PluginInstallationStateService.ts | 128 +++++++++- src/renderer/store/slices/extensionsSlice.ts | 47 +++- .../extensions/PluginInstallService.test.ts | 16 ++ .../PluginInstallationStateService.test.ts | 231 ++++++++++++++---- test/renderer/store/extensionsSlice.test.ts | 100 +++++--- 6 files changed, 432 insertions(+), 104 deletions(-) diff --git a/src/main/services/extensions/install/PluginInstallService.ts b/src/main/services/extensions/install/PluginInstallService.ts index f3f91d74..4a3a7711 100644 --- a/src/main/services/extensions/install/PluginInstallService.ts +++ b/src/main/services/extensions/install/PluginInstallService.ts @@ -48,6 +48,13 @@ export class PluginInstallService { }; } + if (scope === 'project' && !projectPath) { + return { + state: 'error', + error: 'projectPath is required for project-scoped plugin installs', + }; + } + // 3. Resolve qualifiedName from catalog (NOT from renderer) const resolved = await this.catalogService.resolvePlugin(pluginId); if (!resolved) { @@ -123,6 +130,13 @@ export class PluginInstallService { }; } + if (scope === 'project' && !projectPath) { + return { + state: 'error', + error: 'projectPath is required for project-scoped plugin uninstalls', + }; + } + // Resolve qualifiedName from catalog const resolved = await this.catalogService.resolvePlugin(pluginId); if (!resolved) { diff --git a/src/main/services/extensions/state/PluginInstallationStateService.ts b/src/main/services/extensions/state/PluginInstallationStateService.ts index 382b07f8..42c6c1a8 100644 --- a/src/main/services/extensions/state/PluginInstallationStateService.ts +++ b/src/main/services/extensions/state/PluginInstallationStateService.ts @@ -60,23 +60,26 @@ interface TimedCache { // ── Service ──────────────────────────────────────────────────────────────── export class PluginInstallationStateService { - private installedCache: TimedCache | null = null; + private installedCache = new Map>(); private countsCache: TimedCache> | null = null; /** - * Get all installed plugins across all scopes. - * Returns merged list from installed_plugins.json with scope tags. + * Get installed plugins relevant to the active context. + * Always includes user scope. Project/local entries are only included when + * they are enabled for the active project. */ - async getInstalledPlugins(_projectPath?: string): Promise { - if ( - this.installedCache && - Date.now() - this.installedCache.fetchedAt < INSTALLED_STATE_TTL_MS - ) { - return this.installedCache.data; + async getInstalledPlugins(projectPath?: string): Promise { + const normalizedProjectPath = + typeof projectPath === 'string' && path.isAbsolute(projectPath) ? projectPath : undefined; + const cacheKey = this.getInstalledCacheKey(normalizedProjectPath); + const cached = this.installedCache.get(cacheKey); + + if (cached && Date.now() - cached.fetchedAt < INSTALLED_STATE_TTL_MS) { + return cached.data; } - const entries = await this.readInstalledPlugins(); - this.installedCache = { data: entries, fetchedAt: Date.now() }; + const entries = await this.buildInstalledEntriesForContext(normalizedProjectPath); + this.installedCache.set(cacheKey, { data: entries, fetchedAt: Date.now() }); return entries; } @@ -97,7 +100,7 @@ export class PluginInstallationStateService { * Invalidate all caches. Call after install/uninstall operations. */ invalidateCache(): void { - this.installedCache = null; + this.installedCache.clear(); this.countsCache = null; } @@ -107,7 +110,81 @@ export class PluginInstallationStateService { return path.join(getClaudeBasePath(), 'plugins'); } - private async readInstalledPlugins(): Promise { + private getInstalledCacheKey(projectPath?: string): string { + return projectPath ?? '__user__'; + } + + private async buildInstalledEntriesForContext( + projectPath?: string + ): Promise { + const installedMetadata = await this.readInstalledPluginMetadata(); + const metadataByKey = new Map(); + + for (const entry of installedMetadata) { + const key = this.getPluginScopeKey(entry.pluginId, entry.scope); + const matches = metadataByKey.get(key) ?? []; + matches.push(entry); + metadataByKey.set(key, matches); + } + + const userEnabled = await this.readEnabledPlugins( + path.join(getClaudeBasePath(), 'settings.json') + ); + const projectEnabled = projectPath + ? await this.readEnabledPlugins(path.join(projectPath, '.claude', 'settings.json')) + : new Set(); + const localEnabled = projectPath + ? await this.readEnabledPlugins(path.join(projectPath, '.claude', 'settings.local.json')) + : new Set(); + + return [ + ...this.buildScopedEntries('user', userEnabled, metadataByKey), + ...this.buildScopedEntries('project', projectEnabled, metadataByKey), + ...this.buildScopedEntries('local', localEnabled, metadataByKey), + ]; + } + + private buildScopedEntries( + scope: InstallScope, + enabledPlugins: Set, + metadataByKey: Map + ): InstalledPluginEntry[] { + return Array.from(enabledPlugins).map((pluginId) => { + const key = this.getPluginScopeKey(pluginId, scope); + const bestMatch = this.pickBestInstallationEntry(metadataByKey.get(key) ?? []); + + return bestMatch + ? { + ...bestMatch, + pluginId, + scope, + } + : { + pluginId, + scope, + }; + }); + } + + private getPluginScopeKey(pluginId: string, scope: InstallScope): string { + return `${pluginId}::${scope}`; + } + + private pickBestInstallationEntry(entries: InstalledPluginEntry[]): InstalledPluginEntry | null { + if (entries.length === 0) { + return null; + } + + return [...entries].sort((left, right) => { + const leftInstalledAt = Date.parse(left.installedAt ?? ''); + const rightInstalledAt = Date.parse(right.installedAt ?? ''); + const normalizedLeft = Number.isFinite(leftInstalledAt) ? leftInstalledAt : 0; + const normalizedRight = Number.isFinite(rightInstalledAt) ? rightInstalledAt : 0; + return normalizedRight - normalizedLeft; + })[0]; + } + + private async readInstalledPluginMetadata(): Promise { const filePath = path.join(this.getPluginsDir(), 'installed_plugins.json'); try { @@ -143,6 +220,31 @@ export class PluginInstallationStateService { } } + private async readEnabledPlugins(filePath: string): Promise> { + try { + const raw = await fs.readFile(filePath, 'utf-8'); + const json = JSON.parse(raw) as { + enabledPlugins?: Record | null; + }; + + if (!json.enabledPlugins || typeof json.enabledPlugins !== 'object') { + return new Set(); + } + + return new Set( + Object.entries(json.enabledPlugins) + .filter(([, enabled]) => enabled === true) + .map(([pluginId]) => pluginId) + ); + } catch (err) { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') { + return new Set(); + } + logger.error(`Failed to read plugin settings from ${filePath}:`, err); + return new Set(); + } + } + private async readInstallCounts(): Promise> { const filePath = path.join(this.getPluginsDir(), 'install-counts-cache.json'); diff --git a/src/renderer/store/slices/extensionsSlice.ts b/src/renderer/store/slices/extensionsSlice.ts index dbb32224..abea1a2f 100644 --- a/src/renderer/store/slices/extensionsSlice.ts +++ b/src/renderer/store/slices/extensionsSlice.ts @@ -152,6 +152,8 @@ const CLI_HEALTHCHECK_FAILED_MESSAGE = 'Claude CLI was found but failed its startup health check. Open the Dashboard to repair or reinstall it before retrying.'; const CLI_STATUS_UNKNOWN_MESSAGE = 'Unable to verify Claude CLI status. Open the Dashboard and check the CLI before retrying.'; +const PROJECT_SCOPE_REQUIRED_MESSAGE = + 'Project-scoped plugins require an active project in the Extensions tab.'; export const createExtensionsSlice: StateCreator = ( set, @@ -561,6 +563,15 @@ export const createExtensionsSlice: StateCreator { if (!api.plugins) return; + const effectiveProjectPath = + request.scope === 'project' + ? (request.projectPath ?? get().pluginCatalogProjectPath ?? undefined) + : request.projectPath; + const effectiveRequest = + effectiveProjectPath === request.projectPath + ? request + : { ...request, projectPath: effectiveProjectPath }; + const preflightState = get(); if (preflightState.cliStatus === null || preflightState.cliStatusLoading) { try { @@ -572,15 +583,17 @@ export const createExtensionsSlice: StateCreator ({ @@ -596,7 +609,7 @@ export const createExtensionsSlice: StateCreator ({ pluginInstallProgress: { ...prev.pluginInstallProgress, [request.pluginId]: 'error' }, @@ -634,12 +647,24 @@ export const createExtensionsSlice: StateCreator { if (!api.plugins) return; + const effectiveProjectPath = + scope === 'project' + ? (projectPath ?? get().pluginCatalogProjectPath ?? undefined) + : projectPath; + if (scope === 'project' && !effectiveProjectPath) { + set((prev) => ({ + pluginInstallProgress: { ...prev.pluginInstallProgress, [pluginId]: 'error' }, + installErrors: { ...prev.installErrors, [pluginId]: PROJECT_SCOPE_REQUIRED_MESSAGE }, + })); + return; + } + set((prev) => ({ pluginInstallProgress: { ...prev.pluginInstallProgress, [pluginId]: 'pending' }, })); try { - const result = await api.plugins.uninstall(pluginId, scope, projectPath); + const result = await api.plugins.uninstall(pluginId, scope, effectiveProjectPath); if (result.state === 'error') { set((prev) => ({ pluginInstallProgress: { ...prev.pluginInstallProgress, [pluginId]: 'error' }, diff --git a/test/main/services/extensions/PluginInstallService.test.ts b/test/main/services/extensions/PluginInstallService.test.ts index 6e8f625e..b88c1b1e 100644 --- a/test/main/services/extensions/PluginInstallService.test.ts +++ b/test/main/services/extensions/PluginInstallService.test.ts @@ -121,6 +121,14 @@ describe('PluginInstallService', () => { expect(result.state).toBe('error'); expect(result.error).toContain('Command failed'); }); + + it('rejects project scope when projectPath is missing', async () => { + const result = await service.install({ pluginId: 'context7', scope: 'project' }); + + expect(result.state).toBe('error'); + expect(result.error).toContain('projectPath is required'); + expect(mockExecCli).not.toHaveBeenCalled(); + }); }); // ── uninstall ─────────────────────────────────────────────────────────────── @@ -171,5 +179,13 @@ describe('PluginInstallService', () => { expect(result.state).toBe('error'); expect(result.error).toContain('Cannot uninstall'); }); + + it('rejects project scope when projectPath is missing', async () => { + const result = await service.uninstall('context7', 'project'); + + expect(result.state).toBe('error'); + expect(result.error).toContain('projectPath is required'); + expect(mockExecCli).not.toHaveBeenCalled(); + }); }); }); diff --git a/test/main/services/extensions/PluginInstallationStateService.test.ts b/test/main/services/extensions/PluginInstallationStateService.test.ts index 1a2c2d97..16f2d2e1 100644 --- a/test/main/services/extensions/PluginInstallationStateService.test.ts +++ b/test/main/services/extensions/PluginInstallationStateService.test.ts @@ -25,47 +25,150 @@ describe('PluginInstallationStateService', () => { }); describe('getInstalledPlugins', () => { - it('parses installed_plugins.json version 2 format', async () => { - const installedData = { - version: 2, - plugins: { - 'context7@claude-plugins-official': [ - { - scope: 'user', - installPath: '/Users/test/.claude/plugins/cache/claude-plugins-official/context7/1.0.0', - version: '1.0.0', - installedAt: '2026-03-01T11:14:21.926Z', + it('returns user-scoped plugins enabled in user settings', async () => { + mockedFs.readFile.mockImplementation(async (filePath) => { + const normalizedPath = String(filePath); + if (normalizedPath.endsWith('/plugins/installed_plugins.json')) { + return JSON.stringify({ + version: 2, + plugins: { + 'context7@claude-plugins-official': [ + { + scope: 'user', + installPath: + '/Users/test/.claude/plugins/cache/claude-plugins-official/context7/1.0.0', + version: '1.0.0', + installedAt: '2026-03-01T11:14:21.926Z', + }, + ], + 'typescript-lsp@claude-plugins-official': [ + { + scope: 'project', + version: '1.0.0', + installedAt: '2026-03-03T10:00:00.000Z', + }, + ], }, - ], - 'typescript-lsp@claude-plugins-official': [ - { - scope: 'user', - version: '1.0.0', - installedAt: '2026-03-02T10:00:00.000Z', - }, - { - scope: 'project', - version: '1.0.0', - installedAt: '2026-03-03T10:00:00.000Z', - }, - ], - }, - }; + }); + } - mockedFs.readFile.mockResolvedValue(JSON.stringify(installedData)); + if (normalizedPath === '/tmp/mock-claude/settings.json') { + return JSON.stringify({ + enabledPlugins: { + 'context7@claude-plugins-official': true, + }, + }); + } + + throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); + }); const entries = await service.getInstalledPlugins(); - expect(entries).toHaveLength(3); - expect(entries[0].pluginId).toBe('context7@claude-plugins-official'); - expect(entries[0].scope).toBe('user'); - expect(entries[0].version).toBe('1.0.0'); + expect(entries).toHaveLength(1); + expect(entries[0]).toMatchObject({ + pluginId: 'context7@claude-plugins-official', + scope: 'user', + version: '1.0.0', + }); + }); - expect(entries[1].pluginId).toBe('typescript-lsp@claude-plugins-official'); - expect(entries[1].scope).toBe('user'); + it('includes project and local scopes only for the active project', async () => { + mockedFs.readFile.mockImplementation(async (filePath) => { + const normalizedPath = String(filePath); + if (normalizedPath.endsWith('/plugins/installed_plugins.json')) { + return JSON.stringify({ + version: 2, + plugins: { + 'context7@claude-plugins-official': [ + { + scope: 'user', + version: '1.0.0', + installedAt: '2026-03-01T11:14:21.926Z', + }, + ], + 'typescript-lsp@claude-plugins-official': [ + { + scope: 'project', + version: '1.1.0', + installedAt: '2026-03-03T10:00:00.000Z', + }, + ], + 'formatter@claude-plugins-official': [ + { + scope: 'local', + version: '2.0.0', + installedAt: '2026-03-04T10:00:00.000Z', + }, + ], + }, + }); + } - expect(entries[2].pluginId).toBe('typescript-lsp@claude-plugins-official'); - expect(entries[2].scope).toBe('project'); + if (normalizedPath === '/tmp/mock-claude/settings.json') { + return JSON.stringify({ + enabledPlugins: { + 'context7@claude-plugins-official': true, + }, + }); + } + + if (normalizedPath === '/tmp/project-a/.claude/settings.json') { + return JSON.stringify({ + enabledPlugins: { + 'typescript-lsp@claude-plugins-official': true, + }, + }); + } + + if (normalizedPath === '/tmp/project-a/.claude/settings.local.json') { + return JSON.stringify({ + enabledPlugins: { + 'formatter@claude-plugins-official': true, + }, + }); + } + + throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); + }); + + const entries = await service.getInstalledPlugins('/tmp/project-a'); + + expect(entries.map((entry) => [entry.pluginId, entry.scope])).toEqual([ + ['context7@claude-plugins-official', 'user'], + ['typescript-lsp@claude-plugins-official', 'project'], + ['formatter@claude-plugins-official', 'local'], + ]); + }); + + it('does not leak another project scope into the current project', async () => { + mockedFs.readFile.mockImplementation(async (filePath) => { + const normalizedPath = String(filePath); + if (normalizedPath.endsWith('/plugins/installed_plugins.json')) { + return JSON.stringify({ + version: 2, + plugins: { + 'typescript-lsp@claude-plugins-official': [ + { + scope: 'project', + version: '1.1.0', + installedAt: '2026-03-03T10:00:00.000Z', + }, + ], + }, + }); + } + + if (normalizedPath.endsWith('/settings.json')) { + return JSON.stringify({ enabledPlugins: {} }); + } + + throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); + }); + + const entries = await service.getInstalledPlugins('/tmp/project-b'); + + expect(entries).toEqual([]); }); it('returns empty array when file does not exist', async () => { @@ -78,22 +181,55 @@ describe('PluginInstallationStateService', () => { }); it('returns empty array for unexpected version', async () => { - mockedFs.readFile.mockResolvedValue(JSON.stringify({ version: 1, plugins: {} })); + mockedFs.readFile.mockImplementation(async (filePath) => { + const normalizedPath = String(filePath); + if (normalizedPath.endsWith('/plugins/installed_plugins.json')) { + return JSON.stringify({ version: 1, plugins: {} }); + } + if (normalizedPath.endsWith('/settings.json')) { + return JSON.stringify({ enabledPlugins: {} }); + } + throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); + }); const entries = await service.getInstalledPlugins(); expect(entries).toEqual([]); }); it('caches within TTL', async () => { - mockedFs.readFile.mockResolvedValue( - JSON.stringify({ version: 2, plugins: {} }), - ); + mockedFs.readFile.mockImplementation(async (filePath) => { + const normalizedPath = String(filePath); + if (normalizedPath.endsWith('/plugins/installed_plugins.json')) { + return JSON.stringify({ version: 2, plugins: {} }); + } + if (normalizedPath.endsWith('/settings.json')) { + return JSON.stringify({ enabledPlugins: {} }); + } + throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); + }); await service.getInstalledPlugins(); await service.getInstalledPlugins(); - // Only one read - expect(mockedFs.readFile).toHaveBeenCalledTimes(1); + expect(mockedFs.readFile).toHaveBeenCalledTimes(2); + }); + + it('caches results independently per project path', async () => { + mockedFs.readFile.mockImplementation(async (filePath) => { + const normalizedPath = String(filePath); + if (normalizedPath.endsWith('/plugins/installed_plugins.json')) { + return JSON.stringify({ version: 2, plugins: {} }); + } + if (normalizedPath.endsWith('/settings.json')) { + return JSON.stringify({ enabledPlugins: {} }); + } + throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); + }); + + await service.getInstalledPlugins('/tmp/project-a'); + await service.getInstalledPlugins('/tmp/project-b'); + + expect(mockedFs.readFile).toHaveBeenCalledTimes(8); }); }); @@ -140,15 +276,22 @@ describe('PluginInstallationStateService', () => { describe('invalidateCache', () => { it('forces re-read after invalidation', async () => { - mockedFs.readFile.mockResolvedValue( - JSON.stringify({ version: 2, plugins: {} }), - ); + mockedFs.readFile.mockImplementation(async (filePath) => { + const normalizedPath = String(filePath); + if (normalizedPath.endsWith('/plugins/installed_plugins.json')) { + return JSON.stringify({ version: 2, plugins: {} }); + } + if (normalizedPath.endsWith('/settings.json')) { + return JSON.stringify({ enabledPlugins: {} }); + } + throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); + }); await service.getInstalledPlugins(); service.invalidateCache(); await service.getInstalledPlugins(); - expect(mockedFs.readFile).toHaveBeenCalledTimes(2); + expect(mockedFs.readFile).toHaveBeenCalledTimes(4); }); }); }); diff --git a/test/renderer/store/extensionsSlice.test.ts b/test/renderer/store/extensionsSlice.test.ts index a69507fa..3240c33a 100644 --- a/test/renderer/store/extensionsSlice.test.ts +++ b/test/renderer/store/extensionsSlice.test.ts @@ -116,6 +116,23 @@ const makeSkillDetail = (overrides: Partial = {}): SkillDetail => ( ...overrides, }); +const makeReadyCliStatus = () => ({ + flavor: 'claude' as const, + displayName: 'Claude', + supportsSelfUpdate: true, + showVersionDetails: true, + showBinaryPath: true, + installed: true, + installedVersion: '1.0.0', + binaryPath: '/usr/local/bin/claude', + latestVersion: '1.0.0', + updateAvailable: false, + authLoggedIn: true, + authStatusChecking: false, + authMethod: 'oauth_token' as const, + providers: [], +}); + describe('extensionsSlice', () => { let store: TestStore; @@ -298,24 +315,7 @@ describe('extensionsSlice', () => { describe('installPlugin', () => { it('sets progress to pending then success', async () => { - store.setState({ - cliStatus: { - flavor: 'claude', - displayName: 'Claude', - supportsSelfUpdate: true, - showVersionDetails: true, - showBinaryPath: true, - installed: true, - installedVersion: '1.0.0', - binaryPath: '/usr/local/bin/claude', - latestVersion: '1.0.0', - updateAvailable: false, - authLoggedIn: true, - authStatusChecking: false, - authMethod: 'oauth_token', - providers: [], - }, - }); + store.setState({ cliStatus: makeReadyCliStatus() }); const plugins = [makePlugin({ pluginId: 'a@m' })]; (api.plugins!.getAll as ReturnType).mockResolvedValue(plugins); (api.plugins!.install as ReturnType).mockResolvedValue({ state: 'success' }); @@ -330,24 +330,7 @@ describe('extensionsSlice', () => { }); it('sets progress to error on failure', async () => { - store.setState({ - cliStatus: { - flavor: 'claude', - displayName: 'Claude', - supportsSelfUpdate: true, - showVersionDetails: true, - showBinaryPath: true, - installed: true, - installedVersion: '1.0.0', - binaryPath: '/usr/local/bin/claude', - latestVersion: '1.0.0', - updateAvailable: false, - authLoggedIn: true, - authStatusChecking: false, - authMethod: 'oauth_token', - providers: [], - }, - }); + store.setState({ cliStatus: makeReadyCliStatus() }); (api.plugins!.install as ReturnType).mockResolvedValue({ state: 'error', error: 'Not found', @@ -357,6 +340,32 @@ describe('extensionsSlice', () => { expect(store.getState().pluginInstallProgress['fail@m']).toBe('error'); }); + + it('fills missing projectPath from the active Extensions project context', async () => { + store.setState({ + cliStatus: makeReadyCliStatus(), + pluginCatalogProjectPath: '/tmp/project-a', + }); + (api.plugins!.install as ReturnType).mockResolvedValue({ state: 'success' }); + + await store.getState().installPlugin({ pluginId: 'project@m', scope: 'project' }); + + expect(api.plugins!.install).toHaveBeenCalledWith({ + pluginId: 'project@m', + scope: 'project', + projectPath: '/tmp/project-a', + }); + }); + + it('fails fast for project scope when there is no active project path', async () => { + store.setState({ cliStatus: makeReadyCliStatus(), pluginCatalogProjectPath: null }); + + await store.getState().installPlugin({ pluginId: 'project@m', scope: 'project' }); + + expect(api.plugins!.install).not.toHaveBeenCalled(); + expect(store.getState().pluginInstallProgress['project@m']).toBe('error'); + expect(store.getState().installErrors['project@m']).toContain('active project'); + }); }); describe('uninstallPlugin', () => { @@ -372,6 +381,25 @@ describe('extensionsSlice', () => { await promise; expect(store.getState().pluginInstallProgress['test@m']).toBe('success'); }); + + it('fills missing projectPath from the active Extensions project context', async () => { + store.setState({ pluginCatalogProjectPath: '/tmp/project-a' }); + (api.plugins!.uninstall as ReturnType).mockResolvedValue({ state: 'success' }); + + await store.getState().uninstallPlugin('project@m', 'project'); + + expect(api.plugins!.uninstall).toHaveBeenCalledWith('project@m', 'project', '/tmp/project-a'); + }); + + it('fails fast for project uninstall when there is no active project path', async () => { + store.setState({ pluginCatalogProjectPath: null }); + + await store.getState().uninstallPlugin('project@m', 'project'); + + expect(api.plugins!.uninstall).not.toHaveBeenCalled(); + expect(store.getState().pluginInstallProgress['project@m']).toBe('error'); + expect(store.getState().installErrors['project@m']).toContain('active project'); + }); }); describe('installMcpServer', () => {