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
jqor 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
}
Use distinct exit codes for different failure modes:
const EXIT_CODES = {
SUCCESS: 0,
ERRORS_FOUND: 1,
INVALID_INPUT: 2,
NETWORK_ERROR: 3,
TIMEOUT: 4,
};
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);
});
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
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);
}
});
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
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
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)
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 };
Example config file (.mytoolrc.json):
{
"threshold": 85,
"maxErrors": 0,
"targets": ["https://example.com", "https://staging.example.com"],
"mobile": true,
"json": false
}
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`
});
Checklist: Is Your CLI CI/CD-Ready?
- [ ] Non-zero exit codes for failures
- [ ]
--jsonflag for machine-readable output - [ ]
--thresholdor--max-errorsfor configurable pass/fail - [ ] Progress messages on stderr, data on stdout
- [ ] Config file support (
.toolrc.jsonor similar) - [ ]
--quietflag for suppressing non-essential output - [ ] Runs without TTY (no interactive prompts in CI)
- [ ] Works without color (
NO_COLORenvironment 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)