@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.
@@ -1,6 +1,7 @@
1
1
  import { Command } from 'commander';
2
2
  import chalk from 'chalk';
3
3
  import { DboClient } from '../lib/client.js';
4
+ import { loadConfig } from '../lib/config.js';
4
5
  import { formatError } from '../lib/formatter.js';
5
6
  import { saveToDisk } from '../lib/save-to-disk.js';
6
7
  import { log } from '../lib/logger.js';
@@ -82,6 +83,7 @@ export const pullCommand = new Command('pull')
82
83
 
83
84
  // Content entity has well-known defaults — skip prompts
84
85
  const isContent = entity === 'content';
86
+ const config = await loadConfig();
85
87
 
86
88
  if (isContent) {
87
89
  await saveToDisk(rows, columns, {
@@ -94,12 +96,16 @@ export const pullCommand = new Command('pull')
94
96
  contentFileMap: columns.includes('Content') ? {
95
97
  Content: { suffix: '', extensionSource: columns.includes('Extension') ? 'Extension' : null, extension: 'txt' },
96
98
  } : null,
99
+ changeDetection: true,
100
+ config,
97
101
  });
98
102
  } else {
99
103
  // Other entities: interactive prompts for column selection
100
104
  await saveToDisk(rows, columns, {
101
105
  entity,
102
106
  nonInteractive: false,
107
+ changeDetection: true,
108
+ config,
103
109
  });
104
110
  }
