diff --git a/e2e/scan/eol.test.ts b/e2e/scan/eol.test.ts index 9f42ac1d..81096038 100644 --- a/e2e/scan/eol.test.ts +++ b/e2e/scan/eol.test.ts @@ -108,6 +108,30 @@ describe('scan:eol e2e', () => { doesNotThrow(() => JSON.parse(stdout)); }); + it('displays results in table format when using the -t flag', async () => { + const cmd = `scan:eol --dir ${simpleDir} -t`; + const { stdout } = await run(cmd); + + // Match table header + match(stdout, /┌.*┬.*┬.*┬.*┬.*┐/, 'Should show table top border'); + match( + stdout, + /│ NAME\s*│ VERSION\s*│ EOL\s*│ DAYS EOL\s*│ TYPE\s*│/, // TODO: add vulns to monorepo api + 'Should show table headers', + ); + match(stdout, /├.*┼.*┼.*┼.*┼.*┤/, 'Should show table header separator'); + + // Match table content + match( + stdout, + /│ bootstrap\s*│ 3\.1\.1\s*│ 2019-07-24\s*│ \d+\s*│ npm\s*│/, + 'Should show bootstrap package in table', + ); + + // Match table footer + match(stdout, /└.*┴.*┴.*┴.*┴.*┘/, 'Should show table bottom border'); + }); + describe('--all flag', () => { it('excludes OK packages by default', async () => { const cmd = `scan:eol --dir ${simpleDir}`; diff --git a/package-lock.json b/package-lock.json index 8c41ac13..343edca3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,7 +14,9 @@ "@oclif/core": "^4", "@oclif/plugin-help": "^6", "@oclif/plugin-update": "^4", - "graphql": "^16.8.1" + "cli-table3": "^0.6.5", + "graphql": "^16.8.1", + "packageurl-js": "^2.0.1" }, "bin": { "hd": "bin/run.js" @@ -1976,6 +1978,16 @@ "license": "(Apache-2.0 AND BSD-3-Clause)", "optional": true }, + "node_modules/@colors/colors": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.5.0.tgz", + "integrity": "sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.1.90" + } + }, "node_modules/@cspotcode/source-map-support": { "version": "0.8.1", "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", @@ -2333,6 +2345,12 @@ "node": ">=14.16" } }, + "node_modules/@cyclonedx/cdxgen/node_modules/packageurl-js": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/packageurl-js/-/packageurl-js-1.0.2.tgz", + "integrity": "sha512-fWC4ZPxo80qlh3xN5FxfIoQD3phVY4+EyzTIqyksjhKNDmaicdpxSvkWwIrYTtv9C1/RcUN6pxaTwGmj2NzS6A==", + "license": "MIT" + }, "node_modules/@cyclonedx/cdxgen/node_modules/type-fest": { "version": "4.38.0", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.38.0.tgz", @@ -6128,6 +6146,21 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/cli-table3": { + "version": "0.6.5", + "resolved": "https://registry.npmjs.org/cli-table3/-/cli-table3-0.6.5.tgz", + "integrity": "sha512-+W/5efTR7y5HRD7gACw9yQjqMVvEMLBHmboM/kPWam+H+Hmyrgjh6YncVKK122YZkXrLudzTuAukUw9FnMf7IQ==", + "license": "MIT", + "dependencies": { + "string-width": "^4.2.0" + }, + "engines": { + "node": "10.* || >= 12.*" + }, + "optionalDependencies": { + "@colors/colors": "1.5.0" + } + }, "node_modules/cli-truncate": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-4.0.0.tgz", @@ -10455,9 +10488,9 @@ "license": "BlueOak-1.0.0" }, "node_modules/packageurl-js": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/packageurl-js/-/packageurl-js-1.0.2.tgz", - "integrity": "sha512-fWC4ZPxo80qlh3xN5FxfIoQD3phVY4+EyzTIqyksjhKNDmaicdpxSvkWwIrYTtv9C1/RcUN6pxaTwGmj2NzS6A==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/packageurl-js/-/packageurl-js-2.0.1.tgz", + "integrity": "sha512-N5ixXjzTy4QDQH0Q9YFjqIWd6zH6936Djpl2m9QNFmDv5Fum8q8BjkpAcHNMzOFE0IwQrFhJWex3AN6kS0OSwg==", "license": "MIT" }, "node_modules/pacote": { diff --git a/package.json b/package.json index e1bba91f..b2dfc5a2 100644 --- a/package.json +++ b/package.json @@ -42,7 +42,9 @@ "@oclif/core": "^4", "@oclif/plugin-help": "^6", "@oclif/plugin-update": "^4", - "graphql": "^16.8.1" + "cli-table3": "^0.6.5", + "graphql": "^16.8.1", + "packageurl-js": "^2.0.1" }, "devDependencies": { "@biomejs/biome": "^1.8.3", diff --git a/src/commands/scan/eol.ts b/src/commands/scan/eol.ts index c771b55e..b8ef071b 100644 --- a/src/commands/scan/eol.ts +++ b/src/commands/scan/eol.ts @@ -1,15 +1,15 @@ import fs from 'node:fs'; import path from 'node:path'; import { Command, Flags, ux } from '@oclif/core'; +import type { Table } from 'cli-table3'; import { batchSubmitPurls } from '../../api/nes/nes.client.ts'; import type { ScanResult } from '../../api/types/hd-cli.types.js'; -import type { InsightsEolScanComponent } from '../../api/types/nes.types.ts'; +import type { ComponentStatus, InsightsEolScanComponent } from '../../api/types/nes.types.ts'; import type { Sbom } from '../../service/eol/cdx.svc.ts'; import { getErrorMessage, isErrnoException } from '../../service/error.svc.ts'; -import { extractPurls } from '../../service/purls.svc.ts'; -import { parsePurlsFile } from '../../service/purls.svc.ts'; -import { createStatusDisplay } from '../../ui/eol.ui.ts'; -import { INDICATORS, STATUS_COLORS } from '../../ui/shared.us.ts'; +import { extractPurls, parsePurlsFile } from '../../service/purls.svc.ts'; +import { createStatusDisplay, createTableForStatus } from '../../ui/eol.ui.ts'; +import { INDICATORS, STATUS_COLORS } from '../../ui/shared.ui.ts'; import ScanSbom from './sbom.ts'; export default class ScanEol extends Command { @@ -44,9 +44,9 @@ export default class ScanEol extends Command { description: 'Show all components (default is EOL and LTS only)', default: false, }), - getCustomerSupport: Flags.boolean({ - char: 'c', - description: 'Get Never-Ending Support for End-of-Life components', + table: Flags.boolean({ + char: 't', + description: 'Display the results in a table', default: false, }), }; @@ -54,10 +54,6 @@ export default class ScanEol extends Command { public async run(): Promise<{ components: InsightsEolScanComponent[] }> { const { flags } = await this.parse(ScanEol); - if (flags.getCustomerSupport) { - this.log(ux.colorize('yellow', 'Never-Ending Support is on the way. Please stay tuned for this feature.')); - } - const scan = await this.getScan(flags, this.config); ux.action.stop('\nScan completed'); @@ -69,7 +65,12 @@ export default class ScanEol extends Command { } if (!this.jsonEnabled()) { - await this.displayResults(scan, flags.all); + if (flags.table) { + this.log(`${scan.components.size} components scanned`); + this.displayResultsInTable(scan, flags.all); + } else { + this.displayResults(scan, flags.all); + } } return { components }; @@ -147,7 +148,7 @@ export default class ScanEol extends Command { } } - private async displayResults(scan: ScanResult, all: boolean): Promise { + private displayResults(scan: ScanResult, all: boolean) { const { UNKNOWN, OK, LTS, EOL } = createStatusDisplay(scan.components, all); if (!UNKNOWN.length && !OK.length && !LTS.length && !EOL.length) { @@ -166,6 +167,27 @@ export default class ScanEol extends Command { this.logLegend(); } + private displayResultsInTable(scan: ScanResult, all: boolean) { + const statuses: ComponentStatus[] = ['LTS', 'EOL']; + + if (all) { + statuses.unshift('UNKNOWN', 'OK'); + } + + for (const status of statuses) { + const table = createTableForStatus(scan.components, status); + + if (table.length > 0) { + this.displayTable(table, table.length, status); + } + } + } + + private displayTable(table: Table, count: number, status: ComponentStatus): void { + this.log(ux.colorize(STATUS_COLORS[status], `${INDICATORS[status]} ${count} ${status} Component(s):`)); + this.log(ux.colorize(STATUS_COLORS[status], table.toString())); + } + private displayNoComponentsMessage(all: boolean): void { if (!all) { this.log(ux.colorize('yellow', 'No End-of-Life or Long Term Support components found in scan.')); diff --git a/src/ui/eol.ui.ts b/src/ui/eol.ui.ts index 46cf1fab..40864137 100644 --- a/src/ui/eol.ui.ts +++ b/src/ui/eol.ui.ts @@ -1,11 +1,14 @@ import { ux } from '@oclif/core'; +import Table from 'cli-table3'; +import { PackageURL } from 'packageurl-js'; import type { ScanResultComponentsMap } from '../api/types/hd-cli.types.ts'; -import type { ComponentStatus } from '../api/types/nes.types.ts'; +import type { ComponentStatus, InsightsEolScanComponent } from '../api/types/nes.types.ts'; import { parseMomentToSimpleDate } from './date.ui.ts'; -import { INDICATORS, STATUS_COLORS } from './shared.us.ts'; +import { INDICATORS, MAX_PURL_LENGTH, MAX_TABLE_COLUMN_WIDTH, STATUS_COLORS } from './shared.ui.ts'; -export function truncatePurl(purl: string): string { - return purl.length > 60 ? `${purl.slice(0, 57)}...` : purl; +export function truncateString(purl: string, maxLength: number): string { + const ellipses = '...'; + return purl.length > maxLength ? `${purl.slice(0, maxLength - ellipses.length)}${ellipses}` : purl; } export function colorizeStatus(status: ComponentStatus): string { @@ -14,7 +17,7 @@ export function colorizeStatus(status: ComponentStatus): string { function formatSimpleComponent(purl: string, status: ComponentStatus): string { const color = STATUS_COLORS[status]; - return ` ${INDICATORS[status]} ${ux.colorize(color, truncatePurl(purl))}`; + return ` ${INDICATORS[status]} ${ux.colorize(color, truncateString(purl, MAX_PURL_LENGTH))}`; } function getDaysEolString(daysEol: number | null): string { @@ -77,3 +80,39 @@ export function createStatusDisplay( return statusOutput; } + +export function createTableForStatus(components: ScanResultComponentsMap, status: ComponentStatus): Table.Table { + const table = new Table({ + head: ['NAME', 'VERSION', 'EOL', 'DAYS EOL', 'TYPE'], + colWidths: [MAX_TABLE_COLUMN_WIDTH, 10, 12, 10, 12], + wordWrap: true, + style: { + 'padding-left': 1, + 'padding-right': 1, + head: [], + border: [], + }, + }); + + for (const component of components.values()) { + if (component.info.status !== status) continue; + + const row = convertComponentToTableRow(component); + table.push(row); + } + return table; +} + +export function convertComponentToTableRow(component: InsightsEolScanComponent) { + const purlParts = PackageURL.fromString(component.purl); + const { eolAt, daysEol } = component.info; + + return [ + { content: purlParts.name }, + { content: purlParts.version }, + { content: parseMomentToSimpleDate(eolAt) }, + { content: daysEol }, + { content: purlParts.type }, + // vulns: component.vulns.length, // TODO: add vulns to monorepo api + ]; +} diff --git a/src/ui/shared.us.ts b/src/ui/shared.ui.ts similarity index 86% rename from src/ui/shared.us.ts rename to src/ui/shared.ui.ts index 7e7483f8..1d90ad6a 100644 --- a/src/ui/shared.us.ts +++ b/src/ui/shared.ui.ts @@ -14,3 +14,7 @@ export const INDICATORS: Record = { OK: ux.colorize(STATUS_COLORS.OK, '✔'), LTS: ux.colorize(STATUS_COLORS.LTS, '⚡'), }; + +export const MAX_PURL_LENGTH = 60; + +export const MAX_TABLE_COLUMN_WIDTH = 30; diff --git a/test/ui/eol.ui.test.ts b/test/ui/eol.ui.test.ts index a8001164..138444bd 100644 --- a/test/ui/eol.ui.test.ts +++ b/test/ui/eol.ui.test.ts @@ -1,30 +1,101 @@ import assert from 'node:assert'; import { describe, it } from 'node:test'; -import { truncatePurl } from '../../src/ui/eol.ui.ts'; +import { convertComponentToTableRow, createTableForStatus, truncateString } from '../../src/ui/eol.ui.ts'; +import { createMockComponent, createMockScan } from '../utils/mocks/scan-result-component.mock.ts'; describe('EOL UI', () => { - describe('truncatePurl', () => { - it('returns original PURL if length is 60 or less', () => { + describe('truncateString', () => { + it('returns original PURL if length is sixty characters or less', () => { // Arrange const purl = 'pkg:npm/test@1.0.0'; // Act - const result = truncatePurl(purl); + const result = truncateString(purl, 60); // Assert assert.strictEqual(result, purl); }); - it('truncates PURL if length is greater than 60', () => { + it('truncates PURL if length is greater than sixty characters', () => { // Arrange const longPurl = 'pkg:npm/very-long-package-name-that-exceeds-sixty-characters-significantly@1.0.0'; const expected = `${longPurl.slice(0, 57)}...`; // Act - const result = truncatePurl(longPurl); + const result = truncateString(longPurl, 60); // Assert assert.strictEqual(result, expected); }); }); + + describe('convertComponentToTableRow', () => { + it('converts a component to a table row', () => { + // Arrange + const component = createMockComponent( + 'pkg:npm/very-long-package-name-that-exceeds-thirty-characters@1.0.0', + 'EOL', + new Date('2023-01-01'), + 365, + ); + + // Act + const result = convertComponentToTableRow(component); + + // Assert + assert.strictEqual(result.length, 5); + assert.strictEqual(result[0].content, 'very-long-package-name-that-exceeds-thirty-characters'); + assert.strictEqual(result[1].content, '1.0.0'); + assert.strictEqual(result[2].content, '2023-01-01'); + assert.strictEqual(result[3].content, 365); + assert.strictEqual(result[4].content, 'npm'); + }); + + it('handles null values for eolAt and daysEol', () => { + // Arrange + const component = createMockComponent('pkg:npm/test@1.0.0', 'OK'); + + // Act + const result = convertComponentToTableRow(component); + + // Assert + assert.strictEqual(result.length, 5); + assert.strictEqual(result[0].content, 'test'); + assert.strictEqual(result[1].content, '1.0.0'); + assert.strictEqual(result[2].content, ''); + assert.strictEqual(result[3].content, null); + assert.strictEqual(result[4].content, 'npm'); + }); + }); + + describe('createTableForStatus', () => { + it('creates a table with components of matching status', () => { + // Arrange + const components = createMockScan([ + createMockComponent('pkg:npm/test1@1.0.0', 'EOL', new Date('2023-01-01'), 365), + createMockComponent('pkg:npm/test2@2.0.0', 'OK'), + createMockComponent('pkg:npm/test3@3.0.0', 'EOL', new Date('2023-02-01'), 400), + ]).components; + + // Act + const table = createTableForStatus(components, 'EOL'); + + // Assert + assert.strictEqual(table.length, 2); + assert.strictEqual(table.length, 2); // Only data rows (excluding header) + assert.deepStrictEqual(table.options.head, ['NAME', 'VERSION', 'EOL', 'DAYS EOL', 'TYPE']); + assert.deepStrictEqual(table.options.colWidths, [30, 10, 12, 10, 12]); + }); + + it('returns empty table when no components match status', () => { + // Arrange + const components = createMockScan([createMockComponent('pkg:npm/test1@1.0.0', 'OK')]).components; + + // Act + const table = createTableForStatus(components, 'EOL'); + + // Assert + assert.strictEqual(table.length, 0); // No data rows + }); + }); });