Add dev-prod harmonize tool for bidirectional catalog and image sync.
Schema migrates dev to prod only; catalog rows merge by updated_at and images by mtime with union merge and conflict reporting. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Cursor
parent
81ebad2120
commit
f247b418d8
@@ -0,0 +1,196 @@
|
||||
/**
|
||||
* Bidirectional image harmonize (dev <-> prod) by file mtime.
|
||||
*
|
||||
* Usage:
|
||||
* npm run harmonize:images
|
||||
* npm run harmonize:images -- --dry-run
|
||||
*/
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { rootDir, confirmProdAction } = require('./db-env');
|
||||
const { loadHarmonizeConfig, prodImagesPath } = require('./lib/harmonize-config');
|
||||
const { printCliResult } = require('./lib/cli-result');
|
||||
|
||||
const IMAGE_SUBDIRS = ['portraits', 'paintings'];
|
||||
const MTIME_TOLERANCE_MS = 1000;
|
||||
|
||||
function parseArgs(argv) {
|
||||
const configArg = argv.find((a) => a.startsWith('--config='));
|
||||
return {
|
||||
dryRun: argv.includes('--dry-run'),
|
||||
verbose: argv.includes('--verbose'),
|
||||
configPath: configArg ? configArg.slice('--config='.length) : null,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveDevImageDir() {
|
||||
require('dotenv').config({ path: path.join(rootDir, '.env') });
|
||||
return path.resolve(process.env.IMAGE_DIR || path.join(rootDir, 'data', 'images'));
|
||||
}
|
||||
|
||||
function walkImageFiles(rootDir, baseRel = '') {
|
||||
const files = [];
|
||||
const abs = path.join(rootDir, baseRel);
|
||||
if (!fs.existsSync(abs)) return files;
|
||||
|
||||
for (const entry of fs.readdirSync(abs, { withFileTypes: true })) {
|
||||
const rel = baseRel ? path.join(baseRel, entry.name).replace(/\\/g, '/') : entry.name;
|
||||
if (entry.isDirectory()) {
|
||||
files.push(...walkImageFiles(rootDir, rel));
|
||||
} else if (entry.isFile()) {
|
||||
files.push(rel);
|
||||
}
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
function collectRelativePaths(imageRoot) {
|
||||
const relPaths = new Set();
|
||||
for (const sub of IMAGE_SUBDIRS) {
|
||||
const subRoot = path.join(imageRoot, sub);
|
||||
if (!fs.existsSync(subRoot)) continue;
|
||||
for (const rel of walkImageFiles(subRoot, sub)) {
|
||||
relPaths.add(rel.replace(/\\/g, '/'));
|
||||
}
|
||||
}
|
||||
return relPaths;
|
||||
}
|
||||
|
||||
function statFile(imageRoot, relPath) {
|
||||
const abs = path.join(imageRoot, relPath);
|
||||
try {
|
||||
if (!fs.existsSync(abs)) return null;
|
||||
const st = fs.statSync(abs);
|
||||
if (!st.isFile()) return null;
|
||||
return { size: st.size, mtimeMs: st.mtimeMs };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function ensureDirForFile(filePath) {
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||
}
|
||||
|
||||
function copyFile(src, dest, dryRun) {
|
||||
if (dryRun) return;
|
||||
ensureDirForFile(dest);
|
||||
fs.copyFileSync(src, dest);
|
||||
}
|
||||
|
||||
function timestampSlug(date = new Date()) {
|
||||
const pad = (n) => String(n).padStart(2, '0');
|
||||
return [
|
||||
date.getFullYear(),
|
||||
pad(date.getMonth() + 1),
|
||||
pad(date.getDate()),
|
||||
'_',
|
||||
pad(date.getHours()),
|
||||
pad(date.getMinutes()),
|
||||
pad(date.getSeconds()),
|
||||
].join('');
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const options = parseArgs(process.argv.slice(2));
|
||||
const config = loadHarmonizeConfig(options.configPath);
|
||||
const dryRun = options.dryRun || config.dryRun;
|
||||
|
||||
const devDir = resolveDevImageDir();
|
||||
const prodDir = prodImagesPath(config);
|
||||
|
||||
if (!fs.existsSync(prodDir)) {
|
||||
throw new Error(
|
||||
`Prod image path not reachable: ${prodDir}. Map SMB share or set paths.prodImages in harmonize config.`,
|
||||
);
|
||||
}
|
||||
|
||||
if (!dryRun && process.env.CONFIRM_PROD !== '1') {
|
||||
await confirmProdAction(
|
||||
'Harmonize will copy image files between dev and prod (prod files may be overwritten).',
|
||||
);
|
||||
}
|
||||
|
||||
const devPaths = collectRelativePaths(devDir);
|
||||
const prodPaths = collectRelativePaths(prodDir);
|
||||
const allPaths = new Set([...devPaths, ...prodPaths]);
|
||||
|
||||
const stats = {
|
||||
devToProd: 0,
|
||||
prodToDev: 0,
|
||||
skipped: 0,
|
||||
actions: [],
|
||||
};
|
||||
|
||||
for (const rel of allPaths) {
|
||||
const devStat = statFile(devDir, rel);
|
||||
const prodStat = statFile(prodDir, rel);
|
||||
|
||||
if (devStat && !prodStat) {
|
||||
stats.devToProd += 1;
|
||||
stats.actions.push({ path: rel, action: 'dev→prod copy' });
|
||||
copyFile(path.join(devDir, rel), path.join(prodDir, rel), dryRun);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (prodStat && !devStat) {
|
||||
stats.prodToDev += 1;
|
||||
stats.actions.push({ path: rel, action: 'prod→dev copy' });
|
||||
copyFile(path.join(prodDir, rel), path.join(devDir, rel), dryRun);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!devStat || !prodStat) continue;
|
||||
|
||||
const sameSize = devStat.size === prodStat.size;
|
||||
const mtimeClose = Math.abs(devStat.mtimeMs - prodStat.mtimeMs) <= MTIME_TOLERANCE_MS;
|
||||
if (sameSize && mtimeClose) {
|
||||
stats.skipped += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (devStat.mtimeMs > prodStat.mtimeMs) {
|
||||
stats.devToProd += 1;
|
||||
stats.actions.push({ path: rel, action: 'dev→prod update' });
|
||||
copyFile(path.join(devDir, rel), path.join(prodDir, rel), dryRun);
|
||||
} else if (prodStat.mtimeMs > devStat.mtimeMs) {
|
||||
stats.prodToDev += 1;
|
||||
stats.actions.push({ path: rel, action: 'prod→dev update' });
|
||||
copyFile(path.join(prodDir, rel), path.join(devDir, rel), dryRun);
|
||||
} else {
|
||||
stats.skipped += 1;
|
||||
}
|
||||
}
|
||||
|
||||
const reportDir = path.join(rootDir, 'db', 'SyncReports');
|
||||
fs.mkdirSync(reportDir, { recursive: true });
|
||||
const reportPath = path.join(reportDir, `harmonize_images_${timestampSlug()}.json`);
|
||||
fs.writeFileSync(reportPath, JSON.stringify({
|
||||
generatedAt: new Date().toISOString(),
|
||||
dryRun,
|
||||
devDir,
|
||||
prodDir,
|
||||
stats: {
|
||||
devToProd: stats.devToProd,
|
||||
prodToDev: stats.prodToDev,
|
||||
skipped: stats.skipped,
|
||||
},
|
||||
actions: options.verbose ? stats.actions : stats.actions.slice(0, 500),
|
||||
}, null, 2));
|
||||
|
||||
const mode = dryRun ? ' (dry-run)' : '';
|
||||
printCliResult({
|
||||
script: 'harmonize-images',
|
||||
ok: true,
|
||||
summary: `Image harmonize complete${mode}: dev→prod ${stats.devToProd}, prod→dev ${stats.prodToDev}, skipped ${stats.skipped}.`,
|
||||
details: [`Dev: ${devDir}`, `Prod: ${prodDir}`, `Report: ${reportPath}`],
|
||||
});
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
printCliResult({
|
||||
script: 'harmonize-images',
|
||||
ok: false,
|
||||
summary: err.message,
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user