@curl-runner/cli 1.13.0 → 1.15.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@curl-runner/cli",
3
- "version": "1.13.0",
3
+ "version": "1.15.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",
package/src/cli.ts CHANGED
@@ -1,16 +1,22 @@
1
1
  #!/usr/bin/env bun
2
2
 
3
3
  import { Glob } from 'bun';
4
+ import { BaselineManager, DiffFormatter, DiffOrchestrator } from './diff';
5
+ import { ProfileExecutor } from './executor/profile-executor';
4
6
  import { RequestExecutor } from './executor/request-executor';
5
7
  import { YamlParser } from './parser/yaml';
6
8
  import type {
9
+ DiffConfig,
7
10
  ExecutionResult,
8
11
  ExecutionSummary,
9
12
  GlobalConfig,
13
+ GlobalDiffConfig,
14
+ ProfileConfig,
10
15
  RequestConfig,
11
16
  WatchConfig,
12
17
  } from './types/config';
13
18
  import { Logger } from './utils/logger';
19
+ import { exportToCSV, exportToJSON } from './utils/stats';
14
20
  import { VersionChecker } from './utils/version-checker';
15
21
  import { getVersion } from './version';
16
22
  import { FileWatcher } from './watcher/file-watcher';
@@ -166,6 +172,49 @@ class CurlRunnerCLI {
166
172
  };
167
173
  }
168
174
 
175
+ // Profile mode configuration
176
+ if (process.env.CURL_RUNNER_PROFILE) {
177
+ const iterations = Number.parseInt(process.env.CURL_RUNNER_PROFILE, 10);
178
+ if (iterations > 0) {
179
+ envConfig.profile = {
180
+ ...envConfig.profile,
181
+ iterations,
182
+ };
183
+ }
184
+ }
185
+
186
+ if (process.env.CURL_RUNNER_PROFILE_WARMUP) {
187
+ envConfig.profile = {
188
+ ...envConfig.profile,
189
+ iterations: envConfig.profile?.iterations ?? 10,
190
+ warmup: Number.parseInt(process.env.CURL_RUNNER_PROFILE_WARMUP, 10),
191
+ };
192
+ }
193
+
194
+ if (process.env.CURL_RUNNER_PROFILE_CONCURRENCY) {
195
+ envConfig.profile = {
196
+ ...envConfig.profile,
197
+ iterations: envConfig.profile?.iterations ?? 10,
198
+ concurrency: Number.parseInt(process.env.CURL_RUNNER_PROFILE_CONCURRENCY, 10),
199
+ };
200
+ }
201
+
202
+ if (process.env.CURL_RUNNER_PROFILE_HISTOGRAM) {
203
+ envConfig.profile = {
204
+ ...envConfig.profile,
205
+ iterations: envConfig.profile?.iterations ?? 10,
206
+ histogram: process.env.CURL_RUNNER_PROFILE_HISTOGRAM.toLowerCase() === 'true',
207
+ };
208
+ }
209
+
210
+ if (process.env.CURL_RUNNER_PROFILE_EXPORT) {
211
+ envConfig.profile = {
212
+ ...envConfig.profile,
213
+ iterations: envConfig.profile?.iterations ?? 10,
214
+ exportFile: process.env.CURL_RUNNER_PROFILE_EXPORT,
215
+ };
216
+ }
217
+
169
218
  // Snapshot configuration
