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