UNPKG

auto-commit-ai

Version:

AI-powered tool to automatically generate your git commit messages

37 lines (31 loc) 20 kB
#!/usr/bin/env node var Se=Object.defineProperty;var r=(e,t)=>Se(e,"name",{value:t,configurable:!0});import{command as _,cli as Ne}from"cleye";import{execa as E}from"execa";import{dim as D,bgCyan as z,black as G,green as N,red as O,blue as V,yellow as Ie}from"kolorist";import{intro as B,spinner as A,confirm as xe,isCancel as J,outro as C,select as Ee,text as Oe}from"@clack/prompts";import h from"fs/promises";import L from"path";import Pe from"os";import ne from"ini";import{request as Me}from"gaxios";import ae from"update-notifier";import{Box as k,Text as b,render as Le}from"ink";import Ue,{useState as I,useEffect as He}from"react";import{jsx as f,jsxs as S}from"react/jsx-runtime";import se from"ink-select-input";import Fe from"ink-text-input";import ie from"ink-spinner";import{fileURLToPath as Re,pathToFileURL as je}from"url";import{createServer as _e}from"http";import*as De from"net";import ze from"open";var U="auto-commit-ai",x="1.4.5",Ge="AI-powered tool to automatically generate your git commit messages";class d extends Error{static{r(this,"KnownError")}}const K=" ",P=r(e=>{e instanceof Error&&!(e instanceof d)&&(e.stack&&console.error(D(e.stack.split(` `).slice(1).join(` `))),console.error(` ${K}${D(`autoCommit v${x}`)}`),console.error(` ${K}Please contact support with the information above:`),console.error(`${K}support@autocommit.top`))},"handleCliError"),re=r(async()=>{const{stdout:e,failed:t}=await E("git",["rev-parse","--show-toplevel"],{reject:!1});if(t)throw new d("The current directory must be a Git repository!");return e},"assertGitRepo"),W=r(e=>`:(exclude)${e}`,"excludeFromDiff"),le=["package-lock.json","pnpm-lock.yaml","*.lock"].map(W),ce=r(async e=>{const t=["diff","--cached","--diff-algorithm=minimal"],{stdout:o}=await E("git",[...t,"--name-only",...le,...e?e.map(W):[]]);if(!o)return;const{stdout:n}=await E("git",[...t,...le,...e?e.map(W):[]]);return{files:o.split(` `),diff:n}},"getStagedDiff"),Ve=r(e=>`Detected ${e.length.toLocaleString()} staged file${e.length>1?"s":""}`,"getDetectedMessage"),q=r(e=>h.lstat(e).then(()=>!0,()=>!1),"fileExists"),$={authtoken:"",locale:"en",generate:1,type:"conventional",model:"deepseek-v3",timeout:3e4,"max-length":50},Y={authtoken:"Authorization Token",locale:"Locale",generate:"Generate Count",type:"Commit Type",model:"AI Model",timeout:"Timeout (ms)","max-length":"Max Length"},Be={authtoken:"API authorization token for AI service",locale:"Language locale for commit messages",generate:"Number of commit messages to generate (1-5)",type:"Type of commit message format",model:"AI model to use for generating commits",timeout:"Request timeout in milliseconds","max-length":"Maximum length of commit message"},ue=[{value:"none",label:"None"},{value:"conventional",label:"Conventional Commits"},{value:"gitmoji",label:"Gitmoji\u{1F604}"}],Je=r((e,t)=>{if(e==="authtoken")return t?"\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022":"Not set";if(e==="type"){const o=ue.find(n=>n.value===t);return o?o.label:t}return String(t)},"getConfigDisplayValue"),Ke=["none","conventional","gitmoji"],Q=["deepseek-v3","deepseek-r1","gemini-2.0","gpt-4-turbo"],We=r(()=>[...Q],"getSupportedModels"),{hasOwnProperty:qe}=Object.prototype,Ye=r((e,t)=>qe.call(e,t),"hasOwn"),w=r((e,t,o)=>{if(!t)throw new d(`Invalid config property ${e}: ${o}`)},"parseAssert"),H={authtoken(e){return e?(w("authtoken",e.length>0,"Cannot be empty"),w("authtoken",e.trim().length>0,"Cannot be only whitespace"),w("authtoken",e.length>=5,'Token appears to be too short. Please run "autocommit auth" to get a valid token.'),e.startsWith("ac-")&&e.length<8&&w("authtoken",!1,'Token format appears incomplete. Please run "autocommit auth" to get a new token.'),e):$.authtoken},locale(e){return e?(w("locale",e,"Cannot be empty"),w("locale",/^[a-z-]+$/i.test(e),"Must be a valid locale (letters and dashes/underscores). You can consult the list of codes in: https://wikipedia.org/wiki/List_of_ISO_639-1_codes"),e):$.locale},generate(e){if(!e)return $.generate;w("generate",/^\d+$/.test(e),"Must be an integer");const t=Number(e);return w("generate",t>0,"Must be greater than 0"),w("generate",t<=5,"Must be less or equal to 5"),t},type(e){return e?(w("type",Ke.includes(e),"Invalid commit type"),e):$.type},model(e){return!e||e.length===0?$.model:Q.includes(e)?e:(console.warn(`Warning: Unsupported model "${e}". Using default model "${$.model}". Supported models are: ${Q.join(", ")}`),$.model)},timeout(e){if(!e)return $.timeout;w("timeout",/^\d+$/.test(e),"Must be an integer");const t=Number(e);return w("timeout",t>=500,"Must be greater than 500ms"),t},"max-length"(e){if(!e)return $["max-length"];w("max-length",/^\d+$/.test(e),"Must be an integer");const t=Number(e);return w("max-length",t>=20,"Must be greater than 20 characters"),t}},me=r(async()=>{const e=L.join(process.cwd(),".autoCommit"),t=L.join(Pe.homedir(),".autoCommit");return await q(e)?e:t},"getConfigPath"),ge=r(async()=>{const e=await me();if(!await q(e))return Object.create(null);const o=await h.readFile(e,"utf8");return ne.parse(o)},"readConfigFile"),F=r(async(e,t)=>{const o=await ge(),n={};for(const a of Object.keys(H)){const i=H[a],l=e?.[a]??o[a];if(t)try{n[a]=i(l)}catch{}else n[a]=i(l)}return n},"getConfig"),X=r(async e=>{const t=await ge(),o=await me();for(const[n,a]of e){if(!Ye(H,n))throw new d(`Invalid config property: ${n}`);const i=H[n](a);t[n]=i}await h.writeFile(o,ne.stringify(t),"utf8")},"setConfigs"),Qe=r(()=>{const e=ae({pkg:{name:U,version:x},updateCheckInterval:72e5,shouldNotifyInNpmScript:!1});e.update&&e.notify({defer:!1,isGlobal:!0,message:`\u{1F680} Update available: ${x} \u2192 ${e.update.latest} Run npm update -g ${U} to update `})},"checkVersionAndUpgrade"),R=r(()=>{const e=ae({pkg:{name:U,version:x},updateCheckInterval:0,shouldNotifyInNpmScript:!1});console.log(` \u{1F4A1} Tip: If you encounter issues, try updating to the latest version:`),console.log(` npm update -g ${U}`),e.update&&console.log(` Current: v${x} \u2192 Latest: v${e.update.latest}`)},"suggestUpdateOnError"),Xe=r(async e=>{const t=Date.now();if(!e.apiKey||e.apiKey.trim()==="")throw new d('Authorization token is not set. Please run "autocommit auth" to authenticate first.');if(e.apiKey.length<5)throw new d('Invalid authorization token format. Please run "autocommit auth" to get a new token.');try{const o={model:e.model,locale:e.locale,diff:e.diff,completions:e.completions,maxLength:e.maxLength,type:e.type,timeout:e.timeout,startTime:e.startTime},n=await Me({method:"POST",url:"https://www.autocommit.top/api/chat",headers:{Authorization:`Bearer ${e.apiKey}`,"Content-Type":"application/json"},data:o,timeout:e.timeout,responseType:"json",validateStatus:r(s=>s>=200&&s<300,"validateStatus"),retryConfig:{retry:2,retryDelay:500,httpMethodsToRetry:["POST"],statusCodesToRetry:[[502,504],[429,429],[500,599]],onRetryAttempt:r(s=>{console.log(`API request failed, retrying... (${s.config?.retryConfig?.currentRetryAttempt||0}/${s.config?.retryConfig?.retry||0})`)},"onRetryAttempt"),shouldRetry:r(s=>{const c=s.message?.includes("timeout")||!1,u=s.code==="ENOTFOUND"||s.code==="ECONNRESET"||!1,p=s.status?[429,502,503,504].includes(s.status):!1;return c||u||p},"shouldRetry")}}),i=Date.now()-t;let l=`${e.model} | ${i}ms`;try{const s=n.data;s.usage&&(l+=` | ${s.usage.total_tokens} tokens`),console.log(`[AI] ${l}`)}catch{console.log(`[AI] ${l}`)}return n.data}catch(o){if(o.code==="ENOTFOUND")throw R(),new d(`Error connecting to ${o.hostname||"www.autocommit.top"} (${o.syscall}). Are you connected to the internet?`);if(o.response){let n=`API Error: ${o.response.status} - ${o.response.statusText}`;if(o.response.data)try{const a=o.response.data;a.details&&a.details.message?n=a.details.message:a.error?n=a.error:n+=` ${JSON.stringify(a)}`}catch{n+=` ${o.response.data}`}throw o.response.status===500&&(n+=` Check the API status.`),R(),new d(n)}throw o.message?.includes("timeout")?(R(),new d(`Time out error: request took over ${e.timeout}ms. Try increasing the \`timeout\` config.`)):(R(),new d(o.message||"An unknown error occurred"))}},"createChatCompletion"),Ze=r(e=>e.trim().replace(/[\n\r]/g,"").replace(/(\w)\.$/,"$1"),"sanitizeMessage"),et=r(e=>Array.from(new Set(e)),"deduplicateMessages"),de=r(async(e,t,o,n,a,i,l,s)=>{const c=Date.now(),y=await Xe({apiKey:e,model:t,locale:o,diff:n,completions:a,maxLength:i,type:l==="none"?"":l,timeout:s,startTime:c});return et(y.choices.filter(v=>v.message?.content).map(v=>Ze(v.message.content)))},"generateCommitMessage");var tt=r(async(e,t,o,n)=>(async()=>{B(z(G(" auto-commit-ai "))),await re();const a=A();o&&await E("git",["add","--update"]),a.start("Detecting staged files");const i=await ce(t);if(!i)throw a.stop("Detecting staged files"),new d("No staged changes found. Stage your changes manually, or automatically stage all changes with the `--all` flag.");a.stop(`${Ve(i.files)}: ${i.files.map(y=>` ${y}`).join(` `)}`);const{env:l}=process,s=await F({authtoken:l.authtoken&&l.authtoken.trim()?l.authtoken:void 0,generate:e?.toString()}),c=A();c.start("Analyzing changes");let u;try{u=await de(s.authtoken,s.model,s.locale,i.diff,s.generate,s["max-length"],s.type,s.timeout)}finally{c.stop("Analysis complete")}if(u.length===0)throw new d("No commit messages were generated. Try again.");let p;if(u.length===1){[p]=u;const y=await xe({message:`Use this commit message? ${p} `});if(!y||J(y)){C("Commit cancelled");return}}else{const y=await Ee({message:`Pick a commit message to use: ${D("(Ctrl+c to exit)")}`,options:u.map(v=>({label:v,value:v}))});if(J(y)){C("Commit cancelled");return}p=y}await E("git",["commit","-m",p,...n]),C(`${N("\u2714")} Successfully committed!`)})().catch(a=>{C(`${O("\u2716")} ${a.message}`),P(a),process.exit(1)}),"autoCommit");const[M,ot]=process.argv.slice(2);var nt=r(()=>(async()=>{if(!M)throw new d('Commit message file path is missing. This file should be called from the "prepare-commit-msg" git hook');if(ot)return;const e=await ce();if(!e)return;B(z(G(" autoCommit ")));const{env:t}=process,o=await F(),n=A();n.start("Analyzing changes");let a;try{a=await de(o.authtoken,o.model,o.locale,e.diff,o.generate,o["max-length"],o.type,o.timeout)}finally{n.stop("Analysis complete")}let i;try{i=await h.readFile(M,"utf8")}catch(u){if(process.env.NODE_ENV==="test"&&u.code==="ENOENT")console.warn(`\u26A0\uFE0F COMMIT_EDITMSG file not found at ${M}, creating empty file for test`),await h.writeFile(M,""),i="";else throw u}const l=i!=="",s=a.length>1;let c="";l&&(c=`# \u{1F916} AI generated commit${s?"s":""} `),s?(l&&(c+=`# Select one of the following messages by uncommeting: `),c+=` ${a.map(u=>`# ${u}`).join(` `)}`):(l&&(c+=`# Edit the message below and commit: `),c+=` ${a[0]} `),await h.appendFile(M,c),C(`${N("\u2714")} Saved commit message!`)})().catch(e=>{if(C(`${O("\u2716")} ${e.message}`),P(e),process.env.NODE_ENV==="test")throw e;process.exit(1)}),"prepareCommitMessageHook");const at=r(({onExit:e})=>{const[t,o]=I(null),[n,a]=I(!0),[i,l]=I("edit"),[s,c]=I(null),[u,p]=I(""),[y,v]=I(!1);He(()=>{r(async()=>{try{const g=await F({},!0);o(g)}catch(g){console.error("Failed to load config:",g)}finally{a(!1)}},"loadConfig")()},[]);const ve=Object.keys(Y).map(m=>({label:`${Y[m]}: ${t?Je(m,t[m]):"Loading..."}`,value:m})),be=r(m=>{if(m.value==="exit"){e();return}const g=m.value;c(g),p(String(t?.[g]||"")),l("item")},"handleConfigSelect"),Z=r(m=>{switch(m){case"model":return We().map(g=>({label:g,value:g}));case"type":return ue.map(g=>({label:g.label,value:g.value}));default:return null}},"getSelectOptions"),$e=r((m,g)=>{switch(m){case"generate":const T=Number(g);if(isNaN(T)||T<1||T>5)return"Must be a number between 1 and 5";break;case"timeout":const te=Number(g);if(isNaN(te)||te<500)return"Must be a number >= 500";break;case"max-length":const oe=Number(g);if(isNaN(oe)||oe<20)return"Must be a number >= 20";break}return null},"validateValue"),ee=r(async(m,g)=>{v(!0);try{await X([[m,g]]);const T=await F({},!0);o(T),l("edit")}catch(T){console.error("Failed to save config:",T)}finally{v(!1)}},"saveConfig"),Te=r(m=>{s&&ee(s,m.value)},"handleSelectSubmit"),Ae=r(m=>{if(s){const g=$e(s,m);if(g){console.error(g);return}ee(s,m)}},"handleTextSubmit");return n?f(k,{flexDirection:"column",alignItems:"center",justifyContent:"center",children:S(k,{marginBottom:1,children:[f(ie,{type:"dots"}),f(b,{children:" Loading configuration..."})]})}):S(k,{flexDirection:"column",padding:1,children:[f(k,{marginBottom:2,borderStyle:"round",borderColor:"cyan",padding:1,children:f(b,{bold:!0,color:"cyan",children:"\u2699\uFE0F AutoCommit Configuration Manager"})}),i==="edit"&&S(k,{flexDirection:"column",children:[f(b,{bold:!0,color:"yellow",children:"\u{1F4DD} Configure your settings"}),f(b,{dimColor:!0,children:"Select a configuration item to modify:"}),f(k,{marginTop:2,children:f(se,{items:[...ve,{label:"\u{1F6AA} Exit",value:"exit"}],onSelect:be})})]}),i==="item"&&s&&S(k,{flexDirection:"column",children:[S(b,{bold:!0,color:"magenta",children:["Editing: ",Y[s]]}),f(b,{dimColor:!0,children:Be[s]}),y?S(k,{marginTop:2,children:[f(ie,{type:"dots"}),f(b,{children:" Saving configuration..."})]}):f(k,{marginTop:2,children:Z(s)?f(se,{items:[...Z(s),{label:"\u2190 Cancel",value:"cancel"}],onSelect:r(m=>{m.value==="cancel"?l("edit"):Te(m)},"onSelect")}):S(k,{flexDirection:"column",children:[f(b,{children:"Enter new value:"}),f(Fe,{value:u,onChange:p,onSubmit:Ae}),f(b,{dimColor:!0,children:"Press Enter to save, Ctrl+C to cancel"})]})})]})]})},"ConfigManager");var st=_({name:"config"},e=>{(async()=>{if(e._.length>0)throw new d("Config command now only supports interactive mode. Use 'autocommit config' without arguments.");const{waitUntilExit:t}=Le(Ue.createElement(at,{onExit:r(()=>{process.exit(0)},"onExit")}));await t()})().catch(t=>{console.error(`${O("\u2716")} ${t.message}`),P(t),process.exit(1)})});const fe="prepare-commit-msg",pe=`.git/hooks/${fe}`,j=Re(new URL("cli.mjs",import.meta.url)),he=process.argv[1].replace(/\\/g,"/").endsWith(`/${pe}`),we=process.platform==="win32",ye=` #!/usr/bin/env node import(${JSON.stringify(je(j))}) `.trim();var it=_({name:"hook",parameters:["<install/uninstall>"]},e=>{(async()=>{const t=await re(),{installUninstall:o}=e._,n=L.join(t,pe),a=await q(n);if(o==="install"){if(a){if(await h.realpath(n).catch(()=>{})===j){console.warn("The hook is already installed");return}throw new d(`A different ${fe} hook seems to be installed. Please remove it before installing autoCommit.`)}await h.mkdir(L.dirname(n),{recursive:!0}),we?await h.writeFile(n,ye):(await h.symlink(j,n,"file"),await h.chmod(n,493)),console.log(`${N("\u2714")} Hook installed`);return}if(o==="uninstall"){if(!a){console.warn("Hook is not installed");return}if(we){if(await h.readFile(n,"utf8")!==ye){console.warn("Hook is not installed");return}}else if(await h.realpath(n)!==j){console.warn("Hook is not installed");return}await h.rm(n),console.log(`${N("\u2714")} Hook uninstalled`);return}throw new d(`Invalid mode: ${o}`)})().catch(t=>{if(console.error(`${O("\u2716")} ${t.message}`),P(t),process.env.NODE_ENV==="test")throw t;process.exit(1)})});const Ce="/callback";function rt(){return new Promise((e,t)=>{let o=0;try{const n=De.createServer();n.listen(0,()=>{o=n.address().port}),n.on("listening",()=>{n.close(),n.unref()}),n.on("error",a=>t(a)),n.on("close",()=>e(o))}catch(n){t(n)}})}r(rt,"getAvailablePort");function lt(e){return new Promise((t,o)=>{const n=_e((a,i)=>{try{if(i.setHeader("Access-Control-Allow-Origin","*"),i.setHeader("Access-Control-Allow-Methods","POST, OPTIONS, GET"),i.setHeader("Access-Control-Allow-Headers","Content-Type, Authorization"),i.setHeader("Access-Control-Allow-Credentials","false"),a.method==="OPTIONS"){i.writeHead(200),i.end();return}if(a.url===Ce&&a.method==="POST"){console.log("[CLI AUTH] Received POST request to callback endpoint");let l="";a.on("data",s=>{l+=s.toString()}),a.on("end",()=>{console.log("[CLI AUTH] Request body:",l);let s=null,c=null;try{const u=JSON.parse(l);s=u.token||u.auth_token||u.access_token||u.authtoken,c=u.error,console.log("[CLI AUTH] Parsed JSON data:",{hasToken:!!s,error:c})}catch{console.log("[CLI AUTH] JSON parse failed, trying form format");const p=new URLSearchParams(l);s=p.get("token")||p.get("auth_token")||p.get("access_token")||p.get("authtoken"),c=p.get("error"),console.log("[CLI AUTH] Parsed form data:",{hasToken:!!s,error:c})}s?(console.log("[CLI AUTH] Token received successfully"),i.writeHead(200,{"Content-Type":"application/json; charset=utf-8","Access-Control-Allow-Origin":"*"}),i.end(JSON.stringify({success:!0,message:"Authorization successful",data:{status:"completed"}})),n.close(),t(s)):c?(console.log("[CLI AUTH] Error received from web:",c),i.writeHead(200,{"Content-Type":"application/json; charset=utf-8","Access-Control-Allow-Origin":"*"}),i.end(JSON.stringify({success:!1,message:"Authorization failed",error:c})),n.close(),o(new d(`Authentication failed: ${c}`))):(console.log("[CLI AUTH] No token or error received"),i.writeHead(400,{"Content-Type":"application/json; charset=utf-8","Access-Control-Allow-Origin":"*"}),i.end(JSON.stringify({success:!1,message:"No token received"})))})}else console.log(`[CLI AUTH] Received ${a.method} request to ${a.url}`),i.writeHead(404,{"Content-Type":"text/plain","Access-Control-Allow-Origin":"*"}),i.end("Not Found")}catch(l){i.writeHead(500,{"Content-Type":"text/plain"}),i.end("Internal Server Error"),n.close(),o(l)}});n.listen(e),n.on("error",a=>{o(a)}),setTimeout(()=>{n.close(),o(new d("Authentication timeout. Please try again."))},5*60*1e3)})}r(lt,"startCallbackServer");var ct=_({name:"auth",parameters:[],flags:{manual:{type:Boolean,description:"Manually enter token instead of using browser authentication",alias:"m",default:!1}}},async e=>{try{if(B(z(G(" auto-commit-ai authentication "))),e.flags.manual){const t=await Oe({message:"Please enter your authorization token:",placeholder:"Enter your token...",validate:r(n=>{if(!n)return"Token is required";if(n.length<10)return"Token seems too short"},"validate")});if(J(t)){C("Authentication cancelled");return}const o=A();o.start("Saving token..."),await X([["authtoken",t]]),o.stop("Token saved successfully"),C(`${N("\u2714")} Authentication completed!`),process.exit(0)}else{const t=A();t.start("Starting authentication server...");try{const o=await rt(),n=`http://localhost:${o}${Ce}`;console.log(`[CLI AUTH] Starting callback server on port ${o}`),console.log(`[CLI AUTH] Callback URL: ${n}`);const a=lt(o),i=`https://www.autocommit.top/auth?callback=${encodeURIComponent(n)}`;console.log(`[CLI AUTH] Auth URL: ${i}`),t.stop(`Authentication server started on port ${o}`),C(`\u{1F513} Opening browser for authentication... If the browser doesn't open automatically, visit: ${V(i)} Callback server is listening on: ${V(n)} Waiting for authentication...`);try{await ze(i)}catch{console.warn(Ie("Warning: Cannot open browser automatically")),console.log(`Please manually visit: ${V(i)}`)}const l=A();l.start("Waiting for authentication...");const s=await a;l.stop("Authentication received!");const c=A();c.start("Saving token..."),await X([["authtoken",s]]),c.stop("Token saved successfully"),C(`${N("\u2714")} Authentication completed!`),process.exit(0)}catch(o){throw t.stop(),o}}}catch(t){C(`${O("\u2716")} ${t.message}`),P(t),process.exit(1)}});const ke=process.argv.slice(2),ut=r(()=>{he||Qe()},"initializeApp");Ne({name:"aca",version:x,flags:{generate:{type:Number,description:"Number of messages to generate (Warning: generating multiple costs more) (default: 1)",alias:"g"},exclude:{type:[String],description:"Files to exclude from AI analysis",alias:"x"},all:{type:Boolean,description:"Automatically stage changes in tracked files for the commit",alias:"a",default:!1}},commands:[st,it,ct],help:{description:Ge}},async e=>{try{ut(),he?await nt():await tt(e.flags.generate,e.flags.exclude,e.flags.all,ke)}catch(t){console.error(t.message||t),process.exit(1)}},ke);