Skip to content

Commit 400c80f

Browse files
authored
fix(ci): remove node_modules post-validation (#4913)
The removal of node_modules was happening in a race with the loading of the virtualTree, and before the validation of the package-lock against the package.json. This defers the removal till after all that validation has happened. It also makes the errors thrown usage errors, and refactors the tests to be real.
1 parent 8898710 commit 400c80f

File tree

8 files changed

+219
-336
lines changed

8 files changed

+219
-336
lines changed

docs/content/commands/npm-ci.md

+2-7
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
---
22
title: npm-ci
33
section: 1
4-
description: Install a project with a clean slate
4+
description: Clean install a project
55
---
66

77
### Synopsis
@@ -28,12 +28,7 @@ it's meant to be used in automated environments such as test platforms,
2828
continuous integration, and deployment -- or any situation where you want
2929
to make sure you're doing a clean install of your dependencies.
3030

31-
`npm ci` will be significantly faster when:
32-
33-
- There is a `package-lock.json` or `npm-shrinkwrap.json` file.
34-
- The `node_modules` folder is missing or empty.
35-
36-
In short, the main differences between using `npm install` and `npm ci` are:
31+
The main differences between using `npm install` and `npm ci` are:
3732

3833
* The project **must** have an existing `package-lock.json` or
3934
`npm-shrinkwrap.json`.

lib/commands/ci.js

+23-26
Original file line numberDiff line numberDiff line change
@@ -8,19 +8,10 @@ const readdir = util.promisify(fs.readdir)
88
const log = require('../utils/log-shim.js')
99
const validateLockfile = require('../utils/validate-lockfile.js')
1010

11-
const removeNodeModules = async where => {
12-
const rimrafOpts = { glob: false }
13-
process.emit('time', 'npm-ci:rm')
14-
const path = `${where}/node_modules`
15-
// get the list of entries so we can skip the glob for performance
16-
const entries = await readdir(path, null).catch(er => [])
17-
await Promise.all(entries.map(f => rimraf(`${path}/${f}`, rimrafOpts)))
18-
process.emit('timeEnd', 'npm-ci:rm')
19-
}
2011
const ArboristWorkspaceCmd = require('../arborist-cmd.js')
2112

2213
class CI extends ArboristWorkspaceCmd {
23-
static description = 'Install a project with a clean slate'
14+
static description = 'Clean install a project'
2415
static name = 'ci'
2516
static params = [
2617
'audit',
@@ -31,9 +22,9 @@ class CI extends ArboristWorkspaceCmd {
3122

3223
async exec () {
3324
if (this.npm.config.get('global')) {
34-
const err = new Error('`npm ci` does not work for global packages')
35-
err.code = 'ECIGLOBAL'
36-
throw err
25+
throw Object.assign(new Error('`npm ci` does not work for global packages'), {
26+
code: 'ECIGLOBAL',
27+
})
3728
}
3829

3930
const where = this.npm.prefix
@@ -46,17 +37,14 @@ class CI extends ArboristWorkspaceCmd {
4637
}
4738

4839
const arb = new Arborist(opts)
49-
await Promise.all([
50-
arb.loadVirtual().catch(er => {
51-
log.verbose('loadVirtual', er.stack)
52-
const msg =
53-
'The `npm ci` command can only install with an existing package-lock.json or\n' +
54-
'npm-shrinkwrap.json with lockfileVersion >= 1. Run an install with npm@5 or\n' +
55-
'later to generate a package-lock.json file, then try again.'
56-
throw new Error(msg)
57-
}),
58-
removeNodeModules(where),
59-
])
40+
await arb.loadVirtual().catch(er => {
41+
log.verbose('loadVirtual', er.stack)
42+
const msg =
43+
'The `npm ci` command can only install with an existing package-lock.json or\n' +
44+
'npm-shrinkwrap.json with lockfileVersion >= 1. Run an install with npm@5 or\n' +
45+
'later to generate a package-lock.json file, then try again.'
46+
throw this.usageError(msg)
47+
})
6048

6149
// retrieves inventory of packages from loaded virtual tree (lock file)
6250
const virtualInventory = new Map(arb.virtualTree.inventory)
@@ -70,15 +58,24 @@ class CI extends ArboristWorkspaceCmd {
7058
// throws a validation error in case of mismatches
7159
const errors = validateLockfile(virtualInventory, arb.idealTree.inventory)
7260
if (errors.length) {
73-
throw new Error(
61+
throw this.usageError(
7462
'`npm ci` can only install packages when your package.json and ' +
7563
'package-lock.json or npm-shrinkwrap.json are in sync. Please ' +
7664
'update your lock file with `npm install` ' +
7765
'before continuing.\n\n' +
78-
errors.join('\n') + '\n'
66+
errors.join('\n')
7967
)
8068
}
8169

70+
// Only remove node_modules after we've successfully loaded the virtual
71+
// tree and validated the lockfile
72+
await this.npm.time('npm-ci:rm', async () => {
73+
const path = `${where}/node_modules`
74+
// get the list of entries so we can skip the glob for performance
75+
const entries = await readdir(path, null).catch(er => [])
76+
return Promise.all(entries.map(f => rimraf(`${path}/${f}`, { glob: false })))
77+
})
78+
8279
await arb.reify(opts)
8380

8481
const ignoreScripts = this.npm.config.get('ignore-scripts')

smoke-tests/tap-snapshots/test/index.js.test.cjs

+14
Original file line numberDiff line numberDiff line change
@@ -43,10 +43,24 @@ npm {CWD}
4343
`
4444

4545
exports[`test/index.js TAP npm ci > should throw mismatch deps in lock file error 1`] = `
46+
npm ERR! code EUSAGE
47+
npm ERR!
4648
npm ERR! \`npm ci\` can only install packages when your package.json and package-lock.json or npm-shrinkwrap.json are in sync. Please update your lock file with \`npm install\` before continuing.
4749
npm ERR!
4850
npm ERR! Invalid: lock file's [email protected] does not satisfy [email protected]
4951
npm ERR!
52+
npm ERR! Clean install a project
53+
npm ERR!
54+
npm ERR! Usage:
55+
npm ERR! npm ci
56+
npm ERR!
57+
npm ERR! Options:
58+
npm ERR! [--no-audit] [--foreground-scripts] [--ignore-scripts]
59+
npm ERR! [--script-shell <script-shell>]
60+
npm ERR!
61+
npm ERR! aliases: clean-install, ic, install-clean, isntall-clean
62+
npm ERR!
63+
npm ERR! Run "npm help ci" for more info
5064
5165
npm ERR! A complete log of this run can be found in:
5266

tap-snapshots/test/lib/commands/ci.js.test.cjs

-13
This file was deleted.

tap-snapshots/test/lib/load-all-commands.js.test.cjs

+1-1
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,7 @@ Run "npm help cache" for more info
113113
`
114114

115115
exports[`test/lib/load-all-commands.js TAP load each command ci > must match snapshot 1`] = `
116-
Install a project with a clean slate
116+
Clean install a project
117117
118118
Usage:
119119
npm ci

tap-snapshots/test/lib/utils/npm-usage.js.test.cjs

+1-1
Original file line numberDiff line numberDiff line change
@@ -251,7 +251,7 @@ All commands:
251251
252252
Run "npm help cache" for more info
253253
254-
ci Install a project with a clean slate
254+
ci Clean install a project
255255
256256
Usage:
257257
npm ci

test/fixtures/mock-registry.js

+10-5
Original file line numberDiff line numberDiff line change
@@ -202,16 +202,21 @@ class MockRegistry {
202202
nock = nock.reply(200, manifest)
203203
if (tarballs) {
204204
for (const version in tarballs) {
205-
// for (const version in manifest.versions) {
206-
const packument = manifest.versions[version]
207-
const dist = new URL(packument.dist.tarball)
208-
const tarball = await pacote.tarball(tarballs[version])
209-
nock.get(dist.pathname).reply(200, tarball)
205+
const m = manifest.versions[version]
206+
nock = await this.tarball({ manifest: m, tarball: tarballs[version] })
210207
}
211208
}
212209
this.nock = nock
213210
}
214211

212+
async tarball ({ manifest, tarball }) {
213+
const nock = this.nock
214+
const dist = new URL(manifest.dist.tarball)
215+
const tar = await pacote.tarball(tarball)
216+
nock.get(dist.pathname).reply(200, tar)
217+
return nock
218+
}
219+
215220
// either pass in packuments if you need to set specific attributes besides version,
216221
// or an array of versions
217222
// the last packument in the packuments or versions array will be tagged latest

0 commit comments

Comments
 (0)