/** * Command executor for webhook events */ import { exec, execSync } from 'child_process'; import { promisify } from 'util'; import logger from './logger.js'; const execAsync = promisify(exec); // Detect shell path at module load time function detectShellPath() { try { // Try to find bash using 'which' command const bashPath = execSync('which bash', { encoding: 'utf8' }).trim(); if (bashPath) { return bashPath; } } catch (error) { // Fallback to common bash locations const commonPaths = ['/bin/bash', '/usr/bin/bash', '/usr/local/bin/bash']; for (const path of commonPaths) { try { execSync(`test -x ${path}`, { encoding: 'utf8' }); return path; } catch { continue; } } } // Ultimate fallback: use 'bash' without path (let PATH resolve it) return 'bash'; } const SHELL_PATH = detectShellPath(); logger.info('Detected shell path', { shell: SHELL_PATH }); export class CommandExecutor { /** * Extract variables from webhook payload */ extractVariables(eventType, payload, headers) { const variables = { event: eventType, repo: payload.repository?.name || '', full_repo: payload.repository?.full_name || payload.repository?.name || '', repo_owner: '', branch: '', pusher: '', commit: '', commit_msg: '', pr_number: '', pr_action: '', tag: '', ref_type: '', issue_number: '', issue_title: '', issue_action: '', issue_body: '', issue_assignee: '', issue_labels: '', comment_body: '' }; // Extract repository owner if (payload.repository?.full_name) { const parts = payload.repository.full_name.split('/'); variables.repo_owner = parts[0] || ''; } else if (payload.repository?.owner) { variables.repo_owner = payload.repository.owner.username || payload.repository.owner.login || payload.repository.owner.name || ''; } // Extract pusher/sender information variables.pusher = payload.pusher?.username || payload.pusher?.name || payload.sender?.username || payload.sender?.login || ''; // Extract branch from ref (e.g., "refs/heads/main" -> "main") if (payload.ref) { const refMatch = payload.ref.match(/^refs\/(heads|tags)\/(.+)$/); if (refMatch) { variables.branch = refMatch[2]; if (refMatch[1] === 'tags') { variables.tag = refMatch[2]; } } } // Event-specific extractions switch (eventType) { case 'push': if (payload.commits && payload.commits.length > 0) { const lastCommit = payload.commits[payload.commits.length - 1]; variables.commit = lastCommit.id || ''; variables.commit_msg = lastCommit.message || ''; } variables.commit = variables.commit.substring(0, 7); // Short hash break; case 'pull_request': variables.pr_number = payload.pull_request?.number || payload.number || ''; variables.pr_action = payload.action || ''; variables.branch = payload.pull_request?.head?.ref || payload.pull_request?.base?.ref || ''; break; case 'create': case 'delete': variables.ref_type = payload.ref_type || ''; variables.branch = payload.ref || ''; if (payload.ref_type === 'tag') { variables.tag = payload.ref || ''; } break; case 'release': variables.tag = payload.release?.tag_name || ''; variables.pr_action = payload.action || ''; break; case 'issues': variables.issue_number = payload.issue?.number || payload.number || ''; variables.issue_title = payload.issue?.title || ''; variables.issue_action = payload.action || ''; variables.issue_body = payload.issue?.body || ''; variables.issue_assignee = payload.issue?.assignee?.username || payload.issue?.assignee?.login || ''; // Extract issue labels as comma-separated string if (payload.issue?.labels && Array.isArray(payload.issue.labels)) { variables.issue_labels = payload.issue.labels.map(label => label.name).join(','); } break; case 'issue_comment': variables.issue_number = payload.issue?.number || ''; variables.issue_title = payload.issue?.title || ''; variables.issue_action = payload.action || ''; variables.comment_body = payload.comment?.body || ''; variables.issue_assignee = payload.issue?.assignee?.username || payload.issue?.assignee?.login || ''; // Extract issue labels as comma-separated string if (payload.issue?.labels && Array.isArray(payload.issue.labels)) { variables.issue_labels = payload.issue.labels.map(label => label.name).join(','); } break; } // Log extracted variables at debug level logger.variablesExtracted(eventType, variables); return variables; } /** * Substitute variables in command string */ substituteVariables(command, variables) { let result = command; for (const [key, value] of Object.entries(variables)) { const regex = new RegExp(`{{${key}}}`, 'g'); result = result.replace(regex, value || ''); } return result; } /** * Build command string from command and args */ buildCommandString(command, args = []) { // Escape arguments for shell execution const escapeArg = (arg) => { // Always wrap empty strings in quotes to preserve them as arguments if (arg === '') { return "''"; } // If arg contains spaces or special chars, wrap in single quotes if (arg.includes(' ') || arg.includes('$') || arg.includes('`') || arg.includes('"')) { return `'${arg.replace(/'/g, "'\\''")}'`; } return arg; }; const escapedArgs = args.map(escapeArg); return [command, ...escapedArgs].join(' '); } /** * Execute a command with timeout */ async executeCommand(command, options = {}) { const { timeout = 300000, cwd = process.cwd(), type = 'shell', args = [] } = options; let fullCommand; if (type === 'node') { // For Node.js execution, use 'node' as the shell command fullCommand = this.buildCommandString('node', [command, ...args]); } else { // For shell execution, build command from command and args fullCommand = this.buildCommandString(command, args); } logger.info('Executing command', { type, command, args, fullCommand, cwd, timeout }); try { // Use default shell if SHELL_PATH is just 'bash' (more compatible with restricted environments) const execOptions = { timeout, cwd: cwd || process.cwd(), maxBuffer: 10 * 1024 * 1024 // 10MB }; // Only set custom shell if we have a full path if (SHELL_PATH.startsWith('/')) { execOptions.shell = SHELL_PATH; } else { // Let Node.js use default shell resolution execOptions.shell = true; } const { stdout, stderr } = await execAsync(fullCommand, execOptions); if (stdout) { logger.info('Command output (stdout)'); console.log(stdout); } if (stderr) { logger.info('Command output (stderr)'); console.error(stderr); } logger.info('Command completed successfully'); return { success: true, stdout, stderr }; } catch (error) { logger.error('Command execution failed', error); if (error.stdout) { logger.info('Command output (stdout) before error'); console.log(error.stdout); } if (error.stderr) { logger.info('Command output (stderr)'); console.error(error.stderr); } return { success: false, error: error.message, stdout: error.stdout, stderr: error.stderr, code: error.code }; } } /** * Execute configured command for webhook event */ async executeWebhookCommand(eventType, payload, headers, config) { // Support both old format (single command string) and new format (command + args) const isNewFormat = config.command && config.args !== undefined; if (!config.command) { return null; } // Extract variables from payload const variables = this.extractVariables(eventType, payload, headers); // Apply branch filter if configured if (config.filterBranch && variables.branch !== config.filterBranch) { const commandName = config.name || config.command || 'unknown'; logger.branchFiltered(variables.branch, config.filterBranch, commandName); logger.info(`Skipping command execution - branch '${variables.branch}' does not match filter '${config.filterBranch}'`); return null; } // Apply action filter if configured if (config.filterActions && Array.isArray(config.filterActions) && config.filterActions.length > 0) { const action = variables.issue_action || variables.pr_action || payload.action || ''; if (!config.filterActions.includes(action)) { const commandName = config.name || config.command || 'unknown'; logger.info(`Skipping command '${commandName}' - action '${action}' not in filterActions [${config.filterActions.join(', ')}]`); return null; } } // Apply assignee filter if configured if (config.filterAssignee) { const assignee = variables.issue_assignee || ''; if (assignee !== config.filterAssignee) { const commandName = config.name || config.command || 'unknown'; logger.info(`Skipping command '${commandName}' - assignee '${assignee}' does not match filter '${config.filterAssignee}'`); return null; } } // Skip execution if issue is closed (for issue and issue_comment events) if (eventType === 'issues' || eventType === 'issue_comment') { const issueState = payload.issue?.state || ''; if (issueState === 'closed') { const commandName = config.name || config.command || 'unknown'; logger.info(`Skipping command '${commandName}' - issue state is 'closed'`); return null; } } let command, args, type; if (isNewFormat) { // New format: separate command and args command = this.substituteVariables(config.command, variables); args = (config.args || []).map(arg => this.substituteVariables(arg, variables)); type = config.type || 'shell'; } else { // Old format: single command string (for backward compatibility) const fullCommand = this.substituteVariables(config.command, variables); // Parse command string into command and args const parts = fullCommand.split(' '); command = parts[0]; args = parts.slice(1); type = 'shell'; } logger.info('Webhook command prepared', { eventType, type, command, args, variables }); // Execute command const result = await this.executeCommand(command, { type, args, timeout: config.timeout || 300000, cwd: config.cwd || config.workingDir || process.cwd() }); return result; } } export default new CommandExecutor();