@dboio/cli 0.17.0 → 0.19.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/README.md +111 -85
  2. package/package.json +1 -1
  3. package/plugins/claude/dbo/docs/dbo-cli-readme.md +111 -85
  4. package/src/commands/build.js +3 -3
  5. package/src/commands/clone.js +236 -97
  6. package/src/commands/deploy.js +3 -3
  7. package/src/commands/init.js +11 -11
  8. package/src/commands/install.js +3 -3
  9. package/src/commands/login.js +2 -2
  10. package/src/commands/mv.js +15 -15
  11. package/src/commands/pull.js +1 -1
  12. package/src/commands/push.js +193 -14
  13. package/src/commands/rm.js +2 -2
  14. package/src/commands/run.js +4 -4
  15. package/src/commands/status.js +1 -1
  16. package/src/commands/sync.js +2 -2
  17. package/src/lib/config.js +186 -135
  18. package/src/lib/delta.js +119 -17
  19. package/src/lib/dependencies.js +51 -24
  20. package/src/lib/deploy-config.js +4 -4
  21. package/src/lib/domain-guard.js +8 -9
  22. package/src/lib/filenames.js +12 -1
  23. package/src/lib/ignore.js +2 -3
  24. package/src/lib/insert.js +1 -1
  25. package/src/lib/metadata-schema.js +14 -20
  26. package/src/lib/metadata-templates.js +4 -4
  27. package/src/lib/migrations.js +1 -1
  28. package/src/lib/modify-key.js +1 -1
  29. package/src/lib/scaffold.js +5 -12
  30. package/src/lib/schema.js +67 -37
  31. package/src/lib/structure.js +6 -6
  32. package/src/lib/tagging.js +2 -2
  33. package/src/lib/ticketing.js +3 -7
  34. package/src/lib/toe-stepping.js +5 -5
  35. package/src/lib/transaction-key.js +1 -1
  36. package/src/migrations/004-rename-output-files.js +2 -2
  37. package/src/migrations/005-rename-output-metadata.js +2 -2
  38. package/src/migrations/006-remove-uid-companion-filenames.js +1 -1
  39. package/src/migrations/007-natural-entity-companion-filenames.js +1 -1
  40. package/src/migrations/008-metadata-uid-in-suffix.js +1 -1
  41. package/src/migrations/009-fix-media-collision-metadata-names.js +1 -1
  42. package/src/migrations/010-delete-paren-media-orphans.js +1 -1
  43. package/src/migrations/012-project-dir-restructure.js +211 -0
@@ -13,10 +13,17 @@ 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
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
+ import { fetchSchema, loadSchema, saveSchema, isSchemaStale } from '../lib/schema.js';
17
+ import { appMetadataPath } from '../lib/config.js';
17
18
  import { runPendingMigrations } from '../lib/migrations.js';
18
19
  import { upsertDeployEntry } from '../lib/deploy-config.js';
19
20
  import { syncDependencies, parseDependenciesColumn } from '../lib/dependencies.js';
21
+ import { sep } from 'path';
22
+
23
+ /** True when cwd is inside app_dependencies/ (dependency checkout clone). */
24
+ function isDependencyCheckout() {
25
+ return process.cwd().includes(`${sep}app_dependencies${sep}`);
26
+ }
20
27
  import { mergeDependencies } from '../lib/config.js';
21
28
 
