@dboio/cli 0.10.1 → 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.
@@ -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, getBinName, findBinByPath, BINS_DIR, DEFAULT_PROJECT_DIRS, ENTITY_DIR_NAMES, OUTPUT_ENTITY_MAP, OUTPUT_HIERARCHY_ENTITIES, EXTENSION_DESCRIPTORS_DIR, EXTENSION_UNSUPPORTED_DIR, DOCUMENTATION_DIR, buildDescriptorMapping, saveDescriptorMapping, loadDescriptorMapping, resolveExtensionSubDir } from '../lib/structure.js';
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, placementPref) {
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
- const hasBinID = record.BinID && structure[record.BinID];
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
- // Prompt for TransactionKeyPreset if not already set (skip in pull mode)
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
- if (options.yes || !process.stdin.isTTY) {
751
- await saveTransactionKeyPreset('RowUID');
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 DEFAULT_PROJECT_DIRS) {
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 = DEFAULT_PROJECT_DIRS.length + mediaSubs.length + createdDirs.length;
763
+ const totalDirs = SCAFFOLD_DIRS.length + createdDirs.length;
807
764
  log.success(`Created ${totalDirs} director${totalDirs === 1 ? 'y' : 'ies'}`);
808
- for (const d of DEFAULT_PROJECT_DIRS) log.dim(` ${d}/`);
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 mixed-case directories from pre-0.9.1
813
- const LEGACY_DIR_MAP = {
814
- 'Bins': 'bins',
815
- 'Automations': 'automation',
816
- 'App Versions': 'app_version',
817
- 'Documentation': 'docs',
818
- 'Sites': 'site',
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}/" — rename it to "${newName}/" for the new convention.`);
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(appJson, options);
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, placementPrefs.mediaPlacement, serverTz, toDeleteUIDs);
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
- // Other entity types process into Bins/ (requires BinID)
932
- const refs = await processGenericEntries(entityName, entries, structure, options, placementPrefs.contentPlacement, serverTz);
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 or prompt the user.
986
- * Returns { contentPlacement, mediaPlacement } where values are 'path'|'bin'|'ask'|null
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(appJson, options) {
989
- // Load saved preferences from config
934
+ async function resolvePlacementPreferences() {
990
935
  const saved = await loadClonePlacement();
991
- let contentPlacement = saved.contentPlacement;
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
- // Always persist resolved values — not just when prompts were shown.
1056
- // This ensures defaults are saved even when the app has no content/media yet,
1057
- // so subsequent clones that do have records skip the prompts.
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, mediaPlacement };
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 non-content, non-output entities.
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, mediaPlacement, serverTz, skipUIDs = new Set()) {
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, mediaPlacement === 'fullpath' ? 'path' : mediaPlacement === 'bin' ? 'bin' : null);
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
- // Determine target directory (default: bins/ for items without explicit placement)
2055
+ // Always place media by BinID; fall back to bins/ root when BinID is missing
2191
2056
  let dir = BINS_DIR;
2192
- const hasBinID = record.BinID && structure[record.BinID];
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 buffer = await client.getBuffer(`/api/media/${record.UID}`);
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
- // Prompt for stale record cleanup
2406
- if (staleRecords.length > 0 && !options.yes) {
2407
- log.plain('');
2408
- log.info(`Found ${staleRecords.length} stale media record(s) (404 - files no longer exist on server)`);
2409
-
2410
- const inquirer = (await import('inquirer')).default;
2411
- const { cleanup } = await inquirer.prompt([{
2412
- type: 'confirm',
2413
- name: 'cleanup',
2414
- message: `Stage these ${staleRecords.length} stale media records for deletion?`,
2415
- default: false, // Conservative default
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
- } else if (staleRecords.length > 0 && options.yes) {
2436
- log.info(`Non-interactive mode: skipping stale cleanup for ${staleRecords.length} record(s)`);
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, placementPreference, serverTz, bulkAction = { value: null }) {
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, ext remains '' (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 && hasPath && !options.yes) {
2518
- // Check for a saved "all" preference
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: _output~<name>~<uid>.column~<name>~<uid>.json
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: ['_output~name~uid', 'join~name~uid', ...]
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: <type>~<name>~<uid>
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
- segment = `_${segment}`;
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 "_output~Sales~abc", entity "output_value", uid "col1"
2972
- * → "_output~Sales~abc.column~col1"
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. "_output~Sales~abc")
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: _output~name~uid.column~name~uid.json (has .column~, .join~, or .filter~ segments)
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. "_output~Sales~abc")
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
- if (f.startsWith(`${rootStem}.`) && f.endsWith('.json') && !f.includes('.CustomSQL.')) {
3119
- // Check it's actually an old child file (has .column~, .join~, or .filter~ segment)
3120
- if (/\.(column|join|filter)~/.test(f)) {
3121
- if (!trashCreated) {
3122
- await mkdir(trashDir, { recursive: true });
3123
- trashCreated = true;
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. "_output~name~uid.column~name~uid.filter~name~uid.json"
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~, output~, column~, join~, filter~
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 for this output
3264
- let binDir = null;
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
- // Skip when chosenBinId is set — keep mtime at "now" so push detects the local edit.
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
+ }