diff --git a/package.json b/package.json index f0cf0d8..84cdda4 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "devvami", "description": "DevEx CLI for developers and teams — manage repos, PRs, pipelines, tasks, and costs from the terminal", - "version": "1.4.1", + "version": "1.4.2", "author": "", "type": "module", "bin": { diff --git a/src/commands/vuln/scan.js b/src/commands/vuln/scan.js index 4c25444..a9e627f 100644 --- a/src/commands/vuln/scan.js +++ b/src/commands/vuln/scan.js @@ -4,7 +4,20 @@ import ora from 'ora' import chalk from 'chalk' import { detectEcosystems, supportedEcosystemsMessage } from '../../services/audit-detector.js' import { runAudit, summarizeFindings, filterBySeverity } from '../../services/audit-runner.js' -import { formatFindingsTable, formatScanSummary, formatMarkdownReport } from '../../formatters/vuln.js' +import { formatFindingsTable, formatScanSummary, formatMarkdownReport, truncate, colorSeverity } from '../../formatters/vuln.js' +import { getCveDetail } from '../../services/nvd.js' +import { startInteractiveTable } from '../../utils/tui/navigable-table.js' + +// Minimum terminal rows required to show the interactive TUI (same threshold as vuln search) +const MIN_TTY_ROWS = 6 + +// Column widths for the navigable table (match the static findings table) +const COL_WIDTHS = { + pkg: 20, + version: 12, + severity: 10, + cve: 20, +} export default class VulnScan extends Command { static description = 'Scan the current directory for known vulnerabilities in dependencies' @@ -112,7 +125,7 @@ export default class VulnScan extends Command { errors, } - // Write report if requested + // Write report if requested (always, regardless of TTY mode) if (report) { const markdown = formatMarkdownReport(result) await writeFile(report, markdown, 'utf8') @@ -128,17 +141,58 @@ export default class VulnScan extends Command { return result } - if (filteredFindings.length > 0) { - this.log(chalk.bold(` Findings (${filteredFindings.length} ${filteredFindings.length === 1 ? 'vulnerability' : 'vulnerabilities'})`)) - this.log('') - this.log(formatFindingsTable(filteredFindings)) - this.log('') - this.log(chalk.bold(' Summary')) - this.log(formatScanSummary(summary)) - this.log('') - this.log(chalk.yellow(` ⚠ ${filteredFindings.length} ${filteredFindings.length === 1 ? 'vulnerability' : 'vulnerabilities'} found. Run \`dvmi vuln detail \` for details.`)) + // ── TTY interactive table ────────────────────────────────────────────────── + // In a real TTY with enough rows and at least one finding, replace the static + // table with the navigable TUI (same experience as `dvmi vuln search`). + const ttyRows = process.stdout.rows ?? 0 + const useTUI = process.stdout.isTTY && filteredFindings.length > 0 && ttyRows >= MIN_TTY_ROWS + + if (useTUI) { + const count = filteredFindings.length + const label = count === 1 ? 'finding' : 'findings' + const heading = `Vulnerability Scan: ${count} ${label}` + + const termCols = process.stdout.columns || 80 + // Title width: whatever is left after Package + Version + Severity + CVE + separators + const fixedCols = COL_WIDTHS.pkg + COL_WIDTHS.version + COL_WIDTHS.severity + COL_WIDTHS.cve + const separators = 5 * 2 // 5 gaps between 5 columns + const titleWidth = Math.max(15, Math.min(50, termCols - fixedCols - separators)) + + const rows = filteredFindings.map((f) => ({ + id: f.cveId ?? null, + pkg: f.package, + version: f.installedVersion ?? '—', + severity: f.severity, + cve: f.cveId ?? '—', + title: truncate(f.title ?? '—', titleWidth), + advisoryUrl: f.advisoryUrl ?? null, + })) + + /** @type {import('../../utils/tui/navigable-table.js').TableColumnDef[]} */ + const columns = [ + { header: 'Package', key: 'pkg', width: COL_WIDTHS.pkg }, + { header: 'Version', key: 'version', width: COL_WIDTHS.version }, + { header: 'Severity', key: 'severity', width: COL_WIDTHS.severity, colorize: (v) => colorSeverity(v) }, + { header: 'CVE', key: 'cve', width: COL_WIDTHS.cve, colorize: (v) => (v !== '—' ? chalk.cyan(v) : chalk.gray(v)) }, + { header: 'Title', key: 'title', width: titleWidth }, + ] + + await startInteractiveTable(rows, columns, heading, filteredFindings.length, getCveDetail) + } else { + // Non-TTY fallback: static table + summary (unchanged from pre-TUI behaviour) + if (filteredFindings.length > 0) { + this.log(chalk.bold(` Findings (${filteredFindings.length} ${filteredFindings.length === 1 ? 'vulnerability' : 'vulnerabilities'})`)) + this.log('') + this.log(formatFindingsTable(filteredFindings)) + this.log('') + this.log(chalk.bold(' Summary')) + this.log(formatScanSummary(summary)) + this.log('') + this.log(chalk.yellow(` ⚠ ${filteredFindings.length} ${filteredFindings.length === 1 ? 'vulnerability' : 'vulnerabilities'} found. Run \`dvmi vuln detail \` for details.`)) + } } + // Always print audit errors (e.g. tool not installed) after findings/TUI if (errors.length > 0) { this.log('') for (const err of errors) { @@ -146,6 +200,7 @@ export default class VulnScan extends Command { } } + // Preserve exit code semantics: exit 1 when vulns found (unless --no-fail) if (filteredFindings.length > 0 && !noFail) { this.exit(1) } diff --git a/src/utils/tui/navigable-table.js b/src/utils/tui/navigable-table.js index 41db7f5..6a3509b 100644 --- a/src/utils/tui/navigable-table.js +++ b/src/utils/tui/navigable-table.js @@ -360,8 +360,16 @@ export async function startInteractiveTable(rows, columns, heading, totalResults * @returns {Promise} */ async function openDetail() { - const cveId = state.rows[state.selectedIndex]?.id + const row = state.rows[state.selectedIndex] + const cveId = row?.id + if (!cveId) { + // No CVE ID — check for an advisory URL (e.g. npm/pnpm advisory findings). + // Open it in the browser and stay in table view rather than showing a modal. + const advisoryUrl = row?.advisoryUrl + if (advisoryUrl) { + await openBrowser(String(advisoryUrl)) + } state = { ...state, currentView: 'table' } process.stdout.write(buildTableScreen(state)) return diff --git a/tests/integration/vuln-scan.test.js b/tests/integration/vuln-scan.test.js index 21f253e..85e59aa 100644 --- a/tests/integration/vuln-scan.test.js +++ b/tests/integration/vuln-scan.test.js @@ -94,6 +94,30 @@ describe('dvmi vuln scan', () => { expect(stderr).toMatch(/Expected.*severity|severity.*expected/i) }) + it('non-TTY output contains static findings table without TUI escape codes', async () => { + const tmpDir = await mkdtemp(join(tmpdir(), 'dvmi-scan-')) + try { + await writeFile(join(tmpDir, 'pnpm-lock.yaml'), 'lockfileVersion: 6.0\n', 'utf8') + + // runCli always uses isTTY=false (spawned subprocess with non-TTY stdio) + // so static table output should appear, not the interactive alt-screen TUI + const { stdout, stderr, exitCode } = await runCli(['vuln', 'scan', '--no-fail'], { + DVMI_SCAN_DIR: tmpDir, + }) + const combined = stdout + stderr + // Must not contain the ANSI alt-screen sequence used by the TUI + expect(combined).not.toContain('\x1b[?1049h') + // If findings were reported, the static table header should be present + if (combined.includes('Findings')) { + expect(combined).toMatch(/Package|Version|Severity/i) + } + // Exit code must be 0 (--no-fail) or 0 (no vulns found) + expect(exitCode).toBe(0) + } finally { + await rm(tmpDir, { recursive: true, force: true }) + } + }) + it('outputs valid JSON structure with --json flag when vulns exist', async () => { const tmpDir = await mkdtemp(join(tmpdir(), 'dvmi-scan-')) try {