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