3
\$\begingroup\$

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):

  1. user invokes the cli command
  2. user inputs the name of their project
  3. program calls create-react-app to create an app based on project name
  4. program deletes the existing .gitignore file when project has been created
  5. 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.

\$\endgroup\$
2
+50
\$\begingroup\$

Preliminary thoughts

The script looks decent and makes good use of 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() and spawn() is how they return the data. As exec() stores all the output in a buffer, it is more memory intensive than spawn(), 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 use spawn(). 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.

| improve this answer | |
\$\endgroup\$

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.