Managing Multiple GitHub Accounts on macOS Using a CLI

At some point during your time as a developer, you have probably encountered the problems associated with using multiple GitHub accounts.

There are a couple of ways to deal with this. The first is manually updating the git config and deleting any associated passwords from the keychain. This method isn’t ideal as you have to login in every time you want to change accounts. It becomes especially annoying when you have to switch between accounts regularly.

The other primary way is using SSH. This method is much better because you can just set your SSH config and then along as you use the correct URL formats in your git commands, you are good to go.

I am going to take this method one step further though and make using multiple GitHub accounts a dream. To do this, I am going to use an SSH config and a custom command line interface written in NodeJS.

Setting Up SSH

The first thing we need to do is create a few SSH keys. We need one for each user. For this article, let’s say we have two accounts.

We will open Terminal (or whichever command line app you use) and change directory to our .ssh folder. The .ssh folder should be in your root user directory. If you don’t have a .ssh folder already, then you can create it by doing the following:

cd ~
mkdir .ssh
cd .ssh

Now we need to create our keys, run the following command once for each user you want to manage.

ssh-keygen -t rsa -b 4096 -C "your@email.com"

What we are doing here is using the ssh-keygen command to generate a public and private key of type rsa, with a size of 4096 bits, and then adding a comment containing our email to it.

When you run the ssh-keygen command, you will then be prompted to enter a file name to be used to store the keys. This file name needs to be unique, for this example use your GitHub username as the file name.

You will also be prompted to enter a passphrase. For this example, I am just going to leave this blank. If you want to use a passphrase and would like to know more then GitHub has some documentation on this.

https://help.github.com/en/articles/working-with-ssh-key-passphrases

Once your keys have been generated, run the ls command, this will allow you to see all of the keys that have been created. For each user, you should have one with no extension, this is the private key, and one with a .pub extension, this is the public key.

Now that we have our keys, we can create our config file. If you don’t already have a file called config in your .ssh folder then run the following command to create it:

touch config

Open this file up in an editor, and type the following into it:

# GitHub username
Host github.com-username
HostName github.com
User git
IdentityFile ~/.ssh/{key}
IdentitiesOnly yes

Replace username with your GitHub username, and replace {key} with the file name that you gave your key relating to this account. We want one of these blocks for each user that we want to manage.

Once you have done that we only have one more step to do and then our SSH is all set up. This step is uploading the keys to GitHub, log into each of your accounts, go to settings, then SSH and GPG keys. We want to create a new SSH key, so click on the New SSH Key button. You can now paste the contents of your public key file into the key field and give it a GitHub specific title.

After this is done, we can move onto building the CLI.

Building The CLI

I am going to call the cli in this example git-config, but you can call it pretty much anything that you would like.

Create a folder somewhere with the name of your cli and navigate to it. We will put all of our code in here. We will start by running npm init to get our package.json file set up. Go through all of the prompts and enter the values that you would like. Once you have finished with npm init open up the file, we need to add one more thing to it. Add the key, bin, with a value of your entry point, in my case it is "bin": "./index.js".

We also need to install a few packages to help make our lives easier. We want commander, data-store, and prompt-async. Commander is a package that will allow us to build the cli quickly, data-store will enable us to store our git config, and prompt-async will make it easy for us to display prompts in the command line. Run the following command to install these:

npm install commander data-store prompt-async --save

Once npm has finished installing the packages, you should have a package.json that looks something like this:

{
"name": "git-cli",
"version": "1.0.0",
"description": "CLI to help with managing multiple GitHub accounts",
"main": "index.js",
"bin": "./index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"author": "Will Swan",
"license": "UNLICENSED",
"dependencies": {
"commander": "^2.20.0",
"data-store": "^3.1.0",
"prompt-async": "^0.9.9"
}
}

We are now ready to get on and build the cli. Let’s start by creating our entrypoint file.

Writing The Entrypoint

In your entrypoint file type in the following code:

#!/usr/bin/env node
// Packages
const Program = require('commander');
// Src
const Config = require('./src/Config');
const Push = require('./src/Push');
const Clone = require('./src/Clone');
// CLI details
Program
.version('1.0.0')
.description('git-cli App');
// Config command
Program
.command('config <name>')
.description('Switch between configurations')
.action((name) => {
Config.handler(name);
}).parse(process.argv);
// Push command
Program
.command('push <version>')
.description('Push changes to GitHub')
.action((version) => {
Push.handler(version);
}).parse(process.argv)
// Clone command
Program
.command('clone <repoName>')
.description('Clone a GitHub repo')
.action((repoName) => {
Clone.handler(repoName);
}).parse(process.argv);
// Parse arguments
Program.parse(process.argv);

Now let’s have a look at what is going on here.

We start with:

#!/usr/bin/env node

This is needed to run the cli using node.

