upgradeps
Version:
Audit and upgrade all dependencies in package.json.
301 lines (284 loc) • 13.1 kB
JavaScript
/**
* Copyright (c) 2023, Luciano Ropero <lropero@gmail.com>
*
* 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 THE AUTHOR DISCLAIMS ALL WARRANTIES
* WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
* MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR 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.
*/
import chalk from 'chalk'
import chalkAnimation from 'chalk-animation'
import detectIndent from 'detect-indent'
import figures from 'figures'
import latestSemver from 'latest-semver'
import pacote from 'pacote'
import semverDiff from 'semver-diff'
import TimeAgo from 'javascript-time-ago'
import en from 'javascript-time-ago/locale/en'
import { basename as pathBasename, resolve as pathResolve } from 'path'
import { execSync } from 'child_process'
import { existsSync, readFileSync, unlinkSync, writeFileSync } from 'fs'
import { program } from 'commander'
const VERSION = '2.1.1'
TimeAgo.addDefaultLocale(en)
const getColor = differenceType => {
switch (differenceType) {
case 'latest':
return 'green'
case 'major':
return 'red'
case 'minor':
return 'yellow'
case 'patch':
return 'blue'
default:
return 'magenta'
}
}
const getConfig = () => {
const configPath = pathResolve(process.cwd(), '.upgradeps.json')
if (existsSync(configPath)) {
try {
const configContents = readFileSync(configPath, 'utf8')
return JSON.parse(configContents)
} catch (error) {
console.log(` ${chalk.yellow(figures.warning)} ${chalk.blue('error parsing .upgradeps.json, using defaults')}`)
return {}
}
}
return {}
}
const getDependencies = dependencies => {
return dependencies
? ` ${chalk.cyan(`${dependencies.amount} dependenc${dependencies.amount > 1 ? 'ies' : 'y'}`)}${
dependencies.audit
? ` ${Object.keys(dependencies.audit)
.map(differenceType => getDifferenceType({ amount: dependencies.audit[differenceType], differenceType }))
.join(chalk.gray(', '))}`
: ''
}`
: ''
}
const getDetails = ({ currentVersion, differenceType, version }) => {
const background = getColor(differenceType)
return `${differenceType === 'latest' ? currentVersion : `${chalk[`bg${`${background.charAt(0).toUpperCase()}${background.slice(1)}`}`](currentVersion)} ${chalk.yellow(figures.arrowRight)} ${version}`} ${getDifferenceType({ differenceType })}`
}
const getDifferenceType = ({ amount = 0, differenceType }) => {
return chalk[getColor(differenceType)](`${amount > 0 ? `${amount} ` : ''}${differenceType}`)
}
const getFigure = differenceType => {
return chalk[getColor(differenceType)](figures[differenceType === 'latest' ? 'tick' : 'cross'])
}
const getPackageInfo = () => {
const path = pathResolve(process.cwd(), 'package.json')
const contents = readFileSync(path, 'utf8')
const indent = detectIndent(contents)
const json = JSON.parse(contents)
const { bundledDependencies = {}, dependencies = {}, devDependencies = {}, optionalDependencies = {}, peerDependencies = {} } = json
const current = { bundledDependencies, dependencies, devDependencies, optionalDependencies, peerDependencies }
return { current, indent, json, path }
}
const normalizeSemver = version => {
const normalized = version.replace('.x', '.0').replace(/[\^~]/, '').trim()
const parts = normalized.split('.')
while (parts.length < 3) {
parts.push('0')
}
return parts.join('.')
}
const print = ({ options, versions }) =>
new Promise(resolve => {
const stats = { amount: 0 }
const timeAgo = new TimeAgo()
Object.keys(versions).forEach(pckg => {
const { currentVersion, dependencies, differenceType = 'latest', latest, time } = versions[pckg]
if (!stats.audit) {
stats.audit = {}
}
if (differenceType !== 'latest' || options.verbose) {
if (!stats.audit[differenceType]) {
stats.audit[differenceType] = 0
}
stats.amount++
stats.audit[differenceType]++
const ago = time ? `last publish ${timeAgo.format(new Date(time))}` : ''
const color = ago.includes('2 years') ? 'bgMagenta' : ago.includes('years') ? 'bgRed' : ago.includes('year') ? 'bgYellow' : 'gray'
console.log(` ${getFigure(differenceType)} ${chalk.cyan(pckg)} ${getDetails({ currentVersion, differenceType, version: latest })}${getDependencies(dependencies)}${ago.length > 0 ? ` ${chalk[color](ago)}` : ''}`)
}
})
const emojis = ['ヽ(´▽`)ノ', 'ԅ(≖‿≖ԅ)', 'ᕕ( ᐛ )ᕗ']
const animation = chalkAnimation[stats.amount === 0 ? 'rainbow' : 'neon'](stats.amount === 0 ? ` ${figures.tick} no updates ${emojis[Math.floor(Math.random() * emojis.length)]}` : ` ${getDependencies(stats)}`, 1.2)
setTimeout(() => {
animation.stop()
return resolve()
}, 1500)
})
const queryVersions = async ({ current, options }) => {
const dependencies = Object.keys(current)
.filter(group => options.groups.includes(group))
.reduce((dependencies, group) => ({ ...dependencies, ...current[group] }), {})
const responses = await Promise.all(
Object.keys(dependencies).map(async pckg => {
try {
let details = {}
const currentVersion = normalizeSemver(dependencies[pckg])
const packument = await pacote.packument(pckg, { fullMetadata: true, ...(options.registry.length > 0 && { registry: options.registry }) })
const innerDependencies = packument.versions[currentVersion]?.dependencies || {}
const keys = Object.keys(innerDependencies)
if (keys.length > 0) {
const counter = { build: 0, major: 0, minor: 0, patch: 0, premajor: 0, preminor: 0, prepatch: 0, prerelease: 0 }
await Promise.all(
keys.map(async pckg => {
try {
const manifest = options.registry.length ? await pacote.manifest(pckg, { registry: options.registry }) : await pacote.manifest(pckg)
const differenceType = semverDiff(normalizeSemver(innerDependencies[pckg]), manifest.version)
if (differenceType) {
counter[differenceType]++
}
} catch (error) {
// Skip packages with invalid semver ranges
}
})
)
const audit = Object.keys(counter)
.filter(differenceType => counter[differenceType] > 0)
.reduce((dependencies, differenceType) => ({ ...dependencies, [differenceType]: counter[differenceType] }), {})
details = {
dependencies: {
amount: keys.length,
...(Object.keys(audit).length > 0 && { audit })
}
}
}
try {
const differenceType = semverDiff(currentVersion, packument['dist-tags'].latest)
details = {
currentVersion,
...details,
...(differenceType && { differenceType }),
latest: packument['dist-tags'].latest,
time: packument.time[packument['dist-tags'].latest]
}
let result = { [pckg]: false }
if (options.minor) {
const patch = latestSemver(
Object.keys(packument.versions).filter(version => {
const differenceType = semverDiff(currentVersion, version)
return differenceType && ['minor', 'patch'].includes(differenceType)
})
)
if (patch) {
details.differenceType = semverDiff(currentVersion, patch)
details.latest = patch
result = { [pckg]: details }
} else if (!differenceType && options.verbose) {
result = { [pckg]: details }
}
} else {
result = { [pckg]: details }
}
return result
} catch (error) {
return { [pckg]: false }
}
} catch (error) {
if (error.statusCode === 404) {
return { [pckg]: false }
}
throw error
}
})
)
return responses.reduce((versions, response) => (Object.values(response)[0] ? { ...versions, ...response } : versions), {})
}
const syncModules = options => {
const lock = options.yarn ? 'yarn.lock' : 'package-lock.json'
if (existsSync(pathResolve(process.cwd(), lock))) {
unlinkSync(lock)
}
if (existsSync(pathResolve(process.cwd(), 'node_modules'))) {
execSync(`${options.yarn ? 'yarn' : 'npm install'}${options.registry.length ? ` --registry ${options.registry}` : ''}`, { stdio: [] })
}
}
const upgrade = ({ info, options, versions }) => {
const { current: upgraded } = info
let hasChanges = false
Object.keys(upgraded)
.filter(group => options.groups.includes(group))
.forEach(group => {
for (const pckg of Object.keys(upgraded[group])) {
if (!options.skip.includes(pckg)) {
if (versions[pckg]?.differenceType) {
upgraded[group][pckg] = `^${versions[pckg].latest}`
hasChanges = true
}
if (options.fixed) {
upgraded[group][pckg] = upgraded[group][pckg].replace('^', '')
}
}
}
})
writePackage({ info, options, upgraded })
if (hasChanges) {
syncModules(options)
}
}
const writePackage = ({ info, options, upgraded }) => {
const { indent, json, path } = info
Object.keys(upgraded)
.filter(group => options.groups.includes(group))
.forEach(group => {
if (json[group]) {
json[group] = upgraded[group]
}
})
writeFileSync(path, JSON.stringify(json, null, indent.type === 'tab' ? '\t' : indent.amount) + '\n', 'utf8')
}
program
.version(VERSION)
.option('-f, --fixed', 'remove ^carets when upgrading (use with -u)')
.option('-g, --groups <groups>', 'specify dependency groups to process (defaults to all) -> e.g. "-g dependencies,devDependecies"')
.option('-m, --minor', 'process only minor and patch updates')
.option('-r, --registry <registry>', 'set custom npm registry URL')
.option('-s, --skip <packages>', 'packages to skip during upgrade -> e.g. "-s react,react-dom" (use with -u)')
.option('-u, --upgrade', 'automatically upgrade package.json')
.option('-v, --verbose', 'print information for latest dependencies as well')
.option('-y, --yarn', 'use yarn instead of npm (use with -u)')
.action(async options => {
console.log(`${chalk.magenta(figures.play)} ${chalk.green(`upgradeps v${VERSION}`)} ${chalk.magenta(`<${pathBasename(process.cwd())}>`)} ${chalk.gray(`${figures.line} run with -h to output usage information`)}`)
const config = Object.keys(options).length > 0 ? {} : getConfig()
if (Object.keys(config).length > 0) {
console.log(` ${chalk.blue(figures.bullet)} ${chalk.blue('using configuration from .upgradeps.json')}`)
}
options = {
fixed: options.fixed !== undefined ? !!options.fixed : config.fixed !== undefined ? !!config.fixed : false,
groups: options.groups ? options.groups.split(',').filter(group => ['bundledDependencies', 'dependencies', 'devDependencies', 'optionalDependencies', 'peerDependencies'].includes(group)) : config.groups && config.groups.length > 0 ? config.groups.filter(group => ['bundledDependencies', 'dependencies', 'devDependencies', 'optionalDependencies', 'peerDependencies'].includes(group)) : 'bundledDependencies,dependencies,devDependencies,optionalDependencies,peerDependencies'.split(','),
minor: options.minor !== undefined ? !!options.minor : config.minor !== undefined ? !!config.minor : false,
registry: options.registry !== undefined ? options.registry : config.registry || '',
skip: options.skip ? options.skip.split(',').filter(skip => skip.length > 0) : config.skip && config.skip.length > 0 ? config.skip : [],
upgrade: options.upgrade !== undefined ? !!options.upgrade : config.upgrade !== undefined ? !!config.upgrade : false,
verbose: options.verbose !== undefined ? !!options.verbose : config.verbose !== undefined ? !!config.verbose : false,
yarn: options.yarn !== undefined ? !!options.yarn : config.yarn !== undefined ? !!config.yarn : false
}
try {
const info = getPackageInfo()
const { current } = info
const versions = await queryVersions({ current, options })
await print({ options, versions })
if (options.upgrade) {
upgrade({ info, options, versions })
}
} catch (error) {
console.log(`${chalk.red(figures.cross)} ${error.toString()}`)
process.exit(1)
}
})
.parse(process.argv)