@dboio/cli 0.8.0 → 0.9.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.
@@ -3,11 +3,13 @@ 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
5
  import { loadConfig, updateConfigWithApp, loadClonePlacement, saveClonePlacement, ensureGitignore, saveEntityDirPreference, loadEntityDirPreference, saveEntityContentExtractions, loadEntityContentExtractions, saveAppJsonBaseline, addDeleteEntry, loadCollisionResolutions, saveCollisionResolutions, loadSynchronize, saveSynchronize, saveAppModifyKey, loadTransactionKeyPreset, saveTransactionKeyPreset, loadOutputFilenamePreference, saveOutputFilenamePreference, saveCloneSource, loadCloneSource, saveDescriptorFilenamePreference, loadDescriptorFilenamePreference, saveDescriptorContentExtractions, loadDescriptorContentExtractions, saveExtensionDocumentationMDPlacement, loadExtensionDocumentationMDPlacement } from '../lib/config.js';
6
- import { buildBinHierarchy, resolveBinPath, createDirectories, saveStructureFile, getBinName, findBinByPath, BINS_DIR, DEFAULT_PROJECT_DIRS, ENTITY_DIR_MAP, OUTPUT_ENTITY_MAP, OUTPUT_HIERARCHY_ENTITIES, EXTENSION_DESCRIPTORS_DIR, EXTENSION_UNSUPPORTED_DIR, DOCUMENTATION_DIR, buildDescriptorMapping, saveDescriptorMapping, loadDescriptorMapping, resolveExtensionSubDir } from '../lib/structure.js';
6
+ import { buildBinHierarchy, resolveBinPath, createDirectories, saveStructureFile, getBinName, findBinByPath, BINS_DIR, DEFAULT_PROJECT_DIRS, ENTITY_DIR_NAMES, OUTPUT_ENTITY_MAP, OUTPUT_HIERARCHY_ENTITIES, EXTENSION_DESCRIPTORS_DIR, EXTENSION_UNSUPPORTED_DIR, DOCUMENTATION_DIR, buildDescriptorMapping, saveDescriptorMapping, loadDescriptorMapping, resolveExtensionSubDir } from '../lib/structure.js';
7
7
  import { log } from '../lib/logger.js';
8
+ import { buildUidFilename, detectLegacyDotUid } from '../lib/filenames.js';
8
9
  import { setFileTimestamps, parseServerDate } from '../lib/timestamps.js';
9
10
  import { getLocalSyncTime, isServerNewer, hasLocalModifications, promptChangeDetection, inlineDiffAndMerge, isDiffable } from '../lib/diff.js';
10
11
  import { checkDomainChange } from '../lib/domain-guard.js';
12
+ import { loadMetadataTemplates, saveMetadataTemplates, getTemplateCols, setTemplateCols, buildTemplateFromCloneRecord } from '../lib/metadata-templates.js';
11
13
 
