const fs = require('fs'); const path = require('path'); const ignore = require('ignore'); const readline = require('readline'); const diff = require('diff'); const crypto = require('crypto'); let chalk; (async () => { chalk = await import('chalk'); })(); const CONSTANTS = { PATHS: { IGNORE_LIST: path.join(__dirname, '.sync-webgui-repo-ignored-files.json'), NEW_FILES: path.join(__dirname, '.sync-webgui-repo-new-files.json'), STATE: path.join(__dirname, '.sync-webgui-repo-state.json'), }, IGNORE_PATTERNS: [/\.md$/i, /\.ico$/i, /\.cfg$/i, /\.json$/i, /^banner\.png$/i], PLUGIN_PATHS: { API: 'plugin/source/dynamix.unraid.net/usr/local/emhttp/plugins', WEBGUI: 'emhttp/plugins', }, WEB_COMPONENTS: { API_WEB_BUILD_PATH: 'web/.nuxt/nuxt-custom-elements/dist/unraid-components', API_UI_BUILD_PATH: 'unraid-ui/dist-wc', WEBGUI_BASE_PATH: 'emhttp/plugins/dynamix.my.servers/unraid-components', WEBGUI_WEB_SUBPATH: 'nuxt', WEBGUI_UI_SUBPATH: 'uui', }, }; const FileSystem = { readJsonFile(path, defaultValue = {}) { try { return fs.existsSync(path) ? JSON.parse(fs.readFileSync(path, 'utf8')) : defaultValue; } catch { return defaultValue; } }, writeJsonFile(path, data) { fs.writeFileSync(path, JSON.stringify(data, null, 2)); }, ensureDir(dir) { if (!fs.existsSync(dir)) { fs.mkdirSync(dir, { recursive: true }); } }, copyFile(source, dest) { try { const destDir = path.dirname(dest); this.ensureDir(destDir); fs.copyFileSync(source, dest); return true; } catch (err) { UI.log(`Failed to copy ${source} to ${dest}: ${err.message}`, 'error'); return false; } }, getFileHash(filePath) { const fileBuffer = fs.readFileSync(filePath); const hashSum = crypto.createHash('sha256'); hashSum.update(fileBuffer); return hashSum.digest('hex'); }, copyDirectory(source, destination) { this.ensureDir(destination); fs.readdirSync(source).forEach((file) => { const sourcePath = path.join(source, file); const destPath = path.join(destination, file); const stats = fs.statSync(sourcePath); if (stats.isDirectory()) { this.copyDirectory(sourcePath, destPath); } else { fs.copyFileSync(sourcePath, destPath); } }); }, }; const UI = { rl: readline.createInterface({ input: process.stdin, output: process.stdout, }), async question(query) { return new Promise((resolve) => this.rl.question(query, resolve)); }, async confirm(query, defaultYes = true) { const answer = await this.question(`${query} (${defaultYes ? 'Y/n' : 'y/N'}) `); return defaultYes ? answer.toLowerCase() !== 'n' : answer.toLowerCase() === 'y'; }, log(message, type) { const icons = { success: '✅', error: '❌', warning: '⚠️', info: 'ℹ️', skip: '⏭️', new: '✨', }; console.log(`${icons[type] || ''} ${message}`); }, playSound() { const sounds = { darwin: 'afplay /System/Library/Sounds/Glass.aiff', linux: 'paplay /usr/share/sounds/freedesktop/stereo/complete.oga', win32: 'powershell.exe -c "(New-Object Media.SoundPlayer \'C:\\Windows\\Media\\Windows Default.wav\').PlaySync()"', }; const sound = sounds[process.platform]; if (sound) require('child_process').exec(sound); }, }; const State = { loadIgnoredFiles() { return FileSystem.readJsonFile(CONSTANTS.PATHS.IGNORE_LIST, []); }, saveIgnoredFiles(files) { FileSystem.writeJsonFile(CONSTANTS.PATHS.IGNORE_LIST, files); }, loadPaths() { return FileSystem.readJsonFile(CONSTANTS.PATHS.STATE); }, savePaths(paths) { Object.keys(paths).forEach((key) => { if (typeof paths[key] === 'string') { paths[key] = paths[key].endsWith('/') ? paths[key] : paths[key] + '/'; } }); FileSystem.writeJsonFile(CONSTANTS.PATHS.STATE, paths); }, getNewFiles() { const data = FileSystem.readJsonFile(CONSTANTS.PATHS.NEW_FILES, { newFiles: {} }); return data.newFiles; }, saveNewFiles(files) { FileSystem.writeJsonFile(CONSTANTS.PATHS.NEW_FILES, { timestamp: new Date().toISOString(), newFiles: files, }); }, }; const FileOps = { loadGitignore(dirPath) { const gitignorePath = path.join(dirPath, '.gitignore'); if (fs.existsSync(gitignorePath)) { const ig = ignore(); ig.add(fs.readFileSync(gitignorePath, 'utf8')); return ig; } return null; }, showDiff(apiFile, webguiFile) { const content1 = fs.readFileSync(apiFile, 'utf8'); const content2 = fs.readFileSync(webguiFile, 'utf8'); const differences = diff.createPatch( path.basename(apiFile), content2, content1, 'webgui version', 'api version' ); if (differences.split('\n').length > 5) { console.log('\nDiff for', chalk.default.cyan(path.basename(apiFile))); console.log(chalk.default.red('--- webgui:'), webguiFile); console.log(chalk.default.green('+++ api: '), apiFile); differences .split('\n') .slice(5) .forEach((line) => { if (line.startsWith('+')) console.log(chalk.default.green(line)); else if (line.startsWith('-')) console.log(chalk.default.red(line)); else if (line.startsWith('@')) console.log(chalk.default.cyan(line)); else console.log(line); }); return true; } return false; }, async handleFileDiff(apiFile, webguiFile) { if (!this.showDiff(apiFile, webguiFile)) return 'identical'; const answer = await UI.question( 'What should I do fam? (w=copy to webgui/a=copy to API/s=skip) ' ); switch (answer.toLowerCase()) { case 'w': return FileSystem.copyFile(apiFile, webguiFile) ? 'webgui' : 'error'; case 'a': return FileSystem.copyFile(webguiFile, apiFile) ? 'api' : 'error'; default: return 'skip'; } }, walkDirectory(currentPath, baseDir, projectFiles, gitignoreRules) { const files = fs.readdirSync(currentPath); files.forEach((file) => { if (file.startsWith('.')) return; const fullPath = path.join(currentPath, file); const relativePath = path.relative(baseDir, fullPath); if (relativePath.includes('test') || relativePath.includes('tests')) return; if (CONSTANTS.IGNORE_PATTERNS.some((pattern) => pattern.test(file))) return; if (gitignoreRules?.ignores(relativePath)) return; const lstat = fs.lstatSync(fullPath); if (lstat.isSymbolicLink()) return; const stats = fs.statSync(fullPath); if (stats.isDirectory()) { this.walkDirectory(fullPath, baseDir, projectFiles, gitignoreRules); } else { if (!projectFiles.has(file)) { projectFiles.set(file, []); } projectFiles.get(file).push(fullPath); } }); }, }; const Features = { async setupPaths() { const paths = State.loadPaths(); let changed = false; if ( !paths.apiProjectDir || !(await UI.confirm(`Use last API repo path (${paths.apiProjectDir})?`)) ) { paths.apiProjectDir = await UI.question('Enter the path to your API repo: '); changed = true; } if ( !paths.webguiProjectDir || !(await UI.confirm(`Use last webgui repo path (${paths.webguiProjectDir})?`)) ) { paths.webguiProjectDir = await UI.question('Enter the path to your webgui repo: '); changed = true; } if (changed) { State.savePaths(paths); } return paths; }, async handleWebComponentBuild() { const webDir = path.join(global.apiProjectDir, 'web'); if (!fs.existsSync(webDir)) { UI.log('Web directory not found in API repo!', 'error'); return; } UI.log('Building web components...', 'info'); const { exec } = require('child_process'); try { await new Promise((resolve, reject) => { const buildProcess = exec('pnpm run build', { cwd: webDir }); buildProcess.stdout.on('data', (data) => process.stdout.write(data)); buildProcess.stderr.on('data', (data) => process.stderr.write(data)); buildProcess.on('exit', (code) => { if (code === 0) { UI.log('Web components build completed successfully!', 'success'); resolve(); } else { reject(new Error(`Web components build failed with code ${code}`)); } }); }); } catch (err) { UI.log(`Error during build: ${err.message}`, 'error'); } }, async handleUiComponentBuild() { const uiDir = path.join(global.apiProjectDir, 'unraid-ui'); if (!fs.existsSync(uiDir)) { UI.log('Unraid UI directory not found in API repo!', 'error'); return; } UI.log('Building UI components...', 'info'); const { exec } = require('child_process'); try { await new Promise((resolve, reject) => { const buildProcess = exec('pnpm run build && pnpm run build:wc', { cwd: uiDir }); buildProcess.stdout.on('data', (data) => process.stdout.write(data)); buildProcess.stderr.on('data', (data) => process.stderr.write(data)); buildProcess.on('exit', (code) => { if (code === 0) { UI.log('UI build completed successfully!', 'success'); resolve(); } else { reject(new Error(`UI build failed with code ${code}`)); } }); }); } catch (err) { UI.log(`Error during build: ${err.message}`, 'error'); } }, async handleWebComponentSync() { const apiWebPath = path.join(global.apiProjectDir, CONSTANTS.WEB_COMPONENTS.API_WEB_BUILD_PATH); const webguiBasePath = path.join(global.webguiProjectDir, CONSTANTS.WEB_COMPONENTS.WEBGUI_BASE_PATH); const webguiWebPath = path.join(webguiBasePath, CONSTANTS.WEB_COMPONENTS.WEBGUI_WEB_SUBPATH); try { if (!fs.existsSync(apiWebPath)) { UI.log('Web components source directory not found! Did you build the web components?', 'error'); return; } UI.log('Removing old web components...', 'info'); fs.rmSync(webguiWebPath, { recursive: true, force: true }); UI.log('Copying new web components...', 'info'); FileSystem.copyDirectory(apiWebPath, webguiWebPath); const indexPath = path.join(webguiWebPath, 'index.html'); if (fs.existsSync(indexPath)) { UI.log('Removing irrelevant index.html...', 'info'); fs.unlinkSync(indexPath); } UI.playSound(); UI.log('Web components copied successfully!', 'success'); } catch (err) { UI.log(`Error during sync: ${err.message}`, 'error'); } }, async handleUiComponentSync() { const apiUiPath = path.join(global.apiProjectDir, CONSTANTS.WEB_COMPONENTS.API_UI_BUILD_PATH); const webguiBasePath = path.join(global.webguiProjectDir, CONSTANTS.WEB_COMPONENTS.WEBGUI_BASE_PATH); const webguiUiPath = path.join(webguiBasePath, CONSTANTS.WEB_COMPONENTS.WEBGUI_UI_SUBPATH); try { if (!fs.existsSync(apiUiPath)) { UI.log('Unraid UI source directory not found!', 'error'); return; } UI.log('Removing old UI components...', 'info'); fs.rmSync(webguiUiPath, { recursive: true, force: true }); UI.log('Copying new UI components...', 'info'); FileSystem.copyDirectory(apiUiPath, webguiUiPath); UI.playSound(); UI.log('UI components copied successfully!', 'success'); } catch (err) { UI.log(`Error during sync: ${err.message}`, 'error'); } }, findMatchingFiles(apiProjectDir, webguiProjectDir) { const matches = new Map(); const apiFiles = new Map(); const webguiFiles = new Map(); const gitignore1 = FileOps.loadGitignore(apiProjectDir); const gitignore2 = FileOps.loadGitignore(webguiProjectDir); FileOps.walkDirectory(apiProjectDir, apiProjectDir, apiFiles, gitignore1); FileOps.walkDirectory(webguiProjectDir, webguiProjectDir, webguiFiles, gitignore2); apiFiles.forEach((paths1, filename) => { if (webguiFiles.has(filename)) { matches.set(filename, [...paths1, ...webguiFiles.get(filename)]); } }); return matches; }, findMissingPluginFiles(apiProjectDir, webguiProjectDir, ignoredFiles) { const missingFiles = new Map(); if (!apiProjectDir || !webguiProjectDir) { UI.log('API project and webgui project directories are required!', 'error'); return missingFiles; } const apiPluginsPath = path.join(apiProjectDir, CONSTANTS.PLUGIN_PATHS.API); const webguiPluginsPath = path.join(webguiProjectDir, CONSTANTS.PLUGIN_PATHS.WEBGUI); if (!fs.existsSync(apiPluginsPath)) { UI.log('API plugins directory not found: ' + apiPluginsPath, 'error'); return missingFiles; } const gitignore1 = FileOps.loadGitignore(apiProjectDir); function walkDir(currentPath, baseDir) { if (!fs.existsSync(currentPath)) { UI.log(`Directory doesn't exist: ${currentPath}`, 'warning'); return; } UI.log(`Checking directory: ${path.relative(apiProjectDir, currentPath)}`, 'info'); fs.readdirSync(currentPath).forEach((file) => { if (file.startsWith('.')) { UI.log(`Skipping dot file/dir: ${file}`, 'skip'); return; } const fullPath = path.join(currentPath, file); const relativePath = path.relative(apiPluginsPath, fullPath); if (CONSTANTS.IGNORE_PATTERNS.some((pattern) => pattern.test(file))) { UI.log(`Skipping ignored pattern: ${file}`, 'skip'); return; } if (gitignore1?.ignores(relativePath)) { UI.log(`Skipping gitignored file: ${file}`, 'skip'); return; } const lstat = fs.lstatSync(fullPath); if (lstat.isSymbolicLink()) { UI.log(`Skipping symlink: ${file}`, 'skip'); return; } const stats = fs.statSync(fullPath); if (stats.isDirectory()) { UI.log(`Found subdirectory: ${file}`, 'info'); walkDir(fullPath, baseDir); return; } if (ignoredFiles.includes(file)) { UI.log(`Skipping manually ignored file: ${file}`, 'skip'); return; } const webguiPath = path.join(webguiPluginsPath, relativePath); if (!fs.existsSync(webguiPath)) { UI.log(`Found new file: ${relativePath}`, 'new'); missingFiles.set(relativePath, { source: fullPath, destinationPath: webguiPath, relativePath, }); } else { UI.log(`File exists in both: ${relativePath}`, 'success'); } }); } UI.log('\nStarting directory scan...', 'info'); UI.log(`API plugins path: ${apiPluginsPath}`, 'info'); UI.log(`Webgui plugins path: ${webguiPluginsPath}\n`, 'info'); try { walkDir(apiPluginsPath, apiPluginsPath); if (missingFiles.size > 0) { State.saveNewFiles( Object.fromEntries( Array.from(missingFiles).map(([relativePath, info]) => [relativePath, info]) ) ); } } catch (err) { UI.log(`Error while scanning directories: ${err.message}`, 'error'); } return missingFiles; }, async handleNewFiles() { const newFiles = State.getNewFiles(); const fileCount = Object.keys(newFiles).length; if (fileCount === 0) { UI.log('No new files to copy bruv!', 'info'); return; } UI.log(`Found ${fileCount} files to review:`, 'info'); const handledFiles = new Set(); const ignoredFiles = State.loadIgnoredFiles(); for (const [relativePath, info] of Object.entries(newFiles)) { console.log(`\nFile: ${relativePath}`); console.log(`From: ${info.source}`); console.log(`To: ${info.destinationPath}`); const answer = await UI.question( 'What should I do fam? (w=copy to webgui/i=ignore forever/s=skip/q=quit) ' ); switch (answer.toLowerCase()) { case 'w': if (FileSystem.copyFile(info.source, info.destinationPath)) { UI.log(`Copied: ${relativePath}`, 'success'); handledFiles.add(relativePath); } break; case 'i': ignoredFiles.push(path.basename(relativePath)); State.saveIgnoredFiles(ignoredFiles); UI.log(`Added ${path.basename(relativePath)} to ignore list`, 'info'); handledFiles.add(relativePath); break; case 'q': UI.log('Stopping here fam!', 'info'); break; default: UI.log(`Skipped: ${relativePath}`, 'skip'); handledFiles.add(relativePath); break; } if (answer.toLowerCase() === 'q') break; } const updatedNewFiles = Object.fromEntries( Object.entries(newFiles).filter(([relativePath]) => !handledFiles.has(relativePath)) ); State.saveNewFiles(updatedNewFiles); const remainingCount = Object.keys(updatedNewFiles).length; UI.log('All done for now bruv! 🔥', 'success'); if (remainingCount > 0) { UI.log(`${remainingCount} files left to handle next time.`, 'info'); } }, }; const Menu = { async show() { while (true) { try { console.log('\nWhat you trying to do fam?'); console.log('1. Find new plugin files in API project'); console.log('2. Handle new plugin files in API project'); console.log('3. Sync shared files between API and webgui'); console.log('4. Build UI components'); console.log('5. Sync UI components'); console.log('6. Build web components'); console.log('7. Sync web components'); console.log('8. Exit\n'); const answer = await UI.question('Choose an option (1-8): '); switch (answer) { case '1': { UI.log('Checking plugin directories for missing files bruv...', 'info'); const ignoredFiles = State.loadIgnoredFiles(); const missingFiles = Features.findMissingPluginFiles( global.apiProjectDir, global.webguiProjectDir, ignoredFiles ); if (missingFiles.size > 0) { UI.log(`Found ${missingFiles.size} new files! 🔍`, 'info'); if (await UI.confirm('Want to handle these new files now fam?', false)) { await Features.handleNewFiles(); } else { UI.log('Safe, you can handle them later with option 2!', 'info'); } } else { UI.log('\n'); UI.log('No new files found bruv! 👌', 'success'); } break; } case '2': await Features.handleNewFiles(); break; case '3': { UI.log('Checking for matching files bruv...', 'info'); const matchingFiles = Features.findMatchingFiles( global.apiProjectDir, global.webguiProjectDir ); if (matchingFiles.size === 0) { UI.log('No matching files found fam!', 'info'); } else { UI.log(`Found ${matchingFiles.size} matching files:\n`, 'info'); for (const [filename, paths] of matchingFiles) { const [apiPath, webguiPath] = paths; console.log(`File: ${filename}`); const apiHash = FileSystem.getFileHash(apiPath); const webguiHash = FileSystem.getFileHash(webguiPath); if (apiHash !== webguiHash) { await FileOps.handleFileDiff(apiPath, webguiPath); } else { UI.log('Files are identical', 'success'); } } } break; } case '4': await Features.handleUiComponentBuild(); break; case '5': await Features.handleUiComponentSync(); break; case '6': await Features.handleWebComponentBuild(); break; case '7': await Features.handleWebComponentSync(); break; case '8': UI.log('Safe bruv, catch you later! 👋', 'success'); UI.rl.close(); process.exit(0); return; default: UI.log("Nah fam, that's not a valid option!", 'error'); break; } } catch (error) { UI.log(error.message, 'error'); UI.rl.close(); process.exit(1); } } } }; const App = { async init() { try { const paths = await Features.setupPaths(); global.apiProjectDir = paths.apiProjectDir; global.webguiProjectDir = paths.webguiProjectDir; await Menu.show(); } catch (error) { UI.log(error.message, 'error'); UI.rl.close(); process.exit(1); } }, }; App.init().catch((error) => { UI.log(`Something went wrong fam: ${error.message}`, 'error'); UI.rl.close(); process.exit(1); });