Deno cli app

Justin CallejaJustin Calleja
4 min read

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

0
Subscribe to my newsletter

Read articles from Justin Calleja directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Justin Calleja
Justin Calleja