@dboio/cli 0.9.8 → 0.11.1

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