ghswap
Version:
Easy GitHub account switching - manage multiple GitHub accounts with seamless context switching for Git config, SSH keys, and GitHub CLI
471 lines (468 loc) ⢠18.9 kB
JavaScript
import { execSync } from "child_process";
import * as fs from "fs";
import * as path from "path";
import * as os from "os";
import * as readline from "readline";
const CONFIG_PATH = path.join(os.homedir(), ".ghswap.json");
const SSH_CONFIG_PATH = path.join(os.homedir(), ".ssh", "config");
class GitHubSwitcher {
constructor() {
this.config = this.loadConfig();
}
loadConfig() {
if (!fs.existsSync(CONFIG_PATH)) {
// Return empty config - let individual commands handle missing config
return {
accounts: [],
directoryMappings: {},
};
}
return JSON.parse(fs.readFileSync(CONFIG_PATH, "utf-8"));
}
ensureConfigExists() {
if (this.config.accounts.length === 0) {
console.log("\nā ļø No GitHub accounts configured yet!\n");
console.log("š Get started:");
console.log(" ghswap add Add your first account (recommended)");
console.log(" ghswap help Show all commands\n");
console.log(`š” Or manually create: ${CONFIG_PATH}\n`);
process.exit(1);
}
}
expandPath(p) {
if (p.startsWith("~")) {
return path.join(os.homedir(), p.slice(1));
}
return path.resolve(p);
}
getCurrentDirectory() {
return process.cwd();
}
findAccountForDirectory(dir) {
const expandedDir = this.expandPath(dir);
for (const [mappedPath, accountName] of Object.entries(this.config.directoryMappings)) {
const expandedMappedPath = this.expandPath(mappedPath);
if (expandedDir.startsWith(expandedMappedPath)) {
return accountName;
}
}
return null;
}
getAccount(name) {
return this.config.accounts.find((acc) => acc.name === name);
}
isGitRepository() {
try {
execSync("git rev-parse --git-dir", { stdio: "pipe" });
return true;
}
catch {
return false;
}
}
async switchAccount(accountName, scope = "global") {
const account = this.getAccount(accountName);
if (!account) {
console.error(`ā Account '${accountName}' not found in config.`);
return;
}
console.log(`\nš Switching to account: ${account.name}`);
// 1. Switch Git Config
try {
// If local scope requested but not in a git repo, fall back to global
let actualScope = scope;
if (scope === "local" && !this.isGitRepository()) {
console.log("ā¹ļø Not in a git repository, using global scope");
actualScope = "global";
}
const scopeFlag = actualScope === "global" ? "--global" : "--local";
execSync(`git config ${scopeFlag} user.name "${account.gitName}"`, {
stdio: "pipe",
});
execSync(`git config ${scopeFlag} user.email "${account.gitEmail}"`, {
stdio: "pipe",
});
console.log(`ā
Git config updated (${actualScope})`);
}
catch (error) {
console.error("ā Failed to update git config");
}
// 2. Switch SSH Key
try {
const sshKeyPath = this.expandPath(account.sshKey);
if (!fs.existsSync(sshKeyPath)) {
console.warn(`ā ļø SSH key not found: ${sshKeyPath}`);
}
else {
// Clear existing SSH identities and add the new one
execSync("ssh-add -D 2>/dev/null || true", { stdio: "pipe" });
execSync(`ssh-add ${sshKeyPath}`, { stdio: "inherit" });
console.log("ā
SSH key updated");
}
}
catch (error) {
console.error("ā Failed to update SSH key");
}
// 3. Switch GitHub CLI (if token provided)
if (account.ghToken) {
try {
execSync(`echo ${account.ghToken} | gh auth login --with-token`, {
stdio: "pipe",
});
console.log("ā
GitHub CLI authenticated");
}
catch (error) {
console.warn("ā ļø Failed to authenticate GitHub CLI (is it installed?)");
}
}
console.log(`\n⨠Switched to ${account.name} (${account.gitEmail})\n`);
}
async autoSwitch() {
const currentDir = this.getCurrentDirectory();
const accountName = this.findAccountForDirectory(currentDir);
if (accountName) {
console.log(`š Detected directory mapping: ${accountName}`);
await this.switchAccount(accountName, "local");
}
else if (this.config.defaultAccount) {
console.log(`ā¹ļø No directory mapping found, using default: ${this.config.defaultAccount}`);
await this.switchAccount(this.config.defaultAccount, "global");
}
else {
console.log("ā¹ļø No directory mapping found for current location");
console.log('š” Run "ghswap" to manually select an account');
console.log('š” Or set a default account in ~/.ghswap.json: "defaultAccount": "personal"');
}
}
async interactiveSwitch() {
this.ensureConfigExists();
console.log("\nš Available accounts:\n");
this.config.accounts.forEach((acc, idx) => {
console.log(` ${idx + 1}. ${acc.name} (${acc.gitEmail})`);
});
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
rl.question("\nSelect account (number or name): ", async (answer) => {
rl.close();
const num = parseInt(answer);
let accountName;
if (!isNaN(num) && num > 0 && num <= this.config.accounts.length) {
accountName = this.config.accounts[num - 1].name;
}
else {
accountName = answer.trim();
}
await this.switchAccount(accountName);
});
}
listAccounts() {
this.ensureConfigExists();
console.log("\nš Configured accounts:\n");
this.config.accounts.forEach((acc) => {
const isDefault = acc.name === this.config.defaultAccount;
console.log(` ⢠${acc.name}${isDefault ? " (default)" : ""}`);
console.log(` Name: ${acc.gitName}`);
console.log(` Email: ${acc.gitEmail}`);
console.log(` SSH: ${acc.sshKey}`);
console.log();
});
console.log("š Directory mappings:\n");
Object.entries(this.config.directoryMappings).forEach(([dir, account]) => {
console.log(` ${dir} ā ${account}`);
});
if (this.config.defaultAccount) {
console.log(`\nšÆ Default account: ${this.config.defaultAccount}`);
console.log(" (Used when no directory mapping matches)");
}
else {
console.log("\nš” Tip: Set a default account in ~/.ghswap.json:");
console.log(' "defaultAccount": "personal"');
}
console.log();
}
setupShellHook() {
const shellRcFile = process.env.SHELL?.includes("zsh")
? ".zshrc"
: ".bashrc";
const rcPath = path.join(os.homedir(), shellRcFile);
const hookCode = `
# GitHub Account Switcher - Auto-switch on directory change
ghswap_auto() {
ghswap auto 2>/dev/null
}
# Hook into directory changes
autoload -U add-zsh-hook 2>/dev/null || true
add-zsh-hook chpwd ghswap_auto 2>/dev/null || PROMPT_COMMAND="ghswap_auto; $PROMPT_COMMAND"
`;
console.log(`\nš To enable auto-switching on directory change, add this to your ${shellRcFile}:\n`);
console.log(hookCode);
console.log(`\nOr run: echo '${hookCode.replace(/\n/g, "\\n")}' >> ~/${shellRcFile}`);
}
generateSSHConfig() {
const sshDir = path.join(os.homedir(), ".ssh");
const sshConfigPath = path.join(sshDir, "config");
// Ensure .ssh directory exists
if (!fs.existsSync(sshDir)) {
fs.mkdirSync(sshDir, { mode: 0o700 });
}
// Read existing config
let existingConfig = "";
let existingLines = [];
if (fs.existsSync(sshConfigPath)) {
existingConfig = fs.readFileSync(sshConfigPath, "utf-8");
existingLines = existingConfig.split("\n");
}
// Remove old ghswap managed blocks
const startMarker = "# BEGIN GHSWAP MANAGED BLOCK";
const endMarker = "# END GHSWAP MANAGED BLOCK";
let filteredLines = [];
let inManagedBlock = false;
for (const line of existingLines) {
if (line.trim() === startMarker) {
inManagedBlock = true;
continue;
}
if (line.trim() === endMarker) {
inManagedBlock = false;
continue;
}
if (!inManagedBlock) {
filteredLines.push(line);
}
}
// Generate new SSH config blocks
const sshBlocks = [startMarker];
this.config.accounts.forEach((account) => {
const sshKeyPath = this.expandPath(account.sshKey);
const hostAlias = `github.com-${account.name}`;
sshBlocks.push("");
sshBlocks.push(`Host ${hostAlias}`);
sshBlocks.push(" HostName github.com");
sshBlocks.push(" User git");
sshBlocks.push(` IdentityFile ${sshKeyPath}`);
sshBlocks.push(" IdentitiesOnly yes");
});
sshBlocks.push("");
sshBlocks.push(endMarker);
// Combine: existing config + new managed block
const newConfig = [...filteredLines, "", ...sshBlocks, ""].join("\n");
// Backup existing config
if (fs.existsSync(sshConfigPath)) {
const backupPath = `${sshConfigPath}.backup.${Date.now()}`;
fs.copyFileSync(sshConfigPath, backupPath);
console.log(`š¦ Backed up existing config to: ${backupPath}`);
}
// Write new config
fs.writeFileSync(sshConfigPath, newConfig, { mode: 0o600 });
console.log(`ā
SSH config updated: ${sshConfigPath}\n`);
// Show usage examples
console.log("šÆ Clone repositories using these host aliases:\n");
this.config.accounts.forEach((account) => {
const hostAlias = `github.com-${account.name}`;
console.log(` ${account.name}:`);
console.log(` git clone git@${hostAlias}:username/repo.git`);
});
console.log("");
}
initSSHKeys() {
console.log("š Checking SSH keys...\n");
let missingKeys = [];
this.config.accounts.forEach((account) => {
const sshKeyPath = this.expandPath(account.sshKey);
const pubKeyPath = `${sshKeyPath}.pub`;
if (!fs.existsSync(sshKeyPath)) {
missingKeys.push(account.name);
console.log(`ā ${account.name}: Key not found at ${sshKeyPath}`);
}
else if (!fs.existsSync(pubKeyPath)) {
console.log(`ā ļø ${account.name}: Private key exists but public key missing`);
}
else {
console.log(`ā
${account.name}: Keys found`);
}
});
if (missingKeys.length > 0) {
console.log("\nš Generate missing keys with:\n");
missingKeys.forEach((accountName) => {
const account = this.getAccount(accountName);
if (account) {
const keyPath = this.expandPath(account.sshKey);
console.log(`ssh-keygen -t ed25519 -C "${account.gitEmail}" -f ${keyPath}`);
}
});
console.log("\nThen add the public keys to GitHub:");
missingKeys.forEach((accountName) => {
const account = this.getAccount(accountName);
if (account) {
const keyPath = this.expandPath(account.sshKey);
console.log(`cat ${keyPath}.pub`);
}
});
}
console.log("");
}
async addAccount() {
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
const question = (prompt) => {
return new Promise((resolve) => {
rl.question(prompt, (answer) => {
resolve(answer.trim());
});
});
};
console.log("\nš Add New GitHub Account\n");
try {
// Collect account info
const accountName = await question('Account name (e.g., "claude", "client1"): ');
if (!accountName || this.getAccount(accountName)) {
console.log("ā Account name is required and must be unique.");
rl.close();
return;
}
const gitName = await question('Git username (e.g., "John Doe"): ');
const gitEmail = await question('Git email (e.g., "you@claude.ai"): ');
const githubUsername = await question('GitHub username (e.g., "johndoe"): ');
const projectPath = await question('Project directory path (e.g., "~/projects/claude"): ');
if (!gitName || !gitEmail || !githubUsername) {
console.log("ā All fields are required.");
rl.close();
return;
}
rl.close();
console.log("\nš§ Setting up account...\n");
// 1. Generate SSH key
const sshKeyPath = path.join(os.homedir(), ".ssh", `id_rsa_${accountName}`);
console.log(`š Generating SSH key at ${sshKeyPath}...`);
try {
execSync(`ssh-keygen -t ed25519 -C "${gitEmail}" -f ${sshKeyPath} -N ""`, { stdio: "pipe" });
console.log("ā
SSH key generated");
}
catch (error) {
console.error("ā Failed to generate SSH key");
return;
}
// 2. Add to config
const newAccount = {
name: accountName,
gitName: gitName,
gitEmail: gitEmail,
sshKey: `~/.ssh/id_rsa_${accountName}`,
};
this.config.accounts.push(newAccount);
// Set as default if it's the first account
if (this.config.accounts.length === 1 || !this.config.defaultAccount) {
this.config.defaultAccount = accountName;
console.log(`ā
Set as default account`);
}
// 3. Add directory mapping if provided
if (projectPath) {
this.config.directoryMappings[projectPath] = accountName;
}
// 4. Save config
fs.writeFileSync(CONFIG_PATH, JSON.stringify(this.config, null, 2));
console.log("ā
Added to config");
// 5. Update SSH config
this.generateSSHConfig();
// 6. Display public key
console.log("\nš Copy this SSH public key to GitHub:\n");
console.log("ā".repeat(60));
const pubKey = fs.readFileSync(`${sshKeyPath}.pub`, "utf-8");
console.log(pubKey);
console.log("ā".repeat(60));
console.log("\nš Next steps:");
console.log(`1. Go to: https://github.com/settings/ssh/new`);
console.log(`2. Title: "${accountName} - ${os.hostname()}"`);
console.log(`3. Paste the key above`);
console.log(`4. Click "Add SSH key"`);
console.log("\n⨠Test the connection:");
console.log(` ssh -T git@github.com`);
if (projectPath) {
console.log(`\nš Auto-switch enabled for: ${projectPath}`);
}
console.log("\nšÆ Start using this account:");
console.log(` ghswap ${accountName}`);
console.log(` git clone git@github.com:${githubUsername}/repo.git`);
console.log(` # Work normally - everything is configured!\n`);
// Copy to clipboard if possible (macOS)
try {
execSync("command -v pbcopy", { stdio: "pipe" });
execSync(`echo "${pubKey}" | pbcopy`);
console.log("š Public key copied to clipboard!\n");
}
catch {
// pbcopy not available, skip
}
}
catch (error) {
console.error("ā Error adding account:", error);
rl.close();
}
}
}
// CLI Entry Point
const args = process.argv.slice(2);
const switcher = new GitHubSwitcher();
if (args.length === 0) {
switcher.interactiveSwitch();
}
else {
const command = args[0];
switch (command) {
case "auto":
switcher.autoSwitch();
break;
case "list":
case "ls":
switcher.listAccounts();
break;
case "setup":
switcher.setupShellHook();
break;
case "ssh-config":
switcher.generateSSHConfig();
break;
case "ssh-init":
switcher.initSSHKeys();
break;
case "add":
switcher.addAccount();
break;
case "help":
case "--help":
case "-h":
console.log(`
GitHub Account Switcher
Usage:
ghswap Interactive account selection
ghswap <account> Switch to specific account
ghswap add Add new account (fully automated)
ghswap auto Auto-detect based on current directory
ghswap list List all configured accounts
ghswap setup Show shell hook setup instructions
ghswap ssh-config Generate SSH config automatically
ghswap ssh-init Check SSH keys and show generation commands
ghswap help Show this help message
ghswap --version Show version number
Examples:
ghswap add Add a new GitHub account (interactive)
ghswap client1 Switch to client1 account
ghswap Show menu to select account
ghswap ssh-config Auto-generate ~/.ssh/config entries
`);
break;
case "--version":
case "-v":
case "version":
console.log("ghswap v1.0.1");
break;
default:
switcher.switchAccount(command);
}
}
//# sourceMappingURL=index.js.map