@dboio/cli 0.19.7 → 0.20.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,18 +3,18 @@ import { readFile, writeFile, appendFile, mkdir, access, readdir, rename, stat,
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, updateConfigUserMedia, 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, loadRootContentFiles } 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';
16
16
  import { fetchSchema, loadSchema, saveSchema, isSchemaStale } from '../lib/schema.js';
17
- import { appMetadataPath } from '../lib/config.js';
17
+ import { appMetadataPath, baselinePath, metadataSchemaPath } from '../lib/config.js';
18
18
  import { runPendingMigrations } from '../lib/migrations.js';
19
19
  import { upsertDeployEntry } from '../lib/deploy-config.js';
20
20
  import { syncDependencies, parseDependenciesColumn } from '../lib/dependencies.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) {
@@ -219,6 +250,114 @@ export async function detectAndTrashOrphans(appJson, ig, sync, options) {
219
250
  }
220
251
  }
221
252
 
253
+ /**
254
+ * Use the explicit `appJson.deleted` map (returned by the server in delta/baseline responses)
255
+ * to find and trash local files for records the server has deleted.
256
+ *
257
+ * Unlike detectAndTrashOrphans() which diffs all local UIDs against all server UIDs,
258
+ * this function is authoritative: if the server says a UID was deleted, it moves the
259
+ * local files immediately — no full UID scan needed. This makes it safe to call in
260
+ * pull/delta mode where appJson.children may only contain changed records.
261
+ *
262
+ * @param {object} appJson - App JSON possibly containing a `deleted` map
263
+ * @param {import('ignore').Ignore} ig - Ignore instance for findMetadataFiles
264
+ * @param {object} sync - Parsed synchronize.json { delete, edit, add }
265
+ * @param {object} options - Clone options
266
+ */
267
+ export async function trashServerDeletedRecords(appJson, ig, sync, options) {
268
+ if (options.entityFilter) return;
269
+ if (!appJson?.deleted || typeof appJson.deleted !== 'object') return;
270
+
271
+ // Build set of UIDs to trash from all entities in deleted map
272
+ const deletedUids = new Map(); // uid → { entity, name }
273
+ for (const [entity, entries] of Object.entries(appJson.deleted)) {
274
+ if (!Array.isArray(entries)) continue;
275
+ for (const entry of entries) {
276
+ if (entry?.UID) {
277
+ deletedUids.set(String(entry.UID), { entity, name: entry.Name || entry.UID });
278
+ }
279
+ }
280
+ }
281
+
282
+ if (deletedUids.size === 0) return;
283
+
284
+ // UIDs already queued for deletion in synchronize.json — skip them
285
+ const stagedDeleteUids = new Set(
286
+ (sync.delete || []).map(e => e.UID).filter(Boolean).map(String)
287
+ );
288
+
289
+ const metaFiles = await findMetadataFiles(process.cwd(), ig);
290
+ if (metaFiles.length === 0) return;
291
+
292
+ const trashDir = join(process.cwd(), 'trash');
293
+ const toTrash = [];
294
+
295
+ for (const metaPath of metaFiles) {
296
+ let meta;
297
+ try {
298
+ meta = JSON.parse(await readFile(metaPath, 'utf8'));
299
+ } catch {
300
+ continue;
301
+ }
302
+
303
+ if (!meta.UID) continue;
304
+ const uid = String(meta.UID);
305
+ if (!deletedUids.has(uid)) continue;
306
+ if (stagedDeleteUids.has(uid)) continue;
307
+
308
+ const metaDir = dirname(metaPath);
309
+ const filesToMove = [metaPath];
310
+
311
+ for (const col of (meta._companionReferenceColumns || meta._contentColumns || [])) {
312
+ const ref = meta[col];
313
+ if (ref && String(ref).startsWith('@')) {
314
+ const refName = String(ref).substring(1);
315
+ const companionPath = refName.startsWith('/')
316
+ ? join(process.cwd(), refName)
317
+ : join(metaDir, refName);
318
+ if (await fileExists(companionPath)) filesToMove.push(companionPath);
319
+ }
320
+ }
321
+
322
+ if (meta._mediaFile && String(meta._mediaFile).startsWith('@')) {
323
+ const refName = String(meta._mediaFile).substring(1);
324
+ const mediaPath = refName.startsWith('/')
325
+ ? join(process.cwd(), refName)
326
+ : join(metaDir, refName);
327
+ if (await fileExists(mediaPath)) filesToMove.push(mediaPath);
328
+ }
329
+
330
+ const { entity } = deletedUids.get(uid);
331
+ toTrash.push({ metaPath, uid, entity, filesToMove });
332
+ }
333
+
334
+ if (toTrash.length === 0) return;
335
+
336
+ await mkdir(trashDir, { recursive: true });
337
+
338
+ let trashed = 0;
339
+ for (const { metaPath, uid, entity, filesToMove } of toTrash) {
340
+ log.dim(` Trashed (server deleted): ${basename(metaPath)} (${entity}:${uid})`);
341
+ for (const filePath of filesToMove) {
342
+ const destBase = basename(filePath);
343
+ let destPath = join(trashDir, destBase);
344
+ try { await stat(destPath); destPath = `${destPath}.${Date.now()}`; } catch {}
345
+ try {
346
+ await rename(filePath, destPath);
347
+ trashed++;
348
+ } catch (err) {
349
+ log.warn(` Could not trash: ${filePath} — ${err.message}`);
350
+ }
351
+ }
352
+ }
353
+
354
+ if (trashed > 0) {
355
+ await ensureTrashIcon(trashDir);
356
+ log.plain('');
357
+ log.warn(`Moved ${toTrash.length} server-deleted record(s) to trash`);
358
+ }
359
+ }
360
+
222
361
  /**
223
362
  * Resolve a content Path to a directory under Bins/.
224
363
  *
@@ -322,8 +461,9 @@ export function resolveRecordPaths(entityName, record, structure, placementPref)
322
461
  const uid = String(record.UID || record._id || 'untitled');
323
462
  // Companion: natural name, no UID
324
463
  const filename = sanitizeFilename(buildContentFileName(record, uid));
325
- // Metadata: name.metadata~uid.json
326
- const metaPath = join(dir, buildMetaFilename(name, uid));
464
+ // Metadata: filename.metadata.json (includes extension to avoid collisions between records
465
+ // with the same Name but different Extension, e.g. codeTest.js vs codeTest.css)
466
+ const metaPath = join(dir, buildMetaFilename(filename));
327
467
 
328
468
  return { dir, filename, metaPath };
329
469
  }
@@ -352,10 +492,9 @@ export function resolveMediaPaths(record, structure) {
352
492
  dir = dir.replace(/^\/+|\/+$/g, '');
353
493
  if (!dir) dir = BINS_DIR;
354
494
 
355
- // Metadata: name.ext.metadata~uid.json
356
- const uid = String(record.UID || record._id || 'untitled');
495
+ // Metadata: name.ext.metadata.json
357
496
  const naturalMediaBase = `${name}.${ext}`;
358
- const metaPath = join(dir, buildMetaFilename(naturalMediaBase, uid));
497
+ const metaPath = join(dir, buildMetaFilename(naturalMediaBase));
359
498
 
360
499
  return { dir, filename: companionFilename, metaPath };
361
500
  }
@@ -397,8 +536,7 @@ export function resolveEntityDirPaths(entityName, record, dirName) {
397
536
  name = sanitizeFilename(String(record.UID || 'untitled'));
398
537
  }
399
538
 
400
- const uid = record.UID || 'untitled';
401
- const metaPath = join(dirName, buildMetaFilename(name, uid));
539
+ const metaPath = join(dirName, buildMetaFilename(name));
402
540
  return { dir: dirName, name, metaPath };
403
541
  }
404
542
 
@@ -1197,11 +1335,42 @@ export async function performClone(source, options = {}) {
1197
1335
  }
1198
1336
  }
1199
1337
 
1338
+ // Save AppShortName to config before writing the metadata schema so that
1339
+ // metadataSchemaPath() resolves to <shortname>.metadata_schema.json rather
1340
+ // than falling back to the generic app.metadata_schema.json. Without this,
1341
+ // processExtensionEntries() later loads null (wrong file) and descriptor
1342
+ // sub-directories + companion @reference entries are lost.
1343
+ if (!options.pullMode && appJson?.ShortName) {
1344
+ // If the app's ShortName changed, rename the .app/ files that are keyed by it
1345
+ // before updating config so the old paths can still be resolved.
1346
+ const oldShortName = config.AppShortName;
1347
+ const newShortName = appJson.ShortName;
1348
+ if (oldShortName && oldShortName !== newShortName) {
1349
+ const oldBaseline = await baselinePath();
1350
+ const oldAppMeta = await appMetadataPath();
1351
+ const oldSchema = await metadataSchemaPath();
1352
+ await updateConfigWithApp({ AppShortName: newShortName });
1353
+ const newBaseline = await baselinePath();
1354
+ const newAppMeta = await appMetadataPath();
1355
+ const newSchema = await metadataSchemaPath();
1356
+ for (const [oldPath, newPath] of [[oldBaseline, newBaseline], [oldAppMeta, newAppMeta], [oldSchema, newSchema]]) {
1357
+ try { await access(oldPath); await rename(oldPath, newPath); log.dim(` Renamed ${basename(oldPath)} → ${basename(newPath)}`); } catch { /* file absent, nothing to rename */ }
1358
+ }
1359
+ } else {
1360
+ await updateConfigWithApp({ AppShortName: newShortName });
1361
+ }
1362
+ }
1363
+
1200
1364
  // Regenerate metadata_schema.json for any new entity types
