Building attractive CLIs in TypeScript
source link: https://blog.terrible.dev/Building-attractive-CLIs-in-JavaScript/
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
Building attractive CLIs in TypeScript
Friday, 08 July 2022
So you've come to a point where you want to build nice CLIs. There's a few different options for building CLI's. My two favorites are oclif and commander.js. I tend toward leaning to commander, unless I know I'm building a super big app. However, I've really enjoyed building smaller CLIs with commander recently.
tl;dr? You can view this repo
Commander.js Lingo
So commander has a few different nouns.
Program
- The root of the CLI. Handles running the core app.Command
- A command that can be run. These must be registered intoProgram
Option
- I would also call theseflags
they're the--something
part of the CLI.Arguments
- These are named positioned arguments. For examplenpm install commander
thecommander
string in this case is an argument.--save
would be an option.
Initial Setup
First, do an npm init, and install commander, types for node, typescript, esbuild, and optionally ora.
npm init -y
npm install --save commander typescript @types/node ora
Next we have to configure a build command in the package.json. This one runs typescript to check for types and then esbuild to compile the app for node.
"scripts": {
"build": "tsc --noEmit ./index.ts && esbuild index.ts --bundle --platform=node --format=cjs --outfile=dist/index.js",
}
We now need to add a bin property in the package.json. This tells the package manager that we have an executable. The key should be the name of your CLI
"bin": {
"<yourclinamehere>": "./dist/index.js"
}
Make a file called index.ts, and place this string on the first line. This is called a shebang and it tells your shell to use node when the file is ran.
#!/usr/bin/env node
Getting started
Hopefully you have done the above. Now in index.ts you can make a very basic program. Try npm build and then run the CLI with --help. Hopefully you'll get some output.
#!/usr/bin/env node
import { Command } from 'commander'
import { spinnerError, stopSpinner } from './spinner';
const program = new Command('Our New CLI');
program.version('0.0.1');
program.addHelpCommand()
async function main() {
await program.parseAsync();
}
console.log() // log a new line so there is a nice space
main();
Setting up the spinner
So, I really like loading spinners. I think it gives the CLI a more polished feel. So I added a spinner using ora. I made a file called spinner.ts
which is a wrapper to handle states of spinning or stopped.
import ora from 'ora';
const spinner = ora({ // make a singleton so we don't ever have 2 spinners
spinner: 'dots',
})
export const updateSpinnerText = (message: string) => {
if(spinner.isSpinning) {
spinner.text = message
return;
}
spinner.start(message)
}
export const stopSpinner = () => {
if(spinner.isSpinning) {
spinner.stop()
}
}
export const spinnerError = (message?: string) => {
if(spinner.isSpinning) {
spinner.fail(message)
}
}
export const spinnerSuccess = (message?: string) => {
if(spinner.isSpinning) {
spinner.succeed(message)
}
}
export const spinnerInfo = (message: string) => {
spinner.info(message)
}
Writing a command
So I like to seperate my commands out into sub-commands. In this case we're making widgets
a sub-command. Make a new file, I call it widgets.ts. I create a new Command
called widgets
. Commands can have commands making them sub-commands. So we can make a sub-command called list
and get
. List will list all the widgets we have, and get will retrive a widget by id. I added some promise to emulate some delay so we can see the spinner in action.
import { Command } from "commander";
import { spinnerError, spinnerInfo, spinnerSuccess, updateSpinnerText } from "./spinner";
export const widgets = new Command("widgets");
widgets.command("list").action(async () => {
updateSpinnerText("Processing ");
// do work
await new Promise(resolve => setTimeout(resolve, 1000)); // emulate work
spinnerSuccess()
console.table([{ id: 1, name: "Tommy" }, { id: 2, name: "Bob" }]);
})
widgets.command("get")
.argument("widget id <id>", "the id of the widget")
.option("-f, --format <format>", "the format of the widget") // an optional flag, this will be in options.f
.action(async (id, options) => {
updateSpinnerText("Getting widget " + id);
await new Promise(resolve => setTimeout(resolve, 3000));
spinnerSuccess()
console.table({ id: 1, name: "Tommy" })
})
Now lets register this command into our program. (see the last line)
#!/usr/bin/env node
import { Command } from 'commander'
import { spinnerError, stopSpinner } from './spinner';
import { widgets } from './widgets';
const program = new Command('Our New CLI');
program.version('0.0.1');
program.addHelpCommand()
program.addCommand(widgets);
Do a build! Hopefully you can type <yourcli> widgets list
and you'll see the spinner. When you call spinnerSuccess
without any parameters the previous spinner text will stop and become a green check. You can pass a message instead to print that to the console. You can also call spinnerError
to make the spinner a red x
and print the message.
Handle unhandled errors
Back in index.ts we need to add a hook to capture unhandled errors. Add a verbose flag to the program so we can see more details about the error, but by default lets hide the errors.
const program = new Command('Our New CLI');
program.option('-v, --verbose', 'verbose logging');
Now we need to listen for the node unhandled promise rejection event and process it.
process.on('unhandledRejection', function (err: Error) { // listen for unhandled promise rejections
const debug = program.opts().verbose; // is the --verbose flag set?
if(debug) {
console.error(err.stack); // print the stack trace if we're in verbose mode
}
spinnerError() // show an error spinner
stopSpinner() // stop the spinner
program.error('', { exitCode: 1 }); // exit with error code 1
})
Testing our error handling
Lets make a widget action called unhandled-error
. Do a build, and then run this action. You should see the error is swallowed. Now try again but use <yourcli> --verbose widgets unhandled-error
and you should see the error stack trace.
widgets.command("unhandled-error").action(async () => {
updateSpinnerText("Processing an unhandled failure ");
await new Promise(resolve => setTimeout(resolve, 3000));
throw new Error("Unhandled error");
})
Organizing the folders
Ok, so you have the basics all setup. Now, how do you organize the folders. I like to have the top level commands in their own directories. That way the folder structure emulates the CLI. This is an idea I saw in oclif.
- index.ts
- /commands/widgets/index.ts
- /commands/widgets/list.ts
- /commands/widgets/get.ts
So why not OCLIF?
A few simple reasons. OCLIF's getting started template comes with an extremely opinionated typescript configuration. For large projects, I've found it to be incredible. However, for smaller-ish things, I've found conforming to it, a trial of turning down the linter a lot. Overall, they're both great tools. Why not both?
Recommend
-
201
sobolevn-dotfiles (3 Part Series) For people who spend half of their lives in...
-
40
It’s probably obvious, but most modern applications crave data and in many of those scenarios, being able to visualize the data is a necessity. This is where charting and graphs become valuable within frontend applicatio...
-
22
One thing I like to do to improve the command-line programs I maintain is to make them aware of whether they’re being run interactively. In this post I’ll show off an easy trick to make programs running interactively more...
-
7
Netflix and the Conservation of Attractive Profits Posted onWednesday, July 8, 2015Wednesday, April 8, 2020 Two years ago, when I first wrote about TV and
-
7
Getting Started with Blogging for the Attractive Female Blogger Aug 30, 2002 Getting Started with Blogging for the Attractive Female Blogger Last updated: 9/3/2002; 7:34:58 PM
-
6
The 5 Most Attractive Qualities in a Partner According to 70 MenWhat men want changes with age — but not as much as you think
-
7
3 Steps to Creating an Attractive Value Proposition The value proposition of a product provides one of the most important meeting points for user experience design and marketing. Although it deals m...
-
3
A simpler building block for Go CLIsPhoto by Glen Carrie on
-
7
As my journey on learning Rust continues, I looked at several options for building Command-Line Interfaces (CLIs) in Rust. There are several crates available you can use to craft tailored CLIs in Rust. At some point,
-
6
Best practices for inclusive CLIsPosted 2022-06-10 by on his Website. Last updated 2022-06-11.
About Joyk
Aggregate valuable and interesting links.
Joyk means Joy of geeK