How to Build a Command-Line Application with Typescript and AdonisJS Ace

How to Build a Command-Line Application with Typescript and AdonisJS Ace

This article shows you how to build a command-line application using typescript and @adonisjs/ace. The command-line application accepts user input and makes a request to the numbers API. The request returns an interesting fact about the number. To begin you need Node.js and npm installed on your machine, npm comes together with Node.js. Basic knowledge of typescript is also advised although not necessary. The code in use can be found in this repo.

Installation

  1. We run npm init -y. It sets up a new Node.js project in the current folder with the default details without asking any questions.
  2. We install typescript globally using npm install -g typescript. Next, we set up a new typescript project by running the command tsc --init. The tsc --init command creates a tsconfig.json file.
    Open the generated tsconfig.json file and update the following details
     "target": "es2015",
     "outDir": "./dist",     
     "rootDir": "./src",
    
    The key rootDir describes the root directory that typescript will compile from. The key outDir describes the directory to save the compiled file. The key target describes the lowest level to compile the code to.
  3. We install AdonisJs Ace which is a tool for creating command-line applications in Node.js
    npm i --save @adonisjs/ace
    
  4. We install type definition for Node.js, that allows you to use global values like process, require etc. in typescript.
    npm i --save @types/node
    

Creating the Shebang

According to Wikipedia

In computing, a shebang is the character sequence consisting of the characters number sign and exclamation mark at the beginning of a script. It is also called sha-bang, hashbang, pound-bang, or hash-pling.

The Shebang tells the operating system what interpreter to use in running the script. To create the shebang:

  1. We create the src folder, in the project root folder.
  2. We create a folder called bin in the src folder.
  3. We create a file we will call numb-cli.ts.
  4. We paste the shebang by pasting the line below.
#!/usr/bin/env node

console.log('Welcome')
  1. We update the package.json file by adding the key-value pair.
    "bin": {
     "numb-cli": "dist/bin/numb-cli.js"
    },
    

    Testing the Shebang

    To test if the shebang is working,
  2. We compile the typescript file into javascript by running the command tsc on the terminal.
  3. We install the package globally by running the command npm link
  4. We run the package by running the command numb-cli.
  5. It should return the response Welcome. N.b: To uninstall the package, use the command npm unlink.

testing cli 1.gif

Create index.ts file

After testing the shebang, we add the line import ".."; in place of console.log('Welcome'). The new line will import the index.ts file, which we will create in the src folder. In the package.json file we update, the value for the key "main" with "dist/index.js",. So the package.json file becomes:

{
  "name": "numb-cli",
  "version": "1.0.0",
  "description": "",
  "main": "dist/index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "bin": {
    "numb-cli": "dist/bin/numb-cli.js"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "dependencies": {
    "@adonisjs/ace": "^5.0.8",
    "@types/node": "^14.14.7"
  }
}

And the src/bin/numb-cli.ts becomes:

#!/usr/bin/env node

import "..";

In the index.ts file, we import ace, and set it up to execute commands

import ace from '@adonisjs/ace';

ace.wireUpWithCommander()
ace.invoke()

This should work when tested, but the @adonisjs/ace package has no type definition so it will not compile the typescript to javascript. Let us create a declaration file for @adonisjs/ace.

Creating declaration file

At the root of the src folder:

  1. Create a folder called @adonisjs
  2. Create a file called ace.d.ts
  3. Paste in the line declare module '@adonisjs/ace'; On testing, a response like the one below will appear.

testing cli 2.gif

Creating commands

To create commands, we extend the ace command class. The new class enables us to overrides the signature, description and the handle method.

  • Signature method: The signature method is a getter that returns the name of the command
  • Description method: The description method is a getter that returns a description of the command.
  • Handle method: This is a method that is called when we call the command. It is an asynchronous method. It accepts two (2) parameters which are not compulsory,
  • args
  • options We register the new command by passing it as a parameter to the ace.addCommand method, which we call in the src/index.ts file.

The src/index.ts file becomes

import ace from '@adonisjs/ace';
import Random from './commands/Random';

ace.addCommand(Random);

ace.wireUpWithCommander();
ace.invoke();

We will call the command subclass Random and keep it in a Random.ts file in the commands folder. The Random.ts file will contain:

import {Command} from '@adonisjs/ace';

class Random extends Command {
    static get signature ():string {
        return 'random';
    }

    static get description ():string {
        return "Returns a random fact about a number";
    }

    async handle () {
        console.log('Welcome');
    }
}

export default Random;

On testing the created command we get testing cli 3.gif

Asking questions

@adonisjs/ace provides methods to ensure that interaction with users occurs. To interact with users, we will use the ask and the choice method amongst others. The ask method accepts the question and a default answer which is optional as parameters. It allows a user to input a response to the question asked. The choice method accepts 3 parameters, a question, an array of options to choose from and an optional default option. Using the two methods mentioned above, we ask the users what number he/she wants a random fact about and what category the random fact will be about. The code in the Random.ts file will look like this:

