@dboio/cli 0.15.3 → 0.16.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.
@@ -1,10 +1,10 @@
1
1
  import { Command } from 'commander';
2
- import { readFile, writeFile, appendFile, mkdir, access, readdir, rename, stat } from 'fs/promises';
2
+ import { readFile, writeFile, appendFile, mkdir, access, readdir, rename, stat, utimes } from 'fs/promises';
3
3
  import { join, basename, extname, dirname } from 'path';
4
4
  import { fileURLToPath } from 'url';
5
5
  import { DboClient } from '../lib/client.js';
6
6
  import { loadConfig, updateConfigWithApp, loadClonePlacement, saveClonePlacement, ensureGitignore, saveEntityDirPreference, loadEntityDirPreference, saveEntityContentExtractions, loadEntityContentExtractions, saveAppJsonBaseline, addDeleteEntry, loadCollisionResolutions, saveCollisionResolutions, loadSynchronize, saveSynchronize, saveAppModifyKey, loadTransactionKeyPreset, saveTransactionKeyPreset, loadOutputFilenamePreference, saveOutputFilenamePreference, saveCloneSource, loadCloneSource, saveDescriptorFilenamePreference, loadDescriptorFilenamePreference, saveDescriptorContentExtractions, loadDescriptorContentExtractions, saveExtensionDocumentationMDPlacement, loadExtensionDocumentationMDPlacement } from '../lib/config.js';
7
- import { buildBinHierarchy, resolveBinPath, createDirectories, saveStructureFile, 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
+ 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, resolveFieldValue } from '../lib/structure.js';
8
8
  import { log } from '../lib/logger.js';
9
9
  import { buildUidFilename, buildContentFileName, buildMetaFilename, isMetadataFile, parseMetaFilename, stripUidFromFilename, hasUidInFilename } from '../lib/filenames.js';
10
10
  import { setFileTimestamps, parseServerDate } from '../lib/timestamps.js';
@@ -12,9 +12,12 @@ import { getLocalSyncTime, isServerNewer, hasLocalModifications, promptChangeDet
12
12
  import { loadIgnore } from '../lib/ignore.js';
13
13
  import { checkDomainChange } from '../lib/domain-guard.js';
14
14
  import { applyTrashIcon, ensureTrashIcon, tagProjectFiles } from '../lib/tagging.js';
15
- import { loadMetadataTemplates, saveMetadataTemplates, getTemplateCols, setTemplateCols, buildTemplateFromCloneRecord } from '../lib/metadata-templates.js';
15
+ import { loadMetadataSchema, saveMetadataSchema, getTemplateCols, setTemplateCols, buildTemplateFromCloneRecord, generateMetadataFromSchema, parseReferenceExpression, mergeDescriptorSchemaFromDependencies } from '../lib/metadata-schema.js';
16
+ import { fetchSchema, loadSchema, saveSchema, isSchemaStale, SCHEMA_FILE } from '../lib/schema.js';
16
17
  import { runPendingMigrations } from '../lib/migrations.js';
17
18
  import { upsertDeployEntry } from '../lib/deploy-config.js';
19
+ import { syncDependencies, parseDependenciesColumn } from '../lib/dependencies.js';
20
+ import { mergeDependencies } from '../lib/config.js';
18
21
 
19
22
  /**
20
23
  * Resolve a column value that may be base64-encoded.
@@ -124,7 +127,7 @@ export async function detectAndTrashOrphans(appJson, ig, sync, options) {
124
127
  const metaDir = dirname(metaPath);
125
128
  const filesToMove = [metaPath];
126
129
 
127
- for (const col of (meta._contentColumns || [])) {
130
+ for (const col of (meta._companionReferenceColumns || meta._contentColumns || [])) {
128
131
  const ref = meta[col];
129
132
  if (ref && String(ref).startsWith('@')) {
130
133
  const refName = String(ref).substring(1);
@@ -411,7 +414,7 @@ async function detectAndRenameLegacyCompanions(metaPath, meta) {
411
414
  if (!uid) return false;
412
415
 
413
416
  const metaDir = dirname(metaPath);
414
- const contentCols = [...(meta._contentColumns || [])];
417
+ const contentCols = [...(meta._companionReferenceColumns || meta._contentColumns || [])];
415
418
  if (meta._mediaFile) contentCols.push('_mediaFile');
416
419
  let metaChanged = false;
417
420
 
@@ -475,10 +478,13 @@ async function detectAndRenameLegacyCompanions(metaPath, meta) {
475
478
  }
476
479
  }
477
480
 
478
- // Rewrite metadata file if @references were updated
481
+ // Rewrite metadata file if @references were updated (preserve timestamps
482
+ // so the write doesn't cause false "local changes" during change detection)
479
483
  if (metaChanged) {
480
484
  try {
485
+ const before = await stat(metaPath);
481
486
  await writeFile(metaPath, JSON.stringify(meta, null, 2) + '\n');
487
+ await utimes(metaPath, before.atime, before.mtime);
482
488
  } catch { /* non-critical */ }
483
489
  }
484
490
 
