From 4a6a8906f942630fe05703658066a5eaaeae9a98 Mon Sep 17 00:00:00 2001 From: Luke Karrys Date: Fri, 18 Mar 2022 21:31:37 -0700 Subject: [PATCH] feat: add logs-dir config to set custom logging location This also allows logs-max to be set to 0 to disable log file writing. Closes #4466 Closes #4206 --- docs/content/using-npm/config.md | 19 +- docs/content/using-npm/logging.md | 33 +- lib/cli.js | 10 +- lib/commands/bin.js | 4 +- lib/commands/doctor.js | 4 - lib/commands/view.js | 3 + lib/npm.js | 291 ++++++------ lib/utils/config/definitions.js | 18 +- lib/utils/exit-handler.js | 89 ++-- lib/utils/log-file.js | 74 +-- lib/utils/replace-info.js | 46 +- lib/utils/timers.js | 34 +- lib/utils/update-notifier.js | 2 +- lib/utils/with-chown-sync.js | 13 - .../@npmcli/fs/lib/common/owner-sync.js | 92 ++++ node_modules/@npmcli/fs/lib/copy-file.js | 12 +- node_modules/@npmcli/fs/lib/fs.js | 12 +- node_modules/@npmcli/fs/lib/index.js | 2 + node_modules/@npmcli/fs/lib/mkdir/index.js | 17 +- node_modules/@npmcli/fs/lib/mkdtemp.js | 11 +- .../@npmcli/fs/lib/with-owner-sync.js | 21 + node_modules/@npmcli/fs/lib/with-owner.js | 21 + node_modules/@npmcli/fs/lib/with-temp-dir.js | 2 +- node_modules/@npmcli/fs/lib/write-file.js | 11 +- node_modules/@npmcli/fs/package.json | 29 +- .../node_modules/@npmcli/fs/LICENSE.md | 20 + .../fs/lib/common/file-url-to-path/index.js | 17 + .../lib/common/file-url-to-path/polyfill.js | 121 +++++ .../@npmcli/fs/lib/common/get-options.js | 20 + .../@npmcli/fs/lib/common/node.js | 9 + .../@npmcli/fs/lib/common/owner.js | 92 ++++ .../node_modules/@npmcli/fs/lib/copy-file.js | 22 + .../node_modules/@npmcli/fs/lib/cp/LICENSE | 15 + .../node_modules/@npmcli/fs/lib/cp/index.js | 22 + .../@npmcli/fs/lib/cp/polyfill.js | 428 ++++++++++++++++++ .../node_modules/@npmcli/fs/lib/errors.js | 129 ++++++ .../cacache/node_modules/@npmcli/fs/lib/fs.js | 8 + .../node_modules/@npmcli/fs/lib/index.js | 10 + .../@npmcli/fs/lib/mkdir/index.js | 32 ++ .../@npmcli/fs/lib/mkdir/polyfill.js | 81 ++++ .../node_modules/@npmcli/fs/lib/mkdtemp.js | 28 ++ .../node_modules/@npmcli/fs/lib/rm/index.js | 22 + .../@npmcli/fs/lib/rm/polyfill.js | 239 ++++++++++ .../@npmcli/fs/lib/with-temp-dir.js | 39 ++ .../node_modules/@npmcli/fs/lib/write-file.js | 19 + .../node_modules/@npmcli/fs/package.json | 38 ++ .../npm-registry-fetch/lib/check-response.js | 15 +- .../npm-registry-fetch/lib/clean-url.js | 24 + node_modules/npm-registry-fetch/lib/index.js | 2 + .../npm-registry-fetch/lib/silentlog.js | 14 - node_modules/npm-registry-fetch/package.json | 12 +- package-lock.json | 89 ++-- package.json | 3 +- .../test/lib/commands/config.js.test.cjs | 2 + .../lib/utils/config/definitions.js.test.cjs | 19 +- .../lib/utils/config/describe-all.js.test.cjs | 19 +- .../test/lib/utils/error-message.js.test.cjs | 96 ++++ .../test/lib/utils/exit-handler.js.test.cjs | 54 ++- .../test/lib/utils/log-file.js.test.cjs | 118 ++--- test/fixtures/mock-npm.js | 17 +- test/lib/cli.js | 155 ++++--- test/lib/commands/bin.js | 90 ++-- test/lib/commands/doctor.js | 14 - test/lib/npm.js | 288 ++++++------ test/lib/utils/exit-handler.js | 68 ++- test/lib/utils/log-file.js | 17 +- test/lib/utils/replace-info.js | 40 +- test/lib/utils/timers.js | 11 + test/lib/utils/update-notifier.js | 3 +- 69 files changed, 2676 insertions(+), 775 deletions(-) delete mode 100644 lib/utils/with-chown-sync.js create mode 100644 node_modules/@npmcli/fs/lib/common/owner-sync.js create mode 100644 node_modules/@npmcli/fs/lib/with-owner-sync.js create mode 100644 node_modules/@npmcli/fs/lib/with-owner.js create mode 100644 node_modules/cacache/node_modules/@npmcli/fs/LICENSE.md create mode 100644 node_modules/cacache/node_modules/@npmcli/fs/lib/common/file-url-to-path/index.js create mode 100644 node_modules/cacache/node_modules/@npmcli/fs/lib/common/file-url-to-path/polyfill.js create mode 100644 node_modules/cacache/node_modules/@npmcli/fs/lib/common/get-options.js create mode 100644 node_modules/cacache/node_modules/@npmcli/fs/lib/common/node.js create mode 100644 node_modules/cacache/node_modules/@npmcli/fs/lib/common/owner.js create mode 100644 node_modules/cacache/node_modules/@npmcli/fs/lib/copy-file.js create mode 100644 node_modules/cacache/node_modules/@npmcli/fs/lib/cp/LICENSE create mode 100644 node_modules/cacache/node_modules/@npmcli/fs/lib/cp/index.js create mode 100644 node_modules/cacache/node_modules/@npmcli/fs/lib/cp/polyfill.js create mode 100644 node_modules/cacache/node_modules/@npmcli/fs/lib/errors.js create mode 100644 node_modules/cacache/node_modules/@npmcli/fs/lib/fs.js create mode 100644 node_modules/cacache/node_modules/@npmcli/fs/lib/index.js create mode 100644 node_modules/cacache/node_modules/@npmcli/fs/lib/mkdir/index.js create mode 100644 node_modules/cacache/node_modules/@npmcli/fs/lib/mkdir/polyfill.js create mode 100644 node_modules/cacache/node_modules/@npmcli/fs/lib/mkdtemp.js create mode 100644 node_modules/cacache/node_modules/@npmcli/fs/lib/rm/index.js create mode 100644 node_modules/cacache/node_modules/@npmcli/fs/lib/rm/polyfill.js create mode 100644 node_modules/cacache/node_modules/@npmcli/fs/lib/with-temp-dir.js create mode 100644 node_modules/cacache/node_modules/@npmcli/fs/lib/write-file.js create mode 100644 node_modules/cacache/node_modules/@npmcli/fs/package.json create mode 100644 node_modules/npm-registry-fetch/lib/clean-url.js delete mode 100644 node_modules/npm-registry-fetch/lib/silentlog.js diff --git a/docs/content/using-npm/config.md b/docs/content/using-npm/config.md index 9fbb614564df8..64bb259c895b1 100644 --- a/docs/content/using-npm/config.md +++ b/docs/content/using-npm/config.md @@ -1025,6 +1025,17 @@ See also the `foreground-scripts` config. +#### `logs-dir` + +* Default: A directory named `_logs` inside the cache +* Type: null or Path + +The location of npm's log directory. See [`npm logging`](/using-npm/logging) +for more information. + + + + #### `logs-max` * Default: 10 @@ -1032,6 +1043,8 @@ See also the `foreground-scripts` config. The maximum number of log files to store. +If set to 0, no log files will be written for the current run. + @@ -1626,9 +1639,9 @@ particular, use care when overriding this setting for public packages. * Default: false * Type: Boolean -If true, writes an `npm-debug` log to `_logs` and timing information to -`_timing.json`, both in your cache, even if the command completes -successfully. `_timing.json` is a newline delimited list of JSON objects. +If true, writes a debug log to `logs-dir` and timing information to +`_timing.json` in the cache, even if the command completes successfully. +`_timing.json` is a newline delimited list of JSON objects. You can quickly view it with this [json](https://npm.im/json) command line: `npm exec -- json -g < ~/.npm/_timing.json`. diff --git a/docs/content/using-npm/logging.md b/docs/content/using-npm/logging.md index b7c5e89977899..eb83b167e698b 100644 --- a/docs/content/using-npm/logging.md +++ b/docs/content/using-npm/logging.md @@ -1,13 +1,24 @@ --- title: Logging section: 7 -description: Why, What & How we Log +description: Why, What & How We Log --- ### Description The `npm` CLI has various mechanisms for showing different levels of information back to end-users for certain commands, configurations & environments. +### Setting Log File Location + +All logs are written to a debug log, with the path to that file printed if the execution of a command fails. + +The default location of the logs directory is a directory named `_logs` inside the npm cache. This can be changed +with the `logs-dir` config option. + +Log files will be removed from the `logs-dir` when the number of log files exceeds `logs-max`, with the oldest logs being deleted first. + +To turn off logs completely set `--logs-max=0`. + ### Setting Log Levels #### `loglevel` @@ -28,8 +39,6 @@ The default value of `loglevel` is `"notice"` but there are several levels/types All logs pertaining to a level proceeding the current setting will be shown. -All logs are written to a debug log, with the path to that file printed if the execution of a command fails. - ##### Aliases The log levels listed above have various corresponding aliases, including: @@ -47,6 +56,15 @@ The log levels listed above have various corresponding aliases, including: The `npm` CLI began hiding the output of lifecycle scripts for `npm install` as of `v7`. Notably, this means you will not see logs/output from packages that may be using "install scripts" to display information back to you or from your own project's scripts defined in `package.json`. If you'd like to change this behavior & log this output you can set `foreground-scripts` to `true`. +### Timing Information + +The `--timing` config can be set which does two things: + +1. Always shows the full path to the debug log regardless of command exit status +1. Write timing information to a timing file in the cache or `logs-dir` + +This file is a newline delimited list of JSON objects that can be inspected to see timing data for each task in a `npm` CLI run. + ### Registry Response Headers #### `npm-notice` @@ -55,6 +73,15 @@ The `npm` CLI reads from & logs any `npm-notice` headers that are returned from This header is not cached, and will not be logged if the request is served from the cache. +### Logs and Sensitive Information + +The `npm` CLI makes a best effort to redact the following from terminal output and log files: + +- Passwords inside basic auth URLs +- npm tokens + +However, this behavior should not be relied on to keep all possible sensitive information redacted. If you are concerned about secrets in your log file or terminal output, you can use `--loglevel=silent` and `--logs-max=0` to ensure no logs are written to your terminal or filesystem. + ### See also * [config](/using-npm/config) diff --git a/lib/cli.js b/lib/cli.js index 3d0c32d4beda3..6583bd0c00206 100644 --- a/lib/cli.js +++ b/lib/cli.js @@ -30,14 +30,12 @@ module.exports = async process => { } const log = require('./utils/log-shim.js') - const replaceInfo = require('./utils/replace-info.js') - log.verbose('cli', replaceInfo(process.argv)) - + // only log node and npm paths in argv initially since argv can contain + // sensitive info. a cleaned version will be logged later + log.verbose('cli', process.argv.slice(0, 2).join(' ')) log.info('using', 'npm@%s', npm.version) log.info('using', 'node@%s', process.version) - const updateNotifier = require('./utils/update-notifier.js') - let cmd // now actually fire up npm and run the command. // this is how to use npm programmatically: @@ -54,8 +52,6 @@ module.exports = async process => { npm.config.set('usage', false, 'cli') } - updateNotifier(npm) - cmd = npm.argv.shift() if (!cmd) { npm.output(await npm.usage) diff --git a/lib/commands/bin.js b/lib/commands/bin.js index 77028f06dd49a..07d33167d0d07 100644 --- a/lib/commands/bin.js +++ b/lib/commands/bin.js @@ -1,3 +1,4 @@ +const log = require('../utils/log-shim.js') const envPath = require('../utils/path.js') const BaseCommand = require('../base-command.js') @@ -11,8 +12,7 @@ class Bin extends BaseCommand { const b = this.npm.bin this.npm.output(b) if (this.npm.config.get('global') && !envPath.includes(b)) { - // XXX: does this need to be console? - console.error('(not in PATH env variable)') + log.error('bin', '(not in PATH env variable)') } } } diff --git a/lib/commands/doctor.js b/lib/commands/doctor.js index 630150c0886fe..22a25477e22e0 100644 --- a/lib/commands/doctor.js +++ b/lib/commands/doctor.js @@ -131,10 +131,6 @@ class Doctor extends BaseCommand { if (!this.npm.silent) { this.npm.output(table(outTable, tableOpts)) - if (!allOk) { - // TODO is this really needed? - console.error('') - } } if (!allOk) { throw new Error('Some problems found. See above for recommendations.') diff --git a/lib/commands/view.js b/lib/commands/view.js index 0c3f232602f87..1289bb06f63ae 100644 --- a/lib/commands/view.js +++ b/lib/commands/view.js @@ -1,3 +1,6 @@ +/* eslint-disable no-console */ +// XXX: remove console.log later + // npm view [pkg [pkg ...]] const color = require('ansicolors') diff --git a/lib/npm.js b/lib/npm.js index 9999cf195d7b1..2500d8369d10d 100644 --- a/lib/npm.js +++ b/lib/npm.js @@ -1,5 +1,5 @@ const EventEmitter = require('events') -const { resolve, dirname } = require('path') +const { resolve, dirname, join } = require('path') const Config = require('@npmcli/config') // Patch the global fs module here at the app level @@ -10,6 +10,7 @@ const { shellouts } = require('./utils/cmd-list.js') const usage = require('./utils/npm-usage.js') const which = require('which') +const fs = require('@npmcli/fs') const deref = require('./utils/deref-command.js') const LogFile = require('./utils/log-file.js') @@ -17,45 +18,46 @@ const Timers = require('./utils/timers.js') const Display = require('./utils/display.js') const log = require('./utils/log-shim') const replaceInfo = require('./utils/replace-info.js') +const updateNotifier = require('./utils/update-notifier.js') +const pkg = require('../package.json') let warnedNonDashArg = false const _load = Symbol('_load') -const _tmpFolder = Symbol('_tmpFolder') -const _title = Symbol('_title') -const pkg = require('../package.json') class Npm extends EventEmitter { static get version () { return pkg.version } + command = null + updateNotification = null + loadErr = null + deref = deref + argv = [] + + #loadPromise = null #unloaded = false - #timers = null - #logFile = null - #display = null - - constructor () { - super() - this.command = null - this.#logFile = new LogFile() - this.#display = new Display() - this.#timers = new Timers({ - start: 'npm', - listener: (name, ms) => { - const args = ['timing', name, `Completed in ${ms}ms`] - this.#logFile.log(...args) - this.#display.log(...args) - }, - }) - this.config = new Config({ - npmPath: dirname(__dirname), - definitions, - flatten, - shorthands, - }) - this[_title] = process.title - this.updateNotification = null - } + #tmpFolder = null + #title = 'npm' + #argvClean = [] + + #logFile = new LogFile() + #display = new Display() + #timers = new Timers({ + start: 'npm', + listener: (name, ms) => { + const args = ['timing', name, `Completed in ${ms}ms`] + this.#logFile.log(...args) + this.#display.log(...args) + }, + }) + + config = new Config({ + npmPath: dirname(__dirname), + definitions, + flatten, + shorthands, + }) get version () { return this.constructor.version @@ -65,10 +67,6 @@ class Npm extends EventEmitter { return shellouts } - deref (c) { - return deref(c) - } - // Get an instantiated npm command // npm.command is already taken as the currently running command, a refactor // would be needed to change this @@ -88,7 +86,7 @@ class Npm extends EventEmitter { // Call an npm command async exec (cmd, args) { const command = await this.cmd(cmd) - process.emit('time', `command:${cmd}`) + const timeEnd = this.time(`command:${cmd}`) // since 'test', 'start', 'stop', etc. commands re-enter this function // to call the run-script command, we need to only set it one time. @@ -97,6 +95,11 @@ class Npm extends EventEmitter { this.command = command.name } + // this is async but we dont await it, since its ok if it doesnt + // finish before the command finishes running. it uses command and argv + // so it must be initiated here, after the command name is set + updateNotifier(this).then((msg) => (this.updateNotification = msg)) + // Options are prefixed by a hyphen-minus (-, \u2d). // Other dash-type chars look similar but are invalid. if (!warnedNonDashArg) { @@ -112,68 +115,60 @@ class Npm extends EventEmitter { }) } + const isGlobal = this.config.get('global') const workspacesEnabled = this.config.get('workspaces') const implicitWorkspace = this.config.get('workspace', 'default').length > 0 const workspacesFilters = this.config.get('workspace') - if (workspacesEnabled === false && workspacesFilters.length > 0) { - throw new Error('Can not use --no-workspaces and --workspace at the same time') - } - + const includeWorkspaceRoot = this.config.get('include-workspace-root') // only call execWorkspaces when we have workspaces explicitly set // or when it is implicit and not in our ignore list - const filterByWorkspaces = - (workspacesEnabled || workspacesFilters.length > 0) - && (!implicitWorkspace || !command.ignoreImplicitWorkspace) + const hasWorkspaceFilters = workspacesFilters.length > 0 + const invalidWorkspaceConfig = workspacesEnabled === false && hasWorkspaceFilters + const filterByWorkspaces = (workspacesEnabled || hasWorkspaceFilters) && + (!implicitWorkspace || !command.ignoreImplicitWorkspace) // normally this would go in the constructor, but our tests don't // actually use a real npm object so this.npm.config isn't always // populated. this is the compromise until we can make that a reality // and then move this into the constructor. - command.workspaces = this.config.get('workspaces') + command.workspaces = workspacesEnabled command.workspacePaths = null // normally this would be evaluated in base-command#setWorkspaces, see // above for explanation - command.includeWorkspaceRoot = this.config.get('include-workspace-root') + command.includeWorkspaceRoot = includeWorkspaceRoot + let execPromise = Promise.resolve() if (this.config.get('usage')) { this.output(command.usage) - return - } - if (filterByWorkspaces) { - if (this.config.get('global')) { - throw new Error('Workspaces not supported for global packages') + } else if (invalidWorkspaceConfig) { + execPromise = Promise.reject( + new Error('Can not use --no-workspaces and --workspace at the same time')) + } else if (filterByWorkspaces) { + if (isGlobal) { + execPromise = Promise.reject(new Error('Workspaces not supported for global packages')) + } else { + execPromise = command.execWorkspaces(args, workspacesFilters) } - - return command.execWorkspaces(args, this.config.get('workspace')).finally(() => { - process.emit('timeEnd', `command:${cmd}`) - }) } else { - return command.exec(args).finally(() => { - process.emit('timeEnd', `command:${cmd}`) - }) + execPromise = command.exec(args) } + + return execPromise.finally(timeEnd) } async load () { - if (!this.loadPromise) { - process.emit('time', 'npm:load') - this.loadPromise = new Promise((resolve, reject) => { - this[_load]() - .catch(er => er) - .then(er => { - this.loadErr = er - if (!er && this.config.get('force')) { - log.warn('using --force', 'Recommended protections disabled.') - } - - process.emit('timeEnd', 'npm:load') - if (er) { - return reject(er) - } - resolve() - }) - }) + if (!this.#loadPromise) { + this.#loadPromise = this.time('npm:load', () => this[_load]().catch(er => er).then((er) => { + this.loadErr = er + if (!er) { + if (this.config.get('force')) { + log.warn('using --force', 'Recommended protections disabled.') + } + } else { + throw er + } + })) } - return this.loadPromise + return this.#loadPromise } get loaded () { @@ -196,7 +191,7 @@ class Npm extends EventEmitter { this.#logFile.off() if (this.loaded && this.config.get('timing')) { this.#timers.writeFile({ - command: process.argv.slice(2), + command: this.#argvClean, // We used to only ever report a single log file // so to be backwards compatible report the last logfile // XXX: remove this in npm 9 or just keep it forever @@ -208,82 +203,99 @@ class Npm extends EventEmitter { this.#unloaded = true } + time (name, fn) { + return this.#timers.time(name, fn) + } + get title () { - return this[_title] + return this.#title } set title (t) { process.title = t - this[_title] = t + this.#title = t } async [_load] () { - process.emit('time', 'npm:load:whichnode') - let node - try { - node = which.sync(process.argv[0]) - } catch { - // TODO should we throw here? - } - process.emit('timeEnd', 'npm:load:whichnode') + const node = this.time('npm:load:whichnode', () => { + try { + return which.sync(process.argv[0]) + } catch {} // TODO should we throw here? + }) + if (node && node.toUpperCase() !== process.execPath.toUpperCase()) { log.verbose('node symlink', node) process.execPath = node this.config.execPath = node } - process.emit('time', 'npm:load:configload') - await this.config.load() - process.emit('timeEnd', 'npm:load:configload') + await this.time('npm:load:configload', () => this.config.load()) + + // mkdir this separately since the logs dir can be set to + // a different location. an error here should be surfaced + // right away since it will error in cacache later + await this.time('npm:load:mkdirpcache', () => + fs.mkdir(this.cache, { recursive: true, owner: 'inherit' })) + + // its ok if this fails. user might have specified an invalid dir + // which we will tell them about at the end + await this.time('npm:load:mkdirplogs', () => + fs.mkdir(this.logsDir, { recursive: true, owner: 'inherit' }) + .catch((e) => log.warn('logfile', `could not create logs-dir: ${e}`))) - this.argv = this.config.parsedArgv.remain // 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. - // if it's a token revocation, then the argv contains a secret, so - // don't show that. (Regrettable historical choice to put it there.) - // Any other secrets are configs only, so showing only the positional - // args keeps those from being leaked. - process.emit('time', 'npm:load:setTitle') - const tokrev = deref(this.argv[0]) === 'token' && this.argv[1] === 'revoke' - this.title = tokrev - ? 'npm token revoke' + (this.argv[2] ? ' ***' : '') - : replaceInfo(['npm', ...this.argv].join(' ')) - process.emit('timeEnd', 'npm:load:setTitle') - - process.emit('time', 'npm:load:display') - this.#display.load({ - // Use logColor since that is based on stderr - color: this.logColor, - progress: this.flatOptions.progress, - silent: this.silent, - timing: this.config.get('timing'), - loglevel: this.config.get('loglevel'), - unicode: this.config.get('unicode'), - heading: this.config.get('heading'), + this.time('npm:load:setTitle', () => { + const { parsedArgv: { cooked, remain } } = this.config + this.argv = remain + // Secrets are mostly in configs, so title is set using only the positional args + // to keep those from being leaked. + this.title = ['npm'].concat(replaceInfo(remain)).join(' ').trim() + // 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(' ')) }) - process.emit('timeEnd', 'npm:load:display') - process.env.COLOR = this.color ? '1' : '0' - process.emit('time', 'npm:load:logFile') - this.#logFile.load({ - dir: resolve(this.cache, '_logs'), - logsMax: this.config.get('logs-max'), + this.time('npm:load:display', () => { + this.#display.load({ + // Use logColor since that is based on stderr + color: this.logColor, + progress: this.flatOptions.progress, + silent: this.silent, + timing: this.config.get('timing'), + loglevel: this.config.get('loglevel'), + unicode: this.config.get('unicode'), + heading: this.config.get('heading'), + }) + process.env.COLOR = this.color ? '1' : '0' }) - log.verbose('logfile', this.#logFile.files[0]) - process.emit('timeEnd', 'npm:load:logFile') - process.emit('time', 'npm:load:timers') - this.#timers.load({ - dir: this.cache, + this.time('npm:load:logFile', () => { + this.#logFile.load({ + dir: this.logsDir, + logsMax: this.config.get('logs-max'), + }) + log.verbose('logfile', this.#logFile.files[0] || 'no logfile created') }) - process.emit('timeEnd', 'npm:load:timers') - process.emit('time', 'npm:load:configScope') - const configScope = this.config.get('scope') - if (configScope && !/^@/.test(configScope)) { - this.config.set('scope', `@${configScope}`, this.config.find('scope')) - } - process.emit('timeEnd', 'npm:load:configScope') + this.time('npm:load:timers', () => + this.#timers.load({ + // XXX(npm9): make this always in logs-dir + dir: this.config.get('logs-dir') || this.cache, + }) + ) + + this.time('npm:load:configScope', () => { + const configScope = this.config.get('scope') + if (configScope && !/^@/.test(configScope)) { + this.config.set('scope', `@${configScope}`, this.config.find('scope')) + } + }) } get flatOptions () { @@ -329,6 +341,14 @@ class Npm extends EventEmitter { return this.#logFile.files } + get logsDir () { + return this.config.get('logs-dir') || join(this.cache, '_logs') + } + + get timingFile () { + return this.#timers.file + } + get cache () { return this.config.get('cache') } @@ -395,11 +415,11 @@ class Npm extends EventEmitter { // XXX add logging to see if we actually use this get tmp () { - if (!this[_tmpFolder]) { + if (!this.#tmpFolder) { const rand = require('crypto').randomBytes(4).toString('hex') - this[_tmpFolder] = `npm-${process.pid}-${rand}` + this.#tmpFolder = `npm-${process.pid}-${rand}` } - return resolve(this.config.get('tmp'), this[_tmpFolder]) + return resolve(this.config.get('tmp'), this.#tmpFolder) } // output to stdout in a progress bar compatible way @@ -409,5 +429,12 @@ class Npm extends EventEmitter { console.log(...msg) log.showProgress() } + + outputError (...msg) { + log.clearProgress() + // eslint-disable-next-line no-console + console.error(...msg) + log.showProgress() + } } module.exports = Npm diff --git a/lib/utils/config/definitions.js b/lib/utils/config/definitions.js index c5c129eb3d495..be58f70dc63f3 100644 --- a/lib/utils/config/definitions.js +++ b/lib/utils/config/definitions.js @@ -1227,11 +1227,25 @@ define('loglevel', { }, }) +define('logs-dir', { + default: null, + type: [null, path], + defaultDescription: ` + A directory named \`_logs\` inside the cache +`, + description: ` + The location of npm's log directory. See [\`npm + logging\`](/using-npm/logging) for more information. + `, +}) + define('logs-max', { default: 10, type: Number, description: ` The maximum number of log files to store. + + If set to 0, no log files will be written for the current run. `, }) @@ -2023,8 +2037,8 @@ define('timing', { default: false, type: Boolean, description: ` - If true, writes an \`npm-debug\` log to \`_logs\` and timing information - to \`_timing.json\`, both in your cache, even if the command completes + If true, writes a debug log to \`logs-dir\` and timing information + to \`_timing.json\` in the cache, even if the command completes successfully. \`_timing.json\` is a newline delimited list of JSON objects. diff --git a/lib/utils/exit-handler.js b/lib/utils/exit-handler.js index 6186ea81dcc02..413780c6e0ecc 100644 --- a/lib/utils/exit-handler.js +++ b/lib/utils/exit-handler.js @@ -1,6 +1,6 @@ const os = require('os') -const log = require('./log-shim.js') +const log = require('./log-shim.js') const errorMessage = require('./error-message.js') const replaceInfo = require('./replace-info.js') @@ -8,7 +8,7 @@ const messageText = msg => msg.map(line => line.slice(1).join(' ')).join('\n') let npm = null // set by the cli let exitHandlerCalled = false -let showLogFileMessage = false +let showLogFileError = false process.on('exit', code => { log.disableProgress() @@ -36,42 +36,62 @@ process.on('exit', code => { if (!exitHandlerCalled) { process.exitCode = code || 1 log.error('', 'Exit handler never called!') + // eslint-disable-next-line no-console console.error('') log.error('', 'This is an error with npm itself. Please report this error at:') log.error('', ' ') - showLogFileMessage = true - } - - // In timing mode we always show the log file message - if (hasLoadedNpm && npm.config.get('timing')) { - showLogFileMessage = true + showLogFileError = true } // npm must be loaded to know where the log file was written - if (showLogFileMessage && hasLoadedNpm) { - // just a line break if not in silent mode - if (!npm.silent) { - console.error('') - } + if (hasLoadedNpm) { + const timing = npm.config.get('timing') + const logsDir = npm.logsDir + const timingFile = npm.timingFile + const logsMax = npm.config.get('logs-max') + const logFiles = npm.logFiles + // Determine whether to show log file message and why it is + // being shown since in timing mode we always show the log file message + const logMethod = showLogFileError ? 'error' : timing ? 'info' : null + + if (logMethod) { + if (!npm.silent) { + // just a line break if not in silent mode + // eslint-disable-next-line no-console + console.error('') + } - log.error( - '', - [ - 'A complete log of this run can be found in:', - ...npm.logFiles.map(f => ' ' + f), - ].join('\n') - ) - } + let message = '' + + if (logFiles.length) { + message = [ + ...(timing ? ['Timing info written to:', ` ${timingFile}`] : []), + 'A complete log of this run can be found in:', + ...logFiles.map(f => ` ${f}`), + ].join('\n') + } else if (logsMax <= 0) { + // user specified no log file + message = `No log file was written due to the config logs-max=${logsMax}` + } else { + // could be an error writing to the directory + message = [ + 'No log file was written which could be due to an error writing to the directory:', + logsDir, + 'Try rerunning the command with `--loglevel=verbose` to see the logs in your terminal.', + ].join(' ') + } - // This removes any listeners npm setup and writes files if necessary - // This is mostly used for tests to avoid max listener warnings - if (hasLoadedNpm) { + log[logMethod]('', message) + } + + // This removes any listeners npm setup and writes files if necessary + // This is mostly used for tests to avoid max listener warnings npm.unload() } // these are needed for the tests to have a clean slate in each test case exitHandlerCalled = false - showLogFileMessage = false + showLogFileError = false }) const exitHandler = err => { @@ -84,12 +104,14 @@ const exitHandler = err => { if (!hasNpm) { err = err || new Error('Exit prior to setting npm in exit handler') + // eslint-disable-next-line no-console console.error(err.stack || err.message) return process.exit(1) } if (!hasLoadedNpm) { err = err || new Error('Exit prior to config file resolving.') + // eslint-disable-next-line no-console console.error(err.stack || err.message) } @@ -135,10 +157,8 @@ const exitHandler = err => { } } - const args = replaceInfo(process.argv) log.verbose('cwd', process.cwd()) log.verbose('', os.type() + ' ' + os.release()) - log.verbose('argv', args.map(JSON.stringify).join(' ')) log.verbose('node', process.version) log.verbose('npm ', 'v' + npm.version) @@ -162,7 +182,7 @@ const exitHandler = err => { detail: messageText(msg.detail), }, } - console.error(JSON.stringify(error, null, 2)) + npm.outputError(JSON.stringify(error, null, 2)) } if (typeof err.errno === 'number') { @@ -175,17 +195,18 @@ const exitHandler = err => { log.verbose('exit', exitCode || 0) - showLogFileMessage = (hasLoadedNpm && npm.silent) || noLogMessage + showLogFileError = (hasLoadedNpm && npm.silent) || noLogMessage ? false : !!exitCode // explicitly call process.exit now so we don't hang on things like the - // update notifier, also flush stdout beforehand because process.exit doesn't + // update notifier, also flush stdout/err beforehand because process.exit doesn't // wait for that to happen. - process.stdout.write('', () => process.exit(exitCode)) + let flushed = 0 + const flush = [process.stderr, process.stdout] + const exit = () => ++flushed === flush.length && process.exit(exitCode) + flush.forEach((f) => f.write('', exit)) } module.exports = exitHandler -module.exports.setNpm = n => { - npm = n -} +module.exports.setNpm = n => (npm = n) diff --git a/lib/utils/log-file.js b/lib/utils/log-file.js index 0bf1e0054ea2b..282c72700e58e 100644 --- a/lib/utils/log-file.js +++ b/lib/utils/log-file.js @@ -5,8 +5,8 @@ const rimraf = promisify(require('rimraf')) const glob = promisify(require('glob')) const MiniPass = require('minipass') const fsMiniPass = require('fs-minipass') +const fs = require('@npmcli/fs') const log = require('./log-shim') -const withChownSync = require('./with-chown-sync') const padZero = (n, length) => n.toString().padStart(length.toString().length, '0') @@ -82,7 +82,9 @@ class LogFiles { this[_endStream]() } - load ({ dir, logsMax } = {}) { + load ({ dir, logsMax = Infinity } = {}) { + // dir is user configurable and is required to exist so + // this can error if the dir is missing or not configured correctly this.#dir = dir this.#logsMax = logsMax @@ -90,16 +92,22 @@ class LogFiles { if (!this.#logStream) { return } + + log.verbose('logfile', `logs-max:${logsMax} dir:${dir}`) + // Pipe our initial stream to our new file stream and // set that as the new log logstream for future writes - const initialFile = this[_openLogFile]() - if (initialFile) { - this.#logStream = this.#logStream.pipe(initialFile) + // if logs max is 0 then the user does not want a log file + if (this.#logsMax > 0) { + const initialFile = this[_openLogFile]() + if (initialFile) { + this.#logStream = this.#logStream.pipe(initialFile) + } } - // Kickoff cleaning process. This is async but it wont delete - // our next log file since it deletes oldest first. Return the - // result so it can be awaited in tests + // Kickoff cleaning process, even if we aren't writing a logfile. + // This is async but it will always ignore the current logfile + // Return the result so it can be awaited in tests return this[_cleanLogs]() } @@ -164,8 +172,8 @@ class LogFiles { return LogFiles.format(this.#totalLogCount++, ...args) } - [_getLogFilePath] (prefix, suffix, sep = '-') { - return path.resolve(this.#dir, prefix + sep + 'debug' + sep + suffix + '.log') + [_getLogFilePath] (count = '') { + return path.resolve(this.#dir, `${this.#logId}-debug-${count}.log`) } [_openLogFile] () { @@ -173,17 +181,19 @@ class LogFiles { const count = this.#files.length try { - const logStream = withChownSync( - // Pad with zeros so that our log files are always sorted properly - // We never want to write files ending in `-9.log` and `-10.log` because - // log file cleaning is done by deleting the oldest so in this example - // `-10.log` would be deleted next - this[_getLogFilePath](this.#logId, padZero(count, this.#MAX_FILES_PER_PROCESS)), - // Some effort was made to make the async, but we need to write logs - // during process.on('exit') which has to be synchronous. So in order - // to never drop log messages, it is easiest to make it sync all the time - // and this was measured to be about 1.5% slower for 40k lines of output - (f) => new fsMiniPass.WriteStreamSync(f, { flags: 'a' }) + // Pad with zeros so that our log files are always sorted properly + // We never want to write files ending in `-9.log` and `-10.log` because + // log file cleaning is done by deleting the oldest so in this example + // `-10.log` would be deleted next + const f = this[_getLogFilePath](padZero(count, this.#MAX_FILES_PER_PROCESS)) + // Some effort was made to make the async, but we need to write logs + // during process.on('exit') which has to be synchronous. So in order + // to never drop log messages, it is easiest to make it sync all the time + // and this was measured to be about 1.5% slower for 40k lines of output + const logStream = fs.withOwnerSync( + f, + () => new fsMiniPass.WriteStreamSync(f, { flags: 'a' }), + { owner: 'inherit' } ) if (count > 0) { // Reset file log count if we are opening @@ -193,9 +203,7 @@ class LogFiles { this.#files.push(logStream.path) return logStream } catch (e) { - // XXX: do something here for errors? - // log to display only? - return null + log.warn('logfile', `could not be created: ${e}`) } } @@ -206,14 +214,16 @@ class LogFiles { // Promise that succeeds when we've tried to delete everything, // just for the benefit of testing this function properly. - if (typeof this.#logsMax !== 'number') { - return - } - try { - // Handle the old (prior to 8.2.0) log file names which did not have an counter suffix - // so match by anything after `-debug` and before `.log` (including nothing) - const logGlob = this[_getLogFilePath]('*-', '*', '') + const logPath = this[_getLogFilePath]() + const logGlob = path.join(path.dirname(logPath), path.basename(logPath) + // tell glob to only match digits + .replace(/\d/g, '[0123456789]') + // Handle the old (prior to 8.2.0) log file names which did not have a + // counter suffix + .replace(/-\.log$/, '*.log') + ) + // Always ignore the currently written files const files = await glob(logGlob, { ignore: this.#files }) const toDelete = files.length - this.#logsMax @@ -233,6 +243,8 @@ class LogFiles { } } catch (e) { log.warn('logfile', 'error cleaning log files', e) + } finally { + log.silly('logfile', 'done cleaning log files') } } } diff --git a/lib/utils/replace-info.js b/lib/utils/replace-info.js index e9d19ef5fb2ba..b9ce61935ffb7 100644 --- a/lib/utils/replace-info.js +++ b/lib/utils/replace-info.js @@ -1,33 +1,31 @@ -const URL = require('url').URL +const { cleanUrl } = require('npm-registry-fetch') +const isString = (v) => typeof v === 'string' -// replaces auth info in an array of arguments or in a strings -function replaceInfo (arg) { - const isArray = Array.isArray(arg) - const isString = str => typeof str === 'string' - - if (!isArray && !isString(arg)) { - return arg - } +// split on \s|= similar to how nopt parses options +const splitAndReplace = (str) => { + // stateful regex, don't move out of this scope + const splitChars = /[\s=]/g - const testUrlAndReplace = str => { - try { - const url = new URL(str) - return url.password === '' ? str : str.replace(url.password, '***') - } catch (e) { - return str - } + let match = null + let result = '' + let index = 0 + while (match = splitChars.exec(str)) { + result += cleanUrl(str.slice(index, match.index)) + match[0] + index = splitChars.lastIndex } - const args = isString(arg) ? arg.split(' ') : arg - const info = args.map(a => { - if (isString(a) && a.indexOf(' ') > -1) { - return a.split(' ').map(testUrlAndReplace).join(' ') - } + return result + cleanUrl(str.slice(index)) +} - return testUrlAndReplace(a) - }) +// replaces auth info in an array of arguments or in a strings +function replaceInfo (arg) { + if (isString(arg)) { + return splitAndReplace(arg) + } else if (Array.isArray(arg)) { + return arg.map((a) => isString(a) ? splitAndReplace(a) : a) + } - return isString(arg) ? info.join(' ') : info + return arg } module.exports = replaceInfo diff --git a/lib/utils/timers.js b/lib/utils/timers.js index acff29eb0521b..b4261c2f4e96d 100644 --- a/lib/utils/timers.js +++ b/lib/utils/timers.js @@ -1,8 +1,7 @@ const EE = require('events') -const path = require('path') -const fs = require('graceful-fs') +const { resolve } = require('path') +const fs = require('@npmcli/fs') const log = require('./log-shim') -const withChownSync = require('./with-chown-sync.js') const _timeListener = Symbol('timeListener') const _timeEndListener = Symbol('timeEndListener') @@ -12,10 +11,11 @@ const _init = Symbol('init') // only listen on a single internal event that gets // emitted whenever a timer ends class Timers extends EE { + file = null + #unfinished = new Map() #finished = {} #onTimeEnd = Symbol('onTimeEnd') - #dir = null #initialListener = null #initialTimer = null @@ -62,8 +62,18 @@ class Timers extends EE { } } + time (name, fn) { + process.emit('time', name) + const end = () => process.emit('timeEnd', name) + if (typeof fn === 'function') { + const res = fn() + return res && res.finally ? res.finally(end) : (end(), res) + } + return end + } + load ({ dir }) { - this.#dir = dir + this.file = resolve(dir, '_timing.json') } writeFile (fileData) { @@ -79,13 +89,13 @@ class Timers extends EE { return acc }, {}), } - withChownSync( - path.resolve(this.#dir, '_timing.json'), - (f) => - // we append line delimited json to this file...forever - // XXX: should we also write a process specific timing file? - // with similar rules to the debug log (max files, etc) - fs.appendFileSync(f, JSON.stringify(content) + '\n') + // we append line delimited json to this file...forever + // XXX: should we also write a process specific timing file? + // with similar rules to the debug log (max files, etc) + fs.withOwnerSync( + this.file, + () => fs.appendFileSync(this.file, JSON.stringify(content) + '\n'), + { owner: 'inherit' } ) } catch (e) { log.warn('timing', 'could not write timing file', e) diff --git a/lib/utils/update-notifier.js b/lib/utils/update-notifier.js index 875c3a99a2579..dde0202b76fe2 100644 --- a/lib/utils/update-notifier.js +++ b/lib/utils/update-notifier.js @@ -122,5 +122,5 @@ module.exports = async npm => { // fails, it's ok. might be using /dev/null as the cache or something weird // like that. writeFile(lastCheckedFile(npm), '').catch(() => {}) - npm.updateNotification = notification + return notification } diff --git a/lib/utils/with-chown-sync.js b/lib/utils/with-chown-sync.js deleted file mode 100644 index 481b5696ddabf..0000000000000 --- a/lib/utils/with-chown-sync.js +++ /dev/null @@ -1,13 +0,0 @@ -const mkdirp = require('mkdirp-infer-owner') -const fs = require('graceful-fs') -const path = require('path') - -module.exports = (file, method) => { - const dir = path.dirname(file) - mkdirp.sync(dir) - const result = method(file) - const st = fs.lstatSync(dir) - fs.chownSync(dir, st.uid, st.gid) - fs.chownSync(file, st.uid, st.gid) - return result -} diff --git a/node_modules/@npmcli/fs/lib/common/owner-sync.js b/node_modules/@npmcli/fs/lib/common/owner-sync.js new file mode 100644 index 0000000000000..2055c4b21dec9 --- /dev/null +++ b/node_modules/@npmcli/fs/lib/common/owner-sync.js @@ -0,0 +1,92 @@ +const { dirname, resolve } = require('path') + +const fileURLToPath = require('./file-url-to-path/index.js') +const fs = require('../fs.js') + +// given a path, find the owner of the nearest parent +const find = (path) => { + // if we have no getuid, permissions are irrelevant on this platform + if (!process.getuid) { + return {} + } + + // fs methods accept URL objects with a scheme of file: so we need to unwrap + // those into an actual path string before we can resolve it + const resolved = path != null && path.href && path.origin + ? resolve(fileURLToPath(path)) + : resolve(path) + + let stat + + try { + stat = fs.lstatSync(resolved) + } finally { + // if we got a stat, return its contents + if (stat) { + return { uid: stat.uid, gid: stat.gid } + } + + // try the parent directory + if (resolved !== dirname(resolved)) { + return find(dirname(resolved)) + } + + // no more parents, never got a stat, just return an empty object + return {} + } +} + +// given a path, uid, and gid update the ownership of the path if necessary +const update = (path, uid, gid) => { + // nothing to update, just exit + if (uid === undefined && gid === undefined) { + return + } + + try { + // see if the permissions are already the same, if they are we don't + // need to do anything, so return early + const stat = fs.statSync(path) + if (uid === stat.uid && gid === stat.gid) { + return + } + } catch (err) {} + + try { + fs.chownSync(path, uid, gid) + } catch (err) {} +} + +// accepts a `path` and the `owner` property of an options object and normalizes +// it into an object with numerical `uid` and `gid` +const validate = (path, input) => { + let uid + let gid + + if (typeof input === 'string' || typeof input === 'number') { + uid = input + gid = input + } else if (input && typeof input === 'object') { + uid = input.uid + gid = input.gid + } + + if (uid === 'inherit' || gid === 'inherit') { + const owner = find(path) + if (uid === 'inherit') { + uid = owner.uid + } + + if (gid === 'inherit') { + gid = owner.gid + } + } + + return { uid, gid } +} + +module.exports = { + find, + update, + validate, +} diff --git a/node_modules/@npmcli/fs/lib/copy-file.js b/node_modules/@npmcli/fs/lib/copy-file.js index d9875aba11f79..8888266d627f0 100644 --- a/node_modules/@npmcli/fs/lib/copy-file.js +++ b/node_modules/@npmcli/fs/lib/copy-file.js @@ -1,22 +1,16 @@ const fs = require('./fs.js') const getOptions = require('./common/get-options.js') -const owner = require('./common/owner.js') +const withOwner = require('./with-owner.js') const copyFile = async (src, dest, opts) => { const options = getOptions(opts, { - copy: ['mode', 'owner'], + copy: ['mode'], wrap: 'mode', }) - const { uid, gid } = await owner.validate(dest, options.owner) - // the node core method as of 16.5.0 does not support the mode being in an // object, so we have to pass the mode value directly - const result = await fs.copyFile(src, dest, options.mode) - - await owner.update(dest, uid, gid) - - return result + return withOwner(dest, () => fs.copyFile(src, dest, options.mode), opts) } module.exports = copyFile diff --git a/node_modules/@npmcli/fs/lib/fs.js b/node_modules/@npmcli/fs/lib/fs.js index 29e5fb5735683..457da10eed03e 100644 --- a/node_modules/@npmcli/fs/lib/fs.js +++ b/node_modules/@npmcli/fs/lib/fs.js @@ -1,8 +1,14 @@ const fs = require('fs') const promisify = require('@gar/promisify') -// this module returns the core fs module wrapped in a proxy that promisifies +const isLower = (s) => s === s.toLowerCase() && s !== s.toUpperCase() + +const fsSync = Object.fromEntries(Object.entries(fs).filter(([k, v]) => + typeof v === 'function' && (k.endsWith('Sync') || !isLower(k[0])) +)) + +// this module returns the core fs async fns wrapped in a proxy that promisifies // method calls within the getter. we keep it in a separate module so that the // overridden methods have a consistent way to get to promisified fs methods -// without creating a circular dependency -module.exports = promisify(fs) +// without creating a circular dependency. the ctors and sync methods are kept untouched +module.exports = { ...promisify(fs), ...fsSync } diff --git a/node_modules/@npmcli/fs/lib/index.js b/node_modules/@npmcli/fs/lib/index.js index e40d748a7da60..43892df5fee07 100644 --- a/node_modules/@npmcli/fs/lib/index.js +++ b/node_modules/@npmcli/fs/lib/index.js @@ -6,5 +6,7 @@ module.exports = { mkdtemp: require('./mkdtemp.js'), rm: require('./rm/index.js'), withTempDir: require('./with-temp-dir.js'), + withOwner: require('./with-owner.js'), + withOwnerSync: require('./with-owner-sync.js'), writeFile: require('./write-file.js'), } diff --git a/node_modules/@npmcli/fs/lib/mkdir/index.js b/node_modules/@npmcli/fs/lib/mkdir/index.js index 04ff447903454..e2691042daa26 100644 --- a/node_modules/@npmcli/fs/lib/mkdir/index.js +++ b/node_modules/@npmcli/fs/lib/mkdir/index.js @@ -1,7 +1,7 @@ const fs = require('../fs.js') const getOptions = require('../common/get-options.js') const node = require('../common/node.js') -const owner = require('../common/owner.js') +const withOwner = require('../with-owner.js') const polyfill = require('./polyfill.js') @@ -12,21 +12,18 @@ const useNative = node.satisfies('>=10.12.0') // extends mkdir with the ability to specify an owner of the new dir const mkdir = async (path, opts) => { const options = getOptions(opts, { - copy: ['mode', 'recursive', 'owner'], + copy: ['mode', 'recursive'], wrap: 'mode', }) - const { uid, gid } = await owner.validate(path, options.owner) // the polyfill is tested separately from this module, no need to hack // process.version to try to trigger it just for coverage // istanbul ignore next - const result = useNative - ? await fs.mkdir(path, options) - : await polyfill(path, options) - - await owner.update(path, uid, gid) - - return result + return withOwner( + path, + () => useNative ? fs.mkdir(path, options) : polyfill(path, options), + opts + ) } module.exports = mkdir diff --git a/node_modules/@npmcli/fs/lib/mkdtemp.js b/node_modules/@npmcli/fs/lib/mkdtemp.js index b7f078029d111..60b12a788de90 100644 --- a/node_modules/@npmcli/fs/lib/mkdtemp.js +++ b/node_modules/@npmcli/fs/lib/mkdtemp.js @@ -2,11 +2,11 @@ const { dirname, sep } = require('path') const fs = require('./fs.js') const getOptions = require('./common/get-options.js') -const owner = require('./common/owner.js') +const withOwner = require('./with-owner.js') const mkdtemp = async (prefix, opts) => { const options = getOptions(opts, { - copy: ['encoding', 'owner'], + copy: ['encoding'], wrap: 'encoding', }) @@ -16,13 +16,8 @@ const mkdtemp = async (prefix, opts) => { // /tmp -> /tmpABCDEF, infers from / // /tmp/ -> /tmp/ABCDEF, infers from /tmp const root = prefix.endsWith(sep) ? prefix : dirname(prefix) - const { uid, gid } = await owner.validate(root, options.owner) - const result = await fs.mkdtemp(prefix, options) - - await owner.update(result, uid, gid) - - return result + return withOwner(root, () => fs.mkdtemp(prefix, options), opts) } module.exports = mkdtemp diff --git a/node_modules/@npmcli/fs/lib/with-owner-sync.js b/node_modules/@npmcli/fs/lib/with-owner-sync.js new file mode 100644 index 0000000000000..3597d1c810475 --- /dev/null +++ b/node_modules/@npmcli/fs/lib/with-owner-sync.js @@ -0,0 +1,21 @@ +const getOptions = require('./common/get-options.js') +const owner = require('./common/owner-sync.js') + +const withOwnerSync = (path, fn, opts) => { + const options = getOptions(opts, { + copy: ['owner'], + }) + + const { uid, gid } = owner.validate(path, options.owner) + + const result = fn({ uid, gid }) + + owner.update(path, uid, gid) + if (typeof result === 'string') { + owner.update(result, uid, gid) + } + + return result +} + +module.exports = withOwnerSync diff --git a/node_modules/@npmcli/fs/lib/with-owner.js b/node_modules/@npmcli/fs/lib/with-owner.js new file mode 100644 index 0000000000000..a679102883dbb --- /dev/null +++ b/node_modules/@npmcli/fs/lib/with-owner.js @@ -0,0 +1,21 @@ +const getOptions = require('./common/get-options.js') +const owner = require('./common/owner.js') + +const withOwner = async (path, fn, opts) => { + const options = getOptions(opts, { + copy: ['owner'], + }) + + const { uid, gid } = await owner.validate(path, options.owner) + + const result = await fn({ uid, gid }) + + await Promise.all([ + owner.update(path, uid, gid), + typeof result === 'string' ? owner.update(result, uid, gid) : null, + ]) + + return result +} + +module.exports = withOwner diff --git a/node_modules/@npmcli/fs/lib/with-temp-dir.js b/node_modules/@npmcli/fs/lib/with-temp-dir.js index 353d5555d10f6..ac9ebb714b989 100644 --- a/node_modules/@npmcli/fs/lib/with-temp-dir.js +++ b/node_modules/@npmcli/fs/lib/with-temp-dir.js @@ -27,7 +27,7 @@ const withTempDir = async (root, fn, opts) => { try { await rm(target, { force: true, recursive: true }) - } catch (err) {} + } catch {} if (err) { throw err diff --git a/node_modules/@npmcli/fs/lib/write-file.js b/node_modules/@npmcli/fs/lib/write-file.js index 01de531d980c4..ff900571a1f28 100644 --- a/node_modules/@npmcli/fs/lib/write-file.js +++ b/node_modules/@npmcli/fs/lib/write-file.js @@ -1,19 +1,14 @@ const fs = require('./fs.js') const getOptions = require('./common/get-options.js') -const owner = require('./common/owner.js') +const withOwner = require('./with-owner.js') const writeFile = async (file, data, opts) => { const options = getOptions(opts, { - copy: ['encoding', 'mode', 'flag', 'signal', 'owner'], + copy: ['encoding', 'mode', 'flag', 'signal'], wrap: 'encoding', }) - const { uid, gid } = await owner.validate(file, options.owner) - const result = await fs.writeFile(file, data, options) - - await owner.update(file, uid, gid) - - return result + return withOwner(file, () => fs.writeFile(file, data, options), opts) } module.exports = writeFile diff --git a/node_modules/@npmcli/fs/package.json b/node_modules/@npmcli/fs/package.json index cb64ac82023f2..799bf514f200b 100644 --- a/node_modules/@npmcli/fs/package.json +++ b/node_modules/@npmcli/fs/package.json @@ -1,11 +1,11 @@ { "name": "@npmcli/fs", - "version": "1.1.0", + "version": "2.1.0", "description": "filesystem utilities for the npm cli", "main": "lib/index.js", "files": [ - "bin", - "lib" + "bin/", + "lib/" ], "scripts": { "preversion": "npm test", @@ -14,11 +14,16 @@ "snap": "tap", "test": "tap", "npmclilint": "npmcli-lint", - "lint": "eslint '**/*.js'", + "lint": "eslint \"**/*.js\"", "lintfix": "npm run lint -- --fix", "posttest": "npm run lint", "postsnap": "npm run lintfix --", - "postlint": "npm-template-check" + "postlint": "template-oss-check", + "template-oss-apply": "template-oss-apply --force" + }, + "repository": { + "type": "git", + "url": "https://github.com/npm/fs.git" }, "keywords": [ "npm", @@ -27,15 +32,19 @@ "author": "GitHub Inc.", "license": "ISC", "devDependencies": { - "@npmcli/template-oss": "^2.3.1", - "tap": "^15.0.9" + "@npmcli/eslint-config": "^3.0.1", + "@npmcli/template-oss": "3.1.2", + "tap": "^15.1.6" }, "dependencies": { - "@gar/promisify": "^1.0.1", + "@gar/promisify": "^1.1.3", "semver": "^7.3.5" }, - "templateVersion": "2.3.1", "engines": { - "node": "^12.13.0 || ^14.15.0 || >=16" + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + }, + "templateOSS": { + "//@npmcli/template-oss": "This file is partially managed by @npmcli/template-oss. Edits may be overwritten.", + "version": "3.1.2" } } diff --git a/node_modules/cacache/node_modules/@npmcli/fs/LICENSE.md b/node_modules/cacache/node_modules/@npmcli/fs/LICENSE.md new file mode 100644 index 0000000000000..5fc208ff122e0 --- /dev/null +++ b/node_modules/cacache/node_modules/@npmcli/fs/LICENSE.md @@ -0,0 +1,20 @@ + + +ISC License + +Copyright npm, Inc. + +Permission to use, copy, modify, and/or distribute this +software for any purpose with or without fee is hereby +granted, provided that the above copyright notice and this +permission notice appear in all copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND NPM DISCLAIMS ALL +WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO +EVENT SHALL NPM BE LIABLE FOR ANY SPECIAL, DIRECT, +INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, +WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER +TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE +USE OR PERFORMANCE OF THIS SOFTWARE. diff --git a/node_modules/cacache/node_modules/@npmcli/fs/lib/common/file-url-to-path/index.js b/node_modules/cacache/node_modules/@npmcli/fs/lib/common/file-url-to-path/index.js new file mode 100644 index 0000000000000..7755d1c10e6d0 --- /dev/null +++ b/node_modules/cacache/node_modules/@npmcli/fs/lib/common/file-url-to-path/index.js @@ -0,0 +1,17 @@ +const url = require('url') + +const node = require('../node.js') +const polyfill = require('./polyfill.js') + +const useNative = node.satisfies('>=10.12.0') + +const fileURLToPath = (path) => { + // the polyfill is tested separately from this module, no need to hack + // process.version to try to trigger it just for coverage + // istanbul ignore next + return useNative + ? url.fileURLToPath(path) + : polyfill(path) +} + +module.exports = fileURLToPath diff --git a/node_modules/cacache/node_modules/@npmcli/fs/lib/common/file-url-to-path/polyfill.js b/node_modules/cacache/node_modules/@npmcli/fs/lib/common/file-url-to-path/polyfill.js new file mode 100644 index 0000000000000..6cc90f0b07d79 --- /dev/null +++ b/node_modules/cacache/node_modules/@npmcli/fs/lib/common/file-url-to-path/polyfill.js @@ -0,0 +1,121 @@ +const { URL, domainToUnicode } = require('url') + +const CHAR_LOWERCASE_A = 97 +const CHAR_LOWERCASE_Z = 122 + +const isWindows = process.platform === 'win32' + +class ERR_INVALID_FILE_URL_HOST extends TypeError { + constructor (platform) { + super(`File URL host must be "localhost" or empty on ${platform}`) + this.code = 'ERR_INVALID_FILE_URL_HOST' + } + + toString () { + return `${this.name} [${this.code}]: ${this.message}` + } +} + +class ERR_INVALID_FILE_URL_PATH extends TypeError { + constructor (msg) { + super(`File URL path ${msg}`) + this.code = 'ERR_INVALID_FILE_URL_PATH' + } + + toString () { + return `${this.name} [${this.code}]: ${this.message}` + } +} + +class ERR_INVALID_ARG_TYPE extends TypeError { + constructor (name, actual) { + super(`The "${name}" argument must be one of type string or an instance ` + + `of URL. Received type ${typeof actual} ${actual}`) + this.code = 'ERR_INVALID_ARG_TYPE' + } + + toString () { + return `${this.name} [${this.code}]: ${this.message}` + } +} + +class ERR_INVALID_URL_SCHEME extends TypeError { + constructor (expected) { + super(`The URL must be of scheme ${expected}`) + this.code = 'ERR_INVALID_URL_SCHEME' + } + + toString () { + return `${this.name} [${this.code}]: ${this.message}` + } +} + +const isURLInstance = (input) => { + return input != null && input.href && input.origin +} + +const getPathFromURLWin32 = (url) => { + const hostname = url.hostname + let pathname = url.pathname + for (let n = 0; n < pathname.length; n++) { + if (pathname[n] === '%') { + const third = pathname.codePointAt(n + 2) | 0x20 + if ((pathname[n + 1] === '2' && third === 102) || + (pathname[n + 1] === '5' && third === 99)) { + throw new ERR_INVALID_FILE_URL_PATH('must not include encoded \\ or / characters') + } + } + } + + pathname = pathname.replace(/\//g, '\\') + pathname = decodeURIComponent(pathname) + if (hostname !== '') { + return `\\\\${domainToUnicode(hostname)}${pathname}` + } + + const letter = pathname.codePointAt(1) | 0x20 + const sep = pathname[2] + if (letter < CHAR_LOWERCASE_A || letter > CHAR_LOWERCASE_Z || + (sep !== ':')) { + throw new ERR_INVALID_FILE_URL_PATH('must be absolute') + } + + return pathname.slice(1) +} + +const getPathFromURLPosix = (url) => { + if (url.hostname !== '') { + throw new ERR_INVALID_FILE_URL_HOST(process.platform) + } + + const pathname = url.pathname + + for (let n = 0; n < pathname.length; n++) { + if (pathname[n] === '%') { + const third = pathname.codePointAt(n + 2) | 0x20 + if (pathname[n + 1] === '2' && third === 102) { + throw new ERR_INVALID_FILE_URL_PATH('must not include encoded / characters') + } + } + } + + return decodeURIComponent(pathname) +} + +const fileURLToPath = (path) => { + if (typeof path === 'string') { + path = new URL(path) + } else if (!isURLInstance(path)) { + throw new ERR_INVALID_ARG_TYPE('path', ['string', 'URL'], path) + } + + if (path.protocol !== 'file:') { + throw new ERR_INVALID_URL_SCHEME('file') + } + + return isWindows + ? getPathFromURLWin32(path) + : getPathFromURLPosix(path) +} + +module.exports = fileURLToPath diff --git a/node_modules/cacache/node_modules/@npmcli/fs/lib/common/get-options.js b/node_modules/cacache/node_modules/@npmcli/fs/lib/common/get-options.js new file mode 100644 index 0000000000000..cb5982f79077a --- /dev/null +++ b/node_modules/cacache/node_modules/@npmcli/fs/lib/common/get-options.js @@ -0,0 +1,20 @@ +// given an input that may or may not be an object, return an object that has +// a copy of every defined property listed in 'copy'. if the input is not an +// object, assign it to the property named by 'wrap' +const getOptions = (input, { copy, wrap }) => { + const result = {} + + if (input && typeof input === 'object') { + for (const prop of copy) { + if (input[prop] !== undefined) { + result[prop] = input[prop] + } + } + } else { + result[wrap] = input + } + + return result +} + +module.exports = getOptions diff --git a/node_modules/cacache/node_modules/@npmcli/fs/lib/common/node.js b/node_modules/cacache/node_modules/@npmcli/fs/lib/common/node.js new file mode 100644 index 0000000000000..4d13bc037359d --- /dev/null +++ b/node_modules/cacache/node_modules/@npmcli/fs/lib/common/node.js @@ -0,0 +1,9 @@ +const semver = require('semver') + +const satisfies = (range) => { + return semver.satisfies(process.version, range, { includePrerelease: true }) +} + +module.exports = { + satisfies, +} diff --git a/node_modules/cacache/node_modules/@npmcli/fs/lib/common/owner.js b/node_modules/cacache/node_modules/@npmcli/fs/lib/common/owner.js new file mode 100644 index 0000000000000..e3468b077d00e --- /dev/null +++ b/node_modules/cacache/node_modules/@npmcli/fs/lib/common/owner.js @@ -0,0 +1,92 @@ +const { dirname, resolve } = require('path') + +const fileURLToPath = require('./file-url-to-path/index.js') +const fs = require('../fs.js') + +// given a path, find the owner of the nearest parent +const find = async (path) => { + // if we have no getuid, permissions are irrelevant on this platform + if (!process.getuid) { + return {} + } + + // fs methods accept URL objects with a scheme of file: so we need to unwrap + // those into an actual path string before we can resolve it + const resolved = path != null && path.href && path.origin + ? resolve(fileURLToPath(path)) + : resolve(path) + + let stat + + try { + stat = await fs.lstat(resolved) + } finally { + // if we got a stat, return its contents + if (stat) { + return { uid: stat.uid, gid: stat.gid } + } + + // try the parent directory + if (resolved !== dirname(resolved)) { + return find(dirname(resolved)) + } + + // no more parents, never got a stat, just return an empty object + return {} + } +} + +// given a path, uid, and gid update the ownership of the path if necessary +const update = async (path, uid, gid) => { + // nothing to update, just exit + if (uid === undefined && gid === undefined) { + return + } + + try { + // see if the permissions are already the same, if they are we don't + // need to do anything, so return early + const stat = await fs.stat(path) + if (uid === stat.uid && gid === stat.gid) { + return + } + } catch (err) {} + + try { + await fs.chown(path, uid, gid) + } catch (err) {} +} + +// accepts a `path` and the `owner` property of an options object and normalizes +// it into an object with numerical `uid` and `gid` +const validate = async (path, input) => { + let uid + let gid + + if (typeof input === 'string' || typeof input === 'number') { + uid = input + gid = input + } else if (input && typeof input === 'object') { + uid = input.uid + gid = input.gid + } + + if (uid === 'inherit' || gid === 'inherit') { + const owner = await find(path) + if (uid === 'inherit') { + uid = owner.uid + } + + if (gid === 'inherit') { + gid = owner.gid + } + } + + return { uid, gid } +} + +module.exports = { + find, + update, + validate, +} diff --git a/node_modules/cacache/node_modules/@npmcli/fs/lib/copy-file.js b/node_modules/cacache/node_modules/@npmcli/fs/lib/copy-file.js new file mode 100644 index 0000000000000..d9875aba11f79 --- /dev/null +++ b/node_modules/cacache/node_modules/@npmcli/fs/lib/copy-file.js @@ -0,0 +1,22 @@ +const fs = require('./fs.js') +const getOptions = require('./common/get-options.js') +const owner = require('./common/owner.js') + +const copyFile = async (src, dest, opts) => { + const options = getOptions(opts, { + copy: ['mode', 'owner'], + wrap: 'mode', + }) + + const { uid, gid } = await owner.validate(dest, options.owner) + + // the node core method as of 16.5.0 does not support the mode being in an + // object, so we have to pass the mode value directly + const result = await fs.copyFile(src, dest, options.mode) + + await owner.update(dest, uid, gid) + + return result +} + +module.exports = copyFile diff --git a/node_modules/cacache/node_modules/@npmcli/fs/lib/cp/LICENSE b/node_modules/cacache/node_modules/@npmcli/fs/lib/cp/LICENSE new file mode 100644 index 0000000000000..93546dfb7655b --- /dev/null +++ b/node_modules/cacache/node_modules/@npmcli/fs/lib/cp/LICENSE @@ -0,0 +1,15 @@ +(The MIT License) + +Copyright (c) 2011-2017 JP Richardson + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files +(the 'Software'), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, + merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE +WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS +OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, + ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/node_modules/cacache/node_modules/@npmcli/fs/lib/cp/index.js b/node_modules/cacache/node_modules/@npmcli/fs/lib/cp/index.js new file mode 100644 index 0000000000000..5da4739bdd528 --- /dev/null +++ b/node_modules/cacache/node_modules/@npmcli/fs/lib/cp/index.js @@ -0,0 +1,22 @@ +const fs = require('../fs.js') +const getOptions = require('../common/get-options.js') +const node = require('../common/node.js') +const polyfill = require('./polyfill.js') + +// node 16.7.0 added fs.cp +const useNative = node.satisfies('>=16.7.0') + +const cp = async (src, dest, opts) => { + const options = getOptions(opts, { + copy: ['dereference', 'errorOnExist', 'filter', 'force', 'preserveTimestamps', 'recursive'], + }) + + // the polyfill is tested separately from this module, no need to hack + // process.version to try to trigger it just for coverage + // istanbul ignore next + return useNative + ? fs.cp(src, dest, options) + : polyfill(src, dest, options) +} + +module.exports = cp diff --git a/node_modules/cacache/node_modules/@npmcli/fs/lib/cp/polyfill.js b/node_modules/cacache/node_modules/@npmcli/fs/lib/cp/polyfill.js new file mode 100644 index 0000000000000..f83ccbf57ecc9 --- /dev/null +++ b/node_modules/cacache/node_modules/@npmcli/fs/lib/cp/polyfill.js @@ -0,0 +1,428 @@ +// this file is a modified version of the code in node 17.2.0 +// which is, in turn, a modified version of the fs-extra module on npm +// node core changes: +// - Use of the assert module has been replaced with core's error system. +// - All code related to the glob dependency has been removed. +// - Bring your own custom fs module is not currently supported. +// - Some basic code cleanup. +// changes here: +// - remove all callback related code +// - drop sync support +// - change assertions back to non-internal methods (see options.js) +// - throws ENOTDIR when rmdir gets an ENOENT for a path that exists in Windows +'use strict' + +const { + ERR_FS_CP_DIR_TO_NON_DIR, + ERR_FS_CP_EEXIST, + ERR_FS_CP_EINVAL, + ERR_FS_CP_FIFO_PIPE, + ERR_FS_CP_NON_DIR_TO_DIR, + ERR_FS_CP_SOCKET, + ERR_FS_CP_SYMLINK_TO_SUBDIRECTORY, + ERR_FS_CP_UNKNOWN, + ERR_FS_EISDIR, + ERR_INVALID_ARG_TYPE, +} = require('../errors.js') +const { + constants: { + errno: { + EEXIST, + EISDIR, + EINVAL, + ENOTDIR, + }, + }, +} = require('os') +const { + chmod, + copyFile, + lstat, + mkdir, + readdir, + readlink, + stat, + symlink, + unlink, + utimes, +} = require('../fs.js') +const { + dirname, + isAbsolute, + join, + parse, + resolve, + sep, + toNamespacedPath, +} = require('path') +const { fileURLToPath } = require('url') + +const defaultOptions = { + dereference: false, + errorOnExist: false, + filter: undefined, + force: true, + preserveTimestamps: false, + recursive: false, +} + +async function cp (src, dest, opts) { + if (opts != null && typeof opts !== 'object') { + throw new ERR_INVALID_ARG_TYPE('options', ['Object'], opts) + } + return cpFn( + toNamespacedPath(getValidatedPath(src)), + toNamespacedPath(getValidatedPath(dest)), + { ...defaultOptions, ...opts }) +} + +function getValidatedPath (fileURLOrPath) { + const path = fileURLOrPath != null && fileURLOrPath.href + && fileURLOrPath.origin + ? fileURLToPath(fileURLOrPath) + : fileURLOrPath + return path +} + +async function cpFn (src, dest, opts) { + // Warn about using preserveTimestamps on 32-bit node + // istanbul ignore next + if (opts.preserveTimestamps && process.arch === 'ia32') { + const warning = 'Using the preserveTimestamps option in 32-bit ' + + 'node is not recommended' + process.emitWarning(warning, 'TimestampPrecisionWarning') + } + const stats = await checkPaths(src, dest, opts) + const { srcStat, destStat } = stats + await checkParentPaths(src, srcStat, dest) + if (opts.filter) { + return handleFilter(checkParentDir, destStat, src, dest, opts) + } + return checkParentDir(destStat, src, dest, opts) +} + +async function checkPaths (src, dest, opts) { + const { 0: srcStat, 1: destStat } = await getStats(src, dest, opts) + if (destStat) { + if (areIdentical(srcStat, destStat)) { + throw new ERR_FS_CP_EINVAL({ + message: 'src and dest cannot be the same', + path: dest, + syscall: 'cp', + errno: EINVAL, + }) + } + if (srcStat.isDirectory() && !destStat.isDirectory()) { + throw new ERR_FS_CP_DIR_TO_NON_DIR({ + message: `cannot overwrite directory ${src} ` + + `with non-directory ${dest}`, + path: dest, + syscall: 'cp', + errno: EISDIR, + }) + } + if (!srcStat.isDirectory() && destStat.isDirectory()) { + throw new ERR_FS_CP_NON_DIR_TO_DIR({ + message: `cannot overwrite non-directory ${src} ` + + `with directory ${dest}`, + path: dest, + syscall: 'cp', + errno: ENOTDIR, + }) + } + } + + if (srcStat.isDirectory() && isSrcSubdir(src, dest)) { + throw new ERR_FS_CP_EINVAL({ + message: `cannot copy ${src} to a subdirectory of self ${dest}`, + path: dest, + syscall: 'cp', + errno: EINVAL, + }) + } + return { srcStat, destStat } +} + +function areIdentical (srcStat, destStat) { + return destStat.ino && destStat.dev && destStat.ino === srcStat.ino && + destStat.dev === srcStat.dev +} + +function getStats (src, dest, opts) { + const statFunc = opts.dereference ? + (file) => stat(file, { bigint: true }) : + (file) => lstat(file, { bigint: true }) + return Promise.all([ + statFunc(src), + statFunc(dest).catch((err) => { + // istanbul ignore next: unsure how to cover. + if (err.code === 'ENOENT') { + return null + } + // istanbul ignore next: unsure how to cover. + throw err + }), + ]) +} + +async function checkParentDir (destStat, src, dest, opts) { + const destParent = dirname(dest) + const dirExists = await pathExists(destParent) + if (dirExists) { + return getStatsForCopy(destStat, src, dest, opts) + } + await mkdir(destParent, { recursive: true }) + return getStatsForCopy(destStat, src, dest, opts) +} + +function pathExists (dest) { + return stat(dest).then( + () => true, + // istanbul ignore next: not sure when this would occur + (err) => (err.code === 'ENOENT' ? false : Promise.reject(err))) +} + +// Recursively check if dest parent is a subdirectory of src. +// It works for all file types including symlinks since it +// checks the src and dest inodes. It starts from the deepest +// parent and stops once it reaches the src parent or the root path. +async function checkParentPaths (src, srcStat, dest) { + const srcParent = resolve(dirname(src)) + const destParent = resolve(dirname(dest)) + if (destParent === srcParent || destParent === parse(destParent).root) { + return + } + let destStat + try { + destStat = await stat(destParent, { bigint: true }) + } catch (err) { + // istanbul ignore else: not sure when this would occur + if (err.code === 'ENOENT') { + return + } + // istanbul ignore next: not sure when this would occur + throw err + } + if (areIdentical(srcStat, destStat)) { + throw new ERR_FS_CP_EINVAL({ + message: `cannot copy ${src} to a subdirectory of self ${dest}`, + path: dest, + syscall: 'cp', + errno: EINVAL, + }) + } + return checkParentPaths(src, srcStat, destParent) +} + +const normalizePathToArray = (path) => + resolve(path).split(sep).filter(Boolean) + +// Return true if dest is a subdir of src, otherwise false. +// It only checks the path strings. +function isSrcSubdir (src, dest) { + const srcArr = normalizePathToArray(src) + const destArr = normalizePathToArray(dest) + return srcArr.every((cur, i) => destArr[i] === cur) +} + +async function handleFilter (onInclude, destStat, src, dest, opts, cb) { + const include = await opts.filter(src, dest) + if (include) { + return onInclude(destStat, src, dest, opts, cb) + } +} + +function startCopy (destStat, src, dest, opts) { + if (opts.filter) { + return handleFilter(getStatsForCopy, destStat, src, dest, opts) + } + return getStatsForCopy(destStat, src, dest, opts) +} + +async function getStatsForCopy (destStat, src, dest, opts) { + const statFn = opts.dereference ? stat : lstat + const srcStat = await statFn(src) + // istanbul ignore else: can't portably test FIFO + if (srcStat.isDirectory() && opts.recursive) { + return onDir(srcStat, destStat, src, dest, opts) + } else if (srcStat.isDirectory()) { + throw new ERR_FS_EISDIR({ + message: `${src} is a directory (not copied)`, + path: src, + syscall: 'cp', + errno: EINVAL, + }) + } else if (srcStat.isFile() || + srcStat.isCharacterDevice() || + srcStat.isBlockDevice()) { + return onFile(srcStat, destStat, src, dest, opts) + } else if (srcStat.isSymbolicLink()) { + return onLink(destStat, src, dest) + } else if (srcStat.isSocket()) { + throw new ERR_FS_CP_SOCKET({ + message: `cannot copy a socket file: ${dest}`, + path: dest, + syscall: 'cp', + errno: EINVAL, + }) + } else if (srcStat.isFIFO()) { + throw new ERR_FS_CP_FIFO_PIPE({ + message: `cannot copy a FIFO pipe: ${dest}`, + path: dest, + syscall: 'cp', + errno: EINVAL, + }) + } + // istanbul ignore next: should be unreachable + throw new ERR_FS_CP_UNKNOWN({ + message: `cannot copy an unknown file type: ${dest}`, + path: dest, + syscall: 'cp', + errno: EINVAL, + }) +} + +function onFile (srcStat, destStat, src, dest, opts) { + if (!destStat) { + return _copyFile(srcStat, src, dest, opts) + } + return mayCopyFile(srcStat, src, dest, opts) +} + +async function mayCopyFile (srcStat, src, dest, opts) { + if (opts.force) { + await unlink(dest) + return _copyFile(srcStat, src, dest, opts) + } else if (opts.errorOnExist) { + throw new ERR_FS_CP_EEXIST({ + message: `${dest} already exists`, + path: dest, + syscall: 'cp', + errno: EEXIST, + }) + } +} + +async function _copyFile (srcStat, src, dest, opts) { + await copyFile(src, dest) + if (opts.preserveTimestamps) { + return handleTimestampsAndMode(srcStat.mode, src, dest) + } + return setDestMode(dest, srcStat.mode) +} + +async function handleTimestampsAndMode (srcMode, src, dest) { + // Make sure the file is writable before setting the timestamp + // otherwise open fails with EPERM when invoked with 'r+' + // (through utimes call) + if (fileIsNotWritable(srcMode)) { + await makeFileWritable(dest, srcMode) + return setDestTimestampsAndMode(srcMode, src, dest) + } + return setDestTimestampsAndMode(srcMode, src, dest) +} + +function fileIsNotWritable (srcMode) { + return (srcMode & 0o200) === 0 +} + +function makeFileWritable (dest, srcMode) { + return setDestMode(dest, srcMode | 0o200) +} + +async function setDestTimestampsAndMode (srcMode, src, dest) { + await setDestTimestamps(src, dest) + return setDestMode(dest, srcMode) +} + +function setDestMode (dest, srcMode) { + return chmod(dest, srcMode) +} + +async function setDestTimestamps (src, dest) { + // The initial srcStat.atime cannot be trusted + // because it is modified by the read(2) system call + // (See https://nodejs.org/api/fs.html#fs_stat_time_values) + const updatedSrcStat = await stat(src) + return utimes(dest, updatedSrcStat.atime, updatedSrcStat.mtime) +} + +function onDir (srcStat, destStat, src, dest, opts) { + if (!destStat) { + return mkDirAndCopy(srcStat.mode, src, dest, opts) + } + return copyDir(src, dest, opts) +} + +async function mkDirAndCopy (srcMode, src, dest, opts) { + await mkdir(dest) + await copyDir(src, dest, opts) + return setDestMode(dest, srcMode) +} + +async function copyDir (src, dest, opts) { + const dir = await readdir(src) + for (let i = 0; i < dir.length; i++) { + const item = dir[i] + const srcItem = join(src, item) + const destItem = join(dest, item) + const { destStat } = await checkPaths(srcItem, destItem, opts) + await startCopy(destStat, srcItem, destItem, opts) + } +} + +async function onLink (destStat, src, dest) { + let resolvedSrc = await readlink(src) + if (!isAbsolute(resolvedSrc)) { + resolvedSrc = resolve(dirname(src), resolvedSrc) + } + if (!destStat) { + return symlink(resolvedSrc, dest) + } + let resolvedDest + try { + resolvedDest = await readlink(dest) + } catch (err) { + // Dest exists and is a regular file or directory, + // Windows may throw UNKNOWN error. If dest already exists, + // fs throws error anyway, so no need to guard against it here. + // istanbul ignore next: can only test on windows + if (err.code === 'EINVAL' || err.code === 'UNKNOWN') { + return symlink(resolvedSrc, dest) + } + // istanbul ignore next: should not be possible + throw err + } + if (!isAbsolute(resolvedDest)) { + resolvedDest = resolve(dirname(dest), resolvedDest) + } + if (isSrcSubdir(resolvedSrc, resolvedDest)) { + throw new ERR_FS_CP_EINVAL({ + message: `cannot copy ${resolvedSrc} to a subdirectory of self ` + + `${resolvedDest}`, + path: dest, + syscall: 'cp', + errno: EINVAL, + }) + } + // Do not copy if src is a subdir of dest since unlinking + // dest in this case would result in removing src contents + // and therefore a broken symlink would be created. + const srcStat = await stat(src) + if (srcStat.isDirectory() && isSrcSubdir(resolvedDest, resolvedSrc)) { + throw new ERR_FS_CP_SYMLINK_TO_SUBDIRECTORY({ + message: `cannot overwrite ${resolvedDest} with ${resolvedSrc}`, + path: dest, + syscall: 'cp', + errno: EINVAL, + }) + } + return copyLink(resolvedSrc, dest) +} + +async function copyLink (resolvedSrc, dest) { + await unlink(dest) + return symlink(resolvedSrc, dest) +} + +module.exports = cp diff --git a/node_modules/cacache/node_modules/@npmcli/fs/lib/errors.js b/node_modules/cacache/node_modules/@npmcli/fs/lib/errors.js new file mode 100644 index 0000000000000..1cd1e05d0c533 --- /dev/null +++ b/node_modules/cacache/node_modules/@npmcli/fs/lib/errors.js @@ -0,0 +1,129 @@ +'use strict' +const { inspect } = require('util') + +// adapted from node's internal/errors +// https://github.com/nodejs/node/blob/c8a04049/lib/internal/errors.js + +// close copy of node's internal SystemError class. +class SystemError { + constructor (code, prefix, context) { + // XXX context.code is undefined in all constructors used in cp/polyfill + // that may be a bug copied from node, maybe the constructor should use + // `code` not `errno`? nodejs/node#41104 + let message = `${prefix}: ${context.syscall} returned ` + + `${context.code} (${context.message})` + + if (context.path !== undefined) { + message += ` ${context.path}` + } + if (context.dest !== undefined) { + message += ` => ${context.dest}` + } + + this.code = code + Object.defineProperties(this, { + name: { + value: 'SystemError', + enumerable: false, + writable: true, + configurable: true, + }, + message: { + value: message, + enumerable: false, + writable: true, + configurable: true, + }, + info: { + value: context, + enumerable: true, + configurable: true, + writable: false, + }, + errno: { + get () { + return context.errno + }, + set (value) { + context.errno = value + }, + enumerable: true, + configurable: true, + }, + syscall: { + get () { + return context.syscall + }, + set (value) { + context.syscall = value + }, + enumerable: true, + configurable: true, + }, + }) + + if (context.path !== undefined) { + Object.defineProperty(this, 'path', { + get () { + return context.path + }, + set (value) { + context.path = value + }, + enumerable: true, + configurable: true, + }) + } + + if (context.dest !== undefined) { + Object.defineProperty(this, 'dest', { + get () { + return context.dest + }, + set (value) { + context.dest = value + }, + enumerable: true, + configurable: true, + }) + } + } + + toString () { + return `${this.name} [${this.code}]: ${this.message}` + } + + [Symbol.for('nodejs.util.inspect.custom')] (_recurseTimes, ctx) { + return inspect(this, { + ...ctx, + getters: true, + customInspect: false, + }) + } +} + +function E (code, message) { + module.exports[code] = class NodeError extends SystemError { + constructor (ctx) { + super(code, message, ctx) + } + } +} + +E('ERR_FS_CP_DIR_TO_NON_DIR', 'Cannot overwrite directory with non-directory') +E('ERR_FS_CP_EEXIST', 'Target already exists') +E('ERR_FS_CP_EINVAL', 'Invalid src or dest') +E('ERR_FS_CP_FIFO_PIPE', 'Cannot copy a FIFO pipe') +E('ERR_FS_CP_NON_DIR_TO_DIR', 'Cannot overwrite non-directory with directory') +E('ERR_FS_CP_SOCKET', 'Cannot copy a socket file') +E('ERR_FS_CP_SYMLINK_TO_SUBDIRECTORY', 'Cannot overwrite symlink in subdirectory of self') +E('ERR_FS_CP_UNKNOWN', 'Cannot copy an unknown file type') +E('ERR_FS_EISDIR', 'Path is a directory') + +module.exports.ERR_INVALID_ARG_TYPE = class ERR_INVALID_ARG_TYPE extends Error { + constructor (name, expected, actual) { + super() + this.code = 'ERR_INVALID_ARG_TYPE' + this.message = `The ${name} argument must be ${expected}. Received ${typeof actual}` + } +} diff --git a/node_modules/cacache/node_modules/@npmcli/fs/lib/fs.js b/node_modules/cacache/node_modules/@npmcli/fs/lib/fs.js new file mode 100644 index 0000000000000..29e5fb5735683 --- /dev/null +++ b/node_modules/cacache/node_modules/@npmcli/fs/lib/fs.js @@ -0,0 +1,8 @@ +const fs = require('fs') +const promisify = require('@gar/promisify') + +// this module returns the core fs module wrapped in a proxy that promisifies +// method calls within the getter. we keep it in a separate module so that the +// overridden methods have a consistent way to get to promisified fs methods +// without creating a circular dependency +module.exports = promisify(fs) diff --git a/node_modules/cacache/node_modules/@npmcli/fs/lib/index.js b/node_modules/cacache/node_modules/@npmcli/fs/lib/index.js new file mode 100644 index 0000000000000..e40d748a7da60 --- /dev/null +++ b/node_modules/cacache/node_modules/@npmcli/fs/lib/index.js @@ -0,0 +1,10 @@ +module.exports = { + ...require('./fs.js'), + copyFile: require('./copy-file.js'), + cp: require('./cp/index.js'), + mkdir: require('./mkdir/index.js'), + mkdtemp: require('./mkdtemp.js'), + rm: require('./rm/index.js'), + withTempDir: require('./with-temp-dir.js'), + writeFile: require('./write-file.js'), +} diff --git a/node_modules/cacache/node_modules/@npmcli/fs/lib/mkdir/index.js b/node_modules/cacache/node_modules/@npmcli/fs/lib/mkdir/index.js new file mode 100644 index 0000000000000..04ff447903454 --- /dev/null +++ b/node_modules/cacache/node_modules/@npmcli/fs/lib/mkdir/index.js @@ -0,0 +1,32 @@ +const fs = require('../fs.js') +const getOptions = require('../common/get-options.js') +const node = require('../common/node.js') +const owner = require('../common/owner.js') + +const polyfill = require('./polyfill.js') + +// node 10.12.0 added the options parameter, which allows recursive and mode +// properties to be passed +const useNative = node.satisfies('>=10.12.0') + +// extends mkdir with the ability to specify an owner of the new dir +const mkdir = async (path, opts) => { + const options = getOptions(opts, { + copy: ['mode', 'recursive', 'owner'], + wrap: 'mode', + }) + const { uid, gid } = await owner.validate(path, options.owner) + + // the polyfill is tested separately from this module, no need to hack + // process.version to try to trigger it just for coverage + // istanbul ignore next + const result = useNative + ? await fs.mkdir(path, options) + : await polyfill(path, options) + + await owner.update(path, uid, gid) + + return result +} + +module.exports = mkdir diff --git a/node_modules/cacache/node_modules/@npmcli/fs/lib/mkdir/polyfill.js b/node_modules/cacache/node_modules/@npmcli/fs/lib/mkdir/polyfill.js new file mode 100644 index 0000000000000..4f8e6f006a30e --- /dev/null +++ b/node_modules/cacache/node_modules/@npmcli/fs/lib/mkdir/polyfill.js @@ -0,0 +1,81 @@ +const { dirname } = require('path') + +const fileURLToPath = require('../common/file-url-to-path/index.js') +const fs = require('../fs.js') + +const defaultOptions = { + mode: 0o777, + recursive: false, +} + +const mkdir = async (path, opts) => { + const options = { ...defaultOptions, ...opts } + + // if we're not in recursive mode, just call the real mkdir with the path and + // the mode option only + if (!options.recursive) { + return fs.mkdir(path, options.mode) + } + + const makeDirectory = async (dir, mode) => { + // we can't use dirname directly since these functions support URL + // objects with the file: protocol as the path input, so first we get a + // string path, then we can call dirname on that + const parent = dir != null && dir.href && dir.origin + ? dirname(fileURLToPath(dir)) + : dirname(dir) + + // if the parent is the dir itself, try to create it. anything but EISDIR + // should be rethrown + if (parent === dir) { + try { + await fs.mkdir(dir, opts) + } catch (err) { + if (err.code !== 'EISDIR') { + throw err + } + } + return undefined + } + + try { + await fs.mkdir(dir, mode) + return dir + } catch (err) { + // ENOENT means the parent wasn't there, so create that + if (err.code === 'ENOENT') { + const made = await makeDirectory(parent, mode) + await makeDirectory(dir, mode) + // return the shallowest path we created, i.e. the result of creating + // the parent + return made + } + + // an EEXIST means there's already something there + // an EROFS means we have a read-only filesystem and can't create a dir + // any other error is fatal and we should give up now + if (err.code !== 'EEXIST' && err.code !== 'EROFS') { + throw err + } + + // stat the directory, if the result is a directory, then we successfully + // created this one so return its path. otherwise, we reject with the + // original error by ignoring the error in the catch + try { + const stat = await fs.stat(dir) + if (stat.isDirectory()) { + // if it already existed, we didn't create anything so return + // undefined + return undefined + } + } catch (_) {} + + // if the thing that's there isn't a directory, then just re-throw + throw err + } + } + + return makeDirectory(path, options.mode) +} + +module.exports = mkdir diff --git a/node_modules/cacache/node_modules/@npmcli/fs/lib/mkdtemp.js b/node_modules/cacache/node_modules/@npmcli/fs/lib/mkdtemp.js new file mode 100644 index 0000000000000..b7f078029d111 --- /dev/null +++ b/node_modules/cacache/node_modules/@npmcli/fs/lib/mkdtemp.js @@ -0,0 +1,28 @@ +const { dirname, sep } = require('path') + +const fs = require('./fs.js') +const getOptions = require('./common/get-options.js') +const owner = require('./common/owner.js') + +const mkdtemp = async (prefix, opts) => { + const options = getOptions(opts, { + copy: ['encoding', 'owner'], + wrap: 'encoding', + }) + + // mkdtemp relies on the trailing path separator to indicate if it should + // create a directory inside of the prefix. if that's the case then the root + // we infer ownership from is the prefix itself, otherwise it's the dirname + // /tmp -> /tmpABCDEF, infers from / + // /tmp/ -> /tmp/ABCDEF, infers from /tmp + const root = prefix.endsWith(sep) ? prefix : dirname(prefix) + const { uid, gid } = await owner.validate(root, options.owner) + + const result = await fs.mkdtemp(prefix, options) + + await owner.update(result, uid, gid) + + return result +} + +module.exports = mkdtemp diff --git a/node_modules/cacache/node_modules/@npmcli/fs/lib/rm/index.js b/node_modules/cacache/node_modules/@npmcli/fs/lib/rm/index.js new file mode 100644 index 0000000000000..cb81fbdf8cc47 --- /dev/null +++ b/node_modules/cacache/node_modules/@npmcli/fs/lib/rm/index.js @@ -0,0 +1,22 @@ +const fs = require('../fs.js') +const getOptions = require('../common/get-options.js') +const node = require('../common/node.js') +const polyfill = require('./polyfill.js') + +// node 14.14.0 added fs.rm, which allows both the force and recursive options +const useNative = node.satisfies('>=14.14.0') + +const rm = async (path, opts) => { + const options = getOptions(opts, { + copy: ['retryDelay', 'maxRetries', 'recursive', 'force'], + }) + + // the polyfill is tested separately from this module, no need to hack + // process.version to try to trigger it just for coverage + // istanbul ignore next + return useNative + ? fs.rm(path, options) + : polyfill(path, options) +} + +module.exports = rm diff --git a/node_modules/cacache/node_modules/@npmcli/fs/lib/rm/polyfill.js b/node_modules/cacache/node_modules/@npmcli/fs/lib/rm/polyfill.js new file mode 100644 index 0000000000000..a25c17483b001 --- /dev/null +++ b/node_modules/cacache/node_modules/@npmcli/fs/lib/rm/polyfill.js @@ -0,0 +1,239 @@ +// this file is a modified version of the code in node core >=14.14.0 +// which is, in turn, a modified version of the rimraf module on npm +// node core changes: +// - Use of the assert module has been replaced with core's error system. +// - All code related to the glob dependency has been removed. +// - Bring your own custom fs module is not currently supported. +// - Some basic code cleanup. +// changes here: +// - remove all callback related code +// - drop sync support +// - change assertions back to non-internal methods (see options.js) +// - throws ENOTDIR when rmdir gets an ENOENT for a path that exists in Windows +const errnos = require('os').constants.errno +const { join } = require('path') +const fs = require('../fs.js') + +// error codes that mean we need to remove contents +const notEmptyCodes = new Set([ + 'ENOTEMPTY', + 'EEXIST', + 'EPERM', +]) + +// error codes we can retry later +const retryCodes = new Set([ + 'EBUSY', + 'EMFILE', + 'ENFILE', + 'ENOTEMPTY', + 'EPERM', +]) + +const isWindows = process.platform === 'win32' + +const defaultOptions = { + retryDelay: 100, + maxRetries: 0, + recursive: false, + force: false, +} + +// this is drastically simplified, but should be roughly equivalent to what +// node core throws +class ERR_FS_EISDIR extends Error { + constructor (path) { + super() + this.info = { + code: 'EISDIR', + message: 'is a directory', + path, + syscall: 'rm', + errno: errnos.EISDIR, + } + this.name = 'SystemError' + this.code = 'ERR_FS_EISDIR' + this.errno = errnos.EISDIR + this.syscall = 'rm' + this.path = path + this.message = `Path is a directory: ${this.syscall} returned ` + + `${this.info.code} (is a directory) ${path}` + } + + toString () { + return `${this.name} [${this.code}]: ${this.message}` + } +} + +class ENOTDIR extends Error { + constructor (path) { + super() + this.name = 'Error' + this.code = 'ENOTDIR' + this.errno = errnos.ENOTDIR + this.syscall = 'rmdir' + this.path = path + this.message = `not a directory, ${this.syscall} '${this.path}'` + } + + toString () { + return `${this.name}: ${this.code}: ${this.message}` + } +} + +// force is passed separately here because we respect it for the first entry +// into rimraf only, any further calls that are spawned as a result (i.e. to +// delete content within the target) will ignore ENOENT errors +const rimraf = async (path, options, isTop = false) => { + const force = isTop ? options.force : true + const stat = await fs.lstat(path) + .catch((err) => { + // we only ignore ENOENT if we're forcing this call + if (err.code === 'ENOENT' && force) { + return + } + + if (isWindows && err.code === 'EPERM') { + return fixEPERM(path, options, err, isTop) + } + + throw err + }) + + // no stat object here means either lstat threw an ENOENT, or lstat threw + // an EPERM and the fixPERM function took care of things. either way, we're + // already done, so return early + if (!stat) { + return + } + + if (stat.isDirectory()) { + return rmdir(path, options, null, isTop) + } + + return fs.unlink(path) + .catch((err) => { + if (err.code === 'ENOENT' && force) { + return + } + + if (err.code === 'EISDIR') { + return rmdir(path, options, err, isTop) + } + + if (err.code === 'EPERM') { + // in windows, we handle this through fixEPERM which will also try to + // delete things again. everywhere else since deleting the target as a + // file didn't work we go ahead and try to delete it as a directory + return isWindows + ? fixEPERM(path, options, err, isTop) + : rmdir(path, options, err, isTop) + } + + throw err + }) +} + +const fixEPERM = async (path, options, originalErr, isTop) => { + const force = isTop ? options.force : true + const targetMissing = await fs.chmod(path, 0o666) + .catch((err) => { + if (err.code === 'ENOENT' && force) { + return true + } + + throw originalErr + }) + + // got an ENOENT above, return now. no file = no problem + if (targetMissing) { + return + } + + // this function does its own lstat rather than calling rimraf again to avoid + // infinite recursion for a repeating EPERM + const stat = await fs.lstat(path) + .catch((err) => { + if (err.code === 'ENOENT' && force) { + return + } + + throw originalErr + }) + + if (!stat) { + return + } + + if (stat.isDirectory()) { + return rmdir(path, options, originalErr, isTop) + } + + return fs.unlink(path) +} + +const rmdir = async (path, options, originalErr, isTop) => { + if (!options.recursive && isTop) { + throw originalErr || new ERR_FS_EISDIR(path) + } + const force = isTop ? options.force : true + + return fs.rmdir(path) + .catch(async (err) => { + // in Windows, calling rmdir on a file path will fail with ENOENT rather + // than ENOTDIR. to determine if that's what happened, we have to do + // another lstat on the path. if the path isn't actually gone, we throw + // away the ENOENT and replace it with our own ENOTDIR + if (isWindows && err.code === 'ENOENT') { + const stillExists = await fs.lstat(path).then(() => true, () => false) + if (stillExists) { + err = new ENOTDIR(path) + } + } + + // not there, not a problem + if (err.code === 'ENOENT' && force) { + return + } + + // we may not have originalErr if lstat tells us our target is a + // directory but that changes before we actually remove it, so + // only throw it here if it's set + if (originalErr && err.code === 'ENOTDIR') { + throw originalErr + } + + // the directory isn't empty, remove the contents and try again + if (notEmptyCodes.has(err.code)) { + const files = await fs.readdir(path) + await Promise.all(files.map((file) => { + const target = join(path, file) + return rimraf(target, options) + })) + return fs.rmdir(path) + } + + throw err + }) +} + +const rm = async (path, opts) => { + const options = { ...defaultOptions, ...opts } + let retries = 0 + + const errHandler = async (err) => { + if (retryCodes.has(err.code) && ++retries < options.maxRetries) { + const delay = retries * options.retryDelay + await promiseTimeout(delay) + return rimraf(path, options, true).catch(errHandler) + } + + throw err + } + + return rimraf(path, options, true).catch(errHandler) +} + +const promiseTimeout = (ms) => new Promise((r) => setTimeout(r, ms)) + +module.exports = rm diff --git a/node_modules/cacache/node_modules/@npmcli/fs/lib/with-temp-dir.js b/node_modules/cacache/node_modules/@npmcli/fs/lib/with-temp-dir.js new file mode 100644 index 0000000000000..353d5555d10f6 --- /dev/null +++ b/node_modules/cacache/node_modules/@npmcli/fs/lib/with-temp-dir.js @@ -0,0 +1,39 @@ +const { join, sep } = require('path') + +const getOptions = require('./common/get-options.js') +const mkdir = require('./mkdir/index.js') +const mkdtemp = require('./mkdtemp.js') +const rm = require('./rm/index.js') + +// create a temp directory, ensure its permissions match its parent, then call +// the supplied function passing it the path to the directory. clean up after +// the function finishes, whether it throws or not +const withTempDir = async (root, fn, opts) => { + const options = getOptions(opts, { + copy: ['tmpPrefix'], + }) + // create the directory, and fix its ownership + await mkdir(root, { recursive: true, owner: 'inherit' }) + + const target = await mkdtemp(join(`${root}${sep}`, options.tmpPrefix || ''), { owner: 'inherit' }) + let err + let result + + try { + result = await fn(target) + } catch (_err) { + err = _err + } + + try { + await rm(target, { force: true, recursive: true }) + } catch (err) {} + + if (err) { + throw err + } + + return result +} + +module.exports = withTempDir diff --git a/node_modules/cacache/node_modules/@npmcli/fs/lib/write-file.js b/node_modules/cacache/node_modules/@npmcli/fs/lib/write-file.js new file mode 100644 index 0000000000000..01de531d980c4 --- /dev/null +++ b/node_modules/cacache/node_modules/@npmcli/fs/lib/write-file.js @@ -0,0 +1,19 @@ +const fs = require('./fs.js') +const getOptions = require('./common/get-options.js') +const owner = require('./common/owner.js') + +const writeFile = async (file, data, opts) => { + const options = getOptions(opts, { + copy: ['encoding', 'mode', 'flag', 'signal', 'owner'], + wrap: 'encoding', + }) + const { uid, gid } = await owner.validate(file, options.owner) + + const result = await fs.writeFile(file, data, options) + + await owner.update(file, uid, gid) + + return result +} + +module.exports = writeFile diff --git a/node_modules/cacache/node_modules/@npmcli/fs/package.json b/node_modules/cacache/node_modules/@npmcli/fs/package.json new file mode 100644 index 0000000000000..0296aa7f1e1d5 --- /dev/null +++ b/node_modules/cacache/node_modules/@npmcli/fs/package.json @@ -0,0 +1,38 @@ +{ + "name": "@npmcli/fs", + "version": "1.1.1", + "description": "filesystem utilities for the npm cli", + "main": "lib/index.js", + "files": [ + "bin", + "lib" + ], + "scripts": { + "preversion": "npm test", + "postversion": "npm publish", + "prepublishOnly": "git push origin --follow-tags", + "snap": "tap", + "test": "tap", + "npmclilint": "npmcli-lint", + "lint": "eslint '**/*.js'", + "lintfix": "npm run lint -- --fix", + "posttest": "npm run lint", + "postsnap": "npm run lintfix --", + "postlint": "npm-template-check" + }, + "keywords": [ + "npm", + "oss" + ], + "author": "GitHub Inc.", + "license": "ISC", + "devDependencies": { + "@npmcli/template-oss": "^2.3.1", + "tap": "^15.0.9" + }, + "dependencies": { + "@gar/promisify": "^1.0.1", + "semver": "^7.3.5" + }, + "templateVersion": "2.3.1" +} diff --git a/node_modules/npm-registry-fetch/lib/check-response.js b/node_modules/npm-registry-fetch/lib/check-response.js index 872ec8a888694..714513908df40 100644 --- a/node_modules/npm-registry-fetch/lib/check-response.js +++ b/node_modules/npm-registry-fetch/lib/check-response.js @@ -4,6 +4,7 @@ const errors = require('./errors.js') const { Response } = require('minipass-fetch') const defaultOpts = require('./default-opts.js') const log = require('proc-log') +const cleanUrl = require('./clean-url.js') /* eslint-disable-next-line max-len */ const moreInfoUrl = 'https://github.com/npm/cli/wiki/No-auth-for-URI,-but-auth-present-for-scoped-registry' @@ -45,19 +46,7 @@ function logRequest (method, res, startTime) { const attemptStr = attempt && attempt > 1 ? ` attempt #${attempt}` : '' const cacheStatus = res.headers.get('x-local-cache-status') const cacheStr = cacheStatus ? ` (cache ${cacheStatus})` : '' - - let urlStr - try { - const { URL } = require('url') - const url = new URL(res.url) - if (url.password) { - url.password = '***' - } - - urlStr = url.toString() - } catch (er) { - urlStr = res.url - } + const urlStr = cleanUrl(res.url) log.http( 'fetch', diff --git a/node_modules/npm-registry-fetch/lib/clean-url.js b/node_modules/npm-registry-fetch/lib/clean-url.js new file mode 100644 index 0000000000000..ba31dc462f3c5 --- /dev/null +++ b/node_modules/npm-registry-fetch/lib/clean-url.js @@ -0,0 +1,24 @@ +const { URL } = require('url') + +const replace = '***' +const tokenRegex = /\bnpm_[a-zA-Z0-9]{36}\b/g +const guidRegex = /\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b/g + +const cleanUrl = (str) => { + if (typeof str !== 'string' || !str) { + return str + } + + try { + const url = new URL(str) + if (url.password) { + str = str.replace(url.password, replace) + } + } catch {} + + return str + .replace(tokenRegex, `npm_${replace}`) + .replace(guidRegex, `npm_${replace}`) +} + +module.exports = cleanUrl diff --git a/node_modules/npm-registry-fetch/lib/index.js b/node_modules/npm-registry-fetch/lib/index.js index 19c921403e5cd..a0fc280a933e5 100644 --- a/node_modules/npm-registry-fetch/lib/index.js +++ b/node_modules/npm-registry-fetch/lib/index.js @@ -239,3 +239,5 @@ function getHeaders (uri, auth, opts) { return headers } + +module.exports.cleanUrl = require('./clean-url.js') diff --git a/node_modules/npm-registry-fetch/lib/silentlog.js b/node_modules/npm-registry-fetch/lib/silentlog.js deleted file mode 100644 index 483bd44c7086a..0000000000000 --- a/node_modules/npm-registry-fetch/lib/silentlog.js +++ /dev/null @@ -1,14 +0,0 @@ -'use strict' - -const noop = Function.prototype -module.exports = { - error: noop, - warn: noop, - notice: noop, - info: noop, - verbose: noop, - silly: noop, - http: noop, - pause: noop, - resume: noop, -} diff --git a/node_modules/npm-registry-fetch/package.json b/node_modules/npm-registry-fetch/package.json index 75236be2a9015..d0a8bbaaa950d 100644 --- a/node_modules/npm-registry-fetch/package.json +++ b/node_modules/npm-registry-fetch/package.json @@ -31,17 +31,17 @@ "author": "GitHub Inc.", "license": "ISC", "dependencies": { - "make-fetch-happen": "^10.0.3", + "make-fetch-happen": "^10.0.4", "minipass": "^3.1.6", - "minipass-fetch": "^2.0.1", + "minipass-fetch": "^2.0.2", "minipass-json-stream": "^1.0.1", "minizlib": "^2.1.2", - "npm-package-arg": "^9.0.0", + "npm-package-arg": "^9.0.1", "proc-log": "^2.0.0" }, "devDependencies": { - "@npmcli/template-oss": "^2.8.1", - "cacache": "^15.3.0", + "@npmcli/template-oss": "^2.9.2", + "cacache": "^16.0.1", "nock": "^13.2.4", "require-inject": "^1.4.4", "ssri": "^8.0.1", @@ -55,6 +55,6 @@ "node": "^12.13.0 || ^14.15.0 || >=16" }, "templateOSS": { - "version": "2.8.1" + "version": "2.9.2" } } diff --git a/package-lock.json b/package-lock.json index b0d9ef3710c25..75f7049b11900 100644 --- a/package-lock.json +++ b/package-lock.json @@ -90,6 +90,7 @@ "@npmcli/arborist": "^5.0.2", "@npmcli/ci-detect": "^2.0.0", "@npmcli/config": "^4.0.1", + "@npmcli/fs": "^2.1.0", "@npmcli/map-workspaces": "^2.0.2", "@npmcli/package-json": "^1.0.1", "@npmcli/run-script": "^3.0.1", @@ -135,7 +136,7 @@ "npm-package-arg": "^9.0.1", "npm-pick-manifest": "^7.0.0", "npm-profile": "^6.0.2", - "npm-registry-fetch": "^13.0.1", + "npm-registry-fetch": "github:npm/npm-registry-fetch#lk/redact-token", "npm-user-validate": "^1.0.1", "npmlog": "^6.0.1", "opener": "^1.5.2", @@ -865,16 +866,15 @@ } }, "node_modules/@npmcli/fs": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@npmcli/fs/-/fs-1.1.0.tgz", - "integrity": "sha512-VhP1qZLXcrXRIaPoqb4YA55JQxLNF3jNR4T55IdOJa3+IFJKNYHtPvtXx8slmeMavj37vCzCfrqQM1vWLsYKLA==", - "inBundle": true, + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@npmcli/fs/-/fs-2.1.0.tgz", + "integrity": "sha512-DmfBvNXGaetMxj9LTp8NAN9vEidXURrf5ZTslQzEAi/6GbW+4yjaLFQc6Tue5cpZ9Frlk4OBo/Snf1Bh/S7qTQ==", "dependencies": { - "@gar/promisify": "^1.0.1", + "@gar/promisify": "^1.1.3", "semver": "^7.3.5" }, "engines": { - "node": "^12.13.0 || ^14.15.0 || >=16" + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" } }, "node_modules/@npmcli/git": { @@ -1046,19 +1046,6 @@ "tap": "^15.0.9" } }, - "node_modules/@npmcli/template-oss/node_modules/@npmcli/fs": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@npmcli/fs/-/fs-2.0.1.tgz", - "integrity": "sha512-vlaJ+kcURCo0SK1afdX5BQ9hgbXDKhpOxdIOg3jvn7wnKp8NcSDjvYc490VuJn2ciOgAFXV9qZzZPgHlcpXkxA==", - "dev": true, - "dependencies": { - "@gar/promisify": "^1.1.3", - "semver": "^7.3.5" - }, - "engines": { - "node": "^12.13.0 || ^14.15.0 || >=16" - } - }, "node_modules/@tootallnate/once": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.0.tgz", @@ -1632,6 +1619,16 @@ "node": "^12.13.0 || ^14.15.0 || >=16" } }, + "node_modules/cacache/node_modules/@npmcli/fs": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@npmcli/fs/-/fs-1.1.1.tgz", + "integrity": "sha512-8KG5RD0GVP4ydEzRn/I4BNDuxDtqVbOdm8675T49OIG/NGhaK0pjPX7ZcDlvKYbA+ulvVK3ztfcF4uBdOxuJbQ==", + "inBundle": true, + "dependencies": { + "@gar/promisify": "^1.0.1", + "semver": "^7.3.5" + } + }, "node_modules/caching-transform": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/caching-transform/-/caching-transform-4.0.0.tgz", @@ -5442,16 +5439,16 @@ }, "node_modules/npm-registry-fetch": { "version": "13.0.1", - "resolved": "https://registry.npmjs.org/npm-registry-fetch/-/npm-registry-fetch-13.0.1.tgz", - "integrity": "sha512-Ak+LXVtSrCLOdscFW/apUw67OPNph8waHsPKM9UOJosL7i59EF5XoSWQMEsXEOeifM9Bb4/2+WrQC4t/pd8DGg==", + "resolved": "git+ssh://git@github.com/npm/npm-registry-fetch.git#4aa555f7bd5452a9e53c6403e0c22573a636c386", "inBundle": true, + "license": "ISC", "dependencies": { - "make-fetch-happen": "^10.0.3", + "make-fetch-happen": "^10.0.4", "minipass": "^3.1.6", - "minipass-fetch": "^2.0.1", + "minipass-fetch": "^2.0.2", "minipass-json-stream": "^1.0.1", "minizlib": "^2.1.2", - "npm-package-arg": "^9.0.0", + "npm-package-arg": "^9.0.1", "proc-log": "^2.0.0" }, "engines": { @@ -11275,11 +11272,11 @@ } }, "@npmcli/fs": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@npmcli/fs/-/fs-1.1.0.tgz", - "integrity": "sha512-VhP1qZLXcrXRIaPoqb4YA55JQxLNF3jNR4T55IdOJa3+IFJKNYHtPvtXx8slmeMavj37vCzCfrqQM1vWLsYKLA==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@npmcli/fs/-/fs-2.1.0.tgz", + "integrity": "sha512-DmfBvNXGaetMxj9LTp8NAN9vEidXURrf5ZTslQzEAi/6GbW+4yjaLFQc6Tue5cpZ9Frlk4OBo/Snf1Bh/S7qTQ==", "requires": { - "@gar/promisify": "^1.0.1", + "@gar/promisify": "^1.1.3", "semver": "^7.3.5" } }, @@ -11405,18 +11402,6 @@ "@npmcli/package-json": "^1.0.1", "json-parse-even-better-errors": "^2.3.1", "which": "^2.0.2" - }, - "dependencies": { - "@npmcli/fs": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@npmcli/fs/-/fs-2.0.1.tgz", - "integrity": "sha512-vlaJ+kcURCo0SK1afdX5BQ9hgbXDKhpOxdIOg3jvn7wnKp8NcSDjvYc490VuJn2ciOgAFXV9qZzZPgHlcpXkxA==", - "dev": true, - "requires": { - "@gar/promisify": "^1.1.3", - "semver": "^7.3.5" - } - } } }, "@tootallnate/once": { @@ -11856,6 +11841,17 @@ "ssri": "^8.0.1", "tar": "^6.1.11", "unique-filename": "^1.1.1" + }, + "dependencies": { + "@npmcli/fs": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@npmcli/fs/-/fs-1.1.1.tgz", + "integrity": "sha512-8KG5RD0GVP4ydEzRn/I4BNDuxDtqVbOdm8675T49OIG/NGhaK0pjPX7ZcDlvKYbA+ulvVK3ztfcF4uBdOxuJbQ==", + "requires": { + "@gar/promisify": "^1.0.1", + "semver": "^7.3.5" + } + } } }, "caching-transform": { @@ -14911,16 +14907,15 @@ } }, "npm-registry-fetch": { - "version": "13.0.1", - "resolved": "https://registry.npmjs.org/npm-registry-fetch/-/npm-registry-fetch-13.0.1.tgz", - "integrity": "sha512-Ak+LXVtSrCLOdscFW/apUw67OPNph8waHsPKM9UOJosL7i59EF5XoSWQMEsXEOeifM9Bb4/2+WrQC4t/pd8DGg==", + "version": "git+ssh://git@github.com/npm/npm-registry-fetch.git#4aa555f7bd5452a9e53c6403e0c22573a636c386", + "from": "npm-registry-fetch@github:npm/npm-registry-fetch#lk/redact-token", "requires": { - "make-fetch-happen": "^10.0.3", + "make-fetch-happen": "^10.0.4", "minipass": "^3.1.6", - "minipass-fetch": "^2.0.1", + "minipass-fetch": "^2.0.2", "minipass-json-stream": "^1.0.1", "minizlib": "^2.1.2", - "npm-package-arg": "^9.0.0", + "npm-package-arg": "^9.0.1", "proc-log": "^2.0.0" } }, diff --git a/package.json b/package.json index faf59dc5d34d4..d2a3dca1d7399 100644 --- a/package.json +++ b/package.json @@ -58,6 +58,7 @@ "@npmcli/arborist": "^5.0.2", "@npmcli/ci-detect": "^2.0.0", "@npmcli/config": "^4.0.1", + "@npmcli/fs": "^2.1.0", "@npmcli/map-workspaces": "^2.0.2", "@npmcli/package-json": "^1.0.1", "@npmcli/run-script": "^3.0.1", @@ -103,7 +104,7 @@ "npm-package-arg": "^9.0.1", "npm-pick-manifest": "^7.0.0", "npm-profile": "^6.0.2", - "npm-registry-fetch": "^13.0.1", + "npm-registry-fetch": "github:npm/npm-registry-fetch#lk/redact-token", "npm-user-validate": "^1.0.1", "npmlog": "^6.0.1", "opener": "^1.5.2", diff --git a/tap-snapshots/test/lib/commands/config.js.test.cjs b/tap-snapshots/test/lib/commands/config.js.test.cjs index 8e97915230719..3594cf25d5b73 100644 --- a/tap-snapshots/test/lib/commands/config.js.test.cjs +++ b/tap-snapshots/test/lib/commands/config.js.test.cjs @@ -89,6 +89,7 @@ exports[`test/lib/commands/config.js TAP config list --json > output matches sna "location": "user", "lockfile-version": null, "loglevel": "notice", + "logs-dir": null, "logs-max": 10, "long": false, "maxsockets": 15, @@ -241,6 +242,7 @@ local-address = null location = "user" lockfile-version = null loglevel = "notice" +logs-dir = null logs-max = 10 ; long = false ; overridden by cli maxsockets = 15 diff --git a/tap-snapshots/test/lib/utils/config/definitions.js.test.cjs b/tap-snapshots/test/lib/utils/config/definitions.js.test.cjs index d75d32d9b74ea..df74a7d2b7420 100644 --- a/tap-snapshots/test/lib/utils/config/definitions.js.test.cjs +++ b/tap-snapshots/test/lib/utils/config/definitions.js.test.cjs @@ -85,6 +85,7 @@ Array [ "location", "lockfile-version", "loglevel", + "logs-dir", "logs-max", "long", "maxsockets", @@ -1096,6 +1097,16 @@ Any logs of a higher level than the setting are shown. The default is See also the \`foreground-scripts\` config. ` +exports[`test/lib/utils/config/definitions.js TAP > config description for logs-dir 1`] = ` +#### \`logs-dir\` + +* Default: A directory named \`_logs\` inside the cache +* Type: null or Path + +The location of npm's log directory. See [\`npm logging\`](/using-npm/logging) +for more information. +` + exports[`test/lib/utils/config/definitions.js TAP > config description for logs-max 1`] = ` #### \`logs-max\` @@ -1103,6 +1114,8 @@ exports[`test/lib/utils/config/definitions.js TAP > config description for logs- * Type: Number The maximum number of log files to store. + +If set to 0, no log files will be written for the current run. ` exports[`test/lib/utils/config/definitions.js TAP > config description for long 1`] = ` @@ -1718,9 +1731,9 @@ exports[`test/lib/utils/config/definitions.js TAP > config description for timin * Default: false * Type: Boolean -If true, writes an \`npm-debug\` log to \`_logs\` and timing information to -\`_timing.json\`, both in your cache, even if the command completes -successfully. \`_timing.json\` is a newline delimited list of JSON objects. +If true, writes a debug log to \`logs-dir\` and timing information to +\`_timing.json\` in the cache, even if the command completes successfully. +\`_timing.json\` is a newline delimited list of JSON objects. You can quickly view it with this [json](https://npm.im/json) command line: \`npm exec -- json -g < ~/.npm/_timing.json\`. diff --git a/tap-snapshots/test/lib/utils/config/describe-all.js.test.cjs b/tap-snapshots/test/lib/utils/config/describe-all.js.test.cjs index 27b8278fd388e..3b650024d578c 100644 --- a/tap-snapshots/test/lib/utils/config/describe-all.js.test.cjs +++ b/tap-snapshots/test/lib/utils/config/describe-all.js.test.cjs @@ -899,6 +899,17 @@ See also the \`foreground-scripts\` config. +#### \`logs-dir\` + +* Default: A directory named \`_logs\` inside the cache +* Type: null or Path + +The location of npm's log directory. See [\`npm logging\`](/using-npm/logging) +for more information. + + + + #### \`logs-max\` * Default: 10 @@ -906,6 +917,8 @@ See also the \`foreground-scripts\` config. The maximum number of log files to store. +If set to 0, no log files will be written for the current run. + @@ -1500,9 +1513,9 @@ particular, use care when overriding this setting for public packages. * Default: false * Type: Boolean -If true, writes an \`npm-debug\` log to \`_logs\` and timing information to -\`_timing.json\`, both in your cache, even if the command completes -successfully. \`_timing.json\` is a newline delimited list of JSON objects. +If true, writes a debug log to \`logs-dir\` and timing information to +\`_timing.json\` in the cache, even if the command completes successfully. +\`_timing.json\` is a newline delimited list of JSON objects. You can quickly view it with this [json](https://npm.im/json) command line: \`npm exec -- json -g < ~/.npm/_timing.json\`. diff --git a/tap-snapshots/test/lib/utils/error-message.js.test.cjs b/tap-snapshots/test/lib/utils/error-message.js.test.cjs index 3b82e3c055de6..3f44d77418de5 100644 --- a/tap-snapshots/test/lib/utils/error-message.js.test.cjs +++ b/tap-snapshots/test/lib/utils/error-message.js.test.cjs @@ -503,6 +503,18 @@ Object { exports[`test/lib/utils/error-message.js TAP eacces/eperm {"windows":false,"loaded":true,"cachePath":false,"cacheDest":false} > must match snapshot 2`] = ` Array [ + Array [ + "title", + "npm", + ], + Array [ + "argv", + "", + ], + Array [ + "logfile", + "logs-max:10 dir:{CWD}/test/lib/utils/tap-testdir-error-message-eacces-eperm--windows-false-loaded-true-cachePath-false-cacheDest-false-/cache/_logs", + ], Array [ "logfile", "{CWD}/test/lib/utils/tap-testdir-error-message-eacces-eperm--windows-false-loaded-true-cachePath-false-cacheDest-false-/cache/_logs/{DATE}-debug-0.log", @@ -531,6 +543,18 @@ Object { exports[`test/lib/utils/error-message.js TAP eacces/eperm {"windows":false,"loaded":true,"cachePath":false,"cacheDest":true} > must match snapshot 2`] = ` Array [ + Array [ + "title", + "npm", + ], + Array [ + "argv", + "", + ], + Array [ + "logfile", + "logs-max:10 dir:{CWD}/test/lib/utils/tap-testdir-error-message-eacces-eperm--windows-false-loaded-true-cachePath-false-cacheDest-true-/cache/_logs", + ], Array [ "logfile", "{CWD}/test/lib/utils/tap-testdir-error-message-eacces-eperm--windows-false-loaded-true-cachePath-false-cacheDest-true-/cache/_logs/{DATE}-debug-0.log", @@ -562,6 +586,18 @@ Object { exports[`test/lib/utils/error-message.js TAP eacces/eperm {"windows":false,"loaded":true,"cachePath":true,"cacheDest":false} > must match snapshot 2`] = ` Array [ + Array [ + "title", + "npm", + ], + Array [ + "argv", + "", + ], + Array [ + "logfile", + "logs-max:10 dir:{CWD}/test/lib/utils/tap-testdir-error-message-eacces-eperm--windows-false-loaded-true-cachePath-true-cacheDest-false-/cache/_logs", + ], Array [ "logfile", "{CWD}/test/lib/utils/tap-testdir-error-message-eacces-eperm--windows-false-loaded-true-cachePath-true-cacheDest-false-/cache/_logs/{DATE}-debug-0.log", @@ -593,6 +629,18 @@ Object { exports[`test/lib/utils/error-message.js TAP eacces/eperm {"windows":false,"loaded":true,"cachePath":true,"cacheDest":true} > must match snapshot 2`] = ` Array [ + Array [ + "title", + "npm", + ], + Array [ + "argv", + "", + ], + Array [ + "logfile", + "logs-max:10 dir:{CWD}/test/lib/utils/tap-testdir-error-message-eacces-eperm--windows-false-loaded-true-cachePath-true-cacheDest-true-/cache/_logs", + ], Array [ "logfile", "{CWD}/test/lib/utils/tap-testdir-error-message-eacces-eperm--windows-false-loaded-true-cachePath-true-cacheDest-true-/cache/_logs/{DATE}-debug-0.log", @@ -771,6 +819,18 @@ Object { exports[`test/lib/utils/error-message.js TAP eacces/eperm {"windows":true,"loaded":true,"cachePath":false,"cacheDest":false} > must match snapshot 2`] = ` Array [ + Array [ + "title", + "npm", + ], + Array [ + "argv", + "", + ], + Array [ + "logfile", + "logs-max:10 dir:{CWD}/test/lib/utils/tap-testdir-error-message-eacces-eperm--windows-true-loaded-true-cachePath-false-cacheDest-false-/cache/_logs", + ], Array [ "logfile", "{CWD}/test/lib/utils/tap-testdir-error-message-eacces-eperm--windows-true-loaded-true-cachePath-false-cacheDest-false-/cache/_logs/{DATE}-debug-0.log", @@ -810,6 +870,18 @@ Object { exports[`test/lib/utils/error-message.js TAP eacces/eperm {"windows":true,"loaded":true,"cachePath":false,"cacheDest":true} > must match snapshot 2`] = ` Array [ + Array [ + "title", + "npm", + ], + Array [ + "argv", + "", + ], + Array [ + "logfile", + "logs-max:10 dir:{CWD}/test/lib/utils/tap-testdir-error-message-eacces-eperm--windows-true-loaded-true-cachePath-false-cacheDest-true-/cache/_logs", + ], Array [ "logfile", "{CWD}/test/lib/utils/tap-testdir-error-message-eacces-eperm--windows-true-loaded-true-cachePath-false-cacheDest-true-/cache/_logs/{DATE}-debug-0.log", @@ -849,6 +921,18 @@ Object { exports[`test/lib/utils/error-message.js TAP eacces/eperm {"windows":true,"loaded":true,"cachePath":true,"cacheDest":false} > must match snapshot 2`] = ` Array [ + Array [ + "title", + "npm", + ], + Array [ + "argv", + "", + ], + Array [ + "logfile", + "logs-max:10 dir:{CWD}/test/lib/utils/tap-testdir-error-message-eacces-eperm--windows-true-loaded-true-cachePath-true-cacheDest-false-/cache/_logs", + ], Array [ "logfile", "{CWD}/test/lib/utils/tap-testdir-error-message-eacces-eperm--windows-true-loaded-true-cachePath-true-cacheDest-false-/cache/_logs/{DATE}-debug-0.log", @@ -888,6 +972,18 @@ Object { exports[`test/lib/utils/error-message.js TAP eacces/eperm {"windows":true,"loaded":true,"cachePath":true,"cacheDest":true} > must match snapshot 2`] = ` Array [ + Array [ + "title", + "npm", + ], + Array [ + "argv", + "", + ], + Array [ + "logfile", + "logs-max:10 dir:{CWD}/test/lib/utils/tap-testdir-error-message-eacces-eperm--windows-true-loaded-true-cachePath-true-cacheDest-true-/cache/_logs", + ], Array [ "logfile", "{CWD}/test/lib/utils/tap-testdir-error-message-eacces-eperm--windows-true-loaded-true-cachePath-true-cacheDest-true-/cache/_logs/{DATE}-debug-0.log", diff --git a/tap-snapshots/test/lib/utils/exit-handler.js.test.cjs b/tap-snapshots/test/lib/utils/exit-handler.js.test.cjs index 0aaf235fd14b6..edb7edaa5d5f5 100644 --- a/tap-snapshots/test/lib/utils/exit-handler.js.test.cjs +++ b/tap-snapshots/test/lib/utils/exit-handler.js.test.cjs @@ -9,44 +9,54 @@ exports[`test/lib/utils/exit-handler.js TAP handles unknown error with logs and 0 timing npm:load:whichnode Completed in {TIME}ms 15 timing config:load Completed in {TIME}ms 16 timing npm:load:configload Completed in {TIME}ms -17 timing npm:load:setTitle Completed in {TIME}ms -19 timing npm:load:display Completed in {TIME}ms -20 verbose logfile {CWD}/test/lib/utils/tap-testdir-exit-handler-handles-unknown-error-with-logs-and-debug-file/cache/_logs/{DATE}-debug-0.log -21 timing npm:load:logFile Completed in {TIME}ms -22 timing npm:load:timers Completed in {TIME}ms -23 timing npm:load:configScope Completed in {TIME}ms -24 timing npm:load Completed in {TIME}ms -25 verbose stack Error: Unknown error -26 verbose cwd {CWD} -27 verbose Foo 1.0.0 -28 verbose argv "/node" "{CWD}/test/lib/utils/exit-handler.js" -29 verbose node v1.0.0 -30 verbose npm v1.0.0 -31 error code ECODE -32 error ERR SUMMARY Unknown error -33 error ERR DETAIL Unknown error -34 verbose exit 1 -36 timing npm Completed in {TIME}ms -37 verbose code 1 -38 error A complete log of this run can be found in: -38 error {CWD}/test/lib/utils/tap-testdir-exit-handler-handles-unknown-error-with-logs-and-debug-file/cache/_logs/{DATE}-debug-0.log +17 timing npm:load:mkdirpcache Completed in {TIME}ms +18 timing npm:load:mkdirplogs Completed in {TIME}ms +19 verbose title npm +20 verbose argv +21 timing npm:load:setTitle Completed in {TIME}ms +23 timing npm:load:display Completed in {TIME}ms +24 verbose logfile logs-max:10 dir:{CWD}/test/lib/utils/tap-testdir-exit-handler-handles-unknown-error-with-logs-and-debug-file/cache/_logs +25 verbose logfile {CWD}/test/lib/utils/tap-testdir-exit-handler-handles-unknown-error-with-logs-and-debug-file/cache/_logs/{DATE}-debug-0.log +26 timing npm:load:logFile Completed in {TIME}ms +27 timing npm:load:timers Completed in {TIME}ms +28 timing npm:load:configScope Completed in {TIME}ms +29 timing npm:load Completed in {TIME}ms +30 silly logfile done cleaning log files +31 verbose stack Error: Unknown error +32 verbose cwd {CWD} +33 verbose Foo 1.0.0 +34 verbose node v1.0.0 +35 verbose npm v1.0.0 +36 error code ECODE +37 error ERR SUMMARY Unknown error +38 error ERR DETAIL Unknown error +39 verbose exit 1 +41 timing npm Completed in {TIME}ms +42 verbose code 1 +43 error A complete log of this run can be found in: +43 error {CWD}/test/lib/utils/tap-testdir-exit-handler-handles-unknown-error-with-logs-and-debug-file/cache/_logs/{DATE}-debug-0.log ` exports[`test/lib/utils/exit-handler.js TAP handles unknown error with logs and debug file > logs 1`] = ` timing npm:load:whichnode Completed in {TIME}ms timing config:load Completed in {TIME}ms timing npm:load:configload Completed in {TIME}ms +timing npm:load:mkdirpcache Completed in {TIME}ms +timing npm:load:mkdirplogs Completed in {TIME}ms +verbose title npm +verbose argv timing npm:load:setTitle Completed in {TIME}ms timing npm:load:display Completed in {TIME}ms +verbose logfile logs-max:10 dir:{CWD}/test/lib/utils/tap-testdir-exit-handler-handles-unknown-error-with-logs-and-debug-file/cache/_logs verbose logfile {CWD}/test/lib/utils/tap-testdir-exit-handler-handles-unknown-error-with-logs-and-debug-file/cache/_logs/{DATE}-debug-0.log timing npm:load:logFile Completed in {TIME}ms timing npm:load:timers Completed in {TIME}ms timing npm:load:configScope Completed in {TIME}ms timing npm:load Completed in {TIME}ms +silly logfile done cleaning log files verbose stack Error: Unknown error verbose cwd {CWD} verbose Foo 1.0.0 -verbose argv "/node" "{CWD}/test/lib/utils/exit-handler.js" verbose node v1.0.0 verbose npm v1.0.0 error code ECODE diff --git a/tap-snapshots/test/lib/utils/log-file.js.test.cjs b/tap-snapshots/test/lib/utils/log-file.js.test.cjs index ecce9eafcc925..7a39184939026 100644 --- a/tap-snapshots/test/lib/utils/log-file.js.test.cjs +++ b/tap-snapshots/test/lib/utils/log-file.js.test.cjs @@ -6,63 +6,65 @@ */ 'use strict' exports[`test/lib/utils/log-file.js TAP snapshot > must match snapshot 1`] = ` -0 error no prefix -1 error prefix with prefix -2 error prefix 1 2 3 -3 verbose { obj: { with: { many: [Object] } } } -4 verbose {"obj":{"with":{"many":{"props":1}}}} -5 verbose { -5 verbose "obj": { -5 verbose "with": { -5 verbose "many": { -5 verbose "props": 1 -5 verbose } -5 verbose } -5 verbose } -5 verbose } -6 verbose [ 'test', 'with', 'an', 'array' ] -7 verbose ["test","with","an","array"] -8 verbose [ -8 verbose "test", -8 verbose "with", -8 verbose "an", -8 verbose "array" -8 verbose ] -9 verbose [ 'test', [ 'with', [ 'an', [Array] ] ] ] -10 verbose ["test",["with",["an",["array"]]]] -11 verbose [ -11 verbose "test", -11 verbose [ -11 verbose "with", -11 verbose [ -11 verbose "an", -11 verbose [ -11 verbose "array" -11 verbose ] -11 verbose ] -11 verbose ] -11 verbose ] -12 error pre has many errors Error: message -12 error pre at stack trace line 0 -12 error pre at stack trace line 1 -12 error pre at stack trace line 2 -12 error pre at stack trace line 3 -12 error pre at stack trace line 4 -12 error pre at stack trace line 5 -12 error pre at stack trace line 6 -12 error pre at stack trace line 7 -12 error pre at stack trace line 8 -12 error pre at stack trace line 9 Error: message2 -12 error pre at stack trace line 0 -12 error pre at stack trace line 1 -12 error pre at stack trace line 2 -12 error pre at stack trace line 3 -12 error pre at stack trace line 4 -12 error pre at stack trace line 5 -12 error pre at stack trace line 6 -12 error pre at stack trace line 7 -12 error pre at stack trace line 8 -12 error pre at stack trace line 9 -13 error nostack [Error: message] +0 verbose logfile logs-max:10 dir:{CWD}/test/lib/utils/tap-testdir-log-file-snapshot +1 silly logfile done cleaning log files +2 error no prefix +3 error prefix with prefix +4 error prefix 1 2 3 +5 verbose { obj: { with: { many: [Object] } } } +6 verbose {"obj":{"with":{"many":{"props":1}}}} +7 verbose { +7 verbose "obj": { +7 verbose "with": { +7 verbose "many": { +7 verbose "props": 1 +7 verbose } +7 verbose } +7 verbose } +7 verbose } +8 verbose [ 'test', 'with', 'an', 'array' ] +9 verbose ["test","with","an","array"] +10 verbose [ +10 verbose "test", +10 verbose "with", +10 verbose "an", +10 verbose "array" +10 verbose ] +11 verbose [ 'test', [ 'with', [ 'an', [Array] ] ] ] +12 verbose ["test",["with",["an",["array"]]]] +13 verbose [ +13 verbose "test", +13 verbose [ +13 verbose "with", +13 verbose [ +13 verbose "an", +13 verbose [ +13 verbose "array" +13 verbose ] +13 verbose ] +13 verbose ] +13 verbose ] +14 error pre has many errors Error: message +14 error pre at stack trace line 0 +14 error pre at stack trace line 1 +14 error pre at stack trace line 2 +14 error pre at stack trace line 3 +14 error pre at stack trace line 4 +14 error pre at stack trace line 5 +14 error pre at stack trace line 6 +14 error pre at stack trace line 7 +14 error pre at stack trace line 8 +14 error pre at stack trace line 9 Error: message2 +14 error pre at stack trace line 0 +14 error pre at stack trace line 1 +14 error pre at stack trace line 2 +14 error pre at stack trace line 3 +14 error pre at stack trace line 4 +14 error pre at stack trace line 5 +14 error pre at stack trace line 6 +14 error pre at stack trace line 7 +14 error pre at stack trace line 8 +14 error pre at stack trace line 9 +15 error nostack [Error: message] ` diff --git a/test/fixtures/mock-npm.js b/test/fixtures/mock-npm.js index ea608d6644446..0102ed198151f 100644 --- a/test/fixtures/mock-npm.js +++ b/test/fixtures/mock-npm.js @@ -9,10 +9,12 @@ const RealMockNpm = (t, otherMocks = {}) => { const mock = { ...mockLogs(otherMocks), outputs: [], + outputErrors: [], joinedOutput: () => mock.outputs.map(o => o.join(' ')).join('\n'), } const Npm = t.mock('../../lib/npm.js', { + '../../lib/utils/update-notifier.js': async () => {}, ...otherMocks, ...mock.logMocks, }) @@ -23,9 +25,17 @@ const RealMockNpm = (t, otherMocks = {}) => { super.output(...args) } + originalOutputError (...args) { + super.outputError(...args) + } + output (...args) { mock.outputs.push(args) } + + outputError (...args) { + mock.outputErrors.push(args) + } } return mock @@ -100,7 +110,11 @@ const LoadMockNpm = async (t, { if (load) { await npm.load() for (const [k, v] of Object.entries(result(config, { npm, prefix, cache }))) { - npm.config.set(k, v) + if (typeof v === 'object' && v.value && v.where) { + npm.config.set(k, v.value, v.where) + } else { + npm.config.set(k, v) + } } // Set global loglevel *again* since it possibly got reset during load // XXX: remove with npmlog @@ -115,6 +129,7 @@ const LoadMockNpm = async (t, { Npm, npm, prefix, + globalPrefix, testdir: dir, cache, debugFile: async () => { diff --git a/test/lib/cli.js b/test/lib/cli.js index f02c57d8cf730..b6606c69fc429 100644 --- a/test/lib/cli.js +++ b/test/lib/cli.js @@ -1,9 +1,8 @@ const t = require('tap') -const mockGlobals = require('../fixtures/mock-globals.js') const { load: loadMockNpm } = require('../fixtures/mock-npm.js') -const cliMock = async (t, mocks) => { +const cliMock = async (t, opts) => { let exitHandlerArgs = null let npm = null const exitHandlerMock = (...args) => { @@ -12,10 +11,9 @@ const cliMock = async (t, mocks) => { } exitHandlerMock.setNpm = _npm => npm = _npm - const { Npm, outputs, logMocks, logs } = await loadMockNpm(t, { mocks, init: false }) + const { Npm, outputs, logMocks, logs } = await loadMockNpm(t, { ...opts, init: false }) const cli = t.mock('../../lib/cli.js', { '../../lib/npm.js': Npm, - '../../lib/utils/update-notifier.js': async () => null, '../../lib/utils/unsupported.js': { checkForBrokenNode: () => {}, checkForUnsupportedNode: () => {}, @@ -31,6 +29,7 @@ const cliMock = async (t, mocks) => { exitHandlerCalled: () => exitHandlerArgs, exitHandlerNpm: () => npm, logs, + logsBy: (title) => logs.verbose.filter(([p]) => p === title).map(([p, ...rest]) => rest), } } @@ -39,17 +38,15 @@ t.afterEach(() => { }) t.test('print the version, and treat npm_g as npm -g', async t => { - mockGlobals(t, { - 'process.argv': ['node', 'npm_g', '-v'], + const { logsBy, logs, cli, Npm, outputs, exitHandlerCalled } = await cliMock(t, { + globals: { 'process.argv': ['node', 'npm_g', '-v'] }, }) - - const { logs, cli, Npm, outputs, exitHandlerCalled } = await cliMock(t) await cli(process) t.strictSame(process.argv, ['node', 'npm', '-g', '-v'], 'system process.argv was rewritten') - t.strictSame(logs.verbose.filter(([p]) => p !== 'logfile'), [ - ['cli', process.argv], - ]) + t.strictSame(logsBy('cli'), [['node npm']]) + t.strictSame(logsBy('title'), [['npm']]) + t.strictSame(logsBy('argv'), [['"--global" "--version"']]) t.strictSame(logs.info, [ ['using', 'npm@%s', Npm.version], ['using', 'node@%s', process.version], @@ -59,68 +56,82 @@ t.test('print the version, and treat npm_g as npm -g', async t => { }) t.test('calling with --versions calls npm version with no args', async t => { - t.plan(6) - mockGlobals(t, { - 'process.argv': ['node', 'npm', 'install', 'or', 'whatever', '--versions'], - }) - const { logs, cli, Npm, outputs, exitHandlerCalled } = await cliMock(t, { - '../../lib/commands/version.js': class Version { - async exec (args) { - t.strictSame(args, []) - } + const { logsBy, cli, outputs, exitHandlerCalled } = await cliMock(t, { + mocks: { + '../../lib/commands/version.js': class Version { + async exec (args) { + t.strictSame(args, []) + } + }, + }, + globals: { + 'process.argv': ['node', 'npm', 'install', 'or', 'whatever', '--versions'], }, }) - await cli(process) - t.equal(process.title, 'npm install or whatever') - t.strictSame(logs.verbose.filter(([p]) => p !== 'logfile'), [ - ['cli', process.argv], - ]) - t.strictSame(logs.info, [ - ['using', 'npm@%s', Npm.version], - ['using', 'node@%s', process.version], - ]) + t.equal(process.title, 'npm install or whatever') + t.strictSame(logsBy('cli'), [['node npm']]) + t.strictSame(logsBy('title'), [['npm install or whatever']]) + t.strictSame(logsBy('argv'), [['"install" "or" "whatever" "--versions"']]) t.strictSame(outputs, []) t.strictSame(exitHandlerCalled(), []) }) t.test('logged argv is sanitized', async t => { - mockGlobals(t, { - 'process.argv': [ - 'node', - 'npm', - 'version', - 'https://username:password@npmjs.org/test_url_with_a_password', - ], - }) - const { logs, cli, Npm } = await cliMock(t, { - '../../lib/commands/version.js': class Version { - async exec (args) {} + const { logsBy, cli } = await cliMock(t, { + mocks: { + '../../lib/commands/version.js': class Version { + async exec () {} + }, + }, + globals: { + 'process.argv': [ + 'node', + 'npm', + 'version', + '--registry', + 'https://u:password@npmjs.org/password', + ], }, }) await cli(process) - t.ok(process.title.startsWith('npm version https://username:***@npmjs.org')) - t.strictSame(logs.verbose.filter(([p]) => p !== 'logfile'), [ - [ - 'cli', - ['node', 'npm', 'version', 'https://username:***@npmjs.org/test_url_with_a_password'], - ], - ]) - t.strictSame(logs.info, [ - ['using', 'npm@%s', Npm.version], - ['using', 'node@%s', process.version], - ]) + t.equal(process.title, 'npm version') + t.strictSame(logsBy('cli'), [['node npm']]) + t.strictSame(logsBy('title'), [['npm version']]) + t.strictSame(logsBy('argv'), [['"version" "--registry" "https://u:***@npmjs.org/password"']]) }) -t.test('print usage if no params provided', async t => { - mockGlobals(t, { - 'process.argv': ['node', 'npm'], +t.test('logged argv is sanitized with equals', async t => { + const { logsBy, cli } = await cliMock(t, { + mocks: { + '../../lib/commands/version.js': class Version { + async exec () {} + }, + }, + globals: { + 'process.argv': [ + 'node', + 'npm', + 'version', + '--registry=https://u:password@npmjs.org', + ], + }, }) + await cli(process) + + t.strictSame(logsBy('argv'), [['"version" "--registry" "https://u:***@npmjs.org"']]) +}) - const { cli, outputs, exitHandlerCalled, exitHandlerNpm } = await cliMock(t) +t.test('print usage if no params provided', async t => { + const { cli, outputs, exitHandlerCalled, exitHandlerNpm } = await cliMock(t, { + globals: { + 'process.argv': ['node', 'npm'], + }, + }) await cli(process) + t.match(outputs[0][0], 'Usage:', 'outputs npm usage') t.match(exitHandlerCalled(), [], 'should call exitHandler with no args') t.ok(exitHandlerNpm(), 'exitHandler npm is set') @@ -128,12 +139,13 @@ t.test('print usage if no params provided', async t => { }) t.test('print usage if non-command param provided', async t => { - mockGlobals(t, { - 'process.argv': ['node', 'npm', 'tset'], + const { cli, outputs, exitHandlerCalled, exitHandlerNpm } = await cliMock(t, { + globals: { + 'process.argv': ['node', 'npm', 'tset'], + }, }) - - const { cli, outputs, exitHandlerCalled, exitHandlerNpm } = await cliMock(t) await cli(process) + t.match(outputs[0][0], 'Unknown command: "tset"') t.match(outputs[0][0], 'Did you mean this?') t.match(exitHandlerCalled(), [], 'should call exitHandler with no args') @@ -142,21 +154,22 @@ t.test('print usage if non-command param provided', async t => { }) t.test('load error calls error handler', async t => { - mockGlobals(t, { - 'process.argv': ['node', 'npm', 'asdf'], - }) - const err = new Error('test load error') const { cli, exitHandlerCalled } = await cliMock(t, { - '../../lib/utils/config/index.js': { - definitions: null, - flatten: null, - shorthands: null, + mocks: { + '../../lib/utils/config/index.js': { + definitions: null, + flatten: null, + shorthands: null, + }, + '@npmcli/config': class BadConfig { + async load () { + throw err + } + }, }, - '@npmcli/config': class BadConfig { - async load () { - throw err - } + globals: { + 'process.argv': ['node', 'npm', 'asdf'], }, }) await cli(process) diff --git a/test/lib/commands/bin.js b/test/lib/commands/bin.js index 4de5a923b3eb6..a889b13361624 100644 --- a/test/lib/commands/bin.js +++ b/test/lib/commands/bin.js @@ -1,76 +1,60 @@ const t = require('tap') -const { fake: mockNpm } = require('../../fixtures/mock-npm') +const { relative, join } = require('path') +const { load: loadMockNpm } = require('../../fixtures/mock-npm') +const mockGlobals = require('../../fixtures/mock-globals') -t.test('bin', async t => { - t.plan(2) - const dir = '/bin/dir' - - const Bin = require('../../../lib/commands/bin.js') +const mockBin = async (t, { args = [], config = {} } = {}) => { + const { npm, outputs, ...rest } = await loadMockNpm(t, { + config, + }) + const cmd = await npm.cmd('bin') + await npm.exec('bin', args) + + return { + npm, + cmd, + bin: outputs[0][0], + ...rest, + } +} - const npm = mockNpm({ - bin: dir, +t.test('bin', async t => { + const { cmd, bin, prefix, outputErrors } = await mockBin(t, { config: { global: false }, - output: (output) => { - t.equal(output, dir, 'prints the correct directory') - }, }) - const bin = new Bin(npm) - t.match(bin.usage, 'bin', 'usage has command name in it') - await bin.exec([]) + t.match(cmd.usage, 'bin', 'usage has command name in it') + t.equal(relative(prefix, bin), join('node_modules/.bin'), 'prints the correct directory') + t.strictSame(outputErrors, []) }) t.test('bin -g', async t => { - t.plan(1) - const consoleError = console.error - t.teardown(() => { - console.error = consoleError + mockGlobals(t, { 'process.platform': 'posix' }) + const { globalPrefix, bin, outputErrors } = await mockBin(t, { + config: { global: true }, }) - console.error = (output) => { - t.fail('should not have printed to console.error') - } - const dir = '/bin/dir' - - const Bin = t.mock('../../../lib/commands/bin.js', { - '../../../lib/utils/path.js': [dir], - }) + t.equal(relative(globalPrefix, bin), 'bin', 'prints the correct directory') + t.strictSame(outputErrors, []) +}) - const npm = mockNpm({ - bin: dir, +t.test('bin -g win32', async t => { + mockGlobals(t, { 'process.platform': 'win32' }) + const { globalPrefix, bin, outputErrors } = await mockBin(t, { config: { global: true }, - output: (output) => { - t.equal(output, dir, 'prints the correct directory') - }, }) - const bin = new Bin(npm) - await bin.exec([]) + t.equal(relative(globalPrefix, bin), '', 'prints the correct directory') + t.strictSame(outputErrors, []) }) t.test('bin -g (not in path)', async t => { - t.plan(2) - const consoleError = console.error - t.teardown(() => { - console.error = consoleError - }) - - console.error = (output) => { - t.equal(output, '(not in PATH env variable)', 'prints env warning') - } - const dir = '/bin/dir' - - const Bin = t.mock('../../../lib/commands/bin.js', { - '../../../lib/utils/path.js': ['/not/my/dir'], - }) - const npm = mockNpm({ - bin: dir, + const { logs } = await mockBin(t, { config: { global: true }, - output: (output) => { - t.equal(output, dir, 'prints the correct directory') + globals: { + 'process.env.PATH': 'emptypath', }, }) - const bin = new Bin(npm) - await bin.exec([]) + t.strictSame(logs.error[0], ['bin', '(not in PATH env variable)']) }) diff --git a/test/lib/commands/doctor.js b/test/lib/commands/doctor.js index 5badab99a1d56..620d908d3030e 100644 --- a/test/lib/commands/doctor.js +++ b/test/lib/commands/doctor.js @@ -52,17 +52,7 @@ const dirs = { }, } -let consoleError = false -t.afterEach(() => { - consoleError = false -}) - const globals = { - console: { - error: () => { - consoleError = true - }, - }, process: { platform: 'test-not-windows', version: 'v1.0.0', @@ -104,7 +94,6 @@ t.test('all clear', async t => { .get('/dist/index.json').reply(200, nodeVersions) await npm.exec('doctor', []) t.matchSnapshot(joinedOutput(), 'output') - t.notOk(consoleError, 'console.error not called') t.matchSnapshot({ info: logs.info, warn: logs.warn, error: logs.error }, 'logs') }) @@ -122,7 +111,6 @@ t.test('all clear in color', async t => { npm.config.set('color', 'always') await npm.exec('doctor', []) t.matchSnapshot(joinedOutput(), 'everything is ok in color') - t.notOk(consoleError, 'console.error not called') t.matchSnapshot({ info: logs.info, warn: logs.warn, error: logs.error }, 'logs') }) @@ -142,7 +130,6 @@ t.test('silent', async t => { .get('/dist/index.json').reply(200, nodeVersions) await npm.exec('doctor', []) t.matchSnapshot(joinedOutput(), 'output') - t.notOk(consoleError, 'console.error not called') t.matchSnapshot({ info: logs.info, warn: logs.warn, error: logs.error }, 'logs') }) @@ -159,7 +146,6 @@ t.test('ping 404', async t => { .get('/dist/index.json').reply(200, nodeVersions) await t.rejects(npm.exec('doctor', [])) t.matchSnapshot(joinedOutput(), 'ping 404') - t.ok(consoleError, 'console.error called') t.matchSnapshot({ info: logs.info, warn: logs.warn, error: logs.error }, 'logs') }) diff --git a/test/lib/npm.js b/test/lib/npm.js index 3ae2af35c287d..c7d8f12ca7913 100644 --- a/test/lib/npm.js +++ b/test/lib/npm.js @@ -139,10 +139,11 @@ t.test('npm.load', async t => { }) t.test('forceful loading', async t => { - mockGlobals(t, { - 'process.argv': [...process.argv, '--force', '--color', 'always'], + const { logs } = await loadMockNpm(t, { + globals: { + 'process.argv': [...process.argv, '--force', '--color', 'always'], + }, }) - const { logs } = await loadMockNpm(t) t.match(logs.warn, [ [ 'using --force', @@ -153,23 +154,21 @@ t.test('npm.load', async t => { t.test('node is a symlink', async t => { const node = process.platform === 'win32' ? 'node.exe' : 'node' - mockGlobals(t, { - 'process.argv': [ - node, - process.argv[1], - '--usage', - '--scope=foo', - 'token', - 'revoke', - 'blergggg', - ], - }) const { npm, logs, outputs, prefix } = await loadMockNpm(t, { prefixDir: { bin: t.fixture('symlink', dirname(process.execPath)), }, globals: ({ prefix }) => ({ 'process.env.PATH': resolve(prefix, 'bin'), + 'process.argv': [ + node, + process.argv[1], + '--usage', + '--scope=foo', + 'token', + 'revoke', + 'blergggg', + ], }), }) @@ -181,6 +180,9 @@ t.test('npm.load', async t => { ], [ ['npm:load:whichnode', /Completed in [0-9.]+ms/], ['node symlink', resolve(prefix, 'bin', node)], + ['title', 'npm token revoke blergggg'], + ['argv', '"--usage" "--scope" "foo" "token" "revoke" "blergggg"'], + ['logfile', /logs-max:\d+ dir:.*/], ['logfile', /.*-debug-0.log/], ['npm:load', /Completed in [0-9.]+ms/], ]) @@ -226,15 +228,6 @@ t.test('npm.load', async t => { }) t.test('--no-workspaces with --workspace', async t => { - mockGlobals(t, { - 'process.argv': [ - process.execPath, - process.argv[1], - '--color', 'false', - '--workspaces', 'false', - '--workspace', 'a', - ], - }) const { npm } = await loadMockNpm(t, { load: false, prefixDir: { @@ -253,6 +246,15 @@ t.test('npm.load', async t => { workspaces: ['./packages/*'], }), }, + globals: { + 'process.argv': [ + process.execPath, + process.argv[1], + '--color', 'false', + '--workspaces', 'false', + '--workspace', 'a', + ], + }, }) await t.rejects( npm.exec('run', []), @@ -261,14 +263,6 @@ t.test('npm.load', async t => { }) t.test('workspace-aware configs and commands', async t => { - mockGlobals(t, { - 'process.argv': [ - process.execPath, - process.argv[1], - '--color', 'false', - '--workspaces', 'true', - ], - }) const { npm, outputs } = await loadMockNpm(t, { prefixDir: { packages: { @@ -293,6 +287,14 @@ t.test('npm.load', async t => { workspaces: ['./packages/*'], }), }, + globals: { + 'process.argv': [ + process.execPath, + process.argv[1], + '--color', 'false', + '--workspaces', 'true', + ], + }, }) // verify that calling the command with a short name still sets @@ -317,17 +319,6 @@ t.test('npm.load', async t => { }) t.test('workspaces in global mode', async t => { - mockGlobals(t, { - 'process.argv': [ - process.execPath, - process.argv[1], - '--color', - 'false', - '--workspaces', - '--global', - 'true', - ], - }) const { npm } = await loadMockNpm(t, { prefixDir: { packages: { @@ -352,6 +343,17 @@ t.test('npm.load', async t => { workspaces: ['./packages/*'], }), }, + globals: { + 'process.argv': [ + process.execPath, + process.argv[1], + '--color', + 'false', + '--workspaces', + '--global', + 'true', + ], + }, }) // verify that calling the command with a short name still sets // the npm.command property to the full canonical name of the cmd. @@ -365,68 +367,93 @@ t.test('npm.load', async t => { t.test('set process.title', async t => { t.test('basic title setting', async t => { - mockGlobals(t, { - 'process.argv': [ - process.execPath, - process.argv[1], - '--usage', - '--scope=foo', - 'ls', - ], + const { npm } = await loadMockNpm(t, { + globals: { + 'process.argv': [ + process.execPath, + process.argv[1], + '--usage', + '--scope=foo', + 'ls', + ], + }, }) - const { npm } = await loadMockNpm(t) t.equal(npm.title, 'npm ls') t.equal(process.title, 'npm ls') }) t.test('do not expose token being revoked', async t => { - mockGlobals(t, { - 'process.argv': [ - process.execPath, - process.argv[1], - '--usage', - '--scope=foo', - 'token', - 'revoke', - 'deadbeefcafebad', - ], + const { npm } = await loadMockNpm(t, { + globals: { + 'process.argv': [ + process.execPath, + process.argv[1], + '--usage', + '--scope=foo', + 'token', + 'revoke', + `npm_${'a'.repeat(36)}`, + ], + }, }) - const { npm } = await loadMockNpm(t) - t.equal(npm.title, 'npm token revoke ***') - t.equal(process.title, 'npm token revoke ***') + t.equal(npm.title, 'npm token revoke npm_***') + t.equal(process.title, 'npm token revoke npm_***') }) t.test('do show *** unless a token is actually being revoked', async t => { - mockGlobals(t, { - 'process.argv': [ - process.execPath, - process.argv[1], - '--usage', - '--scope=foo', - 'token', - 'revoke', - ], + const { npm } = await loadMockNpm(t, { + globals: { + 'process.argv': [ + process.execPath, + process.argv[1], + '--usage', + '--scope=foo', + 'token', + 'revoke', + 'notatoken', + ], + }, }) - const { npm } = await loadMockNpm(t) - t.equal(npm.title, 'npm token revoke') - t.equal(process.title, 'npm token revoke') + t.equal(npm.title, 'npm token revoke notatoken') + t.equal(process.title, 'npm token revoke notatoken') }) }) -t.test('debug-log', async t => { - const { npm, debugFile } = await loadMockNpm(t, { load: false }) +t.test('debug log', async t => { + t.test('writes log file', async t => { + const { npm, debugFile } = await loadMockNpm(t, { load: false }) + + const log1 = ['silly', 'test', 'before load'] + const log2 = ['silly', 'test', 'after load'] - const log1 = ['silly', 'test', 'before load'] - const log2 = ['silly', 'test', 'after load'] + process.emit('log', ...log1) + await npm.load() + process.emit('log', ...log2) - process.emit('log', ...log1) - await npm.load() - process.emit('log', ...log2) + const debug = await debugFile() + t.equal(npm.logFiles.length, 1, 'one debug file') + t.match(debug, log1.join(' '), 'before load appears') + t.match(debug, log2.join(' '), 'after load log appears') + }) + + t.test('with bad dir', async t => { + const { npm } = await loadMockNpm(t, { + globals: { + 'process.env.npm_config_logs_dir': 'LOGS_DIR', + }, + mocks: { + '@npmcli/fs': { + mkdir: async (dir) => { + if (dir.includes('LOGS_DIR')) { + throw new Error('err') + } + }, + }, + }, + }) - const debug = await debugFile() - t.equal(npm.logFiles.length, 1, 'one debug file') - t.match(debug, log1.join(' '), 'before load appears') - t.match(debug, log2.join(' '), 'after load log appears') + t.equal(npm.logFiles.length, 0, 'no log file') + }) }) t.test('timings', async t => { @@ -458,13 +485,14 @@ t.test('timings', async t => { }) t.test('writes timings file', async t => { - const { npm, timingFile } = await loadMockNpm(t, { + const { npm, cache, timingFile } = await loadMockNpm(t, { config: { timing: true }, }) process.emit('time', 'foo') process.emit('timeEnd', 'foo') process.emit('time', 'bar') npm.unload() + t.equal(npm.timingFile, join(cache, '_timing.json')) const timings = await timingFile() t.match(timings, { command: [], @@ -490,15 +518,10 @@ t.test('timings', async t => { }) t.test('output clears progress and console.logs the message', async t => { - t.plan(2) + t.plan(4) let showingProgress = true const logs = [] - mockGlobals(t, { - 'console.log': (...args) => { - t.equal(showingProgress, false, 'should not be showing progress right now') - logs.push(args) - }, - }) + const errors = [] const { npm } = await loadMockNpm(t, { load: false, mocks: { @@ -507,9 +530,22 @@ t.test('output clears progress and console.logs the message', async t => { showProgress: () => showingProgress = true, }, }, + globals: { + 'console.log': (...args) => { + t.equal(showingProgress, false, 'should not be showing progress right now') + logs.push(args) + }, + 'console.error': (...args) => { + t.equal(showingProgress, false, 'should not be showing progress right now') + errors.push(args) + }, + }, }) npm.originalOutput('hello') + npm.originalOutputError('error') + t.match(logs, [['hello']]) + t.match(errors, [['error']]) t.end() }) @@ -522,14 +558,6 @@ t.test('unknown command', async t => { }) t.test('explicit workspace rejection', async t => { - mockGlobals(t, { - 'process.argv': [ - process.execPath, - process.argv[1], - '--color', 'false', - '--workspace', './packages/a', - ], - }) const mock = await loadMockNpm(t, { prefixDir: { packages: { @@ -547,6 +575,14 @@ t.test('explicit workspace rejection', async t => { workspaces: ['./packages/a'], }), }, + globals: { + 'process.argv': [ + process.execPath, + process.argv[1], + '--color', 'false', + '--workspace', './packages/a', + ], + }, }) await t.rejects( mock.npm.exec('ping', []), @@ -572,15 +608,17 @@ t.test('implicit workspace rejection', async t => { workspaces: ['./packages/a'], }), }, - }) - const cwd = join(mock.npm.config.localPrefix, 'packages', 'a') - mock.npm.config.set('workspace', [cwd], 'default') - mockGlobals(t, { - 'process.argv': [ - process.execPath, - process.argv[1], - '--color', 'false', - ], + globals: { + 'process.argv': [ + process.execPath, + process.argv[1], + '--color', 'false', + '--workspace', './packages/a', + ], + }, + config: ({ prefix }) => ({ + workspace: { value: [join(prefix, 'packages', 'a')], where: 'default' }, + }), }) await t.rejects( mock.npm.exec('owner', []), @@ -606,19 +644,17 @@ t.test('implicit workspace accept', async t => { workspaces: ['./packages/a'], }), }, + globals: ({ prefix }) => ({ + 'process.cwd': () => prefix, + 'process.argv': [ + process.execPath, + process.argv[1], + '--color', 'false', + ], + }), + config: ({ prefix }) => ({ + workspace: { value: [join(prefix, 'packages', 'a')], where: 'default' }, + }), }) - const cwd = join(mock.npm.config.localPrefix, 'packages', 'a') - mock.npm.config.set('workspace', [cwd], 'default') - mockGlobals(t, { - 'process.cwd': () => mock.npm.config.cwd, - 'process.argv': [ - process.execPath, - process.argv[1], - '--color', 'false', - ], - }) - await t.rejects( - mock.npm.exec('org', []), - /.*Usage/ - ) + await t.rejects(mock.npm.exec('org', []), /.*Usage/) }) diff --git a/test/lib/utils/exit-handler.js b/test/lib/utils/exit-handler.js index 6a96d92ddb250..1ec702c0038bd 100644 --- a/test/lib/utils/exit-handler.js +++ b/test/lib/utils/exit-handler.js @@ -21,9 +21,10 @@ t.formatSnapshot = (obj) => { } t.cleanSnapshot = (path) => cleanDate(cleanCwd(path)) -// Config loading is dependent on env so strip those from snapshots + // Config loading is dependent on env so strip those from snapshots .replace(/.*timing config:load:.*\n/gm, '') .replace(/(Completed in )\d+(ms)/g, '$1{TIME}$2') + .replace(/(removing )\d+( files)/g, '$1${NUM}2') // cut off process from script so that it won't quit the test runner // while trying to run through the myriad of cases. need to make it @@ -44,9 +45,8 @@ mockGlobals(t, { }), }, { replace: true }) -const mockExitHandler = async (t, { init, load, testdir, config } = {}) => { +const mockExitHandler = async (t, { init, load, testdir, config, globals, mocks } = {}) => { const errors = [] - mockGlobals(t, { 'console.error': (err) => errors.push(err) }) const { npm, logMocks, ...rest } = await loadMockNpm(t, { init, @@ -56,11 +56,16 @@ const mockExitHandler = async (t, { init, load, testdir, config } = {}) => { '../../package.json': { version: '1.0.0', }, + ...mocks, }, config: { loglevel: 'notice', ...config, }, + globals: { + 'console.error': (err) => errors.push(err), + ...globals, + }, }) const exitHandler = t.mock('../../../lib/utils/exit-handler.js', { @@ -74,6 +79,7 @@ const mockExitHandler = async (t, { init, load, testdir, config } = {}) => { release: () => '1.0.0', }, ...logMocks, + ...mocks, }) if (npm) { @@ -89,13 +95,14 @@ const mockExitHandler = async (t, { init, load, testdir, config } = {}) => { ...rest, errors, npm, - // Make it async to make testing ergonomics a little - // easier so we dont need to t.plan() every test to - // make sure we get process.exit called - exitHandler: (...args) => new Promise(resolve => { + // Make it async to make testing ergonomics a little easier so we dont need + // to t.plan() every test to make sure we get process.exit called. Also + // introduce a small artificial delay so the logs are consistently finished + // by the time the exit handler forces process.exit + exitHandler: (...args) => new Promise(resolve => setTimeout(() => { process.once('exit', resolve) exitHandler(...args) - }), + }, 50)), } } @@ -199,7 +206,7 @@ t.test('exit handler called - no npm with error without stack', async (t) => { }) t.test('console.log output using --json', async (t) => { - const { exitHandler, errors } = await mockExitHandler(t, { + const { exitHandler, outputErrors } = await mockExitHandler(t, { config: { json: true, }, @@ -209,7 +216,7 @@ t.test('console.log output using --json', async (t) => { t.equal(process.exitCode, 1) t.same( - JSON.parse(errors[0]), + JSON.parse(outputErrors[0]), { error: { code: 'EBADTHING', // should default error code to E[A-Z]+ @@ -273,6 +280,41 @@ t.test('npm.config not ready', async (t) => { ], 'should exit with config error msg') }) +t.test('no logs dir', async (t) => { + const { exitHandler, logs } = await mockExitHandler(t, { + globals: { + 'process.env.npm_config_logs_max': '0', + }, + }) + + await exitHandler(new Error()) + + t.match(logs.error.filter(([t]) => t === ''), [ + ['', 'No log file was written due to the config logs-max=0'], + ]) +}) + +t.test('log file error', async (t) => { + const { exitHandler, logs } = await mockExitHandler(t, { + globals: { + 'process.env.npm_config_logs_dir': 'LOGS_DIR', + }, + mocks: { + '@npmcli/fs': { + mkdir: async (dir) => { + if (dir.includes('LOGS_DIR')) { + throw new Error('err') + } + }, + }, + }, + }) + + await exitHandler(new Error()) + + t.match(logs.error.filter(([t]) => t === ''), [['', `error writing to the directory`]]) +}) + t.test('timing with no error', async (t) => { const { exitHandler, timingFile, npm, logs } = await mockExitHandler(t, { config: { @@ -285,9 +327,9 @@ t.test('timing with no error', async (t) => { t.equal(process.exitCode, 0) - t.match(logs.error, [ + t.match(logs.info.filter(([t]) => t === ''), [ ['', /A complete log of this run can be found in:[\s\S]*-debug-\d\.log/], - ]) + ], 'timing message is on info not error') t.match( timingFileData, @@ -376,7 +418,7 @@ t.test('verbose logs replace info on err props', async t => { await exitHandler(err('Error with code type number', properties)) t.equal(process.exitCode, 1) t.match( - logs.verbose.filter(([p]) => p !== 'logfile'), + logs.verbose.filter(([p]) => !['logfile', 'title', 'argv'].includes(p)), keys.map((k) => [k, `${k}-https://user:***@registry.npmjs.org/`]), 'all special keys get replaced' ) diff --git a/test/lib/utils/log-file.js b/test/lib/utils/log-file.js index 007ce221b0940..ce6f0bf4cf51f 100644 --- a/test/lib/utils/log-file.js +++ b/test/lib/utils/log-file.js @@ -116,12 +116,12 @@ t.test('max files per process', async t => { } for (const i of range(5)) { - logFile.log('verbose', `log ${i}`) + logFile.log('verbose', `ignored after maxlogs hit ${i}`) } const logs = await readLogs() t.equal(logs.length, maxFilesPerProcess, 'total log files') - t.equal(last(last(logs).logs), '49 error log 49') + t.match(last(last(logs).logs), /49 error log \d+/) }) t.test('stream error', async t => { @@ -182,8 +182,7 @@ t.test('turns off', async t => { logFile.load() const logs = await readLogs() - t.equal(logs.length, 1) - t.equal(logs[0].logs[0], '0 error test') + t.match(last(last(logs).logs), /^\d+ error test$/) }) t.test('cleans logs', async t => { @@ -198,7 +197,7 @@ t.test('cleans logs', async t => { }) t.test('doesnt clean current log by default', async t => { - const logsMax = 0 + const logsMax = 1 const { readLogs, logFile } = await loadLogFile(t, { logsMax, testdir: makeOldLogs(10), @@ -207,7 +206,6 @@ t.test('doesnt clean current log by default', async t => { logFile.log('error', 'test') const logs = await readLogs() - t.equal(logs.length, 1) t.match(last(logs).content, /\d+ error test/) }) @@ -221,8 +219,7 @@ t.test('negative logs max', async t => { logFile.log('error', 'test') const logs = await readLogs() - t.equal(logs.length, 1) - t.match(last(logs).content, /\d+ error test/) + t.equal(logs.length, 0) }) t.test('doesnt need to clean', async t => { @@ -257,7 +254,7 @@ t.test('cleans old style logs too', async t => { const oldLogs = 10 const { readLogs } = await loadLogFile(t, { logsMax, - testdir: makeOldLogs(oldLogs, false), + testdir: makeOldLogs(oldLogs, true), }) const logs = await readLogs() @@ -304,7 +301,7 @@ t.test('delete log file while open', async t => { }) t.test('snapshot', async t => { - const { logFile, readLogs } = await loadLogFile(t) + const { logFile, readLogs } = await loadLogFile(t, { logsMax: 10 }) logFile.log('error', '', 'no prefix') logFile.log('error', 'prefix', 'with prefix') diff --git a/test/lib/utils/replace-info.js b/test/lib/utils/replace-info.js index e4b83783a55a7..c7fffdb544096 100644 --- a/test/lib/utils/replace-info.js +++ b/test/lib/utils/replace-info.js @@ -19,12 +19,30 @@ t.equal( 'should return numbers' ) +t.equal( + replaceInfo(' == = = '), + ' == = = ', + 'should return same string with only separators' +) + +t.equal( + replaceInfo(''), + '', + 'should return empty string' +) + t.equal( replaceInfo('https://user:pass@registry.npmjs.org/'), 'https://user:***@registry.npmjs.org/', 'should replace single item' ) +t.equal( + replaceInfo(`https://registry.npmjs.org/path/npm_${'a'.repeat('36')}`), + 'https://registry.npmjs.org/path/npm_***', + 'should replace single item token' +) + t.equal( replaceInfo('https://example.npmjs.org'), 'https://example.npmjs.org', @@ -49,6 +67,12 @@ t.equal( 'should replace single item within a phrase' ) +t.equal( + replaceInfo('Something --x=https://user:pass@registry.npmjs.org/ foo bar'), + 'Something --x=https://user:***@registry.npmjs.org/ foo bar', + 'should replace single item within a phrase separated by =' +) + t.same( replaceInfo([ 'Something https://user:pass@registry.npmjs.org/ foo bar', @@ -60,7 +84,21 @@ t.same( 'http://foo:***@registry.npmjs.org', 'http://example.npmjs.org', ], - 'should replace single item within a phrase' + 'should replace items in an array' +) + +t.same( + replaceInfo([ + 'Something --x=https://user:pass@registry.npmjs.org/ foo bar', + '--url=http://foo:bar@registry.npmjs.org', + '--url=http://example.npmjs.org', + ]), + [ + 'Something --x=https://user:***@registry.npmjs.org/ foo bar', + '--url=http://foo:***@registry.npmjs.org', + '--url=http://example.npmjs.org', + ], + 'should replace items in an array with equals' ) t.same( diff --git a/test/lib/utils/timers.js b/test/lib/utils/timers.js index 6127f346b179e..3216b7882ef1b 100644 --- a/test/lib/utils/timers.js +++ b/test/lib/utils/timers.js @@ -31,6 +31,17 @@ t.test('listens/stops on process', async (t) => { t.notOk(timers.unfinished.get('baz')) }) +t.test('convenience time method', async (t) => { + const { timers } = mockTimers(t) + + const end = timers.time('later') + timers.time('sync', () => {}) + await timers.time('async', () => new Promise(r => setTimeout(r, 10))) + end() + + t.match(timers.finished, { later: Number, sync: Number, async: Number }) +}) + t.test('initial timer', async (t) => { const { timers } = mockTimers(t, { start: 'foo' }) process.emit('timeEnd', 'foo') diff --git a/test/lib/utils/update-notifier.js b/test/lib/utils/update-notifier.js index fa4af29476f12..a35886c6ef20a 100644 --- a/test/lib/utils/update-notifier.js +++ b/test/lib/utils/update-notifier.js @@ -85,12 +85,11 @@ t.afterEach(() => { const runUpdateNotifier = async ({ color = true, ...npmOptions } = {}) => { const _npm = { ...defaultNpm, ...npmOptions, logColor: color } - await t.mock('../../../lib/utils/update-notifier.js', { + return t.mock('../../../lib/utils/update-notifier.js', { '@npmcli/ci-detect': () => ciMock, pacote, fs, })(_npm) - return _npm.updateNotification } t.test('situations in which we do not notify', t => {