UNPKG

@tanstack/cli

Version:
178 lines (177 loc) 7.17 kB
import fs from 'node:fs'; import path from 'node:path'; import crypto from 'node:crypto'; import * as diff from 'diff'; export class FileSyncer { async sync(sourceDir, targetDir, options) { const result = { updated: [], skipped: [], created: [], deleted: [], sourceFiles: [], errors: [], }; // Ensure directories exist if (!fs.existsSync(sourceDir)) { throw new Error(`Source directory does not exist: ${sourceDir}`); } if (!fs.existsSync(targetDir)) { throw new Error(`Target directory does not exist: ${targetDir}`); } // Walk through source directory and sync files await this.syncDirectory(sourceDir, targetDir, sourceDir, result); if (options?.deleteRemoved && options.previousSourceFiles) { const currentSourceFileSet = new Set(result.sourceFiles); await this.deleteRemovedFiles(targetDir, options.previousSourceFiles, currentSourceFileSet, result); } return result; } async syncDirectory(currentPath, targetBase, sourceBase, result) { const entries = await fs.promises.readdir(currentPath, { withFileTypes: true, }); for (const entry of entries) { const sourcePath = path.join(currentPath, entry.name); const relativePath = path.relative(sourceBase, sourcePath); const targetPath = path.join(targetBase, relativePath); // Skip certain directories if (entry.isDirectory()) { if (this.shouldSkipDirectory(entry.name)) { continue; } // Ensure target directory exists if (!fs.existsSync(targetPath)) { await fs.promises.mkdir(targetPath, { recursive: true }); } // Recursively sync subdirectory await this.syncDirectory(sourcePath, targetBase, sourceBase, result); } else if (entry.isFile()) { // Skip certain files if (this.shouldSkipFile(entry.name)) { continue; } result.sourceFiles.push(relativePath); try { const shouldUpdate = await this.shouldUpdateFile(sourcePath, targetPath); if (shouldUpdate) { // Check if file exists to generate diff let fileDiff; const targetExists = fs.existsSync(targetPath); if (targetExists) { // Generate diff for existing files const oldContent = await fs.promises.readFile(targetPath, 'utf-8'); const newContent = await fs.promises.readFile(sourcePath, 'utf-8'); const changes = diff.createPatch(relativePath, oldContent, newContent, 'Previous', 'Current'); // Only include diff if there are actual changes if (changes && changes.split('\n').length > 5) { fileDiff = changes; } } // Copy file await fs.promises.copyFile(sourcePath, targetPath); // Touch file to trigger dev server reload const now = new Date(); await fs.promises.utimes(targetPath, now, now); if (!targetExists) { result.created.push(relativePath); } else { result.updated.push({ path: relativePath, diff: fileDiff, }); } } else { result.skipped.push(relativePath); } } catch (error) { result.errors.push(`${relativePath}: ${error instanceof Error ? error.message : String(error)}`); } } } } async shouldUpdateFile(sourcePath, targetPath) { // If target doesn't exist, definitely update if (!fs.existsSync(targetPath)) { return true; } // Compare file sizes first (quick check) const [sourceStats, targetStats] = await Promise.all([ fs.promises.stat(sourcePath), fs.promises.stat(targetPath), ]); if (sourceStats.size !== targetStats.size) { return true; } // Compare MD5 hashes for content const [sourceHash, targetHash] = await Promise.all([ this.calculateHash(sourcePath), this.calculateHash(targetPath), ]); return sourceHash !== targetHash; } async calculateHash(filePath) { return new Promise((resolve, reject) => { const hash = crypto.createHash('md5'); const stream = fs.createReadStream(filePath); stream.on('data', (data) => hash.update(data)); stream.on('end', () => resolve(hash.digest('hex'))); stream.on('error', reject); }); } shouldSkipDirectory(name) { const skipDirs = [ 'node_modules', '.git', 'dist', 'build', '.next', '.nuxt', '.cache', '.tmp-dev', 'coverage', '.turbo', ]; return skipDirs.includes(name) || name.startsWith('.'); } shouldSkipFile(name) { const skipFiles = [ '.DS_Store', 'Thumbs.db', 'desktop.ini', '.cta.json', // Skip .cta.json as it contains framework ID that changes each build ]; const skipExtensions = ['.log', '.lock', '.pid', '.seed', '.sqlite']; if (skipFiles.includes(name)) { return true; } const ext = path.extname(name).toLowerCase(); return skipExtensions.includes(ext); } async deleteRemovedFiles(targetDir, previousSourceFiles, currentSourceFiles, result) { for (const relativePath of previousSourceFiles) { if (currentSourceFiles.has(relativePath)) { continue; } const targetPath = path.join(targetDir, relativePath); try { if (!fs.existsSync(targetPath)) { continue; } const stats = await fs.promises.stat(targetPath); if (!stats.isFile()) { continue; } await fs.promises.unlink(targetPath); result.deleted.push(relativePath); } catch (error) { result.errors.push(`${relativePath}: ${error instanceof Error ? error.message : String(error)}`); } } } }