@dboio/cli 0.11.3 → 0.13.2

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.
Files changed (50) hide show
  1. package/README.md +126 -3
  2. package/bin/dbo.js +4 -0
  3. package/package.json +1 -1
  4. package/plugins/claude/dbo/.claude-plugin/plugin.json +1 -1
  5. package/plugins/claude/dbo/commands/dbo.md +65 -244
  6. package/plugins/claude/dbo/docs/_audit_required/API/all.md +40 -0
  7. package/plugins/claude/dbo/docs/_audit_required/API/app.md +38 -0
  8. package/plugins/claude/dbo/docs/_audit_required/API/athenticate.md +26 -0
  9. package/plugins/claude/dbo/docs/_audit_required/API/cache.md +29 -0
  10. package/plugins/claude/dbo/docs/_audit_required/API/content.md +14 -0
  11. package/plugins/claude/dbo/docs/_audit_required/API/data_source.md +28 -0
  12. package/plugins/claude/dbo/docs/_audit_required/API/email.md +18 -0
  13. package/plugins/claude/dbo/docs/_audit_required/API/input.md +25 -0
  14. package/plugins/claude/dbo/docs/_audit_required/API/instance.md +28 -0
  15. package/plugins/claude/dbo/docs/_audit_required/API/log.md +8 -0
  16. package/plugins/claude/dbo/docs/_audit_required/API/media.md +12 -0
  17. package/plugins/claude/dbo/docs/_audit_required/API/output_by_entity.md +12 -0
  18. package/plugins/claude/dbo/docs/_audit_required/API/upload.md +7 -0
  19. package/plugins/claude/dbo/docs/_audit_required/dbo-api-syntax.md +1487 -0
  20. package/plugins/claude/dbo/docs/_audit_required/dbo-problems-code.md +111 -0
  21. package/plugins/claude/dbo/docs/_audit_required/dbo-problems-performance.md +109 -0
  22. package/plugins/claude/dbo/docs/_audit_required/dbo-problems-syntax.md +97 -0
  23. package/plugins/claude/dbo/docs/_audit_required/dbo-product-market.md +119 -0
  24. package/plugins/claude/dbo/docs/_audit_required/dbo-white-paper.md +125 -0
  25. package/plugins/claude/dbo/docs/dbo-cheat-sheet.md +323 -0
  26. package/plugins/claude/dbo/docs/dbo-cli-readme.md +2222 -0
  27. package/plugins/claude/dbo/docs/dbo-core-entities.md +878 -0
  28. package/plugins/claude/dbo/docs/dbo-output-customsql.md +677 -0
  29. package/plugins/claude/dbo/docs/dbo-output-query.md +967 -0
  30. package/plugins/claude/dbo/skills/cli/SKILL.md +62 -246
  31. package/src/commands/add.js +366 -62
  32. package/src/commands/build.js +102 -0
  33. package/src/commands/clone.js +602 -139
  34. package/src/commands/diff.js +4 -0
  35. package/src/commands/init.js +16 -2
  36. package/src/commands/input.js +3 -1
  37. package/src/commands/mv.js +12 -4
  38. package/src/commands/push.js +265 -70
  39. package/src/commands/rm.js +16 -3
  40. package/src/commands/run.js +81 -0
  41. package/src/lib/client.js +4 -7
  42. package/src/lib/config.js +39 -0
  43. package/src/lib/delta.js +7 -1
  44. package/src/lib/diff.js +24 -2
  45. package/src/lib/filenames.js +120 -41
  46. package/src/lib/ignore.js +6 -0
  47. package/src/lib/input-parser.js +13 -4
  48. package/src/lib/scripts.js +232 -0
  49. package/src/lib/toe-stepping.js +17 -2
  50. package/src/migrations/006-remove-uid-companion-filenames.js +181 -0
@@ -1,14 +1,15 @@
1
1
  import { Command } from 'commander';
2
- import { readFile, writeFile, appendFile, mkdir, access, readdir, rename } from 'fs/promises';
2
+ import { readFile, writeFile, appendFile, mkdir, access, readdir, rename, stat } from 'fs/promises';
3
3
  import { join, basename, extname, dirname } from 'path';
4
4
  import { fileURLToPath } from 'url';
5
5
  import { DboClient } from '../lib/client.js';
6
6
  import { loadConfig, updateConfigWithApp, loadClonePlacement, saveClonePlacement, ensureGitignore, saveEntityDirPreference, loadEntityDirPreference, saveEntityContentExtractions, loadEntityContentExtractions, saveAppJsonBaseline, addDeleteEntry, loadCollisionResolutions, saveCollisionResolutions, loadSynchronize, saveSynchronize, saveAppModifyKey, loadTransactionKeyPreset, saveTransactionKeyPreset, loadOutputFilenamePreference, saveOutputFilenamePreference, saveCloneSource, loadCloneSource, saveDescriptorFilenamePreference, loadDescriptorFilenamePreference, saveDescriptorContentExtractions, loadDescriptorContentExtractions, saveExtensionDocumentationMDPlacement, loadExtensionDocumentationMDPlacement } from '../lib/config.js';
7
7
  import { buildBinHierarchy, resolveBinPath, createDirectories, saveStructureFile, findBinByPath, BINS_DIR, DEFAULT_PROJECT_DIRS, SCAFFOLD_DIRS, ENTITY_DIR_NAMES, OUTPUT_ENTITY_MAP, OUTPUT_HIERARCHY_ENTITIES, EXTENSION_DESCRIPTORS_DIR, EXTENSION_UNSUPPORTED_DIR, DOCUMENTATION_DIR, buildDescriptorMapping, saveDescriptorMapping, loadDescriptorMapping, resolveExtensionSubDir, resolveEntityDirPath } from '../lib/structure.js';
8
8
  import { log } from '../lib/logger.js';
9
- import { buildUidFilename, detectLegacyDotUid } from '../lib/filenames.js';
9
+ import { buildUidFilename, buildContentFileName, stripUidFromFilename, hasUidInFilename, detectLegacyDotUid } from '../lib/filenames.js';
10
10
  import { setFileTimestamps, parseServerDate } from '../lib/timestamps.js';
11
- import { getLocalSyncTime, isServerNewer, hasLocalModifications, promptChangeDetection, inlineDiffAndMerge, isDiffable, loadBaselineForComparison, resetBaselineCache } from '../lib/diff.js';
11
+ import { getLocalSyncTime, isServerNewer, hasLocalModifications, promptChangeDetection, inlineDiffAndMerge, isDiffable, loadBaselineForComparison, resetBaselineCache, findMetadataFiles } from '../lib/diff.js';
12
+ import { loadIgnore } from '../lib/ignore.js';
12
13
  import { checkDomainChange } from '../lib/domain-guard.js';
13
14
  import { applyTrashIcon, ensureTrashIcon } from '../lib/folder-icon.js';
14
15
  import { loadMetadataTemplates, saveMetadataTemplates, getTemplateCols, setTemplateCols, buildTemplateFromCloneRecord } from '../lib/metadata-templates.js';
@@ -43,6 +44,143 @@ function isWillDeleteFile(filename) {
43
44
  return basename(filename).startsWith(WILL_DELETE_PREFIX);
44
45
  }
