Join my free Modern C# course https://www.productivecsharp.com/modern-csharp
Command-line interfaces (CLI) are often used for automating tasks, such as building reports, synchronizing data between systems, migrating data, deploying applications, and so on and on. Over the years, I have built countless CLI apps to save time. If I ever find myself doing something more than once, I try to find a way to automate it!
Deno 2.0 is an excellent solution for writing CLI apps. It supports TypeScript and JavaScript, it's cross-platform (runs on Windows, macOS, and Linux), has dozens of powerful tools in its standard library, and can also tap into most Node.js modules. The only limit is your imagination!
In this tutorial, you will learn how to:
First, let's make sure you have the tools you need!
Open your computer's terminal (or command prompt). Change the current directory to the folder where you normally save projects.
Note: If you don't already have a folder where you store software projects, I like creating a folder at the root of my home directory named
projects
. More than likely, when you open your computer's terminal/console app, you are automatically placed in your "user home" folder. Usemkdir projects
(ormd projects
if you're on Windows) to create the folder. Then, usecd projects
to change to that new folder.
Verify you have Deno 2.0 (or higher) installed using the following command.
deno --version
You should see something like:
deno 2.0.5 (stable, release, aarch64-apple-darwin)
v8 12.9.202.13-rusty
typescript 5.6.2
If you receive an error, or if your version of Deno is 1.x, follow the installation.
Next, enter the following commands to initialize a new Deno project.
deno init deno-cli-demo
cd deno-cli-demo
We're going to use Deno's @std/cli standard library, so add that to the project using the following command.
deno add jsr:@std/cli
Open up your new project using your preferred editor. Create a new file named hello.ts
and add the following code.
const now = new Date();
const message = "The current time is: " + now.toTimeString();
console.log("Welcome to Deno 🦕 Land!");
console.log(message);
From your terminal, enter the following command to run the script.
deno run hello.ts
You've built your first Deno CLI application! Feel free to play around with writing other things to the console.
Arguments? No, we're not talking about getting into a heated debate with your terminal. Although that can certainly happen. Computers can be rather obstinate.
Command-line arguments are options and values you might provide the CLI when you run the app. When you enter deno run hello.ts
, deno
is the CLI, and run hello.ts
are two arguments you provide to the CLI.
Create a new file named add.ts
and add the following code.
import { parseArgs } from "@std/cli/parse-args";
const args = parseArgs(Deno.args);
console.log("Arguments:", args);
const a = args._[0];
const b = args._[1];
console.log(`${a} + ${b} = ` + (a + b));
The idea is to take two numbers and add them together. Try it out!
deno run add.ts 1 2
Experiment with additional arguments. Or none at all. The parseArgs
function can also handle arguments traditionally called switches and flags. Try the following and observe the output.
deno run add.ts 3 4 --what=up -t -y no
We've only just scratched the surface of what you can do with command-line arguments. Let's try a more advanced example!
Create a new file named sum.ts
and add the following code.
import { parseArgs, ParseOptions } from "@std/cli/parse-args";
import meta from "./deno.json" with { type: "json" };
function printUsage() {
console.log("");
console.log("Usage: sum <number1> <number2> ... <numberN>");
console.log("Options:");
console.log(" -h, --help Show this help message");
console.log(" -v, --version Show the version number");
}
const options: ParseOptions = {
boolean: ["help", "version"],
alias: { "help": "h", "version": "v" },
};
const args = parseArgs(Deno.args, options);
if (args.help || (args._.length === 0 && !args.version)) {
printUsage();
Deno.exit(0);
} else if (args.version) {
// Pro tip: add a version to your deno.json file
console.log(meta.version ? meta.version : "1.0.0");
Deno.exit(0);
}
// validate all arguments are numbers
const numbers: number[] = args._.filter((arg) => typeof arg === "number");
if (numbers.length !== args._.length) {
console.error("ERROR: All arguments must be numbers");
printUsage();
Deno.exit(1);
}
// sum up the number arguments
const sum = numbers.reduce((sum, val) => sum + val);
// print the numbers and the total
console.log(`${numbers.join(" + ")} = ${sum}`);
Whoa, there's a lot going on here 😬 Let's try it out first, and then we'll cover some of the highlights. Try the following commands and see how the output changes.
deno run sum.ts 1 2 3 4 5
deno run sum.ts --help
deno run sum.ts --version
deno run sum.ts -h
deno run sum.ts 1 2 three
Now, if you go back and look through the code in sum.ts
, you can probably figure out some of the logic involved in handling the different arguments. The main thing I want to point out is this block of code.
const options: ParseOptions = {
boolean: ["help", "version"],
alias: { "help": "h", "version": "v" },
};
const args = parseArgs(Deno.args, options);
The parseArgs
function supports quite a few options to support a wide variety of arguments.
boolean: ["help", "version"]
: defines the --help
and --version
flags.alias: { "help": "h", "version": "v" }
: defines alternate -h
and -v
flags.As you may have guessed, Deno.exit(0);
causes Deno to immediately stop the current script.
Imagine you're creating a CLI app to automate a report. Your app might connect to a system that requires authentication, such as a database or API.
Never embed secrets (sensitive information such as user names, passwords, API keys, connection strings, etc.) in your code. You don't want your secrets ending up in the hands of the wrong people!
In this case, the CLI should prompt for the sensitive information when it runs. Let's create another example to demonstrate how this is done.
First, add a new dependency using the following command.
deno add jsr:@std/dotenv
Create a new file named taskRunner.ts
and add the following code.
import { Spinner } from "@std/cli/unstable-spinner";
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
export async function simulateTask(ms: number, message: string) {
const spinner = new Spinner({ message, color: "yellow" });
const start = performance.now();
spinner.start();
await sleep(ms);
spinner.stop();
const finish = performance.now();
const duration = Math.round((finish - start) / 100) / 10;
console.log(`${message} (${duration.toFixed(1)}s).`);
}
This is a module that exports a function named simulateTask
that we'll use from the CLI app. The simulateTask
function includes a few tricks, such as keeping track of how long the task runs and displaying a cool spinning animation while the task is running 🤓
Create a new file named updater.ts
and add the following code.
import "@std/dotenv/load";
import { parseArgs, ParseOptions } from "@std/cli/parse-args";
import { promptSecret } from "@std/cli/prompt-secret";
import meta from "./deno.json" with { type: "json" };
import { simulateTask } from "./taskRunner.ts";
function printUsage() {
console.log("Usage: ");
console.log(" updater --input <input file> --output <output file>");
console.log("Options:");
console.log(" -h, --help Show this help message");
console.log(" -v, --version Show the version number");
console.log(" -i, --input Input file");
console.log(" -o, --output Output file");
}
const options: ParseOptions = {
boolean: ["help", "version"],
string: ["input", "output"],
default: { "input": "data.csv", "output": "report.pdf" },
alias: { "help": "h", "version": "v", "input": "i", "output": "o" },
};
const args = parseArgs(Deno.args, options);
if (args.help) {
printUsage();
Deno.exit(0);
} else if (args.version) {
// Pro tip: add a version to your deno.json file
console.log(meta.version ? meta.version : "1.0.0");
Deno.exit(0);
}
// validate the input and output arguments
if (!args.input || !args.output) {
console.log("You must specify both an input and output file");
printUsage();
Deno.exit(1);
}
// attempt to get the username and password from environment variables
let user = Deno.env.get("MY_APP_USER");
let password = Deno.env.get("MY_APP_PASSWORD");
if (user === undefined) {
const userPrompt = prompt("Please enter the username:");
user = userPrompt ?? "";
}
if (password === undefined) {
const passPrompt = promptSecret("Please enter the password:");
password = passPrompt ?? "";
}
// simulating a few long-running tasks
await simulateTask(1000, `Reading input file [${args.input}]`);
await simulateTask(1500, `Connecting with user [${user}]`);
await simulateTask(5000, `Reading data from external system`);
await simulateTask(3200, `Writing output file [${args.output}]`);
console.log("Done!");
Some of this code may look familiar to the previous sum.ts
CLI example. Try running the app and see what happens!
deno run updater.ts --help
Did you get a strange message like ⚠️ Deno requests read access to...
? What's going on here?
Deno is secure by default. It won't be able to read/write files, access your local environment variables, connect to your network, and a number of other potentially risky operations unless you explicitly give it permission.
Now run it using the following command. Don't worry. It's not doing anything; it's just simulating what a real app might look like.
deno run --allow-read --allow-env updater.ts
Wasn't that cool??
However, including those permissions is a lot to type every time you want to run the CLI app. Let's add a task to make it easier. Open up your deno.json
file and update the "tasks"
with the following.
"tasks": {
"updater": "deno run --allow-read --allow-env updater.ts"
},
Now you can use the following command.
deno task updater --input data.csv --output report.pdf
Let's revisit the code in updater.ts
. Specifically, this block of code.
// attempt to get the username and password from environment variables
let user = Deno.env.get("MY_APP_USER");
let password = Deno.env.get("MY_APP_PASSWORD");
if (user === undefined) {
const userPrompt = prompt("Please enter the username:");
user = userPrompt ?? "";
}
if (password === undefined) {
const passPrompt = promptSecret("Please enter the password:");
password = passPrompt ?? "";
}
It's common practice to store app configuration as environment variables. Deno can access environment variables (with permission) using Deno.env.get()
. If MY_APP_USER
or MY_APP_PASSWORD
are not set as environment variables, the app will use prompt()
or promptSecret()
to ask for those values. As you've already seen, promptSecret()
hides the characters you type.
Remember the dependency we added for jsr:@std/dotenv
? This standard library reads a file named .env
and adds any values as environment variables.
Create a new file in the project named .env
and add the following text.
MY_APP_USER=loluser
MY_APP_PASSWORD=p@ssw0rd1
Now run the updater
task again. It should run without prompting you for a username or password.
Want to share your CLI app with others or run the app on another computer? You can compile a Deno-powered CLI app into a standalone executable! Standalone means it can run without installing Deno or any of the libraries. Everything is bundled 📦
Pro tip: Use a task scheduler to run the executable periodically.
deno compile --allow-read --allow-env updater.ts
You should now have an executable version!
./updater --help
Among other things, you can create executables for other platforms.
Thank you for joining me on this journey of learning Deno! If you have any questions or suggestions, please drop them in the comments.
Here are more CLI tools for Deno you might explore!
Securing network environments in Microsoft Azure is paramount when organizations migrate their infrastructure and applications to the cloud. Azure, as one of the leading cloud service providers, offers a broad spectrum of security tools and best practices designed to protect resources against a multitude of network threats. Azure network security plays a critical role in […]
The article Azure Network Security Best Practices to Protect Your Cloud Infrastructure was originally published on Build5Nines. To stay up-to-date, Subscribe to the Build5Nines Newsletter.
This week on the GeekWire Podcast, it’s a grab-bag of topics, including self-driving wheelchairs, Expedia Group Chairman Barry Diller’s comments on the prospects for an acquisition by Uber, and an update on GeekWire’s upcoming events and coverage. In the final segment, we discuss what the new Trump administration could mean for technology regulation, including the FTC’s antitrust case against Amazon and oversight for tech M&A.
Subscribe to GeekWire in Apple Podcasts, Spotify, or wherever you listen.
Related coverage and links
Upcoming GeekWire Events
With GeekWire co-founders Todd Bishop and John Cook.
There are plenty of times when I’m offline and want to create a new .NET application. If it’s a trivial application without NuGet packages, then I’m fine.
But usually, I need to add at least one NuGet package, now I have a problem. I’ve always found this a little strange - whenever I add a NuGet package it is downloaded to my computer, I have over 800 on the computer I’m using right now.
Why don’t the restore
or build
commands use the package that is cached locally. It might be to look for the newest version, but I’m offline, so I can’t get the latest version anyway. The most recent will do.
Fortunately, there is a way to get the restore
and build
commands to use the local cache.
First I have to locate the local NuGet cache.
Run the following command to see where you are storing NuGet packages -
dotnet nuget locals all --list
It will show output like -
http-cache: C:\Users\bryan\AppData\Local\NuGet\v3-cache
global-packages: C:\Users\bryan\.nuget\packages\
temp: C:\Users\bryan\AppData\Local\Temp\NuGetScratch
plugins-cache: C:\Users\bryan\AppData\Local\NuGet\plugins-cache
The global-packages
folder is the one you want.
NuGet can look at multiple sources for packages, by default the highest priority source is nuget.org
.
Take a look at the current sources by running -
dotnet nuget list source
Registered Sources:
1. nuget.org [Enabled]
https://api.nuget.org/v3/index.json
You can see that nuget.org
is the only source I have set. If you had Visual Studio, there would be a secondary source set up for that.
To get builds working offline, add the directory from step 1 to the list of sources.
dotnet nuget add source C:\Users\bryan\.nuget\packages\ -n local
Run the list source command again -
dotnet nuget list source
You should see the new source -
Registered Sources:
1. nuget.org [Enabled]
https://api.nuget.org/v3/index.json
2. local [Enabled]
C:\Users\bryan\.nuget\packages\
That’s it. Now I can build almost anything while offline.