Skip to content

Commit

Permalink
new npm-specific update-notifier implementation
Browse files Browse the repository at this point in the history
This drops our usage of the update-notifier module, in favor of checking
ourselves, using the modules and UX patterns that npm already has in
place.

- While on a prerelease version, updates are checked for every day,
  instead of every week, and always checks for a new beta in the current
  release family.  Ie, ^7.0.0-beta.2 instead of latest.
- Latest version is suggested if newer than current.
- If current version is newer than latest, then we check again for an
  update in the current version family.  Ie, ^7.0.0 instead of latest,
  if current is 7.0.0 and latest is 6.x.
- Output is printed using log.notice, at the end of all other log
  output, so that it's both less visually disruptive, and less likely to
  be missed among other warnings and notices.

This has the side effect of requiring that we set npm.flatOptions as
soon as config is loaded, rather than waiting for a command to be run.
Since the cli runs a command immediately after loading anyway, this is
not a relevant change for our purposes, but worth mentioning here.
  • Loading branch information
isaacs committed Aug 6, 2020
1 parent cf28192 commit d062b2c
Show file tree
Hide file tree
Showing 7 changed files with 388 additions and 199 deletions.
4 changes: 2 additions & 2 deletions lib/cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -52,10 +52,10 @@ module.exports = (process) => {
// this is how to use npm programmatically:
conf._exit = true
const updateNotifier = require('../lib/utils/update-notifier.js')
npm.load(conf, er => {
npm.load(conf, async er => {
if (er) return errorHandler(er)

updateNotifier(npm)
npm.updateNotification = await updateNotifier(npm)

const cmd = npm.argv.shift()
const impl = npm.commands[cmd]
Expand Down
15 changes: 10 additions & 5 deletions lib/npm.js
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,11 @@ const npm = module.exports = new class extends EventEmitter {
constructor () {
super()
require('./utils/perf.js')
this.modes = {
exec: 0o755,
file: 0o644,
umask: 0o22
}
this.started = Date.now()
this.command = null
this.commands = proxyCmds(this)
Expand All @@ -75,6 +80,7 @@ const npm = module.exports = new class extends EventEmitter {
this.config = notYetLoadedConfig
this.loading = false
this.loaded = false
this.updateNotification = null
}

deref (c) {
Expand All @@ -93,11 +99,6 @@ const npm = module.exports = new class extends EventEmitter {
process.emit('time', `command:${cmd}`)
this.command = cmd

if (!this[_flatOptions]) {
this[_flatOptions] = require('./config/flat-options.js')(this)
require('./config/set-envs.js')(this)
}

// Options are prefixed by a hyphen-minus (-, \u2d).
// Other dash-type chars look similar but are invalid.
if (!warnedNonDashArg) {
Expand Down Expand Up @@ -144,6 +145,10 @@ const npm = module.exports = new class extends EventEmitter {
if (!er && this.config.get('force')) {
this.log.warn('using --force', 'Recommended protections disabled.')
}
if (!er && !this[_flatOptions]) {
this[_flatOptions] = require('./config/flat-options.js')(this)
require('./config/set-envs.js')(this)
}
process.emit('timeEnd', 'npm:load')
this.emit('load', er)
})
Expand Down
7 changes: 7 additions & 0 deletions lib/utils/error-handler.js
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,13 @@ const errorHandler = (er) => {
er = er || new Error('Callback called more than once.')
}

if (npm.updateNotification) {
const { level } = log
log.level = log.levels.notice
log.notice('', npm.updateNotification)
log.level = level
}

cbCalled = true
if (!er) return exit(0)
if (typeof er === 'string') {
Expand Down
151 changes: 108 additions & 43 deletions lib/utils/update-notifier.js
Original file line number Diff line number Diff line change
@@ -1,57 +1,122 @@
// print a banner telling the user to upgrade npm to latest
// but not in CI, and not if we're doing that already.
// Check daily for betas, and weekly otherwise.

const pacote = require('pacote')
const ciDetect = require('@npmcli/ci-detect')
const semver = require('semver')
const chalk = require('chalk')
const { promisify } = require('util')
const stat = promisify(require('fs').stat)
const writeFile = promisify(require('fs').writeFile)
const { resolve } = require('path')

const isGlobalNpmUpdate = npm => {
return npm.config.get('global') &&
return npm.flatOptions.global &&
['install', 'update'].includes(npm.command) &&
npm.argv.includes('npm')
}

const { checkVersion } = require('./unsupported.js')
// update check frequency
const DAILY = 1000 * 60 * 60 * 24
const WEEKLY = DAILY * 7

const updateTimeout = async (npm, duration) => {
const t = new Date(Date.now() - duration)
// don't put it in the _cacache folder, just in npm's cache
const f = resolve(npm.flatOptions.cache, '../_update-notifier-last-checked')
// if we don't have a file, then definitely check it.
const st = await stat(f).catch(() => ({ mtime: t - 1 }))

if (t > st.mtime) {
// best effort, if this fails, it's ok.
// might be using /dev/null as the cache or something weird like that.
await writeFile(f, '').catch(() => {})
return true
} else {
return false
}
}

module.exports = (npm) => {
const updateNotifier = module.exports = async (npm, spec = 'latest') => {
// never check for updates in CI, when updating npm already, or opted out
if (!npm.config.get('update-notifier') ||
isGlobalNpmUpdate(npm) ||
checkVersion(process.version).unsupported) {
return
isGlobalNpmUpdate(npm) ||
ciDetect()) {
return null
}

// if we're on a prerelease train, then updates are coming fast
// check for a new one daily. otherwise, weekly.
const { version } = npm
const current = semver.parse(version)

// if we're on a beta train, always get the next beta
if (current.prerelease.length) {
spec = `^${version}`
}

// while on a beta train, get updates daily
const duration = spec !== 'latest' ? DAILY : WEEKLY

// if we've already checked within the specified duration, don't check again
if (!(await updateTimeout(npm, duration))) {
return null
}

// if they're currently using a prerelease, nudge to the next prerelease
// otherwise, nudge to latest.
const useColor = npm.log.useColor()

const mani = await pacote.manifest(`npm@${spec}`, {
// always prefer latest, even if doing --tag=whatever on the cmd
defaultTag: 'latest',
...npm.flatOptions
}).catch(() => null)

// if pacote failed, give up
if (!mani) {
return null
}

const pkg = require('../../package.json')
const notifier = require('update-notifier')({ pkg })
const ciDetect = require('@npmcli/ci-detect')
if (
notifier.update &&
notifier.update.latest !== pkg.version &&
!ciDetect()
) {
const chalk = require('chalk')
const useColor = npm.color
const useUnicode = npm.config.get('unicode')
const old = notifier.update.current
const latest = notifier.update.latest
const type = notifier.update.type
const typec = !useColor ? type
: type === 'major' ? chalk.red(type)
: type === 'minor' ? chalk.yellow(type)
: chalk.green(type)

const changelog = `https://github.com/npm/cli/releases/tag/v${latest}`
notifier.notify({
message: `New ${typec} version of ${pkg.name} available! ${
useColor ? chalk.red(old) : old
} ${useUnicode ? '→' : '->'} ${
useColor ? chalk.green(latest) : latest
}\n` +
`${
useColor ? chalk.yellow('Changelog:') : 'Changelog:'
} ${
useColor ? chalk.cyan(changelog) : changelog
}\n` +
`Run ${
useColor
? chalk.green(`npm install -g ${pkg.name}`)
: `npm i -g ${pkg.name}`
} to update!`
})
const latest = mani.version

// if the current version is *greater* than latest, we're on a 'next'
// and should get the updates from that release train.
// Note that this isn't another http request over the network, because
// the packument will be cached by pacote from previous request.
if (semver.gt(version, latest) && spec === 'latest') {
return updateNotifier(npm, `^${version}`)
}

// if we already have something >= the desired spec, then we're done
if (semver.gte(version, latest)) {
return null
}

// ok! notify the user about this update they should get.
// The message is saved for printing at process exit so it will not get
// lost in any other messages being printed as part of the command.
const update = semver.parse(mani.version)
const type = update.major !== current.major ? 'major'
: update.minor !== current.minor ? 'minor'
: update.patch !== current.patch ? 'patch'
: 'prerelease'
const typec = !useColor ? type
: type === 'major' ? chalk.red(type)
: type === 'minor' ? chalk.yellow(type)
: chalk.green(type)
const oldc = !useColor ? current : chalk.red(current)
const latestc = !useColor ? latest : chalk.green(latest)
const changelog = `https://github.com/npm/cli/releases/tag/v${latest}`
const changelogc = !useColor ? `<${changelog}>` : chalk.cyan(changelog)
const cmd = `npm install -g npm@${latest}`
const cmdc = !useColor ? `\`${cmd}\`` : chalk.green(cmd)
const message = `\nNew ${typec} version of npm available! ` +
`${oldc} -> ${latestc}\n` +
`Changelog: ${changelogc}\n` +
`Run ${cmdc} to update!\n`
const messagec = !useColor ? message : chalk.bgBlack.white(message)

return messagec
}
120 changes: 72 additions & 48 deletions tap-snapshots/test-lib-utils-update-notifier.js-TAP.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,74 +5,98 @@
* Make sure to inspect the output below. Do not ignore changes!
*/
'use strict'
exports[`test/lib/utils/update-notifier.js TAP notification situations color and unicode major > must match snapshot 1`] = `
New major version of npm available! <<major>>-beta.1 → 7.0.0
Changelog: https://github.com/npm/cli/releases/tag/v7.0.0
Run npm install -g npm to update!
exports[`test/lib/utils/update-notifier.js TAP notification situations major to current > color 1`] = `

New major version of npm available! 122.420.69 -> 123.420.69
Changelog: https://github.com/npm/cli/releases/tag/v123.420.69
Run npm install -g npm@123.420.69 to update!

`

exports[`test/lib/utils/update-notifier.js TAP notification situations color and unicode minor > must match snapshot 1`] = `
New minor version of npm available! <<minor>>-beta.1 → 7.0.0
Changelog: https://github.com/npm/cli/releases/tag/v7.0.0
Run npm install -g npm to update!
exports[`test/lib/utils/update-notifier.js TAP notification situations major to current > no color 1`] = `
New major version of npm available! 122.420.69 -> 123.420.69
Changelog: <https://github.com/npm/cli/releases/tag/v123.420.69>
Run \`npm install -g npm@123.420.69\` to update!
`

exports[`test/lib/utils/update-notifier.js TAP notification situations color and unicode minor > must match snapshot 2`] = `
New patch version of npm available! <<patch>>-beta.1 → 7.0.0
Changelog: https://github.com/npm/cli/releases/tag/v7.0.0
Run npm install -g npm to update!
exports[`test/lib/utils/update-notifier.js TAP notification situations minor to current > color 1`] = `

New minor version of npm available! 123.419.69 -> 123.420.69
Changelog: https://github.com/npm/cli/releases/tag/v123.420.69
Run npm install -g npm@123.420.69 to update!

`

exports[`test/lib/utils/update-notifier.js TAP notification situations color, no unicode major > must match snapshot 1`] = `
New major version of npm available! <<major>>-beta.1 -> 7.0.0
Changelog: https://github.com/npm/cli/releases/tag/v7.0.0
Run npm install -g npm to update!
exports[`test/lib/utils/update-notifier.js TAP notification situations minor to current > no color 1`] = `
New minor version of npm available! 123.419.69 -> 123.420.69
Changelog: <https://github.com/npm/cli/releases/tag/v123.420.69>
Run \`npm install -g npm@123.420.69\` to update!
`

exports[`test/lib/utils/update-notifier.js TAP notification situations color, no unicode minor > must match snapshot 1`] = `
New minor version of npm available! <<minor>>-beta.1 -> 7.0.0
Changelog: https://github.com/npm/cli/releases/tag/v7.0.0
Run npm install -g npm to update!
exports[`test/lib/utils/update-notifier.js TAP notification situations minor to next version > color 1`] = `

New minor version of npm available! 123.420.70 -> 123.421.70
Changelog: https://github.com/npm/cli/releases/tag/v123.421.70
Run npm install -g npm@123.421.70 to update!

`

exports[`test/lib/utils/update-notifier.js TAP notification situations color, no unicode minor > must match snapshot 2`] = `
New patch version of npm available! <<patch>>-beta.1 -> 7.0.0
Changelog: https://github.com/npm/cli/releases/tag/v7.0.0
Run npm install -g npm to update!
exports[`test/lib/utils/update-notifier.js TAP notification situations minor to next version > no color 1`] = `
New minor version of npm available! 123.420.70 -> 123.421.70
Changelog: <https://github.com/npm/cli/releases/tag/v123.421.70>
Run \`npm install -g npm@123.421.70\` to update!
`

exports[`test/lib/utils/update-notifier.js TAP notification situations no color, no unicode major > must match snapshot 1`] = `
New major version of npm available! <<major>>-beta.1 -> 7.0.0
Changelog: https://github.com/npm/cli/releases/tag/v7.0.0
Run npm i -g npm to update!
exports[`test/lib/utils/update-notifier.js TAP notification situations new beta available > color 1`] = `

New prerelease version of npm available! 124.0.0-beta.0 -> 124.0.0-beta.99999
Changelog: https://github.com/npm/cli/releases/tag/v124.0.0-beta.99999
Run npm install -g npm@124.0.0-beta.99999 to update!

`

exports[`test/lib/utils/update-notifier.js TAP notification situations no color, no unicode minor > must match snapshot 1`] = `
New minor version of npm available! <<minor>>-beta.1 -> 7.0.0
Changelog: https://github.com/npm/cli/releases/tag/v7.0.0
Run npm i -g npm to update!
exports[`test/lib/utils/update-notifier.js TAP notification situations new beta available > no color 1`] = `
New prerelease version of npm available! 124.0.0-beta.0 -> 124.0.0-beta.99999
Changelog: <https://github.com/npm/cli/releases/tag/v124.0.0-beta.99999>
Run \`npm install -g npm@124.0.0-beta.99999\` to update!
`

exports[`test/lib/utils/update-notifier.js TAP notification situations no color, no unicode minor > must match snapshot 2`] = `
New patch version of npm available! <<patch>>-beta.1 -> 7.0.0
Changelog: https://github.com/npm/cli/releases/tag/v7.0.0
Run npm i -g npm to update!
exports[`test/lib/utils/update-notifier.js TAP notification situations patch to current > color 1`] = `

New patch version of npm available! 123.420.68 -> 123.420.69
Changelog: https://github.com/npm/cli/releases/tag/v123.420.69
Run npm install -g npm@123.420.69 to update!

`

exports[`test/lib/utils/update-notifier.js TAP notification situations unicode, no color major > must match snapshot 1`] = `
New major version of npm available! <<major>>-beta.1 → 7.0.0
Changelog: https://github.com/npm/cli/releases/tag/v7.0.0
Run npm i -g npm to update!
exports[`test/lib/utils/update-notifier.js TAP notification situations patch to current > no color 1`] = `
New patch version of npm available! 123.420.68 -> 123.420.69
Changelog: <https://github.com/npm/cli/releases/tag/v123.420.69>
Run \`npm install -g npm@123.420.69\` to update!
`

exports[`test/lib/utils/update-notifier.js TAP notification situations unicode, no color minor > must match snapshot 1`] = `
New minor version of npm available! <<minor>>-beta.1 → 7.0.0
Changelog: https://github.com/npm/cli/releases/tag/v7.0.0
Run npm i -g npm to update!
exports[`test/lib/utils/update-notifier.js TAP notification situations patch to next version > color 1`] = `

New patch version of npm available! 123.421.69 -> 123.421.70
Changelog: https://github.com/npm/cli/releases/tag/v123.421.70
Run npm install -g npm@123.421.70 to update!

`

exports[`test/lib/utils/update-notifier.js TAP notification situations unicode, no color minor > must match snapshot 2`] = `
New patch version of npm available! <<patch>>-beta.1 → 7.0.0
Changelog: https://github.com/npm/cli/releases/tag/v7.0.0
Run npm i -g npm to update!
exports[`test/lib/utils/update-notifier.js TAP notification situations patch to next version > no color 1`] = `
New patch version of npm available! 123.421.69 -> 123.421.70
Changelog: <https://github.com/npm/cli/releases/tag/v123.421.70>
Run \`npm install -g npm@123.421.70\` to update!
`
3 changes: 1 addition & 2 deletions test/lib/npm.js
Original file line number Diff line number Diff line change
Expand Up @@ -115,8 +115,7 @@ t.test('npm.load', t => {
t.match(npm, {
loaded: true,
loading: false,
// flatOptions only loaded when we run an actual command
flatOptions: null
flatOptions: {}
})
t.equal(firstCalled, true, 'first callback got called')
t.equal(secondCalled, true, 'second callback got called')
Expand Down
Loading

0 comments on commit d062b2c

Please sign in to comment.