@oclif/plugin-warn-if-update-available
Version:
warns if there is a newer version of CLI released
151 lines (150 loc) • 6.31 kB
JavaScript
import { Ansis } from 'ansis';
import makeDebug from 'debug';
import { spawn } from 'node:child_process';
import { readFile, stat, writeFile } from 'node:fs/promises';
import { dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
const ansis = new Ansis();
async function readJSON(file) {
return JSON.parse(await readFile(file, 'utf8'));
}
export function hasNotBeenMsSinceDate(ms, now, date) {
const diff = now.getTime() - date.getTime();
return diff < ms;
}
export function convertToMs(frequency, unit) {
switch (unit) {
case 'days': {
return frequency * 24 * 60 * 60 * 1000;
}
case 'hours': {
return frequency * 60 * 60 * 1000;
}
case 'milliseconds': {
return frequency;
}
case 'minutes': {
return frequency * 60 * 1000;
}
case 'seconds': {
return frequency * 1000;
}
default: {
// default to minutes
return frequency * 60 * 1000;
}
}
}
export function getEnvVarNumber(envVar, defaultValue) {
const envVarRaw = process.env[envVar];
if (!envVarRaw)
return defaultValue;
const parsed = Number.parseInt(envVarRaw, 10);
if (Number.isNaN(parsed))
return defaultValue;
return parsed;
}
export function getEnvVarEnum(envVar, allowed, defaultValue) {
const envVarRaw = process.env[envVar];
if (!envVarRaw)
return defaultValue;
if (!allowed.includes(envVarRaw))
return defaultValue;
return envVarRaw;
}
export function semverGreaterThan(a, b) {
return a.localeCompare(b, undefined, { numeric: true, sensitivity: 'base' }) > 0;
}
/**
* Returns the newest version of the CLI from the cache if it is newer than the current version.
*
* Returns undefined early if:
* - `update` command is being run
* - `<CLI>_SKIP_NEW_VERSION_CHECK` is set to true
* - the current version is a prerelease
* - the warning was last shown to the user within the frequency and frequencyUnit
*/
export async function getNewerVersion({ argv, config, lastWarningFile, versionFile, }) {
// do not show warning if running `update` command of <CLI>_SKIP_NEW_VERSION_CHECK=true
if (argv[2] === 'update' || config.scopedEnvVarTrue('SKIP_NEW_VERSION_CHECK'))
return;
// TODO: handle prerelease channels
if (config.version.includes('-'))
return;
const { frequency, frequencyUnit } = config.pjson.oclif['warn-if-update-available'] ?? {};
const warningFrequency = getEnvVarNumber(config.scopedEnvVarKey('NEW_VERSION_CHECK_FREQ'), frequency);
const warningFrequencyUnit = getEnvVarEnum(config.scopedEnvVarKey('NEW_VERSION_CHECK_FREQ_UNIT'), ['days', 'hours', 'minutes', 'seconds', 'milliseconds'], frequencyUnit ?? 'minutes');
try {
const { mtime } = await stat(lastWarningFile);
// If the file was modified before the timeout, don't show the warning
if (warningFrequency &&
warningFrequencyUnit &&
hasNotBeenMsSinceDate(convertToMs(warningFrequency, warningFrequencyUnit), new Date(), mtime))
return;
}
catch {
// The last-warning file doesn't exist, which is okay since it will be created the first time the warning is shown
}
const distTags = await readJSON(versionFile);
const tag = config.scopedEnvVar('NEW_VERSION_CHECK_TAG') ?? 'latest';
if (distTags[tag] && semverGreaterThan(distTags[tag].split('-')[0], config.version.split('-')[0]))
return distTags[tag];
}
const hook = async function ({ config }) {
const debug = makeDebug('update-check');
const versionFile = join(config.cacheDir, 'version');
const lastWarningFile = join(config.cacheDir, 'last-warning');
// Destructure package.json configuration with defaults
const { authorization = '', message = '<%= config.name %> update available from <%= chalk.greenBright(config.version) %> to <%= chalk.greenBright(latest) %>.', registry = config.npmRegistry ?? 'https://registry.npmjs.org', timeoutInDays = 60, } = config.pjson.oclif['warn-if-update-available'] ?? {};
const refreshNeeded = async () => {
if (this.config.scopedEnvVarTrue('FORCE_VERSION_CACHE_UPDATE'))
return true;
if (this.config.scopedEnvVarTrue('SKIP_NEW_VERSION_CHECK'))
return false;
try {
const { mtime } = await stat(versionFile);
const staleAt = new Date(mtime.valueOf() + 1000 * 60 * 60 * 24 * timeoutInDays);
return staleAt < new Date();
}
catch (error) {
debug(error);
return true;
}
};
const spawnRefresh = async () => {
const versionScript = resolve(dirname(fileURLToPath(import.meta.url)), '../../../lib/get-version');
debug('spawning version refresh');
debug(process.execPath, versionScript, config.name, versionFile, config.version, registry, authorization);
spawn(process.execPath, [versionScript, config.name, versionFile, config.version, registry, authorization], {
detached: !config.windows,
stdio: 'ignore',
}).unref();
};
try {
const newerVersion = await getNewerVersion({ argv: process.argv, config, lastWarningFile, versionFile });
if (newerVersion) {
// Default message if the user doesn't provide one
const [lodash] = await Promise.all([
import('lodash'),
// Update the modified time (mtime) of the last-warning file so that we can track the last time we
// showed the warning. This makes it possible to respect the frequency and frequencyUnit options.
writeFile(lastWarningFile, ''),
]);
this.warn(lodash.default.template(message)({
ansis,
// chalk and ansis have the same api. Keeping chalk for backwards compatibility.
chalk: ansis,
config,
latest: newerVersion,
}));
}
}
catch (error) {
const { code } = error;
if (code !== 'ENOENT')
throw error;
}
if (await refreshNeeded())
await spawnRefresh();
};
export default hook;