Building a Node.js CLI with TypeScript, packaged and distributed via Homebrew

Sniptt Official
Geek Culture
Published in
5 min readJun 20, 2021

At Sniptt, most of our experience has been writing TypeScript and JavaScript for Node, so we made the decision to use the same technologies to build out the first few versions of the Snip CLI. Our initial testing has shown, however, that distributing security software exclusively over npm would not sit right with many of our users. While our long-term goal is to port the Snip CLI to Golang (see our first attempt here), we decided to take a pragmatic approach until then and package our JavaScript code as stand-alone executable binaries for both macOS and Linux.

In this tutorial, we’ll show you how to build a simple Node.js CLI with TypeScript, and then package and distribute it via Homebrew.

TLDR; To view a completed setup including workflow automation, take a look at how we’ve done this with our official CLI at https://github.com/sniptt-official/snip-cli 😎.

Project setup

Let’s start by initialising a new Node.js project called hello via npm init. This will create a package.json file where we’ll keep track of our dependencies and scripts.

$ mkdir hello
$ cd hello
$ npm init -y

Next, let’s install TypeScript and the required Node.js typings, and then register our project with the TypeScript compiler. This will create a tsconfig.json with default compiler options.

$ npm i typescript @types/node -D
$ npx tsc --init

Let’s update tsconfig.json to match our needs:

{
"compilerOptions": {
- "target": "es5",
+ "target": "ES2020",
...
+ "outDir": "./build",
+ "importHelpers": false,
...
}
}

NOTE: We’ve selected ES2020 as target because we assume Node.js version 14 or higher is being used.

Lastly, let’s add an entry-point to our CLI and verify that TypeScript compilation works as expected.

$ mkdir src
$ touch src/cli.ts
$ npx tsc
$ ls build
cli.js

Handling commands

Let’s say we want to implement a simple greet command which takes name as a positional argument, and an optional --upper flag. The resulting CLI will work like this:

$ hello greet Alice
Hello, Alice!
$ hello greet Alice --upper
HELLO, ALICE!

We will use yargs to help us parse arguments and flags, and scaffold the CLI command handlers. It also comes with a number of useful features for better user experience such as automatically generated help menu, and more.

$ npm i yargs
$ npm i @types/yargs -D # for TypeScript

Let’s create the greet command module.

$ mkdir src/commands
$ touch src/commands/greet.ts

The greet.ts file will look like this:

Next, let’s update the cli.ts entry-point.

NOTE: The #!/usr/bin/env node shebang character sequence “converts” the JavaScript file into a Node.js command-line script.

Let’s compile our source with TypeScript again and try out the script.

$ npx tsc
$ ./build/cli.js greet Alice
Hello, Alice!%
$ ./build/cli.js greet Alice --upper
HELLO, ALICE!%

👏 Hooray! Our CLI works as expected.

Building a single executable

Let’s say you want to package your CLI for commercial use (without sources). In this case, you probably do not want to distribute via npm, but rather by making it available for download somewhere.

To package our hello CLI as a single executable, we will use the pkg module from Vercel.

$ npm i pkg -D

We’ll need to update package.json to add a script that uses the pkg module to build for macOS, and a configuration to tell pkg to include all .js files in the build folder:

{
...
+ "bin": {
+ "hello": "./build/cli.js"
+ },
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
+ "package": "pkg . --targets node14-macos-x64 --output hello"
},
...
+ "pkg": {
+ "scripts": "build/**/*.js"
+ }
}

NOTE: We need to add the bin entry to correctly package the module with pkg. This also allows users to install the module via npm install -g hello provided it is published on npm, or via npm link locally.

Let’s try if this works as expected.

$ npm run package
$ ./hello greet Bob --upper
HELLO, BOB!%

Awesome! Now, let’s make hello available via Homebrew.

Distributing via Homebrew

The easiest way to do this is to host a repository with a correctly configured formula. The name of the repository must follow the homebrew-[name] convention, so in our case homebrew-hello, which will make it installable via brew install sniptt-official/hello/hello.

Now, we need to host our binary somewhere. We have found that the simplest way is to create a release on GitHub and add the compressed archive as an attachment.

To create the archive, run:

$ tar -cvzf hello-macos-x64.tar.gz hello

To compute its signature:

$ sha256sum hello-macos-x64.tar.gz

We’ll need to take note of the signature as it is required for the Homebrew formula, in our case this is debe8f770becb128c666e64a415be0f6c62db90de4dfad853150e116164a6138.

Next, we’ll create a release and upload the artefact.

After confirming, you should see something like this.

The last step is to create the Homebrew formula in our new repo.

$ git clone git@github.com:sniptt-official/homebrew-hello.git
$ cd homebrew-hello
$ mkdir Formula
$ touch Formula/hello.rb

In hello.rb:

class Hello < Formula
desc "A simple greeter"
homepage "https://github.com/sniptt-official/hello"
url "https://github.com/sniptt-official/homebrew-hello/releases/download/v1.0.0/hello-macos-x64.tar.gz"
sha256 "debe8f770becb128c666e64a415be0f6c62db90de4dfad853150e116164a6138"
version "1.0.0"
def install
bin.install "hello"
end
end

NOTE: The URL can be obtained in the Assets section of the published release, and the signature must match the one obtained earlier.

We’ll push our changes and then it’s finally testing time!

$ git add Formula
$ git commit -m "feat(formula): add formula for hello"
$ git push origin main

Once the formula has been pushed to GitHub, we can install the hello CLI via Homebrew:

$ brew install sniptt-official/hello/hello

And to test:

$ hello greet "Homebrew user"
Hello, Homebrew user!%

🎉 Yay, you’ve made it! Congratulations!

Next steps and advanced usage examples

Want to learn more? Visit our Snip CLI project on GitHub to learn more about:

  • Advanced CLI configuration with multi-command support
  • Packaging for Linux
  • Workflow automation with GitHub actions

and more.

Tired of using password managers to share API Keys, database passwords, configuration files, and other secrets with your team?

Try Sniptt, a new kind of secrets manager for developers that lets you share end-to-end encrypted secrets straight from the command line!

--

--

Sniptt Official
Geek Culture

We believe there should be a better way for teams to manage secrets, so we decided to build a new kind of secret manager. Say goodbye to password managers!