@dboio/cli 0.11.4 → 0.15.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.
Files changed (57) hide show
  1. package/README.md +183 -3
  2. package/bin/dbo.js +6 -0
  3. package/package.json +1 -1
  4. package/plugins/claude/dbo/.claude-plugin/plugin.json +1 -1
  5. package/plugins/claude/dbo/commands/dbo.md +66 -243
  6. package/plugins/claude/dbo/docs/_audit_required/API/all.md +40 -0
  7. package/plugins/claude/dbo/docs/_audit_required/API/app.md +38 -0
  8. package/plugins/claude/dbo/docs/_audit_required/API/athenticate.md +26 -0
  9. package/plugins/claude/dbo/docs/_audit_required/API/cache.md +29 -0
  10. package/plugins/claude/dbo/docs/_audit_required/API/content.md +14 -0
  11. package/plugins/claude/dbo/docs/_audit_required/API/data_source.md +28 -0
  12. package/plugins/claude/dbo/docs/_audit_required/API/email.md +18 -0
  13. package/plugins/claude/dbo/docs/_audit_required/API/input.md +25 -0
  14. package/plugins/claude/dbo/docs/_audit_required/API/instance.md +28 -0
  15. package/plugins/claude/dbo/docs/_audit_required/API/log.md +8 -0
  16. package/plugins/claude/dbo/docs/_audit_required/API/media.md +12 -0
  17. package/plugins/claude/dbo/docs/_audit_required/API/output_by_entity.md +12 -0
  18. package/plugins/claude/dbo/docs/_audit_required/API/upload.md +7 -0
  19. package/plugins/claude/dbo/docs/_audit_required/dbo-api-syntax.md +1487 -0
  20. package/plugins/claude/dbo/docs/_audit_required/dbo-problems-code.md +111 -0
  21. package/plugins/claude/dbo/docs/_audit_required/dbo-problems-performance.md +109 -0
  22. package/plugins/claude/dbo/docs/_audit_required/dbo-problems-syntax.md +97 -0
  23. package/plugins/claude/dbo/docs/_audit_required/dbo-product-market.md +119 -0
  24. package/plugins/claude/dbo/docs/_audit_required/dbo-white-paper.md +125 -0
  25. package/plugins/claude/dbo/docs/dbo-cheat-sheet.md +323 -0
  26. package/plugins/claude/dbo/docs/dbo-cli-readme.md +2279 -0
  27. package/plugins/claude/dbo/docs/dbo-core-entities.md +878 -0
  28. package/plugins/claude/dbo/docs/dbo-output-customsql.md +677 -0
  29. package/plugins/claude/dbo/docs/dbo-output-query.md +967 -0
  30. package/plugins/claude/dbo/skills/cli/SKILL.md +63 -246
  31. package/src/commands/add.js +373 -64
  32. package/src/commands/build.js +102 -0
  33. package/src/commands/clone.js +719 -212
  34. package/src/commands/deploy.js +9 -2
  35. package/src/commands/diff.js +7 -3
  36. package/src/commands/init.js +16 -2
  37. package/src/commands/input.js +3 -1
  38. package/src/commands/login.js +30 -4
  39. package/src/commands/mv.js +28 -7
  40. package/src/commands/push.js +298 -78
  41. package/src/commands/rm.js +21 -6
  42. package/src/commands/run.js +81 -0
  43. package/src/commands/tag.js +65 -0
  44. package/src/lib/config.js +67 -0
  45. package/src/lib/delta.js +7 -1
  46. package/src/lib/deploy-config.js +137 -0
  47. package/src/lib/diff.js +28 -5
  48. package/src/lib/filenames.js +198 -54
  49. package/src/lib/ignore.js +6 -0
  50. package/src/lib/input-parser.js +13 -4
  51. package/src/lib/scaffold.js +1 -1
  52. package/src/lib/scripts.js +232 -0
  53. package/src/lib/tagging.js +380 -0
  54. package/src/lib/toe-stepping.js +2 -1
  55. package/src/migrations/006-remove-uid-companion-filenames.js +181 -0
  56. package/src/migrations/007-natural-entity-companion-filenames.js +165 -0
  57. package/src/migrations/008-metadata-uid-in-suffix.js +70 -0
