diff --git a/.github/funding.yml b/.github/funding.yml deleted file mode 100644 index c202b4b..0000000 --- a/.github/funding.yml +++ /dev/null @@ -1,2 +0,0 @@ -github: sindresorhus -tidelift: npm/update-notifier diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml new file mode 100644 index 0000000..346585c --- /dev/null +++ b/.github/workflows/main.yml @@ -0,0 +1,21 @@ +name: CI +on: + - push + - pull_request +jobs: + test: + name: Node.js ${{ matrix.node-version }} + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + node-version: + - 20 + - 18 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node-version }} + - run: npm install + - run: npm test diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 9d7745e..0000000 --- a/.travis.yml +++ /dev/null @@ -1,5 +0,0 @@ -language: node_js -node_js: - - '14' - - '12' - - '10' diff --git a/check.js b/check.js index fc0ee9c..75d2f8a 100644 --- a/check.js +++ b/check.js @@ -1,12 +1,10 @@ /* eslint-disable unicorn/no-process-exit */ -'use strict'; -let updateNotifier = require('.'); +import process from 'node:process'; +import UpdateNotifier from './update-notifier.js'; -const options = JSON.parse(process.argv[2]); +const updateNotifier = new UpdateNotifier(JSON.parse(process.argv[2])); -updateNotifier = new updateNotifier.UpdateNotifier(options); - -(async () => { +try { // Exit process when offline setTimeout(process.exit, 1000 * 30); @@ -22,7 +20,7 @@ updateNotifier = new updateNotifier.UpdateNotifier(options); // Call process exit explicitly to terminate the child process, // otherwise the child process will run forever, according to the Node.js docs process.exit(); -})().catch(error => { +} catch (error) { console.error(error); process.exit(1); -}); +} diff --git a/contributing.md b/contributing.md deleted file mode 100644 index 0be6f2f..0000000 --- a/contributing.md +++ /dev/null @@ -1 +0,0 @@ -See the [contributing docs](https://github.com/yeoman/yeoman/blob/master/contributing.md) diff --git a/example.js b/example.js index c9ff4de..649ef88 100644 --- a/example.js +++ b/example.js @@ -1,5 +1,4 @@ -'use strict'; -const updateNotifier = require('.'); +import updateNotifier from './index.js'; // Run: $ node example @@ -10,7 +9,8 @@ const updateNotifier = require('.'); updateNotifier({ pkg: { name: 'public-ip', - version: '0.9.2' + version: '0.9.2', }, - updateCheckInterval: 0 -}).notify(); + updateCheckInterval: 0, +}) + .notify(); diff --git a/index.js b/index.js index 5e89216..e216e65 100644 --- a/index.js +++ b/index.js @@ -1,188 +1,7 @@ -'use strict'; -const {spawn} = require('child_process'); -const path = require('path'); -const {format} = require('util'); -const importLazy = require('import-lazy')(require); +import UpdateNotifier from './update-notifier.js'; -const configstore = importLazy('configstore'); -const chalk = importLazy('chalk'); -const semver = importLazy('semver'); -const semverDiff = importLazy('semver-diff'); -const latestVersion = importLazy('latest-version'); -const isNpm = importLazy('is-npm'); -const isInstalledGlobally = importLazy('is-installed-globally'); -const isYarnGlobal = importLazy('is-yarn-global'); -const hasYarn = importLazy('has-yarn'); -const boxen = importLazy('boxen'); -const xdgBasedir = importLazy('xdg-basedir'); -const isCi = importLazy('is-ci'); -const pupa = importLazy('pupa'); - -const ONE_DAY = 1000 * 60 * 60 * 24; - -class UpdateNotifier { - constructor(options = {}) { - this.options = options; - options.pkg = options.pkg || {}; - options.distTag = options.distTag || 'latest'; - - // Reduce pkg to the essential keys. with fallback to deprecated options - // TODO: Remove deprecated options at some point far into the future - options.pkg = { - name: options.pkg.name || options.packageName, - version: options.pkg.version || options.packageVersion - }; - - if (!options.pkg.name || !options.pkg.version) { - throw new Error('pkg.name and pkg.version required'); - } - - this.packageName = options.pkg.name; - this.packageVersion = options.pkg.version; - this.updateCheckInterval = typeof options.updateCheckInterval === 'number' ? options.updateCheckInterval : ONE_DAY; - this.disabled = 'NO_UPDATE_NOTIFIER' in process.env || - process.env.NODE_ENV === 'test' || - process.argv.includes('--no-update-notifier') || - isCi(); - this.shouldNotifyInNpmScript = options.shouldNotifyInNpmScript; - - if (!this.disabled) { - try { - const ConfigStore = configstore(); - this.config = new ConfigStore(`update-notifier-${this.packageName}`, { - optOut: false, - // Init with the current time so the first check is only - // after the set interval, so not to bother users right away - lastUpdateCheck: Date.now() - }); - } catch { - // Expecting error code EACCES or EPERM - const message = - chalk().yellow(format(' %s update check failed ', options.pkg.name)) + - format('\n Try running with %s or get access ', chalk().cyan('sudo')) + - '\n to the local update config store via \n' + - chalk().cyan(format(' sudo chown -R $USER:$(id -gn $USER) %s ', xdgBasedir().config)); - - process.on('exit', () => { - console.error(boxen()(message, {align: 'center'})); - }); - } - } - } - - check() { - if ( - !this.config || - this.config.get('optOut') || - this.disabled - ) { - return; - } - - this.update = this.config.get('update'); - - if (this.update) { - // Use the real latest version instead of the cached one - this.update.current = this.packageVersion; - - // Clear cached information - this.config.delete('update'); - } - - // Only check for updates on a set interval - if (Date.now() - this.config.get('lastUpdateCheck') < this.updateCheckInterval) { - return; - } - - // Spawn a detached process, passing the options as an environment property - spawn(process.execPath, [path.join(__dirname, 'check.js'), JSON.stringify(this.options)], { - detached: true, - stdio: 'ignore' - }).unref(); - } - - async fetchInfo() { - const {distTag} = this.options; - const latest = await latestVersion()(this.packageName, {version: distTag}); - - return { - latest, - current: this.packageVersion, - type: semverDiff()(this.packageVersion, latest) || distTag, - name: this.packageName - }; - } - - notify(options) { - const suppressForNpm = !this.shouldNotifyInNpmScript && isNpm().isNpmOrYarn; - if (!process.stdout.isTTY || suppressForNpm || !this.update || !semver().gt(this.update.latest, this.update.current)) { - return this; - } - - options = { - isGlobal: isInstalledGlobally(), - isYarnGlobal: isYarnGlobal()(), - ...options - }; - - let installCommand; - if (options.isYarnGlobal) { - installCommand = `yarn global add ${this.packageName}`; - } else if (options.isGlobal) { - installCommand = `npm i -g ${this.packageName}`; - } else if (hasYarn()()) { - installCommand = `yarn add ${this.packageName}`; - } else { - installCommand = `npm i ${this.packageName}`; - } - - const defaultTemplate = 'Update available ' + - chalk().dim('{currentVersion}') + - chalk().reset(' → ') + - chalk().green('{latestVersion}') + - ' \nRun ' + chalk().cyan('{updateCommand}') + ' to update'; - - const template = options.message || defaultTemplate; - - options.boxenOptions = options.boxenOptions || { - padding: 1, - margin: 1, - align: 'center', - borderColor: 'yellow', - borderStyle: 'round' - }; - - const message = boxen()( - pupa()(template, { - packageName: this.packageName, - currentVersion: this.update.current, - latestVersion: this.update.latest, - updateCommand: installCommand - }), - options.boxenOptions - ); - - if (options.defer === false) { - console.error(message); - } else { - process.on('exit', () => { - console.error(message); - }); - - process.on('SIGINT', () => { - console.error(''); - process.exit(); - }); - } - - return this; - } -} - -module.exports = options => { +export default function updateNotifier(options) { const updateNotifier = new UpdateNotifier(options); updateNotifier.check(); return updateNotifier; -}; - -module.exports.UpdateNotifier = UpdateNotifier; +} diff --git a/package.json b/package.json index b7a183b..d1dd489 100644 --- a/package.json +++ b/package.json @@ -1,23 +1,27 @@ { "name": "update-notifier", - "version": "5.0.1", + "version": "7.3.1", "description": "Update notifications for your CLI app", "license": "BSD-2-Clause", - "repository": "yeoman/update-notifier", - "funding": "https://github.com/yeoman/update-notifier?sponsor=1", + "repository": "sindresorhus/update-notifier", + "funding": "https://github.com/sponsors/sindresorhus", "author": { "name": "Sindre Sorhus", "email": "sindresorhus@gmail.com", "url": "https://sindresorhus.com" }, + "type": "module", + "exports": "./index.js", + "sideEffects": false, "engines": { - "node": ">=10" + "node": ">=18" }, "scripts": { - "test": "xo && ava --timeout=20s --serial" + "test": "xo && NODE_OPTIONS='--loader=esmock --no-warnings' ava" }, "files": [ "index.js", + "update-notifier.js", "check.js" ], "keywords": [ @@ -34,27 +38,27 @@ "version" ], "dependencies": { - "boxen": "^4.2.0", - "chalk": "^4.1.0", - "configstore": "^5.0.1", - "has-yarn": "^2.1.0", - "import-lazy": "^2.1.0", - "is-ci": "^2.0.0", - "is-installed-globally": "^0.3.2", - "is-npm": "^5.0.0", - "is-yarn-global": "^0.3.0", - "latest-version": "^5.1.0", - "pupa": "^2.1.1", - "semver": "^7.3.2", - "semver-diff": "^3.1.1", - "xdg-basedir": "^4.0.0" + "boxen": "^8.0.1", + "chalk": "^5.3.0", + "configstore": "^7.0.0", + "is-in-ci": "^1.0.0", + "is-installed-globally": "^1.0.0", + "is-npm": "^6.0.0", + "latest-version": "^9.0.0", + "pupa": "^3.1.0", + "semver": "^7.6.3", + "xdg-basedir": "^5.1.0" }, "devDependencies": { - "ava": "^2.4.0", - "clear-module": "^4.1.1", + "ava": "^6.1.3", + "clear-module": "^4.1.2", + "esmock": "^2.6.7", "fixture-stdout": "^0.2.1", - "mock-require": "^3.0.3", - "strip-ansi": "^6.0.0", - "xo": "^0.34.1" + "strip-ansi": "^7.1.0", + "xo": "^0.59.3" + }, + "ava": { + "timeout": "20s", + "serial": true } } diff --git a/readme.md b/readme.md index afab894..ef814b2 100644 --- a/readme.md +++ b/readme.md @@ -1,10 +1,10 @@ -# update-notifier [![Build Status](https://travis-ci.org/yeoman/update-notifier.svg?branch=master)](https://travis-ci.org/yeoman/update-notifier) +# update-notifier > Update notifications for your CLI app ![](screenshot.png) -Inform users of your package of updates in a non-intrusive way. +Inform users of updates for your package in a non-intrusive way. #### Contents @@ -17,29 +17,29 @@ Inform users of your package of updates in a non-intrusive way. ## Install -``` -$ npm install update-notifier +```sh +npm install update-notifier ``` ## Usage -### Simple +### Basic ```js -const updateNotifier = require('update-notifier'); -const pkg = require('./package.json'); +import updateNotifier from 'update-notifier'; +import packageJson from './package.json' assert {type: 'json'}; -updateNotifier({pkg}).notify(); +updateNotifier({pkg: packageJson}).notify(); ``` -### Comprehensive +### Advanced ```js -const updateNotifier = require('update-notifier'); -const pkg = require('./package.json'); +import updateNotifier from 'update-notifier'; +import packageJson from './package.json' assert {type: 'json'}; // Checks for available update and returns an instance -const notifier = updateNotifier({pkg}); +const notifier = updateNotifier({pkg: packageJson}); // Notify using the built-in convenience method notifier.notify(); @@ -69,10 +69,10 @@ if (notifier.update) { } ``` -## How +## How it works Whenever you initiate the update notifier and it's not within the interval threshold, it will asynchronously check with npm in the background for available updates, then persist the result. The next time the notifier is initiated, the result will be loaded into the `.update` property. This prevents any impact on your package startup performance. -The update check is done in a unref'ed [child process](https://nodejs.org/api/child_process.html#child_process_child_process_spawn_command_args_options). This means that if you call `process.exit`, the check will still be performed in its own process. +The update check is done in an unref'ed [child process](https://nodejs.org/api/child_process.html#child_process_child_process_spawn_command_args_options). This means that if you call `process.exit`, the check will still be performed in its own process. The first time the user runs your app, it will check for an update, and even if an update is available, it will wait the specified `updateCheckInterval` before notifying the user. This is done to not be annoying to the user, but might surprise you as an implementer if you're testing whether it works. Check out [`example.js`](example.js) to quickly test out `update-notifier` and see how you can test that it works in your app. @@ -127,10 +127,10 @@ Check update information. Returns an `object` with: -- `latest` _(String)_ - Latest version. -- `current` _(String)_ - Current version. -- `type` _(String)_ - Type of current update. Possible values: `latest`, `major`, `minor`, `patch`, `prerelease`, `build`. -- `name` _(String)_ - Package name. +- `latest` *(string)* - Latest version. +- `current` *(string)* - Current version. +- `type` *(string)* - Type of the current update. Possible values: `latest`, `major`, `minor`, `patch`, `prerelease`, `build`. +- `name` *(string)* - Package name. ### notifier.notify(options?) @@ -147,12 +147,12 @@ Type: `object` Type: `boolean`\ Default: `true` -Defer showing the notification to after the process has exited. +Defer showing the notification until after the process has exited. ##### message Type: `string`\ -Default: [See above screenshot](https://github.com/yeoman/update-notifier#update-notifier-) +Default: [See above screenshot](#update-notifier-) Message that will be shown when an update is available. @@ -180,7 +180,7 @@ Include the `-g` argument in the default message's `npm i` recommendation. You m ##### boxenOptions Type: `object`\ -Default: `{padding: 1, margin: 1, align: 'center', borderColor: 'yellow', borderStyle: 'round'}` *(See screenshot)* +Default: `{padding: 1, margin: 1, textAlignment: 'center', borderColor: 'yellow', borderStyle: 'round'}` *(See screenshot)* Options object that will be passed to [`boxen`](https://github.com/sindresorhus/boxen). @@ -188,11 +188,11 @@ Options object that will be passed to [`boxen`](https://github.com/sindresorhus/ Users of your module have the ability to opt-out of the update notifier by changing the `optOut` property to `true` in `~/.config/configstore/update-notifier-[your-module-name].json`. The path is available in `notifier.config.path`. -Users can also opt-out by [setting the environment variable](https://github.com/sindresorhus/guides/blob/master/set-environment-variables.md) `NO_UPDATE_NOTIFIER` with any value or by using the `--no-update-notifier` flag on a per run basis. +Users can also opt-out by [setting the environment variable](https://github.com/sindresorhus/guides/blob/main/set-environment-variables.md) `NO_UPDATE_NOTIFIER` with any value or by using the `--no-update-notifier` flag on a per run basis. The check is also skipped automatically: - - on CI - - in unit tests (when the `NODE_ENV` environment variable is `test`) +- [in CI](https://github.com/sindresorhus/is-in-ci) +- in unit tests (when the `NODE_ENV` environment variable is `test`) ## About @@ -200,24 +200,12 @@ The idea for this module came from the desire to apply the browser update strate ## Users -There are a bunch projects using it: +There are a bunch of projects using it: - [npm](https://github.com/npm/npm) - Package manager for JavaScript - [Yeoman](https://yeoman.io) - Modern workflows for modern webapps -- [AVA](https://ava.li) - Simple concurrent test runner +- [AVA](https://avajs.dev) - Simple concurrent test runner - [XO](https://github.com/xojs/xo) - JavaScript happiness style linter - [Node GH](https://github.com/node-gh/gh) - GitHub command line tool -[And 2700+ more…](https://www.npmjs.org/browse/depended/update-notifier) - ---- - -
- - Get professional support for this package with a Tidelift subscription - -
- - Tidelift helps make open source sustainable for maintainers while giving companies
assurances about security, maintenance, and licensing for their dependencies. -
-
+[And 5000+ more…](https://www.npmjs.org/browse/depended/update-notifier) diff --git a/test/fs-error.js b/test/fs-error.js index 40488e0..82f3e69 100644 --- a/test/fs-error.js +++ b/test/fs-error.js @@ -1,23 +1,20 @@ +import process from 'node:process'; import clearModule from 'clear-module'; import test from 'ava'; -let updateNotifier; +for (const name of ['..', 'configstore', 'xdg-basedir']) { + clearModule(name); +} -test.before(() => { - for (const name of ['..', 'configstore', 'xdg-basedir']) { - clearModule(name); - } - - // Set configstore.config to something that requires root access - process.env.XDG_CONFIG_HOME = '/usr'; - updateNotifier = require('..'); -}); +// Set configstore.config to something that requires root access +process.env.XDG_CONFIG_HOME = '/usr'; +const {default: updateNotifier} = await import('../index.js'); test('fail gracefully', t => { t.notThrows(() => { updateNotifier({ packageName: 'npme', - packageVersion: '3.7.0' + packageVersion: '3.7.0', }); }); }); diff --git a/test/notify.js b/test/notify.js index f0c7cca..d8740f7 100644 --- a/test/notify.js +++ b/test/notify.js @@ -1,38 +1,38 @@ -import {inherits} from 'util'; -import clearModule from 'clear-module'; +import process from 'node:process'; +import {inherits} from 'node:util'; import FixtureStdout from 'fixture-stdout'; import stripAnsi from 'strip-ansi'; import test from 'ava'; -import mock from 'mock-require'; +import esmock from 'esmock'; const stderr = new FixtureStdout({ - stream: process.stderr + stream: process.stderr, }); function Control(shouldNotifyInNpmScript) { - this.packageName = 'update-notifier-tester'; + this._packageName = 'update-notifier-tester'; this.update = { current: '0.0.2', - latest: '1.0.0' + latest: '1.0.0', }; - this.shouldNotifyInNpmScript = shouldNotifyInNpmScript; + this._shouldNotifyInNpmScript = shouldNotifyInNpmScript; } -const setupTest = isNpmReturnValue => { - for (const name of ['..', 'is-npm']) { - clearModule(name); - } - +const setupTest = async isNpmReturnValue => { process.stdout.isTTY = true; - mock('is-npm', {isNpmOrYarn: isNpmReturnValue || false}); - const updateNotifier = require('..'); - inherits(Control, updateNotifier.UpdateNotifier); + + const UpdateNotifier = await esmock('../update-notifier.js', { + 'is-npm': {isNpmOrYarn: isNpmReturnValue || false}, + }); + + inherits(Control, UpdateNotifier); }; let errorLogs = ''; -test.beforeEach(() => { - setupTest(); +test.beforeEach(async () => { + await setupTest(); + stderr.capture(s => { errorLogs += s; return false; @@ -40,7 +40,6 @@ test.beforeEach(() => { }); test.afterEach(() => { - mock.stopAll(); stderr.release(); errorLogs = ''; }); @@ -49,6 +48,8 @@ test('use pretty boxen message by default', t => { const notifier = new Control(); notifier.notify({defer: false, isGlobal: true}); + console.log('d', errorLogs); + t.is(stripAnsi(errorLogs), ` ╭───────────────────────────────────────────────────╮ │ │ @@ -65,7 +66,7 @@ test('supports custom message', t => { notifier.notify({ defer: false, isGlobal: true, - message: 'custom message' + message: 'custom message', }); t.true(stripAnsi(errorLogs).includes('custom message')); @@ -80,8 +81,8 @@ test('supports message with placeholders', t => { 'Package Name: {packageName}', 'Current Version: {currentVersion}', 'Latest Version: {latestVersion}', - 'Update Command: {updateCommand}' - ].join('\n') + 'Update Command: {updateCommand}', + ].join('\n'), }); t.is(stripAnsi(errorLogs), ` @@ -109,30 +110,30 @@ test('shouldNotifyInNpmScript should default to false', t => { t.not(stripAnsi(errorLogs).indexOf('Update available'), -1); }); -test('suppress output when running as npm script', t => { - setupTest(true); +test('suppress output when running as npm script', async t => { + await setupTest(true); const notifier = new Control(); notifier.notify({defer: false}); t.false(stripAnsi(errorLogs).includes('Update available')); }); -test('should output if running as npm script and shouldNotifyInNpmScript option set', t => { - setupTest(true); +test('should output if running as npm script and shouldNotifyInNpmScript option set', async t => { + await setupTest(true); const notifier = new Control(true); notifier.notify({defer: false}); t.true(stripAnsi(errorLogs).includes('Update available')); }); -test('should not output if current version is the latest', t => { - setupTest(true); +test('should not output if current version is the latest', async t => { + await setupTest(true); const notifier = new Control(true); notifier.update.current = '1.0.0'; notifier.notify({defer: false}); t.false(stripAnsi(errorLogs).includes('Update available')); }); -test('should not output if current version is more recent than the reported latest', t => { - setupTest(true); +test('should not output if current version is more recent than the reported latest', async t => { + await setupTest(true); const notifier = new Control(true); notifier.update.current = '1.0.1'; notifier.notify({defer: false}); diff --git a/test/update-notifier.js b/test/update-notifier.js index 08a284e..56bf398 100644 --- a/test/update-notifier.js +++ b/test/update-notifier.js @@ -1,20 +1,15 @@ -import fs from 'fs'; +import process from 'node:process'; +import fs from 'node:fs'; import test from 'ava'; -import mockRequire from 'mock-require'; +import esmock from 'esmock'; -mockRequire('is-ci', false); - -import updateNotifier from '..'; - -const generateSettings = (options = {}) => { - return { - pkg: { - name: 'update-notifier-tester', - version: '0.0.2' - }, - distTag: options.distTag - }; -}; +const generateSettings = (options = {}) => ({ + pkg: { + name: 'update-notifier-tester', + version: '0.0.2', + }, + distTag: options.distTag, +}); let argv; let configstorePath; @@ -23,43 +18,54 @@ test.beforeEach(() => { // Prevents NODE_ENV 'test' default behavior which disables `update-notifier` process.env.NODE_ENV = 'ava-test'; - argv = process.argv.slice(); - configstorePath = updateNotifier(generateSettings()).config.path; + argv = [...process.argv]; }); test.afterEach(() => { delete process.env.NO_UPDATE_NOTIFIER; process.argv = argv; + setTimeout(() => { - fs.unlinkSync(configstorePath); - }, 10000); + try { + fs.unlinkSync(configstorePath); + } catch {} + }, 10_000); }); test('fetch info', async t => { + const updateNotifier = await esmock('../index.js', undefined, {'is-in-ci': false}); + configstorePath = updateNotifier(generateSettings()).config.path; const update = await updateNotifier(generateSettings()).fetchInfo(); console.log(update); t.is(update.latest, '0.0.2'); }); test('fetch info with dist-tag', async t => { + const updateNotifier = await esmock('../index.js', undefined, {'is-in-ci': false}); + configstorePath = updateNotifier(generateSettings()).config.path; const update = await updateNotifier(generateSettings({distTag: '0.0.3-rc1'})).fetchInfo(); t.is(update.latest, '0.0.3-rc1'); }); -test('don\'t initialize configStore when NO_UPDATE_NOTIFIER is set', t => { +test('don\'t initialize configStore when NO_UPDATE_NOTIFIER is set', async t => { + const updateNotifier = await esmock('../index.js', undefined, {'is-in-ci': false}); + configstorePath = updateNotifier(generateSettings()).config.path; process.env.NO_UPDATE_NOTIFIER = '1'; const notifier = updateNotifier(generateSettings()); t.is(notifier.config, undefined); }); -test('don\'t initialize configStore when --no-update-notifier is set', t => { +test('don\'t initialize configStore when --no-update-notifier is set', async t => { + const updateNotifier = await esmock('../index.js', undefined, {'is-in-ci': false}); + configstorePath = updateNotifier(generateSettings()).config.path; process.argv.push('--no-update-notifier'); const notifier = updateNotifier(generateSettings()); t.is(notifier.config, undefined); }); -test('don\'t initialize configStore when NODE_ENV === "test"', t => { +test('don\'t initialize configStore when NODE_ENV === "test"', async t => { process.env.NODE_ENV = 'test'; + const updateNotifier = await esmock('../index.js', undefined, {'is-in-ci': false}); const notifier = updateNotifier(generateSettings()); t.is(notifier.config, undefined); }); diff --git a/update-notifier.js b/update-notifier.js new file mode 100644 index 0000000..300aa8e --- /dev/null +++ b/update-notifier.js @@ -0,0 +1,177 @@ +import process from 'node:process'; +import {spawn} from 'node:child_process'; +import {fileURLToPath} from 'node:url'; +import path from 'node:path'; +import {format} from 'node:util'; +import ConfigStore from 'configstore'; +import chalk from 'chalk'; +// Only import what we need for performance +import semverDiff from 'semver/functions/diff.js'; +import semverGt from 'semver/functions/gt.js'; +import latestVersion from 'latest-version'; +import {isNpmOrYarn} from 'is-npm'; +import isInstalledGlobally from 'is-installed-globally'; +import boxen from 'boxen'; +import {xdgConfig} from 'xdg-basedir'; +import isInCi from 'is-in-ci'; +import pupa from 'pupa'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +const ONE_DAY = 1000 * 60 * 60 * 24; + +export default class UpdateNotifier { + // Public + config; + update; + + // Semi-private (used for tests) + _packageName; // eslint-disable-line lines-between-class-members + _shouldNotifyInNpmScript; + + #options; // eslint-disable-line lines-between-class-members + #packageVersion; + #updateCheckInterval; + #isDisabled; + + constructor(options = {}) { + this.#options = options; + options.pkg ??= {}; + options.distTag ??= 'latest'; + + // Reduce pkg to the essential keys. with fallback to deprecated options + // TODO: Remove deprecated options at some point far into the future + options.pkg = { + name: options.pkg.name ?? options.packageName, + version: options.pkg.version ?? options.packageVersion, + }; + + if (!options.pkg.name || !options.pkg.version) { + throw new Error('pkg.name and pkg.version required'); + } + + this._packageName = options.pkg.name; + this.#packageVersion = options.pkg.version; + this.#updateCheckInterval = typeof options.updateCheckInterval === 'number' ? options.updateCheckInterval : ONE_DAY; + this.#isDisabled = 'NO_UPDATE_NOTIFIER' in process.env + || process.env.NODE_ENV === 'test' + || process.argv.includes('--no-update-notifier') + || isInCi; + this._shouldNotifyInNpmScript = options.shouldNotifyInNpmScript; + + if (!this.#isDisabled) { + try { + this.config = new ConfigStore(`update-notifier-${this._packageName}`, { + optOut: false, + // Init with the current time so the first check is only + // after the set interval, so not to bother users right away + lastUpdateCheck: Date.now(), + }); + } catch { + // Expecting error code EACCES or EPERM + const message + = chalk.yellow(format(' %s update check failed ', options.pkg.name)) + + format('\n Try running with %s or get access ', chalk.cyan('sudo')) + + '\n to the local update config store via \n' + + chalk.cyan(format(' sudo chown -R $USER:$(id -gn $USER) %s ', xdgConfig)); + + process.on('exit', () => { + console.error(boxen(message, {textAlignment: 'center'})); + }); + } + } + } + + check() { + if ( + !this.config + || this.config.get('optOut') + || this.#isDisabled + ) { + return; + } + + this.update = this.config.get('update'); + + if (this.update) { + // Use the real latest version instead of the cached one + this.update.current = this.#packageVersion; + + // Clear cached information + this.config.delete('update'); + } + + // Only check for updates on a set interval + if (Date.now() - this.config.get('lastUpdateCheck') < this.#updateCheckInterval) { + return; + } + + // Spawn a detached process, passing the options as an environment property + spawn(process.execPath, [path.join(__dirname, 'check.js'), JSON.stringify(this.#options)], { + detached: true, + stdio: 'ignore', + }).unref(); + } + + async fetchInfo() { + const {distTag} = this.#options; + const latest = await latestVersion(this._packageName, {version: distTag}); + + return { + latest, + current: this.#packageVersion, + type: semverDiff(this.#packageVersion, latest) ?? distTag, + name: this._packageName, + }; + } + + notify(options) { + const suppressForNpm = !this._shouldNotifyInNpmScript && isNpmOrYarn; + if (!process.stdout.isTTY || suppressForNpm || !this.update || !semverGt(this.update.latest, this.update.current)) { + return this; + } + + options = { + isGlobal: isInstalledGlobally, + ...options, + }; + + const installCommand = options.isGlobal ? `npm i -g ${this._packageName}` : `npm i ${this._packageName}`; + + const defaultTemplate = 'Update available ' + + chalk.dim('{currentVersion}') + + chalk.reset(' → ') + + chalk.green('{latestVersion}') + + ' \nRun ' + chalk.cyan('{updateCommand}') + ' to update'; + + const template = options.message || defaultTemplate; + + options.boxenOptions ??= { + padding: 1, + margin: 1, + textAlignment: 'center', + borderColor: 'yellow', + borderStyle: 'round', + }; + + const message = boxen( + pupa(template, { + packageName: this._packageName, + currentVersion: this.update.current, + latestVersion: this.update.latest, + updateCommand: installCommand, + }), + options.boxenOptions, + ); + + if (options.defer === false) { + console.error(message); + } else { + process.on('exit', () => { + console.error(message); + }); + } + + return this; + } +}