@curl-runner/cli 1.1.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -1,10 +1,10 @@
1
1
  #!/usr/bin/env bun
2
2
  // @bun
3
- var C=Object.create;var{getPrototypeOf:v,defineProperty:k,getOwnPropertyNames:x}=Object;var y=Object.prototype.hasOwnProperty;var f=($,K,Q)=>{Q=$!=null?C(v($)):{};let X=K||!$||!$.__esModule?k(Q,"default",{value:$,enumerable:!0}):Q;for(let Z of x($))if(!y.call(X,Z))k(X,Z,{get:()=>$[Z],enumerable:!0});return X};var S=import.meta.require;var{Glob:c}=globalThis.Bun;var{YAML:q}=globalThis.Bun;class H{static async parseFile($){let Q=await Bun.file($).text();return q.parse(Q)}static parse($){return q.parse($)}static interpolateVariables($,K,Q){if(typeof $==="string"){let X=$.match(/^\$\{([^}]+)\}$/);if(X){let Z=X[1],z=H.resolveVariable(Z,K,Q);return z!==null?z:$}return $.replace(/\$\{([^}]+)\}/g,(Z,z)=>{let W=H.resolveVariable(z,K,Q);return W!==null?W:Z})}if(Array.isArray($))return $.map((X)=>H.interpolateVariables(X,K,Q));if($&&typeof $==="object"){let X={};for(let[Z,z]of Object.entries($))X[Z]=H.interpolateVariables(z,K,Q);return X}return $}static resolveVariable($,K,Q){if($.startsWith("store.")&&Q){let Z=$.slice(6);if(Z in Q)return Q[Z];return null}let X=H.resolveDynamicVariable($);if(X!==null)return X;if($ in K)return K[$];return null}static resolveDynamicVariable($){if($==="UUID")return crypto.randomUUID();if($==="CURRENT_TIME"||$==="TIMESTAMP")return Date.now().toString();if($.startsWith("DATE:")){let K=$.slice(5);return H.formatDate(new Date,K)}if($.startsWith("TIME:")){let K=$.slice(5);return H.formatTime(new Date,K)}return null}static formatDate($,K){let Q=$.getFullYear(),X=String($.getMonth()+1).padStart(2,"0"),Z=String($.getDate()).padStart(2,"0");return K.replace("YYYY",Q.toString()).replace("MM",X).replace("DD",Z)}static formatTime($,K){let Q=String($.getHours()).padStart(2,"0"),X=String($.getMinutes()).padStart(2,"0"),Z=String($.getSeconds()).padStart(2,"0");return K.replace("HH",Q).replace("mm",X).replace("ss",Z)}static mergeConfigs($,K){return{...$,...K,headers:{...$.headers,...K.headers},params:{...$.params,...K.params},variables:{...$.variables,...K.variables}}}}class M{static buildCommand($){let K=["curl"];if(K.push("-X",$.method||"GET"),K.push("-w",'"\\n__CURL_METRICS_START__%{json}__CURL_METRICS_END__"'),$.headers)for(let[X,Z]of Object.entries($.headers))K.push("-H",`"${X}: ${Z}"`);if($.auth){if($.auth.type==="basic"&&$.auth.username&&$.auth.password)K.push("-u",`"${$.auth.username}:${$.auth.password}"`);else if($.auth.type==="bearer"&&$.auth.token)K.push("-H",`"Authorization: Bearer ${$.auth.token}"`)}if($.body){let X=typeof $.body==="string"?$.body:JSON.stringify($.body);if(K.push("-d",`'${X.replace(/'/g,"'\\''")}'`),!$.headers?.["Content-Type"])K.push("-H",'"Content-Type: application/json"')}if($.timeout)K.push("--max-time",$.timeout.toString());if($.followRedirects!==!1){if(K.push("-L"),$.maxRedirects)K.push("--max-redirs",$.maxRedirects.toString())}if($.proxy)K.push("-x",$.proxy);if($.insecure)K.push("-k");if($.output)K.push("-o",$.output);K.push("-s","-S");let Q=$.url;if($.params&&Object.keys($.params).length>0){let X=new URLSearchParams($.params).toString();Q+=(Q.includes("?")?"&":"?")+X}return K.push(`"${Q}"`),K.join(" ")}static async executeCurl($){try{let K=Bun.spawn(["sh","-c",$],{stdout:"pipe",stderr:"pipe"}),Q=await new Response(K.stdout).text(),X=await new Response(K.stderr).text();if(await K.exited,K.exitCode!==0&&!Q)return{success:!1,error:X||`Command failed with exit code ${K.exitCode}`};let Z=Q,z={},W=Q.match(/__CURL_METRICS_START__(.+?)__CURL_METRICS_END__/);if(W){Z=Q.replace(/__CURL_METRICS_START__.+?__CURL_METRICS_END__/,"").trim();try{z=JSON.parse(W[1])}catch(J){}}let D={};if(z.response_code){let J=X.split(`
4
- `).filter((w)=>w.includes(":"));for(let w of J){let[O,...U]=w.split(":");if(O&&U.length>0)D[O.trim()]=U.join(":").trim()}}return{success:!0,status:z.response_code||z.http_code,headers:D,body:Z,metrics:{duration:(z.time_total||0)*1000,size:z.size_download,dnsLookup:(z.time_namelookup||0)*1000,tcpConnection:(z.time_connect||0)*1000,tlsHandshake:(z.time_appconnect||0)*1000,firstByte:(z.time_starttransfer||0)*1000,download:(z.time_total||0)*1000}}}catch(K){return{success:!1,error:K instanceof Error?K.message:String(K)}}}}class j{colors;constructor($){this.colors=$}color($,K){if(!K||!this.colors[K])return $;return`${this.colors[K]}${$}${this.colors.reset}`}render($,K=" "){$.forEach((Q,X)=>{let Z=X===$.length-1,z=Z?`${K}\u2514\u2500`:`${K}\u251C\u2500`;if(Q.label&&Q.value){let W=Q.color?this.color(Q.value,Q.color):Q.value,D=W.split(`
5
- `);if(D.length===1)console.log(`${z} ${Q.label}: ${W}`);else{console.log(`${z} ${Q.label}:`);let J=Z?`${K} `:`${K}\u2502 `;D.forEach((w)=>{console.log(`${J}${w}`)})}}else if(Q.label&&!Q.value)console.log(`${z} ${Q.label}:`);else if(!Q.label&&Q.value){let W=Z?`${K} `:`${K}\u2502 `;console.log(`${W}${Q.value}`)}if(Q.children&&Q.children.length>0){let W=Z?`${K} `:`${K}\u2502 `;this.render(Q.children,W)}})}}class Y{config;colors={reset:"\x1B[0m",bright:"\x1B[1m",dim:"\x1B[2m",underscore:"\x1B[4m",black:"\x1B[30m",red:"\x1B[31m",green:"\x1B[32m",yellow:"\x1B[33m",blue:"\x1B[34m",magenta:"\x1B[35m",cyan:"\x1B[36m",white:"\x1B[37m",bgBlack:"\x1B[40m",bgRed:"\x1B[41m",bgGreen:"\x1B[42m",bgYellow:"\x1B[43m",bgBlue:"\x1B[44m",bgMagenta:"\x1B[45m",bgCyan:"\x1B[46m",bgWhite:"\x1B[47m"};constructor($={}){this.config={verbose:!1,showHeaders:!1,showBody:!0,showMetrics:!1,format:"pretty",prettyLevel:"minimal",...$}}color($,K){return`${this.colors[K]}${$}${this.colors.reset}`}getShortFilename($){return $.replace(/.*\//,"").replace(".yaml","")}shouldShowOutput(){if(this.config.format==="raw")return!1;if(this.config.format==="pretty")return!0;return this.config.verbose!==!1}shouldShowHeaders(){if(this.config.format!=="pretty")return this.config.showHeaders||!1;switch(this.config.prettyLevel||"standard"){case"minimal":return!1;case"standard":return this.config.showHeaders||!1;case"detailed":return!0;default:return this.config.showHeaders||!1}}shouldShowBody(){if(this.config.format!=="pretty")return this.config.showBody!==!1;switch(this.config.prettyLevel||"standard"){case"minimal":return!1;case"standard":return this.config.showBody!==!1;case"detailed":return!0;default:return this.config.showBody!==!1}}shouldShowMetrics(){if(this.config.format!=="pretty")return this.config.showMetrics||!1;switch(this.config.prettyLevel||"standard"){case"minimal":return!1;case"standard":return this.config.showMetrics||!1;case"detailed":return!0;default:return this.config.showMetrics||!1}}shouldShowRequestDetails(){if(this.config.format!=="pretty")return this.config.verbose||!1;switch(this.config.prettyLevel||"standard"){case"minimal":return!1;case"standard":return this.config.verbose||!1;case"detailed":return!0;default:return this.config.verbose||!1}}shouldShowSeparators(){if(this.config.format!=="pretty")return!0;switch(this.config.prettyLevel||"standard"){case"minimal":return!1;case"standard":return!0;case"detailed":return!0;default:return!0}}colorStatusCode($){return this.color($,"yellow")}logValidationErrors($){let K=$.split("; ");if(K.length===1){let Q=K[0].trim(),X=Q.match(/^Expected status (.+?), got (.+)$/);if(X){let[,Z,z]=X,W=this.colorStatusCode(Z.replace(" or ","|")),D=this.color(z,"red");console.log(` ${this.color("\u2717","red")} ${this.color("Error:","red")} Expected ${this.color("status","yellow")} ${W}, got ${D}`)}else console.log(` ${this.color("\u2717","red")} ${this.color("Error:","red")} ${Q}`)}else{console.log(` ${this.color("\u2717","red")} ${this.color("Validation Errors:","red")}`);for(let Q of K){let X=Q.trim();if(X)if(X.startsWith("Expected ")){let Z=X.match(/^Expected status (.+?), got (.+)$/);if(Z){let[,z,W]=Z,D=this.colorStatusCode(z.replace(" or ","|")),J=this.color(W,"red");console.log(` ${this.color("\u2022","red")} ${this.color("status","yellow")}: expected ${D}, got ${J}`)}else{let z=X.match(/^Expected (.+?) to be (.+?), got (.+)$/);if(z){let[,W,D,J]=z;console.log(` ${this.color("\u2022","red")} ${this.color(W,"yellow")}: expected ${this.color(D,"green")}, got ${this.color(J,"red")}`)}else console.log(` ${this.color("\u2022","red")} ${X}`)}}else console.log(` ${this.color("\u2022","red")} ${X}`)}}}formatJson($){if(this.config.format==="raw")return typeof $==="string"?$:JSON.stringify($);if(this.config.format==="json")return JSON.stringify($);return JSON.stringify($,null,2)}formatDuration($){if($<1000)return`${$.toFixed(0)}ms`;return`${($/1000).toFixed(2)}s`}formatSize($){if(!$)return"0 B";let K=["B","KB","MB","GB"],Q=Math.floor(Math.log($)/Math.log(1024));return`${($/1024**Q).toFixed(2)} ${K[Q]}`}logExecutionStart($,K){if(!this.shouldShowOutput())return;if(this.shouldShowSeparators())console.log(),console.log(this.color(`Executing ${$} request(s) in ${K} mode`,"dim")),console.log();else console.log()}logRequestStart($,K){return}logCommand($){if(this.shouldShowRequestDetails())console.log(this.color(" Command:","dim")),console.log(this.color(` ${$}`,"dim"))}logRetry($,K){console.log(this.color(` \u21BB Retry ${$}/${K}...`,"yellow"))}logRequestComplete($){if(this.config.format==="raw"){if($.success&&this.config.showBody&&$.body){let D=this.formatJson($.body);console.log(D)}return}if(this.config.format==="json"){let D={request:{name:$.request.name,url:$.request.url,method:$.request.method||"GET"},success:$.success,status:$.status,...this.shouldShowHeaders()&&$.headers?{headers:$.headers}:{},...this.shouldShowBody()&&$.body?{body:$.body}:{},...$.error?{error:$.error}:{},...this.shouldShowMetrics()&&$.metrics?{metrics:$.metrics}:{}};console.log(JSON.stringify(D,null,2));return}if(!this.shouldShowOutput())return;let K=this.config.prettyLevel||"minimal",Q=$.success?"green":"red",X=$.success?"\u2713":"x",Z=$.request.name||"Request";if(K==="minimal"){let D=$.request.sourceFile?this.getShortFilename($.request.sourceFile):"inline";console.log(`${this.color(X,Q)} ${this.color(Z,"bright")} [${D}]`);let J=[],w=new j(this.colors);J.push({label:$.request.method||"GET",value:$.request.url,color:"blue"});let O=$.status?`${$.status}`:"ERROR";if(J.push({label:`${X} Status`,value:O,color:Q}),$.metrics){let U=`${this.formatDuration($.metrics.duration)} | ${this.formatSize($.metrics.size)}`;J.push({label:"Duration",value:U,color:"cyan"})}if(w.render(J),$.error)console.log(),this.logValidationErrors($.error);console.log();return}console.log(`${this.color(X,Q)} ${this.color(Z,"bright")}`);let z=[],W=new j(this.colors);if(z.push({label:"URL",value:$.request.url,color:"blue"}),z.push({label:"Method",value:$.request.method||"GET",color:"yellow"}),z.push({label:"Status",value:String($.status||"ERROR"),color:Q}),$.metrics)z.push({label:"Duration",value:this.formatDuration($.metrics.duration),color:"cyan"});if(this.shouldShowHeaders()&&$.headers&&Object.keys($.headers).length>0){let D=Object.entries($.headers).map(([J,w])=>({label:this.color(J,"dim"),value:String(w)}));z.push({label:"Headers",children:D})}if(this.shouldShowBody()&&$.body){let J=this.formatJson($.body).split(`
6
- `),w=this.shouldShowRequestDetails()?1/0:10,O=J.slice(0,w);if(J.length>w)O.push(this.color(`... (${J.length-w} more lines)`,"dim"));z.push({label:"Response Body",value:O.join(`
7
- `)})}if(this.shouldShowMetrics()&&$.metrics&&K==="detailed"){let D=$.metrics,J=[];if(J.push({label:"Request Duration",value:this.formatDuration(D.duration),color:"cyan"}),D.size!==void 0)J.push({label:"Response Size",value:this.formatSize(D.size),color:"cyan"});if(D.dnsLookup)J.push({label:"DNS Lookup",value:this.formatDuration(D.dnsLookup),color:"cyan"});if(D.tcpConnection)J.push({label:"TCP Connection",value:this.formatDuration(D.tcpConnection),color:"cyan"});if(D.tlsHandshake)J.push({label:"TLS Handshake",value:this.formatDuration(D.tlsHandshake),color:"cyan"});if(D.firstByte)J.push({label:"Time to First Byte",value:this.formatDuration(D.firstByte),color:"cyan"});z.push({label:"Metrics",children:J})}if(W.render(z),$.error)console.log(),this.logValidationErrors($.error);console.log()}logSummary($,K=!1){if(this.config.format==="raw")return;if(this.config.format==="json"){let D={summary:{total:$.total,successful:$.successful,failed:$.failed,duration:$.duration},results:$.results.map((J)=>({request:{name:J.request.name,url:J.request.url,method:J.request.method||"GET"},success:J.success,status:J.status,...this.shouldShowHeaders()&&J.headers?{headers:J.headers}:{},...this.shouldShowBody()&&J.body?{body:J.body}:{},...J.error?{error:J.error}:{},...this.shouldShowMetrics()&&J.metrics?{metrics:J.metrics}:{}}))};console.log(JSON.stringify(D,null,2));return}if(!this.shouldShowOutput())return;let Q=this.config.prettyLevel||"minimal";if(K)console.log();if(Q==="minimal"){let D=$.failed===0?"green":"red",J=$.failed===0?`${$.total} request${$.total===1?"":"s"} completed successfully`:`${$.successful}/${$.total} request${$.total===1?"":"s"} completed, ${$.failed} failed`;console.log(`${K?"\u25C6 Global Summary":"Summary"}: ${this.color(J,D)}`);return}let X=($.successful/$.total*100).toFixed(1),Z=$.failed===0?"green":"red",z=$.failed===0?`${$.total} request${$.total===1?"":"s"} completed successfully`:`${$.successful}/${$.total} request${$.total===1?"":"s"} completed, ${$.failed} failed`,W=K?"\u25C6 Global Summary":"Summary";if(console.log(),console.log(`${W}: ${this.color(z,Z)} (${this.color(this.formatDuration($.duration),"cyan")})`),$.failed>0&&this.shouldShowRequestDetails())$.results.filter((D)=>!D.success).forEach((D)=>{let J=D.request.name||D.request.url;console.log(` ${this.color("\u2022","red")} ${J}: ${D.error}`)})}logError($){console.error(this.color(`\u2717 ${$}`,"red"))}logWarning($){console.warn(this.color(`\u26A0 ${$}`,"yellow"))}logInfo($){console.log(this.color(`\u2139 ${$}`,"blue"))}logSuccess($){console.log(this.color(`\u2713 ${$}`,"green"))}logFileHeader($,K){if(!this.shouldShowOutput()||this.config.format!=="pretty")return;let Q=$.replace(/.*\//,"").replace(".yaml","");console.log(),console.log(this.color(`\u25B6 ${Q}.yaml`,"bright")+this.color(` (${K} request${K===1?"":"s"})`,"dim"))}}function g($,K){let Q=K.split("."),X=$;for(let Z of Q){if(X===null||X===void 0)return;if(typeof X!=="object")return;let z=Z.match(/^(\w+)\[(\d+)\]$/);if(z){let[,W,D]=z,J=Number.parseInt(D,10);if(X=X[W],Array.isArray(X))X=X[J];else return}else if(/^\d+$/.test(Z)&&Array.isArray(X))X=X[Number.parseInt(Z,10)];else X=X[Z]}return X}function p($){if($===void 0||$===null)return"";if(typeof $==="string")return $;if(typeof $==="number"||typeof $==="boolean")return String($);return JSON.stringify($)}function N($,K){let Q={},X={status:$.status,headers:$.headers||{},body:$.body,metrics:$.metrics};for(let[Z,z]of Object.entries(K)){let W=g(X,z);Q[Z]=p(W)}return Q}function P(){return{}}class B{logger;globalConfig;constructor($={}){this.globalConfig=$,this.logger=new Y($.output)}mergeOutputConfig($){return{...this.globalConfig.output,...$.sourceOutputConfig}}async executeRequest($,K=0){let Q=performance.now(),X=this.mergeOutputConfig($),Z=new Y(X);Z.logRequestStart($,K);let z=M.buildCommand($);Z.logCommand(z);let W=0,D,J=($.retry?.count||0)+1;while(W<J){if(W>0){if(Z.logRetry(W,J-1),$.retry?.delay)await Bun.sleep($.retry.delay)}let O=await M.executeCurl(z);if(O.success){let U=O.body;try{if(O.headers?.["content-type"]?.includes("application/json")||U&&(U.trim().startsWith("{")||U.trim().startsWith("[")))U=JSON.parse(U)}catch(I){}let G={request:$,success:!0,status:O.status,headers:O.headers,body:U,metrics:{...O.metrics,duration:performance.now()-Q}};if($.expect){let I=this.validateResponse(G,$.expect);if(!I.success)G.success=!1,G.error=I.error}return Z.logRequestComplete(G),G}D=O.error,W++}let w={request:$,success:!1,error:D,metrics:{duration:performance.now()-Q}};return Z.logRequestComplete(w),w}validateResponse($,K){if(!K)return{success:!0};let Q=[];if(K.status!==void 0){let Z=Array.isArray(K.status)?K.status:[K.status];if(!Z.includes($.status||0))Q.push(`Expected status ${Z.join(" or ")}, got ${$.status}`)}if(K.headers)for(let[Z,z]of Object.entries(K.headers)){let W=$.headers?.[Z]||$.headers?.[Z.toLowerCase()];if(W!==z)Q.push(`Expected header ${Z}="${z}", got "${W}"`)}if(K.body!==void 0){let Z=this.validateBodyProperties($.body,K.body,"");if(Z.length>0)Q.push(...Z)}if(K.responseTime!==void 0&&$.metrics){let Z=$.metrics.duration;if(!this.validateRangePattern(Z,K.responseTime))Q.push(`Expected response time to match ${K.responseTime}ms, got ${Z.toFixed(2)}ms`)}let X=Q.length>0;if(K.failure===!0){if(X)return{success:!1,error:Q.join("; ")};let Z=$.status||0;if(Z>=400)return{success:!0};else return{success:!1,error:`Expected request to fail (4xx/5xx) but got status ${Z}`}}else if(X)return{success:!1,error:Q.join("; ")};else return{success:!0}}validateBodyProperties($,K,Q){let X=[];if(typeof K!=="object"||K===null){let Z=this.validateValue($,K,Q||"body");if(!Z.isValid)X.push(Z.error);return X}if(Array.isArray(K)){let Z=this.validateValue($,K,Q||"body");if(!Z.isValid)X.push(Z.error);return X}for(let[Z,z]of Object.entries(K)){let W=Q?`${Q}.${Z}`:Z,D;if(Array.isArray($)&&this.isArraySelector(Z))D=this.getArrayValue($,Z);else D=$?.[Z];if(typeof z==="object"&&z!==null&&!Array.isArray(z)){let J=this.validateBodyProperties(D,z,W);X.push(...J)}else{let J=this.validateValue(D,z,W);if(!J.isValid)X.push(J.error)}}return X}validateValue($,K,Q){if(K==="*")return{isValid:!0};if(Array.isArray(K)){if(!K.some((Z)=>{if(Z==="*")return!0;if(typeof Z==="string"&&this.isRegexPattern(Z))return this.validateRegexPattern($,Z);if(typeof Z==="string"&&this.isRangePattern(Z))return this.validateRangePattern($,Z);return $===Z}))return{isValid:!1,error:`Expected ${Q} to match one of ${JSON.stringify(K)}, got ${JSON.stringify($)}`};return{isValid:!0}}if(typeof K==="string"&&this.isRegexPattern(K)){if(!this.validateRegexPattern($,K))return{isValid:!1,error:`Expected ${Q} to match pattern ${K}, got ${JSON.stringify($)}`};return{isValid:!0}}if(typeof K==="string"&&this.isRangePattern(K)){if(!this.validateRangePattern($,K))return{isValid:!1,error:`Expected ${Q} to match range ${K}, got ${JSON.stringify($)}`};return{isValid:!0}}if(K==="null"||K===null){if($!==null)return{isValid:!1,error:`Expected ${Q} to be null, got ${JSON.stringify($)}`};return{isValid:!0}}if($!==K)return{isValid:!1,error:`Expected ${Q} to be ${JSON.stringify(K)}, got ${JSON.stringify($)}`};return{isValid:!0}}isRegexPattern($){return $.startsWith("^")||$.endsWith("$")||$.includes("\\d")||$.includes("\\w")||$.includes("\\s")||$.includes("[")||$.includes("*")||$.includes("+")||$.includes("?")}validateRegexPattern($,K){let Q=String($);try{return new RegExp(K).test(Q)}catch{return!1}}isRangePattern($){return/^(>=?|<=?|>|<)\s*[\d.-]+(\s*,\s*(>=?|<=?|>|<)\s*[\d.-]+)*$/.test($)}validateRangePattern($,K){let Q=Number($);if(Number.isNaN(Q))return!1;let X=K.match(/^([\d.-]+)\s*-\s*([\d.-]+)$/);if(X){let z=Number(X[1]),W=Number(X[2]);return Q>=z&&Q<=W}return K.split(",").map((z)=>z.trim()).every((z)=>{let W=z.match(/^(>=?|<=?|>|<)\s*([\d.-]+)$/);if(!W)return!1;let D=W[1],J=Number(W[2]);switch(D){case">":return Q>J;case">=":return Q>=J;case"<":return Q<J;case"<=":return Q<=J;default:return!1}})}isArraySelector($){return/^\[.*\]$/.test($)||$==="*"||$.startsWith("slice(")}getArrayValue($,K){if(K==="*")return $;if(K.startsWith("[")&&K.endsWith("]")){let Q=K.slice(1,-1);if(Q==="*")return $;let X=Number(Q);if(!Number.isNaN(X))return X>=0?$[X]:$[$.length+X]}if(K.startsWith("slice(")){let Q=K.match(/slice\((\d+)(?:,(\d+))?\)/);if(Q){let X=Number(Q[1]),Z=Q[2]?Number(Q[2]):void 0;return $.slice(X,Z)}}return}async executeSequential($){let K=performance.now(),Q=[],X=P();for(let Z=0;Z<$.length;Z++){let z=this.interpolateStoreVariables($[Z],X),W=await this.executeRequest(z,Z+1);if(Q.push(W),W.success&&z.store){let D=N(W,z.store);Object.assign(X,D),this.logStoredValues(D)}if(!W.success&&!this.globalConfig.continueOnError){this.logger.logError("Stopping execution due to error");break}}return this.createSummary(Q,performance.now()-K)}interpolateStoreVariables($,K){if(Object.keys(K).length===0)return $;return H.interpolateVariables($,{},K)}logStoredValues($){if(Object.keys($).length===0)return;let K=Object.entries($);for(let[Q,X]of K){let Z=X.length>50?`${X.substring(0,50)}...`:X;this.logger.logInfo(`Stored: ${Q} = "${Z}"`)}}async executeParallel($){let K=performance.now(),Q=$.map((Z,z)=>this.executeRequest(Z,z+1)),X=await Promise.all(Q);return this.createSummary(X,performance.now()-K)}async execute($){this.logger.logExecutionStart($.length,this.globalConfig.execution||"sequential");let K=this.globalConfig.execution==="parallel"?await this.executeParallel($):await this.executeSequential($);if(this.logger.logSummary(K),this.globalConfig.output?.saveToFile)await this.saveSummaryToFile(K);return K}createSummary($,K){let Q=$.filter((Z)=>Z.success).length,X=$.filter((Z)=>!Z.success).length;return{total:$.length,successful:Q,failed:X,duration:K,results:$}}async saveSummaryToFile($){let K=this.globalConfig.output?.saveToFile;if(!K)return;let Q=JSON.stringify($,null,2);await Bun.write(K,Q),this.logger.logInfo(`Results saved to ${K}`)}}function A(){if(typeof BUILD_VERSION<"u")return BUILD_VERSION;if(process.env.CURL_RUNNER_VERSION)return process.env.CURL_RUNNER_VERSION;try{let $=["../package.json","./package.json","../../package.json"];for(let K of $)try{let Q=S(K);if(Q.name==="@curl-runner/cli"&&Q.version)return Q.version}catch{}return"0.0.0"}catch{return"0.0.0"}}var h={reset:"\x1B[0m",bright:"\x1B[1m",dim:"\x1B[2m",underscore:"\x1B[4m",black:"\x1B[30m",red:"\x1B[31m",green:"\x1B[32m",yellow:"\x1B[33m",blue:"\x1B[34m",magenta:"\x1B[35m",cyan:"\x1B[36m",white:"\x1B[37m",bgBlack:"\x1B[40m",bgRed:"\x1B[41m",bgGreen:"\x1B[42m",bgYellow:"\x1B[43m",bgBlue:"\x1B[44m",bgMagenta:"\x1B[45m",bgCyan:"\x1B[46m",bgWhite:"\x1B[47m"};function F($,K){return`${h[K]}${$}${h.reset}`}var b=`${process.env.HOME}/.curl-runner-version-cache.json`,d=86400000,m="https://registry.npmjs.org/@curl-runner/cli/latest";class L{async checkForUpdates($=!1){try{if(process.env.CI)return;let K=A();if(K==="0.0.0")return;if(!$){let X=await this.getCachedVersion();if(X&&Date.now()-X.lastCheck<d){this.compareVersions(K,X.latestVersion);return}}let Q=await this.fetchLatestVersion();if(Q)await this.setCachedVersion(Q),this.compareVersions(K,Q)}catch{}}async fetchLatestVersion(){try{let $=await fetch(m,{signal:AbortSignal.timeout(3000)});if(!$.ok)return null;return(await $.json()).version}catch{return null}}compareVersions($,K){if(this.isNewerVersion($,K))console.log(),console.log(F("\u256D\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256E","yellow")),console.log(F("\u2502","yellow")+" "+F("\u2502","yellow")),console.log(F("\u2502","yellow")+" "+F("\uD83D\uDCE6 New version available!","bright")+` ${F($,"red")} \u2192 ${F(K,"green")} `+F("\u2502","yellow")),console.log(F("\u2502","yellow")+" "+F("\u2502","yellow")),console.log(F("\u2502","yellow")+" Update with: "+F("npm install -g @curl-runner/cli","cyan")+" "+F("\u2502","yellow")),console.log(F("\u2502","yellow")+" or: "+F("bun install -g @curl-runner/cli","cyan")+" "+F("\u2502","yellow")),console.log(F("\u2502","yellow")+" "+F("\u2502","yellow")),console.log(F("\u2570\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256F","yellow")),console.log()}isNewerVersion($,K){try{let Q=$.replace(/^v/,""),X=K.replace(/^v/,""),Z=Q.split(".").map(Number),z=X.split(".").map(Number);for(let W=0;W<Math.max(Z.length,z.length);W++){let D=Z[W]||0,J=z[W]||0;if(J>D)return!0;if(J<D)return!1}return!1}catch{return!1}}async getCachedVersion(){try{let $=Bun.file(b);if(await $.exists())return JSON.parse(await $.text())}catch{}return null}async setCachedVersion($){try{let K={lastCheck:Date.now(),latestVersion:$};await Bun.write(b,JSON.stringify(K))}catch{}}}class V{logger=new Y;async loadConfigFile(){let $=["curl-runner.yaml","curl-runner.yml",".curl-runner.yaml",".curl-runner.yml"];for(let K of $)try{if(await Bun.file(K).exists()){let X=await H.parseFile(K),Z=X.global||X;return this.logger.logInfo(`Loaded configuration from ${K}`),Z}}catch(Q){this.logger.logWarning(`Failed to load configuration from ${K}: ${Q}`)}return{}}loadEnvironmentVariables(){let $={};if(process.env.CURL_RUNNER_TIMEOUT)$.defaults={...$.defaults,timeout:Number.parseInt(process.env.CURL_RUNNER_TIMEOUT,10)};if(process.env.CURL_RUNNER_RETRIES)$.defaults={...$.defaults,retry:{...$.defaults?.retry,count:Number.parseInt(process.env.CURL_RUNNER_RETRIES,10)}};if(process.env.CURL_RUNNER_RETRY_DELAY)$.defaults={...$.defaults,retry:{...$.defaults?.retry,delay:Number.parseInt(process.env.CURL_RUNNER_RETRY_DELAY,10)}};if(process.env.CURL_RUNNER_VERBOSE)$.output={...$.output,verbose:process.env.CURL_RUNNER_VERBOSE.toLowerCase()==="true"};if(process.env.CURL_RUNNER_EXECUTION)$.execution=process.env.CURL_RUNNER_EXECUTION;if(process.env.CURL_RUNNER_CONTINUE_ON_ERROR)$.continueOnError=process.env.CURL_RUNNER_CONTINUE_ON_ERROR.toLowerCase()==="true";if(process.env.CURL_RUNNER_OUTPUT_FORMAT){let K=process.env.CURL_RUNNER_OUTPUT_FORMAT;if(["json","pretty","raw"].includes(K))$.output={...$.output,format:K}}if(process.env.CURL_RUNNER_PRETTY_LEVEL){let K=process.env.CURL_RUNNER_PRETTY_LEVEL;if(["minimal","standard","detailed"].includes(K))$.output={...$.output,prettyLevel:K}}if(process.env.CURL_RUNNER_OUTPUT_FILE)$.output={...$.output,saveToFile:process.env.CURL_RUNNER_OUTPUT_FILE};return $}async run($){try{let{files:K,options:Q}=this.parseArguments($);if(!Q.version&&!Q.help)new L().checkForUpdates().catch(()=>{});if(Q.help){this.showHelp();return}if(Q.version){console.log(`curl-runner v${A()}`);return}let X=this.loadEnvironmentVariables(),Z=await this.loadConfigFile(),z=await this.findYamlFiles(K,Q);if(z.length===0)this.logger.logError("No YAML files found"),process.exit(1);this.logger.logInfo(`Found ${z.length} YAML file(s)`);let W=this.mergeGlobalConfigs(X,Z),D=[],J=[];for(let U of z){this.logger.logInfo(`Processing: ${U}`);let{requests:G,config:I}=await this.processYamlFile(U),E=I?.output||{},_=G.map((T)=>({...T,sourceOutputConfig:E,sourceFile:U}));if(I){let{...T}=I;W=this.mergeGlobalConfigs(W,T)}J.push({file:U,requests:_,config:I}),D.push(..._)}if(Q.execution)W.execution=Q.execution;if(Q.continueOnError!==void 0)W.continueOnError=Q.continueOnError;if(Q.verbose!==void 0)W.output={...W.output,verbose:Q.verbose};if(Q.quiet!==void 0)W.output={...W.output,verbose:!1};if(Q.output)W.output={...W.output,saveToFile:Q.output};if(Q.outputFormat)W.output={...W.output,format:Q.outputFormat};if(Q.prettyLevel)W.output={...W.output,prettyLevel:Q.prettyLevel};if(Q.showHeaders!==void 0)W.output={...W.output,showHeaders:Q.showHeaders};if(Q.showBody!==void 0)W.output={...W.output,showBody:Q.showBody};if(Q.showMetrics!==void 0)W.output={...W.output,showMetrics:Q.showMetrics};if(Q.timeout)W.defaults={...W.defaults,timeout:Q.timeout};if(Q.retries||Q.noRetry){let U=Q.noRetry?0:Q.retries||0;W.defaults={...W.defaults,retry:{...W.defaults?.retry,count:U}}}if(Q.retryDelay)W.defaults={...W.defaults,retry:{...W.defaults?.retry,delay:Q.retryDelay}};if(D.length===0)this.logger.logError("No requests found in YAML files"),process.exit(1);let w=new B(W),O;if(J.length>1){let U=[],G=0;for(let _=0;_<J.length;_++){let T=J[_];this.logger.logFileHeader(T.file,T.requests.length);let R=await w.execute(T.requests);if(U.push(...R.results),G+=R.duration,_<J.length-1)console.log()}let I=U.filter((_)=>_.success).length,E=U.filter((_)=>!_.success).length;O={total:U.length,successful:I,failed:E,duration:G,results:U},w.logger.logSummary(O,!0)}else O=await w.execute(D);process.exit(O.failed>0&&!W.continueOnError?1:0)}catch(K){this.logger.logError(K instanceof Error?K.message:String(K)),process.exit(1)}}parseArguments($){let K={},Q=[];for(let X=0;X<$.length;X++){let Z=$[X];if(Z.startsWith("--")){let z=Z.slice(2),W=$[X+1];if(z==="help"||z==="version")K[z]=!0;else if(z==="no-retry")K.noRetry=!0;else if(z==="quiet")K.quiet=!0;else if(z==="show-headers")K.showHeaders=!0;else if(z==="show-body")K.showBody=!0;else if(z==="show-metrics")K.showMetrics=!0;else if(W&&!W.startsWith("--")){if(z==="continue-on-error")K.continueOnError=W==="true";else if(z==="verbose")K.verbose=W==="true";else if(z==="timeout")K.timeout=Number.parseInt(W,10);else if(z==="retries")K.retries=Number.parseInt(W,10);else if(z==="retry-delay")K.retryDelay=Number.parseInt(W,10);else if(z==="output-format"){if(["json","pretty","raw"].includes(W))K.outputFormat=W}else if(z==="pretty-level"){if(["minimal","standard","detailed"].includes(W))K.prettyLevel=W}else K[z]=W;X++}else K[z]=!0}else if(Z.startsWith("-")){let z=Z.slice(1);for(let W of z)switch(W){case"h":K.help=!0;break;case"v":K.verbose=!0;break;case"p":K.execution="parallel";break;case"c":K.continueOnError=!0;break;case"q":K.quiet=!0;break;case"o":{let D=$[X+1];if(D&&!D.startsWith("-"))K.output=D,X++;break}}}else Q.push(Z)}return{files:Q,options:K}}async findYamlFiles($,K){let Q=new Set,X=[];if($.length===0)X=K.all?["**/*.yaml","**/*.yml"]:["*.yaml","*.yml"];else for(let Z of $)try{let W=await(await import("fs/promises")).stat(Z);if(W.isDirectory()){if(X.push(`${Z}/*.yaml`,`${Z}/*.yml`),K.all)X.push(`${Z}/**/*.yaml`,`${Z}/**/*.yml`)}else if(W.isFile())X.push(Z)}catch{X.push(Z)}for(let Z of X){let z=new c(Z);for await(let W of z.scan("."))if(W.endsWith(".yaml")||W.endsWith(".yml"))Q.add(W)}return Array.from(Q).sort()}async processYamlFile($){let K=await H.parseFile($),Q=[],X;if(K.global)X=K.global;let Z={...K.global?.variables,...K.collection?.variables},z={...K.global?.defaults,...K.collection?.defaults};if(K.request){let W=this.prepareRequest(K.request,Z,z);Q.push(W)}if(K.requests)for(let W of K.requests){let D=this.prepareRequest(W,Z,z);Q.push(D)}if(K.collection?.requests)for(let W of K.collection.requests){let D=this.prepareRequest(W,Z,z);Q.push(D)}return{requests:Q,config:X}}prepareRequest($,K,Q){let X=H.interpolateVariables($,K);return H.mergeConfigs(Q,X)}mergeGlobalConfigs($,K){return{...$,...K,variables:{...$.variables,...K.variables},output:{...$.output,...K.output},defaults:{...$.defaults,...K.defaults}}}showHelp(){console.log(`
3
+ var x=Object.create;var{getPrototypeOf:y,defineProperty:q,getOwnPropertyNames:f}=Object;var p=Object.prototype.hasOwnProperty;var g=($,K,Q)=>{Q=$!=null?x(y($)):{};let Z=K||!$||!$.__esModule?q(Q,"default",{value:$,enumerable:!0}):Q;for(let X of f($))if(!p.call(Z,X))q(Z,X,{get:()=>$[X],enumerable:!0});return Z};var E=import.meta.require;var{Glob:u}=globalThis.Bun;var{YAML:N}=globalThis.Bun;class G{static async parseFile($){let Q=await Bun.file($).text();return N.parse(Q)}static parse($){return N.parse($)}static interpolateVariables($,K,Q){if(typeof $==="string"){let Z=$.match(/^\$\{([^}]+)\}$/);if(Z){let X=Z[1],z=G.resolveVariable(X,K,Q);return z!==null?z:$}return $.replace(/\$\{([^}]+)\}/g,(X,z)=>{let W=G.resolveVariable(z,K,Q);return W!==null?W:X})}if(Array.isArray($))return $.map((Z)=>G.interpolateVariables(Z,K,Q));if($&&typeof $==="object"){let Z={};for(let[X,z]of Object.entries($))Z[X]=G.interpolateVariables(z,K,Q);return Z}return $}static resolveVariable($,K,Q){if($.startsWith("store.")&&Q){let X=$.slice(6);if(X in Q)return Q[X];return null}let Z=G.resolveDynamicVariable($);if(Z!==null)return Z;if($ in K)return K[$];return null}static resolveDynamicVariable($){if($==="UUID")return crypto.randomUUID();if($==="CURRENT_TIME"||$==="TIMESTAMP")return Date.now().toString();if($.startsWith("DATE:")){let K=$.slice(5);return G.formatDate(new Date,K)}if($.startsWith("TIME:")){let K=$.slice(5);return G.formatTime(new Date,K)}return null}static formatDate($,K){let Q=$.getFullYear(),Z=String($.getMonth()+1).padStart(2,"0"),X=String($.getDate()).padStart(2,"0");return K.replace("YYYY",Q.toString()).replace("MM",Z).replace("DD",X)}static formatTime($,K){let Q=String($.getHours()).padStart(2,"0"),Z=String($.getMinutes()).padStart(2,"0"),X=String($.getSeconds()).padStart(2,"0");return K.replace("HH",Q).replace("mm",Z).replace("ss",X)}static mergeConfigs($,K){return{...$,...K,headers:{...$.headers,...K.headers},params:{...$.params,...K.params},variables:{...$.variables,...K.variables}}}}function d($){return typeof $==="object"&&$!==null&&"file"in $}function P($){return $.replace(/'/g,"'\\''")}class j{static buildCommand($){let K=["curl"];if(K.push("-X",$.method||"GET"),K.push("-w",'"\\n__CURL_METRICS_START__%{json}__CURL_METRICS_END__"'),$.headers)for(let[Z,X]of Object.entries($.headers))K.push("-H",`"${Z}: ${X}"`);if($.auth){if($.auth.type==="basic"&&$.auth.username&&$.auth.password)K.push("-u",`"${$.auth.username}:${$.auth.password}"`);else if($.auth.type==="bearer"&&$.auth.token)K.push("-H",`"Authorization: Bearer ${$.auth.token}"`)}if($.formData)for(let[Z,X]of Object.entries($.formData))if(d(X)){let z=`@${X.file}`;if(X.filename)z+=`;filename=${X.filename}`;if(X.contentType)z+=`;type=${X.contentType}`;K.push("-F",`'${Z}=${P(z)}'`)}else{let z=String(X);K.push("-F",`'${Z}=${P(z)}'`)}else if($.body){let Z=typeof $.body==="string"?$.body:JSON.stringify($.body);if(K.push("-d",`'${Z.replace(/'/g,"'\\''")}'`),!$.headers?.["Content-Type"])K.push("-H",'"Content-Type: application/json"')}if($.timeout)K.push("--max-time",$.timeout.toString());if($.followRedirects!==!1){if(K.push("-L"),$.maxRedirects)K.push("--max-redirs",$.maxRedirects.toString())}if($.proxy)K.push("-x",$.proxy);if($.insecure)K.push("-k");if($.output)K.push("-o",$.output);K.push("-s","-S");let Q=$.url;if($.params&&Object.keys($.params).length>0){let Z=new URLSearchParams($.params).toString();Q+=(Q.includes("?")?"&":"?")+Z}return K.push(`"${Q}"`),K.join(" ")}static async executeCurl($){try{let K=Bun.spawn(["sh","-c",$],{stdout:"pipe",stderr:"pipe"}),Q=await new Response(K.stdout).text(),Z=await new Response(K.stderr).text();if(await K.exited,K.exitCode!==0&&!Q)return{success:!1,error:Z||`Command failed with exit code ${K.exitCode}`};let X=Q,z={},W=Q.match(/__CURL_METRICS_START__(.+?)__CURL_METRICS_END__/);if(W){X=Q.replace(/__CURL_METRICS_START__.+?__CURL_METRICS_END__/,"").trim();try{z=JSON.parse(W[1])}catch(J){}}let U={};if(z.response_code){let J=Z.split(`
4
+ `).filter((w)=>w.includes(":"));for(let w of J){let[_,...H]=w.split(":");if(_&&H.length>0)U[_.trim()]=H.join(":").trim()}}return{success:!0,status:z.response_code||z.http_code,headers:U,body:X,metrics:{duration:(z.time_total||0)*1000,size:z.size_download,dnsLookup:(z.time_namelookup||0)*1000,tcpConnection:(z.time_connect||0)*1000,tlsHandshake:(z.time_appconnect||0)*1000,firstByte:(z.time_starttransfer||0)*1000,download:(z.time_total||0)*1000}}}catch(K){return{success:!1,error:K instanceof Error?K.message:String(K)}}}}class L{colors;constructor($){this.colors=$}color($,K){if(!K||!this.colors[K])return $;return`${this.colors[K]}${$}${this.colors.reset}`}render($,K=" "){$.forEach((Q,Z)=>{let X=Z===$.length-1,z=X?`${K}\u2514\u2500`:`${K}\u251C\u2500`;if(Q.label&&Q.value){let W=Q.color?this.color(Q.value,Q.color):Q.value,U=W.split(`
5
+ `);if(U.length===1)console.log(`${z} ${Q.label}: ${W}`);else{console.log(`${z} ${Q.label}:`);let J=X?`${K} `:`${K}\u2502 `;U.forEach((w)=>{console.log(`${J}${w}`)})}}else if(Q.label&&!Q.value)console.log(`${z} ${Q.label}:`);else if(!Q.label&&Q.value){let W=X?`${K} `:`${K}\u2502 `;console.log(`${W}${Q.value}`)}if(Q.children&&Q.children.length>0){let W=X?`${K} `:`${K}\u2502 `;this.render(Q.children,W)}})}}class M{config;colors={reset:"\x1B[0m",bright:"\x1B[1m",dim:"\x1B[2m",underscore:"\x1B[4m",black:"\x1B[30m",red:"\x1B[31m",green:"\x1B[32m",yellow:"\x1B[33m",blue:"\x1B[34m",magenta:"\x1B[35m",cyan:"\x1B[36m",white:"\x1B[37m",bgBlack:"\x1B[40m",bgRed:"\x1B[41m",bgGreen:"\x1B[42m",bgYellow:"\x1B[43m",bgBlue:"\x1B[44m",bgMagenta:"\x1B[45m",bgCyan:"\x1B[46m",bgWhite:"\x1B[47m"};constructor($={}){this.config={verbose:!1,showHeaders:!1,showBody:!0,showMetrics:!1,format:"pretty",prettyLevel:"minimal",...$}}color($,K){return`${this.colors[K]}${$}${this.colors.reset}`}getShortFilename($){return $.replace(/.*\//,"").replace(".yaml","")}shouldShowOutput(){if(this.config.format==="raw")return!1;if(this.config.format==="pretty")return!0;return this.config.verbose!==!1}shouldShowHeaders(){if(this.config.format!=="pretty")return this.config.showHeaders||!1;switch(this.config.prettyLevel||"standard"){case"minimal":return!1;case"standard":return this.config.showHeaders||!1;case"detailed":return!0;default:return this.config.showHeaders||!1}}shouldShowBody(){if(this.config.format!=="pretty")return this.config.showBody!==!1;switch(this.config.prettyLevel||"standard"){case"minimal":return!1;case"standard":return this.config.showBody!==!1;case"detailed":return!0;default:return this.config.showBody!==!1}}shouldShowMetrics(){if(this.config.format!=="pretty")return this.config.showMetrics||!1;switch(this.config.prettyLevel||"standard"){case"minimal":return!1;case"standard":return this.config.showMetrics||!1;case"detailed":return!0;default:return this.config.showMetrics||!1}}shouldShowRequestDetails(){if(this.config.format!=="pretty")return this.config.verbose||!1;switch(this.config.prettyLevel||"standard"){case"minimal":return!1;case"standard":return this.config.verbose||!1;case"detailed":return!0;default:return this.config.verbose||!1}}shouldShowSeparators(){if(this.config.format!=="pretty")return!0;switch(this.config.prettyLevel||"standard"){case"minimal":return!1;case"standard":return!0;case"detailed":return!0;default:return!0}}colorStatusCode($){return this.color($,"yellow")}logValidationErrors($){let K=$.split("; ");if(K.length===1){let Q=K[0].trim(),Z=Q.match(/^Expected status (.+?), got (.+)$/);if(Z){let[,X,z]=Z,W=this.colorStatusCode(X.replace(" or ","|")),U=this.color(z,"red");console.log(` ${this.color("\u2717","red")} ${this.color("Error:","red")} Expected ${this.color("status","yellow")} ${W}, got ${U}`)}else console.log(` ${this.color("\u2717","red")} ${this.color("Error:","red")} ${Q}`)}else{console.log(` ${this.color("\u2717","red")} ${this.color("Validation Errors:","red")}`);for(let Q of K){let Z=Q.trim();if(Z)if(Z.startsWith("Expected ")){let X=Z.match(/^Expected status (.+?), got (.+)$/);if(X){let[,z,W]=X,U=this.colorStatusCode(z.replace(" or ","|")),J=this.color(W,"red");console.log(` ${this.color("\u2022","red")} ${this.color("status","yellow")}: expected ${U}, got ${J}`)}else{let z=Z.match(/^Expected (.+?) to be (.+?), got (.+)$/);if(z){let[,W,U,J]=z;console.log(` ${this.color("\u2022","red")} ${this.color(W,"yellow")}: expected ${this.color(U,"green")}, got ${this.color(J,"red")}`)}else console.log(` ${this.color("\u2022","red")} ${Z}`)}}else console.log(` ${this.color("\u2022","red")} ${Z}`)}}}formatJson($){if(this.config.format==="raw")return typeof $==="string"?$:JSON.stringify($);if(this.config.format==="json")return JSON.stringify($);return JSON.stringify($,null,2)}formatDuration($){if($<1000)return`${$.toFixed(0)}ms`;return`${($/1000).toFixed(2)}s`}formatSize($){if(!$)return"0 B";let K=["B","KB","MB","GB"],Q=Math.floor(Math.log($)/Math.log(1024));return`${($/1024**Q).toFixed(2)} ${K[Q]}`}logExecutionStart($,K){if(!this.shouldShowOutput())return;if(this.shouldShowSeparators())console.log(),console.log(this.color(`Executing ${$} request(s) in ${K} mode`,"dim")),console.log();else console.log()}logRequestStart($,K){return}logCommand($){if(this.shouldShowRequestDetails())console.log(this.color(" Command:","dim")),console.log(this.color(` ${$}`,"dim"))}logRetry($,K){console.log(this.color(` \u21BB Retry ${$}/${K}...`,"yellow"))}logRequestComplete($){if(this.config.format==="raw"){if($.success&&this.config.showBody&&$.body){let U=this.formatJson($.body);console.log(U)}return}if(this.config.format==="json"){let U={request:{name:$.request.name,url:$.request.url,method:$.request.method||"GET"},success:$.success,status:$.status,...this.shouldShowHeaders()&&$.headers?{headers:$.headers}:{},...this.shouldShowBody()&&$.body?{body:$.body}:{},...$.error?{error:$.error}:{},...this.shouldShowMetrics()&&$.metrics?{metrics:$.metrics}:{}};console.log(JSON.stringify(U,null,2));return}if(!this.shouldShowOutput())return;let K=this.config.prettyLevel||"minimal",Q=$.success?"green":"red",Z=$.success?"\u2713":"x",X=$.request.name||"Request";if(K==="minimal"){let U=$.request.sourceFile?this.getShortFilename($.request.sourceFile):"inline";console.log(`${this.color(Z,Q)} ${this.color(X,"bright")} [${U}]`);let J=[],w=new L(this.colors);J.push({label:$.request.method||"GET",value:$.request.url,color:"blue"});let _=$.status?`${$.status}`:"ERROR";if(J.push({label:`${Z} Status`,value:_,color:Q}),$.metrics){let H=`${this.formatDuration($.metrics.duration)} | ${this.formatSize($.metrics.size)}`;J.push({label:"Duration",value:H,color:"cyan"})}if(w.render(J),$.error)console.log(),this.logValidationErrors($.error);console.log();return}console.log(`${this.color(Z,Q)} ${this.color(X,"bright")}`);let z=[],W=new L(this.colors);if(z.push({label:"URL",value:$.request.url,color:"blue"}),z.push({label:"Method",value:$.request.method||"GET",color:"yellow"}),z.push({label:"Status",value:String($.status||"ERROR"),color:Q}),$.metrics)z.push({label:"Duration",value:this.formatDuration($.metrics.duration),color:"cyan"});if(this.shouldShowHeaders()&&$.headers&&Object.keys($.headers).length>0){let U=Object.entries($.headers).map(([J,w])=>({label:this.color(J,"dim"),value:String(w)}));z.push({label:"Headers",children:U})}if(this.shouldShowBody()&&$.body){let J=this.formatJson($.body).split(`
6
+ `),w=this.shouldShowRequestDetails()?1/0:10,_=J.slice(0,w);if(J.length>w)_.push(this.color(`... (${J.length-w} more lines)`,"dim"));z.push({label:"Response Body",value:_.join(`
7
+ `)})}if(this.shouldShowMetrics()&&$.metrics&&K==="detailed"){let U=$.metrics,J=[];if(J.push({label:"Request Duration",value:this.formatDuration(U.duration),color:"cyan"}),U.size!==void 0)J.push({label:"Response Size",value:this.formatSize(U.size),color:"cyan"});if(U.dnsLookup)J.push({label:"DNS Lookup",value:this.formatDuration(U.dnsLookup),color:"cyan"});if(U.tcpConnection)J.push({label:"TCP Connection",value:this.formatDuration(U.tcpConnection),color:"cyan"});if(U.tlsHandshake)J.push({label:"TLS Handshake",value:this.formatDuration(U.tlsHandshake),color:"cyan"});if(U.firstByte)J.push({label:"Time to First Byte",value:this.formatDuration(U.firstByte),color:"cyan"});z.push({label:"Metrics",children:J})}if(W.render(z),$.error)console.log(),this.logValidationErrors($.error);console.log()}logSummary($,K=!1){if(this.config.format==="raw")return;if(this.config.format==="json"){let U={summary:{total:$.total,successful:$.successful,failed:$.failed,duration:$.duration},results:$.results.map((J)=>({request:{name:J.request.name,url:J.request.url,method:J.request.method||"GET"},success:J.success,status:J.status,...this.shouldShowHeaders()&&J.headers?{headers:J.headers}:{},...this.shouldShowBody()&&J.body?{body:J.body}:{},...J.error?{error:J.error}:{},...this.shouldShowMetrics()&&J.metrics?{metrics:J.metrics}:{}}))};console.log(JSON.stringify(U,null,2));return}if(!this.shouldShowOutput())return;let Q=this.config.prettyLevel||"minimal";if(K)console.log();if(Q==="minimal"){let U=$.failed===0?"green":"red",J=$.failed===0?`${$.total} request${$.total===1?"":"s"} completed successfully`:`${$.successful}/${$.total} request${$.total===1?"":"s"} completed, ${$.failed} failed`;console.log(`${K?"\u25C6 Global Summary":"Summary"}: ${this.color(J,U)}`);return}let Z=($.successful/$.total*100).toFixed(1),X=$.failed===0?"green":"red",z=$.failed===0?`${$.total} request${$.total===1?"":"s"} completed successfully`:`${$.successful}/${$.total} request${$.total===1?"":"s"} completed, ${$.failed} failed`,W=K?"\u25C6 Global Summary":"Summary";if(console.log(),console.log(`${W}: ${this.color(z,X)} (${this.color(this.formatDuration($.duration),"cyan")})`),$.failed>0&&this.shouldShowRequestDetails())$.results.filter((U)=>!U.success).forEach((U)=>{let J=U.request.name||U.request.url;console.log(` ${this.color("\u2022","red")} ${J}: ${U.error}`)})}logError($){console.error(this.color(`\u2717 ${$}`,"red"))}logWarning($){console.warn(this.color(`\u26A0 ${$}`,"yellow"))}logInfo($){console.log(this.color(`\u2139 ${$}`,"blue"))}logSuccess($){console.log(this.color(`\u2713 ${$}`,"green"))}logFileHeader($,K){if(!this.shouldShowOutput()||this.config.format!=="pretty")return;let Q=$.replace(/.*\//,"").replace(".yaml","");console.log(),console.log(this.color(`\u25B6 ${Q}.yaml`,"bright")+this.color(` (${K} request${K===1?"":"s"})`,"dim"))}}function m($,K){let Q=K.split("."),Z=$;for(let X of Q){if(Z===null||Z===void 0)return;if(typeof Z!=="object")return;let z=X.match(/^(\w+)\[(\d+)\]$/);if(z){let[,W,U]=z,J=Number.parseInt(U,10);if(Z=Z[W],Array.isArray(Z))Z=Z[J];else return}else if(/^\d+$/.test(X)&&Array.isArray(Z))Z=Z[Number.parseInt(X,10)];else Z=Z[X]}return Z}function c($){if($===void 0||$===null)return"";if(typeof $==="string")return $;if(typeof $==="number"||typeof $==="boolean")return String($);return JSON.stringify($)}function h($,K){let Q={},Z={status:$.status,headers:$.headers||{},body:$.body,metrics:$.metrics};for(let[X,z]of Object.entries(K)){let W=m(Z,z);Q[X]=c(W)}return Q}function b(){return{}}class S{logger;globalConfig;constructor($={}){this.globalConfig=$,this.logger=new M($.output)}mergeOutputConfig($){return{...this.globalConfig.output,...$.sourceOutputConfig}}isFileAttachment($){return typeof $==="object"&&$!==null&&"file"in $}async validateFileAttachments($){if(!$.formData)return;let K=[];for(let[Q,Z]of Object.entries($.formData))if(this.isFileAttachment(Z)){let X=Z.file;if(!await Bun.file(X).exists())K.push(`${Q}: ${X}`)}if(K.length>0)return`File(s) not found: ${K.join(", ")}`;return}async executeRequest($,K=0){let Q=performance.now(),Z=this.mergeOutputConfig($),X=new M(Z);X.logRequestStart($,K);let z=await this.validateFileAttachments($);if(z){let H={request:$,success:!1,error:z,metrics:{duration:performance.now()-Q}};return X.logRequestComplete(H),H}let W=j.buildCommand($);X.logCommand(W);let U=0,J,w=($.retry?.count||0)+1;while(U<w){if(U>0){if(X.logRetry(U,w-1),$.retry?.delay)await Bun.sleep($.retry.delay)}let H=await j.executeCurl(W);if(H.success){let D=H.body;try{if(H.headers?.["content-type"]?.includes("application/json")||D&&(D.trim().startsWith("{")||D.trim().startsWith("[")))D=JSON.parse(D)}catch(T){}let I={request:$,success:!0,status:H.status,headers:H.headers,body:D,metrics:{...H.metrics,duration:performance.now()-Q}};if($.expect){let T=this.validateResponse(I,$.expect);if(!T.success)I.success=!1,I.error=T.error}return X.logRequestComplete(I),I}J=H.error,U++}let _={request:$,success:!1,error:J,metrics:{duration:performance.now()-Q}};return X.logRequestComplete(_),_}validateResponse($,K){if(!K)return{success:!0};let Q=[];if(K.status!==void 0){let X=Array.isArray(K.status)?K.status:[K.status];if(!X.includes($.status||0))Q.push(`Expected status ${X.join(" or ")}, got ${$.status}`)}if(K.headers)for(let[X,z]of Object.entries(K.headers)){let W=$.headers?.[X]||$.headers?.[X.toLowerCase()];if(W!==z)Q.push(`Expected header ${X}="${z}", got "${W}"`)}if(K.body!==void 0){let X=this.validateBodyProperties($.body,K.body,"");if(X.length>0)Q.push(...X)}if(K.responseTime!==void 0&&$.metrics){let X=$.metrics.duration;if(!this.validateRangePattern(X,K.responseTime))Q.push(`Expected response time to match ${K.responseTime}ms, got ${X.toFixed(2)}ms`)}let Z=Q.length>0;if(K.failure===!0){if(Z)return{success:!1,error:Q.join("; ")};let X=$.status||0;if(X>=400)return{success:!0};else return{success:!1,error:`Expected request to fail (4xx/5xx) but got status ${X}`}}else if(Z)return{success:!1,error:Q.join("; ")};else return{success:!0}}validateBodyProperties($,K,Q){let Z=[];if(typeof K!=="object"||K===null){let X=this.validateValue($,K,Q||"body");if(!X.isValid)Z.push(X.error);return Z}if(Array.isArray(K)){let X=this.validateValue($,K,Q||"body");if(!X.isValid)Z.push(X.error);return Z}for(let[X,z]of Object.entries(K)){let W=Q?`${Q}.${X}`:X,U;if(Array.isArray($)&&this.isArraySelector(X))U=this.getArrayValue($,X);else U=$?.[X];if(typeof z==="object"&&z!==null&&!Array.isArray(z)){let J=this.validateBodyProperties(U,z,W);Z.push(...J)}else{let J=this.validateValue(U,z,W);if(!J.isValid)Z.push(J.error)}}return Z}validateValue($,K,Q){if(K==="*")return{isValid:!0};if(Array.isArray(K)){if(!K.some((X)=>{if(X==="*")return!0;if(typeof X==="string"&&this.isRegexPattern(X))return this.validateRegexPattern($,X);if(typeof X==="string"&&this.isRangePattern(X))return this.validateRangePattern($,X);return $===X}))return{isValid:!1,error:`Expected ${Q} to match one of ${JSON.stringify(K)}, got ${JSON.stringify($)}`};return{isValid:!0}}if(typeof K==="string"&&this.isRegexPattern(K)){if(!this.validateRegexPattern($,K))return{isValid:!1,error:`Expected ${Q} to match pattern ${K}, got ${JSON.stringify($)}`};return{isValid:!0}}if(typeof K==="string"&&this.isRangePattern(K)){if(!this.validateRangePattern($,K))return{isValid:!1,error:`Expected ${Q} to match range ${K}, got ${JSON.stringify($)}`};return{isValid:!0}}if(K==="null"||K===null){if($!==null)return{isValid:!1,error:`Expected ${Q} to be null, got ${JSON.stringify($)}`};return{isValid:!0}}if($!==K)return{isValid:!1,error:`Expected ${Q} to be ${JSON.stringify(K)}, got ${JSON.stringify($)}`};return{isValid:!0}}isRegexPattern($){return $.startsWith("^")||$.endsWith("$")||$.includes("\\d")||$.includes("\\w")||$.includes("\\s")||$.includes("[")||$.includes("*")||$.includes("+")||$.includes("?")}validateRegexPattern($,K){let Q=String($);try{return new RegExp(K).test(Q)}catch{return!1}}isRangePattern($){return/^(>=?|<=?|>|<)\s*[\d.-]+(\s*,\s*(>=?|<=?|>|<)\s*[\d.-]+)*$/.test($)}validateRangePattern($,K){let Q=Number($);if(Number.isNaN(Q))return!1;let Z=K.match(/^([\d.-]+)\s*-\s*([\d.-]+)$/);if(Z){let z=Number(Z[1]),W=Number(Z[2]);return Q>=z&&Q<=W}return K.split(",").map((z)=>z.trim()).every((z)=>{let W=z.match(/^(>=?|<=?|>|<)\s*([\d.-]+)$/);if(!W)return!1;let U=W[1],J=Number(W[2]);switch(U){case">":return Q>J;case">=":return Q>=J;case"<":return Q<J;case"<=":return Q<=J;default:return!1}})}isArraySelector($){return/^\[.*\]$/.test($)||$==="*"||$.startsWith("slice(")}getArrayValue($,K){if(K==="*")return $;if(K.startsWith("[")&&K.endsWith("]")){let Q=K.slice(1,-1);if(Q==="*")return $;let Z=Number(Q);if(!Number.isNaN(Z))return Z>=0?$[Z]:$[$.length+Z]}if(K.startsWith("slice(")){let Q=K.match(/slice\((\d+)(?:,(\d+))?\)/);if(Q){let Z=Number(Q[1]),X=Q[2]?Number(Q[2]):void 0;return $.slice(Z,X)}}return}async executeSequential($){let K=performance.now(),Q=[],Z=b();for(let X=0;X<$.length;X++){let z=this.interpolateStoreVariables($[X],Z),W=await this.executeRequest(z,X+1);if(Q.push(W),W.success&&z.store){let U=h(W,z.store);Object.assign(Z,U),this.logStoredValues(U)}if(!W.success&&!this.globalConfig.continueOnError){this.logger.logError("Stopping execution due to error");break}}return this.createSummary(Q,performance.now()-K)}interpolateStoreVariables($,K){if(Object.keys(K).length===0)return $;return G.interpolateVariables($,{},K)}logStoredValues($){if(Object.keys($).length===0)return;let K=Object.entries($);for(let[Q,Z]of K){let X=Z.length>50?`${Z.substring(0,50)}...`:Z;this.logger.logInfo(`Stored: ${Q} = "${X}"`)}}async executeParallel($){let K=performance.now(),Q=$.map((X,z)=>this.executeRequest(X,z+1)),Z=await Promise.all(Q);return this.createSummary(Z,performance.now()-K)}async execute($){this.logger.logExecutionStart($.length,this.globalConfig.execution||"sequential");let K=this.globalConfig.execution==="parallel"?await this.executeParallel($):await this.executeSequential($);if(this.logger.logSummary(K),this.globalConfig.output?.saveToFile)await this.saveSummaryToFile(K);return K}createSummary($,K){let Q=$.filter((X)=>X.success).length,Z=$.filter((X)=>!X.success).length;return{total:$.length,successful:Q,failed:Z,duration:K,results:$}}async saveSummaryToFile($){let K=this.globalConfig.output?.saveToFile;if(!K)return;let Q=JSON.stringify($,null,2);await Bun.write(K,Q),this.logger.logInfo(`Results saved to ${K}`)}}function B(){if(typeof BUILD_VERSION<"u")return BUILD_VERSION;if(process.env.CURL_RUNNER_VERSION)return process.env.CURL_RUNNER_VERSION;try{let $=["../package.json","./package.json","../../package.json"];for(let K of $)try{let Q=E(K);if(Q.name==="@curl-runner/cli"&&Q.version)return Q.version}catch{}return"0.0.0"}catch{return"0.0.0"}}var V={reset:"\x1B[0m",bright:"\x1B[1m",dim:"\x1B[2m",underscore:"\x1B[4m",black:"\x1B[30m",red:"\x1B[31m",green:"\x1B[32m",yellow:"\x1B[33m",blue:"\x1B[34m",magenta:"\x1B[35m",cyan:"\x1B[36m",white:"\x1B[37m",bgBlack:"\x1B[40m",bgRed:"\x1B[41m",bgGreen:"\x1B[42m",bgYellow:"\x1B[43m",bgBlue:"\x1B[44m",bgMagenta:"\x1B[45m",bgCyan:"\x1B[46m",bgWhite:"\x1B[47m"};function O($,K){return`${V[K]}${$}${V.reset}`}var C=`${process.env.HOME}/.curl-runner-version-cache.json`,i=86400000,n="https://registry.npmjs.org/@curl-runner/cli/latest";class R{async checkForUpdates($=!1){try{if(process.env.CI)return;let K=B();if(K==="0.0.0")return;if(!$){let Z=await this.getCachedVersion();if(Z&&Date.now()-Z.lastCheck<i){this.compareVersions(K,Z.latestVersion);return}}let Q=await this.fetchLatestVersion();if(Q)await this.setCachedVersion(Q),this.compareVersions(K,Q)}catch{}}async fetchLatestVersion(){try{let $=await fetch(n,{signal:AbortSignal.timeout(3000)});if(!$.ok)return null;return(await $.json()).version}catch{return null}}compareVersions($,K){if(this.isNewerVersion($,K))console.log(),console.log(O("\u256D\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256E","yellow")),console.log(O("\u2502","yellow")+" "+O("\u2502","yellow")),console.log(O("\u2502","yellow")+" "+O("\uD83D\uDCE6 New version available!","bright")+` ${O($,"red")} \u2192 ${O(K,"green")} `+O("\u2502","yellow")),console.log(O("\u2502","yellow")+" "+O("\u2502","yellow")),console.log(O("\u2502","yellow")+" Update with: "+O("npm install -g @curl-runner/cli","cyan")+" "+O("\u2502","yellow")),console.log(O("\u2502","yellow")+" or: "+O("bun install -g @curl-runner/cli","cyan")+" "+O("\u2502","yellow")),console.log(O("\u2502","yellow")+" "+O("\u2502","yellow")),console.log(O("\u2570\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256F","yellow")),console.log()}isNewerVersion($,K){try{let Q=$.replace(/^v/,""),Z=K.replace(/^v/,""),X=Q.split(".").map(Number),z=Z.split(".").map(Number);for(let W=0;W<Math.max(X.length,z.length);W++){let U=X[W]||0,J=z[W]||0;if(J>U)return!0;if(J<U)return!1}return!1}catch{return!1}}async getCachedVersion(){try{let $=Bun.file(C);if(await $.exists())return JSON.parse(await $.text())}catch{}return null}async setCachedVersion($){try{let K={lastCheck:Date.now(),latestVersion:$};await Bun.write(C,JSON.stringify(K))}catch{}}}class v{logger=new M;async loadConfigFile(){let $=["curl-runner.yaml","curl-runner.yml",".curl-runner.yaml",".curl-runner.yml"];for(let K of $)try{if(await Bun.file(K).exists()){let Z=await G.parseFile(K),X=Z.global||Z;return this.logger.logInfo(`Loaded configuration from ${K}`),X}}catch(Q){this.logger.logWarning(`Failed to load configuration from ${K}: ${Q}`)}return{}}loadEnvironmentVariables(){let $={};if(process.env.CURL_RUNNER_TIMEOUT)$.defaults={...$.defaults,timeout:Number.parseInt(process.env.CURL_RUNNER_TIMEOUT,10)};if(process.env.CURL_RUNNER_RETRIES)$.defaults={...$.defaults,retry:{...$.defaults?.retry,count:Number.parseInt(process.env.CURL_RUNNER_RETRIES,10)}};if(process.env.CURL_RUNNER_RETRY_DELAY)$.defaults={...$.defaults,retry:{...$.defaults?.retry,delay:Number.parseInt(process.env.CURL_RUNNER_RETRY_DELAY,10)}};if(process.env.CURL_RUNNER_VERBOSE)$.output={...$.output,verbose:process.env.CURL_RUNNER_VERBOSE.toLowerCase()==="true"};if(process.env.CURL_RUNNER_EXECUTION)$.execution=process.env.CURL_RUNNER_EXECUTION;if(process.env.CURL_RUNNER_CONTINUE_ON_ERROR)$.continueOnError=process.env.CURL_RUNNER_CONTINUE_ON_ERROR.toLowerCase()==="true";if(process.env.CURL_RUNNER_OUTPUT_FORMAT){let K=process.env.CURL_RUNNER_OUTPUT_FORMAT;if(["json","pretty","raw"].includes(K))$.output={...$.output,format:K}}if(process.env.CURL_RUNNER_PRETTY_LEVEL){let K=process.env.CURL_RUNNER_PRETTY_LEVEL;if(["minimal","standard","detailed"].includes(K))$.output={...$.output,prettyLevel:K}}if(process.env.CURL_RUNNER_OUTPUT_FILE)$.output={...$.output,saveToFile:process.env.CURL_RUNNER_OUTPUT_FILE};if(process.env.CURL_RUNNER_STRICT_EXIT)$.ci={...$.ci,strictExit:process.env.CURL_RUNNER_STRICT_EXIT.toLowerCase()==="true"};if(process.env.CURL_RUNNER_FAIL_ON)$.ci={...$.ci,failOn:Number.parseInt(process.env.CURL_RUNNER_FAIL_ON,10)};if(process.env.CURL_RUNNER_FAIL_ON_PERCENTAGE){let K=Number.parseFloat(process.env.CURL_RUNNER_FAIL_ON_PERCENTAGE);if(K>=0&&K<=100)$.ci={...$.ci,failOnPercentage:K}}return $}async run($){try{let{files:K,options:Q}=this.parseArguments($);if(!Q.version&&!Q.help)new R().checkForUpdates().catch(()=>{});if(Q.help){this.showHelp();return}if(Q.version){console.log(`curl-runner v${B()}`);return}let Z=this.loadEnvironmentVariables(),X=await this.loadConfigFile(),z=await this.findYamlFiles(K,Q);if(z.length===0)this.logger.logError("No YAML files found"),process.exit(1);this.logger.logInfo(`Found ${z.length} YAML file(s)`);let W=this.mergeGlobalConfigs(Z,X),U=[],J=[];for(let D of z){this.logger.logInfo(`Processing: ${D}`);let{requests:I,config:T}=await this.processYamlFile(D),A=T?.output||{},F=I.map((Y)=>({...Y,sourceOutputConfig:A,sourceFile:D}));if(T){let{...Y}=T;W=this.mergeGlobalConfigs(W,Y)}J.push({file:D,requests:F,config:T}),U.push(...F)}if(Q.execution)W.execution=Q.execution;if(Q.continueOnError!==void 0)W.continueOnError=Q.continueOnError;if(Q.verbose!==void 0)W.output={...W.output,verbose:Q.verbose};if(Q.quiet!==void 0)W.output={...W.output,verbose:!1};if(Q.output)W.output={...W.output,saveToFile:Q.output};if(Q.outputFormat)W.output={...W.output,format:Q.outputFormat};if(Q.prettyLevel)W.output={...W.output,prettyLevel:Q.prettyLevel};if(Q.showHeaders!==void 0)W.output={...W.output,showHeaders:Q.showHeaders};if(Q.showBody!==void 0)W.output={...W.output,showBody:Q.showBody};if(Q.showMetrics!==void 0)W.output={...W.output,showMetrics:Q.showMetrics};if(Q.timeout)W.defaults={...W.defaults,timeout:Q.timeout};if(Q.retries||Q.noRetry){let D=Q.noRetry?0:Q.retries||0;W.defaults={...W.defaults,retry:{...W.defaults?.retry,count:D}}}if(Q.retryDelay)W.defaults={...W.defaults,retry:{...W.defaults?.retry,delay:Q.retryDelay}};if(Q.strictExit!==void 0)W.ci={...W.ci,strictExit:Q.strictExit};if(Q.failOn!==void 0)W.ci={...W.ci,failOn:Q.failOn};if(Q.failOnPercentage!==void 0)W.ci={...W.ci,failOnPercentage:Q.failOnPercentage};if(U.length===0)this.logger.logError("No requests found in YAML files"),process.exit(1);let w=new S(W),_;if(J.length>1){let D=[],I=0;for(let F=0;F<J.length;F++){let Y=J[F];this.logger.logFileHeader(Y.file,Y.requests.length);let k=await w.execute(Y.requests);if(D.push(...k.results),I+=k.duration,F<J.length-1)console.log()}let T=D.filter((F)=>F.success).length,A=D.filter((F)=>!F.success).length;_={total:D.length,successful:T,failed:A,duration:I,results:D},w.logger.logSummary(_,!0)}else _=await w.execute(U);let H=this.determineExitCode(_,W);process.exit(H)}catch(K){this.logger.logError(K instanceof Error?K.message:String(K)),process.exit(1)}}parseArguments($){let K={},Q=[];for(let Z=0;Z<$.length;Z++){let X=$[Z];if(X.startsWith("--")){let z=X.slice(2),W=$[Z+1];if(z==="help"||z==="version")K[z]=!0;else if(z==="no-retry")K.noRetry=!0;else if(z==="quiet")K.quiet=!0;else if(z==="show-headers")K.showHeaders=!0;else if(z==="show-body")K.showBody=!0;else if(z==="show-metrics")K.showMetrics=!0;else if(z==="strict-exit")K.strictExit=!0;else if(W&&!W.startsWith("--")){if(z==="continue-on-error")K.continueOnError=W==="true";else if(z==="verbose")K.verbose=W==="true";else if(z==="timeout")K.timeout=Number.parseInt(W,10);else if(z==="retries")K.retries=Number.parseInt(W,10);else if(z==="retry-delay")K.retryDelay=Number.parseInt(W,10);else if(z==="fail-on")K.failOn=Number.parseInt(W,10);else if(z==="fail-on-percentage"){let U=Number.parseFloat(W);if(U>=0&&U<=100)K.failOnPercentage=U}else if(z==="output-format"){if(["json","pretty","raw"].includes(W))K.outputFormat=W}else if(z==="pretty-level"){if(["minimal","standard","detailed"].includes(W))K.prettyLevel=W}else K[z]=W;Z++}else K[z]=!0}else if(X.startsWith("-")){let z=X.slice(1);for(let W of z)switch(W){case"h":K.help=!0;break;case"v":K.verbose=!0;break;case"p":K.execution="parallel";break;case"c":K.continueOnError=!0;break;case"q":K.quiet=!0;break;case"o":{let U=$[Z+1];if(U&&!U.startsWith("-"))K.output=U,Z++;break}}}else Q.push(X)}return{files:Q,options:K}}async findYamlFiles($,K){let Q=new Set,Z=[];if($.length===0)Z=K.all?["**/*.yaml","**/*.yml"]:["*.yaml","*.yml"];else for(let X of $)try{let W=await(await import("fs/promises")).stat(X);if(W.isDirectory()){if(Z.push(`${X}/*.yaml`,`${X}/*.yml`),K.all)Z.push(`${X}/**/*.yaml`,`${X}/**/*.yml`)}else if(W.isFile())Z.push(X)}catch{Z.push(X)}for(let X of Z){let z=new u(X);for await(let W of z.scan("."))if(W.endsWith(".yaml")||W.endsWith(".yml"))Q.add(W)}return Array.from(Q).sort()}async processYamlFile($){let K=await G.parseFile($),Q=[],Z;if(K.global)Z=K.global;let X={...K.global?.variables,...K.collection?.variables},z={...K.global?.defaults,...K.collection?.defaults};if(K.request){let W=this.prepareRequest(K.request,X,z);Q.push(W)}if(K.requests)for(let W of K.requests){let U=this.prepareRequest(W,X,z);Q.push(U)}if(K.collection?.requests)for(let W of K.collection.requests){let U=this.prepareRequest(W,X,z);Q.push(U)}return{requests:Q,config:Z}}prepareRequest($,K,Q){let Z=G.interpolateVariables($,K);return G.mergeConfigs(Q,Z)}mergeGlobalConfigs($,K){return{...$,...K,variables:{...$.variables,...K.variables},output:{...$.output,...K.output},defaults:{...$.defaults,...K.defaults},ci:{...$.ci,...K.ci}}}determineExitCode($,K){let{failed:Q,total:Z}=$,X=K.ci;if(Q===0)return 0;if(X){if(X.strictExit)return 1;if(X.failOn!==void 0&&Q>X.failOn)return 1;if(X.failOnPercentage!==void 0&&Z>0){if(Q/Z*100>X.failOnPercentage)return 1}if(X.failOn!==void 0||X.failOnPercentage!==void 0)return 0}return!K.continueOnError?1:0}showHelp(){console.log(`
8
8
  ${this.logger.color("\uD83D\uDE80 CURL RUNNER","bright")}
9
9
 
10
10
  ${this.logger.color("USAGE:","yellow")}
@@ -29,6 +29,11 @@ ${this.logger.color("OPTIONS:","yellow")}
29
29
  --show-metrics Include performance metrics in output
30
30
  --version Show version
31
31
 
32
+ ${this.logger.color("CI/CD OPTIONS:","yellow")}
33
+ --strict-exit Exit with code 1 if any validation fails (for CI/CD)
34
+ --fail-on <count> Exit with code 1 if failures exceed this count
35
+ --fail-on-percentage <pct> Exit with code 1 if failure percentage exceeds this value
36
+
32
37
  ${this.logger.color("EXAMPLES:","yellow")}
33
38
  # Run all YAML files in current directory
34
39
  curl-runner
@@ -57,6 +62,18 @@ ${this.logger.color("EXAMPLES:","yellow")}
57
62
  # Run with detailed pretty output (show all information)
58
63
  curl-runner --output-format pretty --pretty-level detailed test.yaml
59
64
 
65
+ # CI/CD: Fail if any validation fails (strict mode)
66
+ curl-runner tests/ --strict-exit
67
+
68
+ # CI/CD: Run all tests but fail if any validation fails
69
+ curl-runner tests/ --continue-on-error --strict-exit
70
+
71
+ # CI/CD: Allow up to 2 failures
72
+ curl-runner tests/ --fail-on 2
73
+
74
+ # CI/CD: Allow up to 10% failures
75
+ curl-runner tests/ --fail-on-percentage 10
76
+
60
77
  ${this.logger.color("YAML STRUCTURE:","yellow")}
61
78
  Single request:
62
79
  request:
@@ -79,7 +96,7 @@ ${this.logger.color("YAML STRUCTURE:","yellow")}
79
96
  requests:
80
97
  - url: \${BASE_URL}/users
81
98
  method: GET
82
- `)}}var i=new V;i.run(process.argv.slice(2));
99
+ `)}}var l=new v;l.run(process.argv.slice(2));
83
100
 
