npq 3.25.0 → 3.27.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
@@ -31,7 +31,7 @@ Media coverage about npq:
31
31
 
32
32
  ## About
33
33
 
34
- Once npq is installed, you can safely* install packages:
34
+ Once npq is installed, you can safely\* install packages:
35
35
 
36
36
  ```bash
37
37
  npq install express
@@ -39,12 +39,12 @@ npq install express
39
39
 
40
40
  `npq` will perform the following steps to sanity check that the package is safe by employing syntactic heuristics and querying a CVE database:
41
41
 
42
- * Consult the [snyk.io database of publicly disclosed vulnerabilities](https://snyk.io/vuln) to check if a security vulnerability exists for this package and its version.
43
- * Package age on npm
44
- * Package download count as a popularity metric
45
- * Package has a README file
46
- * Package has a LICENSE file
47
- * Package has pre/post install scripts
42
+ - Consult the [snyk.io database of publicly disclosed vulnerabilities](https://snyk.io/vuln) to check if a security vulnerability exists for this package and its version.
43
+ - Package age on npm
44
+ - Package download count as a popularity metric
45
+ - Package has a README file
46
+ - Package has a LICENSE file
47
+ - Package has pre/post install scripts
48
48
 
49
49
  **IMPORTANT**: npq by default uses an auto-continue mode when warnings are detected (no errors), waiting 15 seconds before proceeding with the installation. You can disable this behavior via the `--disable-auto-continue` CLI flag or the `NPQ_DISABLE_AUTO_CONTINUE=true` environment variable to enforce a strict review and security hardened installs. See [the auto-continue documentation](docs/feature/auto-continue.md) for more details.
50
50
 
@@ -62,7 +62,7 @@ When npq completes its signal checks it hands over the actual package install jo
62
62
  npm install -g npq
63
63
  ```
64
64
 
65
- *Note: we recommend installing with `npm` rather than `yarn`. That way, `npq` can automatically install shell aliases for you.*
65
+ _Note: we recommend installing with `npm` rather than `yarn`. That way, `npq` can automatically install shell aliases for you._
66
66
 
