np-audit 1.3.0 → 1.5.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Simon Kobler
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,11 @@
1
+ ![np-audit](docs/title-image.png)
2
+
3
+ [![npm version](https://img.shields.io/npm/v/np-audit.svg)](https://www.npmjs.com/package/np-audit)
4
+ [![npm downloads](https://img.shields.io/npm/dm/np-audit.svg)](https://www.npmjs.com/package/np-audit)
5
+ [![npm package size](https://img.shields.io/npm/unpacked-size/np-audit)](https://www.npmjs.com/package/np-audit)
6
+ [![GitHub license](https://img.shields.io/github/license/KoblerS/np-audit.svg)](https://github.com/KoblerS/np-audit/blob/main/LICENSE)
7
+ [![CI](https://github.com/KoblerS/np-audit/actions/workflows/ci.yml/badge.svg)](https://github.com/KoblerS/np-audit/actions/workflows/ci.yml)
8
+
1
9
  # np-audit — npm package auditor
2
10
 
3
11
  Statically detect obfuscated code in npm `preinstall`/`postinstall` scripts **before** they run. Drop-in replacement for `npm install` and `npm ci`.
@@ -32,34 +40,59 @@ Real-world examples include:
32
40
  - **[ua-parser-js (2021)](https://github.com/advisories/GHSA-pjwm-rvh2-c87w)** — cryptocurrency miner + info-stealer injected via hijacked maintainer account
33
41
  - **[node-ipc (2022)](https://snyk.io/blog/peacenotwar-malicious-npm-node-ipc-package-vulnerability/)** — wiper malware targeting systems by geo-IP
34
42
  - **[colors / faker (2022)](https://snyk.io/blog/open-source-npm-packages-colors-faker/)** — deliberate sabotage by the maintainer via postinstall
35
- - **XZ Utils (2024)** — multi-year social engineering + backdoor via build scripts (C ecosystem, but same pattern)
43
+ - **[SAP CAP / cds-dbs (2025)](https://community.sap.com/t5/technology-blog-posts-by-sap/cap-developers-call-to-action-to-mitigate-and-apply-solution-provided-in/ba-p/14387683)** — compromised npm package targeting SAP developers
36
44
 
37
45
  **`npa` never executes the scripts.** It downloads and statically analyzes them, detecting:
38
46
 
39
- | Signal | Example |
40
- |---|---|
41
- | `eval()` / `new Function()` | `eval(atob("aGVsbG8="))` |
42
- | Obfuscator.io patterns | `var _0x3f2a = [...]` |
43
- | High-entropy strings | Encrypted/compressed payloads |
44
- | Hex escape density | `\x68\x65\x6c\x6c\x6f` |
47
+ | Signal | Example |
48
+ | ------------------------------ | ------------------------------------------ |
49
+ | `eval()` / `new Function()` | `eval(atob("aGVsbG8="))` |
50
+ | Indirect eval | `(0, eval)(x)`, `globalThis['ev'+'al'](x)` |
51
+ | Function constructor prototype | `({}).constructor.constructor("…")()` |
52
+ | `setTimeout` with string arg | `setTimeout('alert(1)', 100)` |
53
+ | Obfuscator.io patterns | `var _0x3f2a = [...]` |
54
+ | High-entropy strings | Encrypted/compressed payloads |
55
+ | Split-literal high entropy | `'aB' + 'cD' + 'eF' + …` (concat chain) |
56
+ | Hex / Unicode escape density | `\x68\x65\x6c\x6c\x6f`, `\u0068\u0065…` |
45
57
  | `String.fromCharCode()` chains | `String.fromCharCode(104,101,108,108,111)` |
46
- | Base64 decode + exec | `Buffer.from(x,'base64')` + `eval` |
47
- | Shell spawning | `require('child_process').exec(...)` |
48
- | Large hex literal arrays | `[0x1a, 0x2b, 0x3c, ...]` × 20+ |
49
- | `process.env` access | Token/credential harvesting |
50
- | Outbound network calls | Data exfiltration |
58
+ | Decimal char-code arrays | `[101,118,97,108,...]` (printable ASCII) |
59
+ | Base64 / hex decode + exec | `Buffer.from(x,'base64'\|'hex')` + `eval` |
60
+ | Shell spawning | `require('child_process').exec(...)` |
61
+ | `worker_threads` | `new Worker(...)`, eval-class surface |
62
+ | Concealed `require` | `require('child' + '_process')` |
63
+ | Dynamic `require` / `import` | `require(variable)`, `import(expr)` |
64
+ | Large hex literal arrays | `[0x1a, 0x2b, 0x3c, ...]` × 20+ |
65
+ | `process.env` access | Token/credential harvesting |
66
+ | Outbound network calls | `https`/`http`/`net`/`dns`/`tls`/`dgram`/`http2`, `node:` prefix, dynamic `import()` |
67
+ | Missing referenced script | Command references file not in tarball |
68
+ | Oversized require graph | Postinstall reaches >50 files or >5 MB |
69
+
70
+ ### Coverage beyond the entry script
71
+
72
+ `npa` doesn't just inspect the single file named in the lifecycle command. It:
73
+
74
+ - Splits chained commands (`&&`, `||`, `;`, `|`) and analyses each segment, so `node setup.js && node payload.js` no longer hides the second invocation.
75
+ - Handles `node -e "…"`, `sh -c "…"`, `python -c "…"` by analysing the inline code rather than just the wrapper command.
76
+ - Reads shell scripts (`sh ./install.sh`), Python scripts, and shebang-invoked files — not only `node` targets.
77
+ - Follows internal `require('./…')` and `import './…'` chains across the package (with cycle detection and per-package caps), so payloads hidden in helper files like `lib/helper.js` are caught.
78
+ - Records dynamic `require(variable)` / `import(expr)` calls as findings, since they can't be resolved statically.
79
+ - Scans the project's **own** `package.json` lifecycle scripts by default, catching PRs and supply-chain attacks that target the repository itself. Opt out with `scanSelf: false` in `.npmauditor.json`.
80
+ - Inspects **all** lifecycle scripts npm may run, not only `preinstall`/`install`/`postinstall`: also `prepare`, `preprepare`, `postprepare`, and `prepublish`.
51
81
 
52
82
  ---
53
83
 
54
84
  ## Install
55
85
 
86
+ **Global install (recommended):**
87
+
56
88
  ```bash
57
- # Global install (recommended for daily use)
58
89
  npm install -g np-audit
90
+ ```
59
91
 
60
- # Or use directly with npx (no install needed)
92
+ **Or use directly with npx:**
93
+
94
+ ```bash
61
95
  npx np-audit scan
62
- npx np-audit install
63
96
  ```
64
97
 
65
98
  After global install, use the `npa` command:
@@ -74,24 +107,29 @@ npa --version
74
107
 
75
108
  ### Commands
76
109
 
77
- | Command | Alias | Description |
78
- |---|---|---|
79
- | `npa install [package]` | `npa i [package]` | Audit then run `npm install` |
80
- | `npa ci` | — | Audit then run `npm ci` |
81
- | `npa scan` | `npa s` | Scan only, no install |
82
- | `npa config get` | `npa c get` | Show current configuration |
83
- | `npa config set <key> <value>` | `npa c set <key> <value>` | Update a config value |
110
+ | Command | Alias | Description |
111
+ | ------------------------------ | ----------- | ---------------------------------- |
112
+ | `npa install [package]` | `npa i` | Audit then run `npm install` |
113
+ | `npa ci` | — | Audit then run `npm ci` |
114
+ | `npa scan` | `npa s` | Scan only, no install |
115
+ | `npa config get` | `npa c get` | Show current configuration |
116
+ | `npa config set <key> <value>` | `npa c set` | Update a config value |
117
+ | `npa alias` | — | Print shell hook for auto-scanning |
118
+ | `npa alias --install` | — | Install hook to shell profile |
119
+ | `npa alias --uninstall` | — | Remove hook from shell profile |
120
+
121
+ Use `npa <command> -h` for detailed help on any command.
84
122
 
85
123
  ### Flags
86
124
 
87
- | Flag | Alias | Works with | Description |
88
- |---|---|---|---|
89
- | `--aware` | `-a` | `install`, `ci` | Interactive mode — choose which scripts to allow |
90
- | `--json` | — | `install`, `ci`, `scan` | Machine-readable JSON output |
91
- | `--no-dev` | — | `install`, `ci`, `scan` | Skip devDependencies |
92
- | `--verbose` | — | all | Show fetch progress and extra detail |
93
- | `--version` | — | — | Print version and exit |
94
- | `--help` | `-h` | — | Print help and exit |
125
+ | Flag | Alias | Works with | Description |
126
+ | ----------- | ----- | ----------------------- | ------------------------------------------------ |
127
+ | `--review` | `-r` | `install`, `ci` | Interactive mode — choose which scripts to allow |
128
+ | `--json` | — | `install`, `ci`, `scan` | Machine-readable JSON output |
129
+ | `--no-dev` | — | `install`, `ci`, `scan` | Skip devDependencies |
130
+ | `--verbose` | — | all | Show fetch progress and extra detail |
131
+ | `--version` | — | — | Print version and exit |
132
+ | `--help` | `-h` | — | Print help and exit |
95
133
 
96
134
  ### Drop-in replacement for `npm install`
97
135
 
@@ -118,23 +156,23 @@ npa s --no-dev # skip devDependencies
118
156
  npa s --verbose # show fetch progress
119
157
  ```
120
158
 
121
- ### Interactive `--aware` mode
159
+ ### Interactive `--review` mode
122
160
 
123
161
  Review each install script yourself and decide which to allow:
124
162
 
125
163
  ```bash
126
- npa i --aware # or: npa i -a
127
- npa ci --aware
164
+ npa i --review # or: npa i -r
165
+ npa ci --review
128
166
  ```
129
167
 
130
168
  ```
131
- npa --aware mode
169
+ npa --review mode
132
170
  Use ↑/↓ to navigate, SPACE to toggle, ENTER to confirm, q to quit
133
171
 
134
172
  Found 3 package(s) with install scripts:
135
173
 
136
174
  [✓ allow] esbuild@0.24.2 postinstall: post-install.js OK
137
- ▶ [✗ deny ] evil-sdk@1.0.0 postinstall: install.js BLOCK (score: 9)
175
+ ▶ [✗ deny ] evil-sdk@1.0.0 postinstall: install.js DANGER (score: 9)
138
176
  [✓ allow] @scope/pkg@2.1.0 postinstall: install.js WARN (score: 5)
139
177
 
140
178
  2 allowed 1 denied
@@ -159,26 +197,64 @@ npa config set skipScopes '["@types","@babel"]'
159
197
 
160
198
  Config is stored in `~/.npmauditor.json` (global) and can be overridden per project with `.npmauditor.json` in your project root.
161
199
 
200
+ ### Shell Hook (npm alias)
201
+
202
+ Automatically run `npa scan` before every `npm install` or `npm ci`:
203
+
204
+ ```bash
205
+ # Install the hook to your shell profile (~/.zshrc or ~/.bashrc)
206
+ npa alias --install
207
+
208
+ # Reload your shell
209
+ source ~/.zshrc # or ~/.bashrc
210
+ ```
211
+
212
+ Now when you run `npm install` or `npm ci`, npa will scan first:
213
+
214
+ ```bash
215
+ $ npm install lodash
216
+ [npa] Scanning dependencies before npm install...
217
+ ✔ No packages with install scripts found.
218
+ [npa] Scan passed. Running npm install...
219
+ ```
220
+
221
+ If issues are found, the install is blocked:
222
+
223
+ ```bash
224
+ $ npm install evil-pkg
225
+ [npa] Scanning dependencies before npm install...
226
+ ✗ evil-pkg@1.0.0 DANGER (score: 9)
227
+ [npa] Scan found issues. Run 'npa install --review' for interactive mode.
228
+ ```
229
+
230
+ To remove the hook:
231
+
232
+ ```bash
233
+ npa alias --uninstall
234
+ ```
235
+
162
236
  #### All config keys
163
237
 
164
- | Key | Default | Description |
165
- |---|---|---|
166
- | `blockScore` | `7` | Score threshold for hard block (exit 1) |
167
- | `warnScore` | `4` | Score threshold for warning (exit 0) |
168
- | `registry` | `https://registry.npmjs.org` | npm registry URL |
169
- | `timeout` | `30000` | HTTP request timeout (ms) |
170
- | `parallelFetches` | `5` | Concurrent tarball downloads |
171
- | `skipScopes` | `[]` | `@scope` prefixes to skip entirely |
172
- | `skipPackages` | `[]` | Specific package names to skip |
238
+ | Key | Default | Description |
239
+ | ----------------- | ---------------------------- | --------------------------------------- |
240
+ | `blockScore` | `7` | Score threshold for hard block (exit 1) |
241
+ | `warnScore` | `4` | Score threshold for warning (exit 0) |
242
+ | `registry` | `https://registry.npmjs.org` | npm registry URL |
243
+ | `timeout` | `30000` | HTTP request timeout (ms) |
244
+ | `parallelFetches` | `5` | Concurrent tarball downloads |
245
+ | `skipScopes` | `[]` | `@scope` prefixes to skip entirely |
246
+ | `skipPackages` | `[]` | Specific package names to skip |
247
+ | `silent` | `false` | Suppress output when no issues found |
248
+ | `scanSelf` | `true` | Also scan the current project's own `package.json` lifecycle scripts |
173
249
 
174
250
  ---
175
251
 
176
252
  ## Exit codes
177
253
 
178
- | Code | Meaning |
179
- |---|---|
180
- | `0` | All packages clean or only warnings |
181
- | `1` | One or more packages blocked |
254
+ | Code | Meaning |
255
+ | ---- | ----------------------------------- |
256
+ | `0` | All packages clean or only warnings |
257
+ | `1` | One or more packages blocked |
182
258
 
183
259
  ---
184
260
 
@@ -187,10 +263,13 @@ Config is stored in `~/.npmauditor.json` (global) and can be overridden per proj
187
263
  1. **Parse** `package-lock.json` (supports v1, v2, v3 formats)
188
264
  2. **Filter** packages: skip dev deps (`--no-dev`), skipped scopes/packages, packages without install scripts
189
265
  3. **Fetch or read** — for packages in `node_modules`: read from disk. For packages not yet installed: download the tarball from the npm registry and parse it in memory (pure Node.js tar.gz reader, no `tar` package)
190
- 4. **Analyze** each `preinstall`/`install`/`postinstall` script file statically never execute
191
- 5. **Score** findings (0–10 per signal), classify as BLOCK / WARN / OK based on config thresholds
192
- 6. **Report** results to terminal or `--json`
193
- 7. **Proceed** run npm normally, or in `--aware` mode let you selectively allow scripts
266
+ 4. **Parse the lifecycle command** — split on `&&` / `||` / `;` / `|`, classify each segment by interpreter (`node`, `sh`, `python`, `bun`, …), and treat `node -e` / `sh -c` arguments as inline code
267
+ 5. **Walk the require/import graph** from each entry script following internal `./` / `../` paths, with cycle detection and per-package caps (50 files / 5 MB)
268
+ 6. **Analyze** every reached file statically across all lifecycle scripts (`preinstall`, `install`, `postinstall`, `prepare`, `preprepare`, `postprepare`, `prepublish`) — never execute
269
+ 7. **Also scan the current project's own** `package.json` lifecycle scripts (unless `scanSelf: false`)
270
+ 8. **Score** findings (0–10 per signal), classify as DANGER / WARN / OK based on config thresholds
271
+ 9. **Report** results to terminal or `--json` — each finding is tagged with the file it came from
272
+ 10. **Proceed** — run npm normally, or in `--review` mode let you selectively allow scripts
194
273
 
195
274
  ---
196
275
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "np-audit",
3
- "version": "1.3.0",
3
+ "version": "1.5.0",
4
4
  "description": "Static obfuscation detector for npm lifecycle scripts — supply chain attack prevention",
5
5
  "bin": {
6
6
  "npa": "bin/npa.js",
package/src/cli.js CHANGED
@@ -1,31 +1,34 @@
1
1
  'use strict';
2
2
 
3
- const { scan } = require('./scanner');
4
- const { runAware, runNpm } = require('./aware');
5
- const { loadConfig, setGlobalConfig, getGlobalConfigPath, DEFAULT_CONFIG } = require('./config');
6
- const output = require('./output');
3
+ const { loadConfig, DEFAULT_CONFIG } = require('./utils/config');
4
+ const { checkForUpdate } = require('./utils/updateChecker');
5
+ const commands = require('./commands');
6
+ const output = require('./utils/output');
7
7
 
8
8
  const VERSION = require('../package.json').version;
9
9
  const NAME = require('../package.json').name;
10
10
 
11
- const HELP = `
11
+ function buildMainHelp() {
12
+ const cmdList = commands.list();
13
+ const lines = cmdList.map(cmd => {
14
+ const aliases = cmd.aliases.length ? ` (alias: ${cmd.aliases.join(', ')})` : '';
15
+ return ` npa ${cmd.name.padEnd(20)} ${cmd.description}${aliases}`;
16
+ });
17
+
18
+ return `
12
19
  npa — npm package auditor ${VERSION}
13
20
  Statically detects obfuscated code in npm install scripts.
14
21
 
15
22
  Usage:
16
- npa install [package] Audit then run npm install (alias: i)
17
- npa ci Audit then run npm ci
18
- npa scan Scan only, no npm invocation (alias: s)
19
- npa config get Show current configuration (alias: c)
20
- npa config set <k> <v> Set a config value
23
+ ${lines.join('\n')}
21
24
 
22
25
  Flags:
23
- --aware, -a Interactive mode: choose which scripts to allow
26
+ --review, -r Interactive mode: choose which scripts to allow
24
27
  --json Machine-readable JSON output
25
28
  --no-dev Skip devDependencies
26
29
  --verbose Show extra detail
27
30
  --version Print version
28
- --help, -h Print this help
31
+ --help, -h Print this help (use <command> -h for command help)
29
32
 
30
33
  Config keys (stored in ~/.npmauditor.json):
31
34
  blockScore Score threshold for hard block (default: ${DEFAULT_CONFIG.blockScore})
@@ -35,12 +38,14 @@ const HELP = `
35
38
  parallelFetches Concurrent downloads (default: ${DEFAULT_CONFIG.parallelFetches})
36
39
  skipScopes Array of @scopes to skip
37
40
  skipPackages Array of package names to skip
41
+ maxTarballSize Max unpacked tarball size (default: ${DEFAULT_CONFIG.maxTarballSize})
38
42
  `;
43
+ }
39
44
 
40
45
  function parseArgs(argv) {
41
46
  const args = argv.slice(2);
42
47
  const flags = {
43
- aware: false,
48
+ review: false,
44
49
  json: false,
45
50
  noDev: false,
46
51
  verbose: false,
@@ -48,11 +53,12 @@ function parseArgs(argv) {
48
53
  help: false,
49
54
  };
50
55
  const positionals = [];
56
+ const rawArgs = [];
51
57
 
52
58
  for (const arg of args) {
53
59
  switch (arg) {
54
- case '--aware':
55
- case '-a': flags.aware = true; break;
60
+ case '--review':
61
+ case '-r': flags.review = true; break;
56
62
  case '--json': flags.json = true; break;
57
63
  case '--no-dev': flags.noDev = true; break;
58
64
  case '--verbose': flags.verbose = true; break;
@@ -67,164 +73,16 @@ function parseArgs(argv) {
67
73
 
68
74
  const command = positionals[0] || null;
69
75
  const cmdArgs = positionals.slice(1);
70
- return { command, cmdArgs, flags };
71
- }
72
-
73
- async function runInstall(pkgName, flags, config, cwd) {
74
- output.printScanHeader();
75
-
76
- const results = await scan({
77
- cwd,
78
- config,
79
- noDev: flags.noDev,
80
- verbose: flags.verbose,
81
- singlePackage: pkgName || null,
82
- });
83
-
84
- if (flags.json) {
85
- process.stdout.write(JSON.stringify(toJsonReport(results), null, 2) + '\n');
86
- } else {
87
- printResults(results);
76
+ const commandIndex = args.indexOf(command);
77
+ if (commandIndex !== -1) {
78
+ rawArgs.push(...args.slice(commandIndex + 1));
88
79
  }
89
-
90
- const blocked = results.filter(r => r.verdict === 'BLOCK');
91
-
92
- if (blocked.length > 0 && !flags.aware) {
93
- output.error(`${blocked.length} package(s) blocked due to obfuscated install scripts.`);
94
- output.log(output.dim(' Run with --aware to interactively decide which scripts to allow.'));
95
- process.exit(1);
96
- }
97
-
98
- const npmArgs = pkgName ? [pkgName] : [];
99
-
100
- if (flags.aware) {
101
- const packagesWithScripts = results.filter(r => r.verdict !== 'OK' || r.scripts.length > 0);
102
- const exit = await runAware({
103
- results: packagesWithScripts.length > 0 ? packagesWithScripts : results,
104
- command: 'install',
105
- npmArgs,
106
- cwd,
107
- });
108
- process.exit(exit);
109
- } else {
110
- const exit = runNpm('install', npmArgs, cwd);
111
- process.exit(exit);
112
- }
113
- }
114
-
115
- async function runCi(flags, config, cwd) {
116
- output.printScanHeader();
117
-
118
- const results = await scan({ cwd, config, noDev: flags.noDev, verbose: flags.verbose });
119
-
120
- if (flags.json) {
121
- process.stdout.write(JSON.stringify(toJsonReport(results), null, 2) + '\n');
122
- } else {
123
- printResults(results);
124
- }
125
-
126
- const blocked = results.filter(r => r.verdict === 'BLOCK');
127
-
128
- if (blocked.length > 0 && !flags.aware) {
129
- output.error(`${blocked.length} package(s) blocked due to obfuscated install scripts.`);
130
- process.exit(1);
131
- }
132
-
133
- if (flags.aware) {
134
- const exit = await runAware({ results, command: 'ci', npmArgs: [], cwd });
135
- process.exit(exit);
136
- } else {
137
- const exit = runNpm('ci', [], cwd);
138
- process.exit(exit);
139
- }
140
- }
141
-
142
- async function runScan(flags, config, cwd) {
143
- output.printScanHeader();
144
-
145
- const results = await scan({ cwd, config, noDev: flags.noDev, verbose: flags.verbose });
146
-
147
- if (flags.json) {
148
- process.stdout.write(JSON.stringify(toJsonReport(results), null, 2) + '\n');
149
- const hasBlock = results.some(r => r.verdict === 'BLOCK');
150
- process.exit(hasBlock ? 1 : 0);
151
- }
152
-
153
- printResults(results);
154
- output.printSummary(results.map(r => ({ verdict: r.verdict })));
155
-
156
- const hasBlock = results.some(r => r.verdict === 'BLOCK');
157
- process.exit(hasBlock ? 1 : 0);
158
- }
159
-
160
- async function runConfig(cmdArgs, config) {
161
- const subcommand = cmdArgs[0];
162
-
163
- if (subcommand === 'get') {
164
- const globalPath = getGlobalConfigPath();
165
- output.log(output.bold(' Current npa configuration'));
166
- output.log(output.dim(` (global: ${globalPath})`));
167
- output.log('');
168
- for (const [key, val] of Object.entries(config)) {
169
- output.log(` ${output.cyan(key.padEnd(18))} ${JSON.stringify(val)}`);
170
- }
171
- output.log('');
172
- return;
173
- }
174
-
175
- if (subcommand === 'set') {
176
- const key = cmdArgs[1];
177
- const value = cmdArgs[2];
178
- if (!key || value === undefined) {
179
- output.error('Usage: npa config set <key> <value>');
180
- process.exit(1);
181
- }
182
- try {
183
- const updated = setGlobalConfig(key, value);
184
- output.success(`Set ${key} = ${JSON.stringify(updated[key])}`);
185
- output.log(output.dim(` Written to ${getGlobalConfigPath()}`));
186
- } catch (err) {
187
- output.error(err.message);
188
- process.exit(1);
189
- }
190
- return;
191
- }
192
-
193
- output.error(`Unknown config subcommand: "${subcommand}". Use "get" or "set".`);
194
- process.exit(1);
195
- }
196
-
197
- function printResults(results) {
198
- if (results.length === 0) {
199
- output.success('No packages with install scripts found.');
200
- return;
201
- }
202
- for (const r of results) {
203
- output.printPackageResult(r.pkg, r);
204
- }
205
- }
206
-
207
- function toJsonReport(results) {
208
- return {
209
- summary: {
210
- total: results.length,
211
- blocked: results.filter(r => r.verdict === 'BLOCK').length,
212
- warned: results.filter(r => r.verdict === 'WARN').length,
213
- ok: results.filter(r => r.verdict === 'OK').length,
214
- },
215
- packages: results.map(r => ({
216
- name: r.pkg.name,
217
- version: r.pkg.version,
218
- verdict: r.verdict,
219
- score: r.score,
220
- findings: r.findings,
221
- scripts: r.scripts.map(s => ({ lifecycle: s.lifecycle, file: s.file, score: s.score })),
222
- })),
223
- };
80
+ return { command, args: cmdArgs, rawArgs, flags };
224
81
  }
225
82
 
226
83
  async function main() {
227
- const { command, cmdArgs, flags } = parseArgs(process.argv);
84
+ const parsed = parseArgs(process.argv);
85
+ const { command, args, rawArgs, flags } = parsed;
228
86
  const cwd = process.cwd();
229
87
  const config = loadConfig(cwd);
230
88
 
@@ -233,22 +91,38 @@ async function main() {
233
91
  return;
234
92
  }
235
93
 
94
+ if (flags.help && command) {
95
+ const cmd = commands.get(command);
96
+ if (cmd) {
97
+ process.stdout.write(cmd.help() + '\n');
98
+ return;
99
+ }
100
+ }
101
+
236
102
  if (flags.help || !command) {
237
- process.stdout.write(HELP + '\n');
103
+ process.stdout.write(buildMainHelp() + '\n');
238
104
  return;
239
105
  }
240
106
 
241
- switch (command) {
242
- case 'install':
243
- case 'i': await runInstall(cmdArgs[0] || null, flags, config, cwd); break;
244
- case 'ci': await runCi(flags, config, cwd); break;
245
- case 'scan':
246
- case 's': await runScan(flags, config, cwd); break;
247
- case 'config':
248
- case 'c': await runConfig(cmdArgs, config); break;
249
- default:
250
- output.error(`Unknown command: "${command}". Run npa --help for usage.`);
251
- process.exit(1);
107
+ const cmd = commands.get(command);
108
+ if (!cmd) {
109
+ output.error(`Unknown command: "${command}". Run npa --help for usage.`);
110
+ process.exit(1);
111
+ }
112
+
113
+ // Fire update check only for scan/install/ci commands (non-blocking)
114
+ const UPDATE_COMMANDS = ['scan', 's', 'install', 'i', 'ci'];
115
+ const updatePromise = (UPDATE_COMMANDS.includes(command) && !flags.json && !config.silent)
116
+ ? checkForUpdate(config, VERSION)
117
+ : Promise.resolve(null);
118
+
119
+ await cmd.run({ args, rawArgs, flags, config, cwd });
120
+
121
+ // Print update notice after command output
122
+ const latestVersion = await updatePromise;
123
+ if (latestVersion) {
124
+ output.log('');
125
+ output.log(output.dim(` Update available: ${VERSION} → ${latestVersion} — run "npm i -g np-audit" to update`));
252
126
  }
253
127
  }
254
128