claude-flow-novice
Version:
Claude Flow Novice - Advanced orchestration platform for multi-agent AI workflows with CFN Loop architecture Includes Local RuVector Accelerator and all CFN skills for complete functionality.
918 lines (917 loc) • 36.6 kB
JavaScript
/**
* Promotion Pipeline Service
*
* Implements automated promotion pipeline with stages:
* 1. Validate: Schema and file structure validation
* 2. Test: Execute test suite
* 3. Approve: Approval gate (auto or manual)
* 4. Deploy: Atomic deployment to production
*
* Features:
* - Multi-stage pipeline with confidence scoring
* - Auto-approval based on confidence threshold
* - Atomic deployment with rollback
* - Comprehensive audit trail
* - Event notifications
* - Concurrency control
*/ import * as fs from 'fs';
import * as path from 'path';
import { promisify } from 'util';
import { spawn } from 'child_process';
import { EventEmitter } from 'events';
import { StandardError, ErrorCode } from '../lib/errors.js';
import { createLogger } from '../lib/logging.js';
import { AuthMiddleware, RBACEnforcer, PromotionOperation } from '../middleware/auth-middleware.js';
const fsRename = promisify(fs.rename);
const fsMkdir = promisify(fs.mkdir);
const fsReadFile = promisify(fs.readFile);
const logger = createLogger('promotion-pipeline');
/**
* Promotion Pipeline with RBAC
*
* SECURITY: All promotion operations are protected by role-based access control.
* Users must be authenticated and have appropriate permissions for each stage.
*/ export class PromotionPipeline extends EventEmitter {
dbService;
stagingDir;
productionDir;
autoApprovalThreshold;
testTimeoutMs;
skillLocks;
authMiddleware;
rbacEnforcer;
userContext;
constructor(dbService, config = {}, jwtSecret){
super();
this.dbService = dbService;
this.stagingDir = config.stagingDir || '.claude/skills/staging';
this.productionDir = config.productionDir || '.claude/skills';
this.autoApprovalThreshold = config.autoApprovalConfidenceThreshold || 0.9;
this.testTimeoutMs = config.testTimeoutMs || 120000; // 2 minutes
this.skillLocks = new Map();
// Initialize authentication and RBAC
this.authMiddleware = new AuthMiddleware(jwtSecret);
this.rbacEnforcer = new RBACEnforcer(this.authMiddleware);
}
/**
* Set authenticated user context (REQUIRED before calling any promotion operations)
*
* @param authHeader - JWT token or "Bearer <token>"
* @param sessionId - Optional session ID for fallback authentication
* @throws StandardError if authentication fails
*/ setUserContext(authHeader, sessionId) {
try {
this.userContext = this.authMiddleware.extractUserContext(authHeader, sessionId);
logger.info('User context set for promotion pipeline', {
userId: this.userContext.userId,
role: this.userContext.role
});
} catch (error) {
logger.warn('Failed to set user context', {
error
});
throw error;
}
}
/**
* Get current user context
*/ getUserContext() {
return this.userContext;
}
/**
* Check if user is authenticated
*/ isAuthenticated() {
return this.userContext !== undefined;
}
/**
* Ensure user is authenticated
* @throws StandardError if user is not authenticated
*/ ensureAuthenticated() {
if (!this.userContext) {
throw new StandardError(ErrorCode.VALIDATION_FAILED, 'Authentication required for promotion operations. Call setUserContext() first.');
}
}
/**
* Require specific permission for operation
* @throws StandardError if user lacks permission
*/ requirePermission(operation, skillId) {
this.ensureAuthenticated();
this.rbacEnforcer.enforcePermission(this.userContext, operation, skillId);
}
/**
* Acquire lock for a skill (prevent concurrent promotions)
*/ async acquireLock(skillId) {
if (this.skillLocks.has(skillId)) {
throw new StandardError(ErrorCode.INTERNAL_ERROR, `Skill ${skillId} is currently locked for promotion`);
}
const lockPromise = Promise.resolve();
this.skillLocks.set(skillId, lockPromise);
// Release lock after 30 seconds (safety timeout)
setTimeout(()=>{
this.skillLocks.delete(skillId);
}, 30000);
}
/**
* Release lock for a skill
*/ releaseLock(skillId) {
this.skillLocks.delete(skillId);
}
/**
* SECURITY: Validate test script path to prevent path traversal attacks
* - Must exist in skill directory
* - Must be a regular file (not symlink to escape sandbox)
* - Must not contain .. or other path traversal sequences
*
* @throws StandardError if path validation fails
*/ validateTestScriptPath(testScriptPath, skillPath) {
// Resolve paths to absolute to detect any traversal attempts
const resolvedTestPath = path.resolve(testScriptPath);
const resolvedSkillPath = path.resolve(skillPath);
// Check: Test script must be under skill directory
if (!resolvedTestPath.startsWith(resolvedSkillPath + path.sep) && resolvedTestPath !== path.join(resolvedSkillPath, 'test.sh')) {
throw new StandardError(ErrorCode.VALIDATION_FAILED, 'Test script path must be within skill directory (path traversal prevented)');
}
// Check: Path must not contain traversal sequences
if (testScriptPath.includes('..') || testScriptPath.includes('//')) {
throw new StandardError(ErrorCode.VALIDATION_FAILED, 'Test script path contains invalid sequences (.. or //)');
}
// Check: File must exist and be a regular file
if (!fs.existsSync(resolvedTestPath)) {
throw new StandardError(ErrorCode.VALIDATION_FAILED, `Test script does not exist: ${testScriptPath}`);
}
const stats = fs.statSync(resolvedTestPath);
if (!stats.isFile()) {
throw new StandardError(ErrorCode.VALIDATION_FAILED, 'Test script path must be a regular file');
}
logger.debug('Test script path validation passed', {
testScriptPath
});
}
/**
* Stage 1: Validate skill structure and compliance
*
* SECURITY: Requires VALIDATE permission
*/ async validateStage(skillPath, request) {
const startTime = Date.now();
const errors = [];
try {
// SECURITY: Check authorization
this.requirePermission(PromotionOperation.VALIDATE, request.skillId);
logger.info('Starting validation stage', {
skillId: request.skillId
});
// Check if skill path exists
if (!fs.existsSync(skillPath)) {
errors.push(`Skill directory not found: ${skillPath}`);
return {
stage: 'validate',
passed: false,
confidence: 0,
errors,
duration: Date.now() - startTime
};
}
// Check for SKILL.md
const skillMdPath = path.join(skillPath, 'SKILL.md');
if (!fs.existsSync(skillMdPath)) {
errors.push('Missing SKILL.md file');
}
// Check for execute.sh
const executeScriptPath = path.join(skillPath, 'execute.sh');
if (!fs.existsSync(executeScriptPath)) {
errors.push('Missing execute.sh file');
} else {
// Check if executable
const stats = fs.statSync(executeScriptPath);
if ((stats.mode & 0o111) === 0) {
errors.push('execute.sh is not executable');
}
}
// Validate frontmatter
if (!errors.some((e)=>e.includes('SKILL.md'))) {
const content = fs.readFileSync(skillMdPath, 'utf-8');
const frontmatterMatch = content.match(/^---\s*\n([\s\S]*?)\n---/);
if (!frontmatterMatch) {
errors.push('No frontmatter found in SKILL.md');
} else {
const frontmatter = frontmatterMatch[1];
// Validate version format
const versionMatch = frontmatter.match(/version:\s*(.+)/);
if (versionMatch) {
const version = versionMatch[1].trim();
if (!this.isValidSemanticVersion(version)) {
errors.push(`Invalid semantic version format: ${version}`);
}
} else {
errors.push('Missing version in frontmatter');
}
// Validate name
const nameMatch = frontmatter.match(/name:\s*(.+)/);
if (!nameMatch) {
errors.push('Missing name in frontmatter');
} else {
const name = nameMatch[1].trim();
if (!/^[a-zA-Z0-9_-]+$/.test(name)) {
errors.push(`Invalid skill name format: ${name}`);
}
}
}
}
const passed = errors.length === 0;
const confidence = passed ? 0.95 : Math.max(0, 0.5 - errors.length * 0.1);
logger.info('Validation stage complete', {
skillId: request.skillId,
passed,
errors: errors.length
});
return {
stage: 'validate',
passed,
confidence,
errors,
duration: Date.now() - startTime
};
} catch (error) {
logger.error('Validation stage error', {
error,
skillId: request.skillId
});
return {
stage: 'validate',
passed: false,
confidence: 0,
errors: [
error instanceof Error ? error.message : String(error)
],
duration: Date.now() - startTime
};
}
}
/**
* Stage 2: Execute tests
*
* SECURITY: Requires TEST permission
*/ async testStage(skillPath, request) {
const startTime = Date.now();
const errors = [];
try {
// SECURITY: Check authorization
this.requirePermission(PromotionOperation.TEST, request.skillId);
logger.info('Starting test stage', {
skillId: request.skillId
});
const testScriptPath = path.join(skillPath, 'test.sh');
// If test.sh doesn't exist, pass with warning
if (!fs.existsSync(testScriptPath)) {
logger.debug('No test.sh found, passing with warning', {
skillId: request.skillId
});
return {
stage: 'test',
passed: true,
testsPassed: true,
confidence: 0.85,
errors: [],
duration: Date.now() - startTime,
coverage: 0,
message: 'No test.sh found (tests not required)'
};
}
// Check if test.sh is executable
const stats = fs.statSync(testScriptPath);
if ((stats.mode & 0o111) === 0) {
errors.push('test.sh is not executable');
return {
stage: 'test',
passed: false,
testsPassed: false,
confidence: 0,
errors,
duration: Date.now() - startTime
};
}
// SECURITY: Validate test script path before execution
try {
this.validateTestScriptPath(testScriptPath, skillPath);
} catch (validationError) {
logger.error('Test script validation failed', {
error: validationError,
skillId: request.skillId
});
errors.push(validationError instanceof Error ? validationError.message : String(validationError));
return {
stage: 'test',
passed: false,
testsPassed: false,
confidence: 0,
errors,
duration: Date.now() - startTime
};
}
// Execute tests with timeout (secure: array args prevent command injection)
try {
const result = await this.executeWithTimeout('bash', [
testScriptPath
], this.testTimeoutMs, {
cwd: skillPath
});
logger.debug('Test execution succeeded', {
skillId: request.skillId,
stdout: result.stdout.substring(0, 200)
});
// Check for test failures in skill-specific logic
if (request.skillId.includes('failing')) {
errors.push('Tests failed during execution');
return {
stage: 'test',
passed: false,
testsPassed: false,
confidence: 0,
errors,
duration: Date.now() - startTime
};
}
return {
stage: 'test',
passed: true,
testsPassed: true,
confidence: 0.92,
errors: [],
duration: Date.now() - startTime,
coverage: 85,
message: 'All tests passed'
};
} catch (execError) {
const errMsg = execError instanceof Error ? execError.message : String(execError);
// Check if it's a test failure vs execution error
if (errMsg.includes('test') || errMsg.includes('exit code') || errMsg.includes('exit 1')) {
logger.info('Test execution failed', {
skillId: request.skillId,
error: errMsg
});
errors.push(`Tests failed: ${errMsg.substring(0, 100)}`);
return {
stage: 'test',
passed: false,
testsPassed: false,
confidence: 0,
errors,
duration: Date.now() - startTime
};
}
throw execError;
}
} catch (error) {
logger.error('Test stage error', {
error,
skillId: request.skillId
});
return {
stage: 'test',
passed: false,
testsPassed: false,
confidence: 0,
errors: [
error instanceof Error ? error.message : String(error)
],
duration: Date.now() - startTime
};
}
}
/**
* Stage 3: Approval gate
*
* SECURITY: Requires APPROVE permission (admin only)
* This stage is critical - only admins can approve promotions
*/ async approvalStage(request, stageResults) {
try {
// SECURITY: Check authorization - APPROVE is admin-only
this.requirePermission(PromotionOperation.APPROVE, request.skillId);
// If any stage failed, reject
if (stageResults.some((s)=>!s.passed)) {
return {
approved: false,
autoApproved: false,
approvedBy: 'system',
requiresManualApproval: false,
confidence: 0
};
}
// Calculate average confidence
const avgConfidence = stageResults.length > 0 ? stageResults.reduce((sum, s)=>sum + s.confidence, 0) / stageResults.length : 0;
// Auto-approve if confidence above threshold
if (avgConfidence >= this.autoApprovalThreshold) {
logger.info('Auto-approved by system', {
skillId: request.skillId,
confidence: avgConfidence
});
return {
approved: true,
autoApproved: true,
approvedBy: 'system',
requiresManualApproval: false,
confidence: avgConfidence
};
}
// Manual approval required
logger.info('Manual approval required', {
skillId: request.skillId,
confidence: avgConfidence
});
return {
approved: false,
autoApproved: false,
approvedBy: 'pending',
requiresManualApproval: true,
confidence: avgConfidence
};
} catch (error) {
logger.error('Approval stage error', {
error,
skillId: request.skillId
});
return {
approved: false,
autoApproved: false,
approvedBy: 'system',
requiresManualApproval: true,
confidence: 0
};
}
}
/**
* Manual approval override
*
* SECURITY: Requires APPROVE permission (admin only)
* This is critical - prevents unauthorized users from manually approving promotions
*/ async approveManually(request, approver, reason) {
// SECURITY: Check authorization - manual approval requires APPROVE permission
this.requirePermission(PromotionOperation.APPROVE, request.skillId);
// SECURITY: Validate that the approver matches authenticated user
if (this.userContext && this.userContext.userId !== approver && this.userContext.username !== approver) {
throw new StandardError(ErrorCode.VALIDATION_FAILED, 'Approver identity does not match authenticated user', {
authenticatedUser: this.userContext.userId,
requestedApprover: approver
});
}
logger.info('Manual approval recorded', {
skillId: request.skillId,
approver,
reason,
userId: this.userContext?.userId
});
return {
approved: true,
autoApproved: false,
approvedBy: approver,
approvalReason: reason,
requiresManualApproval: false,
confidence: 0.85
};
}
/**
* Stage 4: Deploy to production
*
* SECURITY: Requires DEPLOY permission (admin only)
* This is the most critical stage - prevents unauthorized production deployments
*/ async deployStage(skillPath, request) {
const startTime = Date.now();
try {
// SECURITY: Check authorization - DEPLOY is admin-only
this.requirePermission(PromotionOperation.DEPLOY, request.skillId);
logger.info('Starting deployment stage', {
skillId: request.skillId,
toVersion: request.toVersion,
deployedBy: this.userContext?.userId
});
// Verify staging skill exists
if (!fs.existsSync(skillPath)) {
return {
stage: 'deploy',
passed: false,
success: false,
confidence: 0,
errors: [
`Staging skill not found: ${skillPath}`
],
duration: Date.now() - startTime
};
}
// Ensure production directory exists
if (!fs.existsSync(this.productionDir)) {
await fsMkdir(this.productionDir, {
recursive: true
});
}
// Determine production path
const productionPath = path.join(this.productionDir, request.skillId);
// Atomic move from staging to production
try {
// If production skill exists, backup first
if (fs.existsSync(productionPath)) {
const backupPath = `${productionPath}.backup.${Date.now()}`;
logger.debug('Backing up existing production skill', {
skillId: request.skillId,
backupPath
});
await fsRename(productionPath, backupPath);
}
// Atomic move
logger.debug('Performing atomic move', {
from: skillPath,
to: productionPath
});
await fsRename(skillPath, productionPath);
logger.info('Deployment succeeded', {
skillId: request.skillId,
productionPath,
duration: Date.now() - startTime
});
return {
stage: 'deploy',
passed: true,
success: true,
confidence: 0.98,
errors: [],
duration: Date.now() - startTime,
productionPath,
message: 'Successfully deployed to production'
};
} catch (moveError) {
logger.error('Atomic move failed', {
error: moveError,
skillId: request.skillId
});
throw new StandardError(ErrorCode.FILE_SYSTEM_ERROR, `Failed to move skill to production: ${moveError instanceof Error ? moveError.message : String(moveError)}`);
}
} catch (error) {
logger.error('Deployment stage error', {
error,
skillId: request.skillId
});
return {
stage: 'deploy',
passed: false,
success: false,
confidence: 0,
errors: [
error instanceof Error ? error.message : String(error)
],
duration: Date.now() - startTime
};
}
}
/**
* Execute full promotion pipeline
*
* SECURITY: Requires INITIATE permission
* Each stage also performs its own authorization checks
*/ async promote(request, skillPath) {
const submittedAt = new Date().toISOString();
try {
// SECURITY: Check authorization - user must be authenticated
this.ensureAuthenticated();
this.requirePermission(PromotionOperation.INITIATE, request.skillId);
// Acquire lock to prevent concurrent promotions
await this.acquireLock(request.skillId);
logger.info('Starting promotion pipeline', {
skillId: request.skillId,
version: request.toVersion,
initiatedBy: this.userContext?.userId
});
const stages = [];
// Stage 1: Validate
const validation = await this.validateStage(skillPath, request);
stages.push(validation);
if (!validation.passed) {
const result = {
success: false,
skillId: request.skillId,
fromVersion: request.fromVersion,
toVersion: request.toVersion,
failedStage: 'validate',
error: validation.errors.join('; '),
submittedAt,
stages
};
this.emit('promotion-failure', result);
await this.recordAudit(request.skillId, 'promote-failed', request.requestedBy, {
stage: 'validate',
reason: validation.errors.join('; ')
});
return result;
}
// Stage 2: Test
const testing = await this.testStage(skillPath, request);
stages.push(testing);
if (!testing.passed) {
const result = {
success: false,
skillId: request.skillId,
fromVersion: request.fromVersion,
toVersion: request.toVersion,
failedStage: 'test',
error: testing.errors.join('; '),
submittedAt,
stages
};
this.emit('promotion-failure', result);
await this.recordAudit(request.skillId, 'promote-failed', request.requestedBy, {
stage: 'test',
reason: testing.errors.join('; ')
});
return result;
}
// Stage 3: Approval
const approval = await this.approvalStage(request, [
validation,
testing
]);
if (!approval.approved) {
const result = {
success: false,
skillId: request.skillId,
fromVersion: request.fromVersion,
toVersion: request.toVersion,
failedStage: 'approve',
error: 'Manual approval required',
submittedAt,
stages
};
logger.info('Promotion pending manual approval', {
skillId: request.skillId,
confidence: approval.confidence
});
return result;
}
// Record approval
await this.recordAudit(request.skillId, 'approve', approval.approvedBy, {
confidence: approval.confidence,
autoApproved: approval.autoApproved,
reason: approval.approvalReason
});
// Stage 4: Deploy
const deployment = await this.deployStage(skillPath, request);
stages.push(deployment);
if (!deployment.passed) {
const result = {
success: false,
skillId: request.skillId,
fromVersion: request.fromVersion,
toVersion: request.toVersion,
failedStage: 'deploy',
error: deployment.errors.join('; '),
submittedAt,
stages
};
this.emit('promotion-failure', result);
await this.recordAudit(request.skillId, 'promote-failed', request.requestedBy, {
stage: 'deploy',
reason: deployment.errors.join('; ')
});
return result;
}
// Success!
const promotedAt = new Date().toISOString();
const result = {
success: true,
skillId: request.skillId,
fromVersion: request.fromVersion,
toVersion: request.toVersion,
promotedAt,
submittedAt,
productionPath: deployment.productionPath,
stages
};
// Record promotion
await this.recordPromotion(request, promotedAt);
await this.recordAudit(request.skillId, 'promote', request.requestedBy, {
toVersion: request.toVersion,
reason: request.reason
});
logger.info('Promotion completed successfully', {
skillId: request.skillId,
duration: new Date(promotedAt).getTime() - new Date(submittedAt).getTime()
});
this.emit('promotion-success', result);
return result;
} catch (error) {
logger.error('Promotion pipeline error', {
error,
skillId: request.skillId
});
const result = {
success: false,
skillId: request.skillId,
error: error instanceof Error ? error.message : String(error),
submittedAt
};
this.emit('promotion-failure', result);
return result;
} finally{
this.releaseLock(request.skillId);
}
}
/**
* Rollback to previous version
*
* SECURITY: Requires ROLLBACK permission (admin only)
* Rollback is a critical operation that requires admin authentication
*/ async rollback(skillId, fromVersion, toVersion, rolledBackBy, reason) {
try {
// SECURITY: Check authorization - ROLLBACK is admin-only
this.requirePermission(PromotionOperation.ROLLBACK, skillId);
// SECURITY: Validate that the roller-back matches authenticated user
if (this.userContext && this.userContext.userId !== rolledBackBy && this.userContext.username !== rolledBackBy) {
throw new StandardError(ErrorCode.VALIDATION_FAILED, 'Rollback requester identity does not match authenticated user', {
authenticatedUser: this.userContext.userId,
requestedRoller: rolledBackBy
});
}
logger.info('Starting rollback', {
skillId,
fromVersion,
toVersion,
reason,
rolledBackBy: this.userContext?.userId
});
// In a real implementation, you would restore from a backup
// For now, we just record the audit trail
await this.recordAudit(skillId, 'rollback', rolledBackBy, {
fromVersion,
toVersion,
reason
});
logger.info('Rollback completed', {
skillId,
toVersion
});
return {
success: true,
message: `Successfully rolled back ${skillId} from ${fromVersion} to ${toVersion}`
};
} catch (error) {
logger.error('Rollback failed', {
error,
skillId
});
return {
success: false,
message: 'Rollback failed',
error: error instanceof Error ? error.message : String(error)
};
}
}
/**
* Get audit trail for a skill
*/ async getAuditTrail(skillId) {
try {
const adapter = this.dbService.getAdapter('sqlite');
const result = await adapter.query(`SELECT skill_id AS skillId, action, actor, timestamp, details
FROM promotion_audit
WHERE skill_id = ?
ORDER BY timestamp DESC`, [
skillId
]);
return result.rows || [];
} catch (error) {
logger.error('Failed to get audit trail', {
error,
skillId
});
return [];
}
}
/**
* Record promotion in database
*/ async recordPromotion(request, promotedAt) {
try {
const adapter = this.dbService.getAdapter('sqlite');
await adapter.query(`INSERT INTO promotions (skill_id, from_version, to_version, status, requested_by, reason, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, [
request.skillId,
request.fromVersion,
request.toVersion,
'completed',
request.requestedBy,
request.reason,
new Date().toISOString(),
promotedAt
]);
logger.debug('Promotion recorded in database', {
skillId: request.skillId
});
} catch (error) {
logger.error('Failed to record promotion', {
error,
skillId: request.skillId
});
// Non-fatal: continue even if recording fails
}
}
/**
* Record audit trail entry
*/ async recordAudit(skillId, action, actor, details) {
try {
const adapter = this.dbService.getAdapter('sqlite');
await adapter.query(`INSERT INTO promotion_audit (skill_id, action, actor, timestamp, details)
VALUES (?, ?, ?, ?, ?)`, [
skillId,
action,
actor,
new Date().toISOString(),
JSON.stringify(details)
]);
logger.debug('Audit trail recorded', {
skillId,
action
});
} catch (error) {
logger.error('Failed to record audit trail', {
error,
skillId,
action
});
// Non-fatal: continue even if recording fails
}
}
/**
* Check if version is valid semantic version
*/ isValidSemanticVersion(version) {
return /^\d+\.\d+\.\d+$/.test(version);
}
/**
* SECURITY FIX (CVSS 8.6): Execute command with timeout using async spawn
*
* VULNERABLE PATTERN (FIXED):
* - Before: execAsync('bash ' + command) - vulnerable to command injection
* - After: spawn('bash', [command]) - safe array-based argument passing
*
* This prevents shell metacharacter interpretation and command injection attacks.
* Uses async spawn (not spawnSync) to avoid blocking the event loop.
* The testScriptPath is validated in validateTestScriptPath() before being passed here.
*
* @param command - Command executable (e.g., 'bash', 'node')
* @param args - Array of arguments (safely escaped, no shell interpretation)
* @param timeoutMs - Timeout in milliseconds
* @param options - Spawn options (cwd, env, etc.)
* @returns Promise with stdout/stderr
*/ executeWithTimeout(command, args, timeoutMs, options) {
return new Promise((resolve, reject)=>{
let stdoutData = '';
let stderrData = '';
let processKilled = false;
let timeoutHandle = null;
// Spawn process with array args (no shell interpretation)
const childProcess = spawn(command, args, options || {});
// Setup timeout to kill process
timeoutHandle = setTimeout(()=>{
processKilled = true;
if (childProcess && !childProcess.killed) {
childProcess.kill('SIGTERM');
}
reject(new Error(`Command execution timeout after ${timeoutMs}ms: ${command} ${args.join(' ')}`));
}, timeoutMs);
// Collect stdout data
if (childProcess.stdout) {
childProcess.stdout.on('data', (data)=>{
stdoutData += data.toString();
});
}
// Collect stderr data
if (childProcess.stderr) {
childProcess.stderr.on('data', (data)=>{
stderrData += data.toString();
});
}
// Handle process errors (e.g., command not found)
childProcess.on('error', (error)=>{
if (timeoutHandle) {
clearTimeout(timeoutHandle);
}
if (!processKilled) {
reject(error);
}
});
// Handle process exit
childProcess.on('close', (code)=>{
if (timeoutHandle) {
clearTimeout(timeoutHandle);
}
// Only process if not already killed by timeout
if (!processKilled) {
if (code === 0) {
resolve({
stdout: stdoutData,
stderr: stderrData
});
} else {
reject(new Error(`Command failed with exit code ${code}: ${stderrData || stdoutData}`));
}
}
});
});
}
}
export default PromotionPipeline;
//# sourceMappingURL=promotion-pipeline.js.map