@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 +28 -0
- package/package.json +1 -1
- package/plugins/claude/dbo/.claude-plugin/plugin.json +1 -1
- package/src/commands/clone.js +443 -22
- package/src/commands/push.js +1 -2
- package/src/lib/config.js +31 -0
- package/src/lib/delta.js +5 -9
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
package/src/commands/clone.js
CHANGED
|
@@ -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
|
|
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
|
-
//
|
|
618
|
-
|
|
619
|
-
const
|
|
620
|
-
|
|
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
|
-
|
|
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
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
if (
|
|
961
|
-
|
|
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
|
-
|
|
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'
|
|
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 {
|
package/src/commands/push.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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 {
|
|
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
|
|
179
|
+
const refPath = reference.substring(1); // Strip leading @
|
|
184
180
|
return join(baseDir, refPath);
|
|
185
181
|
}
|
|
186
182
|
|