@curl-runner/cli 1.9.0 → 1.10.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.9.0",
3
+ "version": "1.10.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
@@ -3,10 +3,17 @@
3
3
  import { Glob } from 'bun';
4
4
  import { RequestExecutor } from './executor/request-executor';
5
5
  import { YamlParser } from './parser/yaml';
6
- import type { ExecutionSummary, GlobalConfig, RequestConfig } from './types/config';
6
+ import type {
7
+ ExecutionResult,
8
+ ExecutionSummary,
9
+ GlobalConfig,
10
+ RequestConfig,
11
+ WatchConfig,
12
+ } from './types/config';
7
13
  import { Logger } from './utils/logger';
8
14
  import { VersionChecker } from './utils/version-checker';
9
15
  import { getVersion } from './version';
16
+ import { FileWatcher } from './watcher/file-watcher';
10
17
 
11
18
  class CurlRunnerCLI {
12
19
  private logger = new Logger();
@@ -137,6 +144,28 @@ class CurlRunnerCLI {
137
144
  }
138
145
  }
139
146
 
147
+ // Watch mode configuration
148
+ if (process.env.CURL_RUNNER_WATCH) {
149
+ envConfig.watch = {
150
+ ...envConfig.watch,
151
+ enabled: process.env.CURL_RUNNER_WATCH.toLowerCase() === 'true',
152
+ };
153
+ }
154
+
155
+ if (process.env.CURL_RUNNER_WATCH_DEBOUNCE) {
156
+ envConfig.watch = {
157
+ ...envConfig.watch,
158
+ debounce: Number.parseInt(process.env.CURL_RUNNER_WATCH_DEBOUNCE, 10),
159
+ };
160
+ }
161
+
162
+ if (process.env.CURL_RUNNER_WATCH_CLEAR) {
163
+ envConfig.watch = {
164
+ ...envConfig.watch,
165
+ clear: process.env.CURL_RUNNER_WATCH_CLEAR.toLowerCase() !== 'false',
166
+ };
167
+ }
168
+
140
169
  return envConfig;
141
170
  }
142
171
 
@@ -287,58 +316,107 @@ class CurlRunnerCLI {
287
316
  process.exit(1);
288
317
  }
289
318
 
290
- const executor = new RequestExecutor(globalConfig);
291
- let summary: ExecutionSummary;
319
+ // Check if watch mode is enabled
320
+ const watchEnabled = options.watch || globalConfig.watch?.enabled;
292
321
 
293
- // If multiple files, execute them with file separators for clarity
294
- if (fileGroups.length > 1) {
295
- const allResults: ExecutionResult[] = [];
296
- let totalDuration = 0;
322
+ if (watchEnabled) {
323
+ // Build watch config from options and global config
324
+ const watchConfig: WatchConfig = {
325
+ enabled: true,
326
+ debounce:
327
+ (options.watchDebounce as number | undefined) ?? globalConfig.watch?.debounce ?? 300,
328
+ clear: (options.watchClear as boolean | undefined) ?? globalConfig.watch?.clear ?? true,
329
+ };
297
330
 
298
- for (let i = 0; i < fileGroups.length; i++) {
299
- const group = fileGroups[i];
331
+ const watcher = new FileWatcher({
332
+ files: yamlFiles,
333
+ config: watchConfig,
334
+ logger: this.logger,
335
+ onRun: async () => {
336
+ await this.executeRequests(yamlFiles, globalConfig);
337
+ },
338
+ });
339
+
340
+ await watcher.start();
341
+ } else {
342
+ // Normal execution mode
343
+ const summary = await this.executeRequests(yamlFiles, globalConfig);
344
+ const exitCode = this.determineExitCode(summary, globalConfig);
345
+ process.exit(exitCode);
346
+ }
347
+ } catch (error) {
348
+ this.logger.logError(error instanceof Error ? error.message : String(error));
349
+ process.exit(1);
350
+ }
351
+ }
300
352
 
