Brady Dowling Family first. People before programs. Basketball too. Find me on YouTube at ReadWriteExercise.

Creating a Node CLI with Enquirer

10 min read 2876

Node Cli Enquirer

While a graphical user interface (GUI) is more often seen by end users, a command-line interface (CLI) can be an incredibly valuable addition to developer tools and quick projects in general.

Sometimes, a CLI provides an even better experience than a GUI.

In this post, we’ll walk through how to create a Node CLI to check sports news using data from ESPN.com. We’ll look at a few tools and open source libraries that are helpful for creating a CLI that performs functions such as coloring, loading spinners, and drawing boxes around output. We’ll also use an async/await construct to wait for users to respond to prompts we create in our CLI. Let’s dive in!

Setting up the project

Note: If you’d prefer to skip most of the setup, you can clone this repo, run npm install, and start adding code to index.js.

To start our project, let’s go ahead and create a new directory, initialize an npm package, and create an index.js file where we’ll write all our code:

mkdir my-cli
cd my-cli
npm init -y
touch index.js

From here, let’s install the packages that we’ll be using in our CLI:

npm install --save enquirer boxen ora chalk node-localstorage @rwxdev/espn

Because we’ll be using import statements in our script, we need to make sure to mark our package as a module. Add the following line into your package.json file:

"type": "module",

First, we’re going to set up the index.js file. We’ll set up a main function where we can eventually run some async code and the rest of our CLI. We can import each of our dependencies as we use them along the way:

// index.js

const runCli = async () => {
  // Welcome user
  console.log("Thanks for consuming sports headlines responsibly!");

  console.log("Thanks for using the ESPN cli!");
  return;
}

runCli();

You can run the CLI with node ./index.js to see the logs we just added.

Getting display data

Now that we’ve laid out our skeleton, let’s add some functionality.

We’ll import a library for getting ESPN headlines, then make the initial call to get them. Since this is an asynchronous call to get data, the user will have to wait until the call finishes before seeing any info. It would be helpful to indicate to the user that the CLI is loading.

We made a custom demo for .
No really. Click here to check it out.

To do that, we’ll use a library called ora to show an animated loading indicator like this:

Ora Library Animated Loading Indicator
An example of the ora loading spinner

Before we make our async call, we’ll start the spinner. Once the call is done, we can stop the spinner:

import ora from "ora";
import {
  getArticleText,
  getHeadlines,
  getPageContents,
  getSports,
} from "@rwxdev/espn";

const homepageUrl = "https://espn.com/";

