From 032e82db3ed87050d74cca5ea55d4615edbc331b Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 11 Jun 2026 03:05:16 +0000 Subject: [PATCH] fix(cli): default --format to text instead of crashing The most basic documented invocation, `sbom-diff old.json new.json`, crashed with `Error: Unsupported format: `. When `--format` was absent, `args.indexOf('--format')` returned -1, so `args[-1 + 1]` resolved to the first positional argument (the old SBOM path). That non-undefined value defeated the `?? 'text'` default and was passed to renderReport, which threw. Extract argument parsing into a testable `parseArgs` (src/args.ts) that: - defaults the format to `text` when --format is omitted - supports both `--format json` and `--format=json` - validates the format and prints a clear one-line error on bad input Add unit tests covering the default, both flag forms, and error cases. https://claude.ai/code/session_01YMe4qfgnC6BuCvLreBNnkQ --- CHANGELOG.md | 4 ++++ package-lock.json | 33 +++-------------------------- src/__tests__/args.test.ts | 27 ++++++++++++++++++++++++ src/args.ts | 43 ++++++++++++++++++++++++++++++++++++++ src/cli.ts | 18 +++++++--------- 5 files changed, 85 insertions(+), 40 deletions(-) create mode 100644 src/__tests__/args.test.ts create mode 100644 src/args.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index b4f3c2a..6a132db 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ## [Unreleased] +### Fixed +- CLI no longer crashes on the default (no `--format`) invocation. When `--format` was omitted, the flag value fell back to the first positional path, producing `Error: Unsupported format: `. The format now correctly defaults to `text`, and an unknown `--format` value prints a clear one-line error instead of a stack trace. + ### Added +- `src/args.ts` — extracted, unit-tested CLI argument parsing (`parseArgs`) - Real devDependencies: `typescript`, `vitest`, `@vitest/coverage-v8`, `typescript-eslint`, `@types/node` - `src/types.ts` — Full domain model: `SBOM`, `Component`, `CVEEntry`, `ChangeReport`, `VersionChange`, `SBOMFormat`, `ReportFormat` - `src/parser.ts` — `parse()` / `parseCycloneDX()` / `parseSPDX()`: auto-detect and parse CycloneDX + SPDX JSON SBOMs, extracts purls, ecosystems, licenses, suppliers, CVEs diff --git a/package-lock.json b/package-lock.json index 0934876..72185ed 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,6 +19,9 @@ "typescript": "^5.9.3", "typescript-eslint": "^8.59.3", "vitest": "^4.1.6" + }, + "engines": { + "node": ">=18.0.0" } }, "node_modules/@babel/helper-string-parser": { @@ -492,9 +495,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -512,9 +512,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -532,9 +529,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -552,9 +546,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -572,9 +563,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -592,9 +580,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2187,9 +2172,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -2211,9 +2193,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -2235,9 +2214,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -2259,9 +2235,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ diff --git a/src/__tests__/args.test.ts b/src/__tests__/args.test.ts new file mode 100644 index 0000000..40f5890 --- /dev/null +++ b/src/__tests__/args.test.ts @@ -0,0 +1,27 @@ +import { describe, it, expect } from 'vitest'; +import { parseArgs } from '../args.js'; + +describe('parseArgs', () => { + it('defaults to text format when --format is omitted', () => { + const args = parseArgs(['old.json', 'new.json']); + expect(args).toEqual({ oldPath: 'old.json', newPath: 'new.json', format: 'text' }); + }); + + it('parses the spaced form: --format json', () => { + const args = parseArgs(['old.json', 'new.json', '--format', 'json']); + expect(args.format).toBe('json'); + }); + + it('parses the inline form: --format=markdown', () => { + const args = parseArgs(['old.json', 'new.json', '--format=markdown']); + expect(args.format).toBe('markdown'); + }); + + it('throws when fewer than two positional args are given', () => { + expect(() => parseArgs(['only-one.json'])).toThrow(/Usage:/); + }); + + it('throws on an unknown format', () => { + expect(() => parseArgs(['old.json', 'new.json', '--format', 'xml'])).toThrow(/Invalid format/); + }); +}); diff --git a/src/args.ts b/src/args.ts new file mode 100644 index 0000000..c55d0a6 --- /dev/null +++ b/src/args.ts @@ -0,0 +1,43 @@ +import type { ReportFormat } from './types.js'; + +/** Report formats the CLI accepts. */ +export const VALID_FORMATS: readonly ReportFormat[] = ['text', 'json', 'markdown']; + +/** Parsed CLI arguments. */ +export interface CliArgs { + oldPath: string; + newPath: string; + format: ReportFormat; +} + +const USAGE = 'Usage: sbom-diff [--format text|json|markdown]'; + +/** + * Parse CLI arguments (the slice after `node cli.js`). + * + * Supports both `--format=json` and `--format json`. When `--format` is + * omitted, the format defaults to `text`. + * + * Throws an Error with a user-facing message on invalid input. + */ +export function parseArgs(argv: string[]): CliArgs { + const positional = argv.filter((a) => !a.startsWith('--')); + if (positional.length < 2) { + throw new Error(USAGE); + } + const [oldPath, newPath] = positional; + + // Support `--format=json` (inline) and `--format json` (spaced). + const inlineFormat = argv.find((a) => a.startsWith('--format='))?.split('=')[1]; + const flagIndex = argv.indexOf('--format'); + const spacedFormat = flagIndex !== -1 ? argv[flagIndex + 1] : undefined; + const format = inlineFormat ?? spacedFormat ?? 'text'; + + if (!VALID_FORMATS.includes(format as ReportFormat)) { + throw new Error( + `Invalid format: "${format}". Valid formats: ${VALID_FORMATS.join(', ')}.`, + ); + } + + return { oldPath, newPath, format: format as ReportFormat }; +} diff --git a/src/cli.ts b/src/cli.ts index 986eb1a..8550365 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -10,22 +10,20 @@ import { readFile } from 'node:fs/promises'; import { parse } from './parser.js'; import { diff } from './diff.js'; import { renderReport } from './reporter.js'; +import { parseArgs } from './args.js'; import type { ReportFormat } from './types.js'; async function main(): Promise { - const args = process.argv.slice(2); - - const positional = args.filter(a => !a.startsWith('--')); - if (positional.length < 2) { - console.error('Usage: sbom-diff [--format text|json|markdown]'); + let oldPath: string; + let newPath: string; + let format: ReportFormat; + try { + ({ oldPath, newPath, format } = parseArgs(process.argv.slice(2))); + } catch (err) { + console.error((err as Error).message); process.exit(1); } - 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 [oldRaw, newRaw] = await Promise.all([ readFile(oldPath, 'utf-8'), readFile(newPath, 'utf-8'),