12
14
  /**
13
15
  * Resolve a column value that may be base64-encoded.
@@ -32,6 +34,12 @@ async function fileExists(path) {
32
34
  try { await access(path); return true; } catch { return false; }
33
35
  }
34
36
 
37
+ const WILL_DELETE_PREFIX = '__WILL_DELETE__';
38
+
39
+ function isWillDeleteFile(filename) {
40
+ return basename(filename).startsWith(WILL_DELETE_PREFIX);
41
+ }
42
+
35
43
  /**
36
44
  * Resolve a content Path to a directory under Bins/.
37
45
  *
@@ -132,8 +140,10 @@ function resolveRecordPaths(entityName, record, structure, placementPref) {
132
140
  dir = dir.substring(0, dir.lastIndexOf('/')) || BINS_DIR;
133
141
  }
134
142
 
135
- const filename = ext ? `${name}.${ext}` : name;
136
- const metaPath = join(dir, `${name}.metadata.json`);
143
+ const uid = String(record.UID || record._id || 'untitled');
144
+ const base = buildUidFilename(name, uid);
145
+ const filename = ext ? `${base}.${ext}` : base;
146
+ const metaPath = join(dir, `${base}.metadata.json`);
137
147
 
138
148
  return { dir, filename, metaPath };
139
149
  }
@@ -169,7 +179,9 @@ function resolveMediaPaths(record, structure, placementPref) {
169
179
  dir = dir.replace(/^\/+|\/+$/g, '');
170
180
  if (!dir) dir = BINS_DIR;
171
181
 
172
- const finalFilename = `${name}.${ext}`;
182
+ const uid = String(record.UID || record._id || 'untitled');
183
+ const base = buildUidFilename(name, uid);
184
+ const finalFilename = `${base}.${ext}`;
173
185
  const metaPath = join(dir, `${finalFilename}.metadata.json`);
174
186
 
175
187
  return { dir, filename: finalFilename, metaPath };
@@ -190,7 +202,7 @@ function resolveEntityDirPaths(entityName, record, dirName) {
190
202
  }
191
203
 
192
204
  const uid = record.UID || 'untitled';
193
- const finalName = name === uid ? uid : `${name}.${uid}`;
205
+ const finalName = buildUidFilename(name, uid);
194
206
  const metaPath = join(dirName, `${finalName}.metadata.json`);
195
207
  return { dir: dirName, filename: finalName, metaPath };
196
208
  }
@@ -427,6 +439,7 @@ export const cloneCommand = new Command('clone')
427
439
  .option('--force', 'Force re-processing of all files, skip change detection')
428
440
  .option('--domain <host>', 'Override domain')
429
441
  .option('-y, --yes', 'Auto-accept all prompts')
442
+ .option('--media-placement <placement>', 'Set media placement: fullpath or binpath (default: bin)')
430
443
  .option('-v, --verbose', 'Show HTTP request details')
431
444
  .action(async (source, options) => {
432
445
  try {
@@ -444,10 +457,22 @@ export const cloneCommand = new Command('clone')
444
457
  async function resolveAppSource(source, options, config) {
445
458
  if (source) {
446
459
  if (source.startsWith('http://') || source.startsWith('https://')) {
447
- log.info(`Fetching app JSON from ${source}...`);
448
- const res = await fetch(source);
449
- if (!res.ok) throw new Error(`HTTP ${res.status} fetching ${source}`);
450
- return await res.json();
460
+ const ora = (await import('ora')).default;
461
+ const spinner = ora(`Fetching app JSON from ${source}...`).start();
462
+ let res;
463
+ try {
464
+ res = await fetch(source);
465
+ } catch (err) {
466
+ spinner.fail(`Failed to fetch from ${source}`);
467
+ throw err;
468
+ }
469
+ if (!res.ok) {
470
+ spinner.fail(`HTTP ${res.status} fetching ${source}`);
471
+ throw new Error(`HTTP ${res.status} fetching ${source}`);
472
+ }
473
+ const json = await res.json();
474
+ spinner.succeed('Loaded app JSON');
475
+ return json;
451
476
  }
452
477
  log.info(`Loading app JSON from ${source}...`);
453
478
  const raw = await readFile(source, 'utf8');
@@ -460,10 +485,22 @@ async function resolveAppSource(source, options, config) {
460
485
  if (storedSource && storedSource !== 'default') {
461
486
  // Stored source is a local file path or URL — reuse it
462
487
  if (storedSource.startsWith('http://') || storedSource.startsWith('https://')) {
463
- log.info(`Fetching app JSON from ${storedSource} (stored source)...`);
464
- const res = await fetch(storedSource);
465
- if (!res.ok) throw new Error(`HTTP ${res.status} fetching ${storedSource}`);
466
- return await res.json();
488
+ const ora = (await import('ora')).default;
489
+ const spinner = ora(`Fetching app JSON from ${storedSource} (stored source)...`).start();
490
+ let res;
491
+ try {
492
+ res = await fetch(storedSource);
493
+ } catch (err) {
494
+ spinner.fail(`Failed to fetch from ${storedSource}`);
495
+ throw err;
496
+ }
497
+ if (!res.ok) {
498
+ spinner.fail(`HTTP ${res.status} fetching ${storedSource}`);
499
+ throw new Error(`HTTP ${res.status} fetching ${storedSource}`);
500
+ }
501
+ const json = await res.json();
502
+ spinner.succeed('Loaded app JSON');
503
+ return json;
467
504
  }
468
505
  if (await fileExists(storedSource)) {
469
506
  log.info(`Loading app JSON from ${storedSource} (stored source)...`);
@@ -738,6 +775,29 @@ export async function performClone(source, options = {}) {
738
775
  for (const d of DEFAULT_PROJECT_DIRS) log.dim(` ${d}/`);
739
776
  for (const d of createdDirs) log.dim(` ${d}/`);
740
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
798
+ }
799
+ }
800
+
741
801
  // Step 4b: Determine placement preferences (from config or prompt)
742
802
  const placementPrefs = await resolvePlacementPreferences(appJson, options);
743
803
 
@@ -817,7 +877,7 @@ export async function performClone(source, options = {}) {
817
877
  if (refs.length > 0) {
818
878
  otherRefs[entityName] = refs;
819
879
  }
820
- } else if (ENTITY_DIR_MAP[entityName]) {
880
+ } else if (ENTITY_DIR_NAMES.has(entityName)) {
821
881
  // Entity types with project directories — process into their directory
822
882
  const refs = await processEntityDirEntries(entityName, entries, options, serverTz);
823
883
  if (refs.length > 0) {
@@ -887,8 +947,14 @@ async function resolvePlacementPreferences(appJson, options) {
887
947
  let contentPlacement = saved.contentPlacement;
888
948
  let mediaPlacement = saved.mediaPlacement;
889
949
 
950
+ // --media-placement flag takes precedence over saved config
951
+ if (options.mediaPlacement) {
952
+ mediaPlacement = options.mediaPlacement === 'fullpath' ? 'fullpath' : 'bin';
953
+ await saveClonePlacement({ contentPlacement: contentPlacement || 'bin', mediaPlacement });
954
+ log.dim(` MediaPlacement set to "${mediaPlacement}" via flag`);
955
+ }
956
+
890
957
  const hasContent = (appJson.children.content || []).length > 0;
891
- const hasMedia = (appJson.children.media || []).length > 0;
892
958
 
893
959
  // If -y flag, default to bin placement (no prompts)
894
960
  if (options.yes) {
@@ -906,6 +972,7 @@ async function resolvePlacementPreferences(appJson, options) {
906
972
  const inquirer = (await import('inquirer')).default;
907
973
  const prompts = [];
908
974
 
975
+ // Only prompt for contentPlacement — media placement is NOT prompted interactively
909
976
  if (!contentPlacement && hasContent) {
910
977
  prompts.push({
911
978
  type: 'list',
@@ -919,23 +986,14 @@ async function resolvePlacementPreferences(appJson, options) {
919
986
  });
920
987
  }
921
988
 
922
- if (!mediaPlacement && hasMedia) {
923
- prompts.push({
924
- type: 'list',
925
- name: 'mediaPlacement',
926
- message: 'How should media files be placed?',
927
- choices: [
928
- { name: 'Save all in BinID directory', value: 'bin' },
929
- { name: 'Save all in their specified FullPath directory', value: 'fullpath' },
930
- { name: 'Ask for every file that has both', value: 'ask' },
931
- ],
932
- });
989
+ // Media placement: no interactive prompt — default to 'bin'
990
+ if (!mediaPlacement) {
991
+ mediaPlacement = 'bin';
933
992
  }
934
993
 
935
994
  if (prompts.length > 0) {
936
995
  const answers = await inquirer.prompt(prompts);
937
996
  contentPlacement = contentPlacement || answers.contentPlacement || 'bin';
938
- mediaPlacement = mediaPlacement || answers.mediaPlacement || 'bin';
939
997
  }
940
998
 
941
999
  // Resolve defaults for any still-unset values
@@ -960,19 +1018,45 @@ async function resolvePlacementPreferences(appJson, options) {
960
1018
  */
