Build a CLI Application | Deno By Example

Build a CLI Application | Deno By Example

If you have work for big organization, You must have built reusable and robust tool. That includes web, mobile and CLI Applications. A CLI will not only reduce the effort. It is also improve your CI/CD. Good CI/CD practice helps developer to achieve their goal in simple and convenient way

We have seen how to create a basic Greeting CLI in another tutorial. Now we will extend our knowledge and create a full-fledged CLI which will be partially clone of Mac/Unix find.

Image for post

Photo by Alex Knight

Creating CLI required below mentioned features:

  1. Input command arguments parser
  2. Traverse files and directory trees
  3. Filter files/directory based on the arguments
  4. Logger, better logging information

1. Input command arguments parser

async function main(args: string[]) {
  console.log(args);
}
main(Deno.args);

Taking arguments in Deno, It is very easy. Every process has Deno.args, which returns arguments passed to the program.

Run:

deno run examples/minifind.ts param1 param2

Output:

[ “param1”, “param2” ]

Deno.args returns array of the string passed to the program(examples/minifind.ts).

Our CLI expects params like type, name, and help. To get the value of these parameters. We need to parse arguments. Deno has flags module which helps to parse and collect parameters. Let's add parser.

async function main(args: string[]) {
  const {
    type,
    name,
    not,
    help,
    _: [dir = "."]
  } = parse(args);
  console.log({
    type,
    name,
    not,
    help,
    dir
  });
}
main(Deno.args);

Run:

deno run examples/minifind.ts --help --type=f --type=d --name=".*\.ts" examples

Output:

{ type: [ "f", "d" ], name: ".*\.ts", not: undefined, help: true, dir: "examples" }

When you run the program with a given example, You will see the output as above. Deno parse helps you to collect all the arguments.

I have used the ES6 de-structuring feature to assign default values.

Deno parse automatically tries to collect and combine params based on patterns. Any argument passed as prefixing ( — ), considered as arguments with value. If you don't pass value next to it. It will become boolean.

Example 1:

console.log(parse(["--test", "t"])); // { _: [], test: "t" }
console.log(parse(["--test"])); // { _: [], test: true }

Things to be noted: If you pass an argument with the same param more than once. parse combine them in array. In the above example type is passed twice. That is why type has value [ "f", "d" ].

Example 2:

console.log(parse(["--test", "t", "--test", "t2"]));

// { _: [], test: [ "t", "t2" ] }

underscore(_) here is like a collection of rest params. If arguments do not follow the standard -- or - prefix. All arguments collected in _ as an array of data. We are extracting dir as the directory name from rest _.

Example 3:

const { _ } = parse(["--test", "t", "examples"]);
console.log(_); // _ == [ "examples" ]
const [dir = "."] = _;
console.log(dir);

// examples

For more info read: https://deno.land/std/flags

2. Traverse files and directory trees

Since now we have arguments parsed, let’s add some logic to read the directory.

The first thing we can do, We can resolve the path or directory where files need to be searched. We can use the resolve method from the path module.

import { parse } from "https://deno.land/std/flags/mod.ts";
import { resolve } from "https://deno.land/std/path/mod.ts";

async function main(args: string[]) {
  const {
    type,
    name,
    not,
    help,
    _: [dir = "."]
  } = parse(args);
  const dirFullPath = resolve(Deno.cwd(), String(dir));
  console.log(dirFullPath);
}
main(Deno.args);

Run:

deno run -A examples/minifind.ts examples

Output:

_/Users/xdeepakv/github/deno-by-example/examples_

resolve require --allow-read permission. For the time being, I have given all permission passing flag -A. you can read more about permissions

Deno.cwd() is used to get current running path. We had to convert dir as a string. Since parse can convert it to string | number based on the input type.

Reading a directory can be done using Deno.readDir. But we are traversing the entire tree of directories and files. Writing the traverse method can be tricky. You can try by yourself.

Here, I will take the help of walk function from https://deno.land/std/fs/mod.ts.

import { parse } from "https://deno.land/std/flags/mod.ts";
import { resolve } from "https://deno.land/std/path/mod.ts";
import { walk } from "https://deno.land/std/fs/mod.ts";

async function main(args: string[]) {
  const {
    type,
    name,
    not,
    help,
    _: [dir = "."]
  } = parse(args);
  const dirFullPath = resolve(Deno.cwd(), String(dir));
  for await (let entry of walk(dirFullPath)) {
    console.log(entry);
  }
}
main(Deno.args);

Run:

deno run -A --unstable examples/minifind.ts examples

Output:

{
 path: "/Users/xdeepakv/github/deno-by-example/examples/sample_employee.csv",
 name: "sample_employee.csv",
 isFile: true,
 isDirectory: false,
 isSymlink: false
}
{
 path: "/Users/xdeepakv/github/deno-by-example/examples/06_readfile_chunk.ts",
 name: "06_readfile_chunk.ts",
 isFile: true,
 isDirectory: false,
 isSymlink: false
}

