Publishing a NodeJS CLI tool to NPM
Tags: webdev, node, typescript, npm
Published: 2023-05-15 13:31:00

## 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: 

```json
{
  "name": "my-cli-demo",
  "version": "1.0.0",
  "bin": {
    "my-cli-demo": "bin/index.js" // <- command name of our cli script and the executable file it points to
  },
  "main": "bin/index.js",
  "scripts": {
    "watch": "tsc -w" // <- watch script
  },
  "license": "ISC",
  "dependencies": {
    "chalk": "^4.1.2",
    "yargs": "^17.7.2"
  },
  "devDependencies": {
    "@types/node": "^20.1.0",
    "@types/yargs": "^17.0.24",
    "typescript": "^5.0.4"
  }
}
```

### Typescript configuration

We should also add a minimal `tsconfig.json`:

```json
{
  "compilerOptions": {
    "target": "es6",
    "module": "commonjs",
    "moduleResolution": "node",
    "declaration": true, // creates d.ts type declarations
    "declarationMap": true, // creates map files for our d.ts files
    "sourceMap": true, // creates map files for our source code
    "outDir": "bin", // compile source code into "bin" folder
    "strict": true,
    "forceConsistentCasingInFileNames": true,
    "noImplicitAny": true,
    "esModuleInterop": true
  },
  "include": [
    "src/index.ts"
  ],
  "exclude": [
    "node_modules"
  ]
}
```

## 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:

```tsx
#! /usr/bin/env node
import yargs from 'yargs'
import { hideBin } from 'yargs/helpers'

yargs(hideBin(process.argv))
  .help()
  .argv
```

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:

```tsx
import { CommandModule, Argv, ArgumentsCamelCase } from 'yargs'
import chalk from 'chalk'

// the builder function can be used to define additional
// command line arguments for our command
function builder(yargs: Argv) {
  return yargs.option('name', {
    alias: 'n',
    string: true
  })
}

// the handler function will be called when our command is executed
// it will receive the command line arguments parsed by yargs
function handler(args: ArgumentsCamelCase) {
  console.log(chalk.green('Hello world!'), args)
}

// name and description for our command module
const init: CommandModule = {
  command: 'init',
  describe: 'Init command',
  builder,
  handler
}

export default init
```

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

```tsx
#! /usr/bin/env node
import yargs from 'yargs'
import { hideBin } from 'yargs/helpers'
import init from './commands/init'

yargs(hideBin(process.argv))
  .command(init) // registers the init command module
  // or to register everything in the commands dir: .commandDir('./commands')
  .demandCommand()
  .help()
  .argv
```

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:

```jsx
const RELEASE_MODE = !!(process.env.RELEASE_MODE)

if (!RELEASE_MODE) {
  console.log('Run `npm run release` to publish the package')
  process.exit(1)
}
```

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

```json
[
  "prepublishOnly": "node prepublish.js && tsc",
  "release": "cross-env RELEASE_MODE=true np --no-tests"
]
```

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:

```json
npm i my-cli-demo
my-cli-demo init --name Rense
```

## Links

- [Yargs documentation on command modules](https://github.com/yargs/yargs/blob/main/docs/advanced.md#providing-a-command-module)
- [np documentation](https://github.com/sindresorhus/np)
- [Documentation on NPM bin option](https://docs.npmjs.com/cli/v9/configuring-npm/package-json#bin)