@dboio/cli 0.11.2 → 0.11.4

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dboio/cli",
3
- "version": "0.11.2",
3
+ "version": "0.11.4",
4
4
  "description": "CLI for the DBO.io framework",
5
5
  "type": "module",
6
6
  "bin": {
@@ -2956,8 +2956,8 @@ async function trashOrphanedChildFiles(outputDir, rootStem) {
2956
2956
  log.dim(` Trashed orphaned child file: ${f}`);
2957
2957
  } catch { /* non-critical */ }
2958
2958
  }
2959
- // Also trash the legacy root file itself (_output~Name~UID.json) if new format exists
2960
- if (matchesLegacy === false && f === `${legacyStem}.json`) {
2959
+ // Also trash the legacy root file itself (_output~Name~UID.json or .metadata.json) if new format exists
2960
+ if (matchesLegacy === false && (f === `${legacyStem}.json` || f === `${legacyStem}.metadata.json`)) {
2961
2961
  if (!trashCreated) {
2962
2962
  await mkdir(trashDir, { recursive: true });
2963
2963
  trashCreated = true;
@@ -2984,9 +2984,10 @@ async function trashOrphanedChildFiles(outputDir, rootStem) {
2984
2984
  * @returns {Object} - { segments: [{entity, name, uid}], rootOutputUid, entityType, uid }
2985
2985
  */
2986
2986
  export function parseOutputHierarchyFile(filename) {
2987
- // Strip .json extension
2987
+ // Strip .metadata.json or legacy .json extension
2988
2988
  let base = filename;
2989
- if (base.endsWith('.json')) base = base.substring(0, base.length - 5);
2989
+ if (base.endsWith('.metadata.json')) base = base.substring(0, base.length - 14);
2990
+ else if (base.endsWith('.json')) base = base.substring(0, base.length - 5);
2990
2991
 
2991
2992
  // Split into segments by finding entity type boundaries
2992
2993
  // Entity types are: output~ (or legacy _output~), column~, join~, filter~
@@ -3109,7 +3110,14 @@ async function processOutputHierarchy(appJson, structure, options, serverTz) {
3109
3110
 
3110
3111
  // Build root output filename
3111
3112
  const rootBasename = buildOutputFilename('output', output, filenameCols.output);
3112
- const rootMetaPath = join(binDir, `${rootBasename}.json`);
3113
+ let rootMetaPath = join(binDir, `${rootBasename}.metadata.json`);
3114
+
3115
+ // Legacy fallback: if old .json exists but new .metadata.json doesn't, rename in-place
3116
+ const legacyJsonPath = join(binDir, `${rootBasename}.json`);
3117
+ if (!await fileExists(rootMetaPath) && await fileExists(legacyJsonPath)) {
3118
+ await rename(legacyJsonPath, rootMetaPath);
3119
+ log.dim(` Renamed ${rootBasename}.json → ${rootBasename}.metadata.json`);
3120
+ }
3113
3121
 
3114
3122
  // Detect old-format files that need migration to inline children format.
3115
3123
  // Old format: children.column/join/filter contain @reference strings to separate files.
@@ -1,5 +1,5 @@
1
1
  import { Command } from 'commander';
2
- import { readFile, readdir, stat, writeFile, rename as fsRename, mkdir, access } from 'fs/promises';
2
+ import { readFile, stat, writeFile, rename as fsRename, mkdir, access } 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, getSessionUserOverride } from '../lib/input-parser.js';
@@ -306,18 +306,17 @@ async function pushSingleFile(filePath, client, options, modifyKey = null, trans
306
306
  const baseline = await loadAppJsonBaseline();
307
307
  if (baseline) {
308
308
  const appConfig = await loadAppConfig();
309
- const proceed = await checkToeStepping([{ meta, metaPath }], client, baseline, options, appConfig?.AppShortName);
310
- if (!proceed) return;
309
+ const result = await checkToeStepping([{ meta, metaPath }], client, baseline, options, appConfig?.AppShortName);
310
+ if (result === false || result instanceof Set) return;
311
311
  }
312
312
  }
313
313
 
314
314
  await pushFromMetadata(meta, metaPath, client, options, null, modifyKey, transactionKey);
315
315
  }
316
-
317
316
  /**
318
- * Ensure manifest.json at project root has companion metadata in bins/app/.
319
- * If manifest.json exists but no manifest*.metadata.json is in bins/app/,
320
- * auto-create the metadata so the push flow picks it up.
317
+ * Ensure manifest.json at project root has companion metadata in lib/bins/app/.
318
+ * Only creates metadata if manifest.json exists at root AND no existing metadata
319
+ * anywhere in the project already references @/manifest.json (prevents duplicates).
321
320
  */
322
321
  async function ensureManifestMetadata() {
323
322
  // Check if manifest.json exists at project root
@@ -327,30 +326,24 @@ async function ensureManifestMetadata() {
327
326
  return; // No manifest.json — nothing to do
328
327
  }
329
328
 
330
- // Check if bins/app/ already has metadata that references @/manifest.json.
331
- // A filename-only check (startsWith('manifest')) is insufficient because
332
- // the metadata may have been renamed with a ~UID suffix or prefixed with
333
- // __WILL_DELETE__. Instead, scan actual metadata content for the reference.
334
- const binsAppDir = join(process.cwd(), 'bins', 'app');
335
- try {
336
- const entries = await readdir(binsAppDir);
337
- const metaEntries = entries.filter(e => e.endsWith('.metadata.json'));
338
- for (const entry of metaEntries) {
339
- try {
340
- const raw = await readFile(join(binsAppDir, entry), 'utf8');
341
- const parsed = JSON.parse(raw);
342
- if (parsed.Content === '@/manifest.json') return; // Already tracked
343
- } catch { /* skip unreadable files */ }
344
- }
345
- } catch {
346
- // bins/app/ doesn't exist — will create it
329
+ // Scan the entire project for any metadata file that already references manifest.json.
330
+ // This prevents creating duplicates when the metadata lives in an unexpected location.
331
+ const ig = await loadIgnore();
332
+ const allMeta = await findMetadataFiles(process.cwd(), ig);
333
+ for (const metaPath of allMeta) {
334
+ try {
335
+ const raw = await readFile(metaPath, 'utf8');
336
+ const parsed = JSON.parse(raw);
337
+ if (parsed.Content === '@/manifest.json') return; // Already tracked
338
+ } catch { /* skip unreadable */ }
347
339
  }
348
340
 
349
- // Auto-create manifest.metadata.json
341
+ // No existing metadata references manifest.json — create one
350
342
  const appConfig = await loadAppConfig();
351
343
  const structure = await loadStructureFile();
352
344
  const appBin = findBinByPath('app', structure);
353
345
 
346
+ const binsAppDir = join(process.cwd(), BINS_DIR, 'app');
354
347
  await mkdir(binsAppDir, { recursive: true });
355
348
 
356
349
  const meta = {
@@ -481,8 +474,15 @@ async function pushDirectory(dirPath, client, options, modifyKey = null, transac
481
474
  const toCheck = toPush.filter(item => !item.isNew);
482
475
  if (toCheck.length > 0) {
483
476
  const appConfig = await loadAppConfig();
484
- const proceed = await checkToeStepping(toCheck, client, baseline, options, appConfig?.AppShortName);
485
- if (!proceed) return;
477
+ const result = await checkToeStepping(toCheck, client, baseline, options, appConfig?.AppShortName);
478
+ if (result === false) return; // user cancelled entirely
479
+ if (result instanceof Set) {
480
+ // Filter out skipped UIDs
481
+ for (let i = toPush.length - 1; i >= 0; i--) {
482
+ if (result.has(toPush[i].meta.UID)) toPush.splice(i, 1);
483
+ }
484
+ if (toPush.length === 0) return;
485
+ }
486
486
  }
487
487
  }
488
488
 
@@ -725,8 +725,15 @@ async function pushByUIDs(uids, client, options, modifyKey = null, transactionKe
725
725
  }
726
726
  if (toCheck.length > 0) {
727
727
  const appConfig = await loadAppConfig();
728
- const proceed = await checkToeStepping(toCheck, client, baseline, options, appConfig?.AppShortName);
729
- if (!proceed) return;
728
+ const result = await checkToeStepping(toCheck, client, baseline, options, appConfig?.AppShortName);
729
+ if (result === false) return; // user cancelled entirely
730
+ if (result instanceof Set) {
731
+ // Filter out skipped UIDs from matches
732
+ for (let i = matches.length - 1; i >= 0; i--) {
733
+ if (result.has(matches[i].meta?.UID)) matches.splice(i, 1);
734
+ }
735
+ if (matches.length === 0) return;
736
+ }
730
737
  }
731
738
  }
732
739
 
package/src/lib/client.js CHANGED
@@ -124,13 +124,12 @@ export class DboClient {
124
124
  }
125
125
 
126
126
  /**
127
- * Clear the server-side cache. Must be called after POST transactions so that
128
- * subsequent GET requests (diff, pull, toe-stepping) return fresh data.
127
+ * No-op placeholder. Server-side cache voiding (/?voidcache=true) has been
128
+ * disabled as it was causing server issues. Callers remain unchanged so the
129
+ * hook can be re-enabled later if needed.
129
130
  */
130
131
  async voidCache() {
131
- try {
132
- await this.request('/?voidcache=true');
133
- } catch { /* best-effort — don't block on failure */ }
132
+ // intentionally empty
134
133
  }
135
134
 
136
135
  /**
@@ -1,7 +1,9 @@
1
- import { dirname, basename } from 'path';
1
+ import chalk from 'chalk';
2
+ import { dirname, basename, join } from 'path';
2
3
  import { readFile } from 'fs/promises';
3
4
  import { findBaselineEntry, shouldSkipColumn, normalizeValue, isReference, resolveReferencePath } from './delta.js';
4
5
  import { resolveContentValue } from '../commands/clone.js';
6
+ import { computeLineDiff, formatDiff } from './diff.js';
5
7
  import { log } from './logger.js';
6
8
 
7
9
  /**
@@ -12,6 +14,14 @@ import { log } from './logger.js';
12
14
  */
13
15
  const DIFF_ALLOW = new Set(['_LastUpdated', '_LastUpdatedUserID']);
14
16
 
17
+ /**
18
+ * Metadata-only columns that don't represent real user edits.
19
+ * When the server diff contains ONLY these columns, the conflict is
20
+ * considered a timestamp-only change and is silently resolved by accepting
21
+ * the server values without prompting the user.
22
+ */
23
+ const METADATA_ONLY_COLS = new Set(['_LastUpdated', '_LastUpdatedUserID', '_children']);
24
+
15
25
  /**
16
26
  * Fetch a single record from the server by entity name + row UID.
17
27
  *
@@ -274,7 +284,8 @@ function findOldestBaselineDate(records, baseline) {
274
284
  * @param {Object} baseline - Loaded baseline from .dbo/.app_baseline.json
275
285
  * @param {Object} options - Commander options (options.yes used for auto-accept)
276
286
  * @param {string} [appShortName] - App short name for bulk fetch (optional)
277
- * @returns {Promise<boolean>} - true = proceed, false = user cancelled
287
+ * @returns {Promise<boolean|Set<string>>} - true = proceed with all,
288
+ * false = user cancelled entirely, Set<string> = UIDs to skip (proceed with rest)
278
289
  */
279
290
  export async function checkToeStepping(records, client, baseline, options, appShortName) {
280
291
  // Build list of records to check (skip new records without UID)
@@ -302,6 +313,8 @@ export async function checkToeStepping(records, client, baseline, options, appSh
302
313
 
303
314
  // Fall back to per-record fetches if batch returned nothing or wasn't available
304
315
  if (!serverRecords || serverRecords.size === 0) {
316
+ // Void cache if batch path was skipped (batch already calls voidCache internally)
317
+ if (!appShortName) await client.voidCache();
305
318
  spinner.text = `Fetching ${requests.length} record(s) from server...`;
306
319
  serverRecords = await fetchServerRecords(client, requests);
307
320
  }
@@ -314,7 +327,11 @@ export async function checkToeStepping(records, client, baseline, options, appSh
314
327
 
315
328
  spinner.succeed(`Fetched ${serverRecords.size} record(s) from server`);
316
329
 
317
- const conflicts = [];
330
+ // Detect conflicts and prompt per-record
331
+ const inquirer = (await import('inquirer')).default;
332
+ let skippedUIDs = new Set();
333
+ let bulkAction = null; // 'push_all' | 'skip_all'
334
+ let hasConflicts = false;
318
335
 
319
336
  for (const { meta, metaPath } of records) {
320
337
  const uid = meta.UID;
@@ -343,39 +360,135 @@ export async function checkToeStepping(records, client, baseline, options, appSh
343
360
  const metaDir = dirname(metaPath);
344
361
  const label = basename(metaPath, '.metadata.json');
345
362
  const diffColumns = await buildRecordDiff(serverEntry, baselineEntry, meta, metaDir);
363
+
364
+ // If the only server-side changes are metadata columns (_LastUpdated,
365
+ // _LastUpdatedUserID) with no real data edits, silently accept the
366
+ // server timestamps — no conflict to resolve.
367
+ const realChanges = diffColumns.filter(d => !METADATA_ONLY_COLS.has(d.col));
368
+ if (realChanges.length === 0) continue;
369
+
370
+ hasConflicts = true;
371
+
346
372
  const serverUser = serverEntry._LastUpdatedUserID || 'unknown';
347
373
 
348
374
  displayConflict(label, serverUser, serverTs, diffColumns);
349
- conflicts.push({ label, serverUser });
350
- }
351
375
 
352
- if (conflicts.length === 0) return true; // no conflicts
376
+ // Auto-accept or auto-skip based on bulk action
377
+ if (options.yes || bulkAction === 'push_all') {
378
+ continue; // push this record
379
+ }
380
+ if (bulkAction === 'skip_all') {
381
+ skippedUIDs.add(uid);
382
+ continue;
383
+ }
353
384
 
354
- log.warn('');
355
- log.warn(` ${conflicts.length} record(s) have server changes that would be overwritten.`);
356
- log.warn('');
385
+ // Per-record prompt with compare option
386
+ let action;
387
+ while (true) {
388
+ ({ action } = await inquirer.prompt([{
389
+ type: 'list',
390
+ name: 'action',
391
+ message: `"${label}" has server changes. How to proceed?`,
392
+ choices: [
393
+ { name: 'Push anyway (overwrite server changes)', value: 'push' },
394
+ { name: 'Compare differences', value: 'compare' },
395
+ { name: 'Skip this record', value: 'skip' },
396
+ { name: 'Push all remaining (overwrite all)', value: 'push_all' },
397
+ { name: 'Skip all remaining', value: 'skip_all' },
398
+ { name: 'Cancel entire push', value: 'cancel' },
399
+ ],
400
+ }]));
401
+
402
+ if (action === 'compare') {
403
+ await showPushDiff(serverEntry, meta, metaPath);
404
+ continue; // re-prompt after showing diff
405
+ }
406
+ break;
407
+ }
357
408
 
358
- if (options.yes) {
359
- log.dim(' --yes flag: proceeding despite server conflicts');
360
- return true;
409
+ if (action === 'cancel') {
410
+ log.info('Push cancelled. Run "dbo pull" to fetch server changes first.');
411
+ return false;
412
+ }
413
+ if (action === 'skip' || action === 'skip_all') {
414
+ skippedUIDs.add(uid);
415
+ if (action === 'skip_all') bulkAction = 'skip_all';
416
+ }
417
+ if (action === 'push_all') {
418
+ bulkAction = 'push_all';
419
+ }
361
420
  }
362
421
 
363
- const inquirer = (await import('inquirer')).default;
364
- const { action } = await inquirer.prompt([{
365
- type: 'list',
366
- name: 'action',
367
- message: 'Server has newer changes. How would you like to proceed?',
368
- choices: [
369
- { name: 'Overwrite server changes (push anyway)', value: 'overwrite' },
370
- { name: 'Cancel — pull server changes first', value: 'cancel' },
371
- ],
372
- }]);
373
-
374
- if (action === 'cancel') {
375
- log.info('Push cancelled. Run "dbo pull" to fetch server changes first.');
376
- return false;
422
+ if (!hasConflicts) return true;
423
+
424
+ // Return skipped UIDs so the caller can filter them out
425
+ if (skippedUIDs.size > 0) {
426
+ log.dim(` Skipping ${skippedUIDs.size} conflicting record(s)`);
427
+ return skippedUIDs;
377
428
  }
378
429
 
379
- log.dim(' Proceeding — local changes will overwrite server state.');
380
430
  return true;
381
431
  }
432
+
433
+ /**
434
+ * Show a read-only diff between the server state and local metadata/content
435
+ * for a single record during push toe-stepping.
436
+ *
437
+ * Green (+) = local (what you're about to push)
438
+ * Red (-) = server (what would be overwritten)
439
+ */
440
+ async function showPushDiff(serverEntry, localMeta, metaPath) {
441
+ const metaDir = dirname(metaPath);
442
+ const contentCols = localMeta._contentColumns || [];
443
+
444
+ // Compare content file columns
445
+ for (const col of contentCols) {
446
+ const localRef = localMeta[col];
447
+ if (!localRef || !String(localRef).startsWith('@')) continue;
448
+
449
+ const localFilePath = join(metaDir, String(localRef).substring(1));
450
+ let localContent = '';
451
+ try {
452
+ localContent = await readFile(localFilePath, 'utf8');
453
+ } catch {
454
+ localContent = '';
455
+ }
456
+
457
+ const serverValue = resolveContentValue(serverEntry[col]);
458
+ if (serverValue === null) continue;
459
+
460
+ if (localContent !== serverValue) {
461
+ // Green = local (what we're pushing), Red = server (what gets overwritten)
462
+ const diff = computeLineDiff(serverValue, localContent);
463
+ log.plain('');
464
+ log.label('Field', `${col} (content file)`);
465
+ const formatted = formatDiff(diff, {
466
+ localLabel: `server: ${col}`,
467
+ serverLabel: `local: ${col}`,
468
+ });
469
+ for (const line of formatted) log.plain(line);
470
+ }
471
+ }
472
+
473
+ // Compare metadata fields
474
+ const skipFields = new Set(['_entity', '_contentColumns', '_mediaFile', 'children', '_pathConfirmed']);
475
+ for (const col of Object.keys(serverEntry)) {
476
+ if (skipFields.has(col)) continue;
477
+ if (contentCols.includes(col)) continue;
478
+ if (shouldSkipColumn(col)) continue;
479
+
480
+ const serverVal = serverEntry[col];
481
+ const localVal = localMeta[col];
482
+ const serverStr = serverVal != null ? String(resolveContentValue(serverVal) ?? serverVal) : '';
483
+ const localStr = localVal != null ? String(localVal) : '';
484
+
485
+ if (serverStr !== localStr) {
486
+ log.plain('');
487
+ log.label('Field', col);
488
+ if (serverStr) log.plain(chalk.red(` - server: ${serverStr.substring(0, 200)}`));
489
+ if (localStr) log.plain(chalk.green(` + local: ${localStr.substring(0, 200)}`));
490
+ }
491
+ }
492
+
493
+ log.plain('');
494
+ }
@@ -0,0 +1,91 @@
1
+ import { readdir, rename, access } from 'fs/promises';
2
+ import { join } from 'path';
3
+ import { log } from '../lib/logger.js';
4
+
5
+ export const description = 'Rename output .json files to .metadata.json for consistency';
6
+
7
+ /**
8
+ * Migration 005 — Rename output root metadata files from .json to .metadata.json.
9
+ *
10
+ * Old: Sales~abc123.json
11
+ * New: Sales~abc123.metadata.json
12
+ *
13
+ * Only renames root output files (contain ~ and end with .json).
14
+ * Skips .CustomSQL.sql companions, old-format child files (.column~, .join~, .filter~),
15
+ * and files that already use .metadata.json.
16
+ */
17
+ export default async function run(_options) {
18
+ const cwd = process.cwd();
19
+ let totalRenamed = 0;
20
+
21
+ const dirs = await findDirsWithOutputJsonFiles(cwd);
22
+
23
+ for (const dir of dirs) {
24
+ const entries = await readdir(dir);
25
+
26
+ for (const name of entries) {
27
+ // Must be a .json file with ~ (UID separator) that is NOT already .metadata.json
28
+ if (!name.endsWith('.json')) continue;
29
+ if (name.endsWith('.metadata.json')) continue;
30
+ if (!name.includes('~')) continue;
31
+
32
+ // Skip CustomSQL companions
33
+ if (name.includes('.CustomSQL.')) continue;
34
+
35
+ // Skip old-format child files (.column~, .join~, .filter~)
36
+ if (/\.(column|join|filter)~/.test(name)) continue;
37
+
38
+ const newName = name.replace(/\.json$/, '.metadata.json');
39
+ const oldPath = join(dir, name);
40
+ const newPath = join(dir, newName);
41
+
42
+ // Skip if destination already exists
43
+ try {
44
+ await access(newPath);
45
+ continue;
46
+ } catch { /* good — doesn't exist */ }
47
+
48
+ await rename(oldPath, newPath);
49
+ totalRenamed++;
50
+ }
51
+ }
52
+
53
+ if (totalRenamed > 0) {
54
+ log.dim(` Renamed ${totalRenamed} output file${totalRenamed === 1 ? '' : 's'} (.json → .metadata.json)`);
55
+ }
56
+ }
57
+
58
+ /**
59
+ * Recursively find directories that contain output .json files (with ~ in name).
60
+ * Skips .dbo/, node_modules/, trash/, .git/.
61
+ */
62
+ async function findDirsWithOutputJsonFiles(root) {
63
+ const SKIP = new Set(['.dbo', 'node_modules', 'trash', '.git', '.claude']);
64
+ const results = [];
65
+
66
+ async function walk(dir) {
67
+ let entries;
68
+ try { entries = await readdir(dir, { withFileTypes: true }); } catch { return; }
69
+
70
+ let hasOutputFiles = false;
71
+ for (const entry of entries) {
72
+ if (entry.isDirectory()) {
73
+ if (!SKIP.has(entry.name)) {
74
+ await walk(join(dir, entry.name));
75
+ }
76
+ } else if (
77
+ entry.name.endsWith('.json') &&
78
+ !entry.name.endsWith('.metadata.json') &&
79
+ entry.name.includes('~') &&
80
+ !entry.name.includes('.CustomSQL.') &&
81
+ !/\.(column|join|filter)~/.test(entry.name)
82
+ ) {
83
+ hasOutputFiles = true;
84
+ }
85
+ }
86
+ if (hasOutputFiles) results.push(dir);
87
+ }
88
+
89
+ await walk(root);
90
+ return results;
91
+ }