npq 3.23.3 → 3.24.0

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
@@ -222,6 +222,18 @@ npq install express --dry-run
222
222
  npq install express --plain
223
223
  ```
224
224
 
225
+ ### Machine-readable audits
226
+
227
+ Use `--json` for a non-interactive, audit-only result suitable for CI:
228
+
229
+ ```sh
230
+ npq install express --json
231
+ ```
232
+
233
+ JSON mode never invokes the package manager. It exits `0` for a clean audit,
234
+ `1` for findings, and `2` when the audit cannot complete. See
235
+ [JSON audit output](docs/feature/json-output.md).
236
+
225
237
  ### Disable auto-continue countdown
226
238
 
227
239
  By default, when npq detects only warnings (no errors), it automatically proceeds with installation after a 15-second countdown. To disable this behavior and always require explicit confirmation:
package/bin/npq-hero.js CHANGED
@@ -51,20 +51,13 @@ RegistryConfig.load({ argv: cliArgs.registryConfigArgs || [] })
51
51
  Object.hasOwn(results, 'countErrors') &&
52
52
  Object.hasOwn(results, 'countWarnings')
53
53
  ) {
54
- const {
55
- countErrors,
56
- countWarnings,
57
- countNotEvaluated = 0,
58
- useRichFormatting
59
- } = results
54
+ const { countErrors, countWarnings, countNotEvaluated = 0, useRichFormatting } = results
60
55
  const hasFindings = countErrors > 0 || countWarnings > 0
61
56
  const hasReportableResults = hasFindings || countNotEvaluated > 0
62
57
 
63
58
  if (hasReportableResults) {
64
59
  console.log()
65
- console.log(
66
- hasFindings ? 'Packages with issues found:' : 'Package checks not evaluated:'
67
- )
60
+ console.log(hasFindings ? 'Packages with issues found:' : 'Package checks not evaluated:')
68
61
 
69
62
  if (useRichFormatting) {
70
63
  console.log(results.resultsForPrettyPrint)
package/bin/npq.js CHANGED
@@ -15,171 +15,182 @@ const cliPrompt = require('../lib/helpers/cliPrompt.js')
15
15
  const { reportResults } = require('../lib/helpers/reportResults')
16
16
  const { Spinner } = require('../lib/helpers/cliSpinner')
17
17
  const { promiseThrottleHelper } = require('../lib/helpers/promiseThrottler')
18
+ const { createJsonOutput } = require('../lib/helpers/jsonOutput')
19
+ const { runJsonCli, writeInvalidJsonInvocation } = require('../lib/jsonCli')
18
20
  const RegistryConfig = require('../lib/helpers/registryConfig')
19
21
  const RegistryClient = require('../lib/helpers/registryClient')
20
22
 
21
23
  const debug = util.debuglog('npq')
22
24
 
23
- const cliArgs = CliParser.parseArgsFull()
24
- const auditOnly = cliArgs.dryRun || !cliArgs.installSubcommandExplicit
25
- const isInteractive = cliSupport.isInteractiveTerminal() && !cliArgs.plain
26
- const spinner = isInteractive ? new Spinner({ text: 'Initiating...' }) : null
25
+ const jsonRequested = process.argv.slice(2).includes('--json')
26
+ const jsonOutput = jsonRequested ? createJsonOutput() : null
27
+ let cliArgs
27
28
 
28
- if (spinner) {
29
- spinner.start()
29
+ try {
30
+ cliArgs = CliParser.parseArgsFull()
31
+ } catch (error) {
32
+ if (!jsonRequested) throw error
33
+ writeInvalidJsonInvocation(jsonOutput)
34
+ process.exitCode = 2
30
35
  }
31
36
 
32
- let registryClient
37
+ if (cliArgs && cliArgs.json) {
38
+ runJsonCli(cliArgs, { output: jsonOutput })
39
+ } else if (cliArgs) {
40
+ const auditOnly = cliArgs.dryRun || !cliArgs.installSubcommandExplicit
41
+ const isInteractive = cliSupport.isInteractiveTerminal() && !cliArgs.plain
42
+ const spinner = isInteractive ? new Spinner({ text: 'Initiating...' }) : null
33
43
 
34
- RegistryConfig.load({ argv: cliArgs.registryConfigArgs || [] })
35
- .then((registryConfig) => {
36
- registryClient = new RegistryClient(registryConfig)
37
- })
38
- .then(() => {
39
- if (cliArgs.packages.length === 0) {
40
- debug('\nNo packages specified, using project packages from package.json')
41
- return getProjectPackages()
42
- }
44
+ if (spinner) {
45
+ spinner.start()
46
+ }
43
47
 
44
- return cliArgs.packages
45
- })
46
- .then((packages) => {
47
- if (packages.error) {
48
- console.log()
49
- CliParser.exit({
50
- errorCode: packages.error.code || -1,
51
- message: packages.message,
52
- spinner
53
- })
54
- }
48
+ let registryClient
55
49
 
56
- const marshall = new Marshall({
57
- pkgs: packages,
58
- registryClient,
59
- progressManager: spinner,
60
- promiseThrottleHelper
50
+ Promise.resolve()
51
+ .then(() => RegistryConfig.load({ argv: cliArgs.registryConfigArgs || [] }))
52
+ .then((registryConfig) => {
53
+ registryClient = new RegistryClient(registryConfig)
61
54
  })
55
+ .then(() => {
56
+ if (cliArgs.packages.length === 0) {
57
+ debug('\nNo packages specified, using project packages from package.json')
58
+ return getProjectPackages()
59
+ }
62
60
 
63
- return marshall.process()
64
- })
65
- .then((marshallResults) => {
66
- if (spinner) {
67
- spinner.stop()
68
- }
69
-
70
- const results = reportResults(marshallResults, { plain: cliArgs.plain })
71
- if (
72
- results &&
73
- Object.hasOwn(results, 'countErrors') &&
74
- Object.hasOwn(results, 'countWarnings')
75
- ) {
76
- const {
77
- countErrors,
78
- countWarnings,
79
- countNotEvaluated = 0,
80
- useRichFormatting
81
- } = results
82
- const hasFindings = countErrors > 0 || countWarnings > 0
83
- const hasReportableResults = hasFindings || countNotEvaluated > 0
84
-
85
- if (hasReportableResults) {
61
+ return cliArgs.packages
62
+ })
63
+ .then((packages) => {
64
+ if (packages.error) {
86
65
  console.log()
87
- console.log(
88
- hasFindings ? 'Packages with issues found:' : 'Package checks not evaluated:'
89
- )
90
-
91
- if (useRichFormatting) {
92
- console.log(results.resultsForPrettyPrint)
93
- console.log(results.summaryForPrettyPrint)
94
- } else {
95
- console.log(results.resultsForPlainTextPrint)
96
- console.log(results.summaryForPlainTextPrint)
97
- }
66
+ CliParser.exit({
67
+ errorCode: packages.error.code || -1,
68
+ message: packages.message,
69
+ spinner
70
+ })
98
71
  }
99
72
 
100
- return {
101
- anyIssues: hasFindings,
102
- countErrors,
103
- countWarnings,
104
- countNotEvaluated
105
- }
106
- }
107
- return undefined
108
- })
109
- .then((result) => {
110
- if (auditOnly) {
111
- const auditFailed = result && (result.countErrors > 0 || result.countWarnings > 0)
112
- CliParser.exit({
113
- errorCode: auditFailed ? 1 : 0,
114
- spinner
73
+ const marshall = new Marshall({
74
+ pkgs: packages,
75
+ registryClient,
76
+ progressManager: spinner,
77
+ promiseThrottleHelper
115
78
  })
79
+
80
+ return marshall.process()
81
+ })
82
+ .then((marshallResults) => {
83
+ if (spinner) {
84
+ spinner.stop()
85
+ }
86
+
87
+ const results = reportResults(marshallResults, { plain: cliArgs.plain })
88
+ if (
89
+ results &&
90
+ Object.hasOwn(results, 'countErrors') &&
91
+ Object.hasOwn(results, 'countWarnings')
92
+ ) {
93
+ const { countErrors, countWarnings, countNotEvaluated = 0, useRichFormatting } = results
94
+ const hasFindings = countErrors > 0 || countWarnings > 0
95
+ const hasReportableResults = hasFindings || countNotEvaluated > 0
96
+
97
+ if (hasReportableResults) {
98
+ console.log()
99
+ console.log(hasFindings ? 'Packages with issues found:' : 'Package checks not evaluated:')
100
+
101
+ if (useRichFormatting) {
102
+ console.log(results.resultsForPrettyPrint)
103
+ console.log(results.summaryForPrettyPrint)
104
+ } else {
105
+ console.log(results.resultsForPlainTextPrint)
106
+ console.log(results.summaryForPlainTextPrint)
107
+ }
108
+ }
109
+
110
+ return {
111
+ anyIssues: hasFindings,
112
+ countErrors,
113
+ countWarnings,
114
+ countNotEvaluated
115
+ }
116
+ }
116
117
  return undefined
117
- }
118
-
119
- if (result && result.countErrors > 0) {
120
- console.log()
121
- return cliPrompt.prompt({
122
- name: 'install',
123
- message: 'Continue install ?',
124
- default: false
125
- })
126
- } else {
127
- if (result && result.countWarnings > 0) {
118
+ })
119
+ .then((result) => {
120
+ if (auditOnly) {
121
+ const auditFailed = result && (result.countErrors > 0 || result.countWarnings > 0)
122
+ CliParser.exit({
123
+ errorCode: auditFailed ? 1 : 0,
124
+ spinner
125
+ })
126
+ return undefined
127
+ }
128
+
129
+ if (result && result.countErrors > 0) {
128
130
  console.log()
129
- // Check if auto-continue is disabled via CLI flag or environment variable
130
- if (cliArgs.disableAutoContinue) {
131
- return cliPrompt.prompt({
131
+ return cliPrompt.prompt({
132
+ name: 'install',
133
+ message: 'Continue install ?',
134
+ default: false
135
+ })
136
+ } else {
137
+ if (result && result.countWarnings > 0) {
138
+ console.log()
139
+ // Check if auto-continue is disabled via CLI flag or environment variable
140
+ if (cliArgs.disableAutoContinue) {
141
+ return cliPrompt.prompt({
142
+ name: 'install',
143
+ message: 'Continue install ?',
144
+ default: false
145
+ })
146
+ }
147
+ return cliPrompt.autoContinue({
132
148
  name: 'install',
133
- message: 'Continue install ?',
134
- default: false
149
+ message: 'Auto-continue with install in... ',
150
+ timeInSeconds: 15
135
151
  })
136
152
  }
137
- return cliPrompt.autoContinue({
138
- name: 'install',
139
- message: 'Auto-continue with install in... ',
140
- timeInSeconds: 15
141
- })
142
153
  }
143
- }
144
154
 
145
- return { install: true }
146
- })
147
- .then((status) => {
148
- if (
149
- status &&
150
- Object.prototype.hasOwnProperty.call(status, 'install') &&
151
- status.install === true
152
- ) {
153
- return pkgMgr.process(cliArgs.packageManager)
154
- }
155
- })
156
- .then((exitCode) => {
157
- if (typeof exitCode === 'number') {
158
- process.exitCode = exitCode
159
- }
160
- })
161
- .catch((error) => {
162
- // Ensure errorCode is always a number
163
- let errorCode = -1
164
- if (typeof error.code === 'number') {
165
- errorCode = error.code
166
- } else if (error.code === 'ABORT_ERR') {
167
- errorCode = 1
168
- } else if (error.code === 'USER_ABORT') {
169
- errorCode = error.exitCode || 1
170
- }
155
+ return { install: true }
156
+ })
157
+ .then((status) => {
158
+ if (
159
+ status &&
160
+ Object.prototype.hasOwnProperty.call(status, 'install') &&
161
+ status.install === true
162
+ ) {
163
+ return pkgMgr.process(cliArgs.packageManager)
164
+ }
165
+ })
166
+ .then((exitCode) => {
167
+ if (typeof exitCode === 'number') {
168
+ process.exitCode = exitCode
169
+ }
170
+ })
171
+ .catch((error) => {
172
+ // Ensure errorCode is always a number
173
+ let errorCode = -1
174
+ if (typeof error.code === 'number') {
175
+ errorCode = error.code
176
+ } else if (error.code === 'ABORT_ERR') {
177
+ errorCode = 1
178
+ } else if (error.code === 'USER_ABORT') {
179
+ errorCode = error.exitCode || 1
180
+ }
181
+
182
+ CliParser.exit({
183
+ errorCode,
184
+ message: error.message || 'An error occurred',
185
+ spinner
186
+ })
187
+ })
171
188
 
189
+ // attach event handler for CTRL+C
190
+ process.on('SIGINT', () => {
172
191
  CliParser.exit({
173
- errorCode,
174
- message: error.message || 'An error occurred',
192
+ errorCode: 0,
175
193
  spinner
176
194
  })
177
195
  })
178
-
179
- // attach event handler for CTRL+C
180
- process.on('SIGINT', () => {
181
- CliParser.exit({
182
- errorCode: 0,
183
- spinner
184
- })
185
- })
196
+ }
package/lib/cli.js CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  const { parseArgs } = require('node:util')
4
4
  const npa = require('npm-package-arg')
5
+ const { parseJsonRegistryPackageSpec } = require('./helpers/jsonPackageSpec')
5
6
  const pkg = require('../package.json')
6
7
  const { parsePackageManagerArguments } = require('./helpers/packageManagerArgumentParser')
7
8
 
@@ -45,7 +46,7 @@ class CliParser {
45
46
  process.exit(exitCode)
46
47
  }
47
48
 
48
- static _extractPackagesFromPositionals(positionals, earlyExitNoInstall = false) {
49
+ static _extractPackagesFromPositionals(positionals, earlyExitNoInstall = false, json = false) {
49
50
  let packages = []
50
51
  if (positionals.length > 0) {
51
52
  const command = positionals[0]
@@ -61,7 +62,7 @@ class CliParser {
61
62
 
62
63
  // Parse and normalize packages
63
64
  return packages.map((pkg) => {
64
- const parsedPackage = npa(pkg)
65
+ const parsedPackage = json ? parseJsonRegistryPackageSpec(pkg) : npa(pkg)
65
66
  const versionModifier = parsedPackage.fetchSpec === '*' ? 'latest' : parsedPackage.fetchSpec
66
67
  return `${parsedPackage.name}@${versionModifier}`
67
68
  })
@@ -71,6 +72,7 @@ class CliParser {
71
72
  const options = {
72
73
  'dry-run': { type: 'boolean' },
73
74
  plain: { type: 'boolean' },
75
+ json: { type: 'boolean' },
74
76
  packageManager: { type: 'string' },
75
77
  pkgMgr: { type: 'string' },
76
78
  'disable-auto-continue': { type: 'boolean' },
@@ -102,6 +104,7 @@ Commands:
102
104
  Options:
103
105
  --dry-run Run checks only, don't install
104
106
  --plain Force non-rich text output
107
+ --json Emit JSON and never install
105
108
  --packageManager Package Manager to use (default: npm)
106
109
  --pkgMgr Alias for packageManager
107
110
  --disable-auto-continue Disable auto-continue countdown, always prompt
@@ -129,7 +132,11 @@ curated by Liran Tal at https://github.com/lirantal/npq`)
129
132
  }
