Skip to content

feat: add support for targetting specific workspaces #1212

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Mar 19, 2025
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
91 changes: 53 additions & 38 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,46 +75,61 @@ Usage: cyclonedx-npm [options] [--] [<package-manifest>]
Create CycloneDX Software Bill of Materials (SBOM) from Node.js NPM projects.

Arguments:
<package-manifest> Path to project's manifest file.
(default: "package.json" file in current working directory)
<package-manifest> Path to project's manifest file.
(default: "package.json" file in current working directory)

Options:
--ignore-npm-errors Whether to ignore errors of NPM.
This might be used, if "npm install" was run with "--force" or "--legacy-peer-deps".
(default: false)
--package-lock-only Whether to only use the lock file, ignoring "node_modules".
This means the output will be based only on the few details in and the tree described by the "npm-shrinkwrap.json" or "package-lock.json", rather than the contents of "node_modules" directory.
(default: false)
--omit <type...> Dependency types to omit from the installation tree.
(can be set multiple times)
(choices: "dev", "optional", "peer", default: "dev" if the NODE_ENV environment variable is set to "production", otherwise empty)
--gather-license-texts Search for license files in components and include them as license evidence.
This feature is experimental. (default: false)
--flatten-components Whether to flatten the components.
This means the actual nesting of node packages is not represented in the SBOM result.
(default: false)
--short-PURLs Omit all qualifiers from PackageURLs.
This causes information loss in trade-off shorter PURLs, which might improve ingesting these strings.
(default: false)
--spec-version <version> Which version of CycloneDX spec to use.
(choices: "1.2", "1.3", "1.4", "1.5", "1.6", default: "1.6")
--output-reproducible Whether to go the extra mile and make the output reproducible.
This requires more resources, and might result in loss of time- and random-based-values.
(env: BOM_REPRODUCIBLE)
--output-format <format> Which output format to use.
(choices: "JSON", "XML", default: "JSON")
--output-file <file> Path to the output file.
Set to "-" to write to STDOUT.
(default: write to STDOUT)
--validate Validate resulting BOM before outputting.
Validation is skipped, if requirements not met. See the README.
--no-validate Disable validation of resulting BOM.
--mc-type <type> Type of the main component.
(choices: "application", "firmware", "library", default: "application")
-v, --verbose Increase the verbosity of messages.
Use multiple times to increase the verbosity even more.
-V, --version output the version number
-h, --help display help for command
--ignore-npm-errors Whether to ignore errors of NPM.
This might be used, if "npm install" was run with "--force" or "--legacy-peer-deps".
(default: false)
--package-lock-only Whether to only use the lock file, ignoring "node_modules".
This means the output will be based only on the few details in and the tree described by the "npm-shrinkwrap.json" or "package-lock.json", rather than the contents of "node_modules" directory.
(default: false)
--omit <type...> Dependency types to omit from the installation tree.
(can be set multiple times)
(choices: "dev", "optional", "peer", default: "dev" if the NODE_ENV environment variable is set to "production", otherwise empty)
--gather-license-texts Search for license files in components and include them as license evidence.
This feature is experimental. (default: false)
--flatten-components Whether to flatten the components.
This means the actual nesting of node packages is not represented in the SBOM result.
(default: false)
--short-PURLs Omit all qualifiers from PackageURLs.
This causes information loss in trade-off shorter PURLs, which might improve ingesting these strings.
(default: false)
--spec-version <version> Which version of CycloneDX spec to use.
(choices: "1.2", "1.3", "1.4", "1.5", "1.6", default: "1.6")
--output-reproducible Whether to go the extra mile and make the output reproducible.
This requires more resources, and might result in loss of time- and random-based-values.
(env: BOM_REPRODUCIBLE)
--output-format <format> Which output format to use.
(choices: "JSON", "XML", default: "JSON")
--output-file <file> Path to the output file.
Set to "-" to write to STDOUT.
(default: write to STDOUT)
--validate Validate resulting BOM before outputting.
Validation is skipped, if requirements not met. See the README.
--no-validate Disable validation of resulting BOM.
--mc-type <type> Type of the main component.
(choices: "application", "firmware", "library", default: "application")
-w --workspace <workspace...> Only include dependencies for a specific workspace.
This feature is experimental. (default: empty)
(can be set multiple times)
--no-workspaces Do not include dependencies for workspaces.
Default behaviour is to include dependencies for all configured workspaces.
This can not be used if workspaces have been explicitly defined using "-w" or "--workspace"
This feature is experimental.
--include-workspace-root Include workspace root dependencies along with explicitly defined workspaces' dependencies.
This can only be used if you have explicitly defined workspaces using "-w" or "--workspace".
Default behaviour is to not include the workspace root when workspaces are excplicitly defined using "-w" or "--workspace".
This feature is experimental.
--no-include-workspace-root Do not include workspace root dependencies. This only has an effect if you have one or more workspaces configured in your project.
This is useful if you want to include all dependencies for all workspaces without explicitly defining them with "-w" or "--workspace" (default behaviour) but you do not
want workspace root dependencies included.
This feature is experimental.
-v, --verbose Increase the verbosity of messages.
Use multiple times to increase the verbosity even more.
-V, --version output the version number
-h, --help display help for command
```

## Demo
Expand Down
35 changes: 31 additions & 4 deletions src/builders.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,9 @@ interface BomBuilderOptions {
flattenComponents?: BomBuilder['flattenComponents']
shortPURLs?: BomBuilder['shortPURLs']
gatherLicenseTexts?: BomBuilder['gatherLicenseTexts']
workspace?: BomBuilder['workspace']
includeWorkspaceRoot?: BomBuilder['includeWorkspaceRoot']
workspaces?: BomBuilder['workspaces']
}

type cPath = string
Expand All @@ -64,6 +67,9 @@ export class BomBuilder {
flattenComponents: boolean
shortPURLs: boolean
gatherLicenseTexts: boolean
workspace: string[]
includeWorkspaceRoot?: boolean
workspaces?: boolean

console: Console

Expand All @@ -86,6 +92,9 @@ export class BomBuilder {
this.flattenComponents = options.flattenComponents ?? false
this.shortPURLs = options.shortPURLs ?? false
this.gatherLicenseTexts = options.gatherLicenseTexts ?? false
this.workspace = options.workspace ?? []
this.includeWorkspaceRoot = options.includeWorkspaceRoot
this.workspaces = options.workspaces

this.console = console_
}
Expand Down Expand Up @@ -172,6 +181,26 @@ export class BomBuilder {
}
}

// Although some workspace functionality is supported by npm 7 it is inconsistent with later versions. In order
// to provide a consistent and intuitive experience to users we do not support workspace functionality before npm 8.
if (npmVersionT[0] <= 7) {
if (this.workspace.length > 0 || this.workspaces !== undefined || this.includeWorkspaceRoot !== undefined) {
this.console.warn('WARN | your NPM does not fully support workspaces functionality, internally skipping workspace related options')
}
} else {
for (const workspace of this.workspace) {
args.push(`--workspace=${workspace}`)
}

if (this.includeWorkspaceRoot !== undefined) {
args.push(`--include-workspace-root=${this.includeWorkspaceRoot}`)
}

if (this.workspaces !== undefined) {
args.push(`--workspaces=${this.workspaces}`)
}
}

this.console.info('INFO | gathering dependency tree ...')
this.console.debug('DEBUG | npm-ls: run npm with %j in %j', args, projectDir)
let npmLsReturns: Buffer
Expand All @@ -193,9 +222,7 @@ export class BomBuilder {
this.console.error('%s', runError.stderr)
this.console.groupEnd()
if (!this.ignoreNpmErrors) {
throw new Error(`npm-ls exited with errors: ${
runError.status as string ?? 'noStatus'} ${
runError.signal as string ?? 'noSignal'}`)
throw new Error(`npm-ls exited with errors: ${runError.status as string ?? 'noStatus'} ${runError.signal as string ?? 'noSignal'}`)
}
this.console.debug('DEBUG | npm-ls exited with errors that are to be ignored.')
npmLsReturns = runError.stdout ?? Buffer.alloc(0)
Expand Down Expand Up @@ -355,7 +382,7 @@ export class BomBuilder {
* they fail to load package details or miss details.
* So here is a poly-fill that loads ALL the package's data.
*/
private enhancedPackageData <T>(data: T & { path: string }): T {
private enhancedPackageData<T>(data: T & { path: string }): T {
if (!path.isAbsolute(data.path)) {
this.console.debug('DEBUG | skip loading package manifest in %j', data.path)
return data
Expand Down
52 changes: 51 additions & 1 deletion src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,9 @@ interface CommandOptions {
ignoreNpmErrors: boolean
packageLockOnly: boolean
omit: Omittable[]
workspace: string[]
includeWorkspaceRoot: boolean | undefined
workspaces: boolean | undefined
gatherLicenseTexts: boolean
flattenComponents: boolean
shortPURLs: boolean
Expand Down Expand Up @@ -87,6 +90,37 @@ function makeCommand (process: NodeJS.Process): Command {
: [],
`"${Omittable.Dev}" if the NODE_ENV environment variable is set to "production", otherwise empty`
)
).addOption(
new Option(
'-w, --workspace <workspace...>',
'Only include dependencies for a specific workspace. ' +
'(can be set multiple times)\n' +
'This feature is experimental.'
).default([], 'empty')
).addOption(
new Option(
'--no-workspaces',
'Do not include dependencies for workspaces.\n' +
'Default behaviour is to include dependencies for all configured workspaces.\n' +
'This can not be used if workspaces have been explicitly defined using `-w` or `--workspace`\n' +
'This feature is experimental.'
).default(undefined).conflicts('workspace')
).addOption(
new Option(
'--include-workspace-root',
'Include workspace root dependencies along with explicitly defined workspaces\' dependencies. ' +
'This can only be used if you have explicitly defined workspaces using `-w` or `--workspace`.\n' +
'Default behaviour is to not include the workspace root when workspaces are excplicitly defined using `-w` or `--workspace`.\n' +
'This feature is experimental.'
).default(undefined)
).addOption(
new Option(
'--no-include-workspace-root',
'Do not include workspace root dependencies. This only has an effect if you have one or more workspaces configured in your project.\n' +
'This is useful if you want to include all dependencies for all workspaces without explicitly defining them with `-w` or `--workspace` (default behaviour) but ' +
'you do not want the workspace root dependencies included.\n' +
'This feature is experimental.'
).default(undefined)
).addOption(
new Option(
'--gather-license-texts',
Expand Down Expand Up @@ -238,6 +272,19 @@ export async function run (process: NodeJS.Process): Promise<number> {
throw new Error('missing evidence')
}

// Commander will default this option to true as there
// is no positive boolean parameter (we define --no-workspaces but
// no --workspaces).
if (options.workspaces === true) {
options.workspaces = undefined
}

if (options.includeWorkspaceRoot === true) {
if (options.workspace.length === 0) {
throw new Error('Can only use --include-workspace-root when --workspace is also configured')
}
}

myConsole.log('LOG | gathering BOM data ...')
const bom = new BomBuilder(
new Builders.FromNodePackageJson.ComponentBuilder(
Expand All @@ -254,7 +301,10 @@ export async function run (process: NodeJS.Process): Promise<number> {
gatherLicenseTexts: options.gatherLicenseTexts,
reproducible: options.outputReproducible,
flattenComponents: options.flattenComponents,
shortPURLs: options.shortPURLs
shortPURLs: options.shortPURLs,
workspace: options.workspace,
includeWorkspaceRoot: options.includeWorkspaceRoot,
workspaces: options.workspaces
},
myConsole
).buildFromProjectDir(projectDir, process)
Expand Down
34 changes: 33 additions & 1 deletion tests/integration/cli.args-pass-through.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -70,8 +70,40 @@ describe('integration.cli.args-pass-through', () => {
['package-lock-only npm 8', `8.${rMinor}.${rPatch}`, ['--package-lock-only'], [...npm8ArgsGeneral, '--package-lock-only']],
['package-lock-only npm 9', `9.${rMinor}.${rPatch}`, ['--package-lock-only'], [...npm9ArgsGeneral, '--package-lock-only']],
['package-lock-only npm 10', `10.${rMinor}.${rPatch}`, ['--package-lock-only'], [...npm10ArgsGeneral, '--package-lock-only']],
['package-lock-only npm 11', `11.${rMinor}.${rPatch}`, ['--package-lock-only'], [...npm11ArgsGeneral, '--package-lock-only']]
['package-lock-only npm 11', `11.${rMinor}.${rPatch}`, ['--package-lock-only'], [...npm11ArgsGeneral, '--package-lock-only']],
// endregion package-lock-only
// region workspace
['workspace not supported npm 6', `6.${rMinor}.${rPatch}`, ['--workspace', 'my-wsA', '-w', 'my-wsB'], [...npm6ArgsGeneral]],
['workspace not supported npm 7', `7.${rMinor}.${rPatch}`, ['--workspace', 'my-wsA', '-w', 'my-wsB'], [...npm7ArgsGeneral]],
['workspace npm 8', `8.${rMinor}.${rPatch}`, ['--workspace', 'my-wsA', '-w', 'my-wsB'], [...npm8ArgsGeneral, '--workspace=my-wsA', '--workspace=my-wsB']],
['workspace npm 9', `9.${rMinor}.${rPatch}`, ['--workspace', 'my-wsA', '-w', 'my-wsB'], [...npm9ArgsGeneral, '--workspace=my-wsA', '--workspace=my-wsB']],
['workspace npm 10', `10.${rMinor}.${rPatch}`, ['--workspace', 'my-wsA', '-w', 'my-wsB'], [...npm10ArgsGeneral, '--workspace=my-wsA', '--workspace=my-wsB']],
['workspace npm 11', `11.${rMinor}.${rPatch}`, ['--workspace', 'my-wsA', '-w', 'my-wsB'], [...npm11ArgsGeneral, '--workspace=my-wsA', '--workspace=my-wsB']],
// endregion workspace
// region include-workspace-root
['workspace root not supported npm 6', `6.${rMinor}.${rPatch}`, ['-w', 'my-wsA', '--include-workspace-root'], [...npm6ArgsGeneral]],
['workspace root not supported npm 7', `7.${rMinor}.${rPatch}`, ['-w', 'my-wsA', '--include-workspace-root'], [...npm7ArgsGeneral]],
['workspace root npm 8', `8.${rMinor}.${rPatch}`, ['--workspace', 'my-wsA', '-w', 'my-wsB', '--include-workspace-root'], [...npm8ArgsGeneral, '--workspace=my-wsA', '--workspace=my-wsB', '--include-workspace-root=true']],
['workspace root npm 9', `9.${rMinor}.${rPatch}`, ['--workspace', 'my-wsA', '-w', 'my-wsB', '--include-workspace-root'], [...npm9ArgsGeneral, '--workspace=my-wsA', '--workspace=my-wsB', '--include-workspace-root=true']],
['workspace root npm 10', `10.${rMinor}.${rPatch}`, ['--workspace', 'my-wsA', '-w', 'my-wsB', '--include-workspace-root'], [...npm10ArgsGeneral, '--workspace=my-wsA', '--workspace=my-wsB', '--include-workspace-root=true']],
['workspace root npm 11', `11.${rMinor}.${rPatch}`, ['--workspace', 'my-wsA', '-w', 'my-wsB', '--include-workspace-root'], [...npm11ArgsGeneral, '--workspace=my-wsA', '--workspace=my-wsB', '--include-workspace-root=true']],
// endregion include-workspace-root
// region no-include-workspace-root
['no workspace root not supported npm 6', `6.${rMinor}.${rPatch}`, ['--no-include-workspace-root'], [...npm6ArgsGeneral]],
['no workspace root not supported npm 7', `7.${rMinor}.${rPatch}`, ['--no-include-workspace-root'], [...npm7ArgsGeneral]],
['no workspace root npm 8', `8.${rMinor}.${rPatch}`, ['--no-include-workspace-root'], [...npm8ArgsGeneral, '--include-workspace-root=false']],
['no workspace root npm 9', `9.${rMinor}.${rPatch}`, ['--no-include-workspace-root'], [...npm9ArgsGeneral, '--include-workspace-root=false']],
['no workspace root npm 10', `10.${rMinor}.${rPatch}`, ['--no-include-workspace-root'], [...npm10ArgsGeneral, '--include-workspace-root=false']],
['no workspace root npm 11', `11.${rMinor}.${rPatch}`, ['--no-include-workspace-root'], [...npm11ArgsGeneral, '--include-workspace-root=false']],
// endregion no-include-workspace-root
// region no-workspaces
['no workspaces not supported npm 6', `6.${rMinor}.${rPatch}`, ['--no-workspaces'], [...npm6ArgsGeneral]],
['no workspaces not supported npm 7', `7.${rMinor}.${rPatch}`, ['--no-workspaces'], [...npm7ArgsGeneral]],
['workspaces npm 8', `8.${rMinor}.${rPatch}`, ['--no-workspaces'], [...npm8ArgsGeneral, '--workspaces=false']],
['workspaces npm 9', `9.${rMinor}.${rPatch}`, ['--no-workspaces'], [...npm9ArgsGeneral, '--workspaces=false']],
['workspaces npm 10', `10.${rMinor}.${rPatch}`, ['--no-workspaces'], [...npm10ArgsGeneral, '--workspaces=false']],
['workspaces npm 11', `11.${rMinor}.${rPatch}`, ['--no-workspaces'], [...npm11ArgsGeneral, '--workspaces=false']]
// endregion no-workspaces
])('%s', async (purpose, npmVersion, cdxArgs, expectedArgs) => {
const logFileBase = join(tmpRootRun, purpose.replace(/\W/g, '_'))
const cwd = dummyProjectsRoot
Expand Down