From Zero to npm: How to Build and Publish Your First CLI Tool
Publishing a package to npm feels like a rite of passage for JavaScript developers. But most tutorials make it seem harder than it is — or skip the details that actually matter, like choosing the right package name, setting up the bin field, or handling ESM vs CommonJS.
This guide walks you through building a real CLI tool and publishing it to npm in under 30 minutes. No boilerplate generators, no complex build toolchains. Just Node.js and a few good decisions.
What We're Building
We'll create greet-cli — a simple but complete CLI tool that generates personalized greeting messages. It's deliberately simple so we can focus on the publishing process, not the tool itself.
By the end, you'll understand:
- How
package.jsonfields map to npm behavior - What the
binfield actually does - How to handle ESM modules in CLI tools
- How npm scopes and naming work
- What happens when you run
npm publish
Step 1: Project Setup
mkdir greet-cli && cd greet-cli
npm init -y
Edit package.json to include the essential fields:
{
"name": "greet-cli",
"version": "1.0.0",
"description": "Generate personalized greeting messages from the terminal",
"type": "module",
"bin": {
"greet": "./bin/greet.js"
},
"keywords": ["cli", "greeting", "terminal", "tool"],
"author": "Your Name <you@email.com>",
"license": "MIT",
"engines": {
"node": ">=18"
},
"files": ["bin", "lib", "README.md"],
"repository": {
"type": "git",
"url": "https://github.com/yourname/greet-cli"
}
}
Let's break down the fields that matter for publishing:
bin — Making Your Package Executable
The bin field tells npm to create a symlink in the user's PATH when they install your package globally. When someone runs npm install -g greet-cli, npm creates a greet command that points to ./bin/greet.js.
"bin": {
"greet": "./bin/greet.js"
}
The key is the command name (greet), and the value is the path to your entry file. These can be different — your package can be named greet-cli while the command is just greet.
files — Control What Gets Published
Without files, npm publishes everything in your directory. With it, you whitelist exactly what goes into the package. This keeps your package small and avoids accidentally publishing test files, .env files, or development configs.
type: "module" — ESM Support
Setting "type": "module" lets you use import/export syntax natively. This is the modern default for new Node.js projects.
Step 2: Write the CLI
Create the entry point:
mkdir bin lib
bin/greet.js:
#!/usr/bin/env node
import { program } from 'commander';
import { generateGreeting } from '../lib/greetings.js';
program
.name('greet')
.description('Generate personalized greeting messages')
.version('1.0.0')
.argument('<name>', 'Name to greet')
.option('-s, --style <style>', 'Greeting style: formal, casual, enthusiastic', 'casual')
.option('-l, --language <lang>', 'Language: en, es, fr, de, ja', 'en')
.action((name, options) => {
const greeting = generateGreeting(name, options.style, options.language);
console.log(greeting);
});
program.parse();
The shebang line (#!/usr/bin/env node) is critical. Without it, the OS won't know to use Node.js to execute your script. Every CLI entry point needs this.
lib/greetings.js:
const greetings = {
en: {
casual: (name) => `Hey ${name}! What's up?`,
formal: (name) => `Good day, ${name}. How do you do?`,
enthusiastic: (name) => `🎉 HELLO ${name.toUpperCase()}!!! SO GREAT TO SEE YOU! 🎉`,
},
es: {
casual: (name) => `¡Hola ${name}! ¿Qué tal?`,
formal: (name) => `Buenos días, ${name}. ¿Cómo está usted?`,
enthusiastic: (name) => `🎉 ¡¡¡HOLA ${name.toUpperCase()}!!! ¡QUÉ ALEGRÍA VERTE! 🎉`,
},
fr: {
casual: (name) => `Salut ${name} ! Ça va ?`,
formal: (name) => `Bonjour, ${name}. Comment allez-vous ?`,
enthusiastic: (name) => `🎉 BONJOUR ${name.toUpperCase()} !!! RAVI DE TE VOIR ! 🎉`,
},
};
export function generateGreeting(name, style = 'casual', language = 'en') {
const lang = greetings[language] || greetings.en;
const generator = lang[style] || lang.casual;
return generator(name);
}
Install the only dependency:
npm install commander
Step 3: Test Locally Before Publishing
# Make executable
chmod +x bin/greet.js
# Test directly
node bin/greet.js World
# → Hey World! What's up?
node bin/greet.js Alice --style formal --language fr
# → Bonjour, Alice. Comment allez-vous ?
# Simulate global install
npm link
greet Developer --style enthusiastic
# → 🎉 HELLO DEVELOPER!!! SO GREAT TO SEE YOU! 🎉
npm link creates a global symlink to your local project — it's the best way to test the installed experience without actually publishing.
Step 4: Choose Your Package Name
Before publishing, you need a name that's available on npm:
npm search greet-cli
# Check if the name is taken
If it's taken, you have two options:
Option A: Use a scope (recommended for new packages)
"name": "@yourname/greet-cli"
Scoped packages are namespaced under your npm username. They're always available and avoid naming conflicts.
Option B: Pick a different name
"name": "greeter-tool-cli"
Step 5: Create an npm Account and Publish
# Create account (one-time)
npm adduser
# Verify you're logged in
npm whoami
# Publish!
npm publish --access public
The --access public flag is required for scoped packages (they default to private). For unscoped packages, it's optional but good practice.
After publishing, your package appears at https://www.npmjs.com/package/greet-cli (or @yourname/greet-cli) within a few minutes.
Step 6: Verify the Published Package
# Install globally from npm
npm install -g greet-cli
# Test it
greet World
Common Publishing Mistakes (and How to Avoid Them)
1. Forgetting the shebang
Without #!/usr/bin/env node, your CLI won't execute on macOS/Linux. Windows is more forgiving, but always include it.
2. Publishing node_modules
Add a .npmignore file or use the files field in package.json. Never publish node_modules.
3. Wrong bin path
The path in bin is relative to your package root. "./bin/greet.js" works. "bin/greet.js" might cause issues on some systems.
4. Not testing with npm pack
Before publishing, run npm pack to see exactly what will be included:
npm pack --dry-run
This lists every file that would go into the tarball. Review it for anything that shouldn't be there.
5. Version conflicts
npm won't let you publish the same version twice. Always bump the version:
npm version patch # 1.0.0 → 1.0.1
npm version minor # 1.0.1 → 1.1.0
npm version major # 1.1.0 → 2.0.0
Adding a README That Sells
Your npm README is your package's landing page. Include:
- One-line description — what does it do?
- Install command — copy-pasteable
- Usage examples — show, don't tell
- Options table — every flag documented
- License — MIT is the standard
That's it. Don't write an essay. Developers decide in 10 seconds whether to install your package.
What's Next?
Once your first package is published:
- Add CI/CD — automatically publish on git tag
- Add tests — protect against regressions
-
Track downloads —
npm stat greet-cli - Build more tools — each one gets easier
The npm registry has over 2 million packages, but there's always room for tools that solve real problems clearly. Your first publish is the hardest — after that, it's just code.
Wilson Xu has published 8+ npm CLI tools and writes about developer tooling at dev.to/chengyixu.
Top comments (0)