As great as Node.js is for “traditional” web applications, its potential uses are far broader. Microservices, REST APIs, tooling, working with the Internet of Things and even desktop applications: it’s got your back.
Another area where Node.js is really useful is for building command-line applications — and that’s what we’re going to be doing in this article. We’re going to start by looking at a number of third-party packages designed to help work with the command line, then build a real-world example from scratch.
What we’re going to build is a tool for initializing a Git repository. Sure, it’ll run git init
under the hood, but it’ll do more than just that. It will also create a remote repository on GitHub right from the command line, allow the user to interactively create a .gitignore
file, and finally perform an initial commit and push.
As ever, the code accompanying this tutorial can be found on our GitHub repo.
Before we dive in and start building, it’s worth looking at why we might choose Node.js to build a command-line application.
The most obvious advantage is that, if you’re reading this, you’re probably already familiar with it — and, indeed, with JavaScript.
Another key advantage, as we’ll see as we go along, is that the strong Node.js ecosystem means that among the hundreds of thousands of packages available for all manner of purposes, there are a number which are specifically designed to help build powerful command-line tools.
Finally, we can use npm
to manage any dependencies, rather than have to worry about OS-specific package managers such as Aptitude, Yum or Homebrew.
Tip: that isn’t necessarily true, in that your command-line tool may have other external dependencies.
For this tutorial, We’re going to create a command-line utility which I’m calling ginit. It’s git init
, but on steroids.
You’re probably wondering what on earth that means.
As you no doubt already know, git init
initializes a Git repository in the current folder. However, that’s usually only one of a number of repetitive steps involved in the process of hooking up a new or existing project to Git. For example, as part of a typical workflow, you may well:
git init
.gitignore
fileThere are often more steps involved, but we’ll stick to those for the purposes of our app. Nevertheless, these steps are pretty repetitive. Wouldn’t it be better if we could do all this from the command line, with no copy-pasting of Git URLs and such like?
So what ginit will do is create a Git repository in the current folder, create a remote repository — we’ll be using GitHub for this — and then add it as a remote. Then it will provide a simple interactive “wizard” for creating a .gitignore
file, add the contents of the folder and push it up to the remote repository. It might not save you hours, but it’ll remove some of the initial friction when starting a new project.
With that in mind, let’s get started.
One thing is for certain: in terms of appearance, the console will never have the sophistication of a graphical user interface. Nevertheless, that doesn’t mean it has to be plain, ugly, monochrome text. You might be surprised by just how much you can do visually, while at the same time keeping it functional. We’ll be looking at a couple of libraries for enhancing the display: chalk for colorizing the output and clui to add some additional visual components. Just for fun, we’ll use figlet to create a fancy ASCII-based banner and we’ll also use clear to clear the console.
In terms of input and output, the low-level Readline Node.js module could be used to prompt the user and request input, and in simple cases is more than adequate. But we’re going to take advantage of a third-party package which adds a greater degree of sophistication — Inquirer. As well as providing a mechanism for asking questions, it also implements simple input controls: think radio buttons and checkboxes, but in the console.
We’ll also be using minimist to parse command-line arguments.
Here’s a complete list of the packages we’ll use specifically for developing on the command line:
Additionally, we’ll also be using the following:
Although we’re going to create the application from scratch, don’t forget that you can also grab a copy of the code from the repository which accompanies this article.
Create a new directory for the project. You don’t have to call it ginit
, of course:
mkdir ginit
cd ginit
Create a new package.json
file:
npm init -y
And edit it to look like so:
{
"name": "ginit",
"version": "1.0.0",
"description": "'git init' on steroids",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [
"Git",
"CLI"
],
"author": "<YOUR NAME>",
"license": "ISC"
}
Now install the dependencies:
npm install chalk clear clui figlet inquirer minimist configstore @octokit/rest lodash simple-git touch
Now create an index.js
file in the same folder and require
the following dependencies:
const chalk = require('chalk');
const clear = require('clear');
const figlet = require('figlet');
We’re going to create a lib
folder where we’ll split our helper code into modules:
Let’s start with lib/files.js
. Here, we need to:
.git
).This sounds straightforward, but there are a couple of gotchas to take into consideration.
Firstly, you might be tempted to use the fs
module’s realpathSync method to get the current directory:
path.basename(path.dirname(fs.realpathSync(__filename)));
This will work when we’re calling the application from the same directory (e.g. using node index.js
), but bear in mind that we’re going to be making our console application available globally. This means we’ll want the name of the directory we’re working in, not the directory where the application resides. For this purpose, it’s better to use process.cwd:
path.basename(process.cwd());
Secondly, the preferred method of checking whether a file or directory exists keeps changing. The current way is to use existsSync
. This returns true
if the path exists, false
otherwise.
Finally, it’s worth noting that when you’re writing a command-line application, using the synchronous version of these sorts of methods is just fine.
Putting that all together, let’s create a utility package in lib/files.js
:
const fs = require('fs');
const path = require('path');
module.exports = {
getCurrentDirectoryBase: () => {
return path.basename(process.cwd());
},
directoryExists: (filePath) => {
return fs.existsSync(filePath);
}
};
Go back to index.js
and ensure you require
the new file:
const files = require('./lib/files');
With this in place, we can start developing the application.
Now let’s implement the start-up phase of our console application.
In order to demonstrate some of the packages we’ve installed to enhance the console output, let’s clear the screen and then display a banner:
// index.js
clear();
console.log(
chalk.yellow(
figlet.textSync('Ginit', { horizontalLayout: 'full' })
)
);
You can run the application using node index.js
. The output from this is shown below.
Next up, let’s run a simple check to ensure that the current folder isn’t already a Git repository. That’s easy: we just check for the existence of a .git
folder using the utility method we just created:
//index.js
if (files.directoryExists('.git')) {
console.log(chalk.red('Already a Git repository!'));
process.exit();
}
Tip: notice we’re using the chalk module to show a red-colored message.
The next thing we need to do is create a function that will prompt the user for their GitHub credentials.
We can use Inquirer for this. The module includes a number of methods for various types of prompts, which are roughly analogous to HTML form controls. In order to collect the user’s GitHub username and password, we’re going to use the input
and password
types respectively.
First, create lib/inquirer.js
and insert this code:
const inquirer = require('inquirer');
const files = require('./files');
module.exports = {
askGithubCredentials: () => {
const questions = [
{
name: 'username',
type: 'input',
message: 'Enter your GitHub username or e-mail address:',
validate: function( value ) {
if (value.length) {
return true;
} else {
return 'Please enter your username or e-mail address.';
}
}
},
{
name: 'password',
type: 'password',
message: 'Enter your password:',
validate: function(value) {
if (value.length) {
return true;
} else {
return 'Please enter your password.';
}
}
}
];
return inquirer.prompt(questions);
},
};
As you can see, inquirer.prompt()
asks the user a series of questions, provided in the form of an array as the first argument. Each question is made up of an object which defines the name
of the field, the type
(we’re just using input
and password
respectively here, but later we’ll look at a more advanced example), and the prompt (message
) to display.
The input the user provides will be passed in to the calling function as a Promise
. If successful, we’ll end up with a simple object with two properties; username
and password
.
You can test all of this out by adding the following to index.js
:
const inquirer = require('./lib/inquirer');
const run = async () => {
const credentials = await inquirer.askGithubCredentials();
console.log(credentials);
};
run();
Then run the script using node index.js
.
Tip: When you’re done testing, don’t forget to remove the line const inquirer = require('./lib/inquirer');
from index.js
, as we won’t actually need it in this file.
The next step is to create a function to retrieve an OAuth token for the GitHub API. Essentially, we’re going to “exchange” the username and password for a token.
Of course, we don’t want users to have to enter their credentials every time they use the tool. Instead, we’ll store the OAuth token for subsequent requests. This is where the configstore package comes in.
Storing config is outwardly quite straightforward: you can simply read and write to/from a JSON file without the need for a third-party package. However, the configstore package provides a few key advantages:
To use it, simply create an instance, passing it an application identifier. For example:
const Configstore = require('configstore');
const conf = new Configstore('ginit');
If the configstore
file doesn’t exist, it’ll return an empty object and create the file in the background. If there’s already a configstore
file, the contents will be decoded into JSON and made available to your application. You can now use conf
as a simple object, getting or setting properties as required. As mentioned above, you don’t need to worry about saving it afterwards. That gets taken care of for you.
Tip: on macOS, you’ll find the file in /Users/[YOUR-USERNME]/.config/configstore/ginit.json
, on Linux it’s in /home/[YOUR-USERNME]/.config/configstore/ginit.json
Let’s create a library for handling the GitHub token. Create the file lib/github.js
and place the following code inside it:
const CLI = require('clui');
const Configstore = require('configstore');
const Octokit = require('@octokit/rest');
const Spinner = CLI.Spinner;
const inquirer = require('./inquirer');
const pkg = require('../package.json');
const conf = new Configstore(pkg.name);
Now let’s add the function that checks whether we’ve already got an access token. We’ll also add a function that allows other libs to access octokit
(GitHub) functions:
module.exports = {
getInstance: () => {
return octokit;
},
getStoredGithubToken: () => {
return conf.get('github.token');
},
};
If a conf
object exists and it has github.token
property, this means that there’s already a token in storage. In this case, we return the token value back to the invoking function. We’ll get to that later on.
If no token is detected, we need to fetch one. Of course, getting an OAuth token involves a network request, which means a short wait for the user. This gives us an opportunity to look at the clui package which provides some enhancements for console-based applications, among them an animated spinner.
Creating a spinner is easy:
const status = new Spinner('Authenticating you, please wait...');
status.start();
Once you’re done, simply stop it and it will disappear from the screen:
status.stop();
Tip: you can also set the caption dynamically using the update
method. This could be useful if you have some indication of progress, for example displaying the percentage complete.
Here’s the code to authenticate with GitHub:
...
let octokit;
module.exports = {
...
setGithubCredentials: async () => {
const credentials = await inquirer.askGithubCredentials();
octokit = new Octokit({
auth: {
username: credentials.username,
password: credentials.password,
}
});
},
registerNewToken: async () => {
const status = new Spinner('Authenticating you, please wait...');
status.start();
try {
const response = await octokit.oauthAuthorizations.createAuthorization({
scopes: ['user', 'public_repo', 'repo', 'repo:status'],
note: 'ginit, the command-line tool for initalizing Git repos'
});
const token = response.data.token;
if(token) {
conf.set('github.token', token);
return token;
} else {
throw new Error("Missing Token","GitHub token was not found in the response");
}
} catch (err) {
throw err;
} finally {
status.stop();
}
},
};
Let’s step through this:
octokit
variable in the module’s upper scopeaskGithubCredentials
method we defined earlierauth
option to the Octokit constructor to enable authenticated requestsoctokit
variable so that it is available throughout the moduleconfigstore
for next time.Any access tokens you create, whether manually or via the API as we’re doing here, you’ll be able to see them here. During the course of development, you may find you need to delete ginit’s access token — identifiable by the note
parameter supplied above — so that you can re-generate it.
Tip: if you have two-factor authentication enabled on your GitHub account, the process is slightly more complicated. You’ll need to request the confirmation code — for example, one sent via SMS — then supply it using the X-GitHub-OTP
header. See the documentation for further information.
If you’ve been following along and would like to try out what we have so far, you can update index.js
as follows:
const github = require('./lib/github');
...
const run = async () => {
let token = github.getStoredGithubToken();
if(!token) {
await github.setGithubCredentials();
token = await github.registerNewToken();
}
console.log(token);
}:
Please note you may get a Promise
error if something goes wrong, such as inputting the wrong password. I’ll show you the proper way of handling such errors later.
Once we’ve got an OAuth token, we can use it to create a remote repository with GitHub.
Again, we can use Inquirer to ask a series of questions. We need a name for the repo, we’ll ask for an optional description, and we also need to know whether it should be public or private.
We’ll use minimist to grab defaults for the name and description from optional command-line arguments. For example:
ginit my-repo "just a test repository"
This will set the default name to my-repo
and the description to just a test repository
.
The following line will place the arguments in an array indexed by an underscore:
const argv = require('minimist')(process.argv.slice(2));
// { _: [ 'my-repo', 'just a test repository' ] }
Tip: this only really scratches the surface of the minimist package. You can also use it to intepret flags, switches and name/value pairs. Check out the documentation for more information.
We’ll write code to parse the command-line arguments and ask a series of questions. First, update lib/inquirer.js
by inserting the following code right after askGithubCredentials
function:
askRepoDetails: () => {
const argv = require('minimist')(process.argv.slice(2));
const questions = [
{
type: 'input',
name: 'name',
message: 'Enter a name for the repository:',
default: argv._[0] || files.getCurrentDirectoryBase(),
validate: function( value ) {
if (value.length) {
return true;
} else {
return 'Please enter a name for the repository.';
}
}
},
{
type: 'input',
name: 'description',
default: argv._[1] || null,
message: 'Optionally enter a description of the repository:'
},
{
type: 'list',
name: 'visibility',
message: 'Public or private:',
choices: [ 'public', 'private' ],
default: 'public'
}
];
return inquirer.prompt(questions);
},
Next, create the file lib/repo.js
and add this code:
const CLI = require('clui');
const fs = require('fs');
const git = require('simple-git/promise')();
const Spinner = CLI.Spinner;
const _ = require('lodash');
const inquirer = require('./inquirer');
const gh = require('./github');
module.exports = {
createRemoteRepo: async () => {
const github = gh.getInstance();
const answers = await inquirer.askRepoDetails();
const data = {
name: answers.name,
description: answers.description,
private: (answers.visibility === 'private')
};
const status = new Spinner('Creating remote repository...');
status.start();
try {
const response = await github.repos.createForAuthenticatedUser(data);
return response.data.ssh_url;
} catch(err) {
throw err;
} finally {
status.stop();
}
},
};
Once we have that information, we can simply use the GitHub package to create a repo, which will give us a URL for the newly created repository. We can then set that up as a remote in our local Git repository. First, however, let’s interactively create a .gitignore
file.
For the next step, we’ll create a simple command-line “wizard” to generate a .gitignore
file. If the user is running our application in an existing project directory, let’s show them a list of files and directories already in the current working directory, and allow them to select which ones to ignore.
The Inquirer package provides a checkbox
input type for just that.
The first thing we need to do is scan the current directory, ignoring the .git
folder and any existing .gitignore
file (we do this by making use of lodash’s without method):
const filelist = _.without(fs.readdirSync('.'), '.git', '.gitignore');
If there’s nothing to add, there’s no point in continuing, so let’s simply touch
the current .gitignore
file and bail out of the function:
if (filelist.length) {
...
} else {
touch('.gitignore');
}
Finally, let’s utilize Inquirer’s checkbox “widget” to list the files. Insert the following code in lib/inquirer.js
:
askIgnoreFiles: (filelist) => {
const questions = [
{
type: 'checkbox',
name: 'ignore',
message: 'Select the files and/or folders you wish to ignore:',
choices: filelist,
default: ['node_modules', 'bower_components']
}
];
return inquirer.prompt(questions);
},
Notice that we can also provide a list of defaults. In this case, we’re pre-selecting node_modules
and bower_components
, should they exist.
With the Inquirer code in place, we can now construct the createGitignore()
function. Insert this code in lib/repo.js
:
createGitignore: async () => {
const filelist = _.without(fs.readdirSync('.'), '.git', '.gitignore');
if (filelist.length) {
const answers = await inquirer.askIgnoreFiles(filelist);
if (answers.ignore.length) {
fs.writeFileSync( '.gitignore', answers.ignore.join( '\n' ) );
} else {
touch( '.gitignore' );
}
} else {
touch('.gitignore');
}
},
Once “submitted”, we then generate a .gitignore
by joining up the selected list of files, separated with a newline. Our function now pretty much guarantees we’ve got a .gitignore
file, so we can proceed with initializing a Git repository.
There are a number of ways to interact with Git, but perhaps the simplest is to use the simple-git package. This provides a set of chainable methods which, behind the scenes, run the Git executable.
These are the repetitive tasks we’ll use it to automate:
git init
.gitignore
fileInsert the following code in lib/repo.js
:
setupRepo: async (url) => {
const status = new Spinner('Initializing local repository and pushing to remote...');
status.start();
return git.init()
.then(git.add('.gitignore'))
.then(git.add('./*'))
.then(git.commit('Initial commit'))
.then(git.addRemote('origin', url))
.then(git.push('origin', 'master'))
.finally(status.stop());
},
First, let’s set a helper function in lib/github.js
for setting up an oauth
authentication:
githubAuth: (token) => {
octokit = new Octokit({
auth: token
});
},
Next, we create a function in index.js
for handling the logic of acquiring the token. Place this code before the run()
function’:
const getGithubToken = async () => {
// Fetch token from config store
let token = github.getStoredGithubToken();
if(token) {
return token;
}
// No token found, use credentials to access GitHub account
await github.setGithubCredentials();
// register new token
token = await github.registerNewToken();
return token;
};
Finally, we update the run()
function by writing code that will handle the main logic of the app:
const repo = require('./lib/repo');
...
const run = async () => {
try {
// Retrieve & Set Authentication Token
const token = await getGithubToken();
github.githubAuth(token);
// Create remote repository
const url = await repo.createRemoteRepo();
// Create .gitignore file
await repo.createGitignore();
// Set up local repository and push to remote
await repo.setupRepo(url);
console.log(chalk.green('All done!'));
} catch(err) {
if (err) {
switch (err.status) {
case 401:
console.log(chalk.red('Couldn\'t log you in. Please provide correct credentials/token.'));
break;
case 422:
console.log(chalk.red('There already exists a remote repository with the same name'));
break;
default:
console.log(err);
}
}
}
};
As you can see, we ensure the user is authenticated before calling all of our other functions (createRemoteRepo()
, createGitignore()
, setupRepo()
) sequentially. The code also handles any errors and offers the user appropriate feedback.
You can check out the completed index.js file on our GitHub repo.
At this point you should have a working app. Give it a try and satisfy yourself that it works as expected.
The one remaining thing to do is to make our command available globally. To do this, we’ll need to add a shebang line to the top of index.js
:
#!/usr/bin/env node
Next, we need to add a bin
property to our package.json
file. This maps the command name (ginit
) to the name of the file to be executed (relative to package.json
).
"bin": {
"ginit": "./index.js"
}
After that, install the module globally and you’ll have a working shell command:
npm install -g
Tip: this will also work on Windows, as npm will helpfully install a cmd wrapper alongside your script.
If you want to confirm the install worked, you can list your globally installed Node modules using:
npm ls --depth=0
We’ve got a fairly nifty, albeit simple command-line app for initializing Git repositories. But there’s plenty more you could do to enhance it further.
If you’re a Bitbucket user, you could adapt the program to use the Bitbucket API to create a repository. There’s a Node.js API wrapper available to help you get started. You may wish to add an additional command-line option or prompt to ask the user whether they want to use GitHub or Bitbucket (Inquirer would be perfect for just that) or merely replace the GitHub-specific code with a Bitbucket alternative.
You could also provide the facility to specify your own set of defaults for the .gitgnore
file, instead of a hardcoded list. The preferences package might be suitable here, or you could provide a set of “templates” — perhaps prompting the user for the type of project. You might also want to look at integrating it with the .gitignore.io command-line tool/API.
Beyond all that, you may also want to add additional validation, provide the ability to skip certain sections, and more. If you have any other ideas, do let us know in the comments.
#javascript #node-js #web-development