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
28 changes: 25 additions & 3 deletions .envrc.example
Original file line number Diff line number Diff line change
@@ -1,19 +1,41 @@
#!/usr/bin/env bash

# API Configuration
# API Configuration (override defaults in src/config/constants.ts)
export GRAPHQL_HOST='https://api.nes.herodevs.com';
export GRAPHQL_PATH='/graphql';
export EOL_REPORT_URL='https://eol-report-card.apps.herodevs.com/reports';
export ANALYTICS_URL='https://eol-api.herodevs.com/track';

# OAuth (for hd auth login)
export OAUTH_CONNECT_URL='';
export OAUTH_CLIENT_ID='';
# export OAUTH_CALLBACK_PORT='4000';
# export OAUTH_CALLBACK_REDIRECT='http://localhost:4000/oauth2/callback';

# Performance tuning (optional)
# IAM (for CI token provisioning via hd auth provision-ci-token)
# export IAM_HOST='https://apps.herodevs.io/api/iam';
# export IAM_PATH='/graphql';

# Auth toggles (optional; both default false)
# export ENABLE_AUTH='true';
# export ENABLE_USER_SETUP='true';

# CI token (for headless flows)
# HD_ORG_ID: required when using HD_AUTH_TOKEN; also stored when provisioning
# HD_AUTH_TOKEN: refresh token from provision; exchanged for access token
# HD_ACCESS_TOKEN: direct access token (skips exchange)
# export HD_ORG_ID='1234';
# export HD_AUTH_TOKEN='<long-lived-refresh-token>';
# export HD_ACCESS_TOKEN='<access-token>';

# Performance (optional)
# export CONCURRENT_PAGE_REQUESTS='3';
# export PAGE_SIZE='500';

# Keyring configuration (optional, for debugging)
# Privacy
# export TRACKING_OPT_OUT='true';

# Keyring (optional; for debugging token storage)
# export HD_AUTH_SERVICE_NAME='@herodevs/cli';
# export HD_AUTH_ACCESS_KEY='access-token';
# export HD_AUTH_REFRESH_KEY='refresh-token';
129 changes: 119 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,11 +68,11 @@ Maven and Gradle projects should run an install and build before scanning
## Usage
<!-- usage -->
```sh-session
$ npm install -g @herodevs/cli@beta
$ npm install -g @herodevs/cli
$ hd COMMAND
running command...
$ hd (--version)
@herodevs/cli/2.0.0-beta.14 darwin-arm64 node-v24.10.0
@herodevs/cli/2.0.0-beta.14 darwin-arm64 node-v24.11.1
$ hd --help [COMMAND]
USAGE
$ hd COMMAND
Expand All @@ -81,13 +81,57 @@ USAGE
<!-- usagestop -->
## Commands
<!-- commands -->
* [`hd auth login`](#hd-auth-login)
* [`hd auth logout`](#hd-auth-logout)
* [`hd auth provision-ci-token`](#hd-auth-provision-ci-token)
* [`hd help [COMMAND]`](#hd-help-command)
* [`hd report committers`](#hd-report-committers)
* [`hd scan eol`](#hd-scan-eol)
* [`hd tracker init`](#hd-tracker-init)
* [`hd tracker run`](#hd-tracker-run)
* [`hd update [CHANNEL]`](#hd-update-channel)
* **NOTE:** Only applies to [binary installation method](#binary-installation). NPM users should use [`npm install`](#global-npm-installation) to update to the latest version.

## `hd auth login`

OAuth CLI login

```
USAGE
$ hd auth login

DESCRIPTION
OAuth CLI login
```

_See code: [src/commands/auth/login.ts](https://github.com/herodevs/cli/blob/v2.0.0-beta.14/src/commands/auth/login.ts)_

## `hd auth logout`

Logs out of HeroDevs OAuth and clears stored tokens

```
USAGE
$ hd auth logout

DESCRIPTION
Logs out of HeroDevs OAuth and clears stored tokens
```

_See code: [src/commands/auth/logout.ts](https://github.com/herodevs/cli/blob/v2.0.0-beta.14/src/commands/auth/logout.ts)_

## `hd auth provision-ci-token`

Provision a CI/CD long-lived refresh token for headless auth

```
USAGE
$ hd auth provision-ci-token

DESCRIPTION
Provision a CI/CD long-lived refresh token for headless auth
```

_See code: [src/commands/auth/provision-ci-token.ts](https://github.com/herodevs/cli/blob/v2.0.0-beta.14/src/commands/auth/provision-ci-token.ts)_

## `hd help [COMMAND]`

Expand All @@ -107,7 +151,7 @@ DESCRIPTION
Display help for hd.
```

