@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.
@@ -1,6 +1,6 @@
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';
@@ -12,7 +12,7 @@ import { checkStoredTicket, applyStoredTicketToSubmission, clearRecordTicket, cl
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';
@@ -480,7 +480,7 @@ async function ensureRootContentFiles() {
480
480
  const ig = await loadIgnore();
481
481
  const allMeta = await findMetadataFiles(process.cwd(), ig);
482
482
 
483
- // Build a set of already-tracked filenames from existing metadata.
483
+ // Build a set of already-tracked filenames from existing metadata (lowercase for case-insensitive match).
484
484
  // Also strip stale fields (e.g. Descriptor) from content-entity root file metadata.
485
485
  const tracked = new Set();
486
486
  for (const metaPath of allMeta) {
@@ -493,8 +493,8 @@ async function ensureRootContentFiles() {
493
493
  const refName = content.startsWith('@/') ? content.slice(2)
494
494
  : content.startsWith('@') ? content.slice(1)
495
495
  : null;
496
- if (refName) tracked.add(refName);
497
- if (path) tracked.add(path.replace(/^\//, ''));
496
+ if (refName) tracked.add(refName.toLowerCase());
497
+ if (path) tracked.add(path.replace(/^\//, '').toLowerCase());
498
498
 
499
499
  // Clean up stale Descriptor field: content entities never have Descriptor.
500
500
  // This fixes metadata written by an earlier buggy version of the tool.
@@ -515,10 +515,11 @@ async function ensureRootContentFiles() {
515
515
  for (const filename of rootFiles) {
516
516
  // Skip if file doesn't exist at project root
517
517
  try { await access(join(process.cwd(), filename)); } catch { continue; }
518
- // Skip if already tracked by existing metadata
519
- if (tracked.has(filename)) continue;
518
+ // Skip if already tracked by existing metadata (case-insensitive)
519
+ if (tracked.has(filename.toLowerCase())) continue;
520
520
 
521
- const tmpl = ROOT_FILE_TEMPLATES[filename] || {
521
+ const tmplKey = Object.keys(ROOT_FILE_TEMPLATES).find(k => k.toLowerCase() === filename.toLowerCase());
522
+ const tmpl = (tmplKey ? ROOT_FILE_TEMPLATES[tmplKey] : null) || {
522
523
  _entity: 'content',
523
524
  _companionReferenceColumns: ['Content'],
524
525
  Extension: (filename.includes('.') ? filename.split('.').pop().toUpperCase() : 'TXT'),
@@ -544,6 +545,62 @@ async function ensureRootContentFiles() {
544
545
  }
545
546
  }
546
547
 
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
+ }
568
+
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;
602
+ }
603
+
547
604
  /**
548
605
  * Push all records found in a directory (recursive)
549
606
  */
@@ -559,6 +616,14 @@ async function pushDirectory(dirPath, client, options, modifyKey = null, transac
559
616
  const ig = await loadIgnore();
560
617
  const metaFiles = await findMetadataFiles(dirPath, ig);
561
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
+
562
627
  // ── Load scripts config early (before delta detection) ──────────────
563
628
  // Build hooks must run BEFORE delta detection so compiled output files
564
629
  // (SASS → CSS, Rollup → JS, etc.) are on disk when changes are detected.
@@ -1440,24 +1505,19 @@ async function pushFromMetadata(meta, metaPath, client, options, changedColumns
1440
1505
  // Clean up per-record ticket on success
1441
1506
  await clearRecordTicket(uid || id);
1442
1507
 
1443
- // 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
1444
1509
  try {
1445
1510
  const editResults2 = result.payload?.Results?.Edit || result.data?.Payload?.Results?.Edit || [];
1446
1511
  const addResults2 = result.payload?.Results?.Add || result.data?.Payload?.Results?.Add || [];
1447
1512
  const allResults = [...editResults2, ...addResults2];
1448
1513
  if (allResults.length > 0 && !meta.UID) {
1449
1514
  const serverUID = allResults[0].UID;
1450
- if (serverUID && !hasUidInFilename(basename(metaPath), serverUID)) {
1515
+ if (serverUID) {
1451
1516
  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
- }
1517
+ await renameToUidConvention(meta, metaPath, serverUID, allResults[0]._LastUpdated, config2.ServerTimezone);
1458
1518
  }
1459
1519
  }
1460
- } catch { /* non-critical rename */ }
1520
+ } catch { /* non-critical */ }
1461
1521
 
1462
1522
  // Update file timestamps from server response
1463
1523
  try {
@@ -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() {
@@ -817,8 +817,58 @@ export async function removeFromGitignore(pattern) {
817
817
  log.dim(` Removed ${pattern} from .gitignore`);
818
818
  }
819
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
+
820
867
  /**
821
- * 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.
822
872
  */
823
873
  export async function ensureGitignore(patterns) {
824
874
  const gitignorePath = join(process.cwd(), '.gitignore');
@@ -827,18 +877,48 @@ export async function ensureGitignore(patterns) {
827
877
  content = await readFile(gitignorePath, 'utf8');
828
878
  } catch { /* no .gitignore yet */ }
829
879
 
830
- const toAdd = patterns.filter(p => !content.includes(p));
831
- 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;
832
913
 
833
914
  const isNew = content.length === 0;
834
915
  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`;
916
+ const prefix = needsNewline ? '\n\n' : (isNew ? '' : '\n');
917
+
918
+ await writeFile(gitignorePath, content + prefix + outputLines.join('\n') + '\n');
839
919
 
840
- await writeFile(gitignorePath, content + addition);
841
- for (const p of toAdd) log.dim(` Added ${p} to .gitignore`);
920
+ const added = outputLines.filter(l => l.trim() !== '' && !l.startsWith('#'));
921
+ for (const p of added) log.dim(` Added ${p} to .gitignore`);
842
922
  }
843
923
 
844
924
  // ─── Baseline (.app/<shortName>.json) ─────────────────────────────────────
@@ -1086,7 +1166,7 @@ export async function loadScriptsLocal() {
1086
1166
 
1087
1167
  // ─── Root Content Files ───────────────────────────────────────────────────────
1088
1168
 
1089
- const ROOT_CONTENT_FILES_DEFAULTS = ['CLAUDE.md', 'README.md', 'manifest.json'];
1169
+ const ROOT_CONTENT_FILES_DEFAULTS = ['CLAUDE.md', 'README.md', 'manifest.json', 'package.json', '.dboignore', '.gitignore'];
1090
1170
 
1091
1171
  /**
1092
1172
  * Load rootContentFiles from .app/config.json.
@@ -1,7 +1,7 @@
1
1
  /**
2
- * Filename convention helpers for the metadata~uid convention.
3
- * Metadata files: name.metadata~uid.json
4
- * Companion files: natural names, no UID embedded.
2
+ * Filename convention helpers for the metadata convention.
3
+ * Metadata files: name.metadata.json (UID is stored inside the JSON, not in the filename)
4
+ * Companion files: natural names.
5
5
  */
6
6
 
7
7
  import { rename, unlink, writeFile, readFile, readdir } from 'fs/promises';
@@ -15,6 +15,8 @@ import { join, dirname, basename, extname } from 'path';
15
15
  * @param {string} uid - Server-assigned UID
16
16
  * @param {string} [ext] - File extension WITHOUT leading dot (e.g. 'css', 'png', '')
17
17
  * @returns {string} - e.g. "colors~abc123.css" or "abc123.css" or "colors~abc123"
18
+ * @deprecated Use buildMetaFilename() for metadata files. This function is retained
19
+ * only for legacy detection/migration code.
18
20
  */
19
21
  export function buildUidFilename(name, uid, ext = '') {
20
22
  const base = (name === uid) ? uid : `${name}~${uid}`;
@@ -100,6 +102,7 @@ export function stripUidFromFilename(localName, uid) {
100
102
 
101
103
  /**
102
104
  * Check whether a filename already contains ~<uid>.
105
+ * @deprecated Only used by legacy migration code.
103
106
  */
104
107
  export function hasUidInFilename(filename, uid) {
105
108
  return typeof filename === 'string' && typeof uid === 'string'
@@ -114,25 +117,28 @@ export function hasUidInFilename(filename, uid) {
114
117
  * Returns { name, uid } or null.
115
118
  */
116
119
  export function detectLegacyDotUid(filename) {
117
- const match = filename.match(/^(.+)\.([a-z0-9]{10,})\.metadata\.json$/);
120
+ const match = filename.match(/^(.+)\.([a-z0-9_]{10,})\.metadata\.json$/);
118
121
  return match ? { name: match[1], uid: match[2] } : null;
119
122
  }
120
123
 
121
124
  /**
122
- * Build the new-convention metadata filename.
123
- * Format: <naturalBase>.metadata~<uid>.json
125
+ * Build a metadata filename for a record.
126
+ * Format: <naturalBase>.metadata.json
127
+ * The UID is stored inside the JSON, not in the filename.
124
128
  *
125
- * For content records: naturalBase = "colors" → "colors.metadata~abc123.json"
126
- * For media records: naturalBase = "logo.png" → "logo.png.metadata~abc123.json"
127
- * For output records: naturalBase = "Sales" → "Sales.metadata~abc123.json"
129
+ * For content records: naturalBase = "colors" → "colors.metadata.json"
130
+ * For media records: naturalBase = "logo.png" → "logo.png.metadata.json"
131
+ * For output records: naturalBase = "Sales" → "Sales.metadata.json"
132
+ *
133
+ * Collision avoidance (when two records share a name) is handled by the caller
134
+ * via numbered suffixes: "colors-1.metadata.json", "colors-2.metadata.json", etc.
128
135
  *
129
136
  * @param {string} naturalBase - Name without any ~uid (may include media extension)
130
- * @param {string} uid - Server-assigned UID
131
137
  * @returns {string}
132
138
  */
133
- export function buildMetaFilename(naturalBase, uid) {
139
+ export function buildMetaFilename(naturalBase) {
134
140
  // Guard: strip any trailing .metadata suffix(es) and ~uid fragments from naturalBase
135
- // to prevent double-metadata filenames (e.g., "app.metadata.metadata~app.json")
141
+ // to prevent double-metadata filenames (e.g., "app.metadata.metadata.json")
136
142
  let base = naturalBase;
137
143
  const metaParsed = parseMetaFilename(base + '.json');
138
144
  if (metaParsed) {
@@ -142,34 +148,43 @@ export function buildMetaFilename(naturalBase, uid) {
142
148
  while (base.endsWith('.metadata')) {
143
149
  base = base.substring(0, base.length - 9);
144
150
  }
145
- return `${base}.metadata~${uid}.json`;
151
+ return `${base}.metadata.json`;
146
152
  }
147
153
 
148
154
  /**
149
155
  * Test whether a filename is a metadata file (new or legacy format).
150
156
  *
151
- * New format: name.metadata~uid.json
152
- * Legacy format: name~uid.metadata.json (also accepted during migration)
157
+ * New format: name.metadata.json (UID in JSON content, not filename)
158
+ * Legacy format: name.metadata~uid.json (also accepted during migration)
153
159
  *
154
160
  * @param {string} filename
155
161
  * @returns {boolean}
156
162
  */
157
163
  export function isMetadataFile(filename) {
158
- return /\.metadata~[a-z0-9]+\.json$/i.test(filename) // new: name.metadata~uid.json
159
- || filename.endsWith('.metadata.json') // legacy + pre-UID temp files
160
- || /\.[a-z0-9]{10,}\.metadata\.json$/i.test(filename); // legacy dot-separated: name.uid.metadata.json
164
+ return filename.endsWith('.metadata.json') // new format + legacy pre-UID temp files
165
+ || /\.metadata~[a-z0-9_]+\.json$/i.test(filename) // legacy tilde-uid suffix format
166
+ || /\.[a-z0-9]{10,}\.metadata\.json$/i.test(filename); // legacy dot-separated: name.uid.metadata.json
161
167
  }
162
168
 
163
169
  /**
164
- * Parse a new-format metadata filename into its components.
165
- * Returns null for legacy-format filenames (use detectLegacyTildeMetadata for those).
170
+ * Parse a metadata filename into its components.
171
+ *
172
+ * New format: name.metadata.json → { naturalBase: "name" }
173
+ * Legacy format: name.metadata~uid.json → { naturalBase: "name", uid: "uid" }
166
174
  *
167
- * @param {string} filename - e.g. "colors.metadata~abc123.json"
168
- * @returns {{ naturalBase: string, uid: string } | null}
175
+ * Returns null for non-metadata filenames.
176
+ *
177
+ * @param {string} filename
178
+ * @returns {{ naturalBase: string, uid?: string } | null}
169
179
  */
170
180
  export function parseMetaFilename(filename) {
171
- const m = filename.match(/^(.+)\.metadata~([a-z0-9]+)\.json$/i);
172
- return m ? { naturalBase: m[1], uid: m[2] } : null;
181
+ // Legacy format with uid in suffix (migration target for 008, source for 013)
182
+ const legacy = filename.match(/^(.+)\.metadata~([a-z0-9_]+)\.json$/i);
183
+ if (legacy) return { naturalBase: legacy[1], uid: legacy[2] };
184
+
185
+ // New format: name.metadata.json
186
+ const m = filename.match(/^(.+)\.metadata\.json$/i);
187
+ return m ? { naturalBase: m[1] } : null;
173
188
  }
174
189
 
175
190
  /**
@@ -181,11 +196,11 @@ export function parseMetaFilename(filename) {
181
196
  */
182
197
  export function detectLegacyTildeMetadata(filename) {
183
198
  // Case 1: name~uid.metadata.json (content/entity metadata)
184
- const m1 = filename.match(/^(.+)~([a-z0-9]{10,})\.metadata\.json$/);
199
+ const m1 = filename.match(/^(.+)~([a-z0-9_]{10,})\.metadata\.json$/);
185
200
  if (m1) return { naturalBase: m1[1], uid: m1[2] };
186
201
 
187
202
  // Case 2: name~uid.ext.metadata.json (media metadata)
188
- const m2 = filename.match(/^(.+)~([a-z0-9]{10,})\.([a-z0-9]+)\.metadata\.json$/);
203
+ const m2 = filename.match(/^(.+)~([a-z0-9_]{10,})\.([a-z0-9]+)\.metadata\.json$/);
189
204
  if (m2) return { naturalBase: `${m2[1]}.${m2[3]}`, uid: m2[2] };
190
205
 
191
206
  return null;
@@ -210,7 +225,7 @@ export async function findMetadataForCompanion(companionPath) {
210
225
  let entries;
211
226
  try { entries = await readdir(dir); } catch { return null; }
212
227
 
213
- // 1. Fast path: match naturalBase of new-format metadata files
228
+ // 1. Fast path: match naturalBase of metadata files
214
229
  for (const entry of entries) {
215
230
  const parsed = parseMetaFilename(entry);
216
231
  if (parsed && (parsed.naturalBase === base || parsed.naturalBase === companionName)) {
@@ -218,7 +233,7 @@ export async function findMetadataForCompanion(companionPath) {
218
233
  }
219
234
  }
220
235
 
221
- // 2. Scan all metadata files (new + legacy) for @reference match
236
+ // 2. Scan all metadata files for @reference match
222
237
  for (const entry of entries) {
223
238
  if (!isMetadataFile(entry)) continue;
224
239
  const metaPath = join(dir, entry);
@@ -241,8 +256,10 @@ export async function findMetadataForCompanion(companionPath) {
241
256
  }
242
257
 
243
258
  /**
244
- * Rename a file pair (content + metadata) to the ~uid convention after server assigns a UID.
245
- * Updates @reference values inside the metadata file.
259
+ * Update the UID field in a metadata file in-place.
260
+ * Previously renamed the file to include ~uid in the name; now the UID lives
261
+ * only inside the JSON content, so no rename is needed.
262
+ *
246
263
  * Restores file timestamps from _LastUpdated.
247
264
  *
248
265
  * @param {Object} meta - Current metadata object
@@ -250,57 +267,28 @@ export async function findMetadataForCompanion(companionPath) {
250
267
  * @param {string} uid - Newly assigned UID from server
251
268
  * @param {string} lastUpdated - Server _LastUpdated value
252
269
  * @param {string} serverTz - Timezone for timestamp conversion
253
- * @returns {Promise<{ newMetaPath: string, newFilePath: string|null }>}
270
+ * @returns {Promise<{ newMetaPath: string, newFilePath: null, updatedMeta: Object }>}
254
271
  */
255
272
  export async function renameToUidConvention(meta, metaPath, uid, lastUpdated, serverTz) {
256
- const metaDir = dirname(metaPath);
257
- const metaFilename = basename(metaPath);
258
-
259
- // Already in new format — nothing to do
260
- if (parseMetaFilename(metaFilename)?.uid === uid) {
261
- return { newMetaPath: metaPath, newFilePath: null, updatedMeta: meta };
262
- }
263
-
264
- // Determine naturalBase from the temp/old metadata filename
265
- // Temp format from adopt.js: "colors.metadata.json" → naturalBase = "colors"
266
- // Old tilde format: "colors~uid.metadata.json" → naturalBase = "colors"
267
- let naturalBase;
268
- const legacyParsed = detectLegacyTildeMetadata(metaFilename);
269
- if (legacyParsed) {
270
- naturalBase = legacyParsed.naturalBase;
271
- } else if (metaFilename.endsWith('.metadata.json')) {
272
- naturalBase = metaFilename.slice(0, -'.metadata.json'.length);
273
- } else {
274
- naturalBase = basename(metaFilename, '.json');
275
- }
276
-
277
- const newMetaPath = join(metaDir, buildMetaFilename(naturalBase, uid));
278
-
279
- // Update only the UID field; @reference values are UNCHANGED (companions keep natural names)
280
273
  const updatedMeta = { ...meta, UID: uid };
281
274
 
282
- // Write updated metadata to new path
283
- await writeFile(newMetaPath, JSON.stringify(updatedMeta, null, 2) + '\n');
284
-
285
- // Remove old metadata file (new content already written to newMetaPath above)
286
- if (metaPath !== newMetaPath) {
287
- try { await unlink(metaPath); } catch { /* old file already gone */ }
288
- }
275
+ // Write updated metadata to same path (no rename — UID is in the JSON, not the filename)
276
+ await writeFile(metaPath, JSON.stringify(updatedMeta, null, 2) + '\n');
289
277
 
290
- // Restore timestamps for metadata file and companions (companions are not renamed)
278
+ // Restore timestamps for metadata file and companions
291
279
  if (serverTz && lastUpdated) {
292
280
  const { setFileTimestamps } = await import('./timestamps.js');
293
- try { await setFileTimestamps(newMetaPath, lastUpdated, lastUpdated, serverTz); } catch {}
281
+ try { await setFileTimestamps(metaPath, lastUpdated, lastUpdated, serverTz); } catch {}
294
282
  const contentCols = [...(meta._companionReferenceColumns || meta._contentColumns || [])];
295
283
  if (meta._mediaFile) contentCols.push('_mediaFile');
296
284
  for (const col of contentCols) {
297
285
  const ref = updatedMeta[col];
298
286
  if (ref && String(ref).startsWith('@')) {
299
- const fp = join(metaDir, String(ref).substring(1));
287
+ const fp = join(dirname(metaPath), String(ref).substring(1));
300
288
  try { await setFileTimestamps(fp, lastUpdated, lastUpdated, serverTz); } catch {}
301
289
  }
302
290
  }
303
291
  }
304
292
 
305
- return { newMetaPath, newFilePath: null, updatedMeta };
293
+ return { newMetaPath: metaPath, newFilePath: null, updatedMeta };
306
294
  }
package/src/lib/ignore.js CHANGED
@@ -11,6 +11,9 @@ const DBOIGNORE_FILE = '.dboignore';
11
11
  const DEFAULT_FILE_CONTENT = `# DBO CLI ignore patterns
12
12
  # (gitignore-style syntax — works like .gitignore)
13
13
 
14
+ # Build artifacts
15
+ *.map
16
+
14
17
  # DBO internal
15
18
  .app/
16
19
  .dboignore