pnpm-shield 1.0.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/lib/docs.js ADDED
@@ -0,0 +1,154 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * DOCS — per-check documentation.
5
+ * Each key maps to a check's docKey.
6
+ * refs: array of { label, url } for official documentation.
7
+ */
8
+ const DOCS = {
9
+ pnpm_installed: {
10
+ title: 'pnpm installed and in PATH',
11
+ why: 'pnpm is the only mainstream package manager with built-in supply-chain\nprotections: per-project ignore-scripts, onlyBuiltDependencies whitelisting,\nand a content-addressable store with integrity verification.',
12
+ attack: 'Without pnpm you lose all layered defences this tool checks for.\nnpm has no equivalent to onlyBuiltDependencies.',
13
+ fix: 'curl -fsSL https://get.pnpm.io/install.sh | sh\n# or via Corepack (recommended):\ncorepack enable pnpm',
14
+ refs: [
15
+ { label: 'pnpm Installation', url: 'https://pnpm.io/installation' },
16
+ { label: 'pnpm vs npm security', url: 'https://pnpm.io/faq#security' },
17
+ ],
18
+ },
19
+
20
+ npm_alias: {
21
+ title: 'Shell alias: npm → pnpm',
22
+ why: 'Even with pnpm fully configured, typing `npm install` by muscle memory\ninvokes the real npm binary. npm ignores your .npmrc ignore-scripts,\nyour pnpm-lock.yaml, and your onlyBuiltDependencies whitelist entirely.',
23
+ attack: 'Typosquatting attacks rely on developers accidentally running\n`npm install evil-pkg`. The alias intercepts that at the shell level,\nbefore any network request is made.',
24
+ fix: '# Add to ~/.zshrc (or ~/.bashrc):\nalias npm=pnpm\n# Reload:\nsource ~/.zshrc',
25
+ refs: [
26
+ { label: 'Typosquatting attacks (npm blog)', url: 'https://blog.npmjs.org/post/163723642530/crossenv-malware-on-the-npm-registry' },
27
+ { label: 'Bash aliases docs', url: 'https://www.gnu.org/software/bash/manual/html_node/Aliases.html' },
28
+ ],
29
+ },
30
+
31
+ corepack: {
32
+ title: 'Corepack managing pnpm',
33
+ why: 'Corepack is a Node.js built-in (since v16.9) that reads "packageManager"\nin package.json and enforces it OS-wide. When active, running `npm install`\nin a pnpm project throws an error before any package is downloaded.',
34
+ attack: 'Without Corepack, a CI script or a contributor can silently switch to\nnpm and bypass all protections — the attack surface is invisible.',
35
+ fix: 'corepack enable\ncorepack enable pnpm\n# Then pin the version in package.json:\n# "packageManager": "pnpm@11.1.1"',
36
+ refs: [
37
+ { label: 'Corepack docs (Node.js)', url: 'https://nodejs.org/api/corepack.html' },
38
+ { label: 'pnpm + Corepack guide', url: 'https://pnpm.io/installation#using-corepack' },
39
+ ],
40
+ },
41
+
42
+ foreign_locks: {
43
+ title: 'No foreign lockfiles',
44
+ why: 'A package-lock.json or yarn.lock alongside pnpm-lock.yaml creates two\nconflicting sources of truth. CI systems may pick the wrong one, installing\ndifferent (potentially malicious) resolved versions.',
45
+ attack: 'Dependency confusion attacks can persist silently if an old npm lockfile\nis accidentally used instead of the audited pnpm lockfile on a build server.',
46
+ fix: 'rm package-lock.json yarn.lock bun.lockb\npnpm install\ngit add pnpm-lock.yaml && git commit -m "chore: switch to pnpm lockfile"',
47
+ refs: [
48
+ { label: 'Dependency confusion attack (A. Birsan)', url: 'https://medium.com/@alex.birsan/dependency-confusion-4a5d60fec610' },
49
+ { label: 'pnpm lockfile format', url: 'https://pnpm.io/lockfile-format' },
50
+ ],
51
+ },
52
+
53
+ global_ignore_scripts: {
54
+ title: 'Global ignore-scripts = true',
55
+ why: 'Packages can declare postinstall, preinstall, and install lifecycle scripts\nthat run arbitrary shell commands the moment you install them. This is the\nprimary vector for supply-chain attacks (event-stream 2018, node-ipc 2022,\nxz-utils 2024).',
56
+ attack: 'A malicious postinstall script can exfiltrate env vars, SSH keys,\nAWS credentials, or install a persistent backdoor — all silently\nduring a routine `pnpm install`.',
57
+ fix: 'pnpm config set ignore-scripts true\n# Verify:\npnpm config get ignore-scripts # should print: true',
58
+ refs: [
59
+ { label: 'pnpm config: ignore-scripts', url: 'https://pnpm.io/npmrc#ignore-scripts' },
60
+ { label: 'event-stream incident', url: 'https://snyk.io/blog/a-post-mortem-of-the-malicious-event-stream-backdoor/' },
61
+ { label: 'node-ipc incident (Socket.dev)', url: 'https://socket.dev/npm/package/node-ipc' },
62
+ ],
63
+ },
64
+
65
+ local_npmrc: {
66
+ title: 'Local .npmrc: ignore-scripts=true',
67
+ why: 'The global pnpm config can differ across machines and CI environments.\nA local .npmrc commits the rule into the repo itself, ensuring every\ndeveloper and every CI runner is protected regardless of global config.',
68
+ attack: 'A developer with a fresh machine and default global config runs\n`pnpm install` — without the local .npmrc they have no protection\neven if the repo author has ignore-scripts enabled globally.',
69
+ fix: 'echo "ignore-scripts=true" >> .npmrc\ngit add .npmrc && git commit -m "chore: enforce ignore-scripts"',
70
+ refs: [
71
+ { label: 'pnpm .npmrc reference', url: 'https://pnpm.io/npmrc' },
72
+ { label: 'npm .npmrc docs', url: 'https://docs.npmjs.com/cli/v10/configuring-npm/npmrc' },
73
+ ],
74
+ },
75
+
76
+ save_exact: {
77
+ title: 'save-exact=true in .npmrc',
78
+ why: 'By default pnpm saves dependencies with a ^ prefix (e.g. ^1.2.3),\nallowing any compatible minor/patch update. An attacker who compromises\na package can publish 1.2.4 with malicious code and every project\nusing ^1.2.3 will silently upgrade on next install.',
79
+ attack: 'Semver hijacking: attacker publishes a malicious patch release.\nAll projects with a range like ^1.x.x automatically adopt the payload\non their next `pnpm install` — often in CI pipelines.',
80
+ fix: 'echo "save-exact=true" >> .npmrc\n# Existing ranges in package.json must be updated manually.',
81
+ refs: [
82
+ { label: 'pnpm save-exact config', url: 'https://pnpm.io/npmrc#save-exact' },
83
+ { label: 'Semver hijacking attack', url: 'https://snyk.io/blog/ten-npm-security-best-practices/' },
84
+ ],
85
+ },
86
+
87
+ shamefully_hoist: {
88
+ title: 'shamefully-hoist=false',
89
+ why: 'pnpm uses a strict, isolated node_modules layout by default.\nshamefully-hoist=true flattens it like npm, allowing packages to\nimport dependencies they never declared. This enables phantom\ndependency attacks.',
90
+ attack: 'A package imports a transitive dep it does not declare.\nAn attacker replaces that transitive dep with a malicious version —\nthe package silently consumes the payload via the flat layout.',
91
+ fix: 'echo "shamefully-hoist=false" >> .npmrc\n# This is the pnpm default — making it explicit prevents accidental override.',
92
+ refs: [
93
+ { label: 'pnpm shamefully-hoist', url: 'https://pnpm.io/npmrc#shamefully-hoist' },
94
+ { label: 'Phantom dependencies (pnpm blog)', url: 'https://pnpm.io/blog/2020/05/27/flat-node-modules-is-not-the-only-way' },
95
+ ],
96
+ },
97
+
98
+ engine_strict: {
99
+ title: 'engine-strict=true',
100
+ why: 'Some security patches are Node.js-version-specific. Running code on\nan EOL Node version may miss critical fixes or expose undefined behaviour\nthat attackers can exploit.',
101
+ attack: 'Supply-chain payloads are sometimes crafted to trigger only on\nspecific Node versions to evade detection on developer machines\nwhile exploiting CI runners with different runtimes.',
102
+ fix: 'echo "engine-strict=true" >> .npmrc\n# Also add to package.json:\n# "engines": { "node": ">=20" }',
103
+ refs: [
104
+ { label: 'pnpm engine-strict', url: 'https://pnpm.io/npmrc#engine-strict' },
105
+ { label: 'Node.js release schedule', url: 'https://nodejs.org/en/about/previous-releases' },
106
+ ],
107
+ },
108
+
109
+ only_built_deps: {
110
+ title: 'pnpm.onlyBuiltDependencies whitelist',
111
+ why: 'Even with ignore-scripts=true, you may need certain packages to run\nbuild scripts (e.g. esbuild, sharp). onlyBuiltDependencies lets you\nwhitelist exactly those — everything else stays blocked.',
112
+ attack: 'Without the whitelist, you either block everything (breaking native\npackages) or allow everything (opening the attack surface). The\nwhitelist gives surgical, auditable control.',
113
+ fix: '# In package.json:\n{\n "pnpm": {\n "onlyBuiltDependencies": ["esbuild", "sharp"]\n }\n}',
114
+ refs: [
115
+ { label: 'pnpm onlyBuiltDependencies', url: 'https://pnpm.io/package_json#pnpmonlybuiltdependencies' },
116
+ { label: 'pnpm allowedDeprecatedVersions', url: 'https://pnpm.io/package_json#pnpmalloweddeprecatedversions' },
117
+ ],
118
+ },
119
+
120
+ package_manager_field: {
121
+ title: '"packageManager" field in package.json',
122
+ why: 'This field tells Corepack the exact package manager + version the project\nrequires. Corepack will block npm and yarn when this is set to pnpm@X.Y.Z,\nand auto-download the pinned version if needed.',
123
+ attack: 'Without it, Corepack cannot enforce the package manager restriction.\nAny tool — npm, yarn, bun — can silently install packages and bypass\nyour pnpm-specific protections.',
124
+ fix: '# Automatically set to current pnpm version:\nnode -e "const fs=require(\'fs\'),p=JSON.parse(fs.readFileSync(\'package.json\'));p.packageManager=\'pnpm@\'+require(\'child_process\').execSync(\'pnpm --version\').toString().trim();fs.writeFileSync(\'package.json\',JSON.stringify(p,null,2)+\'\\n\')"',
125
+ refs: [
126
+ { label: 'packageManager field (Node.js docs)', url: 'https://nodejs.org/api/packages.html#packagemanager' },
127
+ { label: 'Corepack + packageManager', url: 'https://nodejs.org/api/corepack.html#enabling-the-feature' },
128
+ ],
129
+ },
130
+
131
+ engines_node: {
132
+ title: 'engines.node range in package.json',
133
+ why: 'Declares the minimum (and optionally maximum) Node.js version required.\nWhen combined with engine-strict=true, pnpm will refuse to install on\nincompatible environments, preventing accidental use of EOL runtimes.',
134
+ attack: 'EOL Node.js versions no longer receive security patches. Running\nproduction code on an EOL runtime can expose known CVEs that have\nbeen fixed in active LTS releases.',
135
+ fix: '# In package.json:\n"engines": { "node": ">=20" }\n# Check current LTS: https://nodejs.org/en/about/previous-releases',
136
+ refs: [
137
+ { label: 'package.json engines field', url: 'https://docs.npmjs.com/cli/v10/configuring-npm/package-json#engines' },
138
+ { label: 'Node.js LTS schedule', url: 'https://nodejs.org/en/about/previous-releases' },
139
+ ],
140
+ },
141
+
142
+ pnpm_lock: {
143
+ title: 'pnpm-lock.yaml present',
144
+ why: 'The lockfile pins exact resolved versions AND SHA-512 integrity hashes\nfor every package in the full dependency tree. Without it, pnpm resolves\nversions fresh on each install and can silently upgrade to a compromised\nrelease.',
145
+ attack: 'Without a lockfile, a publish-time attack (malicious new version of a dep)\ntakes effect on the very next `pnpm install` in any environment.\nThe lockfile is your last line of defence against this.',
146
+ fix: 'pnpm install # generates pnpm-lock.yaml\ngit add pnpm-lock.yaml\ngit commit -m "chore: add pnpm lockfile"\n# Never add pnpm-lock.yaml to .gitignore!',
147
+ refs: [
148
+ { label: 'pnpm lockfile docs', url: 'https://pnpm.io/lockfile-format' },
149
+ { label: 'Why commit lockfiles', url: 'https://pnpm.io/faq#should-lockfile-be-committed' },
150
+ ],
151
+ },
152
+ };
153
+
154
+ module.exports = { DOCS };
package/lib/fixes.js ADDED
@@ -0,0 +1,187 @@
1
+ 'use strict';
2
+
3
+ const { execSync, spawnSync } = require('child_process');
4
+ const fs = require('fs');
5
+ const os = require('os');
6
+ const path = require('path');
7
+ const c = require('./colors');
8
+
9
+ // ─── Individual fix handlers ──────────────────────────────────────────────────
10
+
11
+ const FIXERS = {
12
+
13
+ globalIgnoreScripts(cwd) {
14
+ execSync('pnpm config set ignore-scripts true', { stdio: 'pipe' });
15
+ console.log(` ${c.green}✔ Set global ignore-scripts=true${c.reset}`);
16
+ },
17
+
18
+ localNpmrc(cwd) {
19
+ const rcPath = path.join(cwd, '.npmrc');
20
+ const cur = fs.existsSync(rcPath) ? fs.readFileSync(rcPath, 'utf8') : '';
21
+ if (!cur.includes('ignore-scripts=true')) {
22
+ fs.appendFileSync(rcPath, '\nignore-scripts=true\n');
23
+ console.log(` ${c.green}✔ Added ignore-scripts=true to .npmrc${c.reset}`);
24
+ } else {
25
+ console.log(` ${c.dim} (ignore-scripts already in .npmrc — skipped)${c.reset}`);
26
+ }
27
+ },
28
+
29
+ saveExact(cwd) {
30
+ const rcPath = path.join(cwd, '.npmrc');
31
+ const cur = fs.existsSync(rcPath) ? fs.readFileSync(rcPath, 'utf8') : '';
32
+ if (!cur.includes('save-exact')) {
33
+ fs.appendFileSync(rcPath, '\nsave-exact=true\n');
34
+ console.log(` ${c.green}✔ Added save-exact=true to .npmrc${c.reset}`);
35
+ } else {
36
+ console.log(` ${c.dim} (save-exact already in .npmrc — skipped)${c.reset}`);
37
+ }
38
+ },
39
+
40
+ shamefullyHoist(cwd) {
41
+ const rcPath = path.join(cwd, '.npmrc');
42
+ const cur = fs.existsSync(rcPath) ? fs.readFileSync(rcPath, 'utf8') : '';
43
+ if (!cur.includes('shamefully-hoist')) {
44
+ fs.appendFileSync(rcPath, '\nshamefully-hoist=false\n');
45
+ console.log(` ${c.green}✔ Added shamefully-hoist=false to .npmrc${c.reset}`);
46
+ } else {
47
+ console.log(` ${c.dim} (shamefully-hoist already in .npmrc — skipped)${c.reset}`);
48
+ }
49
+ },
50
+
51
+ engineStrict(cwd) {
52
+ const rcPath = path.join(cwd, '.npmrc');
53
+ const cur = fs.existsSync(rcPath) ? fs.readFileSync(rcPath, 'utf8') : '';
54
+ if (!cur.includes('engine-strict')) {
55
+ fs.appendFileSync(rcPath, '\nengine-strict=true\n');
56
+ console.log(` ${c.green}✔ Added engine-strict=true to .npmrc${c.reset}`);
57
+ } else {
58
+ console.log(` ${c.dim} (engine-strict already in .npmrc — skipped)${c.reset}`);
59
+ }
60
+ },
61
+
62
+ packageManager(cwd) {
63
+ const pkgPath = path.join(cwd, 'package.json');
64
+ if (!fs.existsSync(pkgPath)) {
65
+ console.log(` ${c.yellow} ⚠️ package.json not found — skipped${c.reset}`); return;
66
+ }
67
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
68
+ const version = (() => {
69
+ try { return execSync('pnpm --version', { stdio: 'pipe' }).toString().trim(); }
70
+ catch { return '9.0.0'; }
71
+ })();
72
+ pkg.packageManager = `pnpm@${version}`;
73
+ fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n');
74
+ console.log(` ${c.green}✔ Set packageManager=pnpm@${version} in package.json${c.reset}`);
75
+ },
76
+
77
+ onlyBuiltDeps(cwd) {
78
+ const pkgPath = path.join(cwd, 'package.json');
79
+ if (!fs.existsSync(pkgPath)) {
80
+ console.log(` ${c.yellow} ⚠️ package.json not found — skipped${c.reset}`); return;
81
+ }
82
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
83
+ pkg.pnpm = pkg.pnpm || {};
84
+ if (!Array.isArray(pkg.pnpm.onlyBuiltDependencies)) {
85
+ pkg.pnpm.onlyBuiltDependencies = [];
86
+ fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n');
87
+ console.log(` ${c.green}✔ Added pnpm.onlyBuiltDependencies: [] to package.json${c.reset}`);
88
+ console.log(` ${c.dim} (empty = blocks ALL postinstall scripts by default)${c.reset}`);
89
+ } else {
90
+ console.log(` ${c.dim} (onlyBuiltDependencies already present — skipped)${c.reset}`);
91
+ }
92
+ },
93
+
94
+ enginesNode(cwd) {
95
+ const pkgPath = path.join(cwd, 'package.json');
96
+ if (!fs.existsSync(pkgPath)) {
97
+ console.log(` ${c.yellow} ⚠️ package.json not found — skipped${c.reset}`); return;
98
+ }
99
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
100
+ // Use current major Node version as minimum
101
+ const major = parseInt(process.version.slice(1), 10);
102
+ pkg.engines = pkg.engines || {};
103
+ if (!pkg.engines.node) {
104
+ pkg.engines.node = `>=${major}`;
105
+ fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n');
106
+ console.log(` ${c.green}✔ Added engines.node: ">=${major}" to package.json${c.reset}`);
107
+ } else {
108
+ console.log(` ${c.dim} (engines.node already set — skipped)${c.reset}`);
109
+ }
110
+ },
111
+
112
+ corepack(cwd) {
113
+ console.log(` ${c.dim}Running: corepack enable pnpm${c.reset}`);
114
+ const result = spawnSync('corepack', ['enable', 'pnpm'], { stdio: 'inherit' });
115
+ if (result.status === 0) {
116
+ console.log(` ${c.green}✔ Corepack enabled for pnpm${c.reset}`);
117
+ } else {
118
+ console.log(` ${c.red} ✗ corepack enable pnpm failed (exit ${result.status})${c.reset}`);
119
+ }
120
+ },
121
+
122
+ foreignLocks(cwd) {
123
+ const candidates = ['package-lock.json', 'yarn.lock', 'bun.lockb'];
124
+ let removed = 0;
125
+ for (const name of candidates) {
126
+ const full = path.join(cwd, name);
127
+ if (fs.existsSync(full)) {
128
+ fs.rmSync(full);
129
+ console.log(` ${c.green}✔ Deleted ${name}${c.reset}`);
130
+ removed++;
131
+ }
132
+ }
133
+ if (removed === 0) console.log(` ${c.dim} (no foreign lockfiles found — skipped)${c.reset}`);
134
+ },
135
+
136
+ pnpmInstall(cwd) {
137
+ console.log(` ${c.dim}Running: pnpm install${c.reset}`);
138
+ const result = spawnSync('pnpm', ['install'], { stdio: 'inherit', cwd });
139
+ if (result.status === 0) {
140
+ console.log(` ${c.green}✔ pnpm install completed — pnpm-lock.yaml generated${c.reset}`);
141
+ } else {
142
+ console.log(` ${c.red} ✗ pnpm install failed (exit ${result.status})${c.reset}`);
143
+ }
144
+ },
145
+
146
+ npmAlias(cwd) {
147
+ const shell = process.env.SHELL ?? '';
148
+ const isFish = shell.includes('fish');
149
+ let targetCfg = path.join(os.homedir(), '.zshrc');
150
+ if (shell.includes('bash')) targetCfg = path.join(os.homedir(), '.bashrc');
151
+ if (isFish) targetCfg = path.join(os.homedir(), '.config', 'fish', 'config.fish');
152
+
153
+ const aliasLine = isFish
154
+ ? '\n# pnpm-shield: block accidental npm usage\nalias npm pnpm\n'
155
+ : '\n# pnpm-shield: block accidental npm usage\nalias npm=pnpm\n';
156
+
157
+ const existing = fs.existsSync(targetCfg) ? fs.readFileSync(targetCfg, 'utf8') : '';
158
+ if (!/alias\s+npm[\s=]['"]?pnpm/.test(existing)) {
159
+ fs.appendFileSync(targetCfg, aliasLine);
160
+ console.log(` ${c.green}✔ Added 'alias npm=pnpm' to ${targetCfg}${c.reset}`);
161
+ console.log(` ${c.yellow} ℹ️ Reload shell: source ${targetCfg}${c.reset}`);
162
+ } else {
163
+ console.log(` ${c.dim} (npm alias already present — skipped)${c.reset}`);
164
+ }
165
+ },
166
+ };
167
+
168
+ // ─── Apply a list of chosen result objects ────────────────────────────────────
169
+ async function applyFixes(chosen, cwd) {
170
+ console.log(`\n${c.bold}🔧 Applying ${chosen.length} fix(es)...${c.reset}`);
171
+
172
+ for (const r of chosen) {
173
+ if (!r.fix || !FIXERS[r.fix]) {
174
+ console.log(` ${c.yellow} ⚠️ No auto-fix available for: ${r.label}${c.reset}`);
175
+ continue;
176
+ }
177
+ try {
178
+ FIXERS[r.fix](cwd);
179
+ } catch (err) {
180
+ console.log(` ${c.red} ✗ Fix failed for "${r.label}": ${err.message}${c.reset}`);
181
+ }
182
+ }
183
+
184
+ console.log(`\n${c.green}${c.bold}✅ Done! Run 'pnpm-shield' again to verify.${c.reset}\n`);
185
+ }
186
+
187
+ module.exports = { applyFixes };
package/lib/runner.js ADDED
@@ -0,0 +1,255 @@
1
+ 'use strict';
2
+
3
+ const readline = require('readline');
4
+ const c = require('./colors');
5
+ const { runChecks } = require('./checks');
6
+ const { applyFixes } = require('./fixes');
7
+ const { showSelector } = require('./selector');
8
+ const { printHeader, printCheck, printDoc, printSummary, SEVERITY } = require('./ui');
9
+
10
+ const PKG_VERSION = require('../package.json').version;
11
+
12
+ // ─── CLI flags ────────────────────────────────────────────────────────────────
13
+ function parseArgs() {
14
+ const args = process.argv.slice(2);
15
+ return {
16
+ ci: args.includes('--ci') || args.includes('-c'),
17
+ help: args.includes('--help') || args.includes('-h'),
18
+ version: args.includes('--version') || args.includes('-v'),
19
+ };
20
+ }
21
+
22
+ function printHelp() {
23
+ console.log(`
24
+ ${c.bold}pnpm-shield${c.reset} v${PKG_VERSION} — Supply chain security audit for pnpm projects
25
+
26
+ ${c.bold}Usage:${c.reset}
27
+ pnpm-shield [options]
28
+ pnpm-check [options] (alias)
29
+
30
+ ${c.bold}Options:${c.reset}
31
+ ${c.cyan}--ci${c.reset} Non-interactive mode. Prints results and exits with code 1 if
32
+ failures are found. Designed for CI pipelines.
33
+ ${c.cyan}--help${c.reset} Show this help message.
34
+ ${c.cyan}--version${c.reset} Print version number.
35
+
36
+ ${c.bold}Interactive menu commands (default mode):${c.reset}
37
+ ${c.cyan}?${c.reset} Browse all checks with arrow keys and open documentation.
38
+ ${c.cyan}?N${c.reset} Read docs for check N directly (e.g. ?3).
39
+ ${c.cyan}fix${c.reset} Choose fixes interactively with arrow keys + Space.
40
+ ${c.cyan}1 2 3${c.reset} Apply specific fixes by number (space-separated).
41
+ ${c.cyan}all${c.reset} Apply all auto-fixable items.
42
+ ${c.cyan}q${c.reset} Quit.
43
+
44
+ ${c.bold}Examples:${c.reset}
45
+ pnpm-shield # interactive audit
46
+ pnpm-shield --ci # CI mode, exits 1 on failure
47
+ pnpm-shield --ci | tee audit.log
48
+
49
+ ${c.bold}Exit codes:${c.reset}
50
+ 0 All checks pass (or only warnings)
51
+ 1 One or more CRITICAL/HIGH failures
52
+ 2 Unexpected error
53
+ `);
54
+ }
55
+
56
+ function ask(query) {
57
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
58
+ return new Promise(resolve => rl.question(query, ans => { rl.close(); resolve(ans.trim()); }));
59
+ }
60
+
61
+ // ─── Interactive doc browser ──────────────────────────────────────────────────
62
+ async function browseDoc(results, lastCursor) {
63
+ const items = results.map((r, i) => {
64
+ const sev = SEVERITY[r.severity] || SEVERITY.LOW;
65
+ const icon = r.status === 'pass' ? '✅' : r.status === 'fail' ? '❌' : '⚠️ ';
66
+ return {
67
+ label: `${icon} [${String(i + 1).padStart(2)}] ${sev.badge}${r.severity.padEnd(8)}${c.reset} ${r.label}`,
68
+ };
69
+ });
70
+
71
+ console.log('');
72
+ const selected = await showSelector(items, {
73
+ multi: false,
74
+ title: 'Select a check to read its documentation:',
75
+ initialCursor: lastCursor,
76
+ });
77
+
78
+ if (selected.length === 0) return lastCursor;
79
+
80
+ const idx = selected[0];
81
+ const r = results[idx];
82
+ printDoc(r.docKey, r.label);
83
+ return idx; // return last position so next call starts here
84
+ }
85
+
86
+ // ─── Interactive fix selector ─────────────────────────────────────────────────
87
+ async function selectFixes(fixable) {
88
+ const items = fixable.map((r, i) => {
89
+ const sev = SEVERITY[r.severity] || SEVERITY.LOW;
90
+ return { label: `[${i + 1}] ${sev.badge}${r.severity.padEnd(8)}${c.reset} ${r.label}` };
91
+ });
92
+
93
+ console.log('');
94
+ const selected = await showSelector(items, {
95
+ multi: true,
96
+ title: 'Select fixes to apply (Space toggle, Enter confirm):',
97
+ });
98
+
99
+ return selected.map(i => fixable[i]);
100
+ }
101
+
102
+ // ─── CI mode ─────────────────────────────────────────────────────────────────
103
+ function runCI(results, startMs) {
104
+ console.log('');
105
+ for (let i = 0; i < results.length; i++) printCheck(results[i], i);
106
+
107
+ const { failures } = printSummary(results, startMs);
108
+
109
+ const critFails = results.filter(r => r.status === 'fail' && r.severity === 'CRITICAL');
110
+ const highFails = results.filter(r => r.status === 'fail' && r.severity === 'HIGH');
111
+
112
+ if (critFails.length > 0) {
113
+ console.log(`\n${c.red}${c.bold}🚨 CRITICAL failures:${c.reset}`);
114
+ critFails.forEach(r => {
115
+ console.log(` ${c.red}• ${r.label}${c.reset}`);
116
+ if (r.tip) console.log(` ${c.yellow}↳ ${r.tip}${c.reset}`);
117
+ });
118
+ }
119
+ if (highFails.length > 0) {
120
+ console.log(`\n${c.orange}${c.bold}⚡ HIGH severity failures:${c.reset}`);
121
+ highFails.forEach(r => {
122
+ console.log(` ${c.orange}• ${r.label}${c.reset}`);
123
+ if (r.tip) console.log(` ${c.dim}↳ ${r.tip}${c.reset}`);
124
+ });
125
+ }
126
+
127
+ if (failures === 0) {
128
+ console.log(`\n${c.green}${c.bold}✅ All critical checks passed.${c.reset}\n`);
129
+ process.exit(0);
130
+ } else {
131
+ console.log(`\n${c.red}🛑 ${failures} failure(s) detected. Fix them before proceeding.${c.reset}\n`);
132
+ process.exit(1);
133
+ }
134
+ }
135
+
136
+ // ─── Main runner ──────────────────────────────────────────────────────────────
137
+ async function run() {
138
+ const flags = parseArgs();
139
+ const startMs = Date.now();
140
+ const cwd = process.cwd();
141
+
142
+ if (flags.version) { console.log(`pnpm-shield v${PKG_VERSION}`); process.exit(0); }
143
+ if (flags.help) { printHelp(); process.exit(0); }
144
+
145
+ printHeader(PKG_VERSION);
146
+
147
+ const results = runChecks(cwd);
148
+
149
+ // CI mode — no interaction
150
+ if (flags.ci) { runCI(results, startMs); return; }
151
+
152
+ // Print results
153
+ console.log('');
154
+ for (let i = 0; i < results.length; i++) printCheck(results[i], i);
155
+
156
+ const { failures } = printSummary(results, startMs);
157
+
158
+ const critFails = results.filter(r => r.status === 'fail' && r.severity === 'CRITICAL');
159
+ const highFails = results.filter(r => r.status === 'fail' && r.severity === 'HIGH');
160
+ if (critFails.length > 0) {
161
+ console.log(`\n${c.red}${c.bold}🚨 CRITICAL failures:${c.reset}`);
162
+ critFails.forEach(r => {
163
+ console.log(` ${c.red}• ${r.label}${c.reset}`);
164
+ if (r.tip) console.log(` ${c.yellow}↳ ${r.tip}${c.reset}`);
165
+ });
166
+ }
167
+ if (highFails.length > 0) {
168
+ console.log(`\n${c.orange}${c.bold}⚡ HIGH severity failures:${c.reset}`);
169
+ highFails.forEach(r => {
170
+ console.log(` ${c.orange}• ${r.label}${c.reset}`);
171
+ if (r.tip) console.log(` ${c.dim}↳ ${r.tip}${c.reset}`);
172
+ });
173
+ }
174
+
175
+ // ALL non-passing results that have an auto-fix handler
176
+ const fixable = results.filter(r => r.status !== 'pass' && r.fix);
177
+
178
+ if (failures === 0 && fixable.length === 0) {
179
+ console.log(`\n${c.green}${c.bold}🎉 Your project is a fortress! You can safely start coding.${c.reset}\n`);
180
+ return;
181
+ }
182
+
183
+ // Help text
184
+ console.log(`\n${c.dim} Commands:`);
185
+ console.log(` ? → Browse all checks with arrow keys and read documentation`);
186
+ console.log(` ?N → Read docs for check N directly (e.g. ?3)`);
187
+ if (fixable.length > 0) {
188
+ console.log(` fix → Open visual fix selector (arrow keys + Space + Enter)`);
189
+ console.log(` all → Apply all ${fixable.length} fixable items at once`);
190
+ }
191
+ console.log(` q → Quit${c.reset}`);
192
+
193
+ if (fixable.length > 0) {
194
+ console.log(`\n${c.bold}🔧 Fixable items (${fixable.length}):${c.reset}`);
195
+ fixable.forEach((r, i) => {
196
+ const sev = SEVERITY[r.severity] || SEVERITY.LOW;
197
+ const icon = r.status === 'fail' ? `${c.red}❌${c.reset}` : `${c.yellow}⚠️ ${c.reset}`;
198
+ console.log(` ${c.bold}[${i + 1}]${c.reset} ${icon} ${sev.badge}${r.severity.padEnd(8)}${c.reset} ${r.label}`);
199
+ });
200
+ console.log(`\n${c.dim} Tip: type 'fix' to open the visual selector, or 'all' to apply everything${c.reset}`);
201
+ }
202
+
203
+ // Menu loop — remember last doc cursor position between ? calls
204
+ let lastDocCursor = 0;
205
+
206
+ while (true) {
207
+ const answer = await ask(`\n${c.yellow}${c.bold}> ${c.reset}`);
208
+
209
+ if (!answer || answer === 'q' || answer === 'quit') {
210
+ console.log(`\n${c.yellow}✋ Exiting. Resolve ❌ items before running 'pnpm install'.${c.reset}\n`);
211
+ process.exit(failures > 0 ? 1 : 0);
212
+ }
213
+
214
+ // ? — interactive doc browser
215
+ if (answer === '?') {
216
+ lastDocCursor = await browseDoc(results, lastDocCursor);
217
+ continue;
218
+ }
219
+
220
+ // ?N — direct doc for check N
221
+ const docMatch = answer.match(/^\?(\d+)$/);
222
+ if (docMatch) {
223
+ const n = parseInt(docMatch[1], 10) - 1;
224
+ if (n < 0 || n >= results.length) {
225
+ console.log(`${c.red} Invalid check number. Use 1–${results.length}.${c.reset}`);
226
+ } else {
227
+ printDoc(results[n].docKey, results[n].label);
228
+ lastDocCursor = n;
229
+ }
230
+ continue;
231
+ }
232
+
233
+ // fix — visual multi-select
234
+ if ((answer === 'fix' || answer === '') && fixable.length > 0) {
235
+ const chosen = await selectFixes(fixable);
236
+ if (chosen.length === 0) {
237
+ console.log(`${c.dim} No fixes selected.${c.reset}`);
238
+ } else {
239
+ await applyFixes(chosen, cwd);
240
+ break;
241
+ }
242
+ continue;
243
+ }
244
+
245
+ // all
246
+ if (answer === 'all' && fixable.length > 0) {
247
+ await applyFixes(fixable, cwd);
248
+ break;
249
+ }
250
+
251
+ console.log(`${c.dim} Unknown command. Type ?, ?N, fix, all, or q.${c.reset}`);
252
+ }
253
+ }
254
+
255
+ module.exports = { run };