130
133
 
131
134
  // Process install command and packages
132
- const normalizedPackages = this._extractPackagesFromPositionals(positionals)
135
+ const normalizedPackages = this._extractPackagesFromPositionals(
136
+ positionals,
137
+ false,
138
+ values.json === true
139
+ )
133
140
  const installSubcommandExplicit =
134
141
  positionals.length > 0 && this.isInstallSubcommand(positionals[0])
135
142
 
@@ -138,6 +145,7 @@ curated by Liran Tal at https://github.com/lirantal/npq`)
138
145
  packageManager: process.env.NPQ_PKG_MGR || values.packageManager || values.pkgMgr || 'npm',
139
146
  dryRun: values['dry-run'] || false,
140
147
  plain: values.plain || false,
148
+ json: values.json || false,
141
149
  disableAutoContinue:
142
150
  values['disable-auto-continue'] || process.env.NPQ_DISABLE_AUTO_CONTINUE === 'true',
143
151
  registryConfigArgs: this.registryConfigArgs(values),
@@ -0,0 +1,25 @@
1
+ 'use strict'
2
+
3
+ const AUDIT_FAILURE_CODES = Object.freeze({
4
+ INVALID_INPUT: 'INVALID_INPUT',
5
+ PROJECT_MANIFEST_ERROR: 'PROJECT_MANIFEST_ERROR',
6
+ PACKAGE_LOOKUP_FAILED: 'PACKAGE_LOOKUP_FAILED',
7
+ AUDIT_CHECK_FAILED: 'AUDIT_CHECK_FAILED',
8
+ INTERRUPTED: 'INTERRUPTED',
9
+ INTERNAL_ERROR: 'INTERNAL_ERROR'
10
+ })
11
+
12
+ const knownCodes = new Set(Object.values(AUDIT_FAILURE_CODES))
13
+
14
+ function createAuditFailure(code, message, context = {}) {
15
+ const failure = {
16
+ code: knownCodes.has(code) ? code : AUDIT_FAILURE_CODES.INTERNAL_ERROR,
17
+ message: String(message)
18
+ }
19
+
20
+ if (context.package) failure.package = String(context.package)
21
+ if (context.marshall) failure.marshall = String(context.marshall)
22
+ return failure
23
+ }
24
+
25
+ module.exports = { AUDIT_FAILURE_CODES, createAuditFailure }
@@ -0,0 +1,24 @@
1
+ 'use strict'
2
+
3
+ function createJsonOutput(write = (value, callback) => process.stdout.write(value, callback)) {
4
+ let written = false
5
+
6
+ return {
7
+ write(report, onComplete) {
8
+ if (written) return false
9
+ written = true
10
+ const serialized = `${JSON.stringify(report)}\n`
11
+ if (typeof onComplete === 'function') {
12
+ write(serialized, onComplete)
13
+ } else {
14
+ write(serialized)
15
+ }
16
+ return true
17
+ },
18
+ hasWritten() {
19
+ return written
20
+ }
21
+ }
22
+ }
23
+
24
+ module.exports = { createJsonOutput }
@@ -0,0 +1,29 @@
1
+ 'use strict'
2
+
3
+ const npa = require('npm-package-arg')
4
+
5
+ const JSON_REGISTRY_SPEC_TYPES = new Set(['tag', 'version', 'range'])
6
+
7
+ function parseJsonRegistryPackageSpec(packageSpec) {
8
+ try {
9
+ const parsedPackage = npa(packageSpec)
10
+ if (!parsedPackage.name || !JSON_REGISTRY_SPEC_TYPES.has(parsedPackage.type)) {
11
+ throw new Error('Unsupported package spec')
12
+ }
13
+
14
+ return parsedPackage
15
+ } catch {
16
+ throw new Error('Invalid JSON package input')
17
+ }
18
+ }
19
+
20
+ function isJsonRegistryPackageSpec(packageSpec) {
21
+ try {
22
+ parseJsonRegistryPackageSpec(packageSpec)
23
+ return true
24
+ } catch {
25
+ return false
26
+ }
27
+ }
28
+
29
+ module.exports = { isJsonRegistryPackageSpec, parseJsonRegistryPackageSpec }
@@ -0,0 +1,76 @@
1
+ 'use strict'
2
+
3
+ const pkg = require('../../package.json')
4
+ const { marshallCategories } = require('../marshalls/constants')
5
+ const { createAuditFailure } = require('./auditFailure')
6
+
7
+ function normalizeMarshallGroup(group = {}) {
8
+ const findings = []
9
+
10
+ for (const value of Object.values(group)) {
11
+ if (!value || typeof value !== 'object') continue
12
+ const category = marshallCategories[value.categoryId] || {
13
+ id: value.categoryId,
14
+ title: value.categoryId
15
+ }
16
+
17
+ for (const warning of value.warnings || []) {
18
+ findings.push({
19
+ severity: 'warning',
20
+ marshall: value.marshall,
21
+ category: { id: category.id, title: category.title },
22
+ message: warning.message
23
+ })
24
+ }
25
+ for (const error of value.errors || []) {
26
+ findings.push({
27
+ severity: 'error',
28
+ marshall: value.marshall,
29
+ category: { id: category.id, title: category.title },
30
+ message: error.message
31
+ })
32
+ }
33
+ }
34
+
35
+ return findings
36
+ }
37
+
38
+ function buildJsonReport({
39
+ packages = [],
40
+ marshallResults = {},
41
+ failures = [],
42
+ version = pkg.version
43
+ } = {}) {
44
+ const positionalResults = Array.isArray(marshallResults)
45
+ const packageReports = packages.map((requested, index) => {
46
+ const results = positionalResults ? marshallResults[index] : marshallResults[requested]
47
+ return {
48
+ requested,
49
+ findings: (results || []).flatMap(normalizeMarshallGroup)
50
+ }
51
+ })
52
+ const findings = packageReports.flatMap((entry) => entry.findings)
53
+ const errors = findings.filter((item) => item.severity === 'error').length
54
+ const warnings = findings.filter((item) => item.severity === 'warning').length
55
+ const status = failures.length > 0 ? 'failed' : errors + warnings > 0 ? 'findings' : 'clean'
56
+
57
+ return {
58
+ schemaVersion: 1,
59
+ tool: { name: 'npq', version },
60
+ status,
61
+ summary: { packagesAudited: packageReports.length, errors, warnings },
62
+ packages: packageReports,
63
+ failures: failures.map((failure) =>
64
+ createAuditFailure(failure.code, failure.message, {
65
+ package: failure.package,
66
+ marshall: failure.marshall
67
+ })
68
+ )
69
+ }
70
+ }
71
+
72
+ function exitCodeForJsonReport(report) {
73
+ return report.status === 'failed' ? 2 : report.status === 'findings' ? 1 : 0
74
+ }
75
+
76
+ module.exports = { buildJsonReport, exitCodeForJsonReport }
@@ -16,8 +16,7 @@ const CAPABILITY = Object.freeze({
16
16
  const CAPABILITY_MESSAGE = Object.freeze({
17
17
  [CAPABILITY.SIGNING_KEYS]: 'configured registry does not expose signing keys',
18
18
  [CAPABILITY.ATTESTATIONS]: 'configured registry does not expose attestations',
19
- [CAPABILITY.DOWNLOAD_COUNTS]:
20
- 'download counts are available only for the public npm registry'
19
+ [CAPABILITY.DOWNLOAD_COUNTS]: 'download counts are available only for the public npm registry'
21
20
  })
22
21
 
23
22
  class RegistryClient {
@@ -47,11 +46,7 @@ class RegistryClient {
47
46
  }
48
47
  }
49
48
 
50
- async requestJson(
51
- requestPath,
52
- packageSpec,
53
- { capability = null, notFoundAsData = false } = {}
54
- ) {
49
+ async requestJson(requestPath, packageSpec, { capability = null, notFoundAsData = false } = {}) {
55
50
  const registry = this.registryFor(packageSpec)
56
51
  try {
57
52
  return await this.fetcher.json(requestPath, this.requestOptions(packageSpec))
@@ -3,12 +3,7 @@
3
3
  const path = require('node:path')
4
4
  const Config = require('@npmcli/config')
5
5
  const npmFetch = require('npm-registry-fetch')
6
- const {
7
- definitions,
8
- flatten,
9
- nerfDarts,
10
- shorthands
11
- } = require('@npmcli/config/lib/definitions')
6
+ const { definitions, flatten, nerfDarts, shorthands } = require('@npmcli/config/lib/definitions')
12
7
  const { RegistryError, sanitizeRegistryUrl } = require('./registryErrors')
13
8
 
14
9
  const DEFAULT_REGISTRY = 'https://registry.npmjs.org/'
@@ -57,8 +52,7 @@ class RegistryConfig {
57
52
  }
58
53
  return new RegistryConfig(config.flat)
59
54
  } catch (error) {
60
- const code =
61
- error.code === 'ERR_INVALID_AUTH' ? 'EREGISTRYCONFIGAUTH' : 'EREGISTRYCONFIG'
55
+ const code = error.code === 'ERR_INVALID_AUTH' ? 'EREGISTRYCONFIGAUTH' : 'EREGISTRYCONFIG'
62
56
  const message =
63
57
  error.code === 'ERR_INVALID_AUTH'
64
58
  ? 'Invalid npm registry authentication configuration'
@@ -230,15 +230,9 @@ function reportResults(marshallResults, { plain = false } = {}) {
230
230
  entry.categoryFriendlyName.padEnd(maxTextLength, ' ')
231
231
  )
232
232
  const iconRich = styleText(['cyan'], '○')
233
- const wrappedMessage = wrapTextWithIndent(
234
- entry.message,
235
- maxMessageWidth,
236
- indentString
237
- )
238
- resultsForPrettyPrint +=
239
- `\n ${prefixRich} ${iconRich} ${topicRich} ${separatorRich} ${wrappedMessage} `
240
- resultsForPlainTextPrint +=
241
- `\n NOT EVALUATED: ${entry.categoryFriendlyName} - ${entry.message}`
233
+ const wrappedMessage = wrapTextWithIndent(entry.message, maxMessageWidth, indentString)
234
+ resultsForPrettyPrint += `\n ${prefixRich} ${iconRich} ${topicRich} ${separatorRich} ${wrappedMessage} `
235
+ resultsForPlainTextPrint += `\n NOT EVALUATED: ${entry.categoryFriendlyName} - ${entry.message}`
242
236
  })
243
237
  })
244
238
  }