67
67
  You can also install `npq` via [Homebrew](https://brew.sh) on macOS or Linux:
68
68
 
@@ -114,7 +114,7 @@ alias yarn="NPQ_PKG_MGR=yarn npq-hero"
114
114
  NPQ_PKG_MGR=yarn yarn run npq-hero
115
115
  ```
116
116
 
117
- or
117
+ or
118
118
 
119
119
  ```bash
120
120
  NPQ_PKG_MGR=yarn yarn exec npq-hero
@@ -137,23 +137,23 @@ Note: `npq` by default will offload all commands and their arguments to the `npm
137
137
 
138
138
  ## Marshalls
139
139
 
140
- | Marshall Name | Description | Notes
141
- | --- | --- | ---
142
- | age | Will show a warning for a package if its age on npm is less than 22 days | Checks a package creation date, not a specific version
143
- | author | Validates the resolved version’s publisher (`_npmUser`), flags a **new** maintainer on the package (first publish by that email within 21 days), **dormant maintainer** gaps (warning if over ~6 months since their last publish on that package, error if over ~9 months), and very **recent** publishes | See [docs/feature/author-marshall.md](docs/feature/author-marshall.md)
144
- | downloads | Will show a warning for a package if its download count in the last month is less than 20 | Reports `not evaluated` for custom-registry packages instead of querying the public npm downloads service
145
- | readme | Will show a warning if a package has no README or it has been detected as a security placeholder package by npm staff
146
- | repo | Will show a warning if a package has been found without a valid and working repository URL | Checks the latest version for a repository URL
147
- | scripts | Will show a warning if a package has a pre/post install script which could potentially be malicious
148
- | snyk | Will show a warning if a package has been found with vulnerabilities in Snyk's database | For Snyk to work you need to either have the `snyk` npm package installed with a valid API token, or make the token available in the `SNYK_TOKEN` environment variable, and npq will use it
149
- | license | Will show a warning if a package has been found without a license field | Checks the latest version for a license
150
- | expired domains | Normalizes and checks complete maintainer email hosts, then corroborates a simple two-label DNS failure through authoritative RDAP before warning | Multipart and otherwise inconclusive results are reported as not evaluated; see [expired-domain marshall documentation](docs/feature/expired-domains.md)
151
- | signatures | Will compare the package's signature as it shows on the registry's packument with signing keys published by the selected registry | Reports `not evaluated` if the selected registry does not expose signing keys
152
- | provenance | Will verify the package's attestations of provenance metadata for the published package, and **error** on [provenance regression](docs/feature/provenance.md) (an older semver had registry provenance metadata but the version you install does not) | Reports `not evaluated` if the selected registry does not expose signing keys or attestations
153
- | version-maturity | Will show an error if the specific version being installed was published less than 7 days ago | Suggests the highest older semver outside npq's 30-day version-recency window when one is available
154
- | newBin | Will show a warning if the package version being installed introduces a new command-line binary (via the `bin` field in `package.json`) that was not present in its previous version. | Helps identify potentially unexpected new executables being added to your `node_modules/.bin/` directory.
155
- | typosquatting | Will show a warning if the package name is similar to a popular package name, which could indicate a potential typosquatting attack. | Helps identify packages that may be trying to trick users into installing them by mimicking popular package names.
156
- | deprecation | Will show a warning if the package version is deprecated on npm or if its GitHub repository has been archived. | Helps identify packages that are no longer maintained or recommended for use. Set `GITHUB_TOKEN` environment variable for higher GitHub API rate limits.
140
+ | Marshall Name | Description | Notes |
141
+ | ---------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
142
+ | age | Will show a warning for a package if its age on npm is less than 22 days | Checks a package creation date, not a specific version |
143
+ | author | Validates the resolved version’s publisher (`_npmUser`), flags a **new** maintainer on the package (first publish by that email within 21 days), **dormant maintainer** gaps (warning if over ~6 months since their last publish on that package, error if over ~9 months), and very **recent** publishes | See [docs/feature/author-marshall.md](docs/feature/author-marshall.md) |
144
+ | downloads | Will show a warning for a package if its download count in the last month is less than 20 | Reports `not evaluated` for custom-registry packages instead of querying the public npm downloads service |
145
+ | readme | Will show a warning if a package has no README or it has been detected as a security placeholder package by npm staff |
146
+ | repo | Will show a warning if a package has been found without a valid and working repository URL | Checks the latest version for a repository URL |
147
+ | scripts | Will show a warning if a package has a pre/post install script which could potentially be malicious |
148
+ | snyk | Will show a warning if a package has been found with vulnerabilities in Snyk's database | For Snyk to work you need to either have the `snyk` npm package installed with a valid API token, or make the token available in the `SNYK_TOKEN` environment variable, and npq will use it |
149
+ | license | Will show a warning if a package has been found without a license field | Checks the latest version for a license |
150
+ | expired domains | Normalizes and checks complete maintainer email hosts, then corroborates a simple two-label DNS failure through authoritative RDAP before warning | Multipart and otherwise inconclusive results are reported as not evaluated; see [expired-domain marshall documentation](docs/feature/expired-domains.md) |
151
+ | signatures | Will compare the package's signature as it shows on the registry's packument with signing keys published by the selected registry | Reports `not evaluated` if the selected registry does not expose signing keys |
152
+ | provenance | Will verify the package's attestations of provenance metadata for the published package, and **error** on [provenance regression](docs/feature/provenance.md) (an older semver had registry provenance metadata but the version you install does not) | Reports `not evaluated` if the selected registry does not expose signing keys or attestations |
153
+ | version-maturity | Will show an error if the specific version being installed was published less than 7 days ago | Suggests the highest older semver outside npq's 30-day version-recency window when one is available |
154
+ | newBin | Will show a warning if the package version being installed introduces a new command-line binary (via the `bin` field in `package.json`) that was not present in its previous version. | Helps identify potentially unexpected new executables being added to your `node_modules/.bin/` directory. |
155
+ | typosquatting | Will show a warning if the package name is similar to a popular package name, which could indicate a potential typosquatting attack. | Helps identify packages that may be trying to trick users into installing them by mimicking popular package names. |
156
+ | deprecation | Will show a warning if the package version is deprecated on npm or if its GitHub repository has been archived. | Helps identify packages that are no longer maintained or recommended for use. Set `GITHUB_TOKEN` environment variable for higher GitHub API rate limits. |
157
157
 
158
158
  ### Older release suggestions
159
159
 
@@ -186,13 +186,12 @@ Example, to disable the Snyk vulnerability marshall:
186
186
  MARSHALL_DISABLE_SNYK=1 npq install express
187
187
  ```
188
188
 
189
-
190
189
  #### Available Marshall Environment Variables
191
190
 
192
191
  Here are all the available environment variable names for disabling specific marshalls:
193
192
 
194
193
  | Marshall Name | Environment Variable | Description |
195
- |------------------|-----------------------------------------------|-----------------------------------------------------|
194
+ | ---------------- | --------------------------------------------- | --------------------------------------------------- |
196
195
  | age | `MARSHALL_DISABLE_AGE` | Disable package age checks |
197
196
  | author | `MARSHALL_DISABLE_AUTHOR` | Disable package author verification |
198
197
  | downloads | `MARSHALL_DISABLE_DOWNLOADS` | Disable download count checks |
@@ -212,6 +211,8 @@ Here are all the available environment variable names for disabling specific mar
212
211
 
213
212
  Running `npq` without an `install` / `i` / `add` subcommand (for example from a project directory with a `package.json`) audits dependencies only and does not run the package manager. The same applies to `npq <package>`: checks only, no install passthrough. Use `npq install …` when you want npq to hand off to npm (or `NPQ_PKG_MGR`) after the checks.
214
213
 
214
+ Project-mode audits currently cover only declared direct dependencies from the current project's `package.json` (`dependencies` and `devDependencies`). They do not read lockfiles or discover transitive dependencies.
215
+
215
216
  ```sh
216
217
  npq install express --dry-run
217
218
  ```
@@ -230,18 +231,47 @@ Use `--json` for a non-interactive, audit-only result suitable for CI:
230
231
  npq install express --json
231
232
  ```
232
233
 
233
- When npq detects a supported coding-agent environment, it automatically uses
234
- the same audit-only JSON mode. This applies to `npq` and to install commands
235
- intercepted by `npq-hero`; non-install `npq-hero` commands still pass through to
236
- the configured package manager. See [JSON audit output](docs/feature/json-output.md#coding-agent-detection).
234
+ When npq detects a supported coding-agent environment, non-install audits still
235
+ use the same audit-only JSON mode automatically. Explicit `npq install ...` and
236
+ recognized `npq-hero install ...` commands keep the normal audit/install
237
+ pipeline instead, using coding-agent detection as an authorization for
238
+ warning-only installs. See the [full coding-agent detection signal
239
+ table](docs/feature/json-output.md#coding-agent-detection) for the supported
240
+ signals and contextual matchers.
237
241
 
238
242
  JSON mode never invokes the package manager. It exits `0` for a clean audit,
239
243
  `1` for findings, and `2` when the audit cannot complete. See
240
244
  [JSON audit output](docs/feature/json-output.md).
241
245
 
246
+ ### Non-interactive installs
247
+
248
+ npq never uses the warning countdown when standard input is not interactive. If
249
+ an audit reports warnings or errors in CI, npq exits nonzero and does not
250
+ invoke the package manager.
251
+
252
+ For ordinary automation that intentionally wants warning-only installs to
253
+ continue, opt in explicitly:
254
+
255
+ ```sh
256
+ npq install express --allow-non-interactive-install
257
+ NPQ_ALLOW_NON_INTERACTIVE_INSTALL=true npq install express
258
+ ```
259
+
260
+ When you use `npq-hero` or an aliased package manager, the ordinary opt-in is
261
+ environment-only because the wrapper does not expose its own CLI flags:
262
+
263
+ ```sh
264
+ NPQ_ALLOW_NON_INTERACTIVE_INSTALL=true npm install express
265
+ ```
266
+
267
+ Detected coding-agent environments are treated as an explicit opt-in for
268
+ `npq install express` and `npq-hero install express`, so agent-driven
269
+ package-manager installs continue without the extra flag. Explicit `--json`
270
+ remains audit-only and never invokes the package manager.
271
+
242
272
  ### Disable auto-continue countdown
243
273
 
244
- 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:
274
+ By default, when npq detects only warnings (no errors), it automatically proceeds with installation after a 15-second countdown. To disable this behavior and require explicit confirmation in an interactive terminal:
245
275
 
246
276
  **Using the CLI flag:**
247
277
 
@@ -262,7 +292,10 @@ Or set it permanently in your shell profile (`.bashrc`, `.zshrc`, etc.):
262
292
  export NPQ_DISABLE_AUTO_CONTINUE=true
263
293
  ```
264
294
 
265
- When auto-continue is disabled, npq will always prompt for explicit confirmation before proceeding with installation, even when only warnings are detected.
295
+ When auto-continue is disabled, npq prompts for explicit confirmation in an
296
+ interactive terminal. Non-interactive warning-only installs remain fail-closed
297
+ unless the explicit non-interactive install opt-in or coding-agent authorization
298
+ applies.
266
299
 
267
300
  ## Learn Node.js Security
268
301
 
@@ -286,25 +319,25 @@ When auto-continue is disabled, npq will always prompt for explicit confirmation
286
319
 
287
320
  1. **What is the difference between `npq` and `npq-hero`?**
288
321
 
289
- * `npq` is meant to be its own stand-alone CLI so it has command line flags like `--dry-run` and others (see `npq --help`). However, when you want to alias the `npm` CLI to NPQ you should use `npq-hero` as the executable of the alias to npm (e.g: `alias npm=npq`), which means `npq-hero` can't have its own command-line flags because they could conflict with the `npm` executable.
322
+ - `npq` is meant to be its own stand-alone CLI so it has command line flags like `--dry-run` and others (see `npq --help`). However, when you want to alias the `npm` CLI to NPQ you should use `npq-hero` as the executable of the alias to npm (e.g: `alias npm=npq`), which means `npq-hero` can't have its own command-line flags because they could conflict with the `npm` executable.
290
323
 
291
324
  2. **Can I use NPQ without having npm or yarn?**
292
325
 
293
- * NPQ will audit a package for possible security issues, but it isn't a replacement for npm or yarn. When you choose to continue installing the package, it will offload the installation process to your choice of either npm or yarn.
326
+ - NPQ will audit a package for possible security issues, but it isn't a replacement for npm or yarn. When you choose to continue installing the package, it will offload the installation process to your choice of either npm or yarn.
294
327
 
295
328
  3. **How is NPQ different from npm audit?**
296
329
 
297
- * `npm install` will install a module even if it has vulnerabilities; NPQ will display the issues detected, and prompt the user for confirmation on whether to proceed installing it.
298
- * NPQ will run synthetic checks, called [marshalls](https://github.com/lirantal/npq#marshalls), on the characteristics of a module, such as whether the module you are going to install has a `pre-install` script which can be potentially harmful for your system and prompt you whether to install it. Whereas `npm audit` will not perform any such checks, and only consults a vulnerability database for known security issues.
299
- * `npm audit` is closer in functionality to what Snyk does, rather than what NPQ does.
330
+ - `npm install` will install a module even if it has vulnerabilities; NPQ will display the issues detected, and prompt the user for confirmation on whether to proceed installing it.
331
+ - NPQ will run synthetic checks, called [marshalls](https://github.com/lirantal/npq#marshalls), on the characteristics of a module, such as whether the module you are going to install has a `pre-install` script which can be potentially harmful for your system and prompt you whether to install it. Whereas `npm audit` will not perform any such checks, and only consults a vulnerability database for known security issues.
332
+ - `npm audit` is closer in functionality to what Snyk does, rather than what NPQ does.
300
333
 
301
334
  4. **Do I require a Snyk API key in order to use NPQ?**
302
335
 
303
- * It's not required. If NPQ is unable to detect a Snyk API key for the user running NPQ, then it will skip the database vulnerabilities check. We do, however, greatly encourage you to use Snyk, and connect it with NPQ for broader security.
336
+ - It's not required. If NPQ is unable to detect a Snyk API key for the user running NPQ, then it will skip the database vulnerabilities check. We do, however, greatly encourage you to use Snyk, and connect it with NPQ for broader security.
304
337
 
305
338
  5. **Why is NPQ connecting to external domains like gmail.com or personal websites during installation?**
306
339
 
307
- * This is not telemetry. NPQ does not collect any usage data. When auditing a package, NPQ fetches the maintainers/authors of the dependency and checks their email addresses to verify they are valid and not associated with expired domains. Expired domains can be abused by attackers for account takeover (ATO) attacks to compromise packages with malicious versions. Hence, NPQ may make DNS requests to domains like `gmail.com` or personal domains found in maintainer emails. After a simple two-label domain returns NXDOMAIN and its top-level domain resolves, NPQ may fetch the official IANA RDAP bootstrap and query the selected authoritative HTTPS RDAP service, sending only the normalized two-label domain. Additionally, NPQ makes HTTP requests to `osv.dev` to fetch security vulnerability data (or uses Snyk if configured, as a prioritized option).
340
+ - This is not telemetry. NPQ does not collect any usage data. When auditing a package, NPQ fetches the maintainers/authors of the dependency and checks their email addresses to verify they are valid and not associated with expired domains. Expired domains can be abused by attackers for account takeover (ATO) attacks to compromise packages with malicious versions. Hence, NPQ may make DNS requests to domains like `gmail.com` or personal domains found in maintainer emails. After a simple two-label domain returns NXDOMAIN and its top-level domain resolves, NPQ may fetch the official IANA RDAP bootstrap and query the selected authoritative HTTPS RDAP service, sending only the normalized two-label domain. Additionally, NPQ makes HTTP requests to `osv.dev` to fetch security vulnerability data (or uses Snyk if configured, as a prioritized option).
308
341
 
309
342
  ## Documentation
310
343
 
package/bin/npq-hero.js CHANGED
@@ -8,6 +8,7 @@ cliSupport.isEnvSupport() || (cliSupport.noSupportError() && cliSupport.packageM
8
8
  const pkgMgr = require('../lib/packageManager')
9
9
  const Marshall = require('../lib/marshall')
10
10
  const { CliParser } = require('../lib/cli')
11
+ const { getProjectPackages } = require('../lib/helpers/sourcePackages')
11
12
  const cliPrompt = require('../lib/helpers/cliPrompt.js')
12
13
  const { reportResults } = require('../lib/helpers/reportResults')
13
14
  const { Spinner } = require('../lib/helpers/cliSpinner')
@@ -17,6 +18,10 @@ const RegistryClient = require('../lib/helpers/registryClient')
17
18
  const { createJsonOutput } = require('../lib/helpers/jsonOutput')
18
19
  const { runJsonCli, writeInvalidJsonInvocation } = require('../lib/jsonCli')
19
20
  const { isCodingAgentEnvironment } = require('../lib/helpers/codingAgentEnvironment')
21
+ const {
22
+ getInstallAction,
23
+ createNonInteractiveInstallError
24
+ } = require('../lib/helpers/installPolicy')
20
25
 
21
26
  const PACKAGE_MANAGER_TOOL = process.env.NPQ_PKG_MGR
22
27
  const DISABLE_AUTO_CONTINUE = process.env.NPQ_DISABLE_AUTO_CONTINUE === 'true'
@@ -35,25 +40,39 @@ try {
35
40
  if (cliArgs && cliArgs.json) {
36
41
  runJsonCli(cliArgs, { output: createJsonOutput() })
37
42
  } else if (cliArgs) {
38
- const silentModeNoPackages = !cliArgs || !cliArgs.packages || cliArgs.packages.length === 0
39
-
40
- const isInteractive = cliSupport.isInteractiveTerminal() && !silentModeNoPackages
41
- const spinner = isInteractive ? new Spinner({ text: 'Initiating...' }) : null
43
+ const isInteractive = cliSupport.isInteractiveTerminal()
44
+ const silentModeNoPackages = !cliArgs.installSubcommandExplicit && cliArgs.packages.length === 0
45
+ const spinner =
46
+ isInteractive && !silentModeNoPackages ? new Spinner({ text: 'Initiating...' }) : null
42
47
 
43
48
  if (spinner) {
44
49
  spinner.start()
45
50
  }
46
-
47
51
  RegistryConfig.load({ argv: cliArgs.registryConfigArgs || [] })
48
52
  .then((registryConfig) => {
49
53
  const registryClient = new RegistryClient(registryConfig)
50
- const marshall = new Marshall({
51
- pkgs: cliArgs.packages,
52
- registryClient,
53
- progressManager: spinner,
54
- promiseThrottleHelper
54
+ const packagePromise =
55
+ cliArgs.installSubcommandExplicit && cliArgs.packages.length === 0
56
+ ? getProjectPackages()
57
+ : Promise.resolve(cliArgs.packages)
58
+
59
+ return packagePromise.then((packages) => {
60
+ if (packages && packages.error) {
61
+ CliParser.exit({
62
+ errorCode: typeof packages.error.code === 'number' ? packages.error.code : -1,
63
+ message: packages.message,
64
+ spinner
65
+ })
66
+ }
67
+
68
+ const marshall = new Marshall({
69
+ pkgs: packages,
70
+ registryClient,
71
+ progressManager: spinner,
72
+ promiseThrottleHelper
73
+ })
74
+ return marshall.process()
55
75
  })
56
- return marshall.process()
57
76
  })
58
77
  .then((marshallResults) => {
59
78
  if (spinner) {
@@ -93,30 +112,32 @@ if (cliArgs && cliArgs.json) {
93
112
  return undefined
94
113
  })
95
114
  .then((result) => {
96
- if (result && result.countErrors > 0) {
97
- console.log()
115
+ const action = getInstallAction({
116
+ countErrors: result?.countErrors || 0,
117
+ countWarnings: result?.countWarnings || 0,
118
+ isInteractive,
119
+ disableAutoContinue: DISABLE_AUTO_CONTINUE,
120
+ allowNonInteractiveInstall: cliArgs.allowNonInteractiveInstall
121
+ })
122
+
123
+ if (action === 'reject') {
124
+ throw createNonInteractiveInstallError()
125
+ }
126
+
127
+ if (action === 'prompt') {
98
128
  return cliPrompt.prompt({
99
129
  name: 'install',
100
130
  message: 'Continue install ?',
101
131
  default: false
102
132
  })
103
- } else {
104
- if (result && result.countWarnings > 0) {
105
- console.log()
106
- // Check if auto-continue is disabled via environment variable
107
- if (DISABLE_AUTO_CONTINUE) {
108
- return cliPrompt.prompt({
109
- name: 'install',
110
- message: 'Continue install ?',
111
- default: false
112
- })
113
- }
114
- return cliPrompt.autoContinue({
115
- name: 'install',
116
- message: 'Auto-continue with install in... ',
117
- timeInSeconds: 15
118
- })
119
- }
133
+ }
134
+
135
+ if (action === 'countdown') {
136
+ return cliPrompt.autoContinue({
137
+ name: 'install',
138
+ message: 'Auto-continue with install in... ',
139
+ timeInSeconds: 15
140
+ })
120
141
  }
121
142
 
122
143
  return { install: true }
@@ -140,6 +161,8 @@ if (cliArgs && cliArgs.json) {
140
161
  let errorCode = -1
141
162
  if (typeof error.code === 'number') {
142
163
  errorCode = error.code
164
+ } else if (typeof error.exitCode === 'number') {
165
+ errorCode = error.exitCode
143
166
  } else if (error.code === 'ABORT_ERR') {
144
167
  errorCode = 1
145
168
  } else if (error.code === 'USER_ABORT') {
package/bin/npq.js CHANGED
@@ -18,6 +18,10 @@ const { promiseThrottleHelper } = require('../lib/helpers/promiseThrottler')
18
18
  const { createJsonOutput } = require('../lib/helpers/jsonOutput')
19
19
  const { runJsonCli, writeInvalidJsonInvocation } = require('../lib/jsonCli')
20
20
  const { isCodingAgentEnvironment } = require('../lib/helpers/codingAgentEnvironment')
21
+ const {
22
+ getInstallAction,
23
+ createNonInteractiveInstallError
24
+ } = require('../lib/helpers/installPolicy')
21
25
  const RegistryConfig = require('../lib/helpers/registryConfig')
22
26
  const RegistryClient = require('../lib/helpers/registryClient')
23
27
 
@@ -39,8 +43,8 @@ if (cliArgs && cliArgs.json) {
39
43
  runJsonCli(cliArgs, { output: jsonOutput })
40
44
  } else if (cliArgs) {
41
45
  const auditOnly = cliArgs.dryRun || !cliArgs.installSubcommandExplicit
42
- const isInteractive = cliSupport.isInteractiveTerminal() && !cliArgs.plain
43
- const spinner = isInteractive ? new Spinner({ text: 'Initiating...' }) : null
46
+ const isInteractive = cliSupport.isInteractiveTerminal()
47
+ const spinner = isInteractive && !cliArgs.plain ? new Spinner({ text: 'Initiating...' }) : null
44
48
 
45
49
  if (spinner) {
46
50
  spinner.start()
@@ -127,30 +131,32 @@ if (cliArgs && cliArgs.json) {
127
131
  return undefined
128
132
  }
129
133
 
130
- if (result && result.countErrors > 0) {
131
- console.log()
134
+ const action = getInstallAction({
135
+ countErrors: result?.countErrors || 0,
136
+ countWarnings: result?.countWarnings || 0,
137
+ isInteractive,
138
+ disableAutoContinue: cliArgs.disableAutoContinue,
139
+ allowNonInteractiveInstall: cliArgs.allowNonInteractiveInstall
140
+ })
141
+
142
+ if (action === 'reject') {
143
+ throw createNonInteractiveInstallError()
144
+ }
145
+
146
+ if (action === 'prompt') {
132
147
  return cliPrompt.prompt({
133
148
  name: 'install',
134
149
  message: 'Continue install ?',
135
150
  default: false
136
151
  })
137
- } else {
138
- if (result && result.countWarnings > 0) {
139
- console.log()
140
- // Check if auto-continue is disabled via CLI flag or environment variable
141
- if (cliArgs.disableAutoContinue) {
142
- return cliPrompt.prompt({
143
- name: 'install',
144
- message: 'Continue install ?',
145
- default: false
146
- })
147
- }
148
- return cliPrompt.autoContinue({
149
- name: 'install',
150
- message: 'Auto-continue with install in... ',
151
- timeInSeconds: 15
152
- })
153
- }
152
+ }
153
+
154
+ if (action === 'countdown') {
155
+ return cliPrompt.autoContinue({
156
+ name: 'install',
157
+ message: 'Auto-continue with install in... ',
158
+ timeInSeconds: 15
159
+ })
154
160
  }
155
161
 
156
162
  return { install: true }
@@ -174,6 +180,8 @@ if (cliArgs && cliArgs.json) {
174
180
  let errorCode = -1
175
181
  if (typeof error.code === 'number') {
176
182
  errorCode = error.code
183
+ } else if (typeof error.exitCode === 'number') {
184
+ errorCode = error.exitCode
177
185
  } else if (error.code === 'ABORT_ERR') {
178
186
  errorCode = 1
179
187
  } else if (error.code === 'USER_ABORT') {
package/lib/cli.js CHANGED
@@ -74,6 +74,7 @@ class CliParser {
74
74
  'dry-run': { type: 'boolean' },
75
75
  plain: { type: 'boolean' },
76
76
  json: { type: 'boolean' },
77
+ 'allow-non-interactive-install': { type: 'boolean' },
77
78
  packageManager: { type: 'string' },
78
79
  pkgMgr: { type: 'string' },
79
80
  'disable-auto-continue': { type: 'boolean' },
@@ -106,9 +107,10 @@ Options:
106
107
  --dry-run Run checks only, don't install
107
108
  --plain Force non-rich text output
108
109
  --json Emit JSON and never install
110
+ --allow-non-interactive-install Allow warning-only installs without a TTY
109
111
  --packageManager Package Manager to use (default: npm)
110
112
  --pkgMgr Alias for packageManager
111
- --disable-auto-continue Disable auto-continue countdown, always prompt
113
+ --disable-auto-continue Disable countdown; prompt when interactive
112
114
  --registry npm registry used for audit and install
113
115
  --userconfig Path to the user npm configuration file
114
116
  --globalconfig Path to the global npm configuration file
@@ -118,6 +120,7 @@ Options:
118
120
  Environment Variables:
119
121
  NPQ_PKG_MGR Package manager to use; overrides --packageManager when set (default: npm)
120
122
  NPQ_DISABLE_AUTO_CONTINUE Set to 'true' to disable auto-continue
123
+ NPQ_ALLOW_NON_INTERACTIVE_INSTALL Set to 'true' to allow warning-only non-TTY installs
121
124
 
122
125
  Examples:
123
126
  npq install express
@@ -133,10 +136,22 @@ curated by Liran Tal at https://github.com/lirantal/npq`)
133
136
  }
134
137
 
135
138
  // Process install command and packages
136
- const json = values.json === true || isCodingAgentEnvironment()
137
- const normalizedPackages = this._extractPackagesFromPositionals(positionals, false, json)
139
+ const explicitJson = values.json === true
138
140
  const installSubcommandExplicit =
139
141
  positionals.length > 0 && this.isInstallSubcommand(positionals[0])
142
+ const codingAgentDetected = isCodingAgentEnvironment()
143
+ const json = explicitJson || (codingAgentDetected && !installSubcommandExplicit)
144
+ const jsonPackageParsing = explicitJson || codingAgentDetected
145
+ const normalizedPackages = this._extractPackagesFromPositionals(
146
+ positionals,
147
+ false,
148
+ jsonPackageParsing
149
+ )
150
+ const allowNonInteractiveInstall =
151
+ !explicitJson &&
152
+ (values['allow-non-interactive-install'] === true ||
153
+ process.env.NPQ_ALLOW_NON_INTERACTIVE_INSTALL === 'true' ||
154
+ (codingAgentDetected && installSubcommandExplicit))
140
155
 
141
156
  return {
142
157
  packages: normalizedPackages,
@@ -144,6 +159,7 @@ curated by Liran Tal at https://github.com/lirantal/npq`)
144
159
  dryRun: values['dry-run'] || false,
145
160
  plain: values.plain || false,
146
161
  json,
162
+ allowNonInteractiveInstall,
147
163
  disableAutoContinue:
148
164
  values['disable-auto-continue'] || process.env.NPQ_DISABLE_AUTO_CONTINUE === 'true',
149
165
  registryConfigArgs: this.registryConfigArgs(values),
@@ -159,13 +175,21 @@ curated by Liran Tal at https://github.com/lirantal/npq`)
159
175
  })
160
176
  const installSubcommandExplicit =
161
177
  positionals.length > 0 && this.isInstallSubcommand(positionals[0])
162
- const json = codingAgentEnvironment && installSubcommandExplicit
178
+ const codingAgentInstall = codingAgentEnvironment && installSubcommandExplicit
179
+ const jsonPackageParsing = codingAgentInstall
180
+ const json = false
181
+ const allowNonInteractiveInstall =
182
+ codingAgentInstall || process.env.NPQ_ALLOW_NON_INTERACTIVE_INSTALL === 'true'
163
183
  let normalizedPackages
164
184
 
165
185
  try {
166
- normalizedPackages = this._extractPackagesFromPositionals(positionals, true, json)
186
+ normalizedPackages = this._extractPackagesFromPositionals(
187
+ positionals,
188
+ true,
189
+ jsonPackageParsing
190
+ )
167
191
  } catch (error) {
168
- if (json && error && typeof error === 'object') {
192
+ if (jsonPackageParsing && error && typeof error === 'object') {
169
193
  Object.defineProperty(error, 'npqJsonMode', { value: true })
170
194
  }
171
195
  throw error
@@ -175,7 +199,8 @@ curated by Liran Tal at https://github.com/lirantal/npq`)
175
199
  packages: normalizedPackages,
176
200
  registryConfigArgs: this.registryConfigArgs(values),
177
201
  installSubcommandExplicit,
178
- json
202
+ json,
203
+ allowNonInteractiveInstall
179
204
  }
180
205
  }
181
206
  }
@@ -3,6 +3,7 @@
3
3
  const { setTimeout } = require('node:timers/promises')
4
4
  const readline = require('node:readline/promises')
5
5
  const { stdin, stdout } = require('node:process')
6
+ const { createNonInteractiveInstallError } = require('./installPolicy')
6
7
 
7
8
  /**
8
9
  * Simple CLI prompt for yes/no confirmation
@@ -70,6 +71,10 @@ async function prompt(options = {}) {
70
71
  }
71
72
 
72
73
  async function autoContinue({ name, message, timeInSeconds = 5 } = {}) {
74
+ if (!process.stdin.isTTY) {
75
+ throw createNonInteractiveInstallError()
76
+ }
77
+
73
78
  let aborted = false
74
79
  let userProceeded = false
75
80
 
@@ -150,32 +155,6 @@ async function autoContinue({ name, message, timeInSeconds = 5 } = {}) {
150
155
  cleanup()
151
156
  throw error
152
157
  }
153
- } else {
154
- // Fallback for non-TTY environments (tests, etc.)
155
- // Show countdown message with instruction
156
- process.stdout.write(`${message}${timeInSeconds} (press 'y' to proceed)`)
157
-
158
- // Count down from timeInSeconds-1 to 1
159
- for (let i = timeInSeconds - 1; i > 0; i--) {
160
- await setTimeout(1000)
161
-
162
- // Update similar to TTY version - backspace over number and instruction
163
- const instructionText = " (press 'y' to proceed)"
164
- const prevNumber = i + 1
165
- const totalTextLength = prevNumber.toString().length + instructionText.length
166
-
167
- // Backspace over everything, write new number and instruction
168
- const backspaces = '\b'.repeat(totalTextLength)
169
- process.stdout.write(`${backspaces}${i}${instructionText}`)
170
- }
171
-
172
- // Wait for the final second
173
- await setTimeout(1000)
174
-
175
- // Move to next line after countdown completes
176
- console.log('')
177
-
178
- return { [name]: true }
179
158
  }
180
159
  }
181
160
 
@@ -5,6 +5,20 @@ const { styleText } = require('node:util')
5
5
  const childProcess = require('child_process')
6
6
  const semver = require('semver')
7
7
 
8
+ const CI_ENV_VARS = [
9
+ 'CI',
10
+ 'CONTINUOUS_INTEGRATION',
11
+ 'BUILD_NUMBER',
12
+ 'RUN_ID',
13
+ 'GITHUB_ACTIONS',
14
+ 'GITLAB_CI',
15
+ 'TRAVIS',
16
+ 'CIRCLECI',
17
+ 'JENKINS_URL',
18
+ 'TEAMCITY_VERSION',
19
+ 'TF_BUILD'
20
+ ]
21
+
8
22
  const DEFAULT_PKGMGR = process.env.NPQ_PKG_MGR || 'npm'
9
23
 
10
24
  const nodeVersion = process.versions.node
@@ -46,31 +60,18 @@ function packageManagerPassthrough() {
46
60
  */
47
61
  function isInteractiveTerminal() {
48
62
  // Check common CI environment variables
49
- const ciEnvVars = [
50
- 'CI',
51
- 'CONTINUOUS_INTEGRATION',
52
- 'BUILD_NUMBER',
53
- 'RUN_ID',
54
- 'GITHUB_ACTIONS',
55
- 'GITLAB_CI',
56
- 'TRAVIS',
57
- 'CIRCLECI',
58
- 'JENKINS_URL',
59
- 'TEAMCITY_VERSION',
60
- 'TF_BUILD'
61
- ]
62
-
63
63
  // If any CI environment variable is set, we're likely in a CI environment
64
- const isCI = ciEnvVars.some((envVar) => process.env[envVar])
64
+ const isCI = CI_ENV_VARS.some((envVar) => process.env[envVar])
65
65
 
66
- // Check if stdout is a TTY (terminal)
67
- const isTTY = Boolean(process.stdout.isTTY)
66
+ // Check if both stdin and stdout are TTYs (terminal)
67
+ const isTTY = Boolean(process.stdin.isTTY && process.stdout.isTTY)
68
68
 
69
69
  // We're interactive if we're in a TTY and not in CI
70
70
  return isTTY && !isCI
71
71
  }
72
72
 
73
73
  module.exports = {
74
+ CI_ENV_VARS,
74
75
  isEnvSupport,
75
76
  noSupportError,
76
77
  packageManagerPassthrough,
@@ -11,13 +11,34 @@ const CODING_AGENT_ENVIRONMENT_VARIABLES = Object.freeze([
11
11
  'WINDSURF_AGENT',
12
12
  'CODEIUM_AGENT',
13
13
  'AGENT',
14
- 'AI_AGENT'
14
+ 'AI_AGENT',
15
+ 'CLAUDE_CODE',
16
+ 'REPL_ID',
17
+ 'OPENCODE',
18
+ 'AUGMENT_AGENT',
19
+ 'GOOSE_PROVIDER',
20
+ 'JUNIE_DATA',
21
+ 'JUNIE_SHIM_PATH'
15
22
  ])
16
23
 
17
- function isCodingAgentEnvironment(env = process.env) {
18
- return CODING_AGENT_ENVIRONMENT_VARIABLES.some(
19
- (name) => typeof env[name] === 'string' && env[name].length > 0
20
- )
24
+ function hasNonEmptyValue(env, name) {
25
+ return typeof env[name] === 'string' && env[name].length > 0
26
+ }
27
+
28
+ function isCodingAgentEnvironment(env = process.env, { stdoutIsTTY = process.stdout?.isTTY } = {}) {
29
+ if (CODING_AGENT_ENVIRONMENT_VARIABLES.some((name) => hasNonEmptyValue(env, name))) {
30
+ return true
31
+ }
32
+
33
+ if (typeof env.PATH === 'string' && /\.pi[\\/]agent/.test(env.PATH)) {
34
+ return true
35
+ }
36
+
37
+ if (typeof env.EDITOR === 'string' && /devin/i.test(env.EDITOR)) {
38
+ return true
39
+ }
40
+
41
+ return !stdoutIsTTY && typeof env.TERM_PROGRAM === 'string' && /kiro/i.test(env.TERM_PROGRAM)
21
42
  }
22
43
 
23
44
  module.exports = { CODING_AGENT_ENVIRONMENT_VARIABLES, isCodingAgentEnvironment }
@@ -0,0 +1,37 @@
1
+ 'use strict'
2
+
3
+ function getInstallAction({
4
+ countErrors = 0,
5
+ countWarnings = 0,
6
+ isInteractive = false,
7
+ disableAutoContinue = false,
8
+ allowNonInteractiveInstall = false
9
+ } = {}) {
10
+ if (countErrors > 0) {
11
+ return isInteractive ? 'prompt' : 'reject'
12
+ }
13
+
14
+ if (countWarnings > 0) {
15
+ if (isInteractive) {
16
+ return disableAutoContinue ? 'prompt' : 'countdown'
17
+ }
18
+
19
+ return !disableAutoContinue && allowNonInteractiveInstall ? 'install' : 'reject'
20
+ }
21
+
22
+ return 'install'
23
+ }
24
+
25
+ function createNonInteractiveInstallError() {
26
+ const error = new Error(
27
+ 'Installation blocked: findings require an interactive terminal or an explicit non-interactive install opt-in.'
28
+ )
29
+ error.code = 'NON_INTERACTIVE_INSTALL'
30
+ error.exitCode = 1
31
+ return error
32
+ }
33
+
34
+ module.exports = {
35
+ getInstallAction,
36
+ createNonInteractiveInstallError
37
+ }
@@ -1,6 +1,7 @@
1
1
  'use strict'
2
2
 
3
- const childProcess = require('child_process')
3
+ const crossSpawn = require('cross-spawn')
4
+ const LINE_BREAK_PATTERN = /[\r\n]/
4
5
  const DEFAULT_PKGMGR = 'npm'
5
6
 
6
7
  class packageManager {
@@ -10,35 +11,45 @@ class packageManager {
10
11
  }
11
12
 
12
13
  static spawnPackageManager(packageManagerOption) {
13
- let args = []
14
- args = args.concat(process.argv.slice(2)).filter((item) => {
14
+ const args = process.argv.slice(2).filter((item) => {
15
15
  switch (item) {
16
16
  case '--packageManager':
17
17
  case '--pkgMgr':
18
18
  case '--dry-run':
19
+ case '--allow-non-interactive-install':
19
20
  return false
20
21
  default:
21
- return true
22
+ return !item.startsWith('--allow-non-interactive-install=')
22
23
  }
23
24
  })
24
25
 
25
- let cmd = `${packageManagerOption}`
26
- if (args.length > 0) {
27
- cmd += ` ${args.join(' ')}`
26
+ if (args.some((item) => LINE_BREAK_PATTERN.test(item))) {
27
+ return Promise.reject(new Error('package manager arguments cannot contain line breaks'))
28
28
  }
29
29
 
30
- const child = childProcess.spawn(cmd, {
30
+ const { executable, args: launchArgs } = packageManager.getPackageManagerLaunchSpec(
31
+ packageManagerOption,
32
+ args
33
+ )
34
+
35
+ const child = crossSpawn.spawn(executable, launchArgs, {
31
36
  stdio: 'inherit',
32
- shell: true
37
+ shell: false
33
38
  })
34
39
 
35
- return new Promise((resolve) => {
36
- child.on('close', (code) => {
37
- resolve(code)
38
- })
40
+ return new Promise((resolve, reject) => {
41
+ child.once('error', reject)
42
+ child.once('close', resolve)
39
43
  })
40
44
  }
41
45
 
46
+ static getPackageManagerLaunchSpec(packageManagerOption, args) {
47
+ return {
48
+ executable: packageManagerOption,
49
+ args
50
+ }
51
+ }
52
+
42
53
  static validatePackageManager(packageManagerOption) {
43
54
  if (!packageManagerOption) {
44
55
  packageManagerOption = packageManager.getDefaultPackageManager()
@@ -48,6 +59,10 @@ class packageManager {
48
59
  throw new Error('a packageManager should be specified as a string')
49
60
  }
50
61
 
62
+ if (LINE_BREAK_PATTERN.test(packageManagerOption)) {
63
+ throw new Error('a packageManager should not contain line breaks')
64
+ }
65
+
51
66
  return packageManagerOption
52
67
  }
53
68
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "npq",
3
- "version": "3.25.0",
3
+ "version": "3.27.0",
4
4
  "description": "marshall your npm/npm package installs with high quality and class 🎖",
5
5
  "bin": {
6
6
  "npq": "./bin/npq.js",
@@ -45,6 +45,7 @@
45
45
  },
46
46
  "dependencies": {
47
47
  "@npmcli/config": "^10.10.0",
48
+ "cross-spawn": "^7.0.6",
48
49
  "npm-package-arg": "^13.0.2",
49
50
  "npm-registry-fetch": "^19.1.1",
50
51
  "semver": "^7.7.4",