Skip to main content
Glama

DollhouseMCP

by DollhouseMCP
PortfolioSyncManager.tsโ€ข32 kB
/** * PortfolioSyncManager - Handles synchronization between local and GitHub portfolios * * Features: * - Download elements from GitHub portfolio * - Upload elements with consent * - Version comparison and diff viewing * - Privacy-first with explicit permissions * - Conflict resolution strategies * - Bulk operations with configuration checks */ import * as fs from 'fs/promises'; import * as path from 'path'; import { createHash } from 'crypto'; import { logger } from '../utils/logger.js'; import { ConfigManager, DollhouseConfig } from '../config/ConfigManager.js'; import { PortfolioManager } from './PortfolioManager.js'; import { PortfolioRepoManager } from './PortfolioRepoManager.js'; import { GitHubPortfolioIndexer, GitHubIndexEntry } from './GitHubPortfolioIndexer.js'; import { getPortfolioRepositoryName } from '../config/portfolioConfig.js'; import { TokenManager } from '../security/tokenManager.js'; import { ContentValidator } from '../security/contentValidator.js'; import { UnicodeValidator } from '../security/validators/unicodeValidator.js'; import { SecureYamlParser } from '../security/secureYamlParser.js'; import { ElementType } from './types.js'; import { IElement, ElementStatus } from '../types/elements/IElement.js'; import { PortfolioElementAdapter } from '../tools/portfolio/PortfolioElementAdapter.js'; export interface SyncOperation { operation: 'download' | 'upload' | 'compare' | 'list-remote'; element_name?: string; element_type?: ElementType; bulk?: boolean; version?: string; show_diff?: boolean; force?: boolean; confirm?: boolean; } export interface SyncResult { success: boolean; message: string; data?: any; elements?: SyncElementInfo[]; conflicts?: ConflictInfo[]; } export interface SyncElementInfo { name: string; type: ElementType; localVersion?: string; remoteVersion?: string; status: 'new' | 'updated' | 'conflict' | 'unchanged' | 'local-only'; action?: 'download' | 'upload' | 'skip'; } export interface ConflictInfo { element: string; type: ElementType; localVersion: string; remoteVersion: string; localModified: Date; remoteModified: Date; resolution?: 'local' | 'remote' | 'manual'; } export interface VersionInfo { version: string; timestamp: Date; author: string; hash: string; size: number; source: 'local' | 'remote'; } export interface ElementDiff { element: string; type: ElementType; changes: { metadata?: { field: string; oldValue: any; newValue: any; }[]; content?: { additions: number; deletions: number; diff: string; }; }; } export class PortfolioSyncManager { private configManager: ConfigManager; private portfolioManager: PortfolioManager; private repoManager: PortfolioRepoManager; private indexer: GitHubPortfolioIndexer; constructor() { this.configManager = ConfigManager.getInstance(); this.portfolioManager = PortfolioManager.getInstance(); this.repoManager = new PortfolioRepoManager(getPortfolioRepositoryName()); this.indexer = GitHubPortfolioIndexer.getInstance(); } /** * Main handler for sync operations */ public async handleSyncOperation(params: SyncOperation): Promise<SyncResult> { try { // Check if sync is enabled in config const config = this.configManager.getConfig(); if (!config.sync.enabled && params.operation !== 'list-remote') { return { success: false, message: 'Sync is disabled. Enable it with: dollhouse_config --action update --setting sync.enabled --value true' }; } // Check bulk permissions if (params.bulk) { const bulkAllowed = this.isBulkOperationAllowed(params.operation, config); if (!bulkAllowed.allowed) { return { success: false, message: bulkAllowed.message }; } } // Handle operations switch (params.operation) { case 'list-remote': return await this.listRemoteElements(params.element_type); case 'download': if (params.bulk) { return await this.bulkDownload(params.element_type, params.confirm); } else if (params.element_name) { return await this.downloadElement( params.element_name, params.element_type!, params.version, params.force ); } else { return { success: false, message: 'Element name required for individual download' }; } case 'upload': if (params.bulk) { return await this.bulkUpload(params.element_type, params.confirm); } else if (params.element_name) { return await this.uploadElement( params.element_name, params.element_type!, params.confirm ); } else { return { success: false, message: 'Element name required for individual upload' }; } case 'compare': if (params.element_name && params.element_type) { return await this.compareVersions( params.element_name, params.element_type, params.show_diff ); } else { return { success: false, message: 'Element name and type required for comparison' }; } default: return { success: false, message: `Unknown operation: ${params.operation}` }; } } catch (error) { logger.error('Sync operation failed', { operation: params.operation, error: error instanceof Error ? error.message : String(error) }); return { success: false, message: `Sync operation failed: ${error instanceof Error ? error.message : String(error)}` }; } } /** * Check if bulk operation is allowed */ private isBulkOperationAllowed(operation: string, config: DollhouseConfig): { allowed: boolean; message: string } { if (operation === 'download' && !config.sync.bulk.download_enabled) { return { allowed: false, message: 'Bulk download is disabled. Enable with: dollhouse_config --action update --setting sync.bulk.download_enabled --value true' }; } if (operation === 'upload' && !config.sync.bulk.upload_enabled) { return { allowed: false, message: 'Bulk upload is disabled. Enable with: dollhouse_config --action update --setting sync.bulk.upload_enabled --value true' }; } return { allowed: true, message: '' }; } /** * List elements available in GitHub portfolio */ private async listRemoteElements(filterType?: ElementType): Promise<SyncResult> { try { // Get GitHub token const token = await TokenManager.getGitHubTokenAsync(); if (!token) { return { success: false, message: 'GitHub authentication required. Use setup_github_auth first.' }; } this.repoManager.setToken(token); // Get index of GitHub portfolio const index = await this.indexer.getIndex(); if (!index || index.totalElements === 0) { return { success: true, message: 'No elements found in GitHub portfolio', elements: [] }; } // Format elements for display const elements: SyncElementInfo[] = []; for (const [type, entries] of index.elements) { // Skip if filtering by type and this isn't the requested type if (filterType && type !== filterType) { continue; } for (const entry of entries) { elements.push({ name: entry.name, type: type, remoteVersion: entry.version, status: 'unchanged', action: 'download' }); } } return { success: true, message: `Found ${elements.length} elements in GitHub portfolio`, elements }; } catch (error) { return { success: false, message: `Failed to list remote elements: ${error instanceof Error ? error.message : String(error)}` }; } } /** * Download a specific element from GitHub */ private async downloadElement( elementName: string, elementType: ElementType, version?: string, force?: boolean ): Promise<SyncResult> { try { const config = this.configManager.getConfig(); // Validate element name const validation = UnicodeValidator.normalize(elementName); if (!validation.isValid) { return { success: false, message: `Invalid element name: ${validation.detectedIssues?.[0] || 'unknown error'}` }; } // Get token and set it const token = await TokenManager.getGitHubTokenAsync(); if (!token) { return { success: false, message: 'GitHub authentication required' }; } this.repoManager.setToken(token); // Get GitHub index const index = await this.indexer.getIndex(); // Find the element - first try exact match, then fuzzy match const entries = index.elements.get(elementType) || []; let entry = entries.find(e => e.name === elementName); // If exact match not found, try fuzzy matching if (!entry) { // Try case-insensitive exact match first entry = entries.find(e => e.name.toLowerCase() === elementName.toLowerCase()); // If still not found, try fuzzy matching if (!entry) { const fuzzyMatch = this.findFuzzyMatch(elementName, entries); if (fuzzyMatch) { logger.info(`Fuzzy match found: '${elementName}' matched to '${fuzzyMatch.name}'`); entry = fuzzyMatch; } } } if (!entry) { // Generate helpful suggestions const suggestions = this.getSuggestions(elementName, entries); const suggestionText = suggestions.length > 0 ? `\n\nDid you mean one of these?\n${suggestions.map(s => ` โ€ข ${s.name}`).join('\n')}` : ''; return { success: false, message: `Element '${elementName}' (${elementType}) not found in GitHub portfolio${suggestionText}` }; } // Check for local conflicts const localPath = this.portfolioManager.getElementPath(elementType, `${elementName}.md`); let hasLocalVersion = false; let localContent: string | null = null; try { localContent = await fs.readFile(localPath, 'utf-8'); hasLocalVersion = true; } catch { // No local version exists } // Download the element const response = await fetch(entry.downloadUrl, { headers: { 'Authorization': `Bearer ${token}`, 'Accept': 'application/vnd.github.v3.raw' } }); if (!response.ok) { throw new Error(`Failed to download: ${response.statusText}`); } const remoteContent = await response.text(); // Validate content security const validationResult = ContentValidator.validateAndSanitize(remoteContent); if (!validationResult.isValid && validationResult.severity === 'critical') { return { success: false, message: `Security issue detected in remote content: ${validationResult.detectedPatterns?.join(', ')}` }; } // Check if content is different if (hasLocalVersion && localContent) { const localHash = createHash('sha256').update(localContent).digest('hex'); const remoteHash = createHash('sha256').update(remoteContent).digest('hex'); if (localHash === remoteHash) { return { success: true, message: `Element '${elementName}' is already up to date` }; } // Show confirmation for overwrite unless force flag is set if (config.sync.individual.require_confirmation && !force) { const diff = await this.generateDiff(localContent, remoteContent); return { success: false, message: `Local version exists. Please confirm download will overwrite:\n\n${diff}\n\nTo proceed, use --force flag`, data: { requiresConfirmation: true } }; } } // Save the element await fs.mkdir(path.dirname(localPath), { recursive: true }); await fs.writeFile(localPath, remoteContent, 'utf-8'); logger.info('Element downloaded from GitHub', { element: elementName, type: elementType, version: entry.version }); return { success: true, message: `Successfully downloaded '${elementName}' (${elementType}) from GitHub portfolio` }; } catch (error) { return { success: false, message: `Failed to download element: ${error instanceof Error ? error.message : String(error)}` }; } } /** * Upload a specific element to GitHub */ private async uploadElement( elementName: string, elementType: ElementType, confirm?: boolean ): Promise<SyncResult> { try { const config = this.configManager.getConfig(); // Check for local element const localPath = this.portfolioManager.getElementPath(elementType, `${elementName}.md`); let content: string; try { content = await fs.readFile(localPath, 'utf-8'); } catch { return { success: false, message: `Element '${elementName}' (${elementType}) not found locally` }; } // Check privacy metadata const parsed = SecureYamlParser.parse(content, { maxYamlSize: 64 * 1024, validateContent: false, validateFields: false }); if (parsed.data?.privacy?.local_only === true) { return { success: false, message: `Element '${elementName}' is marked as local-only and cannot be uploaded` }; } // Validate content security const validationResult = ContentValidator.validateAndSanitize(content); if (!validationResult.isValid && validationResult.severity === 'critical') { return { success: false, message: `Security issue detected: ${validationResult.detectedPatterns?.join(', ')}` }; } // Scan for sensitive content if configured if (config.sync.privacy.scan_for_secrets) { logger.debug('Scanning for secrets before upload'); // Implement actual secret scanning const secretPatterns = [ /api[_-]?key\s*[:=]\s*['"][^'"]+['"]/gi, /secret\s*[:=]\s*['"][^'"]+['"]/gi, /password\s*[:=]\s*['"][^'"]+['"]/gi, /token\s*[:=]\s*['"][^'"]+['"]/gi, /private[_-]?key\s*[:=]\s*['"][^'"]+['"]/gi ]; for (const pattern of secretPatterns) { if (pattern.test(content)) { return { success: false, message: `Potential secret detected in content. Please review and remove sensitive information before uploading.` }; } } } // Get confirmation if required (unless already confirmed) if (config.sync.individual.require_confirmation && !confirm) { return { success: false, message: `Please confirm upload of '${elementName}' (${elementType}) to GitHub.\n\nContent preview:\n${content.substring(0, 500)}...\n\nTo proceed, use --confirm flag`, data: { requiresConfirmation: true } }; } // Get token and validate const token = await TokenManager.getGitHubTokenAsync(); if (!token) { return { success: false, message: 'GitHub authentication required' }; } // Create a PortfolioElement for the adapter (fixes Issue #913) // Using PortfolioElementAdapter instead of incomplete IElement implementation const portfolioElement = { type: elementType, metadata: { name: elementName, description: parsed.data?.description || '', author: parsed.data?.author || 'unknown', created: parsed.data?.created || new Date().toISOString(), updated: new Date().toISOString(), version: parsed.data?.version || '1.0.0', tags: parsed.data?.tags || [] }, content: content }; // Use PortfolioElementAdapter to properly implement IElement interface const adapter = new PortfolioElementAdapter(portfolioElement); // Use PortfolioRepoManager to upload this.repoManager.setToken(token); // DEBUG: Log upload attempt logger.debug('[BULK_SYNC_DEBUG] Upload element attempt', { elementName, elementType, hasToken: !!token, tokenPrefix: token ? token.substring(0, 10) + '...' : 'none', adapterHasMetadata: !!(adapter && adapter.metadata), timestamp: new Date().toISOString() }); try { const url = await this.repoManager.saveElement(adapter, true); // consent is true since we've already checked logger.info('Element uploaded to GitHub', { element: elementName, type: elementType, url }); return { success: true, message: `Successfully uploaded '${elementName}' (${elementType}) to GitHub portfolio`, data: { url } }; } catch (uploadError) { // Handle specific errors if (uploadError instanceof Error && uploadError.message.includes('repository does not exist')) { return { success: false, message: `GitHub portfolio repository not found. Please initialize it first using init_portfolio tool.` }; } throw uploadError; } } catch (error) { return { success: false, message: `Failed to upload element: ${error instanceof Error ? error.message : String(error)}` }; } } /** * Compare local and remote versions */ private async compareVersions( elementName: string, elementType: ElementType, showDiff?: boolean ): Promise<SyncResult> { try { // Get local version const localPath = this.portfolioManager.getElementPath(elementType, `${elementName}.md`); let localContent: string | null = null; let localVersion: VersionInfo | null = null; try { localContent = await fs.readFile(localPath, 'utf-8'); const parsed = SecureYamlParser.parse(localContent, { maxYamlSize: 64 * 1024, validateContent: false, validateFields: false }); localVersion = { version: parsed.data?.version || '1.0.0', timestamp: new Date(parsed.data?.updated || parsed.data?.created || Date.now()), author: parsed.data?.author || 'unknown', hash: createHash('sha256').update(localContent).digest('hex'), size: Buffer.byteLength(localContent), source: 'local' }; } catch { // No local version } // Get remote version const token = await TokenManager.getGitHubTokenAsync(); if (!token) { return { success: false, message: 'GitHub authentication required' }; } const index = await this.indexer.getIndex(); const entries = index.elements.get(elementType) || []; const entry = entries.find(e => e.name === elementName); let remoteVersion: VersionInfo | null = null; let remoteContent: string | null = null; if (entry) { const response = await fetch(entry.downloadUrl, { headers: { 'Authorization': `Bearer ${token}`, 'Accept': 'application/vnd.github.v3.raw' } }); if (response.ok) { remoteContent = await response.text(); remoteVersion = { version: entry.version || '1.0.0', timestamp: entry.lastModified, author: entry.author || 'unknown', hash: createHash('sha256').update(remoteContent).digest('hex'), size: entry.size, source: 'remote' }; } } // Build comparison result const result: any = { element: elementName, type: elementType, local: localVersion, remote: remoteVersion }; if (localVersion && remoteVersion) { result.status = localVersion.hash === remoteVersion.hash ? 'identical' : 'different'; if (showDiff && localContent && remoteContent && result.status === 'different') { result.diff = await this.generateDiff(localContent, remoteContent); } } else if (localVersion && !remoteVersion) { result.status = 'local-only'; } else if (!localVersion && remoteVersion) { result.status = 'remote-only'; } else { result.status = 'not-found'; } return { success: true, message: `Version comparison for '${elementName}' (${elementType})`, data: result }; } catch (error) { return { success: false, message: `Failed to compare versions: ${error instanceof Error ? error.message : String(error)}` }; } } /** * Bulk download elements */ private async bulkDownload(elementType?: ElementType, confirm?: boolean): Promise<SyncResult> { const config = this.configManager.getConfig(); if (!config.sync.bulk.download_enabled) { return { success: false, message: 'Bulk download is not enabled in configuration' }; } // Get list of remote elements const remoteResult = await this.listRemoteElements(); if (!remoteResult.success || !remoteResult.elements) { return remoteResult; } // Filter by type if specified let elementsToDownload = remoteResult.elements; if (elementType) { elementsToDownload = elementsToDownload.filter(e => e.type === elementType); } if (elementsToDownload.length === 0) { return { success: true, message: 'No elements to download', elements: [] }; } // Show preview if required (unless already confirmed) if (config.sync.bulk.require_preview && !confirm) { return { success: false, message: `Bulk download preview:\n\n${elementsToDownload.length} elements will be downloaded:\n${elementsToDownload.map(e => `- ${e.name} (${e.type})`).join('\n')}\n\nTo proceed, use --confirm flag`, data: { requiresConfirmation: true }, elements: elementsToDownload }; } // Perform actual bulk download const results = { downloaded: [] as string[], skipped: [] as string[], failed: [] as { name: string; error: string }[] }; for (const element of elementsToDownload) { try { const result = await this.downloadElement(element.name, element.type, undefined, true); // force=true to skip individual confirmations if (result.success) { results.downloaded.push(element.name); } else if (result.message?.includes('already up to date')) { results.skipped.push(element.name); } else { results.failed.push({ name: element.name, error: result.message || 'Unknown error' }); } } catch (error) { results.failed.push({ name: element.name, error: error instanceof Error ? error.message : String(error) }); } } // Build summary message let message = `Bulk download complete:\n`; message += `- Downloaded: ${results.downloaded.length} elements\n`; message += `- Skipped (up to date): ${results.skipped.length} elements\n`; message += `- Failed: ${results.failed.length} elements`; if (results.failed.length > 0) { message += `\n\nFailed downloads:\n${results.failed.map(f => `- ${f.name}: ${f.error}`).join('\n')}`; } return { success: results.failed.length === 0, message, data: results }; } /** * Bulk upload elements */ private async bulkUpload(elementType?: ElementType, confirm?: boolean): Promise<SyncResult> { const config = this.configManager.getConfig(); if (!config.sync.bulk.upload_enabled) { return { success: false, message: 'Bulk upload is not enabled in configuration' }; } // Get list of local elements const types = elementType ? [elementType] : [ ElementType.PERSONA, ElementType.SKILL, ElementType.TEMPLATE, ElementType.AGENT, ElementType.MEMORY, ElementType.ENSEMBLE ]; const localElements: { name: string; type: ElementType; path: string }[] = []; for (const type of types) { const dir = this.portfolioManager.getElementDir(type); try { const files = await fs.readdir(dir); for (const file of files) { if (file.endsWith('.md')) { localElements.push({ name: file.replace('.md', ''), type, path: path.join(dir, file) }); } } } catch (error) { // Directory may not exist yet logger.debug(`Directory for ${type} does not exist yet`); } } if (localElements.length === 0) { return { success: true, message: 'No local elements to upload', elements: [] }; } // Show preview if required (unless already confirmed) if (config.sync.bulk.require_preview && !confirm) { // Convert to SyncElementInfo format for preview const previewElements: SyncElementInfo[] = localElements.map(e => ({ name: e.name, type: e.type, status: 'local-only' as const, action: 'upload' as const })); return { success: false, message: `Bulk upload preview:\n\n${localElements.length} elements will be uploaded:\n${localElements.map(e => `- ${e.name} (${e.type})`).join('\n')}\n\nTo proceed, use --confirm flag`, data: { requiresConfirmation: true }, elements: previewElements }; } // Perform actual bulk upload const results = { uploaded: [] as string[], skipped: [] as string[], failed: [] as { name: string; error: string }[] }; for (const element of localElements) { try { const result = await this.uploadElement(element.name, element.type, true); // confirm=true to skip individual confirmations if (result.success) { results.uploaded.push(element.name); } else if (result.message?.includes('local-only')) { results.skipped.push(element.name); } else { results.failed.push({ name: element.name, error: result.message || 'Unknown error' }); } } catch (error) { results.failed.push({ name: element.name, error: error instanceof Error ? error.message : String(error) }); } } // Build summary message let message = `Bulk upload complete:\n`; message += `- Uploaded: ${results.uploaded.length} elements\n`; message += `- Skipped (local-only): ${results.skipped.length} elements\n`; message += `- Failed: ${results.failed.length} elements`; if (results.failed.length > 0) { message += `\n\nFailed uploads:\n${results.failed.map(f => `- ${f.name}: ${f.error}`).join('\n')}`; } return { success: results.failed.length === 0, message, data: results }; } /** * Generate diff between two content versions */ private async generateDiff(local: string, remote: string): Promise<string> { // Simple line-based diff for now const localLines = local.split('\n'); const remoteLines = remote.split('\n'); let diff = ''; const maxLines = Math.max(localLines.length, remoteLines.length); for (let i = 0; i < maxLines && i < 10; i++) { // Show first 10 lines of diff const localLine = localLines[i] || ''; const remoteLine = remoteLines[i] || ''; if (localLine !== remoteLine) { if (localLine && !remoteLine) { diff += `- ${localLine}\n`; } else if (!localLine && remoteLine) { diff += `+ ${remoteLine}\n`; } else { diff += `- ${localLine}\n`; diff += `+ ${remoteLine}\n`; } } } if (maxLines > 10) { diff += `\n... ${maxLines - 10} more lines ...`; } return diff || 'No differences found'; } /** * Find a fuzzy match for an element name */ private findFuzzyMatch(searchName: string, entries: GitHubIndexEntry[]): GitHubIndexEntry | null { const search = searchName.toLowerCase().replaceAll(/[-_]/g, ''); let bestMatch: typeof entries[0] | null = null; let bestScore = 0; for (const entry of entries) { // Normalize the entry name for comparison const normalized = entry.name.toLowerCase().replaceAll(/[-_]/g, ''); // Calculate similarity score const score = this.calculateSimilarity(search, normalized); if (score > bestScore && score > 0.5) { // Minimum threshold of 0.5 bestScore = score; bestMatch = entry; } } return bestMatch; } /** * Get suggestions for similar element names */ private getSuggestions(searchName: string, entries: GitHubIndexEntry[]): Array<{name: string}> { const search = searchName.toLowerCase().replaceAll(/[-_]/g, ''); const scored: Array<{entry: typeof entries[0]; score: number}> = []; for (const entry of entries) { const normalized = entry.name.toLowerCase().replaceAll(/[-_]/g, ''); const score = this.calculateSimilarity(search, normalized); if (score > 0.3) { // Lower threshold for suggestions scored.push({ entry, score }); } } // Sort by score and return top 5 return scored .sort((a, b) => b.score - a.score) .slice(0, 5) .map(s => ({ name: s.entry.name })); } /** * Calculate similarity between two strings * Returns a score between 0 and 1 */ private calculateSimilarity(a: string, b: string): number { // Exact match if (a === b) return 1.0; // One contains the other if (a.includes(b) || b.includes(a)) return 0.8; // Calculate word overlap const wordsA = a.split(/[^a-z0-9]+/); const wordsB = b.split(/[^a-z0-9]+/); let matches = 0; for (const wordA of wordsA) { if (wordA && wordsB.some(wordB => wordB === wordA)) { matches++; } } if (matches > 0) { const overlap = (matches * 2) / (wordsA.length + wordsB.length); return Math.max(0.6, overlap); // At least 0.6 for any word match } // Check for partial matches for (const wordA of wordsA) { for (const wordB of wordsB) { if (wordA.length > 3 && wordB.length > 3) { if (wordA.includes(wordB) || wordB.includes(wordA)) { return 0.5; } } } } // No significant similarity return 0; } }

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/DollhouseMCP/DollhouseMCP'

If you have feedback or need assistance with the MCP directory API, please join our Discord server