@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.
@@ -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, loadRootContentFiles } from '../lib/config.js';
9
+ import { loadConfig, loadAppConfig, loadSynchronize, saveSynchronize, loadAppJsonBaseline, saveAppJsonBaseline, hasBaseline, loadScripts, loadScriptsLocal, addDeleteEntry, loadRootContentFiles, loadRepositoryIntegrationID } from '../lib/config.js';
10
10
  import { mergeScriptsConfig, resolveHooks, buildHookEnv, runBuildLifecycle, runPushLifecycle } from '../lib/scripts.js';
11
- import { checkStoredTicket, applyStoredTicketToSubmission, clearRecordTicket, clearGlobalTicket } from '../lib/ticketing.js';
11
+ import { checkStoredTicket, applyStoredTicketToSubmission, clearRecordTicket, clearGlobalTicket, fetchAndCacheRepositoryIntegration } 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';
@@ -380,8 +380,8 @@ async function pushSingleFile(filePath, client, options, modifyKey = null, trans
380
380
  }
381
381
  }
382
382
 
383
- // Toe-stepping check for single-file push
384
- if (isToeStepping(options) && meta.UID) {
383
+ // Toe-stepping check for single-file push (only for records confirmed on server)
384
+ if (isToeStepping(options) && (meta._CreatedOn || meta._LastUpdated)) {
385
385
  const baseline = await loadAppJsonBaseline();
386
386
  if (baseline) {
387
387
  const appConfig = await loadAppConfig();
@@ -426,7 +426,13 @@ async function pushSingleFile(filePath, client, options, modifyKey = null, trans
426
426
  }
427
427
  // ── End script hooks ────────────────────────────────────────────────
428
428
 
429
- const success = await pushFromMetadata(meta, metaPath, client, options, null, modifyKey, transactionKey);
429
+ const isNewRecord = !meta._CreatedOn && !meta._LastUpdated;
430
+ let success;
431
+ if (isNewRecord) {
432
+ success = await addFromMetadata(meta, metaPath, client, options, modifyKey);
433
+ } else {
434
+ success = await pushFromMetadata(meta, metaPath, client, options, null, modifyKey, transactionKey);
435
+ }
430
436
  if (success) {
431
437
  const baseline = await loadAppJsonBaseline();
432
438
  if (baseline) {
@@ -480,7 +486,7 @@ async function ensureRootContentFiles() {
480
486
  const ig = await loadIgnore();
481
487
  const allMeta = await findMetadataFiles(process.cwd(), ig);
482
488
 
483
- // Build a set of already-tracked filenames from existing metadata.
489
+ // Build a set of already-tracked filenames from existing metadata (lowercase for case-insensitive match).
484
490
  // Also strip stale fields (e.g. Descriptor) from content-entity root file metadata.
485
491
  const tracked = new Set();
486
492
  for (const metaPath of allMeta) {
@@ -493,8 +499,8 @@ async function ensureRootContentFiles() {
493
499
  const refName = content.startsWith('@/') ? content.slice(2)
494
500
  : content.startsWith('@') ? content.slice(1)
495
501
  : null;
496
- if (refName) tracked.add(refName);
497
- if (path) tracked.add(path.replace(/^\//, ''));
502
+ if (refName) tracked.add(refName.toLowerCase());
503
+ if (path) tracked.add(path.replace(/^\//, '').toLowerCase());
498
504
 
499
505
  // Clean up stale Descriptor field: content entities never have Descriptor.
500
506
  // This fixes metadata written by an earlier buggy version of the tool.
@@ -515,10 +521,11 @@ async function ensureRootContentFiles() {
515
521
  for (const filename of rootFiles) {
516
522
  // Skip if file doesn't exist at project root
517
523
  try { await access(join(process.cwd(), filename)); } catch { continue; }
518
- // Skip if already tracked by existing metadata
519
- if (tracked.has(filename)) continue;
524
+ // Skip if already tracked by existing metadata (case-insensitive)
525
+ if (tracked.has(filename.toLowerCase())) continue;
520
526
 
521
- const tmpl = ROOT_FILE_TEMPLATES[filename] || {
527
+ const tmplKey = Object.keys(ROOT_FILE_TEMPLATES).find(k => k.toLowerCase() === filename.toLowerCase());
528
+ const tmpl = (tmplKey ? ROOT_FILE_TEMPLATES[tmplKey] : null) || {
522
529
  _entity: 'content',
523
530
  _companionReferenceColumns: ['Content'],
524
531
  Extension: (filename.includes('.') ? filename.split('.').pop().toUpperCase() : 'TXT'),
@@ -544,6 +551,62 @@ async function ensureRootContentFiles() {
544
551
  }
545
552
  }
546
553
 
554
+ /**
555
+ * Collect all non-metadata file absolute paths from a directory (recursive).
556
+ */
557
+ async function collectCompanionFiles(dirPath, ig) {
558
+ const results = [];
559
+ let entries;
560
+ try { entries = await readdir(dirPath, { withFileTypes: true }); } catch { return results; }
561
+ for (const entry of entries) {
562
+ const fullPath = join(process.cwd(), dirPath, entry.name);
563
+ if (entry.isDirectory()) {
564
+ const relPath = relative(process.cwd(), fullPath).replace(/\\/g, '/');
565
+ if (!ig.ignores(relPath + '/') && !ig.ignores(relPath)) {
566
+ results.push(...await collectCompanionFiles(join(dirPath, entry.name), ig));
567
+ }
568
+ } else if (!isMetadataFile(entry.name)) {
569
+ results.push(fullPath);
570
+ }
571
+ }
572
+ return results;
573
+ }
574
+
575
+ /**
576
+ * Find metadata files project-wide that reference companion files in a given set via @reference.
577
+ * Used when pushing a directory like docs/ whose metadata lives elsewhere (e.g. lib/extension/Documentation/).
578
+ */
579
+ async function findCrossDirectoryMetadata(dirPath, ig) {
580
+ const companionFiles = await collectCompanionFiles(dirPath, ig);
581
+ if (companionFiles.length === 0) return [];
582
+
583
+ const companionSet = new Set(companionFiles);
584
+ const appDepsPrefix = join(process.cwd(), 'app_dependencies') + sep;
585
+ const allMetaFiles = (await findMetadataFiles(process.cwd(), ig))
586
+ .filter(p => !p.startsWith(appDepsPrefix));
587
+ const found = [];
588
+
589
+ for (const metaPath of allMetaFiles) {
590
+ try {
591
+ const candidateMeta = JSON.parse(await readFile(metaPath, 'utf8'));
592
+ const metaDir = dirname(metaPath);
593
+ const cols = [...(candidateMeta._companionReferenceColumns || candidateMeta._contentColumns || [])];
594
+ if (candidateMeta._mediaFile) cols.push('_mediaFile');
595
+ for (const col of cols) {
596
+ const ref = candidateMeta[col];
597
+ if (!ref || !String(ref).startsWith('@')) continue;
598
+ const resolved = resolveAtReference(String(ref).substring(1), metaDir);
599
+ if (companionSet.has(resolved)) {
600
+ found.push(metaPath);
601
+ break;
602
+ }
603
+ }
604
+ } catch { /* skip unreadable */ }
605
+ }
606
+
607
+ return found;
608
+ }
609
+
547
610
  /**
548
611
  * Push all records found in a directory (recursive)
549
612
  */
@@ -559,6 +622,14 @@ async function pushDirectory(dirPath, client, options, modifyKey = null, transac
559
622
  const ig = await loadIgnore();
560
623
  const metaFiles = await findMetadataFiles(dirPath, ig);
561
624
 
625
+ // Cross-directory metadata lookup: if the target dir has no metadata files of its own
626
+ // (e.g. docs/ only contains .md companions, with metadata in lib/extension/Documentation/),
627
+ // find metadata files project-wide that reference those companions via @reference.
628
+ if (metaFiles.length === 0) {
629
+ const crossMetas = await findCrossDirectoryMetadata(dirPath, ig);
630
+ metaFiles.push(...crossMetas);
631
+ }
632
+
562
633
  // ── Load scripts config early (before delta detection) ──────────────
563
634
  // Build hooks must run BEFORE delta detection so compiled output files
564
635
  // (SASS → CSS, Rollup → JS, etc.) are on disk when changes are detected.
@@ -671,7 +742,7 @@ async function pushDirectory(dirPath, client, options, modifyKey = null, transac
671
742
  continue;
672
743
  }
673
744
 
674
- const isNewRecord = !meta.UID && !meta._id;
745
+ const isNewRecord = !meta._CreatedOn && !meta._LastUpdated;
675
746
 
676
747
  // Verify @file references exist
677
748
  const contentCols = meta._companionReferenceColumns || meta._contentColumns || [];
@@ -808,19 +879,50 @@ async function pushDirectory(dirPath, client, options, modifyKey = null, transac
808
879
  // Pre-flight ticket validation (only if no --ticket flag)
809
880
  const totalRecords = toPush.length + outputsWithChanges.length + binPushItems.length;
810
881
  if (!options.ticket && totalRecords > 0) {
811
- const recordSummary = [
812
- ...toPush.map(r => { const p = parseMetaFilename(basename(r.metaPath)); return p ? p.naturalBase : basename(r.metaPath, '.metadata.json'); }),
813
- ...outputsWithChanges.map(r => basename(r.metaPath, '.json')),
814
- ...binPushItems.map(r => `bin:${r.meta.Name}`),
815
- ].join(', ');
816
- const ticketCheck = await checkStoredTicket(options, `${totalRecords} record(s): ${recordSummary}`);
817
- if (ticketCheck.cancel) {
818
- log.info('Submission cancelled');
819
- return;
882
+ // Proactive check: fetch RepositoryIntegrationID from the server before prompting.
883
+ // Uses UpdatedAfter=<today> to keep the response small; the top-level app record is always returned.
884
+ // Result is cached in .app/config.json so subsequent fetches can fall back to it.
885
+ let ticketingNeeded = null; // null = unknown (fetch failed)
886
+ const appConfig = await loadAppConfig();
887
+ const appShortNameForTicket = appConfig?.AppShortName;
888
+ if (appShortNameForTicket) {
889
+ const { id, fetched } = await fetchAndCacheRepositoryIntegration(client, appShortNameForTicket);
890
+ if (fetched) {
891
+ // Server answered: null means no RepositoryIntegration configured → skip ticketing
892
+ ticketingNeeded = (id != null);
893
+ }
894
+ }
895
+
896
+ // Fallback chain when the server fetch failed or no AppShortName:
897
+ // 1. Stored RepositoryIntegrationID in .app/config.json (from last successful fetch)
898
+ // 2. ticketing_required flag in ticketing.local.json (set reactively on first ticket_error)
899
+ if (ticketingNeeded === null) {
900
+ const storedId = await loadRepositoryIntegrationID();
901
+ if (storedId != null) {
902
+ ticketingNeeded = true;
903
+ }
904
+ // If storedId is also null, leave ticketingNeeded as null — checkStoredTicket will
905
+ // decide based on ticketing_required in ticketing.local.json (reactive fallback).
820
906
  }
821
- if (ticketCheck.clearTicket) {
822
- await clearGlobalTicket();
823
- log.dim(' Cleared stored ticket');
907
+
908
+ // Skip ticketing entirely when we have a confirmed negative signal
909
+ if (ticketingNeeded === false) {
910
+ // RepositoryIntegrationID is null on the server — no ticket needed
911
+ } else {
912
+ const recordSummary = [
913
+ ...toPush.map(r => { const p = parseMetaFilename(basename(r.metaPath)); return p ? p.naturalBase : basename(r.metaPath, '.metadata.json'); }),
914
+ ...outputsWithChanges.map(r => basename(r.metaPath, '.json')),
915
+ ...binPushItems.map(r => `bin:${r.meta.Name}`),
916
+ ].join(', ');
917
+ const ticketCheck = await checkStoredTicket(options, `${totalRecords} record(s): ${recordSummary}`);
918
+ if (ticketCheck.cancel) {
919
+ log.info('Submission cancelled');
920
+ return;
921
+ }
922
+ if (ticketCheck.clearTicket) {
923
+ await clearGlobalTicket();
924
+ log.dim(' Cleared stored ticket');
925
+ }
824
926
  }
825
927
  }
826
928
 
@@ -1116,8 +1218,10 @@ async function pushByUIDs(uids, client, options, modifyKey = null, transactionKe
1116
1218
  }
1117
1219
 
1118
1220
  /**
1119
- * Submit a new record (add) from metadata that has no UID yet.
1120
- * Builds RowID:add1 expressions, submits, then renames files with the returned ~UID.
1221
+ * Submit a new record (insert) from metadata that has no _CreatedOn/_LastUpdated yet.
1222
+ * Builds RowID:add1 expressions and submits. A manually-specified UID (if present in
1223
+ * metadata, placed there by the developer) is included so the server uses that UID.
1224
+ * The server assigns _CreatedOn/_LastUpdated on success, which are written back.
1121
1225
  */
1122
1226
  async function addFromMetadata(meta, metaPath, client, options, modifyKey = null) {
1123
1227
  const entity = meta._entity;
@@ -1129,7 +1233,8 @@ async function addFromMetadata(meta, metaPath, client, options, modifyKey = null
1129
1233
 
1130
1234
  for (const [key, value] of Object.entries(meta)) {
1131
1235
  if (shouldSkipColumn(key)) continue;
1132
- if (key === 'UID') continue;
1236
+ // UID is included only if the developer manually placed it in the metadata file.
1237
+ // The CLI never auto-generates UIDs — the server assigns them on insert.
1133
1238
  if (value === null || value === undefined) continue;
1134
1239
 
1135
1240
  const strValue = String(value);
@@ -1201,7 +1306,7 @@ async function addFromMetadata(meta, metaPath, client, options, modifyKey = null
1201
1306
  return false;
1202
1307
  }
1203
1308
 
1204
- // Extract UID from response and rename metadata to ~uid convention
1309
+ // Extract UID and server-populated fields from response, write back to metadata
1205
1310
  const addResults = result.payload?.Results?.Add || result.data?.Payload?.Results?.Add || [];
1206
1311
  if (addResults.length > 0) {
1207
1312
  const returnedUID = addResults[0].UID;
@@ -1221,7 +1326,7 @@ async function addFromMetadata(meta, metaPath, client, options, modifyKey = null
1221
1326
  const config = await loadConfig();
1222
1327
  const serverTz = config.ServerTimezone;
1223
1328
 
1224
- // Rename metadata file to ~UID convention; companions keep natural names
1329
+ // Write UID and server timestamps back to metadata file; filenames are never renamed
1225
1330
  const renameResult = await renameToUidConvention(meta, metaPath, returnedUID, returnedLastUpdated, serverTz);
1226
1331
 
1227
1332
  // Propagate updated meta back (renameToUidConvention creates a new object)
@@ -1440,24 +1545,19 @@ async function pushFromMetadata(meta, metaPath, client, options, changedColumns
1440
1545
  // Clean up per-record ticket on success
1441
1546
  await clearRecordTicket(uid || id);
1442
1547
 
1443
- // Post-UID rename: if the record lacked a UID and the server returned one
1548
+ // Post-push UID write: if the record lacked a UID (data record) and the server returned one
1444
1549
  try {
1445
1550
  const editResults2 = result.payload?.Results?.Edit || result.data?.Payload?.Results?.Edit || [];
1446
1551
  const addResults2 = result.payload?.Results?.Add || result.data?.Payload?.Results?.Add || [];
1447
1552
  const allResults = [...editResults2, ...addResults2];
1448
1553
  if (allResults.length > 0 && !meta.UID) {
1449
1554
  const serverUID = allResults[0].UID;
1450
- if (serverUID && !hasUidInFilename(basename(metaPath), serverUID)) {
1555
+ if (serverUID) {
1451
1556
  const config2 = await loadConfig();
1452
- const renameResult = await renameToUidConvention(meta, metaPath, serverUID, allResults[0]._LastUpdated, config2.ServerTimezone);
1453
- if (renameResult.newMetaPath !== metaPath) {
1454
- log.success(` Renamed to ~${serverUID} convention`);
1455
- // Update metaPath reference for subsequent timestamp operations
1456
- // (metaPath is const, but timestamp update below re-reads from meta)
1457
- }
1557
+ await renameToUidConvention(meta, metaPath, serverUID, allResults[0]._LastUpdated, config2.ServerTimezone);
1458
1558
  }
1459
1559
  }
1460
- } catch { /* non-critical rename */ }
1560
+ } catch { /* non-critical */ }
1461
1561
 
1462
1562
  // Update file timestamps from server response
1463
1563
  try {
@@ -1578,8 +1678,7 @@ async function checkPathMismatch(meta, metaPath, entity, options) {
1578
1678
  if (!contentFileName) return;
1579
1679
 
1580
1680
  // Compute the current path based on where the file actually is.
1581
- // Strip the ~UID from the filename — the metadata Path is the canonical
1582
- // server path and never contains the local ~UID suffix.
1681
+ // Strip any legacy ~UID suffix — the metadata Path is the canonical server path.
1583
1682
  const uid = meta.UID;
1584
1683
  const serverFileName = uid ? stripUidFromFilename(contentFileName, uid) : contentFileName;
1585
1684
  const currentFilePath = join(metaDir, serverFileName);
@@ -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
@@ -527,7 +527,7 @@ export async function removeAppJsonReference(metaPath) {
527
527
  // ─── config.local.json (global ~/.dbo/settings.json) ────────────────────
528
528
 
529
529
  function configLocalPath() {
530
- return join(homedir(), '.dbo', CONFIG_LOCAL_FILE);
530
+ return process.env.DBO_SETTINGS_PATH || join(homedir(), '.dbo', CONFIG_LOCAL_FILE);
531
531
  }
532
532
 
533
533
  async function ensureGlobalDboDir() {
@@ -794,6 +794,32 @@ export async function loadTicketSuggestionOutput() {
794
794
  } catch { return null; }
795
795
  }
796
796
 
797
+ /**
798
+ * Save RepositoryIntegrationID to .app/config.json.
799
+ * Stores the value fetched from the server's app object.
800
+ * Pass null to clear (ticketing not required for this app).
801
+ */
802
+ export async function saveRepositoryIntegrationID(value) {
803
+ await mkdir(projectDir(), { recursive: true });
804
+ let existing = {};
805
+ try { existing = JSON.parse(await readFile(configPath(), 'utf8')); } catch {}
806
+ if (value != null) existing.RepositoryIntegrationID = value;
807
+ else delete existing.RepositoryIntegrationID;
808
+ await writeFile(configPath(), JSON.stringify(existing, null, 2) + '\n');
809
+ }
810
+
811
+ /**
812
+ * Load RepositoryIntegrationID from .app/config.json.
813
+ * Returns the stored value or null if not set.
814
+ */
815
+ export async function loadRepositoryIntegrationID() {
816
+ try {
817
+ const raw = await readFile(configPath(), 'utf8');
818
+ const val = JSON.parse(raw).RepositoryIntegrationID;
819
+ return (val != null && val !== '') ? val : null;
820
+ } catch { return null; }
821
+ }
822
+
797
823
  // ─── Gitignore ────────────────────────────────────────────────────────────
798
824
 
799
825
  /**
@@ -817,8 +843,58 @@ export async function removeFromGitignore(pattern) {
817
843
  log.dim(` Removed ${pattern} from .gitignore`);
818
844
  }
819
845
 
846
+ /** Canonical set of gitignore entries for DBO CLI projects. */
847
+ export const DEFAULT_GITIGNORE_ENTRIES = [
848
+ '*local.',
849
+ '.DS_Store',
850
+ '*.DS_Store',
851
+ '*/.DS_Store',
852
+ '.idea*',
853
+ '.vscode*',
854
+ '*/node_modules',
855
+ 'config.codekit3',
856
+ '/node_modules/',
857
+ 'cookies.txt',
858
+ '*app.compiled.js',
859
+ '*.min.css',
860
+ '*.min.js',
861
+ '.profile',
862
+ '.secret',
863
+ '.password',
864
+ '.username',
865
+ '.cookies',
866
+ '.domain',
867
+ '.OverrideTicketID',
868
+ 'media/*',
869
+ '',
870
+ '# DBO CLI (sensitive files)',
871
+ '.app/credentials.json',
872
+ '.app/cookies.txt',
873
+ '.app/ticketing.local.json',
874
+ 'trash/',
875
+ '.app.json',
876
+ '',
877
+ '.app/scripts.local.json',
878
+ '.app/errors.log',
879
+ '',
880
+ '# DBO CLI (machine-generated baselines — regenerated by dbo pull/clone)',
881
+ '.app/operator.json',
882
+ '.app/operator.metadata.json',
883
+ '.app/synchronize.json',
884
+ 'app_dependencies/',
885
+ '',
886
+ '# DBO CLI Claude Code commands (managed by dbo-cli)',
887
+ '.claude/plugins/dbo/',
888
+ '',
889
+ '# DBO CLI Claude Code commands (managed by dbo-cli)',
890
+ '.claude/plugins/track/',
891
+ ];
892
+
820
893
  /**
821
- * Ensure patterns are in .gitignore. Creates .gitignore if it doesn't exist.
894
+ * Ensure entries are in .gitignore. Creates .gitignore if it doesn't exist.
895
+ * Accepts an array that may include comment lines and blank separators;
896
+ * only entries not already present in the file are appended, preserving
897
+ * the section structure (comments, blank lines) of the input list.
822
898
  */
823
899
  export async function ensureGitignore(patterns) {
824
900
  const gitignorePath = join(process.cwd(), '.gitignore');
@@ -827,18 +903,48 @@ export async function ensureGitignore(patterns) {
827
903
  content = await readFile(gitignorePath, 'utf8');
828
904
  } catch { /* no .gitignore yet */ }
829
905
 
830
- const toAdd = patterns.filter(p => !content.includes(p));
831
- if (toAdd.length === 0) return;
906
+ const isMissing = entry => entry.trim() !== '' && !content.includes(entry);
907
+ if (!patterns.some(isMissing)) return;
908
+
909
+ // Build the block to append: include missing entries with their surrounding
910
+ // structure (comments, blank separators), omitting blanks that end up adjacent
911
+ // to already-present entries.
912
+ const outputLines = [];
913
+ let prevWasContent = false;
914
+
915
+ for (const entry of patterns) {
916
+ if (entry.trim() === '') {
917
+ if (prevWasContent) outputLines.push('');
918
+ prevWasContent = false;
919
+ continue;
920
+ }
921
+ if (isMissing(entry)) {
922
+ outputLines.push(entry);
923
+ prevWasContent = true;
924
+ } else {
925
+ // Entry already exists — remove any optimistically-added trailing blank.
926
+ if (!prevWasContent && outputLines.length > 0 && outputLines[outputLines.length - 1].trim() === '') {
927
+ outputLines.pop();
928
+ }
929
+ prevWasContent = false;
930
+ }
931
+ }
932
+
933
+ // Strip trailing blank lines.
934
+ while (outputLines.length > 0 && outputLines[outputLines.length - 1].trim() === '') {
935
+ outputLines.pop();
936
+ }
937
+
938
+ if (outputLines.length === 0) return;
832
939
 
833
940
  const isNew = content.length === 0;
834
941
  const needsNewline = !isNew && !content.endsWith('\n');
835
- const hasHeader = content.includes('# DBO CLI');
836
- const section = needsNewline ? '\n' : '';
837
- const header = hasHeader ? '' : (isNew ? '# DBO CLI (sensitive files)\n' : '\n# DBO CLI (sensitive files)\n');
838
- const addition = `${section}${header}${toAdd.join('\n')}\n`;
942
+ const prefix = needsNewline ? '\n\n' : (isNew ? '' : '\n');
943
+
944
+ await writeFile(gitignorePath, content + prefix + outputLines.join('\n') + '\n');
839
945
 
840
- await writeFile(gitignorePath, content + addition);
841
- for (const p of toAdd) log.dim(` Added ${p} to .gitignore`);
946
+ const added = outputLines.filter(l => l.trim() !== '' && !l.startsWith('#'));
947
+ for (const p of added) log.dim(` Added ${p} to .gitignore`);
842
948
  }
843
949
 
844
950
  // ─── Baseline (.app/<shortName>.json) ─────────────────────────────────────
@@ -1086,7 +1192,7 @@ export async function loadScriptsLocal() {
1086
1192
 
1087
1193
  // ─── Root Content Files ───────────────────────────────────────────────────────
1088
1194
 
1089
- const ROOT_CONTENT_FILES_DEFAULTS = ['CLAUDE.md', 'README.md', 'manifest.json'];
1195
+ const ROOT_CONTENT_FILES_DEFAULTS = ['CLAUDE.md', 'README.md', 'manifest.json', 'package.json', '.dboignore', '.gitignore'];
1090
1196
 
1091
1197
  /**
1092
1198
  * Load rootContentFiles from .app/config.json.
@@ -289,15 +289,14 @@ export async function syncDependencies(options = {}) {
289
289
  await symlinkCredentials(parentProjectDir, checkoutProjectDir);
290
290
 
291
291
  // 3. Staleness check (unless --force or --schema)
292
- if (!forceAll) {
293
- // Also check if the checkout is essentially empty (only .app/ exists) —
294
- // a previous clone may have failed or been cleaned up, leaving just config
295
- let checkoutEmpty = true;
296
- try {
297
- const entries = await readdir(checkoutDir);
298
- checkoutEmpty = entries.every(e => e === '.app' || e.startsWith('.'));
299
- } catch { /* dir doesn't exist yet — treat as empty */ }
292
+ // Track checkoutEmpty here so step 4 can decide whether to use the local schema.
293
+ let checkoutEmpty = true;
294
+ try {
295
+ const entries = await readdir(checkoutDir);
296
+ checkoutEmpty = entries.every(e => e === '.app' || e.startsWith('.'));
297
+ } catch { /* dir doesn't exist yet — treat as empty */ }
300
298
 
299
+ if (!forceAll) {
301
300
  if (!checkoutEmpty) {
302
301
  let isStale = true;
303
302
  try {
@@ -314,7 +313,10 @@ export async function syncDependencies(options = {}) {
314
313
  }
315
314
 
316
315
  // 4. Run the clone (quiet — suppress child process output)
317
- if (shortname === '_system' && options.systemSchemaPath) {
316
+ // Use the local schema file only for the very first (empty) checkout — it's a fast path
317
+ // for pre-bundled schemas. For stale checkouts always fetch from the server so that
318
+ // newly-added records (e.g. docs added after the schema was saved) are included.
319
+ if (shortname === '_system' && options.systemSchemaPath && checkoutEmpty) {
318
320
  const relPath = relative(checkoutDir, options.systemSchemaPath);
319
321
  await execFn(checkoutDir, ['clone', relPath, '--force', '--yes', '--no-deps'], { quiet: true });
320
322
  } else {