CLI Tools & Scripting with Node.js
5 questions found
What is a shebang line, and why does it need to be the first line of an executable Node.js CLI script?
Beginner
A shebang line (#!/usr/bin/env node) tells Unix-like operating systems which interpreter should execute the file when it's run directly as a program (like ./my-script.js) rather than being explicitly invoked via 'node my-script.js' -- using 'env node' rather than hardcoding a specific path like /usr/bin/node makes the script portable across systems where Node.js might be installed in different locations, since env looks up 'node' in the user's PATH.
#!/usr/bin/env node
console.log('Hello from a CLI tool!');
// After making it executable: chmod +x my-script.js
// It can then be run directly: ./my-script.js
Real-world example
An npm package's CLI tool includes a shebang line in its entry file and is registered in package.json's 'bin' field, letting users run it directly as a command (like 'my-tool --help') after global installation, without needing to explicitly type 'node' before it.
Common follow-ups: How does the package.json 'bin' field work together with the shebang line to make an npm package installable as a global command?;Why doesn't the shebang line have any effect at all when the script is run on Windows?
npm & Packages;Modules (CommonJS/ESM)
How would you parse command-line arguments and flags in a Node.js CLI tool using a library like commander or yargs?
Intermediate
Rather than manually parsing process.argv (which requires hand-written logic to distinguish flags, positional arguments, and their values), libraries like commander or yargs provide a declarative API for defining expected options, flags, and arguments, automatically generating help text, validating required arguments, and parsing values (including type coercion) with far less boilerplate than a hand-rolled parser.
const { program } = require('commander');
program
.option('-v, --verbose', 'enable verbose output')
.option('-o, --output <path>', 'output file path', 'default.txt')
.argument('<input>', 'input file to process')
.action((input, options) => {
console.log(`Processing ${input} -> ${options.output}`, options.verbose ? '(verbose)' : '');
});
program.parse();
Real-world example
A data-processing CLI tool uses commander to define a required input-file argument, an optional --output flag with a sensible default, and a --verbose boolean flag, automatically getting a properly formatted --help output and clear error messages for missing required arguments, without hand-writing any of that parsing logic.
Common follow-ups: How does commander automatically generate a --help output, and how would you customize it?;What's the difference in philosophy and API style between commander and yargs?
npm & Packages;Modules (CommonJS/ESM)
How do you write cross-platform shell scripts in Node.js that work identically on Windows, macOS, and Linux?
Intermediate
Rather than relying on OS-specific shell commands or path separators, cross-platform Node.js scripts use Node's built-in modules (fs, path) instead of shelling out to 'rm', 'cp', or 'mkdir', use path.join()/path.sep instead of hardcoded slashes, and when shelling out to external commands is genuinely necessary, use a cross-platform library like shelljs or execa that normalizes command behavior across operating systems, since raw shell commands like 'rm -rf' simply don't exist on Windows by default.
// Fragile: relies on a Unix-only shell command
// exec('rm -rf dist');
// Cross-platform: use Node's own fs API instead
const fs = require('node:fs/promises');
await fs.rm('dist', { recursive: true, force: true });
Real-world example
A build script that previously called 'rm -rf dist && cp -r src dist' via exec() (breaking on Windows CI runners) is rewritten using fs.rm() and fs.cp() directly from Node's fs/promises module, working identically across the team's mixed Windows, macOS, and Linux development machines.
Common follow-ups: What specific fs module functions replace the most commonly used Unix shell commands (rm, cp, mkdir, mv)?;When is shelling out to a genuinely platform-specific command still unavoidable, and how do you handle that gracefully?
File System (fs) Module;Path & OS Modules
How would you build an interactive CLI wizard that prompts the user for multiple pieces of input using a library like Inquirer.js?
Advanced
Inquirer.js provides a set of prompt types (input, list/select, checkbox for multi-select, confirm) that render interactive terminal UI, collecting a structured object of answers after the user completes each prompt in sequence -- far more ergonomic than hand-building this interaction with the raw readline module, and it handles keyboard navigation, validation, and formatting automatically.
const inquirer = require('inquirer');
const answers = await inquirer.prompt([
{ type: 'input', name: 'projectName', message: 'Project name:' },
{ type: 'list', name: 'framework', message: 'Choose a framework:', choices: ['Express', 'Fastify', 'Koa'] },
{ type: 'confirm', name: 'useTypeScript', message: 'Use TypeScript?', default: true },
]);
console.log(answers); // { projectName: '...', framework: '...', useTypeScript: true/false }
Real-world example
A project-scaffolding CLI tool (similar to 'create-react-app') uses Inquirer.js to interactively ask the user for a project name, preferred framework, and whether to include TypeScript, then generates the appropriate boilerplate files based on the structured answers object it receives.
Common follow-ups: How would you add input validation to an Inquirer prompt, like ensuring a project name doesn't contain invalid characters?;How does Inquirer's prompt chaining compare to manually building the same flow with the built-in readline module?
Async Patterns;Design Patterns in JavaScript
How do you correctly set an exit code from a Node.js CLI script to signal success or failure to the calling shell or CI pipeline?
Intermediate
process.exitCode = n sets the exit code the process will use once it naturally finishes all pending work, without abruptly terminating the process (letting any pending I/O, like a final console.log flush, complete first); process.exit(n) forces immediate termination with that code, which can risk truncating unflushed output if called too early -- setting process.exitCode is generally the safer, more correct approach, with process.exit() reserved for cases needing genuinely immediate termination.
async function main() {
try {
await runTask();
process.exitCode = 0; // success, but lets the process exit naturally
} catch (err) {
console.error(err.message);
process.exitCode = 1; // signals failure to the calling shell/CI without forcing an abrupt exit
}
}
main();
Real-world example
A CI pipeline running a custom Node.js validation script checks the script's exit code to decide whether to fail the build; the script sets process.exitCode = 1 on any validation failure, correctly signaling failure to the CI system while still letting any final buffered log output flush properly before the process actually exits.
Common follow-ups: What's the specific risk of calling process.exit() immediately after an async console.log(), given stdout can be asynchronous in some environments?;What exit code conventions (0 for success, non-zero for various failure types) are commonly followed by CLI tools?
Error Handling;CI/CD
Publishing & Deployment