codekingpro/portable-devtools
114k
1const { resolve, dirname, join } = require('node:path')
2const Config = require('@npmcli/config')
3const which = require('which')
4const fs = require('node:fs/promises')
5const { definitions, flatten, nerfDarts, shorthands } = require('@npmcli/config/lib/definitions')
6const usage = require('./utils/npm-usage.js')
7const LogFile = require('./utils/log-file.js')
8const Timers = require('./utils/timers.js')
9const Display = require('./utils/display.js')
10const { log, time, output, META } = require('proc-log')
11const { redactLog: replaceInfo } = require('@npmcli/redact')
12const pkg = require('../package.json')
13const { deref } = require('./utils/cmd-list.js')
14const { jsonError, outputError } = require('./utils/output-error.js')
15
16class Npm {
17 static get version () {
18 return pkg.version
19 }
20
21 static cmd (c) {
22 const command = deref(c)
23 if (!command) {
24 throw Object.assign(new Error(`Unknown command ${c}`), {
25 code: 'EUNKNOWNCOMMAND',
26 command: c,
27 })
28 }
29 return require(`./commands/${command}`)
30 }
31
32 unrefPromises = []
33 updateNotification = null
34 argv = []
35
36 #command = null
37 #runId = new Date().toISOString().replace(/[.:]/g, '_')
38 #title = 'npm'
39 #argvClean = []
40 #npmRoot = null
41
42 #display = null
43 #logFile = new LogFile()
44 #timers = new Timers()
45
46 // All these options are only used by tests in order to make testing more closely resemble real world usage.
47 // For now, npm has no programmatic API so it is ok to add stuff here, but we should not rely on it more than necessary.
48 // XXX: make these options not necessary by refactoring @npmcli/config
49 // - npmRoot: this is where npm looks for docs files and the builtin config
50 // - argv: this allows tests to extend argv in the same way the argv would be passed in via a CLI arg.
51 // - excludeNpmCwd: this is a hack to get @npmcli/config to stop walking up dirs to set a local prefix when it encounters the `npmRoot`.
52 // this allows tests created by tap inside this repo to not set the local prefix to `npmRoot` since that is the first dir it would encounter when doing implicit detection
53 constructor ({
54 stdout = process.stdout,
55 stderr = process.stderr,
56 npmRoot = dirname(__dirname),
57 argv = [],
58 excludeNpmCwd = false,
59 } = {}) {
60 this.#display = new Display({ stdout, stderr })
61 this.#npmRoot = npmRoot
62 this.config = new Config({
63 npmPath: this.#npmRoot,
64 definitions,
65 flatten,
66 nerfDarts,
67 shorthands,
68 argv: [...process.argv, ...argv],
69 excludeNpmCwd,
70 warn: false,
71 })
72 }
73
74 async load () {
75 let err
76 try {
77 return await time.start('npm:load', () => this.#load())
78 } catch (e) {
79 err = e
80 }
81 return this.#handleError(err)
82 }
83
84 async #load () {
85 await time.start('npm:load:whichnode', async () => {
86 // TODO should we throw here?
87 const node = await which(process.argv[0]).catch(() => {})
88 if (node && node.toUpperCase() !== process.execPath.toUpperCase()) {
89 log.verbose('node symlink', node)
90 process.execPath = node
91 this.config.execPath = node
92 }
93 })
94
95 await time.start('npm:load:configload', () => this.config.load())
96
97 // npm --versions
98 if (this.config.get('versions', 'cli')) {
99 this.argv = ['version']
100 this.config.set('usage', false, 'cli')
101 } else {
102 this.argv = [...this.config.parsedArgv.remain]
103 }
104
105 // Remove first argv since that is our command as typed
106 // Note that this might not be the actual name of the command due to aliases, etc.
107 // But we use the raw form of it later in user output so it must be preserved as is.
108 const commandArg = this.argv.shift()
109
110 // This is the actual name of the command that will be run or undefined if deref could not find a match
111 const command = deref(commandArg)
112
113 await this.#display.load({
114 command,
115 loglevel: this.config.get('loglevel'),
116 stdoutColor: this.color,
117 stderrColor: this.logColor,
118 timing: this.config.get('timing'),
119 unicode: this.config.get('unicode'),
120 progress: this.flatOptions.progress,
121 json: this.config.get('json'),
122 heading: this.config.get('heading'),
123 })
124 process.env.COLOR = this.color ? '1' : '0'
125
126 // npm -v
127 // return from here early so we don't create any caches/logfiles/timers etc
128 if (this.config.get('version', 'cli')) {
129 output.standard(this.version)
130 return { exec: false }
131 }
132
133 // mkdir this separately since the logs dir can be set to a different location.
134 // if this fails, then we don't have a cache dir, but we don't want to fail immediately since the command might not need a cache dir (like `npm --version`)
135 await time.start('npm:load:mkdirpcache', () =>
136 fs.mkdir(this.cache, { recursive: true })
137 .catch((e) => log.verbose('cache', `could not create cache: ${e}`)))
138
139 // it's ok if this fails. user might have specified an invalid dir which we will tell them about at the end
140 if (this.config.get('logs-max') > 0) {
141 await time.start('npm:load:mkdirplogs', () =>
142 fs.mkdir(this.#logsDir, { recursive: true })
143 .catch((e) => log.verbose('logfile', `could not create logs-dir: ${e}`)))
144 }
145
146 // note: this MUST be shorter than the actual argv length, because it uses the same memory, so node will truncate it if it's too long.
147 // We time this because setting process.title is slow sometimes but we have to do it for security reasons. But still helpful to know how slow it is.
148 time.start('npm:load:setTitle', () => {
149 const { parsedArgv: { cooked, remain } } = this.config
150 // Secrets are mostly in configs, so title is set using only the positional args to keep those from being leaked.
151 // We still do a best effort replaceInfo.
152 this.#title = ['npm'].concat(replaceInfo(remain)).join(' ').trim()
153 process.title = this.#title
154 // The cooked argv is also logged separately for debugging purposes.
155 // It is cleaned as a best effort by replacing known secrets like basic auth password and strings that look like npm tokens.
156 // XXX: for this to be safer the config should create a sanitized version of the argv as it has the full context of what each option contains.
157 this.#argvClean = replaceInfo(cooked)
158 log.verbose('title', this.title)
159 log.verbose('argv', this.#argvClean.map(JSON.stringify).join(' '))
160 })
161
162 // logFile.load returns a promise that resolves when old logs are done being cleaned.
163 // We save this promise to an array so that we can await it in tests to ensure more deterministic logging behavior.
164 // The process will also hang open if this were to take a long time to resolve, but that is why process.exit is called explicitly in the exit-handler.
165 this.unrefPromises.push(this.#logFile.load({
166 command,
167 path: this.logPath,
168 logsMax: this.config.get('logs-max'),
169 timing: this.config.get('timing'),
170 }))
171
172 this.#timers.load({
173 path: this.logPath,
174 timing: this.config.get('timing'),
175 })
176
177 const configScope = this.config.get('scope')
178 if (configScope && !/^@/.test(configScope)) {
179 this.config.set('scope', `@${configScope}`, this.config.find('scope'))
180 }
181
182 if (this.config.get('force')) {
183 log.warn('using --force', 'Recommended protections disabled.')
184 }
185
186 return { exec: true, command: commandArg, args: this.argv }
187 }
188
189 async exec (cmd, args = this.argv) {
190 if (!this.#command) {
191 let err
192 try {
193 await this.#exec(cmd, args)
194 } catch (e) {
195 err = e
196 }
197 return this.#handleError(err)
198 } else {
199 return this.#exec(cmd, args)
200 }
201 }
202
203 // Call an npm command
204 async #exec (cmd, args) {
205 const Command = this.constructor.cmd(cmd)
206 const command = new Command(this)
207
208 // since 'test', 'start', 'stop', etc. commands re-enter this function to call the run command, we need to only set it one time.
209 if (!this.#command) {
210 this.#command = command
211 process.env.npm_command = this.command
212 }
213
214 // Only log warnings for legacy commands without definitions or subcommands
215 // Commands with definitions will handle warnings in base-cmd flags()
216 // Commands with subcommands will delegate to the subcommand to handle warnings
217 if (!Command.definitions && !Command.subcommands) {
218 this.config.logWarnings()
219 }
220
221 // this needs to be rest after because some commands run this.npm.config.checkUnknown('publishConfig', key)
222 this.config.warn = true
223
224 return this.execCommandClass(command, args, [cmd])
225 }
226
227 // Unified command execution for both top-level commands and subcommands
228 // Supports n-depth subcommands, workspaces, and definitions
229 async execCommandClass (commandInstance, args, commandPath = []) {
230 const Command = commandInstance.constructor
231 const commandName = commandPath.join(':')
232
233 // Handle subcommands if present
234 if (Command.subcommands) {
235 const subcommandName = args[0]
236
237 // If help is requested without a subcommand, show main command help
238 if (this.config.get('usage') && !subcommandName) {
239 return output.standard(commandInstance.usage)
240 }
241
242 // If no subcommand provided, show usage error
243 if (!subcommandName) {
244 throw commandInstance.usageError()
245 }
246
247 // Check if the subcommand exists
248 const SubCommand = Command.subcommands[subcommandName]
249 if (!SubCommand) {
250 throw commandInstance.usageError(`Unknown subcommand: ${subcommandName}`)
251 }
252
253 // Check if help is requested for the subcommand
254 if (this.config.get('usage')) {
255 const parentName = commandPath[0]
256 return output.standard(SubCommand.getUsage(parentName))
257 }
258
259 // Create subcommand instance and recurse
260 const subcommandInstance = new SubCommand(this)
261 const subcommandArgs = args.slice(1) // Remove subcommand name from args
262 const subcommandPath = [...commandPath, subcommandName]
263
264 return time.start(`command:${subcommandPath.join(':')}`, () =>
265 this.execCommandClass(subcommandInstance, subcommandArgs, subcommandPath))
266 }
267
268 // No subcommands - execute this command
269 if (this.config.get('usage')) {
270 return output.standard(commandInstance.usage)
271 }
272
273 let execWorkspaces = false
274 const hasWsConfig = this.config.get('workspaces') || this.config.get('workspace').length
275 // if cwd is a workspace, the default is set to [that workspace]
276 const implicitWs = this.config.get('workspace', 'default').length
277 // (-ws || -w foo) && (cwd is not a workspace || command is not ignoring implicit workspaces)
278 if (hasWsConfig && (!implicitWs || !Command.ignoreImplicitWorkspace)) {
279 if (this.global) {
280 throw new Error('Workspaces not supported for global packages')
281 }
282 if (!Command.workspaces) {
283 throw Object.assign(new Error('This command does not support workspaces.'), {
284 code: 'ENOWORKSPACES',
285 })
286 }
287 execWorkspaces = true
288 }
289
290 // Check dev engines if needed
291 if (commandInstance.checkDevEngines && !this.global) {
292 await commandInstance.checkDevEngines()
293 }
294
295 // Execute command with or without definitions
296 if (Command.definitions) {
297 // config.argv contains the full argv with flags (set by Config in production, by MockNpm in tests)
298 // Pass depth so flags() knows how many command names to skip
299 const [flags, positionalArgs] = commandInstance.flags(commandPath.length)
300 return time.start(`command:${commandName}`, () =>
301 execWorkspaces
302 ? commandInstance.execWorkspaces(positionalArgs, flags)
303 : commandInstance.exec(positionalArgs, flags))
304 } else {
305 // Legacy commands without definitions
306 this.config.logWarnings()
307 return time.start(`command:${commandName}`, () =>
308 execWorkspaces ? commandInstance.execWorkspaces(args) : commandInstance.exec(args))
309 }
310 }
311
312 // This gets called at the end of the exit handler and during any tests to cleanup all of our listeners
313 // Everything in here should be synchronous
314 unload () {
315 this.#timers.off()
316 this.#display.off()
317 this.#logFile.off()
318 }
319
320 finish (err) {
321 // Finish all our timer work, this will write the file if requested, end timers, etc
322 this.#timers.finish({
323 id: this.#runId,
324 command: this.#argvClean,
325 logfiles: this.logFiles,
326 version: this.version,
327 })
328
329 output.flush({
330 [META]: true,
331 // json can be set during a command so we send the final value of it to the display layer here
332 json: this.loaded && this.config.get('json'),
333 jsonError: jsonError(err, this),
334 })
335 }
336
337 exitErrorMessage () {
338 if (this.logFiles.length) {
339 return `A complete log of this run can be found in: ${this.logFiles}`
340 }
341
342 const logsMax = this.config.get('logs-max')
343 if (logsMax <= 0) {
344 // user specified no log file
345 return `Log files were not written due to the config logs-max=${logsMax}`
346 }
347
348 // could be an error writing to the directory
349 return `Log files were not written due to an error writing to the directory: ${this.#logsDir}` +
350 '\nYou can rerun the command with `--loglevel=verbose` to see the logs in your terminal'
351 }
352
353 async #handleError (err) {
354 if (err) {
355 // Get the local package if it exists for a more helpful error message
356 const localPkg = await require('@npmcli/package-json')
357 .normalize(this.localPrefix)
358 .then(p => p.content)
359 .catch(() => null)
360 Object.assign(err, this.#getError(err, { pkg: localPkg }))
361 }
362
363 this.finish(err)
364
365 if (err) {
366 throw err
367 }
368 }
369
370 #getError (rawErr, opts) {
371 const { files = [], ...error } = require('./utils/error-message.js').getError(rawErr, {
372 npm: this,
373 command: this.#command,
374 ...opts,
375 })
376
377 const { writeFileSync } = require('node:fs')
378 for (const [file, content] of files) {
379 const filePath = `${this.logPath}${file}`
380 const fileContent = `'Log files:\n${this.logFiles.join('\n')}\n\n${content.trim()}\n`
381 try {
382 writeFileSync(filePath, fileContent)
383 error.detail.push(['', `\n\nFor a full report see:\n${filePath}`])
384 } catch (fileErr) {
385 log.warn('', `Could not write error message to ${file} due to ${fileErr}`)
386 }
387 }
388
389 outputError(error)
390
391 return error
392 }
393
394 get title () {
395 return this.#title
396 }
397
398 get loaded () {
399 return this.config.loaded
400 }
401
402 get version () {
403 return this.constructor.version
404 }
405
406 get command () {
407 return this.#command?.name
408 }
409
410 get flatOptions () {
411 const { flat } = this.config
412 flat.nodeVersion = process.version
413 flat.npmVersion = pkg.version
414 if (this.command) {
415 flat.npmCommand = this.command
416 }
417 return flat
418 }
419
420 // color and logColor are a special derived values that takes into consideration not only the config, but whether or not we are operating in a tty with the associated output (stdout/stderr)
421 get color () {
422 return this.flatOptions.color
423 }
424
425 get logColor () {
426 return this.flatOptions.logColor
427 }
428
429 get noColorChalk () {
430 return this.#display.chalk.noColor
431 }
432
433 get chalk () {
434 return this.#display.chalk.stdout
435 }
436
437 get logChalk () {
438 return this.#display.chalk.stderr
439 }
440
441 get global () {
442 return this.config.get('global') || this.config.get('location') === 'global'
443 }
444
445 get silent () {
446 return this.flatOptions.silent
447 }
448
449 get lockfileVersion () {
450 return 2
451 }
452
453 get started () {
454 return this.#timers.started
455 }
456
457 get logFiles () {
458 return this.#logFile.files
459 }
460
461 get #logsDir () {
462 return this.config.get('logs-dir') || join(this.cache, '_logs')
463 }
464
465 get logPath () {
466 return resolve(this.#logsDir, `${this.#runId}-`)
467 }
468
469 get npmRoot () {
470 return this.#npmRoot
471 }
472
473 get cache () {
474 return this.config.get('cache')
475 }
476
477 get globalPrefix () {
478 return this.config.globalPrefix
479 }
480
481 get localPrefix () {
482 return this.config.localPrefix
483 }
484
485 get localPackage () {
486 return this.config.localPackage
487 }
488
489 get globalDir () {
490 return process.platform !== 'win32'
491 ? resolve(this.globalPrefix, 'lib', 'node_modules')
492 : resolve(this.globalPrefix, 'node_modules')
493 }
494
495 get localDir () {
496 return resolve(this.localPrefix, 'node_modules')
497 }
498
499 get dir () {
500 return this.global ? this.globalDir : this.localDir
501 }
502
503 get globalBin () {
504 const b = this.globalPrefix
505 return process.platform !== 'win32' ? resolve(b, 'bin') : b
506 }
507
508 get localBin () {
509 return resolve(this.dir, '.bin')
510 }
511
512 get bin () {
513 return this.global ? this.globalBin : this.localBin
514 }
515
516 get prefix () {
517 return this.global ? this.globalPrefix : this.localPrefix
518 }
519
520 get usage () {
521 return usage(this)
522 }
523}
524
525module.exports = Npm
526 