UNPKG

@cuiguojie/gap

Version:

Automatic Git profile switching based on remote URL patterns

21 lines (20 loc) 11.6 kB
#!/usr/bin/env node import{program as $}from"commander";import{intro as te,outro as K,text as oe,confirm as ie}from"@clack/prompts";import C from"chalk";import{promises as U}from"fs";import re from"path";import{promises as ee}from"fs";import G from"path";import B from"os";function k(n){return n.startsWith("~")?G.join(B.homedir(),n.slice(1)):G.resolve(n)}async function F(n){try{await ee.mkdir(n,{recursive:!0})}catch(e){if(e.code!=="EEXIST")throw e}}function u(){return G.join(B.homedir(),".git-auto-profile","config.json")}function T(){return G.join(B.homedir(),".gitconfig")}function v(n){return k(n.profilesPath)}import{promises as j}from"fs";var h=class{static MANAGED_START="# --- GIT-AUTO-PROFILE MANAGED BLOCK ---";static MANAGED_END="# --- END GIT-AUTO-PROFILE MANAGED BLOCK ---";static async readGitConfig(){let e=T();try{return await j.readFile(e,"utf8")}catch(r){if(r.code==="ENOENT")return"";throw r}}static async writeGitConfig(e){let r=T();await j.writeFile(r,e)}static getManagedBlockInfo(e){let r=e.indexOf(this.MANAGED_START),o=e.indexOf(this.MANAGED_END);if(r===-1||o===-1)return null;let t=r+this.MANAGED_START.length,i=o;return{startIndex:r,endIndex:o,contentStart:t,contentEnd:i,content:e.substring(t,i).trim(),beforeBlock:e.substring(0,r+this.MANAGED_START.length),afterBlock:e.substring(o+this.MANAGED_END.length)}}static async ensureManagedBlock(){let e=await this.readGitConfig();if(!this.getManagedBlockInfo(e)){let o=`${this.MANAGED_START} ${this.MANAGED_END}`;e.trim()&&!e.endsWith(` `)&&(e+=` `),e+=(e.trim()?` `:"")+o+` `,await this.writeGitConfig(e)}}static async addIncludeIfRule(e,r){let o=await this.readGitConfig(),t=this.getManagedBlockInfo(o);if(!t)throw new Error('Managed block not found. Please run "gap init" first.');if(t.content.includes(`hasconfig:remote.*.url:${e}"`))throw new Error("A profile with this URL pattern already exists");let i=`[includeIf "hasconfig:remote.*.url:${e}"] path = ${r}`,s=t.content?`${t.content} ${i}`:i,f=`${t.beforeBlock} ${s} ${this.MANAGED_END}${t.afterBlock}`;await this.writeGitConfig(f)}static async removeIncludeIfRule(e){let r=await this.readGitConfig(),o=this.getManagedBlockInfo(r);if(!o)throw new Error("Managed block not found in .gitconfig");let t=o.content.split(` `),i=[],s=0;for(;s<t.length;){let c=t[s].trim();if(c.startsWith("[includeIf")){let p=t[s+1];if(p&&p.includes(`path = ${e}`)){s+=2;continue}}else if(c.includes(`path = ${e}`)){s+=1;continue}c&&i.push(t[s]),s+=1}let f=i.join(` `),l=`${o.beforeBlock} ${f} ${this.MANAGED_END}${o.afterBlock}`;await this.writeGitConfig(l)}static async listIncludeIfRules(){let e=await this.readGitConfig(),r=this.getManagedBlockInfo(e);if(!r)return[];let o=[],t=r.content.split(` `);for(let i=0;i<t.length;i++){let s=t[i].trim();if(s.startsWith("[includeIf")){let f=s.match(/hasconfig:remote\.\*\.url:([^"]+)/),l=t[i+1];if(f&&l){let c=l.trim().match(/path = (\S+)/);c&&(o.push({urlPattern:f[1],profilePath:c[1]}),i++)}}}return o}};async function _(){te(C.cyan("Initialize git-auto-profile"));let n=u();try{try{if(await U.access(n),!await ie({message:"git-auto-profile is already initialized. Re-initialize?"})){K(C.yellow("Initialization cancelled"));return}}catch{}let e=await oe({message:"Where should profile files be stored?",placeholder:"~/.git-auto-profile/profiles",initialValue:"~/.git-auto-profile/profiles"});if(typeof e!="string"){K(C.yellow("Initialization cancelled"));return}let r=re.dirname(n);await F(r);let o=k(e);await F(o);let t={profilesPath:e};await U.writeFile(n,JSON.stringify(t,null,2));try{await h.ensureManagedBlock()}catch(i){console.error(C.red("Warning: Failed to update .gitconfig"),i.message)}K(C.green("\u2705 git-auto-profile initialized successfully!")),console.log(`Profile directory: ${C.cyan(e)}`)}catch(e){console.error(C.red("Error during initialization:"),e.message),process.exit(1)}}import{intro as ne,outro as A,text as b,select as se}from"@clack/prompts";import y from"chalk";import{promises as x}from"fs";import D from"path";async function z(){ne(y.cyan("Create new profile"));try{let n=u(),e=JSON.parse(await x.readFile(n,"utf8")),r=v(e),o=await b({message:"Profile name:",placeholder:"work-github",validate:a=>{if(!a)return"Profile name is required";if(!/^[a-zA-Z0-9_-]+$/.test(a))return"Use only letters, numbers, hyphens, and underscores"}});if(typeof o!="string"){A(y.yellow("Cancelled"));return}let t=await b({message:"Git user.name:",placeholder:"Your Name",validate:a=>a?void 0:"User name is required"}),i=await b({message:"Git user.email:",placeholder:"your.email@example.com",validate:a=>{if(!a)return"Email is required";if(!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(a))return"Invalid email format"}}),s=D.join(process.env.HOME||"",".ssh"),f=await x.readdir(s).catch(()=>[]),l=[];for(let a of f){if(a.endsWith(".pub"))continue;let N=D.join(s,a);try{if(!(await x.stat(N)).isFile())continue;let d=await x.readFile(N,"utf8");(d.includes("-----BEGIN ")&&d.includes(" PRIVATE KEY-----")||d.startsWith("-----BEGIN OPENSSH PRIVATE KEY-----")||d.startsWith("-----BEGIN RSA PRIVATE KEY-----")||d.startsWith("-----BEGIN DSA PRIVATE KEY-----")||d.startsWith("-----BEGIN EC PRIVATE KEY-----")||d.startsWith("-----BEGIN ED25519 PRIVATE KEY-----")||d.startsWith("SSH PRIVATE KEY FILE FORMAT 1.1"))&&l.push(a)}catch{continue}}if(l.length===0){A(y.red("No SSH keys found in ~/.ssh directory"));return}let c=await se({message:"Select SSH key:",options:l.map(a=>({value:D.join("~/.ssh",a),label:a}))});if(typeof c!="string"){A(y.yellow("Cancelled"));return}let p=await b({message:"URL pattern (e.g., git@github.com:your-org/**):",placeholder:"git@github.com:your-org/**",validate:a=>a?void 0:"URL pattern is required"});if(typeof p!="string"){A(y.yellow("Cancelled"));return}let E={name:t,email:i,sshKey:c},w=["[user]",` name = ${E.name}`,` email = ${E.email}`,"[core]",` sshCommand = ssh -i ${E.sshKey}`].join(` `),P=D.join(r,`${o}.conf`);await x.writeFile(P,w);try{await h.addIncludeIfRule(p,`${e.profilesPath}/${o}.conf`)}catch(a){A(y.red(a.message));return}A(y.green("\u2705 Profile created successfully!")),console.log(`Profile: ${y.cyan(o)}`),console.log(`File: ${y.cyan(P)}`)}catch(n){console.error(y.red("Error creating profile:"),n.message),process.exit(1)}}import{intro as ae,outro as le}from"@clack/prompts";import g from"chalk";import{execSync as M}from"child_process";import{promises as V}from"fs";import Y from"path";async function H(){ae(g.cyan("Current Git configuration"));try{if(M('git rev-parse --is-inside-work-tree 2>/dev/null || echo "false"',{encoding:"utf8"}).trim()==="false")console.log(g.yellow("Not in a Git repository. Showing global config:"));else{let o=M("git rev-parse --show-toplevel",{encoding:"utf8"}).trim();console.log(g.gray(`Repository: ${o}`));try{let t=u(),i=await V.readFile(t,"utf8"),s=JSON.parse(i),f=k(s.profilesPath);try{let l=M("git config --get remote.origin.url",{encoding:"utf8"}).trim(),c=Y.join(process.env.HOME||"",".gitconfig"),p=await V.readFile(c,"utf8"),E=/\[includeIf "hasconfig:remote\.\*\.url:([^"]+)"\][\s\S]*?path = ([^\n]+)/g,w=Array.from(p.matchAll(E)),P=null;for(let a of w){let[,N,R]=a,d=N.replace(/\*\*/g,".*").replace(/\*/g,"[^/]*");if(new RegExp(d).test(l)){P=Y.basename(R,".conf");break}}console.log(P?g.green(`\u2705 Active profile: ${P}`):g.yellow(`\u26A0\uFE0F No matching profile found for remote: ${l}`))}catch{console.log(g.yellow("\u26A0\uFE0F Could not determine active profile"))}}catch{console.log(g.red("\u274C git-auto-profile not initialized"))}console.log()}let r=["git config --show-origin --get user.name","git config --show-origin --get user.email","git config --show-origin --get core.sshCommand"].map(o=>{try{let t=M(o,{encoding:"utf8"}).trim(),[i,s]=t.split(" ");return{key:o.split(".").pop(),source:i.replace("file:",""),value:s}}catch{return{key:o.split(".").pop(),source:"not set",value:"not set"}}});console.log(g.bold("Current Git settings:")),r.forEach(({key:o,source:t,value:i})=>{console.log(`${g.bold(o)}: ${g.green(i)}`),console.log(` ${g.gray("from:")} ${g.dim(t)}`),console.log()}),le("")}catch(n){console.error(g.red("Error checking configuration:"),n.message),process.exit(1)}}import{intro as ce,outro as J}from"@clack/prompts";import m from"chalk";import{promises as O}from"fs";import W from"path";async function q(){ce(m.cyan("Available profiles"));try{let n=u(),e=JSON.parse(await O.readFile(n,"utf8")),r=v(e),o=await O.readdir(r).catch(()=>[]),t=[],i={},s=await h.listIncludeIfRules();for(let f of s){let l=W.basename(f.profilePath,".conf");i[l]||(i[l]=[]),i[l].push(f.urlPattern)}for(let f of o){if(!f.endsWith(".conf"))continue;let l=W.basename(f,".conf"),c=W.join(r,f);try{let E=(await O.readFile(c,"utf8")).split(` `),w={};for(let P of E){let a=P.trim();if(a.startsWith("name = "))w.name=a.replace("name = ","");else if(a.startsWith("email = "))w.email=a.replace("email = ","");else if(a.startsWith("sshCommand = ")){let R=a.replace("sshCommand = ","").match(/-i (\S+)/);R&&(w.sshKey=R[1])}}t.push({name:l,file:c,config:w,urlPatterns:i[l]||[]})}catch(p){console.log(m.yellow(`\u26A0\uFE0F Could not read profile ${l}: ${p.message}`))}}if(t.length===0){console.log(m.yellow("No profiles found.")),console.log(m.gray('Run "gap create" to create your first profile.')),J("");return}console.log(m.bold(` Found ${t.length} profile${t.length>1?"s":""}: `)),t.forEach((f,l)=>{console.log(`${m.bold(`${l+1}. ${f.name}`)}`),console.log(` ${m.gray("Name:")} ${f.config.name||m.red("not set")}`),console.log(` ${m.gray("Email:")} ${f.config.email||m.red("not set")}`),console.log(` ${m.gray("SSH Key:")} ${f.config.sshKey||m.red("not set")}`),f.urlPatterns.length>0?(console.log(` ${m.gray("URL Patterns:")}`),f.urlPatterns.forEach(c=>{console.log(` ${m.cyan("\u2022")} ${c}`)})):console.log(` ${m.gray("URL Patterns:")} ${m.yellow("not configured")}`),l<t.length-1&&console.log()}),J("")}catch(n){console.error(m.red("Error listing profiles:"),n.message),process.exit(1)}}import{intro as fe,outro as S,select as me,confirm as ge}from"@clack/prompts";import I from"chalk";import{promises as L}from"fs";import X from"path";async function Z(){fe(I.cyan("Remove profile"));try{let n=u(),e=JSON.parse(await L.readFile(n,"utf8")),r=v(e),t=(await L.readdir(r).catch(()=>[])).filter(c=>c.endsWith(".conf"));if(t.length===0){console.log(I.yellow("No profiles found to remove.")),S("");return}let i=t.map(c=>X.basename(c,".conf")),s=await me({message:"Select profile to remove:",options:i.map(c=>({value:c,label:c}))});if(typeof s!="string"){S(I.yellow("Cancelled"));return}if(!await ge({message:`Are you sure you want to remove profile "${s}"?`,initialValue:!1})){S(I.yellow("Cancelled"));return}let l=X.join(r,`${s}.conf`);await L.unlink(l);try{await h.removeIncludeIfRule(`${e.profilesPath}/${s}.conf`)}catch(c){console.warn(I.yellow("Warning: Failed to update .gitconfig"),c.message)}S(I.green(`\u2705 Profile "${s}" removed successfully!`))}catch(n){console.error(I.red("Error removing profile:"),n.message),process.exit(1)}}$.name("gap").description("Automatic Git profile switching based on remote URL patterns").version("1.0.0");$.command("init").description("Initialize git-auto-profile environment").action(_);$.command("create").alias("add").description("Create a new profile and associate URL pattern").action(z);$.command("whoami").description("Show current Git configuration and its source").action(H);$.command("list").alias("ls").description("List all available profiles").action(q);$.command("remove").alias("rm").description("Remove a profile").action(Z);$.parse();