Background
I crated a CLI tool which executes an npm
command (to create a react app) and modifies the contents of the generated files. It was a practice attempt at creating CLI tools for NodeJS and a chance for me to experience publishing NPM packages.
What I set out to do
My plan was to create something which follows the below workflow (in order):
- user invokes the cli command
- user inputs the name of their project
- program calls
create-react-app
to create an app based on project name - program deletes the existing
.gitignore
file when project has been created - program downloads the latest
.gitignore
file for nodejs
I got used to prototyping a lot with create-react-app
and got tired of modifying the .gitignore
file every time I wanted to add .env
or code coverage files. So I created this tool to do this for me. In the future I hope to also modify the README.md
file and allow the users to open the project in vscode when everything has been generated and all files have been modified etc.
The code
The tool, in it's entirety is here:
#!/usr/bin/env node
const chalk = require("chalk");
const figlet = require("figlet");
const exec = require("child_process").exec;
const Ora = require("ora");
const fs = require("fs");
const https = require("https");
// create an input interface
const readline = require("readline").createInterface({
input: process.stdin,
output: process.stdout
});
function execShellCommand(cmd) {
return new Promise((resolve, reject) => {
exec(cmd, (error, stdout, stderr) => {
if (error) {
console.warn(error);
console.warn(stdout);
console.warn(stderr);
}
resolve(stdout ? stdout : stderr);
});
});
}
// 1. clear the console
process.stdout.write("\x1Bc");
// 2. print some fancy text
console.log(
chalk.green(figlet.textSync("My CLI Tool", { horizontalLayout: "full" }))
);
// 3. ask user for a project name
readline.question(`What is your project name? `, name => {
console.log(`\n`); // add a space
let firstSpinner = new Ora(
`Creating a new React project named: ${name} ... this may take a while\n`
);
firstSpinner.start();
// 4. Create a create-react-app project
execShellCommand(`npx create-react-app ${name}`).then(() => {
firstSpinner.succeed();
console.log(`Project ${name} created!\n\n`);
// 5. Remove the existing .gitignore file from that project folder
let secondSpinner = new Ora(
`Removing the original ${name}/.gitignore file...\n`
);
secondSpinner.start();
fs.unlink(`./${name}/.gitignore`, err => {
if (err) {
console.error(err);
return;
}
//file removed
secondSpinner.succeed();
console.log(`Original ${name}/.gitignore file removed!\n\n`);
// 6. Place new .gitignore file in that project directory
let thirdSpinner = new Ora(`Placing new .gitignore file...\n`);
thirdSpinner.start();
let newGitignore = fs.createWriteStream(`./${name}/.gitignore`); // cannot declare anywhere else as the folder has not been created before this point
https
.get(
`https://raw.githubusercontent.com/github/gitignore/master/Node.gitignore`,
res => {
res.pipe(newGitignore);
newGitignore.on("finish", () => {
newGitignore.close();
thirdSpinner.succeed();
console.log(
`New ${name}/.gitignore created!\n\n\nClosing CLI tool\n`
);
readline.close();
process.exit();
});
}
)
.on("error", err => {
fs.unlink(newGitignore);
console.error(err);
process.exit();
});
});
});
readline.close();
});
Reflection
After I created the tool, I realised there are several ways in which I can enhance the user's experience - e.g. adding yargs
to implement a help
function or parsing of arguments into my CLI tool.
Questions
I was wondering how I could improve my code to be more efficient. Perhaps I could learn some best practices before I publish this as an npm
package. In particular, I'd like to know if there is any other way for me to create, start and stop my spinners which doesn't involve creating 3 separate ones for various 'checkpoint' sections of my code. And whether my use of exec()
is efficient/ best suited for this kind of CLI app.
1 Answer 1
Preliminary thoughts
The script looks decent and makes good use of ecmascript-6 features like arrow functions and template literals. I like the use of the chalk and Ora plugins.
Main questions
I'd like to know if there is any other way for me to create, start and stop my spinners which doesn't involve creating 3 separate ones for various 'checkpoint' sections of my code.
I admit that I wasn't familiar with the library ora but after reading over the API I see that a single spinner can be used for all three lines. See the sample below, where the first spinner is re-used in place of secondSpinner
:
const spinner = new Ora(
`Creating a new React project named: ${name} ... this may take a while\n`
);
spinner.start();
// 4. Create a create-react-app project
execShellCommand(`npx create-react-app ${name}`).then(() => {
spinner.succeed();
console.log(`Project ${name} created!\n\n`);
// 5. Remove the existing .gitignore file from that project folder
spinner.start(`Removing the original ${name}/.gitignore file...\n`);
And then after the gitignore file is unlinked, that spinner can be referenced again
spinner.succeed();
console.log(`Original ${name}/.gitignore file removed!\n\n`);
// 6. Place new .gitignore file in that project directory
spinner.start(`Placing new .gitignore file...\n`);
And whether my use of
exec()
is efficient/ best suited for this kind of CLI app.
I looked at this post which mentions using exec()
as well as spawn()
:
When to use exec and spawn?
The key difference between
exec()
andspawn()
is how they return the data. Asexec()
stores all the output in a buffer, it is more memory intensive thanspawn()
, which streams the output as it comes.Generally, if you are not expecting large amounts of data to be returned, you can use
exec()
for simplicity. Good examples of use-cases are creating a folder or getting the status of a file. However, if you are expecting a large amount of output from your command, then you should usespawn()
. A good example would be using command to manipulate binary data and then loading it in to your Node.js program.
You could consider switching the code to using spawn()
but it appears the argument handling may need to be altered.
Another thing to consider would be instead of deleting the gitignore file, just overwrite it with the contents of the downloaded file - then if that happens to fail then it wouldn't be deleted.
Other feedback
Error handling
It would be wise to add error handling functions - for example, chain on a .catch()
after the execShellCommand.then()
call.
Callback hell
The code becomes not-so-shallow i.e. has a few indentation levels towards the final steps. CallbackHell.com has some tips to avoid this - e.g. naming those anonymous callback functions, just as you did with execShellCommand()
. This may require passing parameters - can be done with Function.bind()
.
Constants
These can be used for values that never change
`https://raw.githubusercontent.com/github/gitignore/master/Node.gitignore`,
This seems like a good candidate for a constant declared at the beginning of the code. While it appears it is only used in one spot, it is a bit long to have sitting in the code, and if it ever needs to be updated in the future, it would be easier to find it at the top of the code instead of buried in the code.
I would call that constant something like GITHUB_NODE_GITIGNORE_PATH
- which isn't exactly brief but it is shorter than the entire URL.
Other uses for const
Other variables that don't get re-assigned can be declared with const
as well, even if they aren't really a constant - e.g. arrays that get pushed into, objects that get mutated. This helps avoid accidental re-assignment. Notice in the sample code I gave above to re-use a single spinner I used const spinner
.
Readline closed in two spots
I see readline.close();
in two spots - one within the callback function to newGitignore.on("finish")
as well as one at the end of the callback function to
readline.question(`What is your project name? `
Perhaps only one call should exist - possibly in a .finally()
callback.
Explore related questions
See similar questions with these tags.