How To Build A Command-Line Tool With NodeJS - A step-by-step guide
Ibrahim Adeniyi
Posted on January 12, 2020
This post will guide developers on building CLI tools with node.js. You will also learn how to publish the tool to NPM. Node.js allows us to build command-line tools using JavaScript. There is a rich node.js package ecosystem as shown by the npm registry.
Building CLI tools that people can use is a good way to increase your coding and problem-solving skills. In this post, we'll explore how I created a cli tool that checks if a website is up or down. You can find the source code here
STEPS
Have A Plan
- Run the app on nodejs
- Get my arguments from the terminal
- Check the website status from isitup
- Return the response to the terminal
- Create an option to launch the website if it is up.
Create A Node App
Let's create a folder for our project and navigate to the root of the project directory on our terminal.
mkdir cli-project && cd cli-project
Initialize a node project
npm init -y
.
This creates a node app with a package.json structure like this:
{
"name": "cli-project",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "",
"license": "ISC",
}
Create an index.js file
touch index.js
Open this file and print "Hello, here is my first CLI tool" to the console
\\ index.js
console.log("Hello, here is my first CLI tool")
Navigate back to the terminal now and run node index
$ node index
Hello, here is my first CLI tool
Now that your node app runs, it is time to turn it into a shell command.
In order to invoke our index.js file directly without the node command, put this #!/usr/bin/env node
at the top of our index.js file
\\ index.js
#!/usr/bin/env node
console.log("Hello, here is my first CLI tool")
Next, we are going to add a bin property to our package.json file. However, our project is going to run on a single file so we won't use the bin property to specify command names. We will be using the name property for that.
{
"name": "cli-project",
"version": "1.0.0",
// ...
"bin": "./index.js",
// ...
}
If you run cli-project
in the project's directory now it should return
$ cli-project
Hello, here is my first CLI tool
We will make two changes now. We don't want our CLI name to be cli-project
. So we will change the value of the package.json name
property to webcheck
{
"name": "webcheck",
// ...
}
Our shell command is still local. It is time to make it global. Run
npm link
Navigate away from the root of our project directory and run webcheck
from any directory. You should see this as the result.
$ webcheck
Hello, here is my first CLI tool
CONGRATULATIONS!!! You just created your first shell command with a node app. This can be pushed to NPM for users to download and run but since we are only halfway done with project, I would recommend waiting until our node app is done before publishing.
Parse Arguments From The Terminal
To parse our arguments from the terminal, we will be using a built-in node module, argv. According to the official nodejs docs, the process.argv property returns an array containing the command line arguments passed when the Node.js process was launched. The first element will be process.execPath. The second element will be the path to the JavaScript file being executed. The remaining elements will be any additional command line arguments. So, any argument we pass to the terminal will be the third element of the array. Edit your index.js file to look like this.
\\ index.js
#!/usr/bin/env node
console.log(processs.argv);
Run your app on the terminal. The output should resemble this.
$ webcheck
[
'C:\\Program Files\\nodejs\\node.exe',
'C:\\Users\\adeniyi\\Desktop\\Projects\\cli-project\\index'
]
Now add an additional argument to your command and your output should resemble this.
$ webcheck file
[
'C:\\Program Files\\nodejs\\node.exe',
'C:\\Users\\adeniyi\\Desktop\\Projects\\cli-project\\index',
'file'
]
Note: The more arguments you append, the larger the array becomes. For our purpose, we will limit our argument to a string and parse it into our project as the third element of the array.
It is time to parse this argument into our app and fetch information from isitup api.
Open your index.js file and put this code.
#!/usr/bin/env node
const fetch = require("node-fetch");
// console.log(process.argv);
const website = process.argv[2];
function CheckWeb(name) {
const info =fetch(`https://isitup.org/${name}.json`)
.then(response => response.json());
info.then(function(result) {
if (result.response_code == 200) {
console.log('website is up and running')
} else {
console.log('website is down')
}
}
CheckWeb(website);
We required the node-fetch
package to help us get our data from the isitup api because node doesn't support native javascript fetch. Run npm install node-fetch
.
Our CheckWeb function takes in a name argument and fetches the appropriate response from the API. We are now passing our command line argument into the function. Let's head over to the terminal and see some code running.
$ webcheck duckduckgo.com
website is up and running
Yay!!!
$ webcheck google.com
website is down
Wait what?!
Let's try to figure out what went wrong here. My favorite debugging tool to the rescue(console).
#!/usr/bin/env node
//...
function CheckWeb(name) {
const info =fetch(`https://isitup.org/${name}.json`)
.then(response => response.json());
info.then(function(result) {
console.log(result)
}
CheckWeb(website);
Run the app from the terminal again
$ webcheck google.com
{
domain: "google.com",
port: 80,
status_code: 1,
response_ip: "216.58.210.206",
response_code: 301,
response_time: 0.008
}
So, the 301 redirect is considered a best practice for upgrading users from HTTP to HTTPS. We need our app to know this and tell us google is up. There are two ways we can go about this; a chain of if else statements navigating the appropiate response codes or looking fore null
response codes
This
#!/usr/bin/env node
const fetch = require("node-fetch");
// console.log(process.argv);
const website = process.argv[2];
function CheckWeb(name) {
const info =fetch(`https://isitup.org/${name}.json`)
.then(response => response.json());
info.then(function(result) {
if (result.response_code == null) {
console.log('website is down')
} else {
console.log('website is up and running')
}
}
CheckWeb(website);
Run your app
$ webcheck google.com
website is up and running
OR This
#!/usr/bin/env node
const fetch = require("node-fetch");
// console.log(process.argv);
const website = process.argv[2];
function CheckWeb(name) {
const info =fetch(`https://isitup.org/${name}.json`)
.then(response => response.json());
info.then(function(result) {
if (result.response_code == 200) {
console.log('\x1b[32m%s\x1b[0m', 'website is up and running');
} else if (result.response_code == 301) {
console.log('\x1b[34m%s\x1b[0m', 'website has been moved permanently but is up');
} else if (result.response_code == 302){
console.log('\x1b[34m%s\x1b[0m', 'temporary redirect, website is up');
} else if (result.response_code == 403) {
console.log('\x1b[33m%s\x1b[0m', 'information not found');
}
else {
console.log('\x1b[31m%s\x1b[0m', 'website is down')
}
});
CheckWeb(website);
This '\x1b[31m%s\x1b[0m'
snippet and others like it you see in the console statement determine the color of our response message.
Run your app
$ webcheck google.com
website has been moved permanently but is up
We can publish the first version of our cli tool to NPM now. You need to create a .npmignore
file. Copy this into the file.
//.npmignore
node_modules/
This ensures that you don't publish node modules with the package. Now, run
npm publish
If you have not logged in to npm from your terminal before, do so first
npm login
Cheers mate. Users can now head to NPM to search for and download your cli tool.
Launching The Website From The Terminal
For this, we will be requiring open
a package that will help us open our url. Then we will write a function we can call to launch website.
npm install open
Edit your index.js file
#!/usr/bin/env node
const fetch = require("node-fetch");
const open = require("open");
const website = process.argv[2];
function CheckWeb(name) {
const info =fetch(`https://isitup.org/${name}.json`)
.then(response => response.json());
info.then(function(result) {
function openWebSite () {
setTimeout(function()
{ open(`https://${result.domain}`); }, 1000);
};
if (result.response_code == 200) {
console.log('\x1b[32m%s\x1b[0m', 'website is up and running');
openWebSite();
} else if (result.response_code == 301) {
console.log('\x1b[32m%s\x1b[0m', 'website has been moved permanently but is up');
openWebSite();
} else if (result.response_code == 302){
console.log('\x1b[34m%s\x1b[0m', 'temporary redirect, website is up');
openWebSite();
} else if (result.response_code == 403) {
console.log('\x1b[33m%s\x1b[0m', 'information not found');
openWebSite();
}
else {
console.log('\x1b[31m%s\x1b[0m', 'website is down')
}
});
}
}
CheckWeb(website);
The function openWebsite launches the checked website in your default browser automatically from the terminal. However, we want our users to have the power to open the website or not.
We are going to install two packages arg
and inquirer
. We shall be parsing the command line argument into options with arg
and inquirer
to prompt users for values.
npm install arg inquirer
We are going to structure our index.js file like this
#!/usr/bin/env node
const fetch = require("node-fetch");
const open = require('open');
const arg = require('arg');
const inquirer = require('inquirer');
function ParseCliArgsIntoOptions() {
const args = arg(
{
'--website': Boolean,
'--yes': Boolean,
'-w': '--website',
'-y': '--yes',
},
{
argv: process.argv.slice(2),
}
);
return {
website: args['--website'] || false,
};
}
async function PromptForOptions(options) {
const questions = [];
if (!options.website) {
questions.push({
type: 'confirm',
name: 'website',
message: 'Open the website on your browser?',
default: false,
});
}
const answers = await inquirer.prompt(questions);
return {
...options,
website: options.website || answers.website,
};
}
async function LaunchWebsite(result) {
let options = ParseCliArgsIntoOptions();
options = await PromptForOptions(options);
if (options.website == true) {
open(`https://${result.domain}`);
}
}
const website = process.argv[2];
function CheckWeb(name) {
// ....
}
What we have done is create a LaunchWebsite function that takes in two other functions ParseCliArgsIntoOptions()
which provides you a boolean yes/no option to the question prompted by the PromptForOptions()
function. If the chosen options is true
i.e 'yes', the website is opened.
We will now inject the LaunchWebsite function into our Checkweb function and pass the result of our fetch
operation down to it.
#!/usr/bin/env node
const fetch = require("node-fetch");
const open = require('open');
const arg = require('arg');
const inquirer = require('inquirer');
function ParseCliArgsIntoOptions() {
//...
}
async function PromptForOptions(options) {
//...
}
async function LaunchWebsite(result) {
//...
}
function CheckWeb(name) {
const info =fetch(`https://isitup.org/${name}.json`)
.then(response => response.json());
info.then(function(result) {
if (result.response_code == 200) {
console.log('\x1b[32m%s\x1b[0m', 'website is up and running');
LaunchWebsite(result)
} else if (result.response_code == 301) {
console.log('\x1b[32m%s\x1b[0m', 'website has been moved permanently but is up');
LaunchWebsite(result)
console.log('\x1b[34m%s\x1b[0m', 'website has been moved permanently but is up');
LaunchWebsite(result)
} else if (result.response_code == 302){
console.log('\x1b[34m%s\x1b[0m', 'temporary redirect, website is up');
LaunchWebsite(result)
} else if (result.response_code == 403) {
console.log('\x1b[33m%s\x1b[0m', 'information not found');
LaunchWebsite(result)
}
else {
console.log('\x1b[31m%s\x1b[0m', 'website is down')
}
});
}
}
CheckWeb(website);
If you run your shell command on the terminal now, this should happen
$ webcheck google.com
website has been moved permanently but is up
? Open the website on your browser? (y/N)
Great! The journey is almost over.
Let us round off by handling errors for people who might forget to add the website extension. The website might be up but this will definitely return it as down.
$ webcheck google
website is down
There are many ways to approach this. You could write create an array of all the possible extensions (over 400) and write a regex function that searches for any of the array arguments in our website string. A bit unnecessary if you ask me. Or you could just search for the substring '.' in our argument as done below.
#!/usr/bin/env node
const fetch = require("node-fetch");
const open = require('open');
const arg = require('arg');
const inquirer = require('inquirer');
function ParseCliArgsIntoOptions() {
//...
}
async function PromptForOptions(options) {
//...
}
async function LaunchWebsite(result) {
//...
}
function CheckWeb(name) {
if (name.indexOf('.') > -1) {
const info =fetch(`https://isitup.org/${name}.json`)
.then(response => response.json());
info.then(function(result) {
//...
});
} else {
console.log('\x1b[31m%s\x1b[0m', 'please append your url extension e.g(mouse.com)')
}
}
CheckWeb(website);
At the terminal.
$ webcheck google
please append your url extension e.g(mouse.com)
Now, let's publish our updated tool again. You have to update the version number. Run
npm version 1.1.0
Then push to NPM
npm publish
Conclusion
Our CLI tool is up and running on NPM.
If you have any questions regarding this, I am available in the comment section. Also, feel free to correct or add anything I might have missed. Do not hesitate to send me a message by email or on twitter.
Once again, you can find the source code here.
Thanks!
Posted on January 12, 2020
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.