Engineering

Building a CLI in Typescript

Akshay Nathan
November 07, 2019

Building a CLI with Typescript

At 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:

Walrus CLI Tool

Setting up

Let's create a new directory and initialize npm.

$ mkdir cli
$ cd cli
$ npm init -y

We will need to install Typescript, the types for node, as well as ts-node which will enable us to run Typescript files directly without compiling.

$ 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 for the Typescript compiler:

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

And now our first Typescript file:

// src/index.ts

console.log('Hello World');

Now, we can compile and run this file:

$ 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.

// package.json
...
"scripts": {
  "dev": "ts-node src/index.ts"
}
...
$ 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, commander, and argparse. All three libraries have similar APIs, but we chose to go with yargs.

The 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.

npm i yargs
npm i -D @types/yargs
// 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:

$ 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.

$ 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 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, axios, and request. In our case, we chose axios.

npm i axios
// 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.

$ 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 or cli-progress are both good libraries for this solution.

In our case, however, while all 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 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.

// 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 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:

// 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.

// 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:

$ 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 or 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 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.

{
  "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:

// 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.

$ npx tsc
$ npm link

Now we can run our binary directly.

$ 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, authenticate in our command line, and then run:

$ npx tsc
$ npm publish

Now, our users can install our cli more easily:

$ 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 CLI here.

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

Follow us on Twitter