• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1const cacache = require('cacache')
2const fs = require('fs')
3const fetch = require('make-fetch-happen')
4const Table = require('cli-table3')
5const which = require('which')
6const pacote = require('pacote')
7const { resolve } = require('path')
8const semver = require('semver')
9const { promisify } = require('util')
10const log = require('../utils/log-shim.js')
11const ping = require('../utils/ping.js')
12const { defaults } = require('@npmcli/config/lib/definitions')
13const lstat = promisify(fs.lstat)
14const readdir = promisify(fs.readdir)
15const access = promisify(fs.access)
16const { R_OK, W_OK, X_OK } = fs.constants
17
18const maskLabel = mask => {
19  const label = []
20  if (mask & R_OK) {
21    label.push('readable')
22  }
23
24  if (mask & W_OK) {
25    label.push('writable')
26  }
27
28  if (mask & X_OK) {
29    label.push('executable')
30  }
31
32  return label.join(', ')
33}
34
35const subcommands = [
36  {
37    groups: ['ping', 'registry'],
38    title: 'npm ping',
39    cmd: 'checkPing',
40  }, {
41    groups: ['versions'],
42    title: 'npm -v',
43    cmd: 'getLatestNpmVersion',
44  }, {
45    groups: ['versions'],
46    title: 'node -v',
47    cmd: 'getLatestNodejsVersion',
48  }, {
49    groups: ['registry'],
50    title: 'npm config get registry',
51    cmd: 'checkNpmRegistry',
52  }, {
53    groups: ['environment'],
54    title: 'git executable in PATH',
55    cmd: 'getGitPath',
56  }, {
57    groups: ['environment'],
58    title: 'global bin folder in PATH',
59    cmd: 'getBinPath',
60  }, {
61    groups: ['permissions', 'cache'],
62    title: 'Perms check on cached files',
63    cmd: 'checkCachePermission',
64    windows: false,
65  }, {
66    groups: ['permissions'],
67    title: 'Perms check on local node_modules',
68    cmd: 'checkLocalModulesPermission',
69    windows: false,
70  }, {
71    groups: ['permissions'],
72    title: 'Perms check on global node_modules',
73    cmd: 'checkGlobalModulesPermission',
74    windows: false,
75  }, {
76    groups: ['permissions'],
77    title: 'Perms check on local bin folder',
78    cmd: 'checkLocalBinPermission',
79    windows: false,
80  }, {
81    groups: ['permissions'],
82    title: 'Perms check on global bin folder',
83    cmd: 'checkGlobalBinPermission',
84    windows: false,
85  }, {
86    groups: ['cache'],
87    title: 'Verify cache contents',
88    cmd: 'verifyCachedFiles',
89    windows: false,
90  },
91  // TODO:
92  // group === 'dependencies'?
93  //   - ensure arborist.loadActual() runs without errors and no invalid edges
94  //   - ensure package-lock.json matches loadActual()
95  //   - verify loadActual without hidden lock file matches hidden lockfile
96  // group === '???'
97  //   - verify all local packages have bins linked
98  // What is the fix for these?
99]
100const BaseCommand = require('../base-command.js')
101class Doctor extends BaseCommand {
102  static description = 'Check your npm environment'
103  static name = 'doctor'
104  static params = ['registry']
105  static ignoreImplicitWorkspace = false
106  static usage = [`[${subcommands.flatMap(s => s.groups)
107    .filter((value, index, self) => self.indexOf(value) === index)
108    .join('] [')}]`]
109
110  static subcommands = subcommands
111
112  // minimum width of check column, enough for the word `Check`
113  #checkWidth = 5
114
115  async exec (args) {
116    log.info('Running checkup')
117    let allOk = true
118
119    const actions = this.actions(args)
120    this.#checkWidth = actions.reduce((length, item) =>
121      Math.max(item.title.length, length), this.#checkWidth)
122
123    if (!this.npm.silent) {
124      this.output(['Check', 'Value', 'Recommendation/Notes'].map(h => this.npm.chalk.underline(h)))
125    }
126    // Do the actual work
127    for (const { title, cmd } of actions) {
128      const item = [title]
129      try {
130        item.push(true, await this[cmd]())
131      } catch (err) {
132        item.push(false, err)
133      }
134      if (!item[1]) {
135        allOk = false
136        item[0] = this.npm.chalk.red(item[0])
137        item[1] = this.npm.chalk.red('not ok')
138        item[2] = this.npm.chalk.magenta(String(item[2]))
139      } else {
140        item[1] = this.npm.chalk.green('ok')
141      }
142      if (!this.npm.silent) {
143        this.output(item)
144      }
145    }
146
147    if (!allOk) {
148      if (this.npm.silent) {
149        /* eslint-disable-next-line max-len */
150        throw new Error('Some problems found. Check logs or disable silent mode for recommendations.')
151      } else {
152        throw new Error('Some problems found. See above for recommendations.')
153      }
154    }
155  }
156
157  async checkPing () {
158    const tracker = log.newItem('checkPing', 1)
159    tracker.info('checkPing', 'Pinging registry')
160    try {
161      await ping({ ...this.npm.flatOptions, retry: false })
162      return ''
163    } catch (er) {
164      if (/^E\d{3}$/.test(er.code || '')) {
165        throw er.code.slice(1) + ' ' + er.message
166      } else {
167        throw er.message
168      }
169    } finally {
170      tracker.finish()
171    }
172  }
173
174  async getLatestNpmVersion () {
175    const tracker = log.newItem('getLatestNpmVersion', 1)
176    tracker.info('getLatestNpmVersion', 'Getting npm package information')
177    try {
178      const latest = (await pacote.manifest('npm@latest', this.npm.flatOptions)).version
179      if (semver.gte(this.npm.version, latest)) {
180        return `current: v${this.npm.version}, latest: v${latest}`
181      } else {
182        throw `Use npm v${latest}`
183      }
184    } finally {
185      tracker.finish()
186    }
187  }
188
189  async getLatestNodejsVersion () {
190    // XXX get the latest in the current major as well
191    const current = process.version
192    const currentRange = `^${current}`
193    const url = 'https://nodejs.org/dist/index.json'
194    const tracker = log.newItem('getLatestNodejsVersion', 1)
195    tracker.info('getLatestNodejsVersion', 'Getting Node.js release information')
196    try {
197      const res = await fetch(url, { method: 'GET', ...this.npm.flatOptions })
198      const data = await res.json()
199      let maxCurrent = '0.0.0'
200      let maxLTS = '0.0.0'
201      for (const { lts, version } of data) {
202        if (lts && semver.gt(version, maxLTS)) {
203          maxLTS = version
204        }
205
206        if (semver.satisfies(version, currentRange) && semver.gt(version, maxCurrent)) {
207          maxCurrent = version
208        }
209      }
210      const recommended = semver.gt(maxCurrent, maxLTS) ? maxCurrent : maxLTS
211      if (semver.gte(process.version, recommended)) {
212        return `current: ${current}, recommended: ${recommended}`
213      } else {
214        throw `Use node ${recommended} (current: ${current})`
215      }
216    } finally {
217      tracker.finish()
218    }
219  }
220
221  async getBinPath (dir) {
222    const tracker = log.newItem('getBinPath', 1)
223    tracker.info('getBinPath', 'Finding npm global bin in your PATH')
224    if (!process.env.PATH.includes(this.npm.globalBin)) {
225      throw new Error(`Add ${this.npm.globalBin} to your $PATH`)
226    }
227    return this.npm.globalBin
228  }
229
230  async checkCachePermission () {
231    return this.checkFilesPermission(this.npm.cache, true, R_OK)
232  }
233
234  async checkLocalModulesPermission () {
235    return this.checkFilesPermission(this.npm.localDir, true, R_OK | W_OK, true)
236  }
237
238  async checkGlobalModulesPermission () {
239    return this.checkFilesPermission(this.npm.globalDir, false, R_OK)
240  }
241
242  async checkLocalBinPermission () {
243    return this.checkFilesPermission(this.npm.localBin, false, R_OK | W_OK | X_OK, true)
244  }
245
246  async checkGlobalBinPermission () {
247    return this.checkFilesPermission(this.npm.globalBin, false, X_OK)
248  }
249
250  async checkFilesPermission (root, shouldOwn, mask, missingOk) {
251    let ok = true
252
253    const tracker = log.newItem(root, 1)
254
255    try {
256      const uid = process.getuid()
257      const gid = process.getgid()
258      const files = new Set([root])
259      for (const f of files) {
260        tracker.silly('checkFilesPermission', f.slice(root.length + 1))
261        const st = await lstat(f).catch(er => {
262          // if it can't be missing, or if it can and the error wasn't that it was missing
263          if (!missingOk || er.code !== 'ENOENT') {
264            ok = false
265            tracker.warn('checkFilesPermission', 'error getting info for ' + f)
266          }
267        })
268
269        tracker.completeWork(1)
270
271        if (!st) {
272          continue
273        }
274
275        if (shouldOwn && (uid !== st.uid || gid !== st.gid)) {
276          tracker.warn('checkFilesPermission', 'should be owner of ' + f)
277          ok = false
278        }
279
280        if (!st.isDirectory() && !st.isFile()) {
281          continue
282        }
283
284        try {
285          await access(f, mask)
286        } catch (er) {
287          ok = false
288          const msg = `Missing permissions on ${f} (expect: ${maskLabel(mask)})`
289          tracker.error('checkFilesPermission', msg)
290          continue
291        }
292
293        if (st.isDirectory()) {
294          const entries = await readdir(f).catch(er => {
295            ok = false
296            tracker.warn('checkFilesPermission', 'error reading directory ' + f)
297            return []
298          })
299          for (const entry of entries) {
300            files.add(resolve(f, entry))
301          }
302        }
303      }
304    } finally {
305      tracker.finish()
306      if (!ok) {
307        throw (
308          `Check the permissions of files in ${root}` +
309          (shouldOwn ? ' (should be owned by current user)' : '')
310        )
311      } else {
312        return ''
313      }
314    }
315  }
316
317  async getGitPath () {
318    const tracker = log.newItem('getGitPath', 1)
319    tracker.info('getGitPath', 'Finding git in your PATH')
320    try {
321      return await which('git').catch(er => {
322        tracker.warn(er)
323        throw new Error("Install git and ensure it's in your PATH.")
324      })
325    } finally {
326      tracker.finish()
327    }
328  }
329
330  async verifyCachedFiles () {
331    const tracker = log.newItem('verifyCachedFiles', 1)
332    tracker.info('verifyCachedFiles', 'Verifying the npm cache')
333    try {
334      const stats = await cacache.verify(this.npm.flatOptions.cache)
335      const { badContentCount, reclaimedCount, missingContent, reclaimedSize } = stats
336      if (badContentCount || reclaimedCount || missingContent) {
337        if (badContentCount) {
338          tracker.warn('verifyCachedFiles', `Corrupted content removed: ${badContentCount}`)
339        }
340
341        if (reclaimedCount) {
342          tracker.warn(
343            'verifyCachedFiles',
344            `Content garbage-collected: ${reclaimedCount} (${reclaimedSize} bytes)`
345          )
346        }
347
348        if (missingContent) {
349          tracker.warn('verifyCachedFiles', `Missing content: ${missingContent}`)
350        }
351
352        tracker.warn('verifyCachedFiles', 'Cache issues have been fixed')
353      }
354      tracker.info(
355        'verifyCachedFiles',
356        `Verification complete. Stats: ${JSON.stringify(stats, null, 2)}`
357      )
358      return `verified ${stats.verifiedContent} tarballs`
359    } finally {
360      tracker.finish()
361    }
362  }
363
364  async checkNpmRegistry () {
365    if (this.npm.flatOptions.registry !== defaults.registry) {
366      throw `Try \`npm config set registry=${defaults.registry}\``
367    } else {
368      return `using default registry (${defaults.registry})`
369    }
370  }
371
372  output (row) {
373    const t = new Table({
374      chars: {
375        top: '',
376        'top-mid': '',
377        'top-left': '',
378        'top-right': '',
379        bottom: '',
380        'bottom-mid': '',
381        'bottom-left': '',
382        'bottom-right': '',
383        left: '',
384        'left-mid': '',
385        mid: '',
386        'mid-mid': '',
387        right: '',
388        'right-mid': '',
389        middle: '  ',
390      },
391      style: {
392        'padding-left': 0,
393        'padding-right': 0,
394        // setting border here is not necessary visually since we've already
395        // zeroed out all the chars above, but without it cli-table3 will wrap
396        // some of the separator spaces with ansi codes which show up in
397        // snapshots.
398        border: 0,
399      },
400      colWidths: [this.#checkWidth, 6],
401    })
402    t.push(row)
403    this.npm.output(t.toString())
404  }
405
406  actions (params) {
407    return this.constructor.subcommands.filter(subcmd => {
408      if (process.platform === 'win32' && subcmd.windows === false) {
409        return false
410      }
411      if (params.length) {
412        return params.some(param => subcmd.groups.includes(param))
413      }
414      return true
415    })
416  }
417}
418
419module.exports = Doctor
420