Publishing a Rust CLI on npm
While building my CLI thanks-contributors (opens in a new tab) (this little script accesses GitHub’s API to get all contributors and their PRs between two distinct points in the history of commits) I decided that I wanted to make this CLI available to my colleagues. And they shouldn’t need to install any additional toolchains besides what they already use on a daily basis (which is Node.js among other things). So it only made sense to publish the CLI to npm so that it can be run with npx @lekoarts/thanks-contributors
.
Well, at first this was easier said than done. While searching for “Rust CLI npm” or “Rust binary npm” I’ve found articles talking about binary-install (opens in a new tab) and wasm-pack (opens in a new tab). They are certainly great projects and good options but I wanted something simpler.
Luckily I found napi-rs (opens in a new tab). It’s a framework for building pre-compiled Node.js addons in Rust.
#Setting Up napi-rs
I recommend using @napi-rs/cli
to initialize your project. The napi-rs getting started guide (opens in a new tab) is a great resource for all options and a deep dive on how it works.
Install the CLI globally:
npm install -g @napi-rs/cli
Afterwards, navigate to your desired parent directory and run the CLI. It’ll create a new folder containing the project:
napi new
When you’re asked for the package name, I recommend using a npm scope (opens in a new tab) because for each platform a separate package will be published. This can trigger spam detection.
In one of the next steps you’re asked for the targets you want to support. There is a short overview page (opens in a new tab) in the napi-rs documentation but this one took me longer to figure out initially. You’ll need to research where your CLI should be able to run. I’ve chosen these options and so far I didn’t get any complaints: darwin-arm64, darwin-x64, linux-arm-gnueabihf, linux-arm64-gnu, linux-arm64-musl, linux-x64-gnu, linux-x64-musl, win32-x64-msvc
. So I’m supporting Intel and Apple Silicon Macs, a couple of Linux flavors (including ARM), and Windows.
I recommend enabling GitHub actions as they’ll enable you to automatically build and publish new versions in CI.
Follow the rest of the getting started instructions (opens in a new tab) and also check out the example package walkthrough (opens in a new tab) to learn how to use the scaffolded project.
#Defining an Executable
So you’ve successfully initialized your project and wrote your Rust CLI, e.g. with clap (opens in a new tab). Great! Now you need to wire up your compiled file in such a way that anyone can run it as a Node.js executable.
Give your exported function inside src/lib.rs
a suitable name, for example run
. When you run npm run build
napi-rs will compile your native packages and create a new file at the root called index.js
. When you inspect the file you’ll see that it’s quite long — it needs to figure out which package/native binary it should load for your platform. At the very bottom you’ll see run
exported as a named function:
const { run } = nativeBinding
module.exports.run = run
Which means that you can import run
from index.js
.
You can use the bin
property inside package.json (opens in a new tab) to define one or more executables files. But you can’t directly execute run
from index.js
as you need to define a small helper file.
At the root of your project, create a bin.js
file with the following contents:
#!/usr/bin/env node
const cli = require("./index")const arguments = process.argv.slice(2)
cli.run(arguments).catch((e) => { console.error(e) process.exit(1)})
The first line is a shebang (opens in a new tab) and necessary to run the Node.js executable. Since you only want to pass the arguments to the run
function, you need to grab them from process.argv
. Feel free to console.log(process.argv)
to see what it all contains when you run node bin.js
.
Last but not least, add the bin
property to your root package.json
:
{ "bin": "bin.js"}
The next time you publish your package to npm one can directly use the CLI you wrote in Rust 🎉 As mentioned in the beginning, if you need a real-world example of this pattern look at the source code of thanks-contributors (opens in a new tab).