DEV Community

Cover image for Javascript CLI demystified
Romain Trotard
Romain Trotard

Posted on • Updated on • Originally published at romaintrotard.com

Javascript CLI demystified

The other day, I was adding jest to a new project. And when I add my script to run my test in the package.json, I asked myself:

How libraries can expose executable script? And how the project can use it?

Every day I use Command Line Interface, and I am sure you do too. Just look at my package.json:

{
  "scripts": {
    "lint": "eslint .",
    "test": "jest .",
    "build": "webpack ."
  }
}
Enter fullscreen mode Exit fullscreen mode

A lot of libraries have CLI, jest, vitest, webpack, rollup, ...

In this article you will see that thanks to the package.json you can expose executable file.
And that, at the installation of library, the package manager creates symbolic link in a binary folder inside node_modules
to the library CLI.

Let's make a CLI together :)

Note: In the article, I will talk about npm and yarn on a UNIX machine. If you use another package manager, for example pnpm you can also
do everything I do in this article. And the command are here to help you do it. It can also act differently if you use volta for managing your version your package manager versions.


Initialization of the library

Let's make a new folder and go inside:

mkdir example-js-cli
cd example-js-cli
Enter fullscreen mode Exit fullscreen mode

Now, we are going to create the package.json thanks to yarn:

yarn init
Enter fullscreen mode Exit fullscreen mode

We are going to let all the values empty.

Note: You can do the same with npm by launching npm init. The final package.json will differ a little bit but not so much ;)

We end up with the following package.json:

{
  "name": "example-js-cli",
  "version": "1.0.0",
  "main": "index.js",
  "license": "MIT"
}
Enter fullscreen mode Exit fullscreen mode

Let's make some code

In this article, we are going to implement a simple CLI, that will print some information about your PC:

  • your username
  • the uptime of the computer
  • the operating system
  • the total memory

We are just going to use the os module of nodejs and the library chalk that allow us to make some stylized console logs easily.

Note: We can stylized console logs and do a lot of other things than console.log like console.time, console.count. If you want to know more about it
you can read my article Unknown console API in JS.

No more talking, we want some code.

Just add the chalk library:

yarn add chalk
Enter fullscreen mode Exit fullscreen mode

Warning: Do not use devDependencies in a CLI! Otherwise, it will not work because the dependencies will not be added!

And here is the code, I have written:

const os = require("os");
const chalk = require("chalk");

const colors = [
  "red",
  "yellow",
  "green",
  "cyan",
  "blue",
  "magenta",
];

/**
 * Function to make some rainbow <3
 */
function rainbowText(value) {
  return value
    .split("")
    .map((letter, index) =>
      chalk[colors[index % colors.length]].bold(letter)
    )
    .join("");
}

const SECONDS_BY_HOUR = 3600;
const SECONDS_BY_DAY = SECONDS_BY_HOUR * 24;

/**
 * Seconds doesn't mean anything for me.
 * Let's transform it to day / hour / minute
 */
function secondsToHumanTime(seconds) {
  const days = Math.floor(seconds / SECONDS_BY_DAY);
  const hours = Math.floor(
    (seconds - days * SECONDS_BY_DAY) / 3600
  );
  const minutes = Math.floor(
    (seconds -
      hours * SECONDS_BY_HOUR -
      days * SECONDS_BY_DAY) /
      60
  );

  const array = [
    {
      value: days,
      label: "day",
    },
    {
      value: hours,
      label: "hour",
    },
    {
      value: minutes,
      label: "minute",
    },
  ];

  // Do not insert 0 values
  return (
    array
      .filter(({ value }) => value > 0)
      // Trick to make plural when needed
      .map(
        ({ value, label }) =>
          `${value}${label}${value > 1 ? "s" : ""}`
      )
      .join(" ")
  );
}

/**
 * Mb is way more readable for me
 */
function byteToMegaByte(byteValue) {
  return Math.floor(byteValue / Math.pow(10, 6));
}

console.log(
  `Hello ${rainbowText(os.userInfo().username)}\n`
);
console.log(
  chalk.underline(
    "Here you will some information about your pc"
  )
);

console.table([
  {
    info: "Uptime",
    value: secondsToHumanTime(os.uptime()),
  },
  {
    info: "Operating System",
    value: os.version(),
  },
  {
    info: "Total memory",
    value: `${byteToMegaByte(os.totalmem())}Mb`,
  },
]);
Enter fullscreen mode Exit fullscreen mode

