fix(extensions): support local plugin scope actions
This commit is contained in:
parent
2b8062dfa3
commit
4502152427
6 changed files with 109 additions and 15 deletions
|
|
@ -26,6 +26,10 @@ const VALID_SCOPES = new Set(['local', 'user', 'project']);
|
||||||
const INSTALL_TIMEOUT_MS = 120_000; // plugins may clone repos
|
const INSTALL_TIMEOUT_MS = 120_000; // plugins may clone repos
|
||||||
const UNINSTALL_TIMEOUT_MS = 30_000;
|
const UNINSTALL_TIMEOUT_MS = 30_000;
|
||||||
|
|
||||||
|
function scopeRequiresProjectPath(scope?: string): boolean {
|
||||||
|
return scope === 'project' || scope === 'local';
|
||||||
|
}
|
||||||
|
|
||||||
export class PluginInstallService {
|
export class PluginInstallService {
|
||||||
constructor(private readonly catalogService: PluginCatalogService) {}
|
constructor(private readonly catalogService: PluginCatalogService) {}
|
||||||
|
|
||||||
|
|
@ -48,10 +52,10 @@ export class PluginInstallService {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (scope === 'project' && !projectPath) {
|
if (scopeRequiresProjectPath(scope) && !projectPath) {
|
||||||
return {
|
return {
|
||||||
state: 'error',
|
state: 'error',
|
||||||
error: 'projectPath is required for project-scoped plugin installs',
|
error: `projectPath is required for ${scope}-scoped plugin installs`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -130,10 +134,10 @@ export class PluginInstallService {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (scope === 'project' && !projectPath) {
|
if (scopeRequiresProjectPath(scope) && !projectPath) {
|
||||||
return {
|
return {
|
||||||
state: 'error',
|
state: 'error',
|
||||||
error: 'projectPath is required for project-scoped plugin uninstalls',
|
error: `projectPath is required for ${scope}-scoped plugin uninstalls`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -48,7 +48,8 @@ interface PluginDetailDialogProps {
|
||||||
|
|
||||||
const SCOPE_OPTIONS: { value: InstallScope; label: string }[] = [
|
const SCOPE_OPTIONS: { value: InstallScope; label: string }[] = [
|
||||||
{ value: 'user', label: 'User (global)' },
|
{ value: 'user', label: 'User (global)' },
|
||||||
{ value: 'project', label: 'Project' },
|
{ value: 'project', label: 'Project (shared)' },
|
||||||
|
{ value: 'local', label: 'Local (gitignored)' },
|
||||||
];
|
];
|
||||||
|
|
||||||
export const PluginDetailDialog = ({
|
export const PluginDetailDialog = ({
|
||||||
|
|
@ -94,7 +95,7 @@ export const PluginDetailDialog = ({
|
||||||
}, [open, plugin?.pluginId]);
|
}, [open, plugin?.pluginId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (scope === 'project' && !projectScopeAvailable) {
|
if (scope !== 'user' && !projectScopeAvailable) {
|
||||||
setScope('user');
|
setScope('user');
|
||||||
}
|
}
|
||||||
}, [projectScopeAvailable, scope]);
|
}, [projectScopeAvailable, scope]);
|
||||||
|
|
@ -186,7 +187,7 @@ export const PluginDetailDialog = ({
|
||||||
<SelectItem
|
<SelectItem
|
||||||
key={opt.value}
|
key={opt.value}
|
||||||
value={opt.value}
|
value={opt.value}
|
||||||
disabled={opt.value === 'project' && !projectScopeAvailable}
|
disabled={opt.value !== 'user' && !projectScopeAvailable}
|
||||||
>
|
>
|
||||||
{opt.label}
|
{opt.label}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
|
|
@ -201,7 +202,7 @@ export const PluginDetailDialog = ({
|
||||||
installPlugin({
|
installPlugin({
|
||||||
pluginId: plugin.pluginId,
|
pluginId: plugin.pluginId,
|
||||||
scope,
|
scope,
|
||||||
...(scope === 'project' && pluginCatalogProjectPath
|
...(scope !== 'user' && pluginCatalogProjectPath
|
||||||
? { projectPath: pluginCatalogProjectPath }
|
? { projectPath: pluginCatalogProjectPath }
|
||||||
: {}),
|
: {}),
|
||||||
})
|
})
|
||||||
|
|
@ -210,7 +211,7 @@ export const PluginDetailDialog = ({
|
||||||
uninstallPlugin(
|
uninstallPlugin(
|
||||||
plugin.pluginId,
|
plugin.pluginId,
|
||||||
scope,
|
scope,
|
||||||
scope === 'project' ? (pluginCatalogProjectPath ?? undefined) : undefined
|
scope !== 'user' ? (pluginCatalogProjectPath ?? undefined) : undefined
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
size="default"
|
size="default"
|
||||||
|
|
|
||||||
|
|
@ -225,7 +225,7 @@ const CLI_HEALTHCHECK_FAILED_MESSAGE =
|
||||||
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 =
|
const PROJECT_SCOPE_REQUIRED_MESSAGE =
|
||||||
'Project-scoped plugins require an active project in the Extensions tab.';
|
'Project- and local-scoped plugins require an active project in the Extensions tab.';
|
||||||
|
|
||||||
export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSlice> = (
|
export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSlice> = (
|
||||||
set,
|
set,
|
||||||
|
|
@ -688,7 +688,7 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
|
||||||
if (!api.plugins) return;
|
if (!api.plugins) return;
|
||||||
|
|
||||||
const effectiveProjectPath =
|
const effectiveProjectPath =
|
||||||
request.scope === 'project'
|
request.scope !== 'user'
|
||||||
? (request.projectPath ?? get().pluginCatalogProjectPath ?? undefined)
|
? (request.projectPath ?? get().pluginCatalogProjectPath ?? undefined)
|
||||||
: request.projectPath;
|
: request.projectPath;
|
||||||
const effectiveRequest =
|
const effectiveRequest =
|
||||||
|
|
@ -707,7 +707,7 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
|
||||||
|
|
||||||
const cliStatus = get().cliStatus;
|
const cliStatus = get().cliStatus;
|
||||||
const preflightError =
|
const preflightError =
|
||||||
effectiveRequest.scope === 'project' && !effectiveRequest.projectPath
|
effectiveRequest.scope !== 'user' && !effectiveRequest.projectPath
|
||||||
? PROJECT_SCOPE_REQUIRED_MESSAGE
|
? PROJECT_SCOPE_REQUIRED_MESSAGE
|
||||||
: cliStatus === null
|
: cliStatus === null
|
||||||
? CLI_STATUS_UNKNOWN_MESSAGE
|
? CLI_STATUS_UNKNOWN_MESSAGE
|
||||||
|
|
@ -770,10 +770,10 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
|
||||||
if (!api.plugins) return;
|
if (!api.plugins) return;
|
||||||
|
|
||||||
const effectiveProjectPath =
|
const effectiveProjectPath =
|
||||||
scope === 'project'
|
scope && scope !== 'user'
|
||||||
? (projectPath ?? get().pluginCatalogProjectPath ?? undefined)
|
? (projectPath ?? get().pluginCatalogProjectPath ?? undefined)
|
||||||
: projectPath;
|
: projectPath;
|
||||||
if (scope === 'project' && !effectiveProjectPath) {
|
if (scope && scope !== 'user' && !effectiveProjectPath) {
|
||||||
clearPluginSuccessResetTimer(pluginId);
|
clearPluginSuccessResetTimer(pluginId);
|
||||||
set((prev) => ({
|
set((prev) => ({
|
||||||
pluginInstallProgress: { ...prev.pluginInstallProgress, [pluginId]: 'error' },
|
pluginInstallProgress: { ...prev.pluginInstallProgress, [pluginId]: 'error' },
|
||||||
|
|
|
||||||
|
|
@ -70,7 +70,7 @@ export function inferCapabilities(item: PluginCatalogItem): PluginCapability[] {
|
||||||
export interface PluginInstallRequest {
|
export interface PluginInstallRequest {
|
||||||
pluginId: string; // canonical key — main resolves qualifiedName from catalog
|
pluginId: string; // canonical key — main resolves qualifiedName from catalog
|
||||||
scope: InstallScope;
|
scope: InstallScope;
|
||||||
projectPath?: string; // required for 'project' scope
|
projectPath?: string; // required for repo-scoped installs ('project' or 'local')
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Filters (renderer-only concern) ────────────────────────────────────────
|
// ── Filters (renderer-only concern) ────────────────────────────────────────
|
||||||
|
|
|
||||||
|
|
@ -85,6 +85,22 @@ describe('PluginInstallService', () => {
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('adds local scope flag and cwd for local installs', async () => {
|
||||||
|
mockExecCli.mockResolvedValue({ stdout: '', stderr: '' });
|
||||||
|
|
||||||
|
await service.install({
|
||||||
|
pluginId: 'context7',
|
||||||
|
scope: 'local',
|
||||||
|
projectPath: '/tmp/test-project',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockExecCli).toHaveBeenCalledWith(
|
||||||
|
'/usr/local/bin/claude',
|
||||||
|
['plugin', 'install', '-s', 'local', 'context7@claude-plugins-official'],
|
||||||
|
expect.objectContaining({ cwd: '/tmp/test-project' }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it('returns error if plugin not found in catalog', async () => {
|
it('returns error if plugin not found in catalog', async () => {
|
||||||
catalog = createMockCatalog({
|
catalog = createMockCatalog({
|
||||||
resolvePlugin: vi.fn().mockResolvedValue(null) as PluginCatalogService['resolvePlugin'],
|
resolvePlugin: vi.fn().mockResolvedValue(null) as PluginCatalogService['resolvePlugin'],
|
||||||
|
|
@ -129,6 +145,14 @@ describe('PluginInstallService', () => {
|
||||||
expect(result.error).toContain('projectPath is required');
|
expect(result.error).toContain('projectPath is required');
|
||||||
expect(mockExecCli).not.toHaveBeenCalled();
|
expect(mockExecCli).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('rejects local scope when projectPath is missing', async () => {
|
||||||
|
const result = await service.install({ pluginId: 'context7', scope: 'local' });
|
||||||
|
|
||||||
|
expect(result.state).toBe('error');
|
||||||
|
expect(result.error).toContain('local-scoped');
|
||||||
|
expect(mockExecCli).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── uninstall ───────────────────────────────────────────────────────────────
|
// ── uninstall ───────────────────────────────────────────────────────────────
|
||||||
|
|
@ -159,6 +183,18 @@ describe('PluginInstallService', () => {
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('adds scope flag for local scope', async () => {
|
||||||
|
mockExecCli.mockResolvedValue({ stdout: '', stderr: '' });
|
||||||
|
|
||||||
|
await service.uninstall('context7', 'local', '/tmp/test-project');
|
||||||
|
|
||||||
|
expect(mockExecCli).toHaveBeenCalledWith(
|
||||||
|
'/usr/local/bin/claude',
|
||||||
|
['plugin', 'uninstall', '-s', 'local', 'context7@claude-plugins-official'],
|
||||||
|
expect.objectContaining({ cwd: '/tmp/test-project' }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it('returns error if plugin not in catalog', async () => {
|
it('returns error if plugin not in catalog', async () => {
|
||||||
catalog = createMockCatalog({
|
catalog = createMockCatalog({
|
||||||
resolvePlugin: vi.fn().mockResolvedValue(null) as PluginCatalogService['resolvePlugin'],
|
resolvePlugin: vi.fn().mockResolvedValue(null) as PluginCatalogService['resolvePlugin'],
|
||||||
|
|
@ -187,5 +223,13 @@ describe('PluginInstallService', () => {
|
||||||
expect(result.error).toContain('projectPath is required');
|
expect(result.error).toContain('projectPath is required');
|
||||||
expect(mockExecCli).not.toHaveBeenCalled();
|
expect(mockExecCli).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('rejects local scope when projectPath is missing', async () => {
|
||||||
|
const result = await service.uninstall('context7', 'local');
|
||||||
|
|
||||||
|
expect(result.state).toBe('error');
|
||||||
|
expect(result.error).toContain('local-scoped');
|
||||||
|
expect(mockExecCli).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -492,6 +492,32 @@ describe('extensionsSlice', () => {
|
||||||
expect(store.getState().installErrors['project@m']).toContain('active project');
|
expect(store.getState().installErrors['project@m']).toContain('active project');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('fills missing projectPath for local scope 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: 'local@m', scope: 'local' });
|
||||||
|
|
||||||
|
expect(api.plugins!.install).toHaveBeenCalledWith({
|
||||||
|
pluginId: 'local@m',
|
||||||
|
scope: 'local',
|
||||||
|
projectPath: '/tmp/project-a',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fails fast for local scope when there is no active project path', async () => {
|
||||||
|
store.setState({ cliStatus: makeReadyCliStatus(), pluginCatalogProjectPath: null });
|
||||||
|
|
||||||
|
await store.getState().installPlugin({ pluginId: 'local@m', scope: 'local' });
|
||||||
|
|
||||||
|
expect(api.plugins!.install).not.toHaveBeenCalled();
|
||||||
|
expect(store.getState().pluginInstallProgress['local@m']).toBe('error');
|
||||||
|
expect(store.getState().installErrors['local@m']).toContain('active project');
|
||||||
|
});
|
||||||
|
|
||||||
it('clears older success reset timers before a new operation on the same plugin', async () => {
|
it('clears older success reset timers before a new operation on the same plugin', async () => {
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
store.setState({ cliStatus: makeReadyCliStatus() });
|
store.setState({ cliStatus: makeReadyCliStatus() });
|
||||||
|
|
@ -545,6 +571,25 @@ describe('extensionsSlice', () => {
|
||||||
expect(store.getState().installErrors['project@m']).toContain('active project');
|
expect(store.getState().installErrors['project@m']).toContain('active project');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('fills missing projectPath for local uninstall 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('local@m', 'local');
|
||||||
|
|
||||||
|
expect(api.plugins!.uninstall).toHaveBeenCalledWith('local@m', 'local', '/tmp/project-a');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fails fast for local uninstall when there is no active project path', async () => {
|
||||||
|
store.setState({ pluginCatalogProjectPath: null });
|
||||||
|
|
||||||
|
await store.getState().uninstallPlugin('local@m', 'local');
|
||||||
|
|
||||||
|
expect(api.plugins!.uninstall).not.toHaveBeenCalled();
|
||||||
|
expect(store.getState().pluginInstallProgress['local@m']).toBe('error');
|
||||||
|
expect(store.getState().installErrors['local@m']).toContain('active project');
|
||||||
|
});
|
||||||
|
|
||||||
it('does not restore idle state after project switch clears a pending success timer', async () => {
|
it('does not restore idle state after project switch clears a pending success timer', async () => {
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
store.setState({
|
store.setState({
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue