tcsetup 1.7.0 → 1.8.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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/src/yaml-merge.js +710 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tcsetup",
3
- "version": "1.7.0",
3
+ "version": "1.8.0",
4
4
  "type": "module",
5
5
  "description": "Bootstrap a new project with BMAD, Spec Kit, Agreement System, and Mermaid Workbench in one command.",
6
6
  "bin": {
@@ -0,0 +1,710 @@
1
+ /**
2
+ * YAML Merge Module
3
+ * Handles intelligent merging of YAML configuration files with deduplication
4
+ * and proper handling of nested objects and arrays.
5
+ *
6
+ * Zero runtime dependencies - uses only Node.js built-ins (no external YAML libraries)
7
+ */
8
+
9
+ // ============================================================================
10
+ // Helper Functions - Deep Equality and Comparison
11
+ // ============================================================================
12
+
13
+ /**
14
+ * Recursively checks if two values are deeply equal
15
+ * @param {*} a - First value to compare
16
+ * @param {*} b - Second value to compare
17
+ * @returns {boolean} True if values are deeply equal
18
+ */
19
+ export function deepEqual(a, b) {
20
+ // Primitive types
21
+ if (a === b) return true;
22
+ if (a == null || b == null) return a === b;
23
+ if (typeof a !== typeof b) return false;
24
+
25
+ // Arrays
26
+ if (Array.isArray(a) && Array.isArray(b)) {
27
+ if (a.length !== b.length) return false;
28
+ return a.every((item, i) => deepEqual(item, b[i]));
29
+ }
30
+
31
+ // Objects
32
+ if (typeof a === 'object' && typeof b === 'object') {
33
+ const keysA = Object.keys(a);
34
+ const keysB = Object.keys(b);
35
+ if (keysA.length !== keysB.length) return false;
36
+ return keysA.every((key) => deepEqual(a[key], b[key]));
37
+ }
38
+
39
+ return false;
40
+ }
41
+
42
+ // ============================================================================
43
+ // Array Deduplication
44
+ // ============================================================================
45
+
46
+ /**
47
+ * Deduplicates an array by removing duplicate items found in the existing array
48
+ * Uses deep equality checking
49
+ * @param {Array} existing - Current array values
50
+ * @param {Array} update - New items to merge
51
+ * @returns {Array} Merged array with duplicates removed
52
+ */
53
+ export function deduplicateArrays(existing, update) {
54
+ if (!Array.isArray(existing)) existing = [];
55
+ if (!Array.isArray(update)) update = [];
56
+
57
+ const result = [...existing];
58
+ const dedup = [];
59
+
60
+ for (const item of update) {
61
+ const isDuplicate = result.some((existingItem) =>
62
+ deepEqual(item, existingItem)
63
+ );
64
+ if (!isDuplicate) {
65
+ result.push(item);
66
+ dedup.push(item);
67
+ }
68
+ }
69
+
70
+ return { result, deduped: dedup.length };
71
+ }
72
+
73
+ // ============================================================================
74
+ // Object Merging
75
+ // ============================================================================
76
+
77
+ /**
78
+ * Recursively merges two objects
79
+ * New keys are added, existing keys are preserved, nested objects are merged recursively
80
+ * @param {object} existing - Current object
81
+ * @param {object} update - Object with new/updated values
82
+ * @returns {object} Merged object
83
+ */
84
+ export function mergeObjects(existing, update) {
85
+ if (!existing || typeof existing !== 'object' || Array.isArray(existing)) {
86
+ return update || {};
87
+ }
88
+ if (!update || typeof update !== 'object' || Array.isArray(update)) {
89
+ return existing;
90
+ }
91
+
92
+ const result = { ...existing };
93
+
94
+ for (const [key, value] of Object.entries(update)) {
95
+ if (key in result) {
96
+ // Key exists - recursively merge if both are objects
97
+ if (
98
+ typeof result[key] === 'object' &&
99
+ !Array.isArray(result[key]) &&
100
+ typeof value === 'object' &&
101
+ !Array.isArray(value)
102
+ ) {
103
+ result[key] = mergeObjects(result[key], value);
104
+ } else if (Array.isArray(result[key]) && Array.isArray(value)) {
105
+ // Both are arrays - deduplicate
106
+ const { result: merged } = deduplicateArrays(result[key], value);
107
+ result[key] = merged;
108
+ }
109
+ // Otherwise keep existing value (don't overwrite)
110
+ } else {
111
+ // New key - add it
112
+ result[key] = value;
113
+ }
114
+ }
115
+
116
+ return result;
117
+ }
118
+
119
+ // ============================================================================
120
+ // YAML Parsing and Serialization
121
+ // ============================================================================
122
+
123
+ /**
124
+ * Simple YAML parser for basic structures (objects, arrays, strings, numbers, booleans, null)
125
+ * Handles indentation-based structure and preserves comments
126
+ * @param {string} content - YAML string content
127
+ * @returns {object} Parsed YAML with { data, comments, raw }
128
+ */
129
+ export function parseYAML(content) {
130
+ if (!content || typeof content !== 'string') {
131
+ return { data: {}, comments: {}, raw: content || '' };
132
+ }
133
+
134
+ try {
135
+ const lines = content.split('\n');
136
+ return { data: parseYAMLLines(lines, 0).data, comments: {}, raw: content };
137
+ } catch (error) {
138
+ return { data: {}, comments: {}, raw: content, parseError: error.message };
139
+ }
140
+ }
141
+
142
+ /**
143
+ * Recursively parse YAML lines starting at a given index and indentation level
144
+ */
145
+ function parseYAMLLines(lines, startIdx = 0, expectedIndent = 0, parentIsArray = false) {
146
+ const result = {};
147
+ const items = [];
148
+ let i = startIdx;
149
+ let isArray = parentIsArray;
150
+ let currentArrayItem = null;
151
+
152
+ while (i < lines.length) {
153
+ const line = lines[i];
154
+ const trimmed = line.trim();
155
+
156
+ // Skip empty lines and comments
157
+ if (!trimmed || trimmed.startsWith('#')) {
158
+ i++;
159
+ continue;
160
+ }
161
+
162
+ const indent = line.length - line.trimStart().length;
163
+
164
+ // If indent is less than expected, we're done with this level
165
+ if (indent < expectedIndent) {
166
+ break;
167
+ }
168
+
169
+ // If indent is greater than expected, skip until we find matching indent
170
+ if (indent > expectedIndent) {
171
+ i++;
172
+ continue;
173
+ }
174
+
175
+ // Handle array items
176
+ if (trimmed.startsWith('- ')) {
177
+ isArray = true;
178
+ const itemValue = trimmed.substring(2).trim();
179
+
180
+ // Check if next line is more indented (nested object/array)
181
+ if (i + 1 < lines.length) {
182
+ const nextLine = lines[i + 1];
183
+ const nextTrimmed = nextLine.trim();
184
+ const nextIndent = nextLine.length - nextLine.trimStart().length;
185
+
186
+ if (
187
+ nextIndent > indent &&
188
+ !nextTrimmed.startsWith('#') &&
189
+ nextTrimmed &&
190
+ nextTrimmed.includes(':') &&
191
+ !nextTrimmed.startsWith('-')
192
+ ) {
193
+ // Nested object in array
194
+ const nested = parseYAMLLines(lines, i + 1, nextIndent, false);
195
+ // If there was a value after the dash, add it as a property
196
+ if (itemValue) {
197
+ const [k, ...vParts] = itemValue.split(':');
198
+ nested.data[k.trim()] = parseYAMLValue(vParts.join(':').trim());
199
+ }
200
+ items.push(nested.data);
201
+ currentArrayItem = nested.data;
202
+ i = nested.i;
203
+ continue;
204
+ }
205
+ }
206
+
207
+ // Simple array item
208
+ if (itemValue) {
209
+ items.push(parseYAMLValue(itemValue));
210
+ currentArrayItem = null;
211
+ }
212
+ i++;
213
+ continue;
214
+ }
215
+
216
+ // Handle key: value pairs
217
+ if (trimmed.includes(':')) {
218
+ const colonIdx = trimmed.indexOf(':');
219
+ const key = trimmed.substring(0, colonIdx).trim();
220
+ const valueStr = trimmed.substring(colonIdx + 1).trim();
221
+
222
+ // If we're in an array item (next indent is array), add to current item
223
+ if (currentArrayItem && typeof currentArrayItem === 'object' && !Array.isArray(currentArrayItem)) {
224
+ if (!valueStr) {
225
+ // Nested content follows
226
+ if (i + 1 < lines.length) {
227
+ const nextLine = lines[i + 1];
228
+ const nextTrimmed = nextLine.trim();
229
+ const nextIndent = nextLine.length - nextLine.trimStart().length;
230
+
231
+ if (nextIndent > indent && !nextTrimmed.startsWith('#') && nextTrimmed) {
232
+ const nested = parseYAMLLines(lines, i + 1, nextIndent, nextTrimmed.startsWith('-'));
233
+ currentArrayItem[key] = nested.data;
234
+ i = nested.i;
235
+ continue;
236
+ }
237
+ }
238
+ currentArrayItem[key] = null;
239
+ } else {
240
+ currentArrayItem[key] = parseYAMLValue(valueStr);
241
+ }
242
+ i++;
243
+ continue;
244
+ }
245
+
246
+ // Normal object key
247
+ if (!valueStr) {
248
+ // Nested content follows
249
+ if (i + 1 < lines.length) {
250
+ const nextLine = lines[i + 1];
251
+ const nextTrimmed = nextLine.trim();
252
+ const nextIndent = nextLine.length - nextLine.trimStart().length;
253
+
254
+ if (
255
+ nextIndent > indent &&
256
+ !nextTrimmed.startsWith('#') &&
257
+ nextTrimmed
258
+ ) {
259
+ // This is nested
260
+ const nested = parseYAMLLines(lines, i + 1, nextIndent, nextTrimmed.startsWith('-'));
261
+ result[key] = nested.data;
262
+ i = nested.i;
263
+ continue;
264
+ }
265
+ }
266
+ result[key] = null;
267
+ } else {
268
+ result[key] = parseYAMLValue(valueStr);
269
+ }
270
+
271
+ i++;
272
+ continue;
273
+ }
274
+
275
+ i++;
276
+ }
277
+
278
+ return {
279
+ data: isArray && items.length > 0 ? items : result,
280
+ i,
281
+ };
282
+ }
283
+
284
+ /**
285
+ * Parse a YAML array value
286
+ */
287
+ function parseYAMLArray(str) {
288
+ if (!str.startsWith('[') || !str.endsWith(']')) {
289
+ return [];
290
+ }
291
+ const content = str.slice(1, -1).trim();
292
+ if (!content) return [];
293
+ return content.split(',').map((item) => parseYAMLValue(item.trim()));
294
+ }
295
+
296
+ /**
297
+ * Parse a YAML object value
298
+ */
299
+ function parseYAMLObject(str) {
300
+ if (!str.startsWith('{') || !str.endsWith('}')) {
301
+ return {};
302
+ }
303
+ const content = str.slice(1, -1).trim();
304
+ if (!content) return {};
305
+
306
+ const obj = {};
307
+ const pairs = content.split(',');
308
+ for (const pair of pairs) {
309
+ const [key, ...valueParts] = pair.split(':');
310
+ if (key) {
311
+ obj[key.trim()] = parseYAMLValue(valueParts.join(':').trim());
312
+ }
313
+ }
314
+ return obj;
315
+ }
316
+
317
+ /**
318
+ * Parse a single YAML value (string, number, boolean, null)
319
+ */
320
+ function parseYAMLValue(str) {
321
+ if (!str) return null;
322
+ str = str.trim();
323
+
324
+ if (str === 'null' || str === '~') return null;
325
+ if (str === 'true') return true;
326
+ if (str === 'false') return false;
327
+ if (str === '[]') return [];
328
+ if (str === '{}') return {};
329
+
330
+ if (str.startsWith('"') && str.endsWith('"')) {
331
+ return str.slice(1, -1);
332
+ }
333
+ if (str.startsWith("'") && str.endsWith("'")) {
334
+ return str.slice(1, -1);
335
+ }
336
+
337
+ // Check if it's a number (but not a version string like 1.0)
338
+ // Version strings contain dots - keep as string
339
+ if (str.includes('.') && str.split('.').length === 2 && str.split('.').every(part => /^\d+$/.test(part))) {
340
+ // This looks like a version string (e.g., 1.0, 2.1.5)
341
+ return str;
342
+ }
343
+
344
+ const num = Number(str);
345
+ if (!isNaN(num) && str !== '' && !str.includes('.')) return num;
346
+
347
+ return str;
348
+ }
349
+
350
+ /**
351
+ * Validate YAML syntax by attempting to parse it
352
+ * @param {string} content - YAML content to validate
353
+ * @returns {object} { valid: boolean, errors: string[] }
354
+ */
355
+ export function validateYAML(content) {
356
+ if (!content || typeof content !== 'string') {
357
+ return { valid: true, errors: [] };
358
+ }
359
+
360
+ try {
361
+ parseYAML(content);
362
+ return { valid: true, errors: [] };
363
+ } catch (error) {
364
+ return { valid: false, errors: [error.message] };
365
+ }
366
+ }
367
+
368
+ /**
369
+ * Serialize JavaScript object back to YAML string
370
+ * @param {object} obj - Object to serialize
371
+ * @param {number} indent - Starting indentation level
372
+ * @returns {string} YAML string
373
+ */
374
+ export function serializeYAML(obj, indent = 0) {
375
+ if (obj === null || obj === undefined) return '';
376
+
377
+ const lines = [];
378
+ const indentStr = ' '.repeat(indent);
379
+ const nextIndentStr = ' '.repeat(indent + 2);
380
+
381
+ if (Array.isArray(obj)) {
382
+ for (const item of obj) {
383
+ if (
384
+ typeof item === 'object' &&
385
+ item !== null &&
386
+ !Array.isArray(item)
387
+ ) {
388
+ // For objects in arrays, serialize the first property inline if simple
389
+ const keys = Object.keys(item);
390
+ if (keys.length === 1) {
391
+ const key = keys[0];
392
+ const value = item[key];
393
+ const serialized = serializeYAMLValue(value);
394
+ lines.push(indentStr + '- ' + key + ': ' + serialized);
395
+ } else {
396
+ // Multiple properties - inline first, rest below
397
+ const serialized = serializeYAML(item, indent + 2).trim();
398
+ lines.push(indentStr + '- ' + serialized);
399
+ }
400
+ } else if (Array.isArray(item)) {
401
+ lines.push(
402
+ indentStr + '-\n' + serializeYAML(item, indent + 2)
403
+ );
404
+ } else {
405
+ const value = serializeYAMLValue(item);
406
+ lines.push(indentStr + '- ' + value);
407
+ }
408
+ }
409
+ } else if (typeof obj === 'object') {
410
+ // Sort keys to maintain consistent ordering
411
+ const keys = Object.keys(obj).sort();
412
+ for (const key of keys) {
413
+ const value = obj[key];
414
+ if (value === null || value === undefined) {
415
+ lines.push(nextIndentStr + key + ': null');
416
+ } else if (Array.isArray(value)) {
417
+ lines.push(nextIndentStr + key + ':');
418
+ for (const item of value) {
419
+ if (typeof item === 'object' && item !== null) {
420
+ const itemSerialized = serializeYAML(item, indent + 4).trim();
421
+ lines.push(nextIndentStr + ' - ' + itemSerialized);
422
+ } else {
423
+ lines.push(
424
+ nextIndentStr +
425
+ ' - ' +
426
+ serializeYAMLValue(item)
427
+ );
428
+ }
429
+ }
430
+ } else if (
431
+ typeof value === 'object' &&
432
+ !Array.isArray(value)
433
+ ) {
434
+ lines.push(nextIndentStr + key + ':');
435
+ lines.push(serializeYAML(value, indent + 4));
436
+ } else {
437
+ const serialized = serializeYAMLValue(value);
438
+ lines.push(nextIndentStr + key + ': ' + serialized);
439
+ }
440
+ }
441
+ }
442
+
443
+ return lines.join('\n');
444
+ }
445
+
446
+ /**
447
+ * Serialize a single value for YAML
448
+ */
449
+ function serializeYAMLValue(value) {
450
+ if (value === null || value === undefined) return 'null';
451
+ if (typeof value === 'boolean') return value ? 'true' : 'false';
452
+ if (typeof value === 'number') return String(value);
453
+ if (typeof value === 'string') {
454
+ if (value.includes(':') || value.includes('#') || value.includes('"')) {
455
+ return '"' + value.replace(/"/g, '\\"') + '"';
456
+ }
457
+ return value;
458
+ }
459
+ return String(value);
460
+ }
461
+
462
+ // ============================================================================
463
+ // MergeChangelog - Tracks what changed during merge
464
+ // ============================================================================
465
+
466
+ class MergeChangelog {
467
+ constructor() {
468
+ this.added = [];
469
+ this.deduplicated = [];
470
+ this.preserved = [];
471
+ this.merged = [];
472
+ this.errors = [];
473
+ }
474
+
475
+ recordAdded(section, items) {
476
+ this.added.push({
477
+ section,
478
+ items: items.length > 0 ? items : null,
479
+ });
480
+ }
481
+
482
+ recordDeduplicated(section, count) {
483
+ if (count > 0) {
484
+ this.deduplicated.push({ section, count });
485
+ }
486
+ }
487
+
488
+ recordPreserved(path) {
489
+ this.preserved.push(path);
490
+ }
491
+
492
+ recordMerged(section, keys) {
493
+ this.merged.push({ section, keys });
494
+ }
495
+
496
+ recordError(error) {
497
+ this.errors.push(error);
498
+ }
499
+
500
+ toJSON() {
501
+ return {
502
+ added: this.added,
503
+ deduplicated: this.deduplicated,
504
+ preserved: this.preserved,
505
+ merged: this.merged,
506
+ };
507
+ }
508
+ }
509
+
510
+ // ============================================================================
511
+ // MergeResult - Represents the outcome of a merge operation
512
+ // ============================================================================
513
+
514
+ class MergeResult {
515
+ constructor() {
516
+ this.success = true;
517
+ this.data = {};
518
+ this.errors = [];
519
+ this.warnings = [];
520
+ this.changelog = new MergeChangelog();
521
+ }
522
+
523
+ /**
524
+ * Convert merged data back to YAML string
525
+ */
526
+ toYAML() {
527
+ try {
528
+ if (!this.data || typeof this.data !== 'object') {
529
+ return '';
530
+ }
531
+
532
+ const lines = [];
533
+ for (const [key, value] of Object.entries(this.data)) {
534
+ if (value === null || value === undefined) {
535
+ lines.push(key + ': null');
536
+ } else if (Array.isArray(value)) {
537
+ lines.push(key + ':');
538
+ for (const item of value) {
539
+ if (typeof item === 'object' && item !== null) {
540
+ const serialized = serializeYAML(item, 2).trim();
541
+ lines.push(' - ' + serialized);
542
+ } else {
543
+ const serialized = serializeYAMLValue(item);
544
+ lines.push(' - ' + serialized);
545
+ }
546
+ }
547
+ } else if (typeof value === 'object') {
548
+ lines.push(key + ':');
549
+ const serialized = serializeYAML(value, 2);
550
+ lines.push(serialized);
551
+ } else {
552
+ const serialized = serializeYAMLValue(value);
553
+ lines.push(key + ': ' + serialized);
554
+ }
555
+ }
556
+
557
+ return lines.join('\n');
558
+ } catch (error) {
559
+ this.errors.push('Serialization error: ' + error.message);
560
+ this.success = false;
561
+ return '';
562
+ }
563
+ }
564
+
565
+ /**
566
+ * Validate that merged result is valid YAML
567
+ */
568
+ validate() {
569
+ const errors = [];
570
+
571
+ if (!this.data || typeof this.data !== 'object') {
572
+ errors.push('Merged data is not an object');
573
+ }
574
+
575
+ // Check for valid YAML serialization
576
+ try {
577
+ this.toYAML();
578
+ } catch (error) {
579
+ errors.push('Invalid YAML output: ' + error.message);
580
+ }
581
+
582
+ return errors;
583
+ }
584
+ }
585
+
586
+ // ============================================================================
587
+ // Main Merge Function
588
+ // ============================================================================
589
+
590
+ /**
591
+ * Merges two YAML configuration strings intelligently
592
+ * Arrays are deduplicated using deep equality
593
+ * Objects are recursively merged with new keys added and existing keys preserved
594
+ *
595
+ * @param {string} existing - Current configuration (file content or empty string)
596
+ * @param {string} update - New configuration to merge
597
+ * @returns {MergeResult} Result object with merged data, success status, errors, warnings, and changelog
598
+ */
599
+ export function mergeYAML(existing, update) {
600
+ const result = new MergeResult();
601
+
602
+ try {
603
+ // Validate inputs
604
+ if (typeof existing !== 'string') {
605
+ result.errors.push('existing parameter must be a string');
606
+ result.success = false;
607
+ return result;
608
+ }
609
+ if (typeof update !== 'string') {
610
+ result.errors.push('update parameter must be a string');
611
+ result.success = false;
612
+ return result;
613
+ }
614
+
615
+ // Parse both YAML strings
616
+ const existingParsed = parseYAML(existing);
617
+ const updateParsed = parseYAML(update);
618
+
619
+ if (existingParsed.parseError) {
620
+ result.errors.push('Existing YAML parse error: ' + existingParsed.parseError);
621
+ result.success = false;
622
+ return result;
623
+ }
624
+
625
+ if (updateParsed.parseError) {
626
+ result.errors.push('Update YAML parse error: ' + updateParsed.parseError);
627
+ result.success = false;
628
+ return result;
629
+ }
630
+
631
+ const existingData = existingParsed.data || {};
632
+ const updateData = updateParsed.data || {};
633
+
634
+ // Merge the data
635
+ result.data = mergeObjectsWithChangelog(
636
+ existingData,
637
+ updateData,
638
+ result.changelog
639
+ );
640
+
641
+ // Validate output
642
+ const validationErrors = result.validate();
643
+ if (validationErrors.length > 0) {
644
+ result.errors.push(...validationErrors);
645
+ result.success = false;
646
+ }
647
+
648
+ return result;
649
+ } catch (error) {
650
+ result.errors.push('Merge error: ' + error.message);
651
+ result.success = false;
652
+ return result;
653
+ }
654
+ }
655
+
656
+ /**
657
+ * Merge objects while recording changelog
658
+ */
659
+ function mergeObjectsWithChangelog(existing, update, changelog) {
660
+ const result = { ...existing };
661
+
662
+ for (const [key, updateValue] of Object.entries(update)) {
663
+ if (key in result) {
664
+ const existingValue = result[key];
665
+
666
+ if (Array.isArray(existingValue) && Array.isArray(updateValue)) {
667
+ const { result: merged, deduped } = deduplicateArrays(
668
+ existingValue,
669
+ updateValue
670
+ );
671
+ result[key] = merged;
672
+ if (deduped > 0) {
673
+ changelog.recordDeduplicated(key, deduped);
674
+ }
675
+ } else if (
676
+ typeof existingValue === 'object' &&
677
+ existingValue !== null &&
678
+ !Array.isArray(existingValue) &&
679
+ typeof updateValue === 'object' &&
680
+ updateValue !== null &&
681
+ !Array.isArray(updateValue)
682
+ ) {
683
+ result[key] = mergeObjectsWithChangelog(
684
+ existingValue,
685
+ updateValue,
686
+ changelog
687
+ );
688
+ } else if (existingValue !== updateValue) {
689
+ // Values differ - update to new value (tool updates should be applied)
690
+ result[key] = updateValue;
691
+ }
692
+ // If values are equal, no change needed
693
+ } else {
694
+ result[key] = updateValue;
695
+ if (Array.isArray(updateValue)) {
696
+ changelog.recordAdded(key, updateValue);
697
+ } else if (typeof updateValue === 'object') {
698
+ changelog.recordAdded(key, []);
699
+ }
700
+ }
701
+ }
702
+
703
+ return result;
704
+ }
705
+
706
+ // ============================================================================
707
+ // Exports (including classes for advanced usage)
708
+ // ============================================================================
709
+
710
+ export { MergeResult, MergeChangelog };