@simonecoelhosfo/optimizely-mcp-server
Version:
Optimizely MCP Server for AI assistants with integrated CLI tools
577 lines (473 loc) • 21.7 kB
JavaScript
/**
* Optimizely Project JavaScript Sync Tool
*
* This script provides two main features:
* 1. INITIALIZATION: Adds comment delimiters to existing projects to prepare them for syncing
* 2. SYNCHRONIZATION: Updates shared JavaScript code across multiple Optimizely projects while preserving project-specific code
*
* The script uses comment delimiters to identify shared code:
* - Shared code is wrapped between // OPTIMIZELY_SHARED_CODE_START and // OPTIMIZELY_SHARED_CODE_END
*/
// Configuration - Update these values before running
const config = {
// Required: Your Optimizely API token
apiToken: 'YOUR_OPTIMIZELY_API_TOKEN',
// API endpoint (shouldn't need to change)
baseUrl: 'https://api.optimizely.com/v2',
// Delimiters to identify shared code sections (can customize if needed)
sharedCodeStartDelimiter: '// OPTIMIZELY_SHARED_CODE_START',
sharedCodeEndDelimiter: '// OPTIMIZELY_SHARED_CODE_END',
// Initial placeholder text when initializing projects
initialSharedCodePlaceholder: '// Shared code managed by automation - will be populated by update script',
// Optional: Filter projects by name pattern (e.g., only sync projects containing "production")
projectFilter: null, // Set to a regex pattern like /production/i or null to process all projects
// NEW: Skip archived projects during sync
skipArchivedProjects: true, // Set to true to skip archived projects
// NEW: Specific project IDs to sync (empty array = all projects)
targetProjectIds: [], // e.g., ['12345', '67890'] to sync only these projects
// NEW: Enable detailed comparison logging for debugging
verboseComparison: false, // Set to true to debug comparison issues
// For SYNC mode, set ONE of these:
referenceProjectId: null, // ID of the reference project to extract shared code from
sharedCodeContent: null, // Direct string content to use as shared code
// Set to true to only log what would happen without making actual update calls (for testing)
dryRun: true
};
// Helper function to make API requests
async function makeApiRequest(endpoint, method = 'GET', data = null) {
const url = `${config.baseUrl}${endpoint}`;
const headers = {
'Authorization': `Bearer ${config.apiToken}`,
'Content-Type': 'application/json'
};
const options = {
method,
headers
};
if (data && (method === 'POST' || method === 'PATCH' || method === 'PUT')) {
options.body = JSON.stringify(data);
}
try {
console.log(`Making API call: ${method} ${url}`);
if (config.dryRun && (method === 'POST' || method === 'PATCH' || method === 'PUT')) {
console.log(`DRY RUN: Would make ${method} request to ${url} with data:`, data);
return { dryRun: true, success: true };
}
const response = await fetch(url, options);
if (!response.ok) {
const errorText = await response.text();
throw new Error(`API request failed: ${response.status} ${response.statusText} - ${errorText}`);
}
// Check if the response has content
const contentType = response.headers.get('content-type');
if (contentType && contentType.includes('application/json')) {
return await response.json();
} else {
return await response.text();
}
} catch (error) {
console.error('API request error:', error);
throw error;
}
}
// Get all projects
async function listProjects(includeArchived = false) {
console.log('Fetching projects...');
let projects = await makeApiRequest('/projects');
// Filter out archived projects if configured
if (config.skipArchivedProjects && !includeArchived) {
const originalCount = projects.length;
projects = projects.filter(project => {
// Check various possible fields for archived status
return !project.archived && project.status !== 'archived';
});
if (originalCount !== projects.length) {
console.log(`Filtered out ${originalCount - projects.length} archived projects.`);
}
}
// Apply name filter if configured
if (config.projectFilter) {
const beforeFilterCount = projects.length;
projects = projects.filter(project => config.projectFilter.test(project.name));
console.log(`Found ${projects.length} projects matching name filter out of ${beforeFilterCount} non-archived projects.`);
}
// Apply ID filter if configured
if (config.targetProjectIds && config.targetProjectIds.length > 0) {
const beforeFilterCount = projects.length;
projects = projects.filter(project => config.targetProjectIds.includes(project.id.toString()));
console.log(`Found ${projects.length} projects matching ID filter out of ${beforeFilterCount} projects.`);
}
console.log(`Total projects to process: ${projects.length}`);
return projects;
}
// Get project details by ID
async function getProject(projectId) {
console.log(`Fetching project ${projectId}...`);
return await makeApiRequest(`/projects/${projectId}`);
}
// Update project with new JavaScript
async function updateProject(projectId, projectData) {
console.log(`Updating project ${projectId}...`);
return await makeApiRequest(`/projects/${projectId}`, 'PATCH', projectData);
}
// Helper function to escape special characters in regex
function escapeRegExp(string) {
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
// Helper function to normalize strings for comparison
function normalizeString(str) {
if (!str) return '';
// Normalize line endings (CRLF -> LF)
let normalized = str.replace(/\r\n/g, '\n');
// Trim trailing whitespace from each line
normalized = normalized.split('\n').map(line => line.trimEnd()).join('\n');
// Trim leading/trailing whitespace from entire string
normalized = normalized.trim();
return normalized;
}
// Extract shared code sections using delimiters
function extractCodeSections(javascript) {
// If javascript is null or undefined, return empty
if (!javascript) {
return {
sharedCodeSections: [],
hasSharedCodeDelimiters: false
};
}
const sharedCodeRegex = new RegExp(
`${escapeRegExp(config.sharedCodeStartDelimiter)}([\\s\\S]*?)${escapeRegExp(config.sharedCodeEndDelimiter)}`,
'g'
);
const sharedCodeMatches = [...(javascript.matchAll(sharedCodeRegex) || [])];
// Extract all shared code sections
const sharedCodeSections = sharedCodeMatches.map(match => ({
fullMatch: match[0],
content: match[1],
startIndex: match.index,
endIndex: match.index + match[0].length
}));
// Check if delimiters exist
const hasSharedCodeDelimiters = sharedCodeMatches.length > 0;
return {
sharedCodeSections,
hasSharedCodeDelimiters
};
}
// Initialize project JavaScript with shared code markers
function initializeProjectJavaScript(projectJavaScript) {
// If the JavaScript is null or undefined, initialize with an empty string
projectJavaScript = projectJavaScript || '';
// Check if markers already exist
const { hasSharedCodeDelimiters } = extractCodeSections(projectJavaScript);
if (hasSharedCodeDelimiters) {
console.log('Project already has shared code markers. No initialization needed.');
return null;
}
// Construct the new JS content with markers wrapping an empty shared block,
// followed by the original content
const newJavaScriptContent =
config.sharedCodeStartDelimiter + '\n' +
config.initialSharedCodePlaceholder + '\n' +
config.sharedCodeEndDelimiter +
// Add separation only if original JS exists
(projectJavaScript.trim() ? '\n\n// --- Original Project Specific Code Below ---\n' + projectJavaScript : '');
return newJavaScriptContent;
}
// Update a project's JavaScript with new shared code
function updateProjectJavaScript(projectJavaScript, latestSharedCode) {
const { sharedCodeSections, hasSharedCodeDelimiters } = extractCodeSections(projectJavaScript);
if (!hasSharedCodeDelimiters) {
console.warn('No shared code sections found in project JavaScript. Project needs initialization first.');
return null;
}
// Replace each shared code section with the latest shared code
let newJavaScript = projectJavaScript;
let offset = 0;
for (const section of sharedCodeSections) {
const updatedSection = `${config.sharedCodeStartDelimiter}
${latestSharedCode}
${config.sharedCodeEndDelimiter}`;
const startIndex = section.startIndex + offset;
const endIndex = section.endIndex + offset;
newJavaScript = newJavaScript.substring(0, startIndex) + updatedSection + newJavaScript.substring(endIndex);
// Update offset for subsequent replacements
offset += updatedSection.length - (endIndex - startIndex);
}
return newJavaScript;
}
// Get shared code from reference project or direct content
async function getLatestSharedCode() {
// First check if direct content is provided
if (config.sharedCodeContent) {
console.log('Using provided shared code content.');
return config.sharedCodeContent;
}
// Then check if reference project ID is provided
else if (config.referenceProjectId) {
console.log(`Getting shared code from reference project: ${config.referenceProjectId}`);
const referenceProject = await getProject(config.referenceProjectId);
if (!referenceProject.javascript) {
throw new Error(`Reference project ${config.referenceProjectId} does not have any JavaScript.`);
}
const { sharedCodeSections, hasSharedCodeDelimiters } = extractCodeSections(referenceProject.javascript);
if (!hasSharedCodeDelimiters) {
throw new Error(`Reference project ${config.referenceProjectId} does not have shared code delimiters.`);
}
// Take the first shared code section
if (sharedCodeSections.length > 0) {
return sharedCodeSections[0].content;
} else {
throw new Error(`No shared code found in reference project ${config.referenceProjectId}.`);
}
} else {
throw new Error('Neither sharedCodeContent nor referenceProjectId is configured. One is required for sync.');
}
}
// Main function to initialize projects
async function initializeProjects(targetProjectIds = []) {
try {
console.log('Starting Optimizely Project JavaScript Initialization...');
if (config.dryRun) {
console.warn('\n*** DRY RUN MODE ENABLED: No actual updates will be performed. ***\n');
}
if (!config.apiToken || config.apiToken === 'YOUR_OPTIMIZELY_API_TOKEN') {
throw new Error('API Token is not set. Please configure apiToken in the config object.');
}
// Get all projects or use provided list
let projectIdsToProcess = targetProjectIds;
if (!projectIdsToProcess || projectIdsToProcess.length === 0) {
console.log('Fetching all projects...');
const projects = await listProjects();
projectIdsToProcess = projects.map(p => p.id);
console.log(`Found ${projectIdsToProcess.length} projects.`);
} else {
console.log(`Processing specific projects: ${projectIdsToProcess.join(', ')}`);
}
// Process each project
let successCount = 0;
let skipCount = 0;
let failCount = 0;
for (const projectId of projectIdsToProcess) {
try {
console.log(`\n--- Processing Project ID: ${projectId} ---`);
// Get project details
const projectDetails = await getProject(projectId);
// Initialize project JavaScript if needed
const updatedJavaScript = initializeProjectJavaScript(projectDetails.javascript);
// Skip if already initialized
if (updatedJavaScript === null) {
console.log(`⏭️ Project ${projectId} - ${projectDetails.name} already has markers. Skipping.`);
skipCount++;
continue;
}
// Update the project
await updateProject(projectId, { javascript: updatedJavaScript });
console.log(`✅ Project ${projectId} - ${projectDetails.name} initialized successfully`);
successCount++;
} catch (error) {
console.error(`❌ Error processing project ${projectId}:`, error);
failCount++;
}
}
console.log('\nInitialization Summary:');
console.log(`Total projects: ${projectIdsToProcess.length}`);
console.log(`Successfully initialized: ${successCount}`);
console.log(`Already initialized (skipped): ${skipCount}`);
console.log(`Failed: ${failCount}`);
} catch (error) {
console.error('An error occurred during initialization:', error);
}
}
// Main function to sync projects
async function syncProjects(targetProjectIds = []) {
try {
console.log('Starting Optimizely Project JavaScript Sync...');
if (config.dryRun) {
console.warn('\n*** DRY RUN MODE ENABLED: No actual updates will be performed. ***\n');
}
if (!config.apiToken || config.apiToken === 'YOUR_OPTIMIZELY_API_TOKEN') {
throw new Error('API Token is not set. Please configure apiToken in the config object.');
}
// Get projects - either specific ones or all based on filters
let projects;
if (targetProjectIds && targetProjectIds.length > 0) {
// Override config with command line project IDs
const originalTargetIds = config.targetProjectIds;
config.targetProjectIds = targetProjectIds;
projects = await listProjects();
config.targetProjectIds = originalTargetIds; // Restore original config
} else {
projects = await listProjects();
}
if (projects.length === 0) {
console.log('No projects found to sync based on current filters.');
return;
}
// Get the latest shared code
const latestSharedCode = await getLatestSharedCode();
console.log('Latest shared code retrieved successfully.');
// Process each project
let successCount = 0;
let notInitializedCount = 0;
let noChangeCount = 0;
let failCount = 0;
let archivedSkipCount = 0;
for (const project of projects) {
try {
console.log(`\nProcessing project: ${project.id} - ${project.name}`);
// Get full project details
const projectDetails = await getProject(project.id);
// Double-check archived status with full details
if (config.skipArchivedProjects && (projectDetails.archived || projectDetails.status === 'archived')) {
console.log(`⏭️ Skipping archived project ${project.id} - ${project.name}`);
archivedSkipCount++;
continue;
}
// Skip projects without JavaScript
if (!projectDetails.javascript) {
console.log(`⏭️ Skipping project ${project.id} - No JavaScript found`);
continue;
}
// Update the project's JavaScript
const updatedJavaScript = updateProjectJavaScript(projectDetails.javascript, latestSharedCode);
// Skip if project is not initialized
if (updatedJavaScript === null) {
console.log(`⚠️ Project ${project.id} - ${project.name} is not initialized with markers. Run initialization first.`);
notInitializedCount++;
continue;
}
// Normalize both strings for comparison
const normalizedOriginal = normalizeString(projectDetails.javascript);
const normalizedUpdated = normalizeString(updatedJavaScript);
// Log verbose comparison info if enabled
if (config.verboseComparison) {
console.log(`Comparison details for project ${project.id}:`);
console.log(` Original length: ${projectDetails.javascript.length} -> Normalized: ${normalizedOriginal.length}`);
console.log(` Updated length: ${updatedJavaScript.length} -> Normalized: ${normalizedUpdated.length}`);
console.log(` Strings equal: ${normalizedOriginal === normalizedUpdated}`);
}
// Only update if the normalized JavaScript has changed
if (normalizedOriginal !== normalizedUpdated) {
await updateProject(project.id, { javascript: updatedJavaScript });
console.log(`✅ Project ${project.id} - ${project.name} updated successfully`);
successCount++;
} else {
console.log(`⏭️ Project ${project.id} - ${project.name} already has the latest shared code`);
noChangeCount++;
}
} catch (error) {
console.error(`❌ Error processing project ${project.id} - ${project.name}:`, error);
failCount++;
}
}
console.log('\n========================================');
console.log('Sync Summary:');
console.log(`Total projects processed: ${projects.length}`);
console.log(`Successfully updated: ${successCount}`);
console.log(`No changes needed: ${noChangeCount}`);
console.log(`Not initialized (skipped): ${notInitializedCount}`);
if (archivedSkipCount > 0) {
console.log(`Archived projects (skipped): ${archivedSkipCount}`);
}
console.log(`Failed: ${failCount}`);
console.log('========================================\n');
} catch (error) {
console.error('An error occurred during sync:', error);
}
}
// Function to handle command line arguments and run the appropriate mode
function runScript() {
// Default mode is sync
let mode = 'sync';
let targetProjectIds = [];
// Simple command line args parser
const args = process.argv.slice(2);
for (let i = 0; i < args.length; i++) {
const arg = args[i];
if (arg === '--init' || arg === '--initialize') {
mode = 'initialize';
} else if (arg === '--sync') {
mode = 'sync';
} else if (arg === '--token' && i + 1 < args.length) {
config.apiToken = args[i + 1];
i++; // Skip the next arg which is the token value
} else if (arg === '--ref' && i + 1 < args.length) {
config.referenceProjectId = args[i + 1];
i++; // Skip the next arg which is the project ID
} else if (arg === '--dry-run') {
config.dryRun = true;
} else if (arg === '--live') {
config.dryRun = false;
} else if (arg === '--project' && i + 1 < args.length) {
targetProjectIds.push(args[i + 1]);
i++; // Skip the next arg which is the project ID
} else if (arg === '--include-archived') {
config.skipArchivedProjects = false;
} else if (arg === '--skip-archived') {
config.skipArchivedProjects = true;
} else if (arg === '--verbose-compare') {
config.verboseComparison = true;
} else if (arg === '--help') {
showHelp();
return;
}
}
// Run the appropriate mode
if (mode === 'initialize') {
initializeProjects(targetProjectIds);
} else if (mode === 'sync') {
syncProjects(targetProjectIds); // Pass project IDs to sync as well
}
}
// Help text for command line usage
function showHelp() {
console.log(`
Optimizely Project JavaScript Sync Tool
Usage: node optimizely-sync.js [options]
Options:
--help Show this help text
--init Run in initialization mode (add delimiters to projects)
--sync Run in sync mode (update shared code across projects)
--token <token> Set the Optimizely API token
--ref <projectId> Set the reference project ID for sync mode
--project <id> Target specific project ID (can use multiple times for both init and sync)
--dry-run Run without making any actual updates (default)
--live Make actual updates to projects
--include-archived Include archived projects (default: skip archived)
--skip-archived Skip archived projects (default behavior)
--verbose-compare Show detailed comparison info for debugging
Examples:
# Initialize all projects (dry run)
node optimizely-sync.js --init --token YOUR_API_TOKEN
# Initialize specific projects (live run)
node optimizely-sync.js --init --token YOUR_API_TOKEN --project 12345 --project 67890 --live
# Sync shared code from reference project to all non-archived projects (dry run)
node optimizely-sync.js --sync --token YOUR_API_TOKEN --ref 12345
# Sync shared code to specific projects only (live run)
node optimizely-sync.js --sync --token YOUR_API_TOKEN --ref 12345 --project 11111 --project 22222 --live
# Sync including archived projects (live run)
node optimizely-sync.js --sync --token YOUR_API_TOKEN --ref 12345 --include-archived --live
# Debug comparison issues
node optimizely-sync.js --sync --token YOUR_API_TOKEN --ref 12345 --verbose-compare
`);
}
// Check if being run directly (not imported)
if (typeof require !== 'undefined' && require.main === module) {
runScript();
}
// Export functions for modular usage
if (typeof module !== 'undefined' && module.exports) {
module.exports = {
initializeProjects,
syncProjects,
listProjects,
getProject,
updateProject,
extractCodeSections,
updateProjectJavaScript,
initializeProjectJavaScript,
getLatestSharedCode,
normalizeString,
config
};
}