@dboio/cli 0.6.7 → 0.6.8

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 CHANGED
@@ -253,6 +253,34 @@ dbo init --domain my-domain.com --app myapp --clone
253
253
 
254
254
  When cloning an app that was already cloned locally, the CLI detects existing files and compares modification times against the server's `_LastUpdated`. You'll be prompted to overwrite, compare differences, or skip — same as `dbo pull`. Use `-y` to auto-accept all changes.
255
255
 
256
+ #### Collision detection
257
+
258
+ When multiple records would create files at the same path (e.g., a `content` record and a `media` record both named `colors.css`), the CLI detects the collision before writing any files and prompts you to choose which record to keep:
259
+
260
+ ```
261
+ ⚠ Collision: 2 records want to create "Bins/app/colors.css"
262
+ ? Which record should create this file?
263
+ ❯ [content] colors (UID: abc123)
264
+ [media] colors.css (UID: def456)
265
+ ```
266
+
267
+ The rejected record is automatically staged for deletion in `.dbo/synchronize.json`. Run `dbo push` to delete it from the server.
268
+
269
+ In non-interactive mode (`-y`), the first record is kept and others are auto-staged for deletion.
270
+
271
+ #### Stale media cleanup
272
+
273
+ During media downloads, files returning 404 (no longer exist on the server) are collected as "stale records". After all downloads complete, you'll be prompted to stage them for deletion:
274
+
275
+ ```
276
+ Found 3 stale media record(s) (404 - files no longer exist on server)
277
+ ? Stage these 3 stale media records for deletion? (y/N)
278
+ ```
279
+
280
+ This helps keep your app clean by removing database records for media files that have been deleted from the server.
281
+
282
+ In non-interactive mode (`-y`), stale cleanup is skipped (conservative default).
283
+
256
284
  #### Path resolution
257
285
 
258
286
  When a content record has both `Path` and `BinID`, the CLI prompts:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dboio/cli",
3
- "version": "0.6.7",
3
+ "version": "0.6.8",
4
4
  "description": "CLI for the DBO.io framework",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dbo",
3
- "version": "0.6.6",
3
+ "version": "0.6.8",
4
4
  "description": "DBO.io CLI integration for Claude Code",
