@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,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 {
@@ -1,18 +1,18 @@
1
1
  import { Command } from 'commander';
2
- import { readFile, stat, writeFile, rename as fsRename, mkdir, access, utimes } from 'fs/promises';
3
- import { join, dirname, basename, extname, relative } from 'path';
2
+ import { readFile, stat, writeFile, rename as fsRename, mkdir, access, utimes, readdir } from 'fs/promises';
3
+ import { join, dirname, basename, extname, relative, sep } from 'path';
4
4
  import { DboClient } from '../lib/client.js';
5
5
  import { buildInputBody, checkSubmitErrors, getSessionUserOverride } from '../lib/input-parser.js';
6
6
  import { formatResponse, formatError } from '../lib/formatter.js';
7
7
  import { log } from '../lib/logger.js';
8
8
  import { shouldSkipColumn } from '../lib/columns.js';
9
- import { loadConfig, loadAppConfig, loadSynchronize, saveSynchronize, loadAppJsonBaseline, saveAppJsonBaseline, hasBaseline, loadScripts, loadScriptsLocal, addDeleteEntry } from '../lib/config.js';
9
+ import { loadConfig, loadAppConfig, loadSynchronize, saveSynchronize, loadAppJsonBaseline, saveAppJsonBaseline, hasBaseline, loadScripts, loadScriptsLocal, addDeleteEntry, loadRootContentFiles } from '../lib/config.js';
10
10
  import { mergeScriptsConfig, resolveHooks, buildHookEnv, runBuildLifecycle, runPushLifecycle } from '../lib/scripts.js';
11
11
  import { checkStoredTicket, applyStoredTicketToSubmission, clearRecordTicket, clearGlobalTicket } from '../lib/ticketing.js';
12
12
  import { checkModifyKey, isModifyKeyError, handleModifyKeyError } from '../lib/modify-key.js';
13
13
  import { resolveTransactionKey } from '../lib/transaction-key.js';
14
14
  import { setFileTimestamps, parseServerDate } from '../lib/timestamps.js';
15
- import { stripUidFromFilename, renameToUidConvention, hasUidInFilename, isMetadataFile, parseMetaFilename, findMetadataForCompanion } from '../lib/filenames.js';
15
+ import { stripUidFromFilename, renameToUidConvention, isMetadataFile, parseMetaFilename, findMetadataForCompanion } from '../lib/filenames.js';
16
16
  import { findMetadataFiles, findFileInProject, findByUID } from '../lib/diff.js';
17
17
  import { loadIgnore } from '../lib/ignore.js';
18
18
  import { detectChangedColumns, findBaselineEntry, detectOutputChanges, detectEntityChildrenChanges, getAllUserColumns, isReference, resolveReferencePath, detectBinChanges, synthesizeBinMetadata } from '../lib/delta.js';
@@ -343,6 +343,33 @@ async function pushSingleFile(filePath, client, options, modifyKey = null, trans
343
343
  metaPath = found;
344
344
  try { meta = JSON.parse(await readFile(metaPath, 'utf8')); } catch {}
345
345
  }
346
+ if (!meta) {
347
+ // Last resort: project-wide search for cross-directory @reference.
348
+ // Handles cases where the companion file is in a different directory from its metadata
349
+ // (e.g. docs/Operator_Claude.md → lib/extension/Documentation/Operator_Claude.metadata~uid.json).
350
+ const absoluteFilePath = filePath.startsWith('/') ? filePath : join(process.cwd(), filePath);
351
+ const ig = await loadIgnore();
352
+ const allMetaFiles = await findMetadataFiles(process.cwd(), ig);
353
+ for (const candidatePath of allMetaFiles) {
354
+ try {
355
+ const candidateMeta = JSON.parse(await readFile(candidatePath, 'utf8'));
356
+ const metaDir = dirname(candidatePath);
357
+ const cols = [...(candidateMeta._companionReferenceColumns || candidateMeta._contentColumns || [])];
358
+ if (candidateMeta._mediaFile) cols.push('_mediaFile');
359
+ for (const col of cols) {
360
+ const ref = candidateMeta[col];
361
+ if (!ref || !String(ref).startsWith('@')) continue;
362
+ const resolved = resolveAtReference(String(ref).substring(1), metaDir);
363
+ if (resolved === absoluteFilePath) {
364
+ metaPath = candidatePath;
365
+ meta = candidateMeta;
366
+ break;
367
+ }
368
+ }
369
+ } catch { /* skip unreadable */ }
370
+ if (meta) break;
371
+ }
372
+ }
346
373
  if (!meta) {
347
374
  // AUTO-ADD DISABLED: local-file-first approach is being replaced by server-first workflow.
348
375
  // New records should be created via the DBO REST API first, then `dbo pull` to populate locally.
@@ -399,70 +426,187 @@ async function pushSingleFile(filePath, client, options, modifyKey = null, trans
399
426
  }
400
427
  // ── End script hooks ────────────────────────────────────────────────
401
428
 
402
- await pushFromMetadata(meta, metaPath, client, options, null, modifyKey, transactionKey);
429
+ const success = await pushFromMetadata(meta, metaPath, client, options, null, modifyKey, transactionKey);
430
+ if (success) {
431
+ const baseline = await loadAppJsonBaseline();
432
+ if (baseline) {
433
+ await updateBaselineAfterPush(baseline, [{ meta, metaPath, changedColumns: null }]);
434
+ }
435
+ }
436
+ return success;
403
437
  }
404
438
  /**
405
- * Ensure manifest.json at project root has companion metadata in lib/bins/app/.
406
- * Only creates metadata if manifest.json exists at root AND no existing metadata
407
- * anywhere in the project already references @/manifest.json (prevents duplicates).
439
+ * Metadata templates for known root content files.
440
+ * Keyed by filename (case-sensitive). Unknown files in rootContentFiles
441
+ * get a sensible derived template (extension from name, Public: 0).
408
442
  */
409
- async function ensureManifestMetadata() {
410
- // Check if manifest.json exists at project root
411
- try {
412
- await access(join(process.cwd(), 'manifest.json'));
413
- } catch {
414
- return; // No manifest.json — nothing to do
415
- }
443
+ const ROOT_FILE_TEMPLATES = {
444
+ 'manifest.json': {
445
+ _entity: 'content',
446
+ _companionReferenceColumns: ['Content'],
447
+ Extension: 'JSON',
448
+ Public: 1,
449
+ Active: 1,
450
+ Title: 'PWA Manifest',
451
+ },
452
+ 'CLAUDE.md': {
453
+ _entity: 'content',
454
+ _companionReferenceColumns: ['Content'],
455
+ Extension: 'MD',
456
+ Public: 0,
457
+ Active: 1,
458
+ Title: 'Claude Code Instructions',
459
+ },
460
+ 'README.md': {
461
+ _entity: 'content',
462
+ _companionReferenceColumns: ['Content'],
463
+ Extension: 'MD',
464
+ Public: 1,
465
+ Active: 1,
466
+ Title: 'README',
467
+ },
468
+ };
469
+
470
+ /**
471
+ * For each filename in rootContentFiles, ensure a companion metadata file
472
+ * exists in lib/bins/app/. Skips if the file is absent at root or if metadata
473
+ * already tracks it. Creates metadata with Content: "@/<filename>" so push
474
+ * always reads from the project root.
475
+ */
476
+ async function ensureRootContentFiles() {
477
+ const rootFiles = await loadRootContentFiles();
478
+ if (!rootFiles.length) return;
416
479
 
417
- // Scan the entire project for any metadata file that already references manifest.json.
418
- // This prevents creating duplicates when the metadata lives in an unexpected location.
419
- // Check both @/manifest.json (root-relative) and @manifest.json (local) references,
420
- // as well as Path: manifest.json which indicates a server record for this file.
421
480
  const ig = await loadIgnore();
422
481
  const allMeta = await findMetadataFiles(process.cwd(), ig);
482
+
483
+ // Build a set of already-tracked filenames from existing metadata (lowercase for case-insensitive match).
484
+ // Also strip stale fields (e.g. Descriptor) from content-entity root file metadata.
485
+ const tracked = new Set();
423
486
  for (const metaPath of allMeta) {
424
487
  try {
425
488
  const raw = await readFile(metaPath, 'utf8');
426
489
  const parsed = JSON.parse(raw);
427
- if (parsed.Content === '@/manifest.json' || parsed.Content === '@manifest.json') return;
428
- if (parsed.Path === 'manifest.json' || parsed.Path === '/manifest.json') return;
490
+ const content = parsed.Content || '';
491
+ const path = parsed.Path || '';
492
+ // Detect both @/filename (root-relative) and @filename (local) references
493
+ const refName = content.startsWith('@/') ? content.slice(2)
494
+ : content.startsWith('@') ? content.slice(1)
495
+ : null;
496
+ if (refName) tracked.add(refName.toLowerCase());
497
+ if (path) tracked.add(path.replace(/^\//, '').toLowerCase());
498
+
499
+ // Clean up stale Descriptor field: content entities never have Descriptor.
500
+ // This fixes metadata written by an earlier buggy version of the tool.
501
+ if (parsed._entity === 'content' && parsed.Descriptor !== undefined) {
502
+ const cleaned = { ...parsed };
503
+ delete cleaned.Descriptor;
504
+ await writeFile(metaPath, JSON.stringify(cleaned, null, 2) + '\n');
505
+ log.dim(` Removed stale Descriptor field from ${basename(metaPath)}`);
506
+ }
429
507
  } catch { /* skip unreadable */ }
430
508
  }
431
509
 
432
- // No existing metadata references manifest.json — create one
433
510
  const appConfig = await loadAppConfig();
434
511
  const structure = await loadStructureFile();
435
512
  const appBin = findBinByPath('app', structure);
436
-
437
513
  const binsAppDir = join(process.cwd(), BINS_DIR, 'app');
438
- await mkdir(binsAppDir, { recursive: true });
439
514
 
440
- const meta = {
441
- _entity: 'content',
442
- _companionReferenceColumns: ['Content'],
443
- Content: '@/manifest.json',
444
- Path: 'manifest.json',
445
- Name: 'manifest.json',
446
- Extension: 'JSON',
447
- Public: 1,
448
- Active: 1,
449
- Title: 'PWA Manifest',
450
- };
515
+ for (const filename of rootFiles) {
516
+ // Skip if file doesn't exist at project root
517
+ try { await access(join(process.cwd(), filename)); } catch { continue; }
518
+ // Skip if already tracked by existing metadata (case-insensitive)
519
+ if (tracked.has(filename.toLowerCase())) continue;
520
+
521
+ const tmplKey = Object.keys(ROOT_FILE_TEMPLATES).find(k => k.toLowerCase() === filename.toLowerCase());
522
+ const tmpl = (tmplKey ? ROOT_FILE_TEMPLATES[tmplKey] : null) || {
523
+ _entity: 'content',
524
+ _companionReferenceColumns: ['Content'],
525
+ Extension: (filename.includes('.') ? filename.split('.').pop().toUpperCase() : 'TXT'),
526
+ Public: 0,
527
+ Active: 1,
528
+ Title: filename,
529
+ };
530
+
531
+ const meta = {
532
+ ...tmpl,
533
+ Content: `@/${filename}`,
534
+ Path: filename,
535
+ Name: filename,
536
+ };
537
+ if (appBin) meta.BinID = appBin.binId;
538
+ if (appConfig.AppID) meta.AppID = appConfig.AppID;
539
+
540
+ await mkdir(binsAppDir, { recursive: true });
541
+ const stem = filename.replace(/\.[^.]+$/, '');
542
+ const metaFilename = `${stem}.metadata.json`;
543
+ await writeFile(join(binsAppDir, metaFilename), JSON.stringify(meta, null, 2) + '\n');
544
+ log.info(`Auto-created ${metaFilename} for ${filename}`);
545
+ }
546
+ }
451
547
 
452
- if (appBin) meta.BinID = appBin.binId;
453
- if (appConfig.AppID) meta.AppID = appConfig.AppID;
548
+ /**
549
+ * Collect all non-metadata file absolute paths from a directory (recursive).
550
+ */
551
+ async function collectCompanionFiles(dirPath, ig) {
552
+ const results = [];
553
+ let entries;
554
+ try { entries = await readdir(dirPath, { withFileTypes: true }); } catch { return results; }
555
+ for (const entry of entries) {
556
+ const fullPath = join(process.cwd(), dirPath, entry.name);
557
+ if (entry.isDirectory()) {
558
+ const relPath = relative(process.cwd(), fullPath).replace(/\\/g, '/');
559
+ if (!ig.ignores(relPath + '/') && !ig.ignores(relPath)) {
560
+ results.push(...await collectCompanionFiles(join(dirPath, entry.name), ig));
561
+ }
562
+ } else if (!isMetadataFile(entry.name)) {
563
+ results.push(fullPath);
564
+ }
565
+ }
566
+ return results;
567
+ }
454
568
 
455
- const metaPath = join(binsAppDir, 'manifest.metadata.json');
456
- await writeFile(metaPath, JSON.stringify(meta, null, 2) + '\n');
457
- log.info('Auto-created manifest.metadata.json for manifest.json');
569
+ /**
570
+ * Find metadata files project-wide that reference companion files in a given set via @reference.
571
+ * Used when pushing a directory like docs/ whose metadata lives elsewhere (e.g. lib/extension/Documentation/).
572
+ */
573
+ async function findCrossDirectoryMetadata(dirPath, ig) {
574
+ const companionFiles = await collectCompanionFiles(dirPath, ig);
575
+ if (companionFiles.length === 0) return [];
576
+
577
+ const companionSet = new Set(companionFiles);
578
+ const appDepsPrefix = join(process.cwd(), 'app_dependencies') + sep;
579
+ const allMetaFiles = (await findMetadataFiles(process.cwd(), ig))
580
+ .filter(p => !p.startsWith(appDepsPrefix));
581
+ const found = [];
582
+
583
+ for (const metaPath of allMetaFiles) {
584
+ try {
585
+ const candidateMeta = JSON.parse(await readFile(metaPath, 'utf8'));
586
+ const metaDir = dirname(metaPath);
587
+ const cols = [...(candidateMeta._companionReferenceColumns || candidateMeta._contentColumns || [])];
588
+ if (candidateMeta._mediaFile) cols.push('_mediaFile');
589
+ for (const col of cols) {
590
+ const ref = candidateMeta[col];
591
+ if (!ref || !String(ref).startsWith('@')) continue;
592
+ const resolved = resolveAtReference(String(ref).substring(1), metaDir);
593
+ if (companionSet.has(resolved)) {
594
+ found.push(metaPath);
595
+ break;
596
+ }
597
+ }
598
+ } catch { /* skip unreadable */ }
599
+ }
600
+
601
+ return found;
458
602
  }
459
603
 
460
604
  /**
461
605
  * Push all records found in a directory (recursive)
462
606
  */
463
607
  async function pushDirectory(dirPath, client, options, modifyKey = null, transactionKey = 'RowUID', deletedCount = 0) {
464
- // Auto-create manifest.metadata.json if manifest.json exists at root without companion metadata
465
- await ensureManifestMetadata();
608
+ // Auto-create metadata for root content files (manifest.json, CLAUDE.md, README.md, etc.)
609
+ await ensureRootContentFiles();
466
610
 
467
611
  // AUTO-ADD DISABLED: local-file-first approach is being replaced by server-first workflow.
468
612
  // New records should be created via the DBO REST API first, then `dbo pull` to populate locally.
@@ -472,6 +616,14 @@ async function pushDirectory(dirPath, client, options, modifyKey = null, transac
472
616
  const ig = await loadIgnore();
473
617
  const metaFiles = await findMetadataFiles(dirPath, ig);
474
618
 
619
+ // Cross-directory metadata lookup: if the target dir has no metadata files of its own
620
+ // (e.g. docs/ only contains .md companions, with metadata in lib/extension/Documentation/),
621
+ // find metadata files project-wide that reference those companions via @reference.
622
+ if (metaFiles.length === 0) {
623
+ const crossMetas = await findCrossDirectoryMetadata(dirPath, ig);
624
+ metaFiles.push(...crossMetas);
625
+ }
626
+
475
627
  // ── Load scripts config early (before delta detection) ──────────────
476
628
  // Build hooks must run BEFORE delta detection so compiled output files
477
629
  // (SASS → CSS, Rollup → JS, etc.) are on disk when changes are detected.
@@ -1353,24 +1505,19 @@ async function pushFromMetadata(meta, metaPath, client, options, changedColumns
1353
1505
  // Clean up per-record ticket on success
1354
1506
  await clearRecordTicket(uid || id);
1355
1507
 
1356
- // Post-UID rename: if the record lacked a UID and the server returned one
1508
+ // Post-insert UID write: if the record lacked a UID and the server returned one
1357
1509
  try {
1358
1510
  const editResults2 = result.payload?.Results?.Edit || result.data?.Payload?.Results?.Edit || [];
1359
1511
  const addResults2 = result.payload?.Results?.Add || result.data?.Payload?.Results?.Add || [];
1360
1512
  const allResults = [...editResults2, ...addResults2];
1361
1513
  if (allResults.length > 0 && !meta.UID) {
1362
1514
  const serverUID = allResults[0].UID;
1363
- if (serverUID && !hasUidInFilename(basename(metaPath), serverUID)) {
1515
+ if (serverUID) {
1364
1516
  const config2 = await loadConfig();
1365
- const renameResult = await renameToUidConvention(meta, metaPath, serverUID, allResults[0]._LastUpdated, config2.ServerTimezone);
1366
- if (renameResult.newMetaPath !== metaPath) {
1367
- log.success(` Renamed to ~${serverUID} convention`);
1368
- // Update metaPath reference for subsequent timestamp operations
1369
- // (metaPath is const, but timestamp update below re-reads from meta)
1370
- }
1517
+ await renameToUidConvention(meta, metaPath, serverUID, allResults[0]._LastUpdated, config2.ServerTimezone);
1371
1518
  }
1372
1519
  }
1373
- } catch { /* non-critical rename */ }
1520
+ } catch { /* non-critical */ }
1374
1521
 
1375
1522
  // Update file timestamps from server response
1376
1523
  try {
@@ -1378,12 +1525,14 @@ async function pushFromMetadata(meta, metaPath, client, options, changedColumns
1378
1525
  if (editResults.length > 0) {
1379
1526
  const updated = editResults[0]._LastUpdated || editResults[0].LastUpdated;
1380
1527
  if (updated) {
1528
+ // Always update _LastUpdated in memory and on disk (required for baseline sync
1529
+ // even when ServerTimezone is not configured)
1530
+ meta._LastUpdated = updated;
1531
+ await writeFile(metaPath, JSON.stringify(meta, null, 2) + '\n');
1381
1532
  const config = await loadConfig();
1382
1533
  const serverTz = config.ServerTimezone;
1383
1534
  if (serverTz) {
1384
- // Update metadata _LastUpdated and set file mtime
1385
- meta._LastUpdated = updated;
1386
- await writeFile(metaPath, JSON.stringify(meta, null, 2) + '\n');
1535
+ // Set file mtimes to server timestamp (requires timezone for correct conversion)
1387
1536
  await setFileTimestamps(metaPath, meta._CreatedOn, updated, serverTz);
1388
1537
  // Update content file mtime too
1389
1538
  const contentCols = meta._companionReferenceColumns || meta._contentColumns || [];
@@ -59,16 +59,24 @@ export const statusCommand = new Command('status')
59
59
  log.plain('');
60
60
  log.info('Claude Code Plugins:');
61
61
  for (const [name, scope] of Object.entries(scopes)) {
62
- const resolvedScope = scope || 'project';
63
- const fileName = `${name}.md`;
64
- let location;
65
- if (resolvedScope === 'global') {
66
- location = join(homedir(), '.claude', 'commands', fileName);
62
+ let resolvedScope, location;
63
+ if (Array.isArray(scope)) {
64
+ // Registry format: [{ scope: 'user'|'project', installPath, ... }]
65
+ const entry = scope[0];
66
+ resolvedScope = entry.scope === 'user' ? 'global' : (entry.scope || 'project');
67
+ location = entry.installPath;
67
68
  } else {
68
- location = join(process.cwd(), '.claude', 'commands', fileName);
69
+ // Legacy format: scope is a plain string
70
+ resolvedScope = scope || 'project';
71
+ const fileName = `${name}.md`;
72
+ location = resolvedScope === 'global'
73
+ ? join(homedir(), '.claude', 'commands', fileName)
74
+ : join(process.cwd(), '.claude', 'commands', fileName);
69
75
  }
70
76
  let installed = false;
71
- try { await access(location); installed = true; } catch {}
77
+ if (location) {
78
+ try { await access(location); installed = true; } catch {}
79
+ }
72
80
  const icon = installed ? '\u2713' : '\u2717';
73
81
  const scopeLabel = resolvedScope === 'global' ? 'global' : 'project';
74
82
  log.label(` ${name}`, `${icon} ${scopeLabel} (${installed ? location : 'not found'})`);
package/src/lib/config.js CHANGED
@@ -173,6 +173,20 @@ export async function updateConfigWithApp({ AppID, AppUID, AppName, AppShortName
173
173
  await writeFile(configPath(), JSON.stringify(existing, null, 2) + '\n');
174
174
  }
175
175
 
176
+ /**
177
+ * Persist the UserMedia preference to .app/config.json.
178
+ * @param {boolean} value - true = download user media, false = skip
179
+ */
180
+ export async function updateConfigUserMedia(value) {
181
+ await mkdir(projectDir(), { recursive: true });
182
+ let existing = {};
183
+ try {
184
+ existing = JSON.parse(await readFile(configPath(), 'utf8'));
185
+ } catch { /* no existing config */ }
186
+ existing.UserMedia = value;
187
+ await writeFile(configPath(), JSON.stringify(existing, null, 2) + '\n');
188
+ }
189
+
176
190
  // ─── Dependency helpers ───────────────────────────────────────────────────
177
191
 
178
192
  /**
@@ -513,7 +527,7 @@ export async function removeAppJsonReference(metaPath) {
513
527
  // ─── config.local.json (global ~/.dbo/settings.json) ────────────────────
514
528
 
515
529
  function configLocalPath() {
516
- return join(homedir(), '.dbo', CONFIG_LOCAL_FILE);
530
+ return process.env.DBO_SETTINGS_PATH || join(homedir(), '.dbo', CONFIG_LOCAL_FILE);
517
531
  }
518
532
 
519
533
  async function ensureGlobalDboDir() {
@@ -803,8 +817,58 @@ export async function removeFromGitignore(pattern) {
803
817
  log.dim(` Removed ${pattern} from .gitignore`);
804
818
  }
805
819
 
820
+ /** Canonical set of gitignore entries for DBO CLI projects. */
821
+ export const DEFAULT_GITIGNORE_ENTRIES = [
822
+ '*local.',
823
+ '.DS_Store',
824
+ '*.DS_Store',
825
+ '*/.DS_Store',
826
+ '.idea*',
827
+ '.vscode*',
828
+ '*/node_modules',
829
+ 'config.codekit3',
830
+ '/node_modules/',
831
+ 'cookies.txt',
832
+ '*app.compiled.js',
833
+ '*.min.css',
834
+ '*.min.js',
835
+ '.profile',
836
+ '.secret',
837
+ '.password',
838
+ '.username',
839
+ '.cookies',
840
+ '.domain',
841
+ '.OverrideTicketID',
842
+ 'media/*',
843
+ '',
844
+ '# DBO CLI (sensitive files)',
845
+ '.app/credentials.json',
846
+ '.app/cookies.txt',
847
+ '.app/ticketing.local.json',
848
+ 'trash/',
849
+ '.app.json',
850
+ '',
851
+ '.app/scripts.local.json',
852
+ '.app/errors.log',
853
+ '',
854
+ '# DBO CLI (machine-generated baselines — regenerated by dbo pull/clone)',
855
+ '.app/operator.json',
856
+ '.app/operator.metadata.json',
857
+ '.app/synchronize.json',
858
+ 'app_dependencies/',
859
+ '',
860
+ '# DBO CLI Claude Code commands (managed by dbo-cli)',
861
+ '.claude/plugins/dbo/',
862
+ '',
863
+ '# DBO CLI Claude Code commands (managed by dbo-cli)',
864
+ '.claude/plugins/track/',
865
+ ];
866
+
806
867
  /**
807
- * Ensure patterns are in .gitignore. Creates .gitignore if it doesn't exist.
868
+ * Ensure entries are in .gitignore. Creates .gitignore if it doesn't exist.
869
+ * Accepts an array that may include comment lines and blank separators;
870
+ * only entries not already present in the file are appended, preserving
871
+ * the section structure (comments, blank lines) of the input list.
808
872
  */
809
873
  export async function ensureGitignore(patterns) {
810
874
  const gitignorePath = join(process.cwd(), '.gitignore');
@@ -813,18 +877,48 @@ export async function ensureGitignore(patterns) {
813
877
  content = await readFile(gitignorePath, 'utf8');
814
878
  } catch { /* no .gitignore yet */ }
815
879
 
816
- const toAdd = patterns.filter(p => !content.includes(p));
817
- if (toAdd.length === 0) return;
880
+ const isMissing = entry => entry.trim() !== '' && !content.includes(entry);
881
+ if (!patterns.some(isMissing)) return;
882
+
883
+ // Build the block to append: include missing entries with their surrounding
884
+ // structure (comments, blank separators), omitting blanks that end up adjacent
885
+ // to already-present entries.
886
+ const outputLines = [];
887
+ let prevWasContent = false;
888
+
889
+ for (const entry of patterns) {
890
+ if (entry.trim() === '') {
891
+ if (prevWasContent) outputLines.push('');
892
+ prevWasContent = false;
893
+ continue;
894
+ }
895
+ if (isMissing(entry)) {
896
+ outputLines.push(entry);
897
+ prevWasContent = true;
898
+ } else {
899
+ // Entry already exists — remove any optimistically-added trailing blank.
900
+ if (!prevWasContent && outputLines.length > 0 && outputLines[outputLines.length - 1].trim() === '') {
901
+ outputLines.pop();
902
+ }
903
+ prevWasContent = false;
904
+ }
905
+ }
906
+
907
+ // Strip trailing blank lines.
908
+ while (outputLines.length > 0 && outputLines[outputLines.length - 1].trim() === '') {
909
+ outputLines.pop();
910
+ }
911
+
912
+ if (outputLines.length === 0) return;
818
913
 
819
914
  const isNew = content.length === 0;
820
915
  const needsNewline = !isNew && !content.endsWith('\n');
821
- const hasHeader = content.includes('# DBO CLI');
822
- const section = needsNewline ? '\n' : '';
823
- const header = hasHeader ? '' : (isNew ? '# DBO CLI (sensitive files)\n' : '\n# DBO CLI (sensitive files)\n');
824
- const addition = `${section}${header}${toAdd.join('\n')}\n`;
916
+ const prefix = needsNewline ? '\n\n' : (isNew ? '' : '\n');
825
917
 
826
- await writeFile(gitignorePath, content + addition);
827
- for (const p of toAdd) log.dim(` Added ${p} to .gitignore`);
918
+ await writeFile(gitignorePath, content + prefix + outputLines.join('\n') + '\n');
919
+
920
+ const added = outputLines.filter(l => l.trim() !== '' && !l.startsWith('#'));
921
+ for (const p of added) log.dim(` Added ${p} to .gitignore`);
828
922
  }
829
923
 
830
924
  // ─── Baseline (.app/<shortName>.json) ─────────────────────────────────────
@@ -1070,6 +1164,39 @@ export async function loadScriptsLocal() {
1070
1164
  }
1071
1165
  }
1072
1166
 
1167
+ // ─── Root Content Files ───────────────────────────────────────────────────────
1168
+
1169
+ const ROOT_CONTENT_FILES_DEFAULTS = ['CLAUDE.md', 'README.md', 'manifest.json', 'package.json', '.dboignore', '.gitignore'];
1170
+
1171
+ /**
1172
+ * Load rootContentFiles from .app/config.json.
1173
+ * If the key is absent, writes the defaults and returns them.
1174
+ * If the key is [], false, or null, returns [] (disabled).
1175
+ */
1176
+ export async function loadRootContentFiles() {
1177
+ let existing = {};
1178
+ try { existing = JSON.parse(await readFile(configPath(), 'utf8')); } catch {}
1179
+ if (!('rootContentFiles' in existing)) {
1180
+ await saveRootContentFiles(ROOT_CONTENT_FILES_DEFAULTS);
1181
+ return ROOT_CONTENT_FILES_DEFAULTS;
1182
+ }
1183
+ const val = existing.rootContentFiles;
1184
+ if (!val || (Array.isArray(val) && val.length === 0)) return [];
1185
+ return Array.isArray(val) ? val : [];
1186
+ }
1187
+
1188
+ /**
1189
+ * Persist rootContentFiles to .app/config.json.
1190
+ * Pass [] to disable root content file tracking.
1191
+ */
1192
+ export async function saveRootContentFiles(list) {
1193
+ await mkdir(projectDir(), { recursive: true });
1194
+ let existing = {};
1195
+ try { existing = JSON.parse(await readFile(configPath(), 'utf8')); } catch {}
1196
+ existing.rootContentFiles = list;
1197
+ await writeFile(configPath(), JSON.stringify(existing, null, 2) + '\n');
1198
+ }
1199
+
1073
1200
  // ─── Tag Config ───────────────────────────────────────────────────────────────
1074
1201
 
1075
1202
  /**