node-power-user 2.1.3 → 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 +15 -1
- package/bin/node-power-user +15 -1
- package/dist/commands/install.js +21 -0
- package/dist/commands/outdated.js +281 -49
- package/dist/lib/npm.js +102 -0
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -103,9 +103,22 @@ 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 --
|
|
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
|
|
|
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.
|
|
117
|
+
- **Sync** — when `node_modules` is *behind* `package.json`, installs packages to match what `package.json` declares.
|
|
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.
|
|
121
|
+
|
|
109
122
|
### List Packages
|
|
110
123
|
List all packages in your project.
|
|
111
124
|
```shell
|
|
@@ -137,6 +150,7 @@ npu wait <ms>
|
|
|
137
150
|
```
|
|
138
151
|
|
|
139
152
|
### Global flags
|
|
153
|
+
* `-C <dir>`, `--cwd <dir>`: Run as if invoked from `<dir>` (e.g. `npu -C /path/to/project out`)
|
|
140
154
|
* `--debug`: Log the commands and flags before they are executed
|
|
141
155
|
|
|
142
156
|
## 🛠️ Development
|
package/bin/node-power-user
CHANGED
|
@@ -1,8 +1,22 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
const { hideBin } = require('yargs/helpers');
|
|
3
3
|
const argv = require('yargs')(hideBin(process.argv)).parseSync();
|
|
4
|
+
|
|
5
|
+
// --cwd / -C: run as if invoked from a different directory
|
|
6
|
+
const targetDir = argv.cwd || argv.C;
|
|
7
|
+
if (targetDir) {
|
|
8
|
+
process.chdir(targetDir);
|
|
9
|
+
}
|
|
10
|
+
|
|
4
11
|
const cli = new (require('../dist/cli.js'))();
|
|
5
12
|
(async function() {
|
|
6
13
|
'use strict';
|
|
7
|
-
|
|
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
|
+
}
|
|
8
22
|
}());
|
package/dist/commands/install.js
CHANGED
|
@@ -1,6 +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 npm = require('../lib/npm');
|
|
4
5
|
|
|
5
6
|
// Module
|
|
6
7
|
module.exports = async function (options) {
|
|
@@ -22,6 +23,26 @@ module.exports = async function (options) {
|
|
|
22
23
|
flags.push('--global');
|
|
23
24
|
}
|
|
24
25
|
|
|
26
|
+
// When installing specific packages with a version/tag (e.g. @latest, @^2.0.0),
|
|
27
|
+
// remove existing node_modules copies first so npm actually re-fetches them
|
|
28
|
+
// instead of reporting "up to date" with a stale cached version.
|
|
29
|
+
if (packages.length > 0 && !flags.includes('--global')) {
|
|
30
|
+
const names = [];
|
|
31
|
+
|
|
32
|
+
for (const pkg of packages) {
|
|
33
|
+
// For scoped packages like @scope/name@version, skip the leading @
|
|
34
|
+
const searchFrom = pkg.startsWith('@') ? 1 : 0;
|
|
35
|
+
const versionIdx = pkg.indexOf('@', searchFrom);
|
|
36
|
+
if (versionIdx <= 0) {
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
names.push(pkg.substring(0, versionIdx));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
npm.removeInstalledCopies(names);
|
|
44
|
+
}
|
|
45
|
+
|
|
25
46
|
const command = packages.length > 0
|
|
26
47
|
? `npm install ${packages.join(' ')} ${flags.join(' ')}`.trim()
|
|
27
48
|
: `npm install ${flags.join(' ')}`.trim();
|
|
@@ -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,49 +109,73 @@ 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
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
const
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
if (pkg.
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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.
|
|
134
|
+
latestDisplay = chalk.dim('-');
|
|
124
135
|
}
|
|
125
|
-
|
|
126
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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:
|
|
167
|
+
// - "behind": node_modules has an OLDER version than package.json wants (needs npm install)
|
|
168
|
+
// - "ahead": node_modules has a NEWER version than package.json specifies (reconcile package.json)
|
|
147
169
|
const discrepancies = [...allPackages.values()].filter(pkg => pkg.hasDiscrepancy);
|
|
170
|
+
const behindPackages = discrepancies.filter(pkg =>
|
|
171
|
+
pkg.installedVersion !== '?' && version.is(pkg.installedVersion, '<', pkg.packageVersion)
|
|
172
|
+
);
|
|
173
|
+
const aheadPackages = discrepancies.filter(pkg =>
|
|
174
|
+
pkg.installedVersion !== '?' && version.is(pkg.installedVersion, '>', pkg.packageVersion)
|
|
175
|
+
);
|
|
176
|
+
const unknownPackages = discrepancies.filter(pkg => pkg.installedVersion === '?');
|
|
177
|
+
|
|
178
|
+
// Get counts for menu (only show a tier if it offers upgrades beyond the tier below)
|
|
148
179
|
const patchCount = Object.keys(patchUpgrades).length;
|
|
149
180
|
const minorCount = Object.keys(minorUpgrades).length;
|
|
150
181
|
const majorCount = Object.keys(latestUpgrades).length;
|
|
@@ -161,12 +192,16 @@ module.exports = async function (options) {
|
|
|
161
192
|
|
|
162
193
|
// If noPrompt, return data without showing menu (used by tests)
|
|
163
194
|
if (options.noPrompt) {
|
|
164
|
-
return { allPackages };
|
|
195
|
+
return { allPackages, desynced };
|
|
165
196
|
}
|
|
166
197
|
|
|
167
198
|
// Check for shortcut flags (skip menu)
|
|
168
199
|
let action = null;
|
|
169
|
-
if (options.
|
|
200
|
+
if (options.heal) {
|
|
201
|
+
action = 'heal';
|
|
202
|
+
} else if (options.sync) {
|
|
203
|
+
action = 'sync';
|
|
204
|
+
} else if (options.r || options.reconcile) {
|
|
170
205
|
action = 'reconcile';
|
|
171
206
|
} else if (options.P || options.patch) {
|
|
172
207
|
action = 'patch';
|
|
@@ -180,9 +215,27 @@ module.exports = async function (options) {
|
|
|
180
215
|
if (!action) {
|
|
181
216
|
const choices = [];
|
|
182
217
|
|
|
183
|
-
|
|
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
|
+
|
|
226
|
+
// "Sync" installs packages where node_modules is behind package.json
|
|
227
|
+
const syncCount = behindPackages.length + unknownPackages.length;
|
|
228
|
+
if (syncCount > 0) {
|
|
229
|
+
choices.push({
|
|
230
|
+
name: `Sync (${syncCount}) - install packages to match package.json`,
|
|
231
|
+
value: 'sync',
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// "Reconcile" updates package.json where node_modules is ahead
|
|
236
|
+
if (aheadPackages.length > 0) {
|
|
184
237
|
choices.push({
|
|
185
|
-
name: `Reconcile (${
|
|
238
|
+
name: `Reconcile (${aheadPackages.length}) - sync package.json to installed versions`,
|
|
186
239
|
value: 'reconcile',
|
|
187
240
|
});
|
|
188
241
|
}
|
|
@@ -217,15 +270,131 @@ module.exports = async function (options) {
|
|
|
217
270
|
}
|
|
218
271
|
|
|
219
272
|
if (action === 'exit') {
|
|
220
|
-
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 };
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// Handle sync — run npm install for packages where node_modules is behind package.json
|
|
328
|
+
if (action === 'sync') {
|
|
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
|
+
|
|
336
|
+
const installCmd = `npm install ${toSync.map(pkg => `${pkg.name}@${pkg.packageVersion}`).join(' ')}`;
|
|
337
|
+
logger.log(logger.format.cyan(`\nRunning ${installCmd}...`));
|
|
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
|
+
|
|
345
|
+
try {
|
|
346
|
+
await socket.wrap(installCmd, { force: options.force });
|
|
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
|
+
|
|
354
|
+
if (e.reason === 'npm-failed') {
|
|
355
|
+
logger.log('');
|
|
356
|
+
logger.log('Fix the npm error above and retry.');
|
|
357
|
+
return { allPackages, desynced, synced: false };
|
|
358
|
+
}
|
|
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 };
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
try {
|
|
371
|
+
await socket.audit({ force: options.force });
|
|
372
|
+
} catch (e) {
|
|
373
|
+
logger.error(`Audit warning: ${e.message}`);
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
logger.log(logger.format.green(`\nSynced ${toSync.length} package(s) to match package.json.`));
|
|
377
|
+
return { allPackages, desynced, synced: true };
|
|
221
378
|
}
|
|
222
379
|
|
|
223
|
-
// Handle reconcile
|
|
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.
|
|
224
383
|
if (action === 'reconcile') {
|
|
225
|
-
|
|
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
|
+
|
|
226
395
|
projectJson = jetpack.read(packageJsonPath, 'json');
|
|
227
396
|
|
|
228
|
-
for (const pkg of
|
|
397
|
+
for (const pkg of aheadPackages) {
|
|
229
398
|
const depType = pkg.type === 'dev' ? 'devDependencies'
|
|
230
399
|
: pkg.type === 'peer' ? 'peerDependencies'
|
|
231
400
|
: 'dependencies';
|
|
@@ -234,18 +403,28 @@ module.exports = async function (options) {
|
|
|
234
403
|
}
|
|
235
404
|
|
|
236
405
|
jetpack.write(packageJsonPath, projectJson);
|
|
237
|
-
logger.log(logger.format.green(`\nReconciled ${
|
|
406
|
+
logger.log(logger.format.green(`\nReconciled ${aheadPackages.length} package(s) in package.json.`));
|
|
238
407
|
|
|
239
|
-
return { allPackages, reconciled: true };
|
|
408
|
+
return { allPackages, desynced, reconciled: true };
|
|
240
409
|
}
|
|
241
410
|
|
|
242
411
|
// Handle patch/minor/major updates
|
|
243
412
|
const upgrades = action === 'patch' ? patchUpgrades
|
|
244
413
|
: action === 'minor' ? minorUpgrades
|
|
245
414
|
: latestUpgrades;
|
|
415
|
+
const packageNames = Object.keys(upgrades);
|
|
246
416
|
|
|
247
|
-
|
|
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
|
+
}
|
|
421
|
+
|
|
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
|
|
248
426
|
const packageJsonBackup = jetpack.read(packageJsonPath);
|
|
427
|
+
const lockfileBackup = jetpack.read(lockfilePath);
|
|
249
428
|
|
|
250
429
|
await ncu.run({
|
|
251
430
|
packageFile: packageJsonPath,
|
|
@@ -253,27 +432,34 @@ module.exports = async function (options) {
|
|
|
253
432
|
upgrade: true,
|
|
254
433
|
});
|
|
255
434
|
|
|
256
|
-
logger.log(logger.format.green(`\nUpdated ${
|
|
435
|
+
logger.log(logger.format.green(`\nUpdated ${packageNames.length} package(s) in package.json.`));
|
|
257
436
|
|
|
258
437
|
// Install the specific upgraded packages so npm actually pulls them in
|
|
259
438
|
// (plain `npm install` won't upgrade packages that already satisfy the range)
|
|
260
|
-
const packageNames = Object.keys(upgrades);
|
|
261
439
|
const installCmd = `npm install ${packageNames.map(name => `${name}@${version.clean(upgrades[name])}`).join(' ')}`;
|
|
262
440
|
logger.log(logger.format.cyan(`\nRunning ${installCmd}...`));
|
|
263
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
|
+
|
|
264
446
|
try {
|
|
265
447
|
await socket.wrap(installCmd, { force: options.force });
|
|
266
448
|
} catch (e) {
|
|
267
|
-
// Restore
|
|
449
|
+
// Restore both manifests since the bulk install failed
|
|
268
450
|
jetpack.write(packageJsonPath, packageJsonBackup);
|
|
269
|
-
|
|
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.');
|
|
270
456
|
|
|
271
457
|
// npm itself failed (ERESOLVE, network, peer-dep conflict) — not a Socket block.
|
|
272
458
|
// The npm error was already printed above; just acknowledge and stop.
|
|
273
459
|
if (e.reason === 'npm-failed') {
|
|
274
460
|
logger.log('');
|
|
275
461
|
logger.log('Fix the npm error above (e.g. resolve peer-dep conflicts) and retry.');
|
|
276
|
-
return { allPackages, updated: false, target: action };
|
|
462
|
+
return { allPackages, desynced, updated: false, target: action };
|
|
277
463
|
}
|
|
278
464
|
|
|
279
465
|
const flaggedPackages = e.flaggedPackages || [];
|
|
@@ -328,7 +514,13 @@ module.exports = async function (options) {
|
|
|
328
514
|
logger.log('To bypass Socket for this install only:');
|
|
329
515
|
logger.log(logger.format.cyan(` SOCKET_CLI_ACCEPT_RISKS=1 npm install ${packageNames.map(name => `${name}@${version.clean(upgrades[name])}`).join(' ')}`));
|
|
330
516
|
|
|
331
|
-
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 };
|
|
332
524
|
}
|
|
333
525
|
|
|
334
526
|
// Run full audit after install
|
|
@@ -338,9 +530,49 @@ module.exports = async function (options) {
|
|
|
338
530
|
logger.error(`Audit warning: ${e.message}`);
|
|
339
531
|
}
|
|
340
532
|
|
|
341
|
-
return { allPackages, updated: true, target: action };
|
|
533
|
+
return { allPackages, desynced, updated: true, target: action };
|
|
342
534
|
};
|
|
343
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
|
+
|
|
344
576
|
// Helper to determine dependency type
|
|
345
577
|
function getDependencyType(packageJson, dep) {
|
|
346
578
|
if (packageJson.devDependencies?.[dep]) {
|
package/dist/lib/npm.js
ADDED
|
@@ -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.
|
|
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 &&
|
|
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')()\"",
|