@dboio/cli 0.4.2 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +294 -71
- package/bin/dbo.js +12 -3
- package/bin/postinstall.js +88 -0
- package/package.json +10 -3
- package/src/commands/clone.js +597 -19
- package/src/commands/diff.js +246 -0
- package/src/commands/init.js +30 -22
- package/src/commands/install.js +517 -69
- package/src/commands/mv.js +869 -0
- package/src/commands/pull.js +6 -0
- package/src/commands/push.js +289 -33
- package/src/commands/rm.js +337 -0
- package/src/commands/status.js +28 -1
- package/src/lib/config.js +265 -0
- package/src/lib/delta.js +204 -0
- package/src/lib/dependencies.js +131 -0
- package/src/lib/diff.js +740 -0
- package/src/lib/save-to-disk.js +71 -4
- package/src/lib/structure.js +36 -0
- package/src/plugins/claudecommands/dbo.md +37 -6
- package/src/commands/update.js +0 -168
package/src/commands/clone.js
CHANGED
|
@@ -2,24 +2,28 @@ 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 } from '../lib/config.js';
|
|
6
|
-
import { buildBinHierarchy, resolveBinPath, createDirectories, saveStructureFile, getBinName, BINS_DIR, DEFAULT_PROJECT_DIRS } from '../lib/structure.js';
|
|
5
|
+
import { loadConfig, updateConfigWithApp, loadClonePlacement, saveClonePlacement, ensureGitignore, saveEntityDirPreference, loadEntityDirPreference, saveEntityContentExtractions, loadEntityContentExtractions, saveAppJsonBaseline } from '../lib/config.js';
|
|
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';
|
|
9
|
+
import { getLocalSyncTime, isServerNewer, hasLocalModifications, promptChangeDetection, inlineDiffAndMerge } from '../lib/diff.js';
|
|
9
10
|
|
|
10
11
|
/**
|
|
11
12
|
* Resolve a column value that may be base64-encoded.
|
|
12
13
|
* Server returns large text as: { bytes: N, value: "base64string", encoding: "base64" }
|
|
13
14
|
*/
|
|
14
|
-
function resolveContentValue(value) {
|
|
15
|
+
export function resolveContentValue(value) {
|
|
15
16
|
if (value && typeof value === 'object' && !Array.isArray(value)
|
|
16
|
-
&& value.encoding === 'base64'
|
|
17
|
-
|
|
17
|
+
&& value.encoding === 'base64') {
|
|
18
|
+
// Base64 object: decode if value is a string, otherwise empty string
|
|
19
|
+
return typeof value.value === 'string'
|
|
20
|
+
? Buffer.from(value.value, 'base64').toString('utf8')
|
|
21
|
+
: '';
|
|
18
22
|
}
|
|
19
23
|
return value !== null && value !== undefined ? String(value) : null;
|
|
20
24
|
}
|
|
21
25
|
|
|
22
|
-
function sanitizeFilename(name) {
|
|
26
|
+
export function sanitizeFilename(name) {
|
|
23
27
|
return name.replace(/[/\\?%*:|"<>]/g, '-').replace(/\s+/g, '-').substring(0, 200);
|
|
24
28
|
}
|
|
25
29
|
|
|
@@ -27,6 +31,55 @@ async function fileExists(path) {
|
|
|
27
31
|
try { await access(path); return true; } catch { return false; }
|
|
28
32
|
}
|
|
29
33
|
|
|
34
|
+
/**
|
|
35
|
+
* Resolve a content Path to a directory under Bins/.
|
|
36
|
+
*
|
|
37
|
+
* When a record has BinID=null but a Path like "tpl/ticket-menu-list.html"
|
|
38
|
+
* or "app/nima-test-test.txt", we need to place it inside Bins/:
|
|
39
|
+
*
|
|
40
|
+
* 1. Extract the directory portion of the Path (e.g. "tpl", "app")
|
|
41
|
+
* 2. Try to match it to an existing bin in structure.json
|
|
42
|
+
* - If found → use the resolved bin path (e.g. "Bins/app")
|
|
43
|
+
* 3. If not found → place under Bins/<dir> (e.g. "Bins/tpl")
|
|
44
|
+
*
|
|
45
|
+
* This ensures content files always land inside Bins/, never at the project root.
|
|
46
|
+
*
|
|
47
|
+
* **Note on Bins/app/:**
|
|
48
|
+
* Files placed in Bins/app/ are purely for local organization. During push operations,
|
|
49
|
+
* the "app/" subdirectory is treated specially and stripped from path comparisons,
|
|
50
|
+
* as these files are served from the root path on the server without the "app/" prefix.
|
|
51
|
+
* Other custom bin directories (like "tpl/") maintain their directory name in the path.
|
|
52
|
+
*
|
|
53
|
+
* @param {string} pathValue - Server-side Path value
|
|
54
|
+
* @param {Object} structure - Bin hierarchy structure from structure.json
|
|
55
|
+
* @returns {string} - Local directory path under Bins/
|
|
56
|
+
*/
|
|
57
|
+
function resolvePathToBinsDir(pathValue, structure) {
|
|
58
|
+
const cleaned = pathValue.replace(/^\/+|\/+$/g, '');
|
|
59
|
+
if (!cleaned) return BINS_DIR;
|
|
60
|
+
|
|
61
|
+
// Extract the directory portion (strip filename if path contains one)
|
|
62
|
+
const pathExt = extname(cleaned);
|
|
63
|
+
let dirPart;
|
|
64
|
+
if (pathExt) {
|
|
65
|
+
const lastSlash = cleaned.lastIndexOf('/');
|
|
66
|
+
dirPart = lastSlash >= 0 ? cleaned.substring(0, lastSlash) : null;
|
|
67
|
+
} else {
|
|
68
|
+
dirPart = cleaned;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (!dirPart) return BINS_DIR;
|
|
72
|
+
|
|
73
|
+
// Try to match the directory path to an existing bin in structure.json
|
|
74
|
+
const bin = findBinByPath(dirPart, structure);
|
|
75
|
+
if (bin) {
|
|
76
|
+
return resolveBinPath(bin.binId, structure);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// No matching bin — place under Bins/<dir>
|
|
80
|
+
return `${BINS_DIR}/${dirPart}`;
|
|
81
|
+
}
|
|
82
|
+
|
|
30
83
|
export const cloneCommand = new Command('clone')
|
|
31
84
|
.description('Clone an app from DBO.io to a local project structure')
|
|
32
85
|
.argument('[source]', 'Local JSON file path (optional)')
|
|
@@ -163,9 +216,18 @@ export async function performClone(source, options = {}) {
|
|
|
163
216
|
if (['bin', 'content', 'output', 'media'].includes(entityName)) continue;
|
|
164
217
|
if (!Array.isArray(entries)) continue;
|
|
165
218
|
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
219
|
+
if (ENTITY_DIR_MAP[entityName]) {
|
|
220
|
+
// Entity types with project directories — process into their directory
|
|
221
|
+
const refs = await processEntityDirEntries(entityName, entries, options, serverTz);
|
|
222
|
+
if (refs.length > 0) {
|
|
223
|
+
otherRefs[entityName] = refs;
|
|
224
|
+
}
|
|
225
|
+
} else {
|
|
226
|
+
// Other entity types — process into Bins/ (requires BinID)
|
|
227
|
+
const refs = await processGenericEntries(entityName, entries, structure, options, placementPrefs.contentPlacement, serverTz);
|
|
228
|
+
if (refs.length > 0) {
|
|
229
|
+
otherRefs[entityName] = refs;
|
|
230
|
+
}
|
|
169
231
|
}
|
|
170
232
|
}
|
|
171
233
|
|
|
@@ -177,6 +239,12 @@ export async function performClone(source, options = {}) {
|
|
|
177
239
|
// Step 7: Save app.json with references
|
|
178
240
|
await saveAppJson(appJson, contentRefs, otherRefs);
|
|
179
241
|
|
|
242
|
+
// Step 8: Create .app.json baseline for delta tracking
|
|
243
|
+
await saveBaselineFile(appJson);
|
|
244
|
+
|
|
245
|
+
// Step 9: Ensure .app.json is in .gitignore
|
|
246
|
+
await ensureGitignore(['.app.json']);
|
|
247
|
+
|
|
180
248
|
log.plain('');
|
|
181
249
|
log.success('Clone complete!');
|
|
182
250
|
log.dim(' app.json saved to project root');
|
|
@@ -337,11 +405,12 @@ async function processContentEntries(contents, structure, options, contentPlacem
|
|
|
337
405
|
const placementPreference = {
|
|
338
406
|
value: (contentPlacement === 'path' || contentPlacement === 'bin') ? contentPlacement : null,
|
|
339
407
|
};
|
|
408
|
+
const bulkAction = { value: null }; // Shared across records for overwrite_all / skip_all
|
|
340
409
|
|
|
341
410
|
log.info(`Processing ${contents.length} content record(s)...`);
|
|
342
411
|
|
|
343
412
|
for (const record of contents) {
|
|
344
|
-
const ref = await processRecord('content', record, structure, options, usedNames, placementPreference, serverTz);
|
|
413
|
+
const ref = await processRecord('content', record, structure, options, usedNames, placementPreference, serverTz, bulkAction);
|
|
345
414
|
if (ref) refs.push(ref);
|
|
346
415
|
}
|
|
347
416
|
|
|
@@ -360,12 +429,13 @@ async function processGenericEntries(entityName, entries, structure, options, co
|
|
|
360
429
|
const placementPreference = {
|
|
361
430
|
value: (contentPlacement === 'path' || contentPlacement === 'bin') ? contentPlacement : null,
|
|
362
431
|
};
|
|
432
|
+
const bulkAction = { value: null };
|
|
363
433
|
let processed = 0;
|
|
364
434
|
|
|
365
435
|
for (const record of entries) {
|
|
366
436
|
if (!record.BinID) continue; // Skip entries without BinID
|
|
367
437
|
|
|
368
|
-
const ref = await processRecord(entityName, record, structure, options, usedNames, placementPreference, serverTz);
|
|
438
|
+
const ref = await processRecord(entityName, record, structure, options, usedNames, placementPreference, serverTz, bulkAction);
|
|
369
439
|
if (ref) {
|
|
370
440
|
refs.push(ref);
|
|
371
441
|
processed++;
|
|
@@ -379,6 +449,308 @@ async function processGenericEntries(entityName, entries, structure, options, co
|
|
|
379
449
|
return refs;
|
|
380
450
|
}
|
|
381
451
|
|
|
452
|
+
/**
|
|
453
|
+
* Process entity-dir entries: entities that map to DEFAULT_PROJECT_DIRS.
|
|
454
|
+
* These don't require a BinID — they go directly into their project directory
|
|
455
|
+
* (e.g., Extensions/, Data Sources/, etc.)
|
|
456
|
+
*
|
|
457
|
+
* Returns array of { uid, metaPath } for app.json reference replacement.
|
|
458
|
+
*/
|
|
459
|
+
async function processEntityDirEntries(entityName, entries, options, serverTz) {
|
|
460
|
+
if (!entries || entries.length === 0) return [];
|
|
461
|
+
|
|
462
|
+
const dirName = ENTITY_DIR_MAP[entityName];
|
|
463
|
+
if (!dirName) return [];
|
|
464
|
+
|
|
465
|
+
await mkdir(dirName, { recursive: true });
|
|
466
|
+
|
|
467
|
+
const refs = [];
|
|
468
|
+
const usedNames = new Map();
|
|
469
|
+
const bulkAction = { value: null };
|
|
470
|
+
const config = await loadConfig();
|
|
471
|
+
|
|
472
|
+
// Determine filename column: saved preference, or prompt, or default
|
|
473
|
+
let filenameCol = null;
|
|
474
|
+
|
|
475
|
+
if (options.yes) {
|
|
476
|
+
// Non-interactive: use Name, fallback UID
|
|
477
|
+
filenameCol = null; // will resolve per-record below
|
|
478
|
+
} else {
|
|
479
|
+
// Check saved preference
|
|
480
|
+
const savedCol = await loadEntityDirPreference(entityName);
|
|
481
|
+
if (savedCol) {
|
|
482
|
+
filenameCol = savedCol;
|
|
483
|
+
log.dim(` Using saved filename column "${filenameCol}" for ${entityName}`);
|
|
484
|
+
} else {
|
|
485
|
+
// Prompt user to pick a filename column from available columns
|
|
486
|
+
const sampleRecord = entries[0];
|
|
487
|
+
const columns = Object.keys(sampleRecord).filter(k => k !== 'children' && !k.startsWith('_'));
|
|
488
|
+
|
|
489
|
+
if (columns.length > 0) {
|
|
490
|
+
const inquirer = (await import('inquirer')).default;
|
|
491
|
+
|
|
492
|
+
// Find best default (app_version prioritizes Number → Name → UID)
|
|
493
|
+
const defaultCol = (entityName === 'app_version' && columns.includes('Number')) ? 'Number'
|
|
494
|
+
: columns.includes('Name') ? 'Name'
|
|
495
|
+
: columns.includes('name') ? 'name'
|
|
496
|
+
: columns.includes('UID') ? 'UID'
|
|
497
|
+
: columns[0];
|
|
498
|
+
|
|
499
|
+
const { col } = await inquirer.prompt([{
|
|
500
|
+
type: 'list',
|
|
501
|
+
name: 'col',
|
|
502
|
+
message: `Which column should be used as the filename for ${entityName} records?`,
|
|
503
|
+
choices: columns,
|
|
504
|
+
default: defaultCol,
|
|
505
|
+
}]);
|
|
506
|
+
filenameCol = col;
|
|
507
|
+
|
|
508
|
+
// Save preference for future clones
|
|
509
|
+
await saveEntityDirPreference(entityName, filenameCol);
|
|
510
|
+
log.dim(` Saved filename column preference for ${entityName}`);
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
// Detect base64 content columns and prompt per-column for extraction
|
|
516
|
+
let contentColsToExtract = [];
|
|
517
|
+
if (!options.yes) {
|
|
518
|
+
// Collect base64 columns with a content snippet from the first record that has data
|
|
519
|
+
const base64Cols = []; // { col, snippet }
|
|
520
|
+
for (const record of entries) {
|
|
521
|
+
for (const [key, value] of Object.entries(record)) {
|
|
522
|
+
if (key === 'children' || key.startsWith('_')) continue;
|
|
523
|
+
if (value && typeof value === 'object' && !Array.isArray(value)
|
|
524
|
+
&& value.encoding === 'base64' && value.value !== null) {
|
|
525
|
+
if (!base64Cols.find(c => c.col === key)) {
|
|
526
|
+
// Decode a snippet for preview
|
|
527
|
+
let snippet = '';
|
|
528
|
+
try {
|
|
529
|
+
const decoded = Buffer.from(value.value, 'base64').toString('utf8');
|
|
530
|
+
snippet = decoded.substring(0, 80).replace(/\n/g, ' ').replace(/\s+/g, ' ').trim();
|
|
531
|
+
if (decoded.length > 80) snippet += '...';
|
|
532
|
+
} catch { /* ignore decode errors */ }
|
|
533
|
+
base64Cols.push({ col: key, snippet });
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
if (base64Cols.length > 0) {
|
|
540
|
+
// Load saved content extraction preferences
|
|
541
|
+
const savedExtractions = await loadEntityContentExtractions(entityName);
|
|
542
|
+
const newPreferences = savedExtractions ? { ...savedExtractions } : {};
|
|
543
|
+
let hasNewChoices = false;
|
|
544
|
+
|
|
545
|
+
const inquirer = (await import('inquirer')).default;
|
|
546
|
+
|
|
547
|
+
// Prompt per column: show snippet and ask extract yes/no + extension
|
|
548
|
+
for (const { col, snippet } of base64Cols) {
|
|
549
|
+
// Check if we have a saved preference for this column
|
|
550
|
+
if (savedExtractions && col in savedExtractions) {
|
|
551
|
+
const savedPref = savedExtractions[col];
|
|
552
|
+
if (savedPref === false) {
|
|
553
|
+
// User previously chose not to extract this column
|
|
554
|
+
log.dim(` Skipping "${col}" (saved preference: no extraction)`);
|
|
555
|
+
continue;
|
|
556
|
+
} else if (typeof savedPref === 'string') {
|
|
557
|
+
// User previously chose to extract with a specific extension
|
|
558
|
+
log.dim(` Extracting "${col}" as .${savedPref} file (saved preference)`);
|
|
559
|
+
contentColsToExtract.push({ col, ext: savedPref });
|
|
560
|
+
continue;
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
// No saved preference - prompt the user
|
|
565
|
+
const preview = snippet ? ` (${snippet})` : '';
|
|
566
|
+
const guessed = guessExtensionForColumn(col);
|
|
567
|
+
|
|
568
|
+
const { extract } = await inquirer.prompt([{
|
|
569
|
+
type: 'confirm',
|
|
570
|
+
name: 'extract',
|
|
571
|
+
message: `Extract "${col}" as companion file?${preview}`,
|
|
572
|
+
default: true,
|
|
573
|
+
}]);
|
|
574
|
+
|
|
575
|
+
if (extract) {
|
|
576
|
+
const { ext } = await inquirer.prompt([{
|
|
577
|
+
type: 'input',
|
|
578
|
+
name: 'ext',
|
|
579
|
+
message: `File extension for "${col}":`,
|
|
580
|
+
default: guessed,
|
|
581
|
+
}]);
|
|
582
|
+
const cleanExt = ext.replace(/^\./, '');
|
|
583
|
+
contentColsToExtract.push({ col, ext: cleanExt });
|
|
584
|
+
newPreferences[col] = cleanExt;
|
|
585
|
+
hasNewChoices = true;
|
|
586
|
+
} else {
|
|
587
|
+
// User chose not to extract - save this preference too
|
|
588
|
+
newPreferences[col] = false;
|
|
589
|
+
hasNewChoices = true;
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
// Save preferences if any new choices were made
|
|
594
|
+
if (hasNewChoices) {
|
|
595
|
+
await saveEntityContentExtractions(entityName, newPreferences);
|
|
596
|
+
log.dim(` Saved content extraction preferences for ${entityName}`);
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
log.info(`Processing ${entries.length} ${entityName} record(s) → ${dirName}/`);
|
|
602
|
+
|
|
603
|
+
for (const record of entries) {
|
|
604
|
+
// Determine filename for this record
|
|
605
|
+
let name;
|
|
606
|
+
if (filenameCol && record[filenameCol] !== null && record[filenameCol] !== undefined) {
|
|
607
|
+
name = sanitizeFilename(String(record[filenameCol]));
|
|
608
|
+
} else if (entityName === 'app_version' && record.Number) {
|
|
609
|
+
// Special fallback for app_version: Number is preferred over Name
|
|
610
|
+
name = sanitizeFilename(String(record.Number));
|
|
611
|
+
} else if (record.Name) {
|
|
612
|
+
name = sanitizeFilename(String(record.Name));
|
|
613
|
+
} else {
|
|
614
|
+
name = sanitizeFilename(String(record.UID || 'untitled'));
|
|
615
|
+
}
|
|
616
|
+
|
|
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;
|
|
622
|
+
|
|
623
|
+
const metaPath = join(dirName, `${finalName}.metadata.json`);
|
|
624
|
+
|
|
625
|
+
// Change detection for existing files
|
|
626
|
+
// Skip change detection when user has selected new content columns to extract —
|
|
627
|
+
// we need to re-process all records to create the companion files
|
|
628
|
+
const hasNewExtractions = contentColsToExtract.length > 0;
|
|
629
|
+
if (await fileExists(metaPath) && !options.yes && !hasNewExtractions) {
|
|
630
|
+
if (bulkAction.value === 'skip_all') {
|
|
631
|
+
log.dim(` Skipped ${finalName}`);
|
|
632
|
+
refs.push({ uid: record.UID, metaPath });
|
|
633
|
+
continue;
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
if (bulkAction.value !== 'overwrite_all') {
|
|
637
|
+
const localSyncTime = await getLocalSyncTime(metaPath);
|
|
638
|
+
const serverNewer = isServerNewer(localSyncTime, record._LastUpdated, config);
|
|
639
|
+
|
|
640
|
+
if (serverNewer) {
|
|
641
|
+
const action = await promptChangeDetection(finalName, record, config);
|
|
642
|
+
|
|
643
|
+
if (action === 'skip') {
|
|
644
|
+
log.dim(` Skipped ${finalName}`);
|
|
645
|
+
refs.push({ uid: record.UID, metaPath });
|
|
646
|
+
continue;
|
|
647
|
+
}
|
|
648
|
+
if (action === 'skip_all') {
|
|
649
|
+
bulkAction.value = 'skip_all';
|
|
650
|
+
log.dim(` Skipped ${finalName}`);
|
|
651
|
+
refs.push({ uid: record.UID, metaPath });
|
|
652
|
+
continue;
|
|
653
|
+
}
|
|
654
|
+
if (action === 'overwrite_all') {
|
|
655
|
+
bulkAction.value = 'overwrite_all';
|
|
656
|
+
}
|
|
657
|
+
if (action === 'compare') {
|
|
658
|
+
await inlineDiffAndMerge(record, metaPath, config);
|
|
659
|
+
refs.push({ uid: record.UID, metaPath });
|
|
660
|
+
continue;
|
|
661
|
+
}
|
|
662
|
+
} else {
|
|
663
|
+
const locallyModified = await hasLocalModifications(metaPath, config);
|
|
664
|
+
if (locallyModified) {
|
|
665
|
+
const action = await promptChangeDetection(finalName, record, config, { localIsNewer: true });
|
|
666
|
+
|
|
667
|
+
if (action === 'skip') {
|
|
668
|
+
log.dim(` Kept local: ${finalName}`);
|
|
669
|
+
refs.push({ uid: record.UID, metaPath });
|
|
670
|
+
continue;
|
|
671
|
+
}
|
|
672
|
+
if (action === 'skip_all') {
|
|
673
|
+
bulkAction.value = 'skip_all';
|
|
674
|
+
log.dim(` Kept local: ${finalName}`);
|
|
675
|
+
refs.push({ uid: record.UID, metaPath });
|
|
676
|
+
continue;
|
|
677
|
+
}
|
|
678
|
+
if (action === 'overwrite_all') {
|
|
679
|
+
bulkAction.value = 'overwrite_all';
|
|
680
|
+
}
|
|
681
|
+
if (action === 'compare') {
|
|
682
|
+
await inlineDiffAndMerge(record, metaPath, config, { localIsNewer: true });
|
|
683
|
+
refs.push({ uid: record.UID, metaPath });
|
|
684
|
+
continue;
|
|
685
|
+
}
|
|
686
|
+
} else {
|
|
687
|
+
log.dim(` Up to date: ${finalName}`);
|
|
688
|
+
refs.push({ uid: record.UID, metaPath });
|
|
689
|
+
continue;
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
// Build metadata
|
|
696
|
+
const meta = {};
|
|
697
|
+
const extractedContentCols = [];
|
|
698
|
+
|
|
699
|
+
for (const [key, value] of Object.entries(record)) {
|
|
700
|
+
if (key === 'children') continue;
|
|
701
|
+
|
|
702
|
+
// Check if this column should be extracted as a companion file
|
|
703
|
+
const extractInfo = contentColsToExtract.find(c => c.col === key);
|
|
704
|
+
if (extractInfo && value && typeof value === 'object' && value.encoding === 'base64' && value.value !== null) {
|
|
705
|
+
const decoded = resolveContentValue(value);
|
|
706
|
+
if (decoded) {
|
|
707
|
+
const colFileName = `${finalName}.${key}.${extractInfo.ext}`;
|
|
708
|
+
const colFilePath = join(dirName, colFileName);
|
|
709
|
+
await writeFile(colFilePath, decoded);
|
|
710
|
+
meta[key] = `@${colFileName}`;
|
|
711
|
+
extractedContentCols.push(key);
|
|
712
|
+
|
|
713
|
+
// Set timestamps on companion file
|
|
714
|
+
if (serverTz && (record._CreatedOn || record._LastUpdated)) {
|
|
715
|
+
try {
|
|
716
|
+
await setFileTimestamps(colFilePath, record._CreatedOn, record._LastUpdated, serverTz);
|
|
717
|
+
} catch { /* non-critical */ }
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
log.dim(` → ${colFilePath}`);
|
|
721
|
+
continue;
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
// Other base64 columns not selected for extraction — decode inline
|
|
726
|
+
if (value && typeof value === 'object' && !Array.isArray(value) && value.encoding === 'base64') {
|
|
727
|
+
meta[key] = resolveContentValue(value);
|
|
728
|
+
} else {
|
|
729
|
+
meta[key] = value;
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
meta._entity = entityName;
|
|
734
|
+
if (extractedContentCols.length > 0) {
|
|
735
|
+
meta._contentColumns = extractedContentCols;
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
await writeFile(metaPath, JSON.stringify(meta, null, 2) + '\n');
|
|
739
|
+
log.success(`Saved ${metaPath}`);
|
|
740
|
+
|
|
741
|
+
// Set file timestamps from server dates
|
|
742
|
+
if (serverTz && (record._CreatedOn || record._LastUpdated)) {
|
|
743
|
+
try {
|
|
744
|
+
await setFileTimestamps(metaPath, record._CreatedOn, record._LastUpdated, serverTz);
|
|
745
|
+
} catch { /* non-critical */ }
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
refs.push({ uid: record.UID, metaPath });
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
return refs;
|
|
752
|
+
}
|
|
753
|
+
|
|
382
754
|
/**
|
|
383
755
|
* Process media entries: download binary files from server + create metadata.
|
|
384
756
|
* Media uses Filename (not Name) and files are fetched via /api/media/{uid}.
|
|
@@ -424,6 +796,7 @@ async function processMediaEntries(mediaRecords, structure, options, config, app
|
|
|
424
796
|
const placementPreference = {
|
|
425
797
|
value: mediaPlacement === 'fullpath' ? 'path' : mediaPlacement === 'bin' ? 'bin' : null,
|
|
426
798
|
};
|
|
799
|
+
const mediaBulkAction = { value: null };
|
|
427
800
|
let downloaded = 0;
|
|
428
801
|
let failed = 0;
|
|
429
802
|
|
|
@@ -503,6 +876,76 @@ async function processMediaEntries(mediaRecords, structure, options, config, app
|
|
|
503
876
|
const filePath = join(dir, finalFilename);
|
|
504
877
|
const metaPath = join(dir, `${finalFilename}.metadata.json`);
|
|
505
878
|
|
|
879
|
+
// Change detection for existing media files
|
|
880
|
+
if (await fileExists(metaPath) && !options.yes) {
|
|
881
|
+
if (mediaBulkAction.value === 'skip_all') {
|
|
882
|
+
log.dim(` Skipped ${finalFilename}`);
|
|
883
|
+
refs.push({ uid: record.UID, metaPath });
|
|
884
|
+
continue;
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
if (mediaBulkAction.value !== 'overwrite_all') {
|
|
888
|
+
const localSyncTime = await getLocalSyncTime(metaPath);
|
|
889
|
+
const serverNewer = isServerNewer(localSyncTime, record._LastUpdated, config);
|
|
890
|
+
|
|
891
|
+
if (serverNewer) {
|
|
892
|
+
const action = await promptChangeDetection(dedupName, record, config);
|
|
893
|
+
|
|
894
|
+
if (action === 'skip') {
|
|
895
|
+
log.dim(` Skipped ${finalFilename}`);
|
|
896
|
+
refs.push({ uid: record.UID, metaPath });
|
|
897
|
+
continue;
|
|
898
|
+
}
|
|
899
|
+
if (action === 'skip_all') {
|
|
900
|
+
mediaBulkAction.value = 'skip_all';
|
|
901
|
+
log.dim(` Skipped ${finalFilename}`);
|
|
902
|
+
refs.push({ uid: record.UID, metaPath });
|
|
903
|
+
continue;
|
|
904
|
+
}
|
|
905
|
+
if (action === 'overwrite_all') {
|
|
906
|
+
mediaBulkAction.value = 'overwrite_all';
|
|
907
|
+
}
|
|
908
|
+
if (action === 'compare') {
|
|
909
|
+
// For binary media, show metadata diffs only
|
|
910
|
+
await inlineDiffAndMerge(record, metaPath, config);
|
|
911
|
+
refs.push({ uid: record.UID, metaPath });
|
|
912
|
+
continue;
|
|
913
|
+
}
|
|
914
|
+
} else {
|
|
915
|
+
// Server _LastUpdated hasn't changed — check for local modifications
|
|
916
|
+
const locallyModified = await hasLocalModifications(metaPath, config);
|
|
917
|
+
if (locallyModified) {
|
|
918
|
+
const action = await promptChangeDetection(dedupName, record, config, { localIsNewer: true });
|
|
919
|
+
|
|
920
|
+
if (action === 'skip') {
|
|
921
|
+
log.dim(` Kept local: ${finalFilename}`);
|
|
922
|
+
refs.push({ uid: record.UID, metaPath });
|
|
923
|
+
continue;
|
|
924
|
+
}
|
|
925
|
+
if (action === 'skip_all') {
|
|
926
|
+
mediaBulkAction.value = 'skip_all';
|
|
927
|
+
log.dim(` Kept local: ${finalFilename}`);
|
|
928
|
+
refs.push({ uid: record.UID, metaPath });
|
|
929
|
+
continue;
|
|
930
|
+
}
|
|
931
|
+
if (action === 'overwrite_all') {
|
|
932
|
+
mediaBulkAction.value = 'overwrite_all';
|
|
933
|
+
}
|
|
934
|
+
if (action === 'compare') {
|
|
935
|
+
await inlineDiffAndMerge(record, metaPath, config, { localIsNewer: true });
|
|
936
|
+
refs.push({ uid: record.UID, metaPath });
|
|
937
|
+
continue;
|
|
938
|
+
}
|
|
939
|
+
// 'overwrite' falls through to download
|
|
940
|
+
} else {
|
|
941
|
+
log.dim(` Up to date: ${finalFilename}`);
|
|
942
|
+
refs.push({ uid: record.UID, metaPath });
|
|
943
|
+
continue;
|
|
944
|
+
}
|
|
945
|
+
}
|
|
946
|
+
}
|
|
947
|
+
}
|
|
948
|
+
|
|
506
949
|
// Download the file
|
|
507
950
|
try {
|
|
508
951
|
const buffer = await client.getBuffer(`/api/media/${record.UID}`);
|
|
@@ -551,14 +994,35 @@ async function processMediaEntries(mediaRecords, structure, options, config, app
|
|
|
551
994
|
* Process a single record: determine directory, write content file + metadata.
|
|
552
995
|
* Returns { uid, metaPath } or null.
|
|
553
996
|
*/
|
|
554
|
-
async function processRecord(entityName, record, structure, options, usedNames, placementPreference, serverTz) {
|
|
997
|
+
async function processRecord(entityName, record, structure, options, usedNames, placementPreference, serverTz, bulkAction = { value: null }) {
|
|
555
998
|
let name = sanitizeFilename(String(record.Name || record.UID || 'untitled'));
|
|
556
|
-
|
|
999
|
+
|
|
1000
|
+
// Determine file extension (priority: Extension field > Name field > Path field > empty)
|
|
1001
|
+
let ext = '';
|
|
1002
|
+
if (record.Extension) {
|
|
1003
|
+
// Use explicit Extension field
|
|
1004
|
+
ext = String(record.Extension).toLowerCase();
|
|
1005
|
+
} else {
|
|
1006
|
+
// Try to extract from Name field first
|
|
1007
|
+
if (record.Name) {
|
|
1008
|
+
const extractedExt = extname(String(record.Name));
|
|
1009
|
+
ext = extractedExt ? extractedExt.substring(1).toLowerCase() : '';
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
// If no extension from Name, try Path field as fallback
|
|
1013
|
+
if (!ext && record.Path) {
|
|
1014
|
+
const extractedExt = extname(String(record.Path));
|
|
1015
|
+
ext = extractedExt ? extractedExt.substring(1).toLowerCase() : '';
|
|
1016
|
+
}
|
|
1017
|
+
}
|
|
1018
|
+
// If still no extension, ext remains '' (no extension)
|
|
557
1019
|
|
|
558
1020
|
// Avoid double extension: if name already ends with .ext, strip it
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
1021
|
+
if (ext) {
|
|
1022
|
+
const extWithDot = `.${ext}`;
|
|
1023
|
+
if (name.toLowerCase().endsWith(extWithDot)) {
|
|
1024
|
+
name = name.substring(0, name.length - extWithDot.length);
|
|
1025
|
+
}
|
|
562
1026
|
}
|
|
563
1027
|
|
|
564
1028
|
// Determine target directory (default: bins/ for items without explicit placement)
|
|
@@ -603,7 +1067,8 @@ async function processRecord(entityName, record, structure, options, usedNames,
|
|
|
603
1067
|
} else if (hasBinID) {
|
|
604
1068
|
dir = resolveBinPath(record.BinID, structure);
|
|
605
1069
|
} else if (hasPath) {
|
|
606
|
-
|
|
1070
|
+
// BinID is null — resolve Path into a Bins/ location
|
|
1071
|
+
dir = resolvePathToBinsDir(record.Path, structure);
|
|
607
1072
|
}
|
|
608
1073
|
|
|
609
1074
|
// Clean up directory path
|
|
@@ -632,10 +1097,75 @@ async function processRecord(entityName, record, structure, options, usedNames,
|
|
|
632
1097
|
(typeof contentValue === 'string' && contentValue.length > 0)
|
|
633
1098
|
);
|
|
634
1099
|
|
|
635
|
-
const fileName = `${finalName}.${ext}
|
|
1100
|
+
const fileName = ext ? `${finalName}.${ext}` : finalName;
|
|
636
1101
|
const filePath = join(dir, fileName);
|
|
637
1102
|
const metaPath = join(dir, `${finalName}.metadata.json`);
|
|
638
1103
|
|
|
1104
|
+
// Change detection: check if file already exists locally
|
|
1105
|
+
if (await fileExists(metaPath) && !options.yes) {
|
|
1106
|
+
if (bulkAction.value === 'skip_all') {
|
|
1107
|
+
log.dim(` Skipped ${finalName}.${ext}`);
|
|
1108
|
+
return { uid: record.UID, metaPath };
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1111
|
+
if (bulkAction.value !== 'overwrite_all') {
|
|
1112
|
+
const localSyncTime = await getLocalSyncTime(metaPath);
|
|
1113
|
+
const config = await loadConfig();
|
|
1114
|
+
const serverNewer = isServerNewer(localSyncTime, record._LastUpdated, config);
|
|
1115
|
+
|
|
1116
|
+
if (serverNewer) {
|
|
1117
|
+
const action = await promptChangeDetection(finalName, record, config);
|
|
1118
|
+
|
|
1119
|
+
if (action === 'skip') {
|
|
1120
|
+
log.dim(` Skipped ${finalName}.${ext}`);
|
|
1121
|
+
return { uid: record.UID, metaPath };
|
|
1122
|
+
}
|
|
1123
|
+
if (action === 'skip_all') {
|
|
1124
|
+
bulkAction.value = 'skip_all';
|
|
1125
|
+
log.dim(` Skipped ${finalName}.${ext}`);
|
|
1126
|
+
return { uid: record.UID, metaPath };
|
|
1127
|
+
}
|
|
1128
|
+
if (action === 'overwrite_all') {
|
|
1129
|
+
bulkAction.value = 'overwrite_all';
|
|
1130
|
+
// Fall through to write
|
|
1131
|
+
}
|
|
1132
|
+
if (action === 'compare') {
|
|
1133
|
+
await inlineDiffAndMerge(record, metaPath, config);
|
|
1134
|
+
return { uid: record.UID, metaPath };
|
|
1135
|
+
}
|
|
1136
|
+
// 'overwrite' falls through to normal write
|
|
1137
|
+
} else {
|
|
1138
|
+
// Server _LastUpdated hasn't changed since last sync.
|
|
1139
|
+
// Check if local content files were modified (user edits).
|
|
1140
|
+
const locallyModified = await hasLocalModifications(metaPath, config);
|
|
1141
|
+
if (locallyModified) {
|
|
1142
|
+
const action = await promptChangeDetection(finalName, record, config, { localIsNewer: true });
|
|
1143
|
+
|
|
1144
|
+
if (action === 'skip') {
|
|
1145
|
+
log.dim(` Kept local: ${finalName}.${ext}`);
|
|
1146
|
+
return { uid: record.UID, metaPath };
|
|
1147
|
+
}
|
|
1148
|
+
if (action === 'skip_all') {
|
|
1149
|
+
bulkAction.value = 'skip_all';
|
|
1150
|
+
log.dim(` Kept local: ${finalName}.${ext}`);
|
|
1151
|
+
return { uid: record.UID, metaPath };
|
|
1152
|
+
}
|
|
1153
|
+
if (action === 'overwrite_all') {
|
|
1154
|
+
bulkAction.value = 'overwrite_all';
|
|
1155
|
+
}
|
|
1156
|
+
if (action === 'compare') {
|
|
1157
|
+
await inlineDiffAndMerge(record, metaPath, config, { localIsNewer: true });
|
|
1158
|
+
return { uid: record.UID, metaPath };
|
|
1159
|
+
}
|
|
1160
|
+
// 'overwrite' falls through to normal write
|
|
1161
|
+
} else {
|
|
1162
|
+
log.dim(` Up to date: ${finalName}.${ext}`);
|
|
1163
|
+
return { uid: record.UID, metaPath };
|
|
1164
|
+
}
|
|
1165
|
+
}
|
|
1166
|
+
}
|
|
1167
|
+
}
|
|
1168
|
+
|
|
639
1169
|
if (hasContent) {
|
|
640
1170
|
const decoded = resolveContentValue(contentValue);
|
|
641
1171
|
if (decoded) {
|
|
@@ -694,7 +1224,7 @@ async function processRecord(entityName, record, structure, options, usedNames,
|
|
|
694
1224
|
/**
|
|
695
1225
|
* Guess file extension for a column name.
|
|
696
1226
|
*/
|
|
697
|
-
function guessExtensionForColumn(columnName) {
|
|
1227
|
+
export function guessExtensionForColumn(columnName) {
|
|
698
1228
|
const lower = columnName.toLowerCase();
|
|
699
1229
|
if (lower.includes('css')) return 'css';
|
|
700
1230
|
if (lower.includes('js') || lower.includes('script')) return 'js';
|
|
@@ -740,3 +1270,51 @@ async function saveAppJson(appJson, contentRefs, otherRefs) {
|
|
|
740
1270
|
|
|
741
1271
|
await writeFile('app.json', JSON.stringify(output, null, 2) + '\n');
|
|
742
1272
|
}
|
|
1273
|
+
|
|
1274
|
+
/**
|
|
1275
|
+
* Save .app.json baseline file with decoded base64 values.
|
|
1276
|
+
* This file tracks the server state for delta detection.
|
|
1277
|
+
*/
|
|
1278
|
+
async function saveBaselineFile(appJson) {
|
|
1279
|
+
// Deep clone the app JSON
|
|
1280
|
+
const baseline = JSON.parse(JSON.stringify(appJson));
|
|
1281
|
+
|
|
1282
|
+
// Recursively decode all base64 fields
|
|
1283
|
+
decodeBase64Fields(baseline);
|
|
1284
|
+
|
|
1285
|
+
// Save to .app.json
|
|
1286
|
+
await saveAppJsonBaseline(baseline);
|
|
1287
|
+
|
|
1288
|
+
log.dim(' .app.json baseline created (system-managed, do not edit)');
|
|
1289
|
+
}
|
|
1290
|
+
|
|
1291
|
+
/**
|
|
1292
|
+
* Recursively decode base64 fields in an object or array.
|
|
1293
|
+
* Modifies the input object in-place.
|
|
1294
|
+
*/
|
|
1295
|
+
function decodeBase64Fields(obj) {
|
|
1296
|
+
if (!obj || typeof obj !== 'object') {
|
|
1297
|
+
return;
|
|
1298
|
+
}
|
|
1299
|
+
|
|
1300
|
+
if (Array.isArray(obj)) {
|
|
1301
|
+
for (const item of obj) {
|
|
1302
|
+
decodeBase64Fields(item);
|
|
1303
|
+
}
|
|
1304
|
+
return;
|
|
1305
|
+
}
|
|
1306
|
+
|
|
1307
|
+
// Process each property
|
|
1308
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
1309
|
+
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') {
|
|
1312
|
+
// Decode using existing resolveContentValue function
|
|
1313
|
+
obj[key] = resolveContentValue(value);
|
|
1314
|
+
} else {
|
|
1315
|
+
// Recursively process nested objects/arrays
|
|
1316
|
+
decodeBase64Fields(value);
|
|
1317
|
+
}
|
|
1318
|
+
}
|
|
1319
|
+
}
|
|
1320
|
+
}
|