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

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.11/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.11/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
@@ -215,19 +224,23 @@ on:
215
224
  jobs:
216
225
  scan:
217
226
  runs-on: ubuntu-latest
227
+ environment: demo
218
228
  steps:
219
- - uses: actions/checkout@v4
229
+ - name: Checkout repository
230
+ uses: actions/checkout@v4
220
231
 
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
232
+ - name: Run EOL Scan
233
+ run: |
234
+ docker run --rm \
235
+ -v $GITHUB_WORKSPACE:/app \
236
+ -w /app \
237
+ ghcr.io/herodevs/eol-scan --save
238
+
239
+ - name: Upload artifact
227
240
  uses: actions/upload-artifact@v4
228
241
  with:
229
242
  name: my-eol-report
230
- path: herodevs.report.json
243
+ path: ./herodevs.report.json
231
244
  ```
232
245
 
233
246
  #### GitLab CI/CD
@@ -273,14 +286,14 @@ jobs:
273
286
  - uses: actions/checkout@v4
274
287
  - uses: actions/setup-node@v4
275
288
  with:
276
- node-version: '20'
289
+ node-version: '22'
277
290
 
278
291
  - run: echo # Prepare environment, install tooling, perform setup, etc.
279
292
 
280
293
  - name: Run EOL Scan
281
- run: npx @herodevs/cli@beta
294
+ run: npx @herodevs/cli@beta scan eol
282
295
 
283
- - name: Upload artifact
296
+ - name: Upload artifact
284
297
  uses: actions/upload-artifact@v4
285
298
  with:
286
299
  name: my-eol-report
@@ -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;
@@ -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.11",
4
4
  "author": "HeroDevs, Inc",
5
5
  "bin": {
6
6
  "hd": "./bin/run.js"
@@ -39,7 +39,7 @@
39
39
  "herodevs cli"
40
40
  ],
41
41
  "dependencies": {
42
- "@amplitude/analytics-node": "^1.5.8",
42
+ "@amplitude/analytics-node": "^1.5.14",
43
43
  "@apollo/client": "^3.13.8",
44
44
  "@cyclonedx/cdxgen": "~11.4.4",
45
45
  "@herodevs/eol-shared": "github:herodevs/eol-shared#v0.1.11",
@@ -50,23 +50,23 @@
50
50
  "node-machine-id": "^1.1.12",
51
51
  "ora": "^8.2.0",
52
52
  "packageurl-js": "^2.0.1",
53
- "terminal-link": "^4.0.0",
53
+ "terminal-link": "^5.0.0",
54
54
  "update-notifier": "^7.3.1"
55
55
  },
56
56
  "devDependencies": {
57
57
  "@biomejs/biome": "^2.2.2",
58
58
  "@oclif/test": "^4.1.13",
59
59
  "@types/inquirer": "^9.0.9",
60
- "@types/node": "^24.3.1",
60
+ "@types/node": "^24.7.0",
61
61
  "@types/sinon": "^17.0.4",
62
62
  "@types/update-notifier": "^6.0.8",
63
63
  "globstar": "^1.0.0",
64
- "oclif": "^4.22.27",
64
+ "oclif": "^4.22.29",
65
65
  "shx": "^0.4.0",
66
66
  "sinon": "^21.0.0",
67
67
  "ts-node": "^10.9.2",
68
68
  "tsx": "^4.20.5",
69
- "typescript": "^5.9.2"
69
+ "typescript": "^5.9.3"
70
70
  },
71
71
  "engines": {
72
72
  "node": ">=20.0.0"