@dboio/cli 0.4.2 → 0.5.1

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.
@@ -2,24 +2,28 @@ import { Command } from 'commander';
2
2
  import { readFile, writeFile, mkdir, access } from 'fs/promises';
3
3
  import { join, basename, extname } from 'path';
4
4
  import { DboClient } from '../lib/client.js';
5
- import { loadConfig, updateConfigWithApp, loadClonePlacement, saveClonePlacement, ensureGitignore } from '../lib/config.js';
6
- import { buildBinHierarchy, resolveBinPath, createDirectories, saveStructureFile, getBinName, BINS_DIR, DEFAULT_PROJECT_DIRS } from '../lib/structure.js';
5
+ import { loadConfig, updateConfigWithApp, loadClonePlacement, saveClonePlacement, ensureGitignore, saveEntityDirPreference, loadEntityDirPreference } from '../lib/config.js';
6
+ import { buildBinHierarchy, resolveBinPath, createDirectories, saveStructureFile, getBinName, findBinByPath, BINS_DIR, DEFAULT_PROJECT_DIRS, ENTITY_DIR_MAP } from '../lib/structure.js';
7
7
  import { log } from '../lib/logger.js';
8
8
  import { setFileTimestamps } from '../lib/timestamps.js';
9
+ import { getLocalSyncTime, isServerNewer, hasLocalModifications, promptChangeDetection, inlineDiffAndMerge } from '../lib/diff.js';
9
10
 
10
11
  /**
11
12
  * Resolve a column value that may be base64-encoded.
12
13
  * Server returns large text as: { bytes: N, value: "base64string", encoding: "base64" }
13
14
  */
