Skip to content
Open
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 @@ -40,6 +40,10 @@ npx @hailbytes/sbom-diff old.json new.json --format json

# Output as Markdown (great for PR comments)
npx @hailbytes/sbom-diff old.json new.json --format markdown

# Show help or print the installed version
npx @hailbytes/sbom-diff --help
npx @hailbytes/sbom-diff --version
```

### Programmatic
Expand Down
22 changes: 22 additions & 0 deletions src/__tests__/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,4 +37,26 @@ describe('parseArgs', () => {
it('throws on an unknown option', () => {
expect(() => parseArgs(['old.json', 'new.json', '--bogus'])).toThrow(/Unknown option/);
});

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);
});
});
61 changes: 59 additions & 2 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 All @@ -16,9 +17,31 @@ import type { ReportFormat } from './types.js';
const USAGE = 'Usage: sbom-diff <old.json> <new.json> [--format text|json|markdown]';
const VALID_FORMATS: ReportFormat[] = ['text', 'json', 'markdown'];

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;
/** true when -h/--help was requested */
help: boolean;
/** true when -v/--version was requested */
version: boolean;
}

/**
Expand All @@ -27,9 +50,19 @@ export interface ParsedArgs {
* Supports `--format text`, `--format=text`, and flags appearing in any
* position relative to the positional file paths. Defaults to `text`.
*
* `-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', help: true, version: false };
}
if (argv.some(a => a === '-v' || a === '-V' || a === '--version')) {
return { positional: [], format: 'text', help: false, version: true };
}

const positional: string[] = [];
let format: ReportFormat = 'text';

Expand All @@ -46,7 +79,21 @@ export function parseArgs(argv: string[]): ParsedArgs {
}
}

return { positional, format };
return { positional, format, 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 All @@ -59,7 +106,17 @@ function assertFormat(value: string | undefined): ReportFormat {
}

async function main(): Promise<void> {
const { positional, format } = parseArgs(process.argv.slice(2));
const { positional, format, 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