@@ -519,7 +525,7 @@ async function trashOrphanedLegacyCompanions() {
519
525
  // Read metadata and collect all @references
520
526
  try {
521
527
  const meta = JSON.parse(await readFile(full, 'utf8'));
522
- const cols = [...(meta._contentColumns || [])];
528
+ const cols = [...(meta._companionReferenceColumns || meta._contentColumns || [])];
523
529
  if (meta._mediaFile) cols.push('_mediaFile');
524
530
  for (const col of cols) {
525
531
  const ref = meta[col];
@@ -804,7 +810,11 @@ export const cloneCommand = new Command('clone')
804
810
  .option('-y, --yes', 'Auto-accept all prompts')
805
811
  .option('--media-placement <placement>', 'Set media placement: fullpath or binpath (default: bin)')
806
812
  .option('-v, --verbose', 'Show HTTP request details')
813
+ .option('--schema', 'Re-fetch schema.json from server before cloning')
807
814
  .option('--no-migrate', 'Skip pending migrations for this invocation')
815
+ .option('--no-deps', 'Skip dependency cloning after clone')
816
+ .option('--dependencies <apps>', 'Sync specific dependency apps (comma-separated short-names)')
817
+ .option('--configure', 'Re-prompt for filename columns and companion file extraction preferences')
808
818
  .action(async (source, options) => {
809
819
  try {
810
820
  await runPendingMigrations(options);
@@ -1009,6 +1019,35 @@ export async function performClone(source, options = {}) {
1009
1019
  const effectiveDomain = options.domain || config.domain;
1010
1020
  let appJson;
1011
1021
 
1022
+ // Fetch schema if missing, explicitly requested, or server has a newer version
1023
+ let schema = await loadSchema();
1024
+ let shouldFetch = !schema || options.schema;
1025
+ if (!shouldFetch && schema) {
1026
+ try {
1027
+ shouldFetch = await isSchemaStale({ domain: effectiveDomain, verbose: options.verbose });
1028
+ if (shouldFetch) log.dim(` Server schema is newer — refreshing schema.json`);
1029
+ } catch {
1030
+ // Can't check — continue with local schema
1031
+ }
1032
+ }
1033
+ if (shouldFetch) {
1034
+ try {
1035
+ schema = await fetchSchema({ domain: effectiveDomain, verbose: options.verbose });
1036
+ await saveSchema(schema);
1037
+ log.dim(` Saved schema.json`);
1038
+ } catch (err) {
1039
+ if (!schema) log.warn(` Could not fetch schema: ${err.message}`);
1040
+ // Continue with stale schema or null
1041
+ }
1042
+ }
1043
+
1044
+ // Regenerate metadata_schema.json for any new entity types
1045
+ if (schema) {
1046
+ const existing = await loadMetadataSchema();
1047
+ const updated = generateMetadataFromSchema(schema, existing ?? {});
1048
+ await saveMetadataSchema(updated);
1049
+ }
1050
+
1012
1051
  // Step 1: Source mismatch detection (skip in pull mode)
1013
1052
  // Warn when the user provides an explicit source that differs from the stored one.
1014
1053
  const storedCloneSource = options.pullMode ? null : await loadCloneSource();
@@ -1092,6 +1131,16 @@ export async function performClone(source, options = {}) {
1092
1131
  });
1093
1132
  await saveCloneSource(activeSource || 'default');
1094
1133
  log.dim(' Updated .dbo/config.json with app metadata');
1134
+
1135
+ // Merge Dependencies from app.json into .dbo/config.json
1136
+ // Always ensure at least ["_system"] is persisted
1137
+ const fromApp = parseDependenciesColumn(appJson.Dependencies);
1138
+ if (fromApp.length > 0) {
1139
+ await mergeDependencies(fromApp);
1140
+ log.dim(` Merged app.json Dependencies: ${fromApp.join(', ')}`);
1141
+ } else {
1142
+ await mergeDependencies([]);
1143
+ }
1095
1144
  }
1096
1145
 
1097
1146
  // Detect and store ModifyKey for locked/production apps (skip in pull mode)
@@ -1120,6 +1169,41 @@ export async function performClone(source, options = {}) {
1120
1169
  await updatePackageJson(appJson, config);
1121
1170
  }
1122
1171
 
1172
+ // Step 3b: Sync dependency apps — before main content processing (skip in pull mode, entity filter, no-deps)
1173
+ if (!options.pullMode && !options.entity && !options.noDeps) {
1174
+ const explicitDeps = options.dependencies
1175
+ ? options.dependencies.split(',').map(s => s.trim().toLowerCase()).filter(Boolean)
1176
+ : null;
1177
+ if (explicitDeps && explicitDeps.length > 0) {
1178
+ await mergeDependencies(explicitDeps);
1179
+ }
1180
+ try {
1181
+ await syncDependencies({
1182
+ domain: effectiveDomain,
1183
+ force: explicitDeps ? true : options.force,
1184
+ schema: options.schema,
1185
+ verbose: options.verbose,
1186
+ systemSchemaPath: join(process.cwd(), SCHEMA_FILE),
1187
+ only: explicitDeps || undefined,
1188
+ });
1189
+ } catch (err) {
1190
+ log.warn(` Dependency sync failed: ${err.message}`);
1191
+ }
1192
+
1193
+ // Merge extension descriptor definitions from dependency schemas
1194
+ // (e.g. operator provides descriptor_definition entries for apps that lack their own)
1195
+ try {
1196
+ const localSchema = await loadMetadataSchema();
1197
+ if (localSchema) {
1198
+ const merged = await mergeDescriptorSchemaFromDependencies(localSchema);
1199
+ if (merged) {
1200
+ await saveMetadataSchema(localSchema);
1201
+ log.dim(' Merged extension descriptor schema from dependencies');
1202
+ }
1203
+ }
1204
+ } catch { /* non-critical */ }
1205
+ }
1206
+
1123
1207
  // Step 4: Create default project directories + bin structure
1124
1208
  const bins = appJson.children.bin || [];
1125
1209
  const structure = buildBinHierarchy(bins, appJson.AppID);
@@ -1578,26 +1662,23 @@ async function processEntityDirEntries(entityName, entries, options, serverTz) {
1578
1662
  const usedNames = new Map(); // name → count, for collision resolution
1579
1663
  const config = await loadConfig();
1580
1664
 
1581
- // Determine filename column: saved preference, or prompt, or default
1665
+ // Determine filename column: saved preference, or auto-default, or prompt (--configure)
1582
1666
  let filenameCol = null;
1583
1667
 
1584
1668
  if (options.yes) {
1585
1669
  // Non-interactive: use Name, fallback UID
1586
1670
  filenameCol = null; // will resolve per-record below
1587
1671
  } else {
1588
- // Check saved preference
1589
- const savedCol = await loadEntityDirPreference(entityName);
1672
+ // Check saved preference (skip if --configure to allow re-prompting)
1673
+ const savedCol = options.configure ? null : await loadEntityDirPreference(entityName);
1590
1674
  if (savedCol) {
1591
1675
  filenameCol = savedCol;
1592
1676
  log.dim(` Using saved filename column "${filenameCol}" for ${entityName}`);
1593
1677
  } else {
1594
- // Prompt user to pick a filename column from available columns
1595
1678
  const sampleRecord = entries[0];
1596
1679
  const columns = Object.keys(sampleRecord).filter(k => k !== 'children' && !k.startsWith('_'));
1597
1680
 
1598
1681
  if (columns.length > 0) {
1599
- const inquirer = (await import('inquirer')).default;
1600
-
1601
1682
  // Find best default (app_version prioritizes Number → Name → UID)
1602
1683
  const defaultCol = (entityName === 'app_version' && columns.includes('Number')) ? 'Number'
1603
1684
  : columns.includes('Name') ? 'Name'
@@ -1605,18 +1686,25 @@ async function processEntityDirEntries(entityName, entries, options, serverTz) {
1605
1686
  : columns.includes('UID') ? 'UID'
1606
1687
  : columns[0];
1607
1688
 
1608
- const { col } = await inquirer.prompt([{
1609
- type: 'list',
1610
- name: 'col',
1611
- message: `Which column should be used as the filename for ${entityName} records?`,
1612
- choices: columns,
1613
- default: defaultCol,
1614
- }]);
1615
- filenameCol = col;
1689
+ if (options.configure) {
1690
+ // --configure: prompt user to pick
1691
+ const inquirer = (await import('inquirer')).default;
1692
+ const { col } = await inquirer.prompt([{
1693
+ type: 'list',
1694
+ name: 'col',
1695
+ message: `Which column should be used as the filename for ${entityName} records?`,
1696
+ choices: columns,
1697
+ default: defaultCol,
1698
+ }]);
1699
+ filenameCol = col;
1700
+ } else {
1701
+ // Auto-apply best default silently
1702
+ filenameCol = defaultCol;
1703
+ }
1616
1704
 
1617
1705
  // Save preference for future clones
1618
1706
  await saveEntityDirPreference(entityName, filenameCol);
1619
- log.dim(` Saved filename column preference for ${entityName}`);
1707
+ log.dim(` Using filename column "${filenameCol}" for ${entityName}`);
1620
1708
  }
1621
1709
  }
1622
1710
  }
@@ -1646,13 +1734,11 @@ async function processEntityDirEntries(entityName, entries, options, serverTz) {
1646
1734
  }
1647
1735
 
1648
1736
  if (base64Cols.length > 0) {
1649
- // Load saved content extraction preferences
1650
- const savedExtractions = await loadEntityContentExtractions(entityName);
1737
+ // Load saved content extraction preferences (skip if --configure to allow re-prompting)
1738
+ const savedExtractions = options.configure ? null : await loadEntityContentExtractions(entityName);
1651
1739
  const newPreferences = savedExtractions ? { ...savedExtractions } : {};
1652
1740
  let hasNewChoices = false;
1653
1741
 
1654
- const inquirer = (await import('inquirer')).default;
1655
-
1656
1742
  // Prompt per column: show snippet and ask extract yes/no + extension
1657
1743
  for (const { col, snippet } of base64Cols) {
1658
1744
  // Check if we have a saved preference for this column
@@ -1670,32 +1756,42 @@ async function processEntityDirEntries(entityName, entries, options, serverTz) {
1670
1756
  }
1671
1757
  }
1672
1758
 
1673
- // No saved preference - prompt the user
1674
- const preview = snippet ? ` (${snippet})` : '';
1759
+ // No saved preference
1675
1760
  const guessed = guessExtensionForColumn(col);
1676
1761
 
1677
- const { extract } = await inquirer.prompt([{
1678
- type: 'confirm',
1679
- name: 'extract',
1680
- message: `Extract "${col}" as companion file?${preview}`,
1681
- default: true,
1682
- }]);
1762
+ if (options.configure) {
1763
+ // --configure: prompt user
1764
+ const preview = snippet ? ` (${snippet})` : '';
1765
+ const inquirer = (await import('inquirer')).default;
1683
1766
 
1684
- if (extract) {
1685
- const { ext } = await inquirer.prompt([{
1686
- type: 'input',
1687
- name: 'ext',
1688
- message: `File extension for "${col}":`,
1689
- default: guessed,
1767
+ const { extract } = await inquirer.prompt([{
1768
+ type: 'confirm',
1769
+ name: 'extract',
1770
+ message: `Extract "${col}" as companion file?${preview}`,
1771
+ default: true,
1690
1772
  }]);
1691
- const cleanExt = ext.replace(/^\./, '');
1692
- contentColsToExtract.push({ col, ext: cleanExt });
1693
- newPreferences[col] = cleanExt;
1694
- hasNewChoices = true;
1773
+
1774
+ if (extract) {
1775
+ const { ext } = await inquirer.prompt([{
1776
+ type: 'input',
1777
+ name: 'ext',
1778
+ message: `File extension for "${col}":`,
1779
+ default: guessed,
1780
+ }]);
1781
+ const cleanExt = ext.replace(/^\./, '');
1782
+ contentColsToExtract.push({ col, ext: cleanExt });
1783
+ newPreferences[col] = cleanExt;
1784
+ hasNewChoices = true;
1785
+ } else {
1786
+ newPreferences[col] = false;
1787
+ hasNewChoices = true;
1788
+ }
1695
1789
  } else {
1696
- // User chose not to extract - save this preference too
1697
- newPreferences[col] = false;
1790
+ // Auto-apply: extract with guessed extension
1791
+ contentColsToExtract.push({ col, ext: guessed });
1792
+ newPreferences[col] = guessed;
1698
1793
  hasNewChoices = true;
1794
+ log.dim(` Auto-extracting "${col}" as .${guessed} file`);
1699
1795
  }
1700
1796
  }
1701
1797
 
@@ -1906,7 +2002,7 @@ async function processEntityDirEntries(entityName, entries, options, serverTz) {
1906
2002
 
1907
2003
  meta._entity = entityName;
1908
2004
  if (extractedContentCols.length > 0) {
1909
- meta._contentColumns = extractedContentCols;
2005
+ meta._companionReferenceColumns = extractedContentCols;
1910
2006
  }
1911
2007
 
1912
2008
  await writeFile(metaPath, JSON.stringify(meta, null, 2) + '\n');
@@ -1924,7 +2020,7 @@ async function processEntityDirEntries(entityName, entries, options, serverTz) {
1924
2020
 
1925
2021
  // --- Seed metadata template if not already present ---
1926
2022
  if (entries.length > 0) {
1927
- const templates = await loadMetadataTemplates();
2023
+ const templates = await loadMetadataSchema();
1928
2024
  if (templates !== null) {
1929
2025
  const existing = getTemplateCols(templates, entityName, null);
1930
2026
  if (!existing) {
@@ -1932,7 +2028,7 @@ async function processEntityDirEntries(entityName, entries, options, serverTz) {
1932
2028
  const extractedColNames = contentColsToExtract.map(c => c.col);
1933
2029
  const cols = buildTemplateFromCloneRecord(firstRecord, extractedColNames);
1934
2030
  setTemplateCols(templates, entityName, null, cols);
1935
- await saveMetadataTemplates(templates);
2031
+ await saveMetadataSchema(templates);
1936
2032
  }
1937
2033
  }
1938
2034
  }
@@ -1942,25 +2038,51 @@ async function processEntityDirEntries(entityName, entries, options, serverTz) {
1942
2038
 
1943
2039
  // ─── Extension Descriptor Sub-directory Processing ────────────────────────
1944
2040
 
2041
+ /**
2042
+ * Parse form-control-code and form-control-title from a descriptor_definition String5 value.
2043
+ * Returns { colToExt: Map<col,ext>, colToTitle: Map<col,title> }.
2044
+ */
2045
+ function parseFormControlCode(string5) {
2046
+ if (!string5) return { colToExt: new Map(), colToTitle: new Map() };
2047
+ const colToExt = new Map();
2048
+ const colToTitle = new Map();
2049
+ try {
2050
+ const params = new URLSearchParams(string5.startsWith('&') ? string5.slice(1) : string5);
2051
+ const codeStr = params.get('form-control-code');
2052
+ if (codeStr) {
2053
+ for (const pair of codeStr.split(',')) {
2054
+ const [col, ext] = pair.split('|');
2055
+ if (col?.trim() && ext?.trim()) colToExt.set(col.trim(), ext.trim().toLowerCase());
2056
+ }
2057
+ }
2058
+ const titleStr = params.get('form-control-title');
2059
+ if (titleStr) {
2060
+ for (const pair of titleStr.split(',')) {
2061
+ const [col, title] = pair.split('|');
2062
+ if (col?.trim() && title?.trim()) colToTitle.set(col.trim(), title.trim());
2063
+ }
2064
+ }
2065
+ } catch { /* malformed params — ignore */ }
2066
+ return { colToExt, colToTitle };
2067
+ }
2068
+
1945
2069
  /**
1946
2070
  * Scan extension records for descriptor_definition entries.
1947
- * Builds the mapping, persists to structure.json, always creates Extensions/Unsupported/,
1948
- * and creates sub-directories for every mapped descriptor.
2071
+ * Builds the mapping, persists to structure.json, creates sub-directories
2072
+ * for every mapped descriptor, and populates metadata_schema.json from
2073
+ * form-control-code entries.
1949
2074
  * Returns the mapping object.
1950
2075
  *
1951
2076
  * @param {Object[]} extensionEntries
1952
- * @param {Object} structure - Current bin structure (from loadStructureFile)
2077
+ * @param {Object} structure - Current bin structure (from loadStructureFile)
2078
+ * @param {Object} metadataSchema - Current metadata schema (from loadMetadataSchema)
1953
2079
  * @returns {Promise<Object<string,string>>}
1954
2080
  */
1955
- async function buildDescriptorPrePass(extensionEntries, structure) {
2081
+ async function buildDescriptorPrePass(extensionEntries, structure, metadataSchema) {
1956
2082
  const { mapping, warnings } = buildDescriptorMapping(extensionEntries);
1957
2083
 
1958
2084
  for (const w of warnings) log.warn(` descriptor_definition: ${w}`);
1959
2085
 
1960
- // Always create Unsupported/ — even if empty, users see at a glance what couldn't be mapped
1961
- await mkdir(EXTENSION_UNSUPPORTED_DIR, { recursive: true });
1962
- log.dim(` ${EXTENSION_UNSUPPORTED_DIR}/`);
1963
-
1964
2086
  // Create one sub-directory per mapped descriptor name
1965
2087
  for (const dirName of new Set(Object.values(mapping))) {
1966
2088
  const fullDir = `${EXTENSION_DESCRIPTORS_DIR}/${dirName}`;
@@ -1985,9 +2107,63 @@ async function buildDescriptorPrePass(extensionEntries, structure) {
1985
2107
  await saveDescriptorMapping(structure, mapping);
1986
2108
  log.dim(` Saved descriptorMapping to .dbo/structure.json`);
1987
2109
 
2110
+ // Parse form-control-code from descriptor_definition records → populate metadata_schema.json
2111
+ const descriptorDefs = extensionEntries.filter(r =>
2112
+ resolveFieldValue(r.Descriptor) === 'descriptor_definition'
2113
+ );
2114
+ let schemaUpdated = false;
2115
+ for (const defRecord of descriptorDefs) {
2116
+ const descriptor = resolveFieldValue(defRecord.String1);
2117
+ if (!descriptor) continue;
2118
+ const string5 = resolveFieldValue(defRecord.String5);
2119
+ const { colToExt, colToTitle } = parseFormControlCode(string5);
2120
+ if (colToExt.size === 0) continue;
2121
+
2122
+ // Only populate if no entry already exists for this descriptor
2123
+ const existing = getTemplateCols(metadataSchema, 'extension', descriptor);
2124
+ if (existing) continue;
2125
+
2126
+ const refEntries = [];
2127
+ for (const [col, ext] of colToExt) {
2128
+ const title = colToTitle.get(col);
2129
+ const titleSuffix = title ? `{${title}}` : '';
2130
+ refEntries.push(`${col}=@reference:@Name[${ext}]${titleSuffix}`);
2131
+ }
2132
+ setTemplateCols(metadataSchema, 'extension', descriptor, refEntries);
2133
+ schemaUpdated = true;
2134
+ }
2135
+ if (schemaUpdated) await saveMetadataSchema(metadataSchema);
2136
+
1988
2137
  return mapping;
1989
2138
  }
1990
2139
 
2140
+ /**
2141
+ * Derive companion column @reference expressions from metadata_schema.json for a descriptor.
2142
+ * Returns array of parsed @reference expression objects.
2143
+ */
2144
+ function getDescriptorCompanionCols(metadataSchema, descriptor) {
2145
+ const cols = getTemplateCols(metadataSchema, 'extension', descriptor) ?? [];
2146
+ const refs = [];
2147
+ for (const col of cols) {
2148
+ const parsed = parseReferenceExpression(col);
2149
+ if (parsed) refs.push(parsed);
2150
+ }
2151
+ return refs;
2152
+ }
2153
+
2154
+ /**
2155
+ * Derive filename column from metadata_schema.json @reference entries for a descriptor.
2156
+ * Returns the column name (e.g., "Name") or falls back to 'Name'.
2157
+ */
2158
+ function getDescriptorFilenameCol(metadataSchema, descriptor) {
2159
+ const cols = getTemplateCols(metadataSchema, 'extension', descriptor) ?? [];
2160
+ for (const col of cols) {
2161
+ const parsed = parseReferenceExpression(col);
2162
+ if (parsed?.filenameCol?.startsWith('@')) return parsed.filenameCol.slice(1);
2163
+ }
2164
+ return 'Name';
2165
+ }
2166
+
1991
2167
  /**
1992
2168
  * Resolve filename column and content extraction preferences for one descriptor.
1993
2169
  * Prompts the user on first use; saves to config.json; respects -y and --force.
@@ -2006,16 +2182,16 @@ async function resolveDescriptorPreferences(descriptor, records, options) {
2006
2182
  let filenameCol;
2007
2183
  const savedCol = await loadDescriptorFilenamePreference(descriptor);
2008
2184
 
2185
+ const defaultCol = columns.includes('Name') ? 'Name'
2186
+ : columns.includes('UID') ? 'UID' : columns[0];
2187
+
2009
2188
  if (options.yes) {
2010
- filenameCol = columns.includes('Name') ? 'Name'
2011
- : columns.includes('UID') ? 'UID' : columns[0];
2012
- } else if (savedCol && !options.force) {
2189
+ filenameCol = defaultCol;
2190
+ } else if (savedCol && !options.force && !options.configure) {
2013
2191
  filenameCol = savedCol;
2014
2192
  log.dim(` Filename column for "${descriptor}": "${filenameCol}" (saved)`);
2015
- } else {
2193
+ } else if (options.configure) {
2016
2194
  const inquirer = (await import('inquirer')).default;
2017
- const defaultCol = columns.includes('Name') ? 'Name'
2018
- : columns.includes('UID') ? 'UID' : columns[0];
2019
2195
  const { col } = await inquirer.prompt([{
2020
2196
  type: 'list', name: 'col',
2021
2197
  message: `Filename column for "${descriptor}" extensions:`,
@@ -2024,6 +2200,11 @@ async function resolveDescriptorPreferences(descriptor, records, options) {
2024
2200
  filenameCol = col;
2025
2201
  await saveDescriptorFilenamePreference(descriptor, filenameCol);
2026
2202
  log.dim(` Saved filename column for "${descriptor}": "${filenameCol}"`);
2203
+ } else {
2204
+ // Auto-apply default silently
2205
+ filenameCol = defaultCol;
2206
+ await saveDescriptorFilenamePreference(descriptor, filenameCol);
2207
+ log.dim(` Using filename column "${filenameCol}" for "${descriptor}"`);
2027
2208
  }
2028
2209
 
2029
2210
  // ── Content extraction ───────────────────────────────────────────────
@@ -2052,12 +2233,11 @@ async function resolveDescriptorPreferences(descriptor, records, options) {
2052
2233
  }
2053
2234
 
2054
2235
  if (base64Cols.length > 0) {
2055
- const savedExtractions = options.force
2236
+ const savedExtractions = (options.force || options.configure)
2056
2237
  ? null
2057
2238
  : await loadDescriptorContentExtractions(descriptor);
2058
2239
  const newPreferences = savedExtractions ? { ...savedExtractions } : {};
2059
2240
  let changed = false;
2060
- const inquirer = (await import('inquirer')).default;
2061
2241
 
2062
2242
  for (const { col, snippet } of base64Cols) {
2063
2243
  if (savedExtractions) {
@@ -2069,24 +2249,35 @@ async function resolveDescriptorPreferences(descriptor, records, options) {
2069
2249
  continue;
2070
2250
  }
2071
2251
  }
2072
- const preview = snippet ? ` ("${snippet}")` : '';
2073
- const { extract } = await inquirer.prompt([{
2074
- type: 'confirm', name: 'extract',
2075
- message: `Extract column "${col}" (${descriptor}) as companion file?${preview}`,
2076
- default: true,
2077
- }]);
2078
- if (extract) {
2079
- const guessed = guessExtensionForDescriptor(descriptor, col);
2080
- const { ext } = await inquirer.prompt([{
2081
- type: 'input', name: 'ext',
2082
- message: `File extension for "${col}" (${descriptor}):`,
2083
- default: guessed,
2252
+
2253
+ if (options.configure) {
2254
+ // --configure: prompt user
2255
+ const preview = snippet ? ` ("${snippet}")` : '';
2256
+ const inquirer = (await import('inquirer')).default;
2257
+ const { extract } = await inquirer.prompt([{
2258
+ type: 'confirm', name: 'extract',
2259
+ message: `Extract column "${col}" (${descriptor}) as companion file?${preview}`,
2260
+ default: true,
2084
2261
  }]);
2085
- const cleanExt = ext.replace(/^\./, '');
2086
- contentColsToExtract.push({ col, ext: cleanExt });
2087
- newPreferences[col] = cleanExt;
2262
+ if (extract) {
2263
+ const guessed = guessExtensionForDescriptor(descriptor, col);
2264
+ const { ext } = await inquirer.prompt([{
2265
+ type: 'input', name: 'ext',
2266
+ message: `File extension for "${col}" (${descriptor}):`,
2267
+ default: guessed,
2268
+ }]);
2269
+ const cleanExt = ext.replace(/^\./, '');
2270
+ contentColsToExtract.push({ col, ext: cleanExt });
2271
+ newPreferences[col] = cleanExt;
2272
+ } else {
2273
+ newPreferences[col] = false;
2274
+ }
2088
2275
  } else {
2089
- newPreferences[col] = false;
2276
+ // Auto-apply: extract with guessed extension
2277
+ const guessed = guessExtensionForDescriptor(descriptor, col);
2278
+ contentColsToExtract.push({ col, ext: guessed });
2279
+ newPreferences[col] = guessed;
2280
+ log.dim(` Auto-extracting "${col}" for "${descriptor}" as .${guessed}`);
2090
2281
  }
2091
2282
  changed = true;
2092
2283
  }
@@ -2116,24 +2307,30 @@ async function resolveDocumentationPlacement(options) {
2116
2307
  if (options.yes) return 'inline';
2117
2308
 
2118
2309
  const saved = await loadExtensionDocumentationMDPlacement();
2119
- if (saved && !options.force) {
2310
+ if (saved && !options.force && !options.configure) {
2120
2311
  log.dim(` Documentation MD placement: ${saved} (saved)`);
2121
2312
  return saved;
2122
2313
  }
2123
2314
 
2124
- const inquirer = (await import('inquirer')).default;
2125
- const { placement } = await inquirer.prompt([{
2126
- type: 'list', name: 'placement',
2127
- message: 'Where should extracted documentation MD files be placed?',
2128
- choices: [
2129
- { name: 'docs/<filename>.md — project root (recommended)', value: 'root' },
2130
- { name: 'extension/documentation/<filename>.md — inline alongside metadata', value: 'inline' },
2131
- ],
2132
- default: 'root',
2133
- }]);
2315
+ let placement;
2316
+ if (options.configure) {
2317
+ const inquirer = (await import('inquirer')).default;
2318
+ ({ placement } = await inquirer.prompt([{
2319
+ type: 'list', name: 'placement',
2320
+ message: 'Where should extracted documentation MD files be placed?',
2321
+ choices: [
2322
+ { name: 'docs/<filename>.md — project root (recommended)', value: 'root' },
2323
+ { name: 'extension/documentation/<filename>.md — inline alongside metadata', value: 'inline' },
2324
+ ],
2325
+ default: 'root',
2326
+ }]));
2327
+ } else {
2328
+ // Auto-apply default
2329
+ placement = 'root';
2330
+ }
2134
2331
 
2135
2332
  await saveExtensionDocumentationMDPlacement(placement);
2136
- log.dim(` Saved documentation MD placement: "${placement}"`);
2333
+ log.dim(` Documentation MD placement: ${placement}`);
2137
2334
  return placement;
2138
2335
  }
2139
2336
 
@@ -2158,7 +2355,8 @@ async function processExtensionEntries(entries, structure, options, serverTz) {
2158
2355
  log.info(`Processing ${entries.length} extension record(s)...`);
2159
2356
 
2160
2357
  // Step A: Pre-pass — build mapping + create directories
2161
- const mapping = await buildDescriptorPrePass(entries, structure);
2358
+ const metadataSchema = await loadMetadataSchema();
2359
+ const mapping = await buildDescriptorPrePass(entries, structure, metadataSchema);
2162
2360
 
2163
2361
  // Clear documentation preferences when --force is used with --documentation-only
2164
2362
  if (options.documentationOnly && options.force) {
@@ -2171,7 +2369,7 @@ async function processExtensionEntries(entries, structure, options, serverTz) {
2171
2369
  // Step B: Group records by descriptor
2172
2370
  const groups = new Map(); // descriptor → { dir, records[] }
2173
2371
  for (const record of entries) {
2174
- const descriptor = record.Descriptor || '__unsupported__';
2372
+ const descriptor = record.Descriptor || '__no_descriptor__';
2175
2373
 
2176
2374
  // --documentation-only: skip non-documentation records
2177
2375
  if (options.documentationOnly && descriptor !== 'documentation') continue;
@@ -2186,13 +2384,14 @@ async function processExtensionEntries(entries, structure, options, serverTz) {
2186
2384
  const descriptorPrefs = new Map();
2187
2385
  let docPlacement = 'inline';
2188
2386
 
2189
- for (const [descriptor, { records }] of groups.entries()) {
2190
- if (descriptor === '__unsupported__') {
2191
- descriptorPrefs.set(descriptor, { filenameCol: null, contentColsToExtract: [] });
2387
+ for (const [descriptor] of groups.entries()) {
2388
+ if (descriptor === '__no_descriptor__') {
2389
+ descriptorPrefs.set(descriptor, { filenameCol: 'Name', companionRefs: [] });
2192
2390
  continue;
2193
2391
  }
2194
- const prefs = await resolveDescriptorPreferences(descriptor, records, options);
2195
- descriptorPrefs.set(descriptor, prefs);
2392
+ const filenameCol = getDescriptorFilenameCol(metadataSchema, descriptor);
2393
+ const companionRefs = getDescriptorCompanionCols(metadataSchema, descriptor);
2394
+ descriptorPrefs.set(descriptor, { filenameCol, companionRefs });
2196
2395
 
2197
2396
  if (descriptor === 'documentation') {
2198
2397
  docPlacement = await resolveDocumentationPlacement(options);
@@ -2210,9 +2409,9 @@ async function processExtensionEntries(entries, structure, options, serverTz) {
2210
2409
  const config = await loadConfig();
2211
2410
 
2212
2411
  for (const [descriptor, { dir, records }] of groups.entries()) {
2213
- const { filenameCol, contentColsToExtract } = descriptorPrefs.get(descriptor);
2412
+ const { filenameCol, companionRefs } = descriptorPrefs.get(descriptor);
2214
2413
  const useRootDoc = (descriptor === 'documentation' && docPlacement === 'root');
2215
- const mdColInfo = useRootDoc ? contentColsToExtract.find(c => c.ext === 'md') : null;
2414
+ const mdColInfo = useRootDoc ? companionRefs.find(r => r.extensionCol === 'md') : null;
2216
2415
  const usedNames = new Map(); // name → count, for collision resolution within this descriptor group
2217
2416
 
2218
2417
  log.info(`Processing ${records.length} "${descriptor}" extension(s) → ${dir}/`);
@@ -2284,11 +2483,11 @@ async function processExtensionEntries(entries, structure, options, serverTz) {
2284
2483
  }
2285
2484
 
2286
2485
  // Check if any @reference content files are missing — force re-extraction if so
2287
- let hasNewExtractions = contentColsToExtract.length > 0;
2486
+ let hasNewExtractions = companionRefs.length > 0;
2288
2487
  if (!hasNewExtractions && await fileExists(metaPath)) {
2289
2488
  try {
2290
2489
  const existingMeta = JSON.parse(await readFile(metaPath, 'utf8'));
2291
- for (const col of (existingMeta._contentColumns || [])) {
2490
+ for (const col of (existingMeta._companionReferenceColumns || existingMeta._contentColumns || [])) {
2292
2491
  const ref = existingMeta[col];
2293
2492
  if (ref && String(ref).startsWith('@')) {
2294
2493
  const refName = String(ref).substring(1);
@@ -2348,19 +2547,23 @@ async function processExtensionEntries(entries, structure, options, serverTz) {
2348
2547
  for (const [key, value] of Object.entries(record)) {
2349
2548
  if (key === 'children') continue;
2350
2549
 
2351
- const extractInfo = contentColsToExtract.find(c => c.col === key);
2352
- if (extractInfo) {
2550
+ const companionRef = companionRefs.find(r => r.column.toLowerCase() === key.toLowerCase());
2551
+ if (companionRef) {
2353
2552
  const isBase64 = value && typeof value === 'object' && !Array.isArray(value) && value.encoding === 'base64';
2354
2553
  const decoded = isBase64 ? (resolveContentValue(value) ?? '') : (value ?? '');
2355
2554
  let colFilePath, refValue;
2555
+ const ext = companionRef.extensionCol?.startsWith('@')
2556
+ ? (String(record[companionRef.extensionCol.slice(1)] ?? '') || companionRef.extensionFallback || 'txt').toLowerCase()
2557
+ : (companionRef.extensionCol || companionRef.extensionFallback || 'txt');
2356
2558
 
2357
- if (mdColInfo && extractInfo.col === mdColInfo.col) {
2559
+ if (mdColInfo && companionRef.column === mdColInfo.column) {
2358
2560
  // Root placement: docs/<name>.md (natural name, no ~UID)
2359
2561
  const docFileName = `${name}.md`;
2360
2562
  colFilePath = join(DOCUMENTATION_DIR, docFileName);
2361
2563
  refValue = `@/${DOCUMENTATION_DIR}/${docFileName}`;
2362
2564
  } else {
2363
- const colFileName = `${name}.${key}.${extractInfo.ext}`;
2565
+ const colSegment = companionRef.title ? sanitizeFilename(companionRef.title) : key;
2566
+ const colFileName = `${name}.${colSegment}.${ext}`;
2364
2567
  colFilePath = join(dir, colFileName);
2365
2568
  refValue = `@${colFileName}`;
2366
2569
  }
@@ -2385,7 +2588,7 @@ async function processExtensionEntries(entries, structure, options, serverTz) {
2385
2588
  }
2386
2589
 
2387
2590
  meta._entity = 'extension';
2388
- if (extractedCols.length > 0) meta._contentColumns = extractedCols;
2591
+ if (extractedCols.length > 0) meta._companionReferenceColumns = extractedCols;
2389
2592
 
2390
2593
  await writeFile(metaPath, JSON.stringify(meta, null, 2) + '\n');
2391
2594
  if (serverTz) {
@@ -2397,15 +2600,15 @@ async function processExtensionEntries(entries, structure, options, serverTz) {
2397
2600
 
2398
2601
  // --- Seed metadata template for this descriptor ---
2399
2602
  if (records.length > 0) {
2400
- const templates = await loadMetadataTemplates();
2603
+ const templates = await loadMetadataSchema();
2401
2604
  if (templates !== null) {
2402
2605
  const existing = getTemplateCols(templates, 'extension', descriptor);
2403
2606
  if (!existing) {
2404
2607
  const firstRecord = records[0];
2405
- const extractedCols = contentColsToExtract.map(c => c.col);
2608
+ const extractedCols = companionRefs.map(r => r.column);
2406
2609
  const cols = buildTemplateFromCloneRecord(firstRecord, extractedCols);
2407
2610
  setTemplateCols(templates, 'extension', descriptor, cols);
2408
- await saveMetadataTemplates(templates);
2611
+ await saveMetadataSchema(templates);
2409
2612
  }
2410
2613
  }
2411
2614
  }
@@ -2688,8 +2891,27 @@ async function processMediaEntries(mediaRecords, structure, options, config, app
2688
2891
  error: err.message,
2689
2892
  });
2690
2893
 
2894
+ // For 404s, write metadata anyway so the file isn't re-prompted on next clone.
2895
+ // The media file is genuinely gone from the server — nothing to download.
2896
+ if (is404) {
2897
+ const staleMeta = {};
2898
+ for (const [key, value] of Object.entries(record)) {
2899
+ if (key === 'children') continue;
2900
+ staleMeta[key] = value;
2901
+ }
2902
+ staleMeta._entity = 'media';
2903
+ staleMeta._stale404 = true;
2904
+ try {
2905
+ await writeFile(metaPath, JSON.stringify(staleMeta, null, 2) + '\n');
2906
+ if (serverTz && (record._CreatedOn || record._LastUpdated)) {
2907
+ await setFileTimestamps(metaPath, record._CreatedOn, record._LastUpdated, serverTz);
2908
+ }
2909
+ refs.push({ uid: record.UID, metaPath });
2910
+ } catch { /* non-critical */ }
2911
+ }
2912
+
2691
2913
  failed++;
2692
- continue; // Skip metadata if download failed
2914
+ continue; // Skip media write if download failed
2693
2915
  }
2694
2916
 
2695
2917
  // Build metadata
@@ -2788,7 +3010,7 @@ async function processRecord(entityName, record, structure, options, usedNames,
2788
3010
  const raw = await readFile(probeMeta, 'utf8');
2789
3011
  const localMeta = JSON.parse(raw);
2790
3012
  // Extract extension from Content @reference (e.g. "@Name~uid.html")
2791
- for (const col of (localMeta._contentColumns || ['Content'])) {
3013
+ for (const col of (localMeta._companionReferenceColumns || localMeta._contentColumns || ['Content'])) {
2792
3014
  const ref = localMeta[col];
2793
3015
  if (typeof ref === 'string' && ref.startsWith('@')) {
2794
3016
  const refExt = extname(ref);
@@ -3014,8 +3236,8 @@ async function processRecord(entityName, record, structure, options, usedNames,
3014
3236
  await writeFile(colFilePath, decoded);
3015
3237
  await upsertDeployEntry(colFilePath, record.UID, entityName, key);
3016
3238
  meta[key] = `@${colFileName}`;
3017
- if (!meta._contentColumns) meta._contentColumns = [];
3018
- meta._contentColumns.push(key);
3239
+ if (!meta._companionReferenceColumns) meta._companionReferenceColumns = [];
3240
+ meta._companionReferenceColumns.push(key);
3019
3241
  } else {
3020
3242
  meta[key] = decoded;
3021
3243
  }
@@ -3025,8 +3247,8 @@ async function processRecord(entityName, record, structure, options, usedNames,
3025
3247
  }
3026
3248
 
3027
3249
  meta._entity = entityName;
3028
- if (hasContent && !meta._contentColumns) {
3029
- meta._contentColumns = ['Content'];
3250
+ if (hasContent && !meta._companionReferenceColumns) {
3251
+ meta._companionReferenceColumns = ['Content'];
3030
3252
  }
3031
3253
 
3032
3254
  // Extension was derived from Name/Path for local filename purposes only.
@@ -3195,20 +3417,13 @@ async function resolveOutputFilenameColumns(appJson, options) {
3195
3417
  const result = {};
3196
3418
 
3197
3419
  for (const entityKey of OUTPUT_HIERARCHY_ENTITIES) {
3198
- // Check saved preference
3199
- const saved = await loadOutputFilenamePreference(entityKey);
3420
+ // Check saved preference (skip if --configure to allow re-prompting)
3421
+ const saved = options.configure ? null : await loadOutputFilenamePreference(entityKey);
3200
3422
  if (saved) {
3201
3423
  result[entityKey] = saved;
3202
3424
  continue;
3203
3425
  }
3204
3426
 
3205
- // In -y mode use defaults
3206
- if (options.yes) {
3207
- result[entityKey] = defaults[entityKey];
3208
- await saveOutputFilenamePreference(entityKey, defaults[entityKey]);
3209
- continue;
3210
- }
3211
-
3212
3427
  // Find a sample record to get available columns
3213
3428
  // Check top-level first, then look inside nested output children
3214
3429
  let records = appJson.children[entityKey] || [];
@@ -3235,18 +3450,25 @@ async function resolveOutputFilenameColumns(appJson, options) {
3235
3450
  if (columns.includes(fb)) { defaultCol = fb; break; }
3236
3451
  }
3237
3452
 
3238
- const inquirer = (await import('inquirer')).default;
3239
- const { col } = await inquirer.prompt([{
3240
- type: 'list',
3241
- name: 'col',
3242
- message: `Which column should be used as the filename for ${docNames[entityKey]} (${entityKey}) records?`,
3243
- choices: columns,
3244
- default: defaultCol,
3245
- }]);
3453
+ let col;
3454
+ if (options.configure) {
3455
+ // --configure: prompt user to pick
3456
+ const inquirer = (await import('inquirer')).default;
3457
+ ({ col } = await inquirer.prompt([{
3458
+ type: 'list',
3459
+ name: 'col',
3460
+ message: `Which column should be used as the filename for ${docNames[entityKey]} (${entityKey}) records?`,
3461
+ choices: columns,
3462
+ default: defaultCol,
3463
+ }]));
3464
+ } else {
3465
+ // Auto-apply best default silently
3466
+ col = defaultCol;
3467
+ }
3246
3468
 
3247
3469
  result[entityKey] = col;
3248
3470
  await saveOutputFilenamePreference(entityKey, col);
3249
- log.dim(` Saved filename column preference for ${entityKey}`);
3471
+ log.dim(` Using filename column "${col}" for ${entityKey}`);
3250
3472
  }
3251
3473
 
3252
3474
  return result;
@@ -3336,9 +3558,9 @@ async function extractCustomSqlIfNeeded(entityObj, companionStem, outputDir, ser
3336
3558
  await writeFile(companionPath, hasContent ? decoded : '', 'utf8');
3337
3559
  await upsertDeployEntry(companionPath, entityObj.UID || entityObj._uid, 'output', 'CustomSQL');
3338
3560
  entityObj.CustomSQL = `@${companionName}`;
3339
- entityObj._contentColumns = entityObj._contentColumns || [];
3340
- if (!entityObj._contentColumns.includes('CustomSQL')) {
3341
- entityObj._contentColumns.push('CustomSQL');
3561
+ entityObj._companionReferenceColumns = entityObj._companionReferenceColumns || [];
3562
+ if (!entityObj._companionReferenceColumns.includes('CustomSQL')) {
3563
+ entityObj._companionReferenceColumns.push('CustomSQL');
3342
3564
  }
3343
3565
 
3344
3566
  // Sync timestamps