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