All files / src/utils exec.ts

96.77% Statements 30/31
80.76% Branches 21/26
100% Functions 5/5
96.77% Lines 30/31

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 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138              53x 53x 53x 53x 53x     53x 53x 53x                                   57x                     128x                                     10x     10x         10x       10x 5x 5x     5x 10x                                     118x 118x   118x   118x 1x     117x 41x 41x     76x 118x                           96x         96x    
import { spawnSync, type SpawnSyncOptions } from 'node:child_process';
 
/**
 * Error thrown when a command fails.
 */
export class ExecError extends Error {
  constructor(
    public readonly command: string,
    public readonly exitCode: number | null,
    public readonly stderr: string,
    public readonly signal: string | null,
    public readonly code?: string,
    options?: { cause?: unknown }
  ) {
    const details = stderr || (signal ? `Killed by signal ${signal}` : 'Unknown error');
    super(`Command failed: ${command}\n${details}`, options);
    this.name = 'ExecError';
  }
}
 
/**
 * Options for exec functions.
 */
export interface ExecOptions {
  cwd?: string;
  env?: Record<string, string>;
  timeout?: number;
}
 
/**
 * Git environment variables that disable interactive prompts.
 * - GIT_TERMINAL_PROMPT=0: Disables git's internal prompts
 * - GIT_SSH_COMMAND with BatchMode=yes: Makes SSH fail instead of prompting for passphrase
 */
export const GIT_NON_INTERACTIVE_ENV = {
  GIT_TERMINAL_PROMPT: '0',
  GIT_SSH_COMMAND: 'ssh -o BatchMode=yes',
};
 
/**
 * Build spawn options for non-interactive execution.
 * - stdin: 'ignore' maps to /dev/null, ensuring immediate EOF on read (no hangs)
 * - stdout/stderr: 'pipe' to capture output
 */
function buildSpawnOptions(options?: ExecOptions): SpawnSyncOptions {
  return {
    encoding: 'utf-8',
    cwd: options?.cwd,
    env: options?.env ? { ...process.env, ...options.env } : process.env,
    timeout: options?.timeout,
    stdio: ['ignore', 'pipe', 'pipe'],
  };
}
 
/**
 * Execute a shell command in non-interactive mode.
 * Uses piped stdio to avoid passing terminal to child process.
 *
 * @param command - The shell command to execute
 * @param options - Execution options (cwd, env, timeout)
 * @returns The trimmed stdout output
 * @throws ExecError if the command fails
 */
export function execNonInteractive(command: string, options?: ExecOptions): string {
  const spawnOptions = buildSpawnOptions(options);
 
  // Use shell to execute the command string
  const result = spawnSync(command, {
    ...spawnOptions,
    shell: true,
  });
 
  Iif (result.error) {
    throw new ExecError(command, null, result.error.message, null, (result.error as NodeJS.ErrnoException).code, { cause: result.error });
  }
 
  if (result.status !== 0) {
    const stderr = typeof result.stderr === 'string' ? result.stderr.trim() : '';
    throw new ExecError(command, result.status, stderr, result.signal?.toString() ?? null);
  }
 
  const stdout = typeof result.stdout === 'string' ? result.stdout : '';
  return stdout.trim();
}
 
/**
 * Execute a file with arguments in non-interactive mode.
 * Uses execFile semantics (no shell), avoiding shell injection vulnerabilities.
 * Uses piped stdio to avoid passing terminal to child process.
 *
 * @param file - The executable to run
 * @param args - Arguments to pass to the executable
 * @param options - Execution options (cwd, env, timeout)
 * @returns The trimmed stdout output
 * @throws ExecError if the command fails
 */
export function execFileNonInteractive(
  file: string,
  args: string[],
  options?: ExecOptions
): string {
  const spawnOptions = buildSpawnOptions(options);
  const command = `${file} ${args.join(' ')}`;
 
  const result = spawnSync(file, args, spawnOptions);
 
  if (result.error) {
    throw new ExecError(command, null, result.error.message, null, (result.error as NodeJS.ErrnoException).code, { cause: result.error });
  }
 
  if (result.status !== 0) {
    const stderr = typeof result.stderr === 'string' ? result.stderr.trim() : '';
    throw new ExecError(command, result.status, stderr, result.signal?.toString() ?? null);
  }
 
  const stdout = typeof result.stdout === 'string' ? result.stdout : '';
  return stdout.trim();
}
 
/**
 * Execute a git command in non-interactive mode.
 * Combines execFileNonInteractive with GIT_NON_INTERACTIVE_ENV for
 * defense-in-depth against SSH prompts.
 *
 * @param args - Arguments to pass to git
 * @param options - Execution options (cwd, env, timeout)
 * @returns The trimmed stdout output
 * @throws ExecError if the command fails
 */
export function execGitNonInteractive(args: string[], options?: ExecOptions): string {
  const env = {
    ...options?.env,
    ...GIT_NON_INTERACTIVE_ENV, // Always override to ensure non-interactive
  };
 
  return execFileNonInteractive('git', args, { ...options, env });
}