@dboio/cli 0.19.4 → 0.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,15 +1,15 @@
1
1
  import { Command } from 'commander';
2
- import { readFile, writeFile, appendFile, mkdir, access, readdir, rename, stat, utimes } from 'fs/promises';
2
+ import { readFile, writeFile, appendFile, mkdir, access, readdir, rename, stat, utimes, unlink } from 'fs/promises';
3
3
  import { join, basename, extname, dirname } from 'path';
4
4
  import { fileURLToPath } from 'url';
5
5
  import { DboClient } from '../lib/client.js';
6
- 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 { loadConfig, updateConfigWithApp, updateConfigUserMedia, loadClonePlacement, saveClonePlacement, ensureGitignore, DEFAULT_GITIGNORE_ENTRIES, saveEntityDirPreference, loadEntityDirPreference, saveEntityContentExtractions, loadEntityContentExtractions, saveAppJsonBaseline, addDeleteEntry, loadCollisionResolutions, saveCollisionResolutions, loadSynchronize, saveSynchronize, saveAppModifyKey, loadTransactionKeyPreset, saveTransactionKeyPreset, loadOutputFilenamePreference, saveOutputFilenamePreference, saveCloneSource, loadCloneSource, saveDescriptorFilenamePreference, loadDescriptorFilenamePreference, saveDescriptorContentExtractions, loadDescriptorContentExtractions, saveExtensionDocumentationMDPlacement, loadExtensionDocumentationMDPlacement, loadRootContentFiles } from '../lib/config.js';
7
7
  import { buildBinHierarchy, resolveBinPath, createDirectories, saveStructureFile, findBinByPath, BINS_DIR, DEFAULT_PROJECT_DIRS, SCAFFOLD_DIRS, ENTITY_DIR_NAMES, OUTPUT_ENTITY_MAP, OUTPUT_HIERARCHY_ENTITIES, EXTENSION_DESCRIPTORS_DIR, EXTENSION_UNSUPPORTED_DIR, DOCUMENTATION_DIR, buildDescriptorMapping, saveDescriptorMapping, loadDescriptorMapping, resolveExtensionSubDir, resolveEntityDirPath, resolveFieldValue } from '../lib/structure.js';
8
8
  import { log } from '../lib/logger.js';
9
9
  import { buildUidFilename, buildContentFileName, buildMetaFilename, isMetadataFile, parseMetaFilename, stripUidFromFilename, hasUidInFilename } from '../lib/filenames.js';
10
10
  import { setFileTimestamps, parseServerDate } from '../lib/timestamps.js';
11
11
  import { getLocalSyncTime, isServerNewer, hasLocalModifications, promptChangeDetection, inlineDiffAndMerge, isDiffable, loadBaselineForComparison, resetBaselineCache, findMetadataFiles } from '../lib/diff.js';
12
- import { loadIgnore } from '../lib/ignore.js';
12
+ import { loadIgnore, getDefaultFileContent as getDboignoreDefaultContent } 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';
@@ -76,6 +76,37 @@ async function fileExists(path) {
76
76
  try { await access(path); return true; } catch { return false; }
77
77
  }
78
78
 
79
+ /**
80
+ * Resolve a metadata file path with collision detection.
81
+ * Content records are processed before output, so they naturally claim the unsuffixed
82
+ * name. This function ensures output (and any late-arriving entity) gets a numbered
83
+ * suffix when the clean name is already owned by a different UID.
84
+ *
85
+ * Algorithm: try name.metadata.json, then name-1.metadata.json, name-2.metadata.json, …
86
+ * until we find a slot that either doesn't exist or is owned by the same UID.
87
+ *
88
+ * @param {string} dir - Directory to check
89
+ * @param {string} naturalBase - Natural base name (no .metadata.json suffix)
90
+ * @param {string} uid - UID of the record being written
91
+ * @returns {Promise<string>} - Absolute path to use for the metadata file
92
+ */
93
+ async function resolveMetaCollision(dir, naturalBase, uid) {
94
+ for (let i = 0; i < 1000; i++) {
95
+ const candidate = i === 0
96
+ ? `${naturalBase}.metadata.json`
97
+ : `${naturalBase}-${i}.metadata.json`;
98
+ const fullPath = join(dir, candidate);
99
+ try {
100
+ const existing = JSON.parse(await readFile(fullPath, 'utf8'));
101
+ if (!existing.UID || existing.UID === uid) return fullPath; // same or new record
102
+ // Slot owned by a different UID — try next
103
+ } catch {
104
+ return fullPath; // file doesn't exist — use it
105
+ }
106
+ }
107
+ return join(dir, `${naturalBase}.metadata.json`); // fallback (should never reach here)
108
+ }
109
+
79
110
  const WILL_DELETE_PREFIX = '__WILL_DELETE__';
80
111
 
81
112
  function isWillDeleteFile(filename) {
@@ -322,8 +353,8 @@ export function resolveRecordPaths(entityName, record, structure, placementPref)
322
353
  const uid = String(record.UID || record._id || 'untitled');
323
354
  // Companion: natural name, no UID
324
355
  const filename = sanitizeFilename(buildContentFileName(record, uid));
325
- // Metadata: name.metadata~uid.json
326
- const metaPath = join(dir, buildMetaFilename(name, uid));
356
+ // Metadata: name.metadata.json
357
+ const metaPath = join(dir, buildMetaFilename(name));
327
358
 
328
359
  return { dir, filename, metaPath };
329
360
  }
@@ -352,14 +383,33 @@ export function resolveMediaPaths(record, structure) {
352
383
  dir = dir.replace(/^\/+|\/+$/g, '');
353
384
  if (!dir) dir = BINS_DIR;
354
385
 
355
- // Metadata: name.ext.metadata~uid.json
356
- const uid = String(record.UID || record._id || 'untitled');
386
+ // Metadata: name.ext.metadata.json
357
387
  const naturalMediaBase = `${name}.${ext}`;
358
- const metaPath = join(dir, buildMetaFilename(naturalMediaBase, uid));
388
+ const metaPath = join(dir, buildMetaFilename(naturalMediaBase));
359
389
 
360
390
  return { dir, filename: companionFilename, metaPath };
361
391
  }
