@dboio/cli 0.15.2 → 0.15.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dboio/cli",
3
- "version": "0.15.2",
3
+ "version": "0.15.3",
4
4
  "description": "CLI for the DBO.io framework",
5
5
  "type": "module",
6
6
  "bin": {
@@ -349,7 +349,7 @@ export function resolveEntityDirPaths(entityName, record, dirName) {
349
349
  * Resolve companion filename collisions within a shared BinID directory.
350
350
  *
351
351
  * Mutates the `filename` field in each entry to apply collision suffixes:
352
- * - Content vs media same name → media gets "(media)" before extension
352
+ * - Content vs media same name → media gets "_media" before extension
353
353
  * - Same entity type duplicates → 2nd+ get "-1", "-2", ... suffix
354
354
  *
355
355
  * Companion files never contain ~UID. The metadata @reference stores the
@@ -373,12 +373,12 @@ export function resolveFilenameCollisions(entries) {
373
373
  const contentGroup = group.filter(e => e.entity === 'content');
374
374
  const mediaGroup = group.filter(e => e.entity === 'media');
375
375
 
376
- // Content wins: media gets (media) suffix
376
+ // Content wins: media gets _media suffix
377
377
  if (contentGroup.length > 0 && mediaGroup.length > 0) {
378
378
  for (const m of mediaGroup) {
379
379
  const ext = extname(m.filename);
380
380
  const base = basename(m.filename, ext);
381
- m.filename = `${base}(media)${ext}`;
381
+ m.filename = `${base}_media${ext}`;
382
382
  }
383
383
  }
384
384
 
@@ -588,7 +588,7 @@ async function buildFileRegistry(appJson, structure, placementPrefs) {
588
588
  }
589
589
 
590
590
  // Auto-resolve content vs media collisions and same-entity duplicates
591
- // (content wins, media gets "(media)" suffix; same-entity duplicates get "-N" suffix)
591
+ // (content wins, media gets "_media" suffix; same-entity duplicates get "-N" suffix)
592
592
  resolveFilenameCollisions(allEntries);
593
593
 
594
594
  // Build registry with resolved filenames
@@ -2432,8 +2432,13 @@ async function processMediaEntries(mediaRecords, structure, options, config, app
2432
2432
  for (const record of mediaRecords) {
2433
2433
  if (skipUIDs.has(record.UID)) continue;
2434
2434
 
2435
- const { metaPath: scanMetaPath } = resolveMediaPaths(record, structure);
2436
- const scanExists = await fileExists(scanMetaPath);
2435
+ const { metaPath: scanMetaPath, dir: scanDir } = resolveMediaPaths(record, structure);
2436
+ // Use collision-resolved filename for metadata path when available (e.g. "_media" suffix)
2437
+ const resolvedName = resolvedFilenames.get(record.UID);
2438
+ const effectiveMetaPath = resolvedName
2439
+ ? join(scanDir, buildMetaFilename(resolvedName, String(record.UID || record._id || 'untitled')))
2440
+ : scanMetaPath;
2441
+ const scanExists = await fileExists(effectiveMetaPath);
2437
2442
 
2438
2443
  if (!scanExists) {
2439
2444
  // New file — always needs download
@@ -2444,13 +2449,13 @@ async function processMediaEntries(mediaRecords, structure, options, config, app
2444
2449
  } else {
2445
2450
  // Existing file — check if server is newer
2446
2451
  const configWithTz = { ...config, ServerTimezone: serverTz };
2447
- const localSyncTime = await getLocalSyncTime(scanMetaPath);
2452
+ const localSyncTime = await getLocalSyncTime(effectiveMetaPath);
2448
2453
  const serverNewer = isServerNewer(localSyncTime, record._LastUpdated, configWithTz, 'media', record.UID);
2449
2454
  if (serverNewer) {
2450
2455
  needsDownload.push(record);
2451
2456
  } else {
2452
2457
  // Up to date — still need ref for app.json
2453
- upToDateRefs.push({ uid: record.UID, metaPath: scanMetaPath });
2458
+ upToDateRefs.push({ uid: record.UID, metaPath: effectiveMetaPath });
2454
2459
  }
2455
2460
  }
2456
2461
  }
@@ -2525,9 +2530,8 @@ async function processMediaEntries(mediaRecords, structure, options, config, app
2525
2530
  const uid = String(record.UID || record._id || 'untitled');
2526
2531
  const finalFilename = resolvedFilenames.get(record.UID) || sanitizeFilename(filename);
2527
2532
  const filePath = join(dir, finalFilename);
2528
- // Metadata: name.ext.metadata~uid.json
2529
- const naturalMediaBase = `${name}.${ext}`;
2530
- const metaPath = join(dir, buildMetaFilename(naturalMediaBase, uid));
2533
+ // Metadata: use collision-resolved filename as base (e.g. "env_media.js" → "env_media.js.metadata~uid.json")
2534
+ const metaPath = join(dir, buildMetaFilename(finalFilename, uid));
2531
2535
  // usedNames retained for tracking
2532
2536
  const fileKey = `${dir}/${name}.${ext}`;
2533
2537
  usedNames.set(fileKey, (usedNames.get(fileKey) || 0) + 1);
@@ -3744,15 +3748,22 @@ async function writeManifestJson(appJson, contentRefs) {
3744
3748
  const contentRef = meta.Content;
3745
3749
  if (contentRef && String(contentRef).startsWith('@')) {
3746
3750
  const refFile = String(contentRef).substring(1);
3747
- const contentPath = refFile.startsWith('/')
3748
- ? join(process.cwd(), refFile)
3749
- : join(dirname(ref.metaPath), refFile);
3750
- try {
3751
- const content = await readFile(contentPath, 'utf8');
3751
+ // Try root-relative first, then sibling of metadata, then project root fallback
3752
+ const candidates = refFile.startsWith('/')
3753
+ ? [join(process.cwd(), refFile)]
3754
+ : [join(dirname(ref.metaPath), refFile), join(process.cwd(), refFile)];
3755
+ let content;
3756
+ for (const candidate of candidates) {
3757
+ try {
3758
+ content = await readFile(candidate, 'utf8');
3759
+ break;
3760
+ } catch { /* try next */ }
3761
+ }
3762
+ if (content !== undefined) {
3752
3763
  await writeFile('manifest.json', content);
3753
3764
  log.dim(' manifest.json written to project root (from server content)');
3754
- } catch (err) {
3755
- log.warn(` Could not write manifest.json from server content: ${err.message}`);
3765
+ } else {
3766
+ log.warn(` Could not find manifest.json companion file`);
3756
3767
  }
3757
3768
  }
3758
3769
  return;
@@ -358,7 +358,8 @@ async function pushSingleFile(filePath, client, options, modifyKey = null, trans
358
358
  const baseline = await loadAppJsonBaseline();
359
359
  if (baseline) {
360
360
  const appConfig = await loadAppConfig();
361
- const result = await checkToeStepping([{ meta, metaPath }], client, baseline, options, appConfig?.AppShortName);
361
+ const cfg = await loadConfig();
362
+ const result = await checkToeStepping([{ meta, metaPath }], client, baseline, options, appConfig?.AppShortName, cfg.ServerTimezone);
362
363
  if (result === false || result instanceof Set) return;
363
364
  }
364
365
  }
@@ -537,6 +538,7 @@ async function pushDirectory(dirPath, client, options, modifyKey = null, transac
537
538
  // Collect metadata with detected changes
538
539
  const toPush = [];
539
540
  const outputCompoundFiles = [];
541
+ const seenUIDs = new Map(); // UID → metaPath (deduplicate same-UID metadata in different dirs)
540
542
  let skipped = 0;
541
543
 
542
544
  for (const metaPath of metaFiles) {
@@ -555,6 +557,16 @@ async function pushDirectory(dirPath, client, options, modifyKey = null, transac
555
557
  continue;
556
558
  }
557
559
 
560
+ // Deduplicate: skip if another metadata file for the same UID was already processed.
561
+ // This happens when the same server record has stale metadata in a previous directory
562
+ // (e.g. after a BinID change on the server). The first match wins.
563
+ if (meta.UID && seenUIDs.has(meta.UID)) {
564
+ log.dim(` Skipping duplicate UID ${meta.UID} at ${basename(metaPath)} (already seen at ${basename(seenUIDs.get(meta.UID))})`);
565
+ skipped++;
566
+ continue;
567
+ }
568
+ if (meta.UID) seenUIDs.set(meta.UID, metaPath);
569
+
558
570
  // AUTO-ADD DISABLED: justAddedUIDs skip removed (server-first workflow)
559
571
 
560
572
  // Output hierarchy entities: only push compound output files (root with inline children).
@@ -631,7 +643,8 @@ async function pushDirectory(dirPath, client, options, modifyKey = null, transac
631
643
  const toCheck = toPush.filter(item => !item.isNew);
632
644
  if (toCheck.length > 0) {
633
645
  const appConfig = await loadAppConfig();
634
- const result = await checkToeStepping(toCheck, client, baseline, options, appConfig?.AppShortName);
646
+ const cfg = await loadConfig();
647
+ const result = await checkToeStepping(toCheck, client, baseline, options, appConfig?.AppShortName, cfg.ServerTimezone);
635
648
  if (result === false) return; // user cancelled entirely
636
649
  if (result instanceof Set) {
637
650
  // Filter out skipped UIDs
@@ -934,7 +947,8 @@ async function pushByUIDs(uids, client, options, modifyKey = null, transactionKe
934
947
  }
935
948
  if (toCheck.length > 0) {
936
949
  const appConfig = await loadAppConfig();
937
- const result = await checkToeStepping(toCheck, client, baseline, options, appConfig?.AppShortName);
950
+ const cfg3 = await loadConfig();
951
+ const result = await checkToeStepping(toCheck, client, baseline, options, appConfig?.AppShortName, cfg3.ServerTimezone);
938
952
  if (result === false) return; // user cancelled entirely
939
953
  if (result instanceof Set) {
940
954
  // Filter out skipped UIDs from matches
package/src/lib/delta.js CHANGED
@@ -2,6 +2,7 @@ import { readFile, stat } from 'fs/promises';
2
2
  import { join, dirname } from 'path';
3
3
  import { log } from './logger.js';
4
4
  import { loadAppJsonBaseline, saveAppJsonBaseline } from './config.js';
5
+ import { parseServerDate } from './timestamps.js';
5
6
 
6
7
  /**
7
8
  * Load the baseline file from disk.
@@ -164,15 +165,28 @@ export async function detectChangedColumns(metaPath, baseline) {
164
165
  }
165
166
  }
166
167
 
167
- // Check _mediaFile for binary file changes (media entities)
168
+ // Check _mediaFile for binary file changes (media entities).
169
+ // Compare the media file's mtime against the baseline's _LastUpdated (the sync point)
170
+ // rather than the metadata file's mtime, because migrations and other operations can
171
+ // rewrite metadata without touching the companion, skewing the mtime relationship.
168
172
  if (metadata._mediaFile && isReference(metadata._mediaFile)) {
169
173
  const mediaPath = resolveReferencePath(metadata._mediaFile, metaDir);
170
174
  try {
171
175
  const mediaStat = await stat(mediaPath);
172
- const metaStat = await stat(metaPath);
173
- // Media file modified more recently than metadata = local change
174
- if (mediaStat.mtimeMs > metaStat.mtimeMs + 2000) {
175
- changedColumns.push('_mediaFile');
176
+ const baselineDate = baselineEntry?._LastUpdated
177
+ ? parseServerDate(baselineEntry._LastUpdated)
178
+ : null;
179
+ if (baselineDate) {
180
+ // Media file modified after baseline sync point = local change
181
+ if (mediaStat.mtimeMs > baselineDate.getTime() + 2000) {
182
+ changedColumns.push('_mediaFile');
183
+ }
184
+ } else {
185
+ // No baseline date — fall back to metadata mtime comparison
186
+ const metaStat = await stat(metaPath);
187
+ if (mediaStat.mtimeMs > metaStat.mtimeMs + 2000) {
188
+ changedColumns.push('_mediaFile');
189
+ }
176
190
  }
177
191
  } catch { /* missing file, skip */ }
178
192
  }
@@ -245,7 +259,16 @@ export function normalizeValue(value) {
245
259
  return JSON.stringify(value);
246
260
  }
247
261
 
248
- return String(value).trim();
262
+ const str = String(value).trim();
263
+
264
+ // Normalize ISO date strings: strip trailing Z so that
265
+ // "2026-03-11T03:12:35" and "2026-03-11T03:12:35Z" compare as equal.
266
+ // Both values come from the same server timezone — only the Z suffix differs.
267
+ if (/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}/.test(str)) {
268
+ return str.replace(/Z$/, '');
269
+ }
270
+
271
+ return str;
249
272
  }
250
273
 
251
274
  // ─── Compound Output Delta Detection ────────────────────────────────────────
@@ -13,7 +13,7 @@ function ticketingPath() {
13
13
  return join(dboDir(), TICKETING_FILE);
14
14
  }
15
15
 
16
- const DEFAULT_TICKETING = { ticket_id: null, ticketing_required: false, records: [] };
16
+ const DEFAULT_TICKETING = { ticket_id: null, ticketing_required: false, ticket_confirmed: false, records: [] };
17
17
 
18
18
  /**
19
19
  * Load ticketing.local.json. Returns default structure if missing or corrupted.
@@ -31,6 +31,7 @@ export async function loadTicketing() {
31
31
  return {
32
32
  ticket_id: data.ticket_id || null,
33
33
  ticketing_required: !!data.ticketing_required,
34
+ ticket_confirmed: !!data.ticket_confirmed,
34
35
  records: Array.isArray(data.records) ? data.records : [],
35
36
  };
36
37
  } catch (err) {
@@ -74,6 +75,7 @@ export async function getRecordTicket(uid) {
74
75
  export async function setGlobalTicket(ticketId) {
75
76
  const data = await loadTicketing();
76
77
  data.ticket_id = ticketId;
78
+ data.ticket_confirmed = false; // reset confirmation when ticket changes
77
79
  await saveTicketing(data);
78
80
  }
79
81
 
@@ -123,6 +125,7 @@ export async function isTicketingRequired() {
123
125
  export async function clearGlobalTicket() {
124
126
  const data = await loadTicketing();
125
127
  data.ticket_id = null;
128
+ data.ticket_confirmed = false;
126
129
  await saveTicketing(data);
127
130
  }
128
131
 
@@ -231,6 +234,12 @@ export async function checkStoredTicket(options, context = '') {
231
234
  return { useTicket: true, clearTicket: false, cancel: false, overrideTicket: ticket };
232
235
  }
233
236
 
237
+ // Stored ticket exists and confirmed — auto-use without prompting
238
+ if (data.ticket_confirmed) {
239
+ log.info(`Using confirmed ticket "${data.ticket_id}"`);
240
+ return { useTicket: true, clearTicket: false, cancel: false };
241
+ }
242
+
234
243
  // Stored ticket exists — prompt to use, change, or clear
235
244
  const { action } = await inquirer.prompt([{
236
245
  type: 'list',
@@ -238,6 +247,7 @@ export async function checkStoredTicket(options, context = '') {
238
247
  message: `Use stored Ticket ID "${data.ticket_id}" for this submission?${suffix}`,
239
248
  choices: [
240
249
  { name: `Yes, use "${data.ticket_id}"`, value: 'use' },
250
+ { name: `Yes, use "${data.ticket_id}" for all future submissions (don't ask again)`, value: 'use_confirmed' },
241
251
  { name: 'Use a different ticket for this submission only', value: 'alt_once' },
242
252
  { name: 'Use a different ticket for this and future submissions', value: 'alt_save' },
243
253
  { name: 'No, clear stored ticket', value: 'clear' },
@@ -245,6 +255,12 @@ export async function checkStoredTicket(options, context = '') {
245
255
  ],
246
256
  }]);
247
257
 
258
+ if (action === 'use_confirmed') {
259
+ await saveTicketing({ ...data, ticket_confirmed: true });
260
+ log.dim(` Ticket "${data.ticket_id}" confirmed — will auto-apply without prompting`);
261
+ return { useTicket: true, clearTicket: false, cancel: false };
262
+ }
263
+
248
264
  if (action === 'alt_once' || action === 'alt_save') {
249
265
  const { altTicket } = await inquirer.prompt([{
250
266
  type: 'input',
@@ -257,7 +273,7 @@ export async function checkStoredTicket(options, context = '') {
257
273
  return { useTicket: false, clearTicket: false, cancel: true };
258
274
  }
259
275
  if (action === 'alt_save') {
260
- await saveTicketing({ ...data, ticket_id: ticket });
276
+ await saveTicketing({ ...data, ticket_id: ticket, ticket_confirmed: false });
261
277
  log.dim(` Stored ticket updated to "${ticket}"`);
262
278
  }
263
279
  return { useTicket: true, clearTicket: false, cancel: false, overrideTicket: ticket };
@@ -5,6 +5,7 @@ import { findBaselineEntry, shouldSkipColumn, normalizeValue, isReference, resol
5
5
  import { resolveContentValue } from '../commands/clone.js';
6
6
  import { computeLineDiff, formatDiff } from './diff.js';
7
7
  import { parseMetaFilename } from './filenames.js';
8
+ import { parseServerDate } from './timestamps.js';
8
9
  import { log } from './logger.js';
9
10
 
10
11
  /**
@@ -285,10 +286,11 @@ function findOldestBaselineDate(records, baseline) {
285
286
  * @param {Object} baseline - Loaded baseline from .dbo/.app_baseline.json
286
287
  * @param {Object} options - Commander options (options.yes used for auto-accept)
287
288
  * @param {string} [appShortName] - App short name for bulk fetch (optional)
289
+ * @param {string} [serverTz] - Server timezone from config (e.g. "America/Chicago")
288
290
  * @returns {Promise<boolean|Set<string>>} - true = proceed with all,
289
291
  * false = user cancelled entirely, Set<string> = UIDs to skip (proceed with rest)
290
292
  */
291
- export async function checkToeStepping(records, client, baseline, options, appShortName) {
293
+ export async function checkToeStepping(records, client, baseline, options, appShortName, serverTz) {
292
294
  // Build list of records to check (skip new records without UID)
293
295
  const requests = [];
294
296
  for (const { meta } of records) {
@@ -350,9 +352,10 @@ export async function checkToeStepping(records, client, baseline, options, appSh
350
352
 
351
353
  if (!serverTs || !baselineTs) continue; // missing timestamps — skip safely
352
354
 
353
- // Parse both as dates (ISO 8601 strings or server-format timestamps)
354
- const serverDate = new Date(serverTs);
355
- const baselineDate = new Date(baselineTs);
355
+ // Parse both dates using server timezone from config — server dates
356
+ // may arrive without Z suffix and represent the server's local time
357
+ const serverDate = parseServerDate(serverTs, serverTz);
358
+ const baselineDate = parseServerDate(baselineTs, serverTz);
356
359
 
357
360
  if (isNaN(serverDate) || isNaN(baselineDate)) continue; // unparseable — skip
358
361
  if (serverDate <= baselineDate) continue; // server is same or older — no conflict
@@ -0,0 +1,161 @@
1
+ import { readdir, readFile, rename, unlink, access } from 'fs/promises';
2
+ import { join, basename, dirname, extname } from 'path';
3
+ import { log } from '../lib/logger.js';
4
+ import { parseMetaFilename, buildMetaFilename } from '../lib/filenames.js';
5
+
6
+ export const description = 'Fix media collision suffix: rename (media) → _media and fix mismatched metadata filenames';
7
+
8
+ /**
9
+ * Migration 009 — Fix media metadata/companion files with wrong collision suffix.
10
+ *
11
+ * Two issues addressed:
12
+ *
13
+ * 1. Metadata filename mismatch: media records with collision-resolved names had their
14
+ * metadata created as "env.js.metadata~uid.json" instead of matching the companion.
15
+ * Detection: _mediaFile @reference base differs from metadata filename base.
16
+ *
17
+ * 2. (media) → _media rename: the old "(media)" suffix causes zsh glob issues.
18
+ * Renames both companion files and metadata files, and updates @references.
19
+ */
20
+ export default async function run(_options) {
21
+ const cwd = process.cwd();
22
+ let totalFixed = 0;
23
+ let totalOrphansDeleted = 0;
24
+ let totalParenRenamed = 0;
25
+
26
+ const metaFiles = await findAllMetadataFiles(cwd);
27
+ if (metaFiles.length === 0) return;
28
+
29
+ for (let metaPath of metaFiles) {
30
+ try {
31
+ let content = JSON.parse(await readFile(metaPath, 'utf8'));
32
+ const filename = basename(metaPath);
33
+ const parsed = parseMetaFilename(filename);
34
+ if (!parsed) continue;
35
+
36
+ // ── Phase 1: Rename (media) → _media in companion files and @references ──
37
+
38
+ // Check media @reference for (media) pattern
39
+ if (content._entity === 'media' && content._mediaFile) {
40
+ const ref = String(content._mediaFile);
41
+ if (ref.startsWith('@') && ref.includes('(media)')) {
42
+ const oldCompanion = ref.substring(1);
43
+ const ext = extname(oldCompanion);
44
+ const base = basename(oldCompanion, ext);
45
+ const newCompanion = `${base.replace('(media)', '_media')}${ext}`;
46
+ const dir = dirname(metaPath);
47
+
48
+ // Rename companion file on disk (or delete old if new already exists)
49
+ const oldCompanionPath = join(dir, oldCompanion);
50
+ const newCompanionPath = join(dir, newCompanion);
51
+ try {
52
+ await access(oldCompanionPath);
53
+ let newExists = false;
54
+ try { await access(newCompanionPath); newExists = true; } catch { /* */ }
55
+ if (newExists) {
56
+ // New file already exists (from re-clone) — delete the old orphan
57
+ await unlink(oldCompanionPath);
58
+ log.dim(` Deleted old companion: ${oldCompanion}`);
59
+ totalOrphansDeleted++;
60
+ } else {
61
+ await rename(oldCompanionPath, newCompanionPath);
62
+ log.dim(` ${oldCompanion} → ${newCompanion}`);
63
+ }
64
+ } catch { /* companion doesn't exist or already renamed */ }
65
+
66
+ // Update @reference in metadata
67
+ content._mediaFile = `@${newCompanion}`;
68
+ await import('fs/promises').then(fs => fs.writeFile(metaPath, JSON.stringify(content, null, 2) + '\n'));
69
+ totalParenRenamed++;
70
+ }
71
+ }
72
+
73
+ // Check content @references for (media) — shouldn't happen but be safe
74
+ if (content._contentColumns) {
75
+ for (const col of content._contentColumns) {
76
+ const ref = content[col];
77
+ if (ref && String(ref).startsWith('@') && String(ref).includes('(media)')) {
78
+ const oldName = String(ref).substring(1);
79
+ const ext = extname(oldName);
80
+ const base = basename(oldName, ext);
81
+ content[col] = `@${base.replace('(media)', '_media')}${ext}`;
82
+ await import('fs/promises').then(fs => fs.writeFile(metaPath, JSON.stringify(content, null, 2) + '\n'));
83
+ }
84
+ }
85
+ }
86
+
87
+ // Rename metadata file itself if it contains (media)
88
+ if (parsed.naturalBase.includes('(media)')) {
89
+ const newBase = parsed.naturalBase.replace('(media)', '_media');
90
+ const newMetaFilename = buildMetaFilename(newBase, parsed.uid);
91
+ const newMetaPath = join(dirname(metaPath), newMetaFilename);
92
+ try { await access(newMetaPath); } catch {
93
+ await rename(metaPath, newMetaPath);
94
+ log.dim(` ${filename} → ${newMetaFilename}`);
95
+ metaPath = newMetaPath; // update for phase 2
96
+ totalParenRenamed++;
97
+ }
98
+ // Re-read parsed after rename
99
+ content = JSON.parse(await readFile(metaPath, 'utf8'));
100
+ }
101
+
102
+ // ── Phase 2: Fix metadata filename not matching @reference ──
103
+
104
+ if (content._entity !== 'media') continue;
105
+ const mediaRef = content._mediaFile;
106
+ if (!mediaRef || !String(mediaRef).startsWith('@')) continue;
107
+
108
+ const refFilename = String(mediaRef).substring(1);
109
+ const currentParsed = parseMetaFilename(basename(metaPath));
110
+ if (!currentParsed) continue;
111
+
112
+ // Already correct
113
+ if (currentParsed.naturalBase === refFilename) continue;
114
+
115
+ const correctFilename = buildMetaFilename(refFilename, currentParsed.uid);
116
+ const correctPath = join(dirname(metaPath), correctFilename);
117
+
118
+ // If correct metadata already exists, this one is an orphan
119
+ try {
120
+ await access(correctPath);
121
+ await unlink(metaPath);
122
+ log.dim(` Deleted orphan: ${basename(metaPath)}`);
123
+ totalOrphansDeleted++;
124
+ continue;
125
+ } catch { /* correct file doesn't exist — rename */ }
126
+
127
+ await rename(metaPath, correctPath);
128
+ log.dim(` ${basename(metaPath)} → ${correctFilename}`);
129
+ totalFixed++;
130
+ } catch { /* skip unreadable files */ }
131
+ }
132
+
133
+ if (totalParenRenamed > 0) {
134
+ log.dim(` Renamed ${totalParenRenamed} file(s) from (media) to _media`);
135
+ }
136
+ if (totalFixed > 0) {
137
+ log.dim(` Renamed ${totalFixed} media metadata file(s) to match companion names`);
138
+ }
139
+ if (totalOrphansDeleted > 0) {
140
+ log.dim(` Deleted ${totalOrphansDeleted} orphaned media metadata file(s)`);
141
+ }
142
+ }
143
+
144
+ const SKIP = new Set(['.dbo', 'node_modules', 'trash', '.git', '.claude']);
145
+
146
+ async function findAllMetadataFiles(dir) {
147
+ const results = [];
148
+ try {
149
+ const entries = await readdir(dir, { withFileTypes: true });
150
+ for (const entry of entries) {
151
+ if (SKIP.has(entry.name)) continue;
152
+ const full = join(dir, entry.name);
153
+ if (entry.isDirectory()) {
154
+ results.push(...await findAllMetadataFiles(full));
155
+ } else if (entry.name.includes('.metadata~') && entry.name.endsWith('.json')) {
156
+ results.push(full);
157
+ }
158
+ }
159
+ } catch { /* skip unreadable dirs */ }
160
+ return results;
161
+ }
@@ -0,0 +1,61 @@
1
+ import { readdir, unlink, access } from 'fs/promises';
2
+ import { join, basename, extname } from 'path';
3
+ import { log } from '../lib/logger.js';
4
+
5
+ export const description = 'Delete orphaned (media) companion and metadata files replaced by _media';
6
+
7
+ /**
8
+ * Migration 010 — Clean up leftover (media) files.
9
+ *
10
+ * Migration 009 renamed (media) → _media but did not delete the old files
11
+ * when the new _media versions already existed. This migration finds any
12
+ * remaining files with "(media)" in their name and deletes them if the
13
+ * corresponding _media version exists.
14
+ */
15
+ export default async function run(_options) {
16
+ const cwd = process.cwd();
17
+ let totalDeleted = 0;
18
+
19
+ const parenFiles = await findParenMediaFiles(cwd);
20
+ if (parenFiles.length === 0) return;
21
+
22
+ for (const filePath of parenFiles) {
23
+ const filename = basename(filePath);
24
+ // Build the _media equivalent
25
+ const newFilename = filename.replace(/\(media\)/g, '_media');
26
+ const newPath = join(filePath, '..', newFilename);
27
+
28
+ // Only delete if the _media replacement exists
29
+ try {
30
+ await access(newPath);
31
+ await unlink(filePath);
32
+ log.dim(` Deleted: ${filename}`);
33
+ totalDeleted++;
34
+ } catch {
35
+ // No _media replacement — leave alone (might be a legitimate filename)
36
+ }
37
+ }
38
+
39
+ if (totalDeleted > 0) {
40
+ log.dim(` Removed ${totalDeleted} orphaned (media) file(s)`);
41
+ }
42
+ }
43
+
44
+ const SKIP = new Set(['.dbo', 'node_modules', 'trash', '.git', '.claude']);
45
+
46
+ async function findParenMediaFiles(dir) {
47
+ const results = [];
48
+ try {
49
+ const entries = await readdir(dir, { withFileTypes: true });
50
+ for (const entry of entries) {
51
+ if (SKIP.has(entry.name)) continue;
52
+ const full = join(dir, entry.name);
53
+ if (entry.isDirectory()) {
54
+ results.push(...await findParenMediaFiles(full));
55
+ } else if (entry.name.includes('(media)')) {
56
+ results.push(full);
57
+ }
58
+ }
59
+ } catch { /* skip unreadable dirs */ }
60
+ return results;
61
+ }