const runCli = async () => {
  console.log("Thanks for consuming sports headlines responsibly!");
  const spinner = ora("Getting headlines...").start();
  const $homepage = await getPageContents(homepageUrl);
  spinner.succeed("ESPN headlines received");
...

Running the CLI now with node ./index.js shows a brief spinner/loader while the data is being fetched. Once we have the data, we’ll see a check mark. Notice that the text changes to what we passed to the .succeed function. Pretty cool!

Using the $homepage data we just got, we’ll grab all the headlines that we need to display to users later on. Again, we’ll use our helper functions in here:

...
  spinner.succeed("ESPN headlines received");
  const homepageHeadlines = getHeadlines($homepage);
  const sports = getSports($homepage);
  const headlinesBySport = {};
  for (let sport of sports) {
    getPageContents(sport.href).then(($sportPage) => {
      const headlines = getHeadlines($sportPage);
      headlinesBySport[sport.title] = headlines;
    }).catch((e) => {
      console.log("there was an issue getting headlines for a certain sport", e);
    });
  }
...

Now, we’ve gathered and stored all the info we want to display to users: sports and a large list of headlines to choose from. We’ll use these later on.

Declaring CLI options

In our CLI, we’ll give users a couple of different option types to choose from: an article to read, a specific sport to see headlines for, and a MORE type to see more headlines for a specific sport.

This might be a good candidate for using TypeScript because selections will behave differently based on what type they are (e.g., headline, sport, exit). We’re just using vanilla JavaScript in this tutorial, so we’ll do some minor type handling ourselves.

Let’s declare variables so that we can handle user input differently based on the selection type. We’ll also make a few generic options for the user to choose, regardless of what headlines are available on any given day.

We’ll insert the following code right after our previous for loop that we added:

...
  const selectionTypes = {
    HEADLINE: "headline",
    SPORT: "sport",
    MORE: "more"
  };
  const genericOptions = {
    HOMEPAGE_HEADLINES: { title: "see homepage headlines" },
    LIST_SPORTS: { title: "see headlines for specific sports", type: selectionTypes.MORE },
    OTHER_SPORTS: { title: "see headlines for other sports", type: selectionTypes.MORE },
    EXIT: { title: "exit" },
  };
...

Because this is a CLI, we’ll be prompting the user for information over and over until they choose to exit. This is a good fit for a while loop in which we can specify the selection the user made. We’ll store the selection and selection title:

...
  let selection;
  let selectionTitle;
  let articleText;
  let currentPrompt;
  let exit = false;
  while(!exit) {
    // Where we'll handle the user's selection
  }
  console.log("Thanks for using the ESPN cli!");
...

Each time the user makes a choice, we’ll store the selection, go back through our loop, then handle the selection that was previously made. There are a couple different cases to handle, so let’s tackle them one by one.

If you were to run the CLI now, the while loop would be infinite because we neither tell it to exit nor allow the user to give input. If that happens to you, hit ctrl + c or close out of your terminal window. With that behind us, let’s make our first prompt.

Creating prompts with Enquirer

We’ll be using Enquirer, a library that allows us to show something in the console and then await the user’s input. It has a very straightforward API, as we’re about to see.

Let’s start by importing it at the top of our file:

import ora from "ora";
import enquirer from "enquirer";
...

We can put it to use in our while loop by creating our first prompt to show the user. It will ask the user which story from the homepage they’d like to read, then give them a few options:

  • Read one of the homepage headlines in the list
  • List out different sports for which to see further headlines
  • Exit the app
...
      while(!exit) {
        if (!selection || selection.title === genericOptions.HOMEPAGEHEADLINES.title) {
          currentPrompt = new enquirer.Select({
            name: "homepage",
            message: "What story shall we read?",
            choices: [...homepageHeadlines.map(item => item.title), genericOptions.LISTSPORTS.title, genericOptions.EXIT.title]
          });
        }
        selectionTitle = await currentPrompt.run();
      }
    ...

For the choices value, we provide an array of strings for the user to choose from. Below this if block is where we actually run the prompt and store the user’s choice as the selectionTitle. We will need more info than just the title, so let’s declare a few variables that will find the selection based on the selection title:

...
    selectionTitle = await currentPrompt.run();
    const combinedSportHeadlines = Object.values(headlinesBySport).reduce((accumulator, item) => {
      return [...accumulator, ...item];
    }, [])
    const allOptions = [...Object.values(genericOptions), ...homepageHeadlines, ...sports, ...combinedSportHeadlines];
    selection = allOptions.find(item => item.title === selectionTitle);
  }
  console.log("Thanks for using the ESPN cli!");
...

These variables are basically combining all our different option types into one big array of selections. From that allOptions array, we can find the one with a matching title to our user’s selection.

Now that we’re handling just one type of selection, our CLI will show the original homepage headlines. This is a step in the right direction! However, if you run through it now, you’ll get stuck in an infinite loop once you make your first selection, which is no fun.

Quitting the CLI

Let’s handle another selection type now: exiting. We could check that the user selected to exit selection?.title === genericOptions.EXIT.title, but it might also be helpful to have this as the last bit of our if...else blocks so that we can get rid of our infinite loops before we handle all our other selection types. We’ll add our exit block toward the end of our while loop:

