Building a Standalone CLI App with Node.js
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:
#!/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:
{
"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
:
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.