14
- function resolveContentValue(value) {
15
+ export function resolveContentValue(value) {
15
16
  if (value && typeof value === 'object' && !Array.isArray(value)
16
- && value.encoding === 'base64' && typeof value.value === 'string') {
17
- return Buffer.from(value.value, 'base64').toString('utf8');
17
+ && value.encoding === 'base64') {
18
+ // Base64 object: decode if value is a string, otherwise empty string
19
+ return typeof value.value === 'string'
20
+ ? Buffer.from(value.value, 'base64').toString('utf8')
21
+ : '';
18
22
  }
19
23
  return value !== null && value !== undefined ? String(value) : null;
20
24
  }
21
25
 
22
- function sanitizeFilename(name) {
26
+ export function sanitizeFilename(name) {
23
27
  return name.replace(/[/\\?%*:|"<>]/g, '-').replace(/\s+/g, '-').substring(0, 200);
24
28
  }
25
29
 
@@ -27,6 +31,45 @@ async function fileExists(path) {
27
31
  try { await access(path); return true; } catch { return false; }
28
32
  }
29
33
 
34
+ /**
35
+ * Resolve a content Path to a directory under Bins/.
36
+ *
37
+ * When a record has BinID=null but a Path like "tpl/ticket-menu-list.html"
38
+ * or "app/nima-test-test.txt", we need to place it inside Bins/:
39
+ *
40
+ * 1. Extract the directory portion of the Path (e.g. "tpl", "app")
41
+ * 2. Try to match it to an existing bin in structure.json
42
+ * - If found → use the resolved bin path (e.g. "Bins/app")
43
+ * 3. If not found → place under Bins/<dir> (e.g. "Bins/tpl")
44
+ *
45
+ * This ensures content files always land inside Bins/, never at the project root.
46
+ */
47
+ function resolvePathToBinsDir(pathValue, structure) {
48
+ const cleaned = pathValue.replace(/^\/+|\/+$/g, '');
49
+ if (!cleaned) return BINS_DIR;
50
+
51
+ // Extract the directory portion (strip filename if path contains one)
52
+ const pathExt = extname(cleaned);
53
+ let dirPart;
54
+ if (pathExt) {
55
+ const lastSlash = cleaned.lastIndexOf('/');
56
+ dirPart = lastSlash >= 0 ? cleaned.substring(0, lastSlash) : null;
57
+ } else {
58
+ dirPart = cleaned;
59
+ }
60
+
61
+ if (!dirPart) return BINS_DIR;
62
+
63
+ // Try to match the directory path to an existing bin in structure.json
64
+ const bin = findBinByPath(dirPart, structure);
65
+ if (bin) {
66
+ return resolveBinPath(bin.binId, structure);
67
+ }
68
+
69
+ // No matching bin — place under Bins/<dir>
70
+ return `${BINS_DIR}/${dirPart}`;
71
+ }
72
+
30
73
  export const cloneCommand = new Command('clone')
31
74
  .description('Clone an app from DBO.io to a local project structure')
32
75
  .argument('[source]', 'Local JSON file path (optional)')
@@ -163,9 +206,18 @@ export async function performClone(source, options = {}) {
163
206
  if (['bin', 'content', 'output', 'media'].includes(entityName)) continue;
164
207
  if (!Array.isArray(entries)) continue;
165
208
 
166
- const refs = await processGenericEntries(entityName, entries, structure, options, placementPrefs.contentPlacement, serverTz);
167
- if (refs.length > 0) {
168
- otherRefs[entityName] = refs;
209
+ if (ENTITY_DIR_MAP[entityName]) {
210
+ // Entity types with project directories — process into their directory
211
+ const refs = await processEntityDirEntries(entityName, entries, options, serverTz);
212
+ if (refs.length > 0) {
213
+ otherRefs[entityName] = refs;
214
+ }
215
+ } else {
216
+ // Other entity types — process into Bins/ (requires BinID)
217
+ const refs = await processGenericEntries(entityName, entries, structure, options, placementPrefs.contentPlacement, serverTz);
218
+ if (refs.length > 0) {
219
+ otherRefs[entityName] = refs;
220
+ }
169
221
  }
170
222
  }
171
223
 
@@ -337,11 +389,12 @@ async function processContentEntries(contents, structure, options, contentPlacem
337
389
  const placementPreference = {
338
390
  value: (contentPlacement === 'path' || contentPlacement === 'bin') ? contentPlacement : null,
339
391
  };
392
+ const bulkAction = { value: null }; // Shared across records for overwrite_all / skip_all
340
393
 
341
394
  log.info(`Processing ${contents.length} content record(s)...`);
342
395
 
343
396
  for (const record of contents) {
344
- const ref = await processRecord('content', record, structure, options, usedNames, placementPreference, serverTz);
397
+ const ref = await processRecord('content', record, structure, options, usedNames, placementPreference, serverTz, bulkAction);
345
398
  if (ref) refs.push(ref);
346
399
  }
347
400
 
@@ -360,12 +413,13 @@ async function processGenericEntries(entityName, entries, structure, options, co
360
413
  const placementPreference = {
361
414
  value: (contentPlacement === 'path' || contentPlacement === 'bin') ? contentPlacement : null,
362
415
  };
416
+ const bulkAction = { value: null };
363
417
  let processed = 0;
364
418
 
365
419
  for (const record of entries) {
366
420
  if (!record.BinID) continue; // Skip entries without BinID
367
421
 
368
- const ref = await processRecord(entityName, record, structure, options, usedNames, placementPreference, serverTz);
422
+ const ref = await processRecord(entityName, record, structure, options, usedNames, placementPreference, serverTz, bulkAction);
369
423
  if (ref) {
370
424
  refs.push(ref);
371
425
  processed++;
@@ -379,6 +433,270 @@ async function processGenericEntries(entityName, entries, structure, options, co
379
433
  return refs;
380
434
  }
381
435
 
436
+ /**
437
+ * Process entity-dir entries: entities that map to DEFAULT_PROJECT_DIRS.
438
+ * These don't require a BinID — they go directly into their project directory
439
+ * (e.g., Extensions/, Data Sources/, etc.)
440
+ *
441
+ * Returns array of { uid, metaPath } for app.json reference replacement.
442
+ */
443
+ async function processEntityDirEntries(entityName, entries, options, serverTz) {
444
+ if (!entries || entries.length === 0) return [];
445
+
446
+ const dirName = ENTITY_DIR_MAP[entityName];
447
+ if (!dirName) return [];
448
+
449
+ await mkdir(dirName, { recursive: true });
450
+
451
+ const refs = [];
452
+ const usedNames = new Map();
453
+ const bulkAction = { value: null };
454
+ const config = await loadConfig();
455
+
456
+ // Determine filename column: saved preference, or prompt, or default
457
+ let filenameCol = null;
458
+
459
+ if (options.yes) {
460
+ // Non-interactive: use Name, fallback UID
461
+ filenameCol = null; // will resolve per-record below
462
+ } else {
463
+ // Check saved preference
464
+ const savedCol = await loadEntityDirPreference(entityName);
465
+ if (savedCol) {
466
+ filenameCol = savedCol;
467
+ log.dim(` Using saved filename column "${filenameCol}" for ${entityName}`);
468
+ } else {
469
+ // Prompt user to pick a filename column from available columns
470
+ const sampleRecord = entries[0];
471
+ const columns = Object.keys(sampleRecord).filter(k => k !== 'children' && !k.startsWith('_'));
472
+
473
+ if (columns.length > 0) {
474
+ const inquirer = (await import('inquirer')).default;
475
+
476
+ // Find best default
477
+ const defaultCol = columns.includes('Name') ? 'Name'
478
+ : columns.includes('name') ? 'name'
479
+ : columns.includes('UID') ? 'UID'
480
+ : columns[0];
481
+
482
+ const { col } = await inquirer.prompt([{
483
+ type: 'list',
484
+ name: 'col',
485
+ message: `Which column should be used as the filename for ${entityName} records?`,
486
+ choices: columns,
487
+ default: defaultCol,
488
+ }]);
489
+ filenameCol = col;
490
+
491
+ // Save preference for future clones
492
+ await saveEntityDirPreference(entityName, filenameCol);
493
+ log.dim(` Saved filename column preference for ${entityName}`);
494
+ }
495
+ }
496
+ }
497
+
498
+ // Detect base64 content columns and prompt per-column for extraction
499
+ let contentColsToExtract = [];
500
+ if (!options.yes) {
501
+ // Collect base64 columns with a content snippet from the first record that has data
502
+ const base64Cols = []; // { col, snippet }
503
+ for (const record of entries) {
504
+ for (const [key, value] of Object.entries(record)) {
505
+ if (key === 'children' || key.startsWith('_')) continue;
506
+ if (value && typeof value === 'object' && !Array.isArray(value)
507
+ && value.encoding === 'base64' && value.value !== null) {
508
+ if (!base64Cols.find(c => c.col === key)) {
509
+ // Decode a snippet for preview
510
+ let snippet = '';
511
+ try {
512
+ const decoded = Buffer.from(value.value, 'base64').toString('utf8');
513
+ snippet = decoded.substring(0, 80).replace(/\n/g, ' ').replace(/\s+/g, ' ').trim();
514
+ if (decoded.length > 80) snippet += '...';
515
+ } catch { /* ignore decode errors */ }
516
+ base64Cols.push({ col: key, snippet });
517
+ }
518
+ }
519
+ }
520
+ }
521
+
522
+ if (base64Cols.length > 0) {
523
+ const inquirer = (await import('inquirer')).default;
524
+
525
+ // Prompt per column: show snippet and ask extract yes/no + extension
526
+ for (const { col, snippet } of base64Cols) {
527
+ const preview = snippet ? ` (${snippet})` : '';
528
+ const guessed = guessExtensionForColumn(col);
529
+
530
+ const { extract } = await inquirer.prompt([{
531
+ type: 'confirm',
532
+ name: 'extract',
533
+ message: `Extract "${col}" as companion file?${preview}`,
534
+ default: true,
535
+ }]);
536
+
537
+ if (extract) {
538
+ const { ext } = await inquirer.prompt([{
539
+ type: 'input',
540
+ name: 'ext',
541
+ message: `File extension for "${col}":`,
542
+ default: guessed,
543
+ }]);
544
+ contentColsToExtract.push({ col, ext: ext.replace(/^\./, '') });
545
+ }
546
+ }
547
+ }
548
+ }
549
+
550
+ log.info(`Processing ${entries.length} ${entityName} record(s) → ${dirName}/`);
551
+
552
+ for (const record of entries) {
553
+ // Determine filename for this record
554
+ let name;
555
+ if (filenameCol && record[filenameCol] !== null && record[filenameCol] !== undefined) {
556
+ name = sanitizeFilename(String(record[filenameCol]));
557
+ } else if (record.Name) {
558
+ name = sanitizeFilename(String(record.Name));
559
+ } else {
560
+ name = sanitizeFilename(String(record.UID || 'untitled'));
561
+ }
562
+
563
+ // Deduplicate filenames
564
+ const nameKey = `${dirName}/${name}`;
565
+ const count = usedNames.get(nameKey) || 0;
566
+ usedNames.set(nameKey, count + 1);
567
+ const finalName = count > 0 ? `${name}-${count + 1}` : name;
568
+
569
+ const metaPath = join(dirName, `${finalName}.metadata.json`);
570
+
571
+ // Change detection for existing files
572
+ // Skip change detection when user has selected new content columns to extract —
573
+ // we need to re-process all records to create the companion files
574
+ const hasNewExtractions = contentColsToExtract.length > 0;
575
+ if (await fileExists(metaPath) && !options.yes && !hasNewExtractions) {
576
+ if (bulkAction.value === 'skip_all') {
577
+ log.dim(` Skipped ${finalName}`);
578
+ refs.push({ uid: record.UID, metaPath });
579
+ continue;
580
+ }
581
+
582
+ if (bulkAction.value !== 'overwrite_all') {
583
+ const localSyncTime = await getLocalSyncTime(metaPath);
584
+ const serverNewer = isServerNewer(localSyncTime, record._LastUpdated, config);
585
+
586
+ if (serverNewer) {
587
+ const action = await promptChangeDetection(finalName, record, config);
588
+
589
+ if (action === 'skip') {
590
+ log.dim(` Skipped ${finalName}`);
591
+ refs.push({ uid: record.UID, metaPath });
592
+ continue;
593
+ }
594
+ if (action === 'skip_all') {
595
+ bulkAction.value = 'skip_all';
596
+ log.dim(` Skipped ${finalName}`);
597
+ refs.push({ uid: record.UID, metaPath });
598
+ continue;
599
+ }
600
+ if (action === 'overwrite_all') {
601
+ bulkAction.value = 'overwrite_all';
602
+ }
603
+ if (action === 'compare') {
604
+ await inlineDiffAndMerge(record, metaPath, config);
605
+ refs.push({ uid: record.UID, metaPath });
606
+ continue;
607
+ }
608
+ } else {
609
+ const locallyModified = await hasLocalModifications(metaPath, config);
610
+ if (locallyModified) {
611
+ const action = await promptChangeDetection(finalName, record, config, { localIsNewer: true });
612
+
613
+ if (action === 'skip') {
614
+ log.dim(` Kept local: ${finalName}`);
615
+ refs.push({ uid: record.UID, metaPath });
616
+ continue;
617
+ }
618
+ if (action === 'skip_all') {
619
+ bulkAction.value = 'skip_all';
620
+ log.dim(` Kept local: ${finalName}`);
621
+ refs.push({ uid: record.UID, metaPath });
622
+ continue;
623
+ }
624
+ if (action === 'overwrite_all') {
625
+ bulkAction.value = 'overwrite_all';
626
+ }
627
+ if (action === 'compare') {
628
+ await inlineDiffAndMerge(record, metaPath, config, { localIsNewer: true });
629
+ refs.push({ uid: record.UID, metaPath });
630
+ continue;
631
+ }
632
+ } else {
633
+ log.dim(` Up to date: ${finalName}`);
634
+ refs.push({ uid: record.UID, metaPath });
635
+ continue;
636
+ }
637
+ }
638
+ }
639
+ }
640
+
641
+ // Build metadata
642
+ const meta = {};
643
+ const extractedContentCols = [];
644
+
645
+ for (const [key, value] of Object.entries(record)) {
646
+ if (key === 'children') continue;
647
+
648
+ // Check if this column should be extracted as a companion file
649
+ const extractInfo = contentColsToExtract.find(c => c.col === key);
650
+ if (extractInfo && value && typeof value === 'object' && value.encoding === 'base64' && value.value !== null) {
651
+ const decoded = resolveContentValue(value);
652
+ if (decoded) {
653
+ const colFileName = `${finalName}.${key}.${extractInfo.ext}`;
654
+ const colFilePath = join(dirName, colFileName);
655
+ await writeFile(colFilePath, decoded);
656
+ meta[key] = `@${colFileName}`;
657
+ extractedContentCols.push(key);
658
+
659
+ // Set timestamps on companion file
660
+ if (serverTz && (record._CreatedOn || record._LastUpdated)) {
661
+ try {
662
+ await setFileTimestamps(colFilePath, record._CreatedOn, record._LastUpdated, serverTz);
663
+ } catch { /* non-critical */ }
664
+ }
665
+
666
+ log.dim(` → ${colFilePath}`);
667
+ continue;
668
+ }
669
+ }
670
+
671
+ // Other base64 columns not selected for extraction — decode inline
672
+ if (value && typeof value === 'object' && !Array.isArray(value) && value.encoding === 'base64') {
673
+ meta[key] = resolveContentValue(value);
674
+ } else {
675
+ meta[key] = value;
676
+ }
677
+ }
678
+
679
+ meta._entity = entityName;
680
+ if (extractedContentCols.length > 0) {
681
+ meta._contentColumns = extractedContentCols;
682
+ }
683
+
684
+ await writeFile(metaPath, JSON.stringify(meta, null, 2) + '\n');
685
+ log.success(`Saved ${metaPath}`);
686
+
687
+ // Set file timestamps from server dates
688
+ if (serverTz && (record._CreatedOn || record._LastUpdated)) {
689
+ try {
690
+ await setFileTimestamps(metaPath, record._CreatedOn, record._LastUpdated, serverTz);
691
+ } catch { /* non-critical */ }
692
+ }
693
+
694
+ refs.push({ uid: record.UID, metaPath });
695
+ }
696
+
697
+ return refs;
698
+ }
699
+
382
700
  /**
383
701
  * Process media entries: download binary files from server + create metadata.
384
702
  * Media uses Filename (not Name) and files are fetched via /api/media/{uid}.
@@ -424,6 +742,7 @@ async function processMediaEntries(mediaRecords, structure, options, config, app
424
742
  const placementPreference = {
425
743
  value: mediaPlacement === 'fullpath' ? 'path' : mediaPlacement === 'bin' ? 'bin' : null,
426
744
  };
745
+ const mediaBulkAction = { value: null };
427
746
  let downloaded = 0;
428
747
  let failed = 0;
429
748
 
@@ -503,6 +822,76 @@ async function processMediaEntries(mediaRecords, structure, options, config, app
503
822
  const filePath = join(dir, finalFilename);
504
823
  const metaPath = join(dir, `${finalFilename}.metadata.json`);
505
824
 
825
+ // Change detection for existing media files
826
+ if (await fileExists(metaPath) && !options.yes) {
827
+ if (mediaBulkAction.value === 'skip_all') {
828
+ log.dim(` Skipped ${finalFilename}`);
829
+ refs.push({ uid: record.UID, metaPath });
830
+ continue;
831
+ }
832
+
833
+ if (mediaBulkAction.value !== 'overwrite_all') {
834
+ const localSyncTime = await getLocalSyncTime(metaPath);
835
+ const serverNewer = isServerNewer(localSyncTime, record._LastUpdated, config);
836
+
837
+ if (serverNewer) {
838
+ const action = await promptChangeDetection(dedupName, record, config);
839
+
840
+ if (action === 'skip') {
841
+ log.dim(` Skipped ${finalFilename}`);
842
+ refs.push({ uid: record.UID, metaPath });
843
+ continue;
844
+ }
845
+ if (action === 'skip_all') {
846
+ mediaBulkAction.value = 'skip_all';
847
+ log.dim(` Skipped ${finalFilename}`);
848
+ refs.push({ uid: record.UID, metaPath });
849
+ continue;
850
+ }
851
+ if (action === 'overwrite_all') {
852
+ mediaBulkAction.value = 'overwrite_all';
853
+ }
854
+ if (action === 'compare') {
855
+ // For binary media, show metadata diffs only
856
+ await inlineDiffAndMerge(record, metaPath, config);
857
+ refs.push({ uid: record.UID, metaPath });
858
+ continue;
859
+ }
860
+ } else {
861
+ // Server _LastUpdated hasn't changed — check for local modifications
862
+ const locallyModified = await hasLocalModifications(metaPath, config);
863
+ if (locallyModified) {
864
+ const action = await promptChangeDetection(dedupName, record, config, { localIsNewer: true });
865
+
866
+ if (action === 'skip') {
867
+ log.dim(` Kept local: ${finalFilename}`);
868
+ refs.push({ uid: record.UID, metaPath });
869
+ continue;
870
+ }
871
+ if (action === 'skip_all') {
872
+ mediaBulkAction.value = 'skip_all';
873
+ log.dim(` Kept local: ${finalFilename}`);
874
+ refs.push({ uid: record.UID, metaPath });
875
+ continue;
876
+ }
877
+ if (action === 'overwrite_all') {
878
+ mediaBulkAction.value = 'overwrite_all';
879
+ }
880
+ if (action === 'compare') {
881
+ await inlineDiffAndMerge(record, metaPath, config, { localIsNewer: true });
882
+ refs.push({ uid: record.UID, metaPath });
883
+ continue;
884
+ }
885
+ // 'overwrite' falls through to download
886
+ } else {
887
+ log.dim(` Up to date: ${finalFilename}`);
888
+ refs.push({ uid: record.UID, metaPath });
889
+ continue;
890
+ }
891
+ }
892
+ }
893
+ }
894
+
506
895
  // Download the file
507
896
  try {
508
897
  const buffer = await client.getBuffer(`/api/media/${record.UID}`);
@@ -551,7 +940,7 @@ async function processMediaEntries(mediaRecords, structure, options, config, app
551
940
  * Process a single record: determine directory, write content file + metadata.
552
941
  * Returns { uid, metaPath } or null.
553
942
  */
554
- async function processRecord(entityName, record, structure, options, usedNames, placementPreference, serverTz) {
943
+ async function processRecord(entityName, record, structure, options, usedNames, placementPreference, serverTz, bulkAction = { value: null }) {
555
944
  let name = sanitizeFilename(String(record.Name || record.UID || 'untitled'));
556
945
  const ext = (record.Extension || 'txt').toLowerCase();
557
946
 
@@ -603,7 +992,8 @@ async function processRecord(entityName, record, structure, options, usedNames,
603
992
  } else if (hasBinID) {
604
993
  dir = resolveBinPath(record.BinID, structure);
605
994
  } else if (hasPath) {
606
- dir = record.Path;
995
+ // BinID is null — resolve Path into a Bins/ location
996
+ dir = resolvePathToBinsDir(record.Path, structure);
607
997
  }
608
998
 
609
999
  // Clean up directory path
@@ -636,6 +1026,71 @@ async function processRecord(entityName, record, structure, options, usedNames,
636
1026
  const filePath = join(dir, fileName);
637
1027
  const metaPath = join(dir, `${finalName}.metadata.json`);
638
1028
 
1029
+ // Change detection: check if file already exists locally
1030
+ if (await fileExists(metaPath) && !options.yes) {
1031
+ if (bulkAction.value === 'skip_all') {
1032
+ log.dim(` Skipped ${finalName}.${ext}`);
1033
+ return { uid: record.UID, metaPath };
1034
+ }
1035
+
1036
+ if (bulkAction.value !== 'overwrite_all') {
1037
+ const localSyncTime = await getLocalSyncTime(metaPath);
1038
+ const config = await loadConfig();
1039
+ const serverNewer = isServerNewer(localSyncTime, record._LastUpdated, config);
1040
+
1041
+ if (serverNewer) {
1042
+ const action = await promptChangeDetection(finalName, record, config);
1043
+
1044
+ if (action === 'skip') {
1045
+ log.dim(` Skipped ${finalName}.${ext}`);
1046
+ return { uid: record.UID, metaPath };
1047
+ }
1048
+ if (action === 'skip_all') {
1049
+ bulkAction.value = 'skip_all';
1050
+ log.dim(` Skipped ${finalName}.${ext}`);
1051
+ return { uid: record.UID, metaPath };
1052
+ }
1053
+ if (action === 'overwrite_all') {
1054
+ bulkAction.value = 'overwrite_all';
1055
+ // Fall through to write
1056
+ }
1057
+ if (action === 'compare') {
1058
+ await inlineDiffAndMerge(record, metaPath, config);
1059
+ return { uid: record.UID, metaPath };
1060
+ }
1061
+ // 'overwrite' falls through to normal write
1062
+ } else {
1063
+ // Server _LastUpdated hasn't changed since last sync.
1064
+ // Check if local content files were modified (user edits).
1065
+ const locallyModified = await hasLocalModifications(metaPath, config);
1066
+ if (locallyModified) {
1067
+ const action = await promptChangeDetection(finalName, record, config, { localIsNewer: true });
1068
+
1069
+ if (action === 'skip') {
1070
+ log.dim(` Kept local: ${finalName}.${ext}`);
1071
+ return { uid: record.UID, metaPath };
1072
+ }
1073
+ if (action === 'skip_all') {
1074
+ bulkAction.value = 'skip_all';
1075
+ log.dim(` Kept local: ${finalName}.${ext}`);
1076
+ return { uid: record.UID, metaPath };
1077
+ }
1078
+ if (action === 'overwrite_all') {
1079
+ bulkAction.value = 'overwrite_all';
1080
+ }
1081
+ if (action === 'compare') {
1082
+ await inlineDiffAndMerge(record, metaPath, config, { localIsNewer: true });
1083
+ return { uid: record.UID, metaPath };
1084
+ }
1085
+ // 'overwrite' falls through to normal write
1086
+ } else {
1087
+ log.dim(` Up to date: ${finalName}.${ext}`);
1088
+ return { uid: record.UID, metaPath };
1089
+ }
1090
+ }
1091
+ }
1092
+ }
1093
+
639
1094
  if (hasContent) {
640
1095
  const decoded = resolveContentValue(contentValue);
641
1096
  if (decoded) {
@@ -694,7 +1149,7 @@ async function processRecord(entityName, record, structure, options, usedNames,
694
1149
  /**
695
1150
  * Guess file extension for a column name.
696
1151
  */
697
- function guessExtensionForColumn(columnName) {
1152
+ export function guessExtensionForColumn(columnName) {
698
1153
  const lower = columnName.toLowerCase();
699
1154
  if (lower.includes('css')) return 'css';
700
1155
  if (lower.includes('js') || lower.includes('script')) return 'js';