All files / core/engine process-runner.service.ts

100% Statements 36/36
100% Branches 14/14
100% Functions 8/8
100% Lines 34/34

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 1155x 5x                   5x             75x     75x           75x                     75x   2x             75x 74x     75x 75x 75x   75x 74x 63x       75x 74x 8x       75x 5x   5x 5x 4x   4x 3x 1x             5x               75x 71x 71x               75x 2x 2x                      
import { Injectable } from '@nestjs/common';
import { spawn, ChildProcess } from 'child_process';
 
export interface IProcessResult {
  exitCode: number | null;
  stdout: string;
  stderr: string;
  timedOut: boolean;
}
 
@Injectable()
export class ProcessRunnerService {
  async runProcess(
    command: string,
    args: string[],
    cwd: string,
    timeoutMs: number
  ): Promise<IProcessResult> {
    return new Promise((resolve) => {
      // With shell: true, pass the full command as a single string
      // If args are provided, join them; otherwise use command as-is
      const shellCommand = args.length > 0
        ? `${command} ${args.join(' ')}`
        : command;
 
      // Explicitly wrap in sh -c to ensure shell interpretation
      // This ensures pipes and other shell operators are properly interpreted
      const childProcess: ChildProcess = spawn(
        'sh',
        [ '-c', shellCommand ],
        {
        cwd,
        // We're explicitly invoking sh, so no need for shell: true
        shell: false,
        detached: false
        }
      );
 
      if (process.env.PROCESS_RUNNER_DEBUG === 'true') {
        // eslint-disable-next-line no-console
        console.log(
          `[ProcessRunner] spawn command="${shellCommand}" cwd="${cwd}"`
        );
      }
 
      // Close stdin so commands expecting EOF when run without a TTY
      // (like cursor-agent in non-interactive mode) can exit gracefully.
      if (childProcess.stdin) {
        childProcess.stdin.end();
      }
 
      let stdout = '';
      let stderr = '';
      let timedOut = false;
 
      if (childProcess.stdout) {
        childProcess.stdout.on('data', (data) => {
          stdout += data.toString();
        });
      }
 
      if (childProcess.stderr) {
        childProcess.stderr.on('data', (data) => {
          stderr += data.toString();
        });
      }
 
      const timeout = setTimeout(() => {
        timedOut = true;
        // Kill the process group to ensure all children are terminated
        try {
          if (childProcess.pid) {
            childProcess.kill('SIGTERM');
            // Give it a moment, then force kill
            setTimeout(() => {
              if (!childProcess.killed) {
                childProcess.kill('SIGKILL');
              }
            }, 1000);
          }
        } catch {
          // Ignore kill errors
        }
        resolve({
          exitCode: null,
          stdout,
          stderr,
          timedOut: true
        });
      }, timeoutMs);
 
      childProcess.on('close', (code) => {
        clearTimeout(timeout);
        resolve({
          exitCode: code,
          stdout,
          stderr,
          timedOut
        });
      });
 
      childProcess.on('error', () => {
        clearTimeout(timeout);
        resolve({
          exitCode: null,
          stdout,
          stderr,
          timedOut
        });
      });
    });
  }
}