@dboio/cli 0.9.8 → 0.11.1
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 +172 -70
- package/bin/dbo.js +2 -0
- package/bin/postinstall.js +9 -1
- package/package.json +3 -3
- package/plugins/claude/dbo/commands/dbo.md +3 -3
- package/plugins/claude/dbo/skills/cli/SKILL.md +3 -3
- package/src/commands/add.js +50 -0
- package/src/commands/clone.js +720 -552
- package/src/commands/content.js +7 -3
- package/src/commands/deploy.js +22 -7
- package/src/commands/diff.js +41 -3
- package/src/commands/init.js +42 -79
- package/src/commands/input.js +5 -0
- package/src/commands/login.js +2 -2
- package/src/commands/mv.js +3 -0
- package/src/commands/output.js +8 -10
- package/src/commands/pull.js +268 -87
- package/src/commands/push.js +814 -94
- package/src/commands/rm.js +4 -1
- package/src/commands/status.js +12 -1
- package/src/commands/sync.js +71 -0
- package/src/lib/client.js +10 -0
- package/src/lib/config.js +80 -8
- package/src/lib/delta.js +178 -25
- package/src/lib/diff.js +150 -20
- package/src/lib/folder-icon.js +120 -0
- package/src/lib/ignore.js +2 -3
- package/src/lib/input-parser.js +37 -10
- package/src/lib/metadata-templates.js +21 -4
- package/src/lib/migrations.js +75 -0
- package/src/lib/save-to-disk.js +1 -1
- package/src/lib/scaffold.js +58 -3
- package/src/lib/structure.js +158 -21
- package/src/lib/toe-stepping.js +381 -0
- package/src/migrations/001-transaction-key-preset-scope.js +35 -0
- package/src/migrations/002-move-entity-dirs-to-lib.js +190 -0
- package/src/migrations/003-move-deploy-config.js +50 -0
- package/src/migrations/004-rename-output-files.js +101 -0
package/src/commands/clone.js
CHANGED
|
@@ -1,15 +1,18 @@
|
|
|
1
1
|
import { Command } from 'commander';
|
|
2
|
-
import { readFile, writeFile, mkdir, access } from 'fs/promises';
|
|
3
|
-
import { join, basename, extname } from 'path';
|
|
2
|
+
import { readFile, writeFile, appendFile, mkdir, access, readdir, rename } from 'fs/promises';
|
|
3
|
+
import { join, basename, extname, dirname } from 'path';
|
|
4
|
+
import { fileURLToPath } from 'url';
|
|
4
5
|
import { DboClient } from '../lib/client.js';
|
|
5
6
|
import { loadConfig, updateConfigWithApp, loadClonePlacement, saveClonePlacement, ensureGitignore, saveEntityDirPreference, loadEntityDirPreference, saveEntityContentExtractions, loadEntityContentExtractions, saveAppJsonBaseline, addDeleteEntry, loadCollisionResolutions, saveCollisionResolutions, loadSynchronize, saveSynchronize, saveAppModifyKey, loadTransactionKeyPreset, saveTransactionKeyPreset, loadOutputFilenamePreference, saveOutputFilenamePreference, saveCloneSource, loadCloneSource, saveDescriptorFilenamePreference, loadDescriptorFilenamePreference, saveDescriptorContentExtractions, loadDescriptorContentExtractions, saveExtensionDocumentationMDPlacement, loadExtensionDocumentationMDPlacement } from '../lib/config.js';
|
|
6
|
-
import { buildBinHierarchy, resolveBinPath, createDirectories, saveStructureFile,
|
|
7
|
+
import { buildBinHierarchy, resolveBinPath, createDirectories, saveStructureFile, findBinByPath, BINS_DIR, DEFAULT_PROJECT_DIRS, SCAFFOLD_DIRS, ENTITY_DIR_NAMES, OUTPUT_ENTITY_MAP, OUTPUT_HIERARCHY_ENTITIES, EXTENSION_DESCRIPTORS_DIR, EXTENSION_UNSUPPORTED_DIR, DOCUMENTATION_DIR, buildDescriptorMapping, saveDescriptorMapping, loadDescriptorMapping, resolveExtensionSubDir, resolveEntityDirPath } from '../lib/structure.js';
|
|
7
8
|
import { log } from '../lib/logger.js';
|
|
8
9
|
import { buildUidFilename, detectLegacyDotUid } from '../lib/filenames.js';
|
|
9
10
|
import { setFileTimestamps, parseServerDate } from '../lib/timestamps.js';
|
|
10
|
-
import { getLocalSyncTime, isServerNewer, hasLocalModifications, promptChangeDetection, inlineDiffAndMerge, isDiffable } from '../lib/diff.js';
|
|
11
|
+
import { getLocalSyncTime, isServerNewer, hasLocalModifications, promptChangeDetection, inlineDiffAndMerge, isDiffable, loadBaselineForComparison, resetBaselineCache } from '../lib/diff.js';
|
|
11
12
|
import { checkDomainChange } from '../lib/domain-guard.js';
|
|
13
|
+
import { applyTrashIcon, ensureTrashIcon } from '../lib/folder-icon.js';
|
|
12
14
|
import { loadMetadataTemplates, saveMetadataTemplates, getTemplateCols, setTemplateCols, buildTemplateFromCloneRecord } from '../lib/metadata-templates.js';
|
|
15
|
+
import { runPendingMigrations } from '../lib/migrations.js';
|
|
13
16
|
|
|
14
17
|
/**
|
|
15
18
|
* Resolve a column value that may be base64-encoded.
|
|
@@ -93,7 +96,7 @@ function resolvePathToBinsDir(pathValue, structure) {
|
|
|
93
96
|
* Extract path components for content/generic records (read-only, no file writes).
|
|
94
97
|
* Replicates logic from processRecord() for collision detection.
|
|
95
98
|
*/
|
|
96
|
-
function resolveRecordPaths(entityName, record, structure, placementPref) {
|
|
99
|
+
export function resolveRecordPaths(entityName, record, structure, placementPref) {
|
|
97
100
|
let name = sanitizeFilename(String(record.Name || record.UID || 'untitled'));
|
|
98
101
|
|
|
99
102
|
// Determine extension (priority: Extension field > Name > Path)
|
|
@@ -152,28 +155,15 @@ function resolveRecordPaths(entityName, record, structure, placementPref) {
|
|
|
152
155
|
* Extract path components for media records.
|
|
153
156
|
* Replicates logic from processMediaEntries() for collision detection.
|
|
154
157
|
*/
|
|
155
|
-
function resolveMediaPaths(record, structure
|
|
158
|
+
export function resolveMediaPaths(record, structure) {
|
|
156
159
|
const filename = record.Filename || `${record.Name || record.UID}.${(record.Extension || 'bin').toLowerCase()}`;
|
|
157
160
|
const name = sanitizeFilename(filename.replace(/\.[^.]+$/, ''));
|
|
158
161
|
const ext = (record.Extension || 'bin').toLowerCase();
|
|
159
162
|
|
|
163
|
+
// Always place media by BinID; fall back to bins/ root
|
|
160
164
|
let dir = BINS_DIR;
|
|
161
|
-
|
|
162
|
-
const hasFullPath = record.FullPath && typeof record.FullPath === 'string';
|
|
163
|
-
|
|
164
|
-
let fullPathDir = null;
|
|
165
|
-
if (hasFullPath) {
|
|
166
|
-
const stripped = record.FullPath.replace(/^\/+/, '');
|
|
167
|
-
const lastSlash = stripped.lastIndexOf('/');
|
|
168
|
-
fullPathDir = lastSlash >= 0 ? stripped.substring(0, lastSlash) : BINS_DIR;
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
if (hasBinID && fullPathDir && fullPathDir !== '.') {
|
|
172
|
-
dir = placementPref === 'path' ? fullPathDir : resolveBinPath(record.BinID, structure);
|
|
173
|
-
} else if (hasBinID) {
|
|
165
|
+
if (record.BinID && structure[record.BinID]) {
|
|
174
166
|
dir = resolveBinPath(record.BinID, structure);
|
|
175
|
-
} else if (fullPathDir && fullPathDir !== BINS_DIR) {
|
|
176
|
-
dir = fullPathDir;
|
|
177
167
|
}
|
|
178
168
|
|
|
179
169
|
dir = dir.replace(/^\/+|\/+$/g, '');
|
|
@@ -191,7 +181,7 @@ function resolveMediaPaths(record, structure, placementPref) {
|
|
|
191
181
|
* Extract path components for entity-dir records.
|
|
192
182
|
* Simplified from processEntityDirEntries() for collision detection.
|
|
193
183
|
*/
|
|
194
|
-
function resolveEntityDirPaths(entityName, record, dirName) {
|
|
184
|
+
export function resolveEntityDirPaths(entityName, record, dirName) {
|
|
195
185
|
let name;
|
|
196
186
|
if (entityName === 'app_version' && record.Number) {
|
|
197
187
|
name = sanitizeFilename(String(record.Number));
|
|
@@ -231,9 +221,7 @@ async function buildFileRegistry(appJson, structure, placementPrefs) {
|
|
|
231
221
|
|
|
232
222
|
// Process media records
|
|
233
223
|
for (const record of (appJson.children.media || [])) {
|
|
234
|
-
const { dir, filename, metaPath } = resolveMediaPaths(
|
|
235
|
-
record, structure, placementPrefs.mediaPlacement
|
|
236
|
-
);
|
|
224
|
+
const { dir, filename, metaPath } = resolveMediaPaths(record, structure);
|
|
237
225
|
addToRegistry(join(dir, filename), 'media', record, dir, filename, metaPath);
|
|
238
226
|
}
|
|
239
227
|
|
|
@@ -441,8 +429,10 @@ export const cloneCommand = new Command('clone')
|
|
|
441
429
|
.option('-y, --yes', 'Auto-accept all prompts')
|
|
442
430
|
.option('--media-placement <placement>', 'Set media placement: fullpath or binpath (default: bin)')
|
|
443
431
|
.option('-v, --verbose', 'Show HTTP request details')
|
|
432
|
+
.option('--no-migrate', 'Skip pending migrations for this invocation')
|
|
444
433
|
.action(async (source, options) => {
|
|
445
434
|
try {
|
|
435
|
+
await runPendingMigrations(options);
|
|
446
436
|
await performClone(source, options);
|
|
447
437
|
} catch (err) {
|
|
448
438
|
log.error(err.message);
|
|
@@ -644,10 +634,10 @@ export async function performClone(source, options = {}) {
|
|
|
644
634
|
const effectiveDomain = options.domain || config.domain;
|
|
645
635
|
let appJson;
|
|
646
636
|
|
|
647
|
-
// Step 1: Source mismatch detection
|
|
637
|
+
// Step 1: Source mismatch detection (skip in pull mode)
|
|
648
638
|
// Warn when the user provides an explicit source that differs from the stored one.
|
|
649
|
-
const storedCloneSource = await loadCloneSource();
|
|
650
|
-
if (source && storedCloneSource && source !== storedCloneSource) {
|
|
639
|
+
const storedCloneSource = options.pullMode ? null : await loadCloneSource();
|
|
640
|
+
if (!options.pullMode && source && storedCloneSource && source !== storedCloneSource) {
|
|
651
641
|
if (!options.force && !options.yes) {
|
|
652
642
|
log.warn('');
|
|
653
643
|
log.warn(` ⚠ This project was previously cloned from: ${storedCloneSource}`);
|
|
@@ -707,99 +697,94 @@ export async function performClone(source, options = {}) {
|
|
|
707
697
|
}
|
|
708
698
|
}
|
|
709
699
|
|
|
710
|
-
log.success(
|
|
700
|
+
log.success(`${options.pullMode ? 'Pulling' : 'Cloning'} "${appJson.Name}" (${appJson.ShortName})`);
|
|
711
701
|
|
|
712
|
-
// Check for un-pushed staged items in synchronize.json
|
|
713
|
-
|
|
702
|
+
// Check for un-pushed staged items in synchronize.json (skip in pull mode)
|
|
703
|
+
if (!options.pullMode) {
|
|
704
|
+
await checkPendingSynchronize(options);
|
|
705
|
+
}
|
|
714
706
|
|
|
715
707
|
// Ensure sensitive files are gitignored
|
|
716
|
-
await ensureGitignore(['.dbo/credentials.json', '.dbo/cookies.txt']);
|
|
717
|
-
|
|
718
|
-
// Step 2: Update .dbo/config.json
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
if (
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
const existingPreset = await loadTransactionKeyPreset();
|
|
740
|
-
if (!existingPreset) {
|
|
741
|
-
if (options.yes || !process.stdin.isTTY) {
|
|
742
|
-
await saveTransactionKeyPreset('RowUID');
|
|
743
|
-
log.dim(' TransactionKeyPreset: RowUID (default)');
|
|
744
|
-
} else {
|
|
745
|
-
const inquirer = (await import('inquirer')).default;
|
|
746
|
-
const { preset } = await inquirer.prompt([{
|
|
747
|
-
type: 'list',
|
|
748
|
-
name: 'preset',
|
|
749
|
-
message: 'Which row key should the CLI use when building input expressions?',
|
|
750
|
-
choices: [
|
|
751
|
-
{ name: 'RowUID (recommended — stable across domains)', value: 'RowUID' },
|
|
752
|
-
{ name: 'RowID (numeric IDs)', value: 'RowID' },
|
|
753
|
-
],
|
|
754
|
-
}]);
|
|
755
|
-
await saveTransactionKeyPreset(preset);
|
|
756
|
-
log.dim(` TransactionKeyPreset: ${preset}`);
|
|
708
|
+
await ensureGitignore(['.dbo/credentials.json', '.dbo/cookies.txt', '.dbo/.app_baseline.json']);
|
|
709
|
+
|
|
710
|
+
// Step 2: Update .dbo/config.json (skip in pull mode — config already set)
|
|
711
|
+
if (!options.pullMode) {
|
|
712
|
+
await updateConfigWithApp({
|
|
713
|
+
AppID: appJson.AppID,
|
|
714
|
+
AppUID: appJson.UID,
|
|
715
|
+
AppName: appJson.Name,
|
|
716
|
+
AppShortName: appJson.ShortName,
|
|
717
|
+
});
|
|
718
|
+
await saveCloneSource(activeSource || 'default');
|
|
719
|
+
log.dim(' Updated .dbo/config.json with app metadata');
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
// Detect and store ModifyKey for locked/production apps (skip in pull mode)
|
|
723
|
+
if (!options.pullMode) {
|
|
724
|
+
const modifyKey = appJson.ModifyKey || null;
|
|
725
|
+
await saveAppModifyKey(modifyKey);
|
|
726
|
+
if (modifyKey) {
|
|
727
|
+
log.warn('');
|
|
728
|
+
log.warn(' ⚠ This app has a ModifyKey set (production/locked mode).');
|
|
729
|
+
log.warn(' You will be prompted to enter the ModifyKey before any push, input, add, content deploy, or deploy command.');
|
|
730
|
+
log.warn('');
|
|
757
731
|
}
|
|
758
732
|
}
|
|
759
733
|
|
|
760
|
-
//
|
|
761
|
-
|
|
734
|
+
// Auto-set TransactionKeyPreset to RowUID if not already set (skip in pull mode)
|
|
735
|
+
if (!options.pullMode) {
|
|
736
|
+
const existingPreset = await loadTransactionKeyPreset();
|
|
737
|
+
if (!existingPreset) {
|
|
738
|
+
await saveTransactionKeyPreset('RowUID');
|
|
739
|
+
log.dim(' TransactionKeyPreset: RowUID');
|
|
740
|
+
}
|
|
741
|
+
}
|
|
762
742
|
|
|
763
|
-
// Step
|
|
764
|
-
|
|
765
|
-
await
|
|
743
|
+
// Step 3: Update package.json (skip in pull mode)
|
|
744
|
+
if (!options.pullMode) {
|
|
745
|
+
await updatePackageJson(appJson, config);
|
|
766
746
|
}
|
|
767
747
|
|
|
748
|
+
// Step 4: Create default project directories + bin structure
|
|
768
749
|
const bins = appJson.children.bin || [];
|
|
769
750
|
const structure = buildBinHierarchy(bins, appJson.AppID);
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
'
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
751
|
+
|
|
752
|
+
if (!options.pullMode) {
|
|
753
|
+
for (const dir of SCAFFOLD_DIRS) {
|
|
754
|
+
await mkdir(dir, { recursive: true });
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
// Best-effort: apply trash icon
|
|
758
|
+
await applyTrashIcon(join(process.cwd(), 'trash'));
|
|
759
|
+
|
|
760
|
+
const createdDirs = await createDirectories(structure);
|
|
761
|
+
await saveStructureFile(structure);
|
|
762
|
+
|
|
763
|
+
const totalDirs = SCAFFOLD_DIRS.length + createdDirs.length;
|
|
764
|
+
log.success(`Created ${totalDirs} director${totalDirs === 1 ? 'y' : 'ies'}`);
|
|
765
|
+
for (const d of SCAFFOLD_DIRS) log.dim(` ${d}/`);
|
|
766
|
+
for (const d of createdDirs) log.dim(` ${d}/`);
|
|
767
|
+
|
|
768
|
+
// Warn about legacy root-level entity directories
|
|
769
|
+
const LEGACY_ENTITY_DIRS = [
|
|
770
|
+
'bins', 'extension', 'automation', 'app_version', 'entity',
|
|
771
|
+
'entity_column', 'entity_column_value', 'integration', 'security',
|
|
772
|
+
'security_column', 'data_source', 'group', 'site', 'redirect',
|
|
773
|
+
];
|
|
774
|
+
for (const oldName of LEGACY_ENTITY_DIRS) {
|
|
775
|
+
try {
|
|
776
|
+
await access(join(process.cwd(), oldName));
|
|
777
|
+
log.warn(`Legacy directory detected: "${oldName}/" — run \`dbo\` to trigger automatic migration to lib/${oldName}/`);
|
|
778
|
+
} catch { /* does not exist — no warning needed */ }
|
|
798
779
|
}
|
|
780
|
+
} else {
|
|
781
|
+
// Pull mode: reuse existing structure, just ensure dirs exist for new bins
|
|
782
|
+
await createDirectories(structure);
|
|
783
|
+
await saveStructureFile(structure);
|
|
799
784
|
}
|
|
800
785
|
|
|
801
786
|
// Step 4b: Determine placement preferences (from config or prompt)
|
|
802
|
-
const placementPrefs = await resolvePlacementPreferences(
|
|
787
|
+
const placementPrefs = await resolvePlacementPreferences();
|
|
803
788
|
|
|
804
789
|
// Ensure ServerTimezone is set in config (default: America/Los_Angeles for DBO.io)
|
|
805
790
|
let serverTz = config.ServerTimezone;
|
|
@@ -816,9 +801,9 @@ export async function performClone(source, options = {}) {
|
|
|
816
801
|
log.info(`Entity filter: only processing ${options.entity}`);
|
|
817
802
|
}
|
|
818
803
|
|
|
819
|
-
// Step 4c: Detect and resolve file path collisions (skip in entity-filter mode)
|
|
804
|
+
// Step 4c: Detect and resolve file path collisions (skip in pull mode and entity-filter mode)
|
|
820
805
|
let toDeleteUIDs = new Set();
|
|
821
|
-
if (!entityFilter) {
|
|
806
|
+
if (!options.pullMode && !entityFilter) {
|
|
822
807
|
log.info('Scanning for file path collisions...');
|
|
823
808
|
const fileRegistry = await buildFileRegistry(appJson, structure, placementPrefs);
|
|
824
809
|
toDeleteUIDs = await resolveCollisions(fileRegistry, options);
|
|
@@ -828,6 +813,10 @@ export async function performClone(source, options = {}) {
|
|
|
828
813
|
}
|
|
829
814
|
}
|
|
830
815
|
|
|
816
|
+
// Pre-load previous baseline for fast _LastUpdated string comparison in isServerNewer.
|
|
817
|
+
// This prevents false-positive "server newer" prompts caused by timezone conversion drift.
|
|
818
|
+
await loadBaselineForComparison();
|
|
819
|
+
|
|
831
820
|
// Step 5: Process content → files + metadata (skip rejected records)
|
|
832
821
|
let contentRefs = [];
|
|
833
822
|
if (!entityFilter || entityFilter.has('content')) {
|
|
@@ -841,12 +830,17 @@ export async function performClone(source, options = {}) {
|
|
|
841
830
|
);
|
|
842
831
|
}
|
|
843
832
|
|
|
833
|
+
// Step 5a: Write manifest.json to project root (from server content or resolved template)
|
|
834
|
+
if (!entityFilter || entityFilter.has('content')) {
|
|
835
|
+
await writeManifestJson(appJson, contentRefs);
|
|
836
|
+
}
|
|
837
|
+
|
|
844
838
|
// Step 5b: Process media → download binary files + metadata (skip rejected records)
|
|
845
839
|
let mediaRefs = [];
|
|
846
840
|
if (!entityFilter || entityFilter.has('media')) {
|
|
847
841
|
const mediaEntries = appJson.children.media || [];
|
|
848
842
|
if (mediaEntries.length > 0) {
|
|
849
|
-
mediaRefs = await processMediaEntries(mediaEntries, structure, options, config, appJson.ShortName,
|
|
843
|
+
mediaRefs = await processMediaEntries(mediaEntries, structure, options, config, appJson.ShortName, serverTz, toDeleteUIDs);
|
|
850
844
|
}
|
|
851
845
|
}
|
|
852
846
|
|
|
@@ -877,15 +871,9 @@ export async function performClone(source, options = {}) {
|
|
|
877
871
|
if (refs.length > 0) {
|
|
878
872
|
otherRefs[entityName] = refs;
|
|
879
873
|
}
|
|
880
|
-
} else if (ENTITY_DIR_NAMES.has(entityName)) {
|
|
881
|
-
// Entity types with project directories — process into their directory
|
|
882
|
-
const refs = await processEntityDirEntries(entityName, entries, options, serverTz);
|
|
883
|
-
if (refs.length > 0) {
|
|
884
|
-
otherRefs[entityName] = refs;
|
|
885
|
-
}
|
|
886
874
|
} else {
|
|
887
|
-
//
|
|
888
|
-
const refs = await
|
|
875
|
+
// All remaining entities → project directory named after entity
|
|
876
|
+
const refs = await processEntityDirEntries(entityName, entries, options, serverTz);
|
|
889
877
|
if (refs.length > 0) {
|
|
890
878
|
otherRefs[entityName] = refs;
|
|
891
879
|
}
|
|
@@ -900,18 +888,19 @@ export async function performClone(source, options = {}) {
|
|
|
900
888
|
// Step 7: Save app.json with references
|
|
901
889
|
await saveAppJson(appJson, contentRefs, otherRefs, effectiveDomain);
|
|
902
890
|
|
|
903
|
-
// Step 8: Create .
|
|
891
|
+
// Step 8: Create .dbo/.app_baseline.json baseline for delta tracking (skip in entity-filter mode to avoid overwriting)
|
|
904
892
|
if (!entityFilter) {
|
|
905
893
|
await saveBaselineFile(appJson);
|
|
894
|
+
resetBaselineCache(); // invalidate so next operation reloads the fresh baseline
|
|
906
895
|
}
|
|
907
896
|
|
|
908
|
-
// Step 9: Ensure .app.json is in .gitignore
|
|
909
|
-
await ensureGitignore(['.app.json']);
|
|
910
|
-
|
|
911
897
|
log.plain('');
|
|
912
|
-
|
|
898
|
+
const verb = options.pullMode ? 'Pull' : 'Clone';
|
|
899
|
+
log.success(entityFilter ? `${verb} complete! (filtered: ${options.entity})` : `${verb} complete!`);
|
|
913
900
|
log.dim(' app.json saved to project root');
|
|
914
|
-
|
|
901
|
+
if (!options.pullMode) {
|
|
902
|
+
log.dim(' Run "dbo login" to authenticate, then "dbo push" to deploy changes');
|
|
903
|
+
}
|
|
915
904
|
}
|
|
916
905
|
|
|
917
906
|
/**
|
|
@@ -922,7 +911,7 @@ export async function performClone(source, options = {}) {
|
|
|
922
911
|
* Entity-dir names (e.g. "extension", "site") are matched directly.
|
|
923
912
|
* Documentation aliases are also accepted (e.g. "column" → "output_value").
|
|
924
913
|
*/
|
|
925
|
-
function resolveEntityFilter(entityArg) {
|
|
914
|
+
export function resolveEntityFilter(entityArg) {
|
|
926
915
|
if (!entityArg) return null;
|
|
927
916
|
|
|
928
917
|
const input = entityArg.toLowerCase().trim();
|
|
@@ -938,83 +927,26 @@ function resolveEntityFilter(entityArg) {
|
|
|
938
927
|
}
|
|
939
928
|
|
|
940
929
|
/**
|
|
941
|
-
* Resolve placement preferences from config
|
|
942
|
-
*
|
|
930
|
+
* Resolve placement preferences from config.
|
|
931
|
+
* Content defaults to 'bin'. Media always uses BinID (no placement preference).
|
|
932
|
+
* Returns { contentPlacement } where value is 'path'|'bin'.
|
|
943
933
|
*/
|
|
944
|
-
async function resolvePlacementPreferences(
|
|
945
|
-
// Load saved preferences from config
|
|
934
|
+
async function resolvePlacementPreferences() {
|
|
946
935
|
const saved = await loadClonePlacement();
|
|
947
|
-
|
|
948
|
-
let mediaPlacement = saved.mediaPlacement;
|
|
949
|
-
|
|
950
|
-
// --media-placement flag takes precedence over saved config
|
|
951
|
-
if (options.mediaPlacement) {
|
|
952
|
-
mediaPlacement = options.mediaPlacement === 'fullpath' ? 'fullpath' : 'bin';
|
|
953
|
-
await saveClonePlacement({ contentPlacement: contentPlacement || 'bin', mediaPlacement });
|
|
954
|
-
log.dim(` MediaPlacement set to "${mediaPlacement}" via flag`);
|
|
955
|
-
}
|
|
956
|
-
|
|
957
|
-
const hasContent = (appJson.children.content || []).length > 0;
|
|
958
|
-
|
|
959
|
-
// If -y flag, default to bin placement (no prompts)
|
|
960
|
-
if (options.yes) {
|
|
961
|
-
return {
|
|
962
|
-
contentPlacement: contentPlacement || 'bin',
|
|
963
|
-
mediaPlacement: mediaPlacement || 'bin',
|
|
964
|
-
};
|
|
965
|
-
}
|
|
936
|
+
const contentPlacement = saved.contentPlacement || 'bin';
|
|
966
937
|
|
|
967
|
-
//
|
|
968
|
-
if (contentPlacement
|
|
969
|
-
|
|
938
|
+
// Persist default if not yet saved
|
|
939
|
+
if (!saved.contentPlacement) {
|
|
940
|
+
await saveClonePlacement({ contentPlacement });
|
|
970
941
|
}
|
|
971
942
|
|
|
972
|
-
|
|
973
|
-
const prompts = [];
|
|
974
|
-
|
|
975
|
-
// Only prompt for contentPlacement — media placement is NOT prompted interactively
|
|
976
|
-
if (!contentPlacement && hasContent) {
|
|
977
|
-
prompts.push({
|
|
978
|
-
type: 'list',
|
|
979
|
-
name: 'contentPlacement',
|
|
980
|
-
message: 'How should content files be placed?',
|
|
981
|
-
choices: [
|
|
982
|
-
{ name: 'Save all in BinID directory', value: 'bin' },
|
|
983
|
-
{ name: 'Save all in their specified Path directory', value: 'path' },
|
|
984
|
-
{ name: 'Ask for every file that has both', value: 'ask' },
|
|
985
|
-
],
|
|
986
|
-
});
|
|
987
|
-
}
|
|
988
|
-
|
|
989
|
-
// Media placement: no interactive prompt — default to 'bin'
|
|
990
|
-
if (!mediaPlacement) {
|
|
991
|
-
mediaPlacement = 'bin';
|
|
992
|
-
}
|
|
993
|
-
|
|
994
|
-
if (prompts.length > 0) {
|
|
995
|
-
const answers = await inquirer.prompt(prompts);
|
|
996
|
-
contentPlacement = contentPlacement || answers.contentPlacement || 'bin';
|
|
997
|
-
}
|
|
998
|
-
|
|
999
|
-
// Resolve defaults for any still-unset values
|
|
1000
|
-
contentPlacement = contentPlacement || 'bin';
|
|
1001
|
-
mediaPlacement = mediaPlacement || 'bin';
|
|
1002
|
-
|
|
1003
|
-
// Always persist resolved values — not just when prompts were shown.
|
|
1004
|
-
// This ensures defaults are saved even when the app has no content/media yet,
|
|
1005
|
-
// so subsequent clones that do have records skip the prompts.
|
|
1006
|
-
if (!saved.contentPlacement || !saved.mediaPlacement) {
|
|
1007
|
-
await saveClonePlacement({ contentPlacement, mediaPlacement });
|
|
1008
|
-
if (prompts.length > 0) {
|
|
1009
|
-
log.dim(' Saved placement preferences to .dbo/config.json');
|
|
1010
|
-
}
|
|
1011
|
-
}
|
|
1012
|
-
|
|
1013
|
-
return { contentPlacement, mediaPlacement };
|
|
943
|
+
return { contentPlacement };
|
|
1014
944
|
}
|
|
1015
945
|
|
|
1016
946
|
/**
|
|
1017
947
|
* Fetch app JSON from the server by AppShortName.
|
|
948
|
+
* Distinguishes between authentication failures (expired session) and
|
|
949
|
+
* genuine "app not found" responses, offering re-login when appropriate.
|
|
1018
950
|
*/
|
|
1019
951
|
async function fetchAppFromServer(appShortName, options, config) {
|
|
1020
952
|
const client = new DboClient({ domain: options.domain, verbose: options.verbose });
|
|
@@ -1030,6 +962,58 @@ async function fetchAppFromServer(appShortName, options, config) {
|
|
|
1030
962
|
throw err;
|
|
1031
963
|
}
|
|
1032
964
|
|
|
965
|
+
// Check for authentication / session errors before parsing app data.
|
|
966
|
+
// The server may return HTTP 401/403 or a 200 envelope with Successful=false
|
|
967
|
+
// and messages containing user identity patterns.
|
|
968
|
+
const AUTH_PATTERNS = ['LoggedInUser_UID', 'LoggedInUserID', 'CurrentUserID', 'UserID', 'not authenticated', 'session expired', 'login required'];
|
|
969
|
+
const messages = result.messages || [];
|
|
970
|
+
const allMsgText = messages.filter(m => typeof m === 'string').join(' ');
|
|
971
|
+
const isAuthError = !result.ok && (result.status === 401 || result.status === 403)
|
|
972
|
+
|| (!result.successful && AUTH_PATTERNS.some(p => allMsgText.includes(p)));
|
|
973
|
+
|
|
974
|
+
if (isAuthError) {
|
|
975
|
+
spinner.fail('Session expired or not authenticated');
|
|
976
|
+
log.warn('Your session appears to have expired.');
|
|
977
|
+
if (allMsgText) log.dim(` Server: ${allMsgText.substring(0, 200)}`);
|
|
978
|
+
|
|
979
|
+
// Offer re-login
|
|
980
|
+
if (process.stdin.isTTY) {
|
|
981
|
+
const inquirer = (await import('inquirer')).default;
|
|
982
|
+
const { action } = await inquirer.prompt([{
|
|
983
|
+
type: 'list',
|
|
984
|
+
name: 'action',
|
|
985
|
+
message: 'How would you like to proceed?',
|
|
986
|
+
choices: [
|
|
987
|
+
{ name: 'Re-login now (recommended)', value: 'relogin' },
|
|
988
|
+
{ name: 'Abort', value: 'abort' },
|
|
989
|
+
],
|
|
990
|
+
}]);
|
|
991
|
+
|
|
992
|
+
if (action === 'relogin') {
|
|
993
|
+
const { performLogin } = await import('./login.js');
|
|
994
|
+
await performLogin(options.domain || config.domain);
|
|
995
|
+
log.info('Retrying app fetch...');
|
|
996
|
+
return fetchAppFromServer(appShortName, options, config);
|
|
997
|
+
}
|
|
998
|
+
} else {
|
|
999
|
+
log.dim(' Run "dbo login" to authenticate, then retry.');
|
|
1000
|
+
}
|
|
1001
|
+
throw new Error('Authentication required. Run "dbo login" first.');
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
// Check for non-auth server errors (500, envelope Successful=false, etc.)
|
|
1005
|
+
if (!result.ok && result.status >= 500) {
|
|
1006
|
+
spinner.fail(`Server error (HTTP ${result.status})`);
|
|
1007
|
+
if (allMsgText) log.dim(` Server: ${allMsgText.substring(0, 200)}`);
|
|
1008
|
+
throw new Error(`Server error (HTTP ${result.status}) fetching app "${appShortName}"`);
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
if (!result.successful && allMsgText) {
|
|
1012
|
+
spinner.fail(`Server returned an error`);
|
|
1013
|
+
log.warn(` ${allMsgText.substring(0, 300)}`);
|
|
1014
|
+
throw new Error(`Server error fetching app "${appShortName}": ${allMsgText.substring(0, 200)}`);
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1033
1017
|
const data = result.payload || result.data;
|
|
1034
1018
|
|
|
1035
1019
|
// Handle all response shapes:
|
|
@@ -1051,7 +1035,31 @@ async function fetchAppFromServer(appShortName, options, config) {
|
|
|
1051
1035
|
}
|
|
1052
1036
|
|
|
1053
1037
|
if (!appRecord) {
|
|
1038
|
+
// Empty payload can mean either a genuinely missing app OR an expired session
|
|
1039
|
+
// (some servers return 200 + empty data instead of 401 when not authenticated).
|
|
1040
|
+
// Offer re-login as an option so the user doesn't have to guess.
|
|
1054
1041
|
spinner.fail(`No app found with ShortName "${appShortName}"`);
|
|
1042
|
+
|
|
1043
|
+
if (process.stdin.isTTY) {
|
|
1044
|
+
log.warn('If your session has expired, re-logging in may fix this.');
|
|
1045
|
+
const inquirer = (await import('inquirer')).default;
|
|
1046
|
+
const { action } = await inquirer.prompt([{
|
|
1047
|
+
type: 'list',
|
|
1048
|
+
name: 'action',
|
|
1049
|
+
message: 'How would you like to proceed?',
|
|
1050
|
+
choices: [
|
|
1051
|
+
{ name: 'Re-login and retry (session may have expired)', value: 'relogin' },
|
|
1052
|
+
{ name: 'Abort', value: 'abort' },
|
|
1053
|
+
],
|
|
1054
|
+
}]);
|
|
1055
|
+
if (action === 'relogin') {
|
|
1056
|
+
const { performLogin } = await import('./login.js');
|
|
1057
|
+
await performLogin(options.domain || config.domain);
|
|
1058
|
+
log.info('Retrying app fetch...');
|
|
1059
|
+
return fetchAppFromServer(appShortName, options, config);
|
|
1060
|
+
}
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1055
1063
|
throw new Error(`No app found with ShortName "${appShortName}"`);
|
|
1056
1064
|
}
|
|
1057
1065
|
|
|
@@ -1100,6 +1108,21 @@ async function updatePackageJson(appJson, config) {
|
|
|
1100
1108
|
changed = true;
|
|
1101
1109
|
}
|
|
1102
1110
|
|
|
1111
|
+
// Add @dboio/cli to devDependencies with current CLI version
|
|
1112
|
+
if (!pkg.devDependencies || !pkg.devDependencies['@dboio/cli']) {
|
|
1113
|
+
try {
|
|
1114
|
+
const cliRoot = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
|
|
1115
|
+
const cliPkg = JSON.parse(await readFile(join(cliRoot, 'package.json'), 'utf8'));
|
|
1116
|
+
if (cliPkg.version) {
|
|
1117
|
+
if (!pkg.devDependencies) pkg.devDependencies = {};
|
|
1118
|
+
pkg.devDependencies['@dboio/cli'] = `^${cliPkg.version}`;
|
|
1119
|
+
changed = true;
|
|
1120
|
+
}
|
|
1121
|
+
} catch {
|
|
1122
|
+
// Could not read CLI version — skip
|
|
1123
|
+
}
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1103
1126
|
if (changed) {
|
|
1104
1127
|
await writeFile(pkgPath, JSON.stringify(pkg, null, 2) + '\n');
|
|
1105
1128
|
log.dim(' Updated package.json with app metadata');
|
|
@@ -1136,39 +1159,7 @@ async function processContentEntries(contents, structure, options, contentPlacem
|
|
|
1136
1159
|
}
|
|
1137
1160
|
|
|
1138
1161
|
/**
|
|
1139
|
-
* Process
|
|
1140
|
-
* Only handles entries with a BinID.
|
|
1141
|
-
*/
|
|
1142
|
-
async function processGenericEntries(entityName, entries, structure, options, contentPlacement, serverTz) {
|
|
1143
|
-
if (!entries || entries.length === 0) return [];
|
|
1144
|
-
|
|
1145
|
-
const refs = [];
|
|
1146
|
-
const usedNames = new Map();
|
|
1147
|
-
const placementPreference = {
|
|
1148
|
-
value: (contentPlacement === 'path' || contentPlacement === 'bin') ? contentPlacement : null,
|
|
1149
|
-
};
|
|
1150
|
-
const bulkAction = { value: null };
|
|
1151
|
-
let processed = 0;
|
|
1152
|
-
|
|
1153
|
-
for (const record of entries) {
|
|
1154
|
-
if (!record.BinID) continue; // Skip entries without BinID
|
|
1155
|
-
|
|
1156
|
-
const ref = await processRecord(entityName, record, structure, options, usedNames, placementPreference, serverTz, bulkAction);
|
|
1157
|
-
if (ref) {
|
|
1158
|
-
refs.push(ref);
|
|
1159
|
-
processed++;
|
|
1160
|
-
}
|
|
1161
|
-
}
|
|
1162
|
-
|
|
1163
|
-
if (processed > 0) {
|
|
1164
|
-
log.info(`Processed ${processed} ${entityName} record(s)`);
|
|
1165
|
-
}
|
|
1166
|
-
|
|
1167
|
-
return refs;
|
|
1168
|
-
}
|
|
1169
|
-
|
|
1170
|
-
/**
|
|
1171
|
-
* Process entity-dir entries: entities that map to DEFAULT_PROJECT_DIRS.
|
|
1162
|
+
* Process entity-dir entries: entities that get their own project directory.
|
|
1172
1163
|
* These don't require a BinID — they go directly into their project directory
|
|
1173
1164
|
* (e.g., Extensions/, Data Sources/, etc.)
|
|
1174
1165
|
*
|
|
@@ -1177,8 +1168,7 @@ async function processGenericEntries(entityName, entries, structure, options, co
|
|
|
1177
1168
|
async function processEntityDirEntries(entityName, entries, options, serverTz) {
|
|
1178
1169
|
if (!entries || entries.length === 0) return [];
|
|
1179
1170
|
|
|
1180
|
-
const dirName = entityName;
|
|
1181
|
-
if (!ENTITY_DIR_NAMES.has(entityName)) return [];
|
|
1171
|
+
const dirName = resolveEntityDirPath(entityName);
|
|
1182
1172
|
|
|
1183
1173
|
await mkdir(dirName, { recursive: true });
|
|
1184
1174
|
|
|
@@ -1388,7 +1378,7 @@ async function processEntityDirEntries(entityName, entries, options, serverTz) {
|
|
|
1388
1378
|
if (bulkAction.value !== 'overwrite_all') {
|
|
1389
1379
|
const configWithTz = { ...config, ServerTimezone: serverTz };
|
|
1390
1380
|
const localSyncTime = await getLocalSyncTime(metaPath);
|
|
1391
|
-
const serverNewer = isServerNewer(localSyncTime, record._LastUpdated, configWithTz);
|
|
1381
|
+
const serverNewer = isServerNewer(localSyncTime, record._LastUpdated, configWithTz, 'content', record.UID);
|
|
1392
1382
|
const serverDate = parseServerDate(record._LastUpdated, serverTz);
|
|
1393
1383
|
|
|
1394
1384
|
if (serverNewer) {
|
|
@@ -1555,6 +1545,20 @@ async function buildDescriptorPrePass(extensionEntries, structure) {
|
|
|
1555
1545
|
log.dim(` ${fullDir}/`);
|
|
1556
1546
|
}
|
|
1557
1547
|
|
|
1548
|
+
// Create directories for descriptors not in the mapping but with a non-empty value
|
|
1549
|
+
const unmappedDescriptors = new Set();
|
|
1550
|
+
for (const rec of extensionEntries) {
|
|
1551
|
+
const d = rec.Descriptor;
|
|
1552
|
+
if (d && d !== 'descriptor_definition' && !mapping[d]) {
|
|
1553
|
+
unmappedDescriptors.add(d);
|
|
1554
|
+
}
|
|
1555
|
+
}
|
|
1556
|
+
for (const dirName of unmappedDescriptors) {
|
|
1557
|
+
const fullDir = `${EXTENSION_DESCRIPTORS_DIR}/${dirName}`;
|
|
1558
|
+
await mkdir(fullDir, { recursive: true });
|
|
1559
|
+
log.dim(` ${fullDir}/`);
|
|
1560
|
+
}
|
|
1561
|
+
|
|
1558
1562
|
await saveDescriptorMapping(structure, mapping);
|
|
1559
1563
|
log.dim(` Saved descriptorMapping to .dbo/structure.json`);
|
|
1560
1564
|
|
|
@@ -1851,7 +1855,7 @@ async function processExtensionEntries(entries, structure, options, serverTz) {
|
|
|
1851
1855
|
if (bulkAction.value !== 'overwrite_all') {
|
|
1852
1856
|
const cfgWithTz = { ...config, ServerTimezone: serverTz };
|
|
1853
1857
|
const localSyncTime = await getLocalSyncTime(metaPath);
|
|
1854
|
-
const serverNewer = isServerNewer(localSyncTime, record._LastUpdated, cfgWithTz);
|
|
1858
|
+
const serverNewer = isServerNewer(localSyncTime, record._LastUpdated, cfgWithTz, 'extension', record.UID);
|
|
1855
1859
|
const serverDate = parseServerDate(record._LastUpdated, serverTz);
|
|
1856
1860
|
|
|
1857
1861
|
if (serverNewer) {
|
|
@@ -1955,12 +1959,48 @@ async function processExtensionEntries(entries, structure, options, serverTz) {
|
|
|
1955
1959
|
* Process media entries: download binary files from server + create metadata.
|
|
1956
1960
|
* Media uses Filename (not Name) and files are fetched via /api/media/{uid}.
|
|
1957
1961
|
*/
|
|
1958
|
-
async function processMediaEntries(mediaRecords, structure, options, config, appShortName,
|
|
1962
|
+
async function processMediaEntries(mediaRecords, structure, options, config, appShortName, serverTz, skipUIDs = new Set()) {
|
|
1959
1963
|
if (!mediaRecords || mediaRecords.length === 0) return [];
|
|
1960
1964
|
|
|
1961
1965
|
// Track stale records (404s) for cleanup prompt
|
|
1962
1966
|
const staleRecords = [];
|
|
1963
1967
|
|
|
1968
|
+
// Pre-scan: determine which media files actually need downloading
|
|
1969
|
+
// (new files or files with newer server timestamps)
|
|
1970
|
+
const needsDownload = [];
|
|
1971
|
+
const upToDateRefs = [];
|
|
1972
|
+
|
|
1973
|
+
for (const record of mediaRecords) {
|
|
1974
|
+
if (skipUIDs.has(record.UID)) continue;
|
|
1975
|
+
|
|
1976
|
+
const { metaPath: scanMetaPath } = resolveMediaPaths(record, structure);
|
|
1977
|
+
const scanExists = await fileExists(scanMetaPath);
|
|
1978
|
+
|
|
1979
|
+
if (!scanExists) {
|
|
1980
|
+
// New file — always needs download
|
|
1981
|
+
needsDownload.push(record);
|
|
1982
|
+
} else if (options.force) {
|
|
1983
|
+
// Force mode — re-download everything
|
|
1984
|
+
needsDownload.push(record);
|
|
1985
|
+
} else {
|
|
1986
|
+
// Existing file — check if server is newer
|
|
1987
|
+
const configWithTz = { ...config, ServerTimezone: serverTz };
|
|
1988
|
+
const localSyncTime = await getLocalSyncTime(scanMetaPath);
|
|
1989
|
+
const serverNewer = isServerNewer(localSyncTime, record._LastUpdated, configWithTz, 'media', record.UID);
|
|
1990
|
+
if (serverNewer) {
|
|
1991
|
+
needsDownload.push(record);
|
|
1992
|
+
} else {
|
|
1993
|
+
// Up to date — still need ref for app.json
|
|
1994
|
+
upToDateRefs.push({ uid: record.UID, metaPath: scanMetaPath });
|
|
1995
|
+
}
|
|
1996
|
+
}
|
|
1997
|
+
}
|
|
1998
|
+
|
|
1999
|
+
if (needsDownload.length === 0) {
|
|
2000
|
+
log.dim(` All ${mediaRecords.length} media file(s) up to date`);
|
|
2001
|
+
return upToDateRefs;
|
|
2002
|
+
}
|
|
2003
|
+
|
|
1964
2004
|
// Determine if we can download (need a server connection)
|
|
1965
2005
|
let canDownload = false;
|
|
1966
2006
|
let client = null;
|
|
@@ -1970,7 +2010,7 @@ async function processMediaEntries(mediaRecords, structure, options, config, app
|
|
|
1970
2010
|
const { download } = await inquirer.prompt([{
|
|
1971
2011
|
type: 'confirm',
|
|
1972
2012
|
name: 'download',
|
|
1973
|
-
message: `${
|
|
2013
|
+
message: `${needsDownload.length} media file(s) need to be downloaded (${mediaRecords.length - needsDownload.length} up to date). Attempt download now?`,
|
|
1974
2014
|
default: true,
|
|
1975
2015
|
}]);
|
|
1976
2016
|
canDownload = download;
|
|
@@ -1989,16 +2029,12 @@ async function processMediaEntries(mediaRecords, structure, options, config, app
|
|
|
1989
2029
|
}
|
|
1990
2030
|
|
|
1991
2031
|
if (!canDownload) {
|
|
1992
|
-
log.warn(`Skipping ${
|
|
1993
|
-
return
|
|
2032
|
+
log.warn(`Skipping ${needsDownload.length} media file(s) — download not attempted`);
|
|
2033
|
+
return upToDateRefs;
|
|
1994
2034
|
}
|
|
1995
2035
|
|
|
1996
2036
|
const refs = [];
|
|
1997
2037
|
const usedNames = new Map();
|
|
1998
|
-
// Map config values: 'fullpath' → 'path' (used internally), 'bin' → 'bin', 'ask' → null
|
|
1999
|
-
const placementPreference = {
|
|
2000
|
-
value: mediaPlacement === 'fullpath' ? 'path' : mediaPlacement === 'bin' ? 'bin' : null,
|
|
2001
|
-
};
|
|
2002
2038
|
const mediaBulkAction = { value: null };
|
|
2003
2039
|
let downloaded = 0;
|
|
2004
2040
|
let failed = 0;
|
|
@@ -2016,57 +2052,10 @@ async function processMediaEntries(mediaRecords, structure, options, config, app
|
|
|
2016
2052
|
const name = sanitizeFilename(filename.replace(/\.[^.]+$/, '')); // base name without extension
|
|
2017
2053
|
const ext = (record.Extension || 'bin').toLowerCase();
|
|
2018
2054
|
|
|
2019
|
-
//
|
|
2055
|
+
// Always place media by BinID; fall back to bins/ root when BinID is missing
|
|
2020
2056
|
let dir = BINS_DIR;
|
|
2021
|
-
|
|
2022
|
-
const hasFullPath = record.FullPath && typeof record.FullPath === 'string';
|
|
2023
|
-
|
|
2024
|
-
// Parse directory from FullPath: strip leading / and remove filename
|
|
2025
|
-
// FullPath like /media/operator/app/assets/gfx/logo.png → media/operator/app/assets/gfx/
|
|
2026
|
-
// These are valid project-relative paths (media/, dir/ are real directories)
|
|
2027
|
-
let fullPathDir = null;
|
|
2028
|
-
if (hasFullPath) {
|
|
2029
|
-
const stripped = record.FullPath.replace(/^\/+/, '');
|
|
2030
|
-
const lastSlash = stripped.lastIndexOf('/');
|
|
2031
|
-
fullPathDir = lastSlash >= 0 ? stripped.substring(0, lastSlash) : BINS_DIR;
|
|
2032
|
-
}
|
|
2033
|
-
|
|
2034
|
-
if (hasBinID && fullPathDir && fullPathDir !== '.' && !options.yes) {
|
|
2035
|
-
if (placementPreference.value) {
|
|
2036
|
-
dir = placementPreference.value === 'path'
|
|
2037
|
-
? fullPathDir
|
|
2038
|
-
: resolveBinPath(record.BinID, structure);
|
|
2039
|
-
} else {
|
|
2040
|
-
const binPath = resolveBinPath(record.BinID, structure);
|
|
2041
|
-
const binName = getBinName(record.BinID, structure);
|
|
2042
|
-
const inquirer = (await import('inquirer')).default;
|
|
2043
|
-
|
|
2044
|
-
const { placement } = await inquirer.prompt([{
|
|
2045
|
-
type: 'list',
|
|
2046
|
-
name: 'placement',
|
|
2047
|
-
message: `Where do you want me to place ${filename}?`,
|
|
2048
|
-
choices: [
|
|
2049
|
-
{ name: `Into the FullPath of ${fullPathDir}`, value: 'path' },
|
|
2050
|
-
{ name: `Into the BinID of ${record.BinID} (${binName} → ${binPath})`, value: 'bin' },
|
|
2051
|
-
{ name: `Place this and all further files by FullPath`, value: 'all_path' },
|
|
2052
|
-
{ name: `Place this and all further files by BinID`, value: 'all_bin' },
|
|
2053
|
-
],
|
|
2054
|
-
}]);
|
|
2055
|
-
|
|
2056
|
-
if (placement === 'all_path') {
|
|
2057
|
-
placementPreference.value = 'path';
|
|
2058
|
-
dir = fullPathDir;
|
|
2059
|
-
} else if (placement === 'all_bin') {
|
|
2060
|
-
placementPreference.value = 'bin';
|
|
2061
|
-
dir = binPath;
|
|
2062
|
-
} else {
|
|
2063
|
-
dir = placement === 'path' ? fullPathDir : binPath;
|
|
2064
|
-
}
|
|
2065
|
-
}
|
|
2066
|
-
} else if (hasBinID) {
|
|
2057
|
+
if (record.BinID && structure[record.BinID]) {
|
|
2067
2058
|
dir = resolveBinPath(record.BinID, structure);
|
|
2068
|
-
} else if (fullPathDir && fullPathDir !== BINS_DIR) {
|
|
2069
|
-
dir = fullPathDir;
|
|
2070
2059
|
}
|
|
2071
2060
|
|
|
2072
2061
|
dir = dir.replace(/^\/+|\/+$/g, '');
|
|
@@ -2099,7 +2088,7 @@ async function processMediaEntries(mediaRecords, structure, options, config, app
|
|
|
2099
2088
|
// with how setFileTimestamps set the mtime — the two must use the same timezone.
|
|
2100
2089
|
const configWithTz = { ...config, ServerTimezone: serverTz };
|
|
2101
2090
|
const localSyncTime = await getLocalSyncTime(metaPath);
|
|
2102
|
-
const serverNewer = isServerNewer(localSyncTime, record._LastUpdated, configWithTz);
|
|
2091
|
+
const serverNewer = isServerNewer(localSyncTime, record._LastUpdated, configWithTz, 'media', record.UID);
|
|
2103
2092
|
const serverDate = parseServerDate(record._LastUpdated, serverTz);
|
|
2104
2093
|
const diffable = isDiffable(ext);
|
|
2105
2094
|
|
|
@@ -2171,9 +2160,25 @@ async function processMediaEntries(mediaRecords, structure, options, config, app
|
|
|
2171
2160
|
}
|
|
2172
2161
|
}
|
|
2173
2162
|
|
|
2174
|
-
// Download the file
|
|
2163
|
+
// Download the file with fallback chain: /media/ → /dir/ → /api/media/
|
|
2164
|
+
// 1. FullPath directly (e.g. /media/todoes/App/assets/file.ttf)
|
|
2165
|
+
// 2. /dir/ route (FullPath with /media/ prefix stripped → appShortName/bins/filename)
|
|
2166
|
+
// 3. /api/media/{uid} (UID-based endpoint as last resort)
|
|
2175
2167
|
try {
|
|
2176
|
-
const
|
|
2168
|
+
const urls = buildMediaDownloadUrls(record);
|
|
2169
|
+
let buffer;
|
|
2170
|
+
let usedUrl;
|
|
2171
|
+
for (const url of urls) {
|
|
2172
|
+
try {
|
|
2173
|
+
buffer = await client.getBuffer(url);
|
|
2174
|
+
usedUrl = url;
|
|
2175
|
+
break;
|
|
2176
|
+
} catch (urlErr) {
|
|
2177
|
+
// If this is the last URL in the chain, rethrow
|
|
2178
|
+
if (url === urls[urls.length - 1]) throw urlErr;
|
|
2179
|
+
// Otherwise try next URL
|
|
2180
|
+
}
|
|
2181
|
+
}
|
|
2177
2182
|
await writeFile(filePath, buffer);
|
|
2178
2183
|
const sizeKB = (buffer.length / 1024).toFixed(1);
|
|
2179
2184
|
log.success(`Downloaded ${filePath} (${sizeKB} KB)`);
|
|
@@ -2194,11 +2199,23 @@ async function processMediaEntries(mediaRecords, structure, options, config, app
|
|
|
2194
2199
|
} else {
|
|
2195
2200
|
log.warn(`Failed to download ${filename}`);
|
|
2196
2201
|
log.dim(` UID: ${record.UID}`);
|
|
2197
|
-
log.dim(` URL: /api/media/${record.UID}`);
|
|
2198
2202
|
if (record.FullPath) log.dim(` FullPath: ${record.FullPath}`);
|
|
2199
2203
|
log.dim(` Error: ${err.message}`);
|
|
2200
2204
|
}
|
|
2201
2205
|
|
|
2206
|
+
// Append to .dbo/errors.log
|
|
2207
|
+
await appendErrorLog({
|
|
2208
|
+
timestamp: new Date().toISOString(),
|
|
2209
|
+
command: 'clone',
|
|
2210
|
+
entity: 'media',
|
|
2211
|
+
action: 'download',
|
|
2212
|
+
uid: record.UID,
|
|
2213
|
+
rowId: record._id || record.MediaID,
|
|
2214
|
+
filename,
|
|
2215
|
+
fullPath: record.FullPath || null,
|
|
2216
|
+
error: err.message,
|
|
2217
|
+
});
|
|
2218
|
+
|
|
2202
2219
|
failed++;
|
|
2203
2220
|
continue; // Skip metadata if download failed
|
|
2204
2221
|
}
|
|
@@ -2229,50 +2246,34 @@ async function processMediaEntries(mediaRecords, structure, options, config, app
|
|
|
2229
2246
|
refs.push({ uid: record.UID, metaPath });
|
|
2230
2247
|
}
|
|
2231
2248
|
|
|
2232
|
-
log.info(`Media: ${downloaded} downloaded, ${failed} failed`);
|
|
2233
|
-
|
|
2234
|
-
// Prompt for stale record cleanup
|
|
2235
|
-
if (staleRecords.length > 0 && !options.yes) {
|
|
2236
|
-
log.plain('');
|
|
2237
|
-
log.info(`Found ${staleRecords.length} stale media record(s) (404 - files no longer exist on server)`);
|
|
2238
|
-
|
|
2239
|
-
const inquirer = (await import('inquirer')).default;
|
|
2240
|
-
const { cleanup } = await inquirer.prompt([{
|
|
2241
|
-
type: 'confirm',
|
|
2242
|
-
name: 'cleanup',
|
|
2243
|
-
message: `Stage these ${staleRecords.length} stale media records for deletion?`,
|
|
2244
|
-
default: false, // Conservative default
|
|
2245
|
-
}]);
|
|
2249
|
+
log.info(`Media: ${downloaded} downloaded, ${failed} failed, ${upToDateRefs.length} up to date`);
|
|
2246
2250
|
|
|
2247
|
-
|
|
2248
|
-
|
|
2249
|
-
|
|
2250
|
-
|
|
2251
|
-
|
|
2252
|
-
|
|
2253
|
-
|
|
2254
|
-
|
|
2255
|
-
|
|
2256
|
-
|
|
2257
|
-
|
|
2258
|
-
}
|
|
2259
|
-
log.
|
|
2260
|
-
log.dim(' Run "dbo push" to delete from server');
|
|
2261
|
-
} else {
|
|
2262
|
-
log.info('Skipped stale media cleanup');
|
|
2251
|
+
// Auto-stage stale records for deletion (404 — files no longer exist on server)
|
|
2252
|
+
if (staleRecords.length > 0) {
|
|
2253
|
+
log.info(`Staging ${staleRecords.length} stale media record(s) for deletion (404)`);
|
|
2254
|
+
for (const stale of staleRecords) {
|
|
2255
|
+
const expression = `RowID:del${stale.RowID};entity:media=true`;
|
|
2256
|
+
await addDeleteEntry({
|
|
2257
|
+
UID: stale.UID,
|
|
2258
|
+
RowID: stale.RowID,
|
|
2259
|
+
entity: 'media',
|
|
2260
|
+
name: stale.filename,
|
|
2261
|
+
expression,
|
|
2262
|
+
});
|
|
2263
|
+
log.dim(` Staged: ${stale.filename}`);
|
|
2263
2264
|
}
|
|
2264
|
-
|
|
2265
|
-
log.
|
|
2265
|
+
log.success('Stale media records staged in .dbo/synchronize.json');
|
|
2266
|
+
log.dim(' Run "dbo push" to delete from server');
|
|
2266
2267
|
}
|
|
2267
2268
|
|
|
2268
|
-
return refs;
|
|
2269
|
+
return [...upToDateRefs, ...refs];
|
|
2269
2270
|
}
|
|
2270
2271
|
|
|
2271
2272
|
/**
|
|
2272
2273
|
* Process a single record: determine directory, write content file + metadata.
|
|
2273
2274
|
* Returns { uid, metaPath } or null.
|
|
2274
2275
|
*/
|
|
2275
|
-
async function processRecord(entityName, record, structure, options, usedNames,
|
|
2276
|
+
async function processRecord(entityName, record, structure, options, usedNames, _placementPreference, serverTz, bulkAction = { value: null }) {
|
|
2276
2277
|
let name = sanitizeFilename(String(record.Name || record.UID || 'untitled'));
|
|
2277
2278
|
|
|
2278
2279
|
// Determine file extension (priority: Extension field > Name field > Path field > empty)
|
|
@@ -2293,7 +2294,40 @@ async function processRecord(entityName, record, structure, options, usedNames,
|
|
|
2293
2294
|
ext = extractedExt ? extractedExt.substring(1).toLowerCase() : '';
|
|
2294
2295
|
}
|
|
2295
2296
|
}
|
|
2296
|
-
// If still no extension,
|
|
2297
|
+
// If still no extension, check existing local metadata for a previously chosen extension.
|
|
2298
|
+
// On re-clone, the Content @reference in the local metadata already has the extension
|
|
2299
|
+
// the user picked on the first clone (e.g. "@CurrentTask~uid.html" → "html").
|
|
2300
|
+
if (!ext && record.UID) {
|
|
2301
|
+
try {
|
|
2302
|
+
const uid = String(record.UID);
|
|
2303
|
+
const sanitized = sanitizeFilename(String(record.Name || uid || 'untitled'));
|
|
2304
|
+
const probe = buildUidFilename(sanitized, uid);
|
|
2305
|
+
// Resolve the directory the same way the main code does below
|
|
2306
|
+
let probeDir = BINS_DIR;
|
|
2307
|
+
if (record.BinID && structure[record.BinID]) {
|
|
2308
|
+
probeDir = resolveBinPath(record.BinID, structure);
|
|
2309
|
+
} else if (record.Path && typeof record.Path === 'string' && record.Path.trim()) {
|
|
2310
|
+
probeDir = resolvePathToBinsDir(record.Path, structure);
|
|
2311
|
+
}
|
|
2312
|
+
probeDir = probeDir.replace(/^\/+|\/+$/g, '') || BINS_DIR;
|
|
2313
|
+
if (extname(probeDir)) probeDir = probeDir.substring(0, probeDir.lastIndexOf('/')) || BINS_DIR;
|
|
2314
|
+
|
|
2315
|
+
const probeMeta = join(probeDir, `${probe}.metadata.json`);
|
|
2316
|
+
const raw = await readFile(probeMeta, 'utf8');
|
|
2317
|
+
const localMeta = JSON.parse(raw);
|
|
2318
|
+
// Extract extension from Content @reference (e.g. "@Name~uid.html")
|
|
2319
|
+
for (const col of (localMeta._contentColumns || ['Content'])) {
|
|
2320
|
+
const ref = localMeta[col];
|
|
2321
|
+
if (typeof ref === 'string' && ref.startsWith('@')) {
|
|
2322
|
+
const refExt = extname(ref);
|
|
2323
|
+
if (refExt) {
|
|
2324
|
+
ext = refExt.substring(1).toLowerCase();
|
|
2325
|
+
break;
|
|
2326
|
+
}
|
|
2327
|
+
}
|
|
2328
|
+
}
|
|
2329
|
+
} catch { /* no existing metadata — will prompt below */ }
|
|
2330
|
+
}
|
|
2297
2331
|
|
|
2298
2332
|
// If no extension determined and Content column has data, prompt user to choose one
|
|
2299
2333
|
if (!ext && !options.yes && record.Content) {
|
|
@@ -2343,41 +2377,8 @@ async function processRecord(entityName, record, structure, options, usedNames,
|
|
|
2343
2377
|
const hasBinID = record.BinID && structure[record.BinID];
|
|
2344
2378
|
const hasPath = record.Path && typeof record.Path === 'string' && record.Path.trim();
|
|
2345
2379
|
|
|
2346
|
-
if (hasBinID
|
|
2347
|
-
//
|
|
2348
|
-
if (placementPreference.value) {
|
|
2349
|
-
dir = placementPreference.value === 'path'
|
|
2350
|
-
? record.Path
|
|
2351
|
-
: resolveBinPath(record.BinID, structure);
|
|
2352
|
-
} else {
|
|
2353
|
-
// Both BinID and Path — prompt user
|
|
2354
|
-
const binPath = resolveBinPath(record.BinID, structure);
|
|
2355
|
-
const binName = getBinName(record.BinID, structure);
|
|
2356
|
-
const inquirer = (await import('inquirer')).default;
|
|
2357
|
-
|
|
2358
|
-
const { placement } = await inquirer.prompt([{
|
|
2359
|
-
type: 'list',
|
|
2360
|
-
name: 'placement',
|
|
2361
|
-
message: `Where do you want me to place ${name}.${ext}?`,
|
|
2362
|
-
choices: [
|
|
2363
|
-
{ name: `Into the Path of ${record.Path}`, value: 'path' },
|
|
2364
|
-
{ name: `Into the BinID of ${record.BinID} (${binName} → ${binPath})`, value: 'bin' },
|
|
2365
|
-
{ name: `Place this and all further files by Path`, value: 'all_path' },
|
|
2366
|
-
{ name: `Place this and all further files by BinID`, value: 'all_bin' },
|
|
2367
|
-
],
|
|
2368
|
-
}]);
|
|
2369
|
-
|
|
2370
|
-
if (placement === 'all_path') {
|
|
2371
|
-
placementPreference.value = 'path';
|
|
2372
|
-
dir = record.Path;
|
|
2373
|
-
} else if (placement === 'all_bin') {
|
|
2374
|
-
placementPreference.value = 'bin';
|
|
2375
|
-
dir = binPath;
|
|
2376
|
-
} else {
|
|
2377
|
-
dir = placement === 'path' ? record.Path : binPath;
|
|
2378
|
-
}
|
|
2379
|
-
}
|
|
2380
|
-
} else if (hasBinID) {
|
|
2380
|
+
if (hasBinID) {
|
|
2381
|
+
// Always place by BinID when available
|
|
2381
2382
|
dir = resolveBinPath(record.BinID, structure);
|
|
2382
2383
|
} else if (hasPath) {
|
|
2383
2384
|
// BinID is null — resolve Path into a Bins/ location
|
|
@@ -2429,7 +2430,7 @@ async function processRecord(entityName, record, structure, options, usedNames,
|
|
|
2429
2430
|
const config = await loadConfig();
|
|
2430
2431
|
const configWithTz = { ...config, ServerTimezone: serverTz };
|
|
2431
2432
|
const localSyncTime = await getLocalSyncTime(metaPath);
|
|
2432
|
-
const serverNewer = isServerNewer(localSyncTime, record._LastUpdated, configWithTz);
|
|
2433
|
+
const serverNewer = isServerNewer(localSyncTime, record._LastUpdated, configWithTz, entityName, record.UID);
|
|
2433
2434
|
const serverDate = parseServerDate(record._LastUpdated, serverTz);
|
|
2434
2435
|
|
|
2435
2436
|
if (serverNewer) {
|
|
@@ -2758,12 +2759,12 @@ async function resolveOutputFilenameColumns(appJson, options) {
|
|
|
2758
2759
|
|
|
2759
2760
|
/**
|
|
2760
2761
|
* Build a filename for an output hierarchy entity.
|
|
2761
|
-
* Uses dot-separated hierarchical naming:
|
|
2762
|
+
* Uses dot-separated hierarchical naming: <name>~<uid>.column~<name>~<uid>.json
|
|
2762
2763
|
*
|
|
2763
2764
|
* @param {string} entityType - Documentation name: 'output', 'column', 'join', 'filter'
|
|
2764
2765
|
* @param {Object} node - The entity record
|
|
2765
2766
|
* @param {string} filenameCol - Column to use for the name portion
|
|
2766
|
-
* @param {string[]} parentChain - Array of parent segments: ['
|
|
2767
|
+
* @param {string[]} parentChain - Array of parent segments: ['name~uid', 'join~name~uid', ...]
|
|
2767
2768
|
* @returns {string} - Base filename without extension
|
|
2768
2769
|
*/
|
|
2769
2770
|
export function buildOutputFilename(entityType, node, filenameCol, parentChain = []) {
|
|
@@ -2771,28 +2772,215 @@ export function buildOutputFilename(entityType, node, filenameCol, parentChain =
|
|
|
2771
2772
|
const rawName = node[filenameCol];
|
|
2772
2773
|
const name = rawName ? sanitizeFilename(String(rawName)) : '';
|
|
2773
2774
|
|
|
2774
|
-
// Build this entity's segment
|
|
2775
|
-
// If filenameCol IS the UID, don't double-append it
|
|
2775
|
+
// Build this entity's segment
|
|
2776
2776
|
let segment;
|
|
2777
|
-
if (!name || name === uid) {
|
|
2778
|
-
segment = `${entityType}~${uid}`;
|
|
2779
|
-
} else {
|
|
2780
|
-
segment = `${entityType}~${name}~${uid}`;
|
|
2781
|
-
}
|
|
2782
|
-
|
|
2783
|
-
// Root output gets _ prefix
|
|
2784
2777
|
if (entityType === 'output') {
|
|
2785
|
-
|
|
2778
|
+
// Root output: use Name~UID directly (no type prefix)
|
|
2779
|
+
segment = (!name || name === uid) ? uid : `${name}~${uid}`;
|
|
2780
|
+
} else {
|
|
2781
|
+
// Child entities: keep type prefix (column~, join~, filter~)
|
|
2782
|
+
segment = (!name || name === uid) ? `${entityType}~${uid}` : `${entityType}~${name}~${uid}`;
|
|
2786
2783
|
}
|
|
2787
2784
|
|
|
2788
2785
|
const allSegments = [...parentChain, segment];
|
|
2789
2786
|
return allSegments.join('.');
|
|
2790
2787
|
}
|
|
2791
2788
|
|
|
2789
|
+
// ─── Inline Output Helpers ─────────────────────────────────────────────────
|
|
2790
|
+
|
|
2791
|
+
const INLINE_DOC_NAMES = { output_value: 'column', output_value_filter: 'filter', output_value_entity_column_rel: 'join' };
|
|
2792
|
+
const INLINE_DOC_TO_PHYSICAL = { column: 'output_value', join: 'output_value_entity_column_rel', filter: 'output_value_filter' };
|
|
2793
|
+
const INLINE_DOC_KEYS = ['column', 'join', 'filter'];
|
|
2794
|
+
|
|
2795
|
+
/**
|
|
2796
|
+
* Build the companion file stem for a child entity within a root output file.
|
|
2797
|
+
* e.g. root stem "Sales~abc", entity "output_value", uid "col1"
|
|
2798
|
+
* → "Sales~abc.column~col1"
|
|
2799
|
+
*
|
|
2800
|
+
* @param {string} rootStem - Root output file stem (no extension)
|
|
2801
|
+
* @param {string} physicalEntity - Physical entity name ('output_value', etc.)
|
|
2802
|
+
* @param {string} uid - Child entity UID
|
|
2803
|
+
* @param {string} [parentChainStem] - Already-built ancestor stem (for nested children)
|
|
2804
|
+
* @returns {string}
|
|
2805
|
+
*/
|
|
2806
|
+
export function getChildCompanionStem(rootStem, physicalEntity, uid, parentChainStem = rootStem) {
|
|
2807
|
+
const docName = INLINE_DOC_NAMES[physicalEntity] || physicalEntity;
|
|
2808
|
+
return `${parentChainStem}.${docName}~${uid}`;
|
|
2809
|
+
}
|
|
2810
|
+
|
|
2811
|
+
/**
|
|
2812
|
+
* Extract CustomSQL as a companion .sql file if rules require it.
|
|
2813
|
+
* Rules:
|
|
2814
|
+
* 1. Type === 'CustomSQL' → always extract (even empty)
|
|
2815
|
+
* 2. Type !== 'CustomSQL' AND CustomSQL non-empty decoded value → extract
|
|
2816
|
+
* 3. Otherwise → store "" inline; no file
|
|
2817
|
+
*
|
|
2818
|
+
* Mutates entityObj.CustomSQL to the @basename reference when extracted.
|
|
2819
|
+
* Returns the companion filename (without directory) if written, else null.
|
|
2820
|
+
*
|
|
2821
|
+
* @param {Object} entityObj - The entity object (mutated in place)
|
|
2822
|
+
* @param {string} companionStem - Stem for the companion file (no extension)
|
|
2823
|
+
* @param {string} outputDir - Directory where the root output JSON lives
|
|
2824
|
+
* @param {string} serverTz - Server timezone for timestamp syncing
|
|
2825
|
+
* @returns {Promise<string|null>} - Companion filename or null
|
|
2826
|
+
*/
|
|
2827
|
+
async function extractCustomSqlIfNeeded(entityObj, companionStem, outputDir, serverTz) {
|
|
2828
|
+
const rawSql = entityObj.CustomSQL;
|
|
2829
|
+
const isCustomSqlType = entityObj.Type === 'CustomSQL';
|
|
2830
|
+
|
|
2831
|
+
// Decode base64 server value if needed
|
|
2832
|
+
const decoded = resolveContentValue(rawSql) ?? '';
|
|
2833
|
+
const hasContent = typeof decoded === 'string' && decoded.trim().length > 0;
|
|
2834
|
+
|
|
2835
|
+
if (!isCustomSqlType && !hasContent) {
|
|
2836
|
+
// Rule 3: store empty string inline, no file
|
|
2837
|
+
entityObj.CustomSQL = '';
|
|
2838
|
+
return null;
|
|
2839
|
+
}
|
|
2840
|
+
|
|
2841
|
+
// Rules 1 and 2: extract as companion .sql file
|
|
2842
|
+
const companionName = `${companionStem}.CustomSQL.sql`;
|
|
2843
|
+
const companionPath = join(outputDir, companionName);
|
|
2844
|
+
await writeFile(companionPath, hasContent ? decoded : '', 'utf8');
|
|
2845
|
+
entityObj.CustomSQL = `@${companionName}`;
|
|
2846
|
+
entityObj._contentColumns = entityObj._contentColumns || [];
|
|
2847
|
+
if (!entityObj._contentColumns.includes('CustomSQL')) {
|
|
2848
|
+
entityObj._contentColumns.push('CustomSQL');
|
|
2849
|
+
}
|
|
2850
|
+
|
|
2851
|
+
// Sync timestamps
|
|
2852
|
+
if (serverTz && (entityObj._CreatedOn || entityObj._LastUpdated)) {
|
|
2853
|
+
try { await setFileTimestamps(companionPath, entityObj._CreatedOn, entityObj._LastUpdated, serverTz); } catch { /* non-critical */ }
|
|
2854
|
+
}
|
|
2855
|
+
|
|
2856
|
+
log.dim(` → ${companionPath}`);
|
|
2857
|
+
return companionName;
|
|
2858
|
+
}
|
|
2859
|
+
|
|
2860
|
+
/**
|
|
2861
|
+
* Recursively build a children object for a parent entity.
|
|
2862
|
+
* Mutates parentObj to set parentObj.children = { column: [], join: [], filter: [] }.
|
|
2863
|
+
* Returns companionFiles: string[] of written companion file basenames.
|
|
2864
|
+
*
|
|
2865
|
+
* Each child object retains _entity set to the physical entity name
|
|
2866
|
+
* (output_value, output_value_entity_column_rel, output_value_filter)
|
|
2867
|
+
* so that push can route submissions correctly.
|
|
2868
|
+
*
|
|
2869
|
+
* @param {Object} parentObj - The entity object to populate (mutated in place)
|
|
2870
|
+
* @param {Object} node - Tree node from buildOutputHierarchyTree (has _children)
|
|
2871
|
+
* @param {string} rootStem - Root output file stem (e.g. "Sales~abc")
|
|
2872
|
+
* @param {string} outputDir - Directory where root output JSON lives
|
|
2873
|
+
* @param {string} serverTz - Server timezone
|
|
2874
|
+
* @param {string} [parentStem] - Ancestor stem for compound companion naming
|
|
2875
|
+
* @returns {Promise<string[]>} - Array of written companion file basenames
|
|
2876
|
+
*/
|
|
2877
|
+
async function buildInlineOutputChildren(parentObj, node, rootStem, outputDir, serverTz, parentStem = rootStem) {
|
|
2878
|
+
const companionFiles = [];
|
|
2879
|
+
const nodeChildren = node._children || {};
|
|
2880
|
+
|
|
2881
|
+
// Always create children object with all three doc keys
|
|
2882
|
+
parentObj.children = { column: [], join: [], filter: [] };
|
|
2883
|
+
|
|
2884
|
+
for (const docKey of INLINE_DOC_KEYS) {
|
|
2885
|
+
const entityArray = nodeChildren[docKey];
|
|
2886
|
+
const physicalKey = INLINE_DOC_TO_PHYSICAL[docKey];
|
|
2887
|
+
|
|
2888
|
+
if (!Array.isArray(entityArray) || entityArray.length === 0) continue;
|
|
2889
|
+
|
|
2890
|
+
for (const child of entityArray) {
|
|
2891
|
+
// Build a clean copy without tree-internal fields
|
|
2892
|
+
const childObj = { ...child };
|
|
2893
|
+
delete childObj._children;
|
|
2894
|
+
|
|
2895
|
+
// Decode any base64 values
|
|
2896
|
+
for (const [key, value] of Object.entries(childObj)) {
|
|
2897
|
+
if (key === 'CustomSQL') continue; // handled by extractCustomSqlIfNeeded
|
|
2898
|
+
if (value && typeof value === 'object' && !Array.isArray(value) && value.encoding === 'base64') {
|
|
2899
|
+
childObj[key] = resolveContentValue(value);
|
|
2900
|
+
}
|
|
2901
|
+
}
|
|
2902
|
+
|
|
2903
|
+
// Ensure _entity is set to physical entity name (for push routing)
|
|
2904
|
+
childObj._entity = physicalKey;
|
|
2905
|
+
|
|
2906
|
+
// Compute companion stem for this child
|
|
2907
|
+
const childStem = getChildCompanionStem(rootStem, physicalKey, child.UID, parentStem);
|
|
2908
|
+
|
|
2909
|
+
// Extract CustomSQL if needed
|
|
2910
|
+
const companionFile = await extractCustomSqlIfNeeded(childObj, childStem, outputDir, serverTz);
|
|
2911
|
+
if (companionFile) companionFiles.push(companionFile);
|
|
2912
|
+
|
|
2913
|
+
// Recurse into child's _children (e.g. join→column, column→filter)
|
|
2914
|
+
if (child._children && Object.keys(child._children).some(k => child._children[k]?.length > 0)) {
|
|
2915
|
+
const gcFiles = await buildInlineOutputChildren(childObj, child, rootStem, outputDir, serverTz, childStem);
|
|
2916
|
+
companionFiles.push(...gcFiles);
|
|
2917
|
+
} else {
|
|
2918
|
+
// Leaf node: still set empty children
|
|
2919
|
+
childObj.children = { column: [], join: [], filter: [] };
|
|
2920
|
+
}
|
|
2921
|
+
|
|
2922
|
+
parentObj.children[docKey].push(childObj);
|
|
2923
|
+
}
|
|
2924
|
+
}
|
|
2925
|
+
|
|
2926
|
+
return companionFiles;
|
|
2927
|
+
}
|
|
2928
|
+
|
|
2929
|
+
/**
|
|
2930
|
+
* Move orphaned old-format child output .json files to /trash.
|
|
2931
|
+
* Old format: name~uid.column~name~uid.json (has .column~, .join~, or .filter~ segments)
|
|
2932
|
+
*
|
|
2933
|
+
* @param {string} outputDir - Directory containing output files
|
|
2934
|
+
* @param {string} rootStem - Root output file stem (e.g. "Sales~abc")
|
|
2935
|
+
*/
|
|
2936
|
+
async function trashOrphanedChildFiles(outputDir, rootStem) {
|
|
2937
|
+
let files;
|
|
2938
|
+
try { files = await readdir(outputDir); } catch { return; }
|
|
2939
|
+
|
|
2940
|
+
const trashDir = join(process.cwd(), 'trash');
|
|
2941
|
+
let trashCreated = false;
|
|
2942
|
+
// Also match legacy _output~ prefix for files from older CLI versions
|
|
2943
|
+
const legacyStem = `_output~${rootStem}`;
|
|
2944
|
+
|
|
2945
|
+
for (const f of files) {
|
|
2946
|
+
const matchesCurrent = f.startsWith(`${rootStem}.`);
|
|
2947
|
+
const matchesLegacy = f.startsWith(`${legacyStem}.`);
|
|
2948
|
+
if ((matchesCurrent || matchesLegacy) && /\.(column|join|filter)~/.test(f)) {
|
|
2949
|
+
// Old child file or legacy CustomSQL companion — trash it
|
|
2950
|
+
if (!trashCreated) {
|
|
2951
|
+
await mkdir(trashDir, { recursive: true });
|
|
2952
|
+
trashCreated = true;
|
|
2953
|
+
}
|
|
2954
|
+
try {
|
|
2955
|
+
await rename(join(outputDir, f), join(trashDir, f));
|
|
2956
|
+
log.dim(` Trashed orphaned child file: ${f}`);
|
|
2957
|
+
} catch { /* non-critical */ }
|
|
2958
|
+
}
|
|
2959
|
+
// Also trash the legacy root file itself (_output~Name~UID.json) if new format exists
|
|
2960
|
+
if (matchesLegacy === false && f === `${legacyStem}.json`) {
|
|
2961
|
+
if (!trashCreated) {
|
|
2962
|
+
await mkdir(trashDir, { recursive: true });
|
|
2963
|
+
trashCreated = true;
|
|
2964
|
+
}
|
|
2965
|
+
try {
|
|
2966
|
+
await rename(join(outputDir, f), join(trashDir, f));
|
|
2967
|
+
log.dim(` Trashed legacy output file: ${f}`);
|
|
2968
|
+
} catch { /* non-critical */ }
|
|
2969
|
+
}
|
|
2970
|
+
}
|
|
2971
|
+
|
|
2972
|
+
// Re-apply trash icon if files were moved (self-heals after user clears trash)
|
|
2973
|
+
if (trashCreated) {
|
|
2974
|
+
await ensureTrashIcon(trashDir);
|
|
2975
|
+
}
|
|
2976
|
+
}
|
|
2977
|
+
|
|
2978
|
+
// ─── Filename Parsing ──────────────────────────────────────────────────────
|
|
2979
|
+
|
|
2792
2980
|
/**
|
|
2793
2981
|
* Parse an output hierarchy filename back into entity relationships.
|
|
2794
2982
|
*
|
|
2795
|
-
* @param {string} filename - e.g. "
|
|
2983
|
+
* @param {string} filename - e.g. "name~uid.column~name~uid.filter~name~uid.json" (or legacy _output~ prefix)
|
|
2796
2984
|
* @returns {Object} - { segments: [{entity, name, uid}], rootOutputUid, entityType, uid }
|
|
2797
2985
|
*/
|
|
2798
2986
|
export function parseOutputHierarchyFile(filename) {
|
|
@@ -2801,7 +2989,7 @@ export function parseOutputHierarchyFile(filename) {
|
|
|
2801
2989
|
if (base.endsWith('.json')) base = base.substring(0, base.length - 5);
|
|
2802
2990
|
|
|
2803
2991
|
// Split into segments by finding entity type boundaries
|
|
2804
|
-
// Entity types are: _output
|
|
2992
|
+
// Entity types are: output~ (or legacy _output~), column~, join~, filter~
|
|
2805
2993
|
const parts = [];
|
|
2806
2994
|
|
|
2807
2995
|
// First, split by '.' but we need to be careful since names can contain '.'
|
|
@@ -2882,7 +3070,11 @@ export function parseOutputHierarchyFile(filename) {
|
|
|
2882
3070
|
|
|
2883
3071
|
/**
|
|
2884
3072
|
* Main orchestrator: process output hierarchy entities during clone.
|
|
2885
|
-
* Builds tree, resolves filenames, writes
|
|
3073
|
+
* Builds tree, resolves filenames, writes single-file inline format.
|
|
3074
|
+
*
|
|
3075
|
+
* Each root output produces exactly one .json file with all children
|
|
3076
|
+
* embedded inline under children: { column: [], join: [], filter: [] }.
|
|
3077
|
+
* Companion .sql files are extracted per CustomSQL rules.
|
|
2886
3078
|
*
|
|
2887
3079
|
* @param {Object} appJson - The full app JSON
|
|
2888
3080
|
* @param {Object} structure - Bin hierarchy structure
|
|
@@ -2907,47 +3099,41 @@ async function processOutputHierarchy(appJson, structure, options, serverTz) {
|
|
|
2907
3099
|
const forceReprocess = !!options.force;
|
|
2908
3100
|
|
|
2909
3101
|
for (const output of tree) {
|
|
2910
|
-
// Resolve bin directory
|
|
2911
|
-
let binDir =
|
|
2912
|
-
let chosenBinId = null;
|
|
3102
|
+
// Resolve bin directory: BinID → bins/<path>, null BinID → bins/ root
|
|
3103
|
+
let binDir = BINS_DIR;
|
|
2913
3104
|
if (output.BinID && structure[output.BinID]) {
|
|
2914
3105
|
binDir = resolveBinPath(output.BinID, structure);
|
|
2915
3106
|
}
|
|
2916
3107
|
|
|
2917
|
-
if (!binDir) {
|
|
2918
|
-
// No BinID — prompt or default
|
|
2919
|
-
if (!options.yes) {
|
|
2920
|
-
const inquirer = (await import('inquirer')).default;
|
|
2921
|
-
const binChoices = Object.entries(structure).map(([id, entry]) => ({
|
|
2922
|
-
name: `${entry.name} (${entry.fullPath})`,
|
|
2923
|
-
value: id,
|
|
2924
|
-
}));
|
|
2925
|
-
|
|
2926
|
-
if (binChoices.length > 0) {
|
|
2927
|
-
const { binId } = await inquirer.prompt([{
|
|
2928
|
-
type: 'list',
|
|
2929
|
-
name: 'binId',
|
|
2930
|
-
message: `Output "${output.Name || output.UID}" has no BinID. Which bin should it go in?`,
|
|
2931
|
-
choices: binChoices,
|
|
2932
|
-
}]);
|
|
2933
|
-
chosenBinId = Number(binId);
|
|
2934
|
-
binDir = resolveBinPath(chosenBinId, structure);
|
|
2935
|
-
} else {
|
|
2936
|
-
binDir = BINS_DIR;
|
|
2937
|
-
}
|
|
2938
|
-
} else {
|
|
2939
|
-
binDir = BINS_DIR;
|
|
2940
|
-
}
|
|
2941
|
-
}
|
|
2942
|
-
|
|
2943
3108
|
await mkdir(binDir, { recursive: true });
|
|
2944
3109
|
|
|
2945
3110
|
// Build root output filename
|
|
2946
3111
|
const rootBasename = buildOutputFilename('output', output, filenameCols.output);
|
|
2947
3112
|
const rootMetaPath = join(binDir, `${rootBasename}.json`);
|
|
2948
3113
|
|
|
3114
|
+
// Detect old-format files that need migration to inline children format.
|
|
3115
|
+
// Old format: children.column/join/filter contain @reference strings to separate files.
|
|
3116
|
+
// New format: children contain inline entity objects directly.
|
|
3117
|
+
let needsFormatMigration = false;
|
|
3118
|
+
if (await fileExists(rootMetaPath)) {
|
|
3119
|
+
try {
|
|
3120
|
+
const existingMeta = JSON.parse(await readFile(rootMetaPath, 'utf8'));
|
|
3121
|
+
if (existingMeta.children) {
|
|
3122
|
+
const allRefs = [
|
|
3123
|
+
...(existingMeta.children.column || []),
|
|
3124
|
+
...(existingMeta.children.join || []),
|
|
3125
|
+
...(existingMeta.children.filter || []),
|
|
3126
|
+
];
|
|
3127
|
+
needsFormatMigration = allRefs.some(ref => typeof ref === 'string' && ref.startsWith('@'));
|
|
3128
|
+
}
|
|
3129
|
+
} catch { /* read error — will be overwritten */ }
|
|
3130
|
+
if (needsFormatMigration) {
|
|
3131
|
+
log.info(` Migrating ${rootBasename} to inline children format...`);
|
|
3132
|
+
}
|
|
3133
|
+
}
|
|
3134
|
+
|
|
2949
3135
|
// Change detection for existing files (skip when --entity forces re-processing)
|
|
2950
|
-
if (await fileExists(rootMetaPath) && !options.yes && !forceReprocess) {
|
|
3136
|
+
if (await fileExists(rootMetaPath) && !options.yes && !forceReprocess && !needsFormatMigration) {
|
|
2951
3137
|
if (bulkAction.value === 'skip_all') {
|
|
2952
3138
|
log.dim(` Skipped ${rootBasename}`);
|
|
2953
3139
|
refs.push({ uid: output.UID, metaPath: rootMetaPath });
|
|
@@ -2955,7 +3141,7 @@ async function processOutputHierarchy(appJson, structure, options, serverTz) {
|
|
|
2955
3141
|
}
|
|
2956
3142
|
if (bulkAction.value !== 'overwrite_all') {
|
|
2957
3143
|
const localSyncTime = await getLocalSyncTime(rootMetaPath);
|
|
2958
|
-
const serverNewer = isServerNewer(localSyncTime, output._LastUpdated, configWithTz);
|
|
3144
|
+
const serverNewer = isServerNewer(localSyncTime, output._LastUpdated, configWithTz, 'output', output.UID);
|
|
2959
3145
|
const serverDate = parseServerDate(output._LastUpdated, serverTz);
|
|
2960
3146
|
if (serverNewer) {
|
|
2961
3147
|
const action = await promptChangeDetection(rootBasename, output, configWithTz, { serverDate, localDate: localSyncTime });
|
|
@@ -2980,94 +3166,11 @@ async function processOutputHierarchy(appJson, structure, options, serverTz) {
|
|
|
2980
3166
|
}
|
|
2981
3167
|
}
|
|
2982
3168
|
|
|
2983
|
-
//
|
|
2984
|
-
const childRefs = { column: [], join: [], filter: [] };
|
|
2985
|
-
|
|
2986
|
-
// Helper to build a child filename segment
|
|
2987
|
-
const childSegment = (type, node, col) => {
|
|
2988
|
-
const uid = node.UID || '';
|
|
2989
|
-
const rawName = node[col];
|
|
2990
|
-
const name = rawName ? sanitizeFilename(String(rawName)) : '';
|
|
2991
|
-
return (!name || name === uid) ? `${type}~${uid}` : `${type}~${name}~${uid}`;
|
|
2992
|
-
};
|
|
2993
|
-
|
|
2994
|
-
// Process all children depth-first
|
|
2995
|
-
// Direct filters on output
|
|
2996
|
-
for (const filter of output._children.filter) {
|
|
2997
|
-
const filterName = `${rootBasename}.${childSegment('filter', filter, filenameCols.output_value_filter)}`;
|
|
2998
|
-
const filterPath = join(binDir, `${filterName}.json`);
|
|
2999
|
-
await writeOutputEntityFile(filter, 'output_value_filter', filterPath, serverTz);
|
|
3000
|
-
childRefs.filter.push(`@${filterPath}`);
|
|
3001
|
-
}
|
|
3002
|
-
|
|
3003
|
-
// Direct columns on output
|
|
3004
|
-
for (const col of output._children.column) {
|
|
3005
|
-
const colSeg = childSegment('column', col, filenameCols.output_value);
|
|
3006
|
-
const colName = `${rootBasename}.${colSeg}`;
|
|
3007
|
-
const colPath = join(binDir, `${colName}.json`);
|
|
3008
|
-
await writeOutputEntityFile(col, 'output_value', colPath, serverTz);
|
|
3009
|
-
childRefs.column.push(`@${colPath}`);
|
|
3010
|
-
|
|
3011
|
-
// Filters under this column
|
|
3012
|
-
for (const filter of col._children.filter) {
|
|
3013
|
-
const filterName = `${colName}.${childSegment('filter', filter, filenameCols.output_value_filter)}`;
|
|
3014
|
-
const filterPath = join(binDir, `${filterName}.json`);
|
|
3015
|
-
await writeOutputEntityFile(filter, 'output_value_filter', filterPath, serverTz);
|
|
3016
|
-
childRefs.filter.push(`@${filterPath}`);
|
|
3017
|
-
}
|
|
3018
|
-
}
|
|
3019
|
-
|
|
3020
|
-
// Joins on output
|
|
3021
|
-
for (const j of output._children.join) {
|
|
3022
|
-
const joinSeg = childSegment('join', j, filenameCols.output_value_entity_column_rel);
|
|
3023
|
-
const joinName = `${rootBasename}.${joinSeg}`;
|
|
3024
|
-
const joinPath = join(binDir, `${joinName}.json`);
|
|
3025
|
-
await writeOutputEntityFile(j, 'output_value_entity_column_rel', joinPath, serverTz);
|
|
3026
|
-
childRefs.join.push(`@${joinPath}`);
|
|
3027
|
-
|
|
3028
|
-
// Columns under this join
|
|
3029
|
-
for (const col of j._children.column) {
|
|
3030
|
-
const joinColName = `${joinName}.${childSegment('column', col, filenameCols.output_value)}`;
|
|
3031
|
-
const joinColPath = join(binDir, `${joinColName}.json`);
|
|
3032
|
-
await writeOutputEntityFile(col, 'output_value', joinColPath, serverTz);
|
|
3033
|
-
childRefs.column.push(`@${joinColPath}`);
|
|
3034
|
-
|
|
3035
|
-
// Filters under this join→column
|
|
3036
|
-
for (const filter of col._children.filter) {
|
|
3037
|
-
const filterName = `${joinColName}.${childSegment('filter', filter, filenameCols.output_value_filter)}`;
|
|
3038
|
-
const filterPath = join(binDir, `${filterName}.json`);
|
|
3039
|
-
await writeOutputEntityFile(filter, 'output_value_filter', filterPath, serverTz);
|
|
3040
|
-
childRefs.filter.push(`@${filterPath}`);
|
|
3041
|
-
}
|
|
3042
|
-
}
|
|
3043
|
-
}
|
|
3044
|
-
|
|
3045
|
-
// Write root output JSON with child references
|
|
3169
|
+
// Build clean root object (strip tree-internal fields)
|
|
3046
3170
|
const rootMeta = {};
|
|
3047
|
-
const rootContentColumns = [];
|
|
3048
3171
|
for (const [key, value] of Object.entries(output)) {
|
|
3049
3172
|
if (key === '_children') continue;
|
|
3050
|
-
|
|
3051
|
-
// Extract CustomSQL to companion .sql file when Type is CustomSQL (even if empty)
|
|
3052
|
-
// or when the column has actual content
|
|
3053
|
-
if (key === 'CustomSQL') {
|
|
3054
|
-
const decoded = resolveContentValue(value);
|
|
3055
|
-
const hasContent = decoded && decoded.trim();
|
|
3056
|
-
if (output.Type === 'CustomSQL' || hasContent) {
|
|
3057
|
-
const sqlFilePath = rootMetaPath.replace(/\.json$/, '.CustomSQL.sql');
|
|
3058
|
-
await writeFile(sqlFilePath, hasContent ? decoded : '');
|
|
3059
|
-
rootMeta[key] = `@${basename(sqlFilePath)}`;
|
|
3060
|
-
rootContentColumns.push('CustomSQL');
|
|
3061
|
-
if (serverTz && (output._CreatedOn || output._LastUpdated)) {
|
|
3062
|
-
try { await setFileTimestamps(sqlFilePath, output._CreatedOn, output._LastUpdated, serverTz); } catch { /* non-critical */ }
|
|
3063
|
-
}
|
|
3064
|
-
log.dim(` → ${sqlFilePath}`);
|
|
3065
|
-
continue;
|
|
3066
|
-
}
|
|
3067
|
-
// Not CustomSQL type and empty — store inline
|
|
3068
|
-
rootMeta[key] = '';
|
|
3069
|
-
continue;
|
|
3070
|
-
}
|
|
3173
|
+
if (key === 'CustomSQL') continue; // handled by extractCustomSqlIfNeeded below
|
|
3071
3174
|
|
|
3072
3175
|
if (value && typeof value === 'object' && !Array.isArray(value) && value.encoding === 'base64') {
|
|
3073
3176
|
rootMeta[key] = resolveContentValue(value);
|
|
@@ -3076,23 +3179,25 @@ async function processOutputHierarchy(appJson, structure, options, serverTz) {
|
|
|
3076
3179
|
}
|
|
3077
3180
|
}
|
|
3078
3181
|
rootMeta._entity = 'output';
|
|
3079
|
-
if (rootContentColumns.length > 0) {
|
|
3080
|
-
rootMeta._contentColumns = rootContentColumns;
|
|
3081
|
-
}
|
|
3082
|
-
rootMeta.children = childRefs;
|
|
3083
3182
|
|
|
3084
|
-
//
|
|
3085
|
-
|
|
3086
|
-
|
|
3087
|
-
|
|
3088
|
-
|
|
3183
|
+
// Copy raw CustomSQL for extraction helper
|
|
3184
|
+
rootMeta.CustomSQL = output.CustomSQL;
|
|
3185
|
+
|
|
3186
|
+
// Extract CustomSQL on root (rules 1/2/3)
|
|
3187
|
+
await extractCustomSqlIfNeeded(rootMeta, rootBasename, binDir, serverTz);
|
|
3188
|
+
|
|
3189
|
+
// Embed all children under rootMeta.children = { column, join, filter }
|
|
3190
|
+
await buildInlineOutputChildren(rootMeta, output, rootBasename, binDir, serverTz);
|
|
3191
|
+
// rootMeta now has .children = { column: [...], join: [...], filter: [...] }
|
|
3089
3192
|
|
|
3090
3193
|
await writeFile(rootMetaPath, JSON.stringify(rootMeta, null, 2) + '\n');
|
|
3091
3194
|
log.success(`Saved ${rootMetaPath}`);
|
|
3092
3195
|
|
|
3196
|
+
// Move orphaned old-format child .json files to /trash
|
|
3197
|
+
await trashOrphanedChildFiles(binDir, rootBasename);
|
|
3198
|
+
|
|
3093
3199
|
// Set file timestamps to server's _LastUpdated so diff detection works.
|
|
3094
|
-
|
|
3095
|
-
if (!chosenBinId && serverTz && (output._CreatedOn || output._LastUpdated)) {
|
|
3200
|
+
if (serverTz && (output._CreatedOn || output._LastUpdated)) {
|
|
3096
3201
|
try {
|
|
3097
3202
|
await setFileTimestamps(rootMetaPath, output._CreatedOn, output._LastUpdated, serverTz);
|
|
3098
3203
|
} catch { /* non-critical */ }
|
|
@@ -3105,61 +3210,79 @@ async function processOutputHierarchy(appJson, structure, options, serverTz) {
|
|
|
3105
3210
|
}
|
|
3106
3211
|
|
|
3107
3212
|
/**
|
|
3108
|
-
* Write
|
|
3109
|
-
*
|
|
3213
|
+
* Write manifest.json to project root.
|
|
3214
|
+
* If a manifest content record was cloned from the server, use its Content value.
|
|
3215
|
+
* Otherwise, generate from appJson values (empty strings for missing fields).
|
|
3110
3216
|
*/
|
|
3111
|
-
async function
|
|
3112
|
-
|
|
3113
|
-
const
|
|
3114
|
-
|
|
3115
|
-
|
|
3116
|
-
|
|
3117
|
-
|
|
3118
|
-
|
|
3119
|
-
|
|
3120
|
-
|
|
3121
|
-
|
|
3122
|
-
|
|
3123
|
-
|
|
3124
|
-
|
|
3125
|
-
|
|
3126
|
-
|
|
3127
|
-
|
|
3128
|
-
|
|
3129
|
-
|
|
3130
|
-
|
|
3131
|
-
|
|
3132
|
-
|
|
3217
|
+
async function writeManifestJson(appJson, contentRefs) {
|
|
3218
|
+
// 1. Search contentRefs for a manifest content record
|
|
3219
|
+
for (const ref of contentRefs) {
|
|
3220
|
+
let meta;
|
|
3221
|
+
try {
|
|
3222
|
+
meta = JSON.parse(await readFile(ref.metaPath, 'utf8'));
|
|
3223
|
+
} catch { continue; }
|
|
3224
|
+
|
|
3225
|
+
const name = (meta.Name || '').toLowerCase();
|
|
3226
|
+
const ext = (meta.Extension || '').toLowerCase();
|
|
3227
|
+
if (name.startsWith('manifest') && ext === 'json') {
|
|
3228
|
+
// Found manifest — read content file and write to project root
|
|
3229
|
+
const contentRef = meta.Content;
|
|
3230
|
+
if (contentRef && String(contentRef).startsWith('@')) {
|
|
3231
|
+
const refFile = String(contentRef).substring(1);
|
|
3232
|
+
const contentPath = refFile.startsWith('/')
|
|
3233
|
+
? join(process.cwd(), refFile)
|
|
3234
|
+
: join(dirname(ref.metaPath), refFile);
|
|
3235
|
+
try {
|
|
3236
|
+
const content = await readFile(contentPath, 'utf8');
|
|
3237
|
+
await writeFile('manifest.json', content);
|
|
3238
|
+
log.dim(' manifest.json written to project root (from server content)');
|
|
3239
|
+
} catch (err) {
|
|
3240
|
+
log.warn(` Could not write manifest.json from server content: ${err.message}`);
|
|
3133
3241
|
}
|
|
3134
|
-
log.dim(` → ${sqlFilePath}`);
|
|
3135
|
-
continue;
|
|
3136
3242
|
}
|
|
3137
|
-
|
|
3138
|
-
meta[key] = '';
|
|
3139
|
-
continue;
|
|
3140
|
-
}
|
|
3141
|
-
|
|
3142
|
-
// Decode other base64 columns inline
|
|
3143
|
-
if (value && typeof value === 'object' && !Array.isArray(value) && value.encoding === 'base64') {
|
|
3144
|
-
meta[key] = resolveContentValue(value);
|
|
3145
|
-
} else {
|
|
3146
|
-
meta[key] = value;
|
|
3243
|
+
return;
|
|
3147
3244
|
}
|
|
3148
3245
|
}
|
|
3149
3246
|
|
|
3150
|
-
|
|
3151
|
-
|
|
3152
|
-
|
|
3247
|
+
// 2. No manifest content record — generate from appJson values
|
|
3248
|
+
const shortName = appJson.ShortName || '';
|
|
3249
|
+
const appName = appJson.Name || '';
|
|
3250
|
+
const description = appJson.Description || '';
|
|
3251
|
+
|
|
3252
|
+
// Find background_color from extension children (widget descriptor matching ShortName)
|
|
3253
|
+
let bgColor = '#ffffff';
|
|
3254
|
+
if (shortName) {
|
|
3255
|
+
const extensions = appJson.children?.extension || [];
|
|
3256
|
+
for (const ext of extensions) {
|
|
3257
|
+
const descriptor = resolveContentValue(ext.Descriptor) || ext.Descriptor;
|
|
3258
|
+
const string1 = resolveContentValue(ext.String1) || ext.String1;
|
|
3259
|
+
if (descriptor === 'widget' && string1 === shortName) {
|
|
3260
|
+
bgColor = resolveContentValue(ext.String4) || ext.String4 || bgColor;
|
|
3261
|
+
break;
|
|
3262
|
+
}
|
|
3263
|
+
}
|
|
3153
3264
|
}
|
|
3154
3265
|
|
|
3155
|
-
|
|
3156
|
-
|
|
3266
|
+
const manifest = {
|
|
3267
|
+
name: appName,
|
|
3268
|
+
short_name: shortName,
|
|
3269
|
+
description,
|
|
3270
|
+
orientation: 'portrait',
|
|
3271
|
+
start_url: shortName ? `/app/${shortName}/ui/` : '',
|
|
3272
|
+
lang: 'en',
|
|
3273
|
+
scope: shortName ? `/app/${shortName}/ui/` : '',
|
|
3274
|
+
display_override: ['window-control-overlay', 'minimal-ui'],
|
|
3275
|
+
display: 'standalone',
|
|
3276
|
+
background_color: bgColor,
|
|
3277
|
+
theme_color: '#000000',
|
|
3278
|
+
id: shortName,
|
|
3279
|
+
screenshots: [],
|
|
3280
|
+
ios: {},
|
|
3281
|
+
icons: [],
|
|
3282
|
+
};
|
|
3157
3283
|
|
|
3158
|
-
|
|
3159
|
-
|
|
3160
|
-
await setFileTimestamps(filePath, node._CreatedOn, node._LastUpdated, serverTz);
|
|
3161
|
-
} catch { /* non-critical */ }
|
|
3162
|
-
}
|
|
3284
|
+
await writeFile('manifest.json', JSON.stringify(manifest, null, 2) + '\n');
|
|
3285
|
+
log.dim(' manifest.json generated at project root (from app.json values)');
|
|
3163
3286
|
}
|
|
3164
3287
|
|
|
3165
3288
|
/**
|
|
@@ -3205,7 +3328,7 @@ async function saveAppJson(appJson, contentRefs, otherRefs, domain) {
|
|
|
3205
3328
|
}
|
|
3206
3329
|
|
|
3207
3330
|
/**
|
|
3208
|
-
* Save .
|
|
3331
|
+
* Save .dbo/.app_baseline.json baseline file with decoded base64 values.
|
|
3209
3332
|
* This file tracks the server state for delta detection.
|
|
3210
3333
|
*/
|
|
3211
3334
|
async function saveBaselineFile(appJson) {
|
|
@@ -3218,14 +3341,14 @@ async function saveBaselineFile(appJson) {
|
|
|
3218
3341
|
// Save to .app.json
|
|
3219
3342
|
await saveAppJsonBaseline(baseline);
|
|
3220
3343
|
|
|
3221
|
-
log.dim(' .
|
|
3344
|
+
log.dim(' .dbo/.app_baseline.json baseline created (system-managed, do not edit)');
|
|
3222
3345
|
}
|
|
3223
3346
|
|
|
3224
3347
|
/**
|
|
3225
3348
|
* Recursively decode base64 fields in an object or array.
|
|
3226
3349
|
* Modifies the input object in-place.
|
|
3227
3350
|
*/
|
|
3228
|
-
function decodeBase64Fields(obj) {
|
|
3351
|
+
export function decodeBase64Fields(obj) {
|
|
3229
3352
|
if (!obj || typeof obj !== 'object') {
|
|
3230
3353
|
return;
|
|
3231
3354
|
}
|
|
@@ -3251,3 +3374,48 @@ function decodeBase64Fields(obj) {
|
|
|
3251
3374
|
}
|
|
3252
3375
|
}
|
|
3253
3376
|
}
|
|
3377
|
+
|
|
3378
|
+
// ── Error log ─────────────────────────────────────────────────────────────
|
|
3379
|
+
|
|
3380
|
+
const ERROR_LOG_PATH = join('.dbo', 'errors.log');
|
|
3381
|
+
|
|
3382
|
+
/**
|
|
3383
|
+
* Append a structured error entry to .dbo/errors.log.
|
|
3384
|
+
* Creates the file if absent. Each entry is one JSON line (JSONL format).
|
|
3385
|
+
*/
|
|
3386
|
+
async function appendErrorLog(entry) {
|
|
3387
|
+
try {
|
|
3388
|
+
await appendFile(ERROR_LOG_PATH, JSON.stringify(entry) + '\n');
|
|
3389
|
+
} catch {
|
|
3390
|
+
// Best-effort — don't fail the command if logging fails
|
|
3391
|
+
}
|
|
3392
|
+
}
|
|
3393
|
+
|
|
3394
|
+
/**
|
|
3395
|
+
* Build an ordered list of download URLs for a media record.
|
|
3396
|
+
* Fallback chain: FullPath directly (/media/...) → /dir/ route → /api/media/{uid}
|
|
3397
|
+
*
|
|
3398
|
+
* @param {object} record - Media record with FullPath and UID
|
|
3399
|
+
* @returns {string[]} - URLs to try in order
|
|
3400
|
+
*/
|
|
3401
|
+
function buildMediaDownloadUrls(record) {
|
|
3402
|
+
const urls = [];
|
|
3403
|
+
|
|
3404
|
+
if (record.FullPath) {
|
|
3405
|
+
// 1. FullPath as-is (e.g. /media/todoes/App/assets/file.ttf)
|
|
3406
|
+
const fullPathUrl = record.FullPath.startsWith('/') ? record.FullPath : `/${record.FullPath}`;
|
|
3407
|
+
urls.push(fullPathUrl);
|
|
3408
|
+
|
|
3409
|
+
// 2. /dir/ route — strip the /media/ prefix so the path starts with appShortName
|
|
3410
|
+
const fp = record.FullPath.startsWith('/') ? record.FullPath.slice(1) : record.FullPath;
|
|
3411
|
+
const dirPath = fp.startsWith('media/') ? fp.slice(6) : fp;
|
|
3412
|
+
urls.push(`/dir/${dirPath}`);
|
|
3413
|
+
}
|
|
3414
|
+
|
|
3415
|
+
// 3. /api/media/{uid} — UID-based endpoint as last resort
|
|
3416
|
+
if (record.UID) {
|
|
3417
|
+
urls.push(`/api/media/${record.UID}`);
|
|
3418
|
+
}
|
|
3419
|
+
|
|
3420
|
+
return urls;
|
|
3421
|
+
}
|