Building a Node.js CLI with TypeScript, packaged and distributed via Homebrew
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!