npm.js 17 KB

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