@herodevs/cli 2.0.0-beta.10 → 2.0.0-beta.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -10,6 +10,11 @@ The HeroDevs CLI
10
10
  * [@herodevs/cli](#herodevscli)
11
11
  <!-- tocstop -->
12
12
 
13
+ ### Terms and Data Security
14
+
15
+ - [HeroDevs End of Life Dataset Terms of Service and Data Policy](https://docs.herodevs.com/legal/end-of-life-dataset-terms)
16
+ - [HeroDevs End of Life Dataset Data Privacy and Security](https://docs.herodevs.com/eol-ds/data-privacy-and-security)
17
+
13
18
  ### Prerequisites
14
19
 
15
20
  - Install node v20 or higher: [Download Node](https://nodejs.org/en/download)
@@ -38,17 +43,13 @@ npm install -g @herodevs/cli@beta
38
43
  HeroDevs CLI is available as a binary installation, without requiring `npm`. To do that, you may either download and run the script manually, or use the following cURL or Wget command:
39
44
 
40
45
  ```sh
41
- curl -o- https://raw.githubusercontent.com/herodevs/cli/v2.0.0-beta.10/scripts/install.sh | bash
46
+ curl -o- https://raw.githubusercontent.com/herodevs/cli/v2.0.0-beta.12/scripts/install.sh | bash
42
47
  ```
43
48
 
44
49
  ```sh
45
- wget -qO- https://raw.githubusercontent.com/herodevs/cli/v2.0.0-beta.10/scripts/install.sh | bash
50
+ wget -qO- https://raw.githubusercontent.com/herodevs/cli/v2.0.0-beta.12/scripts/install.sh | bash
46
51
  ```
47
52
 
48
- ## TERMS
49
-
50
- Use of this CLI is governed by the [HeroDevs End of Life Dataset Terms of Service and Data Policy](https://docs.herodevs.com/legal/end-of-life-dataset-terms).
51
-
52
53
  ## Scanning Behavior
53
54
 
54
55
  The CLI is designed to be non-invasive:
@@ -103,7 +104,7 @@ DESCRIPTION
103
104
  Display help for hd.
104
105
  ```
105
106
 
106
- _See code: [@oclif/plugin-help](https://github.com/oclif/plugin-help/blob/v6.2.32/src/commands/help.ts)_
107
+ _See code: [@oclif/plugin-help](https://github.com/oclif/plugin-help/blob/v6.2.33/src/commands/help.ts)_
107
108
 
108
109
  ## `hd scan eol`
109
110
 
@@ -111,14 +112,18 @@ Scan a given SBOM for EOL data
111
112
 
112
113
  ```
113
114
  USAGE
114
- $ hd scan eol [--json] [-f <value> | -d <value>] [-s] [--saveSbom] [--version]
115
+ $ hd scan eol [--json] [-f <value> | -d <value>] [-s] [-o <value>] [--saveSbom] [--sbomOutput <value>] [--saveTrimmedSbom] [--hideReportUrl] [--version]
115
116
 
116
117
  FLAGS
117
- -d, --dir=<value> [default: <current directory>] The directory to scan in order to create a cyclonedx SBOM
118
- -f, --file=<value> The file path of an existing cyclonedx SBOM to scan for EOL
119
- -s, --save Save the generated report as herodevs.report.json in the scanned directory
120
- --saveSbom Save the generated SBOM as herodevs.sbom.json in the scanned directory
121
- --version Show CLI version.
118
+ -d, --dir=<value> [default: <current directory>] The directory to scan in order to scan for EOL
119
+ -f, --file=<value> The file path of an existing SBOM to scan for EOL (supports CycloneDX and SPDX 2.3 formats)
120
+ -s, --save Save the generated report as herodevs.report.json in the scanned directory
121
+ -o, --output=<value> Save the generated report to a custom path (requires --save, defaults to herodevs.report.json when not provided)
122
+ --hideReportUrl Hide the generated web report URL for this scan
123
+ --saveSbom Save the generated SBOM as herodevs.sbom.json in the scanned directory
124
+ --sbomOutput=<value> Save the generated SBOM to a custom path (requires --saveSbom, defaults to herodevs.sbom.json when not provided)
125
+ --saveTrimmedSbom Save the trimmed SBOM as herodevs.sbom-trimmed.json in the scanned directory
126
+ --version Show CLI version.
122
127
 
123
128
  GLOBAL FLAGS
124
129
  --json Format output as json.
@@ -143,6 +148,10 @@ EXAMPLES
143
148
 
144
149
  $ hd scan eol --save --saveSbom
145
150
 
151
+ Save the report and SBOM to custom paths
152
+
153
+ $ hd scan eol --dir . --save --saveSbom --output ./reports/my-report.json --sbomOutput ./reports/my-sbom.json
154
+
146
155
  Output the report in JSON format (for APIs, CI, etc.)
147
156
 
148
157
  $ hd scan eol --json
@@ -188,7 +197,7 @@ EXAMPLES
188
197
  $ hd update --available
189
198
  ```
190
199
 
191
- _See code: [@oclif/plugin-update](https://github.com/oclif/plugin-update/blob/v4.7.4/src/commands/update.ts)_
200
+ _See code: [@oclif/plugin-update](https://github.com/oclif/plugin-update/blob/v4.7.8/src/commands/update.ts)_
192
201
  <!-- commandsstop -->
193
202
 
194
203
  ## CI/CD Usage
@@ -200,6 +209,8 @@ You can use `@herodevs/cli` in your CI/CD pipelines to automate EOL scanning.
200
209
  We provide a Docker image that's pre-configured to run EOL scans. Based on [`cdxgen`](https://github.com/CycloneDX/cdxgen),
201
210
  it contains build tools for most project types and will provide best results when generating an SBOM. Use these templates to generate a report and save it to your CI job artifact for analysis and processing after your scan runs.
202
211
 
212
+ **Note:** There is a potential to run into permission issues writing out the report to your CI runner. Please ensure that your CI runner is setup to have proper read/write permissions for wherever your output files are being written to.
213
+
203
214
  #### GitHub Actions
204
215
 
205
216
  ```yaml
@@ -215,19 +226,25 @@ on:
215
226
  jobs:
216
227
  scan:
217
228
  runs-on: ubuntu-latest
229
+ environment: demo
218
230
  steps:
219
- - uses: actions/checkout@v4
231
+ - name: Checkout repository
232
+ uses: actions/checkout@v5
220
233
 
221
- - name: Run EOL Scan with Docker
222
- uses: docker://ghcr.io/herodevs/eol-scan
223
- with:
224
- args: "-s"
225
-
226
- - name: Upload artifact
227
- uses: actions/upload-artifact@v4
234
+ - name: Run EOL Scan
235
+ run: |
236
+ docker run --name eol-scanner \
237
+ -v $GITHUB_WORKSPACE:/app \
238
+ -w /app \
239
+ ghcr.io/herodevs/eol-scan --save --output /tmp/herodevs.report.json
240
+ docker cp eol-scanner:/tmp/herodevs.report.json ${{ runner.temp }}/herodevs.report.json
241
+ docker rm eol-scanner
242
+
243
+ - name: Upload artifact
244
+ uses: actions/upload-artifact@v5
228
245
  with:
229
246
  name: my-eol-report
230
- path: herodevs.report.json
247
+ path: ${{ runner.temp }}/herodevs.report.json
231
248
  ```
232
249
 
233
250
  #### GitLab CI/CD
@@ -270,18 +287,19 @@ jobs:
270
287
  scan:
271
288
  runs-on: ubuntu-latest
272
289
  steps:
273
- - uses: actions/checkout@v4
274
- - uses: actions/setup-node@v4
290
+ - uses: actions/checkout@v5
291
+
292
+ - uses: actions/setup-node@v6
275
293
  with:
276
- node-version: '20'
294
+ node-version: '24'
277
295
 
278
296
  - run: echo # Prepare environment, install tooling, perform setup, etc.
279
297
 
280
298
  - name: Run EOL Scan
281
- run: npx @herodevs/cli@beta
299
+ run: npx @herodevs/cli@beta scan eol
282
300
 
283
- - name: Upload artifact
284
- uses: actions/upload-artifact@v4
301
+ - name: Upload artifact
302
+ uses: actions/upload-artifact@v5
285
303
  with:
286
304
  name: my-eol-report
287
305
  path: herodevs.report.json
@@ -6,7 +6,8 @@ import { createReportMutation, getEolReportQuery } from "./gql-operations.js";
6
6
  export const createApollo = (uri) => new ApolloClient({
7
7
  cache: new InMemoryCache(),
8
8
  defaultOptions: {
9
- query: { fetchPolicy: 'no-cache' },
9
+ query: { fetchPolicy: 'no-cache', errorPolicy: 'all' },
10
+ mutate: { errorPolicy: 'all' },
10
11
  },
11
12
  link: new HttpLink({
12
13
  uri,
@@ -21,6 +22,10 @@ export const SbomScanner = (client) => {
21
22
  mutation: createReportMutation,
22
23
  variables: { input },
23
24
  });
25
+ if (res?.errors?.length) {
26
+ debugLogger('GraphQL errors in createReport: %o', res.errors);
27
+ throw new Error('Failed to create EOL report');
28
+ }
24
29
  const result = res.data?.eol?.createReport;
25
30
  if (!result?.success || !result.id) {
26
31
  debugLogger('failed scan %o', result || {});
@@ -44,6 +49,10 @@ export const SbomScanner = (client) => {
44
49
  const batch = pages.slice(i, i + config.concurrentPageRequests);
45
50
  const batchResponses = await Promise.all(batch);
46
51
  for (const response of batchResponses) {
52
+ if (response?.errors?.length) {
53
+ debugLogger('GraphQL errors in getReport query: %o', response.errors);
54
+ throw new Error('Failed to fetch EOL report');
55
+ }
47
56
  const report = response.data.eol.report;
48
57
  reportMetadata ??= report;
49
58
  components.push(...(report?.components ?? []));
@@ -11,7 +11,11 @@ export default class ScanEol extends Command {
11
11
  file: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
12
12
  dir: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
13
13
  save: import("@oclif/core/interfaces").BooleanFlag<boolean>;
14
+ output: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
14
15
  saveSbom: import("@oclif/core/interfaces").BooleanFlag<boolean>;
16
+ sbomOutput: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
17
+ saveTrimmedSbom: import("@oclif/core/interfaces").BooleanFlag<boolean>;
18
+ hideReportUrl: import("@oclif/core/interfaces").BooleanFlag<boolean>;
15
19
  version: import("@oclif/core/interfaces").BooleanFlag<void>;
16
20
  };
17
21
  run(): Promise<EolReport | undefined>;
@@ -19,6 +23,7 @@ export default class ScanEol extends Command {
19
23
  private scanSbom;
20
24
  private saveReport;
21
25
  private saveSbom;
26
+ private saveTrimmedSbom;
22
27
  private displayResults;
23
28
  private getSbomFromScan;
24
29
  private getSbomFromFile;
@@ -5,8 +5,8 @@ import { submitScan } from "../../api/nes.client.js";
5
5
  import { config, filenamePrefix } from "../../config/constants.js";
6
6
  import { track } from "../../service/analytics.svc.js";
7
7
  import { createSbom } from "../../service/cdx.svc.js";
8
- import { countComponentsByStatus, formatScanResults, formatWebReportUrl } from "../../service/display.svc.js";
9
- import { readSbomFromFile, saveReportToFile, saveSbomToFile, validateDirectory } from "../../service/file.svc.js";
8
+ import { countComponentsByStatus, formatDataPrivacyLink, formatReportSaveHint, formatScanResults, formatWebReportUrl, } from "../../service/display.svc.js";
9
+ import { readSbomFromFile, saveArtifactToFile, validateDirectory } from "../../service/file.svc.js";
10
10
  import { getErrorMessage } from "../../service/log.svc.js";
11
11
  export default class ScanEol extends Command {
12
12
  static description = 'Scan a given SBOM for EOL data';
@@ -30,7 +30,7 @@ export default class ScanEol extends Command {
30
30
  static flags = {
31
31
  file: Flags.string({
32
32
  char: 'f',
33
- description: 'The file path of an existing cyclonedx SBOM to scan for EOL',
33
+ description: 'The file path of an existing SBOM to scan for EOL (supports CycloneDX and SPDX 2.3 formats)',
34
34
  exclusive: ['dir'],
35
35
  }),
36
36
  dir: Flags.string({
@@ -45,11 +45,29 @@ export default class ScanEol extends Command {
45
45
  default: false,
46
46
  description: `Save the generated report as ${filenamePrefix}.report.json in the scanned directory`,
47
47
  }),
48
+ output: Flags.string({
49
+ char: 'o',
50
+ description: `Save the generated report to a custom path (defaults to ${filenamePrefix}.report.json when not provided)`,
51
+ }),
48
52
  saveSbom: Flags.boolean({
49
53
  aliases: ['save-sbom'],
50
54
  default: false,
51
55
  description: `Save the generated SBOM as ${filenamePrefix}.sbom.json in the scanned directory`,
52
56
  }),
57
+ sbomOutput: Flags.string({
58
+ aliases: ['sbom-output'],
59
+ description: `Save the generated SBOM to a custom path (defaults to ${filenamePrefix}.sbom.json when not provided)`,
60
+ }),
61
+ saveTrimmedSbom: Flags.boolean({
62
+ aliases: ['save-trimmed-sbom'],
63
+ default: false,
64
+ description: `Save the trimmed SBOM as ${filenamePrefix}.sbom-trimmed.json in the scanned directory`,
65
+ }),
66
+ hideReportUrl: Flags.boolean({
67
+ aliases: ['hide-report-url'],
68
+ default: false,
69
+ description: 'Hide the generated web report URL for this scan',
70
+ }),
53
71
  version: Flags.version(),
54
72
  };
55
73
  async run() {
@@ -57,7 +75,6 @@ export default class ScanEol extends Command {
57
75
  track('CLI EOL Scan Started', (context) => ({
58
76
  command: context.command,
59
77
  command_flags: context.command_flags,
60
- scan_location: flags.dir,
61
78
  }));
62
79
  const sbomStartTime = performance.now();
63
80
  const sbom = await this.loadSbom();
@@ -66,12 +83,22 @@ export default class ScanEol extends Command {
66
83
  track('CLI SBOM Generated', (context) => ({
67
84
  command: context.command,
68
85
  command_flags: context.command_flags,
69
- scan_location: flags.dir,
70
86
  sbom_generation_time: (sbomEndTime - sbomStartTime) / 1000,
71
87
  }));
72
88
  }
73
- if (flags.saveSbom && !flags.file) {
74
- const sbomPath = this.saveSbom(flags.dir, sbom);
89
+ let reportOutputPath = flags.output;
90
+ let sbomOutputPath = flags.sbomOutput;
91
+ if (flags.output && !flags.save) {
92
+ this.warn('--output requires --save to write the report. Run again with --save to create the file.');
93
+ reportOutputPath = undefined;
94
+ }
95
+ if (flags.sbomOutput && !flags.saveSbom) {
96
+ this.warn('--sbomOutput requires --saveSbom to write the SBOM. Run again with --saveSbom to create the file.');
97
+ sbomOutputPath = undefined;
98
+ }
99
+ const shouldSaveSbom = !flags.file && flags.saveSbom;
100
+ if (shouldSaveSbom) {
101
+ const sbomPath = this.saveSbom(flags.dir, sbom, sbomOutputPath);
75
102
  this.log(`SBOM saved to ${sbomPath}`);
76
103
  track('CLI SBOM Output Saved', (context) => ({
77
104
  command: context.command,
@@ -83,7 +110,6 @@ export default class ScanEol extends Command {
83
110
  track('CLI EOL Scan Ended, No Components Found', (context) => ({
84
111
  command: context.command,
85
112
  command_flags: context.command_flags,
86
- scan_location: flags.dir,
87
113
  }));
88
114
  this.log('No components found in scan. Report not generated.');
89
115
  return;
@@ -100,13 +126,14 @@ export default class ScanEol extends Command {
100
126
  nes_available_count: componentCounts.NES_AVAILABLE,
101
127
  number_of_packages: componentCounts.TOTAL,
102
128
  sbom_created: !flags.file,
103
- scan_location: flags.dir,
104
129
  scan_load_time: (scanEndTime - scanStartTime) / 1000,
105
130
  scanned_ecosystems: componentCounts.ECOSYSTEMS,
106
- web_report_link: scan.id ? `${config.eolReportUrl}/${scan.id}` : undefined,
131
+ web_report_link: !flags.hideReportUrl && scan.id ? `${config.eolReportUrl}/${scan.id}` : undefined,
132
+ web_report_hidden: flags.hideReportUrl,
107
133
  }));
108
- if (flags.save) {
109
- const reportPath = this.saveReport(scan, flags.dir);
134
+ const shouldSaveReport = flags.save;
135
+ if (shouldSaveReport) {
136
+ const reportPath = this.saveReport(scan, flags.dir, reportOutputPath);
110
137
  this.log(`Report saved to ${reportPath}`);
111
138
  track('CLI JSON Scan Output Saved', (context) => ({
112
139
  command: context.command,
@@ -115,7 +142,7 @@ export default class ScanEol extends Command {
115
142
  }));
116
143
  }
117
144
  if (!this.jsonEnabled()) {
118
- this.displayResults(scan);
145
+ this.displayResults(scan, flags.hideReportUrl, Boolean(reportOutputPath || sbomOutputPath));
119
146
  }
120
147
  return scan;
121
148
  }
@@ -132,9 +159,21 @@ export default class ScanEol extends Command {
132
159
  return sbom;
133
160
  }
134
161
  async scanSbom(sbom) {
135
- const spinner = ora().start('Scanning for EOL packages');
162
+ const { flags } = await this.parse(ScanEol);
163
+ const spinner = ora().start('Trimming SBOM');
164
+ const trimmedSbom = trimCdxBom(sbom);
165
+ spinner.succeed('SBOM trimmed');
166
+ if (flags.saveTrimmedSbom) {
167
+ const trimmedPath = this.saveTrimmedSbom(flags.dir, trimmedSbom);
168
+ this.log(`Trimmed SBOM saved to ${trimmedPath}`);
169
+ track('CLI Trimmed SBOM Output Saved', (context) => ({
170
+ command: context.command,
171
+ command_flags: context.command_flags,
172
+ }));
173
+ }
174
+ spinner.start('Scanning for EOL packages');
136
175
  try {
137
- const scan = await submitScan({ sbom: trimCdxBom(sbom) });
176
+ const scan = await submitScan({ sbom: trimmedSbom });
138
177
  spinner.succeed('Scan completed');
139
178
  return scan;
140
179
  }
@@ -144,15 +183,14 @@ export default class ScanEol extends Command {
144
183
  track('CLI EOL Scan Failed', (context) => ({
145
184
  command: context.command,
146
185
  command_flags: context.command_flags,
147
- scan_location: context.scan_location,
148
186
  scan_failure_reason: errorMessage,
149
187
  }));
150
188
  this.error(`Failed to submit scan to NES. ${errorMessage}`);
151
189
  }
152
190
  }
153
- saveReport(report, dir) {
191
+ saveReport(report, dir, outputPath) {
154
192
  try {
155
- return saveReportToFile(dir, report);
193
+ return saveArtifactToFile(dir, { kind: 'report', payload: report, outputPath });
156
194
  }
157
195
  catch (error) {
158
196
  const errorMessage = getErrorMessage(error);
@@ -160,9 +198,9 @@ export default class ScanEol extends Command {
160
198
  this.error(errorMessage);
161
199
  }
162
200
  }
163
- saveSbom(dir, sbom) {
201
+ saveSbom(dir, sbom, outputPath) {
164
202
  try {
165
- return saveSbomToFile(dir, sbom);
203
+ return saveArtifactToFile(dir, { kind: 'sbom', payload: sbom, outputPath });
166
204
  }
167
205
  catch (error) {
168
206
  const errorMessage = getErrorMessage(error);
@@ -170,17 +208,37 @@ export default class ScanEol extends Command {
170
208
  this.error(errorMessage);
171
209
  }
172
210
  }
173
- displayResults(report) {
211
+ saveTrimmedSbom(dir, sbom) {
212
+ try {
213
+ return saveArtifactToFile(dir, { kind: 'sbomTrimmed', payload: sbom });
214
+ }
215
+ catch (error) {
216
+ const errorMessage = getErrorMessage(error);
217
+ track('CLI Error Encountered', () => ({ error: errorMessage }));
218
+ this.error(errorMessage);
219
+ }
220
+ }
221
+ displayResults(report, hideReportUrl, hasCustomOutput) {
174
222
  const lines = formatScanResults(report);
175
223
  for (const line of lines) {
176
224
  this.log(line);
177
225
  }
178
- if (report.id) {
226
+ if (!hideReportUrl && report.id) {
179
227
  const lines = formatWebReportUrl(report.id, config.eolReportUrl);
180
228
  for (const line of lines) {
181
229
  this.log(line);
182
230
  }
183
231
  }
232
+ else if (hideReportUrl && !hasCustomOutput) {
233
+ const lines = formatReportSaveHint();
234
+ for (const line of lines) {
235
+ this.log(line);
236
+ }
237
+ }
238
+ const privacyLines = formatDataPrivacyLink();
239
+ for (const line of privacyLines) {
240
+ this.log(line);
241
+ }
184
242
  this.log('* Use --json to output the report payload');
185
243
  this.log(`* Use --save to save the report to ${filenamePrefix}.report.json`);
186
244
  this.log('* Use --help for more commands or options');
@@ -2,16 +2,16 @@ import ora, {} from 'ora';
2
2
  import { track } from "../../service/analytics.svc.js";
3
3
  const hook = async (opts) => {
4
4
  const isHelpOrVersionCmd = opts.argv.includes('--help') || opts.argv.includes('--version');
5
+ const hasError = Boolean(opts.error);
5
6
  let spinner;
6
- if (!isHelpOrVersionCmd) {
7
+ if (!isHelpOrVersionCmd && !hasError) {
7
8
  spinner = ora().start('Cleaning up');
8
9
  }
9
- const event = track('CLI Session Ended', (context) => ({
10
+ await track('CLI Session Ended', (context) => ({
10
11
  cli_version: context.cli_version,
11
12
  ended_at: new Date(),
12
13
  })).promise;
13
- if (!isHelpOrVersionCmd) {
14
- await event;
14
+ if (!isHelpOrVersionCmd && !hasError) {
15
15
  spinner?.stop();
16
16
  }
17
17
  };
@@ -11,7 +11,6 @@ interface AnalyticsContext {
11
11
  command?: string;
12
12
  command_flags?: string;
13
13
  error?: string;
14
- scan_location?: string;
15
14
  eol_true_count?: number;
16
15
  eol_unknown_count?: number;
17
16
  nes_available_count?: number;
@@ -1,3 +1,5 @@
1
+ import { createBom } from '@cyclonedx/cdxgen';
2
+ import { postProcess } from '@cyclonedx/cdxgen/stages/postgen/postgen';
1
3
  import type { CdxBom } from '@herodevs/eol-shared';
2
4
  export declare const SBOM_DEFAULT__OPTIONS: {
3
5
  $0: string;
@@ -61,4 +63,10 @@ export declare const SBOM_DEFAULT__OPTIONS: {
61
63
  * Lazy loads cdxgen (for ESM purposes), scans
62
64
  * `directory`, and returns the `bomJson` property.
63
65
  */
64
- export declare function createSbom(directory: string): Promise<CdxBom>;
66
+ type CreateSbomDependencies = {
67
+ createBom: typeof createBom;
68
+ postProcess: typeof postProcess;
69
+ };
70
+ export declare function createSbomFactory({ createBom: createBomDependency, postProcess: postProcessDependency, }?: Partial<CreateSbomDependencies>): (directory: string) => Promise<CdxBom>;
71
+ export declare const createSbom: (directory: string) => Promise<CdxBom>;
72
+ export {};
@@ -1,4 +1,5 @@
1
1
  import { createBom } from '@cyclonedx/cdxgen';
2
+ import { postProcess } from '@cyclonedx/cdxgen/stages/postgen/postgen';
2
3
  import { debugLogger } from "./log.svc.js";
3
4
  const author = process.env.npm_package_author ?? 'HeroDevs, Inc.';
4
5
  export const SBOM_DEFAULT__OPTIONS = {
@@ -24,8 +25,8 @@ export const SBOM_DEFAULT__OPTIONS = {
24
25
  includeFormulation: false,
25
26
  'no-install-deps': true,
26
27
  noInstallDeps: true,
27
- 'min-confidence': 1,
28
- minConfidence: 1,
28
+ 'min-confidence': 0.1,
29
+ minConfidence: 0.1,
29
30
  multiProject: true,
30
31
  'no-banner': false,
31
32
  noBabel: false,
@@ -62,14 +63,18 @@ export const SBOM_DEFAULT__OPTIONS = {
62
63
  usagesSlicesFile: 'usages.slices.json',
63
64
  validate: true,
64
65
  };
65
- /**
66
- * Lazy loads cdxgen (for ESM purposes), scans
67
- * `directory`, and returns the `bomJson` property.
68
- */
69
- export async function createSbom(directory) {
70
- const sbom = await createBom(directory, SBOM_DEFAULT__OPTIONS);
71
- if (!sbom)
72
- throw new Error('SBOM not generated');
73
- debugLogger('Successfully generated SBOM');
74
- return sbom.bomJson;
66
+ export function createSbomFactory({ createBom: createBomDependency = createBom, postProcess: postProcessDependency = postProcess, } = {}) {
67
+ return async function createSbom(directory) {
68
+ const sbom = await createBomDependency(directory, SBOM_DEFAULT__OPTIONS);
69
+ if (!sbom) {
70
+ throw new Error('SBOM not generated');
71
+ }
72
+ const postProcessedSbom = postProcessDependency(sbom, SBOM_DEFAULT__OPTIONS);
73
+ if (!postProcessedSbom) {
74
+ throw new Error('SBOM not generated');
75
+ }
76
+ debugLogger('Successfully generated SBOM');
77
+ return postProcessedSbom.bomJson;
78
+ };
75
79
  }
80
+ export const createSbom = createSbomFactory();
@@ -20,3 +20,11 @@ export declare function formatScanResults(report: EolReport): string[];
20
20
  * Formats web report URL for console display
21
21
  */
22
22
  export declare function formatWebReportUrl(id: string, reportCardUrl: string): string[];
23
+ /**
24
+ * Formats data privacy information link for console display
25
+ */
26
+ export declare function formatDataPrivacyLink(): string[];
27
+ /**
28
+ * Formats the report save hint for console display when the web report URL is hidden
29
+ */
30
+ export declare function formatReportSaveHint(): string[];
@@ -7,6 +7,7 @@ const STATUS_COLORS = {
7
7
  OK: 'green',
8
8
  EOL_UPCOMING: 'yellow',
9
9
  };
10
+ const SEPARATOR_WIDTH = 40;
10
11
  /**
11
12
  * Formats status row text with appropriate color and icon
12
13
  */
@@ -54,7 +55,7 @@ export function formatScanResults(report) {
54
55
  }
55
56
  return [
56
57
  ux.colorize('bold', 'Scan results:'),
57
- ux.colorize('bold', '-'.repeat(40)),
58
+ ux.colorize('bold', '-'.repeat(SEPARATOR_WIDTH)),
58
59
  ux.colorize('bold', `${report.components.length.toLocaleString()} total packages scanned`),
59
60
  getStatusRowText.EOL(`${EOL.toLocaleString().padEnd(5)} End-of-Life (EOL)`),
60
61
  getStatusRowText.EOL_UPCOMING(`${EOL_UPCOMING.toLocaleString().padEnd(5)} EOL Upcoming`),
@@ -68,5 +69,19 @@ export function formatScanResults(report) {
68
69
  */
69
70
  export function formatWebReportUrl(id, reportCardUrl) {
70
71
  const url = ux.colorize('blue', terminalLink(new URL(reportCardUrl).hostname, `${reportCardUrl}/${id}`, { fallback: (_, url) => url }));
71
- return [ux.colorize('bold', '-'.repeat(40)), `🌐 View your full EOL report at: ${url}\n`];
72
+ return [ux.colorize('bold', '-'.repeat(SEPARATOR_WIDTH)), `🌐 View your full EOL report at: ${url}\n`];
73
+ }
74
+ /**
75
+ * Formats data privacy information link for console display
76
+ */
77
+ export function formatDataPrivacyLink() {
78
+ const privacyUrl = 'https://docs.herodevs.com/eol-ds/data-privacy-and-security';
79
+ const link = ux.colorize('blue', terminalLink('Learn more about data privacy', privacyUrl, { fallback: (text, url) => `${text}: ${url}` }));
80
+ return [`🔒 ${link}\n`];
81
+ }
82
+ /**
83
+ * Formats the report save hint for console display when the web report URL is hidden
84
+ */
85
+ export function formatReportSaveHint() {
86
+ return [ux.colorize('bold', '-'.repeat(SEPARATOR_WIDTH)), 'To save your detailed JSON report, use the --save flag'];
72
87
  }
@@ -3,18 +3,28 @@ export interface FileError extends Error {
3
3
  code?: string;
4
4
  }
5
5
  /**
6
- * Reads an SBOM from a file path
6
+ * Reads an SBOM from a file path and converts it to CycloneDX format
7
+ * Supports both SPDX 2.3 and CycloneDX formats
7
8
  */
8
9
  export declare function readSbomFromFile(filePath: string): CdxBom;
9
10
  /**
10
11
  * Validates that a directory path exists and is actually a directory
11
12
  */
12
13
  export declare function validateDirectory(dirPath: string): void;
14
+ type SaveArtifactRequest = {
15
+ kind: 'sbom';
16
+ payload: CdxBom;
17
+ outputPath?: string;
18
+ } | {
19
+ kind: 'sbomTrimmed';
20
+ payload: CdxBom;
21
+ } | {
22
+ kind: 'report';
23
+ payload: EolReport;
24
+ outputPath?: string;
25
+ };
13
26
  /**
14
- * Saves an SBOM to a file in the specified directory
27
+ * Saves an SBOM, trimmed SBOM, or report to disk using the correct default filename.
15
28
  */
16
- export declare function saveSbomToFile(dir: string, sbom: CdxBom): string;
17
- /**
18
- * Saves an EOL report to a file in the specified directory
19
- */
20
- export declare function saveReportToFile(dir: string, report: EolReport): string;
29
+ export declare function saveArtifactToFile(dir: string, request: SaveArtifactRequest): string;
30
+ export {};
@@ -1,10 +1,70 @@
1
1
  import fs from 'node:fs';
2
2
  import path, { join, resolve } from 'node:path';
3
- import { isCdxBom } from '@herodevs/eol-shared';
3
+ import { isCdxBom, isSpdxBom, spdxToCdxBom } from '@herodevs/eol-shared';
4
4
  import { filenamePrefix } from "../config/constants.js";
5
5
  import { getErrorMessage } from "./log.svc.js";
6
6
  /**
7
- * Reads an SBOM from a file path
7
+ * Computes an absolute output path using either a provided path or the base directory and default name.
8
+ */
9
+ function resolveOutputPath(baseDir, defaultFilename, customPath) {
10
+ const defaultOutput = resolve(join(baseDir, defaultFilename));
11
+ if (!customPath) {
12
+ return { fileName: defaultFilename, fullPath: defaultOutput };
13
+ }
14
+ const resolvedCustomPath = resolve(customPath);
15
+ let targetPath = resolvedCustomPath;
16
+ const hasTrailingSeparator = /[\\/]$/.test(customPath);
17
+ const customIsDirectory = fs.existsSync(resolvedCustomPath) && fs.statSync(resolvedCustomPath).isDirectory();
18
+ if (hasTrailingSeparator || customIsDirectory) {
19
+ targetPath = join(resolvedCustomPath, defaultFilename);
20
+ }
21
+ return { fileName: path.basename(targetPath), fullPath: targetPath };
22
+ }
23
+ /**
24
+ * Ensures the output directory for a given path exists, is a directory, and is writable.
25
+ */
26
+ function ensureOutputDirectory(fullPath, fileName) {
27
+ const targetDir = path.dirname(fullPath);
28
+ if (!fs.existsSync(targetDir)) {
29
+ throw new Error(`Unable to save ${fileName}`);
30
+ }
31
+ const stats = fs.statSync(targetDir);
32
+ if (!stats.isDirectory()) {
33
+ throw new Error(`Unable to save ${fileName}`);
34
+ }
35
+ try {
36
+ fs.accessSync(targetDir, fs.constants.W_OK);
37
+ }
38
+ catch {
39
+ throw new Error(`Unable to save ${fileName}`);
40
+ }
41
+ }
42
+ /**
43
+ * Writes JSON to disk after validating directory constraints and formats the payload for readability.
44
+ */
45
+ function writeJsonFile(fullPath, fileName, payload, failureLabel) {
46
+ ensureOutputDirectory(fullPath, fileName);
47
+ try {
48
+ fs.writeFileSync(fullPath, JSON.stringify(payload, null, 2));
49
+ return fullPath;
50
+ }
51
+ catch (error) {
52
+ const fileError = error;
53
+ switch (fileError.code) {
54
+ case 'EACCES':
55
+ throw new Error(`Permission denied. Unable to save ${fileName}`);
56
+ case 'ENOSPC':
57
+ throw new Error(`No space left on device. Unable to save ${fileName}`);
58
+ case 'ENOENT':
59
+ case 'ENOTDIR':
60
+ throw new Error(`Unable to save ${fileName}`);
61
+ }
62
+ throw new Error(`Failed to save ${failureLabel}: ${getErrorMessage(error)}`);
63
+ }
64
+ }
65
+ /**
66
+ * Reads an SBOM from a file path and converts it to CycloneDX format
67
+ * Supports both SPDX 2.3 and CycloneDX formats
8
68
  */
9
69
  export function readSbomFromFile(filePath) {
10
70
  const file = resolve(filePath);
@@ -13,11 +73,14 @@ export function readSbomFromFile(filePath) {
13
73
  }
14
74
  try {
15
75
  const fileContent = fs.readFileSync(file, 'utf8');
16
- const sbom = JSON.parse(fileContent);
17
- if (!isCdxBom(sbom)) {
18
- throw new Error(`Invalid SBOM file: ${file}`);
76
+ const jsonContent = JSON.parse(fileContent);
77
+ if (isSpdxBom(jsonContent)) {
78
+ return spdxToCdxBom(jsonContent);
79
+ }
80
+ if (isCdxBom(jsonContent)) {
81
+ return jsonContent;
19
82
  }
20
- return sbom;
83
+ throw new Error(`Invalid SBOM file format. Expected SPDX 2.3 or CycloneDX format.`);
21
84
  }
22
85
  catch (error) {
23
86
  throw new Error(`Failed to read SBOM file: ${getErrorMessage(error)}`);
@@ -36,36 +99,17 @@ export function validateDirectory(dirPath) {
36
99
  throw new Error(`Path is not a directory: ${dir}`);
37
100
  }
38
101
  }
102
+ const artifactFilenames = {
103
+ sbom: `${filenamePrefix}.sbom.json`,
104
+ sbomTrimmed: `${filenamePrefix}.sbom-trimmed.json`,
105
+ report: `${filenamePrefix}.report.json`,
106
+ };
39
107
  /**
40
- * Saves an SBOM to a file in the specified directory
108
+ * Saves an SBOM, trimmed SBOM, or report to disk using the correct default filename.
41
109
  */
42
- export function saveSbomToFile(dir, sbom) {
43
- const outputPath = join(dir, `${filenamePrefix}.sbom.json`);
44
- try {
45
- fs.writeFileSync(outputPath, JSON.stringify(sbom, null, 2));
46
- return outputPath;
47
- }
48
- catch (error) {
49
- throw new Error(`Failed to save SBOM: ${getErrorMessage(error)}`);
50
- }
51
- }
52
- /**
53
- * Saves an EOL report to a file in the specified directory
54
- */
55
- export function saveReportToFile(dir, report) {
56
- const reportPath = path.join(dir, `${filenamePrefix}.report.json`);
57
- try {
58
- fs.writeFileSync(reportPath, JSON.stringify(report, null, 2));
59
- return reportPath;
60
- }
61
- catch (error) {
62
- const fileError = error;
63
- if (fileError.code === 'EACCES') {
64
- throw new Error(`Permission denied. Unable to save report to ${filenamePrefix}.report.json`);
65
- }
66
- if (fileError.code === 'ENOSPC') {
67
- throw new Error(`No space left on device. Unable to save report to ${filenamePrefix}.report.json`);
68
- }
69
- throw new Error(`Failed to save report: ${getErrorMessage(error)}`);
70
- }
110
+ export function saveArtifactToFile(dir, request) {
111
+ const defaultFilename = artifactFilenames[request.kind];
112
+ const customOutputPath = 'outputPath' in request ? request.outputPath : undefined;
113
+ const { fileName, fullPath } = resolveOutputPath(dir, defaultFilename, customOutputPath);
114
+ return writeJsonFile(fullPath, fileName, request.payload, fileName);
71
115
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@herodevs/cli",
3
- "version": "2.0.0-beta.10",
3
+ "version": "2.0.0-beta.12",
4
4
  "author": "HeroDevs, Inc",
5
5
  "bin": {
6
6
  "hd": "./bin/run.js"
@@ -39,34 +39,33 @@
39
39
  "herodevs cli"
40
40
  ],
41
41
  "dependencies": {
42
- "@amplitude/analytics-node": "^1.5.8",
42
+ "@amplitude/analytics-node": "^1.5.20",
43
43
  "@apollo/client": "^3.13.8",
44
- "@cyclonedx/cdxgen": "~11.4.4",
44
+ "@cyclonedx/cdxgen": "^11.11.0",
45
45
  "@herodevs/eol-shared": "github:herodevs/eol-shared#v0.1.11",
46
46
  "@oclif/core": "^4.5.3",
47
47
  "@oclif/plugin-help": "^6.2.32",
48
- "@oclif/plugin-update": "^4.7.8",
49
- "graphql": "^16.11.0",
48
+ "@oclif/plugin-update": "^4.7.13",
50
49
  "node-machine-id": "^1.1.12",
51
- "ora": "^8.2.0",
50
+ "ora": "^9.0.0",
52
51
  "packageurl-js": "^2.0.1",
53
- "terminal-link": "^4.0.0",
52
+ "terminal-link": "^5.0.0",
54
53
  "update-notifier": "^7.3.1"
55
54
  },
56
55
  "devDependencies": {
57
56
  "@biomejs/biome": "^2.2.2",
58
57
  "@oclif/test": "^4.1.13",
59
58
  "@types/inquirer": "^9.0.9",
60
- "@types/node": "^24.3.1",
59
+ "@types/node": "^24.9.2",
61
60
  "@types/sinon": "^17.0.4",
62
61
  "@types/update-notifier": "^6.0.8",
63
62
  "globstar": "^1.0.0",
64
- "oclif": "^4.22.27",
63
+ "oclif": "^4.22.38",
65
64
  "shx": "^0.4.0",
66
65
  "sinon": "^21.0.0",
67
66
  "ts-node": "^10.9.2",
68
- "tsx": "^4.20.5",
69
- "typescript": "^5.9.2"
67
+ "tsx": "^4.20.6",
68
+ "typescript": "^5.9.3"
70
69
  },
71
70
  "engines": {
72
71
  "node": ">=20.0.0"
@@ -1 +0,0 @@
1
- export {};
@@ -1,26 +0,0 @@
1
- import { writeFileSync } from 'node:fs';
2
- import { join } from 'node:path';
3
- import { createBom } from '@cyclonedx/cdxgen';
4
- import { filenamePrefix } from "../config/constants.js";
5
- import { SBOM_DEFAULT__OPTIONS } from "./cdx.svc.js";
6
- process.on('uncaughtException', (err) => {
7
- console.error('Uncaught exception:', err.message);
8
- process.exit(1);
9
- });
10
- process.on('unhandledRejection', (reason) => {
11
- console.error('Unhandled rejection:', reason);
12
- process.exit(1);
13
- });
14
- try {
15
- console.log('Sbom worker started');
16
- const options = JSON.parse(process.argv[2]);
17
- const { path, opts } = options;
18
- const { bomJson } = await createBom(path, { ...SBOM_DEFAULT__OPTIONS, ...opts });
19
- const outputPath = join(path, `${filenamePrefix}.sbom.json`);
20
- writeFileSync(outputPath, JSON.stringify(bomJson, null, 2));
21
- process.exit(0);
22
- }
23
- catch (error) {
24
- console.error('Error creating SBOM', error.message);
25
- process.exit(1);
26
- }