From 73632cf500fa4758b020139d86d2252a8d0c3fa4 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 2 Jun 2026 03:08:31 +0000 Subject: [PATCH] fix(cli): default to text format when --format is omitted The default documented invocation `sbom-diff old.json new.json` crashed with "Unsupported format". When --format was absent, args.indexOf('--format') returned -1, so args[0] (the old file path) was used as the format value and renderReport() threw. - Extract a tested resolveFormat() helper supporting both `--format x` and `--format=x`, defaulting to 'text' when absent/empty - Validate the value and emit a clean one-line error (no stack trace) for unsupported formats - Guard main() so it only runs when invoked directly, enabling unit tests - Add cli.test.ts regression coverage - Fix the README programmatic example to match the real API (parse + diff on SBOM objects, not file paths) https://claude.ai/code/session_014i1tcsdNL9uaFYgd34bTV6 --- README.md | 15 +++++++----- src/__tests__/cli.test.ts | 28 ++++++++++++++++++++++ src/cli.ts | 50 +++++++++++++++++++++++++++++++++------ 3 files changed, 80 insertions(+), 13 deletions(-) create mode 100644 src/__tests__/cli.test.ts diff --git a/README.md b/README.md index 837611a..590cc5b 100644 --- a/README.md +++ b/README.md @@ -44,14 +44,17 @@ npx @hailbytes/sbom-diff old.json new.json --format markdown ### Programmatic ```ts -import { diff } from '@hailbytes/sbom-diff'; +import { readFile } from 'node:fs/promises'; +import { parse, diff } from '@hailbytes/sbom-diff'; -const report = await diff('old.cdx.json', 'new.cdx.json'); +const oldSBOM = parse(await readFile('old.cdx.json', 'utf-8')); +const newSBOM = parse(await readFile('new.cdx.json', 'utf-8')); +const report = diff(oldSBOM, newSBOM); -console.log(report.added); // Component[] — newly added packages -console.log(report.removed); // Component[] — packages removed -console.log(report.upgraded); // { from: Component, to: Component }[] -console.log(report.newCVEs); // CVE[] — vulnerabilities in new packages +console.log(report.added); // Component[] — newly added packages +console.log(report.removed); // Component[] — packages removed +console.log(report.upgraded); // VersionChange[] — { component, from, to, isMajorBump } +console.log(report.newCVEs); // CVEEntry[] — vulnerabilities in new packages ``` --- diff --git a/src/__tests__/cli.test.ts b/src/__tests__/cli.test.ts new file mode 100644 index 0000000..f20563b --- /dev/null +++ b/src/__tests__/cli.test.ts @@ -0,0 +1,28 @@ +import { describe, it, expect } from 'vitest'; +import { resolveFormat } from '../cli.js'; + +describe('resolveFormat', () => { + it('defaults to text when --format is absent (regression for #5)', () => { + // Previously `args.indexOf('--format')` returned -1, so args[0] (the old + // file path) was used as the format and renderReport threw. + expect(resolveFormat(['old.json', 'new.json'])).toBe('text'); + }); + + it('parses `--format json`', () => { + expect(resolveFormat(['old.json', 'new.json', '--format', 'json'])).toBe('json'); + }); + + it('parses `--format=markdown`', () => { + expect(resolveFormat(['old.json', 'new.json', '--format=markdown'])).toBe('markdown'); + }); + + it('defaults to text when --format has no value', () => { + expect(resolveFormat(['old.json', 'new.json', '--format'])).toBe('text'); + }); + + it('throws a helpful error on an unsupported format', () => { + expect(() => resolveFormat(['old.json', 'new.json', '--format', 'xml'])).toThrow( + /Unsupported format: xml/, + ); + }); +}); diff --git a/src/cli.ts b/src/cli.ts index 986eb1a..36d860a 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -7,11 +7,46 @@ */ import { readFile } from 'node:fs/promises'; +import { pathToFileURL } from 'node:url'; import { parse } from './parser.js'; import { diff } from './diff.js'; import { renderReport } from './reporter.js'; import type { ReportFormat } from './types.js'; +const VALID_FORMATS: readonly ReportFormat[] = ['text', 'json', 'markdown']; + +/** + * Resolve the requested report format from CLI args. + * + * Supports both `--format=json` and `--format json` syntax. Defaults to + * 'text' when the flag is absent or has no value. + * + * @throws if a `--format` value is supplied that is not a supported format. + */ +export function resolveFormat(args: string[]): ReportFormat { + // `--format=value` + const inline = args.find(a => a.startsWith('--format=')); + let value: string | undefined; + if (inline) { + value = inline.slice('--format='.length); + } else { + // `--format value` + const idx = args.indexOf('--format'); + if (idx !== -1) { + const next = args[idx + 1]; + if (next !== undefined && !next.startsWith('--')) value = next; + } + } + + if (value === undefined || value === '') return 'text'; + if (!VALID_FORMATS.includes(value as ReportFormat)) { + throw new Error( + `Unsupported format: ${value}. Valid formats: ${VALID_FORMATS.join(', ')}.`, + ); + } + return value as ReportFormat; +} + async function main(): Promise { const args = process.argv.slice(2); @@ -22,9 +57,7 @@ async function main(): Promise { } const [oldPath, newPath] = positional; - const formatArg = args.find(a => a.startsWith('--format='))?.split('=')[1] - ?? args[args.indexOf('--format') + 1]; - const format: ReportFormat = (formatArg as ReportFormat) ?? 'text'; + const format = resolveFormat(args); const [oldRaw, newRaw] = await Promise.all([ readFile(oldPath, 'utf-8'), @@ -38,7 +71,10 @@ async function main(): Promise { console.log(renderReport(report, format)); } -main().catch(err => { - console.error(err); - process.exit(1); -}); +// Only run when invoked directly (not when imported by tests). +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + main().catch(err => { + console.error(err instanceof Error ? err.message : err); + process.exit(1); + }); +}