DEV Community

Wilson Xu
Wilson Xu

Posted on

How to Make Any CLI Tool CI/CD-Ready in 5 Steps

How to Make Any CLI Tool CI/CD-Ready in 5 Steps

You built a useful CLI tool. It works great on your machine. But can it run in a GitHub Actions workflow? Can it fail loudly when something goes wrong in a deployment pipeline? Can another developer pipe its output into their own scripts?

Making a CLI tool CI/CD-friendly isn't just about adding a --json flag. It's about designing for automation from the start — and retrofitting these patterns into existing tools takes surprisingly little effort.

In this article, I'll walk through five concrete patterns that transform any Node.js CLI from "works on my machine" to "runs reliably in production pipelines." Every pattern includes real code you can copy into your own tools.

Why CI/CD Compatibility Matters

Developer tools that only work interactively miss a massive audience. The moment someone needs to:

  • Run your linter in a pre-commit hook
  • Execute your auditor in a GitHub Actions workflow
  • Chain your output into jq or another pipeline tool
  • Use your tool as a health check in a Docker container

...they'll hit a wall if your tool only outputs pretty-printed tables and always exits with code 0.

Let's fix that.

Step 1: Meaningful Exit Codes

The most critical CI/CD pattern is also the simplest: exit with non-zero codes when something is wrong.

// Bad: always exits 0
async function audit(url) {
  const results = await runCheck(url);
  console.log(formatResults(results));
  // implicitly exits 0
}

// Good: exit code reflects outcome
async function audit(url, { threshold }) {
  const results = await runCheck(url);
  console.log(formatResults(results));

  if (results.errors.length > 0) {
    process.exit(1); // Errors found
  }
  if (threshold && results.score < threshold) {
    process.exit(1); // Below threshold
  }
  // Exit 0 only when genuinely passing
}
Enter fullscreen mode Exit fullscreen mode

Use distinct exit codes for different failure modes:

const EXIT_CODES = {
  SUCCESS: 0,
  ERRORS_FOUND: 1,
  INVALID_INPUT: 2,
  NETWORK_ERROR: 3,
  TIMEOUT: 4,
};
Enter fullscreen mode Exit fullscreen mode

CI systems like GitHub Actions, GitLab CI, and Jenkins all check exit codes. A non-zero exit fails the step — which is exactly what you want when your tool detects problems.

Step 2: Machine-Readable Output with --json

Human-readable output is great in terminals. But in pipelines, you need structured data.

import { program } from 'commander';

program
  .command('check <target>')
  .option('--json', 'Output JSON instead of formatted text')
  .option('--quiet', 'Suppress all output except errors')
  .action(async (target, options) => {
    const results = await runCheck(target);

    if (options.json) {
      // Machine-readable: parseable by jq, other scripts, etc.
      console.log(JSON.stringify({
        target,
        timestamp: new Date().toISOString(),
        score: results.score,
        errors: results.errors,
        warnings: results.warnings,
      }, null, 2));
    } else if (!options.quiet) {
      // Human-readable: colors, tables, formatting
      printFormattedReport(results);
    }

    if (results.errors.length > 0) process.exit(1);
  });
Enter fullscreen mode Exit fullscreen mode

The --json flag is the single most important addition for CI/CD users. It lets them:

# Parse with jq in pipelines
perfwatch audit example.com --json | jq '.metrics.lcp'

# Store results as artifacts
perfwatch audit example.com --json > results.json

# Use in conditional logic
SCORE=$(perfwatch audit example.com --json | jq '.score')
if [ "$SCORE" -lt 80 ]; then
  echo "Performance regression detected"
fi
Enter fullscreen mode Exit fullscreen mode

Step 3: Threshold Flags for Pass/Fail Logic

Hard-coding what "passing" means is inflexible. Let users set their own thresholds:

program
  .command('audit <url>')
  .option('--min-score <n>', 'Minimum acceptable score', parseInt)
  .option('--max-errors <n>', 'Maximum allowed errors', parseInt, 0)
  .option('--max-warnings <n>', 'Maximum allowed warnings', parseInt)
  .action(async (url, options) => {
    const results = await runAudit(url);

    let passed = true;
    const failures = [];

    if (options.minScore && results.score < options.minScore) {
      passed = false;
      failures.push(`Score ${results.score} < minimum ${options.minScore}`);
    }

    if (options.maxErrors !== undefined && results.errors.length > options.maxErrors) {
      passed = false;
      failures.push(`${results.errors.length} errors > maximum ${options.maxErrors}`);
    }

    if (options.maxWarnings !== undefined && results.warnings.length > options.maxWarnings) {
      passed = false;
      failures.push(`${results.warnings.length} warnings > maximum ${options.maxWarnings}`);
    }

    printReport(results);

    if (!passed) {
      console.error('\nThreshold violations:');
      failures.forEach(f => console.error(`  - ${f}`));
      process.exit(1);
    }
  });