...
  while(!exit) {
    if (!selection || selection.title === genericOptions.HOMEPAGE_HEADLINES.title) {
      currentPrompt = new enquirer.Select({
        name: "homepage",
        message: "What story shall we read?",
        choices: [...homepageHeadlines.map(item => item.title), genericOptions.LIST_SPORTS.title, genericOptions.EXIT.title]
      });
    }
    else {
      exit = true;
      break;
    }
...

Now, if the user chooses to exit, or if they choose a selection that isn’t yet handled, the CLI will quit right away. No more infinite loops!

Handling other user selections

We’re showing homepage headlines already, so now we can also show headlines for other sports, if that’s what the user chooses.

Let’s add another block to handle that:

...
    else if (selection.type === selectionTypes.MORE) {
      currentPrompt = new enquirer.Select({
        name: "sports",
        message: "Which sport would you like headlines for?",
        choices: sports.map(choice => choice.title)
      });
    }
    else {
      exit = true;
      break;
    }
...

Once the user chooses a specific sport, we want them to be able to see headlines for that sport. Therefore, we’ll do something similar to when we showed homepage headlines, but this time, we only need them for the chosen sport. We can insert another if else block just before our else block where we exit the CLI:

...
    else if (selection.type === selectionTypes.SPORT) {
      const sportHeadlines = headlinesBySport[selection.title];
      const sportChoices = sportHeadlines.map(option => option.title);
      currentPrompt = new enquirer.Select({
        name: "sportHeadlines",
        message: `Select a ${selection.title} headline to get article text`,
        choices: [...sportChoices, genericOptions.HOMEPAGE_HEADLINES.title, genericOptions.OTHER_SPORTS.title, genericOptions.EXIT.title]
      });
    }
    else {
      exit = true;
      break;
    }
...

Styling logs with boxen

Lastly, we can actually show article text if a user chooses a headline to read. Because this is an article, we can add a bit of styling to it so it’s not just plain old text.

First, import a library called boxen at the top of the file:

import ora from "ora";
import enquirer from "enquirer";
import boxen from "boxen";
...

This will allow us to draw a box around the article text that we show. Insert one more if else before our final else block. We’ll log out some text using styling from boxen before showing another prompt:

    else if (selection.type === selectionTypes.HEADLINE) {
      articleText = await getArticleText(selection.href);
      console.log(boxen(selection.href, { borderStyle: 'bold'}));
      console.log(boxen(articleText, { borderStyle: 'singleDouble'}));
      currentPrompt = new enquirer.Select({
        name: "article",
        message: "Done reading? What next?",
        choices: [genericOptions.HOMEPAGE_HEADLINES.title, genericOptions.LIST_SPORTS.title, genericOptions.EXIT.title]
      });
      articleText = "";
    }
    else {
...

Now, the CLI allows users to see front page headlines, a list of sports, headlines for specific sports, and read articles for headlines. This is looking pretty good! We have two finishing touches to add before we’re done.

Clearing Enquirer prompts

If you run through the CLI a few times, you’ll notice that the prompts kind of pile up on top of each other. They stay in the console but don’t offer much value there. Since we’re storing the prompt each time, we can use Enquirer to clear it out. At the beginning of our while loop, we can call .clear() on currentPrompt. We’ll use optional chaining so it doesn’t error out on the first execution:

...
  while(!exit) {
    currentPrompt?.clear();
...

The last thing we’ll add to our CLI is a log at the very start telling users how many times they’ve used the CLI in a day. This will be helpful because it’s generally not a productive CLI, so this will help you know how distracted you are. Maybe you’ll realize that you should be focusing more on your work. 🤷‍♂️

For more helpful CLIs, like developer tools, this kind of feature can also show you how valuable or indispensable the tool is. Then, of course, if you realize you’re running the same script 100 times a day, that might signal that you should incorporate its functionality into something that runs on its own without human intervention at all.

Adding color to console logs

To do this part, we’ll store a count of daily executions using a node-localstorage module, which we import here. This will behave just like Window.localstorage, so we can use a similar API. We could also use a database here if we needed to, but this should do the trick just fine.

Depending on how many times we’ve run the CLI, we want to show our log in a different color, so we’ll import our last library, Chalk, to change our text color:

import ora from "ora";
import enquirer from "enquirer";
import boxen from "boxen";
import { LocalStorage } from "node-localstorage";
const localStorage = new LocalStorage("./scratch"); // scratch is the name of the directory where local storage is saved, this can be change to whatever you'd like
import chalk from "chalk";
...

Now, we can make a new function where we’ll handle all the daily usage logic, using Chalk to change the log color and local storage to count the daily usage. We’ll call it at the beginning of our main function:

...
const showTodaysUsage = () => {
  const dateOptions = { year: "numeric", month: "numeric", day: "numeric" };
  const now = new Date();
  const dateString = now.toLocaleString("en-US", dateOptions);
  const todaysRuns = parseInt(localStorage.getItem(dateString)) || 0;
  const chalkColor = todaysRuns < 5 ? "green" : todaysRuns > 10 ? "red" : "yellow";
  console.log(chalk\[chalkColor\](`Times you've checked ESPN today: ${todaysRuns}`));
  localStorage.setItem(dateString, todaysRuns + 1);
}

const runCli = async () => {
  showTodaysUsage();
  console.log("Thanks for consuming sports headlines responsibly!");
...

Reviewing the final product

Now, we’re handling all the cases using all the libraries we installed. Your final script should look something like this:

import ora from "ora";
import enquirer from "enquirer";
import boxen from "boxen";
import { LocalStorage } from "node-localstorage";
const localStorage = new LocalStorage("./scratch");
import chalk from "chalk";
import {
  getArticleText,
  getHeadlines,
  getPageContents,
  getSports,
} from "@rwxdev/espn";
const homepageUrl = "https://espn.com/";
const showTodaysUsage = () => {
  const dateOptions = { year: "numeric", month: "numeric", day: "numeric" };
  const now = new Date();
  const dateString = now.toLocaleString("en-US", dateOptions);
  const todaysRuns = parseInt(localStorage.getItem(dateString)) || 0;
  const chalkColor = todaysRuns < 5 ? "green" : todaysRuns > 10 ? "red" : "yellow";
  console.log(chalk\[chalkColor\](`Times you've checked ESPN today: ${todaysRuns}`));
  localStorage.setItem(dateString, todaysRuns + 1);
}
const runCli = async () => {
  showTodaysUsage();
  console.log("Thanks for consuming sports headlines responsibly!");
  const spinner = ora("Getting headlines...").start();
  const $homepage = await getPageContents(homepageUrl);
  spinner.succeed("ESPN headlines received");
  const homepageHeadlines = getHeadlines($homepage);
  const sports = getSports($homepage);
  const headlinesBySport = {};
  for (let sport of sports) {
    getPageContents(sport.href).then(($sportPage) => {
      const headlines = getHeadlines($sportPage);
      headlinesBySport[sport.title] = headlines;
    }).catch((e) => {
      console.log("there was an issue getting headlines for a certain sport", e);
    });
  }

  const selectionTypes = {
    HEADLINE: "headline",
    SPORT: "sport",
    MORE: "more"
  };
  const genericOptions = {
    HOMEPAGE_HEADLINES: { title: "see homepage headlines" },
    LIST_SPORTS: { title: "see headlines for specific sports", type: selectionTypes.MORE },
    OTHER_SPORTS: { title: "see headlines for other sports", type: selectionTypes.MORE },
    EXIT: { title: "exit" },
  };

  let selection;
  let selectionTitle;
  let articleText;
  let currentPrompt;
  let exit = false;
  while(!exit) {
    currentPrompt?.clear();
    if (!selection || selection.title === genericOptions.HOMEPAGE_HEADLINES.title) {
      currentPrompt = new enquirer.Select({
        name: "homepage",
        message: "What story shall we read?",
        choices: [...homepageHeadlines.map(item => item.title), genericOptions.LIST_SPORTS.title, genericOptions.EXIT.title]
      });
    }
    else if (selection.type === selectionTypes.MORE) {
      currentPrompt = new enquirer.Select({
        name: "sports",
        message: "Which sport would you like headlines for?",
        choices: sports.map(choice => choice.title)
      });
    }
    else if (selection.type === selectionTypes.SPORT) {
      const sportHeadlines = headlinesBySport[selection.title];
      const sportChoices = sportHeadlines.map(option => option.title);
      currentPrompt = new enquirer.Select({
        name: "sportHeadlines",
        message: `Select a ${selection.title} headline to get article text`,
        choices: [...sportChoices, genericOptions.HOMEPAGE_HEADLINES.title, genericOptions.OTHER_SPORTS.title, genericOptions.EXIT.title]
      });
    }
    else if (selection.type === selectionTypes.HEADLINE) {
      articleText = await getArticleText(selection.href);
      console.log(boxen(selection.href, { borderStyle: 'bold'}));
      console.log(boxen(articleText, { borderStyle: 'singleDouble'}));
      currentPrompt = new enquirer.Select({
        name: "article",
        message: "Done reading? What next?",
        choices: [genericOptions.HOMEPAGE_HEADLINES.title, genericOptions.LIST_SPORTS.title, genericOptions.EXIT.title]
      });
      articleText = "";
    }
    else {
      exit = true;
      break;
    }

    selectionTitle = await currentPrompt.run();
    const combinedSportHeadlines = Object.values(headlinesBySport).reduce((accumulator, item) => {
      return [...accumulator, ...item];
    }, [])
    const allOptions = [...Object.values(genericOptions), ...homepageHeadlines, ...sports, ...combinedSportHeadlines];
    selection = allOptions.find(item => item.title === selectionTitle);
  }
  console.log("Thanks for using the ESPN cli!");
  return;
}

runCli();

You can also find what the code should look like here.

Conclusion

We’re all set! Our CLI will show us ESPN homepage headlines, list available sports, and let us read articles. We’ve provided prompts for the user, displayed an animated spinner during loading, colored console output, and drawn boxes around some console text.

We’ve gotten familiar with a few different Node CLI utilities, but there are several more that you can explore and play around with. Command-line interfaces can be tremendously helpful for productivity, and they can just be fun! Play around with what we’ve made in this tutorial and see how you can change it into something that’s helpful for you!

 

200’s only Monitor failed and slow network requests in production

Deploying a Node-based web app or website is the easy part. Making sure your Node instance continues to serve resources to your app is where things get tougher. If you’re interested in ensuring requests to the backend or third party services are successful, try LogRocket. https://logrocket.com/signup/

LogRocket is like a DVR for web apps, recording literally everything that happens on your site. Instead of guessing why problems happen, you can aggregate and report on problematic network requests to quickly understand the root cause.

LogRocket instruments your app to record baseline performance timings such as page load time, time to first byte, slow network requests, and also logs Redux, NgRx, and Vuex actions/state. .
Brady Dowling Family first. People before programs. Basketball too. Find me on YouTube at ReadWriteExercise.

Leave a Reply