diff --git a/README.md b/README.md index 593c8ad..9272090 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,10 @@ npx @hailbytes/sbom-diff old.json new.json --format markdown # Fail the build (exit code 3) if any new high or critical CVE appears npx @hailbytes/sbom-diff old.json new.json --fail-on high + +# Show help or print the installed version +npx @hailbytes/sbom-diff --help +npx @hailbytes/sbom-diff --version ``` ### CI/CD gate diff --git a/src/__tests__/cli.test.ts b/src/__tests__/cli.test.ts index 0fddebf..cb243f5 100644 --- a/src/__tests__/cli.test.ts +++ b/src/__tests__/cli.test.ts @@ -200,3 +200,27 @@ describe('loadSbom', () => { } }); }); + +describe('parseArgs help/version', () => { + it('sets help for --help and -h', () => { + expect(parseArgs(['--help']).help).toBe(true); + expect(parseArgs(['-h']).help).toBe(true); + }); + + it('sets version for --version and -v', () => { + expect(parseArgs(['--version']).version).toBe(true); + expect(parseArgs(['-v']).version).toBe(true); + }); + + it('short-circuits --help even alongside otherwise-invalid args', () => { + // Would normally throw on the bad --format value; --help wins instead. + expect(() => parseArgs(['--help', '--format=yaml'])).not.toThrow(); + expect(parseArgs(['--help', '--format=yaml']).help).toBe(true); + }); + + it('does not treat normal invocations as help or version', () => { + const parsed = parseArgs(['old.json', 'new.json']); + expect(parsed.help).toBe(false); + expect(parsed.version).toBe(false); + }); +}); diff --git a/src/cli.ts b/src/cli.ts index 7353a89..f3f2eab 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -7,6 +7,7 @@ */ import { readFile } from 'node:fs/promises'; +import { readFileSync } from 'node:fs'; import { pathToFileURL } from 'node:url'; import { parse } from './parser.js'; import { diff } from './diff.js'; @@ -38,10 +39,32 @@ const SEVERITY_RANK: Record, number> = { critical: 4, }; +const HELP = `sbom-diff — diff two CycloneDX or SPDX SBOMs into a change report. + +${USAGE} + +Arguments: + Baseline SBOM (the "before" document) + Updated SBOM (the "after" document) + +Options: + --format Output format: text (default), json, or markdown + -h, --help Show this help and exit + -v, --version Print the installed version and exit + +Examples: + sbom-diff old.json new.json + sbom-diff old.json new.json --format json + sbom-diff old.json new.json --format markdown`; + export interface ParsedArgs { positional: string[]; format: ReportFormat; failOn: FailOn; + /** true when -h/--help was requested */ + help: boolean; + /** true when -v/--version was requested */ + version: boolean; } /** @@ -52,9 +75,19 @@ export interface ParsedArgs { * and flags appearing in any position relative to the positional file paths. * Defaults to `text` format and a `none` gate policy. * - * @throws if an unknown flag or unsupported flag value is supplied. + * `-h`/`--help` and `-v`/`--version` short-circuit parsing so they always + * work — even alongside otherwise-invalid arguments — and never throw. + * + * @throws if an unknown flag or unsupported format value is supplied. */ export function parseArgs(argv: string[]): ParsedArgs { + if (argv.some(a => a === '-h' || a === '--help')) { + return { positional: [], format: 'text', failOn: 'none', help: true, version: false }; + } + if (argv.some(a => a === '-v' || a === '-V' || a === '--version')) { + return { positional: [], format: 'text', failOn: 'none', help: false, version: true }; + } + const positional: string[] = []; let format: ReportFormat = 'text'; let failOn: FailOn = 'none'; @@ -76,7 +109,21 @@ export function parseArgs(argv: string[]): ParsedArgs { } } - return { positional, format, failOn }; + return { positional, format, failOn, help: false, version: false }; +} + +/** + * Read the package version from the shipped package.json, resolved relative to + * this module so it works whether invoked from `dist/` or via `npx`. + */ +export function resolveVersion(): string { + try { + const pkgUrl = new URL('../package.json', import.meta.url); + const pkg = JSON.parse(readFileSync(pkgUrl, 'utf-8')) as { version?: string }; + return typeof pkg.version === 'string' ? pkg.version : '0.0.0'; + } catch { + return '0.0.0'; + } } function assertFormat(value: string | undefined): ReportFormat { @@ -169,7 +216,17 @@ export async function loadSbom(path: string, label: string): Promise { } async function main(): Promise { - const { positional, format, failOn } = parseArgs(process.argv.slice(2)); + const { positional, format, failOn, help, version } = parseArgs(process.argv.slice(2)); + + if (help) { + console.log(HELP); + return; + } + + if (version) { + console.log(resolveVersion()); + return; + } if (positional.length < 2) { console.error(USAGE);