DEV Community

Cover image for How to make beautiful, simple CLI apps with Node
Hugo Di Francesco
Hugo Di Francesco

Posted on • Originally published at codewithhugo.com on

How to make beautiful, simple CLI apps with Node

Channel your inner Sindre Sohrus and ship a beautifully simple CLI app using Node.

Command line apps are a neat way to package repetitive tasks. This will walk you through some tools
that are useful to build CLI apps.

Subscribe to get the latest posts right in your inbox (before anyone else).

The idea 💡

When merging/rebasing, the file that always seems to cause trouble is the package-lock.
We'll go through how to make a simple utility that deletes the package-lock.json file, regenerates it (npm install) and adds it to the git index.

You can find it here: https://github.com/HugoDF/fix-package-lock and run it using npx fix-package-lock.

Piping to the command line 🚇

To start off, we'll leverage a package from Sindre Sohrus, execa, which is described as “a better child_process". For the following snippet to work, run npm install --save execa:

index.js

const execa = require('execa');

execa('ls').then(result => console.log(result.stdout));
Enter fullscreen mode Exit fullscreen mode
node index.js
index.js
node_modules
package-lock.json
package.json 
Enter fullscreen mode Exit fullscreen mode

Dealing with sequential actions ✨

To re-generate the package-lock we'll need to first delete it, then run an npm install.

To this end, we can use Listr, it allows us to do things that look like:

Run npm install --save listr and add leverage Listr as follows:

index.js:

const execa = require('execa');
const Listr = require('listr');

new Listr([
  {
    title: 'Removing package-lock',
    task: () => execa('rm', ['package-lock.json'])
  },
  {
    title: 'Running npm install',
    task: () => execa('npm', ['install'])
  },
  {
    title: 'Adding package-lock to git',
    task: (ctx, task) =>
        execa('git', ['add', 'package-lock.json'])
        .catch(() => task.skip())
  }
]).run();
Enter fullscreen mode Exit fullscreen mode

Now the output of node index.js looks like the following:
`node index.js` output

Listr gives you a loading state when you have a
long-running task that returns a Promise (like the execa invocation of npm install).

It's also possible to display a message that changes using Observables, for more information see the Listr docs

Executable JavaScript files 🦅

It's ideal to be able to execute our script using ./index.js instead of node index.js.

To do this, we need the file to be executable on UNIX systems that's: chmod +x. So

chmod +x index.js
Enter fullscreen mode Exit fullscreen mode

We then need to inform the system how it should attempt to run the file, that's using the following hashbang:

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

If we add it to index.js we get:

#!/usr/bin/env node
const execa = require('execa');
const Listr = require('listr');

new Listr([
  {
    title: 'Removing package-lock',
    task: () => execa('rm', ['package-lock.json'])
  },
  {
    title: 'Running npm install',
    task: () => execa('npm', ['install'])
  },
  {
    title: 'Adding package-lock to git',
    task: (ctx, task) =>
        execa('git', ['add', 'package-lock.json'])
        .catch(() => task.skip())
  }
]).run();
Enter fullscreen mode Exit fullscreen mode

Which we can now run using:

./index.js
Enter fullscreen mode Exit fullscreen mode

Adding package binaries

npm has a bin field which we can use like the following (in package.json):

{
  "name": "beautiful-cli",
  "version": "1.0.0",
  "description": "A simple CLI",
  "main": "index.js",
  "bin": {
    "fix-package-json": "./index.js"
  }
  "dependencies": {
    "execa": "^0.10.0",
    "listr": "^0.14.1"
  }
}
Enter fullscreen mode Exit fullscreen mode

Publishing to npm 🚀

This is left to the reader as an exercise, although using the np
package, it's super straightforward.

Hint: run npx np in whatever package you're trying to publish

You can find the full package at You can find it here: https://github.com/HugoDF/fix-package-lock and run it using npx fix-package-lock.

Subscribe to get the latest posts right in your inbox (before anyone else).

Discussion (7)

Collapse
namirsab profile image
Namir

I've used commander and it was a pleasure to use. I like the listr package :D

Collapse
zalithka profile image
Andre Greeff

Listr is seriously cool, thanks for the introduction.. I've written a whole bunch of quick-fix Windows batch and *nix bash files over the years, which I've been meaning to port over to Node scripts for ages now.

This post just gave me the inspiration to get started on that task. Maybe I'll even make a cool little menu system for the scripts in my folder too.. Hehe. :D

Collapse
hugo__df profile image
Hugo Di Francesco Author

Glad it helped 😊 shame I haven't done much cross platform shell stuff 🤔

Collapse
jvarness profile image
Jake Varness

I too have used commander on projects and I really enjoy using it!

Another pattern that you could follow instead of using index.js as the entry point for your program is to have the actual executable in a bin folder in your package. That way, you can separate your actual package from how it gets executed. Following this pattern, module could be used as a CLI or consumed as a library rather than purely being a CLI.

Collapse
hugo__df profile image
Hugo Di Francesco Author

Yeah that was done for the sake of simplicity in a blog post, less files and folders is better to just quickly walk through some awesome packages.

Collapse
caseywebb profile image
Casey Webb

It should be noted that shebangs will not work on Windows, so for documentation it is still a good idea to use node index.js.

Collapse
zalithka profile image
Andre Greeff

I can't use the OS I really want to at work, so I end up running Hyper Terminal with Git's bash.exe as my shell..

There are many other advantages of this route (like autocomplete on environment variable names), but in this particular case it means you can technically use shebangs "in Windows". (: