@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,917 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const { requirePro } = require('../premium/gate');
6
+ const { loadConfig, mergeWithFlags } = require('../config');
7
+
8
+ /**
9
+ * Supported config file names in order of precedence
10
+ */
11
+ const CONFIG_FILE_NAMES = [
12
+ '.contextpackrc.json',
13
+ '.contextpackrc.js',
14
+ '.contextpackrc.cjs',
15
+ 'contextpack.config.json',
16
+ 'contextpack.config.js',
17
+ 'contextpack.config.cjs',
18
+ ];
19
+
20
+ /**
21
+ * Package.json key for embedded config
22
+ */
23
+ const PACKAGE_JSON_KEY = 'contextpack';
24
+
25
+ /**
26
+ * Deep merge two objects, with source overriding target
27
+ * @param {object} target
28
+ * @param {object} source
29
+ * @returns {object}
30
+ */
31
+ function deepMerge(target, source) {
32
+ if (!source || typeof source !== 'object') return target;
33
+ if (!target || typeof target !== 'object') return source;
34
+
35
+ const result = Object.assign({}, target);
36
+
37
+ for (const key of Object.keys(source)) {
38
+ const srcVal = source[key];
39
+ const tgtVal = result[key];
40
+
41
+ if (
42
+ srcVal !== null &&
43
+ typeof srcVal === 'object' &&
44
+ !Array.isArray(srcVal) &&
45
+ tgtVal !== null &&
46
+ typeof tgtVal === 'object' &&
47
+ !Array.isArray(tgtVal)
48
+ ) {
49
+ result[key] = deepMerge(tgtVal, srcVal);
50
+ } else {
51
+ result[key] = srcVal;
52
+ }
53
+ }
54
+
55
+ return result;
56
+ }
57
+
58
+ /**
59
+ * Validate a config object for required types and known fields
60
+ * @param {object} config
61
+ * @returns {{ valid: boolean, errors: string[], warnings: string[] }}
62
+ */
63
+ function validateConfigShape(config) {
64
+ const errors = [];
65
+ const warnings = [];
66
+
67
+ if (config === null || typeof config !== 'object' || Array.isArray(config)) {
68
+ errors.push('Config must be a plain object.');
69
+ return { valid: false, errors, warnings };
70
+ }
71
+
72
+ const stringFields = ['rootDir', 'output', 'format'];
73
+ for (const field of stringFields) {
74
+ if (field in config && typeof config[field] !== 'string') {
75
+ errors.push(`Field "${field}" must be a string, got ${typeof config[field]}.`);
76
+ }
77
+ }
78
+
79
+ const numberFields = ['tokenLimit', 'maxFileSummaryLength'];
80
+ for (const field of numberFields) {
81
+ if (field in config) {
82
+ if (typeof config[field] !== 'number' || !isFinite(config[field])) {
83
+ errors.push(`Field "${field}" must be a finite number, got ${typeof config[field]}.`);
84
+ } else if (config[field] < 0) {
85
+ errors.push(`Field "${field}" must be non-negative.`);
86
+ }
87
+ }
88
+ }
89
+
90
+ const boolFields = ['verbose', 'includeDependencyMap', 'includeSymbolIndex'];
91
+ for (const field of boolFields) {
92
+ if (field in config && typeof config[field] !== 'boolean') {
93
+ warnings.push(`Field "${field}" should be a boolean, got ${typeof config[field]}.`);
94
+ }
95
+ }
96
+
97
+ const arrayFields = ['include', 'exclude'];
98
+ for (const field of arrayFields) {
99
+ if (field in config) {
100
+ if (!Array.isArray(config[field])) {
101
+ errors.push(`Field "${field}" must be an array.`);
102
+ } else {
103
+ for (let i = 0; i < config[field].length; i++) {
104
+ if (typeof config[field][i] !== 'string') {
105
+ errors.push(`Field "${field}[${i}]" must be a string.`);
106
+ }
107
+ }
108
+ }
109
+ }
110
+ }
111
+
112
+ if ('format' in config && typeof config.format === 'string') {
113
+ const validFormats = ['json', 'markdown', 'md'];
114
+ if (!validFormats.includes(config.format.toLowerCase())) {
115
+ warnings.push(
116
+ `Field "format" has unrecognized value "${config.format}". Expected one of: ${validFormats.join(', ')}.`
117
+ );
118
+ }
119
+ }
120
+
121
+ if ('fileTypes' in config && config.fileTypes !== null && typeof config.fileTypes === 'object') {
122
+ const ft = config.fileTypes;
123
+ if ('extensions' in ft && !Array.isArray(ft.extensions)) {
124
+ errors.push('Field "fileTypes.extensions" must be an array.');
125
+ }
126
+ if ('excludeExtensions' in ft && !Array.isArray(ft.excludeExtensions)) {
127
+ errors.push('Field "fileTypes.excludeExtensions" must be an array.');
128
+ }
129
+ }
130
+
131
+ if (
132
+ 'symbolExtraction' in config &&
133
+ config.symbolExtraction !== null &&
134
+ typeof config.symbolExtraction === 'object'
135
+ ) {
136
+ const se = config.symbolExtraction;
137
+ const seBools = [
138
+ 'enabled',
139
+ 'extractFunctions',
140
+ 'extractClasses',
141
+ 'extractExports',
142
+ 'extractImports',
143
+ 'extractArrowFunctions',
144
+ 'extractDefaultExports',
145
+ 'excludePrivate',
146
+ ];
147
+ for (const field of seBools) {
148
+ if (field in se && typeof se[field] !== 'boolean') {
149
+ warnings.push(`Field "symbolExtraction.${field}" should be a boolean.`);
150
+ }
151
+ }
152
+ if ('minSymbolLength' in se) {
153
+ if (typeof se.minSymbolLength !== 'number' || !isFinite(se.minSymbolLength)) {
154
+ errors.push('Field "symbolExtraction.minSymbolLength" must be a number.');
155
+ } else if (se.minSymbolLength < 1) {
156
+ errors.push('Field "symbolExtraction.minSymbolLength" must be at least 1.');
157
+ }
158
+ }
159
+ if ('languages' in se && !Array.isArray(se.languages)) {
160
+ errors.push('Field "symbolExtraction.languages" must be an array.');
161
+ }
162
+ if ('excludePatterns' in se && !Array.isArray(se.excludePatterns)) {
163
+ errors.push('Field "symbolExtraction.excludePatterns" must be an array.');
164
+ }
165
+ }
166
+
167
+ if ('directoryRules' in config) {
168
+ if (!Array.isArray(config.directoryRules)) {
169
+ errors.push('Field "directoryRules" must be an array.');
170
+ } else {
171
+ for (let i = 0; i < config.directoryRules.length; i++) {
172
+ const rule = config.directoryRules[i];
173
+ if (!rule || typeof rule !== 'object') {
174
+ errors.push(`Field "directoryRules[${i}]" must be an object.`);
175
+ continue;
176
+ }
177
+ if (!('directory' in rule)) {
178
+ warnings.push(`Field "directoryRules[${i}]" is missing "directory" key.`);
179
+ } else if (typeof rule.directory !== 'string') {
180
+ errors.push(`Field "directoryRules[${i}].directory" must be a string.`);
181
+ }
182
+ if ('include' in rule && !Array.isArray(rule.include)) {
183
+ errors.push(`Field "directoryRules[${i}].include" must be an array.`);
184
+ }
185
+ if ('exclude' in rule && !Array.isArray(rule.exclude)) {
186
+ errors.push(`Field "directoryRules[${i}].exclude" must be an array.`);
187
+ }
188
+ }
189
+ }
190
+ }
191
+
192
+ if ('profiles' in config) {
193
+ if (!Array.isArray(config.profiles)) {
194
+ errors.push('Field "profiles" must be an array.');
195
+ } else {
196
+ for (let i = 0; i < config.profiles.length; i++) {
197
+ const profile = config.profiles[i];
198
+ if (!profile || typeof profile !== 'object') {
199
+ errors.push(`Field "profiles[${i}]" must be an object.`);
200
+ continue;
201
+ }
202
+ if (!('name' in profile) || typeof profile.name !== 'string') {
203
+ errors.push(`Field "profiles[${i}].name" must be a string.`);
204
+ }
205
+ }
206
+ }
207
+ }
208
+
209
+ return { valid: errors.length === 0, errors, warnings };
210
+ }
211
+
212
+ /**
213
+ * Locate a config file by searching upward from startDir
214
+ * @param {string} startDir - Directory to begin search
215
+ * @param {number} maxDepth - Maximum number of parent directories to traverse
216
+ * @returns {{ filePath: string, fileName: string } | null}
217
+ */
218
+ function findConfigFile(startDir, maxDepth) {
219
+ const opts = { startDir, maxDepth };
220
+ const { startDir: start = process.cwd(), maxDepth: depth = 5 } = opts || {};
221
+
222
+ let current = path.resolve(start);
223
+ let traversed = 0;
224
+
225
+ while (traversed <= depth) {
226
+ for (const name of CONFIG_FILE_NAMES) {
227
+ const candidate = path.join(current, name);
228
+ if (fs.existsSync(candidate)) {
229
+ return { filePath: candidate, fileName: name };
230
+ }
231
+ }
232
+
233
+ const pkgPath = path.join(current, 'package.json');
234
+ if (fs.existsSync(pkgPath)) {
235
+ try {
236
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
237
+ if (pkg && typeof pkg === 'object' && PACKAGE_JSON_KEY in pkg) {
238
+ return { filePath: pkgPath, fileName: 'package.json', embeddedKey: PACKAGE_JSON_KEY };
239
+ }
240
+ } catch (_) {
241
+ // ignore malformed package.json during discovery
242
+ }
243
+ }
244
+
245
+ const parent = path.dirname(current);
246
+ if (parent === current) break;
247
+ current = parent;
248
+ traversed++;
249
+ }
250
+
251
+ return null;
252
+ }
253
+
254
+ /**
255
+ * Load raw config data from a file path
256
+ * @param {string} filePath - Absolute path to config file
257
+ * @param {string|undefined} embeddedKey - If set, read this key from the parsed object
258
+ * @returns {object}
259
+ */
260
+ function loadRawConfig(filePath, embeddedKey) {
261
+ const ext = path.extname(filePath).toLowerCase();
262
+
263
+ let raw;
264
+
265
+ if (ext === '.json') {
266
+ let content;
267
+ try {
268
+ content = fs.readFileSync(filePath, 'utf8');
269
+ } catch (err) {
270
+ throw new Error(`Cannot read config file "${filePath}": ${err.message}`);
271
+ }
272
+
273
+ // Strip single-line comments before parsing (common in .contextpackrc.json)
274
+ const stripped = content.replace(/^\s*\/\/.*$/gm, '').replace(/,\s*([\]}])/g, '$1');
275
+
276
+ try {
277
+ raw = JSON.parse(stripped);
278
+ } catch (err) {
279
+ throw new Error(`Cannot parse JSON in "${filePath}": ${err.message}`);
280
+ }
281
+ } else if (ext === '.js' || ext === '.cjs') {
282
+ try {
283
+ // Clear require cache so edits are picked up in watch scenarios
284
+ delete require.cache[require.resolve(filePath)];
285
+ raw = require(filePath);
286
+ } catch (err) {
287
+ throw new Error(`Cannot load JS config file "${filePath}": ${err.message}`);
288
+ }
289
+ } else {
290
+ throw new Error(`Unsupported config file extension "${ext}" for file "${filePath}".`);
291
+ }
292
+
293
+ if (embeddedKey) {
294
+ if (!raw || typeof raw !== 'object' || !(embeddedKey in raw)) {
295
+ throw new Error(
296
+ `Expected key "${embeddedKey}" in "${filePath}" but it was not found.`
297
+ );
298
+ }
299
+ raw = raw[embeddedKey];
300
+ }
301
+
302
+ // Strip internal comment keys before returning
303
+ if (raw && typeof raw === 'object' && !Array.isArray(raw)) {
304
+ raw = stripCommentKeys(raw);
305
+ }
306
+
307
+ return raw;
308
+ }
309
+
310
+ /**
311
+ * Recursively remove keys starting with "_comment" from a config object
312
+ * @param {object} obj
313
+ * @returns {object}
314
+ */
315
+ function stripCommentKeys(obj) {
316
+ if (!obj || typeof obj !== 'object') return obj;
317
+ if (Array.isArray(obj)) return obj.map(stripCommentKeys);
318
+
319
+ const result = {};
320
+ for (const key of Object.keys(obj)) {
321
+ if (key.startsWith('_comment')) continue;
322
+ result[key] = stripCommentKeys(obj[key]);
323
+ }
324
+ return result;
325
+ }
326
+
327
+ /**
328
+ * Resolve a named profile from a config object
329
+ * @param {object} config - Full config object
330
+ * @param {string} profileName - Name of the profile to resolve
331
+ * @returns {object} - Merged config with profile applied on top
332
+ */
333
+ function resolveProfile(config, profileName) {
334
+ const { profiles } = config || {};
335
+
336
+ if (!Array.isArray(profiles) || profiles.length === 0) {
337
+ throw new Error(
338
+ `Profile "${profileName}" requested but no "profiles" array found in config.`
339
+ );
340
+ }
341
+
342
+ const profile = profiles.find(
343
+ (p) => p && typeof p === 'object' && p.name === profileName
344
+ );
345
+
346
+ if (!profile) {
347
+ const available = profiles
348
+ .filter((p) => p && typeof p.name === 'string')
349
+ .map((p) => `"${p.name}"`)
350
+ .join(', ');
351
+ throw new Error(
352
+ `Profile "${profileName}" not found. Available profiles: ${available || 'none'}.`
353
+ );
354
+ }
355
+
356
+ // Clone profile without the name key, then deep merge onto base config
357
+ const { name: _name, ...profileOverrides } = profile;
358
+ const base = Object.assign({}, config);
359
+ delete base.profiles;
360
+
361
+ return deepMerge(base, profileOverrides);
362
+ }
363
+
364
+ /**
365
+ * Write a config file to disk
366
+ * @param {string} filePath - Destination path
367
+ * @param {object} config - Config object to write
368
+ * @param {{ format?: 'json'|'js', pretty?: boolean }} opts
369
+ */
370
+ function writeConfigFile(filePath, config, opts) {
371
+ const { format = 'json', pretty = true } = opts || {};
372
+
373
+ let content;
374
+
375
+ if (format === 'js' || filePath.endsWith('.js') || filePath.endsWith('.cjs')) {
376
+ const serialized = JSON.stringify(config, null, pretty ? 2 : 0);
377
+ content = `'use strict';\n\nmodule.exports = ${serialized};\n`;
378
+ } else {
379
+ content = JSON.stringify(config, null, pretty ? 2 : 0);
380
+ }
381
+
382
+ try {
383
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
384
+ fs.writeFileSync(filePath, content, 'utf8');
385
+ } catch (err) {
386
+ throw new Error(`Cannot write config file "${filePath}": ${err.message}`);
387
+ }
388
+ }
389
+
390
+ /**
391
+ * Generate a default config scaffold with all documented fields
392
+ * @param {object} overrides - Optional field overrides
393
+ * @returns {object}
394
+ */
395
+ function generateDefaultConfig(overrides) {
396
+ const base = {
397
+ rootDir: '.',
398
+ include: ['**/*'],
399
+ exclude: [
400
+ 'node_modules/**',
401
+ '.git/**',
402
+ 'dist/**',
403
+ 'build/**',
404
+ 'coverage/**',
405
+ '.nyc_output/**',
406
+ '**/*.min.js',
407
+ '**/*.map',
408
+ '**/*.lock',
409
+ '**/*.log',
410
+ 'tmp/**',
411
+ '.cache/**',
412
+ ],
413
+ fileTypes: {
414
+ extensions: [
415
+ '.js', '.mjs', '.cjs', '.jsx',
416
+ '.ts', '.tsx',
417
+ '.json', '.md',
418
+ '.py', '.rb', '.go', '.java', '.cs', '.php', '.swift', '.kt',
419
+ ],
420
+ excludeExtensions: [
421
+ '.png', '.jpg', '.jpeg', '.gif', '.svg', '.ico',
422
+ '.woff', '.woff2', '.ttf', '.eot',
423
+ '.mp4', '.mp3', '.zip', '.tar', '.gz',
424
+ ],
425
+ },
426
+ output: 'contextpack-output.json',
427
+ format: 'json',
428
+ tokenLimit: 100000,
429
+ maxFileSummaryLength: 500,
430
+ includeDependencyMap: true,
431
+ includeSymbolIndex: true,
432
+ verbose: false,
433
+ symbolExtraction: {
434
+ enabled: true,
435
+ extractFunctions: true,
436
+ extractClasses: true,
437
+ extractExports: true,
438
+ extractImports: true,
439
+ extractArrowFunctions: true,
440
+ extractDefaultExports: true,
441
+ languages: ['javascript', 'typescript'],
442
+ minSymbolLength: 2,
443
+ excludePrivate: false,
444
+ excludePatterns: ['^_', '^test', '^spec', '^mock'],
445
+ },
446
+ directoryRules: [],
447
+ profiles: [],
448
+ };
449
+
450
+ return deepMerge(base, overrides || {});
451
+ }
452
+
453
+ /**
454
+ * Diff two config objects and return a structured change report
455
+ * @param {object} baseConfig
456
+ * @param {object} newConfig
457
+ * @param {string} [keyPath]
458
+ * @returns {Array<{ path: string, type: 'added'|'removed'|'changed', from: any, to: any }>}
459
+ */
460
+ function diffConfigs(baseConfig, newConfig, keyPath) {
461
+ const prefix = keyPath ? keyPath + '.' : '';
462
+ const changes = [];
463
+
464
+ const allKeys = new Set([
465
+ ...Object.keys(baseConfig || {}),
466
+ ...Object.keys(newConfig || {}),
467
+ ]);
468
+
469
+ for (const key of allKeys) {
470
+ if (key.startsWith('_comment')) continue;
471
+
472
+ const fullKey = prefix + key;
473
+ const baseVal = (baseConfig || {})[key];
474
+ const newVal = (newConfig || {})[key];
475
+
476
+ if (!(key in (baseConfig || {}))) {
477
+ changes.push({ path: fullKey, type: 'added', from: undefined, to: newVal });
478
+ } else if (!(key in (newConfig || {}))) {
479
+ changes.push({ path: fullKey, type: 'removed', from: baseVal, to: undefined });
480
+ } else if (
481
+ baseVal !== null &&
482
+ typeof baseVal === 'object' &&
483
+ !Array.isArray(baseVal) &&
484
+ newVal !== null &&
485
+ typeof newVal === 'object' &&
486
+ !Array.isArray(newVal)
487
+ ) {
488
+ const nested = diffConfigs(baseVal, newVal, fullKey);
489
+ changes.push(...nested);
490
+ } else if (JSON.stringify(baseVal) !== JSON.stringify(newVal)) {
491
+ changes.push({ path: fullKey, type: 'changed', from: baseVal, to: newVal });
492
+ }
493
+ }
494
+
495
+ return changes;
496
+ }
497
+
498
+ /**
499
+ * Format a diff report as a human-readable string
500
+ * @param {Array} changes
501
+ * @returns {string}
502
+ */
503
+ function formatDiff(changes) {
504
+ if (!Array.isArray(changes) || changes.length === 0) {
505
+ return 'No differences found.';
506
+ }
507
+
508
+ const lines = [];
509
+
510
+ for (const change of changes) {
511
+ if (change.type === 'added') {
512
+ lines.push(` + ${change.path}: ${JSON.stringify(change.to)}`);
513
+ } else if (change.type === 'removed') {
514
+ lines.push(` - ${change.path}: ${JSON.stringify(change.from)}`);
515
+ } else if (change.type === 'changed') {
516
+ lines.push(
517
+ ` ~ ${change.path}: ${JSON.stringify(change.from)} → ${JSON.stringify(change.to)}`
518
+ );
519
+ }
520
+ }
521
+
522
+ return lines.join('\n');
523
+ }
524
+
525
+ /**
526
+ * Merge multiple config files in order, with later files taking precedence
527
+ * @param {string[]} filePaths - Ordered list of config file paths
528
+ * @param {object} [cliFlags] - CLI flags to apply last
529
+ * @returns {{ config: object, sources: string[] }}
530
+ */
531
+ function mergeConfigFiles(filePaths, cliFlags) {
532
+ const paths = Array.isArray(filePaths) ? filePaths : [];
533
+ const sources = [];
534
+ let merged = generateDefaultConfig();
535
+
536
+ for (const filePath of paths) {
537
+ if (!filePath || typeof filePath !== 'string') continue;
538
+
539
+ const resolved = path.resolve(filePath);
540
+
541
+ if (!fs.existsSync(resolved)) {
542
+ throw new Error(`Config file not found: "${resolved}"`);
543
+ }
544
+
545
+ const raw = loadRawConfig(resolved, undefined);
546
+ merged = deepMerge(merged, raw);
547
+ sources.push(resolved);
548
+ }
549
+
550
+ if (cliFlags && typeof cliFlags === 'object') {
551
+ merged = mergeWithFlags(merged, cliFlags);
552
+ }
553
+
554
+ return { config: merged, sources };
555
+ }
556
+
557
+ /**
558
+ * Main entry point for the config-file premium feature
559
+ *
560
+ * Supported actions:
561
+ * - 'find' : Locate the config file from a given directory
562
+ * - 'load' : Load, validate, and return the resolved config
563
+ * - 'validate' : Validate a config file and return a report
564
+ * - 'init' : Scaffold a new config file at a given path
565
+ * - 'profile' : Load config and apply a named profile
566
+ * - 'diff' : Compare two config files and report differences
567
+ * - 'merge' : Merge multiple config files into one resolved config
568
+ * - 'show' : Pretty-print the resolved config to stdout
569
+ *
570
+ * @param {object} opts
571
+ * @param {string} [opts.action='load']
572
+ * @param {string} [opts.cwd] - Working directory for file discovery
573
+ * @param {string} [opts.configPath] - Explicit config file path
574
+ * @param {string} [opts.profileName] - Profile name to activate
575
+ * @param {string} [opts.outputPath] - Destination for 'init' action
576
+ * @param {string} [opts.compareWith] - Second config path for 'diff' action
577
+ * @param {string[]} [opts.mergeFiles] - Ordered config paths for 'merge' action
578
+ * @param {object} [opts.cliFlags] - CLI flags to merge last
579
+ * @param {boolean} [opts.strict] - Treat validation warnings as errors
580
+ * @param {boolean} [opts.verbose] - Print extra diagnostic info
581
+ * @param {number} [opts.maxSearchDepth] - Max parent dirs to search (default 5)
582
+ * @returns {object} - Result object with action-specific fields
583
+ */
584
+ function configFile(opts) {
585
+ requirePro('Config File Management');
586
+
587
+ const {
588
+ action = 'load',
589
+ cwd = process.cwd(),
590
+ configPath,
591
+ profileName,
592
+ outputPath,
593
+ compareWith,
594
+ mergeFiles,
595
+ cliFlags,
596
+ strict = false,
597
+ verbose = false,
598
+ maxSearchDepth = 5,
599
+ } = opts || {};
600
+
601
+ const log = verbose
602
+ ? (...args) => console.log('[config-file]', ...args)
603
+ : () => {};
604
+
605
+ // ── FIND ──────────────────────────────────────────────────────────────────
606
+ if (action === 'find') {
607
+ log('Searching for config file from:', cwd);
608
+
609
+ const found = findConfigFile(cwd, maxSearchDepth);
610
+
611
+ if (!found) {
612
+ return {
613
+ action,
614
+ found: false,
615
+ filePath: null,
616
+ fileName: null,
617
+ message: 'No config file found.',
618
+ };
619
+ }
620
+
621
+ log('Found config file:', found.filePath);
622
+
623
+ return {
624
+ action,
625
+ found: true,
626
+ filePath: found.filePath,
627
+ fileName: found.fileName,
628
+ embeddedKey: found.embeddedKey || null,
629
+ message: `Config file found: ${found.filePath}`,
630
+ };
631
+ }
632
+
633
+ // ── VALIDATE ──────────────────────────────────────────────────────────────
634
+ if (action === 'validate') {
635
+ const targetPath = configPath
636
+ ? path.resolve(configPath)
637
+ : (() => {
638
+ const found = findConfigFile(cwd, maxSearchDepth);
639
+ if (!found) throw new Error('No config file found to validate.');
640
+ return found.filePath;
641
+ })();
642
+
643
+ log('Validating config file:', targetPath);
644
+
645
+ let raw;
646
+ try {
647
+ raw = loadRawConfig(targetPath, undefined);
648
+ } catch (err) {
649
+ return {
650
+ action,
651
+ valid: false,
652
+ filePath: targetPath,
653
+ errors: [err.message],
654
+ warnings: [],
655
+ message: `Validation failed: ${err.message}`,
656
+ };
657
+ }
658
+
659
+ const report = validateConfigShape(raw);
660
+
661
+ if (strict && report.warnings.length > 0) {
662
+ report.errors.push(...report.warnings.map((w) => `[strict] ${w}`));
663
+ report.warnings = [];
664
+ report.valid = false;
665
+ }
666
+
667
+ log('Validation result:', report.valid ? 'PASS' : 'FAIL');
668
+
669
+ return {
670
+ action,
671
+ valid: report.valid,
672
+ filePath: targetPath,
673
+ errors: report.errors,
674
+ warnings: report.warnings,
675
+ message: report.valid
676
+ ? `Config file is valid: ${targetPath}`
677
+ : `Config file has ${report.errors.length} error(s).`,
678
+ };
679
+ }
680
+
681
+ // ── INIT ──────────────────────────────────────────────────────────────────
682
+ if (action === 'init') {
683
+ const dest = outputPath
684
+ ? path.resolve(outputPath)
685
+ : path.join(path.resolve(cwd), '.contextpackrc.json');
686
+
687
+ if (fs.existsSync(dest)) {
688
+ throw new Error(
689
+ `Config file already exists at "${dest}". Delete it first or specify a different --output-path.`
690
+ );
691
+ }
692
+
693
+ log('Scaffolding config file at:', dest);
694
+
695
+ const defaultConfig = generateDefaultConfig(cliFlags || {});
696
+ writeConfigFile(dest, defaultConfig, { format: 'json', pretty: true });
697
+
698
+ return {
699
+ action,
700
+ filePath: dest,
701
+ config: defaultConfig,
702
+ message: `Config file created: ${dest}`,
703
+ };
704
+ }
705
+
706
+ // ── DIFF ──────────────────────────────────────────────────────────────────
707
+ if (action === 'diff') {
708
+ if (!configPath) {
709
+ throw new Error('action "diff" requires opts.configPath (base config).');
710
+ }
711
+ if (!compareWith) {
712
+ throw new Error('action "diff" requires opts.compareWith (second config path).');
713
+ }
714
+
715
+ const basePath = path.resolve(configPath);
716
+ const comparePath = path.resolve(compareWith);
717
+
718
+ log('Diffing configs:', basePath, '↔', comparePath);
719
+
720
+ const baseRaw = loadRawConfig(basePath, undefined);
721
+ const compareRaw = loadRawConfig(comparePath, undefined);
722
+
723
+ const changes = diffConfigs(baseRaw, compareRaw);
724
+ const formatted = formatDiff(changes);
725
+
726
+ return {
727
+ action,
728
+ baseFile: basePath,
729
+ compareFile: comparePath,
730
+ changes,
731
+ changeCount: changes.length,
732
+ formatted,
733
+ message:
734
+ changes.length === 0
735
+ ? 'Configs are identical.'
736
+ : `Found ${changes.length} difference(s).`,
737
+ };
738
+ }
739
+
740
+ // ── MERGE ─────────────────────────────────────────────────────────────────
741
+ if (action === 'merge') {
742
+ const files = Array.isArray(mergeFiles) && mergeFiles.length > 0
743
+ ? mergeFiles
744
+ : (() => {
745
+ if (!configPath) {
746
+ throw new Error(
747
+ 'action "merge" requires opts.mergeFiles (array) or opts.configPath.'
748
+ );
749
+ }
750
+ return [configPath];
751
+ })();
752
+
753
+ log('Merging config files:', files);
754
+
755
+ const { config: merged, sources } = mergeConfigFiles(files, cliFlags);
756
+ const report = validateConfigShape(merged);
757
+
758
+ if (outputPath) {
759
+ const dest = path.resolve(outputPath);
760
+ writeConfigFile(dest, merged, { format: 'json', pretty: true });
761
+ log('Merged config written to:', dest);
762
+ }
763
+
764
+ return {
765
+ action,
766
+ config: merged,
767
+ sources,
768
+ valid: report.valid,
769
+ errors: report.errors,
770
+ warnings: report.warnings,
771
+ outputPath: outputPath ? path.resolve(outputPath) : null,
772
+ message: `Merged ${sources.length} config file(s).`,
773
+ };
774
+ }
775
+
776
+ // ── PROFILE ───────────────────────────────────────────────────────────────
777
+ if (action === 'profile') {
778
+ if (!profileName) {
779
+ throw new Error('action "profile" requires opts.profileName.');
780
+ }
781
+
782
+ const targetPath = configPath
783
+ ? path.resolve(configPath)
784
+ : (() => {
785
+ const found = findConfigFile(cwd, maxSearchDepth);
786
+ if (!found) throw new Error('No config file found for profile resolution.');
787
+ return found.filePath;
788
+ })();
789
+
790
+ log('Loading config for profile:', profileName, 'from:', targetPath);
791
+
792
+ const raw = loadRawConfig(targetPath, undefined);
793
+ const resolved = resolveProfile(raw, profileName);
794
+
795
+ if (cliFlags && typeof cliFlags === 'object') {
796
+ Object.assign(resolved, mergeWithFlags(resolved, cliFlags));
797
+ }
798
+
799
+ const report = validateConfigShape(resolved);
800
+
801
+ return {
802
+ action,
803
+ profileName,
804
+ filePath: targetPath,
805
+ config: resolved,
806
+ valid: report.valid,
807
+ errors: report.errors,
808
+ warnings: report.warnings,
809
+ message: `Profile "${profileName}" resolved from "${targetPath}".`,
810
+ };
811
+ }
812
+
813
+ // ── SHOW ──────────────────────────────────────────────────────────────────
814
+ if (action === 'show') {
815
+ const targetPath = configPath
816
+ ? path.resolve(configPath)
817
+ : (() => {
818
+ const found = findConfigFile(cwd, maxSearchDepth);
819
+ if (!found) throw new Error('No config file found to show.');
820
+ return found.filePath;
821
+ })();
822
+
823
+ log('Showing config from:', targetPath);
824
+
825
+ const raw = loadRawConfig(targetPath, undefined);
826
+ let resolved = raw;
827
+
828
+ if (cliFlags && typeof cliFlags === 'object') {
829
+ resolved = mergeWithFlags(raw, cliFlags);
830
+ }
831
+
832
+ const report = validateConfigShape(resolved);
833
+
834
+ console.log('\n── ContextPack Config ──────────────────────────────');
835
+ console.log(' File:', targetPath);
836
+ console.log(' Valid:', report.valid ? '✓ Yes' : '✗ No');
837
+ if (report.errors.length > 0) {
838
+ console.log(' Errors:');
839
+ report.errors.forEach((e) => console.log(' ✗', e));
840
+ }
841
+ if (report.warnings.length > 0) {
842
+ console.log(' Warnings:');
843
+ report.warnings.forEach((w) => console.log(' ⚠', w));
844
+ }
845
+ console.log('────────────────────────────────────────────────────');
846
+ console.log(JSON.stringify(resolved, null, 2));
847
+ console.log('────────────────────────────────────────────────────\n');
848
+
849
+ return {
850
+ action,
851
+ filePath: targetPath,
852
+ config: resolved,
853
+ valid: report.valid,
854
+ errors: report.errors,
855
+ warnings: report.warnings,
856
+ message: `Config shown from "${targetPath}".`,
857
+ };
858
+ }
859
+
860
+ // ── LOAD (default) ────────────────────────────────────────────────────────
861
+ {
862
+ const targetPath = configPath
863
+ ? path.resolve(configPath)
864
+ : (() => {
865
+ const found = findConfigFile(cwd, maxSearchDepth);
866
+ if (!found) {
867
+ log('No config file found; using defaults.');
868
+ return null;
869
+ }
870
+ return found.filePath;
871
+ })();
872
+
873
+ let raw;
874
+
875
+ if (targetPath) {
876
+ log('Loading config from:', targetPath);
877
+ raw = loadRawConfig(targetPath, undefined);
878
+ } else {
879
+ raw = generateDefaultConfig();
880
+ }
881
+
882
+ let resolved = raw;
883
+
884
+ if (profileName) {
885
+ log('Applying profile:', profileName);
886
+ resolved = resolveProfile(raw, profileName);
887
+ }
888
+
889
+ if (cliFlags && typeof cliFlags === 'object') {
890
+ resolved = mergeWithFlags(resolved, cliFlags);
891
+ }
892
+
893
+ const report = validateConfigShape(resolved);
894
+
895
+ if (strict && !report.valid) {
896
+ const summary = report.errors.join('; ');
897
+ throw new Error(`Config validation failed (strict mode): ${summary}`);
898
+ }
899
+
900
+ log('Config loaded successfully. Valid:', report.valid);
901
+
902
+ return {
903
+ action: 'load',
904
+ filePath: targetPath,
905
+ config: resolved,
906
+ valid: report.valid,
907
+ errors: report.errors,
908
+ warnings: report.warnings,
909
+ usingDefaults: !targetPath,
910
+ message: targetPath
911
+ ? `Config loaded from "${targetPath}".`
912
+ : 'No config file found; using built-in defaults.',
913
+ };
914
+ }
915
+ }
916
+
917
+ module.exports = { configFile };