How to build an interactive CLI tool with NodeJS

Command-Line Interfaces(CLI) are great tools for automating repetitive tasks or allowing your computer to take care of the boring stuffs.

Node.js is an interesting choice for building CLIs as you can leverage its vast ecosystem. And thanks to package managers like npm and yarn, these can be easily distributed and consumed across multiple platforms. In this post, we'll look at why you might want to write a CLI, and how to use Node.js for it.

The CLI tool we'll be building will be called Prtfy. This will simply set up a Prettifier in javascript directory. It will write the Prettier Config and prompts the user for their configuration settings.

Let's get started.

We'll familiarize ourself with the npm modules that will make coding process more simplified.

  • chalk - Terminal Styling, i.e colors etc.
  • figlet - For making large letters out of ordinary text.
  • inquirer - Collects user inputs from the command line.
  • shelljs - Portable Unix shell commands for Node.js

Let's jump to the code.

First, we need to setup a Node project. Go to the command line. and type

mkdir prtfy
cd prtfy
npm init

Follow all the prompts to get the project going. You could also skip all the prompts by simply usingnpm init -y instead of npm init. By now you should have a basic Node project with the package.json file.

Next, we'll install all the core depedencies listed above. Type the below for this.

npm install chalk figlet inquirer shelljs

index.js file

Now create an index.js file and import the installed modules.

const inquirer = require("inquirer");
const chalk = require("chalk");
const figlet = require("figlet");
const shell = require("shelljs");

Let's plan the CLI

It does a couple of things.

  • Asks user their preffered prettier config.
  • Install the prettier locally.
  • Writes the config file.
  • Configures a pre-commit hook.

With this in mind, let's wrote a pseudo-code for the this.index.js

const run = async () => {
  // show prtfy introduction
  // install GitHook
  // ask questions
  // create the files
  // configures pre-commit hook
  // show success message
};

run();

For ease, we'll have a default configuration. For additional challenge, you can ask all this from the user. Our default config will be stored in a variable.

prettierConfig = {
    trailingComma: "es5",
    tabWidth: 4,
    semi: false,
    singleQuote: true,
    useTabs: false,
    printWidth: 100,
    bracketSpacing: true,
    jsxBracketSameLine: false,
    arrowParens: "avoid",
}

Let's create these tasks one after the other.

// initializes and displays the welcome screen
const init = async () => {
    clear()
    console.log(
        chalk.green(
            figlet.textSync('PrTfY', {
                horizontalLayout: 'full',
            })
        )
    )
}

You will notice we have a clear() function. This clears the console of any clutter when we run prtfy. We need to install the clear module. Run

npm install clear

Let's configure Git hook more info an what that means here

const installGitHook = async () => {
    const spinner = new Spinner('Configuring Git Hook..')
    return installHelper(
        'npx mrm lint-staged',
        () => console.log(chalk.green('Git hook configured 👍')),
        spinner
    )
}

Next, we need to prompt user for some answers.

const askIfJsorTs= () => {
    const questions = [
        {
            name: 'ENV',
            type: 'list',
            choices: ['.Typescript', '.Javascript'],
            message: 'Please, select if this is a JavaScript or Typescript project',
            filter: function (val) {
                return (val === '.Typescript') ? 'ts' : 'js'
            },
        },
    ]
    return inquirer.prompt(questions)
}

What askIfJsorTs() does basically is to ask if the user wants to setup prettier for Javascript or Typescript. The filter then returns 'ts' or 'js' based on the selection.

Next, we'll setup the configuration files based on user input. But, to make things more snazzy and realistic. We'll add a spinner to indicate when an asynchronous process like installing prettier or writing files is ongoing and when it's done. Something like below

ezgif.com-video-to-gif (1).gif

This is probably the trickiest part as we need to handle some async logic elegantly. We'll start by installing the spinner. Run

npm install clui

Also, don't forget to add the spinner to your list of imports. Like so

const clui = require('clui')
const Spinner = clui.Spinner

Now, we write the async logic to help us out with this. We need to await the child process installing the prettier and other modules before writing the config files. You can check the clui docs for more info

const installHelper = (command, onSuccess, spinner) => {
    return new Promise((resolve, reject) => {
        var process = spawn(command, { shell: true })
        spinner.start()
        process.on('exit', () => {
            spinner.stop()
            onSuccess()
            resolve()
        })
    })
}

