@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.
- package/README.md +294 -71
- package/bin/dbo.js +12 -3
- package/bin/postinstall.js +88 -0
- package/package.json +10 -3
- package/src/commands/clone.js +597 -19
- package/src/commands/diff.js +246 -0
- package/src/commands/init.js +30 -22
- package/src/commands/install.js +517 -69
- package/src/commands/mv.js +869 -0
- package/src/commands/pull.js +6 -0
- package/src/commands/push.js +289 -33
- package/src/commands/rm.js +337 -0
- package/src/commands/status.js +28 -1
- package/src/lib/config.js +265 -0
- package/src/lib/delta.js +204 -0
- package/src/lib/dependencies.js +131 -0
- package/src/lib/diff.js +740 -0
- package/src/lib/save-to-disk.js +71 -4
- package/src/lib/structure.js +36 -0
- package/src/plugins/claudecommands/dbo.md +37 -6
- package/src/commands/update.js +0 -168
package/src/lib/diff.js
ADDED
|
@@ -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
|
+
}
|