DEV Community

Cover image for Create a Node.js command-line library with NRWL NX workspace
Eran Sakal
Eran Sakal

Posted on • Originally published at sakalim.com

Create a Node.js command-line library with NRWL NX workspace

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

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
  2. Obsidian PDF album creator
  3. 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, I recommend you to give it a try.

Setting up the workspace

Create a new NX workspace.

npx create-nx-workspace@latest
Enter fullscreen mode Exit fullscreen mode

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

Now, run the following commands to create a new node-based project in the workspace.

npm install -D @nrwl/node

npx nx g @nrwl/node:lib cli --publishable --importPath=obsidian-album
Enter fullscreen mode Exit fullscreen mode

In file packages/cli/package.json:

  • set version to 1.0.0.
  • make the script executable
"bin": "./src/cli.js"
Enter fullscreen mode Exit fullscreen mode

Note: once deployed to NPM, 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
"scripts": {  
  "build": "nx run cli:build",  
  "watch": "nx run cli:build --watch",
  "cli": "node dist/packages/cli"
},
Enter fullscreen mode Exit fullscreen mode

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

In file packages/cli/tsconfig.lib.json
This step is optional. If you plan to mix .ts files with .js files:

"compilerOptions": {
    "allowJs": true
}
Enter fullscreen mode Exit fullscreen mode

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.

"targets": {
    "build": {
        "updateBuildableProjectDepsInPackageJson": true,  
        "buildableProjectDepsInPackageJsonType": "dependencies"
    }
}
Enter fullscreen mode Exit fullscreen mode

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

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 - Required. A library that lets you define the commands and their arguments, options, help, etc.
  2. debug - npm - Required. A popular library to write debug logs.
  3. fast-glob - npm - Recommended. A high-speed and efficient glob library.
  4. inquirer - npm - Recommended. A collection of common interactive command line user interfaces.

Install the required libraries (feel free to add a few more).

npm i commander debug
Enter fullscreen mode Exit fullscreen mode

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.

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

The src/doSomething/command.ts file

Please copy the following template and adjust it to your needs.

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

the src/cli.ts file

Create the file and add the following:

#! /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);
Enter fullscreen mode Exit fullscreen mode

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.

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

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:

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

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.

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

  • myapp property in package.json
  • .myapprc file in JSON or YAML format
  • .myapprc.json.myapprc.yaml.myapprc.yml.myapprc.js, or .myapprc.cjs file
  • myapprcmyapprc.jsonmyapprc.yamlmyapprc.ymlmyapprc.js or myapprc.cjs file inside a .config subdirectory
  • myapp.config.js or myapp.config.cjs CommonJS module exporting an object

Interact with the users using a friendly interface

The library inquirer - npm 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 to support automatic deployments using GitHub Actions and Semantic Releases.

Read 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 if you are using GitHub packages in your workflow.


Photo by Paul Esch-Laurent on Unsplash

Top comments (0)