45
46
 
47
+ /**
48
+ * Collect all UIDs from the freshly fetched app JSON.
49
+ * Traverses all entity types in appJson.children.
50
+ *
51
+ * @param {object} appJson - Parsed app JSON with children map
52
+ * @returns {Set<string>} Set of all UIDs present on the server
53
+ */
54
+ export function collectServerUids(appJson) {
55
+ const uids = new Set();
56
+ if (!appJson?.children || typeof appJson.children !== 'object') return uids;
57
+
58
+ for (const [entityName, entries] of Object.entries(appJson.children)) {
59
+ if (!Array.isArray(entries)) continue;
60
+ for (const entry of entries) {
61
+ if (entry && typeof entry === 'object' && entry.UID) {
62
+ uids.add(String(entry.UID));
63
+ }
64
+ // Output hierarchy: collect nested child UIDs too
65
+ if (entry && typeof entry === 'object' && entry.children) {
66
+ for (const childArr of Object.values(entry.children)) {
67
+ if (!Array.isArray(childArr)) continue;
68
+ for (const child of childArr) {
69
+ if (child && typeof child === 'object' && child.UID) {
70
+ uids.add(String(child.UID));
71
+ }
72
+ }
73
+ }
74
+ }
75
+ }
76
+ }
77
+
78
+ return uids;
79
+ }
80
+
81
+ /**
82
+ * Detect local metadata files whose UIDs are absent from the server response,
83
+ * then move them and their companion files to trash/.
84
+ *
85
+ * @param {object} appJson - Fresh app JSON from server
86
+ * @param {import('ignore').Ignore} ig - Ignore instance for findMetadataFiles
87
+ * @param {object} sync - Parsed synchronize.json { delete, edit, add }
88
+ * @param {object} options - Clone options ({ entityFilter?, verbose? })
89
+ */
90
+ export async function detectAndTrashOrphans(appJson, ig, sync, options) {
91
+ if (options.entityFilter) return;
92
+ if (!appJson?.children) return;
93
+
94
+ const serverUids = collectServerUids(appJson);
95
+ if (serverUids.size === 0) return;
96
+
97
+ // UIDs already queued for deletion in synchronize.json
98
+ const stagedDeleteUids = new Set(
99
+ (sync.delete || []).map(e => e.UID).filter(Boolean).map(String)
100
+ );
101
+
102
+ const metaFiles = await findMetadataFiles(process.cwd(), ig);
103
+ if (metaFiles.length === 0) return;
104
+
105
+ const trashDir = join(process.cwd(), 'trash');
106
+ const orphans = [];
107
+
108
+ for (const metaPath of metaFiles) {
109
+ let meta;
110
+ try {
111
+ meta = JSON.parse(await readFile(metaPath, 'utf8'));
112
+ } catch {
113
+ continue;
114
+ }
115
+
116
+ if (!meta.UID) continue;
117
+
118
+ const uid = String(meta.UID);
119
+ if (stagedDeleteUids.has(uid)) continue;
120
+ if (serverUids.has(uid)) continue;
121
+
122
+ // Orphan — collect files to move
123
+ const metaDir = dirname(metaPath);
124
+ const filesToMove = [metaPath];
125
+
126
+ for (const col of (meta._contentColumns || [])) {
127
+ const ref = meta[col];
128
+ if (ref && String(ref).startsWith('@')) {
129
+ const refName = String(ref).substring(1);
130
+ const companionPath = refName.startsWith('/')
131
+ ? join(process.cwd(), refName)
132
+ : join(metaDir, refName);
133
+ if (await fileExists(companionPath)) {
134
+ filesToMove.push(companionPath);
135
+ }
136
+ }
137
+ }
138
+
139
+ if (meta._mediaFile && String(meta._mediaFile).startsWith('@')) {
140
+ const refName = String(meta._mediaFile).substring(1);
141
+ const mediaPath = refName.startsWith('/')
142
+ ? join(process.cwd(), refName)
143
+ : join(metaDir, refName);
144
+ if (await fileExists(mediaPath)) {
145
+ filesToMove.push(mediaPath);
146
+ }
147
+ }
148
+
149
+ orphans.push({ metaPath, uid, entity: meta._entity || 'unknown', filesToMove });
150
+ }
151
+
152
+ if (orphans.length === 0) return;
153
+
154
+ await mkdir(trashDir, { recursive: true });
155
+
156
+ let trashed = 0;
157
+
158
+ for (const { metaPath, uid, entity, filesToMove } of orphans) {
159
+ log.dim(` Trashed: ${basename(metaPath)} (${entity}:${uid})`);
160
+
161
+ for (const filePath of filesToMove) {
162
+ const destBase = basename(filePath);
163
+ let destPath = join(trashDir, destBase);
164
+
165
+ // Collision: append timestamp suffix (same pattern as moveWillDeleteToTrash in push.js)
166
+ try { await stat(destPath); destPath = `${destPath}.${Date.now()}`; } catch {}
167
+
168
+ try {
169
+ await rename(filePath, destPath);
170
+ trashed++;
171
+ } catch (err) {
172
+ log.warn(` Could not trash: ${filePath} — ${err.message}`);
173
+ }
174
+ }
175
+ }
176
+
177
+ if (trashed > 0) {
178
+ await ensureTrashIcon(trashDir);
179
+ log.plain('');
180
+ log.warn(`Moved ${orphans.length} orphaned record(s) to trash (deleted on server)`);
181
+ }
182
+ }
183
+
46
184
  /**
47
185
  * Resolve a content Path to a directory under Bins/.
48
186
  *
@@ -144,9 +282,11 @@ export function resolveRecordPaths(entityName, record, structure, placementPref)
144
282
  }
145
283
 
146
284
  const uid = String(record.UID || record._id || 'untitled');
147
- const base = buildUidFilename(name, uid);
148
- const filename = ext ? `${base}.${ext}` : base;
149
- const metaPath = join(dir, `${base}.metadata.json`);
285
+ // Companion: natural name, no UID
286
+ const filename = sanitizeFilename(buildContentFileName(record, uid));
287
+ // Metadata: still uses ~UID
288
+ const metaBase = buildUidFilename(name, uid);
289
+ const metaPath = join(dir, `${metaBase}.metadata.json`);
150
290
 
151
291
  return { dir, filename, metaPath };
152
292
  }
@@ -156,9 +296,15 @@ export function resolveRecordPaths(entityName, record, structure, placementPref)
156
296
  * Replicates logic from processMediaEntries() for collision detection.
157
297
  */