961
1019
  async function fetchAppFromServer(appShortName, options, config) {
962
1020
  const client = new DboClient({ domain: options.domain, verbose: options.verbose });
963
- log.info(`Fetching app "${appShortName}" from server...`);
964
1021
 
965
- const result = await client.get(`/api/app/object/${appShortName}`);
1022
+ const ora = (await import('ora')).default;
1023
+ const spinner = ora(`Fetching app "${appShortName}" from server...`).start();
1024
+
1025
+ let result;
1026
+ try {
1027
+ result = await client.get(`/api/app/object/${appShortName}`);
1028
+ } catch (err) {
1029
+ spinner.fail(`Failed to fetch app "${appShortName}"`);
1030
+ throw err;
1031
+ }
966
1032
 
967
1033
  const data = result.payload || result.data;
968
- const rows = Array.isArray(data) ? data : (data?.Rows || data?.rows || []);
969
1034
 
970
- if (rows.length === 0) {
1035
+ // Handle all response shapes:
1036
+ // 1. Array of rows: [{ UID, ShortName, ... }]
1037
+ // 2. Object with Rows key: { Rows: [...] }
1038
+ // 3. Single app object: { UID, ShortName, children, ... }
1039
+ let appRecord;
1040
+ if (Array.isArray(data)) {
1041
+ appRecord = data.length > 0 ? data[0] : null;
1042
+ } else if (data?.Rows?.length > 0) {
1043
+ appRecord = data.Rows[0];
1044
+ } else if (data?.rows?.length > 0) {
1045
+ appRecord = data.rows[0];
1046
+ } else if (data && typeof data === 'object' && (data.UID || data.ShortName)) {
1047
+ // Single app object returned directly as payload
1048
+ appRecord = data;
1049
+ } else {
1050
+ appRecord = null;
1051
+ }
1052
+
1053
+ if (!appRecord) {
1054
+ spinner.fail(`No app found with ShortName "${appShortName}"`);
971
1055
  throw new Error(`No app found with ShortName "${appShortName}"`);
972
1056
  }
973
1057
 
974
- log.success(`Found app on server`);
975
- return rows[0];
1058
+ spinner.succeed(`Found app on server`);
1059
+ return appRecord;
976
1060
  }
977
1061
 
978
1062
  /**
@@ -1093,13 +1177,14 @@ async function processGenericEntries(entityName, entries, structure, options, co
1093
1177
  async function processEntityDirEntries(entityName, entries, options, serverTz) {
1094
1178
  if (!entries || entries.length === 0) return [];
1095
1179
 
1096
- const dirName = ENTITY_DIR_MAP[entityName];
1097
- if (!dirName) return [];
1180
+ const dirName = entityName;
1181
+ if (!ENTITY_DIR_NAMES.has(entityName)) return [];
1098
1182
 
1099
1183
  await mkdir(dirName, { recursive: true });
1100
1184
 
1101
1185
  const refs = [];
1102
1186
  const bulkAction = { value: null };
1187
+ const legacyRenameAction = { value: null }; // 'rename_all' | 'skip_all' | null
1103
1188
  const config = await loadConfig();
1104
1189
 
1105
1190
  // Determine filename column: saved preference, or prompt, or default
@@ -1247,18 +1332,53 @@ async function processEntityDirEntries(entityName, entries, options, serverTz) {
1247
1332
  name = sanitizeFilename(String(record.UID || 'untitled'));
1248
1333
  }
1249
1334
 
1250
- // Include UID in filename to ensure uniqueness (multiple records can share the same name)
1251
- // Convention: <name>.<UID>.metadata.json — unless name === UID, then just <UID>.metadata.json
1335
+ // Include UID in filename via tilde convention to ensure uniqueness
1252
1336
  const uid = record.UID || 'untitled';
1253
- const finalName = name === uid ? uid : `${name}.${uid}`;
1337
+ const finalName = buildUidFilename(name, uid);
1254
1338
 
1255
1339
  const metaPath = join(dirName, `${finalName}.metadata.json`);
1256
1340
 
1341
+ // Legacy dot-separator detection: rename <name>.<uid>.metadata.json → <name>~<uid>.metadata.json
1342
+ const legacyMetaPath = join(dirName, `${name}.${uid}.metadata.json`);
1343
+ if (!await fileExists(metaPath) && await fileExists(legacyMetaPath)) {
1344
+ if (options.yes || legacyRenameAction.value === 'rename_all') {
1345
+ const { rename: fsRename } = await import('fs/promises');
1346
+ await fsRename(legacyMetaPath, metaPath);
1347
+ log.dim(` Auto-renamed: ${basename(legacyMetaPath)} → ${basename(metaPath)}`);
1348
+ } else if (legacyRenameAction.value === 'skip_all') {
1349
+ // skip silently
1350
+ } else {
1351
+ const inquirer = (await import('inquirer')).default;
1352
+ const { action } = await inquirer.prompt([{
1353
+ type: 'list',
1354
+ name: 'action',
1355
+ message: `Found legacy filename "${basename(legacyMetaPath)}" — rename to "${basename(metaPath)}"?`,
1356
+ choices: [
1357
+ { name: 'Yes', value: 'rename' },
1358
+ { name: 'Rename all remaining', value: 'rename_all' },
1359
+ { name: 'Skip', value: 'skip' },
1360
+ { name: 'Skip all remaining', value: 'skip_all' },
1361
+ ],
1362
+ }]);
1363
+ if (action === 'rename' || action === 'rename_all') {
1364
+ const { rename: fsRename } = await import('fs/promises');
1365
+ await fsRename(legacyMetaPath, metaPath);
1366
+ log.success(` Renamed: ${basename(legacyMetaPath)} → ${basename(metaPath)}`);
1367
+ if (action === 'rename_all') legacyRenameAction.value = 'rename_all';
1368
+ } else {
1369
+ if (action === 'skip_all') legacyRenameAction.value = 'skip_all';
1370
+ }
1371
+ }
1372
+ }
1373
+
1257
1374
  // Change detection for existing files
1258
1375
  // Skip change detection when user has selected new content columns to extract —
1259
1376
  // we need to re-process all records to create the companion files
1260
1377
  const hasNewExtractions = contentColsToExtract.length > 0;
1261
- if (await fileExists(metaPath) && !options.yes && !hasNewExtractions) {
1378
+ // Skip __WILL_DELETE__-prefixed files treat as "no existing file"
1379
+ const willDeleteEntityMeta = join(dirName, `${WILL_DELETE_PREFIX}${basename(metaPath)}`);
1380
+ const entityMetaExists = await fileExists(metaPath) && !await fileExists(willDeleteEntityMeta);
1381
+ if (entityMetaExists && !options.yes && !hasNewExtractions) {
1262
1382
  if (bulkAction.value === 'skip_all') {
1263
1383
  log.dim(` Skipped ${finalName}`);
1264
1384
  refs.push({ uid: record.UID, metaPath });
@@ -1389,6 +1509,21 @@ async function processEntityDirEntries(entityName, entries, options, serverTz) {
1389
1509
  refs.push({ uid: record.UID, metaPath });
1390
1510
  }
1391
1511
 
1512
+ // --- Seed metadata template if not already present ---
1513
+ if (entries.length > 0) {
1514
+ const templates = await loadMetadataTemplates();
1515
+ if (templates !== null) {
1516
+ const existing = getTemplateCols(templates, entityName, null);
1517
+ if (!existing) {
1518
+ const firstRecord = entries[0];
1519
+ const extractedColNames = contentColsToExtract.map(c => c.col);
1520
+ const cols = buildTemplateFromCloneRecord(firstRecord, extractedColNames);
1521
+ setTemplateCols(templates, entityName, null, cols);
1522
+ await saveMetadataTemplates(templates);
1523
+ }
1524
+ }
1525
+ }
1526
+
1392
1527
  return refs;
1393
1528
  }
1394
1529
 
@@ -1564,8 +1699,8 @@ async function resolveDocumentationPlacement(options) {
1564
1699
  type: 'list', name: 'placement',
1565
1700
  message: 'Where should extracted documentation MD files be placed?',
1566
1701
  choices: [
1567
- { name: '/Documentation/<filename>.md — project root (recommended)', value: 'root' },
1568
- { name: 'Extensions/Documentation/<filename>.md — inline alongside metadata', value: 'inline' },
1702
+ { name: 'docs/<filename>.md — project root (recommended)', value: 'root' },
1703
+ { name: 'extension/documentation/<filename>.md — inline alongside metadata', value: 'inline' },
1569
1704
  ],
1570
1705
  default: 'root',
1571
1706
  }]);
@@ -1644,6 +1779,7 @@ async function processExtensionEntries(entries, structure, options, serverTz) {
1644
1779
  // Step D: Write files, one group at a time
1645
1780
  const refs = [];
1646
1781
  const bulkAction = { value: null };
1782
+ const legacyRenameAction = { value: null }; // 'rename_all' | 'skip_all' | null
1647
1783
  const config = await loadConfig();
1648
1784
 
1649
1785
  for (const [descriptor, { dir, records }] of groups.entries()) {
@@ -1665,12 +1801,48 @@ async function processExtensionEntries(entries, structure, options, serverTz) {
1665
1801
  }
1666
1802
 
1667
1803
  const uid = record.UID || 'untitled';
1668
- const finalName = name === uid ? uid : `${name}.${uid}`;
1804
+ const finalName = buildUidFilename(name, uid);
1669
1805
  const metaPath = join(dir, `${finalName}.metadata.json`);
1670
1806
 
1807
+ // Legacy dot-separator detection: rename <name>.<uid>.metadata.json → <name>~<uid>.metadata.json
1808
+ const legacyExtMetaPath = join(dir, `${name}.${uid}.metadata.json`);
1809
+ if (!await fileExists(metaPath) && await fileExists(legacyExtMetaPath)) {
1810
+ if (options.yes || legacyRenameAction.value === 'rename_all') {
1811
+ const { rename: fsRename } = await import('fs/promises');
1812
+ await fsRename(legacyExtMetaPath, metaPath);
1813
+ log.dim(` Auto-renamed: ${basename(legacyExtMetaPath)} → ${basename(metaPath)}`);
1814
+ } else if (legacyRenameAction.value === 'skip_all') {
1815
+ // skip silently
1816
+ } else {
1817
+ const inquirer = (await import('inquirer')).default;
1818
+ const { action } = await inquirer.prompt([{
1819
+ type: 'list',
1820
+ name: 'action',
1821
+ message: `Found legacy filename "${basename(legacyExtMetaPath)}" — rename to "${basename(metaPath)}"?`,
1822
+ choices: [
1823
+ { name: 'Yes', value: 'rename' },
1824
+ { name: 'Rename all remaining', value: 'rename_all' },
1825
+ { name: 'Skip', value: 'skip' },
1826
+ { name: 'Skip all remaining', value: 'skip_all' },
1827
+ ],
1828
+ }]);
1829
+ if (action === 'rename' || action === 'rename_all') {
1830
+ const { rename: fsRename } = await import('fs/promises');
1831
+ await fsRename(legacyExtMetaPath, metaPath);
1832
+ log.success(` Renamed: ${basename(legacyExtMetaPath)} → ${basename(metaPath)}`);
1833
+ if (action === 'rename_all') legacyRenameAction.value = 'rename_all';
1834
+ } else {
1835
+ if (action === 'skip_all') legacyRenameAction.value = 'skip_all';
1836
+ }
1837
+ }
1838
+ }
1839
+
1671
1840
  // Change detection — same pattern as processEntityDirEntries()
1672
1841
  const hasNewExtractions = contentColsToExtract.length > 0;
1673
- if (await fileExists(metaPath) && !options.yes && !hasNewExtractions) {
1842
+ // Skip __WILL_DELETE__-prefixed files treat as "no existing file"
1843
+ const willDeleteExtMeta = join(dir, `${WILL_DELETE_PREFIX}${basename(metaPath)}`);
1844
+ const extMetaExists = await fileExists(metaPath) && !await fileExists(willDeleteExtMeta);
1845
+ if (extMetaExists && !options.yes && !hasNewExtractions) {
1674
1846
  if (bulkAction.value === 'skip_all') {
1675
1847
  log.dim(` Skipped ${finalName}`);
1676
1848
  refs.push({ uid: record.UID, metaPath });
@@ -1720,8 +1892,8 @@ async function processExtensionEntries(entries, structure, options, serverTz) {
1720
1892
  let colFilePath, refValue;
1721
1893
 
1722
1894
  if (mdColInfo && extractInfo.col === mdColInfo.col) {
1723
- // Root placement: Documentation/<name>.md
1724
- const docFileName = `${name}.md`;
1895
+ // Root placement: docs/<name>~<uid>.md
1896
+ const docFileName = `${finalName}.md`;
1725
1897
  colFilePath = join(DOCUMENTATION_DIR, docFileName);
1726
1898
  refValue = `@/${DOCUMENTATION_DIR}/${docFileName}`;
1727
1899
  } else {
@@ -1759,6 +1931,21 @@ async function processExtensionEntries(entries, structure, options, serverTz) {
1759
1931
  log.success(`Saved ${metaPath}`);
1760
1932
  refs.push({ uid: record.UID, metaPath });
1761
1933
  }
1934
+
1935
+ // --- Seed metadata template for this descriptor ---
1936
+ if (records.length > 0) {
1937
+ const templates = await loadMetadataTemplates();
1938
+ if (templates !== null) {
1939
+ const existing = getTemplateCols(templates, 'extension', descriptor);
1940
+ if (!existing) {
1941
+ const firstRecord = records[0];
1942
+ const extractedCols = contentColsToExtract.map(c => c.col);
1943
+ const cols = buildTemplateFromCloneRecord(firstRecord, extractedCols);
1944
+ setTemplateCols(templates, 'extension', descriptor, cols);
1945
+ await saveMetadataTemplates(templates);
1946
+ }
1947
+ }
1948
+ }
1762
1949
  }
1763
1950
 
1764
1951
  return refs;
@@ -1886,27 +2073,21 @@ async function processMediaEntries(mediaRecords, structure, options, config, app
1886
2073
  if (!dir) dir = BINS_DIR;
1887
2074
  await mkdir(dir, { recursive: true });
1888
2075
 
1889
- // Deduplicate using full filename (name.ext) so different formats don't collide
1890
- // e.g. KaTeX_SansSerif-Italic.woff and KaTeX_SansSerif-Italic.woff2 are separate files
1891
- const fileKey = `${dir}/${name}.${ext}`;
1892
- const fileCount = usedNames.get(fileKey) || 0;
1893
- usedNames.set(fileKey, fileCount + 1);
1894
- let dedupName;
1895
- if (fileCount > 0) {
1896
- // Duplicate name — include UID for uniqueness
1897
- const uid = record.UID || 'untitled';
1898
- dedupName = name === uid ? uid : `${name}.${uid}`;
1899
- } else {
1900
- dedupName = name;
1901
- }
1902
- const finalFilename = `${dedupName}.${ext}`;
1903
- // Metadata: use name.ext as base to avoid collisions between formats
1904
- // e.g. KaTeX_SansSerif-Italic.woff.metadata.json vs KaTeX_SansSerif-Italic.woff2.metadata.json
2076
+ // Always include UID in filename via tilde convention
2077
+ const uid = String(record.UID || record._id || 'untitled');
2078
+ const base = buildUidFilename(name, uid);
2079
+ const finalFilename = `${base}.${ext}`;
1905
2080
  const filePath = join(dir, finalFilename);
1906
2081
  const metaPath = join(dir, `${finalFilename}.metadata.json`);
2082
+ // usedNames retained for tracking
2083
+ const fileKey = `${dir}/${name}.${ext}`;
2084
+ usedNames.set(fileKey, (usedNames.get(fileKey) || 0) + 1);
1907
2085
 
1908
2086
  // Change detection for existing media files
1909
- if (await fileExists(metaPath) && !options.yes) {
2087
+ // Skip __WILL_DELETE__-prefixed files treat as "no existing file"
2088
+ const willDeleteMediaMeta = join(dir, `${WILL_DELETE_PREFIX}${basename(metaPath)}`);
2089
+ const mediaMetaExists = await fileExists(metaPath) && !await fileExists(willDeleteMediaMeta);
2090
+ if (mediaMetaExists && !options.yes) {
1910
2091
  if (mediaBulkAction.value === 'skip_all') {
1911
2092
  log.dim(` Skipped ${finalFilename}`);
1912
2093
  refs.push({ uid: record.UID, metaPath });
@@ -2114,6 +2295,41 @@ async function processRecord(entityName, record, structure, options, usedNames,
2114
2295
  }
2115
2296
  // If still no extension, ext remains '' (no extension)
2116
2297
 
2298
+ // If no extension determined and Content column has data, prompt user to choose one
2299
+ if (!ext && !options.yes && record.Content) {
2300
+ const cv = record.Content;
2301
+ const hasContentData = cv && (
2302
+ (typeof cv === 'object' && cv.value !== null && cv.value !== undefined) ||
2303
+ (typeof cv === 'string' && cv.length > 0)
2304
+ );
2305
+ if (hasContentData) {
2306
+ // Decode a snippet for preview
2307
+ let snippet = '';
2308
+ try {
2309
+ const decoded = resolveContentValue(cv);
2310
+ if (decoded) {
2311
+ snippet = decoded.substring(0, 80).replace(/\n/g, ' ').replace(/\s+/g, ' ').trim();
2312
+ if (decoded.length > 80) snippet += '...';
2313
+ }
2314
+ } catch { /* ignore decode errors */ }
2315
+ const preview = snippet ? ` (${snippet})` : '';
2316
+ const VALID_CONTENT_EXTENSIONS = ['css', 'js', 'html', 'xml', 'txt', 'md', 'cs', 'json', 'sql'];
2317
+ const inquirer = (await import('inquirer')).default;
2318
+ const { chosenExt } = await inquirer.prompt([{
2319
+ type: 'list',
2320
+ name: 'chosenExt',
2321
+ message: `No extension found for "${record.Name || record.UID}". Choose a file extension for the Content:${preview}`,
2322
+ choices: [
2323
+ ...VALID_CONTENT_EXTENSIONS.map(e => ({ name: `.${e}`, value: e })),
2324
+ { name: 'No extension (skip)', value: '' },
2325
+ ],
2326
+ }]);
2327
+ if (chosenExt) {
2328
+ ext = chosenExt;
2329
+ }
2330
+ }
2331
+ }
2332
+
2117
2333
  // Avoid double extension: if name already ends with .ext, strip it
