@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.
- package/.contextpackrc.example.json +167 -0
- package/.env.example +5 -0
- package/.github/ISSUE_TEMPLATE/bug_report.md +26 -0
- package/.github/ISSUE_TEMPLATE/feature_request.md +15 -0
- package/.github/pull_request_template.md +9 -0
- package/CODE_OF_CONDUCT.md +40 -0
- package/CONTRIBUTING.md +59 -0
- package/LICENSE +21 -0
- package/README.md +100 -0
- package/SECURITY.md +21 -0
- package/index.js +428 -0
- package/lib/analyzer.js +547 -0
- package/lib/bundler.js +477 -0
- package/lib/config.js +269 -0
- package/lib/license.js +180 -0
- package/lib/premium/config-file.js +917 -0
- package/lib/premium/gate.js +13 -0
- package/lib/premium/html-report.js +1094 -0
- package/lib/premium/index.js +57 -0
- package/lib/premium/watch-mode.js +627 -0
- package/lib/scanner.js +480 -0
- package/lib/tokenizer.js +291 -0
- package/lib/validator.js +561 -0
- package/package.json +12 -0
- package/tests/analyzer.test.mjs +128 -0
- package/tests/bundler.test.mjs +126 -0
- package/tests/config.test.mjs +103 -0
- package/tests/gate.test.mjs +118 -0
- package/tests/index.test.mjs +103 -0
- package/tests/license.test.mjs +97 -0
- package/tests/scanner.test.mjs +110 -0
- package/tests/tokenizer.test.mjs +103 -0
- package/tests/validator.test.mjs +111 -0
- package/vitest.config.mjs +13 -0
|
@@ -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;
|