fix(extensions): scope plugin install state to active project
This commit is contained in:
parent
58644b24c6
commit
f2c5d52bdc
6 changed files with 432 additions and 104 deletions
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -60,23 +60,26 @@ interface TimedCache<T> {
|
|||
// ── Service ────────────────────────────────────────────────────────────────
|
||||
|
||||
export class PluginInstallationStateService {
|
||||
private installedCache: TimedCache<InstalledPluginEntry[]> | null = null;
|
||||
private installedCache = new Map<string, TimedCache<InstalledPluginEntry[]>>();
|
||||
private countsCache: TimedCache<Map<string, number>> | 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<InstalledPluginEntry[]> {
|
||||
if (
|
||||
this.installedCache &&
|
||||
Date.now() - this.installedCache.fetchedAt < INSTALLED_STATE_TTL_MS
|
||||
) {
|
||||
return this.installedCache.data;
|
||||
async getInstalledPlugins(projectPath?: string): Promise<InstalledPluginEntry[]> {
|
||||
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<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');
|
||||
|
||||
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>> {
|
||||
const filePath = path.join(this.getPluginsDir(), 'install-counts-cache.json');
|
||||
|
||||
|
|
|
|||
|
|
@ -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<AppState, [], [], ExtensionsSlice> = (
|
||||
set,
|
||||
|
|
@ -561,6 +563,15 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
|
|||
installPlugin: async (request: PluginInstallRequest) => {
|
||||
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<AppState, [], [], ExtensionsSli
|
|||
|
||||
const cliStatus = get().cliStatus;
|
||||
const preflightError =
|
||||
cliStatus === null
|
||||
? CLI_STATUS_UNKNOWN_MESSAGE
|
||||
: !cliStatus.installed
|
||||
? cliStatus.binaryPath && cliStatus.launchError
|
||||
? CLI_HEALTHCHECK_FAILED_MESSAGE
|
||||
: CLI_NOT_FOUND_MESSAGE
|
||||
: !cliStatus.authLoggedIn
|
||||
? CLI_AUTH_REQUIRED_MESSAGE
|
||||
: null;
|
||||
effectiveRequest.scope === 'project' && !effectiveRequest.projectPath
|
||||
? PROJECT_SCOPE_REQUIRED_MESSAGE
|
||||
: cliStatus === null
|
||||
? CLI_STATUS_UNKNOWN_MESSAGE
|
||||
: !cliStatus.installed
|
||||
? cliStatus.binaryPath && cliStatus.launchError
|
||||
? CLI_HEALTHCHECK_FAILED_MESSAGE
|
||||
: CLI_NOT_FOUND_MESSAGE
|
||||
: !cliStatus.authLoggedIn
|
||||
? CLI_AUTH_REQUIRED_MESSAGE
|
||||
: null;
|
||||
|
||||
if (preflightError) {
|
||||
set((prev) => ({
|
||||
|
|
@ -596,7 +609,7 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
|
|||
}));
|
||||
|
||||
try {
|
||||
const result = await api.plugins.install(request);
|
||||
const result = await api.plugins.install(effectiveRequest);
|
||||
if (result.state === 'error') {
|
||||
set((prev) => ({
|
||||
pluginInstallProgress: { ...prev.pluginInstallProgress, [request.pluginId]: 'error' },
|
||||
|
|
@ -634,12 +647,24 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
|
|||
uninstallPlugin: async (pluginId: string, scope?: InstallScope, projectPath?: string) => {
|
||||
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' },
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -116,6 +116,23 @@ const makeSkillDetail = (overrides: Partial<SkillDetail> = {}): 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<typeof vi.fn>).mockResolvedValue(plugins);
|
||||
(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 () => {
|
||||
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<typeof vi.fn>).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<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', () => {
|
||||
|
|
@ -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<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', () => {
|
||||
|
|
|
|||
Loading…
Reference in a new issue