| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525 |
- const { resolve, dirname, join } = require('node:path')
- const Config = require('@npmcli/config')
- const which = require('which')
- const fs = require('node:fs/promises')
- const { definitions, flatten, nerfDarts, shorthands } = require('@npmcli/config/lib/definitions')
- const usage = require('./utils/npm-usage.js')
- const LogFile = require('./utils/log-file.js')
- const Timers = require('./utils/timers.js')
- const Display = require('./utils/display.js')
- const { log, time, output, META } = require('proc-log')
- const { redactLog: replaceInfo } = require('@npmcli/redact')
- const pkg = require('../package.json')
- const { deref } = require('./utils/cmd-list.js')
- const { jsonError, outputError } = require('./utils/output-error.js')
- class Npm {
- static get version () {
- return pkg.version
- }
- static cmd (c) {
- const command = deref(c)
- if (!command) {
- throw Object.assign(new Error(`Unknown command ${c}`), {
- code: 'EUNKNOWNCOMMAND',
- command: c,
- })
- }
- return require(`./commands/${command}`)
- }
- unrefPromises = []
- updateNotification = null
- argv = []
- #command = null
- #runId = new Date().toISOString().replace(/[.:]/g, '_')
- #title = 'npm'
- #argvClean = []
- #npmRoot = null
- #display = null
- #logFile = new LogFile()
- #timers = new Timers()
- // All these options are only used by tests in order to make testing more closely resemble real world usage.
- // 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.
- // XXX: make these options not necessary by refactoring @npmcli/config
- // - npmRoot: this is where npm looks for docs files and the builtin config
- // - argv: this allows tests to extend argv in the same way the argv would be passed in via a CLI arg.
- // - excludeNpmCwd: this is a hack to get @npmcli/config to stop walking up dirs to set a local prefix when it encounters the `npmRoot`.
- // 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
- constructor ({
- stdout = process.stdout,
- stderr = process.stderr,
- npmRoot = dirname(__dirname),
- argv = [],
- excludeNpmCwd = false,
- } = {}) {
- this.#display = new Display({ stdout, stderr })
- this.#npmRoot = npmRoot
- this.config = new Config({
- npmPath: this.#npmRoot,
- definitions,
- flatten,
- nerfDarts,
- shorthands,
- argv: [...process.argv, ...argv],
- excludeNpmCwd,
- warn: false,
- })
- }
- async load () {
- let err
- try {
- return await time.start('npm:load', () => this.#load())
- } catch (e) {
- err = e
- }
- return this.#handleError(err)
- }
- async #load () {
- await time.start('npm:load:whichnode', async () => {
- // TODO should we throw here?
- const node = await which(process.argv[0]).catch(() => {})
- if (node && node.toUpperCase() !== process.execPath.toUpperCase()) {
- log.verbose('node symlink', node)
- process.execPath = node
- this.config.execPath = node
- }
- })
- await time.start('npm:load:configload', () => this.config.load())
- // npm --versions
- if (this.config.get('versions', 'cli')) {
- this.argv = ['version']
- this.config.set('usage', false, 'cli')
- } else {
- this.argv = [...this.config.parsedArgv.remain]
- }
- // Remove first argv since that is our command as typed
- // Note that this might not be the actual name of the command due to aliases, etc.
- // But we use the raw form of it later in user output so it must be preserved as is.
- const commandArg = this.argv.shift()
- // This is the actual name of the command that will be run or undefined if deref could not find a match
- const command = deref(commandArg)
- await this.#display.load({
- command,
- loglevel: this.config.get('loglevel'),
- stdoutColor: this.color,
- stderrColor: this.logColor,
- timing: this.config.get('timing'),
- unicode: this.config.get('unicode'),
- progress: this.flatOptions.progress,
- json: this.config.get('json'),
- heading: this.config.get('heading'),
- })
- process.env.COLOR = this.color ? '1' : '0'
- // npm -v
- // return from here early so we don't create any caches/logfiles/timers etc
- if (this.config.get('version', 'cli')) {
- output.standard(this.version)
- return { exec: false }
- }
- // mkdir this separately since the logs dir can be set to a different location.
- // 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`)
- await time.start('npm:load:mkdirpcache', () =>
- fs.mkdir(this.cache, { recursive: true })
- .catch((e) => log.verbose('cache', `could not create cache: ${e}`)))
- // it's ok if this fails. user might have specified an invalid dir which we will tell them about at the end
- if (this.config.get('logs-max') > 0) {
- await time.start('npm:load:mkdirplogs', () =>
- fs.mkdir(this.#logsDir, { recursive: true })
- .catch((e) => log.verbose('logfile', `could not create logs-dir: ${e}`)))
- }
- // 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.
- // 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.
- time.start('npm:load:setTitle', () => {
- const { parsedArgv: { cooked, remain } } = this.config
- // Secrets are mostly in configs, so title is set using only the positional args to keep those from being leaked.
- // We still do a best effort replaceInfo.
- this.#title = ['npm'].concat(replaceInfo(remain)).join(' ').trim()
- process.title = this.#title
- // The cooked argv is also logged separately for debugging purposes.
- // It is cleaned as a best effort by replacing known secrets like basic auth password and strings that look like npm tokens.
- // 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.
- this.#argvClean = replaceInfo(cooked)
- log.verbose('title', this.title)
- log.verbose('argv', this.#argvClean.map(JSON.stringify).join(' '))
- })
- // logFile.load returns a promise that resolves when old logs are done being cleaned.
- // We save this promise to an array so that we can await it in tests to ensure more deterministic logging behavior.
- // 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.
- this.unrefPromises.push(this.#logFile.load({
- command,
- path: this.logPath,
- logsMax: this.config.get('logs-max'),
- timing: this.config.get('timing'),
- }))
- this.#timers.load({
- path: this.logPath,
- timing: this.config.get('timing'),
- })
- const configScope = this.config.get('scope')
- if (configScope && !/^@/.test(configScope)) {
- this.config.set('scope', `@${configScope}`, this.config.find('scope'))
- }
- if (this.config.get('force')) {
- log.warn('using --force', 'Recommended protections disabled.')
- }
- return { exec: true, command: commandArg, args: this.argv }
- }
- async exec (cmd, args = this.argv) {
- if (!this.#command) {
- let err
- try {
- await this.#exec(cmd, args)
- } catch (e) {
- err = e
- }
- return this.#handleError(err)
- } else {
- return this.#exec(cmd, args)
- }
- }
- // Call an npm command
- async #exec (cmd, args) {
- const Command = this.constructor.cmd(cmd)
- const command = new Command(this)
- // since 'test', 'start', 'stop', etc. commands re-enter this function to call the run command, we need to only set it one time.
- if (!this.#command) {
- this.#command = command
- process.env.npm_command = this.command
- }
- // Only log warnings for legacy commands without definitions or subcommands
- // Commands with definitions will handle warnings in base-cmd flags()
- // Commands with subcommands will delegate to the subcommand to handle warnings
- if (!Command.definitions && !Command.subcommands) {
- this.config.logWarnings()
- }
- // this needs to be rest after because some commands run this.npm.config.checkUnknown('publishConfig', key)
- this.config.warn = true
- return this.execCommandClass(command, args, [cmd])
- }
- // Unified command execution for both top-level commands and subcommands
- // Supports n-depth subcommands, workspaces, and definitions
- async execCommandClass (commandInstance, args, commandPath = []) {
- const Command = commandInstance.constructor
- const commandName = commandPath.join(':')
- // Handle subcommands if present
- if (Command.subcommands) {
- const subcommandName = args[0]
- // If help is requested without a subcommand, show main command help
- if (this.config.get('usage') && !subcommandName) {
- return output.standard(commandInstance.usage)
- }
- // If no subcommand provided, show usage error
- if (!subcommandName) {
- throw commandInstance.usageError()
- }
- // Check if the subcommand exists
- const SubCommand = Command.subcommands[subcommandName]
- if (!SubCommand) {
- throw commandInstance.usageError(`Unknown subcommand: ${subcommandName}`)
- }
- // Check if help is requested for the subcommand
- if (this.config.get('usage')) {
- const parentName = commandPath[0]
- return output.standard(SubCommand.getUsage(parentName))
- }
- // Create subcommand instance and recurse
- const subcommandInstance = new SubCommand(this)
- const subcommandArgs = args.slice(1) // Remove subcommand name from args
- const subcommandPath = [...commandPath, subcommandName]
- return time.start(`command:${subcommandPath.join(':')}`, () =>
- this.execCommandClass(subcommandInstance, subcommandArgs, subcommandPath))
- }
- // No subcommands - execute this command
- if (this.config.get('usage')) {
- return output.standard(commandInstance.usage)
- }
- let execWorkspaces = false
- const hasWsConfig = this.config.get('workspaces') || this.config.get('workspace').length
- // if cwd is a workspace, the default is set to [that workspace]
- const implicitWs = this.config.get('workspace', 'default').length
- // (-ws || -w foo) && (cwd is not a workspace || command is not ignoring implicit workspaces)
- if (hasWsConfig && (!implicitWs || !Command.ignoreImplicitWorkspace)) {
- if (this.global) {
- throw new Error('Workspaces not supported for global packages')
- }
- if (!Command.workspaces) {
- throw Object.assign(new Error('This command does not support workspaces.'), {
- code: 'ENOWORKSPACES',
- })
- }
- execWorkspaces = true
- }
- // Check dev engines if needed
- if (commandInstance.checkDevEngines && !this.global) {
- await commandInstance.checkDevEngines()
- }
- // Execute command with or without definitions
- if (Command.definitions) {
- // config.argv contains the full argv with flags (set by Config in production, by MockNpm in tests)
- // Pass depth so flags() knows how many command names to skip
- const [flags, positionalArgs] = commandInstance.flags(commandPath.length)
- return time.start(`command:${commandName}`, () =>
- execWorkspaces
- ? commandInstance.execWorkspaces(positionalArgs, flags)
- : commandInstance.exec(positionalArgs, flags))
- } else {
- // Legacy commands without definitions
- this.config.logWarnings()
- return time.start(`command:${commandName}`, () =>
- execWorkspaces ? commandInstance.execWorkspaces(args) : commandInstance.exec(args))
- }
- }
- // This gets called at the end of the exit handler and during any tests to cleanup all of our listeners
- // Everything in here should be synchronous
- unload () {
- this.#timers.off()
- this.#display.off()
- this.#logFile.off()
- }
- finish (err) {
- // Finish all our timer work, this will write the file if requested, end timers, etc
- this.#timers.finish({
- id: this.#runId,
- command: this.#argvClean,
- logfiles: this.logFiles,
- version: this.version,
- })
- output.flush({
- [META]: true,
- // json can be set during a command so we send the final value of it to the display layer here
- json: this.loaded && this.config.get('json'),
- jsonError: jsonError(err, this),
- })
- }
- exitErrorMessage () {
- if (this.logFiles.length) {
- return `A complete log of this run can be found in: ${this.logFiles}`
- }
- const logsMax = this.config.get('logs-max')
- if (logsMax <= 0) {
- // user specified no log file
- return `Log files were not written due to the config logs-max=${logsMax}`
- }
- // could be an error writing to the directory
- return `Log files were not written due to an error writing to the directory: ${this.#logsDir}` +
- '\nYou can rerun the command with `--loglevel=verbose` to see the logs in your terminal'
- }
- async #handleError (err) {
- if (err) {
- // Get the local package if it exists for a more helpful error message
- const localPkg = await require('@npmcli/package-json')
- .normalize(this.localPrefix)
- .then(p => p.content)
- .catch(() => null)
- Object.assign(err, this.#getError(err, { pkg: localPkg }))
- }
- this.finish(err)
- if (err) {
- throw err
- }
- }
- #getError (rawErr, opts) {
- const { files = [], ...error } = require('./utils/error-message.js').getError(rawErr, {
- npm: this,
- command: this.#command,
- ...opts,
- })
- const { writeFileSync } = require('node:fs')
- for (const [file, content] of files) {
- const filePath = `${this.logPath}${file}`
- const fileContent = `'Log files:\n${this.logFiles.join('\n')}\n\n${content.trim()}\n`
- try {
- writeFileSync(filePath, fileContent)
- error.detail.push(['', `\n\nFor a full report see:\n${filePath}`])
- } catch (fileErr) {
- log.warn('', `Could not write error message to ${file} due to ${fileErr}`)
- }
- }
- outputError(error)
- return error
- }
- get title () {
- return this.#title
- }
- get loaded () {
- return this.config.loaded
- }
- get version () {
- return this.constructor.version
- }
- get command () {
- return this.#command?.name
- }
- get flatOptions () {
- const { flat } = this.config
- flat.nodeVersion = process.version
- flat.npmVersion = pkg.version
- if (this.command) {
- flat.npmCommand = this.command
- }
- return flat
- }
- // 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)
- get color () {
- return this.flatOptions.color
- }
- get logColor () {
- return this.flatOptions.logColor
- }
- get noColorChalk () {
- return this.#display.chalk.noColor
- }
- get chalk () {
- return this.#display.chalk.stdout
- }
- get logChalk () {
- return this.#display.chalk.stderr
- }
- get global () {
- return this.config.get('global') || this.config.get('location') === 'global'
- }
- get silent () {
- return this.flatOptions.silent
- }
- get lockfileVersion () {
- return 2
- }
- get started () {
- return this.#timers.started
- }
- get logFiles () {
- return this.#logFile.files
- }
- get #logsDir () {
- return this.config.get('logs-dir') || join(this.cache, '_logs')
- }
- get logPath () {
- return resolve(this.#logsDir, `${this.#runId}-`)
- }
- get npmRoot () {
- return this.#npmRoot
- }
- get cache () {
- return this.config.get('cache')
- }
- get globalPrefix () {
- return this.config.globalPrefix
- }
- get localPrefix () {
- return this.config.localPrefix
- }
- get localPackage () {
- return this.config.localPackage
- }
- get globalDir () {
- return process.platform !== 'win32'
- ? resolve(this.globalPrefix, 'lib', 'node_modules')
- : resolve(this.globalPrefix, 'node_modules')
- }
- get localDir () {
- return resolve(this.localPrefix, 'node_modules')
- }
- get dir () {
- return this.global ? this.globalDir : this.localDir
- }
- get globalBin () {
- const b = this.globalPrefix
- return process.platform !== 'win32' ? resolve(b, 'bin') : b
- }
- get localBin () {
- return resolve(this.dir, '.bin')
- }
- get bin () {
- return this.global ? this.globalBin : this.localBin
- }
- get prefix () {
- return this.global ? this.globalPrefix : this.localPrefix
- }
- get usage () {
- return usage(this)
- }
- }
- module.exports = Npm
|