Skip to content
Back home

Tutorial — Beginner to intermediate

Build & publish a Node CLI to npm

Build a real command-line tool with zero dependencies, test it with node:test and ship it to npm with provenance from GitHub Actions.
Reading time
35 min
Stack
Node.js 20+ · npm · GitHub Actions
Published
3 November 2026

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 to stderr, and colour switches off when output is piped or NO_COLOR is set.
  • It uses exit codes. 0 means success, anything else means failure. That's what lets your tool work in CI pipelines and && chains.
  • It has --help and --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 use import syntax without .mjs extensions.
  • files is an allow-list of what gets published. Without it, npm publishes everything not in .gitignore, including your tests and any stray local files.
  • engines tells users which Node versions are supported. We need 20.12 or later for util.styleText, which handles terminal colours for us.

Check the name first. Run npm view envcheck before 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…

More tutorials