Install prettier

const installPrettier = async () => {
    const spinner = new Spinner('Installing Prettier...')
    return installHelper(
        'yarn add -D prettier',
        () => console.log(chalk.green('Prettier has been installed! 👍')),
        spinner
    )
}

Finally, putting everything toeghter we write a prettier file based on all the information we have.

#!/usr/bin / env node
const cli = require('clui')
const shell = require('shelljs')
const Spinner = cli.Spinner
const clear = require('clear')
const spawn = require('child_process').spawn
const chalk = require('chalk')
const inquirer = require('inquirer')
const figlet = require('figlet')
const config = require('./config')

// initializes and displays the welcome screen
const init = async () => {
    clear()
    console.log(
        chalk.green(
            figlet.textSync('PrTfY', {
                horizontalLayout: 'full',
            })
        )
    )
}

const installHelper = (command, onSuccess, spinner) => {
    return new Promise((resolve, reject) => {
        var process = spawn(command, { shell: true })
        spinner.start()
        process.on('exit', () => {
            spinner.stop()
            onSuccess()
            resolve()
        })
    })
}

const installPrettier = async () => {
    const spinner = new Spinner('Installing Prettier...')
    return installHelper(
        'yarn add -D prettier',
        () => console.log(chalk.green('Prettier has been installed! 👍')),
        spinner
    )
}

const installGitHook = async () => {
    const spinner = new Spinner('Configuring Git Hook..')
    return installHelper(
        'npx mrm lint-staged',
        () => console.log(chalk.green('Git hook configured 👍')),
        spinner
    )
}

const askIfJsorTs = () => {
    const questions = [
        {
            name: 'ENV',
            type: 'list',
            choices: ['.Typescript', '.Javascript'],
            message: 'Please, select if this is a JavaScript or Typescript project',
            filter: function(val) {
                return val === '.Typescript' ? 'ts' : 'js'
            },
        },
    ]
    return inquirer.prompt(questions)
}

const setPrettierConfig = async () => {
    shell.ShellString(config).to(`.prettierrc.js`)
}
const success = () => {
    console.log(chalk.blue.bold(`Prettier Config completed`))
}

;(async () => {
    init()
    await installPrettier()
    await setPrettierConfig()
    await installGitHook()
    const answer = await askIfJsorTs()
    const { ENV } = answer
    if (ENV === 'js') {
        await installPrettier()
        await setPrettierConfig()
    }
    if (ENV == 'ts') {
        const tsConfig = {
            parser: '@typescript-eslint/parser',
            extends: [
                'plugin:react/recommended',
                'plugin:@typescript-eslint/recommended',
                'prettier/@typescript-eslint',
                'plugin:prettier/recommended',
            ],
            parserOptions: {
                ecmaVersion: 2018,
                sourceType: 'module',
                ecmaFeatures: {
                    jsx: true,
                },
            },
            rules: {},
            settings: {
                react: {
                    version: 'detect',
                },
            },
        }

        // install eslint plugins
        const pluginSpinner = new Spinner('Installing plugin configs...')
        await installHelper(
            'npm install eslint @typescript-eslint/parser @typescript-eslint/eslint-plugin --dev',
            () => console.log(chalk.green('Eslint Typescript plugin installed 👍')),
            pluginSpinner
        )

        // write eslintrc.js
        await shell.ShellString(tsConfig).to(`.eslintrc.js`)

        // install typescript prettier config
        const tsSpinner = new Spinner('Installing Typescript prettier configs...')
        await installHelper(
            'npm install prettier eslint-config-prettier eslint-plugin-prettier --dev',
            () => console.log(chalk.green('Eslint Typescript prettier configs installed 👍')),
            tsSpinner
        )
    }

    success()
})()

To test the CLI, simply run the below inside the root directory

node index

One last thing, notice the expression on the first line of index.js

#!/usr/bin / env node

It enables you to simply run prtfy inside any directory and have the cli run and install the configurations. I will leave you to do this. You can also publish as an npm module if you so wish.

Comments (1)

Sandeep Panda's photo

Hi Ola.. Nice article! In order for others to see your article in their personalized feeds, you need to tag this post with Node.js. Currently, this is untagged and therefore appears only on recent tab. :)