@dboio/cli 0.15.1 → 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 +1 -1
- package/plugins/claude/dbo/.claude-plugin/plugin.json +1 -1
- package/src/commands/clone.js +29 -18
- package/src/commands/push.js +20 -18
- package/src/lib/delta.js +29 -6
- package/src/lib/tagging.js +15 -18
- package/src/lib/ticketing.js +18 -2
- package/src/lib/toe-stepping.js +7 -4
- package/src/migrations/009-fix-media-collision-metadata-names.js +161 -0
- package/src/migrations/010-delete-paren-media-orphans.js +61 -0
package/package.json
CHANGED
package/src/commands/clone.js
CHANGED
|
@@ -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 "
|
|
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
|
|
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}
|
|
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 "
|
|
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
|
-
|
|
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(
|
|
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:
|
|
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:
|
|
2529
|
-
const
|
|
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
|
-
|
|
3748
|
-
|
|
3749
|
-
|
|
3750
|
-
|
|
3751
|
-
|
|
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
|
-
}
|
|
3755
|
-
log.warn(` Could not
|
|
3765
|
+
} else {
|
|
3766
|
+
log.warn(` Could not find manifest.json companion file`);
|
|
3756
3767
|
}
|
|
3757
3768
|
}
|
|
3758
3769
|
return;
|
package/src/commands/push.js
CHANGED
|
@@ -22,16 +22,6 @@ import { checkToeStepping } from '../lib/toe-stepping.js';
|
|
|
22
22
|
import { runPendingMigrations } from '../lib/migrations.js';
|
|
23
23
|
// AUTO-ADD DISABLED: import { findUnaddedFiles, detectBinFile, submitAdd } from './add.js';
|
|
24
24
|
|
|
25
|
-
function _getMetaCompanionPaths(meta, metaPath) {
|
|
26
|
-
const dir = dirname(metaPath);
|
|
27
|
-
const paths = [];
|
|
28
|
-
for (const col of (meta._contentColumns || [])) {
|
|
29
|
-
const ref = meta[col];
|
|
30
|
-
if (ref && String(ref).startsWith('@')) paths.push(join(dir, String(ref).substring(1)));
|
|
31
|
-
}
|
|
32
|
-
return paths;
|
|
33
|
-
}
|
|
34
|
-
|
|
35
25
|
/**
|
|
36
26
|
* Resolve an @reference file path to an absolute filesystem path.
|
|
37
27
|
* "@filename.ext" → relative to the metadata file's directory (existing behaviour)
|
|
@@ -368,7 +358,8 @@ async function pushSingleFile(filePath, client, options, modifyKey = null, trans
|
|
|
368
358
|
const baseline = await loadAppJsonBaseline();
|
|
369
359
|
if (baseline) {
|
|
370
360
|
const appConfig = await loadAppConfig();
|
|
371
|
-
const
|
|
361
|
+
const cfg = await loadConfig();
|
|
362
|
+
const result = await checkToeStepping([{ meta, metaPath }], client, baseline, options, appConfig?.AppShortName, cfg.ServerTimezone);
|
|
372
363
|
if (result === false || result instanceof Set) return;
|
|
373
364
|
}
|
|
374
365
|
}
|
|
@@ -547,6 +538,7 @@ async function pushDirectory(dirPath, client, options, modifyKey = null, transac
|
|
|
547
538
|
// Collect metadata with detected changes
|
|
548
539
|
const toPush = [];
|
|
549
540
|
const outputCompoundFiles = [];
|
|
541
|
+
const seenUIDs = new Map(); // UID → metaPath (deduplicate same-UID metadata in different dirs)
|
|
550
542
|
let skipped = 0;
|
|
551
543
|
|
|
552
544
|
for (const metaPath of metaFiles) {
|
|
@@ -565,6 +557,16 @@ async function pushDirectory(dirPath, client, options, modifyKey = null, transac
|
|
|
565
557
|
continue;
|
|
566
558
|
}
|
|
567
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
|
+
|
|
568
570
|
// AUTO-ADD DISABLED: justAddedUIDs skip removed (server-first workflow)
|
|
569
571
|
|
|
570
572
|
// Output hierarchy entities: only push compound output files (root with inline children).
|
|
@@ -641,7 +643,8 @@ async function pushDirectory(dirPath, client, options, modifyKey = null, transac
|
|
|
641
643
|
const toCheck = toPush.filter(item => !item.isNew);
|
|
642
644
|
if (toCheck.length > 0) {
|
|
643
645
|
const appConfig = await loadAppConfig();
|
|
644
|
-
const
|
|
646
|
+
const cfg = await loadConfig();
|
|
647
|
+
const result = await checkToeStepping(toCheck, client, baseline, options, appConfig?.AppShortName, cfg.ServerTimezone);
|
|
645
648
|
if (result === false) return; // user cancelled entirely
|
|
646
649
|
if (result instanceof Set) {
|
|
647
650
|
// Filter out skipped UIDs
|
|
@@ -850,11 +853,9 @@ async function pushDirectory(dirPath, client, options, modifyKey = null, transac
|
|
|
850
853
|
await updateBaselineAfterPush(baseline, successfulPushes);
|
|
851
854
|
}
|
|
852
855
|
|
|
853
|
-
// Re-tag successfully pushed files as Synced (best-effort)
|
|
854
|
-
for (const {
|
|
855
|
-
|
|
856
|
-
setFileTag(filePath, 'synced').catch(() => {});
|
|
857
|
-
}
|
|
856
|
+
// Re-tag successfully pushed metadata files as Synced (best-effort)
|
|
857
|
+
for (const { metaPath } of successfulPushes) {
|
|
858
|
+
setFileTag(metaPath, 'synced').catch(() => {});
|
|
858
859
|
}
|
|
859
860
|
|
|
860
861
|
log.info(`Push complete: ${succeeded} succeeded, ${failed} failed, ${skipped} skipped`);
|
|
@@ -946,7 +947,8 @@ async function pushByUIDs(uids, client, options, modifyKey = null, transactionKe
|
|
|
946
947
|
}
|
|
947
948
|
if (toCheck.length > 0) {
|
|
948
949
|
const appConfig = await loadAppConfig();
|
|
949
|
-
const
|
|
950
|
+
const cfg3 = await loadConfig();
|
|
951
|
+
const result = await checkToeStepping(toCheck, client, baseline, options, appConfig?.AppShortName, cfg3.ServerTimezone);
|
|
950
952
|
if (result === false) return; // user cancelled entirely
|
|
951
953
|
if (result instanceof Set) {
|
|
952
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
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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
|
-
|
|
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 ────────────────────────────────────────
|
package/src/lib/tagging.js
CHANGED
|
@@ -80,12 +80,15 @@ export async function tagProjectFiles(options = {}) {
|
|
|
80
80
|
const counts = { synced: 0, modified: 0, untracked: 0, conflict: 0, trashed: 0 };
|
|
81
81
|
|
|
82
82
|
if (clearAll) {
|
|
83
|
-
|
|
84
|
-
|
|
83
|
+
// Clear tags from ALL files in the project: metadata, companions, and any
|
|
84
|
+
// legacy-tagged content files (from old untracked detection).
|
|
85
|
+
const contentFiles = await _collectContentFiles(dir, ig);
|
|
86
|
+
const allFiles = [...metaPaths, ...contentFiles];
|
|
87
|
+
await _bulkApplyTags(allFiles.map(fp => ({ filePath: fp, clear: true })));
|
|
85
88
|
return null;
|
|
86
89
|
}
|
|
87
|
-
|
|
88
|
-
//
|
|
90
|
+
// Tag metadata files — the source of truth for sync status.
|
|
91
|
+
// Companion files are dependent artifacts and should not carry their own tags.
|
|
89
92
|
const toTag = [];
|
|
90
93
|
for (const metaPath of metaPaths) {
|
|
91
94
|
const inTrash = metaPath.replace(/\\/g, '/').includes('/trash/');
|
|
@@ -93,20 +96,9 @@ export async function tagProjectFiles(options = {}) {
|
|
|
93
96
|
? 'trashed'
|
|
94
97
|
: (await hasLocalModifications(metaPath, config).catch(() => false)) ? 'modified'
|
|
95
98
|
: 'synced';
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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)}`);
|
|
99
|
+
toTag.push({ filePath: metaPath, status });
|
|
100
|
+
counts[status]++;
|
|
101
|
+
if (verbose) console.log(` ${status.padEnd(10)} ${relative(dir, metaPath)}`);
|
|
110
102
|
}
|
|
111
103
|
|
|
112
104
|
await _bulkApplyTags(toTag);
|
|
@@ -132,6 +124,11 @@ async function _getCompanionPaths(metaPath) {
|
|
|
132
124
|
try { await stat(candidate); paths.push(candidate); } catch { /* missing */ }
|
|
133
125
|
}
|
|
134
126
|
}
|
|
127
|
+
// Check _mediaFile (single media companion reference)
|
|
128
|
+
if (meta._mediaFile && String(meta._mediaFile).startsWith("@")) {
|
|
129
|
+
const candidate = join(dir, String(meta._mediaFile).substring(1));
|
|
130
|
+
try { await stat(candidate); paths.push(candidate); } catch { /* missing */ }
|
|
131
|
+
}
|
|
135
132
|
for (const col of (meta._mediaColumns || [])) {
|
|
136
133
|
const ref = meta[col];
|
|
137
134
|
if (ref && String(ref).startsWith('@')) {
|
package/src/lib/ticketing.js
CHANGED
|
@@ -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 };
|
package/src/lib/toe-stepping.js
CHANGED
|
@@ -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
|
|
354
|
-
|
|
355
|
-
const
|
|
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
|
+
}
|