Create a Node.js command-line library with NRWL NX workspace
Tags: javascript, node, npm, tutorial
Published: 2023-01-09 05:00:00


## Why bother writing yet another article about CLI libraries
There are countless articles about creating a Node.js command line library available, and this article doesn't try to invent the wheel. It was written as a unified workflow tailored to the technology stack our group adopts and uses across products: NRWL NX workspace, semantic release, GitHub actions, GitHub packages, multiple distribution channels (a.k.a feature/pre-release branches), and Netlify/Vercel services. 

In this article, I'm sharing the precise flow we are doing at [Kaltura](https://corp.kaltura.com/) in our development group when creating a command line library. This unified stack helps us share knowledge between teams and reduce the time people spend working on dev-ops and when sharing libraries between projects.

## What in it for me reading this article
This article will guide you how to:
1. Create an NX-based workspace
2. Create a node project in the NX workspace for Typescript
3. Expose the project as node.js CLI execution
4. Transpile to  `ESM modules` 
5. Split your code into commands

There are some follow-ups articles at the end of the article for:
1. Deploy the library automatically to the NPM registry using GitHub Actions.
2. Run the library in the dev machine using environment arguments

A workable example can be found in [esakal/obsidian-album](https://github.com/esakal/obsidian-album)

## About the article code snippets
The code snippets in this article contain some terminologies relevant to the demo project. You should look for the following in your code and make sure you didn't accidentally leave some of them:
1. obsidian-album
4. Obsidian PDF album creator
5. Create a printable styled PDF album from Obsidian

## Prerequisites
Make sure you are using node version >= 16. 

As a side note, to be able to use multiple Node versions on your machine, If you are not using [nvm-sh/nvm: Node Version Manager](https://github.com/nvm-sh/nvm#automatically-call-nvm-use), I recommend you to give it a try.

## Setting up the workspace
Create a new NX workspace.
```bash
npx create-nx-workspace@latest
```

When asked, select the option `integrated monorepo > ts`.

The create-nx-workspace script creates a folder with the project name you provided. Enter the newly created folder.

Add a `.nvmrc` file and set the content as a number of the desired Node.js version. For example, if you are using node v18:
```
18
```

Now, run the following commands to create a new node-based project in the workspace. 
```bash
npm install -D @nrwl/node

npx nx g @nrwl/node:lib cli --publishable --importPath=obsidian-album
```

In file `packages/cli/package.json`:
- set version to `1.0.0`.
- make the script executable
```JSON
"bin": "./src/cli.js"
```

Note: once deployed to [NPM](https://npmjs.com/), you will then be able to run the library using its name, for example by running `npx obsidian-album --help`

- add some scripts that will help you during the development
```JSON
"scripts": {  
  "build": "nx run cli:build",  
  "watch": "nx run cli:build --watch",
  "cli": "node dist/packages/cli"
},
```

In file `packages/cli/tsconfig.lib.json`
Add a flag to avoid the Typescript error when a library doesn't export a default object.
```
compilerOptions { "allowSyntheticDefaultImports": true }}
``` 

In file `packages/cli/tsconfig.lib.json`
This step is optional. If you plan to mix `.ts` files with `.js` files:
```JSON
"compilerOptions": {
	"allowJs": true
}
```

In file `packages/cli/project.json`
You should instruct NX to include the dependencies used by the package in the generated package.json when building the package. 

```JSON
"targets": {
	"build": {
		"updateBuildableProjectDepsInPackageJson": true,  
		"buildableProjectDepsInPackageJsonType": "dependencies"
	}
}
```

### Transpile the library to ES Module

> When writing the ES Module library, you should include the extension `.js` when importing files. For example `import { rootDebug } from './utils.js'`

To import ES Module libraries, your library should also be ES Module. See [@nrwl/node application not transpiling to esm · Issue #10296 · nrwl/nx](https://github.com/nrwl/nx/issues/10296) for more information.

In file `packages/cli/package.json`:
add `"type": "module"`.

In file `packages/cli/tsconfig.lib.json`
Change the "module" value to `esnext`.

In file  `tsconfig.base.json`:
Change the "target" compiler value to `esnext`.

## Create the initial command of the CLI

> Before continuing with the guide, this is a good time to commit your workspace to Github.

In the previous section, you created a workspace and prepared it to your commands. Now it is time to add the command.

The recommended structure for the library:
```
packages/cli/src                           (folder)
	┣ index.ts
	┣ cli.ts
	┣ utils.ts
	┗ {command-name}                       (folder)
		┣ any-file-relevant-to-command.ts
		┗ command.ts	
	┗ {another-command-name}               (folder)
		┗ command.ts	
```

In this article, we will create a command named `doSomething` that does nothing besides writing to the console.

### Install recommended libraries
Many excellent libraries can be used to provide a rich and friendly command-line user experience.

In this article, we will install a few mandatory libraries.
1. [commander - npm](https://www.npmjs.com/package/commander) - Required. A library that lets you define the commands and their arguments, options, help, etc.
2. [debug - npm](https://www.npmjs.com/package/debug) - Required. A popular library to write debug logs.
3. [fast-glob - npm](https://www.npmjs.com/package/fast-glob) - Recommended. A high-speed and efficient glob library.
4. [inquirer - npm](https://www.npmjs.com/package/inquirer) - Recommended. A collection of common interactive command line user interfaces.

Install the required libraries (feel free to add a few more).
```bash
npm i commander debug
```

### Remove unused files
in the project, delete the `packages/cli/src/lib` folder, which was added when you created the node package.

Clear the content from the `packages/cli/src/index.ts` file. Keep the file as you might need it later, but it can be empty at the moment.

### Add the initial command code

#### The `src/utils.ts` file
Copy the following content into the utils file.
```ts
import Debug from 'debug';  
  
// TODO replace `obsidian-album` with a friendly short label that describe best your library  
export const rootDebug = Debug('obsidian-album')  
  
export const printVerboseHook = (thisCommand) => {  
  
  const options = thisCommand.opts();  
  
  if (options.verbose) {  
    Debug.enable('obsidian-album*');  
    rootDebug(`CLI arguments`);  
    rootDebug(options);  
  }  
}
```

#### The `src/doSomething/command.ts` file
Please copy the following template and adjust it to your needs.

```ts
import * as fs from "fs";  
import { Command }  from 'commander';  
import { printVerboseHook, rootDebug } from '../utils.js';  
import * as process from "process";  
  
// TODO general: remember to name the folder of this file as the command name  
// TODO general: search all the occurrences of `doSomething` and replace with your command name  
  
const debug = rootDebug.extend('doSomething')  
const debugError = rootDebug.extend('doSomething:error')  
  
export const doSomethingCommand = () => {  
  const command = new Command('doSomething');  
  command  
    .argument('[path]', "directory to do something with")  
    .option('--verbose', 'output debug logs',false)  
    .option('--target <name>', 'the target name', 'aws')  
    // .requiredOption('--includeDirectories', 'copy directories')  
    .hook('preAction', printVerboseHook)  
    .action(async(path, options) => {  
      if (path && !fs.existsSync(path)) {  
        debugError('invalid path provided')  
        process.exit(1)  
      }  
  
      debug(`Something important is happening now....`)  
    });  
  return command;  
}
```

#### the `src/cli.ts` file
Create the file and add the following:
```ts
#! /usr/bin/env node  
import {Command} from 'commander';  
import {doSomethingCommand} from "./doSomething/command.js";  
  
const program = new Command();  
program  
  .name('Obsidian PDF album creator')  
  .description('Create printable styled PDF album from Obsidian')  
  
program.addCommand(doSomethingCommand());  
  
program.parse(process.argv);
```

## Test the command
Run the following command `npm run cli -- doSomething --verbose`.

**Note!** the additional `--` after the `npm run cli` is used to signal NPM to send all the remaining arguments to the underline script, meaning our node CLI library.

```bash
> obsidian-album@1.0.0 cli
> node dist/packages/cli/src/cli doSomething --verbose

  obsidian-album CLI arguments +0ms
  obsidian-album { verbose: true, target: 'aws' } +1ms
  obsidian-album:doSomething Something important is happening now.... +0ms
```

### Test the command #2
You can test it in a way that resembles the deployed application's behavior.

Make sure you build your project.

In the terminal, navigate to `dist/packages/cli` and run the `npm link` command.

Once done, you can navigate back to the root folder.

Use npx to run the library. For example, `npx obsidian-album`:

```bash
Usage: Obsidian PDF album creator [options] [command]

Create a printable styled PDF album from Obsidian

Options:
  -h, --help                    display help for command

Commands:
  doSomething [options] [path]
  help [command]                display help for command
```

### Test the command #3
Once deployed to the NPM registry, you can run it without downloading the library using NPX. This is a recommended way if your library is not tight the workflow of the libraries/apps that consume it.

## Providing powerful UX that people will appreciate

> The javascript ecosystem is amazing and lets you make your application shine by consuming other libraries. Still, remember that you increase the potential for security vulnerabilities when you rely more and more on 3rd party libraries.

I'm using two libraries to improve my project's UX significantly. You can check my usage with them in [esakal/obsidian-album](https://github.com/esakal/obsidian-album).

### Multiple ways to configure your library
There is a fantastic library [davidtheclark/cosmiconfig: Find and load configuration from a package.json property, rc file, or CommonJS module](https://github.com/davidtheclark/cosmiconfig) that does all the tedious work for you. 

Cosmiconfig searches for and loads configuration for your program. For example, if your module's name is `myapp`, cosmiconfig will search up the directory tree for configuration in the following places:

-   a `myapp` property in `package.json`
-   a `.myapprc` file in JSON or YAML format
-   a `.myapprc.json`, `.myapprc.yaml`, `.myapprc.yml`, `.myapprc.js`, or `.myapprc.cjs` file
-   a `myapprc`, `myapprc.json`, `myapprc.yaml`, `myapprc.yml`, `myapprc.js` or `myapprc.cjs` file inside a `.config` subdirectory
-   a `myapp.config.js` or `myapp.config.cjs` CommonJS module exporting an object

### Interact with the users using a friendly interface
The library [inquirer - npm](https://www.npmjs.com/package/inquirer) is a collection of common interactive command line user interfaces. Some people struggle with arguments, especially when having many of them. Instead, they prefer to interact with the library, and the inquirer does precisely that. 

It works great for `create-react-app`, `create-nx-workspace`, and many others, so it should also work for you.

## Whats next
That is it. You are now ready to add the library logic. Feel free to reach out and ask questions in dev.to 

## Additional resources
Read [How to deploy automatically to NPM and Github packages from NRWL NX workspace](https://sakalim.com/content/how-to-deploy-automatically-to-npm-and-github-packages-from-nrwl-nx-workspace) to support automatic deployments using GitHub Actions and Semantic Releases.

Read [Handy tips when working on CLI library during development](https://sakalim.com/content/handy-tips-when-working-on-cli-library-during-development) to learn about some helpful development techniques. 

Read [How to use private GitHub packages in your repository and with Github Actions](https://sakalim.com/content/how-to-use-private-github-packages-in-your-repository-and-with-github-actions) if you are using GitHub packages in your workflow.

---

Photo by <a href="https://unsplash.com/@pinjasaur?utm_source=unsplash&utm_medium=referral&utm_content=creditCopyText">Paul Esch-Laurent</a> on <a href="https://unsplash.com/photos/oZMUrWFHOB4?utm_source=unsplash&utm_medium=referral&utm_content=creditCopyText">Unsplash</a>