Most developers use dozens of command-line tools every day, but far fewer have shipped one. It's one of the most satisfying things you can publish: small, useful, and installable by anyone with a single command.
In this tutorial we'll build envcheck, a zero-dependency CLI that compares your .env file against .env.example and tells you exactly which variables are missing or unexpected. It's the kind of tool that saves a teammate twenty minutes of "why doesn't this work on my machine?"
By the end you'll have a tested package on npm, published from GitHub Actions with provenance, so people can trust where it came from.
What makes a good CLI
Before writing any code, it's worth knowing what separates a pleasant CLI from a frustrating one:
- It does one thing well. envcheck checks env files. It doesn't also generate them, encrypt them or deploy them.
- It respects the terminal. Results go to
stdout, problems go tostderr, and colour switches off when output is piped orNO_COLORis set. - It uses exit codes.
0means success, anything else means failure. That's what lets your tool work in CI pipelines and&&chains. - It has
--helpand--version. Always. Nobody should need to open your README to remember a flag. - It's fast to start. Every dependency adds startup time. Node now ships argument parsing, colours and a test runner, so we need none.
How npm turns a file into a command
When someone runs npm install -g envcheck, npm reads the bin field in your package.json and links each entry onto their PATH. On macOS and Linux that's a symlink; on Windows npm generates a small .cmd shim.
"bin": {
"envcheck": "./bin/envcheck.js"
}
The file itself needs a shebang on its first line so the operating system knows to run it with Node:
#!/usr/bin/env node
Using /usr/bin/env node rather than a hard-coded path means it works wherever the user has Node installed, including version managers like nvm and fnm.
Project setup
Create the folder and a minimal package.json:
mkdir envcheck && cd envcheck
npm init -y
mkdir bin src test
Then edit package.json so it looks like this:
{
"name": "envcheck",
"version": "0.1.0",
"description": "Compare .env against .env.example and report missing or unexpected variables",
"type": "module",
"bin": {
"envcheck": "./bin/envcheck.js"
},
"files": ["bin", "src"],
"engines": {
"node": ">=20.12"
},
"scripts": {
"test": "node --test"
},
"keywords": ["env", "dotenv", "cli", "validation"],
"license": "MIT"
}
A few of these fields matter more than they look:
"type": "module"lets us useimportsyntax without.mjsextensions.filesis an allow-list of what gets published. Without it, npm publishes everything not in.gitignore, including your tests and any stray local files.enginestells users which Node versions are supported. We need 20.12 or later forutil.styleText, which handles terminal colours for us.
Check the name first. Run
npm view envcheckbefore you get attached to a name. If it's taken, use a scoped name like@yourname/envcheck. Scoped packages are private by default, so you'll publish them with--access public.
Testing it locally with npm link
You don't need to publish to try your command. Create a placeholder entry point:
#!/usr/bin/env node
console.log('envcheck is alive');
Save it as bin/envcheck.js, make it executable and link it:
chmod +x bin/envcheck.js
npm link
envcheck
npm link symlinks your working folder globally, so every change you save is live immediately. Run npm unlink -g envcheck when you're done.
What's next
You now have a package npm can install as a command. In the full tutorial we'll build the real thing:
- parsing arguments with Node's built-in
parseArgs - reading and parsing env files safely
- colour output that behaves in CI
- proper exit codes for scripts and pipelines
- tests with
node:test - publishing with provenance from GitHub Actions
Checking access…