{ ILoveJS }

Run shell commands with output streaming

typescript

A production-ready TypeScript utility that wraps child_process.spawn to execute shell commands, stream output to the console in real-time, and return the exit code via a Promise.

nodeshellchild-processcli

Code

typescript
import { spawn, SpawnOptions } from "child_process";

interface ExecResult {
  exitCode: number;
  stdout: string;
  stderr: string;
}

interface ExecOptions {
  cwd?: string;
  env?: NodeJS.ProcessEnv;
  shell?: boolean | string;
  timeout?: number;
  silent?: boolean;
}

function exec(command: string, options: ExecOptions = {}): Promise<ExecResult> {
  return new Promise((resolve, reject) => {
    const {
      cwd = process.cwd(),
      env = process.env,
      shell = true,
      timeout = 0,
      silent = false,
    } = options;

    const spawnOptions: SpawnOptions = {
      cwd,
      env,
      shell,
      stdio: "pipe",
    };

    const child = spawn(command, [], spawnOptions);

    let stdout = "";
    let stderr = "";
    let timeoutId: NodeJS.Timeout | undefined;

    if (timeout > 0) {
      timeoutId = setTimeout(() => {
        child.kill("SIGTERM");
        reject(new Error(`Command timed out after ${timeout}ms: ${command}`));
      }, timeout);
    }

    child.stdout?.on("data", (data: Buffer) => {
      const text = data.toString();
      stdout += text;
      if (!silent) {
        process.stdout.write(text);
      }
    });

    child.stderr?.on("data", (data: Buffer) => {
      const text = data.toString();
      stderr += text;
      if (!silent) {
        process.stderr.write(text);
      }
    });

    child.on("error", (error: Error) => {
      if (timeoutId) clearTimeout(timeoutId);
      reject(new Error(`Failed to execute command: ${error.message}`));
    });

    child.on("close", (exitCode: number | null) => {
      if (timeoutId) clearTimeout(timeoutId);
      resolve({
        exitCode: exitCode ?? 1,
        stdout,
        stderr,
      });
    });
  });
}

async function execOrThrow(command: string, options: ExecOptions = {}): Promise<ExecResult> {
  const result = await exec(command, options);
  if (result.exitCode !== 0) {
    const error = new Error(`Command failed with exit code ${result.exitCode}: ${command}`);
    (error as Error & { result: ExecResult }).result = result;
    throw error;
  }
  return result;
}

// Example usage
async function main(): Promise<void> {
  console.log("=== Running ls command ===");
  const lsResult = await exec("ls -la");
  console.log(`\nExit code: ${lsResult.exitCode}`);

  console.log("\n=== Running echo with pipe ===");
  const pipeResult = await exec("echo 'Hello World' | tr '[:lower:]' '[:upper:]'");
  console.log(`Exit code: ${pipeResult.exitCode}`);

  console.log("\n=== Running silent command ===");
  const silentResult = await exec("echo 'This is silent'", { silent: true });
  console.log(`Captured output: ${silentResult.stdout.trim()}`);

  console.log("\n=== Running command with timeout ===");
  try {
    await exec("sleep 5", { timeout: 1000 });
  } catch (error) {
    console.log(`Caught timeout: ${(error as Error).message}`);
  }

  console.log("\n=== Running failing command ===");
  const failResult = await exec("exit 42");
  console.log(`Exit code: ${failResult.exitCode}`);

  console.log("\n=== Using execOrThrow ===");
  try {
    await execOrThrow("exit 1");
  } catch (error) {
    console.log(`Caught error: ${(error as Error).message}`);
  }
}

main().catch(console.error);

export { exec, execOrThrow, ExecResult, ExecOptions };

How It Works

This utility leverages Node.js's child_process.spawn instead of exec to enable real-time output streaming. While exec buffers the entire output before returning, spawn provides stream access to stdout and stderr as data becomes available, making it ideal for long-running commands or build processes where you want immediate feedback.

The exec function returns a Promise that resolves with an ExecResult object containing the exit code and captured output. By default, it pipes output to the console in real-time while simultaneously accumulating it in memory. The silent option suppresses console output while still capturing the data, useful for programmatic use cases where you need the output but don't want to clutter logs.

The shell: true option is enabled by default, allowing you to use shell features like pipes, redirections, and environment variable expansion. This makes the utility behave like running commands in a terminal. However, be cautious with user-provided input as shell mode can introduce command injection vulnerabilities—sanitize inputs or disable shell mode when processing untrusted data.

Timeout handling uses SIGTERM to gracefully terminate long-running processes. The timeout clears on both successful completion and errors to prevent memory leaks. Note that some processes may not respond to SIGTERM; for critical applications, consider implementing a follow-up SIGKILL after a grace period.

The companion execOrThrow function provides a stricter interface that throws on non-zero exit codes, attaching the full result to the error object for inspection. This pattern is useful in build scripts or CI pipelines where any failure should halt execution. Choose exec when you need to handle failures gracefully, and execOrThrow when failures are exceptional conditions.