158
298
  export function resolveMediaPaths(record, structure) {
159
- const filename = record.Filename || `${record.Name || record.UID}.${(record.Extension || 'bin').toLowerCase()}`;
160
- const name = sanitizeFilename(filename.replace(/\.[^.]+$/, ''));
161
- const ext = (record.Extension || 'bin').toLowerCase();
299
+ // Companion: use Filename column directly (natural name, no UID)
300
+ const companionFilename = sanitizeFilename(
301
+ record.Filename
302
+ || `${record.Name || record.UID}.${(record.Extension || 'bin').toLowerCase()}`
303
+ );
304
+
305
+ // Base name and ext for metadata naming
306
+ const name = sanitizeFilename(companionFilename.replace(/\.[^.]+$/, ''));
307
+ const ext = (record.Extension || extname(companionFilename).substring(1) || 'bin').toLowerCase();
162
308
 
163
309
  // Always place media by BinID; fall back to bins/ root
164
310
  let dir = BINS_DIR;
@@ -169,12 +315,12 @@ export function resolveMediaPaths(record, structure) {
169
315
  dir = dir.replace(/^\/+|\/+$/g, '');
170
316
  if (!dir) dir = BINS_DIR;
171
317
 
318
+ // Metadata: name~uid.ext.metadata.json (unchanged format)
172
319
  const uid = String(record.UID || record._id || 'untitled');
173
- const base = buildUidFilename(name, uid);
174
- const finalFilename = `${base}.${ext}`;
175
- const metaPath = join(dir, `${finalFilename}.metadata.json`);
320
+ const metaBase = buildUidFilename(name, uid);
321
+ const metaPath = join(dir, `${metaBase}.${ext}.metadata.json`);
176
322
 
177
- return { dir, filename: finalFilename, metaPath };
323
+ return { dir, filename: companionFilename, metaPath };
178
324
  }
179
325
 
180
326
  /**
@@ -197,6 +343,224 @@ export function resolveEntityDirPaths(entityName, record, dirName) {
197
343
  return { dir: dirName, filename: finalName, metaPath };
198
344
  }
199
345
 
346
+ /**
347
+ * Resolve companion filename collisions within a shared BinID directory.
348
+ *
349
+ * Mutates the `filename` field in each entry to apply collision suffixes:
350
+ * - Content vs media same name → media gets "(media)" before extension
351
+ * - Same entity type duplicates → 2nd+ get "-1", "-2", ... suffix
352
+ *
353
+ * Companion files never contain ~UID. The metadata @reference stores the
354
+ * collision-suffixed name, so dbo rm/push/diff find metadata via @reference
355
+ * scan rather than filename derivation.
356
+ *
357
+ * @param {Array<{entity: string, uid: string, filename: string, dir: string}>} entries
358
+ */
359
+ export function resolveFilenameCollisions(entries) {
360
+ // Group by dir + filename
361
+ const byPath = new Map();
362
+ for (const entry of entries) {
363
+ const key = `${entry.dir}/${entry.filename}`;
364
+ if (!byPath.has(key)) byPath.set(key, []);
365
+ byPath.get(key).push(entry);
366
+ }
367
+
368
+ for (const group of byPath.values()) {
369
+ if (group.length <= 1) continue;
370
+
371
+ const contentGroup = group.filter(e => e.entity === 'content');
372
+ const mediaGroup = group.filter(e => e.entity === 'media');
373
+
374
+ // Content wins: media gets (media) suffix
375
+ if (contentGroup.length > 0 && mediaGroup.length > 0) {
376
+ for (const m of mediaGroup) {
377
+ const ext = extname(m.filename);
378
+ const base = basename(m.filename, ext);
379
+ m.filename = `${base}(media)${ext}`;
380
+ }
381
+ }
382
+
383
+ // Same-entity duplicates: -1, -2, ... suffix (by insertion order)
384
+ for (const sameType of [contentGroup, mediaGroup]) {
385
+ if (sameType.length <= 1) continue;
386
+ for (let i = 1; i < sameType.length; i++) {
387
+ const ext = extname(sameType[i].filename);
388
+ const base = basename(sameType[i].filename, ext);
389
+ sameType[i].filename = `${base}-${i}${ext}`;
390
+ }
391
+ }
392
+ }
393
+ }
394
+
395
+ /**
396
+ * If companion files on disk still use legacy ~UID naming,
397
+ * rename them to the natural filename and update @references in metadata.
398
+ *
399
+ * Handles two cases:
400
+ * A) @reference itself contains ~UID (e.g. "@colors~uid.css") → strip ~UID,
401
+ * rename file, update @reference in metadata, rewrite metadata file.
402
+ * B) @reference is already natural but file on disk has ~UID → just rename file.
403
+ *
404
+ * Returns true if any metadata @references were updated (caller should NOT
405
+ * overwrite metaPath after this — it's already been rewritten).
406
+ */
407
+ async function detectAndRenameLegacyCompanions(metaPath, meta) {
408
+ const uid = meta.UID;
409
+ if (!uid) return false;
410
+
411
+ const metaDir = dirname(metaPath);
412
+ const contentCols = [...(meta._contentColumns || [])];
413
+ if (meta._mediaFile) contentCols.push('_mediaFile');
414
+ let metaChanged = false;
415
+
416
+ for (const col of contentCols) {
417
+ const ref = meta[col];
418
+ if (!ref || !String(ref).startsWith('@')) continue;
419
+
420
+ const refName = String(ref).substring(1);
421
+ // Resolve paths: @/ references are root-relative, others are metaDir-relative
422
+ const resolveRef = (name) => name.startsWith('/')
423
+ ? join(process.cwd(), name)
424
+ : join(metaDir, name);
425
+
426
+ // Case A: @reference itself contains ~UID — strip it
427
+ if (hasUidInFilename(refName, uid)) {
428
+ const naturalName = stripUidFromFilename(refName, uid);
429
+ const legacyPath = resolveRef(refName);
430
+ const naturalPath = resolveRef(naturalName);
431
+ const legacyExists = await fileExists(legacyPath);
432
+ const naturalExists = await fileExists(naturalPath);
433
+
434
+ if (legacyExists && !naturalExists) {
435
+ // Rename legacy → natural
436
+ try {
437
+ await mkdir(dirname(naturalPath), { recursive: true });
438
+ await rename(legacyPath, naturalPath);
439
+ log.dim(` Legacy companion renamed: ${basename(legacyPath)} → ${basename(naturalPath)}`);
440
+ } catch { /* rename failed */ }
441
+ } else if (legacyExists && naturalExists) {
442
+ // Both exist (clone downloaded fresh copy) — move orphaned legacy file to trash
443
+ try {
444
+ const trashDir = join(process.cwd(), 'trash');
445
+ await mkdir(trashDir, { recursive: true });
446
+ await rename(legacyPath, join(trashDir, basename(legacyPath)));
447
+ await ensureTrashIcon(trashDir);
448
+ log.dim(` Trashed orphan legacy file: ${basename(legacyPath)}`);
449
+ } catch { /* non-critical */ }
450
+ }
451
+
452
+ // Update @reference regardless (even if file rename failed, fix the reference)
453
+ meta[col] = `@${naturalName}`;
454
+ metaChanged = true;
455
+ continue;
456
+ }
457
+
458
+ // Case B: @reference is natural but file on disk might still have ~UID
459
+ const naturalPath = resolveRef(refName);
460
+ if (await fileExists(naturalPath)) continue;
461
+
462
+ const ext = extname(refName);
463
+ const base = basename(refName, ext);
464
+ const legacyName = ext ? `${base}~${uid}${ext}` : `${base}~${uid}`;
465
+ const legacyPath = resolveRef(legacyName);
466
+
467
+ if (await fileExists(legacyPath)) {
468
+ try {
469
+ await mkdir(dirname(naturalPath), { recursive: true });
470
+ await rename(legacyPath, naturalPath);
471
+ log.dim(` Legacy companion renamed: ${basename(legacyPath)} → ${basename(naturalPath)}`);
472
+ } catch { /* rename failed */ }
473
+ }
474
+ }
475
+
476
+ // Rewrite metadata file if @references were updated
477
+ if (metaChanged) {
478
+ try {
479
+ await writeFile(metaPath, JSON.stringify(meta, null, 2) + '\n');
480
+ } catch { /* non-critical */ }
481
+ }
482
+
483
+ return metaChanged;
484
+ }
485
+
486
+ /**
487
+ * Scan all directories under Bins/ for orphaned legacy ~UID companion files
488
+ * that no metadata @reference points to, and move them to trash/.
489
+ *
490
+ * A file is considered an orphan if:
491
+ * - It contains ~ in its name (potential legacy ~UID naming)
492
+ * - It's NOT a .metadata.json file
493
+ * - No .metadata.json in the same directory has an @reference pointing to it
494
+ */
495
+ async function trashOrphanedLegacyCompanions() {
496
+ const binsDir = join(process.cwd(), BINS_DIR);
497
+ if (!await fileExists(binsDir)) return;
498
+
499
+ // Collect all @references from all metadata files, and all non-metadata files with ~
500
+ const referencedFiles = new Set(); // Set of absolute paths referenced by metadata
501
+ const tildeFiles = []; // non-metadata files containing ~
502
+
503
+ async function scan(dir) {
504
+ let entries;
505
+ try { entries = await readdir(dir, { withFileTypes: true }); } catch { return; }
506
+
507
+ for (const entry of entries) {
508
+ if (entry.name.startsWith('.')) continue;
509
+ const full = join(dir, entry.name);
510
+
511
+ if (entry.isDirectory()) {
512
+ await scan(full);
513
+ continue;
514
+ }
515
+
516
+ if (entry.name.endsWith('.metadata.json')) {
517
+ // Read metadata and collect all @references
518
+ try {
519
+ const meta = JSON.parse(await readFile(full, 'utf8'));
520
+ const cols = [...(meta._contentColumns || [])];
521
+ if (meta._mediaFile) cols.push('_mediaFile');
522
+ for (const col of cols) {
523
+ const ref = meta[col];
524
+ if (ref && String(ref).startsWith('@')) {
525
+ const refName = String(ref).substring(1);
526
+ // Handle both relative and @/ (root-relative) references
527
+ if (refName.startsWith('/')) {
528
+ referencedFiles.add(join(process.cwd(), refName));
529
+ } else {
530
+ referencedFiles.add(join(dir, refName));
531
+ }
532
+ }
533
+ }
534
+ } catch { /* skip unreadable metadata */ }
535
+ } else if (entry.name.includes('~')) {
536
+ tildeFiles.push(full);
537
+ }
538
+ }
539
+ }
540
+
541
+ await scan(binsDir);
542
+
543
+ // Filter to orphans: ~ files not referenced by any metadata
544
+ const orphans = tildeFiles.filter(f => !referencedFiles.has(f));
545
+ if (orphans.length === 0) return;
546
+
547
+ const trashDir = join(process.cwd(), 'trash');
548
+ await mkdir(trashDir, { recursive: true });
549
+ let trashed = 0;
550
+
551
+ for (const orphan of orphans) {
552
+ try {
553
+ await rename(orphan, join(trashDir, basename(orphan)));
554
+ trashed++;
555
+ } catch { /* non-critical */ }
556
+ }
557
+
558
+ if (trashed > 0) {
559
+ await ensureTrashIcon(trashDir);
560
+ log.dim(` Trashed ${trashed} orphaned legacy ~UID companion file(s)`);
561
+ }
562
+ }
563
+
200
564
  /**
201
565
  * Build global registry of all files that will be created during clone.
202
566
  * Returns Map<filePath, Array<{entity, record, dir, filename, metaPath}>>
@@ -204,25 +568,34 @@ export function resolveEntityDirPaths(entityName, record, dirName) {
204
568
  async function buildFileRegistry(appJson, structure, placementPrefs) {
205
569
  const registry = new Map();
206
570
 
207
- function addToRegistry(filePath, entity, record, dir, filename, metaPath) {
208
- if (!registry.has(filePath)) {
209
- registry.set(filePath, []);
210
- }
211
- registry.get(filePath).push({ entity, record, dir, filename, metaPath });
212
- }
571
+ // Collect all entries first for collision resolution
572
+ const allEntries = [];
213
573
 
214
574
  // Process content records
215
575
  for (const record of (appJson.children.content || [])) {
216
576
  const { dir, filename, metaPath } = resolveRecordPaths(
217
577
  'content', record, structure, placementPrefs.contentPlacement
218
578
  );
219
- addToRegistry(join(dir, filename), 'content', record, dir, filename, metaPath);
579
+ allEntries.push({ entity: 'content', uid: record.UID, record, dir, filename, metaPath });
220
580
  }
221
581
 
222
582
  // Process media records
223
583
  for (const record of (appJson.children.media || [])) {
224
584
  const { dir, filename, metaPath } = resolveMediaPaths(record, structure);
225
- addToRegistry(join(dir, filename), 'media', record, dir, filename, metaPath);
585
+ allEntries.push({ entity: 'media', uid: record.UID, record, dir, filename, metaPath });
586
+ }
587
+
588
+ // Auto-resolve content vs media collisions and same-entity duplicates
589
+ // (content wins, media gets "(media)" suffix; same-entity duplicates get "-N" suffix)
590
+ resolveFilenameCollisions(allEntries);
591
+
592
+ // Build registry with resolved filenames
593
+ for (const entry of allEntries) {
594
+ const filePath = join(entry.dir, entry.filename);
595
+ if (!registry.has(filePath)) {
596
+ registry.set(filePath, []);
597
+ }
598
+ registry.get(filePath).push(entry);
226
599
  }
227
600
 
228
601
  // Note: entity-dir records (Extensions/, Data Sources/, etc.) and generic entities
@@ -803,6 +1176,7 @@ export async function performClone(source, options = {}) {
803
1176
 
804
1177
  // Step 4c: Detect and resolve file path collisions (skip in pull mode and entity-filter mode)
805
1178
  let toDeleteUIDs = new Set();
1179
+ let resolvedFilenames = new Map(); // UID → resolved filename (after collision resolution)
806
1180
  if (!options.pullMode && !entityFilter) {
807
1181
  log.info('Scanning for file path collisions...');
808
1182
  const fileRegistry = await buildFileRegistry(appJson, structure, placementPrefs);
@@ -811,6 +1185,15 @@ export async function performClone(source, options = {}) {
811
1185
  if (toDeleteUIDs.size > 0) {
812
1186
  await stageCollisionDeletions(toDeleteUIDs, appJson, options);
813
1187
  }
1188
+
1189
+ // Build UID → filename map from the collision-resolved registry
1190
+ for (const entries of fileRegistry.values()) {
1191
+ for (const entry of entries) {
1192
+ if (entry.record.UID) {
1193
+ resolvedFilenames.set(entry.record.UID, entry.filename);
1194
+ }
1195
+ }
1196
+ }
814
1197
  }
815
1198
 
816
1199
  // Pre-load previous baseline for fast _LastUpdated string comparison in isServerNewer.
@@ -827,6 +1210,7 @@ export async function performClone(source, options = {}) {
827
1210
  placementPrefs.contentPlacement,
828
1211
  serverTz,
829
1212
  toDeleteUIDs,
1213
+ resolvedFilenames,
830
1214
  );
831
1215
  }
832
1216
 
@@ -840,7 +1224,7 @@ export async function performClone(source, options = {}) {
840
1224
  if (!entityFilter || entityFilter.has('media')) {
841
1225
  const mediaEntries = appJson.children.media || [];
842
1226
  if (mediaEntries.length > 0) {
843
- mediaRefs = await processMediaEntries(mediaEntries, structure, options, config, appJson.ShortName, serverTz, toDeleteUIDs);
1227
+ mediaRefs = await processMediaEntries(mediaEntries, structure, options, config, appJson.ShortName, serverTz, toDeleteUIDs, resolvedFilenames);
844
1228
  }
845
1229
  }
846
1230
 
@@ -894,6 +1278,16 @@ export async function performClone(source, options = {}) {
894
1278
  resetBaselineCache(); // invalidate so next operation reloads the fresh baseline
895
1279
  }
896
1280
 
1281
+ // Step 8.5: Detect and trash orphaned local records (deleted on server)
1282
+ if (!entityFilter) {
1283
+ const ig = await loadIgnore();
1284
+ const sync = await loadSynchronize();
1285
+ await detectAndTrashOrphans(appJson, ig, sync, { ...options, entityFilter });
1286
+ }
1287
+
1288
+ // Step 9: Trash orphaned legacy ~UID companion files that no metadata references
1289
+ await trashOrphanedLegacyCompanions();
1290
+
897
1291
  log.plain('');
898
1292
  const verb = options.pullMode ? 'Pull' : 'Clone';
899
1293
  log.success(entityFilter ? `${verb} complete! (filtered: ${options.entity})` : `${verb} complete!`);
@@ -1133,7 +1527,7 @@ async function updatePackageJson(appJson, config) {
1133
1527
  * Process content entries: write files + metadata, return reference map.
1134
1528
  * Returns array of { uid, metaPath } for app.json reference replacement.
1135
1529
  */
1136
- async function processContentEntries(contents, structure, options, contentPlacement, serverTz, skipUIDs = new Set()) {
1530
+ async function processContentEntries(contents, structure, options, contentPlacement, serverTz, skipUIDs = new Set(), resolvedFilenames = new Map()) {
1137
1531
  if (!contents || contents.length === 0) return [];
1138
1532
 
1139
1533
  const refs = [];
@@ -1151,7 +1545,8 @@ async function processContentEntries(contents, structure, options, contentPlacem
1151
1545
  log.dim(` Skipped ${record.Name || record.UID} (collision rejection)`);
1152
1546
  continue;
1153
1547
  }
1154
- const ref = await processRecord('content', record, structure, options, usedNames, placementPreference, serverTz, bulkAction);
1548
+ const filenameOverride = resolvedFilenames.get(record.UID) || null;
1549
+ const ref = await processRecord('content', record, structure, options, usedNames, placementPreference, serverTz, bulkAction, filenameOverride);
1155
1550
  if (ref) refs.push(ref);
1156
1551
  }
1157
1552
 
@@ -1378,33 +1773,47 @@ async function processEntityDirEntries(entityName, entries, options, serverTz) {
1378
1773
  if (bulkAction.value !== 'overwrite_all') {
1379
1774
  const configWithTz = { ...config, ServerTimezone: serverTz };
1380
1775
  const localSyncTime = await getLocalSyncTime(metaPath);
1381
- const serverNewer = isServerNewer(localSyncTime, record._LastUpdated, configWithTz, 'content', record.UID);
1776
+
1777
+ // If local metadata has no _LastUpdated (e.g. from dbo add), treat as server-newer
1778
+ let localMissingLastUpdated = false;
1779
+ try {
1780
+ const localMeta = JSON.parse(await readFile(metaPath, 'utf8'));
1781
+ if (!localMeta._LastUpdated) localMissingLastUpdated = true;
1782
+ } catch { /* unreadable */ }
1783
+
1784
+ const serverNewer = localMissingLastUpdated || isServerNewer(localSyncTime, record._LastUpdated, configWithTz, 'content', record.UID);
1382
1785
  const serverDate = parseServerDate(record._LastUpdated, serverTz);
1383
1786
 
1384
1787
  if (serverNewer) {
1385
- const action = await promptChangeDetection(finalName, record, configWithTz, {
1386
- serverDate,
1387
- localDate: localSyncTime,
1388
- });
1788
+ // Incomplete metadata (no _LastUpdated) from dbo add — auto-accept without prompting
1789
+ if (localMissingLastUpdated) {
1790
+ log.dim(` Completing metadata: ${finalName}`);
1791
+ // Fall through to write
1792
+ } else {
1793
+ const action = await promptChangeDetection(finalName, record, configWithTz, {
1794
+ serverDate,
1795
+ localDate: localSyncTime,
1796
+ });
1389
1797
 
1390
- if (action === 'skip') {
1391
- log.dim(` Skipped ${finalName}`);
1392
- refs.push({ uid: record.UID, metaPath });
1393
- continue;
1394
- }
1395
- if (action === 'skip_all') {
1396
- bulkAction.value = 'skip_all';
1397
- log.dim(` Skipped ${finalName}`);
1398
- refs.push({ uid: record.UID, metaPath });
1399
- continue;
1400
- }
1401
- if (action === 'overwrite_all') {
1402
- bulkAction.value = 'overwrite_all';
1403
- }
1404
- if (action === 'compare') {
1405
- await inlineDiffAndMerge(record, metaPath, configWithTz);
1406
- refs.push({ uid: record.UID, metaPath });
1407
- continue;
1798
+ if (action === 'skip') {
1799
+ log.dim(` Skipped ${finalName}`);
1800
+ refs.push({ uid: record.UID, metaPath });
1801
+ continue;
1802
+ }
1803
+ if (action === 'skip_all') {
1804
+ bulkAction.value = 'skip_all';
1805
+ log.dim(` Skipped ${finalName}`);
1806
+ refs.push({ uid: record.UID, metaPath });
1807
+ continue;
1808
+ }
1809
+ if (action === 'overwrite_all') {
1810
+ bulkAction.value = 'overwrite_all';
1811
+ }
1812
+ if (action === 'compare') {
1813
+ await inlineDiffAndMerge(record, metaPath, configWithTz);
1814
+ refs.push({ uid: record.UID, metaPath });
1815
+ continue;
1816
+ }
1408
1817
  }
1409
1818
  } else {
1410
1819
  const locallyModified = await hasLocalModifications(metaPath, configWithTz);
@@ -1452,25 +1861,24 @@ async function processEntityDirEntries(entityName, entries, options, serverTz) {
1452
1861
 
1453
1862
  // Check if this column should be extracted as a companion file
1454
1863
  const extractInfo = contentColsToExtract.find(c => c.col === key);
1455
- if (extractInfo && value && typeof value === 'object' && value.encoding === 'base64' && value.value !== null) {
1456
- const decoded = resolveContentValue(value);
1457
- if (decoded) {
1458
- const colFileName = `${finalName}.${key}.${extractInfo.ext}`;
1459
- const colFilePath = join(dirName, colFileName);
1460
- await writeFile(colFilePath, decoded);
1461
- meta[key] = `@${colFileName}`;
1462
- extractedContentCols.push(key);
1463
-
1464
- // Set timestamps on companion file
1465
- if (serverTz && (record._CreatedOn || record._LastUpdated)) {
1466
- try {
1467
- await setFileTimestamps(colFilePath, record._CreatedOn, record._LastUpdated, serverTz);
1468
- } catch { /* non-critical */ }
1469
- }
1864
+ if (extractInfo) {
1865
+ const isBase64 = value && typeof value === 'object' && !Array.isArray(value) && value.encoding === 'base64';
1866
+ const decoded = isBase64 ? (resolveContentValue(value) ?? '') : (value ?? '');
1867
+ const colFileName = `${finalName}.${key}.${extractInfo.ext}`;
1868
+ const colFilePath = join(dirName, colFileName);
1869
+ await writeFile(colFilePath, decoded);
1870
+ meta[key] = `@${colFileName}`;
1871
+ extractedContentCols.push(key);
1470
1872
 
1471
- log.dim(` → ${colFilePath}`);
1472
- continue;
1873
+ // Set timestamps on companion file
1874
+ if (serverTz && (record._CreatedOn || record._LastUpdated)) {
1875
+ try {
1876
+ await setFileTimestamps(colFilePath, record._CreatedOn, record._LastUpdated, serverTz);
1877
+ } catch { /* non-critical */ }
1473
1878
  }
1879
+
1880
+ log.dim(` → ${colFilePath}`);
1881
+ continue;
1474
1882
  }
1475
1883
 
1476
1884
  // Other base64 columns not selected for extraction — decode inline
@@ -1841,8 +2249,34 @@ async function processExtensionEntries(entries, structure, options, serverTz) {
1841
2249
  }
1842
2250
  }
1843
2251
 
2252
+ // Rename legacy ~UID companion files and update @references in extension metadata
2253
+ if (await fileExists(metaPath)) {
2254
+ try {
2255
+ const existingMeta = JSON.parse(await readFile(metaPath, 'utf8'));
2256
+ await detectAndRenameLegacyCompanions(metaPath, existingMeta);
2257
+ } catch { /* non-critical */ }
2258
+ }
2259
+
2260
+ // Check if any @reference content files are missing — force re-extraction if so
2261
+ let hasNewExtractions = contentColsToExtract.length > 0;
2262
+ if (!hasNewExtractions && await fileExists(metaPath)) {
2263
+ try {
2264
+ const existingMeta = JSON.parse(await readFile(metaPath, 'utf8'));
2265
+ for (const col of (existingMeta._contentColumns || [])) {
2266
+ const ref = existingMeta[col];
2267
+ if (ref && String(ref).startsWith('@')) {
2268
+ const refName = String(ref).substring(1);
2269
+ const refPath = refName.startsWith('/') ? join(process.cwd(), refName) : join(dir, refName);
2270
+ if (!await fileExists(refPath)) {
2271
+ hasNewExtractions = true; // Force re-extraction
2272
+ break;
2273
+ }
2274
+ }
2275
+ }
2276
+ } catch { /* non-critical */ }
2277
+ }
2278
+
1844
2279
  // Change detection — same pattern as processEntityDirEntries()
1845
- const hasNewExtractions = contentColsToExtract.length > 0;
1846
2280
  // Skip __WILL_DELETE__-prefixed files — treat as "no existing file"
1847
2281
  const willDeleteExtMeta = join(dir, `${WILL_DELETE_PREFIX}${basename(metaPath)}`);
1848
2282
  const extMetaExists = await fileExists(metaPath) && !await fileExists(willDeleteExtMeta);
@@ -1889,32 +2323,30 @@ async function processExtensionEntries(entries, structure, options, serverTz) {
1889
2323
  if (key === 'children') continue;
1890
2324
 
1891
2325
  const extractInfo = contentColsToExtract.find(c => c.col === key);
1892
- if (extractInfo && value && typeof value === 'object'
1893
- && value.encoding === 'base64' && value.value !== null) {
1894
- const decoded = resolveContentValue(value);
1895
- if (decoded) {
1896
- let colFilePath, refValue;
1897
-
1898
- if (mdColInfo && extractInfo.col === mdColInfo.col) {
1899
- // Root placement: docs/<name>~<uid>.md
1900
- const docFileName = `${finalName}.md`;
1901
- colFilePath = join(DOCUMENTATION_DIR, docFileName);
1902
- refValue = `@/${DOCUMENTATION_DIR}/${docFileName}`;
1903
- } else {
1904
- const colFileName = `${finalName}.${key}.${extractInfo.ext}`;
1905
- colFilePath = join(dir, colFileName);
1906
- refValue = `@${colFileName}`;
1907
- }
2326
+ if (extractInfo) {
2327
+ const isBase64 = value && typeof value === 'object' && !Array.isArray(value) && value.encoding === 'base64';
2328
+ const decoded = isBase64 ? (resolveContentValue(value) ?? '') : (value ?? '');
2329
+ let colFilePath, refValue;
2330
+
2331
+ if (mdColInfo && extractInfo.col === mdColInfo.col) {
2332
+ // Root placement: docs/<name>.md (natural name, no ~UID)
2333
+ const docFileName = `${name}.md`;
2334
+ colFilePath = join(DOCUMENTATION_DIR, docFileName);
2335
+ refValue = `@/${DOCUMENTATION_DIR}/${docFileName}`;
2336
+ } else {
2337
+ const colFileName = `${finalName}.${key}.${extractInfo.ext}`;
2338
+ colFilePath = join(dir, colFileName);
2339
+ refValue = `@${colFileName}`;
2340
+ }
1908
2341
 
1909
- meta[key] = refValue;
1910
- await writeFile(colFilePath, decoded);
1911
- extractedCols.push(key);
1912
- if (serverTz) {
1913
- try { await setFileTimestamps(colFilePath, record._CreatedOn, record._LastUpdated, serverTz); } catch {}
1914
- }
1915
- log.dim(` → ${colFilePath}`);
1916
- continue;
2342
+ meta[key] = refValue;
2343
+ await writeFile(colFilePath, decoded);
2344
+ extractedCols.push(key);
2345
+ if (serverTz) {
2346
+ try { await setFileTimestamps(colFilePath, record._CreatedOn, record._LastUpdated, serverTz); } catch {}
1917
2347
  }
2348
+ log.dim(` → ${colFilePath}`);
2349
+ continue;
1918
2350
  }
1919
2351
 
1920
2352
  // Inline or non-extraction columns
@@ -1959,7 +2391,7 @@ async function processExtensionEntries(entries, structure, options, serverTz) {
1959
2391
  * Process media entries: download binary files from server + create metadata.
1960
2392
  * Media uses Filename (not Name) and files are fetched via /api/media/{uid}.
1961
2393
  */
1962
- async function processMediaEntries(mediaRecords, structure, options, config, appShortName, serverTz, skipUIDs = new Set()) {
2394
+ async function processMediaEntries(mediaRecords, structure, options, config, appShortName, serverTz, skipUIDs = new Set(), resolvedFilenames = new Map()) {
1963
2395
  if (!mediaRecords || mediaRecords.length === 0) return [];
1964
2396
 
1965
2397
  // Track stale records (404s) for cleanup prompt
@@ -2062,16 +2494,25 @@ async function processMediaEntries(mediaRecords, structure, options, config, app
2062
2494
  if (!dir) dir = BINS_DIR;
2063
2495
  await mkdir(dir, { recursive: true });
2064
2496
 
2065
- // Always include UID in filename via tilde convention
2497
+ // Companion: natural name, no UID (use collision-resolved override if available)
2066
2498
  const uid = String(record.UID || record._id || 'untitled');
2067
- const base = buildUidFilename(name, uid);
2068
- const finalFilename = `${base}.${ext}`;
2499
+ const finalFilename = resolvedFilenames.get(record.UID) || sanitizeFilename(filename);
2069
2500
  const filePath = join(dir, finalFilename);
2070
- const metaPath = join(dir, `${finalFilename}.metadata.json`);
2501
+ // Metadata: name~uid.ext.metadata.json (unchanged format)
2502
+ const metaBase = buildUidFilename(name, uid);
2503
+ const metaPath = join(dir, `${metaBase}.${ext}.metadata.json`);
2071
2504
  // usedNames retained for tracking
2072
2505
  const fileKey = `${dir}/${name}.${ext}`;
2073
2506
  usedNames.set(fileKey, (usedNames.get(fileKey) || 0) + 1);
2074
2507
 
2508
+ // Rename legacy ~UID companion files to natural names if needed
2509
+ if (await fileExists(metaPath)) {
2510
+ try {
2511
+ const existingMeta = JSON.parse(await readFile(metaPath, 'utf8'));
2512
+ await detectAndRenameLegacyCompanions(metaPath, existingMeta);
2513
+ } catch { /* non-critical */ }
2514
+ }
2515
+
2075
2516
  // Change detection for existing media files
2076
2517
  // Skip __WILL_DELETE__-prefixed files — treat as "no existing file"
2077
2518
  const willDeleteMediaMeta = join(dir, `${WILL_DELETE_PREFIX}${basename(metaPath)}`);
@@ -2093,7 +2534,7 @@ async function processMediaEntries(mediaRecords, structure, options, config, app
2093
2534
  const diffable = isDiffable(ext);
2094
2535
 
2095
2536
  if (serverNewer) {
2096
- const action = await promptChangeDetection(dedupName, record, configWithTz, {
2537
+ const action = await promptChangeDetection(finalFilename, record, configWithTz, {
2097
2538
  diffable,
2098
2539
  serverDate,
2099
2540
  localDate: localSyncTime,
@@ -2124,7 +2565,7 @@ async function processMediaEntries(mediaRecords, structure, options, config, app
2124
2565
  const locallyModified = await hasLocalModifications(metaPath, configWithTz);
2125
2566
  if (locallyModified) {
2126
2567
  const localDate = localSyncTime; // mtime already fetched above
2127
- const action = await promptChangeDetection(dedupName, record, configWithTz, {
2568
+ const action = await promptChangeDetection(finalFilename, record, configWithTz, {
2128
2569
  localIsNewer: true,
2129
2570
  diffable,
2130
2571
  serverDate,
@@ -2273,7 +2714,7 @@ async function processMediaEntries(mediaRecords, structure, options, config, app
2273
2714
  * Process a single record: determine directory, write content file + metadata.
2274
2715
  * Returns { uid, metaPath } or null.
2275
2716
  */
2276
- async function processRecord(entityName, record, structure, options, usedNames, _placementPreference, serverTz, bulkAction = { value: null }) {
2717
+ async function processRecord(entityName, record, structure, options, usedNames, _placementPreference, serverTz, bulkAction = { value: null }, filenameOverride = null) {
2277
2718
  let name = sanitizeFilename(String(record.Name || record.UID || 'untitled'));
2278
2719
 
2279
2720
  // Determine file extension (priority: Extension field > Name field > Path field > empty)
@@ -2296,7 +2737,7 @@ async function processRecord(entityName, record, structure, options, usedNames,
2296
2737
  }
2297
2738
  // If still no extension, check existing local metadata for a previously chosen extension.
2298
2739
  // On re-clone, the Content @reference in the local metadata already has the extension
2299
- // the user picked on the first clone (e.g. "@CurrentTask~uid.html" → "html").
2740
+ // the user picked on the first clone (e.g. "@CurrentTask.html" → "html").
2300
2741
  if (!ext && record.UID) {
2301
2742
  try {
2302
2743
  const uid = String(record.UID);
@@ -2398,23 +2839,32 @@ async function processRecord(entityName, record, structure, options, usedNames,
2398
2839
 
2399
2840
  await mkdir(dir, { recursive: true });
2400
2841
 
2401
- // Always include UID in filename via tilde convention
2402
2842
  const uid = String(record.UID || record._id || 'untitled');
2403
- const finalName = buildUidFilename(name, uid);
2843
+ // Companion: natural name, no UID (use collision-resolved override if available)
2844
+ const fileName = filenameOverride || sanitizeFilename(buildContentFileName(record, uid));
2845
+ // Metadata: still uses ~UID
2846
+ const metaBase = buildUidFilename(name, uid);
2404
2847
  // usedNames retained for non-UID edge case tracking
2405
2848
  const nameKey = `${dir}/${name}`;
2406
2849
  usedNames.set(nameKey, (usedNames.get(nameKey) || 0) + 1);
2407
2850
 
2408
- // Write content file if Content column has data
2851
+ // Write content file always create companion for content entities even if empty
2409
2852
  const contentValue = record.Content;
2410
- const hasContent = contentValue && (
2411
- (typeof contentValue === 'object' && contentValue.value) ||
2412
- (typeof contentValue === 'string' && contentValue.length > 0)
2853
+ const hasContent = contentValue !== null && contentValue !== undefined && (
2854
+ (typeof contentValue === 'object' && contentValue.encoding === 'base64') ||
2855
+ (typeof contentValue === 'string')
2413
2856
  );
2414
2857
 
2415
- const fileName = ext ? `${finalName}.${ext}` : finalName;
2416
2858
  const filePath = join(dir, fileName);
2417
- const metaPath = join(dir, `${finalName}.metadata.json`);
2859
+ const metaPath = join(dir, `${metaBase}.metadata.json`);
2860
+
2861
+ // Rename legacy ~UID companion files to natural names if needed
2862
+ if (await fileExists(metaPath)) {
2863
+ try {
2864
+ const existingMeta = JSON.parse(await readFile(metaPath, 'utf8'));
2865
+ await detectAndRenameLegacyCompanions(metaPath, existingMeta);
2866
+ } catch { /* non-critical */ }
2867
+ }
2418
2868
 
2419
2869
  // Change detection: check if file already exists locally
2420
2870
  // Skip __WILL_DELETE__-prefixed files — treat as "no existing file"
@@ -2422,7 +2872,7 @@ async function processRecord(entityName, record, structure, options, usedNames,
2422
2872
  const metaExistsForChangeDetect = await fileExists(metaPath) && !await fileExists(willDeleteMeta);
2423
2873
  if (metaExistsForChangeDetect && !options.yes) {
2424
2874
  if (bulkAction.value === 'skip_all') {
2425
- log.dim(` Skipped ${finalName}.${ext}`);
2875
+ log.dim(` Skipped ${fileName}`);
2426
2876
  return { uid: record.UID, metaPath };
2427
2877
  }
2428
2878
 
@@ -2430,51 +2880,66 @@ async function processRecord(entityName, record, structure, options, usedNames,
2430
2880
  const config = await loadConfig();
2431
2881
  const configWithTz = { ...config, ServerTimezone: serverTz };
2432
2882
  const localSyncTime = await getLocalSyncTime(metaPath);
2433
- const serverNewer = isServerNewer(localSyncTime, record._LastUpdated, configWithTz, entityName, record.UID);
2883
+
2884
+ // If local metadata has no _LastUpdated (e.g. from dbo add with incomplete fields),
2885
+ // always treat as server-newer so pull populates missing columns.
2886
+ let localMissingLastUpdated = false;
2887
+ try {
2888
+ const localMeta = JSON.parse(await readFile(metaPath, 'utf8'));
2889
+ if (!localMeta._LastUpdated) localMissingLastUpdated = true;
2890
+ } catch { /* unreadable — will be overwritten */ }
2891
+
2892
+ const serverNewer = localMissingLastUpdated || isServerNewer(localSyncTime, record._LastUpdated, configWithTz, entityName, record.UID);
2434
2893
  const serverDate = parseServerDate(record._LastUpdated, serverTz);
2435
2894
 
2436
2895
  if (serverNewer) {
2437
- const action = await promptChangeDetection(finalName, record, configWithTz, {
2438
- serverDate,
2439
- localDate: localSyncTime,
2440
- });
2441
-
2442
- if (action === 'skip') {
2443
- log.dim(` Skipped ${finalName}.${ext}`);
2444
- return { uid: record.UID, metaPath };
2445
- }
2446
- if (action === 'skip_all') {
2447
- bulkAction.value = 'skip_all';
2448
- log.dim(` Skipped ${finalName}.${ext}`);
2449
- return { uid: record.UID, metaPath };
2450
- }
2451
- if (action === 'overwrite_all') {
2452
- bulkAction.value = 'overwrite_all';
2896
+ // Incomplete metadata (no _LastUpdated) from dbo add — auto-accept without prompting
2897
+ if (localMissingLastUpdated) {
2898
+ log.dim(` Completing metadata: ${fileName}`);
2453
2899
  // Fall through to write
2900
+ } else {
2901
+ const action = await promptChangeDetection(fileName, record, configWithTz, {
2902
+ serverDate,
2903
+ localDate: localSyncTime,
2904
+ });
2905
+
2906
+ if (action === 'skip') {
2907
+ log.dim(` Skipped ${fileName}`);
2908
+ return { uid: record.UID, metaPath };
2909
+ }
2910
+ if (action === 'skip_all') {
2911
+ bulkAction.value = 'skip_all';
2912
+ log.dim(` Skipped ${fileName}`);
2913
+ return { uid: record.UID, metaPath };
2914
+ }
2915
+ if (action === 'overwrite_all') {
2916
+ bulkAction.value = 'overwrite_all';
2917
+ // Fall through to write
2918
+ }
2919
+ if (action === 'compare') {
2920
+ await inlineDiffAndMerge(record, metaPath, configWithTz);
2921
+ return { uid: record.UID, metaPath };
2922
+ }
2923
+ // 'overwrite' falls through to normal write
2454
2924
  }
2455
- if (action === 'compare') {
2456
- await inlineDiffAndMerge(record, metaPath, configWithTz);
2457
- return { uid: record.UID, metaPath };
2458
- }
2459
- // 'overwrite' falls through to normal write
2460
2925
  } else {
2461
2926
  // Server _LastUpdated hasn't changed since last sync.
2462
2927
  // Check if local content files were modified (user edits).
2463
2928
  const locallyModified = await hasLocalModifications(metaPath, configWithTz);
2464
2929
  if (locallyModified) {
2465
- const action = await promptChangeDetection(finalName, record, configWithTz, {
2930
+ const action = await promptChangeDetection(fileName, record, configWithTz, {
2466
2931
  localIsNewer: true,
2467
2932
  serverDate,
2468
2933
  localDate: localSyncTime,
2469
2934
  });
2470
2935
 
2471
2936
  if (action === 'skip') {
2472
- log.dim(` Kept local: ${finalName}.${ext}`);
2937
+ log.dim(` Kept local: ${fileName}`);
2473
2938
  return { uid: record.UID, metaPath };
2474
2939
  }
2475
2940
  if (action === 'skip_all') {
2476
2941
  bulkAction.value = 'skip_all';
2477
- log.dim(` Kept local: ${finalName}.${ext}`);
2942
+ log.dim(` Kept local: ${fileName}`);
2478
2943
  return { uid: record.UID, metaPath };
2479
2944
  }
2480
2945
  if (action === 'overwrite_all') {
@@ -2486,7 +2951,7 @@ async function processRecord(entityName, record, structure, options, usedNames,
2486
2951
  }
2487
2952
  // 'overwrite' falls through to normal write
2488
2953
  } else {
2489
- log.dim(` Up to date: ${finalName}.${ext}`);
2954
+ log.dim(` Up to date: ${fileName}`);
2490
2955
  return { uid: record.UID, metaPath };
2491
2956
  }
2492
2957
  }
@@ -2494,11 +2959,9 @@ async function processRecord(entityName, record, structure, options, usedNames,
2494
2959
  }
2495
2960
 
2496
2961
  if (hasContent) {
2497
- const decoded = resolveContentValue(contentValue);
2498
- if (decoded) {
2499
- await writeFile(filePath, decoded);
2500
- log.success(`Saved ${filePath}`);
2501
- }
2962
+ const decoded = resolveContentValue(contentValue) ?? '';
2963
+ await writeFile(filePath, decoded);
2964
+ log.success(`Saved ${filePath}`);
2502
2965
  }
2503
2966
 
2504
2967
  // Build metadata
@@ -2515,7 +2978,7 @@ async function processRecord(entityName, record, structure, options, usedNames,
2515
2978
  if (decoded && decoded.length > 200) {
2516
2979
  // Large value: save as separate file
2517
2980
  const colExt = guessExtensionForColumn(key);
2518
- const colFileName = `${finalName}-${key.toLowerCase()}.${colExt}`;
2981
+ const colFileName = `${metaBase}-${key.toLowerCase()}.${colExt}`;
2519
2982
  const colFilePath = join(dir, colFileName);
2520
2983
  await writeFile(colFilePath, decoded);
2521
2984
  meta[key] = `@${colFileName}`;