Team Ai
Datasetpublic

codekingpro/portable-devtools

sourceHugging Faceupdated 5mo agoView on Hugging Face
1likes14kdownloads
npm.js526 linesDownload Raw Back to lib
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 
codekingpro/portable-devtools · Team Ai