Building a CLI tool with Deno

20 December 2022

We love building things at Bluegg. We also love finding ways to streamline our daily workloads to spend more time focusing on the important stuff, so I built something awesome that I wanted to share.

Recently, we've spent a lot of time improving our internal development process, and have devised some great solutions. One of these solutions is in the form of a nifty Command Line Interface (CLI) tool that I've built using something called Deno.

What is Deno?

In short, Deno (dee-no) is “a modern runtime for JavaScript and TypeScript”. It can be thought of as an alternative to the Node.js runtime. If you want to learn everything you could ever want to know about Deno, I'd suggest heading over to deno.land for the most accurate and up to date information.

Why did I choose Deno?

I'm always trialling new and exciting tools, and Deno is yet another example. When I set out to build the CLI tool, I initially spent some time setting up a basic scaffold in Bash using the experience that I had with it, but quickly came to see the amount of complexity that would be involved in creating exactly what we needed.

So I looked for a better solution. Enter JavaScript.

JavaScript is a language that we're extremely familiar with at Bluegg and love to use (mostly), and we're no strangers to the Node ecosystem. Building the CLI tool in Node was my first instinct, but I instead took an opportunity to learn something new and spent some additional time looking at other possibilities.

Ultimately, I settled with Deno over Node for several reasons, but here are just three:

  • Secure by default. As a rule, Deno takes security very seriously. For building a tool that's intended for use on all of our development machines and that would interact with all of our client's servers, this was very important to us too.

  • Out of the box TypeScript support. How can you not love TypeScript? The fact that Deno treats TypeScript as a first class language was a huge point in its favour.

  • Built-in development tooling. Deno comes with a built-in formatter, linter, and test runner. Code cleanliness and consistency is something I'm personally extremely scrupulous about, so this was a really great thing to have.

Several other points also swayed my decision, but you can read more about Deno's philosophy, goals, and benefits over Node here.

Less talk; more code

You might be here because you've had the same idea and want somewhere to start. I've documented some elements of the CLI-tool-building-journey that I'm hoping will serve as a quick start-up guide to building your own.

A couple of assumptions first:

  • I'll assume that you're running macOS. Some instructions or code snippets may vary for different operating systems.

  • I'll assume that you're already familiar with TypeScript. This post won't be covering any TypeScript-specific tips or hints.

Installing Deno

First step is installing Deno - shocker!

Instead of providing my own documentation on this, I'd recommend following Deno's official guidance in case anything ever changes.

Project structure

The structure of the Deno project is as follows. This isn't in any way the “correct” way to do things, but it's what felt natural:

/src
	/arguments
	/libs
	/subcommands
		/example
			example.ts
	constants.ts
	helpers.ts
deps.ts
main.ts

Here's a quick explanation on each directory and file:

  • /src - This will contain all of your code. Anything outside of this directory (at the root of the project) should generally be for configuration purposes.

  • /src/arguments - Every argument for the CLI tool should have it's own file within this directory.

  • /src/libs - Logic that is commonly used throughout the application and used by more than one file can be written as a shared library.

  • /src/subcommands - Every subcommand for the CLI tool should have it's own directory within this directory. For the purposes of this guide, there's one called /example.

  • /src/subcommands/example - Logic pertaining specifically to a given subcommand.

  • constants.ts - Common values, objects, or pure functions used throughout the application.

  • helpers.ts - Useful functions that aren't essential to the application's core functionality.

  • deps.ts - Contains all of the application's dependencies. Read more on Deno's dependency management.

  • main.ts - The application's entry point, I.E. the first thing Deno will run.

Getting started

The bare minimum required for creating any Deno application is a file that acts as an entry point and a file containing a list of dependencies, respectively called main.ts and deps.ts.

The entry point's logic should be responsible for managing any subcommands or arguments that the user enters when using the tool in the command line.

Thankfully, one of Deno's many standard libraries includes a command line arguments parser called flags. The library's parse function takes a set of command line arguments, optionally with a set of options, and returns an object representing the flags found in the passed arguments.

Here's an example of how this might be implemented:

// deps.ts
import { parse } from "https://deno.land/std/flags/mod.ts";
// main.ts
import { parse } from "./deps.ts";

const args = parse(Deno.args);

The name of our own CLI tool's executable is bluegg. Should a user run something like this in the command line:

$ bluegg subcommand -a value -b value -c value

The value of args would be as follows:

{ _: [ "subcommand" ], a: "value", b: "value", c: "value" }

The subcommand(s) entered by the user will always be stored in an array with the underscore key. This means we can parse the value of the entered subcommand by reading the array:

const subcommand = args._.shift() as string;

Once we've captured the user's input, we can process it. The core of this logic lies in a series of if statements to check for each subcommand and to run the related functions. Here's how this might look...

Breaking it down

Let's breakdown the contents of the main.ts file. The first thing to do is import the necessary dependencies. Here's some examples of my own:

import { parse } from "./deps.ts";
import { help } from "./src/arguments/help.ts";
import { version } from "./src/arguments/version.ts";
import { app, invalidSubcommand, missingSubcommand } from "./src/constants.ts";
import { error, warning } from "./src/libraries/messages.ts";
import { example } from "./src/subcommands/example/example.ts";

Next, we parse what's been entered by the user on the command line as explained above:

/* Get any arguments entered by the user. */
const args = parse(Deno.args);

/* Get the subcommand entered by the user. */
const subcommand = args._.shift() as string;

Once we've got these values, we can run the logic behind each subcommand and/or argument.

/* Process the subcommands or arguments entered by the user. */
if (subcommand) {
  if (subcommand === "example" || subcommand === "eg") example(args);
  else error(invalidSubcommand(subcommand));
} else {
  if (args.version || args.v) version();
  else if (args.help || args.h) help();
  else error(missingSubcommand());

  Deno.exit();
}

The example above contains some custom functions that won't be covered here. It'll be up to you to write the logic for those yourself.

In the above example, we first check to see if a subcommand has been entered. You can check for multiple aliases of the same subcommand or argument by checking for multiple values in your if statements. If no subcommand is entered, we check for any recognised arguments. In this example, we're checking for four different possible arguments: --version, -v, --help, or -h.

It's important to note that in Unix-like systems, the hyphen character denotes an argument. The convention is to either use two hyphens followed by a word, or to use one hyphen followed by one letter. If one hyphen is followed by two or more letters, these will be treated as two separate arguments. E.G. -abc will be parsed as -a -b -c.

The functions called for each subcommand and argument should be written in their own files within the relevant directories, as explained previously.

Running

Running the application is extremely simple:

$ deno run main.ts

However, it's possible that Deno will alert you (or fail to run entirely) due to issues relating to permissions. As previously mentioned, Deno is secure by default, so some extra steps are needed to ensure the application has certain permissions to run.

You can read more about permissions in Deno here.

Compiling

Deno also provides a way of compiling a ready-to-go executable once you're happy with the application, which is just as easy as running it locally:

$ deno compile main.ts

You can read more about compiling with Deno here.

Finishing up

For a developer, a collection of CLI tools under ones belt is invaluable. A custom one, however, can be a game changer. For us at Bluegg, the tool I've built currently allows the team to synchronise both databases and assets (CMS uploads etc.) between local and remote environments, which is something that we do frequently.

There's so much more we can build to make this tool even more useful too, and being able to carry out such minor tasks like this with a few keystrokes makes our day-to-day workloads that little bit easier.