362
392
 
393
+ /**
394
+ * Classify a media record based on its FullPath relative to the app short name.
395
+ *
396
+ * Returns:
397
+ * 'app' — /media/<appShortName>/app/... or no FullPath (assumed app media)
398
+ * 'user' — /media/<appShortName>/user/...
399
+ * 'foreign' — /media/<other_app>/... (belongs to a different app entirely)
400
+ */
401
+ function classifyMediaRecord(record, appShortName) {
402
+ const fp = record.FullPath;
403
+ if (!fp || !appShortName) return 'app';
404
+ const normalized = fp.startsWith('/') ? fp.slice(1) : fp;
405
+ // Must start with media/<appShortName>/
406
+ const appPrefix = `media/${appShortName.toLowerCase()}/`;
407
+ if (!normalized.toLowerCase().startsWith(appPrefix)) return 'foreign';
408
+ const rest = normalized.slice(appPrefix.length);
409
+ if (rest.toLowerCase().startsWith('user/')) return 'user';
410
+ return 'app';
411
+ }
412
+
363
413
  /**
364
414
  * Extract path components for entity-dir records.
365
415
  * Simplified from processEntityDirEntries() for collision detection.
@@ -377,8 +427,7 @@ export function resolveEntityDirPaths(entityName, record, dirName) {
377
427
  name = sanitizeFilename(String(record.UID || 'untitled'));
378
428
  }
379
429
 
380
- const uid = record.UID || 'untitled';
381
- const metaPath = join(dirName, buildMetaFilename(name, uid));
430
+ const metaPath = join(dirName, buildMetaFilename(name));
382
431
  return { dir: dirName, name, metaPath };
383
432
  }
384
433
 
@@ -1177,11 +1226,25 @@ export async function performClone(source, options = {}) {
1177
1226
  }
1178
1227
  }
1179
1228
 
1229
+ // Save AppShortName to config before writing the metadata schema so that
1230
+ // metadataSchemaPath() resolves to <shortname>.metadata_schema.json rather
1231
+ // than falling back to the generic app.metadata_schema.json. Without this,
1232
+ // processExtensionEntries() later loads null (wrong file) and descriptor
1233
+ // sub-directories + companion @reference entries are lost.
1234
+ if (!options.pullMode && appJson?.ShortName) {
1235
+ await updateConfigWithApp({ AppShortName: appJson.ShortName });
1236
+ }
1237
+
1180
1238
  // Regenerate metadata_schema.json for any new entity types
1181
1239
  if (schema) {
1182
1240
  const existing = await loadMetadataSchema();
1183
1241
  const updated = generateMetadataFromSchema(schema, existing ?? {});
1184
1242
  await saveMetadataSchema(updated);
1243
+ // Remove orphaned app.metadata_schema.json left by previous runs that wrote
1244
+ // the schema before AppShortName was saved (entity entries are regenerated above).
1245
+ if (appJson?.ShortName && appJson.ShortName !== 'app') {
1246
+ try { await unlink(join('.app', 'app.metadata_schema.json')); } catch { /* not present */ }
1247
+ }
1185
1248
  }
1186
1249
 
1187
1250
  // Domain change detection
@@ -1202,7 +1265,7 @@ export async function performClone(source, options = {}) {
1202
1265
 
1203
1266
  // Ensure sensitive files are gitignored (skip for dependency checkouts — not user projects)
1204
1267
  if (!isDependencyCheckout()) {
1205
- await ensureGitignore(['.app/credentials.json', '.app/cookies.txt', '.app/ticketing.local.json', '.app/scripts.local.json', '.app/errors.log', 'app_dependencies/']);
1268
+ await ensureGitignore(DEFAULT_GITIGNORE_ENTRIES);
1206
1269
  }
1207
1270
 
1208
1271
  // Step 2: Update .app/config.json (skip in pull mode — config already set)
@@ -1393,9 +1456,10 @@ export async function performClone(source, options = {}) {
1393
1456
  );
1394
1457
  }
1395
1458
 
