node-power-user 2.1.4 → 2.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -103,12 +103,21 @@ npu audit
103
103
  Compare the versions of installed modules to those in your package.json. When you choose to update, the install step and a full post-install audit are both wrapped with Socket for supply chain protection.
104
104
  ```shell
105
105
  npu outdated
106
- npu out --force # bypass Socket protection
106
+ npu out --heal # skip the menu: reinstall copies that don't match package-lock.json
107
+ npu out --sync # skip the menu: install packages to match package.json
108
+ npu out -r # skip the menu: reconcile package.json to installed versions
109
+ npu out -P | -m | -M # skip the menu: apply patch / minor / major updates
110
+ npu out --force # bypass Socket protection
107
111
  ```
108
112
 
109
- When discrepancies are found between `package.json` and `node_modules`, the menu offers context-aware actions:
113
+ Every run starts with an integrity check: npu compares what `node_modules/.package-lock.json` claims is installed against the packages physically on disk — including transitive deps the table can't show. Desynced copies (stale or partially-extracted installs, typically left behind by an interrupted or Socket-blocked install) make npm silently no-op (`npm install` trusts the lockfile over the disk), so npu warns about them and offers to heal.
114
+
115
+ When problems are found, the menu offers context-aware actions:
116
+ - **Heal** — when disk copies don't match `package-lock.json`, removes them and reinstalls so reality matches the lockfile again.
110
117
  - **Sync** — when `node_modules` is *behind* `package.json`, installs packages to match what `package.json` declares.
111
- - **Reconcile** — when `node_modules` is *ahead* of `package.json`, updates `package.json` to match installed versions.
118
+ - **Reconcile** — when `node_modules` is *ahead* of `package.json`, updates `package.json` to match installed versions. Strictly ahead-only — it never downgrades `package.json` to match a stale install.
119
+
120
+ Installs remove the targeted `node_modules` copies first so npm actually re-fetches them (instead of trusting a stale lockfile and reporting "up to date"), then npu verifies the new versions physically landed in `node_modules`. If an install fails or Socket blocks it, both `package.json` and `package-lock.json` are restored — npu never leaves the lockfile advanced past the files on disk.
112
121
 
113
122
  ### List Packages
114
123
  List all packages in your project.
