How To Build A CLI Using TypeScript And Bun
A command-line interface (CLI) is a program that runs in the terminal. It takes user inputs, processes commands, and executes system functions. In the early days of computing, the terminal was the only way to interact with a computer. Today, most users rely on graphical user interfaces (GUIs) for their computer usage—some don’t even know there was a time like that :). However, as a software developer, the CLI is a key part of your development toolbelt. We use it frequently for library installations, Docker setups, and several other automations that are impractical to do with GUIs. This tutorial will show you how to build a CLI using TypeScript and Bun — a fast, all-in-one JavaScript runtime. If you prefer video, you can watch this YouTube video which contains the same content. What You’re Going to Build This tutorial will guide you step by step in building a CLI for uploading files or directories to S3-compatible storage. By the end of this tutorial, you will understand how to parse command-line arguments and publish your CLI to the npm registry. We’ll use the S3 API in Bun and meow, a CLI helper library that’ll parse and handle help flags. Here’s a glimpse of what you’ll create by the end of this tutorial. Project Set-Up And Structure Let’s start by creating a Bun project and installing the necessary dependencies. Open your terminal and run the command below: mkdir s3upload && cd s3upload bun init -y bun add meow The commands you just executed created a Bun project named s3upload, and installed the meow package. meow helps with parsing arguments and flags, as well as: Converts flags to camelCase Negates flags when using the --no- prefix Outputs version when --version Outputs description and supplied help text when --help Sets the process title to the binary name defined in package.json We have a index.ts file and that’s where we will keep all the code for our CLI program. Open the index.ts file and replace its content with the following import statements. #!/usr/bin/env bun import { S3Client } from "bun"; import { basename } from "node:path"; import { readdir } from "node:fs/promises"; import meow from "meow"; In the code above, we imported the S3Client to interact with any S3-compatible object storage. We also included the fs and path modules for reading files and directories later in the tutorial. At the top of the file, you’ll notice the shebang statement (#!/usr/bin/env bun) which tells the operating system to use the Bun runtime to execute the script. Define the Program’s Arguments The next step after creating the project is to define the CLI flags/options that the program expects. We need a way to tell the program to upload a particular file, or the files in a directory. We also want to optionally specify the access key and secret required for authenticating with the object storage service. Open index.ts and paste in the code below: const cli = meow( ` Usage $ s3upload Options --file, -f Single file to upload --dir Directory to upload recursively --region AWS region --endpoint S3 endpoint/url --access-key-id AWS access key ID --secret-access-key AWS secret access key --help Displays this message --version Displays the version number Examples $ s3upload my-bucket --file index.html $ s3upload react-site --dir build --access-key-id $AWS_ACCESS_KEY_ID --secret-access-key $AWS_SECRET_ACCESS_KEY --endpoint $S3_URL --region $AWS_REGION `, { importMeta: import.meta, // This is required flags: { file: { type: "string", shortFlag: "f", }, dir: { type: "string", }, region: { type: "string", }, endpoint: { type: "string", }, accessKeyId: { type: "string", }, secretAccessKey: { type: "string", }, }, } ); const bucket = cli.input.at(0); if (bucket) upload(bucket, cli.flags); else { console.error("Please provide a bucket name as argument"); cli.showHelp(1); } Here we used the meow(helpText, options) function to define the program’s behaviour. We gave it the help text to display and defined the flags in the second parameter. The flags object specifies the flags as keys along with their type and optional shortFlag (or alias). If the --version or --help flags are not part of the arguments passed to the program, it goes ahead to execute the rest of the code. In this case, it’ll call the upload() function if the bucket is specified, otherwise, it’ll show an error message and print the help text. Uploading Files Using Bun’s S3 Client In this section, we’re going to implement the upload() function with code to upload files or directories. Let’s start with the code to upload files first. Copy and paste the function below into th
A command-line interface (CLI) is a program that runs in the terminal. It takes user inputs, processes commands, and executes system functions.
In the early days of computing, the terminal was the only way to interact with a computer. Today, most users rely on graphical user interfaces (GUIs) for their computer usage—some don’t even know there was a time like that :). However, as a software developer, the CLI is a key part of your development toolbelt. We use it frequently for library installations, Docker setups, and several other automations that are impractical to do with GUIs.
This tutorial will show you how to build a CLI using TypeScript and Bun — a fast, all-in-one JavaScript runtime. If you prefer video, you can watch this YouTube video which contains the same content.
What You’re Going to Build
This tutorial will guide you step by step in building a CLI for uploading files or directories to S3-compatible storage. By the end of this tutorial, you will understand how to parse command-line arguments and publish your CLI to the npm registry. We’ll use the S3 API in Bun and meow, a CLI helper library that’ll parse and handle help flags.
Here’s a glimpse of what you’ll create by the end of this tutorial.
Project Set-Up And Structure
Let’s start by creating a Bun project and installing the necessary dependencies. Open your terminal and run the command below:
mkdir s3upload && cd s3upload
bun init -y
bun add meow
The commands you just executed created a Bun project named s3upload, and installed the meow package. meow helps with parsing arguments and flags, as well as:
Converts flags to camelCase
Negates flags when using the
--no-
prefixOutputs version when
--version
Outputs description and supplied help text when
--help
Sets the process title to the binary name defined in package.json
We have a index.ts file and that’s where we will keep all the code for our CLI program. Open the index.ts file and replace its content with the following import statements.
#!/usr/bin/env bun
import { S3Client } from "bun";
import { basename } from "node:path";
import { readdir } from "node:fs/promises";
import meow from "meow";
In the code above, we imported the S3Client
to interact with any S3-compatible object storage. We also included the fs
and path
modules for reading files and directories later in the tutorial. At the top of the file, you’ll notice the shebang statement (#!/usr/bin/env bun
) which tells the operating system to use the Bun runtime to execute the script.
Define the Program’s Arguments
The next step after creating the project is to define the CLI flags/options that the program expects. We need a way to tell the program to upload a particular file, or the files in a directory. We also want to optionally specify the access key and secret required for authenticating with the object storage service.
Open index.ts and paste in the code below:
const cli = meow(
`
Usage
$ s3upload
Options
--file, -f Single file to upload
--dir Directory to upload recursively
--region AWS region
--endpoint S3 endpoint/url
--access-key-id AWS access key ID
--secret-access-key AWS secret access key
--help Displays this message
--version Displays the version number
Examples
$ s3upload my-bucket --file index.html
$ s3upload react-site --dir build --access-key-id $AWS_ACCESS_KEY_ID --secret-access-key $AWS_SECRET_ACCESS_KEY --endpoint $S3_URL --region $AWS_REGION
` ,
{
importMeta: import.meta, // This is required
flags: {
file: {
type: "string",
shortFlag: "f",
},
dir: {
type: "string",
},
region: {
type: "string",
},
endpoint: {
type: "string",
},
accessKeyId: {
type: "string",
},
secretAccessKey: {
type: "string",
},
},
}
);
const bucket = cli.input.at(0);
if (bucket) upload(bucket, cli.flags);
else {
console.error("Please provide a bucket name as argument");
cli.showHelp(1);
}
Here we used the meow(helpText, options)
function to define the program’s behaviour. We gave it the help text to display and defined the flags in the second parameter. The flags
object specifies the flags as keys along with their type and optional shortFlag
(or alias).
If the --version
or --help
flags are not part of the arguments passed to the program, it goes ahead to execute the rest of the code. In this case, it’ll call the upload()
function if the bucket is specified, otherwise, it’ll show an error message and print the help text.
Uploading Files Using Bun’s S3 Client
In this section, we’re going to implement the upload()
function with code to upload files or directories. Let’s start with the code to upload files first.
Copy and paste the function below into the index.ts file:
async function upload(bucket: string, flags: typeof cli.flags) {
if (!flags.file && !flags.dir) {
console.error("Either --file or --dir must be specified");
process.exit(1);
}
const client = new S3Client({
bucket: bucket,
region: flags.region,
accessKeyId: flags.accessKeyId,
secretAccessKey: flags.secretAccessKey,
endpoint: flags.endpoint,
});
if (flags.file) {
const key = basename(flags.file);
await client.write(key, Bun.file(flags.file));
console.log(`✓ Uploaded ${key} to ${bucket}`);
return;
}
}
The upload()
function first checks if the file or directory flag is specified. If the file flag is set, it uses the client.write()
method to upload the file. client
is an instance of S3Client
which will use the accessKeyId, region, endpoint, and secretAccessKey if specified. Otherwise, it’ll read those credentials from the environment variable (see the documentation for the environment variables).
Next, we will add the code to read a directory and upload the files in that directory.
async function upload(bucket: string, flags: typeof cli.flags) {
//...The rest of the code from the previous snippet
if (flags.dir) {
// Handle recursive directory upload
const directoryContent = await readdir(flags.dir, {
recursive: true,
withFileTypes: true,
});
const files = directoryContent.reduce((acc: string[], dirent) => {
if (dirent.isFile()) {
acc.push(
dirent.parentPath
? dirent.parentPath + "/" + dirent.name
: dirent.name
);
}
return acc;
}, []);
for (const file of files) {
await client.write(file, Bun.file(file));
console.log(`✓ Uploaded ${file} to ${bucket}`);
}
console.log(`Uploaded ${files.length} files to ${bucket}`);
}
}
We used the readdir()
function to recursively read the content of the specified directory. We filter the result to get only the files and asynchronously upload them.
That’s all we need to get our s3upload CLI working. If you open the terminal and run the command bun index.ts
you should see it upload that file to the bucket you specified.
How To Pack and Publish the CLI
So far we’ve been able to run the program using the Bun CLI, but we’d like to distribute it so that anyone can install and use it. We can package and distribute it using any of the following:
Compile the code into a binary executable. Then, distribute it through package registries such as Homebrew, or provide individuals with an installation script that will install the executable and add it to the system’s PATH.
Publish to npm registry. Then install it using any JavaScript package manager.
We’re going to go the npm registry route for this tutorial. We need to update the package.json file to include the following information:
description: This describes the package and is displayed on npm.
version: To tell the version of the published package. This is also used when the CLI is called with the
--version
flag.bin: This is where we specify the executable file that npm (or any package manager) will install in the system’s PATH. This is why we specified
!/usr/bin/env bun
at the beginning of index.ts.
Open package.json and add these values to it:
{
//... The rest of package.json
"version": "1.0.0",
"description": "A CLI to upload files to S3",
"bin": {
"s3upload": "index.ts"
}
}
With that added, publishing the package is as simple as running npm publish
. If you’re publishing it as a scoped package, you should use npm publish --access public
to make it a public package. This is because scoped packages default to restricted access.
You’ll be asked to login if you aren’t authenticated to the npm registry. Alternatively, you can run
npm login
before publishing.
Wrap Up
This tutorial guided you through building a command-line application using TypeScript and Bun. We created a utility for uploading files or directories to S3-compatible storage. We learned how to use meow as a helper library and Bun’s S3 Client. Additionally, you learned how to package and publish the CLI application to the npm registry.
You can download the sample code from GitHub and explore Buntastic, a tool designed for deploying and hosting static websites using S3 as its storage backend.