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 UNINSTALL_TIMEOUT_MS = 30_000;
|
||||
|
||||
function scopeRequiresProjectPath(scope?: string): boolean {
|
||||
return scope === 'project' || scope === 'local';
|
||||
}
|
||||
|
||||
export class PluginInstallService {
|
||||
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)
|
||||
const resolved = await this.catalogService.resolvePlugin(pluginId);
|
||||
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
|
||||
const resolved = await this.catalogService.resolvePlugin(pluginId);
|
||||
if (!resolved) {
|
||||
|
|
|
|||
|
|
@ -60,23 +60,26 @@ interface TimedCache<T> {
|
|||
// ── Service ────────────────────────────────────────────────────────────────
|
||||
|
||||
export class PluginInstallationStateService {
|
||||
private installedCache: TimedCache<InstalledPluginEntry[]> | null = null;
|
||||
private installedCache = new Map<string, TimedCache<InstalledPluginEntry[]>>();
|
||||
private countsCache: TimedCache<Map<string, number>> | null = null;
|
||||
|
||||
/**
|
||||
* Get all installed plugins across all scopes.
|
||||
* Returns merged list from installed_plugins.json with scope tags.
|
||||
* Get installed plugins relevant to the active context.
|
||||
* Always includes user scope. Project/local entries are only included when
|
||||
* they are enabled for the active project.
|
||||
*/
|
||||
async getInstalledPlugins(_projectPath?: string): Promise<InstalledPluginEntry[]> {
|
||||
if (
|
||||
this.installedCache &&
|
||||
Date.now() - this.installedCache.fetchedAt < INSTALLED_STATE_TTL_MS
|
||||
) {
|
||||
return this.installedCache.data;
|
||||
async getInstalledPlugins(projectPath?: string): Promise<InstalledPluginEntry[]> {
|
||||
const normalizedProjectPath =
|
||||
typeof projectPath === 'string' && path.isAbsolute(projectPath) ? projectPath : undefined;
|
||||
const cacheKey = this.getInstalledCacheKey(normalizedProjectPath);
|
||||
const cached = this.installedCache.get(cacheKey);
|
||||
|
||||
if (cached && Date.now() - cached.fetchedAt < INSTALLED_STATE_TTL_MS) {
|
||||
return cached.data;
|
||||
}
|
||||
|
||||
const entries = await this.readInstalledPlugins();
|
||||
this.installedCache = { data: entries, fetchedAt: Date.now() };
|
||||
const entries = await this.buildInstalledEntriesForContext(normalizedProjectPath);
|
||||
this.installedCache.set(cacheKey, { data: entries, fetchedAt: Date.now() });
|
||||
return entries;
|
||||
}
|
||||
|
||||
|
|
@ -97,7 +100,7 @@ export class PluginInstallationStateService {
|
|||
* Invalidate all caches. Call after install/uninstall operations.
|
||||
*/
|
||||
invalidateCache(): void {
|
||||
this.installedCache = null;
|
||||
this.installedCache.clear();
|
||||
this.countsCache = null;
|
||||
}
|
||||
|
||||
|
|
@ -107,7 +110,81 @@ export class PluginInstallationStateService {
|
|||
return path.join(getClaudeBasePath(), 'plugins');
|
||||
}
|
||||
|
||||
private async readInstalledPlugins(): Promise<InstalledPluginEntry[]> {
|
||||
private getInstalledCacheKey(projectPath?: string): string {
|
||||
return projectPath ?? '__user__';
|
||||
}
|
||||
|
||||
private async buildInstalledEntriesForContext(
|
||||
projectPath?: string
|
||||
): Promise<InstalledPluginEntry[]> {
|
||||
const installedMetadata = await this.readInstalledPluginMetadata();
|
||||
const metadataByKey = new Map<string, InstalledPluginEntry[]>();
|
||||
|
||||
for (const entry of installedMetadata) {
|
||||
const key = this.getPluginScopeKey(entry.pluginId, entry.scope);
|
||||
const matches = metadataByKey.get(key) ?? [];
|
||||
matches.push(entry);
|
||||
metadataByKey.set(key, matches);
|
||||
}
|
||||
|
||||
const userEnabled = await this.readEnabledPlugins(
|
||||
path.join(getClaudeBasePath(), 'settings.json')
|
||||
);
|
||||
const projectEnabled = projectPath
|
||||
? await this.readEnabledPlugins(path.join(projectPath, '.claude', 'settings.json'))
|
||||
: new Set<string>();
|
||||
const localEnabled = projectPath
|
||||
? await this.readEnabledPlugins(path.join(projectPath, '.claude', 'settings.local.json'))
|
||||
: new Set<string>();
|
||||
|
||||
return [
|
||||
...this.buildScopedEntries('user', userEnabled, metadataByKey),
|
||||
...this.buildScopedEntries('project', projectEnabled, metadataByKey),
|
||||
...this.buildScopedEntries('local', localEnabled, metadataByKey),
|
||||
];
|
||||
}
|
||||
|
||||
private buildScopedEntries(
|
||||
scope: InstallScope,
|
||||
enabledPlugins: Set<string>,
|
||||
metadataByKey: Map<string, InstalledPluginEntry[]>
|
||||
): InstalledPluginEntry[] {
|
||||
return Array.from(enabledPlugins).map((pluginId) => {
|
||||
const key = this.getPluginScopeKey(pluginId, scope);
|
||||
const bestMatch = this.pickBestInstallationEntry(metadataByKey.get(key) ?? []);
|
||||
|
||||
return bestMatch
|
||||
? {
|
||||
...bestMatch,
|
||||
pluginId,
|
||||
scope,
|
||||
}
|
||||
: {
|
||||
pluginId,
|
||||
scope,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
private getPluginScopeKey(pluginId: string, scope: InstallScope): string {
|
||||
return `${pluginId}::${scope}`;
|
||||
}
|
||||
|
||||
private pickBestInstallationEntry(entries: InstalledPluginEntry[]): InstalledPluginEntry | null {
|
||||
if (entries.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [...entries].sort((left, right) => {
|
||||
const leftInstalledAt = Date.parse(left.installedAt ?? '');
|
||||
const rightInstalledAt = Date.parse(right.installedAt ?? '');
|
||||
const normalizedLeft = Number.isFinite(leftInstalledAt) ? leftInstalledAt : 0;
|
||||
const normalizedRight = Number.isFinite(rightInstalledAt) ? rightInstalledAt : 0;
|
||||
return normalizedRight - normalizedLeft;
|
||||
})[0];
|
||||
}
|
||||
|
||||
private async readInstalledPluginMetadata(): Promise<InstalledPluginEntry[]> {
|
||||
const filePath = path.join(this.getPluginsDir(), 'installed_plugins.json');
|
||||
|
||||
try {
|
||||
|
|
@ -143,6 +220,31 @@ export class PluginInstallationStateService {
|
|||
}
|
||||
}
|
||||
|
||||
private async readEnabledPlugins(filePath: string): Promise<Set<string>> {
|
||||
try {
|
||||
const raw = await fs.readFile(filePath, 'utf-8');
|
||||
const json = JSON.parse(raw) as {
|
||||
enabledPlugins?: Record<string, boolean> | null;
|
||||
};
|
||||
|
||||
if (!json.enabledPlugins || typeof json.enabledPlugins !== 'object') {
|
||||
return new Set<string>();
|
||||
}
|
||||
|
||||
return new Set(
|
||||
Object.entries(json.enabledPlugins)
|
||||
.filter(([, enabled]) => enabled === true)
|
||||
.map(([pluginId]) => pluginId)
|
||||
);
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
return new Set<string>();
|
||||
}
|
||||
logger.error(`Failed to read plugin settings from ${filePath}:`, err);
|
||||
return new Set<string>();
|
||||
}
|
||||
}
|
||||
|
||||
private async readInstallCounts(): Promise<Map<string, number>> {
|
||||
const filePath = path.join(this.getPluginsDir(), 'install-counts-cache.json');
|
||||
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@ export const ExtensionStoreView = (): React.JSX.Element => {
|
|||
fetchSkillsCatalog,
|
||||
mcpBrowse,
|
||||
mcpFetchInstalled,
|
||||
apiKeysLoading,
|
||||
pluginCatalogLoading,
|
||||
mcpBrowseLoading,
|
||||
skillsLoading,
|
||||
|
|
@ -55,6 +56,7 @@ export const ExtensionStoreView = (): React.JSX.Element => {
|
|||
fetchSkillsCatalog: s.fetchSkillsCatalog,
|
||||
mcpBrowse: s.mcpBrowse,
|
||||
mcpFetchInstalled: s.mcpFetchInstalled,
|
||||
apiKeysLoading: s.apiKeysLoading,
|
||||
pluginCatalogLoading: s.pluginCatalogLoading,
|
||||
mcpBrowseLoading: s.mcpBrowseLoading,
|
||||
skillsLoading: s.skillsLoading,
|
||||
|
|
@ -143,13 +145,24 @@ export const ExtensionStoreView = (): React.JSX.Element => {
|
|||
|
||||
// Refresh all data (plugins + MCP browse + installed + skills)
|
||||
const handleRefresh = useCallback(() => {
|
||||
void fetchCliStatus();
|
||||
void fetchApiKeys();
|
||||
void fetchPluginCatalog(projectPath ?? undefined, true);
|
||||
void mcpBrowse(); // re-fetch first page
|
||||
void mcpFetchInstalled(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(() => {
|
||||
if (cliStatusLoading || cliStatus === null) {
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import {
|
|||
TooltipTrigger,
|
||||
} from '@renderer/components/ui/tooltip';
|
||||
import { useStore } from '@renderer/store';
|
||||
import { getExtensionActionDisableReason } from '@shared/utils/extensionNormalizers';
|
||||
import { Check, Loader2, Trash2 } from 'lucide-react';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
|
||||
|
|
@ -43,18 +44,11 @@ export const InstallButton = ({
|
|||
cliStatusLoading: s.cliStatusLoading,
|
||||
}))
|
||||
);
|
||||
const cliUnknown = cliStatus === null;
|
||||
const cliMissing = cliStatus?.installed === false;
|
||||
const authMissing = cliStatus?.installed === true && !cliStatus.authLoggedIn;
|
||||
const disableReason = 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 disableReason = getExtensionActionDisableReason({
|
||||
isInstalled,
|
||||
cliStatus,
|
||||
cliStatusLoading,
|
||||
});
|
||||
const isDisabled = disabled || Boolean(disableReason);
|
||||
const [lastAction, setLastAction] = useState<'install' | 'uninstall' | null>(null);
|
||||
|
||||
|
|
|
|||
|
|
@ -5,7 +5,9 @@
|
|||
import { Badge } from '@renderer/components/ui/badge';
|
||||
import { useStore } from '@renderer/store';
|
||||
import {
|
||||
getInstallationSummaryLabel,
|
||||
getCapabilityLabel,
|
||||
hasInstallationInScope,
|
||||
inferCapabilities,
|
||||
normalizeCategory,
|
||||
} from '@shared/utils/extensionNormalizers';
|
||||
|
|
@ -29,6 +31,8 @@ export const PluginCard = ({ plugin, index, onClick }: PluginCardProps): React.J
|
|||
const installPlugin = useStore((s) => s.installPlugin);
|
||||
const uninstallPlugin = useStore((s) => s.uninstallPlugin);
|
||||
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 smStriped = Math.floor(index / 2) % 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 className="flex shrink-0 items-center gap-2">
|
||||
<InstallCountBadge count={plugin.installCount} />
|
||||
{plugin.isInstalled && (
|
||||
{installSummaryLabel && (
|
||||
<Badge
|
||||
className="shrink-0 border-emerald-500/30 bg-emerald-500/10 text-emerald-400"
|
||||
variant="outline"
|
||||
>
|
||||
Installed
|
||||
{installSummaryLabel}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -112,9 +116,9 @@ export const PluginCard = ({ plugin, index, onClick }: PluginCardProps): React.J
|
|||
<div className="shrink-0" onClick={(e) => e.stopPropagation()}>
|
||||
<InstallButton
|
||||
state={installProgress}
|
||||
isInstalled={plugin.isInstalled}
|
||||
isInstalled={isUserInstalled}
|
||||
onInstall={() => installPlugin({ pluginId: plugin.pluginId, scope: 'user' })}
|
||||
onUninstall={() => uninstallPlugin(plugin.pluginId)}
|
||||
onUninstall={() => uninstallPlugin(plugin.pluginId, 'user')}
|
||||
size="sm"
|
||||
errorMessage={installError}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -25,7 +25,9 @@ import {
|
|||
} from '@renderer/components/ui/select';
|
||||
import { useStore } from '@renderer/store';
|
||||
import {
|
||||
getInstallationSummaryLabel,
|
||||
getCapabilityLabel,
|
||||
hasInstallationInScope,
|
||||
inferCapabilities,
|
||||
normalizeCategory,
|
||||
} from '@shared/utils/extensionNormalizers';
|
||||
|
|
@ -46,7 +48,8 @@ interface PluginDetailDialogProps {
|
|||
|
||||
const SCOPE_OPTIONS: { value: InstallScope; label: string }[] = [
|
||||
{ value: 'user', label: 'User (global)' },
|
||||
{ value: 'project', label: 'Project' },
|
||||
{ value: 'project', label: 'Project (shared)' },
|
||||
{ value: 'local', label: 'Local (gitignored)' },
|
||||
];
|
||||
|
||||
export const PluginDetailDialog = ({
|
||||
|
|
@ -54,13 +57,21 @@ export const PluginDetailDialog = ({
|
|||
open,
|
||||
onClose,
|
||||
}: PluginDetailDialogProps): React.JSX.Element => {
|
||||
const { fetchPluginReadme, readmes, readmeLoading, installPlugin, uninstallPlugin } = useStore(
|
||||
const {
|
||||
fetchPluginReadme,
|
||||
readmes,
|
||||
readmeLoading,
|
||||
installPlugin,
|
||||
uninstallPlugin,
|
||||
pluginCatalogProjectPath,
|
||||
} = useStore(
|
||||
useShallow((s) => ({
|
||||
fetchPluginReadme: s.fetchPluginReadme,
|
||||
readmes: s.pluginReadmes,
|
||||
readmeLoading: s.pluginReadmeLoading,
|
||||
installPlugin: s.installPlugin,
|
||||
uninstallPlugin: s.uninstallPlugin,
|
||||
pluginCatalogProjectPath: s.pluginCatalogProjectPath,
|
||||
}))
|
||||
);
|
||||
const installProgress = useStore(
|
||||
|
|
@ -69,6 +80,7 @@ export const PluginDetailDialog = ({
|
|||
const installError = useStore((s) => (plugin ? s.installErrors[plugin.pluginId] : undefined));
|
||||
|
||||
const [scope, setScope] = useState<InstallScope>('user');
|
||||
const projectScopeAvailable = Boolean(pluginCatalogProjectPath);
|
||||
|
||||
useEffect(() => {
|
||||
if (plugin && open) {
|
||||
|
|
@ -76,12 +88,26 @@ export const PluginDetailDialog = ({
|
|||
}
|
||||
}, [plugin, open, fetchPluginReadme]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setScope('user');
|
||||
}
|
||||
}, [open, plugin?.pluginId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (scope !== 'user' && !projectScopeAvailable) {
|
||||
setScope('user');
|
||||
}
|
||||
}, [projectScopeAvailable, scope]);
|
||||
|
||||
if (!plugin) return <></>;
|
||||
|
||||
const capabilities = inferCapabilities(plugin);
|
||||
const category = normalizeCategory(plugin.category);
|
||||
const readme = readmes[plugin.pluginId];
|
||||
const isReadmeLoading = readmeLoading[plugin.pluginId] ?? false;
|
||||
const isInstalledForScope = hasInstallationInScope(plugin.installations, scope);
|
||||
const installSummaryLabel = getInstallationSummaryLabel(plugin.installations);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
|
||||
|
|
@ -93,12 +119,12 @@ export const PluginDetailDialog = ({
|
|||
<DialogDescription className="mt-1">{plugin.description}</DialogDescription>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1.5">
|
||||
{plugin.isInstalled && (
|
||||
{installSummaryLabel && (
|
||||
<Badge
|
||||
className="shrink-0 border-emerald-500/30 bg-emerald-500/10 text-emerald-400"
|
||||
variant="outline"
|
||||
>
|
||||
Installed
|
||||
{installSummaryLabel}
|
||||
</Badge>
|
||||
)}
|
||||
<SourceBadge source={plugin.source} />
|
||||
|
|
@ -158,7 +184,11 @@ export const PluginDetailDialog = ({
|
|||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{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}
|
||||
</SelectItem>
|
||||
))}
|
||||
|
|
@ -167,9 +197,23 @@ export const PluginDetailDialog = ({
|
|||
</div>
|
||||
<InstallButton
|
||||
state={installProgress}
|
||||
isInstalled={plugin.isInstalled}
|
||||
onInstall={() => installPlugin({ pluginId: plugin.pluginId, scope })}
|
||||
onUninstall={() => uninstallPlugin(plugin.pluginId, scope)}
|
||||
isInstalled={isInstalledForScope}
|
||||
onInstall={() =>
|
||||
installPlugin({
|
||||
pluginId: plugin.pluginId,
|
||||
scope,
|
||||
...(scope !== 'user' && pluginCatalogProjectPath
|
||||
? { projectPath: pluginCatalogProjectPath }
|
||||
: {}),
|
||||
})
|
||||
}
|
||||
onUninstall={() =>
|
||||
uninstallPlugin(
|
||||
plugin.pluginId,
|
||||
scope,
|
||||
scope !== 'user' ? (pluginCatalogProjectPath ?? undefined) : undefined
|
||||
)
|
||||
}
|
||||
size="default"
|
||||
errorMessage={installError}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
* 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 { Button } from '@renderer/components/ui/button';
|
||||
|
|
@ -142,6 +142,18 @@ export const PluginsPanel = ({
|
|||
[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 activeFilterCount =
|
||||
pluginFilters.categories.length +
|
||||
|
|
|
|||
|
|
@ -127,7 +127,9 @@ export interface ExtensionsSlice {
|
|||
// 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 skillsCatalogRequestSeq = 0;
|
||||
let skillsDetailRequestSeq = 0;
|
||||
|
|
@ -140,6 +142,76 @@ function hasAnyLoading(loadingMap: Record<string, boolean>): 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 {
|
||||
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.';
|
||||
const CLI_STATUS_UNKNOWN_MESSAGE =
|
||||
'Unable to verify Claude CLI status. Open the Dashboard and check the CLI before retrying.';
|
||||
const PROJECT_SCOPE_REQUIRED_MESSAGE =
|
||||
'Project- and local-scoped plugins require an active project in the Extensions tab.';
|
||||
|
||||
export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSlice> = (
|
||||
set,
|
||||
|
|
@ -203,34 +277,80 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
|
|||
// ── Plugin catalog fetch ──
|
||||
fetchPluginCatalog: async (projectPath?: string, forceRefresh?: boolean) => {
|
||||
if (!api.plugins) return;
|
||||
const requestKey = getPluginCatalogKey(projectPath);
|
||||
|
||||
// Dedup concurrent requests
|
||||
if (pluginFetchInFlight && !forceRefresh) {
|
||||
await pluginFetchInFlight;
|
||||
if (pluginFetchInFlight && !forceRefresh && pluginFetchInFlight.key === requestKey) {
|
||||
await pluginFetchInFlight.promise;
|
||||
return;
|
||||
}
|
||||
|
||||
const requestSeq = ++pluginCatalogRequestSeq;
|
||||
set({ pluginCatalogLoading: true, pluginCatalogError: null });
|
||||
|
||||
const promise = (async () => {
|
||||
try {
|
||||
const result = await api.plugins!.getAll(projectPath, forceRefresh);
|
||||
set({
|
||||
pluginCatalog: result,
|
||||
pluginCatalogLoading: false,
|
||||
pluginCatalogProjectPath: projectPath ?? null,
|
||||
set((prev) => {
|
||||
if (requestSeq !== pluginCatalogRequestSeq) {
|
||||
return {};
|
||||
}
|
||||
|
||||
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) {
|
||||
set({
|
||||
pluginCatalogLoading: false,
|
||||
pluginCatalogError: err instanceof Error ? err.message : 'Failed to load plugins',
|
||||
set((prev) => {
|
||||
if (requestSeq !== pluginCatalogRequestSeq) {
|
||||
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 {
|
||||
pluginFetchInFlight = null;
|
||||
if (pluginFetchInFlight?.promise === promise) {
|
||||
pluginFetchInFlight = null;
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
pluginFetchInFlight = promise;
|
||||
pluginFetchInFlight = { key: requestKey, promise };
|
||||
await promise;
|
||||
},
|
||||
|
||||
|
|
@ -238,7 +358,13 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
|
|||
fetchPluginReadme: (pluginId: string) => {
|
||||
if (!api.plugins) return;
|
||||
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) => ({
|
||||
pluginReadmeLoading: { ...prev.pluginReadmeLoading, [pluginId]: true },
|
||||
|
|
@ -561,6 +687,15 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
|
|||
installPlugin: async (request: PluginInstallRequest) => {
|
||||
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();
|
||||
if (preflightState.cliStatus === null || preflightState.cliStatusLoading) {
|
||||
try {
|
||||
|
|
@ -572,17 +707,20 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
|
|||
|
||||
const cliStatus = get().cliStatus;
|
||||
const preflightError =
|
||||
cliStatus === null
|
||||
? CLI_STATUS_UNKNOWN_MESSAGE
|
||||
: !cliStatus.installed
|
||||
? cliStatus.binaryPath && cliStatus.launchError
|
||||
? CLI_HEALTHCHECK_FAILED_MESSAGE
|
||||
: CLI_NOT_FOUND_MESSAGE
|
||||
: !cliStatus.authLoggedIn
|
||||
? CLI_AUTH_REQUIRED_MESSAGE
|
||||
: null;
|
||||
effectiveRequest.scope !== 'user' && !effectiveRequest.projectPath
|
||||
? PROJECT_SCOPE_REQUIRED_MESSAGE
|
||||
: cliStatus === null
|
||||
? CLI_STATUS_UNKNOWN_MESSAGE
|
||||
: !cliStatus.installed
|
||||
? cliStatus.binaryPath && cliStatus.launchError
|
||||
? CLI_HEALTHCHECK_FAILED_MESSAGE
|
||||
: CLI_NOT_FOUND_MESSAGE
|
||||
: !cliStatus.authLoggedIn
|
||||
? CLI_AUTH_REQUIRED_MESSAGE
|
||||
: null;
|
||||
|
||||
if (preflightError) {
|
||||
clearPluginSuccessResetTimer(request.pluginId);
|
||||
set((prev) => ({
|
||||
pluginInstallProgress: { ...prev.pluginInstallProgress, [request.pluginId]: 'error' },
|
||||
installErrors: { ...prev.installErrors, [request.pluginId]: preflightError },
|
||||
|
|
@ -590,13 +728,14 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
|
|||
return;
|
||||
}
|
||||
|
||||
clearPluginSuccessResetTimer(request.pluginId);
|
||||
set((prev) => ({
|
||||
pluginInstallProgress: { ...prev.pluginInstallProgress, [request.pluginId]: 'pending' },
|
||||
installErrors: { ...prev.installErrors, [request.pluginId]: '' },
|
||||
}));
|
||||
|
||||
try {
|
||||
const result = await api.plugins.install(request);
|
||||
const result = await api.plugins.install(effectiveRequest);
|
||||
if (result.state === 'error') {
|
||||
set((prev) => ({
|
||||
pluginInstallProgress: { ...prev.pluginInstallProgress, [request.pluginId]: 'error' },
|
||||
|
|
@ -615,13 +754,9 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
|
|||
// Refresh catalog to pick up new installed state
|
||||
void get().fetchPluginCatalog(get().pluginCatalogProjectPath ?? undefined, true);
|
||||
|
||||
// Return to idle after brief success display
|
||||
setTimeout(() => {
|
||||
set((prev) => ({
|
||||
pluginInstallProgress: { ...prev.pluginInstallProgress, [request.pluginId]: 'idle' },
|
||||
}));
|
||||
}, SUCCESS_DISPLAY_MS);
|
||||
schedulePluginSuccessReset(request.pluginId, set);
|
||||
} catch (err) {
|
||||
clearPluginSuccessResetTimer(request.pluginId);
|
||||
const message = err instanceof Error ? err.message : 'Install failed';
|
||||
set((prev) => ({
|
||||
pluginInstallProgress: { ...prev.pluginInstallProgress, [request.pluginId]: 'error' },
|
||||
|
|
@ -634,12 +769,26 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
|
|||
uninstallPlugin: async (pluginId: string, scope?: InstallScope, projectPath?: string) => {
|
||||
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) => ({
|
||||
pluginInstallProgress: { ...prev.pluginInstallProgress, [pluginId]: 'pending' },
|
||||
}));
|
||||
|
||||
try {
|
||||
const result = await api.plugins.uninstall(pluginId, scope, projectPath);
|
||||
const result = await api.plugins.uninstall(pluginId, scope, effectiveProjectPath);
|
||||
if (result.state === 'error') {
|
||||
set((prev) => ({
|
||||
pluginInstallProgress: { ...prev.pluginInstallProgress, [pluginId]: 'error' },
|
||||
|
|
@ -655,12 +804,9 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
|
|||
// Refresh catalog
|
||||
void get().fetchPluginCatalog(get().pluginCatalogProjectPath ?? undefined, true);
|
||||
|
||||
setTimeout(() => {
|
||||
set((prev) => ({
|
||||
pluginInstallProgress: { ...prev.pluginInstallProgress, [pluginId]: 'idle' },
|
||||
}));
|
||||
}, SUCCESS_DISPLAY_MS);
|
||||
schedulePluginSuccessReset(pluginId, set);
|
||||
} catch (err) {
|
||||
clearPluginSuccessResetTimer(pluginId);
|
||||
const message = err instanceof Error ? err.message : 'Uninstall failed';
|
||||
set((prev) => ({
|
||||
pluginInstallProgress: { ...prev.pluginInstallProgress, [pluginId]: 'error' },
|
||||
|
|
|
|||
|
|
@ -70,7 +70,7 @@ export function inferCapabilities(item: PluginCatalogItem): PluginCapability[] {
|
|||
export interface PluginInstallRequest {
|
||||
pluginId: string; // canonical key — main resolves qualifiedName from catalog
|
||||
scope: InstallScope;
|
||||
projectPath?: string; // required for 'project' scope
|
||||
projectPath?: string; // required for repo-scoped installs ('project' or 'local')
|
||||
}
|
||||
|
||||
// ── Filters (renderer-only concern) ────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -2,7 +2,13 @@
|
|||
* 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.
|
||||
|
|
@ -94,6 +100,71 @@ export function buildPluginId(pluginName: string, marketplaceName: string): stri
|
|||
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.
|
||||
* 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 () => {
|
||||
catalog = createMockCatalog({
|
||||
resolvePlugin: vi.fn().mockResolvedValue(null) as PluginCatalogService['resolvePlugin'],
|
||||
|
|
@ -121,6 +137,22 @@ describe('PluginInstallService', () => {
|
|||
expect(result.state).toBe('error');
|
||||
expect(result.error).toContain('Command failed');
|
||||
});
|
||||
|
||||
it('rejects project scope when projectPath is missing', async () => {
|
||||
const result = await service.install({ pluginId: 'context7', scope: 'project' });
|
||||
|
||||
expect(result.state).toBe('error');
|
||||
expect(result.error).toContain('projectPath is required');
|
||||
expect(mockExecCli).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
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 ───────────────────────────────────────────────────────────────
|
||||
|
|
@ -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 () => {
|
||||
catalog = createMockCatalog({
|
||||
resolvePlugin: vi.fn().mockResolvedValue(null) as PluginCatalogService['resolvePlugin'],
|
||||
|
|
@ -171,5 +215,21 @@ describe('PluginInstallService', () => {
|
|||
expect(result.state).toBe('error');
|
||||
expect(result.error).toContain('Cannot uninstall');
|
||||
});
|
||||
|
||||
it('rejects project scope when projectPath is missing', async () => {
|
||||
const result = await service.uninstall('context7', 'project');
|
||||
|
||||
expect(result.state).toBe('error');
|
||||
expect(result.error).toContain('projectPath is required');
|
||||
expect(mockExecCli).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
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', () => {
|
||||
it('parses installed_plugins.json version 2 format', async () => {
|
||||
const installedData = {
|
||||
version: 2,
|
||||
plugins: {
|
||||
'context7@claude-plugins-official': [
|
||||
{
|
||||
scope: 'user',
|
||||
installPath: '/Users/test/.claude/plugins/cache/claude-plugins-official/context7/1.0.0',
|
||||
version: '1.0.0',
|
||||
installedAt: '2026-03-01T11:14:21.926Z',
|
||||
it('returns user-scoped plugins enabled in user settings', async () => {
|
||||
mockedFs.readFile.mockImplementation(async (filePath) => {
|
||||
const normalizedPath = String(filePath);
|
||||
if (normalizedPath.endsWith('/plugins/installed_plugins.json')) {
|
||||
return JSON.stringify({
|
||||
version: 2,
|
||||
plugins: {
|
||||
'context7@claude-plugins-official': [
|
||||
{
|
||||
scope: 'user',
|
||||
installPath:
|
||||
'/Users/test/.claude/plugins/cache/claude-plugins-official/context7/1.0.0',
|
||||
version: '1.0.0',
|
||||
installedAt: '2026-03-01T11:14:21.926Z',
|
||||
},
|
||||
],
|
||||
'typescript-lsp@claude-plugins-official': [
|
||||
{
|
||||
scope: 'project',
|
||||
version: '1.0.0',
|
||||
installedAt: '2026-03-03T10:00:00.000Z',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
'typescript-lsp@claude-plugins-official': [
|
||||
{
|
||||
scope: 'user',
|
||||
version: '1.0.0',
|
||||
installedAt: '2026-03-02T10:00:00.000Z',
|
||||
},
|
||||
{
|
||||
scope: 'project',
|
||||
version: '1.0.0',
|
||||
installedAt: '2026-03-03T10:00:00.000Z',
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
mockedFs.readFile.mockResolvedValue(JSON.stringify(installedData));
|
||||
if (normalizedPath === '/tmp/mock-claude/settings.json') {
|
||||
return JSON.stringify({
|
||||
enabledPlugins: {
|
||||
'context7@claude-plugins-official': true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' });
|
||||
});
|
||||
|
||||
const entries = await service.getInstalledPlugins();
|
||||
|
||||
expect(entries).toHaveLength(3);
|
||||
expect(entries[0].pluginId).toBe('context7@claude-plugins-official');
|
||||
expect(entries[0].scope).toBe('user');
|
||||
expect(entries[0].version).toBe('1.0.0');
|
||||
expect(entries).toHaveLength(1);
|
||||
expect(entries[0]).toMatchObject({
|
||||
pluginId: 'context7@claude-plugins-official',
|
||||
scope: 'user',
|
||||
version: '1.0.0',
|
||||
});
|
||||
});
|
||||
|
||||
expect(entries[1].pluginId).toBe('typescript-lsp@claude-plugins-official');
|
||||
expect(entries[1].scope).toBe('user');
|
||||
it('includes project and local scopes only for the active project', async () => {
|
||||
mockedFs.readFile.mockImplementation(async (filePath) => {
|
||||
const normalizedPath = String(filePath);
|
||||
if (normalizedPath.endsWith('/plugins/installed_plugins.json')) {
|
||||
return JSON.stringify({
|
||||
version: 2,
|
||||
plugins: {
|
||||
'context7@claude-plugins-official': [
|
||||
{
|
||||
scope: 'user',
|
||||
version: '1.0.0',
|
||||
installedAt: '2026-03-01T11:14:21.926Z',
|
||||
},
|
||||
],
|
||||
'typescript-lsp@claude-plugins-official': [
|
||||
{
|
||||
scope: 'project',
|
||||
version: '1.1.0',
|
||||
installedAt: '2026-03-03T10:00:00.000Z',
|
||||
},
|
||||
],
|
||||
'formatter@claude-plugins-official': [
|
||||
{
|
||||
scope: 'local',
|
||||
version: '2.0.0',
|
||||
installedAt: '2026-03-04T10:00:00.000Z',
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
expect(entries[2].pluginId).toBe('typescript-lsp@claude-plugins-official');
|
||||
expect(entries[2].scope).toBe('project');
|
||||
if (normalizedPath === '/tmp/mock-claude/settings.json') {
|
||||
return JSON.stringify({
|
||||
enabledPlugins: {
|
||||
'context7@claude-plugins-official': true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (normalizedPath === '/tmp/project-a/.claude/settings.json') {
|
||||
return JSON.stringify({
|
||||
enabledPlugins: {
|
||||
'typescript-lsp@claude-plugins-official': true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (normalizedPath === '/tmp/project-a/.claude/settings.local.json') {
|
||||
return JSON.stringify({
|
||||
enabledPlugins: {
|
||||
'formatter@claude-plugins-official': true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' });
|
||||
});
|
||||
|
||||
const entries = await service.getInstalledPlugins('/tmp/project-a');
|
||||
|
||||
expect(entries.map((entry) => [entry.pluginId, entry.scope])).toEqual([
|
||||
['context7@claude-plugins-official', 'user'],
|
||||
['typescript-lsp@claude-plugins-official', 'project'],
|
||||
['formatter@claude-plugins-official', 'local'],
|
||||
]);
|
||||
});
|
||||
|
||||
it('does not leak another project scope into the current project', async () => {
|
||||
mockedFs.readFile.mockImplementation(async (filePath) => {
|
||||
const normalizedPath = String(filePath);
|
||||
if (normalizedPath.endsWith('/plugins/installed_plugins.json')) {
|
||||
return JSON.stringify({
|
||||
version: 2,
|
||||
plugins: {
|
||||
'typescript-lsp@claude-plugins-official': [
|
||||
{
|
||||
scope: 'project',
|
||||
version: '1.1.0',
|
||||
installedAt: '2026-03-03T10:00:00.000Z',
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (normalizedPath.endsWith('/settings.json')) {
|
||||
return JSON.stringify({ enabledPlugins: {} });
|
||||
}
|
||||
|
||||
throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' });
|
||||
});
|
||||
|
||||
const entries = await service.getInstalledPlugins('/tmp/project-b');
|
||||
|
||||
expect(entries).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns empty array when file does not exist', async () => {
|
||||
|
|
@ -78,22 +181,55 @@ describe('PluginInstallationStateService', () => {
|
|||
});
|
||||
|
||||
it('returns empty array for unexpected version', async () => {
|
||||
mockedFs.readFile.mockResolvedValue(JSON.stringify({ version: 1, plugins: {} }));
|
||||
mockedFs.readFile.mockImplementation(async (filePath) => {
|
||||
const normalizedPath = String(filePath);
|
||||
if (normalizedPath.endsWith('/plugins/installed_plugins.json')) {
|
||||
return JSON.stringify({ version: 1, plugins: {} });
|
||||
}
|
||||
if (normalizedPath.endsWith('/settings.json')) {
|
||||
return JSON.stringify({ enabledPlugins: {} });
|
||||
}
|
||||
throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' });
|
||||
});
|
||||
|
||||
const entries = await service.getInstalledPlugins();
|
||||
expect(entries).toEqual([]);
|
||||
});
|
||||
|
||||
it('caches within TTL', async () => {
|
||||
mockedFs.readFile.mockResolvedValue(
|
||||
JSON.stringify({ version: 2, plugins: {} }),
|
||||
);
|
||||
mockedFs.readFile.mockImplementation(async (filePath) => {
|
||||
const normalizedPath = String(filePath);
|
||||
if (normalizedPath.endsWith('/plugins/installed_plugins.json')) {
|
||||
return JSON.stringify({ version: 2, plugins: {} });
|
||||
}
|
||||
if (normalizedPath.endsWith('/settings.json')) {
|
||||
return JSON.stringify({ enabledPlugins: {} });
|
||||
}
|
||||
throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' });
|
||||
});
|
||||
|
||||
await service.getInstalledPlugins();
|
||||
await service.getInstalledPlugins();
|
||||
|
||||
// Only one read
|
||||
expect(mockedFs.readFile).toHaveBeenCalledTimes(1);
|
||||
expect(mockedFs.readFile).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('caches results independently per project path', async () => {
|
||||
mockedFs.readFile.mockImplementation(async (filePath) => {
|
||||
const normalizedPath = String(filePath);
|
||||
if (normalizedPath.endsWith('/plugins/installed_plugins.json')) {
|
||||
return JSON.stringify({ version: 2, plugins: {} });
|
||||
}
|
||||
if (normalizedPath.endsWith('/settings.json')) {
|
||||
return JSON.stringify({ enabledPlugins: {} });
|
||||
}
|
||||
throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' });
|
||||
});
|
||||
|
||||
await service.getInstalledPlugins('/tmp/project-a');
|
||||
await service.getInstalledPlugins('/tmp/project-b');
|
||||
|
||||
expect(mockedFs.readFile).toHaveBeenCalledTimes(8);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -140,15 +276,22 @@ describe('PluginInstallationStateService', () => {
|
|||
|
||||
describe('invalidateCache', () => {
|
||||
it('forces re-read after invalidation', async () => {
|
||||
mockedFs.readFile.mockResolvedValue(
|
||||
JSON.stringify({ version: 2, plugins: {} }),
|
||||
);
|
||||
mockedFs.readFile.mockImplementation(async (filePath) => {
|
||||
const normalizedPath = String(filePath);
|
||||
if (normalizedPath.endsWith('/plugins/installed_plugins.json')) {
|
||||
return JSON.stringify({ version: 2, plugins: {} });
|
||||
}
|
||||
if (normalizedPath.endsWith('/settings.json')) {
|
||||
return JSON.stringify({ enabledPlugins: {} });
|
||||
}
|
||||
throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' });
|
||||
});
|
||||
|
||||
await service.getInstalledPlugins();
|
||||
service.invalidateCache();
|
||||
await service.getInstalledPlugins();
|
||||
|
||||
expect(mockedFs.readFile).toHaveBeenCalledTimes(2);
|
||||
expect(mockedFs.readFile).toHaveBeenCalledTimes(4);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -116,6 +116,23 @@ const makeSkillDetail = (overrides: Partial<SkillDetail> = {}): SkillDetail => (
|
|||
...overrides,
|
||||
});
|
||||
|
||||
const makeReadyCliStatus = () => ({
|
||||
flavor: 'claude' as const,
|
||||
displayName: 'Claude',
|
||||
supportsSelfUpdate: true,
|
||||
showVersionDetails: true,
|
||||
showBinaryPath: true,
|
||||
installed: true,
|
||||
installedVersion: '1.0.0',
|
||||
binaryPath: '/usr/local/bin/claude',
|
||||
latestVersion: '1.0.0',
|
||||
updateAvailable: false,
|
||||
authLoggedIn: true,
|
||||
authStatusChecking: false,
|
||||
authMethod: 'oauth_token' as const,
|
||||
providers: [],
|
||||
});
|
||||
|
||||
describe('extensionsSlice', () => {
|
||||
let store: TestStore;
|
||||
|
||||
|
|
@ -125,6 +142,7 @@ describe('extensionsSlice', () => {
|
|||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
|
|
@ -149,6 +167,121 @@ describe('extensionsSlice', () => {
|
|||
expect(store.getState().pluginCatalogError).toBe('boom');
|
||||
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', () => {
|
||||
|
|
@ -171,6 +304,15 @@ describe('extensionsSlice', () => {
|
|||
|
||||
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', () => {
|
||||
|
|
@ -298,24 +440,7 @@ describe('extensionsSlice', () => {
|
|||
|
||||
describe('installPlugin', () => {
|
||||
it('sets progress to pending then success', async () => {
|
||||
store.setState({
|
||||
cliStatus: {
|
||||
flavor: 'claude',
|
||||
displayName: 'Claude',
|
||||
supportsSelfUpdate: true,
|
||||
showVersionDetails: true,
|
||||
showBinaryPath: true,
|
||||
installed: true,
|
||||
installedVersion: '1.0.0',
|
||||
binaryPath: '/usr/local/bin/claude',
|
||||
latestVersion: '1.0.0',
|
||||
updateAvailable: false,
|
||||
authLoggedIn: true,
|
||||
authStatusChecking: false,
|
||||
authMethod: 'oauth_token',
|
||||
providers: [],
|
||||
},
|
||||
});
|
||||
store.setState({ cliStatus: makeReadyCliStatus() });
|
||||
const plugins = [makePlugin({ pluginId: 'a@m' })];
|
||||
(api.plugins!.getAll as ReturnType<typeof vi.fn>).mockResolvedValue(plugins);
|
||||
(api.plugins!.install as ReturnType<typeof vi.fn>).mockResolvedValue({ state: 'success' });
|
||||
|
|
@ -330,24 +455,7 @@ describe('extensionsSlice', () => {
|
|||
});
|
||||
|
||||
it('sets progress to error on failure', async () => {
|
||||
store.setState({
|
||||
cliStatus: {
|
||||
flavor: 'claude',
|
||||
displayName: 'Claude',
|
||||
supportsSelfUpdate: true,
|
||||
showVersionDetails: true,
|
||||
showBinaryPath: true,
|
||||
installed: true,
|
||||
installedVersion: '1.0.0',
|
||||
binaryPath: '/usr/local/bin/claude',
|
||||
latestVersion: '1.0.0',
|
||||
updateAvailable: false,
|
||||
authLoggedIn: true,
|
||||
authStatusChecking: false,
|
||||
authMethod: 'oauth_token',
|
||||
providers: [],
|
||||
},
|
||||
});
|
||||
store.setState({ cliStatus: makeReadyCliStatus() });
|
||||
(api.plugins!.install as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
state: 'error',
|
||||
error: 'Not found',
|
||||
|
|
@ -357,6 +465,77 @@ describe('extensionsSlice', () => {
|
|||
|
||||
expect(store.getState().pluginInstallProgress['fail@m']).toBe('error');
|
||||
});
|
||||
|
||||
it('fills missing projectPath from the active Extensions project context', async () => {
|
||||
store.setState({
|
||||
cliStatus: makeReadyCliStatus(),
|
||||
pluginCatalogProjectPath: '/tmp/project-a',
|
||||
});
|
||||
(api.plugins!.install as ReturnType<typeof vi.fn>).mockResolvedValue({ state: 'success' });
|
||||
|
||||
await store.getState().installPlugin({ pluginId: 'project@m', scope: 'project' });
|
||||
|
||||
expect(api.plugins!.install).toHaveBeenCalledWith({
|
||||
pluginId: 'project@m',
|
||||
scope: 'project',
|
||||
projectPath: '/tmp/project-a',
|
||||
});
|
||||
});
|
||||
|
||||
it('fails fast for project scope when there is no active project path', async () => {
|
||||
store.setState({ cliStatus: makeReadyCliStatus(), pluginCatalogProjectPath: null });
|
||||
|
||||
await store.getState().installPlugin({ pluginId: 'project@m', scope: 'project' });
|
||||
|
||||
expect(api.plugins!.install).not.toHaveBeenCalled();
|
||||
expect(store.getState().pluginInstallProgress['project@m']).toBe('error');
|
||||
expect(store.getState().installErrors['project@m']).toContain('active project');
|
||||
});
|
||||
|
||||
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', () => {
|
||||
|
|
@ -372,6 +551,66 @@ describe('extensionsSlice', () => {
|
|||
await promise;
|
||||
expect(store.getState().pluginInstallProgress['test@m']).toBe('success');
|
||||
});
|
||||
|
||||
it('fills missing projectPath from the active Extensions project context', async () => {
|
||||
store.setState({ pluginCatalogProjectPath: '/tmp/project-a' });
|
||||
(api.plugins!.uninstall as ReturnType<typeof vi.fn>).mockResolvedValue({ state: 'success' });
|
||||
|
||||
await store.getState().uninstallPlugin('project@m', 'project');
|
||||
|
||||
expect(api.plugins!.uninstall).toHaveBeenCalledWith('project@m', 'project', '/tmp/project-a');
|
||||
});
|
||||
|
||||
it('fails fast for project uninstall when there is no active project path', async () => {
|
||||
store.setState({ pluginCatalogProjectPath: null });
|
||||
|
||||
await store.getState().uninstallPlugin('project@m', 'project');
|
||||
|
||||
expect(api.plugins!.uninstall).not.toHaveBeenCalled();
|
||||
expect(store.getState().pluginInstallProgress['project@m']).toBe('error');
|
||||
expect(store.getState().installErrors['project@m']).toContain('active project');
|
||||
});
|
||||
|
||||
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', () => {
|
||||
|
|
|
|||
|
|
@ -5,8 +5,11 @@ import type { PluginCatalogItem } from '@shared/types/extensions';
|
|||
import {
|
||||
buildPluginId,
|
||||
formatInstallCount,
|
||||
getExtensionActionDisableReason,
|
||||
getCapabilityLabel,
|
||||
getInstallationSummaryLabel,
|
||||
getPrimaryCapabilityLabel,
|
||||
hasInstallationInScope,
|
||||
inferCapabilities,
|
||||
normalizeCategory,
|
||||
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', () => {
|
||||
it('lowercases and replaces spaces with dashes', () => {
|
||||
expect(sanitizeMcpServerName('My Server')).toBe('my-server');
|
||||
|
|
|
|||
Loading…
Reference in a new issue