My go-to "scaffold" when writing a cli app in Node.js has been something like:
#!/usr/bin/env node
import { join } from "node:path";
import { readFileSync } from "node:fs";
import { Command } from "commander";
import { filesCmd } from "./cmd/files";
import { tunnelCmd } from "./cmd/tunnel";
const program = new Command();
let cliVersion = "0.0.1";
try {
const pkgJSON = JSON.parse(
readFileSync(join(__dirname, "..", "package.json")).toString("utf-8"),
);
cliVersion = pkgJSON.version;
} catch (_err) {}
program
.name("tmp-cli")
.description(`A CLI app`)
.version(cliVersion);
program.addCommand(filesCmd());
program.addCommand(tunnelCmd());
program.parseAsync();
Where each imported command e.g. filesCmd
has a dynamic import when it's actually executed to avoid loading all the commands on startup of app (e.g. just displaying --help
output shouldn't require node to interpret every command in your app):
import { Command } from "commander";
export const filesCmd = () => {
const cmd = new Command("files").description("interact with the files table");
cmd.action(async () => {
const { run } = await import("./files.js");
await run();
process.exit(0);
});
return cmd;
};
How might this look like in Deno?
Project setup
deno init cliffytmp && cd cliffytmp
Open deno.json
and add @cliffy/command
(get version from: https://jsr.io/@cliffy/command/versions):
"imports": {
"@std/assert": "jsr:@std/assert@1",
"@cliffy/command": "jsr:@cliffy/command@^1.0.0-rc.8"
}
If using vscode or similar, install deno extension and enable it in .vscode/settings.json
:
{
"deno.enable": true
}
Copy / paste an example from https://github.com/c4spar/deno-cliffy/blob/main/examples/command.ts
#!/usr/bin/env -S deno run --allow-net
import { Command } from "@cliffy/command";
await new Command()
.name("reverse-proxy")
.description("A simple reverse proxy example cli.")
.version("v1.0.0")
.option("-p, --port <port:number>", "The port number for the local server.", {
default: 8080,
})
.option("--host <hostname>", "The host name for the local server.", {
default: "localhost",
})
.arguments("[domain]")
.action(({ port, host }, domain = "deno.com") => {
Deno.serve(
{
hostname: host,
port,
},
(req: Request) => {
const url = new URL(req.url);
url.protocol = "https:";
url.hostname = domain;
url.port = "443";
console.log("Proxy request to:", url.href);
return fetch(url.href, {
headers: req.headers,
method: req.method,
body: req.body,
});
}
);
})
.parse();
chmod +x ./main.ts
and run it:
❯ ./main.ts
Listening on http://[::1]:8080/
Browse to localhost:8080 and you should see the deno.com
site.
All set up - after running - external modules should be cached by deno and any missing dependencies errors in vscode should be sorted out.
Start dev
mkdir src && touch ./src/index.ts && chmod +x ./src/index.ts
Add a version for the cli app in deno.json
and replace the dev
task with the build
one below (since we're not running a server we want to re-load on file changes - we just want to build a self-contained bin as a result):
{
"version": "0.1.0",
"tasks": {
"build": "deno compile --output=./bin/cliffytmp ./src/index.ts"
},
"imports": {
"@std/assert": "jsr:@std/assert@1",
"@cliffy/command": "jsr:@cliffy/command@^1.0.0-rc.8"
}
}
Re getting the app's version from "deno.json", we can do this via a compile time import instead of a runtime file read in Deno with:
#!/usr/bin/env -S deno run --allow-read
import denoConfig from "../deno.json" with { type: "json" };
const cliVersion = denoConfig.version ?? "0.0.1";
console.log(cliVersion);
You should see the 0.1.0
output when running ./src/index.ts
.
Cliffy commands
With the boilerplate taken care of, the main command can be defined:
#!/usr/bin/env -S deno run --allow-read
import { Command } from "@cliffy/command";
import { filesCommand } from "./cmds/files.ts";
import { tunnelCommand } from "./cmds/tunnel.ts";
import denoConfig from "../deno.json" with { type: "json" };
const cliVersion = denoConfig.version ?? "0.0.1";
const cli = new Command()
.name("tmp-cli")
.description("A demo CLI")
.version(cliVersion)
.command("files", filesCommand)
.command("tunnel", tunnelCommand);
await cli.parse();
Adding "@std/async/delay": "jsr:@std/async@1/delay"
to imports
in deno.json
and a src/utils.ts
file with:
import { delay } from "@std/async/delay";
export const sleep = delay;
// export const sleep = (ms: number) =>
// new Promise((resolve) => setTimeout(resolve, ms));
Finally adding the subcommands:
// src/cmds/files.ts
import { Command } from "@cliffy/command";
export const filesCommand = new Command()
.name("files")
.description("Manage files")
.action(async () => {
console.log("in files cmd");
const { sleep } = await import("../util.ts");
await sleep(1000);
console.log("files cmd done");
});
// src/cmds/tunnel.ts
import { Command } from "@cliffy/command";
export const tunnelCommand = new Command()
.name("tunnel")
.description("Manage tunnels")
.action(async () => {
console.log("in tunnel cmd");
const { sleep } = await import("../util.ts");
await sleep(4000);
console.log("tunnel cmd done");
});
Ends up with more or less the same structure as with the Node.js program above using commander.
Building is a matter of deno run build
to get a self contained binary in ./bin/cliffytmp
. Just put that file somewhere on your $PATH
env var and you're good to go.
Git repo
Here's a repo with the code in this post: https://github.com/justin-calleja/tmpcli-node-deno
It also has an equivalent node.js project built to binary with Bun
Top comments (0)