@dboio/cli 0.11.2 → 0.11.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/src/commands/clone.js +13 -5
- package/src/commands/push.js +36 -29
- package/src/lib/client.js +4 -2
- package/src/lib/toe-stepping.js +125 -27
- package/src/migrations/005-rename-output-metadata.js +91 -0
package/package.json
CHANGED
package/src/commands/clone.js
CHANGED
|
@@ -2956,8 +2956,8 @@ async function trashOrphanedChildFiles(outputDir, rootStem) {
|
|
|
2956
2956
|
log.dim(` Trashed orphaned child file: ${f}`);
|
|
2957
2957
|
} catch { /* non-critical */ }
|
|
2958
2958
|
}
|
|
2959
|
-
// Also trash the legacy root file itself (_output~Name~UID.json) if new format exists
|
|
2960
|
-
if (matchesLegacy === false && f === `${legacyStem}.json`) {
|
|
2959
|
+
// Also trash the legacy root file itself (_output~Name~UID.json or .metadata.json) if new format exists
|
|
2960
|
+
if (matchesLegacy === false && (f === `${legacyStem}.json` || f === `${legacyStem}.metadata.json`)) {
|
|
2961
2961
|
if (!trashCreated) {
|
|
2962
2962
|
await mkdir(trashDir, { recursive: true });
|
|
2963
2963
|
trashCreated = true;
|
|
@@ -2984,9 +2984,10 @@ async function trashOrphanedChildFiles(outputDir, rootStem) {
|
|
|
2984
2984
|
* @returns {Object} - { segments: [{entity, name, uid}], rootOutputUid, entityType, uid }
|
|
2985
2985
|
*/
|
|
2986
2986
|
export function parseOutputHierarchyFile(filename) {
|
|
2987
|
-
// Strip .json extension
|
|
2987
|
+
// Strip .metadata.json or legacy .json extension
|
|
2988
2988
|
let base = filename;
|
|
2989
|
-
if (base.endsWith('.json')) base = base.substring(0, base.length -
|
|
2989
|
+
if (base.endsWith('.metadata.json')) base = base.substring(0, base.length - 14);
|
|
2990
|
+
else if (base.endsWith('.json')) base = base.substring(0, base.length - 5);
|
|
2990
2991
|
|
|
2991
2992
|
// Split into segments by finding entity type boundaries
|
|
2992
2993
|
// Entity types are: output~ (or legacy _output~), column~, join~, filter~
|
|
@@ -3109,7 +3110,14 @@ async function processOutputHierarchy(appJson, structure, options, serverTz) {
|
|
|
3109
3110
|
|
|
3110
3111
|
// Build root output filename
|
|
3111
3112
|
const rootBasename = buildOutputFilename('output', output, filenameCols.output);
|
|
3112
|
-
|
|
3113
|
+
let rootMetaPath = join(binDir, `${rootBasename}.metadata.json`);
|
|
3114
|
+
|
|
3115
|
+
// Legacy fallback: if old .json exists but new .metadata.json doesn't, rename in-place
|
|
3116
|
+
const legacyJsonPath = join(binDir, `${rootBasename}.json`);
|
|
3117
|
+
if (!await fileExists(rootMetaPath) && await fileExists(legacyJsonPath)) {
|
|
3118
|
+
await rename(legacyJsonPath, rootMetaPath);
|
|
3119
|
+
log.dim(` Renamed ${rootBasename}.json → ${rootBasename}.metadata.json`);
|
|
3120
|
+
}
|
|
3113
3121
|
|
|
3114
3122
|
// Detect old-format files that need migration to inline children format.
|
|
3115
3123
|
// Old format: children.column/join/filter contain @reference strings to separate files.
|
package/src/commands/push.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Command } from 'commander';
|
|
2
|
-
import { readFile,
|
|
2
|
+
import { readFile, stat, writeFile, rename as fsRename, mkdir, access } from 'fs/promises';
|
|
3
3
|
import { join, dirname, basename, extname, relative } from 'path';
|
|
4
4
|
import { DboClient } from '../lib/client.js';
|
|
5
5
|
import { buildInputBody, checkSubmitErrors, getSessionUserOverride } from '../lib/input-parser.js';
|
|
@@ -306,18 +306,17 @@ async function pushSingleFile(filePath, client, options, modifyKey = null, trans
|
|
|
306
306
|
const baseline = await loadAppJsonBaseline();
|
|
307
307
|
if (baseline) {
|
|
308
308
|
const appConfig = await loadAppConfig();
|
|
309
|
-
const
|
|
310
|
-
if (
|
|
309
|
+
const result = await checkToeStepping([{ meta, metaPath }], client, baseline, options, appConfig?.AppShortName);
|
|
310
|
+
if (result === false || result instanceof Set) return;
|
|
311
311
|
}
|
|
312
312
|
}
|
|
313
313
|
|
|
314
314
|
await pushFromMetadata(meta, metaPath, client, options, null, modifyKey, transactionKey);
|
|
315
315
|
}
|
|
316
|
-
|
|
317
316
|
/**
|
|
318
|
-
* Ensure manifest.json at project root has companion metadata in bins/app/.
|
|
319
|
-
*
|
|
320
|
-
*
|
|
317
|
+
* Ensure manifest.json at project root has companion metadata in lib/bins/app/.
|
|
318
|
+
* Only creates metadata if manifest.json exists at root AND no existing metadata
|
|
319
|
+
* anywhere in the project already references @/manifest.json (prevents duplicates).
|
|
321
320
|
*/
|
|
322
321
|
async function ensureManifestMetadata() {
|
|
323
322
|
// Check if manifest.json exists at project root
|
|
@@ -327,30 +326,24 @@ async function ensureManifestMetadata() {
|
|
|
327
326
|
return; // No manifest.json — nothing to do
|
|
328
327
|
}
|
|
329
328
|
|
|
330
|
-
//
|
|
331
|
-
//
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
const
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
const raw = await readFile(join(binsAppDir, entry), 'utf8');
|
|
341
|
-
const parsed = JSON.parse(raw);
|
|
342
|
-
if (parsed.Content === '@/manifest.json') return; // Already tracked
|
|
343
|
-
} catch { /* skip unreadable files */ }
|
|
344
|
-
}
|
|
345
|
-
} catch {
|
|
346
|
-
// bins/app/ doesn't exist — will create it
|
|
329
|
+
// Scan the entire project for any metadata file that already references manifest.json.
|
|
330
|
+
// This prevents creating duplicates when the metadata lives in an unexpected location.
|
|
331
|
+
const ig = await loadIgnore();
|
|
332
|
+
const allMeta = await findMetadataFiles(process.cwd(), ig);
|
|
333
|
+
for (const metaPath of allMeta) {
|
|
334
|
+
try {
|
|
335
|
+
const raw = await readFile(metaPath, 'utf8');
|
|
336
|
+
const parsed = JSON.parse(raw);
|
|
337
|
+
if (parsed.Content === '@/manifest.json') return; // Already tracked
|
|
338
|
+
} catch { /* skip unreadable */ }
|
|
347
339
|
}
|
|
348
340
|
|
|
349
|
-
//
|
|
341
|
+
// No existing metadata references manifest.json — create one
|
|
350
342
|
const appConfig = await loadAppConfig();
|
|
351
343
|
const structure = await loadStructureFile();
|
|
352
344
|
const appBin = findBinByPath('app', structure);
|
|
353
345
|
|
|
346
|
+
const binsAppDir = join(process.cwd(), BINS_DIR, 'app');
|
|
354
347
|
await mkdir(binsAppDir, { recursive: true });
|
|
355
348
|
|
|
356
349
|
const meta = {
|
|
@@ -481,8 +474,15 @@ async function pushDirectory(dirPath, client, options, modifyKey = null, transac
|
|
|
481
474
|
const toCheck = toPush.filter(item => !item.isNew);
|
|
482
475
|
if (toCheck.length > 0) {
|
|
483
476
|
const appConfig = await loadAppConfig();
|
|
484
|
-
const
|
|
485
|
-
if (
|
|
477
|
+
const result = await checkToeStepping(toCheck, client, baseline, options, appConfig?.AppShortName);
|
|
478
|
+
if (result === false) return; // user cancelled entirely
|
|
479
|
+
if (result instanceof Set) {
|
|
480
|
+
// Filter out skipped UIDs
|
|
481
|
+
for (let i = toPush.length - 1; i >= 0; i--) {
|
|
482
|
+
if (result.has(toPush[i].meta.UID)) toPush.splice(i, 1);
|
|
483
|
+
}
|
|
484
|
+
if (toPush.length === 0) return;
|
|
485
|
+
}
|
|
486
486
|
}
|
|
487
487
|
}
|
|
488
488
|
|
|
@@ -725,8 +725,15 @@ async function pushByUIDs(uids, client, options, modifyKey = null, transactionKe
|
|
|
725
725
|
}
|
|
726
726
|
if (toCheck.length > 0) {
|
|
727
727
|
const appConfig = await loadAppConfig();
|
|
728
|
-
const
|
|
729
|
-
if (
|
|
728
|
+
const result = await checkToeStepping(toCheck, client, baseline, options, appConfig?.AppShortName);
|
|
729
|
+
if (result === false) return; // user cancelled entirely
|
|
730
|
+
if (result instanceof Set) {
|
|
731
|
+
// Filter out skipped UIDs from matches
|
|
732
|
+
for (let i = matches.length - 1; i >= 0; i--) {
|
|
733
|
+
if (result.has(matches[i].meta?.UID)) matches.splice(i, 1);
|
|
734
|
+
}
|
|
735
|
+
if (matches.length === 0) return;
|
|
736
|
+
}
|
|
730
737
|
}
|
|
731
738
|
}
|
|
732
739
|
|
package/src/lib/client.js
CHANGED
|
@@ -124,11 +124,13 @@ export class DboClient {
|
|
|
124
124
|
}
|
|
125
125
|
|
|
126
126
|
/**
|
|
127
|
-
* Clear the server-side cache
|
|
128
|
-
*
|
|
127
|
+
* Clear the server-side cache so that subsequent GET requests return fresh data.
|
|
128
|
+
* Called before comparison fetches (toe-stepping, diff) and after POST submissions.
|
|
129
129
|
*/
|
|
130
130
|
async voidCache() {
|
|
131
131
|
try {
|
|
132
|
+
const baseUrl = await this.getBaseUrl();
|
|
133
|
+
if (this.verbose) log.verbose(`VoidCache: ${baseUrl}/?voidcache=true`);
|
|
132
134
|
await this.request('/?voidcache=true');
|
|
133
135
|
} catch { /* best-effort — don't block on failure */ }
|
|
134
136
|
}
|
package/src/lib/toe-stepping.js
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
|
-
import
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
import { dirname, basename, join } from 'path';
|
|
2
3
|
import { readFile } from 'fs/promises';
|
|
3
4
|
import { findBaselineEntry, shouldSkipColumn, normalizeValue, isReference, resolveReferencePath } from './delta.js';
|
|
4
5
|
import { resolveContentValue } from '../commands/clone.js';
|
|
6
|
+
import { computeLineDiff, formatDiff } from './diff.js';
|
|
5
7
|
import { log } from './logger.js';
|
|
6
8
|
|
|
7
9
|
/**
|
|
@@ -274,7 +276,8 @@ function findOldestBaselineDate(records, baseline) {
|
|
|
274
276
|
* @param {Object} baseline - Loaded baseline from .dbo/.app_baseline.json
|
|
275
277
|
* @param {Object} options - Commander options (options.yes used for auto-accept)
|
|
276
278
|
* @param {string} [appShortName] - App short name for bulk fetch (optional)
|
|
277
|
-
* @returns {Promise<boolean
|
|
279
|
+
* @returns {Promise<boolean|Set<string>>} - true = proceed with all,
|
|
280
|
+
* false = user cancelled entirely, Set<string> = UIDs to skip (proceed with rest)
|
|
278
281
|
*/
|
|
279
282
|
export async function checkToeStepping(records, client, baseline, options, appShortName) {
|
|
280
283
|
// Build list of records to check (skip new records without UID)
|
|
@@ -302,6 +305,8 @@ export async function checkToeStepping(records, client, baseline, options, appSh
|
|
|
302
305
|
|
|
303
306
|
// Fall back to per-record fetches if batch returned nothing or wasn't available
|
|
304
307
|
if (!serverRecords || serverRecords.size === 0) {
|
|
308
|
+
// Void cache if batch path was skipped (batch already calls voidCache internally)
|
|
309
|
+
if (!appShortName) await client.voidCache();
|
|
305
310
|
spinner.text = `Fetching ${requests.length} record(s) from server...`;
|
|
306
311
|
serverRecords = await fetchServerRecords(client, requests);
|
|
307
312
|
}
|
|
@@ -314,7 +319,11 @@ export async function checkToeStepping(records, client, baseline, options, appSh
|
|
|
314
319
|
|
|
315
320
|
spinner.succeed(`Fetched ${serverRecords.size} record(s) from server`);
|
|
316
321
|
|
|
317
|
-
|
|
322
|
+
// Detect conflicts and prompt per-record
|
|
323
|
+
const inquirer = (await import('inquirer')).default;
|
|
324
|
+
let skippedUIDs = new Set();
|
|
325
|
+
let bulkAction = null; // 'push_all' | 'skip_all'
|
|
326
|
+
let hasConflicts = false;
|
|
318
327
|
|
|
319
328
|
for (const { meta, metaPath } of records) {
|
|
320
329
|
const uid = meta.UID;
|
|
@@ -339,6 +348,8 @@ export async function checkToeStepping(records, client, baseline, options, appSh
|
|
|
339
348
|
if (isNaN(serverDate) || isNaN(baselineDate)) continue; // unparseable — skip
|
|
340
349
|
if (serverDate <= baselineDate) continue; // server is same or older — no conflict
|
|
341
350
|
|
|
351
|
+
hasConflicts = true;
|
|
352
|
+
|
|
342
353
|
// Conflict detected: server changed since our baseline
|
|
343
354
|
const metaDir = dirname(metaPath);
|
|
344
355
|
const label = basename(metaPath, '.metadata.json');
|
|
@@ -346,36 +357,123 @@ export async function checkToeStepping(records, client, baseline, options, appSh
|
|
|
346
357
|
const serverUser = serverEntry._LastUpdatedUserID || 'unknown';
|
|
347
358
|
|
|
348
359
|
displayConflict(label, serverUser, serverTs, diffColumns);
|
|
349
|
-
conflicts.push({ label, serverUser });
|
|
350
|
-
}
|
|
351
360
|
|
|
352
|
-
|
|
361
|
+
// Auto-accept or auto-skip based on bulk action
|
|
362
|
+
if (options.yes || bulkAction === 'push_all') {
|
|
363
|
+
continue; // push this record
|
|
364
|
+
}
|
|
365
|
+
if (bulkAction === 'skip_all') {
|
|
366
|
+
skippedUIDs.add(uid);
|
|
367
|
+
continue;
|
|
368
|
+
}
|
|
353
369
|
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
370
|
+
// Per-record prompt with compare option
|
|
371
|
+
let action;
|
|
372
|
+
while (true) {
|
|
373
|
+
({ action } = await inquirer.prompt([{
|
|
374
|
+
type: 'list',
|
|
375
|
+
name: 'action',
|
|
376
|
+
message: `"${label}" has server changes. How to proceed?`,
|
|
377
|
+
choices: [
|
|
378
|
+
{ name: 'Push anyway (overwrite server changes)', value: 'push' },
|
|
379
|
+
{ name: 'Compare differences', value: 'compare' },
|
|
380
|
+
{ name: 'Skip this record', value: 'skip' },
|
|
381
|
+
{ name: 'Push all remaining (overwrite all)', value: 'push_all' },
|
|
382
|
+
{ name: 'Skip all remaining', value: 'skip_all' },
|
|
383
|
+
{ name: 'Cancel entire push', value: 'cancel' },
|
|
384
|
+
],
|
|
385
|
+
}]));
|
|
386
|
+
|
|
387
|
+
if (action === 'compare') {
|
|
388
|
+
await showPushDiff(serverEntry, meta, metaPath);
|
|
389
|
+
continue; // re-prompt after showing diff
|
|
390
|
+
}
|
|
391
|
+
break;
|
|
392
|
+
}
|
|
357
393
|
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
394
|
+
if (action === 'cancel') {
|
|
395
|
+
log.info('Push cancelled. Run "dbo pull" to fetch server changes first.');
|
|
396
|
+
return false;
|
|
397
|
+
}
|
|
398
|
+
if (action === 'skip' || action === 'skip_all') {
|
|
399
|
+
skippedUIDs.add(uid);
|
|
400
|
+
if (action === 'skip_all') bulkAction = 'skip_all';
|
|
401
|
+
}
|
|
402
|
+
if (action === 'push_all') {
|
|
403
|
+
bulkAction = 'push_all';
|
|
404
|
+
}
|
|
361
405
|
}
|
|
362
406
|
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
{ name: 'Overwrite server changes (push anyway)', value: 'overwrite' },
|
|
370
|
-
{ name: 'Cancel — pull server changes first', value: 'cancel' },
|
|
371
|
-
],
|
|
372
|
-
}]);
|
|
373
|
-
|
|
374
|
-
if (action === 'cancel') {
|
|
375
|
-
log.info('Push cancelled. Run "dbo pull" to fetch server changes first.');
|
|
376
|
-
return false;
|
|
407
|
+
if (!hasConflicts) return true;
|
|
408
|
+
|
|
409
|
+
// Return skipped UIDs so the caller can filter them out
|
|
410
|
+
if (skippedUIDs.size > 0) {
|
|
411
|
+
log.dim(` Skipping ${skippedUIDs.size} conflicting record(s)`);
|
|
412
|
+
return skippedUIDs;
|
|
377
413
|
}
|
|
378
414
|
|
|
379
|
-
log.dim(' Proceeding — local changes will overwrite server state.');
|
|
380
415
|
return true;
|
|
381
416
|
}
|
|
417
|
+
|
|
418
|
+
/**
|
|
419
|
+
* Show a read-only diff between the server state and local metadata/content
|
|
420
|
+
* for a single record during push toe-stepping.
|
|
421
|
+
*
|
|
422
|
+
* Green (+) = local (what you're about to push)
|
|
423
|
+
* Red (-) = server (what would be overwritten)
|
|
424
|
+
*/
|
|
425
|
+
async function showPushDiff(serverEntry, localMeta, metaPath) {
|
|
426
|
+
const metaDir = dirname(metaPath);
|
|
427
|
+
const contentCols = localMeta._contentColumns || [];
|
|
428
|
+
|
|
429
|
+
// Compare content file columns
|
|
430
|
+
for (const col of contentCols) {
|
|
431
|
+
const localRef = localMeta[col];
|
|
432
|
+
if (!localRef || !String(localRef).startsWith('@')) continue;
|
|
433
|
+
|
|
434
|
+
const localFilePath = join(metaDir, String(localRef).substring(1));
|
|
435
|
+
let localContent = '';
|
|
436
|
+
try {
|
|
437
|
+
localContent = await readFile(localFilePath, 'utf8');
|
|
438
|
+
} catch {
|
|
439
|
+
localContent = '';
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
const serverValue = resolveContentValue(serverEntry[col]);
|
|
443
|
+
if (serverValue === null) continue;
|
|
444
|
+
|
|
445
|
+
if (localContent !== serverValue) {
|
|
446
|
+
// Green = local (what we're pushing), Red = server (what gets overwritten)
|
|
447
|
+
const diff = computeLineDiff(serverValue, localContent);
|
|
448
|
+
log.plain('');
|
|
449
|
+
log.label('Field', `${col} (content file)`);
|
|
450
|
+
const formatted = formatDiff(diff, {
|
|
451
|
+
localLabel: `server: ${col}`,
|
|
452
|
+
serverLabel: `local: ${col}`,
|
|
453
|
+
});
|
|
454
|
+
for (const line of formatted) log.plain(line);
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
// Compare metadata fields
|
|
459
|
+
const skipFields = new Set(['_entity', '_contentColumns', '_mediaFile', 'children', '_pathConfirmed']);
|
|
460
|
+
for (const col of Object.keys(serverEntry)) {
|
|
461
|
+
if (skipFields.has(col)) continue;
|
|
462
|
+
if (contentCols.includes(col)) continue;
|
|
463
|
+
if (shouldSkipColumn(col)) continue;
|
|
464
|
+
|
|
465
|
+
const serverVal = serverEntry[col];
|
|
466
|
+
const localVal = localMeta[col];
|
|
467
|
+
const serverStr = serverVal != null ? String(resolveContentValue(serverVal) ?? serverVal) : '';
|
|
468
|
+
const localStr = localVal != null ? String(localVal) : '';
|
|
469
|
+
|
|
470
|
+
if (serverStr !== localStr) {
|
|
471
|
+
log.plain('');
|
|
472
|
+
log.label('Field', col);
|
|
473
|
+
if (serverStr) log.plain(chalk.red(` - server: ${serverStr.substring(0, 200)}`));
|
|
474
|
+
if (localStr) log.plain(chalk.green(` + local: ${localStr.substring(0, 200)}`));
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
log.plain('');
|
|
479
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { readdir, rename, access } from 'fs/promises';
|
|
2
|
+
import { join } from 'path';
|
|
3
|
+
import { log } from '../lib/logger.js';
|
|
4
|
+
|
|
5
|
+
export const description = 'Rename output .json files to .metadata.json for consistency';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Migration 005 — Rename output root metadata files from .json to .metadata.json.
|
|
9
|
+
*
|
|
10
|
+
* Old: Sales~abc123.json
|
|
11
|
+
* New: Sales~abc123.metadata.json
|
|
12
|
+
*
|
|
13
|
+
* Only renames root output files (contain ~ and end with .json).
|
|
14
|
+
* Skips .CustomSQL.sql companions, old-format child files (.column~, .join~, .filter~),
|
|
15
|
+
* and files that already use .metadata.json.
|
|
16
|
+
*/
|
|
17
|
+
export default async function run(_options) {
|
|
18
|
+
const cwd = process.cwd();
|
|
19
|
+
let totalRenamed = 0;
|
|
20
|
+
|
|
21
|
+
const dirs = await findDirsWithOutputJsonFiles(cwd);
|
|
22
|
+
|
|
23
|
+
for (const dir of dirs) {
|
|
24
|
+
const entries = await readdir(dir);
|
|
25
|
+
|
|
26
|
+
for (const name of entries) {
|
|
27
|
+
// Must be a .json file with ~ (UID separator) that is NOT already .metadata.json
|
|
28
|
+
if (!name.endsWith('.json')) continue;
|
|
29
|
+
if (name.endsWith('.metadata.json')) continue;
|
|
30
|
+
if (!name.includes('~')) continue;
|
|
31
|
+
|
|
32
|
+
// Skip CustomSQL companions
|
|
33
|
+
if (name.includes('.CustomSQL.')) continue;
|
|
34
|
+
|
|
35
|
+
// Skip old-format child files (.column~, .join~, .filter~)
|
|
36
|
+
if (/\.(column|join|filter)~/.test(name)) continue;
|
|
37
|
+
|
|
38
|
+
const newName = name.replace(/\.json$/, '.metadata.json');
|
|
39
|
+
const oldPath = join(dir, name);
|
|
40
|
+
const newPath = join(dir, newName);
|
|
41
|
+
|
|
42
|
+
// Skip if destination already exists
|
|
43
|
+
try {
|
|
44
|
+
await access(newPath);
|
|
45
|
+
continue;
|
|
46
|
+
} catch { /* good — doesn't exist */ }
|
|
47
|
+
|
|
48
|
+
await rename(oldPath, newPath);
|
|
49
|
+
totalRenamed++;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (totalRenamed > 0) {
|
|
54
|
+
log.dim(` Renamed ${totalRenamed} output file${totalRenamed === 1 ? '' : 's'} (.json → .metadata.json)`);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Recursively find directories that contain output .json files (with ~ in name).
|
|
60
|
+
* Skips .dbo/, node_modules/, trash/, .git/.
|
|
61
|
+
*/
|
|
62
|
+
async function findDirsWithOutputJsonFiles(root) {
|
|
63
|
+
const SKIP = new Set(['.dbo', 'node_modules', 'trash', '.git', '.claude']);
|
|
64
|
+
const results = [];
|
|
65
|
+
|
|
66
|
+
async function walk(dir) {
|
|
67
|
+
let entries;
|
|
68
|
+
try { entries = await readdir(dir, { withFileTypes: true }); } catch { return; }
|
|
69
|
+
|
|
70
|
+
let hasOutputFiles = false;
|
|
71
|
+
for (const entry of entries) {
|
|
72
|
+
if (entry.isDirectory()) {
|
|
73
|
+
if (!SKIP.has(entry.name)) {
|
|
74
|
+
await walk(join(dir, entry.name));
|
|
75
|
+
}
|
|
76
|
+
} else if (
|
|
77
|
+
entry.name.endsWith('.json') &&
|
|
78
|
+
!entry.name.endsWith('.metadata.json') &&
|
|
79
|
+
entry.name.includes('~') &&
|
|
80
|
+
!entry.name.includes('.CustomSQL.') &&
|
|
81
|
+
!/\.(column|join|filter)~/.test(entry.name)
|
|
82
|
+
) {
|
|
83
|
+
hasOutputFiles = true;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
if (hasOutputFiles) results.push(dir);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
await walk(root);
|
|
90
|
+
return results;
|
|
91
|
+
}
|