diff --git a/coverage/base.css b/coverage/base.css new file mode 100644 index 0000000..f418035 --- /dev/null +++ b/coverage/base.css @@ -0,0 +1,224 @@ +body, html { + margin:0; padding: 0; + height: 100%; +} +body { + font-family: Helvetica Neue, Helvetica, Arial; + font-size: 14px; + color:#333; +} +.small { font-size: 12px; } +*, *:after, *:before { + -webkit-box-sizing:border-box; + -moz-box-sizing:border-box; + box-sizing:border-box; + } +h1 { font-size: 20px; margin: 0;} +h2 { font-size: 14px; } +pre { + font: 12px/1.4 Consolas, "Liberation Mono", Menlo, Courier, monospace; + margin: 0; + padding: 0; + -moz-tab-size: 2; + -o-tab-size: 2; + tab-size: 2; +} +a { color:#0074D9; text-decoration:none; } +a:hover { text-decoration:underline; } +.strong { font-weight: bold; } +.space-top1 { padding: 10px 0 0 0; } +.pad2y { padding: 20px 0; } +.pad1y { padding: 10px 0; } +.pad2x { padding: 0 20px; } +.pad2 { padding: 20px; } +.pad1 { padding: 10px; } +.space-left2 { padding-left:55px; } +.space-right2 { padding-right:20px; } +.center { text-align:center; } +.clearfix { display:block; } +.clearfix:after { + content:''; + display:block; + height:0; + clear:both; + visibility:hidden; + } +.fl { float: left; } +@media only screen and (max-width:640px) { + .col3 { width:100%; max-width:100%; } + .hide-mobile { display:none!important; } +} + +.quiet { + color: #7f7f7f; + color: rgba(0,0,0,0.5); +} +.quiet a { opacity: 0.7; } + +.fraction { + font-family: Consolas, 'Liberation Mono', Menlo, Courier, monospace; + font-size: 10px; + color: #555; + background: #E8E8E8; + padding: 4px 5px; + border-radius: 3px; + vertical-align: middle; +} + +div.path a:link, div.path a:visited { color: #333; } +table.coverage { + border-collapse: collapse; + margin: 10px 0 0 0; + padding: 0; +} + +table.coverage td { + margin: 0; + padding: 0; + vertical-align: top; +} +table.coverage td.line-count { + text-align: right; + padding: 0 5px 0 20px; +} +table.coverage td.line-coverage { + text-align: right; + padding-right: 10px; + min-width:20px; +} + +table.coverage td span.cline-any { + display: inline-block; + padding: 0 5px; + width: 100%; +} +.missing-if-branch { + display: inline-block; + margin-right: 5px; + border-radius: 3px; + position: relative; + padding: 0 4px; + background: #333; + color: yellow; +} + +.skip-if-branch { + display: none; + margin-right: 10px; + position: relative; + padding: 0 4px; + background: #ccc; + color: white; +} +.missing-if-branch .typ, .skip-if-branch .typ { + color: inherit !important; +} +.coverage-summary { + border-collapse: collapse; + width: 100%; +} +.coverage-summary tr { border-bottom: 1px solid #bbb; } +.keyline-all { border: 1px solid #ddd; } +.coverage-summary td, .coverage-summary th { padding: 10px; } +.coverage-summary tbody { border: 1px solid #bbb; } +.coverage-summary td { border-right: 1px solid #bbb; } +.coverage-summary td:last-child { border-right: none; } +.coverage-summary th { + text-align: left; + font-weight: normal; + white-space: nowrap; +} +.coverage-summary th.file { border-right: none !important; } +.coverage-summary th.pct { } +.coverage-summary th.pic, +.coverage-summary th.abs, +.coverage-summary td.pct, +.coverage-summary td.abs { text-align: right; } +.coverage-summary td.file { white-space: nowrap; } +.coverage-summary td.pic { min-width: 120px !important; } +.coverage-summary tfoot td { } + +.coverage-summary .sorter { + height: 10px; + width: 7px; + display: inline-block; + margin-left: 0.5em; + background: url(sort-arrow-sprite.png) no-repeat scroll 0 0 transparent; +} +.coverage-summary .sorted .sorter { + background-position: 0 -20px; +} +.coverage-summary .sorted-desc .sorter { + background-position: 0 -10px; +} +.status-line { height: 10px; } +/* yellow */ +.cbranch-no { background: yellow !important; color: #111; } +/* dark red */ +.red.solid, .status-line.low, .low .cover-fill { background:#C21F39 } +.low .chart { border:1px solid #C21F39 } +.highlighted, +.highlighted .cstat-no, .highlighted .fstat-no, .highlighted .cbranch-no{ + background: #C21F39 !important; +} +/* medium red */ +.cstat-no, .fstat-no, .cbranch-no, .cbranch-no { background:#F6C6CE } +/* light red */ +.low, .cline-no { background:#FCE1E5 } +/* light green */ +.high, .cline-yes { background:rgb(230,245,208) } +/* medium green */ +.cstat-yes { background:rgb(161,215,106) } +/* dark green */ +.status-line.high, .high .cover-fill { background:rgb(77,146,33) } +.high .chart { border:1px solid rgb(77,146,33) } +/* dark yellow (gold) */ +.status-line.medium, .medium .cover-fill { background: #f9cd0b; } +.medium .chart { border:1px solid #f9cd0b; } +/* light yellow */ +.medium { background: #fff4c2; } + +.cstat-skip { background: #ddd; color: #111; } +.fstat-skip { background: #ddd; color: #111 !important; } +.cbranch-skip { background: #ddd !important; color: #111; } + +span.cline-neutral { background: #eaeaea; } + +.coverage-summary td.empty { + opacity: .5; + padding-top: 4px; + padding-bottom: 4px; + line-height: 1; + color: #888; +} + +.cover-fill, .cover-empty { + display:inline-block; + height: 12px; +} +.chart { + line-height: 0; +} +.cover-empty { + background: white; +} +.cover-full { + border-right: none !important; +} +pre.prettyprint { + border: none !important; + padding: 0 !important; + margin: 0 !important; +} +.com { color: #999 !important; } +.ignore-none { color: #999; font-weight: normal; } + +.wrapper { + min-height: 100%; + height: auto !important; + height: 100%; + margin: 0 auto -48px; +} +.footer, .push { + height: 48px; +} diff --git a/coverage/block-navigation.js b/coverage/block-navigation.js new file mode 100644 index 0000000..530d1ed --- /dev/null +++ b/coverage/block-navigation.js @@ -0,0 +1,87 @@ +/* eslint-disable */ +var jumpToCode = (function init() { + // Classes of code we would like to highlight in the file view + var missingCoverageClasses = ['.cbranch-no', '.cstat-no', '.fstat-no']; + + // Elements to highlight in the file listing view + var fileListingElements = ['td.pct.low']; + + // We don't want to select elements that are direct descendants of another match + var notSelector = ':not(' + missingCoverageClasses.join('):not(') + ') > '; // becomes `:not(a):not(b) > ` + + // Selector that finds elements on the page to which we can jump + var selector = + fileListingElements.join(', ') + + ', ' + + notSelector + + missingCoverageClasses.join(', ' + notSelector); // becomes `:not(a):not(b) > a, :not(a):not(b) > b` + + // The NodeList of matching elements + var missingCoverageElements = document.querySelectorAll(selector); + + var currentIndex; + + function toggleClass(index) { + missingCoverageElements + .item(currentIndex) + .classList.remove('highlighted'); + missingCoverageElements.item(index).classList.add('highlighted'); + } + + function makeCurrent(index) { + toggleClass(index); + currentIndex = index; + missingCoverageElements.item(index).scrollIntoView({ + behavior: 'smooth', + block: 'center', + inline: 'center' + }); + } + + function goToPrevious() { + var nextIndex = 0; + if (typeof currentIndex !== 'number' || currentIndex === 0) { + nextIndex = missingCoverageElements.length - 1; + } else if (missingCoverageElements.length > 1) { + nextIndex = currentIndex - 1; + } + + makeCurrent(nextIndex); + } + + function goToNext() { + var nextIndex = 0; + + if ( + typeof currentIndex === 'number' && + currentIndex < missingCoverageElements.length - 1 + ) { + nextIndex = currentIndex + 1; + } + + makeCurrent(nextIndex); + } + + return function jump(event) { + if ( + document.getElementById('fileSearch') === document.activeElement && + document.activeElement != null + ) { + // if we're currently focused on the search input, we don't want to navigate + return; + } + + switch (event.which) { + case 78: // n + case 74: // j + goToNext(); + break; + case 66: // b + case 75: // k + case 80: // p + goToPrevious(); + break; + } + }; +})(); +window.addEventListener('keydown', jumpToCode); diff --git a/coverage/clover.xml b/coverage/clover.xml new file mode 100644 index 0000000..95df54d --- /dev/null +++ b/coverage/clover.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/coverage/coverage-final.json b/coverage/coverage-final.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/coverage/coverage-final.json @@ -0,0 +1 @@ +{} diff --git a/coverage/favicon.png b/coverage/favicon.png new file mode 100644 index 0000000..c1525b8 Binary files /dev/null and b/coverage/favicon.png differ diff --git a/coverage/index.html b/coverage/index.html new file mode 100644 index 0000000..30510a0 --- /dev/null +++ b/coverage/index.html @@ -0,0 +1,101 @@ + + + + + + Code coverage report for All files + + + + + + + + + +
+
+

All files

+
+ +
+ Unknown% + Statements + 0/0 +
+ + +
+ Unknown% + Branches + 0/0 +
+ + +
+ Unknown% + Functions + 0/0 +
+ + +
+ Unknown% + Lines + 0/0 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+
+ + + + + + + + + + + + + + + + +
FileStatementsBranchesFunctionsLines
+
+
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/prettify.css b/coverage/prettify.css new file mode 100644 index 0000000..b317a7c --- /dev/null +++ b/coverage/prettify.css @@ -0,0 +1 @@ +.pln{color:#000}@media screen{.str{color:#080}.kwd{color:#008}.com{color:#800}.typ{color:#606}.lit{color:#066}.pun,.opn,.clo{color:#660}.tag{color:#008}.atn{color:#606}.atv{color:#080}.dec,.var{color:#606}.fun{color:red}}@media print,projection{.str{color:#060}.kwd{color:#006;font-weight:bold}.com{color:#600;font-style:italic}.typ{color:#404;font-weight:bold}.lit{color:#044}.pun,.opn,.clo{color:#440}.tag{color:#006;font-weight:bold}.atn{color:#404}.atv{color:#060}}pre.prettyprint{padding:2px;border:1px solid #888}ol.linenums{margin-top:0;margin-bottom:0}li.L0,li.L1,li.L2,li.L3,li.L5,li.L6,li.L7,li.L8{list-style-type:none}li.L1,li.L3,li.L5,li.L7,li.L9{background:#eee} diff --git a/coverage/prettify.js b/coverage/prettify.js new file mode 100644 index 0000000..b322523 --- /dev/null +++ b/coverage/prettify.js @@ -0,0 +1,2 @@ +/* eslint-disable */ +window.PR_SHOULD_USE_CONTINUATION=true;(function(){var h=["break,continue,do,else,for,if,return,while"];var u=[h,"auto,case,char,const,default,double,enum,extern,float,goto,int,long,register,short,signed,sizeof,static,struct,switch,typedef,union,unsigned,void,volatile"];var p=[u,"catch,class,delete,false,import,new,operator,private,protected,public,this,throw,true,try,typeof"];var l=[p,"alignof,align_union,asm,axiom,bool,concept,concept_map,const_cast,constexpr,decltype,dynamic_cast,explicit,export,friend,inline,late_check,mutable,namespace,nullptr,reinterpret_cast,static_assert,static_cast,template,typeid,typename,using,virtual,where"];var x=[p,"abstract,boolean,byte,extends,final,finally,implements,import,instanceof,null,native,package,strictfp,super,synchronized,throws,transient"];var R=[x,"as,base,by,checked,decimal,delegate,descending,dynamic,event,fixed,foreach,from,group,implicit,in,interface,internal,into,is,lock,object,out,override,orderby,params,partial,readonly,ref,sbyte,sealed,stackalloc,string,select,uint,ulong,unchecked,unsafe,ushort,var"];var r="all,and,by,catch,class,else,extends,false,finally,for,if,in,is,isnt,loop,new,no,not,null,of,off,on,or,return,super,then,true,try,unless,until,when,while,yes";var w=[p,"debugger,eval,export,function,get,null,set,undefined,var,with,Infinity,NaN"];var s="caller,delete,die,do,dump,elsif,eval,exit,foreach,for,goto,if,import,last,local,my,next,no,our,print,package,redo,require,sub,undef,unless,until,use,wantarray,while,BEGIN,END";var I=[h,"and,as,assert,class,def,del,elif,except,exec,finally,from,global,import,in,is,lambda,nonlocal,not,or,pass,print,raise,try,with,yield,False,True,None"];var f=[h,"alias,and,begin,case,class,def,defined,elsif,end,ensure,false,in,module,next,nil,not,or,redo,rescue,retry,self,super,then,true,undef,unless,until,when,yield,BEGIN,END"];var H=[h,"case,done,elif,esac,eval,fi,function,in,local,set,then,until"];var A=[l,R,w,s+I,f,H];var e=/^(DIR|FILE|vector|(de|priority_)?queue|list|stack|(const_)?iterator|(multi)?(set|map)|bitset|u?(int|float)\d*)/;var C="str";var z="kwd";var j="com";var O="typ";var G="lit";var L="pun";var F="pln";var m="tag";var E="dec";var J="src";var P="atn";var n="atv";var N="nocode";var M="(?:^^\\.?|[+-]|\\!|\\!=|\\!==|\\#|\\%|\\%=|&|&&|&&=|&=|\\(|\\*|\\*=|\\+=|\\,|\\-=|\\->|\\/|\\/=|:|::|\\;|<|<<|<<=|<=|=|==|===|>|>=|>>|>>=|>>>|>>>=|\\?|\\@|\\[|\\^|\\^=|\\^\\^|\\^\\^=|\\{|\\||\\|=|\\|\\||\\|\\|=|\\~|break|case|continue|delete|do|else|finally|instanceof|return|throw|try|typeof)\\s*";function k(Z){var ad=0;var S=false;var ac=false;for(var V=0,U=Z.length;V122)){if(!(al<65||ag>90)){af.push([Math.max(65,ag)|32,Math.min(al,90)|32])}if(!(al<97||ag>122)){af.push([Math.max(97,ag)&~32,Math.min(al,122)&~32])}}}}af.sort(function(av,au){return(av[0]-au[0])||(au[1]-av[1])});var ai=[];var ap=[NaN,NaN];for(var ar=0;arat[0]){if(at[1]+1>at[0]){an.push("-")}an.push(T(at[1]))}}an.push("]");return an.join("")}function W(al){var aj=al.source.match(new RegExp("(?:\\[(?:[^\\x5C\\x5D]|\\\\[\\s\\S])*\\]|\\\\u[A-Fa-f0-9]{4}|\\\\x[A-Fa-f0-9]{2}|\\\\[0-9]+|\\\\[^ux0-9]|\\(\\?[:!=]|[\\(\\)\\^]|[^\\x5B\\x5C\\(\\)\\^]+)","g"));var ah=aj.length;var an=[];for(var ak=0,am=0;ak=2&&ai==="["){aj[ak]=X(ag)}else{if(ai!=="\\"){aj[ak]=ag.replace(/[a-zA-Z]/g,function(ao){var ap=ao.charCodeAt(0);return"["+String.fromCharCode(ap&~32,ap|32)+"]"})}}}}return aj.join("")}var aa=[];for(var V=0,U=Z.length;V=0;){S[ac.charAt(ae)]=Y}}var af=Y[1];var aa=""+af;if(!ag.hasOwnProperty(aa)){ah.push(af);ag[aa]=null}}ah.push(/[\0-\uffff]/);V=k(ah)})();var X=T.length;var W=function(ah){var Z=ah.sourceCode,Y=ah.basePos;var ad=[Y,F];var af=0;var an=Z.match(V)||[];var aj={};for(var ae=0,aq=an.length;ae=5&&"lang-"===ap.substring(0,5);if(am&&!(ai&&typeof ai[1]==="string")){am=false;ap=J}if(!am){aj[ag]=ap}}var ab=af;af+=ag.length;if(!am){ad.push(Y+ab,ap)}else{var al=ai[1];var ak=ag.indexOf(al);var ac=ak+al.length;if(ai[2]){ac=ag.length-ai[2].length;ak=ac-al.length}var ar=ap.substring(5);B(Y+ab,ag.substring(0,ak),W,ad);B(Y+ab+ak,al,q(ar,al),ad);B(Y+ab+ac,ag.substring(ac),W,ad)}}ah.decorations=ad};return W}function i(T){var W=[],S=[];if(T.tripleQuotedStrings){W.push([C,/^(?:\'\'\'(?:[^\'\\]|\\[\s\S]|\'{1,2}(?=[^\']))*(?:\'\'\'|$)|\"\"\"(?:[^\"\\]|\\[\s\S]|\"{1,2}(?=[^\"]))*(?:\"\"\"|$)|\'(?:[^\\\']|\\[\s\S])*(?:\'|$)|\"(?:[^\\\"]|\\[\s\S])*(?:\"|$))/,null,"'\""])}else{if(T.multiLineStrings){W.push([C,/^(?:\'(?:[^\\\']|\\[\s\S])*(?:\'|$)|\"(?:[^\\\"]|\\[\s\S])*(?:\"|$)|\`(?:[^\\\`]|\\[\s\S])*(?:\`|$))/,null,"'\"`"])}else{W.push([C,/^(?:\'(?:[^\\\'\r\n]|\\.)*(?:\'|$)|\"(?:[^\\\"\r\n]|\\.)*(?:\"|$))/,null,"\"'"])}}if(T.verbatimStrings){S.push([C,/^@\"(?:[^\"]|\"\")*(?:\"|$)/,null])}var Y=T.hashComments;if(Y){if(T.cStyleComments){if(Y>1){W.push([j,/^#(?:##(?:[^#]|#(?!##))*(?:###|$)|.*)/,null,"#"])}else{W.push([j,/^#(?:(?:define|elif|else|endif|error|ifdef|include|ifndef|line|pragma|undef|warning)\b|[^\r\n]*)/,null,"#"])}S.push([C,/^<(?:(?:(?:\.\.\/)*|\/?)(?:[\w-]+(?:\/[\w-]+)+)?[\w-]+\.h|[a-z]\w*)>/,null])}else{W.push([j,/^#[^\r\n]*/,null,"#"])}}if(T.cStyleComments){S.push([j,/^\/\/[^\r\n]*/,null]);S.push([j,/^\/\*[\s\S]*?(?:\*\/|$)/,null])}if(T.regexLiterals){var X=("/(?=[^/*])(?:[^/\\x5B\\x5C]|\\x5C[\\s\\S]|\\x5B(?:[^\\x5C\\x5D]|\\x5C[\\s\\S])*(?:\\x5D|$))+/");S.push(["lang-regex",new RegExp("^"+M+"("+X+")")])}var V=T.types;if(V){S.push([O,V])}var U=(""+T.keywords).replace(/^ | $/g,"");if(U.length){S.push([z,new RegExp("^(?:"+U.replace(/[\s,]+/g,"|")+")\\b"),null])}W.push([F,/^\s+/,null," \r\n\t\xA0"]);S.push([G,/^@[a-z_$][a-z_$@0-9]*/i,null],[O,/^(?:[@_]?[A-Z]+[a-z][A-Za-z_$@0-9]*|\w+_t\b)/,null],[F,/^[a-z_$][a-z_$@0-9]*/i,null],[G,new RegExp("^(?:0x[a-f0-9]+|(?:\\d(?:_\\d+)*\\d*(?:\\.\\d*)?|\\.\\d\\+)(?:e[+\\-]?\\d+)?)[a-z]*","i"),null,"0123456789"],[F,/^\\[\s\S]?/,null],[L,/^.[^\s\w\.$@\'\"\`\/\#\\]*/,null]);return g(W,S)}var K=i({keywords:A,hashComments:true,cStyleComments:true,multiLineStrings:true,regexLiterals:true});function Q(V,ag){var U=/(?:^|\s)nocode(?:\s|$)/;var ab=/\r\n?|\n/;var ac=V.ownerDocument;var S;if(V.currentStyle){S=V.currentStyle.whiteSpace}else{if(window.getComputedStyle){S=ac.defaultView.getComputedStyle(V,null).getPropertyValue("white-space")}}var Z=S&&"pre"===S.substring(0,3);var af=ac.createElement("LI");while(V.firstChild){af.appendChild(V.firstChild)}var W=[af];function ae(al){switch(al.nodeType){case 1:if(U.test(al.className)){break}if("BR"===al.nodeName){ad(al);if(al.parentNode){al.parentNode.removeChild(al)}}else{for(var an=al.firstChild;an;an=an.nextSibling){ae(an)}}break;case 3:case 4:if(Z){var am=al.nodeValue;var aj=am.match(ab);if(aj){var ai=am.substring(0,aj.index);al.nodeValue=ai;var ah=am.substring(aj.index+aj[0].length);if(ah){var ak=al.parentNode;ak.insertBefore(ac.createTextNode(ah),al.nextSibling)}ad(al);if(!ai){al.parentNode.removeChild(al)}}}break}}function ad(ak){while(!ak.nextSibling){ak=ak.parentNode;if(!ak){return}}function ai(al,ar){var aq=ar?al.cloneNode(false):al;var ao=al.parentNode;if(ao){var ap=ai(ao,1);var an=al.nextSibling;ap.appendChild(aq);for(var am=an;am;am=an){an=am.nextSibling;ap.appendChild(am)}}return aq}var ah=ai(ak.nextSibling,0);for(var aj;(aj=ah.parentNode)&&aj.nodeType===1;){ah=aj}W.push(ah)}for(var Y=0;Y=S){ah+=2}if(V>=ap){Z+=2}}}var t={};function c(U,V){for(var S=V.length;--S>=0;){var T=V[S];if(!t.hasOwnProperty(T)){t[T]=U}else{if(window.console){console.warn("cannot override language handler %s",T)}}}}function q(T,S){if(!(T&&t.hasOwnProperty(T))){T=/^\s*]*(?:>|$)/],[j,/^<\!--[\s\S]*?(?:-\->|$)/],["lang-",/^<\?([\s\S]+?)(?:\?>|$)/],["lang-",/^<%([\s\S]+?)(?:%>|$)/],[L,/^(?:<[%?]|[%?]>)/],["lang-",/^]*>([\s\S]+?)<\/xmp\b[^>]*>/i],["lang-js",/^]*>([\s\S]*?)(<\/script\b[^>]*>)/i],["lang-css",/^]*>([\s\S]*?)(<\/style\b[^>]*>)/i],["lang-in.tag",/^(<\/?[a-z][^<>]*>)/i]]),["default-markup","htm","html","mxml","xhtml","xml","xsl"]);c(g([[F,/^[\s]+/,null," \t\r\n"],[n,/^(?:\"[^\"]*\"?|\'[^\']*\'?)/,null,"\"'"]],[[m,/^^<\/?[a-z](?:[\w.:-]*\w)?|\/?>$/i],[P,/^(?!style[\s=]|on)[a-z](?:[\w:-]*\w)?/i],["lang-uq.val",/^=\s*([^>\'\"\s]*(?:[^>\'\"\s\/]|\/(?=\s)))/],[L,/^[=<>\/]+/],["lang-js",/^on\w+\s*=\s*\"([^\"]+)\"/i],["lang-js",/^on\w+\s*=\s*\'([^\']+)\'/i],["lang-js",/^on\w+\s*=\s*([^\"\'>\s]+)/i],["lang-css",/^style\s*=\s*\"([^\"]+)\"/i],["lang-css",/^style\s*=\s*\'([^\']+)\'/i],["lang-css",/^style\s*=\s*([^\"\'>\s]+)/i]]),["in.tag"]);c(g([],[[n,/^[\s\S]+/]]),["uq.val"]);c(i({keywords:l,hashComments:true,cStyleComments:true,types:e}),["c","cc","cpp","cxx","cyc","m"]);c(i({keywords:"null,true,false"}),["json"]);c(i({keywords:R,hashComments:true,cStyleComments:true,verbatimStrings:true,types:e}),["cs"]);c(i({keywords:x,cStyleComments:true}),["java"]);c(i({keywords:H,hashComments:true,multiLineStrings:true}),["bsh","csh","sh"]);c(i({keywords:I,hashComments:true,multiLineStrings:true,tripleQuotedStrings:true}),["cv","py"]);c(i({keywords:s,hashComments:true,multiLineStrings:true,regexLiterals:true}),["perl","pl","pm"]);c(i({keywords:f,hashComments:true,multiLineStrings:true,regexLiterals:true}),["rb"]);c(i({keywords:w,cStyleComments:true,regexLiterals:true}),["js"]);c(i({keywords:r,hashComments:3,cStyleComments:true,multilineStrings:true,tripleQuotedStrings:true,regexLiterals:true}),["coffee"]);c(g([],[[C,/^[\s\S]+/]]),["regex"]);function d(V){var U=V.langExtension;try{var S=a(V.sourceNode);var T=S.sourceCode;V.sourceCode=T;V.spans=S.spans;V.basePos=0;q(U,T)(V);D(V)}catch(W){if("console" in window){console.log(W&&W.stack?W.stack:W)}}}function y(W,V,U){var S=document.createElement("PRE");S.innerHTML=W;if(U){Q(S,U)}var T={langExtension:V,numberLines:U,sourceNode:S};d(T);return S.innerHTML}function b(ad){function Y(af){return document.getElementsByTagName(af)}var ac=[Y("pre"),Y("code"),Y("xmp")];var T=[];for(var aa=0;aa=0){var ah=ai.match(ab);var am;if(!ah&&(am=o(aj))&&"CODE"===am.tagName){ah=am.className.match(ab)}if(ah){ah=ah[1]}var al=false;for(var ak=aj.parentNode;ak;ak=ak.parentNode){if((ak.tagName==="pre"||ak.tagName==="code"||ak.tagName==="xmp")&&ak.className&&ak.className.indexOf("prettyprint")>=0){al=true;break}}if(!al){var af=aj.className.match(/\blinenums\b(?::(\d+))?/);af=af?af[1]&&af[1].length?+af[1]:true:false;if(af){Q(aj,af)}S={langExtension:ah,sourceNode:aj,numberLines:af};d(S)}}}if(X]*(?:>|$)/],[PR.PR_COMMENT,/^<\!--[\s\S]*?(?:-\->|$)/],[PR.PR_PUNCTUATION,/^(?:<[%?]|[%?]>)/],["lang-",/^<\?([\s\S]+?)(?:\?>|$)/],["lang-",/^<%([\s\S]+?)(?:%>|$)/],["lang-",/^]*>([\s\S]+?)<\/xmp\b[^>]*>/i],["lang-handlebars",/^]*type\s*=\s*['"]?text\/x-handlebars-template['"]?\b[^>]*>([\s\S]*?)(<\/script\b[^>]*>)/i],["lang-js",/^]*>([\s\S]*?)(<\/script\b[^>]*>)/i],["lang-css",/^]*>([\s\S]*?)(<\/style\b[^>]*>)/i],["lang-in.tag",/^(<\/?[a-z][^<>]*>)/i],[PR.PR_DECLARATION,/^{{[#^>/]?\s*[\w.][^}]*}}/],[PR.PR_DECLARATION,/^{{&?\s*[\w.][^}]*}}/],[PR.PR_DECLARATION,/^{{{>?\s*[\w.][^}]*}}}/],[PR.PR_COMMENT,/^{{![^}]*}}/]]),["handlebars","hbs"]);PR.registerLangHandler(PR.createSimpleLexer([[PR.PR_PLAIN,/^[ \t\r\n\f]+/,null," \t\r\n\f"]],[[PR.PR_STRING,/^\"(?:[^\n\r\f\\\"]|\\(?:\r\n?|\n|\f)|\\[\s\S])*\"/,null],[PR.PR_STRING,/^\'(?:[^\n\r\f\\\']|\\(?:\r\n?|\n|\f)|\\[\s\S])*\'/,null],["lang-css-str",/^url\(([^\)\"\']*)\)/i],[PR.PR_KEYWORD,/^(?:url|rgb|\!important|@import|@page|@media|@charset|inherit)(?=[^\-\w]|$)/i,null],["lang-css-kw",/^(-?(?:[_a-z]|(?:\\[0-9a-f]+ ?))(?:[_a-z0-9\-]|\\(?:\\[0-9a-f]+ ?))*)\s*:/i],[PR.PR_COMMENT,/^\/\*[^*]*\*+(?:[^\/*][^*]*\*+)*\//],[PR.PR_COMMENT,/^(?:)/],[PR.PR_LITERAL,/^(?:\d+|\d*\.\d+)(?:%|[a-z]+)?/i],[PR.PR_LITERAL,/^#(?:[0-9a-f]{3}){1,2}/i],[PR.PR_PLAIN,/^-?(?:[_a-z]|(?:\\[\da-f]+ ?))(?:[_a-z\d\-]|\\(?:\\[\da-f]+ ?))*/i],[PR.PR_PUNCTUATION,/^[^\s\w\'\"]+/]]),["css"]);PR.registerLangHandler(PR.createSimpleLexer([],[[PR.PR_KEYWORD,/^-?(?:[_a-z]|(?:\\[\da-f]+ ?))(?:[_a-z\d\-]|\\(?:\\[\da-f]+ ?))*/i]]),["css-kw"]);PR.registerLangHandler(PR.createSimpleLexer([],[[PR.PR_STRING,/^[^\)\"\']+/]]),["css-str"]); diff --git a/coverage/sort-arrow-sprite.png b/coverage/sort-arrow-sprite.png new file mode 100644 index 0000000..6ed6831 Binary files /dev/null and b/coverage/sort-arrow-sprite.png differ diff --git a/coverage/sorter.js b/coverage/sorter.js new file mode 100644 index 0000000..4ed70ae --- /dev/null +++ b/coverage/sorter.js @@ -0,0 +1,210 @@ +/* eslint-disable */ +var addSorting = (function() { + 'use strict'; + var cols, + currentSort = { + index: 0, + desc: false + }; + + // returns the summary table element + function getTable() { + return document.querySelector('.coverage-summary'); + } + // returns the thead element of the summary table + function getTableHeader() { + return getTable().querySelector('thead tr'); + } + // returns the tbody element of the summary table + function getTableBody() { + return getTable().querySelector('tbody'); + } + // returns the th element for nth column + function getNthColumn(n) { + return getTableHeader().querySelectorAll('th')[n]; + } + + function onFilterInput() { + const searchValue = document.getElementById('fileSearch').value; + const rows = document.getElementsByTagName('tbody')[0].children; + + // Try to create a RegExp from the searchValue. If it fails (invalid regex), + // it will be treated as a plain text search + let searchRegex; + try { + searchRegex = new RegExp(searchValue, 'i'); // 'i' for case-insensitive + } catch (error) { + searchRegex = null; + } + + for (let i = 0; i < rows.length; i++) { + const row = rows[i]; + let isMatch = false; + + if (searchRegex) { + // If a valid regex was created, use it for matching + isMatch = searchRegex.test(row.textContent); + } else { + // Otherwise, fall back to the original plain text search + isMatch = row.textContent + .toLowerCase() + .includes(searchValue.toLowerCase()); + } + + row.style.display = isMatch ? '' : 'none'; + } + } + + // loads the search box + function addSearchBox() { + var template = document.getElementById('filterTemplate'); + var templateClone = template.content.cloneNode(true); + templateClone.getElementById('fileSearch').oninput = onFilterInput; + template.parentElement.appendChild(templateClone); + } + + // loads all columns + function loadColumns() { + var colNodes = getTableHeader().querySelectorAll('th'), + colNode, + cols = [], + col, + i; + + for (i = 0; i < colNodes.length; i += 1) { + colNode = colNodes[i]; + col = { + key: colNode.getAttribute('data-col'), + sortable: !colNode.getAttribute('data-nosort'), + type: colNode.getAttribute('data-type') || 'string' + }; + cols.push(col); + if (col.sortable) { + col.defaultDescSort = col.type === 'number'; + colNode.innerHTML = + colNode.innerHTML + ''; + } + } + return cols; + } + // attaches a data attribute to every tr element with an object + // of data values keyed by column name + function loadRowData(tableRow) { + var tableCols = tableRow.querySelectorAll('td'), + colNode, + col, + data = {}, + i, + val; + for (i = 0; i < tableCols.length; i += 1) { + colNode = tableCols[i]; + col = cols[i]; + val = colNode.getAttribute('data-value'); + if (col.type === 'number') { + val = Number(val); + } + data[col.key] = val; + } + return data; + } + // loads all row data + function loadData() { + var rows = getTableBody().querySelectorAll('tr'), + i; + + for (i = 0; i < rows.length; i += 1) { + rows[i].data = loadRowData(rows[i]); + } + } + // sorts the table using the data for the ith column + function sortByIndex(index, desc) { + var key = cols[index].key, + sorter = function(a, b) { + a = a.data[key]; + b = b.data[key]; + return a < b ? -1 : a > b ? 1 : 0; + }, + finalSorter = sorter, + tableBody = document.querySelector('.coverage-summary tbody'), + rowNodes = tableBody.querySelectorAll('tr'), + rows = [], + i; + + if (desc) { + finalSorter = function(a, b) { + return -1 * sorter(a, b); + }; + } + + for (i = 0; i < rowNodes.length; i += 1) { + rows.push(rowNodes[i]); + tableBody.removeChild(rowNodes[i]); + } + + rows.sort(finalSorter); + + for (i = 0; i < rows.length; i += 1) { + tableBody.appendChild(rows[i]); + } + } + // removes sort indicators for current column being sorted + function removeSortIndicators() { + var col = getNthColumn(currentSort.index), + cls = col.className; + + cls = cls.replace(/ sorted$/, '').replace(/ sorted-desc$/, ''); + col.className = cls; + } + // adds sort indicators for current column being sorted + function addSortIndicators() { + getNthColumn(currentSort.index).className += currentSort.desc + ? ' sorted-desc' + : ' sorted'; + } + // adds event listeners for all sorter widgets + function enableUI() { + var i, + el, + ithSorter = function ithSorter(i) { + var col = cols[i]; + + return function() { + var desc = col.defaultDescSort; + + if (currentSort.index === i) { + desc = !currentSort.desc; + } + sortByIndex(i, desc); + removeSortIndicators(); + currentSort.index = i; + currentSort.desc = desc; + addSortIndicators(); + }; + }; + for (i = 0; i < cols.length; i += 1) { + if (cols[i].sortable) { + // add the click event handler on the th so users + // dont have to click on those tiny arrows + el = getNthColumn(i).querySelector('.sorter').parentElement; + if (el.addEventListener) { + el.addEventListener('click', ithSorter(i)); + } else { + el.attachEvent('onclick', ithSorter(i)); + } + } + } + } + // adds sorting functionality to the UI + return function() { + if (!getTable()) { + return; + } + cols = loadColumns(); + loadData(); + addSearchBox(); + addSortIndicators(); + enableUI(); + }; +})(); + +window.addEventListener('load', addSorting); diff --git a/eslint.config.mjs b/eslint.config.mjs index 5d6013c..fe1d9e1 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -67,7 +67,7 @@ export default tseslint.config( 'error', { argsIgnorePattern: '^_', varsIgnorePattern: '^_' }, ], - 'sonarjs/cognitive-complexity': 'off', + 'sonarjs/cognitive-complexity': ['error', 79], 'sonarjs/different-types-comparison': 'off', 'sonarjs/function-return-type': 'off', 'sonarjs/no-duplicated-branches': 'off', diff --git a/package-lock.json b/package-lock.json index c2f29b0..9560bea 100644 --- a/package-lock.json +++ b/package-lock.json @@ -35,6 +35,7 @@ "@types/marked": "^5.0.2", "@types/marked-terminal": "^6.1.1", "@types/node": "^20.11.20", + "@vitest/coverage-v8": "^4.1.8", "@vitest/eslint-plugin": "^1.6.17", "eslint": "^10.3.0", "eslint-config-prettier": "^10.1.8", @@ -69,6 +70,16 @@ "node": ">=6.9.0" } }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/helper-validator-identifier": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", @@ -78,6 +89,46 @@ "node": ">=6.9.0" } }, + "node_modules/@babel/parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bcoe/v8-coverage": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", + "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/@clack/core": { "version": "0.4.2", "resolved": "https://registry.npmjs.org/@clack/core/-/core-0.4.2.tgz", @@ -728,6 +779,16 @@ "url": "https://github.com/sponsors/nzakas" } }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/@jridgewell/sourcemap-codec": { "version": "1.5.5", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", @@ -735,6 +796,17 @@ "dev": true, "license": "MIT" }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, "node_modules/@napi-rs/wasm-runtime": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", @@ -1472,6 +1544,37 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/@vitest/coverage-v8": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.8.tgz", + "integrity": "sha512-lt3kovsyHwYe00wq4D1ti0Z974fWj4NLp6siqiyEufUpyFwK9Yhi7rBhac9JL5aA0zoMrJqc4vYPZRUnI7l7nw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@bcoe/v8-coverage": "^1.0.2", + "@vitest/utils": "4.1.8", + "ast-v8-to-istanbul": "^1.0.0", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-reports": "^3.2.0", + "magicast": "^0.5.2", + "obug": "^2.1.1", + "std-env": "^4.0.0-rc.1", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@vitest/browser": "4.1.8", + "vitest": "4.1.8" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + } + } + }, "node_modules/@vitest/eslint-plugin": { "version": "1.6.17", "resolved": "https://registry.npmjs.org/@vitest/eslint-plugin/-/eslint-plugin-1.6.17.tgz", @@ -1748,6 +1851,25 @@ "node": ">=12" } }, + "node_modules/ast-v8-to-istanbul": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.3.tgz", + "integrity": "sha512-jCMQ6ZylLPudp0CDfBmQBZUsrh1/8psbmu9ibeVWKuHWD0YrH9YABwlKu5kVEFoT0GCQQW9Z/SxfuEbbkGQCRg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.31", + "estree-walker": "^3.0.3", + "js-tokens": "^10.0.0" + } + }, + "node_modules/ast-v8-to-istanbul/node_modules/js-tokens": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", + "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==", + "dev": true, + "license": "MIT" + }, "node_modules/balanced-match": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", @@ -2935,6 +3057,13 @@ "node": ">=8" } }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, "node_modules/https-proxy-agent": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", @@ -3137,6 +3266,58 @@ "dev": true, "license": "ISC" }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-report/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/jose": { "version": "6.2.3", "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz", @@ -3748,6 +3929,34 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/magicast": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.3.tgz", + "integrity": "sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.3", + "@babel/types": "^7.29.0", + "source-map-js": "^1.2.1" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/marked": { "version": "9.1.6", "resolved": "https://registry.npmjs.org/marked/-/marked-9.1.6.tgz", @@ -5228,11 +5437,42 @@ "picocolors": "^1.1.1" } }, + "@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true + }, "@babel/helper-validator-identifier": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==" }, + "@babel/parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "dev": true, + "requires": { + "@babel/types": "^7.29.7" + } + }, + "@babel/types": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "dev": true, + "requires": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + } + }, + "@bcoe/v8-coverage": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", + "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", + "dev": true + }, "@clack/core": { "version": "0.4.2", "resolved": "https://registry.npmjs.org/@clack/core/-/core-0.4.2.tgz", @@ -5562,12 +5802,28 @@ "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", "dev": true }, + "@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true + }, "@jridgewell/sourcemap-codec": { "version": "1.5.5", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", "dev": true }, + "@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "requires": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, "@napi-rs/wasm-runtime": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", @@ -5988,6 +6244,24 @@ } } }, + "@vitest/coverage-v8": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.8.tgz", + "integrity": "sha512-lt3kovsyHwYe00wq4D1ti0Z974fWj4NLp6siqiyEufUpyFwK9Yhi7rBhac9JL5aA0zoMrJqc4vYPZRUnI7l7nw==", + "dev": true, + "requires": { + "@bcoe/v8-coverage": "^1.0.2", + "@vitest/utils": "4.1.8", + "ast-v8-to-istanbul": "^1.0.0", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-reports": "^3.2.0", + "magicast": "^0.5.2", + "obug": "^2.1.1", + "std-env": "^4.0.0-rc.1", + "tinyrainbow": "^3.1.0" + } + }, "@vitest/eslint-plugin": { "version": "1.6.17", "resolved": "https://registry.npmjs.org/@vitest/eslint-plugin/-/eslint-plugin-1.6.17.tgz", @@ -6146,6 +6420,25 @@ "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", "dev": true }, + "ast-v8-to-istanbul": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.3.tgz", + "integrity": "sha512-jCMQ6ZylLPudp0CDfBmQBZUsrh1/8psbmu9ibeVWKuHWD0YrH9YABwlKu5kVEFoT0GCQQW9Z/SxfuEbbkGQCRg==", + "dev": true, + "requires": { + "@jridgewell/trace-mapping": "^0.3.31", + "estree-walker": "^3.0.3", + "js-tokens": "^10.0.0" + }, + "dependencies": { + "js-tokens": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", + "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==", + "dev": true + } + } + }, "balanced-match": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", @@ -6867,6 +7160,12 @@ "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==" }, + "html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true + }, "https-proxy-agent": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", @@ -6974,6 +7273,44 @@ "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", "dev": true }, + "istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true + }, + "istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "requires": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "dependencies": { + "supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "requires": { + "has-flag": "^4.0.0" + } + } + } + }, + "istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "requires": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + } + }, "jose": { "version": "6.2.3", "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz", @@ -7298,6 +7635,26 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "magicast": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.3.tgz", + "integrity": "sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw==", + "dev": true, + "requires": { + "@babel/parser": "^7.29.3", + "@babel/types": "^7.29.0", + "source-map-js": "^1.2.1" + } + }, + "make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "requires": { + "semver": "^7.5.3" + } + }, "marked": { "version": "9.1.6", "resolved": "https://registry.npmjs.org/marked/-/marked-9.1.6.tgz", diff --git a/package.json b/package.json index 84f6e15..4f2c21a 100644 --- a/package.json +++ b/package.json @@ -38,6 +38,7 @@ "@types/marked": "^5.0.2", "@types/marked-terminal": "^6.1.1", "@types/node": "^20.11.20", + "@vitest/coverage-v8": "^4.1.8", "@vitest/eslint-plugin": "^1.6.17", "eslint": "^10.3.0", "eslint-config-prettier": "^10.1.8", diff --git a/src/auth/oauth/token-refresh.ts b/src/auth/oauth/token-refresh.ts index d390db3..c568a9c 100644 --- a/src/auth/oauth/token-refresh.ts +++ b/src/auth/oauth/token-refresh.ts @@ -34,6 +34,11 @@ function isTransientError(error: unknown): boolean { // while ensuring different stores don't share promises. const inFlightByConfig = new WeakMap>>(); +interface RefreshErrorAction { + clearTokens: boolean; + retry: boolean; +} + export async function refreshAccessToken( config: Configuration, tokenStore: TokenStore, @@ -57,6 +62,29 @@ export async function refreshAccessToken( return promise; } +function classifyRefreshError(error: unknown, attempt: number): RefreshErrorAction { + if (error instanceof ResponseBodyError) { + const isPermanent = + error.error === 'invalid_grant' || error.status === 401 || error.status === 403; + return { clearTokens: isPermanent, retry: false }; + } + + if ( + error instanceof Error && + (error.message.includes('401') || + error.message.includes('403') || + error.message.includes('invalid_grant')) + ) { + return { clearTokens: true, retry: false }; + } + + if (isTransientError(error) && attempt < MAX_RETRIES - 1) { + return { clearTokens: false, retry: true }; + } + + return { clearTokens: false, retry: false }; +} + /** * Try to refresh the access token with exponential backoff on transient * network failures. Permanent auth errors clear stored tokens; transient @@ -71,46 +99,16 @@ async function doRefresh(config: Configuration, tokenStore: TokenStore): Promise while (true) { try { const result = await refreshTokenGrant(config, tokenData.refresh_token); - - // Extract tokens from response - const accessToken = result.access_token; - const refreshToken = result.refresh_token || tokenData.refresh_token; - const expiresIn = result.expires_in || 3600; - - // Calculate expiration from JWT or fallback - const jwtExpiresAt = extractJwtExpiresAt(accessToken); - const expiresAt = jwtExpiresAt > 0 ? jwtExpiresAt : Date.now() + expiresIn * 1000; - - const newTokenData: TokenData = { - access_token: accessToken, - expires_at: expiresAt, - refresh_token: refreshToken, - }; - - await tokenStore.set(newTokenData); + await storeNewTokens(tokenStore, tokenData.refresh_token, result); return true; } catch (error) { - // On invalid/expired refresh token (401/403 from Keycloak), clear tokens. - // ResponseBodyError from openid-client carries structured error info. - if (error instanceof ResponseBodyError) { - if (error.error === 'invalid_grant' || error.status === 401 || error.status === 403) { - await tokenStore.clear(); - } - return false; - } + const action = classifyRefreshError(error, attempt); - if ( - error instanceof Error && - (error.message.includes('401') || - error.message.includes('403') || - error.message.includes('invalid_grant')) - ) { - // Fallback for non-standard error shapes (e.g. network-level failures) + if (action.clearTokens) { await tokenStore.clear(); - return false; } - if (isTransientError(error) && attempt < MAX_RETRIES - 1) { + if (action.retry) { await sleep(INITIAL_BACKOFF_MS * 2 ** attempt); attempt++; continue; @@ -124,3 +122,24 @@ async function doRefresh(config: Configuration, tokenStore: TokenStore): Promise function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } + +async function storeNewTokens( + tokenStore: TokenStore, + oldRefreshToken: string, + result: any, +): Promise { + const accessToken = result.access_token; + const refreshToken = result.refresh_token || oldRefreshToken; + const expiresIn = result.expires_in || 3600; + + const jwtExpiresAt = extractJwtExpiresAt(accessToken); + const expiresAt = jwtExpiresAt > 0 ? jwtExpiresAt : Date.now() + expiresIn * 1000; + + const newTokenData: TokenData = { + access_token: accessToken, + expires_at: expiresAt, + refresh_token: refreshToken, + }; + + await tokenStore.set(newTokenData); +} diff --git a/src/commands/chat.ts b/src/commands/chat.ts index b2110b4..4a16d15 100644 --- a/src/commands/chat.ts +++ b/src/commands/chat.ts @@ -22,7 +22,6 @@ export function registerChatCommands(program: Command): void { .description('Run a chat session with a specified model') .argument('[message]', 'Message to send directly (skips interactive mode)') .option('-m, --model ', 'Model to use (default: kimi-k2.6)') - .option('-t, --temperature ', 'Temperature (0-1)', Number.parseFloat) .option('--max-tokens ', 'Maximum tokens to generate', Number.parseInt) .option('-k, --api-key ', 'API key to use for this chat session') @@ -30,454 +29,25 @@ export function registerChatCommands(program: Command): void { .option('--no-stream', 'Disable streaming (streaming is enabled by default)') .action(async (message, options) => { try { - const chatService = ChatService.getInstance(); - - // Check if we have an API key or need to get one - let apiKey = options.apiKey; - let apiKeyId = options.apiKeyId; - - // Check for environment variable first - const environmentApiKey = process.env.BERGET_API_KEY; - if (environmentApiKey) { - console.log(chalk.dim(`Using API key from BERGET_API_KEY environment variable`)); - apiKey = environmentApiKey; - - // Debug the API key (first few characters only) - if (process.argv.includes('--debug')) { - console.log( - chalk.yellow( - `DEBUG: API key from env starts with: ${environmentApiKey.slice(0, 4)}...`, - ), - ); - } - } - // If API key is already provided via command line, use it - else if (options.apiKey) { - console.log(chalk.dim(`Using API key from command line argument`)); - apiKey = options.apiKey; - } - // If no API key or API key ID provided and no env var, check for default API key - else if (!apiKey && !apiKeyId) { - try { - const defaultApiKeyManager = DefaultApiKeyManager.getInstance(); - const defaultApiKeyData = defaultApiKeyManager.getDefaultApiKeyData(); - - if (defaultApiKeyData) { - apiKeyId = defaultApiKeyData.id; - apiKey = defaultApiKeyData.key; - - if (apiKey) { - console.log(chalk.dim(`Using default API key: ${defaultApiKeyData.name}`)); - } else { - console.log( - chalk.yellow( - `Default API key "${defaultApiKeyData.name}" exists but the key value is missing.`, - ), - ); - console.log( - chalk.yellow( - `Try rotating the key with: berget api-keys rotate ${defaultApiKeyData.id}`, - ), - ); - } - } else { - // No default API key, prompt the user to create one - console.log(chalk.yellow('No default API key set.')); - - // Try to prompt for a default API key - apiKey = await defaultApiKeyManager.promptForDefaultApiKey(); - - if (!apiKey) { - console.log(chalk.red('Error: An API key is required to use the chat command.')); - console.log(chalk.yellow('You can:')); - console.log( - chalk.yellow('1. Create an API key with: berget api-keys create --name "My Key"'), - ); - console.log( - chalk.yellow('2. Set a default API key with: berget api-keys set-default '), - ); - console.log(chalk.yellow('3. Provide an API key with the --api-key option')); - return; - } - } - } catch (error) { - if (process.argv.includes('--debug')) { - console.log(chalk.yellow('DEBUG: Error checking default API key:')); - console.log(chalk.yellow(String(error))); - } - } - } + let apiKey = await resolveApiKey(options); - // If no direct API key, try to get one from API key ID - if (!apiKey && apiKeyId) { - try { - const apiKeyService = ApiKeyService.getInstance(); - const keys = await apiKeyService.list(); - const selectedKey = keys.find((key) => key.id.toString() === options.apiKeyId); - - if (selectedKey) { - console.log(chalk.dim(`Using API key: ${selectedKey.name}`)); - - // We need to rotate the key to get the actual key value - const shouldRotate = await clackConfirm({ - message: `To use API key "${selectedKey.name}", it needs to be rotated. This will invalidate the current key. Continue?`, - }); - if (isCancel(shouldRotate)) { - console.log(chalk.yellow('Operation cancelled.')); - return; - } - if (shouldRotate) { - const rotatedKey = await apiKeyService.rotate(options.apiKeyId); - apiKey = rotatedKey.key; - console.log(chalk.green(`API key "${selectedKey.name}" rotated successfully.`)); - } else { - console.log(chalk.yellow('Using default authentication instead.')); - } - } else { - console.log( - chalk.yellow( - `API key with ID ${options.apiKeyId} not found. Using default authentication.`, - ), - ); - } - } catch (error) { - // Check if this is an authentication error - const errorMessage = error instanceof Error ? error.message : String(error); - const isAuthError = - errorMessage.includes('Unauthorized') || - errorMessage.includes('Authentication failed') || - errorMessage.includes('AUTH_FAILED'); - - if (isAuthError) { - console.log( - chalk.yellow('Authentication required. Please run `berget auth login` first.'), - ); - } else { - console.error(chalk.red('Error fetching API key:')); - console.error(error); - } - console.log(chalk.yellow('Using default authentication instead.')); - } + if (!apiKey && options.apiKeyId) { + apiKey = await resolveApiKeyFromId(options.apiKeyId); } - // Verify we have authentication before starting chat - if (!apiKey) { - try { - AuthService.getInstance(); - } catch { - console.log(chalk.red('Error: Authentication required for chat')); - console.log(chalk.yellow('Please either:')); - console.log(chalk.yellow('1. Log in with `berget auth login`')); - console.log(chalk.yellow('2. Provide an API key with `--api-key`')); - console.log(chalk.yellow('3. Provide an API key ID with `--api-key-id`')); - console.log( - chalk.yellow('4. Set a default API key with `berget api-keys set-default `'), - ); - return; - } + if (!(await verifyAuthentication(apiKey))) { + return; } - // Prepare messages array const messages: ChatMessage[] = []; + const inputMessage = await resolveInputMessage(message); - // Add system message if provided - if (options.system) { - messages.push({ - content: options.system, - role: 'system', - }); - } - - // Check if input is being piped in - let inputMessage = message; - let stdinContent = ''; - - if (!process.stdin.isTTY) { - // Read from stdin (piped input) - const chunks = []; - for await (const chunk of process.stdin) { - chunks.push(chunk); - } - stdinContent = Buffer.concat(chunks).toString('utf8').trim(); - } - - // Combine stdin content with message if both exist - if (stdinContent && message) { - inputMessage = `${stdinContent}\n\n${message}`; - } else if (stdinContent && !message) { - inputMessage = stdinContent; - } - - // If a message is provided (either as argument, from stdin, or both), send it directly and exit if (inputMessage) { - // Add user message - messages.push({ - content: inputMessage, - role: 'user', - }); - - try { - // Call the API - const completionOptions: ChatCompletionOptions = { - max_tokens: options.maxTokens || 4096, - messages: messages, - model: options.model || 'openai/gpt-oss', - stream: options.stream !== false, - temperature: options.temperature === undefined ? 0.7 : options.temperature, - }; - - // Only add apiKey if it actually exists - if (apiKey) { - completionOptions.apiKey = apiKey; - } - - // Add streaming support (now default) - if (completionOptions.stream) { - let assistantResponse = ''; - - // Stream the response in real-time - completionOptions.onChunk = (chunk: any) => { - if ( - chunk.choices && - chunk.choices[0] && - chunk.choices[0].delta && - chunk.choices[0].delta.content - ) { - const content = chunk.choices[0].delta.content; - try { - process.stdout.write(content); - } catch (error: any) { - // Handle EPIPE errors gracefully (when pipe is closed) - if (error.code === 'EPIPE') { - // Stop streaming if the pipe is closed - return; - } - throw error; - } - assistantResponse += content; - } - }; - - try { - await chatService.createCompletion(completionOptions); - } catch (streamError) { - console.error(chalk.red('\nStreaming error:'), streamError); - - // Fallback to non-streaming if streaming fails - console.log(chalk.yellow('Falling back to non-streaming mode...')); - completionOptions.stream = false; - delete completionOptions.onChunk; - - const response = await chatService.createCompletion(completionOptions); - - if ( - response && - response.choices && - response.choices[0] && - response.choices[0].message - ) { - assistantResponse = response.choices[0].message.content; - console.log(assistantResponse); - } - } - console.log(); // Add newline at the end - return; - } - - const response = await chatService.createCompletion(completionOptions); - - // Check if response has the expected structure - if ( - !response || - !response.choices || - !response.choices[0] || - !response.choices[0].message - ) { - console.error(chalk.red('Error: Unexpected response format from API')); - console.error(chalk.red('Response:', JSON.stringify(response, null, 2))); - throw new Error('Unexpected response format from API'); - } - - // Get assistant's response - const assistantMessage = response.choices[0].message.content; - - // Display the response - if (containsMarkdown(assistantMessage)) { - console.log(renderMarkdown(assistantMessage)); - } else { - console.log(assistantMessage); - } - - return; - } catch (error) { - console.error(chalk.red('Error: Failed to get response')); - if (error instanceof Error) { - console.error(chalk.red(error.message)); - } - process.exitCode = 1; - return; - } + await runSingleShotChat(inputMessage, options, apiKey); + return; } - // Set up readline interface for user input (only for interactive mode) - const rl = readline.createInterface({ - input: process.stdin, - output: process.stdout, - }); - - console.log(chalk.cyan('Chat with Berget AI (type "exit" to quit)')); - console.log(chalk.cyan('----------------------------------------')); - - // Start the conversation loop - const askQuestion = () => { - rl.question(chalk.green('You: '), async (input) => { - // Check if user wants to exit - if (input.toLowerCase() === 'exit') { - console.log(chalk.cyan('Goodbye!')); - rl.close(); - return; - } - - // Add user message - messages.push({ - content: input, - role: 'user', - }); - - try { - // Call the API - const completionOptions: ChatCompletionOptions = { - max_tokens: options.maxTokens || 4096, - messages: messages, - model: options.model || 'openai/gpt-oss', - stream: options.stream !== false, - temperature: options.temperature === undefined ? 0.7 : options.temperature, - }; - - // Only add apiKey if it actually exists - if (apiKey) { - completionOptions.apiKey = apiKey; - } - - // Add streaming support (now default) - if (completionOptions.stream) { - let assistantResponse = ''; - console.log(chalk.blue('Assistant: ')); - - // Stream the response in real-time - completionOptions.onChunk = (chunk: any) => { - if ( - chunk.choices && - chunk.choices[0] && - chunk.choices[0].delta && - chunk.choices[0].delta.content - ) { - const content = chunk.choices[0].delta.content; - try { - process.stdout.write(content); - } catch (error: any) { - // Handle EPIPE errors gracefully (when pipe is closed) - if (error.code === 'EPIPE') { - // Stop streaming if the pipe is closed - return; - } - throw error; - } - assistantResponse += content; - } - }; - - try { - await chatService.createCompletion(completionOptions); - } catch (streamError) { - console.error(chalk.red('\nStreaming error:'), streamError); - - // Fallback to non-streaming if streaming fails - console.log(chalk.yellow('Falling back to non-streaming mode...')); - completionOptions.stream = false; - delete completionOptions.onChunk; - - const response = await chatService.createCompletion(completionOptions); - - if ( - response && - response.choices && - response.choices[0] && - response.choices[0].message - ) { - assistantResponse = response.choices[0].message.content; - console.log(assistantResponse); - } - } - console.log('\n'); - - // Add assistant response to messages - messages.push({ - content: assistantResponse, - role: 'assistant', - }); - - // Continue the conversation - askQuestion(); - return; - } - - const response = await chatService.createCompletion(completionOptions); - - // Debug output - if (program.opts().debug) { - console.log(chalk.yellow('DEBUG: Full response:')); - console.log(chalk.yellow(JSON.stringify(response, null, 2))); - } - - // Check if response has the expected structure - if ( - !response || - !response.choices || - !response.choices[0] || - !response.choices[0].message - ) { - console.error(chalk.red('Error: Unexpected response format from API')); - console.error(chalk.red('Response:', JSON.stringify(response, null, 2))); - throw new Error('Unexpected response format from API'); - } - - // Get assistant's response - const assistantMessage = response.choices[0].message.content; - - // Add to messages array - messages.push({ - content: assistantMessage, - role: 'assistant', - }); - - // Display the response - console.log(chalk.blue('Assistant: ')); - - // Check if the response contains markdown and render it if it does - if (containsMarkdown(assistantMessage)) { - console.log(renderMarkdown(assistantMessage)); - } else { - console.log(assistantMessage); - } - - console.log(); // Empty line for better readability - - // Continue the conversation - askQuestion(); - } catch (error) { - console.error(chalk.red('Error: Failed to get response')); - if (error instanceof Error) { - console.error(chalk.red(error.message)); - } - // Continue despite error - askQuestion(); - } - }); - }; - - // Start the conversation - askQuestion(); + await runInteractiveChat(options, apiKey, messages); } catch (error) { handleError('Failed to create chat completion', error); } @@ -490,89 +60,465 @@ export function registerChatCommands(program: Command): void { .option('--api-key-id ', 'ID of the API key to use from your saved keys') .action(async (options) => { try { - // If API key ID is provided, fetch the actual key - let apiKey = options.apiKey; - let apiKeyId = options.apiKeyId; - - // If no API key or API key ID provided, check for default API key - if (!apiKey && !apiKeyId) { - const defaultApiKeyManager = DefaultApiKeyManager.getInstance(); - const defaultApiKeyData = defaultApiKeyManager.getDefaultApiKeyData(); - - if (defaultApiKeyData) { - apiKeyId = defaultApiKeyData.id; - console.log(chalk.dim(`Using default API key: ${defaultApiKeyData.name}`)); - } - } - - if (apiKeyId && !apiKey) { - try { - const apiKeyService = ApiKeyService.getInstance(); - const keys = await apiKeyService.list(); - const selectedKey = keys.find((key) => key.id.toString() === options.apiKeyId); - - if (selectedKey) { - console.log(chalk.dim(`Using API key: ${selectedKey.name}`)); - - // We need to rotate the key to get the actual key value - const shouldRotate = await clackConfirm({ - message: `To use API key "${selectedKey.name}", it needs to be rotated. This will invalidate the current key. Continue?`, - }); - if (isCancel(shouldRotate)) { - console.log(chalk.yellow('Operation cancelled.')); - return; - } - if (shouldRotate) { - const rotatedKey = await apiKeyService.rotate(options.apiKeyId); - apiKey = rotatedKey.key; - console.log(chalk.green(`API key "${selectedKey.name}" rotated successfully.`)); - } else { - console.log(chalk.yellow('Using default authentication instead.')); - } - } else { - console.log( - chalk.yellow( - `API key with ID ${options.apiKeyId} not found. Using default authentication.`, - ), - ); - } - } catch (error) { - console.error(chalk.red('Error fetching API key:')); - console.error(error); - console.log(chalk.yellow('Using default authentication instead.')); - } - } - + const apiKey = await resolveApiKeyForList(options); const chatService = ChatService.getInstance(); const models = await chatService.listModels(apiKey); - // Debug output if (program.opts().debug) { console.log(chalk.yellow('DEBUG: Models response:')); console.log(chalk.yellow(JSON.stringify(models, null, 2))); } - console.log(chalk.bold('Available Chat Models:')); - console.log(chalk.dim('─'.repeat(70))); - console.log(chalk.dim('MODEL ID'.padEnd(40)) + chalk.dim('CAPABILITIES')); - console.log(chalk.dim('─'.repeat(70))); + displayModels(models); + } catch (error) { + handleError('Failed to list chat models', error); + } + }); +} + +function buildCompletionOptions( + options: any, + messages: ChatMessage[], + apiKey: string | undefined, +): ChatCompletionOptions { + const completionOptions: ChatCompletionOptions = { + max_tokens: options.maxTokens || 4096, + messages: messages, + model: options.model || 'openai/gpt-oss', + stream: options.stream !== false, + temperature: options.temperature === undefined ? 0.7 : options.temperature, + }; + + if (apiKey) { + completionOptions.apiKey = apiKey; + } + + return completionOptions; +} + +function createAskQuestion( + rl: readline.Interface, + messages: ChatMessage[], + apiKey: string | undefined, + options: any, + chatService: ChatService, +): () => void { + return () => { + rl.question(chalk.green('You: '), async (input) => { + if (input.toLowerCase() === 'exit') { + console.log(chalk.cyan('Goodbye!')); + rl.close(); + return; + } - // Filter to only show active models - const activeModels = models.data.filter((model: any) => model.active === true); + messages.push({ content: input, role: 'user' }); - activeModels.forEach((model: any) => { - const capabilities = []; - if (model.capabilities.vision) capabilities.push('vision'); - if (model.capabilities.function_calling) capabilities.push('function_calling'); - if (model.capabilities.json_mode) capabilities.push('json_mode'); + try { + const completionOptions = buildCompletionOptions(options, messages, apiKey); - // Format model ID in Huggingface compatible format (owner/model) - const modelId = `${model.owned_by.toLowerCase()}/${model.id}`.padEnd(40); + if (completionOptions.stream) { + await runInteractiveStreaming(chatService, completionOptions, messages); + } else { + await runInteractiveNonStreaming(chatService, completionOptions, messages); + } - console.log(modelId + capabilities.join(', ')); - }); + createAskQuestion(rl, messages, apiKey, options, chatService)(); } catch (error) { - handleError('Failed to list chat models', error); + console.error(chalk.red('Error: Failed to get response')); + if (error instanceof Error) { + console.error(chalk.red(error.message)); + } + createAskQuestion(rl, messages, apiKey, options, chatService)(); } }); + }; +} + +function displayModels(models: any): void { + console.log(chalk.bold('Available Chat Models:')); + console.log(chalk.dim('─'.repeat(70))); + console.log(chalk.dim('MODEL ID'.padEnd(40)) + chalk.dim('CAPABILITIES')); + console.log(chalk.dim('─'.repeat(70))); + + const activeModels = models.data.filter((model: any) => model.active === true); + + for (const model of activeModels) { + const capabilities = []; + if (model.capabilities.vision) capabilities.push('vision'); + if (model.capabilities.function_calling) capabilities.push('function_calling'); + if (model.capabilities.json_mode) capabilities.push('json_mode'); + + const modelId = `${model.owned_by.toLowerCase()}/${model.id}`.padEnd(40); + console.log(modelId + capabilities.join(', ')); + } +} + +async function displayResponse(response: any): Promise { + if (!response || !response.choices || !response.choices[0] || !response.choices[0].message) { + console.error(chalk.red('Error: Unexpected response format from API')); + console.error(chalk.red('Response:', JSON.stringify(response, null, 2))); + throw new Error('Unexpected response format from API'); + } + + const assistantMessage = response.choices[0].message.content; + if (containsMarkdown(assistantMessage)) { + console.log(renderMarkdown(assistantMessage)); + } else { + console.log(assistantMessage); + } +} + +async function handleNonStreamingCompletion( + chatService: ChatService, + completionOptions: ChatCompletionOptions, +): Promise { + const response = await chatService.createCompletion(completionOptions); + await displayResponse(response); +} + +async function handleStreamingCompletion( + chatService: ChatService, + completionOptions: ChatCompletionOptions, +): Promise { + let assistantResponse = ''; + + completionOptions.onChunk = (chunk: any) => { + if ( + chunk.choices && + chunk.choices[0] && + chunk.choices[0].delta && + chunk.choices[0].delta.content + ) { + const content = chunk.choices[0].delta.content; + try { + process.stdout.write(content); + } catch (error: any) { + if (error.code === 'EPIPE') { + return; + } + throw error; + } + assistantResponse += content; + } + }; + + try { + await chatService.createCompletion(completionOptions); + } catch (streamError) { + console.error(chalk.red('\nStreaming error:'), streamError); + console.log(chalk.yellow('Falling back to non-streaming mode...')); + completionOptions.stream = false; + delete completionOptions.onChunk; + + const response = await chatService.createCompletion(completionOptions); + if (response && response.choices && response.choices[0] && response.choices[0].message) { + assistantResponse = response.choices[0].message.content; + console.log(assistantResponse); + } + } + + console.log(); +} + +async function resolveApiKey(options: any): Promise { + const environmentApiKey = process.env.BERGET_API_KEY; + if (environmentApiKey) { + console.log(chalk.dim('Using API key from BERGET_API_KEY environment variable')); + + if (process.argv.includes('--debug')) { + console.log( + chalk.yellow(`DEBUG: API key from env starts with: ${environmentApiKey.slice(0, 4)}...`), + ); + } + return environmentApiKey; + } + + if (options.apiKey) { + console.log(chalk.dim('Using API key from command line argument')); + return options.apiKey; + } + + if (!options.apiKeyId) { + return resolveDefaultApiKey(); + } + + return undefined; +} + +async function resolveApiKeyForList(options: any): Promise { + const apiKey = options.apiKey; + let apiKeyId = options.apiKeyId; + + if (!apiKey && !apiKeyId) { + const defaultApiKeyManager = DefaultApiKeyManager.getInstance(); + const defaultApiKeyData = defaultApiKeyManager.getDefaultApiKeyData(); + + if (defaultApiKeyData) { + apiKeyId = defaultApiKeyData.id; + console.log(chalk.dim(`Using default API key: ${defaultApiKeyData.name}`)); + } + } + + if (apiKeyId && !apiKey) { + return resolveApiKeyFromId(apiKeyId); + } + + return apiKey; +} + +async function resolveApiKeyFromId(apiKeyId: string): Promise { + try { + const apiKeyService = ApiKeyService.getInstance(); + const keys = await apiKeyService.list(); + const selectedKey = keys.find((key) => key.id.toString() === apiKeyId); + + if (!selectedKey) { + console.log( + chalk.yellow(`API key with ID ${apiKeyId} not found. Using default authentication.`), + ); + return undefined; + } + + console.log(chalk.dim(`Using API key: ${selectedKey.name}`)); + + const shouldRotate = await clackConfirm({ + message: `To use API key "${selectedKey.name}", it needs to be rotated. This will invalidate the current key. Continue?`, + }); + if (isCancel(shouldRotate)) { + console.log(chalk.yellow('Operation cancelled.')); + return undefined; + } + + if (shouldRotate) { + const rotatedKey = await apiKeyService.rotate(apiKeyId); + console.log(chalk.green(`API key "${selectedKey.name}" rotated successfully.`)); + return rotatedKey.key; + } else { + console.log(chalk.yellow('Using default authentication instead.')); + return undefined; + } + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + const isAuthError = + errorMessage.includes('Unauthorized') || + errorMessage.includes('Authentication failed') || + errorMessage.includes('AUTH_FAILED'); + + if (isAuthError) { + console.log(chalk.yellow('Authentication required. Please run `berget auth login` first.')); + } else { + console.error(chalk.red('Error fetching API key:')); + console.error(error); + } + console.log(chalk.yellow('Using default authentication instead.')); + return undefined; + } +} + +async function resolveDefaultApiKey(): Promise { + try { + const defaultApiKeyManager = DefaultApiKeyManager.getInstance(); + const defaultApiKeyData = defaultApiKeyManager.getDefaultApiKeyData(); + + if (defaultApiKeyData) { + if (defaultApiKeyData.key) { + console.log(chalk.dim(`Using default API key: ${defaultApiKeyData.name}`)); + return defaultApiKeyData.key; + } + + console.log( + chalk.yellow( + `Default API key "${defaultApiKeyData.name}" exists but the key value is missing.`, + ), + ); + console.log( + chalk.yellow(`Try rotating the key with: berget api-keys rotate ${defaultApiKeyData.id}`), + ); + return undefined; + } + + console.log(chalk.yellow('No default API key set.')); + const apiKey = await defaultApiKeyManager.promptForDefaultApiKey(); + + if (!apiKey) { + console.log(chalk.red('Error: An API key is required to use the chat command.')); + console.log(chalk.yellow('You can:')); + console.log( + chalk.yellow('1. Create an API key with: berget api-keys create --name "My Key"'), + ); + console.log(chalk.yellow('2. Set a default API key with: berget api-keys set-default ')); + console.log(chalk.yellow('3. Provide an API key with the --api-key option')); + return undefined; + } + + return apiKey; + } catch (error) { + if (process.argv.includes('--debug')) { + console.log(chalk.yellow('DEBUG: Error checking default API key:')); + console.log(chalk.yellow(String(error))); + } + return undefined; + } +} + +async function resolveInputMessage(message: string | undefined): Promise { + let inputMessage = message; + let stdinContent = ''; + + if (!process.stdin.isTTY) { + const chunks = []; + for await (const chunk of process.stdin) { + chunks.push(chunk); + } + stdinContent = Buffer.concat(chunks).toString('utf8').trim(); + } + + if (stdinContent && message) { + inputMessage = `${stdinContent}\n\n${message}`; + } else if (stdinContent && !message) { + inputMessage = stdinContent; + } + + return inputMessage; +} + +async function runInteractiveChat( + options: any, + apiKey: string | undefined, + messages: ChatMessage[], +): Promise { + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + }); + + console.log(chalk.cyan('Chat with Berget AI (type "exit" to quit)')); + console.log(chalk.cyan('----------------------------------------')); + + const chatService = ChatService.getInstance(); + const askQuestion = createAskQuestion(rl, messages, apiKey, options, chatService); + askQuestion(); +} + +async function runInteractiveNonStreaming( + chatService: ChatService, + completionOptions: ChatCompletionOptions, + messages: ChatMessage[], +): Promise { + const response = await chatService.createCompletion(completionOptions); + + if (!response || !response.choices || !response.choices[0] || !response.choices[0].message) { + console.error(chalk.red('Error: Unexpected response format from API')); + console.error(chalk.red('Response:', JSON.stringify(response, null, 2))); + throw new Error('Unexpected response format from API'); + } + + const assistantMessage = response.choices[0].message.content; + messages.push({ content: assistantMessage, role: 'assistant' }); + + console.log(chalk.blue('Assistant: ')); + if (containsMarkdown(assistantMessage)) { + console.log(renderMarkdown(assistantMessage)); + } else { + console.log(assistantMessage); + } + + console.log(); +} + +async function runInteractiveStreaming( + chatService: ChatService, + completionOptions: ChatCompletionOptions, + messages: ChatMessage[], +): Promise { + let assistantResponse = ''; + console.log(chalk.blue('Assistant: ')); + + completionOptions.onChunk = (chunk: any) => { + if ( + chunk.choices && + chunk.choices[0] && + chunk.choices[0].delta && + chunk.choices[0].delta.content + ) { + const content = chunk.choices[0].delta.content; + try { + process.stdout.write(content); + } catch (error: any) { + if (error.code === 'EPIPE') { + return; + } + throw error; + } + assistantResponse += content; + } + }; + + try { + await chatService.createCompletion(completionOptions); + } catch (streamError) { + console.error(chalk.red('\nStreaming error:'), streamError); + console.log(chalk.yellow('Falling back to non-streaming mode...')); + completionOptions.stream = false; + delete completionOptions.onChunk; + + const response = await chatService.createCompletion(completionOptions); + if (response && response.choices && response.choices[0] && response.choices[0].message) { + assistantResponse = response.choices[0].message.content; + console.log(assistantResponse); + } + } + + console.log('\n'); + messages.push({ content: assistantResponse, role: 'assistant' }); +} + +async function runSingleShotChat( + inputMessage: string, + options: any, + apiKey: string | undefined, +): Promise { + const messages: ChatMessage[] = []; + + if (options.system) { + messages.push({ content: options.system, role: 'system' }); + } + + messages.push({ content: inputMessage, role: 'user' }); + + try { + const chatService = ChatService.getInstance(); + const completionOptions = buildCompletionOptions(options, messages, apiKey); + + if (completionOptions.stream) { + await handleStreamingCompletion(chatService, completionOptions); + } else { + await handleNonStreamingCompletion(chatService, completionOptions); + } + } catch (error) { + console.error(chalk.red('Error: Failed to get response')); + if (error instanceof Error) { + console.error(chalk.red(error.message)); + } + process.exitCode = 1; + } +} + +async function verifyAuthentication(apiKey: string | undefined): Promise { + if (apiKey) { + return true; + } + + try { + AuthService.getInstance(); + return true; + } catch { + console.log(chalk.red('Error: Authentication required for chat')); + console.log(chalk.yellow('Please either:')); + console.log(chalk.yellow('1. Log in with `berget auth login`')); + console.log(chalk.yellow('2. Provide an API key with `--api-key`')); + console.log(chalk.yellow('3. Provide an API key ID with `--api-key-id`')); + console.log(chalk.yellow('4. Set a default API key with `berget api-keys set-default `')); + return false; + } } diff --git a/src/commands/code/auth-sync.ts b/src/commands/code/auth-sync.ts index 0ed2231..b5acb84 100644 --- a/src/commands/code/auth-sync.ts +++ b/src/commands/code/auth-sync.ts @@ -71,106 +71,19 @@ export async function configureAuth( return { authenticated: false }; } - // Check Berget Code seat const jwtPayload = decodeJwtPayload(cliAuth.access_token); - // If we can't decode the JWT, sync OAuth anyway — the tokens are valid even if - // we can't verify the subscription role. Let the tool handle authorization. if (!jwtPayload) { - const s = prompter.spinner(); - s.start('Authenticating with Berget AI...'); - try { - await syncOAuthToTool(files, homeDir, tool, cliAuth); - s.stop('Authenticated.'); - } catch (error) { - s.stop('Authentication failed.'); - throw error; - } - prompter.note( - 'Warning: Could not verify Berget Code subscription status.\nIf you do not have a subscription, the tool may show an authorization error.', - 'Authentication', - ); - return { authenticated: true }; + return handleUndecodableJwt(prompter, files, homeDir, tool, cliAuth); } - // JWT decoded successfully — check subscription seat const hasSeat = hasBergetCodeSeat(cliAuth.access_token); if (hasSeat) { - // Case B: Has seat — ask how to authenticate - const method = await prompter.select<'api_key' | 'subscription'>({ - message: 'You have a Berget Code subscription. How do you want to authenticate?', - options: [ - { label: 'Use my Berget Code subscription', value: 'subscription' }, - { label: 'Use an API key instead', value: 'api_key' }, - ], - }); - - if (method === 'subscription') { - const s = prompter.spinner(); - s.start('Authenticating with Berget AI via subscription...'); - try { - await syncOAuthToTool(files, homeDir, tool, cliAuth); - s.stop('Authenticated.'); - } catch (error) { - s.stop('Authentication failed.'); - throw error; - } - return { authenticated: true }; - } - - // Create API key instead - const s = prompter.spinner(); - s.start('Creating API key...'); - try { - const { key } = await apiKeyService.create({ - description: 'Created by berget code init', - name: `${tool === 'opencode' ? 'OpenCode' : 'Pi'} (created by berget CLI)`, - }); - await syncApiKeyToTool(files, homeDir, tool, key); - s.stop('API key created and saved.'); - return { authenticated: true }; - } catch (error: any) { - s.stop('API key creation failed.'); - throw new FatalError( - error?.message || - 'Could not create API key. Please create one manually with `berget api-keys create`.', - ); - } - } - - // No Berget Code seat — prompt for API key creation - const shouldCreate = await prompter.confirm({ - initialValue: true, - message: 'You do not have a Berget Code subscription. Would you like to create a new API key?', - }); - - if (shouldCreate) { - const s = prompter.spinner(); - s.start('Creating API key...'); - try { - const { key } = await apiKeyService.create({ - description: 'Created by berget code init', - name: `${tool === 'opencode' ? 'OpenCode' : 'Pi'} (created by berget CLI)`, - }); - await syncApiKeyToTool(files, homeDir, tool, key); - s.stop('API key created and saved.'); - return { authenticated: true }; - } catch (error: any) { - s.stop('API key creation failed.'); - throw new FatalError( - error?.message || - 'Could not create API key. Please create one manually with `berget api-keys create`.', - ); - } + return handleHasSeat(prompter, apiKeyService, files, homeDir, tool, cliAuth); } - // Case D: Declined - prompter.note( - 'Authentication skipped. You\'ll need to set up authentication manually:\n1. Run: berget api-keys create --name "My Key"\n2. Set BERGET_API_KEY environment variable, or\n3. Run `berget auth login` and try again', - 'Authentication', - ); - return { authenticated: false }; + return handleNoSeat(prompter, apiKeyService, files, homeDir, tool); } export async function ensureCliAuth( @@ -325,3 +238,107 @@ export async function syncOAuthToTool( await files.writeFile(authPath, JSON.stringify(updated, null, 2) + '\n'); await files.chmod(authPath, 0o600); } + +async function createAndSyncApiKey( + prompter: Prompter, + apiKeyService: ApiKeyServicePort, + files: FileStore, + homeDir: string, + tool: 'opencode' | 'pi', +): Promise { + const s = prompter.spinner(); + s.start('Creating API key...'); + try { + const { key } = await apiKeyService.create({ + description: 'Created by berget code init', + name: `${tool === 'opencode' ? 'OpenCode' : 'Pi'} (created by berget CLI)`, + }); + await syncApiKeyToTool(files, homeDir, tool, key); + s.stop('API key created and saved.'); + return { authenticated: true }; + } catch (error: any) { + s.stop('API key creation failed.'); + throw new FatalError( + error?.message || + 'Could not create API key. Please create one manually with `berget api-keys create`.', + ); + } +} + +async function handleHasSeat( + prompter: Prompter, + apiKeyService: ApiKeyServicePort, + files: FileStore, + homeDir: string, + tool: 'opencode' | 'pi', + cliAuth: CliAuth, +): Promise { + const method = await prompter.select<'api_key' | 'subscription'>({ + message: 'You have a Berget Code subscription. How do you want to authenticate?', + options: [ + { label: 'Use my Berget Code subscription', value: 'subscription' }, + { label: 'Use an API key instead', value: 'api_key' }, + ], + }); + + if (method === 'subscription') { + const s = prompter.spinner(); + s.start('Authenticating with Berget AI via subscription...'); + try { + await syncOAuthToTool(files, homeDir, tool, cliAuth); + s.stop('Authenticated.'); + } catch (error) { + s.stop('Authentication failed.'); + throw error; + } + return { authenticated: true }; + } + + return createAndSyncApiKey(prompter, apiKeyService, files, homeDir, tool); +} + +async function handleNoSeat( + prompter: Prompter, + apiKeyService: ApiKeyServicePort, + files: FileStore, + homeDir: string, + tool: 'opencode' | 'pi', +): Promise { + const shouldCreate = await prompter.confirm({ + initialValue: true, + message: 'You do not have a Berget Code subscription. Would you like to create a new API key?', + }); + + if (shouldCreate) { + return createAndSyncApiKey(prompter, apiKeyService, files, homeDir, tool); + } + + prompter.note( + 'Authentication skipped. You\'ll need to set up authentication manually:\n1. Run: berget api-keys create --name "My Key"\n2. Set BERGET_API_KEY environment variable, or\n3. Run `berget auth login` and try again', + 'Authentication', + ); + return { authenticated: false }; +} + +async function handleUndecodableJwt( + prompter: Prompter, + files: FileStore, + homeDir: string, + tool: 'opencode' | 'pi', + cliAuth: CliAuth, +): Promise { + const s = prompter.spinner(); + s.start('Authenticating with Berget AI...'); + try { + await syncOAuthToTool(files, homeDir, tool, cliAuth); + s.stop('Authenticated.'); + } catch (error) { + s.stop('Authentication failed.'); + throw error; + } + prompter.note( + 'Warning: Could not verify Berget Code subscription status.\nIf you do not have a subscription, the tool may show an authorization error.', + 'Authentication', + ); + return { authenticated: true }; +} diff --git a/src/commands/code/init.ts b/src/commands/code/init.ts index 98ccafa..44b93c3 100644 --- a/src/commands/code/init.ts +++ b/src/commands/code/init.ts @@ -83,76 +83,13 @@ export async function runInit(deps: WizardDeps): Promise { ], }); - // Check if the selected tool is installed const toolCheck = await checkTool(commands, tool); - let toolConfigured = toolCheck.installed; - - if (!toolCheck.installed) { - // Non-TTY guard: throw instead of exit so callers (including tests) - // can decide how to handle the failure. - if (isTty === false) { - throw new FatalError( - `${toolCheck.name} is not installed.\nInstall it first:\n ${toolCheck.installCommand}\nDocs: ${toolCheck.docsUrl}`, - ); - } - - const action = await promptForMissingTool(prompter, toolCheck); - - if (action === 'exit') { - throw new CancelledError(); - } - - if (action === 'retry') { - // Re-check once. If still missing, show prompt again. - const recheck = await checkTool(commands, tool); - if (recheck.installed) { - toolConfigured = true; - } else { - const secondAction = await promptForMissingTool(prompter, recheck); - if (secondAction === 'exit') throw new CancelledError(); - if (secondAction === 'continue') toolConfigured = false; - if (secondAction === 'retry') { - // One more check, then give up and treat as "continue" - const finalCheck = await checkTool(commands, tool); - toolConfigured = finalCheck.installed; - } - } - } - - if (action === 'continue') { - toolConfigured = false; - } - } + const toolConfigured = await resolveToolConfigured(commands, tool, toolCheck, isTty, prompter); const scope = await prompter.select<'global' | 'project'>({ initialValue: 'project', message: 'Where should the configuration apply?', - options: [ - { - hint: - tool === 'opencode' - ? ocState.project - ? 'Already configured' - : 'opencode.json in current directory' - : piState.project - ? 'Already configured' - : '.pi/settings.json in current directory', - label: 'This project only', - value: 'project', - }, - { - hint: - tool === 'opencode' - ? ocState.global - ? 'Already configured' - : '~/.config/opencode/opencode.json' - : piState.global - ? 'Already configured' - : '~/.pi/agent/settings.json', - label: 'Globally for all projects', - value: 'global', - }, - ], + options: buildScopeOptions(tool, ocState, piState), }); prompter.log('step', 'Configuring authentication...'); @@ -163,33 +100,11 @@ export async function runInit(deps: WizardDeps): Promise { cliAuth, ); - // Only configure the tool if it's installed (or user chose retry and it was found) if (toolConfigured) { - if (tool === 'opencode') { - await initOpenCode({ commands, cwd, files, homeDir, prompter, scope }); - await initOpenCodeAgents({ cwd, files, homeDir, prompter, scope }); - } else { - await initPi({ commands, cwd, files, homeDir, prompter, scope }); - await initPiAgent({ cwd, files, homeDir, prompter, scope }); - } + await configureTool(commands, cwd, files, homeDir, prompter, tool, scope); } - const nextSteps = toolConfigured - ? authResult.authenticated - ? tool === 'opencode' - ? "You're all set!\n\n1. Run: opencode\n2. Select model: /models" - : "You're all set!\n\n1. Restart Pi or run /reload\n2. Select model: /model" - : tool === 'opencode' - ? 'Next steps:\n\n1. Run: opencode\n2. Type: /connect\n3. Choose your auth method:\n • "Login with Berget" — Berget Code plan\n • "Enter Berget API Key manually"\n • (or set BERGET_API_KEY env var)\n4. Select model: /models' - : 'Next steps:\n\n1. Restart Pi or run /reload\n2. Type: /login\n3. Choose your auth method:\n • "Use a subscription" → Berget AI\n • (or set BERGET_API_KEY env var)\n4. Select model: /model' - : authResult.authenticated - ? tool === 'opencode' - ? `Auth is configured. Next steps:\n\n1. Install OpenCode:\n ${toolCheck.installCommand}\n2. Run: opencode\n3. Select model: /models` - : `Auth is configured. Next steps:\n\n1. Install Pi:\n ${toolCheck.installCommand}\n2. Run: pi\n3. Select model: /model` - : tool === 'opencode' - ? `Next steps:\n\n1. Install OpenCode:\n ${toolCheck.installCommand}\n2. Run: opencode\n3. Authenticate with Berget AI` - : `Next steps:\n\n1. Install Pi:\n ${toolCheck.installCommand}\n2. Run: pi\n3. Authenticate with Berget AI`; - + const nextSteps = buildNextSteps(tool, toolCheck, toolConfigured, authResult.authenticated); const toolName = tool === 'opencode' ? 'OpenCode' : 'Pi'; const docsUrl = tool === 'opencode' @@ -219,3 +134,127 @@ export async function runInitCommand(): Promise { if (result.stderr) console.error(result.stderr); process.exitCode = result.exitCode; } + +function buildNextSteps( + tool: 'opencode' | 'pi', + toolCheck: { installCommand: string }, + toolConfigured: boolean, + authenticated: boolean, +): string { + if (toolConfigured && authenticated) { + return tool === 'opencode' + ? "You're all set!\n\n1. Run: opencode\n2. Select model: /models" + : "You're all set!\n\n1. Restart Pi or run /reload\n2. Select model: /model"; + } + + if (toolConfigured && !authenticated) { + return tool === 'opencode' + ? 'Next steps:\n\n1. Run: opencode\n2. Type: /connect\n3. Choose your auth method:\n • "Login with Berget" — Berget Code plan\n • "Enter Berget API Key manually"\n • (or set BERGET_API_KEY env var)\n4. Select model: /models' + : 'Next steps:\n\n1. Restart Pi or run /reload\n2. Type: /login\n3. Choose your auth method:\n • "Use a subscription" → Berget AI\n • (or set BERGET_API_KEY env var)\n4. Select model: /model'; + } + + if (!toolConfigured && authenticated) { + return tool === 'opencode' + ? `Auth is configured. Next steps:\n\n1. Install OpenCode:\n ${toolCheck.installCommand}\n2. Run: opencode\n3. Select model: /models` + : `Auth is configured. Next steps:\n\n1. Install Pi:\n ${toolCheck.installCommand}\n2. Run: pi\n3. Select model: /model`; + } + + return tool === 'opencode' + ? `Next steps:\n\n1. Install OpenCode:\n ${toolCheck.installCommand}\n2. Run: opencode\n3. Authenticate with Berget AI` + : `Next steps:\n\n1. Install Pi:\n ${toolCheck.installCommand}\n2. Run: pi\n3. Authenticate with Berget AI`; +} + +function buildScopeOptions( + tool: 'opencode' | 'pi', + ocState: { global: boolean; project: boolean }, + piState: { global: boolean; project: boolean }, +): Array<{ hint: string; label: string; value: 'global' | 'project' }> { + const projectHint = + tool === 'opencode' + ? ocState.project + ? 'Already configured' + : 'opencode.json in current directory' + : piState.project + ? 'Already configured' + : '.pi/settings.json in current directory'; + + const globalHint = + tool === 'opencode' + ? ocState.global + ? 'Already configured' + : '~/.config/opencode/opencode.json' + : piState.global + ? 'Already configured' + : '~/.pi/agent/settings.json'; + + return [ + { hint: projectHint, label: 'This project only', value: 'project' }, + { hint: globalHint, label: 'Globally for all projects', value: 'global' }, + ]; +} + +async function configureTool( + commands: CommandRunner, + cwd: string, + files: FileStore, + homeDir: string, + prompter: Prompter, + tool: 'opencode' | 'pi', + scope: 'global' | 'project', +): Promise { + if (tool === 'opencode') { + await initOpenCode({ commands, cwd, files, homeDir, prompter, scope }); + await initOpenCodeAgents({ cwd, files, homeDir, prompter, scope }); + } else { + await initPi({ commands, cwd, files, homeDir, prompter, scope }); + await initPiAgent({ cwd, files, homeDir, prompter, scope }); + } +} + +async function resolveToolConfigured( + commands: CommandRunner, + tool: 'opencode' | 'pi', + toolCheck: { + description: string; + docsUrl: string; + installCommand: string; + installed: boolean; + name: string; + }, + isTty: boolean | undefined, + prompter: Prompter, +): Promise { + if (toolCheck.installed) { + return true; + } + + if (isTty === false) { + throw new FatalError( + `${toolCheck.name} is not installed.\nInstall it first:\n ${toolCheck.installCommand}\nDocs: ${toolCheck.docsUrl}`, + ); + } + + const action = await promptForMissingTool(prompter, toolCheck); + + if (action === 'exit') { + throw new CancelledError(); + } + + if (action === 'continue') { + return false; + } + + // action === 'retry' + const recheck = await checkTool(commands, tool); + if (recheck.installed) { + return true; + } + + const secondAction = await promptForMissingTool(prompter, recheck); + if (secondAction === 'exit') throw new CancelledError(); + if (secondAction === 'continue') return false; + + // secondAction === 'retry' + const finalCheck = await checkTool(commands, tool); + return finalCheck.installed; +} diff --git a/src/services/api-key-service.ts b/src/services/api-key-service.ts index 041c9f8..13dc6a0 100644 --- a/src/services/api-key-service.ts +++ b/src/services/api-key-service.ts @@ -55,72 +55,14 @@ export class ApiKeyService { */ public async create(options: CreateApiKeyOptions): Promise { try { - // Validate input before sending request - if (!options.name || options.name.trim().length === 0) { - throw new Error('API key name is required and cannot be empty'); - } - - if (options.name.length > 100) { - throw new Error('API key name must be 100 characters or less'); - } - - if (options.description && options.description.length > 500) { - throw new Error('API key description must be 500 characters or less'); - } + this.validateCreateOptions(options); const { data, error } = await this.client.POST('/v1/api-keys', { body: options, }); if (error) { - // Enhanced error handling with specific troubleshooting - - // Handle specific error cases - if (typeof error === 'object' && error !== null) { - const errorObject = error as any; - - if (errorObject.error?.code === 'API_KEY_CREATION_FAILED') { - let detailedMessage = 'Failed to create API key. This could be due to:\n'; - detailedMessage += '• Account limits or quota restrictions\n'; - detailedMessage += '• Insufficient permissions for API key creation\n'; - detailedMessage += '• Temporary server issues\n'; - detailedMessage += '• Billing or subscription issues\n\n'; - detailedMessage += 'Troubleshooting steps:\n'; - detailedMessage += '1. Check if you have reached your API key limit\n'; - detailedMessage += '2. Verify your account has API key creation permissions\n'; - detailedMessage += '3. Check your billing status and subscription\n'; - detailedMessage += '4. Try again in a few minutes if this is a temporary issue\n'; - detailedMessage += '5. Contact support if the problem persists'; - - throw new Error(detailedMessage); - } - - if (errorObject.error?.code === 'USER_NOT_FOUND') { - throw new Error( - 'Before you can create API keys, you need to finish setting up your account.\n\nCheck your inbox for a verification email from Berget AI and complete the account setup.', - ); - } - - if (errorObject.error?.code === 'QUOTA_EXCEEDED') { - throw new Error( - 'You have reached your API key limit. Please delete existing keys or contact support to increase your quota.', - ); - } - - if (errorObject.error?.code === 'INSUFFICIENT_PERMISSIONS') { - throw new Error( - 'Your account does not have permission to create API keys. Please contact your administrator.', - ); - } - - if (errorObject.error?.code === 'BILLING_REQUIRED') { - throw new Error( - 'A valid billing method is required to create API keys. Please add a payment method.', - ); - } - } - - throw new Error(JSON.stringify(error)); + throw this.mapCreateError(error); } if (!data) { @@ -129,28 +71,7 @@ export class ApiKeyService { return data; } catch (error) { - // Add additional context for common issues - if (error instanceof Error) { - if (error.message.includes('ECONNREFUSED')) { - throw new Error('Cannot connect to Berget API. Please check your internet connection.'); - } - - if (error.message.includes('ENOTFOUND')) { - throw new Error('Cannot resolve Berget API hostname. Please check your DNS settings.'); - } - - if (error.message.includes('401') || error.message.includes('Unauthorized')) { - throw new Error('Authentication failed. Please run `berget auth login` to log in again.'); - } - - if (error.message.includes('403')) { - throw new Error( - 'Access forbidden. Your account may not have permission to create API keys.', - ); - } - } - - throw error; + throw this.enhanceNetworkError(error); } } @@ -219,4 +140,100 @@ export class ApiKeyService { throw error; } } + + private buildCreationFailedMessage(): string { + return ( + 'Failed to create API key. This could be due to:\n' + + '• Account limits or quota restrictions\n' + + '• Insufficient permissions for API key creation\n' + + '• Temporary server issues\n' + + '• Billing or subscription issues\n\n' + + 'Troubleshooting steps:\n' + + '1. Check if you have reached your API key limit\n' + + '2. Verify your account has API key creation permissions\n' + + '3. Check your billing status and subscription\n' + + '4. Try again in a few minutes if this is a temporary issue\n' + + '5. Contact support if the problem persists' + ); + } + + private enhanceNetworkError(error: unknown): Error { + if (!(error instanceof Error)) { + throw error; + } + + const message = error.message; + + if (message.includes('ECONNREFUSED')) { + return new Error('Cannot connect to Berget API. Please check your internet connection.'); + } + + if (message.includes('ENOTFOUND')) { + return new Error('Cannot resolve Berget API hostname. Please check your DNS settings.'); + } + + if (message.includes('401') || message.includes('Unauthorized')) { + return new Error('Authentication failed. Please run `berget auth login` to log in again.'); + } + + if (message.includes('403')) { + return new Error( + 'Access forbidden. Your account may not have permission to create API keys.', + ); + } + + throw error; + } + + private mapCreateError(error: unknown): Error { + if (typeof error !== 'object' || error === null) { + return new Error(JSON.stringify(error)); + } + + const errorObject = error as any; + const code = errorObject.error?.code; + + switch (code) { + case 'API_KEY_CREATION_FAILED': { + return new Error(this.buildCreationFailedMessage()); + } + case 'BILLING_REQUIRED': { + return new Error( + 'A valid billing method is required to create API keys. Please add a payment method.', + ); + } + case 'INSUFFICIENT_PERMISSIONS': { + return new Error( + 'Your account does not have permission to create API keys. Please contact your administrator.', + ); + } + case 'QUOTA_EXCEEDED': { + return new Error( + 'You have reached your API key limit. Please delete existing keys or contact support to increase your quota.', + ); + } + case 'USER_NOT_FOUND': { + return new Error( + 'Before you can create API keys, you need to finish setting up your account.\n\nCheck your inbox for a verification email from Berget AI and complete the account setup.', + ); + } + default: { + return new Error(JSON.stringify(error)); + } + } + } + + private validateCreateOptions(options: CreateApiKeyOptions): void { + if (!options.name || options.name.trim().length === 0) { + throw new Error('API key name is required and cannot be empty'); + } + + if (options.name.length > 100) { + throw new Error('API key name must be 100 characters or less'); + } + + if (options.description && options.description.length > 500) { + throw new Error('API key description must be 500 characters or less'); + } + } } diff --git a/src/services/chat-service.ts b/src/services/chat-service.ts index 49f9c9c..e794104 100644 --- a/src/services/chat-service.ts +++ b/src/services/chat-service.ts @@ -1,3 +1,5 @@ +import { ReadableStreamDefaultReader } from 'node:stream/web'; + import { createAuthenticatedClient } from '../client.js'; import { logger } from '../utils/logger.js'; @@ -17,6 +19,12 @@ export interface ChatMessage { role: 'assistant' | 'system' | 'user'; } +interface StreamState { + buffer: string; + fullContent: string; + fullResponse: any; +} + /** * Service for interacting with the chat API * Command group: chat @@ -58,24 +66,7 @@ export class ChatService { optionsCopy.messages = []; } - logger.debug('Starting createCompletion with options:'); - try { - logger.debug( - JSON.stringify( - { - ...optionsCopy, - apiKey: optionsCopy.apiKey ? '***' : undefined, - messages: optionsCopy.messages - ? `${optionsCopy.messages.length} messages` - : '0 messages', - }, - null, - 2, - ), - ); - } catch (error) { - logger.error('Failed to stringify options:', error); - } + this.logCompletionOptions(optionsCopy); const headers: Record = {}; @@ -83,41 +74,16 @@ export class ChatService { headers['Authorization'] = optionsCopy.apiKey; } - // Set default model if not provided if (!optionsCopy.model) { logger.debug('No model specified, using default: google/gemma-3-27b-it'); optionsCopy.model = 'google/gemma-3-27b-it'; } - logger.debug('Chat completion options:'); - logger.debug( - JSON.stringify( - { - ...optionsCopy, - apiKey: optionsCopy.apiKey ? '***' : undefined, - }, - null, - 2, - ), - ); + this.logCompletionOptions(optionsCopy); return this.executeCompletion(optionsCopy, headers); } catch (error) { - let errorMessage = 'Failed to create chat completion'; - - if (error instanceof Error) { - try { - const parsedError = JSON.parse(error.message); - if (parsedError.error && parsedError.error.message) { - errorMessage = `Chat error: ${parsedError.error.message}`; - } - } catch { - errorMessage = `Chat error: ${error.message}`; - } - } - - logger.error(errorMessage); - throw new Error(errorMessage); + throw this.formatCompletionError(error); } } @@ -127,40 +93,53 @@ export class ChatService { */ public async listModels(apiKey?: string): Promise { try { - const headers = apiKey ? { Authorization: apiKey } : {}; + let data: any; + let error: any; if (apiKey) { - const { data, error } = await this.client.GET('/v1/models', { headers }); - if (error) throw new Error(JSON.stringify(error)); - return data; + const result = await this.client.GET('/v1/models', { headers: { Authorization: apiKey } }); + data = result.data; + error = result.error; } else { - const { data, error } = await this.client.GET('/v1/models'); - if (error) throw new Error(JSON.stringify(error)); - return data; + const result = await this.client.GET('/v1/models'); + data = result.data; + error = result.error; } + + if (error) throw new Error(JSON.stringify(error)); + return data; } catch (error) { - let errorMessage = 'Failed to list models'; - - if (error instanceof Error) { - try { - const parsedError = JSON.parse(error.message); - if (parsedError.error) { - errorMessage = `Models error: ${ - typeof parsedError.error === 'string' - ? parsedError.error - : parsedError.error.message || JSON.stringify(parsedError.error) - }`; - } - } catch { - errorMessage = `Models error: ${error.message}`; - } - } + throw this.formatListModelsError(error); + } + } - logger.error(errorMessage); - throw new Error(errorMessage); + private accumulateContent(parsedData: any, state: StreamState): void { + if ( + parsedData.choices && + parsedData.choices[0] && + parsedData.choices[0].delta && + parsedData.choices[0].delta.content + ) { + state.fullContent += parsedData.choices[0].delta.content; } } + private buildStreamResult(state: StreamState): any { + if (state.fullResponse) { + if (state.fullContent) { + state.fullResponse.choices[0].message = { + content: state.fullContent, + role: 'assistant', + }; + } + return state.fullResponse; + } + + return { + choices: [{ message: { content: state.fullContent, role: 'assistant' } }], + }; + } + /** * Execute the completion request with the provided options * @param options The completion options @@ -237,6 +216,46 @@ export class ChatService { } } + private formatCompletionError(error: unknown): Error { + let errorMessage = 'Failed to create chat completion'; + + if (error instanceof Error) { + try { + const parsedError = JSON.parse(error.message); + if (parsedError.error && parsedError.error.message) { + errorMessage = `Chat error: ${parsedError.error.message}`; + } + } catch { + errorMessage = `Chat error: ${error.message}`; + } + } + + logger.error(errorMessage); + return new Error(errorMessage); + } + + private formatListModelsError(error: unknown): Error { + let errorMessage = 'Failed to list models'; + + if (error instanceof Error) { + try { + const parsedError = JSON.parse(error.message); + if (parsedError.error) { + errorMessage = `Models error: ${ + typeof parsedError.error === 'string' + ? parsedError.error + : parsedError.error.message || JSON.stringify(parsedError.error) + }`; + } + } catch { + errorMessage = `Models error: ${error.message}`; + } + } + + logger.error(errorMessage); + return new Error(errorMessage); + } + /** * Handle streaming response from the API * @param options Request options @@ -247,183 +266,222 @@ export class ChatService { options: any, headers: Record, ): Promise { - // Use the same base URL as the client const baseUrl = process.env.API_BASE_URL || 'https://api.berget.ai'; const url = new URL(`${baseUrl}/v1/chat/completions`); try { - logger.debug(`Making streaming request to: ${url.toString()}`); - logger.debug(`Headers:`, JSON.stringify(headers, null, 2)); - logger.debug(`Body:`, JSON.stringify(options, null, 2)); - - // Make fetch request directly to handle streaming - const response = await fetch(url.toString(), { - body: JSON.stringify(options), - headers: { - Accept: 'text/event-stream', - 'Content-Type': 'application/json', - ...headers, - }, - method: 'POST', - }); - - logger.debug(`Response status: ${response.status}`); + this.logStreamingRequest(url, headers, options); + const response = await this.makeStreamingRequest(url, options, headers); + const state: StreamState = { buffer: '', fullContent: '', fullResponse: null }; + await this.readStream(response.body!.getReader(), options, state); + return this.buildStreamResult(state); + } catch (error) { + logger.error(`Streaming error: ${error instanceof Error ? error.message : String(error)}`); + throw error; + } + } + + private hasBalancedBraces(jsonData: string): boolean { + let braceCount = 0; + let inString = false; + let escaped = false; + + for (const char of jsonData) { + if (escaped) { + escaped = false; + continue; + } + if (char === '\\') { + escaped = true; + continue; + } + if (char === '"') { + inString = !inString; + continue; + } + if (!inString && char === '{') { + braceCount++; + } else if (!inString && char === '}') { + braceCount--; + } + } + + return braceCount === 0; + } + + private logCompletionOptions(optionsCopy: ChatCompletionOptions): void { + try { logger.debug( - `Response headers:`, - JSON.stringify(Object.fromEntries(response.headers.entries()), null, 2), + JSON.stringify( + { + ...optionsCopy, + apiKey: optionsCopy.apiKey ? '***' : undefined, + messages: optionsCopy.messages + ? `${optionsCopy.messages.length} messages` + : '0 messages', + }, + null, + 2, + ), + ); + } catch (error) { + logger.error('Failed to stringify options:', error); + } + } + + private logParseError(error: unknown, jsonData: string): void { + logger.error(`Error parsing chunk: ${error}`); + const errorMsg = (error as any).message || ''; + const errorPos = Number.parseInt(errorMsg.match(/position (\d+)/)?.[1] || '0'); + if (errorPos > 0) { + const start = Math.max(0, errorPos - 20); + const end = Math.min(jsonData.length, errorPos + 20); + logger.error(`Context around error position ${errorPos}:`); + logger.error(`"${jsonData.substring(start, end)}"`); + logger.error( + `Character codes: ${[...jsonData.substring(start, end)].map((c) => c.charCodeAt(0)).join(' ')}`, ); + } + } + + private logStreamingRequest(url: URL, headers: Record, options: any): void { + logger.debug(`Making streaming request to: ${url.toString()}`); + logger.debug(`Headers:`, JSON.stringify(headers, null, 2)); + logger.debug(`Body:`, JSON.stringify(options, null, 2)); + } + + private async makeStreamingRequest( + url: URL, + options: any, + headers: Record, + ): Promise { + const response = await fetch(url.toString(), { + body: JSON.stringify(options), + headers: { + Accept: 'text/event-stream', + 'Content-Type': 'application/json', + ...headers, + }, + method: 'POST', + }); + + logger.debug(`Response status: ${response.status}`); + logger.debug( + `Response headers:`, + JSON.stringify(Object.fromEntries(response.headers.entries()), null, 2), + ); + + if (!response.ok) { + const errorText = await response.text(); + logger.error(`Stream request failed: ${response.status} ${response.statusText}`); + logger.error(`Error response: ${errorText}`); + throw new Error( + `Stream request failed: ${response.status} ${response.statusText} - ${errorText}`, + ); + } + + if (!response.body) { + throw new Error('No response body received'); + } + + return response; + } + + private processLines(lines: string[], options: any, state: StreamState): number { + let processedLines = 0; + + for (const [index, line] of lines.entries()) { + logger.debug(`Line ${index}: "${line}"`); + + if (!line.startsWith('data:')) { + continue; + } + + const jsonData = line.slice(5).trim(); + logger.debug(`Extracted JSON data: "${jsonData}"`); + + if (jsonData === '' || jsonData === '[DONE]') { + logger.debug(`Skipping empty data or [DONE] marker`); + processedLines = index + 1; + continue; + } - if (!response.ok) { - const errorText = await response.text(); - logger.error(`Stream request failed: ${response.status} ${response.statusText}`); - logger.error(`Error response: ${errorText}`); - throw new Error( - `Stream request failed: ${response.status} ${response.statusText} - ${errorText}`, + if (!jsonData.startsWith('{')) { + logger.warn( + `JSON data doesn't start with '{', might be partial: "${jsonData.slice(0, 50)}..."`, ); + break; } - if (!response.body) { - throw new Error('No response body received'); + if (!this.hasBalancedBraces(jsonData)) { + logger.warn( + `JSON braces don't balance, treating as partial: "${jsonData.slice(0, 50)}..."`, + ); + break; } - // Process the stream - const reader = response.body.getReader(); - const decoder = new TextDecoder(); - let fullContent = ''; - let fullResponse: any = null; - let buffer = ''; - - while (true) { - const { done, value } = await reader.read(); - if (done) break; - - const chunk = decoder.decode(value, { stream: true }); - logger.debug(`Received chunk: ${chunk.length} bytes`); - - buffer += chunk; - logger.debug(`Added chunk to buffer. Buffer length: ${buffer.length}`); - - const lines = buffer.split('\n'); - logger.debug(`Processing ${lines.length} lines from buffer`); - - let processedLines = 0; - - for (const [index, line] of lines.entries()) { - logger.debug(`Line ${index}: "${line}"`); - - if (line.startsWith('data:')) { - const jsonData = line.slice(5).trim(); - logger.debug(`Extracted JSON data: "${jsonData}"`); - - if (jsonData === '' || jsonData === '[DONE]') { - logger.debug(`Skipping empty data or [DONE] marker`); - processedLines = index + 1; - continue; - } - - if (!jsonData.startsWith('{')) { - logger.warn( - `JSON data doesn't start with '{', might be partial: "${jsonData.slice(0, 50)}..."`, - ); - break; - } - - let braceCount = 0; - let inString = false; - let escaped = false; - - for (const char of jsonData) { - if (escaped) { - escaped = false; - continue; - } - if (char === '\\') { - escaped = true; - continue; - } - if (char === '"') { - inString = !inString; - continue; - } - if (!inString && char === '{') { - braceCount++; - } else if (!inString && char === '}') { - braceCount--; - } - } - - if (braceCount !== 0) { - logger.warn( - `JSON braces don't balance (${braceCount}), treating as partial: "${jsonData.slice(0, 50)}..."`, - ); - break; - } - - try { - logger.debug(`Attempting to parse JSON of length: ${jsonData.length}`); - const parsedData = JSON.parse(jsonData); - logger.debug(`Successfully parsed JSON: ${JSON.stringify(parsedData, null, 2)}`); - processedLines = index + 1; - - if (options.onChunk) { - options.onChunk(parsedData); - } - - if (!fullResponse) { - fullResponse = parsedData; - } else if ( - parsedData.choices && - parsedData.choices[0] && - parsedData.choices[0].delta && - parsedData.choices[0].delta.content - ) { - fullContent += parsedData.choices[0].delta.content; - } - } catch (error) { - logger.error(`Error parsing chunk: ${error}`); - const errorMsg = (error as any).message || ''; - const errorPos = Number.parseInt(errorMsg.match(/position (\d+)/)?.[1] || '0'); - if (errorPos > 0) { - const start = Math.max(0, errorPos - 20); - const end = Math.min(jsonData.length, errorPos + 20); - logger.error(`Context around error position ${errorPos}:`); - logger.error(`"${jsonData.substring(start, end)}"`); - logger.error( - `Character codes: ${[...jsonData.substring(start, end)] - .map((c) => c.charCodeAt(0)) - .join(' ')}`, - ); - } - } - } - } + if (this.tryParseAndProcess(jsonData, options, state)) { + processedLines = index + 1; + } else { + break; + } + } - if (processedLines > 0) { - const remainingLines = lines.slice(processedLines); - buffer = remainingLines.join('\n'); - logger.debug( - `Updated buffer. Remaining lines: ${remainingLines.length}, Buffer length: ${buffer.length}`, - ); - } + return processedLines; + } + + private async readStream( + reader: ReadableStreamDefaultReader, + options: any, + state: StreamState, + ): Promise { + const decoder = new TextDecoder(); + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + const chunk = decoder.decode(value, { stream: true }); + logger.debug(`Received chunk: ${chunk.length} bytes`); + + state.buffer += chunk; + logger.debug(`Added chunk to buffer. Buffer length: ${state.buffer.length}`); + + const lines = state.buffer.split('\n'); + logger.debug(`Processing ${lines.length} lines from buffer`); + + const processedLines = this.processLines(lines, options, state); + + if (processedLines > 0) { + const remainingLines = lines.slice(processedLines); + state.buffer = remainingLines.join('\n'); + logger.debug( + `Updated buffer. Remaining lines: ${remainingLines.length}, Buffer length: ${state.buffer.length}`, + ); } + } + } - // Construct the final response object similar to non-streaming response - if (fullResponse) { - if (fullContent) { - fullResponse.choices[0].message = { - content: fullContent, - role: 'assistant', - }; - } - return fullResponse; + private tryParseAndProcess(jsonData: string, options: any, state: StreamState): boolean { + try { + logger.debug(`Attempting to parse JSON of length: ${jsonData.length}`); + const parsedData = JSON.parse(jsonData); + logger.debug(`Successfully parsed JSON: ${JSON.stringify(parsedData, null, 2)}`); + + if (options.onChunk) { + options.onChunk(parsedData); } - return { - choices: [{ message: { content: fullContent, role: 'assistant' } }], - }; + if (state.fullResponse) { + this.accumulateContent(parsedData, state); + } else { + state.fullResponse = parsedData; + } + + return true; } catch (error) { - logger.error(`Streaming error: ${error instanceof Error ? error.message : String(error)}`); - throw error; + this.logParseError(error, jsonData); + return false; } } } diff --git a/tests/commands/chat.test.ts b/tests/commands/chat.test.ts index 0a41f72..8ad3d4e 100644 --- a/tests/commands/chat.test.ts +++ b/tests/commands/chat.test.ts @@ -42,6 +42,7 @@ describe('Chat Commands', () => { afterEach(() => { vi.clearAllMocks(); + delete process.env.BERGET_API_KEY; }); describe('chat run command', () => { @@ -85,9 +86,6 @@ describe('Chat Commands', () => { // but since it involves readline interaction, we just verify // that the service would be called with correct defaults expect(mockChatService.createCompletion).not.toHaveBeenCalled(); - - // Clean up - delete process.env.BERGET_API_KEY; }); }); diff --git a/tests/services/api-key-service.test.ts b/tests/services/api-key-service.test.ts new file mode 100644 index 0000000..979dc48 --- /dev/null +++ b/tests/services/api-key-service.test.ts @@ -0,0 +1,256 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { ApiKeyService } from '../../src/services/api-key-service.js'; + +const mockPost = vi.fn(); + +vi.mock('../../src/client.js', () => ({ + createAuthenticatedClient: vi.fn(() => ({ + POST: mockPost, + })), +})); + +describe('ApiKeyService', () => { + let service: ApiKeyService; + + beforeEach(() => { + // Reset the singleton instance so each test gets a fresh instance + (ApiKeyService as any).instance = undefined; + service = ApiKeyService.getInstance(); + mockPost.mockReset(); + }); + + describe('create', () => { + it('creates an API key successfully', async () => { + const mockResponse = { + created: '2024-01-01', + description: null, + id: 1, + key: 'test-key', + name: 'Test Key', + }; + mockPost.mockResolvedValue({ data: mockResponse, error: null }); + + const result = await service.create({ name: 'Test Key' }); + + expect(result).toEqual(mockResponse); + expect(mockPost).toHaveBeenCalledWith('/v1/api-keys', { + body: { name: 'Test Key' }, + }); + }); + + it('creates an API key with description', async () => { + const mockResponse = { + created: '2024-01-01', + description: 'A test key', + id: 1, + key: 'test-key', + name: 'Test Key', + }; + mockPost.mockResolvedValue({ data: mockResponse, error: null }); + + const result = await service.create({ description: 'A test key', name: 'Test Key' }); + + expect(result).toEqual(mockResponse); + expect(mockPost).toHaveBeenCalledWith('/v1/api-keys', { + body: { description: 'A test key', name: 'Test Key' }, + }); + }); + + it('throws when name is empty', async () => { + await expect(service.create({ name: '' })).rejects.toThrow( + 'API key name is required and cannot be empty', + ); + expect(mockPost).not.toHaveBeenCalled(); + }); + + it('throws when name is whitespace only', async () => { + await expect(service.create({ name: ' ' })).rejects.toThrow( + 'API key name is required and cannot be empty', + ); + expect(mockPost).not.toHaveBeenCalled(); + }); + + it('throws when name exceeds 100 characters', async () => { + const longName = 'a'.repeat(101); + await expect(service.create({ name: longName })).rejects.toThrow( + 'API key name must be 100 characters or less', + ); + expect(mockPost).not.toHaveBeenCalled(); + }); + + it('allows name exactly 100 characters', async () => { + const mockResponse = { + created: '2024-01-01', + description: null, + id: 1, + key: 'test-key', + name: 'a'.repeat(100), + }; + mockPost.mockResolvedValue({ data: mockResponse, error: null }); + + const result = await service.create({ name: 'a'.repeat(100) }); + + expect(result.name).toBe('a'.repeat(100)); + }); + + it('throws when description exceeds 500 characters', async () => { + const longDescription = 'a'.repeat(501); + await expect(service.create({ description: longDescription, name: 'Test' })).rejects.toThrow( + 'API key description must be 500 characters or less', + ); + expect(mockPost).not.toHaveBeenCalled(); + }); + + it('allows description exactly 500 characters', async () => { + const mockResponse = { + created: '2024-01-01', + description: 'a'.repeat(500), + id: 1, + key: 'test-key', + name: 'Test', + }; + mockPost.mockResolvedValue({ data: mockResponse, error: null }); + + const result = await service.create({ description: 'a'.repeat(500), name: 'Test' }); + + expect(result.description).toBe('a'.repeat(500)); + }); + + it('throws when data is null', async () => { + mockPost.mockResolvedValue({ data: null, error: null }); + + await expect(service.create({ name: 'Test' })).rejects.toThrow( + 'No data received from server', + ); + }); + + describe('error code handling', () => { + it('throws detailed message for API_KEY_CREATION_FAILED', async () => { + mockPost.mockResolvedValue({ + data: null, + error: { error: { code: 'API_KEY_CREATION_FAILED' } }, + }); + + await expect(service.create({ name: 'Test' })).rejects.toThrow( + 'Failed to create API key. This could be due to:', + ); + }); + + it('throws account setup message for USER_NOT_FOUND', async () => { + mockPost.mockResolvedValue({ + data: null, + error: { error: { code: 'USER_NOT_FOUND' } }, + }); + + await expect(service.create({ name: 'Test' })).rejects.toThrow( + 'Before you can create API keys', + ); + }); + + it('throws quota message for QUOTA_EXCEEDED', async () => { + mockPost.mockResolvedValue({ + data: null, + error: { error: { code: 'QUOTA_EXCEEDED' } }, + }); + + await expect(service.create({ name: 'Test' })).rejects.toThrow( + 'You have reached your API key limit', + ); + }); + + it('throws permission message for INSUFFICIENT_PERMISSIONS', async () => { + mockPost.mockResolvedValue({ + data: null, + error: { error: { code: 'INSUFFICIENT_PERMISSIONS' } }, + }); + + await expect(service.create({ name: 'Test' })).rejects.toThrow( + 'Your account does not have permission to create API keys', + ); + }); + + it('throws billing message for BILLING_REQUIRED', async () => { + mockPost.mockResolvedValue({ + data: null, + error: { error: { code: 'BILLING_REQUIRED' } }, + }); + + await expect(service.create({ name: 'Test' })).rejects.toThrow( + 'A valid billing method is required', + ); + }); + + it('throws stringified error for unknown error object', async () => { + mockPost.mockResolvedValue({ + data: null, + error: { error: { code: 'UNKNOWN_ERROR' } }, + }); + + await expect(service.create({ name: 'Test' })).rejects.toThrow( + JSON.stringify({ error: { code: 'UNKNOWN_ERROR' } }), + ); + }); + + it('throws stringified error for non-object error', async () => { + mockPost.mockResolvedValue({ data: null, error: 'network error' }); + + await expect(service.create({ name: 'Test' })).rejects.toThrow('"network error"'); + }); + }); + + describe('network error handling', () => { + it('throws connection error for ECONNREFUSED', async () => { + mockPost.mockRejectedValue(new Error('ECONNREFUSED')); + + await expect(service.create({ name: 'Test' })).rejects.toThrow( + 'Cannot connect to Berget API', + ); + }); + + it('throws DNS error for ENOTFOUND', async () => { + mockPost.mockRejectedValue(new Error('ENOTFOUND')); + + await expect(service.create({ name: 'Test' })).rejects.toThrow( + 'Cannot resolve Berget API hostname', + ); + }); + + it('throws auth error for 401', async () => { + mockPost.mockRejectedValue(new Error('HTTP 401 Unauthorized')); + + await expect(service.create({ name: 'Test' })).rejects.toThrow( + 'Authentication failed. Please run `berget auth login`', + ); + }); + + it('throws auth error for Unauthorized', async () => { + mockPost.mockRejectedValue(new Error('Request failed: Unauthorized')); + + await expect(service.create({ name: 'Test' })).rejects.toThrow( + 'Authentication failed. Please run `berget auth login`', + ); + }); + + it('throws permission error for 403', async () => { + mockPost.mockRejectedValue(new Error('HTTP 403 Forbidden')); + + await expect(service.create({ name: 'Test' })).rejects.toThrow( + 'Access forbidden. Your account may not have permission to create API keys', + ); + }); + + it('re-throws unknown errors', async () => { + mockPost.mockRejectedValue(new Error('Something unexpected')); + + await expect(service.create({ name: 'Test' })).rejects.toThrow('Something unexpected'); + }); + + it('re-throws non-Error values', async () => { + mockPost.mockRejectedValue('plain string error'); + + await expect(service.create({ name: 'Test' })).rejects.toBe('plain string error'); + }); + }); + }); +}); diff --git a/tests/services/chat-service.test.ts b/tests/services/chat-service.test.ts new file mode 100644 index 0000000..f3cc9f1 --- /dev/null +++ b/tests/services/chat-service.test.ts @@ -0,0 +1,306 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { ChatService } from '../../src/services/chat-service.js'; + +const mockPost = vi.fn(); +const mockGet = vi.fn(); + +let fetchMock: any; + +vi.mock('../../src/client.js', () => ({ + createAuthenticatedClient: vi.fn(() => ({ + GET: mockGet, + POST: mockPost, + })), +})); + +vi.mock('../../src/utils/logger.js', () => ({ + logger: { + debug: vi.fn(), + error: vi.fn(), + warn: vi.fn(), + }, +})); + +describe('ChatService', () => { + let service: ChatService; + + beforeEach(() => { + (ChatService as any).instance = undefined; + service = ChatService.getInstance(); + mockPost.mockReset(); + mockGet.mockReset(); + fetchMock = vi.fn(); + globalThis.fetch = fetchMock; + }); + + describe('createCompletion', () => { + it('returns data for non-streaming request', async () => { + const mockResponse = { + choices: [{ message: { content: 'Hello', role: 'assistant' } }], + }; + mockPost.mockResolvedValue({ data: mockResponse, error: null }); + + const result = await service.createCompletion({ + messages: [{ content: 'Hi', role: 'user' }], + stream: false, + }); + + expect(result).toEqual(mockResponse); + }); + + it('sets default model when not provided', async () => { + mockPost.mockResolvedValue({ + data: { choices: [{ message: { content: 'Hi', role: 'assistant' } }] }, + error: null, + }); + + await service.createCompletion({ messages: [{ content: 'Hi', role: 'user' }] }); + + expect(mockPost).toHaveBeenCalledWith( + '/v1/chat/completions', + expect.objectContaining({ + body: expect.objectContaining({ model: 'google/gemma-3-27b-it' }), + }), + ); + }); + + it('uses provided model', async () => { + mockPost.mockResolvedValue({ + data: { choices: [{ message: { content: 'Hi', role: 'assistant' } }] }, + error: null, + }); + + await service.createCompletion({ + messages: [{ content: 'Hi', role: 'user' }], + model: 'custom-model', + }); + + expect(mockPost).toHaveBeenCalledWith( + '/v1/chat/completions', + expect.objectContaining({ + body: expect.objectContaining({ model: 'custom-model' }), + }), + ); + }); + + it('adds apiKey to headers', async () => { + mockPost.mockResolvedValue({ + data: { choices: [{ message: { content: 'Hi', role: 'assistant' } }] }, + error: null, + }); + + await service.createCompletion({ + apiKey: 'test-key', + messages: [{ content: 'Hi', role: 'user' }], + }); + + expect(mockPost).toHaveBeenCalledWith( + '/v1/chat/completions', + expect.objectContaining({ + headers: { Authorization: 'test-key' }, + }), + ); + }); + + it('throws on API error response', async () => { + mockPost.mockResolvedValue({ data: null, error: { message: 'Bad request' } }); + + await expect( + service.createCompletion({ messages: [{ content: 'Hi', role: 'user' }] }), + ).rejects.toThrow('Bad request'); + }); + + it('initializes empty messages array when missing', async () => { + mockPost.mockResolvedValue({ + data: { choices: [{ message: { content: 'Hi', role: 'assistant' } }] }, + error: null, + }); + + await service.createCompletion({ messages: [] as any }); + + expect(mockPost).toHaveBeenCalled(); + }); + + it('propagates unexpected errors from executeCompletion', async () => { + mockPost.mockRejectedValue(new Error('Unexpected failure')); + + await expect( + service.createCompletion({ messages: [{ content: 'Hi', role: 'user' }] }), + ).rejects.toThrow('Unexpected failure'); + }); + }); + + describe('listModels', () => { + it('returns models without apiKey', async () => { + const mockData = { data: [{ id: 'model-1' }] }; + mockGet.mockResolvedValue({ data: mockData, error: null }); + + const result = await service.listModels(); + + expect(result).toEqual(mockData); + expect(mockGet).toHaveBeenCalledWith('/v1/models'); + }); + + it('returns models with apiKey', async () => { + const mockData = { data: [{ id: 'model-1' }] }; + mockGet.mockResolvedValue({ data: mockData, error: null }); + + const result = await service.listModels('test-key'); + + expect(result).toEqual(mockData); + expect(mockGet).toHaveBeenCalledWith('/v1/models', { + headers: { Authorization: 'test-key' }, + }); + }); + + it('throws on API error', async () => { + mockGet.mockResolvedValue({ data: null, error: 'service unavailable' }); + + await expect(service.listModels()).rejects.toThrow('Failed to list models'); + }); + }); + + describe('handleStreamingResponse', () => { + function createMockStream(chunks: string[]) { + let index = 0; + return { + getReader: () => ({ + read: () => { + if (index >= chunks.length) { + return Promise.resolve({ done: true, value: undefined }); + } + const chunk = new TextEncoder().encode(chunks[index++]); + return Promise.resolve({ done: false, value: chunk }); + }, + }), + }; + } + + it('streams chunks and returns full response', async () => { + const chunks: string[] = [ + 'data: {"id":"1","choices":[{"delta":{"content":"Hello"}}]}\n\n', + 'data: {"id":"1","choices":[{"delta":{"content":" world"}}]}\n\n', + 'data: [DONE]\n\n', + ]; + + fetchMock.mockResolvedValue({ + body: createMockStream(chunks), + headers: new Headers(), + ok: true, + status: 200, + statusText: 'OK', + }); + + const receivedChunks: any[] = []; + + const result = await service.createCompletion({ + messages: [{ content: 'Hi', role: 'user' }], + onChunk: (chunk: any) => receivedChunks.push(chunk), + stream: true, + }); + + expect(receivedChunks).toHaveLength(2); + expect(result.choices[0].message.content).toBe(' world'); + }); + + it('handles first chunk as full response with no content', async () => { + const chunks: string[] = ['data: {"id":"1","choices":[{"delta":{"content":"Test"}}]}\n\n']; + + fetchMock.mockResolvedValue({ + body: createMockStream(chunks), + headers: new Headers(), + ok: true, + status: 200, + statusText: 'OK', + }); + + const result = await service.createCompletion({ + messages: [{ content: 'Hi', role: 'user' }], + onChunk: vi.fn(), + stream: true, + }); + + // First chunk sets fullResponse, but no content accumulated + expect(result.id).toBe('1'); + }); + + it('handles empty stream', async () => { + fetchMock.mockResolvedValue({ + body: createMockStream([]), + headers: new Headers(), + ok: true, + status: 200, + statusText: 'OK', + }); + + const result = await service.createCompletion({ + messages: [{ content: 'Hi', role: 'user' }], + onChunk: vi.fn(), + stream: true, + }); + + expect(result.choices[0].message.content).toBe(''); + }); + + it('throws on non-ok response', async () => { + fetchMock.mockResolvedValue({ + headers: new Headers(), + ok: false, + status: 401, + statusText: 'Unauthorized', + text: () => Promise.resolve('Invalid API key'), + }); + + await expect( + service.createCompletion({ + messages: [{ content: 'Hi', role: 'user' }], + onChunk: vi.fn(), + stream: true, + }), + ).rejects.toThrow('Stream request failed: 401 Unauthorized'); + }); + + it('throws when response body is missing', async () => { + fetchMock.mockResolvedValue({ + body: null, + headers: new Headers(), + ok: true, + status: 200, + statusText: 'OK', + }); + + await expect( + service.createCompletion({ + messages: [{ content: 'Hi', role: 'user' }], + onChunk: vi.fn(), + stream: true, + }), + ).rejects.toThrow('No response body received'); + }); + + it('handles partial JSON across chunks', async () => { + const fullJson = '{"id":"1","choices":[{"delta":{"content":"Hello"}}]}'; + const chunks: string[] = [ + `data: ${fullJson.slice(0, 20)}\n\n`, + `data: ${fullJson.slice(20)}\n\n`, + ]; + + fetchMock.mockResolvedValue({ + body: createMockStream(chunks), + headers: new Headers(), + ok: true, + status: 200, + statusText: 'OK', + }); + + const result = await service.createCompletion({ + messages: [{ content: 'Hi', role: 'user' }], + onChunk: vi.fn(), + stream: true, + }); + + expect(result).toBeDefined(); + }); + }); +});