node-power-user 2.1.4 → 2.1.5

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(',')
@@ -102,45 +109,58 @@ module.exports = async function (options) {
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:
@@ -172,12 +192,16 @@ module.exports = async function (options) {
172
192
 
173
193
  // If noPrompt, return data without showing menu (used by tests)
174
194
  if (options.noPrompt) {
175
- return { allPackages };
195
+ return { allPackages, desynced };
176
196
  }
177
197
 
178
198
  // Check for shortcut flags (skip menu)
179
199
  let action = null;
180
- if (options.r || options.reconcile) {
200
+ if (options.heal) {
201
+ action = 'heal';
202
+ } else if (options.sync) {
203
+ action = 'sync';
204
+ } else if (options.r || options.reconcile) {
181
205
  action = 'reconcile';
182
206
  } else if (options.P || options.patch) {
183
207
  action = 'patch';
@@ -191,6 +215,14 @@ module.exports = async function (options) {
191
215
  if (!action) {
192
216
  const choices = [];
193
217
 
218
+ // "Heal" reinstalls copies whose disk state doesn't match the lockfile
219
+ if (desynced.length > 0) {
220
+ choices.push({
221
+ name: `Heal (${desynced.length}) - reinstall copies that don't match package-lock.json`,
222
+ value: 'heal',
223
+ });
224
+ }
225
+
194
226
  // "Sync" installs packages where node_modules is behind package.json
195
227
  const syncCount = behindPackages.length + unknownPackages.length;
196
228
  if (syncCount > 0) {
@@ -238,24 +270,101 @@ module.exports = async function (options) {
238
270
  }
239
271
 
240
272
  if (action === 'exit') {
241
- return { allPackages };
273
+ return { allPackages, desynced };
274
+ }
275
+
276
+ // Handle heal — reinstall node_modules copies that don't match the hidden
277
+ // lockfile. Desynced copies make npm silently no-op installs, which traps
278
+ // projects in reconcile/update loops where nothing ever actually changes.
279
+ if (action === 'heal') {
280
+ if (desynced.length === 0) {
281
+ logger.log(logger.format.green('\nNothing to heal — node_modules matches package-lock.json.'));
282
+ return { allPackages, desynced, healed: false };
283
+ }
284
+
285
+ const lockfileBackup = jetpack.read(lockfilePath);
286
+
287
+ logger.log(logger.format.cyan(`\nRemoving ${desynced.length} desynced copies and reinstalling...`));
288
+ npm.removeLocations(desynced.map(d => d.loc), projectPath);
289
+
290
+ try {
291
+ await socket.wrap('npm install', { force: options.force });
292
+ } catch (e) {
293
+ // Restore the lockfile so the failed run can't advance it further
294
+ if (lockfileBackup) {
295
+ jetpack.write(lockfilePath, lockfileBackup);
296
+ }
297
+
298
+ if (e.reason === 'npm-failed') {
299
+ logger.log('');
300
+ logger.log('Fix the npm error above and re-run with --heal — the removed copies will reinstall then.');
301
+ return { allPackages, desynced, healed: false };
302
+ }
303
+
304
+ reportSocketBlock(e, 'npu out --heal --force');
305
+ return { allPackages, desynced, healed: false };
306
+ }
307
+
308
+ // Confirm disk and lockfile are actually back in sync
309
+ const remaining = npm.findDesynced(projectPath);
310
+ if (remaining.length > 0) {
311
+ logger.error(`\n${remaining.length} package(s) are still desynced after reinstalling:`);
312
+ remaining.slice(0, 10).forEach(d => logger.error(` • ${d.loc.replace(/^node_modules\//, '')} — lockfile has ${d.lockfileVersion}, disk has ${d.diskVersion || 'nothing'}`));
313
+ logger.log(`Try a clean install: ${logger.format.cyan('rm -rf node_modules && npm install')}`);
314
+ return { allPackages, desynced, healed: false };
315
+ }
316
+
317
+ try {
318
+ await socket.audit({ force: options.force });
319
+ } catch (e) {
320
+ logger.error(`Audit warning: ${e.message}`);
321
+ }
322
+
323
+ logger.log(logger.format.green(`\nHealed ${desynced.length} package(s) — node_modules now matches package-lock.json.`));
324
+ return { allPackages, desynced, healed: true };
242
325
  }
243
326
 
244
327
  // Handle sync — run npm install for packages where node_modules is behind package.json
245
328
  if (action === 'sync') {
246
329
  const toSync = [...behindPackages, ...unknownPackages];
330
+
331
+ if (toSync.length === 0) {
332
+ logger.log(logger.format.green('\nNothing to sync — node_modules already matches package.json.'));
333
+ return { allPackages, desynced, synced: false };
334
+ }
335
+
247
336
  const installCmd = `npm install ${toSync.map(pkg => `${pkg.name}@${pkg.packageVersion}`).join(' ')}`;
248
337
  logger.log(logger.format.cyan(`\nRunning ${installCmd}...`));
249
338
 
339
+ const lockfileBackup = jetpack.read(lockfilePath);
340
+
341
+ // Remove stale node_modules copies first so npm actually re-fetches them
342
+ // instead of trusting the hidden lockfile and reporting "up to date"
343
+ npm.removeInstalledCopies(toSync.map(pkg => pkg.name), projectPath);
344
+
250
345
  try {
251
346
  await socket.wrap(installCmd, { force: options.force });
252
347
  } catch (e) {
348
+ // Restore the lockfile so a blocked/failed install can't leave it
349
+ // advanced past the physical files (that desync is what mints loops)
350
+ if (lockfileBackup) {
351
+ jetpack.write(lockfilePath, lockfileBackup);
352
+ }
353
+
253
354
  if (e.reason === 'npm-failed') {
254
355
  logger.log('');
255
356
  logger.log('Fix the npm error above and retry.');
256
- return { allPackages, updated: false };
357
+ return { allPackages, desynced, synced: false };
257
358
  }
258
- throw e;
359
+
360
+ reportSocketBlock(e, 'npu out --sync --force');
361
+ return { allPackages, desynced, synced: false };
362
+ }
363
+
364
+ // Confirm the installs physically landed in node_modules
365
+ const expected = Object.fromEntries(toSync.map(pkg => [pkg.name, pkg.packageVersion]));
366
+ if (reportMismatches(npm.verifyInstalled(expected, projectPath))) {
367
+ return { allPackages, desynced, synced: false };
259
368
  }
260
369
 
261
370
  try {
@@ -265,15 +374,27 @@ module.exports = async function (options) {
265
374
  }
266
375
 
267
376
  logger.log(logger.format.green(`\nSynced ${toSync.length} package(s) to match package.json.`));
268
- return { allPackages, synced: true };
377
+ return { allPackages, desynced, synced: true };
269
378
  }
270
379
 
271
- // Handle reconcile — update package.json for packages where node_modules is ahead
380
+ // Handle reconcile — update package.json for packages where node_modules is ahead.
381
+ // Strictly ahead-only: packages where node_modules is BEHIND are a sync problem,
382
+ // and downgrading package.json to match them would mask a stale install.
272
383
  if (action === 'reconcile') {
384
+ if (aheadPackages.length === 0) {
385
+ logger.log('\nNothing to reconcile — no installed packages are ahead of package.json.');
386
+
387
+ const syncCount = behindPackages.length + unknownPackages.length;
388
+ if (syncCount > 0) {
389
+ logger.log(`${syncCount} package(s) are behind package.json — run ${logger.format.cyan('npu out --sync')} to install them.`);
390
+ }
391
+
392
+ return { allPackages, desynced, reconciled: false };
393
+ }
394
+
273
395
  projectJson = jetpack.read(packageJsonPath, 'json');
274
396
 
275
- const toReconcile = aheadPackages.length > 0 ? aheadPackages : discrepancies;
276
- for (const pkg of toReconcile) {
397
+ for (const pkg of aheadPackages) {
277
398
  const depType = pkg.type === 'dev' ? 'devDependencies'
278
399
  : pkg.type === 'peer' ? 'peerDependencies'
279
400
  : 'dependencies';
@@ -282,18 +403,28 @@ module.exports = async function (options) {
282
403
  }
283
404
 
284
405
  jetpack.write(packageJsonPath, projectJson);
285
- logger.log(logger.format.green(`\nReconciled ${toReconcile.length} package(s) in package.json.`));
406
+ logger.log(logger.format.green(`\nReconciled ${aheadPackages.length} package(s) in package.json.`));
286
407
 
287
- return { allPackages, reconciled: true };
408
+ return { allPackages, desynced, reconciled: true };
288
409
  }
289
410
 
290
411
  // Handle patch/minor/major updates
291
412
  const upgrades = action === 'patch' ? patchUpgrades
292
413
  : action === 'minor' ? minorUpgrades
293
414
  : latestUpgrades;
415
+ const packageNames = Object.keys(upgrades);
416
+
417
+ if (packageNames.length === 0) {
418
+ logger.log(logger.format.green(`\nNothing to update — package.json is already at the requested versions.`));
419
+ return { allPackages, desynced, updated: false, target: action };
420
+ }
294
421
 
295
- // Back up package.json before modifying
422
+ // Back up package.json and package-lock.json before modifying, so a failed
423
+ // install can restore BOTH — restoring only package.json leaves the lockfile
424
+ // advanced past the physical files, which is exactly the desync that mints
425
+ // silent no-op installs
296
426
  const packageJsonBackup = jetpack.read(packageJsonPath);
427
+ const lockfileBackup = jetpack.read(lockfilePath);
297
428
 
298
429
  await ncu.run({
299
430
  packageFile: packageJsonPath,
@@ -301,27 +432,34 @@ module.exports = async function (options) {
301
432
  upgrade: true,
302
433
  });
303
434
 
304
- logger.log(logger.format.green(`\nUpdated ${Object.keys(upgrades).length} package(s) in package.json.`));
435
+ logger.log(logger.format.green(`\nUpdated ${packageNames.length} package(s) in package.json.`));
305
436
 
306
437
  // Install the specific upgraded packages so npm actually pulls them in
307
438
  // (plain `npm install` won't upgrade packages that already satisfy the range)
308
- const packageNames = Object.keys(upgrades);
309
439
  const installCmd = `npm install ${packageNames.map(name => `${name}@${version.clean(upgrades[name])}`).join(' ')}`;
310
440
  logger.log(logger.format.cyan(`\nRunning ${installCmd}...`));
311
441
 
442
+ // Remove stale node_modules copies first so npm actually re-fetches them
443
+ // instead of trusting the hidden lockfile and reporting "up to date"
444
+ npm.removeInstalledCopies(packageNames, projectPath);
445
+
312
446
  try {
313
447
  await socket.wrap(installCmd, { force: options.force });
314
448
  } catch (e) {
315
- // Restore package.json since the bulk install failed
449
+ // Restore both manifests since the bulk install failed
316
450
  jetpack.write(packageJsonPath, packageJsonBackup);
317
- logger.log('package.json has been restored to its original state.');
451
+ if (lockfileBackup) {
452
+ jetpack.write(lockfilePath, lockfileBackup);
453
+ }
454
+ logger.log('package.json and package-lock.json have been restored to their original state.');
455
+ logger.log('The removed package copies will show as missing in the next check until reinstalled.');
318
456
 
319
457
  // npm itself failed (ERESOLVE, network, peer-dep conflict) — not a Socket block.
320
458
  // The npm error was already printed above; just acknowledge and stop.
321
459
  if (e.reason === 'npm-failed') {
322
460
  logger.log('');
323
461
  logger.log('Fix the npm error above (e.g. resolve peer-dep conflicts) and retry.');
324
- return { allPackages, updated: false, target: action };
462
+ return { allPackages, desynced, updated: false, target: action };
325
463
  }
326
464
 
327
465
  const flaggedPackages = e.flaggedPackages || [];
@@ -376,7 +514,13 @@ module.exports = async function (options) {
376
514
  logger.log('To bypass Socket for this install only:');
377
515
  logger.log(logger.format.cyan(` SOCKET_CLI_ACCEPT_RISKS=1 npm install ${packageNames.map(name => `${name}@${version.clean(upgrades[name])}`).join(' ')}`));
378
516
 
379
- return { allPackages, updated: false, target: action };
517
+ return { allPackages, desynced, updated: false, target: action };
518
+ }
519
+
520
+ // Confirm the installs physically landed in node_modules
521
+ const expectedUpgrades = Object.fromEntries(packageNames.map(name => [name, version.clean(upgrades[name])]));
522
+ if (reportMismatches(npm.verifyInstalled(expectedUpgrades, projectPath))) {
523
+ return { allPackages, desynced, updated: false, target: action };
380
524
  }
381
525
 
382
526
  // Run full audit after install
@@ -386,9 +530,49 @@ module.exports = async function (options) {
386
530
  logger.error(`Audit warning: ${e.message}`);
387
531
  }
388
532
 
389
- return { allPackages, updated: true, target: action };
533
+ return { allPackages, desynced, updated: true, target: action };
390
534
  };
391
535
 
536
+ // Helper to explain a Socket risk-block after stale copies were already removed.
537
+ // Deliberately does NOT reinstall the removed copies — that would silently
538
+ // bypass the block Socket just raised; removed is safer than stale-and-risky.
539
+ function reportSocketBlock(e, retryCommand) {
540
+ const flaggedPackages = e.flaggedPackages || [];
541
+
542
+ if (flaggedPackages.length > 0) {
543
+ logger.log('');
544
+ logger.error('Socket flagged the following dependencies:');
545
+ flaggedPackages.forEach(pkg => logger.error(` • ${pkg}`));
546
+
547
+ const flaggedNames = flaggedPackages.map(pkg => pkg.replace(/@[^@]+$/, ''));
548
+ logger.log('');
549
+ logger.log('If these are fixable CVEs in transitive deps pinned by package-lock.json, re-resolve them with:');
550
+ logger.log(logger.format.cyan(` socket npm update ${flaggedNames.join(' ')}`));
551
+ }
552
+
553
+ logger.log('');
554
+ logger.log('To retry with Socket protection bypassed:');
555
+ logger.log(logger.format.cyan(` ${retryCommand}`));
556
+ logger.log('');
557
+ logger.log('The risky copies stay removed from node_modules (safer than leaving stale versions) and will show as missing until reinstalled.');
558
+ }
559
+
560
+ // Helper to report packages that npm claimed to install but didn't physically land.
561
+ // Returns true if there were mismatches (callers should bail).
562
+ function reportMismatches(mismatches) {
563
+ if (mismatches.length === 0) {
564
+ return false;
565
+ }
566
+
567
+ logger.error('\nnpm reported success but these packages did not actually update in node_modules:');
568
+ for (const m of mismatches) {
569
+ logger.error(` • ${m.name} — expected ${m.expected}, found ${m.actual}`);
570
+ }
571
+ logger.log(`Try a clean install: ${logger.format.cyan('rm -rf node_modules && npm install')}`);
572
+
573
+ return true;
574
+ }
575
+
392
576
  // Helper to determine dependency type
393
577
  function getDependencyType(packageJson, dep) {
394
578
  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.5",
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')()\"",