DEV Community

Wilson Xu
Wilson Xu

Posted on

From Zero to npm: How to Build and Publish Your First CLI Tool

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.json fields map to npm behavior
  • What the bin field 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
Enter fullscreen mode Exit fullscreen mode

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"
  }
}
Enter fullscreen mode Exit fullscreen mode

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"
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

Install the only dependency:

npm install commander
Enter fullscreen mode Exit fullscreen mode

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! 🎉
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

If it's taken, you have two options:

Option A: Use a scope (recommended for new packages)

"name": "@yourname/greet-cli"
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Adding a README That Sells

Your npm README is your package's landing page. Include:

  1. One-line description — what does it do?
  2. Install command — copy-pasteable
  3. Usage examples — show, don't tell
  4. Options table — every flag documented
  5. 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 downloadsnpm 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)