@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.
package/src/cli.ts ADDED
@@ -0,0 +1,562 @@
1
+ #!/usr/bin/env bun
2
+
3
+ import { Glob } from 'bun';
4
+ import { RequestExecutor } from './executor/request-executor';
5
+ import { YamlParser } from './parser/yaml';
6
+ import type { GlobalConfig, RequestConfig } from './types/config';
7
+ import { Logger } from './utils/logger';
8
+
9
+ class CurlRunnerCLI {
10
+ private logger = new Logger();
11
+
12
+ private async loadConfigFile(): Promise<Partial<GlobalConfig>> {
13
+ const configFiles = [
14
+ 'curl-runner.yaml',
15
+ 'curl-runner.yml',
16
+ '.curl-runner.yaml',
17
+ '.curl-runner.yml',
18
+ ];
19
+
20
+ for (const filename of configFiles) {
21
+ try {
22
+ const file = Bun.file(filename);
23
+ if (await file.exists()) {
24
+ const yamlContent = await YamlParser.parseFile(filename);
25
+ // Extract global config from the YAML file
26
+ const config = yamlContent.global || yamlContent;
27
+ this.logger.logInfo(`Loaded configuration from ${filename}`);
28
+ return config;
29
+ }
30
+ } catch (error) {
31
+ this.logger.logWarning(`Failed to load configuration from ${filename}: ${error}`);
32
+ }
33
+ }
34
+
35
+ return {};
36
+ }
37
+
38
+ private loadEnvironmentVariables(): Partial<GlobalConfig> {
39
+ const envConfig: Partial<GlobalConfig> = {};
40
+
41
+ // Load environment variables
42
+ if (process.env.CURL_RUNNER_TIMEOUT) {
43
+ envConfig.defaults = {
44
+ ...envConfig.defaults,
45
+ timeout: Number.parseInt(process.env.CURL_RUNNER_TIMEOUT, 10),
46
+ };
47
+ }
48
+
49
+ if (process.env.CURL_RUNNER_RETRIES) {
50
+ envConfig.defaults = {
51
+ ...envConfig.defaults,
52
+ retry: {
53
+ ...envConfig.defaults?.retry,
54
+ count: Number.parseInt(process.env.CURL_RUNNER_RETRIES, 10),
55
+ },
56
+ };
57
+ }
58
+
59
+ if (process.env.CURL_RUNNER_RETRY_DELAY) {
60
+ envConfig.defaults = {
61
+ ...envConfig.defaults,
62
+ retry: {
63
+ ...envConfig.defaults?.retry,
64
+ delay: Number.parseInt(process.env.CURL_RUNNER_RETRY_DELAY, 10),
65
+ },
66
+ };
67
+ }
68
+
69
+ if (process.env.CURL_RUNNER_VERBOSE) {
70
+ envConfig.output = {
71
+ ...envConfig.output,
72
+ verbose: process.env.CURL_RUNNER_VERBOSE.toLowerCase() === 'true',
73
+ };
74
+ }
75
+
76
+ if (process.env.CURL_RUNNER_EXECUTION) {
77
+ envConfig.execution = process.env.CURL_RUNNER_EXECUTION as 'sequential' | 'parallel';
78
+ }
79
+
80
+ if (process.env.CURL_RUNNER_CONTINUE_ON_ERROR) {
81
+ envConfig.continueOnError =
82
+ process.env.CURL_RUNNER_CONTINUE_ON_ERROR.toLowerCase() === 'true';
83
+ }
84
+
85
+ if (process.env.CURL_RUNNER_OUTPUT_FORMAT) {
86
+ const format = process.env.CURL_RUNNER_OUTPUT_FORMAT;
87
+ if (['json', 'pretty', 'raw'].includes(format)) {
88
+ envConfig.output = { ...envConfig.output, format: format as 'json' | 'pretty' | 'raw' };
89
+ }
90
+ }
91
+
92
+ if (process.env.CURL_RUNNER_PRETTY_LEVEL) {
93
+ const level = process.env.CURL_RUNNER_PRETTY_LEVEL;
94
+ if (['minimal', 'standard', 'detailed'].includes(level)) {
95
+ envConfig.output = {
96
+ ...envConfig.output,
97
+ prettyLevel: level as 'minimal' | 'standard' | 'detailed',
98
+ };
99
+ }
100
+ }
101
+
102
+ if (process.env.CURL_RUNNER_OUTPUT_FILE) {
103
+ envConfig.output = { ...envConfig.output, saveToFile: process.env.CURL_RUNNER_OUTPUT_FILE };
104
+ }
105
+
106
+ return envConfig;
107
+ }
108
+
109
+ async run(args: string[]): Promise<void> {
110
+ try {
111
+ const { files, options } = this.parseArguments(args);
112
+
113
+ if (options.help) {
114
+ this.showHelp();
115
+ return;
116
+ }
117
+
118
+ if (options.version) {
119
+ console.log('curl-runner v1.0.0');
120
+ return;
121
+ }
122
+
123
+ // Load configuration from environment variables, config file, then CLI options
124
+ const envConfig = this.loadEnvironmentVariables();
125
+ const configFile = await this.loadConfigFile();
126
+
127
+ const yamlFiles = await this.findYamlFiles(files, options);
128
+
129
+ if (yamlFiles.length === 0) {
130
+ this.logger.logError('No YAML files found');
131
+ process.exit(1);
132
+ }
133
+
134
+ this.logger.logInfo(`Found ${yamlFiles.length} YAML file(s)`);
135
+
136
+ let globalConfig: GlobalConfig = this.mergeGlobalConfigs(envConfig, configFile);
137
+ const allRequests: RequestConfig[] = [];
138
+
139
+ // Group requests by file to show clear file separations in output
140
+ const fileGroups: Array<{ file: string; requests: RequestConfig[]; config?: GlobalConfig }> =
141
+ [];
142
+
143
+ for (const file of yamlFiles) {
144
+ this.logger.logInfo(`Processing: ${file}`);
145
+ const { requests, config } = await this.processYamlFile(file);
146
+
147
+ // Associate each request with its source file's output configuration and filename
148
+ const fileOutputConfig = config?.output || {};
149
+ const requestsWithSourceConfig = requests.map((request) => ({
150
+ ...request,
151
+ sourceOutputConfig: fileOutputConfig,
152
+ sourceFile: file,
153
+ }));
154
+
155
+ // Only merge non-output global configs (execution, continueOnError, variables, defaults)
156
+ if (config) {
157
+ const { ...nonOutputConfig } = config;
158
+ globalConfig = this.mergeGlobalConfigs(globalConfig, nonOutputConfig);
159
+ }
160
+
161
+ fileGroups.push({ file, requests: requestsWithSourceConfig, config });
162
+ allRequests.push(...requestsWithSourceConfig);
163
+ }
164
+
165
+ if (options.execution) {
166
+ globalConfig.execution = options.execution as 'sequential' | 'parallel';
167
+ }
168
+ if (options.continueOnError !== undefined) {
169
+ globalConfig.continueOnError = options.continueOnError;
170
+ }
171
+ if (options.verbose !== undefined) {
172
+ globalConfig.output = { ...globalConfig.output, verbose: options.verbose };
173
+ }
174
+ if (options.quiet !== undefined) {
175
+ globalConfig.output = { ...globalConfig.output, verbose: false };
176
+ }
177
+ if (options.output) {
178
+ globalConfig.output = { ...globalConfig.output, saveToFile: options.output };
179
+ }
180
+ if (options.outputFormat) {
181
+ globalConfig.output = {
182
+ ...globalConfig.output,
183
+ format: options.outputFormat as 'json' | 'pretty' | 'raw',
184
+ };
185
+ }
186
+ if (options.prettyLevel) {
187
+ globalConfig.output = {
188
+ ...globalConfig.output,
189
+ prettyLevel: options.prettyLevel as 'minimal' | 'standard' | 'detailed',
190
+ };
191
+ }
192
+ if (options.showHeaders !== undefined) {
193
+ globalConfig.output = { ...globalConfig.output, showHeaders: options.showHeaders };
194
+ }
195
+ if (options.showBody !== undefined) {
196
+ globalConfig.output = { ...globalConfig.output, showBody: options.showBody };
197
+ }
198
+ if (options.showMetrics !== undefined) {
199
+ globalConfig.output = { ...globalConfig.output, showMetrics: options.showMetrics };
200
+ }
201
+
202
+ // Apply timeout and retry settings to defaults
203
+ if (options.timeout) {
204
+ globalConfig.defaults = { ...globalConfig.defaults, timeout: options.timeout };
205
+ }
206
+ if (options.retries || options.noRetry) {
207
+ const retryCount = options.noRetry ? 0 : options.retries || 0;
208
+ globalConfig.defaults = {
209
+ ...globalConfig.defaults,
210
+ retry: {
211
+ ...globalConfig.defaults?.retry,
212
+ count: retryCount,
213
+ },
214
+ };
215
+ }
216
+ if (options.retryDelay) {
217
+ globalConfig.defaults = {
218
+ ...globalConfig.defaults,
219
+ retry: {
220
+ ...globalConfig.defaults?.retry,
221
+ delay: options.retryDelay,
222
+ },
223
+ };
224
+ }
225
+
226
+ if (allRequests.length === 0) {
227
+ this.logger.logError('No requests found in YAML files');
228
+ process.exit(1);
229
+ }
230
+
231
+ const executor = new RequestExecutor(globalConfig);
232
+ let summary: ExecutionSummary;
233
+
234
+ // If multiple files, execute them with file separators for clarity
235
+ if (fileGroups.length > 1) {
236
+ const allResults: ExecutionResult[] = [];
237
+ let totalDuration = 0;
238
+
239
+ for (let i = 0; i < fileGroups.length; i++) {
240
+ const group = fileGroups[i];
241
+
242
+ // Show file header for better organization
243
+ this.logger.logFileHeader(group.file, group.requests.length);
244
+
245
+ const fileSummary = await executor.execute(group.requests);
246
+ allResults.push(...fileSummary.results);
247
+ totalDuration += fileSummary.duration;
248
+
249
+ // Don't show individual file summaries for cleaner output
250
+
251
+ // Add spacing between files (except for the last one)
252
+ if (i < fileGroups.length - 1) {
253
+ console.log();
254
+ }
255
+ }
256
+
257
+ // Create combined summary
258
+ const successful = allResults.filter((r) => r.success).length;
259
+ const failed = allResults.filter((r) => !r.success).length;
260
+
261
+ summary = {
262
+ total: allResults.length,
263
+ successful,
264
+ failed,
265
+ duration: totalDuration,
266
+ results: allResults,
267
+ };
268
+
269
+ // Show final summary
270
+ executor.logger.logSummary(summary, true);
271
+ } else {
272
+ // Single file - use normal execution
273
+ summary = await executor.execute(allRequests);
274
+ }
275
+
276
+ process.exit(summary.failed > 0 && !globalConfig.continueOnError ? 1 : 0);
277
+ } catch (error) {
278
+ this.logger.logError(error instanceof Error ? error.message : String(error));
279
+ process.exit(1);
280
+ }
281
+ }
282
+
283
+ private parseArguments(args: string[]): { files: string[]; options: Record<string, unknown> } {
284
+ const options: Record<string, unknown> = {};
285
+ const files: string[] = [];
286
+
287
+ for (let i = 0; i < args.length; i++) {
288
+ const arg = args[i];
289
+
290
+ if (arg.startsWith('--')) {
291
+ const key = arg.slice(2);
292
+ const nextArg = args[i + 1];
293
+
294
+ if (key === 'help' || key === 'version') {
295
+ options[key] = true;
296
+ } else if (key === 'no-retry') {
297
+ options.noRetry = true;
298
+ } else if (key === 'quiet') {
299
+ options.quiet = true;
300
+ } else if (key === 'show-headers') {
301
+ options.showHeaders = true;
302
+ } else if (key === 'show-body') {
303
+ options.showBody = true;
304
+ } else if (key === 'show-metrics') {
305
+ options.showMetrics = true;
306
+ } else if (nextArg && !nextArg.startsWith('--')) {
307
+ if (key === 'continue-on-error') {
308
+ options.continueOnError = nextArg === 'true';
309
+ } else if (key === 'verbose') {
310
+ options.verbose = nextArg === 'true';
311
+ } else if (key === 'timeout') {
312
+ options.timeout = Number.parseInt(nextArg, 10);
313
+ } else if (key === 'retries') {
314
+ options.retries = Number.parseInt(nextArg, 10);
315
+ } else if (key === 'retry-delay') {
316
+ options.retryDelay = Number.parseInt(nextArg, 10);
317
+ } else if (key === 'output-format') {
318
+ if (['json', 'pretty', 'raw'].includes(nextArg)) {
319
+ options.outputFormat = nextArg;
320
+ }
321
+ } else if (key === 'pretty-level') {
322
+ if (['minimal', 'standard', 'detailed'].includes(nextArg)) {
323
+ options.prettyLevel = nextArg;
324
+ }
325
+ } else {
326
+ options[key] = nextArg;
327
+ }
328
+ i++;
329
+ } else {
330
+ options[key] = true;
331
+ }
332
+ } else if (arg.startsWith('-')) {
333
+ const flags = arg.slice(1);
334
+ for (const flag of flags) {
335
+ switch (flag) {
336
+ case 'h':
337
+ options.help = true;
338
+ break;
339
+ case 'v':
340
+ options.verbose = true;
341
+ break;
342
+ case 'p':
343
+ options.execution = 'parallel';
344
+ break;
345
+ case 'c':
346
+ options.continueOnError = true;
347
+ break;
348
+ case 'q':
349
+ options.quiet = true;
350
+ break;
351
+ case 'o': {
352
+ // Handle -o flag for output file
353
+ const outputArg = args[i + 1];
354
+ if (outputArg && !outputArg.startsWith('-')) {
355
+ options.output = outputArg;
356
+ i++;
357
+ }
358
+ break;
359
+ }
360
+ }
361
+ }
362
+ } else {
363
+ files.push(arg);
364
+ }
365
+ }
366
+
367
+ return { files, options };
368
+ }
369
+
370
+ private async findYamlFiles(
371
+ patterns: string[],
372
+ options: Record<string, unknown>,
373
+ ): Promise<string[]> {
374
+ const files: Set<string> = new Set();
375
+
376
+ let searchPatterns: string[] = [];
377
+
378
+ if (patterns.length === 0) {
379
+ searchPatterns = options.all ? ['**/*.yaml', '**/*.yml'] : ['*.yaml', '*.yml'];
380
+ } else {
381
+ // Check if patterns include directories
382
+ for (const pattern of patterns) {
383
+ try {
384
+ // Use Bun's file system API to check if it's a directory
385
+ const fs = await import('node:fs/promises');
386
+ const stat = await fs.stat(pattern);
387
+
388
+ if (stat.isDirectory()) {
389
+ // Add glob patterns for all YAML files in this directory
390
+ searchPatterns.push(`${pattern}/*.yaml`, `${pattern}/*.yml`);
391
+ // If --all flag is set, search recursively
392
+ if (options.all) {
393
+ searchPatterns.push(`${pattern}/**/*.yaml`, `${pattern}/**/*.yml`);
394
+ }
395
+ } else if (stat.isFile()) {
396
+ // It's a file, add it directly
397
+ searchPatterns.push(pattern);
398
+ }
399
+ } catch {
400
+ // If stat fails, assume it's a glob pattern
401
+ searchPatterns.push(pattern);
402
+ }
403
+ }
404
+ }
405
+
406
+ for (const pattern of searchPatterns) {
407
+ const globber = new Glob(pattern);
408
+ for await (const file of globber.scan('.')) {
409
+ // Only add files with .yaml or .yml extension
410
+ if (file.endsWith('.yaml') || file.endsWith('.yml')) {
411
+ files.add(file);
412
+ }
413
+ }
414
+ }
415
+
416
+ return Array.from(files).sort();
417
+ }
418
+
419
+ private async processYamlFile(
420
+ filepath: string,
421
+ ): Promise<{ requests: RequestConfig[]; config?: GlobalConfig }> {
422
+ const yamlContent = await YamlParser.parseFile(filepath);
423
+ const requests: RequestConfig[] = [];
424
+ let globalConfig: GlobalConfig | undefined;
425
+
426
+ if (yamlContent.global) {
427
+ globalConfig = yamlContent.global;
428
+ }
429
+
430
+ const variables = {
431
+ ...yamlContent.global?.variables,
432
+ ...yamlContent.collection?.variables,
433
+ };
434
+
435
+ const defaults = {
436
+ ...yamlContent.global?.defaults,
437
+ ...yamlContent.collection?.defaults,
438
+ };
439
+
440
+ if (yamlContent.request) {
441
+ const request = this.prepareRequest(yamlContent.request, variables, defaults);
442
+ requests.push(request);
443
+ }
444
+
445
+ if (yamlContent.requests) {
446
+ for (const req of yamlContent.requests) {
447
+ const request = this.prepareRequest(req, variables, defaults);
448
+ requests.push(request);
449
+ }
450
+ }
451
+
452
+ if (yamlContent.collection?.requests) {
453
+ for (const req of yamlContent.collection.requests) {
454
+ const request = this.prepareRequest(req, variables, defaults);
455
+ requests.push(request);
456
+ }
457
+ }
458
+
459
+ return { requests, config: globalConfig };
460
+ }
461
+
462
+ private prepareRequest(
463
+ request: RequestConfig,
464
+ variables: Record<string, string>,
465
+ defaults: Partial<RequestConfig>,
466
+ ): RequestConfig {
467
+ const interpolated = YamlParser.interpolateVariables(request, variables);
468
+ return YamlParser.mergeConfigs(defaults, interpolated);
469
+ }
470
+
471
+ private mergeGlobalConfigs(base: GlobalConfig, override: GlobalConfig): GlobalConfig {
472
+ return {
473
+ ...base,
474
+ ...override,
475
+ variables: { ...base.variables, ...override.variables },
476
+ output: { ...base.output, ...override.output },
477
+ defaults: { ...base.defaults, ...override.defaults },
478
+ };
479
+ }
480
+
481
+ private showHelp(): void {
482
+ console.log(`
483
+ ${this.logger.color('🚀 CURL RUNNER', 'bright')}
484
+
485
+ ${this.logger.color('USAGE:', 'yellow')}
486
+ curl-runner [files...] [options]
487
+
488
+ ${this.logger.color('OPTIONS:', 'yellow')}
489
+ -h, --help Show this help message
490
+ -v, --verbose Enable verbose output
491
+ -q, --quiet Suppress non-error output
492
+ -p, --execution parallel Execute requests in parallel
493
+ -c, --continue-on-error Continue execution on errors
494
+ -o, --output <file> Save results to file
495
+ --all Find all YAML files recursively
496
+ --timeout <ms> Set request timeout in milliseconds
497
+ --retries <count> Set maximum retry attempts
498
+ --retry-delay <ms> Set delay between retries in milliseconds
499
+ --no-retry Disable retry mechanism
500
+ --output-format <format> Set output format (json|pretty|raw)
501
+ --pretty-level <level> Set pretty format level (minimal|standard|detailed)
502
+ --show-headers Include response headers in output
503
+ --show-body Include response body in output
504
+ --show-metrics Include performance metrics in output
505
+ --version Show version
506
+
507
+ ${this.logger.color('EXAMPLES:', 'yellow')}
508
+ # Run all YAML files in current directory
509
+ curl-runner
510
+
511
+ # Run specific file
512
+ curl-runner api-tests.yaml
513
+
514
+ # Run all files in a directory
515
+ curl-runner examples/
516
+
517
+ # Run all files in multiple directories
518
+ curl-runner tests/ examples/
519
+
520
+ # Run all files recursively in parallel
521
+ curl-runner --all -p
522
+
523
+ # Run directory recursively
524
+ curl-runner --all examples/
525
+
526
+ # Run with verbose output and continue on errors
527
+ curl-runner tests/*.yaml -vc
528
+
529
+ # Run with minimal pretty output (only status and errors)
530
+ curl-runner --output-format pretty --pretty-level minimal test.yaml
531
+
532
+ # Run with detailed pretty output (show all information)
533
+ curl-runner --output-format pretty --pretty-level detailed test.yaml
534
+
535
+ ${this.logger.color('YAML STRUCTURE:', 'yellow')}
536
+ Single request:
537
+ request:
538
+ url: https://api.example.com
539
+ method: GET
540
+
541
+ Multiple requests:
542
+ requests:
543
+ - url: https://api.example.com/users
544
+ method: GET
545
+ - url: https://api.example.com/posts
546
+ method: POST
547
+ body: { title: "Test" }
548
+
549
+ With global config:
550
+ global:
551
+ execution: parallel
552
+ variables:
553
+ BASE_URL: https://api.example.com
554
+ requests:
555
+ - url: \${BASE_URL}/users
556
+ method: GET
557
+ `);
558
+ }
559
+ }
560
+
561
+ const cli = new CurlRunnerCLI();
562
+ cli.run(process.argv.slice(2));