@@ -11,5 +11,12 @@ if (targetDir) {
11
11
  const cli = new (require('../dist/cli.js'))();
12
12
  (async function() {
13
13
  'use strict';
14
- await cli.process(argv);
14
+
15
+ // cli.process logs errors itself — exit non-zero without an unhandled
16
+ // rejection crash (which dumps noise from libraries with global handlers)
17
+ try {
18
+ await cli.process(argv);
19
+ } catch (e) {
20
+ process.exitCode = 1;
21
+ }
15
22
  }());
@@ -1,8 +1,7 @@
1
1
  // Libraries
2
2
  const logger = new (require('../lib/logger'))('node-power-user');
3
3
  const socket = require('../lib/socket');
4
- const jetpack = require('fs-jetpack');
5
- const path = require('path');
4
+ const npm = require('../lib/npm');
6
5
 
7
6
  // Module
8
7
  module.exports = async function (options) {
@@ -28,6 +27,8 @@ module.exports = async function (options) {
28
27
  // remove existing node_modules copies first so npm actually re-fetches them
29
28
  // instead of reporting "up to date" with a stale cached version.
30
29
  if (packages.length > 0 && !flags.includes('--global')) {
30
+ const names = [];
31
+
31
32
  for (const pkg of packages) {
32
33
  // For scoped packages like @scope/name@version, skip the leading @
33
34
  const searchFrom = pkg.startsWith('@') ? 1 : 0;
@@ -36,12 +37,10 @@ module.exports = async function (options) {
36
37
  continue;
37
38
  }
38
39
 
39
- const pkgName = pkg.substring(0, versionIdx);
40
- const pkgDir = path.join(process.cwd(), 'node_modules', pkgName);
41
- if (jetpack.exists(pkgDir)) {
42
- jetpack.remove(pkgDir);
43
- }
40
+ names.push(pkg.substring(0, versionIdx));
44
41
  }
42
+
43
+ npm.removeInstalledCopies(names);
45
44
  }
46
45
 
47
46
  const command = packages.length > 0
@@ -8,6 +8,7 @@ const version = require('wonderful-version');
8
8
  const inquirer = require('@inquirer/prompts');
9
9
  const ncu = require('npm-check-updates');
10
10
  const socket = require('../lib/socket');
11
+ const npm = require('../lib/npm');
11
12
  const { execute } = require('node-powertools');
12
13
 
13
14
  // Load package.json
@@ -16,6 +17,7 @@ const projectPath = process.cwd();
16
17
  // Module
17
18
  module.exports = async function (options) {
18
19
  const packageJsonPath = path.join(projectPath, 'package.json');
20
+ const lockfilePath = path.join(projectPath, 'package-lock.json');
19
21
  let projectJson = jetpack.read(packageJsonPath, 'json');
20
22
 
21
23
  // Check if package.json exists
@@ -34,6 +36,11 @@ module.exports = async function (options) {
34
36
  // Log start
35
37
  logger.log(`Checking packages for ${logger.format.bold(projectJson.name || 'Unknown Project')}...`);
36
38
 
39
+ // Detect desync between the hidden lockfile and the physical node_modules —
40
+ // desynced copies make npm silently no-op installs (it trusts the lockfile
41
+ // over the disk), which is what traps projects in reconcile/update loops
42
+ const desynced = npm.findDesynced(projectPath);
43
+
37
44
  // Parse --ignore flag (comma-separated list of package names to skip)
38
45
  const ignoreList = (options.ignore || '')
39
46
  .split(',')
@@ -96,51 +103,64 @@ module.exports = async function (options) {
96
103
  latestVersion,
97
104
  type: getDependencyType(projectJson, dep),
98
105
  hasDiscrepancy,
99
- hasMajorUpdate: latestVersion && minorVersion !== latestVersion,
106
+ hasMajorUpdate: latestVersion && latestVersion.split('.')[0] !== packageVersion.split('.')[0],
100
107
  });
101
108
  }
102
109
  }
103
110
 
104
111
  // Display unified table
105
- if (allPackages.size === 0) {
112
+ if (allPackages.size === 0 && desynced.length === 0) {
106
113
  logger.log(logger.format.green('\nAll packages are up to date!'));
107
- return { allPackages };
114
+ return { allPackages, desynced };
108
115
  }
109
116
 
110
- const dataTable = [['Package', 'package.json', 'Installed', 'Latest', 'Type']];
111
-
112
- for (const pkg of allPackages.values()) {
113
- const pkgVersionColor = pkg.hasDiscrepancy ? 'red' : 'green';
114
-
115
- // Format latest version - show major updates in magenta
116
- let latestDisplay;
117
- if (pkg.latestVersion) {
118
- if (pkg.hasMajorUpdate) {
119
- latestDisplay = chalk.magenta(pkg.latestVersion + ' ⚠');
120
- } else if (pkg.latestVersion !== pkg.installedVersion) {
121
- latestDisplay = chalk.cyan(pkg.latestVersion);
117
+ if (allPackages.size > 0) {
118
+ const dataTable = [['Package', 'package.json', 'Installed', 'Latest', 'Type']];
119
+
120
+ for (const pkg of allPackages.values()) {
121
+ const pkgVersionColor = pkg.hasDiscrepancy ? 'red' : 'green';
122
+
123
+ // Format latest version - show major updates in magenta
124
+ let latestDisplay;
125
+ if (pkg.latestVersion) {
126
+ if (pkg.hasMajorUpdate) {
127
+ latestDisplay = chalk.magenta(pkg.latestVersion + ' ⚠');
128
+ } else if (pkg.latestVersion !== pkg.installedVersion) {
129
+ latestDisplay = chalk.cyan(pkg.latestVersion);
130
+ } else {
131
+ latestDisplay = chalk.green(pkg.latestVersion);
132
+ }
122
133
  } else {
123
- latestDisplay = chalk.green(pkg.latestVersion);
134
+ latestDisplay = chalk.dim('-');
124
135
  }
125
- } else {
126
- latestDisplay = chalk.dim('-');
136
+
137
+ dataTable.push([
138
+ pkg.name,
139
+ chalk[pkgVersionColor](pkg.packageVersion),
140
+ chalk.green(pkg.installedVersion),
141
+ latestDisplay,
142
+ pkg.type,
143
+ ]);
127
144
  }
128
145
 
129
- dataTable.push([
130
- pkg.name,
131
- chalk[pkgVersionColor](pkg.packageVersion),
132
- chalk.green(pkg.installedVersion),
133
- latestDisplay,
134
- pkg.type,
135
- ]);
136
- }
146
+ console.log(table(dataTable));
137
147
 
138
- console.log(table(dataTable));
148
+ // Only show legend if there are major updates
149
+ const hasMajorUpdates = [...allPackages.values()].some(pkg => pkg.hasMajorUpdate);
150
+ if (hasMajorUpdates) {
151
+ logger.log(chalk.dim('Legend: ') + chalk.magenta('⚠ = major version (breaking changes)'));
152
+ }
153
+ }
139
154
 
140
- // Only show legend if there are major updates
141
- const hasMajorUpdates = [...allPackages.values()].some(pkg => pkg.hasMajorUpdate);
142
- if (hasMajorUpdates) {
143
- logger.log(chalk.dim('Legend: ') + chalk.magenta('⚠ = major version (breaking changes)'));
155
+ // Warn about desynced copies includes transitive deps the table can't show
156
+ if (desynced.length > 0) {
157
+ logger.warn(`${desynced.length} package(s) on disk don't match what package-lock.json claims is installed (stale or partial installs):`);
158
+ for (const d of desynced.slice(0, 10)) {
159
+ logger.warn(` • ${d.loc.replace(/^node_modules\//, '')} — lockfile has ${d.lockfileVersion}, disk has ${d.diskVersion || 'nothing'}`);
160
+ }
161
+ if (desynced.length > 10) {
162
+ logger.warn(` …and ${desynced.length - 10} more`);
163
+ }
144
164
  }
145
165
 
146
166
  // Separate discrepancies into two categories:
@@ -166,18 +186,24 @@ module.exports = async function (options) {
166
186
  );
167
187
 
168
188
  // Check if major/latest offers any versions beyond what minor gives
169
- const hasMajorBeyondMinor = Object.keys(latestUpgrades).some(dep =>
170
- latestUpgrades[dep] !== (minorUpgrades[dep] || allDependencies[dep])
171
- );
189
+ const hasMajorBeyondMinor = Object.keys(latestUpgrades).some(dep => {
190
+ const latestMajor = version.clean(latestUpgrades[dep]).split('.')[0];
191
+ const currentMajor = version.clean(allDependencies[dep]).split('.')[0];
192
+ return latestMajor !== currentMajor;
193
+ });
172
194
 
173
195
  // If noPrompt, return data without showing menu (used by tests)
174
196
  if (options.noPrompt) {
175
- return { allPackages };
197
+ return { allPackages, desynced };
176
198
  }
177
199
 
178
200
  // Check for shortcut flags (skip menu)
179
201
  let action = null;
180
- if (options.r || options.reconcile) {
202
+ if (options.heal) {
203
+ action = 'heal';
204
+ } else if (options.sync) {
205
+ action = 'sync';
206
+ } else if (options.r || options.reconcile) {
181
207
  action = 'reconcile';
182
208
  } else if (options.P || options.patch) {
183
209
  action = 'patch';
@@ -191,6 +217,14 @@ module.exports = async function (options) {
191
217
  if (!action) {
192
218
  const choices = [];
193
219
 
220
+ // "Heal" reinstalls copies whose disk state doesn't match the lockfile
221
+ if (desynced.length > 0) {
222
+ choices.push({
223
+ name: `Heal (${desynced.length}) - reinstall copies that don't match package-lock.json`,
224
+ value: 'heal',
225
+ });
226
+ }
227
+
194
228
  // "Sync" installs packages where node_modules is behind package.json
195
229
  const syncCount = behindPackages.length + unknownPackages.length;
196
230
  if (syncCount > 0) {
@@ -238,24 +272,101 @@ module.exports = async function (options) {
238
272
  }
239
273
 
240
274
  if (action === 'exit') {
241
- return { allPackages };
275
+ return { allPackages, desynced };
276
+ }
277
+
278
+ // Handle heal — reinstall node_modules copies that don't match the hidden
279
+ // lockfile. Desynced copies make npm silently no-op installs, which traps
280
+ // projects in reconcile/update loops where nothing ever actually changes.
281
+ if (action === 'heal') {
282
+ if (desynced.length === 0) {
283
+ logger.log(logger.format.green('\nNothing to heal — node_modules matches package-lock.json.'));
284
+ return { allPackages, desynced, healed: false };
285
+ }
286
+
287
+ const lockfileBackup = jetpack.read(lockfilePath);
288
+
289
+ logger.log(logger.format.cyan(`\nRemoving ${desynced.length} desynced copies and reinstalling...`));
290
+ npm.removeLocations(desynced.map(d => d.loc), projectPath);
291
+
292
+ try {
293
+ await socket.wrap('npm install', { force: options.force });
294
+ } catch (e) {
295
+ // Restore the lockfile so the failed run can't advance it further
296
+ if (lockfileBackup) {
297
+ jetpack.write(lockfilePath, lockfileBackup);
298
+ }
299
+
300
+ if (e.reason === 'npm-failed') {
301
+ logger.log('');
302
+ logger.log('Fix the npm error above and re-run with --heal — the removed copies will reinstall then.');
303
+ return { allPackages, desynced, healed: false };
304
+ }
305
+
306
+ reportSocketBlock(e, 'npu out --heal --force');
307
+ return { allPackages, desynced, healed: false };
308
+ }
309
+
310
+ // Confirm disk and lockfile are actually back in sync
311
+ const remaining = npm.findDesynced(projectPath);
312
+ if (remaining.length > 0) {
313
+ logger.error(`\n${remaining.length} package(s) are still desynced after reinstalling:`);
314
+ remaining.slice(0, 10).forEach(d => logger.error(` • ${d.loc.replace(/^node_modules\//, '')} — lockfile has ${d.lockfileVersion}, disk has ${d.diskVersion || 'nothing'}`));
315
+ logger.log(`Try a clean install: ${logger.format.cyan('rm -rf node_modules && npm install')}`);
316
+ return { allPackages, desynced, healed: false };
317
+ }
318
+
319
+ try {
320
+ await socket.audit({ force: options.force });
321
+ } catch (e) {
322
+ logger.error(`Audit warning: ${e.message}`);
323
+ }
324
+
325
+ logger.log(logger.format.green(`\nHealed ${desynced.length} package(s) — node_modules now matches package-lock.json.`));
326
+ return { allPackages, desynced, healed: true };
242
327
  }
243
328
 
244
329
  // Handle sync — run npm install for packages where node_modules is behind package.json
245
330
  if (action === 'sync') {
246
331
  const toSync = [...behindPackages, ...unknownPackages];
332
+
333
+ if (toSync.length === 0) {
334
+ logger.log(logger.format.green('\nNothing to sync — node_modules already matches package.json.'));
335
+ return { allPackages, desynced, synced: false };
336
+ }
337
+
247
338
  const installCmd = `npm install ${toSync.map(pkg => `${pkg.name}@${pkg.packageVersion}`).join(' ')}`;
248
339
  logger.log(logger.format.cyan(`\nRunning ${installCmd}...`));
249
340
 
341
+ const lockfileBackup = jetpack.read(lockfilePath);
342
+
343
+ // Remove stale node_modules copies first so npm actually re-fetches them
344
+ // instead of trusting the hidden lockfile and reporting "up to date"
345
+ npm.removeInstalledCopies(toSync.map(pkg => pkg.name), projectPath);
346
+
250
347
  try {
251
348
  await socket.wrap(installCmd, { force: options.force });
252
349
  } catch (e) {
350
+ // Restore the lockfile so a blocked/failed install can't leave it
351
+ // advanced past the physical files (that desync is what mints loops)
352
+ if (lockfileBackup) {
353
+ jetpack.write(lockfilePath, lockfileBackup);
354
+ }
355
+
253
356
  if (e.reason === 'npm-failed') {
254
357
  logger.log('');
255
358
  logger.log('Fix the npm error above and retry.');
256
- return { allPackages, updated: false };
359
+ return { allPackages, desynced, synced: false };
257
360
  }
258
- throw e;
361
+
362
+ reportSocketBlock(e, 'npu out --sync --force');
363
+ return { allPackages, desynced, synced: false };
364
+ }
365
+
366
+ // Confirm the installs physically landed in node_modules
367
+ const expected = Object.fromEntries(toSync.map(pkg => [pkg.name, pkg.packageVersion]));
368
+ if (reportMismatches(npm.verifyInstalled(expected, projectPath))) {
369
+ return { allPackages, desynced, synced: false };
259
370
  }
260
371
 
261
372
  try {
@@ -265,15 +376,27 @@ module.exports = async function (options) {
265
376
  }
266
377
 
267
378
  logger.log(logger.format.green(`\nSynced ${toSync.length} package(s) to match package.json.`));
268
- return { allPackages, synced: true };
379
+ return { allPackages, desynced, synced: true };
269
380
  }
270
381
 
271
- // Handle reconcile — update package.json for packages where node_modules is ahead
382
+ // Handle reconcile — update package.json for packages where node_modules is ahead.
383
+ // Strictly ahead-only: packages where node_modules is BEHIND are a sync problem,
384
+ // and downgrading package.json to match them would mask a stale install.
272
385
  if (action === 'reconcile') {
386
+ if (aheadPackages.length === 0) {
387
+ logger.log('\nNothing to reconcile — no installed packages are ahead of package.json.');
388
+
389
+ const syncCount = behindPackages.length + unknownPackages.length;
390
+ if (syncCount > 0) {
391
+ logger.log(`${syncCount} package(s) are behind package.json — run ${logger.format.cyan('npu out --sync')} to install them.`);
392
+ }
393
+
394
+ return { allPackages, desynced, reconciled: false };
395
+ }
396
+
273
397
  projectJson = jetpack.read(packageJsonPath, 'json');
274
398
 
275
- const toReconcile = aheadPackages.length > 0 ? aheadPackages : discrepancies;
276
- for (const pkg of toReconcile) {
399
+ for (const pkg of aheadPackages) {
277
400
  const depType = pkg.type === 'dev' ? 'devDependencies'
278
401
  : pkg.type === 'peer' ? 'peerDependencies'
279
402
  : 'dependencies';
@@ -282,18 +405,28 @@ module.exports = async function (options) {
282
405
  }
283
406
 
284
407
  jetpack.write(packageJsonPath, projectJson);
285
- logger.log(logger.format.green(`\nReconciled ${toReconcile.length} package(s) in package.json.`));
408
+ logger.log(logger.format.green(`\nReconciled ${aheadPackages.length} package(s) in package.json.`));
286
409
 
287
- return { allPackages, reconciled: true };
410
+ return { allPackages, desynced, reconciled: true };
288
411
  }
289
412
 
290
413
  // Handle patch/minor/major updates
291
414
  const upgrades = action === 'patch' ? patchUpgrades
292
415
  : action === 'minor' ? minorUpgrades
293
416
  : latestUpgrades;
417
+ const packageNames = Object.keys(upgrades);
418
+
419
+ if (packageNames.length === 0) {
420
+ logger.log(logger.format.green(`\nNothing to update — package.json is already at the requested versions.`));
421
+ return { allPackages, desynced, updated: false, target: action };
422
+ }
294
423
 
295
- // Back up package.json before modifying
424
+ // Back up package.json and package-lock.json before modifying, so a failed
425
+ // install can restore BOTH — restoring only package.json leaves the lockfile
426
+ // advanced past the physical files, which is exactly the desync that mints
427
+ // silent no-op installs
296
428
  const packageJsonBackup = jetpack.read(packageJsonPath);
429
+ const lockfileBackup = jetpack.read(lockfilePath);
297
430
 
298
431
  await ncu.run({
299
432
  packageFile: packageJsonPath,
@@ -301,27 +434,34 @@ module.exports = async function (options) {
301
434
  upgrade: true,
302
435
  });
303
436
 
304
- logger.log(logger.format.green(`\nUpdated ${Object.keys(upgrades).length} package(s) in package.json.`));
437
+ logger.log(logger.format.green(`\nUpdated ${packageNames.length} package(s) in package.json.`));
305
438
 
306
439
  // Install the specific upgraded packages so npm actually pulls them in
307
440
  // (plain `npm install` won't upgrade packages that already satisfy the range)
308
- const packageNames = Object.keys(upgrades);
309
441
  const installCmd = `npm install ${packageNames.map(name => `${name}@${version.clean(upgrades[name])}`).join(' ')}`;
310
442
  logger.log(logger.format.cyan(`\nRunning ${installCmd}...`));
311
443
 
444
+ // Remove stale node_modules copies first so npm actually re-fetches them
445
+ // instead of trusting the hidden lockfile and reporting "up to date"
446
+ npm.removeInstalledCopies(packageNames, projectPath);
447
+
312
448
  try {
313
449
  await socket.wrap(installCmd, { force: options.force });
314
450
  } catch (e) {
315
- // Restore package.json since the bulk install failed
451
+ // Restore both manifests since the bulk install failed
316
452
  jetpack.write(packageJsonPath, packageJsonBackup);
317
- logger.log('package.json has been restored to its original state.');
453
+ if (lockfileBackup) {
454
+ jetpack.write(lockfilePath, lockfileBackup);
455
+ }
456
+ logger.log('package.json and package-lock.json have been restored to their original state.');
457
+ logger.log('The removed package copies will show as missing in the next check until reinstalled.');
318
458
 
319
459
  // npm itself failed (ERESOLVE, network, peer-dep conflict) — not a Socket block.
320
460
  // The npm error was already printed above; just acknowledge and stop.
321
461
  if (e.reason === 'npm-failed') {
322
462
  logger.log('');
323
463
  logger.log('Fix the npm error above (e.g. resolve peer-dep conflicts) and retry.');
324
- return { allPackages, updated: false, target: action };
464
+ return { allPackages, desynced, updated: false, target: action };
325
465
  }
326
466
 
327
467
  const flaggedPackages = e.flaggedPackages || [];
@@ -376,7 +516,13 @@ module.exports = async function (options) {
376
516
  logger.log('To bypass Socket for this install only:');
377
517
  logger.log(logger.format.cyan(` SOCKET_CLI_ACCEPT_RISKS=1 npm install ${packageNames.map(name => `${name}@${version.clean(upgrades[name])}`).join(' ')}`));
378
518
 
379
- return { allPackages, updated: false, target: action };
519
+ return { allPackages, desynced, updated: false, target: action };
520
+ }
521
+
522
+ // Confirm the installs physically landed in node_modules
523
+ const expectedUpgrades = Object.fromEntries(packageNames.map(name => [name, version.clean(upgrades[name])]));
524
+ if (reportMismatches(npm.verifyInstalled(expectedUpgrades, projectPath))) {
525
+ return { allPackages, desynced, updated: false, target: action };
380
526
  }
381
527
 
382
528
  // Run full audit after install
@@ -386,9 +532,49 @@ module.exports = async function (options) {
386
532
  logger.error(`Audit warning: ${e.message}`);
387
533
  }
388
534
 
389
- return { allPackages, updated: true, target: action };
535
+ return { allPackages, desynced, updated: true, target: action };
390
536
  };
391
537
 
538
+ // Helper to explain a Socket risk-block after stale copies were already removed.
539
+ // Deliberately does NOT reinstall the removed copies — that would silently
540
+ // bypass the block Socket just raised; removed is safer than stale-and-risky.
541
+ function reportSocketBlock(e, retryCommand) {
542
+ const flaggedPackages = e.flaggedPackages || [];
543
+
544
+ if (flaggedPackages.length > 0) {
545
+ logger.log('');
546
+ logger.error('Socket flagged the following dependencies:');
547
+ flaggedPackages.forEach(pkg => logger.error(` • ${pkg}`));
548
+
549
+ const flaggedNames = flaggedPackages.map(pkg => pkg.replace(/@[^@]+$/, ''));
550
+ logger.log('');
551
+ logger.log('If these are fixable CVEs in transitive deps pinned by package-lock.json, re-resolve them with:');
552
+ logger.log(logger.format.cyan(` socket npm update ${flaggedNames.join(' ')}`));
553
+ }
554
+
555
+ logger.log('');
556
+ logger.log('To retry with Socket protection bypassed:');
557
+ logger.log(logger.format.cyan(` ${retryCommand}`));
558
+ logger.log('');
559
+ logger.log('The risky copies stay removed from node_modules (safer than leaving stale versions) and will show as missing until reinstalled.');
560
+ }
561
+
562
+ // Helper to report packages that npm claimed to install but didn't physically land.
563
+ // Returns true if there were mismatches (callers should bail).
564
+ function reportMismatches(mismatches) {
565
+ if (mismatches.length === 0) {
566
+ return false;
567
+ }
568
+
569
+ logger.error('\nnpm reported success but these packages did not actually update in node_modules:');
570
+ for (const m of mismatches) {
571
+ logger.error(` • ${m.name} — expected ${m.expected}, found ${m.actual}`);
572
+ }
573
+ logger.log(`Try a clean install: ${logger.format.cyan('rm -rf node_modules && npm install')}`);
574
+
575
+ return true;
576
+ }
577
+
392
578
  // Helper to determine dependency type
393
579
  function getDependencyType(packageJson, dep) {
394
580
  if (packageJson.devDependencies?.[dep]) {
@@ -0,0 +1,102 @@
1
+ // Libraries
2
+ const jetpack = require('fs-jetpack');
3
+ const path = require('path');
4
+ const version = require('wonderful-version');
5
+
6
+ // Remove installed node_modules copies for the given package names so npm
7
+ // actually re-fetches them. npm trusts the hidden lockfile
8
+ // (node_modules/.package-lock.json) over the physical files — if the lockfile
9
+ // already records the requested version, `npm install pkg@version` reports
10
+ // "up to date" without installing anything, even when the physical copy is
11
+ // stale. Deleting the copy forces npm to reify it from the registry/cache.
12
+ function removeInstalledCopies(names, cwd) {
13
+ const base = cwd || process.cwd();
14
+
15
+ for (const name of names) {
16
+ const pkgDir = path.join(base, 'node_modules', name);
17
+
18
+ if (jetpack.exists(pkgDir)) {
19
+ jetpack.remove(pkgDir);
20
+ }
21
+ }
22
+ }
23
+
24
+ // Remove node_modules entries by their lockfile location (e.g.
25
+ // "node_modules/mocha/node_modules/brace-expansion") — unlike
26
+ // removeInstalledCopies, this handles nested copies.
27
+ function removeLocations(locations, cwd) {
28
+ const base = cwd || process.cwd();
29
+
30
+ for (const location of locations) {
31
+ jetpack.remove(path.join(base, location));
32
+ }
33
+ }
34
+
35
+ // Find packages where the physical node_modules copy doesn't match what the
36
+ // hidden lockfile (node_modules/.package-lock.json) claims is installed.
37
+ // This desync is what makes npm silently no-op installs: npm trusts the hidden
38
+ // lockfile over the physical files, so a stale or partially-extracted copy is
39
+ // never repaired by `npm install`. Created by interrupted or Socket-blocked
40
+ // installs that advance the lockfiles without extracting files.
41
+ // Returns [{ loc, lockfileVersion, diskVersion }] — diskVersion null = missing.
42
+ function findDesynced(cwd) {
43
+ const base = cwd || process.cwd();
44
+ const hidden = jetpack.read(path.join(base, 'node_modules', '.package-lock.json'), 'json');
45
+
46
+ if (!hidden) {
47
+ return [];
48
+ }
49
+
50
+ const desynced = [];
51
+
52
+ for (const [loc, info] of Object.entries(hidden.packages || {})) {
53
+ if (!loc.startsWith('node_modules/') || !info.version || info.link) {
54
+ continue;
55
+ }
56
+
57
+ const pkgJson = jetpack.read(path.join(base, loc, 'package.json'), 'json');
58
+ const diskVersion = pkgJson?.version || null;
59
+
60
+ if (!diskVersion) {
61
+ // Missing optional packages are normal (platform-specific binaries
62
+ // like @esbuild/* and fsevents are skipped on non-matching systems)
63
+ if (info.optional) {
64
+ continue;
65
+ }
66
+
67
+ desynced.push({ loc, lockfileVersion: info.version, diskVersion: null });
68
+ } else if (diskVersion !== info.version) {
69
+ desynced.push({ loc, lockfileVersion: info.version, diskVersion });
70
+ }
71
+ }
72
+
73
+ return desynced;
74
+ }
75
+
76
+ // Verify that the physical node_modules copies match the expected versions.
77
+ // expected is a map of { name: version }. Returns an array of mismatches:
78
+ // [{ name, expected, actual }] — empty when everything landed correctly.
79
+ function verifyInstalled(expected, cwd) {
80
+ const base = cwd || process.cwd();
81
+ const mismatches = [];
82
+
83
+ for (const [name, expectedVersion] of Object.entries(expected)) {
84
+ const cleanExpected = version.clean(expectedVersion);
85
+ const pkgJson = jetpack.read(path.join(base, 'node_modules', name, 'package.json'), 'json');
86
+ const actual = pkgJson?.version ? version.clean(pkgJson.version) : null;
87
+
88
+ if (!actual || !version.is(actual, '==', cleanExpected)) {
89
+ mismatches.push({ name, expected: cleanExpected, actual: actual || 'missing' });
90
+ }
91
+ }
92
+
93
+ return mismatches;
94
+ }
95
+
96
+ // Export
97
+ module.exports = {
98
+ removeInstalledCopies,
99
+ removeLocations,
100
+ findDesynced,
101
+ verifyInstalled,
102
+ };
package/package.json CHANGED
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "name": "node-power-user",
3
- "version": "2.1.4",
3
+ "version": "2.1.6",
4
4
  "description": "Easy tools for every Node.js developer!",
5
5
  "main": "dist/index.js",
6
6
  "scripts": {
7
- "test": "npm run prepare && ./node_modules/mocha/bin/mocha test/ --recursive --timeout=10000",
7
+ "test": "npm run prepare && mocha test/ --recursive --timeout=10000",
8
8
  "start": "npm run prepare && ./bin/node-power-user",
9
9
  "help": "echo 'npm start -- -v'",
10
10
  "prepare": "node -e \"require('prepare-package')()\"",