84
- //# debugId=8CFAFA63D966D23664756E2164756E21
101
+ //# debugId=EA3EA83C7A39EBEE64756E2164756E21
85
102
  //# sourceMappingURL=cli.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@curl-runner/cli",
3
- "version": "1.1.0",
3
+ "version": "1.3.0",
4
4
  "description": "A powerful CLI tool for HTTP request management using YAML configuration",
5
5
  "type": "module",
6
6
  "main": "./dist/cli.js",
@@ -0,0 +1,215 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import type { ExecutionSummary, GlobalConfig } from './types/config';
3
+
4
+ /**
5
+ * Determines the appropriate exit code based on execution results and CI configuration.
6
+ * This is a standalone function for testing purposes.
7
+ */
8
+ function determineExitCode(summary: ExecutionSummary, config: GlobalConfig): number {
9
+ const { failed, total } = summary;
10
+ const ci = config.ci;
11
+
12
+ // If no failures, always exit with 0
13
+ if (failed === 0) {
14
+ return 0;
15
+ }
16
+
17
+ // Check CI exit code options
18
+ if (ci) {
19
+ // strictExit: exit 1 if ANY failures occur
20
+ if (ci.strictExit) {
21
+ return 1;
22
+ }
23
+
24
+ // failOn: exit 1 if failures exceed the threshold
25
+ if (ci.failOn !== undefined && failed > ci.failOn) {
26
+ return 1;
27
+ }
28
+
29
+ // failOnPercentage: exit 1 if failure percentage exceeds the threshold
30
+ if (ci.failOnPercentage !== undefined && total > 0) {
31
+ const failurePercentage = (failed / total) * 100;
32
+ if (failurePercentage > ci.failOnPercentage) {
33
+ return 1;
34
+ }
35
+ }
36
+
37
+ // If any CI option is set but thresholds not exceeded, exit 0
38
+ if (ci.failOn !== undefined || ci.failOnPercentage !== undefined) {
39
+ return 0;
40
+ }
41
+ }
42
+
43
+ // Default behavior: exit 1 if failures AND continueOnError is false
44
+ return !config.continueOnError ? 1 : 0;
45
+ }
46
+
47
+ /**
48
+ * Creates a mock execution summary for testing
49
+ */
50
+ function createSummary(total: number, failed: number): ExecutionSummary {
51
+ return {
52
+ total,
53
+ successful: total - failed,
54
+ failed,
55
+ duration: 1000,
56
+ results: [],
57
+ };
58
+ }
59
+
60
+ describe('CI Exit Code', () => {
61
+ describe('default behavior (no CI options)', () => {
62
+ test('should exit 0 when no failures', () => {
63
+ const summary = createSummary(10, 0);
64
+ const config: GlobalConfig = {};
65
+ expect(determineExitCode(summary, config)).toBe(0);
66
+ });
67
+
68
+ test('should exit 1 when failures exist and continueOnError is false', () => {
69
+ const summary = createSummary(10, 2);
70
+ const config: GlobalConfig = {};
71
+ expect(determineExitCode(summary, config)).toBe(1);
72
+ });
73
+
74
+ test('should exit 0 when failures exist and continueOnError is true', () => {
75
+ const summary = createSummary(10, 2);
76
+ const config: GlobalConfig = { continueOnError: true };
77
+ expect(determineExitCode(summary, config)).toBe(0);
78
+ });
79
+ });
80
+
81
+ describe('--strict-exit flag', () => {
82
+ test('should exit 1 when strictExit is true and any failures exist', () => {
83
+ const summary = createSummary(10, 1);
84
+ const config: GlobalConfig = { ci: { strictExit: true } };
85
+ expect(determineExitCode(summary, config)).toBe(1);
86
+ });
87
+
88
+ test('should exit 0 when strictExit is true but no failures', () => {
89
+ const summary = createSummary(10, 0);
90
+ const config: GlobalConfig = { ci: { strictExit: true } };
91
+ expect(determineExitCode(summary, config)).toBe(0);
92
+ });
93
+
94
+ test('should exit 1 when strictExit is true even with continueOnError', () => {
95
+ const summary = createSummary(10, 1);
96
+ const config: GlobalConfig = {
97
+ continueOnError: true,
98
+ ci: { strictExit: true },
99
+ };
100
+ expect(determineExitCode(summary, config)).toBe(1);
101
+ });
102
+ });
103
+
104
+ describe('--fail-on threshold', () => {
105
+ test('should exit 0 when failures are at or below threshold', () => {
106
+ const summary = createSummary(10, 2);
107
+ const config: GlobalConfig = { ci: { failOn: 2 } };
108
+ expect(determineExitCode(summary, config)).toBe(0);
109
+ });
110
+
111
+ test('should exit 1 when failures exceed threshold', () => {
112
+ const summary = createSummary(10, 3);
113
+ const config: GlobalConfig = { ci: { failOn: 2 } };
114
+ expect(determineExitCode(summary, config)).toBe(1);
115
+ });
116
+
117
+ test('should exit 0 when failOn is 0 and no failures', () => {
118
+ const summary = createSummary(10, 0);
119
+ const config: GlobalConfig = { ci: { failOn: 0 } };
120
+ expect(determineExitCode(summary, config)).toBe(0);
121
+ });
122
+
123
+ test('should exit 1 when failOn is 0 and any failures exist', () => {
124
+ const summary = createSummary(10, 1);
125
+ const config: GlobalConfig = { ci: { failOn: 0 } };
126
+ expect(determineExitCode(summary, config)).toBe(1);
127
+ });
128
+ });
129
+
130
+ describe('--fail-on-percentage threshold', () => {
131
+ test('should exit 0 when failure percentage is at or below threshold', () => {
132
+ const summary = createSummary(100, 10);
133
+ const config: GlobalConfig = { ci: { failOnPercentage: 10 } };
134
+ expect(determineExitCode(summary, config)).toBe(0);
135
+ });
136
+
137
+ test('should exit 1 when failure percentage exceeds threshold', () => {
138
+ const summary = createSummary(100, 11);
139
+ const config: GlobalConfig = { ci: { failOnPercentage: 10 } };
140
+ expect(determineExitCode(summary, config)).toBe(1);
141
+ });
142
+
143
+ test('should exit 0 when failOnPercentage is 50 and failures are 50%', () => {
144
+ const summary = createSummary(10, 5);
145
+ const config: GlobalConfig = { ci: { failOnPercentage: 50 } };
146
+ expect(determineExitCode(summary, config)).toBe(0);
147
+ });
148
+
149
+ test('should exit 1 when failOnPercentage is 50 and failures are 51%', () => {
150
+ const summary = createSummary(100, 51);
151
+ const config: GlobalConfig = { ci: { failOnPercentage: 50 } };
152
+ expect(determineExitCode(summary, config)).toBe(1);
153
+ });
154
+
155
+ test('should handle edge case with 0 total requests', () => {
156
+ const summary = createSummary(0, 0);
157
+ const config: GlobalConfig = { ci: { failOnPercentage: 10 } };
158
+ expect(determineExitCode(summary, config)).toBe(0);
159
+ });
160
+ });
161
+
162
+ describe('combined options', () => {
163
+ test('strictExit takes precedence over failOn', () => {
164
+ const summary = createSummary(10, 1);
165
+ const config: GlobalConfig = {
166
+ ci: { strictExit: true, failOn: 5 },
167
+ };
168
+ expect(determineExitCode(summary, config)).toBe(1);
169
+ });
170
+
171
+ test('strictExit takes precedence over failOnPercentage', () => {
172
+ const summary = createSummary(100, 1);
173
+ const config: GlobalConfig = {
174
+ ci: { strictExit: true, failOnPercentage: 50 },
175
+ };
176
+ expect(determineExitCode(summary, config)).toBe(1);
177
+ });
178
+
179
+ test('failOn checked before failOnPercentage', () => {
180
+ const summary = createSummary(100, 6); // 6% failure
181
+ const config: GlobalConfig = {
182
+ ci: { failOn: 5, failOnPercentage: 10 }, // Would pass percentage but fail count
183
+ };
184
+ expect(determineExitCode(summary, config)).toBe(1);
185
+ });
186
+
187
+ test('should exit 0 when all thresholds pass', () => {
188
+ const summary = createSummary(100, 5); // 5% failure
189
+ const config: GlobalConfig = {
190
+ ci: { failOn: 5, failOnPercentage: 10 },
191
+ };
192
+ expect(determineExitCode(summary, config)).toBe(0);
193
+ });
194
+ });
195
+
196
+ describe('CI option with continueOnError', () => {
197
+ test('should still respect CI thresholds even with continueOnError', () => {
198
+ const summary = createSummary(10, 3);
199
+ const config: GlobalConfig = {
200
+ continueOnError: true,
201
+ ci: { failOn: 2 },
202
+ };
203
+ expect(determineExitCode(summary, config)).toBe(1);
204
+ });
205
+
206
+ test('should exit 0 when threshold not exceeded with continueOnError', () => {
207
+ const summary = createSummary(10, 2);
208
+ const config: GlobalConfig = {
209
+ continueOnError: true,
210
+ ci: { failOn: 2 },
211
+ };
212
+ expect(determineExitCode(summary, config)).toBe(0);
213
+ });
214
+ });
215
+ });
package/src/cli.ts CHANGED
@@ -3,7 +3,7 @@
3
3
  import { Glob } from 'bun';
