@simonecoelhosfo/optimizely-mcp-server
Version:
Optimizely MCP Server for AI assistants with integrated CLI tools
452 lines • 21.3 kB
JavaScript
/**
* 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