Building a Typescript CLI
Tags: javascript, typescript, cli
Published: 2019-11-07 16:51:52

# Building a CLI with Typescript

At [walrus.ai](http://walrus.ai), we're building a platform for end-to-end testing via a single API call. Our users give us a url and plain English instructions, and we use a human assisted trained model to verify each test-case. While one can use the walrus.ai API using **curl** or any http library of their favorite language, we recently decided to build a command line tool to make it easier to submit walrus.ai tests, and plug them into existing CI/CD pipelines.

This blog post will go over building this CLI in Typescript. First, the finished product:

![The walrus.ai CLI](https://thepracticaldev.s3.amazonaws.com/i/53b8wudxnaq1z7cnntxp.gif)

## Setting up

Let's create a new directory and initialize **npm**.

```bash
$ mkdir cli
$ cd cli
$ npm init -y
```

We will need to install Typescript, the types for node, as well as [ts-node](https://github.com/TypeStrong/ts-node) which will enable us to run Typescript files directly without compiling.

```bash
$ npm install -D typescript @types/node ts-node
```

Notice how we're installing all Typescript related packages as dev dependencies? This is because our published package will only need the compiled Javascript. More on that later.

For now, let's create a basic [tsconfig.json](https://www.typescriptlang.org/docs/handbook/tsconfig-json.html) for the Typescript compiler:

```json
{
  "compilerOptions": {
    "baseUrl": ".",
    "target": "ES2017",
    "module": "commonjs",
    "strict": true,
    "esModuleInterop": true,
    "rootDir": "src",
    "outDir": "dist"
  }
}
```

And now our first Typescript file:

```javascript
// src/index.ts

console.log('Hello World');
```

Now, we can compile and run this file:

```bash
$ npx tsc
$ node dist/index.js
Hello World
```

Remember **ts-node**, which we installed earlier? We can use it to run our program more easily while developing. Let's create an npm script using **ts-node**.

```javascript
// package.json
...
'scripts': {
  'dev': 'ts-node src/index.ts'
}
...
```

```bash
$ npm run dev

> npx ts-node src/index.ts

Hello World
```

## Accepting input

Almost all command line tools follow similar flows — they accept input via arguments or stdin, they do something, and then they output results to stdout and errors to stderr.

In node, a program's arguments are stored in an array inside `process.argv`. You can access these arguments directly, or you can use an option parsing library to simplify access and create a better user experience. Some node options include [yargs](https://github.com/yargs/yargs), [commander](https://github.com/tj/commander.js/), and [argparse](https://github.com/nodeca/argparse). All three libraries have similar APIs, but we chose to go with yargs.

The [walrus.ai](http://walrus.ai) API functionally takes in 3 required parameters. An **API key** to identify the user, the **url** of the application we want to test against, and a list of **instructions** to execute and verify. Let's install yargs and parse these arguments.

```bash
npm i yargs
npm i -D @types/yargs
```

```javascript
// src/index.ts

import yargs from 'yargs';

const args = yargs.options({
  'api-key': { type: 'string', demandOption: true, alias: 'a' },
  'url': { type: 'string', demandOption: true, alias: 'u' },
  'instructions': { type: 'array', demandOption: true, alias: 'i' },
}).argv;

console.log(args);
```

We can use the `demandOption` parameter to require a program argument. If we try to rerun our script now, our program will complain about the missing arguments:

```bash
$ npm run dev

> npx ts-node src/index.ts

Options:
  --help              Show help                                        [boolean]
  --version           Show version number                              [boolean]
  --api-key, -a                                              [string] [required]
  --url, -u                                                  [string] [required]
  --instructions, -i                                          [array] [required]

Missing required arguments: api-key, url, instructions
```

When we supply them, we can see that `yargs` has parsed our arguments into a **strongly-typed** map.

```bash
$ npm run dev -- -a 'key' -u 'url' -i 'instruction'

> ts-node src/index.ts "-a" "key" "-u" "url" "-i" "instruction"

{
  _: [],
  a: 'key',
  'api-key': 'key',
  apiKey: 'key',
  u: 'url',
  url: 'url',
  i: [ 'instruction' ],
  instructions: [ 'instruction' ],
  '$0': 'src/index.ts'
}
```

## Doing something

Now that our CLI is accepting input, the next step is to **do something**.

In the case of the [walrus.ai](http://walrus.ai) CLI, we want to call the API with our parsed arguments. Again, there are many libraries we can use to make HTTP requests including [superagent](https://github.com/visionmedia/superagent), [axios](https://github.com/axios/axios), and [request](https://github.com/request/request). In our case, we chose `axios`.

```bash
npm i axios
```

```javascript
// src/index.ts

import yargs from 'yargs';
import axios from 'axios';

const args = yargs.options({
  'api-key': { type: 'string', demandOption: true, alias: 'a' },
  'url': { type: 'string', demandOption: true, alias: 'u' },
  'instructions': { type: 'array', demandOption: true, alias: 'i' },
}).argv;

axios
  .post(
    'https://api.walrus.ai',
    { url: args['url'], instructions: args['instructions'] },
    { headers: { 'X-Walrus-Token': args['api-key'] }, },
  )
  .then(
    (response) => {
      console.log(JSON.stringify(response.data, null, 2));
    },
    (reason) => {
      console.error(JSON.stringify(reason.response.data, null, 2));
    },
  );
```

Note that we're handling both branches of the Promise returned by `axios.post`. Maintaining convention, we print successful results to `stdout` and error messages to `stderr`. Now when we run our program, it will silently wait while the test is completed, and then print out the results.

```bash
$ npm run dev -- -a fake-key -u https://google.com -i 'Search for something'

> cli@1.0.0 dev /Users/akshaynathan/dev/blog/cli
> ts-node src/index.ts "-a" "fake-key" "-u" "https://google.com" "-i" "Search for something"

{
  "error": "Authentication required. Please sign in at https://app.walrus.ai/login."
}
```

## Displaying progress

We can improve on our CLI by making it slightly more interactive. On the web, long-running operations are often handled in the UI by displaying some sort of loading state. There are a few node libraries that can help us bring these UI paradigms to the command line.

Loading bars are useful when the long-running task takes a relatively static amount of time, or if we have a discrete intuition about 'progress'. [node-progress](https://github.com/visionmedia/node-progress) or [cli-progress](https://github.com/AndiDittrich/Node.CLI-Progress) are both good libraries for this solution.

In our case, however, while all [walrus.ai](http://walrus.ai) results are returned under 5 minutes, we don't have a discrete notion of progress. A test is either **pending**, or it has been **completed**. Spinners are a better fit for our CLI, and [ora](https://github.com/sindresorhus/ora#readme) is a popular node spinner library.

We can create our spinner before making our request, and clear our spinner once the Promise resolves or rejects.

```javascript
// src/index.ts

import yargs from 'yargs';
import axios from 'axios';
import ora from 'ora';

const args = yargs.options({
  'api-key': { type: 'string', demandOption: true, alias: 'a' },
  'url': { type: 'string', demandOption: true, alias: 'u' },
  'instructions': { type: 'array', demandOption: true, alias: 'i' },
}).argv;

const spinner = ora(`Running test on ${args['url']}`).start();

axios
  .post(
    'https://api.walrus.ai',
    { url: args['url'], instructions: args['instructions'] },
    { headers: { 'X-Walrus-Token': args['api-key'] }, },
  )
  .then(
    (response) => {
      spinner.stop();
      console.log(JSON.stringify(response.data, null, 2));
    },
    (reason) => {
      spinner.stop();
      console.error(JSON.stringify(reason.response.data, null, 2));
    },
  );
  ```

Now when we run our program, we will see the spinner from the GIF above!

## Exiting

The last thing our CLI program has to do is to exit, and exit correctly. When programs exit, they can specify an integer exit code to indicate success of failure. Generally, any non-zero exit code indicates failure.

For the [walrus.ai](http://walrus.ai) CLI, correctly specifying an exit code is imperative. Our users call our CLI from CI/CD pipelines. When a test fails, we have to exit with a non-zero exit code so that the next step in the pipeline, usually the deploy to production, doesn't run.

You may be tempted to use node's `process.exit` API:

```javascript
// src/index.ts

...
(response) => {
      spinner.stop();
      console.log(JSON.stringify(response.data, null, 2));
      process.exit(0);
    },
    (reason) => {
      spinner.stop();
      console.error(JSON.stringify(reason.response.data, null, 2));
      process.exit(1);
    },
...
```

However, `process.exit` will exit the program **synchronously**, even if there are operations waiting to be run or caches that need to be flushed. The most common problem here is output. In the above code, depending on how our output is buffered, our program may exit **before** our success or error messages are printed to the screen.

We can solve this by simply setting the exit code, and letting the node script **automatically** exit upon completion.

```javascript
// src/index.ts

import yargs from 'yargs';
import axios from 'axios';
import ora from 'ora';

const args = yargs.options({
  'api-key': { type: 'string', demandOption: true, alias: 'a' },
  'url': { type: 'string', demandOption: true, alias: 'u' },
  'instructions': { type: 'array', demandOption: true, alias: 'i' },
}).argv;

const spinner = ora(`Running test on ${args['url']}`).start();

axios
  .post(
    'https://api.walrus.ai',
    { url: args['url'], instructions: args['instructions'] },
    { headers: { 'X-Walrus-Token': args['api-key'] }, },
  )
  .then(
    (response) => {
      spinner.stop();
      console.log(JSON.stringify(response.data, null, 2));
    },
    (reason) => {
      spinner.stop();
      console.error(JSON.stringify(reason.response.data, null, 2));
      process.exitCode = 1;
    },
  );
```

Now when we run our script, it will fail with a non-zero exit code:

```bash
$ npm run dev -- -a fake-key -u https://google.com -i 'Search for something'

> ts-node src/index.ts "-a" "fake-key" "-u" "https://google.com" "-i" "Search for something"

{
  "error": "Authentication required. Please sign in at https://app.walrus.ai/login."
}
$ echo $?
1
```

## Publishing

Now that we've built our CLI, we need to publish it so our users can use it.

There are many options we have here. Most simply, we can distribute the package and the CLI via npm. Alternatively, we could use a library like [pkg](https://github.com/zeit/pkg) or [oclif](https://github.com/oclif/oclif) to bundle `node` itself into our binary. This way, users will not need to have npm or node installed to run our tool.

Since [walrus.ai](http://walrus.ai) is a tool for running browser end-to-end tests, and our users are probably already familiar with npm and node, we decided to go with the simple option. First, we can edit our package.json to specify a binary, in this case `walrus`.

```json
{
  "name": "@walrusai/cli",
  "version": "1.0.0",
  "description": "",
  "scripts": {
    "dev": "ts-node src/index.ts"
  },
  "bin": {
    "walrus": "dist/index.js"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "devDependencies": {
    "@types/node": "^12.12.6",
    "@types/yargs": "^13.0.3",
    "ts-node": "^8.4.1",
    "typescript": "^3.7.2"
  },
  "dependencies": {
    "axios": "^0.19.0",
    "ora": "^4.0.2",
    "yargs": "^14.2.0"
  }
}
```

Next, let's make our `index.ts` runnable by telling the shell how to run it:

```javascript
// src/index.ts

#!/usr/bin/env node
...
```

Now we can use `npm link`, to effectively link our node script into our path, as if we installed the binary.

```bash
$ npx tsc
$ npm link
```

Now we can run our binary directly.

```bash
$ walrus -a fake-key -u https://google.com -i 'Search for something'
{
  "error": "Authentication required. Please sign in at https://app.walrus.ai/login."
}
```

`npm link` is useful for development, but we want our users to be able to install our CLI more easily. For that, we can publish to `npm`.

First, we should create a unique name for our package — `@walrusai/cli` in our case.

Next, we will need to create an account on [npm](https://npmjs.com), authenticate in our command line, and then run:

```bash
$ npx tsc
$ npm publish
```

Now, our users can install our cli more easily:

```bash
$ npm install -g @walrusai/cli
```

## Conclusion

In this blog post, we've built a Typescript CLI that accepts user input, makes an api call, outputs results, and exits correctly. You can check out the final open-source implementation of the [walrus.ai](http://walrus.ai) CLI [here](https://github.com/walrusai/cli).

Are you an engineer tired of building and maintaining flaky browser tests?  Try [walrus.ai](http://walrus.ai) now, supply instructions in plain english, and receive results in under 5 minutes.