import {Command} from '@adonisjs/ace';

class Random extends Command {
    static get signature ():string {
        return 'random';
    }

    static get description ():string {
        return "Returns a random fact about a number";
    }

    async handle () {
        const number:number = await this
            .ask('Input a number')

        const category:string = await this
            .choice('Select a category', [
                'trivia',
                'math',
                'date',
                'year'
            ]);
    }
}

export default Random;

Testing the CLI should return:

testing cli 4.gif

Making an HTTP request

There is a need to use the details gotten earlier from the user in other get the random fact. @adonisjs/ace does not have the capability to make an HTTP request. To make an HTTP request we install the got NPM package.

npm i got

The command above installs got as a dependency. Next, we import got into the Random.ts file.

import got from 'got';

In the handle method of the Random class, we make an HTTP get request to the URL that returns the fact.

const url:string = `http://numbersapi.com/${number}/${category}`

const response = await got(url);

Displaying response

At this point, the user needs to see the random fact. This is possible with either the methods provided by @adonisjs/ace Command class or with the use of methods of the Console class. The @adonisjs/ace methods each give the displayed text to a unique colour. With the console methods, the text becomes beautiful using the this.chalk method of the Command class. this.chalk accesses an instance of kleur offering flexibility to the user. We add Icons to the displayed response by using the this.icon method of the Command class and passing one of the acceptable parameters which are:

  • success
  • error
  • info
  • warn Each of this parameter represents a particular colour and an icon. For the CLI we add the snippet below to display other response as well our icon
    this.success(`${response.body} -  ${this.icon('success')}`);
    

testing cli 5.gif

Handling exceptions

We have built the CLI this far:

  • Creating a command;
  • Asking questions;
  • Accepting responses;
  • Making an external HTTP request;
  • Getting back results;
  • Displaying the results to the user.

But what if there is an exception since we did not confirm user input and other errors that may occur from making an external network call. To handle this @adonisjs/ace provides an event listener onError. The onError listens for exceptions, notes the command that throws it, and passes the exception and the command to a closure function. The closure function is where we display the error message from and stop the running process. The onError listener is a method of the ace class and is placed in the index.ts file. Since the use of icons and colourful text displays works as methods on the command class. We create an instance of the Command class so we can use its methods. To throw an exception we check the user input ensuring it is a whole number. So in the handle method of the Random class, we add these checks:

// check if number is of datatype number
if(isNaN(number)) {
    throw new Error(`${number} is not a number.`)
}

// ensures that number is not a float
if(number % 1 != 0) {
    throw new Error(`${number} should not be a decimal number`)
}

The index.ts file becomes:

import ace, {Command} from '@adonisjs/ace';

interface ErrorMessage {
    message: string;
}

const command = new Command()

ace.onError(function (error:ErrorMessage, commandName:string) {
    command.error(`${commandName} reported ${error.message} - ${command.icon('error')}`)

    process.exit(1)
  })

The interface ErrorMessage used above ensures the datatype of keys within the error object. So it becomes the datatype for the object.

testing cli 6.gif

Conclusion

This article explains how to build a Nodejs CLI using typescript and @adonisjs/ace. Always remember to compile to javascript before running and to always unlink before linking back when testing a new update. Finally, the code in the index.ts file will be:

import ace, {Command} from '@adonisjs/ace';
import Random from './commands/Random';

ace.addCommand(Random);

interface ErrorMessage {
    message: string;
}

const command = new Command()

ace.onError(function (error:ErrorMessage, commandName:string) {
    command.error(`${commandName} reported ${error.message} - ${command.icon('error')}`)

    process.exit(1)
  })

ace.wireUpWithCommander();
ace.invoke();

and the class in the Random.ts file will be:

import { Command } from '@adonisjs/ace';
import got from 'got';

class Random extends Command {
    static get signature ():string {
        return 'random';
    }

    static get description ():string {
        return "Returns a random fact about a number";
    }

    async handle () {
        const number:number = await this
            .ask('Input a number')
        if(isNaN(number)) {
            throw new Error(`${number} is not a number.`)
        }

        if(number % 1 != 0) {
            throw new Error(`${number} should not be a decimal number`)
        }

        const category:string = await this
            .choice('Select a category', [
                'trivia',
                'math',
                'date',
                'year'
            ]);

        const url:string = `http://numbersapi.com/${number}/${category}`

        const response = await got(url);

        this.success(`${response.body} - ${this.icon('success')}`);
        process.exit(0)
    }
}

export default Random;

To learn more about @adonisjs/ace you can check the official documentation here. @adonisjs/ace also has the ability to work with the filesystem and the database. I hope this helps as a starting point in showing how to use the @adonisjs/ace and also how to use it with typescript. If you have come this far thanks for reading. Kindly drop your comments in the comment section.