powershell-helper.js•29.4 kB
/**
* PowerShell Helper Module
* Centralized PowerShell command execution and response handling
* Part of Jaxon Digital Optimizely DXP MCP Server
*/
const { promisify } = require('util');
const { exec, spawn } = require('child_process');
const execAsync = promisify(exec);
const SecurityHelper = require('./security-helper');
const RetryHelper = require('./retry-helper');
const RateLimiter = require('./rate-limiter');
const CacheManager = require('./cache-manager');
const { getPowerShellDetector } = require('./powershell-detector');
const OutputLogger = require('./output-logger');
// Global instances
let rateLimiter = null;
let cacheManager = null;
let powerShellCommand = null;
class PowerShellHelper {
/**
* Get or create rate limiter instance
*/
static getRateLimiter() {
if (!rateLimiter) {
rateLimiter = new RateLimiter({
maxRequestsPerMinute: 30, // Conservative limit
maxRequestsPerHour: 500,
burstAllowance: 5,
debug: process.env.DEBUG === 'true'
});
}
return rateLimiter;
}
/**
* Get or create cache manager instance
*/
static getCacheManager() {
if (!cacheManager) {
cacheManager = new CacheManager({
debug: process.env.DEBUG === 'true'
});
}
return cacheManager;
}
/**
* Get PowerShell command to use
*/
static async getPowerShellCommand() {
if (!powerShellCommand) {
// Skip detection to avoid hangs - just use pwsh directly
// The npm package works, so we know pwsh is available
powerShellCommand = 'pwsh';
OutputLogger.info('Using PowerShell Core (pwsh)');
}
return powerShellCommand;
}
/**
* Execute a PowerShell command with EpiCloud module
* @param {string} command - The EpiCloud command to execute
* @param {Object} credentials - API credentials {apiKey, apiSecret, projectId}
* @param {Object} options - Additional options {timeout, parseJson}
* @returns {Promise<Object>} Result with stdout, stderr, and parsed data
*/
static async executeEpiCommand(command, credentials, options = {}) {
// Use provided credentials or fall back to environment variables
const apiKey = credentials.apiKey || process.env.OPTIMIZELY_API_KEY;
const apiSecret = credentials.apiSecret || process.env.OPTIMIZELY_API_SECRET;
const projectId = credentials.projectId || process.env.OPTIMIZELY_PROJECT_ID;
const { timeout = 120000, parseJson = true, operation = 'api_call' } = options;
// Validate credentials
const validation = SecurityHelper.validateCredentials({ apiKey, apiSecret, projectId });
if (!validation.valid) {
console.error('Credential validation failed:', validation.errors.join(', '));
return {
stdout: '',
stderr: `Invalid credentials: ${validation.errors.join(', ')}`,
parsedData: null,
success: false
};
}
// Check cache first (if operation supports caching)
const cache = this.getCacheManager();
const cachedResult = cache.get(operation, options.cacheArgs || {}, projectId);
if (cachedResult) {
// Return cached result with cache metadata
return {
...cachedResult,
fromCache: true,
cacheAge: Date.now() - (cachedResult.cachedAt || Date.now())
};
}
// Check rate limits
const limiter = this.getRateLimiter();
const rateLimitCheck = limiter.checkRateLimit(projectId, operation);
if (!rateLimitCheck.allowed) {
const waitTime = Math.ceil(rateLimitCheck.waitTime / 1000);
let errorMessage = '';
switch (rateLimitCheck.reason) {
case 'rate_limit_minute':
errorMessage = `Rate limit exceeded: Too many requests per minute. Wait ${waitTime} seconds.`;
break;
case 'rate_limit_hour':
errorMessage = `Rate limit exceeded: Too many requests per hour. Wait ${waitTime} seconds.`;
break;
case 'throttled':
errorMessage = `API throttled: Server returned 429. Wait ${waitTime} seconds.`;
break;
case 'backoff':
errorMessage = `Backing off due to repeated failures. Wait ${waitTime} seconds.`;
break;
case 'burst_protection':
errorMessage = `Burst protection: Too many rapid requests. Wait ${waitTime} seconds.`;
break;
default:
errorMessage = `Request blocked: ${rateLimitCheck.reason}. Wait ${waitTime} seconds.`;
}
console.error(`Rate limit: ${errorMessage}`);
return {
stdout: '',
stderr: errorMessage,
parsedData: null,
success: false,
rateLimited: true,
retryAfter: rateLimitCheck.retryAfter
};
}
// Build the full PowerShell script
// WORKAROUND: EpiCloud authentication is returning false for all valid credentials as of Sept 2025
// We still call Connect-EpiCloud but don't check the result, as the commands may still work
const psScript = [
'Import-Module EpiCloud -Force',
`$connection = Connect-EpiCloud -ClientKey '${apiKey}' -ClientSecret '${apiSecret}' -ProjectId '${projectId}'`,
'# Workaround: Skipping authentication check due to EpiCloud bug',
'# Commands may still work even if AuthenticationVerified is false',
parseJson ? `${command} | ConvertTo-Json -Depth 10 -Compress` : command
].join('; ');
// Log sanitized command for debugging (without exposing secrets)
if (process.env.DEBUG) {
OutputLogger.info(`Executing command: ${SecurityHelper.sanitizeCommand(command)}`);
}
let stdout = '';
let stderr = '';
try {
const psCommand = await this.getPowerShellCommand();
const result = await execAsync(`${psCommand} -Command "${psScript}"`, { timeout });
stdout = result.stdout;
stderr = result.stderr;
// Record successful request
limiter.recordRequest(projectId, operation);
} catch (error) {
stdout = error.stdout || '';
stderr = error.stderr || error.message || '';
// Enhanced error detection and messaging
if (stderr.includes('Import-Module : The specified module \'EpiCloud\' was not loaded')) {
stderr = '❌ EpiCloud PowerShell module not installed!\n\n' +
'**To install:**\n' +
'```powershell\n' +
'Install-Module -Name EpiCloud -Force -Scope CurrentUser\n' +
'```\n\n' +
'Original error: ' + stderr;
} else if (stderr.includes('Failed to validate the credentials') ||
stderr.includes('403') && stderr.includes('Forbidden')) {
stderr = '❌ **Invalid API Credentials (403 Forbidden)**\n\n' +
'**The Optimizely API is rejecting your credentials.**\n\n' +
'Common causes:\n' +
'• API credentials have expired (they expire after 365 days)\n' +
'• Credentials were regenerated in the portal by another user\n' +
'• The project ID doesn\'t match these credentials\n' +
'• Account associated with credentials is disabled\n\n' +
'**To fix this:**\n' +
'1. Log into https://paasportal.episerver.net\n' +
'2. Navigate to **API Access** → **Manage API Credentials**\n' +
'3. Generate new API Key and Secret\n' +
'4. Update your .mcp.json or environment variables:\n' +
' `CONTOSO="id=PROJECT_ID;key=NEW_KEY;secret=NEW_SECRET;..."`\n\n' +
'**Note:** API credentials expire after 365 days and must be regenerated.';
} else if (stderr.includes('Connect-EpiCloud : ') && stderr.includes('401')) {
stderr = '❌ Authentication failed!\n\n' +
'**Possible causes:**\n' +
'• Invalid API key or secret\n' +
'• API key doesn\'t have access to this project\n' +
'• Credentials have expired\n\n' +
'**To fix:**\n' +
'1. Verify your API credentials in Optimizely DXP portal\n' +
'2. Ensure the API key has the necessary permissions\n' +
'3. Try regenerating your API credentials\n\n' +
'Original error: ' + stderr;
} else if (stderr.includes('429') || stderr.includes('Too Many Requests') || stderr.includes('rate limit')) {
// Parse retry-after if available
const retryMatch = stderr.match(/retry[- ]?after[:\s]*(\d+)/i);
const retryAfter = retryMatch ? parseInt(retryMatch[1]) * 1000 : undefined;
limiter.recordRateLimit(projectId, { retryAfter });
const waitTime = retryAfter ? Math.ceil(retryAfter / 1000) : 60;
stderr = `⏱️ Rate limit exceeded!\n\n` +
`**Please wait ${waitTime} seconds before retrying.**\n\n` +
`The API has temporary limits to prevent overload.\n` +
`This is normal and will automatically resolve.\n\n` +
'Original error: ' + stderr;
} else {
// Record other failures for backoff calculation
limiter.recordFailure(projectId, error);
}
}
// Parse JSON if requested and possible
let parsedData = null;
if (parseJson && stdout) {
parsedData = this.parseJsonFromOutput(stdout);
}
const success = !stderr || (!stderr.includes('error') && !stderr.includes('Exception'));
// Check for rate limit indicators in response
if (stderr.includes('429') || stderr.includes('Too Many Requests') || stderr.includes('rate limit')) {
return {
stdout,
stderr,
parsedData,
success: false,
rateLimited: true
};
}
const result = {
stdout,
stderr,
parsedData,
success,
cachedAt: Date.now()
};
// Cache successful results
if (success && parsedData) {
cache.set(operation, options.cacheArgs || {}, projectId, result);
}
return result;
}
/**
* Execute PowerShell command with cache invalidation for write operations
* @param {string} command - The EpiCloud command to execute
* @param {Object} credentials - API credentials
* @param {Object} options - Execution options
* @returns {Promise<Object>} Result with stdout, stderr, and parsed data
*/
static async executeEpiCommandWithInvalidation(command, credentials, options = {}) {
const result = await this.executeEpiCommandDirectWithCredentials(command, credentials, options);
// If it's a write operation and successful, invalidate related cache
if (result.success && options.operation && this.isWriteOperation(options.operation)) {
const cache = this.getCacheManager();
const projectId = credentials.projectId || process.env.OPTIMIZELY_PROJECT_ID;
cache.invalidateRelated(options.operation, projectId);
}
return result;
}
/**
* Execute EpiCloud command directly without Connect-EpiCloud
* @param {string} command - The complete EpiCloud command with credentials
* @param {Object} options - Execution options
* @returns {Promise<Object>} Result with stdout, stderr, and parsed data
*/
static async executeEpiCommandDirect(command, options = {}) {
const { parseJson = false, timeout = 120000 } = options;
// Build the PowerShell script
const psScript = [
'Import-Module EpiCloud -Force',
parseJson ? `${command} | ConvertTo-Json -Depth 10 -Compress` : command
].join('; ');
let stdout = '';
let stderr = '';
try {
const psCommand = await this.getPowerShellCommand();
const result = await execAsync(`${psCommand} -Command "${psScript}"`, { timeout });
stdout = result.stdout;
stderr = result.stderr;
} catch (error) {
stdout = error.stdout || '';
stderr = error.stderr || error.message || '';
}
// Parse JSON if requested and possible
let parsedData = null;
if (parseJson && stdout) {
parsedData = this.parseJsonFromOutput(stdout);
}
const success = !stderr || (!stderr.includes('error') && !stderr.includes('Exception'));
return {
stdout,
stderr,
parsedData,
success
};
}
/**
* Execute EpiCloud command with credentials - bridges old and new patterns
* @param {string} command - The EpiCloud command WITHOUT credentials
* @param {Object} credentials - Credentials object with apiKey, apiSecret, projectId
* @param {Object} options - Execution options
* @returns {Promise<Object>} Result with stdout, stderr, and parsed data
*/
static async executeEpiCommandDirectWithCredentials(command, credentials, options = {}) {
// Validate credentials
const validation = SecurityHelper.validateCredentials(credentials);
if (!validation.valid) {
return {
stdout: '',
stderr: `Invalid credentials: ${validation.errors.join(', ')}`,
parsedData: null,
success: false
};
}
const { apiKey, apiSecret, projectId } = credentials;
// Add credentials to the command
// Parse the command to inject credentials
let enhancedCommand = command;
// Check if command already has credentials (avoid duplicates)
if (!command.includes('-ClientKey') && !command.includes('-ClientSecret')) {
// Find the cmdlet name (first word after any Get-, Set-, Start-, etc.)
const cmdletMatch = command.match(/^(\S+)/);
if (cmdletMatch) {
const cmdlet = cmdletMatch[1];
// Inject credentials after the cmdlet name
enhancedCommand = `${cmdlet} -ProjectId '${projectId}' -ClientKey '${apiKey}' -ClientSecret '${apiSecret}'`;
// Add the rest of the original command (skip the cmdlet part)
const restOfCommand = command.substring(cmdlet.length).trim();
if (restOfCommand) {
// Remove -ProjectId if it already exists in the rest
const cleanedRest = restOfCommand.replace(/-ProjectId\s+'[^']+'\s*/g, '').trim();
if (cleanedRest) {
enhancedCommand += ' ' + cleanedRest;
}
}
}
}
// Now call executeEpiCommandDirect with the enhanced command
return await this.executeEpiCommandDirect(enhancedCommand, options);
}
/**
* Check if operation is a write operation that should invalidate cache
* @param {string} operation - Operation name
* @returns {boolean} Whether operation is a write operation
*/
static isWriteOperation(operation) {
const writeOperations = new Set([
'start_deployment',
'complete_deployment',
'reset_deployment',
'upload_deployment_package',
'deploy_package_and_start',
'copy_content',
'export_database'
]);
return writeOperations.has(operation);
}
/**
* Get cache statistics
* @returns {Object} Cache statistics
*/
static getCacheStats() {
const cache = this.getCacheManager();
return cache.getStats();
}
/**
* Clear cache for a specific project or all projects
* @param {string} projectId - Optional project ID to clear specific project cache
*/
static clearCache(projectId) {
const cache = this.getCacheManager();
cache.clear(projectId);
}
/**
* Execute a raw PowerShell script (not EpiCloud specific)
* @param {string} script - The PowerShell script to execute
* @param {Object} options - Additional options {timeout, parseJson}
* @returns {Promise<Object>} Result with stdout, stderr, and parsed data
*/
static async executePowerShell(script, options = {}) {
const { timeout = 120000, parseJson = false } = options;
try {
// PowerShell -EncodedCommand expects UTF-16LE base64
const encodedScript = Buffer.from(script, 'utf16le').toString('base64');
const psCommand = await this.getPowerShellCommand();
const { stdout, stderr } = await execAsync(`${psCommand} -EncodedCommand ${encodedScript}`, {
timeout,
maxBuffer: 1024 * 1024 * 10 // 10MB buffer
});
let parsedData = null;
if (parseJson && stdout) {
parsedData = this.parseJsonFromOutput(stdout);
}
return {
stdout,
stderr,
parsedData,
success: !stderr || (!stderr.includes('error') && !stderr.includes('Exception'))
};
} catch (error) {
console.error('PowerShell execution error:', error.message);
return {
stdout: '',
stderr: error.message,
parsedData: null,
success: false
};
}
}
/**
* Parse JSON from mixed PowerShell output
* @param {string} output - Raw PowerShell output
* @returns {Object|null} Parsed JSON object or null
*/
static parseJsonFromOutput(output) {
if (!output || !output.trim()) return null;
try {
// First try direct parsing
return JSON.parse(output.trim());
} catch {
// Extract JSON from mixed output
let foundJson = false;
let jsonLines = [];
const lines = output.split('\n');
for (const line of lines) {
if (line.trim().startsWith('{') || line.trim().startsWith('[')) {
foundJson = true;
}
if (foundJson) {
jsonLines.push(line);
}
}
if (jsonLines.length > 0) {
try {
return JSON.parse(jsonLines.join('\n'));
} catch {
return null;
}
}
}
return null;
}
/**
* Check for common PowerShell errors
* @param {string} stderr - Standard error output
* @returns {Object} Error details {type, message, suggestion}
*/
static checkForErrors(stderr) {
if (!stderr) return null;
// Module not installed
if (stderr.includes('EpiCloud')) {
return {
type: 'MODULE_MISSING',
message: 'EpiCloud PowerShell Module not found',
suggestion: 'Install-Module EpiCloud -Force'
};
}
// Authentication failures
if (stderr.includes('authentication') || stderr.includes('unauthorized') ||
stderr.includes('403') || stderr.includes('401')) {
return {
type: 'AUTH_FAILED',
message: 'Authentication failed',
suggestion: 'Verify your API credentials and permissions'
};
}
// Ongoing operation
if (stderr.includes('on-going') || stderr.includes('already running')) {
return {
type: 'OPERATION_IN_PROGRESS',
message: 'Another operation is already in progress',
suggestion: 'Wait for the current operation to complete'
};
}
// Invalid state
if (stderr.includes('invalid state') || stderr.includes('cannot be')) {
return {
type: 'INVALID_STATE',
message: 'Operation not allowed in current state',
suggestion: 'Check the current status before retrying'
};
}
// Generic error
if (stderr.includes('error') || stderr.includes('Exception')) {
// Try to extract error message
const errorMatch = stderr.match(/"errors":\["(.+?)"/i);
if (errorMatch) {
return {
type: 'API_ERROR',
message: errorMatch[1],
suggestion: 'Check the error message for details'
};
}
return {
type: 'GENERIC_ERROR',
message: 'An error occurred',
suggestion: 'Check the logs for more details'
};
}
return null;
}
/**
* Format error for user display
* @param {Object} error - Error object from checkForErrors
* @param {Object} context - Additional context {operation, projectId, etc}
* @returns {string} Formatted error message
*/
static formatError(error, context = {}) {
let result = '❌ **Error: ' + error.message + '**\n\n';
if (error.type === 'MODULE_MISSING') {
result += '**Installation Required:**\n';
result += '```powershell\n';
result += error.suggestion + '\n';
result += '```\n';
} else if (error.type === 'AUTH_FAILED') {
result += '**Troubleshooting:**\n';
result += '- ' + error.suggestion + '\n';
if (context.projectId) {
result += `- Ensure credentials have access to project ${context.projectId}\n`;
}
} else {
result += '**Suggestion:** ' + error.suggestion + '\n';
}
if (context.operation) {
result += `\n**Operation:** ${context.operation}`;
}
if (context.projectId) {
result += `\n**Project ID:** ${context.projectId}`;
}
return result;
}
/**
* Execute an EpiCloud command with retry logic
* @param {string} command - The EpiCloud command to execute
* @param {Object} credentials - API credentials
* @param {Object} options - Execution options
* @param {Object} retryOptions - Retry configuration
* @returns {Promise<Object>} Result with stdout, stderr, and parsed data
*/
static async executeWithRetry(command, credentials, options = {}, retryOptions = {}) {
const context = {
operation: options.operation || 'PowerShell command',
projectId: credentials.projectId
};
// Create the execution function using the new direct method
const executeFn = async () => {
return await this.executeEpiCommandDirectWithCredentials(command, credentials, options);
};
// Execute with retry logic
return await RetryHelper.retryPowerShell(executeFn, context, retryOptions);
}
/**
* Execute PowerShell command with streaming support
* @param {string} command - The EpiCloud command to execute
* @param {Object} credentials - API credentials
* @param {Object} options - Execution options including onProgress callback
* @returns {Promise<Object>} Result with stdout, stderr, and parsed data
*/
static async executeEpiCommandStreaming(command, credentials, options = {}) {
// Use provided credentials or fall back to environment variables
const apiKey = credentials.apiKey || process.env.OPTIMIZELY_API_KEY;
const apiSecret = credentials.apiSecret || process.env.OPTIMIZELY_API_SECRET;
const projectId = credentials.projectId || process.env.OPTIMIZELY_PROJECT_ID;
const { timeout = 120000, parseJson = true, onProgress } = options;
// Validate credentials
const validation = SecurityHelper.validateCredentials({ apiKey, apiSecret, projectId });
if (!validation.valid) {
return {
stdout: '',
stderr: `Invalid credentials: ${validation.errors.join(', ')}`,
parsedData: null,
success: false
};
}
// Build the full PowerShell script
// WORKAROUND: EpiCloud authentication is returning false for all valid credentials as of Sept 2025
// We still call Connect-EpiCloud but don't check the result, as the commands may still work
const psScript = [
'Import-Module EpiCloud -Force',
`$connection = Connect-EpiCloud -ClientKey '${apiKey}' -ClientSecret '${apiSecret}' -ProjectId '${projectId}'`,
'# Workaround: Skipping authentication check due to EpiCloud bug',
'# Commands may still work even if AuthenticationVerified is false',
parseJson ? `${command} | ConvertTo-Json -Depth 10 -Compress` : command
].join('; ');
return new Promise(async (resolve, reject) => {
let stdout = '';
let stderr = '';
// Spawn PowerShell process
const psCommand = await this.getPowerShellCommand();
const ps = spawn(psCommand, ['-Command', psScript]);
// Set timeout
const timer = setTimeout(() => {
ps.kill();
reject(new Error(`Command timed out after ${timeout}ms`));
}, timeout);
// Handle stdout
ps.stdout.on('data', (data) => {
const chunk = data.toString();
stdout += chunk;
// Call progress callback if provided
if (onProgress) {
onProgress(chunk);
}
});
// Handle stderr
ps.stderr.on('data', (data) => {
const chunk = data.toString();
stderr += chunk;
// Also send verbose output to progress
if (onProgress && chunk.includes('VERBOSE:')) {
onProgress(chunk);
}
});
// Handle process exit
ps.on('close', (code) => {
clearTimeout(timer);
// Parse JSON if needed
let parsedData = null;
if (parseJson && stdout) {
parsedData = this.parseJsonFromOutput(stdout);
}
resolve({
stdout,
stderr,
parsedData,
success: !stderr || (!stderr.includes('error') && !stderr.includes('Exception'))
});
});
ps.on('error', (error) => {
clearTimeout(timer);
reject(error);
});
});
}
/**
* Execute PowerShell command without EpiCloud
* @param {string} command - Raw PowerShell command
* @param {Object} options - Execution options
* @returns {Promise<Object>} Result with stdout and stderr
*/
static async executeRawCommand(command, options = {}) {
const { timeout = 120000 } = options;
try {
const psCommand = await this.getPowerShellCommand();
const result = await execAsync(`${psCommand} -Command "${command}"`, { timeout });
return {
stdout: result.stdout,
stderr: result.stderr,
success: true
};
} catch (error) {
return {
stdout: error.stdout || '',
stderr: error.stderr || error.message,
success: false
};
}
}
}
module.exports = PowerShellHelper;