@dboio/cli 0.9.8 → 0.10.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,6 +1,7 @@
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, 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
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';
@@ -9,6 +10,7 @@ import { buildUidFilename, detectLegacyDotUid } from '../lib/filenames.js';
9
10
  import { setFileTimestamps, parseServerDate } from '../lib/timestamps.js';
10
11
  import { getLocalSyncTime, isServerNewer, hasLocalModifications, promptChangeDetection, inlineDiffAndMerge, isDiffable } 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';
13
15
 
14
16
  /**
@@ -93,7 +95,7 @@ function resolvePathToBinsDir(pathValue, structure) {
93
95
  * Extract path components for content/generic records (read-only, no file writes).
94
96
  * Replicates logic from processRecord() for collision detection.
95
97
  */
96
- function resolveRecordPaths(entityName, record, structure, placementPref) {
98
+ export function resolveRecordPaths(entityName, record, structure, placementPref) {
97
99
  let name = sanitizeFilename(String(record.Name || record.UID || 'untitled'));
98
100
 
99
101
  // Determine extension (priority: Extension field > Name > Path)
@@ -152,7 +154,7 @@ function resolveRecordPaths(entityName, record, structure, placementPref) {
152
154
  * Extract path components for media records.
153
155
  * Replicates logic from processMediaEntries() for collision detection.
154
156
  */
155
- function resolveMediaPaths(record, structure, placementPref) {
157
+ export function resolveMediaPaths(record, structure, placementPref) {
156
158
  const filename = record.Filename || `${record.Name || record.UID}.${(record.Extension || 'bin').toLowerCase()}`;
157
159
  const name = sanitizeFilename(filename.replace(/\.[^.]+$/, ''));
158
160
  const ext = (record.Extension || 'bin').toLowerCase();
@@ -191,7 +193,7 @@ function resolveMediaPaths(record, structure, placementPref) {
191
193
  * Extract path components for entity-dir records.
192
194
  * Simplified from processEntityDirEntries() for collision detection.
193
195
  */
194
- function resolveEntityDirPaths(entityName, record, dirName) {
196
+ export function resolveEntityDirPaths(entityName, record, dirName) {
195
197
  let name;
196
198
  if (entityName === 'app_version' && record.Number) {
197
199
  name = sanitizeFilename(String(record.Number));
@@ -644,10 +646,10 @@ export async function performClone(source, options = {}) {
644
646
  const effectiveDomain = options.domain || config.domain;
645
647
  let appJson;
646
648
 
647
- // Step 1: Source mismatch detection
649
+ // Step 1: Source mismatch detection (skip in pull mode)
648
650
  // 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) {
651
+ const storedCloneSource = options.pullMode ? null : await loadCloneSource();
652
+ if (!options.pullMode && source && storedCloneSource && source !== storedCloneSource) {
651
653
  if (!options.force && !options.yes) {
652
654
  log.warn('');
653
655
  log.warn(` ⚠ This project was previously cloned from: ${storedCloneSource}`);
@@ -707,95 +709,132 @@ export async function performClone(source, options = {}) {
707
709
  }
708
710
  }
709
711
 
710
- log.success(`Cloning "${appJson.Name}" (${appJson.ShortName})`);
712
+ log.success(`${options.pullMode ? 'Pulling' : 'Cloning'} "${appJson.Name}" (${appJson.ShortName})`);
711
713
 
712
- // Check for un-pushed staged items in synchronize.json
713
- await checkPendingSynchronize(options);
714
+ // Check for un-pushed staged items in synchronize.json (skip in pull mode)
715
+ if (!options.pullMode) {
716
+ await checkPendingSynchronize(options);
717
+ }
714
718
 
715
719
  // 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}`);
720
+ await ensureGitignore(['.dbo/credentials.json', '.dbo/cookies.txt', '.dbo/.app_baseline.json']);
721
+
722
+ // Step 2: Update .dbo/config.json (skip in pull mode — config already set)
723
+ if (!options.pullMode) {
724
+ await updateConfigWithApp({
725
+ AppID: appJson.AppID,
726
+ AppUID: appJson.UID,
727
+ AppName: appJson.Name,
728
+ AppShortName: appJson.ShortName,
729
+ });
730
+ await saveCloneSource(activeSource || 'default');
731
+ log.dim(' Updated .dbo/config.json with app metadata');
732
+ }
733
+
734
+ // Detect and store ModifyKey for locked/production apps (skip in pull mode)
735
+ if (!options.pullMode) {
736
+ const modifyKey = appJson.ModifyKey || null;
737
+ await saveAppModifyKey(modifyKey);
738
+ if (modifyKey) {
739
+ log.warn('');
740
+ log.warn(' ⚠ This app has a ModifyKey set (production/locked mode).');
741
+ log.warn(' You will be prompted to enter the ModifyKey before any push, input, add, content deploy, or deploy command.');
742
+ log.warn('');
757
743
  }
758
744
  }
759
745
 
760
- // Step 3: Update package.json
761
- await updatePackageJson(appJson, config);
746
+ // Prompt for TransactionKeyPreset if not already set (skip in pull mode)
747
+ if (!options.pullMode) {
748
+ const existingPreset = await loadTransactionKeyPreset();
749
+ 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
+ }
767
+ }
768
+ }
762
769
 
763
- // Step 4: Create default project directories + bin structure
764
- for (const dir of DEFAULT_PROJECT_DIRS) {
765
- await mkdir(dir, { recursive: true });
770
+ // Step 3: Update package.json (skip in pull mode)
771
+ if (!options.pullMode) {
772
+ await updatePackageJson(appJson, config);
766
773
  }
767
774
 
775
+ // Step 4: Create default project directories + bin structure
768
776
  const bins = appJson.children.bin || [];
769
777
  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
778
+
779
+ if (!options.pullMode) {
780
+ for (const dir of DEFAULT_PROJECT_DIRS) {
781
+ await mkdir(dir, { recursive: true });
782
+ }
783
+
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
798
  }
799
+
800
+ // Best-effort: apply trash icon
801
+ await applyTrashIcon(join(process.cwd(), 'trash'));
802
+
803
+ const createdDirs = await createDirectories(structure);
804
+ await saveStructureFile(structure);
805
+
806
+ const totalDirs = DEFAULT_PROJECT_DIRS.length + mediaSubs.length + createdDirs.length;
807
+ 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}/`);
810
+ for (const d of createdDirs) log.dim(` ${d}/`);
811
+
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)) {
827
+ try {
828
+ 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
+ }
833
+ }
834
+ } else {
835
+ // Pull mode: reuse existing structure, just ensure dirs exist for new bins
836
+ await createDirectories(structure);
837
+ await saveStructureFile(structure);
799
838
  }
800
839
 
801
840
  // Step 4b: Determine placement preferences (from config or prompt)
@@ -816,9 +855,9 @@ export async function performClone(source, options = {}) {
816
855
  log.info(`Entity filter: only processing ${options.entity}`);
817
856
  }
818
857
 
819
- // Step 4c: Detect and resolve file path collisions (skip in entity-filter mode)
858
+ // Step 4c: Detect and resolve file path collisions (skip in pull mode and entity-filter mode)
820
859
  let toDeleteUIDs = new Set();
821
- if (!entityFilter) {
860
+ if (!options.pullMode && !entityFilter) {
822
861
  log.info('Scanning for file path collisions...');
823
862
  const fileRegistry = await buildFileRegistry(appJson, structure, placementPrefs);
824
863
  toDeleteUIDs = await resolveCollisions(fileRegistry, options);
@@ -841,6 +880,11 @@ export async function performClone(source, options = {}) {
841
880
  );
842
881
  }
843
882
 
883
+ // Step 5a: Write manifest.json to project root (from server content or resolved template)
884
+ if (!entityFilter || entityFilter.has('content')) {
885
+ await writeManifestJson(appJson, contentRefs);
886
+ }
887
+
844
888
  // Step 5b: Process media → download binary files + metadata (skip rejected records)
845
889
  let mediaRefs = [];
846
890
  if (!entityFilter || entityFilter.has('media')) {
@@ -900,18 +944,18 @@ export async function performClone(source, options = {}) {
900
944
  // Step 7: Save app.json with references
901
945
  await saveAppJson(appJson, contentRefs, otherRefs, effectiveDomain);
902
946
 
903
- // Step 8: Create .app.json baseline for delta tracking (skip in entity-filter mode to avoid overwriting)
947
+ // Step 8: Create .dbo/.app_baseline.json baseline for delta tracking (skip in entity-filter mode to avoid overwriting)
904
948
  if (!entityFilter) {
905
949
  await saveBaselineFile(appJson);
906
950
  }
907
951
 
908
- // Step 9: Ensure .app.json is in .gitignore
909
- await ensureGitignore(['.app.json']);
910
-
911
952
  log.plain('');
912
- log.success(entityFilter ? `Clone complete! (filtered: ${options.entity})` : 'Clone complete!');
953
+ const verb = options.pullMode ? 'Pull' : 'Clone';
954
+ log.success(entityFilter ? `${verb} complete! (filtered: ${options.entity})` : `${verb} complete!`);
913
955
  log.dim(' app.json saved to project root');
914
- log.dim(' Run "dbo login" to authenticate, then "dbo push" to deploy changes');
956
+ if (!options.pullMode) {
957
+ log.dim(' Run "dbo login" to authenticate, then "dbo push" to deploy changes');
958
+ }
915
959
  }
916
960
 
917
961
  /**
@@ -922,7 +966,7 @@ export async function performClone(source, options = {}) {
922
966
  * Entity-dir names (e.g. "extension", "site") are matched directly.
923
967
  * Documentation aliases are also accepted (e.g. "column" → "output_value").
924
968
  */
925
- function resolveEntityFilter(entityArg) {
969
+ export function resolveEntityFilter(entityArg) {
926
970
  if (!entityArg) return null;
927
971
 
928
972
  const input = entityArg.toLowerCase().trim();
@@ -947,6 +991,14 @@ async function resolvePlacementPreferences(appJson, options) {
947
991
  let contentPlacement = saved.contentPlacement;
948
992
  let mediaPlacement = saved.mediaPlacement;
949
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
+
950
1002
  // --media-placement flag takes precedence over saved config
951
1003
  if (options.mediaPlacement) {
952
1004
  mediaPlacement = options.mediaPlacement === 'fullpath' ? 'fullpath' : 'bin';
@@ -1015,6 +1067,8 @@ async function resolvePlacementPreferences(appJson, options) {
1015
1067
 
1016
1068
  /**
1017
1069
  * Fetch app JSON from the server by AppShortName.
1070
+ * Distinguishes between authentication failures (expired session) and
1071
+ * genuine "app not found" responses, offering re-login when appropriate.
1018
1072
  */
1019
1073
  async function fetchAppFromServer(appShortName, options, config) {
1020
1074
  const client = new DboClient({ domain: options.domain, verbose: options.verbose });
@@ -1030,6 +1084,58 @@ async function fetchAppFromServer(appShortName, options, config) {
1030
1084
  throw err;
1031
1085
  }
1032
1086
 
1087
+ // Check for authentication / session errors before parsing app data.
1088
+ // The server may return HTTP 401/403 or a 200 envelope with Successful=false
1089
+ // and messages containing user identity patterns.
1090
+ const AUTH_PATTERNS = ['LoggedInUser_UID', 'LoggedInUserID', 'CurrentUserID', 'UserID', 'not authenticated', 'session expired', 'login required'];
1091
+ const messages = result.messages || [];
1092
+ const allMsgText = messages.filter(m => typeof m === 'string').join(' ');
1093
+ const isAuthError = !result.ok && (result.status === 401 || result.status === 403)
1094
+ || (!result.successful && AUTH_PATTERNS.some(p => allMsgText.includes(p)));
1095
+
1096
+ if (isAuthError) {
1097
+ spinner.fail('Session expired or not authenticated');
1098
+ log.warn('Your session appears to have expired.');
1099
+ if (allMsgText) log.dim(` Server: ${allMsgText.substring(0, 200)}`);
1100
+
1101
+ // Offer re-login
1102
+ if (process.stdin.isTTY) {
1103
+ const inquirer = (await import('inquirer')).default;
1104
+ const { action } = await inquirer.prompt([{
1105
+ type: 'list',
1106
+ name: 'action',
1107
+ message: 'How would you like to proceed?',
1108
+ choices: [
1109
+ { name: 'Re-login now (recommended)', value: 'relogin' },
1110
+ { name: 'Abort', value: 'abort' },
1111
+ ],
1112
+ }]);
1113
+
1114
+ if (action === 'relogin') {
1115
+ const { performLogin } = await import('./login.js');
1116
+ await performLogin(options.domain || config.domain);
1117
+ log.info('Retrying app fetch...');
1118
+ return fetchAppFromServer(appShortName, options, config);
1119
+ }
1120
+ } else {
1121
+ log.dim(' Run "dbo login" to authenticate, then retry.');
1122
+ }
1123
+ throw new Error('Authentication required. Run "dbo login" first.');
1124
+ }
1125
+
1126
+ // Check for non-auth server errors (500, envelope Successful=false, etc.)
1127
+ if (!result.ok && result.status >= 500) {
1128
+ spinner.fail(`Server error (HTTP ${result.status})`);
1129
+ if (allMsgText) log.dim(` Server: ${allMsgText.substring(0, 200)}`);
1130
+ throw new Error(`Server error (HTTP ${result.status}) fetching app "${appShortName}"`);
1131
+ }
1132
+
1133
+ if (!result.successful && allMsgText) {
1134
+ spinner.fail(`Server returned an error`);
1135
+ log.warn(` ${allMsgText.substring(0, 300)}`);
1136
+ throw new Error(`Server error fetching app "${appShortName}": ${allMsgText.substring(0, 200)}`);
1137
+ }
1138
+
1033
1139
  const data = result.payload || result.data;
1034
1140
 
1035
1141
  // Handle all response shapes:
@@ -1100,6 +1206,21 @@ async function updatePackageJson(appJson, config) {
1100
1206
  changed = true;
1101
1207
  }
1102
1208
 
1209
+ // Add @dboio/cli to devDependencies with current CLI version
1210
+ if (!pkg.devDependencies || !pkg.devDependencies['@dboio/cli']) {
1211
+ try {
1212
+ const cliRoot = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
1213
+ const cliPkg = JSON.parse(await readFile(join(cliRoot, 'package.json'), 'utf8'));
1214
+ if (cliPkg.version) {
1215
+ if (!pkg.devDependencies) pkg.devDependencies = {};
1216
+ pkg.devDependencies['@dboio/cli'] = `^${cliPkg.version}`;
1217
+ changed = true;
1218
+ }
1219
+ } catch {
1220
+ // Could not read CLI version — skip
1221
+ }
1222
+ }
1223
+
1103
1224
  if (changed) {
1104
1225
  await writeFile(pkgPath, JSON.stringify(pkg, null, 2) + '\n');
1105
1226
  log.dim(' Updated package.json with app metadata');
@@ -1555,6 +1676,20 @@ async function buildDescriptorPrePass(extensionEntries, structure) {
1555
1676
  log.dim(` ${fullDir}/`);
1556
1677
  }
1557
1678
 
1679
+ // Create directories for descriptors not in the mapping but with a non-empty value
1680
+ const unmappedDescriptors = new Set();
1681
+ for (const rec of extensionEntries) {
1682
+ const d = rec.Descriptor;
1683
+ if (d && d !== 'descriptor_definition' && !mapping[d]) {
1684
+ unmappedDescriptors.add(d);
1685
+ }
1686
+ }
1687
+ for (const dirName of unmappedDescriptors) {
1688
+ const fullDir = `${EXTENSION_DESCRIPTORS_DIR}/${dirName}`;
1689
+ await mkdir(fullDir, { recursive: true });
1690
+ log.dim(` ${fullDir}/`);
1691
+ }
1692
+
1558
1693
  await saveDescriptorMapping(structure, mapping);
1559
1694
  log.dim(` Saved descriptorMapping to .dbo/structure.json`);
1560
1695
 
@@ -1961,6 +2096,42 @@ async function processMediaEntries(mediaRecords, structure, options, config, app
1961
2096
  // Track stale records (404s) for cleanup prompt
1962
2097
  const staleRecords = [];
1963
2098
 
2099
+ // Pre-scan: determine which media files actually need downloading
2100
+ // (new files or files with newer server timestamps)
2101
+ const needsDownload = [];
2102
+ const upToDateRefs = [];
2103
+
2104
+ for (const record of mediaRecords) {
2105
+ if (skipUIDs.has(record.UID)) continue;
2106
+
2107
+ const { metaPath: scanMetaPath } = resolveMediaPaths(record, structure, mediaPlacement === 'fullpath' ? 'path' : mediaPlacement === 'bin' ? 'bin' : null);
2108
+ const scanExists = await fileExists(scanMetaPath);
2109
+
2110
+ if (!scanExists) {
2111
+ // New file — always needs download
2112
+ needsDownload.push(record);
2113
+ } else if (options.force) {
2114
+ // Force mode — re-download everything
2115
+ needsDownload.push(record);
2116
+ } else {
2117
+ // Existing file — check if server is newer
2118
+ const configWithTz = { ...config, ServerTimezone: serverTz };
2119
+ const localSyncTime = await getLocalSyncTime(scanMetaPath);
2120
+ const serverNewer = isServerNewer(localSyncTime, record._LastUpdated, configWithTz);
2121
+ if (serverNewer) {
2122
+ needsDownload.push(record);
2123
+ } else {
2124
+ // Up to date — still need ref for app.json
2125
+ upToDateRefs.push({ uid: record.UID, metaPath: scanMetaPath });
2126
+ }
2127
+ }
2128
+ }
2129
+
2130
+ if (needsDownload.length === 0) {
2131
+ log.dim(` All ${mediaRecords.length} media file(s) up to date`);
2132
+ return upToDateRefs;
2133
+ }
2134
+
1964
2135
  // Determine if we can download (need a server connection)
1965
2136
  let canDownload = false;
1966
2137
  let client = null;
@@ -1970,7 +2141,7 @@ async function processMediaEntries(mediaRecords, structure, options, config, app
1970
2141
  const { download } = await inquirer.prompt([{
1971
2142
  type: 'confirm',
1972
2143
  name: 'download',
1973
- message: `${mediaRecords.length} media file(s) need to be downloaded from the server. Attempt download now?`,
2144
+ message: `${needsDownload.length} media file(s) need to be downloaded (${mediaRecords.length - needsDownload.length} up to date). Attempt download now?`,
1974
2145
  default: true,
1975
2146
  }]);
1976
2147
  canDownload = download;
@@ -1989,8 +2160,8 @@ async function processMediaEntries(mediaRecords, structure, options, config, app
1989
2160
  }
1990
2161
 
1991
2162
  if (!canDownload) {
1992
- log.warn(`Skipping ${mediaRecords.length} media file(s) — download not attempted`);
1993
- return [];
2163
+ log.warn(`Skipping ${needsDownload.length} media file(s) — download not attempted`);
2164
+ return upToDateRefs;
1994
2165
  }
1995
2166
 
1996
2167
  const refs = [];
@@ -2229,7 +2400,7 @@ async function processMediaEntries(mediaRecords, structure, options, config, app
2229
2400
  refs.push({ uid: record.UID, metaPath });
2230
2401
  }
2231
2402
 
2232
- log.info(`Media: ${downloaded} downloaded, ${failed} failed`);
2403
+ log.info(`Media: ${downloaded} downloaded, ${failed} failed, ${upToDateRefs.length} up to date`);
2233
2404
 
2234
2405
  // Prompt for stale record cleanup
2235
2406
  if (staleRecords.length > 0 && !options.yes) {
@@ -2265,7 +2436,7 @@ async function processMediaEntries(mediaRecords, structure, options, config, app
2265
2436
  log.info(`Non-interactive mode: skipping stale cleanup for ${staleRecords.length} record(s)`);
2266
2437
  }
2267
2438
 
2268
- return refs;
2439
+ return [...upToDateRefs, ...refs];
2269
2440
  }
2270
2441
 
2271
2442
  /**
@@ -2789,6 +2960,184 @@ export function buildOutputFilename(entityType, node, filenameCol, parentChain =
2789
2960
  return allSegments.join('.');
2790
2961
  }
2791
2962
 
2963
+ // ─── Inline Output Helpers ─────────────────────────────────────────────────
2964
+
2965
+ const INLINE_DOC_NAMES = { output_value: 'column', output_value_filter: 'filter', output_value_entity_column_rel: 'join' };
2966
+ const INLINE_DOC_TO_PHYSICAL = { column: 'output_value', join: 'output_value_entity_column_rel', filter: 'output_value_filter' };
2967
+ const INLINE_DOC_KEYS = ['column', 'join', 'filter'];
2968
+
2969
+ /**
2970
+ * 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"
2973
+ *
2974
+ * @param {string} rootStem - Root output file stem (no extension)
2975
+ * @param {string} physicalEntity - Physical entity name ('output_value', etc.)
2976
+ * @param {string} uid - Child entity UID
2977
+ * @param {string} [parentChainStem] - Already-built ancestor stem (for nested children)
2978
+ * @returns {string}
2979
+ */
2980
+ export function getChildCompanionStem(rootStem, physicalEntity, uid, parentChainStem = rootStem) {
2981
+ const docName = INLINE_DOC_NAMES[physicalEntity] || physicalEntity;
2982
+ return `${parentChainStem}.${docName}~${uid}`;
2983
+ }
2984
+
2985
+ /**
2986
+ * Extract CustomSQL as a companion .sql file if rules require it.
2987
+ * Rules:
2988
+ * 1. Type === 'CustomSQL' → always extract (even empty)
2989
+ * 2. Type !== 'CustomSQL' AND CustomSQL non-empty decoded value → extract
2990
+ * 3. Otherwise → store "" inline; no file
2991
+ *
2992
+ * Mutates entityObj.CustomSQL to the @basename reference when extracted.
2993
+ * Returns the companion filename (without directory) if written, else null.
2994
+ *
2995
+ * @param {Object} entityObj - The entity object (mutated in place)
2996
+ * @param {string} companionStem - Stem for the companion file (no extension)
2997
+ * @param {string} outputDir - Directory where the root output JSON lives
2998
+ * @param {string} serverTz - Server timezone for timestamp syncing
2999
+ * @returns {Promise<string|null>} - Companion filename or null
3000
+ */
3001
+ async function extractCustomSqlIfNeeded(entityObj, companionStem, outputDir, serverTz) {
3002
+ const rawSql = entityObj.CustomSQL;
3003
+ const isCustomSqlType = entityObj.Type === 'CustomSQL';
3004
+
3005
+ // Decode base64 server value if needed
3006
+ const decoded = resolveContentValue(rawSql) ?? '';
3007
+ const hasContent = typeof decoded === 'string' && decoded.trim().length > 0;
3008
+
3009
+ if (!isCustomSqlType && !hasContent) {
3010
+ // Rule 3: store empty string inline, no file
3011
+ entityObj.CustomSQL = '';
3012
+ return null;
3013
+ }
3014
+
3015
+ // Rules 1 and 2: extract as companion .sql file
3016
+ const companionName = `${companionStem}.CustomSQL.sql`;
3017
+ const companionPath = join(outputDir, companionName);
3018
+ await writeFile(companionPath, hasContent ? decoded : '', 'utf8');
3019
+ entityObj.CustomSQL = `@${companionName}`;
3020
+ entityObj._contentColumns = entityObj._contentColumns || [];
3021
+ if (!entityObj._contentColumns.includes('CustomSQL')) {
3022
+ entityObj._contentColumns.push('CustomSQL');
3023
+ }
3024
+
3025
+ // Sync timestamps
3026
+ if (serverTz && (entityObj._CreatedOn || entityObj._LastUpdated)) {
3027
+ try { await setFileTimestamps(companionPath, entityObj._CreatedOn, entityObj._LastUpdated, serverTz); } catch { /* non-critical */ }
3028
+ }
3029
+
3030
+ log.dim(` → ${companionPath}`);
3031
+ return companionName;
3032
+ }
3033
+
3034
+ /**
3035
+ * Recursively build a children object for a parent entity.
3036
+ * Mutates parentObj to set parentObj.children = { column: [], join: [], filter: [] }.
3037
+ * Returns companionFiles: string[] of written companion file basenames.
3038
+ *
3039
+ * Each child object retains _entity set to the physical entity name
3040
+ * (output_value, output_value_entity_column_rel, output_value_filter)
3041
+ * so that push can route submissions correctly.
3042
+ *
3043
+ * @param {Object} parentObj - The entity object to populate (mutated in place)
3044
+ * @param {Object} node - Tree node from buildOutputHierarchyTree (has _children)
3045
+ * @param {string} rootStem - Root output file stem (e.g. "_output~Sales~abc")
3046
+ * @param {string} outputDir - Directory where root output JSON lives
3047
+ * @param {string} serverTz - Server timezone
3048
+ * @param {string} [parentStem] - Ancestor stem for compound companion naming
3049
+ * @returns {Promise<string[]>} - Array of written companion file basenames
3050
+ */
3051
+ async function buildInlineOutputChildren(parentObj, node, rootStem, outputDir, serverTz, parentStem = rootStem) {
3052
+ const companionFiles = [];
3053
+ const nodeChildren = node._children || {};
3054
+
3055
+ // Always create children object with all three doc keys
3056
+ parentObj.children = { column: [], join: [], filter: [] };
3057
+
3058
+ for (const docKey of INLINE_DOC_KEYS) {
3059
+ const entityArray = nodeChildren[docKey];
3060
+ const physicalKey = INLINE_DOC_TO_PHYSICAL[docKey];
3061
+
3062
+ if (!Array.isArray(entityArray) || entityArray.length === 0) continue;
3063
+
3064
+ for (const child of entityArray) {
3065
+ // Build a clean copy without tree-internal fields
3066
+ const childObj = { ...child };
3067
+ delete childObj._children;
3068
+
3069
+ // Decode any base64 values
3070
+ for (const [key, value] of Object.entries(childObj)) {
3071
+ if (key === 'CustomSQL') continue; // handled by extractCustomSqlIfNeeded
3072
+ if (value && typeof value === 'object' && !Array.isArray(value) && value.encoding === 'base64') {
3073
+ childObj[key] = resolveContentValue(value);
3074
+ }
3075
+ }
3076
+
3077
+ // Ensure _entity is set to physical entity name (for push routing)
3078
+ childObj._entity = physicalKey;
3079
+
3080
+ // Compute companion stem for this child
3081
+ const childStem = getChildCompanionStem(rootStem, physicalKey, child.UID, parentStem);
3082
+
3083
+ // Extract CustomSQL if needed
3084
+ const companionFile = await extractCustomSqlIfNeeded(childObj, childStem, outputDir, serverTz);
3085
+ if (companionFile) companionFiles.push(companionFile);
3086
+
3087
+ // Recurse into child's _children (e.g. join→column, column→filter)
3088
+ if (child._children && Object.keys(child._children).some(k => child._children[k]?.length > 0)) {
3089
+ const gcFiles = await buildInlineOutputChildren(childObj, child, rootStem, outputDir, serverTz, childStem);
3090
+ companionFiles.push(...gcFiles);
3091
+ } else {
3092
+ // Leaf node: still set empty children
3093
+ childObj.children = { column: [], join: [], filter: [] };
3094
+ }
3095
+
3096
+ parentObj.children[docKey].push(childObj);
3097
+ }
3098
+ }
3099
+
3100
+ return companionFiles;
3101
+ }
3102
+
3103
+ /**
3104
+ * 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)
3106
+ *
3107
+ * @param {string} outputDir - Directory containing output files
3108
+ * @param {string} rootStem - Root output file stem (e.g. "_output~Sales~abc")
3109
+ */
3110
+ async function trashOrphanedChildFiles(outputDir, rootStem) {
3111
+ let files;
3112
+ try { files = await readdir(outputDir); } catch { return; }
3113
+
3114
+ const trashDir = join(process.cwd(), 'trash');
3115
+ let trashCreated = false;
3116
+
3117
+ 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 */ }
3129
+ }
3130
+ }
3131
+ }
3132
+
3133
+ // Re-apply trash icon if files were moved (self-heals after user clears trash)
3134
+ if (trashCreated) {
3135
+ await ensureTrashIcon(trashDir);
3136
+ }
3137
+ }
3138
+
3139
+ // ─── Filename Parsing ──────────────────────────────────────────────────────
3140
+
2792
3141
  /**
2793
3142
  * Parse an output hierarchy filename back into entity relationships.
2794
3143
  *
@@ -2882,7 +3231,11 @@ export function parseOutputHierarchyFile(filename) {
2882
3231
 
2883
3232
  /**
2884
3233
  * Main orchestrator: process output hierarchy entities during clone.
2885
- * Builds tree, resolves filenames, writes hierarchy files.
3234
+ * Builds tree, resolves filenames, writes single-file inline format.
3235
+ *
3236
+ * Each root output produces exactly one .json file with all children
3237
+ * embedded inline under children: { column: [], join: [], filter: [] }.
3238
+ * Companion .sql files are extracted per CustomSQL rules.
2886
3239
  *
2887
3240
  * @param {Object} appJson - The full app JSON
2888
3241
  * @param {Object} structure - Bin hierarchy structure
@@ -2946,8 +3299,29 @@ async function processOutputHierarchy(appJson, structure, options, serverTz) {
2946
3299
  const rootBasename = buildOutputFilename('output', output, filenameCols.output);
2947
3300
  const rootMetaPath = join(binDir, `${rootBasename}.json`);
2948
3301
 
3302
+ // Detect old-format files that need migration to inline children format.
3303
+ // Old format: children.column/join/filter contain @reference strings to separate files.
3304
+ // New format: children contain inline entity objects directly.
3305
+ let needsFormatMigration = false;
3306
+ if (await fileExists(rootMetaPath)) {
3307
+ try {
3308
+ const existingMeta = JSON.parse(await readFile(rootMetaPath, 'utf8'));
3309
+ if (existingMeta.children) {
3310
+ const allRefs = [
3311
+ ...(existingMeta.children.column || []),
3312
+ ...(existingMeta.children.join || []),
3313
+ ...(existingMeta.children.filter || []),
3314
+ ];
3315
+ needsFormatMigration = allRefs.some(ref => typeof ref === 'string' && ref.startsWith('@'));
3316
+ }
3317
+ } catch { /* read error — will be overwritten */ }
3318
+ if (needsFormatMigration) {
3319
+ log.info(` Migrating ${rootBasename} to inline children format...`);
3320
+ }
3321
+ }
3322
+
2949
3323
  // Change detection for existing files (skip when --entity forces re-processing)
2950
- if (await fileExists(rootMetaPath) && !options.yes && !forceReprocess) {
3324
+ if (await fileExists(rootMetaPath) && !options.yes && !forceReprocess && !needsFormatMigration) {
2951
3325
  if (bulkAction.value === 'skip_all') {
2952
3326
  log.dim(` Skipped ${rootBasename}`);
2953
3327
  refs.push({ uid: output.UID, metaPath: rootMetaPath });
@@ -2980,94 +3354,11 @@ async function processOutputHierarchy(appJson, structure, options, serverTz) {
2980
3354
  }
2981
3355
  }
2982
3356
 
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
3357
+ // Build clean root object (strip tree-internal fields)
3046
3358
  const rootMeta = {};
3047
- const rootContentColumns = [];
3048
3359
  for (const [key, value] of Object.entries(output)) {
3049
3360
  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
- }
3361
+ if (key === 'CustomSQL') continue; // handled by extractCustomSqlIfNeeded below
3071
3362
 
3072
3363
  if (value && typeof value === 'object' && !Array.isArray(value) && value.encoding === 'base64') {
3073
3364
  rootMeta[key] = resolveContentValue(value);
@@ -3076,10 +3367,16 @@ async function processOutputHierarchy(appJson, structure, options, serverTz) {
3076
3367
  }
3077
3368
  }
3078
3369
  rootMeta._entity = 'output';
3079
- if (rootContentColumns.length > 0) {
3080
- rootMeta._contentColumns = rootContentColumns;
3081
- }
3082
- rootMeta.children = childRefs;
3370
+
3371
+ // Copy raw CustomSQL for extraction helper
3372
+ rootMeta.CustomSQL = output.CustomSQL;
3373
+
3374
+ // Extract CustomSQL on root (rules 1/2/3)
3375
+ await extractCustomSqlIfNeeded(rootMeta, rootBasename, binDir, serverTz);
3376
+
3377
+ // Embed all children under rootMeta.children = { column, join, filter }
3378
+ await buildInlineOutputChildren(rootMeta, output, rootBasename, binDir, serverTz);
3379
+ // rootMeta now has .children = { column: [...], join: [...], filter: [...] }
3083
3380
 
3084
3381
  // If user chose a bin for a BinID-less output, store it and mark as modified
3085
3382
  if (chosenBinId) {
@@ -3090,6 +3387,9 @@ async function processOutputHierarchy(appJson, structure, options, serverTz) {
3090
3387
  await writeFile(rootMetaPath, JSON.stringify(rootMeta, null, 2) + '\n');
3091
3388
  log.success(`Saved ${rootMetaPath}`);
3092
3389
 
3390
+ // Move orphaned old-format child .json files to /trash
3391
+ await trashOrphanedChildFiles(binDir, rootBasename);
3392
+
3093
3393
  // Set file timestamps to server's _LastUpdated so diff detection works.
3094
3394
  // Skip when chosenBinId is set — keep mtime at "now" so push detects the local edit.
3095
3395
  if (!chosenBinId && serverTz && (output._CreatedOn || output._LastUpdated)) {
@@ -3105,61 +3405,79 @@ async function processOutputHierarchy(appJson, structure, options, serverTz) {
3105
3405
  }
3106
3406
 
3107
3407
  /**
3108
- * Write a single output hierarchy entity file (column, join, or filter).
3109
- * Handles CustomSQL extraction to companion .sql files.
3408
+ * Write manifest.json to project root.
3409
+ * If a manifest content record was cloned from the server, use its Content value.
3410
+ * Otherwise, generate from appJson values (empty strings for missing fields).
3110
3411
  */
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 */ }
3412
+ async function writeManifestJson(appJson, contentRefs) {
3413
+ // 1. Search contentRefs for a manifest content record
3414
+ for (const ref of contentRefs) {
3415
+ let meta;
3416
+ try {
3417
+ meta = JSON.parse(await readFile(ref.metaPath, 'utf8'));
3418
+ } catch { continue; }
3419
+
3420
+ const name = (meta.Name || '').toLowerCase();
3421
+ const ext = (meta.Extension || '').toLowerCase();
3422
+ if (name.startsWith('manifest') && ext === 'json') {
3423
+ // Found manifest read content file and write to project root
3424
+ const contentRef = meta.Content;
3425
+ if (contentRef && String(contentRef).startsWith('@')) {
3426
+ const refFile = String(contentRef).substring(1);
3427
+ const contentPath = refFile.startsWith('/')
3428
+ ? join(process.cwd(), refFile)
3429
+ : join(dirname(ref.metaPath), refFile);
3430
+ try {
3431
+ const content = await readFile(contentPath, 'utf8');
3432
+ await writeFile('manifest.json', content);
3433
+ log.dim(' manifest.json written to project root (from server content)');
3434
+ } catch (err) {
3435
+ log.warn(` Could not write manifest.json from server content: ${err.message}`);
3133
3436
  }
3134
- log.dim(` → ${sqlFilePath}`);
3135
- continue;
3136
3437
  }
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;
3438
+ return;
3147
3439
  }
3148
3440
  }
3149
3441
 
3150
- meta._entity = physicalEntity;
3151
- if (contentColumns.length > 0) {
3152
- meta._contentColumns = contentColumns;
3442
+ // 2. No manifest content record — generate from appJson values
3443
+ const shortName = appJson.ShortName || '';
3444
+ const appName = appJson.Name || '';
3445
+ const description = appJson.Description || '';
3446
+
3447
+ // Find background_color from extension children (widget descriptor matching ShortName)
3448
+ let bgColor = '#ffffff';
3449
+ if (shortName) {
3450
+ const extensions = appJson.children?.extension || [];
3451
+ for (const ext of extensions) {
3452
+ const descriptor = resolveContentValue(ext.Descriptor) || ext.Descriptor;
3453
+ const string1 = resolveContentValue(ext.String1) || ext.String1;
3454
+ if (descriptor === 'widget' && string1 === shortName) {
3455
+ bgColor = resolveContentValue(ext.String4) || ext.String4 || bgColor;
3456
+ break;
3457
+ }
3458
+ }
3153
3459
  }
3154
3460
 
3155
- await writeFile(filePath, JSON.stringify(meta, null, 2) + '\n');
3156
- log.dim(` → ${filePath}`);
3461
+ const manifest = {
3462
+ name: appName,
3463
+ short_name: shortName,
3464
+ description,
3465
+ orientation: 'portrait',
3466
+ start_url: shortName ? `/app/${shortName}/ui/` : '',
3467
+ lang: 'en',
3468
+ scope: shortName ? `/app/${shortName}/ui/` : '',
3469
+ display_override: ['window-control-overlay', 'minimal-ui'],
3470
+ display: 'standalone',
3471
+ background_color: bgColor,
3472
+ theme_color: '#000000',
3473
+ id: shortName,
3474
+ screenshots: [],
3475
+ ios: {},
3476
+ icons: [],
3477
+ };
3157
3478
 
3158
- if (serverTz && (node._CreatedOn || node._LastUpdated)) {
3159
- try {
3160
- await setFileTimestamps(filePath, node._CreatedOn, node._LastUpdated, serverTz);
3161
- } catch { /* non-critical */ }
3162
- }
3479
+ await writeFile('manifest.json', JSON.stringify(manifest, null, 2) + '\n');
3480
+ log.dim(' manifest.json generated at project root (from app.json values)');
3163
3481
  }
3164
3482
 
3165
3483
  /**
@@ -3205,7 +3523,7 @@ async function saveAppJson(appJson, contentRefs, otherRefs, domain) {
3205
3523
  }
3206
3524
 
3207
3525
  /**
3208
- * Save .app.json baseline file with decoded base64 values.
3526
+ * Save .dbo/.app_baseline.json baseline file with decoded base64 values.
3209
3527
  * This file tracks the server state for delta detection.
3210
3528
  */
3211
3529
  async function saveBaselineFile(appJson) {
@@ -3218,14 +3536,14 @@ async function saveBaselineFile(appJson) {
3218
3536
  // Save to .app.json
3219
3537
  await saveAppJsonBaseline(baseline);
3220
3538
 
3221
- log.dim(' .app.json baseline created (system-managed, do not edit)');
3539
+ log.dim(' .dbo/.app_baseline.json baseline created (system-managed, do not edit)');
3222
3540
  }
3223
3541
 
3224
3542
  /**
3225
3543
  * Recursively decode base64 fields in an object or array.
3226
3544
  * Modifies the input object in-place.
3227
3545
  */
3228
- function decodeBase64Fields(obj) {
3546
+ export function decodeBase64Fields(obj) {
3229
3547
  if (!obj || typeof obj !== 'object') {
3230
3548
  return;
3231
3549
  }