DEV Community

Cover image for CLI application with the Node.js Readline module

CLI application with the Node.js Readline module

Node.js version: 22.1.0.

Using Node.js to create your own scripts works well. And if you're used to using Javascript or Typescript, writing your scripts will be very quick. But while I was using k9s (a CLI manager for kubernetes), I wondered if it was possible to make such a tool with Node.js. Without going that far, I embarked on a small project to analyze the Readline module in Node.js. This is to be used both in scripts and in more complete CLI applications.

I’m going to detail the difficulties I encountered in using Node.js Readline and creating a basic CLI application. (And not the module itself, read the doc!) I've put together all the solutions to the problems I encountered right here, which should give you a comprehensive guide to understanding and avoiding the same complications.

This blog is longer than initially expected and will be divided into three parts:

  • Part one - the basics - is below.
  • Part two - going further - is here: (coming start of March 2025)
  • Part three - advanced notes - is here: (coming end of March 2025)

About my test project.

To learn new terms in another language, I've often used paper cards. On one side there's a word in one language, on the other side the word in the other language. The exercise is perfect for this project. The application has to display one side of a “card”, the user responds, validates the answer, and moves on to the next card. In the course of the project, I added “game” features such as clues, lives, time, etc. to the application.

I've used as few external libraries as possible in order to fully understand readline's capabilities and limitations.

You can access the project in question here: https://github.com/ger-benjamin/cli-learning-cards/tree/1.1.0

The basics

Readline “question”

To question the user, my first approach was to use Readline's question method.

import { createInterface } from 'node:readline';
import { stdin, stdout } from 'node:process';

const rl = createInterface({
  input: stdin,
  output: stdout,
});

rl.question("What's your name?\n", (answer) => {
  console.log(`Hello ${answer}`);
  rl.close();
});
Enter fullscreen mode Exit fullscreen mode

The result is as follows:

> node test.mjs 
What's your name?
Zorro
Hello Zorro
Enter fullscreen mode Exit fullscreen mode

And it works well... but it's not very flexible when it comes to the UI, as we'll come back to later.

Note that I used createInterface from node:readline. The equivalent is possible with node:readlinePromise, but instead of a callback, you'll use an await to get your response. The advantage of callback here is that I don't have to wait for the response to execute other things.

I start by creating a readline interface. I bind to it the standard input (stdin, the keyboard), and the standard output (stdout, the terminal) of the process. This would also make it possible to link input and output to a file, for example.

As the interface is an instance, it is possible to have several instances. This may be practical (we will see that later), but not necessarily with rl.question.

Readline stream

Note that createInterface creates a stream (duplex). And since in my example it's linked to stdin and stdout, there's no reason for the stream to stop on its own. This allows me to keep the process active.

After printing my response via a console.log, I close the stream to exit the process. In this case, rl.pause() would amount to the same thing, since the stream here is keeping the process alive.

Since I'm using readline as a stream (and not as a promise), I have access to certain events. So I can perform the same script with rl.(“on”, (line) => callback);

import { createInterface } from 'node:readline';
import { stdin, stdout } from 'node:process';

const rl = createInterface({
  input: stdin,
  output: stdout,
});

rl.on("line", (line) => {
  console.log(`Hello ${line}`);
  rl.close();
});

console.log("What's your name?");
Enter fullscreen mode Exit fullscreen mode

A subtle difference here is that I write my question with console.log, and console.log automatically adds a line break. This was not the case with rl.question, which in my case is based on stdout.write. This kind of difference is listed here for example: https://www.geeksforgeeks.org/difference-between-process-stdout-write-and-console-log-in-node-js/

This way of writing the code is a little more flexible, since it's not necessary to call rl.question after each entry. But this is not exclusive, and you can mix the two:

import { createInterface } from 'node:readline';
import { stdin, stdout } from 'node:process';

const rl = createInterface({
  input: stdin,
  output: stdout,
});

const printQuestion = () => console.log("What's you name?");

rl.on("line", (line) => {
  console.log(`Hello ${line}`);
  rl.close();
}).on("SIGINT", () => {
  rl.question("Are you sure you want to exit?\n", (answer) => {
    if (answer.match(/^y(es)?$/i)) {
      rl.close();
    } else {
      printQuestion();
    }
  });
});

printQuestion();
Enter fullscreen mode Exit fullscreen mode

Here, a confirmation with question is requested before closing the process.

Key concepts

A few key points on the basic principles:

  • Readline.createInterface allows you to create stream or promise instances of Readline.Interface (Be careful not to accidentally mix the two).
  • An open stream keeps the process alive.
  • Stdin and stdout are linked to one process.
  • Stdin and stdout are unique to a process.
  • rl.question() or rl.on(‘line’, cb) allows you to read the input.
  • Console.log, or stdout.write, or rl.write, allows you to write to the output.

Part two - going further - is here: (coming start of March 2025)
Part three - advanced notes - is here: (coming end of March 2025)

Top comments (0)