Skip to main content

Building a Standalone CLI App with Node.js

· 4 min read

Creating a command-line interface (CLI) tool with Node.js is fairly common. But what if you want to distribute your tool to users who don’t have Node.js installed? That’s where the magic of pkg comes in.

In this post, I’ll go step by step to build a CLI tool, package it into a standalone executable, and even touch on obfuscating the code for extra protection.


1. Initialize Your Project

Start by creating a project folder and initializing it with npm:

mkdir my-cli-tool
cd my-cli-tool
npm init -y

This generates a package.json file with default settings.


2. Install Dependencies

For our CLI, we’ll use commander to handle CLI options.

npm install commander
npm install --save-dev pkg

3. Write the CLI Script

Create a file kernel.js in your project root:

kernel.js
#!/usr/bin/env node

const { Command } = require("commander");
const program = new Command();

program
.name("mycli")
.description("A simple CLI tool example")
.version("1.0.0");

program
.option("-v, --verbose", "Enable verbose logging")
.option("-n, --name <string>", "Specify a name");

program.parse(process.argv);
const options = program.opts();

if (options.verbose) console.log("Verbose mode enabled");
if (options.name) console.log(`Hello, ${options.name}!`);

A few key things:

  • The shebang line (#!/usr/bin/env node) makes the file runnable as a CLI.
  • commander handles parsing CLI arguments.

Make the file executable:

chmod +x kernel.js

4. Configure package.json

Edit your package.json to include a bin entry and pkg config:

package.json
{
"name": "mycli",
"version": "1.0.0",
"bin": "kernel.js",
"scripts": {
"build": "pkg . --targets node18-linux-x64"
},
"pkg": {
"assets": []
},
"dependencies": {
"commander": "^14.0.0"
},
"devDependencies": {
"pkg": "^5.8.1"
}
}

Here:

  • bin tells npm which file should be executed when the CLI is run.
  • scripts.build defines a shortcut for building the executable.
  • pkg config defines which extra files should be bundled (none for now).

5. Build the Executable

Run the build command:

npm run build

This generates a binary in the project root, e.g. mycli-linux.

Now, you can send that file to anyone on Linux (with the same architecture), and they can run it without Node.js:

./mycli-linux -n Emanuel -v

✅ Output:

Verbose mode enabled
Hello, Emanuel!

6. Distribute Across Platforms

pkg supports building for:

  • Linux
  • macOS
  • Windows

Just specify multiple targets:

pkg . --targets node18-linux-x64,node18-macos-x64,node18-win-x64

You’ll get three standalone binaries, one for each OS.


7. (Optional) Obfuscate Your Code

If you don’t want users to peek inside your code easily, you can obfuscate it before packaging.

Install the obfuscator:

npm install --save-dev javascript-obfuscator

Create a build-obfuscate.js:

build-obfuscate.js
const fs = require("fs");
const path = require("path");
const JavaScriptObfuscator = require("javascript-obfuscator");

const srcDir = path.join(__dirname, "src");
const distDir = path.join(__dirname, "dist");

// Recursively process files
function processDir(src, dest) {
if (!fs.existsSync(dest)) {
fs.mkdirSync(dest, { recursive: true });
}

fs.readdirSync(src).forEach((file) => {
const srcPath = path.join(src, file);
const destPath = path.join(dest, file);

if (fs.lstatSync(srcPath).isDirectory()) {
processDir(srcPath, destPath);
} else if (file.endsWith(".js")) {
const code = fs.readFileSync(srcPath, "utf8");
const obfuscationResult = JavaScriptObfuscator.obfuscate(code, {
compact: true,
controlFlowFlattening: true,
deadCodeInjection: true,
stringArray: true,
stringArrayEncoding: ["base64"],
});
fs.writeFileSync(destPath, obfuscationResult.getObfuscatedCode());
} else {
// Copy non-JS files as-is (views, images, css, etc.)
fs.copyFileSync(srcPath, destPath);
}
});
}

// Clean dist folder
if (fs.existsSync(distDir)) {
fs.rmSync(distDir, { recursive: true, force: true });
}

processDir(srcDir, distDir);

console.log("✅ Obfuscation complete. Files written to /dist");

Then update package.json:

"scripts": {
"build:obfuscate": "node build-obfuscate.js",
"build:pkg": "pkg . --targets node18-linux-x64",
"build": "npm run build:obfuscate && npm run build:pkg"
}

Now your build process first obfuscates the code, then packages it.


Final Thoughts

With just a few steps, you’ve created a CLI tool that:

  • Runs on any system without requiring Node.js
  • Is easily distributable as a single file
  • Can be obfuscated for code protection

This approach is great for developers who want to share tools with teammates or clients without asking them to install Node.js first.