Enter fullscreen mode Exit fullscreen mode

This enables workflows like:

# Strict for production
- run: mytool audit https://prod.example.com --min-score 90 --max-errors 0

# Lenient for staging
- run: mytool audit https://staging.example.com --min-score 70 --max-warnings 5
Enter fullscreen mode Exit fullscreen mode

Step 4: Stderr for Diagnostics, Stdout for Data

This is the Unix convention that most CLI tools get wrong:

  • stdout → program output (data, results, JSON)
  • stderr → everything else (progress, diagnostics, errors)
import chalk from 'chalk';

function log(message) {
  // Progress and diagnostics go to stderr
  process.stderr.write(chalk.gray(`  ${message}\n`));
}

function output(data) {
  // Actual results go to stdout
  process.stdout.write(typeof data === 'string' ? data : JSON.stringify(data, null, 2));
  process.stdout.write('\n');
}

// Usage
log('Connecting to target...');        // → stderr
log('Running audit...');                // → stderr
output({ score: 95, metrics: {...} }); // → stdout
log('Done.');                           // → stderr
Enter fullscreen mode Exit fullscreen mode

Why this matters in CI/CD:

# Pipe only the data, not the progress messages
mytool audit example.com --json 2>/dev/null | jq '.score'

# Save results but still see progress
mytool audit example.com --json > results.json
# (progress messages appear in terminal via stderr)

# GitHub Actions: capture output in a variable
RESULT=$(mytool audit example.com --json 2>/dev/null)
Enter fullscreen mode Exit fullscreen mode

Step 5: Config File Support

CI/CD pipelines are declarative. Users want to define tool behavior in a config file, not in command-line flags:

import { readFile } from 'node:fs/promises';
import { resolve } from 'node:path';

async function loadConfig() {
  const configPaths = [
    '.mytoolrc.json',
    '.mytoolrc',
    'mytool.config.json',
  ];

  for (const name of configPaths) {
    try {
      const path = resolve(process.cwd(), name);
      const content = await readFile(path, 'utf-8');
      return JSON.parse(content);
    } catch {
      continue;
    }
  }
  return {};
}

// Merge: CLI flags override config file
const config = await loadConfig();
const options = { ...config, ...cliOptions };
Enter fullscreen mode Exit fullscreen mode

Example config file (.mytoolrc.json):

{
  "threshold": 85,
  "maxErrors": 0,
  "targets": ["https://example.com", "https://staging.example.com"],
  "mobile": true,
  "json": false
}
Enter fullscreen mode Exit fullscreen mode

This is especially useful in monorepos where different packages might need different thresholds.

Putting It All Together: A GitHub Actions Example

name: Performance Gate
on: [push, pull_request]

jobs:
  performance:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20

      - name: Install CLI
        run: npm install -g perfwatch-cli

      - name: Audit production
        run: perfwatch audit https://example.com --threshold 85 --json > perf-report.json

      - name: Upload report
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: performance-report
          path: perf-report.json

      - name: Comment on PR
        if: github.event_name == 'pull_request'
        uses: actions/github-script@v7
        with:
          script: |
            const fs = require('fs');
            const report = JSON.parse(fs.readFileSync('perf-report.json', 'utf-8'));
            github.rest.issues.createComment({
              issue_number: context.issue.number,
              owner: context.repo.owner,
              repo: context.repo.repo,
              body: `## Performance Report\nScore: ${report.score}/100\nLCP: ${Math.round(report.metrics.lcp)}ms`
            });
Enter fullscreen mode Exit fullscreen mode

Checklist: Is Your CLI CI/CD-Ready?

  • [ ] Non-zero exit codes for failures
  • [ ] --json flag for machine-readable output
  • [ ] --threshold or --max-errors for configurable pass/fail
  • [ ] Progress messages on stderr, data on stdout
  • [ ] Config file support (.toolrc.json or similar)
  • [ ] --quiet flag for suppressing non-essential output
  • [ ] Runs without TTY (no interactive prompts in CI)
  • [ ] Works without color (NO_COLOR environment variable)
  • [ ] Timeout handling (don't hang forever in CI)

Conclusion

Making a CLI tool CI/CD-ready isn't about adding complexity — it's about following conventions that automation systems already expect. Exit codes, structured output, configurable thresholds, proper stream usage, and config files. Five patterns, maybe 50 lines of code total, and your tool goes from "useful on my machine" to "useful in everyone's pipeline."

The best part? These patterns also make your tool better for human users. --json enables scripting. Threshold flags give users control. Config files reduce typing. CI/CD compatibility and good UX are the same thing.


Wilson Xu builds developer CLI tools and publishes them on npm. Find his work at github.com/chengyixu and dev.to/chengyixu.

Top comments (0)