1const { resolve, dirname, join } = require('path') 2const Config = require('@npmcli/config') 3const which = require('which') 4const fs = require('fs/promises') 5 6// Patch the global fs module here at the app level 7require('graceful-fs').gracefulify(require('fs')) 8 9const { definitions, flatten, shorthands } = require('@npmcli/config/lib/definitions') 10const usage = require('./utils/npm-usage.js') 11const LogFile = require('./utils/log-file.js') 12const Timers = require('./utils/timers.js') 13const Display = require('./utils/display.js') 14const log = require('./utils/log-shim') 15const replaceInfo = require('./utils/replace-info.js') 16const updateNotifier = require('./utils/update-notifier.js') 17const pkg = require('../package.json') 18const { deref } = require('./utils/cmd-list.js') 19 20class Npm { 21 static get version () { 22 return pkg.version 23 } 24 25 static cmd (c) { 26 const command = deref(c) 27 if (!command) { 28 throw Object.assign(new Error(`Unknown command ${c}`), { 29 code: 'EUNKNOWNCOMMAND', 30 }) 31 } 32 return require(`./commands/${command}.js`) 33 } 34 35 updateNotification = null 36 loadErr = null 37 argv = [] 38 39 #command = null 40 #runId = new Date().toISOString().replace(/[.:]/g, '_') 41 #loadPromise = null 42 #title = 'npm' 43 #argvClean = [] 44 #npmRoot = null 45 #warnedNonDashArg = false 46 47 #chalk = null 48 #logChalk = null 49 #noColorChalk = null 50 51 #outputBuffer = [] 52 #logFile = new LogFile() 53 #display = new Display() 54 #timers = new Timers({ 55 start: 'npm', 56 listener: (name, ms) => { 57 const args = ['timing', name, `Completed in ${ms}ms`] 58 this.#logFile.log(...args) 59 this.#display.log(...args) 60 }, 61 }) 62 63 // all these options are only used by tests in order to make testing more 64 // closely resemble real world usage. for now, npm has no programmatic API so 65 // it is ok to add stuff here, but we should not rely on it more than 66 // necessary. XXX: make these options not necessary by refactoring @npmcli/config 67 // - npmRoot: this is where npm looks for docs files and the builtin config 68 // - argv: this allows tests to extend argv in the same way the argv would 69 // be passed in via a CLI arg. 70 // - excludeNpmCwd: this is a hack to get @npmcli/config to stop walking up 71 // dirs to set a local prefix when it encounters the `npmRoot`. this 72 // allows tests created by tap inside this repo to not set the local 73 // prefix to `npmRoot` since that is the first dir it would encounter when 74 // doing implicit detection 75 constructor ({ npmRoot = dirname(__dirname), argv = [], excludeNpmCwd = false } = {}) { 76 this.#npmRoot = npmRoot 77 this.config = new Config({ 78 npmPath: this.#npmRoot, 79 definitions, 80 flatten, 81 shorthands, 82 argv: [...process.argv, ...argv], 83 excludeNpmCwd, 84 }) 85 } 86 87 get version () { 88 return this.constructor.version 89 } 90 91 setCmd (cmd) { 92 const Command = Npm.cmd(cmd) 93 const command = new Command(this) 94 95 // since 'test', 'start', 'stop', etc. commands re-enter this function 96 // to call the run-script command, we need to only set it one time. 97 if (!this.#command) { 98 this.#command = command 99 process.env.npm_command = this.command 100 } 101 102 return command 103 } 104 105 // Call an npm command 106 // TODO: tests are currently the only time the second 107 // parameter of args is used. When called via `lib/cli.js` the config is 108 // loaded and this.argv is set to the remaining command line args. We should 109 // consider testing the CLI the same way it is used and not allow args to be 110 // passed in directly. 111 async exec (cmd, args = this.argv) { 112 const command = this.setCmd(cmd) 113 114 const timeEnd = this.time(`command:${cmd}`) 115 116 // this is async but we dont await it, since its ok if it doesnt 117 // finish before the command finishes running. it uses command and argv 118 // so it must be initiated here, after the command name is set 119 // eslint-disable-next-line promise/catch-or-return 120 updateNotifier(this).then((msg) => (this.updateNotification = msg)) 121 122 // Options are prefixed by a hyphen-minus (-, \u2d). 123 // Other dash-type chars look similar but are invalid. 124 if (!this.#warnedNonDashArg) { 125 const nonDashArgs = args.filter(a => /^[\u2010-\u2015\u2212\uFE58\uFE63\uFF0D]/.test(a)) 126 if (nonDashArgs.length) { 127 this.#warnedNonDashArg = true 128 log.error( 129 'arg', 130 'Argument starts with non-ascii dash, this is probably invalid:', 131 nonDashArgs.join(', ') 132 ) 133 } 134 } 135 136 return command.cmdExec(args).finally(timeEnd) 137 } 138 139 async load () { 140 if (!this.#loadPromise) { 141 this.#loadPromise = this.time('npm:load', () => this.#load().catch((er) => { 142 this.loadErr = er 143 throw er 144 })) 145 } 146 return this.#loadPromise 147 } 148 149 get loaded () { 150 return this.config.loaded 151 } 152 153 // This gets called at the end of the exit handler and 154 // during any tests to cleanup all of our listeners 155 // Everything in here should be synchronous 156 unload () { 157 this.#timers.off() 158 this.#display.off() 159 this.#logFile.off() 160 } 161 162 time (name, fn) { 163 return this.#timers.time(name, fn) 164 } 165 166 writeTimingFile () { 167 this.#timers.writeFile({ 168 id: this.#runId, 169 command: this.#argvClean, 170 logfiles: this.logFiles, 171 version: this.version, 172 }) 173 } 174 175 get title () { 176 return this.#title 177 } 178 179 set title (t) { 180 process.title = t 181 this.#title = t 182 } 183 184 async #load () { 185 await this.time('npm:load:whichnode', async () => { 186 // TODO should we throw here? 187 const node = await which(process.argv[0]).catch(() => {}) 188 if (node && node.toUpperCase() !== process.execPath.toUpperCase()) { 189 log.verbose('node symlink', node) 190 process.execPath = node 191 this.config.execPath = node 192 } 193 }) 194 195 await this.time('npm:load:configload', () => this.config.load()) 196 197 // get createSupportsColor from chalk directly if this lands 198 // https://github.com/chalk/chalk/pull/600 199 const [{ Chalk }, { createSupportsColor }] = await Promise.all([ 200 import('chalk'), 201 import('supports-color'), 202 ]) 203 this.#noColorChalk = new Chalk({ level: 0 }) 204 // we get the chalk level based on a null stream meaning chalk will only use 205 // what it knows about the environment to get color support since we already 206 // determined in our definitions that we want to show colors. 207 const level = Math.max(createSupportsColor(null).level, 1) 208 this.#chalk = this.color ? new Chalk({ level }) : this.#noColorChalk 209 this.#logChalk = this.logColor ? new Chalk({ level }) : this.#noColorChalk 210 211 // mkdir this separately since the logs dir can be set to 212 // a different location. if this fails, then we don't have 213 // a cache dir, but we don't want to fail immediately since 214 // the command might not need a cache dir (like `npm --version`) 215 await this.time('npm:load:mkdirpcache', () => 216 fs.mkdir(this.cache, { recursive: true }) 217 .catch((e) => log.verbose('cache', `could not create cache: ${e}`))) 218 219 // its ok if this fails. user might have specified an invalid dir 220 // which we will tell them about at the end 221 await this.time('npm:load:mkdirplogs', () => 222 fs.mkdir(this.logsDir, { recursive: true }) 223 .catch((e) => log.verbose('logfile', `could not create logs-dir: ${e}`))) 224 225 // note: this MUST be shorter than the actual argv length, because it 226 // uses the same memory, so node will truncate it if it's too long. 227 this.time('npm:load:setTitle', () => { 228 const { parsedArgv: { cooked, remain } } = this.config 229 this.argv = remain 230 // Secrets are mostly in configs, so title is set using only the positional args 231 // to keep those from being leaked. 232 this.title = ['npm'].concat(replaceInfo(remain)).join(' ').trim() 233 // The cooked argv is also logged separately for debugging purposes. It is 234 // cleaned as a best effort by replacing known secrets like basic auth 235 // password and strings that look like npm tokens. XXX: for this to be 236 // safer the config should create a sanitized version of the argv as it 237 // has the full context of what each option contains. 238 this.#argvClean = replaceInfo(cooked) 239 log.verbose('title', this.title) 240 log.verbose('argv', this.#argvClean.map(JSON.stringify).join(' ')) 241 }) 242 243 this.time('npm:load:display', () => { 244 this.#display.load({ 245 // Use logColor since that is based on stderr 246 color: this.logColor, 247 chalk: this.logChalk, 248 progress: this.flatOptions.progress, 249 silent: this.silent, 250 timing: this.config.get('timing'), 251 loglevel: this.config.get('loglevel'), 252 unicode: this.config.get('unicode'), 253 heading: this.config.get('heading'), 254 }) 255 process.env.COLOR = this.color ? '1' : '0' 256 }) 257 258 this.time('npm:load:logFile', () => { 259 this.#logFile.load({ 260 path: this.logPath, 261 logsMax: this.config.get('logs-max'), 262 }) 263 log.verbose('logfile', this.#logFile.files[0] || 'no logfile created') 264 }) 265 266 this.time('npm:load:timers', () => 267 this.#timers.load({ 268 path: this.config.get('timing') ? this.logPath : null, 269 }) 270 ) 271 272 this.time('npm:load:configScope', () => { 273 const configScope = this.config.get('scope') 274 if (configScope && !/^@/.test(configScope)) { 275 this.config.set('scope', `@${configScope}`, this.config.find('scope')) 276 } 277 }) 278 279 if (this.config.get('force')) { 280 log.warn('using --force', 'Recommended protections disabled.') 281 } 282 } 283 284 get isShellout () { 285 return this.#command?.constructor?.isShellout 286 } 287 288 get command () { 289 return this.#command?.name 290 } 291 292 get flatOptions () { 293 const { flat } = this.config 294 flat.nodeVersion = process.version 295 flat.npmVersion = pkg.version 296 if (this.command) { 297 flat.npmCommand = this.command 298 } 299 return flat 300 } 301 302 // color and logColor are a special derived values that takes into 303 // consideration not only the config, but whether or not we are operating 304 // in a tty with the associated output (stdout/stderr) 305 get color () { 306 return this.flatOptions.color 307 } 308 309 get logColor () { 310 return this.flatOptions.logColor 311 } 312 313 get noColorChalk () { 314 return this.#noColorChalk 315 } 316 317 get chalk () { 318 return this.#chalk 319 } 320 321 get logChalk () { 322 return this.#logChalk 323 } 324 325 get global () { 326 return this.config.get('global') || this.config.get('location') === 'global' 327 } 328 329 get silent () { 330 return this.flatOptions.silent 331 } 332 333 get lockfileVersion () { 334 return 2 335 } 336 337 get unfinishedTimers () { 338 return this.#timers.unfinished 339 } 340 341 get finishedTimers () { 342 return this.#timers.finished 343 } 344 345 get started () { 346 return this.#timers.started 347 } 348 349 get logFiles () { 350 return this.#logFile.files 351 } 352 353 get logsDir () { 354 return this.config.get('logs-dir') || join(this.cache, '_logs') 355 } 356 357 get logPath () { 358 return resolve(this.logsDir, `${this.#runId}-`) 359 } 360 361 get timingFile () { 362 return this.#timers.file 363 } 364 365 get npmRoot () { 366 return this.#npmRoot 367 } 368 369 get cache () { 370 return this.config.get('cache') 371 } 372 373 set cache (r) { 374 this.config.set('cache', r) 375 } 376 377 get globalPrefix () { 378 return this.config.globalPrefix 379 } 380 381 set globalPrefix (r) { 382 this.config.globalPrefix = r 383 } 384 385 get localPrefix () { 386 return this.config.localPrefix 387 } 388 389 set localPrefix (r) { 390 this.config.localPrefix = r 391 } 392 393 get localPackage () { 394 return this.config.localPackage 395 } 396 397 get globalDir () { 398 return process.platform !== 'win32' 399 ? resolve(this.globalPrefix, 'lib', 'node_modules') 400 : resolve(this.globalPrefix, 'node_modules') 401 } 402 403 get localDir () { 404 return resolve(this.localPrefix, 'node_modules') 405 } 406 407 get dir () { 408 return this.global ? this.globalDir : this.localDir 409 } 410 411 get globalBin () { 412 const b = this.globalPrefix 413 return process.platform !== 'win32' ? resolve(b, 'bin') : b 414 } 415 416 get localBin () { 417 return resolve(this.dir, '.bin') 418 } 419 420 get bin () { 421 return this.global ? this.globalBin : this.localBin 422 } 423 424 get prefix () { 425 return this.global ? this.globalPrefix : this.localPrefix 426 } 427 428 set prefix (r) { 429 const k = this.global ? 'globalPrefix' : 'localPrefix' 430 this[k] = r 431 } 432 433 get usage () { 434 return usage(this) 435 } 436 437 // output to stdout in a progress bar compatible way 438 output (...msg) { 439 log.clearProgress() 440 // eslint-disable-next-line no-console 441 console.log(...msg) 442 log.showProgress() 443 } 444 445 outputBuffer (item) { 446 this.#outputBuffer.push(item) 447 } 448 449 flushOutput (jsonError) { 450 if (!jsonError && !this.#outputBuffer.length) { 451 return 452 } 453 454 if (this.config.get('json')) { 455 const jsonOutput = this.#outputBuffer.reduce((acc, item) => { 456 if (typeof item === 'string') { 457 // try to parse it as json in case its a string 458 try { 459 item = JSON.parse(item) 460 } catch { 461 return acc 462 } 463 } 464 return { ...acc, ...item } 465 }, {}) 466 this.output(JSON.stringify({ ...jsonOutput, ...jsonError }, null, 2)) 467 } else { 468 for (const item of this.#outputBuffer) { 469 this.output(item) 470 } 471 } 472 473 this.#outputBuffer.length = 0 474 } 475 476 outputError (...msg) { 477 log.clearProgress() 478 // eslint-disable-next-line no-console 479 console.error(...msg) 480 log.showProgress() 481 } 482} 483module.exports = Npm 484