Deno cli app

Table of contents
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
Subscribe to my newsletter
Read articles from Justin Calleja directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by
