SearchCtrlK

  • ↑↓navigate
  • ↵open
  • escclose

Can’t find it? Contact us

Build a command-line tool

A tool people install from npm that reads their configuration file, calls an HTTP API through a typed client, retries the calls that can succeed next time, and is tested without a terminal.

After reading this guide, you will know

  • How to put commands, a configuration file and an API client together in one tool
  • Where retries go so a flaky call never reaches the person running the tool
  • How to test a whole command against a mocked API

At the end of this recipe you have books, a command-line tool for an HTTP API: it reads the user's books.jsonc, calls the API through a typed client, retries a call the server could answer next time, and runs end to end in a test. Each linked guide lists the packages it installs; the code on this page adds:

Terminal
npm add @sdxc/backoff @sdxc/cli @sdxc/result

Declare the commands

Help, usage errors and every handler's types come from the command contract, so it comes first. Follow Build a command line, and declare a show command with one id argument for the steps below.

Read the user's configuration file

Follow Read configuration files, reading books.jsonc with a schema of your own settings: here, origin: s.string(), the API the tool talks to. Its middleware hands the settings to every command as ctx.config.

Call the API through a typed client

Follow Write a typed API client, putting the client in src/books/ and importing its modules relatively. Every call answers a Result, so a handler only branches on success or failure.

Retry the calls that can succeed next time

A timeout or a 503 often clears on its own, and a person at a terminal waits through every attempt, so the show handler retries on the short schedule from Retry inside one request, and only a call that got no answer, a 429 or a 5xx. It reads the origin from ctx.config and the key from ctx.env:

src/cli.ts
import { createBackoff } from "@sdxc/backoff";
import { createCLI, failure, success, usage } from "@sdxc/cli";
import { isFailure, retry } from "@sdxc/result";

import { BooksClient } from "./books/client.js";
import { COMMANDS } from "./commands.js";
import { config } from "./middleware/config.js";

const BACKOFF = createBackoff({ base: 200, max: "2 seconds", jitter: "full" });

export const cli = createCLI({
	name: "books",
	version: "1.0.0",
	commands: COMMANDS,
	middleware: [config()],
});

cli.map(COMMANDS.show, async (ctx) => {
	let token = ctx.env.BOOKS_API_KEY;
	if (token === undefined) return usage("Set BOOKS_API_KEY to your API key");

	let client = new BooksClient(token, new URL(ctx.config.origin));
	let book = await retry(() => client.book(ctx.arguments.id), {
		times: 4,
		delay: (attempt) => BACKOFF.delay(attempt),
		when: (error) =>
			error.status === null || error.status === 429 || error.status >= 500,
	});
	if (isFailure(book)) {
		return failure(new Error("Could not read the book", { cause: book.error }));
	}

	ctx.stdout(`${JSON.stringify(book.data, null, 2)}\n`);
	return success();
});

Test the command

invoke from @sdxc/cli/test runs show with the folder and environment you give it, so a test writes a books.jsonc into a temporary folder, sets BOOKS_API_KEY, and has MSW answer a 503 once before the book: one run then checks the configuration, the client and the retry together. Test a command shows invoke, and Write a typed API client mocks the API with MSW.

Where to go next

Documentation

Can’t find it? Contact us