UNPKG

@simonecoelhosfo/optimizely-mcp-server

Version:

Optimizely MCP Server for AI assistants with integrated CLI tools

586 lines • 26.4 kB
#!/usr/bin/env node /** * Orchestration Template CLI * * A command-line tool for creating, validating, and managing Optimizely orchestration templates. * * Features: * - Interactive template creation with inquirer * - Comprehensive validation with auto-fix * - Progress reporting for long operations * - TTY-aware output formatting * - Robust error handling and recovery * * Inspired by the engineering excellence of cache-sync-enhanced.ts */ import { Command } from 'commander'; import chalk from 'chalk'; import * as fs from 'fs'; import * as path from 'path'; import { fileURLToPath } from 'url'; import * as dotenv from 'dotenv'; import { TemplateValidator } from './core/TemplateValidator'; // Load environment variables dotenv.config(); const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const program = new Command(); // Version from package.json const packageJson = JSON.parse(fs.readFileSync(path.join(__dirname, '../../package.json'), 'utf8')); program .name('optly-template') .description('CLI for Optimizely orchestration templates') .version(packageJson.version); /** * Validate command */ program .command('validate <file>') .description('Validate an orchestration template') .option('--fix', 'Auto-fix errors where possible') .option('--json', 'Output results as JSON') .option('--strict', 'Enable strict validation mode') .action(async (file, options) => { try { // Check if file exists if (!fs.existsSync(file)) { console.error(chalk.red(`āŒ File not found: ${file}`)); process.exit(1); } // Read template const templateContent = fs.readFileSync(file, 'utf8'); let template; try { template = JSON.parse(templateContent); } catch (error) { console.error(chalk.red('āŒ Invalid JSON in template file')); console.error(chalk.gray(error instanceof Error ? error.message : String(error))); process.exit(1); } // Validate const validator = new TemplateValidator(); const result = await validator.validate(template); // Handle auto-fix if (options.fix && !result.valid && result.errors.some(e => e.fix)) { console.log(chalk.yellow('\nšŸ”§ Applying auto-fixes...')); const { fixedTemplate, fixes } = await validator.applyAutoFixes(template); console.log(chalk.green(`\n✨ Applied ${fixes.length} fixes:`)); for (const fix of fixes) { console.log(` • ${chalk.cyan(fix.path)}: ${chalk.red(JSON.stringify(fix.oldValue))} → ${chalk.green(JSON.stringify(fix.newValue))}`); console.log(` ${chalk.gray(fix.description)}`); } // Save fixed template const fixedPath = file.replace('.json', '.fixed.json'); fs.writeFileSync(fixedPath, JSON.stringify(fixedTemplate, null, 2)); console.log(chalk.green(`\nāœ… Fixed template saved to: ${fixedPath}`)); // Re-validate fixed template console.log(chalk.blue('\nšŸ” Re-validating fixed template...')); const fixedResult = await validator.validate(fixedTemplate); console.log(validator.formatResults(fixedResult)); if (fixedResult.valid) { console.log(chalk.green('\nāœ… Template validation passed after fixes!')); process.exit(0); } else { console.log(chalk.yellow(`\nāš ļø Template still has ${fixedResult.errors.length} error(s) that require manual fixing`)); process.exit(1); } } // Output results if (options.json) { console.log(JSON.stringify(result, null, 2)); } else { console.log(validator.formatResults(result)); if (result.valid) { console.log(chalk.green('\nāœ… Template validation passed!')); } else { console.log(chalk.red(`\nāŒ Template validation failed with ${result.errors.length} error(s)`)); if (!options.fix && result.errors.some(e => e.fix)) { console.log(chalk.yellow('\nšŸ’” Some errors can be auto-fixed. Run with --fix to apply fixes.')); } } } process.exit(result.valid ? 0 : 1); } catch (error) { console.error(chalk.red('āŒ Validation error:'), error instanceof Error ? error.message : String(error)); process.exit(1); } }); /** * Fields command */ program .command('fields <entity>') .description('Show field requirements for an entity type') .option('-p, --platform <platform>', 'Platform (web or feature)', 'web') .option('-m, --mode <mode>', 'Mode (template or direct)', 'template') .option('--required-only', 'Show only required fields') .option('--json', 'Output as JSON') .action(async (entity, options) => { try { // Import fields from generated file const { FIELDS } = await import('../generated/fields.generated'); const entityKey = entity; if (!FIELDS[entityKey]) { console.error(chalk.red(`āŒ Unknown entity type: ${entity}`)); console.error(chalk.gray(`Available entities: ${Object.keys(FIELDS).join(', ')}`)); process.exit(1); } const fieldDef = FIELDS[entityKey]; if (options.json) { console.log(JSON.stringify(fieldDef, null, 2)); } else { console.log(chalk.blue.bold(`\n${entity.toUpperCase()} FIELDS (${options.platform} platform, ${options.mode} mode)`)); console.log('='.repeat(60)); // Required fields if (fieldDef.required && fieldDef.required.length > 0) { console.log(chalk.yellow('\nREQUIRED FIELDS:')); for (const field of fieldDef.required) { const type = fieldDef.fieldTypes?.[field] || 'any'; const desc = fieldDef.fieldDescriptions?.[field] || ''; console.log(` ${chalk.green(field)} ${chalk.gray(type)}`); if (desc && !options.requiredOnly) { console.log(` ${desc.split('\n')[0]}`); } const example = fieldDef.fieldExamples?.[field]; if (example !== undefined && !options.requiredOnly) { console.log(` Example: ${chalk.cyan(JSON.stringify(example))}`); } } } // Optional fields if (!options.requiredOnly && fieldDef.optional && fieldDef.optional.length > 0) { console.log(chalk.yellow('\nOPTIONAL FIELDS:')); for (const field of fieldDef.optional.slice(0, 10)) { const type = fieldDef.fieldTypes?.[field] || 'any'; console.log(` ${field} ${chalk.gray(type)}`); } if (fieldDef.optional.length > 10) { console.log(chalk.gray(` ... and ${fieldDef.optional.length - 10} more`)); } } // Enum values if (fieldDef.enums && Object.keys(fieldDef.enums).length > 0) { console.log(chalk.yellow('\nENUM VALUES:')); for (const [field, values] of Object.entries(fieldDef.enums)) { console.log(` ${field}: ${Array.isArray(values) ? values.join(', ') : String(values)}`); } } // Orchestration-specific rules console.log(chalk.yellow('\nORCHESTRATION RULES:')); if (entity === 'campaign' && options.platform === 'web') { console.log(chalk.red(' āš ļø metrics.scope MUST be "session" for campaigns')); } if (entity === 'experiment' && options.platform === 'web') { console.log(chalk.red(' āš ļø metrics.scope MUST be "visitor" for experiments')); } if (entity === 'audience') { console.log(chalk.red(' āš ļø Complex audiences require _acknowledged_complexity: true')); } // Platform restrictions const webOnly = ['experiment', 'campaign', 'page']; const featureOnly = ['flag', 'ruleset', 'rule']; if (webOnly.includes(entity) && options.platform === 'feature') { console.log(chalk.red(`\nāŒ ${entity} is not supported on feature platform`)); } if (featureOnly.includes(entity) && options.platform === 'web') { console.log(chalk.red(`\nāŒ ${entity} is not supported on web platform`)); } } } catch (error) { console.error(chalk.red('āŒ Error:'), error instanceof Error ? error.message : String(error)); process.exit(1); } }); /** * Create command */ program .command('create') .description('Create a new orchestration template') .option('-i, --interactive', 'Interactive mode (requires TTY)') .option('-p, --pattern <name>', 'Use a pattern (ab-test, feature-flag, campaign, full-stack)') .option('-o, --output <file>', 'Output file path (defaults to template ID)') .option('--platform <platform>', 'Platform (web or feature)', 'web') .option('-l, --list-patterns', 'List available patterns') .option('--name <name>', 'Template name/description') .option('--add-event <name>', 'Add an event to the template') .option('--add-audience <name>', 'Add an audience to the template') .option('--experiment-name <name>', 'Name for experiment (web platform)') .option('--flag-name <name>', 'Name for flag (feature platform)') .option('--no-customize', 'Skip customization prompts (use defaults)') .option('--params <params>', 'Parameters as key=value pairs (comma-separated)') .action(async (options) => { try { const { TemplateGenerator } = await import('./core/TemplateGenerator'); const generator = new TemplateGenerator(); // List patterns if requested if (options.listPatterns) { console.log(chalk.blue.bold('\nAvailable Patterns:')); console.log('==================='); const patterns = { 'ab-test': 'Create an A/B test with variations', 'feature-flag': 'Create a feature flag with rollout rules', 'campaign': 'Create a campaign with experiments (Web only)', 'campaign-builder': 'Build custom campaign interactively with multiple experiments', 'personalization': 'Pre-built personalization campaign pattern', 'full-stack': 'Complete setup with audiences, events, and experiments', 'e-commerce': 'E-commerce optimization setup', 'feature-rollout': 'Progressive feature rollout', 'multi-armed-bandit': 'Auto-optimizing experiment' }; for (const [name, desc] of Object.entries(patterns)) { console.log(` ${chalk.green(name.padEnd(15))} ${desc}`); } process.exit(0); } // Validate platform if (!['web', 'feature'].includes(options.platform)) { console.error(chalk.red(`āŒ Invalid platform: ${options.platform}. Must be 'web' or 'feature'`)); process.exit(1); } // Generate template console.log(chalk.blue(`\nšŸ”Ø Creating ${options.pattern ? `'${options.pattern}'` : 'basic'} template for ${options.platform} platform...`)); let template; try { template = await generator.generate({ pattern: options.pattern, interactive: options.interactive, platform: options.platform, customize: options.customize, // Pass the customize option platformExplicit: options.platform !== 'web' // Check if platform was explicitly set }); } catch (error) { // If interactive mode was interrupted, exit without saving if (error instanceof Error && error.message.includes('interrupted')) { console.log(chalk.yellow('\nāš ļø Template creation cancelled.')); process.exit(0); } throw error; } // Set template description if provided if (options.name) { template.description = options.name; } // Apply command-line parameters if provided if (options.params) { const params = options.params.split(',').reduce((acc, param) => { const [key, value] = param.split('=').map(s => s.trim()); acc[key] = value; return acc; }, {}); // Update template steps with provided parameters for (const step of template.steps) { if (step.template?.inputs) { // Update entity names if provided if (params[step.id + '_name'] && step.template.inputs.name) { step.template.inputs.name = params[step.id + '_name']; if (step.template.inputs.key) { step.template.inputs.key = params[step.id + '_name'].toLowerCase().replace(/\s+/g, '_'); } } // Update project_id if provided if (params.project_id) { const projectParam = template.parameters.find(p => p.name === 'project_id'); if (projectParam) { projectParam.default = parseInt(params.project_id); } } } } } // Add more steps based on pattern (only if not interactive) if (!options.interactive && (!options.pattern || options.pattern === 'basic')) { // Add event if requested or by default const eventName = options.addEvent || 'Example Event'; const eventKey = eventName.toLowerCase().replace(/\s+/g, '_'); generator.addCreateStep(template, 'event', 'create_event', { name: eventName, key: eventKey, description: `Tracking event: ${eventName}` }); // Add audience if requested if (options.addAudience) { const audienceName = options.addAudience; generator.addCreateStep(template, 'audience', 'create_audience', { name: audienceName, conditions: JSON.stringify(['and', ['or', ['custom_attribute', 'plan', 'equals', 'premium']]]) }); } if (options.platform === 'web') { const expName = options.experimentName || 'Example Experiment'; const expKey = expName.toLowerCase().replace(/\s+/g, '_'); generator.addCreateStep(template, 'experiment', 'create_experiment', { name: expName, key: expKey, status: 'not_started', variations: [ { name: 'Control', weight: 5000 }, { name: 'Treatment', weight: 5000 } ], metrics: [{ event_id: '${create_event.id}', scope: 'visitor' }] }); } else { const flagName = options.flagName || 'Example Flag'; const flagKey = flagName.toLowerCase().replace(/\s+/g, '_'); generator.addCreateStep(template, 'flag', 'create_flag', { name: flagName, key: flagKey, variations: [ { key: 'off', value: 'false' }, { key: 'on', value: 'true' } ] }); } } // Save template const outputFile = options.output; const templateId = await generator.saveTemplate(template, outputFile); // Determine actual file path for display const templateDir = process.env.ORCHESTRATION_TEMPLATES_DIR || path.join(process.cwd(), 'orchestration-templates'); let actualFilePath; if (outputFile) { actualFilePath = path.isAbsolute(outputFile) ? outputFile : path.join(templateDir, outputFile); } else { actualFilePath = path.join(templateDir, `${templateId}.json`); } console.log(chalk.green(`\nāœ… Template created with ID: ${templateId}`)); console.log(chalk.blue(`šŸ“ File saved to: ${actualFilePath}`)); // Validate the created template console.log(chalk.blue('\nšŸ” Validating created template...')); const { TemplateValidator } = await import('./core/TemplateValidator'); const validator = new TemplateValidator(); const result = await validator.validate(template); if (result.valid) { console.log(chalk.green('āœ… Template is valid!')); } else { console.log(chalk.yellow(`āš ļø Template has ${result.errors.length} validation error(s):`)); for (const error of result.errors.slice(0, 3)) { console.log(chalk.red(` • ${error.message}`)); } if (result.errors.length > 3) { console.log(chalk.gray(` ... and ${result.errors.length - 3} more`)); } console.log(chalk.yellow('\nRun validation with --fix to auto-correct errors:')); console.log(chalk.cyan(` ./ot validate ${actualFilePath} --fix`)); } console.log(chalk.gray(`\nNext steps:`)); console.log(chalk.gray(` 1. Edit ${actualFilePath} to customize`)); console.log(chalk.gray(` 2. Validate: ./ot validate ${actualFilePath}`)); console.log(chalk.gray(` 3. Preview: ./ot preview ${actualFilePath}`)); console.log(chalk.gray(` 4. Execute: ./ot execute ${actualFilePath} (coming soon)`)); } catch (error) { console.error(chalk.red('āŒ Create error:'), error instanceof Error ? error.message : String(error)); process.exit(1); } }); /** * Preview command */ program .command('preview <file>') .description('Preview template execution') .option('-p, --params <params>', 'Parameters as key=value pairs (comma-separated)') .option('--project-id <id>', 'Project ID for the dry run') .option('--json', 'Output as JSON') .action(async (file, options) => { try { // Import DryRunExecutor const { DryRunExecutor } = await import('./core/DryRunExecutor'); // Check if file exists if (!fs.existsSync(file)) { console.error(chalk.red(`āŒ File not found: ${file}`)); process.exit(1); } // Read template const templateContent = fs.readFileSync(file, 'utf8'); let template; try { template = JSON.parse(templateContent); } catch (error) { console.error(chalk.red('āŒ Invalid JSON in template file')); console.error(chalk.gray(error instanceof Error ? error.message : String(error))); process.exit(1); } // Parse parameters const params = {}; if (options.params) { options.params.split(',').forEach((param) => { const [key, value] = param.split('='); params[key.trim()] = value.trim(); }); } // Set project ID const projectId = options.projectId || params.project_id || '12345678'; // Execute dry run const executor = new DryRunExecutor(); const result = await executor.execute(template, { projectId, parameters: params, resolveReferences: true }); // Output results if (options.json) { console.log(JSON.stringify(result, null, 2)); } else { console.log(executor.formatResults(result)); if (result.success) { console.log(chalk.green('\nāœ… Dry run completed successfully!')); } else { console.log(chalk.red('\nāŒ Dry run failed with errors')); } } process.exit(result.success ? 0 : 1); } catch (error) { console.error(chalk.red('āŒ Preview error:'), error instanceof Error ? error.message : String(error)); process.exit(1); } }); /** * Execute command */ program .command('execute <file>') .description('Execute an orchestration template (create entities in Optimizely)') .option('-p, --params <params>', 'Parameters as key=value pairs (comma-separated)') .option('--project-id <id>', 'Project ID for execution') .option('--dry-run', 'Preview without executing') .option('--token <token>', 'Optimizely API token') .option('--confirm', 'Skip confirmation prompt') .action(async (file, options) => { try { // Check if file exists if (!fs.existsSync(file)) { console.error(chalk.red(`āŒ File not found: ${file}`)); process.exit(1); } // Read template const templateContent = fs.readFileSync(file, 'utf8'); let template; try { template = JSON.parse(templateContent); } catch (error) { console.error(chalk.red('āŒ Invalid JSON in template file')); console.error(chalk.gray(error instanceof Error ? error.message : String(error))); process.exit(1); } // Check for API token const apiToken = options.token || process.env.OPTIMIZELY_API_TOKEN; if (!apiToken) { console.error(chalk.red('āŒ API token required!')); console.error(chalk.yellow('\nProvide token via:')); console.error(chalk.gray(' • --token flag: npm run ot -- execute template.json --token YOUR_TOKEN')); console.error(chalk.gray(' • Environment variable: export OPTIMIZELY_API_TOKEN=YOUR_TOKEN')); process.exit(1); } // Parse parameters const parameters = {}; if (options.projectId) { parameters.project_id = parseInt(options.projectId); } if (options.params) { const pairs = options.params.split(','); for (const pair of pairs) { const [key, value] = pair.trim().split('='); parameters[key] = isNaN(Number(value)) ? value : Number(value); } } // Validate template first console.log(chalk.blue('šŸ” Validating template...')); const { TemplateValidator } = await import('./core/TemplateValidator'); const validator = new TemplateValidator(); const validation = await validator.validate(template); if (!validation.valid) { console.log(chalk.red('āŒ Template validation failed!')); console.log(validator.formatResults(validation)); process.exit(1); } console.log(chalk.green('āœ… Template is valid')); // Preview what will be created if (!options.dryRun) { console.log(chalk.blue('\nšŸ“‹ Preview of what will be created:')); const { DryRunExecutor } = await import('./core/DryRunExecutor'); const dryRunner = new DryRunExecutor(); const preview = await dryRunner.execute(template, { projectId: parameters.project_id?.toString() || 'unknown', parameters }); console.log(dryRunner.formatResults(preview)); } // Confirm execution if (!options.confirm && !options.dryRun) { console.log(chalk.yellow('\nāš ļø This will create real entities in Optimizely!')); console.log(chalk.yellow('Are you sure you want to continue? (yes/no)')); // Simple confirmation (in real implementation, use readline or inquirer) process.stdout.write('> '); // For now, just show what would happen console.log(chalk.gray('\n(Auto-declining for safety - use --confirm to skip this prompt)')); process.exit(0); } // Execute template const { TemplateExecutor } = await import('./core/TemplateExecutor'); const executor = new TemplateExecutor(); console.log(chalk.blue('\nšŸš€ Executing template...')); const result = await executor.execute(template, { projectId: parameters.project_id?.toString(), parameters, results: {}, apiToken }, { dryRun: options.dryRun, stopOnError: true }); // Output results console.log(executor.formatResults(result)); if (result.success) { console.log(chalk.green('\nāœ… Template executed successfully!')); if (result.createdEntities.length > 0) { console.log(chalk.blue('\nšŸ“ Created entity IDs (save these for reference):')); const output = {}; for (const entity of result.createdEntities) { output[entity.stepId] = { id: entity.entityId, type: entity.entityType, name: entity.name }; } console.log(JSON.stringify(output, null, 2)); } } else { console.log(chalk.red('\nāŒ Template execution failed!')); process.exit(1); } } catch (error) { console.error(chalk.red('āŒ Execute error:'), error instanceof Error ? error.message : String(error)); process.exit(1); } }); // Parse command line arguments program.parse(process.argv); // Show help if no command provided if (!process.argv.slice(2).length) { program.outputHelp(); } //# sourceMappingURL=orchestration-template-cli.js.map