We then go ahead and import commander, we also import three source files. These files will contain the code for each of our commands.

After that, we set up the cli with a version and a description. If you run the help command, then these should be displayed in the command line.

Next, we create three commands, config, push, and clone. All of these commands have a required argument, in the config command, this is <name>. They also have a description. The description should be displayed when you run the help command. Lastly they all have an action, this is where all of the logic for the command is written, in our case we are invoking the handlers in our source files and passing the arguments.

We then have a line that parses all incoming arguments.

Currently, if you tried to run this it wouldn’t work, you would get a bash error saying that the command is not found. This is because we haven’t created any symlinks yet, we can do this by running npm link. Once you have run this, you should be able to access the cli just like you would any other. For example, if we ran:

git-cli config TestName

This would now work, well you would get an error because we haven’t created our source files yet, but the command would be found.

Let’s now create our src folder, and add our three source files.

mkdir src
touch ./src/Config.js
touch ./src/Push.js
touch ./src/Clone.js

Writing The Config Command

We will have a look at our Config.js file bit by bit. First, we need to import some packages.

// Packages
const Prompt = require('prompt-async');
const Store = require('data-store');
const store = new Store({ path: '/Users/username/git-config.json'});
const util = require('util');
const exec = util.promisify(require('child_process').exec);

Here we are importing prompt-async and data-store. We then set up our data store by creating a new Store object, passing in the location where we want our json file to be saved. We will store this in our user root directory. This will allow us to run the command from any directory and still be able to access the store.

We then import util. We want this so that we can use promisify because we want to be able to run exec using async await. We then go ahead in import exec.

Next type the following:

// Available configs
const configs = {
username1: {
name: 'username1',
email: 'emailforaccount@domain.com'
},
username2: {
name: 'username2',
email: 'emailforaccount@domain.com'
}
};

This configs object will hold all of our available accounts, go ahead and add all the accounts that you want to manage. We will use this object in the next step.

Now let’s write our handler, type the following:

// Handler
exports.handler = async (name) => {
  // Check configs
console.log('Checking configs...');
let config = {};
if (name === 'username1') {
config.name = configs.username1.name;
config.email = configs.username1.email;
} else if (name === 'username2') {
config.name = configs.username2.name;
config.email = configs.username2.email;
} else {
throw 'Please provide a valid config name';
}
  // Ask what repo we are working on
Prompt.start();
  try {
    let response = await Prompt.get({
name: 'repoName',
description: 'What repo are you working on?',
required: true
});
    config.repo = response.repoName;
    // Store config
store.set('git', config);
    console.log('Configs checked');
    // Set git configs
await setGitConfigs(config);
    // Set remote url
await setRemoteURL(config);
  } catch(err) {
throw err;
}
}

The first thing that we do in our handler is create an empty config object. We then check if the name argument corresponds to one of our accounts defined in the configs object. If we get a match, then we set the name and the email to the empty config object. If there is no match, then we throw an error telling us to provide a valid config name.