1201
1365
  if (schema) {
1202
1366
  const existing = await loadMetadataSchema();
1203
1367
  const updated = generateMetadataFromSchema(schema, existing ?? {});
1204
1368
  await saveMetadataSchema(updated);
1369
+ // Remove orphaned app.metadata_schema.json left by previous runs that wrote
1370
+ // the schema before AppShortName was saved (entity entries are regenerated above).
1371
+ if (appJson?.ShortName && appJson.ShortName !== 'app') {
1372
+ try { await unlink(join('.app', 'app.metadata_schema.json')); } catch { /* not present */ }
1373
+ }
1205
1374
  }
1206
1375
 
1207
1376
  // Domain change detection
@@ -1222,7 +1391,7 @@ export async function performClone(source, options = {}) {
1222
1391
 
1223
1392
  // Ensure sensitive files are gitignored (skip for dependency checkouts — not user projects)
1224
1393
  if (!isDependencyCheckout()) {
1225
- await ensureGitignore(['.app/credentials.json', '.app/cookies.txt', '.app/ticketing.local.json', '.app/scripts.local.json', '.app/errors.log', 'app_dependencies/']);
1394
+ await ensureGitignore(DEFAULT_GITIGNORE_ENTRIES);
1226
1395
  }
1227
1396
 
1228
1397
  // Step 2: Update .app/config.json (skip in pull mode — config already set)
@@ -1415,7 +1584,7 @@ export async function performClone(source, options = {}) {
1415
1584
 
1416
1585
  // Step 5a: Write root content files (manifest.json, CLAUDE.md, README.md, etc.) to project root.
1417
1586
  // Also fixes the duplicate bug: relocates companions from lib/bins/app/ to root and rewrites metadata.
1418
- if ((!entityFilter || entityFilter.has('content')) && !isDependencyCheckout()) {
1587
+ if (!entityFilter || entityFilter.has('content')) {
1419
1588
  await writeRootContentFiles(appJson, contentRefs);
1420
1589
  }
1421
1590
 
@@ -1482,6 +1651,9 @@ export async function performClone(source, options = {}) {
1482
1651
  if (!entityFilter) {
1483
1652
  const ig = await loadIgnore();
1484
1653
  const sync = await loadSynchronize();
1654
+ // Use explicit deleted list from server first (authoritative, works in delta/pull mode)
1655
+ await trashServerDeletedRecords(appJson, ig, sync, { ...options, entityFilter });
1656
+ // Fall back to full UID diff for records absent from server but not in deleted list
1485
1657
  await detectAndTrashOrphans(appJson, ig, sync, { ...options, entityFilter });
1486
1658
  }
1487
1659
 
@@ -1978,14 +2150,15 @@ async function processEntityDirEntries(entityName, entries, options, serverTz) {
1978
2150
  }
1979
2151
 
1980
2152
  // Resolve name collisions: second+ record with same name gets -1, -2, etc.
2153
+ // Use case-insensitive key to prevent companion file collisions on case-insensitive filesystems.
1981
2154
  const uid = record.UID || 'untitled';
1982
- const nameKey = name;
2155
+ const nameKey = name.toLowerCase();
1983
2156
  const count = usedNames.get(nameKey) || 0;
1984
2157
  usedNames.set(nameKey, count + 1);
1985
2158
  if (count > 0) name = `${name}-${count}`;
1986
2159
 
1987
- // Metadata: name.metadata~uid.json; companion files use natural name
1988
- const metaPath = join(dirName, buildMetaFilename(name, uid));
2160
+ // Metadata: name.metadata.json; companion files use natural name
2161
+ const metaPath = join(dirName, buildMetaFilename(name));
1989
2162
 
1990
2163
  // Legacy detection: rename old-format metadata files to new convention
1991
2164
  const legacyDotMetaPath = join(dirName, `${name}.${uid}.metadata.json`);
@@ -2031,7 +2204,7 @@ async function processEntityDirEntries(entityName, entries, options, serverTz) {
2031
2204
  // Skip __WILL_DELETE__-prefixed files — treat as "no existing file"
2032
2205
  const willDeleteEntityMeta = join(dirName, `${WILL_DELETE_PREFIX}${basename(metaPath)}`);
2033
2206
  const entityMetaExists = await fileExists(metaPath) && !await fileExists(willDeleteEntityMeta);
2034
- if (entityMetaExists && !options.yes && !hasNewExtractions) {
2207
+ if (entityMetaExists && !options.yes && !options.force && !hasNewExtractions) {
2035
2208
  if (bulkAction.value === 'skip_all') {
2036
2209
  log.dim(` Skipped ${name}`);
2037
2210
  refs.push({ uid: record.UID, metaPath });
@@ -2217,7 +2390,7 @@ function parseFormControlCode(string5) {
2217
2390
  if (codeStr) {
2218
2391
  for (const pair of codeStr.split(',')) {
2219
2392
  const [col, ext] = pair.split('|');
2220
- if (col?.trim() && ext?.trim()) colToExt.set(col.trim(), ext.trim().toLowerCase());
2393
+ if (col?.trim()) colToExt.set(col.trim(), ext?.trim().toLowerCase() || 'md');
2221
2394
  }
2222
2395
  }
2223
2396
  const titleStr = params.get('form-control-title');
@@ -2284,9 +2457,10 @@ async function buildDescriptorPrePass(extensionEntries, structure, metadataSchem
2284
2457
  const { colToExt, colToTitle } = parseFormControlCode(string5);
2285
2458
  if (colToExt.size === 0) continue;
2286
2459
 
2287
- // Only populate if no entry already exists for this descriptor
2460
+ // Only skip if the existing entry already has @reference expressions;
2461
+ // plain-column entries (seeded without form-control-code) should be overwritten
2288
2462
  const existing = getTemplateCols(metadataSchema, 'extension', descriptor);
2289
- if (existing) continue;
2463
+ if (existing?.some(e => e.includes('@reference'))) continue;
2290
2464
 
2291
2465
  const refEntries = [];
2292
2466
  for (const [col, ext] of colToExt) {
@@ -2469,8 +2643,6 @@ function guessExtensionForDescriptor(descriptor, columnName) {
2469
2643
  * @returns {Promise<'inline'|'root'>}
2470
2644
  */
2471
2645
  async function resolveDocumentationPlacement(options) {
2472
- if (options.yes) return 'inline';
2473
-
2474
2646
  const saved = await loadExtensionDocumentationMDPlacement();
2475
2647
  if (saved && !options.force && !options.configure) {
2476
2648
  log.dim(` Documentation MD placement: ${saved} (saved)`);
@@ -2478,7 +2650,7 @@ async function resolveDocumentationPlacement(options) {
2478
2650
  }
2479
2651
 
2480
2652
  let placement;
2481
- if (options.configure) {
2653
+ if (options.configure && !options.yes) {
2482
2654
  const inquirer = (await import('inquirer')).default;
2483
2655
  ({ placement } = await inquirer.prompt([{
2484
2656
  type: 'list', name: 'placement',
@@ -2520,7 +2692,7 @@ async function processExtensionEntries(entries, structure, options, serverTz) {
2520
2692
  log.info(`Processing ${entries.length} extension record(s)...`);
2521
2693
 
2522
2694
  // Step A: Pre-pass — build mapping + create directories
2523
- const metadataSchema = await loadMetadataSchema();
2695
+ const metadataSchema = (await loadMetadataSchema()) ?? {};
2524
2696
  const mapping = await buildDescriptorPrePass(entries, structure, metadataSchema);
2525
2697
 
2526
2698
  // Clear documentation preferences when --force is used with --documentation-only
@@ -2568,12 +2740,19 @@ async function processExtensionEntries(entries, structure, options, serverTz) {
2568
2740
  }
2569
2741
 
2570
2742
  // Step D: Write files, one group at a time
2743
+ // descriptor_definition must be written before dependent descriptors (e.g. control)
2571
2744
  const refs = [];
2572
2745
  const bulkAction = { value: null };
2573
2746
  const legacyRenameAction = { value: null }; // 'rename_all' | 'skip_all' | null
2574
2747
  const config = await loadConfig();
2575
2748
 
2576
- for (const [descriptor, { dir, records }] of groups.entries()) {
2749
+ const sortedGroups = [...groups.entries()].sort(([a], [b]) => {
2750
+ if (a === 'descriptor_definition') return -1;
2751
+ if (b === 'descriptor_definition') return 1;
2752
+ return 0;
2753
+ });
2754
+
2755
+ for (const [descriptor, { dir, records }] of sortedGroups) {
2577
2756
  const { filenameCol, companionRefs } = descriptorPrefs.get(descriptor);
2578
2757
  const useRootDoc = (descriptor === 'documentation' && docPlacement === 'root');
2579
2758
  const mdColInfo = useRootDoc ? companionRefs.find(r => r.extensionCol === 'md') : null;
@@ -2593,14 +2772,15 @@ async function processExtensionEntries(entries, structure, options, serverTz) {
2593
2772
  }
2594
2773
 
2595
2774
  // Resolve name collisions: second+ record with same name gets -1, -2, etc.
2775
+ // Use case-insensitive key to prevent companion file collisions on case-insensitive filesystems.
2596
2776
  const uid = record.UID || 'untitled';
2597
- const nameKey = name;
2777
+ const nameKey = name.toLowerCase();
2598
2778
  const nameCount = usedNames.get(nameKey) || 0;
2599
2779
  usedNames.set(nameKey, nameCount + 1);
2600
2780
  if (nameCount > 0) name = `${name}-${nameCount}`;
2601
2781
 
2602
- // Metadata: name.metadata~uid.json; companion files use natural name
2603
- const metaPath = join(dir, buildMetaFilename(name, uid));
2782
+ // Metadata: name.metadata.json; companion files use natural name
2783
+ const metaPath = join(dir, buildMetaFilename(name));
2604
2784
 
2605
2785
  // Legacy detection: rename old-format metadata files to new convention
2606
2786
  const legacyDotExtMetaPath = join(dir, `${name}.${uid}.metadata.json`);
@@ -2819,7 +2999,7 @@ async function processMediaEntries(mediaRecords, structure, options, config, app
2819
2999
  const { metaPath: scanMetaPath, dir: scanDir } = resolveMediaPaths(record, structure);
2820
3000
  const resolvedName = resolvedFilenames.get(record.UID);
2821
3001
  const effectiveMetaPath = resolvedName
2822
- ? join(scanDir, buildMetaFilename(resolvedName, String(record.UID || record._id || 'untitled')))
3002
+ ? join(scanDir, buildMetaFilename(resolvedName))
2823
3003
  : scanMetaPath;
2824
3004
  if (!(await fileExists(effectiveMetaPath))) {
2825
3005
  const staleMeta = { _entity: 'media', _foreignApp: true };
@@ -2881,7 +3061,7 @@ async function processMediaEntries(mediaRecords, structure, options, config, app
2881
3061
  // Use collision-resolved filename for metadata path when available (e.g. "_media" suffix)
2882
3062
  const resolvedName = resolvedFilenames.get(record.UID);
2883
3063
  const effectiveMetaPath = resolvedName
2884
- ? join(scanDir, buildMetaFilename(resolvedName, String(record.UID || record._id || 'untitled')))
3064
+ ? join(scanDir, buildMetaFilename(resolvedName))
2885
3065
  : scanMetaPath;
2886
3066
  const scanExists = await fileExists(effectiveMetaPath);
2887
3067
 
@@ -2971,12 +3151,11 @@ async function processMediaEntries(mediaRecords, structure, options, config, app
2971
3151
  if (!dir) dir = BINS_DIR;
2972
3152
  await mkdir(dir, { recursive: true });
2973
3153
 
2974
- // Companion: natural name, no UID (use collision-resolved override if available)
2975
- const uid = String(record.UID || record._id || 'untitled');
3154
+ // Companion: natural name (use collision-resolved override if available)
2976
3155
  const finalFilename = resolvedFilenames.get(record.UID) || sanitizeFilename(filename);
2977
3156
  const filePath = join(dir, finalFilename);
2978
- // Metadata: use collision-resolved filename as base (e.g. "env_media.js" → "env_media.js.metadata~uid.json")
2979
- const metaPath = join(dir, buildMetaFilename(finalFilename, uid));
3157
+ // Metadata: use collision-resolved filename as base (e.g. "env_media.js" → "env_media.js.metadata.json")
3158
+ const metaPath = join(dir, buildMetaFilename(finalFilename));
2980
3159
  // usedNames retained for tracking
2981
3160
  const fileKey = `${dir}/${name}.${ext}`;
2982
3161
  usedNames.set(fileKey, (usedNames.get(fileKey) || 0) + 1);
@@ -3248,7 +3427,7 @@ async function processRecord(entityName, record, structure, options, usedNames,
3248
3427
  probeDir = probeDir.replace(/^\/+|\/+$/g, '') || BINS_DIR;
3249
3428
  if (extname(probeDir)) probeDir = probeDir.substring(0, probeDir.lastIndexOf('/')) || BINS_DIR;
3250
3429
 
3251
- const probeMeta = join(probeDir, buildMetaFilename(sanitized, uid));
3430
+ const probeMeta = join(probeDir, buildMetaFilename(sanitized));
3252
3431
  const raw = await readFile(probeMeta, 'utf8');
3253
3432
  const localMeta = JSON.parse(raw);
3254
3433
  // Extract extension from Content @reference (e.g. "@Name~uid.html")
@@ -3339,8 +3518,8 @@ async function processRecord(entityName, record, structure, options, usedNames,
3339
3518
  const uid = String(record.UID || record._id || 'untitled');
3340
3519
  // Companion: natural name, no UID (use collision-resolved override if available)
3341
3520
  const fileName = filenameOverride || sanitizeFilename(buildContentFileName(record, uid));
3342
- // Metadata: name.metadata~uid.json
3343
- // usedNames retained for non-UID edge case tracking
3521
+ // Metadata: filename.metadata.json (includes extension to avoid collisions between records
3522
+ // with the same Name but different Extension, e.g. codeTest.js vs codeTest.css)
3344
3523
  const nameKey = `${dir}/${name}`;
3345
3524
  usedNames.set(nameKey, (usedNames.get(nameKey) || 0) + 1);
3346
3525
 
@@ -3352,7 +3531,20 @@ async function processRecord(entityName, record, structure, options, usedNames,
3352
3531
  );
3353
3532
 
3354
3533
  const filePath = join(dir, fileName);
3355
- const metaPath = join(dir, buildMetaFilename(name, uid));
3534
+ const metaPath = join(dir, buildMetaFilename(fileName));
3535
+
3536
+ // Legacy migration: rename old name.metadata.json → new filename.metadata.json
3537
+ // (repos cloned before this fix used the base name without extension as the metadata stem)
3538
+ const legacyMetaPath = join(dir, buildMetaFilename(name));
3539
+ if (legacyMetaPath !== metaPath && !await fileExists(metaPath) && await fileExists(legacyMetaPath)) {
3540
+ try {
3541
+ const legacyMeta = JSON.parse(await readFile(legacyMetaPath, 'utf8'));
3542
+ if (legacyMeta.UID === uid) {
3543
+ const { rename: fsRename } = await import('fs/promises');
3544
+ await fsRename(legacyMetaPath, metaPath);
3545
+ }
3546
+ } catch { /* non-critical */ }
3547
+ }
3356
3548
 
3357
3549
  // Rename legacy ~UID companion files to natural names if needed
3358
3550
  if (await fileExists(metaPath)) {
@@ -4081,22 +4273,32 @@ async function processOutputHierarchy(appJson, structure, options, serverTz) {
4081
4273
  // Build root output filename (natural name, no UID in stem)
4082
4274
  const rootBasename = buildOutputFilename('output', output, filenameCols.output);
4083
4275
  const rootUid = output.UID || '';
4084
- let rootMetaPath = join(binDir, buildMetaFilename(rootBasename, rootUid));
4276
+
4277
+ // Resolve metadata path with collision detection (content records have priority over output).
4278
+ // If name.metadata.json is owned by a different UID (e.g. a content record), use -1, -2, etc.
4279
+ let rootMetaPath = await resolveMetaCollision(binDir, rootBasename, rootUid);
4085
4280
 
4086
4281
  // Legacy fallback: rename old-format metadata to new convention
4087
4282
  const legacyTildeOutputMeta = join(binDir, `${rootBasename}~${rootUid}.metadata.json`);
4088
4283
  const legacyJsonPath = join(binDir, `${rootBasename}.json`);
4089
- const legacyOutputMeta = join(binDir, `${rootBasename}.metadata.json`);
4090
4284
  if (!await fileExists(rootMetaPath)) {
4091
4285
  if (await fileExists(legacyTildeOutputMeta)) {
4092
4286
  await rename(legacyTildeOutputMeta, rootMetaPath);
4093
4287
  log.dim(` Renamed ${basename(legacyTildeOutputMeta)} → ${basename(rootMetaPath)}`);
4094
- } else if (await fileExists(legacyOutputMeta)) {
4095
- await rename(legacyOutputMeta, rootMetaPath);
4096
- log.dim(` Renamed ${basename(legacyOutputMeta)} → ${basename(rootMetaPath)}`);
4097
4288
  } else if (await fileExists(legacyJsonPath)) {
4098
- await rename(legacyJsonPath, rootMetaPath);
4099
- log.dim(` Renamed ${rootBasename}.json ${basename(rootMetaPath)}`);
4289
+ // Only rename if the .json file actually looks like output metadata JSON.
4290
+ // Content companions can share the same {name}.json filename pattern —
4291
+ // renaming those would corrupt the content record's companion file.
4292
+ let isOutputMeta = false;
4293
+ try {
4294
+ const legacyContent = await readFile(legacyJsonPath, 'utf8');
4295
+ const legacyParsed = JSON.parse(legacyContent);
4296
+ isOutputMeta = legacyParsed && (legacyParsed._entity === 'output' || legacyParsed.OutputID != null);
4297
+ } catch { /* not valid JSON — definitely a content companion, skip */ }
4298
+ if (isOutputMeta) {
4299
+ await rename(legacyJsonPath, rootMetaPath);
4300
+ log.dim(` Renamed ${rootBasename}.json → ${basename(rootMetaPath)}`);
4301
+ }
4100
4302
  }
4101
4303
  }
4102
4304
 
@@ -4206,28 +4408,34 @@ async function processOutputHierarchy(appJson, structure, options, serverTz) {
4206
4408
  /**
4207
4409
  * Write root content files (manifest.json, CLAUDE.md, README.md, etc.) to the project root.
4208
4410
  *
4209
- * For each filename in rootContentFiles:
4210
- * - If a matching content record exists in contentRefs: relocate the companion from
4211
- * lib/bins/app/<filename> to the project root, delete the bins/app copy, and rewrite
4212
- * the metadata to use Content: "@/<filename>" (root-relative). This fixes the duplicate
4213
- * bug where processRecord always writes companions next to the metadata file.
4214
- * - If no server record: generate a fallback stub at the project root.
4411
+ * Two passes:
4412
+ * 1. For each filename in rootContentFiles config: search contentRefs for a matching record,
4413
+ * relocate its companion to the project root, and rewrite the metadata to use
4414
+ * Content: "@/<filename>" (root-relative). Fall back to a stub if no server record.
4415
+ * 2. Promote any remaining content records with BinID=null whose companion filename is also
4416
+ * in rootContentFiles (catches records not matched by pass 1's name/content/path heuristics).
4215
4417
  */
4216
4418
  async function writeRootContentFiles(appJson, contentRefs) {
4217
4419
  const rootFiles = await loadRootContentFiles();
4218
- if (!rootFiles.length) return;
4420
+ const handledUids = new Set();
4219
4421
 
4220
4422
  for (const filename of rootFiles) {
4221
- const handled = await _writeRootFile(filename, appJson, contentRefs);
4222
- if (!handled) {
4423
+ const handledUid = await _writeRootFile(filename, appJson, contentRefs);
4424
+ if (handledUid) {
4425
+ handledUids.add(handledUid);
4426
+ } else {
4223
4427
  await _generateRootFileStub(filename, appJson);
4224
4428
  }
4225
4429
  }
4430
+
4431
+ // Promote any content records with no BinID that weren't already handled above,
4432
+ // but only if the companion filename appears in rootContentFiles.
4433
+ await _promoteNoBinContentToRoot(contentRefs, handledUids, rootFiles);
4226
4434
  }
4227
4435
 
4228
4436
  /**
4229
4437
  * Find the content record for a root file and relocate its companion to the project root.
4230
- * Returns true if handled, false if no matching record was found.
4438
+ * Returns the record UID if handled, null if no matching record was found.
4231
4439
  */
4232
4440
  async function _writeRootFile(filename, appJson, contentRefs) {
4233
4441
  const filenameLower = filename.toLowerCase();
@@ -4283,10 +4491,69 @@ async function _writeRootFile(filename, appJson, contentRefs) {
4283
4491
  await writeFile(ref.metaPath, JSON.stringify(updated, null, 2) + '\n');
4284
4492
  } catch { /* non-critical */ }
4285
4493
 
4286
- return true;
4494
+ return ref.uid;
4287
4495
  }
4288
4496
 
4289
- return false; // No server record found
4497
+ return null; // No server record found
4498
+ }
4499
+
4500
+ /**
4501
+ * Promote content records with no BinID to the project root.
4502
+ * Only promotes records whose companion filename appears in rootContentFiles —
4503
+ * records not in that list stay in their lib/bins/ placement.
4504
+ * Skips records already handled by _writeRootFile (tracked via handledUids).
4505
+ * Companion filename is extracted from the Content @-reference in the metadata.
4506
+ */
4507
+ async function _promoteNoBinContentToRoot(contentRefs, handledUids, rootFiles) {
4508
+ const rootFilesLower = new Set((rootFiles || []).map(f => f.toLowerCase()));
4509
+ for (const ref of contentRefs) {
4510
+ if (handledUids.has(ref.uid)) continue;
4511
+
4512
+ let meta;
4513
+ try { meta = JSON.parse(await readFile(ref.metaPath, 'utf8')); } catch { continue; }
4514
+
4515
+ // Only promote records with no BinID
4516
+ if (meta.BinID != null) continue;
4517
+
4518
+ // Extract companion filename from Content @-reference
4519
+ const metaContent = String(meta.Content || '');
4520
+ const contentRef = metaContent.startsWith('@/') ? metaContent.slice(2)
4521
+ : metaContent.startsWith('@') ? metaContent.slice(1)
4522
+ : null;
4523
+
4524
+ // Skip if no companion reference, if it includes a path (not a root-level file),
4525
+ // or if it's not listed in rootContentFiles.
4526
+ if (!contentRef || contentRef.includes('/')) continue;
4527
+ if (!rootFilesLower.has(contentRef.toLowerCase())) continue;
4528
+
4529
+ const filename = contentRef;
4530
+ const metaDir = dirname(ref.metaPath);
4531
+ const localCompanion = join(metaDir, filename);
4532
+ let content;
4533
+ try {
4534
+ content = await readFile(localCompanion, 'utf8');
4535
+ } catch {
4536
+ try { content = await readFile(join(process.cwd(), filename), 'utf8'); } catch { /* nothing */ }
4537
+ }
4538
+
4539
+ if (content !== undefined) {
4540
+ await writeFile(join(process.cwd(), filename), content);
4541
+ log.dim(` ${filename} written to project root (BinID=null)`);
4542
+
4543
+ if (localCompanion !== join(process.cwd(), filename)) {
4544
+ try { await unlink(localCompanion); } catch { /* already gone or at root */ }
4545
+ }
4546
+ } else {
4547
+ log.warn(` Could not find companion file for ${filename} (BinID=null record)`);
4548
+ }
4549
+
4550
+ // Rewrite metadata: root-relative Content reference
4551
+ try {
4552
+ const updated = { ...meta, Content: `@/${filename}` };
4553
+ if (!updated._companionReferenceColumns) updated._companionReferenceColumns = ['Content'];
4554
+ await writeFile(ref.metaPath, JSON.stringify(updated, null, 2) + '\n');
4555
+ } catch { /* non-critical */ }
4556
+ }
4290
4557
  }
4291
4558
 
4292
4559
  /**
@@ -4307,14 +4574,76 @@ async function _generateRootFileStub(filename, appJson) {
4307
4574
  }
4308
4575
 
4309
4576
  if (filenameLower === 'claude.md') {
4310
- const stub = `# ${appName}\n\nAdd Claude Code instructions for this project here.\n`;
4577
+ const cfg = await loadConfig();
4578
+ const domain = cfg.domain || '';
4579
+ const appShortName = appJson.ShortName || '';
4580
+ const siteRecords = appJson.children?.site || [];
4581
+ const siteLines = siteRecords.map(s => {
4582
+ const url = `//${domain}/app/${appShortName}/${s.ShortName}`;
4583
+ const label = s.Title || s.Name || s.ShortName;
4584
+ return `- \`${url}\` — ${label} (add \`?dev=true\` to serve uncompiled JS; add \`&console=true\` for verbose debug logging)`;
4585
+ });
4586
+ const stub = [
4587
+ `# ${appName}`,
4588
+ ...(siteLines.length > 0 ? [``, `## App Sites`, ``, ...siteLines] : []),
4589
+ ``,
4590
+ `## DBO CLI`,
4591
+ ``,
4592
+ `Always run \`dbo\` commands from the project root (the directory containing this CLAUDE.md file).`,
4593
+ ``,
4594
+ `## DBO API Submissions`,
4595
+ ``,
4596
+ `- To create new records, use the REST API (\`/api/input/submit\`) directly — the CLI has no \`add\` command.`,
4597
+ `- 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.`,
4598
+ `- Cookie file for auth: \`.app/cookies.txt\``,
4599
+ `- 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.`,
4600
+ `- Domain: read from \`.app/config.json\` (\`domain\` field)`,
4601
+ ``,
4602
+ `## Documentation`,
4603
+ ``,
4604
+ `Project docs may live in any of these locations:`,
4605
+ ``,
4606
+ `- \`CLAUDE.md\` _(this file)_`,
4607
+ `- \`README.md\``,
4608
+ `- \`docs/\``,
4609
+ `- \`lib/bins/docs/\``,
4610
+ `- \`lib/extension/Documentation/\``,
4611
+ ``,
4612
+ `For dependency apps, look under \`app_dependencies/<app_short_name>/\` using the same structure:`,
4613
+ ``,
4614
+ `- \`app_dependencies/<app_short_name>/CLAUDE.md\``,
4615
+ `- \`app_dependencies/<app_short_name>/README.md\``,
4616
+ `- \`app_dependencies/<app_short_name>/docs/\``,
4617
+ `- \`app_dependencies/<app_short_name>/lib/bins/docs/\``,
4618
+ `- \`app_dependencies/<app_short_name>/lib/extension/Documentation/\``,
4619
+ ``,
4620
+ `### DBO.io Framework API Reference`,
4621
+ ``,
4622
+ `For the DBO.io REST API (input/submit, output, content, etc.), check in priority order:`,
4623
+ ``,
4624
+ `1. \`app_dependencies/_system/lib/bins/docs/\` — authoritative source`,
4625
+ `2. \`plugins/claude/dbo/skills/white-paper/references/\` — fallback reference`,
4626
+ ``,
4627
+ ].join('\n');
4311
4628
  await writeFile(rootPath, stub);
4312
4629
  log.dim(` CLAUDE.md generated at project root (stub)`);
4313
4630
  return;
4314
4631
  }
4315
4632
 
4316
4633
  if (filenameLower === 'readme.md') {
4634
+ const cfg = await loadConfig();
4635
+ const domain = cfg.domain || '';
4636
+ const appShortName = appJson.ShortName || '';
4637
+ const siteRecords = appJson.children?.site || [];
4317
4638
  const parts = [`# ${appName}`];
4639
+ if (siteRecords.length > 0) {
4640
+ parts.push('');
4641
+ for (const s of siteRecords) {
4642
+ const url = `//${domain}/app/${appShortName}/${s.ShortName}`;
4643
+ const label = s.Title || s.Name || s.ShortName;
4644
+ parts.push(`- [${label}](${url})`);
4645
+ }
4646
+ }
4318
4647
  if (description) parts.push('', description);
4319
4648
  parts.push('');
4320
4649
  await writeFile(rootPath, parts.join('\n'));
@@ -4322,6 +4651,32 @@ async function _generateRootFileStub(filename, appJson) {
4322
4651
  return;
4323
4652
  }
4324
4653
 
4654
+ if (filenameLower === 'package.json') {
4655
+ const shortName = (appJson.ShortName || appName || 'app').toLowerCase().replace(/\s+/g, '-');
4656
+ const stub = JSON.stringify({
4657
+ name: shortName,
4658
+ version: '1.0.0',
4659
+ description: description || '',
4660
+ private: true,
4661
+ }, null, 2) + '\n';
4662
+ await writeFile(rootPath, stub);
4663
+ log.dim(` package.json generated at project root (stub)`);
4664
+ return;
4665
+ }
4666
+
4667
+ if (filenameLower === '.dboignore') {
4668
+ await writeFile(rootPath, getDboignoreDefaultContent());
4669
+ log.dim(` .dboignore generated at project root (stub)`);
4670
+ return;
4671
+ }
4672
+
4673
+ if (filenameLower === '.gitignore') {
4674
+ const lines = DEFAULT_GITIGNORE_ENTRIES.map(e => e).join('\n') + '\n';
4675
+ await writeFile(rootPath, lines);
4676
+ log.dim(` .gitignore generated at project root (stub)`);
4677
+ return;
4678
+ }
4679
+
4325
4680
  // Unknown file type — no stub generated
4326
4681
  }
4327
4682