170
219
  if (process.env.CURL_RUNNER_SNAPSHOT) {
171
220
  envConfig.snapshot = {
@@ -198,6 +247,52 @@ class CurlRunnerCLI {
198
247
  };
199
248
  }
200
249
 
250
+ // Diff configuration
251
+ if (process.env.CURL_RUNNER_DIFF) {
252
+ envConfig.diff = {
253
+ ...envConfig.diff,
254
+ enabled: process.env.CURL_RUNNER_DIFF.toLowerCase() === 'true',
255
+ };
256
+ }
257
+
258
+ if (process.env.CURL_RUNNER_DIFF_SAVE) {
259
+ envConfig.diff = {
260
+ ...envConfig.diff,
261
+ save: process.env.CURL_RUNNER_DIFF_SAVE.toLowerCase() === 'true',
262
+ };
263
+ }
264
+
265
+ if (process.env.CURL_RUNNER_DIFF_LABEL) {
266
+ envConfig.diff = {
267
+ ...envConfig.diff,
268
+ label: process.env.CURL_RUNNER_DIFF_LABEL,
269
+ };
270
+ }
271
+
272
+ if (process.env.CURL_RUNNER_DIFF_COMPARE) {
273
+ envConfig.diff = {
274
+ ...envConfig.diff,
275
+ compareWith: process.env.CURL_RUNNER_DIFF_COMPARE,
276
+ };
277
+ }
278
+
279
+ if (process.env.CURL_RUNNER_DIFF_DIR) {
280
+ envConfig.diff = {
281
+ ...envConfig.diff,
282
+ dir: process.env.CURL_RUNNER_DIFF_DIR,
283
+ };
284
+ }
285
+
286
+ if (process.env.CURL_RUNNER_DIFF_OUTPUT) {
287
+ const format = process.env.CURL_RUNNER_DIFF_OUTPUT.toLowerCase();
288
+ if (['terminal', 'json', 'markdown'].includes(format)) {
289
+ envConfig.diff = {
290
+ ...envConfig.diff,
291
+ outputFormat: format as 'terminal' | 'json' | 'markdown',
292
+ };
293
+ }
294
+ }
295
+
201
296
  return envConfig;
202
297
  }
203
298
 
@@ -223,6 +318,12 @@ class CurlRunnerCLI {
223
318
  return;
224
319
  }
225
320
 
321
+ // Handle diff subcommand: curl-runner diff <label1> <label2> [file]
322
+ if (args[0] === 'diff' && args.length >= 3) {
323
+ await this.executeDiffSubcommand(args.slice(1), options);
324
+ return;
325
+ }
326
+
226
327
  // Load configuration from environment variables, config file, then CLI options
227
328
  const envConfig = this.loadEnvironmentVariables();
228
329
  const configFile = await this.loadConfigFile();
@@ -370,6 +471,46 @@ class CurlRunnerCLI {
370
471
  };
371
472
  }
372
473
 
