Merge branch 'dev' into spike/team-snapshot-split-plan
This commit is contained in:
commit
821e23e633
14 changed files with 1079 additions and 157 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,6 +52,13 @@ export class PluginInstallService {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (scopeRequiresProjectPath(scope) && !projectPath) {
|
||||||
|
return {
|
||||||
|
state: 'error',
|
||||||
|
error: `projectPath is required for ${scope}-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 +134,13 @@ export class PluginInstallService {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (scopeRequiresProjectPath(scope) && !projectPath) {
|
||||||
|
return {
|
||||||
|
state: 'error',
|
||||||
|
error: `projectPath is required for ${scope}-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) {
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,7 @@ export const ExtensionStoreView = (): React.JSX.Element => {
|
||||||
fetchSkillsCatalog,
|
fetchSkillsCatalog,
|
||||||
mcpBrowse,
|
mcpBrowse,
|
||||||
mcpFetchInstalled,
|
mcpFetchInstalled,
|
||||||
|
apiKeysLoading,
|
||||||
pluginCatalogLoading,
|
pluginCatalogLoading,
|
||||||
mcpBrowseLoading,
|
mcpBrowseLoading,
|
||||||
skillsLoading,
|
skillsLoading,
|
||||||
|
|
@ -55,6 +56,7 @@ export const ExtensionStoreView = (): React.JSX.Element => {
|
||||||
fetchSkillsCatalog: s.fetchSkillsCatalog,
|
fetchSkillsCatalog: s.fetchSkillsCatalog,
|
||||||
mcpBrowse: s.mcpBrowse,
|
mcpBrowse: s.mcpBrowse,
|
||||||
mcpFetchInstalled: s.mcpFetchInstalled,
|
mcpFetchInstalled: s.mcpFetchInstalled,
|
||||||
|
apiKeysLoading: s.apiKeysLoading,
|
||||||
pluginCatalogLoading: s.pluginCatalogLoading,
|
pluginCatalogLoading: s.pluginCatalogLoading,
|
||||||
mcpBrowseLoading: s.mcpBrowseLoading,
|
mcpBrowseLoading: s.mcpBrowseLoading,
|
||||||
skillsLoading: s.skillsLoading,
|
skillsLoading: s.skillsLoading,
|
||||||
|
|
@ -143,13 +145,24 @@ export const ExtensionStoreView = (): React.JSX.Element => {
|
||||||
|
|
||||||
// Refresh all data (plugins + MCP browse + installed + skills)
|
// Refresh all data (plugins + MCP browse + installed + skills)
|
||||||
const handleRefresh = useCallback(() => {
|
const handleRefresh = useCallback(() => {
|
||||||
|
void fetchCliStatus();
|
||||||
|
void fetchApiKeys();
|
||||||
void fetchPluginCatalog(projectPath ?? undefined, true);
|
void fetchPluginCatalog(projectPath ?? undefined, true);
|
||||||
void mcpBrowse(); // re-fetch first page
|
void mcpBrowse(); // re-fetch first page
|
||||||
void mcpFetchInstalled(projectPath ?? undefined);
|
void mcpFetchInstalled(projectPath ?? undefined);
|
||||||
void fetchSkillsCatalog(projectPath ?? undefined);
|
void fetchSkillsCatalog(projectPath ?? undefined);
|
||||||
}, [fetchPluginCatalog, fetchSkillsCatalog, mcpBrowse, mcpFetchInstalled, projectPath]);
|
}, [
|
||||||
|
fetchApiKeys,
|
||||||
|
fetchCliStatus,
|
||||||
|
fetchPluginCatalog,
|
||||||
|
fetchSkillsCatalog,
|
||||||
|
mcpBrowse,
|
||||||
|
mcpFetchInstalled,
|
||||||
|
projectPath,
|
||||||
|
]);
|
||||||
|
|
||||||
const isRefreshing = pluginCatalogLoading || mcpBrowseLoading || skillsLoading;
|
const isRefreshing =
|
||||||
|
cliStatusLoading || apiKeysLoading || pluginCatalogLoading || mcpBrowseLoading || skillsLoading;
|
||||||
const cliStatusBanner = useMemo(() => {
|
const cliStatusBanner = useMemo(() => {
|
||||||
if (cliStatusLoading || cliStatus === null) {
|
if (cliStatusLoading || cliStatus === null) {
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ import {
|
||||||
TooltipTrigger,
|
TooltipTrigger,
|
||||||
} from '@renderer/components/ui/tooltip';
|
} from '@renderer/components/ui/tooltip';
|
||||||
import { useStore } from '@renderer/store';
|
import { useStore } from '@renderer/store';
|
||||||
|
import { getExtensionActionDisableReason } from '@shared/utils/extensionNormalizers';
|
||||||
import { Check, Loader2, Trash2 } from 'lucide-react';
|
import { Check, Loader2, Trash2 } from 'lucide-react';
|
||||||
import { useShallow } from 'zustand/react/shallow';
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
|
|
||||||
|
|
@ -43,18 +44,11 @@ export const InstallButton = ({
|
||||||
cliStatusLoading: s.cliStatusLoading,
|
cliStatusLoading: s.cliStatusLoading,
|
||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
const cliUnknown = cliStatus === null;
|
const disableReason = getExtensionActionDisableReason({
|
||||||
const cliMissing = cliStatus?.installed === false;
|
isInstalled,
|
||||||
const authMissing = cliStatus?.installed === true && !cliStatus.authLoggedIn;
|
cliStatus,
|
||||||
const disableReason = cliStatusLoading
|
cliStatusLoading,
|
||||||
? 'Checking Claude CLI status...'
|
});
|
||||||
: cliUnknown
|
|
||||||
? 'Checking Claude CLI availability...'
|
|
||||||
: cliMissing
|
|
||||||
? 'Claude CLI required. Install it from the Dashboard.'
|
|
||||||
: authMissing
|
|
||||||
? 'Claude CLI is installed but not signed in. Open the Dashboard to sign in.'
|
|
||||||
: null;
|
|
||||||
const isDisabled = disabled || Boolean(disableReason);
|
const isDisabled = disabled || Boolean(disableReason);
|
||||||
const [lastAction, setLastAction] = useState<'install' | 'uninstall' | null>(null);
|
const [lastAction, setLastAction] = useState<'install' | 'uninstall' | null>(null);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,9 @@
|
||||||
import { Badge } from '@renderer/components/ui/badge';
|
import { Badge } from '@renderer/components/ui/badge';
|
||||||
import { useStore } from '@renderer/store';
|
import { useStore } from '@renderer/store';
|
||||||
import {
|
import {
|
||||||
|
getInstallationSummaryLabel,
|
||||||
getCapabilityLabel,
|
getCapabilityLabel,
|
||||||
|
hasInstallationInScope,
|
||||||
inferCapabilities,
|
inferCapabilities,
|
||||||
normalizeCategory,
|
normalizeCategory,
|
||||||
} from '@shared/utils/extensionNormalizers';
|
} from '@shared/utils/extensionNormalizers';
|
||||||
|
|
@ -29,6 +31,8 @@ export const PluginCard = ({ plugin, index, onClick }: PluginCardProps): React.J
|
||||||
const installPlugin = useStore((s) => s.installPlugin);
|
const installPlugin = useStore((s) => s.installPlugin);
|
||||||
const uninstallPlugin = useStore((s) => s.uninstallPlugin);
|
const uninstallPlugin = useStore((s) => s.uninstallPlugin);
|
||||||
const installError = useStore((s) => s.installErrors[plugin.pluginId]);
|
const installError = useStore((s) => s.installErrors[plugin.pluginId]);
|
||||||
|
const isUserInstalled = hasInstallationInScope(plugin.installations, 'user');
|
||||||
|
const installSummaryLabel = getInstallationSummaryLabel(plugin.installations);
|
||||||
const baseStriped = index % 2 === 0;
|
const baseStriped = index % 2 === 0;
|
||||||
const smStriped = Math.floor(index / 2) % 2 === 0;
|
const smStriped = Math.floor(index / 2) % 2 === 0;
|
||||||
const xlStriped = Math.floor(index / 3) % 2 === 0;
|
const xlStriped = Math.floor(index / 3) % 2 === 0;
|
||||||
|
|
@ -81,12 +85,12 @@ export const PluginCard = ({ plugin, index, onClick }: PluginCardProps): React.J
|
||||||
</div>
|
</div>
|
||||||
<div className="flex shrink-0 items-center gap-2">
|
<div className="flex shrink-0 items-center gap-2">
|
||||||
<InstallCountBadge count={plugin.installCount} />
|
<InstallCountBadge count={plugin.installCount} />
|
||||||
{plugin.isInstalled && (
|
{installSummaryLabel && (
|
||||||
<Badge
|
<Badge
|
||||||
className="shrink-0 border-emerald-500/30 bg-emerald-500/10 text-emerald-400"
|
className="shrink-0 border-emerald-500/30 bg-emerald-500/10 text-emerald-400"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
>
|
>
|
||||||
Installed
|
{installSummaryLabel}
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -112,9 +116,9 @@ export const PluginCard = ({ plugin, index, onClick }: PluginCardProps): React.J
|
||||||
<div className="shrink-0" onClick={(e) => e.stopPropagation()}>
|
<div className="shrink-0" onClick={(e) => e.stopPropagation()}>
|
||||||
<InstallButton
|
<InstallButton
|
||||||
state={installProgress}
|
state={installProgress}
|
||||||
isInstalled={plugin.isInstalled}
|
isInstalled={isUserInstalled}
|
||||||
onInstall={() => installPlugin({ pluginId: plugin.pluginId, scope: 'user' })}
|
onInstall={() => installPlugin({ pluginId: plugin.pluginId, scope: 'user' })}
|
||||||
onUninstall={() => uninstallPlugin(plugin.pluginId)}
|
onUninstall={() => uninstallPlugin(plugin.pluginId, 'user')}
|
||||||
size="sm"
|
size="sm"
|
||||||
errorMessage={installError}
|
errorMessage={installError}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,9 @@ import {
|
||||||
} from '@renderer/components/ui/select';
|
} from '@renderer/components/ui/select';
|
||||||
import { useStore } from '@renderer/store';
|
import { useStore } from '@renderer/store';
|
||||||
import {
|
import {
|
||||||
|
getInstallationSummaryLabel,
|
||||||
getCapabilityLabel,
|
getCapabilityLabel,
|
||||||
|
hasInstallationInScope,
|
||||||
inferCapabilities,
|
inferCapabilities,
|
||||||
normalizeCategory,
|
normalizeCategory,
|
||||||
} from '@shared/utils/extensionNormalizers';
|
} from '@shared/utils/extensionNormalizers';
|
||||||
|
|
@ -46,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 = ({
|
||||||
|
|
@ -54,13 +57,21 @@ export const PluginDetailDialog = ({
|
||||||
open,
|
open,
|
||||||
onClose,
|
onClose,
|
||||||
}: PluginDetailDialogProps): React.JSX.Element => {
|
}: PluginDetailDialogProps): React.JSX.Element => {
|
||||||
const { fetchPluginReadme, readmes, readmeLoading, installPlugin, uninstallPlugin } = useStore(
|
const {
|
||||||
|
fetchPluginReadme,
|
||||||
|
readmes,
|
||||||
|
readmeLoading,
|
||||||
|
installPlugin,
|
||||||
|
uninstallPlugin,
|
||||||
|
pluginCatalogProjectPath,
|
||||||
|
} = useStore(
|
||||||
useShallow((s) => ({
|
useShallow((s) => ({
|
||||||
fetchPluginReadme: s.fetchPluginReadme,
|
fetchPluginReadme: s.fetchPluginReadme,
|
||||||
readmes: s.pluginReadmes,
|
readmes: s.pluginReadmes,
|
||||||
readmeLoading: s.pluginReadmeLoading,
|
readmeLoading: s.pluginReadmeLoading,
|
||||||
installPlugin: s.installPlugin,
|
installPlugin: s.installPlugin,
|
||||||
uninstallPlugin: s.uninstallPlugin,
|
uninstallPlugin: s.uninstallPlugin,
|
||||||
|
pluginCatalogProjectPath: s.pluginCatalogProjectPath,
|
||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
const installProgress = useStore(
|
const installProgress = useStore(
|
||||||
|
|
@ -69,6 +80,7 @@ export const PluginDetailDialog = ({
|
||||||
const installError = useStore((s) => (plugin ? s.installErrors[plugin.pluginId] : undefined));
|
const installError = useStore((s) => (plugin ? s.installErrors[plugin.pluginId] : undefined));
|
||||||
|
|
||||||
const [scope, setScope] = useState<InstallScope>('user');
|
const [scope, setScope] = useState<InstallScope>('user');
|
||||||
|
const projectScopeAvailable = Boolean(pluginCatalogProjectPath);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (plugin && open) {
|
if (plugin && open) {
|
||||||
|
|
@ -76,12 +88,26 @@ export const PluginDetailDialog = ({
|
||||||
}
|
}
|
||||||
}, [plugin, open, fetchPluginReadme]);
|
}, [plugin, open, fetchPluginReadme]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
setScope('user');
|
||||||
|
}
|
||||||
|
}, [open, plugin?.pluginId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (scope !== 'user' && !projectScopeAvailable) {
|
||||||
|
setScope('user');
|
||||||
|
}
|
||||||
|
}, [projectScopeAvailable, scope]);
|
||||||
|
|
||||||
if (!plugin) return <></>;
|
if (!plugin) return <></>;
|
||||||
|
|
||||||
const capabilities = inferCapabilities(plugin);
|
const capabilities = inferCapabilities(plugin);
|
||||||
const category = normalizeCategory(plugin.category);
|
const category = normalizeCategory(plugin.category);
|
||||||
const readme = readmes[plugin.pluginId];
|
const readme = readmes[plugin.pluginId];
|
||||||
const isReadmeLoading = readmeLoading[plugin.pluginId] ?? false;
|
const isReadmeLoading = readmeLoading[plugin.pluginId] ?? false;
|
||||||
|
const isInstalledForScope = hasInstallationInScope(plugin.installations, scope);
|
||||||
|
const installSummaryLabel = getInstallationSummaryLabel(plugin.installations);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
|
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
|
||||||
|
|
@ -93,12 +119,12 @@ export const PluginDetailDialog = ({
|
||||||
<DialogDescription className="mt-1">{plugin.description}</DialogDescription>
|
<DialogDescription className="mt-1">{plugin.description}</DialogDescription>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex shrink-0 items-center gap-1.5">
|
<div className="flex shrink-0 items-center gap-1.5">
|
||||||
{plugin.isInstalled && (
|
{installSummaryLabel && (
|
||||||
<Badge
|
<Badge
|
||||||
className="shrink-0 border-emerald-500/30 bg-emerald-500/10 text-emerald-400"
|
className="shrink-0 border-emerald-500/30 bg-emerald-500/10 text-emerald-400"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
>
|
>
|
||||||
Installed
|
{installSummaryLabel}
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
<SourceBadge source={plugin.source} />
|
<SourceBadge source={plugin.source} />
|
||||||
|
|
@ -158,7 +184,11 @@ export const PluginDetailDialog = ({
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{SCOPE_OPTIONS.map((opt) => (
|
{SCOPE_OPTIONS.map((opt) => (
|
||||||
<SelectItem key={opt.value} value={opt.value}>
|
<SelectItem
|
||||||
|
key={opt.value}
|
||||||
|
value={opt.value}
|
||||||
|
disabled={opt.value !== 'user' && !projectScopeAvailable}
|
||||||
|
>
|
||||||
{opt.label}
|
{opt.label}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
|
|
@ -167,9 +197,23 @@ export const PluginDetailDialog = ({
|
||||||
</div>
|
</div>
|
||||||
<InstallButton
|
<InstallButton
|
||||||
state={installProgress}
|
state={installProgress}
|
||||||
isInstalled={plugin.isInstalled}
|
isInstalled={isInstalledForScope}
|
||||||
onInstall={() => installPlugin({ pluginId: plugin.pluginId, scope })}
|
onInstall={() =>
|
||||||
onUninstall={() => uninstallPlugin(plugin.pluginId, scope)}
|
installPlugin({
|
||||||
|
pluginId: plugin.pluginId,
|
||||||
|
scope,
|
||||||
|
...(scope !== 'user' && pluginCatalogProjectPath
|
||||||
|
? { projectPath: pluginCatalogProjectPath }
|
||||||
|
: {}),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
onUninstall={() =>
|
||||||
|
uninstallPlugin(
|
||||||
|
plugin.pluginId,
|
||||||
|
scope,
|
||||||
|
scope !== 'user' ? (pluginCatalogProjectPath ?? undefined) : undefined
|
||||||
|
)
|
||||||
|
}
|
||||||
size="default"
|
size="default"
|
||||||
errorMessage={installError}
|
errorMessage={installError}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
* PluginsPanel — search, filter, sort and browse the plugin catalog.
|
* PluginsPanel — search, filter, sort and browse the plugin catalog.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useMemo } from 'react';
|
import { useEffect, useMemo } from 'react';
|
||||||
|
|
||||||
import { Badge } from '@renderer/components/ui/badge';
|
import { Badge } from '@renderer/components/ui/badge';
|
||||||
import { Button } from '@renderer/components/ui/button';
|
import { Button } from '@renderer/components/ui/button';
|
||||||
|
|
@ -142,6 +142,18 @@ export const PluginsPanel = ({
|
||||||
[catalog, selectedPluginId]
|
[catalog, selectedPluginId]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedPluginId && !loading && !selectedPlugin) {
|
||||||
|
setSelectedPluginId(null);
|
||||||
|
}
|
||||||
|
}, [loading, selectedPlugin, selectedPluginId, setSelectedPluginId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (error && selectedPluginId) {
|
||||||
|
setSelectedPluginId(null);
|
||||||
|
}
|
||||||
|
}, [error, selectedPluginId, setSelectedPluginId]);
|
||||||
|
|
||||||
const sortValue = `${pluginSort.field}:${pluginSort.order}`;
|
const sortValue = `${pluginSort.field}:${pluginSort.order}`;
|
||||||
const activeFilterCount =
|
const activeFilterCount =
|
||||||
pluginFilters.categories.length +
|
pluginFilters.categories.length +
|
||||||
|
|
|
||||||
|
|
@ -127,7 +127,9 @@ export interface ExtensionsSlice {
|
||||||
// Slice Creator
|
// Slice Creator
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
||||||
let pluginFetchInFlight: Promise<void> | null = null;
|
let pluginFetchInFlight: { key: string; promise: Promise<void> } | null = null;
|
||||||
|
let pluginCatalogRequestSeq = 0;
|
||||||
|
const pluginSuccessResetTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
||||||
let mcpDiagnosticsInFlight: Promise<void> | null = null;
|
let mcpDiagnosticsInFlight: Promise<void> | null = null;
|
||||||
let skillsCatalogRequestSeq = 0;
|
let skillsCatalogRequestSeq = 0;
|
||||||
let skillsDetailRequestSeq = 0;
|
let skillsDetailRequestSeq = 0;
|
||||||
|
|
@ -140,6 +142,76 @@ function hasAnyLoading(loadingMap: Record<string, boolean>): boolean {
|
||||||
return Object.values(loadingMap).some(Boolean);
|
return Object.values(loadingMap).some(Boolean);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getPluginCatalogKey(projectPath?: string): string {
|
||||||
|
return projectPath ?? '__user__';
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPluginIdSet(catalog: EnrichedPlugin[]): Set<string> {
|
||||||
|
return new Set(catalog.map((plugin) => plugin.pluginId));
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearPluginOperationState(
|
||||||
|
pluginIds: Set<string>,
|
||||||
|
pluginInstallProgress: Record<string, ExtensionOperationState>,
|
||||||
|
installErrors: Record<string, string>
|
||||||
|
): {
|
||||||
|
pluginInstallProgress: Record<string, ExtensionOperationState>;
|
||||||
|
installErrors: Record<string, string>;
|
||||||
|
} {
|
||||||
|
if (pluginIds.size === 0) {
|
||||||
|
return { pluginInstallProgress, installErrors };
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextPluginInstallProgress = { ...pluginInstallProgress };
|
||||||
|
const nextInstallErrors = { ...installErrors };
|
||||||
|
|
||||||
|
for (const pluginId of pluginIds) {
|
||||||
|
delete nextPluginInstallProgress[pluginId];
|
||||||
|
delete nextInstallErrors[pluginId];
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
pluginInstallProgress: nextPluginInstallProgress,
|
||||||
|
installErrors: nextInstallErrors,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearPluginSuccessResetTimer(pluginId: string): void {
|
||||||
|
const timer = pluginSuccessResetTimers.get(pluginId);
|
||||||
|
if (!timer) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
clearTimeout(timer);
|
||||||
|
pluginSuccessResetTimers.delete(pluginId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearPluginSuccessResetTimers(pluginIds: Set<string>): void {
|
||||||
|
for (const pluginId of pluginIds) {
|
||||||
|
clearPluginSuccessResetTimer(pluginId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function schedulePluginSuccessReset(
|
||||||
|
pluginId: string,
|
||||||
|
set: Parameters<StateCreator<AppState, [], [], ExtensionsSlice>>[0]
|
||||||
|
): void {
|
||||||
|
clearPluginSuccessResetTimer(pluginId);
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
pluginSuccessResetTimers.delete(pluginId);
|
||||||
|
set((prev) => {
|
||||||
|
if (prev.pluginInstallProgress[pluginId] !== 'success') {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
pluginInstallProgress: { ...prev.pluginInstallProgress, [pluginId]: 'idle' },
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}, SUCCESS_DISPLAY_MS);
|
||||||
|
pluginSuccessResetTimers.set(pluginId, timer);
|
||||||
|
}
|
||||||
|
|
||||||
function getSkillsCatalogKey(projectPath?: string): string {
|
function getSkillsCatalogKey(projectPath?: string): string {
|
||||||
return projectPath ?? USER_SKILLS_CATALOG_KEY;
|
return projectPath ?? USER_SKILLS_CATALOG_KEY;
|
||||||
}
|
}
|
||||||
|
|
@ -152,6 +224,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- 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,
|
||||||
|
|
@ -203,34 +277,80 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
|
||||||
// ── Plugin catalog fetch ──
|
// ── Plugin catalog fetch ──
|
||||||
fetchPluginCatalog: async (projectPath?: string, forceRefresh?: boolean) => {
|
fetchPluginCatalog: async (projectPath?: string, forceRefresh?: boolean) => {
|
||||||
if (!api.plugins) return;
|
if (!api.plugins) return;
|
||||||
|
const requestKey = getPluginCatalogKey(projectPath);
|
||||||
|
|
||||||
// Dedup concurrent requests
|
// Dedup concurrent requests
|
||||||
if (pluginFetchInFlight && !forceRefresh) {
|
if (pluginFetchInFlight && !forceRefresh && pluginFetchInFlight.key === requestKey) {
|
||||||
await pluginFetchInFlight;
|
await pluginFetchInFlight.promise;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const requestSeq = ++pluginCatalogRequestSeq;
|
||||||
set({ pluginCatalogLoading: true, pluginCatalogError: null });
|
set({ pluginCatalogLoading: true, pluginCatalogError: null });
|
||||||
|
|
||||||
const promise = (async () => {
|
const promise = (async () => {
|
||||||
try {
|
try {
|
||||||
const result = await api.plugins!.getAll(projectPath, forceRefresh);
|
const result = await api.plugins!.getAll(projectPath, forceRefresh);
|
||||||
set({
|
set((prev) => {
|
||||||
pluginCatalog: result,
|
if (requestSeq !== pluginCatalogRequestSeq) {
|
||||||
pluginCatalogLoading: false,
|
return {};
|
||||||
pluginCatalogProjectPath: projectPath ?? null,
|
}
|
||||||
|
|
||||||
|
const nextProjectPath = projectPath ?? null;
|
||||||
|
const isSameProjectContext = prev.pluginCatalogProjectPath === nextProjectPath;
|
||||||
|
const pluginIdsToClear = isSameProjectContext
|
||||||
|
? new Set<string>()
|
||||||
|
: new Set([...buildPluginIdSet(prev.pluginCatalog), ...buildPluginIdSet(result)]);
|
||||||
|
const nextOperationState = clearPluginOperationState(
|
||||||
|
pluginIdsToClear,
|
||||||
|
prev.pluginInstallProgress,
|
||||||
|
prev.installErrors
|
||||||
|
);
|
||||||
|
clearPluginSuccessResetTimers(pluginIdsToClear);
|
||||||
|
|
||||||
|
return {
|
||||||
|
pluginCatalog: result,
|
||||||
|
pluginCatalogLoading: false,
|
||||||
|
pluginCatalogError: null,
|
||||||
|
pluginCatalogProjectPath: nextProjectPath,
|
||||||
|
pluginInstallProgress: nextOperationState.pluginInstallProgress,
|
||||||
|
installErrors: nextOperationState.installErrors,
|
||||||
|
};
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
set({
|
set((prev) => {
|
||||||
pluginCatalogLoading: false,
|
if (requestSeq !== pluginCatalogRequestSeq) {
|
||||||
pluginCatalogError: err instanceof Error ? err.message : 'Failed to load plugins',
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextProjectPath = projectPath ?? null;
|
||||||
|
const isSameProjectContext = prev.pluginCatalogProjectPath === nextProjectPath;
|
||||||
|
const nextOperationState = clearPluginOperationState(
|
||||||
|
isSameProjectContext ? new Set<string>() : buildPluginIdSet(prev.pluginCatalog),
|
||||||
|
prev.pluginInstallProgress,
|
||||||
|
prev.installErrors
|
||||||
|
);
|
||||||
|
clearPluginSuccessResetTimers(
|
||||||
|
isSameProjectContext ? new Set<string>() : buildPluginIdSet(prev.pluginCatalog)
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
pluginCatalog: isSameProjectContext ? prev.pluginCatalog : [],
|
||||||
|
pluginCatalogLoading: false,
|
||||||
|
pluginCatalogError: err instanceof Error ? err.message : 'Failed to load plugins',
|
||||||
|
pluginCatalogProjectPath: nextProjectPath,
|
||||||
|
pluginInstallProgress: nextOperationState.pluginInstallProgress,
|
||||||
|
installErrors: nextOperationState.installErrors,
|
||||||
|
};
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
pluginFetchInFlight = null;
|
if (pluginFetchInFlight?.promise === promise) {
|
||||||
|
pluginFetchInFlight = null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
|
|
||||||
pluginFetchInFlight = promise;
|
pluginFetchInFlight = { key: requestKey, promise };
|
||||||
await promise;
|
await promise;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
@ -238,7 +358,13 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
|
||||||
fetchPluginReadme: (pluginId: string) => {
|
fetchPluginReadme: (pluginId: string) => {
|
||||||
if (!api.plugins) return;
|
if (!api.plugins) return;
|
||||||
const state = get();
|
const state = get();
|
||||||
if (pluginId in state.pluginReadmes || state.pluginReadmeLoading[pluginId]) return;
|
const cachedReadme = state.pluginReadmes[pluginId];
|
||||||
|
if (
|
||||||
|
(cachedReadme !== undefined && cachedReadme !== null) ||
|
||||||
|
state.pluginReadmeLoading[pluginId]
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
set((prev) => ({
|
set((prev) => ({
|
||||||
pluginReadmeLoading: { ...prev.pluginReadmeLoading, [pluginId]: true },
|
pluginReadmeLoading: { ...prev.pluginReadmeLoading, [pluginId]: true },
|
||||||
|
|
@ -561,6 +687,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 !== 'user'
|
||||||
|
? (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,17 +707,20 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
|
||||||
|
|
||||||
const cliStatus = get().cliStatus;
|
const cliStatus = get().cliStatus;
|
||||||
const preflightError =
|
const preflightError =
|
||||||
cliStatus === null
|
effectiveRequest.scope !== 'user' && !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) {
|
||||||
|
clearPluginSuccessResetTimer(request.pluginId);
|
||||||
set((prev) => ({
|
set((prev) => ({
|
||||||
pluginInstallProgress: { ...prev.pluginInstallProgress, [request.pluginId]: 'error' },
|
pluginInstallProgress: { ...prev.pluginInstallProgress, [request.pluginId]: 'error' },
|
||||||
installErrors: { ...prev.installErrors, [request.pluginId]: preflightError },
|
installErrors: { ...prev.installErrors, [request.pluginId]: preflightError },
|
||||||
|
|
@ -590,13 +728,14 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
clearPluginSuccessResetTimer(request.pluginId);
|
||||||
set((prev) => ({
|
set((prev) => ({
|
||||||
pluginInstallProgress: { ...prev.pluginInstallProgress, [request.pluginId]: 'pending' },
|
pluginInstallProgress: { ...prev.pluginInstallProgress, [request.pluginId]: 'pending' },
|
||||||
installErrors: { ...prev.installErrors, [request.pluginId]: '' },
|
installErrors: { ...prev.installErrors, [request.pluginId]: '' },
|
||||||
}));
|
}));
|
||||||
|
|
||||||
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' },
|
||||||
|
|
@ -615,13 +754,9 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
|
||||||
// Refresh catalog to pick up new installed state
|
// Refresh catalog to pick up new installed state
|
||||||
void get().fetchPluginCatalog(get().pluginCatalogProjectPath ?? undefined, true);
|
void get().fetchPluginCatalog(get().pluginCatalogProjectPath ?? undefined, true);
|
||||||
|
|
||||||
// Return to idle after brief success display
|
schedulePluginSuccessReset(request.pluginId, set);
|
||||||
setTimeout(() => {
|
|
||||||
set((prev) => ({
|
|
||||||
pluginInstallProgress: { ...prev.pluginInstallProgress, [request.pluginId]: 'idle' },
|
|
||||||
}));
|
|
||||||
}, SUCCESS_DISPLAY_MS);
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
clearPluginSuccessResetTimer(request.pluginId);
|
||||||
const message = err instanceof Error ? err.message : 'Install failed';
|
const message = err instanceof Error ? err.message : 'Install failed';
|
||||||
set((prev) => ({
|
set((prev) => ({
|
||||||
pluginInstallProgress: { ...prev.pluginInstallProgress, [request.pluginId]: 'error' },
|
pluginInstallProgress: { ...prev.pluginInstallProgress, [request.pluginId]: 'error' },
|
||||||
|
|
@ -634,12 +769,26 @@ 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 && scope !== 'user'
|
||||||
|
? (projectPath ?? get().pluginCatalogProjectPath ?? undefined)
|
||||||
|
: projectPath;
|
||||||
|
if (scope && scope !== 'user' && !effectiveProjectPath) {
|
||||||
|
clearPluginSuccessResetTimer(pluginId);
|
||||||
|
set((prev) => ({
|
||||||
|
pluginInstallProgress: { ...prev.pluginInstallProgress, [pluginId]: 'error' },
|
||||||
|
installErrors: { ...prev.installErrors, [pluginId]: PROJECT_SCOPE_REQUIRED_MESSAGE },
|
||||||
|
}));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
clearPluginSuccessResetTimer(pluginId);
|
||||||
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' },
|
||||||
|
|
@ -655,12 +804,9 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
|
||||||
// Refresh catalog
|
// Refresh catalog
|
||||||
void get().fetchPluginCatalog(get().pluginCatalogProjectPath ?? undefined, true);
|
void get().fetchPluginCatalog(get().pluginCatalogProjectPath ?? undefined, true);
|
||||||
|
|
||||||
setTimeout(() => {
|
schedulePluginSuccessReset(pluginId, set);
|
||||||
set((prev) => ({
|
|
||||||
pluginInstallProgress: { ...prev.pluginInstallProgress, [pluginId]: 'idle' },
|
|
||||||
}));
|
|
||||||
}, SUCCESS_DISPLAY_MS);
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
clearPluginSuccessResetTimer(pluginId);
|
||||||
const message = err instanceof Error ? err.message : 'Uninstall failed';
|
const message = err instanceof Error ? err.message : 'Uninstall failed';
|
||||||
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) ────────────────────────────────────────
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,13 @@
|
||||||
* Pure-function normalizers for Extension Store data.
|
* Pure-function normalizers for Extension Store data.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { PluginCapability, PluginCatalogItem } from '@shared/types/extensions';
|
import type {
|
||||||
|
CliInstallationStatus,
|
||||||
|
InstallScope,
|
||||||
|
InstalledPluginEntry,
|
||||||
|
PluginCapability,
|
||||||
|
PluginCatalogItem,
|
||||||
|
} from '@shared/types';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Normalize a repository URL for dedup comparison.
|
* Normalize a repository URL for dedup comparison.
|
||||||
|
|
@ -94,6 +100,71 @@ export function buildPluginId(pluginName: string, marketplaceName: string): stri
|
||||||
return `${pluginName}@${marketplaceName}`;
|
return `${pluginName}@${marketplaceName}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check whether a plugin has an installation for the selected scope.
|
||||||
|
*/
|
||||||
|
export function hasInstallationInScope(
|
||||||
|
installations: Pick<InstalledPluginEntry, 'scope'>[],
|
||||||
|
scope: InstallScope
|
||||||
|
): boolean {
|
||||||
|
return installations.some((installation) => installation.scope === scope);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a concise install-status label for plugin badges.
|
||||||
|
*/
|
||||||
|
export function getInstallationSummaryLabel(
|
||||||
|
installations: Pick<InstalledPluginEntry, 'scope'>[]
|
||||||
|
): string | null {
|
||||||
|
const scopes = Array.from(new Set(installations.map((installation) => installation.scope)));
|
||||||
|
if (scopes.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (scopes.length > 1) {
|
||||||
|
return `Installed in ${scopes.length} scopes`;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (scopes[0]) {
|
||||||
|
case 'user':
|
||||||
|
return 'Installed globally';
|
||||||
|
case 'project':
|
||||||
|
return 'Installed in project';
|
||||||
|
case 'local':
|
||||||
|
return 'Installed locally';
|
||||||
|
default:
|
||||||
|
return 'Installed';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Install actions require Claude auth, but uninstall only requires a working CLI.
|
||||||
|
*/
|
||||||
|
export function getExtensionActionDisableReason(options: {
|
||||||
|
isInstalled: boolean;
|
||||||
|
cliStatus: Pick<CliInstallationStatus, 'installed' | 'authLoggedIn'> | null;
|
||||||
|
cliStatusLoading: boolean;
|
||||||
|
}): string | null {
|
||||||
|
const { isInstalled, cliStatus, cliStatusLoading } = options;
|
||||||
|
if (cliStatusLoading) {
|
||||||
|
return 'Checking Claude CLI status...';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cliStatus === null) {
|
||||||
|
return 'Checking Claude CLI availability...';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cliStatus.installed === false) {
|
||||||
|
return 'Claude CLI required. Install it from the Dashboard.';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isInstalled && !cliStatus.authLoggedIn) {
|
||||||
|
return 'Claude CLI is installed but not signed in. Open the Dashboard to sign in.';
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sanitize an MCP server display name into a CLI-safe server name.
|
* Sanitize an MCP server display name into a CLI-safe server name.
|
||||||
* Must match the regex /^[\w.-]{1,100}$/ required by McpInstallService.
|
* Must match the regex /^[\w.-]{1,100}$/ required by McpInstallService.
|
||||||
|
|
|
||||||
|
|
@ -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'],
|
||||||
|
|
@ -121,6 +137,22 @@ 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();
|
||||||
|
});
|
||||||
|
|
||||||
|
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 ───────────────────────────────────────────────────────────────
|
||||||
|
|
@ -151,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'],
|
||||||
|
|
@ -171,5 +215,21 @@ 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();
|
||||||
|
});
|
||||||
|
|
||||||
|
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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
||||||
|
|
@ -125,6 +142,7 @@ describe('extensionsSlice', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
vi.restoreAllMocks();
|
vi.restoreAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -149,6 +167,121 @@ describe('extensionsSlice', () => {
|
||||||
expect(store.getState().pluginCatalogError).toBe('boom');
|
expect(store.getState().pluginCatalogError).toBe('boom');
|
||||||
expect(store.getState().pluginCatalogLoading).toBe(false);
|
expect(store.getState().pluginCatalogLoading).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('clears stale catalog when a different project fetch fails', async () => {
|
||||||
|
store.setState({
|
||||||
|
pluginCatalog: [makePlugin({ pluginId: 'project-a@m' })],
|
||||||
|
pluginCatalogProjectPath: '/tmp/project-a',
|
||||||
|
});
|
||||||
|
(api.plugins!.getAll as ReturnType<typeof vi.fn>).mockRejectedValue(new Error('boom'));
|
||||||
|
|
||||||
|
await store.getState().fetchPluginCatalog('/tmp/project-b');
|
||||||
|
|
||||||
|
expect(store.getState().pluginCatalog).toEqual([]);
|
||||||
|
expect(store.getState().pluginCatalogProjectPath).toBe('/tmp/project-b');
|
||||||
|
expect(store.getState().pluginCatalogError).toBe('boom');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clears plugin operation state when switching project context', async () => {
|
||||||
|
store.setState({
|
||||||
|
pluginCatalog: [makePlugin({ pluginId: 'project-a@m' })],
|
||||||
|
pluginCatalogProjectPath: '/tmp/project-a',
|
||||||
|
pluginInstallProgress: {
|
||||||
|
'project-a@m': 'error',
|
||||||
|
},
|
||||||
|
installErrors: {
|
||||||
|
'project-a@m': 'Install failed',
|
||||||
|
'mcp-server': 'Keep me',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
(api.plugins!.getAll as ReturnType<typeof vi.fn>).mockResolvedValue([
|
||||||
|
makePlugin({ pluginId: 'project-b@m' }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
await store.getState().fetchPluginCatalog('/tmp/project-b');
|
||||||
|
|
||||||
|
expect(store.getState().pluginCatalogProjectPath).toBe('/tmp/project-b');
|
||||||
|
expect(store.getState().pluginInstallProgress['project-a@m']).toBeUndefined();
|
||||||
|
expect(store.getState().installErrors['project-a@m']).toBeUndefined();
|
||||||
|
expect(store.getState().installErrors['mcp-server']).toBe('Keep me');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('dedups concurrent requests for the same project key', async () => {
|
||||||
|
let resolveFetch!: (plugins: EnrichedPlugin[]) => void;
|
||||||
|
const inFlight = new Promise<EnrichedPlugin[]>((resolve) => {
|
||||||
|
resolveFetch = resolve;
|
||||||
|
});
|
||||||
|
(api.plugins!.getAll as ReturnType<typeof vi.fn>).mockImplementation(() => inFlight);
|
||||||
|
|
||||||
|
const firstFetch = store.getState().fetchPluginCatalog('/tmp/project-a');
|
||||||
|
const secondFetch = store.getState().fetchPluginCatalog('/tmp/project-a');
|
||||||
|
|
||||||
|
expect(api.plugins!.getAll).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
resolveFetch([makePlugin({ pluginId: 'same@m' })]);
|
||||||
|
await Promise.all([firstFetch, secondFetch]);
|
||||||
|
|
||||||
|
expect(store.getState().pluginCatalogProjectPath).toBe('/tmp/project-a');
|
||||||
|
expect(store.getState().pluginCatalog.map((plugin) => plugin.pluginId)).toEqual(['same@m']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the newest project catalog when project changes mid-flight', async () => {
|
||||||
|
let resolveProjectA!: (plugins: EnrichedPlugin[]) => void;
|
||||||
|
let resolveProjectB!: (plugins: EnrichedPlugin[]) => void;
|
||||||
|
const projectAFetch = new Promise<EnrichedPlugin[]>((resolve) => {
|
||||||
|
resolveProjectA = resolve;
|
||||||
|
});
|
||||||
|
const projectBFetch = new Promise<EnrichedPlugin[]>((resolve) => {
|
||||||
|
resolveProjectB = resolve;
|
||||||
|
});
|
||||||
|
|
||||||
|
(api.plugins!.getAll as ReturnType<typeof vi.fn>)
|
||||||
|
.mockImplementationOnce(() => projectAFetch)
|
||||||
|
.mockImplementationOnce(() => projectBFetch);
|
||||||
|
|
||||||
|
const firstFetch = store.getState().fetchPluginCatalog('/tmp/project-a');
|
||||||
|
const secondFetch = store.getState().fetchPluginCatalog('/tmp/project-b');
|
||||||
|
|
||||||
|
expect(api.plugins!.getAll).toHaveBeenCalledTimes(2);
|
||||||
|
|
||||||
|
resolveProjectB([makePlugin({ pluginId: 'project-b@m' })]);
|
||||||
|
await secondFetch;
|
||||||
|
|
||||||
|
expect(store.getState().pluginCatalogProjectPath).toBe('/tmp/project-b');
|
||||||
|
expect(store.getState().pluginCatalog.map((plugin) => plugin.pluginId)).toEqual([
|
||||||
|
'project-b@m',
|
||||||
|
]);
|
||||||
|
|
||||||
|
resolveProjectA([makePlugin({ pluginId: 'project-a@m' })]);
|
||||||
|
await firstFetch;
|
||||||
|
|
||||||
|
expect(store.getState().pluginCatalogProjectPath).toBe('/tmp/project-b');
|
||||||
|
expect(store.getState().pluginCatalog.map((plugin) => plugin.pluginId)).toEqual([
|
||||||
|
'project-b@m',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clears plugin operation state when a different project fetch fails', async () => {
|
||||||
|
store.setState({
|
||||||
|
pluginCatalog: [makePlugin({ pluginId: 'project-a@m' })],
|
||||||
|
pluginCatalogProjectPath: '/tmp/project-a',
|
||||||
|
pluginInstallProgress: {
|
||||||
|
'project-a@m': 'error',
|
||||||
|
},
|
||||||
|
installErrors: {
|
||||||
|
'project-a@m': 'Install failed',
|
||||||
|
'mcp-server': 'Keep me',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
(api.plugins!.getAll as ReturnType<typeof vi.fn>).mockRejectedValue(new Error('boom'));
|
||||||
|
|
||||||
|
await store.getState().fetchPluginCatalog('/tmp/project-b');
|
||||||
|
|
||||||
|
expect(store.getState().pluginCatalog).toEqual([]);
|
||||||
|
expect(store.getState().pluginInstallProgress['project-a@m']).toBeUndefined();
|
||||||
|
expect(store.getState().installErrors['project-a@m']).toBeUndefined();
|
||||||
|
expect(store.getState().installErrors['mcp-server']).toBe('Keep me');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('fetchPluginReadme', () => {
|
describe('fetchPluginReadme', () => {
|
||||||
|
|
@ -171,6 +304,15 @@ describe('extensionsSlice', () => {
|
||||||
|
|
||||||
expect(api.plugins!.getReadme).not.toHaveBeenCalled();
|
expect(api.plugins!.getReadme).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('retries README fetch when the cached value is null', () => {
|
||||||
|
store.setState({ pluginReadmes: { 'test@m': null } });
|
||||||
|
(api.plugins!.getReadme as ReturnType<typeof vi.fn>).mockResolvedValue(null);
|
||||||
|
|
||||||
|
store.getState().fetchPluginReadme('test@m');
|
||||||
|
|
||||||
|
expect(api.plugins!.getReadme).toHaveBeenCalledWith('test@m');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('mcpBrowse', () => {
|
describe('mcpBrowse', () => {
|
||||||
|
|
@ -298,24 +440,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 +455,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 +465,77 @@ 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');
|
||||||
|
});
|
||||||
|
|
||||||
|
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 () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
store.setState({ cliStatus: makeReadyCliStatus() });
|
||||||
|
(api.plugins!.getAll as ReturnType<typeof vi.fn>).mockResolvedValue([]);
|
||||||
|
(api.plugins!.install as ReturnType<typeof vi.fn>)
|
||||||
|
.mockResolvedValueOnce({ state: 'success' })
|
||||||
|
.mockResolvedValueOnce({ state: 'error', error: 'second failure' });
|
||||||
|
|
||||||
|
await store.getState().installPlugin({ pluginId: 'test@m', scope: 'user' });
|
||||||
|
expect(store.getState().pluginInstallProgress['test@m']).toBe('success');
|
||||||
|
|
||||||
|
await store.getState().installPlugin({ pluginId: 'test@m', scope: 'user' });
|
||||||
|
expect(store.getState().pluginInstallProgress['test@m']).toBe('error');
|
||||||
|
|
||||||
|
await vi.advanceTimersByTimeAsync(2_000);
|
||||||
|
|
||||||
|
expect(store.getState().pluginInstallProgress['test@m']).toBe('error');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('uninstallPlugin', () => {
|
describe('uninstallPlugin', () => {
|
||||||
|
|
@ -372,6 +551,66 @@ 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');
|
||||||
|
});
|
||||||
|
|
||||||
|
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 () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
store.setState({
|
||||||
|
pluginCatalogProjectPath: '/tmp/project-a',
|
||||||
|
pluginCatalog: [makePlugin({ pluginId: 'test@m' })],
|
||||||
|
});
|
||||||
|
(api.plugins!.getAll as ReturnType<typeof vi.fn>)
|
||||||
|
.mockResolvedValueOnce([makePlugin({ pluginId: 'test@m' })])
|
||||||
|
.mockResolvedValueOnce([makePlugin({ pluginId: 'other@m' })]);
|
||||||
|
(api.plugins!.uninstall as ReturnType<typeof vi.fn>).mockResolvedValue({ state: 'success' });
|
||||||
|
|
||||||
|
await store.getState().uninstallPlugin('test@m', 'user');
|
||||||
|
expect(store.getState().pluginInstallProgress['test@m']).toBe('success');
|
||||||
|
|
||||||
|
await store.getState().fetchPluginCatalog('/tmp/project-b');
|
||||||
|
expect(store.getState().pluginInstallProgress['test@m']).toBeUndefined();
|
||||||
|
|
||||||
|
await vi.advanceTimersByTimeAsync(2_000);
|
||||||
|
|
||||||
|
expect(store.getState().pluginInstallProgress['test@m']).toBeUndefined();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('installMcpServer', () => {
|
describe('installMcpServer', () => {
|
||||||
|
|
|
||||||
|
|
@ -5,8 +5,11 @@ import type { PluginCatalogItem } from '@shared/types/extensions';
|
||||||
import {
|
import {
|
||||||
buildPluginId,
|
buildPluginId,
|
||||||
formatInstallCount,
|
formatInstallCount,
|
||||||
|
getExtensionActionDisableReason,
|
||||||
getCapabilityLabel,
|
getCapabilityLabel,
|
||||||
|
getInstallationSummaryLabel,
|
||||||
getPrimaryCapabilityLabel,
|
getPrimaryCapabilityLabel,
|
||||||
|
hasInstallationInScope,
|
||||||
inferCapabilities,
|
inferCapabilities,
|
||||||
normalizeCategory,
|
normalizeCategory,
|
||||||
normalizeRepoUrl,
|
normalizeRepoUrl,
|
||||||
|
|
@ -149,6 +152,79 @@ describe('buildPluginId', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('hasInstallationInScope', () => {
|
||||||
|
it('returns true when the selected scope exists', () => {
|
||||||
|
expect(
|
||||||
|
hasInstallationInScope(
|
||||||
|
[
|
||||||
|
{ scope: 'user' },
|
||||||
|
{ scope: 'project' },
|
||||||
|
],
|
||||||
|
'project',
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false when the selected scope is missing', () => {
|
||||||
|
expect(hasInstallationInScope([{ scope: 'user' }], 'project')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getInstallationSummaryLabel', () => {
|
||||||
|
it('returns null when there are no installations', () => {
|
||||||
|
expect(getInstallationSummaryLabel([])).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('describes a single global installation', () => {
|
||||||
|
expect(getInstallationSummaryLabel([{ scope: 'user' }])).toBe('Installed globally');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('describes a single project installation', () => {
|
||||||
|
expect(getInstallationSummaryLabel([{ scope: 'project' }])).toBe('Installed in project');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('summarizes multiple scopes without pretending they are global', () => {
|
||||||
|
expect(
|
||||||
|
getInstallationSummaryLabel([
|
||||||
|
{ scope: 'project' },
|
||||||
|
{ scope: 'user' },
|
||||||
|
]),
|
||||||
|
).toBe('Installed in 2 scopes');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getExtensionActionDisableReason', () => {
|
||||||
|
it('requires auth only for install actions', () => {
|
||||||
|
expect(
|
||||||
|
getExtensionActionDisableReason({
|
||||||
|
isInstalled: false,
|
||||||
|
cliStatus: { installed: true, authLoggedIn: false },
|
||||||
|
cliStatusLoading: false,
|
||||||
|
}),
|
||||||
|
).toContain('not signed in');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows uninstall when CLI is present but auth is missing', () => {
|
||||||
|
expect(
|
||||||
|
getExtensionActionDisableReason({
|
||||||
|
isInstalled: true,
|
||||||
|
cliStatus: { installed: true, authLoggedIn: false },
|
||||||
|
cliStatusLoading: false,
|
||||||
|
}),
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('still blocks actions when the CLI is missing', () => {
|
||||||
|
expect(
|
||||||
|
getExtensionActionDisableReason({
|
||||||
|
isInstalled: true,
|
||||||
|
cliStatus: { installed: false, authLoggedIn: false },
|
||||||
|
cliStatusLoading: false,
|
||||||
|
}),
|
||||||
|
).toContain('Claude CLI required');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('sanitizeMcpServerName', () => {
|
describe('sanitizeMcpServerName', () => {
|
||||||
it('lowercases and replaces spaces with dashes', () => {
|
it('lowercases and replaces spaces with dashes', () => {
|
||||||
expect(sanitizeMcpServerName('My Server')).toBe('my-server');
|
expect(sanitizeMcpServerName('My Server')).toBe('my-server');
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue