Using JavaScript to Work with Spreadsheets, Part 4: Making the CLI More Robust
This is the fourth article in a series where we’ll be learning to use the powerful logic of a programming language like JavaScript to manipulate spreadsheet data.
In this article, we’ll talk about how to add a dependency from the npm registry and we’ll add some robustness to our interface.
Being able to accept arguments from the command line already places our program in the command-line interface (CLI) category. That is, our program is run by typing commands on the command line AKA terminal.
Right now, our CLI has no documentation and no ability accept options. It’s entirely possible to build your own system for looking at process.argv
and parsing out options, adding commands for getting help with the program, etc. However, some very smart people have already written code which handles this and they’ve shared it with the world on Node.js’s npm registry.
There are many options on the registry for managing CLI’s. In this project, we’ll use the very popular Commander. Installing it is easy.
In VS Code, with your project folder opened, run the following command in the Terminal ( `Ctrl + `` ).
npm i commander
This tells npm (Node Package Manager) to install the package by the name of “commander.” Now that it’s installed, we can use it in our project. Our project now “depends” on Commander and therefor Commander is considered a “dependency” of our project.
If you open up package.json
, you can even see Commander listed in the dependencies
section.
Also you will notice that a new folder was created in your project called node_modules
. This folder contains all the code for your dependencies.
And finally you will notice a new file called package-lock.json
. This file helps npm keep track of the exact versions of dependencies that are installed.
Before we implement Commander into our file, let’s do a small bit of housekeeping. At the moment, fs.readFile()
will be called every time we run the program. Instead, we want to move this process into a function that we can call later.
Create a new function called logAction
which accepts a file path as an argument.
// Function for taking a file path and logging the contents
function logAction(path) {
// Read file at the path
}
Move the line of code that reads files into this new function.
// Function for taking a file path and logging the contents
function logAction(path) {
// Read the file at the path
fs.readFile(process.argv[2], "", readFileCallback);
}
Change the line so that fs.readFile
reads whatever path is passed into the function.
// Function for taking a file path and logging the contents
function logAction(path) {
// Read the file at the path
fs.readFile(path, "", readFileCallback);
}
Since our readFileCallback
function isn’t used anywhere but inside this logAction
function, we can actually nest it inside. Structuring the code this way keeps everything tidy.
// Function for taking a file path and logging the contents
function logAction(path) {
// Used as a callback when a file is read
function readFileCallback(err, data) {
// Convert the file's data buffer to a string
const fileContents = data.toString();
// Log the file contents
console.log(fileContents);
}
// Read the file at the path
fs.readFile(path, "", readFileCallback);
}
I know that this new logAction()
function isn’t being used yet. But it will be shortly.
The last bit of housekeeping is to remove the line where we define inputPath
. It is no longer used at all.
// Delete this line:
const inputPath = process.argv[2];
Near the top of index.js
, require Commander.
// Require outside packages
const fs = require("fs");
const commander = require("commander");
And at the very bottom of the file, tell Commander to parse our arguments.
// Parse the CLI arguments with Commander
commander.program.parse(process.argv);
NOTE: It’s very important to keep this line at the very bottom of the file. This is the code that tells Commander to actually parse the command at the very end after we’ve described what we want it to do.
If you save the file and run it now, nothing happens because we move the file reading component inside a function that never gets called.
However, you can start to see some of the features of Commander if you run node index --help
This help section will actually automatically fill out as we tell Commander more about our program.
You can tell Commander about commands you want your program to have by using commander.program.command()
.
Important: Make sure to place this and all further code above commander.program.parse(process.argv);
.
// A Commander command for reading files and logging the contents
commander.program
.command("log <path>")
.description("Log a text file to the console")
.action(logAction);
Notice how the action of this command is calling logAction
. Commander will automatically pass the value of <path>
to this function.
Try viewing the help again.
Commander automatically generated the help command and added information about our new log
command.
We can use the log
command like so:
node index log 'D:\Development\spreadsheets\Data Files\proverb.txt'
If we try to run it without a path, a friendly error appears.
One thing we haven’t tied up yet is what happens when a path is given, but the path doesn’t actually exist. What if someone tries…
node index log 'C:\i-dont-exist.txt'
That’s not a very friendly error. It’s telling us that the method toString()
can’t be read from undefined
. Since we are trying to do data.toString()
that must mean data
is undefined. data
should be defined when fs.readFile()
calls the readFileCallback()
function. Here’s another look at that function again.
// Used as a callback when a file is read
function readFileCallback(err, data) {
// Convert the file's data buffer to a string
const fileContents = data.toString();
// Log the file contents
console.log(fileContents);
}
Aha. readFileCallback()
takes two arguments: err
and data
. data
isn’t defined, but I bet err
is. Let’s write logic to handle this case.
// Used as a callback when a file is read
function readFileCallback(err, data) {
if (err !== null) {
console.error(err);
} else {
// Convert the file's data buffer to a string
const fileContents = data.toString();
// Log the file contents
console.log(fileContents);
}
}
This code says that if err
isn’t null
, log the err
object to the console. I’m using console.error
here instead of console.log
. They mostly do the same thing, but console.error
is more correct and it can behave differently depending on the environment.
Else, if err
is null
, run the code just as before.
Now we can see what error fs.readFile
is giving us.
Code ENOENT
, which means the file we were trying to access does not exist.
We can actually make this error a little more friendly. If an error exists, execute some switch
logic based on the code. If the code is ENOENT
, log a friendly error message we wrote ourselves. Otherwise if it’s some other code we haven’t encountered, just show the ugly error message.
if (err !== null) {
switch (err.code) {
case "ENOENT":
console.error("That file doesn't exist.");
break;
default:
console.error(err.message);
}
}
The reason I used switch
here was so I can make other friendly error messages later if needed.
Running the program on a nonexistent file now produces a better error message.
Now that we know how to add commands to our program using Commander, we can keep extending our program to do different things depending on our needs.
In the next article, we’ll go over how to read and parse XLSX files.