UNPKG

ludus-mcp

Version:

MCP server for managing Ludus cybersecurity training environments through natural language commands

250 lines (207 loc) 8.37 kB
import fs from 'fs/promises'; import path from 'path'; import os from 'os'; import { execSync } from 'child_process'; import { Logger } from './logger.js'; export async function downloadSchemas(logger: Logger): Promise<void> { const repoUrl = 'https://github.com/NocteDefensor/LudusMCP.git'; const targetPath = 'schemas'; const ludusConfigDir = path.join(os.homedir(), '.ludus-mcp'); const schemasDir = path.join(ludusConfigDir, 'schemas'); const tempDir = path.join(ludusConfigDir, 'temp-schemas-download'); try { logger.info('Updating schemas from GitHub (preserving custom schemas)...'); // Ensure .ludus-mcp directory exists await fs.mkdir(ludusConfigDir, { recursive: true }); // Check if schemas directory already exists const schemasExists = await directoryExists(schemasDir); if (schemasExists) { logger.info('Existing schemas directory found - merging with official schemas'); await mergeSchemas(schemasDir, tempDir, repoUrl, targetPath, logger); } else { logger.info('No existing schemas directory - creating fresh installation'); await freshInstallSchemas(schemasDir, tempDir, repoUrl, targetPath, logger); } // Always update range-config.json (official file) await downloadRangeConfigSchema(schemasDir, logger); // Report final state const schemaFiles = await findSchemaFiles(schemasDir); logger.info(`Schema synchronization complete`, { totalFiles: schemaFiles.length, files: schemaFiles.map(f => path.basename(f)) }); } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); logger.warn('Failed to update schemas (continuing without update)', { error: errorMessage }); await removeDirectorySafe(tempDir); } } /** * Check if directory exists */ async function directoryExists(dirPath: string): Promise<boolean> { try { const stats = await fs.stat(dirPath); return stats.isDirectory(); } catch { return false; } } /** * Merge official schemas with existing custom schemas */ async function mergeSchemas( schemasDir: string, tempDir: string, repoUrl: string, targetPath: string, logger: Logger ): Promise<void> { // Clean temp directory await removeDirectorySafe(tempDir); // Download official schemas to temp location logger.info('Downloading official schemas to temporary location...'); await cloneSchemas(tempDir, tempDir + '-git', repoUrl, targetPath, logger); // Get list of official schema files const officialSchemas = await findYamlSchemaFiles(tempDir); const officialFileNames = officialSchemas.map(f => path.basename(f)); logger.info(`Found ${officialFileNames.length} official schemas to update: ${officialFileNames.join(', ')}`); // Copy official schemas to target (overwriting official files only) for (const officialFile of officialSchemas) { const fileName = path.basename(officialFile); const targetFile = path.join(schemasDir, fileName); try { await fs.copyFile(officialFile, targetFile); logger.debug(`Updated official schema: ${fileName}`); } catch (error) { logger.warn(`Failed to update schema ${fileName}`, { error: error instanceof Error ? error.message : String(error) }); } } // Identify custom schemas (existing files not in official list) const existingSchemas = await findYamlSchemaFiles(schemasDir); const customSchemas = existingSchemas.filter(f => !officialFileNames.includes(path.basename(f)) ); if (customSchemas.length > 0) { const customFileNames = customSchemas.map(f => path.basename(f)); logger.info(`Preserved ${customSchemas.length} custom schemas: ${customFileNames.join(', ')}`); } // Clean up temp directory await removeDirectorySafe(tempDir); } /** * Fresh installation of schemas (no existing directory) */ async function freshInstallSchemas( schemasDir: string, tempDir: string, repoUrl: string, targetPath: string, logger: Logger ): Promise<void> { // Clean temp directory await removeDirectorySafe(tempDir); // Direct clone to final location (original behavior for fresh installs) await cloneSchemas(schemasDir, tempDir, repoUrl, targetPath, logger); } /** * Find YAML schema files (.yaml and .yml) in a directory (non-recursive) */ async function findYamlSchemaFiles(dir: string): Promise<string[]> { const files: string[] = []; try { const items = await fs.readdir(dir, { withFileTypes: true }); for (const item of items) { if (item.isFile() && (item.name.endsWith('.yaml') || item.name.endsWith('.yml'))) { files.push(path.join(dir, item.name)); } } } catch (error) { // Directory doesn't exist or can't be read } return files.sort(); } async function cloneSchemas( schemasDir: string, tempDir: string, repoUrl: string, targetPath: string, logger: Logger ): Promise<void> { // Clone only the specific schemas folder from main branch logger.info('Cloning schemas from GitHub repository (main branch)...'); execSync(`git clone --filter=blob:none --sparse --depth 1 --branch main "${repoUrl}" "${tempDir}"`, { stdio: 'pipe' }); execSync(`git -C "${tempDir}" sparse-checkout set "${targetPath}"`, { stdio: 'pipe' }); const sourceSchemas = path.join(tempDir, targetPath); // Copy schemas to destination await copyDirectoryRecursive(sourceSchemas, schemasDir); // Clean up temp directory await removeDirectorySafe(tempDir); } async function downloadRangeConfigSchema(schemasDir: string, logger: Logger): Promise<void> { try { logger.info('Downloading range-config.json schema...'); // Download range-config schema const response = await fetch('https://docs.ludus.cloud/schemas/range-config.json'); if (!response.ok) { throw new Error(`Failed to fetch range-config schema: ${response.status} ${response.statusText}`); } const schemaContent = await response.text(); const schemaPath = path.join(schemasDir, 'range-config.json'); await fs.writeFile(schemaPath, schemaContent, 'utf-8'); logger.info('Successfully downloaded range-config.json schema'); } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); logger.warn('Failed to download range-config schema', { error: errorMessage }); } } /** * Find all schema files (.json, .yaml, .yml) in a directory */ async function findSchemaFiles(dir: string): Promise<string[]> { const files: string[] = []; try { const items = await fs.readdir(dir, { withFileTypes: true }); for (const item of items) { const fullPath = path.join(dir, item.name); if (item.isDirectory()) { const subFiles = await findSchemaFiles(fullPath); files.push(...subFiles); } else if (item.isFile() && (item.name.endsWith('.json') || item.name.endsWith('.yaml') || item.name.endsWith('.yml'))) { files.push(fullPath); } } } catch (error) { // Directory doesn't exist or can't be read } return files.sort(); } /** * Recursively copy directory contents */ async function copyDirectoryRecursive(source: string, destination: string): Promise<void> { // Ensure destination directory exists await fs.mkdir(destination, { recursive: true }); const items = await fs.readdir(source, { withFileTypes: true }); for (const item of items) { const sourcePath = path.join(source, item.name); const destPath = path.join(destination, item.name); if (item.isDirectory()) { await copyDirectoryRecursive(sourcePath, destPath); } else if (item.isFile()) { await fs.copyFile(sourcePath, destPath); } } } /** * Windows-safe directory removal */ async function removeDirectorySafe(dirPath: string): Promise<void> { try { await fs.access(dirPath); // Directory exists, try to remove it await fs.rm(dirPath, { recursive: true, force: true }); } catch (error) { // Directory doesn't exist or couldn't be removed - that's fine for cleanup } }