@dboio/cli 0.19.7 → 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.
@@ -3,13 +3,13 @@ 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';
@@ -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,10 +383,9 @@ 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
  }
@@ -397,8 +427,7 @@ export function resolveEntityDirPaths(entityName, record, dirName) {
397
427
  name = sanitizeFilename(String(record.UID || 'untitled'));
398
428
  }
399
429
 
400
- const uid = record.UID || 'untitled';
401
- const metaPath = join(dirName, buildMetaFilename(name, uid));
430
+ const metaPath = join(dirName, buildMetaFilename(name));
402
431
  return { dir: dirName, name, metaPath };
403
432
  }
404
433
 
@@ -1197,11 +1226,25 @@ export async function performClone(source, options = {}) {
1197
1226
  }
1198
1227
  }
1199
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
+
1200
1238
  // Regenerate metadata_schema.json for any new entity types
1201
1239
  if (schema) {
1202
1240
  const existing = await loadMetadataSchema();
1203
1241
  const updated = generateMetadataFromSchema(schema, existing ?? {});
1204
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
+ }
1205
1248
  }
1206
1249
 
1207
1250
  // Domain change detection
@@ -1222,7 +1265,7 @@ export async function performClone(source, options = {}) {
1222
1265
 
1223
1266
  // Ensure sensitive files are gitignored (skip for dependency checkouts — not user projects)
1224
1267
  if (!isDependencyCheckout()) {
1225
- 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);
1226
1269
  }
1227
1270
 
1228
1271
  // Step 2: Update .app/config.json (skip in pull mode — config already set)