105
111
  } catch (err) {
@@ -1,13 +1,16 @@
1
1
  import { Command } from 'commander';
2
- import { readFile, readdir, stat, writeFile } from 'fs/promises';
2
+ import { readFile, stat, writeFile } from 'fs/promises';
3
3
  import { join, dirname, basename, extname, relative } from 'path';
4
4
  import { DboClient } from '../lib/client.js';
5
5
  import { buildInputBody, checkSubmitErrors } from '../lib/input-parser.js';
6
6
  import { formatResponse, formatError } from '../lib/formatter.js';
7
7
  import { log } from '../lib/logger.js';
8
8
  import { shouldSkipColumn } from '../lib/columns.js';
9
- import { loadConfig } from '../lib/config.js';
9
+ import { loadConfig, loadSynchronize, saveSynchronize, loadAppJsonBaseline, saveAppJsonBaseline, hasBaseline } from '../lib/config.js';
10
10
  import { setFileTimestamps } from '../lib/timestamps.js';
11
+ import { findMetadataFiles } from '../lib/diff.js';
12
+ import { detectChangedColumns, findBaselineEntry } from '../lib/delta.js';
13
+ import { buildDependencyGraph } from '../lib/dependencies.js';
11
14
 
12
15
  export const pushCommand = new Command('push')
13
16
  .description('Push local files back to DBO.io using metadata from pull')
@@ -24,6 +27,10 @@ export const pushCommand = new Command('push')
24
27
  .action(async (targetPath, options) => {
25
28
  try {
26
29
  const client = new DboClient({ domain: options.domain, verbose: options.verbose });
30
+
31
+ // Process pending deletions from synchronize.json
32
+ await processPendingDeletes(client, options);
33
+
27
34
  const pathStat = await stat(targetPath);
28
35
 
29
36
  if (pathStat.isDirectory()) {
@@ -37,6 +44,71 @@ export const pushCommand = new Command('push')
37
44
  }
38
45
  });
39
46
 
47
+ /**
48
+ * Process pending delete entries from .dbo/synchronize.json
49
+ */
50
+ async function processPendingDeletes(client, options) {
51
+ const sync = await loadSynchronize();
52
+ if (!sync.delete || sync.delete.length === 0) return;
53
+
54
+ log.info(`Processing ${sync.delete.length} pending deletion(s)...`);
55
+
56
+ const remaining = [];
57
+ const deletedUids = [];
58
+
59
+ for (const entry of sync.delete) {
60
+ log.info(`Deleting "${entry.name}" (${entry.entity}:${entry.RowID})`);
61
+
62
+ const extraParams = { '_confirm': options.confirm || 'true' };
63
+ if (options.ticket) extraParams['_OverrideTicketID'] = options.ticket;
64
+
65
+ const body = await buildInputBody([entry.expression], extraParams);
66
+
67
+ try {
68
+ const result = await client.postUrlEncoded('/api/input/submit', body);
69
+
70
+ // Retry with prompted params if needed
71
+ const retryParams = await checkSubmitErrors(result);
72
+ if (retryParams) {
73
+ Object.assign(extraParams, retryParams);
74
+ const retryBody = await buildInputBody([entry.expression], extraParams);
75
+ const retryResult = await client.postUrlEncoded('/api/input/submit', retryBody);
76
+ if (retryResult.successful) {
77
+ log.success(` Deleted "${entry.name}" from server`);
78
+ deletedUids.push(entry.UID);
79
+ } else {
80
+ log.error(` Failed to delete "${entry.name}"`);
81
+ formatResponse(retryResult, { json: options.json, jq: options.jq });
82
+ remaining.push(entry);
83
+ }
84
+ } else if (result.successful) {
85
+ log.success(` Deleted "${entry.name}" from server`);
86
+ deletedUids.push(entry.UID);
87
+ } else {
88
+ log.error(` Failed to delete "${entry.name}"`);
89
+ formatResponse(result, { json: options.json, jq: options.jq });
90
+ remaining.push(entry);
91
+ }
92
+ } catch (err) {
93
+ log.error(` Failed to delete "${entry.name}": ${err.message}`);
94
+ remaining.push(entry);
95
+ }
96
+ }
97
+
98
+ // Remove edit entries for successfully deleted records (spec requirement)
99
+ if (deletedUids.length > 0) {
100
+ sync.edit = (sync.edit || []).filter(e => !deletedUids.includes(e.UID));
101
+ }
102
+
103
+ // Update synchronize.json with any remaining entries
104
+ sync.delete = remaining;
105
+ await saveSynchronize(sync);
106
+
107
+ if (remaining.length > 0) {
108
+ log.warn(`${remaining.length} deletion(s) failed and remain staged.`);
109
+ }
110
+ }
111
+
40
112
  /**
41
113
  * Push a single file using its companion .metadata.json
42
114
  */
@@ -70,8 +142,17 @@ async function pushDirectory(dirPath, client, options) {
70
142
 
71
143
  log.info(`Found ${metaFiles.length} record(s) to push`);
72
144
 
73
- let succeeded = 0;
74
- let failed = 0;
145
+ // Load baseline for delta detection
146
+ const baseline = await loadAppJsonBaseline();
147
+ const config = await loadConfig();
148
+
149
+ if (!baseline) {
150
+ log.warn('No .app.json baseline found — performing full push (run "dbo clone" to enable delta sync)');
151
+ }
152
+
153
+ // Collect metadata with detected changes
154
+ const toPush = [];
155
+ let skipped = 0;
75
156
 
76
157
  for (const metaPath of metaFiles) {
77
158
  let meta;
@@ -79,19 +160,19 @@ async function pushDirectory(dirPath, client, options) {
79
160
  meta = JSON.parse(await readFile(metaPath, 'utf8'));
80
161
  } catch (err) {
81
162
  log.warn(`Skipping invalid metadata: ${metaPath} (${err.message})`);
82
- failed++;
163
+ skipped++;
83
164
  continue;
84
165
  }
85
166
 
86
167
  if (!meta.UID && !meta._id) {
87
168
  log.warn(`Skipping "${metaPath}": no UID or _id found`);
88
- failed++;
169
+ skipped++;
89
170
  continue;
90
171
  }
91
172
 
92
173
  if (!meta._entity) {
93
174
  log.warn(`Skipping "${metaPath}": no _entity found`);
94
- failed++;
175
+ skipped++;
95
176
  continue;
96
177
  }
97
178
 
@@ -111,24 +192,77 @@ async function pushDirectory(dirPath, client, options) {
111
192
  }
112
193
  }
113
194
  }
114
- if (missingFiles) { failed++; continue; }
195
+ if (missingFiles) { skipped++; continue; }
196
+
197
+ // Detect changed columns (delta detection)
198
+ let changedColumns = null;
199
+ if (baseline) {
200
+ try {
201
+ changedColumns = await detectChangedColumns(metaPath, baseline, config);
202
+ if (changedColumns.length === 0) {
203
+ log.dim(` Skipping ${basename(metaPath)} — no changes detected`);
204
+ skipped++;
205
+ continue;
206
+ }
207
+ } catch (err) {
208
+ log.warn(`Delta detection failed for ${metaPath}: ${err.message} — performing full push`);
209
+ }
210
+ }
211
+
212
+ toPush.push({ meta, metaPath, changedColumns });
213
+ }
214
+
215
+ if (toPush.length === 0) {
216
+ log.info('No changes to push');
217
+ return;
218
+ }
115
219
 
220
+ // Group by entity and apply dependency ordering
221
+ const byEntity = {};
222
+ for (const item of toPush) {
223
+ const entity = item.meta._entity;
224
+ if (!byEntity[entity]) byEntity[entity] = [];
225
+ byEntity[entity].push(item);
226
+ }
227
+
228
+ // Process in dependency order
229
+ let succeeded = 0;
230
+ let failed = 0;
231
+ const successfulPushes = [];
232
+
233
+ for (const item of toPush) {
116
234
  try {
117
- await pushFromMetadata(meta, metaPath, client, options);
118
- succeeded++;
235
+ const success = await pushFromMetadata(item.meta, item.metaPath, client, options, item.changedColumns);
236
+ if (success) {
237
+ succeeded++;
238
+ successfulPushes.push(item);
239
+ } else {
240
+ failed++;
241
+ }
119
242
  } catch (err) {
120
- log.error(`Failed: ${metaPath} — ${err.message}`);
243
+ log.error(`Failed: ${item.metaPath} — ${err.message}`);
121
244
  failed++;
122
245
  }
123
246
  }
124
247
 
125
- log.info(`Push complete: ${succeeded} succeeded, ${failed} failed`);
248
+ // Update baseline after successful pushes
249
+ if (baseline && successfulPushes.length > 0) {
250
+ await updateBaselineAfterPush(baseline, successfulPushes);
251
+ }
252
+
253
+ log.info(`Push complete: ${succeeded} succeeded, ${failed} failed, ${skipped} skipped`);
126
254
  }
