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 +74 -41
- package/bin/npq-hero.js +53 -30
- package/bin/npq.js +29 -21
- package/lib/cli.js +32 -7
- package/lib/helpers/cliPrompt.js +5 -26
- package/lib/helpers/cliSupportHandler.js +18 -17
- package/lib/helpers/codingAgentEnvironment.js +26 -5
- package/lib/helpers/installPolicy.js +37 -0
- package/lib/packageManager.js +28 -13
- package/package.json +2 -1
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
|
|
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
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
-
|
|
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
|
|
141
|
-
|
|
|
142
|
-
| age
|
|
143
|
-
| author
|
|
144
|
-
| downloads
|
|
145
|
-
| readme
|
|
146
|
-
| repo
|
|
147
|
-
| scripts
|
|
148
|
-
| snyk
|
|
149
|
-
| license
|
|
150
|
-
| expired domains
|
|
151
|
-
| signatures
|
|
152
|
-
| provenance
|
|
153
|
-
| version-maturity | Will show an error if the specific version being installed was published less than 7 days ago
|
|
154
|
-
| newBin
|
|
155
|
-
| typosquatting
|
|
156
|
-
| deprecation
|
|
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,
|
|
234
|
-
the same audit-only JSON mode.
|
|
235
|
-
|
|
236
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
298
|
-
|
|
299
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
39
|
-
|
|
40
|
-
const
|
|
41
|
-
|
|
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
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
-
|
|
97
|
-
|
|
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
|
-
}
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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()
|
|
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
|
-
|
|
131
|
-
|
|
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
|
-
}
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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
|
|
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
|
|
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
|
|
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(
|
|
186
|
+
normalizedPackages = this._extractPackagesFromPositionals(
|
|
187
|
+
positionals,
|
|
188
|
+
true,
|
|
189
|
+
jsonPackageParsing
|
|
190
|
+
)
|
|
167
191
|
} catch (error) {
|
|
168
|
-
if (
|
|
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
|
}
|
package/lib/helpers/cliPrompt.js
CHANGED
|
@@ -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 =
|
|
64
|
+
const isCI = CI_ENV_VARS.some((envVar) => process.env[envVar])
|
|
65
65
|
|
|
66
|
-
// Check if stdout
|
|
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
|
|
18
|
-
return
|
|
19
|
-
|
|
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
|
+
}
|
package/lib/packageManager.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
'use strict'
|
|
2
2
|
|
|
3
|
-
const
|
|
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
|
-
|
|
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
|
|
22
|
+
return !item.startsWith('--allow-non-interactive-install=')
|
|
22
23
|
}
|
|
23
24
|
})
|
|
24
25
|
|
|
25
|
-
|
|
26
|
-
|
|
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
|
|
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:
|
|
37
|
+
shell: false
|
|
33
38
|
})
|
|
34
39
|
|
|
35
|
-
return new Promise((resolve) => {
|
|
36
|
-
child.
|
|
37
|
-
|
|
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.
|
|
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",
|