UNPKG

@simonecoelhosfo/optimizely-mcp-server

Version:

Optimizely MCP Server for AI assistants with integrated CLI tools

452 lines 21.3 kB
/** * Static Progress Reporter - Progress bar that stays in one place * Uses process.stdout.write with carriage return for in-place updates * Now event-driven using ProgressEventEmitter */ import chalk from 'chalk'; import ProgressEventEmitter from '../events/ProgressEventEmitter.js'; export class StaticProgressReporter { totalSteps = 0; currentStep = 0; currentProject = ''; lastProgressLine = ''; startedEntities = new Set(); completedEntities = new Set(); lastEntityCounts = new Map(); lastEntityMessages = new Map(); spinnerFrames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']; spinnerIndex = 0; spinnerInterval = null; eventDrivenMode = false; constructor() { // Simple, static progress bar using stdout // Hide cursor to prevent flickering process.stdout.write('\u001B[?25l'); this.startSpinner(); // Listen to progress events ProgressEventEmitter.on('progress', this.handleProgressEvent.bind(this)); } /** * Start sync operation */ startSync(operation, options) { console.log(chalk.bold.blue(`🚀 Optimizely Cache Sync`)); console.log(chalk.gray(`${operation} • ${new Date().toLocaleTimeString()}`)); console.log(''); // Empty line for spacing } /** * Set total steps */ setTotalSteps(projectCount, entitiesPerProject, totalSteps) { this.totalSteps = totalSteps || (projectCount * entitiesPerProject); console.log(chalk.gray(`Total: ${this.totalSteps} operations across ${projectCount} projects`)); console.log(''); // Empty line before progress bar this.updateProgressBar(); } /** * Update progress for a specific phase */ updateProgress(progress) { // Skip legacy callback handling if we're in event-driven mode if (this.eventDrivenMode) { return; } const { phase, message } = progress; if (phase === 'projects') { // Handle project messages if (message.includes('Syncing project:')) { const projectMatch = message.match(/Syncing project:\s*(.+)/); if (projectMatch) { const projectName = projectMatch[1].trim(); if (projectName !== this.currentProject) { this.clearProgressBar(); console.log(chalk.bold.cyan(`📦 ${projectName}`)); this.currentProject = projectName; this.updateProgressBar(); } } return; } // Handle intermediate progress messages (e.g., "Downloading flags... 100" or "Syncing flags... (100... records)" or "Syncing flag rulesets... (100... API calls)") if ((message.includes('Downloading') || message.includes('Syncing')) && (message.includes('...') && /\d+/.test(message))) { // Handle flag rulesets with API calls const rulesetProgressMatch = message.match(/\((\d+)(\.\.\.)?.*API\s+calls\)/); if (rulesetProgressMatch) { const count = parseInt(rulesetProgressMatch[1]); const hasDots = !!rulesetProgressMatch[2]; const entityKey = `${this.currentProject}_flag_rulesets_progress`; const lastState = this.lastEntityCounts.get(entityKey) || -1; const lastMessage = this.lastEntityMessages.get(entityKey) || ''; // Only update if the count or dots have changed if (count !== lastState || message !== lastMessage) { this.lastEntityCounts.set(entityKey, count); this.lastEntityMessages.set(entityKey, message); this.clearProgressBar(); console.log(chalk.yellow(` ⏳ ${message}`)); this.updateProgressBar(); } return; } // Extract the count and check for the "..." pagination indicator for records const progressMatch = message.match(/\((\d+)(\.\.\.)?.*records\)/); if (progressMatch) { const count = parseInt(progressMatch[1]); const hasDots = !!progressMatch[2]; const entityMatch = message.match(/Syncing\s+(\w+)/); const entityType = entityMatch ? entityMatch[1] : 'unknown'; const entityKey = `${this.currentProject}_${entityType}_progress`; const lastState = this.lastEntityCounts.get(entityKey) || -1; const lastMessage = this.lastEntityMessages.get(entityKey) || ''; // Only update if the count or dots have changed if (count !== lastState || message !== lastMessage) { this.lastEntityCounts.set(entityKey, count); this.lastEntityMessages.set(entityKey, message); this.clearProgressBar(); console.log(chalk.yellow(` ⏳ ${message}`)); this.updateProgressBar(); } } return; } // Handle "Syncing X entity" messages to show what's being synced if (message.includes('Syncing') && (message.includes('flags') || message.includes('experiments') || message.includes('campaigns') || message.includes('pages') || message.includes('audiences') || message.includes('attributes') || message.includes('events') || message.includes('extensions') || message.includes('change_history') || message.includes('environments') || message.includes('features') || message.includes('flag rulesets'))) { // Special handling for flag rulesets with API calls count if (message.includes('flag rulesets')) { const rulesetMatch = message.match(/Syncing\s+flag\s+rulesets\.\.\.\s*\((\d+)\s*API\s+calls\)/i); if (rulesetMatch) { const count = rulesetMatch[1]; const key = `${this.currentProject}_flag_rulesets_syncing`; if (!this.startedEntities.has(key)) { this.clearProgressBar(); console.log(chalk.yellow(` ⏳ Syncing flag rulesets (${count} API calls)...`)); this.startedEntities.add(key); this.updateProgressBar(); } } } else { // Regular entity syncing pattern const syncMatch = message.match(/Syncing\s+(\d+)?\s*(\w+)/i); if (syncMatch) { const count = syncMatch[1] || ''; const entityType = syncMatch[2].toLowerCase(); // Don't show duplicate syncing messages for the same entity const key = `${this.currentProject}_${entityType}_syncing`; if (!this.startedEntities.has(key)) { this.clearProgressBar(); console.log(chalk.yellow(` ⏳ Syncing ${entityType}${count ? ` (${count} items)` : ''}...`)); this.startedEntities.add(key); this.updateProgressBar(); } } } return; } // Handle progress update messages like "Downloading flags... 20" if ((message.includes('Downloading') || message.includes('Processing')) && message.includes('...') && /\d+$/.test(message)) { const progressMatch = message.match(/(\w+)\s+(\w+)\.\.\.\s*(\d+)/); if (progressMatch) { const action = progressMatch[1]; const entityType = progressMatch[2].toLowerCase(); const count = progressMatch[3]; // Print progress update directly const entityKey = `${this.currentProject}_${entityType}_download`; const lastCount = this.lastEntityCounts.get(entityKey) || -1; const currentCount = parseInt(count); if (currentCount !== lastCount) { this.lastEntityCounts.set(entityKey, currentCount); this.clearProgressBar(); console.log(chalk.yellow(` ⏳ ${action} ${entityType}... ${count}`)); this.updateProgressBar(); } } return; } // Handle entity completion - extract record count from message if (message.includes('synced') || message.includes('completed')) { const entityMatch = message.match(/(\w+)\s+(?:synced|completed)/i); if (entityMatch) { const entityType = entityMatch[1].toLowerCase(); // Skip if this is a duplicate from the entity-specific handler // We'll handle it in the entity-specific phase handler instead return; } } } // Handle individual entity phases if (phase.startsWith('project_') && phase.includes('_')) { const parts = phase.split('_'); if (parts.length >= 3) { const entityType = parts.slice(2).join('_'); const entityData = progress.entityData; // Handle entity progress updates (initial sync or intermediate counts) if (progress.percent === 0) { // Handle initial entity sync if (!this.entityStarted(entityType)) { // First time seeing this entity this.clearProgressBar(); console.log(chalk.yellow(` ⏳ Syncing ${entityType}...`)); this.markEntityStarted(entityType); this.lastEntityCounts.delete(`${this.currentProject}_${entityType}`); // Clear count tracking this.updateProgressBar(); } } // Handle intermediate progress updates with structured entityData (regardless of percent) if (entityData?.count !== undefined && progress.percent < 100) { const count = entityData.count; const countType = entityData.countType || 'records'; const suffix = entityData.isOngoing ? '...' : ''; const countText = `${count}${suffix} ${countType}`; const entityKey = `${this.currentProject}_${entityType}`; const lastCount = this.lastEntityCounts.get(entityKey) || -1; // Only update if the count has changed if (count !== lastCount) { this.lastEntityCounts.set(entityKey, count); this.clearProgressBar(); console.log(chalk.yellow(` ⏳ Syncing ${entityType}... (${countText})`)); this.updateProgressBar(); } } // Show when entity completes else if (progress.percent >= 100) { // Extract record count or API calls count from parentheses pattern const recordMatch = message.match(/\((\d+) records?\)/); const apiCallsMatch = message.match(/\((\d+) API calls?\)/); let countSuffix = ''; if (recordMatch) { countSuffix = ` (${recordMatch[1]} records)`; } else if (apiCallsMatch) { countSuffix = ` (${apiCallsMatch[1]} API calls)`; } // Clear count tracking for this entity this.lastEntityCounts.delete(`${this.currentProject}_${entityType}`); this.lastEntityCounts.delete(`${this.currentProject}_${entityType}_progress`); this.clearProgressBar(); console.log(chalk.green(` ✅ ${entityType}${countSuffix}`)); this.currentStep++; this.markEntityCompleted(entityType); this.updateProgressBar(); } } } } /** * Update the static progress bar in place */ updateProgressBar() { if (this.totalSteps === 0) return; const percent = Math.round((this.currentStep / this.totalSteps) * 100); const barLength = 30; const filledLength = Math.round((barLength * this.currentStep) / this.totalSteps); const bar = '█'.repeat(filledLength) + '░'.repeat(barLength - filledLength); const spinner = this.spinnerFrames[this.spinnerIndex]; const progressText = `${spinner} Progress: [${bar}] ${percent}% (${this.currentStep}/${this.totalSteps})`; // Clear current line completely and move cursor to beginning process.stdout.write('\r\x1b[K'); // Show the progress bar with spinner process.stdout.write(chalk.cyan(progressText)); this.lastProgressLine = progressText; } /** * Update only the spinner character without redrawing entire progress bar */ updateSpinnerOnly() { if (this.totalSteps === 0 || !this.lastProgressLine) return; const percent = Math.round((this.currentStep / this.totalSteps) * 100); const barLength = 30; const filledLength = Math.round((barLength * this.currentStep) / this.totalSteps); const bar = '█'.repeat(filledLength) + '░'.repeat(barLength - filledLength); const spinner = this.spinnerFrames[this.spinnerIndex]; const progressText = `${spinner} Progress: [${bar}] ${percent}% (${this.currentStep}/${this.totalSteps})`; // Only update spinner if the rest of the content is the same const oldSpinner = this.lastProgressLine.charAt(0); const newSpinner = spinner; if (oldSpinner !== newSpinner) { // Just update the first character (spinner) instead of the whole line process.stdout.write(`\r${chalk.cyan(progressText)}`); } } /** * Clear the progress bar to write other content */ clearProgressBar() { if (this.lastProgressLine) { process.stdout.write('\r\x1b[K'); this.lastProgressLine = ''; } } /** * Complete sync operation */ completeSync(result) { this.clearProgressBar(); // Show cursor again when done process.stdout.write('\u001B[?25h'); console.log(''); // New line after progress bar console.log(chalk.bold.green('✨ Sync Completed Successfully!')); if (result.duration) { console.log(chalk.gray(`⏱️ Completed in ${(result.duration / 1000).toFixed(1)}s`)); } } /** * Report error */ error(error) { this.clearProgressBar(); // Show cursor again on error process.stdout.write('\u001B[?25h'); console.log(''); // New line after progress bar console.error(chalk.red.bold('❌ Sync Failed!')); console.error(chalk.red(error.message)); } /** * Handle progress events from the event emitter */ handleProgressEvent(event) { // Enable event-driven mode to suppress legacy callbacks this.eventDrivenMode = true; if (event.type === 'start') { if (event.entity === 'project') { // Handle project start const projectMatch = event.message?.match(/Syncing project:\s*(.+)/); if (projectMatch) { const projectName = projectMatch[1].trim(); if (projectName !== this.currentProject) { this.clearProgressBar(); console.log(chalk.bold.cyan(`📦 ${projectName}`)); this.currentProject = projectName; this.updateProgressBar(); } } } else { // Handle entity start if (!this.entityStarted(event.entity)) { this.clearProgressBar(); console.log(chalk.yellow(` ⏳ Syncing ${event.entity}...`)); this.markEntityStarted(event.entity); this.lastEntityCounts.delete(`${this.currentProject}_${event.entity}`); this.updateProgressBar(); } } } else if (event.type === 'progress' && event.count !== undefined) { // Handle intermediate progress with counts const count = event.count; const countType = event.countType || 'records'; const suffix = event.isOngoing ? '...' : ''; // Create clearer messaging let progressText = ''; if (countType === 'api_calls') { if (event.entity === 'flag_rulesets') { progressText = event.isOngoing ? `Loading ruleset ${count}...` : `Loaded ${count} rulesets`; } else { progressText = event.isOngoing ? `API call ${count}...` : `${count} API calls`; } } else { progressText = event.isOngoing ? `${count}... ${countType}` : `${count} ${countType}`; } const entityKey = `${this.currentProject}_${event.entity}`; const lastCount = this.lastEntityCounts.get(entityKey) || -1; if (count !== lastCount) { this.lastEntityCounts.set(entityKey, count); this.clearProgressBar(); console.log(chalk.yellow(` ⏳ Syncing ${event.entity}... (${progressText})`)); this.updateProgressBar(); } } else if (event.type === 'complete') { // Handle entity completion let countSuffix = ''; if (event.count !== undefined) { const countType = event.countType || 'records'; if (countType === 'api_calls' && event.entity === 'flag_rulesets') { countSuffix = ` (${event.count} rulesets)`; } else { countSuffix = ` (${event.count} ${countType})`; } } // Clear count tracking for this entity this.lastEntityCounts.delete(`${this.currentProject}_${event.entity}`); this.lastEntityCounts.delete(`${this.currentProject}_${event.entity}_progress`); this.clearProgressBar(); console.log(chalk.green(` ✅ ${event.entity}${countSuffix}`)); this.currentStep++; this.markEntityCompleted(event.entity); this.updateProgressBar(); } else if (event.type === 'error') { this.clearProgressBar(); console.log(chalk.red(` ❌ ${event.entity} (${event.error?.message || 'Unknown error'})`)); this.updateProgressBar(); } } /** * Clean up */ dispose() { this.stopSpinner(); this.clearProgressBar(); // Always restore cursor on cleanup process.stdout.write('\u001B[?25h'); console.log(''); // Ensure we end with a clean line // Remove event listeners ProgressEventEmitter.removeListener('progress', this.handleProgressEvent.bind(this)); } /** * Track entity started */ entityStarted(entityType) { const key = `${this.currentProject}_${entityType}`; return this.startedEntities.has(key); } /** * Mark entity as started */ markEntityStarted(entityType) { const key = `${this.currentProject}_${entityType}`; this.startedEntities.add(key); } /** * Mark entity as completed */ markEntityCompleted(entityType) { const key = `${this.currentProject}_${entityType}`; this.completedEntities.add(key); } /** * Start the spinner animation */ startSpinner() { if (!this.spinnerInterval) { this.spinnerInterval = setInterval(() => { this.spinnerIndex = (this.spinnerIndex + 1) % this.spinnerFrames.length; this.updateSpinnerOnly(); }, 250); // Update spinner every 250ms to reduce flickering } } /** * Stop the spinner animation */ stopSpinner() { if (this.spinnerInterval) { clearInterval(this.spinnerInterval); this.spinnerInterval = null; } } } //# sourceMappingURL=StaticProgressReporter.js.map