UNPKG

check-outdated

Version:

Light-weight CLI tool to ensure that your dependencies are up to date, otherwise the process is terminated with status code 1.

676 lines (581 loc) 20.2 kB
#!/usr/bin/env node /** * @file The CLI entry point. */ /** * @external NodeModule */ const parseArguments = require('./helper/args'); const colorize = require('./helper/colorize'); const { getOutdatedDependencies, compareByName, compareByType } = require('./helper/dependencies'); const { getChangelogPath, getDependencyPackageJSON, getParentPackageJSONPath, readFile } = require('./helper/files'); const generateKeyValueList = require('./helper/list'); const { getRegExpPosition, escapeRegExp } = require('./helper/regexp'); const { semverDiff, semverDiffType, semverInRange } = require('./helper/semver'); const prettifyTable = require('./helper/table'); const { getNpmJSLink, getPackageAuthor, getPackageHomepage, getPackageRepository } = require('./helper/urls'); const pkg = require('./package.json'); /** * @typedef {import('./helper/dependencies').NpmOptions} NpmOptions * @typedef {import('./helper/dependencies').OutdatedDependencies} OutdatedDependencies * @typedef {import('./helper/dependencies').OutdatedDependency} OutdatedDependency * @typedef {import('./helper/files').PackageJSON} PackageJSON * @typedef {import('./helper/table').Table} Table * @typedef {import('./helper/table').TableColumn} TableColumn * @typedef {import('./helper/args').AvailableArguments} AvailableArguments */ /** * The options based on the CLI arguments. * * @typedef {object} CheckOutdatedOptions * @property {string[]} [ignorePackages] * @property {boolean} [ignoreDevDependencies] * @property {boolean} [ignorePreReleases] * @property {boolean} [preferWanted] * @property {string[]} [columns] * @property {string[]} [types] */ /** @typedef {CheckOutdatedOptions & NpmOptions} Options */ /** * Array of outdated dependencies. * * @typedef {OutdatedDependency[]} Dependencies */ /** * The details change can be used to share data between columns. * For example, if the first column reads the package.json, the next column can rely of this data, without to acquire it again. * * @typedef {object} DependencyDetailsCache * @property {[string, string]} [semverDiff] * @property {PackageJSON} [packageJSON] */ const DEFAULT_COLUMNS = ['package', 'current', 'wanted', 'latest', 'reference', 'changes', 'location']; /** * @typedef {object} Column * @property {TableColumn | string} caption; * @property {(dependency: OutdatedDependency, options: Options, detailsCache: DependencyDetailsCache) => Promise<TableColumn | string>} getValue */ /** @type {{ [filePath: string]: string }} */ const packageJsonCache = {}; /** @type {{ readonly [columnName: string]: Column; }} */ const AVAILABLE_COLUMNS = { package: { caption: colorize.underline('Package'), getValue: async (dependency, options) => { switch (semverDiffType(dependency.current, getWantedOrLatest(dependency, options))) { case 'major': return colorize.yellow(dependency.name); case 'minor': return colorize.cyan(dependency.name); case 'patch': return colorize.green(dependency.name); case 'revert': return colorize.red(dependency.name); default: return dependency.name; } } }, current: { caption: { text: colorize.underline('Current'), alignRight: true }, getValue: async (dependency, options) => { if (dependency.current === '') { return { text: colorize.gray('-'), alignRight: true }; } const diff = semverDiff( [dependency.current, getWantedOrLatest(dependency, options)], [colorize, colorize.magenta], [colorize.underline, colorize.magenta.underline] ); return { text: diff[0], alignRight: true }; } }, wanted: { caption: { text: colorize.underline('Wanted'), alignRight: true }, getValue: async (dependency) => { if (dependency.wanted === '') { return { text: colorize.gray('-'), alignRight: true }; } const diff = semverDiff( [dependency.current, dependency.wanted], [colorize, colorize.green], [colorize.underline, colorize.green.underline] ); return { text: diff[1], alignRight: true }; } }, latest: { caption: { text: colorize.underline('Latest'), alignRight: true }, getValue: async (dependency) => { if (dependency.latest === '') { return { text: colorize.gray('-'), alignRight: true }; } const diff = semverDiff( [dependency.current, dependency.latest], [colorize, colorize.magenta], [colorize.underline, colorize.magenta.underline] ); return { text: diff[1], alignRight: true }; } }, type: { caption: colorize.underline('Type'), getValue: async (dependency, options) => (semverDiffType(dependency.current, getWantedOrLatest(dependency, options)) || colorize.gray('-')) }, location: { caption: colorize.underline('Location'), getValue: async (dependency) => (dependency.location || colorize.gray('-')) }, packageType: { caption: colorize.underline('Package Type'), getValue: async (dependency) => (dependency.type || colorize.gray('-')) }, reference: { caption: colorize.underline('Reference'), getValue: async (dependency) => { const filePath = getParentPackageJSONPath(dependency.location); let fileContent = (packageJsonCache[filePath] || readFile(filePath)); if (fileContent !== undefined) { fileContent = fileContent.replace(/\r\n|\r/gu, '\n'); if (!('filePath' in packageJsonCache)) { packageJsonCache[filePath] = fileContent; } const json = JSON.parse(fileContent); const actualVersion = ((dependency.type && dependency.type in json) ? json[dependency.type][dependency.name] : undefined); const needle = new RegExp(`"${escapeRegExp(dependency.name)}"[^:]*:[^"]*"[^"]*${(actualVersion ? escapeRegExp(actualVersion) : '')}"`, 'u'); const [line, column] = getRegExpPosition(fileContent, needle); if (line && column) { return `${filePath}:${line}:${column}`; } } return colorize.gray('-'); } }, changes: { caption: colorize.underline('Changes'), getValue: async (dependency, _options, detailsCache) => { detailsCache.packageJSON = (detailsCache.packageJSON || getDependencyPackageJSON(dependency.location)); return ( await getPackageRepository(detailsCache.packageJSON, true) || getPackageHomepage(detailsCache.packageJSON) || getNpmJSLink(dependency.name) || colorize.gray('-') ); } }, changesPreferLocal: { caption: colorize.underline('Changes'), getValue: async (dependency, _options, detailsCache) => { const changelogFile = getChangelogPath(dependency.location); if (changelogFile) { return changelogFile; } detailsCache.packageJSON = (detailsCache.packageJSON || getDependencyPackageJSON(dependency.location)); return ( await getPackageRepository(detailsCache.packageJSON, true) || getPackageHomepage(detailsCache.packageJSON) || getNpmJSLink(dependency.name) || colorize.gray('-') ); } }, homepage: { caption: colorize.underline('Homepage'), getValue: async (dependency, _options, detailsCache) => { detailsCache.packageJSON = (detailsCache.packageJSON || getDependencyPackageJSON(dependency.location)); return ( dependency.homepage || getPackageHomepage(detailsCache.packageJSON) || await getPackageRepository(detailsCache.packageJSON) || getPackageAuthor(detailsCache.packageJSON) || getNpmJSLink(dependency.name) || colorize.gray('-') ); } }, npmjs: { caption: colorize.underline('npmjs.com'), getValue: async (dependency) => (getNpmJSLink(dependency.name) || colorize.gray('-')) } }; /** * @deprecated `name` has been replaced by `package` in version 2.8.0. */ // @ts-expect-error -- That's the easiest way the clone the `package` property values AVAILABLE_COLUMNS.name = AVAILABLE_COLUMNS.package; /** @type {AvailableArguments} */ const AVAILABLE_ARGUMENTS = { '--help': () => help(), '-h': () => help(), '--ignore-pre-releases': { ignorePreReleases: true }, '--ignore-dev-dependencies': { ignoreDevDependencies: true }, '--ignore-packages': (value) => { const ignorePackages = value.split(','); if (ignorePackages.length === 1 && (ignorePackages[0] === '' || ignorePackages[0].startsWith('-'))) { return help('Invalid value of --ignore-packages'); } return { ignorePackages }; }, '--prefer-wanted': { preferWanted: true }, '--columns': (value) => { const columns = value.split(','); const availableColumnsNames = Object.keys(AVAILABLE_COLUMNS); if (columns.length === 1 && (columns[0] === '' || columns[0].startsWith('-'))) { return help('Invalid value of --columns'); } const invalidColumn = columns.find((name) => !availableColumnsNames.includes(name)); if (invalidColumn) { return help(`Invalid column name "${invalidColumn}" in --columns\nAvailable columns are:\n${availableColumnsNames.join(', ')}`); } return { columns }; }, '--types': (value) => { const types = value.split(','); const availableTypesNames = ['major', 'minor', 'patch', 'prerelease', 'build', 'reverted']; if (types.length === 1 && (types[0] === '' || types[0].startsWith('-'))) { return help('Invalid value of --types'); } const invalidType = types.find((name) => !availableTypesNames.includes(name)); if (invalidType) { return help(`Invalid type name "${invalidType}" in --types\nAvailable types are:\n${availableTypesNames.join(', ')}`); } return { types }; }, '--global': { global: true }, '--depth': (value) => { const depth = Number.parseInt(value, 10); if (!Number.isFinite(depth)) { return help('Invalid value of --depth'); } return { depth }; } }; if (require.main === /** @type {NodeModule} */(/** @type {any} */(module))) { process.title = pkg.name; const argv = process.argv.slice(2); void (async () => { process.exitCode = await checkOutdated(argv); })(); } else { module.exports = checkOutdated; } /** * The main functionality of the tool. * * @public * @param {string[]} argv - Arguments given in the command line (`process.argv.slice(2)`). * @returns {Promise<number>} A number which shall used as process exit code. */ async function checkOutdated (argv) { /** @type {Options | string} */ let args; try { args = /** @type {Options | string} */(parseArguments(argv, AVAILABLE_ARGUMENTS)); } catch (error) { if (error instanceof Error) { args = help(error.message); } else { args = help(); } } if (typeof args === 'string') { process.stdout.write(args); return 1; } try { const outdatedDependencies = Object.values(await getOutdatedDependencies(args)); const filteredDependencies = getFilteredDependencies(outdatedDependencies, args); if (filteredDependencies.length === 0) { process.stdout.write('All dependencies are up-to-date.\n'); return 0; } if (filteredDependencies.length === 1) { process.stdout.write('1 outdated dependency found:\n\n'); } else { process.stdout.write(`${filteredDependencies.length} outdated dependencies found:\n\n`); } const visibleColumns = ((args.columns === undefined || args.columns.length === 0) ? DEFAULT_COLUMNS : args.columns); await writeOutdatedDependenciesToStdout(visibleColumns, filteredDependencies, args); writeUnnecessaryIgnoredPackagesToStdout(filteredDependencies, args); } catch (error) { if (typeof error === 'object' && error !== null) { // Required for TypeScript to ensure the type is an `object` instead of `unknown`. /** @type {Record<string, any>} */ const errorObject = error; const out = generateKeyValueList(Object.getOwnPropertyNames(errorObject).map((property) => [colorize.magenta(property), errorObject[property]])); process.stdout.write(`${colorize.red('Error while gathering outdated dependencies:')}\n\n${out}\n`); } else { process.stdout.write(`${colorize.red('Unknown error while gathering outdated dependencies.')}\n`); } } return 1; } /** * Returns the help text of the CLI tool. * * @private * @param {string[]} additionalLines - Additional text (error messages etc.) which shall be shown after the help. * @returns {string} Multiline text containing the whole help text. */ function help (...additionalLines) { return [ `${pkg.name} v${pkg.version} - ${pkg.description}`, 'Usage: ', [ '[--ignore-pre-releases]', '[--ignore-dev-dependencies]', '[--ignore-packages <comma-separated-list-of-package-names>]', '[--prefer-wanted]', '[--columns <comma-separated-list-of-columns>]', '[--types <comma-separated-list-of-update-types>]', '[--global]', '[--depth <number>]' ].join(' '), '', 'Arguments:', prettifyTable([ [ '--help, -h', 'Show this help.' ], [ '--ignore-pre-releases', 'Don\'t recommend to update to versions which contain a hyphen (e.g. "2.1.0-alpha", "2.1.0-beta", "2.1.0-rc.1").' ], [ '--ignore-dev-dependencies', 'Do not warn if devDependencies are outdated.' ], [ '--ignore-packages <comma-separated-list-of-package-names>', 'Ignore the listed packages, even if they are outdated.' ], [ '--prefer-wanted', 'Compare the "Current" version to the "Wanted" version, instead of the "Latest" version.' ], [ '--columns <comma-separated-list-of-columns>', 'Defines which columns should be shown in which order.' ], [ // Follow-up line for '--columns' description '', `Possible values: ${Object.keys(AVAILABLE_COLUMNS).join(',')}` ], [ '--types <comma-separated-list-of-update-types>', 'Restrict the update type (e.g. only show minor updates, or reverted versions)' ], [ // Follow-up line for '--types' description '', 'Possible values: major,minor,patch,prerelease,build,reverted' ], [ '--global', 'Check packages in the global install prefix instead of in the current project (equal to the npm outdated-option).' ], [ '--depth <number>', 'Max depth for checking dependency tree (equal to the npm outdated-option).' ] ]), ...(Array.isArray(additionalLines) ? [''].concat(additionalLines) : []), '' ].join('\n'); } /** * Filters dependencies by the given filter `options`. * * @private * @param {Dependencies} dependencies - Array of dependency objects which shall be filtered. * @param {Options} options - Options to configure the filtering. * @returns {Dependencies} Array with of the filtered dependency objects. */ function getFilteredDependencies (dependencies, options) { let filteredDependencies = dependencies.filter((dependency) => { if (['git', 'linked', 'remote'].includes(getWantedOrLatest(dependency, options))) { return false; } // Ignore this dependency if package.json specifies "*" as the version, meaning any version is acceptable if (dependency.type) { try { const packageJSONContent = readFile(getParentPackageJSONPath(dependency.location)); if (packageJSONContent) { const versionString = JSON.parse(packageJSONContent)[dependency.type][dependency.name]; if (versionString === '*') { return false; } } } catch { /* Do nothing */ } } return true; }); if (options.ignorePackages) { const ignorePackages = options.ignorePackages; const packageVersionRegExp = /^(.+?)@(.*)$/u; filteredDependencies = filteredDependencies.filter((dependency) => { for (const ignoredPackage of ignorePackages) { const match = packageVersionRegExp.exec(ignoredPackage); if (match === null) { if (ignoredPackage === dependency.name) { return false; } } else { if (match[1] === dependency.name) { if (semverInRange(getWantedOrLatest(dependency, options), match[2])) { return false; } } } } return true; }); } if (options.ignoreDevDependencies) { filteredDependencies = filteredDependencies.filter(({ type }) => ( type !== 'devDependencies' )); } if (options.ignorePreReleases) { filteredDependencies = filteredDependencies.filter((dependency) => ( !dependency.current.includes('-') && !getWantedOrLatest(dependency, options).includes('-') )); } if (options.preferWanted) { filteredDependencies = filteredDependencies.filter(({ current, wanted }) => current !== wanted); } if (options.types) { filteredDependencies = filteredDependencies.filter(({ current, latest }) => (options.types && options.types.includes(semverDiffType(current, latest) || ''))); } return filteredDependencies; } /** * Depending on the `preferWanted` option, either the `wanted` or the `latest` property of a dependency is returned. * * @param {OutdatedDependency} dependency - A specific outdated dependency. * @param {Options} options - The arguments which the user provided. * @returns {string} Either `wanted` or `latest` */ function getWantedOrLatest (dependency, options) { if (options.preferWanted) { return dependency.wanted; } return dependency.latest; } /** * Show the version information of outdated dependencies in a styled way on the terminal (stdout). * * @private * @param {string[]} visibleColumns - The columns which should be shown in the given order. * @param {Dependencies} dependencies - Array of dependency objects, which shall be formatted and shown in the terminal. * @param {Options} options - The arguments which the user provided. * @returns {Promise<void>} */ async function writeOutdatedDependenciesToStdout (visibleColumns, dependencies, options) { /** @type {(string | (string | TableColumn)[] | Promise<string | (string | TableColumn)[]>)[]} */ const table = [ visibleColumns.map((columnName) => AVAILABLE_COLUMNS[columnName].caption) ]; const groupByPackageType = !visibleColumns.includes('packageType'); /** @type {undefined | string} */ let previousPackageTypeGroup; dependencies.sort((groupByPackageType ? compareByType : compareByName)); for (const dependency of dependencies) { if (groupByPackageType && previousPackageTypeGroup !== dependency.type) { table.push(`\n${colorize.underline(dependency.type || colorize.gray('unknown'))}`); previousPackageTypeGroup = dependency.type; } table.push((async () => { /** @type {DependencyDetailsCache} */ const dependencyDetailsCache = {}; return Promise.all(visibleColumns.map(async (columnName) => AVAILABLE_COLUMNS[columnName].getValue(dependency, options, dependencyDetailsCache))); })()); } process.stdout.write([ // eslint-disable-next-line @typescript-eslint/await-thenable -- `table` can be either synchronous or asynchronous. prettifyTable(await Promise.all(table)), '', colorize.underline('Color legend'), `${colorize.yellow('Major update')}: backward-incompatible updates`, `${colorize.cyan('Minor update')}: backward-compatible features`, `${colorize.green('Patch update')}: backward-compatible bug fixes`, `${colorize.red('Reverted')}: latest available version is lower than the installed version`, '', '' ].join('\n')); } /** * Show information about packages which are ignored by `--ignore-packages` with version number, but where the `latest` version differs. * * Example: * Current "module" version: 2.0.0 * Latest "module" version: 2.0.2 * --ignore-packages module@^1 * --ignore-packages module@2.0.1 * In these cases, the ignore-statements have no effect, because version ^1 and 2.0.1 are outdated. That means, the ignore-statement can be removed. * * @private * @param {Dependencies} filteredDependencies - Array of dependency objects, which will be shown in the terminal. * @param {Options} options - The arguments which the user provided. * @returns {void} */ function writeUnnecessaryIgnoredPackagesToStdout (filteredDependencies, options) { const packageVersionRegExp = /^(.+?)@(.*)$/u; if (!options.ignorePackages) { return; } for (const ignoredPackage of options.ignorePackages) { const match = packageVersionRegExp.exec(ignoredPackage); if (match !== null) { const dependency = filteredDependencies.find(({ name }) => name === match[1]); if (dependency) { process.stdout.write(`The --ignore-packages filter "${ignoredPackage}" has no effect, the latest version is ${dependency.latest}.\n\n`); } } } }