5
5
  "author": {
6
6
  "name": "DBO.io"
@@ -2,7 +2,7 @@ import { Command } from 'commander';
2
2
  import { readFile, writeFile, mkdir, access } from 'fs/promises';
3
3
  import { join, basename, extname } from 'path';
4
4
  import { DboClient } from '../lib/client.js';
5
- import { loadConfig, updateConfigWithApp, loadClonePlacement, saveClonePlacement, ensureGitignore, saveEntityDirPreference, loadEntityDirPreference, saveEntityContentExtractions, loadEntityContentExtractions, saveAppJsonBaseline } from '../lib/config.js';
5
+ import { loadConfig, updateConfigWithApp, loadClonePlacement, saveClonePlacement, ensureGitignore, saveEntityDirPreference, loadEntityDirPreference, saveEntityContentExtractions, loadEntityContentExtractions, saveAppJsonBaseline, addDeleteEntry, loadCollisionResolutions, saveCollisionResolutions, loadSynchronize } from '../lib/config.js';
6
6
  import { buildBinHierarchy, resolveBinPath, createDirectories, saveStructureFile, getBinName, findBinByPath, BINS_DIR, DEFAULT_PROJECT_DIRS, ENTITY_DIR_MAP } from '../lib/structure.js';
7
7
  import { log } from '../lib/logger.js';
8
8
  import { setFileTimestamps } from '../lib/timestamps.js';
@@ -80,6 +80,342 @@ function resolvePathToBinsDir(pathValue, structure) {
80
80
  return `${BINS_DIR}/${dirPart}`;
81
81
  }
82
82
 
83
+ /**
84
+ * Extract path components for content/generic records (read-only, no file writes).
85
+ * Replicates logic from processRecord() for collision detection.
86
+ */
87
+ function resolveRecordPaths(entityName, record, structure, placementPref) {
88
+ let name = sanitizeFilename(String(record.Name || record.UID || 'untitled'));
89
+
90
+ // Determine extension (priority: Extension field > Name > Path)
91
+ let ext = '';
92
+ if (record.Extension) {
93
+ ext = String(record.Extension).toLowerCase();
94
+ } else {
95
+ if (record.Name) {
96
+ const extractedExt = extname(String(record.Name));
97
+ ext = extractedExt ? extractedExt.substring(1).toLowerCase() : '';
98
+ }
99
+ if (!ext && record.Path) {
100
+ const extractedExt = extname(String(record.Path));
101
+ ext = extractedExt ? extractedExt.substring(1).toLowerCase() : '';
102
+ }
103
+ }
104
+
105
+ // Strip double extension
106
+ if (ext) {
107
+ const extWithDot = `.${ext}`;
108
+ if (name.toLowerCase().endsWith(extWithDot)) {
109
+ name = name.substring(0, name.length - extWithDot.length);
110
+ }
111
+ }
112
+
113
+ // Resolve directory (BinID vs Path)
114
+ let dir = BINS_DIR;
115
+ const hasBinID = record.BinID && structure[record.BinID];
116
+ const hasPath = record.Path && typeof record.Path === 'string' && record.Path.trim();
117
+
118
+ if (hasBinID && hasPath) {
119
+ dir = placementPref === 'path' ? record.Path : resolveBinPath(record.BinID, structure);
120
+ } else if (hasBinID) {
121
+ dir = resolveBinPath(record.BinID, structure);
122
+ } else if (hasPath) {
123
+ dir = resolvePathToBinsDir(record.Path, structure);
124
+ }
125
+
126
+ // Clean directory path
127
+ dir = dir.replace(/^\/+|\/+$/g, '');
128
+ if (!dir) dir = BINS_DIR;
129
+ const pathExt = extname(dir);
130
+ if (pathExt) {
131
+ dir = dir.substring(0, dir.lastIndexOf('/')) || BINS_DIR;
132
+ }
133
+
134
+ const filename = ext ? `${name}.${ext}` : name;
135
+ const metaPath = join(dir, `${name}.metadata.json`);
136
+
137
+ return { dir, filename, metaPath };
138
+ }
139
+
140
+ /**
141
+ * Extract path components for media records.
142
+ * Replicates logic from processMediaEntries() for collision detection.
143
+ */
144
+ function resolveMediaPaths(record, structure, placementPref) {
145
+ const filename = record.Filename || `${record.Name || record.UID}.${(record.Extension || 'bin').toLowerCase()}`;
146
+ const name = sanitizeFilename(filename.replace(/\.[^.]+$/, ''));
147
+ const ext = (record.Extension || 'bin').toLowerCase();
148
+
149
+ let dir = BINS_DIR;
150
+ const hasBinID = record.BinID && structure[record.BinID];
151
+ const hasFullPath = record.FullPath && typeof record.FullPath === 'string';
152
+
153
+ let fullPathDir = null;
154
+ if (hasFullPath) {
155
+ const stripped = record.FullPath.replace(/^\/+/, '');
156
+ const lastSlash = stripped.lastIndexOf('/');
157
+ fullPathDir = lastSlash >= 0 ? stripped.substring(0, lastSlash) : BINS_DIR;
158
+ }
159
+
160
+ if (hasBinID && fullPathDir && fullPathDir !== '.') {
161
+ dir = placementPref === 'path' ? fullPathDir : resolveBinPath(record.BinID, structure);
162
+ } else if (hasBinID) {
163
+ dir = resolveBinPath(record.BinID, structure);
164
+ } else if (fullPathDir && fullPathDir !== BINS_DIR) {
165
+ dir = fullPathDir;
166
+ }
167
+
168
+ dir = dir.replace(/^\/+|\/+$/g, '');
169
+ if (!dir) dir = BINS_DIR;
170
+
171
+ const finalFilename = `${name}.${ext}`;
172
+ const metaPath = join(dir, `${finalFilename}.metadata.json`);
173
+
174
+ return { dir, filename: finalFilename, metaPath };
175
+ }
176
+
177
+ /**
178
+ * Extract path components for entity-dir records.
179
+ * Simplified from processEntityDirEntries() for collision detection.
180
+ */
181
+ function resolveEntityDirPaths(entityName, record, dirName) {
182
+ let name;
183
+ if (entityName === 'app_version' && record.Number) {
184
+ name = sanitizeFilename(String(record.Number));
185
+ } else if (record.Name) {
186
+ name = sanitizeFilename(String(record.Name));
187
+ } else {
188
+ name = sanitizeFilename(String(record.UID || 'untitled'));
189
+ }
190
+
191
+ const uid = record.UID || 'untitled';
192
+ const finalName = name === uid ? uid : `${name}.${uid}`;
193
+ const metaPath = join(dirName, `${finalName}.metadata.json`);
194
+ return { dir: dirName, filename: finalName, metaPath };
195
+ }
196
+
197
+ /**
198
+ * Build global registry of all files that will be created during clone.
199
+ * Returns Map<filePath, Array<{entity, record, dir, filename, metaPath}>>
200
+ */
201
+ async function buildFileRegistry(appJson, structure, placementPrefs) {
202
+ const registry = new Map();
203
+
204
+ function addToRegistry(filePath, entity, record, dir, filename, metaPath) {
205
+ if (!registry.has(filePath)) {
206
+ registry.set(filePath, []);
207
+ }
208
+ registry.get(filePath).push({ entity, record, dir, filename, metaPath });
209
+ }
210
+
211
+ // Process content records
212
+ for (const record of (appJson.children.content || [])) {
213
+ const { dir, filename, metaPath } = resolveRecordPaths(
214
+ 'content', record, structure, placementPrefs.contentPlacement
215
+ );
216
+ addToRegistry(join(dir, filename), 'content', record, dir, filename, metaPath);
217
+ }
218
+
219
+ // Process media records
220
+ for (const record of (appJson.children.media || [])) {
221
+ const { dir, filename, metaPath } = resolveMediaPaths(
222
+ record, structure, placementPrefs.mediaPlacement
223
+ );
224
+ addToRegistry(join(dir, filename), 'media', record, dir, filename, metaPath);
225
+ }
226
+
227
+ // Note: entity-dir records (Extensions/, Data Sources/, etc.) and generic entities
228
+ // are excluded from collision detection — they use UID-based naming to handle duplicates.
229
+ // Only content and media records in Bins/ are checked for cross-entity collisions.
230
+
231
+ return registry;
232
+ }
233
+
234
+ /**
235
+ * Detect collisions and prompt user to choose which record to keep.
236
+ * Uses stored resolutions from config and pending deletes from synchronize.json
237
+ * to avoid re-prompting on subsequent clones.
238
+ * Returns Set of UIDs to skip during processing.
239
+ */
240
+ async function resolveCollisions(registry, options) {
241
+ const toDelete = new Set();
242
+ const savedResolutions = await loadCollisionResolutions();
243
+ const sync = await loadSynchronize();
244
+ const pendingDeleteUIDs = new Set((sync.delete || []).map(e => e.UID));
245
+ const newResolutions = { ...savedResolutions };
246
+
247
+ // Find all collisions (paths with >1 record)
248
+ const collisions = [];
249
+ for (const [filePath, records] of registry.entries()) {
250
+ if (records.length > 1) {
251
+ collisions.push({ filePath, records });
252
+ }
253
+ }
254
+
255
+ if (collisions.length === 0) return toDelete;
256
+
257
+ log.warn(`Found ${collisions.length} file path collision(s)`);
258
+
259
+ for (const { filePath, records } of collisions) {
260
+ // Same-entity duplicates are not cross-entity collisions — handled by UID naming
261
+ const entities = new Set(records.map(r => r.entity));
262
+ if (entities.size === 1) {
263
+ log.dim(` Same-entity duplicates for "${filePath}" — will use UID naming`);
264
+ continue;
265
+ }
266
+
267
+ // Check if we have a stored resolution for this path
268
+ const saved = savedResolutions[filePath];
269
+ if (saved && records.some(r => r.record.UID === saved.keepUID)) {
270
+ // Auto-apply stored resolution
271
+ for (const r of records) {
272
+ if (r.record.UID !== saved.keepUID) {
273
+ toDelete.add(r.record.UID);
274
+ }
275
+ }
276
+ log.dim(` Collision "${filePath}" → keeping ${saved.keepEntity} (saved preference)`);
277
+ continue;
278
+ }
279
+
280
+ // Check if any record in this collision is already pending deletion
281
+ const pendingRecord = records.find(r => pendingDeleteUIDs.has(r.record.UID));
282
+ if (pendingRecord) {
283
+ toDelete.add(pendingRecord.record.UID);
284
+ // Store as resolution for future clones
285
+ const keptRecord = records.find(r => r.record.UID !== pendingRecord.record.UID);
286
+ if (keptRecord) {
287
+ newResolutions[filePath] = { keepUID: keptRecord.record.UID, keepEntity: keptRecord.entity };
288
+ }
289
+ log.dim(` Collision "${filePath}" → ${pendingRecord.record.UID} already staged for deletion`);
290
+ continue;
291
+ }
292
+
293
+ // Non-interactive mode: keep first, skip rest
294
+ if (options.yes) {
295
+ for (let i = 1; i < records.length; i++) {
296
+ toDelete.add(records[i].record.UID);
297
+ }
298
+ newResolutions[filePath] = { keepUID: records[0].record.UID, keepEntity: records[0].entity };
299
+ continue;
300
+ }
301
+
302
+ // Interactive resolution
303
+ const inquirer = (await import('inquirer')).default;
304
+
305
+ log.plain('');
306
+ log.warn(`Collision: ${records.length} records want to create "${filePath}"`);
307
+
308
+ const choices = records.map((r, idx) => {
309
+ const label = r.record.Name || r.record.Filename || r.record.UID;
310
+ return {
311
+ name: `[${r.entity}] ${label} (UID: ${r.record.UID})`,
312
+ value: idx,
313
+ };
314
+ });
315
+
316
+ const { keepIdx } = await inquirer.prompt([{
317
+ type: 'list',
318
+ name: 'keepIdx',
319
+ message: 'Which record should create this file?',
320
+ choices,
321
+ }]);
322
+
323
+ // Store resolution for future clones
324
+ newResolutions[filePath] = { keepUID: records[keepIdx].record.UID, keepEntity: records[keepIdx].entity };
325
+
326
+ // Mark others for deletion
327
+ for (let i = 0; i < records.length; i++) {
328
+ if (i !== keepIdx) {
329
+ toDelete.add(records[i].record.UID);
330
+ }
331
+ }
332
+ }
333
+
334
+ // Save resolutions for future clones
335
+ await saveCollisionResolutions(newResolutions);
336
+
337
+ return toDelete;
338
+ }
339
+
340
+ /**
341
+ * Stage deletion entries for rejected collision records.
342
+ * Prompts per-item for confirmation unless -y flag is set.
343
+ */
344
+ async function stageCollisionDeletions(toDelete, appJson, options) {
345
+ if (toDelete.size === 0) return;
346
+
347
+ // Collect records to potentially stage
348
+ const toStage = [];
349
+ for (const [entityName, entries] of Object.entries(appJson.children)) {
350
+ if (!Array.isArray(entries)) continue;
351
+
352
+ for (const record of entries) {
353
+ if (!toDelete.has(record.UID)) continue;
354
+
355
+ // Find RowID
356
+ let rowId = record._id;
357
+ if (!rowId) {
358
+ const entityCap = entityName.charAt(0).toUpperCase() + entityName.slice(1);
359
+ rowId = record[`${entityCap}ID`];
360
+ }
361
+
362
+ if (!rowId) {
363
+ log.warn(`Cannot stage ${entityName} ${record.UID}: no RowID`);
364
+ continue;
365
+ }
366
+
367
+ const name = record.Name || record.Filename || record.UID;
368
+ toStage.push({ UID: record.UID, RowID: rowId, entity: entityName, name });
369
+ }
370
+ }
371
+
372
+ if (toStage.length === 0) return;
373
+
374
+ // Check which are already staged (skip re-prompting)
375
+ const sync = await loadSynchronize();
376
+ const alreadyStaged = new Set((sync.delete || []).map(e => e.UID));
377
+ const newItems = toStage.filter(item => !alreadyStaged.has(item.UID));
378
+
379
+ if (newItems.length === 0) {
380
+ log.dim(` All ${toStage.length} collision rejection(s) already staged for deletion`);
381
+ return;
382
+ }
383
+
384
+ // Non-interactive mode: skip staging
385
+ if (options.yes) {
386
+ log.info(`Non-interactive mode: skipping deletion staging for ${newItems.length} rejected record(s)`);
387
+ return;
388
+ }
389
+
390
+ // Prompt per-item
391
+ log.info(`${newItems.length} rejected record(s) can be staged for deletion:`);
392
+ const inquirer = (await import('inquirer')).default;
393
+ let staged = 0;
394
+
395
+ for (const item of newItems) {
396
+ const { stage } = await inquirer.prompt([{
397
+ type: 'confirm',
398
+ name: 'stage',
399
+ message: `Stage [${item.entity}] "${item.name}" (UID: ${item.UID}) for deletion?`,
400
+ default: true,
401
+ }]);
402
+
403
+ if (stage) {
404
+ const expression = `RowID:del${item.RowID};entity:${item.entity}=true`;
405
+ await addDeleteEntry({ UID: item.UID, RowID: item.RowID, entity: item.entity, name: item.name, expression });
406
+ log.dim(` Staged: ${item.entity} "${item.name}"`);
407
+ staged++;
408
+ } else {
409
+ log.dim(` Skipped: ${item.entity} "${item.name}"`);
410
+ }
411
+ }
412
+
413
+ if (staged > 0) {
414
+ log.success(`${staged} record(s) staged in .dbo/synchronize.json`);
415
+ log.dim(' Run "dbo push" to delete from server');
416
+ }
417
+ }
418
+
83
419
  export const cloneCommand = new Command('clone')
84
420
  .description('Clone an app from DBO.io to a local project structure')
85
421
  .argument('[source]', 'Local JSON file path (optional)')
@@ -194,20 +530,30 @@ export async function performClone(source, options = {}) {
194
530
  log.dim(` Set ServerTimezone to ${serverTz} in .dbo/config.json`);
195
531
  }
196
532
 
197
- // Step 5: Process content files + metadata
533
+ // Step 4c: Detect and resolve file path collisions
534
+ log.info('Scanning for file path collisions...');
535
+ const fileRegistry = await buildFileRegistry(appJson, structure, placementPrefs);
536
+ const toDeleteUIDs = await resolveCollisions(fileRegistry, options);
537
+
538
+ if (toDeleteUIDs.size > 0) {
539
+ await stageCollisionDeletions(toDeleteUIDs, appJson, options);
540
+ }
541
+
542
+ // Step 5: Process content → files + metadata (skip rejected records)
198
543
  const contentRefs = await processContentEntries(
199
544
  appJson.children.content || [],
200
545
  structure,
201
546
  options,
202
547
  placementPrefs.contentPlacement,
203
548
  serverTz,
549
+ toDeleteUIDs,
204
550
  );
205
551
 
206
- // Step 5b: Process media → download binary files + metadata
552
+ // Step 5b: Process media → download binary files + metadata (skip rejected records)
207
553
  let mediaRefs = [];
208
554
  const mediaEntries = appJson.children.media || [];
209
555
  if (mediaEntries.length > 0) {
210
- mediaRefs = await processMediaEntries(mediaEntries, structure, options, config, appJson.ShortName, placementPrefs.mediaPlacement, serverTz);
556
+ mediaRefs = await processMediaEntries(mediaEntries, structure, options, config, appJson.ShortName, placementPrefs.mediaPlacement, serverTz, toDeleteUIDs);
211
557
  }
212
558
 
213
559
  // Step 6: Process other entities (not output, not bin, not content, not media)
@@ -396,7 +742,7 @@ async function updatePackageJson(appJson, config) {
396
742
  * Process content entries: write files + metadata, return reference map.
397
743
  * Returns array of { uid, metaPath } for app.json reference replacement.
398
744
  */
399
- async function processContentEntries(contents, structure, options, contentPlacement, serverTz) {
745
+ async function processContentEntries(contents, structure, options, contentPlacement, serverTz, skipUIDs = new Set()) {
400
746
  if (!contents || contents.length === 0) return [];
401
747
 
402
748
  const refs = [];
@@ -410,6 +756,10 @@ async function processContentEntries(contents, structure, options, contentPlacem
410
756
  log.info(`Processing ${contents.length} content record(s)...`);
411
757
 
412
758
  for (const record of contents) {
759
+ if (skipUIDs.has(record.UID)) {
760
+ log.dim(` Skipped ${record.Name || record.UID} (collision rejection)`);
761
+ continue;
762
+ }
413
763
  const ref = await processRecord('content', record, structure, options, usedNames, placementPreference, serverTz, bulkAction);
414
764
  if (ref) refs.push(ref);
415
765
  }
@@ -465,7 +815,6 @@ async function processEntityDirEntries(entityName, entries, options, serverTz) {
465
815
  await mkdir(dirName, { recursive: true });
466
816
 
467
817
  const refs = [];
468
- const usedNames = new Map();
469
818
  const bulkAction = { value: null };
470
819
  const config = await loadConfig();
471
820
 
@@ -614,11 +963,10 @@ async function processEntityDirEntries(entityName, entries, options, serverTz) {
614
963
  name = sanitizeFilename(String(record.UID || 'untitled'));
615
964
  }
616
965
 
617
- // Deduplicate filenames
618
- const nameKey = `${dirName}/${name}`;
619
- const count = usedNames.get(nameKey) || 0;
620
- usedNames.set(nameKey, count + 1);
621
- const finalName = count > 0 ? `${name}-${count + 1}` : name;
966
+ // Include UID in filename to ensure uniqueness (multiple records can share the same name)
967
+ // Convention: <name>.<UID>.metadata.json — unless name === UID, then just <UID>.metadata.json
968
+ const uid = record.UID || 'untitled';
969
+ const finalName = name === uid ? uid : `${name}.${uid}`;
622
970
 
623
971
  const metaPath = join(dirName, `${finalName}.metadata.json`);
624
972
 
@@ -755,9 +1103,12 @@ async function processEntityDirEntries(entityName, entries, options, serverTz) {
755
1103
  * Process media entries: download binary files from server + create metadata.
756
1104
  * Media uses Filename (not Name) and files are fetched via /api/media/{uid}.
757
1105
  */
758
- async function processMediaEntries(mediaRecords, structure, options, config, appShortName, mediaPlacement, serverTz) {
1106
+ async function processMediaEntries(mediaRecords, structure, options, config, appShortName, mediaPlacement, serverTz, skipUIDs = new Set()) {
759
1107
  if (!mediaRecords || mediaRecords.length === 0) return [];
760
1108
 
1109
+ // Track stale records (404s) for cleanup prompt
1110
+ const staleRecords = [];
1111
+
761
1112
  // Determine if we can download (need a server connection)
762
1113
  let canDownload = false;
763
1114
  let client = null;
@@ -804,6 +1155,12 @@ async function processMediaEntries(mediaRecords, structure, options, config, app
804
1155
 
805
1156
  for (const record of mediaRecords) {
806
1157
  const filename = record.Filename || `${record.Name || record.UID}.${(record.Extension || 'bin').toLowerCase()}`;
1158
+
1159
+ if (skipUIDs.has(record.UID)) {
1160
+ log.dim(` Skipped ${filename} (collision rejection)`);
1161
+ continue;
1162
+ }
1163
+
807
1164
  const name = sanitizeFilename(filename.replace(/\.[^.]+$/, '')); // base name without extension
808
1165
  const ext = (record.Extension || 'bin').toLowerCase();
809
1166
 
@@ -869,7 +1226,14 @@ async function processMediaEntries(mediaRecords, structure, options, config, app
869
1226
  const fileKey = `${dir}/${name}.${ext}`;
870
1227
  const fileCount = usedNames.get(fileKey) || 0;
871
1228
  usedNames.set(fileKey, fileCount + 1);
872
- const dedupName = fileCount > 0 ? `${name}-${fileCount + 1}` : name;
1229
+ let dedupName;
1230
+ if (fileCount > 0) {
1231
+ // Duplicate name — include UID for uniqueness
1232
+ const uid = record.UID || 'untitled';
1233
+ dedupName = name === uid ? uid : `${name}.${uid}`;
1234
+ } else {
1235
+ dedupName = name;
1236
+ }
873
1237
  const finalFilename = `${dedupName}.${ext}`;
874
1238
  // Metadata: use name.ext as base to avoid collisions between formats
875
1239
  // e.g. KaTeX_SansSerif-Italic.woff.metadata.json vs KaTeX_SansSerif-Italic.woff2.metadata.json
@@ -954,11 +1318,26 @@ async function processMediaEntries(mediaRecords, structure, options, config, app
954
1318
  log.success(`Downloaded ${filePath} (${sizeKB} KB)`);
955
1319
  downloaded++;
956
1320
  } catch (err) {
957
- log.warn(`Failed to download ${filename}`);
958
- log.dim(` UID: ${record.UID}`);
959
- log.dim(` URL: /api/media/${record.UID}`);
960
- if (record.FullPath) log.dim(` FullPath: ${record.FullPath}`);
961
- log.dim(` Error: ${err.message}`);
1321
+ // Check if error is 404 (stale record)
1322
+ const is404 = /Download failed: 404/.test(err.message);
1323
+
1324
+ if (is404) {
1325
+ log.warn(`Stale media: ${filename} (404 - file no longer exists)`);
1326
+ staleRecords.push({
1327
+ UID: record.UID,
1328
+ RowID: record._id || record.MediaID,
1329
+ filename,
1330
+ fullPath: record.FullPath,
1331
+ record,
1332
+ });
1333
+ } else {
1334
+ log.warn(`Failed to download ${filename}`);
1335
+ log.dim(` UID: ${record.UID}`);
1336
+ log.dim(` URL: /api/media/${record.UID}`);
1337
+ if (record.FullPath) log.dim(` FullPath: ${record.FullPath}`);
1338
+ log.dim(` Error: ${err.message}`);
1339
+ }
1340
+
962
1341
  failed++;
963
1342
  continue; // Skip metadata if download failed
964
1343
  }
@@ -987,6 +1366,41 @@ async function processMediaEntries(mediaRecords, structure, options, config, app
987
1366
  }
988
1367
 
989
1368
  log.info(`Media: ${downloaded} downloaded, ${failed} failed`);
1369
+
1370
+ // Prompt for stale record cleanup
1371
+ if (staleRecords.length > 0 && !options.yes) {
1372
+ log.plain('');
1373
+ log.info(`Found ${staleRecords.length} stale media record(s) (404 - files no longer exist on server)`);
1374
+
1375
+ const inquirer = (await import('inquirer')).default;
1376
+ const { cleanup } = await inquirer.prompt([{
1377
+ type: 'confirm',
1378
+ name: 'cleanup',
1379
+ message: `Stage these ${staleRecords.length} stale media records for deletion?`,
1380
+ default: false, // Conservative default
1381
+ }]);
1382
+
1383
+ if (cleanup) {
1384
+ for (const stale of staleRecords) {
1385
+ const expression = `RowID:del${stale.RowID};entity:media=true`;
1386
+ await addDeleteEntry({
1387
+ UID: stale.UID,
1388
+ RowID: stale.RowID,
1389
+ entity: 'media',
1390
+ name: stale.filename,
1391
+ expression,
1392
+ });
1393
+ log.dim(` Staged: ${stale.filename}`);
1394
+ }
1395
+ log.success('Stale media records staged in .dbo/synchronize.json');
1396
+ log.dim(' Run "dbo push" to delete from server');
1397
+ } else {
1398
+ log.info('Skipped stale media cleanup');
1399
+ }
1400
+ } else if (staleRecords.length > 0 && options.yes) {
1401
+ log.info(`Non-interactive mode: skipping stale cleanup for ${staleRecords.length} record(s)`);
1402
+ }
1403
+
990
1404
  return refs;
991
1405
  }
992
1406
 
@@ -1084,11 +1498,18 @@ async function processRecord(entityName, record, structure, options, usedNames,
1084
1498
 
1085
1499
  await mkdir(dir, { recursive: true });
1086
1500
 
1087
- // Deduplicate filenames
1501
+ // Deduplicate filenames — use UID naming when duplicates exist
1088
1502
  const nameKey = `${dir}/${name}`;
1089
1503
  const count = usedNames.get(nameKey) || 0;
1090
1504
  usedNames.set(nameKey, count + 1);
1091
- const finalName = count > 0 ? `${name}-${count + 1}` : name;
1505
+ let finalName;
1506
+ if (count > 0) {
1507
+ // Duplicate name — include UID for uniqueness
1508
+ const uid = record.UID || 'untitled';
1509
+ finalName = name === uid ? uid : `${name}.${uid}`;
1510
+ } else {
1511
+ finalName = name;
1512
+ }
1092
1513
 
1093
1514
  // Write content file if Content column has data
1094
1515
  const contentValue = record.Content;
@@ -1307,8 +1728,8 @@ function decodeBase64Fields(obj) {
1307
1728
  // Process each property
1308
1729
  for (const [key, value] of Object.entries(obj)) {
1309
1730
  if (value && typeof value === 'object') {
1310
- // Check if it's a base64 encoded value
1311
- if (!Array.isArray(value) && value.encoding === 'base64' && typeof value.value === 'string') {
1731
+ // Check if it's a base64 encoded value (handles both value: string and value: null)
1732
+ if (!Array.isArray(value) && value.encoding === 'base64') {
1312
1733
  // Decode using existing resolveContentValue function
1313
1734
  obj[key] = resolveContentValue(value);
1314
1735
  } else {
@@ -151,7 +151,6 @@ async function pushDirectory(dirPath, client, options) {
151
151
 
152
152
  // Load baseline for delta detection
153
153
  const baseline = await loadAppJsonBaseline();
154
- const config = await loadConfig();
155
154
 
156
155
  if (!baseline) {
157
156
  log.warn('No .app.json baseline found — performing full push (run "dbo clone" to enable delta sync)');
@@ -205,7 +204,7 @@ async function pushDirectory(dirPath, client, options) {
205
204
  let changedColumns = null;
206
205
  if (baseline) {
207
206
  try {
208
- changedColumns = await detectChangedColumns(metaPath, baseline, config);
207
+ changedColumns = await detectChangedColumns(metaPath, baseline);
209
208
  if (changedColumns.length === 0) {
210
209
  log.dim(` Skipping ${basename(metaPath)} — no changes detected`);
211
210
  skipped++;
package/src/lib/config.js CHANGED
@@ -286,6 +286,37 @@ export async function loadEntityContentExtractions(entityKey) {
286
286
  }
287
287
  }
288
288
 
289
+ /**
290
+ * Save collision resolutions to .dbo/config.json.
291
+ * Maps file paths to the UID of the record the user chose to keep.
292
+ *
293
+ * @param {Object} resolutions - { "Bins/app/file.css": { keepUID: "abc", keepEntity: "content" } }
294
+ */
295
+ export async function saveCollisionResolutions(resolutions) {
296
+ await mkdir(dboDir(), { recursive: true });
297
+ let existing = {};
298
+ try {
299
+ existing = JSON.parse(await readFile(configPath(), 'utf8'));
300
+ } catch { /* no existing config */ }
301
+ existing.CollisionResolutions = resolutions;
302
+ await writeFile(configPath(), JSON.stringify(existing, null, 2) + '\n');
303
+ }
304
+
305
+ /**
306
+ * Load collision resolutions from .dbo/config.json.
307
+ *
308
+ * @returns {Object} - { "Bins/app/file.css": { keepUID: "abc", keepEntity: "content" } }
309
+ */
310
+ export async function loadCollisionResolutions() {
311
+ try {
312
+ const raw = await readFile(configPath(), 'utf8');
313
+ const config = JSON.parse(raw);
314
+ return config.CollisionResolutions || {};
315
+ } catch {
316
+ return {};
317
+ }
318
+ }
319
+
289
320
  /**
290
321
  * Save user profile fields (FirstName, LastName, Email) into credentials.json.
291
322
  */
package/src/lib/delta.js CHANGED
@@ -84,10 +84,9 @@ export async function compareFileContent(filePath, baselineValue) {
84
84
  *
85
85
  * @param {string} metaPath - Path to metadata.json file
86
86
  * @param {Object} baseline - The baseline JSON
87
- * @param {Object} config - CLI config (for resolving file paths)
88
87
  * @returns {Promise<string[]>} - Array of changed column names
89
88
  */
90
- export async function detectChangedColumns(metaPath, baseline, config) {
89
+ export async function detectChangedColumns(metaPath, baseline) {
91
90
  // Load current metadata
92
91
  const metaRaw = await readFile(metaPath, 'utf8');
93
92
  const metadata = JSON.parse(metaRaw);
@@ -160,27 +159,24 @@ function shouldSkipColumn(columnName) {
160
159
  }
161
160
 
162
161
  /**
163
- * Check if a value is a @reference object.
162
+ * Check if a value is a @reference (string starting with @).
164
163
  *
165
164
  * @param {*} value - Value to check
166
165
  * @returns {boolean} - True if reference
167
166
  */
168
167
  function isReference(value) {
169
- return value &&
170
- typeof value === 'object' &&
171
- !Array.isArray(value) &&
172
- value['@reference'] !== undefined;
168
+ return typeof value === 'string' && value.startsWith('@');
173
169
  }
174
170
 
175
171
  /**
176
172
  * Resolve a @reference path to absolute file path.
177
173
  *
178
- * @param {Object} reference - Reference object with @reference property
174
+ * @param {string} reference - Reference string starting with @ (e.g., "@file.html")
179
175
  * @param {string} baseDir - Base directory containing metadata
180
176
  * @returns {string} - Absolute file path
181
177
  */
182
178
  function resolveReferencePath(reference, baseDir) {
183
- const refPath = reference['@reference'];
179
+ const refPath = reference.substring(1); // Strip leading @
184
180
  return join(baseDir, refPath);
185
181
  }
186
182