301
- // Show file header for better organization
302
- this.logger.logFileHeader(group.file, group.requests.length);
353
+ private async executeRequests(
354
+ yamlFiles: string[],
355
+ globalConfig: GlobalConfig,
356
+ ): Promise<ExecutionSummary> {
357
+ // Process YAML files and collect requests
358
+ const fileGroups: Array<{ file: string; requests: RequestConfig[]; config?: GlobalConfig }> =
359
+ [];
360
+ const allRequests: RequestConfig[] = [];
361
+
362
+ for (const file of yamlFiles) {
363
+ const { requests, config } = await this.processYamlFile(file);
364
+
365
+ const fileOutputConfig = config?.output || {};
366
+ const requestsWithSourceConfig = requests.map((request) => ({
367
+ ...request,
368
+ sourceOutputConfig: fileOutputConfig,
369
+ sourceFile: file,
370
+ }));
371
+
372
+ fileGroups.push({ file, requests: requestsWithSourceConfig, config });
373
+ allRequests.push(...requestsWithSourceConfig);
374
+ }
303
375
 
304
- const fileSummary = await executor.execute(group.requests);
305
- allResults.push(...fileSummary.results);
306
- totalDuration += fileSummary.duration;
376
+ const executor = new RequestExecutor(globalConfig);
377
+ let summary: ExecutionSummary;
307
378
 
308
- // Don't show individual file summaries for cleaner output
379
+ // If multiple files, execute them with file separators for clarity
380
+ if (fileGroups.length > 1) {
381
+ const allResults: ExecutionResult[] = [];
382
+ let totalDuration = 0;
309
383
 
310
- // Add spacing between files (except for the last one)
311
- if (i < fileGroups.length - 1) {
312
- console.log();
313
- }
314
- }
384
+ for (let i = 0; i < fileGroups.length; i++) {
385
+ const group = fileGroups[i];
315
386
 
316
- // Create combined summary
317
- const successful = allResults.filter((r) => r.success).length;
318
- const failed = allResults.filter((r) => !r.success).length;
387
+ // Show file header for better organization
388
+ this.logger.logFileHeader(group.file, group.requests.length);
319
389
 
320
- summary = {
321
- total: allResults.length,
322
- successful,
323
- failed,
324
- duration: totalDuration,
325
- results: allResults,
326
- };
390
+ const fileSummary = await executor.execute(group.requests);
391
+ allResults.push(...fileSummary.results);
392
+ totalDuration += fileSummary.duration;
327
393
 
328
- // Show final summary
329
- executor.logger.logSummary(summary, true);
330
- } else {
331
- // Single file - use normal execution
332
- summary = await executor.execute(allRequests);
394
+ // Add spacing between files (except for the last one)
395
+ if (i < fileGroups.length - 1) {
396
+ console.log();
397
+ }
333
398
  }
334
399
 
335
- // Determine exit code based on CI configuration
336
- const exitCode = this.determineExitCode(summary, globalConfig);
337
- process.exit(exitCode);
338
- } catch (error) {
339
- this.logger.logError(error instanceof Error ? error.message : String(error));
340
- process.exit(1);
400
+ // Create combined summary
401
+ const successful = allResults.filter((r) => r.success).length;
402
+ const failed = allResults.filter((r) => !r.success).length;
403
+
404
+ summary = {
405
+ total: allResults.length,
406
+ successful,
407
+ failed,
408
+ duration: totalDuration,
409
+ results: allResults,
410
+ };
411
+
412
+ // Show final summary
413
+ this.logger.logSummary(summary, true);
414
+ } else {
415
+ // Single file - use normal execution
416
+ summary = await executor.execute(allRequests);
341
417
  }
418
+
419
+ return summary;
342
420
  }
343
421
 