1396
- // Step 5a: Write manifest.json to project root (skip for dependency checkouts)
1397
- if ((!entityFilter || entityFilter.has('content')) && !isDependencyCheckout()) {
1398
- await writeManifestJson(appJson, contentRefs);
1459
+ // Step 5a: Write root content files (manifest.json, CLAUDE.md, README.md, etc.) to project root.
1460
+ // Also fixes the duplicate bug: relocates companions from lib/bins/app/ to root and rewrites metadata.
1461
+ if (!entityFilter || entityFilter.has('content')) {
1462
+ await writeRootContentFiles(appJson, contentRefs);
1399
1463
  }
1400
1464
 
1401
1465
  // Step 5b: Process media → download binary files + metadata (skip rejected records)
@@ -1957,14 +2021,15 @@ async function processEntityDirEntries(entityName, entries, options, serverTz) {
1957
2021
  }
1958
2022
 
1959
2023
  // Resolve name collisions: second+ record with same name gets -1, -2, etc.
2024
+ // Use case-insensitive key to prevent companion file collisions on case-insensitive filesystems.
1960
2025
  const uid = record.UID || 'untitled';
1961
- const nameKey = name;
2026
+ const nameKey = name.toLowerCase();
1962
2027
  const count = usedNames.get(nameKey) || 0;
1963
2028
  usedNames.set(nameKey, count + 1);
1964
2029
  if (count > 0) name = `${name}-${count}`;
1965
2030
 
1966
- // Metadata: name.metadata~uid.json; companion files use natural name
1967
- const metaPath = join(dirName, buildMetaFilename(name, uid));
2031
+ // Metadata: name.metadata.json; companion files use natural name
2032
+ const metaPath = join(dirName, buildMetaFilename(name));
1968
2033
 
1969
2034
  // Legacy detection: rename old-format metadata files to new convention
1970
2035
  const legacyDotMetaPath = join(dirName, `${name}.${uid}.metadata.json`);
@@ -2196,7 +2261,7 @@ function parseFormControlCode(string5) {
2196
2261
  if (codeStr) {
2197
2262
  for (const pair of codeStr.split(',')) {
2198
2263
  const [col, ext] = pair.split('|');
2199
- if (col?.trim() && ext?.trim()) colToExt.set(col.trim(), ext.trim().toLowerCase());
2264
+ if (col?.trim()) colToExt.set(col.trim(), ext?.trim().toLowerCase() || 'md');
2200
2265
  }
2201
2266
  }
2202
2267
  const titleStr = params.get('form-control-title');
@@ -2263,9 +2328,10 @@ async function buildDescriptorPrePass(extensionEntries, structure, metadataSchem
2263
2328
  const { colToExt, colToTitle } = parseFormControlCode(string5);
2264
2329
  if (colToExt.size === 0) continue;
2265
2330
 
2266
- // Only populate if no entry already exists for this descriptor
2331
+ // Only skip if the existing entry already has @reference expressions;
2332
+ // plain-column entries (seeded without form-control-code) should be overwritten
2267
2333
  const existing = getTemplateCols(metadataSchema, 'extension', descriptor);
2268
- if (existing) continue;
2334
+ if (existing?.some(e => e.includes('@reference'))) continue;
2269
2335
 
2270
2336
  const refEntries = [];
2271
2337
  for (const [col, ext] of colToExt) {
@@ -2448,8 +2514,6 @@ function guessExtensionForDescriptor(descriptor, columnName) {
2448
2514
  * @returns {Promise<'inline'|'root'>}
2449
2515
  */
2450
2516
  async function resolveDocumentationPlacement(options) {
2451
- if (options.yes) return 'inline';
2452
-
2453
2517
  const saved = await loadExtensionDocumentationMDPlacement();
2454
2518
  if (saved && !options.force && !options.configure) {
2455
2519
  log.dim(` Documentation MD placement: ${saved} (saved)`);
@@ -2457,7 +2521,7 @@ async function resolveDocumentationPlacement(options) {
2457
2521
  }
2458
2522
 
2459
2523
  let placement;
2460
- if (options.configure) {
2524
+ if (options.configure && !options.yes) {
2461
2525
  const inquirer = (await import('inquirer')).default;
2462
2526
  ({ placement } = await inquirer.prompt([{
2463
2527
  type: 'list', name: 'placement',
@@ -2499,7 +2563,7 @@ async function processExtensionEntries(entries, structure, options, serverTz) {
2499
2563
  log.info(`Processing ${entries.length} extension record(s)...`);
2500
2564
 
2501
2565
  // Step A: Pre-pass — build mapping + create directories
2502
- const metadataSchema = await loadMetadataSchema();
2566
+ const metadataSchema = (await loadMetadataSchema()) ?? {};
2503
2567
  const mapping = await buildDescriptorPrePass(entries, structure, metadataSchema);
2504
2568
 
2505
2569
  // Clear documentation preferences when --force is used with --documentation-only
@@ -2547,12 +2611,19 @@ async function processExtensionEntries(entries, structure, options, serverTz) {
2547
2611
  }
2548
2612
 
2549
2613
  // Step D: Write files, one group at a time
2614
+ // descriptor_definition must be written before dependent descriptors (e.g. control)
2550
2615
  const refs = [];
2551
2616
  const bulkAction = { value: null };
2552
2617
  const legacyRenameAction = { value: null }; // 'rename_all' | 'skip_all' | null
2553
2618
  const config = await loadConfig();
2554
2619
 
2555
- for (const [descriptor, { dir, records }] of groups.entries()) {
2620
+ const sortedGroups = [...groups.entries()].sort(([a], [b]) => {
2621
+ if (a === 'descriptor_definition') return -1;
2622
+ if (b === 'descriptor_definition') return 1;
2623
+ return 0;
2624
+ });
2625
+
2626
+ for (const [descriptor, { dir, records }] of sortedGroups) {
2556
2627
  const { filenameCol, companionRefs } = descriptorPrefs.get(descriptor);
2557
2628
  const useRootDoc = (descriptor === 'documentation' && docPlacement === 'root');
2558
2629
  const mdColInfo = useRootDoc ? companionRefs.find(r => r.extensionCol === 'md') : null;
@@ -2572,14 +2643,15 @@ async function processExtensionEntries(entries, structure, options, serverTz) {
2572
2643
  }
2573
2644
 
2574
2645
  // Resolve name collisions: second+ record with same name gets -1, -2, etc.
2646
+ // Use case-insensitive key to prevent companion file collisions on case-insensitive filesystems.
2575
2647
  const uid = record.UID || 'untitled';
2576
- const nameKey = name;
2648
+ const nameKey = name.toLowerCase();
2577
2649
  const nameCount = usedNames.get(nameKey) || 0;
2578
2650
  usedNames.set(nameKey, nameCount + 1);
2579
2651
  if (nameCount > 0) name = `${name}-${nameCount}`;
2580
2652
 
2581
- // Metadata: name.metadata~uid.json; companion files use natural name
2582
- const metaPath = join(dir, buildMetaFilename(name, uid));
2653
+ // Metadata: name.metadata.json; companion files use natural name
2654
+ const metaPath = join(dir, buildMetaFilename(name));
2583
2655
 
2584
2656
  // Legacy detection: rename old-format metadata files to new convention
2585
2657
  const legacyDotExtMetaPath = join(dir, `${name}.${uid}.metadata.json`);
@@ -2775,22 +2847,92 @@ async function processExtensionEntries(entries, structure, options, serverTz) {
2775
2847
  async function processMediaEntries(mediaRecords, structure, options, config, appShortName, serverTz, skipUIDs = new Set(), resolvedFilenames = new Map()) {
2776
2848
  if (!mediaRecords || mediaRecords.length === 0) return [];
2777
2849
 
2850
+ // ── Step 0: Classify records by path scope ──────────────────────────────
2851
+ // foreign = /media/<other_app>/... (not this app — never download)
2852
+ // user = /media/<appShortName>/user/... (opt-in via UserMedia config)
2853
+ // app = /media/<appShortName>/app/... or no FullPath (always download)
2854
+ const foreignRecords = [];
2855
+ const userRecords = [];
2856
+ const appRecords = [];
2857
+
2858
+ for (const record of mediaRecords) {
2859
+ if (skipUIDs.has(record.UID)) continue;
2860
+ const cls = classifyMediaRecord(record, appShortName);
2861
+ if (cls === 'foreign') foreignRecords.push(record);
2862
+ else if (cls === 'user') userRecords.push(record);
2863
+ else appRecords.push(record);
2864
+ }
2865
+
2866
+ // Silently write stale metadata for foreign records so they are never
2867
+ // re-prompted on future clones. No download is attempted.
2868
+ const foreignRefs = [];
2869
+ for (const record of foreignRecords) {
2870
+ const { metaPath: scanMetaPath, dir: scanDir } = resolveMediaPaths(record, structure);
2871
+ const resolvedName = resolvedFilenames.get(record.UID);
2872
+ const effectiveMetaPath = resolvedName
2873
+ ? join(scanDir, buildMetaFilename(resolvedName))
2874
+ : scanMetaPath;
2875
+ if (!(await fileExists(effectiveMetaPath))) {
2876
+ const staleMeta = { _entity: 'media', _foreignApp: true };
2877
+ for (const [k, v] of Object.entries(record)) {
2878
+ if (k !== 'children') staleMeta[k] = v;
2879
+ }
2880
+ try {
2881
+ await mkdir(scanDir, { recursive: true });
2882
+ await writeFile(effectiveMetaPath, JSON.stringify(staleMeta, null, 2) + '\n');
2883
+ if (serverTz && (record._CreatedOn || record._LastUpdated)) {
2884
+ await setFileTimestamps(effectiveMetaPath, record._CreatedOn, record._LastUpdated, serverTz);
2885
+ }
2886
+ } catch { /* non-critical */ }
2887
+ }
2888
+ foreignRefs.push({ uid: record.UID, metaPath: effectiveMetaPath });
2889
+ }
2890
+ if (foreignRecords.length > 0) {
2891
+ log.dim(` Skipped ${foreignRecords.length} foreign media file(s) (not in /media/${appShortName || '?'}/)`);
2892
+ }
2893
+
2894
+ // ── User media: prompt once if preference is unset ───────────────────────
2895
+ // config.UserMedia: true = include, false = skip, undefined = not yet asked
2896
+ let includeUserMedia = config.UserMedia ?? null;
2897
+ if (userRecords.length > 0 && includeUserMedia === null && !options.yes) {
2898
+ const inquirer = (await import('inquirer')).default;
2899
+ const exampleFile = userRecords[0].Filename || userRecords[0].Name || 'user file';
2900
+ const { includeUser } = await inquirer.prompt([{
2901
+ type: 'confirm',
2902
+ name: 'includeUser',
2903
+ message: `App has ${userRecords.length} user media file(s) (e.g. "${exampleFile}"). Download user media? (saves preference)`,
2904
+ default: false,
2905
+ }]);
2906
+ includeUserMedia = includeUser;
2907
+ await updateConfigUserMedia(includeUserMedia);
2908
+ } else if (userRecords.length > 0 && options.yes && includeUserMedia === null) {
2909
+ // Non-interactive: default to false, save preference
2910
+ includeUserMedia = false;
2911
+ await updateConfigUserMedia(false);
2912
+ }
2913
+
2914
+ if (!includeUserMedia && userRecords.length > 0) {
2915
+ log.dim(` Skipped ${userRecords.length} user media file(s) (UserMedia=false)`);
2916
+ }
2917
+
2918
+ // Active records = app media + user media (if opted in)
2919
+ const activeRecords = includeUserMedia ? [...appRecords, ...userRecords] : appRecords;
2920
+
2778
2921
  // Track stale records (404s) for cleanup prompt
2779
2922
  const staleRecords = [];
2780
2923
 
2781
2924
  // Pre-scan: determine which media files actually need downloading
2782
2925
  // (new files or files with newer server timestamps)
2783
2926
  const needsDownload = [];
2784
- const upToDateRefs = [];
2927
+ const upToDateRefs = [...foreignRefs];
2785
2928
 
2786
- for (const record of mediaRecords) {
2787
- if (skipUIDs.has(record.UID)) continue;
2929
+ for (const record of activeRecords) {
2788
2930
 
2789
2931
  const { metaPath: scanMetaPath, dir: scanDir } = resolveMediaPaths(record, structure);
2790
2932
  // Use collision-resolved filename for metadata path when available (e.g. "_media" suffix)
2791
2933
  const resolvedName = resolvedFilenames.get(record.UID);
2792
2934
  const effectiveMetaPath = resolvedName
2793
- ? join(scanDir, buildMetaFilename(resolvedName, String(record.UID || record._id || 'untitled')))
2935
+ ? join(scanDir, buildMetaFilename(resolvedName))
2794
2936
  : scanMetaPath;
2795
2937
  const scanExists = await fileExists(effectiveMetaPath);
2796
2938
 
@@ -2815,7 +2957,7 @@ async function processMediaEntries(mediaRecords, structure, options, config, app
2815
2957
  }
2816
2958
 
2817
2959
  if (needsDownload.length === 0) {
2818
- log.dim(` All ${mediaRecords.length} media file(s) up to date`);
2960
+ log.dim(` All ${activeRecords.length} media file(s) up to date`);
2819
2961
  return upToDateRefs;
2820
2962
  }
2821
2963
 
@@ -2828,7 +2970,7 @@ async function processMediaEntries(mediaRecords, structure, options, config, app
2828
2970
  const { download } = await inquirer.prompt([{
2829
2971
  type: 'confirm',
2830
2972
  name: 'download',
2831
- message: `${needsDownload.length} media file(s) need to be downloaded (${mediaRecords.length - needsDownload.length} up to date). Attempt download now?`,
2973
+ message: `${needsDownload.length} media file(s) need to be downloaded (${activeRecords.length - needsDownload.length} up to date). Attempt download now?`,
2832
2974
  default: true,
2833
2975
  }]);
2834
2976
  canDownload = download;
@@ -2857,9 +2999,9 @@ async function processMediaEntries(mediaRecords, structure, options, config, app
2857
2999
  let downloaded = 0;
2858
3000
  let failed = 0;
2859
3001
 
2860
- log.info(`Downloading ${mediaRecords.length} media file(s)...`);
3002
+ log.info(`Downloading ${activeRecords.length} media file(s)...`);
2861
3003
 
2862
- for (const record of mediaRecords) {
3004
+ for (const record of activeRecords) {
2863
3005
  const filename = record.Filename || `${record.Name || record.UID}.${(record.Extension || 'bin').toLowerCase()}`;
2864
3006
 
2865
3007
  if (skipUIDs.has(record.UID)) {
@@ -2880,12 +3022,11 @@ async function processMediaEntries(mediaRecords, structure, options, config, app
2880
3022
  if (!dir) dir = BINS_DIR;
2881
3023
  await mkdir(dir, { recursive: true });
2882
3024
 
2883
- // Companion: natural name, no UID (use collision-resolved override if available)
2884
- const uid = String(record.UID || record._id || 'untitled');
3025
+ // Companion: natural name (use collision-resolved override if available)
2885
3026
  const finalFilename = resolvedFilenames.get(record.UID) || sanitizeFilename(filename);
2886
3027
  const filePath = join(dir, finalFilename);
2887
- // Metadata: use collision-resolved filename as base (e.g. "env_media.js" → "env_media.js.metadata~uid.json")
2888
- const metaPath = join(dir, buildMetaFilename(finalFilename, uid));
3028
+ // Metadata: use collision-resolved filename as base (e.g. "env_media.js" → "env_media.js.metadata.json")
3029
+ const metaPath = join(dir, buildMetaFilename(finalFilename));
2889
3030
  // usedNames retained for tracking
2890
3031
  const fileKey = `${dir}/${name}.${ext}`;
2891
3032
  usedNames.set(fileKey, (usedNames.get(fileKey) || 0) + 1);
@@ -3157,7 +3298,7 @@ async function processRecord(entityName, record, structure, options, usedNames,
3157
3298
  probeDir = probeDir.replace(/^\/+|\/+$/g, '') || BINS_DIR;
3158
3299
  if (extname(probeDir)) probeDir = probeDir.substring(0, probeDir.lastIndexOf('/')) || BINS_DIR;
3159
3300
 
3160
- const probeMeta = join(probeDir, buildMetaFilename(sanitized, uid));
3301
+ const probeMeta = join(probeDir, buildMetaFilename(sanitized));
3161
3302
  const raw = await readFile(probeMeta, 'utf8');
3162
3303
  const localMeta = JSON.parse(raw);
3163
3304
  // Extract extension from Content @reference (e.g. "@Name~uid.html")
@@ -3248,8 +3389,7 @@ async function processRecord(entityName, record, structure, options, usedNames,
3248
3389
  const uid = String(record.UID || record._id || 'untitled');
3249
3390
  // Companion: natural name, no UID (use collision-resolved override if available)
3250
3391
  const fileName = filenameOverride || sanitizeFilename(buildContentFileName(record, uid));
3251
- // Metadata: name.metadata~uid.json
3252
- // usedNames retained for non-UID edge case tracking
3392
+ // Metadata: name.metadata.json; usedNames retained for non-UID edge case tracking
3253
3393
  const nameKey = `${dir}/${name}`;
3254
3394
  usedNames.set(nameKey, (usedNames.get(nameKey) || 0) + 1);
3255
3395
 
@@ -3261,7 +3401,7 @@ async function processRecord(entityName, record, structure, options, usedNames,
3261
3401
  );
3262
3402
 
3263
3403
  const filePath = join(dir, fileName);
3264
- const metaPath = join(dir, buildMetaFilename(name, uid));
3404
+ const metaPath = join(dir, buildMetaFilename(name));
3265
3405
 
3266
3406
  // Rename legacy ~UID companion files to natural names if needed
3267
3407
  if (await fileExists(metaPath)) {
@@ -3990,22 +4130,32 @@ async function processOutputHierarchy(appJson, structure, options, serverTz) {
3990
4130
  // Build root output filename (natural name, no UID in stem)
3991
4131
  const rootBasename = buildOutputFilename('output', output, filenameCols.output);
3992
4132
  const rootUid = output.UID || '';
3993
- let rootMetaPath = join(binDir, buildMetaFilename(rootBasename, rootUid));
4133
+
4134
+ // Resolve metadata path with collision detection (content records have priority over output).
4135
+ // If name.metadata.json is owned by a different UID (e.g. a content record), use -1, -2, etc.
4136
+ let rootMetaPath = await resolveMetaCollision(binDir, rootBasename, rootUid);
3994
4137
 
3995
4138
  // Legacy fallback: rename old-format metadata to new convention
3996
4139
  const legacyTildeOutputMeta = join(binDir, `${rootBasename}~${rootUid}.metadata.json`);
3997
4140
  const legacyJsonPath = join(binDir, `${rootBasename}.json`);
3998
- const legacyOutputMeta = join(binDir, `${rootBasename}.metadata.json`);
3999
4141
  if (!await fileExists(rootMetaPath)) {
4000
4142
  if (await fileExists(legacyTildeOutputMeta)) {
4001
4143
  await rename(legacyTildeOutputMeta, rootMetaPath);
4002
4144
  log.dim(` Renamed ${basename(legacyTildeOutputMeta)} → ${basename(rootMetaPath)}`);
4003
- } else if (await fileExists(legacyOutputMeta)) {
4004
- await rename(legacyOutputMeta, rootMetaPath);
4005
- log.dim(` Renamed ${basename(legacyOutputMeta)} → ${basename(rootMetaPath)}`);
4006
4145
  } else if (await fileExists(legacyJsonPath)) {
4007
- await rename(legacyJsonPath, rootMetaPath);
4008
- log.dim(` Renamed ${rootBasename}.json ${basename(rootMetaPath)}`);
4146
+ // Only rename if the .json file actually looks like output metadata JSON.
4147
+ // Content companions can share the same {name}.json filename pattern —
4148
+ // renaming those would corrupt the content record's companion file.
4149
+ let isOutputMeta = false;
4150
+ try {
4151
+ const legacyContent = await readFile(legacyJsonPath, 'utf8');
4152
+ const legacyParsed = JSON.parse(legacyContent);
4153
+ isOutputMeta = legacyParsed && (legacyParsed._entity === 'output' || legacyParsed.OutputID != null);
4154
+ } catch { /* not valid JSON — definitely a content companion, skip */ }
4155
+ if (isOutputMeta) {
4156
+ await rename(legacyJsonPath, rootMetaPath);
4157
+ log.dim(` Renamed ${rootBasename}.json → ${basename(rootMetaPath)}`);
4158
+ }
4009
4159
  }
4010
4160
  }
4011
4161
 
@@ -4113,53 +4263,267 @@ async function processOutputHierarchy(appJson, structure, options, serverTz) {
4113
4263
  }
4114
4264
 
4115
4265
  /**
4116
- * Write manifest.json to project root.
4117
- * If a manifest content record was cloned from the server, use its Content value.
4118
- * Otherwise, generate from appJson values (empty strings for missing fields).
4266
+ * Write root content files (manifest.json, CLAUDE.md, README.md, etc.) to the project root.
4267
+ *
4268
+ * Two passes:
4269
+ * 1. For each filename in rootContentFiles config: search contentRefs for a matching record,
4270
+ * relocate its companion to the project root, and rewrite the metadata to use
4271
+ * Content: "@/<filename>" (root-relative). Fall back to a stub if no server record.
4272
+ * 2. Promote any remaining content records with BinID=null whose companion filename is also
4273
+ * in rootContentFiles (catches records not matched by pass 1's name/content/path heuristics).
4119
4274
  */
4120
- async function writeManifestJson(appJson, contentRefs) {
4121
- // 1. Search contentRefs for a manifest content record
4275
+ async function writeRootContentFiles(appJson, contentRefs) {
4276
+ const rootFiles = await loadRootContentFiles();
4277
+ const handledUids = new Set();
4278
+
4279
+ for (const filename of rootFiles) {
4280
+ const handledUid = await _writeRootFile(filename, appJson, contentRefs);
4281
+ if (handledUid) {
4282
+ handledUids.add(handledUid);
4283
+ } else {
4284
+ await _generateRootFileStub(filename, appJson);
4285
+ }
4286
+ }
4287
+
4288
+ // Promote any content records with no BinID that weren't already handled above,
4289
+ // but only if the companion filename appears in rootContentFiles.
4290
+ await _promoteNoBinContentToRoot(contentRefs, handledUids, rootFiles);
4291
+ }
4292
+
4293
+ /**
4294
+ * Find the content record for a root file and relocate its companion to the project root.
4295
+ * Returns the record UID if handled, null if no matching record was found.
4296
+ */
4297
+ async function _writeRootFile(filename, appJson, contentRefs) {
4298
+ const filenameLower = filename.toLowerCase();
4299
+ const stemLower = filenameLower.includes('.') ? filenameLower.slice(0, filenameLower.lastIndexOf('.')) : filenameLower;
4300
+ const extLower = filenameLower.includes('.') ? filenameLower.slice(filenameLower.lastIndexOf('.') + 1) : '';
4301
+
4122
4302
  for (const ref of contentRefs) {
4123
4303
  let meta;
4304
+ try { meta = JSON.parse(await readFile(ref.metaPath, 'utf8')); } catch { continue; }
4305
+
4306
+ // Match by Name+Extension, or by Content @reference, or by Path
4307
+ const metaName = (meta.Name || '').toLowerCase();
4308
+ const metaExt = (meta.Extension || '').toLowerCase();
4309
+ const metaContent = String(meta.Content || '');
4310
+ const metaPath2 = String(meta.Path || '');
4311
+ const contentRef = metaContent.startsWith('@/') ? metaContent.slice(2)
4312
+ : metaContent.startsWith('@') ? metaContent.slice(1)
4313
+ : null;
4314
+
4315
+ const matchByName = metaName.startsWith(stemLower) && metaExt === extLower;
4316
+ const matchByContent = contentRef && contentRef.toLowerCase() === filenameLower;
4317
+ const matchByPath = metaPath2.replace(/^\//, '').toLowerCase() === filenameLower;
4318
+
4319
+ if (!matchByName && !matchByContent && !matchByPath) continue;
4320
+
4321
+ // Found a matching record — read companion content from bins/app dir
4322
+ const metaDir = dirname(ref.metaPath);
4323
+ const localCompanion = join(metaDir, filename);
4324
+ let content;
4124
4325
  try {
4125
- meta = JSON.parse(await readFile(ref.metaPath, 'utf8'));
4126
- } catch { continue; }
4127
-
4128
- const name = (meta.Name || '').toLowerCase();
4129
- const ext = (meta.Extension || '').toLowerCase();
4130
- if (name.startsWith('manifest') && ext === 'json') {
4131
- // Found manifest — read content file and write to project root
4132
- const contentRef = meta.Content;
4133
- if (contentRef && String(contentRef).startsWith('@')) {
4134
- const refFile = String(contentRef).substring(1);
4135
- // Try root-relative first, then sibling of metadata, then project root fallback
4136
- const candidates = refFile.startsWith('/')
4137
- ? [join(process.cwd(), refFile)]
4138
- : [join(dirname(ref.metaPath), refFile), join(process.cwd(), refFile)];
4139
- let content;
4140
- for (const candidate of candidates) {
4141
- try {
4142
- content = await readFile(candidate, 'utf8');
4143
- break;
4144
- } catch { /* try next */ }
4145
- }
4146
- if (content !== undefined) {
4147
- await writeFile('manifest.json', content);
4148
- log.dim(' manifest.json written to project root (from server content)');
4149
- } else {
4150
- log.warn(` Could not find manifest.json companion file`);
4151
- }
4326
+ content = await readFile(localCompanion, 'utf8');
4327
+ } catch {
4328
+ // Companion may already be at root (re-clone scenario) or never written (no Content on server)
4329
+ try { content = await readFile(join(process.cwd(), filename), 'utf8'); } catch { /* nothing */ }
4330
+ }
4331
+
4332
+ if (content !== undefined) {
4333
+ await writeFile(join(process.cwd(), filename), content);
4334
+ log.dim(` ${filename} written to project root (from server content)`);
4335
+
4336
+ // Delete stale companion in bins/app if it exists there
4337
+ if (localCompanion !== join(process.cwd(), filename)) {
4338
+ try { await unlink(localCompanion); } catch { /* already gone or at root */ }
4152
4339
  }
4153
- return;
4340
+ } else {
4341
+ log.warn(` Could not find companion file for ${filename}`);
4342
+ }
4343
+
4344
+ // Rewrite metadata: root-relative Content reference
4345
+ try {
4346
+ const updated = { ...meta, Content: `@/${filename}` };
4347
+ if (!updated._companionReferenceColumns) updated._companionReferenceColumns = ['Content'];
4348
+ await writeFile(ref.metaPath, JSON.stringify(updated, null, 2) + '\n');
4349
+ } catch { /* non-critical */ }
4350
+
4351
+ return ref.uid;
4352
+ }
4353
+
4354
+ return null; // No server record found
4355
+ }
4356
+
4357
+ /**
4358
+ * Promote content records with no BinID to the project root.
4359
+ * Only promotes records whose companion filename appears in rootContentFiles —
4360
+ * records not in that list stay in their lib/bins/ placement.
4361
+ * Skips records already handled by _writeRootFile (tracked via handledUids).
4362
+ * Companion filename is extracted from the Content @-reference in the metadata.
4363
+ */
4364
+ async function _promoteNoBinContentToRoot(contentRefs, handledUids, rootFiles) {
4365
+ const rootFilesLower = new Set((rootFiles || []).map(f => f.toLowerCase()));
4366
+ for (const ref of contentRefs) {
4367
+ if (handledUids.has(ref.uid)) continue;
4368
+
4369
+ let meta;
4370
+ try { meta = JSON.parse(await readFile(ref.metaPath, 'utf8')); } catch { continue; }
4371
+
4372
+ // Only promote records with no BinID
4373
+ if (meta.BinID != null) continue;
4374
+
4375
+ // Extract companion filename from Content @-reference
4376
+ const metaContent = String(meta.Content || '');
4377
+ const contentRef = metaContent.startsWith('@/') ? metaContent.slice(2)
4378
+ : metaContent.startsWith('@') ? metaContent.slice(1)
4379
+ : null;
4380
+
4381
+ // Skip if no companion reference, if it includes a path (not a root-level file),
4382
+ // or if it's not listed in rootContentFiles.
4383
+ if (!contentRef || contentRef.includes('/')) continue;
4384
+ if (!rootFilesLower.has(contentRef.toLowerCase())) continue;
4385
+
4386
+ const filename = contentRef;
4387
+ const metaDir = dirname(ref.metaPath);
4388
+ const localCompanion = join(metaDir, filename);
4389
+ let content;
4390
+ try {
4391
+ content = await readFile(localCompanion, 'utf8');
4392
+ } catch {
4393
+ try { content = await readFile(join(process.cwd(), filename), 'utf8'); } catch { /* nothing */ }
4394
+ }
4395
+
4396
+ if (content !== undefined) {
4397
+ await writeFile(join(process.cwd(), filename), content);
4398
+ log.dim(` ${filename} written to project root (BinID=null)`);
4399
+
4400
+ if (localCompanion !== join(process.cwd(), filename)) {
4401
+ try { await unlink(localCompanion); } catch { /* already gone or at root */ }
4402
+ }
4403
+ } else {
4404
+ log.warn(` Could not find companion file for ${filename} (BinID=null record)`);
4154
4405
  }
4406
+
4407
+ // Rewrite metadata: root-relative Content reference
4408
+ try {
4409
+ const updated = { ...meta, Content: `@/${filename}` };
4410
+ if (!updated._companionReferenceColumns) updated._companionReferenceColumns = ['Content'];
4411
+ await writeFile(ref.metaPath, JSON.stringify(updated, null, 2) + '\n');
4412
+ } catch { /* non-critical */ }
4413
+ }
4414
+ }
4415
+
4416
+ /**
4417
+ * Generate a fallback stub for a root content file when the server has no record.
4418
+ * Skips if the file already exists at the project root.
4419
+ */
4420
+ async function _generateRootFileStub(filename, appJson) {
4421
+ const rootPath = join(process.cwd(), filename);
4422
+ try { await access(rootPath); return; } catch { /* doesn't exist — generate */ }
4423
+
4424
+ const appName = appJson.Name || appJson.ShortName || '';
4425
+ const description = appJson.Description || '';
4426
+ const filenameLower = filename.toLowerCase();
4427
+
4428
+ if (filenameLower === 'manifest.json') {
4429
+ await _generateManifestFallback(appJson);
4430
+ return;
4431
+ }
4432
+
4433
+ if (filenameLower === 'claude.md') {
4434
+ const stub = [
4435
+ `# ${appName}`,
4436
+ ``,
4437
+ `## DBO CLI`,
4438
+ ``,
4439
+ `Always run \`dbo\` commands from the project root (the directory containing this CLAUDE.md file).`,
4440
+ ``,
4441
+ `## DBO API Submissions`,
4442
+ ``,
4443
+ `- To create new records, use the REST API (\`/api/input/submit\`) directly — the CLI has no \`add\` command.`,
4444
+ `- This project may require a ticket ID for all submissions when the RepositoryIntegrationID column in the baseline JSON is set. Read it from \`.app/ticketing.local.json\` and include \`_OverrideTicketID={ticket_id}\` as a query parameter on every \`/api/input/submit\` call.`,
4445
+ `- Cookie file for auth: \`.app/cookies.txt\``,
4446
+ `- If the project's baseline JSON (\`.app/baseline.json\`) has a \`ModifyKey\` field, the app is not updatable and should not be edited. If you must, the user must be supplying that key as _modifyKey in the submission query parameters to update existing records.`,
4447
+ `- Domain: read from \`.app/config.json\` (\`domain\` field)`,
4448
+ ``,
4449
+ `## Documentation`,
4450
+ ``,
4451
+ `Project docs may live in any of these locations:`,
4452
+ ``,
4453
+ `- \`CLAUDE.md\` _(this file)_`,
4454
+ `- \`README.md\``,
4455
+ `- \`docs/\``,
4456
+ `- \`lib/bins/docs/\``,
4457
+ `- \`lib/extension/Documentation/\``,
4458
+ ``,
4459
+ `For dependency apps, look under \`app_dependencies/<app_short_name>/\` using the same structure:`,
4460
+ ``,
4461
+ `- \`app_dependencies/<app_short_name>/CLAUDE.md\``,
4462
+ `- \`app_dependencies/<app_short_name>/README.md\``,
4463
+ `- \`app_dependencies/<app_short_name>/docs/\``,
4464
+ `- \`app_dependencies/<app_short_name>/lib/bins/docs/\``,
4465
+ `- \`app_dependencies/<app_short_name>/lib/extension/Documentation/\``,
4466
+ ``,
4467
+ `### DBO.io Framework API Reference`,
4468
+ ``,
4469
+ `For the DBO.io REST API (input/submit, output, content, etc.), check in priority order:`,
4470
+ ``,
4471
+ `1. \`app_dependencies/_system/lib/bins/docs/\` — authoritative source`,
4472
+ `2. \`plugins/claude/dbo/skills/white-paper/references/\` — fallback reference`,
4473
+ ``,
4474
+ ].join('\n');
4475
+ await writeFile(rootPath, stub);
4476
+ log.dim(` CLAUDE.md generated at project root (stub)`);
4477
+ return;
4155
4478
  }
4156
4479
 
4157
- // 2. No manifest content record — generate from appJson values
4480
+ if (filenameLower === 'readme.md') {
4481
+ const parts = [`# ${appName}`];
4482
+ if (description) parts.push('', description);
4483
+ parts.push('');
4484
+ await writeFile(rootPath, parts.join('\n'));
4485
+ log.dim(` README.md generated at project root (stub)`);
4486
+ return;
4487
+ }
4488
+
4489
+ if (filenameLower === 'package.json') {
4490
+ const shortName = (appJson.ShortName || appName || 'app').toLowerCase().replace(/\s+/g, '-');
4491
+ const stub = JSON.stringify({
4492
+ name: shortName,
4493
+ version: '1.0.0',
4494
+ description: description || '',
4495
+ private: true,
4496
+ }, null, 2) + '\n';
4497
+ await writeFile(rootPath, stub);
4498
+ log.dim(` package.json generated at project root (stub)`);
4499
+ return;
4500
+ }
4501
+
4502
+ if (filenameLower === '.dboignore') {
4503
+ await writeFile(rootPath, getDboignoreDefaultContent());
4504
+ log.dim(` .dboignore generated at project root (stub)`);
4505
+ return;
4506
+ }
4507
+
4508
+ if (filenameLower === '.gitignore') {
4509
+ const lines = DEFAULT_GITIGNORE_ENTRIES.map(e => e).join('\n') + '\n';
4510
+ await writeFile(rootPath, lines);
4511
+ log.dim(` .gitignore generated at project root (stub)`);
4512
+ return;
4513
+ }
4514
+
4515
+ // Unknown file type — no stub generated
4516
+ }
4517
+
4518
+ /**
4519
+ * Generate manifest.json at project root from appJson values.
4520
+ * Preserves existing behaviour from the old writeManifestJson fallback.
4521
+ */
4522
+ async function _generateManifestFallback(appJson) {
4158
4523
  const shortName = appJson.ShortName || '';
4159
4524
  const appName = appJson.Name || '';
4160
4525
  const description = appJson.Description || '';
4161
4526
 
4162
- // Find background_color from extension children (widget descriptor matching ShortName)
4163
4527
  let bgColor = '#ffffff';
4164
4528
  if (shortName) {
4165
4529
  const extensions = appJson.children?.extension || [];
@@ -4191,7 +4555,7 @@ async function writeManifestJson(appJson, contentRefs) {
4191
4555
  icons: [],
4192
4556
  };
4193
4557
 
4194
- await writeFile('manifest.json', JSON.stringify(manifest, null, 2) + '\n');
4558
+ await writeFile(join(process.cwd(), 'manifest.json'), JSON.stringify(manifest, null, 2) + '\n');
4195
4559
  log.dim(' manifest.json generated at project root (from app metadata)');
4196
4560
  }
4197
4561