TidyURLs
Go to Tools
BLOG
The TidyURLsJournal

Practical guides, tutorials, and insights about URLs, SEO, web development, and online marketing. Learn, troubleshoot, and get the most of the web.

Back to the Journal
TidyURLs Journal

How to Create a Node.js CLI Tool and Publish It to npm

Practical insights, clear explanations, and useful ideas for understanding the web.

October 25, 20268 min read6 topics
How to Create a Node.js CLI Tool and Publish It to npm
Explore the details
#Node.js#npm#CLI#JavaScript#Open Source#Package Publishing
October 25, 20268 min read

A small command-line tool can make a repeated task easier to run, share, and automate. But a script that works on your computer is not yet a package: users also need a clear command name, sensible argument handling, tests, and a safe release process.

This tutorial builds a dependency-free Node.js CLI, tests its behavior, and walks through publishing it to npm. Publishing and authentication policies can change, so check npm’s official documentation for the current requirements before you release.

Key takeaways

  • Declare a command in package.json with the bin field and point it to a file with a Node.js shebang.
  • Keep parsing and validation separate from the executable entry point so they are easier to test.
  • Test valid input, help output, and invalid arguments before publishing.
  • Use semantic versioning and publish each release under a new version.
  • Never commit npm credentials or put secrets in a package.

1. Set up the CLI package

Create a project and initialize its package manifest:

mkdir hello-cli
cd hello-cli
npm init -y
mkdir -p bin src test

Choose a package name that is available and appropriate for your project. The example uses hello-cli-demo; it is illustrative and may already be taken. Scoped names, such as @yourname/hello-cli, are another option.

Edit package.json to describe the package and connect the command name to its executable:

{
  "name": "hello-cli-demo",
  "version": "1.0.0",
  "description": "A small example command-line tool",
  "type": "module",
  "bin": {
    "hello": "./bin/hello.js"
  },
  "files": [
    "bin",
    "src"
  ],
  "scripts": {
    "test": "node --test"
  },
  "engines": {
    "node": ">=20"
  },
  "license": "MIT"
}

The bin mapping means npm will make the hello command available when the package is installed. The files field limits which project files are included in the published package; review the package contents before release rather than assuming every development file should ship. Set the license and supported Node.js version to match your actual project.

The type field makes .js files in this package use ECMAScript modules. If your project uses CommonJS instead, use a consistent module format and adjust the imports and exports accordingly.

2. Parse and validate arguments

Keep argument handling in a module that can be imported by tests. This example supports --name, its --name=value form, and help flags. It rejects unknown options and missing or empty names.

Create src/cli.js:

export function parseArgs(args) {
  let name = 'world';

  for (let i = 0; i < args.length; i += 1) {
    const arg = args[i];

    if (arg === '--help' || arg === '-h') {
      return { help: true, name };
    }

    if (arg === '--name') {
      const value = args[i + 1];
      if (value === undefined || value.startsWith('--') || value === '') {
        throw new Error('Expected a value after --name.');
      }
      name = value;
      i += 1;
      continue;
    }

    if (arg.startsWith('--name=')) {
      const value = arg.slice('--name='.length);
      if (value === '') {
        throw new Error('Expected a value after --name=.');
      }
      name = value;
      continue;
    }

    throw new Error(`Unknown option: ${arg}`);
  }

  if (!name.trim()) {
    throw new Error('Name must not be empty.');
  }

  return { help: false, name };
}

export function run(args, io = { stdout: process.stdout, stderr: process.stderr }) {
  try {
    const options = parseArgs(args);

    if (options.help) {
      io.stdout.write('Usage: hello [--name <name>] [--help]\n');
      return 0;
    }

    io.stdout.write(`Hello, ${options.name}!\n`);
    return 0;
  } catch (error) {
    io.stderr.write(`${error.message}\nUsage: hello [--name <name>] [--help]\n`);
    return 2;
  }
}

Validation should match what the program does with an argument. Here, a non-empty name is enough because it is only printed. If your CLI uses input as a file path, URL, database identifier, or shell argument, validate it for that specific context too. Parsing a string alone does not make later operations safe.

3. Add the executable entry point

Create bin/hello.js:

#!/usr/bin/env node

import { run } from '../src/cli.js';

process.exitCode = run(process.argv.slice(2));

The shebang tells compatible environments to run the file with Node.js. process.argv.slice(2) passes only the arguments supplied after the executable name. Setting process.exitCode lets Node exit with the intended status without stopping the process abruptly.

