@craftpipe/contextpack 1.0.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.
@@ -0,0 +1,57 @@
1
+ 'use strict';
2
+
3
+ const { checkPro, requirePro } = require('./lib/premium/gate');
4
+ const htmlReport = require('./lib/premium/html-report');
5
+ const watchMode = require('./lib/premium/watch-mode');
6
+ const configFile = require('./lib/premium/config-file');
7
+
8
+ const premiumFeatures = {
9
+ 'html-report': htmlReport,
10
+ 'watch-mode': watchMode,
11
+ 'config-file': configFile,
12
+ };
13
+
14
+ function addPremiumCommands(program) {
15
+ program
16
+ .option(
17
+ '--html-report [outputPath]',
18
+ '[PRO] Generate an HTML report of the results'
19
+ )
20
+ .option(
21
+ '--watch',
22
+ '[PRO] Watch for file changes and re-run automatically'
23
+ )
24
+ .option(
25
+ '--config <configPath>',
26
+ '[PRO] Load options from a configuration file'
27
+ );
28
+
29
+ return program;
30
+ }
31
+
32
+ function runPremium(feature, data) {
33
+ requirePro(feature);
34
+
35
+ const mod = premiumFeatures[feature];
36
+
37
+ if (!mod) {
38
+ throw new Error(
39
+ `Unknown premium feature: "${feature}". ` +
40
+ `Available features: ${Object.keys(premiumFeatures).join(', ')}`
41
+ );
42
+ }
43
+
44
+ if (typeof mod.run !== 'function') {
45
+ throw new Error(
46
+ `Premium feature "${feature}" does not export a run() function.`
47
+ );
48
+ }
49
+
50
+ return mod.run(data);
51
+ }
52
+
53
+ module.exports = {
54
+ addPremiumCommands,
55
+ runPremium,
56
+ checkPro,
57
+ };
@@ -0,0 +1,627 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const { requirePro } = require('../premium/gate');
6
+ const { scanProject } = require('../scanner');
7
+ const { analyzeProject } = require('../analyzer');
8
+ const { createBundle, formatAsJSON, formatAsMarkdown } = require('../bundler');
9
+ const { validateBundle } = require('../validator');
10
+ const { calculateBundleSize } = require('../tokenizer');
11
+ const { loadConfig, mergeWithFlags } = require('../config');
12
+
13
+ /**
14
+ * Debounce a function call
15
+ * @param {Function} fn
16
+ * @param {number} delay
17
+ * @returns {Function}
18
+ */
19
+ function debounce(fn, delay) {
20
+ let timer = null;
21
+ return function (...args) {
22
+ if (timer) clearTimeout(timer);
23
+ timer = setTimeout(() => {
24
+ timer = null;
25
+ fn.apply(this, args);
26
+ }, delay);
27
+ };
28
+ }
29
+
30
+ /**
31
+ * Format bytes into a human-readable string
32
+ * @param {number} bytes
33
+ * @returns {string}
34
+ */
35
+ function formatBytes(bytes) {
36
+ if (typeof bytes !== 'number' || isNaN(bytes)) return '0 B';
37
+ if (bytes < 1024) return bytes + ' B';
38
+ if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
39
+ return (bytes / (1024 * 1024)).toFixed(2) + ' MB';
40
+ }
41
+
42
+ /**
43
+ * Format a duration in milliseconds to a readable string
44
+ * @param {number} ms
45
+ * @returns {string}
46
+ */
47
+ function formatDuration(ms) {
48
+ if (typeof ms !== 'number' || isNaN(ms)) return '0ms';
49
+ if (ms < 1000) return ms + 'ms';
50
+ return (ms / 1000).toFixed(2) + 's';
51
+ }
52
+
53
+ /**
54
+ * Get a timestamp string for console output
55
+ * @returns {string}
56
+ */
57
+ function timestamp() {
58
+ const now = new Date();
59
+ const h = String(now.getHours()).padStart(2, '0');
60
+ const m = String(now.getMinutes()).padStart(2, '0');
61
+ const s = String(now.getSeconds()).padStart(2, '0');
62
+ return '[' + h + ':' + m + ':' + s + ']';
63
+ }
64
+
65
+ /**
66
+ * Collect all watchable directories and files from a file list
67
+ * @param {string[]} filePaths
68
+ * @param {string} rootDir
69
+ * @returns {Set<string>}
70
+ */
71
+ function collectWatchTargets(filePaths, rootDir) {
72
+ const targets = new Set();
73
+ const root = path.resolve(rootDir || '.');
74
+ targets.add(root);
75
+
76
+ if (!Array.isArray(filePaths)) return targets;
77
+
78
+ for (const filePath of filePaths) {
79
+ try {
80
+ const abs = path.resolve(filePath);
81
+ targets.add(abs);
82
+ const dir = path.dirname(abs);
83
+ if (dir !== root) {
84
+ targets.add(dir);
85
+ }
86
+ } catch (_) {
87
+ // skip unresolvable paths
88
+ }
89
+ }
90
+
91
+ return targets;
92
+ }
93
+
94
+ /**
95
+ * Resolve the output file path from config and flags
96
+ * @param {object} config
97
+ * @returns {string}
98
+ */
99
+ function resolveOutputPath(config) {
100
+ const cfg = config || {};
101
+ const output = cfg.output || 'contextpack-output.json';
102
+ const format = (cfg.format || 'json').toLowerCase();
103
+ const ext = path.extname(output);
104
+ if (!ext) {
105
+ return output + '.' + format;
106
+ }
107
+ return output;
108
+ }
109
+
110
+ /**
111
+ * Run the full scan → analyze → bundle → validate pipeline once
112
+ * @param {object} config - Merged config object
113
+ * @param {object} watchState - Mutable state shared across runs
114
+ * @returns {Promise<object>} Result summary
115
+ */
116
+ async function runPipeline(config, watchState) {
117
+ const cfg = config || {};
118
+ const state = watchState || {};
119
+ const startTime = Date.now();
120
+ const rootDir = cfg.rootDir || '.';
121
+ const format = (cfg.format || 'json').toLowerCase();
122
+ const outputPath = resolveOutputPath(cfg);
123
+ const verbose = !!cfg.verbose;
124
+
125
+ try {
126
+ if (verbose) {
127
+ console.log(timestamp() + ' \u{1F50D} Scanning ' + rootDir + ' ...');
128
+ }
129
+
130
+ const scanResult = await scanProject(cfg);
131
+
132
+ if (!scanResult || !scanResult.files) {
133
+ throw new Error('Scanner returned no file data');
134
+ }
135
+
136
+ const filePaths = scanResult.files.map(function (f) {
137
+ return f && (f.absolutePath || f.path);
138
+ }).filter(Boolean);
139
+
140
+ if (verbose) {
141
+ console.log(timestamp() + ' \u{1F9E0} Analyzing ' + filePaths.length + ' file(s)...');
142
+ }
143
+
144
+ const analysisResult = await analyzeProject(filePaths, cfg);
145
+
146
+ if (verbose) {
147
+ console.log(timestamp() + ' \u{1F4E6} Bundling...');
148
+ }
149
+
150
+ const bundle = await createBundle(scanResult, analysisResult, cfg);
151
+
152
+ if (!bundle) {
153
+ throw new Error('Bundler returned empty bundle');
154
+ }
155
+
156
+ const validationReport = await validateBundle(bundle, cfg);
157
+ const tokenInfo = calculateBundleSize(bundle);
158
+
159
+ let outputContent;
160
+ if (format === 'markdown' || format === 'md') {
161
+ outputContent = formatAsMarkdown(bundle);
162
+ } else {
163
+ outputContent = formatAsJSON(bundle);
164
+ }
165
+
166
+ const outputDir = path.dirname(path.resolve(outputPath));
167
+ if (!fs.existsSync(outputDir)) {
168
+ fs.mkdirSync(outputDir, { recursive: true });
169
+ }
170
+
171
+ fs.writeFileSync(outputPath, outputContent, 'utf8');
172
+
173
+ const duration = Date.now() - startTime;
174
+ const outputSize = Buffer.byteLength(outputContent, 'utf8');
175
+
176
+ const hasErrors = validationReport &&
177
+ Array.isArray(validationReport.errors) &&
178
+ validationReport.errors.length > 0;
179
+
180
+ const hasWarnings = validationReport &&
181
+ Array.isArray(validationReport.warnings) &&
182
+ validationReport.warnings.length > 0;
183
+
184
+ state.runCount = (state.runCount || 0) + 1;
185
+ state.lastRunAt = new Date().toISOString();
186
+ state.lastDuration = duration;
187
+ state.lastOutputSize = outputSize;
188
+ state.lastTokenCount = tokenInfo && tokenInfo.totalTokens;
189
+ state.lastFilesScanned = filePaths.length;
190
+
191
+ const statusIcon = hasErrors ? '\u274C' : hasWarnings ? '\u26A0\uFE0F' : '\u2705';
192
+
193
+ console.log(
194
+ timestamp() +
195
+ ' ' + statusIcon +
196
+ ' Bundle #' + state.runCount +
197
+ ' \u2192 ' + outputPath +
198
+ ' | ' + filePaths.length + ' files' +
199
+ ' | ~' + (tokenInfo && tokenInfo.totalTokens ? tokenInfo.totalTokens.toLocaleString() : '?') + ' tokens' +
200
+ ' | ' + formatBytes(outputSize) +
201
+ ' | ' + formatDuration(duration)
202
+ );
203
+
204
+ if (hasErrors) {
205
+ console.log(timestamp() + ' \u274C Validation errors:');
206
+ validationReport.errors.forEach(function (e) {
207
+ console.log(' - ' + (e && (e.message || e)));
208
+ });
209
+ }
210
+
211
+ if (hasWarnings && verbose) {
212
+ console.log(timestamp() + ' \u26A0\uFE0F Validation warnings:');
213
+ validationReport.warnings.forEach(function (w) {
214
+ console.log(' - ' + (w && (w.message || w)));
215
+ });
216
+ }
217
+
218
+ return {
219
+ success: true,
220
+ runCount: state.runCount,
221
+ filePaths,
222
+ outputPath,
223
+ outputSize,
224
+ duration,
225
+ tokenInfo,
226
+ validationReport,
227
+ hasErrors,
228
+ hasWarnings,
229
+ };
230
+ } catch (err) {
231
+ const duration = Date.now() - startTime;
232
+ state.errorCount = (state.errorCount || 0) + 1;
233
+ console.error(timestamp() + ' \u{1F525} Pipeline error after ' + formatDuration(duration) + ': ' + (err && err.message || String(err)));
234
+ if (cfg.verbose && err && err.stack) {
235
+ console.error(err.stack);
236
+ }
237
+ return {
238
+ success: false,
239
+ error: err,
240
+ duration,
241
+ };
242
+ }
243
+ }
244
+
245
+ /**
246
+ * Build the set of fs.watch handles for a list of targets
247
+ * @param {Set<string>} targets
248
+ * @param {Function} onChange
249
+ * @param {object} opts
250
+ * @returns {Map<string, fs.FSWatcher>}
251
+ */
252
+ function attachWatchers(targets, onChange, opts) {
253
+ const options = opts || {};
254
+ const watchers = new Map();
255
+
256
+ targets.forEach(function (target) {
257
+ try {
258
+ if (!fs.existsSync(target)) return;
259
+
260
+ const stat = fs.statSync(target);
261
+ const isDir = stat.isDirectory();
262
+
263
+ const watcher = fs.watch(
264
+ target,
265
+ { recursive: isDir, persistent: true },
266
+ function (eventType, filename) {
267
+ const label = filename
268
+ ? path.join(target, filename)
269
+ : target;
270
+ onChange(eventType, label);
271
+ }
272
+ );
273
+
274
+ watcher.on('error', function (err) {
275
+ if (options.verbose) {
276
+ console.warn(timestamp() + ' \u26A0\uFE0F Watcher error on ' + target + ': ' + (err && err.message));
277
+ }
278
+ });
279
+
280
+ watchers.set(target, watcher);
281
+ } catch (err) {
282
+ if (options.verbose) {
283
+ console.warn(timestamp() + ' \u26A0\uFE0F Could not watch ' + target + ': ' + (err && err.message));
284
+ }
285
+ }
286
+ });
287
+
288
+ return watchers;
289
+ }
290
+
291
+ /**
292
+ * Close all active fs.watch handles
293
+ * @param {Map<string, fs.FSWatcher>} watchers
294
+ */
295
+ function closeWatchers(watchers) {
296
+ if (!watchers) return;
297
+ watchers.forEach(function (watcher, target) {
298
+ try {
299
+ watcher.close();
300
+ } catch (_) {
301
+ // ignore close errors
302
+ }
303
+ });
304
+ watchers.clear();
305
+ }
306
+
307
+ /**
308
+ * Refresh watchers after a pipeline run to pick up new files/dirs
309
+ * @param {Map<string, fs.FSWatcher>} watchers
310
+ * @param {string[]} filePaths
311
+ * @param {string} rootDir
312
+ * @param {Function} onChange
313
+ * @param {object} opts
314
+ */
315
+ function refreshWatchers(watchers, filePaths, rootDir, onChange, opts) {
316
+ const newTargets = collectWatchTargets(filePaths, rootDir);
317
+ const existingKeys = new Set(watchers.keys());
318
+
319
+ // Add watchers for new targets
320
+ newTargets.forEach(function (target) {
321
+ if (!existingKeys.has(target)) {
322
+ try {
323
+ if (!fs.existsSync(target)) return;
324
+ const stat = fs.statSync(target);
325
+ const isDir = stat.isDirectory();
326
+ const watcher = fs.watch(
327
+ target,
328
+ { recursive: isDir, persistent: true },
329
+ function (eventType, filename) {
330
+ const label = filename ? path.join(target, filename) : target;
331
+ onChange(eventType, label);
332
+ }
333
+ );
334
+ watcher.on('error', function () {});
335
+ watchers.set(target, watcher);
336
+ } catch (_) {
337
+ // skip
338
+ }
339
+ }
340
+ });
341
+
342
+ // Remove watchers for targets that no longer exist
343
+ existingKeys.forEach(function (key) {
344
+ if (!newTargets.has(key)) {
345
+ try {
346
+ const w = watchers.get(key);
347
+ if (w) w.close();
348
+ } catch (_) {
349
+ // ignore
350
+ }
351
+ watchers.delete(key);
352
+ }
353
+ });
354
+ }
355
+
356
+ /**
357
+ * Determine if a changed file path should trigger a rebuild
358
+ * @param {string} changedPath
359
+ * @param {object} config
360
+ * @returns {boolean}
361
+ */
362
+ function shouldRebuild(changedPath, config) {
363
+ const cfg = config || {};
364
+ const outputPath = path.resolve(resolveOutputPath(cfg));
365
+ const absChanged = path.resolve(changedPath || '');
366
+
367
+ // Never rebuild because the output file itself changed
368
+ if (absChanged === outputPath) return false;
369
+
370
+ // Skip common noise paths
371
+ const noisePatterns = [
372
+ /node_modules/,
373
+ /\.git[/\\]/,
374
+ /\.nyc_output/,
375
+ /coverage[/\\]/,
376
+ /dist[/\\]/,
377
+ /build[/\\]/,
378
+ /\.cache[/\\]/,
379
+ /tmp[/\\]/,
380
+ /\.DS_Store$/,
381
+ /Thumbs\.db$/,
382
+ /~$/,
383
+ ];
384
+
385
+ for (let i = 0; i < noisePatterns.length; i++) {
386
+ if (noisePatterns[i].test(absChanged)) return false;
387
+ }
388
+
389
+ return true;
390
+ }
391
+
392
+ /**
393
+ * Print the watch-mode startup banner
394
+ * @param {object} config
395
+ * @param {object} opts
396
+ */
397
+ function printBanner(config, opts) {
398
+ const cfg = config || {};
399
+ const options = opts || {};
400
+ const rootDir = cfg.rootDir || '.';
401
+ const outputPath = resolveOutputPath(cfg);
402
+ const debounceMs = options.debounceMs || 400;
403
+
404
+ console.log('');
405
+ console.log(' \u{1F4E6} ContextPack \u2014 Watch Mode \u2B50 PRO');
406
+ console.log(' \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');
407
+ console.log(' Root : ' + path.resolve(rootDir));
408
+ console.log(' Output : ' + outputPath);
409
+ console.log(' Format : ' + (cfg.format || 'json').toUpperCase());
410
+ console.log(' Debounce : ' + debounceMs + 'ms');
411
+ console.log(' Verbose : ' + (cfg.verbose ? 'yes' : 'no'));
412
+ console.log(' \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');
413
+ console.log(' Press Ctrl+C to stop.');
414
+ console.log('');
415
+ }
416
+
417
+ /**
418
+ * Print a summary line when watch mode exits
419
+ * @param {object} state
420
+ */
421
+ function printExitSummary(state) {
422
+ const s = state || {};
423
+ console.log('');
424
+ console.log(' \u{1F4CA} Watch Mode Summary');
425
+ console.log(' \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');
426
+ console.log(' Total builds : ' + (s.runCount || 0));
427
+ console.log(' Errors : ' + (s.errorCount || 0));
428
+ console.log(' Last output : ' + (s.lastOutputSize ? formatBytes(s.lastOutputSize) : 'n/a'));
429
+ console.log(' Last tokens : ' + (s.lastTokenCount ? s.lastTokenCount.toLocaleString() : 'n/a'));
430
+ console.log(' Last build : ' + (s.lastDuration ? formatDuration(s.lastDuration) : 'n/a'));
431
+ console.log(' \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');
432
+ console.log('');
433
+ }
434
+
435
+ /**
436
+ * Main watch-mode entry point (PRO feature)
437
+ *
438
+ * Watches the project for file changes and automatically re-runs the full
439
+ * scan → analyze → bundle → validate pipeline, writing the output file on
440
+ * every meaningful change.
441
+ *
442
+ * @param {object} opts
443
+ * @param {object} [opts.flags] - CLI flags (override config file values)
444
+ * @param {number} [opts.debounceMs] - Milliseconds to debounce rapid changes (default 400)
445
+ * @param {boolean} [opts.runOnStart] - Run pipeline immediately on start (default true)
446
+ * @param {Function}[opts.onRebuild] - Optional callback(result) after each rebuild
447
+ * @param {Function}[opts.onError] - Optional callback(err) on unhandled errors
448
+ * @returns {Promise<{ stop: Function, getState: Function }>} Control handle
449
+ */
450
+ async function watchMode(opts) {
451
+ requirePro('Watch Mode');
452
+
453
+ const {
454
+ flags,
455
+ debounceMs,
456
+ runOnStart,
457
+ onRebuild,
458
+ onError,
459
+ } = opts || {};
460
+
461
+ const effectiveDebounce = (typeof debounceMs === 'number' && debounceMs >= 0)
462
+ ? debounceMs
463
+ : 400;
464
+
465
+ const shouldRunOnStart = runOnStart !== false;
466
+
467
+ // Load and merge config
468
+ let config;
469
+ try {
470
+ const rawConfig = loadConfig();
471
+ config = mergeWithFlags(rawConfig, flags || {});
472
+ } catch (err) {
473
+ console.error(timestamp() + ' \u274C Failed to load config: ' + (err && err.message));
474
+ config = mergeWithFlags({}, flags || {});
475
+ }
476
+
477
+ const rootDir = config.rootDir || '.';
478
+
479
+ // Shared mutable state across pipeline runs
480
+ const watchState = {
481
+ runCount: 0,
482
+ errorCount: 0,
483
+ lastRunAt: null,
484
+ lastDuration: null,
485
+ lastOutputSize: null,
486
+ lastTokenCount: null,
487
+ lastFilesScanned: null,
488
+ active: true,
489
+ };
490
+
491
+ // Active fs.watch handles
492
+ let watchers = new Map();
493
+
494
+ // Whether a build is currently in progress
495
+ let building = false;
496
+
497
+ // Queue a rebuild if one arrives while building
498
+ let pendingRebuild = false;
499
+
500
+ printBanner(config, { debounceMs: effectiveDebounce });
501
+
502
+ /**
503
+ * Execute the pipeline and refresh watchers afterwards
504
+ */
505
+ async function executePipeline() {
506
+ if (!watchState.active) return;
507
+
508
+ if (building) {
509
+ pendingRebuild = true;
510
+ return;
511
+ }
512
+
513
+ building = true;
514
+ pendingRebuild = false;
515
+
516
+ let result;
517
+ try {
518
+ result = await runPipeline(config, watchState);
519
+ } catch (err) {
520
+ watchState.errorCount = (watchState.errorCount || 0) + 1;
521
+ console.error(timestamp() + ' \u{1F525} Unhandled pipeline error: ' + (err && err.message));
522
+ if (typeof onError === 'function') {
523
+ try { onError(err); } catch (_) {}
524
+ }
525
+ result = { success: false, error: err };
526
+ }
527
+
528
+ // Refresh watchers to cover any new files/dirs discovered
529
+ if (result && result.filePaths && result.filePaths.length > 0) {
530
+ refreshWatchers(watchers, result.filePaths, rootDir, debouncedChange, config);
531
+ }
532
+
533
+ if (typeof onRebuild === 'function') {
534
+ try { onRebuild(result); } catch (_) {}
535
+ }
536
+
537
+ building = false;
538
+
539
+ // If a change arrived while we were building, run again
540
+ if (pendingRebuild && watchState.active) {
541
+ pendingRebuild = false;
542
+ setImmediate(executePipeline);
543
+ }
544
+ }
545
+
546
+ /**
547
+ * Handle a raw fs.watch event
548
+ * @param {string} eventType
549
+ * @param {string} changedPath
550
+ */
551
+ function handleChange(eventType, changedPath) {
552
+ if (!watchState.active) return;
553
+ if (!shouldRebuild(changedPath, config)) return;
554
+
555
+ if (config.verbose) {
556
+ console.log(timestamp() + ' \u{1F440} ' + eventType + ': ' + changedPath);
557
+ }
558
+
559
+ executePipeline();
560
+ }
561
+
562
+ const debouncedChange = debounce(handleChange, effectiveDebounce);
563
+
564
+ // Initial scan to discover files and set up watchers
565
+ let initialFilePaths = [];
566
+ try {
567
+ const initialScan = await scanProject(config);
568
+ if (initialScan && Array.isArray(initialScan.files)) {
569
+ initialFilePaths = initialScan.files.map(function (f) {
570
+ return f && (f.absolutePath || f.path);
571
+ }).filter(Boolean);
572
+ }
573
+ } catch (err) {
574
+ if (config.verbose) {
575
+ console.warn(timestamp() + ' \u26A0\uFE0F Initial scan for watch targets failed: ' + (err && err.message));
576
+ }
577
+ }
578
+
579
+ const initialTargets = collectWatchTargets(initialFilePaths, rootDir);
580
+ watchers = attachWatchers(initialTargets, debouncedChange, config);
581
+
582
+ console.log(
583
+ timestamp() +
584
+ ' \u{1F440} Watching ' + watchers.size + ' path(s) for changes...'
585
+ );
586
+
587
+ // Run pipeline immediately on start
588
+ if (shouldRunOnStart) {
589
+ await executePipeline();
590
+ }
591
+
592
+ // Graceful shutdown on SIGINT / SIGTERM
593
+ function shutdown(signal) {
594
+ if (!watchState.active) return;
595
+ watchState.active = false;
596
+ console.log('\n' + timestamp() + ' \u{1F6D1} Received ' + signal + ', shutting down watch mode...');
597
+ closeWatchers(watchers);
598
+ printExitSummary(watchState);
599
+ process.exit(0);
600
+ }
601
+
602
+ process.once('SIGINT', function () { shutdown('SIGINT'); });
603
+ process.once('SIGTERM', function () { shutdown('SIGTERM'); });
604
+
605
+ /**
606
+ * Programmatic stop handle — allows callers to stop watch mode without
607
+ * killing the process (useful in tests or when embedded in a larger CLI).
608
+ */
609
+ function stop() {
610
+ if (!watchState.active) return;
611
+ watchState.active = false;
612
+ closeWatchers(watchers);
613
+ printExitSummary(watchState);
614
+ }
615
+
616
+ /**
617
+ * Return a snapshot of the current watch state
618
+ * @returns {object}
619
+ */
620
+ function getState() {
621
+ return Object.assign({}, watchState);
622
+ }
623
+
624
+ return { stop, getState };
625
+ }
626
+
627
+ module.exports = watchMode;