- An overview of the tech stack
- Setting up the project & installing dependencies
- Quality of life: Typescript path alias imports with
bun
- Quality of life: Prettier & package.json scripts
- File structure and separation of concerns
- The CLI menu -
menu.ts
- The last run configuration from DB -
db.ts
- Bringing it all together -
main.ts
- Conclusion
If you have ever wanted to build a Command Line Interface (short: CLI
) in typescript, this post is for you!
Granted, Typescript is not the most conventional language to do this. Maybe you’re better served with Go and Cobra-cli. But in case TS is your go-to language - like it is for me right now - continue reading.
An overview of the tech stack
First, let’s take a brief overview of the tools we’ll be using:
For the basic typescript project, package manager and javascript-runtime, bun has become my favorite. Bun is blazingly-fast, easy to set up, and just makes coding in TS a joy. If you’ve never tried it, this is the perfect opportunity to do so. Make sure you have bun
installed, then continue reading.
Typescript is the language of choice. It provides the developer experience that Javascript never had. In the beginning the type-syntax may seem daunting, but usually the best Typescript is just sprinkled in and the rest is inferred.
Now to the real MVP of this post is @clack/prompts. Clack enables you to effortlessly build beautiful command-line apps. With Typescript. Check out their npm page. I’ve taken a liking to their API and general flow.
Lastly, in case our CLI requires some kind of data storage, we’ll use lowdb. Lowdb is a very simple database that runs on .json
. The benefits are to have the basic database operations (create, read, update, delete), persistent storage, and the database is easily readable for troubleshooting and general checks. This is optional, but often comes to be used in some way for my CLIs.
Setting up the project & installing dependencies
Let’s get coding!
Open your favorite code editor and terminal. To start with, we need our basic project:
mkdir simple-ts-cli
cd simple-ts-cli
bun init # run through the bun CLI - for 'entry point' set src/main.ts
We’ll use a src/
directory for all our code. This way we keep the project root clean.
Bun already created a tsconfig.json
for us with awesome defaults!
Next, we install the dependencies. Since this is a CLI, we don’t really care about them being devDependencies
. Just run:
bun add @clack/prompts lowdb
Quality of life: Typescript path alias imports with bun
One thing that’s easy to setup and makes development even nicer is having import path aliases for every file. This allows us to always use absolute imports. Whenever you move code from one file to the other, the imports can be copied as well. No more trying to figure out how many directories to ..
up!
In the tsconfig.json
we just add:
{
"compilerOptions": {
// ...
"paths": {
"~/*": ["./src/*"]
}
}
Now we can import from the base of our src/
directory with “~/”!
Quality of life: Prettier & package.json scripts
I don’t like the semicolons at the end of the line, so I use prettier to remove them. This also makes moving lines, cutting and pasting easier. My IDE is already configured to read the prettier file in the root of the project.
Create a .prettierrc
in the project root and add this line
semi: false
Last quality of life thing we cannot go without is a simple start
script in our package.json
to run our CLI.
In the package.json
add:
{
// ...
"scripts": {
"start": "bun run src/main.ts"
},
// ...
}
Now we can run our CLI with bun start
from the root of the project!
File structure and separation of concerns
We’ll write a simple typescript CLI: ask the user some questions and execut business logic depending on the answers.
Our CLI will “remember” the last run and default to these choices. For this we’ll use lowdb as our database.
The main.ts
contains the entry point to our CLI and the main flow. Keep it clean to easily follow the overall flow of the app.
The db.ts
contains our database code. The type definition for lowdb
and some basic exported functions, like reading the database, updating it or cleaning all the data.
Our user interaction is covered in menu.ts
. Here we define the different questions and export a function for each menu - if our CLI ends up being more interactive.
Finally, lib.ts
is more of a placeholder for more business logic. Everything else will end up here.
This is not a strict requirement, just some pattern that I’ve ended up with. Keep it simple, don’t abstract, unless you have to.
The CLI menu - menu.ts
Create the file src/menu.ts
and add this content:
import { group, outro, select, text } from "@clack/prompts"
import type { LastRunConfig } from "~/db"
// We'll get into the lastRunConfig in the next section
export async function mainMenu(lastRunConfig: LastRunConfig) {
// group() chains multiple prompts in a row
const choices = await group(
{
username: () =>
// text() is a basic user input prompt
text({
message: "Enter your name: ",
// if there is a last run, use the value from it
initialValue: lastRunConfig.username,
// validate user input before continuing
validate(value) {
if (!/[a-z]/.test(value))
// if we return from validate() the user cannot continue
return "Username should only contain lowercase characters"
},
}),
// results is an object that contains previous answers!
environment: ({ result }) =>
// select() with some types for inferred return types
select<EnvironmentOption[], EnvironmentValue>({
message: `Select your environment, ${result.username}`,
initialValue: lastRunConfig.environment,
options: [
// we get auto-complete here from our types
{ value: "dev", label: "Dev" },
{ value: "qa", label: "Staging" },
{
value: "prod",
label: "Prod",
// hints are only shown when option is hovered
hint: "⚠ You better know what you are doing",
},
],
}),
},
{
// in case the user interrupts the menu with CTRL + C
onCancel: () => {
outro("K, thx, bye")
// abort the program, no error
process.exit(0)
},
},
)
// we could do more validation or reshaping with the choices here
return choices
}
// --- Types
// I always use this GenericOption for typing selects
export type GenericOption = {
label?: string | undefined
hint?: string | undefined
}
// the actual options of our select
export type EnvironmentValue = "dev" | "qa" | "prod"
// some typescript inferrence magic
export type EnvironmentOption = GenericOption &
Record<"value", EnvironmentValue>
Study this code and the comments. The group()
is a sequence of prompts. In the end choices
will be an object containing username
and environment
. This is just a sample, depending on your needs, create new properties in the group()
.
The Types are something I just copy & paste from project to project. Notice how we pass EnvironmentOption[]
(array!) in the select<EnvironmentOption[], EnvironmentValue>({...})
. We’re saying: “These are the possible options and in the end I expect one of these Values”.
With this setup our choices
variable knows which values it contains. Insanely useful for later switch case
statements or other conditional logic.
With results
in the callback of a prompt, we can access previous answers. Super handy, we could even add optional prompts:
const choices = await group({
answer1: () => text({ message: "Whats the secret word?" }),
answer2: ({ results }) => {
if (results.answer1 === "valar morgulis") {
// optional answer2
return text({ message: "A man needs a name: " })
}
// otherwise answer2 will be undefined
return
}
})
For more detail about using the @clack/prompts
, read their examples.
The last run configuration from DB - db.ts
Our mainMenu()
accepts a lastRunConfig
object. It’s a nice touch to remember the last time the user ran an app. We’ll use a simple JSON structure to store this data.
Create the src/db.ts
and input this:
import { JSONFilePreset } from "lowdb/node"
export type LastRunConfig = {
username: string
environment: "qa" | "dev" | "prod"
}
type Data = {
lastRunConfig: LastRunConfig
}
const defaultData: Data = {
lastRunConfig: {
username: "",
environment: "dev",
},
}
const db = await JSONFilePreset<Data>("db.json", defaultData)
export async function updateLastRunConfig(config: LastRunConfig) {
db.data.lastRunConfig = config
await db.write()
}
export async function getLastRunConfig() {
await db.read()
return db.data.lastRunConfig
}
This code uses the db.json
file as our database. We export a function to add a new lastRunConfig
and another to get the lastRunConfig
.
Bringing it all together - main.ts
Let’s put these pieces together. In the main.ts
we write:
import { mainMenu } from "~/menu"
import { getLastRunConfig } from "~/db"
import { outro } from "@clack/prompts"
const lastRunConfig = await getLastRunConfig()
const choices = await mainMenu(lastRunConfig)
await updateLastRunConfig(choices)
outro(`Great choices:
Username - ${choices.username}
Environment - ${choices.environment}`)
Ahh, how simple and beautiful. This code explains itself!
Run the script twice to see if the lastRunConfig
actually works.
outro()
is another function from @clack/prompts
to say a final word of “Goodbye!” to the user.
Of course in a real world app this would only be the beginning. After asking the user some questions, we would actually do something with it. However, this is not the focus of this post. You got this from here!
Conclusion
We created a simple typescript CLI with bun
, @clack/prompts
and lowdb
. Our CLI asks the user some question and remembers the answers for the next run.
Hopefully this post gave you an overview of how to approach the topic of CLI with typescript.