doctor.js 10.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343
  1. const cacache = require('cacache')
  2. const { access, lstat, readdir, constants: { R_OK, W_OK, X_OK } } = require('node:fs/promises')
  3. const npmFetch = require('make-fetch-happen')
  4. const which = require('which')
  5. const pacote = require('pacote')
  6. const { resolve } = require('node:path')
  7. const semver = require('semver')
  8. const { log, output } = require('proc-log')
  9. const ping = require('../utils/ping.js')
  10. const { defaults } = require('@npmcli/config/lib/definitions')
  11. const BaseCommand = require('../base-cmd.js')
  12. const maskLabel = mask => {
  13. const label = []
  14. if (mask & R_OK) {
  15. label.push('readable')
  16. }
  17. if (mask & W_OK) {
  18. label.push('writable')
  19. }
  20. if (mask & X_OK) {
  21. label.push('executable')
  22. }
  23. return label.join(', ')
  24. }
  25. const checks = [
  26. {
  27. // Ping is left in as a legacy command but is listed as "connection" to make more sense to more people
  28. groups: ['connection', 'ping', 'registry'],
  29. title: 'Connecting to the registry',
  30. cmd: 'checkPing',
  31. }, {
  32. groups: ['versions'],
  33. title: 'Checking npm version',
  34. cmd: 'getLatestNpmVersion',
  35. }, {
  36. groups: ['versions'],
  37. title: 'Checking node version',
  38. cmd: 'getLatestNodejsVersion',
  39. }, {
  40. groups: ['registry'],
  41. title: 'Checking configured npm registry',
  42. cmd: 'checkNpmRegistry',
  43. }, {
  44. groups: ['environment'],
  45. title: 'Checking for git executable in PATH',
  46. cmd: 'getGitPath',
  47. }, {
  48. groups: ['environment'],
  49. title: 'Checking for global bin folder in PATH',
  50. cmd: 'getBinPath',
  51. }, {
  52. groups: ['permissions', 'cache'],
  53. title: 'Checking permissions on cached files (this may take awhile)',
  54. cmd: 'checkCachePermission',
  55. windows: false,
  56. }, {
  57. groups: ['permissions'],
  58. title: 'Checking permissions on local node_modules (this may take awhile)',
  59. cmd: 'checkLocalModulesPermission',
  60. windows: false,
  61. }, {
  62. groups: ['permissions'],
  63. title: 'Checking permissions on global node_modules (this may take awhile)',
  64. cmd: 'checkGlobalModulesPermission',
  65. windows: false,
  66. }, {
  67. groups: ['permissions'],
  68. title: 'Checking permissions on local bin folder',
  69. cmd: 'checkLocalBinPermission',
  70. windows: false,
  71. }, {
  72. groups: ['permissions'],
  73. title: 'Checking permissions on global bin folder',
  74. cmd: 'checkGlobalBinPermission',
  75. windows: false,
  76. }, {
  77. groups: ['cache'],
  78. title: 'Verifying cache contents (this may take awhile)',
  79. cmd: 'verifyCachedFiles',
  80. windows: false,
  81. },
  82. // TODO:
  83. // group === 'dependencies'?
  84. // - ensure arborist.loadActual() runs without errors and no invalid edges
  85. // - ensure package-lock.json matches loadActual()
  86. // - verify loadActual without hidden lock file matches hidden lockfile
  87. // group === '???'
  88. // - verify all local packages have bins linked
  89. // What is the fix for these?
  90. ]
  91. class Doctor extends BaseCommand {
  92. static description = 'Check the health of your npm environment'
  93. static name = 'doctor'
  94. static params = ['registry']
  95. static ignoreImplicitWorkspace = false
  96. static usage = [`[${checks.flatMap(s => s.groups)
  97. .filter((value, index, self) => self.indexOf(value) === index && value !== 'ping')
  98. .join('] [')}]`]
  99. async exec (args) {
  100. log.info('doctor', 'Running checkup')
  101. let allOk = true
  102. const actions = this.actions(args)
  103. const chalk = this.npm.chalk
  104. for (const { title, cmd } of actions) {
  105. this.output(title)
  106. // TODO when we have an in progress indicator that could go here
  107. let result
  108. try {
  109. result = await this[cmd]()
  110. this.output(`${chalk.green('Ok')}${result ? `\n${result}` : ''}\n`)
  111. } catch (err) {
  112. allOk = false
  113. this.output(`${chalk.red('Not ok')}\n${chalk.cyan(err)}\n`)
  114. }
  115. }
  116. if (!allOk) {
  117. if (this.npm.silent) {
  118. throw new Error('Some problems found. Check logs or disable silent mode for recommendations.')
  119. } else {
  120. throw new Error('Some problems found. See above for recommendations.')
  121. }
  122. }
  123. }
  124. async checkPing () {
  125. log.info('doctor', 'Pinging registry')
  126. try {
  127. await ping({ ...this.npm.flatOptions, retry: false })
  128. return ''
  129. } catch (er) {
  130. if (/^E\d{3}$/.test(er.code || '')) {
  131. throw er.code.slice(1) + ' ' + er.message
  132. } else {
  133. throw er.message
  134. }
  135. }
  136. }
  137. async getLatestNpmVersion () {
  138. log.info('doctor', 'Getting npm package information')
  139. const latest = (await pacote.manifest('npm@latest', this.npm.flatOptions)).version
  140. if (semver.gte(this.npm.version, latest)) {
  141. return `current: v${this.npm.version}, latest: v${latest}`
  142. } else {
  143. throw `Use npm v${latest}`
  144. }
  145. }
  146. async getLatestNodejsVersion () {
  147. // XXX get the latest in the current major as well
  148. const current = process.version
  149. const currentRange = `^${current}`
  150. const url = 'https://nodejs.org/dist/index.json'
  151. log.info('doctor', 'Getting Node.js release information')
  152. const res = await npmFetch(url, { method: 'GET', ...this.npm.flatOptions })
  153. const data = await res.json()
  154. let maxCurrent = '0.0.0'
  155. let maxLTS = '0.0.0'
  156. for (const { lts, version } of data) {
  157. if (lts && semver.gt(version, maxLTS)) {
  158. maxLTS = version
  159. }
  160. if (semver.satisfies(version, currentRange) && semver.gt(version, maxCurrent)) {
  161. maxCurrent = version
  162. }
  163. }
  164. const recommended = semver.gt(maxCurrent, maxLTS) ? maxCurrent : maxLTS
  165. if (semver.gte(process.version, recommended)) {
  166. return `current: ${current}, recommended: ${recommended}`
  167. } else {
  168. throw `Use node ${recommended} (current: ${current})`
  169. }
  170. }
  171. async getBinPath () {
  172. log.info('doctor', 'getBinPath', 'Finding npm global bin in your PATH')
  173. if (!process.env.PATH.includes(this.npm.globalBin)) {
  174. throw new Error(`Add ${this.npm.globalBin} to your $PATH`)
  175. }
  176. return this.npm.globalBin
  177. }
  178. async checkCachePermission () {
  179. return this.checkFilesPermission(this.npm.cache, true, R_OK)
  180. }
  181. async checkLocalModulesPermission () {
  182. return this.checkFilesPermission(this.npm.localDir, true, R_OK | W_OK, true)
  183. }
  184. async checkGlobalModulesPermission () {
  185. return this.checkFilesPermission(this.npm.globalDir, false, R_OK)
  186. }
  187. async checkLocalBinPermission () {
  188. return this.checkFilesPermission(this.npm.localBin, false, R_OK | W_OK | X_OK, true)
  189. }
  190. async checkGlobalBinPermission () {
  191. return this.checkFilesPermission(this.npm.globalBin, false, X_OK)
  192. }
  193. async checkFilesPermission (root, shouldOwn, mask, missingOk) {
  194. let ok = true
  195. try {
  196. const uid = process.getuid()
  197. const gid = process.getgid()
  198. const files = new Set([root])
  199. for (const f of files) {
  200. const st = await lstat(f).catch(er => {
  201. // if it can't be missing, or if it can and the error wasn't that it was missing
  202. if (!missingOk || er.code !== 'ENOENT') {
  203. ok = false
  204. log.warn('doctor', 'checkFilesPermission', 'error getting info for ' + f)
  205. }
  206. })
  207. if (!st) {
  208. continue
  209. }
  210. if (shouldOwn && (uid !== st.uid || gid !== st.gid)) {
  211. log.warn('doctor', 'checkFilesPermission', 'should be owner of ' + f)
  212. ok = false
  213. }
  214. if (!st.isDirectory() && !st.isFile()) {
  215. continue
  216. }
  217. try {
  218. await access(f, mask)
  219. } catch {
  220. ok = false
  221. const msg = `Missing permissions on ${f} (expect: ${maskLabel(mask)})`
  222. log.error('doctor', 'checkFilesPermission', msg)
  223. continue
  224. }
  225. if (st.isDirectory()) {
  226. const entries = await readdir(f).catch(() => {
  227. ok = false
  228. log.warn('doctor', 'checkFilesPermission', 'error reading directory ' + f)
  229. return []
  230. })
  231. for (const entry of entries) {
  232. files.add(resolve(f, entry))
  233. }
  234. }
  235. }
  236. } finally {
  237. if (!ok) {
  238. throw (
  239. `Check the permissions of files in ${root}` +
  240. (shouldOwn ? ' (should be owned by current user)' : '')
  241. )
  242. } else {
  243. return ''
  244. }
  245. }
  246. }
  247. async getGitPath () {
  248. log.info('doctor', 'Finding git in your PATH')
  249. return await which('git').catch(er => {
  250. log.warn('doctor', 'getGitPath', er)
  251. throw new Error("Install git and ensure it's in your PATH.")
  252. })
  253. }
  254. async verifyCachedFiles () {
  255. log.info('doctor', 'verifyCachedFiles', 'Verifying the npm cache')
  256. const stats = await cacache.verify(this.npm.flatOptions.cache)
  257. const { badContentCount, reclaimedCount, missingContent, reclaimedSize } = stats
  258. if (badContentCount || reclaimedCount || missingContent) {
  259. if (badContentCount) {
  260. log.warn('doctor', 'verifyCachedFiles', `Corrupted content removed: ${badContentCount}`)
  261. }
  262. if (reclaimedCount) {
  263. log.warn(
  264. 'doctor',
  265. 'verifyCachedFiles',
  266. `Content garbage-collected: ${reclaimedCount} (${reclaimedSize} bytes)`
  267. )
  268. }
  269. if (missingContent) {
  270. log.warn('doctor', 'verifyCachedFiles', `Missing content: ${missingContent}`)
  271. }
  272. log.warn('doctor', 'verifyCachedFiles', 'Cache issues have been fixed')
  273. }
  274. log.info(
  275. 'doctor',
  276. 'verifyCachedFiles',
  277. `Verification complete. Stats: ${JSON.stringify(stats, null, 2)}`
  278. )
  279. return `verified ${stats.verifiedContent} tarballs`
  280. }
  281. async checkNpmRegistry () {
  282. if (this.npm.flatOptions.registry !== defaults.registry) {
  283. throw `Try \`npm config set registry=${defaults.registry}\``
  284. } else {
  285. return `using default registry (${defaults.registry})`
  286. }
  287. }
  288. output (...args) {
  289. // TODO display layer should do this
  290. if (!this.npm.silent) {
  291. output.standard(...args)
  292. }
  293. }
  294. actions (params) {
  295. return checks.filter(subcmd => {
  296. if (process.platform === 'win32' && subcmd.windows === false) {
  297. return false
  298. }
  299. if (params.length) {
  300. return params.some(param => subcmd.groups.includes(param))
  301. }
  302. return true
  303. })
  304. }
  305. }
  306. module.exports = Doctor