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