It's a lot of code that I give without explanation. But it should be fine to understand.

If you have some questions about it, do not hesitate to PM me on Twitter :)

And now we can run this script by running:

node index.js
Enter fullscreen mode Exit fullscreen mode

This is what I get:

Own PC information after launching the script

Otherwise, you can also add a scripts entry into our package.json:

{
  "scripts": {
    "start": "node index.js"
  }
}
Enter fullscreen mode Exit fullscreen mode

And now we can just launch it with:

yarn start
Enter fullscreen mode Exit fullscreen mode

But this is not the goal of this article, we want the world to use our wonderful library.


Let's make the CLI stuff

It would be wonderful if any project could just add the following scripts in their package.json:

{
  "scripts": {
    "computerInfo": "giveMeComputerInfo"
  }
}
Enter fullscreen mode Exit fullscreen mode

With giveMeComputerInfo that is our CLI. Instead of located our exported scripts in their node_modules and run it with node.

As you may guess it, you will just edit your package.json to do it.

The property to do that is named bin (meaning binary).

There is two ways to reference our script.
The first one is just to put the path to your script.

{
  "bin": "./index.js"
}
Enter fullscreen mode Exit fullscreen mode

In this case the name of the executable is the name of our package, i.e. example-js-cli.

Well, not the best. Here is the second method to the rescue:

{
  "bin": {
    "giveMeComputerInfo": "./index.js"
  }
}
Enter fullscreen mode Exit fullscreen mode

And that's all?

Actually, no. If you add publish the library. And add it in another project, and add the scripts' entry written a bit upper on the page.
It will... just fail.

Why does it fail?

Because, we need to tell to the unix shell what is the interpreter that needs to run the script.
We can do this thanks to the following shebang syntax:

#!/usr/bin/env node
Enter fullscreen mode Exit fullscreen mode

Put this at the top of the index.js file. And here we go.


How does it work?

When you add a library, the package manager will detect the property bin and will make symbolic links to each scripts
to the node_modules/.bin directory.

When a CLI is called in scripts, it will first look at the node_modules/.bin and then will look at every directory
that are registered in your PATH. For example for UNIX users, in the PATH you will find:

  • /usr/.bin
  • /usr/local/bin
  • ...

You can all the directory, by launching:

echo $PATH
Enter fullscreen mode Exit fullscreen mode

Add a package globally on your machine

You may not know, but you can also add package globally on your operating system.

You can do it with yarn with:

yarn global add theWantedPackage
Enter fullscreen mode Exit fullscreen mode

And with npm:

npm install -g theWantedPackage
Enter fullscreen mode Exit fullscreen mode

Then, you will be able to execute CLI of the theWantedPackage from everywhere on your computer.


How does global package work?

As you can imagine when you add a package globally, the package is installed in a node_modules somewhere on your computer and
a symbolic link will be created into /usr/local/bin/.

For example, let's add our package globally:

yarn global add example-js-cli
Enter fullscreen mode Exit fullscreen mode

You can now see where is located the executable giveMeComputerInfo by launching:

which giveMeComputerInfo
Enter fullscreen mode Exit fullscreen mode

Effectively, it's located in the /usr/local/bin folder, but it's only a symbolic link.

But where is the real file?

ls -l /usr/local/bin/giveNeComputerInfo
Enter fullscreen mode Exit fullscreen mode

The result depends on if you use yarn or npm.

Package manager localization
yarn /usr/local/lib/node_modules/bin
npm /usr/local/share/.config/yarn/global/node_modules/bin

As you can see, the symbolic link points to the symbolic link inside the node_modules/.bin folder and not directly to the script.

Fun fact: yarn create a global project that has a package.json. When you add globally a dependency it will be
added inside this package.json.


Conclusion

You can expose CLI thanks to the bin property of your package.json. It's even possible to export multiple CLI.

Then, when you install the library in a project, the package manager will detect the property and will make some symbolic link
to the .bin directory in node_modules. This symbolic link point to the real script file.

When you launch a script command, the package manager will look at the .bin folder then will look to every directory registered in your
PATH.

It's also possible to add a CLI globally on your system, in this case the library is installed in a "global" node_modules directory
and a symbolic link is created inside the /usr/local/bin folder pointer to the symbolic link inside the node_modules/.bin folder.


Do not hesitate to comment and if you want to see more, you can follow me on Twitch or go to my Website. And here is a little link if you want to buy me a coffee

Top comments (0)