fix(extensions): scope plugin install state to active project

This commit is contained in:
777genius 2026-04-16 21:55:50 +03:00
parent 58644b24c6
commit f2c5d52bdc
6 changed files with 432 additions and 104 deletions

View file

@ -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) // 3. Resolve qualifiedName from catalog (NOT from renderer)
const resolved = await this.catalogService.resolvePlugin(pluginId); const resolved = await this.catalogService.resolvePlugin(pluginId);
if (!resolved) { 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 // Resolve qualifiedName from catalog
const resolved = await this.catalogService.resolvePlugin(pluginId); const resolved = await this.catalogService.resolvePlugin(pluginId);
if (!resolved) { if (!resolved) {

View file

@ -60,23 +60,26 @@ interface TimedCache<T> {
// ── Service ──────────────────────────────────────────────────────────────── // ── Service ────────────────────────────────────────────────────────────────
export class PluginInstallationStateService { export class PluginInstallationStateService {
private installedCache: TimedCache<InstalledPluginEntry[]> | null = null; private installedCache = new Map<string, TimedCache<InstalledPluginEntry[]>>();
private countsCache: TimedCache<Map<string, number>> | null = null; private countsCache: TimedCache<Map<string, number>> | null = null;
/** /**
* Get all installed plugins across all scopes. * Get installed plugins relevant to the active context.
* Returns merged list from installed_plugins.json with scope tags. * Always includes user scope. Project/local entries are only included when
* they are enabled for the active project.
*/ */
async getInstalledPlugins(_projectPath?: string): Promise<InstalledPluginEntry[]> { async getInstalledPlugins(projectPath?: string): Promise<InstalledPluginEntry[]> {
if ( const normalizedProjectPath =
this.installedCache && typeof projectPath === 'string' && path.isAbsolute(projectPath) ? projectPath : undefined;
Date.now() - this.installedCache.fetchedAt < INSTALLED_STATE_TTL_MS const cacheKey = this.getInstalledCacheKey(normalizedProjectPath);
) { const cached = this.installedCache.get(cacheKey);
return this.installedCache.data;
if (cached && Date.now() - cached.fetchedAt < INSTALLED_STATE_TTL_MS) {
return cached.data;
} }
const entries = await this.readInstalledPlugins(); const entries = await this.buildInstalledEntriesForContext(normalizedProjectPath);
this.installedCache = { data: entries, fetchedAt: Date.now() }; this.installedCache.set(cacheKey, { data: entries, fetchedAt: Date.now() });
return entries; return entries;
} }
@ -97,7 +100,7 @@ export class PluginInstallationStateService {
* Invalidate all caches. Call after install/uninstall operations. * Invalidate all caches. Call after install/uninstall operations.
*/ */
invalidateCache(): void { invalidateCache(): void {
this.installedCache = null; this.installedCache.clear();
this.countsCache = null; this.countsCache = null;
} }
@ -107,7 +110,81 @@ export class PluginInstallationStateService {
return path.join(getClaudeBasePath(), 'plugins'); return path.join(getClaudeBasePath(), 'plugins');
} }
private async readInstalledPlugins(): Promise<InstalledPluginEntry[]> { private getInstalledCacheKey(projectPath?: string): string {
return projectPath ?? '__user__';
}
private async buildInstalledEntriesForContext(
projectPath?: string
): Promise<InstalledPluginEntry[]> {
const installedMetadata = await this.readInstalledPluginMetadata();
const metadataByKey = new Map<string, InstalledPluginEntry[]>();
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<string>();
const localEnabled = projectPath
? await this.readEnabledPlugins(path.join(projectPath, '.claude', 'settings.local.json'))
: new Set<string>();
return [
...this.buildScopedEntries('user', userEnabled, metadataByKey),
...this.buildScopedEntries('project', projectEnabled, metadataByKey),
...this.buildScopedEntries('local', localEnabled, metadataByKey),
];
}
private buildScopedEntries(
scope: InstallScope,
enabledPlugins: Set<string>,
metadataByKey: Map<string, InstalledPluginEntry[]>
): 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<InstalledPluginEntry[]> {
const filePath = path.join(this.getPluginsDir(), 'installed_plugins.json'); const filePath = path.join(this.getPluginsDir(), 'installed_plugins.json');
try { try {
@ -143,6 +220,31 @@ export class PluginInstallationStateService {
} }
} }
private async readEnabledPlugins(filePath: string): Promise<Set<string>> {
try {
const raw = await fs.readFile(filePath, 'utf-8');
const json = JSON.parse(raw) as {
enabledPlugins?: Record<string, boolean> | null;
};
if (!json.enabledPlugins || typeof json.enabledPlugins !== 'object') {
return new Set<string>();
}
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<string>();
}
logger.error(`Failed to read plugin settings from ${filePath}:`, err);
return new Set<string>();
}
}
private async readInstallCounts(): Promise<Map<string, number>> { private async readInstallCounts(): Promise<Map<string, number>> {
const filePath = path.join(this.getPluginsDir(), 'install-counts-cache.json'); const filePath = path.join(this.getPluginsDir(), 'install-counts-cache.json');

View file

@ -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.'; '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 = const CLI_STATUS_UNKNOWN_MESSAGE =
'Unable to verify Claude CLI status. Open the Dashboard and check the CLI before retrying.'; '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<AppState, [], [], ExtensionsSlice> = ( export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSlice> = (
set, set,
@ -561,6 +563,15 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
installPlugin: async (request: PluginInstallRequest) => { installPlugin: async (request: PluginInstallRequest) => {
if (!api.plugins) return; 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(); const preflightState = get();
if (preflightState.cliStatus === null || preflightState.cliStatusLoading) { if (preflightState.cliStatus === null || preflightState.cliStatusLoading) {
try { try {
@ -572,15 +583,17 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
const cliStatus = get().cliStatus; const cliStatus = get().cliStatus;
const preflightError = const preflightError =
cliStatus === null effectiveRequest.scope === 'project' && !effectiveRequest.projectPath
? CLI_STATUS_UNKNOWN_MESSAGE ? PROJECT_SCOPE_REQUIRED_MESSAGE
: !cliStatus.installed : cliStatus === null
? cliStatus.binaryPath && cliStatus.launchError ? CLI_STATUS_UNKNOWN_MESSAGE
? CLI_HEALTHCHECK_FAILED_MESSAGE : !cliStatus.installed
: CLI_NOT_FOUND_MESSAGE ? cliStatus.binaryPath && cliStatus.launchError
: !cliStatus.authLoggedIn ? CLI_HEALTHCHECK_FAILED_MESSAGE
? CLI_AUTH_REQUIRED_MESSAGE : CLI_NOT_FOUND_MESSAGE
: null; : !cliStatus.authLoggedIn
? CLI_AUTH_REQUIRED_MESSAGE
: null;
if (preflightError) { if (preflightError) {
set((prev) => ({ set((prev) => ({
@ -596,7 +609,7 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
})); }));
try { try {
const result = await api.plugins.install(request); const result = await api.plugins.install(effectiveRequest);
if (result.state === 'error') { if (result.state === 'error') {
set((prev) => ({ set((prev) => ({
pluginInstallProgress: { ...prev.pluginInstallProgress, [request.pluginId]: 'error' }, pluginInstallProgress: { ...prev.pluginInstallProgress, [request.pluginId]: 'error' },
@ -634,12 +647,24 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
uninstallPlugin: async (pluginId: string, scope?: InstallScope, projectPath?: string) => { uninstallPlugin: async (pluginId: string, scope?: InstallScope, projectPath?: string) => {
if (!api.plugins) return; 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) => ({ set((prev) => ({
pluginInstallProgress: { ...prev.pluginInstallProgress, [pluginId]: 'pending' }, pluginInstallProgress: { ...prev.pluginInstallProgress, [pluginId]: 'pending' },
})); }));
try { try {
const result = await api.plugins.uninstall(pluginId, scope, projectPath); const result = await api.plugins.uninstall(pluginId, scope, effectiveProjectPath);
if (result.state === 'error') { if (result.state === 'error') {
set((prev) => ({ set((prev) => ({
pluginInstallProgress: { ...prev.pluginInstallProgress, [pluginId]: 'error' }, pluginInstallProgress: { ...prev.pluginInstallProgress, [pluginId]: 'error' },

View file

@ -121,6 +121,14 @@ describe('PluginInstallService', () => {
expect(result.state).toBe('error'); expect(result.state).toBe('error');
expect(result.error).toContain('Command failed'); 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 ─────────────────────────────────────────────────────────────── // ── uninstall ───────────────────────────────────────────────────────────────
@ -171,5 +179,13 @@ describe('PluginInstallService', () => {
expect(result.state).toBe('error'); expect(result.state).toBe('error');
expect(result.error).toContain('Cannot uninstall'); 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();
});
}); });
}); });

View file

@ -25,47 +25,150 @@ describe('PluginInstallationStateService', () => {
}); });
describe('getInstalledPlugins', () => { describe('getInstalledPlugins', () => {
it('parses installed_plugins.json version 2 format', async () => { it('returns user-scoped plugins enabled in user settings', async () => {
const installedData = { mockedFs.readFile.mockImplementation(async (filePath) => {
version: 2, const normalizedPath = String(filePath);
plugins: { if (normalizedPath.endsWith('/plugins/installed_plugins.json')) {
'context7@claude-plugins-official': [ return JSON.stringify({
{ version: 2,
scope: 'user', plugins: {
installPath: '/Users/test/.claude/plugins/cache/claude-plugins-official/context7/1.0.0', 'context7@claude-plugins-official': [
version: '1.0.0', {
installedAt: '2026-03-01T11:14:21.926Z', 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(); const entries = await service.getInstalledPlugins();
expect(entries).toHaveLength(3); expect(entries).toHaveLength(1);
expect(entries[0].pluginId).toBe('context7@claude-plugins-official'); expect(entries[0]).toMatchObject({
expect(entries[0].scope).toBe('user'); pluginId: 'context7@claude-plugins-official',
expect(entries[0].version).toBe('1.0.0'); scope: 'user',
version: '1.0.0',
});
});
expect(entries[1].pluginId).toBe('typescript-lsp@claude-plugins-official'); it('includes project and local scopes only for the active project', async () => {
expect(entries[1].scope).toBe('user'); 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'); if (normalizedPath === '/tmp/mock-claude/settings.json') {
expect(entries[2].scope).toBe('project'); 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 () => { it('returns empty array when file does not exist', async () => {
@ -78,22 +181,55 @@ describe('PluginInstallationStateService', () => {
}); });
it('returns empty array for unexpected version', async () => { 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(); const entries = await service.getInstalledPlugins();
expect(entries).toEqual([]); expect(entries).toEqual([]);
}); });
it('caches within TTL', async () => { it('caches within TTL', async () => {
mockedFs.readFile.mockResolvedValue( mockedFs.readFile.mockImplementation(async (filePath) => {
JSON.stringify({ version: 2, plugins: {} }), 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();
await service.getInstalledPlugins(); await service.getInstalledPlugins();
// Only one read expect(mockedFs.readFile).toHaveBeenCalledTimes(2);
expect(mockedFs.readFile).toHaveBeenCalledTimes(1); });
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', () => { describe('invalidateCache', () => {
it('forces re-read after invalidation', async () => { it('forces re-read after invalidation', async () => {
mockedFs.readFile.mockResolvedValue( mockedFs.readFile.mockImplementation(async (filePath) => {
JSON.stringify({ version: 2, plugins: {} }), 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();
service.invalidateCache(); service.invalidateCache();
await service.getInstalledPlugins(); await service.getInstalledPlugins();
expect(mockedFs.readFile).toHaveBeenCalledTimes(2); expect(mockedFs.readFile).toHaveBeenCalledTimes(4);
}); });
}); });
}); });

View file

@ -116,6 +116,23 @@ const makeSkillDetail = (overrides: Partial<SkillDetail> = {}): SkillDetail => (
...overrides, ...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', () => { describe('extensionsSlice', () => {
let store: TestStore; let store: TestStore;
@ -298,24 +315,7 @@ describe('extensionsSlice', () => {
describe('installPlugin', () => { describe('installPlugin', () => {
it('sets progress to pending then success', async () => { it('sets progress to pending then success', async () => {
store.setState({ store.setState({ cliStatus: makeReadyCliStatus() });
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: [],
},
});
const plugins = [makePlugin({ pluginId: 'a@m' })]; const plugins = [makePlugin({ pluginId: 'a@m' })];
(api.plugins!.getAll as ReturnType<typeof vi.fn>).mockResolvedValue(plugins); (api.plugins!.getAll as ReturnType<typeof vi.fn>).mockResolvedValue(plugins);
(api.plugins!.install as ReturnType<typeof vi.fn>).mockResolvedValue({ state: 'success' }); (api.plugins!.install as ReturnType<typeof vi.fn>).mockResolvedValue({ state: 'success' });
@ -330,24 +330,7 @@ describe('extensionsSlice', () => {
}); });
it('sets progress to error on failure', async () => { it('sets progress to error on failure', async () => {
store.setState({ store.setState({ cliStatus: makeReadyCliStatus() });
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: [],
},
});
(api.plugins!.install as ReturnType<typeof vi.fn>).mockResolvedValue({ (api.plugins!.install as ReturnType<typeof vi.fn>).mockResolvedValue({
state: 'error', state: 'error',
error: 'Not found', error: 'Not found',
@ -357,6 +340,32 @@ describe('extensionsSlice', () => {
expect(store.getState().pluginInstallProgress['fail@m']).toBe('error'); 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<typeof vi.fn>).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', () => { describe('uninstallPlugin', () => {
@ -372,6 +381,25 @@ describe('extensionsSlice', () => {
await promise; await promise;
expect(store.getState().pluginInstallProgress['test@m']).toBe('success'); 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<typeof vi.fn>).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', () => { describe('installMcpServer', () => {