@dboio/cli 0.8.2 → 0.9.3

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 {
@@ -762,6 +775,29 @@ export async function performClone(source, options = {}) {
762
775
  for (const d of DEFAULT_PROJECT_DIRS) log.dim(` ${d}/`);
763
776
  for (const d of createdDirs) log.dim(` ${d}/`);
764
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
+
765
801
  // Step 4b: Determine placement preferences (from config or prompt)
766
802
  const placementPrefs = await resolvePlacementPreferences(appJson, options);
767
803
 
@@ -841,7 +877,7 @@ export async function performClone(source, options = {}) {
841
877
  if (refs.length > 0) {
842
878
  otherRefs[entityName] = refs;
843
879
  }
844
- } else if (ENTITY_DIR_MAP[entityName]) {
880
+ } else if (ENTITY_DIR_NAMES.has(entityName)) {
845
881
  // Entity types with project directories — process into their directory
846
882
  const refs = await processEntityDirEntries(entityName, entries, options, serverTz);
847
883
  if (refs.length > 0) {
@@ -911,8 +947,14 @@ async function resolvePlacementPreferences(appJson, options) {
911
947
  let contentPlacement = saved.contentPlacement;
912
948
  let mediaPlacement = saved.mediaPlacement;
913
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
+
914
957
  const hasContent = (appJson.children.content || []).length > 0;
915
- const hasMedia = (appJson.children.media || []).length > 0;
916
958
 
917
959
  // If -y flag, default to bin placement (no prompts)
918
960
  if (options.yes) {
@@ -930,6 +972,7 @@ async function resolvePlacementPreferences(appJson, options) {
930
972
  const inquirer = (await import('inquirer')).default;
931
973
  const prompts = [];
932
974
 
975
+ // Only prompt for contentPlacement — media placement is NOT prompted interactively
933
976
  if (!contentPlacement && hasContent) {
934
977
  prompts.push({
935
978
  type: 'list',
@@ -943,23 +986,14 @@ async function resolvePlacementPreferences(appJson, options) {
943
986
  });
944
987
  }
945
988
 
946
- if (!mediaPlacement && hasMedia) {
947
- prompts.push({
948
- type: 'list',
949
- name: 'mediaPlacement',
950
- message: 'How should media files be placed?',
951
- choices: [
952
- { name: 'Save all in BinID directory', value: 'bin' },
953
- { name: 'Save all in their specified FullPath directory', value: 'fullpath' },
954
- { name: 'Ask for every file that has both', value: 'ask' },
955
- ],
956
- });
989
+ // Media placement: no interactive prompt — default to 'bin'
990
+ if (!mediaPlacement) {
991
+ mediaPlacement = 'bin';
957
992
  }
958
993
 
959
994
  if (prompts.length > 0) {
960
995
  const answers = await inquirer.prompt(prompts);
961
996
  contentPlacement = contentPlacement || answers.contentPlacement || 'bin';
962
- mediaPlacement = mediaPlacement || answers.mediaPlacement || 'bin';
963
997
  }
964
998
 
965
999
  // Resolve defaults for any still-unset values
@@ -1143,13 +1177,14 @@ async function processGenericEntries(entityName, entries, structure, options, co
1143
1177
  async function processEntityDirEntries(entityName, entries, options, serverTz) {
1144
1178
  if (!entries || entries.length === 0) return [];
1145
1179
 
1146
- const dirName = ENTITY_DIR_MAP[entityName];
1147
- if (!dirName) return [];
1180
+ const dirName = entityName;
1181
+ if (!ENTITY_DIR_NAMES.has(entityName)) return [];
1148
1182
 
1149
1183
  await mkdir(dirName, { recursive: true });
1150
1184
 
1151
1185
  const refs = [];
1152
1186
  const bulkAction = { value: null };
1187
+ const legacyRenameAction = { value: null }; // 'rename_all' | 'skip_all' | null
1153
1188
  const config = await loadConfig();
1154
1189
 
1155
1190
  // Determine filename column: saved preference, or prompt, or default
@@ -1297,18 +1332,53 @@ async function processEntityDirEntries(entityName, entries, options, serverTz) {
1297
1332
  name = sanitizeFilename(String(record.UID || 'untitled'));
1298
1333
  }
1299
1334
 
1300
- // Include UID in filename to ensure uniqueness (multiple records can share the same name)
1301
- // Convention: <name>.<UID>.metadata.json — unless name === UID, then just <UID>.metadata.json
1335
+ // Include UID in filename via tilde convention to ensure uniqueness
1302
1336
  const uid = record.UID || 'untitled';
1303
- const finalName = name === uid ? uid : `${name}.${uid}`;
1337
+ const finalName = buildUidFilename(name, uid);
1304
1338
 
1305
1339
  const metaPath = join(dirName, `${finalName}.metadata.json`);
1306
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
+
1307
1374
  // Change detection for existing files
1308
1375
  // Skip change detection when user has selected new content columns to extract —
1309
1376
  // we need to re-process all records to create the companion files
1310
1377
  const hasNewExtractions = contentColsToExtract.length > 0;
1311
- 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) {
1312
1382
  if (bulkAction.value === 'skip_all') {
1313
1383
  log.dim(` Skipped ${finalName}`);
1314
1384
  refs.push({ uid: record.UID, metaPath });
@@ -1439,6 +1509,21 @@ async function processEntityDirEntries(entityName, entries, options, serverTz) {
1439
1509
  refs.push({ uid: record.UID, metaPath });
1440
1510
  }
1441
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
+
1442
1527
  return refs;
1443
1528
  }
1444
1529
 
@@ -1614,8 +1699,8 @@ async function resolveDocumentationPlacement(options) {
1614
1699
  type: 'list', name: 'placement',
1615
1700
  message: 'Where should extracted documentation MD files be placed?',
1616
1701
  choices: [
1617
- { name: '/Documentation/<filename>.md — project root (recommended)', value: 'root' },
1618
- { 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' },
1619
1704
  ],
1620
1705
  default: 'root',
1621
1706
  }]);
@@ -1694,6 +1779,7 @@ async function processExtensionEntries(entries, structure, options, serverTz) {
1694
1779
  // Step D: Write files, one group at a time
1695
1780
  const refs = [];
1696
1781
  const bulkAction = { value: null };
1782
+ const legacyRenameAction = { value: null }; // 'rename_all' | 'skip_all' | null
1697
1783
  const config = await loadConfig();
1698
1784
 
1699
1785
  for (const [descriptor, { dir, records }] of groups.entries()) {
@@ -1715,12 +1801,48 @@ async function processExtensionEntries(entries, structure, options, serverTz) {
1715
1801
  }
1716
1802
 
1717
1803
  const uid = record.UID || 'untitled';
1718
- const finalName = name === uid ? uid : `${name}.${uid}`;
1804
+ const finalName = buildUidFilename(name, uid);
1719
1805
  const metaPath = join(dir, `${finalName}.metadata.json`);
1720
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
+
1721
1840
  // Change detection — same pattern as processEntityDirEntries()
1722
1841
  const hasNewExtractions = contentColsToExtract.length > 0;
1723
- 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) {
1724
1846
  if (bulkAction.value === 'skip_all') {
1725
1847
  log.dim(` Skipped ${finalName}`);
1726
1848
  refs.push({ uid: record.UID, metaPath });
@@ -1770,8 +1892,8 @@ async function processExtensionEntries(entries, structure, options, serverTz) {
1770
1892
  let colFilePath, refValue;
1771
1893
 
1772
1894
  if (mdColInfo && extractInfo.col === mdColInfo.col) {
1773
- // Root placement: Documentation/<name>.md
1774
- const docFileName = `${name}.md`;
1895
+ // Root placement: docs/<name>~<uid>.md
1896
+ const docFileName = `${finalName}.md`;
1775
1897
  colFilePath = join(DOCUMENTATION_DIR, docFileName);
1776
1898
  refValue = `@/${DOCUMENTATION_DIR}/${docFileName}`;
1777
1899
  } else {
@@ -1809,6 +1931,21 @@ async function processExtensionEntries(entries, structure, options, serverTz) {
1809
1931
  log.success(`Saved ${metaPath}`);
1810
1932
  refs.push({ uid: record.UID, metaPath });
1811
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
+ }
1812
1949
  }
1813
1950
 
1814
1951
  return refs;
@@ -1936,27 +2073,21 @@ async function processMediaEntries(mediaRecords, structure, options, config, app
1936
2073
  if (!dir) dir = BINS_DIR;
1937
2074
  await mkdir(dir, { recursive: true });
1938
2075
 
1939
- // Deduplicate using full filename (name.ext) so different formats don't collide
1940
- // e.g. KaTeX_SansSerif-Italic.woff and KaTeX_SansSerif-Italic.woff2 are separate files
1941
- const fileKey = `${dir}/${name}.${ext}`;
1942
- const fileCount = usedNames.get(fileKey) || 0;
1943
- usedNames.set(fileKey, fileCount + 1);
1944
- let dedupName;
1945
- if (fileCount > 0) {
1946
- // Duplicate name — include UID for uniqueness
1947
- const uid = record.UID || 'untitled';
1948
- dedupName = name === uid ? uid : `${name}.${uid}`;
1949
- } else {
1950
- dedupName = name;
1951
- }
1952
- const finalFilename = `${dedupName}.${ext}`;
1953
- // Metadata: use name.ext as base to avoid collisions between formats
1954
- // 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}`;
1955
2080
  const filePath = join(dir, finalFilename);
1956
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);
1957
2085
 
1958
2086
  // Change detection for existing media files
1959
- 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) {
1960
2091
  if (mediaBulkAction.value === 'skip_all') {
1961
2092
  log.dim(` Skipped ${finalFilename}`);
1962
2093
  refs.push({ uid: record.UID, metaPath });
@@ -2266,18 +2397,12 @@ async function processRecord(entityName, record, structure, options, usedNames,
2266
2397
 
2267
2398
  await mkdir(dir, { recursive: true });
2268
2399
 
2269
- // 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
2270
2404
  const nameKey = `${dir}/${name}`;
2271
- const count = usedNames.get(nameKey) || 0;
2272
- usedNames.set(nameKey, count + 1);
2273
- let finalName;
2274
- if (count > 0) {
2275
- // Duplicate name — include UID for uniqueness
2276
- const uid = record.UID || 'untitled';
2277
- finalName = name === uid ? uid : `${name}.${uid}`;
2278
- } else {
2279
- finalName = name;
2280
- }
2405
+ usedNames.set(nameKey, (usedNames.get(nameKey) || 0) + 1);
2281
2406
 
2282
2407
  // Write content file if Content column has data
2283
2408
  const contentValue = record.Content;
@@ -2291,7 +2416,10 @@ async function processRecord(entityName, record, structure, options, usedNames,
2291
2416
  const metaPath = join(dir, `${finalName}.metadata.json`);
2292
2417
 
2293
2418
  // Change detection: check if file already exists locally
2294
- 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) {
2295
2423
  if (bulkAction.value === 'skip_all') {
2296
2424
  log.dim(` Skipped ${finalName}.${ext}`);
2297
2425
  return { uid: record.UID, metaPath };
@@ -2452,12 +2580,22 @@ export function guessExtensionForColumn(columnName) {
2452
2580
  */
2453
2581
  export function buildOutputHierarchyTree(appJson) {
2454
2582
  const outputs = appJson.children.output || [];
2455
- const columns = appJson.children.output_value || [];
2456
- const filters = appJson.children.output_value_filter || [];
2457
- const joins = appJson.children.output_value_entity_column_rel || [];
2458
2583
 
2459
2584
  if (outputs.length === 0) return [];
2460
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
+
2461
2599
  // Index all entities by their numeric ID for O(1) lookups
2462
2600
  const outputById = new Map();
2463
2601
  const columnById = new Map();
@@ -2579,7 +2717,17 @@ async function resolveOutputFilenameColumns(appJson, options) {
2579
2717
  }
2580
2718
 
2581
2719
  // Find a sample record to get available columns
2582
- 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
+ }
2583
2731
  if (records.length === 0) {
2584
2732
  result[entityKey] = defaults[entityKey];
2585
2733
  continue;
@@ -2903,18 +3051,24 @@ async function processOutputHierarchy(appJson, structure, options, serverTz) {
2903
3051
  for (const [key, value] of Object.entries(output)) {
2904
3052
  if (key === '_children') continue;
2905
3053
 
2906
- // 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
2907
3056
  if (key === 'CustomSQL') {
2908
3057
  const decoded = resolveContentValue(value);
2909
- const sqlContent = (decoded && decoded.trim()) ? decoded : '';
2910
- const sqlFilePath = rootMetaPath.replace(/\.json$/, '.CustomSQL.sql');
2911
- await writeFile(sqlFilePath, sqlContent);
2912
- rootMeta[key] = `@${basename(sqlFilePath)}`;
2913
- rootContentColumns.push('CustomSQL');
2914
- if (serverTz && (output._CreatedOn || output._LastUpdated)) {
2915
- 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;
2916
3069
  }
2917
- log.dim(` → ${sqlFilePath}`);
3070
+ // Not CustomSQL type and empty — store inline
3071
+ rootMeta[key] = '';
2918
3072
  continue;
2919
3073
  }
2920
3074
 
@@ -2964,21 +3118,27 @@ async function writeOutputEntityFile(node, physicalEntity, filePath, serverTz) {
2964
3118
  for (const [key, value] of Object.entries(node)) {
2965
3119
  if (key === '_children') continue;
2966
3120
 
2967
- // 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
2968
3123
  if (key === 'CustomSQL') {
2969
3124
  const decoded = resolveContentValue(value);
2970
- const sqlContent = (decoded && decoded.trim()) ? decoded : '';
2971
- const sqlFilePath = filePath.replace(/\.json$/, '.CustomSQL.sql');
2972
- await writeFile(sqlFilePath, sqlContent);
2973
- meta[key] = `@${basename(sqlFilePath)}`;
2974
- contentColumns.push('CustomSQL');
2975
-
2976
- if (serverTz && (node._CreatedOn || node._LastUpdated)) {
2977
- try {
2978
- await setFileTimestamps(sqlFilePath, node._CreatedOn, node._LastUpdated, serverTz);
2979
- } 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;
2980
3139
  }
2981
- log.dim(` → ${sqlFilePath}`);
3140
+ // Not CustomSQL type and empty — store inline
3141
+ meta[key] = '';
2982
3142
  continue;
2983
3143
  }
2984
3144
 
@@ -178,7 +178,7 @@ export const deployCommand = new Command('deploy')
178
178
  log.success(`${entryName} deployed`);
179
179
  } else {
180
180
  log.error(`${entryName} failed`);
181
- formatResponse(result, { json: options.json });
181
+ formatResponse(result, { json: options.json, verbose: options.verbose });
182
182
  if (!options.all) process.exit(1);
183
183
  }
184
184
  }
@@ -20,8 +20,9 @@ export const initCommand = new Command('init')
20
20
  .option('--non-interactive', 'Skip all interactive prompts')
21
21
  .option('-g, --global', 'Install Claude commands to user home directory (~/.claude/commands/)')
22
22
  .option('--local', 'Install Claude commands to project directory (.claude/commands/)')
23
- .option('--scaffold', 'Create standard project directories (App Versions, Automations, Bins, …)')
23
+ .option('--scaffold', 'Create standard project directories (app_version, automation, bins, …)')
24
24
  .option('--dboignore', 'Create or reset .dboignore to defaults (use with --force to overwrite)')
25
+ .option('--media-placement <placement>', 'Set media placement when cloning: fullpath or binpath (default: bin)')
25
26
  .action(async (options) => {
26
27
  // Merge --yes into nonInteractive
27
28
  if (options.yes) options.nonInteractive = true;
@@ -98,7 +99,7 @@ export const initCommand = new Command('init')
98
99
  }
99
100
 
100
101
  // Ensure sensitive files are gitignored
101
- await ensureGitignore(['.dbo/credentials.json', '.dbo/cookies.txt', '.dbo/config.local.json', '.dbo/ticketing.local.json']);
102
+ await ensureGitignore(['.dbo/credentials.json', '.dbo/cookies.txt', '.dbo/config.local.json', '.dbo/ticketing.local.json', 'trash/']);
102
103
 
103
104
  const createdIgnore = await createDboignore();
104
105
  if (createdIgnore) log.dim(' Created .dboignore');
@@ -186,7 +187,7 @@ export const initCommand = new Command('init')
186
187
  }
187
188
 
188
189
  const { performClone } = await import('./clone.js');
189
- await performClone(null, { app: appShortName, domain });
190
+ await performClone(null, { app: appShortName, domain, mediaPlacement: options.mediaPlacement });
190
191
  }
191
192
 
192
193
  // Offer Claude Code integration (skip in non-interactive mode)
@@ -124,7 +124,7 @@ export const inputCommand = new Command('input')
124
124
  result = await client.postMultipart('/api/input/submit', fields, files);
125
125
  }
126
126
 
127
- formatResponse(result, { json: options.json, jq: options.jq });
127
+ formatResponse(result, { json: options.json, jq: options.jq, verbose: options.verbose });
128
128
  if (!result.successful) process.exit(1);
129
129
  } else {
130
130
  // URL-encoded mode
@@ -153,7 +153,7 @@ export const inputCommand = new Command('input')
153
153
  result = await client.postUrlEncoded('/api/input/submit', body);
154
154
  }
155
155
 
156
- formatResponse(result, { json: options.json, jq: options.jq });
156
+ formatResponse(result, { json: options.json, jq: options.jq, verbose: options.verbose });
157
157
  if (!result.successful) process.exit(1);
158
158
  }
159
159
  } catch (err) {
@@ -5,6 +5,7 @@ import { loadConfig } from '../lib/config.js';
5
5
  import { formatError } from '../lib/formatter.js';
6
6
  import { saveToDisk } from '../lib/save-to-disk.js';
7
7
  import { log } from '../lib/logger.js';
8
+ import { renameToUidConvention, hasUidInFilename } from '../lib/filenames.js';
8
9
 
9
10
  function collect(value, previous) {
10
11
  return previous.concat([value]);