@@ -1415,7 +1458,7 @@ export async function performClone(source, options = {}) {
1415
1458
 
1416
1459
  // Step 5a: Write root content files (manifest.json, CLAUDE.md, README.md, etc.) to project root.
1417
1460
  // Also fixes the duplicate bug: relocates companions from lib/bins/app/ to root and rewrites metadata.
1418
- if ((!entityFilter || entityFilter.has('content')) && !isDependencyCheckout()) {
1461
+ if (!entityFilter || entityFilter.has('content')) {
1419
1462
  await writeRootContentFiles(appJson, contentRefs);
1420
1463
  }
1421
1464
 
@@ -1978,14 +2021,15 @@ async function processEntityDirEntries(entityName, entries, options, serverTz) {
1978
2021
  }
1979
2022
 
1980
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.
1981
2025
  const uid = record.UID || 'untitled';
1982
- const nameKey = name;
2026
+ const nameKey = name.toLowerCase();
1983
2027
  const count = usedNames.get(nameKey) || 0;
1984
2028
  usedNames.set(nameKey, count + 1);
1985
2029
  if (count > 0) name = `${name}-${count}`;
1986
2030
 
1987
- // Metadata: name.metadata~uid.json; companion files use natural name
1988
- const metaPath = join(dirName, buildMetaFilename(name, uid));
2031
+ // Metadata: name.metadata.json; companion files use natural name
2032
+ const metaPath = join(dirName, buildMetaFilename(name));
1989
2033
 
1990
2034
  // Legacy detection: rename old-format metadata files to new convention
1991
2035
  const legacyDotMetaPath = join(dirName, `${name}.${uid}.metadata.json`);
@@ -2217,7 +2261,7 @@ function parseFormControlCode(string5) {
2217
2261
  if (codeStr) {
2218
2262
  for (const pair of codeStr.split(',')) {
2219
2263
  const [col, ext] = pair.split('|');
2220
- if (col?.trim() && ext?.trim()) colToExt.set(col.trim(), ext.trim().toLowerCase());
2264
+ if (col?.trim()) colToExt.set(col.trim(), ext?.trim().toLowerCase() || 'md');
2221
2265
  }
2222
2266
  }
2223
2267
  const titleStr = params.get('form-control-title');
@@ -2284,9 +2328,10 @@ async function buildDescriptorPrePass(extensionEntries, structure, metadataSchem
2284
2328
  const { colToExt, colToTitle } = parseFormControlCode(string5);
2285
2329
  if (colToExt.size === 0) continue;
2286
2330
 
2287
- // 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
2288
2333
  const existing = getTemplateCols(metadataSchema, 'extension', descriptor);
2289
- if (existing) continue;
2334
+ if (existing?.some(e => e.includes('@reference'))) continue;
2290
2335
 
2291
2336
  const refEntries = [];
2292
2337
  for (const [col, ext] of colToExt) {
@@ -2469,8 +2514,6 @@ function guessExtensionForDescriptor(descriptor, columnName) {
2469
2514
  * @returns {Promise<'inline'|'root'>}
2470
2515
  */
2471
2516
  async function resolveDocumentationPlacement(options) {
2472
- if (options.yes) return 'inline';
2473
-
2474
2517
  const saved = await loadExtensionDocumentationMDPlacement();
2475
2518
  if (saved && !options.force && !options.configure) {
2476
2519
  log.dim(` Documentation MD placement: ${saved} (saved)`);
@@ -2478,7 +2521,7 @@ async function resolveDocumentationPlacement(options) {
2478
2521
  }
2479
2522
 
2480
2523
  let placement;
2481
- if (options.configure) {
2524
+ if (options.configure && !options.yes) {
2482
2525
  const inquirer = (await import('inquirer')).default;
2483
2526
  ({ placement } = await inquirer.prompt([{
2484
2527
  type: 'list', name: 'placement',
@@ -2520,7 +2563,7 @@ async function processExtensionEntries(entries, structure, options, serverTz) {
2520
2563
  log.info(`Processing ${entries.length} extension record(s)...`);
2521
2564
 
2522
2565
  // Step A: Pre-pass — build mapping + create directories
2523
- const metadataSchema = await loadMetadataSchema();
2566
+ const metadataSchema = (await loadMetadataSchema()) ?? {};
2524
2567
  const mapping = await buildDescriptorPrePass(entries, structure, metadataSchema);
2525
2568
 
2526
2569
  // Clear documentation preferences when --force is used with --documentation-only
@@ -2568,12 +2611,19 @@ async function processExtensionEntries(entries, structure, options, serverTz) {
2568
2611
  }
2569
2612
 
2570
2613
  // Step D: Write files, one group at a time
2614
+ // descriptor_definition must be written before dependent descriptors (e.g. control)
2571
2615
  const refs = [];
2572
2616
  const bulkAction = { value: null };
2573
2617
  const legacyRenameAction = { value: null }; // 'rename_all' | 'skip_all' | null
2574
2618
  const config = await loadConfig();
2575
2619
 
2576
- 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) {
2577
2627
  const { filenameCol, companionRefs } = descriptorPrefs.get(descriptor);
2578
2628
  const useRootDoc = (descriptor === 'documentation' && docPlacement === 'root');
2579
2629
  const mdColInfo = useRootDoc ? companionRefs.find(r => r.extensionCol === 'md') : null;
@@ -2593,14 +2643,15 @@ async function processExtensionEntries(entries, structure, options, serverTz) {
2593
2643
  }
2594
2644
 
2595
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.
2596
2647
  const uid = record.UID || 'untitled';
2597
- const nameKey = name;
2648
+ const nameKey = name.toLowerCase();
2598
2649
  const nameCount = usedNames.get(nameKey) || 0;
2599
2650
  usedNames.set(nameKey, nameCount + 1);
2600
2651
  if (nameCount > 0) name = `${name}-${nameCount}`;
2601
2652
 
2602
- // Metadata: name.metadata~uid.json; companion files use natural name
2603
- const metaPath = join(dir, buildMetaFilename(name, uid));
2653
+ // Metadata: name.metadata.json; companion files use natural name
2654
+ const metaPath = join(dir, buildMetaFilename(name));
2604
2655
 
2605
2656
  // Legacy detection: rename old-format metadata files to new convention
2606
2657
  const legacyDotExtMetaPath = join(dir, `${name}.${uid}.metadata.json`);
@@ -2819,7 +2870,7 @@ async function processMediaEntries(mediaRecords, structure, options, config, app
2819
2870
  const { metaPath: scanMetaPath, dir: scanDir } = resolveMediaPaths(record, structure);
2820
2871
  const resolvedName = resolvedFilenames.get(record.UID);
2821
2872
  const effectiveMetaPath = resolvedName
2822
- ? join(scanDir, buildMetaFilename(resolvedName, String(record.UID || record._id || 'untitled')))
2873
+ ? join(scanDir, buildMetaFilename(resolvedName))
2823
2874
  : scanMetaPath;
2824
2875
  if (!(await fileExists(effectiveMetaPath))) {
2825
2876
  const staleMeta = { _entity: 'media', _foreignApp: true };
@@ -2881,7 +2932,7 @@ async function processMediaEntries(mediaRecords, structure, options, config, app
2881
2932
  // Use collision-resolved filename for metadata path when available (e.g. "_media" suffix)
2882
2933
  const resolvedName = resolvedFilenames.get(record.UID);
2883
2934
  const effectiveMetaPath = resolvedName
2884
- ? join(scanDir, buildMetaFilename(resolvedName, String(record.UID || record._id || 'untitled')))
2935
+ ? join(scanDir, buildMetaFilename(resolvedName))
2885
2936
  : scanMetaPath;
2886
2937
  const scanExists = await fileExists(effectiveMetaPath);
2887
2938
 
@@ -2971,12 +3022,11 @@ async function processMediaEntries(mediaRecords, structure, options, config, app
2971
3022
  if (!dir) dir = BINS_DIR;
2972
3023
  await mkdir(dir, { recursive: true });
2973
3024
 
2974
- // Companion: natural name, no UID (use collision-resolved override if available)
2975
- const uid = String(record.UID || record._id || 'untitled');
3025
+ // Companion: natural name (use collision-resolved override if available)
2976
3026
  const finalFilename = resolvedFilenames.get(record.UID) || sanitizeFilename(filename);
2977
3027
  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));
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));
2980
3030
  // usedNames retained for tracking
2981
3031
  const fileKey = `${dir}/${name}.${ext}`;
2982
3032
  usedNames.set(fileKey, (usedNames.get(fileKey) || 0) + 1);
@@ -3248,7 +3298,7 @@ async function processRecord(entityName, record, structure, options, usedNames,
3248
3298
  probeDir = probeDir.replace(/^\/+|\/+$/g, '') || BINS_DIR;
3249
3299
  if (extname(probeDir)) probeDir = probeDir.substring(0, probeDir.lastIndexOf('/')) || BINS_DIR;
3250
3300
 
3251
- const probeMeta = join(probeDir, buildMetaFilename(sanitized, uid));
3301
+ const probeMeta = join(probeDir, buildMetaFilename(sanitized));
3252
3302
  const raw = await readFile(probeMeta, 'utf8');
3253
3303
  const localMeta = JSON.parse(raw);
3254
3304
  // Extract extension from Content @reference (e.g. "@Name~uid.html")
@@ -3339,8 +3389,7 @@ async function processRecord(entityName, record, structure, options, usedNames,
3339
3389
  const uid = String(record.UID || record._id || 'untitled');
3340
3390
  // Companion: natural name, no UID (use collision-resolved override if available)
3341
3391
  const fileName = filenameOverride || sanitizeFilename(buildContentFileName(record, uid));
3342
- // Metadata: name.metadata~uid.json
3343
- // usedNames retained for non-UID edge case tracking
3392
+ // Metadata: name.metadata.json; usedNames retained for non-UID edge case tracking
3344
3393
  const nameKey = `${dir}/${name}`;
3345
3394
  usedNames.set(nameKey, (usedNames.get(nameKey) || 0) + 1);
3346
3395
 
@@ -3352,7 +3401,7 @@ async function processRecord(entityName, record, structure, options, usedNames,
3352
3401
  );
3353
3402
 
3354
3403
  const filePath = join(dir, fileName);
3355
- const metaPath = join(dir, buildMetaFilename(name, uid));
3404
+ const metaPath = join(dir, buildMetaFilename(name));
3356
3405
 
3357
3406
  // Rename legacy ~UID companion files to natural names if needed
3358
3407
  if (await fileExists(metaPath)) {
@@ -4081,22 +4130,32 @@ async function processOutputHierarchy(appJson, structure, options, serverTz) {
4081
4130
  // Build root output filename (natural name, no UID in stem)
4082
4131
  const rootBasename = buildOutputFilename('output', output, filenameCols.output);
4083
4132
  const rootUid = output.UID || '';
4084
- 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);
4085
4137
 
4086
4138
  // Legacy fallback: rename old-format metadata to new convention
4087
4139
  const legacyTildeOutputMeta = join(binDir, `${rootBasename}~${rootUid}.metadata.json`);
4088
4140
  const legacyJsonPath = join(binDir, `${rootBasename}.json`);
4089
- const legacyOutputMeta = join(binDir, `${rootBasename}.metadata.json`);
4090
4141
  if (!await fileExists(rootMetaPath)) {
4091
4142
  if (await fileExists(legacyTildeOutputMeta)) {
4092
4143
  await rename(legacyTildeOutputMeta, rootMetaPath);
4093
4144
  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
4145
  } else if (await fileExists(legacyJsonPath)) {
4098
- await rename(legacyJsonPath, rootMetaPath);
4099
- 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
+ }
4100
4159
  }
4101
4160
  }
4102
4161
 
@@ -4206,28 +4265,34 @@ async function processOutputHierarchy(appJson, structure, options, serverTz) {
4206
4265
  /**
4207
4266
  * Write root content files (manifest.json, CLAUDE.md, README.md, etc.) to the project root.
4208
4267
  *
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.
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).
4215
4274
  */
4216
4275
  async function writeRootContentFiles(appJson, contentRefs) {
4217
4276
  const rootFiles = await loadRootContentFiles();
4218
- if (!rootFiles.length) return;
4277
+ const handledUids = new Set();
4219
4278
 
4220
4279
  for (const filename of rootFiles) {
4221
- const handled = await _writeRootFile(filename, appJson, contentRefs);
4222
- if (!handled) {
4280
+ const handledUid = await _writeRootFile(filename, appJson, contentRefs);
4281
+ if (handledUid) {
4282
+ handledUids.add(handledUid);
4283
+ } else {
4223
4284
  await _generateRootFileStub(filename, appJson);
4224
4285
  }
4225
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);
4226
4291
  }
4227
4292
 
4228
4293
  /**
4229
4294
  * 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.
4295
+ * Returns the record UID if handled, null if no matching record was found.
4231
4296
  */
4232
4297
  async function _writeRootFile(filename, appJson, contentRefs) {
4233
4298
  const filenameLower = filename.toLowerCase();
@@ -4283,10 +4348,69 @@ async function _writeRootFile(filename, appJson, contentRefs) {
4283
4348
  await writeFile(ref.metaPath, JSON.stringify(updated, null, 2) + '\n');
4284
4349
  } catch { /* non-critical */ }
4285
4350
 
4286
- return true;
4351
+ return ref.uid;
4287
4352
  }
4288
4353
 
4289
- return false; // No server record found
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)`);
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
+ }
4290
4414
  }
4291
4415
 
4292
4416
  /**
@@ -4307,7 +4431,47 @@ async function _generateRootFileStub(filename, appJson) {
4307
4431
  }
4308
4432
 
4309
4433
  if (filenameLower === 'claude.md') {
4310
- const stub = `# ${appName}\n\nAdd Claude Code instructions for this project here.\n`;
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');
4311
4475
  await writeFile(rootPath, stub);
4312
4476
  log.dim(` CLAUDE.md generated at project root (stub)`);
4313
4477
  return;
@@ -4322,6 +4486,32 @@ async function _generateRootFileStub(filename, appJson) {
4322
4486
  return;
4323
4487
  }
4324
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
+
4325
4515
  // Unknown file type — no stub generated
4326
4516
  }
4327
4517
 
@@ -1,7 +1,7 @@
1
1
  import { Command } from 'commander';
2
2
  import { mkdir, writeFile, access } from 'fs/promises';
3
3
  import { join } from 'path';
4
- import { isInitialized, hasLegacyConfig, readLegacyConfig, initConfig, saveCredentials, ensureGitignore, saveTransactionKeyPreset, saveTicketSuggestionOutput } from '../lib/config.js';
4
+ import { isInitialized, hasLegacyConfig, readLegacyConfig, initConfig, saveCredentials, ensureGitignore, DEFAULT_GITIGNORE_ENTRIES, saveTransactionKeyPreset, saveTicketSuggestionOutput } from '../lib/config.js';
5
5
  import { installOrUpdateClaudeCommands } from './install.js';
6
6
  import { scaffoldProjectDirs, logScaffoldResult } from '../lib/scaffold.js';
7
7
  import { createDboignore } from '../lib/ignore.js';
@@ -108,7 +108,7 @@ export const initCommand = new Command('init')
108
108
  }
109
109
 
110
110
  // Ensure sensitive files are gitignored
111
- await ensureGitignore(['.app/credentials.json', '.app/cookies.txt', '.app/ticketing.local.json', '.app/scripts.local.json', '.app/errors.log', 'trash/', 'Icon\\r', 'app_dependencies/']);
111
+ await ensureGitignore(DEFAULT_GITIGNORE_ENTRIES);
112
112
 
113
113
  const createdIgnore = await createDboignore();
114
114
  if (createdIgnore) log.dim(' Created .dboignore');
@@ -15,7 +15,7 @@ export const inputCommand = new Command('input')
15
15
  .description('Submit CRUD operations to DBO.io (add, edit, delete records)')
16
16
  .requiredOption('-d, --data <expr>', 'DBO input expression (repeatable)', collect, [])
17
17
  .option('-f, --file <field=@path>', 'File attachment for multipart upload (repeatable)', collect, [])
18
- .option('-C, --confirm <value>', 'Commit changes: true (default) or false for validation only', 'true')
18
+ .option('-C, --confirm [value]', 'Commit changes: true (default) or false for validation only', 'true')
19
19
  .option('--ticket <id>', 'Override ticket ID (_OverrideTicketID)')
20
20
  .option('--modify-key <key>', 'Provide ModifyKey directly (skips interactive prompt)')
21
21
  .option('--row-key <type>', 'Row key type (RowUID or RowID) — no-op for -d passthrough, available for consistency')
@@ -32,7 +32,7 @@ export const inputCommand = new Command('input')
32
32
  const client = new DboClient({ domain: options.domain, verbose: options.verbose });
33
33
 
34
34
  const extraParams = {};
35
- extraParams['_confirm'] = options.confirm;
35
+ extraParams['_confirm'] = String(options.confirm);
36
36
  if (options.ticket) extraParams['_OverrideTicketID'] = options.ticket;
37
37
  if (options.login) extraParams['_login'] = 'true';
38
38
  if (options.transactional) extraParams['_transactional'] = 'true';
@@ -1,5 +1,5 @@
1
1
  import { Command } from 'commander';
2
- import { loadConfig, initConfig, saveUserInfo, saveUserProfile, ensureGitignore } from '../lib/config.js';
2
+ import { loadConfig, initConfig, saveUserInfo, saveUserProfile, ensureGitignore, DEFAULT_GITIGNORE_ENTRIES } from '../lib/config.js';
3
3
  import { DboClient } from '../lib/client.js';
4
4
  import { log } from '../lib/logger.js';
5
5
 
@@ -49,7 +49,7 @@ export async function performLogin(domain, knownUsername) {
49
49
  }
50
50
 
51
51
  log.success(`Authenticated as ${username} on ${await client.getDomain()}`);
52
- await ensureGitignore(['.app/credentials.json', '.app/cookies.txt', '.app/ticketing.local.json']);
52
+ await ensureGitignore(DEFAULT_GITIGNORE_ENTRIES);
53
53
 
54
54
  // Fetch and store user info (non-critical)
55
55
  try {
@@ -143,7 +143,7 @@ export const loginCommand = new Command('login')
143
143
  log.success(`Authenticated as ${username} on ${await client.getDomain()}`);
144
144
 
145
145
  // Ensure sensitive files are gitignored
146
- await ensureGitignore(['.app/credentials.json', '.app/cookies.txt', '.app/ticketing.local.json']);
146
+ await ensureGitignore(DEFAULT_GITIGNORE_ENTRIES);
147
147
 
148
148
  // Fetch current user info to store ID for future submissions
149
149
  try {