_See code: [@oclif/plugin-help](https://github.com/oclif/plugin-help/blob/6.2.37/src/commands/help.ts)_
_See code: [@oclif/plugin-help](https://github.com/oclif/plugin-help/blob/v6.2.37/src/commands/help.ts)_

## `hd report committers`

Expand All @@ -121,10 +165,10 @@ USAGE
FLAGS
-c, --csv Output in CSV format
-d, --directory=<value> Directory to search
-e, --afterDate=<value> [default: 2025-02-02] Start date (format: yyyy-MM-dd)
-e, --afterDate=<value> [default: 2025-02-18] Start date (format: yyyy-MM-dd)
-m, --months=<value> [default: 12] The number of months of git history to review. Cannot be used along beforeDate
and afterDate
-s, --beforeDate=<value> [default: 2026-02-02] End date (format: yyyy-MM-dd)
-s, --beforeDate=<value> [default: 2026-02-18] End date (format: yyyy-MM-dd)
-s, --save Save the committers report as herodevs.committers.<output>
-x, --exclude=<value>... Path Exclusions (eg -x="./src/bin" -x="./dist")
--json Output to JSON format
Expand Down Expand Up @@ -254,14 +298,12 @@ EXAMPLES
$ hd tracker run -d tracker -f settings.json
```

_See code: [src/commands/tracker/run.ts](https://github.com/herodevs/cli/blob/main/src/commands/tracker/run.ts)_
_See code: [src/commands/tracker/run.ts](https://github.com/herodevs/cli/blob/v2.0.0-beta.14/src/commands/tracker/run.ts)_

## `hd update [CHANNEL]`

update the hd CLI

* **NOTE:** Only applies to [binary installation method](#binary-installation). NPM users should use [`npm install`](#global-npm-installation) to update to the latest version.

```
USAGE
$ hd update [CHANNEL] [--force | | [-a | -v <value> | -i]] [-b ]
Expand Down Expand Up @@ -294,13 +336,80 @@ EXAMPLES
$ hd update --available
```

_See code: [@oclif/plugin-update](https://github.com/oclif/plugin-update/blob/4.7.18/src/commands/update.ts)_
_See code: [@oclif/plugin-update](https://github.com/oclif/plugin-update/blob/v4.7.18/src/commands/update.ts)_
<!-- commandsstop -->

## CI/CD Usage

You can use `@herodevs/cli` in your CI/CD pipelines to automate EOL scanning.

### CI/CD authentication

For headless use in CI/CD (e.g. GitHub Actions, GitLab CI), the CLI supports long-lived organization-scoped refresh tokens. You do not need to run an interactive login in the pipeline.

**One-time setup (interactive):**

```bash
hd auth login
hd auth provision-ci-token
```

Copy the token output, add as CI secrets: `HD_AUTH_TOKEN` and `HD_ORG_ID` (orgId is obtained from user setup and stored at provision time when using locally).

**CI pipeline (headless):** Run `hd scan eol` directly with `HD_AUTH_TOKEN` and `HD_ORG_ID` set. The CLI exchanges the token for an access token automatically:

```bash
export HD_ORG_ID=<id> HD_AUTH_TOKEN="<token>"
hd scan eol --dir .
```

| Secret / Env Var | Purpose |
|------------------|---------|
| `HD_AUTH_TOKEN` | Long-lived refresh token from provision |
| `HD_ORG_ID` | Organization ID (required when using HD_AUTH_TOKEN; also stored at provision time when using local file) |

#### Local testing

Reproduce the CI flow locally:

```bash
export HD_ORG_ID=1234 HD_AUTH_TOKEN="eyJ..."
hd scan eol --dir /path/to/project
```

#### GitHub Actions (authenticated scan)

Add secrets `HD_AUTH_TOKEN` and `HD_ORG_ID` in your repository or organization, then:

```yaml
- uses: actions/checkout@v5
- uses: actions/setup-node@v6
with:
node-version: '24'
- name: Run EOL Scan
env:
HD_ORG_ID: ${{ secrets.HD_ORG_ID }}
HD_AUTH_TOKEN: ${{ secrets.HD_AUTH_TOKEN }}
run: npx @herodevs/cli@beta scan eol -s
```

#### GitLab CI (authenticated scan)

Add CI/CD variables `HD_AUTH_TOKEN` and `HD_ORG_ID` (masked) in your project:

```yaml
eol-scan:
image: node:24
variables:
HD_ORG_ID: $HD_ORG_ID
HD_AUTH_TOKEN: $HD_AUTH_TOKEN
script:
- npx @herodevs/cli@beta scan eol -s
artifacts:
paths:
- herodevs.report.json
```

### Using the Docker Image (Recommended)

We provide a Docker image that's pre-configured to run EOL scans. Based on [`cdxgen`](https://github.com/CycloneDX/cdxgen),
Expand Down
45 changes: 36 additions & 9 deletions e2e/scan/eol.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,17 @@ const upToDateDir = path.resolve(fixturesDir, 'npm/up-to-date');
const upToDateSbom = path.join(fixturesDir, 'npm/up-to-date.sbom.json');
const noComponentsSbom = path.join(fixturesDir, 'npm/no-components.sbom.json');

function mockUserSetupStatus(orgId = 1) {
return {
eol: {
userSetupStatus: {
isComplete: true,
orgId,
},
},
};
}

function mockReport(components: DeepPartial<EolScanComponent>[] = []) {
return {
eol: {
Expand Down Expand Up @@ -84,7 +95,10 @@ describe('scan:eol e2e', () => {
nesRemediation: { remediations: [{ urls: { main: 'https://example.com' } }] },
},
];
fetchMock = new FetchMock().addGraphQL(mockReport(components)).addGraphQL(mockGetReport(components));
fetchMock = new FetchMock()
.addGraphQL(mockUserSetupStatus())
.addGraphQL(mockReport(components))
.addGraphQL(mockGetReport(components));
});

afterEach(() => {
Expand Down Expand Up @@ -243,7 +257,10 @@ describe('scan:eol e2e', () => {
{ purl: 'pkg:npm/vue@3.5.13', metadata: {} },
];
fetchMock.restore();
fetchMock = new FetchMock().addGraphQL(mockReport(components)).addGraphQL(mockGetReport(components));
fetchMock = new FetchMock()
.addGraphQL(mockUserSetupStatus())
.addGraphQL(mockReport(components))
.addGraphQL(mockGetReport(components));
const cmd = `scan:eol --file ${upToDateSbom}`;
const { stdout } = await run(cmd);
match(stdout, /Scan results:/, 'Should show results header');
Expand All @@ -253,7 +270,10 @@ describe('scan:eol e2e', () => {

it('handles empty components array without errors', async () => {
fetchMock.restore();
fetchMock = new FetchMock().addGraphQL(mockReport([])).addGraphQL(mockGetReport([]));
fetchMock = new FetchMock()
.addGraphQL(mockUserSetupStatus())
.addGraphQL(mockReport([]))
.addGraphQL(mockGetReport([]));
const cmd = `scan:eol --file ${noComponentsSbom}`;
const { stdout } = await run(cmd);
match(stdout, /No components found in scan/, 'Should show no packages found in scan');
Expand All @@ -276,7 +296,7 @@ describe('scan:eol e2e', () => {
});

it('warns and skips saving when --sbomOutput is provided without --saveSbom', async () => {
const customDir = path.join(fixturesDir, 'sbom-outputs');
const customDir = path.join(tmpdir(), 'scan-eol-sbom-output', randomUUID());
const customPath = path.join(customDir, 'custom-sbom.json');
await mkdir(customDir, { recursive: true });

Expand Down Expand Up @@ -398,7 +418,10 @@ describe('scan:eol e2e', () => {
{ purl: 'pkg:npm/bootstrap@5.3.5', metadata: {} },
{ purl: 'pkg:npm/vue@3.5.13', metadata: {} },
];
fetchMock = new FetchMock().addGraphQL(mockReport(components)).addGraphQL(mockGetReport(components));
fetchMock = new FetchMock()
.addGraphQL(mockUserSetupStatus())
.addGraphQL(mockReport(components))
.addGraphQL(mockGetReport(components));
const cmd = `scan:eol --dir ${upToDateDir}`;
const { stdout } = await run(cmd);
match(stdout, /Scan results:/, 'Should show results header');
Expand Down Expand Up @@ -592,7 +615,9 @@ describe('scan:eol e2e', () => {
it('fails when NES returns unsuccessful result', async () => {
// Override fetch mock to return unsuccessful mutation for this test
fetchMock.restore();
fetchMock = new FetchMock().addGraphQL({ eol: { createReport: { success: false, id: null, totalRecords: 0 } } });
fetchMock = new FetchMock()
.addGraphQL(mockUserSetupStatus())
.addGraphQL({ eol: { createReport: { success: false, id: null, totalRecords: 0 } } });
const out = await runExpectFail(`scan:eol --file ${simpleSbom}`);
match(
combinedOutputText(out),
Expand All @@ -603,9 +628,11 @@ describe('scan:eol e2e', () => {

it('fails when NES returns GraphQL errors', async () => {
fetchMock.restore();
fetchMock = new FetchMock().addGraphQL({ eol: { createReport: null } }, [
{ message: 'Internal server error', path: ['eol', 'createReport'] },
]);
fetchMock = new FetchMock()
.addGraphQL(mockUserSetupStatus())
.addGraphQL({ eol: { createReport: null } }, [
{ message: 'Internal server error', path: ['eol', 'createReport'] },
]);
const out = await runExpectFail(`scan:eol --file ${simpleSbom}`);
match(
combinedOutputText(out),
Expand Down
Loading