#!/usr/bin/env node /** * Pre-Deployment Validation * * Validates rsync/scp/deployment commands against inst_025 rules: * - Checks if source files have different subdirectories * - Ensures separate commands for different directory levels * - Prevents directory structure flattening * * Usage: * node scripts/validate-deployment.js --command "rsync ..." * node scripts/validate-deployment.js --files "file1 file2" --target "remote:path" * * Exit codes: * 0 = Valid deployment * 1 = Invalid (violates inst_025) * 2 = Error * * Copyright 2025 Tractatus Project * Licensed under Apache License 2.0 */ const fs = require('fs'); const path = require('path'); /** * Parse rsync command to extract source files and target */ function parseRsyncCommand(command) { // Match rsync with options and files const rsyncPattern = /rsync\s+([^"'\s]+(?:\s+[^"'\s]+)*)\s+((?:[^\s]+\s+)*)([\w@.-]+:[^\s]+|[^\s]+)$/; const match = command.match(rsyncPattern); if (!match) { return null; } // Extract flags and files const parts = command.split(/\s+/).filter(p => p.length > 0); const rsyncIndex = parts.findIndex(p => p === 'rsync' || p.endsWith('/rsync')); if (rsyncIndex === -1) { return null; } const filesAndTarget = parts.slice(rsyncIndex + 1); // Last item is target const target = filesAndTarget[filesAndTarget.length - 1]; // Everything before target that's not a flag is a file const files = []; for (let i = 0; i < filesAndTarget.length - 1; i++) { const item = filesAndTarget[i]; if (!item.startsWith('-')) { files.push(item); } } return { files, target, command }; } /** * Check if files have different subdirectory paths */ function checkDirectoryMismatch(files) { if (files.length <= 1) { return { hasMismatch: false, directories: [] }; } const directories = files.map(f => { const dir = path.dirname(f); return dir === '.' ? '' : dir; }); const uniqueDirs = [...new Set(directories)]; return { hasMismatch: uniqueDirs.length > 1, directories: uniqueDirs, filesByDir: Object.fromEntries( uniqueDirs.map(dir => [ dir, files.filter(f => path.dirname(f) === (dir || '.')) ]) ) }; } /** * Validate deployment command */ function validateDeployment(command) { const parsed = parseRsyncCommand(command); if (!parsed) { return { valid: false, error: 'Could not parse rsync command', suggestion: null }; } const { files, target } = parsed; if (files.length === 0) { return { valid: false, error: 'No source files specified', suggestion: null }; } // Check for directory mismatch const dirCheck = checkDirectoryMismatch(files); if (!dirCheck.hasMismatch) { return { valid: true, message: 'Deployment command is valid - all files in same directory', files, target }; } // Violation detected return { valid: false, error: `inst_025 violation: Files from different subdirectories in single rsync command`, details: { file_count: files.length, unique_directories: dirCheck.directories.length, directories: dirCheck.directories, filesByDir: dirCheck.filesByDir }, suggestion: generateSeparateCommands(dirCheck.filesByDir, target, command) }; } /** * Generate separate rsync commands for each directory */ function generateSeparateCommands(filesByDir, target, originalCommand) { const commands = []; // Extract rsync flags from original command const flagMatch = originalCommand.match(/rsync\s+([^/\s][^\s]*)/); const flags = flagMatch ? flagMatch[1] : '-avz --progress'; Object.entries(filesByDir).forEach(([dir, files]) => { const targetWithDir = dir ? `${target}/${dir}/` : target; files.forEach(file => { const cmd = `rsync ${flags} ${file} ${targetWithDir}`; commands.push(cmd); }); }); return commands; } /** * Display validation results */ function displayResults(result) { if (result.valid) { console.log('\x1b[32m✅ Deployment command is VALID\x1b[0m'); console.log(` Files: ${result.files.length}`); console.log(` Target: ${result.target}`); return 0; } console.log('\x1b[31m❌ Deployment command VIOLATES inst_025\x1b[0m'); console.log(`\n Error: ${result.error}\n`); if (result.details) { console.log(' Details:'); console.log(` File count: ${result.details.file_count}`); console.log(` Unique directories: ${result.details.unique_directories}`); console.log(' Directories:'); result.details.directories.forEach(dir => { const dirDisplay = dir || '(root)'; const fileCount = result.details.filesByDir[dir].length; console.log(` • ${dirDisplay} (${fileCount} files)`); result.details.filesByDir[dir].forEach(file => { console.log(` - ${path.basename(file)}`); }); }); } if (result.suggestion) { console.log('\n \x1b[33mSuggested fix (separate commands per directory):\x1b[0m\n'); result.suggestion.forEach((cmd, i) => { console.log(` ${i + 1}. ${cmd}`); }); console.log(''); } return 1; } /** * Main */ function main() { const args = process.argv.slice(2); if (args.length === 0 || args.includes('--help')) { console.log('Pre-Deployment Validation'); console.log('\nUsage:'); console.log(' node scripts/validate-deployment.js --command "rsync ..."'); console.log('\nExample:'); console.log(' node scripts/validate-deployment.js --command "rsync -avz file1 file2/sub/file remote:path"'); process.exit(0); } const commandIndex = args.indexOf('--command'); if (commandIndex === -1 || !args[commandIndex + 1]) { console.error('Error: --command flag required'); process.exit(2); } const command = args[commandIndex + 1]; const result = validateDeployment(command); const exitCode = displayResults(result); process.exit(exitCode); } main();