After this, we use prompt-async to display a prompt asking us what repo we are currently working on. Once a value has been entered for the prompt, we set that value to our config object. Our config object now has everything we need in it, so we save this to our store using store.set('key', 'data).

We then go on to call two functions, setGitConfigs() and setRemoteURL(). The first function is used to set the local and global git configs, and the second is used to set the git remote URL.

Let’s write the setGitConfigs() function:

async function setGitConfigs(config) {
  console.log('Setting local and global git configs...');
  // Set global and local git configs
const { stdout, stderr } = await exec(`git config --global user.name ${config.name}; git config --global user.email ${config.email}; git config user.name ${config.name}; git config user.email ${config.email};`);
  if (stderr) {
throw stderr;
} else {
console.log('Local and global git configs set');
}
}

Here we use exec to run the commands for setting the git config just like we would in the command line. We wait for exec to finish and then check stderr to see if there was an error.

Our setRemoteURL() function is very similar:

async function setRemoteURL(config) {
  console.log('Setting remote url...');
  // Set remote url
const { stdout, stderr } = await exec(`git remote set-url origin git@github.com-${config.name}:${config.name}/${config.repo}.git`);
  if (stderr) {
throw stderr;
} else {
console.log('Remote url set');
}
}

That is now it for the config command. If you now run git-cli config yourusername, then you should see logs displayed letting us know what is going on. If you want you can check the git-config.json file to make sure that the config was stored, you could also run git config user.name to see if the git configs have been set.

One thing to note with our config command is that it can only be run in directories that are already git directories. This is because we are running git commands. However, this shouldn’t be a problem because you should only really ever need to run this command while you are working in a git directory.

Writing The Push Command

The packages we need in our Push.js file are the same as in our Config.js file so you can copy them over.

Let’s have a look at writing the handler:

// Handler
exports.handler = async (version) => {
  console.log('Checking config...');
  // Get the stored config
const config = store.get('git');
  // Display the username for this config
console.log('');
console.log(`Username: ${config.name}`);
console.log('');
  // Ask if this config is correct
Prompt.start();
  try {
    let response = await Prompt.get({
name: 'correct',
description: 'Is this the correct config? Y/n',
required: true
});
    if (response.correct.toLowerCase() === 'y') {
      console.log('Config checked');
      // Add files
await addFiles();
      // Commit
await commitFiles(version);
      // Push
await pushFiles();
    } else {
throw 'Please use git-cli config <name> to set the correct config'
}
  } catch(err) {
throw err;
}
}

The first thing that we do here is to get our stored config from our git-config.json file. We then display the username for the saved config. We do this because we want to check to make sure that the stored config is the correct one.

To perform this check we display a prompt asking if this is the correct config, if the answer is Y, then we continue with the command. If the answer is anything other than Y, then we throw an error asking us to use our config command to set the correct config.

If continuing with the command we call three functions, addFiles() , commitFiles() , and pushFiles() . Let’s have a look at these functions.

async function addFiles() {
  console.log('Adding files...');
  const { stdout, stderr } = await exec('git add -A');
  if (stderr) {
throw stderr;
} else {
console.log('Files added');
}
}

In our addFiles() function we use exec to run the git command for adding files. For this example, I have just set this to add all files, if you wanted you could add an extra argument to the push command allowing you to pass in the files that you want to be added. We then wait for exec to finish and check if there was an error.

The commit function is very similar:

async function commitFiles(version) {
   console.log(`Comitting version ${version}...`);
   const { stdout, stderr } = await exec(`git commit -m "${version}"`);
  if (stderr) {
throw stderr;
} else {
console.log(`Version ${version} committed`);
}
}

Here we run the git command for committing the files, and we use the version argument as the message.

And lastly the push function:

async function pushFiles() {
  console.log('Pushing files');
  const { stdout, stderr } = await exec('git push');
console.log('Files pushed');
}

You will notice here that we don’t check for any errors, this is because git push outputs to stderr no matter whether it is successful or not. If there is an error, then the cli will exit, and the error will be displayed.

That is now everything that we need for the push command. If you run git-cli push 1.0.0 in a git directory, then your changes should be pushed to the correct account and the correct repo.

Writing The Clone Command

Our last command is for cloning repos. Typically if cloning a repo, you might copy the repo URL and run git clone, if this repo is in a different account then you might be asked to log into that account. This command aims to simplify the process by allowing you to execute git-cli clone repo-name, as long as you have the correct config set then this should clone the repo into the directory that you executed the command without needing to copy anything or enter any additional details.

Just like in Push.js, our imports are the same for Clone.js so you can copy them across.

Let’s write the handler:

// Handler
exports.handler = async (repoName) => {
  console.log('Checking config');
  // Get stored config
const config = store.get('git');
  // Display username for the stored config
console.log('');
console.log(`Username: ${config.name}`);
console.log('');
  // Ask if this correct config
Prompt.start();
  try {
    let response = await Prompt.get({
name: 'correct',
description: 'Is this the correct config? Y/n',
required: true
});
    if (response.correct.toLowerCase() === 'y') {
      console.log('Config checked');
console.log('Cloning repo...');
      const { stdout, stderr } = await exec(`git clone git@github.com-${config.name}:${config.name}/${repoName}.git`);
      if (stderr.includes('Cloning into')) {
console.log('Repo cloned');
} else {
throw stderr;
}
    } else {
throw 'Please use git-cli config <name> to set the correct config';
}
  } catch(err) {
throw err;
}
}

You will notice that our handler is very similar to the one in Push.js. This is because we also want to check to make sure we are using the correct config in our clone command.

The difference in the handler is that instead of calling some functions, we use exec to run the git command for cloning a repo including the details stored in our config and the repoName argument.

Like git push, git clone also outputs everything to stderr. However, when cloning, we can check if there was an error by checking if stderr includes 'Cloning into'.

That is all there is to the clone command. You should now be able to run git-cli clone repo-name. If you are using the correct config and the repo exists, then you will see the repo cloned.

Conclusion

There we have it, we have set up SSH and built a cli to help us efficiently manage our multiple GitHub accounts. I very regular need to switch GitHub users and repos, since creating my cli I have been using it all the time, and it has sped up my development process. I hope that you will find it just as useful.

If you want to learn more about building the cli and how it works, then you can watch a YouTube video that I have uploaded detailing everything.

https://www.youtube.com/watch?v=rjELrXXF_os

I have also put the cli on GitHub for everyone to use. I already have quite a few improvements and features that I want to make. These will be pushed to GitHub when I make them. You should also feel free to contribute to the repo. I am excited to see what people add to it.

https://github.com/willptswan/git-cli