Reference: Internal CLI Tool
A reference internal CLI for platform teams: Commander.js, ESM, private npm publish - Node.js 24 patterns for acme-cli style tools.
Recipe
Quick-reference recipe card.
package.json bin → dist/cli.js
├── acme-cli auth login
├── acme-cli deploy promote --service orders-api
└── acme-cli db migrate status{
"name": "@acme/cli",
"version": "2.4.0",
"type": "module",
"bin": { "acme-cli": "./dist/cli.js" },
"engines": { "node": ">=24.18.0" }
}Working Example
// src/cli.ts
#!/usr/bin/env node
import { Command } from "commander";
import { registerDeployCommands } from "./commands/deploy.js";
import { registerAuthCommands } from "./commands/auth.js";
const program = new Command()
.name("acme-cli")
.description("Acme platform CLI")
.version("2.4.0");
registerAuthCommands(program);
registerDeployCommands(program);
await program.parseAsync(process.argv);// src/commands/deploy.ts
import type { Command } from "commander";
import { getConfig } from "../config.js";
export function registerDeployCommands(program: Command) {
program
.command("promote")
.requiredOption("--service <name>")
.option("--env <env>", "target", "staging")
.action(async (opts) => {
const cfg = await getConfig();
const res = await fetch(`${cfg.apiBase}/v1/deploy/promote`, {
method: "POST",
headers: { Authorization: `Bearer ${cfg.token}` },
body: JSON.stringify(opts),
});
if (!res.ok) throw new Error(`promote failed: ${res.status}`);
console.log(await res.json());
});
}// test/deploy.test.ts
import { test } from "node:test";
import assert from "node:assert/strict";
import { spawn } from "node:child_process";
test("promote requires --service", async () => {
const child = spawn("node", ["dist/cli.js", "promote"], { shell: false });
const code = await new Promise<number>((r) => child.on("close", r));
assert.notEqual(code, 0);
});Publish Pipeline
# .github/workflows/publish-cli.yml
- run: npm run build
- run: npm publish --access restricted
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}- Semver: breaking flag shape = major; new subcommand = minor
npx @acme/cli@latestdocumented in platform wiki
Deep Dive
Why Node for CLI
| Pro | Con |
|---|---|
| Same language as APIs | Cold start vs Go/Rust |
| Shared types with monorepo | Binary size |
fetch built-in Node 24 | Requires Node installed |
Config Precedence
1. --flag
2. ACME_API_TOKEN env
3. ~/.config/acme/config.json
4. defaultsDistribution Options
| Channel | Use |
|---|---|
| Private npm | Default for JS orgs |
pkg / nexe | Air-gapped (add build complexity) |
| Docker image | CI-only operators |
Gotchas
- CJS bin on ESM project - Shebang confusion. Fix:
"type":"module"+.jsimports in dist. - No semver on breaking API - Scripts break in CI. Fix: Contract tests on JSON output.
- Token in shell history - Leak. Fix: OIDC device flow or keychain store.
- Globally installed stale version -
acme-cli --versionmismatch. Fix: Documentnpm update -g @acme/cli.
Related
- CLI Basics for Node Teams - operator skills
- GitHub Actions for Node - publish pipeline
- OAuth2 & OIDC - device flow auth
- Testing Basics -
node:test - Case Studies Best Practices - reading references
Stack versions: This page was written for Node.js 24.18.0 (Active LTS), npm 10+, TypeScript 5.6+, Express 5, Fastify 5, and NestJS 11.