@dboio/cli 0.10.1 → 0.11.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +135 -70
- 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 +4 -0
- package/src/commands/clone.js +249 -399
- 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 +25 -60
- 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 +12 -8
- package/src/commands/push.js +317 -42
- package/src/commands/rm.js +3 -0
- package/src/commands/status.js +12 -1
- package/src/commands/sync.js +3 -0
- package/src/lib/client.js +10 -0
- package/src/lib/config.js +31 -0
- package/src/lib/delta.js +92 -0
- package/src/lib/diff.js +143 -19
- package/src/lib/ignore.js +1 -2
- 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 +3 -28
- package/src/lib/structure.js +157 -22
- 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,17 +1,18 @@
|
|
|
1
1
|
import { Command } from 'commander';
|
|
2
|
-
import { readFile, writeFile, mkdir, access, readdir, rename } from 'fs/promises';
|
|
2
|
+
import { readFile, writeFile, appendFile, mkdir, access, readdir, rename } from 'fs/promises';
|
|
3
3
|
import { join, basename, extname, dirname } from 'path';
|
|
4
4
|
import { fileURLToPath } from 'url';
|
|
5
5
|
import { DboClient } from '../lib/client.js';
|
|
6
6
|
import { loadConfig, updateConfigWithApp, loadClonePlacement, saveClonePlacement, ensureGitignore, saveEntityDirPreference, loadEntityDirPreference, saveEntityContentExtractions, loadEntityContentExtractions, saveAppJsonBaseline, addDeleteEntry, loadCollisionResolutions, saveCollisionResolutions, loadSynchronize, saveSynchronize, saveAppModifyKey, loadTransactionKeyPreset, saveTransactionKeyPreset, loadOutputFilenamePreference, saveOutputFilenamePreference, saveCloneSource, loadCloneSource, saveDescriptorFilenamePreference, loadDescriptorFilenamePreference, saveDescriptorContentExtractions, loadDescriptorContentExtractions, saveExtensionDocumentationMDPlacement, loadExtensionDocumentationMDPlacement } from '../lib/config.js';
|
|
7
|
-
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';
|
|
8
8
|
import { log } from '../lib/logger.js';
|
|
9
9
|
import { buildUidFilename, detectLegacyDotUid } from '../lib/filenames.js';
|
|
10
10
|
import { setFileTimestamps, parseServerDate } from '../lib/timestamps.js';
|
|
11
|
-
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';
|
|
12
12
|
import { checkDomainChange } from '../lib/domain-guard.js';
|
|
13
13
|
import { applyTrashIcon, ensureTrashIcon } from '../lib/folder-icon.js';
|
|
14
14
|
import { loadMetadataTemplates, saveMetadataTemplates, getTemplateCols, setTemplateCols, buildTemplateFromCloneRecord } from '../lib/metadata-templates.js';
|
|
15
|
+
import { runPendingMigrations } from '../lib/migrations.js';
|
|
15
16
|
|
|
16
17
|
/**
|
|
17
18
|
* Resolve a column value that may be base64-encoded.
|
|
@@ -154,28 +155,15 @@ export function resolveRecordPaths(entityName, record, structure, placementPref)
|
|
|
154
155
|
* Extract path components for media records.
|
|
155
156
|
* Replicates logic from processMediaEntries() for collision detection.
|
|
156
157
|
*/
|
|
157
|
-
export function resolveMediaPaths(record, structure
|
|
158
|
+
export function resolveMediaPaths(record, structure) {
|
|
158
159
|
const filename = record.Filename || `${record.Name || record.UID}.${(record.Extension || 'bin').toLowerCase()}`;
|
|
159
160
|
const name = sanitizeFilename(filename.replace(/\.[^.]+$/, ''));
|
|
160
161
|
const ext = (record.Extension || 'bin').toLowerCase();
|
|
161
162
|
|
|
163
|
+
// Always place media by BinID; fall back to bins/ root
|
|
162
164
|
let dir = BINS_DIR;
|
|
163
|
-
|
|
164
|
-
const hasFullPath = record.FullPath && typeof record.FullPath === 'string';
|
|
165
|
-
|
|
166
|
-
let fullPathDir = null;
|
|
167
|
-
if (hasFullPath) {
|
|
168
|
-
const stripped = record.FullPath.replace(/^\/+/, '');
|
|
169
|
-
const lastSlash = stripped.lastIndexOf('/');
|
|
170
|
-
fullPathDir = lastSlash >= 0 ? stripped.substring(0, lastSlash) : BINS_DIR;
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
if (hasBinID && fullPathDir && fullPathDir !== '.') {
|
|
174
|
-
dir = placementPref === 'path' ? fullPathDir : resolveBinPath(record.BinID, structure);
|
|
175
|
-
} else if (hasBinID) {
|
|
165
|
+
if (record.BinID && structure[record.BinID]) {
|
|
176
166
|
dir = resolveBinPath(record.BinID, structure);
|
|
177
|
-
} else if (fullPathDir && fullPathDir !== BINS_DIR) {
|
|
178
|
-
dir = fullPathDir;
|
|
179
167
|
}
|
|
180
168
|
|
|
181
169
|
dir = dir.replace(/^\/+|\/+$/g, '');
|
|
@@ -233,9 +221,7 @@ async function buildFileRegistry(appJson, structure, placementPrefs) {
|
|
|
233
221
|
|
|
234
222
|
// Process media records
|
|
235
223
|
for (const record of (appJson.children.media || [])) {
|
|
236
|
-
const { dir, filename, metaPath } = resolveMediaPaths(
|
|
237
|
-
record, structure, placementPrefs.mediaPlacement
|
|
238
|
-
);
|
|
224
|
+
const { dir, filename, metaPath } = resolveMediaPaths(record, structure);
|
|
239
225
|
addToRegistry(join(dir, filename), 'media', record, dir, filename, metaPath);
|
|
240
226
|
}
|
|
241
227
|
|
|
@@ -443,8 +429,10 @@ export const cloneCommand = new Command('clone')
|
|
|
443
429
|
.option('-y, --yes', 'Auto-accept all prompts')
|
|
444
430
|
.option('--media-placement <placement>', 'Set media placement: fullpath or binpath (default: bin)')
|
|
445
431
|
.option('-v, --verbose', 'Show HTTP request details')
|
|
432
|
+
.option('--no-migrate', 'Skip pending migrations for this invocation')
|
|
446
433
|
.action(async (source, options) => {
|
|
447
434
|
try {
|
|
435
|
+
await runPendingMigrations(options);
|
|
448
436
|
await performClone(source, options);
|
|
449
437
|
} catch (err) {
|
|
450
438
|
log.error(err.message);
|
|
@@ -743,27 +731,12 @@ export async function performClone(source, options = {}) {
|
|
|
743
731
|
}
|
|
744
732
|
}
|
|
745
733
|
|
|
746
|
-
//
|
|
734
|
+
// Auto-set TransactionKeyPreset to RowUID if not already set (skip in pull mode)
|
|
747
735
|
if (!options.pullMode) {
|
|
748
736
|
const existingPreset = await loadTransactionKeyPreset();
|
|
749
737
|
if (!existingPreset) {
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
log.dim(' TransactionKeyPreset: RowUID (default)');
|
|
753
|
-
} else {
|
|
754
|
-
const inquirer = (await import('inquirer')).default;
|
|
755
|
-
const { preset } = await inquirer.prompt([{
|
|
756
|
-
type: 'list',
|
|
757
|
-
name: 'preset',
|
|
758
|
-
message: 'Which row key should the CLI use when building input expressions?',
|
|
759
|
-
choices: [
|
|
760
|
-
{ name: 'RowUID (recommended — stable across domains)', value: 'RowUID' },
|
|
761
|
-
{ name: 'RowID (numeric IDs)', value: 'RowID' },
|
|
762
|
-
],
|
|
763
|
-
}]);
|
|
764
|
-
await saveTransactionKeyPreset(preset);
|
|
765
|
-
log.dim(` TransactionKeyPreset: ${preset}`);
|
|
766
|
-
}
|
|
738
|
+
await saveTransactionKeyPreset('RowUID');
|
|
739
|
+
log.dim(' TransactionKeyPreset: RowUID');
|
|
767
740
|
}
|
|
768
741
|
}
|
|
769
742
|
|
|
@@ -777,59 +750,32 @@ export async function performClone(source, options = {}) {
|
|
|
777
750
|
const structure = buildBinHierarchy(bins, appJson.AppID);
|
|
778
751
|
|
|
779
752
|
if (!options.pullMode) {
|
|
780
|
-
for (const dir of
|
|
753
|
+
for (const dir of SCAFFOLD_DIRS) {
|
|
781
754
|
await mkdir(dir, { recursive: true });
|
|
782
755
|
}
|
|
783
756
|
|
|
784
|
-
// Create media sub-directories for this app:
|
|
785
|
-
// media/<ShortName>/app/ — app-level media assets
|
|
786
|
-
// media/<ShortName>/user/ — user-uploaded media
|
|
787
|
-
const appShortName = appJson.ShortName;
|
|
788
|
-
const mediaSubs = [];
|
|
789
|
-
if (appShortName) {
|
|
790
|
-
const mediaDirs = [
|
|
791
|
-
`media/${appShortName}/app`,
|
|
792
|
-
`media/${appShortName}/user`,
|
|
793
|
-
];
|
|
794
|
-
for (const sub of mediaDirs) {
|
|
795
|
-
await mkdir(sub, { recursive: true });
|
|
796
|
-
mediaSubs.push(sub);
|
|
797
|
-
}
|
|
798
|
-
}
|
|
799
|
-
|
|
800
757
|
// Best-effort: apply trash icon
|
|
801
758
|
await applyTrashIcon(join(process.cwd(), 'trash'));
|
|
802
759
|
|
|
803
760
|
const createdDirs = await createDirectories(structure);
|
|
804
761
|
await saveStructureFile(structure);
|
|
805
762
|
|
|
806
|
-
const totalDirs =
|
|
763
|
+
const totalDirs = SCAFFOLD_DIRS.length + createdDirs.length;
|
|
807
764
|
log.success(`Created ${totalDirs} director${totalDirs === 1 ? 'y' : 'ies'}`);
|
|
808
|
-
for (const d of
|
|
809
|
-
for (const d of mediaSubs) log.dim(` ${d}/`);
|
|
765
|
+
for (const d of SCAFFOLD_DIRS) log.dim(` ${d}/`);
|
|
810
766
|
for (const d of createdDirs) log.dim(` ${d}/`);
|
|
811
767
|
|
|
812
|
-
// Warn about legacy
|
|
813
|
-
const
|
|
814
|
-
'
|
|
815
|
-
'
|
|
816
|
-
'
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
'Extensions': 'extension',
|
|
820
|
-
'Data Sources': 'data_source',
|
|
821
|
-
'Groups': 'group',
|
|
822
|
-
'Integrations': 'integration',
|
|
823
|
-
'Trash': 'trash',
|
|
824
|
-
'Src': 'src',
|
|
825
|
-
};
|
|
826
|
-
for (const [oldName, newName] of Object.entries(LEGACY_DIR_MAP)) {
|
|
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) {
|
|
827
775
|
try {
|
|
828
776
|
await access(join(process.cwd(), oldName));
|
|
829
|
-
log.warn(`Legacy directory detected: "${oldName}/" —
|
|
830
|
-
} catch {
|
|
831
|
-
// does not exist — no warning needed
|
|
832
|
-
}
|
|
777
|
+
log.warn(`Legacy directory detected: "${oldName}/" — run \`dbo\` to trigger automatic migration to lib/${oldName}/`);
|
|
778
|
+
} catch { /* does not exist — no warning needed */ }
|
|
833
779
|
}
|
|
834
780
|
} else {
|
|
835
781
|
// Pull mode: reuse existing structure, just ensure dirs exist for new bins
|
|
@@ -838,7 +784,7 @@ export async function performClone(source, options = {}) {
|
|
|
838
784
|
}
|
|
839
785
|
|
|
840
786
|
// Step 4b: Determine placement preferences (from config or prompt)
|
|
841
|
-
const placementPrefs = await resolvePlacementPreferences(
|
|
787
|
+
const placementPrefs = await resolvePlacementPreferences();
|
|
842
788
|
|
|
843
789
|
// Ensure ServerTimezone is set in config (default: America/Los_Angeles for DBO.io)
|
|
844
790
|
let serverTz = config.ServerTimezone;
|
|
@@ -867,6 +813,10 @@ export async function performClone(source, options = {}) {
|
|
|
867
813
|
}
|
|
868
814
|
}
|
|
869
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
|
+
|
|
870
820
|
// Step 5: Process content → files + metadata (skip rejected records)
|
|
871
821
|
let contentRefs = [];
|
|
872
822
|
if (!entityFilter || entityFilter.has('content')) {
|
|
@@ -890,7 +840,7 @@ export async function performClone(source, options = {}) {
|
|
|
890
840
|
if (!entityFilter || entityFilter.has('media')) {
|
|
891
841
|
const mediaEntries = appJson.children.media || [];
|
|
892
842
|
if (mediaEntries.length > 0) {
|
|
893
|
-
mediaRefs = await processMediaEntries(mediaEntries, structure, options, config, appJson.ShortName,
|
|
843
|
+
mediaRefs = await processMediaEntries(mediaEntries, structure, options, config, appJson.ShortName, serverTz, toDeleteUIDs);
|
|
894
844
|
}
|
|
895
845
|
}
|
|
896
846
|
|
|
@@ -921,15 +871,9 @@ export async function performClone(source, options = {}) {
|
|
|
921
871
|
if (refs.length > 0) {
|
|
922
872
|
otherRefs[entityName] = refs;
|
|
923
873
|
}
|
|
924
|
-
} else if (ENTITY_DIR_NAMES.has(entityName)) {
|
|
925
|
-
// Entity types with project directories — process into their directory
|
|
926
|
-
const refs = await processEntityDirEntries(entityName, entries, options, serverTz);
|
|
927
|
-
if (refs.length > 0) {
|
|
928
|
-
otherRefs[entityName] = refs;
|
|
929
|
-
}
|
|
930
874
|
} else {
|
|
931
|
-
//
|
|
932
|
-
const refs = await
|
|
875
|
+
// All remaining entities → project directory named after entity
|
|
876
|
+
const refs = await processEntityDirEntries(entityName, entries, options, serverTz);
|
|
933
877
|
if (refs.length > 0) {
|
|
934
878
|
otherRefs[entityName] = refs;
|
|
935
879
|
}
|
|
@@ -947,6 +891,7 @@ export async function performClone(source, options = {}) {
|
|
|
947
891
|
// Step 8: Create .dbo/.app_baseline.json baseline for delta tracking (skip in entity-filter mode to avoid overwriting)
|
|
948
892
|
if (!entityFilter) {
|
|
949
893
|
await saveBaselineFile(appJson);
|
|
894
|
+
resetBaselineCache(); // invalidate so next operation reloads the fresh baseline
|
|
950
895
|
}
|
|
951
896
|
|
|
952
897
|
log.plain('');
|
|
@@ -982,87 +927,20 @@ export function resolveEntityFilter(entityArg) {
|
|
|
982
927
|
}
|
|
983
928
|
|
|
984
929
|
/**
|
|
985
|
-
* Resolve placement preferences from config
|
|
986
|
-
*
|
|
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'.
|
|
987
933
|
*/
|
|
988
|
-
async function resolvePlacementPreferences(
|
|
989
|
-
// Load saved preferences from config
|
|
934
|
+
async function resolvePlacementPreferences() {
|
|
990
935
|
const saved = await loadClonePlacement();
|
|
991
|
-
|
|
992
|
-
let mediaPlacement = saved.mediaPlacement;
|
|
993
|
-
|
|
994
|
-
// Pull mode: use saved preferences or default to 'bin', no prompts
|
|
995
|
-
if (options.pullMode) {
|
|
996
|
-
return {
|
|
997
|
-
contentPlacement: contentPlacement || 'bin',
|
|
998
|
-
mediaPlacement: mediaPlacement || 'bin',
|
|
999
|
-
};
|
|
1000
|
-
}
|
|
1001
|
-
|
|
1002
|
-
// --media-placement flag takes precedence over saved config
|
|
1003
|
-
if (options.mediaPlacement) {
|
|
1004
|
-
mediaPlacement = options.mediaPlacement === 'fullpath' ? 'fullpath' : 'bin';
|
|
1005
|
-
await saveClonePlacement({ contentPlacement: contentPlacement || 'bin', mediaPlacement });
|
|
1006
|
-
log.dim(` MediaPlacement set to "${mediaPlacement}" via flag`);
|
|
1007
|
-
}
|
|
1008
|
-
|
|
1009
|
-
const hasContent = (appJson.children.content || []).length > 0;
|
|
1010
|
-
|
|
1011
|
-
// If -y flag, default to bin placement (no prompts)
|
|
1012
|
-
if (options.yes) {
|
|
1013
|
-
return {
|
|
1014
|
-
contentPlacement: contentPlacement || 'bin',
|
|
1015
|
-
mediaPlacement: mediaPlacement || 'bin',
|
|
1016
|
-
};
|
|
1017
|
-
}
|
|
1018
|
-
|
|
1019
|
-
// If both are already set in config, use them
|
|
1020
|
-
if (contentPlacement && mediaPlacement) {
|
|
1021
|
-
return { contentPlacement, mediaPlacement };
|
|
1022
|
-
}
|
|
1023
|
-
|
|
1024
|
-
const inquirer = (await import('inquirer')).default;
|
|
1025
|
-
const prompts = [];
|
|
1026
|
-
|
|
1027
|
-
// Only prompt for contentPlacement — media placement is NOT prompted interactively
|
|
1028
|
-
if (!contentPlacement && hasContent) {
|
|
1029
|
-
prompts.push({
|
|
1030
|
-
type: 'list',
|
|
1031
|
-
name: 'contentPlacement',
|
|
1032
|
-
message: 'How should content files be placed?',
|
|
1033
|
-
choices: [
|
|
1034
|
-
{ name: 'Save all in BinID directory', value: 'bin' },
|
|
1035
|
-
{ name: 'Save all in their specified Path directory', value: 'path' },
|
|
1036
|
-
{ name: 'Ask for every file that has both', value: 'ask' },
|
|
1037
|
-
],
|
|
1038
|
-
});
|
|
1039
|
-
}
|
|
1040
|
-
|
|
1041
|
-
// Media placement: no interactive prompt — default to 'bin'
|
|
1042
|
-
if (!mediaPlacement) {
|
|
1043
|
-
mediaPlacement = 'bin';
|
|
1044
|
-
}
|
|
1045
|
-
|
|
1046
|
-
if (prompts.length > 0) {
|
|
1047
|
-
const answers = await inquirer.prompt(prompts);
|
|
1048
|
-
contentPlacement = contentPlacement || answers.contentPlacement || 'bin';
|
|
1049
|
-
}
|
|
1050
|
-
|
|
1051
|
-
// Resolve defaults for any still-unset values
|
|
1052
|
-
contentPlacement = contentPlacement || 'bin';
|
|
1053
|
-
mediaPlacement = mediaPlacement || 'bin';
|
|
936
|
+
const contentPlacement = saved.contentPlacement || 'bin';
|
|
1054
937
|
|
|
1055
|
-
//
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
if (!saved.contentPlacement || !saved.mediaPlacement) {
|
|
1059
|
-
await saveClonePlacement({ contentPlacement, mediaPlacement });
|
|
1060
|
-
if (prompts.length > 0) {
|
|
1061
|
-
log.dim(' Saved placement preferences to .dbo/config.json');
|
|
1062
|
-
}
|
|
938
|
+
// Persist default if not yet saved
|
|
939
|
+
if (!saved.contentPlacement) {
|
|
940
|
+
await saveClonePlacement({ contentPlacement });
|
|
1063
941
|
}
|
|
1064
942
|
|
|
1065
|
-
return { contentPlacement
|
|
943
|
+
return { contentPlacement };
|
|
1066
944
|
}
|
|
1067
945
|
|
|
1068
946
|
/**
|
|
@@ -1157,7 +1035,31 @@ async function fetchAppFromServer(appShortName, options, config) {
|
|
|
1157
1035
|
}
|
|
1158
1036
|
|
|
1159
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.
|
|
1160
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
|
+
|
|
1161
1063
|
throw new Error(`No app found with ShortName "${appShortName}"`);
|
|
1162
1064
|
}
|
|
1163
1065
|
|
|
@@ -1257,39 +1159,7 @@ async function processContentEntries(contents, structure, options, contentPlacem
|
|
|
1257
1159
|
}
|
|
1258
1160
|
|
|
1259
1161
|
/**
|
|
1260
|
-
* Process
|
|
1261
|
-
* Only handles entries with a BinID.
|
|
1262
|
-
*/
|
|
1263
|
-
async function processGenericEntries(entityName, entries, structure, options, contentPlacement, serverTz) {
|
|
1264
|
-
if (!entries || entries.length === 0) return [];
|
|
1265
|
-
|
|
1266
|
-
const refs = [];
|
|
1267
|
-
const usedNames = new Map();
|
|
1268
|
-
const placementPreference = {
|
|
1269
|
-
value: (contentPlacement === 'path' || contentPlacement === 'bin') ? contentPlacement : null,
|
|
1270
|
-
};
|
|
1271
|
-
const bulkAction = { value: null };
|
|
1272
|
-
let processed = 0;
|
|
1273
|
-
|
|
1274
|
-
for (const record of entries) {
|
|
1275
|
-
if (!record.BinID) continue; // Skip entries without BinID
|
|
1276
|
-
|
|
1277
|
-
const ref = await processRecord(entityName, record, structure, options, usedNames, placementPreference, serverTz, bulkAction);
|
|
1278
|
-
if (ref) {
|
|
1279
|
-
refs.push(ref);
|
|
1280
|
-
processed++;
|
|
1281
|
-
}
|
|
1282
|
-
}
|
|
1283
|
-
|
|
1284
|
-
if (processed > 0) {
|
|
1285
|
-
log.info(`Processed ${processed} ${entityName} record(s)`);
|
|
1286
|
-
}
|
|
1287
|
-
|
|
1288
|
-
return refs;
|
|
1289
|
-
}
|
|
1290
|
-
|
|
1291
|
-
/**
|
|
1292
|
-
* Process entity-dir entries: entities that map to DEFAULT_PROJECT_DIRS.
|
|
1162
|
+
* Process entity-dir entries: entities that get their own project directory.
|
|
1293
1163
|
* These don't require a BinID — they go directly into their project directory
|
|
1294
1164
|
* (e.g., Extensions/, Data Sources/, etc.)
|
|
1295
1165
|
*
|
|
@@ -1298,8 +1168,7 @@ async function processGenericEntries(entityName, entries, structure, options, co
|
|
|
1298
1168
|
async function processEntityDirEntries(entityName, entries, options, serverTz) {
|
|
1299
1169
|
if (!entries || entries.length === 0) return [];
|
|
1300
1170
|
|
|
1301
|
-
const dirName = entityName;
|
|
1302
|
-
if (!ENTITY_DIR_NAMES.has(entityName)) return [];
|
|
1171
|
+
const dirName = resolveEntityDirPath(entityName);
|
|
1303
1172
|
|
|
1304
1173
|
await mkdir(dirName, { recursive: true });
|
|
1305
1174
|
|
|
@@ -1509,7 +1378,7 @@ async function processEntityDirEntries(entityName, entries, options, serverTz) {
|
|
|
1509
1378
|
if (bulkAction.value !== 'overwrite_all') {
|
|
1510
1379
|
const configWithTz = { ...config, ServerTimezone: serverTz };
|
|
1511
1380
|
const localSyncTime = await getLocalSyncTime(metaPath);
|
|
1512
|
-
const serverNewer = isServerNewer(localSyncTime, record._LastUpdated, configWithTz);
|
|
1381
|
+
const serverNewer = isServerNewer(localSyncTime, record._LastUpdated, configWithTz, 'content', record.UID);
|
|
1513
1382
|
const serverDate = parseServerDate(record._LastUpdated, serverTz);
|
|
1514
1383
|
|
|
1515
1384
|
if (serverNewer) {
|
|
@@ -1986,7 +1855,7 @@ async function processExtensionEntries(entries, structure, options, serverTz) {
|
|
|
1986
1855
|
if (bulkAction.value !== 'overwrite_all') {
|
|
1987
1856
|
const cfgWithTz = { ...config, ServerTimezone: serverTz };
|
|
1988
1857
|
const localSyncTime = await getLocalSyncTime(metaPath);
|
|
1989
|
-
const serverNewer = isServerNewer(localSyncTime, record._LastUpdated, cfgWithTz);
|
|
1858
|
+
const serverNewer = isServerNewer(localSyncTime, record._LastUpdated, cfgWithTz, 'extension', record.UID);
|
|
1990
1859
|
const serverDate = parseServerDate(record._LastUpdated, serverTz);
|
|
1991
1860
|
|
|
1992
1861
|
if (serverNewer) {
|
|
@@ -2090,7 +1959,7 @@ async function processExtensionEntries(entries, structure, options, serverTz) {
|
|
|
2090
1959
|
* Process media entries: download binary files from server + create metadata.
|
|
2091
1960
|
* Media uses Filename (not Name) and files are fetched via /api/media/{uid}.
|
|
2092
1961
|
*/
|
|
2093
|
-
async function processMediaEntries(mediaRecords, structure, options, config, appShortName,
|
|
1962
|
+
async function processMediaEntries(mediaRecords, structure, options, config, appShortName, serverTz, skipUIDs = new Set()) {
|
|
2094
1963
|
if (!mediaRecords || mediaRecords.length === 0) return [];
|
|
2095
1964
|
|
|
2096
1965
|
// Track stale records (404s) for cleanup prompt
|
|
@@ -2104,7 +1973,7 @@ async function processMediaEntries(mediaRecords, structure, options, config, app
|
|
|
2104
1973
|
for (const record of mediaRecords) {
|
|
2105
1974
|
if (skipUIDs.has(record.UID)) continue;
|
|
2106
1975
|
|
|
2107
|
-
const { metaPath: scanMetaPath } = resolveMediaPaths(record, structure
|
|
1976
|
+
const { metaPath: scanMetaPath } = resolveMediaPaths(record, structure);
|
|
2108
1977
|
const scanExists = await fileExists(scanMetaPath);
|
|
2109
1978
|
|
|
2110
1979
|
if (!scanExists) {
|
|
@@ -2117,7 +1986,7 @@ async function processMediaEntries(mediaRecords, structure, options, config, app
|
|
|
2117
1986
|
// Existing file — check if server is newer
|
|
2118
1987
|
const configWithTz = { ...config, ServerTimezone: serverTz };
|
|
2119
1988
|
const localSyncTime = await getLocalSyncTime(scanMetaPath);
|
|
2120
|
-
const serverNewer = isServerNewer(localSyncTime, record._LastUpdated, configWithTz);
|
|
1989
|
+
const serverNewer = isServerNewer(localSyncTime, record._LastUpdated, configWithTz, 'media', record.UID);
|
|
2121
1990
|
if (serverNewer) {
|
|
2122
1991
|
needsDownload.push(record);
|
|
2123
1992
|
} else {
|
|
@@ -2166,10 +2035,6 @@ async function processMediaEntries(mediaRecords, structure, options, config, app
|
|
|
2166
2035
|
|
|
2167
2036
|
const refs = [];
|
|
2168
2037
|
const usedNames = new Map();
|
|
2169
|
-
// Map config values: 'fullpath' → 'path' (used internally), 'bin' → 'bin', 'ask' → null
|
|
2170
|
-
const placementPreference = {
|
|
2171
|
-
value: mediaPlacement === 'fullpath' ? 'path' : mediaPlacement === 'bin' ? 'bin' : null,
|
|
2172
|
-
};
|
|
2173
2038
|
const mediaBulkAction = { value: null };
|
|
2174
2039
|
let downloaded = 0;
|
|
2175
2040
|
let failed = 0;
|
|
@@ -2187,57 +2052,10 @@ async function processMediaEntries(mediaRecords, structure, options, config, app
|
|
|
2187
2052
|
const name = sanitizeFilename(filename.replace(/\.[^.]+$/, '')); // base name without extension
|
|
2188
2053
|
const ext = (record.Extension || 'bin').toLowerCase();
|
|
2189
2054
|
|
|
2190
|
-
//
|
|
2055
|
+
// Always place media by BinID; fall back to bins/ root when BinID is missing
|
|
2191
2056
|
let dir = BINS_DIR;
|
|
2192
|
-
|
|
2193
|
-
const hasFullPath = record.FullPath && typeof record.FullPath === 'string';
|
|
2194
|
-
|
|
2195
|
-
// Parse directory from FullPath: strip leading / and remove filename
|
|
2196
|
-
// FullPath like /media/operator/app/assets/gfx/logo.png → media/operator/app/assets/gfx/
|
|
2197
|
-
// These are valid project-relative paths (media/, dir/ are real directories)
|
|
2198
|
-
let fullPathDir = null;
|
|
2199
|
-
if (hasFullPath) {
|
|
2200
|
-
const stripped = record.FullPath.replace(/^\/+/, '');
|
|
2201
|
-
const lastSlash = stripped.lastIndexOf('/');
|
|
2202
|
-
fullPathDir = lastSlash >= 0 ? stripped.substring(0, lastSlash) : BINS_DIR;
|
|
2203
|
-
}
|
|
2204
|
-
|
|
2205
|
-
if (hasBinID && fullPathDir && fullPathDir !== '.' && !options.yes) {
|
|
2206
|
-
if (placementPreference.value) {
|
|
2207
|
-
dir = placementPreference.value === 'path'
|
|
2208
|
-
? fullPathDir
|
|
2209
|
-
: resolveBinPath(record.BinID, structure);
|
|
2210
|
-
} else {
|
|
2211
|
-
const binPath = resolveBinPath(record.BinID, structure);
|
|
2212
|
-
const binName = getBinName(record.BinID, structure);
|
|
2213
|
-
const inquirer = (await import('inquirer')).default;
|
|
2214
|
-
|
|
2215
|
-
const { placement } = await inquirer.prompt([{
|
|
2216
|
-
type: 'list',
|
|
2217
|
-
name: 'placement',
|
|
2218
|
-
message: `Where do you want me to place ${filename}?`,
|
|
2219
|
-
choices: [
|
|
2220
|
-
{ name: `Into the FullPath of ${fullPathDir}`, value: 'path' },
|
|
2221
|
-
{ name: `Into the BinID of ${record.BinID} (${binName} → ${binPath})`, value: 'bin' },
|
|
2222
|
-
{ name: `Place this and all further files by FullPath`, value: 'all_path' },
|
|
2223
|
-
{ name: `Place this and all further files by BinID`, value: 'all_bin' },
|
|
2224
|
-
],
|
|
2225
|
-
}]);
|
|
2226
|
-
|
|
2227
|
-
if (placement === 'all_path') {
|
|
2228
|
-
placementPreference.value = 'path';
|
|
2229
|
-
dir = fullPathDir;
|
|
2230
|
-
} else if (placement === 'all_bin') {
|
|
2231
|
-
placementPreference.value = 'bin';
|
|
2232
|
-
dir = binPath;
|
|
2233
|
-
} else {
|
|
2234
|
-
dir = placement === 'path' ? fullPathDir : binPath;
|
|
2235
|
-
}
|
|
2236
|
-
}
|
|
2237
|
-
} else if (hasBinID) {
|
|
2057
|
+
if (record.BinID && structure[record.BinID]) {
|
|
2238
2058
|
dir = resolveBinPath(record.BinID, structure);
|
|
2239
|
-
} else if (fullPathDir && fullPathDir !== BINS_DIR) {
|
|
2240
|
-
dir = fullPathDir;
|
|
2241
2059
|
}
|
|
2242
2060
|
|
|
2243
2061
|
dir = dir.replace(/^\/+|\/+$/g, '');
|
|
@@ -2270,7 +2088,7 @@ async function processMediaEntries(mediaRecords, structure, options, config, app
|
|
|
2270
2088
|
// with how setFileTimestamps set the mtime — the two must use the same timezone.
|
|
2271
2089
|
const configWithTz = { ...config, ServerTimezone: serverTz };
|
|
2272
2090
|
const localSyncTime = await getLocalSyncTime(metaPath);
|
|
2273
|
-
const serverNewer = isServerNewer(localSyncTime, record._LastUpdated, configWithTz);
|
|
2091
|
+
const serverNewer = isServerNewer(localSyncTime, record._LastUpdated, configWithTz, 'media', record.UID);
|
|
2274
2092
|
const serverDate = parseServerDate(record._LastUpdated, serverTz);
|
|
2275
2093
|
const diffable = isDiffable(ext);
|
|
2276
2094
|
|
|
@@ -2342,9 +2160,25 @@ async function processMediaEntries(mediaRecords, structure, options, config, app
|
|
|
2342
2160
|
}
|
|
2343
2161
|
}
|
|
2344
2162
|
|
|
2345
|
-
// 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)
|
|
2346
2167
|
try {
|
|
2347
|
-
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
|
+
}
|
|
2348
2182
|
await writeFile(filePath, buffer);
|
|
2349
2183
|
const sizeKB = (buffer.length / 1024).toFixed(1);
|
|
2350
2184
|
log.success(`Downloaded ${filePath} (${sizeKB} KB)`);
|
|
@@ -2365,11 +2199,23 @@ async function processMediaEntries(mediaRecords, structure, options, config, app
|
|
|
2365
2199
|
} else {
|
|
2366
2200
|
log.warn(`Failed to download ${filename}`);
|
|
2367
2201
|
log.dim(` UID: ${record.UID}`);
|
|
2368
|
-
log.dim(` URL: /api/media/${record.UID}`);
|
|
2369
2202
|
if (record.FullPath) log.dim(` FullPath: ${record.FullPath}`);
|
|
2370
2203
|
log.dim(` Error: ${err.message}`);
|
|
2371
2204
|
}
|
|
2372
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
|
+
|
|
2373
2219
|
failed++;
|
|
2374
2220
|
continue; // Skip metadata if download failed
|
|
2375
2221
|
}
|
|
@@ -2402,38 +2248,22 @@ async function processMediaEntries(mediaRecords, structure, options, config, app
|
|
|
2402
2248
|
|
|
2403
2249
|
log.info(`Media: ${downloaded} downloaded, ${failed} failed, ${upToDateRefs.length} up to date`);
|
|
2404
2250
|
|
|
2405
|
-
//
|
|
2406
|
-
if (staleRecords.length > 0
|
|
2407
|
-
log.
|
|
2408
|
-
|
|
2409
|
-
|
|
2410
|
-
|
|
2411
|
-
|
|
2412
|
-
|
|
2413
|
-
|
|
2414
|
-
|
|
2415
|
-
|
|
2416
|
-
|
|
2417
|
-
|
|
2418
|
-
if (cleanup) {
|
|
2419
|
-
for (const stale of staleRecords) {
|
|
2420
|
-
const expression = `RowID:del${stale.RowID};entity:media=true`;
|
|
2421
|
-
await addDeleteEntry({
|
|
2422
|
-
UID: stale.UID,
|
|
2423
|
-
RowID: stale.RowID,
|
|
2424
|
-
entity: 'media',
|
|
2425
|
-
name: stale.filename,
|
|
2426
|
-
expression,
|
|
2427
|
-
});
|
|
2428
|
-
log.dim(` Staged: ${stale.filename}`);
|
|
2429
|
-
}
|
|
2430
|
-
log.success('Stale media records staged in .dbo/synchronize.json');
|
|
2431
|
-
log.dim(' Run "dbo push" to delete from server');
|
|
2432
|
-
} else {
|
|
2433
|
-
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}`);
|
|
2434
2264
|
}
|
|
2435
|
-
|
|
2436
|
-
log.
|
|
2265
|
+
log.success('Stale media records staged in .dbo/synchronize.json');
|
|
2266
|
+
log.dim(' Run "dbo push" to delete from server');
|
|
2437
2267
|
}
|
|
2438
2268
|
|
|
2439
2269
|
return [...upToDateRefs, ...refs];
|
|
@@ -2443,7 +2273,7 @@ async function processMediaEntries(mediaRecords, structure, options, config, app
|
|
|
2443
2273
|
* Process a single record: determine directory, write content file + metadata.
|
|
2444
2274
|
* Returns { uid, metaPath } or null.
|
|
2445
2275
|
*/
|
|
2446
|
-
async function processRecord(entityName, record, structure, options, usedNames,
|
|
2276
|
+
async function processRecord(entityName, record, structure, options, usedNames, _placementPreference, serverTz, bulkAction = { value: null }) {
|
|
2447
2277
|
let name = sanitizeFilename(String(record.Name || record.UID || 'untitled'));
|
|
2448
2278
|
|
|
2449
2279
|
// Determine file extension (priority: Extension field > Name field > Path field > empty)
|
|
@@ -2464,7 +2294,40 @@ async function processRecord(entityName, record, structure, options, usedNames,
|
|
|
2464
2294
|
ext = extractedExt ? extractedExt.substring(1).toLowerCase() : '';
|
|
2465
2295
|
}
|
|
2466
2296
|
}
|
|
2467
|
-
// 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
|
+
}
|
|
2468
2331
|
|
|
2469
2332
|
// If no extension determined and Content column has data, prompt user to choose one
|
|
2470
2333
|
if (!ext && !options.yes && record.Content) {
|
|
@@ -2514,41 +2377,8 @@ async function processRecord(entityName, record, structure, options, usedNames,
|
|
|
2514
2377
|
const hasBinID = record.BinID && structure[record.BinID];
|
|
2515
2378
|
const hasPath = record.Path && typeof record.Path === 'string' && record.Path.trim();
|
|
2516
2379
|
|
|
2517
|
-
if (hasBinID
|
|
2518
|
-
//
|
|
2519
|
-
if (placementPreference.value) {
|
|
2520
|
-
dir = placementPreference.value === 'path'
|
|
2521
|
-
? record.Path
|
|
2522
|
-
: resolveBinPath(record.BinID, structure);
|
|
2523
|
-
} else {
|
|
2524
|
-
// Both BinID and Path — prompt user
|
|
2525
|
-
const binPath = resolveBinPath(record.BinID, structure);
|
|
2526
|
-
const binName = getBinName(record.BinID, structure);
|
|
2527
|
-
const inquirer = (await import('inquirer')).default;
|
|
2528
|
-
|
|
2529
|
-
const { placement } = await inquirer.prompt([{
|
|
2530
|
-
type: 'list',
|
|
2531
|
-
name: 'placement',
|
|
2532
|
-
message: `Where do you want me to place ${name}.${ext}?`,
|
|
2533
|
-
choices: [
|
|
2534
|
-
{ name: `Into the Path of ${record.Path}`, value: 'path' },
|
|
2535
|
-
{ name: `Into the BinID of ${record.BinID} (${binName} → ${binPath})`, value: 'bin' },
|
|
2536
|
-
{ name: `Place this and all further files by Path`, value: 'all_path' },
|
|
2537
|
-
{ name: `Place this and all further files by BinID`, value: 'all_bin' },
|
|
2538
|
-
],
|
|
2539
|
-
}]);
|
|
2540
|
-
|
|
2541
|
-
if (placement === 'all_path') {
|
|
2542
|
-
placementPreference.value = 'path';
|
|
2543
|
-
dir = record.Path;
|
|
2544
|
-
} else if (placement === 'all_bin') {
|
|
2545
|
-
placementPreference.value = 'bin';
|
|
2546
|
-
dir = binPath;
|
|
2547
|
-
} else {
|
|
2548
|
-
dir = placement === 'path' ? record.Path : binPath;
|
|
2549
|
-
}
|
|
2550
|
-
}
|
|
2551
|
-
} else if (hasBinID) {
|
|
2380
|
+
if (hasBinID) {
|
|
2381
|
+
// Always place by BinID when available
|
|
2552
2382
|
dir = resolveBinPath(record.BinID, structure);
|
|
2553
2383
|
} else if (hasPath) {
|
|
2554
2384
|
// BinID is null — resolve Path into a Bins/ location
|
|
@@ -2600,7 +2430,7 @@ async function processRecord(entityName, record, structure, options, usedNames,
|
|
|
2600
2430
|
const config = await loadConfig();
|
|
2601
2431
|
const configWithTz = { ...config, ServerTimezone: serverTz };
|
|
2602
2432
|
const localSyncTime = await getLocalSyncTime(metaPath);
|
|
2603
|
-
const serverNewer = isServerNewer(localSyncTime, record._LastUpdated, configWithTz);
|
|
2433
|
+
const serverNewer = isServerNewer(localSyncTime, record._LastUpdated, configWithTz, entityName, record.UID);
|
|
2604
2434
|
const serverDate = parseServerDate(record._LastUpdated, serverTz);
|
|
2605
2435
|
|
|
2606
2436
|
if (serverNewer) {
|
|
@@ -2929,12 +2759,12 @@ async function resolveOutputFilenameColumns(appJson, options) {
|
|
|
2929
2759
|
|
|
2930
2760
|
/**
|
|
2931
2761
|
* Build a filename for an output hierarchy entity.
|
|
2932
|
-
* Uses dot-separated hierarchical naming:
|
|
2762
|
+
* Uses dot-separated hierarchical naming: <name>~<uid>.column~<name>~<uid>.json
|
|
2933
2763
|
*
|
|
2934
2764
|
* @param {string} entityType - Documentation name: 'output', 'column', 'join', 'filter'
|
|
2935
2765
|
* @param {Object} node - The entity record
|
|
2936
2766
|
* @param {string} filenameCol - Column to use for the name portion
|
|
2937
|
-
* @param {string[]} parentChain - Array of parent segments: ['
|
|
2767
|
+
* @param {string[]} parentChain - Array of parent segments: ['name~uid', 'join~name~uid', ...]
|
|
2938
2768
|
* @returns {string} - Base filename without extension
|
|
2939
2769
|
*/
|
|
2940
2770
|
export function buildOutputFilename(entityType, node, filenameCol, parentChain = []) {
|
|
@@ -2942,18 +2772,14 @@ export function buildOutputFilename(entityType, node, filenameCol, parentChain =
|
|
|
2942
2772
|
const rawName = node[filenameCol];
|
|
2943
2773
|
const name = rawName ? sanitizeFilename(String(rawName)) : '';
|
|
2944
2774
|
|
|
2945
|
-
// Build this entity's segment
|
|
2946
|
-
// If filenameCol IS the UID, don't double-append it
|
|
2775
|
+
// Build this entity's segment
|
|
2947
2776
|
let segment;
|
|
2948
|
-
if (!name || name === uid) {
|
|
2949
|
-
segment = `${entityType}~${uid}`;
|
|
2950
|
-
} else {
|
|
2951
|
-
segment = `${entityType}~${name}~${uid}`;
|
|
2952
|
-
}
|
|
2953
|
-
|
|
2954
|
-
// Root output gets _ prefix
|
|
2955
2777
|
if (entityType === 'output') {
|
|
2956
|
-
|
|
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}`;
|
|
2957
2783
|
}
|
|
2958
2784
|
|
|
2959
2785
|
const allSegments = [...parentChain, segment];
|
|
@@ -2968,8 +2794,8 @@ const INLINE_DOC_KEYS = ['column', 'join', 'filter'];
|
|
|
2968
2794
|
|
|
2969
2795
|
/**
|
|
2970
2796
|
* Build the companion file stem for a child entity within a root output file.
|
|
2971
|
-
* e.g. root stem "
|
|
2972
|
-
* → "
|
|
2797
|
+
* e.g. root stem "Sales~abc", entity "output_value", uid "col1"
|
|
2798
|
+
* → "Sales~abc.column~col1"
|
|
2973
2799
|
*
|
|
2974
2800
|
* @param {string} rootStem - Root output file stem (no extension)
|
|
2975
2801
|
* @param {string} physicalEntity - Physical entity name ('output_value', etc.)
|
|
@@ -3042,7 +2868,7 @@ async function extractCustomSqlIfNeeded(entityObj, companionStem, outputDir, ser
|
|
|
3042
2868
|
*
|
|
3043
2869
|
* @param {Object} parentObj - The entity object to populate (mutated in place)
|
|
3044
2870
|
* @param {Object} node - Tree node from buildOutputHierarchyTree (has _children)
|
|
3045
|
-
* @param {string} rootStem - Root output file stem (e.g. "
|
|
2871
|
+
* @param {string} rootStem - Root output file stem (e.g. "Sales~abc")
|
|
3046
2872
|
* @param {string} outputDir - Directory where root output JSON lives
|
|
3047
2873
|
* @param {string} serverTz - Server timezone
|
|
3048
2874
|
* @param {string} [parentStem] - Ancestor stem for compound companion naming
|
|
@@ -3102,10 +2928,10 @@ async function buildInlineOutputChildren(parentObj, node, rootStem, outputDir, s
|
|
|
3102
2928
|
|
|
3103
2929
|
/**
|
|
3104
2930
|
* Move orphaned old-format child output .json files to /trash.
|
|
3105
|
-
* Old format:
|
|
2931
|
+
* Old format: name~uid.column~name~uid.json (has .column~, .join~, or .filter~ segments)
|
|
3106
2932
|
*
|
|
3107
2933
|
* @param {string} outputDir - Directory containing output files
|
|
3108
|
-
* @param {string} rootStem - Root output file stem (e.g. "
|
|
2934
|
+
* @param {string} rootStem - Root output file stem (e.g. "Sales~abc")
|
|
3109
2935
|
*/
|
|
3110
2936
|
async function trashOrphanedChildFiles(outputDir, rootStem) {
|
|
3111
2937
|
let files;
|
|
@@ -3113,20 +2939,33 @@ async function trashOrphanedChildFiles(outputDir, rootStem) {
|
|
|
3113
2939
|
|
|
3114
2940
|
const trashDir = join(process.cwd(), 'trash');
|
|
3115
2941
|
let trashCreated = false;
|
|
2942
|
+
// Also match legacy _output~ prefix for files from older CLI versions
|
|
2943
|
+
const legacyStem = `_output~${rootStem}`;
|
|
3116
2944
|
|
|
3117
2945
|
for (const f of files) {
|
|
3118
|
-
|
|
3119
|
-
|
|
3120
|
-
|
|
3121
|
-
|
|
3122
|
-
|
|
3123
|
-
|
|
3124
|
-
|
|
3125
|
-
try {
|
|
3126
|
-
await rename(join(outputDir, f), join(trashDir, f));
|
|
3127
|
-
log.dim(` Trashed orphaned child file: ${f}`);
|
|
3128
|
-
} catch { /* non-critical */ }
|
|
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;
|
|
3129
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 */ }
|
|
3130
2969
|
}
|
|
3131
2970
|
}
|
|
3132
2971
|
|
|
@@ -3141,7 +2980,7 @@ async function trashOrphanedChildFiles(outputDir, rootStem) {
|
|
|
3141
2980
|
/**
|
|
3142
2981
|
* Parse an output hierarchy filename back into entity relationships.
|
|
3143
2982
|
*
|
|
3144
|
-
* @param {string} filename - e.g. "
|
|
2983
|
+
* @param {string} filename - e.g. "name~uid.column~name~uid.filter~name~uid.json" (or legacy _output~ prefix)
|
|
3145
2984
|
* @returns {Object} - { segments: [{entity, name, uid}], rootOutputUid, entityType, uid }
|
|
3146
2985
|
*/
|
|
3147
2986
|
export function parseOutputHierarchyFile(filename) {
|
|
@@ -3150,7 +2989,7 @@ export function parseOutputHierarchyFile(filename) {
|
|
|
3150
2989
|
if (base.endsWith('.json')) base = base.substring(0, base.length - 5);
|
|
3151
2990
|
|
|
3152
2991
|
// Split into segments by finding entity type boundaries
|
|
3153
|
-
// Entity types are: _output
|
|
2992
|
+
// Entity types are: output~ (or legacy _output~), column~, join~, filter~
|
|
3154
2993
|
const parts = [];
|
|
3155
2994
|
|
|
3156
2995
|
// First, split by '.' but we need to be careful since names can contain '.'
|
|
@@ -3260,39 +3099,12 @@ async function processOutputHierarchy(appJson, structure, options, serverTz) {
|
|
|
3260
3099
|
const forceReprocess = !!options.force;
|
|
3261
3100
|
|
|
3262
3101
|
for (const output of tree) {
|
|
3263
|
-
// Resolve bin directory
|
|
3264
|
-
let binDir =
|
|
3265
|
-
let chosenBinId = null;
|
|
3102
|
+
// Resolve bin directory: BinID → bins/<path>, null BinID → bins/ root
|
|
3103
|
+
let binDir = BINS_DIR;
|
|
3266
3104
|
if (output.BinID && structure[output.BinID]) {
|
|
3267
3105
|
binDir = resolveBinPath(output.BinID, structure);
|
|
3268
3106
|
}
|
|
3269
3107
|
|
|
3270
|
-
if (!binDir) {
|
|
3271
|
-
// No BinID — prompt or default
|
|
3272
|
-
if (!options.yes) {
|
|
3273
|
-
const inquirer = (await import('inquirer')).default;
|
|
3274
|
-
const binChoices = Object.entries(structure).map(([id, entry]) => ({
|
|
3275
|
-
name: `${entry.name} (${entry.fullPath})`,
|
|
3276
|
-
value: id,
|
|
3277
|
-
}));
|
|
3278
|
-
|
|
3279
|
-
if (binChoices.length > 0) {
|
|
3280
|
-
const { binId } = await inquirer.prompt([{
|
|
3281
|
-
type: 'list',
|
|
3282
|
-
name: 'binId',
|
|
3283
|
-
message: `Output "${output.Name || output.UID}" has no BinID. Which bin should it go in?`,
|
|
3284
|
-
choices: binChoices,
|
|
3285
|
-
}]);
|
|
3286
|
-
chosenBinId = Number(binId);
|
|
3287
|
-
binDir = resolveBinPath(chosenBinId, structure);
|
|
3288
|
-
} else {
|
|
3289
|
-
binDir = BINS_DIR;
|
|
3290
|
-
}
|
|
3291
|
-
} else {
|
|
3292
|
-
binDir = BINS_DIR;
|
|
3293
|
-
}
|
|
3294
|
-
}
|
|
3295
|
-
|
|
3296
3108
|
await mkdir(binDir, { recursive: true });
|
|
3297
3109
|
|
|
3298
3110
|
// Build root output filename
|
|
@@ -3329,7 +3141,7 @@ async function processOutputHierarchy(appJson, structure, options, serverTz) {
|
|
|
3329
3141
|
}
|
|
3330
3142
|
if (bulkAction.value !== 'overwrite_all') {
|
|
3331
3143
|
const localSyncTime = await getLocalSyncTime(rootMetaPath);
|
|
3332
|
-
const serverNewer = isServerNewer(localSyncTime, output._LastUpdated, configWithTz);
|
|
3144
|
+
const serverNewer = isServerNewer(localSyncTime, output._LastUpdated, configWithTz, 'output', output.UID);
|
|
3333
3145
|
const serverDate = parseServerDate(output._LastUpdated, serverTz);
|
|
3334
3146
|
if (serverNewer) {
|
|
3335
3147
|
const action = await promptChangeDetection(rootBasename, output, configWithTz, { serverDate, localDate: localSyncTime });
|
|
@@ -3378,12 +3190,6 @@ async function processOutputHierarchy(appJson, structure, options, serverTz) {
|
|
|
3378
3190
|
await buildInlineOutputChildren(rootMeta, output, rootBasename, binDir, serverTz);
|
|
3379
3191
|
// rootMeta now has .children = { column: [...], join: [...], filter: [...] }
|
|
3380
3192
|
|
|
3381
|
-
// If user chose a bin for a BinID-less output, store it and mark as modified
|
|
3382
|
-
if (chosenBinId) {
|
|
3383
|
-
rootMeta.BinID = chosenBinId;
|
|
3384
|
-
log.dim(` Set BinID=${chosenBinId} on "${output.Name || output.UID}" (staged for next push)`);
|
|
3385
|
-
}
|
|
3386
|
-
|
|
3387
3193
|
await writeFile(rootMetaPath, JSON.stringify(rootMeta, null, 2) + '\n');
|
|
3388
3194
|
log.success(`Saved ${rootMetaPath}`);
|
|
3389
3195
|
|
|
@@ -3391,8 +3197,7 @@ async function processOutputHierarchy(appJson, structure, options, serverTz) {
|
|
|
3391
3197
|
await trashOrphanedChildFiles(binDir, rootBasename);
|
|
3392
3198
|
|
|
3393
3199
|
// Set file timestamps to server's _LastUpdated so diff detection works.
|
|
3394
|
-
|
|
3395
|
-
if (!chosenBinId && serverTz && (output._CreatedOn || output._LastUpdated)) {
|
|
3200
|
+
if (serverTz && (output._CreatedOn || output._LastUpdated)) {
|
|
3396
3201
|
try {
|
|
3397
3202
|
await setFileTimestamps(rootMetaPath, output._CreatedOn, output._LastUpdated, serverTz);
|
|
3398
3203
|
} catch { /* non-critical */ }
|
|
@@ -3569,3 +3374,48 @@ export function decodeBase64Fields(obj) {
|
|
|
3569
3374
|
}
|
|
3570
3375
|
}
|
|
3571
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
|
+
}
|