127
255
 
128
256
  /**
129
257
  * Build and submit input expressions from a metadata object
258
+ * @param {Object} meta - Metadata object
259
+ * @param {string} metaPath - Path to metadata file
260
+ * @param {DboClient} client - API client
261
+ * @param {Object} options - Push options
262
+ * @param {string[]|null} changedColumns - Optional array of changed column names (for delta sync)
263
+ * @returns {Promise<boolean>} - True if push succeeded
130
264
  */
131
- async function pushFromMetadata(meta, metaPath, client, options) {
265
+ async function pushFromMetadata(meta, metaPath, client, options, changedColumns = null) {
132
266
  const uid = meta.UID || meta._id;
133
267
  const entity = meta._entity;
134
268
  const contentCols = new Set(meta._contentColumns || []);
@@ -149,11 +283,17 @@ async function pushFromMetadata(meta, metaPath, client, options) {
149
283
  const dataExprs = [];
150
284
  let metaUpdated = false;
151
285
 
286
+ // If changedColumns is provided, only push those columns (delta sync)
287
+ const columnsToProcess = changedColumns ? new Set(changedColumns) : null;
288
+
152
289
  for (const [key, value] of Object.entries(meta)) {
153
290
  if (shouldSkipColumn(key)) continue;
154
291
  if (key === 'UID') continue; // UID is the identifier, not a column to update
155
292
  if (value === null || value === undefined) continue;
156
293
 
294
+ // Delta sync: skip columns not in changedColumns
295
+ if (columnsToProcess && !columnsToProcess.has(key)) continue;
296
+
157
297
  const isContentCol = contentCols.has(key);
158
298
 
159
299
  // --meta-only: skip content columns
@@ -175,10 +315,11 @@ async function pushFromMetadata(meta, metaPath, client, options) {
175
315
 
176
316
  if (dataExprs.length === 0) {
177
317
  log.warn(`Nothing to push for ${basename(metaPath)}`);
178
- return;
318
+ return false;
179
319
  }
180
320
 
181
- log.info(`Pushing ${basename(metaPath, '.metadata.json')} (${entity}:${uid}) ${dataExprs.length} field(s)`);
321
+ const fieldLabel = changedColumns ? `${dataExprs.length} changed field(s)` : `${dataExprs.length} field(s)`;
322
+ log.info(`Pushing ${basename(metaPath, '.metadata.json')} (${entity}:${uid}) — ${fieldLabel}`);
182
323
 
183
324
  const extraParams = { '_confirm': options.confirm };
184
325
  if (options.ticket) extraParams['_OverrideTicketID'] = options.ticket;
@@ -203,7 +344,7 @@ async function pushFromMetadata(meta, metaPath, client, options) {
203
344
  }
204
345
 
205
346
  if (!result.successful) {
206
- throw new Error('Push failed');
347
+ return false;
207
348
  }
208
349
 
209
350
  // Update file timestamps from server response
@@ -232,6 +373,64 @@ async function pushFromMetadata(meta, metaPath, client, options) {
232
373
  }
233
374
  }
234
375
  } catch { /* non-critical timestamp update */ }
376
+
377
+ return true;
378
+ }
379
+
380
+ /**
381
+ * Normalize a local file path for comparison with server-side Path.
382
+ *
383
+ * The Bins/ directory is used for local file organization and does not reflect
384
+ * the actual server-side serving path. This function strips organizational prefixes
385
+ * to enable correct path comparison during push operations.
386
+ *
387
+ * **Directory Structure:**
388
+ * - `Bins/` — Local organizational root (always stripped)
389
+ * - `Bins/app/` — Special case: the "app" subdirectory is also stripped because it's
390
+ * purely organizational. Files in Bins/app/ are served from the app root without
391
+ * the "app/" prefix on the server.
392
+ * - `Bins/custom_name/` — Custom bin directories (tpl/, ticket_test/, etc.) are
393
+ * preserved because they represent actual bin hierarchies that serve from their
394
+ * directory name.
395
+ *
396
+ * **Why "app/" is special:**
397
+ * The main app bin is placed in Bins/app/ locally for organization, but server-side
398
+ * these files are served from the root path (no "app/" prefix). Other custom bins
399
+ * like "tpl/" maintain their directory name in the serving path.
400
+ *
401
+ * Examples:
402
+ * "Bins/app/assets/css/file.css" → "assets/css/file.css" (strips Bins/app/)
403
+ * "Bins/tpl/header.html" → "tpl/header.html" (preserves tpl/)
404
+ * "Bins/assets/css/file.css" → "assets/css/file.css" (strips Bins/ only)
405
+ * "Sites/MySite/content/page.html" → "Sites/MySite/content/page.html" (unchanged)
406
+ *
407
+ * @param {string} localPath - Relative path from project root
408
+ * @returns {string} - Normalized path for comparison with metadata Path column
409
+ */
410
+ function normalizePathForComparison(localPath) {
411
+ // Convert backslashes to forward slashes (Windows compatibility)
412
+ const normalized = localPath.replace(/\\/g, '/');
413
+
414
+ // Strip leading and trailing slashes
415
+ const cleaned = normalized.replace(/^\/+|\/+$/g, '');
416
+
417
+ // Check if path starts with "Bins/" organizational directory
418
+ if (cleaned.startsWith('Bins/')) {
419
+ // Remove "Bins/" prefix (length = 5)
420
+ const withoutBins = cleaned.substring(5);
421
+
422
+ // Special case: strip "app/" organizational subdirectory
423
+ // This is the only special subdirectory - all others (tpl/, assets/, etc.) are preserved
424
+ if (withoutBins.startsWith('app/')) {
425
+ return withoutBins.substring(4); // Remove "app/" (length = 4)
426
+ }
427
+
428
+ // For all other paths, return without Bins/ prefix
429
+ return withoutBins;
430
+ }
431
+
432
+ // Path doesn't start with Bins/, return as-is
433
+ return cleaned;
235
434
  }
236
435
 
237
436
  /**
@@ -258,15 +457,25 @@ async function checkPathMismatch(meta, metaPath, options) {
258
457
  const currentFilePath = join(metaDir, contentFileName);
259
458
  const currentRelPath = relative(process.cwd(), currentFilePath);
260
459
 
261
- // Normalize stored path for comparison
262
- const storedPath = String(meta.Path).replace(/^\/+|\/+$/g, '');
263
- const currentPath = currentRelPath.replace(/\\/g, '/');
460
+ // Normalize both paths for comparison
461
+ // The metadata Path may also incorrectly contain Bins/app/ prefix from old data
462
+ const storedPathRaw = String(meta.Path).replace(/^\/+|\/+$/g, ''); // Strip leading/trailing slashes
463
+ const normalizedStoredPath = normalizePathForComparison(storedPathRaw);
464
+ const normalizedCurrentPath = normalizePathForComparison(currentRelPath);
465
+
466
+ // Compare with flexible handling of leading slash
467
+ // (Both /path and path are considered equivalent)
468
+ const storedNormalized = normalizedStoredPath.replace(/^\/+/, '');
469
+ const currentNormalized = normalizedCurrentPath.replace(/^\/+/, '');
470
+
471
+ if (storedNormalized === currentNormalized) return;
264
472
 
265
- if (storedPath === currentPath) return;
473
+ // Use normalized path for display
474
+ const currentPath = normalizedCurrentPath;
266
475
 
267
476
  // Path mismatch detected
268
477
  log.warn(`Path mismatch for "${metaBase}":`);
269
- log.label(' Metadata Path', storedPath);
478
+ log.label(' Metadata Path', storedPathRaw);
270
479
  log.label(' Current path ', currentPath);
271
480
 
272
481
  let updatePath = options.yes;
@@ -290,20 +499,67 @@ async function checkPathMismatch(meta, metaPath, options) {
290
499
  }
291
500
 
292
501
  /**
293
- * Recursively find all .metadata.json files in a directory
502
+ * Update baseline file (.app.json) after successful pushes.
503
+ * Syncs changed column values and timestamps from metadata to baseline.
504
+ *
505
+ * @param {Object} baseline - The baseline JSON object
506
+ * @param {Array} successfulPushes - Array of { meta, metaPath, changedColumns }
294
507
  */
295
- async function findMetadataFiles(dir) {
296
- const results = [];
297
- const entries = await readdir(dir, { withFileTypes: true });
298
-
299
- for (const entry of entries) {
300
- const fullPath = join(dir, entry.name);
301
- if (entry.isDirectory()) {
302
- results.push(...await findMetadataFiles(fullPath));
303
- } else if (entry.name.endsWith('.metadata.json')) {
304
- results.push(fullPath);
508
+ async function updateBaselineAfterPush(baseline, successfulPushes) {
509
+ let modified = false;
510
+
511
+ for (const { meta, metaPath, changedColumns } of successfulPushes) {
512
+ const uid = meta.UID || meta._id;
513
+ const entity = meta._entity;
514
+
515
+ // Find the baseline entry
516
+ const baselineEntry = findBaselineEntry(baseline, entity, uid);
517
+ if (!baselineEntry) {
518
+ log.warn(` Baseline entry not found for ${entity}:${uid} — skipping baseline update`);
519
+ continue;
520
+ }
521
+
522
+ // Update _LastUpdated and _LastUpdatedUserID from metadata
523
+ if (meta._LastUpdated) {
524
+ baselineEntry._LastUpdated = meta._LastUpdated;
525
+ modified = true;
526
+ }
527
+ if (meta._LastUpdatedUserID) {
528
+ baselineEntry._LastUpdatedUserID = meta._LastUpdatedUserID;
529
+ modified = true;
530
+ }
531
+
532
+ // Update changed column values in baseline
533
+ const columnsToUpdate = changedColumns || Object.keys(meta).filter(k => !shouldSkipColumn(k) && k !== 'UID');
534
+
535
+ for (const col of columnsToUpdate) {
536
+ const value = meta[col];
537
+ if (value === null || value === undefined) continue;
538
+
539
+ const strValue = String(value);
540
+
541
+ // If it's a @reference, read the file content and store in baseline
542
+ if (strValue.startsWith('@')) {
543
+ try {
544
+ const refFile = strValue.substring(1);
545
+ const refPath = join(dirname(metaPath), refFile);
546
+ const fileContent = await readFile(refPath, 'utf8');
547
+ baselineEntry[col] = fileContent;
548
+ modified = true;
549
+ } catch (err) {
550
+ log.warn(` Failed to read ${strValue} for baseline update: ${err.message}`);
551
+ }
552
+ } else {
553
+ // Scalar value: store directly
554
+ baselineEntry[col] = value;
555
+ modified = true;
556
+ }
305
557
  }
306
558
  }
307
559
 
308
- return results;
560
+ // Save updated baseline
561
+ if (modified) {
562
+ await saveAppJsonBaseline(baseline);
563
+ log.dim(' Baseline updated with pushed changes');
564
+ }
309
565
  }