474
+ // Apply diff options
475
+ if (options.diff !== undefined) {
476
+ globalConfig.diff = {
477
+ ...globalConfig.diff,
478
+ enabled: options.diff as boolean,
479
+ };
480
+ }
481
+ if (options.diffSave !== undefined) {
482
+ globalConfig.diff = {
483
+ ...globalConfig.diff,
484
+ enabled: true,
485
+ save: options.diffSave as boolean,
486
+ };
487
+ }
488
+ if (options.diffLabel !== undefined) {
489
+ globalConfig.diff = {
490
+ ...globalConfig.diff,
491
+ label: options.diffLabel as string,
492
+ };
493
+ }
494
+ if (options.diffCompare !== undefined) {
495
+ globalConfig.diff = {
496
+ ...globalConfig.diff,
497
+ enabled: true,
498
+ compareWith: options.diffCompare as string,
499
+ };
500
+ }
501
+ if (options.diffDir !== undefined) {
502
+ globalConfig.diff = {
503
+ ...globalConfig.diff,
504
+ dir: options.diffDir as string,
505
+ };
506
+ }
507
+ if (options.diffOutput !== undefined) {
508
+ globalConfig.diff = {
509
+ ...globalConfig.diff,
510
+ outputFormat: options.diffOutput as 'terminal' | 'json' | 'markdown',
511
+ };
512
+ }
513
+
373
514
  if (allRequests.length === 0) {
374
515
  this.logger.logError('No requests found in YAML files');
375
516
  process.exit(1);
@@ -378,7 +519,36 @@ class CurlRunnerCLI {
378
519
  // Check if watch mode is enabled
379
520
  const watchEnabled = options.watch || globalConfig.watch?.enabled;
380
521
 
381
- if (watchEnabled) {
522
+ // Check if profile mode is enabled (mutually exclusive with watch mode)
523
+ const profileIterations =
524
+ (options.profile as number | undefined) ?? globalConfig.profile?.iterations;
525
+ const profileEnabled = profileIterations && profileIterations > 0;
526
+
527
+ if (watchEnabled && profileEnabled) {
528
+ this.logger.logError('Profile mode and watch mode cannot be used together');
529
+ process.exit(1);
530
+ }
531
+
532
+ if (profileEnabled) {
533
+ // Profile mode - run requests multiple times for latency stats
534
+ const profileConfig: ProfileConfig = {
535
+ iterations: profileIterations,
536
+ warmup:
537
+ (options.profileWarmup as number | undefined) ?? globalConfig.profile?.warmup ?? 1,
538
+ concurrency:
539
+ (options.profileConcurrency as number | undefined) ??
540
+ globalConfig.profile?.concurrency ??
541
+ 1,
542
+ histogram:
543
+ (options.profileHistogram as boolean | undefined) ??
544
+ globalConfig.profile?.histogram ??
545
+ false,
546
+ exportFile:
547
+ (options.profileExport as string | undefined) ?? globalConfig.profile?.exportFile,
548
+ };
549
+
550
+ await this.executeProfileMode(allRequests, globalConfig, profileConfig);
551
+ } else if (watchEnabled) {
382
552
  // Build watch config from options and global config
383
553
  const watchConfig: WatchConfig = {
384
554
  enabled: true,
@@ -409,6 +579,49 @@ class CurlRunnerCLI {
409
579
  }
410
580
  }
411
581
 
582
+ private async executeProfileMode(
583
+ requests: RequestConfig[],
584
+ globalConfig: GlobalConfig,
585
+ profileConfig: ProfileConfig,
586
+ ): Promise<void> {
587
+ const profileExecutor = new ProfileExecutor(globalConfig, profileConfig);
588
+ const results = await profileExecutor.profileRequests(requests);
589
+
590
+ this.logger.logProfileSummary(results);
591
+
592
+ // Export results if requested
593
+ if (profileConfig.exportFile) {
594
+ const exportData: string[] = [];
595
+ const isCSV = profileConfig.exportFile.endsWith('.csv');
596
+
597
+ for (const result of results) {
598
+ const name = result.request.name || result.request.url;
599
+ if (isCSV) {
600
+ exportData.push(exportToCSV(result.stats, name));
601
+ } else {
602
+ exportData.push(exportToJSON(result.stats, name));
603
+ }
604
+ }
605
+
606
+ const content = isCSV ? exportData.join('\n\n') : `[${exportData.join(',\n')}]`;
607
+ await Bun.write(profileConfig.exportFile, content);
608
+ this.logger.logInfo(`Profile results exported to ${profileConfig.exportFile}`);
609
+ }
610
+
611
+ // Exit with code 1 if failure rate is high
612
+ const totalFailures = results.reduce((sum, r) => sum + r.stats.failures, 0);
613
+ const totalIterations = results.reduce(
614
+ (sum, r) => sum + r.stats.iterations + r.stats.warmup,
615
+ 0,
616
+ );
617
+
618
+ if (totalFailures > 0 && totalFailures / totalIterations > 0.5) {
619
+ process.exit(1);
620
+ }
621
+
622
+ process.exit(0);
623
+ }
624
+
412
625
  private async executeRequests(
413
626
  yamlFiles: string[],
414
627
  globalConfig: GlobalConfig,
@@ -475,9 +688,138 @@ class CurlRunnerCLI {
475
688
  summary = await executor.execute(allRequests);
476
689
  }
477
690
 
691
+ // Handle diff mode
692
+ if (globalConfig.diff?.enabled || globalConfig.diff?.save || globalConfig.diff?.compareWith) {
693
+ await this.handleDiffMode(yamlFiles[0], summary.results, globalConfig.diff);
694
+ }
695
+
478
696
  return summary;
479
697
  }
480
698
 
699
+ private async handleDiffMode(
700
+ yamlPath: string,
701
+ results: ExecutionResult[],
702
+ diffConfig: GlobalDiffConfig,
703
+ ): Promise<void> {
704
+ const orchestrator = new DiffOrchestrator(diffConfig);
705
+ const formatter = new DiffFormatter(diffConfig.outputFormat || 'terminal');
706
+ const config: DiffConfig = BaselineManager.mergeConfig(diffConfig, true) || {};
707
+
708
+ const currentLabel = diffConfig.label || 'current';
709
+ const compareLabel = diffConfig.compareWith;
710
+
711
+ // Save baseline if requested
712
+ if (diffConfig.save) {
713
+ await orchestrator.saveBaseline(yamlPath, currentLabel, results, config);
714
+ this.logger.logInfo(`Baseline saved as '${currentLabel}'`);
715
+ }
716
+
717
+ // Compare with baseline if requested
718
+ if (compareLabel) {
719
+ const diffSummary = await orchestrator.compareWithBaseline(
720
+ yamlPath,
721
+ results,
722
+ currentLabel,
723
+ compareLabel,
724
+ config,
725
+ );
726
+
727
+ // Check if baseline exists
728
+ if (diffSummary.newBaselines === diffSummary.totalRequests) {
729
+ this.logger.logWarning(
730
+ `No baseline '${compareLabel}' found. Saving current run as baseline.`,
731
+ );
732
+ await orchestrator.saveBaseline(yamlPath, compareLabel, results, config);
733
+ return;
734
+ }
735
+
736
+ const output = formatter.formatSummary(diffSummary, compareLabel, currentLabel);
737
+ console.log(output);
738
+
739
+ // Save current as baseline if configured
740
+ if (diffConfig.save) {
741
+ await orchestrator.saveBaseline(yamlPath, currentLabel, results, config);
742
+ }
743
+ } else if (diffConfig.enabled && !diffConfig.save) {
744
+ // Auto-detect: list available baselines or save first baseline
745
+ const labels = await orchestrator.listLabels(yamlPath);
746
+
747
+ if (labels.length === 0) {
748
+ // No baselines exist - save current as default baseline
749
+ await orchestrator.saveBaseline(yamlPath, 'baseline', results, config);
750
+ this.logger.logInfo(`No baselines found. Saved current run as 'baseline'.`);
751
+ } else if (labels.length === 1) {
752
+ // One baseline exists - compare against it
753
+ const diffSummary = await orchestrator.compareWithBaseline(
754
+ yamlPath,
755
+ results,
756
+ currentLabel,
757
+ labels[0],
758
+ config,
759
+ );
760
+ const output = formatter.formatSummary(diffSummary, labels[0], currentLabel);
761
+ console.log(output);
762
+ } else {
763
+ // Multiple baselines - list them
764
+ this.logger.logInfo(`Available baselines: ${labels.join(', ')}`);
765
+ this.logger.logInfo(`Use --diff-compare <label> to compare against a specific baseline.`);
766
+ }
767
+ }
768
+ }
769
+
770
+ /**
771
+ * Executes the diff subcommand to compare two stored baselines.
772
+ * Usage: curl-runner diff <label1> <label2> [file.yaml]
773
+ */
774
+ private async executeDiffSubcommand(
775
+ args: string[],
776
+ options: Record<string, unknown>,
777
+ ): Promise<void> {
778
+ const label1 = args[0];
779
+ const label2 = args[1];
780
+ let yamlFile = args[2];
781
+
782
+ // Find YAML file if not specified
783
+ if (!yamlFile) {
784
+ const yamlFiles = await this.findYamlFiles([], options);
785
+ if (yamlFiles.length === 0) {
786
+ this.logger.logError(
787
+ 'No YAML files found. Specify a file: curl-runner diff <label1> <label2> <file.yaml>',
788
+ );
789
+ process.exit(1);
790
+ }
791
+ if (yamlFiles.length > 1) {
792
+ this.logger.logError('Multiple YAML files found. Specify which file to use.');
793
+ process.exit(1);
794
+ }
795
+ yamlFile = yamlFiles[0];
796
+ }
797
+
798
+ const diffConfig: GlobalDiffConfig = {
799
+ dir: (options.diffDir as string) || '__baselines__',
800
+ outputFormat: (options.diffOutput as 'terminal' | 'json' | 'markdown') || 'terminal',
801
+ };
802
+
803
+ const orchestrator = new DiffOrchestrator(diffConfig);
804
+ const formatter = new DiffFormatter(diffConfig.outputFormat || 'terminal');
805
+ const config: DiffConfig = { exclude: [], match: {} };
806
+
807
+ try {
808
+ const diffSummary = await orchestrator.compareTwoBaselines(yamlFile, label1, label2, config);
809
+ const output = formatter.formatSummary(diffSummary, label1, label2);
810
+ console.log(output);
811
+
812
+ // Exit with code 1 if differences found
813
+ if (diffSummary.changed > 0) {
814
+ process.exit(1);
815
+ }
816
+ process.exit(0);
817
+ } catch (error) {
818
+ this.logger.logError(error instanceof Error ? error.message : String(error));
819
+ process.exit(1);
820
+ }
821
+ }
822
+
481
823
  private parseArguments(args: string[]): { files: string[]; options: Record<string, unknown> } {
482
824
  const options: Record<string, unknown> = {};
483
825
  const files: string[] = [];
@@ -509,6 +851,8 @@ class CurlRunnerCLI {
509
851
  options.watchClear = true;
510
852
  } else if (key === 'no-watch-clear') {
511
853
  options.watchClear = false;
854
+ } else if (key === 'profile-histogram') {
855
+ options.profileHistogram = true;
512
856
  } else if (key === 'snapshot') {
513
857
  options.snapshot = true;
514
858
  } else if (key === 'update-snapshots') {
@@ -517,6 +861,10 @@ class CurlRunnerCLI {
517
861
  options.snapshotUpdate = 'failing';
518
862
  } else if (key === 'ci-snapshot') {
519
863
  options.snapshotCi = true;
864
+ } else if (key === 'diff') {
865
+ options.diff = true;
866
+ } else if (key === 'diff-save') {
867
+ options.diffSave = true;
520
868
  } else if (nextArg && !nextArg.startsWith('--')) {
521
869
  if (key === 'continue-on-error') {
522
870
  options.continueOnError = nextArg === 'true';
@@ -550,8 +898,26 @@ class CurlRunnerCLI {
550
898
  }
551
899
  } else if (key === 'watch-debounce') {
552
900
  options.watchDebounce = Number.parseInt(nextArg, 10);
901
+ } else if (key === 'profile') {
902
+ options.profile = Number.parseInt(nextArg, 10);
903
+ } else if (key === 'profile-warmup') {
904
+ options.profileWarmup = Number.parseInt(nextArg, 10);
905
+ } else if (key === 'profile-concurrency') {
906
+ options.profileConcurrency = Number.parseInt(nextArg, 10);
907
+ } else if (key === 'profile-export') {
908
+ options.profileExport = nextArg;
553
909
  } else if (key === 'snapshot-dir') {
554
910
  options.snapshotDir = nextArg;
911
+ } else if (key === 'diff-label') {
912
+ options.diffLabel = nextArg;
913
+ } else if (key === 'diff-compare') {
914
+ options.diffCompare = nextArg;
915
+ } else if (key === 'diff-dir') {
916
+ options.diffDir = nextArg;
917
+ } else if (key === 'diff-output') {
918
+ if (['terminal', 'json', 'markdown'].includes(nextArg)) {
919
+ options.diffOutput = nextArg;
920
+ }
555
921
  } else {
556
922
  options[key] = nextArg;
557
923
  }
@@ -587,6 +953,9 @@ class CurlRunnerCLI {
587
953
  case 'u':
588
954
  options.snapshotUpdate = 'all';
589
955
  break;
956
+ case 'd':
957
+ options.diff = true;
958
+ break;
590
959
  case 'o': {
591
960
  // Handle -o flag for output file
592
961
  const outputArg = args[i + 1];
@@ -596,6 +965,15 @@ class CurlRunnerCLI {
596
965
  }
597
966
  break;
598
967
  }
968
+ case 'P': {
969
+ // Handle -P flag for profile mode
970
+ const profileArg = args[i + 1];
971
+ if (profileArg && !profileArg.startsWith('-')) {
972
+ options.profile = Number.parseInt(profileArg, 10);
973
+ i++;
974
+ }
975
+ break;
976
+ }
599
977
  }
600
978
  }
601
979
  } else {
@@ -717,6 +1095,7 @@ class CurlRunnerCLI {
717
1095
  ci: { ...base.ci, ...override.ci },
718
1096
  watch: { ...base.watch, ...override.watch },
719
1097
  snapshot: { ...base.snapshot, ...override.snapshot },
1098
+ diff: { ...base.diff, ...override.diff },
720
1099
  };
721
1100
  }
722
1101
 
@@ -804,6 +1183,13 @@ ${this.logger.color('WATCH MODE:', 'yellow')}
804
1183
  --watch-debounce <ms> Debounce delay for watch mode (default: 300)
805
1184
  --no-watch-clear Don't clear screen between watch runs
806
1185
 
1186
+ ${this.logger.color('PROFILE MODE:', 'yellow')}
1187
+ -P, --profile <n> Run each request N times for latency stats
1188
+ --profile-warmup <n> Warmup iterations to exclude from stats (default: 1)
1189
+ --profile-concurrency <n> Concurrent iterations (default: 1 = sequential)
1190
+ --profile-histogram Show ASCII histogram of latency distribution
1191
+ --profile-export <file> Export raw timings to file (.json or .csv)
1192
+
807
1193
  ${this.logger.color('CI/CD OPTIONS:', 'yellow')}
808
1194
  --strict-exit Exit with code 1 if any validation fails (for CI/CD)
809
1195
  --fail-on <count> Exit with code 1 if failures exceed this count
@@ -816,6 +1202,18 @@ ${this.logger.color('SNAPSHOT OPTIONS:', 'yellow')}
816
1202
  --snapshot-dir <dir> Custom snapshot directory (default: __snapshots__)
817
1203
  --ci-snapshot Fail if snapshot is missing (CI mode)
818
1204
 
1205
+ ${this.logger.color('DIFF OPTIONS:', 'yellow')}
1206
+ -d, --diff Enable response diffing (compare with baseline)
1207
+ --diff-save Save current run as baseline
1208
+ --diff-label <name> Label for current run (e.g., 'staging', 'v1.0')
1209
+ --diff-compare <label> Compare against this baseline label
1210
+ --diff-dir <dir> Baseline storage directory (default: __baselines__)
1211
+ --diff-output <format> Output format (terminal|json|markdown)
1212
+
1213
+ ${this.logger.color('DIFF SUBCOMMAND:', 'yellow')}
1214
+ curl-runner diff <label1> <label2> [file.yaml]
1215
+ Compare two stored baselines without making requests
1216
+
819
1217
  ${this.logger.color('EXAMPLES:', 'yellow')}
820
1218
  # Run all YAML files in current directory
821
1219
  curl-runner
@@ -865,6 +1263,15 @@ ${this.logger.color('EXAMPLES:', 'yellow')}
865
1263
  # Watch with custom debounce
866
1264
  curl-runner tests/ -w --watch-debounce 500
867
1265
 
1266
+ # Profile mode - run request 100 times for latency stats
1267
+ curl-runner api.yaml -P 100
1268
+
1269
+ # Profile with 5 warmup iterations and histogram
1270
+ curl-runner api.yaml --profile 50 --profile-warmup 5 --profile-histogram
1271
+
1272
+ # Profile with concurrent iterations and export
1273
+ curl-runner api.yaml -P 100 --profile-concurrency 10 --profile-export results.json
1274
+
868
1275
  # Snapshot testing - save and compare responses
869
1276
  curl-runner api.yaml --snapshot
870
1277
 
@@ -874,6 +1281,21 @@ ${this.logger.color('EXAMPLES:', 'yellow')}
874
1281
  # CI mode - fail if snapshot missing
875
1282
  curl-runner api.yaml --snapshot --ci-snapshot
876
1283
 
1284
+ # Response diffing - save baseline for staging
1285
+ curl-runner api.yaml --diff-save --diff-label staging
1286
+
1287
+ # Compare current run against staging baseline
1288
+ curl-runner api.yaml --diff --diff-compare staging
1289
+
1290
+ # Compare staging vs production baselines (offline)
1291
+ curl-runner diff staging production api.yaml
1292
+
1293
+ # Auto-diff: creates baseline on first run, compares on subsequent runs
1294
+ curl-runner api.yaml --diff
1295
+
1296
+ # Diff with JSON output for CI
1297
+ curl-runner api.yaml --diff --diff-compare staging --diff-output json
1298
+
877
1299
  ${this.logger.color('YAML STRUCTURE:', 'yellow')}
878
1300
  Single request:
879
1301
  request: