UNPKG

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
#!/usr/bin/env node 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