344
422
  private parseArguments(args: string[]): { files: string[]; options: Record<string, unknown> } {
@@ -366,6 +444,12 @@ class CurlRunnerCLI {
366
444
  options.showMetrics = true;
367
445
  } else if (key === 'strict-exit') {
368
446
  options.strictExit = true;
447
+ } else if (key === 'watch') {
448
+ options.watch = true;
449
+ } else if (key === 'watch-clear') {
450
+ options.watchClear = true;
451
+ } else if (key === 'no-watch-clear') {
452
+ options.watchClear = false;
369
453
  } else if (nextArg && !nextArg.startsWith('--')) {
370
454
  if (key === 'continue-on-error') {
371
455
  options.continueOnError = nextArg === 'true';
@@ -397,6 +481,8 @@ class CurlRunnerCLI {
397
481
  if (['minimal', 'standard', 'detailed'].includes(nextArg)) {
398
482
  options.prettyLevel = nextArg;
399
483
  }
484
+ } else if (key === 'watch-debounce') {
485
+ options.watchDebounce = Number.parseInt(nextArg, 10);
400
486
  } else {
401
487
  options[key] = nextArg;
402
488
  }
@@ -423,6 +509,9 @@ class CurlRunnerCLI {
423
509
  case 'q':
424
510
  options.quiet = true;
425
511
  break;
512
+ case 'w':
513
+ options.watch = true;
514
+ break;
426
515
  case 'o': {
427
516
  // Handle -o flag for output file
428
517
  const outputArg = args[i + 1];
@@ -551,6 +640,7 @@ class CurlRunnerCLI {
551
640
  output: { ...base.output, ...override.output },
552
641
  defaults: { ...base.defaults, ...override.defaults },
553
642
  ci: { ...base.ci, ...override.ci },
643
+ watch: { ...base.watch, ...override.watch },
554
644
  };
555
645
  }
556
646
 
@@ -633,6 +723,11 @@ ${this.logger.color('OPTIONS:', 'yellow')}
633
723
  --show-metrics Include performance metrics in output
634
724
  --version Show version
635
725
 
726
+ ${this.logger.color('WATCH MODE:', 'yellow')}
727
+ -w, --watch Watch files and re-run on changes
728
+ --watch-debounce <ms> Debounce delay for watch mode (default: 300)
729
+ --no-watch-clear Don't clear screen between watch runs
730
+
636
731
  ${this.logger.color('CI/CD OPTIONS:', 'yellow')}
637
732
  --strict-exit Exit with code 1 if any validation fails (for CI/CD)
638
733
  --fail-on <count> Exit with code 1 if failures exceed this count
@@ -681,6 +776,12 @@ ${this.logger.color('EXAMPLES:', 'yellow')}
681
776
  # CI/CD: Allow up to 10% failures
682
777
  curl-runner tests/ --fail-on-percentage 10
683
778
 
779
+ # Watch mode - re-run on file changes
780
+ curl-runner api.yaml --watch
781
+
782
+ # Watch with custom debounce
783
+ curl-runner tests/ -w --watch-debounce 500
784
+
684
785
  ${this.logger.color('YAML STRUCTURE:', 'yellow')}
685
786
  Single request:
686
787
  request:
@@ -102,7 +102,7 @@ export class RequestExecutor {
102
102
  requestLogger.logRetry(attempt, maxAttempts - 1);
103
103
  if (config.retry?.delay) {
104
104
  const backoff = config.retry.backoff ?? 1;
105
- const delay = config.retry.delay * Math.pow(backoff, attempt - 1);
105
+ const delay = config.retry.delay * backoff ** (attempt - 1);
106
106
  await Bun.sleep(delay);
107
107
  }
108
108
  }
@@ -210,6 +210,11 @@ export interface GlobalConfig {
210
210
  * Applied to all requests unless overridden at the request level.
211
211
  */
212
212
  ssl?: SSLConfig;
213
+ /**
214
+ * Watch mode configuration.
215
+ * Automatically re-runs requests when YAML files change.
216
+ */
217
+ watch?: WatchConfig;
213
218
  variables?: Record<string, string>;
214
219
  output?: {
215
220
  verbose?: boolean;
@@ -262,3 +267,16 @@ export interface ExecutionSummary {
262
267
  * Values are stored as strings and can be referenced using ${store.variableName} syntax.
263
268
  */
264
269
  export type ResponseStoreContext = Record<string, string>;
270
+
271
+ /**
272
+ * Configuration for watch mode.
273
+ * Watch mode automatically re-runs requests when YAML files change.
274
+ */
275
+ export interface WatchConfig {
276
+ /** Enable watch mode. Default: false */
277
+ enabled?: boolean;
278
+ /** Debounce delay in milliseconds. Default: 300 */
279
+ debounce?: number;
280
+ /** Clear screen between runs. Default: true */
281
+ clear?: boolean;
282
+ }
@@ -645,4 +645,28 @@ export class Logger {
645
645
  this.color(` (${requestCount} request${requestCount === 1 ? '' : 's'})`, 'dim'),
646
646
  );
647
647
  }
648
+
649
+ logWatch(files: string[]): void {
650
+ console.log();
651
+ console.log(
652
+ `${this.color('Watching for changes...', 'cyan')} ${this.color('(press Ctrl+C to stop)', 'dim')}`,
653
+ );
654
+ const fileList = files.length <= 3 ? files.join(', ') : `${files.length} files`;
655
+ console.log(this.color(` Files: ${fileList}`, 'dim'));
656
+ console.log();
657
+ }
658
+
659
+ logWatchReady(): void {
660
+ console.log();
661
+ console.log(this.color('Watching for changes...', 'cyan'));
662
+ }
663
+
664
+ logFileChanged(filename: string): void {
665
+ const timestamp = new Date().toLocaleTimeString('en-US', { hour12: false });
666
+ console.log(this.color('-'.repeat(50), 'dim'));
667
+ console.log(
668
+ `${this.color(`[${timestamp}]`, 'dim')} File changed: ${this.color(filename, 'yellow')}`,
669
+ );
670
+ console.log();
671
+ }
648
672
  }
@@ -0,0 +1,186 @@
1
+ import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test';
2
+ import { FileWatcher, type WatcherOptions } from './file-watcher';
3
+
4
+ // Mock logger
5
+ const createMockLogger = () => ({
6
+ logWatch: mock(() => {}),
7
+ logWatchReady: mock(() => {}),
8
+ logFileChanged: mock(() => {}),
9
+ logError: mock(() => {}),
10
+ logWarning: mock(() => {}),
11
+ });
12
+
13
+ // Helper to access private method for testing
14
+ const triggerFileChange = (watcher: FileWatcher, filename: string) => {
15
+ // biome-ignore lint/complexity/useLiteralKeys: accessing private method for testing
16
+ watcher['handleFileChange'](filename);
17
+ };
18
+
19
+ describe('FileWatcher', () => {
20
+ let watcher: FileWatcher;
21
+ let mockLogger: ReturnType<typeof createMockLogger>;
22
+ let onRunMock: ReturnType<typeof mock>;
23
+
24
+ beforeEach(() => {
25
+ mockLogger = createMockLogger();
26
+ onRunMock = mock(() => Promise.resolve());
27
+ });
28
+
29
+ afterEach(() => {
30
+ if (watcher) {
31
+ watcher.stop();
32
+ }
33
+ });
34
+
35
+ test('should debounce rapid file changes', async () => {
36
+ const options: WatcherOptions = {
37
+ files: [],
38
+ config: { debounce: 50 },
39
+ onRun: onRunMock,
40
+ logger: mockLogger as unknown as WatcherOptions['logger'],
41
+ };
42
+
43
+ watcher = new FileWatcher(options);
44
+
45
+ // Simulate rapid file changes
46
+ triggerFileChange(watcher, 'test.yaml');
47
+ triggerFileChange(watcher, 'test.yaml');
48
+ triggerFileChange(watcher, 'test.yaml');
49
+
50
+ // Wait for debounce to settle
51
+ await Bun.sleep(100);
52
+
53
+ // Should only have been called once due to debouncing
54
+ // Initial run doesn't happen because we didn't call start()
55
+ expect(onRunMock).toHaveBeenCalledTimes(1);
56
+ });
57
+
58
+ test('should queue runs during execution', async () => {
59
+ let runCount = 0;
60
+ const slowOnRun = mock(async () => {
61
+ runCount++;
62
+ await Bun.sleep(100);
63
+ });
64
+
65
+ const options: WatcherOptions = {
66
+ files: [],
67
+ config: { debounce: 10, clear: false },
68
+ onRun: slowOnRun,
69
+ logger: mockLogger as unknown as WatcherOptions['logger'],
70
+ };
71
+
72
+ watcher = new FileWatcher(options);
73
+
74
+ // Start first run
75
+ triggerFileChange(watcher, 'test.yaml');
76
+ await Bun.sleep(20); // Let debounce fire
77
+
78
+ // Change during run (should be queued)
79
+ triggerFileChange(watcher, 'test.yaml');
80
+
81
+ // Wait for both runs to complete
82
+ await Bun.sleep(300);
83
+
84
+ expect(runCount).toBe(2);
85
+ });
86
+
87
+ test('should clear debounce timer on stop', () => {
88
+ const options: WatcherOptions = {
89
+ files: [],
90
+ config: { debounce: 1000 },
91
+ onRun: onRunMock,
92
+ logger: mockLogger as unknown as WatcherOptions['logger'],
93
+ };
94
+
95
+ watcher = new FileWatcher(options);
96
+
97
+ // Trigger a change to start debounce timer
98
+ triggerFileChange(watcher, 'test.yaml');
99
+
100
+ // Stop should clear the timer
101
+ watcher.stop();
102
+
103
+ // biome-ignore lint/complexity/useLiteralKeys: accessing private field for testing
104
+ expect(watcher['debounceTimer']).toBeNull();
105
+ // biome-ignore lint/complexity/useLiteralKeys: accessing private field for testing
106
+ expect(watcher['watchers']).toHaveLength(0);
107
+ });
108
+
109
+ test('should use default debounce of 300ms', async () => {
110
+ const options: WatcherOptions = {
111
+ files: [],
112
+ config: {}, // No debounce specified
113
+ onRun: onRunMock,
114
+ logger: mockLogger as unknown as WatcherOptions['logger'],
115
+ };
116
+
117
+ watcher = new FileWatcher(options);
118
+
119
+ const startTime = performance.now();
120
+ triggerFileChange(watcher, 'test.yaml');
121
+
122
+ // Wait less than default debounce
123
+ await Bun.sleep(100);
124
+ expect(onRunMock).not.toHaveBeenCalled();
125
+
126
+ // Wait for full debounce
127
+ await Bun.sleep(250);
128
+ expect(onRunMock).toHaveBeenCalledTimes(1);
129
+
130
+ const elapsed = performance.now() - startTime;
131
+ expect(elapsed).toBeGreaterThanOrEqual(300);
132
+ });
133
+
134
+ test('should handle onRun errors gracefully', async () => {
135
+ const errorOnRun = mock(async () => {
136
+ throw new Error('Test error');
137
+ });
138
+
139
+ const options: WatcherOptions = {
140
+ files: [],
141
+ config: { debounce: 10 },
142
+ onRun: errorOnRun,
143
+ logger: mockLogger as unknown as WatcherOptions['logger'],
144
+ };
145
+
146
+ watcher = new FileWatcher(options);
147
+
148
+ // Should not throw
149
+ triggerFileChange(watcher, 'test.yaml');
150
+ await Bun.sleep(50);
151
+
152
+ expect(mockLogger.logError).toHaveBeenCalledWith('Test error');
153
+ });
154
+
155
+ test('should log file changed with filename', async () => {
156
+ const options: WatcherOptions = {
157
+ files: [],
158
+ config: { debounce: 10, clear: false },
159
+ onRun: onRunMock,
160
+ logger: mockLogger as unknown as WatcherOptions['logger'],
161
+ };
162
+
163
+ watcher = new FileWatcher(options);
164
+
165
+ triggerFileChange(watcher, 'my-api.yaml');
166
+ await Bun.sleep(50);
167
+
168
+ expect(mockLogger.logFileChanged).toHaveBeenCalledWith('my-api.yaml');
169
+ });
170
+
171
+ test('should call logWatchReady after run completes', async () => {
172
+ const options: WatcherOptions = {
173
+ files: [],
174
+ config: { debounce: 10, clear: false },
175
+ onRun: onRunMock,
176
+ logger: mockLogger as unknown as WatcherOptions['logger'],
177
+ };
178
+
179
+ watcher = new FileWatcher(options);
180
+
181
+ triggerFileChange(watcher, 'test.yaml');
182
+ await Bun.sleep(50);
183
+
184
+ expect(mockLogger.logWatchReady).toHaveBeenCalled();
185
+ });
186
+ });
@@ -0,0 +1,140 @@
1
+ import { type FSWatcher, watch } from 'node:fs';
2
+ import type { WatchConfig } from '../types/config';
3
+ import type { Logger } from '../utils/logger';
4
+
5
+ export interface WatcherOptions {
6
+ files: string[];
7
+ config: WatchConfig;
8
+ onRun: () => Promise<void>;
9
+ logger: Logger;
10
+ }
11
+
12
+ export class FileWatcher {
13
+ private watchers: FSWatcher[] = [];
14
+ private debounceTimer: Timer | null = null;
15
+ private isRunning = false;
16
+ private pendingRun = false;
17
+ private options: WatcherOptions;
18
+
19
+ constructor(options: WatcherOptions) {
20
+ this.options = options;
21
+ }
22
+
23
+ async start(): Promise<void> {
24
+ const { files, logger } = this.options;
25
+
26
+ // Initial run
27
+ await this.runRequests();
28
+
29
+ // Setup watchers for each file
30
+ for (const file of files) {
31
+ try {
32
+ const watcher = watch(file, (event) => {
33
+ if (event === 'change') {
34
+ this.handleFileChange(file);
35
+ }
36
+ });
37
+
38
+ watcher.on('error', (error) => {
39
+ logger.logWarning(`Watch error on ${file}: ${error.message}`);
40
+ });
41
+
42
+ this.watchers.push(watcher);
43
+ } catch (error) {
44
+ logger.logWarning(
45
+ `Failed to watch ${file}: ${error instanceof Error ? error.message : String(error)}`,
46
+ );
47
+ }
48
+ }
49
+
50
+ // Log watching status
51
+ logger.logWatch(files);
52
+
53
+ // Handle graceful shutdown
54
+ this.setupSignalHandlers();
55
+
56
+ // Keep process alive
57
+ await this.keepAlive();
58
+ }
59
+
60
+ private handleFileChange(filename: string): void {
61
+ const { config, logger } = this.options;
62
+ const debounce = config.debounce ?? 300;
63
+
64
+ // Clear existing debounce timer
65
+ if (this.debounceTimer) {
66
+ clearTimeout(this.debounceTimer);
67
+ }
68
+
69
+ // Debounce the run
70
+ this.debounceTimer = setTimeout(async () => {
71
+ // If already running, queue for after completion
72
+ if (this.isRunning) {
73
+ this.pendingRun = true;
74
+ return;
75
+ }
76
+
77
+ if (config.clear !== false) {
78
+ console.clear();
79
+ }
80
+
81
+ logger.logFileChanged(filename);
82
+ await this.runRequests();
83
+ }, debounce);
84
+ }
85
+
86
+ private async runRequests(): Promise<void> {
87
+ const { onRun, logger } = this.options;
88
+
89
+ this.isRunning = true;
90
+
91
+ try {
92
+ await onRun();
93
+ } catch (error) {
94
+ logger.logError(error instanceof Error ? error.message : String(error));
95
+ }
96
+
97
+ this.isRunning = false;
98
+
99
+ // Check for pending runs
100
+ if (this.pendingRun) {
101
+ this.pendingRun = false;
102
+ const { config } = this.options;
103
+ if (config.clear !== false) {
104
+ console.clear();
105
+ }
106
+ logger.logFileChanged('(queued change)');
107
+ await this.runRequests();
108
+ } else {
109
+ logger.logWatchReady();
110
+ }
111
+ }
112
+
113
+ private setupSignalHandlers(): void {
114
+ const cleanup = () => {
115
+ this.stop();
116
+ process.exit(0);
117
+ };
118
+
119
+ process.on('SIGINT', cleanup);
120
+ process.on('SIGTERM', cleanup);
121
+ }
122
+
123
+ private async keepAlive(): Promise<never> {
124
+ while (true) {
125
+ await Bun.sleep(1000);
126
+ }
127
+ }
128
+
129
+ stop(): void {
130
+ for (const watcher of this.watchers) {
131
+ watcher.close();
132
+ }
133
+ this.watchers = [];
134
+
135
+ if (this.debounceTimer) {
136
+ clearTimeout(this.debounceTimer);
137
+ this.debounceTimer = null;
138
+ }
139
+ }
140
+ }