@dboio/cli 0.6.14 → 0.7.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,8 +2,8 @@ import { Command } from 'commander';
2
2
  import { readFile, writeFile, mkdir, access } from 'fs/promises';
3
3
  import { join, basename, extname } from 'path';
4
4
  import { DboClient } from '../lib/client.js';
5
- import { loadConfig, updateConfigWithApp, loadClonePlacement, saveClonePlacement, ensureGitignore, saveEntityDirPreference, loadEntityDirPreference, saveEntityContentExtractions, loadEntityContentExtractions, saveAppJsonBaseline, addDeleteEntry, loadCollisionResolutions, saveCollisionResolutions, loadSynchronize, saveAppModifyKey, loadTransactionKeyPreset, saveTransactionKeyPreset } from '../lib/config.js';
6
- import { buildBinHierarchy, resolveBinPath, createDirectories, saveStructureFile, getBinName, findBinByPath, BINS_DIR, DEFAULT_PROJECT_DIRS, ENTITY_DIR_MAP } from '../lib/structure.js';
5
+ import { loadConfig, updateConfigWithApp, loadClonePlacement, saveClonePlacement, ensureGitignore, saveEntityDirPreference, loadEntityDirPreference, saveEntityContentExtractions, loadEntityContentExtractions, saveAppJsonBaseline, addDeleteEntry, loadCollisionResolutions, saveCollisionResolutions, loadSynchronize, saveAppModifyKey, loadTransactionKeyPreset, saveTransactionKeyPreset, loadOutputFilenamePreference, saveOutputFilenamePreference } from '../lib/config.js';
6
+ import { buildBinHierarchy, resolveBinPath, createDirectories, saveStructureFile, getBinName, findBinByPath, BINS_DIR, DEFAULT_PROJECT_DIRS, ENTITY_DIR_MAP, OUTPUT_ENTITY_MAP, OUTPUT_HIERARCHY_ENTITIES } from '../lib/structure.js';
7
7
  import { log } from '../lib/logger.js';
8
8
  import { setFileTimestamps } from '../lib/timestamps.js';
9
9
  import { getLocalSyncTime, isServerNewer, hasLocalModifications, promptChangeDetection, inlineDiffAndMerge } from '../lib/diff.js';
@@ -421,6 +421,8 @@ export const cloneCommand = new Command('clone')
421
421
  .description('Clone an app from DBO.io to a local project structure')
422
422
  .argument('[source]', 'Local JSON file path (optional)')
423
423
  .option('--app <shortName>', 'App short name to fetch from server')
424
+ .option('-e, --entity <type>', 'Only clone a specific entity type (e.g. output, content, media, extension)')
425
+ .option('--force', 'Force re-processing of all files, skip change detection')
424
426
  .option('--domain <host>', 'Override domain')
425
427
  .option('-y, --yes', 'Auto-accept all prompts')
426
428
  .option('-v, --verbose', 'Show HTTP request details')
@@ -573,38 +575,68 @@ export async function performClone(source, options = {}) {
573
575
  log.dim(` Set ServerTimezone to ${serverTz} in .dbo/config.json`);
574
576
  }
575
577
 
