Use ClaudeBinaryResolver instead of null binary path in extension services
(McpHealthDiagnosticsService, PluginInstallService, McpInstallService).
Packaged Electron on macOS has minimal PATH — bare `claude` lookup fails
with ENOENT. Now all CLI calls resolve the binary via ClaudeBinaryResolver
which checks PATH, NVM, standard install dirs and login shell env.
- Add buildEnrichedEnv() helper for child process env (PATH, HOME, USERPROFILE)
- Add stale cache re-verification with 30s TTL in ClaudeBinaryResolver
- Guard execCli() against null binaryPath with explicit error
- Replace projectPath.startsWith('/') with path.isAbsolute() for Windows
- Extract CLI_NOT_FOUND_MARKER/MESSAGE constants for consistent error detection
- Show amber info banner instead of red error when CLI not installed
175 lines
6.2 KiB
TypeScript
175 lines
6.2 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
|
|
import { PluginInstallService } from '@main/services/extensions/install/PluginInstallService';
|
|
|
|
import type { PluginCatalogService } from '@main/services/extensions/catalog/PluginCatalogService';
|
|
|
|
// ── Mock execCli ─────────────────────────────────────────────────────────────
|
|
|
|
vi.mock('@main/utils/childProcess', () => ({
|
|
execCli: vi.fn(),
|
|
}));
|
|
|
|
vi.mock('@main/services/team/ClaudeBinaryResolver', () => ({
|
|
ClaudeBinaryResolver: {
|
|
resolve: vi.fn().mockResolvedValue('/usr/local/bin/claude'),
|
|
},
|
|
}));
|
|
|
|
import { ClaudeBinaryResolver } from '@main/services/team/ClaudeBinaryResolver';
|
|
|
|
import { execCli } from '@main/utils/childProcess';
|
|
|
|
const mockExecCli = vi.mocked(execCli);
|
|
|
|
// ── Mock catalog service ──────────────────────────────────────────────────────
|
|
|
|
function createMockCatalog(overrides?: Partial<PluginCatalogService>): PluginCatalogService {
|
|
return {
|
|
getPlugins: vi.fn(),
|
|
getPluginReadme: vi.fn(),
|
|
resolvePlugin: vi.fn().mockResolvedValue({
|
|
qualifiedName: 'context7@claude-plugins-official',
|
|
}),
|
|
...overrides,
|
|
} as unknown as PluginCatalogService;
|
|
}
|
|
|
|
describe('PluginInstallService', () => {
|
|
let service: PluginInstallService;
|
|
let catalog: PluginCatalogService;
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
vi.mocked(ClaudeBinaryResolver.resolve).mockResolvedValue('/usr/local/bin/claude');
|
|
catalog = createMockCatalog();
|
|
service = new PluginInstallService(catalog);
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
// ── install ─────────────────────────────────────────────────────────────────
|
|
|
|
describe('install', () => {
|
|
it('builds correct CLI args for user scope', async () => {
|
|
mockExecCli.mockResolvedValue({ stdout: '', stderr: '' });
|
|
|
|
const result = await service.install({
|
|
pluginId: 'context7',
|
|
scope: 'user',
|
|
});
|
|
|
|
expect(result.state).toBe('success');
|
|
expect(mockExecCli).toHaveBeenCalledWith(
|
|
'/usr/local/bin/claude',
|
|
['plugin', 'install', 'context7@claude-plugins-official'],
|
|
expect.objectContaining({ timeout: 120_000 }),
|
|
);
|
|
});
|
|
|
|
it('adds scope flag for non-user scope', async () => {
|
|
mockExecCli.mockResolvedValue({ stdout: '', stderr: '' });
|
|
|
|
await service.install({
|
|
pluginId: 'context7',
|
|
scope: 'project',
|
|
projectPath: '/tmp/test-project',
|
|
});
|
|
|
|
expect(mockExecCli).toHaveBeenCalledWith(
|
|
'/usr/local/bin/claude',
|
|
['plugin', 'install', '-s', 'project', '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'],
|
|
});
|
|
service = new PluginInstallService(catalog);
|
|
|
|
const result = await service.install({ pluginId: 'nonexistent', scope: 'user' });
|
|
|
|
expect(result.state).toBe('error');
|
|
expect(result.error).toContain('not found in catalog');
|
|
expect(mockExecCli).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('returns error if qualifiedName has invalid format', async () => {
|
|
catalog = createMockCatalog({
|
|
resolvePlugin: vi.fn().mockResolvedValue({
|
|
qualifiedName: '../../../etc/passwd',
|
|
}) as PluginCatalogService['resolvePlugin'],
|
|
});
|
|
service = new PluginInstallService(catalog);
|
|
|
|
const result = await service.install({ pluginId: 'evil', scope: 'user' });
|
|
|
|
expect(result.state).toBe('error');
|
|
expect(result.error).toContain('Invalid plugin identifier');
|
|
expect(mockExecCli).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('returns error if CLI execution fails', async () => {
|
|
mockExecCli.mockRejectedValue(new Error('Command failed: exit code 1'));
|
|
|
|
const result = await service.install({ pluginId: 'context7', scope: 'user' });
|
|
|
|
expect(result.state).toBe('error');
|
|
expect(result.error).toContain('Command failed');
|
|
});
|
|
});
|
|
|
|
// ── uninstall ───────────────────────────────────────────────────────────────
|
|
|
|
describe('uninstall', () => {
|
|
it('builds correct CLI args for user scope', async () => {
|
|
mockExecCli.mockResolvedValue({ stdout: '', stderr: '' });
|
|
|
|
const result = await service.uninstall('context7');
|
|
|
|
expect(result.state).toBe('success');
|
|
expect(mockExecCli).toHaveBeenCalledWith(
|
|
'/usr/local/bin/claude',
|
|
['plugin', 'uninstall', 'context7@claude-plugins-official'],
|
|
expect.objectContaining({ timeout: 30_000 }),
|
|
);
|
|
});
|
|
|
|
it('adds scope flag for project scope', async () => {
|
|
mockExecCli.mockResolvedValue({ stdout: '', stderr: '' });
|
|
|
|
await service.uninstall('context7', 'project', '/tmp/test-project');
|
|
|
|
expect(mockExecCli).toHaveBeenCalledWith(
|
|
'/usr/local/bin/claude',
|
|
['plugin', 'uninstall', '-s', 'project', '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'],
|
|
});
|
|
service = new PluginInstallService(catalog);
|
|
|
|
const result = await service.uninstall('nonexistent');
|
|
|
|
expect(result.state).toBe('error');
|
|
expect(result.error).toContain('not found in catalog');
|
|
});
|
|
|
|
it('returns error if CLI fails', async () => {
|
|
mockExecCli.mockRejectedValue(new Error('Cannot uninstall'));
|
|
|
|
const result = await service.uninstall('context7');
|
|
|
|
expect(result.state).toBe('error');
|
|
expect(result.error).toContain('Cannot uninstall');
|
|
});
|
|
});
|
|
});
|