Automate GitHub: Build a CLI App with Node.js #2

Automate GitHub: Build a CLI App with Node.js #2

Let's build a CLI app with Node.js to automate GitHub workflow in this step-by-step tutorial! (Part 2 Final)

Welcome back to the 2nd and final part of the tutorial on how to build a simple CLI tool that can automate the process of pushing a local git repository to your GitHub.

If you haven't read the first part, please do so here so you can follow along.

Step 7: New_repo.js (newRepo)

Let's continue where we left off. After we authenticate the user with the authenticate function in creds.js, we can now create a remote repo using octokit's repos.createForAuthenticatedUser(data).

We can supply information about the repo such as its name, description and visibility in data as an argument.

For more information on the parameters, please see the documentation here.

The repos.createForAuthenticatedUser(data) is a request that on success, will return data about the repo that was created. All we need from that response data is the clone_url.

This url is in the format:

https://github.com/[username]/[repo-name].git

We need this url when we push files from our local repo to the remote repo with:

git.push(url, 'master');

So let's create the newRepo function in new_repo.js where we will do the following:

  1. Ask the user for repo information (i.e. name, description and visibility)
  2. Pass this information as data in repos.createForAuthenticatedUser(data)
  3. Return the clone_url of the response.data object

As usual, we create a questions array with objects representing each question. Then use inquirer.prompt(questions) to ask the user in the terminal.

So first, import inquirer at the top of new_repo.js. We also need to import a built-in Node.js module called path to get the base name of our current working directory.

For example, my current working directory is: C:\Users\victo\Desktop\Projects\cli-app. We can get this value using process.cwd().

To get the base name, we can use path.basename(process.cwd()). Thus, the base name is cli-app, which will be the default name of our repo (unless the user specifies something else).

Feel free to read more about the Node.js path module in their documentation.

const path = require('path');
const inquirer = require('inquirer');

Then we write our newRepo function below:

async function newRepo(octokit){
    //create questions array, ask for name, description and visibility
    const questions = [
        {
            name: 'name',
            type: 'input',
            message: 'Enter new repo name.',
            default: path.basename(process.cwd()), //set default to basename
            validate: function(value) {
                if (value.length) {
                    return true;
                } else {
                    return 'Please enter a valid input.';
                }
            }
        },
        {
            name: 'description',
            type: 'input',
            message: 'Enter new repo description (optional).',
            default: null
        },
        {
            name: 'visibility',
            type: 'input',
            message: 'Set repo to public or private?',
            choices: ['public', 'private'],
            default: 'private'
        }
    ];
    //prompt the questions
    const answers = await inquirer.prompt(questions);

    //create the data argument object from user's answers
    const data = {
        name: answers.name,
        description: answers.description,
        private: (answers.visibility === 'private')
    };

    try {
        //create the remote repo and return the clone_url
        const response = await octokit.repos.createForAuthenticatedUser(data)
        return response.data.clone_url;  
    } catch (error) {
        console.log(error)
    }
}

//final step, export function to use in index.js
module.exports = {newRepo}

Our function is complete. Let's call it in index.js, after the authenticate function. Import it first at the top of index.js.

const repo = require('./new_repo');

Then add these lines below the auth.authenticate() line, inside the init command.

app.command('init')
    .description('Run CLI tool')
    .action(async() => {
        //welcome message - see part1
        //prompt question - see part1

        if(answer.proceed == "Yes"){
            console.log(chalk.gray("Authenticating...")) //from part1
            const octokit = await auth.authenticate(); //from part1
            //add these 2 lines below
            console.log(chalk.gray("Initializing new remote repo..."));
            const url = await repo.newRepo(octokit);
        }else{
            console.log(chalk.gray("Okay, bye."))
        }
    })

Let's test if it works! Run npm start on the terminal.

repo.gif

As shown in the clip above, our remote repo has been successfully created with the parameters the user provided in the 3 questions.

Notice it is now just an empty repo. The next step is to create a .gitignore file, ask the user which files they want to ignore, then commit and push the rest to the remote repo.

Step 8: New_repo.js (ignoreFiles)

In our new_repo.js file, let's create a function called ignoreFiles. This function will do 2 things:

  1. List all the files in the project directory
  2. Prompt the user to select the file(s) they want to add to .gitignore from the list. By default, node_modules would be included.

If the user selects none, an empty .gitignore file will be created. Else, we use Node.js built-in module fs (File System) to write the selected ignored files to .gitignore using fs.writeFileSync().

Feel free to check out Node.js File System module documentation here.

In order to create a list of all the files in our project directory, including nested ones, we use glob, a dependency we installed earlier in Part 1 of this tutorial. glob allows us to iterate through our directory and return all the files as an array of file names.

We will then pass this array of file names in the prompt, so the user can select the ones they want to ignore from the terminal.

So let's first import the dependencies we need: fs and glob at the top of new_repo.js.

const fs   = require("fs");
const glob = require("glob");

Then we write the ignoreFiles function below:

async function ignoreFiles(){
    //get array of files in the project, ignore node_modules folder
    const files = glob.sync("**/*",{"ignore":'**/node_modules/**'});

    //add any node_modules to gitignore by default
    const filesToIgnore = glob.sync('{*/node_modules/,node_modules/}');
    if(filesToIgnore.length){
        fs.writeFileSync('.gitignore', filesToIgnore.join('\n')+'\n');
    }else {
        //if no files are chosen to be ignored, create an empty .gitignore
        fs.closeSync(fs.openSync('.gitignore', 'w'));
    }

    //create question and pass files as the choices
    const question = [
        {
            name: 'ignore',
            type: 'checkbox',
            message: 'Select the file and/or folders you wish to ignore:',
            choices: files
        }
    ];
    //prompt the question
    const answers = await inquirer.prompt(question);

    //if user selects some files/folders, write them into .gitignore
    if (answers.ignore.length) {
        fs.writeFileSync('.gitignore', answers.ignore.join('\n'));
    }
}

//export ignoreFiles to use in index.js
module.exports = {newRepo, ignoreFiles}

Now we can call ignoreFiles in index.js after calling newRepo.

//Some code above
if(answer.proceed == "Yes"){
    console.log(chalk.gray("Authenticating...")) //from part1
    const octokit = await auth.authenticate(); //from part1

    console.log(chalk.gray("Initializing new remote repo...")); //step7
    const url = await repo.newRepo(octokit); //step7

    //add these 2 lines below
    console.log(chalk.gray("Remote repo created. Choose files to ignore."));
    await repo.ignoreFiles();

}
//Some code below

Here's what our app should be able to do at this point:

  1. Display a welcome message
  2. Ask user if they want to continue with this tool
  3. If yes, ask for token. If token already saved, skip to next step.
  4. Ask repo name, description and visibility. Then create it on GitHub.
  5. Ask which files to ignore. Then create .gitignore file with the selected files.

gitignore.gif

Let's finish up this app. All there's left to do is to commit and push the files that are not in .gitignore from local to the remote repo.

Step 9: New_repo.js (initialCommit)

The initialCommit function uses the simple-git dependency we installed in Part 1 to easily make git commands in JavaScript. So first, import it at the top of new_repo.js.

const git = require('simple-git')();

The function will take the clone_url from Step 7 as an argument to do the following:

  1. git init
  2. git add .gitignore
  3. git add ./*
  4. git commit -m "Initial commit"
  5. git remote add origin <clone_url>
  6. git push <clone_url> master

If there's no error, the function returns true. Let's see its code below:

async function initialCommit(url) {
    try {
        await git
        .init()
        .add('.gitignore')
        .add('./*')
        .commit('Initial commit')
        .addRemote('origin', url)
        .push(url, 'master', ['--set-upstream']);

        return true;
    } catch (error) {
        console.log(error)
    }
}

//don't forget to export it
module.exports = {newRepo, ignoreFiles, initialCommit}

Finally, let's add the last lines of code in our index.js to complete the app.

//Some code above
if(answer.proceed == "Yes"){
    console.log(chalk.gray("Authenticating...")) //from part1
    const octokit = await auth.authenticate(); //from part1

    console.log(chalk.gray("Initializing new remote repo...")); //step7
    const url = await repo.newRepo(octokit); //step7

    console.log(chalk.gray("Remote repo created. Choose files to ignore.")); //step8
    await repo.ignoreFiles(); //step8

    //add these lines
    console.log(chalk.gray("Committing files to GitHub at: " + chalk.yellow(url)));
    const commit = await repo.initialCommit(url);
    //if initialCommit returns true, show success message
    if(commit){
        console.log(chalk.green("Your project has been successfully 
               committed to Github!"));
    }    
}
//Some code below

Final Product

done.gif

Congratulations! We have completed the app. There's a couple of optional polishing actions such as:

  1. Create another command to allow user to delete their stored token
  2. Publish the tool to npm for reusability

Since this tutorial has been quite long, I'll just quickly show you the 2nd optional action on how to publish the tool to npm.

Optional Step: Publishing

In package.json, add the bin property and remove the start script under scripts.

"bin": "index.js"

Then add this line below at the top on index.js. This tells the terminal to use Node.js when executing the file.

#! /usr/bin/env node

Finally, to test locally, run npm link to install the cli-app. Then run:

cli-app init

The app should start and work the same as npm start. After you tested the app and it works correctly, run npm unlink then login to your npm account with:

npm login

Enter your credentials and then run:

npm publish

Note: I ended up renaming the project to repo-create because the name cli-app was taken on npm.

Installation

Now that it's published on npm, we can install the tool with the command:

npm install --global @victoria-lo/repo-create

To use the tool, simply run the repo-create init command on any project you want to create and commit a remote repo for.

Conclusion

We've reached the end of this 2-part tutorial series on how to build a simple CLI tool with Node.js. Thank you for reading! I hope this has been as enjoyable to read for you as much as I've enjoyed writing this.

Please refer to the links below for the tool on npm and the GitHub repo for the code.

Please like and share this article, and ask any questions you may have in the comments below. Cheers!

Did you find this article valuable?

Support Victoria Lo by becoming a sponsor. Any amount is appreciated!

ย