2118
2334
  if (ext) {
2119
2335
  const extWithDot = `.${ext}`;
@@ -2181,18 +2397,12 @@ async function processRecord(entityName, record, structure, options, usedNames,
2181
2397
 
2182
2398
  await mkdir(dir, { recursive: true });
2183
2399
 
2184
- // Deduplicate filenames use UID naming when duplicates exist
2400
+ // Always include UID in filename via tilde convention
2401
+ const uid = String(record.UID || record._id || 'untitled');
2402
+ const finalName = buildUidFilename(name, uid);
2403
+ // usedNames retained for non-UID edge case tracking
2185
2404
  const nameKey = `${dir}/${name}`;
2186
- const count = usedNames.get(nameKey) || 0;
2187
- usedNames.set(nameKey, count + 1);
2188
- let finalName;
2189
- if (count > 0) {
2190
- // Duplicate name — include UID for uniqueness
2191
- const uid = record.UID || 'untitled';
2192
- finalName = name === uid ? uid : `${name}.${uid}`;
2193
- } else {
2194
- finalName = name;
2195
- }
2405
+ usedNames.set(nameKey, (usedNames.get(nameKey) || 0) + 1);
2196
2406
 
2197
2407
  // Write content file if Content column has data
2198
2408
  const contentValue = record.Content;
@@ -2206,7 +2416,10 @@ async function processRecord(entityName, record, structure, options, usedNames,
2206
2416
  const metaPath = join(dir, `${finalName}.metadata.json`);
2207
2417
 
2208
2418
  // Change detection: check if file already exists locally
2209
- if (await fileExists(metaPath) && !options.yes) {
2419
+ // Skip __WILL_DELETE__-prefixed files treat as "no existing file"
2420
+ const willDeleteMeta = join(dir, `${WILL_DELETE_PREFIX}${basename(metaPath)}`);
2421
+ const metaExistsForChangeDetect = await fileExists(metaPath) && !await fileExists(willDeleteMeta);
2422
+ if (metaExistsForChangeDetect && !options.yes) {
2210
2423
  if (bulkAction.value === 'skip_all') {
2211
2424
  log.dim(` Skipped ${finalName}.${ext}`);
2212
2425
  return { uid: record.UID, metaPath };
@@ -2320,6 +2533,13 @@ async function processRecord(entityName, record, structure, options, usedNames,
2320
2533
  meta._contentColumns = ['Content'];
2321
2534
  }
2322
2535
 
2536
+ // If the extension picker chose an extension (record.Extension was null),
2537
+ // set it in metadata only — not in the record — so the baseline preserves
2538
+ // the server's null and push detects the change.
2539
+ if (ext && !record.Extension) {
2540
+ meta.Extension = ext;
2541
+ }
2542
+
2323
2543
  await writeFile(metaPath, JSON.stringify(meta, null, 2) + '\n');
2324
2544
  log.dim(` → ${metaPath}`);
2325
2545
 
@@ -2360,12 +2580,22 @@ export function guessExtensionForColumn(columnName) {
2360
2580
  */
2361
2581
  export function buildOutputHierarchyTree(appJson) {
2362
2582
  const outputs = appJson.children.output || [];
2363
- const columns = appJson.children.output_value || [];
2364
- const filters = appJson.children.output_value_filter || [];
2365
- const joins = appJson.children.output_value_entity_column_rel || [];
2366
2583
 
2367
2584
  if (outputs.length === 0) return [];
2368
2585
 
2586
+ // Collect columns/filters/joins from both top-level arrays (if present)
2587
+ // and from each output record's nested .children object.
2588
+ const columns = [...(appJson.children.output_value || [])];
2589
+ const filters = [...(appJson.children.output_value_filter || [])];
2590
+ const joins = [...(appJson.children.output_value_entity_column_rel || [])];
2591
+
2592
+ for (const o of outputs) {
2593
+ if (!o.children) continue;
2594
+ if (Array.isArray(o.children.output_value)) columns.push(...o.children.output_value);
2595
+ if (Array.isArray(o.children.output_value_filter)) filters.push(...o.children.output_value_filter);
2596
+ if (Array.isArray(o.children.output_value_entity_column_rel)) joins.push(...o.children.output_value_entity_column_rel);
2597
+ }
2598
+
2369
2599
  // Index all entities by their numeric ID for O(1) lookups
2370
2600
  const outputById = new Map();
2371
2601
  const columnById = new Map();
@@ -2487,7 +2717,17 @@ async function resolveOutputFilenameColumns(appJson, options) {
2487
2717
  }
2488
2718
 
2489
2719
  // Find a sample record to get available columns
2490
- const records = appJson.children[entityKey] || [];
2720
+ // Check top-level first, then look inside nested output children
2721
+ let records = appJson.children[entityKey] || [];
2722
+ if (records.length === 0 && entityKey !== 'output') {
2723
+ const outputs = appJson.children.output || [];
2724
+ for (const o of outputs) {
2725
+ if (o.children && Array.isArray(o.children[entityKey]) && o.children[entityKey].length > 0) {
2726
+ records = o.children[entityKey];
2727
+ break;
2728
+ }
2729
+ }
2730
+ }
2491
2731
  if (records.length === 0) {
2492
2732
  result[entityKey] = defaults[entityKey];
2493
2733
  continue;
@@ -2811,18 +3051,24 @@ async function processOutputHierarchy(appJson, structure, options, serverTz) {
2811
3051
  for (const [key, value] of Object.entries(output)) {
2812
3052
  if (key === '_children') continue;
2813
3053
 
2814
- // Always extract CustomSQL to companion .sql file
3054
+ // Extract CustomSQL to companion .sql file when Type is CustomSQL (even if empty)
3055
+ // or when the column has actual content
2815
3056
  if (key === 'CustomSQL') {
2816
3057
  const decoded = resolveContentValue(value);
2817
- const sqlContent = (decoded && decoded.trim()) ? decoded : '';
2818
- const sqlFilePath = rootMetaPath.replace(/\.json$/, '.CustomSQL.sql');
2819
- await writeFile(sqlFilePath, sqlContent);
2820
- rootMeta[key] = `@${basename(sqlFilePath)}`;
2821
- rootContentColumns.push('CustomSQL');
2822
- if (serverTz && (output._CreatedOn || output._LastUpdated)) {
2823
- try { await setFileTimestamps(sqlFilePath, output._CreatedOn, output._LastUpdated, serverTz); } catch { /* non-critical */ }
3058
+ const hasContent = decoded && decoded.trim();
3059
+ if (output.Type === 'CustomSQL' || hasContent) {
3060
+ const sqlFilePath = rootMetaPath.replace(/\.json$/, '.CustomSQL.sql');
3061
+ await writeFile(sqlFilePath, hasContent ? decoded : '');
3062
+ rootMeta[key] = `@${basename(sqlFilePath)}`;
3063
+ rootContentColumns.push('CustomSQL');
3064
+ if (serverTz && (output._CreatedOn || output._LastUpdated)) {
3065
+ try { await setFileTimestamps(sqlFilePath, output._CreatedOn, output._LastUpdated, serverTz); } catch { /* non-critical */ }
3066
+ }
3067
+ log.dim(` → ${sqlFilePath}`);
3068
+ continue;
2824
3069
  }
2825
- log.dim(` → ${sqlFilePath}`);
3070
+ // Not CustomSQL type and empty — store inline
3071
+ rootMeta[key] = '';
2826
3072
  continue;
2827
3073
  }
2828
3074
 
@@ -2872,21 +3118,27 @@ async function writeOutputEntityFile(node, physicalEntity, filePath, serverTz) {
2872
3118
  for (const [key, value] of Object.entries(node)) {
2873
3119
  if (key === '_children') continue;
2874
3120
 
2875
- // Always extract CustomSQL to companion .sql file
3121
+ // Extract CustomSQL to companion .sql file when Type is CustomSQL (even if empty)
3122
+ // or when the column has actual content
2876
3123
  if (key === 'CustomSQL') {
2877
3124
  const decoded = resolveContentValue(value);
2878
- const sqlContent = (decoded && decoded.trim()) ? decoded : '';
2879
- const sqlFilePath = filePath.replace(/\.json$/, '.CustomSQL.sql');
2880
- await writeFile(sqlFilePath, sqlContent);
2881
- meta[key] = `@${basename(sqlFilePath)}`;
2882
- contentColumns.push('CustomSQL');
2883
-
2884
- if (serverTz && (node._CreatedOn || node._LastUpdated)) {
2885
- try {
2886
- await setFileTimestamps(sqlFilePath, node._CreatedOn, node._LastUpdated, serverTz);
2887
- } catch { /* non-critical */ }
3125
+ const hasContent = decoded && decoded.trim();
3126
+ if (node.Type === 'CustomSQL' || hasContent) {
3127
+ const sqlFilePath = filePath.replace(/\.json$/, '.CustomSQL.sql');
3128
+ await writeFile(sqlFilePath, hasContent ? decoded : '');
3129
+ meta[key] = `@${basename(sqlFilePath)}`;
3130
+ contentColumns.push('CustomSQL');
3131
+
3132
+ if (serverTz && (node._CreatedOn || node._LastUpdated)) {
3133
+ try {
3134
+ await setFileTimestamps(sqlFilePath, node._CreatedOn, node._LastUpdated, serverTz);
3135
+ } catch { /* non-critical */ }
3136
+ }
3137
+ log.dim(` → ${sqlFilePath}`);
3138
+ continue;
2888
3139
  }
2889
- log.dim(` → ${sqlFilePath}`);
3140
+ // Not CustomSQL type and empty — store inline
3141
+ meta[key] = '';
2890
3142
  continue;
2891
3143
  }
2892
3144