@curl-runner/cli 1.0.1

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.
@@ -0,0 +1,152 @@
1
+ import type { RequestConfig } from '../types/config';
2
+
3
+ interface CurlMetrics {
4
+ response_code?: number;
5
+ http_code?: number;
6
+ time_total?: number;
7
+ size_download?: number;
8
+ time_namelookup?: number;
9
+ time_connect?: number;
10
+ time_appconnect?: number;
11
+ time_starttransfer?: number;
12
+ }
13
+
14
+ // Using class for organization, but could be refactored to functions
15
+ export class CurlBuilder {
16
+ static buildCommand(config: RequestConfig): string {
17
+ const parts: string[] = ['curl'];
18
+
19
+ parts.push('-X', config.method || 'GET');
20
+
21
+ parts.push('-w', '"\\n__CURL_METRICS_START__%{json}__CURL_METRICS_END__"');
22
+
23
+ if (config.headers) {
24
+ for (const [key, value] of Object.entries(config.headers)) {
25
+ parts.push('-H', `"${key}: ${value}"`);
26
+ }
27
+ }
28
+
29
+ if (config.auth) {
30
+ if (config.auth.type === 'basic' && config.auth.username && config.auth.password) {
31
+ parts.push('-u', `"${config.auth.username}:${config.auth.password}"`);
32
+ } else if (config.auth.type === 'bearer' && config.auth.token) {
33
+ parts.push('-H', `"Authorization: Bearer ${config.auth.token}"`);
34
+ }
35
+ }
36
+
37
+ if (config.body) {
38
+ const bodyStr = typeof config.body === 'string' ? config.body : JSON.stringify(config.body);
39
+ parts.push('-d', `'${bodyStr.replace(/'/g, "'\\''")}'`);
40
+
41
+ if (!config.headers?.['Content-Type']) {
42
+ parts.push('-H', '"Content-Type: application/json"');
43
+ }
44
+ }
45
+
46
+ if (config.timeout) {
47
+ parts.push('--max-time', config.timeout.toString());
48
+ }
49
+
50
+ if (config.followRedirects !== false) {
51
+ parts.push('-L');
52
+ if (config.maxRedirects) {
53
+ parts.push('--max-redirs', config.maxRedirects.toString());
54
+ }
55
+ }
56
+
57
+ if (config.proxy) {
58
+ parts.push('-x', config.proxy);
59
+ }
60
+
61
+ if (config.insecure) {
62
+ parts.push('-k');
63
+ }
64
+
65
+ if (config.output) {
66
+ parts.push('-o', config.output);
67
+ }
68
+
69
+ parts.push('-s', '-S');
70
+
71
+ let url = config.url;
72
+ if (config.params && Object.keys(config.params).length > 0) {
73
+ const queryString = new URLSearchParams(config.params).toString();
74
+ url += (url.includes('?') ? '&' : '?') + queryString;
75
+ }
76
+
77
+ parts.push(`"${url}"`);
78
+
79
+ return parts.join(' ');
80
+ }
81
+
82
+ static async executeCurl(command: string): Promise<{
83
+ success: boolean;
84
+ status?: number;
85
+ headers?: Record<string, string>;
86
+ body?: string;
87
+ metrics?: CurlMetrics;
88
+ error?: string;
89
+ }> {
90
+ try {
91
+ const proc = Bun.spawn(['sh', '-c', command], {
92
+ stdout: 'pipe',
93
+ stderr: 'pipe',
94
+ });
95
+
96
+ const stdout = await new Response(proc.stdout).text();
97
+ const stderr = await new Response(proc.stderr).text();
98
+
99
+ await proc.exited;
100
+
101
+ if (proc.exitCode !== 0 && !stdout) {
102
+ return {
103
+ success: false,
104
+ error: stderr || `Command failed with exit code ${proc.exitCode}`,
105
+ };
106
+ }
107
+
108
+ let responseBody = stdout;
109
+ let metrics: CurlMetrics = {};
110
+
111
+ const metricsMatch = stdout.match(/__CURL_METRICS_START__(.+?)__CURL_METRICS_END__/);
112
+ if (metricsMatch) {
113
+ responseBody = stdout.replace(/__CURL_METRICS_START__.+?__CURL_METRICS_END__/, '').trim();
114
+ try {
115
+ metrics = JSON.parse(metricsMatch[1]);
116
+ } catch (_e) {}
117
+ }
118
+
119
+ const responseHeaders: Record<string, string> = {};
120
+ if (metrics.response_code) {
121
+ const headerLines = stderr.split('\n').filter((line) => line.includes(':'));
122
+ for (const line of headerLines) {
123
+ const [key, ...valueParts] = line.split(':');
124
+ if (key && valueParts.length > 0) {
125
+ responseHeaders[key.trim()] = valueParts.join(':').trim();
126
+ }
127
+ }
128
+ }
129
+
130
+ return {
131
+ success: true,
132
+ status: metrics.response_code || metrics.http_code,
133
+ headers: responseHeaders,
134
+ body: responseBody,
135
+ metrics: {
136
+ duration: (metrics.time_total || 0) * 1000,
137
+ size: metrics.size_download,
138
+ dnsLookup: (metrics.time_namelookup || 0) * 1000,
139
+ tcpConnection: (metrics.time_connect || 0) * 1000,
140
+ tlsHandshake: (metrics.time_appconnect || 0) * 1000,
141
+ firstByte: (metrics.time_starttransfer || 0) * 1000,
142
+ download: (metrics.time_total || 0) * 1000,
143
+ },
144
+ };
145
+ } catch (error) {
146
+ return {
147
+ success: false,
148
+ error: error instanceof Error ? error.message : String(error),
149
+ };
150
+ }
151
+ }
152
+ }
@@ -0,0 +1,501 @@
1
+ import type {
2
+ ExecutionResult,
3
+ ExecutionSummary,
4
+ GlobalConfig,
5
+ RequestConfig,
6
+ } from '../types/config';
7
+
8
+ export class Logger {
9
+ private config: GlobalConfig['output'];
10
+
11
+ private readonly colors = {
12
+ reset: '\x1b[0m',
13
+ bright: '\x1b[1m',
14
+ dim: '\x1b[2m',
15
+ underscore: '\x1b[4m',
16
+
17
+ black: '\x1b[30m',
18
+ red: '\x1b[31m',
19
+ green: '\x1b[32m',
20
+ yellow: '\x1b[33m',
21
+ blue: '\x1b[34m',
22
+ magenta: '\x1b[35m',
23
+ cyan: '\x1b[36m',
24
+ white: '\x1b[37m',
25
+
26
+ bgBlack: '\x1b[40m',
27
+ bgRed: '\x1b[41m',
28
+ bgGreen: '\x1b[42m',
29
+ bgYellow: '\x1b[43m',
30
+ bgBlue: '\x1b[44m',
31
+ bgMagenta: '\x1b[45m',
32
+ bgCyan: '\x1b[46m',
33
+ bgWhite: '\x1b[47m',
34
+ };
35
+
36
+ constructor(config: GlobalConfig['output'] = {}) {
37
+ this.config = {
38
+ verbose: false,
39
+ showHeaders: false,
40
+ showBody: true,
41
+ showMetrics: false,
42
+ format: 'pretty',
43
+ prettyLevel: 'standard',
44
+ ...config,
45
+ };
46
+ }
47
+
48
+ private color(text: string, color: keyof typeof this.colors): string {
49
+ return `${this.colors[color]}${text}${this.colors.reset}`;
50
+ }
51
+
52
+ private getShortFilename(filePath: string): string {
53
+ return filePath.replace(/.*\//, '').replace('.yaml', '');
54
+ }
55
+
56
+ private shouldShowOutput(): boolean {
57
+ if (this.config.format === 'raw') {
58
+ return false;
59
+ }
60
+ if (this.config.format === 'pretty') {
61
+ return true; // Pretty format should always show output
62
+ }
63
+ return this.config.verbose !== false; // For other formats, respect verbose flag
64
+ }
65
+
66
+ private shouldShowHeaders(): boolean {
67
+ if (this.config.format !== 'pretty') {
68
+ return this.config.showHeaders || false;
69
+ }
70
+
71
+ const level = this.config.prettyLevel || 'standard';
72
+ switch (level) {
73
+ case 'minimal':
74
+ return false;
75
+ case 'standard':
76
+ return this.config.showHeaders || false;
77
+ case 'detailed':
78
+ return true;
79
+ default:
80
+ return this.config.showHeaders || false;
81
+ }
82
+ }
83
+
84
+ private shouldShowBody(): boolean {
85
+ if (this.config.format !== 'pretty') {
86
+ return this.config.showBody !== false;
87
+ }
88
+
89
+ const level = this.config.prettyLevel || 'standard';
90
+ switch (level) {
91
+ case 'minimal':
92
+ return false; // Minimal never shows body
93
+ case 'standard':
94
+ return this.config.showBody !== false;
95
+ case 'detailed':
96
+ return true; // Detailed always shows body
97
+ default:
98
+ return this.config.showBody !== false;
99
+ }
100
+ }
101
+
102
+ private shouldShowMetrics(): boolean {
103
+ if (this.config.format !== 'pretty') {
104
+ return this.config.showMetrics || false;
105
+ }
106
+
107
+ const level = this.config.prettyLevel || 'standard';
108
+ switch (level) {
109
+ case 'minimal':
110
+ return false; // Minimal never shows metrics
111
+ case 'standard':
112
+ return this.config.showMetrics || false;
113
+ case 'detailed':
114
+ return true; // Detailed always shows metrics
115
+ default:
116
+ return this.config.showMetrics || false;
117
+ }
118
+ }
119
+
120
+ private shouldShowRequestDetails(): boolean {
121
+ if (this.config.format !== 'pretty') {
122
+ return this.config.verbose || false;
123
+ }
124
+
125
+ const level = this.config.prettyLevel || 'standard';
126
+ switch (level) {
127
+ case 'minimal':
128
+ return false;
129
+ case 'standard':
130
+ return this.config.verbose || false;
131
+ case 'detailed':
132
+ return true;
133
+ default:
134
+ return this.config.verbose || false;
135
+ }
136
+ }
137
+
138
+ private shouldShowSeparators(): boolean {
139
+ if (this.config.format !== 'pretty') {
140
+ return true;
141
+ }
142
+
143
+ const level = this.config.prettyLevel || 'standard';
144
+ switch (level) {
145
+ case 'minimal':
146
+ return false;
147
+ case 'standard':
148
+ return true;
149
+ case 'detailed':
150
+ return true;
151
+ default:
152
+ return true;
153
+ }
154
+ }
155
+
156
+ private colorStatusCode(statusStr: string): string {
157
+ // For expected status codes in validation errors, use yellow to distinguish from red actual values
158
+ return this.color(statusStr, 'yellow');
159
+ }
160
+
161
+ private logValidationErrors(errorString: string): void {
162
+ // Check if this is a validation error with multiple parts (separated by ';')
163
+ const errors = errorString.split('; ');
164
+
165
+ if (errors.length === 1) {
166
+ // Single error - check if it's a status error for special formatting
167
+ const trimmedError = errors[0].trim();
168
+ const statusMatch = trimmedError.match(/^Expected status (.+?), got (.+)$/);
169
+ if (statusMatch) {
170
+ const [, expected, actual] = statusMatch;
171
+ const expectedStatus = this.colorStatusCode(expected.replace(' or ', '|'));
172
+ const actualStatus = this.color(actual, 'red'); // Always red for incorrect actual values
173
+ console.log(
174
+ ` ${this.color('✗', 'red')} ${this.color('Error:', 'red')} Expected ${this.color('status', 'yellow')} ${expectedStatus}, got ${actualStatus}`,
175
+ );
176
+ } else {
177
+ console.log(` ${this.color('✗', 'red')} ${this.color('Error:', 'red')} ${trimmedError}`);
178
+ }
179
+ } else {
180
+ // Multiple validation errors - show them nicely formatted
181
+ console.log(` ${this.color('✗', 'red')} ${this.color('Validation Errors:', 'red')}`);
182
+ for (const error of errors) {
183
+ const trimmedError = error.trim();
184
+ if (trimmedError) {
185
+ // Parse different error formats for better formatting
186
+ if (trimmedError.startsWith('Expected ')) {
187
+ // Format 1: "Expected status 201, got 200"
188
+ const statusMatch = trimmedError.match(/^Expected status (.+?), got (.+)$/);
189
+ if (statusMatch) {
190
+ const [, expected, actual] = statusMatch;
191
+ const expectedStatus = this.colorStatusCode(expected.replace(' or ', '|'));
192
+ const actualStatus = this.color(actual, 'red'); // Always red for incorrect actual values
193
+ console.log(
194
+ ` ${this.color('â€ĸ', 'red')} ${this.color('status', 'yellow')}: expected ${expectedStatus}, got ${actualStatus}`,
195
+ );
196
+ } else {
197
+ // Format 2: "Expected field to be value, got value"
198
+ const fieldMatch = trimmedError.match(/^Expected (.+?) to be (.+?), got (.+)$/);
199
+ if (fieldMatch) {
200
+ const [, field, expected, actual] = fieldMatch;
201
+ console.log(
202
+ ` ${this.color('â€ĸ', 'red')} ${this.color(field, 'yellow')}: expected ${this.color(expected, 'green')}, got ${this.color(actual, 'red')}`,
203
+ );
204
+ } else {
205
+ console.log(` ${this.color('â€ĸ', 'red')} ${trimmedError}`);
206
+ }
207
+ }
208
+ } else {
209
+ console.log(` ${this.color('â€ĸ', 'red')} ${trimmedError}`);
210
+ }
211
+ }
212
+ }
213
+ }
214
+ }
215
+
216
+ private formatJson(data: unknown): string {
217
+ if (this.config.format === 'raw') {
218
+ return typeof data === 'string' ? data : JSON.stringify(data);
219
+ }
220
+ if (this.config.format === 'json') {
221
+ return JSON.stringify(data);
222
+ }
223
+ return JSON.stringify(data, null, 2);
224
+ }
225
+
226
+ private formatDuration(ms: number): string {
227
+ if (ms < 1000) {
228
+ return `${ms.toFixed(0)}ms`;
229
+ }
230
+ return `${(ms / 1000).toFixed(2)}s`;
231
+ }
232
+
233
+ private formatSize(bytes: number | undefined): string {
234
+ if (!bytes) {
235
+ return '0 B';
236
+ }
237
+ const sizes = ['B', 'KB', 'MB', 'GB'];
238
+ const i = Math.floor(Math.log(bytes) / Math.log(1024));
239
+ return `${(bytes / 1024 ** i).toFixed(2)} ${sizes[i]}`;
240
+ }
241
+
242
+ private printSeparator(char: string = '─', length: number = 60): void {
243
+ console.log(this.color(char.repeat(length), 'dim'));
244
+ }
245
+
246
+ logExecutionStart(count: number, mode: string): void {
247
+ if (!this.shouldShowOutput()) {
248
+ return;
249
+ }
250
+
251
+ if (this.shouldShowSeparators()) {
252
+ this.printSeparator('═');
253
+ console.log(this.color('🚀 CURL RUNNER', 'bright'));
254
+ console.log(this.color(`Executing ${count} request(s) in ${mode} mode`, 'cyan'));
255
+ this.printSeparator('═');
256
+ console.log();
257
+ }
258
+ }
259
+
260
+ logRequestStart(config: RequestConfig, index: number): void {
261
+ if (!this.shouldShowOutput()) {
262
+ return;
263
+ }
264
+
265
+ const name = config.name || `Request #${index}`;
266
+ const sourceFile = config.sourceFile
267
+ ? ` ${this.color(`[${this.getShortFilename(config.sourceFile)}]`, 'cyan')}`
268
+ : '';
269
+ console.log(this.color(`â–ļ ${name}`, 'bright') + sourceFile);
270
+ console.log(
271
+ ` ${this.color(config.method || 'GET', 'yellow')} ${this.color(config.url, 'blue')}`,
272
+ );
273
+
274
+ if (
275
+ this.shouldShowRequestDetails() &&
276
+ config.headers &&
277
+ Object.keys(config.headers).length > 0
278
+ ) {
279
+ console.log(this.color(' Headers:', 'dim'));
280
+ for (const [key, value] of Object.entries(config.headers)) {
281
+ console.log(` ${key}: ${value}`);
282
+ }
283
+ }
284
+
285
+ if (this.shouldShowRequestDetails() && config.body) {
286
+ console.log(this.color(' Body:', 'dim'));
287
+ const bodyStr = this.formatJson(config.body);
288
+ for (const line of bodyStr.split('\n')) {
289
+ console.log(` ${line}`);
290
+ }
291
+ }
292
+ }
293
+
294
+ logCommand(command: string): void {
295
+ if (this.shouldShowRequestDetails()) {
296
+ console.log(this.color(' Command:', 'dim'));
297
+ console.log(this.color(` ${command}`, 'dim'));
298
+ }
299
+ }
300
+
301
+ logRetry(attempt: number, maxRetries: number): void {
302
+ console.log(this.color(` â†ģ Retry ${attempt}/${maxRetries}...`, 'yellow'));
303
+ }
304
+
305
+ logRequestComplete(result: ExecutionResult): void {
306
+ // Handle raw format output - only show response body
307
+ if (this.config.format === 'raw') {
308
+ if (result.success && this.config.showBody && result.body) {
309
+ const bodyStr = this.formatJson(result.body);
310
+ console.log(bodyStr);
311
+ }
312
+ return;
313
+ }
314
+
315
+ // Handle JSON format output - structured JSON only
316
+ if (this.config.format === 'json') {
317
+ const jsonResult = {
318
+ request: {
319
+ name: result.request.name,
320
+ url: result.request.url,
321
+ method: result.request.method || 'GET',
322
+ },
323
+ success: result.success,
324
+ status: result.status,
325
+ ...(this.shouldShowHeaders() && result.headers ? { headers: result.headers } : {}),
326
+ ...(this.shouldShowBody() && result.body ? { body: result.body } : {}),
327
+ ...(result.error ? { error: result.error } : {}),
328
+ ...(this.shouldShowMetrics() && result.metrics ? { metrics: result.metrics } : {}),
329
+ };
330
+ console.log(JSON.stringify(jsonResult, null, 2));
331
+ return;
332
+ }
333
+
334
+ // Pretty format output (default behavior)
335
+ if (!this.shouldShowOutput()) {
336
+ return;
337
+ }
338
+
339
+ const statusColor = result.success ? 'green' : 'red';
340
+ const statusIcon = result.success ? '✓' : '✗';
341
+
342
+ console.log(
343
+ ` ${this.color(statusIcon, statusColor)} ` +
344
+ `Status: ${this.color(String(result.status || 'ERROR'), statusColor)}`,
345
+ );
346
+
347
+ if (result.error) {
348
+ this.logValidationErrors(result.error);
349
+ }
350
+
351
+ if (this.shouldShowMetrics() && result.metrics) {
352
+ const metrics = result.metrics;
353
+ const parts = [`Duration: ${this.color(this.formatDuration(metrics.duration), 'cyan')}`];
354
+
355
+ if (metrics.size !== undefined) {
356
+ parts.push(`Size: ${this.color(this.formatSize(metrics.size), 'cyan')}`);
357
+ }
358
+
359
+ if (this.shouldShowRequestDetails()) {
360
+ if (metrics.dnsLookup) {
361
+ parts.push(`DNS: ${this.formatDuration(metrics.dnsLookup)}`);
362
+ }
363
+ if (metrics.tcpConnection) {
364
+ parts.push(`TCP: ${this.formatDuration(metrics.tcpConnection)}`);
365
+ }
366
+ if (metrics.tlsHandshake) {
367
+ parts.push(`TLS: ${this.formatDuration(metrics.tlsHandshake)}`);
368
+ }
369
+ if (metrics.firstByte) {
370
+ parts.push(`TTFB: ${this.formatDuration(metrics.firstByte)}`);
371
+ }
372
+ }
373
+
374
+ console.log(` ${parts.join(' | ')}`);
375
+ }
376
+
377
+ if (this.shouldShowHeaders() && result.headers && Object.keys(result.headers).length > 0) {
378
+ console.log(this.color(' Response Headers:', 'dim'));
379
+ for (const [key, value] of Object.entries(result.headers)) {
380
+ console.log(` ${key}: ${value}`);
381
+ }
382
+ }
383
+
384
+ if (this.shouldShowBody() && result.body) {
385
+ console.log(this.color(' Response Body:', 'dim'));
386
+ const bodyStr = this.formatJson(result.body);
387
+ const lines = bodyStr.split('\n');
388
+ const maxLines = this.shouldShowRequestDetails() ? Infinity : 10;
389
+ for (const line of lines.slice(0, maxLines)) {
390
+ console.log(` ${line}`);
391
+ }
392
+ if (lines.length > maxLines) {
393
+ console.log(this.color(` ... (${lines.length - maxLines} more lines)`, 'dim'));
394
+ }
395
+ }
396
+
397
+ console.log();
398
+ }
399
+
400
+ logSummary(summary: ExecutionSummary, isGlobal: boolean = false): void {
401
+ // For raw format, don't show summary
402
+ if (this.config.format === 'raw') {
403
+ return;
404
+ }
405
+
406
+ // For JSON format, output structured summary
407
+ if (this.config.format === 'json') {
408
+ const jsonSummary = {
409
+ summary: {
410
+ total: summary.total,
411
+ successful: summary.successful,
412
+ failed: summary.failed,
413
+ duration: summary.duration,
414
+ },
415
+ results: summary.results.map((result) => ({
416
+ request: {
417
+ name: result.request.name,
418
+ url: result.request.url,
419
+ method: result.request.method || 'GET',
420
+ },
421
+ success: result.success,
422
+ status: result.status,
423
+ ...(this.shouldShowHeaders() && result.headers ? { headers: result.headers } : {}),
424
+ ...(this.shouldShowBody() && result.body ? { body: result.body } : {}),
425
+ ...(result.error ? { error: result.error } : {}),
426
+ ...(this.shouldShowMetrics() && result.metrics ? { metrics: result.metrics } : {}),
427
+ })),
428
+ };
429
+ console.log(JSON.stringify(jsonSummary, null, 2));
430
+ return;
431
+ }
432
+
433
+ // Pretty format summary (default behavior)
434
+ if (!this.shouldShowOutput()) {
435
+ return;
436
+ }
437
+
438
+ if (this.shouldShowSeparators()) {
439
+ this.printSeparator('═');
440
+ const title = isGlobal ? 'đŸŽ¯ OVERALL SUMMARY' : '📊 EXECUTION SUMMARY';
441
+ console.log(this.color(title, 'bright'));
442
+ this.printSeparator();
443
+ }
444
+
445
+ const successRate = ((summary.successful / summary.total) * 100).toFixed(1);
446
+ const statusColor =
447
+ summary.failed === 0 ? 'green' : summary.successful === 0 ? 'red' : 'yellow';
448
+
449
+ console.log(` Total Requests: ${this.color(String(summary.total), 'cyan')}`);
450
+ console.log(` Successful: ${this.color(String(summary.successful), 'green')}`);
451
+ console.log(` Failed: ${this.color(String(summary.failed), 'red')}`);
452
+ console.log(` Success Rate: ${this.color(`${successRate}%`, statusColor)}`);
453
+ console.log(` Total Duration: ${this.color(this.formatDuration(summary.duration), 'cyan')}`);
454
+
455
+ if (summary.failed > 0 && this.shouldShowRequestDetails()) {
456
+ console.log();
457
+ console.log(this.color(' Failed Requests:', 'red'));
458
+ summary.results
459
+ .filter((r) => !r.success)
460
+ .forEach((r) => {
461
+ const name = r.request.name || r.request.url;
462
+ console.log(` â€ĸ ${name}: ${r.error}`);
463
+ });
464
+ }
465
+
466
+ if (this.shouldShowSeparators()) {
467
+ this.printSeparator('═');
468
+ }
469
+ }
470
+
471
+ logError(message: string): void {
472
+ console.error(this.color(`❌ ${message}`, 'red'));
473
+ }
474
+
475
+ logWarning(message: string): void {
476
+ console.warn(this.color(`âš ī¸ ${message}`, 'yellow'));
477
+ }
478
+
479
+ logInfo(message: string): void {
480
+ console.log(this.color(`â„šī¸ ${message}`, 'blue'));
481
+ }
482
+
483
+ logSuccess(message: string): void {
484
+ console.log(this.color(`✅ ${message}`, 'green'));
485
+ }
486
+
487
+ logFileHeader(fileName: string, requestCount: number): void {
488
+ if (!this.shouldShowOutput() || this.config.format !== 'pretty') {
489
+ return;
490
+ }
491
+
492
+ const shortName = fileName.replace(/.*\//, '').replace('.yaml', '');
493
+ console.log();
494
+ this.printSeparator('─');
495
+ console.log(
496
+ this.color(`📄 ${shortName}.yaml`, 'bright') +
497
+ this.color(` (${requestCount} request${requestCount === 1 ? '' : 's'})`, 'dim'),
498
+ );
499
+ this.printSeparator('─');
500
+ }
501
+ }