4
4
  import { RequestExecutor } from './executor/request-executor';
5
5
  import { YamlParser } from './parser/yaml';
6
- import type { GlobalConfig, RequestConfig } from './types/config';
6
+ import type { ExecutionSummary, GlobalConfig, RequestConfig } from './types/config';
7
7
  import { Logger } from './utils/logger';
8
8
  import { VersionChecker } from './utils/version-checker';
9
9
  import { getVersion } from './version';
@@ -105,6 +105,31 @@ class CurlRunnerCLI {
105
105
  envConfig.output = { ...envConfig.output, saveToFile: process.env.CURL_RUNNER_OUTPUT_FILE };
106
106
  }
107
107
 
108
+ // CI exit code configuration
109
+ if (process.env.CURL_RUNNER_STRICT_EXIT) {
110
+ envConfig.ci = {
111
+ ...envConfig.ci,
112
+ strictExit: process.env.CURL_RUNNER_STRICT_EXIT.toLowerCase() === 'true',
113
+ };
114
+ }
115
+
116
+ if (process.env.CURL_RUNNER_FAIL_ON) {
117
+ envConfig.ci = {
118
+ ...envConfig.ci,
119
+ failOn: Number.parseInt(process.env.CURL_RUNNER_FAIL_ON, 10),
120
+ };
121
+ }
122
+
123
+ if (process.env.CURL_RUNNER_FAIL_ON_PERCENTAGE) {
124
+ const percentage = Number.parseFloat(process.env.CURL_RUNNER_FAIL_ON_PERCENTAGE);
125
+ if (percentage >= 0 && percentage <= 100) {
126
+ envConfig.ci = {
127
+ ...envConfig.ci,
128
+ failOnPercentage: percentage,
129
+ };
130
+ }
131
+ }
132
+
108
133
  return envConfig;