@@ -0,0 +1,380 @@
1
+ import { execFile } from 'child_process';
2
+ import { writeFile, readFile, unlink, stat, access } from 'fs/promises';
3
+ import { join, dirname, relative } from 'path';
4
+ import { readdir } from 'fs/promises';
5
+ import { tmpdir } from 'os';
6
+ import { promisify } from 'util';
7
+ import { findMetadataFiles, hasLocalModifications } from './diff.js';
8
+ import { loadIgnore } from './ignore.js';
9
+ import { loadAppConfig, loadTagConfig } from './config.js';
10
+
11
+ const execFileAsync = promisify(execFile);
12
+
13
+ // ── Tag status map ─────────────────────────────────────────────────────────────
14
+ // Tag names use 'dbo:' prefix so --clear only removes dbo-applied tags.
15
+ // macCode values: 0=None 1=Grey 2=Green 3=Purple 4=Blue 5=Yellow 6=Orange 7=Red
16
+ const TAG_MAP = {
17
+ synced: { name: 'dbo:Synced', macCode: 2, linuxEmblem: 'emblem-default' },
18
+ modified: { name: 'dbo:Modified', macCode: 4, linuxEmblem: 'emblem-important' },
19
+ untracked: { name: 'dbo:Untracked', macCode: 5, linuxEmblem: 'emblem-new' },
20
+ conflict: { name: 'dbo:Conflict', macCode: 6, linuxEmblem: 'emblem-urgent' },
21
+ trashed: { name: 'dbo:Trashed', macCode: 7, linuxEmblem: 'emblem-unreadable' },
22
+ };
23
+
24
+ // ── Public API ─────────────────────────────────────────────────────────────────
25
+
26
+ /**
27
+ * Apply sync-status tag to a single file. Best-effort, never throws.
28
+ * @param {string} filePath Absolute path to the content/companion file.
29
+ * @param {string} status One of: synced | modified | untracked | conflict | trashed
30
+ */
31
+ export async function setFileTag(filePath, status) {
32
+ try {
33
+ const info = TAG_MAP[status];
34
+ if (!info) return;
35
+ switch (process.platform) {
36
+ case 'darwin': await _setMacTag(filePath, info); break;
37
+ case 'linux': await _setLinuxTag(filePath, info); break;
38
+ // win32 and others: silently skip
39
+ }
40
+ } catch { /* best-effort */ }
41
+ }
42
+
43
+ /**
44
+ * Remove all dbo:* tags from a single file. Best-effort, never throws.
45
+ * @param {string} filePath Absolute path to the content/companion file.
46
+ */
47
+ export async function clearFileTag(filePath) {
48
+ try {
49
+ switch (process.platform) {
50
+ case 'darwin': await _clearMacTag(filePath); break;
51
+ case 'linux': await _clearLinuxTag(filePath); break;
52
+ }
53
+ } catch { /* best-effort */ }
54
+ }
55
+
56
+ /**
57
+ * Scan all companion files in the project and apply sync-status tags.
58
+ * Respects .dboignore. Skips if tagFiles config is false or platform unsupported.
59
+ *
60
+ * @param {Object} [options]
61
+ * @param {boolean} [options.verbose] Log each file tagged.
62
+ * @param {boolean} [options.clearAll] Remove all dbo:* tags instead of applying.
63
+ * @param {string} [options.dir] Root directory to scan (defaults to cwd).
64
+ * @returns {Promise<{synced:number,modified:number,untracked:number,conflict:number,trashed:number}|null>}
65
+ * Returns null on unsupported platform or when tagFiles is disabled.
66
+ */
67
+ export async function tagProjectFiles(options = {}) {
68
+ const { verbose = false, clearAll = false, dir = process.cwd() } = options;
69
+ const platform = process.platform;
70
+ if (platform !== 'darwin' && platform !== 'linux') return null;
71
+
72
+ const { tagFiles } = await loadTagConfig().catch(() => ({ tagFiles: true }));
73
+ if (!tagFiles) return null;
74
+
75
+ let config;
76
+ try { config = await loadAppConfig(); } catch { config = {}; }
77
+
78
+ const ig = await loadIgnore();
79
+ const metaPaths = await findMetadataFiles(dir, ig);
80
+ const counts = { synced: 0, modified: 0, untracked: 0, conflict: 0, trashed: 0 };
81
+
82
+ if (clearAll) {
83
+ const companions = (await Promise.all(metaPaths.map(mp => _getCompanionPaths(mp)))).flat();
84
+ await _bulkApplyTags(companions.map(fp => ({ filePath: fp, clear: true })));
85
+ return null;
86
+ }
87
+
88
+ // Collect file→status pairs from metadata
89
+ const toTag = [];
90
+ for (const metaPath of metaPaths) {
91
+ const inTrash = metaPath.replace(/\\/g, '/').includes('/trash/');
92
+ const status = inTrash
93
+ ? 'trashed'
94
+ : (await hasLocalModifications(metaPath, config).catch(() => false)) ? 'modified'
95
+ : 'synced';
96
+ const companions = await _getCompanionPaths(metaPath);
97
+ for (const filePath of companions) {
98
+ toTag.push({ filePath, status });
99
+ counts[status]++;
100
+ if (verbose) console.log(` ${status.padEnd(10)} ${relative(dir, filePath)}`);
101
+ }
102
+ }
103
+
104
+ // Detect untracked files (content files without any metadata)
105
+ const untrackedFiles = await _findUntrackedFiles(dir, ig, metaPaths);
106
+ for (const filePath of untrackedFiles) {
107
+ toTag.push({ filePath, status: 'untracked' });
108
+ counts.untracked++;
109
+ if (verbose) console.log(` untracked ${relative(dir, filePath)}`);
110
+ }
111
+
112
+ await _bulkApplyTags(toTag);
113
+
114
+ // Re-apply trash folder icon (best-effort, in case it was cleared)
115
+ const trashDir = join(dir, 'trash');
116
+ ensureTrashIcon(trashDir).catch(() => {});
117
+
118
+ return counts;
119
+ }
120
+
121
+ // ── Private helpers ────────────────────────────────────────────────────────────
122
+
123
+ async function _getCompanionPaths(metaPath) {
124
+ try {
125
+ const meta = JSON.parse(await readFile(metaPath, 'utf8'));
126
+ const dir = dirname(metaPath);
127
+ const paths = [];
128
+ for (const col of (meta._contentColumns || [])) {
129
+ const ref = meta[col];
130
+ if (ref && String(ref).startsWith('@')) {
131
+ const candidate = join(dir, String(ref).substring(1));
132
+ try { await stat(candidate); paths.push(candidate); } catch { /* missing */ }
133
+ }
134
+ }
135
+ for (const col of (meta._mediaColumns || [])) {
136
+ const ref = meta[col];
137
+ if (ref && String(ref).startsWith('@')) {
138
+ const candidate = join(dir, String(ref).substring(1));
139
+ try { await stat(candidate); paths.push(candidate); } catch { /* missing */ }
140
+ }
141
+ }
142
+ return paths;
143
+ } catch { return []; }
144
+ }
145
+
146
+ async function _findUntrackedFiles(dir, ig, knownMetaPaths) {
147
+ const knownCompanions = new Set();
148
+ for (const mp of knownMetaPaths) {
149
+ for (const fp of await _getCompanionPaths(mp)) knownCompanions.add(fp);
150
+ }
151
+ const all = await _collectContentFiles(dir, ig);
152
+ return all.filter(fp => !knownCompanions.has(fp));
153
+ }
154
+
155
+ // Recursively collect non-metadata, non-hidden, non-.dbo content files
156
+ async function _collectContentFiles(dir, ig) {
157
+ const results = [];
158
+ let entries;
159
+ try { entries = await readdir(dir, { withFileTypes: true }); } catch { return []; }
160
+ for (const entry of entries) {
161
+ if (entry.name.startsWith('.')) continue; // skip hidden and .dbo
162
+ const fullPath = join(dir, entry.name);
163
+ const relPath = relative(process.cwd(), fullPath).replace(/\\/g, '/');
164
+ if (entry.isDirectory()) {
165
+ if (ig.ignores(relPath + '/') || ig.ignores(relPath)) continue;
166
+ results.push(...await _collectContentFiles(fullPath, ig));
167
+ } else {
168
+ if (entry.name.endsWith('.json')) continue; // skip metadata and config files
169
+ if (ig.ignores(relPath)) continue;
170
+ results.push(fullPath);
171
+ }
172
+ }
173
+ return results;
174
+ }
175
+
176
+ // Apply tags in bulk. For macOS with >50 files, uses a shell script for performance.
177
+ // items: Array<{ filePath: string, status?: string, clear?: boolean }>
178
+ async function _bulkApplyTags(items) {
179
+ if (!items.length) return;
180
+ const platform = process.platform;
181
+
182
+ if (platform === 'darwin') {
183
+ if (items.length <= 50) {
184
+ await Promise.all(items.map(({ filePath, status, clear }) =>
185
+ clear ? _clearMacTag(filePath) : _setMacTag(filePath, TAG_MAP[status])
186
+ ));
187
+ } else {
188
+ // Batch mode: write one shell script and execute it once
189
+ const lines = items.map(({ filePath, status, clear }) => {
190
+ const escaped = filePath.replace(/'/g, "'\\''");
191
+ if (clear) return `xattr -d com.apple.metadata:_kMDItemUserTags '${escaped}' 2>/dev/null || true`;
192
+ const info = TAG_MAP[status];
193
+ if (!info) return '';
194
+ return `xattr -w com.apple.metadata:_kMDItemUserTags '("${info.name}\\n${info.macCode}")' '${escaped}'`;
195
+ }).filter(Boolean);
196
+ const tmpFile = join(tmpdir(), `dbo-tag-${Date.now()}.sh`);
197
+ await writeFile(tmpFile, `#!/bin/sh\n${lines.join('\n')}\n`);
198
+ try {
199
+ await execFileAsync('sh', [tmpFile], { timeout: 30000 });
200
+ } finally {
201
+ await unlink(tmpFile).catch(() => {});
202
+ }
203
+ }
204
+ } else if (platform === 'linux') {
205
+ await Promise.all(items.map(({ filePath, status, clear }) =>
206
+ clear ? _clearLinuxTag(filePath) : _setLinuxTag(filePath, TAG_MAP[status])
207
+ ));
208
+ }
209
+ }
210
+
211
+ // ── macOS implementation ───────────────────────────────────────────────────────
212
+
213
+ async function _setMacTag(filePath, tagInfo) {
214
+ // Read existing non-dbo tags, append the new dbo: tag, write back.
215
+ const existing = await _readMacTags(filePath);
216
+ const filtered = existing.filter(t => !t.startsWith('dbo:'));
217
+ filtered.push(`${tagInfo.name}\n${tagInfo.macCode}`);
218
+ await _writeMacTags(filePath, filtered);
219
+ }
220
+
221
+ async function _clearMacTag(filePath) {
222
+ const existing = await _readMacTags(filePath);
223
+ const remaining = existing.filter(t => !t.startsWith('dbo:'));
224
+ if (remaining.length === 0) {
225
+ await execFileAsync('xattr', ['-d', 'com.apple.metadata:_kMDItemUserTags', filePath], { timeout: 5000 }).catch(() => {});
226
+ } else {
227
+ await _writeMacTags(filePath, remaining);
228
+ }
229
+ }
230
+
231
+ // Use python3 (always on macOS 10.15+) to read binary plist — no third-party modules needed.
232
+ async function _readMacTags(filePath) {
233
+ try {
234
+ const script =
235
+ `import plistlib,sys,subprocess\n` +
236
+ `r=subprocess.run(['xattr','-px','com.apple.metadata:_kMDItemUserTags',sys.argv[1]],capture_output=True,text=True)\n` +
237
+ `if r.returncode!=0:print('');exit()\n` +
238
+ `hex_=r.stdout.strip().replace(' ','').replace('\\n','')\n` +
239
+ `tags=plistlib.loads(bytes.fromhex(hex_))\n` +
240
+ `print('\\x00'.join(tags))`;
241
+ const { stdout } = await execFileAsync('python3', ['-c', script, filePath], { timeout: 5000 });
242
+ return stdout.trim() ? stdout.trim().split('\x00') : [];
243
+ } catch { return []; }
244
+ }
245
+
246
+ async function _writeMacTags(filePath, tags) {
247
+ const script =
248
+ `import plistlib,sys,subprocess\n` +
249
+ `tags=sys.argv[2:]\n` +
250
+ `data=plistlib.dumps(tags,fmt=plistlib.FMT_BINARY)\n` +
251
+ `hex_=data.hex()\n` +
252
+ `subprocess.run(['xattr','-wx','com.apple.metadata:_kMDItemUserTags',hex_,sys.argv[1]],check=True)`;
253
+ await execFileAsync('python3', ['-c', script, filePath, ...tags], { timeout: 5000 });
254
+ }
255
+
256
+ // ── Linux implementation ───────────────────────────────────────────────────────
257
+
258
+ async function _setLinuxTag(filePath, tagInfo) {
259
+ await execFileAsync('gio', ['set', filePath, 'metadata::emblems', tagInfo.linuxEmblem], { timeout: 5000 });
260
+ }
261
+
262
+ async function _clearLinuxTag(filePath) {
263
+ await execFileAsync('gio', ['set', filePath, 'metadata::emblems', ''], { timeout: 5000 }).catch(() => {});
264
+ }
265
+
266
+ // ── Folder icon (consolidated from folder-icon.js) ────────────────────────────
267
+
268
+ /**
269
+ * Best-effort: apply the system trash/recycle-bin icon to a folder.
270
+ * Never throws — all errors are silently swallowed.
271
+ *
272
+ * @param {string} folderPath Absolute path to the trash directory
273
+ */
274
+ export async function applyTrashIcon(folderPath) {
275
+ try {
276
+ const s = await stat(folderPath);
277
+ if (!s.isDirectory()) return;
278
+
279
+ switch (process.platform) {
280
+ case 'darwin':
281
+ await applyMacIcon(folderPath);
282
+ break;
283
+ case 'win32':
284
+ await applyWindowsIcon(folderPath);
285
+ break;
286
+ case 'linux':
287
+ await applyLinuxIcon(folderPath);
288
+ break;
289
+ }
290
+ } catch {
291
+ // Best-effort — never propagate
292
+ }
293
+ }
294
+
295
+ /**
296
+ * Re-apply the trash icon only if the icon marker file is missing.
297
+ * Much cheaper than applyTrashIcon — skips the osascript/attrib call
298
+ * when the icon is already in place.
299
+ *
300
+ * Call this after moving files into trash/ to self-heal the icon
301
+ * in case the user cleared the directory contents.
302
+ *
303
+ * @param {string} folderPath Absolute path to the trash directory
304
+ */
305
+ export async function ensureTrashIcon(folderPath) {
306
+ try {
307
+ switch (process.platform) {
308
+ case 'darwin': {
309
+ // macOS: Icon\r file exists when icon is applied
310
+ const iconFile = join(folderPath, 'Icon\r');
311
+ try { await access(iconFile); return; } catch { /* missing — re-apply */ }
312
+ await applyMacIcon(folderPath);
313
+ break;
314
+ }
315
+ case 'win32': {
316
+ const iniPath = join(folderPath, 'desktop.ini');
317
+ try { await access(iniPath); return; } catch { /* missing */ }
318
+ await applyWindowsIcon(folderPath);
319
+ break;
320
+ }
321
+ case 'linux': {
322
+ const dirFile = join(folderPath, '.directory');
323
+ try { await access(dirFile); return; } catch { /* missing */ }
324
+ await applyLinuxIcon(folderPath);
325
+ break;
326
+ }
327
+ }
328
+ } catch {
329
+ // Best-effort — never propagate
330
+ }
331
+ }
332
+
333
+ // ── macOS ──────────────────────────────────────────────────────────────
334
+
335
+ const MACOS_TRASH_ICON =
336
+ '/System/Library/CoreServices/CoreTypes.bundle/Contents/Resources/TrashIcon.icns';
337
+
338
+ async function applyMacIcon(folderPath) {
339
+ await access(MACOS_TRASH_ICON);
340
+
341
+ const script =
342
+ 'use framework "AppKit"\n' +
343
+ `set iconImage to (current application's NSImage's alloc()'s initWithContentsOfFile:"${MACOS_TRASH_ICON}")\n` +
344
+ `(current application's NSWorkspace's sharedWorkspace()'s setIcon:iconImage forFile:"${folderPath}" options:0)`;
345
+
346
+ await execFileAsync('osascript', ['-e', script], { timeout: 5000 });
347
+ }
348
+
349
+ // ── Windows ────────────────────────────────────────────────────────────
350
+
351
+ async function applyWindowsIcon(folderPath) {
352
+ const iniPath = join(folderPath, 'desktop.ini');
353
+ const iniContent =
354
+ '[.ShellClassInfo]\r\nIconResource=%SystemRoot%\\System32\\shell32.dll,31\r\n';
355
+
356
+ await writeFile(iniPath, iniContent);
357
+ await execFileAsync('attrib', ['+H', '+S', iniPath], { timeout: 5000 });
358
+ await execFileAsync('attrib', ['+S', folderPath], { timeout: 5000 });
359
+ }
360
+
361
+ // ── Linux ──────────────────────────────────────────────────────────────
362
+
363
+ async function applyLinuxIcon(folderPath) {
364
+ // KDE Dolphin — .directory file
365
+ await writeFile(
366
+ join(folderPath, '.directory'),
367
+ '[Desktop Entry]\nIcon=user-trash\n',
368
+ );
369
+
370
+ // GNOME Nautilus — gio metadata (may not be available on KDE-only systems)
371
+ try {
372
+ await execFileAsync(
373
+ 'gio',
374
+ ['set', folderPath, 'metadata::custom-icon-name', 'user-trash'],
375
+ { timeout: 5000 },
376
+ );
377
+ } catch {
378
+ // gio not available — .directory file is enough for KDE
379
+ }
380
+ }
@@ -4,6 +4,7 @@ import { readFile } from 'fs/promises';
4
4
  import { findBaselineEntry, shouldSkipColumn, normalizeValue, isReference, resolveReferencePath } from './delta.js';
5
5
  import { resolveContentValue } from '../commands/clone.js';
6
6
  import { computeLineDiff, formatDiff } from './diff.js';
7
+ import { parseMetaFilename } from './filenames.js';
7
8
  import { log } from './logger.js';
8
9
 
9
10
  /**
@@ -358,7 +359,7 @@ export async function checkToeStepping(records, client, baseline, options, appSh
358
359
 
359
360
  // Conflict detected: server changed since our baseline
360
361
  const metaDir = dirname(metaPath);
361
- const label = basename(metaPath, '.metadata.json');
362
+ const label = parseMetaFilename(basename(metaPath))?.naturalBase ?? basename(metaPath, '.metadata.json');
362
363
  const diffColumns = await buildRecordDiff(serverEntry, baselineEntry, meta, metaDir);
363
364
 
364
365
  // If the only server-side changes are metadata columns (_LastUpdated,
@@ -0,0 +1,181 @@
1
+ import { readdir, readFile, writeFile, rename, access, mkdir } from 'fs/promises';
2
+ import { join, extname, basename, dirname } from 'path';
3
+ import { log } from '../lib/logger.js';
4
+ import { stripUidFromFilename, hasUidInFilename, isMetadataFile } from '../lib/filenames.js';
5
+ import { ensureTrashIcon } from '../lib/tagging.js';
6
+
7
+ export const description = 'Rename legacy ~UID companion files to natural filenames';
8
+
9
+ /**
10
+ * Migration 006 — Rename companion files from name~uid.ext to name.ext.
11
+ *
12
+ * Handles two cases:
13
+ * A) @reference itself contains ~UID (e.g. "@colors~uid.css") — strip ~UID
14
+ * from both the filename on disk AND the @reference in metadata.
15
+ * B) @reference is already natural but file on disk has ~UID — just rename file.
16
+ *
17
+ * Metadata files retain their ~UID naming.
18
+ */
19
+ export default async function run(_options) {
20
+ const cwd = process.cwd();
21
+ let totalRenamed = 0;
22
+ let totalRefsUpdated = 0;
23
+
24
+ const metaFiles = await findAllMetadataFiles(cwd);
25
+
26
+ for (const metaPath of metaFiles) {
27
+ try {
28
+ const meta = JSON.parse(await readFile(metaPath, 'utf8'));
29
+ const uid = meta.UID;
30
+ if (!uid) continue;
31
+
32
+ const metaDir = dirname(metaPath);
33
+ const contentCols = [...(meta._contentColumns || [])];
34
+ if (meta._mediaFile) contentCols.push('_mediaFile');
35
+ let metaChanged = false;
36
+
37
+ for (const col of contentCols) {
38
+ const ref = meta[col];
39
+ if (!ref || !String(ref).startsWith('@')) continue;
40
+
41
+ const refName = String(ref).substring(1);
42
+
43
+ // Case A: @reference itself contains ~UID — strip it
44
+ if (hasUidInFilename(refName, uid)) {
45
+ const naturalName = stripUidFromFilename(refName, uid);
46
+ const legacyPath = join(metaDir, refName);
47
+ const naturalPath = join(metaDir, naturalName);
48
+
49
+ // Rename or trash legacy file
50
+ let naturalExists = false;
51
+ let legacyExists = false;
52
+ try { await access(naturalPath); naturalExists = true; } catch { /* missing */ }
53
+ try { await access(legacyPath); legacyExists = true; } catch { /* missing */ }
54
+
55
+ if (legacyExists && !naturalExists) {
56
+ try {
57
+ await rename(legacyPath, naturalPath);
58
+ log.dim(` ${refName} → ${naturalName}`);
59
+ totalRenamed++;
60
+ } catch { /* rename failed */ }
61
+ } else if (legacyExists && naturalExists) {
62
+ // Both exist — move orphan to trash
63
+ try {
64
+ const trashDir = join(process.cwd(), 'trash');
65
+ await mkdir(trashDir, { recursive: true });
66
+ await rename(legacyPath, join(trashDir, basename(legacyPath)));
67
+ await ensureTrashIcon(trashDir);
68
+ log.dim(` Trashed orphan: ${refName}`);
69
+ totalRenamed++;
70
+ } catch { /* non-critical */ }
71
+ }
72
+
73
+ // Update @reference
74
+ meta[col] = `@${naturalName}`;
75
+ metaChanged = true;
76
+ continue;
77
+ }
78
+
79
+ // Case B: @reference is natural but file on disk might have ~UID
80
+ const naturalPath = join(metaDir, refName);
81
+ try { await access(naturalPath); continue; } catch { /* missing */ }
82
+
83
+ const ext = extname(refName);
84
+ const base = basename(refName, ext);
85
+ const legacyName = ext ? `${base}~${uid}${ext}` : `${base}~${uid}`;
86
+ const legacyPath = join(metaDir, legacyName);
87
+
88
+ try {
89
+ await access(legacyPath);
90
+ await rename(legacyPath, naturalPath);
91
+ log.dim(` ${legacyName} → ${refName}`);
92
+ totalRenamed++;
93
+ } catch { /* legacy not present — skip */ }
94
+ }
95
+
96
+ // Rewrite metadata if @references were updated
97
+ if (metaChanged) {
98
+ try {
99
+ await writeFile(metaPath, JSON.stringify(meta, null, 2) + '\n');
100
+ totalRefsUpdated++;
101
+ } catch { /* non-critical */ }
102
+ }
103
+ } catch { /* skip unreadable metadata */ }
104
+ }
105
+
106
+ // Pass 2: Trash orphaned ~UID companion files not referenced by any metadata
107
+ const referencedFiles = new Set();
108
+ const tildeFiles = [];
109
+
110
+ for (const metaPath of metaFiles) {
111
+ try {
112
+ const meta = JSON.parse(await readFile(metaPath, 'utf8'));
113
+ const metaDir = dirname(metaPath);
114
+ const cols = [...(meta._contentColumns || [])];
115
+ if (meta._mediaFile) cols.push('_mediaFile');
116
+ for (const col of cols) {
117
+ const ref = meta[col];
118
+ if (ref && String(ref).startsWith('@')) {
119
+ const refName = String(ref).substring(1);
120
+ referencedFiles.add(refName.startsWith('/') ? join(cwd, refName) : join(metaDir, refName));
121
+ }
122
+ }
123
+ } catch { /* skip */ }
124
+ }
125
+
126
+ // Scan for ~ files not referenced
127
+ async function scanForTildeFiles(dir) {
128
+ let entries;
129
+ try { entries = await readdir(dir, { withFileTypes: true }); } catch { return; }
130
+ for (const entry of entries) {
131
+ if (SKIP.has(entry.name) || entry.name.startsWith('.')) continue;
132
+ const full = join(dir, entry.name);
133
+ if (entry.isDirectory()) { await scanForTildeFiles(full); continue; }
134
+ if (!entry.name.endsWith('.metadata.json') && entry.name.includes('~')) {
135
+ tildeFiles.push(full);
136
+ }
137
+ }
138
+ }
139
+ await scanForTildeFiles(cwd);
140
+
141
+ const orphans = tildeFiles.filter(f => !referencedFiles.has(f));
142
+ if (orphans.length > 0) {
143
+ const trashDir = join(cwd, 'trash');
144
+ await mkdir(trashDir, { recursive: true });
145
+ let trashed = 0;
146
+ for (const orphan of orphans) {
147
+ try {
148
+ await rename(orphan, join(trashDir, basename(orphan)));
149
+ trashed++;
150
+ } catch { /* non-critical */ }
151
+ }
152
+ if (trashed > 0) {
153
+ await ensureTrashIcon(trashDir);
154
+ totalRenamed += trashed;
155
+ log.dim(` Trashed ${trashed} orphaned legacy ~UID file(s)`);
156
+ }
157
+ }
158
+
159
+ if (totalRenamed > 0 || totalRefsUpdated > 0) {
160
+ log.dim(` Renamed ${totalRenamed} companion file(s), updated ${totalRefsUpdated} metadata @reference(s)`);
161
+ }
162
+ }
163
+
164
+ const SKIP = new Set(['.dbo', 'node_modules', 'trash', '.git', '.claude']);
165
+
166
+ async function findAllMetadataFiles(dir) {
167
+ const results = [];
168
+ try {
169
+ const entries = await readdir(dir, { withFileTypes: true });
170
+ for (const entry of entries) {
171
+ if (SKIP.has(entry.name)) continue;
172
+ const full = join(dir, entry.name);
173
+ if (entry.isDirectory()) {
174
+ results.push(...await findAllMetadataFiles(full));
175
+ } else if (isMetadataFile(entry.name)) {
176
+ results.push(full);
177
+ }
178
+ }
179
+ } catch { /* skip unreadable dirs */ }
180
+ return results;
181
+ }