Skip to content

Commit cf57ffa

Browse files
authoredDec 7, 2022
feat: discrete npm doctor commands (#5888)
* fix: output doctor checks as they finish * feat: allow for discrete npm doctor checks * feat: add environment check to npm doctor * chore: refactor command
1 parent 83fb125 commit cf57ffa

File tree

5 files changed

+903
-359
lines changed

5 files changed

+903
-359
lines changed
 

‎docs/lib/content/commands/npm-doctor.md

+4-2
Original file line numberDiff line numberDiff line change
@@ -31,8 +31,10 @@ Also, in addition to this, there are also very many issue reports due to
3131
using old versions of npm. Since npm is constantly improving, running
3232
`npm@latest` is better than an old version.
3333

34-
`npm doctor` verifies the following items in your environment, and if there
35-
are any recommended changes, it will display them.
34+
`npm doctor` verifies the following items in your environment, and if
35+
there are any recommended changes, it will display them. By default npm
36+
runs all of these checks. You can limit what checks are ran by
37+
specifying them as extra arguments.
3638

3739
#### `npm ping`
3840

‎lib/commands/doctor.js

+162-68
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,13 @@
11
const cacache = require('cacache')
22
const fs = require('fs')
33
const fetch = require('make-fetch-happen')
4-
const table = require('text-table')
4+
const Table = require('cli-table3')
55
const which = require('which')
66
const pacote = require('pacote')
77
const { resolve } = require('path')
88
const semver = require('semver')
99
const { promisify } = require('util')
1010
const log = require('../utils/log-shim.js')
11-
const ansiTrim = require('../utils/ansi-trim.js')
1211
const ping = require('../utils/ping.js')
1312
const {
1413
registry: { default: defaultRegistry },
@@ -17,6 +16,7 @@ const lstat = promisify(fs.lstat)
1716
const readdir = promisify(fs.readdir)
1817
const access = promisify(fs.access)
1918
const { R_OK, W_OK, X_OK } = fs.constants
19+
2020
const maskLabel = mask => {
2121
const label = []
2222
if (mask & R_OK) {
@@ -34,76 +34,105 @@ const maskLabel = mask => {
3434
return label.join(', ')
3535
}
3636

37+
const subcommands = [
38+
{
39+
groups: ['ping', 'registry'],
40+
title: 'npm ping',
41+
cmd: 'checkPing',
42+
}, {
43+
groups: ['versions'],
44+
title: 'npm -v',
45+
cmd: 'getLatestNpmVersion',
46+
}, {
47+
groups: ['versions'],
48+
title: 'node -v',
49+
cmd: 'getLatestNodejsVersion',
50+
}, {
51+
groups: ['registry'],
52+
title: 'npm config get registry',
53+
cmd: 'checkNpmRegistry',
54+
}, {
55+
groups: ['environment'],
56+
title: 'git executable in PATH',
57+
cmd: 'getGitPath',
58+
}, {
59+
groups: ['environment'],
60+
title: 'global bin folder in PATH',
61+
cmd: 'getBinPath',
62+
}, {
63+
groups: ['permissions', 'cache'],
64+
title: 'Perms check on cached files',
65+
cmd: 'checkCachePermission',
66+
windows: false,
67+
}, {
68+
groups: ['permissions'],
69+
title: 'Perms check on local node_modules',
70+
cmd: 'checkLocalModulesPermission',
71+
windows: false,
72+
}, {
73+
groups: ['permissions'],
74+
title: 'Perms check on global node_modules',
75+
cmd: 'checkGlobalModulesPermission',
76+
windows: false,
77+
}, {
78+
groups: ['permissions'],
79+
title: 'Perms check on local bin folder',
80+
cmd: 'checkLocalBinPermission',
81+
windows: false,
82+
}, {
83+
groups: ['permissions'],
84+
title: 'Perms check on global bin folder',
85+
cmd: 'checkGlobalBinPermission',
86+
windows: false,
87+
}, {
88+
groups: ['cache'],
89+
title: 'Verify cache contents',
90+
cmd: 'verifyCachedFiles',
91+
windows: false,
92+
},
93+
// TODO:
94+
// group === 'dependencies'?
95+
// - ensure arborist.loadActual() runs without errors and no invalid edges
96+
// - ensure package-lock.json matches loadActual()
97+
// - verify loadActual without hidden lock file matches hidden lockfile
98+
// group === '???'
99+
// - verify all local packages have bins linked
100+
// What is the fix for these?
101+
]
37102
const BaseCommand = require('../base-command.js')
38103
class Doctor extends BaseCommand {
39104
static description = 'Check your npm environment'
40105
static name = 'doctor'
41106
static params = ['registry']
42107
static ignoreImplicitWorkspace = false
108+
static usage = [`[${subcommands.flatMap(s => s.groups)
109+
.filter((value, index, self) => self.indexOf(value) === index)
110+
.join('] [')}]`]
111+
112+
static subcommands = subcommands
113+
114+
// minimum width of check column, enough for the word `Check`
115+
#checkWidth = 5
43116

44117
async exec (args) {
45118
log.info('Running checkup')
119+
let allOk = true
46120

47-
// each message is [title, ok, message]
48-
const messages = []
49-
50-
const actions = [
51-
['npm ping', 'checkPing', []],
52-
['npm -v', 'getLatestNpmVersion', []],
53-
['node -v', 'getLatestNodejsVersion', []],
54-
['npm config get registry', 'checkNpmRegistry', []],
55-
['which git', 'getGitPath', []],
56-
...(process.platform === 'win32'
57-
? []
58-
: [
59-
[
60-
'Perms check on cached files',
61-
'checkFilesPermission',
62-
[this.npm.cache, true, R_OK],
63-
], [
64-
'Perms check on local node_modules',
65-
'checkFilesPermission',
66-
[this.npm.localDir, true, R_OK | W_OK, true],
67-
], [
68-
'Perms check on global node_modules',
69-
'checkFilesPermission',
70-
[this.npm.globalDir, false, R_OK],
71-
], [
72-
'Perms check on local bin folder',
73-
'checkFilesPermission',
74-
[this.npm.localBin, false, R_OK | W_OK | X_OK, true],
75-
], [
76-
'Perms check on global bin folder',
77-
'checkFilesPermission',
78-
[this.npm.globalBin, false, X_OK],
79-
],
80-
]),
81-
[
82-
'Verify cache contents',
83-
'verifyCachedFiles',
84-
[this.npm.flatOptions.cache],
85-
],
86-
// TODO:
87-
// - ensure arborist.loadActual() runs without errors and no invalid edges
88-
// - ensure package-lock.json matches loadActual()
89-
// - verify loadActual without hidden lock file matches hidden lockfile
90-
// - verify all local packages have bins linked
91-
]
121+
const actions = this.actions(args)
122+
this.#checkWidth = actions.reduce((length, item) =>
123+
Math.max(item.title.length, length), this.#checkWidth)
92124

125+
if (!this.npm.silent) {
126+
this.output(['Check', 'Value', 'Recommendation/Notes'].map(h => this.npm.chalk.underline(h)))
127+
}
93128
// Do the actual work
94-
for (const [msg, fn, args] of actions) {
95-
const line = [msg]
129+
for (const { title, cmd } of actions) {
130+
const item = [title]
96131
try {
97-
line.push(true, await this[fn](...args))
98-
} catch (er) {
99-
line.push(false, er)
132+
item.push(true, await this[cmd]())
133+
} catch (err) {
134+
item.push(false, err)
100135
}
101-
messages.push(line)
102-
}
103-
104-
const outHead = ['Check', 'Value', 'Recommendation/Notes'].map(h => this.npm.chalk.underline(h))
105-
let allOk = true
106-
const outBody = messages.map(item => {
107136
if (!item[1]) {
108137
allOk = false
109138
item[0] = this.npm.chalk.red(item[0])
@@ -112,18 +141,18 @@ class Doctor extends BaseCommand {
112141
} else {
113142
item[1] = this.npm.chalk.green('ok')
114143
}
115-
return item
116-
})
117-
const outTable = [outHead, ...outBody]
118-
const tableOpts = {
119-
stringLength: s => ansiTrim(s).length,
144+
if (!this.npm.silent) {
145+
this.output(item)
146+
}
120147
}
121148

122-
if (!this.npm.silent) {
123-
this.npm.output(table(outTable, tableOpts))
124-
}
125149
if (!allOk) {
126-
throw new Error('Some problems found. See above for recommendations.')
150+
if (this.npm.silent) {
151+
/* eslint-disable-next-line max-len */
152+
throw new Error('Some problems found. Check logs or disable silent mode for recommendations.')
153+
} else {
154+
throw new Error('Some problems found. See above for recommendations.')
155+
}
127156
}
128157
}
129158

@@ -191,6 +220,35 @@ class Doctor extends BaseCommand {
191220
}
192221
}
193222

223+
async getBinPath (dir) {
224+
const tracker = log.newItem('getBinPath', 1)
225+
tracker.info('getBinPath', 'Finding npm global bin in your PATH')
226+
if (!process.env.PATH.includes(this.npm.globalBin)) {
227+
throw new Error(`Add ${this.npm.globalBin} to your $PATH`)
228+
}
229+
return this.npm.globalBin
230+
}
231+
232+
async checkCachePermission () {
233+
return this.checkFilesPermission(this.npm.cache, true, R_OK)
234+
}
235+
236+
async checkLocalModulesPermission () {
237+
return this.checkFilesPermission(this.npm.localDir, true, R_OK | W_OK, true)
238+
}
239+
240+
async checkGlobalModulesPermission () {
241+
return this.checkFilesPermission(this.npm.globalDir, false, R_OK)
242+
}
243+
244+
async checkLocalBinPermission () {
245+
return this.checkFilesPermission(this.npm.localBin, false, R_OK | W_OK | X_OK, true)
246+
}
247+
248+
async checkGlobalBinPermission () {
249+
return this.checkFilesPermission(this.npm.globalBin, false, X_OK)
250+
}
251+
194252
async checkFilesPermission (root, shouldOwn, mask, missingOk) {
195253
let ok = true
196254

@@ -264,7 +322,7 @@ class Doctor extends BaseCommand {
264322
try {
265323
return await which('git').catch(er => {
266324
tracker.warn(er)
267-
throw "Install git and ensure it's in your PATH."
325+
throw new Error("Install git and ensure it's in your PATH.")
268326
})
269327
} finally {
270328
tracker.finish()
@@ -312,6 +370,42 @@ class Doctor extends BaseCommand {
312370
return `using default registry (${defaultRegistry})`
313371
}
314372
}
373+
374+
output (row) {
375+
const t = new Table({
376+
chars: { top: '',
377+
'top-mid': '',
378+
'top-left': '',
379+
'top-right': '',
380+
bottom: '',
381+
'bottom-mid': '',
382+
'bottom-left': '',
383+
'bottom-right': '',
384+
left: '',
385+
'left-mid': '',
386+
mid: '',
387+
'mid-mid': '',
388+
right: '',
389+
'right-mid': '',
390+
middle: ' ' },
391+
style: { 'padding-left': 0, 'padding-right': 0 },
392+
colWidths: [this.#checkWidth, 6],
393+
})
394+
t.push(row)
395+
this.npm.output(t.toString())
396+
}
397+
398+
actions (params) {
399+
return this.constructor.subcommands.filter(subcmd => {
400+
if (process.platform === 'win32' && subcmd.windows === false) {
401+
return false
402+
}
403+
if (params.length) {
404+
return params.some(param => subcmd.groups.includes(param))
405+
}
406+
return true
407+
})
408+
}
315409
}
316410

317411
module.exports = Doctor

‎tap-snapshots/test/lib/commands/doctor.js.test.cjs

+556-262
Large diffs are not rendered by default.

‎tap-snapshots/test/lib/docs.js.test.cjs

+2-2
Original file line numberDiff line numberDiff line change
@@ -2861,15 +2861,15 @@ exports[`test/lib/docs.js TAP usage doctor > must match snapshot 1`] = `
28612861
Check your npm environment
28622862
28632863
Usage:
2864-
npm doctor
2864+
npm doctor [ping] [registry] [versions] [environment] [permissions] [cache]
28652865
28662866
Options:
28672867
[--registry <registry>]
28682868
28692869
Run "npm help doctor" for more info
28702870
28712871
\`\`\`bash
2872-
npm doctor
2872+
npm doctor [ping] [registry] [versions] [environment] [permissions] [cache]
28732873
\`\`\`
28742874
28752875
#### \`registry\`

‎test/lib/commands/doctor.js

+179-25
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
const t = require('tap')
22
const fs = require('fs')
3+
const path = require('path')
34

45
const { load: loadMockNpm } = require('../../fixtures/mock-npm')
56
const tnock = require('../../fixtures/tnock.js')
@@ -52,11 +53,14 @@ const dirs = {
5253
},
5354
}
5455

55-
const globals = {
56-
process: {
57-
platform: 'test-not-windows',
58-
version: 'v1.0.0',
59-
},
56+
const globals = ({ globalPrefix }) => {
57+
return {
58+
process: {
59+
'env.PATH': `${globalPrefix}:${path.join(globalPrefix, 'bin')}`,
60+
platform: 'test-not-windows',
61+
version: 'v1.0.0',
62+
},
63+
}
6064
}
6165

6266
// getuid and getgid do not exist in windows, so we shim them
@@ -114,7 +118,7 @@ t.test('all clear in color', async t => {
114118
t.matchSnapshot({ info: logs.info, warn: logs.warn, error: logs.error }, 'logs')
115119
})
116120

117-
t.test('silent', async t => {
121+
t.test('silent success', async t => {
118122
const { joinedOutput, logs, npm } = await loadMockNpm(t, {
119123
mocks,
120124
globals,
@@ -133,6 +137,24 @@ t.test('silent', async t => {
133137
t.matchSnapshot({ info: logs.info, warn: logs.warn, error: logs.error }, 'logs')
134138
})
135139

140+
t.test('silent errors', async t => {
141+
const { joinedOutput, logs, npm } = await loadMockNpm(t, {
142+
mocks,
143+
globals,
144+
config: {
145+
loglevel: 'silent',
146+
},
147+
...dirs,
148+
})
149+
tnock(t, npm.config.get('registry'))
150+
.get('/-/ping?write=true').reply(404, '{}')
151+
await t.rejects(npm.exec('doctor', ['ping']), {
152+
message: /Check logs/,
153+
})
154+
t.matchSnapshot(joinedOutput(), 'output')
155+
t.matchSnapshot({ info: logs.info, warn: logs.warn, error: logs.error }, 'logs')
156+
})
157+
136158
t.test('ping 404', async t => {
137159
const { joinedOutput, logs, npm } = await loadMockNpm(t, {
138160
mocks,
@@ -144,7 +166,9 @@ t.test('ping 404', async t => {
144166
.get('/npm').reply(200, npmManifest(npm.version))
145167
tnock(t, 'https://nodejs.org')
146168
.get('/dist/index.json').reply(200, nodeVersions)
147-
await t.rejects(npm.exec('doctor', []))
169+
await t.rejects(npm.exec('doctor', []), {
170+
message: /See above/,
171+
})
148172
t.matchSnapshot(joinedOutput(), 'ping 404')
149173
t.matchSnapshot({ info: logs.info, warn: logs.warn, error: logs.error }, 'logs')
150174
})
@@ -217,12 +241,16 @@ t.test('npm out of date', async t => {
217241
t.test('node out of date - lts', async t => {
218242
const { joinedOutput, logs, npm } = await loadMockNpm(t, {
219243
mocks,
220-
globals: {
221-
...globals,
222-
process: {
223-
platform: 'test-not-windows',
224-
version: 'v0.0.1',
225-
},
244+
globals: (context) => {
245+
const g = globals(context)
246+
return {
247+
...g,
248+
process: {
249+
...g.process,
250+
platform: 'test-not-windows',
251+
version: 'v0.0.1',
252+
},
253+
}
226254
},
227255
...dirs,
228256
})
@@ -239,12 +267,15 @@ t.test('node out of date - lts', async t => {
239267
t.test('node out of date - current', async t => {
240268
const { joinedOutput, logs, npm } = await loadMockNpm(t, {
241269
mocks,
242-
globals: {
243-
...globals,
244-
process: {
245-
...globals.process,
246-
version: 'v2.0.0',
247-
},
270+
globals: (context) => {
271+
const g = globals(context)
272+
return {
273+
...g,
274+
process: {
275+
...g.process,
276+
version: 'v2.0.0',
277+
},
278+
}
248279
},
249280
...dirs,
250281
})
@@ -299,12 +330,15 @@ t.test('missing git', async t => {
299330
t.test('windows skips permissions checks', async t => {
300331
const { joinedOutput, logs, npm } = await loadMockNpm(t, {
301332
mocks,
302-
globals: {
303-
...globals,
304-
process: {
305-
...globals.process,
306-
platform: 'win32',
307-
},
333+
globals: (context) => {
334+
const g = globals(context)
335+
return {
336+
...g,
337+
process: {
338+
...g.process,
339+
platform: 'win32',
340+
},
341+
}
308342
},
309343
prefixDir: {},
310344
globalPrefixDir: {},
@@ -510,3 +544,123 @@ t.test('bad proxy', async t => {
510544
t.matchSnapshot(joinedOutput(), 'output')
511545
t.matchSnapshot({ info: logs.info, warn: logs.warn, error: logs.error }, 'logs')
512546
})
547+
548+
t.test('discrete checks', t => {
549+
t.test('ping', async t => {
550+
const { joinedOutput, logs, npm } = await loadMockNpm(t, {
551+
mocks,
552+
globals,
553+
...dirs,
554+
})
555+
tnock(t, npm.config.get('registry'))
556+
.get('/-/ping?write=true').reply(200, '{}')
557+
await npm.exec('doctor', ['ping'])
558+
t.matchSnapshot(joinedOutput(), 'output')
559+
t.matchSnapshot({ info: logs.info, warn: logs.warn, error: logs.error }, 'logs')
560+
})
561+
562+
t.test('versions', async t => {
563+
const { joinedOutput, logs, npm } = await loadMockNpm(t, {
564+
mocks,
565+
globals,
566+
...dirs,
567+
})
568+
tnock(t, npm.config.get('registry'))
569+
.get('/npm').reply(200, npmManifest(npm.version))
570+
tnock(t, 'https://nodejs.org')
571+
.get('/dist/index.json').reply(200, nodeVersions)
572+
await npm.exec('doctor', ['versions'])
573+
t.matchSnapshot(joinedOutput(), 'output')
574+
t.matchSnapshot({ info: logs.info, warn: logs.warn, error: logs.error }, 'logs')
575+
})
576+
577+
t.test('registry', async t => {
578+
const { joinedOutput, logs, npm } = await loadMockNpm(t, {
579+
mocks,
580+
globals,
581+
...dirs,
582+
})
583+
tnock(t, npm.config.get('registry'))
584+
.get('/-/ping?write=true').reply(200, '{}')
585+
await npm.exec('doctor', ['registry'])
586+
t.matchSnapshot(joinedOutput(), 'output')
587+
t.matchSnapshot({ info: logs.info, warn: logs.warn, error: logs.error }, 'logs')
588+
})
589+
590+
t.test('git', async t => {
591+
const { joinedOutput, logs, npm } = await loadMockNpm(t, {
592+
mocks,
593+
globals,
594+
...dirs,
595+
})
596+
await npm.exec('doctor', ['git'])
597+
t.matchSnapshot(joinedOutput(), 'output')
598+
t.matchSnapshot({ info: logs.info, warn: logs.warn, error: logs.error }, 'logs')
599+
})
600+
601+
t.test('permissions - not windows', async t => {
602+
const { joinedOutput, logs, npm } = await loadMockNpm(t, {
603+
mocks,
604+
globals,
605+
...dirs,
606+
})
607+
await npm.exec('doctor', ['permissions'])
608+
t.matchSnapshot(joinedOutput(), 'output')
609+
t.matchSnapshot({ info: logs.info, warn: logs.warn, error: logs.error }, 'logs')
610+
})
611+
612+
t.test('cache', async t => {
613+
const { joinedOutput, logs, npm } = await loadMockNpm(t, {
614+
mocks,
615+
globals,
616+
...dirs,
617+
})
618+
await npm.exec('doctor', ['cache'])
619+
t.matchSnapshot(joinedOutput(), 'output')
620+
t.matchSnapshot({ info: logs.info, warn: logs.warn, error: logs.error }, 'logs')
621+
})
622+
623+
t.test('permissions - windows', async t => {
624+
const { joinedOutput, logs, npm } = await loadMockNpm(t, {
625+
mocks,
626+
globals: (context) => {
627+
const g = globals(context)
628+
return {
629+
...g,
630+
process: {
631+
...g.process,
632+
platform: 'win32',
633+
},
634+
}
635+
},
636+
prefixDir: {},
637+
globalPrefixDir: {},
638+
})
639+
await npm.exec('doctor', ['permissions'])
640+
t.matchSnapshot(joinedOutput(), 'output')
641+
t.matchSnapshot({ info: logs.info, warn: logs.warn, error: logs.error }, 'logs')
642+
})
643+
644+
t.test('invalid environment', async t => {
645+
const { joinedOutput, logs, npm } = await loadMockNpm(t, {
646+
mocks,
647+
globals: (context) => {
648+
const g = globals(context)
649+
return {
650+
...g,
651+
process: {
652+
...g.process,
653+
'env.PATH': '/nope',
654+
},
655+
}
656+
},
657+
prefixDir: {},
658+
globalPrefixDir: {},
659+
})
660+
await t.rejects(npm.exec('doctor', ['environment']))
661+
t.matchSnapshot(joinedOutput(), 'output')
662+
t.matchSnapshot({ info: logs.info, warn: logs.warn, error: logs.error }, 'logs')
663+
})
664+
665+
t.end()
666+
})

0 commit comments

Comments
 (0)
Please sign in to comment.