Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
24 changes: 24 additions & 0 deletions src/__tests__/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
63 changes: 60 additions & 3 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -38,10 +39,32 @@ const SEVERITY_RANK: Record<NonNullable<CVEEntry['severity']>, number> = {
critical: 4,
};

const HELP = `sbom-diff — diff two CycloneDX or SPDX SBOMs into a change report.

${USAGE}

Arguments:
<old.json> Baseline SBOM (the "before" document)
<new.json> Updated SBOM (the "after" document)

Options:
--format <fmt> 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;
}

/**
Expand All @@ -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';
Expand All @@ -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 {
Expand Down Expand Up @@ -169,7 +216,17 @@ export async function loadSbom(path: string, label: string): Promise<SBOM> {
}

async function main(): Promise<void> {
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);
Expand Down
Loading