On macOS or Linux, mark the file as executable:

chmod +x bin/hello.js

Try it directly before installing the package:

node bin/hello.js --name Ada
node bin/hello.js --help
node bin/hello.js --unknown

The first command should print a greeting, help should show usage, and the unknown option should produce an error and a nonzero exit code.

4. Test argument parsing and behavior

Node.js includes a test runner, so this example needs no test dependency. Create test/cli.test.js:

import test from 'node:test';
import assert from 'node:assert/strict';
import { parseArgs, run } from '../src/cli.js';

test('parses a name option', () => {
  assert.deepEqual(parseArgs(['--name', 'Ada']), {
    help: false,
    name: 'Ada'
  });
});

test('parses the equals form', () => {
  assert.deepEqual(parseArgs(['--name=Lin']), {
    help: false,
    name: 'Lin'
  });
});

test('rejects unknown options', () => {
  assert.throws(() => parseArgs(['--other']), /Unknown option/);
});

test('returns an error code for invalid input', () => {
  let stderr = '';
  const io = {
    stdout: { write() {} },
    stderr: { write(text) { stderr += text; } }
  };

  assert.equal(run(['--name='], io), 2);
  assert.match(stderr, /Expected a value/);
});

Run the tests with:

npm test

For a larger CLI, add tests for each option, option combinations, boundary cases, and expected exit codes. If the tool reads files or talks to services, test error handling without relying on uncontrolled external systems.

5. Check the package before publishing

Inspect what npm will include:

npm pack --dry-run

Review the file list for accidental inclusion of credentials, local configuration, private data, or unnecessary development files. The files field helps control package contents, but a review is still important.

You can also test a packed package in a temporary project or install the package locally before publishing. Confirm that the command runs after installation, not only through node bin/hello.js in the source directory.

Before release, make sure the package has a useful description, a valid license, and any required repository or documentation details. Check the official npm documentation for package.json, npm pack, and npm publish for current behavior and requirements.

6. Version and publish the package

Use semantic versioning to communicate the scope of a release: patch for compatible fixes, minor for compatible features, and major for breaking changes. Update the version before publishing. For example:

npm version patch

Choose minor or major when appropriate. Review the changes made by the command and commit or tag them according to your project’s release process. A package version that has already been published cannot simply be reused for different contents; publish the next version instead.

Publishing requires an npm account and authentication configured according to npm’s current policy. Those requirements can change, so consult npm’s official publishing and account-security documentation immediately before release rather than relying on a fixed assumption about login, two-factor authentication, or token types.

For an unscoped public package, publish with:

npm publish

For a scoped package intended to be public, explicitly request public access:

npm publish --access public

After publishing, check the package page or query its published metadata with npm. A name lookup is not a reservation: another publisher may claim an available name before your release completes.

7. Protect publishing credentials

Treat npm tokens and other publishing credentials as secrets:

  • Do not put tokens in package.json, source files, example commands committed to a repository, or published files.
  • Do not commit an .npmrc file containing an authentication token. Keep local authentication in the appropriate user-level configuration, and inspect project configuration before committing it.
  • Store CI credentials in the CI platform’s protected secret store. Where supported and configured, consider a trusted publishing workflow instead of a long-lived token.
  • Use only the access needed for the release, follow npm’s current authentication guidance, and revoke or rotate a credential if it may have been exposed.
  • Review the packed file list and Git changes before publishing. A .gitignore file is useful, but it is not a substitute for checking the package contents.

The CLI itself should not need npm publishing credentials at runtime. Users who install your tool should not receive the secret used to release it.

Common mistakes

  • Forgetting the bin field: The package may install, but npm will not know which command to expose.
  • Leaving out the shebang or executable permission: The command may fail when invoked directly on some systems.
  • Testing only the happy path: Missing values and unknown flags should produce a useful error and a nonzero exit code.
  • Publishing before inspecting package contents: Local files or configuration may be included unintentionally.
  • Reusing a published version: Increment the package version for each release instead.
  • Assuming authentication rules are permanent: Check npm’s current publishing requirements before each release process is designed or changed.
  • Committing a token in configuration: Remove the secret from the repository and revoke or rotate it if exposed; deleting the visible line may not remove it from Git history.

Conclusion

A publishable Node.js CLI needs more than an executable script. Declare its command with bin, parse and validate arguments deliberately, test both success and failure cases, inspect the packed files, and use a new semantic version for each release. Keep publishing credentials out of the package and repository, and verify npm’s current publishing requirements before you release.