Rense Bakker

Publishing a NodeJS CLI tool to NPM

Published: May 15th 2023
TypescriptNodeJSNPM

Project setup

Create a new folder (my-cli-demo for example) and use npm init command. Answer the questions with default options, except the entry point, which should be changed to bin/index.js

package name: (cli-demo)
version: (1.0.0)
description:
entry point: (index.js) bin/index.js
test command:
keywords:
author: 
license: (ISC)

After that, we can use npm to install the project dependencies for our CLI tool:

npm i yargs chalk@~4 && npm i typescript @types/yargs @types/node --save-dev

Lastly we need to tell npm that our package has an executable file and since we are using typescript, we should also add a watch script, to recompile our code when we make changes. This will allow us to test our CLI tool locally, without having to run a compile command manually every time:

1{ 2 "name": "my-cli-demo", 3 "version": "1.0.0", 4 "bin": { 5 "my-cli-demo": "bin/index.js" // <- command name of our cli script and the executable file it points to 6 }, 7 "main": "bin/index.js", 8 "scripts": { 9 "watch": "tsc -w" // <- watch script 10 }, 11 "license": "ISC", 12 "dependencies": { 13 "chalk": "^4.1.2", 14 "yargs": "^17.7.2" 15 }, 16 "devDependencies": { 17 "@types/node": "^20.1.0", 18 "@types/yargs": "^17.0.24", 19 "typescript": "^5.0.4" 20 } 21} 22

Typescript configuration

We should also add a minimal tsconfig.json:

1{ 2 "compilerOptions": { 3 "target": "es6", 4 "module": "commonjs", 5 "moduleResolution": "node", 6 "declaration": true, // creates d.ts type declarations 7 "declarationMap": true, // creates map files for our d.ts files 8 "sourceMap": true, // creates map files for our source code 9 "outDir": "bin", // compile source code into "bin" folder 10 "strict": true, 11 "forceConsistentCasingInFileNames": true, 12 "noImplicitAny": true, 13 "esModuleInterop": true 14 }, 15 "include": [ 16 "src/index.ts" 17 ], 18 "exclude": [ 19 "node_modules" 20 ] 21} 22

Parsing command line arguments

It’s time to start writing our CLI script! We’re going to use yargs to parse the command line arguments, that can be passed to our CLI tool. Yargs makes it easier to define which command line arguments can be used and it can automatically add documentation for our CLI tool that can be show with the --help flag.

Start by creating src/index.ts and adding the following contents:

1#! /usr/bin/env node 2import yargs from 'yargs' 3import { hideBin } from 'yargs/helpers' 4 5yargs(hideBin(process.argv)) 6 .help() 7 .argv 8

Let’s start by getting our watch script up and running by typing npm run watch in a terminal. Now everytime we make a change, our code will be recompiled into the bin folder, which is configured as our main entry point. If we open another terminal and run node . --help, we should see the following output from our CLI script:

Options:
  --version  Show version number                                       [boolean]
  --help     Show help                                                 [boolean]

Adding a command module

Yargs allows us to define commands for our CLI tool, for example, we could define an init command that gets executed like this:

node . init

To add a command module, let’s start by creating src/commands/init.ts and adding the following content:

1import { CommandModule, Argv, ArgumentsCamelCase } from 'yargs' 2import chalk from 'chalk' 3 4// the builder function can be used to define additional 5// command line arguments for our command 6function builder(yargs: Argv) { 7 return yargs.option('name', { 8 alias: 'n', 9 string: true 10 }) 11} 12 13// the handler function will be called when our command is executed 14// it will receive the command line arguments parsed by yargs 15function handler(args: ArgumentsCamelCase) { 16 console.log(chalk.green('Hello world!'), args) 17} 18 19// name and description for our command module 20const init: CommandModule = { 21 command: 'init', 22 describe: 'Init command', 23 builder, 24 handler 25} 26 27export default init 28

We should also change our src/index.ts to let yargs know about our new command module:

1#! /usr/bin/env node 2import yargs from 'yargs' 3import { hideBin } from 'yargs/helpers' 4import init from './commands/init' 5 6yargs(hideBin(process.argv)) 7 .command(init) // registers the init command module 8 // or to register everything in the commands dir: .commandDir('./commands') 9 .demandCommand() 10 .help() 11 .argv 12

Now if we run node . init --name Rense in our terminal, we will see the following output:

Hello world! Rense

Publishing to NPM

To publish our new CLI tool to NPM, we first need to add a git repository. Run the git init command in our project root directory and then create a repository on github or any other git host and add the remote url to our project with: git remote add origin git@github.com:[username]/[repository_name].git now add our files to the registry with git add . and push our initial commit: git commit -m "init" && git push.

We will use np for publishing, let’s install some additional dependencies to make this happen:

npm i np cross-env --save-dev

We’re going to add a release script to our package.json, but we need a way to prevent someone from accidentally running npm publish in the root of our project. To achieve this we can create this prepublish.js file with the following content:

1const RELEASE_MODE = !!(process.env.RELEASE_MODE) 2 3if (!RELEASE_MODE) { 4 console.log('Run `npm run release` to publish the package') 5 process.exit(1) 6} 7

Now we can add the following scripts to our package.json:

1[ 2 "prepublishOnly": "node prepublish.js", 3 "release": "cross-env RELEASE_MODE=true np --no-tests" 4] 5

And then run npm run release and answer the questions that np asks us. The prePublishOnly script will prevent anyone from manually running npm publish.

Using our CLI tool

After the CLI tool is published to NPM, we can install it anywhere we want to use it:

1npm i my-cli-demo 2my-cli-demo init --name Rense 3
  • Yargs documentation on command modules
  • np documentation
  • Documentation on NPM bin option

Table of contents

Project setup Typescript configuration Parsing command line arguments Adding a command module Publishing to NPM Using our CLI tool Links