UNPKG

woaru

Version:

Universal Project Setup Autopilot - Analyze and automatically configure development tools for ANY programming language

416 lines 15.8 kB
import fs from 'fs-extra'; import * as path from 'path'; import * as os from 'os'; import chalk from 'chalk'; import { initializeI18n } from '../config/i18n.js'; import { FilenameHelper } from '../utils/filenameHelper.js'; /** * Security constants for input validation */ const SECURITY_LIMITS = { MAX_FILE_SIZE: 10 * 1024 * 1024, // 10MB MAX_URL_LENGTH: 2048, MAX_REPORTS_TO_PROCESS: 100, TIMEOUT_MS: 30000, // 30 seconds }; /** * MessageHandler - Core system for reading, filtering, and sending WOARU reports * Handles integration with CLI commands and external services via webhooks */ export class MessageHandler { sentReportsDir; woaruDir; constructor() { this.woaruDir = path.join(os.homedir(), '.woaru'); this.sentReportsDir = path.join(this.woaruDir, 'sent-reports'); } /** * Static method for CLI integration - main entry point * @param options - MessageHandler options from CLI arguments * @returns Promise<void> */ static async send(options) { try { await initializeI18n(); const handler = new MessageHandler(); console.log(chalk.blue('📤 WOARU Message Handler')); console.log(chalk.gray('═'.repeat(50))); // Read and filter reports const reports = await handler.readReports(); if (reports.length === 0) { console.log(chalk.yellow('📭 No reports found in sent-reports directory')); console.log(chalk.gray(` Location: ${handler.sentReportsDir}`)); return; } const filteredReports = handler.filterReports(reports, options); if (filteredReports.length === 0) { console.log(chalk.yellow('📭 No reports match the specified criteria')); return; } console.log(chalk.green(`📋 Found ${filteredReports.length} matching reports`)); // Display reports in terminal await handler.formatForTerminal(filteredReports, options); // Send webhook if URL provided if (options.url) { await handler.sendWebhook(options.url, filteredReports, options); } } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); console.error(chalk.red(`❌ Error: ${errorMessage}`)); if (options?.verbose) { console.error(chalk.gray('Stack trace:'), error); } throw error; } } /** * Read all reports from .woaru/sent-reports/ directory * @returns Promise<ReportMetadata[]> Array of report metadata */ async readReports() { try { // Ensure directory exists await fs.ensureDir(this.sentReportsDir); // Read directory contents const files = await fs.readdir(this.sentReportsDir); const reports = []; for (const filename of files) { // Only process markdown files that match WOARU naming pattern if (!filename.endsWith('.md') || !this.isWOARUReportFile(filename)) { continue; } const filepath = path.join(this.sentReportsDir, filename); try { const stats = await fs.stat(filepath); // Security check: skip files that are too large if (stats.size > SECURITY_LIMITS.MAX_FILE_SIZE) { console.warn(chalk.yellow(`⚠️ Skipping large file: ${filename} (${stats.size} bytes)`)); continue; } const timestamp = this.extractTimestampFromFilename(filename); const type = this.extractReportType(filename); reports.push({ filename, filepath, timestamp: timestamp || stats.mtime, type: type || 'unknown', size: stats.size, }); } catch { console.warn(chalk.yellow(`⚠️ Could not process file: ${filename}`)); continue; } } // Sort by timestamp (newest first) reports.sort((a, b) => b.timestamp.getTime() - a.timestamp.getTime()); return reports.slice(0, SECURITY_LIMITS.MAX_REPORTS_TO_PROCESS); } catch (error) { if (error?.code === 'ENOENT') { console.warn(chalk.yellow('📁 sent-reports directory does not exist')); return []; } throw new Error(`Failed to read reports: ${error?.message || String(error)}`); } } /** * Filter reports based on criteria * @param reports - Array of report metadata * @param criteria - Filter criteria from CLI options * @returns ReportMetadata[] Filtered reports */ filterReports(reports, criteria) { let filtered = [...reports]; // Filter by type if (criteria.type && criteria.type !== 'all') { filtered = filtered.filter(report => report.type === criteria.type || (criteria.type && report.filename.includes(criteria.type))); } // Filter for latest only if (criteria.latest) { filtered = filtered.slice(0, 1); } return filtered; } /** * Format reports for terminal output * @param reports - Array of report metadata * @param options - Display options */ async formatForTerminal(reports, options) { console.log(); console.log(chalk.cyan('📊 Report Summary:')); console.log(chalk.gray('-'.repeat(50))); for (const [index, report] of reports.entries()) { const typeIcon = this.getTypeIcon(report.type); const sizeFormatted = this.formatFileSize(report.size); const timeFormatted = report.timestamp.toLocaleString(); console.log(`${index + 1}. ${typeIcon} ${chalk.bold(report.type.toUpperCase())}`); console.log(` ${chalk.gray('File:')} ${report.filename}`); console.log(` ${chalk.gray('Size:')} ${sizeFormatted}`); console.log(` ${chalk.gray('Date:')} ${timeFormatted}`); // Show content preview if verbose if (options.verbose) { try { const content = await fs.readFile(report.filepath, 'utf8'); const preview = this.extractContentPreview(content); console.log(` ${chalk.gray('Preview:')} ${preview}`); } catch { console.log(` ${chalk.red('Preview: Error reading file')}`); } } console.log(); } } /** * Send reports to webhook URL * @param url - Webhook URL * @param reports - Array of report metadata * @param options - Send options */ async sendWebhook(url, reports, options) { // Validate URL if (!this.isValidWebhookUrl(url)) { throw new Error('Invalid webhook URL provided'); } console.log(chalk.blue(`🌐 Sending to webhook: ${this.sanitizeUrl(url)}`)); for (const report of reports) { try { // Read report content const content = await fs.readFile(report.filepath, 'utf8'); // Prepare payload const payload = { reportType: report.type, timestamp: report.timestamp.toISOString(), filename: report.filename, content: options.format === 'json' ? this.convertToJson(content) : content, metadata: { size: report.size, originalPath: report.filepath, }, }; // Send HTTP POST request await this.sendHttpPost(url, payload, options); console.log(chalk.green(`✅ Sent: ${report.filename}`)); } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); console.error(chalk.red(`❌ Failed to send ${report.filename}: ${errorMessage}`)); if (options.verbose) { console.error(chalk.gray('Error details:'), error); } } } } /** * Check if filename matches WOARU report pattern * @param filename - Filename to check * @returns boolean */ isWOARUReportFile(filename) { // Check for WOARU report pattern: YYYY-MM-DDTHH-mm-ss-fffZ-woaru-[command]-YYYYMMDD-HHMMSS.md // Or the standard FilenameHelper pattern return (FilenameHelper.isValidReportFilename(filename) || filename.includes('woaru') || /^\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-\d{3}Z-woaru-/.test(filename)); } /** * Extract timestamp from filename * @param filename - Report filename * @returns Date | null */ extractTimestampFromFilename(filename) { try { // Try FilenameHelper format first const timestamp = FilenameHelper.extractTimestampFromFilename(filename); if (timestamp) { const [datePart, timePart] = timestamp.split('_'); const [year, month, day] = datePart.split('-').map(Number); const [hour, minute, second] = timePart.split('-').map(Number); return new Date(year, month - 1, day, hour, minute, second); } // Try legacy format: YYYY-MM-DDTHH-mm-ss-fffZ const isoMatch = filename.match(/^(\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-\d{3}Z)/); if (isoMatch) { return new Date(isoMatch[1] .replace(/-/g, ':') .replace(/T(\d{2}):(\d{2}):(\d{2}):(\d{3})Z/, 'T$1:$2:$3.$4Z')); } return null; } catch { return null; } } /** * Extract report type from filename * @param filename - Report filename * @returns string */ extractReportType(filename) { // Try FilenameHelper format first const type = FilenameHelper.extractCommandType(filename); if (type) { return type; } // Try legacy format: ...-woaru-[type]-... const match = filename.match(/-woaru-([^-]+)-/); return match ? match[1] : null; } /** * Get icon for report type * @param type - Report type * @returns string */ getTypeIcon(type) { const icons = { analyze: '🔍', review: '📝', audit: '🔒', 'llm-review': '🧠', git: '📊', unknown: '📄', }; return icons[type] || icons.unknown; } /** * Format file size for display * @param bytes - File size in bytes * @returns string */ formatFileSize(bytes) { const units = ['B', 'KB', 'MB', 'GB']; let size = bytes; let unitIndex = 0; while (size >= 1024 && unitIndex < units.length - 1) { size /= 1024; unitIndex++; } return `${size.toFixed(1)} ${units[unitIndex]}`; } /** * Extract content preview from report * @param content - Full report content * @returns string */ extractContentPreview(content) { const lines = content.split('\n'); const meaningfulLines = lines .filter(line => line.trim() && !line.startsWith('#')) .slice(0, 2); const preview = meaningfulLines.join(' ').substring(0, 100); return preview + (preview.length === 100 ? '...' : ''); } /** * Validate webhook URL * @param url - URL to validate * @returns boolean */ isValidWebhookUrl(url) { try { if (!url || url.length > SECURITY_LIMITS.MAX_URL_LENGTH) { return false; } const parsedUrl = new URL(url); return parsedUrl.protocol === 'https:' || parsedUrl.protocol === 'http:'; } catch { return false; } } /** * Sanitize URL for logging * @param url - URL to sanitize * @returns string */ sanitizeUrl(url) { try { const parsedUrl = new URL(url); return `${parsedUrl.protocol}//${parsedUrl.host}${parsedUrl.pathname}`; } catch { return '[invalid URL]'; } } /** * Convert markdown content to JSON format * @param content - Markdown content * @returns string */ convertToJson(content) { try { return JSON.stringify({ format: 'markdown', content: content, lines: content.split('\n').length, size: content.length, timestamp: new Date().toISOString(), }, null, 2); } catch { return JSON.stringify({ error: 'Failed to convert content', original: content, }); } } /** * Send HTTP POST request to webhook * @param url - Webhook URL * @param payload - Payload to send * @param options - Request options */ async sendHttpPost(url, payload, options) { // Using dynamic import for better compatibility const https = await import('https'); const http = await import('http'); return new Promise((resolve, reject) => { const urlObj = new URL(url); const isHttps = urlObj.protocol === 'https:'; const client = isHttps ? https : http; const postData = JSON.stringify(payload); const requestOptions = { hostname: urlObj.hostname, port: urlObj.port || (isHttps ? 443 : 80), path: urlObj.pathname + urlObj.search, method: 'POST', headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(postData), 'User-Agent': 'WOARU-MessageHandler/1.0', }, timeout: SECURITY_LIMITS.TIMEOUT_MS, }; const req = client.request(requestOptions, res => { let responseData = ''; res.on('data', chunk => { responseData += chunk; }); res.on('end', () => { if (res.statusCode && res.statusCode >= 200 && res.statusCode < 300) { if (options.verbose) { console.log(chalk.gray(` Response: ${res.statusCode} ${responseData.substring(0, 100)}`)); } resolve(); } else { reject(new Error(`HTTP ${res.statusCode}: ${responseData}`)); } }); }); req.on('error', error => { reject(new Error(`Request failed: ${error.message}`)); }); req.on('timeout', () => { req.destroy(); reject(new Error('Request timeout')); }); req.write(postData); req.end(); }); } } //# sourceMappingURL=MessageHandler.js.map