commandExecutor.js 9.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313
  1. /**
  2. * Command executor for webhook events
  3. */
  4. import { exec, execSync } from 'child_process';
  5. import { promisify } from 'util';
  6. import logger from './logger.js';
  7. const execAsync = promisify(exec);
  8. // Detect shell path at module load time
  9. function detectShellPath() {
  10. try {
  11. // Try to find bash using 'which' command
  12. const bashPath = execSync('which bash', { encoding: 'utf8' }).trim();
  13. if (bashPath) {
  14. return bashPath;
  15. }
  16. } catch (error) {
  17. // Fallback to common bash locations
  18. const commonPaths = ['/bin/bash', '/usr/bin/bash', '/usr/local/bin/bash'];
  19. for (const path of commonPaths) {
  20. try {
  21. execSync(`test -x ${path}`, { encoding: 'utf8' });
  22. return path;
  23. } catch {
  24. continue;
  25. }
  26. }
  27. }
  28. // Ultimate fallback: use 'bash' without path (let PATH resolve it)
  29. return 'bash';
  30. }
  31. const SHELL_PATH = detectShellPath();
  32. logger.info('Detected shell path', { shell: SHELL_PATH });
  33. export class CommandExecutor {
  34. /**
  35. * Extract variables from webhook payload
  36. */
  37. extractVariables(eventType, payload, headers) {
  38. const variables = {
  39. event: eventType,
  40. repo: payload.repository?.name || '',
  41. full_repo: payload.repository?.full_name || payload.repository?.name || '',
  42. repo_owner: '',
  43. branch: '',
  44. pusher: '',
  45. commit: '',
  46. commit_msg: '',
  47. pr_number: '',
  48. pr_action: '',
  49. tag: '',
  50. ref_type: '',
  51. issue_number: '',
  52. issue_title: '',
  53. issue_action: '',
  54. issue_body: '',
  55. issue_assignee: '',
  56. comment_body: ''
  57. };
  58. // Extract repository owner
  59. if (payload.repository?.full_name) {
  60. const parts = payload.repository.full_name.split('/');
  61. variables.repo_owner = parts[0] || '';
  62. } else if (payload.repository?.owner) {
  63. variables.repo_owner = payload.repository.owner.username ||
  64. payload.repository.owner.login ||
  65. payload.repository.owner.name || '';
  66. }
  67. // Extract pusher/sender information
  68. variables.pusher = payload.pusher?.username ||
  69. payload.pusher?.name ||
  70. payload.sender?.username ||
  71. payload.sender?.login ||
  72. '';
  73. // Extract branch from ref (e.g., "refs/heads/main" -> "main")
  74. if (payload.ref) {
  75. const refMatch = payload.ref.match(/^refs\/(heads|tags)\/(.+)$/);
  76. if (refMatch) {
  77. variables.branch = refMatch[2];
  78. if (refMatch[1] === 'tags') {
  79. variables.tag = refMatch[2];
  80. }
  81. }
  82. }
  83. // Event-specific extractions
  84. switch (eventType) {
  85. case 'push':
  86. if (payload.commits && payload.commits.length > 0) {
  87. const lastCommit = payload.commits[payload.commits.length - 1];
  88. variables.commit = lastCommit.id || '';
  89. variables.commit_msg = lastCommit.message || '';
  90. }
  91. variables.commit = variables.commit.substring(0, 7); // Short hash
  92. break;
  93. case 'pull_request':
  94. variables.pr_number = payload.pull_request?.number || payload.number || '';
  95. variables.pr_action = payload.action || '';
  96. variables.branch = payload.pull_request?.head?.ref ||
  97. payload.pull_request?.base?.ref || '';
  98. break;
  99. case 'create':
  100. case 'delete':
  101. variables.ref_type = payload.ref_type || '';
  102. variables.branch = payload.ref || '';
  103. if (payload.ref_type === 'tag') {
  104. variables.tag = payload.ref || '';
  105. }
  106. break;
  107. case 'release':
  108. variables.tag = payload.release?.tag_name || '';
  109. variables.pr_action = payload.action || '';
  110. break;
  111. case 'issues':
  112. variables.issue_number = payload.issue?.number || payload.number || '';
  113. variables.issue_title = payload.issue?.title || '';
  114. variables.issue_action = payload.action || '';
  115. variables.issue_body = payload.issue?.body || '';
  116. variables.issue_assignee = payload.issue?.assignee?.username ||
  117. payload.issue?.assignee?.login || '';
  118. break;
  119. case 'issue_comment':
  120. variables.issue_number = payload.issue?.number || '';
  121. variables.issue_title = payload.issue?.title || '';
  122. variables.issue_action = payload.action || '';
  123. variables.comment_body = payload.comment?.body || '';
  124. variables.issue_assignee = payload.issue?.assignee?.username ||
  125. payload.issue?.assignee?.login || '';
  126. break;
  127. }
  128. return variables;
  129. }
  130. /**
  131. * Substitute variables in command string
  132. */
  133. substituteVariables(command, variables) {
  134. let result = command;
  135. for (const [key, value] of Object.entries(variables)) {
  136. const regex = new RegExp(`{{${key}}}`, 'g');
  137. result = result.replace(regex, value || '');
  138. }
  139. return result;
  140. }
  141. /**
  142. * Build command string from command and args
  143. */
  144. buildCommandString(command, args = []) {
  145. // Escape arguments for shell execution
  146. const escapeArg = (arg) => {
  147. // Always wrap empty strings in quotes to preserve them as arguments
  148. if (arg === '') {
  149. return "''";
  150. }
  151. // If arg contains spaces or special chars, wrap in single quotes
  152. if (arg.includes(' ') || arg.includes('$') || arg.includes('`') || arg.includes('"')) {
  153. return `'${arg.replace(/'/g, "'\\''")}'`;
  154. }
  155. return arg;
  156. };
  157. const escapedArgs = args.map(escapeArg);
  158. return [command, ...escapedArgs].join(' ');
  159. }
  160. /**
  161. * Execute a command with timeout
  162. */
  163. async executeCommand(command, options = {}) {
  164. const {
  165. timeout = 300000,
  166. cwd = process.cwd(),
  167. type = 'shell',
  168. args = []
  169. } = options;
  170. let fullCommand;
  171. if (type === 'node') {
  172. // For Node.js execution, use 'node' as the shell command
  173. fullCommand = this.buildCommandString('node', [command, ...args]);
  174. } else {
  175. // For shell execution, build command from command and args
  176. fullCommand = this.buildCommandString(command, args);
  177. }
  178. logger.info('Executing command', { type, command, args, fullCommand, cwd, timeout });
  179. try {
  180. // Use default shell if SHELL_PATH is just 'bash' (more compatible with restricted environments)
  181. const execOptions = {
  182. timeout,
  183. cwd: cwd || process.cwd(),
  184. maxBuffer: 10 * 1024 * 1024 // 10MB
  185. };
  186. // Only set custom shell if we have a full path
  187. if (SHELL_PATH.startsWith('/')) {
  188. execOptions.shell = SHELL_PATH;
  189. } else {
  190. // Let Node.js use default shell resolution
  191. execOptions.shell = true;
  192. }
  193. const { stdout, stderr } = await execAsync(fullCommand, execOptions);
  194. if (stdout) {
  195. logger.info('Command output (stdout)');
  196. console.log(stdout);
  197. }
  198. if (stderr) {
  199. logger.info('Command output (stderr)');
  200. console.error(stderr);
  201. }
  202. logger.info('Command completed successfully');
  203. return { success: true, stdout, stderr };
  204. } catch (error) {
  205. logger.error('Command execution failed', error);
  206. if (error.stdout) {
  207. logger.info('Command output (stdout) before error');
  208. console.log(error.stdout);
  209. }
  210. if (error.stderr) {
  211. logger.info('Command output (stderr)');
  212. console.error(error.stderr);
  213. }
  214. return {
  215. success: false,
  216. error: error.message,
  217. stdout: error.stdout,
  218. stderr: error.stderr,
  219. code: error.code
  220. };
  221. }
  222. }
  223. /**
  224. * Execute configured command for webhook event
  225. */
  226. async executeWebhookCommand(eventType, payload, headers, config) {
  227. // Support both old format (single command string) and new format (command + args)
  228. const isNewFormat = config.command && config.args !== undefined;
  229. if (!config.command) {
  230. return null;
  231. }
  232. // Extract variables from payload
  233. const variables = this.extractVariables(eventType, payload, headers);
  234. // Apply branch filter if configured
  235. if (config.filterBranch && variables.branch !== config.filterBranch) {
  236. logger.info(`Skipping command execution - branch '${variables.branch}' does not match filter '${config.filterBranch}'`);
  237. return null;
  238. }
  239. let command, args, type;
  240. if (isNewFormat) {
  241. // New format: separate command and args
  242. command = this.substituteVariables(config.command, variables);
  243. args = (config.args || []).map(arg => this.substituteVariables(arg, variables));
  244. type = config.type || 'shell';
  245. } else {
  246. // Old format: single command string (for backward compatibility)
  247. const fullCommand = this.substituteVariables(config.command, variables);
  248. // Parse command string into command and args
  249. const parts = fullCommand.split(' ');
  250. command = parts[0];
  251. args = parts.slice(1);
  252. type = 'shell';
  253. }
  254. logger.info('Webhook command prepared', {
  255. eventType,
  256. type,
  257. command,
  258. args,
  259. variables
  260. });
  261. // Execute command
  262. const result = await this.executeCommand(command, {
  263. type,
  264. args,
  265. timeout: config.timeout || 300000,
  266. cwd: config.cwd || config.workingDir || process.cwd()
  267. });
  268. return result;
  269. }
  270. }
  271. export default new CommandExecutor();