Introduction
In this guide, we are going to:
- Create a simple interactive CLI that prompts the user for a URL and opens that in a browser.
- Implement the option to skip the interactive part by providing arguments directly to the CLI (like —help, —force, etc.)
- Introduce the basic tools that you should know to create powerful NodeJS CLI’s with ease.
All the tools we are going to use are optional. You can choose and combine only the ones you need. They are also very customizable on their own - check out their full documentation to discover more awesomeness.
TL;DR The full project is available on GitHub.
Getting Started
To get started, we first need to create a standard Node project:
$ mkdir nodejs-cli
$ cd nodejs-cli
$ npm init -y
Rename name
and add bin
in your package.json file:
{
"name": "devimal-cli","version": "1.0.0",
"description": "",
"main": "index.js",
"bin": "./index.js","scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "",
"license": "ISC"
}
Create an index.js file and add a shebang line at the very top:
#!/usr/bin/env node
Creating the CLI Script
For the dynamic portion of our CLI, we are going to use a fun package called Inquirer.
To install it, open a terminal window and run:
$ npm install --save inquirer
Now let’s begin creating our CLI script in index.js.
We want to create an async
function called run
since prompting the user for input is an asynchronous action.
One of the cool things about Inquirer is that is works great with async/await
.
Let’s add a confirmation prompt:
#!/usr/bin/env node
async function run() { const { openDevimal } = await inquirer.prompt({ type: 'confirm', name: 'openDevimal', message: 'Would you like to visit devimalplanet.com?', default: true }); console.log(openDevimal);}run();
Now when we run our script, it prints:
$ node index.js
? Would you like to visit devimalplanet.com? $ Yes
true
Nice! Now let’s add some more functionality. When the user answers “no” we want to prompt him with a new question.
We can accomplish that like so:
#!/usr/bin/env node
const inquirer = require('inquirer');
async function run() {
console.log('Hi! 👋 Welcome devimal-cli!'); let urlToVisit = 'devimalplanet.com';
const { openDevimal } = await inquirer.prompt({
type: 'confirm',
name: 'openDevimal',
message: 'Would you like to visit devimalplanet.com?',
default: true
});
if (!openDevimal) { // not opening devimalplanet.com const { someFunUrl } = await inquirer.prompt({ type: 'input', name: 'someFunUrl', message: '😢 No? Which URL would you like to visit?', validate: function(input) { return ( /^(?:http(s)?:\/\/)?[\w.-]+(?:\.[\w.-]+)+[\w\-._~:/?#[\]@!$&'()*+,;=.]+$/.test( input ) || 'Please enter a valid URL.' ); } }); urlToVisit = someFunUrl; } console.log(urlToVisit);}
run();
Cool stuff! This example is simple, but you can already see how async/await
will help readability by avoiding chaining .then()
’s when your CLI begins to grow.
Also, notice that Inquirer makes it easy to validate the user’s input via the validate
function. We return true
if it passes, or a string with the error message if it fails. I got the URL regex from here.
You can see how the project looks like at this stage by clicking here.
Opening the URL
Now that we have seen how to do the interactive part of the CLI, let’s add some code to open the URLs in the browser. In your own CLI, you could replace that with any other code of your choice.
For opening the chosen URL’s, we are going to use a handy package called open.
$ npm install --save open
Adding open
to our index.js
:
#!/usr/bin/env node
const inquirer = require('inquirer');
const open = require('open');
async function run() {
console.log('Hi! 👋 Welcome devimal-cli!');
let urlToVisit = 'devimalplanet.com';
const { openDevimal } = await inquirer.prompt({
type: 'confirm',
name: 'openDevimal',
message: 'Would you like to visit devimalplanet.com?',
default: true
});
if (!openDevimal) {
// not opening devimalplanet.com
const { someFunUrl } = await inquirer.prompt({
type: 'input',
name: 'someFunUrl',
message: '😢 No? Which URL would you like to visit?',
validate: function(input) {
return (
/^(?:http(s)?:\/\/)?[\w.-]+(?:\.[\w.-]+)+[\w\-._~:/?#[\]@!$&'()*+,;=.]+$/.test(
input
) || 'Please enter a valid URL.'
);
}
});
urlToVisit = someFunUrl;
}
urlToVisit = urlToVisit.startsWith('http') ? urlToVisit : 'https://' + urlToVisit; await open(urlToVisit);}
run();
Now you can open devimalplanet.com:
$ node index.js
? Would you like to visit devimalplanet.com? $ Yes
You can see how the project looks like at this stage by clicking here.
Making the Script Available in the Command Line
Alright! Now our script does as advertised, but we always have to call node <path to script>
. We don’t want that.
CLIs like Angular CLI allow us to just type ng <cmd>
in any terminal window. That’s what we want, and npm
makes it very easy:
$ npm link
Now we can use our script from the terminal by invoking the name
property of our pakcage.json
(we renamed it earlier, remember?):
$ devimal-cli
Hi! 👋 Welcome devimal-cli!
? Would you like to visit devimalplanet.com? (Y/n)
Yeeeeah! That’s what we are talking about!
With the above knowledge you can already create some CLIs, but if you wish to learn a bit more, read on.
You can see how the project looks like at this stage by clicking here.
Bonus Tip #1: Skipping the Interactive Part
More developed CLIs often allow you to skip the interactive questions by passing command line arguments.
Let’s add -y and -u
$ npm install --save commander
#!/usr/bin/env node
const inquirer = require('inquirer');
const open = require('open');
const package = require('./package.json');
const commander = require('commander');
const devilmalUrl = 'devimalplanet.com';
const program = setupCommander();
async function run() {
const ranWithArgs = program.skipPrompts || program.url;
if (!ranWithArgs) return interactiveRun();
const url = typeof ranWithArgs === 'string' ? ranWithArgs : devilmalUrl;
return staticRun(url);
}
async function interactiveRun() {
console.log('Hi! 👋 Welcome devimal-cli!');
const { openDevimal } = await inquirer.prompt({
type: 'confirm',
name: 'openDevimal',
message: 'Would you like to visit devimalplanet.com?',
default: true
});
const urlToVisit = openDevimal
? devilmalUrl
: (
await inquirer.prompt({
type: 'input',
name: 'someFunUrl',
message: '😢 No? Which URL would you like to visit?',
validate: function(input) {
return (
/^(?:http(s)?:\/\/)?[\w.-]+(?:\.[\w.-]+)+[\w\-._~:/?#[\]@!$&'()*+,;=.]+$/.test(
input
) || 'Please enter a valid URL.'
);
}
})
).someFunUrl;
await openUlr(urlToVisit);
}
async function staticRun(url) {
// for now just opens url
await openUlr(url);
}
async function openUlr(url) {
url = url.startsWith('http') ? url : 'https://' + url;
await open(url);
}
function setupCommander() {
const program = new commander.Command();
program
.version(package.version)
.option(
'-y, --skip-prompts',
'skips questions, directly opens devimalplanet.com'
)
.option('-u, --url <URL>', 'skips questions, directly opens provided URL')
.parse(process.argv);
return program;
}
run();
A quick look at the changes:
- In
setupCommander()
we initializedCommander
in a variable calledprogram
. We then gave ourprogram
its version (frompackage.json
), the-y
and-u <URL>
options, and told it to parse the process arguments. - We split our code into
interactiveRun()
andstaticRun()
. run()
now only controls whetherstaticRun()
orinteractiveRun()
should be called.
For this guide’s simplicity, we are going to keep all code in a single file. But keep in mind that as your CLI begins to grow, you should start splitting your code in different modules/files. Just like in any other type of development!
Also, Commander is a very powerful tool. Check out their docs to see more awesome stuff it can do.
Click here to browse the full project state up to this point.
Bonus Tip #2: Adding ASCII Art
This is completely cosmetic, but if you want to add some ASCII art into your CLI, you may like Figlet:
$ npm install --save figlet
#!/usr/bin/env node
const inquirer = require('inquirer');
const open = require('open');
const package = require('./package.json');
const commander = require('commander');
const figlet = require('figlet');
const devilmalUrl = 'devimalplanet.com';
const program = setupCommander();
async function run() {
const ranWithArgs = program.skipPrompts || program.url;
if (!ranWithArgs) return interactiveRun();
const url = typeof ranWithArgs === 'string' ? ranWithArgs : devilmalUrl;
return staticRun(url);
}
async function interactiveRun() {
console.log('Hi! 👋 Welcome devimal-cli!');
const { openDevimal } = await inquirer.prompt({
type: 'confirm',
name: 'openDevimal',
message: 'Would you like to visit devimalplanet.com?',
default: true
});
const urlToVisit = openDevimal
? devilmalUrl
: (
await inquirer.prompt({
type: 'input',
name: 'someFunUrl',
message: '😢 No? Which URL would you like to visit?',
validate: function(input) {
return (
/^(?:http(s)?:\/\/)?[\w.-]+(?:\.[\w.-]+)+[\w\-._~:/?#[\]@!$&'()*+,;=.]+$/.test(
input
) || 'Please enter a valid URL.'
);
}
})
).someFunUrl;
await openUlr(urlToVisit);
}
async function staticRun(url) {
console.log(await generateAsciiArt()); await openUlr(url);
}
async function openUlr(url) {
url = url.startsWith('http') ? url : 'https://' + url;
await open(url);
}
function setupCommander() {
const program = new commander.Command();
program
.version(package.version)
.option(
'-y, --skip-prompts',
'skips questions, directly opens devimalplanet.com'
)
.option('-u, --url <URL>', 'skips questions, directly opens provided URL')
.parse(process.argv);
return program;
}
async function generateAsciiArt() { return new Promise((resolve, reject) => { // figlet docs: https://www.npmjs.com/package/figlet figlet.text( 'Devimal', { font: 'ANSI Shadow', horizontalLayout: 'default', verticalLayout: 'default' }, function(err, data) { if (err) { console.log('Something went wrong...'); console.dir(err); reject(err); } resolve(data); } ); });}
run();
The result:
$ devimal-cli -y
██████╗ ███████╗██╗ ██╗██╗███╗ ███╗ █████╗ ██╗
██╔══██╗██╔════╝██║ ██║██║████╗ ████║██╔══██╗██║
██║ ██║█████╗ ██║ ██║██║██╔████╔██║███████║██║
██║ ██║██╔══╝ ╚██╗ ██╔╝██║██║╚██╔╝██║██╔══██║██║
██████╔╝███████╗ ╚████╔╝ ██║██║ ╚═╝ ██║██║ ██║███████╗
╚═════╝ ╚══════╝ ╚═══╝ ╚═╝╚═╝ ╚═╝╚═╝ ╚═╝╚══════╝
Click here to browse the full project state up to this point.
Bonus Tip #3: Add Spinners for Long Running Tasks
Bonus Tip #2 was completely cosmetic. This is one is not.
CLI feed back is important. Without it, users cannot tell the difference between a frozen script or a long running task.
By the way, don’t forget: JavaScript runs in a single thread. Use async operations for long tasks not to block the main thread.
The package we are going to use for spinners is called Ora:
$ npm install --save ora
#!/usr/bin/env node
const inquirer = require('inquirer');
const open = require('open');
const package = require('./package.json');
const commander = require('commander');
const figlet = require('figlet');
const ora = require('ora');
const devilmalUrl = 'devimalplanet.com';
const program = setupCommander();
async function run() {
const spinner = ora({ text: 'Simulating some slow async task. What a Devimal Planet...', spinner: 'earth' }).start(); await simulateSlowAsyncTask(5000); spinner.succeed('Heavy task finished!\n');
const ranWithArgs = program.skipPrompts || program.url;
if (!ranWithArgs) return interactiveRun();
const url = typeof ranWithArgs === 'string' ? ranWithArgs : devilmalUrl;
return staticRun(url);
}
async function interactiveRun() {
console.log('Hi! 👋 Welcome devimal-cli!');
const { openDevimal } = await inquirer.prompt({
type: 'confirm',
name: 'openDevimal',
message: 'Would you like to visit devimalplanet.com?',
default: true
});
const urlToVisit = openDevimal
? devilmalUrl
: (
await inquirer.prompt({
type: 'input',
name: 'someFunUrl',
message: '😢 No? Which URL would you like to visit?',
validate: function(input) {
return (
/^(?:http(s)?:\/\/)?[\w.-]+(?:\.[\w.-]+)+[\w\-._~:/?#[\]@!$&'()*+,;=.]+$/.test(
input
) || 'Please enter a valid URL.'
);
}
})
).someFunUrl;
await openUlr(urlToVisit);
}
async function staticRun(url) {
console.log(await generateAsciiArt());
await openUlr(url);
}
async function openUlr(url) {
url = url.startsWith('http') ? url : 'https://' + url;
await open(url);
}
function setupCommander() {
const program = new commander.Command();
program
.version(package.version)
.option(
'-y, --skip-prompts',
'skips questions, directly opens devimalplanet.com'
)
.option('-u, --url <URL>', 'skips questions, directly opens provided URL')
.parse(process.argv);
return program;
}
async function generateAsciiArt() {
return new Promise((resolve, reject) => {
// figlet docs: https://www.npmjs.com/package/figlet
figlet.text(
'Devimal',
{
font: 'ANSI Shadow',
horizontalLayout: 'default',
verticalLayout: 'default'
},
function(err, data) {
if (err) {
console.log('Something went wrong...');
console.dir(err);
reject(err);
}
resolve(data);
}
);
});
}
async function simulateSlowAsyncTask(ms) { return new Promise(resolve => { setTimeout(() => resolve(), ms); });}
run();
With the above changes, your terminal should look like this:
Click here to browse the full project state up to this point.
Bonus Tip #4: Add Multiple CLI Scripts
Most use cases will need only a single CLI command.
However there are times you want to split your CLI into multiple commands. You may also want to create multiple aliases for the same command.
Npm allows you to do all that with ease. Let’s add the new script file, joy.js, that generates some more ASCII art:
#!/usr/bin/env node
const figlet = require('figlet');
function generateAsciiArt() {
figlet.text(
'Enjoy!',
{
font: 'ANSI Shadow',
horizontalLayout: 'default',
verticalLayout: 'default'
},
function(err, data) {
if (err) {
console.log('Something went wrong...');
console.dir(err);
}
console.log(data);
}
);
}
generateAsciiArt();
Cool. Now we just need to modify the bin
property in the package.json file:
{
[...]
"bin": { "devimal-cli": "./index.js", "devimal-joy": "./joy.js" } [...]
}
Tell npm to link once again:
$ npm link
And voila! The new script works:
$ devimal-joy
███████╗███╗ ██╗ ██╗ ██████╗ ██╗ ██╗██╗
██╔════╝████╗ ██║ ██║██╔═══██╗╚██╗ ██╔╝██║
█████╗ ██╔██╗ ██║ ██║██║ ██║ ╚████╔╝ ██║
██╔══╝ ██║╚██╗██║██ ██║██║ ██║ ╚██╔╝ ╚═╝
███████╗██║ ╚████║╚█████╔╝╚██████╔╝ ██║ ██╗
╚══════╝╚═╝ ╚═══╝ ╚════╝ ╚═════╝ ╚═╝ ╚═╝
Click here to browse the full project state up to this point.
Bonus Tip #5: More NPM Usage
You can remove the linked scripts:
$ npm unlink
If you publish your package, you can install the CLI globally on any machine with:
$ npm install -g <package-name>
Conclusion
CLIs often grow and become very powerful tools in your development workflow.
In this guide we have created a basic interactive CLI in NodeJS. We introduced tools that help you build the functionality of your CLI, while also providing good end user experience.
Now go hack together some CLIs!