import { Command } from 'commander'; import chalk from 'chalk'; import ora from 'ora'; import axios from 'axios'; import fs from 'fs/promises'; import path from 'path'; import inquirer from 'inquirer'; import * as diff from 'diff'; import { glob } from 'glob'; import { getApiKey, getApiUrl, getProjectId } from '../utils/config.js'; import { readGitignore, shouldIgnore, isCodeFile } from '../utils/files.js'; interface Vulnerability { id: string; type: string; severity: string; title: string; description: string; line_number?: number; fixed_content?: string; recommendation?: string; } export function createHealCommand(): Command { return new Command('heal') .description('Automatically apply architectural antidotes to fix local violations') .argument('[path]', 'File or directory to heal', '.') .option('--dry-run', 'Show what would be changed without applying') .option('--yes', 'Automatically apply all fixes without prompting') .action(async (targetPath: string, options: { dryRun?: boolean; yes?: boolean }) => { const spinner = ora('Initializing Sentinel Healing Protocol...').start(); try { const apiKey = getApiKey(); const apiUrl = getApiUrl(); const projectId = getProjectId(); if (!projectId) { spinner.fail(chalk.red('Project context required. Run `rigstate link` first.')); return; } // NEW: Predictive Governance - Check for Entropy Insights await showPredictiveInsights(apiUrl, apiKey, projectId); // 1. Resolve files to scan const scanPath = path.resolve(process.cwd(), targetPath); const gitignorePatterns = await readGitignore(scanPath); let stats; try { stats = await fs.stat(scanPath); } catch (e) { spinner.fail(chalk.red(`Path not found: ${targetPath}`)); return; } let codeFiles: string[] = []; if (stats.isFile()) { codeFiles = [scanPath]; } else { const pattern = path.join(scanPath, '**/*'); const allFiles = await glob(pattern, { nodir: true, dot: false, ignore: ['**/node_modules/**', '**/.git/**', '**/dist/**', '**/build/**', '**/.next/**'], }); codeFiles = allFiles.filter(f => isCodeFile(f) && !shouldIgnore(path.relative(scanPath, f), gitignorePatterns)); } if (codeFiles.length === 0) { spinner.warn(chalk.yellow('No code files found to heal.')); return; } spinner.succeed(chalk.green(`Sentinel is active. Analyzing ${codeFiles.length} files...`)); let totalHealed = 0; for (const filePath of codeFiles) { const relativePath = path.relative(process.cwd(), filePath); const fileSpinner = ora(`Scanning ${relativePath}...`).start(); try { const originalContent = await fs.readFile(filePath, 'utf-8'); const response = await axios.post(`${apiUrl}/api/v1/audit`, { content: originalContent, file_path: relativePath, project_id: projectId }, { headers: { Authorization: `Bearer ${apiKey}` }, timeout: 60000 }); const vulns: Vulnerability[] = response.data.vulnerabilities || []; const fixable = vulns.find(v => v.fixed_content && v.fixed_content !== originalContent); if (fixable && fixable.fixed_content) { fileSpinner.stop(); console.log(`\n${chalk.cyan('🧬 ANTIDOTE FOUND')} for ${chalk.bold(relativePath)}`); console.log(chalk.yellow(`Issue: ${fixable.title}`)); console.log(chalk.dim(fixable.description)); if (options.dryRun) { showDiff(originalContent, fixable.fixed_content, relativePath); console.log(chalk.blue('Dry-run: No changes applied.')); continue; } const shouldApply = options.yes || await askConfirm(originalContent, fixable.fixed_content, relativePath); if (shouldApply) { await fs.writeFile(filePath, fixable.fixed_content, 'utf-8'); console.log(chalk.green(`✅ Healed: ${relativePath}`)); totalHealed++; } else { console.log(chalk.gray(`Skipped: ${relativePath}`)); } } else { fileSpinner.succeed(chalk.dim(`${relativePath}: No patterns matched.`)); } } catch (e: any) { fileSpinner.fail(chalk.red(`${relativePath}: ${e.message}`)); } } console.log(chalk.bold(`\n✨ Sentinel Report: ${totalHealed} files healed.`)); } catch (error: any) { spinner.fail(chalk.red(`Healing failed: ${error.message}`)); } }); } function showDiff(oldContent: string, newContent: string, filename: string) { const patch = diff.createTwoFilesPatch(filename, filename, oldContent, newContent); const lines = patch.split('\n'); lines.slice(4).forEach(line => { if (line.startsWith('+')) console.log(chalk.green(line)); else if (line.startsWith('-')) console.log(chalk.red(line)); else console.log(chalk.dim(line)); }); } async function askConfirm(oldContent: string, newContent: string, filename: string): Promise { console.log(chalk.bold('\nProposed Changes:')); showDiff(oldContent, newContent, filename); const { confirm } = await inquirer.prompt([ { type: 'confirm', name: 'confirm', message: `Apply this antidote to ${filename}?`, default: true } ]); return confirm; } async function showPredictiveInsights(apiUrl: string, apiKey: string, projectId: string) { try { const response = await axios.get(`${apiUrl}/api/v1/system/insights?project_id=${projectId}`, { headers: { Authorization: `Bearer ${apiKey}` } }); const insights = response.data.insights || []; if (insights.length > 0) { console.log(chalk.bold.yellow('\n📊 SENTINEL PREDICTIVE INSIGHTS:')); for (const insight of insights) { const color = insight.severity === 'critical' ? chalk.red : insight.severity === 'warning' ? chalk.yellow : chalk.blue; console.log(color(`[${insight.insight_type.toUpperCase()}] ${insight.title}`)); console.log(chalk.dim(` ${insight.message}\n`)); } } } catch (e) { // Silently skip if insights fail } }