Building Developer Tools with open-source tools
This guide outlines the technical implementation of a production-grade CLI tool using TypeScript and Commander.js. It focuses on creating a robust architecture that supports cross-platform execution, automated configuration management, and developer-centric error reporting.
Initialize Project with ESM and Shebang Support
Create a new project directory and initialize it as an ES Module. This ensures compatibility with modern libraries. You must include a shebang line at the top of your entry point to tell the OS loader which interpreter to use.
#!/usr/bin/env node
import { Command } from 'commander';
const program = new Command();
program
.name('my-devtools-cli')
.description('CLI for internal platform operations')
.version('1.0.0');
program.parse();⚠ Common Pitfalls
- •Forgetting the shebang line will cause the script to fail when executed as a binary.
- •Ensure 'type': 'module' is set in package.json to avoid ESM/CJS interop issues.
Implement Robust Configuration Persistence
Use the 'conf' library to manage user preferences, API tokens, and local state. Store these in the standard OS-specific config directories (e.g., ~/.config or %APPDATA%) to ensure persistence across sessions.
import Conf from 'conf';
const schema = {
apiKey: {
type: 'string',
default: ''
},
environment: {
type: 'string',
enum: ['development', 'staging', 'production'],
default: 'development'
}
} as const;
export const config = new Conf({ schema });⚠ Common Pitfalls
- •Hardcoding configuration paths instead of using standard libraries leads to cross-platform failures.
- •Storing sensitive API keys in plain text without warning the user.
Design Intuitive Subcommands and Options
Structure your CLI into logical subcommands. Use Commander.js to define mandatory arguments and optional flags. Grouping logic into separate command files improves maintainability as the tool scales.
program
.command('deploy')
.description('Deploy service to the cluster')
.argument('<service-name>', 'Name of the service to deploy')
.option('-f, --force', 'Overwrite existing deployment')
.action(async (serviceName, options) => {
console.log(`Deploying ${serviceName}...`);
if (options.force) console.warn('Force mode enabled');
});⚠ Common Pitfalls
- •Using inconsistent naming conventions for flags (e.g., mixing camelCase and kebab-case).
- •Failing to provide helpful descriptions, which breaks the built-in --help output.
Integrate Visual Feedback and TTY Detection
Use 'ora' for spinners and 'chalk' for colored output. Crucially, detect if the process is running in a TTY (interactive terminal) to disable animations in CI/CD environments, preventing log pollution.
import ora from 'ora';
import chalk from 'chalk';
export const logger = {
info: (msg: string) => console.log(chalk.blue('ℹ'), msg),
success: (msg: string) => console.log(chalk.green('✔'), msg),
error: (msg: string) => console.error(chalk.red('✖'), msg)
};
const spinner = ora({ isSilent: !process.stdout.isTTY });⚠ Common Pitfalls
- •Running spinners in non-TTY environments (like GitHub Actions) results in hundreds of lines of escape codes.
- •Overusing colors can make the tool inaccessible to users with color blindness.
Standardize Error Handling and Exit Codes
Implement a global error handler that catches asynchronous exceptions. Map specific failure types to standard POSIX exit codes (e.g., 1 for general errors, 127 for command not found) to allow scriptability in bash/zsh.
process.on('unhandledRejection', (reason) => {
console.error(chalk.red('Critical Error:'), reason);
process.exit(1);
});
try {
await program.parseAsync(process.argv);
} catch (err) {
const message = err instanceof Error ? err.message : 'Unknown error';
logger.error(message);
process.exit(1);
}⚠ Common Pitfalls
- •Exiting with code 0 after a failure prevents CI/CD pipelines from stopping on errors.
- •Printing stack traces to the user instead of actionable, human-readable error messages.
Configure Distribution and Binary Linking
Map your entry point in the 'bin' field of package.json. Use a build step (like esbuild or tsc) to bundle the TypeScript into a single executable JS file, ensuring users don't need to install dev dependencies to run the tool.
{
"name": "@org/dev-tool",
"version": "1.0.0",
"type": "module",
"bin": {
"dev-tool": "./dist/index.js"
},
"scripts": {
"build": "tsc && chmod +x dist/index.js",
"prepublishOnly": "npm run build"
}
}⚠ Common Pitfalls
- •Publishing the src/ directory instead of the compiled dist/ directory.
- •Missing 'chmod +x' on the output file, which prevents execution on Unix-based systems after installation.
What you built
By following this structured approach, you have built a CLI tool that adheres to modern DevTool standards: ESM support, standardized configuration, environment-aware UI, and proper POSIX exit codes. This foundation allows for seamless integration into both developer workstations and automated pipelines.