- Create Economist SubmissionTracking package correctly: * mainArticle = full blog post content * coverLetter = 216-word SIR— letter * Links to blog post via blogPostId - Archive 'Letter to The Economist' from blog posts (it's the cover letter) - Fix date display on article cards (use published_at) - Target publication already displaying via blue badge Database changes: - Make blogPostId optional in SubmissionTracking model - Economist package ID: 68fa85ae49d4900e7f2ecd83 - Le Monde package ID: 68fa2abd2e6acd5691932150 Next: Enhanced modal with tabs, validation, export 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
236 lines
5.9 KiB
JavaScript
Executable file
236 lines
5.9 KiB
JavaScript
Executable file
#!/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();
|