@dboio/cli 0.4.2 → 0.6.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,740 @@
1
+ import chalk from 'chalk';
2
+ import { readFile, writeFile, readdir, access, stat } from 'fs/promises';
3
+ import { join, dirname, basename, extname } from 'path';
4
+ import { parseServerDate, setFileTimestamps } from './timestamps.js';
5
+ import { loadConfig, loadUserInfo } from './config.js';
6
+ import { log } from './logger.js';
7
+
8
+ // ─── Content Value Resolution ───────────────────────────────────────────────
9
+
10
+ /**
11
+ * Resolve a column value that may be base64-encoded.
12
+ * Server returns large text as: { bytes: N, value: "base64string", encoding: "base64" }
13
+ */
14
+ export function resolveContentValue(value) {
15
+ if (value && typeof value === 'object' && !Array.isArray(value)
16
+ && value.encoding === 'base64') {
17
+ return typeof value.value === 'string'
18
+ ? Buffer.from(value.value, 'base64').toString('utf8')
19
+ : '';
20
+ }
21
+ return value !== null && value !== undefined ? String(value) : null;
22
+ }
23
+
24
+ // ─── File Utilities ─────────────────────────────────────────────────────────
25
+
26
+ async function fileExists(path) {
27
+ try { await access(path); return true; } catch { return false; }
28
+ }
29
+
30
+ /**
31
+ * Recursively find all .metadata.json files in a directory.
32
+ */
33
+ export async function findMetadataFiles(dir) {
34
+ const results = [];
35
+ const entries = await readdir(dir, { withFileTypes: true });
36
+
37
+ for (const entry of entries) {
38
+ const fullPath = join(dir, entry.name);
39
+ if (entry.isDirectory()) {
40
+ // Skip hidden dirs, node_modules, .dbo
41
+ if (entry.name.startsWith('.') || entry.name === 'node_modules') continue;
42
+ results.push(...await findMetadataFiles(fullPath));
43
+ } else if (entry.name.endsWith('.metadata.json')) {
44
+ results.push(fullPath);
45
+ }
46
+ }
47
+
48
+ return results;
49
+ }
50
+
51
+ /**
52
+ * Get the local sync time for a record — the mtime of the metadata.json file.
53
+ * This represents when the record was last synced from the server.
54
+ * We intentionally use only the metadata file's mtime, NOT content files,
55
+ * because local edits to content files would shift the sync point forward
56
+ * and cause isServerNewer() to miss real server changes.
57
+ */
58
+ export async function getLocalSyncTime(metaPath) {
59
+ try {
60
+ const metaStat = await stat(metaPath);
61
+ return metaStat.mtime;
62
+ } catch {
63
+ return null;
64
+ }
65
+ }
66
+
67
+ /**
68
+ * Check if any files (metadata, content, or media) have been locally modified
69
+ * since the last sync. Compares each file's mtime against the stored _LastUpdated
70
+ * date (the actual sync baseline set by setFileTimestamps during clone/pull).
71
+ *
72
+ * config.ServerTimezone is required to parse the stored _LastUpdated date.
73
+ * Returns true if any associated file has been modified locally.
74
+ */
75
+ export async function hasLocalModifications(metaPath, config = {}) {
76
+ try {
77
+ const meta = JSON.parse(await readFile(metaPath, 'utf8'));
78
+
79
+ // Parse the stored _LastUpdated as the sync baseline
80
+ const serverTz = config.ServerTimezone;
81
+ const syncDate = parseServerDate(meta._LastUpdated, serverTz);
82
+ if (!syncDate) return false;
83
+ const syncTime = syncDate.getTime();
84
+
85
+ // Check if metadata.json itself was edited since last sync
86
+ const metaStat = await stat(metaPath);
87
+ if (metaStat.mtime.getTime() > syncTime + 2000) {
88
+ return true;
89
+ }
90
+
91
+ // Check content files
92
+ const metaDir = dirname(metaPath);
93
+ const contentCols = meta._contentColumns || [];
94
+
95
+ for (const col of contentCols) {
96
+ const ref = meta[col];
97
+ if (ref && String(ref).startsWith('@')) {
98
+ const contentPath = join(metaDir, String(ref).substring(1));
99
+ try {
100
+ const contentStat = await stat(contentPath);
101
+ if (contentStat.mtime.getTime() > syncTime + 2000) {
102
+ return true;
103
+ }
104
+ } catch { /* missing file */ }
105
+ }
106
+ }
107
+
108
+ // Check media file reference
109
+ if (meta._mediaFile && String(meta._mediaFile).startsWith('@')) {
110
+ const mediaPath = join(metaDir, String(meta._mediaFile).substring(1));
111
+ try {
112
+ const mediaStat = await stat(mediaPath);
113
+ if (mediaStat.mtime.getTime() > syncTime + 2000) {
114
+ return true;
115
+ }
116
+ } catch { /* missing file */ }
117
+ }
118
+
119
+ return false;
120
+ } catch {
121
+ return false;
122
+ }
123
+ }
124
+
125
+ /**
126
+ * Compare local sync time against server's _LastUpdated.
127
+ * Returns true if the server record is newer than local files.
128
+ */
129
+ export function isServerNewer(localSyncTime, serverLastUpdated, config) {
130
+ if (!serverLastUpdated) return false;
131
+ if (!localSyncTime) return true;
132
+
133
+ const serverTz = config.ServerTimezone;
134
+ const serverDate = parseServerDate(serverLastUpdated, serverTz);
135
+ if (!serverDate) return false;
136
+
137
+ // Add a small tolerance (2 seconds) for filesystem timestamp precision
138
+ return serverDate.getTime() > localSyncTime.getTime() + 2000;
139
+ }
140
+
141
+ // ─── Server Fetching ────────────────────────────────────────────────────────
142
+
143
+ /**
144
+ * Fetch a single record from the server by entity and UID.
145
+ */
146
+ export async function fetchServerRecord(client, entity, uid) {
147
+ const result = await client.get(`/api/output/entity/${entity}/${uid}`, {
148
+ '_format': 'json_raw',
149
+ });
150
+ const data = result.payload || result.data;
151
+ const rows = Array.isArray(data) ? data : (data?.Rows || data?.rows || [data]);
152
+ return rows.length > 0 ? rows[0] : null;
153
+ }
154
+
155
+ // ─── Diff Algorithm ─────────────────────────────────────────────────────────
156
+
157
+ /**
158
+ * Compute a line-based diff between two strings.
159
+ * Returns array of { type: 'same'|'add'|'remove', line: string }
160
+ * where 'remove' = in local but not server, 'add' = in server but not local.
161
+ */
162
+ export function computeLineDiff(localText, serverText) {
163
+ // Normalize line endings
164
+ const localLines = (localText || '').replace(/\r\n/g, '\n').split('\n');
165
+ const serverLines = (serverText || '').replace(/\r\n/g, '\n').split('\n');
166
+
167
+ const n = localLines.length;
168
+ const m = serverLines.length;
169
+
170
+ // Build LCS length table (optimized: 2 rows)
171
+ let prev = new Array(m + 1).fill(0);
172
+ let curr = new Array(m + 1).fill(0);
173
+
174
+ for (let i = 1; i <= n; i++) {
175
+ for (let j = 1; j <= m; j++) {
176
+ if (localLines[i - 1] === serverLines[j - 1]) {
177
+ curr[j] = prev[j - 1] + 1;
178
+ } else {
179
+ curr[j] = Math.max(prev[j], curr[j - 1]);
180
+ }
181
+ }
182
+ prev = [...curr];
183
+ curr = new Array(m + 1).fill(0);
184
+ }
185
+
186
+ // Rebuild full table for backtracking (needed for edit script)
187
+ const dp = Array.from({ length: n + 1 }, () => new Array(m + 1).fill(0));
188
+ for (let i = 1; i <= n; i++) {
189
+ for (let j = 1; j <= m; j++) {
190
+ if (localLines[i - 1] === serverLines[j - 1]) {
191
+ dp[i][j] = dp[i - 1][j - 1] + 1;
192
+ } else {
193
+ dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1]);
194
+ }
195
+ }
196
+ }
197
+
198
+ // Backtrack to build edit script
199
+ const result = [];
200
+ let i = n, j = m;
201
+
202
+ while (i > 0 || j > 0) {
203
+ if (i > 0 && j > 0 && localLines[i - 1] === serverLines[j - 1]) {
204
+ result.unshift({ type: 'same', line: localLines[i - 1] });
205
+ i--; j--;
206
+ } else if (j > 0 && (i === 0 || dp[i][j - 1] >= dp[i - 1][j])) {
207
+ result.unshift({ type: 'add', line: serverLines[j - 1] });
208
+ j--;
209
+ } else {
210
+ result.unshift({ type: 'remove', line: localLines[i - 1] });
211
+ i--;
212
+ }
213
+ }
214
+
215
+ return result;
216
+ }
217
+
218
+ /**
219
+ * Format diff entries for terminal display in unified diff style.
220
+ * Returns array of formatted strings (one per line).
221
+ */
222
+ export function formatDiff(diffEntries, { contextLines = 3, localLabel = 'local', serverLabel = 'server' } = {}) {
223
+ const lines = [];
224
+ lines.push(chalk.dim(`--- ${localLabel}`));
225
+ lines.push(chalk.dim(`+++ ${serverLabel}`));
226
+
227
+ // Group into hunks with context
228
+ const hunks = groupIntoHunks(diffEntries, contextLines);
229
+
230
+ for (const hunk of hunks) {
231
+ lines.push(chalk.cyan(`@@ -${hunk.localStart},${hunk.localCount} +${hunk.serverStart},${hunk.serverCount} @@`));
232
+ for (const entry of hunk.entries) {
233
+ if (entry.type === 'same') {
234
+ lines.push(chalk.dim(` ${entry.line}`));
235
+ } else if (entry.type === 'remove') {
236
+ lines.push(chalk.red(`- ${entry.line}`));
237
+ } else if (entry.type === 'add') {
238
+ lines.push(chalk.green(`+ ${entry.line}`));
239
+ }
240
+ }
241
+ }
242
+
243
+ return lines;
244
+ }
245
+
246
+ /**
247
+ * Group diff entries into hunks with surrounding context lines.
248
+ */
249
+ function groupIntoHunks(entries, contextLines) {
250
+ const hunks = [];
251
+ const changeIndices = [];
252
+
253
+ // Find indices of all changed lines
254
+ for (let i = 0; i < entries.length; i++) {
255
+ if (entries[i].type !== 'same') {
256
+ changeIndices.push(i);
257
+ }
258
+ }
259
+
260
+ if (changeIndices.length === 0) return [];
261
+
262
+ // Group changes that are within (2 * contextLines) of each other
263
+ let hunkStart = Math.max(0, changeIndices[0] - contextLines);
264
+ let hunkEnd = Math.min(entries.length - 1, changeIndices[0] + contextLines);
265
+
266
+ for (let k = 1; k < changeIndices.length; k++) {
267
+ const nextStart = Math.max(0, changeIndices[k] - contextLines);
268
+ if (nextStart <= hunkEnd + 1) {
269
+ // Merge with current hunk
270
+ hunkEnd = Math.min(entries.length - 1, changeIndices[k] + contextLines);
271
+ } else {
272
+ // Emit current hunk
273
+ hunks.push(buildHunk(entries, hunkStart, hunkEnd));
274
+ hunkStart = nextStart;
275
+ hunkEnd = Math.min(entries.length - 1, changeIndices[k] + contextLines);
276
+ }
277
+ }
278
+
279
+ // Emit last hunk
280
+ hunks.push(buildHunk(entries, hunkStart, hunkEnd));
281
+
282
+ return hunks;
283
+ }
284
+
285
+ function buildHunk(entries, start, end) {
286
+ const hunkEntries = entries.slice(start, end + 1);
287
+ let localLine = 1, serverLine = 1;
288
+
289
+ // Count lines before hunk start
290
+ for (let i = 0; i < start; i++) {
291
+ if (entries[i].type === 'same' || entries[i].type === 'remove') localLine++;
292
+ if (entries[i].type === 'same' || entries[i].type === 'add') serverLine++;
293
+ }
294
+
295
+ let localCount = 0, serverCount = 0;
296
+ for (const e of hunkEntries) {
297
+ if (e.type === 'same' || e.type === 'remove') localCount++;
298
+ if (e.type === 'same' || e.type === 'add') serverCount++;
299
+ }
300
+
301
+ return {
302
+ localStart: localLine,
303
+ localCount,
304
+ serverStart: serverLine,
305
+ serverCount,
306
+ entries: hunkEntries,
307
+ };
308
+ }
309
+
310
+ // ─── Record Comparison ──────────────────────────────────────────────────────
311
+
312
+ /**
313
+ * Compare a local record (metadata.json + content files) against the server.
314
+ * Returns a DiffResult object.
315
+ */
316
+ export async function compareRecord(metaPath, client, config) {
317
+ let localMeta;
318
+ try {
319
+ localMeta = JSON.parse(await readFile(metaPath, 'utf8'));
320
+ } catch (err) {
321
+ return { error: `Cannot read ${metaPath}: ${err.message}` };
322
+ }
323
+
324
+ const entity = localMeta._entity;
325
+ const uid = localMeta.UID || localMeta._id;
326
+
327
+ if (!entity || !uid) {
328
+ return { error: `Missing _entity or UID in ${metaPath}` };
329
+ }
330
+
331
+ const serverRecord = await fetchServerRecord(client, entity, uid);
332
+ if (!serverRecord) {
333
+ return { error: `Record not found on server: ${entity}/${uid}` };
334
+ }
335
+
336
+ const metaDir = dirname(metaPath);
337
+ const metaBase = basename(metaPath, '.metadata.json');
338
+ const contentCols = localMeta._contentColumns || [];
339
+ const fieldDiffs = [];
340
+
341
+ // Compare content file columns
342
+ for (const col of contentCols) {
343
+ const localRef = localMeta[col];
344
+ if (!localRef || !String(localRef).startsWith('@')) continue;
345
+
346
+ const localFilePath = join(metaDir, String(localRef).substring(1));
347
+ let localContent = '';
348
+ try {
349
+ localContent = await readFile(localFilePath, 'utf8');
350
+ } catch {
351
+ localContent = ''; // File missing locally
352
+ }
353
+
354
+ const serverValue = resolveContentValue(serverRecord[col]);
355
+ if (serverValue === null) continue;
356
+
357
+ if (localContent !== serverValue) {
358
+ const diff = computeLineDiff(localContent, serverValue);
359
+ fieldDiffs.push({
360
+ column: col,
361
+ isContentFile: true,
362
+ localValue: localContent,
363
+ serverValue,
364
+ localFilePath,
365
+ diff,
366
+ });
367
+ }
368
+ }
369
+
370
+ // Compare metadata fields (non-content, non-system)
371
+ const skipFields = new Set(['_entity', '_contentColumns', '_mediaFile', 'children']);
372
+ for (const [key, serverVal] of Object.entries(serverRecord)) {
373
+ if (skipFields.has(key)) continue;
374
+ if (contentCols.includes(key)) continue; // Already handled above
375
+
376
+ const localVal = localMeta[key];
377
+ const serverStr = serverVal !== null && serverVal !== undefined ? String(serverVal) : '';
378
+ const localStr = localVal !== null && localVal !== undefined ? String(localVal) : '';
379
+
380
+ if (serverStr !== localStr) {
381
+ fieldDiffs.push({
382
+ column: key,
383
+ isContentFile: false,
384
+ localValue: localStr,
385
+ serverValue: serverStr,
386
+ localFilePath: null,
387
+ diff: null, // Single-value diff, not line-based
388
+ });
389
+ }
390
+ }
391
+
392
+ // Check for fields in local but not on server
393
+ for (const [key, localVal] of Object.entries(localMeta)) {
394
+ if (skipFields.has(key)) continue;
395
+ if (contentCols.includes(key)) continue;
396
+ if (key in serverRecord) continue;
397
+
398
+ const localStr = localVal !== null && localVal !== undefined ? String(localVal) : '';
399
+ if (localStr) {
400
+ fieldDiffs.push({
401
+ column: key,
402
+ isContentFile: false,
403
+ localValue: localStr,
404
+ serverValue: '',
405
+ localFilePath: null,
406
+ diff: null,
407
+ });
408
+ }
409
+ }
410
+
411
+ // Timestamp info
412
+ const serverTz = config.ServerTimezone;
413
+ const localSyncTime = await getLocalSyncTime(metaPath);
414
+ const serverDate = parseServerDate(serverRecord._LastUpdated, serverTz);
415
+ const userInfo = await loadUserInfo();
416
+ const updatedByUserId = serverRecord._LastUpdatedUserID || null;
417
+
418
+ return {
419
+ recordName: metaBase,
420
+ entity,
421
+ uid,
422
+ metaPath,
423
+ hasChanges: fieldDiffs.length > 0,
424
+ serverRecord,
425
+ localMeta,
426
+ fieldDiffs,
427
+ timestampInfo: {
428
+ serverLastUpdated: serverRecord._LastUpdated,
429
+ localLastUpdated: localMeta._LastUpdated,
430
+ localSyncTime,
431
+ serverDate,
432
+ updatedByUserId,
433
+ isOwnChange: !!(userInfo.userId && String(updatedByUserId) === String(userInfo.userId)),
434
+ },
435
+ };
436
+ }
437
+
438
+ // ─── Apply Changes ──────────────────────────────────────────────────────────
439
+
440
+ /**
441
+ * Apply accepted server changes to local files.
442
+ * acceptedFields is a Set of column names to update.
443
+ * Updates content files, metadata.json, and sets mtime on both.
444
+ */
445
+ export async function applyServerChanges(diffResult, acceptedFields, config) {
446
+ const { metaPath, serverRecord, localMeta, fieldDiffs } = diffResult;
447
+ const metaDir = dirname(metaPath);
448
+ const contentCols = new Set(localMeta._contentColumns || []);
449
+ let updatedMeta = { ...localMeta };
450
+ const filesToTimestamp = [metaPath];
451
+
452
+ for (const fd of fieldDiffs) {
453
+ if (!acceptedFields.has(fd.column)) continue;
454
+
455
+ if (fd.isContentFile && fd.localFilePath) {
456
+ // Write server content to local file
457
+ await writeFile(fd.localFilePath, fd.serverValue);
458
+ log.success(`Updated ${fd.localFilePath}`);
459
+ filesToTimestamp.push(fd.localFilePath);
460
+ }
461
+
462
+ if (!fd.isContentFile) {
463
+ // Update metadata field
464
+ if (fd.serverValue === '') {
465
+ delete updatedMeta[fd.column];
466
+ } else {
467
+ updatedMeta[fd.column] = serverRecord[fd.column];
468
+ }
469
+ }
470
+ }
471
+
472
+ // Always update _LastUpdated in metadata to server value
473
+ if (serverRecord._LastUpdated) {
474
+ updatedMeta._LastUpdated = serverRecord._LastUpdated;
475
+ }
476
+ if (serverRecord._CreatedOn) {
477
+ updatedMeta._CreatedOn = serverRecord._CreatedOn;
478
+ }
479
+ if (serverRecord._LastUpdatedUserID) {
480
+ updatedMeta._LastUpdatedUserID = serverRecord._LastUpdatedUserID;
481
+ }
482
+
483
+ // Write updated metadata
484
+ await writeFile(metaPath, JSON.stringify(updatedMeta, null, 2) + '\n');
485
+
486
+ // Set mtime on all affected files to server's _LastUpdated
487
+ const serverTz = config.ServerTimezone;
488
+ if (serverTz && serverRecord._LastUpdated) {
489
+ for (const filePath of filesToTimestamp) {
490
+ try {
491
+ await setFileTimestamps(filePath, serverRecord._CreatedOn, serverRecord._LastUpdated, serverTz);
492
+ } catch { /* non-critical */ }
493
+ }
494
+ }
495
+ }
496
+
497
+ // ─── Change Detection Prompt ────────────────────────────────────────────────
498
+
499
+ /**
500
+ * Build the change detection message describing who changed the file.
501
+ */
502
+ function buildChangeMessage(recordName, serverRecord, config) {
503
+ const userInfo = loadUserInfoSync();
504
+ const updatedBy = serverRecord._LastUpdatedUserID;
505
+
506
+ if (updatedBy && userInfo && String(updatedBy) === String(userInfo.userId)) {
507
+ return `"${recordName}" was updated on server by you (from another session)`;
508
+ } else if (updatedBy) {
509
+ return `"${recordName}" was updated on server by user ${updatedBy}`;
510
+ }
511
+ return `"${recordName}" has updates newer than your local version`;
512
+ }
513
+
514
+ // Sync version for message building (cached)
515
+ let _cachedUserInfo = null;
516
+ function loadUserInfoSync() {
517
+ return _cachedUserInfo;
518
+ }
519
+
520
+ /**
521
+ * Prompt the user when a record has changed.
522
+ * options.localIsNewer: when true, the local file has modifications not on server.
523
+ * Returns: 'overwrite' | 'compare' | 'skip' | 'overwrite_all' | 'skip_all'
524
+ */
525
+ export async function promptChangeDetection(recordName, serverRecord, config, options = {}) {
526
+ const localIsNewer = options.localIsNewer || false;
527
+
528
+ // Cache user info for message building
529
+ _cachedUserInfo = await loadUserInfo();
530
+
531
+ const message = localIsNewer
532
+ ? `"${recordName}" has local changes not on the server`
533
+ : buildChangeMessage(recordName, serverRecord, config);
534
+
535
+ const inquirer = (await import('inquirer')).default;
536
+
537
+ const choices = localIsNewer
538
+ ? [
539
+ { name: 'Restore server version (discard local changes)', value: 'overwrite' },
540
+ { name: 'Compare differences (dbo diff)', value: 'compare' },
541
+ { name: 'Keep local changes', value: 'skip' },
542
+ { name: 'Restore all to server version', value: 'overwrite_all' },
543
+ { name: 'Keep all local changes', value: 'skip_all' },
544
+ ]
545
+ : [
546
+ { name: 'Overwrite local file with server version', value: 'overwrite' },
547
+ { name: 'Compare differences (dbo diff)', value: 'compare' },
548
+ { name: 'Skip this file', value: 'skip' },
549
+ { name: 'Overwrite this and all remaining changed files', value: 'overwrite_all' },
550
+ { name: 'Skip all remaining changed files', value: 'skip_all' },
551
+ ];
552
+
553
+ const { action } = await inquirer.prompt([{
554
+ type: 'list',
555
+ name: 'action',
556
+ message,
557
+ choices,
558
+ }]);
559
+
560
+ return action;
561
+ }
562
+
563
+ // ─── Inline Diff & Merge ───────────────────────────────────────────────────
564
+
565
+ /**
566
+ * Run an inline diff and merge flow during pull/clone.
567
+ * Uses in-memory server data (no re-fetch).
568
+ *
569
+ * options.localIsNewer: when true, the local file is the newer version.
570
+ * - Diff direction flips: green = local (newer), red = server (older)
571
+ * - "Accept" means keep the newer (local) version
572
+ * - "Skip" means revert to server version
573
+ * When false (default): green = server (newer), red = local (older)
574
+ * - "Accept" means take server version
575
+ * - "Skip" means keep local version
576
+ *
577
+ * Returns { applied: Set<string>, skipped: boolean }
578
+ */
579
+ export async function inlineDiffAndMerge(serverRow, metaPath, config, options = {}) {
580
+ const localIsNewer = options.localIsNewer || false;
581
+
582
+ let localMeta;
583
+ try {
584
+ localMeta = JSON.parse(await readFile(metaPath, 'utf8'));
585
+ } catch {
586
+ return { applied: new Set(), skipped: true };
587
+ }
588
+
589
+ const metaDir = dirname(metaPath);
590
+ const metaBase = basename(metaPath, '.metadata.json');
591
+ const contentCols = localMeta._contentColumns || [];
592
+ const fieldDiffs = [];
593
+
594
+ // Compare content columns
595
+ for (const col of contentCols) {
596
+ const localRef = localMeta[col];
597
+ if (!localRef || !String(localRef).startsWith('@')) continue;
598
+
599
+ const localFilePath = join(metaDir, String(localRef).substring(1));
600
+ let localContent = '';
601
+ try {
602
+ localContent = await readFile(localFilePath, 'utf8');
603
+ } catch {
604
+ localContent = '';
605
+ }
606
+
607
+ const serverValue = resolveContentValue(serverRow[col]);
608
+ if (serverValue === null) continue;
609
+
610
+ if (localContent !== serverValue) {
611
+ // Diff direction: green (+) = newer, red (-) = older
612
+ const diff = localIsNewer
613
+ ? computeLineDiff(serverValue, localContent) // green = local (newer)
614
+ : computeLineDiff(localContent, serverValue); // green = server (newer)
615
+ fieldDiffs.push({
616
+ column: col,
617
+ isContentFile: true,
618
+ localValue: localContent,
619
+ serverValue,
620
+ localFilePath,
621
+ diff,
622
+ });
623
+ }
624
+ }
625
+
626
+ // Compare metadata fields
627
+ const skipFields = new Set(['_entity', '_contentColumns', '_mediaFile', 'children']);
628
+ for (const [key, serverVal] of Object.entries(serverRow)) {
629
+ if (skipFields.has(key)) continue;
630
+ if (contentCols.includes(key)) continue;
631
+
632
+ const localVal = localMeta[key];
633
+ const serverStr = serverVal !== null && serverVal !== undefined ? String(serverVal) : '';
634
+ const localStr = localVal !== null && localVal !== undefined ? String(localVal) : '';
635
+
636
+ if (serverStr !== localStr) {
637
+ fieldDiffs.push({
638
+ column: key,
639
+ isContentFile: false,
640
+ localValue: localStr,
641
+ serverValue: serverStr,
642
+ localFilePath: null,
643
+ diff: null,
644
+ });
645
+ }
646
+ }
647
+
648
+ if (fieldDiffs.length === 0) {
649
+ log.info(`No differences found for "${metaBase}".`);
650
+ return { applied: new Set(), skipped: true };
651
+ }
652
+
653
+ // Display and prompt per field
654
+ const accepted = new Set();
655
+ let bulkAction = null;
656
+ const inquirer = (await import('inquirer')).default;
657
+
658
+ // Labels and prompts depend on which side is newer
659
+ const newerLabel = localIsNewer ? 'local' : 'server';
660
+ const olderLabel = localIsNewer ? 'server' : 'local';
661
+
662
+ for (const fd of fieldDiffs) {
663
+ if (bulkAction === 'accept_all') {
664
+ accepted.add(fd.column);
665
+ continue;
666
+ }
667
+ if (bulkAction === 'skip_all') {
668
+ continue;
669
+ }
670
+
671
+ // Display the diff — green (+) is always the newer side
672
+ log.plain('');
673
+ if (fd.isContentFile && fd.diff) {
674
+ log.label('Field', `${fd.column} (${fd.localFilePath})`);
675
+ const formatted = formatDiff(fd.diff, {
676
+ localLabel: localIsNewer
677
+ ? `${olderLabel}: ${metaBase}`
678
+ : `${olderLabel}: ${fd.localFilePath}`,
679
+ serverLabel: localIsNewer
680
+ ? `${newerLabel}: ${fd.localFilePath}`
681
+ : `${newerLabel}: ${metaBase}`,
682
+ });
683
+ for (const line of formatted) log.plain(line);
684
+ } else {
685
+ log.label('Field', fd.column);
686
+ // Red (-) = older, Green (+) = newer
687
+ const olderValue = localIsNewer ? fd.serverValue : fd.localValue;
688
+ const newerValue = localIsNewer ? fd.localValue : fd.serverValue;
689
+ if (olderValue) log.plain(chalk.red(`- ${olderValue}`));
690
+ if (newerValue) log.plain(chalk.green(`+ ${newerValue}`));
691
+ }
692
+
693
+ const promptMessage = localIsNewer
694
+ ? `Keep local change for "${fd.column}"?`
695
+ : `Accept server change for "${fd.column}"?`;
696
+
697
+ const { action } = await inquirer.prompt([{
698
+ type: 'list',
699
+ name: 'action',
700
+ message: promptMessage,
701
+ choices: [
702
+ { name: `Accept (keep ${newerLabel} version)`, value: 'accept' },
703
+ { name: `Skip (keep ${olderLabel} version)`, value: 'skip' },
704
+ { name: `Accept all remaining changes`, value: 'accept_all' },
705
+ { name: `Skip all remaining changes`, value: 'skip_all' },
706
+ ],
707
+ }]);
708
+
709
+ if (action === 'accept' || action === 'accept_all') {
710
+ accepted.add(fd.column);
711
+ }
712
+ if (action === 'accept_all' || action === 'skip_all') {
713
+ bulkAction = action;
714
+ }
715
+ }
716
+
717
+ // Apply changes based on direction
718
+ if (localIsNewer) {
719
+ // "Accepted" = keep local (no-op). "Skipped" = revert to server.
720
+ const allColumns = new Set(fieldDiffs.map(fd => fd.column));
721
+ const revertToServer = new Set([...allColumns].filter(c => !accepted.has(c)));
722
+ if (revertToServer.size > 0) {
723
+ const diffResult = { metaPath, serverRecord: serverRow, localMeta, fieldDiffs };
724
+ await applyServerChanges(diffResult, revertToServer, config);
725
+ log.info(`Reverted ${revertToServer.size} field(s) to server version for "${metaBase}"`);
726
+ }
727
+ if (accepted.size > 0) {
728
+ log.info(`Kept ${accepted.size} local change(s) for "${metaBase}"`);
729
+ }
730
+ } else {
731
+ // "Accepted" = take server version. "Skipped" = keep local.
732
+ if (accepted.size > 0) {
733
+ const diffResult = { metaPath, serverRecord: serverRow, localMeta, fieldDiffs };
734
+ await applyServerChanges(diffResult, accepted, config);
735
+ log.info(`Applied ${accepted.size} change(s) for "${metaBase}"`);
736
+ }
737
+ }
738
+
739
+ return { applied: accepted, skipped: accepted.size === 0 };
740
+ }