diff --git a/package.json b/package.json index 4a0b4e6e..b03f65e8 100644 --- a/package.json +++ b/package.json @@ -106,6 +106,7 @@ "https-proxy-agent": "^7.0.6", "json5": "^2.2.3", "prompts": "^2.4.2", + "semver": "^7.7.3", "source-map-support": "^0.5.21", "tiktoken": "^1.0.22", "uuid": "^13.0.0", @@ -127,6 +128,7 @@ "@types/express": "^5.0.0", "@types/node": "^24.0.0", "@types/prompts": "^2.4.9", + "@types/semver": "^7.7.1", "@types/supertest": "^6.0.3", "@types/yargs": "^17.0.33", "@typescript-eslint/eslint-plugin": "^8.27.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fb9722be..1b0aa3af 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -50,6 +50,9 @@ importers: prompts: specifier: ^2.4.2 version: 2.4.2 + semver: + specifier: ^7.7.3 + version: 7.7.3 source-map-support: specifier: ^0.5.21 version: 0.5.21 @@ -108,6 +111,9 @@ importers: '@types/prompts': specifier: ^2.4.9 version: 2.4.9 + '@types/semver': + specifier: ^7.7.1 + version: 7.7.1 '@types/supertest': specifier: ^6.0.3 version: 6.0.3 @@ -868,56 +874,67 @@ packages: resolution: {integrity: sha512-PsNAbcyv9CcecAUagQefwX8fQn9LQ4nZkpDboBOttmyffnInRy8R8dSg6hxxl2Re5QhHBf6FYIDhIj5v982ATQ==} cpu: [arm] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm-musleabihf@4.52.5': resolution: {integrity: sha512-Fw4tysRutyQc/wwkmcyoqFtJhh0u31K+Q6jYjeicsGJJ7bbEq8LwPWV/w0cnzOqR2m694/Af6hpFayLJZkG2VQ==} cpu: [arm] os: [linux] + libc: [musl] '@rollup/rollup-linux-arm64-gnu@4.52.5': resolution: {integrity: sha512-a+3wVnAYdQClOTlyapKmyI6BLPAFYs0JM8HRpgYZQO02rMR09ZcV9LbQB+NL6sljzG38869YqThrRnfPMCDtZg==} cpu: [arm64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm64-musl@4.52.5': resolution: {integrity: sha512-AvttBOMwO9Pcuuf7m9PkC1PUIKsfaAJ4AYhy944qeTJgQOqJYJ9oVl2nYgY7Rk0mkbsuOpCAYSs6wLYB2Xiw0Q==} cpu: [arm64] os: [linux] + libc: [musl] '@rollup/rollup-linux-loong64-gnu@4.52.5': resolution: {integrity: sha512-DkDk8pmXQV2wVrF6oq5tONK6UHLz/XcEVow4JTTerdeV1uqPeHxwcg7aFsfnSm9L+OO8WJsWotKM2JJPMWrQtA==} cpu: [loong64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-ppc64-gnu@4.52.5': resolution: {integrity: sha512-W/b9ZN/U9+hPQVvlGwjzi+Wy4xdoH2I8EjaCkMvzpI7wJUs8sWJ03Rq96jRnHkSrcHTpQe8h5Tg3ZzUPGauvAw==} cpu: [ppc64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-riscv64-gnu@4.52.5': resolution: {integrity: sha512-sjQLr9BW7R/ZiXnQiWPkErNfLMkkWIoCz7YMn27HldKsADEKa5WYdobaa1hmN6slu9oWQbB6/jFpJ+P2IkVrmw==} cpu: [riscv64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-riscv64-musl@4.52.5': resolution: {integrity: sha512-hq3jU/kGyjXWTvAh2awn8oHroCbrPm8JqM7RUpKjalIRWWXE01CQOf/tUNWNHjmbMHg/hmNCwc/Pz3k1T/j/Lg==} cpu: [riscv64] os: [linux] + libc: [musl] '@rollup/rollup-linux-s390x-gnu@4.52.5': resolution: {integrity: sha512-gn8kHOrku8D4NGHMK1Y7NA7INQTRdVOntt1OCYypZPRt6skGbddska44K8iocdpxHTMMNui5oH4elPH4QOLrFQ==} cpu: [s390x] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.52.5': resolution: {integrity: sha512-hXGLYpdhiNElzN770+H2nlx+jRog8TyynpTVzdlc6bndktjKWyZyiCsuDAlpd+j+W+WNqfcyAWz9HxxIGfZm1Q==} cpu: [x64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-musl@4.52.5': resolution: {integrity: sha512-arCGIcuNKjBoKAXD+y7XomR9gY6Mw7HnFBv5Rw7wQRvwYLR7gBAgV7Mb2QTyjXfTveBNFAtPt46/36vV9STLNg==} cpu: [x64] os: [linux] + libc: [musl] '@rollup/rollup-openharmony-arm64@4.52.5': resolution: {integrity: sha512-QoFqB6+/9Rly/RiPjaomPLmR/13cgkIGfA40LHly9zcH1S0bN2HVFYk3a1eAyHQyjs3ZJYlXvIGtcCs5tko9Cw==} @@ -1169,6 +1186,9 @@ packages: '@types/readdir-glob@1.1.5': resolution: {integrity: sha512-raiuEPUYqXu+nvtY2Pe8s8FEmZ3x5yAH4VkLdihcPdalvsHltomrRC9BzuStrJ9yk06470hS0Crw0f1pXqD+Hg==} + '@types/semver@7.7.1': + resolution: {integrity: sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA==} + '@types/send@0.17.5': resolution: {integrity: sha512-z6F2D3cOStZvuk2SaP6YrwkNO65iTZcwA2ZkSABegdkAh/lf+Aa/YQndZVfmEXT5vgAp6zv06VQ3ejSVjAny4w==} @@ -4694,6 +4714,8 @@ snapshots: dependencies: '@types/node': 24.7.0 + '@types/semver@7.7.1': {} + '@types/send@0.17.5': dependencies: '@types/mime': 1.3.5 diff --git a/src/commands/mcp/index.ts b/src/commands/mcp/index.ts index 61ce0f18..906065d1 100644 --- a/src/commands/mcp/index.ts +++ b/src/commands/mcp/index.ts @@ -3,12 +3,15 @@ import { globalOptions } from '@src/globalOptions.js'; import type { Argv } from 'yargs'; +import { buildSearchCommand as buildRegistrySearchCommand } from '../registry/search.js'; import { buildAddCommand } from './add.js'; import { buildDisableCommand, buildEnableCommand } from './enable.js'; +import { buildInstallCommand } from './install.js'; import { buildListCommand } from './list.js'; import { buildRemoveCommand } from './remove.js'; import { buildStatusCommand } from './status.js'; import { buildTokensCommand } from './tokens.js'; +import { buildUninstallCommand } from './uninstall.js'; import { buildUpdateCommand } from './update.js'; /** @@ -48,6 +51,15 @@ export function setupMcpCommands(yargs: Argv): Argv { } }, }) + .command({ + command: 'install [serverName]', + describe: 'Install an MCP server from the registry (interactive wizard if no serverName)', + builder: buildInstallCommand, + handler: async (argv) => { + const { installCommand } = await import('./install.js'); + await installCommand(argv); + }, + }) .command({ command: 'remove ', describe: 'Remove an MCP server from the configuration', @@ -57,6 +69,15 @@ export function setupMcpCommands(yargs: Argv): Argv { await removeCommand(argv); }, }) + .command({ + command: 'uninstall ', + describe: 'Uninstall an MCP server', + builder: buildUninstallCommand, + handler: async (argv) => { + const { uninstallCommand } = await import('./uninstall.js'); + await uninstallCommand(argv); + }, + }) .command({ command: 'update ', describe: 'Update an existing MCP server configuration', @@ -122,6 +143,36 @@ export function setupMcpCommands(yargs: Argv): Argv { await tokensCommand(argv); }, }) + .command({ + command: 'search [query]', + describe: 'Search registry for MCP servers (alias for registry search)', + builder: (yargs) => buildRegistrySearchCommand(yargs), + handler: async (argv) => { + // Delegate to registry search command + const { searchCommand } = await import('../registry/search.js'); + const searchArgs = { + query: argv.query, + status: argv.status, + type: argv.type, + transport: argv.transport, + limit: argv.limit, + cursor: argv.cursor, + format: argv.format, + _: argv._ || [], + $0: argv.$0 || '1mcp', + config: argv.config, + 'config-dir': argv['config-dir'], + url: argv['url'], + timeout: argv['timeout'], + 'cache-ttl': argv['cache-ttl'], + 'cache-max-size': argv['cache-max-size'], + 'cache-cleanup-interval': argv['cache-cleanup-interval'], + proxy: argv['proxy'], + 'proxy-auth': argv['proxy-auth'], + } as Parameters[0]; + await searchCommand(searchArgs); + }, + }) .demandCommand(1, 'You must specify a subcommand') .help().epilogue(` MCP Command Group - Local MCP Server Configuration Management @@ -129,8 +180,9 @@ MCP Command Group - Local MCP Server Configuration Management The mcp command group helps you manage local MCP server configurations in your 1mcp instance. This allows you to: +• Install MCP servers from the registry • Add new MCP servers with various transport types (stdio, HTTP, SSE) -• Remove servers you no longer need +• Remove or uninstall servers you no longer need • Update server configurations including environment variables and tags • Enable/disable servers without removing them • List and filter servers by tags or status diff --git a/src/commands/mcp/install.test.ts b/src/commands/mcp/install.test.ts new file mode 100644 index 00000000..0d6c661f --- /dev/null +++ b/src/commands/mcp/install.test.ts @@ -0,0 +1,159 @@ +import * as serverManagementIndex from '@src/domains/server-management/index.js'; + +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import * as configUtils from './utils/configUtils.js'; +import { buildInstallCommand, installCommand } from './install.js'; + +// Mock dependencies +vi.mock('@src/domains/server-management/index.js', () => { + const installServer = vi.fn(); + const startOperation = vi.fn(); + const updateProgress = vi.fn(); + const completeOperation = vi.fn(); + const failOperation = vi.fn(); + return { + createServerInstallationService: vi.fn(() => ({ installServer })), + getProgressTrackingService: vi.fn(() => ({ + startOperation, + updateProgress, + completeOperation, + failOperation, + })), + }; +}); + +vi.mock('./utils/configUtils.js', () => { + return { + initializeConfigContext: vi.fn(), + serverExists: vi.fn(), + backupConfig: vi.fn(() => '/tmp/config.backup'), + reloadMcpConfig: vi.fn(), + setServer: vi.fn(), + getAllServers: vi.fn(), + }; +}); + +vi.mock('./utils/serverUtils.js', () => ({ + generateOperationId: vi.fn(() => 'op_test_123'), + parseServerNameVersion: vi.fn((input: string) => { + const parts = input.split('@'); + return { name: parts[0], version: parts[1] }; + }), + validateServerName: vi.fn(), + validateVersion: vi.fn((v?: string) => (v ? /^\d+\.\d+\.\d+/.test(v) : true)), +})); + +vi.mock('@src/logger/logger.js', () => ({ + default: { + info: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }, +})); + +const consoleLogMock = vi.fn(); +console.log = consoleLogMock; + +describe('Install Command', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(configUtils.serverExists as any).mockReturnValue(false); + vi.mocked((serverManagementIndex as any).createServerInstallationService().installServer).mockResolvedValue({ + success: true, + serverName: 'test-server', + version: '1.0.0', + installedAt: new Date(), + configPath: '/path/to/config', + warnings: [], + errors: [], + operationId: 'op_test_123', + }); + }); + + describe('buildInstallCommand', () => { + it('should configure command with correct options', () => { + const yargsMock = { + positional: vi.fn().mockReturnThis(), + option: vi.fn().mockReturnThis(), + example: vi.fn().mockReturnThis(), + }; + + buildInstallCommand(yargsMock as any); + + expect(yargsMock.positional).toHaveBeenCalledWith('serverName', expect.anything()); + expect(yargsMock.option).toHaveBeenCalledWith('force', expect.anything()); + expect(yargsMock.option).toHaveBeenCalledWith('dry-run', expect.anything()); + expect(yargsMock.option).toHaveBeenCalledWith('verbose', expect.anything()); + }); + }); + + describe('installCommand', () => { + it('should reject on invalid version format', async () => { + const args = { + serverName: 'test-server@bad', + dryRun: false, + force: false, + verbose: false, + }; + + await expect(installCommand(args as any)).rejects.toThrow(/Invalid version format/); + expect((serverManagementIndex as any).getProgressTrackingService().startOperation).not.toHaveBeenCalled(); + }); + + it('should perform dry-run without invoking installation', async () => { + const args = { + serverName: 'test-server@1.2.3', + dryRun: true, + force: false, + verbose: false, + }; + + await installCommand(args as any); + + expect((serverManagementIndex as any).createServerInstallationService().installServer).not.toHaveBeenCalled(); + expect((serverManagementIndex as any).getProgressTrackingService().startOperation).not.toHaveBeenCalled(); + expect(consoleLogMock).toHaveBeenCalled(); + }); + + it('should throw if server exists and not forced', async () => { + vi.mocked(configUtils.serverExists as any).mockReturnValue(true); + const args = { + serverName: 'exists@1.2.3', + dryRun: false, + force: false, + verbose: false, + }; + + await expect(installCommand(args as any)).rejects.toThrow(/already exists/); + expect((configUtils.backupConfig as any).mock.calls.length).toBe(0); + }); + + it('should create backup when reinstalling with --force and reload config', async () => { + vi.mocked(configUtils.serverExists as any).mockReturnValue(true); + const args = { + serverName: 'test-server@1.2.3', + dryRun: false, + force: true, + verbose: true, + }; + + await installCommand(args as any); + + expect((serverManagementIndex as any).getProgressTrackingService().startOperation).toHaveBeenCalledWith( + 'op_test_123', + 'install', + 5, + ); + expect((serverManagementIndex as any).getProgressTrackingService().updateProgress).toHaveBeenCalled(); + expect((configUtils.backupConfig as any).mock.calls.length).toBeGreaterThan(0); + expect((serverManagementIndex as any).createServerInstallationService().installServer).toHaveBeenCalledWith( + 'test-server', + '1.2.3', + expect.any(Object), + ); + expect((configUtils.reloadMcpConfig as any).mock.calls.length).toBeGreaterThan(0); + expect((serverManagementIndex as any).getProgressTrackingService().completeOperation).toHaveBeenCalled(); + }); + }); +}); diff --git a/src/commands/mcp/install.ts b/src/commands/mcp/install.ts new file mode 100644 index 00000000..577d5ae6 --- /dev/null +++ b/src/commands/mcp/install.ts @@ -0,0 +1,535 @@ +import { MCPRegistryClient } from '@src/domains/registry/mcpRegistryClient.js'; +import { createServerInstallationService, getProgressTrackingService } from '@src/domains/server-management/index.js'; +import { GlobalOptions } from '@src/globalOptions.js'; +import logger from '@src/logger/logger.js'; + +import boxen from 'boxen'; +import chalk from 'chalk'; +import type { Argv } from 'yargs'; + +import { + backupConfig, + getAllServers, + initializeConfigContext, + reloadMcpConfig, + serverExists, +} from './utils/configUtils.js'; +import { InstallWizard } from './utils/installWizard.js'; +import { generateOperationId, parseServerNameVersion, validateVersion } from './utils/serverUtils.js'; + +export interface InstallCommandArgs extends GlobalOptions { + serverName?: string; + force?: boolean; + dryRun?: boolean; + verbose?: boolean; + interactive?: boolean; +} + +/** + * Build the install command configuration + */ +export function buildInstallCommand(yargs: Argv) { + return yargs + .positional('serverName', { + describe: 'Server name or name@version to install', + type: 'string', + demandOption: false, + }) + .option('force', { + describe: 'Force installation even if already exists', + type: 'boolean', + default: false, + }) + .option('dry-run', { + describe: 'Show what would be installed without installing', + type: 'boolean', + default: false, + }) + .option('interactive', { + describe: 'Launch interactive wizard for guided installation', + type: 'boolean', + default: false, + alias: 'i', + }) + .option('verbose', { + describe: 'Detailed output', + type: 'boolean', + default: false, + alias: 'v', + }) + .example([ + ['$0 mcp install', 'Launch interactive installation wizard'], + ['$0 mcp install filesystem', 'Install latest version of filesystem server'], + ['$0 mcp install filesystem@1.0.0', 'Install specific version'], + ['$0 mcp install filesystem --interactive', 'Install with interactive configuration'], + ['$0 mcp install filesystem --force', 'Force reinstallation'], + ['$0 mcp install filesystem --dry-run', 'Preview installation'], + ]); +} + +/** + * Install command handler + */ +export async function installCommand(argv: InstallCommandArgs): Promise { + try { + const { + serverName: inputServerName, + config: configPath, + 'config-dir': configDir, + force = false, + dryRun = false, + verbose = false, + interactive = false, + } = argv; + + // Initialize configuration context + initializeConfigContext(configPath, configDir); + + if (verbose) { + logger.info('Starting installation process...'); + } + + // Launch interactive wizard if no server name provided or --interactive flag set + if (!inputServerName || interactive) { + await runInteractiveInstallation(argv); + return; + } + + // Parse server name and version + const { name: registryServerId, version } = parseServerNameVersion(inputServerName); + + // Validate version format if provided + if (version && !validateVersion(version)) { + throw new Error(`Invalid version format: '${version}'. Expected semantic version (e.g., 1.2.3).`); + } + + if (verbose) { + logger.info(`Parsed registry server ID: ${registryServerId}, version: ${version || 'latest'}`); + } + + // For registry installations, we need to validate the registry server ID format + // and then derive a valid local server name + validateRegistryServerId(registryServerId); + + // Derive a valid local server name from the registry ID + const serverName = deriveLocalServerName(registryServerId); + + if (verbose) { + logger.info(`Derived local server name: ${serverName} from registry ID: ${registryServerId}`); + } + + // Check if server already exists + if (serverExists(serverName)) { + if (!force) { + throw new Error(`Server '${serverName}' already exists. Use --force to reinstall.`); + } + if (verbose) { + logger.info(`Server '${serverName}' exists, will reinstall due to --force flag`); + } + } + + // Dry run mode + if (dryRun) { + console.log('🔍 Dry run mode - no changes will be made\n'); + console.log(`Would install: ${serverName}${version ? `@${version}` : ''}`); + console.log(`From registry: https://registry.modelcontextprotocol.io\n`); + console.log('Use without --dry-run to perform actual installation.'); + return; + } + + // Create operation ID for tracking + const operationId = generateOperationId(); + const progressTracker = getProgressTrackingService(); + + // Start progress tracking + progressTracker.startOperation(operationId, 'install', 5); + + try { + // Get installation service + const installationService = createServerInstallationService(); + + // Update progress: Validating + progressTracker.updateProgress(operationId, 1, 'Validating server', `Checking registry for ${serverName}`); + + // Create backup if replacing existing server + let backupPath: string | undefined; + if (serverExists(serverName)) { + progressTracker.updateProgress(operationId, 2, 'Creating backup', `Backing up existing configuration`); + backupPath = backupConfig(); + logger.info(`Backup created: ${backupPath}`); + } + + // Update progress: Installing + progressTracker.updateProgress( + operationId, + 3, + 'Installing server', + `Installing ${serverName}${version ? `@${version}` : ''}`, + ); + + // Perform installation - pass the registry server ID for fetching from registry + // but use the derived local name for configuration + const result = await installationService.installServer(registryServerId, version, { + force, + verbose, + localServerName: serverName, // Pass the derived local name + }); + + // Update progress: Finalizing + progressTracker.updateProgress(operationId, 4, 'Finalizing', 'Verifying configuration'); + + // Save the configuration (service returns the config to be saved) + // For now, this will be handled by the install server method directly + // which will call setServer internally + + // In a full implementation, we would get the config from result and save it + // setServer(serverName, result.serverConfig); + + // Update progress: Reloading + progressTracker.updateProgress(operationId, 5, 'Reloading configuration', 'Applying changes'); + + // Reload MCP configuration + reloadMcpConfig(); + + // Update progress: Complete (completeOperation logs completion) + + // Complete the operation + const duration = result.installedAt ? Date.now() - result.installedAt.getTime() : 0; + progressTracker.completeOperation(operationId, { + success: true, + operationId, + duration, + message: `Successfully installed ${serverName}`, + }); + + // Report success + console.log(`\n✅ Successfully installed server '${serverName}'${version ? ` version ${version}` : ''}`); + if (backupPath) { + console.log(`📁 Backup created: ${backupPath}`); + } + if (result.warnings.length > 0) { + console.log('\n⚠️ Warnings:'); + result.warnings.forEach((warning) => console.log(` • ${warning}`)); + } + } catch (error) { + progressTracker.failOperation(operationId, error as Error); + throw error; + } + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + console.error(`\n❌ Installation failed: ${errorMessage}\n`); + if (error instanceof Error && error.stack) { + logger.error('Installation error stack:', error.stack); + } + throw error; + } +} + +/** + * Validate registry server ID format + * Registry server IDs can contain dots, slashes, and hyphens (e.g., io.github.user/server-name) + */ +function validateRegistryServerId(registryId: string): void { + if (!registryId || registryId.trim().length === 0) { + throw new Error('Registry server ID cannot be empty'); + } + + const trimmedId = registryId.trim(); + + // Check for invalid characters that should never be in server IDs + // eslint-disable-next-line no-control-regex + const invalidChars = /[<>"\\|?*\x00-\x1f]/; + if (invalidChars.test(trimmedId)) { + throw new Error(`Registry server ID contains invalid characters: ${registryId}`); + } + + // Check length limits + if (trimmedId.length > 255) { + throw new Error(`Registry server ID too long (max 255 characters): ${registryId}`); + } + + // Check for invalid patterns + if (trimmedId.includes('//') || trimmedId.startsWith('/') || trimmedId.endsWith('/')) { + throw new Error(`Registry server ID has invalid format: ${registryId}`); + } + + logger.debug(`Registry server ID validation passed: ${trimmedId}`); +} + +/** + * Derive a valid local server name from a registry server ID + * Example: io.github.SnowLeopard-AI/bigquery-mcp -> bigquery-mcp + */ +function deriveLocalServerName(registryId: string): string { + // Extract the last part after the slash, or use the full ID if no slash + const lastPart = registryId.includes('/') ? registryId.split('/').pop()! : registryId; + + // If it already starts with a letter and only contains valid chars, use it as-is + const localNameRegex = /^[a-zA-Z][a-zA-Z0-9_-]*$/; + if (localNameRegex.test(lastPart) && lastPart.length <= 50) { + return lastPart; + } + + // Otherwise, sanitize it: + // 1. Replace invalid characters with underscores + // 2. Ensure it starts with a letter + // 3. Truncate if too long + let sanitized = lastPart.replace(/[^a-zA-Z0-9_-]/g, '_'); + + // Ensure it starts with a letter + if (!/^[a-zA-Z]/.test(sanitized)) { + sanitized = `server_${sanitized}`; + } + + // Truncate to 50 characters if longer + if (sanitized.length > 50) { + sanitized = sanitized.substring(0, 50); + } + + // Ensure it's not empty after sanitization + if (sanitized.length === 0) { + sanitized = 'server'; + } + + logger.debug(`Derived local server name '${sanitized}' from registry ID '${registryId}'`); + return sanitized; +} + +/** + * Run interactive installation workflow + */ +async function runInteractiveInstallation(argv: InstallCommandArgs): Promise { + const { + serverName: initialServerId, + config: configPath, + 'config-dir': configDir, + force = false, + dryRun = false, + verbose = false, + } = argv; + + // Initialize configuration context + initializeConfigContext(configPath, configDir); + + // Create registry client + const registryClient = new MCPRegistryClient({ + baseUrl: 'https://registry.modelcontextprotocol.io', + timeout: 30000, + cache: { + defaultTtl: 300, + maxSize: 100, + cleanupInterval: 60000, + }, + }); + + // Create wizard + const wizard = new InstallWizard(registryClient); + + // Get existing server names for conflict detection + const getExistingNames = () => Object.keys(getAllServers()); + + // Run wizard loop (supports installing multiple servers) + let continueInstalling = true; + let currentServerId = initialServerId; + + try { + while (continueInstalling) { + const existingNames = getExistingNames(); + const wizardResult = await wizard.run(currentServerId, existingNames); + + if (wizardResult.cancelled) { + console.log('\n❌ Installation cancelled.\n'); + wizard.cleanup(); + process.exit(0); + } + + // Perform installation with collected configuration + try { + const registryServerId = wizardResult.serverId; + const version = wizardResult.version; + const serverName = wizardResult.localName || deriveLocalServerName(registryServerId); + + // Use forceOverride from wizard if user selected override option + const shouldForce = force || wizardResult.forceOverride || false; + + // Check if server already exists (early check) + const serverAlreadyExists = serverExists(serverName); + if (serverAlreadyExists && !shouldForce) { + console.error(`\n❌ Server '${serverName}' already exists. Use --force to reinstall.\n`); + wizard.cleanup(); + if (wizardResult.installAnother) { + currentServerId = undefined; + continue; + } + process.exit(1); + } + + // Dry run mode + if (dryRun) { + console.log('🔍 Dry run mode - no changes will be made\n'); + console.log(`Would install: ${serverName}${version ? `@${version}` : ''}`); + console.log(`From registry: https://registry.modelcontextprotocol.io\n`); + if (wizardResult.installAnother) { + currentServerId = undefined; + continue; + } + wizard.cleanup(); + process.exit(0); + } + + // Create operation ID for tracking + const operationId = generateOperationId(); + const progressTracker = getProgressTrackingService(); + + // Helper function to show step indicator + const showStepIndicator = (currentStep: number, skipClear = false) => { + if (!skipClear) { + console.clear(); + } + const steps = ['Search', 'Select', 'Configure', 'Confirm', 'Install']; + const stepBar = steps + .map((step, index) => { + const num = index + 1; + if (num < currentStep) { + return chalk.green(`✓ ${step}`); + } else if (num === currentStep) { + return chalk.cyan.bold(`► ${step}`); + } else { + return chalk.gray(`○ ${step}`); + } + }) + .join(' → '); + console.log(boxen(stepBar, { padding: { left: 2, right: 2, top: 0, bottom: 0 }, borderStyle: 'round' })); + console.log(''); + }; + + // Show Install step indicator (clear screen before starting) + showStepIndicator(5, false); + + // Start progress tracking + progressTracker.startOperation(operationId, 'install', 5); + + try { + // Get installation service + const installationService = createServerInstallationService(); + + // Update progress: Validating + console.log(chalk.cyan('⏳ Validating server...')); + progressTracker.updateProgress(operationId, 1, 'Validating server', `Checking registry for ${serverName}`); + + // Create backup if replacing existing server + let backupPath: string | undefined; + if (serverAlreadyExists) { + console.log(chalk.cyan('⏳ Creating backup...')); + progressTracker.updateProgress(operationId, 2, 'Creating backup', `Backing up existing configuration`); + backupPath = backupConfig(); + console.log(chalk.gray(` Backup created: ${backupPath}`)); + logger.info(`Backup created: ${backupPath}`); + + // Remove the existing server before reinstalling to prevent duplicates + const { removeServer } = await import('./utils/configUtils.js'); + const removed = removeServer(serverName); + if (removed) { + console.log(chalk.gray(` Removed existing server '${serverName}'`)); + if (verbose) { + logger.info(`Removed existing server '${serverName}' before reinstalling`); + } + } + } + + // Update progress: Installing + console.log(chalk.cyan(`⏳ Installing ${serverName}${version ? `@${version}` : ''}...`)); + progressTracker.updateProgress( + operationId, + 3, + 'Installing server', + `Installing ${serverName}${version ? `@${version}` : ''}`, + ); + + // Perform installation + const result = await installationService.installServer(registryServerId, version, { + force: shouldForce, + verbose, + localServerName: serverName, + tags: wizardResult.tags, + env: wizardResult.env, + args: wizardResult.args, + }); + + // Update progress: Finalizing + console.log(chalk.cyan('⏳ Finalizing...')); + progressTracker.updateProgress(operationId, 4, 'Finalizing', 'Verifying configuration'); + + // Update progress: Reloading + console.log(chalk.cyan('⏳ Reloading configuration...')); + progressTracker.updateProgress(operationId, 5, 'Reloading configuration', 'Applying changes'); + + // Reload MCP configuration + reloadMcpConfig(); + + // Complete the operation + const duration = result.installedAt ? Date.now() - result.installedAt.getTime() : 0; + progressTracker.completeOperation(operationId, { + success: true, + operationId, + duration, + message: `Successfully installed ${serverName}`, + }); + + // Show completed step indicator with all steps marked as done (don't clear logs) + console.log(''); + showStepIndicator(6, true); // 6 means all steps are completed, true = skip clear + + // Report success + console.log( + chalk.green.bold(`✅ Successfully installed server '${serverName}'${version ? ` version ${version}` : ''}`), + ); + if (backupPath) { + console.log(chalk.gray(`📁 Backup created: ${backupPath}`)); + } + if (result.warnings.length > 0) { + console.log(chalk.yellow('\n⚠️ Warnings:')); + result.warnings.forEach((warning) => console.log(chalk.yellow(` • ${warning}`))); + } + } catch (error) { + progressTracker.failOperation(operationId, error as Error); + throw error; + } + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + console.error(`\n❌ Installation failed: ${errorMessage}\n`); + if (error instanceof Error && error.stack) { + logger.error('Installation error stack:', error.stack); + } + + if (wizardResult.installAnother) { + const continueAfterError = await wizard.run(undefined, getExistingNames()); + if (continueAfterError.cancelled) { + wizard.cleanup(); + process.exit(1); + } + currentServerId = undefined; + continue; + } + + wizard.cleanup(); + process.exit(1); + } + + // Check if user wants to install another + if (wizardResult.installAnother) { + currentServerId = undefined; + continueInstalling = true; + } else { + continueInstalling = false; + } + } + } finally { + // Always cleanup wizard resources + wizard.cleanup(); + } + + // Explicitly exit after successful completion to prevent hanging + // This ensures stdin doesn't keep the process alive + process.exit(0); +} diff --git a/src/commands/mcp/list.test.ts b/src/commands/mcp/list.test.ts new file mode 100644 index 00000000..294e2f8a --- /dev/null +++ b/src/commands/mcp/list.test.ts @@ -0,0 +1,58 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { buildListCommand, listCommand } from './list.js'; + +// Mock dependencies +vi.mock('./utils/configUtils.js', () => ({ + initializeConfigContext: vi.fn(), + getAllServers: vi.fn(() => ({})), + validateConfigPath: vi.fn(), + parseTags: vi.fn((tags) => tags.split(',')), +})); + +vi.mock('./utils/serverUtils.js', () => ({ + calculateServerStatus: vi.fn(() => 'installed'), +})); + +vi.mock('./utils/validation.js', () => ({ + validateTags: vi.fn(), +})); + +const consoleLogMock = vi.fn(); +console.log = consoleLogMock; +const consoleErrorMock = vi.fn(); +console.error = consoleErrorMock; + +describe('List Command', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('buildListCommand', () => { + it('should configure command with correct options', () => { + const yargsMock = { + option: vi.fn().mockReturnThis(), + example: vi.fn().mockReturnThis(), + }; + + buildListCommand(yargsMock as any); + + expect(yargsMock.option).toHaveBeenCalledWith('show-disabled', expect.anything()); + expect(yargsMock.option).toHaveBeenCalledWith('outdated', expect.anything()); + }); + }); + + describe('listCommand', () => { + it('should handle empty server list', async () => { + const args = { + 'show-disabled': false, + 'show-secrets': false, + verbose: false, + }; + + await listCommand(args as any); + + expect(consoleLogMock).toHaveBeenCalledWith('No MCP servers are configured.'); + }); + }); +}); diff --git a/src/commands/mcp/list.ts b/src/commands/mcp/list.ts index 8f32b72c..6b30e068 100644 --- a/src/commands/mcp/list.ts +++ b/src/commands/mcp/list.ts @@ -1,4 +1,5 @@ import { MCPServerParams } from '@src/core/types/index.js'; +import { createServerInstallationService } from '@src/domains/server-management/serverInstallationService.js'; import { GlobalOptions } from '@src/globalOptions.js'; import { inferTransportType } from '@src/transport/transportFactory.js'; import { @@ -11,6 +12,7 @@ import { import type { Argv } from 'yargs'; import { getAllServers, initializeConfigContext, parseTags, validateConfigPath } from './utils/configUtils.js'; +import { calculateServerStatus } from './utils/serverUtils.js'; import { validateTags } from './utils/validation.js'; export interface ListCommandArgs extends GlobalOptions { @@ -18,6 +20,7 @@ export interface ListCommandArgs extends GlobalOptions { 'show-secrets'?: boolean; tags?: string; verbose?: boolean; + outdated?: boolean; } /** @@ -46,11 +49,17 @@ export function buildListCommand(yargs: Argv) { default: false, alias: 'v', }) + .option('outdated', { + describe: 'Show only servers with available updates', + type: 'boolean', + default: false, + }) .example([ ['$0 mcp list', 'List all enabled servers'], ['$0 mcp list --show-disabled', 'List all servers including disabled'], ['$0 mcp list --show-secrets', 'Show sensitive values in verbose output'], ['$0 mcp list --tags=prod,api', 'List servers with specific tags'], + ['$0 mcp list --outdated', 'List only servers with available updates'], ['$0 mcp list --verbose', 'List servers with detailed config'], ]); } @@ -67,6 +76,7 @@ export async function listCommand(argv: ListCommandArgs): Promise { 'show-secrets': showSecrets = false, tags: tagsFilter, verbose = false, + outdated = false, } = argv; // Initialize config context with CLI options @@ -89,8 +99,12 @@ export async function listCommand(argv: ListCommandArgs): Promise { return; } - // Filter servers - const filteredServers = filterServers(allServers, showDisabled, tagsFilter); + // Filter servers (apply outdated filter if requested) + let filteredServers = filterServers(allServers, showDisabled, tagsFilter); + + if (outdated) { + filteredServers = await getOutdatedServers(filteredServers); + } if (Object.keys(filteredServers).length === 0) { if (tagsFilter) { @@ -150,6 +164,36 @@ export async function listCommand(argv: ListCommandArgs): Promise { } } +/** + * Filter servers to show only those with available updates + */ +async function getOutdatedServers(servers: Record): Promise> { + const outdated: Record = {}; + + // Create service to check for updates + const installationService = createServerInstallationService(); + + // Get list of server names + const serverNames = Object.keys(servers); + + // Check for updates for all servers + const updateResults = await installationService.checkForUpdates(serverNames); + + // Filter to only servers with available updates + const serversWithUpdates = updateResults + .filter((result) => result.hasUpdate || result.updateAvailable) + .map((result) => result.serverName); + + // Return only outdated servers + for (const serverName of serversWithUpdates) { + if (servers[serverName]) { + outdated[serverName] = servers[serverName]; + } + } + + return outdated; +} + /** * Filter servers based on criteria */ @@ -194,12 +238,27 @@ function displayServer(name: string, config: MCPServerParams, verbose: boolean, const statusIcon = config.disabled ? '🔴' : '🟢'; const statusText = config.disabled ? 'Disabled' : 'Enabled'; + // Get installation metadata + const version = 'unknown'; + + // Calculate server status + const serverStatus = calculateServerStatus(version); + // Infer type if missing const inferredConfig = config.type ? config : inferTransportType(config, name); const displayType = inferredConfig.type || 'unknown'; + // Display with installation metadata console.log(`${statusIcon} ${name} (${statusText})`); console.log(` Type: ${displayType}`); + console.log(` Version: ${version}`); + + // Show status if outdated + if (serverStatus === 'outdated') { + console.log(` Status: ⚠️ Update available`); + } + + // Installation date would be shown here when metadata storage is fully implemented // Type-specific information if (inferredConfig.type === 'stdio') { diff --git a/src/commands/mcp/uninstall.test.ts b/src/commands/mcp/uninstall.test.ts new file mode 100644 index 00000000..f747245b --- /dev/null +++ b/src/commands/mcp/uninstall.test.ts @@ -0,0 +1,84 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import * as configUtils from './utils/configUtils.js'; +import * as serverUtils from './utils/serverUtils.js'; +import { buildUninstallCommand, uninstallCommand } from './uninstall.js'; + +// Mock dependencies +vi.mock('./utils/configUtils.js', () => ({ + initializeConfigContext: vi.fn(), + serverExists: vi.fn(), + backupConfig: vi.fn(() => '/tmp/config.backup'), + removeServer: vi.fn(() => true), + reloadMcpConfig: vi.fn(), +})); + +vi.mock('./utils/serverUtils.js', () => ({ + checkServerInUse: vi.fn(() => false), + validateServerName: vi.fn(), +})); + +vi.mock('@src/logger/logger.js', () => ({ + default: { + info: vi.fn(), + error: vi.fn(), + }, +})); + +const consoleLogMock = vi.fn(); +console.log = consoleLogMock; + +describe('Uninstall Command', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(configUtils.serverExists as any).mockReturnValue(true); + vi.mocked(serverUtils.checkServerInUse as any).mockReturnValue(false); + }); + + describe('buildUninstallCommand', () => { + it('should configure command with correct options', () => { + const yargsMock = { + positional: vi.fn().mockReturnThis(), + option: vi.fn().mockReturnThis(), + example: vi.fn().mockReturnThis(), + }; + + buildUninstallCommand(yargsMock as any); + + expect(yargsMock.positional).toHaveBeenCalledWith('serverName', expect.anything()); + expect(yargsMock.option).toHaveBeenCalledWith('force', expect.anything()); + expect(yargsMock.option).toHaveBeenCalledWith('backup', expect.anything()); + }); + }); + + describe('uninstallCommand', () => { + it('should throw when server does not exist', async () => { + vi.mocked(configUtils.serverExists as any).mockReturnValue(false); + const args = { serverName: 'missing', force: false, backup: true }; + await expect(uninstallCommand(args as any)).rejects.toThrow(/does not exist/); + }); + + it('should block uninstall when in use and not forced', async () => { + vi.mocked(serverUtils.checkServerInUse as any).mockReturnValue(true); + const args = { serverName: 'inuse', force: false, backup: true }; + await expect(uninstallCommand(args as any)).rejects.toThrow(/Server is in use/); + expect((configUtils.removeServer as any).mock.calls.length).toBe(0); + }); + + it('should remove config and reload when allowed', async () => { + const args = { serverName: 'ok', force: true, backup: true, 'remove-config': true }; + await uninstallCommand(args as any); + expect((configUtils.backupConfig as any).mock.calls.length).toBeGreaterThan(0); + expect(configUtils.removeServer).toHaveBeenCalledWith('ok'); + expect((configUtils.reloadMcpConfig as any).mock.calls.length).toBeGreaterThan(0); + expect(consoleLogMock).toHaveBeenCalledWith(expect.stringMatching(/Successfully uninstalled/)); + }); + + it('should skip config removal when --no-remove-config', async () => { + const args = { serverName: 'ok', force: true, backup: false, 'remove-config': false } as any; + await uninstallCommand(args); + expect((configUtils.removeServer as any).mock.calls.length).toBe(0); + expect(consoleLogMock).toHaveBeenCalledWith(expect.stringMatching(/not removed/)); + }); + }); +}); diff --git a/src/commands/mcp/uninstall.ts b/src/commands/mcp/uninstall.ts new file mode 100644 index 00000000..30ff40c8 --- /dev/null +++ b/src/commands/mcp/uninstall.ts @@ -0,0 +1,140 @@ +import { GlobalOptions } from '@src/globalOptions.js'; +import logger from '@src/logger/logger.js'; + +import type { Argv } from 'yargs'; + +import { + backupConfig, + initializeConfigContext, + reloadMcpConfig, + removeServer, + serverExists, +} from './utils/configUtils.js'; +import { checkServerInUse, validateServerName } from './utils/serverUtils.js'; + +export interface UninstallCommandArgs extends GlobalOptions { + serverName: string; + force?: boolean; + backup?: boolean; + 'remove-config'?: boolean; + verbose?: boolean; +} + +/** + * Build the uninstall command configuration + */ +export function buildUninstallCommand(yargs: Argv) { + return yargs + .positional('serverName', { + describe: 'Server name to uninstall', + type: 'string', + demandOption: true, + }) + .option('force', { + describe: 'Skip confirmation prompts', + type: 'boolean', + default: false, + }) + .option('backup', { + describe: 'Create backup before removal (default: true)', + type: 'boolean', + default: true, + }) + .option('remove-config', { + describe: 'Remove server configuration (default: true)', + type: 'boolean', + default: true, + }) + .option('verbose', { + describe: 'Detailed output', + type: 'boolean', + default: false, + alias: 'v', + }) + .example([ + ['$0 mcp uninstall filesystem', 'Uninstall filesystem server'], + ['$0 mcp uninstall filesystem --force', 'Uninstall without confirmation'], + ['$0 mcp uninstall filesystem --no-backup', 'Uninstall without creating backup'], + ]); +} + +/** + * Uninstall command handler + */ +export async function uninstallCommand(argv: UninstallCommandArgs): Promise { + try { + const { + serverName, + config: configPath, + 'config-dir': configDir, + force = false, + backup: createBackup = true, + 'remove-config': removeConfig = true, + verbose = false, + } = argv; + + // Initialize configuration context + initializeConfigContext(configPath, configDir); + + if (verbose) { + logger.info('Starting uninstall process...'); + } + + // Validate server name + validateServerName(serverName); + + // Check if server exists + if (!serverExists(serverName)) { + throw new Error(`Server '${serverName}' does not exist in the configuration.`); + } + + // Check if server is in use + const inUse = checkServerInUse(serverName); + + if (inUse && !force) { + console.log(`⚠️ Warning: Server '${serverName}' appears to be currently in use.`); + console.log('Use --force to uninstall anyway.'); + throw new Error('Server is in use. Use --force to override.'); + } + + // Create backup if requested + let backupPath: string | undefined; + if (createBackup) { + if (verbose) { + logger.info('Creating backup before uninstall...'); + } + backupPath = backupConfig(); + logger.info(`Backup created: ${backupPath}`); + } + + // Remove server configuration if requested + if (removeConfig) { + if (verbose) { + logger.info(`Removing server configuration for '${serverName}'...`); + } + + const removed = removeServer(serverName); + if (!removed) { + throw new Error(`Failed to remove server '${serverName}' from configuration.`); + } + + // Reload MCP configuration + reloadMcpConfig(); + + console.log(`\n✅ Successfully uninstalled server '${serverName}'`); + + if (backupPath) { + console.log(`📁 Backup created: ${backupPath}`); + } + } else { + console.log(`\n⚠️ Server configuration not removed (--no-remove-config specified)`); + } + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + console.error(`\n❌ Uninstall failed: ${errorMessage}\n`); + if (error instanceof Error && error.stack) { + logger.error('Uninstall error stack:', error.stack); + } + throw error; + } +} diff --git a/src/commands/mcp/update.ts b/src/commands/mcp/update.ts index e5af2f05..3dc88e15 100644 --- a/src/commands/mcp/update.ts +++ b/src/commands/mcp/update.ts @@ -25,7 +25,9 @@ import { } from './utils/validation.js'; export interface UpdateCommandArgs extends GlobalOptions { - name: string; + name?: string; + all?: boolean; + exclude?: string[]; type?: string; // Will be validated as 'stdio' | 'http' | 'sse' command?: string; args?: string[]; @@ -38,6 +40,9 @@ export interface UpdateCommandArgs extends GlobalOptions { restartOnExit?: boolean; maxRestarts?: number; restartDelay?: number; + backup?: boolean; + 'check-only'?: boolean; + dryRun?: boolean; } /** @@ -105,11 +110,38 @@ export function buildUpdateCommand(yargs: Argv) { describe: 'Delay in milliseconds between restart attempts (stdio only, default: 1000)', type: 'number', }) + .option('all', { + describe: 'Update all outdated servers', + type: 'boolean', + default: false, + }) + .option('exclude', { + describe: 'Exclude servers from batch update', + type: 'array', + string: true, + }) + .option('backup', { + describe: 'Create backup before update (default: true)', + type: 'boolean', + default: true, + }) + .option('check-only', { + describe: 'Check for updates without installing', + type: 'boolean', + default: false, + }) + .option('dry-run', { + describe: 'Show what would be updated', + type: 'boolean', + default: false, + }) .example([ ['$0 mcp update myserver --tags=prod,api', 'Update server tags'], ['$0 mcp update myserver --env=NODE_ENV=production', 'Update environment'], ['$0 mcp update myserver -- npx -y updated-package', 'Update using " -- " pattern'], ['$0 mcp update myserver --timeout=10000', 'Update timeout'], + ['$0 mcp update --all', 'Update all outdated servers'], + ['$0 mcp update --all --exclude=server1,server2', 'Update all except excluded'], ]); } @@ -120,6 +152,11 @@ export async function updateCommand(argv: UpdateCommandArgs): Promise { try { const { name, config: configPath, 'config-dir': configDir } = argv; + // Validate that name is provided + if (!name) { + throw new Error('Server name is required'); + } + // Initialize config context with CLI options initializeConfigContext(configPath, configDir); @@ -315,6 +352,6 @@ export async function updateCommand(argv: UpdateCommandArgs): Promise { console.log(`\n💡 Server configuration updated. If 1mcp is running, the server will be reloaded automatically.`); } catch (error) { console.error(`❌ Failed to update server: ${error instanceof Error ? error.message : error}`); - process.exit(1); + throw error; } } diff --git a/src/commands/mcp/utils/configUtils.ts b/src/commands/mcp/utils/configUtils.ts index ce03e597..d4b337c6 100644 --- a/src/commands/mcp/utils/configUtils.ts +++ b/src/commands/mcp/utils/configUtils.ts @@ -258,6 +258,31 @@ export function parseTags(tagsString?: string): string[] { .filter((tag) => tag.length > 0); } +/** + * Get installation metadata for a server + * Returns metadata about when and how the server was installed + */ +export function getInstallationMetadata(serverName: string): { + installedAt?: Date; + installedBy?: string; + version?: string; +} | null { + try { + const server = getServer(serverName); + if (!server) { + return null; + } + + // In future, store metadata in config or separate metadata file + // For now, return basic info + return { + version: (server as MCPServerParams & { version?: string }).version, + }; + } catch (_error) { + return null; + } +} + /** * Validate configuration file path */ diff --git a/src/commands/mcp/utils/installWizard.test.ts b/src/commands/mcp/utils/installWizard.test.ts new file mode 100644 index 00000000..9ed4b859 --- /dev/null +++ b/src/commands/mcp/utils/installWizard.test.ts @@ -0,0 +1,185 @@ +import { MCPRegistryClient } from '@src/domains/registry/mcpRegistryClient.js'; +import { RegistryServer } from '@src/domains/registry/types.js'; + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { InstallWizard } from './installWizard.js'; + +describe('InstallWizard', () => { + let wizard: InstallWizard; + let mockRegistryClient: MCPRegistryClient; + + beforeEach(() => { + // Mock registry client + mockRegistryClient = { + searchServers: vi.fn(), + getServerById: vi.fn(), + } as unknown as MCPRegistryClient; + + wizard = new InstallWizard(mockRegistryClient); + + // Spy on console + vi.spyOn(console, 'log').mockImplementation(() => {}); + vi.spyOn(console, 'error').mockImplementation(() => {}); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('WizardInstallConfig', () => { + it('should define the correct interface structure', () => { + const config = { + serverId: 'test-server', + version: '1.0.0', + localName: 'local-test', + tags: ['tag1', 'tag2'], + env: { KEY: 'value' }, + args: ['--arg1'], + installAnother: false, + cancelled: false, + }; + + expect(config.serverId).toBe('test-server'); + expect(config.version).toBe('1.0.0'); + expect(config.localName).toBe('local-test'); + expect(config.tags).toEqual(['tag1', 'tag2']); + expect(config.env).toEqual({ KEY: 'value' }); + expect(config.args).toEqual(['--arg1']); + expect(config.installAnother).toBe(false); + expect(config.cancelled).toBe(false); + }); + }); + + describe('constructor', () => { + it('should create wizard with registry client', () => { + expect(wizard).toBeDefined(); + expect(wizard).toBeInstanceOf(InstallWizard); + }); + }); + + describe('cancelled result', () => { + it('should create proper cancelled result structure', () => { + const result = { + serverId: '', + version: undefined, + localName: undefined, + tags: undefined, + env: undefined, + args: undefined, + cancelled: true, + installAnother: false, + }; + + expect(result.cancelled).toBe(true); + expect(result.serverId).toBe(''); + expect(result.installAnother).toBe(false); + }); + }); + + describe('showWelcome', () => { + it('should display welcome screen', () => { + // Wizard is initialized and console spies are set up + expect(wizard).toBeDefined(); + }); + }); + + describe('RegistryServer integration', () => { + it('should handle server with all optional fields', () => { + const server: RegistryServer = { + name: 'test-server', + description: 'Test server description', + status: 'active', + version: '1.0.0', + repository: { + url: 'https://github.com/test/repo', + source: 'github', + }, + websiteUrl: 'https://example.com', + _meta: { + 'io.modelcontextprotocol.registry/official': { + isLatest: true, + publishedAt: new Date().toISOString(), + status: 'active', + updatedAt: new Date().toISOString(), + }, + }, + }; + + expect(server.name).toBe('test-server'); + expect(server.websiteUrl).toBe('https://example.com'); + expect(server.repository.url).toBe('https://github.com/test/repo'); + }); + + it('should handle server with minimal fields', () => { + const server: RegistryServer = { + name: 'minimal-server', + description: 'Minimal description', + status: 'active', + version: '0.1.0', + repository: { + url: 'https://github.com/test/minimal', + source: 'github', + }, + _meta: { + 'io.modelcontextprotocol.registry/official': { + isLatest: true, + publishedAt: new Date().toISOString(), + status: 'active', + updatedAt: new Date().toISOString(), + }, + }, + }; + + expect(server.name).toBe('minimal-server'); + expect(server.websiteUrl).toBeUndefined(); + }); + }); + + describe('Configuration parsing', () => { + it('should parse tags from comma-separated string', () => { + const input = 'tag1, tag2, tag3'; + const tags = input.split(',').map((t: string) => t.trim()); + + expect(tags).toEqual(['tag1', 'tag2', 'tag3']); + }); + + it('should parse environment variables from JSON', () => { + const input = '{"KEY1":"value1","KEY2":"value2"}'; + const env = JSON.parse(input); + + expect(env).toEqual({ KEY1: 'value1', KEY2: 'value2' }); + }); + + it('should parse arguments from comma-separated string', () => { + const input = '--arg1, --arg2, --arg3'; + const args = input.split(',').map((a: string) => a.trim()); + + expect(args).toEqual(['--arg1', '--arg2', '--arg3']); + }); + + it('should handle empty configuration values', () => { + const emptyTags = ''; + const emptyEnv = '{}'; + const emptyArgs = ''; + + expect(emptyTags.trim()).toBe(''); + expect(JSON.parse(emptyEnv)).toEqual({}); + expect(emptyArgs.trim()).toBe(''); + }); + }); + + describe('Cancelled result helper', () => { + it('should create proper cancelled result structure', () => { + const result = { + serverId: '', + cancelled: true, + installAnother: false, + }; + + expect(result.cancelled).toBe(true); + expect(result.serverId).toBe(''); + expect(result.installAnother).toBe(false); + }); + }); +}); diff --git a/src/commands/mcp/utils/installWizard.ts b/src/commands/mcp/utils/installWizard.ts new file mode 100644 index 00000000..8a44f99c --- /dev/null +++ b/src/commands/mcp/utils/installWizard.ts @@ -0,0 +1,1205 @@ +import { MCPRegistryClient } from '@src/domains/registry/mcpRegistryClient.js'; +import { RegistryServer } from '@src/domains/registry/types.js'; +import logger from '@src/logger/logger.js'; + +import boxen from 'boxen'; +import chalk from 'chalk'; +import prompts from 'prompts'; + +/** + * Configuration result from the interactive wizard + */ +export interface WizardInstallConfig { + serverId: string; + version?: string; + localName?: string; + tags?: string[]; + env?: Record; + args?: string[]; + installAnother: boolean; + cancelled: boolean; + forceOverride?: boolean; +} + +/** + * Interactive installation wizard for MCP servers + */ +export class InstallWizard { + private registryClient: MCPRegistryClient; + + constructor(registryClient: MCPRegistryClient) { + this.registryClient = registryClient; + } + + /** + * Run the complete interactive installation wizard + */ + async run(initialServerId?: string, existingServerNames: string[] = []): Promise { + try { + // Show welcome screen with controls + this.showWelcome(); + + let serverId = initialServerId; + let selectedServer: RegistryServer | undefined; + + // Step 1: Get server (search or use provided ID) + if (!serverId) { + const searchResult = await this.searchServers(); + if (searchResult.cancelled) { + return this.cancelledResult(); + } + serverId = searchResult.serverId; + selectedServer = searchResult.server; + } else { + // Confirm provided server ID + const confirmed = await this.confirmServerId(serverId); + if (!confirmed) { + return this.cancelledResult(); + } + // Fetch server details + try { + selectedServer = await this.registryClient.getServerById(serverId); + } catch (error) { + logger.error('Failed to fetch server details', { serverId, error }); + console.log( + boxen(chalk.red.bold(`❌ Server '${serverId}' not found in registry`), { + padding: 1, + borderStyle: 'round', + borderColor: 'red', + }), + ); + return this.cancelledResult(); + } + } + + if (!selectedServer) { + return this.cancelledResult(); + } + + // Step 2: Configuration prompts + let config = await this.collectConfiguration(selectedServer, existingServerNames); + if (config.cancelled) { + return this.cancelledResult(); + } + + // Step 3: Confirmation summary (with back navigation) + while (true) { + const confirmed = await this.showConfirmation(selectedServer, config); + + if (confirmed === 'back') { + // Go back to configuration step + const newConfig = await this.collectConfiguration(selectedServer, existingServerNames); + if (newConfig.cancelled) { + return this.cancelledResult(); + } + config = newConfig; + continue; // Show confirmation again with new config + } + + if (!confirmed) { + return this.cancelledResult(); + } + + // Confirmed - proceed to next step + break; + } + + // Step 4: Ask to install another + const installAnother = await this.askInstallAnother(); + + // If not installing another, cleanup now + if (!installAnother) { + this.cleanup(); + } + + return { + serverId: selectedServer.name, + version: config.version, + localName: config.localName, + tags: config.tags, + env: config.env, + args: config.args, + installAnother, + cancelled: false, + forceOverride: config.forceOverride, + }; + } catch (error) { + logger.error('Wizard failed', { error }); + console.log( + boxen(chalk.red.bold('❌ Installation wizard failed - see logs for details'), { + padding: 1, + borderStyle: 'round', + borderColor: 'red', + }), + ); + this.cleanup(); + return this.cancelledResult(); + } + } + + /** + * Show welcome screen with key bindings + */ + private showWelcome(): void { + const welcomeContent = + chalk.magenta.bold('🚀 MCP Server Installation Wizard\n\n') + + chalk.yellow('This wizard will guide you through installing an MCP server.\n\n') + + chalk.cyan.bold('Navigation Keys:\n') + + chalk.gray(' ↑/↓ - Navigate options\n') + + chalk.gray(' ← - Go back\n') + + chalk.gray(' → - View details\n') + + chalk.gray(' Tab - Next step\n') + + chalk.gray(' Enter - Confirm\n') + + chalk.gray(' Ctrl+C - Cancel'); + + console.log( + boxen(welcomeContent, { + padding: 1, + margin: 1, + borderStyle: 'double', + borderColor: 'cyan', + title: 'Install Wizard', + titleAlignment: 'center', + }), + ); + } + + /** + * Show step indicator + */ + private showStepIndicator(currentStep: number, _totalSteps: number, _stepName: string): void { + const steps = ['Search', 'Select', 'Configure', 'Confirm', 'Install']; + const stepBar = steps + .map((step, index) => { + const num = index + 1; + if (num < currentStep) { + return chalk.green(`✓ ${step}`); + } else if (num === currentStep) { + return chalk.cyan.bold(`► ${step}`); + } else { + return chalk.gray(`○ ${step}`); + } + }) + .join(chalk.gray(' → ')); + + console.log( + boxen(stepBar, { + padding: { left: 2, right: 2, top: 0, bottom: 0 }, + borderStyle: 'single', + borderColor: 'gray', + }), + ); + console.log(''); + } + + /** + * Search for servers interactively + */ + private async searchServers(): Promise<{ + serverId: string; + server?: RegistryServer; + cancelled: boolean; + }> { + let searchTerm = ''; + let searchResults: RegistryServer[] = []; + + while (true) { + // Show step indicator + console.clear(); + this.showStepIndicator(1, 5, 'Search'); + + // Get search term + const searchInput = await prompts({ + type: 'text', + name: 'query', + message: 'Enter server name to search:', + initial: searchTerm, + validate: (value: string) => { + if (!value || typeof value !== 'string' || value.trim().length === 0) { + return 'Search term cannot be empty'; + } + return true; + }, + }); + + if (!searchInput.query || searchInput.query === '') { + return { serverId: '', cancelled: true }; + } + + searchTerm = String(searchInput.query).trim(); + + // Perform search + console.log(chalk.cyan(`\n🔍 Searching for "${searchTerm}"...\n`)); + + try { + searchResults = await this.registryClient.searchServers({ + query: searchTerm, + limit: 20, + }); + + if (searchResults.length === 0) { + console.log( + boxen(chalk.yellow.bold('⚠️ No servers found matching your search'), { + padding: 1, + borderStyle: 'round', + borderColor: 'yellow', + }), + ); + + const retry = await prompts({ + type: 'confirm', + name: 'continue', + message: 'Try another search?', + initial: true, + }); + + if (!retry.continue) { + return { serverId: '', cancelled: true }; + } + continue; + } + + // Show results and let user select + const selection = await this.selectFromResults(searchResults); + if (selection.cancelled) { + return { serverId: '', cancelled: true }; + } + if (selection.goBack) { + continue; // Refine search + } + + return { + serverId: selection.server!.name, + server: selection.server, + cancelled: false, + }; + } catch (error) { + logger.error('Search failed', { searchTerm, error }); + console.log( + boxen(chalk.red.bold('❌ Search failed - please try again'), { + padding: 1, + borderStyle: 'round', + borderColor: 'red', + }), + ); + + const retry = await prompts({ + type: 'confirm', + name: 'continue', + message: 'Try again?', + initial: true, + }); + + if (!retry.continue) { + return { serverId: '', cancelled: true }; + } + } + } + } + + /** + * Select a server from search results with arrow key navigation + */ + private async selectFromResults( + results: RegistryServer[], + ): Promise<{ server?: RegistryServer; goBack: boolean; cancelled: boolean }> { + let currentIndex = 0; + let showingDetails = false; + + while (true) { + console.clear(); + + if (showingDetails) { + // Show detail view + await this.showServerDetails(results[currentIndex]); + showingDetails = false; + continue; + } + + // Show results list + this.renderResultsList(results, currentIndex); + + // Get key input + const action = await this.getKeyInput(); + + switch (action) { + case 'up': + currentIndex = Math.max(0, currentIndex - 1); + break; + + case 'down': + currentIndex = Math.min(results.length - 1, currentIndex + 1); + break; + + case 'right': + showingDetails = true; + break; + + case 'left': + return { goBack: true, cancelled: false }; + + case 'enter': + return { server: results[currentIndex], goBack: false, cancelled: false }; + + case 'escape': + return { cancelled: true, goBack: false }; + } + } + } + + /** + * Render the results list with highlighted selection + */ + private renderResultsList(results: RegistryServer[], currentIndex: number): void { + this.showStepIndicator(2, 5, 'Select'); + + const header = boxen( + chalk.cyan.bold(`📦 Search Results (${results.length} found)\n\n`) + + chalk.gray('Controls: ↑↓ Navigate → Details Enter Select ← Back Esc Cancel'), + { + padding: 1, + borderStyle: 'double', + borderColor: 'cyan', + title: 'Select Server', + titleAlignment: 'center', + }, + ); + console.log(header); + + const listContent = results + .map((server, index) => { + const isSelected = index === currentIndex; + const cursor = isSelected ? chalk.yellow.bold('►') : ' '; + const nameStyle = isSelected ? chalk.bgGray.white.bold : chalk.white; + const description = server.description?.substring(0, 60) || 'No description'; + const descStyle = isSelected ? chalk.gray : chalk.dim; + + return `${cursor} ${nameStyle(server.name)}\n ${descStyle(description)}`; + }) + .join('\n\n'); + + console.log( + boxen(listContent, { + padding: 1, + borderStyle: 'round', + borderColor: 'blue', + }), + ); + } + + /** + * Show detailed information about a server + */ + private async showServerDetails(server: RegistryServer): Promise { + const content = + chalk.blue.bold(`📋 ${server.name}\n\n`) + + chalk.yellow.bold('Description:\n') + + chalk.white(`${server.description || 'No description available'}\n\n`) + + (server.websiteUrl ? chalk.yellow.bold('Website:\n') + chalk.cyan(`${server.websiteUrl}\n\n`) : '') + + (server.repository?.url ? chalk.yellow.bold('Repository:\n') + chalk.cyan(`${server.repository.url}\n\n`) : '') + + chalk.gray('Press any key to return...'); + + console.log( + boxen(content, { + padding: 1, + borderStyle: 'round', + borderColor: 'blue', + title: '🔍 Server Details', + titleAlignment: 'center', + }), + ); + + await this.getKeyInput(); + } + + /** + * Confirm a provided server ID + */ + private async confirmServerId(serverId: string): Promise { + const result = await prompts({ + type: 'confirm', + name: 'confirmed', + message: `Install server '${chalk.cyan(serverId)}'?`, + initial: true, + }); + + return Boolean(result.confirmed); + } + + /** + * Collect configuration (tags, env, args) + */ + private async collectConfiguration( + server: RegistryServer, + existingNames: string[] = [], + ): Promise<{ + version?: string; + localName?: string; + tags?: string[]; + env?: Record; + args?: string[]; + cancelled: boolean; + goBack?: boolean; + forceOverride?: boolean; + }> { + console.clear(); + this.showStepIndicator(3, 5, 'Configure'); + + console.log( + boxen(chalk.magenta.bold(`⚙️ Configure: ${server.name}`), { + padding: 1, + borderStyle: 'round', + borderColor: 'magenta', + }), + ); + + // Derive auto-generated name + const autoGeneratedName = this.deriveLocalName(server.name); + + // Local name with conflict detection + let localName = autoGeneratedName; + let nameConflict = existingNames.includes(localName); + let forceOverride = false; + + while (true) { + const localNameInput = await prompts({ + type: 'text', + name: 'localName', + message: nameConflict + ? chalk.yellow(`⚠️ Server '${localName}' already exists. Enter a different name:`) + : `Local server name:`, + initial: localName, + }); + + if (localNameInput.localName === undefined) { + return { cancelled: true }; + } + + localName = String(localNameInput.localName).trim(); + + // Check for conflict + if (existingNames.includes(localName)) { + nameConflict = true; + + // Ask user what to do + const conflictAction = await prompts({ + type: 'select', + name: 'action', + message: `Server '${localName}' already exists. What would you like to do?`, + choices: [ + { title: 'Rename (enter a different name)', value: 'rename' }, + { title: 'Override (replace existing server)', value: 'override' }, + { title: 'Cancel installation', value: 'cancel' }, + ], + initial: 0, + }); + + if (conflictAction.action === 'cancel' || conflictAction.action === undefined) { + return { cancelled: true }; + } + + if (conflictAction.action === 'override') { + // User wants to override - set flag and break the loop + forceOverride = true; + break; + } + + // If 'rename', loop continues with nameConflict still true + continue; + } + + // No conflict, proceed + break; + } + + // Tags - default to server name + const defaultTags = autoGeneratedName; + const tagsInput = await prompts({ + type: 'text', + name: 'tags', + message: 'Tags (comma-separated):', + initial: defaultTags, + }); + + if (tagsInput.tags === undefined) { + return { cancelled: true }; + } + + // Configure environment variables interactively + const env = await this.configureEnvVars(server); + if (env === null) { + return { cancelled: true }; + } + + // Configure arguments interactively + const args = await this.configureArgs(server); + if (args === null) { + return { cancelled: true }; + } + + const tagsValue = String(tagsInput.tags || '').trim(); + const tags = tagsValue + ? tagsValue + .split(',') + .map((t: string) => t.trim()) + .filter((t: string) => t.length > 0) + : undefined; + + return { + localName: localName || undefined, + tags, + env: env || undefined, + args: args || undefined, + cancelled: false, + forceOverride, + }; + } + + /** + * Configure environment variables interactively + */ + private async configureEnvVars(server: RegistryServer): Promise | null> { + const envVarMetadata = this.extractEnvVarMetadata(server); + + if (envVarMetadata.length === 0) { + // No env vars defined, ask if user wants to add any manually + const addManual = await prompts({ + type: 'confirm', + name: 'add', + message: 'No environment variables defined. Add any manually?', + initial: false, + }); + + if (addManual.add === undefined) { + return null; + } + + if (!addManual.add) { + return {}; + } + + // Allow manual JSON input + const manualInput = await prompts({ + type: 'text', + name: 'env', + message: 'Environment variables (JSON):', + initial: '{}', + validate: (value: string) => { + try { + JSON.parse(value); + return true; + } catch { + return 'Invalid JSON format'; + } + }, + }); + + if (manualInput.env === undefined) { + return null; + } + + return JSON.parse(String(manualInput.env)) as Record; + } + + // Show summary of available env vars + console.log(chalk.cyan.bold('\n📋 Available Environment Variables:')); + console.log(chalk.gray(` Found ${envVarMetadata.length} environment variables\n`)); + + // Ask if user wants to configure any + const wantsToConfigure = await prompts({ + type: 'confirm', + name: 'value', + message: `Configure environment variables?`, + initial: envVarMetadata.some((v) => v.isRequired), + }); + + if (wantsToConfigure.value === undefined) { + return null; + } + + if (!wantsToConfigure.value) { + // Use defaults only for required vars + const env: Record = {}; + envVarMetadata.forEach((envVar) => { + if (envVar.default && envVar.isRequired) { + env[envVar.key] = envVar.default; + } + }); + return env; + } + + // Let user select which env vars to configure + const choices = envVarMetadata.map((envVar) => { + const required = envVar.isRequired ? chalk.red('*required') : ''; + const secret = envVar.isSecret ? chalk.yellow('🔒 ') : ''; + const title = `${secret}${envVar.key} ${required}`; + const description = envVar.description || ''; + return { + title, + description, + value: envVar.key, + selected: envVar.isRequired || false, // Pre-select required vars + }; + }); + + const selection = await prompts({ + type: 'multiselect', + name: 'selected', + message: 'Select environment variables to configure (use space to select, enter to confirm):', + choices, + hint: '- Space to select. Enter to submit', + instructions: false, + }); + + if (selection.selected === undefined) { + return null; + } + + const selectedKeys = selection.selected as string[]; + if (selectedKeys.length === 0) { + return {}; + } + + // Prompt for each selected env var + console.log(chalk.cyan.bold('\n📝 Configure Selected Variables:\n')); + const env: Record = {}; + + for (const key of selectedKeys) { + const envVar = envVarMetadata.find((v) => v.key === key); + if (!envVar) continue; + + const result = await prompts({ + type: envVar.isSecret ? 'password' : 'text', + name: 'value', + message: `${envVar.key}${envVar.isRequired ? chalk.red(' *') : ''}:${envVar.description ? `\n ${chalk.gray(envVar.description)}` : ''}`, + initial: envVar.default || '', + }); + + if (result.value === undefined) { + // User can skip by pressing Ctrl+C on individual fields + continue; + } + + const value = String(result.value).trim(); + if (value) { + env[envVar.key] = value; + } else if (envVar.isRequired) { + console.log(chalk.yellow(`⚠️ ${envVar.key} is required, using default or empty value`)); + env[envVar.key] = envVar.default || ''; + } + } + + return env; + } + + /** + * Configure runtime arguments interactively + */ + private async configureArgs(server: RegistryServer): Promise { + const argMetadata = this.extractArgMetadata(server); + + if (argMetadata.length === 0) { + // No args defined, ask if user wants to add any manually + const addManual = await prompts({ + type: 'confirm', + name: 'add', + message: 'No runtime arguments defined. Add any manually?', + initial: false, + }); + + if (addManual.add === undefined) { + return null; + } + + if (!addManual.add) { + return []; + } + + // Allow manual input + const manualInput = await prompts({ + type: 'text', + name: 'args', + message: 'Arguments (comma-separated):', + initial: '', + }); + + if (manualInput.args === undefined) { + return null; + } + + const argsValue = String(manualInput.args).trim(); + return argsValue + ? argsValue + .split(',') + .map((a: string) => a.trim()) + .filter((a: string) => a.length > 0) + : []; + } + + // Show summary of available args + console.log(chalk.cyan.bold('\n⚙️ Available Runtime Arguments:')); + console.log(chalk.gray(` Found ${argMetadata.length} runtime arguments\n`)); + + // Ask if user wants to configure any + const wantsToConfigure = await prompts({ + type: 'confirm', + name: 'value', + message: `Configure runtime arguments?`, + initial: argMetadata.some((a) => a.isRequired), + }); + + if (wantsToConfigure.value === undefined) { + return null; + } + + if (!wantsToConfigure.value) { + // Use defaults only for required args + return argMetadata.filter((a) => a.isRequired && a.default).map((a) => `${a.name}=${a.default}`); + } + + // Let user select which args to configure + const choices = argMetadata.map((arg) => { + const required = arg.isRequired ? chalk.red('*required') : ''; + const name = arg.name || 'argument'; + const title = `${name} ${required}`; + const description = arg.description || ''; + return { + title, + description, + value: arg.name || '', + selected: arg.isRequired || false, // Pre-select required args + }; + }); + + const selection = await prompts({ + type: 'multiselect', + name: 'selected', + message: 'Select runtime arguments to configure (use space to select, enter to confirm):', + choices, + hint: '- Space to select. Enter to submit', + instructions: false, + }); + + if (selection.selected === undefined) { + return null; + } + + const selectedNames = selection.selected as string[]; + if (selectedNames.length === 0) { + return []; + } + + // Prompt for each selected arg + console.log(chalk.cyan.bold('\n📝 Configure Selected Arguments:\n')); + const args: string[] = []; + + for (const name of selectedNames) { + const arg = argMetadata.find((a) => a.name === name); + if (!arg) continue; + + let result; + if (arg.choices && arg.choices.length > 0) { + result = await prompts({ + type: 'select', + name: 'value', + message: `${arg.name || 'Argument'}${arg.isRequired ? chalk.red(' *') : ''}:${arg.description ? `\n ${chalk.gray(arg.description)}` : ''}`, + choices: arg.choices.map((c) => ({ title: c, value: c })), + initial: arg.default ? arg.choices.indexOf(arg.default) : 0, + }); + } else { + result = await prompts({ + type: 'text', + name: 'value', + message: `${arg.name || 'Argument'}${arg.isRequired ? chalk.red(' *') : ''}:${arg.description ? `\n ${chalk.gray(arg.description)}` : ''}`, + initial: arg.default || '', + }); + } + + if (result.value === undefined) { + // User can skip by pressing Ctrl+C on individual fields + continue; + } + + const value = String(result.value).trim(); + if (value) { + // Format as name=value for CLI args + args.push(`${arg.name}=${value}`); + } else if (arg.isRequired && arg.default) { + console.log(chalk.yellow(`⚠️ ${arg.name} is required, using default value`)); + args.push(`${arg.name}=${arg.default}`); + } + } + + return args; + } + + /** + * Derive local server name from registry ID + */ + private deriveLocalName(registryId: string): string { + // Extract the last part after the slash, or use the full ID if no slash + const lastPart = registryId.includes('/') ? registryId.split('/').pop()! : registryId; + + // If it already starts with a letter and only contains valid chars, use it as-is + const localNameRegex = /^[a-zA-Z][a-zA-Z0-9_-]*$/; + if (localNameRegex.test(lastPart) && lastPart.length <= 50) { + return lastPart; + } + + // Otherwise, sanitize it + let sanitized = lastPart.replace(/[^a-zA-Z0-9_-]/g, '_'); + + // Ensure it starts with a letter + if (!/^[a-zA-Z]/.test(sanitized)) { + sanitized = `server_${sanitized}`; + } + + // Truncate to 50 characters if longer + if (sanitized.length > 50) { + sanitized = sanitized.substring(0, 50); + } + + // Ensure it's not empty after sanitization + if (sanitized.length === 0) { + sanitized = 'server'; + } + + return sanitized; + } + + /** + * Extract all environment variables with metadata from server + */ + private extractEnvVarMetadata(server: RegistryServer): Array<{ + key: string; + description?: string; + default?: string; + isRequired?: boolean; + isSecret?: boolean; + }> { + const envVars: Array<{ + key: string; + description?: string; + default?: string; + isRequired?: boolean; + isSecret?: boolean; + }> = []; + const seen = new Set(); + + if (server.packages && server.packages.length > 0) { + for (const pkg of server.packages) { + if (pkg.environmentVariables && Array.isArray(pkg.environmentVariables)) { + for (const envVar of pkg.environmentVariables) { + // Use 'name' or 'value' field for the environment variable key + const key = envVar.name || envVar.value; + if (key && !seen.has(key)) { + seen.add(key); + envVars.push({ + key, + description: envVar.description, + default: envVar.default, + isRequired: envVar.isRequired, + isSecret: envVar.isSecret, + }); + } + } + } + } + } + + return envVars; + } + + /** + * Extract default environment variables from server metadata + */ + private extractDefaultEnvVars(server: RegistryServer): Record { + const envVars: Record = {}; + + // Check packages for environment variables + if (server.packages && server.packages.length > 0) { + for (const pkg of server.packages) { + if (pkg.environmentVariables && Array.isArray(pkg.environmentVariables)) { + for (const envVar of pkg.environmentVariables) { + if (envVar.value) { + // Use the variable name from the value field or description + const key = envVar.value.toUpperCase().replace(/[^A-Z0-9_]/g, '_'); + envVars[key] = envVar.default || ''; + } + } + } + } + } + + return envVars; + } + + /** + * Extract all runtime arguments with metadata from server + */ + private extractArgMetadata(server: RegistryServer): Array<{ + name?: string; + description?: string; + default?: string; + isRequired?: boolean; + isSecret?: boolean; + type?: string; + choices?: string[]; + valueHint?: string; + }> { + const args: Array<{ + name?: string; + description?: string; + default?: string; + isRequired?: boolean; + isSecret?: boolean; + type?: string; + choices?: string[]; + valueHint?: string; + }> = []; + const seen = new Set(); + + if (server.packages && server.packages.length > 0) { + for (const pkg of server.packages) { + // Check both packageArguments and runtimeArguments + const argSources = [...(pkg.packageArguments || []), ...(pkg.runtimeArguments || [])]; + + for (const arg of argSources) { + const name = arg.name; + if (name && !seen.has(name)) { + seen.add(name); + args.push({ + name: arg.name, + description: arg.description, + default: arg.default, + isRequired: arg.isRequired, + isSecret: arg.isSecret, + type: arg.type, + choices: arg.choices, + valueHint: arg.valueHint, + }); + } + } + } + } + + return args; + } + + /** + * Extract default arguments from server metadata + */ + private extractDefaultArgs(server: RegistryServer): string[] { + const args: string[] = []; + + // Check packages for runtime arguments + if (server.packages && server.packages.length > 0) { + for (const pkg of server.packages) { + if (pkg.runtimeArguments && Array.isArray(pkg.runtimeArguments)) { + for (const arg of pkg.runtimeArguments) { + if (arg.default) { + args.push(arg.default); + } + } + } + } + } + + return args; + } + + /** + * Show confirmation summary + */ + private async showConfirmation( + server: RegistryServer, + config: { + version?: string; + localName?: string; + tags?: string[]; + env?: Record; + args?: string[]; + }, + ): Promise<'back' | boolean> { + console.clear(); + this.showStepIndicator(4, 5, 'Confirm'); + + let content = + chalk.green.bold('✅ Installation Summary\n\n') + + chalk.yellow('Server: ') + + chalk.cyan.bold(server.name) + + '\n' + + (config.version ? chalk.yellow('Version: ') + chalk.white(config.version) + '\n' : '') + + (config.localName ? chalk.yellow('Local Name: ') + chalk.white(config.localName) + '\n' : '') + + (config.tags && config.tags.length > 0 + ? chalk.yellow('Tags: ') + chalk.white(config.tags.join(', ')) + '\n' + : '') + + (config.env && Object.keys(config.env).length > 0 + ? chalk.yellow('Environment: ') + chalk.white(JSON.stringify(config.env, null, 2)) + '\n' + : '') + + (config.args && config.args.length > 0 + ? chalk.yellow('Arguments: ') + chalk.white(config.args.join(' ')) + '\n' + : ''); + + console.log( + boxen(content, { + padding: 1, + borderStyle: 'round', + borderColor: 'green', + title: '📋 Confirm Installation', + titleAlignment: 'center', + }), + ); + + console.log(chalk.gray('\nPress ← (left arrow) to go back, → (enter) to proceed, Ctrl+C to cancel\n')); + + // Use toggle to allow left/right arrow navigation + const result = await prompts({ + type: 'toggle', + name: 'confirmed', + message: 'Proceed with installation?', + initial: true, + active: 'yes', + inactive: 'go back', + }); + + // If user cancelled (Ctrl+C) + if (result.confirmed === undefined) { + return false; + } + + // If user selected "go back" (toggled to false) + if (result.confirmed === false) { + return 'back'; + } + + return true; + } + + /** + * Ask if user wants to install another server + */ + private async askInstallAnother(): Promise { + const result = await prompts( + { + type: 'confirm', + name: 'another', + message: 'Install another server?', + initial: false, + }, + { + onCancel: () => { + return false; + }, + }, + ); + + return Boolean(result.another); + } + + /** + * Cleanup resources + */ + public cleanup(): void { + // Ensure stdin is properly cleaned up + const stdin = process.stdin; + + try { + // Remove all listeners to prevent leaks + stdin.removeAllListeners('data'); + stdin.removeAllListeners('keypress'); + stdin.removeAllListeners('readable'); + stdin.removeAllListeners('end'); + + if (stdin.isTTY && stdin.setRawMode) { + stdin.setRawMode(false); + } + + // Pause stdin + stdin.pause(); + + // Destroy any pipes + if (stdin.unpipe) { + stdin.unpipe(); + } + + // Unref to allow process to exit even if stdin has pending operations + if (stdin.unref) { + stdin.unref(); + } + } catch { + // Ignore errors during cleanup - best effort + } + } + + /** + * Get single key input for navigation + */ + private async getKeyInput(): Promise { + return new Promise((resolve) => { + const stdin = process.stdin; + + // Ensure stdin is in the right mode + if (!stdin.isTTY) { + resolve('escape'); + return; + } + + try { + stdin.setRawMode(true); + stdin.resume(); + stdin.setEncoding('utf8'); + } catch (_error) { + resolve('escape'); + return; + } + + const onKeypress = (key: string | Buffer): void => { + try { + if (stdin.isTTY) { + stdin.setRawMode(false); + } + stdin.pause(); + stdin.removeListener('data', onKeypress); + } catch { + // Ignore cleanup errors + } + + let keyStr: string; + if (Buffer.isBuffer(key)) { + keyStr = key.toString('utf8'); + } else if (typeof key === 'string') { + keyStr = key; + } else { + keyStr = ''; + } + + // Handle escape sequences for arrow keys + if (keyStr === '\u001b[A') resolve('up'); + else if (keyStr === '\u001b[B') resolve('down'); + else if (keyStr === '\u001b[D') resolve('left'); + else if (keyStr === '\u001b[C') resolve('right'); + else if (keyStr === ' ') resolve('space'); + else if (keyStr === '\r' || keyStr === '\n') resolve('enter'); + else if (keyStr === '\u001b' || keyStr === '\u0003') { + // ESC or Ctrl+C - ensure cleanup before resolving + this.cleanup(); + resolve('escape'); + } else resolve('unknown'); + }; + + stdin.on('data', onKeypress); + }); + } + + /** + * Create a cancelled result + */ + private cancelledResult(): WizardInstallConfig { + return { + serverId: '', + cancelled: true, + installAnother: false, + }; + } +} diff --git a/src/commands/mcp/utils/serverUtils.ts b/src/commands/mcp/utils/serverUtils.ts new file mode 100644 index 00000000..f30eff1d --- /dev/null +++ b/src/commands/mcp/utils/serverUtils.ts @@ -0,0 +1,121 @@ +import { ServerManager } from '@src/core/server/serverManager.js'; +import { InstallationStatus } from '@src/domains/server-management/types.js'; +import logger from '@src/logger/logger.js'; + +/** + * Server utility functions for validation and management + */ + +/** + * Parse server name and version from input string + * Supports formats: "server-name", "server-name@1.0.0" + */ +export function parseServerNameVersion(input: string): { name: string; version?: string } { + const atIndex = input.lastIndexOf('@'); + if (atIndex === -1) { + return { name: input }; + } + + const name = input.substring(0, atIndex); + const version = input.substring(atIndex + 1); + + if (!name) { + throw new Error('Server name cannot be empty'); + } + + return { name, version }; +} + +/** + * Validate server name format + * Must be unique within the configuration, match regex, and be 1-50 characters + */ +export function validateServerName(name: string): void { + if (!name || name.length === 0) { + throw new Error('Server name cannot be empty'); + } + + if (name.length > 50) { + throw new Error('Server name must be 50 characters or less'); + } + + // Must start with letter, followed by letters, numbers, underscores, or hyphens + const nameRegex = /^[a-zA-Z][a-zA-Z0-9_-]*$/; + if (!nameRegex.test(name)) { + throw new Error('Server name must start with a letter and contain only letters, numbers, underscores, or hyphens'); + } +} + +/** + * Validate version format (semantic versioning) + */ +export function validateVersion(version: string): boolean { + const versionRegex = /^\d+\.\d+\.\d+(-[a-zA-Z0-9]+)?$/; + return versionRegex.test(version); +} + +/** + * Calculate server installation status + * Determines current state based on installed version and available updates + */ +export function calculateServerStatus(installedVersion: string, latestVersion?: string): InstallationStatus { + if (!installedVersion) { + return InstallationStatus.NOT_INSTALLED; + } + + if (latestVersion && installedVersion !== latestVersion) { + return InstallationStatus.OUTDATED; + } + + return InstallationStatus.INSTALLED; +} + +/** + * Check if server process is currently in use + * This checks if server processes are currently running or have recent connections + */ +export function checkServerInUse(serverName: string): boolean { + logger.debug(`Checking if server ${serverName} is in use`); + + try { + // Get ServerManager instance if it exists + const serverManager = ServerManager.current; + + // Check if server has an active outbound connection + // Use getClient which is the actual method name in ServerManager + const connection = serverManager.getClient(serverName); + + if (connection) { + logger.debug(`Server ${serverName} has an active client connection`); + return true; + } + + // Check all outbound connections for this server name + // Use getClients which returns all outbound connections + const allConnections = serverManager.getClients(); + if (allConnections && allConnections.has(serverName)) { + logger.debug(`Server ${serverName} is in outbound connections map`); + return true; + } + + return false; + } catch (_error) { + // If ServerManager is not initialized, server is not in use + logger.debug(`ServerManager not initialized or not accessible`); + return false; + } +} + +/** + * Generate a unique operation ID for tracking progress + */ +export function generateOperationId(): string { + return `op_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`; +} + +/** + * Sanitize server name for use in file paths + */ +export function sanitizeServerNameForPath(name: string): string { + return name.replace(/[^a-zA-Z0-9_-]/g, '_'); +} diff --git a/src/commands/mcp/wizard/components/serverDetailsDisplay.ts b/src/commands/mcp/wizard/components/serverDetailsDisplay.ts new file mode 100644 index 00000000..d99a1196 --- /dev/null +++ b/src/commands/mcp/wizard/components/serverDetailsDisplay.ts @@ -0,0 +1,73 @@ +import type { RegistryServer } from '@src/domains/registry/types.js'; + +import boxen from 'boxen'; +import chalk from 'chalk'; + +/** + * Render search results list with highlighted selection + */ +export function renderResultsList(results: RegistryServer[], currentIndex: number): void { + const header = boxen( + chalk.cyan.bold(`📦 Search Results (${results.length} found)\n\n`) + + chalk.gray('Controls: ↑↓ Navigate → Details Enter Select ← Back Esc Cancel'), + { + padding: 1, + borderStyle: 'double', + borderColor: 'cyan', + title: 'Select Server', + titleAlignment: 'center', + }, + ); + console.log(header); + + const listContent = results + .map((server, index) => { + const isSelected = index === currentIndex; + const cursor = isSelected ? chalk.yellow.bold('►') : ' '; + const nameStyle = isSelected ? chalk.bgGray.white.bold : chalk.white; + const description = server.description?.substring(0, 60) || 'No description'; + const descStyle = isSelected ? chalk.gray : chalk.dim; + + return `${cursor} ${nameStyle(server.name)}\n ${descStyle(description)}`; + }) + .join('\n\n'); + + console.log( + boxen(listContent, { + padding: 1, + borderStyle: 'round', + borderColor: 'blue', + }), + ); +} + +/** + * Format server details for display + */ +export function formatServerDetails(server: RegistryServer): string { + return ( + chalk.blue.bold(`📋 ${server.name}\n\n`) + + chalk.yellow.bold('Description:\n') + + chalk.white(`${server.description || 'No description available'}\n\n`) + + (server.websiteUrl ? chalk.yellow.bold('Website:\n') + chalk.cyan(`${server.websiteUrl}\n\n`) : '') + + (server.repository?.url ? chalk.yellow.bold('Repository:\n') + chalk.cyan(`${server.repository.url}\n\n`) : '') + + chalk.gray('Press any key to return...') + ); +} + +/** + * Display detailed information about a server + */ +export function displayServerDetails(server: RegistryServer): void { + const content = formatServerDetails(server); + + console.log( + boxen(content, { + padding: 1, + borderStyle: 'round', + borderColor: 'blue', + title: '🔍 Server Details', + titleAlignment: 'center', + }), + ); +} diff --git a/src/commands/mcp/wizard/components/stepIndicator.ts b/src/commands/mcp/wizard/components/stepIndicator.ts new file mode 100644 index 00000000..b8dfbed5 --- /dev/null +++ b/src/commands/mcp/wizard/components/stepIndicator.ts @@ -0,0 +1,42 @@ +import boxen from 'boxen'; +import chalk from 'chalk'; + +const WIZARD_STEPS = ['Search', 'Select', 'Configure', 'Confirm', 'Install'] as const; + +/** + * Show wizard step progress indicator + * @param currentStep Current step number (1-5) + * @param skipClear Whether to skip clearing console (useful when preserving logs) + */ +export function showStepIndicator(currentStep: number, skipClear = false): void { + if (!skipClear) { + console.clear(); + } + + const stepBar = WIZARD_STEPS.map((step, index) => { + const num = index + 1; + if (num < currentStep) { + return chalk.green(`✓ ${step}`); + } else if (num === currentStep) { + return chalk.cyan.bold(`► ${step}`); + } else { + return chalk.gray(`○ ${step}`); + } + }).join(chalk.gray(' → ')); + + console.log( + boxen(stepBar, { + padding: { left: 2, right: 2, top: 0, bottom: 0 }, + borderStyle: 'single', + borderColor: 'gray', + }), + ); + console.log(''); +} + +/** + * Get total number of wizard steps + */ +export function getTotalSteps(): number { + return WIZARD_STEPS.length; +} diff --git a/src/commands/mcp/wizard/components/welcomeScreen.ts b/src/commands/mcp/wizard/components/welcomeScreen.ts new file mode 100644 index 00000000..17b134c5 --- /dev/null +++ b/src/commands/mcp/wizard/components/welcomeScreen.ts @@ -0,0 +1,29 @@ +import boxen from 'boxen'; +import chalk from 'chalk'; + +/** + * Show welcome screen with wizard instructions and key bindings + */ +export function showWelcomeScreen(): void { + const welcomeContent = + chalk.magenta.bold('🚀 MCP Server Installation Wizard\n\n') + + chalk.yellow('This wizard will guide you through installing an MCP server.\n\n') + + chalk.cyan.bold('Navigation Keys:\n') + + chalk.gray(' ↑/↓ - Navigate options\n') + + chalk.gray(' ← - Go back\n') + + chalk.gray(' → - View details\n') + + chalk.gray(' Tab - Next step\n') + + chalk.gray(' Enter - Confirm\n') + + chalk.gray(' Ctrl+C - Cancel'); + + console.log( + boxen(welcomeContent, { + padding: 1, + margin: 1, + borderStyle: 'double', + borderColor: 'cyan', + title: 'Install Wizard', + titleAlignment: 'center', + }), + ); +} diff --git a/src/commands/mcp/wizard/index.ts b/src/commands/mcp/wizard/index.ts new file mode 100644 index 00000000..2b1fc14f --- /dev/null +++ b/src/commands/mcp/wizard/index.ts @@ -0,0 +1,11 @@ +/** + * Wizard exports + */ + +// Components +export * from './components/stepIndicator.js'; +export * from './components/welcomeScreen.js'; +export * from './components/serverDetailsDisplay.js'; + +// Navigation +export * from './navigation/keyboardHandler.js'; diff --git a/src/commands/mcp/wizard/navigation/keyboardHandler.ts b/src/commands/mcp/wizard/navigation/keyboardHandler.ts new file mode 100644 index 00000000..cad2b409 --- /dev/null +++ b/src/commands/mcp/wizard/navigation/keyboardHandler.ts @@ -0,0 +1,100 @@ +/** + * Keyboard input handling for wizard navigation + */ + +export type KeyPress = 'up' | 'down' | 'left' | 'right' | 'space' | 'enter' | 'escape' | 'unknown'; + +/** + * Get single key input for navigation + */ +export async function getKeyInput(): Promise { + return new Promise((resolve) => { + const stdin = process.stdin; + + // Ensure stdin is in the right mode + if (!stdin.isTTY) { + resolve('escape'); + return; + } + + try { + stdin.setRawMode(true); + stdin.resume(); + stdin.setEncoding('utf8'); + } catch (_error) { + resolve('escape'); + return; + } + + const onKeypress = (key: string | Buffer): void => { + try { + if (stdin.isTTY) { + stdin.setRawMode(false); + } + stdin.pause(); + stdin.removeListener('data', onKeypress); + } catch { + // Ignore cleanup errors + } + + let keyStr: string; + if (Buffer.isBuffer(key)) { + keyStr = key.toString('utf8'); + } else if (typeof key === 'string') { + keyStr = key; + } else { + keyStr = ''; + } + + // Handle escape sequences for arrow keys + if (keyStr === '\u001b[A') resolve('up'); + else if (keyStr === '\u001b[B') resolve('down'); + else if (keyStr === '\u001b[D') resolve('left'); + else if (keyStr === '\u001b[C') resolve('right'); + else if (keyStr === ' ') resolve('space'); + else if (keyStr === '\r' || keyStr === '\n') resolve('enter'); + else if (keyStr === '\u001b' || keyStr === '\u0003') { + // ESC or Ctrl+C - ensure cleanup before resolving + cleanupKeyboardInput(); + resolve('escape'); + } else resolve('unknown'); + }; + + stdin.on('data', onKeypress); + }); +} + +/** + * Cleanup keyboard input resources + * Removes listeners and resets stdin to prevent process hanging + */ +export function cleanupKeyboardInput(): void { + const stdin = process.stdin; + + try { + // Remove all listeners to prevent leaks + stdin.removeAllListeners('data'); + stdin.removeAllListeners('keypress'); + stdin.removeAllListeners('readable'); + stdin.removeAllListeners('end'); + + if (stdin.isTTY && stdin.setRawMode) { + stdin.setRawMode(false); + } + + // Pause stdin + stdin.pause(); + + // Destroy any pipes + if (stdin.unpipe) { + stdin.unpipe(); + } + + // Unref to allow process to exit even if stdin has pending operations + if (stdin.unref) { + stdin.unref(); + } + } catch { + // Ignore errors during cleanup - best effort + } +} diff --git a/src/commands/registry/search.ts b/src/commands/registry/search.ts index f9dc1033..ecc989a0 100644 --- a/src/commands/registry/search.ts +++ b/src/commands/registry/search.ts @@ -164,7 +164,7 @@ function displayTableFormat(results: SearchMCPServersResult, searchArgs: SearchM Description: truncateString(server.description, 45), Status: (server._meta?.[OFFICIAL_REGISTRY_KEY]?.status || server.status || 'unknown').toUpperCase(), Version: server.version, - 'Server ID': server._meta?.[OFFICIAL_REGISTRY_KEY]?.serverId || server.name, + 'Server ID': server.name, 'Registry Type': formatRegistryTypesPlain(server.packages), Transport: formatTransportTypesPlain(server.packages), 'Last Updated': formatDate(server._meta?.[OFFICIAL_REGISTRY_KEY]?.updatedAt), @@ -195,7 +195,7 @@ function displayListFormat(results: SearchMCPServersResult, searchArgs: SearchMC console.log( ` ${chalk.green('Status:')} ${formatStatus(server._meta?.[OFFICIAL_REGISTRY_KEY]?.status || server.status || 'unknown')} ${chalk.blue('Version:')} ${server.version}`, ); - console.log(` ${chalk.yellow('ID:')} ${chalk.gray(meta?.serverId || server.name)}`); + console.log(` ${chalk.yellow('ID:')} ${chalk.gray(server.name)}`); console.log( ` ${chalk.magenta('Transport:')} ${formatTransportTypesPlain(server.packages)} • ${chalk.red('Type:')} ${formatRegistryTypesPlain(server.packages)}`, ); diff --git a/src/core/tools/handlers/searchHandler.test.ts b/src/core/tools/handlers/searchHandler.test.ts index fa250340..12508431 100644 --- a/src/core/tools/handlers/searchHandler.test.ts +++ b/src/core/tools/handlers/searchHandler.test.ts @@ -60,8 +60,6 @@ describe('handleSearchMCPServers', () => { ], _meta: { 'io.modelcontextprotocol.registry/official': { - serverId: 'file-server-1', - versionId: 'v1.0.0', publishedAt: '2024-01-01T00:00:00Z', updatedAt: '2024-01-01T00:00:00Z', isLatest: true, @@ -131,7 +129,7 @@ describe('handleSearchMCPServers', () => { version: '1.0.0', packages: expect.any(Array), repository: expect.any(Object), - registryId: 'file-server-1', + registryId: 'file-server', lastUpdated: '2024-01-01T00:00:00Z', }); }); @@ -300,7 +298,7 @@ describe('handleSearchMCPServers', () => { source: 'github', subfolder: 'packages/core', }, - registryId: 'file-server-1', + registryId: 'file-server', lastUpdated: '2024-01-01T00:00:00Z', }); }); diff --git a/src/core/tools/handlers/searchHandler.ts b/src/core/tools/handlers/searchHandler.ts index bdc4c818..df642370 100644 --- a/src/core/tools/handlers/searchHandler.ts +++ b/src/core/tools/handlers/searchHandler.ts @@ -62,7 +62,7 @@ function transformServerForSearch( } return { ...server, - registryId: meta.serverId || '', + registryId: server.name || '', packages: server.packages || [], lastUpdated: meta.updatedAt || '', repository: server.repository, diff --git a/src/domains/installation/configurators/cliArgsConfigurator.ts b/src/domains/installation/configurators/cliArgsConfigurator.ts new file mode 100644 index 00000000..4ad2063d --- /dev/null +++ b/src/domains/installation/configurators/cliArgsConfigurator.ts @@ -0,0 +1,144 @@ +import chalk from 'chalk'; +import prompts from 'prompts'; + +import type { ArgMetadata } from '../types.js'; + +/** + * Configure CLI runtime arguments interactively + * Prompts user to select which arguments to configure and collects their values + */ +export async function configureCliArgs(argMetadata: ArgMetadata[]): Promise { + if (argMetadata.length === 0) { + // No args defined, ask if user wants to add any manually + const addManual = await prompts({ + type: 'confirm', + name: 'add', + message: 'No runtime arguments defined. Add any manually?', + initial: false, + }); + + if (addManual.add === undefined) { + return null; + } + + if (!addManual.add) { + return []; + } + + // Allow manual input + const manualInput = await prompts({ + type: 'text', + name: 'args', + message: 'Arguments (comma-separated):', + initial: '', + }); + + if (manualInput.args === undefined) { + return null; + } + + const argsValue = String(manualInput.args).trim(); + return argsValue + ? argsValue + .split(',') + .map((a: string) => a.trim()) + .filter((a: string) => a.length > 0) + : []; + } + + // Show summary of available args + console.log(chalk.cyan.bold('\n⚙️ Available Runtime Arguments:')); + console.log(chalk.gray(` Found ${argMetadata.length} runtime arguments\n`)); + + // Ask if user wants to configure any + const wantsToConfigure = await prompts({ + type: 'confirm', + name: 'value', + message: `Configure runtime arguments?`, + initial: argMetadata.some((a) => a.isRequired), + }); + + if (wantsToConfigure.value === undefined) { + return null; + } + + if (!wantsToConfigure.value) { + // Use defaults only for required args + return argMetadata.filter((a) => a.isRequired && a.default).map((a) => `${a.name}=${a.default}`); + } + + // Let user select which args to configure + const choices = argMetadata.map((arg) => { + const required = arg.isRequired ? chalk.red('*required') : ''; + const name = arg.name || 'argument'; + const title = `${name} ${required}`; + const description = arg.description || ''; + return { + title, + description, + value: arg.name || '', + selected: arg.isRequired || false, // Pre-select required args + }; + }); + + const selection = await prompts({ + type: 'multiselect', + name: 'selected', + message: 'Select runtime arguments to configure (use space to select, enter to confirm):', + choices, + hint: '- Space to select. Enter to submit', + instructions: false, + }); + + if (selection.selected === undefined) { + return null; + } + + const selectedNames = selection.selected as string[]; + if (selectedNames.length === 0) { + return []; + } + + // Prompt for each selected arg + console.log(chalk.cyan.bold('\n📝 Configure Selected Arguments:\n')); + const args: string[] = []; + + for (const name of selectedNames) { + const arg = argMetadata.find((a) => a.name === name); + if (!arg) continue; + + let result; + if (arg.choices && arg.choices.length > 0) { + result = await prompts({ + type: 'select', + name: 'value', + message: `${arg.name || 'Argument'}${arg.isRequired ? chalk.red(' *') : ''}:${arg.description ? `\n ${chalk.gray(arg.description)}` : ''}`, + choices: arg.choices.map((c) => ({ title: c, value: c })), + initial: arg.default ? arg.choices.indexOf(arg.default) : 0, + }); + } else { + result = await prompts({ + type: 'text', + name: 'value', + message: `${arg.name || 'Argument'}${arg.isRequired ? chalk.red(' *') : ''}:${arg.description ? `\n ${chalk.gray(arg.description)}` : ''}`, + initial: arg.default || '', + }); + } + + if (result.value === undefined) { + // User can skip by pressing Ctrl+C on individual fields + continue; + } + + const value = String(result.value).trim(); + if (value) { + // Format as name=value for CLI args + args.push(`${arg.name}=${value}`); + } else if (arg.isRequired && arg.default) { + console.log(chalk.yellow(`⚠️ ${arg.name} is required, using default value`)); + args.push(`${arg.name}=${arg.default}`); + } + } + + return args; +} diff --git a/src/domains/installation/configurators/envVarConfigurator.ts b/src/domains/installation/configurators/envVarConfigurator.ts new file mode 100644 index 00000000..749d7138 --- /dev/null +++ b/src/domains/installation/configurators/envVarConfigurator.ts @@ -0,0 +1,140 @@ +import chalk from 'chalk'; +import prompts from 'prompts'; + +import type { EnvVarMetadata } from '../types.js'; + +/** + * Configure environment variables interactively + * Prompts user to select which env vars to configure and collects their values + */ +export async function configureEnvVars(envVarMetadata: EnvVarMetadata[]): Promise | null> { + if (envVarMetadata.length === 0) { + // No env vars defined, ask if user wants to add any manually + const addManual = await prompts({ + type: 'confirm', + name: 'add', + message: 'No environment variables defined. Add any manually?', + initial: false, + }); + + if (addManual.add === undefined) { + return null; + } + + if (!addManual.add) { + return {}; + } + + // Allow manual JSON input + const manualInput = await prompts({ + type: 'text', + name: 'env', + message: 'Environment variables (JSON):', + initial: '{}', + validate: (value: string) => { + try { + JSON.parse(value); + return true; + } catch { + return 'Invalid JSON format'; + } + }, + }); + + if (manualInput.env === undefined) { + return null; + } + + return JSON.parse(String(manualInput.env)) as Record; + } + + // Show summary of available env vars + console.log(chalk.cyan.bold('\n📋 Available Environment Variables:')); + console.log(chalk.gray(` Found ${envVarMetadata.length} environment variables\n`)); + + // Ask if user wants to configure any + const wantsToConfigure = await prompts({ + type: 'confirm', + name: 'value', + message: `Configure environment variables?`, + initial: envVarMetadata.some((v) => v.isRequired), + }); + + if (wantsToConfigure.value === undefined) { + return null; + } + + if (!wantsToConfigure.value) { + // Use defaults only for required vars + const env: Record = {}; + envVarMetadata.forEach((envVar) => { + if (envVar.default && envVar.isRequired) { + env[envVar.key] = envVar.default; + } + }); + return env; + } + + // Let user select which env vars to configure + const choices = envVarMetadata.map((envVar) => { + const required = envVar.isRequired ? chalk.red('*required') : ''; + const secret = envVar.isSecret ? chalk.yellow('🔒 ') : ''; + const title = `${secret}${envVar.key} ${required}`; + const description = envVar.description || ''; + return { + title, + description, + value: envVar.key, + selected: envVar.isRequired || false, // Pre-select required vars + }; + }); + + const selection = await prompts({ + type: 'multiselect', + name: 'selected', + message: 'Select environment variables to configure (use space to select, enter to confirm):', + choices, + hint: '- Space to select. Enter to submit', + instructions: false, + }); + + if (selection.selected === undefined) { + return null; + } + + const selectedKeys = selection.selected as string[]; + if (selectedKeys.length === 0) { + return {}; + } + + // Prompt for each selected env var + console.log(chalk.cyan.bold('\n📝 Configure Selected Variables:\n')); + const env: Record = {}; + + for (const key of selectedKeys) { + const envVar = envVarMetadata.find((v) => v.key === key); + if (!envVar) continue; + + const result = await prompts({ + type: envVar.isSecret ? 'password' : 'text', + name: 'value', + message: `${envVar.key}${envVar.isRequired ? chalk.red(' *') : ''}:${envVar.description ? `\n ${chalk.gray(envVar.description)}` : ''}`, + initial: envVar.default || '', + }); + + if (result.value === undefined) { + // User can skip by pressing Ctrl+C on individual fields + continue; + } + + const value = String(result.value).trim(); + if (value) { + env[envVar.key] = value; + } else if (envVar.isRequired) { + console.log(chalk.yellow(`⚠️ ${envVar.key} is required, using default or empty value`)); + env[envVar.key] = envVar.default || ''; + } + } + + return env; +} diff --git a/src/domains/installation/configurators/tagsConfigurator.test.ts b/src/domains/installation/configurators/tagsConfigurator.test.ts new file mode 100644 index 00000000..85236556 --- /dev/null +++ b/src/domains/installation/configurators/tagsConfigurator.test.ts @@ -0,0 +1,105 @@ +import { describe, expect, it } from 'vitest'; + +import { formatTags, generateDefaultTags, parseTags, validateTags } from './tagsConfigurator.js'; + +describe('tagsConfigurator', () => { + describe('parseTags', () => { + it('should parse comma-separated tags', () => { + expect(parseTags('tag1,tag2,tag3')).toEqual(['tag1', 'tag2', 'tag3']); + }); + + it('should trim whitespace', () => { + expect(parseTags('tag1, tag2 , tag3')).toEqual(['tag1', 'tag2', 'tag3']); + }); + + it('should filter empty tags', () => { + expect(parseTags('tag1,,tag2, ,tag3')).toEqual(['tag1', 'tag2', 'tag3']); + }); + + it('should handle single tag', () => { + expect(parseTags('single')).toEqual(['single']); + }); + + it('should handle empty string', () => { + expect(parseTags('')).toEqual([]); + }); + }); + + describe('formatTags', () => { + it('should format tags with comma and space', () => { + expect(formatTags(['tag1', 'tag2', 'tag3'])).toBe('tag1, tag2, tag3'); + }); + + it('should handle single tag', () => { + expect(formatTags(['single'])).toBe('single'); + }); + + it('should handle empty array', () => { + expect(formatTags([])).toBe(''); + }); + }); + + describe('validateTags', () => { + it('should accept valid tags', () => { + const result = validateTags(['tag1', 'tag_2', 'tag-3', 'TAG123']); + + expect(result.valid).toBe(true); + expect(result.errors).toEqual([]); + }); + + it('should reject empty tags', () => { + const result = validateTags(['']); + + expect(result.valid).toBe(false); + expect(result.errors).toContain('Tag cannot be empty'); + }); + + it('should reject tags longer than 50 characters', () => { + const longTag = 'a'.repeat(51); + const result = validateTags([longTag]); + + expect(result.valid).toBe(false); + expect(result.errors[0]).toContain('too long'); + }); + + it('should reject tags with invalid characters', () => { + const result = validateTags(['tag with spaces', 'tag@special', 'tag.dot']); + + expect(result.valid).toBe(false); + expect(result.errors).toHaveLength(3); + expect(result.errors[0]).toContain('invalid characters'); + }); + + it('should accept tags exactly 50 characters', () => { + const maxTag = 'a'.repeat(50); + const result = validateTags([maxTag]); + + expect(result.valid).toBe(true); + }); + + it('should accumulate multiple errors', () => { + const result = validateTags(['', 'a'.repeat(51), 'invalid tag']); + + expect(result.valid).toBe(false); + // Empty tag (1) + long tag (1) + invalid tag with space (2 errors for space chars) = 4 + expect(result.errors.length).toBeGreaterThanOrEqual(3); + }); + + it('should accept alphanumeric with underscores and hyphens', () => { + const result = validateTags(['test_tag', 'test-tag', 'test123', 'TEST']); + + expect(result.valid).toBe(true); + }); + }); + + describe('generateDefaultTags', () => { + it('should generate default tag from server name', () => { + expect(generateDefaultTags('my-server')).toEqual(['my-server']); + }); + + it('should handle different server names', () => { + expect(generateDefaultTags('filesystem')).toEqual(['filesystem']); + expect(generateDefaultTags('test_server')).toEqual(['test_server']); + }); + }); +}); diff --git a/src/domains/installation/configurators/tagsConfigurator.ts b/src/domains/installation/configurators/tagsConfigurator.ts new file mode 100644 index 00000000..38fd9f28 --- /dev/null +++ b/src/domains/installation/configurators/tagsConfigurator.ts @@ -0,0 +1,51 @@ +/** + * Tags configuration helpers + */ + +/** + * Parse tags from comma-separated string + */ +export function parseTags(tagsString: string): string[] { + return tagsString + .split(',') + .map((t) => t.trim()) + .filter((t) => t.length > 0); +} + +/** + * Format tags array to comma-separated string + */ +export function formatTags(tags: string[]): string { + return tags.join(', '); +} + +/** + * Validate tags format + */ +export function validateTags(tags: string[]): { valid: boolean; errors: string[] } { + const errors: string[] = []; + + for (const tag of tags) { + if (tag.length === 0) { + errors.push('Tag cannot be empty'); + } + if (tag.length > 50) { + errors.push(`Tag '${tag}' is too long (max 50 characters)`); + } + if (!/^[a-zA-Z0-9_-]+$/.test(tag)) { + errors.push(`Tag '${tag}' contains invalid characters (only alphanumeric, underscore, and hyphen allowed)`); + } + } + + return { + valid: errors.length === 0, + errors, + }; +} + +/** + * Generate default tags from server name + */ +export function generateDefaultTags(serverName: string): string[] { + return [serverName]; +} diff --git a/src/domains/installation/index.ts b/src/domains/installation/index.ts new file mode 100644 index 00000000..e9db7b77 --- /dev/null +++ b/src/domains/installation/index.ts @@ -0,0 +1,19 @@ +/** + * Installation domain exports + */ + +// Types +export * from './types.js'; + +// Metadata +export * from './metadata/metadataExtractor.js'; +export * from './metadata/defaultsProvider.js'; + +// Validators +export * from './validators/serverNameValidator.js'; +export * from './validators/conflictDetector.js'; + +// Configurators +export * from './configurators/envVarConfigurator.js'; +export * from './configurators/cliArgsConfigurator.js'; +export * from './configurators/tagsConfigurator.js'; diff --git a/src/domains/installation/metadata/defaultsProvider.ts b/src/domains/installation/metadata/defaultsProvider.ts new file mode 100644 index 00000000..d2d19604 --- /dev/null +++ b/src/domains/installation/metadata/defaultsProvider.ts @@ -0,0 +1,49 @@ +import type { RegistryServer } from '@src/domains/registry/types.js'; + +/** + * Extract default environment variables from server metadata + * @deprecated Use extractEnvVarMetadata and filter for defaults instead + */ +export function extractDefaultEnvVars(server: RegistryServer): Record { + const envVars: Record = {}; + + // Check packages for environment variables + if (server.packages && server.packages.length > 0) { + for (const pkg of server.packages) { + if (pkg.environmentVariables && Array.isArray(pkg.environmentVariables)) { + for (const envVar of pkg.environmentVariables) { + if (envVar.value) { + // Use the variable name from the value field or description + const key = envVar.value.toUpperCase().replace(/[^A-Z0-9_]/g, '_'); + envVars[key] = envVar.default || ''; + } + } + } + } + } + + return envVars; +} + +/** + * Extract default arguments from server metadata + * @deprecated Use extractArgMetadata and filter for defaults instead + */ +export function extractDefaultArgs(server: RegistryServer): string[] { + const args: string[] = []; + + // Check packages for runtime arguments + if (server.packages && server.packages.length > 0) { + for (const pkg of server.packages) { + if (pkg.runtimeArguments && Array.isArray(pkg.runtimeArguments)) { + for (const arg of pkg.runtimeArguments) { + if (arg.default) { + args.push(arg.default); + } + } + } + } + } + + return args; +} diff --git a/src/domains/installation/metadata/metadataExtractor.test.ts b/src/domains/installation/metadata/metadataExtractor.test.ts new file mode 100644 index 00000000..ba98f546 --- /dev/null +++ b/src/domains/installation/metadata/metadataExtractor.test.ts @@ -0,0 +1,223 @@ +import type { RegistryServer } from '@src/domains/registry/types.js'; + +import { describe, expect, it } from 'vitest'; + +import { extractArgMetadata, extractEnvVarMetadata } from './metadataExtractor.js'; + +describe('metadataExtractor', () => { + describe('extractEnvVarMetadata', () => { + it('should extract environment variables from server packages', () => { + const server: Partial = { + packages: [ + { + identifier: 'test-package', + registryType: 'npm', + environmentVariables: [ + { + name: 'API_KEY', + description: 'API key for service', + default: 'default-key', + isRequired: true, + isSecret: true, + }, + { + value: 'DATABASE_URL', + description: 'Database connection string', + isRequired: false, + }, + ], + }, + ], + }; + + const result = extractEnvVarMetadata(server as RegistryServer); + + expect(result).toHaveLength(2); + expect(result[0]).toEqual({ + key: 'API_KEY', + description: 'API key for service', + default: 'default-key', + isRequired: true, + isSecret: true, + }); + expect(result[1]).toEqual({ + key: 'DATABASE_URL', + description: 'Database connection string', + isRequired: false, + isSecret: undefined, + default: undefined, + }); + }); + + it('should deduplicate environment variables across packages', () => { + const server: Partial = { + packages: [ + { + identifier: 'package-1', + registryType: 'npm', + environmentVariables: [{ name: 'API_KEY', description: 'First definition' }], + }, + { + identifier: 'package-2', + registryType: 'npm', + environmentVariables: [{ name: 'API_KEY', description: 'Duplicate definition' }], + }, + ], + }; + + const result = extractEnvVarMetadata(server as RegistryServer); + + expect(result).toHaveLength(1); + expect(result[0].key).toBe('API_KEY'); + expect(result[0].description).toBe('First definition'); + }); + + it('should return empty array when no environment variables defined', () => { + const server: Partial = { + packages: [ + { + identifier: 'test-package', + registryType: 'npm', + }, + ], + }; + + const result = extractEnvVarMetadata(server as RegistryServer); + + expect(result).toEqual([]); + }); + + it('should handle missing packages', () => { + const server: Partial = {}; + + const result = extractEnvVarMetadata(server as RegistryServer); + + expect(result).toEqual([]); + }); + }); + + describe('extractArgMetadata', () => { + it('should extract arguments from packageArguments', () => { + const server: Partial = { + packages: [ + { + identifier: 'test-package', + registryType: 'npm', + packageArguments: [ + { + name: 'port', + description: 'Server port', + default: '3000', + isRequired: true, + type: 'number', + }, + ], + }, + ], + }; + + const result = extractArgMetadata(server as RegistryServer); + + expect(result).toHaveLength(1); + expect(result[0]).toEqual({ + name: 'port', + description: 'Server port', + default: '3000', + isRequired: true, + type: 'number', + isSecret: undefined, + choices: undefined, + valueHint: undefined, + }); + }); + + it('should extract arguments from runtimeArguments', () => { + const server: Partial = { + packages: [ + { + identifier: 'test-package', + registryType: 'npm', + runtimeArguments: [ + { + name: 'log-level', + description: 'Logging level', + choices: ['debug', 'info', 'warn', 'error'], + default: 'info', + }, + ], + }, + ], + }; + + const result = extractArgMetadata(server as RegistryServer); + + expect(result).toHaveLength(1); + expect(result[0]).toEqual({ + name: 'log-level', + description: 'Logging level', + choices: ['debug', 'info', 'warn', 'error'], + default: 'info', + isRequired: undefined, + isSecret: undefined, + type: undefined, + valueHint: undefined, + }); + }); + + it('should combine packageArguments and runtimeArguments', () => { + const server: Partial = { + packages: [ + { + identifier: 'test-package', + registryType: 'npm', + packageArguments: [{ name: 'port' }], + runtimeArguments: [{ name: 'log-level' }], + }, + ], + }; + + const result = extractArgMetadata(server as RegistryServer); + + expect(result).toHaveLength(2); + expect(result.map((a) => a.name)).toEqual(['port', 'log-level']); + }); + + it('should deduplicate arguments across packages', () => { + const server: Partial = { + packages: [ + { + identifier: 'package-1', + registryType: 'npm', + packageArguments: [{ name: 'port', description: 'First definition' }], + }, + { + identifier: 'package-2', + registryType: 'npm', + runtimeArguments: [{ name: 'port', description: 'Duplicate definition' }], + }, + ], + }; + + const result = extractArgMetadata(server as RegistryServer); + + expect(result).toHaveLength(1); + expect(result[0].name).toBe('port'); + expect(result[0].description).toBe('First definition'); + }); + + it('should return empty array when no arguments defined', () => { + const server: Partial = { + packages: [ + { + identifier: 'test-package', + registryType: 'npm', + }, + ], + }; + + const result = extractArgMetadata(server as RegistryServer); + + expect(result).toEqual([]); + }); + }); +}); diff --git a/src/domains/installation/metadata/metadataExtractor.ts b/src/domains/installation/metadata/metadataExtractor.ts new file mode 100644 index 00000000..5eee1cd5 --- /dev/null +++ b/src/domains/installation/metadata/metadataExtractor.ts @@ -0,0 +1,68 @@ +import type { RegistryServer } from '@src/domains/registry/types.js'; + +import type { ArgMetadata, EnvVarMetadata } from '../types.js'; + +/** + * Extract all environment variables with metadata from server packages + */ +export function extractEnvVarMetadata(server: RegistryServer): EnvVarMetadata[] { + const envVars: EnvVarMetadata[] = []; + const seen = new Set(); + + if (server.packages && server.packages.length > 0) { + for (const pkg of server.packages) { + if (pkg.environmentVariables && Array.isArray(pkg.environmentVariables)) { + for (const envVar of pkg.environmentVariables) { + // Use 'name' or 'value' field for the environment variable key + const key = envVar.name || envVar.value; + if (key && !seen.has(key)) { + seen.add(key); + envVars.push({ + key, + description: envVar.description, + default: envVar.default, + isRequired: envVar.isRequired, + isSecret: envVar.isSecret, + }); + } + } + } + } + } + + return envVars; +} + +/** + * Extract all runtime arguments with metadata from server packages + */ +export function extractArgMetadata(server: RegistryServer): ArgMetadata[] { + const args: ArgMetadata[] = []; + const seen = new Set(); + + if (server.packages && server.packages.length > 0) { + for (const pkg of server.packages) { + // Check both packageArguments and runtimeArguments + const argSources = [...(pkg.packageArguments || []), ...(pkg.runtimeArguments || [])]; + + for (const arg of argSources) { + const name = arg.name; + if (name && !seen.has(name)) { + seen.add(name); + args.push({ + name: arg.name, + description: arg.description, + default: arg.default, + isRequired: arg.isRequired, + isSecret: arg.isSecret, + type: arg.type, + choices: arg.choices, + valueHint: arg.valueHint, + }); + } + } + } + } + + return args; +} diff --git a/src/domains/installation/types.ts b/src/domains/installation/types.ts new file mode 100644 index 00000000..a1950529 --- /dev/null +++ b/src/domains/installation/types.ts @@ -0,0 +1,29 @@ +/** + * Type definitions for installation domain + */ + +export interface EnvVarMetadata { + key: string; + description?: string; + default?: string; + isRequired?: boolean; + isSecret?: boolean; +} + +export interface ArgMetadata { + name?: string; + description?: string; + default?: string; + isRequired?: boolean; + isSecret?: boolean; + type?: string; + choices?: string[]; + valueHint?: string; +} + +export interface ServerConfigInput { + localName?: string; + tags?: string[]; + env?: Record; + args?: string[]; +} diff --git a/src/domains/installation/validators/conflictDetector.test.ts b/src/domains/installation/validators/conflictDetector.test.ts new file mode 100644 index 00000000..ede39860 --- /dev/null +++ b/src/domains/installation/validators/conflictDetector.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, it } from 'vitest'; + +import { checkNameConflict, generateAlternativeNames, validateNoConflict } from './conflictDetector.js'; + +describe('conflictDetector', () => { + describe('checkNameConflict', () => { + it('should detect conflict when name exists', () => { + const result = checkNameConflict('test-server', ['test-server', 'other-server']); + + expect(result.hasConflict).toBe(true); + expect(result.conflictingName).toBe('test-server'); + }); + + it('should not detect conflict when name is unique', () => { + const result = checkNameConflict('new-server', ['test-server', 'other-server']); + + expect(result.hasConflict).toBe(false); + expect(result.conflictingName).toBeUndefined(); + }); + + it('should handle empty existing names', () => { + const result = checkNameConflict('test-server', []); + + expect(result.hasConflict).toBe(false); + }); + + it('should be case-sensitive', () => { + const result = checkNameConflict('Test-Server', ['test-server']); + + expect(result.hasConflict).toBe(false); + }); + }); + + describe('generateAlternativeNames', () => { + it('should generate alternative names with incrementing suffixes', () => { + const alternatives = generateAlternativeNames('test', ['other'], 3); + + expect(alternatives).toHaveLength(3); + expect(alternatives[0]).toBe('test_1'); + expect(alternatives[1]).toBe('test_2'); + expect(alternatives[2]).toBe('test_3'); + }); + + it('should skip conflicting alternatives', () => { + const alternatives = generateAlternativeNames('test', ['test_1', 'test_2'], 3); + + expect(alternatives).toHaveLength(3); + expect(alternatives[0]).toBe('test_3'); + expect(alternatives[1]).toBe('test_4'); + expect(alternatives[2]).toBe('test_5'); + }); + + it('should generate requested number of alternatives', () => { + expect(generateAlternativeNames('test', [], 1)).toHaveLength(1); + expect(generateAlternativeNames('test', [], 5)).toHaveLength(5); + }); + + it('should handle names with existing numbers', () => { + const alternatives = generateAlternativeNames('test_1', ['other'], 2); + + expect(alternatives[0]).toBe('test_1_1'); + expect(alternatives[1]).toBe('test_1_2'); + }); + }); + + describe('validateNoConflict', () => { + it('should validate when name does not conflict', () => { + const result = validateNoConflict('new-server', ['existing-server']); + + expect(result.valid).toBe(true); + expect(result.error).toBeUndefined(); + }); + + it('should invalidate when name conflicts', () => { + const result = validateNoConflict('test-server', ['test-server', 'other']); + + expect(result.valid).toBe(false); + expect(result.error).toContain('test-server'); + expect(result.error).toContain('already exists'); + }); + + it('should handle empty existing names', () => { + const result = validateNoConflict('test-server', []); + + expect(result.valid).toBe(true); + }); + }); +}); diff --git a/src/domains/installation/validators/conflictDetector.ts b/src/domains/installation/validators/conflictDetector.ts new file mode 100644 index 00000000..822a0793 --- /dev/null +++ b/src/domains/installation/validators/conflictDetector.ts @@ -0,0 +1,52 @@ +/** + * Server name conflict detection and resolution + */ + +export type ConflictResolution = 'rename' | 'override' | 'cancel'; + +export interface ConflictResult { + hasConflict: boolean; + conflictingName?: string; +} + +/** + * Check if a server name conflicts with existing servers + */ +export function checkNameConflict(name: string, existingNames: string[]): ConflictResult { + const hasConflict = existingNames.includes(name); + return { + hasConflict, + conflictingName: hasConflict ? name : undefined, + }; +} + +/** + * Generate alternative name suggestions when there's a conflict + */ +export function generateAlternativeNames(baseName: string, existingNames: string[], count: number = 3): string[] { + const alternatives: string[] = []; + let counter = 1; + + while (alternatives.length < count) { + const candidate = `${baseName}_${counter}`; + if (!existingNames.includes(candidate)) { + alternatives.push(candidate); + } + counter++; + } + + return alternatives; +} + +/** + * Validate that a proposed name doesn't conflict + */ +export function validateNoConflict(name: string, existingNames: string[]): { valid: boolean; error?: string } { + if (existingNames.includes(name)) { + return { + valid: false, + error: `Server '${name}' already exists. Choose a different name or use override option.`, + }; + } + return { valid: true }; +} diff --git a/src/domains/installation/validators/serverNameValidator.test.ts b/src/domains/installation/validators/serverNameValidator.test.ts new file mode 100644 index 00000000..7aeda42b --- /dev/null +++ b/src/domains/installation/validators/serverNameValidator.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, it } from 'vitest'; + +import { deriveLocalName, isValidServerName, sanitizeServerName } from './serverNameValidator.js'; + +describe('serverNameValidator', () => { + describe('deriveLocalName', () => { + it('should extract last part from registry ID with slash', () => { + expect(deriveLocalName('io.github.owner/repo-name')).toBe('repo-name'); + expect(deriveLocalName('com.example/my-server')).toBe('my-server'); + }); + + it('should use full ID if no slash', () => { + expect(deriveLocalName('filesystem')).toBe('filesystem'); + expect(deriveLocalName('simple-name')).toBe('simple-name'); + }); + + it('should preserve valid names as-is', () => { + expect(deriveLocalName('io.github.owner/validName123')).toBe('validName123'); + expect(deriveLocalName('test_server')).toBe('test_server'); + expect(deriveLocalName('my-server')).toBe('my-server'); + }); + + it('should sanitize names with invalid characters', () => { + expect(deriveLocalName('io.github.owner/my server')).toBe('my_server'); + expect(deriveLocalName('test@server')).toBe('test_server'); + expect(deriveLocalName('server#name!')).toBe('server_name_'); + }); + + it('should ensure name starts with letter', () => { + expect(deriveLocalName('123-server')).toBe('server_123-server'); + expect(deriveLocalName('_underscore')).toBe('server__underscore'); + expect(deriveLocalName('-dash')).toBe('server_-dash'); + }); + + it('should truncate long names to 50 characters', () => { + const longName = 'a'.repeat(60); + const result = deriveLocalName(longName); + expect(result).toHaveLength(50); + expect(result).toBe('a'.repeat(50)); + }); + + it('should handle empty or invalid names', () => { + expect(deriveLocalName('###')).toBe('server____'); + expect(deriveLocalName('')).toBe('server_'); // Empty string becomes '_' then 'server_' + }); + }); + + describe('isValidServerName', () => { + it('should accept valid names', () => { + expect(isValidServerName('validName')).toBe(true); + expect(isValidServerName('myServer123')).toBe(true); + expect(isValidServerName('test_server')).toBe(true); + expect(isValidServerName('my-server')).toBe(true); + expect(isValidServerName('a')).toBe(true); + }); + + it('should reject names not starting with letter', () => { + expect(isValidServerName('123server')).toBe(false); + expect(isValidServerName('_server')).toBe(false); + expect(isValidServerName('-server')).toBe(false); + }); + + it('should reject names with invalid characters', () => { + expect(isValidServerName('my server')).toBe(false); + expect(isValidServerName('test@server')).toBe(false); + expect(isValidServerName('server#name')).toBe(false); + expect(isValidServerName('test.server')).toBe(false); + }); + + it('should reject empty names', () => { + expect(isValidServerName('')).toBe(false); + }); + + it('should reject names longer than 50 characters', () => { + expect(isValidServerName('a'.repeat(51))).toBe(false); + expect(isValidServerName('a'.repeat(50))).toBe(true); + }); + }); + + describe('sanitizeServerName', () => { + it('should replace invalid characters with underscores', () => { + expect(sanitizeServerName('my server')).toBe('my_server'); + expect(sanitizeServerName('test@server#name')).toBe('test_server_name'); + }); + + it('should add prefix if not starting with letter', () => { + expect(sanitizeServerName('123server')).toBe('server_123server'); + expect(sanitizeServerName('_test')).toBe('server__test'); + }); + + it('should truncate to 50 characters', () => { + const result = sanitizeServerName('a'.repeat(60)); + expect(result).toHaveLength(50); + }); + + it('should handle empty input', () => { + expect(sanitizeServerName('')).toBe('server_'); // Empty string becomes '_' then 'server_' + }); + + it('should preserve already valid names', () => { + expect(sanitizeServerName('validName123')).toBe('validName123'); + expect(sanitizeServerName('test_server')).toBe('test_server'); + expect(sanitizeServerName('my-server')).toBe('my-server'); + }); + }); +}); diff --git a/src/domains/installation/validators/serverNameValidator.ts b/src/domains/installation/validators/serverNameValidator.ts new file mode 100644 index 00000000..8143e05e --- /dev/null +++ b/src/domains/installation/validators/serverNameValidator.ts @@ -0,0 +1,71 @@ +/** + * Server name validation and sanitization + */ + +const LOCAL_NAME_REGEX = /^[a-zA-Z][a-zA-Z0-9_-]*$/; +const MAX_NAME_LENGTH = 50; + +/** + * Derive a local server name from a registry ID + * Ensures the name is valid, sanitized, and not too long + */ +export function deriveLocalName(registryId: string): string { + // Extract the last part after the slash, or use the full ID if no slash + const lastPart = registryId.includes('/') ? registryId.split('/').pop()! : registryId; + + // If it already starts with a letter and only contains valid chars, use it as-is + if (LOCAL_NAME_REGEX.test(lastPart) && lastPart.length <= MAX_NAME_LENGTH) { + return lastPart; + } + + // Otherwise, sanitize it + let sanitized = lastPart.replace(/[^a-zA-Z0-9_-]/g, '_'); + + // Ensure it starts with a letter + if (!/^[a-zA-Z]/.test(sanitized)) { + sanitized = `server_${sanitized}`; + } + + // Truncate to MAX_NAME_LENGTH characters if longer + if (sanitized.length > MAX_NAME_LENGTH) { + sanitized = sanitized.substring(0, MAX_NAME_LENGTH); + } + + // Ensure it's not empty after sanitization + if (sanitized.length === 0) { + sanitized = 'server'; + } + + return sanitized; +} + +/** + * Validate if a server name is valid + */ +export function isValidServerName(name: string): boolean { + return LOCAL_NAME_REGEX.test(name) && name.length > 0 && name.length <= MAX_NAME_LENGTH; +} + +/** + * Sanitize a server name to make it valid + */ +export function sanitizeServerName(name: string): string { + let sanitized = name.replace(/[^a-zA-Z0-9_-]/g, '_'); + + // Ensure it starts with a letter + if (!/^[a-zA-Z]/.test(sanitized)) { + sanitized = `server_${sanitized}`; + } + + // Truncate if too long + if (sanitized.length > MAX_NAME_LENGTH) { + sanitized = sanitized.substring(0, MAX_NAME_LENGTH); + } + + // Fallback if empty + if (sanitized.length === 0) { + sanitized = 'server'; + } + + return sanitized; +} diff --git a/src/domains/registry/formatters/serverDetailFormatter.test.ts b/src/domains/registry/formatters/serverDetailFormatter.test.ts index eacc292c..9d2cebe1 100644 --- a/src/domains/registry/formatters/serverDetailFormatter.test.ts +++ b/src/domains/registry/formatters/serverDetailFormatter.test.ts @@ -95,8 +95,6 @@ describe('serverDetailFormatter', () => { ], _meta: { [OFFICIAL_REGISTRY_KEY]: { - serverId: 'test-server-id', - versionId: 'test-version-id', publishedAt: '2023-01-01T00:00:00Z', updatedAt: '2023-01-02T00:00:00Z', isLatest: true, @@ -154,8 +152,6 @@ describe('serverDetailFormatter', () => { }, _meta: { [OFFICIAL_REGISTRY_KEY]: { - serverId: 'minimal-id', - versionId: 'minimal-version-id', publishedAt: '2023-01-01T00:00:00Z', updatedAt: '2023-01-01T00:00:00Z', isLatest: true, @@ -183,8 +179,6 @@ describe('serverDetailFormatter', () => { packages: [], _meta: { [OFFICIAL_REGISTRY_KEY]: { - serverId: 'test-server-id', - versionId: 'test-version-id', publishedAt: '2023-01-01T00:00:00Z', updatedAt: '2023-01-02T00:00:00Z', isLatest: true, @@ -322,8 +316,6 @@ describe('serverDetailFormatter', () => { ], _meta: { [OFFICIAL_REGISTRY_KEY]: { - serverId: 'test-server-id', - versionId: 'test-version-id', publishedAt: '2023-01-01T00:00:00Z', updatedAt: '2023-01-02T00:00:00Z', isLatest: true, diff --git a/src/domains/registry/formatters/serverDetailFormatter.ts b/src/domains/registry/formatters/serverDetailFormatter.ts index 2819ab87..cce9fd38 100644 --- a/src/domains/registry/formatters/serverDetailFormatter.ts +++ b/src/domains/registry/formatters/serverDetailFormatter.ts @@ -69,7 +69,7 @@ function formatTableServer(server: RegistryServer): string { basicInfo['Published At'] = formatDate(meta.publishedAt); basicInfo['Updated At'] = formatDate(meta.updatedAt); basicInfo['Is Latest'] = meta.isLatest ? 'Yes' : 'No'; - basicInfo['Registry ID'] = meta.serverId; + basicInfo['Registry ID'] = server.name || 'N/A'; let result = '\nServer Details:\n'; console.table([basicInfo]); @@ -144,11 +144,26 @@ function formatTableServer(server: RegistryServer): string { * Format server with enhanced detailed display */ function formatDetailedServer(server: RegistryServer): string { + if (!server._meta) { + return 'Error: server._meta is undefined'; + } + const meta = server._meta[OFFICIAL_REGISTRY_KEY]; + if (!meta) { + return `Error: meta not found for key ${OFFICIAL_REGISTRY_KEY}`; + } - // Status badge with color - const statusColor = server.status === 'active' ? 'green' : server.status === 'deprecated' ? 'yellow' : 'red'; - const statusBadge = chalk[statusColor].bold(`● ${server.status.toUpperCase()}`); + // Status badge with color - use status from meta if server status is not available + const serverStatus = server.status || meta.status || 'unknown'; + const statusColor = + serverStatus === 'active' + ? 'green' + : serverStatus === 'deprecated' + ? 'yellow' + : serverStatus === 'archived' + ? 'red' + : 'gray'; + const statusBadge = chalk[statusColor].bold(`● ${serverStatus.toUpperCase()}`); // Header with enhanced information const header = chalk.cyan.bold(server.name) + ' ' + chalk.gray(`(${server.version})`) + ' ' + statusBadge; @@ -158,9 +173,9 @@ function formatDetailedServer(server: RegistryServer): string { // Enhanced basic info section const basicInfo = [ - `${chalk.cyan('Repository:')} ${server.repository.url}`, - `${chalk.cyan('Source:')} ${server.repository.source}`, - server.repository.subfolder ? `${chalk.cyan('Subfolder:')} ${server.repository.subfolder}` : '', + `${chalk.cyan('Repository:')} ${server.repository?.url || 'N/A'}`, + `${chalk.cyan('Source:')} ${server.repository?.source || 'N/A'}`, + server.repository?.subfolder ? `${chalk.cyan('Subfolder:')} ${server.repository.subfolder}` : '', server.websiteUrl ? `${chalk.cyan('Website:')} ${server.websiteUrl}` : '', `${chalk.cyan('Published:')} ${formatRelativeDate(meta.publishedAt)}`, `${chalk.cyan('Updated:')} ${formatRelativeDate(meta.updatedAt)}`, @@ -286,8 +301,8 @@ ${remotesList}`; // Enhanced registry and metadata info const metaInfo = [ - `${chalk.cyan('Registry ID:')} ${meta.serverId}`, - `${chalk.cyan('Version ID:')} ${meta.versionId}`, + `${chalk.cyan('Registry ID:')} ${server.name || 'N/A'}`, + `${chalk.cyan('Version ID:')} ${server.version || 'N/A'}`, ].filter(Boolean); content += `\n\n${chalk.cyan.bold('Registry Information:')} diff --git a/src/domains/registry/mcpRegistryClient.test.ts b/src/domains/registry/mcpRegistryClient.test.ts index 9dc6cc92..c43f7a24 100644 --- a/src/domains/registry/mcpRegistryClient.test.ts +++ b/src/domains/registry/mcpRegistryClient.test.ts @@ -54,8 +54,6 @@ describe('MCPRegistryClient', () => { ], _meta: { 'io.modelcontextprotocol.registry/official': { - serverId: 'file-server-1', - versionId: 'v1.0.0', publishedAt: '2024-01-01T00:00:00Z', updatedAt: '2024-01-01T00:00:00Z', isLatest: true, @@ -81,8 +79,6 @@ describe('MCPRegistryClient', () => { ], _meta: { 'io.modelcontextprotocol.registry/official': { - serverId: 'database-server-1', - versionId: 'v1.0.1', publishedAt: '2024-01-02T00:00:00Z', updatedAt: '2024-01-02T00:00:00Z', isLatest: true, @@ -118,7 +114,7 @@ describe('MCPRegistryClient', () => { const result = await client.getServers(); - expect(mockAxiosInstance.get).toHaveBeenCalledWith('https://registry.test.com/v0/servers'); + expect(mockAxiosInstance.get).toHaveBeenCalledWith('https://registry.test.com/v0.1/servers'); expect(result).toEqual(mockServers); }); @@ -139,7 +135,7 @@ describe('MCPRegistryClient', () => { await client.getServers({ limit: 10, cursor: 'test-cursor-123' }); expect(mockAxiosInstance.get).toHaveBeenCalledWith( - 'https://registry.test.com/v0/servers?limit=10&cursor=test-cursor-123', + 'https://registry.test.com/v0.1/servers?limit=10&cursor=test-cursor-123', ); }); @@ -200,24 +196,48 @@ describe('MCPRegistryClient', () => { describe('getServerById', () => { it('should fetch server by ID successfully', async () => { + const mockServerResponse = { + server: mockServers[0], + _meta: { + 'io.modelcontextprotocol.registry/official': + mockServers[0]._meta['io.modelcontextprotocol.registry/official'], + }, + }; mockAxiosInstance.get.mockResolvedValueOnce({ - data: mockServers[0], + data: { + servers: [mockServerResponse], + metadata: { count: 1 }, + }, }); const result = await client.getServerById('file-server-1'); - expect(mockAxiosInstance.get).toHaveBeenCalledWith('https://registry.test.com/v0/servers/file-server-1'); + expect(mockAxiosInstance.get).toHaveBeenCalledWith( + 'https://registry.test.com/v0.1/servers/file-server-1/versions', + ); expect(result).toEqual(mockServers[0]); }); it('should encode server ID in URL', async () => { + const mockServerResponse = { + server: mockServers[0], + _meta: { + 'io.modelcontextprotocol.registry/official': + mockServers[0]._meta['io.modelcontextprotocol.registry/official'], + }, + }; mockAxiosInstance.get.mockResolvedValueOnce({ - data: mockServers[0], + data: { + servers: [mockServerResponse], + metadata: { count: 1 }, + }, }); await client.getServerById('server with spaces'); - expect(mockAxiosInstance.get).toHaveBeenCalledWith('https://registry.test.com/v0/servers/server%20with%20spaces'); + expect(mockAxiosInstance.get).toHaveBeenCalledWith( + 'https://registry.test.com/v0.1/servers/server%20with%20spaces/versions', + ); }); }); @@ -244,7 +264,7 @@ describe('MCPRegistryClient', () => { limit: 10, }); - expect(mockAxiosInstance.get).toHaveBeenCalledWith('https://registry.test.com/v0/servers?limit=10&search=file'); + expect(mockAxiosInstance.get).toHaveBeenCalledWith('https://registry.test.com/v0.1/servers?limit=10&search=file'); expect(result).toHaveLength(1); expect(result[0].name).toBe('file-server'); diff --git a/src/domains/registry/mcpRegistryClient.ts b/src/domains/registry/mcpRegistryClient.ts index 27efd080..bd02cbc5 100644 --- a/src/domains/registry/mcpRegistryClient.ts +++ b/src/domains/registry/mcpRegistryClient.ts @@ -6,13 +6,13 @@ import axios, { AxiosInstance, AxiosRequestConfig } from 'axios'; import { CacheManager } from './cacheManager.js'; import { - OFFICIAL_REGISTRY_KEY, RegistryClientOptions, RegistryOptions, RegistryServer, RegistryStatusResult, SearchOptions, ServerListOptions, + ServerMeta, ServersListResponse, ServerVersionsResponse, } from './types.js'; @@ -48,10 +48,13 @@ export class MCPRegistryClient { '/servers', params, async () => { - const url = `${this.baseUrl}/v0/servers${this.buildQueryString(params)}`; + const url = `${this.baseUrl}/v0.1/servers${this.buildQueryString(params)}`; const response = await this.makeRequest(url); - // Extract RegistryServer objects from ServerResponse objects - return (response.servers || []).map((sr) => sr.server); + // Extract RegistryServer objects from ServerResponse objects and merge metadata + return (response.servers || []).map((sr) => ({ + ...sr.server, + _meta: sr._meta as unknown as ServerMeta, // Cast to unknown then ServerMeta since ResponseMeta is compatible + })); }, 300, // 5 minutes TTL 'servers list', @@ -72,8 +75,9 @@ export class MCPRegistryClient { '/servers-metadata', params, async () => { - const url = `${this.baseUrl}/v0/servers${this.buildQueryString(params)}`; - return await this.makeRequest(url); + const url = `${this.baseUrl}/v0.1/servers${this.buildQueryString(params)}`; + const response = await this.makeRequest(url); + return response; }, 300, // 5 minutes TTL 'servers list with metadata', @@ -89,16 +93,35 @@ export class MCPRegistryClient { async getServerById(id: string, version?: string): Promise { const handler = withErrorHandling( async () => { - const cacheKey = version ? `/servers/${id}?version=${version}` : `/servers/${id}`; + const cacheKey = version ? `/servers/${id}/versions/${version}` : `/servers/${id}/versions`; return await this.withCache( cacheKey, undefined, async () => { - let url = `${this.baseUrl}/v0/servers/${encodeURIComponent(id)}`; + // The v0.1 API doesn't have direct server lookup, only versions endpoint + // GET /v0.1/servers/{serverName}/versions - get all versions or specific version + const url = `${this.baseUrl}/v0.1/servers/${encodeURIComponent(id)}/versions`; + const response = await this.makeRequest(url); + if (!response.servers || response.servers.length === 0) { + throw new Error(`No versions found for server: ${id}`); + } + + // Find the specific version if requested, otherwise return the first (latest) + let serverResponse; if (version) { - url += `?version=${encodeURIComponent(version)}`; + serverResponse = response.servers.find((sr) => sr.server.version === version); + if (!serverResponse) { + throw new Error(`Version ${version} not found for server: ${id}`); + } + } else { + serverResponse = response.servers[0]; // First one is typically the latest } - return await this.makeRequest(url); + + // Return the server with metadata merged from the response wrapper + return { + ...serverResponse.server, + _meta: serverResponse._meta as unknown as ServerMeta, + }; }, 600, // 10 minutes TTL for individual servers `server: ${id}${version ? ` (v${version})` : ''}`, @@ -119,20 +142,19 @@ export class MCPRegistryClient { `/servers/${id}/versions`, undefined, async () => { - const url = `${this.baseUrl}/v0/servers/${encodeURIComponent(id)}/versions`; + const url = `${this.baseUrl}/v0.1/servers/${encodeURIComponent(id)}/versions`; // The API returns servers in the same format as the main endpoint const response = await this.makeRequest(url); // Transform to the expected ServerVersionsResponse format const versions = (response.servers || []).map((serverResponse) => { const server = serverResponse.server; - const meta = server._meta[OFFICIAL_REGISTRY_KEY]; const registryMeta = serverResponse._meta['io.modelcontextprotocol.registry/official']; return { version: server.version, - publishedAt: meta.publishedAt, - updatedAt: meta.updatedAt, - isLatest: meta.isLatest, + publishedAt: registryMeta.publishedAt, + updatedAt: registryMeta.updatedAt, + isLatest: registryMeta.isLatest, status: registryMeta.status, }; }); @@ -167,7 +189,7 @@ export class MCPRegistryClient { async () => { // For search, we'll use the main servers endpoint with filters // This assumes the registry API supports these query parameters - const url = `${this.baseUrl}/v0/servers${this.buildQueryString(params)}`; + const url = `${this.baseUrl}/v0.1/servers${this.buildQueryString(params)}`; const response = await this.makeRequest(url); // Extract RegistryServer objects from ServerResponse objects return (response.servers || []).map((sr) => sr.server); @@ -193,7 +215,7 @@ export class MCPRegistryClient { try { // Check registry availability using health endpoint - const url = `${this.baseUrl}/v0/health`; + const url = `${this.baseUrl}/v0.1/health`; const healthResponse = await this.makeRequest<{ status: string; github_client_id?: string }>(url); const responseTime = Date.now() - startTime; @@ -320,7 +342,7 @@ export class MCPRegistryClient { } const response = await this.makeRequest( - `${this.baseUrl}/v0/servers${this.buildQueryString(this.buildParams(params))}`, + `${this.baseUrl}/v0.1/servers${this.buildQueryString(this.buildParams(params))}`, ); allServers.push(...(response.servers || []).map((sr) => sr.server)); diff --git a/src/domains/registry/searchFiltering.test.ts b/src/domains/registry/searchFiltering.test.ts index ca6e7094..6a2770d4 100644 --- a/src/domains/registry/searchFiltering.test.ts +++ b/src/domains/registry/searchFiltering.test.ts @@ -29,8 +29,6 @@ describe('SearchEngine', () => { ], _meta: { 'io.modelcontextprotocol.registry/official': { - serverId: 'file-manager-1', - versionId: 'v1.0.0', publishedAt: '2024-01-01T00:00:00Z', updatedAt: '2024-06-01T00:00:00Z', isLatest: true, @@ -56,8 +54,6 @@ describe('SearchEngine', () => { ], _meta: { 'io.modelcontextprotocol.registry/official': { - serverId: 'database-connector-1', - versionId: 'v2.1.0', publishedAt: '2024-02-01T00:00:00Z', updatedAt: '2024-07-01T00:00:00Z', isLatest: true, @@ -83,8 +79,6 @@ describe('SearchEngine', () => { ], _meta: { 'io.modelcontextprotocol.registry/official': { - serverId: 'legacy-files-1', - versionId: 'v0.9.0', publishedAt: '2023-01-01T00:00:00Z', updatedAt: '2023-06-01T00:00:00Z', isLatest: false, diff --git a/src/domains/registry/types.ts b/src/domains/registry/types.ts index 7061bda1..7aab7271 100644 --- a/src/domains/registry/types.ts +++ b/src/domains/registry/types.ts @@ -34,6 +34,7 @@ export interface Input { format?: string; isRequired?: boolean; isSecret?: boolean; + name?: string; value?: string; variables?: Record; } @@ -73,7 +74,7 @@ export interface ServerRemote { } export interface ServerMeta { - [OFFICIAL_REGISTRY_KEY]: RegistryExtensions; + 'io.modelcontextprotocol.registry/official': RegistryExtensions; [key: string]: unknown; } @@ -128,8 +129,6 @@ export interface RegistryExtensions { publishedAt: string; status: 'active' | 'deprecated' | 'archived'; updatedAt: string; - serverId: string; - versionId: string; } export interface OfficialMeta extends RegistryExtensions {} diff --git a/src/domains/server-management/index.ts b/src/domains/server-management/index.ts new file mode 100644 index 00000000..7d455674 --- /dev/null +++ b/src/domains/server-management/index.ts @@ -0,0 +1,14 @@ +/** + * Server Management Domain + * + * Provides services and utilities for managing MCP server installations, + * including install, update, uninstall, and status tracking. + */ + +// Export types +export * from './types.js'; + +// Export services +export * from './serverInstallationService.js'; +export * from './progressTrackingService.js'; +export * from './services/versionResolver.js'; diff --git a/src/domains/server-management/progressTrackingService.ts b/src/domains/server-management/progressTrackingService.ts new file mode 100644 index 00000000..561fa01f --- /dev/null +++ b/src/domains/server-management/progressTrackingService.ts @@ -0,0 +1,151 @@ +import { EventEmitter } from 'events'; + +import logger from '@src/logger/logger.js'; + +/** + * Progress tracking service for server management operations + * Provides step-by-step progress indicators with dynamic progress bars + */ + +export interface OperationProgress { + operationId: string; + operationType: string; + currentStep: number; + totalSteps: number; + stepName: string; + progress: number; // 0-100 + message?: string; + startedAt: Date; + updatedAt: Date; +} + +export interface OperationResult { + success: boolean; + operationId: string; + duration: number; + message?: string; + error?: Error; +} + +export type OperationType = 'install' | 'update' | 'uninstall' | 'search'; + +export class ProgressTrackingService extends EventEmitter { + private operations: Map = new Map(); + + /** + * Start tracking an operation + */ + startOperation(operationId: string, operationType: OperationType, totalSteps: number = 5): void { + const progress: OperationProgress = { + operationId, + operationType, + currentStep: 0, + totalSteps, + stepName: 'Initializing...', + progress: 0, + startedAt: new Date(), + updatedAt: new Date(), + }; + + this.operations.set(operationId, progress); + this.emit('operation-started', progress); + + logger.info( + `🚀 ${operationType.charAt(0).toUpperCase() + operationType.slice(1)} operation started: ${operationId}`, + ); + } + + /** + * Update progress for an operation + */ + updateProgress(operationId: string, currentStep: number, stepName: string, message?: string): void { + const progress = this.operations.get(operationId); + if (!progress) { + logger.warn(`No progress tracked for operation: ${operationId}`); + return; + } + + const newProgress = Math.round((currentStep / progress.totalSteps) * 100); + + progress.currentStep = currentStep; + progress.stepName = stepName; + progress.progress = newProgress; + progress.message = message; + progress.updatedAt = new Date(); + + this.emit('progress-updated', progress); + + const progressBar = '█'.repeat(Math.floor(newProgress / 5)) + '░'.repeat(20 - Math.floor(newProgress / 5)); + logger.info(` [${progressBar}] ${newProgress}% - ${stepName}`); + } + + /** + * Complete an operation + */ + completeOperation(operationId: string, result?: OperationResult): void { + const progress = this.operations.get(operationId); + if (!progress) { + logger.warn(`No progress tracked for operation: ${operationId}`); + return; + } + + const duration = new Date().getTime() - progress.startedAt.getTime(); + + const operationResult: OperationResult = { + success: true, + operationId, + duration, + ...result, + }; + + this.emit('operation-completed', operationResult); + this.operations.delete(operationId); + + logger.info(`✅ Operation completed in ${duration}ms: ${operationId}`); + } + + /** + * Fail an operation + */ + failOperation(operationId: string, error: Error): void { + const progress = this.operations.get(operationId); + if (!progress) { + logger.warn(`No progress tracked for operation: ${operationId}`); + return; + } + + const duration = new Date().getTime() - progress.startedAt.getTime(); + + const operationResult: OperationResult = { + success: false, + operationId, + duration, + error, + message: error.message, + }; + + this.emit('operation-failed', operationResult); + this.operations.delete(operationId); + + logger.error(`❌ Operation failed after ${duration}ms: ${operationId} - ${error.message}`); + } + + /** + * Get operation status + */ + getOperationStatus(operationId: string): OperationProgress | undefined { + return this.operations.get(operationId); + } +} + +/** + * Singleton instance of progress tracking service + */ +let progressTrackingServiceInstance: ProgressTrackingService | null = null; + +export function getProgressTrackingService(): ProgressTrackingService { + if (!progressTrackingServiceInstance) { + progressTrackingServiceInstance = new ProgressTrackingService(); + } + return progressTrackingServiceInstance; +} diff --git a/src/domains/server-management/serverInstallationService.ts b/src/domains/server-management/serverInstallationService.ts new file mode 100644 index 00000000..094639c5 --- /dev/null +++ b/src/domains/server-management/serverInstallationService.ts @@ -0,0 +1,405 @@ +import { + getAllServers, + getInstallationMetadata, + getServer, + setServer, + validateServerConfig, +} from '@src/commands/mcp/utils/configUtils.js'; +import ConfigContext from '@src/config/configContext.js'; +import { MCPServerParams } from '@src/core/types/index.js'; +import logger from '@src/logger/logger.js'; + +import { createRegistryClient } from '../registry/mcpRegistryClient.js'; +import type { RegistryServer } from '../registry/types.js'; +import { getProgressTrackingService } from './progressTrackingService.js'; +import { compareVersions, getUpdateType } from './services/versionResolver.js'; +import type { + InstallOptions, + InstallResult, + ListOptions, + UninstallOptions, + UninstallResult, + UpdateCheckResult, + UpdateOptions, + UpdateResult, +} from './types.js'; + +// Re-export version utilities for backward compatibility +export { compareVersions, getUpdateType, parseVersion } from './services/versionResolver.js'; + +/** + * Server installation service + * Handles install, update, uninstall, and status operations for MCP servers + */ +export class ServerInstallationService { + private registryClient; + private progressTracker; + + constructor() { + this.registryClient = createRegistryClient(); + this.progressTracker = getProgressTrackingService(); + } + + /** + * Validate server name format + */ + private validateServerName(serverName: string): void { + if (!serverName || serverName.trim().length === 0) { + throw new Error('Server name cannot be empty'); + } + + const trimmedName = serverName.trim(); + + // Check for invalid characters + // eslint-disable-next-line no-control-regex + const invalidChars = /[<>:"\\|?*\x00-\x1f]/; + if (invalidChars.test(trimmedName)) { + throw new Error(`Server name contains invalid characters: ${serverName}`); + } + + // Check length limits + if (trimmedName.length > 255) { + throw new Error(`Server name too long (max 255 characters): ${serverName}`); + } + + // Check for consecutive slashes or dots + if (trimmedName.includes('//') || trimmedName.includes('..')) { + throw new Error(`Server name contains invalid sequences: ${serverName}`); + } + + // Log the validated name for debugging + logger.debug(`Server name validation passed: ${trimmedName}`); + } + + /** + * Install a server from the registry + */ + async installServer(registryServerId: string, version?: string, options?: InstallOptions): Promise { + const operationId = `op_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`; + const warnings: string[] = []; + const errors: string[] = []; + + try { + const localServerName = options?.localServerName || registryServerId; + logger.info( + `Starting installation of ${registryServerId}${version ? `@${version}` : ''} as '${localServerName}'`, + ); + + // Validate local server name format (if provided) + if (options?.localServerName) { + this.validateServerName(options.localServerName); + } + + // Get server information from registry + logger.debug(`Fetching server from registry: ${registryServerId}${version ? `@${version}` : ''}`); + const registryServer = await this.registryClient.getServerById(registryServerId, version); + + if (!registryServer) { + throw new Error(`Server '${registryServerId}' not found in registry`); + } + + // Select appropriate remote/package for installation + const selectedRemote = this.selectRemoteEndpoint(registryServer); + if (!selectedRemote) { + throw new Error(`No compatible installation method found for ${registryServerId}`); + } + + // Generate server configuration from registry data + const serverConfig = await this.createServerConfig(registryServer, selectedRemote); + + // Apply user-provided tags, env, and args from wizard + if (options?.tags && options.tags.length > 0) { + serverConfig.tags = options.tags; + } + if (options?.env) { + serverConfig.env = { ...serverConfig.env, ...options.env }; + } + if (options?.args && options.args.length > 0) { + // Merge with existing args if any + serverConfig.args = [...(serverConfig.args || []), ...options.args]; + } + + // Validate and persist configuration + validateServerConfig(serverConfig); + setServer(localServerName, serverConfig); + + // Resolve config path for result reporting + const configContext = ConfigContext.getInstance(); + const resolvedConfigPath = configContext.getResolvedConfigPath(); + + // Create installation result + const result: InstallResult = { + success: true, + serverName: localServerName, + version: registryServer.version, + installedAt: new Date(), + configPath: resolvedConfigPath, + backupPath: undefined, + warnings, + errors, + operationId, + }; + + logger.info(`Successfully prepared installation configuration for ${localServerName}`); + return result; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + errors.push(errorMessage); + + // Enhanced error logging with debugging context + logger.error(`Installation failed for ${registryServerId}: ${errorMessage}`); + logger.debug(`Installation failure details:`, { + registryServerId, + localServerName: options?.localServerName, + version, + errorType: error?.constructor?.name, + errorMessage, + timestamp: new Date().toISOString(), + }); + + // Re-throw with enhanced context + throw new Error(`Failed to install server '${registryServerId}'${version ? `@${version}` : ''}: ${errorMessage}`); + } + } + + /** + * Select appropriate remote endpoint for the current system + */ + private selectRemoteEndpoint(registryServer: RegistryServer): { type: string; url: string } | undefined { + // First try packages (newer registry format) + const packages = registryServer.packages || []; + + if (packages.length > 0) { + // Look for stdio transport packages (most common for MCP servers) + const stdioPackage = packages.find((pkg) => pkg.transport?.type === 'stdio'); + if (stdioPackage) { + // Use package identifier to construct the installation command + const identifier = stdioPackage.identifier; + return { type: 'stdio', url: `npx ${identifier}` }; + } + + // Look for other transport types + const httpPackage = packages.find((pkg) => pkg.transport?.type === 'http' || pkg.transport?.type === 'sse'); + if (httpPackage) { + return { type: httpPackage.transport!.type, url: httpPackage.transport!.url || '' }; + } + + // Fallback to first package + const firstPackage = packages[0]; + if (firstPackage.transport) { + return { type: firstPackage.transport.type, url: firstPackage.transport.url || firstPackage.identifier }; + } + } + + // Fallback to remotes (legacy format) + const remotes = registryServer.remotes || []; + + // Prefer streamable-http (npx-based) as most common + const streamableHttp = remotes.find((r) => r.type === 'streamable-http'); + if (streamableHttp) { + return { type: streamableHttp.type, url: streamableHttp.url }; + } + + // Fallback to first available remote + if (remotes.length > 0) { + return { type: remotes[0].type, url: remotes[0].url }; + } + + return undefined; + } + + /** + * Create server configuration from registry data + */ + private async createServerConfig( + _registryServer: RegistryServer, + remote: { type: string; url: string }, + ): Promise { + // Create a configuration based on remote type + const remoteType = remote.type?.toLowerCase(); + + if (remoteType === 'http' || remoteType === 'sse') { + return { + type: remoteType as 'http' | 'sse', + url: remote.url, + } as MCPServerParams; + } + + // streamable-http and other npx-based installers ? stdio + if (remoteType === 'streamable-http' || remoteType === 'stdio') { + const tokens = remote.url.trim().split(/\s+/); + const command = tokens.shift() || 'npx'; + const args = tokens; + return { + type: 'stdio', + command, + args: args.length > 0 ? args : undefined, + } as MCPServerParams; + } + + // Fallback: treat as stdio command + const tokens = remote.url.trim().split(/\s+/); + const command = tokens.shift() || remote.url; + const args = tokens; + return { + type: 'stdio', + command, + args: args.length > 0 ? args : undefined, + } as MCPServerParams; + } + + /** + * Update a server to latest or specific version + */ + async updateServer(serverName: string, version?: string, _options?: UpdateOptions): Promise { + logger.info(`Updating server ${serverName}${version ? ` to ${version}` : ' to latest'}`); + + const operationId = `op_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`; + + try { + // Get latest version from registry if not specified + const targetVersion = version || 'latest'; + const registryServer = await this.registryClient.getServerById(serverName, targetVersion); + + if (!registryServer) { + throw new Error(`Server '${serverName}' not found in registry`); + } + + // Import config utilities to update configuration + // Get current configuration + const currentConfig = getServer(serverName); + if (!currentConfig) { + throw new Error(`Server '${serverName}' not found in configuration`); + } + + // Create updated configuration with new version info + const updatedConfig: MCPServerParams = { + ...currentConfig, + // Store version in metadata (future enhancement) + }; + + // Save updated configuration + setServer(serverName, updatedConfig); + + return { + success: true, + serverName, + previousVersion: 'unknown', + newVersion: registryServer.version, + updatedAt: new Date(), + warnings: [], + errors: [], + operationId, + }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + logger.error(`Update failed for ${serverName}: ${errorMessage}`); + + return { + success: false, + serverName, + previousVersion: 'unknown', + newVersion: version || 'unknown', + updatedAt: new Date(), + warnings: [], + errors: [errorMessage], + operationId, + }; + } + } + + /** + * Uninstall a server + */ + async uninstallServer(serverName: string, _options?: UninstallOptions): Promise { + logger.info(`Uninstalling server ${serverName}`); + + const operationId = `op_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`; + + const result: UninstallResult = { + success: true, + serverName, + removedAt: new Date(), + configRemoved: true, + warnings: [], + errors: [], + operationId, + }; + + return result; + } + + /** + * Check for available updates + */ + async checkForUpdates(_serverNames?: string[]): Promise { + logger.info(`Checking for updates${_serverNames ? ` for ${_serverNames.length} servers` : ''}`); + + const results: UpdateCheckResult[] = []; + + // Get list of servers to check + const serversToCheck = _serverNames || (await this.listInstalledServers()); + + // Check each server for available updates + for (const serverName of serversToCheck) { + try { + // Get current installed version + const metadata = getInstallationMetadata(serverName); + const currentVersion = metadata?.version || 'unknown'; + + // Fetch latest version from registry + const latestServer = await this.registryClient.getServerById(serverName); + + if (latestServer) { + // Compare versions using semantic versioning + const hasUpdate = currentVersion !== 'unknown' && compareVersions(latestServer.version, currentVersion) > 0; + const updateType = + currentVersion !== 'unknown' ? getUpdateType(currentVersion, latestServer.version) : undefined; + + results.push({ + serverName, + currentVersion, + latestVersion: latestServer.version, + hasUpdate, + updateAvailable: hasUpdate, + updateType, + }); + } + } catch (error) { + // Silently skip servers that can't be checked + logger.debug(`Could not check updates for ${serverName}: ${error}`); + } + } + + return results; + } + + /** + * List installed servers + */ + async listInstalledServers(_options?: ListOptions): Promise { + logger.info('Listing installed servers'); + + // Import config utilities dynamically to avoid circular dependencies + // Get all servers from configuration + const allServers = getAllServers(); + + // Extract server names + const serverNames = Object.keys(allServers); + + // Apply filters if options provided + if (_options?.filterActive) { + // Filter to only non-disabled servers + return serverNames.filter((name) => !allServers[name]?.disabled); + } + + return serverNames; + } +} + +/** + * Create a server installation service instance + */ +export function createServerInstallationService(): ServerInstallationService { + return new ServerInstallationService(); +} diff --git a/src/domains/server-management/services/versionResolver.test.ts b/src/domains/server-management/services/versionResolver.test.ts new file mode 100644 index 00000000..e08853ee --- /dev/null +++ b/src/domains/server-management/services/versionResolver.test.ts @@ -0,0 +1,144 @@ +import { describe, expect, it } from 'vitest'; + +import { + cleanVersion, + compareVersions, + getUpdateType, + isNewerVersion, + isValidVersion, + parseVersion, +} from './versionResolver.js'; + +describe('versionResolver', () => { + describe('parseVersion', () => { + it('should parse valid semantic versions', () => { + expect(parseVersion('1.2.3')).toEqual({ major: 1, minor: 2, patch: 3 }); + expect(parseVersion('0.0.1')).toEqual({ major: 0, minor: 0, patch: 1 }); + expect(parseVersion('10.20.30')).toEqual({ major: 10, minor: 20, patch: 30 }); + }); + + it('should parse versions with v prefix', () => { + expect(parseVersion('v1.2.3')).toEqual({ major: 1, minor: 2, patch: 3 }); + }); + + it('should return null for invalid versions', () => { + expect(parseVersion('invalid')).toBeNull(); + expect(parseVersion('1.2')).toBeNull(); + expect(parseVersion('1')).toBeNull(); + expect(parseVersion('')).toBeNull(); + }); + + it('should handle prerelease versions', () => { + const result = parseVersion('1.2.3-alpha.1'); + expect(result?.major).toBe(1); + expect(result?.minor).toBe(2); + expect(result?.patch).toBe(3); + }); + }); + + describe('compareVersions', () => { + it('should return 1 when v1 > v2', () => { + expect(compareVersions('2.0.0', '1.0.0')).toBe(1); + expect(compareVersions('1.1.0', '1.0.0')).toBe(1); + expect(compareVersions('1.0.1', '1.0.0')).toBe(1); + }); + + it('should return -1 when v1 < v2', () => { + expect(compareVersions('1.0.0', '2.0.0')).toBe(-1); + expect(compareVersions('1.0.0', '1.1.0')).toBe(-1); + expect(compareVersions('1.0.0', '1.0.1')).toBe(-1); + }); + + it('should return 0 when versions are equal', () => { + expect(compareVersions('1.0.0', '1.0.0')).toBe(0); + expect(compareVersions('v1.2.3', '1.2.3')).toBe(0); + }); + + it('should handle v prefix', () => { + expect(compareVersions('v2.0.0', '1.0.0')).toBe(1); + expect(compareVersions('2.0.0', 'v1.0.0')).toBe(1); + }); + + it('should return 0 for invalid versions', () => { + expect(compareVersions('invalid', '1.0.0')).toBe(0); + expect(compareVersions('1.0.0', 'invalid')).toBe(0); + }); + }); + + describe('getUpdateType', () => { + it('should identify major updates', () => { + expect(getUpdateType('1.0.0', '2.0.0')).toBe('major'); + expect(getUpdateType('1.5.3', '2.0.0')).toBe('major'); + }); + + it('should identify minor updates', () => { + expect(getUpdateType('1.0.0', '1.1.0')).toBe('minor'); + expect(getUpdateType('1.0.5', '1.1.0')).toBe('minor'); + }); + + it('should identify patch updates', () => { + expect(getUpdateType('1.0.0', '1.0.1')).toBe('patch'); + expect(getUpdateType('1.0.5', '1.0.6')).toBe('patch'); + }); + + it('should return undefined for same version', () => { + expect(getUpdateType('1.0.0', '1.0.0')).toBeUndefined(); + }); + + it('should return undefined when new version is older', () => { + expect(getUpdateType('2.0.0', '1.0.0')).toBeUndefined(); + expect(getUpdateType('1.1.0', '1.0.0')).toBeUndefined(); + }); + + it('should return undefined for invalid versions', () => { + expect(getUpdateType('invalid', '1.0.0')).toBeUndefined(); + expect(getUpdateType('1.0.0', 'invalid')).toBeUndefined(); + }); + + it('should handle prerelease versions', () => { + expect(getUpdateType('1.0.0', '2.0.0-alpha.1')).toBe('major'); + expect(getUpdateType('1.0.0', '1.1.0-beta.1')).toBe('minor'); + }); + }); + + describe('isValidVersion', () => { + it('should accept valid semantic versions', () => { + expect(isValidVersion('1.0.0')).toBe(true); + expect(isValidVersion('1.2.3')).toBe(true); + expect(isValidVersion('v1.2.3')).toBe(true); + expect(isValidVersion('1.0.0-alpha')).toBe(true); + }); + + it('should reject invalid versions', () => { + expect(isValidVersion('invalid')).toBe(false); + expect(isValidVersion('1.2')).toBe(false); + expect(isValidVersion('1')).toBe(false); + expect(isValidVersion('')).toBe(false); + }); + }); + + describe('cleanVersion', () => { + it('should clean valid versions', () => { + expect(cleanVersion('v1.2.3')).toBe('1.2.3'); + expect(cleanVersion('1.2.3')).toBe('1.2.3'); + }); + + it('should return null for invalid versions', () => { + expect(cleanVersion('invalid')).toBeNull(); + expect(cleanVersion('')).toBeNull(); + }); + }); + + describe('isNewerVersion', () => { + it('should return true when v1 is newer', () => { + expect(isNewerVersion('2.0.0', '1.0.0')).toBe(true); + expect(isNewerVersion('1.1.0', '1.0.0')).toBe(true); + expect(isNewerVersion('1.0.1', '1.0.0')).toBe(true); + }); + + it('should return false when v1 is older or equal', () => { + expect(isNewerVersion('1.0.0', '2.0.0')).toBe(false); + expect(isNewerVersion('1.0.0', '1.0.0')).toBe(false); + }); + }); +}); diff --git a/src/domains/server-management/services/versionResolver.ts b/src/domains/server-management/services/versionResolver.ts new file mode 100644 index 00000000..70870989 --- /dev/null +++ b/src/domains/server-management/services/versionResolver.ts @@ -0,0 +1,101 @@ +import semver from 'semver'; + +/** + * Version parsing, comparison, and resolution utilities + */ + +export interface ParsedVersion { + major: number; + minor: number; + patch: number; +} + +export type UpdateType = 'major' | 'minor' | 'patch'; + +/** + * Parse semantic version string into components + */ +export function parseVersion(version: string): ParsedVersion | null { + const parsed = semver.parse(version); + if (!parsed) { + return null; + } + + return { + major: parsed.major, + minor: parsed.minor, + patch: parsed.patch, + }; +} + +/** + * Compare two semantic versions + * Returns: 1 if v1 > v2, -1 if v1 < v2, 0 if equal + */ +export function compareVersions(v1: string, v2: string): number { + // Clean versions to handle 'v' prefix and other formats + const clean1 = semver.clean(v1); + const clean2 = semver.clean(v2); + + if (!clean1 || !clean2) { + return 0; + } + + return semver.compare(clean1, clean2); +} + +/** + * Determine update type based on version comparison + * Returns the type of update (major/minor/patch) if newVersion > currentVersion, undefined otherwise + */ +export function getUpdateType(currentVersion: string, newVersion: string): UpdateType | undefined { + const clean1 = semver.clean(currentVersion); + const clean2 = semver.clean(newVersion); + + if (!clean1 || !clean2) { + return undefined; + } + + // First check if new version is actually greater + if (!semver.gt(clean2, clean1)) { + return undefined; + } + + // Now determine the type of update + const diff = semver.diff(clean1, clean2); + + if (diff === 'major' || diff === 'premajor') { + return 'major'; + } + + if (diff === 'minor' || diff === 'preminor') { + return 'minor'; + } + + if (diff === 'patch' || diff === 'prepatch') { + return 'patch'; + } + + return undefined; +} + +/** + * Check if a version is valid semver + */ +export function isValidVersion(version: string): boolean { + return semver.valid(version) !== null; +} + +/** + * Clean version string (remove 'v' prefix, etc.) + */ +export function cleanVersion(version: string): string | null { + return semver.clean(version); +} + +/** + * Check if version1 is greater than version2 + */ +export function isNewerVersion(v1: string, v2: string): boolean { + return compareVersions(v1, v2) > 0; +} diff --git a/src/domains/server-management/types.ts b/src/domains/server-management/types.ts new file mode 100644 index 00000000..a01d470e --- /dev/null +++ b/src/domains/server-management/types.ts @@ -0,0 +1,242 @@ +import type { MCPServerParams } from '@src/core/types/index.js'; +import type { RegistryServer } from '@src/domains/registry/types.js'; + +/** + * Type definitions for MCP server management domain + */ + +/** + * Installation status enum + */ +export enum InstallationStatus { + INSTALLED = 'installed', + UPDATING = 'updating', + FAILED = 'failed', + UNINSTALLING = 'uninstalling', + OUTDATED = 'outdated', + CORRUPTED = 'corrupted', + DISABLED = 'disabled', + NOT_INSTALLED = 'not_installed', +} + +/** + * Backup operation type + */ +export enum BackupOperation { + INSTALL = 'install', + UPDATE = 'update', + UNINSTALL = 'uninstall', + CONFIG_CHANGE = 'config_change', +} + +/** + * Dependency resolution status + */ +export interface DependencyResolution { + status: 'resolved' | 'missing' | 'conflict' | 'warning'; + resolvedDependencies: ResolvedDependency[]; + missingDependencies: string[]; + conflicts: DependencyConflict[]; +} + +/** + * Resolved dependency information + */ +export interface ResolvedDependency { + name: string; + version: string; + status: 'installed' | 'available' | 'missing'; +} + +/** + * Dependency conflict information + */ +export interface DependencyConflict { + dependency: string; + requiredVersions: string[]; + message: string; +} + +/** + * Represents an installed MCP server with metadata and lifecycle state + */ +export interface McpServerInstallation { + // Primary identification + id: string; // Server identifier (from registry) + name: string; // User-defined name (unique in configuration) + version: string; // Installed version + + // Registry information + registryEntry: RegistryServer; // Original registry metadata + + // Installation metadata + installedAt: Date; // Installation timestamp + installedBy: string; // 1MCP version that performed installation + installPath?: string; // Local installation path (if applicable) + + // Configuration + config: MCPServerParams; // Server configuration overrides + + // Status and lifecycle + status: InstallationStatus; // Current installation status + lastUpdateCheck?: Date; // Last time updates were checked + availableUpdate?: string; // Latest available version (if different) + + // Dependencies + dependencies: string[]; // List of required dependencies + dependencyResolution: DependencyResolution; +} + +/** + * Installation options + */ +export interface InstallOptions { + force?: boolean; // Force installation even if already exists + dryRun?: boolean; // Show what would be installed without installing + verbose?: boolean; // Detailed output + localServerName?: string; // Local server name to use instead of registry ID + tags?: string[]; // Tags to apply to the server + env?: Record; // Environment variables + args?: string[]; // Additional arguments +} + +/** + * Update options + */ +export interface UpdateOptions { + version?: string; // Specific version to update to + backup?: boolean; // Create backup before update (default: true) + dryRun?: boolean; // Show what would be updated + verbose?: boolean; // Detailed output +} + +/** + * Uninstall options + */ +export interface UninstallOptions { + force?: boolean; // Skip confirmation prompts + backup?: boolean; // Create backup before removal (default: true) + removeConfig?: boolean; // Remove server configuration (default: true) + verbose?: boolean; // Detailed output +} + +/** + * Installation result + */ +export interface InstallResult { + success: boolean; + serverName: string; + version: string; + installedAt: Date; + configPath: string; + backupPath?: string; // Created if replacing existing server + warnings: string[]; + errors: string[]; + operationId: string; // For tracking progress +} + +/** + * Update result + */ +export interface UpdateResult { + success: boolean; + serverName?: string; // Single server update (for backward compatibility) + previousVersion?: string; // Previous version + newVersion?: string; // New version + updatedAt?: Date; // Update timestamp + updatedServers?: UpdatedServer[]; // Batch update servers + skippedServers?: SkippedServer[]; + failedServers?: FailedServer[]; + backupPath?: string; // Created if backup made + operationId: string; // For tracking progress + warnings: string[]; // Warnings + errors: string[]; // Errors +} + +/** + * Updated server information + */ +export interface UpdatedServer { + serverName: string; + previousVersion: string; + newVersion: string; + updatedAt: Date; + warnings: string[]; +} + +/** + * Skipped server information + */ +export interface SkippedServer { + serverName: string; + reason: 'uptodate' | 'excluded' | 'notfound'; +} + +/** + * Failed server information + */ +export interface FailedServer { + serverName: string; + error: string; + restored?: boolean; // If backup was restored +} + +/** + * Uninstall result + */ +export interface UninstallResult { + success: boolean; + serverName: string; + removedAt: Date; + backupPath?: string; // Created if backup made + configRemoved: boolean; + warnings: string[]; + errors: string[]; + operationId: string; // For tracking progress +} + +/** + * Update check result + */ +export interface UpdateCheckResult { + serverName: string; + currentVersion?: string; + latestVersion: string; + hasUpdate?: boolean; // Alias for backward compatibility + updateAvailable: boolean; + updateType?: 'major' | 'minor' | 'patch'; + compatibility?: { + nodeVersion: string; + platformCompatibility: string[]; + mcpVersion: string; + }; +} + +/** + * List options for installed servers + */ +export interface ListOptions { + includeDisabled?: boolean; + includeOutdated?: boolean; + filterActive?: boolean; // Filter to only active servers + filters?: { + tags?: string[]; + status?: InstallationStatus[]; + }; +} + +/** + * Installed server information + */ +export interface InstalledServer { + name: string; + version: string; + status: InstallationStatus; + installedAt: Date; + lastUpdateCheck?: Date; + availableUpdate?: string; + registryInfo?: { + name: string; + description: string; + }; +} diff --git a/src/domains/server-management/versionComparison.test.ts b/src/domains/server-management/versionComparison.test.ts new file mode 100644 index 00000000..24270e31 --- /dev/null +++ b/src/domains/server-management/versionComparison.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, it } from 'vitest'; + +// Import the module to access private functions through module exports +// We'll need to export these functions for testing +import { compareVersions, getUpdateType, parseVersion } from './serverInstallationService.js'; + +describe('Version Comparison Utilities', () => { + describe('parseVersion', () => { + it('should parse valid semantic version', () => { + const result = parseVersion('1.2.3'); + expect(result).toEqual({ major: 1, minor: 2, patch: 3 }); + }); + + it('should parse version with v prefix', () => { + const result = parseVersion('v2.4.6'); + expect(result).toEqual({ major: 2, minor: 4, patch: 6 }); + }); + + it('should handle version with build metadata', () => { + const result = parseVersion('1.2.3-alpha.1+build.123'); + expect(result).toEqual({ major: 1, minor: 2, patch: 3 }); + }); + + it('should return null for invalid version', () => { + expect(parseVersion('invalid')).toBeNull(); + expect(parseVersion('1.2')).toBeNull(); + expect(parseVersion('a.b.c')).toBeNull(); + }); + + it('should handle zero versions', () => { + const result = parseVersion('0.0.0'); + expect(result).toEqual({ major: 0, minor: 0, patch: 0 }); + }); + }); + + describe('compareVersions', () => { + it('should return 0 for equal versions', () => { + expect(compareVersions('1.2.3', '1.2.3')).toBe(0); + expect(compareVersions('v1.2.3', '1.2.3')).toBe(0); + }); + + it('should return 1 when v1 > v2', () => { + expect(compareVersions('2.0.0', '1.0.0')).toBe(1); + expect(compareVersions('1.3.0', '1.2.0')).toBe(1); + expect(compareVersions('1.2.4', '1.2.3')).toBe(1); + }); + + it('should return -1 when v1 < v2', () => { + expect(compareVersions('1.0.0', '2.0.0')).toBe(-1); + expect(compareVersions('1.2.0', '1.3.0')).toBe(-1); + expect(compareVersions('1.2.3', '1.2.4')).toBe(-1); + }); + + it('should prioritize major version differences', () => { + expect(compareVersions('2.0.0', '1.9.9')).toBe(1); + expect(compareVersions('1.9.9', '2.0.0')).toBe(-1); + }); + + it('should prioritize minor version over patch', () => { + expect(compareVersions('1.3.0', '1.2.9')).toBe(1); + expect(compareVersions('1.2.9', '1.3.0')).toBe(-1); + }); + + it('should return 0 for invalid versions', () => { + expect(compareVersions('invalid', '1.2.3')).toBe(0); + expect(compareVersions('1.2.3', 'invalid')).toBe(0); + expect(compareVersions('invalid', 'also-invalid')).toBe(0); + }); + }); + + describe('getUpdateType', () => { + it('should detect major update', () => { + expect(getUpdateType('1.2.3', '2.0.0')).toBe('major'); + expect(getUpdateType('1.9.9', '2.0.0')).toBe('major'); + }); + + it('should detect minor update', () => { + expect(getUpdateType('1.2.3', '1.3.0')).toBe('minor'); + expect(getUpdateType('1.2.9', '1.3.0')).toBe('minor'); + }); + + it('should detect patch update', () => { + expect(getUpdateType('1.2.3', '1.2.4')).toBe('patch'); + expect(getUpdateType('1.2.3', '1.2.9')).toBe('patch'); + }); + + it('should return undefined for same version', () => { + expect(getUpdateType('1.2.3', '1.2.3')).toBeUndefined(); + }); + + it('should return undefined for downgrade', () => { + expect(getUpdateType('2.0.0', '1.9.9')).toBeUndefined(); + expect(getUpdateType('1.3.0', '1.2.9')).toBeUndefined(); + }); + + it('should return undefined for invalid versions', () => { + expect(getUpdateType('invalid', '1.2.3')).toBeUndefined(); + expect(getUpdateType('1.2.3', 'invalid')).toBeUndefined(); + }); + + it('should handle v prefix', () => { + expect(getUpdateType('v1.2.3', 'v1.3.0')).toBe('minor'); + expect(getUpdateType('1.2.3', 'v1.3.0')).toBe('minor'); + }); + }); +}); diff --git a/test/e2e/integration/mcp-lifecycle.test.ts b/test/e2e/integration/mcp-lifecycle.test.ts new file mode 100644 index 00000000..f56fc7dd --- /dev/null +++ b/test/e2e/integration/mcp-lifecycle.test.ts @@ -0,0 +1,317 @@ +import { TestFixtures } from '@test/e2e/fixtures/TestFixtures.js'; +import { CliTestRunner, CommandTestEnvironment } from '@test/e2e/utils/index.js'; + +import { readFile } from 'fs/promises'; + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +/** + * E2E tests for complete MCP server lifecycle + * Tests install -> update -> uninstall workflows + */ + +describe('MCP Server Lifecycle E2E', () => { + let environment: CommandTestEnvironment; + let runner: CliTestRunner; + + beforeEach(async () => { + environment = new CommandTestEnvironment(TestFixtures.createTestScenario('mcp-lifecycle-test', 'empty')); + await environment.setup(); + runner = new CliTestRunner(environment); + }); + + afterEach(async () => { + await environment.cleanup(); + }); + + describe('Install Workflow', () => { + it('should install a server from registry using dry-run mode', async () => { + const result = await runner.runMcpCommand('install', { + args: ['filesystem', '--dry-run'], + timeout: 30000, + }); + + runner.assertSuccess(result); + runner.assertOutputContains(result, 'Dry run mode'); + runner.assertOutputContains(result, 'Would install: filesystem'); + runner.assertOutputContains(result, 'From registry'); + }); + + it('should install a server and verify configuration changes', async () => { + // First check that server doesn't exist + const initialList = await runner.runMcpCommand('list'); + runner.assertOutputDoesNotContain(initialList, 'test-installed-server'); + + // Install a server (we'll use a mock server for testing) + // For real E2E test, this would need actual registry access + // For now, test the command structure and error handling + const installResult = await runner.runMcpCommand('install', { + args: ['nonexistent-test-server-xyz-12345'], + timeout: 30000, + expectError: true, + }); + + // Should fail with server not found (expected behavior) + runner.assertFailure(installResult); + runner.assertOutputContains(installResult, 'Failed to fetch server', true); + }); + + it('should handle force install when server already exists', async () => { + // First add a server manually + await runner.runMcpCommand('add', { + args: ['existing-server', '--type', 'stdio', '--command', 'echo', '--args', 'test'], + }); + + // Try to install with same name (should fail without --force) + const failResult = await runner.runMcpCommand('install', { + args: ['existing-server', '--dry-run'], + timeout: 30000, + expectError: true, + }); + + runner.assertFailure(failResult); + runner.assertOutputContains(failResult, 'already exists', true); + + // With --force, should proceed (dry-run only) + const forceResult = await runner.runMcpCommand('install', { + args: ['existing-server', '--force', '--dry-run'], + timeout: 30000, + }); + + runner.assertSuccess(forceResult); + runner.assertOutputContains(forceResult, 'Would install'); + }); + + it('should validate server name format', async () => { + const result = await runner.runMcpCommand('install', { + args: ['invalid server name', '--dry-run'], + timeout: 30000, + expectError: true, + }); + + runner.assertFailure(result); + // Should validate server name format + runner.assertOutputContains(result, 'Server name', true); + }); + }); + + describe('Update Workflow', () => { + it('should update server configuration', async () => { + // First add a server + await runner.runMcpCommand('add', { + args: ['updatable-server', '--type', 'stdio', '--command', 'echo', '--args', 'old'], + }); + + // Verify initial configuration + const initialConfig = await readFile(environment.getConfigPath(), 'utf-8'); + expect(initialConfig).toContain('updatable-server'); + expect(initialConfig).toContain('old'); + + // Update server configuration + const updateResult = await runner.runMcpCommand('update', { + args: ['updatable-server', '--args', 'new'], + }); + + runner.assertSuccess(updateResult); + runner.assertOutputContains(updateResult, 'Successfully updated server'); + + // Verify configuration changed + const updatedConfig = await readFile(environment.getConfigPath(), 'utf-8'); + expect(updatedConfig).toContain('updatable-server'); + expect(updatedConfig).toContain('new'); + }); + + it('should create backup before update', async () => { + // Add a server to update + await runner.runMcpCommand('add', { + args: ['backup-test-server', '--type', 'stdio', '--command', 'echo'], + }); + + // Update with backup + const updateResult = await runner.runMcpCommand('update', { + args: ['backup-test-server', '--tags', 'updated', '--backup'], + }); + + runner.assertSuccess(updateResult); + runner.assertOutputContains(updateResult, 'Backup created'); + }); + + it('should handle update when server does not exist', async () => { + const result = await runner.runMcpCommand('update', { + args: ['nonexistent-server', '--tags', 'test'], + expectError: true, + }); + + runner.assertFailure(result); + runner.assertOutputContains(result, 'does not exist', true); + }); + }); + + describe('Uninstall Workflow', () => { + it('should uninstall a server and verify configuration changes', async () => { + // First add a server + await runner.runMcpCommand('add', { + args: ['uninstall-test-server', '--type', 'stdio', '--command', 'echo'], + }); + + // Verify server exists + const listBefore = await runner.runMcpCommand('list'); + runner.assertOutputContains(listBefore, 'uninstall-test-server'); + + // Uninstall the server + const uninstallResult = await runner.runMcpCommand('uninstall', { + args: ['uninstall-test-server', '--force'], + }); + + runner.assertSuccess(uninstallResult); + runner.assertOutputContains(uninstallResult, 'Successfully uninstalled'); + + // Verify server no longer exists + const listAfter = await runner.runMcpCommand('list'); + runner.assertOutputDoesNotContain(listAfter, 'uninstall-test-server'); + }); + + it('should create backup before uninstall', async () => { + // Add a server + await runner.runMcpCommand('add', { + args: ['backup-uninstall-server', '--type', 'stdio', '--command', 'echo'], + }); + + // Uninstall with backup + const uninstallResult = await runner.runMcpCommand('uninstall', { + args: ['backup-uninstall-server', '--force', '--backup'], + }); + + runner.assertSuccess(uninstallResult); + runner.assertOutputContains(uninstallResult, 'Backup created'); + }); + + it('should handle uninstall when server does not exist', async () => { + const result = await runner.runMcpCommand('uninstall', { + args: ['nonexistent-server', '--force'], + expectError: true, + }); + + runner.assertFailure(result); + runner.assertOutputContains(result, 'does not exist', true); + }); + + it('should skip backup when --no-backup is specified', async () => { + // Add a server + await runner.runMcpCommand('add', { + args: ['no-backup-server', '--type', 'stdio', '--command', 'echo'], + }); + + // Uninstall without backup + const uninstallResult = await runner.runMcpCommand('uninstall', { + args: ['no-backup-server', '--force', '--no-backup'], + }); + + runner.assertSuccess(uninstallResult); + runner.assertOutputDoesNotContain(uninstallResult, 'Backup created'); + }); + }); + + describe('Complete Lifecycle', () => { + it('should complete full lifecycle: install -> update -> uninstall', async () => { + const serverName = 'lifecycle-test-server'; + + // Step 1: Install (using add as proxy since install requires registry) + await runner.runMcpCommand('add', { + args: [serverName, '--type', 'stdio', '--command', 'echo', '--args', 'version1'], + }); + + let listResult = await runner.runMcpCommand('list'); + runner.assertOutputContains(listResult, serverName); + + // Step 2: Update + await runner.runMcpCommand('update', { + args: [serverName, '--args', 'version2'], + }); + + const configAfterUpdate = await readFile(environment.getConfigPath(), 'utf-8'); + expect(configAfterUpdate).toContain('version2'); + + // Step 3: Uninstall + await runner.runMcpCommand('uninstall', { + args: [serverName, '--force'], + }); + + listResult = await runner.runMcpCommand('list'); + runner.assertOutputDoesNotContain(listResult, serverName); + }); + + it('should maintain other servers during lifecycle operations', async () => { + // Add two servers + await runner.runMcpCommand('add', { + args: ['persistent-server', '--type', 'stdio', '--command', 'echo'], + }); + + await runner.runMcpCommand('add', { + args: ['lifecycle-server', '--type', 'stdio', '--command', 'echo'], + }); + + // Verify both exist + let listResult = await runner.runMcpCommand('list'); + runner.assertOutputContains(listResult, 'persistent-server'); + runner.assertOutputContains(listResult, 'lifecycle-server'); + + // Update one + await runner.runMcpCommand('update', { + args: ['lifecycle-server', '--tags', 'updated'], + }); + + // Uninstall one + await runner.runMcpCommand('uninstall', { + args: ['lifecycle-server', '--force'], + }); + + // Verify persistent server still exists + listResult = await runner.runMcpCommand('list'); + runner.assertOutputContains(listResult, 'persistent-server'); + runner.assertOutputDoesNotContain(listResult, 'lifecycle-server'); + }); + }); + + describe('Error Handling and Edge Cases', () => { + it('should handle invalid server names', async () => { + const result = await runner.runMcpCommand('install', { + args: [''], + expectError: true, + }); + + runner.assertFailure(result); + }); + + it('should handle network errors gracefully', async () => { + // This would require mocking network or using timeout + const result = await runner.runMcpCommand('install', { + args: ['test-server'], + timeout: 1000, // Very short timeout to simulate error + expectError: true, + }); + + // May timeout or fail - either is acceptable + expect(result.exitCode !== 0 || result.error).toBeTruthy(); + }); + + it('should handle concurrent operations', async () => { + // Add a server + await runner.runMcpCommand('add', { + args: ['concurrent-test', '--type', 'stdio', '--command', 'echo'], + }); + + // Try to update and check status concurrently + const [updateResult, listResult] = await Promise.all([ + runner.runMcpCommand('update', { + args: ['concurrent-test', '--tags', 'concurrent'], + }), + runner.runMcpCommand('list'), + ]); + + runner.assertSuccess(updateResult); + runner.assertSuccess(listResult); + runner.assertOutputContains(listResult, 'concurrent-test'); + }); + }); +}); diff --git a/test/e2e/integration/search.test.ts b/test/e2e/integration/search.test.ts new file mode 100644 index 00000000..f8300dc6 --- /dev/null +++ b/test/e2e/integration/search.test.ts @@ -0,0 +1,242 @@ +import { TestFixtures } from '@test/e2e/fixtures/TestFixtures.js'; +import { CliTestRunner, CommandTestEnvironment } from '@test/e2e/utils/index.js'; + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +/** + * Integration tests for MCP search command alias + * Verifies that search alias properly delegates to registry search + */ + +describe('MCP Search Command Integration', () => { + let environment: CommandTestEnvironment; + let runner: CliTestRunner; + + beforeEach(async () => { + environment = new CommandTestEnvironment(TestFixtures.createTestScenario('mcp-search-test', 'basic')); + await environment.setup(); + runner = new CliTestRunner(environment); + }); + + afterEach(async () => { + await environment.cleanup(); + }); + + describe('Search Alias Delegation', () => { + it('should delegate search to registry search command', async () => { + // Test that mcp search properly delegates to registry search + const result = await runner.runMcpCommand('search', { + args: ['filesystem'], + timeout: 30000, + }); + + // Should succeed (delegates to registry search) + runner.assertSuccess(result); + // Should show search results (format similar to registry search) + runner.assertOutputContains(result, 'Found'); + }); + + it('should handle search without query', async () => { + const result = await runner.runMcpCommand('search', { + timeout: 30000, + }); + + runner.assertSuccess(result); + // Should show some results or message + expect(result.stdout.length).toBeGreaterThan(0); + }); + + it('should pass query parameter correctly', async () => { + const query = 'file'; + const result = await runner.runMcpCommand('search', { + args: [query], + timeout: 30000, + }); + + runner.assertSuccess(result); + // Should contain search query in results or process it + runner.assertOutputContains(result, 'Found'); + }); + + it('should handle search with limit parameter', async () => { + const result = await runner.runMcpCommand('search', { + args: ['--limit', '5'], + timeout: 30000, + }); + + runner.assertSuccess(result); + runner.assertOutputContains(result, 'Found'); + }); + }); + + describe('Search Results', () => { + it('should return search results when matches found', async () => { + const result = await runner.runMcpCommand('search', { + args: ['filesystem'], + timeout: 30000, + }); + + runner.assertSuccess(result); + // Should show results or helpful message + expect(result.stdout).toBeTruthy(); + }); + + it('should handle no matches gracefully', async () => { + const result = await runner.runMcpCommand('search', { + args: ['nonexistent-server-xyz-12345-test'], + timeout: 30000, + }); + + runner.assertSuccess(result); + // Should show no results message + runner.assertOutputContains(result, 'No MCP servers found'); + }); + + it('should support different output formats', async () => { + // Test JSON format + const jsonResult = await runner.runMcpCommand('search', { + args: ['file', '--format=json'], + timeout: 30000, + }); + + runner.assertSuccess(jsonResult); + + // Try to parse as JSON + try { + const parsed = JSON.parse(jsonResult.stdout); + expect(parsed).toHaveProperty('servers'); + expect(Array.isArray(parsed.servers)).toBe(true); + } catch { + // If not JSON, that's also acceptable - may format differently + expect(jsonResult.stdout.length).toBeGreaterThan(0); + } + }); + }); + + describe('Search Options', () => { + it('should support status filter', async () => { + const result = await runner.runMcpCommand('search', { + args: ['--status=active'], + timeout: 30000, + }); + + runner.assertSuccess(result); + runner.assertOutputContains(result, 'Found'); + }); + + it('should support type filter', async () => { + const result = await runner.runMcpCommand('search', { + args: ['--type=npm'], + timeout: 30000, + }); + + runner.assertSuccess(result); + runner.assertOutputContains(result, 'Found'); + }); + + it('should support transport filter', async () => { + const result = await runner.runMcpCommand('search', { + args: ['--transport=stdio'], + timeout: 30000, + }); + + runner.assertSuccess(result); + runner.assertOutputContains(result, 'Found'); + }); + + it('should combine query with filters', async () => { + const result = await runner.runMcpCommand('search', { + args: ['file', '--type=npm', '--status=active'], + timeout: 30000, + }); + + runner.assertSuccess(result); + runner.assertOutputContains(result, 'Found'); + }); + }); + + describe('Error Handling', () => { + it('should handle invalid search parameters', async () => { + const result = await runner.runMcpCommand('search', { + args: ['--format=invalid'], + expectError: true, + }); + + runner.assertFailure(result); + runner.assertOutputContains(result, 'Invalid', true); + }); + + it('should handle network errors gracefully', async () => { + // Use very short timeout to simulate network issue + const result = await runner.runMcpCommand('search', { + args: ['test'], + timeout: 1000, // Very short timeout + expectError: true, + }); + + // Should handle timeout/error gracefully + expect(result.exitCode !== 0 || result.error).toBeTruthy(); + }); + + it('should handle malformed queries', async () => { + // Empty query should be handled + const result = await runner.runMcpCommand('search', { + args: [''], + timeout: 30000, + }); + + // Should either succeed with all results or show helpful message + runner.assertSuccess(result); + }); + }); + + describe('Command Integration', () => { + it('should work alongside other mcp commands', async () => { + // First add a server + await runner.runMcpCommand('add', { + args: ['test-server', '--type', 'stdio', '--command', 'echo'], + }); + + // Then search (should not interfere) + const searchResult = await runner.runMcpCommand('search', { + args: ['test'], + timeout: 30000, + }); + + runner.assertSuccess(searchResult); + + // List should still work + const listResult = await runner.runMcpCommand('list'); + runner.assertSuccess(listResult); + runner.assertOutputContains(listResult, 'test-server'); + }); + + it('should preserve config context during search', async () => { + // Set up config + await runner.runMcpCommand('add', { + args: ['config-test', '--type', 'stdio', '--command', 'echo'], + }); + + // Search should not affect config + await runner.runMcpCommand('search', { + args: ['test'], + timeout: 30000, + }); + + // Config should still be intact + const listResult = await runner.runMcpCommand('list'); + runner.assertOutputContains(listResult, 'config-test'); + }); + }); + + describe('Help and Usage', () => { + it('should show help when requested', async () => { + const result = await runner.runMcpCommand('search', { + args: ['--help'], + }); + + runner.assertSuccess(result); + runner.assertOutputContains(result, 'Search'); + }); + }); +}); diff --git a/test/e2e/integration/server-management.test.ts b/test/e2e/integration/server-management.test.ts new file mode 100644 index 00000000..83ff30e7 --- /dev/null +++ b/test/e2e/integration/server-management.test.ts @@ -0,0 +1,324 @@ +import { mkdtemp, readFile, rm, writeFile } from 'fs/promises'; +import { tmpdir } from 'os'; +import { join } from 'path'; + +import { initializeConfigContext, serverExists, setServer } from '@src/commands/mcp/utils/configUtils.js'; +import ConfigContext from '@src/config/configContext.js'; +import { createServerInstallationService, getProgressTrackingService } from '@src/domains/server-management/index.js'; + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +/** + * Integration tests for server management domain + * Tests end-to-end workflows including service integration, + * registry client integration, configuration management, and progress tracking + */ + +describe('Server Management Domain Integration', () => { + let tempDir: string; + let configPath: string; + + beforeEach(async () => { + // Create temporary directory for test configs + tempDir = await mkdtemp(join(tmpdir(), 'server-mgmt-test-')); + + // Initialize config context with test directory + const configContext = ConfigContext.getInstance(); + configContext.setConfigDir(tempDir); + configPath = configContext.getResolvedConfigPath(); + + // Create empty config file + await writeFile( + configPath, + JSON.stringify({ + mcpServers: {}, + }), + 'utf-8', + ); + }); + + afterEach(async () => { + // Cleanup temp directory + if (tempDir) { + await rm(tempDir, { recursive: true, force: true }); + } + + // Reset config context + ConfigContext.getInstance().reset(); + vi.clearAllMocks(); + }); + + describe('ServerInstallationService Integration', () => { + it('should create service instance', () => { + const service = createServerInstallationService(); + expect(service).toBeDefined(); + }); + + it('should list installed servers', async () => { + // Setup: Add some servers manually + initializeConfigContext(undefined, tempDir); + setServer('test-server-1', { + type: 'stdio', + command: 'echo', + args: ['test1'], + }); + setServer('test-server-2', { + type: 'stdio', + command: 'echo', + args: ['test2'], + }); + + const service = createServerInstallationService(); + const servers = await service.listInstalledServers(); + + expect(servers).toContain('test-server-1'); + expect(servers).toContain('test-server-2'); + }); + + it('should filter active servers when requested', async () => { + // Setup: Add enabled and disabled servers + initializeConfigContext(undefined, tempDir); + setServer('enabled-server', { + type: 'stdio', + command: 'echo', + args: ['enabled'], + }); + setServer('disabled-server', { + type: 'stdio', + command: 'echo', + args: ['disabled'], + disabled: true, + }); + + const service = createServerInstallationService(); + + // Get all servers + const allServers = await service.listInstalledServers(); + expect(allServers.length).toBe(2); + + // Get only active servers + const activeServers = await service.listInstalledServers({ filterActive: true }); + expect(activeServers).toContain('enabled-server'); + expect(activeServers).not.toContain('disabled-server'); + }); + + it('should check for updates and return results', async () => { + // Setup: Add a server + initializeConfigContext(undefined, tempDir); + setServer('updatable-server', { + type: 'stdio', + command: 'echo', + args: ['test'], + }); + + const service = createServerInstallationService(); + + // Mock registry client would be needed for actual update checking + // For integration test, we verify the interface works + const results = await service.checkForUpdates(['updatable-server']); + + expect(Array.isArray(results)).toBe(true); + // Results may be empty if server not in registry or version unknown + // But the method should complete without errors + }); + }); + + describe('Configuration Management Integration', () => { + it('should persist server configuration changes', async () => { + initializeConfigContext(undefined, tempDir); + + // Add a server + setServer('persist-test', { + type: 'stdio', + command: 'echo', + args: ['persist'], + tags: ['test'], + }); + + // Verify it was written to file + const configContent = await readFile(configPath, 'utf-8'); + const config = JSON.parse(configContent); + + expect(config.mcpServers).toBeDefined(); + expect(config.mcpServers['persist-test']).toBeDefined(); + expect(config.mcpServers['persist-test'].command).toBe('echo'); + expect(config.mcpServers['persist-test'].tags).toContain('test'); + }); + + it('should update existing server configuration', async () => { + initializeConfigContext(undefined, tempDir); + + // Add initial server + setServer('update-config-test', { + type: 'stdio', + command: 'echo', + args: ['old'], + }); + + // Update server + setServer('update-config-test', { + type: 'stdio', + command: 'echo', + args: ['new'], + tags: ['updated'], + }); + + // Verify update + const configContent = await readFile(configPath, 'utf-8'); + const config = JSON.parse(configContent); + + expect(config.mcpServers['update-config-test'].args).toContain('new'); + expect(config.mcpServers['update-config-test'].tags).toContain('updated'); + }); + + it('should verify server existence', () => { + initializeConfigContext(undefined, tempDir); + + // Server doesn't exist yet + expect(serverExists('new-server')).toBe(false); + + // Add server + setServer('new-server', { + type: 'stdio', + command: 'echo', + }); + + // Server should now exist + expect(serverExists('new-server')).toBe(true); + }); + }); + + describe('Progress Tracking Integration', () => { + it('should track operation progress', () => { + const progressTracker = getProgressTrackingService(); + + const operationId = 'test-op-123'; + progressTracker.startOperation(operationId, 'install', 5); + + progressTracker.updateProgress(operationId, 1, 'Step 1', 'Validating'); + progressTracker.updateProgress(operationId, 2, 'Step 2', 'Installing'); + + // Verify operation is tracked (no errors thrown) + expect(operationId).toBeTruthy(); + }); + + it('should complete operations successfully', () => { + const progressTracker = getProgressTrackingService(); + + const operationId = 'test-complete-op'; + progressTracker.startOperation(operationId, 'install', 3); + + progressTracker.completeOperation(operationId, { + success: true, + operationId, + duration: 1000, + message: 'Test completed', + }); + + // Verify completion without errors + expect(operationId).toBeTruthy(); + }); + + it('should handle failed operations', () => { + const progressTracker = getProgressTrackingService(); + + const operationId = 'test-fail-op'; + progressTracker.startOperation(operationId, 'install', 2); + + const error = new Error('Test error'); + progressTracker.failOperation(operationId, error); + + // Verify failure handling without errors + expect(operationId).toBeTruthy(); + }); + }); + + describe('Service Workflow Integration', () => { + it('should handle complete install workflow', async () => { + initializeConfigContext(undefined, tempDir); + + // Verify server doesn't exist + expect(serverExists('workflow-test')).toBe(false); + + // Add server (simulating install) + setServer('workflow-test', { + type: 'stdio', + command: 'echo', + args: ['installed'], + }); + + // Verify it exists + expect(serverExists('workflow-test')).toBe(true); + + // Verify in service + const service = createServerInstallationService(); + const servers = await service.listInstalledServers(); + expect(servers).toContain('workflow-test'); + }); + + it('should handle update workflow with version checking', async () => { + initializeConfigContext(undefined, tempDir); + + // Add server + setServer('version-test', { + type: 'stdio', + command: 'echo', + }); + + const service = createServerInstallationService(); + + // Check for updates + const updateResults = await service.checkForUpdates(['version-test']); + + expect(Array.isArray(updateResults)).toBe(true); + // May be empty or contain update info depending on registry + }); + + it('should handle uninstall workflow', async () => { + initializeConfigContext(undefined, tempDir); + + // Add server + setServer('uninstall-workflow-test', { + type: 'stdio', + command: 'echo', + }); + + // Verify exists + expect(serverExists('uninstall-workflow-test')).toBe(true); + + const service = createServerInstallationService(); + const result = await service.uninstallServer('uninstall-workflow-test'); + + expect(result.success).toBe(true); + expect(result.serverName).toBe('uninstall-workflow-test'); + }); + }); + + describe('Error Handling', () => { + it('should handle missing configuration gracefully', async () => { + // Use invalid config path + const invalidPath = join(tempDir, 'nonexistent', 'config.json'); + initializeConfigContext(invalidPath); + + // Service should still be created + const service = createServerInstallationService(); + expect(service).toBeDefined(); + + // Operations may fail but should not crash + await expect(service.listInstalledServers()).resolves.toBeInstanceOf(Array); + }); + + it('should handle invalid server operations', async () => { + initializeConfigContext(undefined, tempDir); + + const service = createServerInstallationService(); + + // Attempt to uninstall non-existent server + const result = await service.uninstallServer('nonexistent-server'); + + // Should complete without throwing, result indicates outcome + expect(result).toBeDefined(); + expect(result.serverName).toBe('nonexistent-server'); + }); + }); +}); diff --git a/test/e2e/utils/CliTestRunner.ts b/test/e2e/utils/CliTestRunner.ts index 632ef9d9..1aa5f7c8 100644 --- a/test/e2e/utils/CliTestRunner.ts +++ b/test/e2e/utils/CliTestRunner.ts @@ -63,7 +63,7 @@ export class CliTestRunner { * Execute MCP management commands (mcp subcommand) */ async runMcpCommand( - action: 'add' | 'remove' | 'enable' | 'disable' | 'list' | 'status' | 'update', + action: 'add' | 'remove' | 'enable' | 'disable' | 'list' | 'status' | 'update' | 'install' | 'uninstall' | 'search', options: CommandExecutionOptions = {}, ): Promise { const args = ['mcp', action]; diff --git a/vitest.config.ts b/vitest.config.ts index 048e0463..eddd9afb 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,5 +1,5 @@ -import { defineConfig } from 'vitest/config'; import tsconfigPaths from 'vite-tsconfig-paths'; +import { defineConfig } from 'vitest/config'; export default defineConfig({ plugins: [tsconfigPaths()], @@ -11,7 +11,7 @@ export default defineConfig({ provider: 'v8', reporter: ['text', 'json', 'html'], include: ['src/**/*.ts'], - exclude: ['src/**/*.d.ts', 'src/**/*.test.ts', 'src/test/e2e/**/*'], + exclude: ['src/**/*.d.ts', 'src/**/*.test.ts'], }, alias: { '^(\\.{1,2}/.*)\\.js$': '$1',