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 // it's ok if this fails. user might have specified an invalid dir 220 // which we will tell them about at the end 221 if (this.config.get('logs-max') > 0) { 222 await this.time('npm:load:mkdirplogs', () => 223 fs.mkdir(this.logsDir, { recursive: true }) 224 .catch((e) => log.verbose('logfile', `could not create logs-dir: ${e}`))) 225 } 226 227 // note: this MUST be shorter than the actual argv length, because it 228 // uses the same memory, so node will truncate it if it's too long. 229 this.time('npm:load:setTitle', () => { 230 const { parsedArgv: { cooked, remain } } = this.config 231 this.argv = remain 232 // Secrets are mostly in configs, so title is set using only the positional args 233 // to keep those from being leaked. 234 this.title = ['npm'].concat(replaceInfo(remain)).join(' ').trim() 235 // The cooked argv is also logged separately for debugging purposes. It is 236 // cleaned as a best effort by replacing known secrets like basic auth 237 // password and strings that look like npm tokens. XXX: for this to be 238 // safer the config should create a sanitized version of the argv as it 239 // has the full context of what each option contains. 240 this.#argvClean = replaceInfo(cooked) 241 log.verbose('title', this.title) 242 log.verbose('argv', this.#argvClean.map(JSON.stringify).join(' ')) 243 }) 244 245 this.time('npm:load:display', () => { 246 this.#display.load({ 247 // Use logColor since that is based on stderr 248 color: this.logColor, 249 chalk: this.logChalk, 250 progress: this.flatOptions.progress, 251 silent: this.silent, 252 timing: this.config.get('timing'), 253 loglevel: this.config.get('loglevel'), 254 unicode: this.config.get('unicode'), 255 heading: this.config.get('heading'), 256 }) 257 process.env.COLOR = this.color ? '1' : '0' 258 }) 259 260 this.time('npm:load:logFile', () => { 261 this.#logFile.load({ 262 path: this.logPath, 263 logsMax: this.config.get('logs-max'), 264 }) 265 log.verbose('logfile', this.#logFile.files[0] || 'no logfile created') 266 }) 267 268 this.time('npm:load:timers', () => 269 this.#timers.load({ 270 path: this.config.get('timing') ? this.logPath : null, 271 }) 272 ) 273 274 this.time('npm:load:configScope', () => { 275 const configScope = this.config.get('scope') 276 if (configScope && !/^@/.test(configScope)) { 277 this.config.set('scope', `@${configScope}`, this.config.find('scope')) 278 } 279 }) 280 281 if (this.config.get('force')) { 282 log.warn('using --force', 'Recommended protections disabled.') 283 } 284 } 285 286 get isShellout () { 287 return this.#command?.constructor?.isShellout 288 } 289 290 get command () { 291 return this.#command?.name 292 } 293 294 get flatOptions () { 295 const { flat } = this.config 296 flat.nodeVersion = process.version 297 flat.npmVersion = pkg.version 298 if (this.command) { 299 flat.npmCommand = this.command 300 } 301 return flat 302 } 303 304 // color and logColor are a special derived values that takes into 305 // consideration not only the config, but whether or not we are operating 306 // in a tty with the associated output (stdout/stderr) 307 get color () { 308 return this.flatOptions.color 309 } 310 311 get logColor () { 312 return this.flatOptions.logColor 313 } 314 315 get noColorChalk () { 316 return this.#noColorChalk 317 } 318 319 get chalk () { 320 return this.#chalk 321 } 322 323 get logChalk () { 324 return this.#logChalk 325 } 326 327 get global () { 328 return this.config.get('global') || this.config.get('location') === 'global' 329 } 330 331 get silent () { 332 return this.flatOptions.silent 333 } 334 335 get lockfileVersion () { 336 return 2 337 } 338 339 get unfinishedTimers () { 340 return this.#timers.unfinished 341 } 342 343 get finishedTimers () { 344 return this.#timers.finished 345 } 346 347 get started () { 348 return this.#timers.started 349 } 350 351 get logFiles () { 352 return this.#logFile.files 353 } 354 355 get logsDir () { 356 return this.config.get('logs-dir') || join(this.cache, '_logs') 357 } 358 359 get logPath () { 360 return resolve(this.logsDir, `${this.#runId}-`) 361 } 362 363 get timingFile () { 364 return this.#timers.file 365 } 366 367 get npmRoot () { 368 return this.#npmRoot 369 } 370 371 get cache () { 372 return this.config.get('cache') 373 } 374 375 set cache (r) { 376 this.config.set('cache', r) 377 } 378 379 get globalPrefix () { 380 return this.config.globalPrefix 381 } 382 383 set globalPrefix (r) { 384 this.config.globalPrefix = r 385 } 386 387 get localPrefix () { 388 return this.config.localPrefix 389 } 390 391 set localPrefix (r) { 392 this.config.localPrefix = r 393 } 394 395 get localPackage () { 396 return this.config.localPackage 397 } 398 399 get globalDir () { 400 return process.platform !== 'win32' 401 ? resolve(this.globalPrefix, 'lib', 'node_modules') 402 : resolve(this.globalPrefix, 'node_modules') 403 } 404 405 get localDir () { 406 return resolve(this.localPrefix, 'node_modules') 407 } 408 409 get dir () { 410 return this.global ? this.globalDir : this.localDir 411 } 412 413 get globalBin () { 414 const b = this.globalPrefix 415 return process.platform !== 'win32' ? resolve(b, 'bin') : b 416 } 417 418 get localBin () { 419 return resolve(this.dir, '.bin') 420 } 421 422 get bin () { 423 return this.global ? this.globalBin : this.localBin 424 } 425 426 get prefix () { 427 return this.global ? this.globalPrefix : this.localPrefix 428 } 429 430 set prefix (r) { 431 const k = this.global ? 'globalPrefix' : 'localPrefix' 432 this[k] = r 433 } 434 435 get usage () { 436 return usage(this) 437 } 438 439 // output to stdout in a progress bar compatible way 440 output (...msg) { 441 log.clearProgress() 442 // eslint-disable-next-line no-console 443 console.log(...msg.map(Display.clean)) 444 log.showProgress() 445 } 446 447 outputBuffer (item) { 448 this.#outputBuffer.push(item) 449 } 450 451 flushOutput (jsonError) { 452 if (!jsonError && !this.#outputBuffer.length) { 453 return 454 } 455 456 if (this.config.get('json')) { 457 const jsonOutput = this.#outputBuffer.reduce((acc, item) => { 458 if (typeof item === 'string') { 459 // try to parse it as json in case its a string 460 try { 461 item = JSON.parse(item) 462 } catch { 463 return acc 464 } 465 } 466 return { ...acc, ...item } 467 }, {}) 468 this.output(JSON.stringify({ ...jsonOutput, ...jsonError }, null, 2)) 469 } else { 470 for (const item of this.#outputBuffer) { 471 this.output(item) 472 } 473 } 474 475 this.#outputBuffer.length = 0 476 } 477 478 outputError (...msg) { 479 log.clearProgress() 480 // eslint-disable-next-line no-console 481 console.error(...msg.map(Display.clean)) 482 log.showProgress() 483 } 484} 485module.exports = Npm 486