576
- // Step 4c: Detect and resolve file path collisions
577
- log.info('Scanning for file path collisions...');
578
- const fileRegistry = await buildFileRegistry(appJson, structure, placementPrefs);
579
- const toDeleteUIDs = await resolveCollisions(fileRegistry, options);
578
+ // Resolve --entity filter: which entity types to process
579
+ // "output" expands to all OUTPUT_HIERARCHY_ENTITIES, others are matched directly
580
+ const entityFilter = resolveEntityFilter(options.entity);
581
+ if (entityFilter) {
582
+ log.info(`Entity filter: only processing ${options.entity}`);
583
+ }
584
+
585
+ // Step 4c: Detect and resolve file path collisions (skip in entity-filter mode)
586
+ let toDeleteUIDs = new Set();
587
+ if (!entityFilter) {
588
+ log.info('Scanning for file path collisions...');
589
+ const fileRegistry = await buildFileRegistry(appJson, structure, placementPrefs);
590
+ toDeleteUIDs = await resolveCollisions(fileRegistry, options);
580
591
 
581
- if (toDeleteUIDs.size > 0) {
582
- await stageCollisionDeletions(toDeleteUIDs, appJson, options);
592
+ if (toDeleteUIDs.size > 0) {
593
+ await stageCollisionDeletions(toDeleteUIDs, appJson, options);
594
+ }
583
595
  }
584
596
 
585
597
  // Step 5: Process content → files + metadata (skip rejected records)
586
- const contentRefs = await processContentEntries(
587
- appJson.children.content || [],
588
- structure,
589
- options,
590
- placementPrefs.contentPlacement,
591
- serverTz,
592
- toDeleteUIDs,
593
- );
598
+ let contentRefs = [];
599
+ if (!entityFilter || entityFilter.has('content')) {
600
+ contentRefs = await processContentEntries(
601
+ appJson.children.content || [],
602
+ structure,
603
+ options,
604
+ placementPrefs.contentPlacement,
605
+ serverTz,
606
+ toDeleteUIDs,
607
+ );
608
+ }
594
609
 
595
610
  // Step 5b: Process media → download binary files + metadata (skip rejected records)
596
611
  let mediaRefs = [];
597
- const mediaEntries = appJson.children.media || [];
598
- if (mediaEntries.length > 0) {
599
- mediaRefs = await processMediaEntries(mediaEntries, structure, options, config, appJson.ShortName, placementPrefs.mediaPlacement, serverTz, toDeleteUIDs);
612
+ if (!entityFilter || entityFilter.has('media')) {
613
+ const mediaEntries = appJson.children.media || [];
614
+ if (mediaEntries.length > 0) {
615
+ mediaRefs = await processMediaEntries(mediaEntries, structure, options, config, appJson.ShortName, placementPrefs.mediaPlacement, serverTz, toDeleteUIDs);
616
+ }
600
617
  }
601
618
 
602
- // Step 6: Process other entities (not output, not bin, not content, not media)
619
+ // Step 5c: Process output hierarchy
603
620
  const otherRefs = {};
621
+ if (!entityFilter || entityFilter.has('output')) {
622
+ const outputEntries = appJson.children.output || [];
623
+ if (outputEntries.length > 0) {
624
+ const outputRefs = await processOutputHierarchy(appJson, structure, options, serverTz);
625
+ if (outputRefs.length > 0) {
626
+ otherRefs.output = outputRefs;
627
+ }
628
+ }
629
+ }
630
+
631
+ // Step 6: Process other entities (not output hierarchy, not bin, not content, not media)
604
632
  for (const [entityName, entries] of Object.entries(appJson.children)) {
605
- if (['bin', 'content', 'output', 'media'].includes(entityName)) continue;
633
+ if (['bin', 'content', 'output', 'output_value', 'output_value_filter',
634
+ 'output_value_entity_column_rel', 'media'].includes(entityName)) continue;
606
635
  if (!Array.isArray(entries)) continue;
607
636
 
637
+ // Skip if entity filter is active and this entity doesn't match
638
+ if (entityFilter && !entityFilter.has(entityName)) continue;
639
+
608
640
  if (ENTITY_DIR_MAP[entityName]) {
609
641
  // Entity types with project directories — process into their directory
610
642
  const refs = await processEntityDirEntries(entityName, entries, options, serverTz);
@@ -628,18 +660,43 @@ export async function performClone(source, options = {}) {
628
660
  // Step 7: Save app.json with references
629
661
  await saveAppJson(appJson, contentRefs, otherRefs, effectiveDomain);
630
662
 
631
- // Step 8: Create .app.json baseline for delta tracking
632
- await saveBaselineFile(appJson);
663
+ // Step 8: Create .app.json baseline for delta tracking (skip in entity-filter mode to avoid overwriting)
664
+ if (!entityFilter) {
665
+ await saveBaselineFile(appJson);
666
+ }
633
667
 
634
668
  // Step 9: Ensure .app.json is in .gitignore
635
669
  await ensureGitignore(['.app.json']);
636
670
 
637
671
  log.plain('');
638
- log.success('Clone complete!');
672
+ log.success(entityFilter ? `Clone complete! (filtered: ${options.entity})` : 'Clone complete!');
639
673
  log.dim(' app.json saved to project root');
640
674
  log.dim(' Run "dbo login" to authenticate, then "dbo push" to deploy changes');
641
675
  }
642
676
 
677
+ /**
678
+ * Resolve --entity filter into a Set of entity keys to process.
679
+ * Returns null if no filter (process everything), or a Set of entity key strings.
680
+ *
681
+ * "output" expands to all OUTPUT_HIERARCHY_ENTITIES.
682
+ * Entity-dir names (e.g. "extension", "site") are matched directly.
683
+ * Documentation aliases are also accepted (e.g. "column" → "output_value").
684
+ */
685
+ function resolveEntityFilter(entityArg) {
686
+ if (!entityArg) return null;
687
+
688
+ const input = entityArg.toLowerCase().trim();
689
+
690
+ // "output" or any output sub-entity → process all output hierarchy entities
691
+ const docToPhysical = { column: 'output_value', filter: 'output_value_filter', join: 'output_value_entity_column_rel' };
692
+ if (input === 'output' || OUTPUT_HIERARCHY_ENTITIES.includes(input) || docToPhysical[input]) {
693
+ return new Set(OUTPUT_HIERARCHY_ENTITIES.concat(['output']));
694
+ }
695
+
696
+ // Direct match on known entity types
697
+ return new Set([input]);
698
+ }
699
+
643
700
  /**
644
701
  * Resolve placement preferences from config or prompt the user.
645
702
  * Returns { contentPlacement, mediaPlacement } where values are 'path'|'bin'|'ask'|null
@@ -1700,6 +1757,568 @@ export function guessExtensionForColumn(columnName) {
1700
1757
  return 'txt';
1701
1758
  }
1702
1759
 
1760
+ // ─── Output Hierarchy Processing ──────────────────────────────────────────
1761
+
1762
+ /**
1763
+ * Build a tree structure from flat output entity arrays.
1764
+ * Groups columns, joins, and filters under their parent output records.
1765
+ *
1766
+ * @param {Object} appJson - The full app JSON with children arrays
1767
+ * @returns {Array} - Array of output root nodes with nested _children
1768
+ */
1769
+ export function buildOutputHierarchyTree(appJson) {
1770
+ const outputs = appJson.children.output || [];
1771
+ const columns = appJson.children.output_value || [];
1772
+ const filters = appJson.children.output_value_filter || [];
1773
+ const joins = appJson.children.output_value_entity_column_rel || [];
1774
+
1775
+ if (outputs.length === 0) return [];
1776
+
1777
+ // Index all entities by their numeric ID for O(1) lookups
1778
+ const outputById = new Map();
1779
+ const columnById = new Map();
1780
+ const joinById = new Map();
1781
+
1782
+ for (const o of outputs) {
1783
+ outputById.set(o.OutputID || o._id, { ...o, _children: { column: [], join: [], filter: [] } });
1784
+ }
1785
+ for (const c of columns) {
1786
+ columnById.set(c.OutputValueID || c._id, { ...c, _children: { filter: [] } });
1787
+ }
1788
+ for (const j of joins) {
1789
+ joinById.set(j.OutputValueEntityColumnRelID || j._id, { ...j, _children: { column: [] } });
1790
+ }
1791
+
1792
+ // Attach filters: to column (via OutputValueID) or directly to output (via OutputID)
1793
+ for (const f of filters) {
1794
+ if (f.OutputValueID) {
1795
+ const parent = columnById.get(f.OutputValueID);
1796
+ if (parent) {
1797
+ parent._children.filter.push({ ...f });
1798
+ continue;
1799
+ }
1800
+ }
1801
+ if (f.OutputID) {
1802
+ const parent = outputById.get(f.OutputID);
1803
+ if (parent) {
1804
+ parent._children.filter.push({ ...f });
1805
+ }
1806
+ }
1807
+ }
1808
+
1809
+ // Attach columns: to join (via OutputValueEntityColumnRelID) or directly to output (via OutputID)
1810
+ for (const [, col] of columnById) {
1811
+ if (col.OutputValueEntityColumnRelID) {
1812
+ const parent = joinById.get(col.OutputValueEntityColumnRelID);
1813
+ if (parent) {
1814
+ parent._children.column.push(col);
1815
+ continue;
1816
+ }
1817
+ }
1818
+ if (col.OutputID) {
1819
+ const parent = outputById.get(col.OutputID);
1820
+ if (parent) {
1821
+ parent._children.column.push(col);
1822
+ }
1823
+ }
1824
+ }
1825
+
1826
+ // Attach joins to outputs (via OutputID)
1827
+ for (const [, j] of joinById) {
1828
+ if (j.OutputID) {
1829
+ const parent = outputById.get(j.OutputID);
1830
+ if (parent) {
1831
+ parent._children.join.push(j);
1832
+ }
1833
+ }
1834
+ }
1835
+
1836
+ // Sort children by OrderNumber (ties broken by array index stability)
1837
+ const sortByOrder = (a, b) => (a.OrderNumber || 0) - (b.OrderNumber || 0);
1838
+
1839
+ for (const [, output] of outputById) {
1840
+ output._children.column.sort(sortByOrder);
1841
+ output._children.join.sort(sortByOrder);
1842
+ output._children.filter.sort(sortByOrder);
1843
+
1844
+ for (const col of output._children.column) {
1845
+ col._children.filter.sort(sortByOrder);
1846
+ }
1847
+ for (const j of output._children.join) {
1848
+ j._children.column.sort(sortByOrder);
1849
+ for (const col of j._children.column) {
1850
+ col._children.filter.sort(sortByOrder);
1851
+ }
1852
+ }
1853
+ }
1854
+
1855
+ return Array.from(outputById.values());
1856
+ }
1857
+
1858
+ /**
1859
+ * Resolve filename column preferences for each output entity type.
1860
+ * Loads from config, prompts if needed, saves choices.
1861
+ *
1862
+ * @param {Object} appJson - The full app JSON
1863
+ * @param {Object} options - CLI options
1864
+ * @returns {Object} - { output: 'Name', output_value: 'Title', ... }
1865
+ */
1866
+ async function resolveOutputFilenameColumns(appJson, options) {
1867
+ const defaults = {
1868
+ output: 'Name',
1869
+ output_value: 'Title',
1870
+ output_value_filter: 'ShortName',
1871
+ output_value_entity_column_rel: 'UID',
1872
+ };
1873
+ const fallbacks = {
1874
+ output: ['Name', 'Title', 'OrderNumber', 'UID'],
1875
+ output_value: ['Title', 'Name', 'OrderNumber', 'UID'],
1876
+ output_value_filter: ['ShortName', 'Name', 'UID'],
1877
+ output_value_entity_column_rel: ['UID'],
1878
+ };
1879
+ const docNames = OUTPUT_ENTITY_MAP;
1880
+ const result = {};
1881
+
1882
+ for (const entityKey of OUTPUT_HIERARCHY_ENTITIES) {
1883
+ // Check saved preference
1884
+ const saved = await loadOutputFilenamePreference(entityKey);
1885
+ if (saved) {
1886
+ result[entityKey] = saved;
1887
+ continue;
1888
+ }
1889
+
1890
+ // In -y mode use defaults
1891
+ if (options.yes) {
1892
+ result[entityKey] = defaults[entityKey];
1893
+ await saveOutputFilenamePreference(entityKey, defaults[entityKey]);
1894
+ continue;
1895
+ }
1896
+
1897
+ // Find a sample record to get available columns
1898
+ const records = appJson.children[entityKey] || [];
1899
+ if (records.length === 0) {
1900
+ result[entityKey] = defaults[entityKey];
1901
+ continue;
1902
+ }
1903
+
1904
+ const sampleRecord = records[0];
1905
+ const columns = Object.keys(sampleRecord).filter(k => k !== 'children' && !k.startsWith('_'));
1906
+
1907
+ // Find best default from fallback chain
1908
+ let defaultCol = defaults[entityKey];
1909
+ for (const fb of fallbacks[entityKey]) {
1910
+ if (columns.includes(fb)) { defaultCol = fb; break; }
1911
+ }
1912
+
1913
+ const inquirer = (await import('inquirer')).default;
1914
+ const { col } = await inquirer.prompt([{
1915
+ type: 'list',
1916
+ name: 'col',
1917
+ message: `Which column should be used as the filename for ${docNames[entityKey]} (${entityKey}) records?`,
1918
+ choices: columns,
1919
+ default: defaultCol,
1920
+ }]);
1921
+
1922
+ result[entityKey] = col;
1923
+ await saveOutputFilenamePreference(entityKey, col);
1924
+ log.dim(` Saved filename column preference for ${entityKey}`);
1925
+ }
1926
+
1927
+ return result;
1928
+ }
1929
+
1930
+ /**
1931
+ * Build a filename for an output hierarchy entity.
1932
+ * Uses dot-separated hierarchical naming: _output~<name>~<uid>.column~<name>~<uid>.json
1933
+ *
1934
+ * @param {string} entityType - Documentation name: 'output', 'column', 'join', 'filter'
1935
+ * @param {Object} node - The entity record
1936
+ * @param {string} filenameCol - Column to use for the name portion
1937
+ * @param {string[]} parentChain - Array of parent segments: ['_output~name~uid', 'join~name~uid', ...]
1938
+ * @returns {string} - Base filename without extension
1939
+ */
1940
+ export function buildOutputFilename(entityType, node, filenameCol, parentChain = []) {
1941
+ const uid = node.UID || '';
1942
+ const rawName = node[filenameCol];
1943
+ const name = rawName ? sanitizeFilename(String(rawName)) : '';
1944
+
1945
+ // Build this entity's segment: <type>~<name>~<uid>
1946
+ // If filenameCol IS the UID, don't double-append it
1947
+ let segment;
1948
+ if (!name || name === uid) {
1949
+ segment = `${entityType}~${uid}`;
1950
+ } else {
1951
+ segment = `${entityType}~${name}~${uid}`;
1952
+ }
1953
+
1954
+ // Root output gets _ prefix
1955
+ if (entityType === 'output') {
1956
+ segment = `_${segment}`;
1957
+ }
1958
+
1959
+ const allSegments = [...parentChain, segment];
1960
+ return allSegments.join('.');
1961
+ }
1962
+
1963
+ /**
1964
+ * Parse an output hierarchy filename back into entity relationships.
1965
+ *
1966
+ * @param {string} filename - e.g. "_output~name~uid.column~name~uid.filter~name~uid.json"
1967
+ * @returns {Object} - { segments: [{entity, name, uid}], rootOutputUid, entityType, uid }
1968
+ */
1969
+ export function parseOutputHierarchyFile(filename) {
1970
+ // Strip .json extension
1971
+ let base = filename;
1972
+ if (base.endsWith('.json')) base = base.substring(0, base.length - 5);
1973
+
1974
+ // Split into segments by finding entity type boundaries
1975
+ // Entity types are: _output~, output~, column~, join~, filter~
1976
+ const parts = [];
1977
+
1978
+ // First, split by '.' but we need to be careful since names can contain '.'
1979
+ // Strategy: find entity-type prefixed segments
1980
+ // Split on '.' then re-join segments that don't start with an entity type
1981
+ const dotParts = base.split('.');
1982
+ let currentSegment = null;
1983
+
1984
+ for (const part of dotParts) {
1985
+ // Check if this part starts with an entity type prefix
1986
+ const stripped = part.replace(/^_/, '');
1987
+ const match = stripped.match(/^(output|column|join|filter)~/);
1988
+
1989
+ if (match) {
1990
+ if (currentSegment !== null) {
1991
+ parts.push(currentSegment);
1992
+ }
1993
+ currentSegment = part;
1994
+ } else if (currentSegment !== null) {
1995
+ // This part belongs to the previous segment (name contained '.')
1996
+ currentSegment += '.' + part;
1997
+ } else {
1998
+ currentSegment = part;
1999
+ }
2000
+ }
2001
+ if (currentSegment !== null) {
2002
+ parts.push(currentSegment);
2003
+ }
2004
+
2005
+ // Parse each segment: <type>~<name>~<uid> or <type>~<uid>
2006
+ const segments = [];
2007
+ for (const part of parts) {
2008
+ const stripped = part.replace(/^_/, '');
2009
+ const firstTilde = stripped.indexOf('~');
2010
+ if (firstTilde < 0) continue;
2011
+
2012
+ const entityType = stripped.substring(0, firstTilde);
2013
+ const rest = stripped.substring(firstTilde + 1);
2014
+
2015
+ // The UID is the last ~-separated value (UIDs are alphanumeric, typically 8+ chars)
2016
+ const lastTilde = rest.lastIndexOf('~');
2017
+ let name, uid;
2018
+ if (lastTilde >= 0) {
2019
+ name = rest.substring(0, lastTilde);
2020
+ uid = rest.substring(lastTilde + 1);
2021
+ } else {
2022
+ // No second tilde — the entire rest is the UID
2023
+ name = null;
2024
+ uid = rest;
2025
+ }
2026
+
2027
+ segments.push({ entity: entityType, name, uid });
2028
+ }
2029
+
2030
+ if (segments.length === 0) return null;
2031
+
2032
+ const last = segments[segments.length - 1];
2033
+ const root = segments[0];
2034
+
2035
+ // Map documentation name back to physical table name
2036
+ const docToPhysical = {
2037
+ output: 'output',
2038
+ column: 'output_value',
2039
+ filter: 'output_value_filter',
2040
+ join: 'output_value_entity_column_rel',
2041
+ };
2042
+
2043
+ return {
2044
+ segments,
2045
+ rootOutputUid: root.uid,
2046
+ entityType: last.entity,
2047
+ physicalEntity: docToPhysical[last.entity] || last.entity,
2048
+ uid: last.uid,
2049
+ parentEntity: segments.length > 1 ? segments[segments.length - 2].entity : null,
2050
+ parentUid: segments.length > 1 ? segments[segments.length - 2].uid : null,
2051
+ };
2052
+ }
2053
+
2054
+ /**
2055
+ * Main orchestrator: process output hierarchy entities during clone.
2056
+ * Builds tree, resolves filenames, writes hierarchy files.
2057
+ *
2058
+ * @param {Object} appJson - The full app JSON
2059
+ * @param {Object} structure - Bin hierarchy structure
2060
+ * @param {Object} options - CLI options
2061
+ * @param {string} serverTz - Server timezone
2062
+ * @returns {Array} - Array of { uid, metaPath } for app.json reference replacement
2063
+ */
2064
+ async function processOutputHierarchy(appJson, structure, options, serverTz) {
2065
+ const tree = buildOutputHierarchyTree(appJson);
2066
+ if (tree.length === 0) return [];
2067
+
2068
+ log.info(`Processing ${tree.length} output record(s) with hierarchy...`);
2069
+
2070
+ // Resolve filename columns for each entity type
2071
+ const filenameCols = await resolveOutputFilenameColumns(appJson, options);
2072
+
2073
+ const refs = [];
2074
+ const bulkAction = { value: null };
2075
+ const config = await loadConfig();
2076
+ // When --force flag is set, skip change detection and re-process all files
2077
+ const forceReprocess = !!options.force;
2078
+
2079
+ for (const output of tree) {
2080
+ // Resolve bin directory for this output
2081
+ let binDir = null;
2082
+ let chosenBinId = null;
2083
+ if (output.BinID && structure[output.BinID]) {
2084
+ binDir = resolveBinPath(output.BinID, structure);
2085
+ }
2086
+
2087
+ if (!binDir) {
2088
+ // No BinID — prompt or default
2089
+ if (!options.yes) {
2090
+ const inquirer = (await import('inquirer')).default;
2091
+ const binChoices = Object.entries(structure).map(([id, entry]) => ({
2092
+ name: `${entry.name} (${entry.fullPath})`,
2093
+ value: id,
2094
+ }));
2095
+
2096
+ if (binChoices.length > 0) {
2097
+ const { binId } = await inquirer.prompt([{
2098
+ type: 'list',
2099
+ name: 'binId',
2100
+ message: `Output "${output.Name || output.UID}" has no BinID. Which bin should it go in?`,
2101
+ choices: binChoices,
2102
+ }]);
2103
+ chosenBinId = Number(binId);
2104
+ binDir = resolveBinPath(chosenBinId, structure);
2105
+ } else {
2106
+ binDir = BINS_DIR;
2107
+ }
2108
+ } else {
2109
+ binDir = BINS_DIR;
2110
+ }
2111
+ }
2112
+
2113
+ await mkdir(binDir, { recursive: true });
2114
+
2115
+ // Build root output filename
2116
+ const rootBasename = buildOutputFilename('output', output, filenameCols.output);
2117
+ const rootMetaPath = join(binDir, `${rootBasename}.json`);
2118
+
2119
+ // Change detection for existing files (skip when --entity forces re-processing)
2120
+ if (await fileExists(rootMetaPath) && !options.yes && !forceReprocess) {
2121
+ if (bulkAction.value === 'skip_all') {
2122
+ log.dim(` Skipped ${rootBasename}`);
2123
+ refs.push({ uid: output.UID, metaPath: rootMetaPath });
2124
+ continue;
2125
+ }
2126
+ if (bulkAction.value !== 'overwrite_all') {
2127
+ const localSyncTime = await getLocalSyncTime(rootMetaPath);
2128
+ const serverNewer = isServerNewer(localSyncTime, output._LastUpdated, config);
2129
+ if (serverNewer) {
2130
+ const action = await promptChangeDetection(rootBasename, output, config);
2131
+ if (action === 'skip') { refs.push({ uid: output.UID, metaPath: rootMetaPath }); continue; }
2132
+ if (action === 'skip_all') { bulkAction.value = 'skip_all'; refs.push({ uid: output.UID, metaPath: rootMetaPath }); continue; }
2133
+ if (action === 'overwrite_all') { bulkAction.value = 'overwrite_all'; }
2134
+ if (action === 'compare') { await inlineDiffAndMerge(output, rootMetaPath, config); refs.push({ uid: output.UID, metaPath: rootMetaPath }); continue; }
2135
+ } else {
2136
+ const locallyModified = await hasLocalModifications(rootMetaPath, config);
2137
+ if (locallyModified) {
2138
+ const action = await promptChangeDetection(rootBasename, output, config, { localIsNewer: true });
2139
+ if (action === 'skip') { refs.push({ uid: output.UID, metaPath: rootMetaPath }); continue; }
2140
+ if (action === 'skip_all') { bulkAction.value = 'skip_all'; refs.push({ uid: output.UID, metaPath: rootMetaPath }); continue; }
2141
+ if (action === 'overwrite_all') { bulkAction.value = 'overwrite_all'; }
2142
+ if (action === 'compare') { await inlineDiffAndMerge(output, rootMetaPath, config, { localIsNewer: true }); refs.push({ uid: output.UID, metaPath: rootMetaPath }); continue; }
2143
+ } else {
2144
+ log.dim(` Up to date: ${rootBasename}`);
2145
+ refs.push({ uid: output.UID, metaPath: rootMetaPath });
2146
+ continue;
2147
+ }
2148
+ }
2149
+ }
2150
+ }
2151
+
2152
+ // Collect child file references for root JSON
2153
+ const childRefs = { column: [], join: [], filter: [] };
2154
+
2155
+ // Helper to build a child filename segment
2156
+ const childSegment = (type, node, col) => {
2157
+ const uid = node.UID || '';
2158
+ const rawName = node[col];
2159
+ const name = rawName ? sanitizeFilename(String(rawName)) : '';
2160
+ return (!name || name === uid) ? `${type}~${uid}` : `${type}~${name}~${uid}`;
2161
+ };
2162
+
2163
+ // Process all children depth-first
2164
+ // Direct filters on output
2165
+ for (const filter of output._children.filter) {
2166
+ const filterName = `${rootBasename}.${childSegment('filter', filter, filenameCols.output_value_filter)}`;
2167
+ const filterPath = join(binDir, `${filterName}.json`);
2168
+ await writeOutputEntityFile(filter, 'output_value_filter', filterPath, serverTz);
2169
+ childRefs.filter.push(`@${filterPath}`);
2170
+ }
2171
+
2172
+ // Direct columns on output
2173
+ for (const col of output._children.column) {
2174
+ const colSeg = childSegment('column', col, filenameCols.output_value);
2175
+ const colName = `${rootBasename}.${colSeg}`;
2176
+ const colPath = join(binDir, `${colName}.json`);
2177
+ await writeOutputEntityFile(col, 'output_value', colPath, serverTz);
2178
+ childRefs.column.push(`@${colPath}`);
2179
+
2180
+ // Filters under this column
2181
+ for (const filter of col._children.filter) {
2182
+ const filterName = `${colName}.${childSegment('filter', filter, filenameCols.output_value_filter)}`;
2183
+ const filterPath = join(binDir, `${filterName}.json`);
2184
+ await writeOutputEntityFile(filter, 'output_value_filter', filterPath, serverTz);
2185
+ childRefs.filter.push(`@${filterPath}`);
2186
+ }
2187
+ }
2188
+
2189
+ // Joins on output
2190
+ for (const j of output._children.join) {
2191
+ const joinSeg = childSegment('join', j, filenameCols.output_value_entity_column_rel);
2192
+ const joinName = `${rootBasename}.${joinSeg}`;
2193
+ const joinPath = join(binDir, `${joinName}.json`);
2194
+ await writeOutputEntityFile(j, 'output_value_entity_column_rel', joinPath, serverTz);
2195
+ childRefs.join.push(`@${joinPath}`);
2196
+
2197
+ // Columns under this join
2198
+ for (const col of j._children.column) {
2199
+ const joinColName = `${joinName}.${childSegment('column', col, filenameCols.output_value)}`;
2200
+ const joinColPath = join(binDir, `${joinColName}.json`);
2201
+ await writeOutputEntityFile(col, 'output_value', joinColPath, serverTz);
2202
+ childRefs.column.push(`@${joinColPath}`);
2203
+
2204
+ // Filters under this join→column
2205
+ for (const filter of col._children.filter) {
2206
+ const filterName = `${joinColName}.${childSegment('filter', filter, filenameCols.output_value_filter)}`;
2207
+ const filterPath = join(binDir, `${filterName}.json`);
2208
+ await writeOutputEntityFile(filter, 'output_value_filter', filterPath, serverTz);
2209
+ childRefs.filter.push(`@${filterPath}`);
2210
+ }
2211
+ }
2212
+ }
2213
+
2214
+ // Write root output JSON with child references
2215
+ const rootMeta = {};
2216
+ const rootContentColumns = [];
2217
+ for (const [key, value] of Object.entries(output)) {
2218
+ if (key === '_children') continue;
2219
+
2220
+ // Always extract CustomSQL to companion .sql file
2221
+ if (key === 'CustomSQL') {
2222
+ const decoded = resolveContentValue(value);
2223
+ const sqlContent = (decoded && decoded.trim()) ? decoded : '';
2224
+ const sqlFilePath = rootMetaPath.replace(/\.json$/, '.CustomSQL.sql');
2225
+ await writeFile(sqlFilePath, sqlContent);
2226
+ rootMeta[key] = `@${basename(sqlFilePath)}`;
2227
+ rootContentColumns.push('CustomSQL');
2228
+ if (serverTz && (output._CreatedOn || output._LastUpdated)) {
2229
+ try { await setFileTimestamps(sqlFilePath, output._CreatedOn, output._LastUpdated, serverTz); } catch { /* non-critical */ }
2230
+ }
2231
+ log.dim(` → ${sqlFilePath}`);
2232
+ continue;
2233
+ }
2234
+
2235
+ if (value && typeof value === 'object' && !Array.isArray(value) && value.encoding === 'base64') {
2236
+ rootMeta[key] = resolveContentValue(value);
2237
+ } else {
2238
+ rootMeta[key] = value;
2239
+ }
2240
+ }
2241
+ rootMeta._entity = 'output';
2242
+ if (rootContentColumns.length > 0) {
2243
+ rootMeta._contentColumns = rootContentColumns;
2244
+ }
2245
+ rootMeta.children = childRefs;
2246
+
2247
+ // If user chose a bin for a BinID-less output, store it and mark as modified
2248
+ if (chosenBinId) {
2249
+ rootMeta.BinID = chosenBinId;
2250
+ log.dim(` Set BinID=${chosenBinId} on "${output.Name || output.UID}" (staged for next push)`);
2251
+ }
2252
+
2253
+ await writeFile(rootMetaPath, JSON.stringify(rootMeta, null, 2) + '\n');
2254
+ log.success(`Saved ${rootMetaPath}`);
2255
+
2256
+ // Set file timestamps to server's _LastUpdated so diff detection works.
2257
+ // Skip when chosenBinId is set — keep mtime at "now" so push detects the local edit.
2258
+ if (!chosenBinId && serverTz && (output._CreatedOn || output._LastUpdated)) {
2259
+ try {
2260
+ await setFileTimestamps(rootMetaPath, output._CreatedOn, output._LastUpdated, serverTz);
2261
+ } catch { /* non-critical */ }
2262
+ }
2263
+
2264
+ refs.push({ uid: output.UID, metaPath: rootMetaPath });
2265
+ }
2266
+
2267
+ return refs;
2268
+ }
2269
+
2270
+ /**
2271
+ * Write a single output hierarchy entity file (column, join, or filter).
2272
+ * Handles CustomSQL extraction to companion .sql files.
2273
+ */
2274
+ async function writeOutputEntityFile(node, physicalEntity, filePath, serverTz) {
2275
+ const meta = {};
2276
+ const contentColumns = [];
2277
+
2278
+ for (const [key, value] of Object.entries(node)) {
2279
+ if (key === '_children') continue;
2280
+
2281
+ // Always extract CustomSQL to companion .sql file
2282
+ if (key === 'CustomSQL') {
2283
+ const decoded = resolveContentValue(value);
2284
+ const sqlContent = (decoded && decoded.trim()) ? decoded : '';
2285
+ const sqlFilePath = filePath.replace(/\.json$/, '.CustomSQL.sql');
2286
+ await writeFile(sqlFilePath, sqlContent);
2287
+ meta[key] = `@${basename(sqlFilePath)}`;
2288
+ contentColumns.push('CustomSQL');
2289
+
2290
+ if (serverTz && (node._CreatedOn || node._LastUpdated)) {
2291
+ try {
2292
+ await setFileTimestamps(sqlFilePath, node._CreatedOn, node._LastUpdated, serverTz);
2293
+ } catch { /* non-critical */ }
2294
+ }
2295
+ log.dim(` → ${sqlFilePath}`);
2296
+ continue;
2297
+ }
2298
+
2299
+ // Decode other base64 columns inline
2300
+ if (value && typeof value === 'object' && !Array.isArray(value) && value.encoding === 'base64') {
2301
+ meta[key] = resolveContentValue(value);
2302
+ } else {
2303
+ meta[key] = value;
2304
+ }
2305
+ }
2306
+
2307
+ meta._entity = physicalEntity;
2308
+ if (contentColumns.length > 0) {
2309
+ meta._contentColumns = contentColumns;
2310
+ }
2311
+
2312
+ await writeFile(filePath, JSON.stringify(meta, null, 2) + '\n');
2313
+ log.dim(` → ${filePath}`);
2314
+
2315
+ if (serverTz && (node._CreatedOn || node._LastUpdated)) {
2316
+ try {
2317
+ await setFileTimestamps(filePath, node._CreatedOn, node._LastUpdated, serverTz);
2318
+ } catch { /* non-critical */ }
2319
+ }
2320
+ }
2321
+
1703
2322
  /**
1704
2323
  * Save app.json to project root with @ references replacing processed entries.
1705
2324
  */
@@ -1731,7 +2350,13 @@ async function saveAppJson(appJson, contentRefs, otherRefs, domain) {
1731
2350
  }
1732
2351
 
1733
2352
  // Bins stay as-is (directory structure, no metadata files)
1734
- // Output stays as-is (ignored for now)
2353
+
2354
+ // Remove sub-entity arrays that are now embedded in output hierarchy files
2355
+ if (otherRefs.output) {
2356
+ delete output.children.output_value;
2357
+ delete output.children.output_value_filter;
2358
+ delete output.children.output_value_entity_column_rel;
2359
+ }
1735
2360
 
1736
2361
  await writeFile('app.json', JSON.stringify(output, null, 2) + '\n');
1737
2362
  }