Since walk function is not a stable function. We have to use --unstable flag while running the example.

Walk function returns an async generator of entries. Each entries have name and path along with other flags like isDirectory and isFile.

Nice: The toughest part has been done. Now we can read entire directories along with files in it.

3. Filter files/directory based on the arguments

Walk function accepts WalkOptions as the second argument. We can use this option to add our logic.

Interface:

export interface WalkOptions {
  maxDepth?: number;
  includeFiles?: boolean;
  includeDirs?: boolean;
  followSymlinks?: boolean;
  exts?: string[];
  match?: RegExp[];
}

Code:

// rest of the code
async function main(args: string[]) {
  // rest of the code
  const dirFullPath = resolve(Deno.cwd(), String(dir));
  let includeFiles = true;
  let includeDirs = true;
  let types = type ? (Array.isArray(type) ? type : [type]) : ["f", "d"];
  if (!types.includes("f")) {
    includeFiles = false;
  }
  if (!types.includes("d")) {
    includeDirs = false;
  }
  const options = {
    maxDepth: 2,
    includeFiles,
    includeDirs,
    followSymlinks: false,
    skip: [/node_modules/g]
  };
  for await (const entry of walk(dirFullPath, options)) {
    console.log(entry.path);
  }
}
main(Deno.args);

Run:

deno run -A --unstable examples/minifind.ts examples

Output:

/Users/xdeepakv/github/deno-by-example/examples
/Users/xdeepakv/github/deno-by-example/examples/subfolder
/Users/xdeepakv/github/deno-by-example/examples/subfolder/dummy.ts

The default type would include both file and dir ["f","d"] . Users can pass the flag --type=f and --type=d to override behavior.

Run- Dirs only:

deno run -A --unstable examples/minifind.ts --type=d examples

Run- Files only:

deno run -A --unstable examples/minifind.ts --type=f examples

WalkOptions supports regexp to include and exclude patterns. We can use this to filter entries by name.

async function main(args: string[]) {
  /// rest of the code
  let matchRegexps: RegExp[] | undefined = name
    ? (Array.isArray(name) ? name : [name]).map(
        (reg: string) => new RegExp(reg)
      )
    : undefined;
  const options = {
    maxDepth: 2,
    includeFiles,
    includeDirs,
    followSymlinks: false,
    match: matchRegexps,
    skip: [/node_modules/g]
  };
  for await (const entry of walk(dirFullPath, options)) {
    console.log(entry.path);
  }
}
main(Deno.args);

Run- Get all file name have logger in it:

deno run -A --unstable examples/minifind.ts --type=f --name=”.*logger.*” examples

Now we have working minifind. Noice!

4. Logger, better logging information

/// rest of the code
import { Logger } from "https://deno.land/x/deno_util/logger.ts";
const usesFormat = `Uses:\n\n  minifind %s`;
const logger = new Logger();
function printHelp(command: string) {
  logger.info(`Welcome to minifind [v%s]`, "1.0.0");
  logger.warn(usesFormat, command);
}
async function main(args: string[]) {
  /// rest of the code
  if (help) {
    printHelp(`--type=f --name=".*logger.*" --help examples`);
    Deno.exit(0);
  }

  /// rest of the code
  for await (const entry of walk(dirFullPath, options)) {
    logger.inverse(entry.path);
  }
}
main(Deno.args);

The last missing piece is to tell your user about your CLI. For that, we have add helping messages for users. I am using logger-util created by me. You can read more here https://deno.land/x/deno_util.

Run with help:

deno run -A --unstable examples/minifind.ts --help

Output:

Image for post

Image for post

Run with other options:

deno run -A --unstable examples/minifind.ts --help

Output:

Image for post

Image for post

TaDa! 👏👏 Now you know how to create a CLI.

Bonus:

Now we have working minifind CLI. However, we had to use deno run and filename to run the command, which is not intended/feasible. Deno provides install the command. Using that, We can convert any program to an executable tool.

Let’s convert our minifind to executable. It is very simple.

deno install -f --allow-read --unstable examples/minifind.ts

Once you run above command you will see output like:

# Add /Users/xdeepakv/.deno/bin to PATH

export PATH="/Users/xdeepakv/.deno/bin:$PATH"

If you see that, just add export PATH="/Users/xdeepakv/.deno/bin:$PATH" this line to your .bashrc or .bash_profile(Depending upon your OS type). Once you add .deno/bin in PATH. Open a new terminal and try below-mentioned command.

minifind --type=f --name=".*logger.*" examples

Now your minifind is ready to use for production(CLI). :-)

I hope you like this tutorial. Please have a look at some other tutorials on our website: https://decipher.dev/deno-by-example/

All working examples can be found in my Github: https://github.com/deepakshrma/deno-by-example/tree/master/examples

Did you find this article valuable?

Support Deepak Vishwakarma by becoming a sponsor. Any amount is appreciated!