InfoI recently heard about oclif: The Open CLI Framework · Create command line tools your users love, but I didn't have a chance to play with that so far. I will share my insights about it once I will.
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:
- Create an NX-based workspace
- Create a node project in the NX workspace for Typescript
- Expose the project as node.js CLI execution
- Transpile to
ESM modules
- Split your code into commands
There are some follow-ups articles at the end of the article for:
- Deploy the library automatically to the NPM registry using GitHub Actions.
- 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:
- obsidian-album
- Obsidian PDF album creator
- 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
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.
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
"bin": "./src/cli.js"
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"
},
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:
"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.
"targets": {
"build": {
"updateBuildableProjectDepsInPackageJson": true,
"buildableProjectDepsInPackageJsonType": "dependencies"
}
}
Transpile the library to ES Module
NoticeWhen writing the ES Module library, you should include the extension
.js
when importing files. For exampleimport { 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
TipBefore 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.
- commander - npm - Required. A library that lets you define the commands and their arguments, options, help, etc.
- debug - npm - Required. A popular library to write debug logs.
- fast-glob - npm - Recommended. A high-speed and efficient glob library.
- 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
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);
}
}
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;
}
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);
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
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
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
WarningThe 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:
- a
myapp
property inpackage.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
ormyapprc.cjs
file inside a.config
subdirectory - a
myapp.config.js
ormyapp.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.