@@ -256,16 +250,11 @@ function reportResults(marshallResults, { plain = false } = {}) {
256
250
  terminalWidth - getDisplayLength(' │ ℹ Suggested older release · ') - 2
257
251
  )
258
252
  const detailWidth = Math.max(20, terminalWidth - getDisplayLength(' │ ') - 2)
259
- const wrappedHeadline = wrapTextWithIndent(
260
- headline,
261
- headlineWidth,
262
- suggestionIndent
263
- )
253
+ const wrappedHeadline = wrapTextWithIndent(headline, headlineWidth, suggestionIndent)
264
254
  const wrappedDetail = wrapTextWithIndent(detail, detailWidth, suggestionIndent)
265
255
 
266
256
  resultsForPrettyPrint += `\n ${prefixRich}`
267
- resultsForPrettyPrint +=
268
- `\n ${prefixRich} ${iconRich} ${labelRich} ${separatorRich} ${wrappedHeadline}`
257
+ resultsForPrettyPrint += `\n ${prefixRich} ${iconRich} ${labelRich} ${separatorRich} ${wrappedHeadline}`
269
258
  resultsForPrettyPrint += `\n${suggestionIndent}${wrappedDetail}`
270
259
 
271
260
  resultsForPlainTextPrint +=
@@ -23,12 +23,7 @@ function findAlternativeVersion({
23
23
  requestedVersion,
24
24
  now = Date.now()
25
25
  }) {
26
- if (
27
- !packageInfo ||
28
- !packageInfo.versions ||
29
- !packageInfo.time ||
30
- !semver.valid(targetVersion)
31
- ) {
26
+ if (!packageInfo || !packageInfo.versions || !packageInfo.time || !semver.valid(targetVersion)) {
32
27
  return null
33
28
  }
34
29
 
@@ -49,9 +44,7 @@ function findAlternativeVersion({
49
44
  })
50
45
  .filter(
51
46
  ({ publishedAt, ageDays }) =>
52
- typeof publishedAt === 'string' &&
53
- ageDays !== null &&
54
- ageDays > MAX_VERSION_RECENCY_DAYS
47
+ typeof publishedAt === 'string' && ageDays !== null && ageDays > MAX_VERSION_RECENCY_DAYS
55
48
  )
56
49
  .sort((left, right) => semver.rcompare(left.version, right.version))
57
50
 
@@ -0,0 +1,71 @@
1
+ 'use strict'
2
+
3
+ const { getProjectPackages } = require('./helpers/sourcePackages')
4
+ const Marshall = require('./marshall')
5
+ const { promiseThrottleHelper } = require('./helpers/promiseThrottler')
6
+ const { buildJsonReport } = require('./helpers/jsonReport')
7
+ const { AUDIT_FAILURE_CODES, createAuditFailure } = require('./helpers/auditFailure')
8
+ const { isJsonRegistryPackageSpec } = require('./helpers/jsonPackageSpec')
9
+ const RegistryConfig = require('./helpers/registryConfig')
10
+ const RegistryClient = require('./helpers/registryClient')
11
+
12
+ async function runJsonAudit(cliArgs, dependencies = {}) {
13
+ const getPackages = dependencies.getProjectPackages || getProjectPackages
14
+ const MarshallClass = dependencies.Marshall || Marshall
15
+ const throttle = dependencies.promiseThrottleHelper || promiseThrottleHelper
16
+ const RegistryConfigClass = dependencies.RegistryConfig || RegistryConfig
17
+ const RegistryClientClass = dependencies.RegistryClient || RegistryClient
18
+ let packages = cliArgs.packages || []
19
+ const failures = []
20
+
21
+ try {
22
+ if (packages.length === 0) {
23
+ const projectPackages = await getPackages()
24
+ if (projectPackages && projectPackages.error) {
25
+ return buildJsonReport({
26
+ failures: [
27
+ createAuditFailure(
28
+ AUDIT_FAILURE_CODES.PROJECT_MANIFEST_ERROR,
29
+ 'Unable to read project package.json'
30
+ )
31
+ ]
32
+ })
33
+ }
34
+ packages = projectPackages
35
+ }
36
+
37
+ if (!packages.every(isJsonRegistryPackageSpec)) {
38
+ return buildJsonReport({
39
+ failures: [
40
+ createAuditFailure(
41
+ AUDIT_FAILURE_CODES.INVALID_INPUT,
42
+ 'Invalid package or option argument'
43
+ )
44
+ ]
45
+ })
46
+ }
47
+
48
+ const registryConfig = await RegistryConfigClass.load({
49
+ argv: cliArgs.registryConfigArgs || []
50
+ })
51
+ const registryClient = new RegistryClientClass(registryConfig)
52
+ const marshall = new MarshallClass({
53
+ pkgs: packages,
54
+ progressManager: null,
55
+ promiseThrottleHelper: throttle,
56
+ registryClient,
57
+ preserveRequestOrder: true,
58
+ suppressOutput: true,
59
+ onAuditFailure: (failure) => failures.push(failure)
60
+ })
61
+ const marshallResults = await marshall.process()
62
+ return buildJsonReport({ packages, marshallResults, failures })
63
+ } catch {
64
+ failures.push(
65
+ createAuditFailure(AUDIT_FAILURE_CODES.INTERNAL_ERROR, 'JSON audit could not complete')
66
+ )
67
+ return buildJsonReport({ packages, failures })
68
+ }
69
+ }
70
+
71
+ module.exports = { runJsonAudit }
package/lib/jsonCli.js ADDED
@@ -0,0 +1,61 @@
1
+ 'use strict'
2
+
3
+ const { createJsonOutput } = require('./helpers/jsonOutput')
4
+ const { buildJsonReport, exitCodeForJsonReport } = require('./helpers/jsonReport')
5
+ const { AUDIT_FAILURE_CODES, createAuditFailure } = require('./helpers/auditFailure')
6
+ const { runJsonAudit } = require('./jsonAudit')
7
+
8
+ function writeInvalidJsonInvocation(output = createJsonOutput()) {
9
+ const report = buildJsonReport({
10
+ failures: [
11
+ createAuditFailure(AUDIT_FAILURE_CODES.INVALID_INPUT, 'Invalid package or option argument')
12
+ ]
13
+ })
14
+ output.write(report)
15
+ return report
16
+ }
17
+
18
+ async function runJsonCli(cliArgs, dependencies = {}) {
19
+ const output = dependencies.output || createJsonOutput()
20
+ const processTarget = dependencies.processTarget || process
21
+ const audit = dependencies.runJsonAudit || runJsonAudit
22
+ const getExitCode = dependencies.exitCodeForJsonReport || exitCodeForJsonReport
23
+ let interrupted = false
24
+ let interruptedReport
25
+
26
+ const interrupt = () => {
27
+ interrupted = true
28
+ interruptedReport = buildJsonReport({
29
+ packages: cliArgs.packages || [],
30
+ failures: [createAuditFailure(AUDIT_FAILURE_CODES.INTERRUPTED, 'Audit interrupted')]
31
+ })
32
+ output.write(interruptedReport, () => {
33
+ processTarget.exit(2)
34
+ })
35
+ }
36
+
37
+ processTarget.once('SIGINT', interrupt)
38
+ let report
39
+ try {
40
+ report = await audit(cliArgs)
41
+ } catch {
42
+ report = buildJsonReport({
43
+ packages: cliArgs.packages || [],
44
+ failures: [
45
+ createAuditFailure(AUDIT_FAILURE_CODES.INTERNAL_ERROR, 'JSON audit could not complete')
46
+ ]
47
+ })
48
+ } finally {
49
+ processTarget.removeListener('SIGINT', interrupt)
50
+ }
51
+
52
+ if (interrupted) {
53
+ return interruptedReport
54
+ }
55
+
56
+ output.write(report)
57
+ processTarget.exitCode = getExitCode(report)
58
+ return report
59
+ }
60
+
61
+ module.exports = { runJsonCli, writeInvalidJsonInvocation }
package/lib/marshall.js CHANGED
@@ -11,14 +11,15 @@ class Marshall {
11
11
  options = options || {}
12
12
  this.pkgs = options.pkgs || null
13
13
  this.registryClient =
14
- options.registryClient ||
15
- options.packageRepoUtils?.registryClient ||
16
- RegistryClient.public()
14
+ options.registryClient || options.packageRepoUtils?.registryClient || RegistryClient.public()
17
15
  this.packageRepoUtils =
18
- options.packageRepoUtils ||
19
- new PackageRepoUtils({ registryClient: this.registryClient })
16
+ options.packageRepoUtils || new PackageRepoUtils({ registryClient: this.registryClient })
20
17
  this.progressManager = options.progressManager || null
21
18
  this.promiseThrottleHelper = options.promiseThrottleHelper || null
19
+ this.preserveRequestOrder = options.preserveRequestOrder === true
20
+ this.suppressOutput = options.suppressOutput === true
21
+ this.onAuditFailure =
22
+ typeof options.onAuditFailure === 'function' ? options.onAuditFailure : null
22
23
  }
23
24
 
24
25
  async process() {
@@ -57,6 +58,7 @@ class Marshall {
57
58
  this.progressManager.stop()
58
59
  }
59
60
 
61
+ if (this.preserveRequestOrder) return res
60
62
  return promiseResultsPerPackage
61
63
  }
62
64
 
@@ -67,7 +69,10 @@ class Marshall {
67
69
  packageRepoUtils: packageRepoUtils,
68
70
  registryClient: this.registryClient
69
71
  }
70
- return Marshalls.tasks(config, this.progressManager)
72
+ return Marshalls.tasks(config, this.progressManager, {
73
+ onAuditFailure: this.onAuditFailure,
74
+ suppressOutput: this.suppressOutput
75
+ })
71
76
  }
72
77
 
73
78
  createPackageVersionMaps(packages) {
@@ -9,7 +9,7 @@ const { marshallCategories } = require('./constants')
9
9
 
10
10
  class BaseMarshall {
11
11
  constructor(options) {
12
- this.debug = util.debuglog('npq')
12
+ this.debug = options.suppressOutput ? () => {} : util.debuglog('npq')
13
13
  this.packageRepoUtils = options.packageRepoUtils
14
14
  this.registryClient = options.registryClient
15
15
  this.categoryId = marshallCategories.PackageHealth.id
@@ -2,6 +2,7 @@
2
2
 
3
3
  const fs = require('node:fs')
4
4
  const path = require('node:path')
5
+ const { AUDIT_FAILURE_CODES, createAuditFailure } = require('../helpers/auditFailure')
5
6
  const { RegistryError } = require('../helpers/registryErrors')
6
7
 
7
8
  class Marshalls {
@@ -22,7 +23,8 @@ class Marshalls {
22
23
 
23
24
  const config = {
24
25
  packageRepoUtils: options.packageRepoUtils,
25
- registryClient: options.registryClient
26
+ registryClient: options.registryClient,
27
+ suppressOutput: options.suppressOutput === true
26
28
  }
27
29
 
28
30
  const marshallTasks = marshalls.reduce((prev, curr) => {
@@ -36,6 +38,7 @@ class Marshalls {
36
38
 
37
39
  return prev.concat({
38
40
  title: marshall.title(),
41
+ name: marshall.name,
39
42
  categoryId: marshall.categoryId,
40
43
  execute: async (options) => {
41
44
  const ctx = {
@@ -61,7 +64,11 @@ class Marshalls {
61
64
  * @param {Object} [progressManager] - Optional progress manager for displaying audit progress updates
62
65
  * @returns {Promise<Array<Object>>} Array of marshall results containing audit findings for each package
63
66
  */
64
- static async tasks(options, progressManager) {
67
+ static async tasks(
68
+ options,
69
+ progressManager,
70
+ { onAuditFailure = null, suppressOutput = false } = {}
71
+ ) {
65
72
  const marshallResults = []
66
73
 
67
74
  // @TODO handle throw error because fetch didn't work due to network issues
@@ -78,7 +85,23 @@ class Marshalls {
78
85
  // hostname: 'registry.npmjs.org'
79
86
  // }
80
87
  // }
81
- const packagesDataList = await Marshalls.warmUpPackagesCache(options)
88
+ let packagesDataList
89
+ try {
90
+ packagesDataList = await Marshalls.warmUpPackagesCache(options)
91
+ } catch (error) {
92
+ if (!onAuditFailure) throw error
93
+
94
+ for (const pkg of options.pkgs) {
95
+ onAuditFailure(
96
+ createAuditFailure(
97
+ AUDIT_FAILURE_CODES.PACKAGE_LOOKUP_FAILED,
98
+ 'Unable to retrieve package metadata',
99
+ { package: pkg.packageString }
100
+ )
101
+ )
102
+ }
103
+ return marshallResults
104
+ }
82
105
 
83
106
  // handle error in case we get just one package and it is not found
84
107
  // if (packagesDataList && packagesDataList.length === 1) {
@@ -123,7 +146,8 @@ class Marshalls {
123
146
  if (packagesToProcess.length > 0) {
124
147
  const marshallTasks = await Marshalls.buildMarshallTasks(marshalls, {
125
148
  packageRepoUtils: options.packageRepoUtils,
126
- registryClient: options.registryClient
149
+ registryClient: options.registryClient,
150
+ suppressOutput
127
151
  })
128
152
 
129
153
  for (const marshall of marshallTasks) {
@@ -134,10 +158,23 @@ class Marshalls {
134
158
  })
135
159
  marshallResults.push(res.marshalls)
136
160
  } catch (error) {
137
- if (error instanceof RegistryError) {
138
- throw error
161
+ if (!onAuditFailure) {
162
+ if (error instanceof RegistryError) {
163
+ throw error
164
+ }
165
+ console.error(`Error running task ${marshall.title}:`, error)
166
+ continue
167
+ }
168
+
169
+ for (const pkg of options.pkgs) {
170
+ onAuditFailure(
171
+ createAuditFailure(
172
+ AUDIT_FAILURE_CODES.AUDIT_CHECK_FAILED,
173
+ 'Audit check could not complete',
174
+ { package: pkg.packageString, marshall: marshall.name }
175
+ )
176
+ )
139
177
  }
140
- console.error(`Error running task ${marshall.title}:`, error)
141
178
  }
142
179
  }
143
180
  }
@@ -50,10 +50,7 @@ class Marshall extends BaseMarshall {
50
50
  if (error instanceof NotEvaluated || error instanceof RegistryError) {
51
51
  throw error
52
52
  }
53
- if (
54
- error.message &&
55
- error.message.includes('but the corresponding public key has expired')
56
- ) {
53
+ if (error.message && error.message.includes('but the corresponding public key has expired')) {
57
54
  throw new Warning('Package is signed with an expired key')
58
55
  }
59
56
  throw new Warning(`Unable to verify package signature on registry: ${error.message}`)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "npq",
3
- "version": "3.23.3",
3
+ "version": "3.24.0",
4
4
  "description": "marshall your npm/npm package installs with high quality and class 🎖",
5
5
  "bin": {
6
6
  "npq": "./bin/npq.js",
@@ -27,6 +27,7 @@
27
27
  "bin/",
28
28
  "data/",
29
29
  "lib/",
30
+ "schema/",
30
31
  "scripts/"
31
32
  ],
32
33
  "author": {
@@ -52,6 +53,7 @@
52
53
  },
53
54
  "devDependencies": {
54
55
  "@eslint/js": "^9.32.0",
56
+ "ajv": "^8.20.0",
55
57
  "eslint": "^9.32.0",
56
58
  "eslint-plugin-n": "^17.21.3",
57
59
  "eslint-plugin-security": "^3.0.1",
@@ -0,0 +1,86 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://raw.githubusercontent.com/lirantal/npq/main/schema/npq-output-v1.schema.json",
4
+ "title": "npq JSON audit output v1",
5
+ "type": "object",
6
+ "additionalProperties": false,
7
+ "required": ["schemaVersion", "tool", "status", "summary", "packages", "failures"],
8
+ "properties": {
9
+ "schemaVersion": { "const": 1 },
10
+ "tool": { "$ref": "#/$defs/tool" },
11
+ "status": { "enum": ["clean", "findings", "failed"] },
12
+ "summary": { "$ref": "#/$defs/summary" },
13
+ "packages": { "type": "array", "items": { "$ref": "#/$defs/package" } },
14
+ "failures": { "type": "array", "items": { "$ref": "#/$defs/failure" } }
15
+ },
16
+ "$defs": {
17
+ "tool": {
18
+ "type": "object",
19
+ "additionalProperties": false,
20
+ "required": ["name", "version"],
21
+ "properties": {
22
+ "name": { "const": "npq" },
23
+ "version": { "type": "string", "minLength": 1 }
24
+ }
25
+ },
26
+ "summary": {
27
+ "type": "object",
28
+ "additionalProperties": false,
29
+ "required": ["packagesAudited", "errors", "warnings"],
30
+ "properties": {
31
+ "packagesAudited": { "type": "integer", "minimum": 0 },
32
+ "errors": { "type": "integer", "minimum": 0 },
33
+ "warnings": { "type": "integer", "minimum": 0 }
34
+ }
35
+ },
36
+ "package": {
37
+ "type": "object",
38
+ "additionalProperties": false,
39
+ "required": ["requested", "findings"],
40
+ "properties": {
41
+ "requested": { "type": "string", "minLength": 1 },
42
+ "findings": { "type": "array", "items": { "$ref": "#/$defs/finding" } }
43
+ }
44
+ },
45
+ "finding": {
46
+ "type": "object",
47
+ "additionalProperties": false,
48
+ "required": ["severity", "marshall", "category", "message"],
49
+ "properties": {
50
+ "severity": { "enum": ["warning", "error"] },
51
+ "marshall": { "type": "string", "minLength": 1 },
52
+ "category": { "$ref": "#/$defs/category" },
53
+ "message": { "type": "string" }
54
+ }
55
+ },
56
+ "category": {
57
+ "type": "object",
58
+ "additionalProperties": false,
59
+ "required": ["id", "title"],
60
+ "properties": {
61
+ "id": { "type": "string", "minLength": 1 },
62
+ "title": { "type": "string", "minLength": 1 }
63
+ }
64
+ },
65
+ "failure": {
66
+ "type": "object",
67
+ "additionalProperties": false,
68
+ "required": ["code", "message"],
69
+ "properties": {
70
+ "code": {
71
+ "enum": [
72
+ "INVALID_INPUT",
73
+ "PROJECT_MANIFEST_ERROR",
74
+ "PACKAGE_LOOKUP_FAILED",
75
+ "AUDIT_CHECK_FAILED",
76
+ "INTERRUPTED",
77
+ "INTERNAL_ERROR"
78
+ ]
79
+ },
80
+ "message": { "type": "string", "minLength": 1 },
81
+ "package": { "type": "string", "minLength": 1 },
82
+ "marshall": { "type": "string", "minLength": 1 }
83
+ }
84
+ }
85
+ }
86
+ }