109
134
  }
110
135
 
@@ -233,6 +258,20 @@ class CurlRunnerCLI {
233
258
  };
234
259
  }
235
260
 
261
+ // Apply CI exit code options
262
+ if (options.strictExit !== undefined) {
263
+ globalConfig.ci = { ...globalConfig.ci, strictExit: options.strictExit as boolean };
264
+ }
265
+ if (options.failOn !== undefined) {
266
+ globalConfig.ci = { ...globalConfig.ci, failOn: options.failOn as number };
267
+ }
268
+ if (options.failOnPercentage !== undefined) {
269
+ globalConfig.ci = {
270
+ ...globalConfig.ci,
271
+ failOnPercentage: options.failOnPercentage as number,
272
+ };
273
+ }
274
+
236
275
  if (allRequests.length === 0) {
237
276
  this.logger.logError('No requests found in YAML files');
238
277
  process.exit(1);
@@ -283,7 +322,9 @@ class CurlRunnerCLI {
283
322
  summary = await executor.execute(allRequests);
284
323
  }
285
324
 
286
- process.exit(summary.failed > 0 && !globalConfig.continueOnError ? 1 : 0);
325
+ // Determine exit code based on CI configuration
326
+ const exitCode = this.determineExitCode(summary, globalConfig);
327
+ process.exit(exitCode);
287
328
  } catch (error) {
288
329
  this.logger.logError(error instanceof Error ? error.message : String(error));
289
330
  process.exit(1);
@@ -313,6 +354,8 @@ class CurlRunnerCLI {
313
354
  options.showBody = true;
314
355
  } else if (key === 'show-metrics') {
315
356
  options.showMetrics = true;
357
+ } else if (key === 'strict-exit') {
358
+ options.strictExit = true;
316
359
  } else if (nextArg && !nextArg.startsWith('--')) {
317
360
  if (key === 'continue-on-error') {
318
361
  options.continueOnError = nextArg === 'true';
@@ -324,6 +367,13 @@ class CurlRunnerCLI {
324
367
  options.retries = Number.parseInt(nextArg, 10);
325
368
  } else if (key === 'retry-delay') {
326
369
  options.retryDelay = Number.parseInt(nextArg, 10);
370
+ } else if (key === 'fail-on') {
371
+ options.failOn = Number.parseInt(nextArg, 10);
372
+ } else if (key === 'fail-on-percentage') {
373
+ const percentage = Number.parseFloat(nextArg);
374
+ if (percentage >= 0 && percentage <= 100) {
375
+ options.failOnPercentage = percentage;
376
+ }
327
377
  } else if (key === 'output-format') {
328
378
  if (['json', 'pretty', 'raw'].includes(nextArg)) {
329
379
  options.outputFormat = nextArg;
@@ -485,9 +535,62 @@ class CurlRunnerCLI {
485
535
  variables: { ...base.variables, ...override.variables },
486
536
  output: { ...base.output, ...override.output },
487
537
  defaults: { ...base.defaults, ...override.defaults },
538
+ ci: { ...base.ci, ...override.ci },
488
539
  };
489
540
  }
490
541
 
542
+ /**
543
+ * Determines the appropriate exit code based on execution results and CI configuration.
544
+ *
545
+ * Exit code logic:
546
+ * - If strictExit is enabled: exit 1 if ANY failures occur
547
+ * - If failOn is set: exit 1 if failures exceed the threshold
548
+ * - If failOnPercentage is set: exit 1 if failure percentage exceeds the threshold
549
+ * - Default behavior: exit 1 only if failures exist AND continueOnError is false
550
+ *
551
+ * @param summary - The execution summary containing success/failure counts
552
+ * @param config - Global configuration including CI exit options
553
+ * @returns 0 for success, 1 for failure
554
+ */
555
+ private determineExitCode(summary: ExecutionSummary, config: GlobalConfig): number {
556
+ const { failed, total } = summary;
557
+ const ci = config.ci;
558
+
559
+ // If no failures, always exit with 0
560
+ if (failed === 0) {
561
+ return 0;
562
+ }
563
+
564
+ // Check CI exit code options
565
+ if (ci) {
566
+ // strictExit: exit 1 if ANY failures occur
567
+ if (ci.strictExit) {
568
+ return 1;
569
+ }
570
+
571
+ // failOn: exit 1 if failures exceed the threshold
572
+ if (ci.failOn !== undefined && failed > ci.failOn) {
573
+ return 1;
574
+ }
575
+
576
+ // failOnPercentage: exit 1 if failure percentage exceeds the threshold
577
+ if (ci.failOnPercentage !== undefined && total > 0) {
578
+ const failurePercentage = (failed / total) * 100;
579
+ if (failurePercentage > ci.failOnPercentage) {
580
+ return 1;
581
+ }
582
+ }
583
+
584
+ // If any CI option is set but thresholds not exceeded, exit 0
585
+ if (ci.failOn !== undefined || ci.failOnPercentage !== undefined) {
586
+ return 0;
587
+ }
588
+ }
589
+
590
+ // Default behavior: exit 1 if failures AND continueOnError is false
591
+ return !config.continueOnError ? 1 : 0;
592
+ }
593
+
491
594
  private showHelp(): void {
492
595
  console.log(`
493
596
  ${this.logger.color('🚀 CURL RUNNER', 'bright')}
@@ -514,6 +617,11 @@ ${this.logger.color('OPTIONS:', 'yellow')}
514
617
  --show-metrics Include performance metrics in output
515
618
  --version Show version
516
619
 
620
+ ${this.logger.color('CI/CD OPTIONS:', 'yellow')}
621
+ --strict-exit Exit with code 1 if any validation fails (for CI/CD)
622
+ --fail-on <count> Exit with code 1 if failures exceed this count
623
+ --fail-on-percentage <pct> Exit with code 1 if failure percentage exceeds this value
624
+
517
625
  ${this.logger.color('EXAMPLES:', 'yellow')}
518
626
  # Run all YAML files in current directory
519
627
  curl-runner
@@ -542,6 +650,18 @@ ${this.logger.color('EXAMPLES:', 'yellow')}
542
650
  # Run with detailed pretty output (show all information)
543
651
  curl-runner --output-format pretty --pretty-level detailed test.yaml
544
652
 
653
+ # CI/CD: Fail if any validation fails (strict mode)
654
+ curl-runner tests/ --strict-exit
655
+
656
+ # CI/CD: Run all tests but fail if any validation fails
657
+ curl-runner tests/ --continue-on-error --strict-exit
658
+
659
+ # CI/CD: Allow up to 2 failures
660
+ curl-runner tests/ --fail-on 2
661
+
662
+ # CI/CD: Allow up to 10% failures
663
+ curl-runner tests/ --fail-on-percentage 10
664
+
545
665
  ${this.logger.color('YAML STRUCTURE:', 'yellow')}
546
666
  Single request:
547
667
  request:
@@ -2,6 +2,8 @@ import { YamlParser } from '../parser/yaml';
2
2
  import type {
3
3
  ExecutionResult,
4
4
  ExecutionSummary,
5
+ FileAttachment,
6
+ FormFieldValue,
5
7
  GlobalConfig,
6
8
  JsonValue,
7
9
  RequestConfig,
@@ -28,6 +30,42 @@ export class RequestExecutor {
28
30
  };
29
31
  }
30
32
 
33
+ /**
34
+ * Checks if a form field value is a file attachment.
35
+ */
36
+ private isFileAttachment(value: FormFieldValue): value is FileAttachment {
37
+ return typeof value === 'object' && value !== null && 'file' in value;
38
+ }
39
+
40
+ /**
41
+ * Validates that all file attachments in formData exist.
42
+ * Returns an error message if any file is missing, or undefined if all files exist.
43
+ */
44
+ private async validateFileAttachments(config: RequestConfig): Promise<string | undefined> {
45
+ if (!config.formData) {
46
+ return undefined;
47
+ }
48
+
49
+ const missingFiles: string[] = [];
50
+
51
+ for (const [fieldName, fieldValue] of Object.entries(config.formData)) {
52
+ if (this.isFileAttachment(fieldValue)) {
53
+ const filePath = fieldValue.file;
54
+ const file = Bun.file(filePath);
55
+ const exists = await file.exists();
56
+ if (!exists) {
57
+ missingFiles.push(`${fieldName}: ${filePath}`);
58
+ }
59
+ }
60
+ }
61
+
62
+ if (missingFiles.length > 0) {
63
+ return `File(s) not found: ${missingFiles.join(', ')}`;
64
+ }
65
+
66
+ return undefined;
67
+ }
68
+
31
69
  async executeRequest(config: RequestConfig, index: number = 0): Promise<ExecutionResult> {
32
70
  const startTime = performance.now();
33
71
 
@@ -37,6 +75,21 @@ export class RequestExecutor {
37
75
 
38
76
  requestLogger.logRequestStart(config, index);
39
77
 
78
+ // Validate file attachments exist before executing
79
+ const fileError = await this.validateFileAttachments(config);
80
+ if (fileError) {
81
+ const failedResult: ExecutionResult = {
82
+ request: config,
83
+ success: false,
84
+ error: fileError,
85
+ metrics: {
86
+ duration: performance.now() - startTime,
87
+ },
88
+ };
89
+ requestLogger.logRequestComplete(failedResult);
90
+ return failedResult;
91
+ }
92
+
40
93
  const command = CurlBuilder.buildCommand(config);
41
94
  requestLogger.logCommand(command);
42
95
 
@@ -4,6 +4,47 @@ export interface JsonObject {
4
4
  }
5
5
  export interface JsonArray extends Array<JsonValue> {}
6
6
 
7
+ /**
8
+ * Configuration for a file attachment in a form data request.
9
+ *
10
+ * Examples:
11
+ * - `{ file: "./image.png" }` - Simple file attachment
12
+ * - `{ file: "./doc.pdf", filename: "document.pdf" }` - With custom filename
13
+ * - `{ file: "./data.json", contentType: "application/json" }` - With explicit content type
14
+ */
15
+ export interface FileAttachment {
16
+ /** Path to the file (relative to YAML file or absolute) */
17
+ file: string;
18
+ /** Custom filename to send (defaults to actual filename) */
19
+ filename?: string;
20
+ /** Explicit content type (curl will auto-detect if not specified) */
21
+ contentType?: string;
22
+ }
23
+
24
+ /**
25
+ * A form field value can be a string, number, boolean, or a file attachment.
26
+ */
27
+ export type FormFieldValue = string | number | boolean | FileAttachment;
28
+
29
+ /**
30
+ * Configuration for form data (multipart/form-data) requests.
31
+ * Each key is a form field name, and the value can be a simple value or a file attachment.
32
+ *
33
+ * Examples:
34
+ * ```yaml
35
+ * formData:
36
+ * name: "John Doe"
37
+ * age: 30
38
+ * avatar:
39
+ * file: "./avatar.png"
40
+ * document:
41
+ * file: "./report.pdf"
42
+ * filename: "quarterly-report.pdf"
43
+ * contentType: "application/pdf"
44
+ * ```
45
+ */
46
+ export type FormDataConfig = Record<string, FormFieldValue>;
47
+
7
48
  /**
8
49
  * Configuration for storing response values as variables for subsequent requests.
9
50
  * Maps a variable name to a JSON path in the response.
@@ -24,6 +65,18 @@ export interface RequestConfig {
24
65
  params?: Record<string, string>;
25
66
  sourceFile?: string; // Source YAML file for better output organization
26
67
  body?: JsonValue;
68
+ /**
69
+ * Form data for multipart/form-data requests.
70
+ * Use this for file uploads or when you need to send form fields.
71
+ * Cannot be used together with 'body'.
72
+ *
73
+ * @example
74
+ * formData:
75
+ * username: "john"
76
+ * avatar:
77
+ * file: "./avatar.png"
78
+ */
79
+ formData?: FormDataConfig;
27
80
  timeout?: number;
28
81
  followRedirects?: boolean;
29
82
  maxRedirects?: number;
@@ -79,9 +132,39 @@ export interface CollectionConfig {
79
132
  requests: RequestConfig[];
80
133
  }
81
134
 
135
+ /**
136
+ * CI exit code configuration options.
137
+ * These options control how curl-runner exits in CI/CD pipelines.
138
+ */
139
+ export interface CIExitConfig {
140
+ /**
141
+ * When true, exit with code 1 if any validation failures occur,
142
+ * regardless of the continueOnError setting.
143
+ * This is useful for CI/CD pipelines that need strict validation.
144
+ */
145
+ strictExit?: boolean;
146
+ /**
147
+ * Maximum number of failures allowed before exiting with code 1.
148
+ * If set to 0, any failure will cause a non-zero exit.
149
+ * If undefined and strictExit is true, any failure causes non-zero exit.
150
+ */
151
+ failOn?: number;
152
+ /**
153
+ * Maximum percentage of failures allowed before exiting with code 1.
154
+ * Value should be between 0 and 100.
155
+ * If set to 10, up to 10% of requests can fail without causing a non-zero exit.
156
+ */
157
+ failOnPercentage?: number;
158
+ }
159
+
82
160
  export interface GlobalConfig {
83
161
  execution?: 'sequential' | 'parallel';
84
162
  continueOnError?: boolean;
163
+ /**
164
+ * CI/CD exit code configuration.
165
+ * Controls when curl-runner should exit with non-zero status codes.
166
+ */
167
+ ci?: CIExitConfig;
85
168
  variables?: Record<string, string>;
86
169
  output?: {
87
170
  verbose?: boolean;
@@ -0,0 +1,165 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import { CurlBuilder } from './curl-builder';
3
+
4
+ describe('CurlBuilder', () => {
5
+ describe('buildCommand', () => {
6
+ test('should build basic GET request', () => {
7
+ const command = CurlBuilder.buildCommand({
8
+ url: 'https://example.com/api',
9
+ method: 'GET',
10
+ });
11
+
12
+ expect(command).toContain('curl');
13
+ expect(command).toContain('-X GET');
14
+ expect(command).toContain('"https://example.com/api"');
15
+ });
16
+
17
+ test('should build POST request with JSON body', () => {
18
+ const command = CurlBuilder.buildCommand({
19
+ url: 'https://example.com/api',
20
+ method: 'POST',
21
+ body: { name: 'test' },
22
+ });
23
+
24
+ expect(command).toContain('-X POST');
25
+ expect(command).toContain('-d \'{"name":"test"}\'');
26
+ expect(command).toContain('Content-Type: application/json');
27
+ });
28
+
29
+ test('should build POST request with form data', () => {
30
+ const command = CurlBuilder.buildCommand({
31
+ url: 'https://example.com/upload',
32
+ method: 'POST',
33
+ formData: {
34
+ username: 'john',
35
+ age: 30,
36
+ },
37
+ });
38
+
39
+ expect(command).toContain('-X POST');
40
+ expect(command).toContain("-F 'username=john'");
41
+ expect(command).toContain("-F 'age=30'");
42
+ expect(command).not.toContain('-d');
43
+ });
44
+
45
+ test('should build POST request with file attachment', () => {
46
+ const command = CurlBuilder.buildCommand({
47
+ url: 'https://example.com/upload',
48
+ method: 'POST',
49
+ formData: {
50
+ document: {
51
+ file: './test.pdf',
52
+ },
53
+ },
54
+ });
55
+
56
+ expect(command).toContain("-F 'document=@./test.pdf'");
57
+ });
58
+
59
+ test('should build POST request with file attachment and custom filename', () => {
60
+ const command = CurlBuilder.buildCommand({
61
+ url: 'https://example.com/upload',
62
+ method: 'POST',
63
+ formData: {
64
+ document: {
65
+ file: './test.pdf',
66
+ filename: 'report.pdf',
67
+ },
68
+ },
69
+ });
70
+
71
+ expect(command).toContain("-F 'document=@./test.pdf;filename=report.pdf'");
72
+ });
73
+
74
+ test('should build POST request with file attachment and content type', () => {
75
+ const command = CurlBuilder.buildCommand({
76
+ url: 'https://example.com/upload',
77
+ method: 'POST',
78
+ formData: {
79
+ data: {
80
+ file: './data.json',
81
+ contentType: 'application/json',
82
+ },
83
+ },
84
+ });
85
+
86
+ expect(command).toContain("-F 'data=@./data.json;type=application/json'");
87
+ });
88
+
89
+ test('should build POST request with file attachment including all options', () => {
90
+ const command = CurlBuilder.buildCommand({
91
+ url: 'https://example.com/upload',
92
+ method: 'POST',
93
+ formData: {
94
+ document: {
95
+ file: './report.pdf',
96
+ filename: 'quarterly-report.pdf',
97
+ contentType: 'application/pdf',
98
+ },
99
+ },
100
+ });
101
+
102
+ expect(command).toContain(
103
+ "-F 'document=@./report.pdf;filename=quarterly-report.pdf;type=application/pdf'",
104
+ );
105
+ });
106
+
107
+ test('should build POST request with mixed form data and files', () => {
108
+ const command = CurlBuilder.buildCommand({
109
+ url: 'https://example.com/upload',
110
+ method: 'POST',
111
+ formData: {
112
+ title: 'My Document',
113
+ description: 'Test upload',
114
+ file: {
115
+ file: './document.pdf',
116
+ },
117
+ },
118
+ });
119
+
120
+ expect(command).toContain("-F 'title=My Document'");
121
+ expect(command).toContain("-F 'description=Test upload'");
122
+ expect(command).toContain("-F 'file=@./document.pdf'");
123
+ });
124
+
125
+ test('should escape single quotes in form field values', () => {
126
+ const command = CurlBuilder.buildCommand({
127
+ url: 'https://example.com/upload',
128
+ method: 'POST',
129
+ formData: {
130
+ message: "It's a test",
131
+ },
132
+ });
133
+
134
+ expect(command).toContain("-F 'message=It'\\''s a test'");
135
+ });
136
+
137
+ test('should prefer formData over body when both are present', () => {
138
+ const command = CurlBuilder.buildCommand({
139
+ url: 'https://example.com/api',
140
+ method: 'POST',
141
+ formData: {
142
+ field: 'value',
143
+ },
144
+ body: { name: 'test' },
145
+ });
146
+
147
+ expect(command).toContain("-F 'field=value'");
148
+ expect(command).not.toContain('-d');
149
+ });
150
+
151
+ test('should handle boolean form field values', () => {
152
+ const command = CurlBuilder.buildCommand({
153
+ url: 'https://example.com/api',
154
+ method: 'POST',
155
+ formData: {
156
+ active: true,
157
+ disabled: false,
158
+ },
159
+ });
160
+
161
+ expect(command).toContain("-F 'active=true'");
162
+ expect(command).toContain("-F 'disabled=false'");
163
+ });
164
+ });
165
+ });
@@ -1,4 +1,4 @@
1
- import type { RequestConfig } from '../types/config';
1
+ import type { FileAttachment, FormFieldValue, RequestConfig } from '../types/config';
2
2
 
3
3
  interface CurlMetrics {
4
4
  response_code?: number;
@@ -11,6 +11,20 @@ interface CurlMetrics {
11
11
  time_starttransfer?: number;
12
12
  }
13
13
 
14
+ /**
15
+ * Checks if a form field value is a file attachment.
16
+ */
17
+ function isFileAttachment(value: FormFieldValue): value is FileAttachment {
18
+ return typeof value === 'object' && value !== null && 'file' in value;
19
+ }
20
+
21
+ /**
22
+ * Escapes a string value for use in curl -F flag.
23
+ */
24
+ function escapeFormValue(value: string): string {
25
+ return value.replace(/'/g, "'\\''");
26
+ }
27
+
14
28
  // Using class for organization, but could be refactored to functions
15
29
  export class CurlBuilder {
16
30
  static buildCommand(config: RequestConfig): string {
@@ -34,7 +48,26 @@ export class CurlBuilder {
34
48
  }
35
49
  }
36
50
 
37
- if (config.body) {
51
+ if (config.formData) {
52
+ // Use -F flags for multipart/form-data
53
+ for (const [fieldName, fieldValue] of Object.entries(config.formData)) {
54
+ if (isFileAttachment(fieldValue)) {
55
+ // File attachment: -F "field=@filepath;filename=name;type=mimetype"
56
+ let fileSpec = `@${fieldValue.file}`;
57
+ if (fieldValue.filename) {
58
+ fileSpec += `;filename=${fieldValue.filename}`;
59
+ }
60
+ if (fieldValue.contentType) {
61
+ fileSpec += `;type=${fieldValue.contentType}`;
62
+ }
63
+ parts.push('-F', `'${fieldName}=${escapeFormValue(fileSpec)}'`);
64
+ } else {
65
+ // Regular form field: -F "field=value"
66
+ const strValue = String(fieldValue);
67
+ parts.push('-F', `'${fieldName}=${escapeFormValue(strValue)}'`);
68
+ }
69
+ }
70
+ } else if (config.body) {
38
71
  const bodyStr = typeof config.body === 'string' ? config.body : JSON.stringify(config.body);
39
72
  parts.push('-d', `'${bodyStr.replace(/'/g, "'\\''")}'`);
40
73