Skip to content

Commit df3cdf0

Browse files
committed
feat: add support for targetting specific workspaces
Signed-off-by: MalickBurger <[email protected]>
1 parent 15da89b commit df3cdf0

File tree

4 files changed

+168
-44
lines changed

4 files changed

+168
-44
lines changed

README.md

Lines changed: 53 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -75,46 +75,61 @@ Usage: cyclonedx-npm [options] [--] [<package-manifest>]
7575
Create CycloneDX Software Bill of Materials (SBOM) from Node.js NPM projects.
7676
7777
Arguments:
78-
<package-manifest> Path to project's manifest file.
79-
(default: "package.json" file in current working directory)
78+
<package-manifest> Path to project's manifest file.
79+
(default: "package.json" file in current working directory)
8080
8181
Options:
82-
--ignore-npm-errors Whether to ignore errors of NPM.
83-
This might be used, if "npm install" was run with "--force" or "--legacy-peer-deps".
84-
(default: false)
85-
--package-lock-only Whether to only use the lock file, ignoring "node_modules".
86-
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.
87-
(default: false)
88-
--omit <type...> Dependency types to omit from the installation tree.
89-
(can be set multiple times)
90-
(choices: "dev", "optional", "peer", default: "dev" if the NODE_ENV environment variable is set to "production", otherwise empty)
91-
--gather-license-texts Search for license files in components and include them as license evidence.
92-
This feature is experimental. (default: false)
93-
--flatten-components Whether to flatten the components.
94-
This means the actual nesting of node packages is not represented in the SBOM result.
95-
(default: false)
96-
--short-PURLs Omit all qualifiers from PackageURLs.
97-
This causes information loss in trade-off shorter PURLs, which might improve ingesting these strings.
98-
(default: false)
99-
--spec-version <version> Which version of CycloneDX spec to use.
100-
(choices: "1.2", "1.3", "1.4", "1.5", "1.6", default: "1.6")
101-
--output-reproducible Whether to go the extra mile and make the output reproducible.
102-
This requires more resources, and might result in loss of time- and random-based-values.
103-
(env: BOM_REPRODUCIBLE)
104-
--output-format <format> Which output format to use.
105-
(choices: "JSON", "XML", default: "JSON")
106-
--output-file <file> Path to the output file.
107-
Set to "-" to write to STDOUT.
108-
(default: write to STDOUT)
109-
--validate Validate resulting BOM before outputting.
110-
Validation is skipped, if requirements not met. See the README.
111-
--no-validate Disable validation of resulting BOM.
112-
--mc-type <type> Type of the main component.
113-
(choices: "application", "firmware", "library", default: "application")
114-
-v, --verbose Increase the verbosity of messages.
115-
Use multiple times to increase the verbosity even more.
116-
-V, --version output the version number
117-
-h, --help display help for command
82+
--ignore-npm-errors Whether to ignore errors of NPM.
83+
This might be used, if "npm install" was run with "--force" or "--legacy-peer-deps".
84+
(default: false)
85+
--package-lock-only Whether to only use the lock file, ignoring "node_modules".
86+
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.
87+
(default: false)
88+
--omit <type...> Dependency types to omit from the installation tree.
89+
(can be set multiple times)
90+
(choices: "dev", "optional", "peer", default: "dev" if the NODE_ENV environment variable is set to "production", otherwise empty)
91+
--gather-license-texts Search for license files in components and include them as license evidence.
92+
This feature is experimental. (default: false)
93+
--flatten-components Whether to flatten the components.
94+
This means the actual nesting of node packages is not represented in the SBOM result.
95+
(default: false)
96+
--short-PURLs Omit all qualifiers from PackageURLs.
97+
This causes information loss in trade-off shorter PURLs, which might improve ingesting these strings.
98+
(default: false)
99+
--spec-version <version> Which version of CycloneDX spec to use.
100+
(choices: "1.2", "1.3", "1.4", "1.5", "1.6", default: "1.6")
101+
--output-reproducible Whether to go the extra mile and make the output reproducible.
102+
This requires more resources, and might result in loss of time- and random-based-values.
103+
(env: BOM_REPRODUCIBLE)
104+
--output-format <format> Which output format to use.
105+
(choices: "JSON", "XML", default: "JSON")
106+
--output-file <file> Path to the output file.
107+
Set to "-" to write to STDOUT.
108+
(default: write to STDOUT)
109+
--validate Validate resulting BOM before outputting.
110+
Validation is skipped, if requirements not met. See the README.
111+
--no-validate Disable validation of resulting BOM.
112+
--mc-type <type> Type of the main component.
113+
(choices: "application", "firmware", "library", default: "application")
114+
-w --workspace <workspace...> Only include dependencies for a specific workspace.
115+
This feature is experimental. (default: empty)
116+
(can be set multiple times)
117+
--no-workspaces Do not include dependencies for workspaces.
118+
Default behaviour is to include dependencies for all configured workspaces.
119+
This can not be used if workspaces have been explicitly defined using "-w" or "--workspace"
120+
This feature is experimental.
121+
--include-workspace-root Include workspace root dependencies along with explicitly defined workspaces' dependencies.
122+
This can only be used if you have explicitly defined workspaces using "-w" or "--workspace".
123+
Default behaviour is to not include the workspace root when workspaces are excplicitly defined using "-w" or "--workspace".
124+
This feature is experimental.
125+
--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.
126+
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
127+
want workspace root dependencies included.
128+
This feature is experimental.
129+
-v, --verbose Increase the verbosity of messages.
130+
Use multiple times to increase the verbosity even more.
131+
-V, --version output the version number
132+
-h, --help display help for command
118133
```
119134

120135
## Demo

src/builders.ts

Lines changed: 31 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,9 @@ interface BomBuilderOptions {
4545
flattenComponents?: BomBuilder['flattenComponents']
4646
shortPURLs?: BomBuilder['shortPURLs']
4747
gatherLicenseTexts?: BomBuilder['gatherLicenseTexts']
48+
workspace?: BomBuilder['workspace']
49+
includeWorkspaceRoot?: BomBuilder['includeWorkspaceRoot']
50+
workspaces?: BomBuilder['workspaces']
4851
}
4952

5053
type cPath = string
@@ -64,6 +67,9 @@ export class BomBuilder {
6467
flattenComponents: boolean
6568
shortPURLs: boolean
6669
gatherLicenseTexts: boolean
70+
workspace: string[]
71+
includeWorkspaceRoot?: boolean
72+
workspaces?: boolean
6773

6874
console: Console
6975

@@ -86,6 +92,9 @@ export class BomBuilder {
8692
this.flattenComponents = options.flattenComponents ?? false
8793
this.shortPURLs = options.shortPURLs ?? false
8894
this.gatherLicenseTexts = options.gatherLicenseTexts ?? false
95+
this.workspace = options.workspace ?? []
96+
this.includeWorkspaceRoot = options.includeWorkspaceRoot
97+
this.workspaces = options.workspaces
8998

9099
this.console = console_
91100
}
@@ -172,6 +181,26 @@ export class BomBuilder {
172181
}
173182
}
174183

184+
// Although some workspace functionality is supported by npm 7 it is inconsistent with later versions. In order
185+
// to provide a consistent and intuitive experience to users we do not support workspace functionality before npm 8.
186+
if (npmVersionT[0] <= 7) {
187+
if (this.workspace.length > 0 || this.workspaces !== undefined || this.includeWorkspaceRoot !== undefined) {
188+
this.console.warn('WARN | your NPM does not fully support workspaces functionality, internally skipping workspace related options')
189+
}
190+
} else {
191+
for (const workspace of this.workspace) {
192+
args.push(`--workspace=${workspace}`)
193+
}
194+
195+
if (this.includeWorkspaceRoot !== undefined) {
196+
args.push(`--include-workspace-root=${this.includeWorkspaceRoot}`)
197+
}
198+
199+
if (this.workspaces !== undefined) {
200+
args.push(`--workspaces=${this.workspaces}`)
201+
}
202+
}
203+
175204
this.console.info('INFO | gathering dependency tree ...')
176205
this.console.debug('DEBUG | npm-ls: run npm with %j in %j', args, projectDir)
177206
let npmLsReturns: Buffer
@@ -193,9 +222,7 @@ export class BomBuilder {
193222
this.console.error('%s', runError.stderr)
194223
this.console.groupEnd()
195224
if (!this.ignoreNpmErrors) {
196-
throw new Error(`npm-ls exited with errors: ${
197-
runError.status as string ?? 'noStatus'} ${
198-
runError.signal as string ?? 'noSignal'}`)
225+
throw new Error(`npm-ls exited with errors: ${runError.status as string ?? 'noStatus'} ${runError.signal as string ?? 'noSignal'}`)
199226
}
200227
this.console.debug('DEBUG | npm-ls exited with errors that are to be ignored.')
201228
npmLsReturns = runError.stdout ?? Buffer.alloc(0)
@@ -355,7 +382,7 @@ export class BomBuilder {
355382
* they fail to load package details or miss details.
356383
* So here is a poly-fill that loads ALL the package's data.
357384
*/
358-
private enhancedPackageData <T>(data: T & { path: string }): T {
385+
private enhancedPackageData<T>(data: T & { path: string }): T {
359386
if (!path.isAbsolute(data.path)) {
360387
this.console.debug('DEBUG | skip loading package manifest in %j', data.path)
361388
return data

src/cli.ts

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,9 @@ interface CommandOptions {
4343
ignoreNpmErrors: boolean
4444
packageLockOnly: boolean
4545
omit: Omittable[]
46+
workspace: string[]
47+
includeWorkspaceRoot: boolean | undefined
48+
workspaces: boolean | undefined
4649
gatherLicenseTexts: boolean
4750
flattenComponents: boolean
4851
shortPURLs: boolean
@@ -87,6 +90,37 @@ function makeCommand (process: NodeJS.Process): Command {
8790
: [],
8891
`"${Omittable.Dev}" if the NODE_ENV environment variable is set to "production", otherwise empty`
8992
)
93+
).addOption(
94+
new Option(
95+
'-w, --workspace <workspace...>',
96+
'Only include dependencies for a specific workspace. ' +
97+
'(can be set multiple times)\n' +
98+
'This feature is experimental.'
99+
).default([], 'empty')
100+
).addOption(
101+
new Option(
102+
'--no-workspaces',
103+
'Do not include dependencies for workspaces.\n' +
104+
'Default behaviour is to include dependencies for all configured workspaces.\n' +
105+
'This can not be used if workspaces have been explicitly defined using `-w` or `--workspace`\n' +
106+
'This feature is experimental.'
107+
).default(undefined).conflicts('workspace')
108+
).addOption(
109+
new Option(
110+
'--include-workspace-root',
111+
'Include workspace root dependencies along with explicitly defined workspaces\' dependencies. ' +
112+
'This can only be used if you have explicitly defined workspaces using `-w` or `--workspace`.\n' +
113+
'Default behaviour is to not include the workspace root when workspaces are excplicitly defined using `-w` or `--workspace`.\n' +
114+
'This feature is experimental.'
115+
).default(undefined)
116+
).addOption(
117+
new Option(
118+
'--no-include-workspace-root',
119+
'Do not include workspace root dependencies. This only has an effect if you have one or more workspaces configured in your project.\n' +
120+
'This is useful if you want to include all dependencies for all workspaces without explicitly defining them with `-w` or `--workspace` (default behaviour) but ' +
121+
'you do not want the workspace root dependencies included.\n' +
122+
'This feature is experimental.'
123+
).default(undefined)
90124
).addOption(
91125
new Option(
92126
'--gather-license-texts',
@@ -238,6 +272,19 @@ export async function run (process: NodeJS.Process): Promise<number> {
238272
throw new Error('missing evidence')
239273
}
240274

275+
// Commander will default this option to true as there
276+
// is no positive boolean parameter (we define --no-workspaces but
277+
// no --workspaces).
278+
if (options.workspaces === true) {
279+
options.workspaces = undefined
280+
}
281+
282+
if (options.includeWorkspaceRoot === true) {
283+
if (options.workspace.length === 0) {
284+
throw new Error('Can only use --include-workspace-root when --workspace is also configured')
285+
}
286+
}
287+
241288
myConsole.log('LOG | gathering BOM data ...')
242289
const bom = new BomBuilder(
243290
new Builders.FromNodePackageJson.ComponentBuilder(
@@ -254,7 +301,10 @@ export async function run (process: NodeJS.Process): Promise<number> {
254301
gatherLicenseTexts: options.gatherLicenseTexts,
255302
reproducible: options.outputReproducible,
256303
flattenComponents: options.flattenComponents,
257-
shortPURLs: options.shortPURLs
304+
shortPURLs: options.shortPURLs,
305+
workspace: options.workspace,
306+
includeWorkspaceRoot: options.includeWorkspaceRoot,
307+
workspaces: options.workspaces
258308
},
259309
myConsole
260310
).buildFromProjectDir(projectDir, process)

tests/integration/cli.args-pass-through.test.js

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,8 +70,40 @@ describe('integration.cli.args-pass-through', () => {
7070
['package-lock-only npm 8', `8.${rMinor}.${rPatch}`, ['--package-lock-only'], [...npm8ArgsGeneral, '--package-lock-only']],
7171
['package-lock-only npm 9', `9.${rMinor}.${rPatch}`, ['--package-lock-only'], [...npm9ArgsGeneral, '--package-lock-only']],
7272
['package-lock-only npm 10', `10.${rMinor}.${rPatch}`, ['--package-lock-only'], [...npm10ArgsGeneral, '--package-lock-only']],
73-
['package-lock-only npm 11', `11.${rMinor}.${rPatch}`, ['--package-lock-only'], [...npm11ArgsGeneral, '--package-lock-only']]
73+
['package-lock-only npm 11', `11.${rMinor}.${rPatch}`, ['--package-lock-only'], [...npm11ArgsGeneral, '--package-lock-only']],
7474
// endregion package-lock-only
75+
// region workspace
76+
['workspace not supported npm 6', `6.${rMinor}.${rPatch}`, ['--workspace', 'my-wsA', '-w', 'my-wsB'], [...npm6ArgsGeneral]],
77+
['workspace not supported npm 7', `7.${rMinor}.${rPatch}`, ['--workspace', 'my-wsA', '-w', 'my-wsB'], [...npm7ArgsGeneral]],
78+
['workspace npm 8', `8.${rMinor}.${rPatch}`, ['--workspace', 'my-wsA', '-w', 'my-wsB'], [...npm8ArgsGeneral, '--workspace=my-wsA', '--workspace=my-wsB']],
79+
['workspace npm 9', `9.${rMinor}.${rPatch}`, ['--workspace', 'my-wsA', '-w', 'my-wsB'], [...npm9ArgsGeneral, '--workspace=my-wsA', '--workspace=my-wsB']],
80+
['workspace npm 10', `10.${rMinor}.${rPatch}`, ['--workspace', 'my-wsA', '-w', 'my-wsB'], [...npm10ArgsGeneral, '--workspace=my-wsA', '--workspace=my-wsB']],
81+
['workspace npm 11', `11.${rMinor}.${rPatch}`, ['--workspace', 'my-wsA', '-w', 'my-wsB'], [...npm11ArgsGeneral, '--workspace=my-wsA', '--workspace=my-wsB']],
82+
// endregion workspace
83+
// region include-workspace-root
84+
['workspace root not supported npm 6', `6.${rMinor}.${rPatch}`, ['-w', 'my-wsA', '--include-workspace-root'], [...npm6ArgsGeneral]],
85+
['workspace root not supported npm 7', `7.${rMinor}.${rPatch}`, ['-w', 'my-wsA', '--include-workspace-root'], [...npm7ArgsGeneral]],
86+
['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']],
87+
['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']],
88+
['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']],
89+
['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']],
90+
// endregion include-workspace-root
91+
// region no-include-workspace-root
92+
['no workspace root not supported npm 6', `6.${rMinor}.${rPatch}`, ['--no-include-workspace-root'], [...npm6ArgsGeneral]],
93+
['no workspace root not supported npm 7', `7.${rMinor}.${rPatch}`, ['--no-include-workspace-root'], [...npm7ArgsGeneral]],
94+
['no workspace root npm 8', `8.${rMinor}.${rPatch}`, ['--no-include-workspace-root'], [...npm8ArgsGeneral, '--include-workspace-root=false']],
95+
['no workspace root npm 9', `9.${rMinor}.${rPatch}`, ['--no-include-workspace-root'], [...npm9ArgsGeneral, '--include-workspace-root=false']],
96+
['no workspace root npm 10', `10.${rMinor}.${rPatch}`, ['--no-include-workspace-root'], [...npm10ArgsGeneral, '--include-workspace-root=false']],
97+
['no workspace root npm 11', `11.${rMinor}.${rPatch}`, ['--no-include-workspace-root'], [...npm11ArgsGeneral, '--include-workspace-root=false']],
98+
// endregion no-include-workspace-root
99+
// region no-workspaces
100+
['no workspaces not supported npm 6', `6.${rMinor}.${rPatch}`, ['--no-workspaces'], [...npm6ArgsGeneral]],
101+
['no workspaces not supported npm 7', `7.${rMinor}.${rPatch}`, ['--no-workspaces'], [...npm7ArgsGeneral]],
102+
['workspaces npm 8', `8.${rMinor}.${rPatch}`, ['--no-workspaces'], [...npm8ArgsGeneral, '--workspaces=false']],
103+
['workspaces npm 9', `9.${rMinor}.${rPatch}`, ['--no-workspaces'], [...npm9ArgsGeneral, '--workspaces=false']],
104+
['workspaces npm 10', `10.${rMinor}.${rPatch}`, ['--no-workspaces'], [...npm10ArgsGeneral, '--workspaces=false']],
105+
['workspaces npm 11', `11.${rMinor}.${rPatch}`, ['--no-workspaces'], [...npm11ArgsGeneral, '--workspaces=false']]
106+
// endregion no-workspaces
75107
])('%s', async (purpose, npmVersion, cdxArgs, expectedArgs) => {
76108
const logFileBase = join(tmpRootRun, purpose.replace(/\W/g, '_'))
77109
const cwd = dummyProjectsRoot

0 commit comments

Comments
 (0)