22
29
  /**
@@ -34,6 +41,32 @@ export function resolveContentValue(value) {
34
41
  return value !== null && value !== undefined ? String(value) : null;
35
42
  }
36
43
 
44
+ /**
45
+ * Embed a server children object into metadata, decoding any base64 field values.
46
+ * Used by processEntityDirEntries() and processExtensionEntries().
47
+ *
48
+ * @param {Object} childrenObj - Server-side children: { entity_column: [...], ... }
49
+ * @returns {Object} - Decoded children object safe to write to metadata
50
+ */
51
+ export function embedEntityChildren(childrenObj) {
52
+ const result = {};
53
+ for (const [childEntityName, childArray] of Object.entries(childrenObj)) {
54
+ if (!Array.isArray(childArray)) continue;
55
+ result[childEntityName] = childArray.map(child => {
56
+ const embedded = {};
57
+ for (const [k, v] of Object.entries(child)) {
58
+ if (v && typeof v === 'object' && !Array.isArray(v) && v.encoding === 'base64') {
59
+ embedded[k] = resolveContentValue(v) ?? '';
60
+ } else {
61
+ embedded[k] = v;
62
+ }
63
+ }
64
+ return embedded;
65
+ });
66
+ }
67
+ return result;
68
+ }
69
+
37
70
  export function sanitizeFilename(name) {
38
71
  return name.replace(/[/\\?%*:|"<>]/g, '-').replace(/\s+/g, '-').substring(0, 200);
39
72
  }
@@ -491,6 +524,49 @@ async function detectAndRenameLegacyCompanions(metaPath, meta) {
491
524
  return metaChanged;
492
525
  }
493
526
 
527
+ /**
528
+ * Clean up double/triple-metadata files (e.g., "app.metadata.metadata~uid.json")
529
+ * caused by an older bug where buildMetaFilename received a base already containing ".metadata".
530
+ * Scans lib/ directories for files matching the pattern and removes them.
531
+ */
532
+ async function cleanDoubleMetadataFiles() {
533
+ const libDir = join(process.cwd(), 'lib');
534
+ if (!await fileExists(libDir)) return;
535
+
536
+ const trashDir = join(process.cwd(), 'trash');
537
+ let cleaned = 0;
538
+
539
+ async function scan(dir) {
540
+ let entries;
541
+ try { entries = await readdir(dir, { withFileTypes: true }); } catch { return; }
542
+
543
+ for (const entry of entries) {
544
+ if (entry.name.startsWith('.')) continue;
545
+ const full = join(dir, entry.name);
546
+
547
+ if (entry.isDirectory()) {
548
+ if (['node_modules', 'trash', '.git', '.app', 'app_dependencies'].includes(entry.name)) continue;
549
+ await scan(full);
550
+ continue;
551
+ }
552
+
553
+ // Detect double+ .metadata pattern: "name.metadata.metadata~uid.json" or "name.metadata.metadata.metadata~uid.json"
554
+ if (/\.metadata\.metadata[.~]/.test(entry.name)) {
555
+ try {
556
+ await mkdir(trashDir, { recursive: true });
557
+ await rename(full, join(trashDir, entry.name));
558
+ cleaned++;
559
+ } catch { /* non-critical */ }
560
+ }
561
+ }
562
+ }
563
+
564
+ await scan(libDir);
565
+ if (cleaned > 0) {
566
+ log.dim(` Cleaned ${cleaned} duplicate metadata file(s) → trash/`);
567
+ }
568
+ }
569
+
494
570
  /**
495
571
  * Scan all directories under Bins/ for orphaned legacy ~UID companion files
496
572
  * that no metadata @reference points to, and move them to trash/.
@@ -793,7 +869,7 @@ async function stageCollisionDeletions(toDelete, appJson, options) {
793
869
  }
794
870
 
795
871
  if (staged > 0) {
796
- log.success(`${staged} record(s) staged in .dbo/synchronize.json`);
872
+ log.success(`${staged} record(s) staged in .app/synchronize.json`);
797
873
  log.dim(' Run "dbo push" to delete from server');
798
874
  }
799
875
  }
@@ -935,7 +1011,7 @@ async function checkPendingSynchronize(options) {
935
1011
  if (totalCount === 0) return;
936
1012
 
937
1013
  log.warn('');
938
- log.warn(` ⚠ There are ${totalCount} un-pushed staged item(s) in .dbo/synchronize.json:`);
1014
+ log.warn(` ⚠ There are ${totalCount} un-pushed staged item(s) in .app/synchronize.json:`);
939
1015
  if (deleteCount > 0) log.warn(` ${deleteCount} pending deletion(s)`);
940
1016
  if (editCount > 0) log.warn(` ${editCount} pending edit(s)`);
941
1017
  if (addCount > 0) log.warn(` ${addCount} pending add(s)`);
@@ -1019,34 +1095,8 @@ export async function performClone(source, options = {}) {
1019
1095
  const effectiveDomain = options.domain || config.domain;
1020
1096
  let appJson;
1021
1097
 
1022
- // Fetch schema if missing, explicitly requested, or server has a newer version
1098
+ // Load local schema (server fetch deferred until after auth validation)
1023
1099
  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
1100
 
1051
1101
  // Step 1: Source mismatch detection (skip in pull mode)
1052
1102
  // Warn when the user provides an explicit source that differs from the stored one.
@@ -1072,7 +1122,9 @@ export async function performClone(source, options = {}) {
1072
1122
  }
1073
1123
  }
1074
1124
 
1075
- // Step 2: Load the app JSON — retry loop with fallback prompt on failure
1125
+ // Step 2: Load the app JSON — retry loop with fallback prompt on failure.
1126
+ // This runs BEFORE schema/dependency sync so that the login prompt fires
1127
+ // here if the session is expired (not buried inside a silent dependency clone).
1076
1128
  let activeSource = source;
1077
1129
  while (true) {
1078
1130
  try {
@@ -1102,6 +1154,35 @@ export async function performClone(source, options = {}) {
1102
1154
  throw new Error('Invalid app JSON: missing UID or children');
1103
1155
  }
1104
1156
 
1157
+ // Fetch schema if missing, explicitly requested, or server has a newer version.
1158
+ // Runs AFTER app fetch so that login prompt fires first on expired session.
1159
+ let shouldFetchSchema = !schema || options.schema;
1160
+ if (!shouldFetchSchema && schema) {
1161
+ try {
1162
+ shouldFetchSchema = await isSchemaStale({ domain: effectiveDomain, verbose: options.verbose });
1163
+ if (shouldFetchSchema) log.dim(` Server schema is newer — refreshing _system dependency baseline`);
1164
+ } catch {
1165
+ // Can't check — continue with local schema
1166
+ }
1167
+ }
1168
+ if (shouldFetchSchema) {
1169
+ try {
1170
+ schema = await fetchSchema({ domain: effectiveDomain, verbose: options.verbose });
1171
+ await saveSchema(schema);
1172
+ log.dim(` Refreshed _system dependency baseline`);
1173
+ } catch (err) {
1174
+ if (!schema) log.warn(` Could not fetch schema: ${err.message}`);
1175
+ // Continue with stale schema or null
1176
+ }
1177
+ }
1178
+
1179
+ // Regenerate metadata_schema.json for any new entity types
1180
+ if (schema) {
1181
+ const existing = await loadMetadataSchema();
1182
+ const updated = generateMetadataFromSchema(schema, existing ?? {});
1183
+ await saveMetadataSchema(updated);
1184
+ }
1185
+
1105
1186
  // Domain change detection
1106
1187
  if (effectiveDomain) {
1107
1188
  const { changed, proceed } = await checkDomainChange(effectiveDomain, options);
@@ -1118,10 +1199,12 @@ export async function performClone(source, options = {}) {
1118
1199
  await checkPendingSynchronize(options);
1119
1200
  }
1120
1201
 
1121
- // Ensure sensitive files are gitignored
1122
- await ensureGitignore(['.dbo/credentials.json', '.dbo/cookies.txt', '.dbo/.app_baseline.json']);
1202
+ // Ensure sensitive files are gitignored (skip for dependency checkouts — not user projects)
1203
+ if (!isDependencyCheckout()) {
1204
+ await ensureGitignore(['.app/credentials.json', '.app/cookies.txt', '.app/ticketing.local.json', '.app/scripts.local.json', '.app/errors.log', 'app_dependencies/']);
1205
+ }
1123
1206
 
1124
- // Step 2: Update .dbo/config.json (skip in pull mode — config already set)
1207
+ // Step 2: Update .app/config.json (skip in pull mode — config already set)
1125
1208
  if (!options.pullMode) {
1126
1209
  await updateConfigWithApp({
1127
1210
  AppID: appJson.AppID,
@@ -1130,9 +1213,9 @@ export async function performClone(source, options = {}) {
1130
1213
  AppShortName: appJson.ShortName,
1131
1214
  });
1132
1215
  await saveCloneSource(activeSource || 'default');
1133
- log.dim(' Updated .dbo/config.json with app metadata');
1216
+ log.dim(' Updated .app/config.json with app metadata');
1134
1217
 
1135
- // Merge Dependencies from app.json into .dbo/config.json
1218
+ // Merge Dependencies into .app/config.json
1136
1219
  // Always ensure at least ["_system"] is persisted
1137
1220
  const fromApp = parseDependenciesColumn(appJson.Dependencies);
1138
1221
  if (fromApp.length > 0) {
@@ -1164,8 +1247,8 @@ export async function performClone(source, options = {}) {
1164
1247
  }
1165
1248
  }
1166
1249
 
1167
- // Step 3: Update package.json (skip in pull mode)
1168
- if (!options.pullMode) {
1250
+ // Step 3: Update package.json (skip in pull mode and dependency checkouts)
1251
+ if (!options.pullMode && !isDependencyCheckout()) {
1169
1252
  await updatePackageJson(appJson, config);
1170
1253
  }
1171
1254
 
@@ -1183,7 +1266,7 @@ export async function performClone(source, options = {}) {
1183
1266
  force: explicitDeps ? true : options.force,
1184
1267
  schema: options.schema,
1185
1268
  verbose: options.verbose,
1186
- systemSchemaPath: join(process.cwd(), SCHEMA_FILE),
1269
+ systemSchemaPath: join(process.cwd(), 'app_dependencies', '_system', '.app', '_system.json'),
1187
1270
  only: explicitDeps || undefined,
1188
1271
  });
1189
1272
  } catch (err) {
@@ -1209,19 +1292,28 @@ export async function performClone(source, options = {}) {
1209
1292
  const structure = buildBinHierarchy(bins, appJson.AppID);
1210
1293
 
1211
1294
  if (!options.pullMode) {
1212
- for (const dir of SCAFFOLD_DIRS) {
1295
+ // Inside app_dependencies/: skip development-only scaffold dirs (src, test, trash)
1296
+ const isDependencyCheckout = process.cwd().includes(`${sep}app_dependencies${sep}`);
1297
+ const DEP_SKIP_DIRS = new Set(['src', 'test', 'trash']);
1298
+ const dirsToScaffold = isDependencyCheckout
1299
+ ? SCAFFOLD_DIRS.filter(d => !DEP_SKIP_DIRS.has(d))
1300
+ : SCAFFOLD_DIRS;
1301
+
1302
+ for (const dir of dirsToScaffold) {
1213
1303
  await mkdir(dir, { recursive: true });
1214
1304
  }
1215
1305
 
1216
1306
  // Best-effort: apply trash icon
1217
- await applyTrashIcon(join(process.cwd(), 'trash'));
1307
+ if (!isDependencyCheckout) {
1308
+ await applyTrashIcon(join(process.cwd(), 'trash'));
1309
+ }
1218
1310
 
1219
1311
  const createdDirs = await createDirectories(structure);
1220
1312
  await saveStructureFile(structure);
1221
1313
 
1222
- const totalDirs = SCAFFOLD_DIRS.length + createdDirs.length;
1314
+ const totalDirs = dirsToScaffold.length + createdDirs.length;
1223
1315
  log.success(`Created ${totalDirs} director${totalDirs === 1 ? 'y' : 'ies'}`);
1224
- for (const d of SCAFFOLD_DIRS) log.dim(` ${d}/`);
1316
+ for (const d of dirsToScaffold) log.dim(` ${d}/`);
1225
1317
  for (const d of createdDirs) log.dim(` ${d}/`);
1226
1318
 
1227
1319
  // Warn about legacy root-level entity directories
@@ -1250,7 +1342,7 @@ export async function performClone(source, options = {}) {
1250
1342
  if (!serverTz || serverTz === 'UTC') {
1251
1343
  serverTz = 'America/Los_Angeles';
1252
1344
  await updateConfigWithApp({ ServerTimezone: serverTz });
1253
- log.dim(` Set ServerTimezone to ${serverTz} in .dbo/config.json`);
1345
+ log.dim(` Set ServerTimezone to ${serverTz} in .app/config.json`);
1254
1346
  }
1255
1347
 
1256
1348
  // Resolve --entity filter: which entity types to process
@@ -1300,8 +1392,8 @@ export async function performClone(source, options = {}) {
1300
1392
  );
1301
1393
  }
1302
1394
 
1303
- // Step 5a: Write manifest.json to project root (from server content or resolved template)
1304
- if (!entityFilter || entityFilter.has('content')) {
1395
+ // Step 5a: Write manifest.json to project root (skip for dependency checkouts)
1396
+ if ((!entityFilter || entityFilter.has('content')) && !isDependencyCheckout()) {
1305
1397
  await writeManifestJson(appJson, contentRefs);
1306
1398
  }
1307
1399
 
@@ -1358,7 +1450,7 @@ export async function performClone(source, options = {}) {
1358
1450
  // Step 7: Save app.json with references
1359
1451
  await saveAppJson(appJson, contentRefs, otherRefs, effectiveDomain);
1360
1452
 
1361
- // Step 8: Create .dbo/.app_baseline.json baseline for delta tracking (skip in entity-filter mode to avoid overwriting)
1453
+ // Step 8: Create .app/<shortName>.json baseline for delta tracking (skip in entity-filter mode to avoid overwriting)
1362
1454
  if (!entityFilter) {
1363
1455
  await saveBaselineFile(appJson);
1364
1456
  resetBaselineCache(); // invalidate so next operation reloads the fresh baseline
@@ -1374,13 +1466,17 @@ export async function performClone(source, options = {}) {
1374
1466
  // Step 9: Trash orphaned legacy ~UID companion files that no metadata references
1375
1467
  await trashOrphanedLegacyCompanions();
1376
1468
 
1469
+ // Step 9b: Clean up double-metadata files (e.g., "app.metadata.metadata~uid.json")
1470
+ // caused by an older bug where buildMetaFilename received a base already containing ".metadata"
1471
+ await cleanDoubleMetadataFiles();
1472
+
1377
1473
  // Step 10: Tag project files with sync status (best-effort, non-blocking)
1378
1474
  tagProjectFiles({ verbose: false }).catch(() => {});
1379
1475
 
1380
1476
  log.plain('');
1381
1477
  const verb = options.pullMode ? 'Pull' : 'Clone';
1382
1478
  log.success(entityFilter ? `${verb} complete! (filtered: ${options.entity})` : `${verb} complete!`);
1383
- log.dim(' app.json saved to project root');
1479
+ log.dim(' App metadata saved to .app/');
1384
1480
  if (!options.pullMode) {
1385
1481
  log.dim(' Run "dbo login" to authenticate, then "dbo push" to deploy changes');
1386
1482
  }
@@ -1546,7 +1642,24 @@ async function fetchAppFromServer(appShortName, options, config) {
1546
1642
  throw new Error(`No app found with ShortName "${appShortName}"`);
1547
1643
  }
1548
1644
 
1549
- spinner.succeed(`Found app on server`);
1645
+ // Heuristic: detect sparse responses — may indicate expired session or
1646
+ // limited permissions on the target app. Warn but proceed: the server may
1647
+ // intentionally scope /api/app/object responses by user security.
1648
+ const children = appRecord.children || {};
1649
+ const childKeys = Object.keys(children);
1650
+ const hasContentOrMedia = children.content?.length > 0 || children.media?.length > 0 || children.bin?.length > 0;
1651
+ if (childKeys.length > 0 && !hasContentOrMedia) {
1652
+ const totalRecords = childKeys.reduce((sum, k) => sum + (Array.isArray(children[k]) ? children[k].length : 0), 0);
1653
+ if (totalRecords <= 5) {
1654
+ spinner.warn(`App "${appShortName}" returned sparse data (${totalRecords} record(s), no content/media/bins)`);
1655
+ log.warn(' If data is missing, check permissions or run "dbo login" and re-clone.');
1656
+ } else {
1657
+ spinner.succeed(`Found app on server`);
1658
+ }
1659
+ } else {
1660
+ spinner.succeed(`Found app on server`);
1661
+ }
1662
+
1550
1663
  return appRecord;
1551
1664
  }
1552
1665
 
@@ -1967,7 +2080,14 @@ async function processEntityDirEntries(entityName, entries, options, serverTz) {
1967
2080
  const extractedContentCols = [];
1968
2081
 
1969
2082
  for (const [key, value] of Object.entries(record)) {
1970
- if (key === 'children') continue;
2083
+ if (key === 'children') {
2084
+ // Embed children inline with base64 decoding (entity_column, security_column, etc.)
2085
+ if (value && typeof value === 'object' && !Array.isArray(value)
2086
+ && Object.keys(value).length > 0) {
2087
+ meta.children = embedEntityChildren(value);
2088
+ }
2089
+ continue;
2090
+ }
1971
2091
 
1972
2092
  // Check if this column should be extracted as a companion file
1973
2093
  const extractInfo = contentColsToExtract.find(c => c.col === key);
@@ -2105,7 +2225,7 @@ async function buildDescriptorPrePass(extensionEntries, structure, metadataSchem
2105
2225
  }
2106
2226
 
2107
2227
  await saveDescriptorMapping(structure, mapping);
2108
- log.dim(` Saved descriptorMapping to .dbo/structure.json`);
2228
+ log.dim(` Saved descriptorMapping to .app/directories.json`);
2109
2229
 
2110
2230
  // Parse form-control-code from descriptor_definition records → populate metadata_schema.json
2111
2231
  const descriptorDefs = extensionEntries.filter(r =>
@@ -2545,7 +2665,14 @@ async function processExtensionEntries(entries, structure, options, serverTz) {
2545
2665
  const extractedCols = [];
2546
2666
 
2547
2667
  for (const [key, value] of Object.entries(record)) {
2548
- if (key === 'children') continue;
2668
+ if (key === 'children') {
2669
+ // Embed children inline with base64 decoding
2670
+ if (value && typeof value === 'object' && !Array.isArray(value)
2671
+ && Object.keys(value).length > 0) {
2672
+ meta.children = embedEntityChildren(value);
2673
+ }
2674
+ continue;
2675
+ }
2549
2676
 
2550
2677
  const companionRef = companionRefs.find(r => r.column.toLowerCase() === key.toLowerCase());
2551
2678
  if (companionRef) {
@@ -2878,7 +3005,7 @@ async function processMediaEntries(mediaRecords, structure, options, config, app
2878
3005
  log.dim(` Error: ${err.message}`);
2879
3006
  }
2880
3007
 
2881
- // Append to .dbo/errors.log
3008
+ // Append to .app/errors.log
2882
3009
  await appendErrorLog({
2883
3010
  timestamp: new Date().toISOString(),
2884
3011
  command: 'clone',
@@ -2957,7 +3084,7 @@ async function processMediaEntries(mediaRecords, structure, options, config, app
2957
3084
  });
2958
3085
  log.dim(` Staged: ${stale.filename}`);
2959
3086
  }
2960
- log.success('Stale media records staged in .dbo/synchronize.json');
3087
+ log.success('Stale media records staged in .app/synchronize.json');
2961
3088
  log.dim(' Run "dbo push" to delete from server');
2962
3089
  }
2963
3090
 
@@ -3024,7 +3151,9 @@ async function processRecord(entityName, record, structure, options, usedNames,
3024
3151
  }
3025
3152
 
3026
3153
  // If no extension determined and Content column has data, prompt user to choose one
3027
- if (!ext && !options.yes && record.Content) {
3154
+ // Only prompt when --configure is set; otherwise skip silently (companion extraction
3155
+ // should not surprise the user with interactive prompts during normal clone/pull)
3156
+ if (!ext && options.configure && !options.yes && record.Content) {
3028
3157
  const cv = record.Content;
3029
3158
  const hasContentData = cv && (
3030
3159
  (typeof cv === 'object' && cv.value !== null && cv.value !== undefined) ||
@@ -3574,27 +3703,23 @@ async function extractCustomSqlIfNeeded(entityObj, companionStem, outputDir, ser
3574
3703
 
3575
3704
  /**
3576
3705
  * Recursively build a children object for a parent entity.
3577
- * Mutates parentObj to set parentObj.children = { column: [], join: [], filter: [] }.
3578
- * Returns companionFiles: string[] of written companion file basenames.
3706
+ * Mutates parentObj to set parentObj.children keyed by physical entity names:
3707
+ * { output_value: [], output_value_filter: [], output_value_entity_column_rel: [] }
3579
3708
  *
3580
3709
  * Each child object retains _entity set to the physical entity name
3581
- * (output_value, output_value_entity_column_rel, output_value_filter)
3582
3710
  * so that push can route submissions correctly.
3583
3711
  *
3712
+ * Child CustomSQL values are decoded inline as strings — only the root output's
3713
+ * CustomSQL is extracted as a companion .sql file (done by the caller).
3714
+ *
3584
3715
  * @param {Object} parentObj - The entity object to populate (mutated in place)
3585
3716
  * @param {Object} node - Tree node from buildOutputHierarchyTree (has _children)
3586
- * @param {string} rootStem - Root output file stem (e.g. "Sales~abc")
3587
- * @param {string} outputDir - Directory where root output JSON lives
3588
- * @param {string} serverTz - Server timezone
3589
- * @param {string} [parentStem] - Ancestor stem for compound companion naming
3590
- * @returns {Promise<string[]>} - Array of written companion file basenames
3591
3717
  */
3592
- async function buildInlineOutputChildren(parentObj, node, rootStem, outputDir, serverTz, parentStem = rootStem) {
3593
- const companionFiles = [];
3718
+ export function buildInlineOutputChildren(parentObj, node) {
3594
3719
  const nodeChildren = node._children || {};
3595
3720
 
3596
- // Always create children object with all three doc keys
3597
- parentObj.children = { column: [], join: [], filter: [] };
3721
+ // Use physical entity names as children keys (not doc aliases)
3722
+ parentObj.children = { output_value: [], output_value_filter: [], output_value_entity_column_rel: [] };
3598
3723
 
3599
3724
  for (const docKey of INLINE_DOC_KEYS) {
3600
3725
  const entityArray = nodeChildren[docKey];
@@ -3602,44 +3727,32 @@ async function buildInlineOutputChildren(parentObj, node, rootStem, outputDir, s
3602
3727
 
3603
3728
  if (!Array.isArray(entityArray) || entityArray.length === 0) continue;
3604
3729
 
3605
- for (let childIdx = 0; childIdx < entityArray.length; childIdx++) {
3606
- const child = entityArray[childIdx];
3730
+ for (const child of entityArray) {
3607
3731
  // Build a clean copy without tree-internal fields
3608
3732
  const childObj = { ...child };
3609
3733
  delete childObj._children;
3610
3734
 
3611
- // Decode any base64 values
3735
+ // Decode all base64 fields inline (including CustomSQL — only root output extracts SQL files)
3612
3736
  for (const [key, value] of Object.entries(childObj)) {
3613
- if (key === 'CustomSQL') continue; // handled by extractCustomSqlIfNeeded
3614
3737
  if (value && typeof value === 'object' && !Array.isArray(value) && value.encoding === 'base64') {
3615
- childObj[key] = resolveContentValue(value);
3738
+ childObj[key] = resolveContentValue(value) ?? '';
3616
3739
  }
3617
3740
  }
3618
3741
 
3619
3742
  // Ensure _entity is set to physical entity name (for push routing)
3620
3743
  childObj._entity = physicalKey;
3621
3744
 
3622
- // Compute companion stem for this child (index-based, not UID-based)
3623
- const childStem = getChildCompanionStem(rootStem, physicalKey, childIdx, parentStem);
3624
-
3625
- // Extract CustomSQL if needed
3626
- const companionFile = await extractCustomSqlIfNeeded(childObj, childStem, outputDir, serverTz);
3627
- if (companionFile) companionFiles.push(companionFile);
3628
-
3629
3745
  // Recurse into child's _children (e.g. join→column, column→filter)
3630
3746
  if (child._children && Object.keys(child._children).some(k => child._children[k]?.length > 0)) {
3631
- const gcFiles = await buildInlineOutputChildren(childObj, child, rootStem, outputDir, serverTz, childStem);
3632
- companionFiles.push(...gcFiles);
3747
+ buildInlineOutputChildren(childObj, child);
3633
3748
  } else {
3634
3749
  // Leaf node: still set empty children
3635
- childObj.children = { column: [], join: [], filter: [] };
3750
+ childObj.children = { output_value: [], output_value_filter: [], output_value_entity_column_rel: [] };
3636
3751
  }
3637
3752
 
3638
- parentObj.children[docKey].push(childObj);
3753
+ parentObj.children[physicalKey].push(childObj);
3639
3754
  }
3640
3755
  }
3641
-
3642
- return companionFiles;
3643
3756
  }
3644
3757
 
3645
3758
  /**
@@ -3661,8 +3774,8 @@ async function trashOrphanedChildFiles(outputDir, rootStem) {
3661
3774
  for (const f of files) {
3662
3775
  const matchesCurrent = f.startsWith(`${rootStem}.`);
3663
3776
  const matchesLegacy = f.startsWith(`${legacyStem}.`);
3664
- if ((matchesCurrent || matchesLegacy) && /\.(column|join|filter)~/.test(f)) {
3665
- // Old child file or legacy CustomSQL companion — trash it
3777
+ if ((matchesCurrent || matchesLegacy) && /\.(column|join|filter)(~|-\d+\.|\.CustomSQL)/.test(f)) {
3778
+ // Old child file, legacy child JSON, or old per-child CustomSQL companion — trash it
3666
3779
  if (!trashCreated) {
3667
3780
  await mkdir(trashDir, { recursive: true });
3668
3781
  trashCreated = true;
@@ -3814,6 +3927,26 @@ async function processOutputHierarchy(appJson, structure, options, serverTz) {
3814
3927
  // Resolve filename columns for each entity type
3815
3928
  const filenameCols = await resolveOutputFilenameColumns(appJson, options);
3816
3929
 
3930
+ // Detect companion filename collisions: multiple outputs that produce the same
3931
+ // rootBasename + binDir. When collisions exist, use UID-qualified companion stems
3932
+ // so each output's .CustomSQL.sql doesn't overwrite the others.
3933
+ const companionKey = (output) => {
3934
+ let binDir = BINS_DIR;
3935
+ if (output.BinID && structure[output.BinID]) {
3936
+ binDir = resolveBinPath(output.BinID, structure);
3937
+ }
3938
+ const base = buildOutputFilename('output', output, filenameCols.output);
3939
+ return `${binDir}/${base}`;
3940
+ };
3941
+ const companionKeyCounts = new Map();
3942
+ for (const output of tree) {
3943
+ const key = companionKey(output);
3944
+ companionKeyCounts.set(key, (companionKeyCounts.get(key) || 0) + 1);
3945
+ }
3946
+ const collidingCompanionKeys = new Set(
3947
+ [...companionKeyCounts.entries()].filter(([, count]) => count > 1).map(([key]) => key)
3948
+ );
3949
+
3817
3950
  const refs = [];
3818
3951
  const bulkAction = { value: null };
3819
3952
  const config = await loadConfig();
@@ -3924,11 +4057,16 @@ async function processOutputHierarchy(appJson, structure, options, serverTz) {
3924
4057
  // Copy raw CustomSQL for extraction helper
3925
4058
  rootMeta.CustomSQL = output.CustomSQL;
3926
4059
 
4060
+ // When multiple outputs share the same name+bin, qualify the companion stem
4061
+ // with the UID so each gets its own .CustomSQL.sql file.
4062
+ const isCollision = collidingCompanionKeys.has(`${binDir}/${rootBasename}`);
4063
+ const companionStem = isCollision ? `${rootBasename}~${rootUid}` : rootBasename;
4064
+
3927
4065
  // Extract CustomSQL on root (rules 1/2/3)
3928
- await extractCustomSqlIfNeeded(rootMeta, rootBasename, binDir, serverTz);
4066
+ await extractCustomSqlIfNeeded(rootMeta, companionStem, binDir, serverTz);
3929
4067
 
3930
4068
  // Embed all children under rootMeta.children = { column, join, filter }
3931
- await buildInlineOutputChildren(rootMeta, output, rootBasename, binDir, serverTz);
4069
+ buildInlineOutputChildren(rootMeta, output);
3932
4070
  // rootMeta now has .children = { column: [...], join: [...], filter: [...] }
3933
4071
 
3934
4072
  await writeFile(rootMetaPath, JSON.stringify(rootMeta, null, 2) + '\n');
@@ -4030,11 +4168,11 @@ async function writeManifestJson(appJson, contentRefs) {
4030
4168
  };
4031
4169
 
4032
4170
  await writeFile('manifest.json', JSON.stringify(manifest, null, 2) + '\n');
4033
- log.dim(' manifest.json generated at project root (from app.json values)');
4171
+ log.dim(' manifest.json generated at project root (from app metadata)');
4034
4172
  }
4035
4173
 
4036
4174
  /**
4037
- * Save app.json to project root with @ references replacing processed entries.
4175
+ * Save app metadata to project root with @ references replacing processed entries.
4038
4176
  */
4039
4177
  async function saveAppJson(appJson, contentRefs, otherRefs, domain) {
4040
4178
  const output = { ...appJson };
@@ -4072,11 +4210,12 @@ async function saveAppJson(appJson, contentRefs, otherRefs, domain) {
4072
4210
  delete output.children.output_value_entity_column_rel;
4073
4211
  }
4074
4212
 
4075
- await writeFile('app.json', JSON.stringify(output, null, 2) + '\n');
4213
+ const metaPath = await appMetadataPath();
4214
+ await writeFile(metaPath, JSON.stringify(output, null, 2) + '\n');
4076
4215
  }
4077
4216
 
4078
4217
  /**
4079
- * Save .dbo/.app_baseline.json baseline file with decoded base64 values.
4218
+ * Save .app/<shortName>.json baseline file with decoded base64 values.
4080
4219
  * This file tracks the server state for delta detection.
4081
4220
  */
4082
4221
  async function saveBaselineFile(appJson) {
@@ -4086,10 +4225,10 @@ async function saveBaselineFile(appJson) {
4086
4225
  // Recursively decode all base64 fields
4087
4226
  decodeBase64Fields(baseline);
4088
4227
 
4089
- // Save to .app.json
4228
+ // Save baseline
4090
4229
  await saveAppJsonBaseline(baseline);
4091
4230
 
4092
- log.dim(' .dbo/.app_baseline.json baseline created (system-managed, do not edit)');
4231
+ log.dim(' .app/ baseline created (system-managed, do not edit)');
4093
4232
  }
4094
4233
 
4095
4234
  /**
@@ -4125,10 +4264,10 @@ export function decodeBase64Fields(obj) {
4125
4264
 
4126
4265
  // ── Error log ─────────────────────────────────────────────────────────────
4127
4266
 
4128
- const ERROR_LOG_PATH = join('.dbo', 'errors.log');
4267
+ const ERROR_LOG_PATH = join('.app', 'errors.log');
4129
4268
 
4130
4269
  /**
4131
- * Append a structured error entry to .dbo/errors.log.
4270
+ * Append a structured error entry to .app/errors.log.
4132
4271
  * Creates the file if absent. Each entry is one JSON line (JSONL format).
4133
4272
  */
4134
4273
  async function appendErrorLog(entry) {
@@ -9,12 +9,12 @@ import { resolveTransactionKey } from '../lib/transaction-key.js';
9
9
  import { log } from '../lib/logger.js';
10
10
  import { runPendingMigrations } from '../lib/migrations.js';
11
11
 
12
- const MANIFEST_FILE = '.dbo/deploy_config.json';
12
+ const MANIFEST_FILE = '.app/deploy_config.json';
13
13
  const LEGACY_MANIFEST_FILE = 'dbo.deploy.json';
14
14
 
15
15
  export const deployCommand = new Command('deploy')
16
16
  .description('Deploy files to DBO.io using a manifest or direct arguments')
17
- .argument('[name]', 'Deployment name from .dbo/deploy_config.json (e.g., css:colors)')
17
+ .argument('[name]', 'Deployment name from .app/deploy_config.json (e.g., css:colors)')
18
18
  .option('--all', 'Deploy all entries in the manifest')
19
19
  .option('-C, --confirm <value>', 'Commit: true (default) or false', 'true')
20
20
  .option('--ticket <id>', 'Override ticket ID')
@@ -29,7 +29,7 @@ export const deployCommand = new Command('deploy')
29
29
  await runPendingMigrations(options);
30
30
  const client = new DboClient({ domain: options.domain, verbose: options.verbose });
31
31
 
32
- // Load manifest — try .dbo/deploy_config.json first, fall back to legacy dbo.deploy.json
32
+ // Load manifest — try .app/deploy_config.json first, fall back to legacy dbo.deploy.json
33
33
  let manifest;
34
34
  let manifestSource;
35
35
  try {